@warren-dean/code-kit 0.6.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,6 +2,7 @@
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';
@@ -17,6 +18,7 @@ function project() {
17
18
  valid: true,
18
19
  problems: [] as string[],
19
20
  lead: { paths: ['docs/**'] },
21
+ harnessSet: ['autonomy'] as string[], // the person chose autonomy (the default, autonomous)
20
22
  lanes: {
21
23
  web: { agent: 'web-engineer', paths: ['apps/web/**'] },
22
24
  api: { agent: 'api-engineer', paths: ['apps/api/**'] },
@@ -92,6 +94,62 @@ function project() {
92
94
  { key: 'agents.reviewer.model', value: null, default: null, about: "The reviewer's model" },
93
95
  ] as any[],
94
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>(),
95
153
  };
96
154
  }
97
155
  type World = ReturnType<typeof project>;
@@ -104,6 +162,7 @@ function stub(on: any, w: World) {
104
162
  value: { exitCode, stdout, stderr },
105
163
  });
106
164
  const sub = argv[2];
165
+ if (argv[0] === 'git' && argv[1] === 'rev-parse') return ran(0, w.heads[argv.at(-1)!] ?? '');
107
166
  if (argv[0] === 'git') return ran(0, w.refs);
108
167
  if (sub === 'check') return ran(w.check.valid ? 0 : 1, JSON.stringify(w.check));
109
168
  if (sub === 'status')
@@ -112,7 +171,7 @@ function stub(on: any, w: World) {
112
171
  JSON.stringify({
113
172
  base: 'main',
114
173
  stories: w.stories,
115
- requirements: {},
174
+ requirements: w.requirements,
116
175
  problems: [],
117
176
  drift: [],
118
177
  }),
@@ -122,9 +181,49 @@ function stub(on: any, w: World) {
122
181
  if (sub === 'verify')
123
182
  return ran(w.verifyExit, w.verifyExit ? '' : w.verifyOut, w.verifyExit ? w.verifyOut : '');
124
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
+ }
125
221
  if (sub === 'approve') {
126
222
  w.acts.push([...argv.slice(2)]);
127
- if (w.approveExit === 0) w.requests = [];
223
+ if (w.approveExit === 0) {
224
+ w.requests = [];
225
+ w.allowed = true;
226
+ }
128
227
  return ran(w.approveExit, '', w.approveExit ? 'approve needs --reason' : '');
129
228
  }
130
229
  if (sub === 'merge') {
@@ -139,9 +238,13 @@ function stub(on: any, w: World) {
139
238
  return ran(
140
239
  1,
141
240
  '',
142
- '"harness.hold.minutes" must be a number of minutes from 0 (off) to 30\nNothing was changed.',
241
+ '"harness.hold.minutes" must be a number of minutes from 0 (off) to 10\nNothing was changed.',
143
242
  );
144
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
+ }
145
248
  const raw = argv[5];
146
249
  row.value =
147
250
  raw === 'null'
@@ -168,10 +271,31 @@ function stub(on: any, w: World) {
168
271
  on('tool.call', { tool: 'Write' }, () =>
169
272
  w.refusal ? { deny: w.refusal } : { result: { type: 'create' } },
170
273
  );
171
- on('tool.call', { tool: 'Bash' }, () =>
172
- w.refusal ? { deny: w.refusal } : { result: { stdout: '', stderr: '', interrupted: false } },
173
- );
174
- 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: '' }));
175
299
  on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: w.limits } }));
176
300
  on('session.measure', ($: any, e: any) => ({ changed: e.changed }));
177
301
  // A model request, answered with the usage the test gave it.
@@ -200,6 +324,15 @@ function stub(on: any, w: World) {
200
324
  on('tool.call', { tool: 'AskUserQuestion' }, ($: any, e: any) => ({
201
325
  result: { questions: e.questions, answers: { [e.questions[0].question]: w.answer } },
202
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
+ });
203
336
  on('ui.panes', () => ({ value: [...w.open].map((id) => ({ id, title: id, isShown: true })) }));
204
337
  on('ui.open', ($: any, e: any) => {
205
338
  w.opened.push(e.id);
@@ -238,10 +371,10 @@ async function request($: any, agentId: string | undefined, thousands: number) {
238
371
  }
239
372
 
240
373
  /** A session started on the project, with the mock clock. */
241
- async function start($: any, on: any, w: World) {
374
+ async function start($: any, on: any, w: World, interactive = true) {
242
375
  stub(on, w);
243
376
  const clock = mock.clock(on);
244
- await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true });
377
+ await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: interactive });
245
378
  await clock.advance(2000); // the first look at the project
246
379
  return clock;
247
380
  }
@@ -349,9 +482,10 @@ test('PANE-4 with nothing changed it reads the project every 10 seconds while th
349
482
  const before = w.runs;
350
483
  await clock.advance(8000); // four quiet ticks: git only
351
484
  expect(w.runs - before).toBe(4);
352
- // 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.
353
487
  await clock.advance(2000);
354
- expect(w.runs - before).toBe(11);
488
+ expect(w.runs - before).toBe(13);
355
489
  });
356
490
 
357
491
  test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
@@ -557,8 +691,6 @@ test('BAND-2 a failing finish check shows, with Lanes opening the pane', async (
557
691
  test('ACT-3 the story shows as being reviewed until the lead has reported', async ($, on) => {
558
692
  const w = project();
559
693
  w.stories[0] = { ...w.stories[0], state: 'review' };
560
- on('turn.start', ($: any, e: any) => e);
561
- on('turn.complete', () => ({ text: '' }));
562
694
  await start($, on, w);
563
695
  const ui = await bandUi($);
564
696
  await press($, 'band-review');
@@ -840,7 +972,7 @@ test('SET-2 a number is typed; one the CLI refuses shows why and changes nothing
840
972
  const ui = await pane($, SETTINGS);
841
973
  await $.ui.input({ plugin: 'code-kit', key: 'set-hold.minutes', text: '45' });
842
974
  await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: 'longer' });
843
- expect(await ui.find({ type: 'Text', text: /from 0 \(off\) to 30/ })).toBeDefined();
975
+ expect(await ui.find({ type: 'Text', text: /from 0 \(off\) to 10/ })).toBeDefined();
844
976
  expect(w.settings[1].value).toBe(2);
845
977
  await press($, 'settings-cancel', SETTINGS);
846
978
  expect(await ui.find({ key: 'settings-confirm' })).toBeUndefined();
@@ -896,6 +1028,7 @@ test("USE-1 a lane agent's requests show on its lane's row and its story", async
896
1028
  await clock.advance(2000);
897
1029
  const ui = await pane($, PANE);
898
1030
  expect((await ui.find({ key: 'lane-web' }))?.text).toContain('200k');
1031
+ await press($, 'tab-usage', PANE);
899
1032
  expect((await ui.find({ key: 'usage-ST-4' }))?.text).toContain('200k');
900
1033
  expect((await ui.find({ key: 'lane-api' }))?.text).not.toMatch(/\d+k/);
901
1034
  await ui.unmount();
@@ -911,6 +1044,7 @@ test("USE-1 the lead's requests are the lead's; an agent's brief names its story
911
1044
  const ui = await pane($, PANE);
912
1045
  expect((await ui.find({ key: 'lane-lead' }))?.text).toContain('30k');
913
1046
  expect((await ui.find({ key: 'lane-core' }))?.text).toContain('50k');
1047
+ await press($, 'tab-usage', PANE);
914
1048
  expect((await ui.find({ key: 'usage-ST-9' }))?.text).toContain('50k');
915
1049
  await ui.unmount();
916
1050
  });
@@ -927,6 +1061,7 @@ test("USE-2 the background agents' share and the plan's 5-hour use", async ($, o
927
1061
  await request($, 'a1', 150);
928
1062
  await request($, 'r1', 50);
929
1063
  const ui = await pane($, PANE);
1064
+ await press($, 'tab-usage', PANE);
930
1065
  expect(await ui.find({ type: 'Text', text: 'Background agents 50k (25%)' })).toBeDefined();
931
1066
  expect(await ui.find({ type: 'Text', text: '62% of the 5-hour window' })).toBeDefined();
932
1067
  expect(await ui.find({ type: 'Text', text: 'This session 200k tokens' })).toBeDefined();
@@ -1013,3 +1148,1303 @@ test('USE-4 the pause point is the harness setting', async ($, on) => {
1013
1148
  expect(await ui.find({ type: 'Text', text: /plan at 91%/ })).toBeDefined();
1014
1149
  await ui.unmount();
1015
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
+ });