@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 +23 -0
- package/README.md +14 -0
- package/docs/reference/blocks.json +33 -1
- package/docs/reference/config.md +1 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.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/cli/review-file.mjs +9 -1
- package/src/cli/review.mjs +11 -3
- package/src/review/engine.mjs +192 -46
- package/src/review/fixloop.mjs +212 -45
- package/src/review/packet.mjs +32 -7
- package/src/review/schema-fallback.mjs +351 -0
- package/src/review/sections.mjs +282 -0
- 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 +45 -4
- package/src/worker/loop.mjs +18 -0
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":
|
|
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
|
}
|
package/docs/reference/config.md
CHANGED
|
@@ -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 | | `
|
|
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
|
@@ -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":
|
|
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
|
|
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.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.' });
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -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
|
|
package/src/cli/review.mjs
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
226
|
-
|
|
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
|
}
|
package/src/review/engine.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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;
|