@codedology/code-forge 0.10.0 → 0.12.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +34 -3
  3. package/docs/reference/blocks.json +360 -1
  4. package/docs/reference/config.md +6 -5
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +3 -2
  7. package/skill/SKILL.md +3 -1
  8. package/skill/references/code.md +17 -8
  9. package/skill/references/proof.md +8 -2
  10. package/skill/references/review.md +32 -5
  11. package/skill/references/rules-core.md +1 -1
  12. package/skill/scripts/forge +1 -1
  13. package/skill/templates/coder-brief.md +17 -7
  14. package/src/cli/block.mjs +112 -7
  15. package/src/cli/gates.mjs +15 -6
  16. package/src/cli/review-file.mjs +118 -14
  17. package/src/config/validate.mjs +12 -5
  18. package/src/context/pack.mjs +7 -4
  19. package/src/gates/expand.mjs +102 -9
  20. package/src/gates/run.mjs +6 -4
  21. package/src/install/wizard/flags.mjs +1 -1
  22. package/src/proof/lock.mjs +23 -10
  23. package/src/proof/rule.mjs +9 -0
  24. package/src/proof/tiers.mjs +2 -14
  25. package/src/review/engine.mjs +59 -12
  26. package/src/review/fixloop.mjs +41 -16
  27. package/src/review/gate-check.mjs +2 -2
  28. package/src/review/lenses/A.md +5 -0
  29. package/src/review/lenses/B.md +5 -0
  30. package/src/review/lenses/full.md +5 -0
  31. package/src/review/lenses/judge.md +4 -0
  32. package/src/review/lenses/quick.md +4 -0
  33. package/src/review/lenses/recheck.md +5 -0
  34. package/src/review/packet.mjs +48 -14
  35. package/src/review/paired.mjs +81 -0
  36. package/src/review/reopen.mjs +18 -6
  37. package/src/review/reviewed-lines.mjs +94 -0
  38. package/src/state/block-end.mjs +10 -6
  39. package/src/util/exec.mjs +2 -1
  40. package/src/util/gate-token.mjs +3 -0
  41. package/src/worker/engine.mjs +43 -15
  42. package/src/worker/loop.mjs +35 -2
  43. package/src/worker/queue.mjs +60 -2
  44. package/src/worker/watch.mjs +113 -0
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * B60 (issue #13 item 3): run only the block's related tests, and give a gate its own environment.
3
- * Pure — no filesystem, no child process. A gate argv item that is EXACTLY {@link TEST_FILES_TOKEN}
3
+ * Scope and argv rules are pure; B99's fileBaseline reads bounded hashes from the workspace.
4
+ * A gate argv item that is EXACTLY {@link TEST_FILES_TOKEN}
4
5
  * (one whole item, never a substring, never a shell string) stands for the block's test files:
5
6
  *
6
7
  * - the block's `test_files` (`block open --tests`), else its owned entries that are concrete
@@ -20,8 +21,14 @@
20
21
  * `CI=1` or `MODE=test` would otherwise mask unrelated text (`npm test`).
21
22
  */
22
23
 
23
- import { isExactEntry } from '../state/registry.mjs';
24
- import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
24
+ import { createHash } from 'node:crypto';
25
+ import { access, lstat, readFile, realpath } from 'node:fs/promises';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import { isExactEntry, ownsFile } from '../state/registry.mjs';
29
+ import { pairsFor } from '../review/paired.mjs';
30
+ import { exec } from '../util/exec.mjs';
31
+ import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
25
32
 
26
33
  export { TEST_FILES_TOKEN };
27
34
 
@@ -67,6 +74,7 @@ const isConcreteFile = (p) => typeof p === 'string' && p.length > 0 && isExactEn
67
74
 
68
75
  /**
69
76
  * @typedef {{scope: 'related', files: string[]} | {scope: 'full', reason: 'threshold' | 'no_tests'}} TestScope
77
+ * @typedef {{scope: 'changed', files: string[], changedFiles: string[], reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'}} ChangedScope
70
78
  */
71
79
 
72
80
  /** A gate run with no block: no test file is known. */
@@ -108,30 +116,115 @@ export function hasTestFilesItem(gates) {
108
116
  return Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(TEST_FILES_TOKEN) === true);
109
117
  }
110
118
 
119
+ /** @param {Record<string, unknown> | null | undefined} gates @returns {boolean} */
120
+ export function hasScopeItem(gates) {
121
+ return hasTestFilesItem(gates) || Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(CHANGED_FILES_TOKEN));
122
+ }
123
+
124
+ /** A safe repo-relative path that cannot turn into an argv option. @param {string} file */
125
+ function safeFile(file) {
126
+ return file.length > 0 && !file.startsWith('-') && !path.isAbsolute(file) && !/[\\\x00-\x1f]/.test(file) && !file.split('/').some((s) => s === '' || s === '.' || s === '..');
127
+ }
128
+
129
+ /**
130
+ * Bounded content baseline. Missing listed files are omitted from hashes and candidates; an
131
+ * owned entry with no current file has no hash. More than 200 owned files, a symlink, a
132
+ * non-regular owned file, a path outside the root or another read error means no record;
133
+ * callers retain the old scope. Git sees only argv and an isolated git environment.
134
+ * @param {string} cwd @param {string[]} owned @param {Record<string, string>} [kinds]
135
+ * @returns {Promise<{files: Record<string, string>, candidates: string[]} | null>}
136
+ */
137
+ export async function fileBaseline(cwd, owned, kinds) {
138
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
139
+ env.GIT_CONFIG_NOSYSTEM = '1';
140
+ env.GIT_CONFIG_GLOBAL = os.devNull;
141
+ const listed = await exec(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd, env, timeoutMs: 30000 });
142
+ if (listed.result !== 'ok') return null;
143
+ const listedFiles = [...new Set(listed.stdout.split('\0').filter(Boolean))].sort();
144
+ const candidates = [];
145
+ const files = /** @type {Record<string, string>} */ (Object.create(null));
146
+ try {
147
+ const root = await realpath(cwd);
148
+ for (const file of listedFiles) {
149
+ const isOwned = ownsFile(owned, file, kinds);
150
+ if (isOwned && !safeFile(file)) return null;
151
+ const absolute = path.join(root, file);
152
+ if (!isOwned) {
153
+ const exists = await access(absolute).then(() => true).catch((error) => {
154
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
155
+ throw error;
156
+ });
157
+ if (exists) candidates.push(file);
158
+ continue;
159
+ }
160
+ const stat = await lstat(absolute).catch((error) => {
161
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
162
+ throw error;
163
+ });
164
+ if (stat === null) continue;
165
+ candidates.push(file);
166
+ if (Object.keys(files).length >= 200 || !stat.isFile() || !(await realpath(absolute)).startsWith(root + path.sep)) return null;
167
+ files[file] = createHash('sha256').update(await readFile(absolute)).digest('hex');
168
+ }
169
+ } catch { return null; }
170
+ return { files, candidates };
171
+ }
172
+
173
+ /**
174
+ * Compare the last green record. A source without a stem pair cannot narrow the test gate.
175
+ * @param {{baseline: unknown, current: Awaited<ReturnType<typeof fileBaseline>>, fallback: TestScope, threshold?: number, owned?: string[], kinds?: Record<string, string>}} opts
176
+ * @returns {ChangedScope}
177
+ */
178
+ export function changedScope({ baseline, current, fallback, threshold = DEFAULT_FULL_SUITE_THRESHOLD, owned, kinds }) {
179
+ const old = /** @type {Record<string, string> | null} */ (baseline);
180
+ const candidates = new Set(current?.candidates);
181
+ const fallbackFiles = fallback.scope === 'related' ? fallback.files.filter((file) => !current || candidates.has(file)) : [];
182
+ if (!current || !old || typeof old !== 'object' || Array.isArray(old) || Object.keys(old).length > 200 ||
183
+ Object.entries(old).some(([file, hash]) => !safeFile(file) || (owned && !ownsFile(owned, file, kinds)) || typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash))) {
184
+ return { scope: 'changed', files: fallbackFiles, changedFiles: [], reason: 'no_baseline' };
185
+ }
186
+ const changes = [...new Set([...Object.keys(old), ...Object.keys(current.files)])].filter((file) => old[file] !== current.files[file]).sort();
187
+ if (changes.length > threshold) return { scope: 'changed', files: [], changedFiles: [], reason: 'threshold' };
188
+ const changedFiles = changes.filter((file) => candidates.has(file));
189
+ // B97 also pairs Swift Tests names outside lowercase test directories.
190
+ const isChangedTest = (/** @type {string} */ file) => isTestPath(file) || file.endsWith('Tests.swift');
191
+ const tests = new Set(changedFiles.filter(isChangedTest));
192
+ for (const file of changes.filter((f) => !isChangedTest(f))) {
193
+ const pairs = pairsFor(file, current.candidates).filter(isChangedTest);
194
+ if (pairs.length === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'unpaired' };
195
+ for (const paired of pairs) tests.add(paired);
196
+ }
197
+ if (tests.size === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'no_changes' };
198
+ return { scope: 'changed', files: [...tests].sort(), changedFiles };
199
+ }
200
+
111
201
  /**
112
202
  * One gate argv with every item that is exactly {@link TEST_FILES_TOKEN} replaced by the related
113
203
  * test files, or dropped for a full run. Always a new array; `scoped` is false (and the items
114
204
  * unchanged) when the gate has no such item.
115
- * @param {ReadonlyArray<string>} argv @param {TestScope} scope
205
+ * @param {ReadonlyArray<string>} argv @param {TestScope | ChangedScope} scope
116
206
  * @returns {{argv: string[], scoped: boolean}}
117
207
  * @throws {TypeError} a test path that starts with `-` (it would be read as an option; `block
118
208
  * open --tests` refuses one too).
119
209
  */
120
210
  export function expandArgv(argv, scope) {
121
- if (!argv.includes(TEST_FILES_TOKEN)) return { argv: [...argv], scoped: false };
122
- const files = scope.scope === 'related' ? scope.files : [];
211
+ if (!argv.includes(TEST_FILES_TOKEN) && !argv.includes(CHANGED_FILES_TOKEN)) return { argv: [...argv], scoped: false };
212
+ const files = scope.scope === 'full' ? [] : scope.files;
213
+ const changed = scope.scope === 'changed' ? scope.changedFiles : [];
214
+ if (changed.some((file) => !safeFile(file))) throw new TypeError('expandArgv: unsafe changed path');
123
215
  const dashed = files.find((f) => f.startsWith('-'));
124
216
  if (dashed !== undefined) throw new TypeError(`expandArgv: test path ${JSON.stringify(dashed)} starts with "-"`);
125
- return { argv: argv.flatMap((item) => (item === TEST_FILES_TOKEN ? files : [item])), scoped: true };
217
+ return { argv: argv.flatMap((item) => item === TEST_FILES_TOKEN ? files : item === CHANGED_FILES_TOKEN ? changed : [item]), scoped: true };
126
218
  }
127
219
 
128
220
  /**
129
221
  * The fields a scoped gate's result and ledger row carry: `scope`, plus `test_count` when related
130
222
  * or `reason` when full.
131
- * @param {TestScope} scope
132
- * @returns {{scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold' | 'no_tests'}}
223
+ * @param {TestScope | ChangedScope} scope
224
+ * @returns {{scope: 'changed', test_count: number, reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'} | {scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold'|'no_tests'}}
133
225
  */
134
226
  export function scopeFields(scope) {
227
+ if (scope.scope === 'changed') return { scope: 'changed', test_count: scope.files.length, ...(scope.reason ? { reason: scope.reason } : {}) };
135
228
  return scope.scope === 'related' ? { scope: 'related', test_count: scope.files.length } : { scope: 'full', reason: scope.reason };
136
229
  }
137
230
 
package/src/gates/run.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { exec } from '../util/exec.mjs';
10
10
  import { registerSecret } from '../util/redact.mjs';
11
+ import { CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
11
12
  import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
12
13
 
13
14
  /** @typedef {import('./detect.mjs').Gate} Gate */
@@ -25,9 +26,9 @@ import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } fro
25
26
  * @property {number | null} code
26
27
  * @property {string} stdout
27
28
  * @property {string} stderr
28
- * @property {'related'|'full'} [scope] - B60: only on a gate whose argv has the `{test_files}` item.
29
+ * @property {'related'|'full'|'changed'} [scope] - only on a gate with a scope token.
29
30
  * @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
30
- * @property {'threshold'|'no_tests'} [reason] - B60: with `scope: full`, why the item was dropped.
31
+ * @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'} [reason] - why narrowing fell back.
31
32
  * @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
32
33
  * NAMES, sorted (the values are never in a result).
33
34
  */
@@ -53,7 +54,7 @@ function normalizeCommand(command) {
53
54
  * no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
54
55
  * variables added to the inherited environment; a gate with neither runs exactly as before B60.
55
56
  * @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
56
- * @param {{cwd: string, timeoutMs?: number, scope?: TestScope}} opts
57
+ * @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope}} opts
57
58
  * @returns {Promise<{results: GateResult[], allOk: boolean}>}
58
59
  */
59
60
  export async function runGates(gates, opts) {
@@ -77,8 +78,9 @@ export async function runGates(gates, opts) {
77
78
  const command = gates[gate] ?? null;
78
79
  if (command === null) return { gate, command: null };
79
80
  const normalized = normalizeCommand(command);
81
+ if (normalized.argv[0] === CHANGED_FILES_TOKEN) throw new TypeError(`gates.${gate}: '{changed_files}' cannot be the command (argv[0])`);
80
82
  const { argv, scoped } = expandArgv(normalized.argv, scope);
81
- if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once '{test_files}' is dropped for the full suite — put the test command before it`);
83
+ if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once ${normalized.argv.map((item) => `'${item}'`).join(' and ')} is dropped for the full suite — put the test command before it`);
82
84
  return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
83
85
  });
84
86
  /** @type {GateResult[]} */
@@ -3,7 +3,7 @@
3
3
  * wizard answer (`ANSWER_FLAGS` maps flag -> answer key); `--no-interaction` is a mode switch and
4
4
  * overrides no answer.
5
5
  *
6
- * **Q16 = cut (Ricardo, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
6
+ * **Q16 = cut (the owner, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
7
7
  * v1.2 `--no-nightly` flag is gone and `--proof` takes only the keys the schema still has
8
8
  * (`isolation`, `high`, `link_dirs`, `copy_untracked`). With `-g` and `-p` counted as two flags
9
9
  * (two tokens that each set the scope answer) there are 21 answer flags: B65 added
@@ -16,7 +16,8 @@
16
16
  * with `isolation: lock` through the B6 writer.
17
17
  */
18
18
 
19
- import { hasTestFilesItem, testScope } from '../gates/expand.mjs';
19
+ import { changedScope, fileBaseline, hasScopeItem, testScope } from '../gates/expand.mjs';
20
+ import { readAllRows } from '../ledger/write.mjs';
20
21
  import { ownedFileCount } from '../gates/owned-count.mjs';
21
22
  import { runGates } from '../gates/run.mjs';
22
23
  import { StateError } from '../state/paths.mjs';
@@ -139,23 +140,35 @@ export async function withGateSlot({ runId, blockId, fn, pid = process.pid }) {
139
140
  * run record once the slot is taken). The full suite runs instead when the block owns more than
140
141
  * `threshold` (`gates.full_suite_threshold_files`) files, or when no test file is known. The
141
142
  * owned files are counted by `ownedFileCount` over the tracked and new (not ignored) files at
142
- * `cwd`. Gates with no `{test_files}` item run exactly as before B60: the run record is not read
143
- * and nothing is counted.
144
- * @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number}} opts
143
+ * `cwd`. Plain gates without --changed retain the old path. B99 records bounded owned hashes
144
+ * after green scoped runs; --changed uses the last green record and B97's stem pairs.
145
+ * @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number, changed?: boolean, slug?: string}} opts
145
146
  */
146
- export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold }) {
147
+ export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold, changed = false, slug }) {
147
148
  return withGateSlot({
148
149
  runId,
149
150
  blockId,
150
151
  fn: async () => {
151
- // plain gates (no `{test_files}` item) run exactly as before B60: no scope is worked out
152
- if (!hasTestFilesItem(gates)) return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
152
+ // Plain gates without --changed need no scope or baseline.
153
+ if (!hasScopeItem(gates) && !changed) return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
153
154
  // the slot checked the block is open; it may still be gone by the time it is read again
154
- const block = (await readRun(runId)).blocks?.[blockId];
155
+ const record = await readRun(runId);
156
+ const block = record.blocks?.[blockId];
155
157
  if (!block) throw new StateError('no-block', `block ${blockId} is not in run ${runId}`);
156
158
  const ownedFiles = await ownedFileCount(cwd, block.owned_files, block.owned_kinds);
157
- const scope = testScope({ testFiles: block.test_files, owned: block.owned_files, ownedFiles, ...(threshold === undefined ? {} : { threshold }) });
158
- return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}), scope });
159
+ const fallback = testScope({ testFiles: block.test_files, owned: block.owned_files, ownedFiles, ...(threshold === undefined ? {} : { threshold }) });
160
+ const current = await fileBaseline(cwd, block.owned_files, block.owned_kinds);
161
+ let baseline;
162
+ if (changed) {
163
+ const rows = await readAllRows(slug ?? record.project);
164
+ baseline = rows.filter((row) => row.event === 'gate.done' && row.run === runId && row.block === blockId && row.all_ok === true && row.worktree === block.worktree?.path).at(-1)?.files;
165
+ }
166
+ const scope = changed ? changedScope({ baseline, current, fallback, owned: block.owned_files, kinds: block.owned_kinds, ...(threshold === undefined ? {} : { threshold }) }) : fallback;
167
+ const ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}), scope });
168
+ // A command that changed an input did not measure the resulting bytes. Do not baseline it.
169
+ const after = ran.allOk && current ? await fileBaseline(cwd, block.owned_files, block.owned_kinds) : null;
170
+ const files = after && JSON.stringify(current?.files) === JSON.stringify(after.files) ? after.files : undefined;
171
+ return { ...ran, ledger_slug: slug ?? record.project, ...(files ? { files } : {}), ...(changed ? { changed_scope: scope } : {}) };
159
172
  },
160
173
  });
161
174
  }
@@ -0,0 +1,9 @@
1
+ /** The built-in proof rule carried by every coder context pack. */
2
+ export const PROOF_RULE = [
3
+ "## proof rule (code-forge)",
4
+ "- Red→green: each new or changed test must FAIL on the old code first (red), then PASS on the new code (green). A red caused by an import, compile or crash error proves nothing; the red must be an assertion failure. `code-forge proof red-green` does this with the block's base code.",
5
+ "- No hand mutations. Do not break the code on purpose to test the tests. Red→green is the proof.",
6
+ "- One exact test per acceptance clause, plus one negative or boundary test where the clause can fail. Assertions say which element and how many, never only \"it exists\".",
7
+ "- Proof scales with risk: a light-tier file needs red→green per new test; a high-tier file must have it at the gate. Do not add tests, mutants, layers, options or abstractions that the acceptance does not ask for.",
8
+ "- A weak assertion is fixed by a stronger assertion, not by more tests or mutants.",
9
+ ].join('\n');
@@ -5,9 +5,8 @@
5
5
  * high ⇐ risk ≥ 2, or the path matches `proof.tiers.high.paths`, or `security_sensitive`
6
6
  * light ⇐ everything else (the review-depth band `1 ≤ risk < 2` does NOT raise the proof tier)
7
7
  *
8
- * The light tier's proof is red→green (B10b). The high tier's extra proof — tool-made mutants on
9
- * changed lines — is held on Q16 (block B10c), so `proofRequirements('high')` reports it as
10
- * `held` and the gate enforces red→green only until Ricardo answers. Pure: no I/O.
8
+ * The light tier needs red→green per new or changed test. The high tier must have red→green
9
+ * at the gate. Mutation testing was cut by the owner (Q16). Pure: no I/O.
11
10
  */
12
11
 
13
12
  import { ownsFile } from '../state/registry.mjs';
@@ -50,14 +49,3 @@ export function blockTier(fileTiers) {
50
49
  if (!Array.isArray(fileTiers)) throw new TypeError('blockTier: fileTiers must be an array');
51
50
  return fileTiers.some((t) => t?.tier === 'high') ? 'high' : 'light';
52
51
  }
53
-
54
- /**
55
- * What the gate requires for a tier. `mutants: 'held'` = the high tier's mutation proof is
56
- * specified but not enforced until Q16 is answered (B10c).
57
- * @param {Tier} tier
58
- * @returns {{red_green: true, mutants: 'none' | 'held'}}
59
- */
60
- export function proofRequirements(tier) {
61
- if (!TIERS.includes(tier)) throw new TypeError(`proofRequirements: tier must be one of ${TIERS.join(', ')}`);
62
- return { red_green: true, mutants: tier === 'high' ? 'held' : 'none' };
63
- }
@@ -29,7 +29,8 @@
29
29
  * The summary then carries `schema_fallback: {level, provider, model}` (`level`:
30
30
  * `review.second_levels.<level>`), the second level's own provider/model, and `tokens_in` /
31
31
  * `tokens_out` summed over both sessions;
32
- * 7. a diff alone over the packet budget is `split_required` (with `tokens_in` and `budget`) —
32
+ * 7. a non-Markdown quick packet over budget first steps up to one full L2 session (B109).
33
+ * A diff alone still over the packet budget is `split_required` (with `tokens_in` and `budget`) —
33
34
  * except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
34
35
  * section packet runs the whole plan above (its own sessions, judge and stub guard, its own
35
36
  * `review.plan` row with `section`), in file order, stopping at the first one that is not
@@ -64,15 +65,17 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
64
65
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
65
66
  import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
66
67
  import { tierFor } from '../proof/tiers.mjs';
67
- import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
68
+ import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, isSecretLike, readFileDiff } from './packet.mjs';
68
69
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
69
70
  import { deletionNote, renderDeletionNote } from './deletion.mjs';
70
71
  import { testQualityNote } from './test-quality.mjs';
72
+ import { pairsFor, summarize } from './paired.mjs';
73
+ import { redact } from '../util/redact.mjs';
71
74
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
72
75
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
73
76
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
74
77
  import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
75
- import { assertRowPath } from '../worker/ticket.mjs';
78
+ import { assertRowPath, readRegularFileNoFollow } from '../worker/ticket.mjs';
76
79
 
77
80
  /**
78
81
  * @typedef {{lens: 'quick' | 'full' | 'A' | 'B' | 'judge', role: 'reviewer' | 'judge', level: 'L2' | 'L3', slot: 'A' | 'B' | null, cfg?: Record<string, any>}} SessionSpec -
@@ -185,6 +188,9 @@ export function rulesRisk({ file, plusCount, cfg }) {
185
188
  * code moved between this file and one of them is named in every packet (`## moved code`).
186
189
  * @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader
187
190
  * of those files' diffs (`peerDiffReader`), shared by the caller across the round; wins over `peers`.
191
+ * @property {ReadonlyArray<string>} [ownedFiles] - concrete paths owned by this block.
192
+ * @property {ReadonlyArray<string>} [changedFiles] - concrete changed paths of the same block.
193
+ * @property {ReadonlyArray<string>} [testFiles] - the block's explicit test_files, regardless of stem.
188
194
  */
189
195
 
190
196
  /**
@@ -224,6 +230,31 @@ function safeNeeds(needs) {
224
230
  }
225
231
  }
226
232
 
233
+ /**
234
+ * B98: names only, from owned changed peers and explicit tests in the reviewed tree.
235
+ * Missing, unsafe or unreadable pairs give no hint, never a failed review.
236
+ * @param {{repoRoot: string, file: string, peers?: ReadonlyArray<string>, ownedFiles?: ReadonlyArray<string>, changedFiles?: ReadonlyArray<string>, testFiles?: ReadonlyArray<string>}} input
237
+ * @returns {string}
238
+ */
239
+ export function pairedContext({ repoRoot, file, peers = [], ownedFiles = [], changedFiles = peers, testFiles = [] }) {
240
+ if (ownedFiles.length === 0) return '';
241
+ const owned = new Set(ownedFiles);
242
+ const candidates = [...new Set([...pairsFor(file, changedFiles), ...testFiles])].filter((p) => p !== file && owned.has(p));
243
+ const lines = [];
244
+ for (const candidate of candidates) {
245
+ try {
246
+ const rel = assertRowPath(candidate);
247
+ if (isSecretLike(rel)) continue;
248
+ const bytes = readRegularFileNoFollow(repoRoot, rel);
249
+ if (bytes === null) continue;
250
+ lines.push(`- ${redact(rel)}`, ...summarize(bytes.toString('utf8'), rel).map((name) => ` - ${name}`));
251
+ } catch {
252
+ // The summary is optional; never follow a symlink or substitute another file.
253
+ }
254
+ }
255
+ return lines.join('\n');
256
+ }
257
+
227
258
  /**
228
259
  * @param {ReviewInput} input @param {ReviewDeps} deps
229
260
  * @returns {Promise<{status: string, approved: boolean, engine: string, sessions: Array<Record<string, any>>} & Record<string, any>>}
@@ -234,8 +265,8 @@ export async function reviewFile(input, deps) {
234
265
  const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, cfg });
235
266
  // no recorded kind ⇒ the reviewed file's own kind (a lone `.md` is a docs review), never a blind `code`
236
267
  const kind = typeof input.kind === 'string' ? input.kind : blockKind({ owned: [diff.file] });
237
- const plan = planSessions({ risk, cfg, kind });
238
- const head = { engine: plan.mode, depth: plan.depth, risk, file: diff.file };
268
+ let plan = planSessions({ risk, cfg, kind });
269
+ let head = { engine: plan.mode, depth: plan.depth, risk, file: diff.file };
239
270
  if (plan.refused) return { status: 'refused', reason: plan.refused, approved: false, ...head, sessions: [] };
240
271
  if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
241
272
  mkdirSync(workDir, { recursive: true, mode: 0o700 });
@@ -265,6 +296,7 @@ export async function reviewFile(input, deps) {
265
296
  // B92: a test that reads a source file and asserts on its text gets the `## test-quality` hint
266
297
  // (line numbers only), read once from the current content; '' for none
267
298
  const quality = testQualityNote(diff.file, diff.content);
299
+ const paired = pairedContext(input);
268
300
  /** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
269
301
  const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys), quality].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
270
302
  const min = minTokensOut(cfg);
@@ -280,7 +312,7 @@ export async function reviewFile(input, deps) {
280
312
  const paths = safeNeeds(needs);
281
313
  let extra = null;
282
314
  try {
283
- extra = paths ? await attachFiles({ repoRoot, paths, budgetTokens: budgetFor(cfg, 'full_in') }) : null;
315
+ extra = paths ? await attachFiles({ repoRoot, paths, budgetTokens: budgetFor(cfg, 'full_in'), ownedFiles: input.ownedFiles, changedFiles: input.changedFiles ?? input.peers ?? [] }) : null;
284
316
  } catch {
285
317
  extra = null;
286
318
  }
@@ -290,10 +322,10 @@ export async function reviewFile(input, deps) {
290
322
  return summary;
291
323
  };
292
324
 
293
- const reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
294
- const judgeSpec = plan.sessions.find((s) => s.role === 'judge');
325
+ let reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
326
+ let judgeSpec = plan.sessions.find((s) => s.role === 'judge');
295
327
  /** @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<number> | null} [keys] */
296
- const packetsFor = (part, keys = null) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg, moved: movedOf(keys) }));
328
+ const packetsFor = (part, keys = null) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg, moved: movedOf(keys), paired }));
297
329
 
298
330
  /**
299
331
  * The plan on one packet set (the whole file, or one Markdown section): reviewers, then the judge.
@@ -327,10 +359,25 @@ export async function reviewFile(input, deps) {
327
359
  return { status: 'reviewed', approved, summary: final.review.summary, findings, sessions };
328
360
  };
329
361
 
330
- const packets = packetsFor(diff);
331
- const split = /** @type {{status: 'split_required', tokensIn: number, budget: number} | undefined} */ (packets.find((p) => p.status === 'split_required'));
362
+ let packets = packetsFor(diff);
363
+ let split = /** @type {{status: 'split_required', tokensIn: number, budget: number} | undefined} */ (packets.find((p) => p.status === 'split_required'));
364
+ /** @type {Record<string, any>} */
365
+ let stepUp = {};
366
+ if (split && plan.depth === 'quick' && !isMarkdownFile(diff.file)) {
367
+ const full = planSessions({ risk: 1, cfg, kind });
368
+ // A configured consensus/refusal is not the single full session this step permits.
369
+ if (full.depth === 'full') {
370
+ stepUp = { step_up: { from: 'quick', tokens_in: split.tokensIn, budget: split.budget } };
371
+ plan = full;
372
+ head = { ...head, engine: plan.mode, depth: plan.depth };
373
+ reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
374
+ judgeSpec = plan.sessions.find((s) => s.role === 'judge');
375
+ packets = packetsFor(diff);
376
+ split = /** @type {typeof split} */ (packets.find((p) => p.status === 'split_required'));
377
+ }
378
+ }
332
379
  if (!split) {
333
- const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets);
380
+ const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets, stepUp);
334
381
  if (status !== 'reviewed') return { status, reason, approved: false, ...head, sessions };
335
382
  return { status, approved, ...head, summary, findings, sessions };
336
383
  }
@@ -93,6 +93,7 @@ import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
93
93
  import { parseDiff } from './context.mjs';
94
94
  import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
95
95
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
96
+ import { pairedContext } from './engine.mjs';
96
97
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
97
98
  import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
98
99
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
@@ -125,6 +126,7 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
125
126
  * @property {string | null} reviewed_content - the content the last counted round reviewed.
126
127
  * @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
127
128
  * @property {'open' | 'complete' | 'stopped'} status
129
+ * @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
128
130
  * @property {NextStep | null} next
129
131
  * @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck round's context: one packet's, or over a Markdown recheck's section packets the summed lines and the one mode they share, else `mixed` (B54).
130
132
  * @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
@@ -144,6 +146,9 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
144
146
  * @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
145
147
  * @property {string | null} [base] - the block base (`recheck_scope: file`, and the B56 moved-code diff).
146
148
  * @property {ReadonlyArray<string>} [peers] - B56: the block's other changed files (moved-code hints).
149
+ * @property {ReadonlyArray<string>} [ownedFiles] - concrete paths owned by this block.
150
+ * @property {ReadonlyArray<string>} [changedFiles] - concrete changed paths of the same block.
151
+ * @property {ReadonlyArray<string>} [testFiles] - the block's explicit test_files.
147
152
  * @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader of their diffs (`peerDiffReader`); wins over `peers`.
148
153
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
149
154
  * @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
@@ -362,11 +367,11 @@ export function recheckTokens(diff, open, moved = '') {
362
367
  /**
363
368
  * The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
364
369
  * the open findings by id. Over budget ⇒ `minimal` (see the module doc).
365
- * @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string}} opts -
370
+ * @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string, paired?: string}} opts -
366
371
  * `moved`: the file's `## moved code` section (B56), '' for none.
367
372
  */
368
- export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '' }) {
369
- const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved });
373
+ export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '', paired = '' }) {
374
+ const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved, paired });
370
375
  if (packet.status !== 'ok') return packet;
371
376
  const text = `${packet.text}${openFindingsText(open)}`;
372
377
  return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
@@ -436,12 +441,21 @@ export function insideHunks(f, hunks) {
436
441
  * (`schema-fallback.mjs`; rechecks and the patch_check alike).
437
442
  * @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
438
443
  * @param {LoopDeps} deps
444
+ * @param {ReadonlyArray<Finding>} open - only the findings listed in this packet.
439
445
  */
440
- async function recheckSession(packet, deps) {
446
+ async function recheckSession(packet, deps, open) {
441
447
  mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
442
448
  const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
443
449
  writeFileSync(promptPath, packet.text, { mode: 0o600 });
444
450
  const min = minTokensOut(deps.cfg);
451
+ /** @param {Record<string, any> | null} result @returns {import('./validate-review.mjs').Verdict} */
452
+ const validate = (result) => {
453
+ const verdict = validateReview(result, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
454
+ if (!verdict.ok) return verdict;
455
+ const ids = new Set((verdict.review?.resolved ?? []).map((/** @type {{id: string}} */ entry) => entry.id));
456
+ if (open.some((finding) => !ids.has(finding.id))) return { ok: false, reason: 'schema', detail: 'resolved does not cover the packet open findings' };
457
+ return verdict;
458
+ };
445
459
  const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
446
460
  // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
447
461
  // (each helper handles its own failed write)
@@ -485,7 +499,7 @@ async function recheckSession(packet, deps) {
485
499
  } catch {
486
500
  res = null; // the helper never throws; if it ever did, it is still an `exit` failure
487
501
  }
488
- verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
502
+ verdict = validate(res);
489
503
  if (first?.to) {
490
504
  // B91: a timed-out attempt was the packet's second broken result; the second level ran. The
491
505
  // verdict is already set: whatever throws after it leaves that verdict, never an exception
@@ -510,7 +524,7 @@ async function recheckSession(packet, deps) {
510
524
  const second = await tryNext(res, 'schema');
511
525
  if (second) {
512
526
  // compute everything first, then assign together, so a throw never leaves a mixed state
513
- const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
527
+ const secondVerdict = validate(second.res);
514
528
  const sumIn = sum(tokensIn, second.res?.usage?.tokens_in);
515
529
  const sumOut = sum(tokensOut, second.res?.usage?.tokens_out);
516
530
  res = second.res;
@@ -607,11 +621,11 @@ function retry(state, kind, reason) {
607
621
  * not written, the open set stays as it was, and the gate-relevant `review.cap` row goes through
608
622
  * the must-write path — a failed write is a `retry`, never a silent stop.
609
623
  * @param {FileState} state @param {LoopDeps} deps
610
- * @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file
624
+ * @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file @param {string} contentHash
611
625
  * @param {{status: string, tokensIn?: number, budget?: number, section?: string | null}} packet - the non-ok packet (a Markdown section names its heading).
612
626
  * @returns {Promise<FileState>}
613
627
  */
614
- async function stopBeforeRound(state, deps, kind, file, packet) {
628
+ async function stopBeforeRound(state, deps, kind, file, contentHash, packet) {
615
629
  const reason = packet.status;
616
630
  const size =
617
631
  typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
@@ -620,6 +634,7 @@ async function stopBeforeRound(state, deps, kind, file, packet) {
620
634
  const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
621
635
  if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
622
636
  state.status = 'stopped';
637
+ state.stop_content_hash = contentHash;
623
638
  state.next = { action: 'stop', reason, ...size };
624
639
  state.pending_kind = null;
625
640
  return state;
@@ -664,6 +679,11 @@ export async function runRound(state, deps, opts = {}) {
664
679
  s.scope === 'file'
665
680
  ? await readFileDiff({ repoRoot: deps.repoRoot, file: current.rel, base: deps.base ?? null })
666
681
  : await fixHunkDiff({ file: current.rel, previous: state.reviewed_content ?? '', current: current.content, workDir: deps.workDir });
682
+ if (kind === 'recheck' && (current.content === state.reviewed_content || diff.hunks.length === 0)) {
683
+ state.next = { action: 'fix', level: state.level, trigger: null, reason: 'no-fix-hunks' };
684
+ state.pending_kind = null;
685
+ return state;
686
+ }
667
687
  if (diff.hunks.length > 0) {
668
688
  /** @type {Finding[]} */
669
689
  const pending = [];
@@ -676,7 +696,8 @@ export async function runRound(state, deps, opts = {}) {
676
696
  if (pending.length > 0) {
677
697
  const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
678
698
  const moves = await recheckMoves(deps, current.rel, s.scope === 'file' ? diff : null);
679
- const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null) });
699
+ const paired = pairedContext({ ...deps, file: current.rel });
700
+ const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null), paired });
680
701
  /** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[], keys: number[] | null}>} */
681
702
  let parts = [{ index: null, heading: null, diff, open: pending, keys: null }];
682
703
  if (whole.status !== 'ok') {
@@ -684,7 +705,7 @@ export async function runRound(state, deps, opts = {}) {
684
705
  // a Markdown file whose packet is `split_required`, rechecked section by section (B54);
685
706
  // any other status stops as it always did
686
707
  const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget, moves) : null;
687
- if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
708
+ if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, current.hash, packed ?? whole);
688
709
  parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open, keys: sec.keys }));
689
710
  }
690
711
  // EVERY packet is built and held to the budget BEFORE any session runs: a section over it
@@ -697,11 +718,11 @@ export async function runRound(state, deps, opts = {}) {
697
718
  continue;
698
719
  }
699
720
  const moved = movedText(moves, diff.content, part.keys);
700
- const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved });
721
+ const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved, paired });
701
722
  // a packet that fails to build stops with ITS status (and size, when it has one)
702
- if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
723
+ if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, current.hash, { ...packet, section: part.heading });
703
724
  const size = recheckTokens(part.diff, part.open, moved);
704
- if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
725
+ if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, current.hash, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
705
726
  built.push(packet);
706
727
  }
707
728
  /** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
@@ -713,7 +734,7 @@ export async function runRound(state, deps, opts = {}) {
713
734
  for (const [i, part] of parts.entries()) {
714
735
  const packet = /** @type {any} */ (built[i]);
715
736
  packetInfo = mergePacketInfo(packetInfo, packet);
716
- const session = await recheckSession(packet, deps);
737
+ const session = await recheckSession(packet, deps, part.open);
717
738
  tokensIn = sum(tokensIn, session.tokens_in);
718
739
  tokensOut = sum(tokensOut, session.tokens_out);
719
740
  if (!session.verdict.ok) {
@@ -743,7 +764,7 @@ export async function runRound(state, deps, opts = {}) {
743
764
  draft.open = [...stillOpen, ...triaged.fix_now, ...blocking];
744
765
  }
745
766
  }
746
- // no fix hunks (the content did not change): nothing closes — the round stalls
767
+ // A patch_check with no fix hunks closed nothing: B73 still stops it.
747
768
  }
748
769
 
749
770
  if (kind !== 'patch_check') {
@@ -785,7 +806,10 @@ export async function runRound(state, deps, opts = {}) {
785
806
  const after = new Set(draft.open.map((f) => f.id));
786
807
  const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
787
808
  draft.next = decideNext(draft, openBefore, s, kind, closedIds);
788
- if (draft.next.action === 'stop') draft.status = 'stopped';
809
+ if (draft.next.action === 'stop') {
810
+ draft.status = 'stopped';
811
+ draft.stop_content_hash = current.hash;
812
+ }
789
813
  }
790
814
  const rows = [...lateRows, roundRow];
791
815
  if (draft.status === 'stopped') rows.push({ event: 'review.cap', file: current.rel, round: draft.round, reason: draft.next.reason, open: draft.open.map((f) => f.id) });
@@ -828,6 +852,7 @@ export async function converge(state, deps) {
828
852
  }
829
853
  await deps.fix?.({ file: state.file, round: state.round + 1, level: state.level, open: [...state.open], l3_patch: state.l3_rung_used });
830
854
  await runRound(state, deps);
855
+ if (state.next?.reason === 'no-fix-hunks') return state;
831
856
  }
832
857
  return state;
833
858
  }