@warren-dean/code-kit 0.5.0 → 0.7.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,8 @@ 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
+
10
12
  export function parseJson(stdout) {
11
13
  try {
12
14
  return JSON.parse(stdout);
@@ -61,7 +63,8 @@ function laneRow(name, agent, stories, atWork) {
61
63
  * subagent types at work:
62
64
  * { kind: 'none' } no .claude/code-kit.json
63
65
  * { kind: 'invalid', problems } a config that doesn't validate
64
- * { kind: 'ok', lanes, ready, planGaps } a row per lane, what's ready, the plan's gaps (or null)
66
+ * { kind: 'ok', lanes, ready, planGaps, stories } a row per lane, what's ready, the plan's gaps (or
67
+ * null), and each story's id, lane and state
65
68
  */
66
69
  export function projectState(check, status, next = null, atWork = new Set()) {
67
70
  if (!check || check.exists === false) return { kind: 'none' };
@@ -77,52 +80,235 @@ export function projectState(check, status, next = null, atWork = new Set()) {
77
80
  const story = stories.find((s) => s.id === id);
78
81
  return { id, title: story?.title ?? '', lane: story?.lane ?? null };
79
82
  });
80
- return { kind: 'ok', lanes, ready, planGaps: status ? (status.problems ?? []).length : null };
83
+ return {
84
+ kind: 'ok',
85
+ lanes,
86
+ ready,
87
+ planGaps: status ? (status.problems ?? []).length : null,
88
+ stories: stories.map((s) => ({
89
+ id: s.id,
90
+ title: s.title,
91
+ lane: s.lane,
92
+ state: s.state,
93
+ branch: s.branch ?? null,
94
+ worktree: s.worktree ?? null,
95
+ requirements: s.requirements ?? [],
96
+ waitingOn: s.waitingOn ?? [],
97
+ })),
98
+ };
81
99
  }
82
100
 
83
- const STATE_COLOR = {
101
+ export const STATE_COLOR = {
84
102
  building: 'blue',
103
+ working: 'green',
85
104
  'in review': 'yellow',
86
105
  'sent back': 'magenta',
87
106
  blocked: 'red',
88
107
  idle: undefined,
89
108
  };
90
109
 
91
- /** The Lanes pane's body, drawn with the elements `$.ui.resolve(e)` returned. */
92
- export function lanesPane(state, { Box, Text }) {
110
+ /**
111
+ * A lane's state as the pane shows it: `queued` while its branch waits in the merge queue (MQ-4), and
112
+ * `working` while its agent (or the lead, mid-turn) is at work on nothing the plan names.
113
+ */
114
+ export function shownState(lane, queue = []) {
115
+ if (lane.state === 'idle' && lane.active) return 'working';
116
+ const waiting = queue.some(
117
+ (e) => e.branch === lane.branch && ['waiting', 'merging'].includes(e.state),
118
+ );
119
+ return lane.branch && waiting ? 'queued' : lane.state;
120
+ }
121
+
122
+ /**
123
+ * The Lanes tab's body, drawn with the elements `$.ui.resolve(e)` returned; `usage` is
124
+ * usageSummary's, `plan` planOf's (USE-2), either null before there is any. `lanesOf` narrows and
125
+ * marks the rows (VIEW-1, VIEW-6): { filter, search, selected }, and `live` is each lane's last tool
126
+ * call (VIEW-4): { [lane]: { tool, ago, level } }, level null, 'quiet' or 'stalled'.
127
+ */
128
+ export function lanesPane(
129
+ state,
130
+ els,
131
+ usage = null,
132
+ plan = null,
133
+ loop = null,
134
+ {
135
+ filter = null,
136
+ search = '',
137
+ selected = null,
138
+ live = {},
139
+ art = null,
140
+ combined = null,
141
+ detail = null,
142
+ } = {},
143
+ ) {
144
+ const { Box, Text } = els;
93
145
  const text = (value, style = {}) => Text({ ...style, children: [value] });
94
146
  if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
95
147
  if (state.kind === 'invalid')
96
148
  return text('The config is invalid: run code-kit check.', { color: 'red' });
97
149
  const width = Math.max(...state.lanes.map((l) => l.name.length));
98
- const rows = state.lanes.map((l) =>
99
- Box({
100
- key: `lane-${l.name}`,
101
- flexDirection: 'column',
102
- children: [
103
- Box({
104
- flexDirection: 'row',
105
- columnGap: 2,
106
- children: [
107
- text(l.active ? '●' : ' ', { color: 'green' }),
108
- text(l.name.padEnd(width), { bold: true }),
109
- text(l.state.padEnd(9), STATE_COLOR[l.state] ? { color: STATE_COLOR[l.state] } : {}),
110
- text(l.agent ?? 'the main session', { dimColor: true }),
111
- ],
150
+ // VIEW-3: the lane's character: half-block cells in the terminal, SVG on Desktop, its glyph elsewhere.
151
+ const character = (l) => {
152
+ const pose = art.poses[l.name] ?? 'idle';
153
+ const lead = l.name === LEAD;
154
+ if (art.surface === 'terminal' && els.Raster)
155
+ return els.Raster({
156
+ key: `char-${l.name}`,
157
+ columns: 6,
158
+ rows: 3,
159
+ cells: rasterCells(sprite(pose, art.frame, art.tints[l.name], { lead })),
160
+ });
161
+ if (els.Svg)
162
+ return els.Svg({
163
+ source: spriteSvg(pose, art.tints[l.name], { lead }),
164
+ alt: `${l.name}: ${pose}`,
165
+ width: 24,
166
+ height: 24,
167
+ ...(pose === 'working' ? { isInteractive: true } : {}),
168
+ });
169
+ return text(POSE_GLYPH[pose]);
170
+ };
171
+ // VIEW-2: the lane's story on the shared time axis.
172
+ const bar = (l) => {
173
+ const cells = art?.bars?.[l.name];
174
+ if (!cells) return [];
175
+ if (art.surface !== 'terminal' && els.Svg)
176
+ return [
177
+ els.Svg({
178
+ source: timelineSvg([{ cells }], { width: art.svgWidth ?? 480 }),
179
+ alt: `${l.name}'s timeline`,
112
180
  }),
113
- ...(l.story
114
- ? [
115
- text(
116
- ` ${l.story.id} ${l.story.title}${l.branch ? ` · ${l.branch}` : ''}${
117
- l.state === 'blocked' ? ` · waits on ${l.story.waitingOn.join(', ')}` : ''
118
- }`,
119
- { dimColor: true, wrap: 'truncate-end' },
120
- ),
121
- ]
122
- : []),
123
- ],
124
- }),
181
+ ];
182
+ return [
183
+ Box({
184
+ key: `bar-${l.name}`,
185
+ flexDirection: 'row',
186
+ children: [
187
+ text(' '),
188
+ ...runs(cells).map((run) =>
189
+ text(
190
+ BAR[run.kind].glyph.repeat(run.n),
191
+ BAR[run.kind].color ? { color: BAR[run.kind].color } : {},
192
+ ),
193
+ ),
194
+ ],
195
+ }),
196
+ ];
197
+ };
198
+ const inQueue = new Set(
199
+ (loop?.queue ?? [])
200
+ .filter((e) => ['waiting', 'merging'].includes(e.state))
201
+ .map((e) => e.branch),
202
+ );
203
+ // MQ-4: a branch in the merge queue is shown as queued, not as waiting for review.
204
+ const queued = (l) => Boolean(l.branch && inQueue.has(l.branch));
205
+ const needle = search.trim().toLowerCase();
206
+ const shown = state.lanes.filter(
207
+ (l) =>
208
+ (!filter || shownState(l, loop?.queue) === filter) &&
209
+ (!needle ||
210
+ [l.name, l.agent, l.story?.id, l.story?.title, l.branch]
211
+ .filter(Boolean)
212
+ .some((s) => s.toLowerCase().includes(needle))),
125
213
  );
214
+ const LIVE = { quiet: 'yellow', stalled: 'red' };
215
+ const rows = shown.map((l) => {
216
+ const lines = [
217
+ Box({
218
+ flexDirection: 'row',
219
+ columnGap: 2,
220
+ children: [
221
+ text(l.name === selected ? '›' : l.active ? '●' : ' ', {
222
+ color: l.name === selected ? 'cyan' : 'green',
223
+ }),
224
+ text(l.name.padEnd(width), {
225
+ bold: true,
226
+ ...(l.name === selected ? { color: 'cyan' } : {}),
227
+ }),
228
+ text(
229
+ shownState(l, loop?.queue).padEnd(9),
230
+ shownState(l, loop?.queue) === 'queued'
231
+ ? { color: 'cyan' }
232
+ : STATE_COLOR[shownState(l, loop?.queue)]
233
+ ? { color: STATE_COLOR[shownState(l, loop?.queue)] }
234
+ : {},
235
+ ),
236
+ text(l.agent ?? 'the main session', { dimColor: true }),
237
+ ...(live[l.name]
238
+ ? [
239
+ text(`${live[l.name].tool} · ${live[l.name].ago}`, {
240
+ wrap: 'truncate-end',
241
+ ...(LIVE[live[l.name].level]
242
+ ? { color: LIVE[live[l.name].level] }
243
+ : { dimColor: true }),
244
+ }),
245
+ ]
246
+ : []),
247
+ ...(loop?.laneNotes?.[l.name]
248
+ ? [
249
+ text(loop.laneNotes[l.name], {
250
+ color: loop.laneNotes[l.name] === 'stalled' ? 'red' : 'yellow',
251
+ }),
252
+ ]
253
+ : []),
254
+ ...(usage?.lanes[l.name] ? [text(tokens(usage.lanes[l.name]), { dimColor: true })] : []),
255
+ // JOIN-2: with an adapter that knows, the lane's edits without understanding and cards owed.
256
+ ...(combined?.[l.name]
257
+ ? [
258
+ text(
259
+ `${combined[l.name].without} edit${combined[l.name].without === 1 ? '' : 's'} without understanding`,
260
+ combined[l.name].without ? { color: 'red' } : { dimColor: true },
261
+ ),
262
+ text(
263
+ `${combined[l.name].owed} card${combined[l.name].owed === 1 ? '' : 's'} owed`,
264
+ combined[l.name].owed ? { color: 'yellow' } : { dimColor: true },
265
+ ),
266
+ ]
267
+ : []),
268
+ ],
269
+ }),
270
+ ...(l.story
271
+ ? [
272
+ text(
273
+ ` ${l.story.id} ${l.story.title}${l.branch ? ` · ${l.branch}` : ''}${
274
+ l.state === 'blocked' ? ` · waits on ${l.story.waitingOn.join(', ')}` : ''
275
+ }`,
276
+ { dimColor: true, wrap: 'truncate-end' },
277
+ ),
278
+ ]
279
+ : []),
280
+ ...bar(l),
281
+ // VIEW-6: the selected lane's detail: the paths it owns and its stories, each with its state.
282
+ ...(l.name === selected && detail
283
+ ? [
284
+ text(` owns ${detail.owns.length ? detail.owns.join(', ') : 'nothing yet'}`, {
285
+ dimColor: true,
286
+ wrap: 'truncate-end',
287
+ }),
288
+ ...(detail.stories.length
289
+ ? detail.stories.map((s) =>
290
+ text(
291
+ ` ${s.id} ${s.title} · ${s.state}${s.state === 'blocked' && s.waitingOn.length ? ` on ${s.waitingOn.join(', ')}` : ''}`,
292
+ {
293
+ dimColor: s.state === 'done',
294
+ ...(s.state === 'blocked' ? { color: 'red' } : {}),
295
+ wrap: 'truncate-end',
296
+ },
297
+ ),
298
+ )
299
+ : [text(' no stories in the plan', { dimColor: true })]),
300
+ ]
301
+ : []),
302
+ ];
303
+ return art
304
+ ? Box({
305
+ key: `lane-${l.name}`,
306
+ flexDirection: 'row',
307
+ columnGap: 1,
308
+ children: [character(l), Box({ flexDirection: 'column', children: lines })],
309
+ })
310
+ : Box({ key: `lane-${l.name}`, flexDirection: 'column', children: lines });
311
+ });
126
312
  const notes = [];
127
313
  if (state.planGaps)
128
314
  notes.push(
@@ -150,12 +336,169 @@ export function lanesPane(state, { Box, Text }) {
150
336
  rowGap: 1,
151
337
  children: [
152
338
  ...notes,
153
- Box({ flexDirection: 'column', children: rows }),
339
+ Box({
340
+ flexDirection: 'column',
341
+ children: rows.length ? rows : [text('No lane matches the filter.', { dimColor: true })],
342
+ }),
154
343
  Box({ flexDirection: 'column', children: ready }),
344
+ ...reviewsSection(loop, text, Box),
345
+ ...loopSection(loop, text, Box),
155
346
  ],
156
347
  });
157
348
  }
158
349
 
350
+ /** How long ago, as a person reads it: now, 40s ago, 12m ago, 2h ago. */
351
+ export function ago(ms) {
352
+ const s = Math.max(0, Math.round(ms / 1000));
353
+ if (s < 5) return 'now';
354
+ if (s < 60) return `${s}s ago`;
355
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
356
+ return `${Math.floor(s / 3600)}h ago`;
357
+ }
358
+
359
+ /** MQ-4: the merge queue in order, then what it merged or sent back lately. */
360
+ export function queueSection(loop, text, Box) {
361
+ const entries = loop?.queue ?? [];
362
+ if (!entries.length) return [];
363
+ const open = entries.filter((e) => ['waiting', 'merging'].includes(e.state));
364
+ const settled = entries
365
+ .filter((e) => !['waiting', 'merging'].includes(e.state))
366
+ .slice(-3)
367
+ .reverse();
368
+ const row = (e, label, style = {}) =>
369
+ Box({
370
+ key: `queue-${e.branch}-${e.state}`,
371
+ flexDirection: 'row',
372
+ columnGap: 2,
373
+ children: [
374
+ text(label.padEnd(3), { dimColor: true }),
375
+ text(e.branch),
376
+ text(e.state, style),
377
+ ...(e.reason ? [text(e.reason, { dimColor: true })] : []),
378
+ ],
379
+ });
380
+ return [
381
+ Box({
382
+ flexDirection: 'column',
383
+ children: [
384
+ text('Merge queue', { bold: true }),
385
+ ...open.map((e, i) => row(e, `${i + 1}.`, { color: 'cyan' })),
386
+ ...settled.map((e) => row(e, '·', { color: e.state === 'merged' ? 'green' : 'magenta' })),
387
+ ],
388
+ }),
389
+ ];
390
+ }
391
+
392
+ /** REVW-1: each branch's background review, running with its clock, or what it found. */
393
+ export function reviewsSection(loop, text, Box) {
394
+ const reviews = loop?.reviews ?? [];
395
+ if (!reviews.length) return [];
396
+ const said = (r) =>
397
+ r.state === 'running'
398
+ ? `running ${ago(loop.now - r.startedAt).replace(' ago', '')}`
399
+ : r.state === 'done'
400
+ ? `done · ${r.summary}`
401
+ : r.state === 'skipped'
402
+ ? `skipped: ${r.why}`
403
+ : `failed: ${r.error}`;
404
+ return [
405
+ Box({
406
+ flexDirection: 'column',
407
+ children: [
408
+ text('Reviews', { bold: true }),
409
+ ...reviews.map((r) =>
410
+ Box({
411
+ key: `review-${r.branch}`,
412
+ flexDirection: 'row',
413
+ columnGap: 2,
414
+ children: [
415
+ text(r.branch),
416
+ text(said(r), {
417
+ color: r.blockers ? 'red' : r.state === 'running' ? 'blue' : undefined,
418
+ dimColor: r.state === 'skipped',
419
+ }),
420
+ ],
421
+ }),
422
+ ),
423
+ ],
424
+ }),
425
+ ];
426
+ }
427
+
428
+ /** The loop's record (spec 05): its state and its latest steps, newest first. */
429
+ export function loopSection(loop, text, Box) {
430
+ if (!loop || (!loop.steps?.length && loop.state !== 'paused')) return [];
431
+ const steps = [...(loop.steps ?? [])].reverse().slice(0, 8);
432
+ return [
433
+ Box({
434
+ flexDirection: 'column',
435
+ children: [
436
+ text(`Loop · ${loop.state === 'paused' ? 'paused' : loop.autonomy}`, { bold: true }),
437
+ ...steps.map((s, i) =>
438
+ Box({
439
+ key: `step-${i}`,
440
+ flexDirection: 'row',
441
+ columnGap: 2,
442
+ children: [
443
+ text(ago(loop.now - s.at).padEnd(8), { dimColor: true }),
444
+ text(s.kind.padEnd(8)),
445
+ text(s.target, { wrap: 'truncate-end' }),
446
+ ],
447
+ }),
448
+ ),
449
+ ],
450
+ }),
451
+ ];
452
+ }
453
+
454
+ /** USE-2: the plan's 5-hour use as a bar, each story's tokens, and the background agents' share. */
455
+ export function usageSection(usage, plan, text, Box) {
456
+ const lines = [];
457
+ if (plan?.percent != null)
458
+ lines.push(
459
+ Box({
460
+ key: 'usage-plan',
461
+ flexDirection: 'row',
462
+ columnGap: 2,
463
+ children: [
464
+ text('Plan'),
465
+ text(planBar(plan.percent), { color: plan.paused ? 'red' : 'green' }),
466
+ text(`${plan.percent}% of the 5-hour window`, { dimColor: true }),
467
+ ...(plan.paused ? [text('background agents paused', { color: 'red' })] : []),
468
+ ],
469
+ }),
470
+ );
471
+ if (usage?.total) {
472
+ const flagged = new Map(usage.outliers.map((o) => [o.id, o.ratio]));
473
+ for (const [id, n] of Object.entries(usage.stories).sort((a, b) => b[1] - a[1]))
474
+ lines.push(
475
+ Box({
476
+ key: `usage-${id}`,
477
+ flexDirection: 'row',
478
+ columnGap: 2,
479
+ children: [
480
+ text(id),
481
+ text(tokens(n), { dimColor: true }),
482
+ ...(flagged.has(id)
483
+ ? [text(`${flagged.get(id)}× the usual`, { color: 'yellow' })]
484
+ : []),
485
+ ],
486
+ }),
487
+ );
488
+ if (usage.background)
489
+ lines.push(
490
+ text(
491
+ `Background agents ${tokens(usage.background)} (${Math.round((usage.background / usage.total) * 100)}%)`,
492
+ { dimColor: true },
493
+ ),
494
+ );
495
+ lines.push(text(`This session ${tokens(usage.total)} tokens`, { dimColor: true }));
496
+ }
497
+ return lines.length
498
+ ? [Box({ flexDirection: 'column', children: [text('Usage', { bold: true }), ...lines] })]
499
+ : [];
500
+ }
501
+
159
502
  // --- the band, and the person's acts -----------------------------------------------------------
160
503
 
161
504
  export const APPROVE_ID = 'code-kit-approve';
@@ -182,17 +525,94 @@ export function prefilledReason(request) {
182
525
 
183
526
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
184
527
 
528
+ /** What a held call would do, as a person reads it (HOLD-2): "install dayjs", "commit docs/brief.md". */
529
+ export function heldVerb(call) {
530
+ if (call.kind === 'dependency') return `install ${call.names.map(approvalLabel).join(', ')}`;
531
+ if (call.kind === 'kit') return 'change the .claude kit';
532
+ return call.what;
533
+ }
534
+
535
+ /** Minutes and seconds left, as 1:53. */
536
+ export function timeLeft(ms) {
537
+ const s = Math.max(0, Math.ceil(ms / 1000));
538
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
539
+ }
540
+
541
+ const sameAsk = (a, b) =>
542
+ a.names.join(',') === b.names.join(',') && (a.lane ?? null) === (b.lane ?? null);
543
+
185
544
  /**
186
545
  * The band's lines (BAND-2): one per kind of thing waiting, each with its actions, and none when
187
546
  * nothing waits. `reviewing` holds the stories the person has asked the lead to review (ACT-3);
188
- * `notice` is why the person's last act failed, until they dismiss it.
547
+ * `notice` is why the person's last act failed, until they dismiss it. `usage` (usageSummary's) flags
548
+ * outliers (USE-3), and `plan` (planOf's) says when background agents are paused (USE-4).
189
549
  */
190
- export function bandLines({ state, requests, stops, reviewing = new Set(), notice = null }) {
550
+ export function bandLines({
551
+ state,
552
+ requests,
553
+ stops,
554
+ reviewing = new Set(),
555
+ notice = null,
556
+ usage = null,
557
+ plan = null,
558
+ held = [],
559
+ now = 0,
560
+ loop = null,
561
+ queue = [],
562
+ check = null,
563
+ }) {
191
564
  const lines = [];
192
565
  if (notice)
193
566
  lines.push({ kind: 'notice', text: notice, actions: [{ id: 'dismiss', label: 'Dismiss' }] });
194
567
  if (!state || state.kind !== 'ok') return lines;
195
- const open = requests?.open ?? [];
568
+ // HOLD-2: calls held for the person, oldest first, with the time left on the oldest.
569
+ if (held.length) {
570
+ const [call] = [...held].sort((a, b) => a.since - b.since);
571
+ const left = timeLeft(call.since + call.minutes * 60000 - now);
572
+ lines.push({
573
+ kind: 'held',
574
+ text: `${held.length > 1 ? `${held.length} calls held: ` : ''}${call.who} wants to ${heldVerb(call)} · ${left}`,
575
+ held: call,
576
+ actions: [
577
+ { id: 'approveHeld', label: 'Approve' },
578
+ { id: 'refuseHeld', label: 'Refuse' },
579
+ ],
580
+ });
581
+ }
582
+ // LOOP-3: a lane stalled past its restarts waits for the person.
583
+ for (const f of loop?.flagged ?? [])
584
+ lines.push({
585
+ kind: 'stalled',
586
+ text: `${f.lane} stalled ${f.count} times on ${f.story}`,
587
+ stall: f,
588
+ actions: [
589
+ { id: 'lanes', label: 'Lanes' },
590
+ { id: 'resumeStall', label: 'Resume' },
591
+ ],
592
+ });
593
+ // LOOP-5: under autonomy propose, each step waits for Go.
594
+ if (loop?.proposal?.first)
595
+ // The first time, the person chooses how hands-off the loop is (until then nothing is taken on its own).
596
+ lines.push({
597
+ kind: 'proposal',
598
+ text: `code-kit can keep the build moving between your prompts. Next: ${loop.proposal.label}.`,
599
+ actions: [
600
+ { id: 'keepGoing', label: 'Keep going' },
601
+ { id: 'askEach', label: 'Ask each time' },
602
+ { id: 'loopOff', label: 'Off' },
603
+ ],
604
+ });
605
+ else if (loop?.proposal)
606
+ lines.push({
607
+ kind: 'proposal',
608
+ // LOOP-8: a step the lead was asked for and didn't take waits for the person to ask again.
609
+ text: loop.proposal.repeated
610
+ ? `${loop.proposal.label}: still next after ${loop.proposal.repeated} prompts. Ask the lead again?`
611
+ : `${loop.proposal.label}?`,
612
+ actions: [{ id: 'go', label: 'Go' }],
613
+ });
614
+ // A held call's request is asked on its own line above, not again here.
615
+ const open = (requests?.open ?? []).filter((r) => !held.some((h) => sameAsk(h, r)));
196
616
  if (open.length)
197
617
  lines.push({
198
618
  kind: 'approvals',
@@ -224,6 +644,50 @@ export function bandLines({ state, requests, stops, reviewing = new Set(), notic
224
644
  text: `Finish check failing: ${stops[0].title}${stops.length > 1 ? ` (and ${stops.length - 1} more)` : ''}`,
225
645
  actions: [{ id: 'lanes', label: 'Lanes' }],
226
646
  });
647
+ if (usage?.outliers.length) {
648
+ const [top] = usage.outliers;
649
+ const more = usage.outliers.length - 1;
650
+ lines.push({
651
+ kind: 'usage',
652
+ text: `${top.id} has used ${top.ratio}× the usual${more ? ` (and ${more} more)` : ''}`,
653
+ actions: [{ id: 'lanes', label: 'Lanes' }],
654
+ });
655
+ }
656
+ if (plan?.paused)
657
+ lines.push({
658
+ kind: 'paused',
659
+ text: `background agents paused: plan at ${plan.percent}%`,
660
+ actions: [],
661
+ });
662
+ // MQ-2: where the lead doesn't merge, the head of the queue waits for the person's Merge.
663
+ const next = queue.find((e) => ['waiting', 'merging'].includes(e.state));
664
+ const leadMerges =
665
+ check?.delegatesMerge && (check?.harness?.autonomy ?? 'autonomous') === 'autonomous';
666
+ if (next && !leadMerges)
667
+ lines.push({
668
+ kind: 'queue',
669
+ // The first merge brings code-kit's rules onto the base, so it's the person's even under autonomy.
670
+ text: check?.firstMerge
671
+ ? `${next.branch} is next to merge: the first merge is yours, then the lead merges`
672
+ : `${next.branch} is next to merge`,
673
+ branch: next.branch,
674
+ actions: [{ id: 'mergeQueue', label: 'Merge' }],
675
+ });
676
+ // LOOP-7: the milestone is done; LOOP-6: the loop's own state, with Pause or Resume.
677
+ if (loop?.state === 'done')
678
+ lines.push({
679
+ kind: 'done',
680
+ text: `Milestone done: ${plural(loop.doneCount, 'story', 'stories')} merged`,
681
+ actions: [{ id: 'dismissDone', label: 'Dismiss' }],
682
+ });
683
+ else if (loop?.state === 'paused')
684
+ lines.push({ kind: 'loop', text: 'loop paused', actions: [{ id: 'resume', label: 'Resume' }] });
685
+ else if (loop?.state === 'running' && loop.started)
686
+ lines.push({
687
+ kind: 'loop',
688
+ text: `loop on${loop.last ? ` · last: ${loop.last}` : ''}`,
689
+ actions: [{ id: 'pause', label: 'Pause' }],
690
+ });
227
691
  // BAND-4: a digit for every action, in the order they're drawn.
228
692
  let digit = 0;
229
693
  for (const line of lines)
@@ -233,7 +697,20 @@ export function bandLines({ state, requests, stops, reviewing = new Set(), notic
233
697
 
234
698
  /** The band, drawn with the elements `$.ui.resolve(e)` returned; `onAction(id, line)` acts. */
235
699
  export function band(lines, { Box, Text, Button }, onAction) {
236
- const COLOR = { notice: 'red', approvals: 'yellow', review: 'blue', finish: 'red' };
700
+ const COLOR = {
701
+ notice: 'red',
702
+ held: 'yellow',
703
+ queue: 'blue',
704
+ stalled: 'red',
705
+ proposal: 'cyan',
706
+ loop: 'green',
707
+ done: 'green',
708
+ approvals: 'yellow',
709
+ review: 'blue',
710
+ finish: 'red',
711
+ usage: 'yellow',
712
+ paused: 'yellow',
713
+ };
237
714
  return Box({
238
715
  flexDirection: 'column',
239
716
  children: lines.map((line) =>
@@ -351,6 +828,25 @@ export function refusalCard(raw) {
351
828
  request: request(`write ${m[1]}`),
352
829
  raw: text,
353
830
  };
831
+ m = first.match(/^(\S+) is (.+); committing it needs a person's approval in force\./);
832
+ if (m)
833
+ return {
834
+ kind: 'approval',
835
+ title: `Commit refused: ${m[1]}`,
836
+ why: `It's ${m[2]}.`,
837
+ todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
838
+ request: request(`commit ${m[1]}`),
839
+ raw: text,
840
+ };
841
+ if (/^the \.claude kit is changed only by the lead or with a person's approval\./.test(first))
842
+ return {
843
+ kind: 'kit',
844
+ title: 'Kit edit refused',
845
+ why: "The .claude kit is changed only by the lead or with a person's approval.",
846
+ todo: 'A person approves it, here or with the `!` command, for 60 minutes.',
847
+ request: request('change the .claude kit'),
848
+ raw: text,
849
+ };
354
850
  m = first.match(/^a new dependency \((.+?)\) needs a person's approval\./);
355
851
  if (m)
356
852
  return {
@@ -425,3 +921,234 @@ export function approvalsText(requests) {
425
921
  ];
426
922
  return lines.join('\n');
427
923
  }
924
+
925
+ // --- the harness settings (SET-2) --------------------------------------------------------------
926
+
927
+ export const SETTINGS_ID = 'code-kit-settings';
928
+ const CHOICES = {
929
+ autonomy: ['autonomous', 'propose', 'off'],
930
+ 'agents.reviewer.model': ['', 'haiku', 'sonnet', 'opus'],
931
+ };
932
+ const shown = (v) =>
933
+ v === null || v === '' ? "the session's" : v === true ? 'on' : v === false ? 'off' : String(v);
934
+
935
+ /**
936
+ * The Settings view: each setting from `code-kit settings --json` with a control, and, once one is
937
+ * changed, a confirmation asking the person's reason before anything is written.
938
+ */
939
+ export function settingsView(rows, pending, { Box, Text, Select, Input, Button }, handlers) {
940
+ const control = (r) => {
941
+ const listed = CHOICES[r.key] ?? (typeof r.default === 'boolean' ? [true, false] : null);
942
+ // A value the list doesn't name (a model given by its id) is still shown, as the one chosen.
943
+ const choices =
944
+ listed && !listed.map(String).includes(String(r.value ?? '')) ? [...listed, r.value] : listed;
945
+ if (choices)
946
+ return Select({
947
+ key: `set-${r.key}`,
948
+ label: '',
949
+ value: String(r.value ?? ''),
950
+ options: choices.map((c) => ({ value: String(c), label: shown(c) })),
951
+ onSelect: (v) => handlers.onChoose(r.key, v === '' ? 'null' : v),
952
+ });
953
+ return Input({
954
+ key: `set-${r.key}`,
955
+ label: '',
956
+ value: String(r.value),
957
+ submitLabel: 'Set',
958
+ onSubmit: (v) => handlers.onChoose(r.key, v.trim()),
959
+ });
960
+ };
961
+ const rowsView = rows.map((r) =>
962
+ Box({
963
+ key: `setting-${r.key}`,
964
+ flexDirection: 'column',
965
+ children: [
966
+ Box({
967
+ flexDirection: 'row',
968
+ columnGap: 2,
969
+ children: [
970
+ Box({ width: 28, children: [Text({ bold: true, children: [r.key] })] }),
971
+ control(r),
972
+ ...(r.value !== r.default
973
+ ? [Text({ dimColor: true, children: [`default ${shown(r.default)}`] })]
974
+ : []),
975
+ ...(r.key === 'autonomy' && r.set === false
976
+ ? [
977
+ Text({
978
+ color: 'yellow',
979
+ children: ['not chosen yet: the loop asks before its first step'],
980
+ }),
981
+ ]
982
+ : []),
983
+ ],
984
+ }),
985
+ Text({ dimColor: true, children: [` ${r.about}`] }),
986
+ ],
987
+ }),
988
+ );
989
+ const confirm = pending
990
+ ? [
991
+ Box({
992
+ key: 'settings-confirm',
993
+ flexDirection: 'column',
994
+ borderStyle: 'round',
995
+ paddingX: 1,
996
+ children: [
997
+ Text({
998
+ bold: true,
999
+ children: [
1000
+ `Set ${pending.key} to ${shown(pending.value === 'null' ? null : pending.value)}?`,
1001
+ ],
1002
+ }),
1003
+ Text({
1004
+ dimColor: true,
1005
+ children: [
1006
+ 'It goes into .claude/code-kit.json, and the approval log keeps your reason.',
1007
+ ],
1008
+ }),
1009
+ Input({
1010
+ key: 'settings-reason',
1011
+ label: 'Reason',
1012
+ value: pending.reason,
1013
+ submitLabel: 'Change it',
1014
+ autoFocus: true,
1015
+ onInput: handlers.onReason,
1016
+ onSubmit: handlers.onConfirm,
1017
+ }),
1018
+ ...(pending.error ? [Text({ color: 'red', children: [pending.error] })] : []),
1019
+ Box({
1020
+ flexDirection: 'row',
1021
+ columnGap: 2,
1022
+ children: [
1023
+ Button({
1024
+ key: 'settings-ok',
1025
+ label: 'Change it',
1026
+ onPress: () => handlers.onConfirm(pending.reason),
1027
+ }),
1028
+ Button({ key: 'settings-cancel', label: 'Cancel', onPress: handlers.onCancel }),
1029
+ ],
1030
+ }),
1031
+ ],
1032
+ }),
1033
+ ]
1034
+ : [];
1035
+ return Box({
1036
+ flexDirection: 'column',
1037
+ rowGap: 1,
1038
+ children: [
1039
+ Text({ bold: true, color: 'cyan', children: ['HARNESS SETTINGS'] }),
1040
+ ...confirm,
1041
+ Box({ flexDirection: 'column', children: rowsView }),
1042
+ Text({
1043
+ dimColor: true,
1044
+ children: ['Lanes, paths, layers and protected paths change through /code-kit:init.'],
1045
+ }),
1046
+ ],
1047
+ });
1048
+ }
1049
+
1050
+ // --- usage per lane (USE-1 to USE-4) -----------------------------------------------------------
1051
+
1052
+ const STORY_ID = /\bST-\d+\b/;
1053
+
1054
+ /** A model request's tokens: input (cache writes included), output and cache reads. */
1055
+ export function tokensOf(usage) {
1056
+ return {
1057
+ input: (usage?.input_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0),
1058
+ output: usage?.output_tokens ?? 0,
1059
+ cache: usage?.cache_read_input_tokens ?? 0,
1060
+ };
1061
+ }
1062
+
1063
+ /**
1064
+ * Whom a model request is for (USE-1): the lead when no agent made it; a lane's agent by its type,
1065
+ * on the story its brief names or else the story its lane is on; any other agent is background.
1066
+ * `agent` is the `$.agent.list()` entry, or null when the list no longer has it.
1067
+ */
1068
+ export function attribution(agentId, agent, check, state) {
1069
+ const laneStory = (name) => state?.lanes?.find((l) => l.name === name)?.story?.id ?? null;
1070
+ if (!agentId) return { key: 'lead', lane: LEAD, story: laneStory(LEAD), background: false };
1071
+ const lane = Object.entries(check?.lanes ?? {}).find(([, l]) => l.agent === agent?.type)?.[0];
1072
+ const named = agent?.description?.match(STORY_ID)?.[0] ?? null;
1073
+ if (!lane) return { key: agentId, lane: null, story: named, background: true };
1074
+ return { key: agentId, lane, story: named ?? laneStory(lane), background: false };
1075
+ }
1076
+
1077
+ /** The ledger with one request added: entries by agent and story, their tokens summed. */
1078
+ export function withUsage(ledger, who, usage) {
1079
+ const id = `${who.key}|${who.story ?? ''}`;
1080
+ const t = tokensOf(usage);
1081
+ const was = ledger[id] ?? { ...who, input: 0, output: 0, cache: 0 };
1082
+ return {
1083
+ ...ledger,
1084
+ [id]: {
1085
+ ...was,
1086
+ input: was.input + t.input,
1087
+ output: was.output + t.output,
1088
+ cache: was.cache + t.cache,
1089
+ },
1090
+ };
1091
+ }
1092
+
1093
+ const total = (e) => e.input + e.output + e.cache;
1094
+ const median = (xs) => {
1095
+ const s = [...xs].sort((a, b) => a - b);
1096
+ const m = Math.floor(s.length / 2);
1097
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
1098
+ };
1099
+
1100
+ /** The stories finished: done, or their lane's branch waiting for review. */
1101
+ export function finishedStories(state) {
1102
+ if (state?.kind !== 'ok') return new Set();
1103
+ return new Set([
1104
+ ...(state.stories ?? []).filter((s) => s.state === 'done').map((s) => s.id),
1105
+ ...state.lanes.filter((l) => l.state === 'in review').map((l) => l.story.id),
1106
+ ]);
1107
+ }
1108
+
1109
+ /**
1110
+ * The session's usage rolled up (USE-1, USE-2): tokens per lane, per story and for the background
1111
+ * agents, and the outliers (USE-3), stories past three times the median of the finished ones once
1112
+ * at least three have finished.
1113
+ */
1114
+ export function usageSummary(ledger, finished = new Set()) {
1115
+ const lanes = {};
1116
+ const stories = {};
1117
+ let background = 0;
1118
+ let all = 0;
1119
+ for (const e of Object.values(ledger ?? {})) {
1120
+ const n = total(e);
1121
+ all += n;
1122
+ if (e.background) background += n;
1123
+ else lanes[e.lane] = (lanes[e.lane] ?? 0) + n;
1124
+ if (e.story) stories[e.story] = (stories[e.story] ?? 0) + n;
1125
+ }
1126
+ const done = Object.entries(stories).filter(([id]) => finished.has(id));
1127
+ const usual = done.length >= 3 ? median(done.map(([, n]) => n)) : null;
1128
+ const outliers = usual
1129
+ ? Object.entries(stories)
1130
+ .filter(([, n]) => n > 3 * usual)
1131
+ .map(([id, n]) => ({ id, ratio: Math.round((n / usual) * 10) / 10 }))
1132
+ .sort((a, b) => b.ratio - a.ratio)
1133
+ : [];
1134
+ return { lanes, stories, background, total: all, outliers };
1135
+ }
1136
+
1137
+ /** The plan's 5-hour use from the session's limits (null off a subscription), and the pause (USE-4). */
1138
+ export function planOf(rateLimits, pauseAtPercent = 80) {
1139
+ const percent = (rateLimits ?? []).find((r) => r.kind === 'five_hour')?.percentUsed ?? null;
1140
+ return { percent, paused: percent !== null && percent > pauseAtPercent };
1141
+ }
1142
+
1143
+ /** Tokens as a person reads them: 950, 12k, 1.4M. */
1144
+ export function tokens(n) {
1145
+ if (n < 1000) return String(n);
1146
+ if (n < 1e6) return `${Math.round(n / 1000)}k`;
1147
+ return `${(n / 1e6).toFixed(1)}M`;
1148
+ }
1149
+
1150
+ /** The plan's use as a bar of ten cells. */
1151
+ export function planBar(percent) {
1152
+ const full = Math.max(0, Math.min(10, Math.round(percent / 10)));
1153
+ return `${'█'.repeat(full)}${'░'.repeat(10 - full)}`;
1154
+ }