Adds a lean output style, six role agents, and a status line command. Adds hooks that ask before work on a repo with an AI policy, check edits and `git` calls…

dotclaude changes Claude Code through its official extension points: settings, hooks, skills, agents, 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.
dotclaude is a marketplace with one core plugin and seven add-on plugins. Each add-on works without the core plugin.
| Plugin | What it gives |
|---|---|
dotclaude | The core: the settings snippet, the status line, the output style, six agents, ten skills, the guard and asks hooks, the spec store, and the second_opinion tool. |
dotclaude-codegraph | The code graph of a project, from the CLI codegraph. |
dotclaude-browser | The browse skill, and a headless browser search for each WebSearch call. |
dotclaude-docs | The docs skill, with current library docs from the CLI ctx7. |
dotclaude-sessions | The start-cost and usage skills, which measure the session start and the usage. |
dotclaude-lab | Tools for plugin authors: the sandbox, capture, schema, and eval compare. |
dcc-plugin-agents-md | Loads AGENTS.md files where Claude Code loads CLAUDE.md, in place of the built-in cc-plugin-agents-md. |
dcc-plugin-you-should-know | Opt-in. Shows one thing to know before Claude changes something, in place of the built-in cc-plugin-you-should-know. |
/plugin marketplace add xsyetopz/dotclaude
/plugin install dotclaude@dotclaude
Then run /dotclaude: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.296 or later. dotclaude is tested only on 2.1.296.git.1.4.3 or later on PATH, for the status line, the setup command, and the second_opinion tool.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 | Off. autoCompactEnabled is false, and DISABLE_COMPACT is 1, which also turns off /compact. |
| Context window | CLAUDE_CODE_MAX_CONTEXT_TOKENS and CLAUDE_CODE_AUTO_COMPACT_WINDOW are 300000. A session that reaches the limit stops, and you run /clear. |
| Memory | autoMemoryEnabled is false. The spec store holds the state instead. |
| Output style | outputStyle is dotclaude:Concise. Your own outputStyle setting can change it. |
| Permissions | Normal work passes. Work like sudo asks. Destructive actions are denied by autoMode.hard_deny. Reads of secret files are denied. |
| Sandbox | Bash runs in the sandbox. Its network reaches GitHub and the package registries only. |
| Guard | Asks before the first call of a session to a GitHub repository of another owner that has an AI policy file. |
| Asks | Asks only when a change weakens a check. It never asks for a read. |
| Status line | The folder, git, model, effort, context against the window, cost, the 5-hour, 7-day, and spend limits, and the progress of a spec change. The percent turns yellow at 75% and red at 90%. |
| Handoff | With the default manual value of mainHandover, a hint at 60% of the window tells you to run /dotclaude:handoff. |
| Checks | The CLI dotclaude-run-checks runs a batch of check commands, with no model call. |
The core plugin has one option, mainHandover. Its values are auto, manual, and off, and the default is manual.
| Agent | Model | Effort |
|---|---|---|
planner, reviewer | claude-opus-5-5 | high |
debugger | claude-sonnet-5-5 | high |
implementer, researcher | claude-sonnet-5-5 | medium |
investigator | claude-haiku-5-5 | medium |
The main session uses claude-opus-5-5, from the settings snippet. The maxEffortLevel setting is max, so /effort can go up to max for a hard check. See Agents.
The ten skills are /dotclaude:handoff, /dotclaude:rules-creator, /dotclaude:setup, and the seven spec phases /dotclaude:specify, /dotclaude:plan, /dotclaude:implement, /dotclaude:review, /dotclaude:merge, /dotclaude:accept, and /dotclaude:archive. See Skills.
Each model call reads the whole context again, so the size of the context sets the cost of each turn.
/dotclaude:specify <id>, or run dotclaude-spec specify <id>./clear, run dotclaude-spec status <id>. In the fresh session, run dotclaude-spec next <id>, which prints the brief of the next package.See Context and handoff and Spec store.
The second_opinion tool of the core plugin asks TypeSafe Jev for a pick and a confidence on a decision. Export TYPESAFE_API_KEY before Claude Code starts. Each call sends the data of the decision to the TypeSafe API. With no key, or when Jev gives no answer, a small Claude model answers instead. The result says that the answer is not from Jev. See Second opinion.
Install each add-on that you need.
/plugin install dotclaude-codegraph@dotclaude
/plugin install dotclaude-browser@dotclaude
/plugin install dotclaude-docs@dotclaude
/plugin install dotclaude-sessions@dotclaude
/plugin install dotclaude-lab@dotclaude
/plugin install dcc-plugin-agents-md@dotclaude
/plugin install dcc-plugin-you-should-know@dotclaude
dotclaude-codegraph needs the CLI codegraph on PATH, and an index that codegraph init -y builds in the project. It syncs the index at session start, and it adds no MCP server. See Code graph.dotclaude-browser uses agent-browser. Its backend option can select CloakBrowser, for sites with bot detection. See Browser.dotclaude-docs needs no key. See Library docs.dotclaude-sessions measures the start of a session and the usage of each run. See Sessions.dotclaude-lab is for plugin authors, and it works alone. See Lab.dcc-plugin-agents-md replaces the built-in cc-plugin-agents-md, so turn the built-in plugin off. See AGENTS.md.dcc-plugin-you-should-know is opt-in, because each check makes one Haiku call, on an API key or a subscription. It replaces the built-in cc-plugin-you-should-know, so turn the built-in plugin off. See You should know.claude plugin marketplace update dotclaude
claude plugin update dotclaude@dotclaude
Run the claude plugin update line again for each add-on that you installed, with its name in place of dotclaude. Then restart Claude Code, read the changelog, and run /dotclaude: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/dotclaude/plugins/dotclaude
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.
MIT.
hooks/register.ts 78 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/*/slice.ts`. Edit the source and run `just generate`.
2import type { On, PluginOptions } from "claude-code";
3import { onCheck as h0 } from "../slices/asks/adapters/mod.ts";
4import { onToolCall as h1 } from "../slices/attribution/adapters/mod.ts";
5import {
6 onAskUserQuestion as h2,
7 onSecondOpinion as h3,
8 onSessionStart as h4,
9} from "../slices/decide/adapters/mod.ts";
10import {
11 onBashCall as h5,
12 onCheck as h6,
13} from "../slices/guard/adapters/mod.ts";
14import {
15 keepOptions as h7,
16 onSkill as h8,
17 onToolCall as h9,
18 onTurnComplete as h10,
19 onTurnStep as h11,
20} from "../slices/handoff/adapters/mod.ts";
21import {
22 onCompose as h12,
23 onSection as h13,
24 onSpawn as h14,
25} from "../slices/prompt/adapters/mod.ts";
26import { onToolCall as h15 } from "../slices/sembr/adapters/mod.ts";
27import { onSessionStart as h16 } from "../slices/setup/adapters/mod.ts";
28import { onSpawn as h17 } from "../slices/spec/adapters/mod.ts";
29import { onSessionStart as h18 } from "../slices/stamp/adapters/mod.ts";
30import {
31 onAgentOffer as h19,
32 onToolDescribe as h20,
33} from "../slices/surface/adapters/mod.ts";
34
35export const register = (on: On, options: PluginOptions): void => {
36 h7(options);
37 // eval: agent.offer surface
38 on("agent.offer", h19);
39 // eval: agent.spawn spec
40 on("agent.spawn", { subagentType: /(?:^|:)implementer$/ }, h17);
41 // eval: agent.spawn prompt
42 on("agent.spawn", h14);
43 // eval: prompt.compose prompt
44 on("prompt.compose", h12);
45 // eval: prompt.section prompt
46 on("prompt.section", h13);
47 // eval: session.start decide
48 on("session.start", { isInteractive: [true, false] }, h4);
49 // eval: session.start setup
50 on("session.start", h16);
51 // eval: session.start stamp
52 on("session.start", { isInteractive: [true, false] }, h18);
53 // eval: skill.prompt handoff
54 on("skill.prompt", { skill: /(?:^|:)handoff$/ }, h8);
55 // eval: tool.call handoff
56 on("tool.call", { tool: /./ }, h9);
57 // eval: tool.call attribution
58 on("tool.call", { tool: ["Bash"] }, h1);
59 // eval: tool.call decide
60 on("tool.call", { tool: ["AskUserQuestion"] }, h2);
61 // eval: tool.call sembr
62 on("tool.call", { tool: ["Write", "Edit", "Bash"] }, h15);
63 // eval: tool.call guard
64 on("tool.call", h5);
65 // eval: tool.call decide
66 on("tool.call", { tool: /^mcp__dotclaude__second_opinion$/ }, h3);
67 // eval: tool.check asks
68 on("tool.check", { tool: ["Bash", "Write", "Edit", "MultiEdit"] }, h0);
69 // eval: tool.check guard
70 on("tool.check", h6);
71 // eval: tool.describe surface
72 on("tool.describe", { tool: ["Agent", "Bash", "Read", "ListAgents"] }, h20);
73 // eval: turn.complete handoff
74 on("turn.complete", h10);
75 // eval: turn.step handoff
76 on("turn.step", { agentId: /./ }, h11);
77};
78slices/asks/adapters/mod.ts 51 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/asks/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handler of the `asks` slice:
3// `tool.check` asks before a `Bash`, `Write`, `Edit`, or `MultiEdit` call that the checks in `domain/` flag (design D34).
4//
5// The handler only asks, and it keeps a deny of the engine, so it never weakens a rule of the user.
6// The hook has no catch-all on purpose.
7// When it throws after `next` resolved, Claude Code logs the failure and the verdict of the engine stands.
8// When it throws before `next`, for example on a file read that rejects, Claude Code skips the hook and the call runs.
9// A `catch` here names the error that it expects, and the others reach that log.
10//
11// `claude plugin validate` follows `$` only into a function of one file,
12// and each `$.env.get` call needs a literal name.
13// For this reason, each function that touches `$` is here, and the rules are in `domain/`.
14
15import type { EngineInterface } from "claude-code";
16import type { Handler } from "../../../kernel/slice.ts";
17import { askReason } from "../domain/check.ts";
18import type { Payload } from "../domain/text.ts";
19import { FILE_MAX_BYTES } from "../limits.ts";
20
21/**
22 * The text of `file` when it is a regular file of at most `max` bytes, else null.
23 * An absent file, a folder, and a link give null, so the check reads nothing.
24 */
25const fileText = async ($: EngineInterface, file: string, max: number) => {
26 if (!(await $.fs.exists(file))) return null;
27 const stat = await $.fs.stat(file);
28 if (stat.kind !== "file" || stat.size > max) return null;
29 return $.fs.read(file);
30};
31
32// The ask reason of `asks` comes first, then the ask reason of `next`, so the ask of another slice stays.
33export const onCheck: Handler<"tool.check"> = async ($, e, next) => {
34 const verdict = await next(e);
35 if (verdict?.decision === "deny") return verdict;
36 const cwd = await $.session.cwd();
37 const root = (await $.env.get("CLAUDE_PROJECT_DIR")) || cwd;
38 const home = (await $.env.get("HOME")) || undefined;
39 // The input of these tools has the fields that the checks read, as `Payload` gives them.
40 const own = await askReason(
41 { cwd, tool_name: e.tool, tool_input: e.input as Payload["tool_input"] },
42 { read: (file) => fileText($, file, FILE_MAX_BYTES), root, home },
43 );
44 if (!own) return verdict;
45 const reason =
46 verdict?.decision === "ask" && verdict.reason
47 ? `${own}\n\n${verdict.reason}`
48 : own;
49 return { decision: "ask", reason };
50};
51slices/attribution/adapters/mod.ts 25 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/attribution/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handler of the `attribution` slice: the `tool.call` rewrite of a `git commit` and a `gh pr create` on `Bash` (design D39).
3// The model comes from `$.session.model()` for the main loop, and a subagent call names `Claude`,
4// because the transcript of a subagent is not read (evidence `attribution.md`).
5// A command with no commit and no pull request reads no port, so it passes on at once.
6// The rules are in `domain/`, which imports no port.
7
8import type { MatchedHook } from "claude-code";
9import { attributeCommand, needsAttribution } from "../domain/call.ts";
10import { linesOf } from "../domain/lines.ts";
11
12/** The hook for `Bash`, with `e` narrowed to it. */
13export type AttributionHook = MatchedHook<
14 "tool.call",
15 { readonly tool: readonly ["Bash"] }
16>;
17
18export const onToolCall: AttributionHook = async ($, e, next) => {
19 if (!needsAttribution(e.command)) return next(e);
20 const model = e.agentId === undefined ? await $.session.model() : undefined;
21 const lines = linesOf(await $.settings.read(), model);
22 const command = attributeCommand(e.command, lines);
23 return next(command === undefined ? e : { ...e, command });
24};
25slices/decide/adapters/mod.ts 99 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/decide/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handlers of the `decide` slice.
3// `session.start` registers the tool at each start, with or without a key, so the fallback is reachable with no key.
4// `tool.call` serves the tool: it tries Jev when the key is set, and the fallback model when Jev gives no answer.
5// A second `tool.call` handler adds the pick of Jev to each `AskUserQuestion` question that facts decide.
6//
7// `claude plugin validate` follows `$` only into a function of one file,
8// and each `$.env.get` call needs a literal name.
9// For this reason, each function that touches `$` is here, and the rules are in `domain/` and in the adapters.
10
11import type { EngineInterface, MatchedHook } from "claude-code";
12import type { Handler } from "../../../kernel/slice.ts";
13import type { CliCall } from "../domain/request.ts";
14import { parseInput } from "../domain/request.ts";
15import { route } from "../domain/route.ts";
16import { resultText } from "../domain/text.ts";
17import { toolSpec } from "../domain/tool.ts";
18import {
19 FALLBACK_MAX_TOKENS,
20 FALLBACK_MODEL,
21 FALLBACK_TIMEOUT_MS,
22 JEV_TIMEOUT_MS,
23} from "../limits.ts";
24import { pickQuestions } from "./ask.ts";
25import { type Completion, createFallback } from "./fallback.ts";
26import { type CliRun, createJev } from "./jev.ts";
27
28/** Runs the CLI `dotclaude-decide` with the key in its environment, never in the arguments. */
29const runCli = async (
30 $: EngineInterface,
31 key: string,
32 call: CliCall,
33): Promise<CliRun> => {
34 const run = await $.process.run(
35 [
36 "bun",
37 "--no-env-file",
38 `${$.plugin.root}/bin/dotclaude-decide`,
39 ...call.args,
40 ],
41 {
42 env: { TYPESAFE_API_KEY: key },
43 timeoutMs: JEV_TIMEOUT_MS,
44 ...(call.stdin === undefined ? {} : { stdin: call.stdin }),
45 },
46 );
47 return { exitCode: run.exitCode, stdout: run.stdout, stderr: run.stderr };
48};
49
50/** One completion with the fallback model, which answers for Claude and not for Jev. */
51const complete = async (
52 $: EngineInterface,
53 prompt: { system: string; prompt: string },
54): Promise<Completion> => {
55 const reply = await $.model.complete({
56 model: FALLBACK_MODEL,
57 system: prompt.system,
58 prompt: prompt.prompt,
59 maxTokens: FALLBACK_MAX_TOKENS,
60 effort: "low",
61 timeoutMs: FALLBACK_TIMEOUT_MS,
62 });
63 return reply.isAnswered
64 ? { isAnswered: true, text: reply.text }
65 : { isAnswered: false };
66};
67
68// A failure here reaches the log of Claude Code, and the session goes on with no tool.
69export const onSessionStart: Handler<"session.start"> = async ($, e, next) => {
70 await $.tool.register(toolSpec());
71 return next(e);
72};
73
74export const onSecondOpinion: Handler<"tool.call"> = async ($, e) => {
75 // An MCP tool gets its arguments as the fields of `e`, next to `tool` and `tool_use_id`.
76 const parsed = parseInput(e);
77 if (!parsed.ok) return { result: parsed.error };
78 const key = await $.env.get("TYPESAFE_API_KEY");
79 const jev = key ? createJev((call) => runCli($, key, call)) : undefined;
80 const fallback = createFallback((prompt) => complete($, prompt));
81 return { result: resultText(await route(jev, fallback, parsed.request)) };
82};
83
84/** The hook for `AskUserQuestion`, with `e` narrowed to that tool by the filter of `slice.ts`. */
85export type AskHook = MatchedHook<
86 "tool.call",
87 { readonly tool: readonly ["AskUserQuestion"] }
88>;
89
90/** The `AskUserQuestion` call with the pick of Jev in each facts question. With no key, the call passes unchanged. */
91export const onAskUserQuestion: AskHook = async ($, e, next) => {
92 const key = await $.env.get("TYPESAFE_API_KEY");
93 if (!key) return next(e);
94 const questions = await pickQuestions(e.questions, (call) =>
95 runCli($, key, call),
96 );
97 return next(questions === undefined ? e : { ...e, questions });
98};
99slices/guard/adapters/mod.ts 162 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/guard/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handlers of the `guard` slice:
3// `tool.call` on `Bash` denies a destructive command (the floor of `domain/guard.ts`), also in a subagent's call,
4// and denies a command that only prints a file and names the `Read` call (design D22),
5// and `tool.check` asks before the first call of a session that reaches a GitHub repository of another owner with an AI policy.
6//
7// The guard only asks, and it keeps a deny of the engine, so it never weakens a rule of the user.
8// The hook has no catch-all on purpose.
9// When it throws after `next` resolved, Claude Code logs the failure and the verdict of the engine stands.
10// When it throws before `next`, Claude Code skips the hook and the call runs.
11// A `catch` here names the error that it expects, and the others reach that log.
12//
13// `claude plugin validate` follows `$` only into a function of one file,
14// so each function that touches `$` is here, and the rules are in `domain/`.
15
16import type { EngineInterface } from "claude-code";
17import { POLICY_TIMEOUT_MS } from "../../../kernel/budget.ts";
18import type { Handler } from "../../../kernel/slice.ts";
19import { lineCount } from "../../../kernel/text.ts";
20import { type Count, printReason } from "../domain/deny.ts";
21import { guardReason } from "../domain/guard.ts";
22import { isAbsPath, normPath } from "../domain/matchers/norm.ts";
23import type { PathKind } from "../domain/matchers/types.ts";
24import {
25 askReason,
26 LOGIN_ARGV,
27 linesOf,
28 ORGS_ARGV,
29 type PolicyRead,
30} from "../domain/owners.ts";
31import { printsOf } from "../domain/reads.ts";
32import { READ_MAX_BYTES } from "../limits.ts";
33
34/**
35 * The stdout of `argv`, or `null` when it exits with a code other than 0.
36 * A run that throws is not caught: Claude Code logs it and the verdict of the engine stands.
37 */
38const output = async ($: EngineInterface, argv: readonly string[]) => {
39 const r = await $.process.run(argv, { timeoutMs: POLICY_TIMEOUT_MS });
40 return r.exitCode === 0 ? r.stdout : null;
41};
42
43// A `gh api` call that exits 1 with `HTTP 404` on stderr names a file that does not exist.
44const readOf = async (
45 $: EngineInterface,
46 argv: readonly string[],
47): Promise<PolicyRead> => {
48 const r = await $.process.run(argv, { timeoutMs: POLICY_TIMEOUT_MS });
49 if (r.exitCode === 0) return { status: "text", text: r.stdout };
50 if (r.exitCode === 1 && r.stderr.includes("HTTP 404")) {
51 return { status: "missing" };
52 }
53 return { status: "failed" };
54};
55
56// The logins of the user and the organizations where the user is an admin.
57// Only a complete answer is kept, so a failed `gh` call is tried again at the next check.
58let owners: string[] | undefined;
59const ownersOf = async ($: EngineInterface) => {
60 if (owners) return owners;
61 const login = await output($, LOGIN_ARGV);
62 const orgs = login === null ? null : await output($, ORGS_ARGV);
63 const found = [...linesOf(login), ...linesOf(orgs)];
64 if (login !== null && orgs !== null && linesOf(login).length) owners = found;
65 return found;
66};
67
68// The repositories of each session whose policy files were read and had no text.
69const checked = new Map<string, Set<string>>();
70
71/**
72 * What a path is for the matchers: `missing` for `ENOENT`, and a folder for any other file system error (fail closed).
73 * A link that leads nowhere, or another kind, reads as a folder, so a matcher does not take it for a file.
74 */
75const kindOf =
76 ($: EngineInterface) =>
77 async (path: string): Promise<PathKind> => {
78 try {
79 const stat = await $.fs.stat(path);
80 return stat.kind === "file" ? "file" : "folder";
81 } catch (err) {
82 return (err as { code?: string }).code === "ENOENT"
83 ? "missing"
84 : "folder";
85 }
86 };
87
88/**
89 * True when the folder `dir` (or the session folder when `dir` is unset, and a relative `dir` joins it) is in the main checkout.
90 * The walk goes up to the first `.git`: a file means a linked worktree, a folder means the main checkout.
91 * A `\` is read as `/`, so a Windows path walks up like a POSIX one, and the walk ends at the drive root.
92 * When no `.git` is found, the folder is not in a repository, and the branch switch stays allowed.
93 */
94const isMainCheckout = async (
95 $: EngineInterface,
96 dir?: string,
97): Promise<boolean> => {
98 const cwd = await $.session.cwd();
99 const start =
100 dir === undefined || isAbsPath(dir) ? (dir ?? cwd) : `${cwd}/${dir}`;
101 let at = normPath(start.replace(/\\/g, "/"));
102 for (;;) {
103 const kind = await kindOf($)(`${at}/.git`);
104 if (kind !== "missing") return kind === "folder";
105 const cut = at.lastIndexOf("/");
106 if (cut < 0) return false;
107 at = at.slice(0, cut);
108 }
109};
110
111/** The line count of a file for the print rule: `Infinity` when it is too large to read. */
112const countOf =
113 ($: EngineInterface): Count =>
114 async (file) => {
115 if (!(await $.fs.exists(file))) return null;
116 const stat = await $.fs.stat(file);
117 if (stat.kind !== "file") return null;
118 if (stat.size > READ_MAX_BYTES) return Infinity;
119 return lineCount(await $.fs.read(file));
120 };
121
122// The destructive floor denies a `Bash` command in every session, and the shared-checkout rule denies more in a subagent (`e.agentId`).
123// A `Bash` command that prints a file, also in one part of a compound command, is denied here, and the `Read` call of the same range runs instead.
124// The `sembr` rewrite of the phase `rewrite` runs before this deny of the phase `check`, so the deny checks the command that would run.
125// The session folder is read only for a command that prints a file, because a relative path needs it.
126export const onBashCall: Handler<"tool.call"> = async ($, e, next) => {
127 if (e.tool !== "Bash") return next(e);
128 const floor = await guardReason(e.command, {
129 kind: kindOf($),
130 isMainCheckout: (dir) => isMainCheckout($, dir),
131 subagent: Boolean(e.agentId),
132 });
133 if (floor) return { deny: floor };
134 if (!printsOf(e.command, "").length) return next(e);
135 const cwd = await $.session.cwd();
136 const deny = await printReason(e.command, cwd, countOf($));
137 return deny ? { deny } : next(e);
138};
139
140// The ask reason of `guard` comes first, then the ask reason of `next`, so the ask of another slice stays.
141export const onCheck: Handler<"tool.check"> = async ($, e, next) => {
142 const verdict = await next(e);
143 if (verdict?.decision === "deny") return verdict;
144 const session = await $.session.id();
145 let seen = checked.get(session);
146 if (!seen) {
147 seen = new Set();
148 checked.set(session, seen);
149 }
150 const own = await askReason(e.tool, e.input, {
151 seen,
152 run: (argv) => readOf($, argv),
153 owners: () => ownersOf($),
154 });
155 if (!own) return verdict;
156 const reason =
157 verdict?.decision === "ask" && verdict.reason
158 ? `${own}\n\n${verdict.reason}`
159 : own;
160 return { decision: "ask", reason };
161};
162slices/handoff/adapters/mod.ts 275 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/handoff/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handlers of the `handoff` slice. Every handler makes no model call, so `requests` is 0.
3//
4// `turn.step` keeps the context of each subagent.
5// `tool.call` adds the report-now note to the result of a subagent that nears a bound.
6// The same `tool.call` handler sets the trigger when a package goes to `done`.
7// `skill.prompt` starts the handoff of `/handoff`.
8// `turn.complete` runs the handoff: it checks the triggers, submits the write prompt,
9// and then, on the next `turn.complete`, runs `/clear` and submits the resume prompt.
10//
11// The slice keeps its flow state in this module, not in `state.json` and not in the store.
12//
13// `claude plugin validate` follows `$` only into a function of one file.
14// For this reason, each function that touches `$` is here, and the rules are in `domain/`.
15
16import type {
17 EngineInterface,
18 PluginOptions,
19 TurnCompleteInput,
20 TurnCompleteResult,
21} from "claude-code";
22import type { Handler } from "../../../kernel/slice.ts";
23import {
24 type AgentState,
25 contextTokens,
26 reportNote,
27} from "../domain/context.ts";
28import {
29 manualHint,
30 REASON_LANE,
31 REASON_NO_FILE,
32 reasonContext,
33 resumeText,
34 writePrompt,
35} from "../domain/text.ts";
36import {
37 overContext,
38 parseHandover,
39 phaseDoneCall,
40} from "../domain/trigger.ts";
41import {
42 CONTEXT_PERCENT,
43 HANDOFF_RETRY_MAX,
44 HANDOFF_WAIT_TURNS,
45 type MainHandover,
46} from "../limits.ts";
47
48/** The state of each subagent by its `agentId`. */
49const agents = new Map<string, AgentState>();
50
51/** The `userConfig` values of the plugin, which the generated `register` gives to `keepOptions`. */
52let options: PluginOptions = {};
53
54/** The reason of a trigger that fired and has no handoff yet, or undefined. */
55let wanted: string | undefined;
56
57/**
58 * The handoff files that this handoff asked Claude to write, and how many turns ended since, or undefined.
59 * A retry adds a path, so a file at any of these paths counts as the written handoff.
60 */
61let pending: { readonly files: readonly string[]; waited: number } | undefined;
62
63/** The state of one trigger episode, which ends when no trigger is active or a handoff runs. */
64type Episode = {
65 /** The kinds of trigger whose `manual` hint went to the user. */
66 hinted: Set<string>;
67 /** The times that the write prompt went out again. */
68 retries: number;
69 /** Whether the handoff of this episode ended with no resume, so no handoff starts again. */
70 over: boolean;
71};
72
73const newEpisode = (): Episode => ({
74 hinted: new Set(),
75 retries: 0,
76 over: false,
77});
78
79let episode = newEpisode();
80
81/** Keeps the `userConfig` values of the plugin for the handlers. */
82export const keepOptions = (values: PluginOptions): void => {
83 options = values;
84};
85
86/** Forgets the flow state. A test calls it between runs. */
87export const resetState = (): void => {
88 agents.clear();
89 wanted = undefined;
90 pending = undefined;
91 episode = newEpisode();
92 options = {};
93};
94
95/** Runs the CLI `bin/<name>` of this plugin and gives its status and text. */
96const runBin = async (
97 $: EngineInterface,
98 name: string,
99 args: string[],
100): Promise<{ ok: boolean; text: string }> => {
101 const run = await $.process.run([
102 "bun",
103 "--no-env-file",
104 `${$.plugin.root}/bin/${name}`,
105 ...args,
106 ]);
107 return { ok: run.exitCode === 0, text: run.stdout.trim() };
108};
109
110/** The usage of each subagent step: its context and its model. */
111export const onTurnStep: Handler<"turn.step"> = async function* (_$, e, next) {
112 const result = yield* next(e);
113 if (e.agentId && result.usage)
114 agents.set(e.agentId, {
115 tokens: contextTokens(result.usage),
116 model: result.usage.model,
117 });
118 return result;
119};
120
121/** Adds the report-now note to the result of a tool call of a subagent that nears a bound, and watches for a merge or an accept. */
122export const onToolCall: Handler<"tool.call"> = async (_$, e, next) => {
123 const out = await next(e);
124 if (phaseDoneCall(e, out)) {
125 wanted = REASON_LANE;
126 if (!pending) episode = newEpisode();
127 }
128 const state = e.agentId ? agents.get(e.agentId) : undefined;
129 if (!state || out.deny !== undefined) return out;
130 const note = reportNote(state);
131 return note ? { ...out, context: [...(out.context ?? []), note] } : out;
132};
133
134/** Starts the handoff of `/handoff`: names the file in the skill text and expects the file at the next `turn.complete`. */
135export const onSkill: Handler<"skill.prompt"> = async ($, e, next) => {
136 const out = await next(e);
137 if (parseHandover(options["mainHandover"]) === "off") return out;
138 const path = await runBin($, "dotclaude-handoff", ["path"]);
139 if (!path.ok) return out;
140 pending = { files: [path.text], waited: 0 };
141 episode = newEpisode();
142 return {
143 ...out,
144 text: `${out.text}\n\nThe handoff file is \`${path.text}\`.`,
145 };
146};
147
148/**
149 * Clears the session and submits the resume prompt when the newest handoff file is one of `files`.
150 * Gives whether it did.
151 */
152const resume = async (
153 $: EngineInterface,
154 files: readonly string[],
155): Promise<boolean> => {
156 const newest = await runBin($, "dotclaude-handoff", ["newest"]);
157 if (!newest.ok || !files.includes(newest.text)) return false;
158 const next = await runBin($, "dotclaude-spec", ["next"]);
159 await $.command.run({ command: "clear" });
160 await $.prompt.submit({
161 text: resumeText(newest.text, next.ok ? next.text : ""),
162 });
163 return true;
164};
165
166/** A trigger that fired for the main session: its kind and its reason text. */
167type Trigger = { readonly kind: string; readonly reason: string };
168
169/** The trigger that fired for the main session, or undefined. */
170const triggerOf = async ($: EngineInterface): Promise<Trigger | undefined> => {
171 if (wanted) return { kind: "lane", reason: wanted };
172 const { context } = await $.session.usage();
173 return overContext(context.percent, CONTEXT_PERCENT)
174 ? { kind: "context", reason: reasonContext(context.percent ?? 0) }
175 : undefined;
176};
177
178/**
179 * Adds the `manual` hint of `trigger` to `out`, once for each kind of trigger in an episode.
180 * The text that the chain passes down is the answer itself, which the user sees already,
181 * so the hint replaces that text and does not repeat it.
182 */
183const withHint = (
184 out: TurnCompleteResult,
185 trigger: Trigger,
186 answer: string,
187): TurnCompleteResult => {
188 if (episode.hinted.has(trigger.kind)) return out;
189 episode.hinted.add(trigger.kind);
190 const beneath = out.text === answer ? "" : out.text;
191 const hint = manualHint(trigger.reason);
192 return { ...out, text: beneath ? `${beneath}\n\n${hint}` : hint };
193};
194
195/**
196 * Asks Claude to write the handoff file, at a new path, and keeps the paths of `files` as well.
197 * A failed `path` call starts nothing.
198 */
199const startWrite = async (
200 $: EngineInterface,
201 files: readonly string[],
202): Promise<void> => {
203 const path = await runBin($, "dotclaude-handoff", ["path"]);
204 if (!path.ok) return;
205 pending = { files: [...files, path.text], waited: 0 };
206 wanted = undefined;
207 await $.prompt.submit({ text: writePrompt(path.text) });
208};
209
210/**
211 * Ends a turn while a write prompt is out.
212 * Only an `answer` turn can be the write turn that wrote the file.
213 * A turn that ends with another reason drops the handoff, and none starts again in this episode.
214 * When the file is not there after `HANDOFF_WAIT_TURNS` turns, the prompt goes out once more,
215 * and then the `manual` hint goes to the user.
216 */
217const settle = async (
218 $: EngineInterface,
219 e: TurnCompleteInput,
220 out: TurnCompleteResult,
221 mode: MainHandover,
222): Promise<TurnCompleteResult> => {
223 const current = pending as NonNullable<typeof pending>;
224 if (e.reason !== "answer") {
225 pending = undefined;
226 wanted = undefined;
227 episode.over = true;
228 return out;
229 }
230 if (await resume($, current.files)) {
231 pending = undefined;
232 wanted = undefined;
233 episode = newEpisode();
234 return out;
235 }
236 current.waited += 1;
237 if (current.waited < HANDOFF_WAIT_TURNS) return out;
238 pending = undefined;
239 wanted = undefined;
240 if (mode === "auto" && episode.retries < HANDOFF_RETRY_MAX) {
241 episode.retries += 1;
242 await startWrite($, current.files);
243 return out;
244 }
245 episode.over = true;
246 return withHint(out, { kind: "no-file", reason: REASON_NO_FILE }, e.answer);
247};
248
249/** Runs the handoff at the end of a turn of the main session. */
250export const onTurnComplete: Handler<"turn.complete"> = async ($, e, next) => {
251 const out = await next(e);
252 if (e.agentId) {
253 agents.delete(e.agentId);
254 return out;
255 }
256 const mode = parseHandover(options["mainHandover"]);
257 if (pending) return settle($, e, out, mode);
258 if (mode === "off") {
259 wanted = undefined;
260 return out;
261 }
262 const trigger = await triggerOf($);
263 if (!trigger) {
264 episode = newEpisode();
265 return out;
266 }
267 if (mode === "manual") {
268 wanted = undefined;
269 return withHint(out, trigger, e.answer);
270 }
271 if (episode.over) return out;
272 await startWrite($, []);
273 return out;
274};
275slices/prompt/adapters/mod.ts 52 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/prompt/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handlers of the `prompt` slice:
3// `prompt.compose` and `agent.spawn` for the prompt variant of the model,
4// and `prompt.section`, which answers each built-in section that has a transform with its shorter text.
5
6import type { Handler } from "../../../kernel/slice.ts";
7import {
8 composeSections,
9 transformText,
10 variantKey,
11 withBlock,
12} from "../domain/apply.ts";
13import type { VariantKey } from "../domain/variants.ts";
14import { TRANSFORMS } from "../transforms/index.ts";
15
16// The variant key of each session, chosen at the first `prompt.compose` of the session and kept.
17// A model switch changes nothing, because a changed system prompt breaks the cache.
18const variants = new Map<string, VariantKey | null>();
19
20export const onCompose: Handler<"prompt.compose"> = async ($, e, next) => {
21 const result = await next(e);
22 if (e.traits.includes("bare")) return result;
23 const sections = result.sections.map((s) => ({
24 ...s,
25 text: transformText(s.id, s.text),
26 }));
27 const session = await $.session.id();
28 if (!variants.has(session))
29 variants.set(session, variantKey(e.promptModel || e.model));
30 const key = variants.get(session);
31 return key ? { sections: composeSections(sections, key) } : { sections };
32};
33
34// A subagent gets its system prompt from its agent body, so its model block goes to the end of the task prompt.
35export const onSpawn: Handler<"agent.spawn"> = (_$, e, next) => {
36 const model = e.model && e.model !== "inherit" ? e.model : e.parentModel;
37 const key = variantKey(model);
38 return next(key ? { ...e, prompt: withBlock(e.prompt, key) } : e);
39};
40
41// The transform of a section is the same for each call, so the cached answer keeps the prompt cache.
42// The `context_management` section says that the context is summarized, so its transform applies only
43// with `DISABLE_COMPACT`, where the context is not summarized.
44export const onSection: Handler<"prompt.section"> = async ($, e, next) => {
45 if (!TRANSFORMS.has(e.name) || e.text === null) return next(e);
46 if (e.name === "context_management") {
47 const off = await $.env.get("DISABLE_COMPACT");
48 if (!off || off === "0" || off === "false") return next(e);
49 }
50 return { text: transformText(e.name, e.text) };
51};
52slices/sembr/adapters/mod.ts 32 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/sembr/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handler of the `sembr` slice: the `tool.call` rewrites on `Write`, `Edit`, and `Bash` (design D29).
3// `Write` and `Edit` put each sentence of a Markdown file on its own line, `Edit` also splits a cross-sentence `old_string`,
4// and `Bash` does the same for the quoted heredoc of `git commit` and `gh pr create`.
5// Claude Code checks `old_string` before a classic hook runs, so only a module can repair it (probe 2.29).
6// A file that is absent passes on with no change.
7// The hook asks `$.fs.exists` first, because `$.fs.read` documents no error code for a missing file.
8// A rule that throws is a defect: Claude Code logs it, skips the hook, and runs the call as written.
9//
10// The filter of `slice.ts` narrows `e` to `Write`, `Edit`, and `Bash`, and one handler checks `e.tool`.
11// `claude plugin validate` follows `$` only into a function of one file,
12// so each function that touches `$` is here, and the rules are in `domain/`.
13
14import type { MatchedHook } from "claude-code";
15import { commandInput, editInput, writeInput } from "../domain/rewrite.ts";
16
17/** The hook for the tools in the filter of `slice.ts`, with `e` narrowed to them. */
18export type SembrHook = MatchedHook<
19 "tool.call",
20 { readonly tool: readonly ["Write", "Edit", "Bash"] }
21>;
22
23export const onToolCall: SembrHook = async ($, e, next) => {
24 if (e.tool === "Write") return next(writeInput(e));
25 if (e.tool === "Bash") return next(commandInput(e));
26 if (e.tool !== "Edit") return next(e);
27 const file = (await $.fs.exists(e.file_path))
28 ? await $.fs.read(e.file_path)
29 : null;
30 return next(editInput(e, file));
31};
32slices/setup/adapters/mod.ts 77 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/setup/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handler of the drift check: `session.start`.
3// It reads the facts, and appends one note to the conversation when `drift.ts` finds a difference.
4// The note is a user-role row that the model reads, because a module cannot add context at `session.start` in another way.
5// A fact that fails to read gives no line, so a failure cannot make a false report.
6
7import type { EngineInterface, Hook } from "claude-code";
8import { driftLines, driftNote } from "../domain/drift.ts";
9
10/**
11 * The result of `read()`, or undefined when it fails.
12 * The catch has no narrower test on purpose.
13 * The calls of `$` give no documented error type, and a fact with no value gives no line (see above),
14 * so a failed read can only remove a line of the note and never adds a false one.
15 */
16const attempt = async <T>(
17 read: () => Promise<T> | T,
18): Promise<T | undefined> => {
19 try {
20 return await read();
21 } catch {
22 return undefined;
23 }
24};
25
26/**
27 * The note of the drift check, or undefined.
28 */
29const reportDrift = async ($: EngineInterface): Promise<string | undefined> => {
30 const [settings, user, policy, version, usage, composed, effort, force] =
31 await Promise.all([
32 attempt(() => $.settings.read()),
33 // The `autoMode` lists count only in the user settings, so this read is not the merge.
34 attempt(() => $.settings.read({ source: "user" })),
35 attempt(() => $.settings.read({ source: "policy" })),
36 attempt(() => $.session.version()),
37 attempt(() => $.session.usage({ breakdown: "summary" })),
38 // The `bare` trait keeps the prompt hooks of dotclaude out, so this call only lists the sections of the engine.
39 attempt(() => $.prompt.compose({ traits: ["bare"] })),
40 attempt(() => $.env.get("CLAUDE_CODE_EFFORT_LEVEL")),
41 attempt(() => $.env.get("CLAUDE_CODE_SUBAGENT_MODEL_FORCE")),
42 ]);
43 const note = driftNote(
44 driftLines({
45 settings,
46 policy,
47 version: version?.version,
48 skills: usage?.context?.breakdown?.skills?.skillFrontmatter,
49 sections: composed?.sections,
50 blocking: {
51 CLAUDE_CODE_EFFORT_LEVEL: effort,
52 CLAUDE_CODE_SUBAGENT_MODEL_FORCE: force,
53 },
54 root: $.plugin.root,
55 user,
56 }),
57 );
58 if (note)
59 await $.session.append({
60 message: { type: "user", content: [{ type: "text", text: note }] },
61 });
62 return note;
63};
64
65/**
66 * `session.start`: the drift check runs once, when the session is ready and before the first prompt.
67 * The event has no field for the source, so a resumed session shows as a transcript with user turns.
68 * A resume keeps the note of the first start in the transcript, so it gets no second note.
69 * A turn count that fails to read counts as a new session.
70 */
71export const onSessionStart: Hook<"session.start"> = async ($, e, next) => {
72 const started = await next(e);
73 const turns = await attempt(() => $.session.turns());
74 if (!((turns ?? 0) > 0)) await reportDrift($);
75 return started;
76};
77slices/spec/adapters/mod.ts 40 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/spec/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handler of the `spec` slice. It makes no model call, so `requests` is 0.
3//
4// `agent.spawn` runs `dotclaude-spec implement <package>` when `/dotclaude:implement` starts the `implementer` of a package.
5// The command appends the `doing` event, in the folder of the main session at the spawn.
6// This is where `implement` works, because it refuses a linked worktree.
7// The hook does not move a package out of `doing`: the phase commands `verify` and `review` do that.
8//
9// `claude plugin validate` follows `$` only into a function of one file.
10// For this reason, each function that touches `$` is here, and the rules are in `domain/`.
11
12import type { Handler } from "../../../kernel/slice.ts";
13import { implementArgs, packageOf } from "../domain/implement-brief.ts";
14
15/**
16 * Runs `dotclaude-spec implement` for the package of an `implementer` prompt once the subagent started.
17 * A failed run changes nothing, and the skill still names the command.
18 * The catch has no narrower test, because `$.process.run` documents no error type.
19 */
20export const onSpawn: Handler<"agent.spawn"> = async ($, e, next) => {
21 const out = await next(e);
22 const pkg = packageOf(e.prompt);
23 if (pkg === undefined || out.deny !== undefined || !out.agentId) return out;
24 const args = implementArgs(pkg);
25 try {
26 const run = await $.process.run(
27 ["bun", "--no-env-file", `${$.plugin.root}/bin/dotclaude-spec`, ...args],
28 { cwd: await $.session.cwd() },
29 );
30 if (run.exitCode !== 0)
31 $.ui.log(
32 `dotclaude-spec ${args.join(" ")} failed with exit status ${run.exitCode}: ${run.stderr.trim()}`,
33 { to: "debug" },
34 );
35 } catch {
36 $.ui.log(`dotclaude-spec ${args.join(" ")} did not run`, { to: "debug" });
37 }
38 return out;
39};
40slices/stamp/adapters/mod.ts 171 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/stamp/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handler of the `stamp` slice: `session.start` appends the build of the session as a `system` notice (P38).
3// It reads `plugin.json`, `installed_plugins.json`, and the transcript of the session, and runs `git` only when no install entry gives a SHA.
4// It appends a notice only when the transcript holds no stamp yet, or its newest stamp has another build, so a resume on the same build adds no row.
5// It reads each file only and never writes one, apart from the notice that `session.append` writes.
6// A missing install record is the normal case of `--plugin-dir`, so it gives no SHA and `git` runs instead.
7// A failed read of `plugin.json` gives no version, so the session gets no stamp.
8// The hook never throws, so a stamp cannot stop a session.
9
10import type { EngineInterface } from "claude-code";
11import type { Handler } from "../../../kernel/slice.ts";
12import { formatStamp, type StampRecord } from "../../../kernel/stamp.ts";
13import {
14 chooseSha,
15 gitSha,
16 gitTop,
17 installedSha,
18 needsStamp,
19 newestStamp,
20 parseJson,
21 projectFolder,
22 versionOf,
23} from "../domain/build.ts";
24import { GIT_TIMEOUT_MS } from "../limits.ts";
25
26/** The file of Claude Code that records each installed plugin, in `CLAUDE_CONFIG_DIR` or in `~/.claude`. */
27const INSTALLED_FILE = "plugins/installed_plugins.json";
28
29/** The file that marks the top folder of a dotclaude checkout. */
30const MARKETPLACE = ".claude-plugin/marketplace.json";
31
32/**
33 * The text of `file`, or undefined when the read fails.
34 * The catch has no narrower test on purpose: a missing file, a folder, and a file over the bound all give no text.
35 */
36const readText = async (
37 $: EngineInterface,
38 file: string,
39): Promise<string | undefined> => {
40 try {
41 return await $.fs.read(file);
42 } catch {
43 return undefined;
44 }
45};
46
47/** The config folder of Claude Code: `CLAUDE_CONFIG_DIR`, else `~/.claude`, or undefined when neither is set. */
48const configDir = async ($: EngineInterface): Promise<string | undefined> => {
49 const configured = await $.env.get("CLAUDE_CONFIG_DIR");
50 if (configured) return configured;
51 const home = await $.env.get("HOME");
52 return home ? `${home}/.claude` : undefined;
53};
54
55/** The path of `installed_plugins.json`, or undefined when neither `CLAUDE_CONFIG_DIR` nor `HOME` is set. */
56const installedPath = async (
57 $: EngineInterface,
58): Promise<string | undefined> => {
59 const dir = await configDir($);
60 return dir === undefined ? undefined : `${dir}/${INSTALLED_FILE}`;
61};
62
63/** The path of the transcript of this session, or undefined when no config folder is set. Claude Code keeps it under `projects/`, in the folder of the working directory. */
64const transcriptPath = async (
65 $: EngineInterface,
66 sessionId: string,
67): Promise<string | undefined> => {
68 const dir = await configDir($);
69 if (dir === undefined) return undefined;
70 const folder = projectFolder(await $.session.cwd());
71 return `${dir}/projects/${folder}/${sessionId}.jsonl`;
72};
73
74/**
75 * The newest stamp of the transcript of this session, or `null` when the transcript exists and its read fails.
76 * A missing transcript, or no config folder, gives no stamp, so the session is new and gets one.
77 * `fs.read` rejects a file over 4 MiB, so a long session has a transcript that exists and cannot be read, and no stamp is written for it.
78 */
79const transcriptStamp = async (
80 $: EngineInterface,
81 sessionId: string,
82): Promise<{ newest: StampRecord | undefined } | null> => {
83 const file = await transcriptPath($, sessionId);
84 if (file === undefined || !(await $.fs.exists(file)))
85 return { newest: undefined };
86 const text = await readText($, file);
87 return text === undefined ? null : { newest: newestStamp(text) };
88};
89
90/** The short SHA of this plugin in the install record, or undefined. */
91const installSha = async ($: EngineInterface): Promise<string | undefined> => {
92 const file = await installedPath($);
93 if (file === undefined) return undefined;
94 const text = await readText($, file);
95 return installedSha(parseJson(text), $.plugin.root);
96};
97
98/** The output of `git rev-parse` with `args` in the plugin root, or undefined when `git` fails or is absent. */
99const revParse = async (
100 $: EngineInterface,
101 args: string[],
102): Promise<{ exitCode: number | null; stdout: string } | undefined> => {
103 try {
104 return await $.process.run(["git", "rev-parse", ...args], {
105 cwd: $.plugin.root,
106 timeoutMs: GIT_TIMEOUT_MS,
107 });
108 } catch {
109 return undefined;
110 }
111};
112
113/**
114 * The short SHA that `git` gives in the plugin root, or undefined.
115 * The plugin root can sit in a parent repository, so `git` gives a SHA only when the top folder
116 * holds `MARKETPLACE`, the mark of a dotclaude checkout.
117 */
118const gitShaOf = async ($: EngineInterface): Promise<string | undefined> => {
119 const top = await revParse($, ["--show-toplevel"]);
120 const folder = top === undefined ? undefined : gitTop(top);
121 if (folder === undefined) return undefined;
122 if ((await readText($, `${folder}/${MARKETPLACE}`)) === undefined) return;
123 const head = await revParse($, ["--short", "HEAD"]);
124 return head === undefined ? undefined : gitSha(head);
125};
126
127/** The record of this session, or undefined when the manifest gives no version. */
128const recordOf = async (
129 $: EngineInterface,
130): Promise<StampRecord | undefined> => {
131 const manifest = parseJson(
132 await readText($, `${$.plugin.root}/.claude-plugin/plugin.json`),
133 );
134 const version = versionOf(manifest);
135 if (version === undefined) return undefined;
136 // `git` runs only when the install record gives no SHA, so the common start runs no process.
137 const installed = await installSha($);
138 const git = installed === undefined ? await gitShaOf($) : undefined;
139 return {
140 version,
141 ...chooseSha(installed, git),
142 sessionId: await $.session.id(),
143 at: new Date().toISOString(),
144 };
145};
146
147/** Appends the stamp, and does nothing when a read gives no version, the transcript exists but cannot be read, or the newest stamp has the same build. */
148const stamp = async ($: EngineInterface): Promise<void> => {
149 const record = await recordOf($);
150 if (record === undefined) return;
151 const transcript = await transcriptStamp($, record.sessionId);
152 if (transcript === null || !needsStamp(transcript.newest, record)) return;
153 await $.session.append({
154 message: {
155 type: "system",
156 content: [{ type: "text", text: formatStamp(record) }],
157 },
158 });
159};
160
161// The stamp runs after `next`, so a session starts with the same hooks as without the stamp.
162export const onSessionStart: Handler<"session.start"> = async ($, e, next) => {
163 const started = await next(e);
164 try {
165 await stamp($);
166 } catch {
167 // A failed append or id read writes no stamp, and the session goes on.
168 }
169 return started;
170};
171slices/surface/adapters/mod.ts 18 lines1// Generated by `just generate` from `src/plugins/dotclaude/slices/surface/adapters/mod.ts`. Edit the source and run `just generate`.
2// The mod handlers of the `surface` slice: `tool.describe` for the tools of the filter in `slice.ts`, and `agent.offer`.
3// Each handler calls `next` for the engine answer, or answers in its place for a dropped agent.
4// A tool that no transform names passes on with no change.
5// `claude plugin validate` follows `$` only into a function of one file, so each handler is here, and the rules are in `domain/`.
6
7import type { Handler } from "../../../kernel/slice.ts";
8import { isDropped } from "../domain/agents.ts";
9import { toolAnswer } from "../domain/tools.ts";
10
11export const onToolDescribe: Handler<"tool.describe"> = async (_$, e, next) => {
12 const answer = await next(e);
13 return toolAnswer(e.tool, answer) ?? answer;
14};
15
16export const onAgentOffer: Handler<"agent.offer"> = async (_$, e, next) =>
17 isDropped(e.agent) ? { isOffered: false } : next(e);
18