@warren-dean/code-kit 0.6.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +37 -0
- package/README.md +19 -9
- package/bin/code-kit.mjs +353 -39
- package/hooks/guard-bash.mjs +30 -14
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +75 -6
- 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/next.mjs +34 -3
- package/hooks/lib/plan.mjs +29 -0
- 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/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/panes.mjs +943 -0
- package/hooks/mod/register.mjs +978 -15
- package/hooks/mod/register.test.ts +1449 -14
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/view.mjs +430 -38
- package/package.json +1 -1
- package/skills/init/SKILL.md +5 -4
- package/skills/review/SKILL.md +8 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The reviewer agent (spec 07), the pure parts: its definition, the lead's prompts that start it and
|
|
2
|
+
// that hand its findings on, and reading its report. The hooks keep it read-only: any subagent that
|
|
3
|
+
// isn't a lane's may only read (hooks/lib/rules.mjs).
|
|
4
|
+
|
|
5
|
+
export const REVIEWER = 'reviewer';
|
|
6
|
+
export const SEVERITIES = ['blocker', 'concern', 'nit'];
|
|
7
|
+
/** The lead reviews without the reviewer once it has had this long (spec 07's quality target). */
|
|
8
|
+
export const REVIEW_WAIT_MS = 15 * 60000;
|
|
9
|
+
|
|
10
|
+
/** The reviewer's definition, for `$.agent.register`: read, search and run commands; never write. */
|
|
11
|
+
export function reviewerSpec({ cli, checklist, model }) {
|
|
12
|
+
return {
|
|
13
|
+
name: REVIEWER,
|
|
14
|
+
description:
|
|
15
|
+
"code-kit's background reviewer: reviews one finished branch, read-only, and reports findings graded blocker, concern or nit. Started by the lead when code-kit's loop asks for it.",
|
|
16
|
+
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
|
17
|
+
...(model ? { model } : {}),
|
|
18
|
+
prompt: [
|
|
19
|
+
"You are code-kit's reviewer. You review one branch, read-only: you never edit or write files, commit, approve, merge or record a send-back (code-kit's hooks refuse those for you). The lead decides; you report.",
|
|
20
|
+
'',
|
|
21
|
+
'For the branch you are given:',
|
|
22
|
+
`1. Run \`node "${cli}" verify --branch <branch>\` from the branch's worktree if it has one (\`git worktree list\`), otherwise in a throwaway worktree (\`git worktree add .claude/worktrees/reviewer-<n> <branch>\`, removed when you're done). A failing verify is always a blocker.`,
|
|
23
|
+
`2. Read ${checklist} and apply its review checklist to the branch's changes against its base, and to the story's requirements. Skip its steps that act (recording a send-back, merging): those are the lead's.`,
|
|
24
|
+
"3. Run the project's test commands that cover the changed files.",
|
|
25
|
+
'',
|
|
26
|
+
'Grade each finding: blocker (it must not merge as it is: a failing check, a requirement not met, a rule broken), concern (worth fixing; the lead decides), nit (small). Say where (path:line), what is wrong and what fixed looks like, and the requirement or rule it cites.',
|
|
27
|
+
'',
|
|
28
|
+
'End your report with one fenced json block, exactly this shape:',
|
|
29
|
+
'```json',
|
|
30
|
+
'{"verify": {"passed": true, "problems": []}, "findings": [{"severity": "blocker", "where": "src/a.ts:12", "text": "…", "rule": "REQ-1"}]}',
|
|
31
|
+
'```',
|
|
32
|
+
].join('\n'),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The lead's prompt to start the reviewer for a branch at its head (REVW-1). */
|
|
37
|
+
export function startReviewerPrompt({ agent, story, branch, head }) {
|
|
38
|
+
const reqs = story.requirements?.length ? ` (requirements ${story.requirements.join(', ')})` : '';
|
|
39
|
+
return `Start code-kit's reviewer in the background for ${branch}: use the Agent tool with subagent_type "${agent}", run_in_background true, description "Review ${branch} (${story.id})", and the prompt "Review ${branch} at ${head.slice(0, 12)} for ${story.id} ${story.title ?? ''}${reqs}." Then carry on; its findings come back to you and code-kit asks for your review then.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The reviewer's report, read (REVW-3): { findings, verify } from its json block, every failed
|
|
44
|
+
* verify problem a blocker; or { error } when the report has no block that reads.
|
|
45
|
+
*/
|
|
46
|
+
export function readReport(answer) {
|
|
47
|
+
const blocks = [...String(answer ?? '').matchAll(/```json\s*\n([\s\S]*?)```/g)];
|
|
48
|
+
if (!blocks.length) return { error: "the reviewer's report had no findings block" };
|
|
49
|
+
let data;
|
|
50
|
+
try {
|
|
51
|
+
data = JSON.parse(blocks.at(-1)[1]);
|
|
52
|
+
} catch {
|
|
53
|
+
return { error: "the reviewer's findings block isn't valid JSON" };
|
|
54
|
+
}
|
|
55
|
+
const findings = (Array.isArray(data.findings) ? data.findings : [])
|
|
56
|
+
.filter((f) => f && SEVERITIES.includes(f.severity) && typeof f.text === 'string')
|
|
57
|
+
.map((f) => ({
|
|
58
|
+
severity: f.severity,
|
|
59
|
+
text: f.text,
|
|
60
|
+
...(f.where ? { where: String(f.where) } : {}),
|
|
61
|
+
...(f.rule ? { rule: String(f.rule) } : {}),
|
|
62
|
+
}));
|
|
63
|
+
const verify = data.verify ?? null;
|
|
64
|
+
return { findings: withVerify(findings, verify), verify };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Findings with a blocker for each problem of a failed verify, unless one already names it. */
|
|
68
|
+
export function withVerify(findings, verify) {
|
|
69
|
+
if (!verify || verify.passed !== false) return findings;
|
|
70
|
+
const problems = verify.problems?.length ? verify.problems : ['code-kit verify fails'];
|
|
71
|
+
const extra = problems
|
|
72
|
+
.filter((p) => !findings.some((f) => f.severity === 'blocker' && f.text.includes(p)))
|
|
73
|
+
.map((p) => ({ severity: 'blocker', text: `verify: ${p}`, rule: 'code-kit verify' }));
|
|
74
|
+
return [...extra, ...findings];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** "1 blocker, 2 nits": the findings counted by severity. */
|
|
78
|
+
export function findingsCount(findings) {
|
|
79
|
+
const n = (s) => findings.filter((f) => f.severity === s).length;
|
|
80
|
+
const parts = SEVERITIES.flatMap((s) => (n(s) ? [`${n(s)} ${s}${n(s) === 1 ? '' : 's'}`] : []));
|
|
81
|
+
return parts.length ? parts.join(', ') : 'no findings';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The lead's review prompt, with the reviewer's findings (REVW-4); a blocker means send back. */
|
|
85
|
+
export function leadReviewPrompt(storyId, review) {
|
|
86
|
+
const head = `Review ${storyId}: run /code-kit:review ${storyId}.`;
|
|
87
|
+
if (!review || review.state === 'skipped')
|
|
88
|
+
return `${head}${review?.why ? ` (No background review: ${review.why}.)` : ''}`;
|
|
89
|
+
if (review.state === 'failed')
|
|
90
|
+
return `${head} (The background reviewer didn't report: ${review.error}. Review it yourself.)`;
|
|
91
|
+
const lines = review.findings.map(
|
|
92
|
+
(f) =>
|
|
93
|
+
`- ${f.severity}${f.where ? ` ${f.where}` : ''}: ${f.text}${f.rule ? ` (${f.rule})` : ''}`,
|
|
94
|
+
);
|
|
95
|
+
return [
|
|
96
|
+
`${head} code-kit's reviewer found ${lines.length ? findingsCount(review.findings) : 'nothing to fix'} on ${review.branch} at ${review.head.slice(0, 12)}${lines.length ? ':' : '.'}`,
|
|
97
|
+
...lines,
|
|
98
|
+
...(review.findings.some((f) => f.severity === 'blocker')
|
|
99
|
+
? ['A blocker means you send the branch back. Concerns and nits are yours to weigh.']
|
|
100
|
+
: []),
|
|
101
|
+
].join('\n');
|
|
102
|
+
}
|
package/hooks/mod/view.mjs
CHANGED
|
@@ -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);
|
|
@@ -83,12 +85,22 @@ export function projectState(check, status, next = null, atWork = new Set()) {
|
|
|
83
85
|
lanes,
|
|
84
86
|
ready,
|
|
85
87
|
planGaps: status ? (status.problems ?? []).length : null,
|
|
86
|
-
stories: stories.map((s) => ({
|
|
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
|
+
})),
|
|
87
98
|
};
|
|
88
99
|
}
|
|
89
100
|
|
|
90
|
-
const STATE_COLOR = {
|
|
101
|
+
export const STATE_COLOR = {
|
|
91
102
|
building: 'blue',
|
|
103
|
+
working: 'green',
|
|
92
104
|
'in review': 'yellow',
|
|
93
105
|
'sent back': 'magenta',
|
|
94
106
|
blocked: 'red',
|
|
@@ -96,46 +108,207 @@ const STATE_COLOR = {
|
|
|
96
108
|
};
|
|
97
109
|
|
|
98
110
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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'.
|
|
101
127
|
*/
|
|
102
|
-
export function lanesPane(
|
|
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;
|
|
103
145
|
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
104
146
|
if (!state || state.kind === 'none') return text(NOT_CODE_KIT, { dimColor: true });
|
|
105
147
|
if (state.kind === 'invalid')
|
|
106
148
|
return text('The config is invalid: run code-kit check.', { color: 'red' });
|
|
107
149
|
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
|
-
|
|
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`,
|
|
125
180
|
}),
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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),
|
|
138
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))),
|
|
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
|
+
});
|
|
139
312
|
const notes = [];
|
|
140
313
|
if (state.planGaps)
|
|
141
314
|
notes.push(
|
|
@@ -163,15 +336,123 @@ export function lanesPane(state, { Box, Text }, usage = null, plan = null) {
|
|
|
163
336
|
rowGap: 1,
|
|
164
337
|
children: [
|
|
165
338
|
...notes,
|
|
166
|
-
Box({
|
|
339
|
+
Box({
|
|
340
|
+
flexDirection: 'column',
|
|
341
|
+
children: rows.length ? rows : [text('No lane matches the filter.', { dimColor: true })],
|
|
342
|
+
}),
|
|
167
343
|
Box({ flexDirection: 'column', children: ready }),
|
|
168
|
-
...
|
|
344
|
+
...reviewsSection(loop, text, Box),
|
|
345
|
+
...loopSection(loop, text, Box),
|
|
169
346
|
],
|
|
170
347
|
});
|
|
171
348
|
}
|
|
172
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
|
+
|
|
173
454
|
/** 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) {
|
|
455
|
+
export function usageSection(usage, plan, text, Box) {
|
|
175
456
|
const lines = [];
|
|
176
457
|
if (plan?.percent != null)
|
|
177
458
|
lines.push(
|
|
@@ -244,6 +525,22 @@ export function prefilledReason(request) {
|
|
|
244
525
|
|
|
245
526
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
246
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
|
+
|
|
247
544
|
/**
|
|
248
545
|
* The band's lines (BAND-2): one per kind of thing waiting, each with its actions, and none when
|
|
249
546
|
* nothing waits. `reviewing` holds the stories the person has asked the lead to review (ACT-3);
|
|
@@ -258,12 +555,64 @@ export function bandLines({
|
|
|
258
555
|
notice = null,
|
|
259
556
|
usage = null,
|
|
260
557
|
plan = null,
|
|
558
|
+
held = [],
|
|
559
|
+
now = 0,
|
|
560
|
+
loop = null,
|
|
561
|
+
queue = [],
|
|
562
|
+
check = null,
|
|
261
563
|
}) {
|
|
262
564
|
const lines = [];
|
|
263
565
|
if (notice)
|
|
264
566
|
lines.push({ kind: 'notice', text: notice, actions: [{ id: 'dismiss', label: 'Dismiss' }] });
|
|
265
567
|
if (!state || state.kind !== 'ok') return lines;
|
|
266
|
-
|
|
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)));
|
|
267
616
|
if (open.length)
|
|
268
617
|
lines.push({
|
|
269
618
|
kind: 'approvals',
|
|
@@ -310,6 +659,35 @@ export function bandLines({
|
|
|
310
659
|
text: `background agents paused: plan at ${plan.percent}%`,
|
|
311
660
|
actions: [],
|
|
312
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
|
+
});
|
|
313
691
|
// BAND-4: a digit for every action, in the order they're drawn.
|
|
314
692
|
let digit = 0;
|
|
315
693
|
for (const line of lines)
|
|
@@ -321,6 +699,12 @@ export function bandLines({
|
|
|
321
699
|
export function band(lines, { Box, Text, Button }, onAction) {
|
|
322
700
|
const COLOR = {
|
|
323
701
|
notice: 'red',
|
|
702
|
+
held: 'yellow',
|
|
703
|
+
queue: 'blue',
|
|
704
|
+
stalled: 'red',
|
|
705
|
+
proposal: 'cyan',
|
|
706
|
+
loop: 'green',
|
|
707
|
+
done: 'green',
|
|
324
708
|
approvals: 'yellow',
|
|
325
709
|
review: 'blue',
|
|
326
710
|
finish: 'red',
|
|
@@ -588,6 +972,14 @@ export function settingsView(rows, pending, { Box, Text, Select, Input, Button }
|
|
|
588
972
|
...(r.value !== r.default
|
|
589
973
|
? [Text({ dimColor: true, children: [`default ${shown(r.default)}`] })]
|
|
590
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
|
+
: []),
|
|
591
983
|
],
|
|
592
984
|
}),
|
|
593
985
|
Text({ dimColor: true, children: [` ${r.about}`] }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warren-dean/code-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Warren Langeveldt",
|