@warren-dean/code-kit 0.3.12 → 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.
@@ -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
+ }