@warren-dean/code-kit 0.12.2 → 0.13.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 +11 -0
- package/README.md +108 -40
- package/SECURITY.md +11 -2
- package/bin/code-kit.mjs +61 -17
- package/hooks/guard-bash.mjs +35 -0
- package/hooks/lib/checks.mjs +19 -6
- package/hooks/lib/config.mjs +8 -0
- package/hooks/lib/next.mjs +34 -8
- package/hooks/lib/plan.mjs +4 -2
- package/hooks/lib/queue.mjs +25 -6
- package/hooks/lib/regression.mjs +321 -0
- package/hooks/lib/verify.mjs +53 -7
- package/hooks/mod/decisions.mjs +24 -4
- package/hooks/mod/loop.mjs +1 -1
- package/hooks/mod/panes.mjs +11 -1
- package/hooks/mod/register.mjs +79 -22
- package/hooks/mod/register.test.ts +173 -62
- package/hooks/mod/view.mjs +96 -25
- package/hooks/stop-check.mjs +1 -1
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -0
- package/skills/review/SKILL.md +3 -1
- package/skills/spec-design/SKILL.md +1 -1
- package/templates/agent.md +2 -0
- package/templates/claude-md.md +1 -1
- package/templates/plan.md +1 -1
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Regression (docs/specs/18-regression.md). REG-1: a fix comes with a test that fails without it, as the
|
|
2
|
+
// bug, and passes with it. REG-2: a test isn't weakened (deleted, skipped, cut down, its snapshot
|
|
3
|
+
// rewritten) without the person's `tests` approval, which the lead can't delegate. The commit hook
|
|
4
|
+
// refuses a weakening commit; verify finds what a branch weakened, from git.
|
|
5
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { basename, dirname, join } from 'node:path';
|
|
9
|
+
import { runChecks } from './checks.mjs';
|
|
10
|
+
import { matchesAny } from './glob.mjs';
|
|
11
|
+
import { readStories } from './plan.mjs';
|
|
12
|
+
|
|
13
|
+
/** The approval that lets a test be weakened: the person's only. */
|
|
14
|
+
export const TESTS_APPROVAL = 'tests';
|
|
15
|
+
|
|
16
|
+
// Test files by the usual conventions, when the config names none (`tests`).
|
|
17
|
+
const TEST =
|
|
18
|
+
/(^|\/)(tests?|__tests__|e2e)\/|\.(test|spec)\.[^/]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/;
|
|
19
|
+
export const SNAPSHOT = /(^|\/)__snapshots__\/|\.snap$/;
|
|
20
|
+
|
|
21
|
+
/** Whether `rel` is a test: the config's `tests` globs, or the usual conventions. */
|
|
22
|
+
export const isTestFile = (rel, config) =>
|
|
23
|
+
!rel.endsWith('.md') && (config?.tests?.length ? matchesAny(rel, config.tests) : TEST.test(rel));
|
|
24
|
+
|
|
25
|
+
// A case's name: it('…') / test('…') in JavaScript, def test_… in Python, func Test… in Go, @Test in JVM.
|
|
26
|
+
const JS_CASE =
|
|
27
|
+
/\b(?:it|test)(?:\.(?:each\s*\([^)]*\)|concurrent|serial|skip|only|todo|fixme|fails))*\s*\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/g;
|
|
28
|
+
const PY_CASE = /^\s*(?:async\s+)?def\s+(test_\w+)/gm;
|
|
29
|
+
const GO_CASE = /^func\s+(Test\w+)\s*\(/gm;
|
|
30
|
+
const JVM_CASE = /@Test\b[^{;]*?\b(?:void|fun)\s+(\w+)\s*\(/g;
|
|
31
|
+
// Markers that skip or focus a test, so the rest stop running.
|
|
32
|
+
const SKIPS = [
|
|
33
|
+
/\b(?:it|test|describe|suite|context)\.(?:skip|only|todo|fixme)\s*\(/,
|
|
34
|
+
/\b(?:xit|xtest|xdescribe|fit|fdescribe)\s*\(/,
|
|
35
|
+
/@pytest\.mark\.(?:skip|skipif|xfail)\b/,
|
|
36
|
+
/\bpytest\.skip\s*\(/,
|
|
37
|
+
/\bunittest\.skip/,
|
|
38
|
+
/\bt\.Skip(?:f|Now)?\s*\(/,
|
|
39
|
+
/@(?:Disabled|Ignore)\b/,
|
|
40
|
+
];
|
|
41
|
+
const ASSERTION =
|
|
42
|
+
/\bexpect\s*\(|\bassert\w*\s*[.(]|\bt\.(?:Error|Errorf|Fatal|Fatalf|Fail)\b|\bshould\.\w/g;
|
|
43
|
+
|
|
44
|
+
const skipLines = (text) =>
|
|
45
|
+
String(text ?? '')
|
|
46
|
+
.split('\n')
|
|
47
|
+
.map((l) => l.trim())
|
|
48
|
+
.filter((l) => SKIPS.some((r) => r.test(l)));
|
|
49
|
+
|
|
50
|
+
/** What a test file holds: its cases' names, its skip and focus markers, and how many assertions. */
|
|
51
|
+
export function testShape(text) {
|
|
52
|
+
const t = String(text ?? '');
|
|
53
|
+
const cases = [
|
|
54
|
+
...[...t.matchAll(JS_CASE)].map((m) => m[2]),
|
|
55
|
+
...[...t.matchAll(PY_CASE)].map((m) => m[1]),
|
|
56
|
+
...[...t.matchAll(GO_CASE)].map((m) => m[1]),
|
|
57
|
+
...[...t.matchAll(JVM_CASE)].map((m) => m[1]),
|
|
58
|
+
];
|
|
59
|
+
return { cases, skips: skipLines(t), assertions: (t.match(ASSERTION) ?? []).length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const clip = (s, n = 70) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
63
|
+
const quoteList = (names) =>
|
|
64
|
+
names
|
|
65
|
+
.slice(0, 3)
|
|
66
|
+
.map((n) => `"${clip(n, 50)}"`)
|
|
67
|
+
.join(', ') + (names.length > 3 ? ` and ${names.length - 3} more` : '');
|
|
68
|
+
|
|
69
|
+
/** How `after` weakens `before`, in words; empty when it doesn't. */
|
|
70
|
+
export function weakenings(before, after) {
|
|
71
|
+
const was = testShape(before);
|
|
72
|
+
const now = testShape(after);
|
|
73
|
+
const reasons = [];
|
|
74
|
+
const old = new Set(was.skips);
|
|
75
|
+
const added = now.skips.filter((l) => !old.has(l));
|
|
76
|
+
if (added.length || now.skips.length > was.skips.length)
|
|
77
|
+
reasons.push(
|
|
78
|
+
`adds a skip or focus marker${
|
|
79
|
+
added.length
|
|
80
|
+
? ` (${added
|
|
81
|
+
.slice(0, 2)
|
|
82
|
+
.map((l) => `\`${clip(l, 60)}\``)
|
|
83
|
+
.join(', ')})`
|
|
84
|
+
: ''
|
|
85
|
+
}`,
|
|
86
|
+
);
|
|
87
|
+
if (now.cases.length < was.cases.length) {
|
|
88
|
+
const left = new Set(now.cases);
|
|
89
|
+
const gone = was.cases.filter((c) => !left.has(c));
|
|
90
|
+
reasons.push(
|
|
91
|
+
`has ${was.cases.length - now.cases.length} fewer test case${was.cases.length - now.cases.length === 1 ? '' : 's'}${gone.length ? ` (${quoteList(gone)} gone)` : ''}`,
|
|
92
|
+
);
|
|
93
|
+
} else if (now.assertions < was.assertions)
|
|
94
|
+
reasons.push(
|
|
95
|
+
`has ${was.assertions - now.assertions} fewer assertion${was.assertions - now.assertions === 1 ? '' : 's'} (${was.assertions} → ${now.assertions})`,
|
|
96
|
+
);
|
|
97
|
+
return reasons;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* REG-2: the tests a set of changes weakens: [{ file, reasons }]. `changes` is [{ status: 'A'|'M'|'D',
|
|
102
|
+
* file, from? }] (from: a rename's old path); `before(path)` and `after(path)` read the two sides, null
|
|
103
|
+
* when the file isn't there. A deleted test whose cases all reappear in an added one was moved, not cut.
|
|
104
|
+
*/
|
|
105
|
+
export function weakenedTests(changes, before, after, config) {
|
|
106
|
+
const found = [];
|
|
107
|
+
const added = changes.filter((c) => c.status === 'A' && isTestFile(c.file, config));
|
|
108
|
+
for (const c of changes) {
|
|
109
|
+
const was = c.from ?? c.file;
|
|
110
|
+
if (SNAPSHOT.test(was) && c.status !== 'A') {
|
|
111
|
+
if (c.status === 'D' || before(was) !== after(c.file))
|
|
112
|
+
found.push({
|
|
113
|
+
file: c.file,
|
|
114
|
+
reasons: [c.status === 'D' ? 'deletes a snapshot' : 'rewrites a snapshot'],
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!isTestFile(was, config) || c.status === 'A') continue;
|
|
119
|
+
const old = before(was);
|
|
120
|
+
if (old === null) continue;
|
|
121
|
+
if (c.status === 'D') {
|
|
122
|
+
const cases = testShape(old).cases;
|
|
123
|
+
const moved =
|
|
124
|
+
cases.length > 0 &&
|
|
125
|
+
added.some((a) => {
|
|
126
|
+
const there = new Set(testShape(after(a.file)).cases);
|
|
127
|
+
return cases.every((n) => there.has(n));
|
|
128
|
+
});
|
|
129
|
+
if (!moved)
|
|
130
|
+
found.push({
|
|
131
|
+
file: c.file,
|
|
132
|
+
reasons: [
|
|
133
|
+
`deletes the test file${cases.length ? ` (${cases.length} case${cases.length === 1 ? '' : 's'})` : ''}`,
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const reasons = weakenings(old, after(c.file));
|
|
139
|
+
if (reasons.length) found.push({ file: c.file, reasons });
|
|
140
|
+
}
|
|
141
|
+
return found;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const git = (root, ...args) =>
|
|
145
|
+
execFileSync('git', ['-C', root, ...args], {
|
|
146
|
+
encoding: 'utf8',
|
|
147
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
148
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const showAt = (root, ref, rel) => {
|
|
152
|
+
try {
|
|
153
|
+
return git(root, 'show', `${ref}:${rel}`);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** REG-2 for a branch: what it weakened since `from` (where it left the base), judged from git. */
|
|
160
|
+
export function branchWeakenings(root, from, config) {
|
|
161
|
+
const changes = git(root, 'diff', '--name-status', '-M', from, 'HEAD')
|
|
162
|
+
.split('\n')
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.map((l) => {
|
|
165
|
+
const [status, a, b] = l.split('\t');
|
|
166
|
+
return status.startsWith('R')
|
|
167
|
+
? { status: 'M', from: a, file: b }
|
|
168
|
+
: { status: status[0] === 'D' ? 'D' : status[0] === 'A' ? 'A' : 'M', file: a };
|
|
169
|
+
});
|
|
170
|
+
return weakenedTests(
|
|
171
|
+
changes,
|
|
172
|
+
(rel) => showAt(root, from, rel),
|
|
173
|
+
(rel) => showAt(root, 'HEAD', rel),
|
|
174
|
+
config,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* REG-2 for a commit about to be made: `files` as the commit hook lists them, HEAD against the
|
|
180
|
+
* working tree.
|
|
181
|
+
*/
|
|
182
|
+
export function commitWeakenings(root, files, config) {
|
|
183
|
+
const read = (rel) => {
|
|
184
|
+
try {
|
|
185
|
+
return readFileSync(join(root, rel), 'utf8');
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const changes = files.map((file) => {
|
|
191
|
+
const was = showAt(root, 'HEAD', file) !== null;
|
|
192
|
+
const is = existsSync(join(root, file));
|
|
193
|
+
return { status: !was ? 'A' : is ? 'M' : 'D', file };
|
|
194
|
+
});
|
|
195
|
+
return weakenedTests(changes, (rel) => showAt(root, 'HEAD', rel), read, config);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* REG-1: what a branch fixes, or null when it isn't a fix: its plan story's `**Fixes:**` line (branch
|
|
200
|
+
* `<lane>/st-<n>`), or a branch named `<lane>/fix-…`.
|
|
201
|
+
*/
|
|
202
|
+
export function fixOf(root, branch, config) {
|
|
203
|
+
if (!branch) return null;
|
|
204
|
+
const story = branch.match(/^[^/]+\/(st-\d+)$/i)?.[1]?.toUpperCase();
|
|
205
|
+
if (story && config.docs?.plan) {
|
|
206
|
+
const s = readStories(root, config.docs.plan).find((x) => x.id === story);
|
|
207
|
+
if (s?.fixes) return { story: s.id, what: s.fixes };
|
|
208
|
+
}
|
|
209
|
+
if (/^[^/]+\/fix-/.test(branch))
|
|
210
|
+
return { story: null, what: branch.split('/').slice(1).join('/').slice(4).replace(/-/g, ' ') };
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A failure that only says the test can't reach what it calls: the fix added it, so it proves nothing.
|
|
215
|
+
const UNREACHABLE =
|
|
216
|
+
/Cannot find module|is not a function|does not provide an export named|is not exported|has no exported member|error TS2305|error TS2339|error TS2724|error TS2614|ImportError|ModuleNotFoundError|cannot import name|undefined: \w|\[build failed\]|ReferenceError: \w+ is not defined/;
|
|
217
|
+
// Signs a test ran and an assertion failed.
|
|
218
|
+
const ASSERTED =
|
|
219
|
+
/AssertionError|AssertionFailedError|Expected:|Received:|expected .+ to |toBe|toEqual|assert\w* failed|^\s*--- FAIL|Error: expect\(/m;
|
|
220
|
+
|
|
221
|
+
/** Whether a failure's output only shows the tests couldn't reach what the fix adds. */
|
|
222
|
+
export const onlyUnreachable = (output) =>
|
|
223
|
+
UNREACHABLE.test(output) && !ASSERTED.test(output.replace(UNREACHABLE, ''));
|
|
224
|
+
|
|
225
|
+
// The folders an install puts beside the code, which a fresh checkout lacks.
|
|
226
|
+
const INSTALLS = new Set(['node_modules', '.venv', 'venv']);
|
|
227
|
+
|
|
228
|
+
/** Links the checkout's installs (node_modules and the like, wherever git ignores them) into `dir`. */
|
|
229
|
+
function linkInstalls(root, dir) {
|
|
230
|
+
let ignored = [];
|
|
231
|
+
try {
|
|
232
|
+
ignored = git(root, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory')
|
|
233
|
+
.split('\n')
|
|
234
|
+
.map((l) => l.replace(/\/$/, ''))
|
|
235
|
+
.filter((l) => l && INSTALLS.has(basename(l)));
|
|
236
|
+
} catch {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
for (const rel of ignored) {
|
|
240
|
+
const to = join(dir, rel);
|
|
241
|
+
if (!existsSync(dirname(to)) || existsSync(to)) continue;
|
|
242
|
+
try {
|
|
243
|
+
symlinkSync(join(root, rel), to, 'dir');
|
|
244
|
+
} catch {
|
|
245
|
+
// the check then says what it couldn't run
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* REG-1: whether a fix's tests prove its bug. In a temporary worktree of the branch (with the
|
|
252
|
+
* checkout's installs linked in, after `regression.prepare`), the checks covering the tests it added
|
|
253
|
+
* or changed must pass, and fail, as the bug, once every change outside the tests goes back to `from`.
|
|
254
|
+
* Problems in words; empty when the tests prove it.
|
|
255
|
+
*/
|
|
256
|
+
export function proveFix({ root, from, base, changed, config, fix }) {
|
|
257
|
+
const what = `This branch fixes ${fix.what}${fix.story ? ` (${fix.story})` : ''}`;
|
|
258
|
+
const tests = changed.filter(
|
|
259
|
+
(f) => isTestFile(f, config) && !SNAPSHOT.test(f) && showAt(root, 'HEAD', f) !== null,
|
|
260
|
+
);
|
|
261
|
+
if (!tests.length)
|
|
262
|
+
return [
|
|
263
|
+
`${what}, but it adds or changes no test. Add a test that fails without the fix: it's what stops the bug coming back.`,
|
|
264
|
+
];
|
|
265
|
+
const covering = config.checks.filter((c) =>
|
|
266
|
+
tests.some((f) => matchesAny(f, c.files) && !matchesAny(f, c.exclude)),
|
|
267
|
+
);
|
|
268
|
+
if (!covering.length)
|
|
269
|
+
return [
|
|
270
|
+
`${what}, but no check runs its tests (${tests.join(', ')}), so code-kit can't show they fail without it. Add a check whose files match them.`,
|
|
271
|
+
];
|
|
272
|
+
const strict = { ...config, checks: covering.map((c) => ({ ...c, ifMissing: 'fail' })) };
|
|
273
|
+
const actor = { kind: 'lead', label: 'lead' };
|
|
274
|
+
const dir = mkdtempSync(join(tmpdir(), 'code-kit-fix-'));
|
|
275
|
+
rmSync(dir, { recursive: true });
|
|
276
|
+
try {
|
|
277
|
+
git(root, 'worktree', 'add', '--quiet', '--detach', dir, 'HEAD');
|
|
278
|
+
linkInstalls(root, dir);
|
|
279
|
+
const prepare = config.regression?.prepare;
|
|
280
|
+
if (prepare) {
|
|
281
|
+
const res = spawnSync(prepare, { cwd: dir, shell: true, encoding: 'utf8', timeout: 900000 });
|
|
282
|
+
if (res.status !== 0)
|
|
283
|
+
return [
|
|
284
|
+
`regression.prepare (\`${prepare}\`) failed in a fresh checkout, so the fix's tests couldn't run:\n${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-20).join('\n')}`,
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
const { failure: withFix } = runChecks(strict, tests, dir, actor);
|
|
288
|
+
if (withFix)
|
|
289
|
+
return [
|
|
290
|
+
`${what}, but its tests don't pass in a fresh checkout of the branch (${withFix.title.replace(/ Fix it:$/, '')}), so code-kit can't tell whether they fail without it. If they need an install or a build first, set regression.prepare.${withFix.output ? `\n${String(withFix.output).trim().split('\n').slice(-20).join('\n')}` : ''}`,
|
|
291
|
+
];
|
|
292
|
+
for (const f of changed) {
|
|
293
|
+
if (isTestFile(f, config) || SNAPSHOT.test(f)) continue;
|
|
294
|
+
if (showAt(root, from, f) !== null) git(dir, 'checkout', from, '--', f);
|
|
295
|
+
else rmSync(join(dir, f), { force: true });
|
|
296
|
+
}
|
|
297
|
+
const { failure: without } = runChecks(strict, tests, dir, actor);
|
|
298
|
+
if (!without)
|
|
299
|
+
return [
|
|
300
|
+
`${what}, but its tests (${tests.join(', ')}) also pass without the fix, so they don't prove the bug and it can come back. Write a test that fails on ${base} and passes here.`,
|
|
301
|
+
];
|
|
302
|
+
const output = `${without.title}\n${without.output ?? ''}`;
|
|
303
|
+
if (onlyUnreachable(output)) {
|
|
304
|
+
const line =
|
|
305
|
+
output
|
|
306
|
+
.split('\n')
|
|
307
|
+
.find((l) => UNREACHABLE.test(l))
|
|
308
|
+
?.trim() ?? '';
|
|
309
|
+
return [
|
|
310
|
+
`${what}, but its tests fail without the fix only because they call what the fix adds (${clip(line, 120)}). A test that goes through the existing code fails on an assertion when the bug is there: write that one.`,
|
|
311
|
+
];
|
|
312
|
+
}
|
|
313
|
+
return [];
|
|
314
|
+
} finally {
|
|
315
|
+
try {
|
|
316
|
+
git(root, 'worktree', 'remove', '--force', dir);
|
|
317
|
+
} catch {
|
|
318
|
+
rmSync(dir, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
package/hooks/lib/verify.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { CONFIG_FILE } from './config.mjs';
|
|
|
10
10
|
import { isDependencyFile } from './dependencies.mjs';
|
|
11
11
|
import { matchesAny } from './glob.mjs';
|
|
12
12
|
import { layerProblems } from './layers.mjs';
|
|
13
|
+
import { TESTS_APPROVAL, branchWeakenings, fixOf, proveFix } from './regression.mjs';
|
|
13
14
|
import {
|
|
14
15
|
APPROVAL_LOG,
|
|
15
16
|
SECRETS,
|
|
@@ -152,17 +153,46 @@ function historySecrets(root, from, changed) {
|
|
|
152
153
|
return problems;
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/** The branch checked out at `root`, or null when HEAD is detached. */
|
|
157
|
+
function currentBranchOf(root) {
|
|
158
|
+
try {
|
|
159
|
+
const name = git(root, 'rev-parse', '--abbrev-ref', 'HEAD').trim();
|
|
160
|
+
return name === 'HEAD' ? null : name;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
155
166
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
167
|
+
* The files changed, every problem with the branch, grouped as { ownership, protected, layers,
|
|
168
|
+
* baseline, design, tests, regression, checks }, and the checks `skipped` because a tool isn't
|
|
169
|
+
* installed. `lane` holds the branch to that lane's paths; `checks: false` leaves the project's
|
|
170
|
+
* checks to CI's own steps. `fixes` runs a fix's tests without the fix (REG-1; with the
|
|
171
|
+
* checks unless said); `branch` names the branch when the checkout is detached.
|
|
159
172
|
*/
|
|
160
|
-
export function verifyBranch({
|
|
173
|
+
export function verifyBranch({
|
|
174
|
+
root,
|
|
175
|
+
base,
|
|
176
|
+
config,
|
|
177
|
+
lane = null,
|
|
178
|
+
checks = true,
|
|
179
|
+
fixes = checks,
|
|
180
|
+
branch = null,
|
|
181
|
+
}) {
|
|
161
182
|
// Where the branch left the base: the base may have moved on since (new log entries, a smaller baseline).
|
|
162
183
|
const from = git(root, 'merge-base', base, 'HEAD').trim();
|
|
163
184
|
const changed = branchChanges(root, base);
|
|
164
185
|
const present = changed.filter((f) => existsSync(join(root, f)));
|
|
165
|
-
const found = {
|
|
186
|
+
const found = {
|
|
187
|
+
ownership: [],
|
|
188
|
+
protected: [],
|
|
189
|
+
layers: [],
|
|
190
|
+
baseline: [],
|
|
191
|
+
design: [],
|
|
192
|
+
tests: [],
|
|
193
|
+
regression: [],
|
|
194
|
+
checks: [],
|
|
195
|
+
};
|
|
166
196
|
const { entries, problems: logProblems } = logEntries(root, from);
|
|
167
197
|
found.protected.push(...logProblems);
|
|
168
198
|
const logged = new Map(entries.map((e) => [e.file, e]));
|
|
@@ -235,11 +265,27 @@ export function verifyBranch({ root, base, config, lane = null, checks = true })
|
|
|
235
265
|
found.baseline.push(`${BASELINE_FILE} gained ${p}; it only shrinks.`);
|
|
236
266
|
}
|
|
237
267
|
|
|
268
|
+
// REG-2: a test weakened on the branch needs the person's `tests` approval, logged by the commit.
|
|
269
|
+
for (const w of branchWeakenings(root, from, config)) {
|
|
270
|
+
const approved = entries.some((e) => e.file === w.file && e.approval === TESTS_APPROVAL);
|
|
271
|
+
if (!approved)
|
|
272
|
+
found.tests.push(
|
|
273
|
+
`${w.file} ${w.reasons.join(', and ')}. A weakened test can hide a regression, so it needs the person's tests approval, which the commit records in ${APPROVAL_LOG}. Restore the test and fix the code, or ask the person.`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// REG-1: a fix's tests fail without it, as the bug.
|
|
278
|
+
const fix = fixes ? fixOf(root, branch ?? currentBranchOf(root), config) : null;
|
|
279
|
+
if (fix) found.regression.push(...proveFix({ root, from, base, changed, config, fix }));
|
|
280
|
+
|
|
281
|
+
let skipped = [];
|
|
238
282
|
if (checks) {
|
|
239
283
|
const actor = lane
|
|
240
284
|
? { kind: 'lane', lane, label: `${lane} lane branch` }
|
|
241
285
|
: { kind: 'lead', label: 'lead' };
|
|
242
|
-
const
|
|
286
|
+
const ran = runChecks(config, changed, root, actor);
|
|
287
|
+
skipped = ran.skipped;
|
|
288
|
+
const { failure } = ran;
|
|
243
289
|
if (failure)
|
|
244
290
|
found.checks.push(
|
|
245
291
|
`${failure.title}\n${String(failure.output ?? '')
|
|
@@ -248,5 +294,5 @@ export function verifyBranch({ root, base, config, lane = null, checks = true })
|
|
|
248
294
|
.join('\n')}`.trim(),
|
|
249
295
|
);
|
|
250
296
|
}
|
|
251
|
-
return { changed, found };
|
|
297
|
+
return { changed, found, skipped };
|
|
252
298
|
}
|
package/hooks/mod/decisions.mjs
CHANGED
|
@@ -189,7 +189,10 @@ function headlineOf(line, story) {
|
|
|
189
189
|
sub: `from the lead${forStory}${line.question.blocks ? ', which waits on it' : ''}`,
|
|
190
190
|
};
|
|
191
191
|
if (line.kind === 'brief')
|
|
192
|
-
return {
|
|
192
|
+
return {
|
|
193
|
+
headline: line.brief.question,
|
|
194
|
+
sub: `open in ${line.brief.path}${line.brief.blocks ? ` · holds up ${line.brief.blocks}` : ''}`,
|
|
195
|
+
};
|
|
193
196
|
if (line.kind === 'denied')
|
|
194
197
|
return {
|
|
195
198
|
headline: `Claude Code refused ${line.denial.who}'s call`,
|
|
@@ -239,7 +242,7 @@ function whyOf(line, check) {
|
|
|
239
242
|
"The lead can't settle this itself: it's yours to decide, and the lead builds on your answer."
|
|
240
243
|
);
|
|
241
244
|
if (line.kind === 'brief')
|
|
242
|
-
return `It's an open question in ${line.brief.path}: what gets built depends on your answer.`;
|
|
245
|
+
return `It's an open question in ${line.brief.path}, left for you: what gets built depends on your answer. Not sure what it means? Ask about it when you answer.`;
|
|
243
246
|
if (line.kind === 'denied')
|
|
244
247
|
return `Claude Code's own permission check refused it${line.denial.reason ? ` (${line.denial.reason})` : ''}, which code-kit can't approve past: only you can run it or allow it.`;
|
|
245
248
|
if (line.kind === 'report')
|
|
@@ -297,9 +300,11 @@ export function decisionCards(lines, context = {}) {
|
|
|
297
300
|
.map(({ source, line }, i) => {
|
|
298
301
|
const story = storyOf(line, state);
|
|
299
302
|
const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? story?.lane ?? null;
|
|
303
|
+
// Approvals in force bear on an approval or a held call, not on a question about the product.
|
|
304
|
+
const aboutApprovals = ['held', 'approvals', 'stalled'].includes(line.kind);
|
|
300
305
|
const settled = [
|
|
301
306
|
...journal.filter((e) => story && e.story === story.id).map((e) => e.text),
|
|
302
|
-
...inForce
|
|
307
|
+
...(aboutApprovals ? inForce : [])
|
|
303
308
|
.filter((a) => !lane || !a.lane || a.lane === lane)
|
|
304
309
|
.slice(0, 3)
|
|
305
310
|
.map(
|
|
@@ -318,7 +323,22 @@ export function decisionCards(lines, context = {}) {
|
|
|
318
323
|
// The band line it came from: one line can stand for several decisions (CARD-8).
|
|
319
324
|
source,
|
|
320
325
|
title: line.text,
|
|
321
|
-
|
|
326
|
+
// CARD-9: what they asked for in a line (the brief's title, not its whole problem).
|
|
327
|
+
asked:
|
|
328
|
+
[request && clip(firstSentence(request), 140), goal].filter(Boolean).join(' · ') || null,
|
|
329
|
+
// The question itself, and the brief's own columns for it (who decides, what it holds up).
|
|
330
|
+
details: [
|
|
331
|
+
...(line.kind === 'brief'
|
|
332
|
+
? [
|
|
333
|
+
{ label: 'Question', value: line.brief.question },
|
|
334
|
+
...(line.brief.owner ? [{ label: 'Who decides', value: line.brief.owner }] : []),
|
|
335
|
+
...(line.brief.blocks ? [{ label: 'Holds up', value: line.brief.blocks }] : []),
|
|
336
|
+
]
|
|
337
|
+
: []),
|
|
338
|
+
...(line.kind === 'question'
|
|
339
|
+
? [{ label: 'Question', value: line.question.question }]
|
|
340
|
+
: []),
|
|
341
|
+
],
|
|
322
342
|
story: story
|
|
323
343
|
? { id: story.id, title: story.title ?? '', branch: story.branch ?? null }
|
|
324
344
|
: null,
|
package/hooks/mod/loop.mjs
CHANGED
|
@@ -75,7 +75,7 @@ export function leadPrompt(step, { cli = 'code-kit' } = {}) {
|
|
|
75
75
|
// MQ-2: the head of the merge queue, where the lead merges within the delegated rules.
|
|
76
76
|
if (step.step === 'merge')
|
|
77
77
|
return ids.length
|
|
78
|
-
? `Merge ${ids[0]}, next in the merge queue: run node "${cli}" queue merge --delegated. It verifies the branch against the base as it is now, and sends it back to its lane if it conflicts or fails.`
|
|
78
|
+
? `Merge ${ids[0]}, next in the merge queue: run node "${cli}" queue merge --delegated. It verifies the branch against the base as it is now, and sends it back to its lane if it conflicts or fails; a branch that moved after its review leaves the queue for you to review again.`
|
|
79
79
|
: null;
|
|
80
80
|
// SETUP-2: the base has no rules yet; the lead puts the approved ones there, then merges as usual.
|
|
81
81
|
if (step.step === 'rules')
|
package/hooks/mod/panes.mjs
CHANGED
|
@@ -1357,7 +1357,17 @@ export function decisionsTab(cards, ui, { Box, Text, Button }, on) {
|
|
|
1357
1357
|
paddingX: 1,
|
|
1358
1358
|
rowGap: 0,
|
|
1359
1359
|
children: [
|
|
1360
|
-
text(
|
|
1360
|
+
text(
|
|
1361
|
+
card.kind === 'brief'
|
|
1362
|
+
? 'An open question in your brief'
|
|
1363
|
+
: card.kind === 'question'
|
|
1364
|
+
? 'A question from the lead'
|
|
1365
|
+
: card.headline,
|
|
1366
|
+
{ bold: true, wrap: 'wrap' },
|
|
1367
|
+
),
|
|
1368
|
+
...(card.details ?? []).map((d) =>
|
|
1369
|
+
row(d.label, d.value, d.label === 'Question' ? { bold: true } : {}),
|
|
1370
|
+
),
|
|
1361
1371
|
...(card.asked ? [row('You asked for', card.asked)] : []),
|
|
1362
1372
|
...(card.story
|
|
1363
1373
|
? [
|
package/hooks/mod/register.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
answerPane,
|
|
22
22
|
approvalAsidePrompt,
|
|
23
23
|
approvePane,
|
|
24
|
+
questionAsidePrompt,
|
|
24
25
|
commandItself,
|
|
25
26
|
band,
|
|
26
27
|
bandLines,
|
|
@@ -564,12 +565,24 @@ export function register(on) {
|
|
|
564
565
|
answerWith: (line) => act.sendAnswer({ kind: 'question', ...line.question }, line.choice),
|
|
565
566
|
// ASK-2, ASK-4: Answer… opens a box for the person's own words.
|
|
566
567
|
answer: async (line) => {
|
|
567
|
-
answering = {
|
|
568
|
+
answering = {
|
|
569
|
+
kind: 'question',
|
|
570
|
+
...line.question,
|
|
571
|
+
text: '',
|
|
572
|
+
error: null,
|
|
573
|
+
aside: { thread: [], draft: '' },
|
|
574
|
+
};
|
|
568
575
|
await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
|
|
569
576
|
$.ui.invalidate('ui.render');
|
|
570
577
|
},
|
|
571
578
|
answerBrief: async (line) => {
|
|
572
|
-
answering = {
|
|
579
|
+
answering = {
|
|
580
|
+
kind: 'brief',
|
|
581
|
+
...line.brief,
|
|
582
|
+
text: '',
|
|
583
|
+
error: null,
|
|
584
|
+
aside: { thread: [], draft: '' },
|
|
585
|
+
};
|
|
573
586
|
await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
|
|
574
587
|
$.ui.invalidate('ui.render');
|
|
575
588
|
},
|
|
@@ -613,17 +626,24 @@ export function register(on) {
|
|
|
613
626
|
}
|
|
614
627
|
}
|
|
615
628
|
notice = null;
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
text:
|
|
619
|
-
q.kind === 'question'
|
|
620
|
-
? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
|
|
621
|
-
: `The person answered an open question in ${q.path}, "${q.question}": ${said} Record it in the brief's decision log, take it out of the open questions, and carry on with it.`,
|
|
622
|
-
});
|
|
629
|
+
// Answered, the decision goes at once; the lead hears it as a turn of its own once it's idle, which
|
|
630
|
+
// can be long after (so the prompt isn't waited on).
|
|
623
631
|
if (q.kind === 'brief') {
|
|
624
632
|
briefLater.add(q.question);
|
|
625
633
|
model = { ...model, brief: (model.brief ?? []).filter((b) => b.question !== q.question) };
|
|
626
634
|
}
|
|
635
|
+
if (q.kind === 'question')
|
|
636
|
+
model = { ...model, questions: (model.questions ?? []).filter((x) => x.id !== q.id) };
|
|
637
|
+
$.ui.invalidate('ui.render');
|
|
638
|
+
const said = /[.!?]$/.test(text) ? text : `${text}.`;
|
|
639
|
+
$.prompt
|
|
640
|
+
.submit({
|
|
641
|
+
text:
|
|
642
|
+
q.kind === 'question'
|
|
643
|
+
? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
|
|
644
|
+
: `The person answered an open question in ${q.path}, "${q.question}": ${said} Record it in the brief's decision log, take it out of the open questions, and carry on with it.`,
|
|
645
|
+
})
|
|
646
|
+
.catch(() => {});
|
|
627
647
|
await act.reload();
|
|
628
648
|
return true;
|
|
629
649
|
},
|
|
@@ -634,16 +654,20 @@ export function register(on) {
|
|
|
634
654
|
deniedDone: async (line) => {
|
|
635
655
|
denied.delete(line.denial.id);
|
|
636
656
|
$.ui.invalidate('ui.render');
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
657
|
+
$.prompt
|
|
658
|
+
.submit({
|
|
659
|
+
text: `The person ran it themselves, or allowed it: ${line.denial.command ?? line.denial.what} (Claude Code had refused it for ${line.denial.who}). Check it worked, and carry on.`,
|
|
660
|
+
})
|
|
661
|
+
.catch(() => {});
|
|
640
662
|
},
|
|
641
663
|
deniedNo: async (line) => {
|
|
642
664
|
denied.delete(line.denial.id);
|
|
643
665
|
$.ui.invalidate('ui.render');
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
666
|
+
$.prompt
|
|
667
|
+
.submit({
|
|
668
|
+
text: `The person won't run ${line.denial.command ?? line.denial.what} now (Claude Code refused it for ${line.denial.who}). Don't try to get around it: carry on with other work, and if a story can't go on without it, ask with code-kit ask --blocks.`,
|
|
669
|
+
})
|
|
670
|
+
.catch(() => {});
|
|
647
671
|
},
|
|
648
672
|
// REPORT-3: the person sends a drafted report to code-kit's GitHub, or discards it.
|
|
649
673
|
sendReport: async (line) => {
|
|
@@ -675,6 +699,40 @@ export function register(on) {
|
|
|
675
699
|
heldId: line.held.id,
|
|
676
700
|
outside: line.held.outside ?? null,
|
|
677
701
|
}),
|
|
702
|
+
// ACT-5: a side call over the lead's own conversation (it shares the prompt cache, and nothing is
|
|
703
|
+
// added to the conversation); before the lead's first reply, from the prompt's facts alone.
|
|
704
|
+
sideCall: async (prompt) => {
|
|
705
|
+
const failed = (err) => ({ isAnswered: false, reason: String(err?.message ?? err) });
|
|
706
|
+
const reply = await $.model.fork({ prompt }).catch(failed);
|
|
707
|
+
if (reply.isAnswered || reply.reason !== 'nothing-to-fork') return reply;
|
|
708
|
+
return $.model.complete({ model: 'sonnet', prompt, maxTokens: 800 }).catch(failed);
|
|
709
|
+
},
|
|
710
|
+
// ASK-5: a question about a question, asked in the answer box before answering.
|
|
711
|
+
askAnswer: async (question) => {
|
|
712
|
+
const q = question.trim();
|
|
713
|
+
if (!answering || !q) return;
|
|
714
|
+
const asked = answering;
|
|
715
|
+
const turn = { question: q, pending: true };
|
|
716
|
+
const thread = asked.aside.thread;
|
|
717
|
+
answering = { ...asked, aside: { thread: [...thread, turn], draft: '' } };
|
|
718
|
+
$.ui.invalidate('ui.render');
|
|
719
|
+
const reply = await act.sideCall(questionAsidePrompt({ q: asked, thread, question: q }));
|
|
720
|
+
if (!answering || answering.question !== asked.question) return;
|
|
721
|
+
const done = reply.isAnswered
|
|
722
|
+
? { question: q, answer: reply.text }
|
|
723
|
+
: {
|
|
724
|
+
question: q,
|
|
725
|
+
error: `No answer: ${reply.reason}${reply.status ? ` (${reply.status})` : ''}`,
|
|
726
|
+
};
|
|
727
|
+
answering = {
|
|
728
|
+
...answering,
|
|
729
|
+
aside: {
|
|
730
|
+
...answering.aside,
|
|
731
|
+
thread: answering.aside.thread.map((t) => (t === turn ? done : t)),
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
$.ui.invalidate('ui.render');
|
|
735
|
+
},
|
|
678
736
|
// ACT-5: a question about the approval, answered in its pane by a side call over the lead's own
|
|
679
737
|
// conversation (it shares the prompt cache, and nothing is added to the conversation); before the
|
|
680
738
|
// lead's first reply, from code-kit's facts alone.
|
|
@@ -694,13 +752,7 @@ export function register(on) {
|
|
|
694
752
|
thread,
|
|
695
753
|
question: q,
|
|
696
754
|
});
|
|
697
|
-
|
|
698
|
-
.fork({ prompt })
|
|
699
|
-
.catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
|
|
700
|
-
if (!reply.isAnswered && reply.reason === 'nothing-to-fork')
|
|
701
|
-
reply = await $.model
|
|
702
|
-
.complete({ model: 'sonnet', prompt, maxTokens: 800 })
|
|
703
|
-
.catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
|
|
755
|
+
const reply = await act.sideCall(prompt);
|
|
704
756
|
// Answered for this approval only: a pane closed or moved on meanwhile keeps what it has.
|
|
705
757
|
if (!approving || approving.request !== asked.request) return;
|
|
706
758
|
const done = reply.isAnswered
|
|
@@ -2004,6 +2056,11 @@ export function register(on) {
|
|
|
2004
2056
|
},
|
|
2005
2057
|
onSubmit: (value) => act.confirmAnswer(value),
|
|
2006
2058
|
onCancel: () => act.cancelAnswer(),
|
|
2059
|
+
onAskInput: (value) => {
|
|
2060
|
+
answering = { ...answering, aside: { ...answering.aside, draft: value } };
|
|
2061
|
+
$.ui.invalidate('ui.render');
|
|
2062
|
+
},
|
|
2063
|
+
onAsk: (value) => act.askAnswer(value),
|
|
2007
2064
|
});
|
|
2008
2065
|
});
|
|
2009
2066
|
|