@warren-dean/code-kit 0.3.15 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "name": "code-kit",
10
10
  "source": "./",
11
11
  "description": "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 and design gates, proof-before-finish checks, dispatch, review, build status and CI verification, configured per project from its docs",
12
- "version": "0.3.15",
12
+ "version": "0.4.1",
13
13
  "category": "development",
14
14
  "tags": ["guardrails", "architecture", "lanes", "layers", "spec-driven", "ci", "multi-agent"],
15
15
  "license": "MIT"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-kit",
3
- "version": "0.3.15",
3
+ "version": "0.4.1",
4
4
  "description": "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 and design gates, proof-before-finish checks, dispatch, review, build status and CI verification, configured per project from its docs",
5
5
  "author": {
6
6
  "name": "Warren Langeveldt"
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  What changed in each release of code-kit, newest first. Versions follow the plugin manifest and the npm package `@warren-dean/code-kit`. Each release is also on [GitHub Releases](https://github.com/warrendeanlangeveldt/code-kit/releases).
4
4
 
5
+ ## 0.4.1 (2026-10-07)
6
+
7
+ - **The band keeps other plugins' lines:** code-kit's lines go above whatever else the band holds, such as Context Graph's "N proposals to ratify", instead of replacing it.
8
+
9
+ ## 0.4.0 (2026-10-07)
10
+
11
+ code-kit in the session: a mod for Claude Code 2.1.287 or later, in the terminal and the Desktop app. Older versions skip it and keep enforcing everything through the hooks, as before. See [In the session](README.md#in-the-session).
12
+
13
+ - **The band** above the prompt shows what waits for you: approvals agents asked for (**Approve…**, with a reason you keep or rewrite), stories ready for review (**Review**, **Merge**), and a finish check failing. Each has a digit hotkey, and the band is gone when nothing waits.
14
+ - **`/lanes`** opens a pane with each lane's agent, story, branch and state, the agents at work, and what's ready. It stays current within 2 seconds of a change.
15
+ - **Refusal cards:** a code-kit refusal reads as a card with the rule, what to do, and Approve… where an approval would allow it.
16
+ - **`/approvals`** and **`/verify-branch`** print without a model call.
17
+ - **New in the CLI:** `code-kit stops` lists finish checks still failing, and `check --json` reports the config's state for the mod.
18
+ - A merge from the pane says so in its commit.
19
+ - `code-kit` with no command now prints its whole usage.
20
+
5
21
  ## 0.3.15 (2026-10-06)
6
22
 
7
23
  - **`verify` accepts your Context Graph ratifications:** a change to `.ctx/graph.ctx` whose commits all carry your `Ctx-Ratified-By` trailer passes without an approval-log entry. That's what `ctx ratify --commit` and `ctx drop --commit` write from your terminal, where code-kit's hooks don't run to log it. Agents still can't write that trailer, and a delegated trailer still needs the log.
package/README.md CHANGED
@@ -101,6 +101,7 @@ Set it up at the start of a new build, straight from the architecture docs, or b
101
101
  | `Stop`, `SubagentStop` | `hooks/stop-check.mjs` | proof before finishing |
102
102
 
103
103
  - **For the main session and every subagent**, including agents in worktrees under `.claude/worktrees/`.
104
+ - **In front of you, in the terminal and the Desktop app,** through the code-kit mod (Claude Code 2.1.287 or later; see [In the session](#in-the-session)). It shows and acts, and never enforces: the hooks above do that everywhere, the mod or not.
104
105
  - **In CI**, through `code-kit verify` in a GitHub Actions workflow that init can install (`templates/github-workflow.yml`).
105
106
  - **Locally, with Node 22 or later.** Nothing is sent anywhere. The CLI in `bin/` runs the same rules from a terminal.
106
107
 
@@ -261,6 +262,23 @@ Editing an agent file can't loosen a rule. And the agent files are kit files (`.
261
262
 
262
263
  **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.
263
264
 
265
+ ## In the session
266
+
267
+ 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.
268
+
269
+ - **The band** sits above the prompt whenever something waits for you, and is gone when nothing does. It shows:
270
+ - approvals agents have asked for: **Approve…** opens a confirmation with a reason prefilled from the request, which you keep or rewrite;
271
+ - stories finished and waiting for review: **Review** asks the lead to run `/code-kit:review` on the branch, and **Merge** runs `verify`, checks included, then merges if it passes;
272
+ - a finish check failing in this session: **Lanes** opens the pane.
273
+
274
+ Each button has a digit: type it in an empty prompt to press it.
275
+
276
+ - **`/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.
277
+ - **Refusals** a code-kit hook gives an agent are drawn as cards: what was refused, the rule, what to do, and Approve… where your approval would allow it. The full text is a press away.
278
+ - **`/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.
279
+
280
+ 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`.
281
+
264
282
  ## Build
265
283
 
266
284
  Once init has switched the rules on, the lead runs the build in a loop:
@@ -299,7 +317,7 @@ Lanes never change silently: only when you approve the diff.
299
317
  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` skill runs `check` and `unowned` for you. `next` also works in a project without code-kit, or outside a git repository.
300
318
 
301
319
  ```bash
302
- code-kit check # validate and summarise
320
+ code-kit check [--json] # validate and summarise
303
321
  code-kit who <path>... # owner and layer of each path
304
322
  code-kit unowned # tracked files nobody may write
305
323
  code-kit diff --config .claude/code-kit.draft.json # what a draft changes
@@ -311,6 +329,7 @@ code-kit next [--base ref] [--json] # the step to take now, from the project'
311
329
  code-kit trace <path>... [--json] # what a file is for: requirements, lane, layer rules
312
330
  code-kit merge <branch> --delegated|--person [--into branch] # merge a branch that passes verify: the lead where approvals.delegate.merge is on, or the person
313
331
  code-kit requests [--json] # approval requests the hooks refused, still open, and the approvals in force
332
+ code-kit stops [--session id] [--json] # finish checks that refused an agent's last stop and still fail
314
333
  code-kit approve <name>... --reason "…" [--lane name] [--delegated | --via pane] # record a person's approval from chat, or (--delegated) grant one within the delegated rules; a package name stands for its dep- approval
315
334
  ```
316
335
 
package/bin/code-kit.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Run from the project root: node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" <command>
3
- // check validate the config and summarise it
3
+ // check [--json] validate the config and summarise it
4
4
  // who <path>... who may write each path, and its layer
5
5
  // unowned tracked files nobody may write
6
6
  // diff what a draft changes against the live config, and whose files move
@@ -18,10 +18,11 @@
18
18
  // merge <branch> --delegated|--person [--into branch] merge a branch that passes verify: the lead
19
19
  // (approvals.delegate.merge) or the person (the code-kit pane)
20
20
  // requests [--json] open approval requests and approvals in force
21
+ // stops [--session id] [--json] finish checks that refused an agent's last stop, still failing
21
22
  // record approvals a person gave in chat (with approvals.lead on, the lead runs it)
22
23
  // --config <file> reads a draft (.claude/code-kit.draft.json) instead of .claude/code-kit.json.
23
24
  import { execFileSync } from 'node:child_process';
24
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
25
26
  import { tmpdir } from 'node:os';
26
27
  import { join, posix, resolve } from 'node:path';
27
28
  import { BASELINE_FILE, loadBaseline } from '../hooks/lib/baseline.mjs';
@@ -71,6 +72,7 @@ const reason = option('--reason');
71
72
  const laneName = option('--lane');
72
73
  const intoName = option('--into');
73
74
  const via = option('--via');
75
+ const sessionId = option('--session');
74
76
  const FLAGS = ['--write', '--no-checks', '--json', '--delegated', '--person'];
75
77
  const flag = (name) => args.includes(name);
76
78
  const writeBaseline = flag('--write');
@@ -92,7 +94,32 @@ function load(path = file) {
92
94
  return { raw, config: effectiveConfig(raw, '.') };
93
95
  }
94
96
 
97
+ /** The config as the mod reads it: whether it's there and valid, and the lead's and lanes' paths. */
98
+ function checkJson() {
99
+ const report = { file, exists: existsSync(resolve(file)), valid: false, problems: [] };
100
+ if (!report.exists) return (out(JSON.stringify(report, null, 2)), process.exit(1));
101
+ let raw;
102
+ try {
103
+ raw = JSON.parse(read(file));
104
+ } catch (e) {
105
+ report.problems = [e.message];
106
+ return (out(JSON.stringify(report, null, 2)), process.exit(1));
107
+ }
108
+ report.problems = validate(raw);
109
+ if (report.problems.length) return (out(JSON.stringify(report, null, 2)), process.exit(1));
110
+ const config = effectiveConfig(raw, '.');
111
+ report.valid = true;
112
+ report.lead = { paths: config.lead.paths };
113
+ report.lanes = Object.fromEntries(
114
+ Object.entries(config.lanes).map(([name, l]) => [name, { agent: l.agent, paths: l.paths }]),
115
+ );
116
+ report.adapters = config.adapters;
117
+ report.docs = config.docs ?? null;
118
+ out(JSON.stringify(report, null, 2));
119
+ }
120
+
95
121
  function check() {
122
+ if (flag('--json')) return checkJson();
96
123
  const { raw } = load();
97
124
  out(`${file} is valid.`);
98
125
  out(`Lead: ${raw.lead.paths.join(', ')}`);
@@ -347,7 +374,7 @@ function merge(branch) {
347
374
  '-m',
348
375
  `Merge ${branch} into ${into}`,
349
376
  '-m',
350
- `Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to the ${lane} lane` : ''}) and merged ${byPerson ? 'by the person' : 'by the lead within the delegated rules'}.`,
377
+ `Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to the ${lane} lane` : ''}) and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}.`,
351
378
  branch,
352
379
  );
353
380
  } catch (e) {
@@ -386,6 +413,33 @@ function requests() {
386
413
  );
387
414
  }
388
415
 
416
+ /** Finish checks still failing: the stop hook's last refusal for each session and agent. */
417
+ function stops() {
418
+ load();
419
+ const dir = resolve('.claude/state/stop-blocks');
420
+ const found = [];
421
+ for (const name of existsSync(dir) ? readdirSync(dir) : []) {
422
+ try {
423
+ const b = JSON.parse(readFileSync(join(dir, name), 'utf8'));
424
+ if (sessionId && b.session !== sessionId) continue;
425
+ found.push({
426
+ session: b.session ?? null,
427
+ agent: b.agent ?? null,
428
+ agentType: b.agentType ?? null,
429
+ title: String(b.message ?? '').split('\n')[0],
430
+ count: b.count ?? 1,
431
+ at: b.at ?? null,
432
+ });
433
+ } catch {
434
+ // a file the hook is still writing, or not one of its own
435
+ }
436
+ }
437
+ if (flag('--json')) return out(JSON.stringify(found, null, 2));
438
+ if (!found.length) return out('No finish check is failing.');
439
+ for (const s of found)
440
+ out(`${s.agentType ?? 'the lead'}${s.session ? ` (session ${s.session})` : ''}: ${s.title}`);
441
+ }
442
+
389
443
  function verify() {
390
444
  const { config } = load();
391
445
  const from = base(config, true);
@@ -616,6 +670,7 @@ const commands = {
616
670
  approve: () => (rest.length ? approve(rest) : usage()),
617
671
  merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
618
672
  requests,
673
+ stops,
619
674
  trace: () => (rest.length ? trace(rest) : usage()),
620
675
  adapters,
621
676
  next,
@@ -632,8 +687,9 @@ function usage() {
632
687
  'Usage: code-kit check | who <path>... | unowned | diff | baseline [--write] | graph [--depth N]\n' +
633
688
  ' | verify [--base ref] [--branch name] [--no-checks] | status [--base ref] [--json]\n' +
634
689
  ' | next [--base ref] [--json] | adapters | trace <path>... [--json]\n' +
635
- ' | approve <name>... --reason "…" [--lane name] [--config file]',
636
- ' | merge <branch> --delegated|--person [--into branch] | requests [--json]',
690
+ ' | approve <name>... --reason "…" [--lane name] [--config file]\n' +
691
+ ' | merge <branch> --delegated|--person [--into branch] | requests [--json]\n' +
692
+ ' | stops [--session id] [--json]',
637
693
  );
638
694
  }
639
695
  await (commands[command] ?? usage)();
package/hooks/hooks.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "description": "code-kit: lanes, protected paths with an approval log, spec-check and design gates, layer rules and the project's checks, from .claude/code-kit.json",
3
+ "modules": ["./mod/register.mjs"],
3
4
  "hooks": {
4
5
  "PreToolUse": [
5
6
  {
@@ -0,0 +1,359 @@
1
+ // 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.
10
+ import {
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.
30
+ let model = { state: null, requests: null, stops: [] };
31
+ let reviewing = new Set(); // stories the person asked the lead to review (ACT-3)
32
+ let reviewTurn = null; // 'next' until the lead's review turn starts, then its id
33
+ let approving = null; // the open Approve… confirmation: { request, reason, error }
34
+ let result = null; // what the last merge reported
35
+ let notice = null; // why the person's last act failed
36
+ let act = null; // the session's actions, made at session start
37
+ const refusals = new Map(); // tool_use_id → the refusal text a hook gave (CARD-1)
38
+ const expanded = new Set(); // cards showing their raw text
39
+ const REFUSALS_KEPT = 200;
40
+
41
+ export 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
+ }