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…

claudettoclaudetto 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).
claudetto is a marketplace with one core plugin and two add-on plugins. Each add-on works without the core plugin.
| Plugin | What it gives |
|---|---|
claudetto | The 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-browser | The browse skill, which drives a real browser with agent-browser. |
claudetto-docs | The find-docs skill, with current library docs from the CLI ctx7. |
# --- 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:
2.1.309 or later. claudetto is tested only on 2.1.309.git.1.4.3 or later on PATH, for the status line, the setup command, and the Jev pick of AskUserQuestion.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.
| Part | Effect |
|---|---|
| Compaction | The 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. |
| Memory | autoMemoryEnabled is false. |
| Prompt | CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT is 1, and the prompt slice gives one tailored text for the short prompt. |
| Output style | outputStyle is claudetto:Concise. Your own outputStyle setting can change it. |
| Permissions | Reads of secret files and destructive commands such as rm -rf / are denied. A force push asks. |
| Sandbox | Bash runs in the sandbox. Its network reaches GitHub and the package registries only. |
| Guard | Asks before a git push that deletes a remote branch, and before a docker command that runs or creates a container. |
| Ground | Denies a Write or Edit of a plan file that cites a file:line or a convention that the source does not hold. |
| Status line | The 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. |
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.
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.
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.
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.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
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.
hooks/register.ts 165 lines1import 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 );
165slices/agents/adapters/mod.ts 24 lines1// 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 });
24slices/decide/adapters/mod.ts 69 lines1// 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};
69slices/finish/adapters/mod.ts 282 lines1// 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 }));
282slices/ground/adapters/mod.ts 103 lines1// 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};
103slices/guard/adapters/mod.ts 356 lines1// 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});
356slices/handoff/adapters/mod.ts 325 lines1// 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};
325slices/hygiene/adapters/mod.ts 315 lines1// 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};
315slices/notes/adapters/mod.ts 304 lines1// 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};
304slices/prompt/adapters/mod.ts 214 lines1// 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};
214kernel/slice.ts 14 lines1// 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}
14kernel/jev.ts 121 lines1// 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