@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.
@@ -47,7 +47,21 @@
47
47
  * with no context at all, exceed the budget) is deterministic — re-running the round would build
48
48
  * the same packet — so it is TERMINAL, never a retry: `stopped`, `next: {action: 'stop', reason:
49
49
  * <packet.status>}`, and the gate-relevant `review.cap` row (the gate then refuses the file's open
50
- * findings until a human waives them or the file is reviewed again).
50
+ * findings until a human waives them or the file is reviewed again). The stop carries the packet's
51
+ * `tokens_in` and `budget` (and a Markdown section's heading as `section`) in `next` and in the
52
+ * `review.cap` row (B54).
53
+ *
54
+ * Markdown sections (B54): a Markdown file whose recheck packet is over the budget is rechecked
55
+ * SECTION BY SECTION (`sections.mjs`, `packRecheck`: packets sized with their open-findings list)
56
+ * instead: one recheck session per section packet, each listing only the open findings that fall
57
+ * in it (`sectionOf`: the spanning or nearest section), each through the stub guard (its
58
+ * own `reviewed_hunks`). A finding closes only when ITS section's session resolves it; new
59
+ * findings get ids `S<section>.<id>`; any section session unavailable ⇒ `retry` of the whole
60
+ * round. A section still over the budget alone ⇒ the terminal stop above (`split_required`).
61
+ *
62
+ * Schema misses (B55): a recheck (or patch_check) session whose answer fails the schema at its
63
+ * packet's second miss is tried once on `review.second_levels.L2` when configured
64
+ * (`schema-fallback.mjs`); only when that fails too is the round `unavailable` as below.
51
65
  *
52
66
  * A session that fails the stub guard is `unavailable`, and a failed write of a gate-relevant row
53
67
  * (`review.late_finding`, `review.round`, `review.cap`) is `ledger_write_failed`: either way the
@@ -64,10 +78,12 @@ import { verifyRow } from '../state/signer.mjs';
64
78
  import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
65
79
  import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
66
80
  import { parseDiff } from './context.mjs';
67
- import { assemblePacket, assertPacketPath, readFileDiff } from './packet.mjs';
81
+ import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
82
+ import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
68
83
  import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
69
84
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
70
85
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
86
+ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
71
87
 
72
88
  /** @typedef {import('./triage.mjs').Finding} Finding */
73
89
  /** @typedef {import('./context.mjs').Hunk} Hunk */
@@ -78,6 +94,9 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
78
94
  * @property {string} [level] - the level the next fix is coded at.
79
95
  * @property {string | null} [trigger] - the escalation trigger that moved the level, if any.
80
96
  * @property {string} [reason] - `review_cap`, `l3_patch_exhausted`, a non-ok packet status (`split_required`), … for `stop`; the stub-guard reason for `retry`.
97
+ * @property {number} [tokens_in] - a `split_required` stop: the estimated tokens of the diff-only packet (B54).
98
+ * @property {number} [budget] - a `split_required` stop: the packet budget it exceeded (B54).
99
+ * @property {string} [section] - a `split_required` stop of a Markdown recheck: the heading of the section that alone is over (B54).
81
100
  */
82
101
 
83
102
  /**
@@ -93,7 +112,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
93
112
  * @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
94
113
  * @property {'open' | 'complete' | 'stopped'} status
95
114
  * @property {NextStep | null} next
96
- * @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck packet's context.
115
+ * @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck round's context: one packet's, or over a Markdown recheck's section packets the summed lines and the one mode they share, else `mixed` (B54).
97
116
  * @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
98
117
  */
99
118
 
@@ -112,6 +131,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
112
131
  * @property {string | null} [base] - the block base (only for `recheck_scope: file`).
113
132
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
114
133
  * @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
134
+ * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's signed schema-miss history; absent ⇒ no second-level try.
115
135
  */
116
136
 
117
137
  /** @param {LoopDeps} deps @param {Record<string, any>} row */
@@ -123,6 +143,58 @@ async function note(deps, row) {
123
143
  }
124
144
  }
125
145
 
146
+ /**
147
+ * Token counts over a round's sessions: a missing count (null/undefined) adds nothing, and no
148
+ * count at all stays null (one session without usage ⇒ null, as before B54).
149
+ * @param {number | null | undefined} a @param {number | null | undefined} b @returns {number | null}
150
+ */
151
+ export const sum = (a, b) => (typeof a !== 'number' ? (typeof b === 'number' ? b : null) : typeof b === 'number' ? a + b : a);
152
+
153
+ /**
154
+ * The round's `last_packet` over its recheck packets (B54): the context lines add up; the context
155
+ * mode is the packets' one mode when they all agree, else `mixed`.
156
+ * @param {{context_mode: string, context_lines: number} | null} info
157
+ * @param {{contextMode: string, contextLines: number}} packet
158
+ * @returns {{context_mode: string, context_lines: number}}
159
+ */
160
+ export function mergePacketInfo(info, packet) {
161
+ if (info === null) return { context_mode: packet.contextMode, context_lines: packet.contextLines };
162
+ return { context_mode: info.context_mode === packet.contextMode ? info.context_mode : 'mixed', context_lines: info.context_lines + packet.contextLines };
163
+ }
164
+
165
+ /**
166
+ * Pack a Markdown recheck into section packets (B54), each sized with {@link recheckTokens} over
167
+ * its own diff and its own open findings. Each open finding belongs to ONE heading group — the one
168
+ * `sectionOf` picks over the groups (spanning, else nearest) — and is sized and listed in the
169
+ * packet that holds that group, so sizing and listing never disagree. No group at all (no hunk,
170
+ * or hunks that do not parse one to one) ⇒ `split_required` at the whole size, never sectioned;
171
+ * a finding no packet holds is listed in the LAST packet — every open finding is listed once.
172
+ * @param {import('./packet.mjs').FileDiff} diff @param {Finding[]} open @param {number} budget
173
+ * @returns {{status: 'ok', sections: Array<import('./sections.mjs').Section & {open: Finding[]}>} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
174
+ */
175
+ export function packRecheck(diff, open, budget) {
176
+ const groups = sectionGroups(diff);
177
+ if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
178
+ /** @type {Map<Finding, number>} */
179
+ const keyOf = new Map();
180
+ for (const f of open) {
181
+ const at = sectionOf(groups, f);
182
+ if (at >= 0) keyOf.set(f, groups[at].key);
183
+ }
184
+ /** @param {ReadonlyArray<number>} keys @returns {Finding[]} the open findings of those groups, in order. */
185
+ const openIn = (keys) => open.filter((f) => keyOf.has(f) && keys.includes(/** @type {number} */ (keyOf.get(f))));
186
+ const packed = packSections({ diff, budget, measure: (part, set) => recheckTokens(part, openIn(set.map((g) => g.key))) });
187
+ if (packed.status !== 'ok') return packed;
188
+ const sections = packed.sections.map((sec) => ({ ...sec, open: openIn(sec.keys) }));
189
+ if (sections.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
190
+ // defensive: a finding no packet holds goes to the last packet (it is sized there by the check
191
+ // every packet passes before any session) — never dropped, never closed unseen
192
+ const listed = new Set(sections.flatMap((sec) => sec.open));
193
+ const last = /** @type {(typeof sections)[number]} */ (sections.at(-1));
194
+ for (const f of open) if (!listed.has(f)) last.open.push(f);
195
+ return { status: 'ok', sections };
196
+ }
197
+
126
198
  /** @param {unknown} v @param {number} d @returns {number} `v` when a non-negative integer, else `d`. */
127
199
  const int = (v, d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
128
200
 
@@ -247,6 +319,25 @@ export async function fixHunkDiff({ file, previous, current, workDir }) {
247
319
  }
248
320
  }
249
321
 
322
+ /** The heading of the open-findings list a recheck packet ends with. */
323
+ const OPEN_FINDINGS_HEAD = '## open findings\n';
324
+
325
+ /** @param {Finding} f @returns {string} the finding's line in a recheck packet's open-findings list. */
326
+ export const openFindingLine = (f) => `- ${f.id} (${f.severity}, lines ${f.line_start}-${f.line_end}): ${f.claim ?? ''}`;
327
+
328
+ /** @param {ReadonlyArray<Finding>} open @returns {string} the open-findings list a recheck packet ends with. */
329
+ const openFindingsText = (open) => `${OPEN_FINDINGS_HEAD}${open.length > 0 ? open.map(openFindingLine).join('\n') : '(none)'}\n`;
330
+
331
+ /**
332
+ * The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
333
+ * `split_required`) plus the open-findings list `buildRecheckPacket` appends. Section packing and
334
+ * the check before any session use this ONE function.
335
+ * @param {import('./packet.mjs').FileDiff} diff @param {ReadonlyArray<Finding>} open @returns {number}
336
+ */
337
+ export function recheckTokens(diff, open) {
338
+ return diffOnlyTokens({ diff, lens: 'recheck' }) + Math.ceil(Buffer.byteLength(openFindingsText(open)) / 4);
339
+ }
340
+
250
341
  /**
251
342
  * The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
252
343
  * the open findings by id. Over budget ⇒ `minimal` (see the module doc).
@@ -255,8 +346,7 @@ export async function fixHunkDiff({ file, previous, current, workDir }) {
255
346
  export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt }) {
256
347
  const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt });
257
348
  if (packet.status !== 'ok') return packet;
258
- const listed = open.map((f) => `- ${f.id} (${f.severity}, lines ${f.line_start}-${f.line_end}): ${f.claim ?? ''}`);
259
- const text = `${packet.text}## open findings\n${listed.length > 0 ? listed.join('\n') : '(none)'}\n`;
349
+ const text = `${packet.text}${openFindingsText(open)}`;
260
350
  return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
261
351
  }
262
352
 
@@ -269,7 +359,8 @@ export function insideHunks(f, hunks) {
269
359
  }
270
360
 
271
361
  /**
272
- * One recheck session through the stub guard.
362
+ * One recheck session through the stub guard — and, on a `schema` miss, the B55 second-level try
363
+ * on the same packet (`schema-fallback.mjs`; rechecks and the patch_check alike).
273
364
  * @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
274
365
  * @param {LoopDeps} deps
275
366
  */
@@ -277,26 +368,62 @@ async function recheckSession(packet, deps) {
277
368
  mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
278
369
  const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
279
370
  writeFileSync(promptPath, packet.text, { mode: 0o600 });
371
+ const min = minTokensOut(deps.cfg);
372
+ const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
373
+ /** @param {Record<string, any> | undefined} cfg */
374
+ const attempt = async (cfg) =>
375
+ deps.spawn
376
+ ? // a `timeout` is spawned once more on the same packet and level (B30)
377
+ spawnWithTimeoutRetry(
378
+ deps.spawn,
379
+ { level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn }, ...(cfg ? { cfg } : {}) },
380
+ (row) => note(deps, row),
381
+ )
382
+ : { res: null, attempts: 0 };
280
383
  /** @type {Record<string, any> | null} */
281
384
  let res = null;
385
+ /** @type {import('./validate-review.mjs').Verdict} */
386
+ let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
387
+ /** @type {number | null} the round's tokens: the first session's, plus a second-level try's */
388
+ let tokensIn = null;
389
+ /** @type {number | null} */
390
+ let tokensOut = null;
391
+ // the packet file is removed on every path, and only after the second-level try (which reads it)
282
392
  try {
283
- // a `timeout` is spawned once more on the same packet and level (B30)
284
- res = deps.spawn
285
- ? (
286
- await spawnWithTimeoutRetry(
287
- deps.spawn,
288
- { level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn } },
289
- (row) => note(deps, row),
290
- )
291
- ).res
292
- : null;
293
- } catch {
294
- res = null; // the helper never throws; if it ever did, it is still an `exit` failure
393
+ try {
394
+ res = (await attempt(undefined)).res;
395
+ } catch {
396
+ res = null; // the helper never throws; if it ever did, it is still an `exit` failure
397
+ }
398
+ tokensIn = res?.usage?.tokens_in ?? null;
399
+ tokensOut = res?.usage?.tokens_out ?? null;
400
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
401
+ if (!verdict.ok && verdict.reason === 'schema') {
402
+ try {
403
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
404
+ // (each helper handles its own failed write)
405
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
406
+ const second = await afterSchemaMiss({ res, session, packetText: packet.text, cfg: deps.cfg ?? {}, allowed: true /* rechecks are never consensus; the budget gate is inside the spawn */, history: deps.schemaHistory, writeRow: write, spawnAt: attempt });
407
+ if (second) {
408
+ // compute everything first, then assign together, so a throw never leaves a mixed state
409
+ const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
410
+ const sumIn = sum(tokensIn, second.res?.usage?.tokens_in);
411
+ const sumOut = sum(tokensOut, second.res?.usage?.tokens_out);
412
+ res = second.res;
413
+ verdict = secondVerdict;
414
+ tokensIn = sumIn;
415
+ tokensOut = sumOut;
416
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: packet.text, writeRow: write });
417
+ }
418
+ } catch {
419
+ // defensive only: the helpers never throw. If they ever did, the first session's result
420
+ // and its `schema` verdict stand (never turned into `exit`, never approval)
421
+ }
422
+ }
295
423
  } finally {
296
424
  rmSync(promptPath, { force: true });
297
425
  }
298
- const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: minTokensOut(deps.cfg) });
299
- return { verdict, tokens_in: res?.usage?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? null };
426
+ return { verdict, tokens_in: tokensIn, tokens_out: tokensOut };
300
427
  }
301
428
 
302
429
  /**
@@ -373,14 +500,20 @@ function retry(state, kind, reason) {
373
500
  * not written, the open set stays as it was, and the gate-relevant `review.cap` row goes through
374
501
  * the must-write path — a failed write is a `retry`, never a silent stop.
375
502
  * @param {FileState} state @param {LoopDeps} deps
376
- * @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file @param {string} reason
503
+ * @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file
504
+ * @param {{status: string, tokensIn?: number, budget?: number, section?: string | null}} packet - the non-ok packet (a Markdown section names its heading).
377
505
  * @returns {Promise<FileState>}
378
506
  */
379
- async function stopBeforeRound(state, deps, kind, file, reason) {
380
- const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id) };
507
+ async function stopBeforeRound(state, deps, kind, file, packet) {
508
+ const reason = packet.status;
509
+ const size =
510
+ typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
511
+ ? { tokens_in: packet.tokensIn, budget: packet.budget, ...(typeof packet.section === 'string' ? { section: packet.section } : {}) }
512
+ : {};
513
+ const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
381
514
  if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
382
515
  state.status = 'stopped';
383
- state.next = { action: 'stop', reason };
516
+ state.next = { action: 'stop', reason, ...size };
384
517
  state.pending_kind = null;
385
518
  return state;
386
519
  }
@@ -434,34 +567,68 @@ export async function runRound(state, deps, opts = {}) {
434
567
  }
435
568
  draft.open = [];
436
569
  if (pending.length > 0) {
437
- const packet = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode: s.scope === 'file' ? 'auto' : 'recheck', rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
438
- // deterministic (the same hunks build the same packet): terminal, never a retry
439
- if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packet.status);
440
- packetInfo = { context_mode: packet.contextMode, context_lines: packet.contextLines };
441
- const session = await recheckSession(packet, deps);
442
- tokensIn = session.tokens_in;
443
- tokensOut = session.tokens_out;
444
- if (!session.verdict.ok) {
445
- await note(deps, { event: 'review.unavailable', file: current.rel, lens: 'recheck', reason: session.verdict.reason });
446
- return retry(state, kind, session.verdict.reason ?? 'unavailable');
570
+ const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
571
+ const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
572
+ /** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[]}>} */
573
+ let parts = [{ index: null, heading: null, diff, open: pending }];
574
+ if (whole.status !== 'ok') {
575
+ // deterministic (the same hunks build the same packet): terminal, never a retry — except
576
+ // a Markdown file whose packet is `split_required`, rechecked section by section (B54);
577
+ // any other status stops as it always did
578
+ const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget) : null;
579
+ if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
580
+ parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open }));
447
581
  }
448
- const review = /** @type {Record<string, any>} */ (session.verdict.review);
449
- const resolvedIds = new Set((review.resolved ?? []).filter((/** @type {any} */ r) => r?.resolved === true).map((/** @type {any} */ r) => r.id));
450
- const stillOpen = pending.filter((f) => !resolvedIds.has(f.id));
451
- closed += pending.length - stillOpen.length;
582
+ // EVERY packet is built and held to the budget BEFORE any session runs: a section over it
583
+ // stops the round as split_required (with its heading) before anything is spent
584
+ /** @type {Array<Record<string, any>>} */
585
+ const built = [];
586
+ for (const part of parts) {
587
+ if (part.index === null) {
588
+ built.push(whole);
589
+ continue;
590
+ }
591
+ const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
592
+ // a packet that fails to build stops with ITS status (and size, when it has one)
593
+ if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
594
+ const size = recheckTokens(part.diff, part.open);
595
+ if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
596
+ built.push(packet);
597
+ }
598
+ /** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
599
+ const resolved = new Set();
452
600
  /** @type {Finding[]} */
453
601
  const inside = [];
454
602
  /** @type {Finding[]} */
455
603
  const blocking = [];
456
- for (const f of /** @type {Finding[]} */ (review.findings ?? [])) {
457
- if (insideHunks(f, diff.hunks)) {
458
- inside.push(f);
459
- continue;
604
+ for (const [i, part] of parts.entries()) {
605
+ const packet = /** @type {any} */ (built[i]);
606
+ packetInfo = mergePacketInfo(packetInfo, packet);
607
+ const session = await recheckSession(packet, deps);
608
+ tokensIn = sum(tokensIn, session.tokens_in);
609
+ tokensOut = sum(tokensOut, session.tokens_out);
610
+ if (!session.verdict.ok) {
611
+ await note(deps, { event: 'review.unavailable', file: current.rel, lens: 'recheck', reason: session.verdict.reason, ...(part.index === null ? {} : { section: part.index }) });
612
+ return retry(state, kind, session.verdict.reason ?? 'unavailable');
613
+ }
614
+ const review = /** @type {Record<string, any>} */ (session.verdict.review);
615
+ const resolvedIds = new Set((review.resolved ?? []).filter((/** @type {any} */ r) => r?.resolved === true).map((/** @type {any} */ r) => r.id));
616
+ // only the findings listed in THIS packet can be resolved by its session
617
+ for (const f of part.open) if (resolvedIds.has(f.id)) resolved.add(f);
618
+ const found = /** @type {Finding[]} */ (review.findings ?? []);
619
+ for (const f of part.index === null ? found : sectionFindings(found, part.index)) {
620
+ if (insideHunks(f, diff.hunks)) {
621
+ inside.push(f);
622
+ continue;
623
+ }
624
+ draft.late.push(f);
625
+ lateRows.push({ event: 'review.late_finding', file: current.rel, round: state.round + 1, finding: f.id, severity: f.severity, line_start: f.line_start, line_end: f.line_end, mode: s.late });
626
+ if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
460
627
  }
461
- draft.late.push(f);
462
- lateRows.push({ event: 'review.late_finding', file: current.rel, round: state.round + 1, finding: f.id, severity: f.severity, line_start: f.line_start, line_end: f.line_end, mode: s.late });
463
- if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
464
628
  }
629
+ // every pending finding stays open unless the session that listed it resolved it
630
+ const stillOpen = pending.filter((f) => !resolved.has(f));
631
+ closed += pending.length - stillOpen.length;
465
632
  const triaged = await triageFindings({ file: current.rel, findings: inside, fromJudge: false, diffText: diff.diffText, cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
466
633
  newInHunks = triaged.fix_now.length;
467
634
  draft.open = [...stillOpen, ...triaged.fix_now, ...blocking];
@@ -17,9 +17,10 @@
17
17
  * (`git diff --no-index -- /dev/null <file>`). A tracked file with no change yields no packet.
18
18
  *
19
19
  * Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
20
- * bytes / 4. A diff that alone exceeds it ⇒ `split_required`. Otherwise over budget ⇒ the context
21
- * shrinks to ± `min_context_lines` (`minimal`) FIRST, then the digest is trimmed from the end;
22
- * the diff is never cut.
20
+ * bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
21
+ * section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
22
+ * ± `min_context_lines` (`minimal`) FIRST, then the digest is trimmed from the end; the diff is
23
+ * never cut.
23
24
  *
24
25
  * Path rule (V4): `file` is repo-root-relative; `./`, `../`, absolute values, symlinks and paths
25
26
  * under `.git/` or `.code-forge/` are refused with `bad-path` before git runs. So is anything git
@@ -40,7 +41,8 @@ export const LENSES = Object.freeze(['quick', 'full', 'recheck', 'A', 'B', 'judg
40
41
  /** The rules digest never exceeds this many lines (§4.3). */
41
42
  export const DIGEST_MAX_LINES = 60;
42
43
 
43
- export const DEFAULT_BUDGETS = Object.freeze({ quick_in: 6000, full_in: 12000, judge_in: 8000 });
44
+ /** `full_in` was 12 000 until B54: a 1,590-line design brief (~25k tokens) was refused unreviewed. */
45
+ export const DEFAULT_BUDGETS = Object.freeze({ quick_in: 6000, full_in: 32000, judge_in: 8000 });
44
46
 
45
47
  const LENS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'lenses');
46
48
  const GIT_TIMEOUT_MS = 30000;
@@ -191,9 +193,9 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
191
193
  const lensText = loadLens(lens);
192
194
  const limit = budget ?? budgetFor(cfg, lens === 'quick' ? 'quick_in' : 'full_in');
193
195
  const hunkHeaders = diff.hunks.map((h) => h.header);
194
- const diffSection = [`file: ${diff.file}`, 'hunks:', ...hunkHeaders.map((h) => `- ${h}`), '', diff.diffText.trimEnd()].join('\n');
195
- const bare = render(lensText, [], '', '', diffSection);
196
- if (estimateTokens(bare) > limit) return { status: 'split_required', tokensIn: estimateTokens(bare), budget: limit };
196
+ const diffSection = diffSectionOf(diff);
197
+ const bare = bareTokens(lensText, diffSection);
198
+ if (bare > limit) return { status: 'split_required', tokensIn: bare, budget: limit };
197
199
 
198
200
  let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
199
201
  let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
@@ -210,6 +212,29 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
210
212
  return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
211
213
  }
212
214
 
215
+ /**
216
+ * The tokens of the diff-only packet (lens + diff, no digest, facts or context): what
217
+ * `assemblePacket` holds against the budget before it says `split_required`.
218
+ * @param {{diff: FileDiff, lens: string}} opts @returns {number}
219
+ */
220
+ export function diffOnlyTokens({ diff, lens }) {
221
+ return bareTokens(loadLens(lens), diffSectionOf(diff));
222
+ }
223
+
224
+ /**
225
+ * The ONE measure behind `split_required` (`assemblePacket`) and section packing (`diffOnlyTokens`,
226
+ * B54), so the two can never disagree.
227
+ * @param {string} lensText @param {string} diffSection @returns {number}
228
+ */
229
+ function bareTokens(lensText, diffSection) {
230
+ return estimateTokens(render(lensText, [], '', '', diffSection));
231
+ }
232
+
233
+ /** @param {FileDiff} diff @returns {string} the `## diff` section body. */
234
+ function diffSectionOf(diff) {
235
+ return [`file: ${diff.file}`, 'hunks:', ...diff.hunks.map((h) => `- ${h.header}`), '', diff.diffText.trimEnd()].join('\n');
236
+ }
237
+
213
238
  /**
214
239
  * @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
215
240
  * @param {string} diffSection