@codedology/code-forge 0.7.2 → 0.7.4

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.4] — 2026-10-08
10
+
11
+ ### Added
12
+
13
+ - Review: code moved between two changed files of the same block is named in a ## moved code packet section
14
+ (deterministic line matching against the block's other changed owned files, same base: >= 3 significant lines or >=
15
+ 120 significant characters; at most 12 ranges; a Markdown section packet lists only the moves in that section) in
16
+ round 1, rechecks, patch checks and the judge packet, so the source file's reviewer no longer reports the removal as a
17
+ deleted feature; every hunk and the reviewed_hunks check are unchanged, and the section counts toward the packet
18
+ budget (issue #2).
19
+
20
+ ## [0.7.3] — 2026-10-08
21
+
22
+ ### Added
23
+
24
+ - Review: a packet whose answer fails the schema twice is retried once on `review.second_levels.<level>` when configured
25
+ (signed `review.schema_fallback` row; never in consensus mode; budget and closed-book rules apply), and every schema
26
+ miss logs a structural diagnosis of the answer (`review.schema_invalid`: keys, types, schema errors or the parse error
27
+ offset, never the text); the autopilot binnacle shows each fallback as an incident (issue #2).
28
+
9
29
  ## [0.7.2] — 2026-10-07
10
30
 
11
31
  ### Changed
package/README.md CHANGED
@@ -12,6 +12,21 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
12
12
 
13
13
  Next release:
14
14
 
15
+ - **Code moved between files is no longer read as deleted.** When a block moves code from one
16
+ file to another, each file's review packet now says where the lines went (or came from), so the
17
+ reviewer of the old file stops reporting the removal as a lost feature, also on rechecks. Every
18
+ hunk is still reviewed.
19
+
20
+ In 0.7.3:
21
+
22
+ - **A model that keeps failing the answer format gets a stand-in.** When a reviewer's answer
23
+ fails the schema twice on the same packet and you set `review.second_levels` for that level, the
24
+ worker retries that packet once with the second model, and the ledger records it. Each failed
25
+ answer's shape (its keys, types and schema errors, never its text) is logged, so you can see
26
+ what went wrong.
27
+
28
+ In 0.7.2:
29
+
15
30
  - **Long documents get reviewed.** The packet budget (`review.budgets.full_in`) is now 32,000
16
31
  tokens, and a Markdown file still too large is reviewed section by section instead of refused;
17
32
  a `split_required` result now says its size and the budget.
@@ -4,7 +4,7 @@
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 57,
7
+ "block_count": 59,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -778,6 +778,38 @@
778
778
  "B53"
779
779
  ],
780
780
  "note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-02 (25k-token brief never reviewed); also amends src/review/{packet,engine,fixloop}.mjs, src/worker/{engine,loop}.mjs, src/cli/{review,review-file}.mjs, schema/code-forge.schema.json, docs/reference/config.md, docs/how-it-works.md, skill/references/review.md"
781
+ },
782
+ "B55": {
783
+ "title": "Schema-miss fallback (issue #2): a review answer that fails the schema twice on the same packet (same run, file, content, packet text, level and role) is retried once on review.second_levels.<level> (its ladder minus the failed model and, for closed-book roles, minus Codex unless allowed) behind a signed review.schema_fallback row written before the spawn; never in consensus mode; tokens of both sessions summed; every miss logs a structural diagnosis (review.schema_invalid: bytes, kind, keys and types, schema errors, never the text); the autopilot binnacle shows each fallback",
784
+ "wave": 12,
785
+ "level": "L2",
786
+ "status": "landed",
787
+ "owned": [
788
+ "src/review/schema-fallback.mjs",
789
+ "src/util/diagnostic-tail.mjs",
790
+ "test/review/schema-fallback.test.mjs",
791
+ "test/worker/schema-fallback-wiring.test.mjs"
792
+ ],
793
+ "depends_on": [
794
+ "B54"
795
+ ],
796
+ "note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-06; also amends src/review/{engine,fixloop,validate-review}.mjs, src/worker/engine.mjs, src/session/spawn.mjs (stderr tail moved to util), src/autopilot/binnacle.mjs, test fakes, docs/how-it-works.md, skill/references/review.md"
797
+ },
798
+ "B56": {
799
+ "title": "Moved-code hint (issue #2): code moved between changed files of the same block is detected by deterministic line matching (>= 3 significant lines or >= 120 significant characters; peers in the base diffed against it, deleted peers included) and named in a ## moved code packet section on both files, in round 1, rechecks, patch checks, the judge packet and per Markdown section; extra text only (every hunk and reviewed_hunks unchanged); counted in the packet budget with one measure; unsafe peer names never read or printed; peer diffs read once per ticket with a 2 MiB per-peer cap",
800
+ "wave": 12,
801
+ "level": "L2",
802
+ "status": "landed",
803
+ "owned": [
804
+ "src/review/moved.mjs",
805
+ "test/review/moved.test.mjs",
806
+ "test/review/moved-wiring.test.mjs",
807
+ "test/worker/moved-peers-guard.test.mjs"
808
+ ],
809
+ "depends_on": [
810
+ "B55"
811
+ ],
812
+ "note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-06 (stale critical from a cross-file move); also amends src/review/{packet,engine,fixloop,sections}.mjs, src/worker/engine.mjs, docs/how-it-works.md, skill/references/review.md"
781
813
  }
782
814
  }
783
815
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,11 +15,11 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
15
15
 
16
16
  ## §3 Isolation and the packet
17
17
 
18
- Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
18
+ Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. Moved code (B56): code moved between two changed files of the same block is named in a `## moved code` section before the diff (`- base lines a-b removed here were added in <file> lines c-d in this block.` / `- lines c-d were moved here from <file> (its base lines a-b) in this block.`), in round 1, rechecks, patch checks and the judge's packet. Detection is deterministic line matching against the block's other changed owned files diffed against the same base: lines trimmed with whitespace collapsed, lines with fewer than 4 letters or digits ignored, a moved stretch needs ≥ 3 such lines or ≥ 120 such characters; at most 12 ranges are listed; a Markdown section packet lists only the moves under its own headings (none ⇒ no section); the peers are listed and read once per review (one `git ls-tree <base>`, one `git diff <base>` over the peers that exist in the base — a peer deleted in the block, staged or not, is all removed lines — one `git check-ignore` and one `git diff --no-index` per new peer; `a/`/`b/` prefixes forced, `core.quotePath=false`; a peer name with a control, bidi or separator character, a quote or a backslash is skipped; a peer whose diff is over 2 MiB is skipped, never buffered past that); a removed block cut right above a heading belongs to the section holding its removed hunk; a move inside one file is not detected. It is extra text counted in the packet budget: every hunk stays, `reviewed_hunks` is unchanged, and a removal listed there is not a defect by itself — only stale references or imports are. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
19
19
 
20
20
  ## §4 The stub guard — what counts as a review (R11)
21
21
 
22
- A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
22
+ A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; `review-file` on the same content re-runs the same packet once, then the file stays unreviewed and the block cannot close. Every `schema` miss leaves a signed `review.schema_invalid` row with a structural diagnosis of the answer — its size and kind (`json`, `not-json`, `empty`), its top-level keys and their types, and the schema errors (path, keyword, message), or the parse error and its byte offset — never the answer's text. At a packet's **second** `schema` miss (same run, file, content hash, packet text, level and role — the first try plus that re-run), the worker tries the packet ONCE on `review.second_levels.<level>` when the owner configured one, and writes a signed `review.schema_fallback {lens, role, level, packet_hash, from_provider, from_model, to, to_provider, to_model, attempts, refused?}` row first (no row written ⇒ no try; any earlier row for the packet at that level ⇒ no try). Its first model that is not the failed one — and, for a closed-book role, not Codex unless `review.allow_open_book_codex` — runs (`refused: same-model` or `refused: closed-book` when there is none). The session's tokens are both sessions'. Its answer goes through the same stub guard; a valid one is a normal answer, a failed one leaves the file unavailable. Never in consensus mode; `budget.usd` refuses it like any session. Without a second level only the diagnosis row is new. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
23
23
 
24
24
  ## §5 The convergence rule — bounded fix rounds (R15)
25
25
 
@@ -10,7 +10,7 @@
10
10
  # copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
11
11
  # equals package.json's version.
12
12
  # The shim holds no logic of its own: every verb, flag and message is the CLI's.
13
- PINNED_VERSION="0.7.2"
13
+ PINNED_VERSION="0.7.4"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -39,7 +39,7 @@ export const LOG_FILE = 'autopilot-log.md';
39
39
  export const NONE_YET = 'none yet';
40
40
 
41
41
  /** The ledger events (besides every `autopilot.*`) that belong to the log and the timeline. */
42
- const EXTRA_EVENTS = Object.freeze(['block.close', 'review.session_timeout', 'worker.down', 'worker.replaced', 'ledger.tamper', 'budget.refused', 'gate.red', 'gate.done']);
42
+ const EXTRA_EVENTS = Object.freeze(['block.close', 'review.session_timeout', 'review.schema_fallback', 'worker.down', 'worker.replaced', 'ledger.tamper', 'budget.refused', 'gate.red', 'gate.done']);
43
43
 
44
44
  /** @typedef {Record<string, any>} Row */
45
45
  /** @typedef {{home?: string | null, cwd?: string | null}} ScrubCtx */
@@ -146,6 +146,8 @@ function describe(row) {
146
146
  return { event: 'Block closed', detail: `status ${row.status ?? 'unknown'}${short(first(row.commit, row.sha, row.commit_sha)) ? ` · commit ${short(first(row.commit, row.sha, row.commit_sha))}` : ''}`, link: first(row.pr_url, row.pr) };
147
147
  case 'review.session_timeout':
148
148
  return { event: 'Review session timed out', detail: `${row.lens ?? row.role ?? 'session'} · attempt ${u(row.attempt)} · ${row.retried === true ? 'retried' : 'gave up'}`, link: null };
149
+ case 'review.schema_fallback':
150
+ return { event: 'Review answer off-schema twice', detail: `${row.lens ?? row.role ?? 'session'} · ${u(row.from_model, 'unknown model')} → ${u(row.to_model, 'unknown model')} · ${typeof row.refused === 'string' ? `refused (${u(row.refused)})` : 'retried once'}`, link: null };
149
151
  case 'worker.down':
150
152
  return { event: 'Worker down', detail: 'the pinned worker is not running', link: null };
151
153
  case 'worker.replaced':
@@ -181,6 +183,31 @@ export function buildFullLog({ runId, rows, scrubCtx }) {
181
183
  /** @param {Row} row @returns {string} the subject line of an event, e.g. ` · b1 · src/a.mjs`. */
182
184
  const where = (row) => `${str(row.block) ? ` · ${u(row.block)}` : ''}${str(row.file) ? ` · ${u(row.file)}` : ''}`;
183
185
 
186
+ /**
187
+ * B55: what the owner does about a refused second-level try, by refusal. `schema-fallback.mjs`
188
+ * writes `closed-book` (every model left is Codex for a closed-book role) and `same-model`
189
+ * (nothing but the failed model is configured). No writer puts `budget` in this row today — the
190
+ * budget gate refuses inside the spawn and writes its own `budget.refused` row — so `budget` is
191
+ * shown only when a row carries it; any other string gets the generic fix.
192
+ */
193
+ const SCHEMA_FALLBACK_FIX = Object.freeze({
194
+ 'closed-book': 'Set a second level that can run closed-book (not Codex), or review the file by hand.',
195
+ 'same-model': 'Set review.second_levels to a different model than the one that failed, or review the file by hand.',
196
+ budget: 'Raise the budget (only you can) or review the file by hand.',
197
+ });
198
+
199
+ /**
200
+ * The incident of a `review.schema_fallback` row (B55): retried once, or refused and why.
201
+ * @param {Row} r @param {string | null} time
202
+ * @returns {{time: string | null, what: string, effect: string, fix: string}}
203
+ */
204
+ function schemaFallbackIncident(r, time) {
205
+ const what = `A ${r.lens ?? r.role ?? 'review'} answer failed the schema twice on the same packet (${u(r.from_model, 'unknown model')})`;
206
+ if (typeof r.refused !== 'string') return { time, what, effect: `It was retried once on the second level (${u(r.to_model, 'unknown model')}).`, fix: "Check the file's next review result." };
207
+ const fix = Object.hasOwn(SCHEMA_FALLBACK_FIX, r.refused) ? SCHEMA_FALLBACK_FIX[/** @type {keyof typeof SCHEMA_FALLBACK_FIX} */ (r.refused)] : 'Review the file by hand.';
208
+ return { time, what, effect: `The second-level try was refused (${u(r.refused)}); the review stays unavailable.`, fix };
209
+ }
210
+
184
211
  /**
185
212
  * @param {Row[]} blockRows rows of one block @param {Row | undefined} entry its run-record entry
186
213
  * @param {string} id
@@ -285,6 +312,7 @@ export function buildBinnacle({ runId, rows, record, now, scrubCtx = scrubCtxOf(
285
312
  for (const { row, time } of mine) {
286
313
  const r = row;
287
314
  if (r.event === 'review.session_timeout') incidents.push({ time, what: `A ${r.lens ?? r.role ?? 'review'} session timed out (attempt ${u(r.attempt)})`, effect: r.retried === true ? 'It was retried.' : 'No retry left; the review was marked unavailable.', fix: r.retried === true ? 'None needed unless it repeats.' : 'Run the review again.' });
315
+ else if (r.event === 'review.schema_fallback') incidents.push(schemaFallbackIncident(r, time));
288
316
  else if (r.event === 'worker.down' || r.event === 'worker.replaced') incidents.push({ time, what: r.event === 'worker.down' ? 'The pinned worker was down' : 'The pinned worker was replaced', effect: 'Reviews wait until the worker is back.', fix: 'Restart the worker for this run.' });
289
317
  else if (r.event === 'ledger.tamper') incidents.push({ time, what: `A ledger check failed for block ${r.block ?? 'unknown'}`, effect: 'The block was stopped.', fix: 'Look at the block before reopening it.' });
290
318
  else if (r.event === 'autopilot.issue_failed') incidents.push({ time, what: `The waiver issue for ${u(r.finding, 'unknown finding')} in ${u(r.file, 'unknown file')} could not be opened`, effect: 'The waiver stands without an issue.', fix: 'Open the tracking issue by hand.' });
@@ -20,6 +20,12 @@
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
22
  * `attempts` says how many ran;
23
+ * 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>`
25
+ * when the owner configured one (`schema-fallback.mjs`, B55; never in consensus mode).
26
+ * The summary then carries `schema_fallback: {level, provider, model}` (`level`:
27
+ * `review.second_levels.<level>`), the second level's own provider/model, and `tokens_in` /
28
+ * `tokens_out` summed over both sessions;
23
29
  * 7. a diff alone over the packet budget is `split_required` (with `tokens_in` and `budget`) —
24
30
  * except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
25
31
  * section packet runs the whole plan above (its own sessions, judge and stub guard, its own
@@ -27,7 +33,13 @@
27
33
  * reviewed (the file is then `unavailable`). The file is approved only when EVERY section is;
28
34
  * the findings of all sections are merged with ids `S<section>.<id>`, every session summary
29
35
  * names its `section`, and `sections` lists them. A section that alone is still over the
30
- * budget keeps the file `split_required` (plus `section`: its heading) — never truncated.
36
+ * budget keeps the file `split_required` (plus `section`: its heading) — never truncated;
37
+ * 8. code moved between this file and another changed file of the block (`input.peers`, both
38
+ * diffed against the block base) is named in a `## moved code` section of every packet —
39
+ * reviewers and judge; a Markdown section packet lists only the moves in that section
40
+ * (`moved.mjs`, B56). The peers' diffs are read once per review (`input.peerDiffs`, or a
41
+ * reader built from `input.peers`). It is extra text: every hunk still goes to the reviewer,
42
+ * and it counts toward the budget in the same measure.
31
43
  * Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
32
44
  * the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
33
45
  * `warning` finding.
@@ -41,9 +53,11 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
41
53
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
42
54
  import { tierFor } from '../proof/tiers.mjs';
43
55
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
56
+ import { movedText, movesFor, peerDiffReader } from './moved.mjs';
44
57
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
45
58
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
46
59
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
60
+ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
47
61
  import { assertRowPath } from '../worker/ticket.mjs';
48
62
 
49
63
  /**
@@ -153,6 +167,10 @@ export function rulesRisk({ file, plusCount, cfg }) {
153
167
  * @property {string} [kind] - the block kind (`code`, `docs`, `contract`; B34); absent ⇒ `blockKind` of the reviewed file.
154
168
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
155
169
  * @property {string} workDir - where packet files are written (under the run's temp root).
170
+ * @property {ReadonlyArray<string>} [peers] - B56: the block's OTHER changed files (repo-relative);
171
+ * code moved between this file and one of them is named in every packet (`## moved code`).
172
+ * @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader
173
+ * of those files' diffs (`peerDiffReader`), shared by the caller across the round; wins over `peers`.
156
174
  */
157
175
 
158
176
  /**
@@ -161,7 +179,10 @@ export function rulesRisk({ file, plusCount, cfg }) {
161
179
  * session (`spawnSession` opts minus the worker-pinned ones); a per-call `cfg` selects another
162
180
  * provider's level for the same slot (consensus).
163
181
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - ledger rows
164
- * (`review.plan`, `review.unavailable`); a failed write never fails the review.
182
+ * (`review.plan`, `review.unavailable`, `review.schema_invalid`, `review.schema_fallback`); a
183
+ * failed write never fails the review (a failed `review.schema_fallback` write means no try).
184
+ * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's
185
+ * signed schema-miss history (the worker's); absent ⇒ no second-level try.
165
186
  */
166
187
 
167
188
  /** @param {ReviewDeps} deps @param {Record<string, any>} row */
@@ -202,12 +223,25 @@ export async function reviewFile(input, deps) {
202
223
  if (plan.refused) return { status: 'refused', reason: plan.refused, approved: false, ...head, sessions: [] };
203
224
  if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
204
225
  mkdirSync(workDir, { recursive: true, mode: 0o700 });
226
+ // B56: the file's moves, read once; every packet and every measure of a part uses `movedOf`
227
+ /** @type {import('./moved.mjs').Moves | null} */
228
+ let moves = null;
229
+ try {
230
+ const peerDiffs = input.peerDiffs ?? peerDiffReader({ repoRoot, base: input.base, file: diff.file, peers: input.peers });
231
+ moves = await movesFor({ diff, peerDiffs });
232
+ } catch {
233
+ moves = null; // no hint; the review goes on
234
+ }
235
+ /** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
236
+ const movedOf = (keys) => movedText(moves, diff.content, keys);
205
237
  const min = minTokensOut(cfg);
238
+ // B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
239
+ const schemaFallback = plan.mode !== 'consensus';
206
240
 
207
241
  /** @param {SessionSpec} spec @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet */
208
242
  const run = async (spec, packet) => {
209
243
  /** @type {Record<string, any>} */
210
- let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg });
244
+ let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg, schemaFallback });
211
245
  const needs = summary.review?.needs_file;
212
246
  if (spec.role === 'reviewer' && summary.status === 'ok' && Array.isArray(needs) && needs.length > 0) {
213
247
  const paths = safeNeeds(needs);
@@ -218,23 +252,24 @@ export async function reviewFile(input, deps) {
218
252
  extra = null;
219
253
  }
220
254
  if (extra === null) return { ...summary, status: 'unavailable', reason: 'needs_file-refused', review: null };
221
- summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg })), needs_file_round: true };
255
+ summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg, schemaFallback })), needs_file_round: true };
222
256
  }
223
257
  return summary;
224
258
  };
225
259
 
226
260
  const reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
227
261
  const judgeSpec = plan.sessions.find((s) => s.role === 'judge');
228
- /** @param {import('./packet.mjs').FileDiff} part */
229
- const packetsFor = (part) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg }));
262
+ /** @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<number> | null} [keys] */
263
+ const packetsFor = (part, keys = null) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg, moved: movedOf(keys) }));
230
264
 
231
265
  /**
232
266
  * The plan on one packet set (the whole file, or one Markdown section): reviewers, then the judge.
233
267
  * @param {import('./packet.mjs').FileDiff} part @param {Array<Record<string, any>>} packets
234
268
  * @param {Record<string, any>} [rowExtra] - added to the `review.plan` row (`section`).
269
+ * @param {ReadonlyArray<number> | null} [keys] - the section's group keys (its moved-code hint).
235
270
  * @returns {Promise<{status: 'reviewed' | 'unavailable', reason?: string, approved: boolean, summary?: string, findings?: Array<Record<string, any>>, sessions: Array<Record<string, any>>}>}
236
271
  */
237
- const reviewPart = async (part, packets, rowExtra = {}) => {
272
+ const reviewPart = async (part, packets, rowExtra = {}, keys = null) => {
238
273
  await note(deps, { event: 'review.plan', depth_unconstrained: plan.depth, depth_chosen: plan.depth, degrade_step: 0, risk, context_mode: packets[0]?.contextMode ?? null, forecast_tokens: packets.reduce((n, p) => n + p.tokensIn, 0), ...rowExtra });
239
274
  const done = await Promise.all(reviewers.map((spec, i) => run(spec, /** @type {any} */ (packets[i]))));
240
275
  const sessions = done.map(publicSummary);
@@ -246,8 +281,8 @@ export async function reviewFile(input, deps) {
246
281
  let final = done[0];
247
282
  if (judgeSpec) {
248
283
  const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
249
- const packet = assembleJudgePacket({ diff: part, reports: { A: bySlot.A, B: bySlot.B }, cfg });
250
- final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg });
284
+ const packet = assembleJudgePacket({ diff: part, reports: { A: bySlot.A, B: bySlot.B }, cfg, moved: movedOf(keys) });
285
+ final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg, schemaFallback });
251
286
  sessions.push(publicSummary(final));
252
287
  if (final.status !== 'ok') {
253
288
  await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge', ...rowExtra });
@@ -269,18 +304,25 @@ export async function reviewFile(input, deps) {
269
304
  if (!isMarkdownFile(diff.file)) return { status: 'split_required', approved: false, ...head, tokens_in: split.tokensIn, budget: split.budget, sessions: [] };
270
305
 
271
306
  // B54: a Markdown file over the budget is reviewed section by section
272
- /** @param {import('./packet.mjs').FileDiff} part @returns {number} the diff-only packet tokens, largest over the lenses. */
273
- const measure = (part) => Math.max(...reviewers.map((spec) => diffOnlyTokens({ diff: part, lens: spec.lens })));
307
+ /**
308
+ * @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<{key: number}> | null} [groups] - null ⇒ the whole file.
309
+ * @returns {number} the diff-only packet tokens, largest over the lenses.
310
+ */
311
+ const measure = (part, groups = null) => {
312
+ const moved = movedOf(groups === null ? null : groups.map((g) => g.key));
313
+ return Math.max(...reviewers.map((spec) => diffOnlyTokens({ diff: part, lens: spec.lens, moved })));
314
+ };
274
315
  const packed = packSections({ diff, budget: split.budget, measure });
275
316
  /**
276
317
  * A `split_required` outcome, its size always numeric (measured here when the packet lacks it).
277
318
  * @param {import('./packet.mjs').FileDiff} part @param {{tokensIn?: unknown, budget?: unknown}} over @param {string | null} section
319
+ * @param {ReadonlyArray<{key: number}> | null} [groups] - the part's heading groups (its moved-code hint); null ⇒ the whole file.
278
320
  */
279
- const splitOutcome = (part, over, section) => ({
321
+ const splitOutcome = (part, over, section, groups = null) => ({
280
322
  status: 'split_required',
281
323
  approved: false,
282
324
  ...head,
283
- tokens_in: Number.isFinite(over.tokensIn) ? over.tokensIn : measure(part),
325
+ tokens_in: Number.isFinite(over.tokensIn) ? over.tokensIn : measure(part, groups),
284
326
  budget: Number.isFinite(over.budget) ? over.budget : split.budget,
285
327
  ...(section ? { section } : {}),
286
328
  sessions: [],
@@ -288,7 +330,7 @@ export async function reviewFile(input, deps) {
288
330
  if (packed.status !== 'ok') {
289
331
  // a named section is measured on its own diff, never the whole file's
290
332
  const group = packed.section ? sectionGroups(diff)?.find((g) => g.heading === packed.section) : undefined;
291
- return splitOutcome(group ? group.diff : diff, packed, packed.section);
333
+ return splitOutcome(group ? group.diff : diff, packed, packed.section, group ? [group] : null);
292
334
  }
293
335
  // never approve with no section: no packet ⇒ no session ⇒ split_required at the whole size
294
336
  if (packed.sections.length === 0) return splitOutcome(diff, split, null);
@@ -296,10 +338,10 @@ export async function reviewFile(input, deps) {
296
338
  // one measure, so with the real packer none is ever not `ok`: this is a DEFENSIVE guard (a test
297
339
  // reaches it through a mocked packer) — one that is not `ok` stops the file as split_required,
298
340
  // sized on that section's own diff
299
- const sectionPackets = packed.sections.map((section) => packetsFor(section.diff));
341
+ const sectionPackets = packed.sections.map((section) => packetsFor(section.diff, section.keys));
300
342
  for (const [i, set] of sectionPackets.entries()) {
301
343
  const over = /** @type {{tokensIn?: unknown, budget?: unknown} | undefined} */ (set.find((p) => p.status !== 'ok'));
302
- if (over) return splitOutcome(packed.sections[i].diff, over, packed.sections[i].headings[0] ?? '(before first heading)');
344
+ if (over) return splitOutcome(packed.sections[i].diff, over, packed.sections[i].headings[0] ?? '(before first heading)', packed.sections[i].keys.map((key) => ({ key })));
303
345
  }
304
346
  const sections = packed.sections.map((s) => ({ index: s.index, headings: s.headings, hunks: s.diff.hunks.length }));
305
347
  /** @type {Array<Record<string, any>>} */
@@ -309,7 +351,7 @@ export async function reviewFile(input, deps) {
309
351
  const summaries = [];
310
352
  let approved = true;
311
353
  for (const [i, section] of packed.sections.entries()) {
312
- const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length });
354
+ const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length }, section.keys);
313
355
  sessions.push(...part.sessions.map((s) => ({ ...s, section: section.index })));
314
356
  if (part.status !== 'reviewed') return { status: part.status, reason: part.reason, approved: false, ...head, sections, sessions };
315
357
  approved = approved && part.approved;
@@ -320,22 +362,29 @@ export async function reviewFile(input, deps) {
320
362
  }
321
363
 
322
364
  /**
323
- * One session: packet file (0600, removed after), spawn, stub guard.
365
+ * One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss, the
366
+ * B55 second-level try (`schema-fallback.mjs`) on the same packet file before it is removed.
324
367
  * @param {SessionSpec} spec @param {string} text
325
368
  * @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
326
- * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>}} env
369
+ * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>, schemaFallback?: boolean}} env -
370
+ * `schemaFallback: false` (consensus mode: its providers ARE the check) never tries.
327
371
  */
328
- async function runOne(spec, text, packet, { deps, workDir, min }) {
372
+ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallback = true }) {
329
373
  const promptPath = path.join(workDir, `${spec.lens}-${randomBytes(6).toString('hex')}.md`);
330
374
  const base = { lens: spec.lens, role: spec.role, level: spec.level, slot: spec.slot, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn };
331
375
  writeFileSync(promptPath, text, { mode: 0o600 });
332
376
  /** @type {Record<string, any> | null} */
333
377
  let res = null;
334
378
  let attempts = 0;
335
- try {
379
+ /** @type {import('./validate-review.mjs').Verdict} */
380
+ let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
381
+ /** @type {{level: string, provider: string, model: string} | null} */
382
+ let fellBackTo = null;
383
+ /** @param {Record<string, any> | undefined} sessionCfg @param {number} done - attempts before this call. */
384
+ const attempt = (sessionCfg, done) =>
336
385
  // a `timeout` is spawned once more on the same packet and level (B30); a spawn that throws
337
386
  // (bad config, refused argv) is an `exit` failure, never approval
338
- ({ res, attempts } = await spawnWithTimeoutRetry(
387
+ spawnWithTimeoutRetry(
339
388
  deps.spawn,
340
389
  {
341
390
  level: spec.level,
@@ -343,35 +392,88 @@ async function runOne(spec, text, packet, { deps, workDir, min }) {
343
392
  promptPath,
344
393
  schema: FINDING_SCHEMA,
345
394
  rowExtra: { lens: spec.lens, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn },
346
- ...(spec.cfg ? { cfg: spec.cfg } : {}),
395
+ ...(sessionCfg ? { cfg: sessionCfg } : {}),
347
396
  },
348
397
  (row) => note(deps, row),
349
398
  (n) => {
350
- attempts = n;
399
+ attempts = done + n;
351
400
  },
352
- ));
353
- } catch {
354
- // defensive only: the helper never throws. If it ever did, it is still an `exit` failure,
355
- // never approval, with the attempts it had reported so far (at least 1).
356
- res = null;
357
- attempts = Math.max(attempts, 1);
401
+ );
402
+ /** @type {number | null} the session's tokens: the first spawn's, plus a second-level try's */
403
+ let tokensIn = null;
404
+ /** @type {number | null} */
405
+ let tokensOut = null;
406
+ // the packet file is removed on every path, and only after the second-level try (which reads it)
407
+ try {
408
+ try {
409
+ ({ res, attempts } = await attempt(spec.cfg, 0));
410
+ } catch {
411
+ // defensive only: the helper never throws. If it ever did, it is still an `exit` failure,
412
+ // never approval, with the attempts it had reported so far (at least 1).
413
+ res = null;
414
+ attempts = Math.max(attempts, 1);
415
+ }
416
+ tokensIn = res?.usage?.tokens_in ?? null;
417
+ tokensOut = res?.usage?.tokens_out ?? null;
418
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
419
+ if (!verdict.ok && verdict.reason === 'schema') {
420
+ try {
421
+ const session = { lens: spec.lens, role: spec.role, level: spec.level };
422
+ const done = attempts;
423
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
424
+ // (each helper handles its own failed write)
425
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<ReviewDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
426
+ const second = await afterSchemaMiss({
427
+ res,
428
+ session,
429
+ packetText: text,
430
+ cfg: spec.cfg ?? cfg,
431
+ allowed: schemaFallback && !spec.cfg,
432
+ history: deps.schemaHistory,
433
+ writeRow: write,
434
+ spawnAt: (c) => attempt(c, done),
435
+ });
436
+ if (second) {
437
+ const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
438
+ res = second.res;
439
+ attempts = done + second.attempts;
440
+ fellBackTo = second.to;
441
+ verdict = secondVerdict;
442
+ tokensIn = addTokens(tokensIn, second.res?.usage?.tokens_in);
443
+ tokensOut = addTokens(tokensOut, second.res?.usage?.tokens_out);
444
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: text, writeRow: write });
445
+ }
446
+ } catch {
447
+ // defensive only: the helpers never throw. If they ever did, the first session's result
448
+ // and its `schema` verdict stand (never turned into `exit`, never approval)
449
+ }
450
+ }
358
451
  } finally {
359
452
  rmSync(promptPath, { force: true });
360
453
  }
361
- const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
362
454
  const meta = {
363
455
  ...base,
364
456
  provider: res?.provider ?? null,
365
457
  model: res?.model ?? null,
366
458
  fallback_step: res?.fallback_step ?? 0,
367
459
  attempts,
368
- tokens_in: res?.usage?.tokens_in ?? null,
369
- tokens_out: res?.usage?.tokens_out ?? null,
460
+ ...(fellBackTo ? { schema_fallback: fellBackTo } : {}),
461
+ tokens_in: tokensIn,
462
+ tokens_out: tokensOut,
370
463
  };
371
464
  if (!verdict.ok) return { ...meta, status: 'unavailable', reason: verdict.reason, review: null };
372
465
  return { ...meta, status: 'ok', reason: null, review: verdict.review };
373
466
  }
374
467
 
468
+ /**
469
+ * Two token counts added; a missing one (not a number) adds nothing, both missing ⇒ null.
470
+ * @param {unknown} a @param {unknown} b @returns {number | null}
471
+ */
472
+ function addTokens(a, b) {
473
+ if (typeof a !== 'number') return typeof b === 'number' ? b : null;
474
+ return typeof b === 'number' ? a + b : a;
475
+ }
476
+
375
477
  /** The per-session summary the worker signs: the answer's verdict and findings, never the packet. */
376
478
  function publicSummary(/** @type {Record<string, any>} */ s) {
377
479
  const { review, ...rest } = s;