@warren-dean/code-kit 0.7.0 → 0.8.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +15 -3
- package/bin/code-kit.mjs +171 -3
- package/hooks/guard-bash.mjs +29 -2
- package/hooks/lib/adapters/context-graph.mjs +18 -19
- package/hooks/lib/journal.mjs +224 -0
- package/hooks/lib/loop-state.mjs +26 -0
- package/hooks/lib/next.mjs +13 -3
- package/hooks/lib/plan.mjs +8 -1
- package/hooks/lib/storymap.mjs +155 -0
- package/hooks/mod/codemap.mjs +328 -0
- package/hooks/mod/decisions.mjs +146 -0
- package/hooks/mod/loop.mjs +7 -3
- package/hooks/mod/mission.mjs +116 -0
- package/hooks/mod/panes.mjs +469 -34
- package/hooks/mod/register.mjs +196 -2
- package/hooks/mod/register.test.ts +456 -25
- package/hooks/mod/trail.mjs +152 -0
- package/hooks/mod/view.mjs +324 -116
- package/package.json +1 -1
- package/skills/init/SKILL.md +3 -3
|
@@ -97,6 +97,8 @@ function project() {
|
|
|
97
97
|
allowed: false, // an approval in force: the hooks beneath let the call through
|
|
98
98
|
bashRuns: 0,
|
|
99
99
|
delegateExit: 1,
|
|
100
|
+
delegateRefusal:
|
|
101
|
+
'This project delegates no approvals to the lead ("approvals.delegate" isn\'t set). Ask the person.',
|
|
100
102
|
delegated: [] as string[][],
|
|
101
103
|
holds: [] as string[][],
|
|
102
104
|
draft: '', // what the person has typed in the prompt
|
|
@@ -148,6 +150,10 @@ function project() {
|
|
|
148
150
|
},
|
|
149
151
|
} as any,
|
|
150
152
|
sent: [] as { to: any; text: string }[],
|
|
153
|
+
loopPaused: false, // the pause recorded by `code-kit loop`
|
|
154
|
+
map: null as any, // `code-kit map <story> --json`
|
|
155
|
+
trail: null as any, // `code-kit trail --json`
|
|
156
|
+
journal: { events: [] as any[] }, // `code-kit journal --json`
|
|
151
157
|
holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
|
|
152
158
|
waiting: new Map<string, (answer: string) => void>(),
|
|
153
159
|
};
|
|
@@ -185,6 +191,15 @@ function stub(on: any, w: World) {
|
|
|
185
191
|
if (sub === 'adapters') return ran(0, JSON.stringify(w.adapters));
|
|
186
192
|
if (sub === 'timeline')
|
|
187
193
|
return ran(0, JSON.stringify({ base: 'main', now: 0, stories: w.times }));
|
|
194
|
+
if (sub === 'map') return ran(w.map ? 0 : 1, JSON.stringify(w.map));
|
|
195
|
+
if (sub === 'trail') return ran(w.trail ? 0 : 1, JSON.stringify(w.trail));
|
|
196
|
+
if (sub === 'journal') return ran(0, JSON.stringify(w.journal));
|
|
197
|
+
if (sub === 'loop' && ['pause', 'resume'].includes(argv[3])) {
|
|
198
|
+
w.acts.push([...argv.slice(2)]);
|
|
199
|
+
w.loopPaused = argv[3] === 'pause';
|
|
200
|
+
return ran(0, '');
|
|
201
|
+
}
|
|
202
|
+
if (sub === 'loop') return ran(0, JSON.stringify({ paused: w.loopPaused, at: null }));
|
|
188
203
|
if (sub === 'sent-back') {
|
|
189
204
|
w.acts.push([...argv.slice(2)]);
|
|
190
205
|
return ran(0, `Recorded: ${argv[3]} was sent back.`);
|
|
@@ -198,7 +213,7 @@ function stub(on: any, w: World) {
|
|
|
198
213
|
if (sub === 'approve' && argv.includes('--delegated')) {
|
|
199
214
|
w.delegated.push([...argv.slice(2)]);
|
|
200
215
|
if (w.delegateExit === 0) w.allowed = true;
|
|
201
|
-
return ran(w.delegateExit, '', w.delegateExit ?
|
|
216
|
+
return ran(w.delegateExit, '', w.delegateExit ? w.delegateRefusal : '');
|
|
202
217
|
}
|
|
203
218
|
if (sub === 'hold') {
|
|
204
219
|
const id = argv[3];
|
|
@@ -437,7 +452,6 @@ test('PANE-2 each lane shows its agent, story, branch and state', async ($, on)
|
|
|
437
452
|
await lanes($);
|
|
438
453
|
const ui = await pane($, PANE);
|
|
439
454
|
expect(await ui.find({ key: 'lane-web' })).toBeDefined();
|
|
440
|
-
expect(await ui.find({ type: 'Text', text: /web-engineer/ })).toBeDefined();
|
|
441
455
|
expect(await ui.find({ type: 'Text', text: 'ST-4 Booking form · web/st-4' })).toBeDefined();
|
|
442
456
|
expect(await ui.find({ type: 'Text', text: /building/ })).toBeDefined();
|
|
443
457
|
expect(await ui.find({ type: 'Text', text: /idle/ })).toBeDefined();
|
|
@@ -613,8 +627,20 @@ test('ACT-1 a grant that fails approves nothing, and the band says why', async (
|
|
|
613
627
|
await after.unmount();
|
|
614
628
|
});
|
|
615
629
|
|
|
630
|
+
test("DECIDE-1 while the loop runs the build, a lane's finished work never waits in the band for the person", async ($, on) => {
|
|
631
|
+
const w = project();
|
|
632
|
+
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
633
|
+
await start($, on, w);
|
|
634
|
+
const band = await bandUi($);
|
|
635
|
+
expect(await band.find({ key: 'band-review' })).toBeUndefined();
|
|
636
|
+
expect(await band.find({ key: 'band-merge' })).toBeUndefined();
|
|
637
|
+
await band.unmount();
|
|
638
|
+
});
|
|
639
|
+
|
|
616
640
|
test('ACT-3 Review asks the lead to run the review skill on the branch', async ($, on) => {
|
|
617
641
|
const w = project();
|
|
642
|
+
// The person runs the build themselves: reviews and merges wait in the band for them.
|
|
643
|
+
(w.check as any).harness = { autonomy: 'off' };
|
|
618
644
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
619
645
|
await start($, on, w);
|
|
620
646
|
const ui = await bandUi($);
|
|
@@ -629,6 +655,8 @@ test('ACT-3 Review asks the lead to run the review skill on the branch', async (
|
|
|
629
655
|
|
|
630
656
|
test('ACT-4 Merge confirms, then merges as the person', async ($, on) => {
|
|
631
657
|
const w = project();
|
|
658
|
+
// The person runs the build themselves: reviews and merges wait in the band for them.
|
|
659
|
+
(w.check as any).harness = { autonomy: 'off' };
|
|
632
660
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
633
661
|
await start($, on, w);
|
|
634
662
|
const ui = await bandUi($);
|
|
@@ -643,6 +671,8 @@ test('ACT-4 Merge confirms, then merges as the person', async ($, on) => {
|
|
|
643
671
|
|
|
644
672
|
test('ACT-4 a branch that fails verify is not merged, and the problems are shown', async ($, on) => {
|
|
645
673
|
const w = project();
|
|
674
|
+
// The person runs the build themselves: reviews and merges wait in the band for them.
|
|
675
|
+
(w.check as any).harness = { autonomy: 'off' };
|
|
646
676
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
647
677
|
w.mergeExit = 1;
|
|
648
678
|
await start($, on, w);
|
|
@@ -657,6 +687,8 @@ test('ACT-4 a branch that fails verify is not merged, and the problems are shown
|
|
|
657
687
|
|
|
658
688
|
test('ACT-4 cancelling the confirmation merges nothing', async ($, on) => {
|
|
659
689
|
const w = project();
|
|
690
|
+
// The person runs the build themselves: reviews and merges wait in the band for them.
|
|
691
|
+
(w.check as any).harness = { autonomy: 'off' };
|
|
660
692
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
661
693
|
w.answer = 'Cancel';
|
|
662
694
|
await start($, on, w);
|
|
@@ -690,6 +722,8 @@ test('BAND-2 a failing finish check shows, with Lanes opening the pane', async (
|
|
|
690
722
|
|
|
691
723
|
test('ACT-3 the story shows as being reviewed until the lead has reported', async ($, on) => {
|
|
692
724
|
const w = project();
|
|
725
|
+
// The person runs the build themselves: reviews and merges wait in the band for them.
|
|
726
|
+
(w.check as any).harness = { autonomy: 'off' };
|
|
693
727
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
694
728
|
await start($, on, w);
|
|
695
729
|
const ui = await bandUi($);
|
|
@@ -1190,6 +1224,117 @@ test('HOLD-1 and HOLD-2 a lane installing a new package is held, and the band as
|
|
|
1190
1224
|
w.waiting.get('tu-h1')?.('timed out');
|
|
1191
1225
|
});
|
|
1192
1226
|
|
|
1227
|
+
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) => {
|
|
1228
|
+
const w = project();
|
|
1229
|
+
w.refusal = webInstall;
|
|
1230
|
+
w.stories[0] = { ...w.stories[0], requirements: ['BOOK-1'] };
|
|
1231
|
+
w.requirements = [{ id: 'BOOK-1', title: 'Request a booking', state: 'in progress' }];
|
|
1232
|
+
w.journal = {
|
|
1233
|
+
events: [
|
|
1234
|
+
{ at: 1, kind: 'request', text: 'You asked for “Bookings”: guests book a room online.' },
|
|
1235
|
+
{
|
|
1236
|
+
at: 2,
|
|
1237
|
+
kind: 'dispatch',
|
|
1238
|
+
story: 'ST-4',
|
|
1239
|
+
text: 'ST-4 Booking form (BOOK-1) was handed to the web lane.',
|
|
1240
|
+
},
|
|
1241
|
+
],
|
|
1242
|
+
};
|
|
1243
|
+
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
1244
|
+
const clock = await start($, on, w);
|
|
1245
|
+
await lanes($);
|
|
1246
|
+
await heldCall($, w, clock);
|
|
1247
|
+
const band = await bandUi($);
|
|
1248
|
+
expect(await band.find({ type: 'Text', text: 'for ST-4 Booking form (BOOK-1)' })).toBeDefined();
|
|
1249
|
+
expect((await band.find({ key: 'band-decide' }))?.props.hotkey).toBe('d');
|
|
1250
|
+
await press($, 'band-decide');
|
|
1251
|
+
await band.unmount();
|
|
1252
|
+
const ui = await pane($, PANE);
|
|
1253
|
+
const card = await ui.find({ key: 'decision-card' });
|
|
1254
|
+
expect(card?.text).toMatch(/ST-4 Booking form · web\/st-4/);
|
|
1255
|
+
expect(card?.text).toMatch(/BOOK-1 Request a booking/);
|
|
1256
|
+
expect(card?.text).toMatch(/npm install dayjs/);
|
|
1257
|
+
expect(card?.text).toMatch(/ST-4 Booking form \(BOOK-1\) was handed to the web lane/);
|
|
1258
|
+
expect(card?.text).toMatch(
|
|
1259
|
+
/Blocked: a new dependency \(dayjs\) needs a person's approval\. You haven't let the lead approve these itself\./,
|
|
1260
|
+
);
|
|
1261
|
+
expect((await ui.find({ key: 'decision-option-approveHeld' }))?.text).toMatch(
|
|
1262
|
+
/The held call runs now, and the agent carries on/,
|
|
1263
|
+
);
|
|
1264
|
+
await ui.unmount();
|
|
1265
|
+
w.waiting.get('tu-h1')?.('timed out');
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
test('DECIDE-3 an option on the card answers the decision, as the band would', async ($, on) => {
|
|
1269
|
+
const w = project();
|
|
1270
|
+
w.refusal = webInstall;
|
|
1271
|
+
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
1272
|
+
const clock = await start($, on, w);
|
|
1273
|
+
await lanes($);
|
|
1274
|
+
const { call } = await heldCall($, w, clock);
|
|
1275
|
+
const band = await bandUi($);
|
|
1276
|
+
await press($, 'band-decide');
|
|
1277
|
+
await band.unmount();
|
|
1278
|
+
const ui = await pane($, PANE);
|
|
1279
|
+
await press($, 'decide-refuseHeld', PANE);
|
|
1280
|
+
expect(refused(await call)).toBe(true);
|
|
1281
|
+
await ui.unmount();
|
|
1282
|
+
const after = await bandUi($);
|
|
1283
|
+
expect(await after.find({ key: 'band-decide' })).toBeUndefined();
|
|
1284
|
+
await after.unmount();
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
test('DECIDE-3 notes are labelled note, not you, and need no decision', async ($, on) => {
|
|
1288
|
+
const w = project();
|
|
1289
|
+
w.limits = [{ kind: 'five_hour', percentUsed: 91 }];
|
|
1290
|
+
await start($, on, w);
|
|
1291
|
+
const band = await bandUi($);
|
|
1292
|
+
expect(await band.find({ type: 'Text', text: 'you' })).toBeUndefined();
|
|
1293
|
+
expect(await band.find({ key: 'band-decide' })).toBeUndefined();
|
|
1294
|
+
await band.unmount();
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
test('JOURNAL-1 the Journal tab tells the lineage, from the request to now, with the loop and your choices in it', async ($, on) => {
|
|
1298
|
+
const w = project();
|
|
1299
|
+
w.journal = {
|
|
1300
|
+
events: [
|
|
1301
|
+
{
|
|
1302
|
+
at: Date.parse('2026-10-01T09:00:00Z'),
|
|
1303
|
+
kind: 'request',
|
|
1304
|
+
text: 'You asked for “Bookings”.',
|
|
1305
|
+
},
|
|
1306
|
+
{
|
|
1307
|
+
at: Date.parse('2026-10-01T09:30:00Z'),
|
|
1308
|
+
kind: 'dispatch',
|
|
1309
|
+
story: 'ST-4',
|
|
1310
|
+
text: 'ST-4 Booking form was handed to the web lane.',
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
at: Date.parse('2026-10-02T10:00:00Z'),
|
|
1314
|
+
kind: 'approval',
|
|
1315
|
+
who: 'person',
|
|
1316
|
+
text: 'You approved adding the package dayjs: “dates”.',
|
|
1317
|
+
},
|
|
1318
|
+
{
|
|
1319
|
+
at: Date.parse('2026-10-02T11:00:00Z'),
|
|
1320
|
+
kind: 'merge',
|
|
1321
|
+
story: 'ST-4',
|
|
1322
|
+
text: 'ST-4 Booking form was merged into main.',
|
|
1323
|
+
},
|
|
1324
|
+
],
|
|
1325
|
+
};
|
|
1326
|
+
await start($, on, w);
|
|
1327
|
+
await lanes($);
|
|
1328
|
+
const ui = await pane($, PANE);
|
|
1329
|
+
expect((await ui.find({ key: 'tab-journal' }))?.props.hotkey).toBe('3');
|
|
1330
|
+
await press($, 'tab-journal', PANE);
|
|
1331
|
+
const journal = await ui.find({ key: 'journal' });
|
|
1332
|
+
expect(journal?.text).toMatch(
|
|
1333
|
+
/2026-10-01[\s\S]*You asked for “Bookings”[\s\S]*handed to the web lane[\s\S]*2026-10-02[\s\S]*You approved adding the package dayjs[\s\S]*merged into main/,
|
|
1334
|
+
);
|
|
1335
|
+
await ui.unmount();
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1193
1338
|
test('HOLD-3 on Approve the approval is written as the pane writes it, and the same call runs', async ($, on) => {
|
|
1194
1339
|
const w = project();
|
|
1195
1340
|
w.refusal = webInstall;
|
|
@@ -1361,7 +1506,7 @@ test('LOOP-1 and LOOP-2 with the lead idle, the loop dispatches every ready stor
|
|
|
1361
1506
|
]);
|
|
1362
1507
|
await lanes($);
|
|
1363
1508
|
const ui = await pane($, PANE);
|
|
1364
|
-
expect((await ui.find({ key: 'step-0' }))?.text).toMatch(/dispatch
|
|
1509
|
+
expect((await ui.find({ key: 'loop-step-0' }))?.text).toMatch(/dispatch ST-9 ST-10 ST-11 ST-12/);
|
|
1365
1510
|
await ui.unmount();
|
|
1366
1511
|
});
|
|
1367
1512
|
|
|
@@ -1477,6 +1622,29 @@ test('LOOP-6 Pause stops new steps until Resume', async ($, on) => {
|
|
|
1477
1622
|
]);
|
|
1478
1623
|
});
|
|
1479
1624
|
|
|
1625
|
+
test('LOOP-6 the pause is recorded for other tools, and a new session starts with the loop running', async ($, on) => {
|
|
1626
|
+
const w = project();
|
|
1627
|
+
w.loopPaused = true; // left by an earlier session
|
|
1628
|
+
w.next = { step: 'review', args: 'ST-4', then: [] };
|
|
1629
|
+
const clock = await start($, on, w);
|
|
1630
|
+
expect(w.acts).toContainEqual(['loop', 'resume']);
|
|
1631
|
+
expect(w.loopPaused).toBe(false);
|
|
1632
|
+
await leadTurn($);
|
|
1633
|
+
await settle(clock);
|
|
1634
|
+
w.acts = [];
|
|
1635
|
+
let ui = await bandUi($);
|
|
1636
|
+
await press($, 'band-pause');
|
|
1637
|
+
await ui.unmount();
|
|
1638
|
+
expect(w.acts).toEqual([['loop', 'pause']]);
|
|
1639
|
+
ui = await bandUi($);
|
|
1640
|
+
await press($, 'band-resume');
|
|
1641
|
+
await ui.unmount();
|
|
1642
|
+
expect(w.acts).toEqual([
|
|
1643
|
+
['loop', 'pause'],
|
|
1644
|
+
['loop', 'resume'],
|
|
1645
|
+
]);
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1480
1648
|
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) => {
|
|
1481
1649
|
const w = project();
|
|
1482
1650
|
w.next = { step: 'wait', args: '', then: [] };
|
|
@@ -1515,6 +1683,51 @@ test('LOOP-3 a quiet lane agent is nudged at 5 minutes, restarted through the le
|
|
|
1515
1683
|
await band.unmount();
|
|
1516
1684
|
});
|
|
1517
1685
|
|
|
1686
|
+
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) => {
|
|
1687
|
+
const w = project();
|
|
1688
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
1689
|
+
w.agents = [
|
|
1690
|
+
{ id: 'a1', type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
|
|
1691
|
+
];
|
|
1692
|
+
const clock = await start($, on, w);
|
|
1693
|
+
await clock.advance(2000);
|
|
1694
|
+
// The agent hands its work back, but stays listed as running while it waits for messages.
|
|
1695
|
+
await $.turn.complete({
|
|
1696
|
+
turnId: 't-a1',
|
|
1697
|
+
agentId: 'a1',
|
|
1698
|
+
answer: 'ST-4 done.',
|
|
1699
|
+
durationMs: 1,
|
|
1700
|
+
isAborted: false,
|
|
1701
|
+
} as any);
|
|
1702
|
+
await clock.advance(11 * 60000);
|
|
1703
|
+
expect(w.sent).toEqual([]);
|
|
1704
|
+
expect(w.prompts).toEqual([]);
|
|
1705
|
+
// Another, whose story's branch is in the merge queue, isn't stalled either.
|
|
1706
|
+
w.agents = [
|
|
1707
|
+
{ id: 'a2', type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
|
|
1708
|
+
];
|
|
1709
|
+
w.queue = [{ branch: 'web/st-4', passed: '2026-10-11T00:00:00Z', state: 'waiting' }];
|
|
1710
|
+
await clock.advance(11 * 60000);
|
|
1711
|
+
expect(w.sent).toEqual([]);
|
|
1712
|
+
expect(w.prompts).toEqual([]);
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
test('LOOP-3 until the person has chosen how hands-off the loop is, a restart is offered, not taken', async ($, on) => {
|
|
1716
|
+
const w = project();
|
|
1717
|
+
(w.check as any).harnessSet = [];
|
|
1718
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
1719
|
+
w.agents = [
|
|
1720
|
+
{ id: 'a1', type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
|
|
1721
|
+
];
|
|
1722
|
+
const clock = await start($, on, w);
|
|
1723
|
+
await clock.advance(10 * 60000 + 4000);
|
|
1724
|
+
expect(w.prompts).toEqual([]);
|
|
1725
|
+
const band = await bandUi($);
|
|
1726
|
+
expect(await band.find({ type: 'Text', text: /Next: Restart ST-4/ })).toBeDefined();
|
|
1727
|
+
expect(await band.find({ key: 'band-keepGoing' })).toBeDefined();
|
|
1728
|
+
await band.unmount();
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1518
1731
|
test('LOOP-3 an agent waiting on a held call (or running a long command) is never quiet', async ($, on) => {
|
|
1519
1732
|
const w = project();
|
|
1520
1733
|
w.next = { step: 'wait', args: '', then: [] };
|
|
@@ -1675,6 +1888,161 @@ test("REVW-2 the reviewer's model is the setting's, registered again when it cha
|
|
|
1675
1888
|
|
|
1676
1889
|
// --- the merge queue --------------------------------------------------------------------------------
|
|
1677
1890
|
|
|
1891
|
+
// --- the code map and the trail -----------------------------------------------------------------
|
|
1892
|
+
|
|
1893
|
+
const storyMap = () => ({
|
|
1894
|
+
story: { id: 'ST-4', title: 'Booking form', lane: 'web', state: 'in progress' },
|
|
1895
|
+
layers: ['domain', 'services', 'tests'],
|
|
1896
|
+
files: [
|
|
1897
|
+
{
|
|
1898
|
+
path: 'apps/web/domain/booking.ts',
|
|
1899
|
+
layer: 'domain',
|
|
1900
|
+
lane: 'web',
|
|
1901
|
+
imports: [],
|
|
1902
|
+
adapters: {
|
|
1903
|
+
'context-graph': { card: { state: 'current', text: 'A booking and its rules.' } },
|
|
1904
|
+
},
|
|
1905
|
+
},
|
|
1906
|
+
{
|
|
1907
|
+
path: 'apps/web/services/book.ts',
|
|
1908
|
+
layer: 'services',
|
|
1909
|
+
lane: 'web',
|
|
1910
|
+
imports: ['apps/web/domain/booking.ts'],
|
|
1911
|
+
adapters: { 'context-graph': { card: { state: 'stale', text: 'Books a slot.' } } },
|
|
1912
|
+
},
|
|
1913
|
+
{
|
|
1914
|
+
path: 'apps/web/e2e/book.spec.ts',
|
|
1915
|
+
layer: 'tests',
|
|
1916
|
+
lane: 'web',
|
|
1917
|
+
imports: ['apps/web/services/book.ts'],
|
|
1918
|
+
adapters: { 'context-graph': { card: { state: 'missing', text: '' } } },
|
|
1919
|
+
},
|
|
1920
|
+
],
|
|
1921
|
+
open: { command: 'graph' },
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
test("MAP-1 the Code map shows the selected lane's story by layer, its imports, and the selected file's card", async ($, on) => {
|
|
1925
|
+
const w = project();
|
|
1926
|
+
w.map = storyMap();
|
|
1927
|
+
const clock = await start($, on, w);
|
|
1928
|
+
await lanes($);
|
|
1929
|
+
const ui = await pane($, PANE);
|
|
1930
|
+
await press($, 'tab-code', PANE);
|
|
1931
|
+
await clock.advance(1);
|
|
1932
|
+
expect((await ui.find({ key: 'map-story-ST-4' }))?.props.variant).toBe('primary');
|
|
1933
|
+
const drawn = (await ui.find({ key: 'code-map' }))?.text ?? '';
|
|
1934
|
+
for (const name of [
|
|
1935
|
+
'domain',
|
|
1936
|
+
'services',
|
|
1937
|
+
'tests',
|
|
1938
|
+
'booking.ts',
|
|
1939
|
+
'book.ts',
|
|
1940
|
+
'book.spec.ts',
|
|
1941
|
+
'✓ why written',
|
|
1942
|
+
'why out of date',
|
|
1943
|
+
'no why yet',
|
|
1944
|
+
])
|
|
1945
|
+
expect(drawn).toContain(name);
|
|
1946
|
+
// An import between neighbouring layers is drawn as a line.
|
|
1947
|
+
expect(drawn).toMatch(/[─┐┘└┌]/);
|
|
1948
|
+
expect((await ui.find({ key: 'code-file' }))?.text).toMatch(
|
|
1949
|
+
/apps\/web\/domain\/booking.ts.*A booking and its rules\..*imported by book.ts/s,
|
|
1950
|
+
);
|
|
1951
|
+
await press($, 'code-next', PANE);
|
|
1952
|
+
expect((await ui.find({ key: 'code-file' }))?.text).toMatch(
|
|
1953
|
+
/book.ts.*Books a slot\..*changed since its why was written.*imports booking.ts.*imported by book.spec.ts/s,
|
|
1954
|
+
);
|
|
1955
|
+
await press($, 'code-open', PANE);
|
|
1956
|
+
expect(w.commands).toContainEqual({ command: 'graph', args: 'apps/web/services/book.ts' });
|
|
1957
|
+
await ui.unmount();
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
test('MAP-2 a file a lane reads or edits glows in the code map, in the lane, and stops after a while', async ($, on) => {
|
|
1961
|
+
const w = project();
|
|
1962
|
+
w.map = storyMap();
|
|
1963
|
+
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
1964
|
+
const clock = await start($, on, w);
|
|
1965
|
+
await lanes($);
|
|
1966
|
+
const ui = await pane($, PANE);
|
|
1967
|
+
await press($, 'tab-code', PANE);
|
|
1968
|
+
await $.tool.call({ tool: 'Bash', tool_use_id: 'tu-w', command: 'ls', agentId: 'a1' } as any);
|
|
1969
|
+
await clock.advance(2000);
|
|
1970
|
+
await $.tool
|
|
1971
|
+
.call({
|
|
1972
|
+
tool: 'Read',
|
|
1973
|
+
tool_use_id: 'tu-r',
|
|
1974
|
+
file_path: '/repo/.claude/worktrees/agent-a1/apps/web/services/book.ts',
|
|
1975
|
+
agentId: 'a1',
|
|
1976
|
+
} as any)
|
|
1977
|
+
.catch(() => {});
|
|
1978
|
+
await clock.advance(1);
|
|
1979
|
+
expect((await ui.find({ key: 'code-map' }))?.text).toContain('web reading');
|
|
1980
|
+
// The box fills with the lane's colour, the status written over it.
|
|
1981
|
+
const filled = (await ui.findAll({ type: 'Text' })).find((t: any) => /web reading/.test(t.text));
|
|
1982
|
+
expect(filled?.props.backgroundColor).toMatch(/^#[0-9a-f]{6}$/);
|
|
1983
|
+
expect(await ui.find({ key: 'code-legend' })).toBeDefined();
|
|
1984
|
+
await clock.advance(30000);
|
|
1985
|
+
expect((await ui.find({ key: 'code-map' }))?.text).not.toContain('web reading');
|
|
1986
|
+
await ui.unmount();
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
test('TRAIL-1 the Trail tab follows each requirement of the spec to its stories, code and tests, with gaps in red', async ($, on) => {
|
|
1990
|
+
const w = project();
|
|
1991
|
+
w.requirements = [
|
|
1992
|
+
{
|
|
1993
|
+
id: 'BOOK-1',
|
|
1994
|
+
title: 'Request',
|
|
1995
|
+
file: 'docs/specs/01-booking.md',
|
|
1996
|
+
state: 'in progress',
|
|
1997
|
+
stories: ['ST-4'],
|
|
1998
|
+
tests: ['apps/web/e2e/book.spec.ts'],
|
|
1999
|
+
},
|
|
2000
|
+
{
|
|
2001
|
+
id: 'BOOK-2',
|
|
2002
|
+
title: 'Cancel',
|
|
2003
|
+
file: 'docs/specs/01-booking.md',
|
|
2004
|
+
state: 'no story',
|
|
2005
|
+
stories: [],
|
|
2006
|
+
tests: [],
|
|
2007
|
+
},
|
|
2008
|
+
];
|
|
2009
|
+
w.trail = {
|
|
2010
|
+
base: 'main',
|
|
2011
|
+
requirements: [
|
|
2012
|
+
{
|
|
2013
|
+
id: 'BOOK-1',
|
|
2014
|
+
title: 'Request',
|
|
2015
|
+
spec: '01-booking.md',
|
|
2016
|
+
state: 'in progress',
|
|
2017
|
+
stories: [{ id: 'ST-4', state: 'in progress', lane: 'web' }],
|
|
2018
|
+
files: ['apps/web/services/book.ts'],
|
|
2019
|
+
tests: ['apps/web/e2e/book.spec.ts'],
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
id: 'BOOK-2',
|
|
2023
|
+
title: 'Cancel',
|
|
2024
|
+
spec: '01-booking.md',
|
|
2025
|
+
state: 'no story',
|
|
2026
|
+
stories: [],
|
|
2027
|
+
files: [],
|
|
2028
|
+
tests: [],
|
|
2029
|
+
},
|
|
2030
|
+
],
|
|
2031
|
+
};
|
|
2032
|
+
const clock = await start($, on, w);
|
|
2033
|
+
await lanes($);
|
|
2034
|
+
const ui = await pane($, PANE);
|
|
2035
|
+
await press($, 'tab-map', PANE);
|
|
2036
|
+
await clock.advance(1);
|
|
2037
|
+
expect((await ui.find({ key: 'trail-BOOK-1' }))?.text).toMatch(
|
|
2038
|
+
/BOOK-1\s*Request\s*─+\s*● ST-4\s*─+\s*1 file\s*─+\s*✓ book\.spec\.ts/,
|
|
2039
|
+
);
|
|
2040
|
+
const gap = await ui.find({ key: 'trail-BOOK-2' });
|
|
2041
|
+
expect(gap?.text).toMatch(/BOOK-2.*╌+\s*no story.*╌+\s*no code.*╌+\s*no test names it/);
|
|
2042
|
+
expect((await ui.find({ key: 'map-files' }))?.text).toMatch(/book.ts/);
|
|
2043
|
+
await ui.unmount();
|
|
2044
|
+
});
|
|
2045
|
+
|
|
1678
2046
|
test('MQ-2 where the lead merges, the loop prompts it to merge the head of the queue', async ($, on) => {
|
|
1679
2047
|
const w = project();
|
|
1680
2048
|
w.next = { step: 'merge', args: 'web/st-4', then: [] };
|
|
@@ -1698,18 +2066,31 @@ test('MQ-2 where the person merges, the head of the queue waits in the band for
|
|
|
1698
2066
|
expect(w.opened).toContain(RESULT);
|
|
1699
2067
|
});
|
|
1700
2068
|
|
|
1701
|
-
test(
|
|
2069
|
+
test("SETUP-2 with no rules on the base yet, the lead puts them there under autonomy: the band doesn't ask the person to merge", async ($, on) => {
|
|
1702
2070
|
const w = project();
|
|
1703
2071
|
(w.check as any).firstMerge = true;
|
|
1704
2072
|
w.queue = [{ branch: 'lead/st-1', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
|
|
2073
|
+
w.next = { step: 'rules', args: 'main', then: [] };
|
|
1705
2074
|
await start($, on, w);
|
|
1706
2075
|
const ui = await bandUi($);
|
|
1707
|
-
expect(
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
2076
|
+
expect(await ui.find({ type: 'Text', text: /next to merge/ })).toBeUndefined();
|
|
2077
|
+
await ui.unmount();
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
test("SETUP-2 with the loop off, the merge is the person's, and its card says how the rules get onto main", async ($, on) => {
|
|
2081
|
+
const w = project();
|
|
2082
|
+
(w.check as any).firstMerge = true;
|
|
2083
|
+
(w.check as any).harness = { autonomy: 'off' };
|
|
2084
|
+
w.queue = [{ branch: 'lead/st-1', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
|
|
2085
|
+
await start($, on, w);
|
|
2086
|
+
const band = await bandUi($);
|
|
2087
|
+
expect(await band.find({ type: 'Text', text: 'lead/st-1 is next to merge' })).toBeDefined();
|
|
2088
|
+
await press($, 'band-decide');
|
|
2089
|
+
await band.unmount();
|
|
2090
|
+
const ui = await pane($, PANE);
|
|
2091
|
+
expect((await ui.find({ key: 'decision-card' }))?.text).toMatch(
|
|
2092
|
+
/code-kit queue rules puts them there/,
|
|
2093
|
+
);
|
|
1713
2094
|
await ui.unmount();
|
|
1714
2095
|
});
|
|
1715
2096
|
|
|
@@ -1784,6 +2165,46 @@ test('VIEW-1 the header counts the stories by state, and a count filters the lan
|
|
|
1784
2165
|
await ui.unmount();
|
|
1785
2166
|
});
|
|
1786
2167
|
|
|
2168
|
+
test('MISSION-1 the band shows the goal, what the lead is doing, the next step and each agent at work', async ($, on) => {
|
|
2169
|
+
const w = project();
|
|
2170
|
+
w.stories = w.stories.map((x: any) => ({ ...x, milestone: 'Milestone 2: Bookings' }));
|
|
2171
|
+
w.stories[0] = { ...w.stories[0], state: 'done' };
|
|
2172
|
+
w.next = { step: 'review', args: 'ST-9', then: [{ step: 'dispatch', args: 'ST-10' }] };
|
|
2173
|
+
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
2174
|
+
const clock = await start($, on, w);
|
|
2175
|
+
await leadTurn($);
|
|
2176
|
+
await settle(clock);
|
|
2177
|
+
await $.tool.call({
|
|
2178
|
+
tool: 'Bash',
|
|
2179
|
+
tool_use_id: 'tu-m',
|
|
2180
|
+
command: 'npm run test:e2e',
|
|
2181
|
+
agentId: 'a1',
|
|
2182
|
+
} as any);
|
|
2183
|
+
await $.turn.start({ turnId: 't-busy', text: '' } as any);
|
|
2184
|
+
await clock.advance(3000);
|
|
2185
|
+
const ui = await bandUi($);
|
|
2186
|
+
const rows = (await ui.findAll({ type: 'Text' })).map((t: any) => t.text);
|
|
2187
|
+
expect(rows).toContain('Milestone 2: Bookings · 1 of 4 stories done');
|
|
2188
|
+
expect(rows.some((t: string) => /^lead at work · loop on · last: review ST-9$/.test(t))).toBe(
|
|
2189
|
+
true,
|
|
2190
|
+
);
|
|
2191
|
+
expect(rows).toContain('Review ST-9, then Dispatch ST-10');
|
|
2192
|
+
expect(rows).toContain('web ');
|
|
2193
|
+
expect(rows).toContain('Bash npm run test:e2e');
|
|
2194
|
+
expect(await ui.find({ key: 'band-pause' })).toBeDefined();
|
|
2195
|
+
await ui.unmount();
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
test('MISSION-1 with nothing happening the band has no mission rows', async ($, on) => {
|
|
2199
|
+
const w = project();
|
|
2200
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
2201
|
+
await start($, on, w);
|
|
2202
|
+
const ui = await bandUi($);
|
|
2203
|
+
const rows = (await ui.findAll({ type: 'Text' })).map((t: any) => t.text);
|
|
2204
|
+
expect(rows.some((t: string) => /^(goal|now|next) +$/.test(t))).toBe(false);
|
|
2205
|
+
await ui.unmount();
|
|
2206
|
+
});
|
|
2207
|
+
|
|
1787
2208
|
test("VIEW-4 each lane shows its agent's last tool call and the time since, amber once quiet", async ($, on) => {
|
|
1788
2209
|
const w = project();
|
|
1789
2210
|
w.next = { step: 'wait', args: '', then: [] };
|
|
@@ -1817,7 +2238,9 @@ test('VIEW-5 what needs the person is pinned at the top with its letter: a held
|
|
|
1817
2238
|
await heldCall($, w, clock);
|
|
1818
2239
|
await lanes($);
|
|
1819
2240
|
const ui = await pane($, PANE);
|
|
1820
|
-
expect((await ui.find({ key: 'need-held' }))?.text).toMatch(
|
|
2241
|
+
expect((await ui.find({ key: 'need-held' }))?.text).toMatch(
|
|
2242
|
+
/web wants to install dayjs · 2:00for ST-4 Booking form/,
|
|
2243
|
+
);
|
|
1821
2244
|
expect((await ui.find({ key: 'need-approveHeld' }))?.props.hotkey).toBe('a');
|
|
1822
2245
|
await press($, 'need-approveHeld', PANE);
|
|
1823
2246
|
expect(w.opened).toContain(APPROVE);
|
|
@@ -1825,13 +2248,14 @@ test('VIEW-5 what needs the person is pinned at the top with its letter: a held
|
|
|
1825
2248
|
w.waiting.get('tu-h1')?.('timed out');
|
|
1826
2249
|
});
|
|
1827
2250
|
|
|
1828
|
-
test('VIEW-6 tabs on 1 to
|
|
2251
|
+
test('VIEW-6 tabs on 1 to 7, j and k move between lanes, and the selected lane has its keys', async ($, on) => {
|
|
1829
2252
|
const w = project();
|
|
1830
2253
|
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
1831
2254
|
await start($, on, w);
|
|
1832
2255
|
await lanes($);
|
|
1833
2256
|
const ui = await pane($, PANE);
|
|
1834
|
-
expect((await ui.find({ key: 'tab-queue' }))?.props.hotkey).toBe('
|
|
2257
|
+
expect((await ui.find({ key: 'tab-queue' }))?.props.hotkey).toBe('6');
|
|
2258
|
+
expect((await ui.find({ key: 'tab-code' }))?.props.hotkey).toBe('4');
|
|
1835
2259
|
await press($, 'tab-queue', PANE);
|
|
1836
2260
|
expect(await ui.find({ type: 'Text', text: 'The merge queue is empty.' })).toBeDefined();
|
|
1837
2261
|
await press($, 'tab-lanes', PANE);
|
|
@@ -2010,7 +2434,8 @@ test('TRACE-1 the map: a cell per requirement, a row per spec, and a legend with
|
|
|
2010
2434
|
await start($, on, w);
|
|
2011
2435
|
await lanes($);
|
|
2012
2436
|
const ui = await pane($, PANE);
|
|
2013
|
-
expect((await ui.find({ key: 'tab-map' }))?.props.
|
|
2437
|
+
expect((await ui.find({ key: 'tab-map' }))?.props.label).toBe('Trail');
|
|
2438
|
+
expect((await ui.find({ key: 'tab-map' }))?.props.hotkey).toBe('5');
|
|
2014
2439
|
await press($, 'tab-map', PANE);
|
|
2015
2440
|
const legend = (await ui.find({ key: 'map-legend' }))?.text ?? '';
|
|
2016
2441
|
expect(legend).toMatch(/■ 20 done and tested/);
|
|
@@ -2227,12 +2652,13 @@ test('JOIN-2 each lane shows its edits without understanding, red above zero, an
|
|
|
2227
2652
|
await lanes($);
|
|
2228
2653
|
const ui = await pane($, PANE);
|
|
2229
2654
|
expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(
|
|
2230
|
-
/1
|
|
2655
|
+
/1 edited without understanding\s*2 cards owed/,
|
|
2231
2656
|
);
|
|
2232
|
-
expect(
|
|
2233
|
-
'
|
|
2234
|
-
);
|
|
2235
|
-
|
|
2657
|
+
expect(
|
|
2658
|
+
(await ui.find({ type: 'Text', text: '1 edited without understanding' }))?.props.color,
|
|
2659
|
+
).toBe('red');
|
|
2660
|
+
// LANES-2: a lane whose facts are all zero shows none of them.
|
|
2661
|
+
expect((await ui.find({ key: 'lane-api' }))?.text).not.toMatch(/understanding|owed/);
|
|
2236
2662
|
await ui.unmount();
|
|
2237
2663
|
});
|
|
2238
2664
|
|
|
@@ -2410,9 +2836,13 @@ test('VIEW-6 a selected idle lane shows what it owns and its stories, Enter open
|
|
|
2410
2836
|
expect((await ui.find({ key: 'count-idle' }))?.props.label).toMatch(/· \d+ idle/);
|
|
2411
2837
|
for (let i = 0; i < 3; i++) await press($, 'lane-next', PANE);
|
|
2412
2838
|
const api = (await ui.find({ key: 'lane-api' }))?.text ?? '';
|
|
2413
|
-
expect(api).toMatch(/owns
|
|
2414
|
-
expect(api).toMatch(/
|
|
2415
|
-
|
|
2839
|
+
expect(api).toMatch(/owns\s*apps\/api\/\*\*/);
|
|
2840
|
+
expect(api).toMatch(/agent\s*api-engineer/);
|
|
2841
|
+
// LANES-3: the lane's stories as cards.
|
|
2842
|
+
expect((await ui.find({ key: 'card-ST-1' }))?.text).toMatch(/ST-1\s*merged\s*Schema/);
|
|
2843
|
+
expect((await ui.find({ key: 'card-ST-2' }))?.text).toMatch(
|
|
2844
|
+
/ST-2\s*blocked\s*Endpoints.*waits on ST-9/,
|
|
2845
|
+
);
|
|
2416
2846
|
expect((await ui.find({ key: 'open-story' }))?.props.label).toBe('Open ST-2');
|
|
2417
2847
|
await press($, 'open-story', PANE);
|
|
2418
2848
|
expect(await ui.find({ key: 'story-head' })).toBeDefined();
|
|
@@ -2431,7 +2861,7 @@ test('VIEW-6 the filter is one row, its key beside the box', async ($, on) => {
|
|
|
2431
2861
|
await ui.unmount();
|
|
2432
2862
|
});
|
|
2433
2863
|
|
|
2434
|
-
test('the lead reads
|
|
2864
|
+
test('the lead reads orchestrating while its turn runs, and idle once it ends; an agent at work outside a story too', async ($, on) => {
|
|
2435
2865
|
const w = project();
|
|
2436
2866
|
w.agents = [
|
|
2437
2867
|
{ id: 'a9', type: 'api-engineer', description: 'look at the logs', status: 'running' },
|
|
@@ -2441,8 +2871,9 @@ test('the lead reads working while its turn runs, and idle once it ends; an agen
|
|
|
2441
2871
|
const ui = await pane($, PANE);
|
|
2442
2872
|
expect((await ui.find({ key: 'lane-api' }))?.text).toMatch(/api\s*working/);
|
|
2443
2873
|
await $.turn.start({ turnId: 't-1', text: '' } as any);
|
|
2444
|
-
|
|
2445
|
-
expect((await ui.find({ key: '
|
|
2874
|
+
// LANES-2: the lead mid-turn is orchestrating, not a lane at work on nothing.
|
|
2875
|
+
expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/lead\s*orchestrating/);
|
|
2876
|
+
expect((await ui.find({ key: 'count-working' }))?.props.label).toBe('▶ 1 working');
|
|
2446
2877
|
await $.turn.complete({ turnId: 't-1', answer: '', durationMs: 1, isAborted: false } as any);
|
|
2447
2878
|
await settle(clock);
|
|
2448
2879
|
expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/lead\s*idle/);
|