Keeps AI coding agents inside your project's rules, from idea to merged code: spec design, lanes, layer rules, protected paths with an approval log, spec-check…

<img src="https://raw.githubusercontent.com/warrendeanlangeveldt/code-kit/main/assets/logo.png" alt="code-kit" width="300">
A Claude Code plugin that keeps AI coding agents inside a project's engineering rules. You write the rules once, per project, in .claude/code-kit.json. The plugin's hooks enforce them on every tool call, for the main session and every subagent. A rule Claude would otherwise only be asked to follow becomes one the hooks refuse to let it break. The same rules run in CI for every pull request.
It also carries the work from idea to merged code: spec an idea into requirements, hand stories to lanes, review each branch against its requirements, and track the build against the spec.
Pairs with Context Graph. code-kit decides who may change what, and what has to be proved. Context Graph gives each agent the why behind a file before it edits it: the file's card, its rules and the decisions made on it, kept in git. Each detects the other: Context Graph's cards and slices then carry the file's spec requirement and layer rule. Install both for agents that stay in their lane and know why the code is the way it is.
<img src="https://raw.githubusercontent.com/warrendeanlangeveldt/code-kit/main/assets/workflow.svg" alt="How code-kit works: the specs create the agents, skills and rules; the lead agent delegates stories to lane agents and takes decisions and approvals to you; lanes send scope and dependency requests back to the lead; hooks enforce ownership, layers, approvals and spec-check while each lane implements, tests and commits; the lead reviews against the requirements and CI runs verify." width="820">
You need Claude Code, Node 22 or later, and git. Then, in Claude Code:
/plugin marketplace add warrendeanlangeveldt/code-kit
/plugin install code-kit@code-kit
/code-kit:next
/code-kit:next works out where your project stands and takes you through the next step. A blank folder starts with speccing an idea; an existing codebase starts with drafting its rules. Nothing is enforced until you approve the rules it drafts. In a project without .claude/code-kit.json, the plugin does nothing.
Rules in CLAUDE.md and agent prompts are advice. Over a long session, or across a dozen parallel subagents, advice drifts:
main, adds a dependency, or runs a deploy nobody asked for.code-kit stops each of these at the moment it would happen. The hook refuses the tool call and tells the agent why and what to do instead. The rules live in one reviewed file, not in every prompt, so they hold for every agent and every session.
.claude/approval-log.jsonl, in the same commit. Only the lead writes these paths without a person's approval, and the person reviews them in the pull request.--no-verify, destructive deletes, or piping downloads into a shell;A lane also has to commit its work first.
code-kit verify checks a pull request as a whole, including commits made outside Claude Code:<lane>/… branch stays in that lane's paths, apart from manifest and lockfile changes logged under a dependency approval;A project without .claude/code-kit.json isn't governed at all. An invalid config fails closed: nothing but the config itself can be written until it's fixed.
The hooks are guard rails, not a sandbox (SECURITY.md): they refuse the actions they recognise. Two limits are deliberate. The finish check stops blocking once it has reported the same problem three times without progress, and says so, for the lead to take up. A check whose tool isn't installed is skipped unless it sets ifMissing: "fail".
Use it when:
It's overkill for a throwaway script, a spike, or a single-file change. In those, just don't add .claude/code-kit.json: the plugin stays installed and does nothing.
Set it up at the start of a new build, straight from the architecture docs, or bring it into an existing codebase at any point (init works the rules out from the code). Re-run init whenever the shape of the project changes: new apps or packages, moved folders, a changed plan.
.claude/code-kit.json.| Hook | Runs | Enforces |
|---|---|---|
PreToolUse Edit/Write | hooks/guard-paths.mjs | lanes, protected paths, spec-check and design gates |
PreToolUse Bash | hooks/guard-bash.mjs | shell rules, dependencies, approval log and secret scan on commit |
PostToolUse Edit/Write | hooks/post-edit-check.mjs | layer rules and the project's postEdit commands |
PostToolUse Bash | hooks/lane-audit.mjs | ownership of files written by shell commands |
Stop, SubagentStop | hooks/stop-check.mjs | proof before finishing |
.claude/worktrees/.code-kit verify in a GitHub Actions workflow that init can install (templates/github-workflow.yml).bin/ runs the same rules from a terminal.Requirements: Claude Code, Node 22 or later, and git. gitleaks is optional: when it's installed, every commit gets a secret scan, and verify scans every commit on the branch.
The plugin, from this repository's marketplace:
/plugin marketplace add warrendeanlangeveldt/code-kit
/plugin install code-kit@code-kit
To update later, run /plugin marketplace update code-kit and restart Claude Code; sessions started before an update keep the old hooks. To pin it for a whole team, add it to the project's .claude/settings.json:
{
"extraKnownMarketplaces": {
"code-kit": { "source": { "source": "github", "repo": "warrendeanlangeveldt/code-kit" } }
},
"enabledPlugins": { "code-kit@code-kit": true }
}
The command line is optional: the skills run it for you in a session. Use it for CI, or from a terminal:
npx @warren-dean/code-kit check # no install
npm i -g @warren-dean/code-kit # or install it, then: code-kit check
From a clone, for working on code-kit itself: /plugin marketplace add <path to the clone>.
Its companion, Context Graph, installs the same way. Nothing needs configuring between the two:
/plugin marketplace add warrendeanlangeveldt/context-graph
/plugin install context-graph@context-graph
/code-kit:nextDon't want to remember which skill comes when? Run /code-kit:next, from a blank folder or at any point in a build:
/code-kit:next a booking app for mobile dog groomers
It reads where the project stands and runs the right skill. It carries on to the following step until something needs you: questions to answer, a draft to approve, a pull request to merge, or lanes still building.
| The project has | next runs |
|---|---|
| Nothing | spec-design |
| A spec still being shaped | spec-design, picking up where it stopped |
| A ready spec, existing code, or a draft config | init |
| Stories finished on their branches | review, then dispatch |
| Stories ready | dispatch |
| Stories being built | nothing: it tells you what's running |
| Everything done | a status summary, then offers the next milestone |
code-kit next on the command line shows the same decision without running anything.
/code-kit:spec-design a booking app for mobile dog groomers
spec-design works an idea into a specification with you before any code exists. It asks the questions that decide the build, a few at a time with a recommended answer, and writes every decision down:
It writes docs/brief.md (with the decision log and open questions), docs/specs/NN-<area>.md, docs/architecture.md, docs/principles.md and docs/plan.md. Those are exactly what /code-kit:init docs/ turns into lanes and layers, and what lanes spec-check against. Run it again to resume where it left off, to change a decision, or to spec a new feature in an existing codebase.
Requirements are headings (### BOOK-4 Cancel a booking). Stories are ### ST-12 headings in the plan with Lane:, Requirements:, Depends on: and Status: lines (templates/plan.md). The lane lead marks the lead's own stories, such as contracts and spikes, which the lead builds itself instead of dispatching; Requirements: none marks a story that delivers no requirement. Those two formats are what the build loop below runs on.
/code-kit:init docs/
.claude/code-kit.draft.json), one agent per lane, any project skills you approve, a CLAUDE.md section and a list of questions (see The agents and skills it creates). Nothing is enforced until you approve and the draft becomes .claude/code-kit.json.Existing violations. Brownfield code usually breaks some layer rules already. On approval, baseline --write records them in .claude/code-kit.baseline.json:
No specs yet? Leave docs.specs out, and lanes aren't held to a spec-check report. Add it once specs exist.
code-kit ships no fixed team. Init writes one for each project, from its docs or its code, and re-running init keeps it in step as the project changes.
.claude/
├── code-kit.json # the rules: lanes, layers, protected paths, checks
├── agents/
│ ├── web-engineer.md # one lane agent per lane
│ ├── api-engineer.md
│ └── db-engineer.md
└── skills/
└── new-migration/ # project recipes, only the ones you approve
└── SKILL.md
CLAUDE.md # plus a section telling the lead how the rules work
Who's who. There are four kinds of actor. The hooks tell the three Claude kinds apart on every tool call; the person works outside them:
| Actor | Who it is | May write |
|---|---|---|
| The person | You | Anything; you also approve and merge |
| The lead | The main Claude Code session | Contracts, specs, config, the kit: lead.paths |
| A lane agent | A subagent whose name is a lane's agent in the config, or a session in a .lane worktree | Only its lane's paths, after its spec-check |
| Any other subagent | Explore, Plan, reviewers, anything not mapped to a lane | Nothing: read-only |
Lane agents (.claude/agents/<agent>.md, from templates/agent.md). Each is filled in from the project's docs, or from the code for brownfield projects:
It also carries the working rules: start from the story's branch and spec-check, build end to end with no mocks or placeholders, name requirement IDs in test titles, and commit before finishing.
The config's lanes.<name>.agent must match the agent file's name. That's how the hooks know a subagent is the web lane and not a read-only helper.
Instructions and enforcement are separate. The agent file tells the agent the rules, so it rarely hits them. The hooks enforce them whether or not the agent follows its file:
Editing an agent file can't loosen a rule. And the agent files are kit files (.claude/**), so only the lead changes them, and every change is logged.
Spawned per story. Dispatch starts a fresh instance of the lane's agent for each story, in its own worktree on <lane>/st-<n>. Agents for different lanes run in parallel. Each starts knowing only its agent file and the story's brief, so nothing leaks between stories.
How the spec is enforced, step by step:
verify checks the rules again in CI.Project skills (.claude/skills/<recipe>/SKILL.md) are the step-by-step recipes your docs describe, like "add a migration" or "add an endpoint". Init lists the ones it finds and writes them only if you say yes, never as empty stubs. Each is listed in the owning lane agent's "Skills to use". Like the agents, they're kit files: the lead maintains them, and changes are logged.
Kept in step. When the project changes (a new app, a new lane, moved folders), re-run /code-kit:init. It drafts new agents for new lanes and updates the paths of existing ones, keeping anything written by hand. You see the difference and approve it before anything changes.
With Claude Code 2.1.287 or later, code-kit's mod shows the project in the session, in the terminal (VS Code's terminal included) and the Desktop app. Older versions skip it and keep enforcing everything through the hooks.
/code-kit:review on the branch, and Merge runs verify, checks included, then merges if it passes;Each button has a digit: type it in an empty prompt to press it.
/lanes opens the Lanes pane: each lane with its agent, story, branch and state (building, in review, blocked, idle), a mark while its agent is at work, and the stories ready to start. It stays current within 2 seconds of a commit, a branch change, a plan edit or an agent starting or stopping. /lanes again, or Escape, closes it./approvals lists the requests waiting and the approvals in force, with minutes left. /verify-branch runs code-kit verify on this branch. Neither calls the model.Approving from the band records (approved in the code-kit pane) with your reason, and a merge from it says it was merged by the person from the pane. Both happen only on your press: the hooks refuse approve --via pane and merge --person from every agent. Outside a project with .claude/code-kit.json, the mod draws nothing. Mods don't draw in the VS Code extension's chat panel, the Agent SDK or claude -p.
Once init has switched the rules on, the lead runs the build in a loop:
/code-kit:dispatch next # start every ready story on its lane
/code-kit:review ST-12 # check a finished branch, then merge or send it back
/code-kit:status # where the build stands against the spec
<lane>/st-<n>, in parallel. Each brief names the story's requirements and acceptance criteria. Lanes spec-check first, build end to end, name requirement IDs in their test titles, and commit.verify on the branch, then checks each requirement: built end to end, proved by a test, matching the spec-check report, and nothing beyond the story. It ends with merge, send back with the list of problems, or a question for you.The hooks only see Claude Code sessions. For everything else, init offers .github/workflows/code-kit.yml (from templates/github-workflow.yml). It runs verify on every pull request, through npx @warren-dean/code-kit@<version>, pinned so CI doesn't change under you. It needs no secret. The one setting to make is marking the code-kit check as required on the protected branches.
verify can't see spec-check reports, because .claude/state/ isn't committed. Review covers those.
Re-run /code-kit:init whenever the build moves on: new apps or packages, moved folders, a changed plan, or files unowned keeps listing. It drafts the whole config again, then shows you the difference before anything changes:
+ lane jobs: jobs-engineer, workers/jobs/**
~ lane backend: exclude + workers/jobs/**
~ layer services: mayImport - schemas
Owner backend lane / backend-engineer → jobs lane / jobs-engineer: 14 file(s), e.g. workers/jobs/run.ts
Lanes never change silently: only when you approve the diff.
Run from the project root, as code-kit <command> once installed (npm i -g @warren-dean/code-kit), or npx @warren-dean/code-kit <command>. Add --config .claude/code-kit.draft.json to any command to read a draft instead. In a session, the `/code-kit:check
hooks/mod/register.mjs 360 lines1// The code-kit mod: code-kit in the session, for the person. It draws and acts, but never enforces: the
2// settings hooks beside it enforce the rules, in every session, CI and harness. Claude Code before
3// 2.1.287 doesn't load this module and runs those hooks as before.
4//
5// Everything it shows comes from the code-kit CLI's JSON, run in the session's folder; the mod never
6// imports hooks/lib, which uses node modules a hooks module may not. What it draws is built by the pure
7// functions in view.mjs. It acts only on the person's presses: approving (`approve --via pane`), asking
8// the lead to review, and merging (`merge --person`). No agent reaches those: they are the mod's own
9// buttons, and the hooks refuse both commands from every agent.
10import {
11 APPROVE_ID,
12 NOT_CODE_KIT,
13 PANE_ID,
14 RESULT_ID,
15 approvalsText,
16 agentsAtWork,
17 approvePane,
18 band,
19 bandLines,
20 lanesPane,
21 parseJson,
22 prefilledReason,
23 projectState,
24 refusalCard,
25 refusalView,
26 resultPane,
27} from './view.mjs';
28
29// What the session knows of the project, read again when it changes.
30let model = { state: null, requests: null, stops: [] };
31let reviewing = new Set(); // stories the person asked the lead to review (ACT-3)
32let reviewTurn = null; // 'next' until the lead's review turn starts, then its id
33let approving = null; // the open Approve… confirmation: { request, reason, error }
34let result = null; // what the last merge reported
35let notice = null; // why the person's last act failed
36let act = null; // the session's actions, made at session start
37const refusals = new Map(); // tool_use_id → the refusal text a hook gave (CARD-1)
38const expanded = new Set(); // cards showing their raw text
39const REFUSALS_KEPT = 200;
40
41export function register(on) {
42 on('session.start', async ($, e, next) => {
43 // A name another command already holds is refused; the rest of the mod goes on without it.
44 const unavailable = (name) => (err) =>
45 $.ui.log(`code-kit: /${name} isn't available in this session: ${err?.message ?? err}`);
46 await $.command
47 .register({
48 name: 'lanes',
49 description: "code-kit's lanes: agents, stories, branches and what's ready",
50 immediate: true,
51 })
52 .catch(unavailable('lanes'));
53 await $.command
54 .register({
55 name: 'approvals',
56 description: "code-kit's approval requests waiting, and the approvals in force",
57 immediate: true,
58 })
59 .catch(unavailable('approvals'));
60 await $.command
61 .register({
62 name: 'verify-branch',
63 description: 'Run code-kit verify on this branch, checks included',
64 })
65 .catch(unavailable('verify-branch'));
66 const cli = `${$.plugin.root}/bin/code-kit.mjs`;
67 const cwd = e.cwd ?? (await $.session.cwd());
68 const session = await $.session.id();
69 const json = async (...args) => {
70 const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
71 return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
72 };
73 const stamp = async (path) => {
74 const s = await $.fs.stat(`${cwd}/${path}`).catch(() => null);
75 return s ? `${path}@${s.mtimeMs}` : `${path}-`;
76 };
77 const listing = async (path) => {
78 const entries = await $.fs.list(`${cwd}/${path}`).catch(() => []);
79 const stamps = [];
80 for (const entry of entries)
81 stamps.push(
82 entry.kind === 'dir'
83 ? await listing(`${path}/${entry.name}`)
84 : await stamp(`${path}/${entry.name}`),
85 );
86 return stamps.join(',');
87 };
88
89 act = {
90 // Everything the band and the pane show, read again.
91 reload: async () => {
92 const ran = await $.process.run(['node', cli, 'check', '--json'], { cwd });
93 const check = parseJson(ran.stdout);
94 const valid = Boolean(check?.valid);
95 const status = valid ? await json('status') : null;
96 const nextStep = valid ? await json('next') : null;
97 const requests = valid ? await json('requests') : null;
98 const stops = valid ? ((await json('stops', '--session', session)) ?? []) : [];
99 const atWork = agentsAtWork(await $.agent.list());
100 model = {
101 state: projectState(check, status, nextStep, atWork),
102 requests,
103 stops,
104 check,
105 base: status?.base ?? null,
106 };
107 $.ui.invalidate('ui.render');
108 },
109 // What a refresh waits on, read cheaply: branches and HEAD, the config and the plan, requests,
110 // approvals and finish checks, and the agents at work. Only a change runs the CLI.
111 fingerprint: async () => {
112 const refs = await $.process.run(
113 ['git', 'for-each-ref', '--format=%(HEAD)%(objectname) %(refname)', 'refs/heads'],
114 { cwd },
115 );
116 const plan = model.check?.docs?.plan;
117 return [
118 refs.stdout,
119 await stamp('.claude/code-kit.json'),
120 plan ? await stamp(plan) : '',
121 await stamp('.claude/state/requests.jsonl'),
122 await listing('.claude/approvals'),
123 await listing('.claude/state/stop-blocks'),
124 [...agentsAtWork(await $.agent.list())].sort().join(','),
125 ].join('\n');
126 },
127 // PANE-1: open the Lanes pane, or close it when it's open.
128 lanes: async () => {
129 // Asked, not remembered: the person may have closed it with Escape.
130 if ((await $.ui.panes()).some((p) => p.id === PANE_ID)) {
131 await $.ui.close({ id: PANE_ID });
132 return {};
133 }
134 await act.reload();
135 if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
136 const placed = await $.ui.open({
137 id: PANE_ID,
138 title: 'Lanes',
139 focus: true,
140 closeOnEscape: true,
141 });
142 // Opened but not drawn yet: it waits for room, and the reason says what seats it.
143 if (!placed.isPlaced) return { text: `The Lanes pane is waiting: ${placed.reason}` };
144 return {};
145 },
146 // ACT-1: the confirmation, prefilled from the request.
147 approve: async (line) => {
148 approving = { request: line.request, reason: prefilledReason(line.request), error: null };
149 await $.ui.open({ id: APPROVE_ID, title: 'Approve', focus: true, closeOnEscape: true });
150 $.ui.invalidate('ui.render');
151 },
152 confirmApproval: async (reason) => {
153 if (!approving) return;
154 if (!reason.trim()) {
155 approving = { ...approving, error: 'Give a reason: it goes in the approval log.' };
156 $.ui.invalidate('ui.render');
157 return;
158 }
159 const { request } = approving;
160 const ran = await $.process.run(
161 [
162 'node',
163 cli,
164 'approve',
165 ...request.names,
166 ...(request.lane ? ['--lane', request.lane] : []),
167 '--reason',
168 reason.trim(),
169 '--via',
170 'pane',
171 ],
172 { cwd },
173 );
174 if (ran.exitCode !== 0) {
175 const why = (ran.stderr || ran.stdout).trim().split('\n')[0];
176 notice = `Nothing was approved: ${why}`;
177 approving = { ...approving, error: notice };
178 $.ui.invalidate('ui.render');
179 return;
180 }
181 approving = null;
182 notice = null;
183 await $.ui.close({ id: APPROVE_ID });
184 await act.reload();
185 },
186 cancelApproval: async () => {
187 approving = null;
188 await $.ui.close({ id: APPROVE_ID });
189 },
190 // ACT-3: ask the lead, and show the story as being reviewed until its turn ends.
191 review: async (line) => {
192 reviewing.add(line.story.id);
193 reviewTurn = 'next';
194 $.ui.invalidate('ui.render');
195 await $.prompt.submit({
196 text: `Review ${line.story.id} (${line.story.title}): run /code-kit:review ${line.story.branch}.`,
197 });
198 },
199 // ACT-4: confirm, then verify and merge as the person; nothing merges unless verify passes.
200 merge: async (line) => {
201 const { branch } = line.story;
202 const into = model.base ?? 'the base branch';
203 const answer = await $.ui
204 .ask(
205 `Merge ${branch} into ${into}? code-kit verify runs first, checks included, and nothing merges unless it passes.`,
206 ['Merge', 'Cancel'],
207 )
208 .catch(() => 'Cancel');
209 if (answer !== 'Merge') return;
210 const ran = await $.process.run(['node', cli, 'merge', branch, '--person'], {
211 cwd,
212 timeoutMs: 600000,
213 });
214 result = {
215 ok: ran.exitCode === 0,
216 title: ran.exitCode === 0 ? `Merged ${branch}` : `Nothing merged: ${branch}`,
217 text: `${ran.stdout}${ran.stderr}`.trim(),
218 };
219 await $.ui.open({ id: RESULT_ID, title: 'Merge', focus: true, closeOnEscape: true });
220 await act.reload();
221 },
222 // CARD-2: no model call; the transcript shows it.
223 approvals: async () => {
224 await act.reload();
225 if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
226 return { text: approvalsText(model.requests) };
227 },
228 // CARD-3: verify's own report, checks included.
229 verify: async () => {
230 if (model.state?.kind === 'none') return { text: NOT_CODE_KIT };
231 const ran = await $.process.run(['node', cli, 'verify'], { cwd, timeoutMs: 600000 });
232 return { text: `${ran.stdout}${ran.stderr}`.trim() };
233 },
234 dismiss: async () => {
235 notice = null;
236 $.ui.invalidate('ui.render');
237 },
238 };
239
240 // PANE-4 and BAND-3: within 2 seconds of a change. With nothing changed, the project is read
241 // again every 10 seconds while the pane is open, and every minute otherwise (approvals expire).
242 let seen = null;
243 let quiet = 0;
244 let busy = false;
245 $.clock.every(2000, async () => {
246 if (busy) return;
247 busy = true;
248 try {
249 const now = await act.fingerprint();
250 const paneOpen = (await $.ui.panes()).some((p) => p.id === PANE_ID);
251 quiet += 1;
252 if (now === seen && quiet < (paneOpen ? 5 : 30)) return;
253 quiet = 0;
254 await act.reload();
255 // Taken again: what it covers (the plan) can change with what was read.
256 seen = await act.fingerprint();
257 } finally {
258 busy = false;
259 }
260 });
261 return next(e);
262 });
263
264 const starting = (name) => ({
265 text: `code-kit is still starting; try /${name} again in a moment.`,
266 });
267 on('command.run', { command: 'lanes' }, async ($, e) => (act ? act.lanes() : starting('lanes')));
268 on('command.run', { command: 'approvals' }, async ($, e) =>
269 act ? act.approvals() : starting('approvals'),
270 );
271 on('command.run', { command: 'verify-branch' }, async ($, e) =>
272 act ? act.verify() : starting('verify-branch'),
273 );
274
275 // CARD-1: the text a code-kit hook refused a call with, kept for the call's result to draw as a card.
276 on('tool.call', async ($, e, next) => {
277 const res = await next(e);
278 const text = res?.deny ?? (res?.isError ? (res.text ?? String(res.result ?? '')) : null);
279 if (text && refusalCard(text)) {
280 refusals.set(e.tool_use_id, text);
281 if (refusals.size > REFUSALS_KEPT) refusals.delete(refusals.keys().next().value);
282 }
283 return res;
284 }).catch(($, e, next) => next(e)); // it only watches: whatever fails here, the call goes on as it would
285 // A refused call's text: kept from its tool.call, or the output its row carries (what the model read).
286 const refusalOf = (id, output) =>
287 refusalCard(refusals.get(id) ?? (typeof output === 'string' ? output : null));
288 // Shell calls fold into one line ("Ran 1 shell command"); a group holding a refusal unfolds, so
289 // the refused call's row can be drawn as its card.
290 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
291 const refused = e.props.calls.some((c) => c.tool_use_id && refusalOf(c.tool_use_id, c.output));
292 return refused && !e.props.isExpanded
293 ? next({ ...e, props: { ...e.props, isExpanded: true } })
294 : next(e);
295 });
296 // The card takes the call's row; the result block beneath a standalone row is then left empty.
297 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
298 const card = refusalOf(e.props.tool_use_id, e.props.output);
299 if (!card || !act) return next(e);
300 return $.ui.resolve(e).Box({ children: [] });
301 });
302 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
303 const id = e.props.tool_use_id;
304 const card = refusalOf(id, e.props.output);
305 if (!card || !act) return next(e);
306 return refusalView(card, expanded.has(id), $.ui.resolve(e), {
307 onApprove: () => act.approve({ request: card.request }),
308 onToggle: () => {
309 if (expanded.has(id)) expanded.delete(id);
310 else expanded.add(id);
311 $.ui.invalidate('ui.render');
312 },
313 });
314 });
315
316 on('turn.start', async ($, e, next) => {
317 if (reviewTurn === 'next') reviewTurn = e.turnId;
318 return next(e);
319 });
320 // The lead has reported on the review it was asked for.
321 on('turn.complete', async ($, e, next) => {
322 if (reviewTurn && reviewTurn === e.turnId) {
323 reviewing = new Set();
324 reviewTurn = null;
325 $.ui.invalidate('ui.render');
326 }
327 return next(e);
328 });
329
330 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) =>
331 lanesPane(model.state, $.ui.resolve(e)),
332 );
333
334 // BAND-2: absent when nothing waits. Its lines go above whatever else the band holds (another
335 // plugin's lines, such as Context Graph's, or Claude Code's own), which it keeps.
336 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
337 const lines = bandLines({ ...model, reviewing, notice });
338 if (!lines.length || !act) return next(e);
339 const ours = band(lines, $.ui.resolve(e), (id, line) => act[id](line));
340 const below = await next(e);
341 return below ? $.ui.resolve(e).Box({ flexDirection: 'column', children: [ours, below] }) : ours;
342 });
343
344 on('ui.render', { component: 'Pane', requestId: APPROVE_ID }, async ($, e) => {
345 if (!approving) return resultPane(null, $.ui.resolve(e));
346 return approvePane(approving, $.ui.resolve(e), {
347 onInput: (value) => {
348 approving = { ...approving, reason: value };
349 $.ui.invalidate('ui.render');
350 },
351 onSubmit: (value) => act.confirmApproval(value),
352 onCancel: () => act.cancelApproval(),
353 });
354 });
355
356 on('ui.render', { component: 'Pane', requestId: RESULT_ID }, async ($, e) =>
357 resultPane(result, $.ui.resolve(e)),
358 );
359}
360hooks/mod/view.mjs 428 lines1// What the code-kit mod shows, as pure functions of the CLI's JSON: the mod's hooks run the CLI and
2// pass its output here, and pass the elements `$.ui.resolve` gives them. Nothing here touches the mods
3// API, files or processes, so it is tested with node as well as with `claude plugin test`.
4
5export const PANE_ID = 'code-kit-lanes';
6export const NOT_CODE_KIT = "This project doesn't use code-kit.";
7const LEAD = 'lead';
8
9/** A CLI run's stdout as JSON, or null when it printed none. */
10export function parseJson(stdout) {
11 try {
12 return JSON.parse(stdout);
13 } catch {
14 return null;
15 }
16}
17
18/** The agent statuses that mean an agent is at work in the session now. */
19const AT_WORK = new Set(['pending', 'running', 'waiting']);
20
21/** The subagent types at work, from `$.agent.list()`. */
22export function agentsAtWork(agents) {
23 return new Set((agents ?? []).filter((a) => AT_WORK.has(a.status)).map((a) => a.type));
24}
25
26/** The stories `code-kit next --json` says can start now: dispatched to lanes, or the lead's own. */
27export function readyFrom(next) {
28 if (!next) return [];
29 const steps = [next, ...(next.then ?? [])].filter((s) => ['dispatch', 'lead'].includes(s.step));
30 return [...new Set(steps.flatMap((s) => (s.args ?? '').split(/\s+/).filter(Boolean)))];
31}
32
33/**
34 * One lane's row (PANE-2): the story it's on, its branch and its state.
35 * building a branch for the story and, if it has commits, the lane's agent still at work on it;
36 * in review commits on the branch (status's `review`) and the agent no longer at work;
37 * sent back a review sent the branch back, and the lane hasn't committed its fix yet;
38 * blocked the lane's next story waits on another;
39 * idle none of these.
40 * `status` can't tell a finished branch from one still being built, so the agent at work decides.
41 */
42function laneRow(name, agent, stories, atWork) {
43 const own = stories.filter((s) => s.lane === name);
44 const active = Boolean(agent && atWork.has(agent));
45 const story = own.find((s) => ['in progress', 'review', 'sent back'].includes(s.state));
46 if (story?.state === 'sent back')
47 return { name, agent, active, story, branch: story.branch, state: 'sent back' };
48 if (story) {
49 const state = story.state === 'review' && !active ? 'in review' : 'building';
50 return { name, agent, active, story, branch: story.branch, state };
51 }
52 const waiting = own.find((s) => ['blocked', 'ready', 'todo'].includes(s.state));
53 if (waiting?.state === 'blocked')
54 return { name, agent, active, story: waiting, branch: null, state: 'blocked' };
55 return { name, agent, active, story: null, branch: null, state: 'idle' };
56}
57
58/**
59 * Where the project stands, from `code-kit check --json` and, when the config is valid, `status
60 * --json` and `next --json` (each null when the config names no plan, or the run failed), and the
61 * subagent types at work:
62 * { kind: 'none' } no .claude/code-kit.json
63 * { kind: 'invalid', problems } a config that doesn't validate
64 * { kind: 'ok', lanes, ready, planGaps } a row per lane, what's ready, the plan's gaps (or null)
65 */
66export function projectState(check, status, next = null, atWork = new Set()) {
67 if (!check || check.exists === false) return { kind: 'none' };
68 if (!check.valid) return { kind: 'invalid', problems: check.problems ?? [] };
69 const stories = status?.stories ?? [];
70 const lanes = [
71 laneRow(LEAD, null, stories, atWork),
72 ...Object.entries(check.lanes ?? {}).map(([name, l]) =>
73 laneRow(name, l.agent, stories, atWork),
74 ),
75 ];
76 const ready = readyFrom(next).map((id) => {
77 const story = stories.find((s) => s.id === id);
78 return { id, title: story?.title ?? '', lane: story?.lane ?? null };
79 });
80 return { kind: 'ok', lanes, ready, planGaps: status ? (status.problems ?? []).length : null };
81}
82
83const STATE_COLOR = {
84 building: 'blue',
85 'in review': 'yellow',
86 'sent back': 'magenta',
87 blocked: 'red',
88 idle: undefined,
89};
90
91/** The Lanes pane's body, drawn with the elements `$.ui.resolve(e)` returned. */
92export function lanesPane(state, { Box, Text }) {
93 const text = (value, style = {}) => Text({ ...style, children: [value] });
94 if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
95 if (state.kind === 'invalid')
96 return text('The config is invalid: run code-kit check.', { color: 'red' });
97 const width = Math.max(...state.lanes.map((l) => l.name.length));
98 const rows = state.lanes.map((l) =>
99 Box({
100 key: `lane-${l.name}`,
101 flexDirection: 'column',
102 children: [
103 Box({
104 flexDirection: 'row',
105 columnGap: 2,
106 children: [
107 text(l.active ? '●' : ' ', { color: 'green' }),
108 text(l.name.padEnd(width), { bold: true }),
109 text(l.state.padEnd(9), STATE_COLOR[l.state] ? { color: STATE_COLOR[l.state] } : {}),
110 text(l.agent ?? 'the main session', { dimColor: true }),
111 ],
112 }),
113 ...(l.story
114 ? [
115 text(
116 ` ${l.story.id} ${l.story.title}${l.branch ? ` · ${l.branch}` : ''}${
117 l.state === 'blocked' ? ` · waits on ${l.story.waitingOn.join(', ')}` : ''
118 }`,
119 { dimColor: true, wrap: 'truncate-end' },
120 ),
121 ]
122 : []),
123 ],
124 }),
125 );
126 const notes = [];
127 if (state.planGaps)
128 notes.push(
129 text(`The plan has ${state.planGaps} gap(s): run code-kit status.`, { color: 'yellow' }),
130 );
131 const ready = state.ready.length
132 ? [
133 text('Ready', { bold: true }),
134 ...state.ready.map((r) =>
135 Box({
136 key: `ready-${r.id}`,
137 flexDirection: 'row',
138 columnGap: 2,
139 children: [
140 text(r.id),
141 text(r.title, { wrap: 'truncate-end' }),
142 text(r.lane === LEAD ? "the lead's own" : (r.lane ?? ''), { dimColor: true }),
143 ],
144 }),
145 ),
146 ]
147 : [text('Nothing is ready to start.', { dimColor: true })];
148 return Box({
149 flexDirection: 'column',
150 rowGap: 1,
151 children: [
152 ...notes,
153 Box({ flexDirection: 'column', children: rows }),
154 Box({ flexDirection: 'column', children: ready }),
155 ],
156 });
157}
158
159// --- the band, and the person's acts -----------------------------------------------------------
160
161export const APPROVE_ID = 'code-kit-approve';
162export const RESULT_ID = 'code-kit-result';
163
164/** An approval name as a person reads it: a dependency approval by its package. */
165export function approvalLabel(name) {
166 return name.startsWith('dep-') ? name.slice(4).replaceAll('+', '/') : name;
167}
168
169/** A command as a person reads it: the part before its pipes, redirects and chained commands. */
170export function commandItself(command) {
171 return String(command)
172 .split(/\s+(?:\d?>>?&?\d*|\|\|?|&&|;)(?:\s|$)/)[0]
173 .trim();
174}
175
176/** The reason an Approve… confirmation starts with (ACT-1). */
177export function prefilledReason(request) {
178 return `Approve ${request.names.map(approvalLabel).join(', ')} for ${
179 request.lane ? `the ${request.lane} lane` : 'any agent'
180 }: ${commandItself(request.what)}`;
181}
182
183const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
184
185/**
186 * The band's lines (BAND-2): one per kind of thing waiting, each with its actions, and none when
187 * nothing waits. `reviewing` holds the stories the person has asked the lead to review (ACT-3);
188 * `notice` is why the person's last act failed, until they dismiss it.
189 */
190export function bandLines({ state, requests, stops, reviewing = new Set(), notice = null }) {
191 const lines = [];
192 if (notice)
193 lines.push({ kind: 'notice', text: notice, actions: [{ id: 'dismiss', label: 'Dismiss' }] });
194 if (!state || state.kind !== 'ok') return lines;
195 const open = requests?.open ?? [];
196 if (open.length)
197 lines.push({
198 kind: 'approvals',
199 text: `${plural(open.length, 'approval', 'approvals')} waiting`,
200 request: open[0],
201 actions: [{ id: 'approve', label: 'Approve…' }],
202 });
203 const inReview = state.lanes.filter((l) => l.state === 'in review').map((l) => l.story);
204 if (inReview.length) {
205 const [story] = inReview;
206 const ids = inReview.map((s) => s.id).join(', ');
207 lines.push(
208 reviewing.has(story.id)
209 ? { kind: 'review', text: `${story.id} being reviewed`, story, actions: [] }
210 : {
211 kind: 'review',
212 text: `${ids} ready for review`,
213 story,
214 actions: [
215 { id: 'review', label: 'Review' },
216 { id: 'merge', label: 'Merge' },
217 ],
218 },
219 );
220 }
221 if (stops?.length)
222 lines.push({
223 kind: 'finish',
224 text: `Finish check failing: ${stops[0].title}${stops.length > 1 ? ` (and ${stops.length - 1} more)` : ''}`,
225 actions: [{ id: 'lanes', label: 'Lanes' }],
226 });
227 // BAND-4: a digit for every action, in the order they're drawn.
228 let digit = 0;
229 for (const line of lines)
230 for (const action of line.actions) action.hotkey = digit < 9 ? String(++digit) : undefined;
231 return lines;
232}
233
234/** The band, drawn with the elements `$.ui.resolve(e)` returned; `onAction(id, line)` acts. */
235export function band(lines, { Box, Text, Button }, onAction) {
236 const COLOR = { notice: 'red', approvals: 'yellow', review: 'blue', finish: 'red' };
237 return Box({
238 flexDirection: 'column',
239 children: lines.map((line) =>
240 Box({
241 key: `band-${line.kind}`,
242 flexDirection: 'row',
243 columnGap: 2,
244 children: [
245 Text({ color: COLOR[line.kind], children: ['code-kit'] }),
246 Text({ children: [line.text] }),
247 ...line.actions.map((a) =>
248 Button({
249 key: `band-${a.id}`,
250 label: a.hotkey ? `${a.hotkey} ${a.label}` : a.label,
251 hotkey: a.hotkey,
252 onPress: () => onAction(a.id, line),
253 }),
254 ),
255 ],
256 }),
257 ),
258 });
259}
260
261/** The Approve… confirmation (ACT-1): what it grants, to whom, for how long, and the reason. */
262export function approvePane(
263 approving,
264 { Box, Text, Input, Button },
265 { onInput, onSubmit, onCancel },
266) {
267 const { request, reason, error } = approving;
268 return Box({
269 flexDirection: 'column',
270 rowGap: 1,
271 children: [
272 Text({ bold: true, children: [`Approve ${request.names.join(', ')}`] }),
273 Text({
274 children: [
275 `For ${request.lane ? `the ${request.lane} lane` : 'any agent'}, ${request.names.every((n) => n.startsWith('dep-')) ? 'until the install is committed (at most 7 days)' : 'for 60 minutes'}. Asked for: ${request.what}`,
276 ],
277 }),
278 Input({
279 key: 'approve-reason',
280 label: 'Reason',
281 value: reason,
282 submitLabel: 'Approve',
283 autoFocus: true,
284 onInput,
285 onSubmit,
286 }),
287 ...(error ? [Text({ color: 'red', children: [error] })] : []),
288 Box({
289 flexDirection: 'row',
290 columnGap: 2,
291 children: [
292 Button({ key: 'approve-confirm', label: 'Approve', onPress: () => onSubmit(reason) }),
293 Button({ key: 'approve-cancel', label: 'Cancel', onPress: onCancel }),
294 ],
295 }),
296 ],
297 });
298}
299
300/** What a merge (or another act run through the CLI) reported. */
301export function resultPane(result, { Box, Text }) {
302 if (!result) return Text({ dimColor: true, children: ['Nothing to show.'] });
303 return Box({
304 flexDirection: 'column',
305 rowGap: 1,
306 children: [
307 Text({ bold: true, color: result.ok ? 'green' : 'red', children: [result.title] }),
308 Text({ children: [result.text.slice(-9000) || '(no output)'] }),
309 ],
310 });
311}
312
313// --- refusal cards and the commands ------------------------------------------------------------
314
315const APPROVAL_LINE =
316 /^\s*! echo "<what you are approving>" > .*?\.claude\/approvals\/(?:([^/\s]+)\/)?([^/\s]+)\s*$/gm;
317
318/**
319 * A code-kit refusal read back from the text its hooks print (CARD-1): what was refused, the rule and
320 * why, what to do, and the approval that would allow it, if any. Null for text it doesn't recognise,
321 * which Claude Code then draws as it would.
322 */
323export function refusalCard(raw) {
324 const at = typeof raw === 'string' ? raw.indexOf('Blocked: ') : -1;
325 if (at < 0) return null;
326 const text = raw.slice(at + 'Blocked: '.length).trim();
327 const [first, ...rest] = text.split('\n');
328 const approvals = [...text.matchAll(APPROVAL_LINE)];
329 const command = text.match(/^Command: (.+)$/m)?.[1] ?? null;
330 const request = (what) =>
331 approvals.length
332 ? { names: approvals.map((m) => m[2]), lane: approvals[0][1] ?? null, what }
333 : null;
334 let m = first.match(/^The (.+?) may not write (\S+?)(?: \(owned by the (.+)\))?\.$/);
335 if (m)
336 return {
337 kind: 'write',
338 title: `Write refused: ${m[2]}`,
339 why: m[3] ? `It's owned by the ${m[3]}, not the ${m[1]}.` : `Nobody may write it.`,
340 todo: rest.find((l) => l.trim()) ?? '',
341 request: null,
342 raw: text,
343 };
344 m = first.match(/^(\S+) is (.+) and needs a person's approval\./);
345 if (m)
346 return {
347 kind: 'approval',
348 title: `Approval needed: ${m[1]}`,
349 why: `It's ${m[2]}.`,
350 todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
351 request: request(`write ${m[1]}`),
352 raw: text,
353 };
354 m = first.match(/^a new dependency \((.+?)\) needs a person's approval\./);
355 if (m)
356 return {
357 kind: 'dependency',
358 title: `Install refused: ${m[1]}`,
359 why: "A new dependency needs a person's approval.",
360 todo: 'A person approves it, here or with the `!` command, until the install is committed (at most 7 days).',
361 request: request(command ?? `install ${m[1]}`),
362 raw: text,
363 };
364 if (command)
365 return {
366 kind: 'command',
367 title: 'Command refused',
368 why: first,
369 todo: rest.filter((l) => l.trim() && !l.startsWith('Command: ')).join(' '),
370 command,
371 request: request(command),
372 raw: text,
373 };
374 return null;
375}
376
377/** A refusal card (CARD-1), with Approve… where an approval would allow it and the raw text a press away. */
378export function refusalView(card, expanded, { Box, Text, Button }, { onApprove, onToggle }) {
379 return Box({
380 flexDirection: 'column',
381 borderStyle: 'round',
382 paddingX: 1,
383 children: [
384 Text({ bold: true, color: 'red', children: [card.title] }),
385 Text({ children: [card.why] }),
386 ...(card.command ? [Text({ dimColor: true, children: [card.command] })] : []),
387 ...(card.todo ? [Text({ dimColor: true, children: [card.todo] })] : []),
388 Box({
389 flexDirection: 'row',
390 columnGap: 2,
391 children: [
392 ...(card.request
393 ? [Button({ key: 'card-approve', label: 'Approve…', onPress: onApprove })]
394 : []),
395 Button({
396 key: 'card-raw',
397 label: expanded ? 'Hide the text' : 'Show the text',
398 plain: true,
399 onPress: onToggle,
400 }),
401 ],
402 }),
403 ...(expanded ? [Text({ dimColor: true, children: [card.raw] })] : []),
404 ],
405 });
406}
407
408/** `/approvals` (CARD-2): the open requests and the approvals in force, from `requests --json`. */
409export function approvalsText(requests) {
410 const open = requests?.open ?? [];
411 const inForce = requests?.inForce ?? [];
412 const lines = [
413 open.length ? `${plural(open.length, 'request', 'requests')} waiting:` : 'No requests waiting.',
414 ...open.map(
415 (r) =>
416 ` ${r.names.map(approvalLabel).join(', ')} for ${r.lane ? `the ${r.lane} lane` : 'any agent'}: ${r.what}`,
417 ),
418 inForce.length
419 ? `${plural(inForce.length, 'approval', 'approvals')} in force:`
420 : 'No approvals in force.',
421 ...inForce.map(
422 (a) =>
423 ` ${approvalLabel(a.name)} for ${a.lane ? `the ${a.lane} lane` : 'any agent'}, ${a.minutesLeft} min left: ${a.reason}`,
424 ),
425 ];
426 return lines.join('\n');
427}
428