@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +108 -40
- package/SECURITY.md +11 -2
- package/bin/code-kit.mjs +61 -17
- package/hooks/guard-bash.mjs +35 -0
- package/hooks/lib/checks.mjs +19 -6
- package/hooks/lib/config.mjs +8 -0
- package/hooks/lib/next.mjs +34 -8
- package/hooks/lib/plan.mjs +4 -2
- package/hooks/lib/queue.mjs +25 -6
- package/hooks/lib/regression.mjs +321 -0
- package/hooks/lib/verify.mjs +53 -7
- package/hooks/mod/decisions.mjs +24 -4
- package/hooks/mod/loop.mjs +1 -1
- package/hooks/mod/panes.mjs +11 -1
- package/hooks/mod/register.mjs +79 -22
- package/hooks/mod/register.test.ts +173 -62
- package/hooks/mod/view.mjs +96 -25
- package/hooks/stop-check.mjs +1 -1
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -0
- package/skills/review/SKILL.md +3 -1
- package/skills/spec-design/SKILL.md +1 -1
- package/templates/agent.md +2 -0
- package/templates/claude-md.md +1 -1
- package/templates/plan.md +1 -1
|
@@ -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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
await
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
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
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
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) => {
|
package/hooks/mod/view.mjs
CHANGED
|
@@ -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
|
-
...
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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',
|
package/hooks/stop-check.mjs
CHANGED
|
@@ -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.
|
|
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",
|
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -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
|
|
package/skills/review/SKILL.md
CHANGED
|
@@ -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.
|
package/templates/agent.md
CHANGED
|
@@ -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.
|
package/templates/claude-md.md
CHANGED
|
@@ -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
|
|
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
|
|