@warren-dean/code-kit 0.11.0 → 0.12.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.
@@ -8,6 +8,7 @@
8
8
  // the lead to review, and merging (`merge --person`). No agent reaches those: they are the mod's own
9
9
  // buttons, and the hooks refuse both commands from every agent.
10
10
  import {
11
+ ANSWER_ID,
11
12
  APPROVE_ID,
12
13
  NOT_CODE_KIT,
13
14
  PANE_ID,
@@ -17,7 +18,9 @@ import {
17
18
  agentsAtWork,
18
19
  shownState,
19
20
  attribution,
21
+ answerPane,
20
22
  approvePane,
23
+ commandItself,
21
24
  band,
22
25
  bandLines,
23
26
  finishedStories,
@@ -45,7 +48,16 @@ import {
45
48
  sendBackPane,
46
49
  toolLine,
47
50
  } from './panes.mjs';
48
- import { leadPrompt, nudgeText, restartPrompt, stallDue, stepKey, stepLabel } from './loop.mjs';
51
+ import {
52
+ leadPrompt,
53
+ nudgeText,
54
+ restartPrompt,
55
+ stallDue,
56
+ stepKey,
57
+ stepLabel,
58
+ storiesInFlight,
59
+ withoutInFlight,
60
+ } from './loop.mjs';
49
61
  import { goalOf, nextText } from './mission.mjs';
50
62
  import { contextOf, decisionCards } from './decisions.mjs';
51
63
  import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
@@ -80,6 +92,9 @@ let ledger = {}; // the session's tokens by agent and story (USE-1)
80
92
  let rateLimits = []; // the session's limits, as session.measure last gave them
81
93
  const agentsSeen = new Map(); // agent id → its $.agent.list() entry, kept once it leaves the list
82
94
  const held = new Map(); // tool_use_id → a call held for the person (HOLD-2)
95
+ const denied = new Map(); // tool_use_id → a call Claude Code's own permission check refused (PERM-1)
96
+ const briefLater = new Set(); // the brief's questions the person put off this session (ASK-4)
97
+ let answering = null; // the open Answer… box: { kind: 'question' | 'brief', question, id?, path?, text, error }
83
98
  let where = null; // { cli, cwd, interactive }, from session start
84
99
  // The lead loop (spec 05): what it did, and what it waits on.
85
100
  const loop = {
@@ -198,6 +213,8 @@ export function register(on) {
198
213
  const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
199
214
  return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
200
215
  };
216
+ // What a CLI run said, in a line: why it refused, for a notice.
217
+ const firstLine = (ran) => (ran.stderr || ran.stdout).trim().split('\n')[0];
201
218
  // A session starts with the loop running: a pause recorded by an earlier one no longer holds.
202
219
  if ((await json('loop').catch(() => null))?.paused)
203
220
  await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
@@ -237,6 +254,13 @@ export function register(on) {
237
254
  : [];
238
255
  // TEAM-7: the team's claims, intents and pull requests, where the project is a team's.
239
256
  const team = valid && check.team ? await json('team') : null;
257
+ // ASK-2: the lead's questions waiting for the person; REPORT-3: bug reports waiting to be sent.
258
+ const questions = valid
259
+ ? ((await json('ask')) ?? []).filter((q) => q.state === 'open')
260
+ : [];
261
+ const reports = valid
262
+ ? ((await json('report')) ?? []).filter((r) => r.state === 'draft')
263
+ : [];
240
264
  // TEAM-12: the plan mirrored onto the team's Project board, at most every 10 minutes, in the
241
265
  // background: a slow GitHub never holds up the band.
242
266
  const at = await $.clock.now();
@@ -256,6 +280,10 @@ export function register(on) {
256
280
  adapters,
257
281
  check,
258
282
  team,
283
+ questions,
284
+ reports,
285
+ // ASK-4: the brief's open questions, less those put off this session.
286
+ brief: (nextStep?.brief ?? []).filter((b) => !briefLater.has(b.question)),
259
287
  base: status?.base ?? null,
260
288
  };
261
289
  if (model.state.kind === 'ok') await act.registerReviewer();
@@ -464,6 +492,8 @@ export function register(on) {
464
492
  $.ui.invalidate('ui.render');
465
493
  return {};
466
494
  },
495
+ // ASK-2, ASK-4, REPORT-3: a question's Answer… or a report's Read it opens its card.
496
+ openDecision: (line) => act.decide(line),
467
497
  // ACT-1: the confirmation, prefilled from the request.
468
498
  approve: async (line) => {
469
499
  approving = {
@@ -512,6 +542,124 @@ export function register(on) {
512
542
  await $.ui.close({ id: APPROVE_ID });
513
543
  await act.reload();
514
544
  },
545
+ // CARD-7: a request nobody needs any more stops waiting; nothing is approved.
546
+ dropRequest: async (line) => {
547
+ const open = model.requests?.open ?? [];
548
+ const same = (r) =>
549
+ [...r.names].sort().join() === [...line.request.names].sort().join() &&
550
+ (r.lane ?? r.actor) === (line.request.lane ?? line.request.actor);
551
+ const numbers = open.flatMap((r, i) => (same(r) ? [i + 1] : []));
552
+ if (!numbers.length) return;
553
+ const ran = await $.process.run(
554
+ ['node', cli, 'requests', 'drop', numbers.join(','), '--via', 'pane'],
555
+ { cwd },
556
+ );
557
+ notice = ran.exitCode === 0 ? null : `Nothing was dropped: ${firstLine(ran)}`;
558
+ await act.reload();
559
+ },
560
+ // ASK-2: the lead's option, pressed: the answer is recorded and sent to the lead.
561
+ answerWith: (line) => act.sendAnswer({ kind: 'question', ...line.question }, line.choice),
562
+ // ASK-2, ASK-4: Answer… opens a box for the person's own words.
563
+ answer: async (line) => {
564
+ answering = { kind: 'question', ...line.question, text: '', error: null };
565
+ await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
566
+ $.ui.invalidate('ui.render');
567
+ },
568
+ answerBrief: async (line) => {
569
+ answering = { kind: 'brief', ...line.brief, text: '', error: null };
570
+ await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
571
+ $.ui.invalidate('ui.render');
572
+ },
573
+ laterBrief: (line) => {
574
+ briefLater.add(line.brief.question);
575
+ model = {
576
+ ...model,
577
+ brief: (model.brief ?? []).filter((b) => b.question !== line.brief.question),
578
+ };
579
+ $.ui.invalidate('ui.render');
580
+ },
581
+ confirmAnswer: async (text) => {
582
+ if (!answering) return;
583
+ if (!text.trim()) {
584
+ answering = { ...answering, error: 'Type your answer: the lead builds on it.' };
585
+ $.ui.invalidate('ui.render');
586
+ return;
587
+ }
588
+ const done = await act.sendAnswer(answering, text.trim());
589
+ if (done) {
590
+ answering = null;
591
+ await $.ui.close({ id: ANSWER_ID });
592
+ }
593
+ },
594
+ cancelAnswer: async () => {
595
+ answering = null;
596
+ await $.ui.close({ id: ANSWER_ID });
597
+ },
598
+ // The answer recorded (a question's, in code-kit) and told to the lead, which carries on with it.
599
+ sendAnswer: async (q, text) => {
600
+ if (q.kind === 'question') {
601
+ const ran = await $.process.run(
602
+ ['node', cli, 'ask', 'answer', q.id, text, '--via', 'pane'],
603
+ { cwd },
604
+ );
605
+ if (ran.exitCode !== 0) {
606
+ notice = `Your answer wasn't recorded: ${firstLine(ran)}`;
607
+ if (answering) answering = { ...answering, error: notice };
608
+ $.ui.invalidate('ui.render');
609
+ return false;
610
+ }
611
+ }
612
+ notice = null;
613
+ const said = /[.!?]$/.test(text) ? text : `${text}.`;
614
+ await $.prompt.submit({
615
+ text:
616
+ q.kind === 'question'
617
+ ? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
618
+ : `The person answered an open question in ${q.path}, "${q.question}": ${said} Record it in the brief's decision log, take it out of the open questions, and carry on with it.`,
619
+ });
620
+ if (q.kind === 'brief') {
621
+ briefLater.add(q.question);
622
+ model = { ...model, brief: (model.brief ?? []).filter((b) => b.question !== q.question) };
623
+ }
624
+ await act.reload();
625
+ return true;
626
+ },
627
+ // PERM-1: a call Claude Code refused, which only the person can run or allow.
628
+ runDenied: async (line) => {
629
+ await $.prompt.fill({ text: `! ${line.denial.command}`, mode: 'replace' }).catch(() => {});
630
+ },
631
+ deniedDone: async (line) => {
632
+ denied.delete(line.denial.id);
633
+ $.ui.invalidate('ui.render');
634
+ await $.prompt.submit({
635
+ text: `The person ran it themselves, or allowed it: ${line.denial.command ?? line.denial.what} (Claude Code had refused it for ${line.denial.who}). Check it worked, and carry on.`,
636
+ });
637
+ },
638
+ deniedNo: async (line) => {
639
+ denied.delete(line.denial.id);
640
+ $.ui.invalidate('ui.render');
641
+ await $.prompt.submit({
642
+ text: `The person won't run ${line.denial.command ?? line.denial.what} now (Claude Code refused it for ${line.denial.who}). Don't try to get around it: carry on with other work, and if a story can't go on without it, ask with code-kit ask --blocks.`,
643
+ });
644
+ },
645
+ // REPORT-3: the person sends a drafted report to code-kit's GitHub, or discards it.
646
+ sendReport: async (line) => {
647
+ const ran = await $.process.run(['node', cli, 'report', 'send', line.report.id], {
648
+ cwd,
649
+ timeoutMs: 120000,
650
+ });
651
+ result = {
652
+ ok: ran.exitCode === 0,
653
+ title: ran.exitCode === 0 ? 'Report sent' : 'Report not sent',
654
+ text: `${ran.stdout}${ran.stderr}`.trim(),
655
+ };
656
+ await $.ui.open({ id: RESULT_ID, title: 'Report', focus: true, closeOnEscape: true });
657
+ await act.reload();
658
+ },
659
+ discardReport: async (line) => {
660
+ await $.process.run(['node', cli, 'report', 'discard', line.report.id], { cwd });
661
+ await act.reload();
662
+ },
515
663
  // HOLD-2: Approve opens ACT-1's confirmation for the held call; Refuse lets the refusal through.
516
664
  approveHeld: (line) =>
517
665
  act.approve({
@@ -790,7 +938,14 @@ export function register(on) {
790
938
  return act.submit(prompt, 'restart', story);
791
939
  }
792
940
  const ran = await $.process.run(['node', cli, 'next', '--json'], { cwd });
793
- const step = ran.exitCode === 0 ? parseJson(ran.stdout) : null;
941
+ // LOOP-9: never a story an agent is already at work on, building or reviewing it.
942
+ const step =
943
+ ran.exitCode === 0
944
+ ? withoutInFlight(
945
+ parseJson(ran.stdout),
946
+ storiesInFlight(await $.agent.list().catch(() => [])),
947
+ )
948
+ : null;
794
949
  // MISSION-1: the band shows what the loop takes next.
795
950
  loop.nextStep = step;
796
951
  if (!step) {
@@ -1189,6 +1344,35 @@ export function register(on) {
1189
1344
  // CARD-1: the text a code-kit hook refused a call with, kept for the call's result to draw as a card.
1190
1345
  // HOLD-1 to HOLD-6: a refusal a person's approval would allow is held while the band asks. The hooks
1191
1346
  // stay the judge: the call is run again once the approval is written, and refused as before otherwise.
1347
+ // PERM-1: Claude Code's own permission check refused a call (auto mode, a deny rule): code-kit can't
1348
+ // approve past it, so it waits in the band for the person, who can run it themselves or allow it.
1349
+ on('classic.PermissionDenied', async ($, e, next) => {
1350
+ const res = await next(e);
1351
+ const input = e.tool_input && typeof e.tool_input === 'object' ? e.tool_input : {};
1352
+ const command =
1353
+ e.tool_name === 'Bash' && typeof input.command === 'string' ? input.command : null;
1354
+ const path = input.file_path ?? input.notebook_path ?? null;
1355
+ const lane = e.agent_id ? activity.get(e.agent_id)?.lane : null;
1356
+ denied.set(e.tool_use_id ?? `${e.tool_name}-${denied.size}`, {
1357
+ id: e.tool_use_id ?? `${e.tool_name}-${denied.size}`,
1358
+ who: lane ? `the ${lane} lane` : (e.agent_type ?? (e.agent_id ? 'an agent' : 'the lead')),
1359
+ tool: e.tool_name,
1360
+ command,
1361
+ what: command
1362
+ ? `\`${commandItself(command)}\``
1363
+ : path
1364
+ ? `${e.tool_name} on ${path}`
1365
+ : e.tool_name,
1366
+ reason:
1367
+ typeof e.reason === 'string'
1368
+ ? e.reason.trim().replace(/\s+/g, ' ').slice(0, 200) || null
1369
+ : null,
1370
+ at: await $.clock.now(),
1371
+ });
1372
+ $.ui.invalidate('ui.render');
1373
+ return res;
1374
+ });
1375
+
1192
1376
  on('tool.call', async ($, e, next) => {
1193
1377
  // LOOP-3: a lane agent's tool call is activity; while it runs, the agent isn't quiet.
1194
1378
  const calledAt = await $.clock.now();
@@ -1567,7 +1751,7 @@ export function register(on) {
1567
1751
  const lines = linesNowRaw(now);
1568
1752
  const cards = cardsNow(now, lines);
1569
1753
  return lines.map((l) => {
1570
- const card = cards.find((c) => c.line === l);
1754
+ const card = cards.find((c) => c.source === l);
1571
1755
  return card ? { ...l, context: contextOf(card) } : l;
1572
1756
  });
1573
1757
  };
@@ -1585,6 +1769,7 @@ export function register(on) {
1585
1769
  nextStep: loop.nextStep,
1586
1770
  combined: combinedNow(),
1587
1771
  team: model.team ?? null,
1772
+ denied: [...denied.values()],
1588
1773
  });
1589
1774
 
1590
1775
  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
@@ -1752,6 +1937,19 @@ export function register(on) {
1752
1937
  });
1753
1938
  });
1754
1939
 
1940
+ // ASK-2, ASK-4: the person's answer, in their own words.
1941
+ on('ui.render', { component: 'Pane', requestId: ANSWER_ID }, async ($, e) => {
1942
+ if (!answering) return resultPane(null, $.ui.resolve(e));
1943
+ return answerPane(answering, $.ui.resolve(e), {
1944
+ onInput: (value) => {
1945
+ answering = { ...answering, text: value };
1946
+ $.ui.invalidate('ui.render');
1947
+ },
1948
+ onSubmit: (value) => act.confirmAnswer(value),
1949
+ onCancel: () => act.cancelAnswer(),
1950
+ });
1951
+ });
1952
+
1755
1953
  on('ui.render', { component: 'Pane', requestId: SETTINGS_ID }, async ($, e) =>
1756
1954
  settingsView(settingRows, pendingSetting, $.ui.resolve(e), {
1757
1955
  onChoose: (key, value) => act.chooseSetting(key, value),
@@ -157,6 +157,9 @@ function project() {
157
157
  journal: { events: [] as any[] }, // `code-kit journal --json`
158
158
  team: null as any, // `code-kit team --json`, for a team's project
159
159
  teamSince: { people: {} } as any, // `code-kit team since --json`
160
+ questions: [] as any[], // `code-kit ask --json`
161
+ reports: [] as any[], // `code-kit report --json`
162
+ fills: [] as string[], // what the mod put in the prompt
160
163
  holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
161
164
  waiting: new Map<string, (answer: string) => void>(),
162
165
  };
@@ -186,7 +189,30 @@ function stub(on: any, w: World) {
186
189
  }),
187
190
  );
188
191
  if (sub === 'next') return ran(0, JSON.stringify(w.next));
192
+ if (sub === 'requests' && argv[3] === 'drop') {
193
+ w.acts.push([...argv.slice(2)]);
194
+ const drop = new Set(argv[4].split(',').map(Number));
195
+ w.requests = w.requests.filter((_, i) => !drop.has(i + 1));
196
+ return ran(0, 'Dropped.');
197
+ }
189
198
  if (sub === 'requests') return ran(0, JSON.stringify({ open: w.requests, inForce: w.inForce }));
199
+ if (sub === 'ask' && argv[3] === 'answer') {
200
+ w.acts.push([...argv.slice(2)]);
201
+ w.questions = w.questions.map((q) => (q.id === argv[4] ? { ...q, state: 'answered' } : q));
202
+ return ran(0, 'Recorded.');
203
+ }
204
+ if (sub === 'ask') return ran(0, JSON.stringify(w.questions));
205
+ if (sub === 'report' && ['send', 'discard'].includes(argv[3])) {
206
+ w.acts.push([...argv.slice(2)]);
207
+ w.reports = w.reports.map((r) =>
208
+ r.id === argv[4] ? { ...r, state: argv[3] === 'send' ? 'sent' : 'discarded' } : r,
209
+ );
210
+ return ran(
211
+ 0,
212
+ argv[3] === 'send' ? 'Filed https://github.com/o/code-kit/issues/9' : 'Discarded.',
213
+ );
214
+ }
215
+ if (sub === 'report') return ran(0, JSON.stringify(w.reports));
190
216
  if (sub === 'verify')
191
217
  return ran(w.verifyExit, w.verifyExit ? '' : w.verifyOut, w.verifyExit ? w.verifyOut : '');
192
218
  if (sub === 'stops') return ran(0, JSON.stringify(w.stops));
@@ -321,6 +347,13 @@ function stub(on: any, w: World) {
321
347
  return { value: { agent: `code-kit:${e.name}` } };
322
348
  });
323
349
  on('prompt.read', () => ({ value: { text: w.draft, cursor: w.draft.length } }));
350
+ // Claude Code's own settings hooks beneath: none answer a permission denial.
351
+ on('classic.PermissionDenied', () => ({}));
352
+ on('prompt.fill', ($: any, e: any) => {
353
+ w.fills.push(e.text);
354
+ w.draft = e.text;
355
+ return { isFilled: true };
356
+ });
324
357
  on('session.send', ($: any, e: any) => {
325
358
  w.sent.push({ to: e.to, text: e.text });
326
359
  return { isDelivered: true };
@@ -513,10 +546,10 @@ test('PANE-4 with nothing changed it reads the project every 10 seconds while th
513
546
  const before = w.runs;
514
547
  await clock.advance(8000); // four quiet ticks: git only
515
548
  expect(w.runs - before).toBe(4);
516
- // The fifth reads it all (check, status, next, requests, stops, queue, timeline) between two looks
517
- // at git.
549
+ // The fifth reads it all (check, status, next, requests, stops, queue, timeline, the lead's questions
550
+ // and the reports waiting) between two looks at git.
518
551
  await clock.advance(2000);
519
- expect(w.runs - before).toBe(13);
552
+ expect(w.runs - before).toBe(15);
520
553
  });
521
554
 
522
555
  test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
@@ -1311,6 +1344,254 @@ test('DECIDE-3 notes are labelled note, not you, and need no decision', async ($
1311
1344
  await band.unmount();
1312
1345
  });
1313
1346
 
1347
+ const question = (extra: any = {}) => ({
1348
+ id: 'Q-1',
1349
+ at: '2026-10-12T05:59:00.000Z',
1350
+ by: 'lead',
1351
+ question: 'Which weather provider: the Bureau of Meteorology or a commercial service?',
1352
+ why: null,
1353
+ story: 'ST-4',
1354
+ blocks: true,
1355
+ options: [
1356
+ { label: 'Bureau of Meteorology', effect: 'Free, Australian data; we credit the Bureau.' },
1357
+ { label: 'Commercial', effect: 'A paid key; global coverage.' },
1358
+ ],
1359
+ state: 'open',
1360
+ answer: null,
1361
+ withdrawn: null,
1362
+ ...extra,
1363
+ });
1364
+
1365
+ test("ASK-2 the lead's question waits in the band and as a card; an option answers it and tells the lead", async ($, on) => {
1366
+ const w = project();
1367
+ w.questions = [
1368
+ question(),
1369
+ question({
1370
+ id: 'Q-2',
1371
+ question: 'May sessions show your city?',
1372
+ story: null,
1373
+ blocks: false,
1374
+ options: [],
1375
+ }),
1376
+ ];
1377
+ await start($, on, w);
1378
+ const band = await bandUi($);
1379
+ expect(
1380
+ await band.find({ type: 'Text', text: /^2 questions from the lead: Which weather provider/ }),
1381
+ ).toBeDefined();
1382
+ await press($, 'band-openDecision');
1383
+ await band.unmount();
1384
+ const ui = await pane($, PANE);
1385
+ // One card per question, not one for both.
1386
+ expect(await ui.find({ key: 'decision-pick-0' })).toBeDefined();
1387
+ expect(await ui.find({ key: 'decision-pick-1' })).toBeDefined();
1388
+ expect(await ui.find({ type: 'Text', text: 'ST-4 waits on your answer.' })).toBeDefined();
1389
+ expect(
1390
+ await ui.find({ type: 'Text', text: 'Free, Australian data; we credit the Bureau.' }),
1391
+ ).toBeDefined();
1392
+ await press($, 'decide-answerWith-0', PANE);
1393
+ expect(w.acts).toContainEqual(['ask', 'answer', 'Q-1', 'Bureau of Meteorology', '--via', 'pane']);
1394
+ expect(w.prompts.at(-1)).toBe(
1395
+ 'The person answered Q-1 (Which weather provider: the Bureau of Meteorology or a commercial service?): Bureau of Meteorology. ST-4 no longer waits on them. Carry on with it.',
1396
+ );
1397
+ await ui.unmount();
1398
+ });
1399
+
1400
+ test("ASK-2 a question with no options, or Something else…, is answered in the person's own words", async ($, on) => {
1401
+ const w = project();
1402
+ w.questions = [
1403
+ question({
1404
+ id: 'Q-2',
1405
+ question: 'May sessions show your city?',
1406
+ story: null,
1407
+ blocks: false,
1408
+ options: [],
1409
+ }),
1410
+ ];
1411
+ await start($, on, w);
1412
+ const band = await bandUi($);
1413
+ await press($, 'band-openDecision');
1414
+ await band.unmount();
1415
+ const ui = await pane($, PANE);
1416
+ await press($, 'decide-answer', PANE);
1417
+ expect(w.opened).toContain('code-kit-answer');
1418
+ const box = await pane($, 'code-kit-answer');
1419
+ await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: '' });
1420
+ expect(
1421
+ await box.find({ type: 'Text', text: 'Type your answer: the lead builds on it.' }),
1422
+ ).toBeDefined();
1423
+ await $.ui.input({
1424
+ plugin: 'code-kit',
1425
+ key: 'answer-text',
1426
+ text: 'Only the suburb, never the street',
1427
+ });
1428
+ expect(w.acts).toContainEqual([
1429
+ 'ask',
1430
+ 'answer',
1431
+ 'Q-2',
1432
+ 'Only the suburb, never the street',
1433
+ '--via',
1434
+ 'pane',
1435
+ ]);
1436
+ expect(w.prompts.at(-1)).toBe(
1437
+ 'The person answered Q-2 (May sessions show your city?): Only the suburb, never the street. Carry on with it.',
1438
+ );
1439
+ expect(w.closed).toContain('code-kit-answer');
1440
+ await box.unmount();
1441
+ await ui.unmount();
1442
+ });
1443
+
1444
+ test("ASK-4 the brief's open questions are the person's decisions: answered to the lead, or put off for the session", async ($, on) => {
1445
+ const w = project();
1446
+ w.next = {
1447
+ ...w.next,
1448
+ brief: [
1449
+ { question: 'Accounts: AWS, Supabase, Fly', path: 'docs/brief.md' },
1450
+ { question: 'The product domain', path: 'docs/brief.md' },
1451
+ ],
1452
+ };
1453
+ await start($, on, w);
1454
+ const band = await bandUi($);
1455
+ expect(
1456
+ await band.find({
1457
+ type: 'Text',
1458
+ text: '2 open questions in your brief: Accounts: AWS, Supabase, Fly',
1459
+ }),
1460
+ ).toBeDefined();
1461
+ await press($, 'band-openDecision');
1462
+ await band.unmount();
1463
+ const ui = await pane($, PANE);
1464
+ expect(
1465
+ await ui.find({ type: 'Text', text: 'From your brief: Accounts: AWS, Supabase, Fly' }),
1466
+ ).toBeDefined();
1467
+ await press($, 'decide-laterBrief', PANE);
1468
+ expect(
1469
+ await ui.find({ type: 'Text', text: 'From your brief: The product domain' }),
1470
+ ).toBeDefined();
1471
+ await press($, 'decide-answerBrief', PANE);
1472
+ const box = await pane($, 'code-kit-answer');
1473
+ await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: 'myaila.com.au' });
1474
+ await box.unmount();
1475
+ expect(w.prompts.at(-1)).toBe(
1476
+ 'The person answered an open question in docs/brief.md, "The product domain": myaila.com.au. Record it in the brief\'s decision log, take it out of the open questions, and carry on with it.',
1477
+ );
1478
+ await ui.unmount();
1479
+ const after = await bandUi($);
1480
+ expect(await after.find({ type: 'Text', text: /in your brief/ })).toBeUndefined();
1481
+ await after.unmount();
1482
+ });
1483
+
1484
+ 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) => {
1485
+ const w = project();
1486
+ await start($, on, w);
1487
+ await $.classic.PermissionDenied({
1488
+ tool_name: 'Bash',
1489
+ tool_input: { command: 'pnpm db:generate && pnpm db:migrate' },
1490
+ tool_use_id: 'tu-1',
1491
+ reason: 'Permission for this action was denied by the Claude Code auto mode classifier.',
1492
+ });
1493
+ const band = await bandUi($);
1494
+ expect(
1495
+ await band.find({ type: 'Text', text: "Claude Code refused the lead's `pnpm db:generate`" }),
1496
+ ).toBeDefined();
1497
+ await press($, 'band-runDenied');
1498
+ expect(w.fills).toEqual(['! pnpm db:generate && pnpm db:migrate']);
1499
+ await press($, 'band-decide');
1500
+ await band.unmount();
1501
+ const ui = await pane($, PANE);
1502
+ expect(
1503
+ await ui.find({
1504
+ type: 'Text',
1505
+ text: /^Claude Code's own permission check refused it \(Permission for this action/,
1506
+ }),
1507
+ ).toBeDefined();
1508
+ await press($, 'decide-deniedDone', PANE);
1509
+ expect(w.prompts.at(-1)).toMatch(
1510
+ /^The person ran it themselves, or allowed it: pnpm db:generate && pnpm db:migrate/,
1511
+ );
1512
+ await ui.unmount();
1513
+ const after = await bandUi($);
1514
+ expect(await after.find({ type: 'Text', text: /Claude Code refused/ })).toBeUndefined();
1515
+ await after.unmount();
1516
+ });
1517
+
1518
+ test('CARD-8 two approvals waiting are two decisions, each approved or dropped on its own', async ($, on) => {
1519
+ const w = project();
1520
+ w.requests = [
1521
+ dayjs,
1522
+ {
1523
+ ...dayjs,
1524
+ at: '2026-10-06T00:05:00.000Z',
1525
+ actor: 'api-engineer',
1526
+ lane: 'api',
1527
+ names: ['dep-zod'],
1528
+ what: 'npm install zod',
1529
+ },
1530
+ ];
1531
+ await start($, on, w);
1532
+ const band = await bandUi($);
1533
+ expect(await band.find({ type: 'Text', text: '2 approvals waiting' })).toBeDefined();
1534
+ await press($, 'band-decide');
1535
+ await band.unmount();
1536
+ const ui = await pane($, PANE);
1537
+ expect(await ui.find({ key: 'decision-pick-0' })).toBeDefined();
1538
+ expect(await ui.find({ key: 'decision-pick-1' })).toBeDefined();
1539
+ await press($, 'decision-pick-1', PANE);
1540
+ await press($, 'decide-dropRequest', PANE);
1541
+ expect(w.acts).toContainEqual(['requests', 'drop', '2', '--via', 'pane']);
1542
+ await ui.unmount();
1543
+ });
1544
+
1545
+ test('REPORT-3 a bug report an agent drafted waits for the person, who reads it and sends it, or discards it', async ($, on) => {
1546
+ const w = project();
1547
+ w.reports = [
1548
+ {
1549
+ id: 'R-1',
1550
+ at: '2026-10-13T00:00:00Z',
1551
+ by: 'lead',
1552
+ title: 'Decisions tab misses a held call',
1553
+ fingerprint: 'ck-1',
1554
+ body: '**What happened:** the card never showed',
1555
+ state: 'draft',
1556
+ sent: null,
1557
+ },
1558
+ ];
1559
+ await start($, on, w);
1560
+ const band = await bandUi($);
1561
+ expect(
1562
+ await band.find({
1563
+ type: 'Text',
1564
+ text: 'A bug report for code-kit to send: Decisions tab misses a held call',
1565
+ }),
1566
+ ).toBeDefined();
1567
+ await press($, 'band-openDecision');
1568
+ await band.unmount();
1569
+ const ui = await pane($, PANE);
1570
+ expect(
1571
+ await ui.find({ type: 'Text', text: '**What happened:** the card never showed' }),
1572
+ ).toBeDefined();
1573
+ await press($, 'decide-sendReport', PANE);
1574
+ expect(w.acts).toContainEqual(['report', 'send', 'R-1']);
1575
+ expect(w.opened).toContain('code-kit-result');
1576
+ await ui.unmount();
1577
+ });
1578
+
1579
+ test('ASK-3 a lane whose story waits on the person reads waiting on you', async ($, on) => {
1580
+ const w = project();
1581
+ w.stories = w.stories.map((s: any) =>
1582
+ s.id === 'ST-4'
1583
+ ? { ...s, state: 'waiting on you', waitingFor: { id: 'Q-1', question: 'x' } }
1584
+ : s,
1585
+ );
1586
+ await start($, on, w);
1587
+ await lanes($);
1588
+ const ui = await pane($, PANE);
1589
+ expect((await ui.findAll({ type: 'Text' })).some((t: any) => /waiting on you/.test(t.text))).toBe(
1590
+ true,
1591
+ );
1592
+ await ui.unmount();
1593
+ });
1594
+
1314
1595
  test('JOURNAL-1 the Journal tab tells the lineage, from the request to now, with the loop and your choices in it', async ($, on) => {
1315
1596
  const w = project();
1316
1597
  w.journal = {
@@ -1527,6 +1808,34 @@ test('LOOP-1 and LOOP-2 with the lead idle, the loop dispatches every ready stor
1527
1808
  await ui.unmount();
1528
1809
  });
1529
1810
 
1811
+ test('LOOP-9 the loop never asks for a story an agent is still on: it waits, and goes on once the agent finishes', async ($, on) => {
1812
+ const w = project();
1813
+ w.next = { step: 'dispatch', args: 'ST-9', then: [] };
1814
+ const clock = await start($, on, w);
1815
+ await leadTurn($);
1816
+ await settle(clock);
1817
+ expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
1818
+ // Dispatched, and ST-4 still being built: next (reading only branches) offers both again.
1819
+ w.agents = [
1820
+ { id: 'a1', type: 'web-engineer', status: 'running', description: 'ST-4 booking form' },
1821
+ { id: 'a2', type: 'core-engineer', status: 'running', description: 'ST-9 pricing' },
1822
+ ];
1823
+ w.next = { step: 'review', args: 'ST-4', then: [{ step: 'dispatch', args: 'ST-9' }] };
1824
+ await leadTurn($, 't-2');
1825
+ await settle(clock);
1826
+ expect(w.prompts).toHaveLength(1);
1827
+ const band = await bandUi($);
1828
+ expect(
1829
+ await band.find({ type: 'Text', text: /waiting: ST-4, ST-9 are still with their agents/ }),
1830
+ ).toBeDefined();
1831
+ await band.unmount();
1832
+ // The web agent finishes: ST-4 is reviewed; ST-9 is still being built, so it isn't dispatched again.
1833
+ w.agents = [{ ...w.agents[0], status: 'completed' }, w.agents[1]];
1834
+ await clock.advance(2000);
1835
+ await settle(clock);
1836
+ expect(w.prompts.slice(1)).toEqual(['Review ST-4: run /code-kit:review ST-4.']);
1837
+ });
1838
+
1530
1839
  test('LOOP-1 nothing is submitted mid-turn, or while the person has a draft, or twice for the same step', async ($, on) => {
1531
1840
  const w = project();
1532
1841
  const clock = await start($, on, w);