@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +62 -0
- package/README.md +31 -10
- package/bin/code-kit.mjs +447 -39
- package/hooks/guard-bash.mjs +54 -16
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +87 -19
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +43 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +2 -2
- package/hooks/lib/journal.mjs +224 -0
- package/hooks/lib/loop-state.mjs +26 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +37 -1
- package/hooks/lib/queue.mjs +75 -0
- package/hooks/lib/reviews.mjs +18 -0
- package/hooks/lib/rules.mjs +8 -4
- package/hooks/lib/shell.mjs +1 -1
- package/hooks/lib/storymap.mjs +155 -0
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/codemap.mjs +328 -0
- package/hooks/mod/decisions.mjs +146 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/mission.mjs +116 -0
- package/hooks/mod/panes.mjs +1378 -0
- package/hooks/mod/register.mjs +1172 -15
- package/hooks/mod/register.test.ts +1868 -15
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/trail.mjs +152 -0
- package/hooks/mod/view.mjs +658 -56
- package/package.json +1 -1
- package/skills/init/SKILL.md +6 -5
- package/skills/review/SKILL.md +8 -1
package/hooks/mod/view.mjs
CHANGED
|
@@ -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) => ({
|
|
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
|
-
*
|
|
100
|
-
*
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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({
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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}`] }),
|