@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.
@@ -15,12 +15,13 @@ import {
15
15
  SETTINGS_ID,
16
16
  approvalsText,
17
17
  agentsAtWork,
18
+ shownState,
18
19
  attribution,
19
20
  approvePane,
20
21
  band,
21
22
  bandLines,
22
23
  finishedStories,
23
- lanesPane,
24
+ ago,
24
25
  parseJson,
25
26
  planOf,
26
27
  prefilledReason,
@@ -32,6 +33,32 @@ import {
32
33
  usageSummary,
33
34
  withUsage,
34
35
  } from './view.mjs';
36
+ import { holdable } from './hold.mjs';
37
+ import { axisStart, barCells, poseOf, rasterCells, sprite, tintOf } from './art.mjs';
38
+ import {
39
+ DEFAULT_UI,
40
+ SENDBACK_ID,
41
+ closeGoesBack,
42
+ laneActions,
43
+ lanesFrame,
44
+ liveLevel,
45
+ sendBackPane,
46
+ toolLine,
47
+ } from './panes.mjs';
48
+ import { leadPrompt, nudgeText, restartPrompt, stallDue, stepKey, stepLabel } from './loop.mjs';
49
+ import { goalOf, nextText } from './mission.mjs';
50
+ import { contextOf, decisionCards } from './decisions.mjs';
51
+ import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
52
+ import { harnessSettings } from '../lib/harness.mjs';
53
+ import {
54
+ REVIEW_WAIT_MS,
55
+ findingsCount,
56
+ leadReviewPrompt,
57
+ readReport,
58
+ reviewerSpec,
59
+ startReviewerPrompt,
60
+ withVerify,
61
+ } from './review.mjs';
35
62
 
36
63
  // What the session knows of the project, read again when it changes.
37
64
  let model = { state: null, requests: null, stops: [] };
@@ -46,6 +73,69 @@ let pendingSetting = null; // a change waiting for the person's reason: { key, v
46
73
  let ledger = {}; // the session's tokens by agent and story (USE-1)
47
74
  let rateLimits = []; // the session's limits, as session.measure last gave them
48
75
  const agentsSeen = new Map(); // agent id → its $.agent.list() entry, kept once it leaves the list
76
+ const held = new Map(); // tool_use_id → a call held for the person (HOLD-2)
77
+ let where = null; // { cli, cwd, interactive }, from session start
78
+ // The lead loop (spec 05): what it did, and what it waits on.
79
+ const loop = {
80
+ paused: false,
81
+ started: false, // it has taken a step this session: from then on it also acts between turns
82
+ steps: [], // { at, kind, target, prompt? }, kept for the session
83
+ lastKey: null, // the last step submitted or proposed, and the project as it stood then
84
+ lastPrint: null,
85
+ asked: { key: null, times: 0 }, // the step the lead was last prompted with, and how often in a row
86
+ proposal: null, // under autonomy propose, or a step asked too often: { prompt, label, kind, target }
87
+ pending: [], // restarts waiting for the lead to be idle: { story, prompt }
88
+ nextStep: null, // what `code-kit next` last said, for the band's next row
89
+ done: null, // { count } once every story is done
90
+ doneDismissed: false,
91
+ };
92
+ /** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
93
+ const ASK_LIMIT = 2;
94
+ let leadTurn = null; // the lead's running turn, if any
95
+ const activity = new Map(); // agent id → { at, inFlight, nudged, stopped, lane, story }
96
+ const restarts = new Map(); // story → times the loop restarted it
97
+ const flagged = new Map(); // story → { lane, story, count }: stalled past its restarts
98
+ /**
99
+ * LOOP-3: whether an agent's work is done, so it isn't stalled however quiet: it handed back, or its
100
+ * story is in review, waiting in the merge queue or merged.
101
+ */
102
+ const finishedWork = (a) => {
103
+ if (a.handedBack) return true;
104
+ const story = (model.state?.stories ?? []).find((s) => s.id === a.story);
105
+ if (!story) return false;
106
+ if (['review', 'done'].includes(story.state)) return true;
107
+ return (model.queue ?? []).some(
108
+ (q) => q.branch === story.branch && ['waiting', 'merging'].includes(q.state),
109
+ );
110
+ };
111
+ /** An agent's activity record, made on first sight. */
112
+ const activityOf = (agentId, now) => {
113
+ let a = activity.get(agentId);
114
+ if (!a) activity.set(agentId, (a = { at: now, inFlight: 0, nudged: false, stopped: false }));
115
+ return a;
116
+ };
117
+ const harness = () => harnessSettings(model.check?.harness);
118
+ // Panes v2 (spec 11): the Lanes pane's own state, the lead's last tool call, and a send-back waiting
119
+ // for the person's reason.
120
+ let paneUi = { ...DEFAULT_UI };
121
+ let leadLast = null; // { tool, at }
122
+ let sendingBack = null; // { lane, branch, story, reason, error }
123
+ let storyOpen = null; // the drill-down's story (VIEW-7): { id, lane, data, error }
124
+ const personAsks = []; // prompts the person asked for (stop an agent), waiting for the lead to be idle
125
+ // VIEW-3: the characters' animation, and the surface the Lanes pane was last drawn on.
126
+ let frame = 0;
127
+ let animation = null; // the timer repainting working characters, while one works
128
+ let paneSurface = null;
129
+ let paneColumns = 80;
130
+ let codeMap = null; // MAP-1: `code-kit map <story> --json` for the Code map tab's story
131
+ let trailData = null; // TRAIL-1: `code-kit trail --json` while the Trail tab shows
132
+ let journalData = null; // JOURNAL-1: `code-kit journal --json`, for the Journal tab and the decisions
133
+ const touches = []; // MAP-2: { path, verb, lane, at }, the files agents read and edited lately
134
+ const TOUCHES_KEPT = 200;
135
+ // The reviewer (spec 07): each branch's review at its head, and the agent type registered for it.
136
+ const reviews = new Map(); // branch → { branch, head, story, state, startedAt, findings, why?, error? }
137
+ let reviewerAgent = null; // the registered agent's full name
138
+ let reviewerModel = null; // the model it was registered with ('' for the session's)
49
139
  const refusals = new Map(); // tool_use_id → the refusal text a hook gave (CARD-1)
50
140
  const expanded = new Set(); // cards showing their raw text
51
141
  const REFUSALS_KEPT = 200;
@@ -86,10 +176,17 @@ export function register(on) {
86
176
  const cli = `${$.plugin.root}/bin/code-kit.mjs`;
87
177
  const cwd = e.cwd ?? (await $.session.cwd());
88
178
  const session = await $.session.id();
179
+ where = { cli, cwd, interactive: e.isInteractive !== false };
180
+ // The held call's answer, for the `code-kit hold` it waits in.
181
+ const answerHeld = (id, answer) =>
182
+ $.process.run(['node', cli, 'hold', id, '--answer', answer], { cwd });
89
183
  const json = async (...args) => {
90
184
  const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
91
185
  return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
92
186
  };
187
+ // A session starts with the loop running: a pause recorded by an earlier one no longer holds.
188
+ if ((await json('loop').catch(() => null))?.paused)
189
+ await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
93
190
  const stamp = async (path) => {
94
191
  const s = await $.fs.stat(`${cwd}/${path}`).catch(() => null);
95
192
  return s ? `${path}@${s.mtimeMs}` : `${path}-`;
@@ -116,14 +213,163 @@ export function register(on) {
116
213
  const nextStep = valid ? await json('next') : null;
117
214
  const requests = valid ? await json('requests') : null;
118
215
  const stops = valid ? ((await json('stops', '--session', session)) ?? []) : [];
119
- const atWork = agentsAtWork(await $.agent.list());
216
+ const queue = valid ? ((await json('queue')) ?? []) : [];
217
+ // VIEW-2: when each story's work happened, for the lanes' timelines.
218
+ const timeline = valid && check.docs?.plan ? await json('timeline') : null;
219
+ // JOIN-1: what the active adapters know of the lanes' work, where any does.
220
+ const adapters =
221
+ valid && check.adapters?.length
222
+ ? ((await json('adapters', '--session', session)) ?? [])
223
+ : [];
224
+ // The agents at work; none where the session has none bound (claude -p before it mounts).
225
+ const atWork = agentsAtWork(await $.agent.list().catch(() => []));
120
226
  model = {
121
227
  state: projectState(check, status, nextStep, atWork),
122
228
  requests,
123
229
  stops,
230
+ queue,
231
+ status,
232
+ timeline,
233
+ adapters,
124
234
  check,
125
235
  base: status?.base ?? null,
126
236
  };
237
+ if (model.state.kind === 'ok') await act.registerReviewer();
238
+ await act.readMaps();
239
+ $.ui.invalidate('ui.render');
240
+ },
241
+ // MAP-1, TRAIL-1: what the Code map and Trail tabs draw, read while they show.
242
+ readMaps: async () => {
243
+ if (model.state?.kind !== 'ok') return;
244
+ if (paneUi.tab === 'code') {
245
+ const id = mapStoryOf(stateNow(), paneUi.selected, paneUi.mapStory);
246
+ codeMap = id ? ((await json('map', id)) ?? codeMap) : null;
247
+ }
248
+ if (paneUi.tab === 'map') trailData = (await json('trail')) ?? trailData;
249
+ // JOURNAL-1, DECIDE-2: the lineage, for the Journal tab and the decisions' cards.
250
+ const deciding = held.size > 0 || (model.requests?.open ?? []).length > 0;
251
+ if (model.check?.docs?.plan && (['journal', 'decisions'].includes(paneUi.tab) || deciding))
252
+ journalData = (await json('journal')) ?? journalData;
253
+ },
254
+ // REVW-2: the reviewer, read-only by the hooks' rule for agents outside the lanes. Registered
255
+ // again when its model setting changes.
256
+ registerReviewer: async () => {
257
+ const wanted = harness().agents.reviewer.model ?? '';
258
+ if (reviewerModel === wanted) return;
259
+ const spec = reviewerSpec({
260
+ cli,
261
+ checklist: `${$.plugin.root}/skills/review/SKILL.md`,
262
+ model: wanted || undefined,
263
+ });
264
+ // Until the session is bound (claude -p, early on) registering fails: it's tried again next time.
265
+ const done = await $.agent.register(spec).catch((err) => {
266
+ $.ui.log(`code-kit: the reviewer agent isn't available yet: ${err?.message ?? err}`, {
267
+ to: 'debug',
268
+ });
269
+ return null;
270
+ });
271
+ if (!done) return;
272
+ reviewerModel = wanted;
273
+ reviewerAgent = done.agent ?? null;
274
+ },
275
+ // REVW-1, REVW-4, LOOP-4: for stories in review, start the reviewer at each branch's head, and
276
+ // once it has reported (or failed, or was skipped) prompt the lead's review with its findings.
277
+ // Null while every review is still running.
278
+ reviewStep: async (step) => {
279
+ const now = await $.clock.now();
280
+ const plan = planOf(rateLimits, harness().background.pauseAtPercent);
281
+ for (const id of (step.args ?? '').split(/\s+/).filter(Boolean)) {
282
+ const story = (model.state.stories ?? []).find((s) => s.id === id);
283
+ if (!story?.branch) continue;
284
+ const head = (
285
+ await $.process.run(['git', 'rev-parse', '--verify', '--quiet', story.branch], { cwd })
286
+ ).stdout.trim();
287
+ if (!head) continue;
288
+ let review = reviews.get(story.branch);
289
+ if (!review || review.head !== head) {
290
+ // USE-4: past the plan's pause point, no reviewer starts; the lead reviews alone.
291
+ review = {
292
+ branch: story.branch,
293
+ head,
294
+ story: id,
295
+ startedAt: now,
296
+ findings: [],
297
+ ...(plan.paused
298
+ ? {
299
+ state: 'skipped',
300
+ why: `background agents paused, the plan at ${plan.percent}%`,
301
+ }
302
+ : { state: 'running' }),
303
+ };
304
+ reviews.set(story.branch, review);
305
+ if (!plan.paused)
306
+ return {
307
+ prompt: startReviewerPrompt({
308
+ agent: reviewerAgent,
309
+ story,
310
+ branch: story.branch,
311
+ head,
312
+ }),
313
+ kind: 'reviewer',
314
+ target: story.branch,
315
+ key: `reviewer:${story.branch}@${head}`,
316
+ label: `Start the reviewer on ${story.branch}`,
317
+ };
318
+ await act.record('skipped', `review of ${story.branch}`);
319
+ }
320
+ if (review.state === 'running') {
321
+ if (now - review.startedAt < REVIEW_WAIT_MS) continue;
322
+ review.state = 'failed';
323
+ review.error = 'no report within 15 minutes';
324
+ }
325
+ return {
326
+ prompt: leadReviewPrompt(id, review),
327
+ kind: 'review',
328
+ target: id,
329
+ key: `review:${story.branch}@${head}`,
330
+ label: `Review ${id}`,
331
+ };
332
+ }
333
+ return null;
334
+ },
335
+ // REVW-3: the reviewer's report, read from the turn it ended with; a failed verify is a blocker.
336
+ reviewed: async (agentId, answer) => {
337
+ let review = [...reviews.values()].find((r) => r.agentId === agentId);
338
+ if (!review) {
339
+ const agent = ((await $.agent.list().catch(() => [])) ?? []).find(
340
+ (a) => a.id === agentId,
341
+ );
342
+ if (!agent || !reviewerAgent || agent.type !== reviewerAgent) return;
343
+ review = [...reviews.values()].find(
344
+ (r) => r.state === 'running' && agent.description?.includes(r.branch),
345
+ );
346
+ if (!review) return;
347
+ review.agentId = agentId;
348
+ }
349
+ const report = readReport(answer);
350
+ if (report.error) {
351
+ review.state = 'failed';
352
+ review.error = report.error;
353
+ } else {
354
+ review.state = 'done';
355
+ review.findings = report.findings;
356
+ // With the branch checked out in a worktree, verify there too: its problems are blockers.
357
+ const story = (model.state?.stories ?? []).find((s) => s.id === review.story);
358
+ if (story?.worktree) {
359
+ const ran = await $.process.run(['node', cli, 'verify', '--no-checks', '--json'], {
360
+ cwd: story.worktree,
361
+ timeoutMs: 120000,
362
+ });
363
+ if (ran.exitCode !== 0) {
364
+ const problems = Object.values(parseJson(ran.stdout)?.found ?? {}).flat();
365
+ review.findings = withVerify(review.findings, { passed: false, problems });
366
+ }
367
+ }
368
+ }
369
+ await act.record(
370
+ 'reviewed',
371
+ `${review.branch}: ${review.state === 'done' ? findingsCount(review.findings) : review.error}`,
372
+ );
127
373
  $.ui.invalidate('ui.render');
128
374
  },
129
375
  // What a refresh waits on, read cheaply: branches and HEAD, the config and the plan, requests,
@@ -134,14 +380,18 @@ export function register(on) {
134
380
  { cwd },
135
381
  );
136
382
  const plan = model.check?.docs?.plan;
383
+ const specs = model.check?.docs?.specs;
137
384
  return [
138
385
  refs.stdout,
139
386
  await stamp('.claude/code-kit.json'),
140
387
  plan ? await stamp(plan) : '',
388
+ // TRACE-3: the map follows the specs too.
389
+ specs ? await listing(specs) : '',
141
390
  await stamp('.claude/state/requests.jsonl'),
391
+ await stamp('.claude/state/merge-queue.json'),
142
392
  await listing('.claude/approvals'),
143
393
  await listing('.claude/state/stop-blocks'),
144
- [...agentsAtWork(await $.agent.list())].sort().join(','),
394
+ [...agentsAtWork(await $.agent.list().catch(() => []))].sort().join(','),
145
395
  ].join('\n');
146
396
  },
147
397
  // PANE-1: open the Lanes pane, or close it when it's open.
@@ -163,9 +413,29 @@ export function register(on) {
163
413
  if (!placed.isPlaced) return { text: `The Lanes pane is waiting: ${placed.reason}` };
164
414
  return {};
165
415
  },
416
+ // DECIDE-3: the Decisions tab, on the decision the person asked about.
417
+ decide: async (line) => {
418
+ const cards = cardsNow(await $.clock.now());
419
+ const at = Math.max(
420
+ 0,
421
+ cards.findIndex((c) => c.line.kind === line?.kind),
422
+ );
423
+ paneUi = { ...paneUi, tab: 'decisions', decision: at, story: null };
424
+ if (!(await $.ui.panes()).some((p) => p.id === PANE_ID))
425
+ await $.ui.open({ id: PANE_ID, title: 'Lanes', focus: true, closeOnEscape: true });
426
+ $.ui.invalidate('ui.render');
427
+ await act.readMaps();
428
+ $.ui.invalidate('ui.render');
429
+ return {};
430
+ },
166
431
  // ACT-1: the confirmation, prefilled from the request.
167
432
  approve: async (line) => {
168
- approving = { request: line.request, reason: prefilledReason(line.request), error: null };
433
+ approving = {
434
+ request: line.request,
435
+ reason: prefilledReason(line.request),
436
+ error: null,
437
+ heldId: line.heldId ?? null,
438
+ };
169
439
  await $.ui.open({ id: APPROVE_ID, title: 'Approve', focus: true, closeOnEscape: true });
170
440
  $.ui.invalidate('ui.render');
171
441
  },
@@ -195,14 +465,26 @@ export function register(on) {
195
465
  const why = (ran.stderr || ran.stdout).trim().split('\n')[0];
196
466
  notice = `Nothing was approved: ${why}`;
197
467
  approving = { ...approving, error: notice };
468
+ // HOLD-3: a held call whose approval couldn't be written is refused as the hooks refused it.
469
+ if (approving.heldId) await answerHeld(approving.heldId, 'refused');
198
470
  $.ui.invalidate('ui.render');
199
471
  return;
200
472
  }
473
+ if (approving.heldId) await answerHeld(approving.heldId, 'approved');
201
474
  approving = null;
202
475
  notice = null;
203
476
  await $.ui.close({ id: APPROVE_ID });
204
477
  await act.reload();
205
478
  },
479
+ // HOLD-2: Approve opens ACT-1's confirmation for the held call; Refuse lets the refusal through.
480
+ approveHeld: (line) =>
481
+ act.approve({
482
+ request: { names: line.held.names, lane: line.held.lane, what: line.held.what },
483
+ heldId: line.held.id,
484
+ }),
485
+ refuseHeld: async (line) => {
486
+ await answerHeld(line.held.id, 'refused');
487
+ },
206
488
  cancelApproval: async () => {
207
489
  approving = null;
208
490
  await $.ui.close({ id: APPROVE_ID });
@@ -240,6 +522,27 @@ export function register(on) {
240
522
  await act.reload();
241
523
  },
242
524
  // CARD-2: no model call; the transcript shows it.
525
+ // MQ-2: the head of the queue, merged by the person: verified first, sent back if it fails.
526
+ mergeQueue: async (line) => {
527
+ const answer = await $.ui
528
+ .ask(
529
+ `Merge ${line.branch}, next in the merge queue, into ${model.base ?? 'the base branch'}? code-kit verify runs first; if it conflicts or fails, it goes back to its lane.`,
530
+ ['Merge', 'Cancel'],
531
+ )
532
+ .catch(() => 'Cancel');
533
+ if (answer !== 'Merge') return;
534
+ const ran = await $.process.run(['node', cli, 'queue', 'merge', '--person'], {
535
+ cwd,
536
+ timeoutMs: 600000,
537
+ });
538
+ result = {
539
+ ok: ran.exitCode === 0,
540
+ title: ran.exitCode === 0 ? `Merged ${line.branch}` : `Not merged: ${line.branch}`,
541
+ text: `${ran.stdout}${ran.stderr}`.trim(),
542
+ };
543
+ await $.ui.open({ id: RESULT_ID, title: 'Merge', focus: true, closeOnEscape: true });
544
+ await act.reload();
545
+ },
243
546
  approvals: async () => {
244
547
  await act.reload();
245
548
  if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
@@ -314,11 +617,395 @@ export function register(on) {
314
617
  // USE-1: a model request's tokens, put down to the agent that made it and its story and lane.
315
618
  measure: async (agentId, usage) => {
316
619
  if (agentId && !agentsSeen.has(agentId))
317
- for (const a of (await $.agent.list()) ?? []) agentsSeen.set(a.id, a);
620
+ for (const a of (await $.agent.list().catch(() => [])) ?? []) agentsSeen.set(a.id, a);
318
621
  const who = attribution(agentId, agentsSeen.get(agentId) ?? null, model.check, model.state);
319
622
  ledger = withUsage(ledger, who, usage);
320
623
  $.ui.invalidate('ui.render');
321
624
  },
625
+ // --- the lead loop (spec 05) ---------------------------------------------------------------
626
+ record: async (kind, target, prompt) => {
627
+ loop.steps.push({ at: await $.clock.now(), kind, target, ...(prompt ? { prompt } : {}) });
628
+ if (loop.steps.length > 50) loop.steps.shift();
629
+ },
630
+ // LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on.
631
+ submit: async (prompt, kind, target) => {
632
+ loop.started = true;
633
+ loop.proposal = null;
634
+ await act.record(kind, target, prompt);
635
+ $.ui.invalidate('ui.render');
636
+ $.prompt.submit({ text: prompt }).catch((err) => {
637
+ notice = `The loop couldn't prompt the lead: ${err?.message ?? err}`;
638
+ $.ui.invalidate('ui.render');
639
+ });
640
+ },
641
+ // LOOP-1, LOOP-5, LOOP-7: with the lead idle and no draft, the step `next` reports.
642
+ step: async (trigger) => {
643
+ const h = harness();
644
+ if (loop.paused || h.autonomy === 'off' || model.state?.kind !== 'ok' || loop.done) return;
645
+ if (leadTurn) return;
646
+ if (trigger === 'tick' && !loop.started && !loop.pending.length) return;
647
+ const box = await $.prompt.read().catch(() => null);
648
+ if (box?.text?.trim()) return;
649
+ if (loop.pending.length) {
650
+ const { story, prompt } = loop.pending.shift();
651
+ // Until the person has chosen how hands-off the loop is, or under propose, a restart is offered
652
+ // like any other step, not taken.
653
+ const chosenYet = (model.check?.harnessSet ?? []).includes('autonomy');
654
+ if (!chosenYet || h.autonomy === 'propose') {
655
+ loop.proposal = {
656
+ prompt,
657
+ label: `Restart ${story}`,
658
+ kind: 'restart',
659
+ target: story,
660
+ first: !chosenYet,
661
+ };
662
+ $.ui.invalidate('ui.render');
663
+ return;
664
+ }
665
+ return act.submit(prompt, 'restart', story);
666
+ }
667
+ const ran = await $.process.run(['node', cli, 'next', '--json'], { cwd });
668
+ const step = ran.exitCode === 0 ? parseJson(ran.stdout) : null;
669
+ // MISSION-1: the band shows what the loop takes next.
670
+ loop.nextStep = step;
671
+ if (!step) {
672
+ notice = `The loop is waiting: code-kit next failed (${(ran.stderr || ran.stdout).trim().split('\n')[0]})`;
673
+ $.ui.invalidate('ui.render');
674
+ return;
675
+ }
676
+ if (step.step === 'done') {
677
+ const count = (model.state.stories ?? []).filter((s) => s.state === 'done').length;
678
+ loop.done = { count };
679
+ await act.record('done', `${count} stories`);
680
+ $.ui.invalidate('ui.render');
681
+ return;
682
+ }
683
+ let prompt = leadPrompt(step, { cli });
684
+ let kind = step.step;
685
+ let target = step.args ?? '';
686
+ let key = stepKey(step);
687
+ let label = stepLabel(step);
688
+ // LOOP-4: with the reviewer on, a branch is reviewed in the background before the lead's review.
689
+ if (step.step === 'review' && harness().agents.reviewer.on && reviewerAgent) {
690
+ const due = await act.reviewStep(step);
691
+ if (!due) return;
692
+ ({ prompt, kind, target, key, label } = due);
693
+ }
694
+ // A different step, or none for the lead, starts the count of times in a row again.
695
+ if (key !== loop.asked.key || !prompt) {
696
+ loop.asked = { key, times: 0 };
697
+ if (loop.proposal?.repeated) loop.proposal = null;
698
+ }
699
+ if (!prompt) return;
700
+ // Never the same step twice while the project stands still.
701
+ const print = await act.fingerprint();
702
+ if (key === loop.lastKey && print === loop.lastPrint) return;
703
+ loop.lastKey = key;
704
+ loop.lastPrint = print;
705
+ // LOOP-8: a step the lead was asked for twice and that is still next didn't take: the project
706
+ // moving elsewhere (lanes committing) doesn't make a third prompt useful. The person decides.
707
+ if (loop.asked.times >= ASK_LIMIT) {
708
+ if (loop.proposal?.label !== label) {
709
+ loop.proposal = { prompt, label, kind, target, repeated: loop.asked.times };
710
+ $.ui.invalidate('ui.render');
711
+ }
712
+ return;
713
+ }
714
+ // Until the person has chosen how hands-off the loop is, its first step is offered, not taken.
715
+ const chosen = (model.check?.harnessSet ?? []).includes('autonomy');
716
+ if (h.autonomy === 'propose' || !chosen) {
717
+ loop.proposal = { prompt, label, kind, target, first: !chosen };
718
+ $.ui.invalidate('ui.render');
719
+ return;
720
+ }
721
+ loop.asked.times += 1;
722
+ await act.submit(prompt, kind, target);
723
+ },
724
+ // The first offer's answers: the person's choice of autonomy, recorded as theirs, then acted on.
725
+ chooseAutonomy: async (value) => {
726
+ const ran = await $.process.run(
727
+ [
728
+ 'node',
729
+ cli,
730
+ 'settings',
731
+ 'set',
732
+ 'autonomy',
733
+ value,
734
+ '--reason',
735
+ 'Chosen when code-kit first offered to keep the build moving between prompts',
736
+ '--via',
737
+ 'pane',
738
+ ],
739
+ { cwd },
740
+ );
741
+ if (ran.exitCode !== 0) {
742
+ notice = `Nothing was changed: ${(ran.stderr || ran.stdout).trim().split('\n')[0]}`;
743
+ $.ui.invalidate('ui.render');
744
+ return false;
745
+ }
746
+ await act.reload();
747
+ return true;
748
+ },
749
+ keepGoing: async () => {
750
+ if (!(await act.chooseAutonomy('autonomous'))) return;
751
+ await act.go();
752
+ },
753
+ askEach: async () => {
754
+ if (!(await act.chooseAutonomy('propose'))) return;
755
+ if (loop.proposal) loop.proposal = { ...loop.proposal, first: false };
756
+ $.ui.invalidate('ui.render');
757
+ },
758
+ loopOff: async () => {
759
+ if (!(await act.chooseAutonomy('off'))) return;
760
+ loop.proposal = null;
761
+ $.ui.invalidate('ui.render');
762
+ },
763
+ go: async () => {
764
+ if (!loop.proposal || leadTurn) return;
765
+ const { prompt, kind, target, repeated } = loop.proposal;
766
+ // The person asked again: once more, then it's theirs to offer again if it still doesn't take.
767
+ loop.asked.times = repeated ? ASK_LIMIT : loop.asked.times + 1;
768
+ await act.submit(prompt, kind, target);
769
+ },
770
+ // LOOP-6: pausing stops new steps at once; a turn in progress finishes.
771
+ pause: async () => {
772
+ loop.paused = true;
773
+ loop.proposal = null;
774
+ // Recorded, so other tools' background work in this project pauses with the loop.
775
+ await $.process.run(['node', cli, 'loop', 'pause'], { cwd }).catch(() => {});
776
+ await act.record('pause', 'the loop');
777
+ $.ui.invalidate('ui.render');
778
+ },
779
+ resume: async () => {
780
+ loop.paused = false;
781
+ await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
782
+ loop.lastKey = null;
783
+ await act.record('resume', 'the loop');
784
+ $.ui.invalidate('ui.render');
785
+ await act.step('resume');
786
+ },
787
+ dismissDone: () => {
788
+ loop.doneDismissed = true;
789
+ $.ui.invalidate('ui.render');
790
+ },
791
+ // LOOP-3: a lane stalled past its restarts; Resume lets the loop restart it again.
792
+ resumeStall: async (line) => {
793
+ const { story } = line.stall;
794
+ flagged.delete(story);
795
+ restarts.set(story, 0);
796
+ for (const a of activity.values()) if (a.story === story) a.stopped = false;
797
+ await act.record('resume', story);
798
+ $.ui.invalidate('ui.render');
799
+ },
800
+ // LOOP-3: each lane agent at work, by the time since its last tool call.
801
+ stalls: async () => {
802
+ const h = harness();
803
+ if (loop.paused || h.autonomy === 'off' || model.state?.kind !== 'ok') return;
804
+ const now = await $.clock.now();
805
+ const lanes = Object.entries(model.check?.lanes ?? {});
806
+ const waitingOnPerson = new Set([...held.values()].map((c) => c.agentId));
807
+ for (const agent of (await $.agent.list().catch(() => [])) ?? []) {
808
+ if (agent.status !== 'running') continue;
809
+ const lane = lanes.find(([, l]) => l.agent === agent.type)?.[0];
810
+ if (!lane) continue;
811
+ agentsSeen.set(agent.id, agent);
812
+ const a = activityOf(agent.id, now);
813
+ a.lane = lane;
814
+ a.story = attribution(agent.id, agent, model.check, model.state).story ?? lane;
815
+ // A held call, or a long command still running, isn't quiet.
816
+ if (a.inFlight > 0 || waitingOnPerson.has(agent.id)) {
817
+ a.at = now;
818
+ continue;
819
+ }
820
+ // LOOP-3: an agent whose work is done isn't stalled: it handed back, or its story is in review,
821
+ // in the merge queue or merged. Nothing to nudge or restart.
822
+ if (finishedWork(a)) {
823
+ a.at = now;
824
+ continue;
825
+ }
826
+ const due = stallDue(now - a.at, a, restarts.get(a.story) ?? 0, h.stall);
827
+ if (due === 'nudge') {
828
+ a.nudged = true;
829
+ await act.record('nudge', `${lane} · ${a.story}`);
830
+ // An undelivered message still counts as the nudge; the restart follows.
831
+ await $.session
832
+ .send({ to: { agentId: agent.id }, text: nudgeText(h.stall.nudgeMinutes) })
833
+ .catch(() => {});
834
+ } else if (due === 'restart') {
835
+ a.stopped = true;
836
+ const n = (restarts.get(a.story) ?? 0) + 1;
837
+ restarts.set(a.story, n);
838
+ loop.pending.push({
839
+ story: a.story,
840
+ prompt: restartPrompt({
841
+ type: agent.type,
842
+ id: agent.id,
843
+ story: a.story,
844
+ minutes: h.stall.restartMinutes,
845
+ }),
846
+ });
847
+ await act.record('restart', `${lane} · ${a.story} (${n}/${h.stall.maxRestarts})`);
848
+ } else if (due === 'flag') {
849
+ a.stopped = true;
850
+ flagged.set(a.story, { lane, story: a.story, count: (restarts.get(a.story) ?? 0) + 1 });
851
+ await act.record('flag', `${lane} · ${a.story}`);
852
+ }
853
+ if (due) $.ui.invalidate('ui.render');
854
+ }
855
+ if (loop.pending.length) await act.step('tick');
856
+ },
857
+ // VIEW-3: working characters repaint in place while the pane shows them; nothing runs otherwise.
858
+ animate: async () => {
859
+ const open = (await $.ui.panes()).some((p) => p.id === PANE_ID);
860
+ const now = await $.clock.now();
861
+ const art = artNow(now, liveNow(now));
862
+ const working = Object.keys(art.poses).filter((n) => art.poses[n] === 'working');
863
+ if (!open || paneSurface !== 'terminal' || !working.length) {
864
+ animation?.cancel();
865
+ animation = null;
866
+ return;
867
+ }
868
+ if (animation) return;
869
+ animation = $.clock.every(400, async () => {
870
+ frame = (frame + 1) % 2;
871
+ const at = await $.clock.now();
872
+ const still = artNow(at, liveNow(at));
873
+ for (const name of Object.keys(still.poses).filter((n) => still.poses[n] === 'working'))
874
+ await $.ui
875
+ .blit({
876
+ requestId: PANE_ID,
877
+ key: `char-${name}`,
878
+ cells: rasterCells(
879
+ sprite('working', frame, still.tints[name], { lead: name === 'lead' }),
880
+ ),
881
+ })
882
+ .catch(() => {});
883
+ });
884
+ },
885
+ // --- panes v2: the selected lane's acts (VIEW-6) ----------------------------------------
886
+ // A prompt the person asked for: submitted now if the lead is idle, else when it next is.
887
+ askLead: async (prompt, kind, target) => {
888
+ await act.record(kind, target, prompt);
889
+ if (leadTurn) personAsks.push(prompt);
890
+ else
891
+ $.prompt.submit({ text: prompt }).catch((err) => {
892
+ notice = `Couldn't prompt the lead: ${err?.message ?? err}`;
893
+ });
894
+ $.ui.invalidate('ui.render');
895
+ },
896
+ runningAgentOf: async (lane) =>
897
+ ((await $.agent.list().catch(() => [])) ?? []).find(
898
+ (a) => a.status === 'running' && a.type === lane.agent,
899
+ ) ?? null,
900
+ nudgeLane: async (lane) => {
901
+ const agent = await act.runningAgentOf(lane);
902
+ if (!agent) return;
903
+ await act.record('nudge', `${lane.name} · by the person`);
904
+ await $.session
905
+ .send({
906
+ to: { agentId: agent.id },
907
+ text: 'The person asks: report where you are, or carry on.',
908
+ })
909
+ .catch(() => {});
910
+ $.ui.invalidate('ui.render');
911
+ },
912
+ stopLane: async (lane) => {
913
+ const agent = await act.runningAgentOf(lane);
914
+ if (!agent) return;
915
+ const answer = await $.ui
916
+ .ask(
917
+ `Stop ${agent.type} on ${lane.story?.id ?? lane.name}? Its uncommitted work stays in its worktree.`,
918
+ ['Stop', 'Cancel'],
919
+ )
920
+ .catch(() => 'Cancel');
921
+ if (answer !== 'Stop') return;
922
+ await act.askLead(
923
+ `The person asked to stop ${agent.type} (agent ${agent.id})${lane.story ? ` on ${lane.story.id}` : ''}: stop it with TaskStop, and don't dispatch its story again until they say so.`,
924
+ 'stop',
925
+ lane.name,
926
+ );
927
+ },
928
+ sendBack: async (lane) => {
929
+ if (!lane.branch) return;
930
+ sendingBack = {
931
+ lane: lane.name,
932
+ branch: lane.branch,
933
+ story: lane.story?.id ?? lane.branch,
934
+ reason: '',
935
+ error: null,
936
+ };
937
+ await $.ui.open({ id: SENDBACK_ID, title: 'Send back', focus: true, closeOnEscape: true });
938
+ $.ui.invalidate('ui.render');
939
+ },
940
+ confirmSendBack: async (reason) => {
941
+ if (!sendingBack) return;
942
+ if (!reason?.trim()) {
943
+ sendingBack = { ...sendingBack, error: 'Give a reason: the lane reads it for its fix.' };
944
+ $.ui.invalidate('ui.render');
945
+ return;
946
+ }
947
+ const ran = await $.process.run(
948
+ ['node', cli, 'sent-back', sendingBack.branch, '--reason', reason.trim()],
949
+ { cwd },
950
+ );
951
+ if (ran.exitCode !== 0) {
952
+ sendingBack = { ...sendingBack, error: (ran.stderr || ran.stdout).trim().split('\n')[0] };
953
+ $.ui.invalidate('ui.render');
954
+ return;
955
+ }
956
+ await act.record('sent back', `${sendingBack.branch}: ${reason.trim()}`);
957
+ sendingBack = null;
958
+ await $.ui.close({ id: SENDBACK_ID });
959
+ await act.reload();
960
+ },
961
+ cancelSendBack: async () => {
962
+ sendingBack = null;
963
+ await $.ui.close({ id: SENDBACK_ID });
964
+ },
965
+ // VIEW-7: a story's drill-down, its facts from `code-kit story --json`.
966
+ openStory: async (lane) => {
967
+ if (!lane.story) return;
968
+ const id = lane.story.id;
969
+ paneUi = { ...paneUi, story: id, file: null };
970
+ storyOpen = { id, lane: lane.name, data: null, error: null };
971
+ $.ui.invalidate('ui.render');
972
+ const ran = await $.process.run(['node', cli, 'story', id, '--json'], {
973
+ cwd,
974
+ timeoutMs: 180000,
975
+ });
976
+ if (storyOpen?.id !== id) return;
977
+ const data = ran.exitCode === 0 ? parseJson(ran.stdout) : null;
978
+ storyOpen = data
979
+ ? { ...storyOpen, data }
980
+ : {
981
+ ...storyOpen,
982
+ error: (ran.stderr || ran.stdout).trim().split('\n')[0] || 'code-kit story failed',
983
+ };
984
+ $.ui.invalidate('ui.render');
985
+ },
986
+ closeStory: () => {
987
+ paneUi = { ...paneUi, story: null, file: null };
988
+ storyOpen = null;
989
+ $.ui.invalidate('ui.render');
990
+ },
991
+ // VIEW-6: a lane's act, from its key or button.
992
+ laneAct: async (id, lane) => {
993
+ if (id === 'approve') {
994
+ const call = [...held.values()].find((c) => (c.lane ?? 'lead') === lane.name);
995
+ if (call) return act.approveHeld({ held: call });
996
+ const request = (model.requests?.open ?? []).find(
997
+ (r) => (r.lane ?? 'lead') === lane.name,
998
+ );
999
+ if (request) return act.approve({ request });
1000
+ return;
1001
+ }
1002
+ if (id === 'review' && lane.story)
1003
+ return act.review({ story: { ...lane.story, branch: lane.branch } });
1004
+ if (id === 'merge') return act.mergeQueue({ branch: lane.branch });
1005
+ if (id === 'sendBack') return act.sendBack(lane);
1006
+ if (id === 'nudge') return act.nudgeLane(lane);
1007
+ if (id === 'stop') return act.stopLane(lane);
1008
+ },
322
1009
  dismiss: async () => {
323
1010
  notice = null;
324
1011
  $.ui.invalidate('ui.render');
@@ -334,6 +1021,8 @@ export function register(on) {
334
1021
  if (busy) return;
335
1022
  busy = true;
336
1023
  try {
1024
+ await act.stalls();
1025
+ await act.animate();
337
1026
  const now = await act.fingerprint();
338
1027
  const paneOpen = (await $.ui.panes()).some((p) => p.id === PANE_ID);
339
1028
  quiet += 1;
@@ -342,10 +1031,16 @@ export function register(on) {
342
1031
  await act.reload();
343
1032
  // Taken again: what it covers (the plan) can change with what was read.
344
1033
  seen = await act.fingerprint();
1034
+ // Between turns, a change (a lane finished, a branch moved) can make a step due.
1035
+ if (now !== seen || quiet === 0) await act.step('tick');
345
1036
  } finally {
346
1037
  busy = false;
347
1038
  }
348
1039
  });
1040
+ // HOLD-2: the countdown on a held call, redrawn each second while one waits.
1041
+ $.clock.every(1000, () => {
1042
+ if (held.size) $.ui.invalidate('ui.render');
1043
+ });
349
1044
  return next(e);
350
1045
  });
351
1046
 
@@ -364,15 +1059,105 @@ export function register(on) {
364
1059
  );
365
1060
 
366
1061
  // CARD-1: the text a code-kit hook refused a call with, kept for the call's result to draw as a card.
1062
+ // HOLD-1 to HOLD-6: a refusal a person's approval would allow is held while the band asks. The hooks
1063
+ // stay the judge: the call is run again once the approval is written, and refused as before otherwise.
367
1064
  on('tool.call', async ($, e, next) => {
368
- const res = await next(e);
369
- const text = res?.deny ?? (res?.isError ? (res.text ?? String(res.result ?? '')) : null);
370
- if (text && refusalCard(text)) {
371
- refusals.set(e.tool_use_id, text);
372
- if (refusals.size > REFUSALS_KEPT) refusals.delete(refusals.keys().next().value);
1065
+ // LOOP-3: a lane agent's tool call is activity; while it runs, the agent isn't quiet.
1066
+ const calledAt = await $.clock.now();
1067
+ const busy = e.agentId ? activityOf(e.agentId, calledAt) : null;
1068
+ if (busy) {
1069
+ busy.inFlight += 1;
1070
+ busy.nudged = false;
1071
+ busy.stopped = false;
1072
+ busy.handedBack = false;
1073
+ // VIEW-4: the agent's current or last tool call.
1074
+ busy.tool = toolLine(e);
1075
+ busy.at = calledAt;
1076
+ } else if (!e.agentId) leadLast = { tool: toolLine(e), at: calledAt };
1077
+ // MAP-2: the file the call reads or edits glows in the code map, in its lane's colour. An agent
1078
+ // first seen on this call has its lane found by its type.
1079
+ const touch = touchOf(e, where?.cwd);
1080
+ if (touch && busy && !busy.lane) {
1081
+ const agent = ((await $.agent.list().catch(() => [])) ?? []).find((a) => a.id === e.agentId);
1082
+ busy.lane =
1083
+ Object.entries(model.check?.lanes ?? {}).find(([, l]) => l.agent === agent?.type)?.[0] ??
1084
+ busy.lane;
373
1085
  }
374
- return res;
375
- }).catch(($, e, next) => next(e)); // it only watches: whatever fails here, the call goes on as it would
1086
+ const touchLane = busy ? busy.lane : e.agentId ? null : 'lead';
1087
+ if (touch && touchLane) {
1088
+ touches.push({ ...touch, lane: touchLane, at: calledAt });
1089
+ if (touches.length > TOUCHES_KEPT) touches.shift();
1090
+ if (paneUi.tab === 'code') $.ui.invalidate('ui.render');
1091
+ }
1092
+ try {
1093
+ const res = await next(e);
1094
+ const text = res?.deny ?? (res?.isError ? (res.text ?? String(res.result ?? '')) : null);
1095
+ if (text && refusalCard(text)) {
1096
+ refusals.set(e.tool_use_id, text);
1097
+ if (refusals.size > REFUSALS_KEPT) refusals.delete(refusals.keys().next().value);
1098
+ }
1099
+ const ask = text ? holdable(text) : null;
1100
+ const settings = model.check?.harness;
1101
+ const minutes = settings?.hold?.minutes ?? 2;
1102
+ // HOLD-6: nobody to ask (claude -p, the SDK), or hold switched off: refused as today.
1103
+ if (!ask || !where?.interactive || minutes <= 0 || model.state?.kind !== 'ok') return res;
1104
+ const id = e.tool_use_id;
1105
+ let outsideLimits = null;
1106
+ // HOLD-5: under autonomy, the project's delegated rules decide first, without asking.
1107
+ if ((settings?.autonomy ?? 'autonomous') === 'autonomous') {
1108
+ const delegated = await $.process.run(
1109
+ [
1110
+ 'node',
1111
+ where.cli,
1112
+ 'approve',
1113
+ ...ask.names,
1114
+ ...(ask.lane ? ['--lane', ask.lane] : []),
1115
+ '--delegated',
1116
+ '--reason',
1117
+ `Held call within the delegated rules: ${ask.what}`,
1118
+ ],
1119
+ { cwd: where.cwd, timeoutMs: 60000 },
1120
+ );
1121
+ if (delegated.exitCode === 0) return next(e);
1122
+ // DECIDE-2: why it's outside the limits the person set, for the decision's card.
1123
+ outsideLimits = (delegated.stderr || delegated.stdout || '').trim();
1124
+ }
1125
+ const agent = e.agentId ? agentsSeen.get(e.agentId) : null;
1126
+ held.set(id, {
1127
+ id,
1128
+ agentId: e.agentId ?? null,
1129
+ who: ask.lane ?? agent?.type ?? (e.agentId ? 'an agent' : 'the lead'),
1130
+ kind: ask.kind,
1131
+ names: ask.names,
1132
+ lane: ask.lane,
1133
+ what: ask.what,
1134
+ since: await $.clock.now(),
1135
+ minutes,
1136
+ // DECIDE-2: the call itself, the rule that refused it, and why the lead couldn't approve it.
1137
+ command: e.command ?? e.file_path ?? null,
1138
+ refusal: text.split('\n')[0],
1139
+ outside: outsideLimits,
1140
+ });
1141
+ $.ui.invalidate('ui.render');
1142
+ // The wait is a process the band's answer ends (`code-kit hold`), so it isn't the hook's own time.
1143
+ const waited = await $.process
1144
+ .run(['node', where.cli, 'hold', id, '--minutes', String(minutes)], {
1145
+ cwd: where.cwd,
1146
+ // A little past the hold time, within the host's ten minutes for a process.
1147
+ timeoutMs: Math.min(minutes * 60000 + 15000, 600000),
1148
+ })
1149
+ .catch(() => null);
1150
+ held.delete(id);
1151
+ $.ui.invalidate('ui.render');
1152
+ // HOLD-3: the same call, now with the approval in force; HOLD-4: refused as the hooks refused it.
1153
+ return waited?.stdout.trim() === 'approved' ? next(e) : res;
1154
+ } finally {
1155
+ if (busy) {
1156
+ busy.inFlight -= 1;
1157
+ busy.at = await $.clock.now();
1158
+ }
1159
+ }
1160
+ }).catch(($, e, next) => next(e)); // whatever fails here, the call goes on as it would
376
1161
  // A refused call's text: kept from its tool.call, or the output its row carries (what the model read).
377
1162
  const refusalOf = (id, output) =>
378
1163
  refusalCard(refusals.get(id) ?? (typeof output === 'string' ? output : null));
@@ -407,6 +1192,7 @@ export function register(on) {
407
1192
  // USE-1: each model request's usage, as the request ends; the lead's requests carry no agentId.
408
1193
  on('turn.step', async function* ($, e, next) {
409
1194
  const res = yield* next(e);
1195
+ if (e.agentId) activityOf(e.agentId, 0).at = await $.clock.now();
410
1196
  if (res?.usage && act) await act.measure(e.agentId, res.usage).catch(() => {});
411
1197
  return res;
412
1198
  });
@@ -426,6 +1212,8 @@ export function register(on) {
426
1212
  });
427
1213
 
428
1214
  on('turn.start', async ($, e, next) => {
1215
+ leadTurn = e.turnId;
1216
+ $.ui.invalidate('ui.render'); // the lead's lane reads working while its turn runs
429
1217
  if (reviewTurn === 'next') reviewTurn = e.turnId;
430
1218
  return next(e);
431
1219
  });
@@ -436,18 +1224,387 @@ export function register(on) {
436
1224
  reviewTurn = null;
437
1225
  $.ui.invalidate('ui.render');
438
1226
  }
439
- return next(e);
1227
+ const res = await next(e);
1228
+ // LOOP-3: a lane agent that has handed back its work isn't stalled while it waits; a message that
1229
+ // starts it again shows as its next tool call.
1230
+ if (e.agentId) activityOf(e.agentId, await $.clock.now()).handedBack = true;
1231
+ // REVW-3: a subagent's last turn may be the reviewer's report.
1232
+ if (e.agentId && act) await act.reviewed(e.agentId, e.answer).catch(() => {});
1233
+ // LOOP-1: the lead is idle; the loop's next step, if one is due (submitted from turn.complete,
1234
+ // as the spike found it must be). The prompt it submits isn't awaited: that turn isn't this hook's.
1235
+ if (!e.agentId && (!leadTurn || leadTurn === e.turnId)) {
1236
+ leadTurn = null;
1237
+ $.ui.invalidate('ui.render');
1238
+ // What the person asked for goes first, whatever the loop's state.
1239
+ if (personAsks.length) {
1240
+ const text = personAsks.shift();
1241
+ $.prompt.submit({ text }).catch(() => {});
1242
+ return res;
1243
+ }
1244
+ await act?.step('turn').catch(() => {});
1245
+ }
1246
+ return res;
440
1247
  });
441
1248
 
1249
+ // What the pane and band show of the loop.
1250
+ const loopNow = (now) => {
1251
+ const h = harness();
1252
+ const state =
1253
+ h.autonomy === 'off' ? 'off' : loop.done ? 'done' : loop.paused ? 'paused' : 'running';
1254
+ const laneNotes = {};
1255
+ for (const a of activity.values()) {
1256
+ if (!a.lane) continue;
1257
+ const n = restarts.get(a.story) ?? 0;
1258
+ if (a.nudged && !a.stopped) laneNotes[a.lane] = 'nudged';
1259
+ if (a.stopped && n) laneNotes[a.lane] = `restarted ${n}/${h.stall.maxRestarts}`;
1260
+ }
1261
+ for (const f of flagged.values()) laneNotes[f.lane] = 'stalled';
1262
+ const last = [...loop.steps]
1263
+ .reverse()
1264
+ .find((s) => ['dispatch', 'review', 'lead', 'restart'].includes(s.kind));
1265
+ return {
1266
+ state: state === 'done' && loop.doneDismissed ? 'stopped' : state,
1267
+ autonomy: h.autonomy,
1268
+ started: loop.started,
1269
+ last: last ? `${last.kind} ${last.target}` : null,
1270
+ proposal: loop.proposal,
1271
+ flagged: [...flagged.values()],
1272
+ doneCount: loop.done?.count ?? 0,
1273
+ steps: loop.steps,
1274
+ // LANES-1: the step the lead is taking now (the loop's last prompt, while its turn runs), and the next.
1275
+ current: leadTurn && loop.started ? (loop.steps.at(-1) ?? null) : null,
1276
+ nextText: nextText(loop.nextStep),
1277
+ laneNotes,
1278
+ now,
1279
+ reviews: [...reviews.values()].map((r) => ({
1280
+ ...r,
1281
+ summary: findingsCount(r.findings),
1282
+ blockers: r.findings.filter((f) => f.severity === 'blocker').length,
1283
+ })),
1284
+ };
1285
+ };
1286
+
1287
+ // The project as the panes draw it: the lead active while its turn runs, which the last read can't know.
1288
+ const stateNow = () =>
1289
+ model.state?.kind === 'ok'
1290
+ ? {
1291
+ ...model.state,
1292
+ lanes: model.state.lanes.map((l) =>
1293
+ l.name === 'lead' ? { ...l, active: Boolean(leadTurn) } : l,
1294
+ ),
1295
+ }
1296
+ : model.state;
1297
+ // VIEW-4: each lane's current or last tool call, and how long ago.
1298
+ // JOIN-2: each lane's facts from the adapters: edits without understanding and cards owed.
1299
+ const factsOf = (lane) => {
1300
+ const key = lane.agent ?? 'lead';
1301
+ const out = { withoutUnderstanding: [], cardsOwed: [] };
1302
+ for (const a of model.adapters ?? []) {
1303
+ const f = a.lanes?.[key];
1304
+ if (!f) continue;
1305
+ out.withoutUnderstanding.push(...f.withoutUnderstanding);
1306
+ out.cardsOwed.push(...f.cardsOwed.filter((c) => !out.cardsOwed.includes(c)));
1307
+ }
1308
+ return out;
1309
+ };
1310
+ const combinedNow = () => {
1311
+ if (!(model.adapters ?? []).some((a) => a.lanes)) return null;
1312
+ const lanesNow = stateNow()?.kind === 'ok' ? stateNow().lanes : [];
1313
+ return Object.fromEntries(
1314
+ lanesNow.map((l) => {
1315
+ const f = factsOf(l);
1316
+ return [l.name, { without: f.withoutUnderstanding.length, owed: f.cardsOwed.length }];
1317
+ }),
1318
+ );
1319
+ };
1320
+ // VIEW-2, VIEW-3: each lane's pose and tint, and its story's bar on the shared axis.
1321
+ const artNow = (now, live) => {
1322
+ const lanesNow = stateNow()?.kind === 'ok' ? stateNow().lanes : [];
1323
+ const names = lanesNow.map((l) => l.name);
1324
+ const queue = model.queue ?? [];
1325
+ const asking = new Set([
1326
+ ...[...held.values()].map((c) => c.lane ?? 'lead'),
1327
+ ...(model.requests?.open ?? []).map((r) => r.lane ?? 'lead'),
1328
+ ]);
1329
+ const stalled = new Set([...flagged.values()].map((f) => f.lane));
1330
+ const times = (model.timeline?.stories ?? []).filter((t) =>
1331
+ lanesNow.some((l) => l.story?.id === t.id),
1332
+ );
1333
+ const start = axisStart(times, now);
1334
+ const width = Math.max(10, Math.min(120, paneColumns - 14));
1335
+ const poses = {};
1336
+ const tints = {};
1337
+ const bars = {};
1338
+ for (const l of lanesNow) {
1339
+ poses[l.name] = poseOf(l, {
1340
+ live: live[l.name],
1341
+ waiting: asking.has(l.name),
1342
+ flagged: stalled.has(l.name),
1343
+ shown: shownState(l, queue),
1344
+ });
1345
+ tints[l.name] = tintOf(l.name, names);
1346
+ const t = times.find((x) => x.id === l.story?.id);
1347
+ if (t) bars[l.name] = barCells(t, { start, now, width });
1348
+ }
1349
+ return { surface: paneSurface, frame, poses, tints, bars, svgWidth: width * 6 };
1350
+ };
1351
+ const liveNow = (now) => {
1352
+ const stall = harness().stall;
1353
+ const live = {};
1354
+ if (leadLast) live.lead = { tool: leadLast.tool, ago: ago(now - leadLast.at), level: null };
1355
+ for (const a of activity.values()) {
1356
+ if (!a.lane || !a.tool) continue;
1357
+ if (live[a.lane] && live[a.lane].at > a.at) continue;
1358
+ live[a.lane] = {
1359
+ tool: a.tool,
1360
+ at: a.at,
1361
+ ago: a.inFlight > 0 ? 'now' : ago(now - a.at),
1362
+ level: liveLevel(now - a.at, stall, {
1363
+ flagged: [...flagged.values()].some((f) => f.lane === a.lane),
1364
+ running: a.inFlight > 0,
1365
+ }),
1366
+ };
1367
+ }
1368
+ return live;
1369
+ };
1370
+ // VIEW-7: the drill-down's view of the open story, with what the session knows of it.
1371
+ const storyNow = (open, now, usage) => {
1372
+ const lane = model.state?.lanes?.find((l) => l.name === open.lane) ?? null;
1373
+ const branch = open.data?.branch ?? lane?.branch ?? null;
1374
+ return {
1375
+ story: open,
1376
+ review: branch ? (reviews.get(branch) ?? null) : null,
1377
+ tokens: usage?.stories?.[open.id] ?? 0,
1378
+ outlier: usage?.outliers?.find((o) => o.id === open.id)?.ratio ?? null,
1379
+ steps: loop.steps.filter(
1380
+ (s) => s.target.includes(open.id) || (branch && s.target.includes(branch)),
1381
+ ),
1382
+ actions: lane
1383
+ ? laneActions(lane, {
1384
+ queue: model.queue ?? [],
1385
+ held: [...held.values()],
1386
+ requests: model.requests?.open ?? [],
1387
+ }).filter((a) => !['nudge', 'stop'].includes(a.id))
1388
+ : [],
1389
+ file: paneUi.file,
1390
+ now,
1391
+ understanding: lane && (model.adapters ?? []).some((a) => a.lanes) ? factsOf(lane) : null,
1392
+ };
1393
+ };
1394
+ // The band's lines, which the pane pins as what needs the person (VIEW-5).
1395
+ // JOURNAL-1: the lineage from the project's records, with this session's loop steps and your own acts
1396
+ // in it, in time order.
1397
+ const STEP_WORDS = {
1398
+ dispatch: (t) => `The loop asked the lead to hand out ${t}.`,
1399
+ review: (t) => `The loop asked the lead to review ${t}.`,
1400
+ lead: (t) => `The loop asked the lead to build ${t} itself.`,
1401
+ merge: (t) => `The loop asked the lead to merge ${t}.`,
1402
+ restart: (t) => `The loop asked the lead to restart ${t}, which had gone quiet.`,
1403
+ nudge: (t) => `The loop nudged ${t}, which had gone quiet.`,
1404
+ flag: (t) => `${t} stalled again after its restarts, so it came to you.`,
1405
+ pause: () => 'You paused the loop.',
1406
+ resume: () => 'You resumed the loop.',
1407
+ done: (t) => `Every story was done (${t}).`,
1408
+ };
1409
+ const journalNow = () => {
1410
+ const recorded = journalData?.events ?? [];
1411
+ const steps = loop.steps
1412
+ .filter((x) => STEP_WORDS[x.kind])
1413
+ .map((x) => ({
1414
+ at: x.at,
1415
+ kind: 'loop',
1416
+ who: ['pause', 'resume'].includes(x.kind) ? 'person' : 'lead',
1417
+ text: STEP_WORDS[x.kind](x.target),
1418
+ }));
1419
+ return [...recorded, ...steps].sort((a, b) => a.at - b.at);
1420
+ };
1421
+ // DECIDE-2: the decisions that need the person, each with its lineage.
1422
+ const cardsNow = (now, lines = linesNowRaw(now)) => {
1423
+ const events = journalData?.events ?? [];
1424
+ const goal = goalOf(model.state?.stories ?? []);
1425
+ const asked =
1426
+ events.find((e) => e.kind === 'request')?.text.replace(/^You asked for /, '') ?? null;
1427
+ return decisionCards(lines, {
1428
+ state: model.state,
1429
+ status: model.status,
1430
+ check: model.check,
1431
+ goal: goal ? goal.name : null,
1432
+ request: asked,
1433
+ journal: events,
1434
+ inForce: model.requests?.inForce ?? [],
1435
+ });
1436
+ };
1437
+ // The band's lines, each that needs the person carrying which story it's for.
1438
+ const linesNow = (now) => {
1439
+ const lines = linesNowRaw(now);
1440
+ const cards = cardsNow(now, lines);
1441
+ return lines.map((l) => {
1442
+ const card = cards.find((c) => c.line === l);
1443
+ return card ? { ...l, context: contextOf(card) } : l;
1444
+ });
1445
+ };
1446
+ const linesNowRaw = (now) =>
1447
+ bandLines({
1448
+ ...model,
1449
+ reviewing,
1450
+ notice,
1451
+ ...usageNow(),
1452
+ held: [...held.values()],
1453
+ now,
1454
+ loop: loopNow(now),
1455
+ leadBusy: Boolean(leadTurn),
1456
+ live: liveNow(now),
1457
+ nextStep: loop.nextStep,
1458
+ combined: combinedNow(),
1459
+ });
1460
+
442
1461
  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
1462
+ const now = await $.clock.now();
1463
+ paneSurface = e.surface;
1464
+ paneColumns = e.props.bodyColumns ?? paneColumns;
443
1465
  const { usage, plan } = usageNow();
444
- return lanesPane(model.state, $.ui.resolve(e), usage, plan);
1466
+ const lanesNow = stateNow()?.kind === 'ok' ? stateNow().lanes : [];
1467
+ return lanesFrame(
1468
+ {
1469
+ state: stateNow(),
1470
+ usage,
1471
+ plan,
1472
+ loop: { ...loopNow(now), queue: model.queue ?? [] },
1473
+ needs: linesNow(now).filter((l) => !l.mission),
1474
+ cards: cardsNow(now),
1475
+ journal: journalNow(),
1476
+ now,
1477
+ live: liveNow(now),
1478
+ held: [...held.values()],
1479
+ requests: model.requests?.open ?? [],
1480
+ ui: paneUi,
1481
+ status: model.status ?? null,
1482
+ // VIEW-6: what each lane owns, for the selected lane's detail.
1483
+ paths: {
1484
+ lead: model.check?.lead?.paths ?? [],
1485
+ ...Object.fromEntries(
1486
+ Object.entries(model.check?.lanes ?? {}).map(([n, l]) => [n, l.paths ?? []]),
1487
+ ),
1488
+ },
1489
+ art: artNow(now, liveNow(now)),
1490
+ combined: combinedNow(),
1491
+ codeMap,
1492
+ trail: trailData,
1493
+ glow: glowOf(touches, now),
1494
+ surface: paneSurface,
1495
+ columns: paneColumns,
1496
+ placement: e.props.placement,
1497
+ story: storyOpen ? storyNow(storyOpen, now, usage) : null,
1498
+ },
1499
+ $.ui.resolve(e),
1500
+ {
1501
+ onTab: (tab) => {
1502
+ paneUi = { ...paneUi, tab };
1503
+ $.ui.invalidate('ui.render');
1504
+ act?.readMaps().then(() => $.ui.invalidate('ui.render'));
1505
+ },
1506
+ onDecision: (i) => {
1507
+ paneUi = { ...paneUi, decision: i };
1508
+ $.ui.invalidate('ui.render');
1509
+ },
1510
+ onMapStory: (id) => {
1511
+ paneUi = { ...paneUi, mapStory: id, mapFile: null };
1512
+ $.ui.invalidate('ui.render');
1513
+ act?.readMaps().then(() => $.ui.invalidate('ui.render'));
1514
+ },
1515
+ onMapFile: (path) => {
1516
+ paneUi = { ...paneUi, mapFile: path };
1517
+ $.ui.invalidate('ui.render');
1518
+ },
1519
+ // MAP-3: the file in Context Graph's own view, through the command its adapter names.
1520
+ onOpenFile: (path) => {
1521
+ const command = codeMap?.open?.command;
1522
+ if (!command) return;
1523
+ $.command.run({ command, args: path }).catch((err) => {
1524
+ notice = `Couldn't open ${path}: ${err?.message ?? err}`;
1525
+ $.ui.invalidate('ui.render');
1526
+ });
1527
+ },
1528
+ onFilter: (state) => {
1529
+ paneUi = { ...paneUi, filter: paneUi.filter === state ? null : state };
1530
+ $.ui.invalidate('ui.render');
1531
+ },
1532
+ onMove: (step) => {
1533
+ if (!lanesNow.length) return;
1534
+ const at = lanesNow.findIndex((l) => l.name === paneUi.selected);
1535
+ const to =
1536
+ at < 0
1537
+ ? step > 0
1538
+ ? 0
1539
+ : lanesNow.length - 1
1540
+ : (at + step + lanesNow.length) % lanesNow.length;
1541
+ paneUi = { ...paneUi, selected: lanesNow[to].name };
1542
+ $.ui.invalidate('ui.render');
1543
+ // VIEW-6: Enter opens the selected lane's story: the focus waits on its Open button.
1544
+ if (
1545
+ lanesNow[to].story ||
1546
+ (model.state?.stories ?? []).some((s) => s.lane === lanesNow[to].name)
1547
+ )
1548
+ $.ui.focus({ requestId: PANE_ID, key: 'open-story' }).catch(() => {});
1549
+ },
1550
+ onOpen: (lane) => act?.openStory(lane),
1551
+ // TRACE-2: a cell selected by its keys or the requirement list.
1552
+ onCell: (cell) => {
1553
+ paneUi = { ...paneUi, cell };
1554
+ $.ui.invalidate('ui.render');
1555
+ },
1556
+ story: {
1557
+ onBack: () => act?.closeStory(),
1558
+ onFile: (file) => {
1559
+ paneUi = { ...paneUi, file };
1560
+ $.ui.invalidate('ui.render');
1561
+ },
1562
+ // JOIN-4: a file in the adapter's own view, through the command it names.
1563
+ onOpenIn: (adapter, path) =>
1564
+ $.command.run({ command: adapter.open.command, args: path }).catch((err) => {
1565
+ notice = `Couldn't open ${path} in ${adapter.name}: ${err?.message ?? err}`;
1566
+ $.ui.invalidate('ui.render');
1567
+ }),
1568
+ onAct: (id) => {
1569
+ const lane = lanesNow.find((l) => l.name === storyOpen?.lane);
1570
+ if (lane) act?.laneAct(id, lane);
1571
+ },
1572
+ },
1573
+ onNeed: (id, line) => act?.[id]?.(line),
1574
+ onLane: (id, lane) => act?.laneAct(id, lane),
1575
+ onSearch: (search) => {
1576
+ paneUi = { ...paneUi, search };
1577
+ $.ui.invalidate('ui.render');
1578
+ },
1579
+ onFocusSearch: () => $.ui.focus({ requestId: PANE_ID, key: 'filter' }).catch(() => {}),
1580
+ },
1581
+ );
1582
+ });
1583
+
1584
+ // VIEW-6: Escape on a story goes back to the lanes; on the lanes, it closes the pane.
1585
+ on('ui.close', async ($, e, next) => {
1586
+ if (closeGoesBack(e, PANE_ID, paneUi)) {
1587
+ act?.closeStory();
1588
+ return;
1589
+ }
1590
+ return next(e);
445
1591
  });
446
1592
 
1593
+ on('ui.render', { component: 'Pane', requestId: SENDBACK_ID }, async ($, e) =>
1594
+ sendBackPane(sendingBack, $.ui.resolve(e), {
1595
+ onInput: (value) => {
1596
+ if (sendingBack) sendingBack = { ...sendingBack, reason: value };
1597
+ $.ui.invalidate('ui.render');
1598
+ },
1599
+ onSubmit: (value) => act.confirmSendBack(value),
1600
+ onCancel: () => act.cancelSendBack(),
1601
+ }),
1602
+ );
1603
+
447
1604
  // BAND-2: absent when nothing waits. Its lines go above whatever else the band holds (another
448
1605
  // plugin's lines, such as Context Graph's, or Claude Code's own), which it keeps.
449
1606
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
450
- const lines = bandLines({ ...model, reviewing, notice, ...usageNow() });
1607
+ const lines = linesNow(await $.clock.now());
451
1608
  if (!lines.length || !act) return next(e);
452
1609
  const ours = band(lines, $.ui.resolve(e), (id, line) => act[id](line));
453
1610
  const below = await next(e);