@codedology/code-forge 0.7.1 → 0.7.3

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,29 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.3] — 2026-10-08
10
+
11
+ ### Added
12
+
13
+ - Review: a packet whose answer fails the schema twice is retried once on `review.second_levels.<level>` when configured
14
+ (signed `review.schema_fallback` row; never in consensus mode; budget and closed-book rules apply), and every schema
15
+ miss logs a structural diagnosis of the answer (`review.schema_invalid`: keys, types, schema errors or the parse error
16
+ offset, never the text); the autopilot binnacle shows each fallback as an incident (issue #2).
17
+
18
+ ## [0.7.2] — 2026-10-07
19
+
20
+ ### Changed
21
+
22
+ - review.budgets.full_in defaults to 32,000 tokens (was 12,000), and a Markdown file whose diff alone is still over it
23
+ is reviewed section by section (cut at its headings, outside fenced code; approved only when every section is;
24
+ findings as S<section>.<id>; rechecks split the same way) instead of split_required. Only a section that alone is over
25
+ the budget keeps the file split_required (issue #2).
26
+
27
+ ### Fixed
28
+
29
+ - A split_required result lost its size: the signed result, the ledger review.result row, review-file --wait (now stop:
30
+ split_required) and code-forge review output now carry tokens_in and budget (issue #2).
31
+
9
32
  ## [0.7.1] — 2026-10-07
10
33
 
11
34
  ### Fixed
package/README.md CHANGED
@@ -12,6 +12,20 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
12
12
 
13
13
  Next release:
14
14
 
15
+ - **A model that keeps failing the answer format gets a stand-in.** When a reviewer's answer
16
+ fails the schema twice on the same packet and you set `review.second_levels` for that level, the
17
+ worker retries that packet once with the second model, and the ledger records it. Each failed
18
+ answer's shape (its keys, types and schema errors, never its text) is logged, so you can see
19
+ what went wrong.
20
+
21
+ In 0.7.2:
22
+
23
+ - **Long documents get reviewed.** The packet budget (`review.budgets.full_in`) is now 32,000
24
+ tokens, and a Markdown file still too large is reviewed section by section instead of refused;
25
+ a `split_required` result now says its size and the budget.
26
+
27
+ In 0.7.1:
28
+
15
29
  - **Short clean reviews count.** A clean pass (no findings, every hunk acknowledged, a summary)
16
30
  needs only 40 output tokens on any diff size, so a correct short "no defect" review is no
17
31
  longer refused as `too_short`.
@@ -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": 56,
7
+ "block_count": 58,
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": {
@@ -762,6 +762,38 @@
762
762
  "B52"
763
763
  ],
764
764
  "note": "root-added 2026-10-07; GitHub issue #6; also amends src/review/engine.mjs (smallDiff removed), schema/code-forge.schema.json, docs/reference/config.md, docs/how-it-works.md, skill/references/review.md"
765
+ },
766
+ "B54": {
767
+ "title": "Long documents get reviewed (issue #2): review.budgets.full_in defaults to 32000; a Markdown file whose diff is still over the budget is reviewed section by section (hunks cut at headings outside fenced code and front matter, sections packed within the budget, every section packet built and checked before any session, approved only when every section is, findings as S<n>.<id>, rechecks sectioned the same way); a split_required result carries tokens_in, budget and section",
768
+ "wave": 12,
769
+ "level": "L2",
770
+ "status": "landed",
771
+ "owned": [
772
+ "src/review/sections.mjs",
773
+ "test/review/sections.test.mjs",
774
+ "test/review/sections-guard.test.mjs",
775
+ "test/worker/split-size.test.mjs"
776
+ ],
777
+ "depends_on": [
778
+ "B53"
779
+ ],
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"
765
797
  }
766
798
  }
767
799
  }
@@ -96,7 +96,7 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
96
96
  | `review.judge_sees_diff` | boolean | | `false` | |
97
97
  | `review.budgets` | object | | — | |
98
98
  | `review.budgets.quick_in` | integer | | `6000` | min 1 |
99
- | `review.budgets.full_in` | integer | | `12000` | min 1 |
99
+ | `review.budgets.full_in` | integer | | `32000` | min 1; Packet budget in tokens (bytes / 4) for a full, lens A/B or recheck review session. A diff alone over it is split_required, except a Markdown file, which is reviewed section by section (B54). |
100
100
  | `review.budgets.judge_in` | integer | | `8000` | min 1 |
101
101
  | `review.budgets.out` | integer | | `1200` | min 1 |
102
102
  | `review.context` | object | | — | |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",
@@ -117,7 +117,7 @@
117
117
  "additionalProperties": false,
118
118
  "properties": {
119
119
  "quick_in": { "type": "integer", "minimum": 1, "default": 6000 },
120
- "full_in": { "type": "integer", "minimum": 1, "default": 12000 },
120
+ "full_in": { "type": "integer", "minimum": 1, "default": 32000, "description": "Packet budget in tokens (bytes / 4) for a full, lens A/B or recheck review session. A diff alone over it is split_required, except a Markdown file, which is reviewed section by section (B54)." },
121
121
  "judge_in": { "type": "integer", "minimum": 1, "default": 8000 },
122
122
  "out": { "type": "integer", "minimum": 1, "default": 1200 }
123
123
  }
@@ -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`. 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. 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.1"
13
+ PINNED_VERSION="0.7.3"
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.' });
@@ -11,7 +11,9 @@
11
11
  * A done result that is not approved and carries open findings adds `fix_list` (what the coder
12
12
  * fixes before re-running `review-file <path>`, which is then a round-2 recheck of the fix hunks,
13
13
  * §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) — the coder
14
- * stops and reports; only the orchestrator and the human act on it.
14
+ * stops and reports; only the orchestrator and the human act on it. A `split_required` result
15
+ * (the packet is over `review.budgets.full_in`) adds `stop: split_required` with `tokens_in` and
16
+ * `budget` (always; null when unknown) and a Markdown `section` — never for another result (B54); a Markdown file gets there only when one of its sections alone is too large.
15
17
  *
16
18
  * Everything is a file read/write inside the workspace (a sandboxed coder has no network). The
17
19
  * path is given relative to the current directory and stored relative to the REPOSITORY ROOT
@@ -80,6 +82,12 @@ export function fixList(result) {
80
82
  }));
81
83
  }
82
84
  if (typeof result.stopped === 'string') out.stop = result.stopped;
85
+ else if (result.status === 'split_required') out.stop = 'split_required';
86
+ // B54: a split_required stop (only) says how large the packet was, the budget, and the section
87
+ if (out.stop === 'split_required') {
88
+ const fin = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
89
+ Object.assign(out, { tokens_in: fin(result.tokens_in), budget: fin(result.budget), ...(typeof result.section === 'string' ? { section: result.section } : {}) });
90
+ }
83
91
  return out;
84
92
  }
85
93
 
@@ -204,7 +204,9 @@ function lastJSON(text) {
204
204
 
205
205
  /**
206
206
  * @typedef {{file: string, result: 'approved' | 'unchanged' | 'findings' | 'stopped' | 'unavailable', reason: string | null,
207
- * findings: Array<{id: unknown, severity: unknown, lines: string, claim: string, fix: string}>}} FileOutcome
207
+ * findings: Array<{id: unknown, severity: unknown, lines: string, claim: string, fix: string}>,
208
+ * tokens_in?: number | null, budget?: number | null, section?: string}} FileOutcome - `tokens_in`/`budget`
209
+ * (null when unknown; and a Markdown `section`): a `split_required` stop's packet size, only then (B54).
208
210
  */
209
211
 
210
212
  /**
@@ -222,8 +224,11 @@ export function classify(file, waited) {
222
224
  if (!r || typeof r !== 'object') return out('unavailable', 'unreadable result');
223
225
  if (r.approved === true) return out('approved', null);
224
226
  if (r.status === 'no_change') return out('unchanged', null);
225
- if (r.status === 'stopped') return out('stopped', String(r.stopped ?? 'stopped'));
226
- if (r.status === 'split_required') return out('stopped', 'split_required');
227
+ // B54: a split_required stop (only) says how large the packet was and what the budget is
228
+ const fin = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
229
+ const size = { tokens_in: fin(r.tokens_in), budget: fin(r.budget), ...(typeof r.section === 'string' ? { section: r.section } : {}) };
230
+ if (r.status === 'stopped') return { ...out('stopped', String(r.stopped ?? 'stopped')), ...(r.stopped === 'split_required' ? size : {}) };
231
+ if (r.status === 'split_required') return { ...out('stopped', 'split_required'), ...size };
227
232
  if (r.status === 'reviewed' && findings.length > 0) return out('findings', null);
228
233
  return out('unavailable', String(r.reason ?? r.status ?? 'not approved'));
229
234
  }
@@ -231,6 +236,9 @@ export function classify(file, waited) {
231
236
  /** @param {FileOutcome} o @returns {string} */
232
237
  function label(o) {
233
238
  if (o.result === 'findings') return `${o.findings.length} finding${o.findings.length === 1 ? '' : 's'}`;
239
+ if (o.result === 'stopped' && o.reason === 'split_required' && Number.isFinite(o.tokens_in) && Number.isFinite(o.budget)) {
240
+ return `${o.result}: ${o.reason} (~${o.tokens_in} tokens, budget ${o.budget}${o.section ? `; section ${JSON.stringify(o.section)}` : ''})`;
241
+ }
234
242
  if (o.result === 'stopped' || o.result === 'unavailable') return `${o.result}: ${o.reason}`;
235
243
  return o.result;
236
244
  }
@@ -19,7 +19,21 @@
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;
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;
29
+ * 7. a diff alone over the packet budget is `split_required` (with `tokens_in` and `budget`) —
30
+ * except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
31
+ * section packet runs the whole plan above (its own sessions, judge and stub guard, its own
32
+ * `review.plan` row with `section`), in file order, stopping at the first one that is not
33
+ * reviewed (the file is then `unavailable`). The file is approved only when EVERY section is;
34
+ * the findings of all sections are merged with ids `S<section>.<id>`, every session summary
35
+ * names its `section`, and `sections` lists them. A section that alone is still over the
36
+ * budget keeps the file `split_required` (plus `section`: its heading) — never truncated.
23
37
  * Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
24
38
  * the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
25
39
  * `warning` finding.
@@ -32,9 +46,11 @@ import { resolveLevel } from '../config/known-ids.mjs';
32
46
  import { blockKind, isDocsKind } from '../decide/escalation.mjs';
33
47
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
34
48
  import { tierFor } from '../proof/tiers.mjs';
35
- import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, readFileDiff } from './packet.mjs';
49
+ import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
50
+ import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
36
51
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
37
52
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
53
+ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
38
54
  import { assertRowPath } from '../worker/ticket.mjs';
39
55
 
40
56
  /**
@@ -152,7 +168,10 @@ export function rulesRisk({ file, plusCount, cfg }) {
152
168
  * session (`spawnSession` opts minus the worker-pinned ones); a per-call `cfg` selects another
153
169
  * provider's level for the same slot (consensus).
154
170
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - ledger rows
155
- * (`review.plan`, `review.unavailable`); a failed write never fails the review.
171
+ * (`review.plan`, `review.unavailable`, `review.schema_invalid`, `review.schema_fallback`); a
172
+ * failed write never fails the review (a failed `review.schema_fallback` write means no try).
173
+ * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's
174
+ * signed schema-miss history (the worker's); absent ⇒ no second-level try.
156
175
  */
157
176
 
158
177
  /** @param {ReviewDeps} deps @param {Record<string, any>} row */
@@ -194,11 +213,13 @@ export async function reviewFile(input, deps) {
194
213
  if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
195
214
  mkdirSync(workDir, { recursive: true, mode: 0o700 });
196
215
  const min = minTokensOut(cfg);
216
+ // B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
217
+ const schemaFallback = plan.mode !== 'consensus';
197
218
 
198
219
  /** @param {SessionSpec} spec @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet */
199
220
  const run = async (spec, packet) => {
200
221
  /** @type {Record<string, any>} */
201
- let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg });
222
+ let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg, schemaFallback });
202
223
  const needs = summary.review?.needs_file;
203
224
  if (spec.role === 'reviewer' && summary.status === 'ok' && Array.isArray(needs) && needs.length > 0) {
204
225
  const paths = safeNeeds(needs);
@@ -209,59 +230,131 @@ export async function reviewFile(input, deps) {
209
230
  extra = null;
210
231
  }
211
232
  if (extra === null) return { ...summary, status: 'unavailable', reason: 'needs_file-refused', review: null };
212
- summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg })), needs_file_round: true };
233
+ summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg, schemaFallback })), needs_file_round: true };
213
234
  }
214
235
  return summary;
215
236
  };
216
237
 
217
238
  const reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
218
- const packets = reviewers.map((spec) => assemblePacket({ diff, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg }));
219
- const split = packets.find((p) => p.status === 'split_required');
220
- if (split) return { status: 'split_required', approved: false, ...head, tokens_in: split.tokensIn, budget: split.budget, sessions: [] };
221
- const firstPacket = /** @type {any} */ (packets[0]);
222
- await note(deps, { event: 'review.plan', depth_unconstrained: plan.depth, depth_chosen: plan.depth, degrade_step: 0, risk, context_mode: firstPacket?.contextMode ?? null, forecast_tokens: packets.reduce((n, p) => n + p.tokensIn, 0) });
223
- const done = await Promise.all(reviewers.map((spec, i) => run(spec, /** @type {any} */ (packets[i]))));
224
- const sessions = done.map(publicSummary);
225
- const failed = done.find((s) => s.status !== 'ok');
226
- if (failed) {
227
- await note(deps, { event: 'review.unavailable', reason: failed.reason, lens: failed.lens });
228
- return { status: 'unavailable', reason: failed.reason, approved: false, ...head, sessions };
229
- }
230
-
231
- let final = done[0];
232
239
  const judgeSpec = plan.sessions.find((s) => s.role === 'judge');
233
- if (judgeSpec) {
234
- const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
235
- const packet = assembleJudgePacket({ diff, reports: { A: bySlot.A, B: bySlot.B }, cfg });
236
- final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg });
237
- sessions.push(publicSummary(final));
238
- if (final.status !== 'ok') {
239
- await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge' });
240
- return { status: 'unavailable', reason: final.reason, approved: false, ...head, sessions };
240
+ /** @param {import('./packet.mjs').FileDiff} part */
241
+ const packetsFor = (part) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg }));
242
+
243
+ /**
244
+ * The plan on one packet set (the whole file, or one Markdown section): reviewers, then the judge.
245
+ * @param {import('./packet.mjs').FileDiff} part @param {Array<Record<string, any>>} packets
246
+ * @param {Record<string, any>} [rowExtra] - added to the `review.plan` row (`section`).
247
+ * @returns {Promise<{status: 'reviewed' | 'unavailable', reason?: string, approved: boolean, summary?: string, findings?: Array<Record<string, any>>, sessions: Array<Record<string, any>>}>}
248
+ */
249
+ const reviewPart = async (part, packets, rowExtra = {}) => {
250
+ 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 });
251
+ const done = await Promise.all(reviewers.map((spec, i) => run(spec, /** @type {any} */ (packets[i]))));
252
+ const sessions = done.map(publicSummary);
253
+ const failed = done.find((s) => s.status !== 'ok');
254
+ if (failed) {
255
+ await note(deps, { event: 'review.unavailable', reason: failed.reason, lens: failed.lens, ...rowExtra });
256
+ return { status: 'unavailable', reason: failed.reason, approved: false, sessions };
241
257
  }
258
+ let final = done[0];
259
+ if (judgeSpec) {
260
+ const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
261
+ const packet = assembleJudgePacket({ diff: part, reports: { A: bySlot.A, B: bySlot.B }, cfg });
262
+ final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg, schemaFallback });
263
+ sessions.push(publicSummary(final));
264
+ if (final.status !== 'ok') {
265
+ await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge', ...rowExtra });
266
+ return { status: 'unavailable', reason: final.reason, approved: false, sessions };
267
+ }
268
+ }
269
+ const findings = final.review.findings;
270
+ const approved = final.review.passed === true && findings.every((/** @type {any} */ f) => f.severity === 'nit');
271
+ return { status: 'reviewed', approved, summary: final.review.summary, findings, sessions };
272
+ };
273
+
274
+ const packets = packetsFor(diff);
275
+ const split = /** @type {{status: 'split_required', tokensIn: number, budget: number} | undefined} */ (packets.find((p) => p.status === 'split_required'));
276
+ if (!split) {
277
+ const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets);
278
+ if (status !== 'reviewed') return { status, reason, approved: false, ...head, sessions };
279
+ return { status, approved, ...head, summary, findings, sessions };
280
+ }
281
+ if (!isMarkdownFile(diff.file)) return { status: 'split_required', approved: false, ...head, tokens_in: split.tokensIn, budget: split.budget, sessions: [] };
282
+
283
+ // B54: a Markdown file over the budget is reviewed section by section
284
+ /** @param {import('./packet.mjs').FileDiff} part @returns {number} the diff-only packet tokens, largest over the lenses. */
285
+ const measure = (part) => Math.max(...reviewers.map((spec) => diffOnlyTokens({ diff: part, lens: spec.lens })));
286
+ const packed = packSections({ diff, budget: split.budget, measure });
287
+ /**
288
+ * A `split_required` outcome, its size always numeric (measured here when the packet lacks it).
289
+ * @param {import('./packet.mjs').FileDiff} part @param {{tokensIn?: unknown, budget?: unknown}} over @param {string | null} section
290
+ */
291
+ const splitOutcome = (part, over, section) => ({
292
+ status: 'split_required',
293
+ approved: false,
294
+ ...head,
295
+ tokens_in: Number.isFinite(over.tokensIn) ? over.tokensIn : measure(part),
296
+ budget: Number.isFinite(over.budget) ? over.budget : split.budget,
297
+ ...(section ? { section } : {}),
298
+ sessions: [],
299
+ });
300
+ if (packed.status !== 'ok') {
301
+ // a named section is measured on its own diff, never the whole file's
302
+ const group = packed.section ? sectionGroups(diff)?.find((g) => g.heading === packed.section) : undefined;
303
+ return splitOutcome(group ? group.diff : diff, packed, packed.section);
304
+ }
305
+ // never approve with no section: no packet ⇒ no session ⇒ split_required at the whole size
306
+ if (packed.sections.length === 0) return splitOutcome(diff, split, null);
307
+ // every section packet is assembled BEFORE any session runs. Packing and `assemblePacket` share
308
+ // one measure, so with the real packer none is ever not `ok`: this is a DEFENSIVE guard (a test
309
+ // reaches it through a mocked packer) — one that is not `ok` stops the file as split_required,
310
+ // sized on that section's own diff
311
+ const sectionPackets = packed.sections.map((section) => packetsFor(section.diff));
312
+ for (const [i, set] of sectionPackets.entries()) {
313
+ const over = /** @type {{tokensIn?: unknown, budget?: unknown} | undefined} */ (set.find((p) => p.status !== 'ok'));
314
+ if (over) return splitOutcome(packed.sections[i].diff, over, packed.sections[i].headings[0] ?? '(before first heading)');
315
+ }
316
+ const sections = packed.sections.map((s) => ({ index: s.index, headings: s.headings, hunks: s.diff.hunks.length }));
317
+ /** @type {Array<Record<string, any>>} */
318
+ const sessions = [];
319
+ /** @type {Array<Record<string, any>>} */
320
+ const findings = [];
321
+ const summaries = [];
322
+ let approved = true;
323
+ for (const [i, section] of packed.sections.entries()) {
324
+ const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length });
325
+ sessions.push(...part.sessions.map((s) => ({ ...s, section: section.index })));
326
+ if (part.status !== 'reviewed') return { status: part.status, reason: part.reason, approved: false, ...head, sections, sessions };
327
+ approved = approved && part.approved;
328
+ findings.push(...sectionFindings(/** @type {Array<{id: string}>} */ (part.findings), section.index));
329
+ summaries.push(`S${section.index}: ${part.summary}`);
242
330
  }
243
- const findings = final.review.findings;
244
- const approved = final.review.passed === true && findings.every((/** @type {any} */ f) => f.severity === 'nit');
245
- return { status: 'reviewed', approved, ...head, summary: final.review.summary, findings, sessions };
331
+ return { status: 'reviewed', approved, ...head, summary: summaries.join('\n'), findings, sessions, sections };
246
332
  }
247
333
 
248
334
  /**
249
- * One session: packet file (0600, removed after), spawn, stub guard.
335
+ * One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss, the
336
+ * B55 second-level try (`schema-fallback.mjs`) on the same packet file before it is removed.
250
337
  * @param {SessionSpec} spec @param {string} text
251
338
  * @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
252
- * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>}} env
339
+ * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>, schemaFallback?: boolean}} env -
340
+ * `schemaFallback: false` (consensus mode: its providers ARE the check) never tries.
253
341
  */
254
- async function runOne(spec, text, packet, { deps, workDir, min }) {
342
+ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallback = true }) {
255
343
  const promptPath = path.join(workDir, `${spec.lens}-${randomBytes(6).toString('hex')}.md`);
256
344
  const base = { lens: spec.lens, role: spec.role, level: spec.level, slot: spec.slot, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn };
257
345
  writeFileSync(promptPath, text, { mode: 0o600 });
258
346
  /** @type {Record<string, any> | null} */
259
347
  let res = null;
260
348
  let attempts = 0;
261
- try {
349
+ /** @type {import('./validate-review.mjs').Verdict} */
350
+ let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
351
+ /** @type {{level: string, provider: string, model: string} | null} */
352
+ let fellBackTo = null;
353
+ /** @param {Record<string, any> | undefined} sessionCfg @param {number} done - attempts before this call. */
354
+ const attempt = (sessionCfg, done) =>
262
355
  // a `timeout` is spawned once more on the same packet and level (B30); a spawn that throws
263
356
  // (bad config, refused argv) is an `exit` failure, never approval
264
- ({ res, attempts } = await spawnWithTimeoutRetry(
357
+ spawnWithTimeoutRetry(
265
358
  deps.spawn,
266
359
  {
267
360
  level: spec.level,
@@ -269,35 +362,88 @@ async function runOne(spec, text, packet, { deps, workDir, min }) {
269
362
  promptPath,
270
363
  schema: FINDING_SCHEMA,
271
364
  rowExtra: { lens: spec.lens, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn },
272
- ...(spec.cfg ? { cfg: spec.cfg } : {}),
365
+ ...(sessionCfg ? { cfg: sessionCfg } : {}),
273
366
  },
274
367
  (row) => note(deps, row),
275
368
  (n) => {
276
- attempts = n;
369
+ attempts = done + n;
277
370
  },
278
- ));
279
- } catch {
280
- // defensive only: the helper never throws. If it ever did, it is still an `exit` failure,
281
- // never approval, with the attempts it had reported so far (at least 1).
282
- res = null;
283
- attempts = Math.max(attempts, 1);
371
+ );
372
+ /** @type {number | null} the session's tokens: the first spawn's, plus a second-level try's */
373
+ let tokensIn = null;
374
+ /** @type {number | null} */
375
+ let tokensOut = null;
376
+ // the packet file is removed on every path, and only after the second-level try (which reads it)
377
+ try {
378
+ try {
379
+ ({ res, attempts } = await attempt(spec.cfg, 0));
380
+ } catch {
381
+ // defensive only: the helper never throws. If it ever did, it is still an `exit` failure,
382
+ // never approval, with the attempts it had reported so far (at least 1).
383
+ res = null;
384
+ attempts = Math.max(attempts, 1);
385
+ }
386
+ tokensIn = res?.usage?.tokens_in ?? null;
387
+ tokensOut = res?.usage?.tokens_out ?? null;
388
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
389
+ if (!verdict.ok && verdict.reason === 'schema') {
390
+ try {
391
+ const session = { lens: spec.lens, role: spec.role, level: spec.level };
392
+ const done = attempts;
393
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
394
+ // (each helper handles its own failed write)
395
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<ReviewDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
396
+ const second = await afterSchemaMiss({
397
+ res,
398
+ session,
399
+ packetText: text,
400
+ cfg: spec.cfg ?? cfg,
401
+ allowed: schemaFallback && !spec.cfg,
402
+ history: deps.schemaHistory,
403
+ writeRow: write,
404
+ spawnAt: (c) => attempt(c, done),
405
+ });
406
+ if (second) {
407
+ const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
408
+ res = second.res;
409
+ attempts = done + second.attempts;
410
+ fellBackTo = second.to;
411
+ verdict = secondVerdict;
412
+ tokensIn = addTokens(tokensIn, second.res?.usage?.tokens_in);
413
+ tokensOut = addTokens(tokensOut, second.res?.usage?.tokens_out);
414
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: text, writeRow: write });
415
+ }
416
+ } catch {
417
+ // defensive only: the helpers never throw. If they ever did, the first session's result
418
+ // and its `schema` verdict stand (never turned into `exit`, never approval)
419
+ }
420
+ }
284
421
  } finally {
285
422
  rmSync(promptPath, { force: true });
286
423
  }
287
- const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
288
424
  const meta = {
289
425
  ...base,
290
426
  provider: res?.provider ?? null,
291
427
  model: res?.model ?? null,
292
428
  fallback_step: res?.fallback_step ?? 0,
293
429
  attempts,
294
- tokens_in: res?.usage?.tokens_in ?? null,
295
- tokens_out: res?.usage?.tokens_out ?? null,
430
+ ...(fellBackTo ? { schema_fallback: fellBackTo } : {}),
431
+ tokens_in: tokensIn,
432
+ tokens_out: tokensOut,
296
433
  };
297
434
  if (!verdict.ok) return { ...meta, status: 'unavailable', reason: verdict.reason, review: null };
298
435
  return { ...meta, status: 'ok', reason: null, review: verdict.review };
299
436
  }
300
437
 
438
+ /**
439
+ * Two token counts added; a missing one (not a number) adds nothing, both missing ⇒ null.
440
+ * @param {unknown} a @param {unknown} b @returns {number | null}
441
+ */
442
+ function addTokens(a, b) {
443
+ if (typeof a !== 'number') return typeof b === 'number' ? b : null;
444
+ return typeof b === 'number' ? a + b : a;
445
+ }
446
+
301
447
  /** The per-session summary the worker signs: the answer's verdict and findings, never the packet. */
302
448
  function publicSummary(/** @type {Record<string, any>} */ s) {
303
449
  const { review, ...rest } = s;