@warren-dean/code-kit 0.13.0 → 0.15.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 +17 -0
- package/README.md +35 -30
- package/bin/code-kit.mjs +26 -6
- package/hooks/guard-bash.mjs +5 -0
- package/hooks/lib/harness.mjs +9 -0
- package/hooks/lib/loop-state.mjs +49 -9
- package/hooks/mod/decisions.mjs +75 -0
- package/hooks/mod/loop.mjs +91 -0
- package/hooks/mod/mission.mjs +19 -15
- package/hooks/mod/register.mjs +305 -14
- package/hooks/mod/register.test.ts +384 -8
- package/hooks/mod/view.mjs +67 -4
- package/package.json +1 -1
- package/skills/init/SKILL.md +2 -1
|
@@ -152,6 +152,12 @@ function project() {
|
|
|
152
152
|
} as any,
|
|
153
153
|
sent: [] as { to: any; text: string }[],
|
|
154
154
|
loopPaused: false, // the pause recorded by `code-kit loop`
|
|
155
|
+
leadSession: null as string | null, // LOOP-10: the session holding the lead lease
|
|
156
|
+
leadClaims: 0,
|
|
157
|
+
asked: [] as { question: string; options: string[]; header?: string }[], // dialogs the mod opened
|
|
158
|
+
typed: null as string | null, // what the person types under the dialog's Other, if anything
|
|
159
|
+
agentFiles: {} as Record<string, string>, // .claude/agents/<name>.md, by path
|
|
160
|
+
spawned: [] as any[], // agent.spawn as it reached Claude Code beneath the mod
|
|
155
161
|
map: null as any, // `code-kit map <story> --json`
|
|
156
162
|
trail: null as any, // `code-kit trail --json`
|
|
157
163
|
journal: { events: [] as any[] }, // `code-kit journal --json`
|
|
@@ -244,7 +250,28 @@ function stub(on: any, w: World) {
|
|
|
244
250
|
w.loopPaused = argv[3] === 'pause';
|
|
245
251
|
return ran(0, '');
|
|
246
252
|
}
|
|
247
|
-
if (sub === 'loop'
|
|
253
|
+
if (sub === 'loop' && argv[3] === 'lead') {
|
|
254
|
+
const session = argv[argv.indexOf('--session') + 1];
|
|
255
|
+
if (w.leadSession && w.leadSession !== session && !argv.includes('--take'))
|
|
256
|
+
return ran(1, '', `Another session (${w.leadSession}) runs the lead loop in this project.`);
|
|
257
|
+
w.leadSession = session;
|
|
258
|
+
w.leadClaims += 1;
|
|
259
|
+
return ran(0, 'This session runs the lead loop.');
|
|
260
|
+
}
|
|
261
|
+
if (sub === 'loop' && argv[3] === 'release') {
|
|
262
|
+
w.acts.push([...argv.slice(2)]);
|
|
263
|
+
if (w.leadSession === argv[argv.indexOf('--session') + 1]) w.leadSession = null;
|
|
264
|
+
return ran(0, '');
|
|
265
|
+
}
|
|
266
|
+
if (sub === 'loop')
|
|
267
|
+
return ran(
|
|
268
|
+
0,
|
|
269
|
+
JSON.stringify({
|
|
270
|
+
paused: w.loopPaused,
|
|
271
|
+
at: null,
|
|
272
|
+
lead: w.leadSession ? { session: w.leadSession, at: '2026-10-16T00:00:00.000Z' } : null,
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
248
275
|
if (sub === 'sent-back') {
|
|
249
276
|
w.acts.push([...argv.slice(2)]);
|
|
250
277
|
return ran(0, `Recorded: ${argv[3]} was sent back.`);
|
|
@@ -371,6 +398,15 @@ function stub(on: any, w: World) {
|
|
|
371
398
|
});
|
|
372
399
|
on('turn.start', ($: any, e: any) => e);
|
|
373
400
|
on('turn.complete', () => ({ text: '' }));
|
|
401
|
+
on('session.end', ($: any, e: any) => ({ sessionId: e.sessionId }));
|
|
402
|
+
on('fs.read', ($: any, e: any) => {
|
|
403
|
+
if (w.agentFiles[e.path] === undefined) throw new Error(`ENOENT: ${e.path}`);
|
|
404
|
+
return { value: w.agentFiles[e.path] };
|
|
405
|
+
});
|
|
406
|
+
on('agent.spawn', ($: any, e: any) => {
|
|
407
|
+
w.spawned.push(e);
|
|
408
|
+
return { model: e.model ?? 'claude-opus-5-5', agentId: `a-${w.spawned.length}` };
|
|
409
|
+
});
|
|
374
410
|
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: w.limits } }));
|
|
375
411
|
on('session.measure', ($: any, e: any) => ({ changed: e.changed }));
|
|
376
412
|
// A model request, answered with the usage the test gave it.
|
|
@@ -397,10 +433,16 @@ function stub(on: any, w: World) {
|
|
|
397
433
|
if (w.leadBusy) return new Promise(() => {});
|
|
398
434
|
return { text: e.text };
|
|
399
435
|
});
|
|
400
|
-
// $.ui.ask is the AskUserQuestion dialog: the person picks
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
436
|
+
// $.ui.ask is the AskUserQuestion dialog: the person picks w.answer where it's one of its labels, types
|
|
437
|
+
// w.typed under Other, or else dismisses it.
|
|
438
|
+
on('tool.call', { tool: 'AskUserQuestion' }, ($: any, e: any) => {
|
|
439
|
+
const q = e.questions[0];
|
|
440
|
+
const labels = (q.options ?? []).map((o: any) => o.label);
|
|
441
|
+
w.asked.push({ question: q.question, options: labels, header: q.header });
|
|
442
|
+
const answer = labels.includes(w.answer) ? w.answer : w.typed;
|
|
443
|
+
if (answer == null) return { deny: 'The person dismissed the question.' };
|
|
444
|
+
return { result: { questions: e.questions, answers: { [q.question]: answer } } };
|
|
445
|
+
});
|
|
404
446
|
// Another plugin's command, as the mod asks for it (Context Graph's /graph).
|
|
405
447
|
on('command.run', ($: any, e: any) => {
|
|
406
448
|
w.commands.push({ command: e.command, args: e.args });
|
|
@@ -559,10 +601,10 @@ test('PANE-4 with nothing changed it reads the project every 10 seconds while th
|
|
|
559
601
|
const before = w.runs;
|
|
560
602
|
await clock.advance(8000); // four quiet ticks: git only
|
|
561
603
|
expect(w.runs - before).toBe(4);
|
|
562
|
-
// The fifth reads it all (check, status, next, requests, stops, queue, timeline, the lead's questions
|
|
563
|
-
// and the
|
|
604
|
+
// The fifth reads it all (check, status, next, requests, stops, queue, timeline, the lead's questions,
|
|
605
|
+
// the reports waiting, and which session runs the loop, while this one doesn't) between two looks at git.
|
|
564
606
|
await clock.advance(2000);
|
|
565
|
-
expect(w.runs - before).toBe(
|
|
607
|
+
expect(w.runs - before).toBe(16);
|
|
566
608
|
});
|
|
567
609
|
|
|
568
610
|
test('CARD-4 outside a code-kit project, /lanes says so, opens nothing, and there is no band', async ($, on) => {
|
|
@@ -2295,6 +2337,340 @@ test('LOOP-7 when every story is done the loop stops, and the band says so until
|
|
|
2295
2337
|
await ui.unmount();
|
|
2296
2338
|
});
|
|
2297
2339
|
|
|
2340
|
+
/** The lead's turn, ending with what it said; `isAborted` when the person stopped it. */
|
|
2341
|
+
async function leadSays($: any, id: string, answer: string, isAborted = false) {
|
|
2342
|
+
await $.turn.start({ turnId: id, text: '' } as any);
|
|
2343
|
+
await $.turn.complete({
|
|
2344
|
+
turnId: id,
|
|
2345
|
+
answer,
|
|
2346
|
+
durationMs: 1,
|
|
2347
|
+
isAborted,
|
|
2348
|
+
reason: isAborted ? 'aborted' : 'answer',
|
|
2349
|
+
} as any);
|
|
2350
|
+
}
|
|
2351
|
+
/** What the person types and sends to the lead. */
|
|
2352
|
+
const personSays = ($: any, text: string) =>
|
|
2353
|
+
$.prompt.submit({ text, origin: { kind: 'composer' }, wait: false } as any);
|
|
2354
|
+
/** The side call's reading of the lead's reply (LOOP-11). */
|
|
2355
|
+
const outcome = (o: any) => ({ isAnswered: true, text: JSON.stringify(o), usage: null });
|
|
2356
|
+
const unread = { isAnswered: true, text: 'Nothing to classify.', usage: null };
|
|
2357
|
+
|
|
2358
|
+
test('LOOP-10 a second session opened in the project never prompts its lead; Lead from here moves the loop to it', async ($, on) => {
|
|
2359
|
+
const w = project();
|
|
2360
|
+
w.leadSession = 's-other'; // a working session already runs the loop
|
|
2361
|
+
const clock = await start($, on, w);
|
|
2362
|
+
await leadTurn($);
|
|
2363
|
+
await settle(clock);
|
|
2364
|
+
await clock.advance(4000);
|
|
2365
|
+
expect(w.prompts).toEqual([]);
|
|
2366
|
+
const ui = await bandUi($);
|
|
2367
|
+
expect(await ui.find({ type: 'Text', text: 'the loop runs in another session' })).toBeDefined();
|
|
2368
|
+
await press($, 'band-leadHere');
|
|
2369
|
+
await ui.unmount();
|
|
2370
|
+
expect(w.leadSession).toBe('s1');
|
|
2371
|
+
expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
test("LOOP-10 a new session leaves a live lead session's pause alone", async ($, on) => {
|
|
2375
|
+
const w = project();
|
|
2376
|
+
w.loopPaused = true;
|
|
2377
|
+
w.leadSession = 's-other';
|
|
2378
|
+
await start($, on, w);
|
|
2379
|
+
expect(w.acts).not.toContainEqual(['loop', 'resume']);
|
|
2380
|
+
expect(w.loopPaused).toBe(true);
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
test('LOOP-10 the lead session renews its lease while it runs, and releases it as it ends', async ($, on) => {
|
|
2384
|
+
const w = project();
|
|
2385
|
+
const clock = await start($, on, w);
|
|
2386
|
+
await leadTurn($);
|
|
2387
|
+
await settle(clock);
|
|
2388
|
+
expect(w.leadSession).toBe('s1');
|
|
2389
|
+
const claims = w.leadClaims;
|
|
2390
|
+
await clock.advance(32000);
|
|
2391
|
+
expect(w.leadClaims).toBeGreaterThan(claims);
|
|
2392
|
+
await $.session.end({ reason: 'prompt_input_exit', sessionId: 's1', resume: {} } as any);
|
|
2393
|
+
expect(w.acts).toContainEqual(['loop', 'release', '--session', 's1']);
|
|
2394
|
+
expect(w.leadSession).toBe(null);
|
|
2395
|
+
});
|
|
2396
|
+
|
|
2397
|
+
test("LOOP-11 a reply that waits on the person holds the loop; the dialog asks the lead's question and the answer goes to the lead", async ($, on) => {
|
|
2398
|
+
const w = project();
|
|
2399
|
+
w.sideReply = unread;
|
|
2400
|
+
const clock = await start($, on, w);
|
|
2401
|
+
await leadTurn($);
|
|
2402
|
+
await settle(clock);
|
|
2403
|
+
expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
|
|
2404
|
+
w.sideReply = outcome({
|
|
2405
|
+
outcome: 'needs-person',
|
|
2406
|
+
question: 'Should ST-9 show prices with GST?',
|
|
2407
|
+
options: [
|
|
2408
|
+
{ label: 'Include GST', effect: 'Every price shows GST.' },
|
|
2409
|
+
{ label: 'Exclude GST', effect: 'GST is added at checkout.' },
|
|
2410
|
+
],
|
|
2411
|
+
reason: 'a pricing rule only the person can set',
|
|
2412
|
+
});
|
|
2413
|
+
w.next = { step: 'review', args: 'ST-4', then: [] };
|
|
2414
|
+
w.answer = 'Include GST';
|
|
2415
|
+
await leadSays($, 't-2', 'Before I dispatch ST-9: should its prices include GST?');
|
|
2416
|
+
await settle(clock);
|
|
2417
|
+
// The side call read the reply over the lead's own conversation, with the step it answered.
|
|
2418
|
+
expect(w.sideAsks.at(-1).fork).toBe(true);
|
|
2419
|
+
expect(w.sideAsks.at(-1).prompt).toMatch(/prompted you: "Dispatch ST-9/);
|
|
2420
|
+
expect(w.sideAsks.at(-1).prompt).toMatch(/should its prices include GST\?/);
|
|
2421
|
+
expect(w.asked.at(-1)).toEqual({
|
|
2422
|
+
question: 'Should ST-9 show prices with GST?',
|
|
2423
|
+
options: ['Include GST', 'Exclude GST'],
|
|
2424
|
+
header: 'the lead',
|
|
2425
|
+
});
|
|
2426
|
+
expect(w.prompts.slice(1)).toEqual([
|
|
2427
|
+
'The person answered what you asked ("Should ST-9 show prices with GST?"): Include GST. Carry on with it.',
|
|
2428
|
+
]);
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2431
|
+
test('LOOP-11 dismissed, what the lead waits on stays in the band, and the loop prompts again only once the person has spoken', async ($, on) => {
|
|
2432
|
+
const w = project();
|
|
2433
|
+
w.sideReply = unread;
|
|
2434
|
+
const clock = await start($, on, w);
|
|
2435
|
+
await leadTurn($);
|
|
2436
|
+
await settle(clock);
|
|
2437
|
+
w.sideReply = outcome({
|
|
2438
|
+
outcome: 'needs-person',
|
|
2439
|
+
question: 'Which plan names should the pricing page use?',
|
|
2440
|
+
options: [
|
|
2441
|
+
{ label: 'Basic and Pro', effect: 'Two plans.' },
|
|
2442
|
+
{ label: 'Starter, Team, Business', effect: 'Three plans.' },
|
|
2443
|
+
],
|
|
2444
|
+
});
|
|
2445
|
+
w.next = { step: 'review', args: 'ST-4', then: [] };
|
|
2446
|
+
await leadSays($, 't-2', 'Which plan names do you want on the pricing page?');
|
|
2447
|
+
await settle(clock);
|
|
2448
|
+
await clock.advance(6000);
|
|
2449
|
+
expect(w.prompts).toHaveLength(1);
|
|
2450
|
+
expect(w.asked).toHaveLength(1); // asked once, and dismissed: not asked again
|
|
2451
|
+
const ui = await bandUi($);
|
|
2452
|
+
expect(
|
|
2453
|
+
await ui.find({
|
|
2454
|
+
type: 'Text',
|
|
2455
|
+
text: 'The lead waits on you: Which plan names should the pricing page use?',
|
|
2456
|
+
}),
|
|
2457
|
+
).toBeDefined();
|
|
2458
|
+
await ui.unmount();
|
|
2459
|
+
// The person answers in the chat; the lead's reply to them doesn't wait on them.
|
|
2460
|
+
await personSays($, 'Basic and Pro.');
|
|
2461
|
+
w.sideReply = outcome({ outcome: 'done' });
|
|
2462
|
+
await leadSays($, 't-3', 'Updated the plan names.');
|
|
2463
|
+
await settle(clock);
|
|
2464
|
+
expect(w.prompts.at(-1)).toBe('Review ST-4: run /code-kit:review ST-4.');
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
test('LOOP-11 a turn the person stopped holds the loop, with no dialog, until they speak or press Carry on', async ($, on) => {
|
|
2468
|
+
const w = project();
|
|
2469
|
+
w.sideReply = unread;
|
|
2470
|
+
const clock = await start($, on, w);
|
|
2471
|
+
await leadTurn($);
|
|
2472
|
+
await settle(clock);
|
|
2473
|
+
w.next = { step: 'review', args: 'ST-4', then: [] };
|
|
2474
|
+
await leadSays($, 't-2', '', true);
|
|
2475
|
+
await settle(clock);
|
|
2476
|
+
await clock.advance(4000);
|
|
2477
|
+
expect(w.prompts).toHaveLength(1);
|
|
2478
|
+
expect(w.asked).toEqual([]);
|
|
2479
|
+
const ui = await bandUi($);
|
|
2480
|
+
expect(
|
|
2481
|
+
await ui.find({ type: 'Text', text: "You stopped the lead's turn: the loop waits for you" }),
|
|
2482
|
+
).toBeDefined();
|
|
2483
|
+
await press($, 'band-carryOn');
|
|
2484
|
+
await ui.unmount();
|
|
2485
|
+
expect(w.prompts.at(-1)).toBe('Review ST-4: run /code-kit:review ST-4.');
|
|
2486
|
+
});
|
|
2487
|
+
|
|
2488
|
+
test("LOOP-11 a step the lead didn't take waits with its reason; Ask again prompts it, and a new step goes on", async ($, on) => {
|
|
2489
|
+
const w = project();
|
|
2490
|
+
w.next = { step: 'merge', args: 'lead/st-1', then: [] };
|
|
2491
|
+
w.sideReply = unread;
|
|
2492
|
+
const clock = await start($, on, w);
|
|
2493
|
+
await leadTurn($);
|
|
2494
|
+
await settle(clock);
|
|
2495
|
+
expect(w.prompts).toHaveLength(1);
|
|
2496
|
+
w.sideReply = outcome({
|
|
2497
|
+
outcome: 'blocked',
|
|
2498
|
+
reason: 'main has a failing check that lead/st-1 does not touch',
|
|
2499
|
+
});
|
|
2500
|
+
w.refs += ' c1 refs/heads/core/st-1\n';
|
|
2501
|
+
await leadSays($, 't-2', "I can't merge lead/st-1 while main's checks fail.");
|
|
2502
|
+
await settle(clock);
|
|
2503
|
+
// The person's own turn, and lanes committing, don't make the step worth prompting again.
|
|
2504
|
+
w.sideReply = unread;
|
|
2505
|
+
w.refs += ' c2 refs/heads/core/st-2\n';
|
|
2506
|
+
await leadTurn($, 't-3');
|
|
2507
|
+
await settle(clock);
|
|
2508
|
+
expect(w.prompts).toHaveLength(1);
|
|
2509
|
+
expect(w.asked.at(-1)?.options).toEqual(['Ask again', 'Not now']);
|
|
2510
|
+
expect(w.asked.at(-1)?.question).toMatch(/the lead didn't take it.*Ask the lead again\?$/);
|
|
2511
|
+
const ui = await bandUi($);
|
|
2512
|
+
expect(
|
|
2513
|
+
await ui.find({
|
|
2514
|
+
type: 'Text',
|
|
2515
|
+
text: "Merge lead/st-1: the lead didn't take it: main has a failing check that lead/st-1 does not touch",
|
|
2516
|
+
}),
|
|
2517
|
+
).toBeDefined();
|
|
2518
|
+
await press($, 'band-askAgain');
|
|
2519
|
+
await ui.unmount();
|
|
2520
|
+
expect(w.prompts).toHaveLength(2);
|
|
2521
|
+
expect(w.prompts[1]).toMatch(/^Merge lead\/st-1/);
|
|
2522
|
+
// A new step isn't held by the old one.
|
|
2523
|
+
w.sideReply = outcome({ outcome: 'blocked', reason: 'still failing' });
|
|
2524
|
+
await leadSays($, 't-4', 'Still failing.');
|
|
2525
|
+
await settle(clock);
|
|
2526
|
+
w.next = { step: 'dispatch', args: 'ST-9', then: [] };
|
|
2527
|
+
w.sideReply = unread;
|
|
2528
|
+
await leadTurn($, 't-5');
|
|
2529
|
+
await settle(clock);
|
|
2530
|
+
expect(w.prompts.at(-1)).toBe('Dispatch ST-9: run /code-kit:dispatch ST-9.');
|
|
2531
|
+
});
|
|
2532
|
+
|
|
2533
|
+
test('LOOP-12 a design story is handed to the lead once; the loop leaves designing it to the person and the lead', async ($, on) => {
|
|
2534
|
+
const w = project();
|
|
2535
|
+
w.next = { step: 'design', args: 'ST-21 ST-22', then: [] };
|
|
2536
|
+
w.sideReply = unread;
|
|
2537
|
+
const clock = await start($, on, w);
|
|
2538
|
+
await leadTurn($);
|
|
2539
|
+
await settle(clock);
|
|
2540
|
+
expect(w.prompts).toEqual([
|
|
2541
|
+
'Design ST-21, ST-22 before any of their screens are built: run /code-kit:design ST-21 ST-22.',
|
|
2542
|
+
]);
|
|
2543
|
+
// The person and the lead go back and forth; ST-21's approved design changes the project.
|
|
2544
|
+
for (const turn of ['t-2', 't-3']) {
|
|
2545
|
+
await personSays($, 'Make the header calmer.');
|
|
2546
|
+
w.refs += ` ${turn} refs/heads/design/${turn}\n`;
|
|
2547
|
+
await leadTurn($, turn);
|
|
2548
|
+
await settle(clock);
|
|
2549
|
+
}
|
|
2550
|
+
expect(w.prompts.filter((p: string) => p.startsWith('Design'))).toHaveLength(1);
|
|
2551
|
+
const ui = await bandUi($);
|
|
2552
|
+
expect(
|
|
2553
|
+
await ui.find({
|
|
2554
|
+
type: 'Text',
|
|
2555
|
+
text: "Design ST-21, ST-22: with you and the lead, so the loop doesn't prompt it again",
|
|
2556
|
+
}),
|
|
2557
|
+
).toBeDefined();
|
|
2558
|
+
await ui.unmount();
|
|
2559
|
+
// A design story not handed over yet is.
|
|
2560
|
+
w.next = { step: 'design', args: 'ST-22 ST-30', then: [] };
|
|
2561
|
+
await leadTurn($, 't-4');
|
|
2562
|
+
await settle(clock);
|
|
2563
|
+
expect(w.prompts.at(-1)).toBe(
|
|
2564
|
+
'Design ST-22, ST-30 before any of their screens are built: run /code-kit:design ST-22 ST-30.',
|
|
2565
|
+
);
|
|
2566
|
+
});
|
|
2567
|
+
|
|
2568
|
+
test("DECIDE-4 the loop's first offer is asked in Claude Code's own dialog; Keep going records it and takes the step", async ($, on) => {
|
|
2569
|
+
const w = project();
|
|
2570
|
+
(w.check as any).harnessSet = [];
|
|
2571
|
+
w.sideReply = unread;
|
|
2572
|
+
w.answer = 'Keep going';
|
|
2573
|
+
const clock = await start($, on, w);
|
|
2574
|
+
await leadTurn($);
|
|
2575
|
+
await settle(clock);
|
|
2576
|
+
expect(w.asked[0].options).toEqual(['Keep going', 'Ask each time', 'Off']);
|
|
2577
|
+
expect(w.asked[0].question).toMatch(
|
|
2578
|
+
/^code-kit can keep the build moving between your prompts\. Next: Dispatch ST-9\. How much should it do on its own\?$/,
|
|
2579
|
+
);
|
|
2580
|
+
expect(w.acts.some((a) => a.join(' ').startsWith('settings set autonomy autonomous'))).toBe(true);
|
|
2581
|
+
expect(w.prompts).toEqual(['Dispatch ST-9: run /code-kit:dispatch ST-9.']);
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
test("DECIDE-4 the lead's question is asked in the dialog: an option answers it, and a dismissed one isn't asked again", async ($, on) => {
|
|
2585
|
+
const w = project();
|
|
2586
|
+
w.questions = [question()];
|
|
2587
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
2588
|
+
const clock = await start($, on, w);
|
|
2589
|
+
await clock.advance(4000);
|
|
2590
|
+
expect(w.asked).toHaveLength(1);
|
|
2591
|
+
expect(w.asked[0]).toEqual({
|
|
2592
|
+
question: 'Which weather provider: the Bureau of Meteorology or a commercial service?',
|
|
2593
|
+
options: ['Bureau of Meteorology', 'Commercial'],
|
|
2594
|
+
header: 'question',
|
|
2595
|
+
});
|
|
2596
|
+
await clock.advance(6000);
|
|
2597
|
+
expect(w.asked).toHaveLength(1);
|
|
2598
|
+
const ui = await bandUi($);
|
|
2599
|
+
expect(
|
|
2600
|
+
await ui.find({ type: 'Text', text: /^The lead asks: Which weather provider/ }),
|
|
2601
|
+
).toBeDefined();
|
|
2602
|
+
await ui.unmount();
|
|
2603
|
+
});
|
|
2604
|
+
|
|
2605
|
+
test("DECIDE-4 an option chosen in the dialog, or the person's own words typed there, answer the lead's question", async ($, on) => {
|
|
2606
|
+
const w = project();
|
|
2607
|
+
w.questions = [question()];
|
|
2608
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
2609
|
+
w.typed = 'Both, the Bureau first';
|
|
2610
|
+
const clock = await start($, on, w);
|
|
2611
|
+
await clock.advance(4000);
|
|
2612
|
+
expect(w.acts).toContainEqual([
|
|
2613
|
+
'ask',
|
|
2614
|
+
'answer',
|
|
2615
|
+
'Q-1',
|
|
2616
|
+
'Both, the Bureau first',
|
|
2617
|
+
'--via',
|
|
2618
|
+
'pane',
|
|
2619
|
+
]);
|
|
2620
|
+
expect(w.prompts.at(-1)).toMatch(
|
|
2621
|
+
/^The person answered Q-1 .*: Both, the Bureau first\. ST-4 no longer waits/,
|
|
2622
|
+
);
|
|
2623
|
+
});
|
|
2624
|
+
|
|
2625
|
+
test('DECIDE-4 nothing is asked in a session another one runs the loop for', async ($, on) => {
|
|
2626
|
+
const w = project();
|
|
2627
|
+
w.leadSession = 's-other';
|
|
2628
|
+
w.questions = [question()];
|
|
2629
|
+
w.next = { step: 'wait', args: '', then: [] };
|
|
2630
|
+
const clock = await start($, on, w);
|
|
2631
|
+
await clock.advance(6000);
|
|
2632
|
+
expect(w.asked).toEqual([]);
|
|
2633
|
+
});
|
|
2634
|
+
|
|
2635
|
+
// --- the lane agents' model (SET-4) ---------------------------------------------------------------
|
|
2636
|
+
|
|
2637
|
+
const dispatch = ($: any, subagentType: string, model?: string) =>
|
|
2638
|
+
$.agent.spawn({
|
|
2639
|
+
prompt: 'ST-9 pricing',
|
|
2640
|
+
description: 'ST-9 pricing',
|
|
2641
|
+
subagentType,
|
|
2642
|
+
...(model ? { model } : {}),
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
test("SET-4 a lane agent starts on the lane model set; the lead's own choice, an agent file's model and other agents are left alone", async ($, on) => {
|
|
2646
|
+
const w = project();
|
|
2647
|
+
(w.check as any).harness = { agents: { lanes: { model: 'sonnet' } } };
|
|
2648
|
+
w.agentFiles['/work/.claude/agents/web-engineer.md'] =
|
|
2649
|
+
'---\nname: web-engineer\nmodel: opus\n---\nYou are the web engineer.\n';
|
|
2650
|
+
w.agentFiles['/work/.claude/agents/api-engineer.md'] =
|
|
2651
|
+
'---\nname: api-engineer\nmodel: inherit\n---\n';
|
|
2652
|
+
await start($, on, w);
|
|
2653
|
+
await dispatch($, 'core-engineer');
|
|
2654
|
+
await dispatch($, 'api-engineer');
|
|
2655
|
+
await dispatch($, 'core-engineer', 'haiku');
|
|
2656
|
+
await dispatch($, 'web-engineer');
|
|
2657
|
+
await dispatch($, 'general-purpose');
|
|
2658
|
+
expect(w.spawned.map((e) => [e.subagentType, e.model ?? null])).toEqual([
|
|
2659
|
+
['core-engineer', 'sonnet'],
|
|
2660
|
+
['api-engineer', 'sonnet'],
|
|
2661
|
+
['core-engineer', 'haiku'],
|
|
2662
|
+
['web-engineer', null],
|
|
2663
|
+
['general-purpose', null],
|
|
2664
|
+
]);
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
test('SET-4 with no lane model set, lane agents start as Claude Code decides', async ($, on) => {
|
|
2668
|
+
const w = project();
|
|
2669
|
+
await start($, on, w);
|
|
2670
|
+
await dispatch($, 'core-engineer');
|
|
2671
|
+
expect(w.spawned[0].model ?? null).toBe(null);
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2298
2674
|
// --- the reviewer -----------------------------------------------------------------------------------
|
|
2299
2675
|
|
|
2300
2676
|
/** ST-4 finished on web/st-4, the reviewer on. */
|
package/hooks/mod/view.mjs
CHANGED
|
@@ -788,6 +788,7 @@ export function bandLines({
|
|
|
788
788
|
lines.push({
|
|
789
789
|
kind: 'proposal',
|
|
790
790
|
text: `code-kit can keep the build moving between your prompts. Next: ${loop.proposal.label}.`,
|
|
791
|
+
proposal: loop.proposal,
|
|
791
792
|
actions: [
|
|
792
793
|
{ id: 'keepGoing', label: 'Keep going' },
|
|
793
794
|
{ id: 'askEach', label: 'Ask each time' },
|
|
@@ -801,8 +802,12 @@ export function bandLines({
|
|
|
801
802
|
text: loop.proposal.repeated
|
|
802
803
|
? `${loop.proposal.label}: still next after ${loop.proposal.repeated} prompts. Ask the lead again?`
|
|
803
804
|
: `${loop.proposal.label}?`,
|
|
804
|
-
|
|
805
|
+
proposal: loop.proposal,
|
|
806
|
+
actions: [{ id: 'go', label: loop.proposal.repeated ? 'Ask again' : 'Go' }],
|
|
805
807
|
});
|
|
808
|
+
// LOOP-11, LOOP-12: what the loop read in the lead's reply holds it until the person says.
|
|
809
|
+
const waitingLine = waitingOf(loop?.waiting);
|
|
810
|
+
if (waitingLine) lines.push(waitingLine);
|
|
806
811
|
// A held call's request is asked on its own line above, not again here.
|
|
807
812
|
// CARD-2: counted as decisions, not files: one approval for a lane covers every file it was asked for.
|
|
808
813
|
const asks = approvalAsks((requests?.open ?? []).filter((r) => !held.some((h) => sameAsk(h, r))));
|
|
@@ -915,7 +920,14 @@ export function bandLines({
|
|
|
915
920
|
// With the mission rows drawn, the loop's state and its Pause or Resume are on the `now` row.
|
|
916
921
|
else if (mission.length) {
|
|
917
922
|
// nothing more
|
|
918
|
-
} else if (loop?.state === '
|
|
923
|
+
} else if (loop?.state === 'elsewhere')
|
|
924
|
+
// LOOP-10: another session in this project runs the loop; this one never prompts its lead.
|
|
925
|
+
lines.push({
|
|
926
|
+
kind: 'loop',
|
|
927
|
+
text: 'the loop runs in another session',
|
|
928
|
+
actions: [{ id: 'leadHere', label: 'Lead from here' }],
|
|
929
|
+
});
|
|
930
|
+
else if (loop?.state === 'paused')
|
|
919
931
|
lines.push({ kind: 'loop', text: 'loop paused', actions: [{ id: 'resume', label: 'Resume' }] });
|
|
920
932
|
else if (loop?.state === 'running' && loop.started)
|
|
921
933
|
lines.push({
|
|
@@ -930,6 +942,48 @@ export function bandLines({
|
|
|
930
942
|
return lines;
|
|
931
943
|
}
|
|
932
944
|
|
|
945
|
+
/**
|
|
946
|
+
* LOOP-11, LOOP-12: the band's line for what the loop waits on, or null: the lead waiting on the
|
|
947
|
+
* person (its question, when the reply asked one), a turn the person stopped, a step the lead didn't
|
|
948
|
+
* take, or design stories left to the person and the lead.
|
|
949
|
+
*/
|
|
950
|
+
export function waitingOf(waiting) {
|
|
951
|
+
if (!waiting) return null;
|
|
952
|
+
if (waiting.kind === 'person')
|
|
953
|
+
return {
|
|
954
|
+
kind: 'waiting',
|
|
955
|
+
text: waiting.question
|
|
956
|
+
? `The lead waits on you: ${waiting.question}`
|
|
957
|
+
: `The lead waits on you${waiting.reason ? `: ${waiting.reason}` : ''}`,
|
|
958
|
+
waiting,
|
|
959
|
+
actions: waiting.question
|
|
960
|
+
? [{ id: 'openDecision', label: 'Answer…' }]
|
|
961
|
+
: [{ id: 'carryOn', label: 'Carry on' }],
|
|
962
|
+
};
|
|
963
|
+
if (waiting.kind === 'stopped')
|
|
964
|
+
return {
|
|
965
|
+
kind: 'waiting',
|
|
966
|
+
text: "You stopped the lead's turn: the loop waits for you",
|
|
967
|
+
waiting,
|
|
968
|
+
actions: [{ id: 'carryOn', label: 'Carry on' }],
|
|
969
|
+
};
|
|
970
|
+
if (waiting.kind === 'blocked')
|
|
971
|
+
return {
|
|
972
|
+
kind: 'waiting',
|
|
973
|
+
text: `${waiting.label}: the lead didn't take it${waiting.reason ? `: ${waiting.reason}` : ''}`,
|
|
974
|
+
waiting,
|
|
975
|
+
actions: [{ id: 'askAgain', label: 'Ask again' }],
|
|
976
|
+
};
|
|
977
|
+
if (waiting.kind === 'design')
|
|
978
|
+
return {
|
|
979
|
+
kind: 'designing',
|
|
980
|
+
text: `${waiting.label}: with you and the lead, so the loop doesn't prompt it again`,
|
|
981
|
+
waiting,
|
|
982
|
+
actions: [{ id: 'askAgain', label: 'Ask the lead' }],
|
|
983
|
+
};
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
|
|
933
987
|
/** The band, drawn with the elements `$.ui.resolve(e)` returned; `onAction(id, line)` acts. */
|
|
934
988
|
export function band(lines, { Box, Text, Button }, onAction) {
|
|
935
989
|
const COLOR = {
|
|
@@ -938,6 +992,8 @@ export function band(lines, { Box, Text, Button }, onAction) {
|
|
|
938
992
|
queue: 'blue',
|
|
939
993
|
stalled: 'red',
|
|
940
994
|
proposal: 'cyan',
|
|
995
|
+
waiting: 'cyan',
|
|
996
|
+
designing: 'blue',
|
|
941
997
|
loop: 'green',
|
|
942
998
|
done: 'green',
|
|
943
999
|
approvals: 'yellow',
|
|
@@ -1143,7 +1199,11 @@ export function questionAsidePrompt({ q, thread = [], question }) {
|
|
|
1143
1199
|
return [
|
|
1144
1200
|
"The person is deciding how to answer a question in code-kit's Decisions pane, and asks you about it first. Answer in a few short sentences, in plain words: what the question means, why it matters for the build, what the choices are and what each leads to, and what you'd recommend if you know enough to. Use what you know from this conversation, the project's docs you've read, and the facts below. If you don't know, say so; don't guess. This answer goes to that pane, not into the conversation: don't act on it.",
|
|
1145
1201
|
'',
|
|
1146
|
-
q.kind === 'brief'
|
|
1202
|
+
q.kind === 'brief'
|
|
1203
|
+
? `An open question in ${q.path}:`
|
|
1204
|
+
: q.kind === 'lead'
|
|
1205
|
+
? 'What the lead asked in its last reply, and waits on:'
|
|
1206
|
+
: `A question the lead asked (${q.id}):`,
|
|
1147
1207
|
`- ${q.question}`,
|
|
1148
1208
|
...(q.owner ? [`- who decides: ${q.owner}`] : []),
|
|
1149
1209
|
...(q.kind === 'brief' && q.blocks ? [`- what it holds up: ${q.blocks}`] : []),
|
|
@@ -1224,7 +1284,9 @@ export function answerPane(
|
|
|
1224
1284
|
children: [
|
|
1225
1285
|
kind === 'brief'
|
|
1226
1286
|
? `An open question in ${answering.path}. The lead records your answer in the brief's decisions and builds on it.`
|
|
1227
|
-
:
|
|
1287
|
+
: kind === 'lead'
|
|
1288
|
+
? 'From the lead, which waits on it. Your answer is sent to the lead.'
|
|
1289
|
+
: `${answering.id}, from the lead${answering.story ? `, for ${answering.story}` : ''}. Your answer is recorded and sent to the lead.`,
|
|
1228
1290
|
],
|
|
1229
1291
|
}),
|
|
1230
1292
|
...(kind === 'brief' && (answering.owner || answering.blocks)
|
|
@@ -1434,6 +1496,7 @@ export const SETTINGS_ID = 'code-kit-settings';
|
|
|
1434
1496
|
const CHOICES = {
|
|
1435
1497
|
autonomy: ['autonomous', 'propose', 'off'],
|
|
1436
1498
|
'agents.reviewer.model': ['', 'haiku', 'sonnet', 'opus'],
|
|
1499
|
+
'agents.lanes.model': ['', 'haiku', 'sonnet', 'opus'],
|
|
1437
1500
|
};
|
|
1438
1501
|
const shown = (v) =>
|
|
1439
1502
|
v === null || v === '' ? "the session's" : v === true ? 'on' : v === false ? 'off' : String(v);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warren-dean/code-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Warren Langeveldt",
|
package/skills/init/SKILL.md
CHANGED
|
@@ -74,6 +74,7 @@ Write `.claude/code-kit.draft.json`. Follow `${CLAUDE_PLUGIN_ROOT}/README.md`
|
|
|
74
74
|
- **`postEdit`:** the project's existing per-file linters and formatters, with `{file}`.
|
|
75
75
|
- **`checks`:** the typecheck, test, architecture and database commands, each scoped by `files`. Only include commands that pass on the current code, or ones the person chose to keep.
|
|
76
76
|
- **`harness.autonomy`:** ask the person how hands-off they want the build. With the code-kit mod (Claude Code 2.1.287 or later), once the plan has stories, a lead loop can keep the work moving between their prompts: it prompts the lead to dispatch ready stories, review finished ones and merge them where merges are delegated. Offer the three choices in plain words: `autonomous` (it keeps going on its own; they can pause it from the band), `propose` (each step waits in the band for their Go), or `off`. Put their answer in the draft as `"harness": { "autonomy": "…" }`. If they'd rather decide later, leave it out: the loop then asks them in the band before its first step. With `autonomous`, the lead owns delivery, so also draft `approvals.delegate`: `"merge": true` (the lead merges a reviewed branch once every check passes) and `"dependencies": {}` (it approves a new package within the default limits on licence, popularity and age), so neither comes to the person. Say these are the limits; anything outside them still comes to them as a decision.
|
|
77
|
+
- **`harness.agents.lanes.model`:** ask which model the lane agents run on. Each lane agent builds one story inside its own paths, with its requirements and acceptance criteria in its brief, and the hooks, `verify` and the lead's review catch what it gets wrong, so Sonnet usually does the work well for much less than the session's model; recommend `sonnet`, and say the lead, the reviewer and the security reviewer keep the session's model. The mod sets it as each lane agent starts; a lane's agent file with its own `model:` keeps that. Put their answer in `harness` as `"agents": { "lanes": { "model": "sonnet" } }`, or leave it out for the session's model.
|
|
77
78
|
- **`harness.report.on`:** ask whether agents may draft bug reports for code-kit itself when code-kit gets in their way (a refusal that shouldn't have happened, a crash). Each report holds only code-kit's own facts (its version, the rule, what it said, the command), never the project's code, paths or data, and waits in their Decisions tab: they read it and send it to code-kit's public GitHub under their own account, or discard it. Off unless they say yes; put `"report": { "on": true }` in `harness` if they do.
|
|
78
79
|
- **`approvals.lead`:** ask the person whether they'll ever approve from a phone or another chat-only client, where a `!` command arrives as plain text. If so, set it to `true`: the lead then records approvals they give in chat (`code-kit approve`), and runs the `shell.block` commands marked `person: true` when they say so. Mark a `shell.block` command `person: true` only if the person wants to be able to have the lead run it on their say-so. Leave the setting out if the lead will run unattended.
|
|
79
80
|
|
|
@@ -119,7 +120,7 @@ Show the person:
|
|
|
119
120
|
1. the layers and what each may depend on, and for brownfield the existing violations the baseline will record;
|
|
120
121
|
2. the lanes, their agents and their paths, and the security reviewer (or why it's left out);
|
|
121
122
|
3. protected paths, blocked and restricted commands, and checks (including any left out because they fail today);
|
|
122
|
-
and whether the lead may record approvals given in chat (`approvals.lead`), how hands-off the build is (`harness.autonomy`), and whether agents may draft bug reports for code-kit (`harness.report.on`);
|
|
123
|
+
and whether the lead may record approvals given in chat (`approvals.lead`), how hands-off the build is (`harness.autonomy`), the lane agents' model (`harness.agents.lanes.model`), and whether agents may draft bug reports for code-kit (`harness.report.on`);
|
|
123
124
|
4. the project skills, and which lane agents use each;
|
|
124
125
|
5. the active adapters and what each adds, from `adapters`. A companion tool that's installed but not set up here (its init skill is in your skills list, for example `/context-graph:init` with no `.ctx/` folder): add `"adapters": { "<name>": true }` to the draft and say it will be set up before any code is written;
|
|
125
126
|
6. every **question**: where the docs or the code are silent, ambiguous or contradictory. Don't guess; a wrong owner or layer blocks real work.
|