@warren-dean/code-kit 0.12.2 → 0.14.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.
@@ -152,6 +152,10 @@ function project() {
152
152
  } as any,
153
153
  sent: [] as { to: any; text: string }[],
154
154
  loopPaused: false, // the pause recorded by `code-kit loop`
155
+ leadSession: null as string | null, // LOOP-10: the session holding the lead lease
156
+ leadClaims: 0,
157
+ asked: [] as { question: string; options: string[]; header?: string }[], // dialogs the mod opened
158
+ typed: null as string | null, // what the person types under the dialog's Other, if anything
155
159
  map: null as any, // `code-kit map <story> --json`
156
160
  trail: null as any, // `code-kit trail --json`
157
161
  journal: { events: [] as any[] }, // `code-kit journal --json`
@@ -160,6 +164,7 @@ function project() {
160
164
  questions: [] as any[], // `code-kit ask --json`
161
165
  reports: [] as any[], // `code-kit report --json`
162
166
  fills: [] as string[], // what the mod put in the prompt
167
+ leadBusy: false, // prompts are queued behind the lead's turn, and never resolve in the test
163
168
  sideAsks: [] as any[], // side calls: { fork } or { model }, with their prompt
164
169
  sideReply: { isAnswered: true, text: 'dayjs formats the booking dates.', usage: null } as any,
165
170
  holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
@@ -243,7 +248,28 @@ function stub(on: any, w: World) {
243
248
  w.loopPaused = argv[3] === 'pause';
244
249
  return ran(0, '');
245
250
  }
246
- if (sub === 'loop') return ran(0, JSON.stringify({ paused: w.loopPaused, at: null }));
251
+ if (sub === 'loop' && argv[3] === 'lead') {
252
+ const session = argv[argv.indexOf('--session') + 1];
253
+ if (w.leadSession && w.leadSession !== session && !argv.includes('--take'))
254
+ return ran(1, '', `Another session (${w.leadSession}) runs the lead loop in this project.`);
255
+ w.leadSession = session;
256
+ w.leadClaims += 1;
257
+ return ran(0, 'This session runs the lead loop.');
258
+ }
259
+ if (sub === 'loop' && argv[3] === 'release') {
260
+ w.acts.push([...argv.slice(2)]);
261
+ if (w.leadSession === argv[argv.indexOf('--session') + 1]) w.leadSession = null;
262
+ return ran(0, '');
263
+ }
264
+ if (sub === 'loop')
265
+ return ran(
266
+ 0,
267
+ JSON.stringify({
268
+ paused: w.loopPaused,
269
+ at: null,
270
+ lead: w.leadSession ? { session: w.leadSession, at: '2026-10-16T00:00:00.000Z' } : null,
271
+ }),
272
+ );
247
273
  if (sub === 'sent-back') {
248
274
  w.acts.push([...argv.slice(2)]);
249
275
  return ran(0, `Recorded: ${argv[3]} was sent back.`);
@@ -370,6 +396,7 @@ function stub(on: any, w: World) {
370
396
  });
371
397
  on('turn.start', ($: any, e: any) => e);
372
398
  on('turn.complete', () => ({ text: '' }));
399
+ on('session.end', ($: any, e: any) => ({ sessionId: e.sessionId }));
373
400
  on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: w.limits } }));
374
401
  on('session.measure', ($: any, e: any) => ({ changed: e.changed }));
375
402
  // A model request, answered with the usage the test gave it.
@@ -392,12 +419,20 @@ function stub(on: any, w: World) {
392
419
  );
393
420
  on('prompt.submit', ($: any, e: any) => {
394
421
  w.prompts.push(e.text);
422
+ // A busy lead: the prompt is queued, and its turn doesn't end while the test looks.
423
+ if (w.leadBusy) return new Promise(() => {});
395
424
  return { text: e.text };
396
425
  });
397
- // $.ui.ask is the AskUserQuestion dialog: the person picks a label.
398
- on('tool.call', { tool: 'AskUserQuestion' }, ($: any, e: any) => ({
399
- result: { questions: e.questions, answers: { [e.questions[0].question]: w.answer } },
400
- }));
426
+ // $.ui.ask is the AskUserQuestion dialog: the person picks w.answer where it's one of its labels, types
427
+ // w.typed under Other, or else dismisses it.
428
+ on('tool.call', { tool: 'AskUserQuestion' }, ($: any, e: any) => {
429
+ const q = e.questions[0];
430
+ const labels = (q.options ?? []).map((o: any) => o.label);
431
+ w.asked.push({ question: q.question, options: labels, header: q.header });
432
+ const answer = labels.includes(w.answer) ? w.answer : w.typed;
433
+ if (answer == null) return { deny: 'The person dismissed the question.' };
434
+ return { result: { questions: e.questions, answers: { [q.question]: answer } } };
435
+ });
401
436
  // Another plugin's command, as the mod asks for it (Context Graph's /graph).
402
437
  on('command.run', ($: any, e: any) => {
403
438
  w.commands.push({ command: e.command, args: e.args });
@@ -556,10 +591,10 @@ test('PANE-4 with nothing changed it reads the project every 10 seconds while th
556
591
  const before = w.runs;
557
592
  await clock.advance(8000); // four quiet ticks: git only
558
593
  expect(w.runs - before).toBe(4);
559
- // The fifth reads it all (check, status, next, requests, stops, queue, timeline, the lead's questions
560
- // and the reports waiting) between two looks at git.
594
+ // The fifth reads it all (check, status, next, requests, stops, queue, timeline, the lead's questions,
595
+ // the reports waiting, and which session runs the loop, while this one doesn't) between two looks at git.
561
596
  await clock.advance(2000);
562
- expect(w.runs - before).toBe(15);
597
+ expect(w.runs - before).toBe(16);
563
598
  });
564
599
 
565
600
  test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
@@ -663,7 +698,7 @@ test("ACT-5 the person asks about an approval in its pane; the answer stays ther
663
698
  const confirm = await pane($, APPROVE);
664
699
  await $.ui.input({
665
700
  plugin: 'code-kit',
666
- key: 'approve-ask',
701
+ key: 'ask-about',
667
702
  text: 'Why does the booking form need this?',
668
703
  });
669
704
  expect(w.sideAsks).toHaveLength(1);
@@ -679,7 +714,7 @@ test("ACT-5 the person asks about an approval in its pane; the answer stays ther
679
714
  /dayjs formats the booking dates/,
680
715
  );
681
716
  // A follow-up carries the aside so far; nothing went to the lead, and nothing was approved.
682
- await $.ui.input({ plugin: 'code-kit', key: 'approve-ask', text: 'Is it maintained?' });
717
+ await $.ui.input({ plugin: 'code-kit', key: 'ask-about', text: 'Is it maintained?' });
683
718
  expect(w.sideAsks[1].prompt).toMatch(
684
719
  /Q: Why does the booking form need this\?\nA: dayjs formats the booking dates\./,
685
720
  );
@@ -697,7 +732,7 @@ test('ACT-5 before the lead has replied there is nothing to fork: it answers fro
697
732
  const ui = await bandUi($);
698
733
  await press($, 'band-approve');
699
734
  const confirm = await pane($, APPROVE);
700
- await $.ui.input({ plugin: 'code-kit', key: 'approve-ask', text: 'What is it?' });
735
+ await $.ui.input({ plugin: 'code-kit', key: 'ask-about', text: 'What is it?' });
701
736
  expect(w.sideAsks.map((a) => a.model ?? 'fork')).toEqual(['fork', 'sonnet']);
702
737
  expect(JSON.stringify(await confirm.find({ key: 'aside-a-0' }))).toMatch(/From the facts alone/);
703
738
  await confirm.unmount();
@@ -1319,7 +1354,7 @@ test('HOLD-1 and HOLD-2 a lane installing a new package is held, and the band as
1319
1354
  const w = project();
1320
1355
  w.refusal = webInstall;
1321
1356
  const clock = await start($, on, w);
1322
- await heldCall($, w, clock);
1357
+ const { call } = await heldCall($, w, clock);
1323
1358
  let ui = await bandUi($);
1324
1359
  expect(
1325
1360
  await ui.find({ type: 'Text', text: /^web wants to install dayjs · 2:00$/ }),
@@ -1332,6 +1367,8 @@ test('HOLD-1 and HOLD-2 a lane installing a new package is held, and the band as
1332
1367
  expect(await ui.find({ type: 'Text', text: /· 1:53$/ })).toBeDefined();
1333
1368
  await ui.unmount();
1334
1369
  w.waiting.get('tu-h1')?.('timed out');
1370
+ // A timed-out hold refuses the call, as the hooks did; awaited, so the test doesn't end mid-call.
1371
+ expect(refused(await call)).toBe(true);
1335
1372
  });
1336
1373
 
1337
1374
  test('DECIDE-2 a decision in the band says which story it is for, and d opens it as a card with its lineage', async ($, on) => {
@@ -1353,7 +1390,7 @@ test('DECIDE-2 a decision in the band says which story it is for, and d opens it
1353
1390
  w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
1354
1391
  const clock = await start($, on, w);
1355
1392
  await lanes($);
1356
- await heldCall($, w, clock);
1393
+ const { call } = await heldCall($, w, clock);
1357
1394
  const band = await bandUi($);
1358
1395
  expect(await band.find({ type: 'Text', text: 'for ST-4 Booking form (BOOK-1)' })).toBeDefined();
1359
1396
  expect((await band.find({ key: 'band-decide' }))?.props.hotkey).toBe('d');
@@ -1373,6 +1410,8 @@ test('DECIDE-2 a decision in the band says which story it is for, and d opens it
1373
1410
  );
1374
1411
  await ui.unmount();
1375
1412
  w.waiting.get('tu-h1')?.('timed out');
1413
+ // A timed-out hold refuses the call, as the hooks did; awaited, so the test doesn't end mid-call.
1414
+ expect(refused(await call)).toBe(true);
1376
1415
  });
1377
1416
 
1378
1417
  test('DECIDE-3 an option on the card answers the decision, as the band would', async ($, on) => {
@@ -1538,6 +1577,92 @@ test("ASK-4 the brief's open questions are the person's decisions: answered to t
1538
1577
  await after.unmount();
1539
1578
  });
1540
1579
 
1580
+ test("ASK-5 a brief's question shows as a question, with who decides and what it holds up, and can be asked about before answering", async ($, on) => {
1581
+ const w = project();
1582
+ w.next = {
1583
+ ...w.next,
1584
+ brief: [
1585
+ {
1586
+ question: 'Product domain (for the inbound email address and the privacy policy URL).',
1587
+ owner: 'Product owner',
1588
+ blocks: 'CAP-1 (M2)',
1589
+ path: 'docs/brief.md',
1590
+ },
1591
+ ],
1592
+ };
1593
+ w.requests = [];
1594
+ w.inForce = [{ name: 'dep-rrule', lane: 'domain', reason: 'rrule', minutesLeft: 50 }];
1595
+ w.journal = {
1596
+ events: [
1597
+ {
1598
+ at: 1,
1599
+ kind: 'request',
1600
+ text: 'You asked for “My Aila”: a household’s default parent carries the mental load. Things get missed.',
1601
+ },
1602
+ ],
1603
+ };
1604
+ await start($, on, w);
1605
+ const band = await bandUi($);
1606
+ await press($, 'band-openDecision');
1607
+ await band.unmount();
1608
+ const ui = await pane($, PANE);
1609
+ expect(await ui.find({ type: 'Text', text: 'An open question in your brief' })).toBeDefined();
1610
+ expect(
1611
+ await ui.find({
1612
+ type: 'Text',
1613
+ text: 'Product domain (for the inbound email address and the privacy policy URL).',
1614
+ }),
1615
+ ).toBeDefined();
1616
+ expect(await ui.find({ type: 'Text', text: 'Product owner' })).toBeDefined();
1617
+ expect(await ui.find({ type: 'Text', text: 'CAP-1 (M2)' })).toBeDefined();
1618
+ // What they asked for in a line, and no approvals that have nothing to do with it.
1619
+ expect(
1620
+ await ui.find({
1621
+ type: 'Text',
1622
+ text: '“My Aila”: a household’s default parent carries the mental load.',
1623
+ }),
1624
+ ).toBeDefined();
1625
+ expect(await ui.find({ type: 'Text', text: /dep-rrule/ })).toBeUndefined();
1626
+ await press($, 'decide-answerBrief', PANE);
1627
+ const box = await pane($, 'code-kit-answer');
1628
+ await $.ui.input({ plugin: 'code-kit', key: 'ask-about', text: 'What is this for?' });
1629
+ expect(w.sideAsks[0].prompt).toMatch(/An open question in docs\/brief\.md:\n- Product domain/);
1630
+ expect(w.sideAsks[0].prompt).toMatch(/what it holds up: CAP-1 \(M2\)/);
1631
+ expect(JSON.stringify(await box.find({ key: 'aside-a-0' }))).toMatch(
1632
+ /dayjs formats the booking dates/,
1633
+ );
1634
+ expect(w.prompts).toEqual([]);
1635
+ await box.unmount();
1636
+ await ui.unmount();
1637
+ });
1638
+
1639
+ test('ASK-2 and ASK-4 an answer closes its decision at once, though the lead hears it only when its turn ends', async ($, on) => {
1640
+ const w = project();
1641
+ w.leadBusy = true;
1642
+ w.questions = [question({ options: [] })];
1643
+ w.next = { ...w.next, brief: [{ question: 'The product domain', path: 'docs/brief.md' }] };
1644
+ await start($, on, w);
1645
+ const band = await bandUi($);
1646
+ await press($, 'band-openDecision');
1647
+ await band.unmount();
1648
+ const ui = await pane($, PANE);
1649
+ await press($, 'decide-answer', PANE);
1650
+ let box = await pane($, 'code-kit-answer');
1651
+ await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: 'The Bureau' });
1652
+ await box.unmount();
1653
+ expect(w.closed).toContain('code-kit-answer');
1654
+ expect(await ui.find({ type: 'Text', text: /Which weather provider/ })).toBeUndefined();
1655
+ await press($, 'decide-answerBrief', PANE);
1656
+ box = await pane($, 'code-kit-answer');
1657
+ await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: 'myaila.com.au' });
1658
+ await box.unmount();
1659
+ expect(w.prompts).toHaveLength(2);
1660
+ expect(
1661
+ await ui.find({ type: 'Text', text: 'Nothing needs you. The lead is handling the build.' }),
1662
+ ).toBeDefined();
1663
+ await ui.unmount();
1664
+ });
1665
+
1541
1666
  test("PERM-1 a call Claude Code's own permission check refused waits for the person, who runs it or says not now", async ($, on) => {
1542
1667
  const w = project();
1543
1668
  await start($, on, w);
@@ -1829,9 +1954,11 @@ test('HOLD-5 with autonomy propose the delegated rules are not tried; the call i
1829
1954
  w.delegateExit = 0;
1830
1955
  (w.check as any).harness = { autonomy: 'propose', hold: { minutes: 2 } };
1831
1956
  const clock = await start($, on, w);
1832
- await heldCall($, w, clock);
1957
+ const { call } = await heldCall($, w, clock);
1833
1958
  expect(w.delegated).toEqual([]);
1834
1959
  w.waiting.get('tu-h1')?.('timed out');
1960
+ // A timed-out hold refuses the call, as the hooks did; awaited, so the test doesn't end mid-call.
1961
+ expect(refused(await call)).toBe(true);
1835
1962
  });
1836
1963
 
1837
1964
  test('HOLD-6 with nobody to ask, or hold off, the call is refused at once', async ($, on) => {
@@ -2067,43 +2194,51 @@ test('LOOP-6 the pause is recorded for other tools, and a new session starts wit
2067
2194
  ]);
2068
2195
  });
2069
2196
 
2070
- 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) => {
2071
- const w = project();
2072
- w.next = { step: 'wait', args: '', then: [] };
2073
- w.agents = [
2074
- { id: 'a1', type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
2075
- ];
2076
- const clock = await start($, on, w);
2077
- // First seen at the next look (2 s), so quiet for 5 minutes a look later.
2078
- await clock.advance(5 * 60000 + 2000);
2079
- expect(w.sent).toEqual([
2080
- {
2081
- to: 'a1',
2082
- text: 'No tool call for 5 minutes: report where you are, or carry on.',
2083
- },
2084
- ]);
2085
- await lanes($);
2086
- let ui = await pane($, PANE);
2087
- expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/nudged/);
2088
- await ui.unmount();
2089
- await clock.advance(5 * 60000);
2090
- expect(w.prompts[0]).toMatch(
2091
- /^web-engineer \(agent a1\) on ST-4 has made no tool call for 10 minutes\. Stop it with TaskStop.*\/code-kit:dispatch ST-4$/,
2092
- );
2093
- ui = await pane($, PANE);
2094
- expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/restarted 1\/2/);
2095
- await ui.unmount();
2096
- // The lane's agent is started again, and stalls again, twice more.
2097
- for (const id of ['a2', 'a3']) {
2098
- w.agents = [{ id, type: 'web-engineer', description: 'ST-4 booking form', status: 'running' }];
2099
- await clock.advance(10 * 60000 + 2000);
2100
- }
2101
- expect(w.prompts).toHaveLength(2);
2102
- const band = await bandUi($);
2103
- expect(await band.find({ type: 'Text', text: 'web stalled 3 times on ST-4' })).toBeDefined();
2104
- expect(await band.find({ key: 'band-resumeStall' })).toBeDefined();
2105
- await band.unmount();
2106
- });
2197
+ // It steps the clock through tens of minutes of the mod's 2-second refresh, each tick a real round of
2198
+ // stubbed CLI runs: about 1.5 to 3 seconds locally, and more on a shared CI runner.
2199
+ test(
2200
+ 'LOOP-3 a quiet lane agent is nudged at 5 minutes, restarted through the lead at 10, and flagged after 2 restarts',
2201
+ { timeoutMs: 30000 },
2202
+ async ($, on) => {
2203
+ const w = project();
2204
+ w.next = { step: 'wait', args: '', then: [] };
2205
+ w.agents = [
2206
+ { id: 'a1', type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
2207
+ ];
2208
+ const clock = await start($, on, w);
2209
+ // First seen at the next look (2 s), so quiet for 5 minutes a look later.
2210
+ await clock.advance(5 * 60000 + 2000);
2211
+ expect(w.sent).toEqual([
2212
+ {
2213
+ to: 'a1',
2214
+ text: 'No tool call for 5 minutes: report where you are, or carry on.',
2215
+ },
2216
+ ]);
2217
+ await lanes($);
2218
+ let ui = await pane($, PANE);
2219
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/nudged/);
2220
+ await ui.unmount();
2221
+ await clock.advance(5 * 60000);
2222
+ expect(w.prompts[0]).toMatch(
2223
+ /^web-engineer \(agent a1\) on ST-4 has made no tool call for 10 minutes\. Stop it with TaskStop.*\/code-kit:dispatch ST-4$/,
2224
+ );
2225
+ ui = await pane($, PANE);
2226
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/restarted 1\/2/);
2227
+ await ui.unmount();
2228
+ // The lane's agent is started again, and stalls again, twice more.
2229
+ for (const id of ['a2', 'a3']) {
2230
+ w.agents = [
2231
+ { id, type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
2232
+ ];
2233
+ await clock.advance(10 * 60000 + 2000);
2234
+ }
2235
+ expect(w.prompts).toHaveLength(2);
2236
+ const band = await bandUi($);
2237
+ expect(await band.find({ type: 'Text', text: 'web stalled 3 times on ST-4' })).toBeDefined();
2238
+ expect(await band.find({ key: 'band-resumeStall' })).toBeDefined();
2239
+ await band.unmount();
2240
+ },
2241
+ );
2107
2242
 
2108
2243
  test('LOOP-3 an agent whose work is done is never nudged or restarted: it handed back, or its branch waits in the merge queue', async ($, on) => {
2109
2244
  const w = project();
@@ -2150,24 +2285,30 @@ test('LOOP-3 until the person has chosen how hands-off the loop is, a restart is
2150
2285
  await band.unmount();
2151
2286
  });
2152
2287
 
2153
- test('LOOP-3 an agent waiting on a held call (or running a long command) is never quiet', async ($, on) => {
2154
- const w = project();
2155
- w.next = { step: 'wait', args: '', then: [] };
2156
- w.refusal = webInstall;
2157
- (w.check as any).harness = { hold: { minutes: 10 } };
2158
- w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
2159
- const clock = await start($, on, w);
2160
- const { call } = await heldCall($, w, clock);
2161
- await clock.advance(6 * 60000);
2162
- expect(w.sent).toEqual([]);
2163
- w.waiting.get('tu-h1')?.('timed out');
2164
- await call;
2165
- // Its quiet time starts again when the call ends: nudged 5 minutes after, not at once.
2166
- await clock.advance(2000);
2167
- expect(w.sent).toEqual([]);
2168
- await clock.advance(5 * 60000);
2169
- expect(w.sent.map((m) => m.to)).toEqual(['a1']);
2170
- });
2288
+ // It steps the clock through tens of minutes of the mod's 2-second refresh, each tick a real round of
2289
+ // stubbed CLI runs: about 1.5 to 3 seconds locally, and more on a shared CI runner.
2290
+ test(
2291
+ 'LOOP-3 an agent waiting on a held call (or running a long command) is never quiet',
2292
+ { timeoutMs: 30000 },
2293
+ async ($, on) => {
2294
+ const w = project();
2295
+ w.next = { step: 'wait', args: '', then: [] };
2296
+ w.refusal = webInstall;
2297
+ (w.check as any).harness = { hold: { minutes: 10 } };
2298
+ w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
2299
+ const clock = await start($, on, w);
2300
+ const { call } = await heldCall($, w, clock);
2301
+ await clock.advance(6 * 60000);
2302
+ expect(w.sent).toEqual([]);
2303
+ w.waiting.get('tu-h1')?.('timed out');
2304
+ await call;
2305
+ // Its quiet time starts again when the call ends: nudged 5 minutes after, not at once.
2306
+ await clock.advance(2000);
2307
+ expect(w.sent).toEqual([]);
2308
+ await clock.advance(5 * 60000);
2309
+ expect(w.sent.map((m) => m.to)).toEqual(['a1']);
2310
+ },
2311
+ );
2171
2312
 
2172
2313
  test('LOOP-7 when every story is done the loop stops, and the band says so until dismissed', async ($, on) => {
2173
2314
  const w = project();
@@ -2186,6 +2327,301 @@ test('LOOP-7 when every story is done the loop stops, and the band says so until
2186
2327
  await ui.unmount();
2187
2328
  });
2188
2329
 
2330
+ /** The lead's turn, ending with what it said; `isAborted` when the person stopped it. */
2331
+ async function leadSays($: any, id: string, answer: string, isAborted = false) {
2332
+ await $.turn.start({ turnId: id, text: '' } as any);
2333
+ await $.turn.complete({
2334
+ turnId: id,
2335
+ answer,
2336
+ durationMs: 1,
2337
+ isAborted,
2338
+ reason: isAborted ? 'aborted' : 'answer',
2339
+ } as any);
2340
+ }
2341
+ /** What the person types and sends to the lead. */
2342
+ const personSays = ($: any, text: string) =>
2343
+ $.prompt.submit({ text, origin: { kind: 'composer' }, wait: false } as any);
2344
+ /** The side call's reading of the lead's reply (LOOP-11). */
2345
+ const outcome = (o: any) => ({ isAnswered: true, text: JSON.stringify(o), usage: null });
2346
+ const unread = { isAnswered: true, text: 'Nothing to classify.', usage: null };
2347
+
2348
+ test('LOOP-10 a second session opened in the project never prompts its lead; Lead from here moves the loop to it', async ($, on) => {
2349
+ const w = project();
2350
+ w.leadSession = 's-other'; // a working session already runs the loop
2351
+ const clock = await start($, on, w);
2352
+ await leadTurn($);
2353
+ await settle(clock);
2354
+ await clock.advance(4000);
2355
+ expect(w.prompts).toEqual([]);
2356
+ const ui = await bandUi($);
2357
+ expect(await ui.find({ type: 'Text', text: 'the loop runs in another session' })).toBeDefined();
2358
+ await press($, 'band-leadHere');
2359
+ await ui.unmount();
2360
+ expect(w.leadSession).toBe('s1');
2361
+ expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
2362
+ });
2363
+
2364
+ test("LOOP-10 a new session leaves a live lead session's pause alone", async ($, on) => {
2365
+ const w = project();
2366
+ w.loopPaused = true;
2367
+ w.leadSession = 's-other';
2368
+ await start($, on, w);
2369
+ expect(w.acts).not.toContainEqual(['loop', 'resume']);
2370
+ expect(w.loopPaused).toBe(true);
2371
+ });
2372
+
2373
+ test('LOOP-10 the lead session renews its lease while it runs, and releases it as it ends', async ($, on) => {
2374
+ const w = project();
2375
+ const clock = await start($, on, w);
2376
+ await leadTurn($);
2377
+ await settle(clock);
2378
+ expect(w.leadSession).toBe('s1');
2379
+ const claims = w.leadClaims;
2380
+ await clock.advance(32000);
2381
+ expect(w.leadClaims).toBeGreaterThan(claims);
2382
+ await $.session.end({ reason: 'prompt_input_exit', sessionId: 's1', resume: {} } as any);
2383
+ expect(w.acts).toContainEqual(['loop', 'release', '--session', 's1']);
2384
+ expect(w.leadSession).toBe(null);
2385
+ });
2386
+
2387
+ test("LOOP-11 a reply that waits on the person holds the loop; the dialog asks the lead's question and the answer goes to the lead", async ($, on) => {
2388
+ const w = project();
2389
+ w.sideReply = unread;
2390
+ const clock = await start($, on, w);
2391
+ await leadTurn($);
2392
+ await settle(clock);
2393
+ expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
2394
+ w.sideReply = outcome({
2395
+ outcome: 'needs-person',
2396
+ question: 'Should ST-9 show prices with GST?',
2397
+ options: [
2398
+ { label: 'Include GST', effect: 'Every price shows GST.' },
2399
+ { label: 'Exclude GST', effect: 'GST is added at checkout.' },
2400
+ ],
2401
+ reason: 'a pricing rule only the person can set',
2402
+ });
2403
+ w.next = { step: 'review', args: 'ST-4', then: [] };
2404
+ w.answer = 'Include GST';
2405
+ await leadSays($, 't-2', 'Before I dispatch ST-9: should its prices include GST?');
2406
+ await settle(clock);
2407
+ // The side call read the reply over the lead's own conversation, with the step it answered.
2408
+ expect(w.sideAsks.at(-1).fork).toBe(true);
2409
+ expect(w.sideAsks.at(-1).prompt).toMatch(/prompted you: "Dispatch ST-9/);
2410
+ expect(w.sideAsks.at(-1).prompt).toMatch(/should its prices include GST\?/);
2411
+ expect(w.asked.at(-1)).toEqual({
2412
+ question: 'Should ST-9 show prices with GST?',
2413
+ options: ['Include GST', 'Exclude GST'],
2414
+ header: 'the lead',
2415
+ });
2416
+ expect(w.prompts.slice(1)).toEqual([
2417
+ 'The person answered what you asked ("Should ST-9 show prices with GST?"): Include GST. Carry on with it.',
2418
+ ]);
2419
+ });
2420
+
2421
+ test('LOOP-11 dismissed, what the lead waits on stays in the band, and the loop prompts again only once the person has spoken', async ($, on) => {
2422
+ const w = project();
2423
+ w.sideReply = unread;
2424
+ const clock = await start($, on, w);
2425
+ await leadTurn($);
2426
+ await settle(clock);
2427
+ w.sideReply = outcome({
2428
+ outcome: 'needs-person',
2429
+ question: 'Which plan names should the pricing page use?',
2430
+ options: [
2431
+ { label: 'Basic and Pro', effect: 'Two plans.' },
2432
+ { label: 'Starter, Team, Business', effect: 'Three plans.' },
2433
+ ],
2434
+ });
2435
+ w.next = { step: 'review', args: 'ST-4', then: [] };
2436
+ await leadSays($, 't-2', 'Which plan names do you want on the pricing page?');
2437
+ await settle(clock);
2438
+ await clock.advance(6000);
2439
+ expect(w.prompts).toHaveLength(1);
2440
+ expect(w.asked).toHaveLength(1); // asked once, and dismissed: not asked again
2441
+ const ui = await bandUi($);
2442
+ expect(
2443
+ await ui.find({
2444
+ type: 'Text',
2445
+ text: 'The lead waits on you: Which plan names should the pricing page use?',
2446
+ }),
2447
+ ).toBeDefined();
2448
+ await ui.unmount();
2449
+ // The person answers in the chat; the lead's reply to them doesn't wait on them.
2450
+ await personSays($, 'Basic and Pro.');
2451
+ w.sideReply = outcome({ outcome: 'done' });
2452
+ await leadSays($, 't-3', 'Updated the plan names.');
2453
+ await settle(clock);
2454
+ expect(w.prompts.at(-1)).toBe('Review ST-4: run /code-kit:review ST-4.');
2455
+ });
2456
+
2457
+ test('LOOP-11 a turn the person stopped holds the loop, with no dialog, until they speak or press Carry on', async ($, on) => {
2458
+ const w = project();
2459
+ w.sideReply = unread;
2460
+ const clock = await start($, on, w);
2461
+ await leadTurn($);
2462
+ await settle(clock);
2463
+ w.next = { step: 'review', args: 'ST-4', then: [] };
2464
+ await leadSays($, 't-2', '', true);
2465
+ await settle(clock);
2466
+ await clock.advance(4000);
2467
+ expect(w.prompts).toHaveLength(1);
2468
+ expect(w.asked).toEqual([]);
2469
+ const ui = await bandUi($);
2470
+ expect(
2471
+ await ui.find({ type: 'Text', text: "You stopped the lead's turn: the loop waits for you" }),
2472
+ ).toBeDefined();
2473
+ await press($, 'band-carryOn');
2474
+ await ui.unmount();
2475
+ expect(w.prompts.at(-1)).toBe('Review ST-4: run /code-kit:review ST-4.');
2476
+ });
2477
+
2478
+ test("LOOP-11 a step the lead didn't take waits with its reason; Ask again prompts it, and a new step goes on", async ($, on) => {
2479
+ const w = project();
2480
+ w.next = { step: 'merge', args: 'lead/st-1', then: [] };
2481
+ w.sideReply = unread;
2482
+ const clock = await start($, on, w);
2483
+ await leadTurn($);
2484
+ await settle(clock);
2485
+ expect(w.prompts).toHaveLength(1);
2486
+ w.sideReply = outcome({
2487
+ outcome: 'blocked',
2488
+ reason: 'main has a failing check that lead/st-1 does not touch',
2489
+ });
2490
+ w.refs += ' c1 refs/heads/core/st-1\n';
2491
+ await leadSays($, 't-2', "I can't merge lead/st-1 while main's checks fail.");
2492
+ await settle(clock);
2493
+ // The person's own turn, and lanes committing, don't make the step worth prompting again.
2494
+ w.sideReply = unread;
2495
+ w.refs += ' c2 refs/heads/core/st-2\n';
2496
+ await leadTurn($, 't-3');
2497
+ await settle(clock);
2498
+ expect(w.prompts).toHaveLength(1);
2499
+ expect(w.asked.at(-1)?.options).toEqual(['Ask again', 'Not now']);
2500
+ expect(w.asked.at(-1)?.question).toMatch(/the lead didn't take it.*Ask the lead again\?$/);
2501
+ const ui = await bandUi($);
2502
+ expect(
2503
+ await ui.find({
2504
+ type: 'Text',
2505
+ text: "Merge lead/st-1: the lead didn't take it: main has a failing check that lead/st-1 does not touch",
2506
+ }),
2507
+ ).toBeDefined();
2508
+ await press($, 'band-askAgain');
2509
+ await ui.unmount();
2510
+ expect(w.prompts).toHaveLength(2);
2511
+ expect(w.prompts[1]).toMatch(/^Merge lead\/st-1/);
2512
+ // A new step isn't held by the old one.
2513
+ w.sideReply = outcome({ outcome: 'blocked', reason: 'still failing' });
2514
+ await leadSays($, 't-4', 'Still failing.');
2515
+ await settle(clock);
2516
+ w.next = { step: 'dispatch', args: 'ST-9', then: [] };
2517
+ w.sideReply = unread;
2518
+ await leadTurn($, 't-5');
2519
+ await settle(clock);
2520
+ expect(w.prompts.at(-1)).toBe('Dispatch ST-9: run /code-kit:dispatch ST-9.');
2521
+ });
2522
+
2523
+ test('LOOP-12 a design story is handed to the lead once; the loop leaves designing it to the person and the lead', async ($, on) => {
2524
+ const w = project();
2525
+ w.next = { step: 'design', args: 'ST-21 ST-22', then: [] };
2526
+ w.sideReply = unread;
2527
+ const clock = await start($, on, w);
2528
+ await leadTurn($);
2529
+ await settle(clock);
2530
+ expect(w.prompts).toEqual([
2531
+ 'Design ST-21, ST-22 before any of their screens are built: run /code-kit:design ST-21 ST-22.',
2532
+ ]);
2533
+ // The person and the lead go back and forth; ST-21's approved design changes the project.
2534
+ for (const turn of ['t-2', 't-3']) {
2535
+ await personSays($, 'Make the header calmer.');
2536
+ w.refs += ` ${turn} refs/heads/design/${turn}\n`;
2537
+ await leadTurn($, turn);
2538
+ await settle(clock);
2539
+ }
2540
+ expect(w.prompts.filter((p: string) => p.startsWith('Design'))).toHaveLength(1);
2541
+ const ui = await bandUi($);
2542
+ expect(
2543
+ await ui.find({
2544
+ type: 'Text',
2545
+ text: "Design ST-21, ST-22: with you and the lead, so the loop doesn't prompt it again",
2546
+ }),
2547
+ ).toBeDefined();
2548
+ await ui.unmount();
2549
+ // A design story not handed over yet is.
2550
+ w.next = { step: 'design', args: 'ST-22 ST-30', then: [] };
2551
+ await leadTurn($, 't-4');
2552
+ await settle(clock);
2553
+ expect(w.prompts.at(-1)).toBe(
2554
+ 'Design ST-22, ST-30 before any of their screens are built: run /code-kit:design ST-22 ST-30.',
2555
+ );
2556
+ });
2557
+
2558
+ test("DECIDE-4 the loop's first offer is asked in Claude Code's own dialog; Keep going records it and takes the step", async ($, on) => {
2559
+ const w = project();
2560
+ (w.check as any).harnessSet = [];
2561
+ w.sideReply = unread;
2562
+ w.answer = 'Keep going';
2563
+ const clock = await start($, on, w);
2564
+ await leadTurn($);
2565
+ await settle(clock);
2566
+ expect(w.asked[0].options).toEqual(['Keep going', 'Ask each time', 'Off']);
2567
+ expect(w.asked[0].question).toMatch(
2568
+ /^code-kit can keep the build moving between your prompts\. Next: Dispatch ST-9\. How much should it do on its own\?$/,
2569
+ );
2570
+ expect(w.acts.some((a) => a.join(' ').startsWith('settings set autonomy autonomous'))).toBe(true);
2571
+ expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
2572
+ });
2573
+
2574
+ test("DECIDE-4 the lead's question is asked in the dialog: an option answers it, and a dismissed one isn't asked again", async ($, on) => {
2575
+ const w = project();
2576
+ w.questions = [question()];
2577
+ w.next = { step: 'wait', args: '', then: [] };
2578
+ const clock = await start($, on, w);
2579
+ await clock.advance(4000);
2580
+ expect(w.asked).toHaveLength(1);
2581
+ expect(w.asked[0]).toEqual({
2582
+ question: 'Which weather provider: the Bureau of Meteorology or a commercial service?',
2583
+ options: ['Bureau of Meteorology', 'Commercial'],
2584
+ header: 'question',
2585
+ });
2586
+ await clock.advance(6000);
2587
+ expect(w.asked).toHaveLength(1);
2588
+ const ui = await bandUi($);
2589
+ expect(
2590
+ await ui.find({ type: 'Text', text: /^The lead asks: Which weather provider/ }),
2591
+ ).toBeDefined();
2592
+ await ui.unmount();
2593
+ });
2594
+
2595
+ test("DECIDE-4 an option chosen in the dialog, or the person's own words typed there, answer the lead's question", async ($, on) => {
2596
+ const w = project();
2597
+ w.questions = [question()];
2598
+ w.next = { step: 'wait', args: '', then: [] };
2599
+ w.typed = 'Both, the Bureau first';
2600
+ const clock = await start($, on, w);
2601
+ await clock.advance(4000);
2602
+ expect(w.acts).toContainEqual([
2603
+ 'ask',
2604
+ 'answer',
2605
+ 'Q-1',
2606
+ 'Both, the Bureau first',
2607
+ '--via',
2608
+ 'pane',
2609
+ ]);
2610
+ expect(w.prompts.at(-1)).toMatch(
2611
+ /^The person answered Q-1 .*: Both, the Bureau first\. ST-4 no longer waits/,
2612
+ );
2613
+ });
2614
+
2615
+ test('DECIDE-4 nothing is asked in a session another one runs the loop for', async ($, on) => {
2616
+ const w = project();
2617
+ w.leadSession = 's-other';
2618
+ w.questions = [question()];
2619
+ w.next = { step: 'wait', args: '', then: [] };
2620
+ const clock = await start($, on, w);
2621
+ await clock.advance(6000);
2622
+ expect(w.asked).toEqual([]);
2623
+ });
2624
+
2189
2625
  // --- the reviewer -----------------------------------------------------------------------------------
2190
2626
 
2191
2627
  /** ST-4 finished on web/st-4, the reviewer on. */
@@ -2657,7 +3093,7 @@ test('VIEW-5 what needs the person is pinned at the top with its letter: a held
2657
3093
  const w = project();
2658
3094
  w.refusal = webInstall;
2659
3095
  const clock = await start($, on, w);
2660
- await heldCall($, w, clock);
3096
+ const { call } = await heldCall($, w, clock);
2661
3097
  await lanes($);
2662
3098
  const ui = await pane($, PANE);
2663
3099
  expect((await ui.find({ key: 'need-held' }))?.text).toMatch(
@@ -2668,6 +3104,8 @@ test('VIEW-5 what needs the person is pinned at the top with its letter: a held
2668
3104
  expect(w.opened).toContain(APPROVE);
2669
3105
  await ui.unmount();
2670
3106
  w.waiting.get('tu-h1')?.('timed out');
3107
+ // A timed-out hold refuses the call, as the hooks did; awaited, so the test doesn't end mid-call.
3108
+ expect(refused(await call)).toBe(true);
2671
3109
  });
2672
3110
 
2673
3111
  test('VIEW-6 tabs on 1 to 7, j and k move between lanes, and the selected lane has its keys', async ($, on) => {