@warren-dean/code-kit 0.12.2 → 0.13.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.
@@ -160,6 +160,7 @@ function project() {
160
160
  questions: [] as any[], // `code-kit ask --json`
161
161
  reports: [] as any[], // `code-kit report --json`
162
162
  fills: [] as string[], // what the mod put in the prompt
163
+ leadBusy: false, // prompts are queued behind the lead's turn, and never resolve in the test
163
164
  sideAsks: [] as any[], // side calls: { fork } or { model }, with their prompt
164
165
  sideReply: { isAnswered: true, text: 'dayjs formats the booking dates.', usage: null } as any,
165
166
  holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
@@ -392,6 +393,8 @@ function stub(on: any, w: World) {
392
393
  );
393
394
  on('prompt.submit', ($: any, e: any) => {
394
395
  w.prompts.push(e.text);
396
+ // A busy lead: the prompt is queued, and its turn doesn't end while the test looks.
397
+ if (w.leadBusy) return new Promise(() => {});
395
398
  return { text: e.text };
396
399
  });
397
400
  // $.ui.ask is the AskUserQuestion dialog: the person picks a label.
@@ -663,7 +666,7 @@ test("ACT-5 the person asks about an approval in its pane; the answer stays ther
663
666
  const confirm = await pane($, APPROVE);
664
667
  await $.ui.input({
665
668
  plugin: 'code-kit',
666
- key: 'approve-ask',
669
+ key: 'ask-about',
667
670
  text: 'Why does the booking form need this?',
668
671
  });
669
672
  expect(w.sideAsks).toHaveLength(1);
@@ -679,7 +682,7 @@ test("ACT-5 the person asks about an approval in its pane; the answer stays ther
679
682
  /dayjs formats the booking dates/,
680
683
  );
681
684
  // 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?' });
685
+ await $.ui.input({ plugin: 'code-kit', key: 'ask-about', text: 'Is it maintained?' });
683
686
  expect(w.sideAsks[1].prompt).toMatch(
684
687
  /Q: Why does the booking form need this\?\nA: dayjs formats the booking dates\./,
685
688
  );
@@ -697,7 +700,7 @@ test('ACT-5 before the lead has replied there is nothing to fork: it answers fro
697
700
  const ui = await bandUi($);
698
701
  await press($, 'band-approve');
699
702
  const confirm = await pane($, APPROVE);
700
- await $.ui.input({ plugin: 'code-kit', key: 'approve-ask', text: 'What is it?' });
703
+ await $.ui.input({ plugin: 'code-kit', key: 'ask-about', text: 'What is it?' });
701
704
  expect(w.sideAsks.map((a) => a.model ?? 'fork')).toEqual(['fork', 'sonnet']);
702
705
  expect(JSON.stringify(await confirm.find({ key: 'aside-a-0' }))).toMatch(/From the facts alone/);
703
706
  await confirm.unmount();
@@ -1319,7 +1322,7 @@ test('HOLD-1 and HOLD-2 a lane installing a new package is held, and the band as
1319
1322
  const w = project();
1320
1323
  w.refusal = webInstall;
1321
1324
  const clock = await start($, on, w);
1322
- await heldCall($, w, clock);
1325
+ const { call } = await heldCall($, w, clock);
1323
1326
  let ui = await bandUi($);
1324
1327
  expect(
1325
1328
  await ui.find({ type: 'Text', text: /^web wants to install dayjs · 2:00$/ }),
@@ -1332,6 +1335,8 @@ test('HOLD-1 and HOLD-2 a lane installing a new package is held, and the band as
1332
1335
  expect(await ui.find({ type: 'Text', text: /· 1:53$/ })).toBeDefined();
1333
1336
  await ui.unmount();
1334
1337
  w.waiting.get('tu-h1')?.('timed out');
1338
+ // A timed-out hold refuses the call, as the hooks did; awaited, so the test doesn't end mid-call.
1339
+ expect(refused(await call)).toBe(true);
1335
1340
  });
1336
1341
 
1337
1342
  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 +1358,7 @@ test('DECIDE-2 a decision in the band says which story it is for, and d opens it
1353
1358
  w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
1354
1359
  const clock = await start($, on, w);
1355
1360
  await lanes($);
1356
- await heldCall($, w, clock);
1361
+ const { call } = await heldCall($, w, clock);
1357
1362
  const band = await bandUi($);
1358
1363
  expect(await band.find({ type: 'Text', text: 'for ST-4 Booking form (BOOK-1)' })).toBeDefined();
1359
1364
  expect((await band.find({ key: 'band-decide' }))?.props.hotkey).toBe('d');
@@ -1373,6 +1378,8 @@ test('DECIDE-2 a decision in the band says which story it is for, and d opens it
1373
1378
  );
1374
1379
  await ui.unmount();
1375
1380
  w.waiting.get('tu-h1')?.('timed out');
1381
+ // A timed-out hold refuses the call, as the hooks did; awaited, so the test doesn't end mid-call.
1382
+ expect(refused(await call)).toBe(true);
1376
1383
  });
1377
1384
 
1378
1385
  test('DECIDE-3 an option on the card answers the decision, as the band would', async ($, on) => {
@@ -1538,6 +1545,92 @@ test("ASK-4 the brief's open questions are the person's decisions: answered to t
1538
1545
  await after.unmount();
1539
1546
  });
1540
1547
 
1548
+ 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) => {
1549
+ const w = project();
1550
+ w.next = {
1551
+ ...w.next,
1552
+ brief: [
1553
+ {
1554
+ question: 'Product domain (for the inbound email address and the privacy policy URL).',
1555
+ owner: 'Product owner',
1556
+ blocks: 'CAP-1 (M2)',
1557
+ path: 'docs/brief.md',
1558
+ },
1559
+ ],
1560
+ };
1561
+ w.requests = [];
1562
+ w.inForce = [{ name: 'dep-rrule', lane: 'domain', reason: 'rrule', minutesLeft: 50 }];
1563
+ w.journal = {
1564
+ events: [
1565
+ {
1566
+ at: 1,
1567
+ kind: 'request',
1568
+ text: 'You asked for “My Aila”: a household’s default parent carries the mental load. Things get missed.',
1569
+ },
1570
+ ],
1571
+ };
1572
+ await start($, on, w);
1573
+ const band = await bandUi($);
1574
+ await press($, 'band-openDecision');
1575
+ await band.unmount();
1576
+ const ui = await pane($, PANE);
1577
+ expect(await ui.find({ type: 'Text', text: 'An open question in your brief' })).toBeDefined();
1578
+ expect(
1579
+ await ui.find({
1580
+ type: 'Text',
1581
+ text: 'Product domain (for the inbound email address and the privacy policy URL).',
1582
+ }),
1583
+ ).toBeDefined();
1584
+ expect(await ui.find({ type: 'Text', text: 'Product owner' })).toBeDefined();
1585
+ expect(await ui.find({ type: 'Text', text: 'CAP-1 (M2)' })).toBeDefined();
1586
+ // What they asked for in a line, and no approvals that have nothing to do with it.
1587
+ expect(
1588
+ await ui.find({
1589
+ type: 'Text',
1590
+ text: '“My Aila”: a household’s default parent carries the mental load.',
1591
+ }),
1592
+ ).toBeDefined();
1593
+ expect(await ui.find({ type: 'Text', text: /dep-rrule/ })).toBeUndefined();
1594
+ await press($, 'decide-answerBrief', PANE);
1595
+ const box = await pane($, 'code-kit-answer');
1596
+ await $.ui.input({ plugin: 'code-kit', key: 'ask-about', text: 'What is this for?' });
1597
+ expect(w.sideAsks[0].prompt).toMatch(/An open question in docs\/brief\.md:\n- Product domain/);
1598
+ expect(w.sideAsks[0].prompt).toMatch(/what it holds up: CAP-1 \(M2\)/);
1599
+ expect(JSON.stringify(await box.find({ key: 'aside-a-0' }))).toMatch(
1600
+ /dayjs formats the booking dates/,
1601
+ );
1602
+ expect(w.prompts).toEqual([]);
1603
+ await box.unmount();
1604
+ await ui.unmount();
1605
+ });
1606
+
1607
+ 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) => {
1608
+ const w = project();
1609
+ w.leadBusy = true;
1610
+ w.questions = [question({ options: [] })];
1611
+ w.next = { ...w.next, brief: [{ question: 'The product domain', path: 'docs/brief.md' }] };
1612
+ await start($, on, w);
1613
+ const band = await bandUi($);
1614
+ await press($, 'band-openDecision');
1615
+ await band.unmount();
1616
+ const ui = await pane($, PANE);
1617
+ await press($, 'decide-answer', PANE);
1618
+ let box = await pane($, 'code-kit-answer');
1619
+ await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: 'The Bureau' });
1620
+ await box.unmount();
1621
+ expect(w.closed).toContain('code-kit-answer');
1622
+ expect(await ui.find({ type: 'Text', text: /Which weather provider/ })).toBeUndefined();
1623
+ await press($, 'decide-answerBrief', PANE);
1624
+ box = await pane($, 'code-kit-answer');
1625
+ await $.ui.input({ plugin: 'code-kit', key: 'answer-text', text: 'myaila.com.au' });
1626
+ await box.unmount();
1627
+ expect(w.prompts).toHaveLength(2);
1628
+ expect(
1629
+ await ui.find({ type: 'Text', text: 'Nothing needs you. The lead is handling the build.' }),
1630
+ ).toBeDefined();
1631
+ await ui.unmount();
1632
+ });
1633
+
1541
1634
  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
1635
  const w = project();
1543
1636
  await start($, on, w);
@@ -1829,9 +1922,11 @@ test('HOLD-5 with autonomy propose the delegated rules are not tried; the call i
1829
1922
  w.delegateExit = 0;
1830
1923
  (w.check as any).harness = { autonomy: 'propose', hold: { minutes: 2 } };
1831
1924
  const clock = await start($, on, w);
1832
- await heldCall($, w, clock);
1925
+ const { call } = await heldCall($, w, clock);
1833
1926
  expect(w.delegated).toEqual([]);
1834
1927
  w.waiting.get('tu-h1')?.('timed out');
1928
+ // A timed-out hold refuses the call, as the hooks did; awaited, so the test doesn't end mid-call.
1929
+ expect(refused(await call)).toBe(true);
1835
1930
  });
1836
1931
 
1837
1932
  test('HOLD-6 with nobody to ask, or hold off, the call is refused at once', async ($, on) => {
@@ -2067,43 +2162,51 @@ test('LOOP-6 the pause is recorded for other tools, and a new session starts wit
2067
2162
  ]);
2068
2163
  });
2069
2164
 
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
- });
2165
+ // It steps the clock through tens of minutes of the mod's 2-second refresh, each tick a real round of
2166
+ // stubbed CLI runs: about 1.5 to 3 seconds locally, and more on a shared CI runner.
2167
+ test(
2168
+ 'LOOP-3 a quiet lane agent is nudged at 5 minutes, restarted through the lead at 10, and flagged after 2 restarts',
2169
+ { timeoutMs: 30000 },
2170
+ async ($, on) => {
2171
+ const w = project();
2172
+ w.next = { step: 'wait', args: '', then: [] };
2173
+ w.agents = [
2174
+ { id: 'a1', type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
2175
+ ];
2176
+ const clock = await start($, on, w);
2177
+ // First seen at the next look (2 s), so quiet for 5 minutes a look later.
2178
+ await clock.advance(5 * 60000 + 2000);
2179
+ expect(w.sent).toEqual([
2180
+ {
2181
+ to: 'a1',
2182
+ text: 'No tool call for 5 minutes: report where you are, or carry on.',
2183
+ },
2184
+ ]);
2185
+ await lanes($);
2186
+ let ui = await pane($, PANE);
2187
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/nudged/);
2188
+ await ui.unmount();
2189
+ await clock.advance(5 * 60000);
2190
+ expect(w.prompts[0]).toMatch(
2191
+ /^web-engineer \(agent a1\) on ST-4 has made no tool call for 10 minutes\. Stop it with TaskStop.*\/code-kit:dispatch ST-4$/,
2192
+ );
2193
+ ui = await pane($, PANE);
2194
+ expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/restarted 1\/2/);
2195
+ await ui.unmount();
2196
+ // The lane's agent is started again, and stalls again, twice more.
2197
+ for (const id of ['a2', 'a3']) {
2198
+ w.agents = [
2199
+ { id, type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
2200
+ ];
2201
+ await clock.advance(10 * 60000 + 2000);
2202
+ }
2203
+ expect(w.prompts).toHaveLength(2);
2204
+ const band = await bandUi($);
2205
+ expect(await band.find({ type: 'Text', text: 'web stalled 3 times on ST-4' })).toBeDefined();
2206
+ expect(await band.find({ key: 'band-resumeStall' })).toBeDefined();
2207
+ await band.unmount();
2208
+ },
2209
+ );
2107
2210
 
2108
2211
  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
2212
  const w = project();
@@ -2150,24 +2253,30 @@ test('LOOP-3 until the person has chosen how hands-off the loop is, a restart is
2150
2253
  await band.unmount();
2151
2254
  });
2152
2255
 
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
- });
2256
+ // It steps the clock through tens of minutes of the mod's 2-second refresh, each tick a real round of
2257
+ // stubbed CLI runs: about 1.5 to 3 seconds locally, and more on a shared CI runner.
2258
+ test(
2259
+ 'LOOP-3 an agent waiting on a held call (or running a long command) is never quiet',
2260
+ { timeoutMs: 30000 },
2261
+ async ($, on) => {
2262
+ const w = project();
2263
+ w.next = { step: 'wait', args: '', then: [] };
2264
+ w.refusal = webInstall;
2265
+ (w.check as any).harness = { hold: { minutes: 10 } };
2266
+ w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
2267
+ const clock = await start($, on, w);
2268
+ const { call } = await heldCall($, w, clock);
2269
+ await clock.advance(6 * 60000);
2270
+ expect(w.sent).toEqual([]);
2271
+ w.waiting.get('tu-h1')?.('timed out');
2272
+ await call;
2273
+ // Its quiet time starts again when the call ends: nudged 5 minutes after, not at once.
2274
+ await clock.advance(2000);
2275
+ expect(w.sent).toEqual([]);
2276
+ await clock.advance(5 * 60000);
2277
+ expect(w.sent.map((m) => m.to)).toEqual(['a1']);
2278
+ },
2279
+ );
2171
2280
 
2172
2281
  test('LOOP-7 when every story is done the loop stops, and the band says so until dismissed', async ($, on) => {
2173
2282
  const w = project();
@@ -2657,7 +2766,7 @@ test('VIEW-5 what needs the person is pinned at the top with its letter: a held
2657
2766
  const w = project();
2658
2767
  w.refusal = webInstall;
2659
2768
  const clock = await start($, on, w);
2660
- await heldCall($, w, clock);
2769
+ const { call } = await heldCall($, w, clock);
2661
2770
  await lanes($);
2662
2771
  const ui = await pane($, PANE);
2663
2772
  expect((await ui.find({ key: 'need-held' }))?.text).toMatch(
@@ -2668,6 +2777,8 @@ test('VIEW-5 what needs the person is pinned at the top with its letter: a held
2668
2777
  expect(w.opened).toContain(APPROVE);
2669
2778
  await ui.unmount();
2670
2779
  w.waiting.get('tu-h1')?.('timed out');
2780
+ // A timed-out hold refuses the call, as the hooks did; awaited, so the test doesn't end mid-call.
2781
+ expect(refused(await call)).toBe(true);
2671
2782
  });
2672
2783
 
2673
2784
  test('VIEW-6 tabs on 1 to 7, j and k move between lanes, and the selected lane has its keys', async ($, on) => {
@@ -663,6 +663,8 @@ export function askWords(request, files = [request?.what]) {
663
663
  }
664
664
 
665
665
  export function approvalLabel(name) {
666
+ // REG-2: the person's approval to weaken a test.
667
+ if (name === 'tests') return 'a weakened test';
666
668
  return name.startsWith('dep-') ? name.slice(4).replaceAll('+', '/') : name;
667
669
  }
668
670
 
@@ -1073,29 +1075,12 @@ export function approvePane(
1073
1075
  ],
1074
1076
  }),
1075
1077
  // 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
- : []),
1078
+ ...asideNodes(
1079
+ aside,
1080
+ { Text, Input, Markdown },
1081
+ { onAskInput, onAsk },
1082
+ 'What is it for, and what does approving it let happen?',
1083
+ ),
1099
1084
  Input({
1100
1085
  key: 'approve-reason',
1101
1086
  label: 'Reason',
@@ -1118,6 +1103,69 @@ export function approvePane(
1118
1103
  });
1119
1104
  }
1120
1105
 
1106
+ /**
1107
+ * ACT-5: an aside in a pane: each question asked and its answer (or that it's being asked, or why there
1108
+ * is none), then the box to ask the next.
1109
+ */
1110
+ function asideNodes(aside, { Text, Input, Markdown }, { onAskInput, onAsk }, placeholder) {
1111
+ return [
1112
+ ...aside.thread.flatMap((turn, i) => [
1113
+ Text({ key: `aside-q-${i}`, color: 'cyan', children: [`You: ${turn.question}`] }),
1114
+ turn.pending
1115
+ ? Text({ key: `aside-a-${i}`, dimColor: true, children: ['Asking…'] })
1116
+ : turn.error
1117
+ ? Text({ key: `aside-a-${i}`, color: 'red', children: [turn.error] })
1118
+ : Markdown
1119
+ ? Markdown({ key: `aside-a-${i}`, text: turn.answer })
1120
+ : Text({ key: `aside-a-${i}`, children: [turn.answer] }),
1121
+ ]),
1122
+ ...(onAsk
1123
+ ? [
1124
+ Input({
1125
+ key: 'ask-about',
1126
+ label: 'Ask about it',
1127
+ value: aside.draft,
1128
+ placeholder,
1129
+ submitLabel: 'Ask',
1130
+ onInput: onAskInput,
1131
+ onSubmit: onAsk,
1132
+ }),
1133
+ ]
1134
+ : []),
1135
+ ];
1136
+ }
1137
+
1138
+ /**
1139
+ * ASK-5: the side call's prompt for a question about one of the person's decisions (a question from the
1140
+ * lead, or one open in their brief): what code-kit knows of it, the aside so far, and their question.
1141
+ */
1142
+ export function questionAsidePrompt({ q, thread = [], question }) {
1143
+ return [
1144
+ "The person is deciding how to answer a question in code-kit's Decisions pane, and asks you about it first. Answer in a few short sentences, in plain words: what the question means, why it matters for the build, what the choices are and what each leads to, and what you'd recommend if you know enough to. Use what you know from this conversation, the project's docs you've read, 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.",
1145
+ '',
1146
+ q.kind === 'brief' ? `An open question in ${q.path}:` : `A question the lead asked (${q.id}):`,
1147
+ `- ${q.question}`,
1148
+ ...(q.owner ? [`- who decides: ${q.owner}`] : []),
1149
+ ...(q.kind === 'brief' && q.blocks ? [`- what it holds up: ${q.blocks}`] : []),
1150
+ ...(q.story ? [`- story: ${q.story}`] : []),
1151
+ ...(q.why ? [`- why the lead can't settle it: ${q.why}`] : []),
1152
+ ...(q.options?.length
1153
+ ? [
1154
+ `- the lead's options: ${q.options.map((o) => `${o.label}${o.effect ? ` (${o.effect})` : ''}`).join('; ')}`,
1155
+ ]
1156
+ : []),
1157
+ ...(thread.length
1158
+ ? [
1159
+ '',
1160
+ 'Asked so far:',
1161
+ ...thread.flatMap((t) => [`Q: ${t.question}`, `A: ${t.answer ?? t.error ?? ''}`]),
1162
+ ]
1163
+ : []),
1164
+ '',
1165
+ `Question: ${question}`,
1166
+ ].join('\n');
1167
+ }
1168
+
1121
1169
  /**
1122
1170
  * ACT-5: the side call's prompt for a question about an approval: what code-kit knows of the request (the
1123
1171
  * names, the lane, what was refused and why, its story, the limits it missed), the aside so far, and the
@@ -1161,10 +1209,11 @@ export const ANSWER_ID = 'code-kit-answer';
1161
1209
  /** ASK-2, ASK-4: the person's answer to the lead's question, or to one in their brief, typed. */
1162
1210
  export function answerPane(
1163
1211
  answering,
1164
- { Box, Text, Input, Button },
1165
- { onInput, onSubmit, onCancel },
1212
+ { Box, Text, Input, Button, Markdown },
1213
+ { onInput, onSubmit, onCancel, onAskInput, onAsk },
1166
1214
  ) {
1167
1215
  const { kind, question, text, error } = answering;
1216
+ const aside = answering.aside ?? { thread: [], draft: '' };
1168
1217
  return Box({
1169
1218
  flexDirection: 'column',
1170
1219
  rowGap: 1,
@@ -1178,6 +1227,28 @@ export function answerPane(
1178
1227
  : `${answering.id}, from the lead${answering.story ? `, for ${answering.story}` : ''}. Your answer is recorded and sent to the lead.`,
1179
1228
  ],
1180
1229
  }),
1230
+ ...(kind === 'brief' && (answering.owner || answering.blocks)
1231
+ ? [
1232
+ Text({
1233
+ dimColor: true,
1234
+ children: [
1235
+ [
1236
+ answering.owner && `Who decides: ${answering.owner}`,
1237
+ answering.blocks && `Holds up: ${answering.blocks}`,
1238
+ ]
1239
+ .filter(Boolean)
1240
+ .join(' · '),
1241
+ ],
1242
+ }),
1243
+ ]
1244
+ : []),
1245
+ // ASK-5: not sure what it means? Ask, here, before answering.
1246
+ ...asideNodes(
1247
+ aside,
1248
+ { Text, Input, Markdown },
1249
+ { onAskInput, onAsk },
1250
+ 'What does it mean, and what would you suggest?',
1251
+ ),
1181
1252
  Input({
1182
1253
  key: 'answer-text',
1183
1254
  label: 'Answer',
@@ -82,7 +82,7 @@ const layers = changed
82
82
  if (layers.length)
83
83
  fail('Imports break the layer rules (.claude/code-kit.json › layers):', layers.join('\n'));
84
84
 
85
- const failure = runChecks(config, changed, root, actor);
85
+ const { failure } = runChecks(config, changed, root, actor);
86
86
  if (failure) fail(failure.title, failure.output);
87
87
 
88
88
  // A lane hands back committed work on its own branch; the lead reviews and merges it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warren-dean/code-kit",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
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",
@@ -61,6 +61,7 @@ Requirements (read each one in full, with its acceptance criteria):
61
61
  - …
62
62
 
63
63
  What to build: <the story's description, and anything the lead decided that the spec doesn't say>
64
+ Fixes: <for a fix story, what was broken and where it was seen; write the test that fails because of it first>
64
65
  Contracts to use: <paths of the schemas, events or tables this story relies on>
65
66
  Out of scope: <what neighbouring stories or other lanes will do>
66
67
 
@@ -29,6 +29,8 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" verify --base <base>
29
29
  - new layer violations, and a baseline that grew;
30
30
  - screens without an approved design;
31
31
  - committed secrets;
32
+ - tests weakened (deleted, skipped or focused, cut down, or a snapshot rewritten) without the person's `tests` approval in the log;
33
+ - for a fix (its story has `**Fixes:**`, or the branch is `<lane>/fix-…`), that its tests fail without the fix, as the bug, and pass with it;
32
34
  - the project's checks for the changed files.
33
35
 
34
36
  Any problem it reports means the branch goes back. Don't fix a lane's code yourself; the lead doesn't write lane paths.
@@ -73,7 +75,7 @@ Report the table, `verify`'s result, the security review's findings (or that non
73
75
  node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs" queue add <branch>
74
76
  ```
75
77
 
76
- Branches merge from the queue one at a time, in the order their reviews passed, each verified against the base as it is then. Where `approvals.delegate.merge` is on and the harness's autonomy is `autonomous`, you merge the head: `code-kit queue merge --delegated` (code-kit's loop asks you when it's its turn). Otherwise the person merges it, with Merge in the code-kit band or `code-kit queue merge --person`. A head that no longer merges cleanly, or fails verify against the moved base, is sent back to its lane on its own; dispatch the fix as for any send-back. When the branch is merged, remove its worktree.
78
+ Branches merge from the queue one at a time, in the order their reviews passed, each verified against the base as it is then. Where `approvals.delegate.merge` is on and the harness's autonomy is `autonomous`, you merge the head: `code-kit queue merge --delegated` (code-kit's loop asks you when it's its turn). Otherwise the person merges it, with Merge in the code-kit band or `code-kit queue merge --person`. The queue records the commit your review passed and merges exactly that commit. A branch that moves on after your review leaves the queue: review it again, then queue it again. A head that no longer merges cleanly, or fails verify against the moved base, is sent back to its lane on its own; dispatch the fix as for any send-back. When the branch is merged, remove its worktree.
77
79
 
78
80
  - **Send back.** List each problem, where it is, and what "fixed" looks like. Record the send-back, so `status` and `next` read the branch as waiting for its fix rather than finished:
79
81
 
@@ -111,7 +111,7 @@ These are what `/code-kit:init` turns into enforced rules, so be concrete.
111
111
  - a **QA lane** (`qa`, agent `test-engineer`) whenever the product has screens or journeys that cross lanes: it owns the end-to-end suites (for example `e2e/**` or `apps/*/e2e/**`), in the runner that suits each surface (Playwright for web, Maestro or Detox for mobile), and its command runs them. Nobody else owns these tests, so without it nothing proves a journey works across the lanes;
112
112
  - no lane for security: it owns no paths. init adds a read-only security reviewer that checks every story touching the threat models' sensitive parts, so security requirements are delivered by the lanes whose code they constrain;
113
113
  - milestones, each a usable increment;
114
- - stories, each small enough for one lane to finish on one branch. Each story is a `### ST-<n> Title` heading with `**Lane:**`, `**Requirements:**` (the IDs it delivers, or `none` for a spike or a foundation that delivers none), `**Depends on:**` and `**Status:** todo` lines. `code-kit status` and the dispatch skill read them. A story that needs two lanes is two stories, with a dependency between them;
114
+ - stories, each small enough for one lane to finish on one branch. Each story is a `### ST-<n> Title` heading with `**Lane:**`, `**Requirements:**` (the IDs it delivers, or `none` for a spike or a foundation that delivers none), `**Depends on:**` and `**Status:** todo` lines, and for a story that fixes a bug, `**Fixes:**` with what was broken. `code-kit status` and the dispatch skill read them. A story that needs two lanes is two stories, with a dependency between them;
115
115
  - sequencing: contracts and shared foundations first, as the lead's own stories (`**Lane:** lead`, built by the lead rather than dispatched), then work that can run in parallel;
116
116
  - **a test story per journey**: for each main user journey, a story in the `qa` lane citing the journey's requirements and depending on the screen and service stories it goes through, so the journey is proven end to end as soon as its parts are built;
117
117
  - **design before code**: when there are screens, a design story for each area's screens (`**Lane:** design`, citing the requirements its screens serve), early in the plan, and every story that builds those screens depends on it. The lead designs them with the person (`/code-kit:design`), and no screen is built until its design is approved.
@@ -26,5 +26,7 @@ Always:
26
26
  - A new dependency needs a person's approval for your lane. If the install is refused, put the package, why it's needed and the approval command from the refusal in a change request for the lead, and stop.
27
27
  - Build every feature end to end. No mocks, stubs, placeholder screens or "coming soon".
28
28
  - Prove each acceptance criterion with a test whose title names the requirement ID, e.g. `BOOK-4 …`.
29
+ - Fixing a bug: first write a test that fails because of the bug, through the code that's already there, then fix it. `verify` runs your test without the fix, and it must fail on an assertion, not because it calls something the fix adds.
30
+ - Never weaken a test to get to green: no deleting, skipping or focusing tests, cutting cases or assertions, or rewriting snapshots. The commit is refused without the person's approval. Fix the code; if the test itself is wrong, say why in a change request for the lead.
29
31
  - Commit your work on your branch before finishing; the stop hook runs the checks and requires it.
30
32
  - Finish with: what you did, the spec section, the commands you ran with their output, open questions.
@@ -1,6 +1,6 @@
1
1
  ## Enforced, not advised
2
2
 
3
- The code-kit plugin enforces the rules in `.claude/code-kit.json` on every tool call:
3
+ The code-kit plugin enforces the rules in `.claude/code-kit.json` on every file edit, shell command and finish:
4
4
 
5
5
  - who writes where (the lead delegates lane work to the lane agents in `.claude/agents/`);
6
6
  - the layer rules ({{layer names, in order}});
package/templates/plan.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Plan
2
2
 
3
- `code-kit status` reads this file. Keep each story's heading and its four `**…:**` lines in this form. Use `**Lane:** lead` for the lead's own stories (contracts, foundations, spikes), `**Lane:** qa` for a journey's end-to-end test story (it depends on the screen and service stories the journey goes through), `**Lane:** design` for a design story (an area's screens, designed with the person before any of them is built; the stories building them depend on it), and `**Requirements:** none` for a story that delivers no requirement.
3
+ `code-kit status` reads this file. Keep each story's heading and its four `**…:**` lines in this form. Use `**Lane:** lead` for the lead's own stories (contracts, foundations, spikes), `**Lane:** qa` for a journey's end-to-end test story (it depends on the screen and service stories the journey goes through), `**Lane:** design` for a design story (an area's screens, designed with the person before any of them is built; the stories building them depend on it), and `**Requirements:** none` for a story that delivers no requirement. A story that fixes a bug adds a fifth line, `**Fixes:** <what was broken, and where it was seen>`: `code-kit verify` then requires a test that fails without the fix and passes with it.
4
4
 
5
5
  ## Lanes
6
6