@warren-dean/code-kit 0.6.0 → 0.8.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 +62 -0
- package/README.md +31 -10
- package/bin/code-kit.mjs +447 -39
- package/hooks/guard-bash.mjs +54 -16
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +87 -19
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +43 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +2 -2
- package/hooks/lib/journal.mjs +224 -0
- package/hooks/lib/loop-state.mjs +26 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +37 -1
- package/hooks/lib/queue.mjs +75 -0
- package/hooks/lib/reviews.mjs +18 -0
- package/hooks/lib/rules.mjs +8 -4
- package/hooks/lib/shell.mjs +1 -1
- package/hooks/lib/storymap.mjs +155 -0
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/codemap.mjs +328 -0
- package/hooks/mod/decisions.mjs +146 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/mission.mjs +116 -0
- package/hooks/mod/panes.mjs +1378 -0
- package/hooks/mod/register.mjs +1172 -15
- package/hooks/mod/register.test.ts +1868 -15
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/trail.mjs +152 -0
- package/hooks/mod/view.mjs +658 -56
- package/package.json +1 -1
- package/skills/init/SKILL.md +6 -5
- package/skills/review/SKILL.md +8 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// which `npm test` runs when it's available. The CLI, git and the agents are stubbed with what they
|
|
3
3
|
// report; the CLI itself is tested in test/hooks.test.mjs, and the drawing in test/units.test.mjs.
|
|
4
4
|
import { expect, mock, test } from 'claude-code/testing';
|
|
5
|
+
import { rasterCells, sprite, tintOf } from './art.mjs';
|
|
5
6
|
|
|
6
7
|
const PANE = 'code-kit-lanes';
|
|
7
8
|
const APPROVE = 'code-kit-approve';
|
|
@@ -17,6 +18,7 @@ function project() {
|
|
|
17
18
|
valid: true,
|
|
18
19
|
problems: [] as string[],
|
|
19
20
|
lead: { paths: ['docs/**'] },
|
|
21
|
+
harnessSet: ['autonomy'] as string[], // the person chose autonomy (the default, autonomous)
|
|
20
22
|
lanes: {
|
|
21
23
|
web: { agent: 'web-engineer', paths: ['apps/web/**'] },
|
|
22
24
|
api: { agent: 'api-engineer', paths: ['apps/api/**'] },
|
|
@@ -92,6 +94,68 @@ function project() {
|
|
|
92
94
|
{ key: 'agents.reviewer.model', value: null, default: null, about: "The reviewer's model" },
|
|
93
95
|
] as any[],
|
|
94
96
|
setExit: 0,
|
|
97
|
+
allowed: false, // an approval in force: the hooks beneath let the call through
|
|
98
|
+
bashRuns: 0,
|
|
99
|
+
delegateExit: 1,
|
|
100
|
+
delegateRefusal:
|
|
101
|
+
'This project delegates no approvals to the lead ("approvals.delegate" isn\'t set). Ask the person.',
|
|
102
|
+
delegated: [] as string[][],
|
|
103
|
+
holds: [] as string[][],
|
|
104
|
+
draft: '', // what the person has typed in the prompt
|
|
105
|
+
heads: { 'web/st-4': 'abc123def4567890\n' } as Record<string, string>,
|
|
106
|
+
registered: [] as any[],
|
|
107
|
+
queue: [] as any[],
|
|
108
|
+
requirements: [] as any[],
|
|
109
|
+
times: [] as any[],
|
|
110
|
+
blits: [] as string[],
|
|
111
|
+
adapters: [] as any[],
|
|
112
|
+
agentsThrow: false,
|
|
113
|
+
registerFails: 0,
|
|
114
|
+
commands: [] as any[],
|
|
115
|
+
story: {
|
|
116
|
+
id: 'ST-4',
|
|
117
|
+
title: 'Booking form',
|
|
118
|
+
lane: 'web',
|
|
119
|
+
state: 'review',
|
|
120
|
+
base: 'main',
|
|
121
|
+
branch: 'web/st-4',
|
|
122
|
+
worktree: null,
|
|
123
|
+
requirements: [
|
|
124
|
+
{
|
|
125
|
+
id: 'BOOK-1',
|
|
126
|
+
title: 'Request a booking',
|
|
127
|
+
spec: 'docs/spec/01.md',
|
|
128
|
+
state: 'in progress',
|
|
129
|
+
tests: [],
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
specCheck: {
|
|
133
|
+
file: '.claude/state/spec-check/web_st-4.md',
|
|
134
|
+
rows: [{ requirement: 'BOOK-1', status: 'partial', where: 'apps/web/form.tsx', note: '' }],
|
|
135
|
+
},
|
|
136
|
+
diff: {
|
|
137
|
+
files: [
|
|
138
|
+
{ path: 'apps/web/form.tsx', added: 2, removed: 0 },
|
|
139
|
+
{ path: 'apps/web/api.ts', added: 1, removed: 0 },
|
|
140
|
+
],
|
|
141
|
+
text:
|
|
142
|
+
'diff --git a/apps/web/form.tsx b/apps/web/form.tsx\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/apps/web/form.tsx\n@@ -0,0 +1,2 @@\n+export const form = 1;\n+export const x = 2;\n' +
|
|
143
|
+
'diff --git a/apps/web/api.ts b/apps/web/api.ts\nindex 2222222..3333333 100644\n--- a/apps/web/api.ts\n+++ b/apps/web/api.ts\n@@ -1 +1,2 @@\n a\n+b\n',
|
|
144
|
+
truncated: false,
|
|
145
|
+
},
|
|
146
|
+
verify: {
|
|
147
|
+
checks: false,
|
|
148
|
+
where: 'a temporary worktree',
|
|
149
|
+
problems: { Ownership: ["apps/api/x.ts: not the web lane's"], Checks: [] },
|
|
150
|
+
},
|
|
151
|
+
} as any,
|
|
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`
|
|
157
|
+
holdWaits: false, // false: the hold ends at once, as if timed out (tests about cards, not hold)
|
|
158
|
+
waiting: new Map<string, (answer: string) => void>(),
|
|
95
159
|
};
|
|
96
160
|
}
|
|
97
161
|
type World = ReturnType<typeof project>;
|
|
@@ -104,6 +168,7 @@ function stub(on: any, w: World) {
|
|
|
104
168
|
value: { exitCode, stdout, stderr },
|
|
105
169
|
});
|
|
106
170
|
const sub = argv[2];
|
|
171
|
+
if (argv[0] === 'git' && argv[1] === 'rev-parse') return ran(0, w.heads[argv.at(-1)!] ?? '');
|
|
107
172
|
if (argv[0] === 'git') return ran(0, w.refs);
|
|
108
173
|
if (sub === 'check') return ran(w.check.valid ? 0 : 1, JSON.stringify(w.check));
|
|
109
174
|
if (sub === 'status')
|
|
@@ -112,7 +177,7 @@ function stub(on: any, w: World) {
|
|
|
112
177
|
JSON.stringify({
|
|
113
178
|
base: 'main',
|
|
114
179
|
stories: w.stories,
|
|
115
|
-
requirements:
|
|
180
|
+
requirements: w.requirements,
|
|
116
181
|
problems: [],
|
|
117
182
|
drift: [],
|
|
118
183
|
}),
|
|
@@ -122,9 +187,58 @@ function stub(on: any, w: World) {
|
|
|
122
187
|
if (sub === 'verify')
|
|
123
188
|
return ran(w.verifyExit, w.verifyExit ? '' : w.verifyOut, w.verifyExit ? w.verifyOut : '');
|
|
124
189
|
if (sub === 'stops') return ran(0, JSON.stringify(w.stops));
|
|
190
|
+
if (sub === 'story') return ran(0, JSON.stringify(w.story));
|
|
191
|
+
if (sub === 'adapters') return ran(0, JSON.stringify(w.adapters));
|
|
192
|
+
if (sub === 'timeline')
|
|
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 }));
|
|
203
|
+
if (sub === 'sent-back') {
|
|
204
|
+
w.acts.push([...argv.slice(2)]);
|
|
205
|
+
return ran(0, `Recorded: ${argv[3]} was sent back.`);
|
|
206
|
+
}
|
|
207
|
+
if (sub === 'queue' && argv[3] === 'merge') {
|
|
208
|
+
w.acts.push([...argv.slice(2)]);
|
|
209
|
+
w.queue = w.queue.map((e, i) => (i === 0 ? { ...e, state: 'merged' } : e));
|
|
210
|
+
return ran(0, 'Merged web/st-4 into main: verify passed on 2 file(s). The queue is empty.');
|
|
211
|
+
}
|
|
212
|
+
if (sub === 'queue') return ran(0, JSON.stringify(w.queue));
|
|
213
|
+
if (sub === 'approve' && argv.includes('--delegated')) {
|
|
214
|
+
w.delegated.push([...argv.slice(2)]);
|
|
215
|
+
if (w.delegateExit === 0) w.allowed = true;
|
|
216
|
+
return ran(w.delegateExit, '', w.delegateExit ? w.delegateRefusal : '');
|
|
217
|
+
}
|
|
218
|
+
if (sub === 'hold') {
|
|
219
|
+
const id = argv[3];
|
|
220
|
+
const at = argv.indexOf('--answer');
|
|
221
|
+
if (at > 0) {
|
|
222
|
+
w.acts.push([...argv.slice(2)]);
|
|
223
|
+
w.waiting.get(id)?.(argv[at + 1]);
|
|
224
|
+
return ran(0, '');
|
|
225
|
+
}
|
|
226
|
+
w.holds.push([...argv.slice(2)]);
|
|
227
|
+
if (!w.holdWaits) return ran(0, 'timed out\n');
|
|
228
|
+
// Waits, as `code-kit hold` does, until the band answers or the test times it out.
|
|
229
|
+
return new Promise((done) =>
|
|
230
|
+
w.waiting.set(id, (answer) => {
|
|
231
|
+
w.waiting.delete(id);
|
|
232
|
+
done(ran(0, `${answer}\n`));
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
125
236
|
if (sub === 'approve') {
|
|
126
237
|
w.acts.push([...argv.slice(2)]);
|
|
127
|
-
if (w.approveExit === 0)
|
|
238
|
+
if (w.approveExit === 0) {
|
|
239
|
+
w.requests = [];
|
|
240
|
+
w.allowed = true;
|
|
241
|
+
}
|
|
128
242
|
return ran(w.approveExit, '', w.approveExit ? 'approve needs --reason' : '');
|
|
129
243
|
}
|
|
130
244
|
if (sub === 'merge') {
|
|
@@ -139,9 +253,13 @@ function stub(on: any, w: World) {
|
|
|
139
253
|
return ran(
|
|
140
254
|
1,
|
|
141
255
|
'',
|
|
142
|
-
'"harness.hold.minutes" must be a number of minutes from 0 (off) to
|
|
256
|
+
'"harness.hold.minutes" must be a number of minutes from 0 (off) to 10\nNothing was changed.',
|
|
143
257
|
);
|
|
144
258
|
const row = w.settings.find((s) => s.key === argv[4]);
|
|
259
|
+
if (argv[4] === 'autonomy') {
|
|
260
|
+
(w.check as any).harness = { ...(w.check as any).harness, autonomy: argv[5] };
|
|
261
|
+
w.check.harnessSet = [...new Set([...w.check.harnessSet, 'autonomy'])];
|
|
262
|
+
}
|
|
145
263
|
const raw = argv[5];
|
|
146
264
|
row.value =
|
|
147
265
|
raw === 'null'
|
|
@@ -168,10 +286,31 @@ function stub(on: any, w: World) {
|
|
|
168
286
|
on('tool.call', { tool: 'Write' }, () =>
|
|
169
287
|
w.refusal ? { deny: w.refusal } : { result: { type: 'create' } },
|
|
170
288
|
);
|
|
171
|
-
on('tool.call', { tool: 'Bash' }, () =>
|
|
172
|
-
w.
|
|
173
|
-
|
|
174
|
-
|
|
289
|
+
on('tool.call', { tool: 'Bash' }, () => {
|
|
290
|
+
w.bashRuns += 1;
|
|
291
|
+
return w.refusal && !w.allowed
|
|
292
|
+
? { deny: w.refusal }
|
|
293
|
+
: { result: { stdout: 'added 1 package', stderr: '', interrupted: false } };
|
|
294
|
+
});
|
|
295
|
+
on('agent.list', () => {
|
|
296
|
+
if (w.agentsThrow) throw new Error('no session is bound in this process');
|
|
297
|
+
return { value: w.agents };
|
|
298
|
+
});
|
|
299
|
+
on('agent.register', ($: any, e: any) => {
|
|
300
|
+
if (w.registerFails > 0) {
|
|
301
|
+
w.registerFails -= 1;
|
|
302
|
+
throw new Error('no session is bound in this process');
|
|
303
|
+
}
|
|
304
|
+
w.registered.push(e);
|
|
305
|
+
return { value: { agent: `code-kit:${e.name}` } };
|
|
306
|
+
});
|
|
307
|
+
on('prompt.read', () => ({ value: { text: w.draft, cursor: w.draft.length } }));
|
|
308
|
+
on('session.send', ($: any, e: any) => {
|
|
309
|
+
w.sent.push({ to: e.to, text: e.text });
|
|
310
|
+
return { isDelivered: true };
|
|
311
|
+
});
|
|
312
|
+
on('turn.start', ($: any, e: any) => e);
|
|
313
|
+
on('turn.complete', () => ({ text: '' }));
|
|
175
314
|
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: w.limits } }));
|
|
176
315
|
on('session.measure', ($: any, e: any) => ({ changed: e.changed }));
|
|
177
316
|
// A model request, answered with the usage the test gave it.
|
|
@@ -200,6 +339,15 @@ function stub(on: any, w: World) {
|
|
|
200
339
|
on('tool.call', { tool: 'AskUserQuestion' }, ($: any, e: any) => ({
|
|
201
340
|
result: { questions: e.questions, answers: { [e.questions[0].question]: w.answer } },
|
|
202
341
|
}));
|
|
342
|
+
// Another plugin's command, as the mod asks for it (Context Graph's /graph).
|
|
343
|
+
on('command.run', ($: any, e: any) => {
|
|
344
|
+
w.commands.push({ command: e.command, args: e.args });
|
|
345
|
+
return { text: '' };
|
|
346
|
+
});
|
|
347
|
+
on('ui.blit', ($: any, e: any) => {
|
|
348
|
+
w.blits.push(e.key);
|
|
349
|
+
return { value: undefined };
|
|
350
|
+
});
|
|
203
351
|
on('ui.panes', () => ({ value: [...w.open].map((id) => ({ id, title: id, isShown: true })) }));
|
|
204
352
|
on('ui.open', ($: any, e: any) => {
|
|
205
353
|
w.opened.push(e.id);
|
|
@@ -238,10 +386,10 @@ async function request($: any, agentId: string | undefined, thousands: number) {
|
|
|
238
386
|
}
|
|
239
387
|
|
|
240
388
|
/** A session started on the project, with the mock clock. */
|
|
241
|
-
async function start($: any, on: any, w: World) {
|
|
389
|
+
async function start($: any, on: any, w: World, interactive = true) {
|
|
242
390
|
stub(on, w);
|
|
243
391
|
const clock = mock.clock(on);
|
|
244
|
-
await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive:
|
|
392
|
+
await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: interactive });
|
|
245
393
|
await clock.advance(2000); // the first look at the project
|
|
246
394
|
return clock;
|
|
247
395
|
}
|
|
@@ -304,7 +452,6 @@ test('PANE-2 each lane shows its agent, story, branch and state', async ($, on)
|
|
|
304
452
|
await lanes($);
|
|
305
453
|
const ui = await pane($, PANE);
|
|
306
454
|
expect(await ui.find({ key: 'lane-web' })).toBeDefined();
|
|
307
|
-
expect(await ui.find({ type: 'Text', text: /web-engineer/ })).toBeDefined();
|
|
308
455
|
expect(await ui.find({ type: 'Text', text: 'ST-4 Booking form · web/st-4' })).toBeDefined();
|
|
309
456
|
expect(await ui.find({ type: 'Text', text: /building/ })).toBeDefined();
|
|
310
457
|
expect(await ui.find({ type: 'Text', text: /idle/ })).toBeDefined();
|
|
@@ -349,9 +496,10 @@ test('PANE-4 with nothing changed it reads the project every 10 seconds while th
|
|
|
349
496
|
const before = w.runs;
|
|
350
497
|
await clock.advance(8000); // four quiet ticks: git only
|
|
351
498
|
expect(w.runs - before).toBe(4);
|
|
352
|
-
// The fifth reads it all (check, status, next, requests, stops) between two looks
|
|
499
|
+
// The fifth reads it all (check, status, next, requests, stops, queue, timeline) between two looks
|
|
500
|
+
// at git.
|
|
353
501
|
await clock.advance(2000);
|
|
354
|
-
expect(w.runs - before).toBe(
|
|
502
|
+
expect(w.runs - before).toBe(13);
|
|
355
503
|
});
|
|
356
504
|
|
|
357
505
|
test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
|
|
@@ -479,8 +627,20 @@ test('ACT-1 a grant that fails approves nothing, and the band says why', async (
|
|
|
479
627
|
await after.unmount();
|
|
480
628
|
});
|
|
481
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
|
+
|
|
482
640
|
test('ACT-3 Review asks the lead to run the review skill on the branch', async ($, on) => {
|
|
483
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' };
|
|
484
644
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
485
645
|
await start($, on, w);
|
|
486
646
|
const ui = await bandUi($);
|
|
@@ -495,6 +655,8 @@ test('ACT-3 Review asks the lead to run the review skill on the branch', async (
|
|
|
495
655
|
|
|
496
656
|
test('ACT-4 Merge confirms, then merges as the person', async ($, on) => {
|
|
497
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' };
|
|
498
660
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
499
661
|
await start($, on, w);
|
|
500
662
|
const ui = await bandUi($);
|
|
@@ -509,6 +671,8 @@ test('ACT-4 Merge confirms, then merges as the person', async ($, on) => {
|
|
|
509
671
|
|
|
510
672
|
test('ACT-4 a branch that fails verify is not merged, and the problems are shown', async ($, on) => {
|
|
511
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' };
|
|
512
676
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
513
677
|
w.mergeExit = 1;
|
|
514
678
|
await start($, on, w);
|
|
@@ -523,6 +687,8 @@ test('ACT-4 a branch that fails verify is not merged, and the problems are shown
|
|
|
523
687
|
|
|
524
688
|
test('ACT-4 cancelling the confirmation merges nothing', async ($, on) => {
|
|
525
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' };
|
|
526
692
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
527
693
|
w.answer = 'Cancel';
|
|
528
694
|
await start($, on, w);
|
|
@@ -556,9 +722,9 @@ test('BAND-2 a failing finish check shows, with Lanes opening the pane', async (
|
|
|
556
722
|
|
|
557
723
|
test('ACT-3 the story shows as being reviewed until the lead has reported', async ($, on) => {
|
|
558
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' };
|
|
559
727
|
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
560
|
-
on('turn.start', ($: any, e: any) => e);
|
|
561
|
-
on('turn.complete', () => ({ text: '' }));
|
|
562
728
|
await start($, on, w);
|
|
563
729
|
const ui = await bandUi($);
|
|
564
730
|
await press($, 'band-review');
|
|
@@ -840,7 +1006,7 @@ test('SET-2 a number is typed; one the CLI refuses shows why and changes nothing
|
|
|
840
1006
|
const ui = await pane($, SETTINGS);
|
|
841
1007
|
await $.ui.input({ plugin: 'code-kit', key: 'set-hold.minutes', text: '45' });
|
|
842
1008
|
await $.ui.input({ plugin: 'code-kit', key: 'settings-reason', text: 'longer' });
|
|
843
|
-
expect(await ui.find({ type: 'Text', text: /from 0 \(off\) to
|
|
1009
|
+
expect(await ui.find({ type: 'Text', text: /from 0 \(off\) to 10/ })).toBeDefined();
|
|
844
1010
|
expect(w.settings[1].value).toBe(2);
|
|
845
1011
|
await press($, 'settings-cancel', SETTINGS);
|
|
846
1012
|
expect(await ui.find({ key: 'settings-confirm' })).toBeUndefined();
|
|
@@ -896,6 +1062,7 @@ test("USE-1 a lane agent's requests show on its lane's row and its story", async
|
|
|
896
1062
|
await clock.advance(2000);
|
|
897
1063
|
const ui = await pane($, PANE);
|
|
898
1064
|
expect((await ui.find({ key: 'lane-web' }))?.text).toContain('200k');
|
|
1065
|
+
await press($, 'tab-usage', PANE);
|
|
899
1066
|
expect((await ui.find({ key: 'usage-ST-4' }))?.text).toContain('200k');
|
|
900
1067
|
expect((await ui.find({ key: 'lane-api' }))?.text).not.toMatch(/\d+k/);
|
|
901
1068
|
await ui.unmount();
|
|
@@ -911,6 +1078,7 @@ test("USE-1 the lead's requests are the lead's; an agent's brief names its story
|
|
|
911
1078
|
const ui = await pane($, PANE);
|
|
912
1079
|
expect((await ui.find({ key: 'lane-lead' }))?.text).toContain('30k');
|
|
913
1080
|
expect((await ui.find({ key: 'lane-core' }))?.text).toContain('50k');
|
|
1081
|
+
await press($, 'tab-usage', PANE);
|
|
914
1082
|
expect((await ui.find({ key: 'usage-ST-9' }))?.text).toContain('50k');
|
|
915
1083
|
await ui.unmount();
|
|
916
1084
|
});
|
|
@@ -927,6 +1095,7 @@ test("USE-2 the background agents' share and the plan's 5-hour use", async ($, o
|
|
|
927
1095
|
await request($, 'a1', 150);
|
|
928
1096
|
await request($, 'r1', 50);
|
|
929
1097
|
const ui = await pane($, PANE);
|
|
1098
|
+
await press($, 'tab-usage', PANE);
|
|
930
1099
|
expect(await ui.find({ type: 'Text', text: 'Background agents 50k (25%)' })).toBeDefined();
|
|
931
1100
|
expect(await ui.find({ type: 'Text', text: '62% of the 5-hour window' })).toBeDefined();
|
|
932
1101
|
expect(await ui.find({ type: 'Text', text: 'This session 200k tokens' })).toBeDefined();
|
|
@@ -1013,3 +1182,1687 @@ test('USE-4 the pause point is the harness setting', async ($, on) => {
|
|
|
1013
1182
|
expect(await ui.find({ type: 'Text', text: /plan at 91%/ })).toBeDefined();
|
|
1014
1183
|
await ui.unmount();
|
|
1015
1184
|
});
|
|
1185
|
+
|
|
1186
|
+
// --- hold and ask -----------------------------------------------------------------------------------
|
|
1187
|
+
|
|
1188
|
+
const webInstall =
|
|
1189
|
+
'Blocked: a new dependency (dayjs) needs a person\'s approval. Write a short change request for the lead.\n ! echo "<what you are approving>" > .claude/approvals/web/dep-dayjs\n(a person runs it; it allows installing it for the web lane, until the install is committed, at most 7 days)\nCommand: npm install dayjs';
|
|
1190
|
+
|
|
1191
|
+
/** A lane's call, left running while the test answers it; returned once the mod has it held. */
|
|
1192
|
+
async function heldCall($: any, w: World, clock: any, id = 'tu-h1') {
|
|
1193
|
+
w.holdWaits = true;
|
|
1194
|
+
const call = $.tool.call({
|
|
1195
|
+
tool: 'Bash',
|
|
1196
|
+
tool_use_id: id,
|
|
1197
|
+
command: 'npm install dayjs',
|
|
1198
|
+
agentId: 'a1',
|
|
1199
|
+
} as any);
|
|
1200
|
+
for (let i = 0; i < 50 && !w.waiting.has(id); i++) await clock.advance(1);
|
|
1201
|
+
expect(w.waiting.has(id)).toBe(true);
|
|
1202
|
+
return { call };
|
|
1203
|
+
}
|
|
1204
|
+
/** A call refused, whichever shape the refusal came back in. */
|
|
1205
|
+
const refused = (res: any) => Boolean(res?.deny || res?.isError);
|
|
1206
|
+
const refusalText = (res: any) => String(res?.deny ?? res?.text ?? '');
|
|
1207
|
+
|
|
1208
|
+
test('HOLD-1 and HOLD-2 a lane installing a new package is held, and the band asks with the time left', async ($, on) => {
|
|
1209
|
+
const w = project();
|
|
1210
|
+
w.refusal = webInstall;
|
|
1211
|
+
const clock = await start($, on, w);
|
|
1212
|
+
await heldCall($, w, clock);
|
|
1213
|
+
let ui = await bandUi($);
|
|
1214
|
+
expect(
|
|
1215
|
+
await ui.find({ type: 'Text', text: /^web wants to install dayjs · 2:00$/ }),
|
|
1216
|
+
).toBeDefined();
|
|
1217
|
+
expect((await ui.find({ key: 'band-approveHeld' }))?.props.hotkey).toBe('1');
|
|
1218
|
+
expect((await ui.find({ key: 'band-refuseHeld' }))?.props.hotkey).toBe('2');
|
|
1219
|
+
await ui.unmount();
|
|
1220
|
+
await clock.advance(7000);
|
|
1221
|
+
ui = await bandUi($);
|
|
1222
|
+
expect(await ui.find({ type: 'Text', text: /· 1:53$/ })).toBeDefined();
|
|
1223
|
+
await ui.unmount();
|
|
1224
|
+
w.waiting.get('tu-h1')?.('timed out');
|
|
1225
|
+
});
|
|
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
|
+
|
|
1338
|
+
test('HOLD-3 on Approve the approval is written as the pane writes it, and the same call runs', async ($, on) => {
|
|
1339
|
+
const w = project();
|
|
1340
|
+
w.refusal = webInstall;
|
|
1341
|
+
const clock = await start($, on, w);
|
|
1342
|
+
const { call } = await heldCall($, w, clock);
|
|
1343
|
+
const shown = await bandUi($);
|
|
1344
|
+
await press($, 'band-approveHeld');
|
|
1345
|
+
await shown.unmount();
|
|
1346
|
+
expect(w.opened).toContain(APPROVE);
|
|
1347
|
+
const confirm = await pane($, APPROVE);
|
|
1348
|
+
await press($, 'approve-confirm', APPROVE);
|
|
1349
|
+
expect(w.acts).toEqual([
|
|
1350
|
+
[
|
|
1351
|
+
'approve',
|
|
1352
|
+
'dep-dayjs',
|
|
1353
|
+
'--lane',
|
|
1354
|
+
'web',
|
|
1355
|
+
'--reason',
|
|
1356
|
+
'Approve dayjs for the web lane: npm install dayjs',
|
|
1357
|
+
'--via',
|
|
1358
|
+
'pane',
|
|
1359
|
+
],
|
|
1360
|
+
['hold', 'tu-h1', '--answer', 'approved'],
|
|
1361
|
+
]);
|
|
1362
|
+
const res: any = await call;
|
|
1363
|
+
expect(refused(res)).toBe(false);
|
|
1364
|
+
expect(w.bashRuns).toBe(2);
|
|
1365
|
+
const ui = await bandUi($);
|
|
1366
|
+
expect(await ui.find({ type: 'Text', text: /wants to/ })).toBeUndefined();
|
|
1367
|
+
await ui.unmount();
|
|
1368
|
+
await confirm.unmount();
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
test("HOLD-3 an approval that can't be written refuses the call, and the band says nothing was approved", async ($, on) => {
|
|
1372
|
+
const w = project();
|
|
1373
|
+
w.refusal = webInstall;
|
|
1374
|
+
w.approveExit = 1;
|
|
1375
|
+
const clock = await start($, on, w);
|
|
1376
|
+
const { call } = await heldCall($, w, clock);
|
|
1377
|
+
const shown = await bandUi($);
|
|
1378
|
+
await press($, 'band-approveHeld');
|
|
1379
|
+
await shown.unmount();
|
|
1380
|
+
const confirm = await pane($, APPROVE);
|
|
1381
|
+
await press($, 'approve-confirm', APPROVE);
|
|
1382
|
+
await confirm.unmount();
|
|
1383
|
+
const res: any = await call;
|
|
1384
|
+
expect(refused(res)).toBe(true);
|
|
1385
|
+
expect(refusalText(res)).toMatch(/needs a person's approval/);
|
|
1386
|
+
const ui = await bandUi($);
|
|
1387
|
+
expect(await ui.find({ type: 'Text', text: /Nothing was approved/ })).toBeDefined();
|
|
1388
|
+
await ui.unmount();
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
test('HOLD-4 Refuse, or no answer in the hold time, lets the refusal through and the request stays', async ($, on) => {
|
|
1392
|
+
const w = project();
|
|
1393
|
+
w.refusal = webInstall;
|
|
1394
|
+
const clock = await start($, on, w);
|
|
1395
|
+
let { call } = await heldCall($, w, clock);
|
|
1396
|
+
const shown = await bandUi($);
|
|
1397
|
+
await press($, 'band-refuseHeld');
|
|
1398
|
+
await shown.unmount();
|
|
1399
|
+
let res: any = await call;
|
|
1400
|
+
expect(refused(res)).toBe(true);
|
|
1401
|
+
expect(w.bashRuns).toBe(1);
|
|
1402
|
+
({ call } = await heldCall($, w, clock, 'tu-h2'));
|
|
1403
|
+
expect(w.holds.at(-1)).toEqual(['hold', 'tu-h2', '--minutes', '2']);
|
|
1404
|
+
w.waiting.get('tu-h2')?.('timed out');
|
|
1405
|
+
res = await call;
|
|
1406
|
+
expect(refused(res)).toBe(true);
|
|
1407
|
+
w.requests = [{ ...dayjs, names: ['dep-dayjs'] }];
|
|
1408
|
+
await clock.advance(60000);
|
|
1409
|
+
const ui = await bandUi($);
|
|
1410
|
+
expect(await ui.find({ type: 'Text', text: '1 approval waiting' })).toBeDefined();
|
|
1411
|
+
await ui.unmount();
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
test('HOLD-5 within the delegated rules the call is approved as delegated and runs, without asking', async ($, on) => {
|
|
1415
|
+
const w = project();
|
|
1416
|
+
w.refusal = webInstall;
|
|
1417
|
+
w.delegateExit = 0;
|
|
1418
|
+
await start($, on, w);
|
|
1419
|
+
const res: any = await $.tool.call({
|
|
1420
|
+
tool: 'Bash',
|
|
1421
|
+
tool_use_id: 'tu-d',
|
|
1422
|
+
command: 'npm install dayjs',
|
|
1423
|
+
agentId: 'a1',
|
|
1424
|
+
} as any);
|
|
1425
|
+
expect(refused(res)).toBe(false);
|
|
1426
|
+
expect(w.delegated[0]).toEqual(
|
|
1427
|
+
expect.arrayContaining(['approve', 'dep-dayjs', '--lane', 'web', '--delegated']),
|
|
1428
|
+
);
|
|
1429
|
+
expect(w.holds).toEqual([]);
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
test('HOLD-5 with autonomy propose the delegated rules are not tried; the call is held', async ($, on) => {
|
|
1433
|
+
const w = project();
|
|
1434
|
+
w.refusal = webInstall;
|
|
1435
|
+
w.delegateExit = 0;
|
|
1436
|
+
(w.check as any).harness = { autonomy: 'propose', hold: { minutes: 2 } };
|
|
1437
|
+
const clock = await start($, on, w);
|
|
1438
|
+
await heldCall($, w, clock);
|
|
1439
|
+
expect(w.delegated).toEqual([]);
|
|
1440
|
+
w.waiting.get('tu-h1')?.('timed out');
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
test('HOLD-6 with nobody to ask, or hold off, the call is refused at once', async ($, on) => {
|
|
1444
|
+
const w = project();
|
|
1445
|
+
w.refusal = webInstall;
|
|
1446
|
+
await start($, on, w, false);
|
|
1447
|
+
const res: any = await $.tool.call({
|
|
1448
|
+
tool: 'Bash',
|
|
1449
|
+
tool_use_id: 'tu-p',
|
|
1450
|
+
command: 'npm install dayjs',
|
|
1451
|
+
} as any);
|
|
1452
|
+
expect(refused(res)).toBe(true);
|
|
1453
|
+
expect(w.holds).toEqual([]);
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test('HOLD-6 hold.minutes 0 switches holding off', async ($, on) => {
|
|
1457
|
+
const w = project();
|
|
1458
|
+
w.refusal = webInstall;
|
|
1459
|
+
(w.check as any).harness = { autonomy: 'autonomous', hold: { minutes: 0 } };
|
|
1460
|
+
await start($, on, w);
|
|
1461
|
+
const res: any = await $.tool.call({
|
|
1462
|
+
tool: 'Bash',
|
|
1463
|
+
tool_use_id: 'tu-0',
|
|
1464
|
+
command: 'npm install dayjs',
|
|
1465
|
+
} as any);
|
|
1466
|
+
expect(refused(res)).toBe(true);
|
|
1467
|
+
expect(w.holds).toEqual([]);
|
|
1468
|
+
expect(w.delegated).toEqual([]);
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
test('HOLD-1 a refusal no approval allows (force-push) is refused at once, unheld', async ($, on) => {
|
|
1472
|
+
const w = project();
|
|
1473
|
+
w.refusal =
|
|
1474
|
+
'Blocked: force-pushing rewrites history others may have pulled.\nCommand: git push --force';
|
|
1475
|
+
await start($, on, w);
|
|
1476
|
+
const res: any = await $.tool.call({
|
|
1477
|
+
tool: 'Bash',
|
|
1478
|
+
tool_use_id: 'tu-f',
|
|
1479
|
+
command: 'git push --force',
|
|
1480
|
+
} as any);
|
|
1481
|
+
expect(refused(res)).toBe(true);
|
|
1482
|
+
expect(w.holds).toEqual([]);
|
|
1483
|
+
expect(w.delegated).toEqual([]);
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
// --- the lead loop ----------------------------------------------------------------------------------
|
|
1487
|
+
|
|
1488
|
+
/** One lead turn, start to end: the person's prompt, or one the loop submitted. */
|
|
1489
|
+
async function leadTurn($: any, id = 't-1') {
|
|
1490
|
+
await $.turn.start({ turnId: id, text: '' } as any);
|
|
1491
|
+
await $.turn.complete({ turnId: id, answer: 'Done.', durationMs: 1, isAborted: false } as any);
|
|
1492
|
+
}
|
|
1493
|
+
/** Lets the loop's work after a turn settle. */
|
|
1494
|
+
const settle = async (clock: any) => {
|
|
1495
|
+
for (let i = 0; i < 5; i++) await clock.advance(1);
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
test('LOOP-1 and LOOP-2 with the lead idle, the loop dispatches every ready story in one prompt and records it', async ($, on) => {
|
|
1499
|
+
const w = project();
|
|
1500
|
+
w.next = { step: 'dispatch', args: 'ST-9 ST-10 ST-11 ST-12', then: [] };
|
|
1501
|
+
const clock = await start($, on, w);
|
|
1502
|
+
await leadTurn($);
|
|
1503
|
+
await settle(clock);
|
|
1504
|
+
expect(w.prompts).toEqual([
|
|
1505
|
+
'Dispatch ST-9, ST-10, ST-11, ST-12: run /code-kit:dispatch ST-9 ST-10 ST-11 ST-12.',
|
|
1506
|
+
]);
|
|
1507
|
+
await lanes($);
|
|
1508
|
+
const ui = await pane($, PANE);
|
|
1509
|
+
expect((await ui.find({ key: 'loop-step-0' }))?.text).toMatch(/dispatch ST-9 ST-10 ST-11 ST-12/);
|
|
1510
|
+
await ui.unmount();
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
test('LOOP-1 nothing is submitted mid-turn, or while the person has a draft, or twice for the same step', async ($, on) => {
|
|
1514
|
+
const w = project();
|
|
1515
|
+
const clock = await start($, on, w);
|
|
1516
|
+
await $.turn.start({ turnId: 't-1', text: '' } as any);
|
|
1517
|
+
await clock.advance(4000);
|
|
1518
|
+
expect(w.prompts).toEqual([]);
|
|
1519
|
+
w.draft = 'half a thought';
|
|
1520
|
+
await $.turn.complete({ turnId: 't-1', answer: '', durationMs: 1, isAborted: false } as any);
|
|
1521
|
+
await settle(clock);
|
|
1522
|
+
expect(w.prompts).toEqual([]);
|
|
1523
|
+
w.draft = '';
|
|
1524
|
+
await leadTurn($, 't-2');
|
|
1525
|
+
await settle(clock);
|
|
1526
|
+
expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
|
|
1527
|
+
await leadTurn($, 't-3');
|
|
1528
|
+
await settle(clock);
|
|
1529
|
+
expect(w.prompts).toHaveLength(1);
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
test("LOOP-1 review and the lead's own stories are prompted with their skill", async ($, on) => {
|
|
1533
|
+
const w = project();
|
|
1534
|
+
w.next = { step: 'review', args: 'ST-4', then: [] };
|
|
1535
|
+
const clock = await start($, on, w);
|
|
1536
|
+
await leadTurn($);
|
|
1537
|
+
await settle(clock);
|
|
1538
|
+
w.next = { step: 'lead', args: 'ST-6', then: [] };
|
|
1539
|
+
w.refs += ' ccc refs/heads/web/st-4-moved\n';
|
|
1540
|
+
await leadTurn($, 't-2');
|
|
1541
|
+
await settle(clock);
|
|
1542
|
+
expect(w.prompts[0]).toBe('Review ST-4: run /code-kit:review ST-4.');
|
|
1543
|
+
expect(w.prompts[1]).toMatch(/^Build ST-6 yourself.*lead\/st-6/);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
test('LOOP-8 a step the lead was asked for twice and that is still next goes to the person, while lanes keep committing', async ($, on) => {
|
|
1547
|
+
const w = project();
|
|
1548
|
+
w.next = { step: 'merge', args: 'lead/st-1', then: [] };
|
|
1549
|
+
const clock = await start($, on, w);
|
|
1550
|
+
for (const [i, turn] of ['t-1', 't-2', 't-3', 't-4'].entries()) {
|
|
1551
|
+
// A lane commits between the lead's turns: the project moves, but not this step.
|
|
1552
|
+
w.refs += ` c${i} refs/heads/core/st-${i}\n`;
|
|
1553
|
+
await leadTurn($, turn);
|
|
1554
|
+
await settle(clock);
|
|
1555
|
+
}
|
|
1556
|
+
expect(w.prompts).toHaveLength(2);
|
|
1557
|
+
expect(w.prompts.every((p: string) => p.startsWith('Merge lead/st-1'))).toBe(true);
|
|
1558
|
+
const ui = await bandUi($);
|
|
1559
|
+
expect(
|
|
1560
|
+
await ui.find({
|
|
1561
|
+
type: 'Text',
|
|
1562
|
+
text: 'Merge lead/st-1: still next after 2 prompts. Ask the lead again?',
|
|
1563
|
+
}),
|
|
1564
|
+
).toBeDefined();
|
|
1565
|
+
await press($, 'band-go');
|
|
1566
|
+
await ui.unmount();
|
|
1567
|
+
expect(w.prompts).toHaveLength(3);
|
|
1568
|
+
// Once the step changes, the loop goes on by itself.
|
|
1569
|
+
w.next = { step: 'dispatch', args: 'ST-9', then: [] };
|
|
1570
|
+
await leadTurn($, 't-5');
|
|
1571
|
+
await settle(clock);
|
|
1572
|
+
expect(w.prompts.at(-1)).toBe('Dispatch ST-9: run /code-kit:dispatch ST-9.');
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
test('LOOP-5 under propose the band offers the step, and nothing goes until Go', async ($, on) => {
|
|
1576
|
+
const w = project();
|
|
1577
|
+
(w.check as any).harness = { autonomy: 'propose' };
|
|
1578
|
+
const clock = await start($, on, w);
|
|
1579
|
+
await leadTurn($);
|
|
1580
|
+
await settle(clock);
|
|
1581
|
+
expect(w.prompts).toEqual([]);
|
|
1582
|
+
const ui = await bandUi($);
|
|
1583
|
+
expect(await ui.find({ type: 'Text', text: 'Dispatch ST-9?' })).toBeDefined();
|
|
1584
|
+
await press($, 'band-go');
|
|
1585
|
+
await ui.unmount();
|
|
1586
|
+
expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
test('LOOP-5 under off the loop takes no step, and the band shows no loop', async ($, on) => {
|
|
1590
|
+
const w = project();
|
|
1591
|
+
(w.check as any).harness = { autonomy: 'off' };
|
|
1592
|
+
const clock = await start($, on, w);
|
|
1593
|
+
await leadTurn($);
|
|
1594
|
+
await settle(clock);
|
|
1595
|
+
expect(w.prompts).toEqual([]);
|
|
1596
|
+
const ui = await bandUi($);
|
|
1597
|
+
expect(await ui.find({ type: 'Text', text: /loop/ })).toBeUndefined();
|
|
1598
|
+
await ui.unmount();
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
test('LOOP-6 Pause stops new steps until Resume', async ($, on) => {
|
|
1602
|
+
const w = project();
|
|
1603
|
+
w.next = { step: 'review', args: 'ST-4', then: [] };
|
|
1604
|
+
const clock = await start($, on, w);
|
|
1605
|
+
await leadTurn($);
|
|
1606
|
+
await settle(clock);
|
|
1607
|
+
let ui = await bandUi($);
|
|
1608
|
+
expect(await ui.find({ type: 'Text', text: 'loop on · last: review ST-4' })).toBeDefined();
|
|
1609
|
+
await press($, 'band-pause');
|
|
1610
|
+
await ui.unmount();
|
|
1611
|
+
w.next = { step: 'dispatch', args: 'ST-9', then: [] };
|
|
1612
|
+
await leadTurn($, 't-2');
|
|
1613
|
+
await settle(clock);
|
|
1614
|
+
expect(w.prompts).toEqual(['Review ST-4: run /code-kit:review ST-4.']);
|
|
1615
|
+
ui = await bandUi($);
|
|
1616
|
+
expect(await ui.find({ type: 'Text', text: 'loop paused' })).toBeDefined();
|
|
1617
|
+
await press($, 'band-resume');
|
|
1618
|
+
await ui.unmount();
|
|
1619
|
+
expect(w.prompts).toEqual([
|
|
1620
|
+
'Review ST-4: run /code-kit:review ST-4.',
|
|
1621
|
+
'Dispatch ST-9: run /code-kit:dispatch ST-9.',
|
|
1622
|
+
]);
|
|
1623
|
+
});
|
|
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
|
+
|
|
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) => {
|
|
1649
|
+
const w = project();
|
|
1650
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
1651
|
+
w.agents = [
|
|
1652
|
+
{ id: 'a1', type: 'web-engineer', description: 'ST-4 booking form', status: 'running' },
|
|
1653
|
+
];
|
|
1654
|
+
const clock = await start($, on, w);
|
|
1655
|
+
// First seen at the next look (2 s), so quiet for 5 minutes a look later.
|
|
1656
|
+
await clock.advance(5 * 60000 + 2000);
|
|
1657
|
+
expect(w.sent).toEqual([
|
|
1658
|
+
{
|
|
1659
|
+
to: 'a1',
|
|
1660
|
+
text: 'No tool call for 5 minutes: report where you are, or carry on.',
|
|
1661
|
+
},
|
|
1662
|
+
]);
|
|
1663
|
+
await lanes($);
|
|
1664
|
+
let ui = await pane($, PANE);
|
|
1665
|
+
expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/nudged/);
|
|
1666
|
+
await ui.unmount();
|
|
1667
|
+
await clock.advance(5 * 60000);
|
|
1668
|
+
expect(w.prompts[0]).toMatch(
|
|
1669
|
+
/^web-engineer \(agent a1\) on ST-4 has made no tool call for 10 minutes\. Stop it with TaskStop.*\/code-kit:dispatch ST-4$/,
|
|
1670
|
+
);
|
|
1671
|
+
ui = await pane($, PANE);
|
|
1672
|
+
expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/restarted 1\/2/);
|
|
1673
|
+
await ui.unmount();
|
|
1674
|
+
// The lane's agent is started again, and stalls again, twice more.
|
|
1675
|
+
for (const id of ['a2', 'a3']) {
|
|
1676
|
+
w.agents = [{ id, type: 'web-engineer', description: 'ST-4 booking form', status: 'running' }];
|
|
1677
|
+
await clock.advance(10 * 60000 + 2000);
|
|
1678
|
+
}
|
|
1679
|
+
expect(w.prompts).toHaveLength(2);
|
|
1680
|
+
const band = await bandUi($);
|
|
1681
|
+
expect(await band.find({ type: 'Text', text: 'web stalled 3 times on ST-4' })).toBeDefined();
|
|
1682
|
+
expect(await band.find({ key: 'band-resumeStall' })).toBeDefined();
|
|
1683
|
+
await band.unmount();
|
|
1684
|
+
});
|
|
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
|
+
|
|
1731
|
+
test('LOOP-3 an agent waiting on a held call (or running a long command) is never quiet', async ($, on) => {
|
|
1732
|
+
const w = project();
|
|
1733
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
1734
|
+
w.refusal = webInstall;
|
|
1735
|
+
(w.check as any).harness = { hold: { minutes: 10 } };
|
|
1736
|
+
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
1737
|
+
const clock = await start($, on, w);
|
|
1738
|
+
const { call } = await heldCall($, w, clock);
|
|
1739
|
+
await clock.advance(6 * 60000);
|
|
1740
|
+
expect(w.sent).toEqual([]);
|
|
1741
|
+
w.waiting.get('tu-h1')?.('timed out');
|
|
1742
|
+
await call;
|
|
1743
|
+
// Its quiet time starts again when the call ends: nudged 5 minutes after, not at once.
|
|
1744
|
+
await clock.advance(2000);
|
|
1745
|
+
expect(w.sent).toEqual([]);
|
|
1746
|
+
await clock.advance(5 * 60000);
|
|
1747
|
+
expect(w.sent.map((m) => m.to)).toEqual(['a1']);
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
test('LOOP-7 when every story is done the loop stops, and the band says so until dismissed', async ($, on) => {
|
|
1751
|
+
const w = project();
|
|
1752
|
+
w.stories = w.stories.map((s) => ({ ...s, state: 'done' }));
|
|
1753
|
+
w.next = { step: 'done', args: '', then: [] };
|
|
1754
|
+
const clock = await start($, on, w);
|
|
1755
|
+
await leadTurn($);
|
|
1756
|
+
await settle(clock);
|
|
1757
|
+
expect(w.prompts).toEqual([]);
|
|
1758
|
+
let ui = await bandUi($);
|
|
1759
|
+
expect(await ui.find({ type: 'Text', text: 'Milestone done: 4 stories merged' })).toBeDefined();
|
|
1760
|
+
await press($, 'band-dismissDone');
|
|
1761
|
+
await ui.unmount();
|
|
1762
|
+
ui = await bandUi($);
|
|
1763
|
+
expect(await ui.find({ type: 'Text', text: /Milestone done/ })).toBeUndefined();
|
|
1764
|
+
await ui.unmount();
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
// --- the reviewer -----------------------------------------------------------------------------------
|
|
1768
|
+
|
|
1769
|
+
/** ST-4 finished on web/st-4, the reviewer on. */
|
|
1770
|
+
function reviewing() {
|
|
1771
|
+
const w = project();
|
|
1772
|
+
w.stories[0] = { ...w.stories[0], state: 'review', requirements: ['BOOK-1'] };
|
|
1773
|
+
w.next = { step: 'review', args: 'ST-4', then: [] };
|
|
1774
|
+
(w.check as any).harness = { agents: { reviewer: { on: true } } };
|
|
1775
|
+
return w;
|
|
1776
|
+
}
|
|
1777
|
+
const report = (json: object) =>
|
|
1778
|
+
'Reviewed web/st-4.\n\n\`\`\`json\n' + JSON.stringify(json) + '\n\`\`\`\n';
|
|
1779
|
+
|
|
1780
|
+
test('REVW-1 with the reviewer on, a finished branch has the lead start one in the background, and the pane shows it running', async ($, on) => {
|
|
1781
|
+
const w = reviewing();
|
|
1782
|
+
const clock = await start($, on, w);
|
|
1783
|
+
expect(w.registered[0]).toMatchObject({
|
|
1784
|
+
name: 'reviewer',
|
|
1785
|
+
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
|
1786
|
+
});
|
|
1787
|
+
await leadTurn($);
|
|
1788
|
+
await settle(clock);
|
|
1789
|
+
expect(w.prompts[0]).toMatch(
|
|
1790
|
+
/^Start code-kit's reviewer in the background for web\/st-4: use the Agent tool with subagent_type "code-kit:reviewer", run_in_background true, description "Review web\/st-4 \(ST-4\)".*at abc123def456 for ST-4 Booking form \(requirements BOOK-1\)/,
|
|
1791
|
+
);
|
|
1792
|
+
await clock.advance(3 * 60000);
|
|
1793
|
+
await lanes($);
|
|
1794
|
+
const ui = await pane($, PANE);
|
|
1795
|
+
expect((await ui.find({ key: 'review-web/st-4' }))?.text).toMatch(/running 3m/);
|
|
1796
|
+
await ui.unmount();
|
|
1797
|
+
// While it runs, the lead isn't asked to review.
|
|
1798
|
+
await leadTurn($, 't-2');
|
|
1799
|
+
await settle(clock);
|
|
1800
|
+
expect(w.prompts).toHaveLength(1);
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
test("REVW-3 and REVW-4 the reviewer's findings, a failed verify as a blocker, go into the lead's review", async ($, on) => {
|
|
1804
|
+
const w = reviewing();
|
|
1805
|
+
const clock = await start($, on, w);
|
|
1806
|
+
await leadTurn($);
|
|
1807
|
+
await settle(clock);
|
|
1808
|
+
w.agents = [
|
|
1809
|
+
{
|
|
1810
|
+
id: 'r1',
|
|
1811
|
+
type: 'code-kit:reviewer',
|
|
1812
|
+
description: 'Review web/st-4 (ST-4)',
|
|
1813
|
+
status: 'completed',
|
|
1814
|
+
},
|
|
1815
|
+
];
|
|
1816
|
+
await $.turn.complete({
|
|
1817
|
+
turnId: 't-r1',
|
|
1818
|
+
agentId: 'r1',
|
|
1819
|
+
answer: report({
|
|
1820
|
+
verify: { passed: false, problems: ['apps/api/x.ts is outside the web lane'] },
|
|
1821
|
+
findings: [{ severity: 'nit', where: 'apps/web/form.tsx:3', text: 'Unused import.' }],
|
|
1822
|
+
}),
|
|
1823
|
+
durationMs: 1,
|
|
1824
|
+
isAborted: false,
|
|
1825
|
+
} as any);
|
|
1826
|
+
await leadTurn($, 't-2');
|
|
1827
|
+
await settle(clock);
|
|
1828
|
+
expect(w.prompts[1]).toBe(
|
|
1829
|
+
[
|
|
1830
|
+
"Review ST-4: run /code-kit:review ST-4. code-kit's reviewer found 1 blocker, 1 nit on web/st-4 at abc123def456:",
|
|
1831
|
+
'- blocker: verify: apps/api/x.ts is outside the web lane (code-kit verify)',
|
|
1832
|
+
'- nit apps/web/form.tsx:3: Unused import.',
|
|
1833
|
+
'A blocker means you send the branch back. Concerns and nits are yours to weigh.',
|
|
1834
|
+
].join('\n'),
|
|
1835
|
+
);
|
|
1836
|
+
await lanes($);
|
|
1837
|
+
const ui = await pane($, PANE);
|
|
1838
|
+
expect((await ui.find({ key: 'review-web/st-4' }))?.text).toMatch(/done · 1 blocker, 1 nit/);
|
|
1839
|
+
await ui.unmount();
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
test("REVW-1 a reviewer that hasn't reported in 15 minutes is passed over: the lead reviews without it", async ($, on) => {
|
|
1843
|
+
const w = reviewing();
|
|
1844
|
+
const clock = await start($, on, w);
|
|
1845
|
+
await leadTurn($);
|
|
1846
|
+
await settle(clock);
|
|
1847
|
+
await clock.advance(15 * 60000 + 2000);
|
|
1848
|
+
await leadTurn($, 't-2');
|
|
1849
|
+
await settle(clock);
|
|
1850
|
+
expect(w.prompts[1]).toMatch(/didn't report: no report within 15 minutes\. Review it yourself/);
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
test('REVW-1 a branch that moves gets a new review', async ($, on) => {
|
|
1854
|
+
const w = reviewing();
|
|
1855
|
+
const clock = await start($, on, w);
|
|
1856
|
+
await leadTurn($);
|
|
1857
|
+
await settle(clock);
|
|
1858
|
+
w.heads['web/st-4'] = 'fff000111222333\n';
|
|
1859
|
+
await leadTurn($, 't-2');
|
|
1860
|
+
await settle(clock);
|
|
1861
|
+
expect(w.prompts[1]).toMatch(/^Start code-kit's reviewer.*at fff000111222/);
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
test('USE-4 past the pause point no reviewer starts: the review is skipped and the lead reviews alone', async ($, on) => {
|
|
1865
|
+
const w = reviewing();
|
|
1866
|
+
w.limits = [{ kind: 'five_hour', percentUsed: 81 }];
|
|
1867
|
+
const clock = await start($, on, w);
|
|
1868
|
+
await leadTurn($);
|
|
1869
|
+
await settle(clock);
|
|
1870
|
+
expect(w.prompts).toEqual([
|
|
1871
|
+
'Review ST-4: run /code-kit:review ST-4. (No background review: background agents paused, the plan at 81%.)',
|
|
1872
|
+
]);
|
|
1873
|
+
await lanes($);
|
|
1874
|
+
const ui = await pane($, PANE);
|
|
1875
|
+
expect((await ui.find({ key: 'review-web/st-4' }))?.text).toMatch(
|
|
1876
|
+
/skipped: background agents paused/,
|
|
1877
|
+
);
|
|
1878
|
+
await ui.unmount();
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
test("REVW-2 the reviewer's model is the setting's, registered again when it changes", async ($, on) => {
|
|
1882
|
+
const w = reviewing();
|
|
1883
|
+
(w.check as any).harness = { agents: { reviewer: { on: true, model: 'haiku' } } };
|
|
1884
|
+
await start($, on, w);
|
|
1885
|
+
expect(w.registered.at(-1)).toMatchObject({ name: 'reviewer', model: 'haiku' });
|
|
1886
|
+
expect(w.registered.at(-1).tools).not.toContain('Write');
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
// --- the merge queue --------------------------------------------------------------------------------
|
|
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
|
+
|
|
2046
|
+
test('MQ-2 where the lead merges, the loop prompts it to merge the head of the queue', async ($, on) => {
|
|
2047
|
+
const w = project();
|
|
2048
|
+
w.next = { step: 'merge', args: 'web/st-4', then: [] };
|
|
2049
|
+
const clock = await start($, on, w);
|
|
2050
|
+
await leadTurn($);
|
|
2051
|
+
await settle(clock);
|
|
2052
|
+
expect(w.prompts[0]).toMatch(
|
|
2053
|
+
/^Merge web\/st-4, next in the merge queue: run node ".*\/bin\/code-kit\.mjs" queue merge --delegated\./,
|
|
2054
|
+
);
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
test('MQ-2 where the person merges, the head of the queue waits in the band for their Merge', async ($, on) => {
|
|
2058
|
+
const w = project();
|
|
2059
|
+
w.queue = [{ branch: 'web/st-4', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
|
|
2060
|
+
await start($, on, w);
|
|
2061
|
+
const ui = await bandUi($);
|
|
2062
|
+
expect(await ui.find({ type: 'Text', text: 'web/st-4 is next to merge' })).toBeDefined();
|
|
2063
|
+
await press($, 'band-mergeQueue');
|
|
2064
|
+
await ui.unmount();
|
|
2065
|
+
expect(w.acts).toEqual([['queue', 'merge', '--person']]);
|
|
2066
|
+
expect(w.opened).toContain(RESULT);
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
test('MQ-2 the first merge, before the base has code-kit rules committed, is offered to the person', async ($, on) => {
|
|
2070
|
+
const w = project();
|
|
2071
|
+
(w.check as any).firstMerge = true;
|
|
2072
|
+
w.queue = [{ branch: 'lead/st-1', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
|
|
2073
|
+
await start($, on, w);
|
|
2074
|
+
const ui = await bandUi($);
|
|
2075
|
+
expect(
|
|
2076
|
+
await ui.find({
|
|
2077
|
+
type: 'Text',
|
|
2078
|
+
text: 'lead/st-1 is next to merge: the first merge is yours, then the lead merges',
|
|
2079
|
+
}),
|
|
2080
|
+
).toBeDefined();
|
|
2081
|
+
await ui.unmount();
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
test('MQ-2 where the lead merges under autonomy, the band offers no Merge', async ($, on) => {
|
|
2085
|
+
const w = project();
|
|
2086
|
+
(w.check as any).delegatesMerge = true;
|
|
2087
|
+
w.queue = [{ branch: 'web/st-4', passed: '2026-10-10T00:00:00Z', state: 'waiting' }];
|
|
2088
|
+
await start($, on, w);
|
|
2089
|
+
const ui = await bandUi($);
|
|
2090
|
+
expect(await ui.find({ type: 'Text', text: /next to merge/ })).toBeUndefined();
|
|
2091
|
+
await ui.unmount();
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
test('MQ-4 the pane shows the queue in order, what it merged and sent back, and the lane as queued', async ($, on) => {
|
|
2095
|
+
const w = project();
|
|
2096
|
+
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
2097
|
+
w.queue = [
|
|
2098
|
+
{ branch: 'api/st-2', passed: '2026-10-10T00:00:00Z', state: 'merged' },
|
|
2099
|
+
{
|
|
2100
|
+
branch: 'api/st-3',
|
|
2101
|
+
passed: '2026-10-10T00:01:00Z',
|
|
2102
|
+
state: 'sent back',
|
|
2103
|
+
reason: 'conflicts with main after api/st-2',
|
|
2104
|
+
},
|
|
2105
|
+
{ branch: 'web/st-4', passed: '2026-10-10T00:02:00Z', state: 'waiting' },
|
|
2106
|
+
];
|
|
2107
|
+
await start($, on, w);
|
|
2108
|
+
await lanes($);
|
|
2109
|
+
const ui = await pane($, PANE);
|
|
2110
|
+
expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/queued/);
|
|
2111
|
+
await press($, 'tab-queue', PANE);
|
|
2112
|
+
expect((await ui.find({ key: 'queue-web/st-4-waiting' }))?.text).toMatch(
|
|
2113
|
+
/1\.\s*web\/st-4\s*waiting/,
|
|
2114
|
+
);
|
|
2115
|
+
expect((await ui.find({ key: 'queue-api/st-3-sent back' }))?.text).toMatch(
|
|
2116
|
+
/conflicts with main after api\/st-2/,
|
|
2117
|
+
);
|
|
2118
|
+
await ui.unmount();
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
// --- panes v2 ---------------------------------------------------------------------------------------
|
|
2122
|
+
|
|
2123
|
+
const inlinePane = ($: any) =>
|
|
2124
|
+
$.ui.mount({
|
|
2125
|
+
plugin: 'code-kit',
|
|
2126
|
+
component: 'Pane',
|
|
2127
|
+
requestId: PANE,
|
|
2128
|
+
surface: 'terminal',
|
|
2129
|
+
viewport: { columns: 90, rows: 30 },
|
|
2130
|
+
props: { title: PANE, isFocused: true, bodyColumns: 80, placement: 'inline' },
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
test('VIEW-1 the header counts the stories by state, and a count filters the lanes', async ($, on) => {
|
|
2134
|
+
const w = project();
|
|
2135
|
+
w.stories = [
|
|
2136
|
+
{ id: 'ST-1', title: 'Done', lane: 'api', state: 'done', branch: 'api/st-1', waitingOn: [] },
|
|
2137
|
+
...w.stories,
|
|
2138
|
+
];
|
|
2139
|
+
w.limits = [{ kind: 'five_hour', percentUsed: 40 }];
|
|
2140
|
+
await start($, on, w);
|
|
2141
|
+
await lanes($);
|
|
2142
|
+
const ui = await pane($, PANE);
|
|
2143
|
+
expect((await ui.find({ key: 'count-building' }))?.props.label).toBe('● 1 building');
|
|
2144
|
+
expect((await ui.find({ key: 'count-ready' }))?.props.label).toBe('○ 3 ready');
|
|
2145
|
+
expect((await ui.find({ key: 'count-merged' }))?.props.label).toBe('✓ 1 merged');
|
|
2146
|
+
expect(await ui.find({ type: 'Text', text: 'plan 40%' })).toBeDefined();
|
|
2147
|
+
await press($, 'count-building', PANE);
|
|
2148
|
+
expect(await ui.find({ key: 'lane-web' })).toBeDefined();
|
|
2149
|
+
expect(await ui.find({ key: 'lane-api' })).toBeUndefined();
|
|
2150
|
+
await press($, 'count-building', PANE);
|
|
2151
|
+
expect(await ui.find({ key: 'lane-api' })).toBeDefined();
|
|
2152
|
+
await ui.unmount();
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
test('MISSION-1 the band shows the goal, what the lead is doing, the next step and each agent at work', async ($, on) => {
|
|
2156
|
+
const w = project();
|
|
2157
|
+
w.stories = w.stories.map((x: any) => ({ ...x, milestone: 'Milestone 2: Bookings' }));
|
|
2158
|
+
w.stories[0] = { ...w.stories[0], state: 'done' };
|
|
2159
|
+
w.next = { step: 'review', args: 'ST-9', then: [{ step: 'dispatch', args: 'ST-10' }] };
|
|
2160
|
+
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
2161
|
+
const clock = await start($, on, w);
|
|
2162
|
+
await leadTurn($);
|
|
2163
|
+
await settle(clock);
|
|
2164
|
+
await $.tool.call({
|
|
2165
|
+
tool: 'Bash',
|
|
2166
|
+
tool_use_id: 'tu-m',
|
|
2167
|
+
command: 'npm run test:e2e',
|
|
2168
|
+
agentId: 'a1',
|
|
2169
|
+
} as any);
|
|
2170
|
+
await $.turn.start({ turnId: 't-busy', text: '' } as any);
|
|
2171
|
+
await clock.advance(3000);
|
|
2172
|
+
const ui = await bandUi($);
|
|
2173
|
+
const rows = (await ui.findAll({ type: 'Text' })).map((t: any) => t.text);
|
|
2174
|
+
expect(rows).toContain('Milestone 2: Bookings · 1 of 4 stories done');
|
|
2175
|
+
expect(rows.some((t: string) => /^lead at work · loop on · last: review ST-9$/.test(t))).toBe(
|
|
2176
|
+
true,
|
|
2177
|
+
);
|
|
2178
|
+
expect(rows).toContain('Review ST-9, then Dispatch ST-10');
|
|
2179
|
+
expect(rows).toContain('web ');
|
|
2180
|
+
expect(rows).toContain('Bash npm run test:e2e');
|
|
2181
|
+
expect(await ui.find({ key: 'band-pause' })).toBeDefined();
|
|
2182
|
+
await ui.unmount();
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
test('MISSION-1 with nothing happening the band has no mission rows', async ($, on) => {
|
|
2186
|
+
const w = project();
|
|
2187
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
2188
|
+
await start($, on, w);
|
|
2189
|
+
const ui = await bandUi($);
|
|
2190
|
+
const rows = (await ui.findAll({ type: 'Text' })).map((t: any) => t.text);
|
|
2191
|
+
expect(rows.some((t: string) => /^(goal|now|next) +$/.test(t))).toBe(false);
|
|
2192
|
+
await ui.unmount();
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2195
|
+
test("VIEW-4 each lane shows its agent's last tool call and the time since, amber once quiet", async ($, on) => {
|
|
2196
|
+
const w = project();
|
|
2197
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
2198
|
+
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
2199
|
+
const clock = await start($, on, w);
|
|
2200
|
+
await $.tool.call({
|
|
2201
|
+
tool: 'Bash',
|
|
2202
|
+
tool_use_id: 'tu-l',
|
|
2203
|
+
command: 'npm test',
|
|
2204
|
+
agentId: 'a1',
|
|
2205
|
+
} as any);
|
|
2206
|
+
await clock.advance(12000);
|
|
2207
|
+
await lanes($);
|
|
2208
|
+
let ui = await pane($, PANE);
|
|
2209
|
+
const live = await ui.find({ type: 'Text', text: /^Bash npm test · / });
|
|
2210
|
+
expect(live?.text).toBe('Bash npm test · 12s ago');
|
|
2211
|
+
expect(live?.props.color).toBeUndefined();
|
|
2212
|
+
await ui.unmount();
|
|
2213
|
+
await clock.advance(5 * 60000);
|
|
2214
|
+
ui = await pane($, PANE);
|
|
2215
|
+
expect((await ui.find({ type: 'Text', text: /^Bash npm test · 5m ago$/ }))?.props.color).toBe(
|
|
2216
|
+
'yellow',
|
|
2217
|
+
);
|
|
2218
|
+
await ui.unmount();
|
|
2219
|
+
});
|
|
2220
|
+
|
|
2221
|
+
test('VIEW-5 what needs the person is pinned at the top with its letter: a held install, a on Approve', async ($, on) => {
|
|
2222
|
+
const w = project();
|
|
2223
|
+
w.refusal = webInstall;
|
|
2224
|
+
const clock = await start($, on, w);
|
|
2225
|
+
await heldCall($, w, clock);
|
|
2226
|
+
await lanes($);
|
|
2227
|
+
const ui = await pane($, PANE);
|
|
2228
|
+
expect((await ui.find({ key: 'need-held' }))?.text).toMatch(
|
|
2229
|
+
/web wants to install dayjs · 2:00for ST-4 Booking form/,
|
|
2230
|
+
);
|
|
2231
|
+
expect((await ui.find({ key: 'need-approveHeld' }))?.props.hotkey).toBe('a');
|
|
2232
|
+
await press($, 'need-approveHeld', PANE);
|
|
2233
|
+
expect(w.opened).toContain(APPROVE);
|
|
2234
|
+
await ui.unmount();
|
|
2235
|
+
w.waiting.get('tu-h1')?.('timed out');
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
test('VIEW-6 tabs on 1 to 7, j and k move between lanes, and the selected lane has its keys', async ($, on) => {
|
|
2239
|
+
const w = project();
|
|
2240
|
+
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
2241
|
+
await start($, on, w);
|
|
2242
|
+
await lanes($);
|
|
2243
|
+
const ui = await pane($, PANE);
|
|
2244
|
+
expect((await ui.find({ key: 'tab-queue' }))?.props.hotkey).toBe('6');
|
|
2245
|
+
expect((await ui.find({ key: 'tab-code' }))?.props.hotkey).toBe('4');
|
|
2246
|
+
await press($, 'tab-queue', PANE);
|
|
2247
|
+
expect(await ui.find({ type: 'Text', text: 'The merge queue is empty.' })).toBeDefined();
|
|
2248
|
+
await press($, 'tab-lanes', PANE);
|
|
2249
|
+
expect((await ui.find({ key: 'lane-next' }))?.props.hotkey).toBe('j');
|
|
2250
|
+
await press($, 'lane-next', PANE);
|
|
2251
|
+
await press($, 'lane-next', PANE);
|
|
2252
|
+
expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/^›/);
|
|
2253
|
+
expect((await ui.find({ key: 'act-nudge' }))?.props.hotkey).toBe('n');
|
|
2254
|
+
await press($, 'act-nudge', PANE);
|
|
2255
|
+
expect(w.sent).toEqual([
|
|
2256
|
+
{ to: 'a1', text: 'The person asks: report where you are, or carry on.' },
|
|
2257
|
+
]);
|
|
2258
|
+
w.answer = 'Stop';
|
|
2259
|
+
await press($, 'act-stop', PANE);
|
|
2260
|
+
expect(w.prompts.at(-1)).toMatch(
|
|
2261
|
+
/^The person asked to stop web-engineer \(agent a1\) on ST-4: stop it with TaskStop/,
|
|
2262
|
+
);
|
|
2263
|
+
await press($, 'lane-prev', PANE);
|
|
2264
|
+
expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/^›/);
|
|
2265
|
+
await ui.unmount();
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
test('VIEW-6 s sends the selected story back, with the reason the person gives', async ($, on) => {
|
|
2269
|
+
const w = project();
|
|
2270
|
+
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
2271
|
+
await start($, on, w);
|
|
2272
|
+
await lanes($);
|
|
2273
|
+
const ui = await pane($, PANE);
|
|
2274
|
+
await press($, 'lane-next', PANE);
|
|
2275
|
+
await press($, 'lane-next', PANE);
|
|
2276
|
+
expect((await ui.find({ key: 'act-sendBack' }))?.props.hotkey).toBe('s');
|
|
2277
|
+
await press($, 'act-sendBack', PANE);
|
|
2278
|
+
expect(w.opened).toContain('code-kit-sendback');
|
|
2279
|
+
const confirm = await pane($, 'code-kit-sendback');
|
|
2280
|
+
await $.ui.input({ plugin: 'code-kit', key: 'sendback-reason', text: '' });
|
|
2281
|
+
expect(await confirm.find({ type: 'Text', text: /Give a reason/ })).toBeDefined();
|
|
2282
|
+
await $.ui.input({
|
|
2283
|
+
plugin: 'code-kit',
|
|
2284
|
+
key: 'sendback-reason',
|
|
2285
|
+
text: 'the cancel button has no test',
|
|
2286
|
+
});
|
|
2287
|
+
expect(w.acts).toEqual([['sent-back', 'web/st-4', '--reason', 'the cancel button has no test']]);
|
|
2288
|
+
expect(w.closed).toContain('code-kit-sendback');
|
|
2289
|
+
await confirm.unmount();
|
|
2290
|
+
await ui.unmount();
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
test('VIEW-6 f puts the keys in the filter, which narrows the lanes by name, agent or story', async ($, on) => {
|
|
2294
|
+
const w = project();
|
|
2295
|
+
await start($, on, w);
|
|
2296
|
+
await lanes($);
|
|
2297
|
+
const ui = await pane($, PANE);
|
|
2298
|
+
expect((await ui.find({ key: 'filter-focus' }))?.props.hotkey).toBe('f');
|
|
2299
|
+
await $.ui.input({ plugin: 'code-kit', key: 'filter', text: 'api-eng', kind: 'change' });
|
|
2300
|
+
expect(await ui.find({ key: 'lane-api' })).toBeDefined();
|
|
2301
|
+
expect(await ui.find({ key: 'lane-web' })).toBeUndefined();
|
|
2302
|
+
await ui.unmount();
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
test('VIEW-8 inline, the pane is the header, what needs the person and one line per lane', async ($, on) => {
|
|
2306
|
+
const w = project();
|
|
2307
|
+
await start($, on, w);
|
|
2308
|
+
await lanes($);
|
|
2309
|
+
const ui = await inlinePane($);
|
|
2310
|
+
expect(await ui.find({ key: 'count-building' })).toBeDefined();
|
|
2311
|
+
expect(await ui.find({ key: 'tabs' })).toBeUndefined();
|
|
2312
|
+
expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/^web\s*building\s*ST-4/);
|
|
2313
|
+
await ui.unmount();
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
// --- the story drill-down ---------------------------------------------------------------------------
|
|
2317
|
+
|
|
2318
|
+
/** The Lanes pane with web's lane selected. */
|
|
2319
|
+
async function onWeb($: any) {
|
|
2320
|
+
await lanes($);
|
|
2321
|
+
const ui = await pane($, PANE);
|
|
2322
|
+
await press($, 'lane-next', PANE);
|
|
2323
|
+
await press($, 'lane-next', PANE);
|
|
2324
|
+
return ui;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
test('VIEW-7 Enter on a lane opens its story: requirements with the spec-check, verify, the diff and its acts', async ($, on) => {
|
|
2328
|
+
const w = project();
|
|
2329
|
+
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
2330
|
+
await start($, on, w);
|
|
2331
|
+
const ui = await onWeb($);
|
|
2332
|
+
const open = await ui.find({ key: 'open-story' });
|
|
2333
|
+
expect(open?.props.label).toBe('Open ST-4');
|
|
2334
|
+
expect(open?.props.hotkey).toBe('o');
|
|
2335
|
+
expect(open?.props.autoFocus).toBe(true);
|
|
2336
|
+
await press($, 'open-story', PANE);
|
|
2337
|
+
expect((await ui.find({ key: 'story-head' }))?.text).toMatch(
|
|
2338
|
+
/ST-4 Booking form\s*web · review · web\/st-4/,
|
|
2339
|
+
);
|
|
2340
|
+
expect((await ui.find({ key: 'req-BOOK-1' }))?.text).toMatch(/spec-check: partial/);
|
|
2341
|
+
expect((await ui.find({ key: 'story-verify' }))?.text).toMatch(/Ownership: apps\/api\/x.ts/);
|
|
2342
|
+
expect((await ui.find({ key: 'file-0' }))?.props.label).toBe('apps/web/form.tsx +2 -0');
|
|
2343
|
+
const code = await ui.find({ type: 'Code' });
|
|
2344
|
+
expect(code?.props.format).toBe('diff');
|
|
2345
|
+
expect(String(code?.props.source)).toMatch(/^--- \/dev\/null\n\+\+\+ b\/apps\/web\/form.tsx\n@@/);
|
|
2346
|
+
await press($, 'file-1', PANE);
|
|
2347
|
+
expect(String((await ui.find({ type: 'Code' }))?.props.source)).toMatch(
|
|
2348
|
+
/\+\+\+ b\/apps\/web\/api.ts/,
|
|
2349
|
+
);
|
|
2350
|
+
expect((await ui.find({ key: 'story-review' }))?.props.hotkey).toBe('r');
|
|
2351
|
+
expect((await ui.find({ key: 'story-sendBack' }))?.props.hotkey).toBe('s');
|
|
2352
|
+
await press($, 'story-back', PANE);
|
|
2353
|
+
expect(await ui.find({ key: 'tabs' })).toBeDefined();
|
|
2354
|
+
await ui.unmount();
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
test("REVW-5 the drill-down shows the reviewer's findings by severity, each a way into the diff", async ($, on) => {
|
|
2358
|
+
const w = reviewing();
|
|
2359
|
+
const clock = await start($, on, w);
|
|
2360
|
+
await leadTurn($);
|
|
2361
|
+
await settle(clock);
|
|
2362
|
+
w.agents = [
|
|
2363
|
+
{
|
|
2364
|
+
id: 'r1',
|
|
2365
|
+
type: 'code-kit:reviewer',
|
|
2366
|
+
description: 'Review web/st-4 (ST-4)',
|
|
2367
|
+
status: 'completed',
|
|
2368
|
+
},
|
|
2369
|
+
];
|
|
2370
|
+
await $.turn.complete({
|
|
2371
|
+
turnId: 't-r1',
|
|
2372
|
+
agentId: 'r1',
|
|
2373
|
+
answer: report({
|
|
2374
|
+
verify: { passed: true, problems: [] },
|
|
2375
|
+
findings: [
|
|
2376
|
+
{ severity: 'nit', where: 'apps/web/form.tsx:2', text: 'x is unused.' },
|
|
2377
|
+
{
|
|
2378
|
+
severity: 'concern',
|
|
2379
|
+
where: 'apps/web/api.ts:2',
|
|
2380
|
+
text: 'No error handling.',
|
|
2381
|
+
rule: 'BOOK-1',
|
|
2382
|
+
},
|
|
2383
|
+
],
|
|
2384
|
+
}),
|
|
2385
|
+
durationMs: 1,
|
|
2386
|
+
isAborted: false,
|
|
2387
|
+
} as any);
|
|
2388
|
+
const ui = await onWeb($);
|
|
2389
|
+
await press($, 'open-story', PANE);
|
|
2390
|
+
const findings = (await ui.find({ key: 'story-findings' }))?.text ?? '';
|
|
2391
|
+
expect(findings).toMatch(/Review of abc123d/);
|
|
2392
|
+
expect(findings.indexOf('Concerns')).toBeLessThan(findings.indexOf('Nits'));
|
|
2393
|
+
await press($, 'finding-concern-0', PANE);
|
|
2394
|
+
expect(String((await ui.find({ type: 'Code' }))?.props.source)).toMatch(/apps\/web\/api.ts/);
|
|
2395
|
+
await ui.unmount();
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
// --- the traceability map ---------------------------------------------------------------------------
|
|
2399
|
+
|
|
2400
|
+
/** 40 requirements over two specs: 30 done (20 tested), 4 in progress, 3 todo, 3 without a story. */
|
|
2401
|
+
function requirementsMap() {
|
|
2402
|
+
const reqs: any[] = [];
|
|
2403
|
+
for (let i = 1; i <= 40; i++) {
|
|
2404
|
+
const state = i <= 30 ? 'done' : i <= 34 ? 'in progress' : i <= 37 ? 'todo' : 'no story';
|
|
2405
|
+
reqs.push({
|
|
2406
|
+
id: `BOOK-${i}`,
|
|
2407
|
+
title: `Requirement ${i}`,
|
|
2408
|
+
file: i <= 20 ? 'docs/spec/01-booking.md' : 'docs/spec/02-payments.md',
|
|
2409
|
+
removed: false,
|
|
2410
|
+
state,
|
|
2411
|
+
stories: state === 'no story' ? [] : ['ST-4'],
|
|
2412
|
+
tests: i <= 20 ? [`apps/web/b${i}.test.ts`] : [],
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
return reqs;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
test('TRACE-1 the map: a cell per requirement, a row per spec, and a legend with the gaps counted', async ($, on) => {
|
|
2419
|
+
const w = project();
|
|
2420
|
+
w.requirements = requirementsMap();
|
|
2421
|
+
await start($, on, w);
|
|
2422
|
+
await lanes($);
|
|
2423
|
+
const ui = await pane($, PANE);
|
|
2424
|
+
expect((await ui.find({ key: 'tab-map' }))?.props.label).toBe('Trail');
|
|
2425
|
+
expect((await ui.find({ key: 'tab-map' }))?.props.hotkey).toBe('5');
|
|
2426
|
+
await press($, 'tab-map', PANE);
|
|
2427
|
+
const legend = (await ui.find({ key: 'map-legend' }))?.text ?? '';
|
|
2428
|
+
expect(legend).toMatch(/■ 20 done and tested/);
|
|
2429
|
+
expect(legend).toMatch(/✗ 3 without a story/);
|
|
2430
|
+
expect((await ui.find({ key: 'map-row-01-booking' }))?.text.replace(/\s/g, '')).toBe(
|
|
2431
|
+
'01-booking' + '■'.repeat(20),
|
|
2432
|
+
);
|
|
2433
|
+
const cells = await Promise.all(w.requirements.map((r) => ui.find({ key: `cell-${r.id}` })));
|
|
2434
|
+
expect(cells.filter((c) => c?.text === '✗')).toHaveLength(3);
|
|
2435
|
+
await ui.unmount();
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
test('TRACE-2 selecting a cell, by keys or the list, shows its spec, stories with their branches, and tests', async ($, on) => {
|
|
2439
|
+
const w = project();
|
|
2440
|
+
w.requirements = requirementsMap();
|
|
2441
|
+
w.stories[0] = { ...w.stories[0], branchExists: true };
|
|
2442
|
+
await start($, on, w);
|
|
2443
|
+
await lanes($);
|
|
2444
|
+
const ui = await pane($, PANE);
|
|
2445
|
+
await press($, 'tab-map', PANE);
|
|
2446
|
+
expect((await ui.find({ key: 'map-detail' }))?.text).toMatch(/BOOK-1 Requirement 1/);
|
|
2447
|
+
await press($, 'cell-next', PANE);
|
|
2448
|
+
expect(JSON.stringify((await ui.find({ key: 'cell-BOOK-2' }))?.children)).toMatch(
|
|
2449
|
+
/"inverse":true/,
|
|
2450
|
+
);
|
|
2451
|
+
await press($, 'row-next', PANE);
|
|
2452
|
+
expect(JSON.stringify((await ui.find({ key: 'cell-BOOK-21' }))?.children)).toMatch(
|
|
2453
|
+
/"inverse":true/,
|
|
2454
|
+
);
|
|
2455
|
+
await $.ui.select({ plugin: 'code-kit', key: 'map-select', value: 'BOOK-3', requestId: PANE });
|
|
2456
|
+
const detail = (await ui.find({ key: 'map-detail' }))?.text ?? '';
|
|
2457
|
+
expect(detail).toMatch(/docs\/spec\/01-booking.md · done and tested/);
|
|
2458
|
+
expect(detail).toMatch(/ST-4 Booking form · web · in progress · web\/st-4/);
|
|
2459
|
+
expect(detail).toMatch(/apps\/web\/b3.test.ts/);
|
|
2460
|
+
await $.ui.select({ plugin: 'code-kit', key: 'map-select', value: 'BOOK-40', requestId: PANE });
|
|
2461
|
+
expect((await ui.find({ key: 'map-detail' }))?.text).toMatch(/No story delivers it yet/);
|
|
2462
|
+
await ui.unmount();
|
|
2463
|
+
});
|
|
2464
|
+
|
|
2465
|
+
test('TRACE-3 the map follows the project: a commit that finishes a requirement shows within 2 seconds', async ($, on) => {
|
|
2466
|
+
const w = project();
|
|
2467
|
+
w.requirements = requirementsMap();
|
|
2468
|
+
const clock = await start($, on, w);
|
|
2469
|
+
await lanes($);
|
|
2470
|
+
const ui = await pane($, PANE);
|
|
2471
|
+
await press($, 'tab-map', PANE);
|
|
2472
|
+
w.requirements = w.requirements.map((r) =>
|
|
2473
|
+
r.id === 'BOOK-31' ? { ...r, state: 'done', tests: ['t'] } : r,
|
|
2474
|
+
);
|
|
2475
|
+
w.refs += ' ddd refs/heads/web/st-9\n';
|
|
2476
|
+
await clock.advance(2000);
|
|
2477
|
+
expect((await ui.find({ key: 'map-legend' }))?.text).toMatch(/■ 21 done and tested/);
|
|
2478
|
+
await ui.unmount();
|
|
2479
|
+
});
|
|
2480
|
+
|
|
2481
|
+
// --- timelines and characters ---------------------------------------------------------------------
|
|
2482
|
+
|
|
2483
|
+
test('VIEW-2 a lane dispatched 40 minutes ago and in review for 5 shows 35 minutes building and 5 in review, to now', async ($, on) => {
|
|
2484
|
+
const w = project();
|
|
2485
|
+
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
2486
|
+
const clock = await start($, on, w);
|
|
2487
|
+
const now = clock.now() + 2000;
|
|
2488
|
+
w.times = [
|
|
2489
|
+
{
|
|
2490
|
+
id: 'ST-4',
|
|
2491
|
+
lane: 'web',
|
|
2492
|
+
branch: 'web/st-4',
|
|
2493
|
+
state: 'review',
|
|
2494
|
+
started: now - 40 * 60000,
|
|
2495
|
+
lastCommit: now - 5 * 60000,
|
|
2496
|
+
merged: null,
|
|
2497
|
+
sentBack: [],
|
|
2498
|
+
approvals: [],
|
|
2499
|
+
},
|
|
2500
|
+
];
|
|
2501
|
+
w.refs += ' eee refs/heads/web/st-4-again\n';
|
|
2502
|
+
await clock.advance(2000);
|
|
2503
|
+
await lanes($);
|
|
2504
|
+
const ui = await pane($, PANE);
|
|
2505
|
+
const bar = (await ui.find({ key: 'bar-web' }))?.text.trim() ?? '';
|
|
2506
|
+
const building = [...bar].filter((c) => c === '█').length;
|
|
2507
|
+
const review = [...bar].filter((c) => c === '▒').length;
|
|
2508
|
+
expect(building + review).toBe(66);
|
|
2509
|
+
expect(Math.abs(building - (66 * 35) / 40)).toBeLessThanOrEqual(1);
|
|
2510
|
+
expect(bar.endsWith('▒')).toBe(true);
|
|
2511
|
+
await ui.unmount();
|
|
2512
|
+
});
|
|
2513
|
+
|
|
2514
|
+
test('VIEW-3 each lane has its character: web works, api waits on a held call, the lead rests', async ($, on) => {
|
|
2515
|
+
const w = project();
|
|
2516
|
+
w.refusal =
|
|
2517
|
+
'Blocked: a new dependency (zod) needs a person\'s approval. Write a short change request for the lead.\n ! echo "<what you are approving>" > .claude/approvals/api/dep-zod\n(a person runs it; it allows installing it for the api lane, until the install is committed, at most 7 days)\nCommand: npm install zod';
|
|
2518
|
+
w.agents = [
|
|
2519
|
+
{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' },
|
|
2520
|
+
{ id: 'a2', type: 'api-engineer', description: 'ST-5', status: 'running' },
|
|
2521
|
+
];
|
|
2522
|
+
const clock = await start($, on, w);
|
|
2523
|
+
w.holdWaits = true;
|
|
2524
|
+
const call = $.tool.call({
|
|
2525
|
+
tool: 'Bash',
|
|
2526
|
+
tool_use_id: 'tu-z',
|
|
2527
|
+
command: 'npm install zod',
|
|
2528
|
+
agentId: 'a2',
|
|
2529
|
+
} as any);
|
|
2530
|
+
for (let i = 0; i < 50 && !w.waiting.has('tu-z'); i++) await clock.advance(1);
|
|
2531
|
+
await lanes($);
|
|
2532
|
+
const ui = await pane($, PANE);
|
|
2533
|
+
const names = ['lead', 'web', 'api', 'core'];
|
|
2534
|
+
const cells = async (lane: string) => (await ui.find({ key: `char-${lane}` }))?.props.cells;
|
|
2535
|
+
expect(await cells('web')).toBe(rasterCells(sprite('working', 0, tintOf('web', names))));
|
|
2536
|
+
expect(await cells('api')).toBe(rasterCells(sprite('waiting', 0, tintOf('api', names))));
|
|
2537
|
+
expect(await cells('lead')).toBe(
|
|
2538
|
+
rasterCells(sprite('idle', 0, tintOf('lead', names), { lead: true })),
|
|
2539
|
+
);
|
|
2540
|
+
// The working character repaints in place; the waiting one doesn't.
|
|
2541
|
+
await clock.advance(2000);
|
|
2542
|
+
await clock.advance(1200);
|
|
2543
|
+
expect(w.blits).toContain('char-web');
|
|
2544
|
+
expect(w.blits).not.toContain('char-api');
|
|
2545
|
+
await ui.unmount();
|
|
2546
|
+
w.waiting.get('tu-z')?.('timed out');
|
|
2547
|
+
await call;
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
test('VIEW-3 nothing repaints when no agent is working', async ($, on) => {
|
|
2551
|
+
const w = project();
|
|
2552
|
+
const clock = await start($, on, w);
|
|
2553
|
+
await lanes($);
|
|
2554
|
+
const ui = await pane($, PANE);
|
|
2555
|
+
await clock.advance(6000);
|
|
2556
|
+
expect(w.blits).toEqual([]);
|
|
2557
|
+
await ui.unmount();
|
|
2558
|
+
});
|
|
2559
|
+
|
|
2560
|
+
test('VIEW-2 and VIEW-3 on Desktop the characters and timelines are SVG', async ($, on) => {
|
|
2561
|
+
const w = project();
|
|
2562
|
+
w.agents = [{ id: 'a1', type: 'web-engineer', description: 'ST-4', status: 'running' }];
|
|
2563
|
+
const clock = await start($, on, w);
|
|
2564
|
+
const now = clock.now() + 2000;
|
|
2565
|
+
w.times = [
|
|
2566
|
+
{
|
|
2567
|
+
id: 'ST-4',
|
|
2568
|
+
lane: 'web',
|
|
2569
|
+
branch: 'web/st-4',
|
|
2570
|
+
state: 'in progress',
|
|
2571
|
+
started: now - 600000,
|
|
2572
|
+
lastCommit: null,
|
|
2573
|
+
merged: null,
|
|
2574
|
+
sentBack: [],
|
|
2575
|
+
approvals: [],
|
|
2576
|
+
},
|
|
2577
|
+
];
|
|
2578
|
+
w.refs += ' fff refs/heads/x\n';
|
|
2579
|
+
await clock.advance(2000);
|
|
2580
|
+
await lanes($);
|
|
2581
|
+
const ui = await $.ui.mount({
|
|
2582
|
+
plugin: 'code-kit',
|
|
2583
|
+
component: 'Pane',
|
|
2584
|
+
requestId: PANE,
|
|
2585
|
+
surface: 'desktop',
|
|
2586
|
+
viewport: { columns: 120, rows: 40 },
|
|
2587
|
+
props: { title: PANE, isFocused: true, bodyColumns: 80, placement: 'dock' },
|
|
2588
|
+
});
|
|
2589
|
+
const svgs = await ui.findAll({ type: 'Svg' });
|
|
2590
|
+
expect(
|
|
2591
|
+
svgs.some((s) => s.props.alt === 'web: working' && String(s.props.source).includes('<animate')),
|
|
2592
|
+
).toBe(true);
|
|
2593
|
+
expect(svgs.some((s) => s.props.alt === "web's timeline")).toBe(true);
|
|
2594
|
+
await ui.unmount();
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
// --- the combined lane view ---------------------------------------------------------------------------
|
|
2598
|
+
|
|
2599
|
+
/** A project where Context Graph's adapter reports on the web lane's agent. */
|
|
2600
|
+
function combinedProject() {
|
|
2601
|
+
const w = project();
|
|
2602
|
+
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
2603
|
+
(w.check as any).adapters = ['context-graph'];
|
|
2604
|
+
w.adapters = [
|
|
2605
|
+
{
|
|
2606
|
+
name: 'context-graph',
|
|
2607
|
+
lanes: {
|
|
2608
|
+
'web-engineer': {
|
|
2609
|
+
withoutUnderstanding: [{ path: 'apps/web/form.tsx', unread: ['apps/web/api.ts'] }],
|
|
2610
|
+
cardsOwed: ['apps/web/form.tsx', 'apps/web/api.ts'],
|
|
2611
|
+
},
|
|
2612
|
+
},
|
|
2613
|
+
open: { command: 'graph' },
|
|
2614
|
+
},
|
|
2615
|
+
];
|
|
2616
|
+
w.story.adapters = [
|
|
2617
|
+
{
|
|
2618
|
+
name: 'context-graph',
|
|
2619
|
+
files: {
|
|
2620
|
+
'apps/web/form.tsx': {
|
|
2621
|
+
rules: [
|
|
2622
|
+
{ id: 'K:no-fetch', text: 'No fetch in components', mode: 'E', proposed: false },
|
|
2623
|
+
{ id: 'K:dates', text: 'Dates through lib/date', mode: 'G?', proposed: true },
|
|
2624
|
+
],
|
|
2625
|
+
},
|
|
2626
|
+
'apps/web/api.ts': {
|
|
2627
|
+
rules: [{ id: 'K:no-fetch', text: 'No fetch in components', mode: 'E', proposed: false }],
|
|
2628
|
+
},
|
|
2629
|
+
},
|
|
2630
|
+
open: { command: 'graph' },
|
|
2631
|
+
},
|
|
2632
|
+
];
|
|
2633
|
+
return w;
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
test('JOIN-2 each lane shows its edits without understanding, red above zero, and its cards owed', async ($, on) => {
|
|
2637
|
+
const w = combinedProject();
|
|
2638
|
+
await start($, on, w);
|
|
2639
|
+
await lanes($);
|
|
2640
|
+
const ui = await pane($, PANE);
|
|
2641
|
+
expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(
|
|
2642
|
+
/1 edited without understanding\s*2 cards owed/,
|
|
2643
|
+
);
|
|
2644
|
+
expect(
|
|
2645
|
+
(await ui.find({ type: 'Text', text: '1 edited without understanding' }))?.props.color,
|
|
2646
|
+
).toBe('red');
|
|
2647
|
+
// LANES-2: a lane whose facts are all zero shows none of them.
|
|
2648
|
+
expect((await ui.find({ key: 'lane-api' }))?.text).not.toMatch(/understanding|owed/);
|
|
2649
|
+
await ui.unmount();
|
|
2650
|
+
});
|
|
2651
|
+
|
|
2652
|
+
test('JOIN-1 without an adapter that knows, the lanes show no such slots', async ($, on) => {
|
|
2653
|
+
const w = project();
|
|
2654
|
+
await start($, on, w);
|
|
2655
|
+
await lanes($);
|
|
2656
|
+
const ui = await pane($, PANE);
|
|
2657
|
+
expect((await ui.find({ key: 'lane-web' }))?.text).not.toMatch(/understanding|owed/);
|
|
2658
|
+
await ui.unmount();
|
|
2659
|
+
});
|
|
2660
|
+
|
|
2661
|
+
test('JOIN-2 to JOIN-4 the drill-down names the unread files, the cards owed and the rules, and opens a file in Context Graph', async ($, on) => {
|
|
2662
|
+
const w = combinedProject();
|
|
2663
|
+
await start($, on, w);
|
|
2664
|
+
const ui = await onWeb($);
|
|
2665
|
+
await press($, 'open-story', PANE);
|
|
2666
|
+
expect((await ui.find({ key: 'story-understanding' }))?.text).toMatch(
|
|
2667
|
+
/apps\/web\/form.tsx\s*still unread: apps\/web\/api.ts/,
|
|
2668
|
+
);
|
|
2669
|
+
expect((await ui.find({ key: 'story-cards' }))?.text).toMatch(/Cards owed \(2\)/);
|
|
2670
|
+
const rules = (await ui.find({ key: 'story-rules' }))?.text ?? '';
|
|
2671
|
+
expect(rules).toMatch(/agreed\s+K:no-fetch\s+No fetch in components\s+\(2 files\)/);
|
|
2672
|
+
expect(rules.indexOf('K:no-fetch')).toBeLessThan(rules.indexOf('K:dates'));
|
|
2673
|
+
expect(rules).toMatch(/proposed\s+K:dates/);
|
|
2674
|
+
expect((await ui.find({ key: 'story-open-in' }))?.props.hotkey).toBe('v');
|
|
2675
|
+
await press($, 'story-open-in', PANE);
|
|
2676
|
+
expect(w.commands).toContainEqual({ command: 'graph', args: 'apps/web/form.tsx' });
|
|
2677
|
+
await press($, 'unread-file-0', PANE);
|
|
2678
|
+
expect(w.commands.length).toBe(2);
|
|
2679
|
+
await ui.unmount();
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2682
|
+
test("with no session bound (claude -p before it mounts), the agent list's failure leaves the project's refresh working", async ($, on) => {
|
|
2683
|
+
const w = project();
|
|
2684
|
+
w.agentsThrow = true;
|
|
2685
|
+
const clock = await start($, on, w);
|
|
2686
|
+
await lanes($);
|
|
2687
|
+
w.stories[0] = { ...w.stories[0], state: 'review' };
|
|
2688
|
+
w.refs += ' abc refs/heads/web/st-4-done\n';
|
|
2689
|
+
await clock.advance(2000);
|
|
2690
|
+
const ui = await pane($, PANE);
|
|
2691
|
+
expect((await ui.find({ key: 'lane-web' }))?.text).toMatch(/in review/);
|
|
2692
|
+
await ui.unmount();
|
|
2693
|
+
});
|
|
2694
|
+
|
|
2695
|
+
test('REVW-2 a reviewer that could not be registered yet is registered at a later refresh, then used', async ($, on) => {
|
|
2696
|
+
const w = reviewing();
|
|
2697
|
+
w.registerFails = 1;
|
|
2698
|
+
const clock = await start($, on, w);
|
|
2699
|
+
expect(w.registered).toEqual([]);
|
|
2700
|
+
w.refs += ' abc refs/heads/x\n';
|
|
2701
|
+
await clock.advance(2000);
|
|
2702
|
+
expect(w.registered.map((s) => s.name)).toEqual(['reviewer']);
|
|
2703
|
+
await leadTurn($);
|
|
2704
|
+
await settle(clock);
|
|
2705
|
+
expect(w.prompts[0]).toMatch(/^Start code-kit's reviewer/);
|
|
2706
|
+
});
|
|
2707
|
+
|
|
2708
|
+
// --- the first offer: how hands-off the loop is -----------------------------------------------------
|
|
2709
|
+
|
|
2710
|
+
/** A project where nobody has chosen autonomy yet: the default stands in. */
|
|
2711
|
+
function unchosen() {
|
|
2712
|
+
const w = project();
|
|
2713
|
+
w.check.harnessSet = [];
|
|
2714
|
+
return w;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
test('until autonomy is chosen, the loop offers its first step: keep going, ask each time, or off', async ($, on) => {
|
|
2718
|
+
const w = unchosen();
|
|
2719
|
+
const clock = await start($, on, w);
|
|
2720
|
+
await leadTurn($);
|
|
2721
|
+
await settle(clock);
|
|
2722
|
+
expect(w.prompts).toEqual([]);
|
|
2723
|
+
const ui = await bandUi($);
|
|
2724
|
+
expect(
|
|
2725
|
+
await ui.find({
|
|
2726
|
+
type: 'Text',
|
|
2727
|
+
text: 'code-kit can keep the build moving between your prompts. Next: Dispatch ST-9.',
|
|
2728
|
+
}),
|
|
2729
|
+
).toBeDefined();
|
|
2730
|
+
expect((await ui.find({ key: 'band-keepGoing' }))?.props.label).toMatch(/Keep going$/);
|
|
2731
|
+
expect(await ui.find({ key: 'band-askEach' })).toBeDefined();
|
|
2732
|
+
expect(await ui.find({ key: 'band-loopOff' })).toBeDefined();
|
|
2733
|
+
await ui.unmount();
|
|
2734
|
+
});
|
|
2735
|
+
|
|
2736
|
+
test("Keep going records autonomous as the person's choice and takes the step", async ($, on) => {
|
|
2737
|
+
const w = unchosen();
|
|
2738
|
+
const clock = await start($, on, w);
|
|
2739
|
+
await leadTurn($);
|
|
2740
|
+
await settle(clock);
|
|
2741
|
+
const ui = await bandUi($);
|
|
2742
|
+
await press($, 'band-keepGoing');
|
|
2743
|
+
await ui.unmount();
|
|
2744
|
+
expect(w.acts[0]).toEqual([
|
|
2745
|
+
'settings',
|
|
2746
|
+
'set',
|
|
2747
|
+
'autonomy',
|
|
2748
|
+
'autonomous',
|
|
2749
|
+
'--reason',
|
|
2750
|
+
'Chosen when code-kit first offered to keep the build moving between prompts',
|
|
2751
|
+
'--via',
|
|
2752
|
+
'pane',
|
|
2753
|
+
]);
|
|
2754
|
+
expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
|
|
2755
|
+
// From then on it acts on its own.
|
|
2756
|
+
w.next = { step: 'review', args: 'ST-4', then: [] };
|
|
2757
|
+
await leadTurn($, 't-2');
|
|
2758
|
+
await settle(clock);
|
|
2759
|
+
expect(w.prompts[1]).toBe('Review ST-4: run /code-kit:review ST-4.');
|
|
2760
|
+
});
|
|
2761
|
+
|
|
2762
|
+
test('Ask each time records propose, and the step waits for Go', async ($, on) => {
|
|
2763
|
+
const w = unchosen();
|
|
2764
|
+
const clock = await start($, on, w);
|
|
2765
|
+
await leadTurn($);
|
|
2766
|
+
await settle(clock);
|
|
2767
|
+
let ui = await bandUi($);
|
|
2768
|
+
await press($, 'band-askEach');
|
|
2769
|
+
await ui.unmount();
|
|
2770
|
+
expect(w.acts[0]?.slice(0, 4)).toEqual(['settings', 'set', 'autonomy', 'propose']);
|
|
2771
|
+
expect(w.prompts).toEqual([]);
|
|
2772
|
+
ui = await bandUi($);
|
|
2773
|
+
expect(await ui.find({ type: 'Text', text: 'Dispatch ST-9?' })).toBeDefined();
|
|
2774
|
+
await press($, 'band-go');
|
|
2775
|
+
await ui.unmount();
|
|
2776
|
+
expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
test('Off records off, and the loop does nothing', async ($, on) => {
|
|
2780
|
+
const w = unchosen();
|
|
2781
|
+
const clock = await start($, on, w);
|
|
2782
|
+
await leadTurn($);
|
|
2783
|
+
await settle(clock);
|
|
2784
|
+
let ui = await bandUi($);
|
|
2785
|
+
await press($, 'band-loopOff');
|
|
2786
|
+
await ui.unmount();
|
|
2787
|
+
expect(w.acts[0]?.slice(0, 4)).toEqual(['settings', 'set', 'autonomy', 'off']);
|
|
2788
|
+
await leadTurn($, 't-2');
|
|
2789
|
+
await settle(clock);
|
|
2790
|
+
expect(w.prompts).toEqual([]);
|
|
2791
|
+
ui = await bandUi($);
|
|
2792
|
+
expect(await ui.find({ type: 'Text', text: /keep the build moving|Dispatch/ })).toBeUndefined();
|
|
2793
|
+
await ui.unmount();
|
|
2794
|
+
});
|
|
2795
|
+
|
|
2796
|
+
// --- an idle lane's detail --------------------------------------------------------------------------
|
|
2797
|
+
|
|
2798
|
+
test('VIEW-6 a selected idle lane shows what it owns and its stories, Enter opens its next one, and the header counts it', async ($, on) => {
|
|
2799
|
+
const w = project();
|
|
2800
|
+
w.stories = [
|
|
2801
|
+
{ id: 'ST-1', title: 'Schema', lane: 'api', state: 'done', branch: 'api/st-1', waitingOn: [] },
|
|
2802
|
+
{
|
|
2803
|
+
id: 'ST-2',
|
|
2804
|
+
title: 'Endpoints',
|
|
2805
|
+
lane: 'api',
|
|
2806
|
+
state: 'blocked',
|
|
2807
|
+
branch: 'api/st-2',
|
|
2808
|
+
waitingOn: ['ST-9'],
|
|
2809
|
+
},
|
|
2810
|
+
{
|
|
2811
|
+
id: 'ST-9',
|
|
2812
|
+
title: 'Pricing',
|
|
2813
|
+
lane: 'core',
|
|
2814
|
+
state: 'ready',
|
|
2815
|
+
branch: 'core/st-9',
|
|
2816
|
+
waitingOn: [],
|
|
2817
|
+
},
|
|
2818
|
+
];
|
|
2819
|
+
await start($, on, w);
|
|
2820
|
+
await lanes($);
|
|
2821
|
+
const ui = await pane($, PANE);
|
|
2822
|
+
expect(await ui.find({ type: 'Text', text: '1 of 3 stories done' })).toBeDefined();
|
|
2823
|
+
expect((await ui.find({ key: 'count-idle' }))?.props.label).toMatch(/· \d+ idle/);
|
|
2824
|
+
for (let i = 0; i < 3; i++) await press($, 'lane-next', PANE);
|
|
2825
|
+
const api = (await ui.find({ key: 'lane-api' }))?.text ?? '';
|
|
2826
|
+
expect(api).toMatch(/owns\s*apps\/api\/\*\*/);
|
|
2827
|
+
expect(api).toMatch(/agent\s*api-engineer/);
|
|
2828
|
+
// LANES-3: the lane's stories as cards.
|
|
2829
|
+
expect((await ui.find({ key: 'card-ST-1' }))?.text).toMatch(/ST-1\s*merged\s*Schema/);
|
|
2830
|
+
expect((await ui.find({ key: 'card-ST-2' }))?.text).toMatch(
|
|
2831
|
+
/ST-2\s*blocked\s*Endpoints.*waits on ST-9/,
|
|
2832
|
+
);
|
|
2833
|
+
expect((await ui.find({ key: 'open-story' }))?.props.label).toBe('Open ST-2');
|
|
2834
|
+
await press($, 'open-story', PANE);
|
|
2835
|
+
expect(await ui.find({ key: 'story-head' })).toBeDefined();
|
|
2836
|
+
await ui.unmount();
|
|
2837
|
+
});
|
|
2838
|
+
|
|
2839
|
+
test('VIEW-6 the filter is one row, its key beside the box', async ($, on) => {
|
|
2840
|
+
const w = project();
|
|
2841
|
+
await start($, on, w);
|
|
2842
|
+
await lanes($);
|
|
2843
|
+
const ui = await pane($, PANE);
|
|
2844
|
+
const row = await ui.find({ key: 'filter-row' });
|
|
2845
|
+
expect(row).toBeDefined();
|
|
2846
|
+
expect((await ui.find({ key: 'filter-focus' }))?.props.hotkey).toBe('f');
|
|
2847
|
+
expect(await ui.findAll({ key: 'filter' })).toHaveLength(1);
|
|
2848
|
+
await ui.unmount();
|
|
2849
|
+
});
|
|
2850
|
+
|
|
2851
|
+
test('the lead reads orchestrating while its turn runs, and idle once it ends; an agent at work outside a story too', async ($, on) => {
|
|
2852
|
+
const w = project();
|
|
2853
|
+
w.agents = [
|
|
2854
|
+
{ id: 'a9', type: 'api-engineer', description: 'look at the logs', status: 'running' },
|
|
2855
|
+
];
|
|
2856
|
+
const clock = await start($, on, w);
|
|
2857
|
+
await lanes($);
|
|
2858
|
+
const ui = await pane($, PANE);
|
|
2859
|
+
expect((await ui.find({ key: 'lane-api' }))?.text).toMatch(/api\s*working/);
|
|
2860
|
+
await $.turn.start({ turnId: 't-1', text: '' } as any);
|
|
2861
|
+
// LANES-2: the lead mid-turn is orchestrating, not a lane at work on nothing.
|
|
2862
|
+
expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/lead\s*orchestrating/);
|
|
2863
|
+
expect((await ui.find({ key: 'count-working' }))?.props.label).toBe('▶ 1 working');
|
|
2864
|
+
await $.turn.complete({ turnId: 't-1', answer: '', durationMs: 1, isAborted: false } as any);
|
|
2865
|
+
await settle(clock);
|
|
2866
|
+
expect((await ui.find({ key: 'lane-lead' }))?.text).toMatch(/lead\s*idle/);
|
|
2867
|
+
await ui.unmount();
|
|
2868
|
+
});
|