@warren-dean/code-kit 0.5.0 → 0.7.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.
@@ -2,10 +2,12 @@
2
2
  // which `npm test` runs when it's available. The CLI, git and the agents are stubbed with what they
3
3
  // report; the CLI itself is tested in test/hooks.test.mjs, and the drawing in test/units.test.mjs.
4
4
  import { expect, mock, test } from 'claude-code/testing';
5
+ import { rasterCells, sprite, tintOf } from './art.mjs';
5
6
 
6
7
  const PANE = 'code-kit-lanes';
7
8
  const APPROVE = 'code-kit-approve';
8
9
  const RESULT = 'code-kit-result';
10
+ const SETTINGS = 'code-kit-settings';
9
11
 
10
12
  /** A project as the stubs report it; tests change it to move the world on. */
11
13
  function project() {
@@ -16,6 +18,7 @@ function project() {
16
18
  valid: true,
17
19
  problems: [] as string[],
18
20
  lead: { paths: ['docs/**'] },
21
+ harnessSet: ['autonomy'] as string[], // the person chose autonomy (the default, autonomous)
19
22
  lanes: {
20
23
  web: { agent: 'web-engineer', paths: ['apps/web/**'] },
21
24
  api: { agent: 'api-engineer', paths: ['apps/api/**'] },
@@ -68,7 +71,8 @@ function project() {
68
71
  taken: [] as string[], // command names another command already holds
69
72
  stops: [] as any[],
70
73
  refs: ' aaa refs/heads/main\n bbb refs/heads/web/st-4\n',
71
- agents: [] as { type: string; status: string }[],
74
+ agents: [] as { type: string; status: string; id?: string; description?: string }[],
75
+ limits: [] as { kind: string; percentUsed: number }[],
72
76
  approveExit: 0,
73
77
  mergeExit: 0,
74
78
  answer: 'Merge',
@@ -78,6 +82,74 @@ function project() {
78
82
  prompts: [] as string[],
79
83
  acts: [] as string[][],
80
84
  runs: 0,
85
+ settings: [
86
+ {
87
+ key: 'autonomy',
88
+ value: 'autonomous',
89
+ default: 'autonomous',
90
+ about: 'How the lead loop acts',
91
+ },
92
+ { key: 'hold.minutes', value: 2, default: 2, about: 'How long a call is held' },
93
+ { key: 'agents.reviewer.on', value: false, default: false, about: 'A background reviewer' },
94
+ { key: 'agents.reviewer.model', value: null, default: null, about: "The reviewer's model" },
95
+ ] as any[],
96
+ setExit: 0,
97
+ allowed: false, // an approval in force: the hooks beneath let the call through
98
+ bashRuns: 0,
99
+ delegateExit: 1,
100
+ delegated: [] as string[][],
101
+ holds: [] as string[][],
102
+ draft: '', // what the person has typed in the prompt
103
+ heads: { 'web/st-4': 'abc123def4567890\n' } as Record<string, string>,
104
+ registered: [] as any[],
105
+ queue: [] as any[],
106
+ requirements: [] as any[],
107
+ times: [] as any[],
108
+ blits: [] as string[],
109
+ adapters: [] as any[],
110
+ agentsThrow: false,
111
+ registerFails: 0,
112
+ commands: [] as any[],
113
+ story: {
114
+ id: 'ST-4',
115
+ title: 'Booking form',
116
+ lane: 'web',
117
+ state: 'review',
118
+ base: 'main',
119
+ branch: 'web/st-4',
120
+ worktree: null,
121
+ requirements: [
122
+ {
123
+ id: 'BOOK-1',
124
+ title: 'Request a booking',
125
+ spec: 'docs/spec/01.md',
126
+ state: 'in progress',
127
+ tests: [],
128
+ },
129
+ ],
130
+ specCheck: {
131
+ file: '.claude/state/spec-check/web_st-4.md',
132
+ rows: [{ requirement: 'BOOK-1', status: 'partial', where: 'apps/web/form.tsx', note: '' }],
133
+ },
134
+ diff: {
135
+ files: [
136
+ { path: 'apps/web/form.tsx', added: 2, removed: 0 },
137
+ { path: 'apps/web/api.ts', added: 1, removed: 0 },
138
+ ],
139
+ text:
140
+ 'diff --git a/apps/web/form.tsx b/apps/web/form.tsx\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/apps/web/form.tsx\n@@ -0,0 +1,2 @@\n+export const form = 1;\n+export const x = 2;\n' +
141
+ 'diff --git a/apps/web/api.ts b/apps/web/api.ts\nindex 2222222..3333333 100644\n--- a/apps/web/api.ts\n+++ b/apps/web/api.ts\n@@ -1 +1,2 @@\n a\n+b\n',
142
+ truncated: false,
143
+ },
144
+ verify: {
145
+ checks: false,
146
+ where: 'a temporary worktree',
147
+ problems: { Ownership: ["apps/api/x.ts: not the web lane's"], Checks: [] },
148
+ },
149
+ } as any,
150
+ sent: [] as { to: any; text: string }[],
151
+ holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
152
+ waiting: new Map<string, (answer: string) => void>(),
81
153
  };
82
154
  }
83
155
  type World = ReturnType<typeof project>;
@@ -90,6 +162,7 @@ function stub(on: any, w: World) {
90
162
  value: { exitCode, stdout, stderr },
91
163
  });
92
164
  const sub = argv[2];
165
+ if (argv[0] === 'git' && argv[1] === 'rev-parse') return ran(0, w.heads[argv.at(-1)!] ?? '');
93
166
  if (argv[0] === 'git') return ran(0, w.refs);
94
167
  if (sub === 'check') return ran(w.check.valid ? 0 : 1, JSON.stringify(w.check));
95
168
  if (sub === 'status')
@@ -98,7 +171,7 @@ function stub(on: any, w: World) {
98
171
  JSON.stringify({
99
172
  base: 'main',
100
173
  stories: w.stories,
101
- requirements: {},
174
+ requirements: w.requirements,
102
175
  problems: [],
103
176
  drift: [],
104
177
  }),
@@ -108,9 +181,49 @@ function stub(on: any, w: World) {
108
181
  if (sub === 'verify')
109
182
  return ran(w.verifyExit, w.verifyExit ? '' : w.verifyOut, w.verifyExit ? w.verifyOut : '');
110
183
  if (sub === 'stops') return ran(0, JSON.stringify(w.stops));
184
+ if (sub === 'story') return ran(0, JSON.stringify(w.story));
185
+ if (sub === 'adapters') return ran(0, JSON.stringify(w.adapters));
186
+ if (sub === 'timeline')
187
+ return ran(0, JSON.stringify({ base: 'main', now: 0, stories: w.times }));
188
+ if (sub === 'sent-back') {
189
+ w.acts.push([...argv.slice(2)]);
190
+ return ran(0, `Recorded: ${argv[3]} was sent back.`);
191
+ }
192
+ if (sub === 'queue' && argv[3] === 'merge') {
193
+ w.acts.push([...argv.slice(2)]);
194
+ w.queue = w.queue.map((e, i) => (i === 0 ? { ...e, state: 'merged' } : e));
195
+ return ran(0, 'Merged web/st-4 into main: verify passed on 2 file(s). The queue is empty.');
196
+ }
197
+ if (sub === 'queue') return ran(0, JSON.stringify(w.queue));
198
+ if (sub === 'approve' && argv.includes('--delegated')) {
199
+ w.delegated.push([...argv.slice(2)]);
200
+ if (w.delegateExit === 0) w.allowed = true;
201
+ return ran(w.delegateExit, '', w.delegateExit ? 'Nothing was approved.' : '');
202
+ }
203
+ if (sub === 'hold') {
204
+ const id = argv[3];
205
+ const at = argv.indexOf('--answer');
206
+ if (at > 0) {
207
+ w.acts.push([...argv.slice(2)]);
208
+ w.waiting.get(id)?.(argv[at + 1]);
209
+ return ran(0, '');
210
+ }
211
+ w.holds.push([...argv.slice(2)]);
212
+ if (!w.holdWaits) return ran(0, 'timed out\n');
213
+ // Waits, as `code-kit hold` does, until the band answers or the test times it out.
214
+ return new Promise((done) =>
215
+ w.waiting.set(id, (answer) => {
216
+ w.waiting.delete(id);
217
+ done(ran(0, `${answer}\n`));
218
+ }),
219
+ );
220
+ }
111
221
  if (sub === 'approve') {
112
222
  w.acts.push([...argv.slice(2)]);
113
- if (w.approveExit === 0) w.requests = [];
223
+ if (w.approveExit === 0) {
224
+ w.requests = [];
225
+ w.allowed = true;
226
+ }
114
227
  return ran(w.approveExit, '', w.approveExit ? 'approve needs --reason' : '');
115
228
  }
116
229
  if (sub === 'merge') {
@@ -119,6 +232,33 @@ function stub(on: any, w: World) {
119
232
  ? ran(1, '', 'Nothing was merged. code-kit verify found 1 problem(s) on web/st-4')
120
233
  : ran(0, 'Merged web/st-4 into main.');
121
234
  }
235
+ if (sub === 'settings' && argv[3] === 'set') {
236
+ w.acts.push([...argv.slice(2)]);
237
+ if (w.setExit)
238
+ return ran(
239
+ 1,
240
+ '',
241
+ '"harness.hold.minutes" must be a number of minutes from 0 (off) to 10\nNothing was changed.',
242
+ );
243
+ const row = w.settings.find((s) => s.key === argv[4]);
244
+ if (argv[4] === 'autonomy') {
245
+ (w.check as any).harness = { ...(w.check as any).harness, autonomy: argv[5] };
246
+ w.check.harnessSet = [...new Set([...w.check.harnessSet, 'autonomy'])];
247
+ }
248
+ const raw = argv[5];
249
+ row.value =
250
+ raw === 'null'
251
+ ? null
252
+ : raw === 'true'
253
+ ? true
254
+ : raw === 'false'
255
+ ? false
256
+ : /^\d+$/.test(raw)
257
+ ? Number(raw)
258
+ : raw;
259
+ return ran(0, `Set harness.${argv[4]} to ${raw}.`);
260
+ }
261
+ if (sub === 'settings') return ran(0, JSON.stringify(w.settings));
122
262
  return ran(1, '', `unexpected ${argv.join(' ')}`);
123
263
  });
124
264
  on('session.start', () => ({ cwd: '/work' }));
@@ -131,10 +271,44 @@ function stub(on: any, w: World) {
131
271
  on('tool.call', { tool: 'Write' }, () =>
132
272
  w.refusal ? { deny: w.refusal } : { result: { type: 'create' } },
133
273
  );
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 }));
274
+ on('tool.call', { tool: 'Bash' }, () => {
275
+ w.bashRuns += 1;
276
+ return w.refusal && !w.allowed
277
+ ? { deny: w.refusal }
278
+ : { result: { stdout: 'added 1 package', stderr: '', interrupted: false } };
279
+ });
280
+ on('agent.list', () => {
281
+ if (w.agentsThrow) throw new Error('no session is bound in this process');
282
+ return { value: w.agents };
283
+ });
284
+ on('agent.register', ($: any, e: any) => {
285
+ if (w.registerFails > 0) {
286
+ w.registerFails -= 1;
287
+ throw new Error('no session is bound in this process');
288
+ }
289
+ w.registered.push(e);
290
+ return { value: { agent: `code-kit:${e.name}` } };
291
+ });
292
+ on('prompt.read', () => ({ value: { text: w.draft, cursor: w.draft.length } }));
293
+ on('session.send', ($: any, e: any) => {
294
+ w.sent.push({ to: e.to, text: e.text });
295
+ return { isDelivered: true };
296
+ });
297
+ on('turn.start', ($: any, e: any) => e);
298
+ on('turn.complete', () => ({ text: '' }));
299
+ on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: w.limits } }));
300
+ on('session.measure', ($: any, e: any) => ({ changed: e.changed }));
301
+ // A model request, answered with the usage the test gave it.
302
+ on('turn.step', async function* ($: any, e: any) {
303
+ return {
304
+ turnId: e.turnId,
305
+ index: e.index,
306
+ answer: '',
307
+ toolUses: [],
308
+ stopReason: 'end_turn',
309
+ usage: e.messageCount === 0 ? null : { model: e.model, ...stepUsage(e.messageCount) },
310
+ };
311
+ } as any);
138
312
  on('fs.stat', () => ({ value: { kind: 'file', size: 1, mtimeMs: 1, isLink: false } }));
139
313
  on('fs.list', () => ({ value: [] }));
140
314
  on('session.cwd', () => ({ value: '/work' }));
@@ -150,6 +324,15 @@ function stub(on: any, w: World) {
150
324
  on('tool.call', { tool: 'AskUserQuestion' }, ($: any, e: any) => ({
151
325
  result: { questions: e.questions, answers: { [e.questions[0].question]: w.answer } },
152
326
  }));
327
+ // Another plugin's command, as the mod asks for it (Context Graph's /graph).
328
+ on('command.run', ($: any, e: any) => {
329
+ w.commands.push({ command: e.command, args: e.args });
330
+ return { text: '' };
331
+ });
332
+ on('ui.blit', ($: any, e: any) => {
333
+ w.blits.push(e.key);
334
+ return { value: undefined };
335
+ });
153
336
  on('ui.panes', () => ({ value: [...w.open].map((id) => ({ id, title: id, isShown: true })) }));
154
337
  on('ui.open', ($: any, e: any) => {
155
338
  w.opened.push(e.id);
@@ -163,11 +346,35 @@ function stub(on: any, w: World) {
163
346
  });
164
347
  }
165
348
 
349
+ /** A request of n thousand tokens, split as a model request's usage is. */
350
+ function stepUsage(thousands: number) {
351
+ const n = thousands * 1000;
352
+ return {
353
+ input_tokens: n * 0.3,
354
+ cache_creation_input_tokens: n * 0.1,
355
+ output_tokens: n * 0.1,
356
+ cache_read_input_tokens: n * 0.5,
357
+ };
358
+ }
359
+
360
+ /** One model request by an agent (or the lead) of so many thousand tokens, run to its end. */
361
+ async function request($: any, agentId: string | undefined, thousands: number) {
362
+ const s = $.turn.step({
363
+ turnId: `t-${agentId ?? 'lead'}`,
364
+ index: 0,
365
+ model: 'claude-sonnet-5-5',
366
+ messageCount: thousands, // the stub beneath answers with this many thousand tokens
367
+ ...(agentId ? { agentId } : {}),
368
+ });
369
+ for await (const _ of s);
370
+ return s.result;
371
+ }
372
+
166
373
  /** A session started on the project, with the mock clock. */
167
- async function start($: any, on: any, w: World) {
374
+ async function start($: any, on: any, w: World, interactive = true) {
168
375
  stub(on, w);
169
376
  const clock = mock.clock(on);
170
- await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true });
377
+ await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: interactive });
171
378
  await clock.advance(2000); // the first look at the project
172
379
  return clock;
173
380
  }
@@ -275,9 +482,10 @@ test('PANE-4 with nothing changed it reads the project every 10 seconds while th
275
482
  const before = w.runs;
276
483
  await clock.advance(8000); // four quiet ticks: git only
277
484
  expect(w.runs - before).toBe(4);
278
- // The fifth reads it all (check, status, next, requests, stops) between two looks at git.
485
+ // The fifth reads it all (check, status, next, requests, stops, queue, timeline) between two looks
486
+ // at git.
279
487
  await clock.advance(2000);
280
- expect(w.runs - before).toBe(11);
488
+ expect(w.runs - before).toBe(13);
281
489
  });
282
490
 
283
491
  test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
@@ -483,8 +691,6 @@ test('BAND-2 a failing finish check shows, with Lanes opening the pane', async (
483
691
  test('ACT-3 the story shows as being reviewed until the lead has reported', async ($, on) => {
484
692
  const w = project();
485
693
  w.stories[0] = { ...w.stories[0], state: 'review' };
486
- on('turn.start', ($: any, e: any) => e);
487
- on('turn.complete', () => ({ text: '' }));
488
694
  await start($, on, w);
489
695
  const ui = await bandUi($);
490
696
  await press($, 'band-review');
@@ -714,3 +920,1531 @@ test(
714
920
  await ui.unmount();
715
921
  },
716
922
  );
923
+
924
+ // --- the harness settings -------------------------------------------------------------------------
925
+
926
+ const harness = ($: any) => $.command.run({ command: 'harness', args: '' });
927
+
928
+ test('SET-2 /harness opens the settings, each with its value, and /harness again closes it', async ($, on) => {
929
+ const w = project();
930
+ await start($, on, w);
931
+ await harness($);
932
+ expect(w.opened).toContain(SETTINGS);
933
+ const ui = await pane($, SETTINGS);
934
+ expect(await ui.find({ type: 'Text', text: 'hold.minutes' })).toBeDefined();
935
+ expect((await ui.find({ key: 'set-hold.minutes' }))?.props.value).toBe('2');
936
+ expect((await ui.find({ key: 'set-autonomy' }))?.props.value).toBe('autonomous');
937
+ await ui.unmount();
938
+ await harness($);
939
+ expect(w.closed).toContain(SETTINGS);
940
+ });
941
+
942
+ test('SET-2 a change asks for a reason, then is written by the CLI as the pane', async ($, on) => {
943
+ const w = project();
944
+ await start($, on, w);
945
+ await harness($);
946
+ const ui = await pane($, SETTINGS);
947
+ await $.ui.select({
948
+ plugin: 'code-kit',
949
+ key: 'set-autonomy',
950
+ value: 'propose',
951
+ requestId: SETTINGS,
952
+ });
953
+ expect(w.acts).toEqual([]);
954
+ expect(await ui.find({ type: 'Text', text: /Set autonomy to propose\?/ })).toBeDefined();
955
+ await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: ' ' });
956
+ expect(w.acts).toEqual([]);
957
+ expect(await ui.find({ type: 'Text', text: /Give a reason/ })).toBeDefined();
958
+ await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: 'watch it for a week' });
959
+ expect(w.acts).toEqual([
960
+ ['settings', 'set', 'autonomy', 'propose', '--reason', 'watch it for a week', '--via', 'pane'],
961
+ ]);
962
+ expect(await ui.find({ key: 'settings-confirm' })).toBeUndefined();
963
+ expect((await ui.find({ key: 'set-autonomy' }))?.props.value).toBe('propose');
964
+ await ui.unmount();
965
+ });
966
+
967
+ test('SET-2 a number is typed; one the CLI refuses shows why and changes nothing', async ($, on) => {
968
+ const w = project();
969
+ w.setExit = 1;
970
+ await start($, on, w);
971
+ await harness($);
972
+ const ui = await pane($, SETTINGS);
973
+ await $.ui.input({ plugin: 'code-kit', key: 'set-hold.minutes', text: '45' });
974
+ await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: 'longer' });
975
+ expect(await ui.find({ type: 'Text', text: /from 0 \(off\) to 10/ })).toBeDefined();
976
+ expect(w.settings[1].value).toBe(2);
977
+ await press($, 'settings-cancel', SETTINGS);
978
+ expect(await ui.find({ key: 'settings-confirm' })).toBeUndefined();
979
+ await ui.unmount();
980
+ });
981
+
982
+ test("SET-2 the reviewer's model can go back to the session's", async ($, on) => {
983
+ const w = project();
984
+ w.settings[3].value = 'haiku';
985
+ await start($, on, w);
986
+ await harness($);
987
+ const ui = await pane($, SETTINGS);
988
+ expect(await ui.find({ type: 'Text', text: /default the session's/ })).toBeDefined();
989
+ await $.ui.select({
990
+ plugin: 'code-kit',
991
+ key: 'set-agents.reviewer.model',
992
+ value: '',
993
+ requestId: SETTINGS,
994
+ });
995
+ await press($, 'settings-ok', SETTINGS);
996
+ expect(w.acts).toEqual([]);
997
+ await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: 'same as the lead' });
998
+ expect(w.acts[0].slice(0, 4)).toEqual(['settings', 'set', 'agents.reviewer.model', 'null']);
999
+ await ui.unmount();
1000
+ });
1001
+
1002
+ test('CARD-4 outside a code-kit project, /harness says so and opens nothing', async ($, on) => {
1003
+ const w = project();
1004
+ w.check.exists = false;
1005
+ w.check.valid = false;
1006
+ await start($, on, w);
1007
+ const said = await harness($);
1008
+ expect(String(said?.text)).toMatch(/code-kit/);
1009
+ expect(w.opened).not.toContain(SETTINGS);
1010
+ });
1011
+
1012
+ // --- usage per lane ---------------------------------------------------------------------------------
1013
+
1014
+ const working = (id: string, type: string, description: string) => ({
1015
+ id,
1016
+ type,
1017
+ description,
1018
+ status: 'running',
1019
+ });
1020
+
1021
+ test("USE-1 a lane agent's requests show on its lane's row and its story", async ($, on) => {
1022
+ const w = project();
1023
+ w.agents = [working('a1', 'web-engineer', 'Build ST-4: the booking form')];
1024
+ const clock = await start($, on, w);
1025
+ await lanes($);
1026
+ await request($, 'a1', 120);
1027
+ await request($, 'a1', 80);
1028
+ await clock.advance(2000);
1029
+ const ui = await pane($, PANE);
1030
+ expect((await ui.find({ key: 'lane-web' }))?.text).toContain('200k');
1031
+ await press($, 'tab-usage', PANE);
1032
+ expect((await ui.find({ key: 'usage-ST-4' }))?.text).toContain('200k');
1033
+ expect((await ui.find({ key: 'lane-api' }))?.text).not.toMatch(/\d+k/);
1034
+ await ui.unmount();
1035
+ });
1036
+
1037
+ test("USE-1 the lead's requests are the lead's; an agent's brief names its story", async ($, on) => {
1038
+ const w = project();
1039
+ w.agents = [working('a2', 'core-engineer', 'ST-9 pricing rules')];
1040
+ await start($, on, w);
1041
+ await lanes($);
1042
+ await request($, undefined, 30);
1043
+ await request($, 'a2', 50);
1044
+ const ui = await pane($, PANE);
1045
+ expect((await ui.find({ key: 'lane-lead' }))?.text).toContain('30k');
1046
+ expect((await ui.find({ key: 'lane-core' }))?.text).toContain('50k');
1047
+ await press($, 'tab-usage', PANE);
1048
+ expect((await ui.find({ key: 'usage-ST-9' }))?.text).toContain('50k');
1049
+ await ui.unmount();
1050
+ });
1051
+
1052
+ test("USE-2 the background agents' share and the plan's 5-hour use", async ($, on) => {
1053
+ const w = project();
1054
+ w.limits = [{ kind: 'five_hour', percentUsed: 62 }];
1055
+ w.agents = [
1056
+ working('a1', 'web-engineer', 'ST-4 booking form'),
1057
+ working('r1', 'code-reviewer', 'Review web/st-4'),
1058
+ ];
1059
+ await start($, on, w);
1060
+ await lanes($);
1061
+ await request($, 'a1', 150);
1062
+ await request($, 'r1', 50);
1063
+ const ui = await pane($, PANE);
1064
+ await press($, 'tab-usage', PANE);
1065
+ expect(await ui.find({ type: 'Text', text: 'Background agents 50k (25%)' })).toBeDefined();
1066
+ expect(await ui.find({ type: 'Text', text: '62% of the 5-hour window' })).toBeDefined();
1067
+ expect(await ui.find({ type: 'Text', text: 'This session 200k tokens' })).toBeDefined();
1068
+ await ui.unmount();
1069
+ });
1070
+
1071
+ test('USE-3 a story past three times the median of three finished ones is flagged in the band', async ($, on) => {
1072
+ const w = project();
1073
+ w.stories = [
1074
+ ...['ST-1', 'ST-2', 'ST-3'].map((id) => ({
1075
+ id,
1076
+ title: id,
1077
+ lane: 'api',
1078
+ state: 'done',
1079
+ branch: `api/${id.toLowerCase()}`,
1080
+ waitingOn: [],
1081
+ })),
1082
+ ...w.stories,
1083
+ ];
1084
+ w.agents = ['ST-1', 'ST-2', 'ST-3', 'ST-4'].map((id, i) =>
1085
+ working(`a${i}`, i < 3 ? 'api-engineer' : 'web-engineer', `${id} work`),
1086
+ );
1087
+ await start($, on, w);
1088
+ await request($, 'a0', 190);
1089
+ await request($, 'a1', 200);
1090
+ await request($, 'a2', 210);
1091
+ await request($, 'a3', 500);
1092
+ let ui = await bandUi($);
1093
+ expect(await ui.find({ type: 'Text', text: /has used/ })).toBeUndefined();
1094
+ await ui.unmount();
1095
+ await request($, 'a3', 200);
1096
+ ui = await bandUi($);
1097
+ expect(await ui.find({ type: 'Text', text: 'ST-4 has used 3.5× the usual' })).toBeDefined();
1098
+ await ui.unmount();
1099
+ });
1100
+
1101
+ test('USE-3 with fewer than three finished, nothing is flagged', async ($, on) => {
1102
+ const w = project();
1103
+ w.stories = [
1104
+ { id: 'ST-1', title: 'a', lane: 'api', state: 'done', branch: 'api/st-1', waitingOn: [] },
1105
+ ...w.stories,
1106
+ ];
1107
+ w.agents = [working('a0', 'api-engineer', 'ST-1'), working('a1', 'web-engineer', 'ST-4')];
1108
+ await start($, on, w);
1109
+ await request($, 'a0', 10);
1110
+ await request($, 'a1', 900);
1111
+ const ui = await bandUi($);
1112
+ expect(await ui.find({ type: 'Text', text: /has used/ })).toBeUndefined();
1113
+ await ui.unmount();
1114
+ });
1115
+
1116
+ test('USE-4 past the pause point the band says background agents are paused, and below it not', async ($, on) => {
1117
+ const w = project();
1118
+ await start($, on, w);
1119
+ const measure = (percentUsed: number) =>
1120
+ $.session.measure({
1121
+ context: {} as any,
1122
+ rateLimits: [{ kind: 'five_hour', percentUsed }],
1123
+ changed: ['rateLimits'],
1124
+ });
1125
+ await measure(82);
1126
+ let ui = await bandUi($);
1127
+ expect(
1128
+ await ui.find({ type: 'Text', text: 'background agents paused: plan at 82%' }),
1129
+ ).toBeDefined();
1130
+ await ui.unmount();
1131
+ await measure(79);
1132
+ ui = await bandUi($);
1133
+ expect(await ui.find({ type: 'Text', text: /paused/ })).toBeUndefined();
1134
+ await ui.unmount();
1135
+ });
1136
+
1137
+ test('USE-4 the pause point is the harness setting', async ($, on) => {
1138
+ const w = project();
1139
+ (w.check as any).harness = { background: { pauseAtPercent: 90 } };
1140
+ w.limits = [{ kind: 'five_hour', percentUsed: 85 }];
1141
+ await start($, on, w);
1142
+ let ui = await bandUi($);
1143
+ expect(await ui.find({ type: 'Text', text: /paused/ })).toBeUndefined();
1144
+ await ui.unmount();
1145
+ w.limits = [{ kind: 'five_hour', percentUsed: 91 }];
1146
+ await $.session.measure({ context: {} as any, rateLimits: w.limits, changed: ['rateLimits'] });
1147
+ ui = await bandUi($);
1148
+ expect(await ui.find({ type: 'Text', text: /plan at 91%/ })).toBeDefined();
1149
+ await ui.unmount();
1150
+ });
1151
+
1152
+ // --- hold and ask -----------------------------------------------------------------------------------
1153
+
1154
+ const webInstall =
1155
+ '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';
1156
+
1157
+ /** A lane's call, left running while the test answers it; returned once the mod has it held. */
1158
+ async function heldCall($: any, w: World, clock: any, id = 'tu-h1') {
1159
+ w.holdWaits = true;
1160
+ const call = $.tool.call({
1161
+ tool: 'Bash',
1162
+ tool_use_id: id,
1163
+ command: 'npm install dayjs',
1164
+ agentId: 'a1',
1165
+ } as any);
1166
+ for (let i = 0; i < 50 && !w.waiting.has(id); i++) await clock.advance(1);
1167
+ expect(w.waiting.has(id)).toBe(true);
1168
+ return { call };
1169
+ }
1170
+ /** A call refused, whichever shape the refusal came back in. */
1171
+ const refused = (res: any) => Boolean(res?.deny || res?.isError);
1172
+ const refusalText = (res: any) => String(res?.deny ?? res?.text ?? '');
1173
+
1174
+ test('HOLD-1 and HOLD-2 a lane installing a new package is held, and the band asks with the time left', async ($, on) => {
1175
+ const w = project();
1176
+ w.refusal = webInstall;
1177
+ const clock = await start($, on, w);
1178
+ await heldCall($, w, clock);
1179
+ let ui = await bandUi($);
1180
+ expect(
1181
+ await ui.find({ type: 'Text', text: /^web wants to install dayjs · 2:00$/ }),
1182
+ ).toBeDefined();
1183
+ expect((await ui.find({ key: 'band-approveHeld' }))?.props.hotkey).toBe('1');
1184
+ expect((await ui.find({ key: 'band-refuseHeld' }))?.props.hotkey).toBe('2');
1185
+ await ui.unmount();
1186
+ await clock.advance(7000);
1187
+ ui = await bandUi($);
1188
+ expect(await ui.find({ type: 'Text', text: /· 1:53$/ })).toBeDefined();
1189
+ await ui.unmount();
1190
+ w.waiting.get('tu-h1')?.('timed out');
1191
+ });
1192
+
1193
+ test('HOLD-3 on Approve the approval is written as the pane writes it, and the same call runs', async ($, on) => {
1194
+ const w = project();
1195
+ w.refusal = webInstall;
1196
+ const clock = await start($, on, w);
1197
+ const { call } = await heldCall($, w, clock);
1198
+ const shown = await bandUi($);
1199
+ await press($, 'band-approveHeld');
1200
+ await shown.unmount();
1201
+ expect(w.opened).toContain(APPROVE);
1202
+ const confirm = await pane($, APPROVE);
1203
+ await press($, 'approve-confirm', APPROVE);
1204
+ expect(w.acts).toEqual([
1205
+ [
1206
+ 'approve',
1207
+ 'dep-dayjs',
1208
+ '--lane',
1209
+ 'web',
1210
+ '--reason',
1211
+ 'Approve dayjs for the web lane: npm install dayjs',
1212
+ '--via',
1213
+ 'pane',
1214
+ ],
1215
+ ['hold', 'tu-h1', '--answer', 'approved'],
1216
+ ]);
1217
+ const res: any = await call;
1218
+ expect(refused(res)).toBe(false);
1219
+ expect(w.bashRuns).toBe(2);
1220
+ const ui = await bandUi($);
1221
+ expect(await ui.find({ type: 'Text', text: /wants to/ })).toBeUndefined();
1222
+ await ui.unmount();
1223
+ await confirm.unmount();
1224
+ });
1225
+
1226
+ test("HOLD-3 an approval that can't be written refuses the call, and the band says nothing was approved", async ($, on) => {
1227
+ const w = project();
1228
+ w.refusal = webInstall;
1229
+ w.approveExit = 1;
1230
+ const clock = await start($, on, w);
1231
+ const { call } = await heldCall($, w, clock);
1232
+ const shown = await bandUi($);
1233
+ await press($, 'band-approveHeld');
1234
+ await shown.unmount();
1235
+ const confirm = await pane($, APPROVE);
1236
+ await press($, 'approve-confirm', APPROVE);
1237
+ await confirm.unmount();
1238
+ const res: any = await call;
1239
+ expect(refused(res)).toBe(true);
1240
+ expect(refusalText(res)).toMatch(/needs a person's approval/);
1241
+ const ui = await bandUi($);
1242
+ expect(await ui.find({ type: 'Text', text: /Nothing was approved/ })).toBeDefined();
1243
+ await ui.unmount();
1244
+ });
1245
+
1246
+ test('HOLD-4 Refuse, or no answer in the hold time, lets the refusal through and the request stays', async ($, on) => {
1247
+ const w = project();
1248
+ w.refusal = webInstall;
1249
+ const clock = await start($, on, w);
1250
+ let { call } = await heldCall($, w, clock);
1251
+ const shown = await bandUi($);
1252
+ await press($, 'band-refuseHeld');
1253
+ await shown.unmount();
1254
+ let res: any = await call;
1255
+ expect(refused(res)).toBe(true);
1256
+ expect(w.bashRuns).toBe(1);
1257
+ ({ call } = await heldCall($, w, clock, 'tu-h2'));
1258
+ expect(w.holds.at(-1)).toEqual(['hold', 'tu-h2', '--minutes', '2']);
1259
+ w.waiting.get('tu-h2')?.('timed out');
1260
+ res = await call;
1261
+ expect(refused(res)).toBe(true);
1262
+ w.requests = [{ ...dayjs, names: ['dep-dayjs'] }];
1263
+ await clock.advance(60000);
1264
+ const ui = await bandUi($);
1265
+ expect(await ui.find({ type: 'Text', text: '1 approval waiting' })).toBeDefined();
1266
+ await ui.unmount();
1267
+ });
1268
+
1269
+ test('HOLD-5 within the delegated rules the call is approved as delegated and runs, without asking', async ($, on) => {
1270
+ const w = project();
1271
+ w.refusal = webInstall;
1272
+ w.delegateExit = 0;
1273
+ await start($, on, w);
1274
+ const res: any = await $.tool.call({
1275
+ tool: 'Bash',
1276
+ tool_use_id: 'tu-d',
1277
+ command: 'npm install dayjs',
1278
+ agentId: 'a1',
1279
+ } as any);
1280
+ expect(refused(res)).toBe(false);
1281
+ expect(w.delegated[0]).toEqual(
1282
+ expect.arrayContaining(['approve', 'dep-dayjs', '--lane', 'web', '--delegated']),
1283
+ );
1284
+ expect(w.holds).toEqual([]);
1285
+ });
1286
+
1287
+ test('HOLD-5 with autonomy propose the delegated rules are not tried; the call is held', async ($, on) => {
1288
+ const w = project();
1289
+ w.refusal = webInstall;
1290
+ w.delegateExit = 0;
1291
+ (w.check as any).harness = { autonomy: 'propose', hold: { minutes: 2 } };
1292
+ const clock = await start($, on, w);
1293
+ await heldCall($, w, clock);
1294
+ expect(w.delegated).toEqual([]);
1295
+ w.waiting.get('tu-h1')?.('timed out');
1296
+ });
1297
+
1298
+ test('HOLD-6 with nobody to ask, or hold off, the call is refused at once', async ($, on) => {
1299
+ const w = project();
1300
+ w.refusal = webInstall;
1301
+ await start($, on, w, false);
1302
+ const res: any = await $.tool.call({
1303
+ tool: 'Bash',
1304
+ tool_use_id: 'tu-p',
1305
+ command: 'npm install dayjs',
1306
+ } as any);
1307
+ expect(refused(res)).toBe(true);
1308
+ expect(w.holds).toEqual([]);
1309
+ });
1310
+
1311
+ test('HOLD-6 hold.minutes 0 switches holding off', async ($, on) => {
1312
+ const w = project();
1313
+ w.refusal = webInstall;
1314
+ (w.check as any).harness = { autonomy: 'autonomous', hold: { minutes: 0 } };
1315
+ await start($, on, w);
1316
+ const res: any = await $.tool.call({
1317
+ tool: 'Bash',
1318
+ tool_use_id: 'tu-0',
1319
+ command: 'npm install dayjs',
1320
+ } as any);
1321
+ expect(refused(res)).toBe(true);
1322
+ expect(w.holds).toEqual([]);
1323
+ expect(w.delegated).toEqual([]);
1324
+ });
1325
+
1326
+ test('HOLD-1 a refusal no approval allows (force-push) is refused at once, unheld', async ($, on) => {
1327
+ const w = project();
1328
+ w.refusal =
1329
+ 'Blocked: force-pushing rewrites history others may have pulled.\nCommand: git push --force';
1330
+ await start($, on, w);
1331
+ const res: any = await $.tool.call({
1332
+ tool: 'Bash',
1333
+ tool_use_id: 'tu-f',
1334
+ command: 'git push --force',
1335
+ } as any);
1336
+ expect(refused(res)).toBe(true);
1337
+ expect(w.holds).toEqual([]);
1338
+ expect(w.delegated).toEqual([]);
1339
+ });
1340
+
1341
+ // --- the lead loop ----------------------------------------------------------------------------------
1342
+
1343
+ /** One lead turn, start to end: the person's prompt, or one the loop submitted. */
1344
+ async function leadTurn($: any, id = 't-1') {
1345
+ await $.turn.start({ turnId: id, text: '' } as any);
1346
+ await $.turn.complete({ turnId: id, answer: 'Done.', durationMs: 1, isAborted: false } as any);
1347
+ }
1348
+ /** Lets the loop's work after a turn settle. */
1349
+ const settle = async (clock: any) => {
1350
+ for (let i = 0; i < 5; i++) await clock.advance(1);
1351
+ };
1352
+
1353
+ test('LOOP-1 and LOOP-2 with the lead idle, the loop dispatches every ready story in one prompt and records it', async ($, on) => {
1354
+ const w = project();
1355
+ w.next = { step: 'dispatch', args: 'ST-9 ST-10 ST-11 ST-12', then: [] };
1356
+ const clock = await start($, on, w);
1357
+ await leadTurn($);
1358
+ await settle(clock);
1359
+ expect(w.prompts).toEqual([
1360
+ 'Dispatch ST-9, ST-10, ST-11, ST-12: run /code-kit:dispatch ST-9 ST-10 ST-11 ST-12.',
1361
+ ]);
1362
+ await lanes($);
1363
+ const ui = await pane($, PANE);
1364
+ expect((await ui.find({ key: 'step-0' }))?.text).toMatch(/dispatch\s*ST-9 ST-10 ST-11 ST-12/);
1365
+ await ui.unmount();
1366
+ });
1367
+
1368
+ test('LOOP-1 nothing is submitted mid-turn, or while the person has a draft, or twice for the same step', async ($, on) => {
1369
+ const w = project();
1370
+ const clock = await start($, on, w);
1371
+ await $.turn.start({ turnId: 't-1', text: '' } as any);
1372
+ await clock.advance(4000);
1373
+ expect(w.prompts).toEqual([]);
1374
+ w.draft = 'half a thought';
1375
+ await $.turn.complete({ turnId: 't-1', answer: '', durationMs: 1, isAborted: false } as any);
1376
+ await settle(clock);
1377
+ expect(w.prompts).toEqual([]);
1378
+ w.draft = '';
1379
+ await leadTurn($, 't-2');
1380
+ await settle(clock);
1381
+ expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
1382
+ await leadTurn($, 't-3');
1383
+ await settle(clock);
1384
+ expect(w.prompts).toHaveLength(1);
1385
+ });
1386
+
1387
+ test("LOOP-1 review and the lead's own stories are prompted with their skill", async ($, on) => {
1388
+ const w = project();
1389
+ w.next = { step: 'review', args: 'ST-4', then: [] };
1390
+ const clock = await start($, on, w);
1391
+ await leadTurn($);
1392
+ await settle(clock);
1393
+ w.next = { step: 'lead', args: 'ST-6', then: [] };
1394
+ w.refs += ' ccc refs/heads/web/st-4-moved\n';
1395
+ await leadTurn($, 't-2');
1396
+ await settle(clock);
1397
+ expect(w.prompts[0]).toBe('Review ST-4: run /code-kit:review ST-4.');
1398
+ expect(w.prompts[1]).toMatch(/^Build ST-6 yourself.*lead\/st-6/);
1399
+ });
1400
+
1401
+ test('LOOP-8 a step the lead was asked for twice and that is still next goes to the person, while lanes keep committing', async ($, on) => {
1402
+ const w = project();
1403
+ w.next = { step: 'merge', args: 'lead/st-1', then: [] };
1404
+ const clock = await start($, on, w);
1405
+ for (const [i, turn] of ['t-1', 't-2', 't-3', 't-4'].entries()) {
1406
+ // A lane commits between the lead's turns: the project moves, but not this step.
1407
+ w.refs += ` c${i} refs/heads/core/st-${i}\n`;
1408
+ await leadTurn($, turn);
1409
+ await settle(clock);
1410
+ }
1411
+ expect(w.prompts).toHaveLength(2);
1412
+ expect(w.prompts.every((p: string) => p.startsWith('Merge lead/st-1'))).toBe(true);
1413
+ const ui = await bandUi($);
1414
+ expect(
1415
+ await ui.find({
1416
+ type: 'Text',
1417
+ text: 'Merge lead/st-1: still next after 2 prompts. Ask the lead again?',
1418
+ }),
1419
+ ).toBeDefined();
1420
+ await press($, 'band-go');
1421
+ await ui.unmount();
1422
+ expect(w.prompts).toHaveLength(3);
1423
+ // Once the step changes, the loop goes on by itself.
1424
+ w.next = { step: 'dispatch', args: 'ST-9', then: [] };
1425
+ await leadTurn($, 't-5');
1426
+ await settle(clock);
1427
+ expect(w.prompts.at(-1)).toBe('Dispatch ST-9: run /code-kit:dispatch ST-9.');
1428
+ });
1429
+
1430
+ test('LOOP-5 under propose the band offers the step, and nothing goes until Go', async ($, on) => {
1431
+ const w = project();
1432
+ (w.check as any).harness = { autonomy: 'propose' };
1433
+ const clock = await start($, on, w);
1434
+ await leadTurn($);
1435
+ await settle(clock);
1436
+ expect(w.prompts).toEqual([]);
1437
+ const ui = await bandUi($);
1438
+ expect(await ui.find({ type: 'Text', text: 'Dispatch ST-9?' })).toBeDefined();
1439
+ await press($, 'band-go');
1440
+ await ui.unmount();
1441
+ expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
1442
+ });
1443
+
1444
+ test('LOOP-5 under off the loop takes no step, and the band shows no loop', async ($, on) => {
1445
+ const w = project();
1446
+ (w.check as any).harness = { autonomy: 'off' };
1447
+ const clock = await start($, on, w);
1448
+ await leadTurn($);
1449
+ await settle(clock);
1450
+ expect(w.prompts).toEqual([]);
1451
+ const ui = await bandUi($);
1452
+ expect(await ui.find({ type: 'Text', text: /loop/ })).toBeUndefined();
1453
+ await ui.unmount();
1454
+ });
1455
+
1456
+ test('LOOP-6 Pause stops new steps until Resume', async ($, on) => {
1457
+ const w = project();
1458
+ w.next = { step: 'review', args: 'ST-4', then: [] };
1459
+ const clock = await start($, on, w);
1460
+ await leadTurn($);
1461
+ await settle(clock);
1462
+ let ui = await bandUi($);
1463
+ expect(await ui.find({ type: 'Text', text: 'loop on · last: review ST-4' })).toBeDefined();
1464
+ await press($, 'band-pause');
1465
+ await ui.unmount();
1466
+ w.next = { step: 'dispatch', args: 'ST-9', then: [] };
1467
+ await leadTurn($, 't-2');
1468
+ await settle(clock);
1469
+ expect(w.prompts).toEqual(['Review ST-4: run /code-kit:review ST-4.']);
1470
+ ui = await bandUi($);
1471
+ expect(await ui.find({ type: 'Text', text: 'loop paused' })).toBeDefined();
1472
+ await press($, 'band-resume');
1473
+ await ui.unmount();
1474
+ expect(w.prompts).toEqual([
1475
+ 'Review ST-4: run /code-kit:review ST-4.',
1476
+ 'Dispatch ST-9: run /code-kit:dispatch ST-9.',
1477
+ ]);
1478
+ });
1479
+
1480
+ test('LOOP-3 a quiet lane agent is nudged at 5 minutes, restarted through the lead at 10, and flagged after 2 restarts', async ($, on) => {
1481
+ const w = project();
1482
+ w.next = { step: 'wait', args: '', then: [] };
1483
+ w.agents = [
1484
+ { id: 'a1', type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
1485
+ ];
1486
+ const clock = await start($, on, w);
1487
+ // First seen at the next look (2 s), so quiet for 5 minutes a look later.
1488
+ await clock.advance(5 * 60000 + 2000);
1489
+ expect(w.sent).toEqual([
1490
+ {
1491
+ to: 'a1',
1492
+ text: 'No tool call for 5 minutes: report where you are, or carry on.',
1493
+ },
1494
+ ]);
1495
+ await lanes($);
1496
+ let ui = await pane($, PANE);
1497
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/nudged/);
1498
+ await ui.unmount();
1499
+ await clock.advance(5 * 60000);
1500
+ expect(w.prompts[0]).toMatch(
1501
+ /^web-engineer \(agent a1\) on ST-4 has made no tool call for 10 minutes\. Stop it with TaskStop.*\/code-kit:dispatch ST-4$/,
1502
+ );
1503
+ ui = await pane($, PANE);
1504
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/restarted 1\/2/);
1505
+ await ui.unmount();
1506
+ // The lane's agent is started again, and stalls again, twice more.
1507
+ for (const id of ['a2', 'a3']) {
1508
+ w.agents = [{ id, type: 'web-engineer', description: 'ST-4 booking form', status: 'running' }];
1509
+ await clock.advance(10 * 60000 + 2000);
1510
+ }
1511
+ expect(w.prompts).toHaveLength(2);
1512
+ const band = await bandUi($);
1513
+ expect(await band.find({ type: 'Text', text: 'web stalled 3 times on ST-4' })).toBeDefined();
1514
+ expect(await band.find({ key: 'band-resumeStall' })).toBeDefined();
1515
+ await band.unmount();
1516
+ });
1517
+
1518
+ test('LOOP-3 an agent waiting on a held call (or running a long command) is never quiet', async ($, on) => {
1519
+ const w = project();
1520
+ w.next = { step: 'wait', args: '', then: [] };
1521
+ w.refusal = webInstall;
1522
+ (w.check as any).harness = { hold: { minutes: 10 } };
1523
+ w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
1524
+ const clock = await start($, on, w);
1525
+ const { call } = await heldCall($, w, clock);
1526
+ await clock.advance(6 * 60000);
1527
+ expect(w.sent).toEqual([]);
1528
+ w.waiting.get('tu-h1')?.('timed out');
1529
+ await call;
1530
+ // Its quiet time starts again when the call ends: nudged 5 minutes after, not at once.
1531
+ await clock.advance(2000);
1532
+ expect(w.sent).toEqual([]);
1533
+ await clock.advance(5 * 60000);
1534
+ expect(w.sent.map((m) => m.to)).toEqual(['a1']);
1535
+ });
1536
+
1537
+ test('LOOP-7 when every story is done the loop stops, and the band says so until dismissed', async ($, on) => {
1538
+ const w = project();
1539
+ w.stories = w.stories.map((s) => ({ ...s, state: 'done' }));
1540
+ w.next = { step: 'done', args: '', then: [] };
1541
+ const clock = await start($, on, w);
1542
+ await leadTurn($);
1543
+ await settle(clock);
1544
+ expect(w.prompts).toEqual([]);
1545
+ let ui = await bandUi($);
1546
+ expect(await ui.find({ type: 'Text', text: 'Milestone done: 4 stories merged' })).toBeDefined();
1547
+ await press($, 'band-dismissDone');
1548
+ await ui.unmount();
1549
+ ui = await bandUi($);
1550
+ expect(await ui.find({ type: 'Text', text: /Milestone done/ })).toBeUndefined();
1551
+ await ui.unmount();
1552
+ });
1553
+
1554
+ // --- the reviewer -----------------------------------------------------------------------------------
1555
+
1556
+ /** ST-4 finished on web/st-4, the reviewer on. */
1557
+ function reviewing() {
1558
+ const w = project();
1559
+ w.stories[0] = { ...w.stories[0], state: 'review', requirements: ['BOOK-1'] };
1560
+ w.next = { step: 'review', args: 'ST-4', then: [] };
1561
+ (w.check as any).harness = { agents: { reviewer: { on: true } } };
1562
+ return w;
1563
+ }
1564
+ const report = (json: object) =>
1565
+ 'Reviewed web/st-4.\n\n\`\`\`json\n' + JSON.stringify(json) + '\n\`\`\`\n';
1566
+
1567
+ test('REVW-1 with the reviewer on, a finished branch has the lead start one in the background, and the pane shows it running', async ($, on) => {
1568
+ const w = reviewing();
1569
+ const clock = await start($, on, w);
1570
+ expect(w.registered[0]).toMatchObject({
1571
+ name: 'reviewer',
1572
+ tools: ['Read', 'Grep', 'Glob', 'Bash'],
1573
+ });
1574
+ await leadTurn($);
1575
+ await settle(clock);
1576
+ expect(w.prompts[0]).toMatch(
1577
+ /^Start code-kit's reviewer in the background for web\/st-4: use the Agent tool with subagent_type "code-kit:reviewer", run_in_background true, description "Review web\/st-4 \(ST-4\)".*at abc123def456 for ST-4 Booking form \(requirements BOOK-1\)/,
1578
+ );
1579
+ await clock.advance(3 * 60000);
1580
+ await lanes($);
1581
+ const ui = await pane($, PANE);
1582
+ expect((await ui.find({ key: 'review-web/st-4' }))?.text).toMatch(/running 3m/);
1583
+ await ui.unmount();
1584
+ // While it runs, the lead isn't asked to review.
1585
+ await leadTurn($, 't-2');
1586
+ await settle(clock);
1587
+ expect(w.prompts).toHaveLength(1);
1588
+ });
1589
+
1590
+ test("REVW-3 and REVW-4 the reviewer's findings, a failed verify as a blocker, go into the lead's review", async ($, on) => {
1591
+ const w = reviewing();
1592
+ const clock = await start($, on, w);
1593
+ await leadTurn($);
1594
+ await settle(clock);
1595
+ w.agents = [
1596
+ {
1597
+ id: 'r1',
1598
+ type: 'code-kit:reviewer',
1599
+ description: 'Review web/st-4 (ST-4)',
1600
+ status: 'completed',
1601
+ },
1602
+ ];
1603
+ await $.turn.complete({
1604
+ turnId: 't-r1',
1605
+ agentId: 'r1',
1606
+ answer: report({
1607
+ verify: { passed: false, problems: ['apps/api/x.ts is outside the web lane'] },
1608
+ findings: [{ severity: 'nit', where: 'apps/web/form.tsx:3', text: 'Unused import.' }],
1609
+ }),
1610
+ durationMs: 1,
1611
+ isAborted: false,
1612
+ } as any);
1613
+ await leadTurn($, 't-2');
1614
+ await settle(clock);
1615
+ expect(w.prompts[1]).toBe(
1616
+ [
1617
+ "Review ST-4: run /code-kit:review ST-4. code-kit's reviewer found 1 blocker, 1 nit on web/st-4 at abc123def456:",
1618
+ '- blocker: verify: apps/api/x.ts is outside the web lane (code-kit verify)',
1619
+ '- nit apps/web/form.tsx:3: Unused import.',
1620
+ 'A blocker means you send the branch back. Concerns and nits are yours to weigh.',
1621
+ ].join('\n'),
1622
+ );
1623
+ await lanes($);
1624
+ const ui = await pane($, PANE);
1625
+ expect((await ui.find({ key: 'review-web/st-4' }))?.text).toMatch(/done · 1 blocker, 1 nit/);
1626
+ await ui.unmount();
1627
+ });
1628
+
1629
+ test("REVW-1 a reviewer that hasn't reported in 15 minutes is passed over: the lead reviews without it", async ($, on) => {
1630
+ const w = reviewing();
1631
+ const clock = await start($, on, w);
1632
+ await leadTurn($);
1633
+ await settle(clock);
1634
+ await clock.advance(15 * 60000 + 2000);
1635
+ await leadTurn($, 't-2');
1636
+ await settle(clock);
1637
+ expect(w.prompts[1]).toMatch(/didn't report: no report within 15 minutes\. Review it yourself/);
1638
+ });
1639
+
1640
+ test('REVW-1 a branch that moves gets a new review', async ($, on) => {
1641
+ const w = reviewing();
1642
+ const clock = await start($, on, w);
1643
+ await leadTurn($);
1644
+ await settle(clock);
1645
+ w.heads['web/st-4'] = 'fff000111222333\n';
1646
+ await leadTurn($, 't-2');
1647
+ await settle(clock);
1648
+ expect(w.prompts[1]).toMatch(/^Start code-kit's reviewer.*at fff000111222/);
1649
+ });
1650
+
1651
+ test('USE-4 past the pause point no reviewer starts: the review is skipped and the lead reviews alone', async ($, on) => {
1652
+ const w = reviewing();
1653
+ w.limits = [{ kind: 'five_hour', percentUsed: 81 }];
1654
+ const clock = await start($, on, w);
1655
+ await leadTurn($);
1656
+ await settle(clock);
1657
+ expect(w.prompts).toEqual([
1658
+ 'Review ST-4: run /code-kit:review ST-4. (No background review: background agents paused, the plan at 81%.)',
1659
+ ]);
1660
+ await lanes($);
1661
+ const ui = await pane($, PANE);
1662
+ expect((await ui.find({ key: 'review-web/st-4' }))?.text).toMatch(
1663
+ /skipped: background agents paused/,
1664
+ );
1665
+ await ui.unmount();
1666
+ });
1667
+
1668
+ test("REVW-2 the reviewer's model is the setting's, registered again when it changes", async ($, on) => {
1669
+ const w = reviewing();
1670
+ (w.check as any).harness = { agents: { reviewer: { on: true, model: 'haiku' } } };
1671
+ await start($, on, w);
1672
+ expect(w.registered.at(-1)).toMatchObject({ name: 'reviewer', model: 'haiku' });
1673
+ expect(w.registered.at(-1).tools).not.toContain('Write');
1674
+ });
1675
+
1676
+ // --- the merge queue --------------------------------------------------------------------------------
1677
+
1678
+ test('MQ-2 where the lead merges, the loop prompts it to merge the head of the queue', async ($, on) => {
1679
+ const w = project();
1680
+ w.next = { step: 'merge', args: 'web/st-4', then: [] };
1681
+ const clock = await start($, on, w);
1682
+ await leadTurn($);
1683
+ await settle(clock);
1684
+ expect(w.prompts[0]).toMatch(
1685
+ /^Merge web\/st-4, next in the merge queue: run node ".*\/bin\/code-kit\.mjs" queue merge --delegated\./,
1686
+ );
1687
+ });
1688
+
1689
+ test('MQ-2 where the person merges, the head of the queue waits in the band for their Merge', async ($, on) => {
1690
+ const w = project();
1691
+ w.queue = [{ branch: 'web/st-4', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
1692
+ await start($, on, w);
1693
+ const ui = await bandUi($);
1694
+ expect(await ui.find({ type: 'Text', text: 'web/st-4 is next to merge' })).toBeDefined();
1695
+ await press($, 'band-mergeQueue');
1696
+ await ui.unmount();
1697
+ expect(w.acts).toEqual([['queue', 'merge', '--person']]);
1698
+ expect(w.opened).toContain(RESULT);
1699
+ });
1700
+
1701
+ test('MQ-2 the first merge, before the base has code-kit rules committed, is offered to the person', async ($, on) => {
1702
+ const w = project();
1703
+ (w.check as any).firstMerge = true;
1704
+ w.queue = [{ branch: 'lead/st-1', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
1705
+ await start($, on, w);
1706
+ const ui = await bandUi($);
1707
+ expect(
1708
+ await ui.find({
1709
+ type: 'Text',
1710
+ text: 'lead/st-1 is next to merge: the first merge is yours, then the lead merges',
1711
+ }),
1712
+ ).toBeDefined();
1713
+ await ui.unmount();
1714
+ });
1715
+
1716
+ test('MQ-2 where the lead merges under autonomy, the band offers no Merge', async ($, on) => {
1717
+ const w = project();
1718
+ (w.check as any).delegatesMerge = true;
1719
+ w.queue = [{ branch: 'web/st-4', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
1720
+ await start($, on, w);
1721
+ const ui = await bandUi($);
1722
+ expect(await ui.find({ type: 'Text', text: /next to merge/ })).toBeUndefined();
1723
+ await ui.unmount();
1724
+ });
1725
+
1726
+ test('MQ-4 the pane shows the queue in order, what it merged and sent back, and the lane as queued', async ($, on) => {
1727
+ const w = project();
1728
+ w.stories[0] = { ...w.stories[0], state: 'review' };
1729
+ w.queue = [
1730
+ { branch: 'api/st-2', passed: '2026-10-10T00:00:00Z', state: 'merged' },
1731
+ {
1732
+ branch: 'api/st-3',
1733
+ passed: '2026-10-10T00:01:00Z',
1734
+ state: 'sent back',
1735
+ reason: 'conflicts with main after api/st-2',
1736
+ },
1737
+ { branch: 'web/st-4', passed: '2026-10-10T00:02:00Z', state: 'waiting' },
1738
+ ];
1739
+ await start($, on, w);
1740
+ await lanes($);
1741
+ const ui = await pane($, PANE);
1742
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/queued/);
1743
+ await press($, 'tab-queue', PANE);
1744
+ expect((await ui.find({ key: 'queue-web/st-4-waiting' }))?.text).toMatch(
1745
+ /1\.\s*web\/st-4\s*waiting/,
1746
+ );
1747
+ expect((await ui.find({ key: 'queue-api/st-3-sent back' }))?.text).toMatch(
1748
+ /conflicts with main after api\/st-2/,
1749
+ );
1750
+ await ui.unmount();
1751
+ });
1752
+
1753
+ // --- panes v2 ---------------------------------------------------------------------------------------
1754
+
1755
+ const inlinePane = ($: any) =>
1756
+ $.ui.mount({
1757
+ plugin: 'code-kit',
1758
+ component: 'Pane',
1759
+ requestId: PANE,
1760
+ surface: 'terminal',
1761
+ viewport: { columns: 90, rows: 30 },
1762
+ props: { title: PANE, isFocused: true, bodyColumns: 80, placement: 'inline' },
1763
+ });
1764
+
1765
+ test('VIEW-1 the header counts the stories by state, and a count filters the lanes', async ($, on) => {
1766
+ const w = project();
1767
+ w.stories = [
1768
+ { id: 'ST-1', title: 'Done', lane: 'api', state: 'done', branch: 'api/st-1', waitingOn: [] },
1769
+ ...w.stories,
1770
+ ];
1771
+ w.limits = [{ kind: 'five_hour', percentUsed: 40 }];
1772
+ await start($, on, w);
1773
+ await lanes($);
1774
+ const ui = await pane($, PANE);
1775
+ expect((await ui.find({ key: 'count-building' }))?.props.label).toBe('● 1 building');
1776
+ expect((await ui.find({ key: 'count-ready' }))?.props.label).toBe('○ 3 ready');
1777
+ expect((await ui.find({ key: 'count-merged' }))?.props.label).toBe('✓ 1 merged');
1778
+ expect(await ui.find({ type: 'Text', text: 'plan 40%' })).toBeDefined();
1779
+ await press($, 'count-building', PANE);
1780
+ expect(await ui.find({ key: 'lane-web' })).toBeDefined();
1781
+ expect(await ui.find({ key: 'lane-api' })).toBeUndefined();
1782
+ await press($, 'count-building', PANE);
1783
+ expect(await ui.find({ key: 'lane-api' })).toBeDefined();
1784
+ await ui.unmount();
1785
+ });
1786
+
1787
+ test("VIEW-4 each lane shows its agent's last tool call and the time since, amber once quiet", async ($, on) => {
1788
+ const w = project();
1789
+ w.next = { step: 'wait', args: '', then: [] };
1790
+ w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
1791
+ const clock = await start($, on, w);
1792
+ await $.tool.call({
1793
+ tool: 'Bash',
1794
+ tool_use_id: 'tu-l',
1795
+ command: 'npm test',
1796
+ agentId: 'a1',
1797
+ } as any);
1798
+ await clock.advance(12000);
1799
+ await lanes($);
1800
+ let ui = await pane($, PANE);
1801
+ const live = await ui.find({ type: 'Text', text: /^Bash npm test · / });
1802
+ expect(live?.text).toBe('Bash npm test · 12s ago');
1803
+ expect(live?.props.color).toBeUndefined();
1804
+ await ui.unmount();
1805
+ await clock.advance(5 * 60000);
1806
+ ui = await pane($, PANE);
1807
+ expect((await ui.find({ type: 'Text', text: /^Bash npm test · 5m ago$/ }))?.props.color).toBe(
1808
+ 'yellow',
1809
+ );
1810
+ await ui.unmount();
1811
+ });
1812
+
1813
+ test('VIEW-5 what needs the person is pinned at the top with its letter: a held install, a on Approve', async ($, on) => {
1814
+ const w = project();
1815
+ w.refusal = webInstall;
1816
+ const clock = await start($, on, w);
1817
+ await heldCall($, w, clock);
1818
+ await lanes($);
1819
+ const ui = await pane($, PANE);
1820
+ expect((await ui.find({ key: 'need-held' }))?.text).toMatch(/web wants to install dayjs/);
1821
+ expect((await ui.find({ key: 'need-approveHeld' }))?.props.hotkey).toBe('a');
1822
+ await press($, 'need-approveHeld', PANE);
1823
+ expect(w.opened).toContain(APPROVE);
1824
+ await ui.unmount();
1825
+ w.waiting.get('tu-h1')?.('timed out');
1826
+ });
1827
+
1828
+ test('VIEW-6 tabs on 1 to 3, j and k move between lanes, and the selected lane has its keys', async ($, on) => {
1829
+ const w = project();
1830
+ w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
1831
+ await start($, on, w);
1832
+ await lanes($);
1833
+ const ui = await pane($, PANE);
1834
+ expect((await ui.find({ key: 'tab-queue' }))?.props.hotkey).toBe('2');
1835
+ await press($, 'tab-queue', PANE);
1836
+ expect(await ui.find({ type: 'Text', text: 'The merge queue is empty.' })).toBeDefined();
1837
+ await press($, 'tab-lanes', PANE);
1838
+ expect((await ui.find({ key: 'lane-next' }))?.props.hotkey).toBe('j');
1839
+ await press($, 'lane-next', PANE);
1840
+ await press($, 'lane-next', PANE);
1841
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/^›/);
1842
+ expect((await ui.find({ key: 'act-nudge' }))?.props.hotkey).toBe('n');
1843
+ await press($, 'act-nudge', PANE);
1844
+ expect(w.sent).toEqual([
1845
+ { to: 'a1', text: 'The person asks: report where you are, or carry on.' },
1846
+ ]);
1847
+ w.answer = 'Stop';
1848
+ await press($, 'act-stop', PANE);
1849
+ expect(w.prompts.at(-1)).toMatch(
1850
+ /^The person asked to stop web-engineer \(agent a1\) on ST-4: stop it with TaskStop/,
1851
+ );
1852
+ await press($, 'lane-prev', PANE);
1853
+ expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/^›/);
1854
+ await ui.unmount();
1855
+ });
1856
+
1857
+ test('VIEW-6 s sends the selected story back, with the reason the person gives', async ($, on) => {
1858
+ const w = project();
1859
+ w.stories[0] = { ...w.stories[0], state: 'review' };
1860
+ await start($, on, w);
1861
+ await lanes($);
1862
+ const ui = await pane($, PANE);
1863
+ await press($, 'lane-next', PANE);
1864
+ await press($, 'lane-next', PANE);
1865
+ expect((await ui.find({ key: 'act-sendBack' }))?.props.hotkey).toBe('s');
1866
+ await press($, 'act-sendBack', PANE);
1867
+ expect(w.opened).toContain('code-kit-sendback');
1868
+ const confirm = await pane($, 'code-kit-sendback');
1869
+ await $.ui.input({ plugin: 'code-kit', key: 'sendback-reason', text: '' });
1870
+ expect(await confirm.find({ type: 'Text', text: /Give a reason/ })).toBeDefined();
1871
+ await $.ui.input({
1872
+ plugin: 'code-kit',
1873
+ key: 'sendback-reason',
1874
+ text: 'the cancel button has no test',
1875
+ });
1876
+ expect(w.acts).toEqual([['sent-back', 'web/st-4', '--reason', 'the cancel button has no test']]);
1877
+ expect(w.closed).toContain('code-kit-sendback');
1878
+ await confirm.unmount();
1879
+ await ui.unmount();
1880
+ });
1881
+
1882
+ test('VIEW-6 f puts the keys in the filter, which narrows the lanes by name, agent or story', async ($, on) => {
1883
+ const w = project();
1884
+ await start($, on, w);
1885
+ await lanes($);
1886
+ const ui = await pane($, PANE);
1887
+ expect((await ui.find({ key: 'filter-focus' }))?.props.hotkey).toBe('f');
1888
+ await $.ui.input({ plugin: 'code-kit', key: 'filter', text: 'api-eng', kind: 'change' });
1889
+ expect(await ui.find({ key: 'lane-api' })).toBeDefined();
1890
+ expect(await ui.find({ key: 'lane-web' })).toBeUndefined();
1891
+ await ui.unmount();
1892
+ });
1893
+
1894
+ test('VIEW-8 inline, the pane is the header, what needs the person and one line per lane', async ($, on) => {
1895
+ const w = project();
1896
+ await start($, on, w);
1897
+ await lanes($);
1898
+ const ui = await inlinePane($);
1899
+ expect(await ui.find({ key: 'count-building' })).toBeDefined();
1900
+ expect(await ui.find({ key: 'tabs' })).toBeUndefined();
1901
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/^web\s*building\s*ST-4/);
1902
+ await ui.unmount();
1903
+ });
1904
+
1905
+ // --- the story drill-down ---------------------------------------------------------------------------
1906
+
1907
+ /** The Lanes pane with web's lane selected. */
1908
+ async function onWeb($: any) {
1909
+ await lanes($);
1910
+ const ui = await pane($, PANE);
1911
+ await press($, 'lane-next', PANE);
1912
+ await press($, 'lane-next', PANE);
1913
+ return ui;
1914
+ }
1915
+
1916
+ test('VIEW-7 Enter on a lane opens its story: requirements with the spec-check, verify, the diff and its acts', async ($, on) => {
1917
+ const w = project();
1918
+ w.stories[0] = { ...w.stories[0], state: 'review' };
1919
+ await start($, on, w);
1920
+ const ui = await onWeb($);
1921
+ const open = await ui.find({ key: 'open-story' });
1922
+ expect(open?.props.label).toBe('Open ST-4');
1923
+ expect(open?.props.hotkey).toBe('o');
1924
+ expect(open?.props.autoFocus).toBe(true);
1925
+ await press($, 'open-story', PANE);
1926
+ expect((await ui.find({ key: 'story-head' }))?.text).toMatch(
1927
+ /ST-4 Booking form\s*web · review · web\/st-4/,
1928
+ );
1929
+ expect((await ui.find({ key: 'req-BOOK-1' }))?.text).toMatch(/spec-check: partial/);
1930
+ expect((await ui.find({ key: 'story-verify' }))?.text).toMatch(/Ownership: apps\/api\/x.ts/);
1931
+ expect((await ui.find({ key: 'file-0' }))?.props.label).toBe('apps/web/form.tsx +2 -0');
1932
+ const code = await ui.find({ type: 'Code' });
1933
+ expect(code?.props.format).toBe('diff');
1934
+ expect(String(code?.props.source)).toMatch(/^--- \/dev\/null\n\+\+\+ b\/apps\/web\/form.tsx\n@@/);
1935
+ await press($, 'file-1', PANE);
1936
+ expect(String((await ui.find({ type: 'Code' }))?.props.source)).toMatch(
1937
+ /\+\+\+ b\/apps\/web\/api.ts/,
1938
+ );
1939
+ expect((await ui.find({ key: 'story-review' }))?.props.hotkey).toBe('r');
1940
+ expect((await ui.find({ key: 'story-sendBack' }))?.props.hotkey).toBe('s');
1941
+ await press($, 'story-back', PANE);
1942
+ expect(await ui.find({ key: 'tabs' })).toBeDefined();
1943
+ await ui.unmount();
1944
+ });
1945
+
1946
+ test("REVW-5 the drill-down shows the reviewer's findings by severity, each a way into the diff", async ($, on) => {
1947
+ const w = reviewing();
1948
+ const clock = await start($, on, w);
1949
+ await leadTurn($);
1950
+ await settle(clock);
1951
+ w.agents = [
1952
+ {
1953
+ id: 'r1',
1954
+ type: 'code-kit:reviewer',
1955
+ description: 'Review web/st-4 (ST-4)',
1956
+ status: 'completed',
1957
+ },
1958
+ ];
1959
+ await $.turn.complete({
1960
+ turnId: 't-r1',
1961
+ agentId: 'r1',
1962
+ answer: report({
1963
+ verify: { passed: true, problems: [] },
1964
+ findings: [
1965
+ { severity: 'nit', where: 'apps/web/form.tsx:2', text: 'x is unused.' },
1966
+ {
1967
+ severity: 'concern',
1968
+ where: 'apps/web/api.ts:2',
1969
+ text: 'No error handling.',
1970
+ rule: 'BOOK-1',
1971
+ },
1972
+ ],
1973
+ }),
1974
+ durationMs: 1,
1975
+ isAborted: false,
1976
+ } as any);
1977
+ const ui = await onWeb($);
1978
+ await press($, 'open-story', PANE);
1979
+ const findings = (await ui.find({ key: 'story-findings' }))?.text ?? '';
1980
+ expect(findings).toMatch(/Review of abc123d/);
1981
+ expect(findings.indexOf('Concerns')).toBeLessThan(findings.indexOf('Nits'));
1982
+ await press($, 'finding-concern-0', PANE);
1983
+ expect(String((await ui.find({ type: 'Code' }))?.props.source)).toMatch(/apps\/web\/api.ts/);
1984
+ await ui.unmount();
1985
+ });
1986
+
1987
+ // --- the traceability map ---------------------------------------------------------------------------
1988
+
1989
+ /** 40 requirements over two specs: 30 done (20 tested), 4 in progress, 3 todo, 3 without a story. */
1990
+ function requirementsMap() {
1991
+ const reqs: any[] = [];
1992
+ for (let i = 1; i <= 40; i++) {
1993
+ const state = i <= 30 ? 'done' : i <= 34 ? 'in progress' : i <= 37 ? 'todo' : 'no story';
1994
+ reqs.push({
1995
+ id: `BOOK-${i}`,
1996
+ title: `Requirement ${i}`,
1997
+ file: i <= 20 ? 'docs/spec/01-booking.md' : 'docs/spec/02-payments.md',
1998
+ removed: false,
1999
+ state,
2000
+ stories: state === 'no story' ? [] : ['ST-4'],
2001
+ tests: i <= 20 ? [`apps/web/b${i}.test.ts`] : [],
2002
+ });
2003
+ }
2004
+ return reqs;
2005
+ }
2006
+
2007
+ test('TRACE-1 the map: a cell per requirement, a row per spec, and a legend with the gaps counted', async ($, on) => {
2008
+ const w = project();
2009
+ w.requirements = requirementsMap();
2010
+ await start($, on, w);
2011
+ await lanes($);
2012
+ const ui = await pane($, PANE);
2013
+ expect((await ui.find({ key: 'tab-map' }))?.props.hotkey).toBe('4');
2014
+ await press($, 'tab-map', PANE);
2015
+ const legend = (await ui.find({ key: 'map-legend' }))?.text ?? '';
2016
+ expect(legend).toMatch(/■ 20 done and tested/);
2017
+ expect(legend).toMatch(/✗ 3 without a story/);
2018
+ expect((await ui.find({ key: 'map-row-01-booking' }))?.text.replace(/\s/g, '')).toBe(
2019
+ '01-booking' + '■'.repeat(20),
2020
+ );
2021
+ const cells = await Promise.all(w.requirements.map((r) => ui.find({ key: `cell-${r.id}` })));
2022
+ expect(cells.filter((c) => c?.text === '✗')).toHaveLength(3);
2023
+ await ui.unmount();
2024
+ });
2025
+
2026
+ test('TRACE-2 selecting a cell, by keys or the list, shows its spec, stories with their branches, and tests', async ($, on) => {
2027
+ const w = project();
2028
+ w.requirements = requirementsMap();
2029
+ w.stories[0] = { ...w.stories[0], branchExists: true };
2030
+ await start($, on, w);
2031
+ await lanes($);
2032
+ const ui = await pane($, PANE);
2033
+ await press($, 'tab-map', PANE);
2034
+ expect((await ui.find({ key: 'map-detail' }))?.text).toMatch(/BOOK-1 Requirement 1/);
2035
+ await press($, 'cell-next', PANE);
2036
+ expect(JSON.stringify((await ui.find({ key: 'cell-BOOK-2' }))?.children)).toMatch(
2037
+ /"inverse":true/,
2038
+ );
2039
+ await press($, 'row-next', PANE);
2040
+ expect(JSON.stringify((await ui.find({ key: 'cell-BOOK-21' }))?.children)).toMatch(
2041
+ /"inverse":true/,
2042
+ );
2043
+ await $.ui.select({ plugin: 'code-kit', key: 'map-select', value: 'BOOK-3', requestId: PANE });
2044
+ const detail = (await ui.find({ key: 'map-detail' }))?.text ?? '';
2045
+ expect(detail).toMatch(/docs\/spec\/01-booking.md · done and tested/);
2046
+ expect(detail).toMatch(/ST-4 Booking form · web · in progress · web\/st-4/);
2047
+ expect(detail).toMatch(/apps\/web\/b3.test.ts/);
2048
+ await $.ui.select({ plugin: 'code-kit', key: 'map-select', value: 'BOOK-40', requestId: PANE });
2049
+ expect((await ui.find({ key: 'map-detail' }))?.text).toMatch(/No story delivers it yet/);
2050
+ await ui.unmount();
2051
+ });
2052
+
2053
+ test('TRACE-3 the map follows the project: a commit that finishes a requirement shows within 2 seconds', async ($, on) => {
2054
+ const w = project();
2055
+ w.requirements = requirementsMap();
2056
+ const clock = await start($, on, w);
2057
+ await lanes($);
2058
+ const ui = await pane($, PANE);
2059
+ await press($, 'tab-map', PANE);
2060
+ w.requirements = w.requirements.map((r) =>
2061
+ r.id === 'BOOK-31' ? { ...r, state: 'done', tests: ['t'] } : r,
2062
+ );
2063
+ w.refs += ' ddd refs/heads/web/st-9\n';
2064
+ await clock.advance(2000);
2065
+ expect((await ui.find({ key: 'map-legend' }))?.text).toMatch(/■ 21 done and tested/);
2066
+ await ui.unmount();
2067
+ });
2068
+
2069
+ // --- timelines and characters ---------------------------------------------------------------------
2070
+
2071
+ test('VIEW-2 a lane dispatched 40 minutes ago and in review for 5 shows 35 minutes building and 5 in review, to now', async ($, on) => {
2072
+ const w = project();
2073
+ w.stories[0] = { ...w.stories[0], state: 'review' };
2074
+ const clock = await start($, on, w);
2075
+ const now = clock.now() + 2000;
2076
+ w.times = [
2077
+ {
2078
+ id: 'ST-4',
2079
+ lane: 'web',
2080
+ branch: 'web/st-4',
2081
+ state: 'review',
2082
+ started: now - 40 * 60000,
2083
+ lastCommit: now - 5 * 60000,
2084
+ merged: null,
2085
+ sentBack: [],
2086
+ approvals: [],
2087
+ },
2088
+ ];
2089
+ w.refs += ' eee refs/heads/web/st-4-again\n';
2090
+ await clock.advance(2000);
2091
+ await lanes($);
2092
+ const ui = await pane($, PANE);
2093
+ const bar = (await ui.find({ key: 'bar-web' }))?.text.trim() ?? '';
2094
+ const building = [...bar].filter((c) => c === '█').length;
2095
+ const review = [...bar].filter((c) => c === '▒').length;
2096
+ expect(building + review).toBe(66);
2097
+ expect(Math.abs(building - (66 * 35) / 40)).toBeLessThanOrEqual(1);
2098
+ expect(bar.endsWith('▒')).toBe(true);
2099
+ await ui.unmount();
2100
+ });
2101
+
2102
+ test('VIEW-3 each lane has its character: web works, api waits on a held call, the lead rests', async ($, on) => {
2103
+ const w = project();
2104
+ w.refusal =
2105
+ 'Blocked: a new dependency (zod) needs a person\'s approval. Write a short change request for the lead.\n ! echo "<what you are approving>" > .claude/approvals/api/dep-zod\n(a person runs it; it allows installing it for the api lane, until the install is committed, at most 7 days)\nCommand: npm install zod';
2106
+ w.agents = [
2107
+ { id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' },
2108
+ { id: 'a2', type: 'api-engineer', description: 'ST-5', status: 'running' },
2109
+ ];
2110
+ const clock = await start($, on, w);
2111
+ w.holdWaits = true;
2112
+ const call = $.tool.call({
2113
+ tool: 'Bash',
2114
+ tool_use_id: 'tu-z',
2115
+ command: 'npm install zod',
2116
+ agentId: 'a2',
2117
+ } as any);
2118
+ for (let i = 0; i < 50 && !w.waiting.has('tu-z'); i++) await clock.advance(1);
2119
+ await lanes($);
2120
+ const ui = await pane($, PANE);
2121
+ const names = ['lead', 'web', 'api', 'core'];
2122
+ const cells = async (lane: string) => (await ui.find({ key: `char-${lane}` }))?.props.cells;
2123
+ expect(await cells('web')).toBe(rasterCells(sprite('working', 0, tintOf('web', names))));
2124
+ expect(await cells('api')).toBe(rasterCells(sprite('waiting', 0, tintOf('api', names))));
2125
+ expect(await cells('lead')).toBe(
2126
+ rasterCells(sprite('idle', 0, tintOf('lead', names), { lead: true })),
2127
+ );
2128
+ // The working character repaints in place; the waiting one doesn't.
2129
+ await clock.advance(2000);
2130
+ await clock.advance(1200);
2131
+ expect(w.blits).toContain('char-web');
2132
+ expect(w.blits).not.toContain('char-api');
2133
+ await ui.unmount();
2134
+ w.waiting.get('tu-z')?.('timed out');
2135
+ await call;
2136
+ });
2137
+
2138
+ test('VIEW-3 nothing repaints when no agent is working', async ($, on) => {
2139
+ const w = project();
2140
+ const clock = await start($, on, w);
2141
+ await lanes($);
2142
+ const ui = await pane($, PANE);
2143
+ await clock.advance(6000);
2144
+ expect(w.blits).toEqual([]);
2145
+ await ui.unmount();
2146
+ });
2147
+
2148
+ test('VIEW-2 and VIEW-3 on Desktop the characters and timelines are SVG', async ($, on) => {
2149
+ const w = project();
2150
+ w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
2151
+ const clock = await start($, on, w);
2152
+ const now = clock.now() + 2000;
2153
+ w.times = [
2154
+ {
2155
+ id: 'ST-4',
2156
+ lane: 'web',
2157
+ branch: 'web/st-4',
2158
+ state: 'in progress',
2159
+ started: now - 600000,
2160
+ lastCommit: null,
2161
+ merged: null,
2162
+ sentBack: [],
2163
+ approvals: [],
2164
+ },
2165
+ ];
2166
+ w.refs += ' fff refs/heads/x\n';
2167
+ await clock.advance(2000);
2168
+ await lanes($);
2169
+ const ui = await $.ui.mount({
2170
+ plugin: 'code-kit',
2171
+ component: 'Pane',
2172
+ requestId: PANE,
2173
+ surface: 'desktop',
2174
+ viewport: { columns: 120, rows: 40 },
2175
+ props: { title: PANE, isFocused: true, bodyColumns: 80, placement: 'dock' },
2176
+ });
2177
+ const svgs = await ui.findAll({ type: 'Svg' });
2178
+ expect(
2179
+ svgs.some((s) => s.props.alt === 'web: working' && String(s.props.source).includes('<animate')),
2180
+ ).toBe(true);
2181
+ expect(svgs.some((s) => s.props.alt === "web's timeline")).toBe(true);
2182
+ await ui.unmount();
2183
+ });
2184
+
2185
+ // --- the combined lane view ---------------------------------------------------------------------------
2186
+
2187
+ /** A project where Context Graph's adapter reports on the web lane's agent. */
2188
+ function combinedProject() {
2189
+ const w = project();
2190
+ w.stories[0] = { ...w.stories[0], state: 'review' };
2191
+ (w.check as any).adapters = ['context-graph'];
2192
+ w.adapters = [
2193
+ {
2194
+ name: 'context-graph',
2195
+ lanes: {
2196
+ 'web-engineer': {
2197
+ withoutUnderstanding: [{ path: 'apps/web/form.tsx', unread: ['apps/web/api.ts'] }],
2198
+ cardsOwed: ['apps/web/form.tsx', 'apps/web/api.ts'],
2199
+ },
2200
+ },
2201
+ open: { command: 'graph' },
2202
+ },
2203
+ ];
2204
+ w.story.adapters = [
2205
+ {
2206
+ name: 'context-graph',
2207
+ files: {
2208
+ 'apps/web/form.tsx': {
2209
+ rules: [
2210
+ { id: 'K:no-fetch', text: 'No fetch in components', mode: 'E', proposed: false },
2211
+ { id: 'K:dates', text: 'Dates through lib/date', mode: 'G?', proposed: true },
2212
+ ],
2213
+ },
2214
+ 'apps/web/api.ts': {
2215
+ rules: [{ id: 'K:no-fetch', text: 'No fetch in components', mode: 'E', proposed: false }],
2216
+ },
2217
+ },
2218
+ open: { command: 'graph' },
2219
+ },
2220
+ ];
2221
+ return w;
2222
+ }
2223
+
2224
+ test('JOIN-2 each lane shows its edits without understanding, red above zero, and its cards owed', async ($, on) => {
2225
+ const w = combinedProject();
2226
+ await start($, on, w);
2227
+ await lanes($);
2228
+ const ui = await pane($, PANE);
2229
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(
2230
+ /1 edit without understanding\s*2 cards owed/,
2231
+ );
2232
+ expect((await ui.find({ type: 'Text', text: '1 edit without understanding' }))?.props.color).toBe(
2233
+ 'red',
2234
+ );
2235
+ expect((await ui.find({ key: 'lane-api' }))?.text).toMatch(/0 edits without understanding/);
2236
+ await ui.unmount();
2237
+ });
2238
+
2239
+ test('JOIN-1 without an adapter that knows, the lanes show no such slots', async ($, on) => {
2240
+ const w = project();
2241
+ await start($, on, w);
2242
+ await lanes($);
2243
+ const ui = await pane($, PANE);
2244
+ expect((await ui.find({ key: 'lane-web' }))?.text).not.toMatch(/understanding|owed/);
2245
+ await ui.unmount();
2246
+ });
2247
+
2248
+ test('JOIN-2 to JOIN-4 the drill-down names the unread files, the cards owed and the rules, and opens a file in Context Graph', async ($, on) => {
2249
+ const w = combinedProject();
2250
+ await start($, on, w);
2251
+ const ui = await onWeb($);
2252
+ await press($, 'open-story', PANE);
2253
+ expect((await ui.find({ key: 'story-understanding' }))?.text).toMatch(
2254
+ /apps\/web\/form.tsx\s*still unread: apps\/web\/api.ts/,
2255
+ );
2256
+ expect((await ui.find({ key: 'story-cards' }))?.text).toMatch(/Cards owed \(2\)/);
2257
+ const rules = (await ui.find({ key: 'story-rules' }))?.text ?? '';
2258
+ expect(rules).toMatch(/agreed\s+K:no-fetch\s+No fetch in components\s+\(2 files\)/);
2259
+ expect(rules.indexOf('K:no-fetch')).toBeLessThan(rules.indexOf('K:dates'));
2260
+ expect(rules).toMatch(/proposed\s+K:dates/);
2261
+ expect((await ui.find({ key: 'story-open-in' }))?.props.hotkey).toBe('v');
2262
+ await press($, 'story-open-in', PANE);
2263
+ expect(w.commands).toContainEqual({ command: 'graph', args: 'apps/web/form.tsx' });
2264
+ await press($, 'unread-file-0', PANE);
2265
+ expect(w.commands.length).toBe(2);
2266
+ await ui.unmount();
2267
+ });
2268
+
2269
+ test("with no session bound (claude -p before it mounts), the agent list's failure leaves the project's refresh working", async ($, on) => {
2270
+ const w = project();
2271
+ w.agentsThrow = true;
2272
+ const clock = await start($, on, w);
2273
+ await lanes($);
2274
+ w.stories[0] = { ...w.stories[0], state: 'review' };
2275
+ w.refs += ' abc refs/heads/web/st-4-done\n';
2276
+ await clock.advance(2000);
2277
+ const ui = await pane($, PANE);
2278
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/in review/);
2279
+ await ui.unmount();
2280
+ });
2281
+
2282
+ test('REVW-2 a reviewer that could not be registered yet is registered at a later refresh, then used', async ($, on) => {
2283
+ const w = reviewing();
2284
+ w.registerFails = 1;
2285
+ const clock = await start($, on, w);
2286
+ expect(w.registered).toEqual([]);
2287
+ w.refs += ' abc refs/heads/x\n';
2288
+ await clock.advance(2000);
2289
+ expect(w.registered.map((s) => s.name)).toEqual(['reviewer']);
2290
+ await leadTurn($);
2291
+ await settle(clock);
2292
+ expect(w.prompts[0]).toMatch(/^Start code-kit's reviewer/);
2293
+ });
2294
+
2295
+ // --- the first offer: how hands-off the loop is -----------------------------------------------------
2296
+
2297
+ /** A project where nobody has chosen autonomy yet: the default stands in. */
2298
+ function unchosen() {
2299
+ const w = project();
2300
+ w.check.harnessSet = [];
2301
+ return w;
2302
+ }
2303
+
2304
+ test('until autonomy is chosen, the loop offers its first step: keep going, ask each time, or off', async ($, on) => {
2305
+ const w = unchosen();
2306
+ const clock = await start($, on, w);
2307
+ await leadTurn($);
2308
+ await settle(clock);
2309
+ expect(w.prompts).toEqual([]);
2310
+ const ui = await bandUi($);
2311
+ expect(
2312
+ await ui.find({
2313
+ type: 'Text',
2314
+ text: 'code-kit can keep the build moving between your prompts. Next: Dispatch ST-9.',
2315
+ }),
2316
+ ).toBeDefined();
2317
+ expect((await ui.find({ key: 'band-keepGoing' }))?.props.label).toMatch(/Keep going$/);
2318
+ expect(await ui.find({ key: 'band-askEach' })).toBeDefined();
2319
+ expect(await ui.find({ key: 'band-loopOff' })).toBeDefined();
2320
+ await ui.unmount();
2321
+ });
2322
+
2323
+ test("Keep going records autonomous as the person's choice and takes the step", async ($, on) => {
2324
+ const w = unchosen();
2325
+ const clock = await start($, on, w);
2326
+ await leadTurn($);
2327
+ await settle(clock);
2328
+ const ui = await bandUi($);
2329
+ await press($, 'band-keepGoing');
2330
+ await ui.unmount();
2331
+ expect(w.acts[0]).toEqual([
2332
+ 'settings',
2333
+ 'set',
2334
+ 'autonomy',
2335
+ 'autonomous',
2336
+ '--reason',
2337
+ 'Chosen when code-kit first offered to keep the build moving between prompts',
2338
+ '--via',
2339
+ 'pane',
2340
+ ]);
2341
+ expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
2342
+ // From then on it acts on its own.
2343
+ w.next = { step: 'review', args: 'ST-4', then: [] };
2344
+ await leadTurn($, 't-2');
2345
+ await settle(clock);
2346
+ expect(w.prompts[1]).toBe('Review ST-4: run /code-kit:review ST-4.');
2347
+ });
2348
+
2349
+ test('Ask each time records propose, and the step waits for Go', async ($, on) => {
2350
+ const w = unchosen();
2351
+ const clock = await start($, on, w);
2352
+ await leadTurn($);
2353
+ await settle(clock);
2354
+ let ui = await bandUi($);
2355
+ await press($, 'band-askEach');
2356
+ await ui.unmount();
2357
+ expect(w.acts[0]?.slice(0, 4)).toEqual(['settings', 'set', 'autonomy', 'propose']);
2358
+ expect(w.prompts).toEqual([]);
2359
+ ui = await bandUi($);
2360
+ expect(await ui.find({ type: 'Text', text: 'Dispatch ST-9?' })).toBeDefined();
2361
+ await press($, 'band-go');
2362
+ await ui.unmount();
2363
+ expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
2364
+ });
2365
+
2366
+ test('Off records off, and the loop does nothing', async ($, on) => {
2367
+ const w = unchosen();
2368
+ const clock = await start($, on, w);
2369
+ await leadTurn($);
2370
+ await settle(clock);
2371
+ let ui = await bandUi($);
2372
+ await press($, 'band-loopOff');
2373
+ await ui.unmount();
2374
+ expect(w.acts[0]?.slice(0, 4)).toEqual(['settings', 'set', 'autonomy', 'off']);
2375
+ await leadTurn($, 't-2');
2376
+ await settle(clock);
2377
+ expect(w.prompts).toEqual([]);
2378
+ ui = await bandUi($);
2379
+ expect(await ui.find({ type: 'Text', text: /keep the build moving|Dispatch/ })).toBeUndefined();
2380
+ await ui.unmount();
2381
+ });
2382
+
2383
+ // --- an idle lane's detail --------------------------------------------------------------------------
2384
+
2385
+ test('VIEW-6 a selected idle lane shows what it owns and its stories, Enter opens its next one, and the header counts it', async ($, on) => {
2386
+ const w = project();
2387
+ w.stories = [
2388
+ { id: 'ST-1', title: 'Schema', lane: 'api', state: 'done', branch: 'api/st-1', waitingOn: [] },
2389
+ {
2390
+ id: 'ST-2',
2391
+ title: 'Endpoints',
2392
+ lane: 'api',
2393
+ state: 'blocked',
2394
+ branch: 'api/st-2',
2395
+ waitingOn: ['ST-9'],
2396
+ },
2397
+ {
2398
+ id: 'ST-9',
2399
+ title: 'Pricing',
2400
+ lane: 'core',
2401
+ state: 'ready',
2402
+ branch: 'core/st-9',
2403
+ waitingOn: [],
2404
+ },
2405
+ ];
2406
+ await start($, on, w);
2407
+ await lanes($);
2408
+ const ui = await pane($, PANE);
2409
+ expect(await ui.find({ type: 'Text', text: '1 of 3 stories done' })).toBeDefined();
2410
+ expect((await ui.find({ key: 'count-idle' }))?.props.label).toMatch(/· \d+ idle/);
2411
+ for (let i = 0; i < 3; i++) await press($, 'lane-next', PANE);
2412
+ const api = (await ui.find({ key: 'lane-api' }))?.text ?? '';
2413
+ expect(api).toMatch(/owns apps\/api\/\*\*/);
2414
+ expect(api).toMatch(/ST-1 Schema · done/);
2415
+ expect(api).toMatch(/ST-2 Endpoints · blocked on ST-9/);
2416
+ expect((await ui.find({ key: 'open-story' }))?.props.label).toBe('Open ST-2');
2417
+ await press($, 'open-story', PANE);
2418
+ expect(await ui.find({ key: 'story-head' })).toBeDefined();
2419
+ await ui.unmount();
2420
+ });
2421
+
2422
+ test('VIEW-6 the filter is one row, its key beside the box', async ($, on) => {
2423
+ const w = project();
2424
+ await start($, on, w);
2425
+ await lanes($);
2426
+ const ui = await pane($, PANE);
2427
+ const row = await ui.find({ key: 'filter-row' });
2428
+ expect(row).toBeDefined();
2429
+ expect((await ui.find({ key: 'filter-focus' }))?.props.hotkey).toBe('f');
2430
+ expect(await ui.findAll({ key: 'filter' })).toHaveLength(1);
2431
+ await ui.unmount();
2432
+ });
2433
+
2434
+ test('the lead reads working while its turn runs, and idle once it ends; an agent at work outside a story too', async ($, on) => {
2435
+ const w = project();
2436
+ w.agents = [
2437
+ { id: 'a9', type: 'api-engineer', description: 'look at the logs', status: 'running' },
2438
+ ];
2439
+ const clock = await start($, on, w);
2440
+ await lanes($);
2441
+ const ui = await pane($, PANE);
2442
+ expect((await ui.find({ key: 'lane-api' }))?.text).toMatch(/api\s*working/);
2443
+ await $.turn.start({ turnId: 't-1', text: '' } as any);
2444
+ expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/lead\s*working/);
2445
+ expect((await ui.find({ key: 'count-working' }))?.props.label).toBe('▶ 2 working');
2446
+ await $.turn.complete({ turnId: 't-1', answer: '', durationMs: 1, isAborted: false } as any);
2447
+ await settle(clock);
2448
+ expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/lead\s*idle/);
2449
+ await ui.unmount();
2450
+ });