@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.
@@ -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
+ }
@@ -0,0 +1,152 @@
1
+ // The trail (docs/specs/14-mission-and-maps.md, TRAIL-1), drawn: each requirement of a spec flowing to
2
+ // the stories that deliver it, the code they changed and the tests that name it, with a gap where any
3
+ // link is missing. Box-drawing text in the terminal, an SVG elsewhere. Its data is `code-kit trail`.
4
+
5
+ /** A story's state as the trail colours it: done, moving (in progress or review), or waiting. */
6
+ export const storyTone = (state) =>
7
+ state === 'done'
8
+ ? 'done'
9
+ : ['in progress', 'review', 'sent back'].includes(state)
10
+ ? 'moving'
11
+ : 'waiting';
12
+
13
+ const GLYPH = { done: '✓', moving: '●', waiting: '■' };
14
+
15
+ const fit = (s, n) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s.padEnd(n));
16
+ const short = (p) => p.split('/').pop();
17
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
18
+
19
+ /**
20
+ * TRAIL-1 in the terminal: one row per requirement, as runs [{ text, style }]. Consecutive
21
+ * requirements delivered by the same stories meet at one story node (┬ … ┘). Styles: req, selected,
22
+ * dim, done, moving, waiting, gap (red), test (green).
23
+ */
24
+ export function trailRows(rows, { selected = null, width = 100 } = {}) {
25
+ const titleW = Math.max(14, Math.min(34, width - 70));
26
+ const key = (r) => r.stories.map((s) => s.id).join(' ');
27
+ return rows.map((r, i) => {
28
+ const runs = [];
29
+ const add = (text, style) => runs.push({ text, style });
30
+ add(r.id.padEnd(9), r.id === selected ? 'selected' : 'req');
31
+ add(fit(r.title, titleW), r.id === selected ? 'selected' : 'dim');
32
+ const same = i > 0 && key(rows[i - 1]) === key(r) && r.stories.length > 0;
33
+ const next = i < rows.length - 1 && key(rows[i + 1]) === key(r) && r.stories.length > 0;
34
+ if (!r.stories.length) {
35
+ add(' ╌╌╌ ', 'gap');
36
+ add(fit('no story', 16), 'gap');
37
+ } else if (same) {
38
+ add(next ? ' ───┤' : ' ───┘', 'dim');
39
+ add(''.padEnd(16), 'dim');
40
+ } else {
41
+ add(next ? ' ───┬ ' : ' ──── ', 'dim');
42
+ const tone = r.stories.every((s) => s.state === 'done')
43
+ ? 'done'
44
+ : r.stories.some((s) => storyTone(s.state) === 'moving')
45
+ ? 'moving'
46
+ : 'waiting';
47
+ add(fit(`${GLYPH[tone]} ${r.stories.map((s) => s.id).join(' ')}`, 15), tone);
48
+ }
49
+ if (r.files.length) {
50
+ add(' ─── ', 'dim');
51
+ add(fit(plural(r.files.length, 'file', 'files'), 9), 'dim');
52
+ } else {
53
+ add(' ╌╌╌ ', 'gap');
54
+ add(fit('no code', 9), 'gap');
55
+ }
56
+ if (r.tests.length) {
57
+ add(' ─── ', 'dim');
58
+ add(`✓ ${short(r.tests[0])}${r.tests.length > 1 ? ` +${r.tests.length - 1}` : ''}`, 'test');
59
+ } else {
60
+ add(' ╌╌╌ ', 'gap');
61
+ add('no test names it', 'gap');
62
+ }
63
+ return runs;
64
+ });
65
+ }
66
+
67
+ const esc = (s) =>
68
+ String(s).replace(
69
+ /[&<>"]/g,
70
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c],
71
+ );
72
+
73
+ const COLOR = {
74
+ done: '#78c98a',
75
+ moving: '#8fb8ff',
76
+ waiting: '#e06a6a',
77
+ dim: '#6e8286',
78
+ fg: '#cfd9da',
79
+ line: '#3b4c50',
80
+ gap: '#e06a6a',
81
+ test: '#78c98a',
82
+ };
83
+
84
+ /**
85
+ * TRAIL-1 on the remote surfaces: the flow as an SVG: requirements on the left, curves meeting at each
86
+ * story, then the files and the test that names it; a gap drawn dashed in red.
87
+ */
88
+ export function trailSvg(rows, { selected = null } = {}) {
89
+ const rowH = 30;
90
+ const W = 820;
91
+ const H = rows.length * rowH + 44;
92
+ const X = { req: 12, story: 300, files: 430, test: 640 };
93
+ const parts = [];
94
+ for (const [label, x] of [
95
+ ['requirement', X.req],
96
+ ['story', X.story],
97
+ ['code', X.files],
98
+ ['test that names it', X.test],
99
+ ])
100
+ parts.push(`<text x="${x}" y="16" fill="${COLOR.dim}" font-size="11">${esc(label)}</text>`);
101
+ const storyY = {};
102
+ rows.forEach((r, i) => {
103
+ const y = 38 + i * rowH;
104
+ const key = r.stories.map((s) => s.id).join(' ');
105
+ if (key && storyY[key] === undefined) storyY[key] = y;
106
+ const sy = key ? storyY[key] : y;
107
+ const sel = r.id === selected;
108
+ parts.push(
109
+ `<text x="${X.req}" y="${y + 4}" fill="${sel ? '#8fb8ff' : COLOR.fg}" font-size="12"${sel ? ' font-weight="700"' : ''}>${esc(r.id)}</text>`,
110
+ );
111
+ parts.push(
112
+ `<text x="${X.req + 70}" y="${y + 4}" fill="${COLOR.dim}" font-size="11">${esc(r.title.length > 26 ? `${r.title.slice(0, 25)}…` : r.title)}</text>`,
113
+ );
114
+ if (key) {
115
+ parts.push(
116
+ `<path d="M${X.story - 50},${y} C${X.story - 25},${y} ${X.story - 25},${sy} ${X.story - 6},${sy}" fill="none" stroke="${COLOR.line}"/>`,
117
+ );
118
+ if (sy === y) {
119
+ const tone = r.stories.every((s) => s.state === 'done')
120
+ ? 'done'
121
+ : r.stories.some((s) => storyTone(s.state) === 'moving')
122
+ ? 'moving'
123
+ : 'waiting';
124
+ parts.push(`<circle cx="${X.story + 3}" cy="${y}" r="4" fill="${COLOR[tone]}"/>`);
125
+ parts.push(
126
+ `<text x="${X.story + 13}" y="${y + 4}" fill="${COLOR.fg}" font-size="12">${esc(key)}</text>`,
127
+ );
128
+ }
129
+ parts.push(
130
+ `<path d="M${X.story + 80},${sy} C${X.files - 30},${sy} ${X.files - 30},${y} ${X.files - 6},${y}" fill="none" stroke="${r.files.length ? COLOR.line : COLOR.gap}"${r.files.length ? '' : ' stroke-dasharray="3 3"'}/>`,
131
+ );
132
+ } else {
133
+ parts.push(
134
+ `<line x1="${X.story - 50}" y1="${y}" x2="${X.story - 6}" y2="${y}" stroke="${COLOR.gap}" stroke-dasharray="3 3"/>`,
135
+ );
136
+ parts.push(
137
+ `<text x="${X.story}" y="${y + 4}" fill="${COLOR.gap}" font-size="11">no story</text>`,
138
+ );
139
+ }
140
+ const files = r.files.map(short).join(', ');
141
+ parts.push(
142
+ `<text x="${X.files}" y="${y + 4}" fill="${r.files.length ? COLOR.dim : COLOR.gap}" font-size="11">${esc(r.files.length ? (files.length > 30 ? `${files.slice(0, 29)}…` : files) : 'no code yet')}</text>`,
143
+ );
144
+ parts.push(
145
+ `<line x1="${X.test - 18}" y1="${y}" x2="${X.test - 6}" y2="${y}" stroke="${r.tests.length ? COLOR.test : COLOR.gap}"${r.tests.length ? '' : ' stroke-dasharray="3 3"'}/>`,
146
+ );
147
+ parts.push(
148
+ `<text x="${X.test}" y="${y + 4}" fill="${r.tests.length ? COLOR.test : COLOR.gap}" font-size="11">${esc(r.tests.length ? `✓ ${short(r.tests[0])}${r.tests.length > 1 ? ` +${r.tests.length - 1}` : ''}` : 'no test names it yet')}</text>`,
149
+ );
150
+ });
151
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="ui-monospace, Menlo, monospace">${parts.join('')}</svg>`;
152
+ }