@codedology/code-forge 0.8.0 → 0.10.0
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 +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
package/src/review/engine.mjs
CHANGED
|
@@ -19,9 +19,12 @@
|
|
|
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. B91: a timed-out attempt is a broken result like a schema
|
|
23
|
+
* miss: its signed `review.session_timeout` note carries the packet's `packet_hash`, and when
|
|
24
|
+
* it is the packet's second broken result (schema or timeout, in any mix) the second level
|
|
25
|
+
* (6b) is tried in the same session instead of the same-model retry (`trigger: timeout`);
|
|
23
26
|
* 6b. a session whose answer fails the schema logs it (`review.schema_invalid`, the raw answer
|
|
24
|
-
* redacted); at the packet's second
|
|
27
|
+
* redacted); at the packet's second broken result it is tried ONCE on `review.second_levels.<level>`
|
|
25
28
|
* when the owner configured one (`schema-fallback.mjs`, B55; never in consensus mode).
|
|
26
29
|
* The summary then carries `schema_fallback: {level, provider, model}` (`level`:
|
|
27
30
|
* `review.second_levels.<level>`), the second level's own provider/model, and `tokens_in` /
|
|
@@ -40,12 +43,20 @@
|
|
|
40
43
|
* (`moved.mjs`, B56). The peers' diffs are read once per review (`input.peerDiffs`, or a
|
|
41
44
|
* reader built from `input.peers`). It is extra text: every hunk still goes to the reviewer,
|
|
42
45
|
* and it counts toward the budget in the same measure.
|
|
46
|
+
* 9. a pure deletion (the file is gone from the reviewed tree) gets a `## deleted file` note in
|
|
47
|
+
* every packet — reviewers and judge — naming up to 20 tree paths that still name its basename
|
|
48
|
+
* (`deletion.mjs`, B74). It is a hint, never an exemption: the deletion still needs approval.
|
|
49
|
+
* 10. a test file that reads a source file and asserts on its text gets a `## test-quality` note in
|
|
50
|
+
* every packet — reviewers and judge — naming up to 10 line numbers, never the file's text
|
|
51
|
+
* (`test-quality.mjs`, B92). It is a hint, never a finding: the lens tells the reviewer to
|
|
52
|
+
* raise it as a `warning` unless the read file is a fixture. It is extra text like the
|
|
53
|
+
* moved code (after it), counted in the same measure; no flag ⇒ no section, no row.
|
|
43
54
|
* Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
|
|
44
55
|
* the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
|
|
45
56
|
* `warning` finding.
|
|
46
57
|
*/
|
|
47
58
|
|
|
48
|
-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
59
|
+
import { lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
49
60
|
import { randomBytes } from 'node:crypto';
|
|
50
61
|
import path from 'node:path';
|
|
51
62
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
@@ -55,10 +66,12 @@ import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
|
|
|
55
66
|
import { tierFor } from '../proof/tiers.mjs';
|
|
56
67
|
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
57
68
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
69
|
+
import { deletionNote, renderDeletionNote } from './deletion.mjs';
|
|
70
|
+
import { testQualityNote } from './test-quality.mjs';
|
|
58
71
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
|
|
59
72
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
60
73
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
61
|
-
import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
|
|
74
|
+
import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
|
|
62
75
|
import { assertRowPath } from '../worker/ticket.mjs';
|
|
63
76
|
|
|
64
77
|
/**
|
|
@@ -182,6 +195,8 @@ export function rulesRisk({ file, plusCount, cfg }) {
|
|
|
182
195
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - ledger rows
|
|
183
196
|
* (`review.plan`, `review.unavailable`, `review.schema_invalid`, `review.schema_fallback`); a
|
|
184
197
|
* failed write never fails the review (a failed `review.schema_fallback` write means no try).
|
|
198
|
+
* @property {(opts: {repoRoot: string, file: string}) => Promise<string>} [deletionNote] - B74: the
|
|
199
|
+
* deletion note reader (default `deletionNote`, `deletion.mjs`); a throw gives `references not checked`.
|
|
185
200
|
* @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's
|
|
186
201
|
* signed schema-miss history (the worker's); absent ⇒ no second-level try.
|
|
187
202
|
*/
|
|
@@ -233,8 +248,25 @@ export async function reviewFile(input, deps) {
|
|
|
233
248
|
} catch {
|
|
234
249
|
moves = null; // no hint; the review goes on
|
|
235
250
|
}
|
|
251
|
+
// B74: a pure deletion gets the `## deleted file` note in every packet, read once; it is extra
|
|
252
|
+
// text like the moved code, counted in the same measure. "Deleted" is explicit: the diff is not
|
|
253
|
+
// empty (checked above), the reader found no content (`readFileDiff` gives null ONLY for a path
|
|
254
|
+
// not on disk: a binary file is decoded text, an unreadable one throws) AND the path is absent
|
|
255
|
+
// from the reviewed tree now. The references are read in that same tree (`repoRoot`).
|
|
256
|
+
let deleted = '';
|
|
257
|
+
if (diff.content === null && isAbsent(repoRoot, diff.file)) {
|
|
258
|
+
const note = deps.deletionNote ?? deletionNote;
|
|
259
|
+
try {
|
|
260
|
+
deleted = await note({ repoRoot, file: diff.file });
|
|
261
|
+
} catch {
|
|
262
|
+
deleted = renderDeletionNote(diff.file, { status: 'not_checked', paths: [], more: 0 }); // the review goes on
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// B92: a test that reads a source file and asserts on its text gets the `## test-quality` hint
|
|
266
|
+
// (line numbers only), read once from the current content; '' for none
|
|
267
|
+
const quality = testQualityNote(diff.file, diff.content);
|
|
236
268
|
/** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
|
|
237
|
-
const movedOf = (keys) => movedText(moves, diff.content, keys);
|
|
269
|
+
const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys), quality].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
|
|
238
270
|
const min = minTokensOut(cfg);
|
|
239
271
|
// B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
|
|
240
272
|
const schemaFallback = plan.mode !== 'consensus';
|
|
@@ -363,8 +395,9 @@ export async function reviewFile(input, deps) {
|
|
|
363
395
|
}
|
|
364
396
|
|
|
365
397
|
/**
|
|
366
|
-
* One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss
|
|
367
|
-
* B55 second-level try (`schema-fallback.mjs`) on the same packet
|
|
398
|
+
* One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss or
|
|
399
|
+
* (B91) a timed-out attempt, the B55 second-level try (`schema-fallback.mjs`) on the same packet
|
|
400
|
+
* file before it is removed.
|
|
368
401
|
* @param {SessionSpec} spec @param {string} text
|
|
369
402
|
* @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
|
|
370
403
|
* @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>, schemaFallback?: boolean}} env -
|
|
@@ -381,10 +414,39 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
|
|
|
381
414
|
let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
|
|
382
415
|
/** @type {{level: string, provider: string, model: string} | null} */
|
|
383
416
|
let fellBackTo = null;
|
|
384
|
-
|
|
385
|
-
|
|
417
|
+
const session = { lens: spec.lens, role: spec.role, level: spec.level };
|
|
418
|
+
// no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
|
|
419
|
+
// (each helper handles its own failed write)
|
|
420
|
+
const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<ReviewDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
|
|
421
|
+
const hash = packetHash(text);
|
|
422
|
+
/** @type {{level: string, provider: string, model: string} | null} the second level once its try started */
|
|
423
|
+
let tried = null;
|
|
424
|
+
/** @param {Record<string, any> | null} missed @param {'schema' | 'timeout'} trigger */
|
|
425
|
+
const tryNext = (missed, trigger) =>
|
|
426
|
+
afterSchemaMiss({
|
|
427
|
+
res: missed,
|
|
428
|
+
session,
|
|
429
|
+
packetText: text,
|
|
430
|
+
cfg: spec.cfg ?? cfg,
|
|
431
|
+
allowed: schemaFallback && !spec.cfg,
|
|
432
|
+
history: deps.schemaHistory,
|
|
433
|
+
writeRow: write,
|
|
434
|
+
// the second level's attempts follow the ones already run in this session
|
|
435
|
+
spawnAt: (c, to) => {
|
|
436
|
+
tried = to; // the try started: whatever happens next, the session fell back
|
|
437
|
+
return attempt(c, attempts, true);
|
|
438
|
+
},
|
|
439
|
+
trigger,
|
|
440
|
+
});
|
|
441
|
+
/**
|
|
442
|
+
* @param {Record<string, any> | undefined} sessionCfg @param {number} done - attempts before this call.
|
|
443
|
+
* @param {boolean} [secondLevel] - B91: the second-level try (its timeouts never count, never try again).
|
|
444
|
+
* @returns {ReturnType<typeof spawnWithTimeoutRetry>}
|
|
445
|
+
*/
|
|
446
|
+
const attempt = (sessionCfg, done, secondLevel = false) =>
|
|
386
447
|
// a `timeout` is spawned once more on the same packet and level (B30); a spawn that throws
|
|
387
|
-
// (bad config, refused argv) is an `exit` failure, never approval
|
|
448
|
+
// (bad config, refused argv) is an `exit` failure, never approval. B91: each timed-out attempt
|
|
449
|
+
// is a broken result of this packet; the second one tries the second level instead
|
|
388
450
|
spawnWithTimeoutRetry(
|
|
389
451
|
deps.spawn,
|
|
390
452
|
{
|
|
@@ -399,6 +461,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
|
|
|
399
461
|
(n) => {
|
|
400
462
|
attempts = done + n;
|
|
401
463
|
},
|
|
464
|
+
{ packetHash: hash, secondLevel, ...(secondLevel ? {} : { onBroken: (/** @type {Record<string, any>} */ r) => tryNext(r, 'timeout') }) },
|
|
402
465
|
);
|
|
403
466
|
/** @type {number | null} the session's tokens: the first spawn's, plus a second-level try's */
|
|
404
467
|
let tokensIn = null;
|
|
@@ -406,34 +469,42 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
|
|
|
406
469
|
let tokensOut = null;
|
|
407
470
|
// the packet file is removed on every path, and only after the second-level try (which reads it)
|
|
408
471
|
try {
|
|
472
|
+
/** @type {Awaited<ReturnType<typeof spawnWithTimeoutRetry>> | null} */
|
|
473
|
+
let first = null;
|
|
409
474
|
try {
|
|
410
|
-
|
|
475
|
+
first = await attempt(spec.cfg, 0);
|
|
476
|
+
({ res, attempts } = first);
|
|
411
477
|
} catch {
|
|
412
|
-
// defensive only: the
|
|
413
|
-
// never approval, with the attempts
|
|
478
|
+
// defensive only: the helpers never throw. If they ever did, it is still an `exit` failure,
|
|
479
|
+
// never approval, with the attempts reported so far (at least 1; a second-level try's spawns
|
|
480
|
+
// included) and, once that try started, its second level (never a same-model spawn after it).
|
|
414
481
|
res = null;
|
|
415
482
|
attempts = Math.max(attempts, 1);
|
|
483
|
+
fellBackTo = tried;
|
|
416
484
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
485
|
+
if (first?.to) {
|
|
486
|
+
// B91: a timed-out attempt was the packet's second broken result; the second level ran and
|
|
487
|
+
// `attempts` already counts its spawns. The verdict is set first: whatever throws after it
|
|
488
|
+
// leaves that verdict (a failed one stays a non-approval), never an exception
|
|
489
|
+
fellBackTo = first.to;
|
|
490
|
+
verdict = { ok: false, reason: 'exit', detail: 'second-level result not checked' };
|
|
491
|
+
try {
|
|
492
|
+
verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
493
|
+
tokensIn = addTokens(first.broken?.usage?.tokens_in ?? null, res?.usage?.tokens_in);
|
|
494
|
+
tokensOut = addTokens(first.broken?.usage?.tokens_out ?? null, res?.usage?.tokens_out);
|
|
495
|
+
if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: text, writeRow: write });
|
|
496
|
+
} catch {
|
|
497
|
+
// defensive only, as on the schema path below
|
|
498
|
+
}
|
|
499
|
+
} else {
|
|
500
|
+
tokensIn = res?.usage?.tokens_in ?? null;
|
|
501
|
+
tokensOut = res?.usage?.tokens_out ?? null;
|
|
502
|
+
verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
503
|
+
}
|
|
504
|
+
if (!fellBackTo && !verdict.ok && verdict.reason === 'schema') {
|
|
421
505
|
try {
|
|
422
|
-
const session = { lens: spec.lens, role: spec.role, level: spec.level };
|
|
423
506
|
const done = attempts;
|
|
424
|
-
|
|
425
|
-
// (each helper handles its own failed write)
|
|
426
|
-
const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<ReviewDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
|
|
427
|
-
const second = await afterSchemaMiss({
|
|
428
|
-
res,
|
|
429
|
-
session,
|
|
430
|
-
packetText: text,
|
|
431
|
-
cfg: spec.cfg ?? cfg,
|
|
432
|
-
allowed: schemaFallback && !spec.cfg,
|
|
433
|
-
history: deps.schemaHistory,
|
|
434
|
-
writeRow: write,
|
|
435
|
-
spawnAt: (c) => attempt(c, done),
|
|
436
|
-
});
|
|
507
|
+
const second = await tryNext(res, 'schema');
|
|
437
508
|
if (second) {
|
|
438
509
|
const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
439
510
|
res = second.res;
|
|
@@ -447,6 +518,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
|
|
|
447
518
|
} catch {
|
|
448
519
|
// defensive only: the helpers never throw. If they ever did, the first session's result
|
|
449
520
|
// and its `schema` verdict stand (never turned into `exit`, never approval)
|
|
521
|
+
fellBackTo = fellBackTo ?? tried;
|
|
450
522
|
}
|
|
451
523
|
}
|
|
452
524
|
} finally {
|
|
@@ -466,6 +538,20 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
|
|
|
466
538
|
return { ...meta, status: 'ok', reason: null, review: verdict.review };
|
|
467
539
|
}
|
|
468
540
|
|
|
541
|
+
/**
|
|
542
|
+
* B74: whether `file` is absent from the tree at `repoRoot` (lstat says ENOENT or ENOTDIR).
|
|
543
|
+
* @param {string} repoRoot @param {string} file @returns {boolean}
|
|
544
|
+
*/
|
|
545
|
+
function isAbsent(repoRoot, file) {
|
|
546
|
+
try {
|
|
547
|
+
lstatSync(path.join(repoRoot, file));
|
|
548
|
+
return false;
|
|
549
|
+
} catch (err) {
|
|
550
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
551
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
469
555
|
/**
|
|
470
556
|
* Two token counts added; a missing one (not a number) adds nothing, both missing ⇒ null.
|
|
471
557
|
* @param {unknown} a @param {unknown} b @returns {number | null}
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -24,11 +24,15 @@
|
|
|
24
24
|
* a coding level, R1) takes it, so for an L2 block the rung IS the +1 at round 2. After the
|
|
25
25
|
* patch the block continues at L2 (§3.6, Fable ruling A): `l3_rung_used = true`,
|
|
26
26
|
* `rounds_at_level = 0`, and the `patch_check` goes through `decideNext` like any recheck —
|
|
27
|
-
* clean ⇒ complete;
|
|
28
|
-
* (`trigger: review_stall`); round ≥ cap ⇒
|
|
29
|
-
* `l3_patch: true` in the brief.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* clean ⇒ complete; the patch closed NO finding that was open before it (ids compared, not
|
|
28
|
+
* counts) ⇒ `stopped: l3_patch_exhausted` (`trigger: review_stall`); round ≥ cap ⇒
|
|
29
|
+
* `stopped: review_cap`; else `fix` at L2 with `l3_patch: true` in the brief. B73 (issue #12):
|
|
30
|
+
* a patch that closed ≥ 1 old id below the cap is progress, not a stall, even when new findings
|
|
31
|
+
* in its fix hunks keep the open set from shrinking. A later stall at L2 ⇒ `l3_patch_exhausted`
|
|
32
|
+
* (the rung is spent, never a second one); round 4 still open ⇒ `review_cap`. Either way the
|
|
33
|
+
* ledger `review.cap` row lists the open ids — the human decides (`STOP_EXITS`,
|
|
34
|
+
* `stop-exits.mjs`): `block reopen` after a fix, `block waive` per open finding,
|
|
35
|
+
* `review-file --answers` for a fix in another owned file, or re-decompose.
|
|
32
36
|
* 6. Ledger per round: `review.round {file, round, level, kind, open_before, closed,
|
|
33
37
|
* new_in_hunks, late, open_after, tokens_in, tokens_out}`; a converged file gets
|
|
34
38
|
* `review.approved {file, content_hash, round}` (the worker signs every row it writes).
|
|
@@ -93,7 +97,7 @@ import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf
|
|
|
93
97
|
import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
|
|
94
98
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
95
99
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
96
|
-
import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
|
|
100
|
+
import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
|
|
97
101
|
|
|
98
102
|
/** @typedef {import('./triage.mjs').Finding} Finding */
|
|
99
103
|
/** @typedef {import('./context.mjs').Hunk} Hunk */
|
|
@@ -427,8 +431,9 @@ export function insideHunks(f, hunks) {
|
|
|
427
431
|
}
|
|
428
432
|
|
|
429
433
|
/**
|
|
430
|
-
* One recheck session through the stub guard — and, on a `schema` miss
|
|
431
|
-
*
|
|
434
|
+
* One recheck session through the stub guard — and, on a `schema` miss or (B91) a timed-out
|
|
435
|
+
* attempt that is the packet's second broken result, the B55 second-level try on the same packet
|
|
436
|
+
* (`schema-fallback.mjs`; rechecks and the patch_check alike).
|
|
432
437
|
* @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
|
|
433
438
|
* @param {LoopDeps} deps
|
|
434
439
|
*/
|
|
@@ -438,14 +443,28 @@ async function recheckSession(packet, deps) {
|
|
|
438
443
|
writeFileSync(promptPath, packet.text, { mode: 0o600 });
|
|
439
444
|
const min = minTokensOut(deps.cfg);
|
|
440
445
|
const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
|
|
441
|
-
|
|
446
|
+
// no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
|
|
447
|
+
// (each helper handles its own failed write)
|
|
448
|
+
const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
|
|
449
|
+
const hash = packetHash(packet.text);
|
|
450
|
+
/** @param {Record<string, any> | null} missed @param {'schema' | 'timeout'} trigger */
|
|
451
|
+
const tryNext = (missed, trigger) =>
|
|
452
|
+
afterSchemaMiss({ res: missed, 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, trigger });
|
|
453
|
+
/**
|
|
454
|
+
* @param {Record<string, any> | undefined} cfg - set only for the second-level try (B91: its
|
|
455
|
+
* timeouts never count and never try again).
|
|
456
|
+
* @returns {Promise<{res: Record<string, any> | null, attempts: number, to?: Record<string, any>, broken?: Record<string, any>}>}
|
|
457
|
+
*/
|
|
442
458
|
const attempt = async (cfg) =>
|
|
443
459
|
deps.spawn
|
|
444
|
-
? // a `timeout` is spawned once more on the same packet and level (B30)
|
|
460
|
+
? // a `timeout` is spawned once more on the same packet and level (B30); B91: the packet's
|
|
461
|
+
// second broken result tries the second level instead
|
|
445
462
|
spawnWithTimeoutRetry(
|
|
446
463
|
deps.spawn,
|
|
447
464
|
{ level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn }, ...(cfg ? { cfg } : {}) },
|
|
448
465
|
(row) => note(deps, row),
|
|
466
|
+
undefined,
|
|
467
|
+
cfg ? { packetHash: hash, secondLevel: true } : { packetHash: hash, onBroken: (r) => tryNext(r, 'timeout') },
|
|
449
468
|
)
|
|
450
469
|
: { res: null, attempts: 0 };
|
|
451
470
|
/** @type {Record<string, any> | null} */
|
|
@@ -458,20 +477,37 @@ async function recheckSession(packet, deps) {
|
|
|
458
477
|
let tokensOut = null;
|
|
459
478
|
// the packet file is removed on every path, and only after the second-level try (which reads it)
|
|
460
479
|
try {
|
|
480
|
+
/** @type {{res: Record<string, any> | null, attempts: number, to?: Record<string, any>, broken?: Record<string, any>} | null} */
|
|
481
|
+
let first = null;
|
|
461
482
|
try {
|
|
462
|
-
|
|
483
|
+
first = await attempt(undefined);
|
|
484
|
+
res = first.res;
|
|
463
485
|
} catch {
|
|
464
486
|
res = null; // the helper never throws; if it ever did, it is still an `exit` failure
|
|
465
487
|
}
|
|
466
|
-
tokensIn = res?.usage?.tokens_in ?? null;
|
|
467
|
-
tokensOut = res?.usage?.tokens_out ?? null;
|
|
468
488
|
verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
469
|
-
if (
|
|
489
|
+
if (first?.to) {
|
|
490
|
+
// B91: a timed-out attempt was the packet's second broken result; the second level ran. The
|
|
491
|
+
// verdict is already set: whatever throws after it leaves that verdict, never an exception
|
|
492
|
+
// the second level's miss is logged first, in its own try, so no sum error can skip it
|
|
470
493
|
try {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
494
|
+
if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: packet.text, writeRow: write });
|
|
495
|
+
} catch {
|
|
496
|
+
// defensive only: the helper never throws; the verdict above stands (never approval)
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
tokensIn = sum(first.broken?.usage?.tokens_in ?? null, res?.usage?.tokens_in);
|
|
500
|
+
tokensOut = sum(first.broken?.usage?.tokens_out ?? null, res?.usage?.tokens_out);
|
|
501
|
+
} catch {
|
|
502
|
+
// defensive only, as on the schema path below
|
|
503
|
+
}
|
|
504
|
+
} else {
|
|
505
|
+
tokensIn = res?.usage?.tokens_in ?? null;
|
|
506
|
+
tokensOut = res?.usage?.tokens_out ?? null;
|
|
507
|
+
}
|
|
508
|
+
if (!first?.to && !verdict.ok && verdict.reason === 'schema') {
|
|
509
|
+
try {
|
|
510
|
+
const second = await tryNext(res, 'schema');
|
|
475
511
|
if (second) {
|
|
476
512
|
// compute everything first, then assign together, so a throw never leaves a mixed state
|
|
477
513
|
const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
@@ -497,17 +533,20 @@ async function recheckSession(packet, deps) {
|
|
|
497
533
|
/**
|
|
498
534
|
* After a counted round with open findings: cap first (rule 6), else stall / ladder (2b, 2).
|
|
499
535
|
* @param {FileState} state @param {number} openBefore @param {ReturnType<typeof settings>} s
|
|
500
|
-
* @param {'full' | 'recheck' | 'patch_check'} kind - a `patch_check`
|
|
501
|
-
*
|
|
536
|
+
* @param {'full' | 'recheck' | 'patch_check'} kind - a `patch_check` is judged by `closedIds`
|
|
537
|
+
* (B73), whatever the round number.
|
|
538
|
+
* @param {number} closedIds - how many ids open before the round are not open after it.
|
|
502
539
|
* @returns {NextStep}
|
|
503
540
|
*/
|
|
504
|
-
function decideNext(state, openBefore, s, kind) {
|
|
541
|
+
function decideNext(state, openBefore, s, kind, closedIds) {
|
|
505
542
|
if (state.open.length === 0) return { action: 'complete' };
|
|
506
543
|
if (kind === 'patch_check') {
|
|
507
|
-
// the block's one rung is spent: a patch that
|
|
508
|
-
// the rung was taken at the cap — then the cap; else
|
|
509
|
-
|
|
544
|
+
// the block's one rung is spent: a patch that closed no old finding stops FIRST — even when
|
|
545
|
+
// the rung was taken at the cap — then the cap; else (B73: ≥ 1 old id closed is progress,
|
|
546
|
+
// whatever new findings its fix hunks brought) the block continues at L2: a fix, no stall
|
|
547
|
+
if (closedIds === 0) return { action: 'stop', reason: 'l3_patch_exhausted', trigger: 'review_stall' };
|
|
510
548
|
if (state.round >= s.maxRounds) return { action: 'stop', reason: 'review_cap', trigger: 'review_cap' };
|
|
549
|
+
return { action: 'fix', level: state.level, trigger: null };
|
|
511
550
|
}
|
|
512
551
|
if (state.round >= s.maxRounds) {
|
|
513
552
|
const esc = escalationAfterAttempt({ currentLevel: state.level, roundsAtFile: state.round, maxRoundsPerFile: s.maxRounds, openFixNowFinding: true, l3RungAlreadyUsed: state.l3_rung_used });
|
|
@@ -515,7 +554,7 @@ function decideNext(state, openBefore, s, kind) {
|
|
|
515
554
|
}
|
|
516
555
|
const esc = escalationAfterAttempt({
|
|
517
556
|
currentLevel: state.level,
|
|
518
|
-
reviewStall:
|
|
557
|
+
reviewStall: state.round >= 2 && reviewRoundStalled(openBefore, state.open.length),
|
|
519
558
|
openFixNowFinding: true,
|
|
520
559
|
reviewRoundsAtLevel: state.rounds_at_level,
|
|
521
560
|
reviewRoundsPerLevel: s.perLevel,
|
|
@@ -740,9 +779,12 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
740
779
|
draft.status = 'complete';
|
|
741
780
|
draft.next = { action: 'complete' };
|
|
742
781
|
} else {
|
|
743
|
-
// after a patch_check the block continues at L2 (§3.6): the rung is spent, so a
|
|
744
|
-
// stops (`l3_patch_exhausted`) and the cap stops (`review_cap`); a
|
|
745
|
-
|
|
782
|
+
// after a patch_check the block continues at L2 (§3.6): the rung is spent, so a patch that
|
|
783
|
+
// closed no old id stops (`l3_patch_exhausted`) and the cap stops (`review_cap`); a patch that
|
|
784
|
+
// closed one is fixed at L2 (B73). Closed = an id open before the round, not open after it.
|
|
785
|
+
const after = new Set(draft.open.map((f) => f.id));
|
|
786
|
+
const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
|
|
787
|
+
draft.next = decideNext(draft, openBefore, s, kind, closedIds);
|
|
746
788
|
if (draft.next.action === 'stop') draft.status = 'stopped';
|
|
747
789
|
}
|
|
748
790
|
const rows = [...lateRows, roundRow];
|