@codedology/code-forge 0.14.1 → 0.15.1

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 (41) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +495 -1
  4. package/docs/reference/config.md +7 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +22 -0
  7. package/scripts/first-use.mjs +11 -5
  8. package/skill/SKILL.md +4 -0
  9. package/skill/references/autopilot.md +2 -0
  10. package/skill/references/code.md +28 -2
  11. package/skill/references/review.md +10 -2
  12. package/skill/references/security.md +2 -0
  13. package/skill/scripts/forge +1 -1
  14. package/skill/templates/coder-brief.md +3 -1
  15. package/src/autopilot/scopes.mjs +2 -1
  16. package/src/cli/block.mjs +271 -50
  17. package/src/cli/gates.mjs +36 -6
  18. package/src/cli/ledger.mjs +21 -2
  19. package/src/cli/report.mjs +14 -0
  20. package/src/cli/review-file.mjs +56 -16
  21. package/src/cli/run.mjs +44 -7
  22. package/src/cli/status.mjs +7 -3
  23. package/src/config/validate.mjs +13 -1
  24. package/src/gates/acceptance.mjs +7 -3
  25. package/src/gates/expand.mjs +25 -2
  26. package/src/gates/run.mjs +38 -8
  27. package/src/ledger/report.mjs +29 -7
  28. package/src/review/accept-hit.mjs +22 -4
  29. package/src/review/engine.mjs +30 -10
  30. package/src/review/finding.schema.json +2 -1
  31. package/src/review/fixloop.mjs +79 -14
  32. package/src/review/gate-check.mjs +33 -7
  33. package/src/review/lenses/recheck.md +3 -0
  34. package/src/review/packet.mjs +26 -0
  35. package/src/state/block.mjs +196 -22
  36. package/src/state/run.mjs +35 -8
  37. package/src/util/forbidden.mjs +230 -6
  38. package/src/util/reaper.mjs +3 -3
  39. package/src/worker/engine.mjs +74 -6
  40. package/src/worker/loop.mjs +19 -10
  41. package/src/worker/queue.mjs +38 -8
package/src/gates/run.mjs CHANGED
@@ -9,12 +9,18 @@
9
9
  import { exec } from '../util/exec.mjs';
10
10
  import { registerSecret } from '../util/redact.mjs';
11
11
  import { CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
12
- import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
12
+ import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
13
13
 
14
14
  /** @typedef {import('./detect.mjs').Gate} Gate */
15
15
  /** @typedef {import('./detect.mjs').GateCommand} GateCommand */
16
16
  /** @typedef {import('./detect.mjs').DetectedGates} DetectedGates */
17
17
  /** @typedef {import('./expand.mjs').TestScope} TestScope */
18
+ /** @typedef {{n: number, files: string[], failed: boolean}} Repeats */
19
+ /**
20
+ * Internal block context carried with the gate plan through the proof lock, so repeats run
21
+ * inside the same slot as the initial commands.
22
+ * @typedef {{n: number, base: string, owned: string[], kinds?: Record<string, string>, onSkip?: (line: string) => void}} RepeatNewTests
23
+ */
18
24
 
19
25
  /**
20
26
  * @typedef {object} GateResult
@@ -28,7 +34,8 @@ import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } fro
28
34
  * @property {string} stderr
29
35
  * @property {'related'|'full'|'changed'} [scope] - only on a gate with a scope token.
30
36
  * @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
31
- * @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'} [reason] - why narrowing fell back.
37
+ * @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'|'flaky-test'} [reason] - fallback or repeat failure.
38
+ * @property {Repeats} [repeats]
32
39
  * @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
33
40
  * NAMES, sorted (the values are never in a result).
34
41
  */
@@ -53,16 +60,16 @@ function normalizeCommand(command) {
53
60
  * item that is exactly `{test_files}` expands to its files, or is dropped for a full run. Left out,
54
61
  * no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
55
62
  * variables added to the inherited environment; a gate with neither runs exactly as before B60.
56
- * @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
63
+ * @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate, repeatNewTests?: RepeatNewTests, lightTestFiles?: string[]}} gates
57
64
  * @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope}} opts
58
- * @returns {Promise<{results: GateResult[], allOk: boolean}>}
65
+ * @returns {Promise<{results: GateResult[], allOk: boolean, repeats?: Repeats}>}
59
66
  */
60
67
  export async function runGates(gates, opts) {
61
68
  if (!gates || typeof gates !== 'object') {
62
69
  throw new TypeError('runGates: gates must be an object with test/lint/types/format');
63
70
  }
64
71
  const { cwd, timeoutMs = 300000 } = opts ?? {};
65
- const scope = opts?.scope ?? NO_BLOCK_SCOPE; // null falls back too
72
+ const scope = gates.lightTestFiles ? { scope: /** @type {const} */ ('related'), files: gates.lightTestFiles } : opts?.scope ?? NO_BLOCK_SCOPE;
66
73
  if (typeof cwd !== 'string' || cwd.length === 0) {
67
74
  throw new TypeError('runGates: opts.cwd must be a non-empty string');
68
75
  }
@@ -81,7 +88,7 @@ export async function runGates(gates, opts) {
81
88
  if (normalized.argv[0] === CHANGED_FILES_TOKEN) throw new TypeError(`gates.${gate}: '{changed_files}' cannot be the command (argv[0])`);
82
89
  const { argv, scoped } = expandArgv(normalized.argv, scope);
83
90
  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`);
84
- return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
91
+ return { gate, command: { argv, originalArgv: normalized.argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
85
92
  });
86
93
  /** @type {GateResult[]} */
87
94
  const results = [];
@@ -95,7 +102,7 @@ export async function runGates(gates, opts) {
95
102
  // next, so a red gate is reported red without swallowing the gates that follow it.
96
103
  const res = await exec(argv, env ? { cwd, timeoutMs, env: { ...process.env, ...env } } : { cwd, timeoutMs });
97
104
  const ok = res.result === 'ok' && (!failOnStdout || res.stdout.trim().length === 0);
98
- results.push({
105
+ const result = /** @type {GateResult} */ ({
99
106
  gate,
100
107
  argv,
101
108
  skipped: false,
@@ -106,6 +113,29 @@ export async function runGates(gates, opts) {
106
113
  ...(scoped ? scopeFields(scope) : {}),
107
114
  ...(env ? { env_keys: Object.keys(env).sort() } : {}),
108
115
  });
116
+ const repeat = gates.repeatNewTests;
117
+ if (gate === 'test' && ok && repeat) {
118
+ if (!command.originalArgv.includes(TEST_FILES_TOKEN)) {
119
+ repeat.onSkip?.('repeat_new_tests: skipped (gates.test has no {test_files})');
120
+ } else {
121
+ const files = (await changedTestFiles({ cwd, ...repeat })).filter((file) => !gates.lightTestFiles || gates.lightTestFiles.includes(file));
122
+ if (files.length > 0) {
123
+ result.repeats = { n: repeat.n, files, failed: false };
124
+ const repeatArgv = expandArgv(command.originalArgv, { scope: 'related', files }).argv;
125
+ for (let i = 0; i < repeat.n; i++) {
126
+ const repeated = await exec(repeatArgv, env ? { cwd, timeoutMs, env: { ...process.env, ...env } } : { cwd, timeoutMs });
127
+ const passed = repeated.result === 'ok' && (!failOnStdout || repeated.stdout.trim().length === 0);
128
+ // Keep the first failed run's output, even when a later repeat passes.
129
+ if (!passed && !result.repeats.failed) {
130
+ result.repeats.failed = true;
131
+ Object.assign(result, { ok: false, reason: 'flaky-test', argv: repeatArgv, code: repeated.code, stdout: repeated.stdout, stderr: repeated.stderr });
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ results.push(result);
109
138
  }
110
- return { results, allOk: results.every((r) => r.ok) };
139
+ const repeats = results.find((r) => r.repeats)?.repeats;
140
+ return { results, allOk: results.every((r) => r.ok), ...(repeats ? { repeats } : {}) };
111
141
  }
@@ -6,7 +6,8 @@
6
6
  * answers are "never acted on" — must not move a single aggregate).
7
7
  *
8
8
  * Row conventions read here: `dispatch` {block,level,lane,lines}; `block.close`
9
- * {block,status,lines_actual} — ANY `block.close` row marks the block completed (a stop is
9
+ * {block,status,lines_actual} — a `block.close` row marks the block completed unless followed by
10
+ * `block.awaiting_human_check`; `block.human_pass` completes the waiting block (a stop is
10
11
  * `block.stop`; B19); `review.plan` {block,tier,depth_unconstrained,depth_chosen,
11
12
  * degrade_step}; `session` {block,role,provider,level,tokens_in,tokens_out,usd,usd_unknown?} — one row per
12
13
  * coder/reviewer/judge/s2/author/facts spend (plus `ledger add coder` manual rows and
@@ -81,7 +82,7 @@ export function buildRunModes(rows) {
81
82
 
82
83
  /**
83
84
  * Cost per block — completed AND still open (B33: a running total while the block is open):
84
- * review $ / coder $ / S1+S2 $ / facts $ / proof ms, `completed` (any `block.close` row) and
85
+ * review $ / coder $ / S1+S2 $ / facts $ / proof ms, `completed` (code close with no pending human check) and
85
86
  * `unknownUsdSessions` (spend rows whose price is unknown, counted 0 in the totals). Grouped by
86
87
  * run + block, so a block id reused in a second run is its own entry; a row without a run joins
87
88
  * the entry of its block when that block id appears in exactly one run (else a `run: null` entry); shadow rows are dropped here too, exactly as `buildSpend` does. An entry is listed
@@ -92,6 +93,7 @@ function costPerBlock(rows) {
92
93
  const byBlock = new Map();
93
94
  /** @type {Set<string>} run+block keys with at least one spend row */
94
95
  const spent = new Set();
96
+ const closedKeys = new Set();
95
97
  // never split one block: a row without a run joins its block's entry when that block id appears
96
98
  // in exactly one run; only a block seen in several runs leaves such a row in a `run: null` entry
97
99
  /** @type {Map<string, Set<string>>} */
@@ -133,7 +135,9 @@ function costPerBlock(rows) {
133
135
  e.level = row.level ?? e.level;
134
136
  e.lane = row.lane ?? e.lane;
135
137
  }
136
- if (row.event === 'block.close') e.completed = true;
138
+ if (row.event === 'block.close') { e.completed = true; closedKeys.add(key); }
139
+ if (row.event === 'block.awaiting_human_check') e.completed = false;
140
+ if (row.event === 'block.human_pass') e.completed = true;
137
141
  if (isSpendRow(row)) {
138
142
  spent.add(key);
139
143
  const cost = sessionCostUsd(row);
@@ -150,7 +154,7 @@ function costPerBlock(rows) {
150
154
  }
151
155
  if (row.event === 'proof' && typeof row.duration_ms === 'number') e.proofTimeMs += row.duration_ms;
152
156
  }
153
- const out = [...byBlock.entries()].filter(([key, e]) => e.completed || spent.has(key)).map(([, e]) => e);
157
+ const out = [...byBlock.entries()].filter(([key, e]) => e.completed || spent.has(key) || closedKeys.has(key)).map(([, e]) => e);
154
158
  for (const e of out) {
155
159
  for (const k of ['reviewUsd', 'coderUsd', 's1s2Usd', 'factsUsd', 'otherUsd', 'totalUsd']) e[k] = Math.round(e[k] * 10000) / 10000;
156
160
  }
@@ -244,9 +248,17 @@ function proofTimePerBlock(rows) {
244
248
 
245
249
  function forecastVsActualLines(rows) {
246
250
  const forecasts = new Map(rows.filter((r) => r.event === 'dispatch').map((r) => [r.block, r.lines]));
247
- return rows
248
- .filter((r) => r.event === 'block.close' && typeof r.lines_actual === 'number')
249
- .map((r) => ({ block: r.block, forecast: forecasts.get(r.block) ?? null, actual: r.lines_actual }));
251
+ const key = (r) => JSON.stringify([r.run ?? null, r.block]);
252
+ const gates = new Map(rows.filter((r) => r.event === 'gate.done').map((r) => [key(r), r]));
253
+ const closed = rows.filter((r) => r.event === 'block.close' && typeof r.lines_actual === 'number');
254
+ const closedKeys = new Set(closed.map(key));
255
+ const repeatCount = (r) => gates.get(key(r))?.repeats?.n;
256
+ return [
257
+ ...closed.map((r) => ({ block: r.block, forecast: forecasts.get(r.block) ?? null, actual: r.lines_actual,
258
+ ...(repeatCount(r) !== undefined ? { repeats: repeatCount(r) } : {}) })),
259
+ ...[...gates.values()].filter((r) => r.repeats && !closedKeys.has(key(r)))
260
+ .map((r) => ({ block: r.block, forecast: forecasts.get(r.block) ?? null, actual: null, repeats: r.repeats.n })),
261
+ ];
250
262
  }
251
263
 
252
264
  const blocksStoppedAtL3 = (rows) => rows.filter((r) => r.event === 'block.stop' && r.level === 'L3').map((r) => r.block);
@@ -317,5 +329,15 @@ export function buildReport(rows) {
317
329
  /** @type {Record<string, any>} */
318
330
  const sections = {};
319
331
  for (const name of SECTION_NAMES) sections[name] = builders[name](acted);
332
+ // Preserve the existing report shape for projects that have no human-check blocks.
333
+ if (acted.some((r) => r.event === 'block.awaiting_human_check')) {
334
+ const waiting = new Map();
335
+ for (const row of acted) {
336
+ const key = `${row.run ?? ''}\u0000${row.block}`;
337
+ if (row.event === 'block.awaiting_human_check') waiting.set(key, row.block);
338
+ if (['block.human_pass', 'block.stop', 'dispatch'].includes(row.event)) waiting.delete(key);
339
+ }
340
+ sections.awaiting_human_check = [...new Set(waiting.values())].sort();
341
+ }
320
342
  return { sections };
321
343
  }
@@ -1,4 +1,4 @@
1
- /** Human acceptance of one false transcript hit, bound to its rule and exact line bytes. */
1
+ /** Human acceptance of false transcript hits, bound to each rule and exact line bytes. */
2
2
  import { createHash } from 'node:crypto';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { StateError } from '../state/paths.mjs';
@@ -27,7 +27,7 @@ export async function prepareAcceptHit({ runId, id, flags, positionals, extraTok
27
27
  // eslint-disable-next-line no-control-regex
28
28
  if (typeof value === 'string' && /[\x00-\x1f\x7f]/.test(value)) throw new StateError('usage', `${name === 'block' ? 'block id' : `--${name}`} must not contain control characters`);
29
29
  }
30
- for (const name of ['rule', 'transcript', 'reason']) {
30
+ for (const name of [...(flags.rule !== undefined ? ['rule'] : []), 'transcript', 'reason']) {
31
31
  if (typeof flags[name] !== 'string' || flags[name].trim().length === 0) throw new StateError('usage', `--${name} is required`);
32
32
  }
33
33
  if (typeof flags.line !== 'string' || !/^-?\d+$/.test(flags.line) || !Number.isSafeInteger(Number(flags.line))) throw new StateError('usage', '--line must be an integer');
@@ -42,8 +42,26 @@ export async function prepareAcceptHit({ runId, id, flags, positionals, extraTok
42
42
  throw noSuchHit();
43
43
  }
44
44
  const lines = text.split('\n');
45
- if (line < 1 || line > lines.length || !scanTranscript(text, mergeForbidden(extraTokens)).some((hit) => hit.id === flags.rule && hit.line === line)) throw noSuchHit();
46
- return { record, row: { block: id, rule: /** @type {string} */ (flags.rule), line, line_sha256: lineHash(lines[line - 1]), reason: /** @type {string} */ (flags.reason.trim()), by: 'human' } };
45
+ const hits = scanTranscript(text, mergeForbidden(extraTokens));
46
+ if (line < 1 || line > lines.length || !hits.some((hit) => hit.line === line && (flags.rule === undefined || hit.id === flags.rule))) throw noSuchHit();
47
+ const selected = new Set([line]);
48
+ if (flags.rule === undefined) {
49
+ // Pair only recognized Codex JSON logs; arbitrary JSON-looking plain text has no item pair.
50
+ try {
51
+ const events = lines.map((s) => s.trim() ? JSON.parse(s) : undefined);
52
+ const nonEmpty = events.filter((event) => event !== undefined);
53
+ const itemId = events[line - 1]?.item?.id;
54
+ if (nonEmpty[0]?.type === 'thread.started' && nonEmpty.every((event) => event !== null && typeof event === 'object' && !Array.isArray(event) && typeof event.type === 'string') &&
55
+ events[line - 1]?.type?.startsWith('item.') && typeof itemId === 'string') {
56
+ events.forEach((event, index) => {
57
+ if (event?.type === 'item.completed' && event.item?.id === itemId) selected.add(index + 1);
58
+ });
59
+ }
60
+ } catch { /* plain text: accept only the requested line */ }
61
+ }
62
+ const rows = hits.filter((hit) => selected.has(hit.line) && (flags.rule === undefined || hit.id === flags.rule))
63
+ .map((hit) => ({ block: id, rule: hit.id, line: hit.line, line_sha256: lineHash(lines[hit.line - 1]), reason: /** @type {string} */ (flags.reason.trim()), by: 'human' }));
64
+ return { record, rows };
47
65
  }
48
66
 
49
67
  /**
@@ -29,7 +29,7 @@
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 non-Markdown quick packet over budget first steps up to one full L2 session (B109).
32
+ * 7. a quick packet over budget first steps up to one full L2 session, including Markdown.
33
33
  * A diff alone still over the packet budget is `split_required` (with `tokens_in` and `budget`) —
34
34
  * except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
35
35
  * section packet runs the whole plan above (its own sessions, judge and stub guard, its own
@@ -65,7 +65,7 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
65
65
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
66
66
  import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
67
67
  import { tierFor } from '../proof/tiers.mjs';
68
- import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, isSecretLike, readFileDiff } from './packet.mjs';
68
+ import { assembleJudgePacket, assembleLightPacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, isSecretLike, readFileDiff } from './packet.mjs';
69
69
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
70
70
  import { deletionNote, renderDeletionNote } from './deletion.mjs';
71
71
  import { testQualityNote } from './test-quality.mjs';
@@ -158,17 +158,17 @@ export function planSessions({ risk, cfg, kind = 'code' }) {
158
158
 
159
159
  /**
160
160
  * The rules risk (§3.5 fallback; S1 `risk` without Jev): a `proof.tiers.high.paths` match ⇒ 3,
161
- * a migration/policy/middleware path ⇒ 2, more than 200 added lines ⇒ 1, else 0.
162
- * @param {{file: string, plusCount: number, cfg: Record<string, any>}} opts
161
+ * a migration/policy/middleware path ⇒ 2, more than 200 changed lines ⇒ 1, else 0.
162
+ * @param {{file: string, plusCount: number, minusCount?: number, cfg: Record<string, any>}} opts
163
163
  * @returns {number}
164
164
  */
165
- export function rulesRisk({ file, plusCount, cfg }) {
165
+ export function rulesRisk({ file, plusCount, minusCount = 0, cfg }) {
166
166
  const highPaths = cfg?.proof?.tiers?.high?.paths;
167
167
  const pathFloorHit = tierFor({ file, risk: 0, highPaths: Array.isArray(highPaths) ? highPaths : [] }).reason === 'path';
168
168
  return fallbackRisk({
169
169
  pathFloorHit,
170
170
  filesChanged: 1,
171
- linesAdded: plusCount,
171
+ linesAdded: plusCount + minusCount,
172
172
  touchesMigration: isMigrationPath(file),
173
173
  touchesPolicyOrMiddleware: isPolicyPath(file),
174
174
  });
@@ -262,7 +262,7 @@ export function pairedContext({ repoRoot, file, peers = [], ownedFiles = [], cha
262
262
  export async function reviewFile(input, deps) {
263
263
  const { repoRoot, cfg, workDir } = input;
264
264
  const diff = await readFileDiff({ repoRoot, file: input.file, base: input.base ?? null });
265
- const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, cfg });
265
+ const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, minusCount: diff.minusCount, cfg });
266
266
  // no recorded kind ⇒ the reviewed file's own kind (a lone `.md` is a docs review), never a blind `code`
267
267
  const kind = typeof input.kind === 'string' ? input.kind : blockKind({ owned: [diff.file] });
268
268
  let plan = planSessions({ risk, cfg, kind });
@@ -363,13 +363,13 @@ export async function reviewFile(input, deps) {
363
363
  let split = /** @type {{status: 'split_required', tokensIn: number, budget: number} | undefined} */ (packets.find((p) => p.status === 'split_required'));
364
364
  /** @type {Record<string, any>} */
365
365
  let stepUp = {};
366
- if (split && plan.depth === 'quick' && !isMarkdownFile(diff.file)) {
366
+ if (split && plan.depth === 'quick') {
367
367
  const full = planSessions({ risk: 1, cfg, kind });
368
368
  // A configured consensus/refusal is not the single full session this step permits.
369
369
  if (full.depth === 'full') {
370
370
  stepUp = { step_up: { from: 'quick', tokens_in: split.tokensIn, budget: split.budget } };
371
371
  plan = full;
372
- head = { ...head, engine: plan.mode, depth: plan.depth };
372
+ head = { ...head, engine: plan.mode, depth: plan.depth, ...stepUp };
373
373
  reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
374
374
  judgeSpec = plan.sessions.find((s) => s.role === 'judge');
375
375
  packets = packetsFor(diff);
@@ -431,7 +431,7 @@ export async function reviewFile(input, deps) {
431
431
  const summaries = [];
432
432
  let approved = true;
433
433
  for (const [i, section] of packed.sections.entries()) {
434
- const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length }, section.keys);
434
+ const part = await reviewPart(section.diff, sectionPackets[i], { ...stepUp, section: section.index, sections: packed.sections.length }, section.keys);
435
435
  sessions.push(...part.sessions.map((s) => ({ ...s, section: section.index })));
436
436
  if (part.status !== 'reviewed') return { status: part.status, reason: part.reason, approved: false, ...head, sections, sessions };
437
437
  approved = approved && part.approved;
@@ -441,6 +441,26 @@ export async function reviewFile(input, deps) {
441
441
  return { status: 'reviewed', approved, ...head, summary: summaries.join('\n'), findings, sessions, sections };
442
442
  }
443
443
 
444
+ /** A light round has one reviewer and no recheck/triage loop.
445
+ * @param {{repoRoot: string, files: string[], base: string | null, cfg: Record<string, any>, workDir: string, rulesDigest?: string, factsExcerpt?: string}} input
446
+ * @param {ReviewDeps} deps
447
+ * @returns {Promise<import('../worker/engine.mjs').ReviewOutcome>}
448
+ */
449
+ export async function reviewLightBatch(input, deps) {
450
+ const diffs = await Promise.all(input.files.map((file) => readFileDiff({ repoRoot: input.repoRoot, file, base: input.base })));
451
+ const packet = assembleLightPacket({ ...input, diffs });
452
+ const head = { engine: 'adaptive', depth: 'full' };
453
+ if (packet.status !== 'ok') return { ...head, status: 'split_required', approved: false, tokens_in: packet.tokensIn, budget: packet.budget, sessions: [] };
454
+ mkdirSync(input.workDir, { recursive: true, mode: 0o700 });
455
+ const final = await runOne({ lens: 'full', role: 'reviewer', level: 'L2', slot: null }, packet.text, packet,
456
+ { deps, workDir: input.workDir, min: minTokensOut(input.cfg), cfg: input.cfg, schemaFallback: false });
457
+ const sessions = [publicSummary(final)];
458
+ if (final.status !== 'ok') return { ...head, status: 'unavailable', reason: final.reason, approved: false, sessions };
459
+ if (final.review.findings.some((f) => !input.files.includes(f.file))) return { ...head, status: 'unavailable', reason: 'finding-file', approved: false, sessions };
460
+ if (final.review.needs_file?.length > 0) return { ...head, status: 'unavailable', reason: 'needs-file', approved: false, sessions };
461
+ return { ...head, status: 'reviewed', approved: final.review.passed === true, findings: final.review.findings, sessions };
462
+ }
463
+
444
464
  /**
445
465
  * One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss or
446
466
  * (B91) a timed-out attempt, the B55 second-level try (`schema-fallback.mjs`) on the same packet
@@ -38,7 +38,8 @@
38
38
  "properties": {
39
39
  "id": { "type": "string" },
40
40
  "resolved": { "type": "boolean" },
41
- "why": { "type": "string", "maxLength": 200 }
41
+ "why": { "type": "string", "maxLength": 200 },
42
+ "current_lines": { "type": "string", "maxLength": 300 }
42
43
  }
43
44
  }
44
45
  },
@@ -91,7 +91,7 @@ import { verifyRow } from '../state/signer.mjs';
91
91
  import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
92
92
  import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
93
93
  import { parseDiff } from './context.mjs';
94
- import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
94
+ import { assemblePacket, assertPacketPath, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
95
95
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
96
96
  import { pairedContext } from './engine.mjs';
97
97
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
@@ -101,6 +101,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
101
101
  import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
102
102
 
103
103
  /** @typedef {import('./triage.mjs').Finding} Finding */
104
+ /** @typedef {Finding & {repeated?: boolean}} LoopFinding */
104
105
  /** @typedef {import('./context.mjs').Hunk} Hunk */
105
106
 
106
107
  /**
@@ -121,12 +122,15 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
121
122
  * @property {number} round - rounds counted so far (patch_check rounds excluded).
122
123
  * @property {number} rounds_at_level
123
124
  * @property {number} [warn_rounds_at_level] - heavy rounds at the current level (rule 2c, B34); absent in a state saved before B34 ⇒ 0.
124
- * @property {Finding[]} open - the open `fix_now` set.
125
+ * @property {LoopFinding[]} open - the open `fix_now` set.
125
126
  * @property {Finding[]} late
127
+ * @property {string[]} [finding_history] - claim/evidence keys from earlier rounds of this file.
128
+ * @property {string[]} [finding_ids] - ids seen in earlier rounds, including closed findings.
126
129
  * @property {string | null} reviewed_content - the content the last counted round reviewed.
127
130
  * @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
128
131
  * @property {'open' | 'complete' | 'stopped'} status
129
132
  * @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
133
+ * @property {string} [stop_lens] - the lens whose packet stopped before a round.
130
134
  * @property {NextStep | null} next
131
135
  * @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).
132
136
  * @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
@@ -262,11 +266,24 @@ export function extraRoundsFor(rows, { runId, block, file, key }) {
262
266
  /**
263
267
  * B47: reopen a file stopped at `review_cap` when its raised cap leaves a round: the state goes
264
268
  * back to `open` with `next: fix` at its level (`trigger: autopilot_extra_round`); the next round
265
- * is a recheck. Any other state is left as it is.
269
+ * is a recheck. B132 also resumes a pre-round split stop when its packet budget changed,
270
+ * preserving the counted rounds and findings. Any other state is left as it is.
266
271
  * @param {FileState} state @param {number} extraRounds @param {Record<string, any> | undefined} cfg
267
272
  * @returns {boolean} whether the state was reopened.
268
273
  */
269
274
  export function reopenForExtraRound(state, extraRounds, cfg) {
275
+ // Only a recheck resumes as a fix; full and patch_check stops keep their round kind.
276
+ // Its packet is measured against full_in, so other lens budgets cannot reopen it.
277
+ if (state.status === 'stopped' && state.next?.reason === 'split_required' && state.stop_lens === 'recheck' &&
278
+ typeof state.next.budget === 'number' && Number.isFinite(state.next.budget) && state.next.budget > 0 &&
279
+ state.next.budget !== budgetFor(cfg, 'full_in')) {
280
+ state.status = 'open';
281
+ state.next = { action: 'fix', level: state.level, trigger: null };
282
+ state.pending_kind = null;
283
+ delete state.stop_content_hash;
284
+ delete state.stop_lens;
285
+ return true;
286
+ }
270
287
  if (state.status !== 'stopped' || state.next?.reason !== 'review_cap') return false;
271
288
  if (state.round >= roundCap(cfg, extraRounds)) return false;
272
289
  state.status = 'open';
@@ -349,8 +366,18 @@ const OPEN_FINDINGS_HEAD = '## open findings\n';
349
366
  /** @param {Finding} f @returns {string} the finding's line in a recheck packet's open-findings list. */
350
367
  export const openFindingLine = (f) => `- ${f.id} (${f.severity}, lines ${f.line_start}-${f.line_end}): ${f.claim ?? ''}`;
351
368
 
369
+ /** @param {Finding} f @returns {string} compare BOTH fields, ignoring only surrounding whitespace. */
370
+ const findingKey = (f) => JSON.stringify([(f.claim ?? '').trim(), (f.evidence ?? '').trim()]);
371
+
372
+ /** @param {string} text @returns {string} */
373
+ const normaliseWhitespace = (text) => text.trim().replace(/\s+/g, ' ');
374
+
352
375
  /** @param {ReadonlyArray<Finding>} open @returns {string} the open-findings list a recheck packet ends with. */
353
- const openFindingsText = (open) => `${OPEN_FINDINGS_HEAD}${open.length > 0 ? open.map(openFindingLine).join('\n') : '(none)'}\n`;
376
+ const openFindingsText = (open) => {
377
+ // Keep the final id/claim list stable for section readers; evidence is keyed by id just before it.
378
+ const evidence = open.length > 0 ? `## finding evidence\n${open.map((f) => `${f.id}: ${(f.evidence ?? '').trim().replace(/\s+/g, ' ').slice(0, 300)}`).join('\n')}\n` : '';
379
+ return `${evidence}${OPEN_FINDINGS_HEAD}${open.length > 0 ? open.map(openFindingLine).join('\n') : '(none)'}\n`;
380
+ };
354
381
 
355
382
  /**
356
383
  * The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
@@ -631,10 +658,11 @@ async function stopBeforeRound(state, deps, kind, file, contentHash, packet) {
631
658
  typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
632
659
  ? { tokens_in: packet.tokensIn, budget: packet.budget, ...(typeof packet.section === 'string' ? { section: packet.section } : {}) }
633
660
  : {};
634
- const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
661
+ const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size, lens: kind };
635
662
  if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
636
663
  state.status = 'stopped';
637
664
  state.stop_content_hash = contentHash;
665
+ state.stop_lens = kind;
638
666
  state.next = { action: 'stop', reason, ...size };
639
667
  state.pending_kind = null;
640
668
  return state;
@@ -661,6 +689,14 @@ export async function runRound(state, deps, opts = {}) {
661
689
  let newInHunks = 0;
662
690
  /** @type {Array<Record<string, any>>} */
663
691
  const lateRows = [];
692
+ /** @type {Array<Record<string, any>>} */
693
+ const staleRows = [];
694
+ const history = new Set([...(state.finding_history ?? []), ...state.open.map(findingKey), ...state.late.map(findingKey)]);
695
+ const knownIds = new Set([...(state.finding_ids ?? []), ...state.open.map((f) => f.id), ...state.late.map((f) => f.id)]);
696
+ const roundFindings = new Set();
697
+ const roundIds = new Set();
698
+ /** @type {Set<string>} */
699
+ const repeated = new Set();
664
700
  /** @type {number | null} */
665
701
  let tokensIn = null;
666
702
  /** @type {number | null} */
@@ -671,6 +707,10 @@ export async function runRound(state, deps, opts = {}) {
671
707
  if (kind === 'full') {
672
708
  const result = await deps.review();
673
709
  if (result?.status !== 'reviewed') return retry(state, kind, result?.reason ?? result?.status ?? 'unavailable');
710
+ for (const finding of result.findings ?? []) {
711
+ roundFindings.add(findingKey(finding));
712
+ roundIds.add(finding.id);
713
+ }
674
714
  const triaged = await triageFindings({ file: current.rel, findings: result.findings ?? [], fromJudge: cameFromJudge(result), cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
675
715
  draft.open = triaged.fix_now;
676
716
  newInHunks = triaged.fix_now.length;
@@ -727,6 +767,9 @@ export async function runRound(state, deps, opts = {}) {
727
767
  }
728
768
  /** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
729
769
  const resolved = new Set();
770
+ /** @type {Set<Finding>} */
771
+ const stale = new Set();
772
+ const currentText = normaliseWhitespace(current.content);
730
773
  /** @type {Finding[]} */
731
774
  const inside = [];
732
775
  /** @type {Finding[]} */
@@ -742,11 +785,30 @@ export async function runRound(state, deps, opts = {}) {
742
785
  return retry(state, kind, session.verdict.reason ?? 'unavailable');
743
786
  }
744
787
  const review = /** @type {Record<string, any>} */ (session.verdict.review);
745
- const resolvedIds = new Set((review.resolved ?? []).filter((/** @type {any} */ r) => r?.resolved === true).map((/** @type {any} */ r) => r.id));
746
- // only the findings listed in THIS packet can be resolved by its session
747
- for (const f of part.open) if (resolvedIds.has(f.id)) resolved.add(f);
788
+ // Only this packet's findings can close here. Legacy answers without a quote stay
789
+ // open; a nonempty quote that no longer exists in the file proves the answer stale.
790
+ for (const f of part.open) {
791
+ const answer = (review.resolved ?? []).find((/** @type {any} */ r) => r.id === f.id);
792
+ if (answer?.resolved === true) {
793
+ resolved.add(f);
794
+ continue;
795
+ }
796
+ const quote = normaliseWhitespace(answer?.current_lines ?? '');
797
+ const reason = quote.length > 0 && !currentText.includes(quote) ? 'current_lines_not_found' : null;
798
+ if (reason) {
799
+ stale.add(f);
800
+ staleRows.push({ event: 'review.stale_finding', file: current.rel, round: state.round + 1, finding: f.id, reason });
801
+ }
802
+ }
748
803
  const found = /** @type {Finding[]} */ (review.findings ?? []);
749
804
  for (const f of part.index === null ? found : sectionFindings(found, part.index)) {
805
+ const key = findingKey(f);
806
+ roundFindings.add(key);
807
+ roundIds.add(f.id);
808
+ if (kind === 'recheck' && !knownIds.has(f.id) && history.has(key)) {
809
+ Object.assign(f, { repeated: true });
810
+ repeated.add(f.id);
811
+ }
750
812
  if (insideHunks(f, diff.hunks)) {
751
813
  inside.push(f);
752
814
  continue;
@@ -756,8 +818,7 @@ export async function runRound(state, deps, opts = {}) {
756
818
  if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
757
819
  }
758
820
  }
759
- // every pending finding stays open unless the session that listed it resolved it
760
- const stillOpen = pending.filter((f) => !resolved.has(f));
821
+ const stillOpen = pending.filter((f) => !resolved.has(f) && !stale.has(f));
761
822
  closed += pending.length - stillOpen.length;
762
823
  const triaged = await triageFindings({ file: current.rel, findings: inside, fromJudge: false, diffText: diff.diffText, cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
763
824
  newInHunks = triaged.fix_now.length;
@@ -767,11 +828,12 @@ export async function runRound(state, deps, opts = {}) {
767
828
  // A patch_check with no fix hunks closed nothing: B73 still stops it.
768
829
  }
769
830
 
831
+ const unweighted = kind !== 'full' && (staleRows.length > 0 || repeated.size > 0 || draft.open.some((f) => f.repeated)) && draft.open.every((f) => f.repeated);
770
832
  if (kind !== 'patch_check') {
771
833
  draft.round += 1;
772
- draft.rounds_at_level += 1;
834
+ if (!unweighted) draft.rounds_at_level += 1;
773
835
  // THIS round's open set, after triage: only the warnings still open count
774
- if (heavyRound(draft.open, s.warnThreshold)) draft.warn_rounds_at_level = (draft.warn_rounds_at_level ?? 0) + 1;
836
+ if (!unweighted && heavyRound(draft.open, s.warnThreshold)) draft.warn_rounds_at_level = (draft.warn_rounds_at_level ?? 0) + 1;
775
837
  } else {
776
838
  // the block continues at L2 after the rung with a fresh round counter (§3.6) — also here, not
777
839
  // only in `converge`, because the worker runs the patch_check from its own ticket
@@ -779,6 +841,8 @@ export async function runRound(state, deps, opts = {}) {
779
841
  draft.warn_rounds_at_level = 0;
780
842
  }
781
843
  draft.reviewed_content = current.content;
844
+ if (history.size > 0 || roundFindings.size > 0) draft.finding_history = [...new Set([...history, ...roundFindings])];
845
+ if (knownIds.size > 0 || roundIds.size > 0) draft.finding_ids = [...new Set([...knownIds, ...roundIds])];
782
846
  draft.last_packet = packetInfo;
783
847
  const roundRow = {
784
848
  event: 'review.round',
@@ -794,6 +858,7 @@ export async function runRound(state, deps, opts = {}) {
794
858
  tokens_in: tokensIn,
795
859
  tokens_out: tokensOut,
796
860
  ...(packetInfo ? { context_mode: packetInfo.context_mode } : {}),
861
+ ...(repeated.size > 0 ? { repeated: [...repeated] } : {}),
797
862
  };
798
863
 
799
864
  if (draft.open.length === 0) {
@@ -805,13 +870,13 @@ export async function runRound(state, deps, opts = {}) {
805
870
  // closed one is fixed at L2 (B73). Closed = an id open before the round, not open after it.
806
871
  const after = new Set(draft.open.map((f) => f.id));
807
872
  const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
808
- draft.next = decideNext(draft, openBefore, s, kind, closedIds);
873
+ draft.next = unweighted && kind !== 'patch_check' ? { action: 'fix', level: draft.level, trigger: null } : decideNext(draft, openBefore, s, kind, closedIds);
809
874
  if (draft.next.action === 'stop') {
810
875
  draft.status = 'stopped';
811
876
  draft.stop_content_hash = current.hash;
812
877
  }
813
878
  }
814
- const rows = [...lateRows, roundRow];
879
+ const rows = [...staleRows, ...lateRows, roundRow];
815
880
  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) });
816
881
  for (const row of rows) {
817
882
  if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');