@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.
@@ -7,6 +7,9 @@ export const NOT_CODE_KIT = "This project doesn't use code-kit.";
7
7
  const LEAD = 'lead';
8
8
 
9
9
  /** A CLI run's stdout as JSON, or null when it printed none. */
10
+ import { BAR, POSE_GLYPH, rasterCells, runs, sprite, spriteSvg, timelineSvg } from './art.mjs';
11
+ import { missionLines, nextText } from './mission.mjs';
12
+
10
13
  export function parseJson(stdout) {
11
14
  try {
12
15
  return JSON.parse(stdout);
@@ -83,12 +86,24 @@ export function projectState(check, status, next = null, atWork = new Set()) {
83
86
  lanes,
84
87
  ready,
85
88
  planGaps: status ? (status.problems ?? []).length : null,
86
- stories: stories.map((s) => ({ id: s.id, lane: s.lane, state: s.state })),
89
+ stories: stories.map((s) => ({
90
+ id: s.id,
91
+ title: s.title,
92
+ lane: s.lane,
93
+ state: s.state,
94
+ branch: s.branch ?? null,
95
+ worktree: s.worktree ?? null,
96
+ requirements: s.requirements ?? [],
97
+ waitingOn: s.waitingOn ?? [],
98
+ milestone: s.milestone ?? null,
99
+ })),
87
100
  };
88
101
  }
89
102
 
90
- const STATE_COLOR = {
103
+ export const STATE_COLOR = {
91
104
  building: 'blue',
105
+ working: 'green',
106
+ orchestrating: 'yellow',
92
107
  'in review': 'yellow',
93
108
  'sent back': 'magenta',
94
109
  blocked: 'red',
@@ -96,46 +111,208 @@ const STATE_COLOR = {
96
111
  };
97
112
 
98
113
  /**
99
- * The Lanes pane's body, drawn with the elements `$.ui.resolve(e)` returned; `usage` is
100
- * usageSummary's, `plan` planOf's (USE-2), either null before there is any.
114
+ * A lane's state as the pane shows it: `queued` while its branch waits in the merge queue (MQ-4), and
115
+ * `working` while its agent (or the lead, mid-turn) is at work on nothing the plan names.
101
116
  */
102
- export function lanesPane(state, { Box, Text }, usage = null, plan = null) {
117
+ export function shownState(lane, queue = []) {
118
+ // LANES-2: the lead mid-turn is orchestrating unless it builds its own story; between turns, a story
119
+ // of its own that waits on another is the story's state, not the lead's.
120
+ if (lane.name === LEAD && lane.active && lane.state !== 'building') return 'orchestrating';
121
+ if (lane.name === LEAD && lane.state === 'blocked') return 'idle';
122
+ if (lane.state === 'idle' && lane.active) return 'working';
123
+ const waiting = queue.some(
124
+ (e) => e.branch === lane.branch && ['waiting', 'merging'].includes(e.state),
125
+ );
126
+ return lane.branch && waiting ? 'queued' : lane.state;
127
+ }
128
+
129
+ /**
130
+ * The Lanes tab's body, drawn with the elements `$.ui.resolve(e)` returned; `usage` is
131
+ * usageSummary's, `plan` planOf's (USE-2), either null before there is any. `lanesOf` narrows and
132
+ * marks the rows (VIEW-1, VIEW-6): { filter, search, selected }, and `live` is each lane's last tool
133
+ * call (VIEW-4): { [lane]: { tool, ago, level } }, level null, 'quiet' or 'stalled'.
134
+ */
135
+ export function lanesPane(
136
+ state,
137
+ els,
138
+ usage = null,
139
+ plan = null,
140
+ loop = null,
141
+ {
142
+ filter = null,
143
+ search = '',
144
+ selected = null,
145
+ live = {},
146
+ art = null,
147
+ combined = null,
148
+ detail = null,
149
+ } = {},
150
+ ) {
151
+ const { Box, Text } = els;
103
152
  const text = (value, style = {}) => Text({ ...style, children: [value] });
104
153
  if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
105
154
  if (state.kind === 'invalid')
106
155
  return text('The config is invalid: run code-kit check.', { color: 'red' });
107
156
  const width = Math.max(...state.lanes.map((l) => l.name.length));
108
- const rows = state.lanes.map((l) =>
109
- Box({
110
- key: `lane-${l.name}`,
111
- flexDirection: 'column',
112
- children: [
113
- Box({
114
- flexDirection: 'row',
115
- columnGap: 2,
116
- children: [
117
- text(l.active ? '●' : ' ', { color: 'green' }),
118
- text(l.name.padEnd(width), { bold: true }),
119
- text(l.state.padEnd(9), STATE_COLOR[l.state] ? { color: STATE_COLOR[l.state] } : {}),
120
- text(l.agent ?? 'the main session', { dimColor: true }),
121
- ...(usage?.lanes[l.name]
122
- ? [text(tokens(usage.lanes[l.name]), { dimColor: true })]
123
- : []),
124
- ],
157
+ // VIEW-3: the lane's character: half-block cells in the terminal, SVG on Desktop, its glyph elsewhere.
158
+ const character = (l) => {
159
+ const pose = art.poses[l.name] ?? 'idle';
160
+ const lead = l.name === LEAD;
161
+ if (art.surface === 'terminal' && els.Raster)
162
+ return els.Raster({
163
+ key: `char-${l.name}`,
164
+ columns: 6,
165
+ rows: 3,
166
+ cells: rasterCells(sprite(pose, art.frame, art.tints[l.name], { lead })),
167
+ });
168
+ if (els.Svg)
169
+ return els.Svg({
170
+ source: spriteSvg(pose, art.tints[l.name], { lead }),
171
+ alt: `${l.name}: ${pose}`,
172
+ width: 24,
173
+ height: 24,
174
+ ...(pose === 'working' ? { isInteractive: true } : {}),
175
+ });
176
+ return text(POSE_GLYPH[pose]);
177
+ };
178
+ // VIEW-2: the lane's story on the shared time axis.
179
+ const bar = (l) => {
180
+ const cells = art?.bars?.[l.name];
181
+ if (!cells) return [];
182
+ if (art.surface !== 'terminal' && els.Svg)
183
+ return [
184
+ els.Svg({
185
+ source: timelineSvg([{ cells }], { width: art.svgWidth ?? 480 }),
186
+ alt: `${l.name}'s timeline`,
125
187
  }),
126
- ...(l.story
127
- ? [
128
- text(
129
- ` ${l.story.id} ${l.story.title}${l.branch ? ` · ${l.branch}` : ''}${
130
- l.state === 'blocked' ? ` · waits on ${l.story.waitingOn.join(', ')}` : ''
131
- }`,
132
- { dimColor: true, wrap: 'truncate-end' },
133
- ),
134
- ]
135
- : []),
136
- ],
137
- }),
188
+ ];
189
+ return [
190
+ Box({
191
+ key: `bar-${l.name}`,
192
+ flexDirection: 'row',
193
+ children: [
194
+ text(' '),
195
+ ...runs(cells).map((run) =>
196
+ text(
197
+ BAR[run.kind].glyph.repeat(run.n),
198
+ BAR[run.kind].color ? { color: BAR[run.kind].color } : {},
199
+ ),
200
+ ),
201
+ ],
202
+ }),
203
+ ];
204
+ };
205
+ const inQueue = new Set(
206
+ (loop?.queue ?? [])
207
+ .filter((e) => ['waiting', 'merging'].includes(e.state))
208
+ .map((e) => e.branch),
209
+ );
210
+ // MQ-4: a branch in the merge queue is shown as queued, not as waiting for review.
211
+ const queued = (l) => Boolean(l.branch && inQueue.has(l.branch));
212
+ const needle = search.trim().toLowerCase();
213
+ const shown = state.lanes.filter(
214
+ (l) =>
215
+ (!filter || shownState(l, loop?.queue) === filter) &&
216
+ (!needle ||
217
+ [l.name, l.agent, l.story?.id, l.story?.title, l.branch]
218
+ .filter(Boolean)
219
+ .some((s) => s.toLowerCase().includes(needle))),
138
220
  );
221
+ const LIVE = { quiet: 'yellow', stalled: 'red' };
222
+ // LANES-2: fixed columns that never wrap: name, state, then the story (and the agent's current tool
223
+ // beneath it), then tokens; another tool's facts only when they aren't zero.
224
+ const STATE_WIDTH = 13;
225
+ const rows = shown.map((l) => {
226
+ const s = shownState(l, loop?.queue);
227
+ const facts = combined?.[l.name];
228
+ const chips = [
229
+ ...(facts?.without
230
+ ? [text(`${facts.without} edited without understanding`, { color: 'red' })]
231
+ : []),
232
+ ...(facts?.owed
233
+ ? [text(`${facts.owed} card${facts.owed === 1 ? '' : 's'} owed`, { color: 'yellow' })]
234
+ : []),
235
+ ...(loop?.laneNotes?.[l.name]
236
+ ? [
237
+ text(loop.laneNotes[l.name], {
238
+ color: loop.laneNotes[l.name] === 'stalled' ? 'red' : 'yellow',
239
+ }),
240
+ ]
241
+ : []),
242
+ ];
243
+ const story = l.story
244
+ ? `${l.story.id} ${l.story.title}${
245
+ l.story.state === 'blocked' && l.story.waitingOn?.length
246
+ ? ` · waits on ${l.story.waitingOn.join(', ')}`
247
+ : l.branch
248
+ ? ` · ${l.branch}`
249
+ : ''
250
+ }`
251
+ : l.name === LEAD
252
+ ? 'the main session'
253
+ : (l.agent ?? '');
254
+ const lines = [
255
+ Box({
256
+ flexDirection: 'row',
257
+ columnGap: 1,
258
+ children: [
259
+ text(l.name === selected ? '›' : l.active ? '●' : ' ', {
260
+ color: l.name === selected ? 'cyan' : 'green',
261
+ }),
262
+ text(l.name.padEnd(width), {
263
+ bold: true,
264
+ ...(l.name === selected ? { color: 'cyan' } : {}),
265
+ }),
266
+ text(
267
+ s.padEnd(STATE_WIDTH),
268
+ s === 'queued'
269
+ ? { color: 'cyan' }
270
+ : STATE_COLOR[s]
271
+ ? { color: STATE_COLOR[s] }
272
+ : { dimColor: true },
273
+ ),
274
+ Box({
275
+ flexGrow: 1,
276
+ flexShrink: 1,
277
+ children: [text(story, { wrap: 'truncate-end', dimColor: !l.story })],
278
+ }),
279
+ ...chips,
280
+ ...(usage?.lanes[l.name]
281
+ ? [text(tokens(usage.lanes[l.name]).padStart(6), { dimColor: true })]
282
+ : []),
283
+ ],
284
+ }),
285
+ // VIEW-4: what its agent is doing now, and since when, beneath the story.
286
+ ...(live[l.name]
287
+ ? [
288
+ Box({
289
+ flexDirection: 'row',
290
+ columnGap: 1,
291
+ children: [
292
+ text(''.padEnd(width + STATE_WIDTH + 3)),
293
+ text(`${live[l.name].tool} · ${live[l.name].ago}`, {
294
+ wrap: 'truncate-end',
295
+ ...(LIVE[live[l.name].level]
296
+ ? { color: LIVE[live[l.name].level] }
297
+ : { dimColor: true }),
298
+ }),
299
+ ],
300
+ }),
301
+ ]
302
+ : []),
303
+ ...bar(l),
304
+ // LANES-3: the selected lane's stories as cards, then what it owns.
305
+ ...(l.name === selected && detail ? laneDetail(detail, els, width) : []),
306
+ ];
307
+ return art
308
+ ? Box({
309
+ key: `lane-${l.name}`,
310
+ flexDirection: 'row',
311
+ columnGap: 1,
312
+ children: [character(l), Box({ flexDirection: 'column', flexGrow: 1, children: lines })],
313
+ })
314
+ : Box({ key: `lane-${l.name}`, flexDirection: 'column', children: lines });
315
+ });
139
316
  const notes = [];
140
317
  if (state.planGaps)
141
318
  notes.push(
@@ -162,16 +339,244 @@ export function lanesPane(state, { Box, Text }, usage = null, plan = null) {
162
339
  flexDirection: 'column',
163
340
  rowGap: 1,
164
341
  children: [
342
+ ...loopCard(loop, els),
165
343
  ...notes,
166
- Box({ flexDirection: 'column', children: rows }),
344
+ Box({
345
+ flexDirection: 'column',
346
+ children: rows.length ? rows : [text('No lane matches the filter.', { dimColor: true })],
347
+ }),
167
348
  Box({ flexDirection: 'column', children: ready }),
168
- ...usageSection(usage, plan, text, Box),
349
+ ...reviewsSection(loop, text, Box),
169
350
  ],
170
351
  });
171
352
  }
172
353
 
354
+ const CARD_STATE = {
355
+ done: { label: 'merged', color: 'gray' },
356
+ review: { label: 'in review', color: 'yellow' },
357
+ 'in progress': { label: 'building', color: 'blue' },
358
+ 'sent back': { label: 'sent back', color: 'magenta' },
359
+ blocked: { label: 'blocked', color: 'red' },
360
+ ready: { label: 'ready', color: 'green' },
361
+ todo: { label: 'to do', color: 'gray' },
362
+ };
363
+
364
+ /**
365
+ * LANES-3: a lane's stories as cards (id and state, title, requirements, and what it waits on or its
366
+ * branch), wrapping across the pane, then its agent and the paths it owns. `detail` is { agent, owns,
367
+ * stories }.
368
+ */
369
+ export function laneDetail(detail, { Box, Text }, indent = 0) {
370
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
371
+ const cards = detail.stories.map((s) => {
372
+ const st = CARD_STATE[s.state] ?? { label: s.state, color: 'gray' };
373
+ return Box({
374
+ key: `card-${s.id}`,
375
+ flexDirection: 'column',
376
+ borderStyle: 'round',
377
+ borderColor: st.color,
378
+ ...(s.state === 'done' ? { borderDimColor: true } : {}),
379
+ paddingX: 1,
380
+ width: 30,
381
+ children: [
382
+ Box({
383
+ flexDirection: 'row',
384
+ justifyContent: 'space-between',
385
+ children: [
386
+ text(s.id, { bold: true, dimColor: s.state === 'done' }),
387
+ text(st.label, { color: st.color }),
388
+ ],
389
+ }),
390
+ text(s.title, { wrap: 'truncate-end', dimColor: s.state === 'done' }),
391
+ ...(s.requirements?.length
392
+ ? [text(s.requirements.join(' '), { dimColor: true, wrap: 'truncate-end' })]
393
+ : []),
394
+ ...(s.state === 'blocked' && s.waitingOn?.length
395
+ ? [text(`waits on ${s.waitingOn.join(', ')}`, { color: 'red', wrap: 'truncate-end' })]
396
+ : s.branch && s.state !== 'done'
397
+ ? [text(s.branch, { dimColor: true, wrap: 'truncate-end' })]
398
+ : []),
399
+ ],
400
+ });
401
+ });
402
+ return [
403
+ Box({
404
+ key: 'lane-cards',
405
+ flexDirection: 'row',
406
+ flexWrap: 'wrap',
407
+ columnGap: 1,
408
+ marginLeft: Math.min(indent + 2, 8),
409
+ children: cards.length
410
+ ? cards
411
+ : [text('No stories in the plan for this lane.', { dimColor: true })],
412
+ }),
413
+ Box({
414
+ key: 'lane-owns',
415
+ flexDirection: 'row',
416
+ flexWrap: 'wrap',
417
+ columnGap: 2,
418
+ marginLeft: Math.min(indent + 2, 8),
419
+ children: [
420
+ ...(detail.agent ? [text('agent', { dimColor: true }), text(detail.agent)] : []),
421
+ text('owns', { dimColor: true }),
422
+ ...(detail.owns.length
423
+ ? detail.owns.map((p) => text(p, { color: 'cyan' }))
424
+ : [text('nothing yet', { dimColor: true })]),
425
+ ],
426
+ }),
427
+ ];
428
+ }
429
+
430
+ /**
431
+ * LANES-1: the loop as a card at the top of the Lanes tab: its mode, then the steps it took, the one
432
+ * running and the one it takes next. None while the loop is off and has taken no step.
433
+ */
434
+ export function loopCard(loop, { Box, Text }) {
435
+ if (!loop || (loop.state === 'off' && !loop.steps?.length)) return [];
436
+ const text = (value, style = {}) => Text({ ...style, children: [value] });
437
+ const mode =
438
+ loop.state === 'paused'
439
+ ? { word: 'paused', color: 'yellow' }
440
+ : loop.state === 'off'
441
+ ? { word: 'off', color: 'gray' }
442
+ : loop.state === 'done' || loop.state === 'stopped'
443
+ ? { word: 'milestone done', color: 'green' }
444
+ : {
445
+ word:
446
+ loop.autonomy === 'propose'
447
+ ? 'asks before each step'
448
+ : 'acts while the lead is idle',
449
+ color: 'green',
450
+ };
451
+ // The step running now has its own chip; the ones before it are what the loop did.
452
+ const steps = [...(loop.steps ?? [])].slice(0, loop.current ? -1 : undefined).slice(-4);
453
+ const chip = (key, value, color, extra = {}) =>
454
+ Box({
455
+ key,
456
+ borderStyle: 'single',
457
+ borderColor: color,
458
+ paddingX: 1,
459
+ children: [text(value, { wrap: 'truncate-end', ...extra })],
460
+ });
461
+ return [
462
+ Box({
463
+ key: 'loop-card',
464
+ flexDirection: 'column',
465
+ borderStyle: 'round',
466
+ borderColor: 'gray',
467
+ paddingX: 1,
468
+ children: [
469
+ Box({
470
+ flexDirection: 'row',
471
+ columnGap: 2,
472
+ children: [
473
+ text('loop', { bold: true, color: 'yellow' }),
474
+ text(mode.word, { color: mode.color }),
475
+ ],
476
+ }),
477
+ Box({
478
+ flexDirection: 'row',
479
+ flexWrap: 'wrap',
480
+ columnGap: 1,
481
+ children: [
482
+ ...steps.map((s, i) =>
483
+ chip(
484
+ `loop-step-${i}`,
485
+ `${ago(loop.now - s.at).replace(' ago', '')} ${s.kind} ${s.target}`,
486
+ 'gray',
487
+ { dimColor: true },
488
+ ),
489
+ ),
490
+ ...(loop.nextText
491
+ ? [chip('loop-next', `next: ${loop.nextText}`, 'cyan', { color: 'cyan' })]
492
+ : []),
493
+ ],
494
+ }),
495
+ ],
496
+ }),
497
+ ];
498
+ }
499
+
500
+ /** How long ago, as a person reads it: now, 40s ago, 12m ago, 2h ago. */
501
+ export function ago(ms) {
502
+ const s = Math.max(0, Math.round(ms / 1000));
503
+ if (s < 5) return 'now';
504
+ if (s < 60) return `${s}s ago`;
505
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
506
+ return `${Math.floor(s / 3600)}h ago`;
507
+ }
508
+
509
+ /** MQ-4: the merge queue in order, then what it merged or sent back lately. */
510
+ export function queueSection(loop, text, Box) {
511
+ const entries = loop?.queue ?? [];
512
+ if (!entries.length) return [];
513
+ const open = entries.filter((e) => ['waiting', 'merging'].includes(e.state));
514
+ const settled = entries
515
+ .filter((e) => !['waiting', 'merging'].includes(e.state))
516
+ .slice(-3)
517
+ .reverse();
518
+ const row = (e, label, style = {}) =>
519
+ Box({
520
+ key: `queue-${e.branch}-${e.state}`,
521
+ flexDirection: 'row',
522
+ columnGap: 2,
523
+ children: [
524
+ text(label.padEnd(3), { dimColor: true }),
525
+ text(e.branch),
526
+ text(e.state, style),
527
+ ...(e.reason ? [text(e.reason, { dimColor: true })] : []),
528
+ ],
529
+ });
530
+ return [
531
+ Box({
532
+ flexDirection: 'column',
533
+ children: [
534
+ text('Merge queue', { bold: true }),
535
+ ...open.map((e, i) => row(e, `${i + 1}.`, { color: 'cyan' })),
536
+ ...settled.map((e) => row(e, '·', { color: e.state === 'merged' ? 'green' : 'magenta' })),
537
+ ],
538
+ }),
539
+ ];
540
+ }
541
+
542
+ /** REVW-1: each branch's background review, running with its clock, or what it found. */
543
+ export function reviewsSection(loop, text, Box) {
544
+ const reviews = loop?.reviews ?? [];
545
+ if (!reviews.length) return [];
546
+ const said = (r) =>
547
+ r.state === 'running'
548
+ ? `running ${ago(loop.now - r.startedAt).replace(' ago', '')}`
549
+ : r.state === 'done'
550
+ ? `done · ${r.summary}`
551
+ : r.state === 'skipped'
552
+ ? `skipped: ${r.why}`
553
+ : `failed: ${r.error}`;
554
+ return [
555
+ Box({
556
+ flexDirection: 'column',
557
+ children: [
558
+ text('Reviews', { bold: true }),
559
+ ...reviews.map((r) =>
560
+ Box({
561
+ key: `review-${r.branch}`,
562
+ flexDirection: 'row',
563
+ columnGap: 2,
564
+ children: [
565
+ text(r.branch),
566
+ text(said(r), {
567
+ color: r.blockers ? 'red' : r.state === 'running' ? 'blue' : undefined,
568
+ dimColor: r.state === 'skipped',
569
+ }),
570
+ ],
571
+ }),
572
+ ),
573
+ ],
574
+ }),
575
+ ];
576
+ }
577
+
173
578
  /** USE-2: the plan's 5-hour use as a bar, each story's tokens, and the background agents' share. */
174
- function usageSection(usage, plan, text, Box) {
579
+ export function usageSection(usage, plan, text, Box) {
175
580
  const lines = [];
176
581
  if (plan?.percent != null)
177
582
  lines.push(
@@ -244,6 +649,22 @@ export function prefilledReason(request) {
244
649
 
245
650
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
246
651
 
652
+ /** What a held call would do, as a person reads it (HOLD-2): "install dayjs", "commit docs/brief.md". */
653
+ export function heldVerb(call) {
654
+ if (call.kind === 'dependency') return `install ${call.names.map(approvalLabel).join(', ')}`;
655
+ if (call.kind === 'kit') return 'change the .claude kit';
656
+ return call.what;
657
+ }
658
+
659
+ /** Minutes and seconds left, as 1:53. */
660
+ export function timeLeft(ms) {
661
+ const s = Math.max(0, Math.ceil(ms / 1000));
662
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
663
+ }
664
+
665
+ const sameAsk = (a, b) =>
666
+ a.names.join(',') === b.names.join(',') && (a.lane ?? null) === (b.lane ?? null);
667
+
247
668
  /**
248
669
  * The band's lines (BAND-2): one per kind of thing waiting, each with its actions, and none when
249
670
  * nothing waits. `reviewing` holds the stories the person has asked the lead to review (ACT-3);
@@ -258,12 +679,71 @@ export function bandLines({
258
679
  notice = null,
259
680
  usage = null,
260
681
  plan = null,
682
+ held = [],
683
+ now = 0,
684
+ loop = null,
685
+ queue = [],
686
+ check = null,
687
+ leadBusy = false,
688
+ live = {},
689
+ nextStep = null,
690
+ combined = null,
261
691
  }) {
262
692
  const lines = [];
693
+ // MISSION-1: the goal, now, next and the agents at work, above what needs the person.
694
+ const mission = missionLines({ state, loop, leadBusy, live, nextStep, combined });
695
+ lines.push(...mission);
263
696
  if (notice)
264
697
  lines.push({ kind: 'notice', text: notice, actions: [{ id: 'dismiss', label: 'Dismiss' }] });
265
698
  if (!state || state.kind !== 'ok') return lines;
266
- const open = requests?.open ?? [];
699
+ // HOLD-2: calls held for the person, oldest first, with the time left on the oldest.
700
+ if (held.length) {
701
+ const [call] = [...held].sort((a, b) => a.since - b.since);
702
+ const left = timeLeft(call.since + call.minutes * 60000 - now);
703
+ lines.push({
704
+ kind: 'held',
705
+ text: `${held.length > 1 ? `${held.length} calls held: ` : ''}${call.who} wants to ${heldVerb(call)} · ${left}`,
706
+ held: call,
707
+ actions: [
708
+ { id: 'approveHeld', label: 'Approve' },
709
+ { id: 'refuseHeld', label: 'Refuse' },
710
+ ],
711
+ });
712
+ }
713
+ // LOOP-3: a lane stalled past its restarts waits for the person.
714
+ for (const f of loop?.flagged ?? [])
715
+ lines.push({
716
+ kind: 'stalled',
717
+ text: `${f.lane} stalled ${f.count} times on ${f.story}`,
718
+ stall: f,
719
+ actions: [
720
+ { id: 'lanes', label: 'Lanes' },
721
+ { id: 'resumeStall', label: 'Resume' },
722
+ ],
723
+ });
724
+ // LOOP-5: under autonomy propose, each step waits for Go.
725
+ if (loop?.proposal?.first)
726
+ // The first time, the person chooses how hands-off the loop is (until then nothing is taken on its own).
727
+ lines.push({
728
+ kind: 'proposal',
729
+ text: `code-kit can keep the build moving between your prompts. Next: ${loop.proposal.label}.`,
730
+ actions: [
731
+ { id: 'keepGoing', label: 'Keep going' },
732
+ { id: 'askEach', label: 'Ask each time' },
733
+ { id: 'loopOff', label: 'Off' },
734
+ ],
735
+ });
736
+ else if (loop?.proposal)
737
+ lines.push({
738
+ kind: 'proposal',
739
+ // LOOP-8: a step the lead was asked for and didn't take waits for the person to ask again.
740
+ text: loop.proposal.repeated
741
+ ? `${loop.proposal.label}: still next after ${loop.proposal.repeated} prompts. Ask the lead again?`
742
+ : `${loop.proposal.label}?`,
743
+ actions: [{ id: 'go', label: 'Go' }],
744
+ });
745
+ // A held call's request is asked on its own line above, not again here.
746
+ const open = (requests?.open ?? []).filter((r) => !held.some((h) => sameAsk(h, r)));
267
747
  if (open.length)
268
748
  lines.push({
269
749
  kind: 'approvals',
@@ -271,7 +751,13 @@ export function bandLines({
271
751
  request: open[0],
272
752
  actions: [{ id: 'approve', label: 'Approve…' }],
273
753
  });
274
- const inReview = state.lanes.filter((l) => l.state === 'in review').map((l) => l.story);
754
+ // DECIDE-1: reviewing a lane's work is the lead's. Only with the loop off, where the person runs the
755
+ // build themselves, does a finished story wait in the band for their Review or Merge; otherwise the
756
+ // loop prompts the lead (or, under propose, offers the step with Go).
757
+ const loopOff = (check?.harness?.autonomy ?? 'autonomous') === 'off';
758
+ const inReview = loopOff
759
+ ? state.lanes.filter((l) => l.state === 'in review').map((l) => l.story)
760
+ : [];
275
761
  if (inReview.length) {
276
762
  const [story] = inReview;
277
763
  const ids = inReview.map((s) => s.id).join(', ');
@@ -310,6 +796,38 @@ export function bandLines({
310
796
  text: `background agents paused: plan at ${plan.percent}%`,
311
797
  actions: [],
312
798
  });
799
+ // MQ-2: where the lead doesn't merge, the head of the queue waits for the person's Merge.
800
+ const next = queue.find((e) => ['waiting', 'merging'].includes(e.state));
801
+ const leadMerges =
802
+ check?.delegatesMerge && (check?.harness?.autonomy ?? 'autonomous') === 'autonomous';
803
+ if (next && !leadMerges)
804
+ lines.push({
805
+ kind: 'queue',
806
+ // The first merge brings code-kit's rules onto the base, so it's the person's even under autonomy.
807
+ text: check?.firstMerge
808
+ ? `${next.branch} is next to merge: the first merge is yours, then the lead merges`
809
+ : `${next.branch} is next to merge`,
810
+ branch: next.branch,
811
+ actions: [{ id: 'mergeQueue', label: 'Merge' }],
812
+ });
813
+ // LOOP-7: the milestone is done; LOOP-6: the loop's own state, with Pause or Resume.
814
+ if (loop?.state === 'done')
815
+ lines.push({
816
+ kind: 'done',
817
+ text: `Milestone done: ${plural(loop.doneCount, 'story', 'stories')} merged`,
818
+ actions: [{ id: 'dismissDone', label: 'Dismiss' }],
819
+ });
820
+ // With the mission rows drawn, the loop's state and its Pause or Resume are on the `now` row.
821
+ else if (mission.length) {
822
+ // nothing more
823
+ } else if (loop?.state === 'paused')
824
+ lines.push({ kind: 'loop', text: 'loop paused', actions: [{ id: 'resume', label: 'Resume' }] });
825
+ else if (loop?.state === 'running' && loop.started)
826
+ lines.push({
827
+ kind: 'loop',
828
+ text: `loop on${loop.last ? ` · last: ${loop.last}` : ''}`,
829
+ actions: [{ id: 'pause', label: 'Pause' }],
830
+ });
313
831
  // BAND-4: a digit for every action, in the order they're drawn.
314
832
  let digit = 0;
315
833
  for (const line of lines)
@@ -321,36 +839,112 @@ export function bandLines({
321
839
  export function band(lines, { Box, Text, Button }, onAction) {
322
840
  const COLOR = {
323
841
  notice: 'red',
842
+ held: 'yellow',
843
+ queue: 'blue',
844
+ stalled: 'red',
845
+ proposal: 'cyan',
846
+ loop: 'green',
847
+ done: 'green',
324
848
  approvals: 'yellow',
325
849
  review: 'blue',
326
850
  finish: 'red',
327
851
  usage: 'yellow',
328
852
  paused: 'yellow',
853
+ facts: 'yellow',
329
854
  };
330
- return Box({
331
- flexDirection: 'column',
332
- children: lines.map((line) =>
333
- Box({
334
- key: `band-${line.kind}`,
335
- flexDirection: 'row',
336
- columnGap: 2,
855
+ const LIVE = { quiet: 'yellow', stalled: 'red' };
856
+ const LABEL = 8;
857
+ const buttons = (line) =>
858
+ line.actions.map((a) =>
859
+ Button({
860
+ key: `band-${a.id}`,
861
+ label: a.hotkey ? `${a.hotkey} ${a.label}` : a.label,
862
+ hotkey: a.hotkey,
863
+ onPress: () => onAction(a.id, line),
864
+ }),
865
+ );
866
+ // MISSION-2: what waits on the person is labelled "you", once, above its first line.
867
+ let saidYou = false;
868
+ const rows = lines.map((line) => {
869
+ if (line.kind === 'agents')
870
+ return Box({
871
+ key: 'band-agents',
872
+ flexDirection: 'column',
337
873
  children: [
338
- Text({ color: COLOR[line.kind], children: ['code-kit'] }),
339
- Text({ children: [line.text] }),
340
- ...line.actions.map((a) =>
341
- Button({
342
- key: `band-${a.id}`,
343
- label: a.hotkey ? `${a.hotkey} ${a.label}` : a.label,
344
- hotkey: a.hotkey,
345
- onPress: () => onAction(a.id, line),
874
+ ...line.agents.map((a) =>
875
+ Box({
876
+ key: `band-agent-${a.lane}`,
877
+ flexDirection: 'row',
878
+ columnGap: 1,
879
+ children: [
880
+ Text({
881
+ dimColor: true,
882
+ children: [(a === line.agents[0] ? line.label : '').padEnd(LABEL)],
883
+ }),
884
+ Text({ color: a.level ? LIVE[a.level] : 'green', children: ['●'] }),
885
+ Text({ bold: true, children: [a.lane.padEnd(8)] }),
886
+ Text({ wrap: 'truncate-end', children: [a.tool] }),
887
+ Text({ dimColor: true, children: [a.ago] }),
888
+ ],
346
889
  }),
347
890
  ),
891
+ ...(line.more
892
+ ? [Text({ dimColor: true, children: [`${''.padEnd(LABEL)} and ${line.more} more`] })]
893
+ : []),
348
894
  ],
349
- }),
350
- ),
895
+ });
896
+ // DECIDE-3: what needs the person is labelled "you", once; notes (usage, the plan's pause) are "note".
897
+ const note = NOTE_KINDS.has(line.kind);
898
+ const label = line.mission ? line.label : note ? 'note' : saidYou ? '' : 'you';
899
+ if (!line.mission && !note) saidYou = true;
900
+ return Box({
901
+ key: `band-${line.kind}`,
902
+ flexDirection: 'row',
903
+ columnGap: 1,
904
+ children: [
905
+ Text({
906
+ ...(line.mission ? { dimColor: true } : { color: COLOR[line.kind] ?? 'yellow' }),
907
+ children: [label.padEnd(LABEL)],
908
+ }),
909
+ Text({
910
+ wrap: 'truncate-end',
911
+ ...(line.kind === 'facts' ? { color: COLOR.facts } : {}),
912
+ children: [line.text],
913
+ }),
914
+ // DECIDE-2: which story it's for, so it isn't a fragment.
915
+ ...(line.context
916
+ ? [Text({ dimColor: true, wrap: 'truncate-end', children: [line.context] })]
917
+ : []),
918
+ ...buttons(line),
919
+ ],
920
+ });
351
921
  });
922
+ // DECIDE-3: one key opens the decisions as cards, each with its lineage and what each choice does.
923
+ const first = lines.find((l) => !l.mission && !NOTE_KINDS.has(l.kind));
924
+ if (first)
925
+ rows.push(
926
+ Box({
927
+ key: 'band-why',
928
+ flexDirection: 'row',
929
+ columnGap: 1,
930
+ children: [
931
+ Text({ children: [''.padEnd(LABEL)] }),
932
+ Button({
933
+ key: 'band-decide',
934
+ label: 'd Why, and what each choice does',
935
+ hotkey: 'd',
936
+ plain: true,
937
+ onPress: () => onAction('decide', first),
938
+ }),
939
+ ],
940
+ }),
941
+ );
942
+ return Box({ flexDirection: 'column', children: rows });
352
943
  }
353
944
 
945
+ /** Band lines that inform rather than ask: they never need the person's answer. */
946
+ const NOTE_KINDS = new Set(['usage', 'paused', 'finish', 'done']);
947
+
354
948
  /** The Approve… confirmation (ACT-1): what it grants, to whom, for how long, and the reason. */
355
949
  export function approvePane(
356
950
  approving,
@@ -588,6 +1182,14 @@ export function settingsView(rows, pending, { Box, Text, Select, Input, Button }
588
1182
  ...(r.value !== r.default
589
1183
  ? [Text({ dimColor: true, children: [`default ${shown(r.default)}`] })]
590
1184
  : []),
1185
+ ...(r.key === 'autonomy' && r.set === false
1186
+ ? [
1187
+ Text({
1188
+ color: 'yellow',
1189
+ children: ['not chosen yet: the loop asks before its first step'],
1190
+ }),
1191
+ ]
1192
+ : []),
591
1193
  ],
592
1194
  }),
593
1195
  Text({ dimColor: true, children: [` ${r.about}`] }),