@warren-dean/code-kit 0.11.1 → 0.12.1

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.
@@ -157,6 +157,11 @@ 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
163
+ sideAsks: [] as any[], // side calls: { fork } or { model }, with their prompt
164
+ sideReply: { isAnswered: true, text: 'dayjs formats the booking dates.', usage: null } as any,
160
165
  holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
161
166
  waiting: new Map<string, (answer: string) => void>(),
162
167
  };
@@ -186,7 +191,30 @@ function stub(on: any, w: World) {
186
191
  }),
187
192
  );
188
193
  if (sub === 'next') return ran(0, JSON.stringify(w.next));
194
+ if (sub === 'requests' && argv[3] === 'drop') {
195
+ w.acts.push([...argv.slice(2)]);
196
+ const drop = new Set(argv[4].split(',').map(Number));
197
+ w.requests = w.requests.filter((_, i) => !drop.has(i + 1));
198
+ return ran(0, 'Dropped.');
199
+ }
189
200
  if (sub === 'requests') return ran(0, JSON.stringify({ open: w.requests, inForce: w.inForce }));
201
+ if (sub === 'ask' && argv[3] === 'answer') {
202
+ w.acts.push([...argv.slice(2)]);
203
+ w.questions = w.questions.map((q) => (q.id === argv[4] ? { ...q, state: 'answered' } : q));
204
+ return ran(0, 'Recorded.');
205
+ }
206
+ if (sub === 'ask') return ran(0, JSON.stringify(w.questions));
207
+ if (sub === 'report' && ['send', 'discard'].includes(argv[3])) {
208
+ w.acts.push([...argv.slice(2)]);
209
+ w.reports = w.reports.map((r) =>
210
+ r.id === argv[4] ? { ...r, state: argv[3] === 'send' ? 'sent' : 'discarded' } : r,
211
+ );
212
+ return ran(
213
+ 0,
214
+ argv[3] === 'send' ? 'Filed https://github.com/o/code-kit/issues/9' : 'Discarded.',
215
+ );
216
+ }
217
+ if (sub === 'report') return ran(0, JSON.stringify(w.reports));
190
218
  if (sub === 'verify')
191
219
  return ran(w.verifyExit, w.verifyExit ? '' : w.verifyOut, w.verifyExit ? w.verifyOut : '');
192
220
  if (sub === 'stops') return ran(0, JSON.stringify(w.stops));
@@ -321,6 +349,21 @@ function stub(on: any, w: World) {
321
349
  return { value: { agent: `code-kit:${e.name}` } };
322
350
  });
323
351
  on('prompt.read', () => ({ value: { text: w.draft, cursor: w.draft.length } }));
352
+ on('model.fork', ($: any, e: any) => {
353
+ w.sideAsks.push({ fork: true, prompt: e.prompt });
354
+ return { value: w.sideReply };
355
+ });
356
+ on('model.complete', ($: any, e: any) => {
357
+ w.sideAsks.push({ model: e.model, prompt: e.prompt });
358
+ return { value: { isAnswered: true, text: 'From the facts alone.', usage: null } };
359
+ });
360
+ // Claude Code's own settings hooks beneath: none answer a permission denial.
361
+ on('classic.PermissionDenied', () => ({}));
362
+ on('prompt.fill', ($: any, e: any) => {
363
+ w.fills.push(e.text);
364
+ w.draft = e.text;
365
+ return { isFilled: true };
366
+ });
324
367
  on('session.send', ($: any, e: any) => {
325
368
  w.sent.push({ to: e.to, text: e.text });
326
369
  return { isDelivered: true };
@@ -513,10 +556,10 @@ test('PANE-4 with nothing changed it reads the project every 10 seconds while th
513
556
  const before = w.runs;
514
557
  await clock.advance(8000); // four quiet ticks: git only
515
558
  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.
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.
518
561
  await clock.advance(2000);
519
- expect(w.runs - before).toBe(13);
562
+ expect(w.runs - before).toBe(15);
520
563
  });
521
564
 
522
565
  test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
@@ -611,6 +654,56 @@ test('ACT-1 the person may type their own reason, and an empty one is not accept
611
654
  await ui.unmount();
612
655
  });
613
656
 
657
+ test("ACT-5 the person asks about an approval in its pane; the answer stays there, out of the lead's conversation", async ($, on) => {
658
+ const w = project();
659
+ w.requests = [dayjs];
660
+ await start($, on, w);
661
+ const ui = await bandUi($);
662
+ await press($, 'band-approve');
663
+ const confirm = await pane($, APPROVE);
664
+ await $.ui.input({
665
+ plugin: 'code-kit',
666
+ key: 'approve-ask',
667
+ text: 'Why does the booking form need this?',
668
+ });
669
+ expect(w.sideAsks).toHaveLength(1);
670
+ expect(w.sideAsks[0].fork).toBe(true);
671
+ expect(w.sideAsks[0].prompt).toMatch(/approvals asked for: dep-dayjs/);
672
+ expect(w.sideAsks[0].prompt).toMatch(/for: the web lane/);
673
+ expect(w.sideAsks[0].prompt).toMatch(/story: ST-4 Booking form/);
674
+ expect(w.sideAsks[0].prompt).toMatch(/Question: Why does the booking form need this\?$/);
675
+ expect(
676
+ await confirm.find({ type: 'Text', text: 'You: Why does the booking form need this?' }),
677
+ ).toBeDefined();
678
+ expect(JSON.stringify(await confirm.find({ key: 'aside-a-0' }))).toMatch(
679
+ /dayjs formats the booking dates/,
680
+ );
681
+ // 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?' });
683
+ expect(w.sideAsks[1].prompt).toMatch(
684
+ /Q: Why does the booking form need this\?\nA: dayjs formats the booking dates\./,
685
+ );
686
+ expect(w.prompts).toEqual([]);
687
+ expect(w.acts).toEqual([]);
688
+ await confirm.unmount();
689
+ await ui.unmount();
690
+ });
691
+
692
+ test('ACT-5 before the lead has replied there is nothing to fork: it answers from the facts alone', async ($, on) => {
693
+ const w = project();
694
+ w.requests = [dayjs];
695
+ w.sideReply = { isAnswered: false, reason: 'nothing-to-fork' };
696
+ await start($, on, w);
697
+ const ui = await bandUi($);
698
+ await press($, 'band-approve');
699
+ const confirm = await pane($, APPROVE);
700
+ await $.ui.input({ plugin: 'code-kit', key: 'approve-ask', text: 'What is it?' });
701
+ expect(w.sideAsks.map((a) => a.model ?? 'fork')).toEqual(['fork', 'sonnet']);
702
+ expect(JSON.stringify(await confirm.find({ key: 'aside-a-0' }))).toMatch(/From the facts alone/);
703
+ await confirm.unmount();
704
+ await ui.unmount();
705
+ });
706
+
614
707
  test('ACT-1 cancelling writes nothing', async ($, on) => {
615
708
  const w = project();
616
709
  w.requests = [dayjs];
@@ -1311,6 +1404,254 @@ test('DECIDE-3 notes are labelled note, not you, and need no decision', async ($
1311
1404
  await band.unmount();
1312
1405
  });
1313
1406
 
1407
+ const question = (extra: any = {}) => ({
1408
+ id: 'Q-1',
1409
+ at: '2026-10-12T05:59:00.000Z',
1410
+ by: 'lead',
1411
+ question: 'Which weather provider: the Bureau of Meteorology or a commercial service?',
1412
+ why: null,
1413
+ story: 'ST-4',
1414
+ blocks: true,
1415
+ options: [
1416
+ { label: 'Bureau of Meteorology', effect: 'Free, Australian data; we credit the Bureau.' },
1417
+ { label: 'Commercial', effect: 'A paid key; global coverage.' },
1418
+ ],
1419
+ state: 'open',
1420
+ answer: null,
1421
+ withdrawn: null,
1422
+ ...extra,
1423
+ });
1424
+
1425
+ 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) => {
1426
+ const w = project();
1427
+ w.questions = [
1428
+ question(),
1429
+ question({
1430
+ id: 'Q-2',
1431
+ question: 'May sessions show your city?',
1432
+ story: null,
1433
+ blocks: false,
1434
+ options: [],
1435
+ }),
1436
+ ];
1437
+ await start($, on, w);
1438
+ const band = await bandUi($);
1439
+ expect(
1440
+ await band.find({ type: 'Text', text: /^2 questions from the lead: Which weather provider/ }),
1441
+ ).toBeDefined();
1442
+ await press($, 'band-openDecision');
1443
+ await band.unmount();
1444
+ const ui = await pane($, PANE);
1445
+ // One card per question, not one for both.
1446
+ expect(await ui.find({ key: 'decision-pick-0' })).toBeDefined();
1447
+ expect(await ui.find({ key: 'decision-pick-1' })).toBeDefined();
1448
+ expect(await ui.find({ type: 'Text', text: 'ST-4 waits on your answer.' })).toBeDefined();
1449
+ expect(
1450
+ await ui.find({ type: 'Text', text: 'Free, Australian data; we credit the Bureau.' }),
1451
+ ).toBeDefined();
1452
+ await press($, 'decide-answerWith-0', PANE);
1453
+ expect(w.acts).toContainEqual(['ask', 'answer', 'Q-1', 'Bureau of Meteorology', '--via', 'pane']);
1454
+ expect(w.prompts.at(-1)).toBe(
1455
+ '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.',
1456
+ );
1457
+ await ui.unmount();
1458
+ });
1459
+
1460
+ test("ASK-2 a question with no options, or Something else…, is answered in the person's own words", async ($, on) => {
1461
+ const w = project();
1462
+ w.questions = [
1463
+ question({
1464
+ id: 'Q-2',
1465
+ question: 'May sessions show your city?',
1466
+ story: null,
1467
+ blocks: false,
1468
+ options: [],
1469
+ }),
1470
+ ];
1471
+ await start($, on, w);
1472
+ const band = await bandUi($);
1473
+ await press($, 'band-openDecision');
1474
+ await band.unmount();
1475
+ const ui = await pane($, PANE);
1476
+ await press($, 'decide-answer', PANE);
1477
+ expect(w.opened).toContain('code-kit-answer');
1478
+ const box = await pane($, 'code-kit-answer');
1479
+ await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: '' });
1480
+ expect(
1481
+ await box.find({ type: 'Text', text: 'Type your answer: the lead builds on it.' }),
1482
+ ).toBeDefined();
1483
+ await $.ui.input({
1484
+ plugin: 'code-kit',
1485
+ key: 'answer-text',
1486
+ text: 'Only the suburb, never the street',
1487
+ });
1488
+ expect(w.acts).toContainEqual([
1489
+ 'ask',
1490
+ 'answer',
1491
+ 'Q-2',
1492
+ 'Only the suburb, never the street',
1493
+ '--via',
1494
+ 'pane',
1495
+ ]);
1496
+ expect(w.prompts.at(-1)).toBe(
1497
+ 'The person answered Q-2 (May sessions show your city?): Only the suburb, never the street. Carry on with it.',
1498
+ );
1499
+ expect(w.closed).toContain('code-kit-answer');
1500
+ await box.unmount();
1501
+ await ui.unmount();
1502
+ });
1503
+
1504
+ 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) => {
1505
+ const w = project();
1506
+ w.next = {
1507
+ ...w.next,
1508
+ brief: [
1509
+ { question: 'Accounts: AWS, Supabase, Fly', path: 'docs/brief.md' },
1510
+ { question: 'The product domain', path: 'docs/brief.md' },
1511
+ ],
1512
+ };
1513
+ await start($, on, w);
1514
+ const band = await bandUi($);
1515
+ expect(
1516
+ await band.find({
1517
+ type: 'Text',
1518
+ text: '2 open questions in your brief: Accounts: AWS, Supabase, Fly',
1519
+ }),
1520
+ ).toBeDefined();
1521
+ await press($, 'band-openDecision');
1522
+ await band.unmount();
1523
+ const ui = await pane($, PANE);
1524
+ expect(
1525
+ await ui.find({ type: 'Text', text: 'From your brief: Accounts: AWS, Supabase, Fly' }),
1526
+ ).toBeDefined();
1527
+ await press($, 'decide-laterBrief', PANE);
1528
+ expect(
1529
+ await ui.find({ type: 'Text', text: 'From your brief: The product domain' }),
1530
+ ).toBeDefined();
1531
+ await press($, 'decide-answerBrief', PANE);
1532
+ const box = await pane($, 'code-kit-answer');
1533
+ await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: 'myaila.com.au' });
1534
+ await box.unmount();
1535
+ expect(w.prompts.at(-1)).toBe(
1536
+ '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.',
1537
+ );
1538
+ await ui.unmount();
1539
+ const after = await bandUi($);
1540
+ expect(await after.find({ type: 'Text', text: /in your brief/ })).toBeUndefined();
1541
+ await after.unmount();
1542
+ });
1543
+
1544
+ 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) => {
1545
+ const w = project();
1546
+ await start($, on, w);
1547
+ await $.classic.PermissionDenied({
1548
+ tool_name: 'Bash',
1549
+ tool_input: { command: 'pnpm db:generate && pnpm db:migrate' },
1550
+ tool_use_id: 'tu-1',
1551
+ reason: 'Permission for this action was denied by the Claude Code auto mode classifier.',
1552
+ });
1553
+ const band = await bandUi($);
1554
+ expect(
1555
+ await band.find({ type: 'Text', text: "Claude Code refused the lead's `pnpm db:generate`" }),
1556
+ ).toBeDefined();
1557
+ await press($, 'band-runDenied');
1558
+ expect(w.fills).toEqual(['! pnpm db:generate && pnpm db:migrate']);
1559
+ await press($, 'band-decide');
1560
+ await band.unmount();
1561
+ const ui = await pane($, PANE);
1562
+ expect(
1563
+ await ui.find({
1564
+ type: 'Text',
1565
+ text: /^Claude Code's own permission check refused it \(Permission for this action/,
1566
+ }),
1567
+ ).toBeDefined();
1568
+ await press($, 'decide-deniedDone', PANE);
1569
+ expect(w.prompts.at(-1)).toMatch(
1570
+ /^The person ran it themselves, or allowed it: pnpm db:generate && pnpm db:migrate/,
1571
+ );
1572
+ await ui.unmount();
1573
+ const after = await bandUi($);
1574
+ expect(await after.find({ type: 'Text', text: /Claude Code refused/ })).toBeUndefined();
1575
+ await after.unmount();
1576
+ });
1577
+
1578
+ test('CARD-8 two approvals waiting are two decisions, each approved or dropped on its own', async ($, on) => {
1579
+ const w = project();
1580
+ w.requests = [
1581
+ dayjs,
1582
+ {
1583
+ ...dayjs,
1584
+ at: '2026-10-06T00:05:00.000Z',
1585
+ actor: 'api-engineer',
1586
+ lane: 'api',
1587
+ names: ['dep-zod'],
1588
+ what: 'npm install zod',
1589
+ },
1590
+ ];
1591
+ await start($, on, w);
1592
+ const band = await bandUi($);
1593
+ expect(await band.find({ type: 'Text', text: '2 approvals waiting' })).toBeDefined();
1594
+ await press($, 'band-decide');
1595
+ await band.unmount();
1596
+ const ui = await pane($, PANE);
1597
+ expect(await ui.find({ key: 'decision-pick-0' })).toBeDefined();
1598
+ expect(await ui.find({ key: 'decision-pick-1' })).toBeDefined();
1599
+ await press($, 'decision-pick-1', PANE);
1600
+ await press($, 'decide-dropRequest', PANE);
1601
+ expect(w.acts).toContainEqual(['requests', 'drop', '2', '--via', 'pane']);
1602
+ await ui.unmount();
1603
+ });
1604
+
1605
+ test('REPORT-3 a bug report an agent drafted waits for the person, who reads it and sends it, or discards it', async ($, on) => {
1606
+ const w = project();
1607
+ w.reports = [
1608
+ {
1609
+ id: 'R-1',
1610
+ at: '2026-10-13T00:00:00Z',
1611
+ by: 'lead',
1612
+ title: 'Decisions tab misses a held call',
1613
+ fingerprint: 'ck-1',
1614
+ body: '**What happened:** the card never showed',
1615
+ state: 'draft',
1616
+ sent: null,
1617
+ },
1618
+ ];
1619
+ await start($, on, w);
1620
+ const band = await bandUi($);
1621
+ expect(
1622
+ await band.find({
1623
+ type: 'Text',
1624
+ text: 'A bug report for code-kit to send: Decisions tab misses a held call',
1625
+ }),
1626
+ ).toBeDefined();
1627
+ await press($, 'band-openDecision');
1628
+ await band.unmount();
1629
+ const ui = await pane($, PANE);
1630
+ expect(
1631
+ await ui.find({ type: 'Text', text: '**What happened:** the card never showed' }),
1632
+ ).toBeDefined();
1633
+ await press($, 'decide-sendReport', PANE);
1634
+ expect(w.acts).toContainEqual(['report', 'send', 'R-1']);
1635
+ expect(w.opened).toContain('code-kit-result');
1636
+ await ui.unmount();
1637
+ });
1638
+
1639
+ test('ASK-3 a lane whose story waits on the person reads waiting on you', async ($, on) => {
1640
+ const w = project();
1641
+ w.stories = w.stories.map((s: any) =>
1642
+ s.id === 'ST-4'
1643
+ ? { ...s, state: 'waiting on you', waitingFor: { id: 'Q-1', question: 'x' } }
1644
+ : s,
1645
+ );
1646
+ await start($, on, w);
1647
+ await lanes($);
1648
+ const ui = await pane($, PANE);
1649
+ expect((await ui.findAll({ type: 'Text' })).some((t: any) => /waiting on you/.test(t.text))).toBe(
1650
+ true,
1651
+ );
1652
+ await ui.unmount();
1653
+ });
1654
+
1314
1655
  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
1656
  const w = project();
1316
1657
  w.journal = {
@@ -39,6 +39,7 @@ export function readyFrom(next) {
39
39
  * building a branch for the story and, if it has commits, the lane's agent still at work on it;
40
40
  * in review commits on the branch (status's `review`) and the agent no longer at work;
41
41
  * sent back a review sent the branch back, and the lane hasn't committed its fix yet;
42
+ * waiting on you the lane's story waits on the person's answer (ASK-3);
42
43
  * blocked the lane's next story waits on another;
43
44
  * idle none of these.
44
45
  * `status` can't tell a finished branch from one still being built, so the agent at work decides.
@@ -46,7 +47,11 @@ export function readyFrom(next) {
46
47
  function laneRow(name, agent, stories, atWork) {
47
48
  const own = stories.filter((s) => s.lane === name);
48
49
  const active = Boolean(agent && atWork.has(agent));
49
- const story = own.find((s) => ['in progress', 'review', 'sent back'].includes(s.state));
50
+ const story = own.find((s) =>
51
+ ['in progress', 'review', 'sent back', 'waiting on you'].includes(s.state),
52
+ );
53
+ if (story?.state === 'waiting on you')
54
+ return { name, agent, active, story, branch: story.branch, state: 'waiting on you' };
50
55
  if (story?.state === 'sent back')
51
56
  return { name, agent, active, story, branch: story.branch, state: 'sent back' };
52
57
  if (story) {
@@ -107,6 +112,7 @@ export const STATE_COLOR = {
107
112
  orchestrating: 'yellow',
108
113
  'in review': 'yellow',
109
114
  'sent back': 'magenta',
115
+ 'waiting on you': 'cyan',
110
116
  blocked: 'red',
111
117
  idle: undefined,
112
118
  };
@@ -357,6 +363,7 @@ const CARD_STATE = {
357
363
  review: { label: 'in review', color: 'yellow' },
358
364
  'in progress': { label: 'building', color: 'blue' },
359
365
  'sent back': { label: 'sent back', color: 'magenta' },
366
+ 'waiting on you': { label: 'waiting on you', color: 'cyan' },
360
367
  blocked: { label: 'blocked', color: 'red' },
361
368
  ready: { label: 'ready', color: 'green' },
362
369
  todo: { label: 'to do', color: 'gray' },
@@ -715,6 +722,10 @@ export function bandLines({
715
722
  nextStep = null,
716
723
  combined = null,
717
724
  team = null,
725
+ questions = [],
726
+ brief = [],
727
+ denied = [],
728
+ reports = [],
718
729
  }) {
719
730
  const lines = [];
720
731
  // MISSION-1: the goal, now, next and the agents at work, above what needs the person.
@@ -725,18 +736,39 @@ export function bandLines({
725
736
  if (!state || state.kind !== 'ok') return lines;
726
737
  // HOLD-2: calls held for the person, oldest first, with the time left on the oldest.
727
738
  if (held.length) {
728
- const [call] = [...held].sort((a, b) => a.since - b.since);
739
+ const calls = [...held].sort((a, b) => a.since - b.since);
740
+ const [call] = calls;
729
741
  const left = timeLeft(call.since + call.minutes * 60000 - now);
730
742
  lines.push({
731
743
  kind: 'held',
732
744
  text: `${held.length > 1 ? `${held.length} calls held: ` : ''}${call.who} wants to ${heldVerb(call)} · ${left}`,
733
745
  held: call,
746
+ // CARD-8: each held call is its own decision in the Decisions tab.
747
+ calls: calls.map((c) => ({
748
+ held: c,
749
+ text: `${c.who} wants to ${heldVerb(c)} · ${timeLeft(c.since + c.minutes * 60000 - now)}`,
750
+ })),
734
751
  actions: [
735
752
  { id: 'approveHeld', label: 'Approve' },
736
753
  { id: 'refuseHeld', label: 'Refuse' },
737
754
  ],
738
755
  });
739
756
  }
757
+ // PERM-1: Claude Code's own permission check refused a call only the person can let through.
758
+ if (denied.length) {
759
+ const [d] = denied;
760
+ lines.push({
761
+ kind: 'denied',
762
+ text: `${denied.length > 1 ? `${denied.length} calls refused by Claude Code: ` : 'Claude Code refused '}${d.who}'s ${d.what}`,
763
+ denial: d,
764
+ denials: denied,
765
+ actions: [
766
+ ...(d.command ? [{ id: 'runDenied', label: 'Put it in the prompt' }] : []),
767
+ { id: 'deniedDone', label: 'Done it' },
768
+ { id: 'deniedNo', label: 'Not now' },
769
+ ],
770
+ });
771
+ }
740
772
  // LOOP-3: a lane stalled past its restarts waits for the person.
741
773
  for (const f of loop?.flagged ?? [])
742
774
  lines.push({
@@ -778,8 +810,28 @@ export function bandLines({
778
810
  text: `${plural(asks.length, 'approval', 'approvals')} waiting`,
779
811
  request: asks[0].request,
780
812
  files: asks[0].files,
813
+ // CARD-8: each approval is its own decision in the Decisions tab.
814
+ asks,
781
815
  actions: [{ id: 'approve', label: 'Approve…' }],
782
816
  });
817
+ // ASK-2: what the lead asked the person and couldn't settle itself.
818
+ if (questions.length)
819
+ lines.push({
820
+ kind: 'question',
821
+ text: `${questions.length > 1 ? `${questions.length} questions from the lead: ` : 'The lead asks: '}${questions[0].question}`,
822
+ question: questions[0],
823
+ questions,
824
+ actions: [{ id: 'openDecision', label: 'Answer…' }],
825
+ });
826
+ // ASK-4: the brief's open questions, the person's to answer.
827
+ if (brief.length)
828
+ lines.push({
829
+ kind: 'brief',
830
+ text: `${plural(brief.length, 'open question', 'open questions')} in your brief: ${brief[0].question}`,
831
+ brief: brief[0],
832
+ briefs: brief,
833
+ actions: [{ id: 'openDecision', label: 'Answer…' }],
834
+ });
783
835
  // DECIDE-1: reviewing a lane's work is the lead's. Only with the loop off, where the person runs the
784
836
  // build themselves, does a finished story wait in the band for their Review or Merge; otherwise the
785
837
  // loop prompts the lead (or, under propose, offers the step with Go).
@@ -839,6 +891,18 @@ export function bandLines({
839
891
  });
840
892
  // TEAM-7, TEAM-9: what of the team needs this person.
841
893
  lines.push(...teamLines(team, check?.lanes ?? {}));
894
+ // REPORT-3: a bug report for code-kit an agent drafted, for the person to read and send, or not.
895
+ if (reports.length)
896
+ lines.push({
897
+ kind: 'report',
898
+ text: `${reports.length > 1 ? `${reports.length} bug reports for code-kit to send: ` : 'A bug report for code-kit to send: '}${reports[0].title}`,
899
+ report: reports[0],
900
+ reports,
901
+ actions: [
902
+ { id: 'openDecision', label: 'Read it' },
903
+ { id: 'discardReport', label: 'Discard' },
904
+ ],
905
+ });
842
906
  // LOOP-7: the milestone is done; LOOP-6: the loop's own state, with Pause or Resume.
843
907
  if (loop?.state === 'done')
844
908
  lines.push({
@@ -880,6 +944,10 @@ export function band(lines, { Box, Text, Button }, onAction) {
880
944
  usage: 'yellow',
881
945
  paused: 'yellow',
882
946
  facts: 'yellow',
947
+ question: 'cyan',
948
+ brief: 'cyan',
949
+ denied: 'red',
950
+ report: 'magenta',
883
951
  };
884
952
  const LIVE = { quiet: 'yellow', stalled: 'red' };
885
953
  const LABEL = 8;
@@ -989,10 +1057,11 @@ const NOTE_KINDS = new Set(['usage', 'paused', 'finish', 'done', 'teamDown']);
989
1057
  /** The Approve… confirmation (ACT-1): what it grants, to whom, for how long, and the reason. */
990
1058
  export function approvePane(
991
1059
  approving,
992
- { Box, Text, Input, Button },
993
- { onInput, onSubmit, onCancel },
1060
+ { Box, Text, Input, Button, Markdown },
1061
+ { onInput, onSubmit, onCancel, onAskInput, onAsk },
994
1062
  ) {
995
1063
  const { request, reason, error } = approving;
1064
+ const aside = approving.aside ?? { thread: [], draft: '' };
996
1065
  return Box({
997
1066
  flexDirection: 'column',
998
1067
  rowGap: 1,
@@ -1003,6 +1072,30 @@ export function approvePane(
1003
1072
  `For ${request.lane ? `the ${request.lane} lane` : 'any agent'}, ${request.names.every((n) => n.startsWith('dep-')) ? 'until the install is committed (at most 7 days)' : 'for 60 minutes'}. Asked for: ${request.what}`,
1004
1073
  ],
1005
1074
  }),
1075
+ // ACT-5: an aside about this approval, answered here and never in the lead's conversation.
1076
+ ...aside.thread.flatMap((turn, i) => [
1077
+ Text({ key: `aside-q-${i}`, color: 'cyan', children: [`You: ${turn.question}`] }),
1078
+ turn.pending
1079
+ ? Text({ key: `aside-a-${i}`, dimColor: true, children: ['Asking…'] })
1080
+ : turn.error
1081
+ ? Text({ key: `aside-a-${i}`, color: 'red', children: [turn.error] })
1082
+ : Markdown
1083
+ ? Markdown({ key: `aside-a-${i}`, text: turn.answer })
1084
+ : Text({ key: `aside-a-${i}`, children: [turn.answer] }),
1085
+ ]),
1086
+ ...(onAsk
1087
+ ? [
1088
+ Input({
1089
+ key: 'approve-ask',
1090
+ label: 'Ask about it',
1091
+ value: aside.draft,
1092
+ placeholder: 'What is it for, and what does approving it let happen?',
1093
+ submitLabel: 'Ask',
1094
+ onInput: onAskInput,
1095
+ onSubmit: onAsk,
1096
+ }),
1097
+ ]
1098
+ : []),
1006
1099
  Input({
1007
1100
  key: 'approve-reason',
1008
1101
  label: 'Reason',
@@ -1025,6 +1118,88 @@ export function approvePane(
1025
1118
  });
1026
1119
  }
1027
1120
 
1121
+ /**
1122
+ * ACT-5: the side call's prompt for a question about an approval: what code-kit knows of the request (the
1123
+ * names, the lane, what was refused and why, its story, the limits it missed), the aside so far, and the
1124
+ * question. It answers in the pane, never in the conversation, and acts on nothing.
1125
+ */
1126
+ export function approvalAsidePrompt({
1127
+ request,
1128
+ story = null,
1129
+ outside = null,
1130
+ thread = [],
1131
+ question,
1132
+ }) {
1133
+ return [
1134
+ "The person is deciding whether to approve something code-kit stopped, and asks you about it in code-kit's approval pane. Answer in a few short sentences, in plain words: what it is, why it's needed for the work, what approving it lets happen, and anything risky about it. Use what you know from this conversation and the facts below. If you don't know, say so; don't guess. This answer goes to that pane, not into the conversation: don't act on it, and don't change your plans because of it.",
1135
+ '',
1136
+ 'The approval:',
1137
+ `- approvals asked for: ${request.names.join(', ')}`,
1138
+ `- for: ${request.lane ? `the ${request.lane} lane` : (request.actor ?? 'an agent')}`,
1139
+ `- what was refused: ${request.what}`,
1140
+ ...(request.why ? [`- why code-kit refused it: ${request.why}`] : []),
1141
+ ...(story
1142
+ ? [
1143
+ `- story: ${story.id} ${story.title ?? ''}${story.requirements?.length ? ` (${story.requirements.join(', ')})` : ''}`,
1144
+ ]
1145
+ : []),
1146
+ ...(outside ? [`- outside the limits the person set for the lead: ${outside}`] : []),
1147
+ ...(thread.length
1148
+ ? [
1149
+ '',
1150
+ 'Asked so far:',
1151
+ ...thread.flatMap((t) => [`Q: ${t.question}`, `A: ${t.answer ?? t.error ?? ''}`]),
1152
+ ]
1153
+ : []),
1154
+ '',
1155
+ `Question: ${question}`,
1156
+ ].join('\n');
1157
+ }
1158
+
1159
+ export const ANSWER_ID = 'code-kit-answer';
1160
+
1161
+ /** ASK-2, ASK-4: the person's answer to the lead's question, or to one in their brief, typed. */
1162
+ export function answerPane(
1163
+ answering,
1164
+ { Box, Text, Input, Button },
1165
+ { onInput, onSubmit, onCancel },
1166
+ ) {
1167
+ const { kind, question, text, error } = answering;
1168
+ return Box({
1169
+ flexDirection: 'column',
1170
+ rowGap: 1,
1171
+ children: [
1172
+ Text({ bold: true, children: [question] }),
1173
+ Text({
1174
+ dimColor: true,
1175
+ children: [
1176
+ kind === 'brief'
1177
+ ? `An open question in ${answering.path}. The lead records your answer in the brief's decisions and builds on it.`
1178
+ : `${answering.id}, from the lead${answering.story ? `, for ${answering.story}` : ''}. Your answer is recorded and sent to the lead.`,
1179
+ ],
1180
+ }),
1181
+ Input({
1182
+ key: 'answer-text',
1183
+ label: 'Answer',
1184
+ value: text,
1185
+ submitLabel: 'Answer',
1186
+ autoFocus: true,
1187
+ onInput,
1188
+ onSubmit,
1189
+ }),
1190
+ ...(error ? [Text({ color: 'red', children: [error] })] : []),
1191
+ Box({
1192
+ flexDirection: 'row',
1193
+ columnGap: 2,
1194
+ children: [
1195
+ Button({ key: 'answer-confirm', label: 'Answer', onPress: () => onSubmit(text) }),
1196
+ Button({ key: 'answer-cancel', label: 'Cancel', onPress: onCancel }),
1197
+ ],
1198
+ }),
1199
+ ],
1200
+ });
1201
+ }
1202
+
1028
1203
  /** What a merge (or another act run through the CLI) reported. */
1029
1204
  export function resultPane(result, { Box, Text }) {
1030
1205
  if (!result) return Text({ dimColor: true, children: ['Nothing to show.'] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warren-dean/code-kit",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
5
5
  "license": "MIT",
6
6
  "author": "Warren Langeveldt",