@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 +20 -0
- package/README.md +15 -0
- package/docs/reference/blocks.json +33 -1
- package/package.json +1 -1
- package/skill/references/review.md +2 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/binnacle.mjs +29 -1
- package/src/review/engine.mjs +135 -33
- package/src/review/fixloop.mjs +146 -33
- package/src/review/moved.mjs +399 -0
- package/src/review/packet.mjs +32 -23
- package/src/review/schema-fallback.mjs +351 -0
- package/src/review/sections.mjs +5 -4
- package/src/review/validate-review.mjs +11 -0
- package/src/session/spawn.mjs +3 -86
- package/src/util/diagnostic-tail.mjs +95 -0
- package/src/worker/engine.mjs +78 -8
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":
|
|
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
|
@@ -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
|
|
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
|
|
package/skill/scripts/forge
CHANGED
|
@@ -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.
|
|
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.' });
|
package/src/review/engine.mjs
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
273
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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;
|