SLOPSHOPPER

dotclaude

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…

newguardprompttoolagents
★ 3v1.0.0MITupdated 2026-10-13xsyetopz/claudetto/plugins/dotclaude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dotclaude
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by dotclaude: This command only prints a file. The `Read` tool sends a short stub when you read the ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dotclaude

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.

Plugins

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

PluginWhat it gives
dotclaudeThe 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-codegraphThe code graph of a project, from the CLI codegraph.
dotclaude-browserThe browse skill, and a headless browser search for each WebSearch call.
dotclaude-docsThe docs skill, with current library docs from the CLI ctx7.
dotclaude-sessionsThe start-cost and usage skills, which measure the session start and the usage.
dotclaude-labTools for plugin authors: the sandbox, capture, schema, and eval compare.
dcc-plugin-agents-mdLoads AGENTS.md files where Claude Code loads CLAUDE.md, in place of the built-in cc-plugin-agents-md.
dcc-plugin-you-should-knowOpt-in. Shows one thing to know before Claude changes something, in place of the built-in cc-plugin-you-should-know.

Install

/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:

  • Claude Code 2.1.296 or later. dotclaude is tested only on 2.1.296.
  • git.
  • Bun 1.4.3 or later on PATH, for the status line, the setup command, and the second_opinion tool.
  • Node.js on PATH, for the npm installs of the add-ons.

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

What the core plugin changes

PartEffect
CompactionOff. autoCompactEnabled is false, and DISABLE_COMPACT is 1, which also turns off /compact.
Context windowCLAUDE_CODE_MAX_CONTEXT_TOKENS and CLAUDE_CODE_AUTO_COMPACT_WINDOW are 300000. A session that reaches the limit stops, and you run /clear.
MemoryautoMemoryEnabled is false. The spec store holds the state instead.
Output styleoutputStyle is dotclaude:Concise. Your own outputStyle setting can change it.
PermissionsNormal work passes. Work like sudo asks. Destructive actions are denied by autoMode.hard_deny. Reads of secret files are denied.
SandboxBash runs in the sandbox. Its network reaches GitHub and the package registries only.
GuardAsks before the first call of a session to a GitHub repository of another owner that has an AI policy file.
AsksAsks only when a change weakens a check. It never asks for a read.
Status lineThe 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%.
HandoffWith the default manual value of mainHandover, a hint at 60% of the window tells you to run /dotclaude:handoff.
ChecksThe 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.

Agents and models

AgentModelEffort
planner, reviewerclaude-opus-5-5high
debuggerclaude-sonnet-5-5high
implementer, researcherclaude-sonnet-5-5medium
investigatorclaude-haiku-5-5medium

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.

Long work without compaction

Each model call reads the whole context again, so the size of the context sets the cost of each turn.

  • For work that takes more than one session, run /dotclaude:specify <id>, or run dotclaude-spec specify <id>.
  • Before /clear, run dotclaude-spec status <id>. In the fresh session, run dotclaude-spec next <id>, which prints the brief of the next package.
  • A model change rebuilds the prompt cache. An effort change keeps the prompt cache.

See Context and handoff and Spec store.

Second opinion

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.

Add-on plugins

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.

Update

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

Development

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

License

MIT.

Source 90 files
hooks/register.ts 78 lines
1// 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};
78
slices/asks/adapters/mod.ts 51 lines
1// 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};
51
slices/attribution/adapters/mod.ts 25 lines
1// 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};
25
slices/decide/adapters/mod.ts 99 lines
1// 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};
99
slices/guard/adapters/mod.ts 162 lines
1// 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};
162
slices/handoff/adapters/mod.ts 275 lines
1// 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};
275
slices/prompt/adapters/mod.ts 52 lines
1// 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};
52
slices/sembr/adapters/mod.ts 32 lines
1// 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};
32
slices/setup/adapters/mod.ts 77 lines
1// 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};
77
slices/spec/adapters/mod.ts 40 lines
1// 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};
40
slices/stamp/adapters/mod.ts 171 lines
1// 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};
171
slices/surface/adapters/mod.ts 18 lines
1// 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