@warren-dean/code-kit 0.3.15 → 0.5.0

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,716 @@
1
+ // The mod end to end through Claude Code's harness: `claude plugin test` (Claude Code 2.1.287 or later),
2
+ // which `npm test` runs when it's available. The CLI, git and the agents are stubbed with what they
3
+ // report; the CLI itself is tested in test/hooks.test.mjs, and the drawing in test/units.test.mjs.
4
+ import { expect, mock, test } from 'claude-code/testing';
5
+
6
+ const PANE = 'code-kit-lanes';
7
+ const APPROVE = 'code-kit-approve';
8
+ const RESULT = 'code-kit-result';
9
+
10
+ /** A project as the stubs report it; tests change it to move the world on. */
11
+ function project() {
12
+ return {
13
+ check: {
14
+ file: '.claude/code-kit.json',
15
+ exists: true,
16
+ valid: true,
17
+ problems: [] as string[],
18
+ lead: { paths: ['docs/**'] },
19
+ lanes: {
20
+ web: { agent: 'web-engineer', paths: ['apps/web/**'] },
21
+ api: { agent: 'api-engineer', paths: ['apps/api/**'] },
22
+ core: { agent: 'core-engineer', paths: ['packages/core/**'] },
23
+ } as Record<string, { agent: string; paths: string[] }>,
24
+ adapters: [],
25
+ docs: { specs: 'docs/specs', plan: 'docs/plan.md' },
26
+ },
27
+ stories: [
28
+ {
29
+ id: 'ST-4',
30
+ title: 'Booking form',
31
+ lane: 'web',
32
+ state: 'in progress',
33
+ branch: 'web/st-4',
34
+ waitingOn: [],
35
+ },
36
+ {
37
+ id: 'ST-6',
38
+ title: 'Contracts',
39
+ lane: 'lead',
40
+ state: 'ready',
41
+ branch: 'lead/st-6',
42
+ waitingOn: [],
43
+ },
44
+ {
45
+ id: 'ST-7',
46
+ title: 'Schema',
47
+ lane: 'lead',
48
+ state: 'ready',
49
+ branch: 'lead/st-7',
50
+ waitingOn: [],
51
+ },
52
+ {
53
+ id: 'ST-9',
54
+ title: 'Pricing',
55
+ lane: 'core',
56
+ state: 'ready',
57
+ branch: 'core/st-9',
58
+ waitingOn: [],
59
+ },
60
+ ] as any[],
61
+ next: { step: 'dispatch', args: 'ST-9', then: [{ step: 'lead', args: 'ST-6 ST-7' }] } as any,
62
+ requests: [] as any[],
63
+ inForce: [] as any[],
64
+ verifyOut: 'No problems.',
65
+ verifyExit: 0,
66
+ refusal: '',
67
+ drawn: [] as any[],
68
+ taken: [] as string[], // command names another command already holds
69
+ stops: [] as any[],
70
+ refs: ' aaa refs/heads/main\n bbb refs/heads/web/st-4\n',
71
+ agents: [] as { type: string; status: string }[],
72
+ approveExit: 0,
73
+ mergeExit: 0,
74
+ answer: 'Merge',
75
+ open: new Set<string>(),
76
+ opened: [] as string[],
77
+ closed: [] as string[],
78
+ prompts: [] as string[],
79
+ acts: [] as string[][],
80
+ runs: 0,
81
+ };
82
+ }
83
+ type World = ReturnType<typeof project>;
84
+
85
+ function stub(on: any, w: World) {
86
+ on('process.run', ($: any, e: any) => {
87
+ const argv: readonly string[] = e.argv;
88
+ w.runs += 1;
89
+ const ran = (exitCode: number, stdout: string, stderr = '') => ({
90
+ value: { exitCode, stdout, stderr },
91
+ });
92
+ const sub = argv[2];
93
+ if (argv[0] === 'git') return ran(0, w.refs);
94
+ if (sub === 'check') return ran(w.check.valid ? 0 : 1, JSON.stringify(w.check));
95
+ if (sub === 'status')
96
+ return ran(
97
+ 0,
98
+ JSON.stringify({
99
+ base: 'main',
100
+ stories: w.stories,
101
+ requirements: {},
102
+ problems: [],
103
+ drift: [],
104
+ }),
105
+ );
106
+ if (sub === 'next') return ran(0, JSON.stringify(w.next));
107
+ if (sub === 'requests') return ran(0, JSON.stringify({ open: w.requests, inForce: w.inForce }));
108
+ if (sub === 'verify')
109
+ return ran(w.verifyExit, w.verifyExit ? '' : w.verifyOut, w.verifyExit ? w.verifyOut : '');
110
+ if (sub === 'stops') return ran(0, JSON.stringify(w.stops));
111
+ if (sub === 'approve') {
112
+ w.acts.push([...argv.slice(2)]);
113
+ if (w.approveExit === 0) w.requests = [];
114
+ return ran(w.approveExit, '', w.approveExit ? 'approve needs --reason' : '');
115
+ }
116
+ if (sub === 'merge') {
117
+ w.acts.push([...argv.slice(2)]);
118
+ return w.mergeExit
119
+ ? ran(1, '', 'Nothing was merged. code-kit verify found 1 problem(s) on web/st-4')
120
+ : ran(0, 'Merged web/st-4 into main.');
121
+ }
122
+ return ran(1, '', `unexpected ${argv.join(' ')}`);
123
+ });
124
+ on('session.start', () => ({ cwd: '/work' }));
125
+ // Claude Code's own drawing, beneath the mod: nothing of code-kit's.
126
+ on('ui.render', ($: any, e: any) => {
127
+ w.drawn.push(e);
128
+ return { type: 'Box', props: {}, children: [] };
129
+ });
130
+ // What the settings hooks beneath answer a Write or a Bash call with.
131
+ on('tool.call', { tool: 'Write' }, () =>
132
+ w.refusal ? { deny: w.refusal } : { result: { type: 'create' } },
133
+ );
134
+ on('tool.call', { tool: 'Bash' }, () =>
135
+ w.refusal ? { deny: w.refusal } : { result: { stdout: '', stderr: '', interrupted: false } },
136
+ );
137
+ on('agent.list', () => ({ value: w.agents }));
138
+ on('fs.stat', () => ({ value: { kind: 'file', size: 1, mtimeMs: 1, isLink: false } }));
139
+ on('fs.list', () => ({ value: [] }));
140
+ on('session.cwd', () => ({ value: '/work' }));
141
+ on('session.id', () => ({ value: 's1' }));
142
+ on('command.register', ($: any, e: any) =>
143
+ w.taken.includes(e.name) ? { deny: `/${e.name} is taken` } : { value: undefined },
144
+ );
145
+ on('prompt.submit', ($: any, e: any) => {
146
+ w.prompts.push(e.text);
147
+ return { text: e.text };
148
+ });
149
+ // $.ui.ask is the AskUserQuestion dialog: the person picks a label.
150
+ on('tool.call', { tool: 'AskUserQuestion' }, ($: any, e: any) => ({
151
+ result: { questions: e.questions, answers: { [e.questions[0].question]: w.answer } },
152
+ }));
153
+ on('ui.panes', () => ({ value: [...w.open].map((id) => ({ id, title: id, isShown: true })) }));
154
+ on('ui.open', ($: any, e: any) => {
155
+ w.opened.push(e.id);
156
+ w.open.add(e.id);
157
+ return { value: { isPlaced: true } };
158
+ });
159
+ on('ui.close', ($: any, e: any) => {
160
+ w.closed.push(e.id);
161
+ w.open.delete(e.id);
162
+ return { value: undefined };
163
+ });
164
+ }
165
+
166
+ /** A session started on the project, with the mock clock. */
167
+ async function start($: any, on: any, w: World) {
168
+ stub(on, w);
169
+ const clock = mock.clock(on);
170
+ await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true });
171
+ await clock.advance(2000); // the first look at the project
172
+ return clock;
173
+ }
174
+
175
+ const pane = ($: any, requestId: string) =>
176
+ $.ui.mount({
177
+ plugin: 'code-kit',
178
+ component: 'Pane',
179
+ requestId,
180
+ surface: 'terminal',
181
+ viewport: { columns: 120, rows: 40 },
182
+ props: { title: requestId, isFocused: true, bodyColumns: 80 },
183
+ });
184
+ const bandUi = ($: any) =>
185
+ $.ui.mount({
186
+ plugin: 'code-kit',
187
+ component: 'AbovePrompt',
188
+ requestId: 'above-prompt',
189
+ surface: 'terminal',
190
+ viewport: { columns: 120, rows: 40 },
191
+ props: { hasSurvey: false, isWorking: false, maxRows: 6, bodyColumns: 100 },
192
+ });
193
+ const press = ($: any, key: string, requestId?: string) =>
194
+ $.ui.press({ plugin: 'code-kit', key, ...(requestId ? { requestId } : {}) });
195
+ const lanes = ($: any) => $.command.run({ command: 'lanes', args: '' });
196
+ const dayjs = {
197
+ at: '2026-10-06T00:00:00.000Z',
198
+ actor: 'web-engineer',
199
+ lane: 'web',
200
+ names: ['dep-dayjs'],
201
+ what: 'npm install dayjs',
202
+ why: 'a new dependency',
203
+ };
204
+
205
+ // --- the Lanes pane -------------------------------------------------------------------------------
206
+
207
+ test('PANE-1 /lanes opens the Lanes pane, and /lanes again closes it', async ($, on) => {
208
+ const w = project();
209
+ await start($, on, w);
210
+ const first = await lanes($);
211
+ expect(w.opened).toEqual([PANE]);
212
+ expect(first.text ?? '').toBe('');
213
+ await lanes($);
214
+ expect(w.opened).toEqual([PANE]);
215
+ expect(w.closed).toEqual([PANE]);
216
+ });
217
+
218
+ test('PANE-1 after Escape closes the pane, /lanes opens it again', async ($, on) => {
219
+ const w = project();
220
+ await start($, on, w);
221
+ await lanes($);
222
+ w.open.clear(); // the person's Escape: the pane is no longer among those open
223
+ await lanes($);
224
+ expect(w.opened).toEqual([PANE, PANE]);
225
+ });
226
+
227
+ test('PANE-2 each lane shows its agent, story, branch and state', async ($, on) => {
228
+ const w = project();
229
+ await start($, on, w);
230
+ await lanes($);
231
+ const ui = await pane($, PANE);
232
+ expect(await ui.find({ key: 'lane-web' })).toBeDefined();
233
+ expect(await ui.find({ type: 'Text', text: /web-engineer/ })).toBeDefined();
234
+ expect(await ui.find({ type: 'Text', text: 'ST-4 Booking form · web/st-4' })).toBeDefined();
235
+ expect(await ui.find({ type: 'Text', text: /building/ })).toBeDefined();
236
+ expect(await ui.find({ type: 'Text', text: /idle/ })).toBeDefined();
237
+ await ui.unmount();
238
+ });
239
+
240
+ test('PANE-3 the ready stories are listed with their lanes, the lead stories as its own', async ($, on) => {
241
+ const w = project();
242
+ await start($, on, w);
243
+ await lanes($);
244
+ const ui = await pane($, PANE);
245
+ for (const id of ['ST-6', 'ST-7', 'ST-9'])
246
+ expect(await ui.find({ key: `ready-${id}` })).toBeDefined();
247
+ expect(await ui.findAll({ type: 'Text', text: "the lead's own" })).toHaveLength(2);
248
+ await ui.unmount();
249
+ });
250
+
251
+ test('PANE-4 a lane that commits shows the change within 2 seconds', async ($, on) => {
252
+ const w = project();
253
+ const clock = await start($, on, w);
254
+ await lanes($);
255
+ w.agents = [{ type: 'web-engineer', status: 'running' }];
256
+ w.stories[0] = { ...w.stories[0], state: 'review' };
257
+ w.refs = ' aaa refs/heads/main\n ccc refs/heads/web/st-4\n';
258
+ await clock.advance(2000);
259
+ const building = await pane($, PANE);
260
+ expect(await building.find({ type: 'Text', text: '●' })).toBeDefined();
261
+ await building.unmount();
262
+ // The agent finishes: the branch now waits for review, and the active mark goes.
263
+ w.agents = [{ type: 'web-engineer', status: 'completed' }];
264
+ await clock.advance(2000);
265
+ const finished = await pane($, PANE);
266
+ expect(await finished.find({ type: 'Text', text: /in review/ })).toBeDefined();
267
+ expect(await finished.find({ type: 'Text', text: '●' })).toBeUndefined();
268
+ await finished.unmount();
269
+ });
270
+
271
+ test('PANE-4 with nothing changed it reads the project every 10 seconds while the pane is open', async ($, on) => {
272
+ const w = project();
273
+ const clock = await start($, on, w);
274
+ await lanes($);
275
+ const before = w.runs;
276
+ await clock.advance(8000); // four quiet ticks: git only
277
+ expect(w.runs - before).toBe(4);
278
+ // The fifth reads it all (check, status, next, requests, stops) between two looks at git.
279
+ await clock.advance(2000);
280
+ expect(w.runs - before).toBe(11);
281
+ });
282
+
283
+ test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
284
+ const w = project();
285
+ w.check = { ...w.check, exists: false, valid: false };
286
+ await start($, on, w);
287
+ const res = await lanes($);
288
+ expect(res.text).toBe("This project doesn't use code-kit.");
289
+ expect(w.opened).toEqual([]);
290
+ const ui = await bandUi($);
291
+ expect(await ui.find({ type: 'Text', text: 'code-kit' })).toBeUndefined();
292
+ await ui.unmount();
293
+ });
294
+
295
+ test('PANE-5 an invalid config shows one line and no lanes', async ($, on) => {
296
+ const w = project();
297
+ w.check = { ...w.check, valid: false, problems: ['"lanes" must be an object'] };
298
+ await start($, on, w);
299
+ await lanes($);
300
+ const ui = await pane($, PANE);
301
+ expect(
302
+ await ui.find({ type: 'Text', text: /The config is invalid: run code-kit check\./ }),
303
+ ).toBeDefined();
304
+ expect(await ui.find({ key: 'lane-web' })).toBeUndefined();
305
+ await ui.unmount();
306
+ });
307
+
308
+ // --- the band, and the person's acts ------------------------------------------------------------
309
+
310
+ test('BAND-2 with nothing waiting there is no band', async ($, on) => {
311
+ const w = project();
312
+ await start($, on, w);
313
+ const ui = await bandUi($);
314
+ expect(await ui.find({ type: 'Text', text: 'code-kit' })).toBeUndefined();
315
+ await ui.unmount();
316
+ });
317
+
318
+ test('BAND-2 and BAND-4 one open request reads "1 approval waiting", with Approve… on 1', async ($, on) => {
319
+ const w = project();
320
+ w.requests = [dayjs];
321
+ await start($, on, w);
322
+ const ui = await bandUi($);
323
+ expect(await ui.find({ type: 'Text', text: '1 approval waiting' })).toBeDefined();
324
+ const button = await ui.find({ key: 'band-approve' });
325
+ expect(button?.props.hotkey).toBe('1');
326
+ await ui.unmount();
327
+ });
328
+
329
+ test('ACT-1 and BAND-3 approving with the prefilled reason grants it for the lane, and the line goes', async ($, on) => {
330
+ const w = project();
331
+ w.requests = [dayjs];
332
+ const clock = await start($, on, w);
333
+ const ui = await bandUi($);
334
+ await press($, 'band-approve');
335
+ expect(w.opened).toContain(APPROVE);
336
+ const confirm = await pane($, APPROVE);
337
+ const reason = 'Approve dayjs for the web lane: npm install dayjs';
338
+ expect((await confirm.find({ key: 'approve-reason' }))?.props.value).toBe(reason);
339
+ expect(
340
+ await confirm.find({ type: 'Text', text: /until the install is committed \(at most 7 days\)/ }),
341
+ ).toBeDefined();
342
+ await press($, 'approve-confirm', APPROVE);
343
+ expect(w.acts).toEqual([
344
+ ['approve', 'dep-dayjs', '--lane', 'web', '--reason', reason, '--via', 'pane'],
345
+ ]);
346
+ expect(w.closed).toContain(APPROVE);
347
+ await clock.advance(2000);
348
+ await confirm.unmount();
349
+ await ui.unmount();
350
+ const after = await bandUi($);
351
+ expect(await after.find({ type: 'Text', text: /approval waiting/ })).toBeUndefined();
352
+ await after.unmount();
353
+ });
354
+
355
+ test('ACT-1 the person may type their own reason, and an empty one is not accepted', async ($, on) => {
356
+ const w = project();
357
+ w.requests = [dayjs];
358
+ await start($, on, w);
359
+ const ui = await bandUi($);
360
+ await press($, 'band-approve');
361
+ const confirm = await pane($, APPROVE);
362
+ await $.ui.input({ plugin: 'code-kit', key: 'approve-reason', text: ' ' });
363
+ expect(w.acts).toEqual([]);
364
+ expect(await confirm.find({ type: 'Text', text: /Give a reason/ })).toBeDefined();
365
+ await $.ui.input({
366
+ plugin: 'code-kit',
367
+ key: 'approve-reason',
368
+ text: 'dates on the booking form',
369
+ });
370
+ expect(w.acts[0]).toContain('dates on the booking form');
371
+ await confirm.unmount();
372
+ await ui.unmount();
373
+ });
374
+
375
+ test('ACT-1 cancelling writes nothing', async ($, on) => {
376
+ const w = project();
377
+ w.requests = [dayjs];
378
+ await start($, on, w);
379
+ const ui = await bandUi($);
380
+ await press($, 'band-approve');
381
+ const confirm = await pane($, APPROVE);
382
+ await press($, 'approve-cancel', APPROVE);
383
+ expect(w.acts).toEqual([]);
384
+ expect(w.closed).toContain(APPROVE);
385
+ await confirm.unmount();
386
+ await ui.unmount();
387
+ });
388
+
389
+ test('ACT-1 a grant that fails approves nothing, and the band says why', async ($, on) => {
390
+ const w = project();
391
+ w.requests = [dayjs];
392
+ w.approveExit = 1;
393
+ await start($, on, w);
394
+ const ui = await bandUi($);
395
+ await press($, 'band-approve');
396
+ const confirm = await pane($, APPROVE);
397
+ await press($, 'approve-confirm', APPROVE);
398
+ await confirm.unmount();
399
+ await ui.unmount();
400
+ const after = await bandUi($);
401
+ expect(
402
+ await after.find({ type: 'Text', text: /Nothing was approved: approve needs --reason/ }),
403
+ ).toBeDefined();
404
+ expect(await after.find({ type: 'Text', text: '1 approval waiting' })).toBeDefined();
405
+ await after.unmount();
406
+ });
407
+
408
+ test('ACT-3 Review asks the lead to run the review skill on the branch', async ($, on) => {
409
+ const w = project();
410
+ w.stories[0] = { ...w.stories[0], state: 'review' };
411
+ await start($, on, w);
412
+ const ui = await bandUi($);
413
+ expect(await ui.find({ type: 'Text', text: 'ST-4 ready for review' })).toBeDefined();
414
+ await press($, 'band-review');
415
+ expect(w.prompts.some((p) => p.includes('/code-kit:review web/st-4'))).toBe(true);
416
+ await ui.unmount();
417
+ const asked = await bandUi($);
418
+ expect(await asked.find({ type: 'Text', text: 'ST-4 being reviewed' })).toBeDefined();
419
+ await asked.unmount();
420
+ });
421
+
422
+ test('ACT-4 Merge confirms, then merges as the person', async ($, on) => {
423
+ const w = project();
424
+ w.stories[0] = { ...w.stories[0], state: 'review' };
425
+ await start($, on, w);
426
+ const ui = await bandUi($);
427
+ await press($, 'band-merge');
428
+ expect(w.acts).toEqual([['merge', 'web/st-4', '--person']]);
429
+ expect(w.opened).toContain(RESULT);
430
+ const shown = await pane($, RESULT);
431
+ expect(await shown.find({ type: 'Text', text: 'Merged web/st-4' })).toBeDefined();
432
+ await shown.unmount();
433
+ await ui.unmount();
434
+ });
435
+
436
+ test('ACT-4 a branch that fails verify is not merged, and the problems are shown', async ($, on) => {
437
+ const w = project();
438
+ w.stories[0] = { ...w.stories[0], state: 'review' };
439
+ w.mergeExit = 1;
440
+ await start($, on, w);
441
+ const ui = await bandUi($);
442
+ await press($, 'band-merge');
443
+ const shown = await pane($, RESULT);
444
+ expect(await shown.find({ type: 'Text', text: 'Nothing merged: web/st-4' })).toBeDefined();
445
+ expect(await shown.find({ type: 'Text', text: /verify found 1 problem/ })).toBeDefined();
446
+ await shown.unmount();
447
+ await ui.unmount();
448
+ });
449
+
450
+ test('ACT-4 cancelling the confirmation merges nothing', async ($, on) => {
451
+ const w = project();
452
+ w.stories[0] = { ...w.stories[0], state: 'review' };
453
+ w.answer = 'Cancel';
454
+ await start($, on, w);
455
+ const ui = await bandUi($);
456
+ await press($, 'band-merge');
457
+ expect(w.acts).toEqual([]);
458
+ await ui.unmount();
459
+ });
460
+
461
+ test('BAND-2 a failing finish check shows, with Lanes opening the pane', async ($, on) => {
462
+ const w = project();
463
+ w.stops = [
464
+ {
465
+ session: 's1',
466
+ agent: 'a1',
467
+ agentType: 'web-engineer',
468
+ title: 'Check tests failed',
469
+ count: 1,
470
+ at: null,
471
+ },
472
+ ];
473
+ await start($, on, w);
474
+ const ui = await bandUi($);
475
+ expect(
476
+ await ui.find({ type: 'Text', text: 'Finish check failing: Check tests failed' }),
477
+ ).toBeDefined();
478
+ await press($, 'band-lanes');
479
+ expect(w.opened).toContain(PANE);
480
+ await ui.unmount();
481
+ });
482
+
483
+ test('ACT-3 the story shows as being reviewed until the lead has reported', async ($, on) => {
484
+ const w = project();
485
+ w.stories[0] = { ...w.stories[0], state: 'review' };
486
+ on('turn.start', ($: any, e: any) => e);
487
+ on('turn.complete', () => ({ text: '' }));
488
+ await start($, on, w);
489
+ const ui = await bandUi($);
490
+ await press($, 'band-review');
491
+ await ui.unmount();
492
+ await $.turn.start({ turnId: 't-review' } as any);
493
+ const during = await bandUi($);
494
+ expect(await during.find({ type: 'Text', text: 'ST-4 being reviewed' })).toBeDefined();
495
+ await during.unmount();
496
+ await $.turn.complete({
497
+ turnId: 't-review',
498
+ answer: 'Reviewed: merge.',
499
+ durationMs: 1,
500
+ isAborted: false,
501
+ } as any);
502
+ const after = await bandUi($);
503
+ expect(await after.find({ type: 'Text', text: 'ST-4 ready for review' })).toBeDefined();
504
+ await after.unmount();
505
+ });
506
+
507
+ // --- refusal cards and the commands -------------------------------------------------------------
508
+
509
+ /** The refused call's row, as an unfolded group or a standalone call draws it. */
510
+ const toolResult = ($: any, id: string, tool: string, output: unknown = null) =>
511
+ $.ui.mount({
512
+ plugin: 'code-kit',
513
+ component: 'ToolUse',
514
+ requestId: id,
515
+ surface: 'terminal',
516
+ viewport: { columns: 120, rows: 40 },
517
+ props: {
518
+ tool_use_id: id,
519
+ tool,
520
+ input: {},
521
+ isRunning: false,
522
+ isErrored: true,
523
+ isInterrupted: false,
524
+ output,
525
+ },
526
+ });
527
+ const resultBlock = ($: any, id: string, tool: string) =>
528
+ $.ui.mount({
529
+ plugin: 'code-kit',
530
+ component: 'ToolResult',
531
+ requestId: id,
532
+ surface: 'terminal',
533
+ viewport: { columns: 120, rows: 40 },
534
+ props: { tool_use_id: id, tool, output: null, isErrored: true },
535
+ });
536
+
537
+ test('CARD-1 a refused write is drawn as a card, its text a press away', async ($, on) => {
538
+ const w = project();
539
+ w.refusal =
540
+ 'PreToolUse:Write hook error: Blocked: The web-engineer (web lane) may not write apps/office/x.ts (owned by the backend lane / backend-engineer).\nWrite a short change request for the lead instead, and stop.';
541
+ await start($, on, w);
542
+ await $.tool.call({
543
+ tool: 'Write',
544
+ tool_use_id: 'tu-1',
545
+ file_path: '/work/apps/office/x.ts',
546
+ content: 'x',
547
+ } as any);
548
+ const ui = await toolResult($, 'tu-1', 'Write');
549
+ expect(await ui.find({ type: 'Text', text: 'Write refused: apps/office/x.ts' })).toBeDefined();
550
+ expect(await ui.find({ type: 'Text', text: /backend lane \/ backend-engineer/ })).toBeDefined();
551
+ expect(
552
+ await ui.find({ type: 'Text', text: /Write a short change request for the lead/ }),
553
+ ).toBeDefined();
554
+ expect(await ui.find({ key: 'card-approve' })).toBeUndefined();
555
+ await press($, 'card-raw', 'tu-1');
556
+ expect(await ui.find({ type: 'Text', text: /may not write apps\/office\/x.ts/ })).toBeDefined();
557
+ await ui.unmount();
558
+ });
559
+
560
+ test('CARD-1 a refusal an approval would allow has Approve…, which opens the confirmation', async ($, on) => {
561
+ const w = project();
562
+ w.refusal =
563
+ 'Blocked: a new dependency (dayjs) needs a person\'s approval. Write a short change request for the lead.\n ! echo "<what you are approving>" > .claude/approvals/web/dep-dayjs\n(a person runs it; it allows installing it for the web lane, until the install is committed, at most 7 days)\nCommand: npm install dayjs';
564
+ await start($, on, w);
565
+ await $.tool.call({ tool: 'Bash', tool_use_id: 'tu-2', command: 'npm install dayjs' } as any);
566
+ const ui = await toolResult($, 'tu-2', 'Bash');
567
+ expect(await ui.find({ type: 'Text', text: 'Install refused: dayjs' })).toBeDefined();
568
+ await press($, 'card-approve', 'tu-2');
569
+ expect(w.opened).toContain(APPROVE);
570
+ const confirm = await pane($, APPROVE);
571
+ expect((await confirm.find({ key: 'approve-reason' }))?.props.value).toBe(
572
+ 'Approve dayjs for the web lane: npm install dayjs',
573
+ );
574
+ await confirm.unmount();
575
+ await ui.unmount();
576
+ });
577
+
578
+ test("CARD-1 a refusal that isn't code-kit's is drawn as Claude Code draws it", async ($, on) => {
579
+ const w = project();
580
+ w.refusal = 'Context Graph: read src/b.ts in full first.';
581
+ await start($, on, w);
582
+ await $.tool.call({
583
+ tool: 'Write',
584
+ tool_use_id: 'tu-3',
585
+ file_path: '/work/src/a.ts',
586
+ content: 'x',
587
+ } as any);
588
+ const ui = await toolResult($, 'tu-3', 'Write');
589
+ expect(await ui.find({ type: 'Text', text: /refused/ })).toBeUndefined();
590
+ await ui.unmount();
591
+ });
592
+
593
+ test('CARD-2 /approvals lists the open requests and the approvals in force, with minutes left', async ($, on) => {
594
+ const w = project();
595
+ w.requests = [dayjs];
596
+ w.inForce = [{ name: 'design', lane: 'web', reason: 'new screens agreed', minutesLeft: 42 }];
597
+ await start($, on, w);
598
+ const res = await $.command.run({ command: 'approvals', args: '' });
599
+ expect(res.text).toContain('dayjs for the web lane: npm install dayjs');
600
+ expect(res.text).toContain('design for the web lane, 42 min left: new screens agreed');
601
+ });
602
+
603
+ test("CARD-3 /verify-branch prints verify's report on this branch", async ($, on) => {
604
+ const w = project();
605
+ w.verifyExit = 1;
606
+ w.verifyOut =
607
+ 'Layer rules:\n packages/domain/x.ts imports workers/s (domain may not import workers)\n\n1 problem(s). Fix them on this branch.';
608
+ await start($, on, w);
609
+ const res = await $.command.run({ command: 'verify-branch', args: '' });
610
+ expect(res.text).toContain('domain may not import workers');
611
+ expect(res.text).toContain('1 problem(s)');
612
+ });
613
+
614
+ test('CARD-4 outside a code-kit project, /approvals and /verify-branch say so', async ($, on) => {
615
+ const w = project();
616
+ w.check = { ...w.check, exists: false, valid: false };
617
+ await start($, on, w);
618
+ expect((await $.command.run({ command: 'approvals', args: '' })).text).toBe(
619
+ "This project doesn't use code-kit.",
620
+ );
621
+ expect((await $.command.run({ command: 'verify-branch', args: '' })).text).toBe(
622
+ "This project doesn't use code-kit.",
623
+ );
624
+ });
625
+
626
+ const install =
627
+ 'Blocked: a new dependency (dayjs) needs a person\'s approval. Write a short change request for the lead.\n ! echo "<what you are approving>" > .claude/approvals/dep-dayjs\n(a person runs it; it allows installing it for any agent, until the install is committed, at most 7 days)\nCommand: npm install dayjs';
628
+
629
+ test('CARD-1 a group holding a refusal unfolds, so its row can be the card', async ($, on) => {
630
+ const w = project();
631
+ await start($, on, w);
632
+ const call = {
633
+ tool_use_id: 'tu-9',
634
+ tool: 'Bash',
635
+ input: {},
636
+ isRunning: false,
637
+ isErrored: true,
638
+ isInterrupted: false,
639
+ output: install,
640
+ };
641
+ const plain = { ...call, tool_use_id: 'tu-8', isErrored: false, output: { stdout: 'ok' } };
642
+ const mountGroup = (calls: any[]) =>
643
+ $.ui.mount({
644
+ plugin: 'code-kit',
645
+ component: 'ToolGroup',
646
+ requestId: 'g-1',
647
+ surface: 'terminal',
648
+ viewport: { columns: 120, rows: 40 },
649
+ props: { calls, isActive: false, isExpanded: false },
650
+ });
651
+ const folded = await mountGroup([plain]);
652
+ expect(w.drawn.at(-1).props.isExpanded).toBe(false);
653
+ await folded.unmount();
654
+ const unfolded = await mountGroup([plain, call]);
655
+ expect(w.drawn.at(-1).props.isExpanded).toBe(true);
656
+ await unfolded.unmount();
657
+ });
658
+
659
+ test('CARD-1 the card is drawn from the text the model read, and the result block beneath is left empty', async ($, on) => {
660
+ const w = project();
661
+ await start($, on, w);
662
+ const row = await toolResult($, 'tu-10', 'Bash', install);
663
+ expect(await row.find({ type: 'Text', text: 'Install refused: dayjs' })).toBeDefined();
664
+ expect(await row.find({ key: 'card-approve' })).toBeDefined();
665
+ await row.unmount();
666
+ w.refusal = install;
667
+ await $.tool.call({ tool: 'Bash', tool_use_id: 'tu-11', command: 'npm install dayjs' } as any);
668
+ const block = await resultBlock($, 'tu-11', 'Bash');
669
+ expect(await block.find({ type: 'Text', text: /dayjs/ })).toBeUndefined();
670
+ await block.unmount();
671
+ });
672
+
673
+ test('CARD-4 a command whose name is taken is left out; the rest of the mod still works', async ($, on) => {
674
+ const w = project();
675
+ w.requests = [dayjs];
676
+ w.taken = ['approvals'];
677
+ stub(on, w);
678
+ const clock = mock.clock(on);
679
+ await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true });
680
+ await clock.advance(2000);
681
+ const res = await $.command.run({ command: 'lanes', args: '' });
682
+ expect(res.text ?? '').toBe('');
683
+ expect(w.opened).toContain(PANE);
684
+ const ui = await bandUi($);
685
+ expect(await ui.find({ type: 'Text', text: '1 approval waiting' })).toBeDefined();
686
+ await ui.unmount();
687
+ });
688
+
689
+ /** Another plugin drawing its own line in the band, as Context Graph's mod does. */
690
+ const neighbour = {
691
+ name: 'neighbour',
692
+ register(on: any) {
693
+ on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => {
694
+ const { Box, Text } = $.ui.resolve(e);
695
+ const below = await next(e);
696
+ return Box({
697
+ flexDirection: 'column',
698
+ children: [Text({ children: ['2 proposals to ratify'] }), ...(below ? [below] : [])],
699
+ });
700
+ });
701
+ },
702
+ };
703
+
704
+ test(
705
+ "BAND-2 code-kit's lines and another plugin's stand together",
706
+ { plugins: [neighbour] },
707
+ async ($, on) => {
708
+ const w = project();
709
+ w.requests = [dayjs];
710
+ await start($, on, w);
711
+ const ui = await bandUi($);
712
+ expect(await ui.find({ type: 'Text', text: '1 approval waiting' })).toBeDefined();
713
+ expect(await ui.find({ type: 'Text', text: '2 proposals to ratify' })).toBeDefined();
714
+ await ui.unmount();
715
+ },
716
+ );