@codedology/code-forge 0.9.0 → 0.11.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 (62) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +49 -9
  3. package/docs/reference/blocks.json +587 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/SKILL.md +1 -1
  8. package/skill/references/adapters/claude-code.md +1 -1
  9. package/skill/references/adapters/solo.md +2 -2
  10. package/skill/references/adapters/subprocess.md +12 -2
  11. package/skill/references/code.md +14 -7
  12. package/skill/references/ledger.md +2 -2
  13. package/skill/references/proof.md +77 -2
  14. package/skill/references/review.md +39 -7
  15. package/skill/references/security.md +4 -4
  16. package/skill/scripts/forge +1 -1
  17. package/skill/templates/coder-brief.md +6 -6
  18. package/src/autopilot/binnacle.mjs +9 -3
  19. package/src/cli/block.mjs +262 -31
  20. package/src/cli/jev.mjs +22 -3
  21. package/src/cli/keys.mjs +156 -21
  22. package/src/cli/proof.mjs +35 -7
  23. package/src/cli/review-file.mjs +17 -11
  24. package/src/cli/spawn.mjs +44 -3
  25. package/src/config/validate.mjs +320 -5
  26. package/src/doctor/index.mjs +2 -1
  27. package/src/doctor/local.mjs +27 -6
  28. package/src/doctor/probes.mjs +38 -0
  29. package/src/engines/builders/codex.mjs +44 -4
  30. package/src/engines/sandbox-dirs.mjs +257 -0
  31. package/src/keys/store.mjs +128 -21
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/engine.mjs +141 -42
  38. package/src/review/fixloop.mjs +75 -23
  39. package/src/review/gate-check.mjs +3 -2
  40. package/src/review/lenses/A.md +2 -0
  41. package/src/review/lenses/B.md +2 -0
  42. package/src/review/lenses/full.md +2 -0
  43. package/src/review/lenses/quick.md +1 -0
  44. package/src/review/lenses/recheck.md +2 -0
  45. package/src/review/packet.mjs +48 -14
  46. package/src/review/paired.mjs +80 -0
  47. package/src/review/reopen.mjs +4 -6
  48. package/src/review/reviewed-lines.mjs +94 -0
  49. package/src/review/route.mjs +232 -0
  50. package/src/review/schema-fallback.mjs +77 -22
  51. package/src/review/session-retry.mjs +44 -9
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block-end.mjs +10 -6
  56. package/src/state/block.mjs +13 -3
  57. package/src/state/config-snapshot.mjs +39 -0
  58. package/src/state/run.mjs +4 -0
  59. package/src/util/forbidden.mjs +2 -2
  60. package/src/worker/engine.mjs +43 -15
  61. package/src/worker/loop.mjs +98 -15
  62. package/src/worker/ticket.mjs +9 -1
@@ -0,0 +1,285 @@
1
+ /**
2
+ * `xcresult` (red-green plan §1.6, B102): read the result bundle of an `xcodebuild test` run and
3
+ * classify the RED and GREEN steps of the `xcodebuild` runner adapter from it.
4
+ *
5
+ * The bundle is read with `xcrun xcresulttool get test-results tests --path <bundle>` (argv only,
6
+ * 60 s timeout, output capped at 8 MiB), and its JSON is mapped to the common `{tests}` shape of
7
+ * `junit.mjs`. The node shape is pinned by real captured output (Xcode 26.6, `test/fixtures/xcresult/`):
8
+ *
9
+ * {testNodes: [Test Plan → Unit test bundle | UI test bundle → Test Suite (→ Test Suite)…
10
+ * → Test Case → Failure Message]}
11
+ *
12
+ * A Test Case carries `nodeIdentifier` (`ClampXCTests/testCapsHigh()` for XCTest,
13
+ * `ClampSwiftTests/capsHigh()` for Swift Testing) and `result` (`Passed`, `Failed`, `Skipped`). Its
14
+ * id is `<bundle name>/<nodeIdentifier>`, e.g. `KitTests/ClampXCTests/testCapsHigh()`. A Test Plan
15
+ * with no children (a filter that selected nothing) is zero tests.
16
+ *
17
+ * Fail closed: no bundle, a bundle that is not a real directory, a failed or timed-out
18
+ * `xcresulttool`, output over 8 MiB, JSON that does not parse, two tests with the same id, and a
19
+ * failed check of what this reader uses make the result unreadable. It checks every node's
20
+ * `nodeType` (a known type) and its parent type (a known place), a Test Case's `result` (a known
21
+ * result) and `nodeIdentifier` (a non-empty string), a bundle's `name` (non-empty, no `/`), a
22
+ * Failure Message's `name` (a string, no children) and every `children` (a list). Any other key of a
23
+ * node or of the document (`devices`, `testPlanConfigurations`, `duration`, …) is not read and is
24
+ * ignored. An unreadable result never proves anything (RED is `RED_INVALID error`, GREEN is
25
+ * `NOT_GREEN`). Today
26
+ * that includes these xcresulttool node types: a parameterized Swift Testing test (`Arguments`),
27
+ * repetitions (`Repetition`, `Test Case Run`), a multi-destination or multi-configuration run
28
+ * (`Device`, `Test Plan Configuration`), `Runtime Warning`, `Source Code Reference`, `Attachment`,
29
+ * `Expression`, `Test Value`, and the result `Expected Failure`.
30
+ *
31
+ * A Failure Message (`<file>.swift:<line>: <text>`, the prefix optional) is an assertion only for
32
+ * these texts, as captured: `XCTAssert… failed` (but not `XCTAssert… failed: threw error`, a thrown
33
+ * error inside the assertion), XCTFail's `failed` and `failed - <message>`, and Swift Testing's
34
+ * `Expectation failed: …`. Every other text is an error: a thrown error (`failed: caught error: …`,
35
+ * `Caught error: …`), `Issue recorded: …`, a crash. A failed test is an assertion only when every
36
+ * one of its messages is one.
37
+ */
38
+
39
+ import { lstat } from 'node:fs/promises';
40
+ import path from 'node:path';
41
+ import { exec } from '../util/exec.mjs';
42
+ import { MAX_REPORT_BYTES, NO_REPORT, classifyGreen, classifyReport, swiftCompileError } from './junit.mjs';
43
+
44
+ /** `xcresulttool` may run this long before it is stopped. */
45
+ export const XCRESULTTOOL_TIMEOUT_MS = 60_000;
46
+
47
+ /** Which parent node type each known node type may have (`null` = the `testNodes` list). */
48
+ const NODE_PARENTS = Object.freeze({
49
+ 'Test Plan': [null],
50
+ 'Unit test bundle': ['Test Plan'],
51
+ 'UI test bundle': ['Test Plan'],
52
+ 'Test Suite': ['Unit test bundle', 'UI test bundle', 'Test Suite'],
53
+ 'Test Case': ['Test Suite'],
54
+ 'Failure Message': ['Test Case'],
55
+ });
56
+
57
+ /** A Test Case `result` this reader knows, and its common status. */
58
+ const RESULT_STATUS = Object.freeze({ Passed: 'passed', Failed: 'failed', Skipped: 'skipped' });
59
+
60
+ /** The optional `<file>.swift:<line>: ` prefix of a Failure Message. */
61
+ const MESSAGE_PREFIX = /^[^\n:]+\.swift:\d+: /;
62
+ /** The assertion texts, after the prefix (see the header). */
63
+ const ASSERTION_MESSAGES = Object.freeze([/^XCTAssert\w* failed(?!: threw error)/, /^failed - /, /^failed$/, /^Expectation failed: /]);
64
+
65
+ // eslint-disable-next-line no-control-regex
66
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
67
+ /** `xcodebuild`'s own build-failure lines (`** BUILD FAILED **`, `** TEST BUILD FAILED **`). */
68
+ const BUILD_FAILED_LINE = /^\*\* (?:TEST )?BUILD FAILED \*\*[ \t\r]*$/m;
69
+ /** `xcodebuild test` (Xcode 26.6) on a test that does not compile prints this line, tab-indented, then `** TEST FAILED **`. */
70
+ const BUILD_CANCELLED_LINE = /^[ \t]*Testing cancelled because the build failed\.[ \t\r]*$/m;
71
+
72
+ /** @param {string} reason @returns {never} */
73
+ function unreadable(reason) {
74
+ const err = new Error(`report-unreadable: ${reason}`);
75
+ /** @type {any} */ (err).code = 'report-unreadable';
76
+ throw err;
77
+ }
78
+
79
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
80
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
81
+
82
+ /**
83
+ * Is a Failure Message text an assertion failure?
84
+ * @param {string} text @returns {'assertion' | 'error'}
85
+ */
86
+ export function failureMessageKind(text) {
87
+ const rest = String(text).replace(MESSAGE_PREFIX, '');
88
+ return ASSERTION_MESSAGES.some((p) => p.test(rest)) ? 'assertion' : 'error';
89
+ }
90
+
91
+ /**
92
+ * Read `xcresulttool get test-results tests` JSON.
93
+ * @param {string} text
94
+ * @returns {{tests: import('./junit.mjs').ReportTest[]}}
95
+ * @throws {Error} `code: 'report-unreadable'`; the message names the reason only, never content.
96
+ */
97
+ export function readXcresult(text) {
98
+ if (typeof text !== 'string') unreadable('the xcresulttool output is not text');
99
+ if (Buffer.byteLength(text, 'utf8') > MAX_REPORT_BYTES) unreadable('the xcresulttool output is over 8 MiB');
100
+ let doc;
101
+ try {
102
+ doc = JSON.parse(text);
103
+ } catch {
104
+ unreadable('the xcresulttool output is not JSON');
105
+ }
106
+ if (!isObject(doc) || !Array.isArray(doc.testNodes)) unreadable('the xcresulttool output has no testNodes list');
107
+
108
+ /** @type {import('./junit.mjs').ReportTest[]} */
109
+ const tests = [];
110
+ const seen = new Set();
111
+ /** @param {unknown} node @param {string | null} parent @param {string | null} bundle */
112
+ const walk = (node, parent, bundle) => {
113
+ if (!isObject(node)) unreadable('a test node that is not an object');
114
+ const type = node.nodeType;
115
+ if (typeof type !== 'string' || !Object.hasOwn(NODE_PARENTS, type)) unreadable('a node type this reader does not know');
116
+ if (!NODE_PARENTS[/** @type {keyof typeof NODE_PARENTS} */ (type)].includes(/** @type {any} */ (parent))) unreadable('a test node in the wrong place');
117
+ const children = node.children ?? [];
118
+ if (!Array.isArray(children)) unreadable('test node children that are not a list');
119
+ if (type === 'Failure Message') {
120
+ if (typeof node.name !== 'string') unreadable('a failure message without a text');
121
+ if (children.length > 0) unreadable('a failure message with children');
122
+ return;
123
+ }
124
+ let inBundle = bundle;
125
+ if (type === 'Unit test bundle' || type === 'UI test bundle') {
126
+ if (typeof node.name !== 'string' || node.name === '' || node.name.includes('/')) unreadable('a test bundle without a name');
127
+ inBundle = node.name;
128
+ }
129
+ if (type === 'Test Case') {
130
+ const ident = node.nodeIdentifier;
131
+ if (typeof ident !== 'string' || ident === '') unreadable('a test case without a nodeIdentifier');
132
+ const result = node.result;
133
+ if (typeof result !== 'string' || !Object.hasOwn(RESULT_STATUS, result)) unreadable('a test result this reader does not know');
134
+ const status = RESULT_STATUS[/** @type {keyof typeof RESULT_STATUS} */ (result)];
135
+ const kinds = children.map((child) => {
136
+ walk(child, 'Test Case', inBundle);
137
+ return failureMessageKind(/** @type {any} */ (child).name);
138
+ });
139
+ const id = `${inBundle}/${ident}`;
140
+ // one id twice (two bundles of one name, a repeated node) would let one result hide the other
141
+ if (seen.has(id)) unreadable('two test cases with the same id');
142
+ seen.add(id);
143
+ if (status === 'failed') tests.push({ id, status, kind: kinds.length > 0 && kinds.every((k) => k === 'assertion') ? 'assertion' : 'error' });
144
+ else if (kinds.length > 0) unreadable('a failure message on a test that did not fail');
145
+ else tests.push({ id, status });
146
+ return;
147
+ }
148
+ for (const child of children) walk(child, type, inBundle);
149
+ };
150
+ for (const node of doc.testNodes) walk(node, null, null);
151
+ return { tests };
152
+ }
153
+
154
+ /**
155
+ * `readXcresult` that never throws.
156
+ * @param {string} text @returns {import('./junit.mjs').ReportResult}
157
+ */
158
+ export function parseXcresult(text) {
159
+ try {
160
+ return readXcresult(text);
161
+ } catch (err) {
162
+ const message = err instanceof Error ? err.message : '';
163
+ return { unreadable: message.startsWith('report-unreadable: ') ? message.slice('report-unreadable: '.length) : 'the result bundle could not be read' };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Read a result bundle with `xcresulttool`. Never throws. No bundle at the path is `NO_REPORT`; a
169
+ * symlink or any other non-directory is unreadable. A relative `bundle` is resolved against `cwd`
170
+ * once, and that one absolute path is both checked and read.
171
+ * @param {string} bundle @param {{cwd: string, env: NodeJS.ProcessEnv, timeoutMs?: number}} opts
172
+ * @returns {Promise<import('./junit.mjs').ReportResult>}
173
+ */
174
+ export async function loadXcresult(bundle, { cwd, env, timeoutMs = XCRESULTTOOL_TIMEOUT_MS }) {
175
+ const target = path.resolve(cwd, bundle);
176
+ let st;
177
+ try {
178
+ st = await lstat(target);
179
+ } catch (err) {
180
+ return /** @type {any} */ (err)?.code === 'ENOENT' ? NO_REPORT : { unreadable: 'the result bundle could not be opened' };
181
+ }
182
+ if (!st.isDirectory()) return { unreadable: 'the result bundle is not a directory' };
183
+ try {
184
+ const res = await exec(['xcrun', 'xcresulttool', 'get', 'test-results', 'tests', '--path', target], { cwd, env, timeoutMs, maxBufferBytes: MAX_REPORT_BYTES + 1 });
185
+ if (res.result !== 'ok' || res.code !== 0 || res.signal || res.timedOut) return { unreadable: 'xcresulttool failed' };
186
+ return parseXcresult(res.stdout);
187
+ } catch {
188
+ return { unreadable: 'xcresulttool failed' };
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Does the console text show that the test build failed? Colours are stripped first.
194
+ * @param {string} text @returns {boolean}
195
+ */
196
+ export function xcodeBuildFailed(text) {
197
+ const plain = String(text).replace(ANSI, '');
198
+ return BUILD_FAILED_LINE.test(plain) || BUILD_CANCELLED_LINE.test(plain);
199
+ }
200
+
201
+ /** @param {import('./junit.mjs').RunResult} run @returns {boolean} */
202
+ const exitedClean = (run) => run.code === 0 && !run.signal && !run.timedOut;
203
+
204
+ /**
205
+ * A scope is `<test_target>/<Suite>/`: two non-empty parts and the closing `/`. Without the `/`,
206
+ * `KitTests/Clamp` would also admit `KitTests/ClampMore/…`, so any other scope throws (the callers
207
+ * then classify fail closed).
208
+ * @param {unknown} scope @returns {asserts scope is string}
209
+ */
210
+ function checkScope(scope) {
211
+ if (typeof scope !== 'string' || !/^[^/]+\/[^/]+\/$/.test(scope)) throw new Error('an xcodebuild scope is <test_target>/<Suite>/');
212
+ }
213
+
214
+ /**
215
+ * Is any test of a readable result outside `scope` (`<test_target>/<Suite>/`)?
216
+ * @param {import('./junit.mjs').ReportResult} report @param {string} scope @returns {boolean}
217
+ * @throws {Error} a scope that is not `<test_target>/<Suite>/`
218
+ */
219
+ const ranOtherTests = (report, scope) => {
220
+ checkScope(scope);
221
+ return 'tests' in report && report.tests.some((t) => !t.id.startsWith(scope));
222
+ };
223
+
224
+ /**
225
+ * Classify the RED step of an `xcodebuild` run. Never throws. A `scope` that is not
226
+ * `<test_target>/<Suite>/` is `RED_INVALID error`. Then the first row that applies wins:
227
+ * - exit 0 and no readable result (no bundle, or an unreadable one) ⇒ `RED_INVALID error`: a clean
228
+ * exit with nothing to read says nothing about the test;
229
+ * - exit 0 and a readable result with a test outside `scope` (or only such tests) ⇒
230
+ * `RED_INVALID error` (the filter ran other tests): the test under proof did not run cleanly
231
+ * in scope, so a passing run is not `NOT_RED`;
232
+ * - exit 0 and a readable result with no test that ran ⇒ `RED_INVALID error` (the filter ran no
233
+ * test). This differs from `junit` and `swift`, where `classifyReport` reads any exit 0 as
234
+ * `NOT_RED`: `xcodebuild test` exits 0 when `-only-testing` selects nothing (a Swift Testing id
235
+ * without its `()`), so an empty run says nothing about the test and must not read as "it passed";
236
+ * - exit 0 otherwise ⇒ `NOT_RED`; a timeout or a signal ⇒ `RED_INVALID fatal`;
237
+ * - a failed run whose console shows a build failure, or that has no tests and a `swiftc` error
238
+ * ⇒ `RED_INVALID compile`;
239
+ * - a test outside `scope` ⇒ `RED_INVALID error` (the filter ran other tests);
240
+ * - else `classifyReport` (§1.1).
241
+ * Every invalid row this adds carries no failed data.
242
+ * @param {{run: import('./junit.mjs').RunResult, report: import('./junit.mjs').ReportResult, scope: string}} input
243
+ * @returns {import('./junit.mjs').JUnitRedParse}
244
+ */
245
+ export function classifyXcodeRed({ run, report, scope }) {
246
+ try {
247
+ checkScope(scope);
248
+ const base = classifyReport({ run, reports: [report], compileError: swiftCompileError });
249
+ /** @param {'compile' | 'error'} red_kind @param {string} reason @returns {import('./junit.mjs').JUnitRedParse} */
250
+ const invalid = (red_kind, reason) => ({ ...base, verdict: 'RED_INVALID', red_kind, failed: null, failed_tests: [], failed_ids: [], reason });
251
+ const readable = isObject(report) && Array.isArray(/** @type {any} */ (report).tests);
252
+ if (exitedClean(run)) {
253
+ if (!readable) return invalid('error', 'no readable test result');
254
+ if (readable && ranOtherTests(report, scope)) return invalid('error', 'the filter ran other tests');
255
+ if (readable && !(/** @type {any} */ (report).tests.some((/** @type {any} */ t) => t.status !== 'skipped'))) return invalid('error', 'the filter ran no test');
256
+ return base;
257
+ }
258
+ if (run.timedOut || run.signal) return base;
259
+ const output = `${run.stdout ?? ''}\n${run.stderr ?? ''}`;
260
+ if (xcodeBuildFailed(output)) return invalid('compile', 'the test did not build');
261
+ const noTests = report === NO_REPORT || (readable && /** @type {any} */ (report).tests.length === 0);
262
+ if (noTests && swiftCompileError(output)) return invalid('compile', 'the test did not compile');
263
+ if (readable && ranOtherTests(report, scope)) return invalid('error', 'the filter ran other tests');
264
+ return base;
265
+ } catch {
266
+ return { verdict: 'RED_INVALID', red_kind: 'error', framework: 'junit', failed: null, failed_tests: [], failed_ids: [], reason: 'no readable test result' };
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Classify the GREEN step of an `xcodebuild` run: `classifyGreen`, and `NOT_GREEN` when the result
272
+ * has a test outside `scope`. Never throws.
273
+ * @param {{run: import('./junit.mjs').RunResult, report: import('./junit.mjs').ReportResult, redFailed: string[], scope: string}} input
274
+ * @returns {import('./junit.mjs').JUnitGreenParse}
275
+ */
276
+ export function classifyXcodeGreen({ run, report, redFailed, scope }) {
277
+ try {
278
+ checkScope(scope);
279
+ const base = classifyGreen({ run, reports: [report], redFailed });
280
+ if (base.verdict === 'GREEN' && ranOtherTests(report, scope)) return { ...base, verdict: 'NOT_GREEN', reason: 'the filter ran other tests' };
281
+ return base;
282
+ } catch {
283
+ return { verdict: 'NOT_GREEN', failed: null, failed_tests: [], reason: 'no readable test result' };
284
+ }
285
+ }
@@ -19,14 +19,18 @@
19
19
  * request with a non-repo-relative path is refused (`needs_file-refused`, never approval);
20
20
  * 6. a session that ends in `timeout` is spawned ONCE more on the same packet and level
21
21
  * (`session-retry.mjs`, B30); a second timeout is `unavailable: timeout`. The summary's
22
- * `attempts` says how many ran;
22
+ * `attempts` says how many ran. B91: a timed-out attempt is a broken result like a schema
23
+ * miss: its signed `review.session_timeout` note carries the packet's `packet_hash`, and when
24
+ * it is the packet's second broken result (schema or timeout, in any mix) the second level
25
+ * (6b) is tried in the same session instead of the same-model retry (`trigger: timeout`);
23
26
  * 6b. a session whose answer fails the schema logs it (`review.schema_invalid`, the raw answer
24
- * redacted); at the packet's second such miss it is tried ONCE on `review.second_levels.<level>`
27
+ * redacted); at the packet's second broken result it is tried ONCE on `review.second_levels.<level>`
25
28
  * when the owner configured one (`schema-fallback.mjs`, B55; never in consensus mode).
26
29
  * The summary then carries `schema_fallback: {level, provider, model}` (`level`:
27
30
  * `review.second_levels.<level>`), the second level's own provider/model, and `tokens_in` /
28
31
  * `tokens_out` summed over both sessions;
29
- * 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`) —
30
34
  * except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
31
35
  * section packet runs the whole plan above (its own sessions, judge and stub guard, its own
32
36
  * `review.plan` row with `section`), in file order, stopping at the first one that is not
@@ -43,6 +47,11 @@
43
47
  * 9. a pure deletion (the file is gone from the reviewed tree) gets a `## deleted file` note in
44
48
  * every packet — reviewers and judge — naming up to 20 tree paths that still name its basename
45
49
  * (`deletion.mjs`, B74). It is a hint, never an exemption: the deletion still needs approval.
50
+ * 10. a test file that reads a source file and asserts on its text gets a `## test-quality` note in
51
+ * every packet — reviewers and judge — naming up to 10 line numbers, never the file's text
52
+ * (`test-quality.mjs`, B92). It is a hint, never a finding: the lens tells the reviewer to
53
+ * raise it as a `warning` unless the read file is a fixture. It is extra text like the
54
+ * moved code (after it), counted in the same measure; no flag ⇒ no section, no row.
46
55
  * Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
47
56
  * the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
48
57
  * `warning` finding.
@@ -56,14 +65,17 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
56
65
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
57
66
  import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
58
67
  import { tierFor } from '../proof/tiers.mjs';
59
- import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
68
+ import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, isSecretLike, readFileDiff } from './packet.mjs';
60
69
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
61
70
  import { deletionNote, renderDeletionNote } from './deletion.mjs';
71
+ import { testQualityNote } from './test-quality.mjs';
72
+ import { pairsFor, summarize } from './paired.mjs';
73
+ import { redact } from '../util/redact.mjs';
62
74
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
63
75
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
64
76
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
65
- import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
66
- import { assertRowPath } from '../worker/ticket.mjs';
77
+ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
78
+ import { assertRowPath, readRegularFileNoFollow } from '../worker/ticket.mjs';
67
79
 
68
80
  /**
69
81
  * @typedef {{lens: 'quick' | 'full' | 'A' | 'B' | 'judge', role: 'reviewer' | 'judge', level: 'L2' | 'L3', slot: 'A' | 'B' | null, cfg?: Record<string, any>}} SessionSpec -
@@ -176,6 +188,9 @@ export function rulesRisk({ file, plusCount, cfg }) {
176
188
  * code moved between this file and one of them is named in every packet (`## moved code`).
177
189
  * @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader
178
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.
179
194
  */
180
195
 
181
196
  /**
@@ -215,6 +230,31 @@ function safeNeeds(needs) {
215
230
  }
216
231
  }
217
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
+
218
258
  /**
219
259
  * @param {ReviewInput} input @param {ReviewDeps} deps
220
260
  * @returns {Promise<{status: string, approved: boolean, engine: string, sessions: Array<Record<string, any>>} & Record<string, any>>}
@@ -225,8 +265,8 @@ export async function reviewFile(input, deps) {
225
265
  const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, cfg });
226
266
  // no recorded kind ⇒ the reviewed file's own kind (a lone `.md` is a docs review), never a blind `code`
227
267
  const kind = typeof input.kind === 'string' ? input.kind : blockKind({ owned: [diff.file] });
228
- const plan = planSessions({ risk, cfg, kind });
229
- 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 };
230
270
  if (plan.refused) return { status: 'refused', reason: plan.refused, approved: false, ...head, sessions: [] };
231
271
  if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
232
272
  mkdirSync(workDir, { recursive: true, mode: 0o700 });
@@ -253,8 +293,12 @@ export async function reviewFile(input, deps) {
253
293
  deleted = renderDeletionNote(diff.file, { status: 'not_checked', paths: [], more: 0 }); // the review goes on
254
294
  }
255
295
  }
296
+ // B92: a test that reads a source file and asserts on its text gets the `## test-quality` hint
297
+ // (line numbers only), read once from the current content; '' for none
298
+ const quality = testQualityNote(diff.file, diff.content);
299
+ const paired = pairedContext(input);
256
300
  /** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
257
- const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys)].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
301
+ const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys), quality].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
258
302
  const min = minTokensOut(cfg);
259
303
  // B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
260
304
  const schemaFallback = plan.mode !== 'consensus';
@@ -268,7 +312,7 @@ export async function reviewFile(input, deps) {
268
312
  const paths = safeNeeds(needs);
269
313
  let extra = null;
270
314
  try {
271
- 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;
272
316
  } catch {
273
317
  extra = null;
274
318
  }
@@ -278,10 +322,10 @@ export async function reviewFile(input, deps) {
278
322
  return summary;
279
323
  };
280
324
 
281
- const reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
282
- 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');
283
327
  /** @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<number> | null} [keys] */
284
- 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 }));
285
329
 
286
330
  /**
287
331
  * The plan on one packet set (the whole file, or one Markdown section): reviewers, then the judge.
@@ -315,10 +359,25 @@ export async function reviewFile(input, deps) {
315
359
  return { status: 'reviewed', approved, summary: final.review.summary, findings, sessions };
316
360
  };
317
361
 
318
- const packets = packetsFor(diff);
319
- 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
+ }
320
379
  if (!split) {
321
- const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets);
380
+ const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets, stepUp);
322
381
  if (status !== 'reviewed') return { status, reason, approved: false, ...head, sessions };
323
382
  return { status, approved, ...head, summary, findings, sessions };
324
383
  }
@@ -383,8 +442,9 @@ export async function reviewFile(input, deps) {
383
442
  }
384
443
 
385
444
  /**
386
- * One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss, the
387
- * B55 second-level try (`schema-fallback.mjs`) on the same packet file before it is removed.
445
+ * One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss or
446
+ * (B91) a timed-out attempt, the B55 second-level try (`schema-fallback.mjs`) on the same packet
447
+ * file before it is removed.
388
448
  * @param {SessionSpec} spec @param {string} text
389
449
  * @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
390
450
  * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>, schemaFallback?: boolean}} env -
@@ -401,10 +461,39 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
401
461
  let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
402
462
  /** @type {{level: string, provider: string, model: string} | null} */
403
463
  let fellBackTo = null;
404
- /** @param {Record<string, any> | undefined} sessionCfg @param {number} done - attempts before this call. */
405
- const attempt = (sessionCfg, done) =>
464
+ const session = { lens: spec.lens, role: spec.role, level: spec.level };
465
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
466
+ // (each helper handles its own failed write)
467
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<ReviewDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
468
+ const hash = packetHash(text);
469
+ /** @type {{level: string, provider: string, model: string} | null} the second level once its try started */
470
+ let tried = null;
471
+ /** @param {Record<string, any> | null} missed @param {'schema' | 'timeout'} trigger */
472
+ const tryNext = (missed, trigger) =>
473
+ afterSchemaMiss({
474
+ res: missed,
475
+ session,
476
+ packetText: text,
477
+ cfg: spec.cfg ?? cfg,
478
+ allowed: schemaFallback && !spec.cfg,
479
+ history: deps.schemaHistory,
480
+ writeRow: write,
481
+ // the second level's attempts follow the ones already run in this session
482
+ spawnAt: (c, to) => {
483
+ tried = to; // the try started: whatever happens next, the session fell back
484
+ return attempt(c, attempts, true);
485
+ },
486
+ trigger,
487
+ });
488
+ /**
489
+ * @param {Record<string, any> | undefined} sessionCfg @param {number} done - attempts before this call.
490
+ * @param {boolean} [secondLevel] - B91: the second-level try (its timeouts never count, never try again).
491
+ * @returns {ReturnType<typeof spawnWithTimeoutRetry>}
492
+ */
493
+ const attempt = (sessionCfg, done, secondLevel = false) =>
406
494
  // a `timeout` is spawned once more on the same packet and level (B30); a spawn that throws
407
- // (bad config, refused argv) is an `exit` failure, never approval
495
+ // (bad config, refused argv) is an `exit` failure, never approval. B91: each timed-out attempt
496
+ // is a broken result of this packet; the second one tries the second level instead
408
497
  spawnWithTimeoutRetry(
409
498
  deps.spawn,
410
499
  {
@@ -419,6 +508,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
419
508
  (n) => {
420
509
  attempts = done + n;
421
510
  },
511
+ { packetHash: hash, secondLevel, ...(secondLevel ? {} : { onBroken: (/** @type {Record<string, any>} */ r) => tryNext(r, 'timeout') }) },
422
512
  );
423
513
  /** @type {number | null} the session's tokens: the first spawn's, plus a second-level try's */
424
514
  let tokensIn = null;
@@ -426,34 +516,42 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
426
516
  let tokensOut = null;
427
517
  // the packet file is removed on every path, and only after the second-level try (which reads it)
428
518
  try {
519
+ /** @type {Awaited<ReturnType<typeof spawnWithTimeoutRetry>> | null} */
520
+ let first = null;
429
521
  try {
430
- ({ res, attempts } = await attempt(spec.cfg, 0));
522
+ first = await attempt(spec.cfg, 0);
523
+ ({ res, attempts } = first);
431
524
  } catch {
432
- // defensive only: the helper never throws. If it ever did, it is still an `exit` failure,
433
- // never approval, with the attempts it had reported so far (at least 1).
525
+ // defensive only: the helpers never throw. If they ever did, it is still an `exit` failure,
526
+ // never approval, with the attempts reported so far (at least 1; a second-level try's spawns
527
+ // included) and, once that try started, its second level (never a same-model spawn after it).
434
528
  res = null;
435
529
  attempts = Math.max(attempts, 1);
530
+ fellBackTo = tried;
531
+ }
532
+ if (first?.to) {
533
+ // B91: a timed-out attempt was the packet's second broken result; the second level ran and
534
+ // `attempts` already counts its spawns. The verdict is set first: whatever throws after it
535
+ // leaves that verdict (a failed one stays a non-approval), never an exception
536
+ fellBackTo = first.to;
537
+ verdict = { ok: false, reason: 'exit', detail: 'second-level result not checked' };
538
+ try {
539
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
540
+ tokensIn = addTokens(first.broken?.usage?.tokens_in ?? null, res?.usage?.tokens_in);
541
+ tokensOut = addTokens(first.broken?.usage?.tokens_out ?? null, res?.usage?.tokens_out);
542
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: text, writeRow: write });
543
+ } catch {
544
+ // defensive only, as on the schema path below
545
+ }
546
+ } else {
547
+ tokensIn = res?.usage?.tokens_in ?? null;
548
+ tokensOut = res?.usage?.tokens_out ?? null;
549
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
436
550
  }
437
- tokensIn = res?.usage?.tokens_in ?? null;
438
- tokensOut = res?.usage?.tokens_out ?? null;
439
- verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
440
- if (!verdict.ok && verdict.reason === 'schema') {
551
+ if (!fellBackTo && !verdict.ok && verdict.reason === 'schema') {
441
552
  try {
442
- const session = { lens: spec.lens, role: spec.role, level: spec.level };
443
553
  const done = attempts;
444
- // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
445
- // (each helper handles its own failed write)
446
- const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<ReviewDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
447
- const second = await afterSchemaMiss({
448
- res,
449
- session,
450
- packetText: text,
451
- cfg: spec.cfg ?? cfg,
452
- allowed: schemaFallback && !spec.cfg,
453
- history: deps.schemaHistory,
454
- writeRow: write,
455
- spawnAt: (c) => attempt(c, done),
456
- });
554
+ const second = await tryNext(res, 'schema');
457
555
  if (second) {
458
556
  const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
459
557
  res = second.res;
@@ -467,6 +565,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
467
565
  } catch {
468
566
  // defensive only: the helpers never throw. If they ever did, the first session's result
469
567
  // and its `schema` verdict stand (never turned into `exit`, never approval)
568
+ fellBackTo = fellBackTo ?? tried;
470
569
  }
471
570
  }
472
571
  } finally {