SLOPSHOPPER

claudetto

Adds a lean output style, a main agent with a roster of six agents, a permission gate that answers each permission prompt, a status line command with the plan…

bandrowsguardpromptprocessnetwork
★ 3v0.3.0-alpha.2MITupdated 2026-10-23xsyetopz/claudetto/plugins/claudetto
A shopper browsing a rack in a slop shop
README

claudetto

claudetto changes Claude Code through its official extension points: settings, hooks, skills, and an output style. It does not patch the Claude Code binary. Its goal is the highest quality for the lowest token cost.

The wiki explains each part and the reason for it. Start with the Home page, or read the Install page for the full setup. The docs of Claude Code itself are on the Claude Code docs site, with an optional MCP server and a skill (bunx skills add xsyetopz/claude-code-docs-skills).

Plugins

claudetto is a marketplace with one core plugin and two add-on plugins. Each add-on works without the core plugin.

PluginWhat it gives
claudettoThe core: the settings snippet, the status line, the output style, the setup skill, the /handoff command, the guard and ground hooks, the tailored prompt, and the Jev pick of AskUserQuestion.
claudetto-browserThe browse skill, which drives a real browser with agent-browser.
claudetto-docsThe find-docs skill, with current library docs from the CLI ctx7.

Install

# --- CLI -----------------------------
claude plugin marketplace add xsyetopz/claudetto
claude plugin install claudetto@claudetto

# --- TUI -----------------------------
/plugin marketplace add xsyetopz/claudetto
/plugin install claudetto@claudetto

Then run /claudetto:setup, and restart Claude Code. The setup skill shows each change to your settings file, and it writes the change after you confirm.

Requirements:

  • Claude Code 2.1.309 or later. claudetto is tested only on 2.1.309.
  • git.
  • Bun 1.4.3 or later on PATH, for the status line, the setup command, and the Jev pick of AskUserQuestion.
  • Node.js on PATH, for the npm installs of the add-ons.

A plugin cannot set permissions, environment variables, or models. So the core plugin gives these settings as a settings snippet. The setup skill applies the snippet for you. You can also copy the keys that you want into your own settings.json.

What the Core Plugin Changes

PartEffect
CompactionThe handoff slice hands over at three triggers: a handoff file, /clear, and a resume. The compaction of Claude Code stays as the safety net. autoCompactWindow is 300000. The compaction option built-in starts no handover.
MemoryautoMemoryEnabled is false.
PromptCLAUDE_CODE_SIMPLE_SYSTEM_PROMPT is 1, and the prompt slice gives one tailored text for the short prompt.
Output styleoutputStyle is claudetto:Concise. Your own outputStyle setting can change it.
PermissionsReads of secret files and destructive commands such as rm -rf / are denied. A force push asks.
SandboxBash runs in the sandbox. Its network reaches GitHub and the package registries only.
GuardAsks before a git push that deletes a remote branch, and before a docker command that runs or creates a container.
GroundDenies a Write or Edit of a plan file that cites a file:line or a convention that the source does not hold.
Status lineThe folder, git, model, effort, context against the window, cost, and the limits. The percent turns yellow at 75% and red at 90%.
Handoff/handoff writes the handoff, clears the chat, and starts the next session from it.

Model and Skills

The main session uses claude-opus-5-5, from the settings snippet. CLAUDE_CODE_SUBAGENT_MODEL is claude-sonnet-5-5.

The core plugin has the skill /claudetto:setup, and the command /handoff. Use openspec for specs: claudetto has no spec store.

Long Work without Compaction

Each model call reads the whole context again, so the size of the context sets the cost of each turn. Run /handoff to start a fresh session from a handoff. A model change rebuilds the prompt cache. An effort change keeps the prompt cache.

See Handoff.

Jev Pick

With the option decide set to jev, the core plugin asks TypeSafe Jev for a pick and a confidence on each AskUserQuestion question that facts settle. Export TYPESAFE_API_KEY before Claude Code starts. Each pick sends the question and the options to the TypeSafe API. When Jev gives no pick, the question stays as Claude asked it. See Decide.

Add-On Plugins

Install each add-on that you need.

# --- CLI -----------------------------
claude plugin install claudetto-browser@claudetto
claude plugin install claudetto-docs@claudetto

# --- TUI -----------------------------
/plugin install claudetto-browser@claudetto
/plugin install claudetto-docs@claudetto
  • claudetto-browser uses agent-browser. Install it with bun add -g agent-browser. See Browser.
  • claudetto-docs needs no key. See Library docs.

Update

claude plugin marketplace update claudetto
claude plugin update claudetto@claudetto

Run the claude plugin update line again for each add-on that you installed, with its name in place of claudetto. Then restart Claude Code, read the changelog, and run /claudetto:setup again. The status line commands hold the path of the plugin version, so each update needs the new setup run.

To try an unreleased checkout, run:

claude --plugin-dir /path/to/claudetto/plugins/claudetto

Development

Edit the source in src/, and run just generate. Do not edit plugins/ by hand. just check runs the lint, the type check, the check of the generated files, the tests, the validation, and the hook labs. just sandbox runs Claude Code with this checkout in a separate config. See Development and Sandbox.

License

MIT

Source 83 files
hooks/register.ts 165 lines
1import type { On, PluginOptions } from "claude-code";
2import {
3  onOffer as h17,
4  onAgentCall as h18,
5} from "../slices/agents/adapters/mod.ts";
6import {
7  keepOptions as h0,
8  onAskUserQuestion as h1,
9  onDescribe as h2,
10} from "../slices/decide/adapters/mod.ts";
11import {
12  onCommand as h3,
13  onStop as h4,
14  onBoxCall as h30,
15} from "../slices/finish/adapters/mod.ts";
16import { onPlanCall as h5 } from "../slices/ground/adapters/mod.ts";
17import {
18  onCheck as h6,
19  onCheckFail as h7,
20  keepOptions as h19,
21  onPermission as h23,
22  onMonitor as h27,
23  onStop as h28,
24  onTitle as h29,
25} from "../slices/guard/adapters/mod.ts";
26import {
27  keepOptions as h8,
28  onTurnComplete as h10,
29  onTurnStep as h11,
30  onPrompt as h25,
31  onStop as h26,
32  onResume as h43,
33} from "../slices/handoff/adapters/mod.ts";
34import {
35  onStart as h20,
36  onEdited as h21,
37  onPrompt as h22,
38  onGitView as h24,
39} from "../slices/hygiene/adapters/mod.ts";
40import {
41  onCheck as h31,
42  onFail as h32,
43  onPass as h33,
44  onTurnComplete as h34,
45  onBand as h35,
46  onMessage as h36,
47  onPrompt as h37,
48  onStop as h38,
49  onStep as h44,
50} from "../slices/notes/adapters/mod.ts";
51import {
52  keepOptions as h12,
53  onAttachment as h13,
54  onContext as h14,
55  onToolDescribe as h15,
56  onCompact as h39,
57  onMeasure as h40,
58  onPrompt as h41,
59  onBatch as h42,
60} from "../slices/prompt/adapters/mod.ts";
61
62export const register = (on: On, options: PluginOptions): void => {
63  h0(options);
64  h8(options);
65  h12(options);
66  h19(options);
67  // eval: agent.offer agents
68  on("agent.offer", h17);
69  // eval: classic.PostToolUse hygiene
70  on("classic.PostToolUse", { tool_name: ["Edit", "Write", "Bash"] }, h21);
71  // eval: classic.PermissionRequest guard
72  on("classic.PermissionRequest", h23);
73  // eval: classic.Stop finish
74  on("classic.Stop", h4);
75  // eval: classic.Stop handoff
76  on("classic.Stop", { stop_hook_active: false }, h26);
77  // eval: classic.Stop guard
78  on("classic.Stop", { stop_hook_active: [true, false] }, h28);
79  // eval: classic.Stop notes
80  on("classic.Stop", { hook_event_name: "Stop" }, h38);
81  // eval: classic.UserPromptSubmit hygiene
82  on("classic.UserPromptSubmit", h22);
83  // eval: command.run finish
84  on("command.run", { command: "goal" }, h3);
85  // eval: prompt.attachment prompt
86  on(
87    "prompt.attachment",
88    { type: ["goal_status", "tool_hosts_notice", "plan_mode"] },
89    h13,
90  );
91  // eval: prompt.context prompt
92  on("prompt.context", h14);
93  // eval: prompt.submit handoff
94  on("prompt.submit", h25);
95  // eval: session.compact prompt
96  on("session.compact", h39);
97  // eval: session.measure prompt
98  on("session.measure", h40);
99  // eval: classic.UserPromptSubmit prompt
100  on("classic.UserPromptSubmit", { hook_event_name: "UserPromptSubmit" }, h41);
101  // eval: classic.PostToolBatch prompt
102  on("classic.PostToolBatch", h42);
103  // eval: session.start hygiene
104  on("session.start", h20);
105  // eval: classic.SessionStart handoff
106  on("classic.SessionStart", { source: ["resume", "fork"] }, h43);
107  // eval: tool.call agents
108  on("tool.call", { tool: ["Agent"] }, h18);
109  // eval: tool.call decide
110  on("tool.call", { tool: ["AskUserQuestion"] }, h1);
111  // eval: tool.call finish
112  on("tool.call", { tool: ["Edit", "Write"] }, h30);
113  // eval: tool.call ground
114  on("tool.call", { tool: ["Write", "Edit"] }, h5);
115  // eval: tool.call guard monitor
116  on("tool.call", { tool: ["Monitor", "TaskStop"] }, h27);
117  // eval: tool.call guard title
118  on(
119    "tool.call",
120    { tool: ["Bash"], command: /\bgh\s+(?:pr|issue)\s+create\b/ },
121    h29,
122  );
123  // eval: tool.call hygiene
124  on("tool.call", { tool: ["Bash"] }, h24);
125  // eval: tool.check notes
126  on(
127    "tool.check",
128    { tool: ["Bash", "Edit", "Glob", "Grep", "Read", "WebFetch", "Write"] },
129    h31,
130  );
131  // eval: classic.PostToolUseFailure notes
132  on("classic.PostToolUseFailure", { tool_name: ["Bash"] }, h32);
133  // eval: classic.PostToolUse notes
134  on("classic.PostToolUse", { tool_name: ["Bash"] }, h33);
135  // eval: turn.complete notes
136  on("turn.complete", h34);
137  // eval: turn.start notes
138  on("turn.start", h37);
139  // eval: ui.render notes band
140  on("ui.render", { component: "AbovePrompt" }, h35);
141  // eval: ui.render notes message
142  on("ui.render", { component: "AssistantMessage" }, h36);
143  // eval: tool.check guard
144  on("tool.check", h6).catch(h7);
145  // eval: tool.describe decide
146  on("tool.describe", { tool: ["AskUserQuestion"] }, h2);
147  // eval: tool.describe prompt
148  on("tool.describe", { tool: DESCRIBED }, h15);
149  // eval: turn.complete handoff
150  on(
151    "turn.complete",
152    { reason: ["answer", "aborted", "refusal", "error"] },
153    h10,
154  );
155  // eval: turn.step handoff
156  on("turn.step", h11);
157  // eval: turn.step notes
158  on("turn.step", { turnId: /^/ }, h44);
159};
160
161const DESCRIBED =
162  "Agent Bash Edit EnterWorktree ExitWorktree LSP ListAgents ListMcpResourcesTool Read ReadMcpResourceDirTool ReadMcpResourceTool SendMessage Skill TodoWrite ToolSearch Workflow Write".split(
163    " ",
164  );
165
slices/agents/adapters/mod.ts 24 lines
1// The mod handlers of the `agents` slice (D2 of change `0-3-0`).
2// `agent.offer` hides each built-in agent and `claudetto:main`, so the model sees only the plugin agents that it can start.
3// `tool.call` gives an `Agent` call with no `subagent_type` the type `claudetto:Worker`, before the permission step treats it as `general-purpose` and refuses it (task 4.2).
4// Each handler calls `next` first with no step before it that can throw, so it has no catch-all (D49).
5
6import type { MatchedHook } from "claude-code";
7import type { Handler } from "../../../kernel/slice.ts";
8
9const DEFAULT_AGENT = "claudetto:Worker";
10const MAIN_AGENT = "claudetto:main";
11
12export const onOffer: Handler<"agent.offer"> = async (_$, e, next) =>
13  e.provider.plugin === "engine" || e.agent === MAIN_AGENT
14    ? { isOffered: false }
15    : next(e);
16
17type AgentCall = MatchedHook<
18  "tool.call",
19  { readonly tool: readonly ["Agent"] }
20>;
21
22export const onAgentCall: AgentCall = async (_$, e, next) =>
23  next(e.subagent_type ? e : { ...e, subagent_type: DEFAULT_AGENT });
24
slices/decide/adapters/mod.ts 69 lines
1// The mod handlers of the `decide` slice. With the option `off`, or with no key, they change nothing. Jev gets one request with `$.http.fetch`, and the key goes only in its header.
2// Each `$.env.get` call needs a literal name, so that `claude plugin validate` can follow it.
3
4import type { EngineInterface, MatchedHook, PluginOptions } from "claude-code";
5import { jevBody, postJev } from "../../../kernel/jev.ts";
6import { keyOf, type Mode, modeOf } from "../domain/request.ts";
7import { JEV_ANSWERED, JEV_CONTINUE, TOOL_ASK } from "../prompts.ts";
8import { judgeQuestions } from "./ask.ts";
9
10const kept: { mode: Mode; key: string } = { mode: "off", key: "" };
11
12/** Keeps the mode and the key from the `userConfig` values, which the generated `register` sets once. */
13export const keepOptions = (options: PluginOptions): void => {
14  kept.mode = modeOf(options);
15  kept.key = keyOf(options);
16};
17
18/** The key of the option, else the exported `TYPESAFE_API_KEY` of 0.3.0-alpha.1. */
19const jevKey = async ($: EngineInterface): Promise<string | undefined> =>
20  kept.key || (await $.env.get("TYPESAFE_API_KEY"));
21
22type DescribeHook = MatchedHook<
23  "tool.describe",
24  { readonly tool: readonly ["AskUserQuestion"] }
25>;
26
27type AskHook = MatchedHook<
28  "tool.call",
29  { readonly tool: readonly ["AskUserQuestion"] }
30>;
31
32/** Answers the call with the picks of Jev when it is sure of each question, else adds the picks to the dialog. */
33export const onAskUserQuestion: AskHook = async ($, e, next) => {
34  if (kept.mode !== "jev") return next(e);
35  const key = await jevKey($);
36  if (!key) return next(e);
37  const outcome = await judgeQuestions(e.questions, (request) =>
38    postJev(
39      key,
40      jevBody(request.state, request.questions),
41      { fetch: (url, init) => $.http.fetch(url, init) },
42      { sleep: (ms, options) => $.clock.sleep(ms, options) },
43    ),
44  );
45  if (outcome === undefined) return next(e);
46  if ("questions" in outcome)
47    return next({ ...e, questions: outcome.questions });
48  const { answered } = outcome;
49  // The `tool.call` handler answers an `AskUserQuestion` call with the picks of Jev when Jev is sure about each question (documented: a `tool.call` hook answers with `{ result }`).
50  // The answers are keyed by the question text, as when the user picks. The context names Jev.
51  const told = answered.map((p) =>
52    JEV_ANSWERED({ ...p, confidence: p.confidence.toFixed(2) }),
53  );
54  return {
55    result: {
56      questions: e.questions,
57      answers: Object.fromEntries(answered.map((p) => [p.question, p.choice])),
58    },
59    context: [[...told, JEV_CONTINUE].join("\n")],
60  };
61};
62
63/** Adds the line for Jev to the tool text, only when Jev can answer. */
64export const onDescribe: DescribeHook = async ($, e, next) => {
65  const answer = await next(e);
66  if (kept.mode !== "jev" || !(await jevKey($))) return answer;
67  return { ...answer, description: `${answer.description}\n\n${TOOL_ASK}` };
68};
69
slices/finish/adapters/mod.ts 282 lines
1// The mod handlers of the `finish` slice, with no model call:
2// `/goal` sets one check command, and `Stop` blocks the stop until the check exits `0`, up to `GOAL_BLOCKS_MAX` times in a row.
3// `Stop` also blocks after a ticked box of a `tasks.md` with an open box left, in a `/claudetto:apply` session with no agent in flight.
4// `Stop` sets a note for `notes` when the reply defers work, says "done" with no proof, or has a heading in the wrong case, and never blocks for a note.
5// The `box` hook denies a task box that changes to checked with no passed check in the session.
6// Each path starts at the project root, because `$.fs` resolves a relative path against the folder of the shell.
7
8import type { EngineInterface, MatchedHook } from "claude-code";
9import { passedChecks, proven } from "../../../kernel/checks.ts";
10import type { Note } from "../../../kernel/notes.ts";
11import { GOAL_FILE } from "../../../kernel/paths.ts";
12import type { Handler } from "../../../kernel/slice.ts";
13import { costReport, sharesOf } from "../../../kernel/usage.ts";
14import {
15  agentBusy,
16  changedPaths,
17  editedPaths,
18  ranApply,
19  tickedFiles,
20  turnRows,
21  uncommitted,
22  unprovenTicks,
23  unprovenWhat,
24} from "../domain/record.ts";
25import { claimsDone, deferredItem, wrongHeading } from "../domain/reply.ts";
26import {
27  checkOf,
28  type Goal,
29  isSlashCommand,
30  parseGoal,
31} from "../domain/state.ts";
32import { nextOpen, ticked } from "../domain/tasks.ts";
33import {
34  GIT_TIMEOUT_MS,
35  GOAL_BLOCKS_MAX,
36  LOOP_TASK_CHARS,
37  NOTE_FILES_MAX,
38  OUTPUT_TAIL_CHARS,
39  RUN_TIMEOUT_MS,
40} from "../limits.ts";
41import {
42  BOX_DENY,
43  DEFERRED_NOTE,
44  GOAL_CAP,
45  HEADING_NOTE,
46  LOOP,
47  LOOP_CAP,
48  NOT_A_CHECK,
49  RETRY,
50  START,
51  UNPROVEN_NOTE,
52} from "../prompts.ts";
53
54/** The project root: `CLAUDE_PROJECT_DIR`, or the root of the session, or `""` with no root. */
55const rootOf = async ($: EngineInterface): Promise<string> =>
56  (await $.env.get("CLAUDE_PROJECT_DIR").catch(() => undefined)) ||
57  (await $.session.root().catch(() => ""));
58
59const fileOf = async ($: EngineInterface) => {
60  const root = await rootOf($);
61  return root ? `${root.replace(/\/+$/, "")}/${GOAL_FILE}` : GOAL_FILE;
62};
63
64const readGoal = async ($: EngineInterface): Promise<Goal | null> =>
65  $.fs.read(await fileOf($)).then(parseGoal, () => null);
66
67const writeGoal = async ($: EngineInterface, goal: Goal | null) =>
68  $.fs.write(await fileOf($), JSON.stringify(goal));
69
70const inRoot = async (
71  $: EngineInterface,
72  argv: string[],
73  timeoutMs: number,
74) => {
75  const root = await rootOf($);
76  return $.process.run(argv, { ...(root ? { cwd: root } : {}), timeoutMs });
77};
78
79/** Runs `command` with `sh -c` in the project root: `pass` on exit 0, and the last `OUTPUT_TAIL_CHARS` characters of the output. */
80export const runCheck = async (
81  $: EngineInterface,
82  command: string,
83): Promise<{ pass: boolean; output: string }> => {
84  const run = await inRoot($, ["sh", "-c", command], RUN_TIMEOUT_MS).catch(
85    (error: Error) => ({ exitCode: 1, stdout: "", stderr: error.message }),
86  );
87  const output = (run.stdout + run.stderr).trim().slice(-OUTPUT_TAIL_CHARS);
88  return { pass: run.exitCode === 0, output };
89};
90
91// The re-blocks in a row live in the `$.state` (X21), the loop count apart from the `/goal` count, and the note goes to `notes`.
92const blocksRef = { plugin: "claudetto", key: "goalBlocks" } as const;
93const loopRef = { plugin: "claudetto", key: "loopBlocks" } as const;
94const noteRef = { plugin: "claudetto", key: "note" } as const;
95
96const setBlocks = ($: EngineInterface, count: number) =>
97  $.state.set(blocksRef, count);
98
99/** The next open task of the first `tasks.md` that the turn ticked and that has an open box, or `null` (also with no `/claudetto:apply`, with an agent in flight, or on a fault). */
100const openTask = async (
101  $: EngineInterface,
102): Promise<{ id: string; task: string } | null> => {
103  try {
104    const messages = await $.session.messages();
105    if (!ranApply(messages)) return null;
106    const rows = turnRows(messages);
107    const files = tickedFiles(rows);
108    if (files.length === 0 || agentBusy(await $.agent.list())) return null;
109    for (const file of files) {
110      const open = nextOpen(await $.fs.read(file));
111      if (open !== null) return open;
112    }
113  } catch {
114    // A loop check is a help, so a fault of it adds no block.
115  }
116  return null;
117};
118
119/** The note for the last reply, or `null` when the reply shows no sign of left or unproven work. */
120const noteOf = async (
121  $: EngineInterface,
122  reply: string,
123): Promise<Note | null> => {
124  const item = deferredItem(reply);
125  if (item !== null) return DEFERRED_NOTE({ item });
126  const heading = wrongHeading(reply);
127  if (!claimsDone(reply)) return heading && HEADING_NOTE(heading);
128  const messages = await $.session.messages();
129  const root = await rootOf($);
130  const status = await inRoot(
131    $,
132    ["git", "status", "--porcelain", "-z"],
133    GIT_TIMEOUT_MS,
134  ).catch(() => ({ exitCode: 1, stdout: "" }));
135  const files = uncommitted(
136    editedPaths(messages),
137    status.exitCode === 0 ? changedPaths(status.stdout) : [],
138  )
139    .map((f) =>
140      root && f.startsWith(`${root}/`) ? f.slice(root.length + 1) : f,
141    )
142    .slice(0, NOTE_FILES_MAX);
143  const boxes = unprovenTicks(messages, passedChecks(messages));
144  return files.length === 0 && boxes.length === 0
145    ? heading && HEADING_NOTE(heading)
146    : UNPROVEN_NOTE({ what: unprovenWhat(files, boxes) });
147};
148
149/** Sets the note for `reply`. A fault here never changes the stop. */
150const raiseNote = async ($: EngineInterface, reply: string): Promise<void> => {
151  try {
152    const note = await noteOf($, reply);
153    if (note !== null) await $.state.set(noteRef, note);
154  } catch {
155    // A note is a help for the user, so a fault of it adds no block.
156  }
157};
158
159/** `classic.Stop`: a goal check that fails blocks the stop with its output, a turn that ticked a box with an open task left blocks the same way (the goal first), each at most `GOAL_BLOCKS_MAX` times in a row. A stop that nothing blocks sets a note. */
160export const onStop: Handler<"classic.Stop"> = async ($, e, next) => {
161  const goal = await readGoal($);
162  if (goal) {
163    const { pass, output } = await runCheck($, goal.check);
164    if (pass) {
165      await writeGoal($, null);
166      await setBlocks($, 0);
167    } else {
168      const count = (await $.state.get(blocksRef)).value ?? 0;
169      if (count < GOAL_BLOCKS_MAX) {
170        await setBlocks($, count + 1);
171        return {
172          ...(await next(e)),
173          block: RETRY({ check: goal.check, output }),
174        };
175      }
176      await setBlocks($, 0);
177      $.ui.log(GOAL_CAP({ check: goal.check, count }));
178    }
179  }
180  const open = await openTask($);
181  const loops = (await $.state.get(loopRef)).value ?? 0;
182  if (open !== null && loops < GOAL_BLOCKS_MAX) {
183    await $.state.set(loopRef, loops + 1);
184    return {
185      ...(await next(e)),
186      block: LOOP({
187        id: open.id,
188        task: open.task.slice(0, LOOP_TASK_CHARS),
189      }),
190    };
191  }
192  if (open !== null) $.ui.log(LOOP_CAP({ count: loops }));
193  if (loops > 0) await $.state.set(loopRef, 0);
194  await raiseNote($, e.last_assistant_message ?? "");
195  return next(e);
196};
197
198/** The usage value that `prompt` keeps from `session.measure`, which holds the first share of each window. */
199const usageRef = { plugin: "claudetto", key: "usage" } as const;
200
201/** The cost report for the model after a tick, or `undefined` with no figure or a fault of the read. */
202const costOf = async ($: EngineInterface): Promise<string | undefined> => {
203  try {
204    const [usage, { value }] = await Promise.all([
205      $.session.usage(),
206      $.state.get(usageRef),
207    ]);
208    const report = costReport(
209      usage.cost?.usd,
210      sharesOf(usage.rateLimits),
211      value?.start ?? {},
212    );
213    return report === "none" ? undefined : report;
214  } catch {
215    return undefined;
216  }
217};
218
219type BoxHook = MatchedHook<
220  "tool.call",
221  { readonly tool: readonly ["Edit", "Write"] }
222>;
223
224/** `tool.call` on `Edit` and `Write` of a `tasks.md`: a box that changes to checked needs a passed check in the transcript of the caller. A tick that goes through adds the cost report as context, so the final report of `apply` has its figures. */
225export const onBoxCall: BoxHook = async ($, e, next) => {
226  if (!e.file_path.endsWith("tasks.md")) return next(e);
227  const before = (await $.fs.exists(e.file_path))
228    ? await $.fs.read(e.file_path)
229    : "";
230  const after =
231    "content" in e
232      ? e.content
233      : e.replace_all
234        ? before.replaceAll(e.old_string, e.new_string)
235        : before.replace(e.old_string, () => e.new_string);
236  const opened = ticked(before, after);
237  if (opened.length === 0) return next(e);
238  const found =
239    e.agentId === undefined
240      ? await $.session.messages()
241      : await $.session.messages({ agentId: e.agentId });
242  // A subagent that the session cannot read has no record to check against, so the call runs.
243  if (!Array.isArray(found)) return next(e);
244  const passed = passedChecks(found);
245  const missing = opened.filter(({ line }) => !proven(line, passed));
246  if (missing.length > 0)
247    return { deny: BOX_DENY({ ids: missing.map(({ id }) => id).join(", ") }) };
248  const answer = await next(e);
249  if (answer.deny !== undefined) return answer;
250  const report = await costOf($);
251  return report === undefined
252    ? answer
253    : { ...answer, context: [...(answer.context ?? []), report] };
254};
255
256const answer: Handler<"command.run"> = async ($, e) => {
257  const text = e.args.trim();
258  const goal = await readGoal($);
259  if (!text || text === "status") {
260    return { text: goal ? `Goal: \`${goal.check}\` must exit 0.` : "No goal." };
261  }
262  if (text === "clear") {
263    await writeGoal($, null);
264    return { text: "Goal cleared." };
265  }
266  const check = checkOf(text);
267  if (isSlashCommand(check)) return { text: NOT_A_CHECK({ check }) };
268  await writeGoal($, { check });
269  // Claude Code refuses `$.prompt.submit` in a `command.run` hook (see the header of `slices/handoff/adapters/mod.ts`), so a timer runs it after the hook.
270  $.clock.after(
271    0,
272    () => void $.prompt.submit({ text: START({ check }) }).catch(() => {}),
273  );
274  return { text: `Goal set: \`${check}\` must exit 0.` };
275};
276
277/** `command.run` of `/goal` (`status`, `clear`, or a check command): answers in place of the built-in command. A failure answers with its message, because a failing hook would let the built-in command run. */
278export const onCommand: Handler<"command.run"> = ($, e, next) =>
279  Promise.resolve(answer($, e, next)).catch((error: Error) => ({
280    text: `Goal: ${error.message}`,
281  }));
282
slices/ground/adapters/mod.ts 103 lines
1// The mod handler of the `ground` slice: `tool.call` on `Write` and `Edit` of a plan or a spec file.
2// A claim with no source gets a deny that names it and the three ways out (`prompts.ts`).
3// The hook has no catch-all: when it throws, Claude Code skips the hook, so the call runs.
4// A tool result that is a deny of this check is no source, because a retry would pass on it.
5// The sources are the messages of the caller (its own transcript for a subagent). An `AskUserQuestion` answer and the handoff text count as user messages.
6
7import type { EngineInterface, MatchedHook } from "claude-code";
8import {
9  type Files,
10  findUnsourced,
11  PROJECT_FILES,
12  RULES_DIR,
13  refFiles,
14} from "../domain/claims.ts";
15import { inScenario, planText } from "../domain/plan.ts";
16import { UNSOURCED } from "../prompts.ts";
17
18const HEAD = UNSOURCED({ claims: "" }).split("\n")[0] as string;
19const DIRS_MAX = 2000;
20/** The start of `HANDOVER_RESUME` in `slices/handoff/prompts.ts`, the text that opens the first message after a handover (pinned by the lab test). */
21const HANDOFF_HEAD = "This is the handoff of the earlier part of this session";
22
23const readOf = async ($: EngineInterface, path: string) =>
24  (await $.fs.exists(path)) ? await $.fs.read(path) : null;
25
26/** The text of the first file under `cwd` whose path ends with `file`, with `node_modules` and the dot folders skipped. */
27const find = async ($: EngineInterface, cwd: string, file: string) => {
28  const queue = [cwd];
29  for (let seen = 0; queue.length > 0 && seen < DIRS_MAX; seen++) {
30    const dir = queue.shift() as string;
31    for (const { name, kind } of await $.fs.list(dir).catch(() => [])) {
32      if (kind === "dir" && name !== "node_modules" && !name.startsWith("."))
33        queue.push(`${dir}/${name}`);
34      if (kind === "file" && `${dir}/${name}`.endsWith(`/${file}`))
35        return readOf($, `${dir}/${name}`);
36    }
37  }
38  return null;
39};
40
41type GroundHook = MatchedHook<
42  "tool.call",
43  { readonly tool: readonly ["Write", "Edit"] }
44>;
45
46export const onPlanCall: GroundHook = async ($, e, next) => {
47  const text = planText(e as Parameters<typeof planText>[0]);
48  if (text === null) return next(e);
49  const cwd = await $.session.cwd();
50  const found =
51    e.agentId === undefined
52      ? await $.session.messages()
53      : await $.session.messages({ agentId: e.agentId });
54  // A subagent that the session cannot read has no sources to check against, so the call runs.
55  if (!Array.isArray(found)) return next(e);
56  const msgs = found.map((m) => ({
57    role: m.text.includes(HANDOFF_HEAD) ? ("user" as const) : m.role,
58    text: m.text,
59    // The text of a tool use is its result as the model read it, and a subagent transcript may hold no `toolResults`.
60    results: [
61      ...(m.toolResults ?? []).map((r) => r.text),
62      ...m.toolUses.map((u) => u.text ?? ""),
63    ].filter((r) => !r.includes(HEAD)),
64    bash: m.toolUses.filter((u) => u.tool === "Bash").length,
65    answers: m.toolUses
66      .filter((u) => u.tool === "AskUserQuestion" && !u.isError)
67      .map((u) => u.text ?? ""),
68  }));
69  const rules = (await $.fs.list(`${cwd}/${RULES_DIR}`).catch(() => []))
70    .filter(({ name, kind }) => kind === "file" && name.endsWith(".md"))
71    .map(({ name }) => `${RULES_DIR}/${name}`);
72  const entries = await Promise.all([
73    ...[...PROJECT_FILES, ...rules].map(
74      async (n) => [n, await readOf($, `${cwd}/${n}`)] as const,
75    ),
76    ...refFiles(text).map(
77      async (n) =>
78        [
79          n,
80          (await readOf($, `${cwd}/${n}`)) ?? (await find($, cwd, n)),
81        ] as const,
82    ),
83  ]);
84  const target = "file_path" in e ? await readOf($, e.file_path) : null;
85  // An edit inside a scenario block of a spec file is an example value.
86  if (
87    "old_string" in e &&
88    inScenario(target ?? "", e.old_string) &&
89    /(?:^|\/)spec\.md$/.test(e.file_path)
90  )
91    return next(e);
92  const own = `${target ?? ""}\n${"old_string" in e ? e.old_string : ""}`;
93  const claims = findUnsourced(
94    text,
95    msgs,
96    Object.fromEntries(entries) as Files,
97    own,
98  );
99  return claims.length === 0
100    ? next(e)
101    : { deny: UNSOURCED({ claims: claims.map((c) => `- ${c}`).join("\n") }) };
102};
103
slices/guard/adapters/mod.ts 356 lines
1// The mod handlers of the `guard` slice: the permission gate of spec `permission-gate` (D1).
2// `tool.check` gives `deny`, `ask`, or `allow`; `classic.PermissionRequest` runs the same gate when Claude Code would show a prompt.
3// An `ask` of the delete and local `git` gates goes to Jev first when the `decide` option is `jev` and `TYPESAFE_API_KEY` is set.
4// Each `ask` goes to a log of the session (task 5.2), and a log that cannot be written changes no verdict.
5// A hook `allow` does not clear an `ask` or `deny` rule (`research/claude-code.md` of change `0-3-0`).
6// A hook that throws is skipped, so `onCheckFail` asks a person (fail closed).
7
8import type {
9  CatchHandler,
10  EngineInterface,
11  MatchedHook,
12  PluginOptions,
13} from "claude-code";
14import { postJev } from "../../../kernel/jev.ts";
15import {
16  GONE,
17  hash64,
18  ledgerPath,
19  madeBy,
20  parseLedger,
21  statusPaths,
22} from "../../../kernel/ledger.ts";
23import type { Handler } from "../../../kernel/slice.ts";
24import { tempDirs } from "../../../kernel/tmp.ts";
25import { sharesOf } from "../../../kernel/usage.ts";
26import { type Facts, gate, gated, type Verdict } from "../domain/gate.ts";
27import { fixTitles } from "../domain/ghtitle.ts";
28import type { GitScope } from "../domain/git.ts";
29import { jevRequest, sureYes } from "../domain/jev.ts";
30import { type Decider, logLine, logPath } from "../domain/log.ts";
31import {
32  keepWatches,
33  monitorIds,
34  runningTask,
35  type Watches,
36  withWatch,
37} from "../domain/monitors.ts";
38import { isRunning, mayAsk, usageAsk } from "../domain/usage.ts";
39import { GIT_TIMEOUT_MS, STAGE_FILES_MAX, STAGE_NAMES_MAX } from "../limits.ts";
40import {
41  CHECK_FAIL,
42  DENY_MONITOR_DUPLICATE,
43  JEV_ALLOW,
44  TITLE_FIXED,
45} from "../prompts.ts";
46
47/** Whether the `decide` option is `jev`, and the sensitive key option, which the generated `register` sets once with `keepOptions`. */
48const kept = { jev: false, key: "" };
49
50export const keepOptions = (options: PluginOptions): void => {
51  kept.jev = options["decide"] === "jev";
52  const key = options["TYPESAFE_API_KEY"];
53  kept.key = typeof key === "string" ? key : "";
54};
55
56type Check = Handler<"tool.check">;
57
58/** The `status` flags of each scope: `delete` also counts the ignored files, because a delete removes them. */
59const STATUS_FLAGS: Record<GitScope, string[]> = {
60  changes: ["--untracked-files=no"],
61  untracked: ["--untracked-files=all"],
62  delete: ["--untracked-files=all", "--ignored"],
63};
64
65/** `"clean"` when the files of `paths` in `scope` have a commit and no change. For `delete`, `git ls-files` must also find each path. A run that fails gives `"dirty"`. */
66const gitState =
67  ($: EngineInterface) =>
68  async (
69    scope: GitScope,
70    dir: string,
71    paths: string[],
72  ): Promise<"clean" | "dirty"> => {
73    const run = (args: string[]) =>
74      $.process.run(["git", "-C", dir, ...args, "--", ...paths], {
75        timeoutMs: GIT_TIMEOUT_MS,
76      });
77    const status = await run(["status", "--porcelain", ...STATUS_FLAGS[scope]]);
78    const lines = status.stdout.split("\n").filter(Boolean);
79    const changed =
80      scope === "untracked"
81        ? lines.some((line) => line.startsWith("??"))
82        : lines.length > 0;
83    if (status.exitCode !== 0 || changed) return "dirty";
84    if (scope !== "delete") return "clean";
85    const tracked = await run(["ls-files", "--error-unmatch"]);
86    return tracked.exitCode === 0 ? "clean" : "dirty";
87  };
88
89/** The changed files of the work tree of `dir` that no line of the edit ledger holds with their hash now, relative to the root. A `git` run that fails gives none, because the stage fails too. */
90const userEdits =
91  ($: EngineInterface, tmp: string) =>
92  async (dir: string): Promise<string[]> => {
93    const run = (args: string[]) =>
94      $.process.run(["git", "-C", dir, ...args], { timeoutMs: GIT_TIMEOUT_MS });
95    const [status, top] = await Promise.all([
96      run(["status", "--porcelain", "-z", "--untracked-files=all"]),
97      run(["rev-parse", "--show-toplevel"]),
98    ]);
99    if (status.exitCode !== 0 || top.exitCode !== 0) return [];
100    const file = ledgerPath(tmp, await $.session.root());
101    const lines = parseLedger(
102      ((await $.fs.exists(file)) && (await $.fs.read(file))) || "",
103    );
104    const root = top.stdout.trim();
105    const found: string[] = [];
106    for (const rel of statusPaths(status.stdout).slice(0, STAGE_FILES_MAX)) {
107      const hash = await $.fs.read(`${root}/${rel}`).then(hash64, () => GONE);
108      if (hash !== GONE && madeBy(lines, `${root}/${rel}`, hash).length === 0)
109        found.push(rel);
110      if (found.length >= STAGE_NAMES_MAX) break;
111    }
112    return found;
113  };
114
115/** The temp folders for each pair of env values, kept for the life of the mod, so each call after the first makes no `stat`. */
116const temps = new Map<string, Promise<string[]>>();
117
118const factsOf = async ($: EngineInterface): Promise<Facts> => {
119  const [cwd, home, tmp, CLAUDE_CODE_TMPDIR] = await Promise.all([
120    $.session.cwd(),
121    $.env.get("HOME"),
122    $.env.get("TMPDIR"),
123    $.env.get("CLAUDE_CODE_TMPDIR"),
124  ]);
125  const key = `${tmp}\0${CLAUDE_CODE_TMPDIR}`;
126  const dirs =
127    temps.get(key) ??
128    tempDirs({ TMPDIR: tmp, CLAUDE_CODE_TMPDIR }, (path) =>
129      $.fs.stat(path, { resolve: true }).then(
130        (s) => s.realPath ?? path,
131        () => path,
132      ),
133    ).then((pairs) => pairs.flat());
134  temps.set(key, dirs);
135  const tmpDir = (tmp ?? "/tmp").replace(/\/+$/, "");
136  return {
137    cwd,
138    home: home ?? "/nonexistent",
139    tmp: tmpDir,
140    temps: await dirs,
141    git: gitState($),
142    userEdits: userEdits($, tmpDir),
143  };
144};
145
146/** `allow` when Jev is sure that the loss of the `ask` `own` of `command` is safe, else `undefined`. */
147const askJev = async (
148  $: EngineInterface,
149  command: string,
150  own: Verdict,
151): Promise<Verdict | undefined> => {
152  // The exported `TYPESAFE_API_KEY` of 0.3.0-alpha.1 stays a fallback.
153  const key = kept.jev
154    ? kept.key || (await $.env.get("TYPESAFE_API_KEY"))
155    : undefined;
156  if (!key) return undefined;
157  const answers = await postJev(
158    key,
159    jevRequest(command, own.reason ?? ""),
160    { fetch: (to, body) => $.http.fetch(to, body) },
161    { sleep: (wait, abort) => $.clock.sleep(wait, abort) },
162  ).catch(() => undefined);
163  const p = sureYes(answers);
164  return p === undefined
165    ? undefined
166    : { decision: "allow", reason: JEV_ALLOW({ p: p.toFixed(2) }) };
167};
168
169/** The last write of the log. Each write waits for it, so two checks at the same time keep both lines. */
170let logged: Promise<void> = Promise.resolve();
171
172const log = (
173  $: EngineInterface,
174  tmp: string,
175  lineAt: (at: number) => string,
176): Promise<void> => {
177  logged = logged
178    .then(async () => {
179      const [id, at] = await Promise.all([$.session.id(), $.clock.now()]);
180      const path = logPath(tmp, id);
181      const old = (await $.fs.exists(path)) ? await $.fs.read(path) : "";
182      await $.fs.write(path, old + lineAt(at));
183    })
184    .catch(() => undefined);
185  return logged;
186};
187
188const ALLOW: Verdict = { decision: "allow" };
189
190/** The `ask` for the call at red, or `undefined`. The 5-hour share comes from `$.session.usage()`, and the weekly share never asks. A fault of the read adds no ask. */
191const askAtRed = async (
192  $: EngineInterface,
193  e: Parameters<Check>[1],
194): Promise<Verdict | undefined> => {
195  const command = (e.input as { command?: unknown } | undefined)?.command;
196  if (!mayAsk(e.tool, command)) return undefined;
197  try {
198    const { rateLimits } = await $.session.usage();
199    const reason = await usageAsk({
200      tool: e.tool,
201      command,
202      fiveHour: sharesOf(rateLimits).five_hour,
203      othersRun: async () =>
204        (await $.agent.list()).some((agent) => isRunning(agent.status)),
205    });
206    return reason === null ? undefined : { decision: "ask", reason };
207  } catch {
208    return undefined;
209  }
210};
211
212/** The verdict of the gate. A `deny` of the engine stays, and an `ask` of the gate goes to Jev or a person. */
213export const onCheck: Check = async ($, e, next) => {
214  const facts = gated(e.input) ? await factsOf($) : undefined;
215  const [engine, { jev, ...own }, red] = await Promise.all([
216    next(e),
217    facts ? gate(e, facts) : ALLOW,
218    askAtRed($, e),
219  ]);
220  if (engine.decision === "deny") return engine;
221  if (!facts || own.decision !== "ask") {
222    if (!red || own.decision !== "allow") return own;
223    const { tmp } = facts ?? (await factsOf($));
224    await log($, tmp, (at) =>
225      logLine({ at, ...e, reason: red.reason ?? "", decider: "person" }),
226    );
227    return { ...red, decider: "person" };
228  }
229  const command = (e.input as { command?: unknown }).command;
230  const allowed =
231    jev && typeof command === "string"
232      ? await askJev($, command, own)
233      : undefined;
234  const decider: Decider = allowed ? "jev" : "person";
235  await log($, facts.tmp, (at) =>
236    logLine({ at, ...e, reason: own.reason ?? "", decider }),
237  );
238  // An `ask` has `decider: "person"`, so the dialog has no "always allow" row, and no classifier settles it.
239  return allowed ?? { ...own, decider: "person" };
240};
241
242/** `classic.PermissionRequest`: `allow` when no gate matches, else the answer of the hooks below, so a person sees the prompt. */
243export const onPermission: Handler<"classic.PermissionRequest"> = async (
244  $,
245  e,
246  next,
247) => {
248  const answer = await next(e);
249  if (answer.decision) return answer;
250  const own = gated(e.tool_input)
251    ? await gate(
252        { tool: e.tool_name, input: e.tool_input, agentId: e.agent_id },
253        await factsOf($),
254      )
255    : ALLOW;
256  return own.decision === "allow"
257    ? { ...answer, decision: { behavior: "allow" } }
258    : answer;
259};
260
261/** The running `Monitor` tasks are one value of `$.state`, so the main loop and each subagent see the same set (task 10.4). */
262const watchRef = { plugin: "claudetto", key: "watches" } as const;
263
264type MonitorHook = MatchedHook<
265  "tool.call",
266  { readonly tool: readonly ["Monitor", "TaskStop"] }
267>;
268
269/** The ID of the task that runs `command` now, or `undefined`. A fault of the state gives `undefined`. */
270const runningId = async (
271  $: EngineInterface,
272  command: string,
273): Promise<string | undefined> => {
274  try {
275    const { value } = await $.state.get(watchRef);
276    return runningTask(value ?? {}, command, await $.clock.now());
277  } catch {
278    return undefined;
279  }
280};
281
282/** Sets the running set to `change` of the set now and the time of the host. A fault of the state leaves the set as it is. */
283const updateWatches = async (
284  $: EngineInterface,
285  change: (held: Watches, now: number) => Watches,
286): Promise<void> => {
287  try {
288    const { value } = await $.state.get(watchRef);
289    await $.state.set(watchRef, change(value ?? {}, await $.clock.now()));
290  } catch {
291    // The state is a help for the agent, so a fault of it adds no stop.
292  }
293};
294
295/** `tool.call` on `Monitor` and `TaskStop`: a `Monitor` with the command of a running one gets a refusal that names the task (X8), a `Monitor` that runs joins the state, and a `TaskStop` leaves it. */
296export const onMonitor: MonitorHook = async ($, e, next) => {
297  const command = e.tool === "Monitor" ? e.command : undefined;
298  const id =
299    typeof command === "string" ? await runningId($, command) : undefined;
300  if (id !== undefined) return { deny: DENY_MONITOR_DUPLICATE({ id }) };
301  const answer = await next(e);
302  if (answer.deny !== undefined || answer.isError) return answer;
303  if (e.tool === "TaskStop") {
304    const task = e.task_id ?? e.shell_id;
305    await updateWatches($, (held) => keepWatches(held, (id) => id !== task));
306  } else if (typeof command === "string") {
307    const { taskId, timeoutMs } = answer.result as {
308      taskId?: unknown;
309      timeoutMs?: unknown;
310    };
311    if (typeof taskId === "string")
312      await updateWatches($, (held, now) =>
313        withWatch(
314          held,
315          command,
316          taskId,
317          now,
318          typeof timeoutMs === "number" ? timeoutMs : 0,
319        ),
320      );
321  }
322  return answer;
323};
324
325type TitleHook = MatchedHook<
326  "tool.call",
327  { readonly tool: readonly ["Bash"]; readonly command: RegExp }
328>;
329
330/** `tool.call` on `gh pr create` and `gh issue create`: the `--title` and `-t` value runs in Chicago Title Case, and the result tells the model. */
331export const onTitle: TitleHook = async (_$, e, next) => {
332  const fixed = fixTitles(e.command);
333  if (fixed === undefined) return next(e);
334  const answer = await next({ ...e, command: fixed });
335  return answer.deny === undefined
336    ? { ...answer, context: [...(answer.context ?? []), TITLE_FIXED] }
337    : answer;
338};
339
340/** `classic.Stop`: the running set becomes the `monitor` tasks of `background_tasks`, so a task that ended on its own leaves the state. */
341export const onStop: Handler<"classic.Stop"> = async ($, e, next) => {
342  const answer = await next(e);
343  if (e.background_tasks) {
344    const live = monitorIds(e.background_tasks);
345    await updateWatches($, (held) => keepWatches(held, (id) => live.has(id)));
346  }
347  return answer;
348};
349
350/** A gate that throws asks a person, and the dialog names the gate. */
351export const onCheckFail: CatchHandler<Check> = () => ({
352  decision: "ask",
353  decider: "person",
354  reason: CHECK_FAIL,
355});
356
slices/handoff/adapters/mod.ts 325 lines
1// The mod handlers of the `handoff` slice: the handover (design D12, spec `handover`), a handoff file built in code, a `/clear`, and a resume prompt.
2// The automatic compaction of Claude Code stays as the safety net. `classic.Stop` starts it at a quiet turn end, and `prompt.submit` offers it after a cold-cache pause.
3// The steps run in a timer after the hook, because Claude Code refuses `$.command.run` and `$.prompt.submit` inside a hook that the turn waits on.
4// Each path starts at the project root, because `$.fs` resolves a relative path against the folder of the shell (D23).
5
6import type {
7  EngineInterface,
8  PluginOptions,
9  SessionMessage,
10} from "claude-code";
11import { openChange } from "../../../kernel/change.ts";
12import { passedChecks, taskLines } from "../../../kernel/checks.ts";
13import { isBuiltInCompaction } from "../../../kernel/compaction.ts";
14import { noulOf, postJev } from "../../../kernel/jev.ts";
15import { CHANGES_DIR } from "../../../kernel/paths.ts";
16import type { Handler } from "../../../kernel/slice.ts";
17import { oneLine } from "../../../kernel/text.ts";
18import { costReport, sharesOf } from "../../../kernel/usage.ts";
19import { isCold, offerAnswer, offerBody } from "../cold.ts";
20import { handoffPath, withChange } from "../compact.ts";
21import {
22  caseOf,
23  groupOf,
24  handoverText,
25  modelOf,
26  optionOf,
27  pathOf,
28  stepsOf,
29} from "../handover.ts";
30import type { Case, Path } from "../limits.ts";
31import { CACHE_TTL_MS } from "../limits.ts";
32import {
33  COMPACT_KEEP,
34  HANDOVER_HELD,
35  HANDOVER_HELD_NOTICE,
36  HANDOVER_NOTICE,
37  HANDOVER_OFFER,
38  HANDOVER_OFFER_RESUMED,
39  HANDOVER_RESUME,
40} from "../prompts.ts";
41
42const kept: { options: PluginOptions } = { options: {} };
43
44/** Keeps the `userConfig` values, which the generated `register` gives. */
45export const keepOptions = (options: PluginOptions): void => {
46  kept.options = options;
47};
48
49/** The time of the last request of the main thread, or `null` before the first turn of this process. */
50let lastTurnAt: number | null = null;
51
52/** Notes that the main thread, with no `agentId`, used the prompt cache just now (D47). */
53const noteCache = async ($: EngineInterface, agentId: string | undefined) => {
54  if (agentId === undefined)
55    lastTurnAt = await $.clock.now().catch(() => lastTurnAt);
56};
57
58/** On a resume or a fork, takes the last answer of the transcript as the last request, so a short pause gets no offer. */
59export const onResume: Handler<"classic.SessionStart"> = async ($, e, next) => {
60  const seconds = e.seconds_since_last_response;
61  if (seconds !== undefined)
62    lastTurnAt = await $.clock.now().then(
63      (now) => now - seconds * 1000,
64      () => lastTurnAt,
65    );
66  return next(e);
67};
68
69export const onTurnComplete: Handler<"turn.complete"> = async ($, e, next) => {
70  await noteCache($, e.agentId);
71  return next(e);
72};
73
74/** The model of the first step after the resume of a handover, or `null` for the session model. */
75let coldModel: string | null = null;
76
77/** `turn.step`: notes each request of the main thread, and names the model of the table on the first step after a handover. */
78export const onTurnStep: Handler<"turn.step"> = async function* ($, e, next) {
79  await noteCache($, e.agentId);
80  if (e.agentId !== undefined || coldModel === null) return yield* next(e);
81  const model = coldModel;
82  coldModel = null;
83  return yield* next({ ...e, model });
84};
85
86/** The project root with a final `/`, or `""` with no root (D23). */
87const rootOf = async ($: EngineInterface): Promise<string> => {
88  const root =
89    (await $.env.get("CLAUDE_PROJECT_DIR").catch(() => undefined)) ||
90    (await $.session.root().catch(() => ""));
91  return root === "" ? "" : `${root.replace(/\/+$/, "")}/`;
92};
93
94/** The open change among the folders of `openspec/changes/` (not `archive`), with the task lines of its `tasks.md`. */
95const openChangeOf = async ($: EngineInterface, root: string) => {
96  const entries = await $.fs.list(`${root}${CHANGES_DIR}`).catch(() => []);
97  const changes = await Promise.all(
98    entries
99      .filter((e) => e.kind === "dir" && e.name !== "archive")
100      .map(async ({ name }) => ({
101        name,
102        tasks: await $.fs
103          .read(`${root}${CHANGES_DIR}/${name}/tasks.md`)
104          .then((t) => (typeof t === "string" ? t : null))
105          .catch(() => null),
106      })),
107  );
108  const change = openChange(changes);
109  const tasks = changes.find((c) => c.name === change?.name)?.tasks ?? "";
110  return { change, lines: taskLines(tasks) };
111};
112
113/** The size in bytes of the file after the write of `text` to `path`, or `0` when the write fails. */
114const writtenSize = ($: EngineInterface, path: string, text: string) =>
115  $.fs
116    .write(path, text)
117    .then(() => $.fs.stat(path))
118    .then(
119      (stat) => stat.size,
120      () => 0,
121    );
122
123/** The task group of the next open task of the open change, or `null` with no open change. */
124const groupNow = async ($: EngineInterface): Promise<string | null> => {
125  const { change } = await openChangeOf($, await rootOf($));
126  return groupOf(change?.next ?? null);
127};
128
129/** True when an agent of the session runs, or a background task is in flight. */
130const isBusy = async ($: EngineInterface, tasks: readonly unknown[] = []) =>
131  tasks.length > 0 ||
132  (await $.agent.list().catch(() => [])).some(
133    (agent) => agent.status === "running" || agent.status === "pending",
134  );
135
136/** The task group at the last quiet turn end, or `null` before the first one. */
137let lastGroup: string | null = null;
138
139/** True while a path runs, so a second trigger cannot start a second path. */
140let isRunning = false;
141
142/** The usage value that `prompt` keeps from `session.measure`, which holds the first share of each window. */
143const usageRef = { plugin: "claudetto", key: "usage" } as const;
144
145/** The cost report of the session: the cost, and the change of each share since the first reading. A fault gives `none`. */
146const reportOf = async ($: EngineInterface): Promise<string> => {
147  try {
148    const [usage, { value }] = await Promise.all([
149      $.session.usage(),
150      $.state.get(usageRef),
151    ]);
152    return costReport(
153      usage.cost?.usd,
154      sharesOf(usage.rateLimits),
155      value?.start ?? {},
156    );
157  } catch {
158    return "none";
159  }
160};
161
162/** Writes the handoff text to `.claudetto/handoffs/`, or gives `null` when the file is empty or missing. */
163const handoverOf = async (
164  $: EngineInterface,
165  messages: readonly SessionMessage[],
166) => {
167  const root = await rootOf($);
168  const { change, lines } = await openChangeOf($, root);
169  const text = withChange(
170    `${handoverText(messages, await reportOf($), change && (lines.get(change.next)?.line ?? null))}\n`,
171    change,
172    passedChecks(messages),
173    lines,
174  );
175  return { root, text };
176};
177
178/** Queues the steps after the hook, each after the one before ends. A failed step ends the run. */
179const queue = (
180  $: EngineInterface,
181  steps: readonly (() => Promise<unknown>)[],
182): void => {
183  isRunning = true;
184  $.clock.after(0, async () => {
185    try {
186      for (const step of steps) await step();
187    } catch {
188      // A refused command leaves the session as it is.
189    } finally {
190      isRunning = false;
191    }
192  });
193};
194
195/** Starts `path` with the `held` prompt after it. A file that is missing or empty stops the path. True when it started. */
196const begin = async (
197  $: EngineInterface,
198  path: Path,
199  held: string | null,
200  model: string | null,
201): Promise<boolean> => {
202  const messages = await $.session.messages();
203  if (!Array.isArray(messages) || isRunning) return false;
204  const steps = stepsOf(path, held !== null);
205  const { root, text } = await handoverOf($, messages);
206  const file = handoffPath(new Date());
207  if (steps.includes("write")) {
208    if ((await writtenSize($, `${root}${file}`, text)) === 0) return false;
209    $.ui.log(oneLine(HANDOVER_NOTICE({ file })));
210  }
211  const resume = [
212    HANDOVER_RESUME({ path: file }),
213    "",
214    text,
215    ...(held === null ? [] : ["", HANDOVER_HELD({ prompt: held })]),
216  ].join("\n");
217  const actions = {
218    clear: () => $.command.run({ command: "clear" }),
219    compact: () =>
220      $.command.run({ command: "compact", args: COMPACT_KEEP({ text }) }),
221    resume: () => {
222      coldModel = model;
223      return $.prompt.submit({ text: resume });
224    },
225    held: () =>
226      $.prompt.submit({ text: HANDOVER_HELD({ prompt: held ?? "" }) }),
227  };
228  queue(
229    $,
230    steps
231      .filter((step) => step !== "write")
232      .map((step) => actions[step as keyof typeof actions]),
233  );
234  return true;
235};
236
237/** `Yes` or `No` when Jev is sure about the offer, else `undefined`. It needs the option `decide` as `jev` and a key: the option, else the exported key. A failed call is `undefined`. */
238const jevAnswer = async (
239  $: EngineInterface,
240  now: number,
241): Promise<"Yes" | "No" | undefined> => {
242  const option = kept.options["TYPESAFE_API_KEY"];
243  const key =
244    kept.options["decide"] === "jev"
245      ? (typeof option === "string" && option) ||
246        (await $.env.get("TYPESAFE_API_KEY"))
247      : undefined;
248  if (!key) return undefined;
249  const tokens = (await $.session.usage()).context.tokens ?? 0;
250  const { change, lines } = await openChangeOf($, await rootOf($));
251  const task = lines.get(change?.next ?? "")?.line ?? "none";
252  const idleMs = lastTurnAt === null ? null : now - lastTurnAt;
253  const answers = await postJev(
254    key,
255    offerBody({ tokens, idleMs, task }),
256    { fetch: (address, request) => $.http.fetch(address, request) },
257    { sleep: (delay, stop) => $.clock.sleep(delay, stop) },
258  ).catch(() => undefined);
259  return offerAnswer(noulOf(answers, "handover"));
260};
261
262/** Starts the path that the option and the table choose for `kind`. */
263const startCase = async (
264  $: EngineInterface,
265  kind: Case,
266  tokens: number,
267  held: string | null,
268): Promise<boolean> => {
269  const path = pathOf(kind, tokens, optionOf(kept.options));
270  return path === null ? false : begin($, path, held, modelOf(kind, tokens));
271};
272
273/** `classic.Stop`: at a quiet turn end, starts a handover for the triggers 1 and 3 of D12. The stop goes on as it is. */
274export const onStop: Handler<"classic.Stop"> = async ($, e, next) => {
275  const result = await next(e);
276  if (isBuiltInCompaction(kept.options) || isRunning) return result;
277  if (await isBusy($, e.background_tasks)) return result;
278  const [usage, now, group] = await Promise.all([
279    $.session.usage(),
280    $.clock.now(),
281    groupNow($),
282  ]);
283  const kind = caseOf({
284    tokens: usage.context.tokens ?? 0,
285    startedAt: usage.startedAt,
286    now,
287    group,
288    before: lastGroup,
289  });
290  lastGroup = group;
291  if (kind !== null) await startCase($, kind, usage.context.tokens ?? 0, null);
292  return result;
293};
294
295/** `prompt.submit`: offers the handover after a pause over the cache TTL. With a Jev key, Jev answers when it is sure, and the dialog shows when it is not. On the yes, the prompt is held and runs after the resume. */
296export const onPrompt: Handler<"prompt.submit"> = async ($, e, next) => {
297  if (
298    isBuiltInCompaction(kept.options) ||
299    isRunning ||
300    e.origin.kind !== "composer" ||
301    e.command !== undefined ||
302    e.isMeta ||
303    e.turnId !== undefined
304  )
305    return next(e);
306  const now = await $.clock.now();
307  if (!isCold(now, lastTurnAt) || (await $.session.turns()) === 0)
308    return next(e);
309  if (await isBusy($)) return next(e);
310  const answer =
311    (await jevAnswer($, now)) ??
312    (await $.ui
313      .ask(
314        lastTurnAt === null
315          ? HANDOVER_OFFER_RESUMED
316          : HANDOVER_OFFER({ minutes: String(CACHE_TTL_MS / 60_000) }),
317        ["Yes", "No"],
318      )
319      .catch(() => "No"));
320  if (answer !== "Yes") return next(e);
321  const tokens = (await $.session.usage()).context.tokens ?? 0;
322  const isStarted = await startCase($, "idle", tokens, e.text);
323  return isStarted ? { drop: HANDOVER_HELD_NOTICE } : next(e);
324};
325
slices/hygiene/adapters/mod.ts 315 lines
1// The mod handlers of the `hygiene` slice (spec `session-hygiene`, D12 of change `0-3-0`): the cleanup at session start, and the edit ledger.
2// The first run of `session.start` holds the first prompt, so the sweep runs after the hook returns.
3// The count goes to `$.ui.log`, which the model does not read (D48).
4
5import type { EngineInterface, MatchedHook } from "claude-code";
6import {
7  GONE,
8  hash64,
9  type LedgerLine,
10  ledgerPath,
11  parseLedger,
12  statusPaths,
13} from "../../../kernel/ledger.ts";
14import type { Handler } from "../../../kernel/slice.ts";
15import { tempDirs } from "../../../kernel/tmp.ts";
16import { isGitView, whoChanged } from "../domain/authors.ts";
17import { cleanupArgv, parseCleanup } from "../domain/cleanup.ts";
18import { titleHeadings } from "../domain/headings.ts";
19import {
20  changedFiles,
21  ledgerText,
22  withEdit,
23  withTold,
24} from "../domain/ledger.ts";
25import { removedNotice } from "../domain/notice.ts";
26import { removedCount, sweepArgv } from "../domain/probes.ts";
27import {
28  BASH_CHANGE_MAX_MS,
29  BASH_CHANGE_SLACK_MS,
30  DEFAULT_CLEANUP_DAYS,
31  GIT_TIMEOUT_MS,
32  HEADINGS_FILE_MAX,
33  LEDGER_WRITE_TRIES,
34  SWEEP_TIMEOUT_MS,
35  WHO_FILES_MAX,
36  WORKTREES_DIR,
37} from "../limits.ts";
38import { FILE_CHANGED, HEADINGS_CASED } from "../prompts.ts";
39
40/** `cleanupPeriodDays` of Claude Code, or 30 when it is not a whole number over 0. */
41const daysOf = async ($: EngineInterface): Promise<number> => {
42  const days = await $.settings
43    .read({ key: "cleanupPeriodDays" })
44    .catch(() => undefined);
45  return typeof days === "number" && Number.isInteger(days) && days > 0
46    ? days
47    : DEFAULT_CLEANUP_DAYS;
48};
49
50/** The session files of Claude Code, in `CLAUDE_CONFIG_DIR` or `~/.claude`. */
51const sessionsOf = async ($: EngineInterface): Promise<string> => {
52  const config = await $.env.get("CLAUDE_CONFIG_DIR").catch(() => undefined);
53  const home = (await $.env.get("HOME").catch(() => undefined)) || "";
54  return `${(config || `${home}/.claude`).replace(/\/+$/, "")}/sessions`;
55};
56
57const outputOf = async ($: EngineInterface, argv: string[]): Promise<string> =>
58  $.process.run(argv, { timeoutMs: SWEEP_TIMEOUT_MS }).then(
59    (run) => run.stdout,
60    () => "",
61  );
62
63/** Removes the folders of ended sessions, the worktrees that keep no work, and the leftover probe folders (anthropics/claude-code#104170), and logs one line for them. */
64export const sweep = async ($: EngineInterface): Promise<void> => {
65  const dirs = await tempDirs(
66    {
67      TMPDIR: await $.env.get("TMPDIR"),
68      CLAUDE_CODE_TMPDIR: await $.env.get("CLAUDE_CODE_TMPDIR"),
69    },
70    (path) =>
71      $.fs.stat(path, { resolve: true }).then(
72        (s) => s.realPath ?? path,
73        () => path,
74      ),
75  );
76  const root = await $.session.root().catch(() => "");
77  const roots = {
78    sessions: await sessionsOf($),
79    worktrees: root ? `${root.replace(/\/+$/, "")}/${WORKTREES_DIR}` : "",
80    days: await daysOf($),
81    current: await $.session.id(),
82  };
83  let probes = 0;
84  let out = "";
85  // Each real folder once (`/tmp` and `/private/tmp` count once), and the worktrees only in the first run.
86  for (const [i, tmp] of [...new Set(dirs.map((d) => d[1]))].entries()) {
87    probes += removedCount(await outputOf($, sweepArgv(tmp)));
88    out += await outputOf(
89      $,
90      cleanupArgv({ ...roots, tmp, worktrees: i ? "" : roots.worktrees }),
91    );
92  }
93  const report = parseCleanup(out);
94  const notice = removedNotice({
95    folders: report.folders,
96    files: report.files,
97    worktrees: report.worktrees.length,
98    probes,
99    bytes: report.bytes,
100  });
101  if (notice !== null) $.ui.log(notice);
102};
103
104/** Starts the sweep and does not wait for it. */
105export const onStart: Handler<"session.start"> = ($, e, next) => {
106  void sweep($).catch(() => undefined);
107  return next(e);
108};
109
110// What this session was told is one value of `$.state`, for the main loop and each subagent alike (task 12.1).
111const toldRef = { plugin: "claudetto", key: "told" } as const;
112
113/** `$TMPDIR`, or `/tmp`, as the gate log of `guard`. */
114const tmpOf = async ($: EngineInterface): Promise<string> =>
115  ((await $.env.get("TMPDIR").catch(() => undefined)) || "/tmp").replace(
116    /(.)\/+$/,
117    "$1",
118  );
119
120const fileOf = async ($: EngineInterface): Promise<string> =>
121  ledgerPath(await tmpOf($), await $.session.root());
122
123const readLedger = async ($: EngineInterface, file: string) =>
124  parseLedger(((await $.fs.exists(file)) && (await $.fs.read(file))) || "");
125
126const same = (a: LedgerLine, b: LedgerLine): boolean =>
127  a.path === b.path &&
128  a.hash === b.hash &&
129  a.session === b.session &&
130  a.agent === b.agent;
131
132/** The last write of the ledger. Each write waits for it, so two calls at the same time keep both lines. */
133let recorded: Promise<void> = Promise.resolve();
134
135/** Adds `added` to the ledger file. The file API has no append, so a write that another session overwrote runs again, at most `LEDGER_WRITE_TRIES` times. */
136const record = (
137  $: EngineInterface,
138  added: readonly LedgerLine[],
139): Promise<void> => {
140  recorded = recorded
141    .then(async () => {
142      const file = await fileOf($);
143      for (let i = 0; i < LEDGER_WRITE_TRIES; i++) {
144        const lines = added.reduce(withEdit, await readLedger($, file));
145        await $.fs.write(file, ledgerText(lines));
146        const held = await readLedger($, file);
147        if (added.every((a) => held.some((h) => same(h, a)))) return;
148      }
149    })
150    .catch(() => undefined);
151  return recorded;
152};
153
154const editedPath = (input: unknown): string | null => {
155  const path = (input as { file_path?: unknown } | null)?.file_path;
156  return typeof path === "string" && path !== "" ? path : null;
157};
158
159/** The hash of the content of `path`, or `GONE` for a file that is missing or unreadable. */
160const hashOf = async ($: EngineInterface, path: string): Promise<string> =>
161  $.fs.read(path).then(
162    (text) => hash64(text),
163    () => GONE,
164  );
165
166/** Sets the Title Case of the headings of the Markdown file `path`, and gives the count that changed. An unreadable, unwritable, or huge file stays as it is. */
167const titleFile = async ($: EngineInterface, path: string): Promise<number> => {
168  try {
169    const text = await $.fs.read(path);
170    if (text.length > HEADINGS_FILE_MAX) return 0;
171    const fixed = titleHeadings(text);
172    if (fixed.changed === 0) return 0;
173    await $.fs.write(path, fixed.text);
174    return fixed.changed;
175  } catch {
176    return 0;
177  }
178};
179
180/** The files that `git status` lists, with their modification times. */
181const workFiles = async (
182  $: EngineInterface,
183): Promise<{ path: string; mtimeMs: number }[]> => {
184  const cwd = await $.session.cwd();
185  const run = (argv: string[]) =>
186    $.process.run(argv, { cwd, timeoutMs: GIT_TIMEOUT_MS });
187  // `--untracked-files=all` lists a new file in a new folder, which `-z` alone shows as the folder.
188  const [status, top] = await Promise.all([
189    run(["git", "status", "--porcelain", "-z", "--untracked-files=all"]),
190    run(["git", "rev-parse", "--show-toplevel"]),
191  ]);
192  if (status.exitCode !== 0 || top.exitCode !== 0) return [];
193  const root = top.stdout.trim();
194  const files: { path: string; mtimeMs: number }[] = [];
195  for (const rel of statusPaths(status.stdout).slice(0, WHO_FILES_MAX)) {
196    const path = `${root}/${rel}`;
197    const stat = await $.fs.stat(path).catch(() => undefined);
198    if (stat?.kind === "file") files.push({ path, mtimeMs: stat.mtimeMs });
199  }
200  return files;
201};
202
203/** The ledger lines for the files of `git status` that changed after the start of a `Bash` call. */
204const bashLines = async (
205  $: EngineInterface,
206  session: string,
207  agent: string,
208  tookMs: number | undefined,
209): Promise<LedgerLine[]> => {
210  const now = await $.clock.now();
211  const since =
212    now -
213    Math.min(tookMs ?? BASH_CHANGE_MAX_MS, BASH_CHANGE_MAX_MS) -
214    BASH_CHANGE_SLACK_MS;
215  const lines: LedgerLine[] = [];
216  for (const { path, mtimeMs } of await workFiles($)) {
217    if (mtimeMs < since) continue;
218    const hash = await hashOf($, path);
219    if (hash !== GONE) lines.push({ path, hash, session, agent });
220  }
221  return lines;
222};
223
224/** Records an `Edit` or `Write` of `path` after the headings of a `.md` file get their case (before the hash). Gives the line for the model when a heading changed. */
225const recordEdit = async (
226  $: EngineInterface,
227  path: string | null,
228  session: string,
229  agent: string,
230): Promise<string | undefined> => {
231  if (path === null) return undefined;
232  const count = /\.md$/i.test(path) ? await titleFile($, path) : 0;
233  const hash = await hashOf($, path);
234  if (hash !== GONE) await record($, [{ path, hash, session, agent }]);
235  return count > 0 ? HEADINGS_CASED({ path, count }) : undefined;
236};
237
238/** `classic.PostToolUse`: the ledger keeps the files of an `Edit`, a `Write`, or a `Bash` call. A failure of the ledger never stops the tool result. */
239export const onEdited: Handler<"classic.PostToolUse"> = async ($, e, next) => {
240  let note: string | undefined;
241  try {
242    const session = await $.session.id();
243    const agent = e.agent_id ? (e.agent_type ?? e.agent_id) : "main";
244    if (e.tool_name === "Bash")
245      await record($, await bashLines($, session, agent, e.duration_ms));
246    else note = await recordEdit($, editedPath(e.tool_input), session, agent);
247  } catch {
248    // The ledger is a help for the agent, so a fault of it adds no stop.
249  }
250  const answer = await next(e);
251  return note === undefined
252    ? answer
253    : {
254        ...answer,
255        additionalContext: [...(answer.additionalContext ?? []), note],
256      };
257};
258
259type GitViewHook = MatchedHook<
260  "tool.call",
261  { readonly tool: readonly ["Bash"] }
262>;
263
264/** `tool.call` on `Bash`: for `git status` and `git diff`, the context sorts each changed file by maker. It calls `next` first (D49). */
265export const onGitView: GitViewHook = async ($, e, next) => {
266  const answer = await next(e);
267  if (answer.deny !== undefined || !isGitView(e.command)) return answer;
268  try {
269    const session = await $.session.id();
270    const lines = await readLedger($, await fileOf($));
271    const files = await Promise.all(
272      (await workFiles($)).map(async ({ path }) => ({
273        path,
274        hash: await hashOf($, path),
275      })),
276    );
277    const text = whoChanged(lines, session, files);
278    return text === null
279      ? answer
280      : { ...answer, context: [...(answer.context ?? []), text] };
281  } catch {
282    return answer;
283  }
284};
285
286/** `classic.UserPromptSubmit`: one line for each file that changed after the last edit of its agent, and nothing when none did. */
287export const onPrompt: Handler<"classic.UserPromptSubmit"> = async (
288  $,
289  e,
290  next,
291) => {
292  const answer = await next(e);
293  try {
294    const session = await $.session.id();
295    const lines = await readLedger($, await fileOf($));
296    const now: Record<string, string> = {};
297    for (const l of lines)
298      if (l.session === session) now[l.path] = await hashOf($, l.path);
299    const { value: held } = await $.state.get(toldRef);
300    const told = held ?? {};
301    const changed = changedFiles(lines, session, told, now);
302    if (changed.length === 0) return answer;
303    await $.state.set(toldRef, withTold(told, changed, now));
304    return {
305      ...answer,
306      additionalContext: [
307        ...(answer.additionalContext ?? []),
308        changed.map((c) => FILE_CHANGED(c)).join("\n"),
309      ],
310    };
311  } catch {
312    return answer;
313  }
314};
315
slices/notes/adapters/mod.ts 304 lines
1// The mod handlers of the `notes` slice (spec `side-notes`): a note above the prompt, with no model call.
2// A note comes from a rule (the same deny, ask, or failing check 3 times), from the `learn:` block of a main reply, or from `finish` through the `$.state` key `note`.
3
4import type { EngineInterface, MatchedHook } from "claude-code";
5import { isCheck } from "../../../kernel/checks.ts";
6import {
7  GONE,
8  hash64,
9  ledgerPath,
10  madeBy,
11  parseLedger,
12  statusPaths,
13} from "../../../kernel/ledger.ts";
14import type { Note } from "../../../kernel/notes.ts";
15import {
16  PROJECT_LOCAL_SETTINGS,
17  PROJECT_SETTINGS,
18} from "../../../kernel/paths.ts";
19import type { Handler } from "../../../kernel/slice.ts";
20import { type Task, track, whatOf } from "../domain/background.ts";
21import {
22  admit,
23  clearFail,
24  countAsk,
25  countFail,
26  EMPTY,
27  type Notes,
28  signOf,
29  typedPast,
30  used,
31} from "../domain/counters.ts";
32import { learnFilter, parseLearn } from "../domain/learn.ts";
33import { GIT_TIMEOUT_MS, REPEAT_MIN } from "../limits.ts";
34import {
35  BACKGROUND_NOTE,
36  CHECK_NOTE,
37  INSERT_INTRO,
38  KEY_INSERT,
39  KEY_OFF,
40  REPEAT_NOTE,
41  USER_EDIT_NOTE,
42} from "../prompts.ts";
43
44const notesRef = { plugin: "claudetto", key: "notes" } as const;
45const pendingRef = { plugin: "claudetto", key: "note" } as const;
46
47/** Writes the state that `next` gives, and tries again when another write landed first. */
48const apply = async <R extends { state: Notes }>(
49  $: EngineInterface,
50  next: (state: Notes) => R,
51): Promise<R> => {
52  for (;;) {
53    const { value, version } = await $.state.get(notesRef);
54    const result = next(value ?? EMPTY);
55    const { isSet } = await $.state.set(notesRef, result.state, {
56      ifVersion: version,
57    });
58    if (isSet) return result;
59  }
60};
61
62const change = ($: EngineInterface, next: (state: Notes) => Notes) =>
63  apply($, (state) => ({ state: next(state) }));
64
65/** Shows `note`, if the counters allow it. */
66const raise = ($: EngineInterface, note: Note) =>
67  change($, (state) => admit(state, note));
68
69/** Shows the note that `finish` left in `$.state`, once. */
70const takePending = async ($: EngineInterface): Promise<void> => {
71  const { value } = await $.state.get(pendingRef);
72  if (!value) return;
73  await $.state.set(pendingRef, null);
74  await raise($, value);
75};
76
77/** The settings file of the project that holds `rule`, or a generic name. */
78const settingsOf = async (
79  $: EngineInterface,
80  rule: string,
81): Promise<string> => {
82  const root = await $.session.root().catch(() => "");
83  for (const file of [PROJECT_LOCAL_SETTINGS, PROJECT_SETTINGS]) {
84    const text = await $.fs.read(`${root}/${file}`).catch(() => "");
85    if (text.includes(rule)) return file;
86  }
87  return "your settings files";
88};
89
90type CheckHook = MatchedHook<
91  "tool.check",
92  {
93    readonly tool: readonly [
94      "Bash",
95      "Edit",
96      "Glob",
97      "Grep",
98      "Read",
99      "WebFetch",
100      "Write",
101    ];
102  }
103>;
104
105/** The path of an `Edit` or `Write` that has a change which no session made, as `git status` shows it, else `null`. The ledger is the file that `hygiene` writes. */
106const userEdited = async (
107  $: EngineInterface,
108  path: string,
109): Promise<string | null> => {
110  const dir = path.replace(/\/[^/]*$/, "") || "/";
111  const run = (args: string[]) =>
112    $.process.run(["git", "-C", dir, ...args], { timeoutMs: GIT_TIMEOUT_MS });
113  const [status, top] = await Promise.all([
114    run(["status", "--porcelain", "-z", "--untracked-files=all"]),
115    run(["rev-parse", "--show-toplevel"]),
116  ]);
117  if (status.exitCode !== 0 || top.exitCode !== 0) return null;
118  const root = top.stdout.trim();
119  const rel = path.slice(root.length + 1);
120  if (!path.startsWith(`${root}/`) || !statusPaths(status.stdout).includes(rel))
121    return null;
122  const hash = await $.fs.read(path).then(hash64, () => GONE);
123  if (hash === GONE) return null;
124  const tmp = (
125    (await $.env.get("TMPDIR").catch(() => undefined)) || "/tmp"
126  ).replace(/(.)\/+$/, "$1");
127  const file = ledgerPath(tmp, await $.session.root());
128  const ledger = ((await $.fs.exists(file)) && (await $.fs.read(file))) || "";
129  return madeBy(parseLedger(ledger), path, hash).length === 0 ? rel : null;
130};
131
132/** The rule "user edit": an allowed `Edit` or `Write` of a file with a change of the user sets a note. A failure of the rule sets none. */
133const checkUserEdit = async (
134  $: EngineInterface,
135  tool: string,
136  input: unknown,
137): Promise<void> => {
138  const path = (input as { file_path?: unknown } | null)?.file_path;
139  if ((tool !== "Edit" && tool !== "Write") || typeof path !== "string") return;
140  const rel = await userEdited($, path).catch(() => null);
141  if (rel) await raise($, USER_EDIT_NOTE({ path: rel }));
142};
143
144/** `tool.check`: counts each deny and ask by rule. The third one of a rule sets a note. */
145export const onCheck: CheckHook = async ($, e, next) => {
146  const verdict = await next(e);
147  if (verdict.decision === "allow") await checkUserEdit($, e.tool, e.input);
148  const rule = verdict.rule ?? verdict.reason;
149  if (verdict.decision === "allow" || !rule) return verdict;
150  const { count } = await apply($, (state) => countAsk(state, rule));
151  if (count === REPEAT_MIN)
152    await raise(
153      $,
154      REPEAT_NOTE({
155        kind: verdict.decision === "deny" ? "deny" : "ask",
156        rule,
157        count,
158        where: await settingsOf($, rule),
159      }),
160    );
161  return verdict;
162};
163
164/** The command of a `Bash` input when it is a check, else `null`. */
165const checkOf = (input: unknown): string | null => {
166  const command = (input as { command?: unknown } | null)?.command;
167  const text = typeof command === "string" ? command.trim() : "";
168  return isCheck(text) ? text : null;
169};
170
171/** `classic.PostToolUseFailure` of `Bash`: a failing check command in a row with the same failing test sets a note on the third. */
172export const onFail: Handler<"classic.PostToolUseFailure"> = async (
173  $,
174  e,
175  next,
176) => {
177  const answer = await next(e);
178  const command = checkOf(e.tool_input);
179  if (!command) return answer;
180  const sign = signOf(e.error);
181  const { count } = await apply($, (state) => countFail(state, command, sign));
182  if (count === REPEAT_MIN)
183    await raise($, CHECK_NOTE({ command, count, sign }));
184  return answer;
185};
186
187/** `classic.PostToolUse` of `Bash`: a check that passes ends its streak of failures. */
188export const onPass: Handler<"classic.PostToolUse"> = async ($, e, next) => {
189  const answer = await next(e);
190  const command = checkOf(e.tool_input);
191  if (command) await change($, (state) => clearFail(state, command));
192  return answer;
193};
194
195/** `classic.Stop`: a background task in flight for 30 minutes sets a note, once for each task. Tasks that are gone leave the record. */
196export const onStop: Handler<"classic.Stop"> = async ($, e, next) => {
197  const answer = await next(e);
198  if (e.agent_id !== undefined) return answer;
199  const now = await $.clock.now().catch(() => null);
200  if (now === null) return answer;
201  const tasks: Task[] = e.background_tasks ?? [];
202  const { late, minutes } = await apply($, (state) => track(state, tasks, now));
203  if (late) await raise($, BACKGROUND_NOTE({ what: whatOf(late), minutes }));
204  return answer;
205};
206
207/** `turn.step` of the main loop: the `learn:` block sets a note, and it is not shown or recorded. A non-text chunk ends the text before it. */
208export const onStep: Handler<"turn.step"> = async function* ($, e, next) {
209  if (e.agentId !== undefined) return yield* next(e);
210  const stream = next(e);
211  let filter = learnFilter();
212  let index = 0;
213  for (;;) {
214    const step = await stream.next();
215    if (step.done) {
216      const { note } = parseLearn(step.value.answer);
217      if (note) await raise($, note);
218      return step.value;
219    }
220    const c = step.value;
221    if (c.kind === "text") {
222      index = c.index;
223      const text = filter.push(c.text);
224      if (text) yield { ...c, text };
225      continue;
226    }
227    const text = filter.end();
228    filter = learnFilter();
229    if (text) yield { kind: "text", index, text };
230    yield c;
231  }
232};
233
234/** `turn.complete` of the main loop: a note that `finish` left shows. */
235export const onTurnComplete: Handler<"turn.complete"> = async ($, e, next) => {
236  const answer = await next(e);
237  if (e.agentId === undefined) await takePending($);
238  return answer;
239};
240
241/** `turn.start` of the main loop with a typed prompt: the shown note stays one prompt less, and a note that `finish` left shows. */
242export const onPrompt: Handler<"turn.start"> = async ($, e, next) => {
243  const answer = await next(e);
244  if (e.agentId !== undefined || e.text === "") return answer;
245  await change($, typedPast);
246  await takePending($);
247  return answer;
248};
249
250/** The press of the insert key: the note and its explanation go into the prompt box. */
251const insert = async ($: EngineInterface, note: Note): Promise<void> => {
252  await $.prompt.fill({
253    text: `${INSERT_INTRO}\n${note.tag} · ${note.line}\n${note.explain}`,
254    mode: "replace",
255  });
256  await change($, used);
257};
258
259/** `ui.render` of `AbovePrompt`: the shown note with its two keys, or one dim key to turn notes on again. */
260type BandHook = MatchedHook<"ui.render", { readonly component: "AbovePrompt" }>;
261
262export const onBand: BandHook = async ($, e, next) => {
263  if (e.props.hasSurvey) return next(e);
264  const { value: state } = await $.state.get(notesRef);
265  const { Box, Text, Button } = $.ui.resolve(e);
266  const key = (k: string, hotkey: string, label: string, run: () => unknown) =>
267    Button({
268      key: k,
269      hotkey,
270      plain: true,
271      dimColor: k !== "insert",
272      label,
273      onPress: () => void run(),
274    });
275  if (state?.isOff)
276    return key("on", "3", "3: Turn notes on", () =>
277      change($, (s) => ({ ...s, isOff: false })),
278    );
279  const note = state?.shown;
280  if (!note) return next(e);
281  return Box({
282    children: [
283      Text({ children: `${note.tag} · ${note.line}` }),
284      key("insert", "1", KEY_INSERT, () => insert($, note)),
285      key("off", "2", KEY_OFF, () =>
286        change($, (s) => ({ ...s, isOff: true, shown: null })),
287      ),
288    ],
289  });
290};
291
292/** `ui.render` of `AssistantMessage`: the `learn:` block is not drawn. The stored message stays as it is. */
293type MessageHook = MatchedHook<
294  "ui.render",
295  { readonly component: "AssistantMessage" }
296>;
297
298export const onMessage: MessageHook = (_$, e, next) => {
299  const { shown } = parseLearn(e.props.text);
300  return shown === e.props.text
301    ? next(e)
302    : next({ ...e, props: { ...e.props, text: shown } });
303};
304
slices/prompt/adapters/mod.ts 214 lines
1// The mod handlers of the `prompt` slice:
2// `prompt.context` adds the blocks `context_management` (the compaction state), `shell`, `plan` (the `plan` option), and `repo` (X5: branch, latest tag, open change, shell, `grep` engine).
3// `tool.describe` gives each kept tool the shorter claudetto text, and defers the tools of `DEFERRED_TOOLS`.
4// `prompt.attachment` turns the `off` attachments off, and gives the plan mode text a shorter text that names `claudetto:Scout`.
5// `session.compact` adds the compaction line of the `built-in` path.
6// `session.measure` keeps the band of each rate-limit window, and the next `classic.UserPromptSubmit` or `classic.PostToolBatch` adds the usage line once when a band changed.
7
8import type { EngineInterface, PluginOptions } from "claude-code";
9import { isBuiltInCompaction } from "../../../kernel/compaction.ts";
10import type { Handler } from "../../../kernel/slice.ts";
11import { sharesOf } from "../../../kernel/usage.ts";
12import {
13  CONTEXT_BLOCK,
14  CONTEXT_TEXT,
15  compactionState,
16} from "../domain/compaction.ts";
17import { SHELL_BLOCK, shellFacts } from "../domain/environment.ts";
18import {
19  PLAN_BLOCK,
20  planLine,
21  REPO_BLOCK,
22  repoFacts,
23  usesUgrep,
24} from "../domain/facts.ts";
25import { planModeText } from "../domain/plan-mode.ts";
26import { DEFERRED_TOOLS, TOOL_TEXTS } from "../domain/tools.ts";
27import { nextUsage } from "../domain/usage.ts";
28import { FACT_CHANGES_MAX, FACT_RUN_TIMEOUT_MS } from "../limits.ts";
29import { PRECOMPACT_LINE } from "../prompts.ts";
30
31/** The `userConfig` values of the plugin, which the generated `register` gives to `keepOptions`. */
32const kept: { options: PluginOptions } = { options: {} };
33
34export const keepOptions = (options: PluginOptions): void => {
35  kept.options = options;
36};
37
38/** The value of an env read, or `""` when it is unset or the read fails. */
39const envValue = (read: Promise<string | undefined>): Promise<string> =>
40  read.then(
41    (value) => value ?? "",
42    () => "",
43  );
44
45/** The output of a run, or `""` when it fails. */
46const output = (
47  run: Promise<{ exitCode: number; stdout: string }>,
48): Promise<string> =>
49  run.then(
50    (r) => (r.exitCode === 0 ? r.stdout : ""),
51    () => "",
52  );
53
54/** The shell facts of this process: `uname -s` runs one time, and a failed run gives no platform. */
55let facts: Promise<string> | undefined;
56const shellFactsOf = ($: EngineInterface): Promise<string> => {
57  facts ??= Promise.all([
58    output($.process.run(["uname", "-s"])),
59    envValue($.env.get("SHELL")),
60  ]).then(([platform, shell]) => shellFacts(platform, shell));
61  return facts;
62};
63
64/** The repository facts of this process: the runs go one time, and a failed run gives an empty fact, because a throw here drops the `context_management` block too. */
65let repo: Promise<string> | undefined;
66const repoFactsOf = async ($: EngineInterface): Promise<string> => {
67  repo ??= (async () => {
68    const cwd = await $.session.cwd().catch(() => "");
69    if (cwd === "") return "";
70    const out = (argv: string[]) =>
71      output($.process.run(argv, { cwd, timeoutMs: FACT_RUN_TIMEOUT_MS }));
72    const dir = `${cwd}/openspec/changes`;
73    const names = (await $.fs.list(dir).catch(() => []))
74      .filter((f) => f.kind === "dir" && f.name !== "archive")
75      .slice(0, FACT_CHANGES_MAX)
76      .map((f) => f.name);
77    const [branch, tag, shell, execPath, entrypoint, toolNames, changes] =
78      await Promise.all([
79        out(["git", "branch", "--show-current"]),
80        out(["git", "describe", "--tags", "--abbrev=0"]),
81        envValue($.env.get("SHELL")),
82        envValue($.env.get("CLAUDE_CODE_EXECPATH")),
83        envValue($.env.get("CLAUDE_CODE_ENTRYPOINT")),
84        // A failed read counts as `Grep` on, so the block never claims `ugrep` from a guess.
85        $.tool.list().then(
86          (tools) => tools.map((t) => t.name),
87          () => ["Grep"],
88        ),
89        Promise.all(
90          names.map(async (name) => ({
91            name,
92            tasks: await $.fs.read(`${dir}/${name}/tasks.md`).catch(() => null),
93          })),
94        ),
95      ]);
96    const ugrep = usesUgrep({ execPath, toolNames, entrypoint });
97    return repoFacts({ branch, tag, changes, shell, ugrep });
98  })();
99  return repo;
100};
101
102// A block of the same name from below is replaced, so each block is there one time.
103export const onContext: Handler<"prompt.context"> = async ($, e, next) => {
104  const { blocks, ...rest } = await next(e);
105  const state = compactionState(
106    kept.options,
107    await $.env.get("DISABLE_COMPACT"),
108  );
109  const shell = await shellFactsOf($);
110  const plan = planLine(kept.options["plan"]);
111  const facts = await repoFactsOf($);
112  const own = [
113    { name: CONTEXT_BLOCK, text: CONTEXT_TEXT[state] },
114    { name: PLAN_BLOCK, text: plan },
115    { name: REPO_BLOCK, text: facts },
116    { name: SHELL_BLOCK, text: shell },
117  ];
118  return {
119    ...rest,
120    blocks: [
121      ...blocks.filter((b) => !own.some((o) => o.name === b.name)),
122      ...own.filter((b) => b.text),
123    ],
124  };
125};
126
127// A tool text is ours when it is shorter than the engine text, so a shorter text of a new build stays.
128// The `agents` slice and the `Agent(...)` deny rules of the settings drop the built-in agents.
129export const onToolDescribe: Handler<"tool.describe"> = async (_$, e, next) => {
130  const answer = await next(e);
131  const text = TOOL_TEXTS[e.tool];
132  const shorter =
133    text !== undefined && text.length < answer.description.length
134      ? { ...answer, description: text }
135      : answer;
136  return DEFERRED_TOOLS.includes(e.tool)
137    ? { ...shorter, isDeferred: true }
138    : shorter;
139};
140
141/** On the `built-in` compaction path, the summarizer gets `PRECOMPACT_LINE` after the text of `/compact`. */
142export const onCompact: Handler<"session.compact"> = (_$, e, next) =>
143  next(
144    isBuiltInCompaction(kept.options)
145      ? {
146          ...e,
147          instructions: [e.instructions, PRECOMPACT_LINE]
148            .filter(Boolean)
149            .join("\n\n"),
150        }
151      : e,
152  );
153
154/** The usage value of `$.state`: the last band of each window, the first share, and the line that waits for the next request. */
155const usageRef = { plugin: "claudetto", key: "usage" } as const;
156
157/** `session.measure`: keeps the bands, and holds one usage line when a band changed. It observes, so a fault changes nothing. */
158export const onMeasure: Handler<"session.measure"> = async ($, e, next) => {
159  const answer = await next(e);
160  try {
161    const { value } = await $.state.get(usageRef);
162    const usage = nextUsage(value, sharesOf(e.rateLimits));
163    if (usage !== null) await $.state.set(usageRef, usage);
164  } catch {
165    // A fault of the state leaves the line out.
166  }
167  return answer;
168};
169
170/** Adds the held usage line to `additionalContext` and clears it, so the line goes out once. */
171const addLine = async <A extends { additionalContext?: string[] }>(
172  $: EngineInterface,
173  answer: A,
174): Promise<A> => {
175  try {
176    const { value } = await $.state.get(usageRef);
177    if (value?.line === undefined) return answer;
178    const { line, ...rest } = value;
179    await $.state.set(usageRef, rest);
180    return {
181      ...answer,
182      additionalContext: [...(answer.additionalContext ?? []), line],
183    };
184  } catch {
185    return answer;
186  }
187};
188
189/** `classic.UserPromptSubmit`: adds the held usage line. */
190export const onPrompt: Handler<"classic.UserPromptSubmit"> = async (
191  $,
192  e,
193  next,
194) => addLine($, await next(e));
195
196/** `classic.PostToolBatch`: adds the held usage line before the next request of a turn. */
197export const onBatch: Handler<"classic.PostToolBatch"> = async ($, e, next) =>
198  addLine($, await next(e));
199
200// The `off` verdicts of the attachments: the list is in `domain/off.ts`, and the filter in `hooks/register.ts` narrows the event to it and to `REWRITTEN_ATTACHMENTS`.
201
202/** An attachment of an `off` kind is left out of the request, and the plan mode text is a shorter text that points a search over many files at `claudetto:Scout`. */
203export const onAttachment = async <E extends { type: string; text: string }>(
204  _$: unknown,
205  e: E,
206  next: (e: E) => Promise<{ text: string | null }>,
207): Promise<{ text: string | null }> => {
208  if (e.type !== "plan_mode") return { text: null };
209  const answer = await next(e);
210  return answer.text === null
211    ? answer
212    : { ...answer, text: planModeText(answer.text) };
213};
214
kernel/slice.ts 14 lines
1// The shared types of the hooks of a slice. No runtime import.
2
3import type { EventName, Events } from "claude-code";
4
5/** The hook signature of the event `E`, as the mod API declares it. */
6export type Handler<E extends EventName = EventName> = Events[E];
7
8/** One segment of the status line, on the row `row`. */
9export interface StatusSegment {
10  readonly id: string;
11  readonly row: number;
12  readonly render: (status: unknown) => string | null;
13}
14
kernel/jev.ts 121 lines
1// The call of the TypeSafe API that the CLI `claudetto-decide` and the mod share: the model, the retry on 429 and 529, and the reading of a yes or no answer.
2// No `node:` import and no `Bun`, for the hooks module. A function here gets the `fetch` and the `sleep` of its caller, and never `$`.
3
4import {
5  ATTEMPT_TIMEOUT_MS,
6  BACKOFF_MS,
7  ERROR_BODY_MAX,
8  TRIES,
9} from "./budget.ts";
10
11export const JEV_API = "https://api.typesafe.ai/v1/systemone";
12export const JEV_MODEL = "jev-latest";
13
14/** A reply of the API with an error status. */
15export class ApiError extends Error {
16  readonly status: number;
17
18  constructor(status: number, message: string) {
19    super(message);
20    this.status = status;
21  }
22}
23
24/** The part of a reply that the retry reads. */
25export interface JevReply {
26  readonly status: number;
27  readonly ok: boolean;
28  readonly text: string;
29}
30
31/** `$.http` in the mod. */
32export interface JevHttp {
33  readonly fetch: (
34    url: string,
35    init: {
36      readonly method: string;
37      readonly headers: Record<string, string>;
38      readonly body: string;
39    },
40  ) => Promise<JevReply>;
41}
42
43/** `$.clock` in the mod. */
44export interface JevClock {
45  readonly sleep: (
46    ms: number,
47    options?: { readonly signal: AbortSignal },
48  ) => Promise<unknown>;
49}
50
51/** The request body for a state and named questions. */
52export const jevBody = (state: string, questions: object) => ({
53  state,
54  model: JEV_MODEL,
55  questions,
56});
57
58/** The JSON of the first `ok` reply of `send`. A 429 or a 529 waits `BACKOFF_MS`, doubled for each retry, up to `TRIES` tries. */
59export const retried = async (
60  send: () => Promise<JevReply>,
61  sleep: (ms: number) => Promise<unknown>,
62): Promise<unknown> => {
63  for (let i = 0; ; i++) {
64    const res = await send();
65    if (res.ok) return JSON.parse(res.text);
66    if ((res.status === 429 || res.status === 529) && i < TRIES - 1) {
67      await sleep(BACKOFF_MS * 2 ** i);
68      continue;
69    }
70    const detail = res.text.slice(0, ERROR_BODY_MAX);
71    throw new ApiError(res.status, `TypeSafe API ${res.status}: ${detail}`);
72  }
73};
74
75/** `work`, or a throw after `ATTEMPT_TIMEOUT_MS`, because `HttpInit` has no timeout. The timer stops when `work` settles. */
76const bounded = async <T>(work: Promise<T>, clock: JevClock): Promise<T> => {
77  const stop = new AbortController();
78  try {
79    return await Promise.race([
80      work,
81      clock.sleep(ATTEMPT_TIMEOUT_MS, { signal: stop.signal }).then(() => {
82        throw new Error("The TypeSafe API did not answer in time.");
83      }),
84    ]);
85  } finally {
86    stop.abort();
87  }
88};
89
90/** The `answers` of the reply to `body`. The key goes only in the `authorization` header. It throws on a failure and on a timeout. `http` and `clock` are `$.http` and `$.clock` in the mod. */
91export const postJev = async (
92  key: string,
93  body: object,
94  http: JevHttp,
95  clock: JevClock,
96): Promise<unknown> => {
97  const json = JSON.stringify(body);
98  const headers = {
99    authorization: `Bearer ${key}`,
100    "content-type": "application/json",
101  };
102  const reply = (await retried(
103    () =>
104      bounded(
105        http.fetch(JEV_API, { method: "POST", headers, body: json }),
106        clock,
107      ),
108    (ms) => clock.sleep(ms),
109  )) as { answers?: unknown };
110  return reply.answers;
111};
112
113/** P(yes) of the yes or no answer `name` in `answers`, or `undefined` when the answer is missing or not a probability. */
114export const noulOf = (answers: unknown, name: string): number | undefined => {
115  const answer = (
116    answers as Record<string, { noul?: unknown } | undefined> | null
117  )?.[name];
118  const p = answer?.noul;
119  return typeof p === "number" && p >= 0 && p <= 1 ? p : undefined;
120};
121