@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.
@@ -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') return ran(0, JSON.stringify({ paused: w.loopPaused, at: null }));
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 a label.
401
- on('tool.call', { tool: 'AskUserQuestion' }, ($: any, e: any) => ({
402
- result: { questions: e.questions, answers: { [e.questions[0].question]: w.answer } },
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 reports waiting) between two looks at git.
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(15);
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. */
@@ -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
- actions: [{ id: 'go', label: 'Go' }],
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 === 'paused')
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' ? `An open question in ${q.path}:` : `A question the lead asked (${q.id}):`,
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
- : `${answering.id}, from the lead${answering.story ? `, for ${answering.story}` : ''}. Your answer is recorded and sent to the lead.`,
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.13.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",
@@ -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.