@warren-dean/code-kit 0.12.1 → 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.
@@ -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
+ }
@@ -415,6 +415,7 @@ export function openRequests(root, now = Date.now()) {
415
415
  // allowed), or the file it named was committed since by someone who could.
416
416
  if (r.names.every((n) => grantedSince(root, given, n, r))) continue;
417
417
  if (committedSince(root, r)) continue;
418
+ if (installedSince(root, r)) continue;
418
419
  open.push(r);
419
420
  }
420
421
  return open;
@@ -439,6 +440,39 @@ function grantedSince(root, given, name, r) {
439
440
  });
440
441
  }
441
442
 
443
+ /**
444
+ * CARD-7: whether every package a dependency request named has since been added to a committed
445
+ * manifest, on any branch: the install happened (approved, or run by the person themselves), so it no
446
+ * longer waits.
447
+ */
448
+ function installedSince(root, r) {
449
+ if (!r.names.length || !r.names.every((n) => n.startsWith(DEPENDENCY_APPROVAL))) return false;
450
+ return r.names.every((name) => {
451
+ const pkg = name.slice(DEPENDENCY_APPROVAL.length).replaceAll('+', '/');
452
+ try {
453
+ const out = execFileSync(
454
+ 'git',
455
+ [
456
+ '-C',
457
+ root,
458
+ 'log',
459
+ '--all',
460
+ '-1',
461
+ '--format=%ct',
462
+ `--since=@${Math.floor(Date.parse(r.at) / 1000)}`,
463
+ `-S"${pkg}"`,
464
+ '--',
465
+ ':(glob)**/package.json',
466
+ ],
467
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
468
+ );
469
+ return Boolean(out.trim());
470
+ } catch {
471
+ return false;
472
+ }
473
+ });
474
+ }
475
+
442
476
  /** Whether the file a request named has been committed, on any branch, since it was asked. */
443
477
  function committedSince(root, r) {
444
478
  const path = String(r.what);
@@ -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
- * Every problem with the branch, grouped: { ownership, protected, layers, baseline, design, checks }.
157
- * `lane` holds the branch to that lane's paths; `checks: false` leaves the project's checks to CI's
158
- * own steps.
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({ root, base, config, lane = null, checks = true }) {
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 = { ownership: [], protected: [], layers: [], baseline: [], design: [], checks: [] };
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 failure = runChecks(config, changed, root, actor);
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
  }
@@ -2,7 +2,7 @@
2
2
  // the person, each with its lineage. The band's "you" lines say what needs them; a decision card says why:
3
3
  // what they asked for, the story and requirements it belongs to, what the agent was doing, what was already
4
4
  // settled, why the lead couldn't settle this itself, and each option with what happens next.
5
- import { askWords } from './view.mjs';
5
+ import { approvalLabel, askWords, commandItself } from './view.mjs';
6
6
 
7
7
  /** The kinds of band line that are the person's to decide; the rest are notes. */
8
8
  export const DECISION_KINDS = new Set([
@@ -136,6 +136,73 @@ function optionsOf(line) {
136
136
  return line.actions;
137
137
  }
138
138
 
139
+ /** A kind of decision as its card's tag says it. */
140
+ const TAG = {
141
+ held: 'held call',
142
+ approvals: 'approval',
143
+ stalled: 'stalled',
144
+ proposal: 'next step',
145
+ queue: 'merge',
146
+ review: 'review',
147
+ notice: 'notice',
148
+ question: 'question',
149
+ brief: 'your brief',
150
+ denied: 'refused',
151
+ report: 'bug report',
152
+ };
153
+
154
+ /** At most `n` characters on one line, cut with an ellipsis. */
155
+ const clip = (text, n) => {
156
+ const one = String(text ?? '')
157
+ .replace(/\s+/g, ' ')
158
+ .trim();
159
+ return one.length > n ? `${one.slice(0, n - 1)}…` : one;
160
+ };
161
+
162
+ /** Names as a person reads them, at most three, then how many more. */
163
+ const someNames = (names) => {
164
+ const shown = names.slice(0, 3).map(approvalLabel).join(', ');
165
+ return names.length > 3 ? `${shown} and ${names.length - 3} more` : shown;
166
+ };
167
+
168
+ /**
169
+ * CARD-9: a decision's headline and the line under it, short enough for a card in a list:
170
+ * { tag, headline, sub }.
171
+ */
172
+ function headlineOf(line, story) {
173
+ const forStory = story ? ` · ${story.id}` : '';
174
+ if (line.kind === 'approvals') {
175
+ const who = line.request.lane ? `the ${line.request.lane} lane` : line.request.actor;
176
+ return {
177
+ headline: `Approve ${someNames(line.request.names)}`,
178
+ sub: `for ${who}${forStory} · ${clip(commandItself(line.files?.[0] ?? line.request.what), 70)}`,
179
+ };
180
+ }
181
+ if (line.kind === 'held')
182
+ return {
183
+ headline: `${line.held.who} wants to ${line.text.replace(/^.*? wants to /, '').replace(/ · \d+:\d+$/, '')}`,
184
+ sub: `held${forStory} · ${line.text.match(/\d+:\d+$/)?.[0] ?? ''} left`,
185
+ };
186
+ if (line.kind === 'question')
187
+ return {
188
+ headline: line.question.question,
189
+ sub: `from the lead${forStory}${line.question.blocks ? ', which waits on it' : ''}`,
190
+ };
191
+ if (line.kind === 'brief')
192
+ return {
193
+ headline: line.brief.question,
194
+ sub: `open in ${line.brief.path}${line.brief.blocks ? ` · holds up ${line.brief.blocks}` : ''}`,
195
+ };
196
+ if (line.kind === 'denied')
197
+ return {
198
+ headline: `Claude Code refused ${line.denial.who}'s call`,
199
+ sub: clip(line.denial.command ?? line.denial.what, 80),
200
+ };
201
+ if (line.kind === 'report')
202
+ return { headline: line.report.title, sub: "drafted by an agent, for code-kit's GitHub" };
203
+ return { headline: line.text, sub: story ? `${story.id} ${story.title ?? ''}` : '' };
204
+ }
205
+
139
206
  /** Why a line needs the person, when the line itself doesn't say. */
140
207
  function whyOf(line, check) {
141
208
  if (line.kind === 'held') {
@@ -175,7 +242,7 @@ function whyOf(line, check) {
175
242
  "The lead can't settle this itself: it's yours to decide, and the lead builds on your answer."
176
243
  );
177
244
  if (line.kind === 'brief')
178
- 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.`;
179
246
  if (line.kind === 'denied')
180
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.`;
181
248
  if (line.kind === 'report')
@@ -233,9 +300,11 @@ export function decisionCards(lines, context = {}) {
233
300
  .map(({ source, line }, i) => {
234
301
  const story = storyOf(line, state);
235
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);
236
305
  const settled = [
237
306
  ...journal.filter((e) => story && e.story === story.id).map((e) => e.text),
238
- ...inForce
307
+ ...(aboutApprovals ? inForce : [])
239
308
  .filter((a) => !lane || !a.lane || a.lane === lane)
240
309
  .slice(0, 3)
241
310
  .map(
@@ -243,14 +312,33 @@ export function decisionCards(lines, context = {}) {
243
312
  `${a.name} is approved${a.lane ? ` for the ${a.lane} lane` : ''}${a.reason ? `: ${a.reason}` : ''}`,
244
313
  ),
245
314
  ].slice(-5);
315
+ const { headline, sub } = headlineOf(line, story);
246
316
  return {
247
317
  id: `${line.kind}-${i}`,
248
318
  kind: line.kind,
319
+ tag: TAG[line.kind] ?? 'decision',
320
+ headline: clip(headline, 100),
321
+ sub,
249
322
  line,
250
323
  // The band line it came from: one line can stand for several decisions (CARD-8).
251
324
  source,
252
325
  title: line.text,
253
- asked: [request, goal].filter(Boolean).join(' · ') || null,
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
+ ],
254
342
  story: story
255
343
  ? { id: story.id, title: story.title ?? '', branch: story.branch ?? null }
256
344
  : null,
@@ -260,14 +348,22 @@ export function decisionCards(lines, context = {}) {
260
348
  })),
261
349
  doing:
262
350
  line.held?.command ??
263
- (line.request ? askWords(line.request, line.files ?? [line.request.what]) : null) ??
351
+ (line.request
352
+ ? clip(
353
+ askWords(
354
+ line.request,
355
+ (line.files ?? [line.request.what]).map((f) => commandItself(f)),
356
+ ),
357
+ 300,
358
+ )
359
+ : null) ??
264
360
  (line.proposal?.label ? `Next step: ${line.proposal.label}` : null) ??
265
361
  (line.intent
266
362
  ? `Intent #${line.intent.id}: ${line.intent.about}${line.intent.touches.length ? ` (touches ${line.intent.touches.join(', ')})` : ''}`
267
363
  : null) ??
268
364
  (line.pull ? `Pull request #${line.pull.number} on ${line.pull.branch}` : null) ??
269
365
  (line.question?.blocks ? `${line.question.story} waits on your answer.` : null) ??
270
- (line.denial ? (line.denial.command ?? line.denial.what) : null) ??
366
+ (line.denial ? clip(line.denial.command ?? line.denial.what, 400) : null) ??
271
367
  (line.report ? line.report.body : null),
272
368
  settled,
273
369
  why: whyOf(line, check),
@@ -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')