@codedology/code-forge 0.14.0 → 0.15.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 (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +136 -438
  3. package/docs/README.md +1 -1
  4. package/docs/reference/blocks.json +420 -1
  5. package/docs/reference/config.md +7 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +22 -0
  8. package/scripts/first-use.mjs +11 -5
  9. package/skill/SKILL.md +2 -0
  10. package/skill/references/autopilot.md +2 -0
  11. package/skill/references/code.md +25 -1
  12. package/skill/references/review.md +10 -2
  13. package/skill/scripts/forge +1 -1
  14. package/src/autopilot/scopes.mjs +2 -1
  15. package/src/cli/block.mjs +235 -44
  16. package/src/cli/gates.mjs +36 -6
  17. package/src/cli/ledger.mjs +21 -2
  18. package/src/cli/report.mjs +14 -0
  19. package/src/cli/review-file.mjs +56 -16
  20. package/src/cli/run.mjs +44 -7
  21. package/src/cli/status.mjs +7 -3
  22. package/src/cli/upgrade.mjs +95 -15
  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/install/link.mjs +12 -6
  28. package/src/ledger/report.mjs +29 -7
  29. package/src/review/accept-hit.mjs +22 -4
  30. package/src/review/engine.mjs +30 -10
  31. package/src/review/finding.schema.json +2 -1
  32. package/src/review/fixloop.mjs +79 -14
  33. package/src/review/gate-check.mjs +22 -3
  34. package/src/review/lenses/recheck.md +3 -0
  35. package/src/review/packet.mjs +26 -0
  36. package/src/state/block.mjs +162 -20
  37. package/src/state/run.mjs +35 -8
  38. package/src/util/forbidden.mjs +14 -0
  39. package/src/util/reaper.mjs +3 -3
  40. package/src/worker/engine.mjs +60 -4
  41. package/src/worker/loop.mjs +19 -10
  42. package/src/worker/queue.mjs +5 -2
@@ -27,6 +27,7 @@ import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { isExactEntry, ownsFile } from '../state/registry.mjs';
29
29
  import { pairsFor } from '../review/paired.mjs';
30
+ import { computeFileSet } from './scope.mjs';
30
31
  import { exec } from '../util/exec.mjs';
31
32
  import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
32
33
 
@@ -66,6 +67,30 @@ export function isTestPath(p) {
66
67
  return /\.(test|spec)\.[^/]+$/.test(name) || name.endsWith('Test.php') || name.endsWith('_test.go') || /^test_.*\.py$/.test(name);
67
68
  }
68
69
 
70
+ /** The changed-test predicate, including Swift's conventional Tests suffix. @param {string} file */
71
+ export function isChangedTest(file) {
72
+ return isTestPath(file) || file.endsWith('Tests.swift');
73
+ }
74
+
75
+ /**
76
+ * Existing block tests added or changed since its base, independent of the last green scope
77
+ * and its threshold. Deleted tests and unchanged source-paired tests are not repeat candidates.
78
+ * @param {{cwd: string, base: string, owned: string[], kinds?: Record<string, string>}} opts
79
+ * @returns {Promise<string[]>}
80
+ */
81
+ export async function changedTestFiles({ cwd, base, owned, kinds }) {
82
+ const { all } = await computeFileSet({ cwd, base });
83
+ const files = all.filter((file) => ownsFile(owned, file, kinds) && isChangedTest(file));
84
+ const existing = await Promise.all(files.map(async (file) => {
85
+ const stat = await lstat(path.join(cwd, file)).catch((error) => {
86
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
87
+ throw error;
88
+ });
89
+ return stat?.isFile() === true;
90
+ }));
91
+ return files.filter((_, i) => existing[i]);
92
+ }
93
+
69
94
  /**
70
95
  * An owned entry that names one file: no glob character (`*`, `?`, `{`) and no trailing `/`.
71
96
  * @param {string} p @returns {boolean}
@@ -186,8 +211,6 @@ export function changedScope({ baseline, current, fallback, threshold = DEFAULT_
186
211
  const changes = [...new Set([...Object.keys(old), ...Object.keys(current.files)])].filter((file) => old[file] !== current.files[file]).sort();
187
212
  if (changes.length > threshold) return { scope: 'changed', files: [], changedFiles: [], reason: 'threshold' };
188
213
  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
214
  const tests = new Set(changedFiles.filter(isChangedTest));
192
215
  for (const file of changes.filter((f) => !isChangedTest(f))) {
193
216
  const pairs = pairsFor(file, current.candidates).filter(isChangedTest);
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
  }
@@ -16,12 +16,14 @@
16
16
  * harness at the same scope again replaces its record rather than duplicating it.
17
17
  */
18
18
 
19
- import { lstatSync, readdirSync, realpathSync } from 'node:fs';
19
+ import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
20
20
  import { cp, lstat, mkdir, readFile, rename, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises';
21
21
  import os from 'node:os';
22
22
  import path from 'node:path';
23
23
  import { randomUUID } from 'node:crypto';
24
24
 
25
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
26
+
25
27
  /** @typedef {"symlink"|"copy"} LinkMethod */
26
28
  /** @typedef {"project"|"global"} InstallScope */
27
29
 
@@ -33,6 +35,7 @@ import { randomUUID } from 'node:crypto';
33
35
  * @property {string} source - absolute path to the skill directory that was linked/copied.
34
36
  * @property {string} target - absolute path to the installed skill directory.
35
37
  * @property {string} linked_at - ISO timestamp of the most recent link/upgrade.
38
+ * @property {string} [version] - package version that last linked this install; absent on legacy records.
36
39
  */
37
40
 
38
41
  /**
@@ -66,7 +69,8 @@ function isValidRecord(value) {
66
69
  typeof r.target === 'string' &&
67
70
  r.target.length > 0 &&
68
71
  path.isAbsolute(r.target) &&
69
- typeof r.linked_at === 'string'
72
+ typeof r.linked_at === 'string' &&
73
+ (r.version === undefined || (typeof r.version === 'string' && r.version.length > 0))
70
74
  );
71
75
  }
72
76
 
@@ -359,13 +363,14 @@ export async function linkSkill({ source, target, method }) {
359
363
  * @param {LinkMethod} opts.method
360
364
  * @param {string} opts.source
361
365
  * @param {string} opts.target
366
+ * @param {string} [opts.version] - defaults to the running package version.
362
367
  * @returns {Promise<InstallRecord[]>} the full record list after this install.
363
368
  */
364
- export async function install({ installsFile, harness, scope, method, source, target }) {
369
+ export async function install({ installsFile, harness, scope, method, source, target, version = PACKAGE_VERSION }) {
365
370
  await linkSkill({ source, target, method });
366
371
  const existing = await readInstalls(installsFile);
367
372
  const kept = existing.filter((r) => !(r.harness === harness && r.scope === scope));
368
- const records = [...kept, { harness, scope, method, source, target, linked_at: new Date().toISOString() }];
373
+ const records = [...kept, { harness, scope, method, source, target, linked_at: new Date().toISOString(), version }];
369
374
  await writeInstallsAtomically(installsFile, records);
370
375
  return records;
371
376
  }
@@ -379,15 +384,16 @@ export async function install({ installsFile, harness, scope, method, source, ta
379
384
  * @param {object} opts
380
385
  * @param {string} opts.installsFile
381
386
  * @param {string} [opts.source] - overrides every record's source when given.
387
+ * @param {string} [opts.version] - defaults to the running package version.
382
388
  * @returns {Promise<InstallRecord[]>} the updated record list (same length as before).
383
389
  */
384
- export async function upgradeAll({ installsFile, source }) {
390
+ export async function upgradeAll({ installsFile, source, version = PACKAGE_VERSION }) {
385
391
  const records = await readInstalls(installsFile);
386
392
  const updated = [];
387
393
  for (const record of records) {
388
394
  const nextSource = source ?? record.source;
389
395
  await linkSkill({ source: nextSource, target: record.target, method: record.method });
390
- updated.push({ ...record, source: nextSource, linked_at: new Date().toISOString() });
396
+ updated.push({ ...record, source: nextSource, linked_at: new Date().toISOString(), version });
391
397
  }
392
398
  await writeInstallsAtomically(installsFile, updated);
393
399
  return updated;
@@ -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
  },