@codedology/code-forge 0.10.0 → 0.12.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 +74 -0
- package/README.md +34 -3
- package/docs/reference/blocks.json +360 -1
- package/docs/reference/config.md +6 -5
- package/package.json +2 -2
- package/schema/code-forge.schema.json +3 -2
- package/skill/SKILL.md +3 -1
- package/skill/references/code.md +17 -8
- package/skill/references/proof.md +8 -2
- package/skill/references/review.md +32 -5
- package/skill/references/rules-core.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +17 -7
- package/src/cli/block.mjs +112 -7
- package/src/cli/gates.mjs +15 -6
- package/src/cli/review-file.mjs +118 -14
- package/src/config/validate.mjs +12 -5
- package/src/context/pack.mjs +7 -4
- package/src/gates/expand.mjs +102 -9
- package/src/gates/run.mjs +6 -4
- package/src/install/wizard/flags.mjs +1 -1
- package/src/proof/lock.mjs +23 -10
- package/src/proof/rule.mjs +9 -0
- package/src/proof/tiers.mjs +2 -14
- package/src/review/engine.mjs +59 -12
- package/src/review/fixloop.mjs +41 -16
- package/src/review/gate-check.mjs +2 -2
- package/src/review/lenses/A.md +5 -0
- package/src/review/lenses/B.md +5 -0
- package/src/review/lenses/full.md +5 -0
- package/src/review/lenses/judge.md +4 -0
- package/src/review/lenses/quick.md +4 -0
- package/src/review/lenses/recheck.md +5 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +81 -0
- package/src/review/reopen.mjs +18 -6
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/state/block-end.mjs +10 -6
- package/src/util/exec.mjs +2 -1
- package/src/util/gate-token.mjs +3 -0
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +35 -2
- package/src/worker/queue.mjs +60 -2
- package/src/worker/watch.mjs +113 -0
package/src/cli/block.mjs
CHANGED
|
@@ -70,6 +70,21 @@
|
|
|
70
70
|
* FAILED: no review-file ticket id for <files>` (skipped under `--no-require-reviews`, the human's
|
|
71
71
|
* way out). It runs after the review rows and reuses their file set (listed once).
|
|
72
72
|
*
|
|
73
|
+
* B96 (E1): the tool builds the close report. After the review gate passed, the close prints
|
|
74
|
+
* `reviewed <path> ticket <id>` for every file the gate accepted, sorted by path, before the
|
|
75
|
+
* `reviewed_by_waiver` lines and the closed line; the id is the queue's ticket for that file at the
|
|
76
|
+
* hash the gate accepted (`resolveTicket`: the tree of a worktree block is part of it). `--json`
|
|
77
|
+
* and the done file get `reviewed: [{file, ticket, via}]` (`via`: approved, waiver or answered —
|
|
78
|
+
* the gate's own verdict, `review/reviewed-lines.mjs`), only when there is a line. So `--report`
|
|
79
|
+
* needs no ticket line: a missing hand line refuses only for a file the tool has no line for, and a
|
|
80
|
+
* hand line that names another ticket prints `WARN report ticket mismatch <path>` on stderr while
|
|
81
|
+
* the close goes on (D1). A gate-accepted file with no ticket prints `WARN no ticket for <path>`
|
|
82
|
+
* (`WARN bad ticket for <path>` when the ticket is not 24 lowercase hex) and has no line; lines that
|
|
83
|
+
* cannot be built at all give no line and `WARN reviewed lines unavailable: <message>` — then
|
|
84
|
+
* `--report` needs a hand line for every file the gate accepted, as before B96. All four warnings
|
|
85
|
+
* are held and print only when the block closes. A waived or external-review close is unchanged:
|
|
86
|
+
* no line, no key.
|
|
87
|
+
*
|
|
73
88
|
* B58 (issue #11): `review.external.required: true` in the run's config snapshot (the owner set it
|
|
74
89
|
* before `run start`; `run reload` refuses to change it) is the owner's non-interactive way out
|
|
75
90
|
* when an outside reviewer reviews the block: the per-file review checks and the `--report` ticket
|
|
@@ -200,12 +215,13 @@ import { validateConfig } from '../config/validate.mjs';
|
|
|
200
215
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
201
216
|
import { exec } from '../util/exec.mjs';
|
|
202
217
|
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
203
|
-
import { clearDone } from '../worker/queue.mjs';
|
|
218
|
+
import { clearDone, resolveTicket } from '../worker/queue.mjs';
|
|
204
219
|
import { loadState } from '../worker/review-state.mjs';
|
|
205
220
|
import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
206
221
|
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
207
222
|
import { runRootPath } from '../util/tmp.mjs';
|
|
208
223
|
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
224
|
+
import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
|
|
209
225
|
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
210
226
|
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
211
227
|
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
@@ -281,11 +297,14 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
281
297
|
* isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
|
|
282
298
|
* ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
|
|
283
299
|
* removeBlockTree?: typeof removeBlockTree,
|
|
300
|
+
* resolveTicket?: typeof resolveTicket, reviewedLines?: typeof reviewedLines,
|
|
284
301
|
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
285
302
|
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
286
303
|
* `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
|
|
287
304
|
* `block reopen`), which needs both to be TTYs.
|
|
288
305
|
* `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
|
|
306
|
+
* `resolveTicket` / `reviewedLines` (B96, tests only): the queue's ticket rule and the reviewed
|
|
307
|
+
* lines builder of the close, default the real ones.
|
|
289
308
|
* @returns {Promise<number>}
|
|
290
309
|
*/
|
|
291
310
|
export async function runBlock(args, deps = {}) {
|
|
@@ -344,7 +363,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
344
363
|
// then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
|
|
345
364
|
// done file is the only addition: no row, and every output stays exactly as before.
|
|
346
365
|
const hook = blockEndHook(record);
|
|
347
|
-
|
|
366
|
+
// B96: `reviewed` (the close's tool lines) joins the done file only when there is one
|
|
367
|
+
const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason, /** @type {import('../review/reviewed-lines.mjs').ReviewedLine[]} */ reviewed = []) => {
|
|
348
368
|
// ruling (e): `ended` never throws — any failure here is the done-file warning and a null
|
|
349
369
|
// `doneFile`, so the caller's try/finally still reaches the hook
|
|
350
370
|
/** @type {string | null} */
|
|
@@ -358,6 +378,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
358
378
|
reason: typeof reason === 'string' ? redact(reason) : null,
|
|
359
379
|
openFindings: (rows) => blockOpenFindings(record, rows, id),
|
|
360
380
|
err,
|
|
381
|
+
...(reviewed.length > 0 ? { reviewed } : {}),
|
|
361
382
|
});
|
|
362
383
|
} catch (thrown) {
|
|
363
384
|
const code = /** @type {any} */ (thrown)?.code;
|
|
@@ -568,6 +589,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
568
589
|
// B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
|
|
569
590
|
// `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
|
|
570
591
|
// when the block closes — a refused close writes none)
|
|
592
|
+
// B96: whether the review gate passed — set from the gate's own result (below), so the files
|
|
593
|
+
// it checked are the ones it accepted and the tool can give their reviewed lines
|
|
594
|
+
let reviewsPassed = false;
|
|
571
595
|
const onWaivedAll = (/** @type {import('../review/gate-check.mjs').WaivedAll[]} */ files) => {
|
|
572
596
|
// idempotent: the waived rows are rebuilt from the final list, never appended per call
|
|
573
597
|
// (closeBlock reads closeRows only after every extra check passed)
|
|
@@ -575,7 +599,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
575
599
|
const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
|
|
576
600
|
closeRows.splice(0, closeRows.length, ...others, ...files.map((f) => ({ event: 'gate.review_waived_all', block: id, file: f.file, content_hash: f.content_hash, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })));
|
|
577
601
|
};
|
|
578
|
-
const
|
|
602
|
+
const gate = reviewGateCheck(async () => {
|
|
579
603
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
580
604
|
if (transcript === null) {
|
|
581
605
|
await writeSigned(runId, writeRow, { event: 'gate.transcript_missing', block: id });
|
|
@@ -625,7 +649,63 @@ export async function runBlock(args, deps = {}) {
|
|
|
625
649
|
highPaths,
|
|
626
650
|
};
|
|
627
651
|
}, { onWaivedAll });
|
|
628
|
-
//
|
|
652
|
+
// B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
|
|
653
|
+
// extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
|
|
654
|
+
// and `closeBlock` is called once below), so the gate's one pass is its final one.
|
|
655
|
+
const reviews = async () => {
|
|
656
|
+
const result = await gate();
|
|
657
|
+
reviewsPassed = result.ok;
|
|
658
|
+
return result;
|
|
659
|
+
};
|
|
660
|
+
// B96: the stderr warnings of the reviewed lines (`WARN no ticket for <path>`, `WARN bad
|
|
661
|
+
// ticket for <path>`, `WARN reviewed lines unavailable: <message>`, `WARN report ticket
|
|
662
|
+
// mismatch <path>`): kept here and printed only when the block closes, before the reviewed
|
|
663
|
+
// lines — a refused close prints none
|
|
664
|
+
/** @type {string[]} */
|
|
665
|
+
const lineWarnings = [];
|
|
666
|
+
// B96: the tool's reviewed lines — one per file the review gate accepted, with the queue's
|
|
667
|
+
// ticket for that file at the hash the gate accepted (`resolveTicket`, the queue's own rule:
|
|
668
|
+
// the same path check, tree resolution and ticket id); a file whose content moved since the
|
|
669
|
+
// gate hashed it, or whose ticket cannot be given, has no line (fail closed, `WARN no ticket
|
|
670
|
+
// for <path>`), and so has a file whose ticket is not 24 lowercase hex (`WARN bad ticket for
|
|
671
|
+
// <path>`). None when the reviews were waived (the human flag or `review.external`) or the
|
|
672
|
+
// gate has not passed — and that empty answer is never memoised: the lines are built once,
|
|
673
|
+
// from the gate's pass (its file set and hashes, its waivedAll). It never throws: an error
|
|
674
|
+
// gives no lines and one held `WARN reviewed lines unavailable: <message>`, so the done file
|
|
675
|
+
// and the on_block_end hook of a closed block always follow.
|
|
676
|
+
/** @type {Promise<import('../review/reviewed-lines.mjs').ReviewedLine[]> | undefined} */
|
|
677
|
+
let linesOnce;
|
|
678
|
+
const toolLines = async () => {
|
|
679
|
+
if (!reviewsPassed || reviewsWaived || external !== null) return [];
|
|
680
|
+
linesOnce ??= (async () => {
|
|
681
|
+
try {
|
|
682
|
+
const { mainTop, top, inTree, fileSet } = await tree();
|
|
683
|
+
const resolve = deps.resolveTicket ?? resolveTicket;
|
|
684
|
+
const ticketOf = (/** @type {{file: string, content_hash: string}} */ f) => {
|
|
685
|
+
try {
|
|
686
|
+
const t = resolve({ repoRoot: mainTop, run: runId, block: id, file: f.file, tree: inTree ? top : null });
|
|
687
|
+
return t.content_hash === f.content_hash ? t.ticket : null;
|
|
688
|
+
} catch {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
return await (deps.reviewedLines ?? reviewedLines)({
|
|
693
|
+
files: fileSet,
|
|
694
|
+
waivedAll,
|
|
695
|
+
ticketOf,
|
|
696
|
+
onNoTicket: (file) => lineWarnings.push(`WARN no ticket for ${file}\n`),
|
|
697
|
+
onBadTicket: (file) => lineWarnings.push(`WARN bad ticket for ${file}\n`),
|
|
698
|
+
});
|
|
699
|
+
} catch (thrown) {
|
|
700
|
+
lineWarnings.push(`WARN reviewed lines unavailable: ${thrown instanceof Error ? thrown.message : String(thrown)}\n`);
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
})();
|
|
704
|
+
return linesOnce;
|
|
705
|
+
};
|
|
706
|
+
// B36 → B96: the coder's report. The tool gives the reviewed lines, so a missing hand line is
|
|
707
|
+
// a refusal only for a file the tool has no line for; a hand line that names another ticket
|
|
708
|
+
// is a warning and the close goes on (D1: the tool lines are the record)
|
|
629
709
|
const report = async () => {
|
|
630
710
|
if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
|
|
631
711
|
let text;
|
|
@@ -635,8 +715,12 @@ export async function runBlock(args, deps = {}) {
|
|
|
635
715
|
return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
|
|
636
716
|
}
|
|
637
717
|
const { fileSet } = await tree();
|
|
638
|
-
const
|
|
639
|
-
|
|
718
|
+
const lines = await toolLines();
|
|
719
|
+
const given = new Set(lines.map((l) => l.file));
|
|
720
|
+
const missing = missingReviewTickets(text, fileSet.map((f) => f.file)).filter((f) => !given.has(f));
|
|
721
|
+
if (missing.length > 0) return { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
722
|
+
for (const file of reportMismatches(text, lines)) lineWarnings.push(`WARN report ticket mismatch ${file}\n`);
|
|
723
|
+
return { ok: true };
|
|
640
724
|
};
|
|
641
725
|
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
642
726
|
const extraChecks = [reviews, report];
|
|
@@ -666,14 +750,19 @@ export async function runBlock(args, deps = {}) {
|
|
|
666
750
|
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
|
|
667
751
|
// B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
|
|
668
752
|
// are written and the run lock released; the done file is written before the tree is removed
|
|
669
|
-
|
|
753
|
+
// B96: the tool's reviewed lines of a block that closed (also kept in its done file)
|
|
754
|
+
const reviewedJson = result.ok ? await toolLines() : [];
|
|
755
|
+
const end = result.status === 'closed' || result.status === 'stopped' ? await ended(result.status, result.event ?? (result.status === 'stopped' ? 'block.stop' : 'block.close'), result.reason ?? null, reviewedJson) : null;
|
|
670
756
|
// B64b ruling (e): the hook runs exactly once for this end, after everything else the verb
|
|
671
757
|
// does — also when a later step (the output, `--remove-worktree`) throws; it never changes
|
|
672
758
|
// the exit code or stdout (`runHook` never throws)
|
|
673
759
|
try {
|
|
760
|
+
// B96: the reviewed lines (sorted by path) come first, then the reviewed_by_waiver lines
|
|
674
761
|
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
675
762
|
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
676
763
|
if (result.ok) {
|
|
764
|
+
for (const w of lineWarnings) err(w);
|
|
765
|
+
for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
|
|
677
766
|
for (const f of waivedAll) {
|
|
678
767
|
const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
|
|
679
768
|
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
@@ -692,6 +781,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
692
781
|
...(end?.json ?? {}),
|
|
693
782
|
// B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
|
|
694
783
|
...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
|
|
784
|
+
// B96: only when the tool gave reviewed lines (a waived or external close is unchanged)
|
|
785
|
+
...(reviewedJson.length > 0 ? { reviewed: reviewedJson } : {}),
|
|
695
786
|
};
|
|
696
787
|
if (!result.ok || !removeTree) {
|
|
697
788
|
if (json && result.ok) emit(closedJson);
|
|
@@ -756,6 +847,20 @@ async function coderTests(reportFlag, id) {
|
|
|
756
847
|
}
|
|
757
848
|
const sentinel = parseSentinel(text, id);
|
|
758
849
|
if (sentinel?.status !== 'complete') return { ok: false, reason: `coder report FAILED: no ===BLOCK ${id} COMPLETE=== sentinel` };
|
|
850
|
+
// Keep diagnostics in the same segment as parseSentinel: any block's sentinel ends it.
|
|
851
|
+
let malformed = null;
|
|
852
|
+
let lastMalformed = null;
|
|
853
|
+
for (const rawLine of text.split('\n')) {
|
|
854
|
+
const line = rawLine.trim();
|
|
855
|
+
const end = parseSentinel(line);
|
|
856
|
+
if (end) {
|
|
857
|
+
if (end.blockId === id) lastMalformed = malformed;
|
|
858
|
+
malformed = null;
|
|
859
|
+
} else if (line.startsWith('TESTS ') && !parseSentinel(`${line}\n===BLOCK ${id} COMPLETE===`, id)?.tests) {
|
|
860
|
+
malformed = line.slice(0, 120);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (lastMalformed !== null) return { ok: false, reason: `coder report FAILED: TESTS line malformed: ${lastMalformed}` };
|
|
759
864
|
if (!sentinel.tests) return { ok: false, reason: 'coder report FAILED: no TESTS line' };
|
|
760
865
|
if (sentinel.tests.exit !== 0) return { ok: false, reason: `coder report FAILED: TESTS exit=${sentinel.tests.exit}` };
|
|
761
866
|
return { ok: true, command: sentinel.tests.command, exit: 0 };
|
package/src/cli/gates.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import { writeSafe, redactJSON } from '../util/redact.mjs';
|
|
|
39
39
|
import { appendRow } from '../ledger/write.mjs';
|
|
40
40
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
41
41
|
import { detectGates } from '../gates/detect.mjs';
|
|
42
|
-
import { printableGates, thresholdOf } from '../gates/expand.mjs';
|
|
42
|
+
import { printableGates, scopeFields, thresholdOf } from '../gates/expand.mjs';
|
|
43
43
|
import { runGates } from '../gates/run.mjs';
|
|
44
44
|
import { runGatesGuarded } from '../proof/lock.mjs';
|
|
45
45
|
import { blockTree } from '../proof/worktree.mjs';
|
|
@@ -189,6 +189,7 @@ function realOrResolved(p) {
|
|
|
189
189
|
*/
|
|
190
190
|
function scopeOf({ scope, test_count: testCount, reason }) {
|
|
191
191
|
if (scope === undefined) return {};
|
|
192
|
+
if (scope === 'changed') return { scope, test_count: testCount, ...(reason ? { reason } : {}) };
|
|
192
193
|
return scope === 'related' ? { scope, test_count: testCount } : { scope, reason };
|
|
193
194
|
}
|
|
194
195
|
|
|
@@ -251,6 +252,11 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
251
252
|
err('gates run: --run <id> and --block <id> go together, each with a value\n');
|
|
252
253
|
return 2;
|
|
253
254
|
}
|
|
255
|
+
const changed = args.includes('--changed');
|
|
256
|
+
if (changed && runFlag.value === undefined) {
|
|
257
|
+
err('gates run: --changed needs --run <id> and --block <id>\n');
|
|
258
|
+
return 2;
|
|
259
|
+
}
|
|
254
260
|
|
|
255
261
|
// B62b: a block with a worktree runs its gates in that tree, configured by the main workspace
|
|
256
262
|
const placed = runFlag.value !== undefined && blockFlag.value !== undefined ? await blockWorktree(runFlag.value, blockFlag.value) : null;
|
|
@@ -273,29 +279,32 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
273
279
|
let ran;
|
|
274
280
|
if (runFlag.value !== undefined && blockFlag.value !== undefined) {
|
|
275
281
|
const threshold = thresholdOf(configured?.full_suite_threshold_files);
|
|
276
|
-
const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, threshold, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
282
|
+
const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, threshold, changed, slug, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
277
283
|
if (guarded.result === 'proof.busy') {
|
|
278
284
|
out({ result: 'proof.busy', holder: /** @type {{holder: string}} */ (guarded).holder });
|
|
279
285
|
return 1;
|
|
280
286
|
}
|
|
281
|
-
ran = /** @type {{value: {results: any[], allOk: boolean}}} */ (guarded).value;
|
|
287
|
+
ran = /** @type {{value: {results: any[], allOk: boolean, ledger_slug?: string, files?: Record<string, string>, changed_scope?: import('../gates/expand.mjs').ChangedScope}}} */ (guarded).value;
|
|
282
288
|
} else {
|
|
283
289
|
ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
284
290
|
}
|
|
285
291
|
const { results, allOk } = ran;
|
|
286
292
|
|
|
287
|
-
|
|
293
|
+
const ledgerSlug = slug ?? ran.ledger_slug;
|
|
294
|
+
if (ledgerSlug) {
|
|
288
295
|
// B60: with --run/--block the rows name the run and the block, so the run's binnacle reads them
|
|
289
296
|
const owner = runFlag.value !== undefined ? { run: runFlag.value, block: blockFlag.value, ...inTree } : {};
|
|
290
297
|
for (const result of results) {
|
|
291
298
|
if (!result.skipped && !result.ok) {
|
|
292
|
-
await appendRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result) }, { slug });
|
|
299
|
+
await appendRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result) }, { slug: ledgerSlug });
|
|
293
300
|
}
|
|
294
301
|
}
|
|
295
302
|
// B60: one scope per run (every `{test_files}` gate shares it), named on gate.done too;
|
|
296
303
|
// without --run/--block that is `scope: full, reason: no_tests`
|
|
297
304
|
const scoped = results.find((r) => r.scope !== undefined);
|
|
298
|
-
await appendRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
|
|
305
|
+
await appendRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
|
|
306
|
+
...(ran.changed_scope ? scopeFields(ran.changed_scope) : scoped ? scopeOf(scoped) : {}),
|
|
307
|
+
...(ran.files ? { files: ran.files } : {}) }, { slug: ledgerSlug });
|
|
299
308
|
}
|
|
300
309
|
out({ stack: gates.stack, ...inTree, results, allOk });
|
|
301
310
|
return allOk ? 0 : 1;
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
* review-file --wait <ticket> [--max <90s>] poll; prints {ticket, status: done, result} or
|
|
6
6
|
* {ticket, status: pending, reason: wait_timeout}
|
|
7
7
|
* when --max elapses (the review is still running)
|
|
8
|
+
* review-file --wait-block <id> --run <id> join owned changed files' current tickets;
|
|
9
|
+
* prints {block, files, done}; never reviews here
|
|
8
10
|
* review-file <answering file> --block <id> --answers <ids> --of <source> [--run <id>]
|
|
9
11
|
* B71: enqueue an answers ticket; prints {ticket,
|
|
10
12
|
* status, kind: answers, file: source, by_file, answers}
|
|
11
13
|
* review-file <path> --block <id> --fresh B75: re-run a cached result that never counted
|
|
12
14
|
*
|
|
13
|
-
* The same content enqueued again after an `unavailable` result is a
|
|
14
|
-
* not the old result.
|
|
15
|
+
* The same content enqueued again after an `unavailable` or round-1 `split_required` result is a
|
|
16
|
+
* fresh attempt (`retry: true`), not the old result.
|
|
15
17
|
* A done result that is not approved and carries open findings adds `fix_list` (what the coder
|
|
16
18
|
* fixes before re-running `review-file <path>`, which is then a round-2 recheck of the fix hunks,
|
|
17
19
|
* §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) and (B73)
|
|
@@ -28,8 +30,9 @@
|
|
|
28
30
|
* down on two polls in a row, and a worker whose pid is alive with a fresh heartbeat is never down
|
|
29
31
|
* (`liveWorker`, B30). Output is one JSON line on stdout.
|
|
30
32
|
* B75: `--fresh` drops the cached done marker of the file's current content and queues the ticket
|
|
31
|
-
* again ONLY when that result never counted as a review: `stale`, `unavailable`,
|
|
32
|
-
* `other-run`, `context-unreadable` or a `worktree-*` reason
|
|
33
|
+
* again ONLY when that result never counted as a review: `stale`, `unavailable`, round-1
|
|
34
|
+
* `split_required`, or `refused` with `other-run`, `context-unreadable` or a `worktree-*` reason
|
|
35
|
+
* (an allow-list, fail closed). Any
|
|
33
36
|
* other cached result — a round ran (findings, approved, stopped), `review-state-lost`, an answers
|
|
34
37
|
* result, an unknown status or reason, an unreadable marker — is refused `fresh-needs-reopen`
|
|
35
38
|
* (exit 1, `exits: [block reopen, block waive]`, the marker kept byte-for-byte); a
|
|
@@ -66,20 +69,22 @@
|
|
|
66
69
|
|
|
67
70
|
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
68
71
|
import path from 'node:path';
|
|
69
|
-
import { answersTicketId, assertAnswers, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
72
|
+
import { answersTicketId, assertAnswers, blockTickets, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
70
73
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
71
|
-
import { namedOnHash } from '../review/gate-check.mjs';
|
|
74
|
+
import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
|
|
72
75
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
73
76
|
import { ownsFile } from '../state/registry.mjs';
|
|
74
77
|
import { readAllRows } from '../ledger/write.mjs';
|
|
75
78
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
76
79
|
import { StateError } from '../state/paths.mjs';
|
|
80
|
+
import { assertRunId } from '../state/paths.mjs';
|
|
77
81
|
import { readRun } from '../state/run.mjs';
|
|
78
82
|
import { blockTree } from '../proof/worktree.mjs';
|
|
79
83
|
import { writeSafe } from '../util/redact.mjs';
|
|
80
84
|
|
|
81
85
|
const USAGE =
|
|
82
86
|
'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
|
|
87
|
+
' code-forge review-file --wait-block <id> --run <id> [--max <seconds>s]\n' +
|
|
83
88
|
' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n';
|
|
84
89
|
|
|
85
90
|
export const DEFAULT_WAIT_MS = 90_000;
|
|
@@ -129,8 +134,9 @@ const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
|
|
|
129
134
|
|
|
130
135
|
/**
|
|
131
136
|
* B75: may `--fresh` drop this cached result? An allow-list, fail closed: `rerun` only for a
|
|
132
|
-
* result that never counted as a review — `stale`, `unavailable`,
|
|
133
|
-
* is not a verdict (`other-run`, `context-unreadable`, `worktree-*`)
|
|
137
|
+
* result that never counted as a review — `stale`, `unavailable`, round-1 `split_required`, or
|
|
138
|
+
* `refused` with a reason that is not a verdict (`other-run`, `context-unreadable`, `worktree-*`)
|
|
139
|
+
* — whose marker names the same
|
|
134
140
|
* status. `tampered` for `refused review-state-tampered`; `counted` for everything else (a round
|
|
135
141
|
* ran, `review-state-lost`, an answers result, an unknown status or reason, an unreadable marker or
|
|
136
142
|
* result).
|
|
@@ -145,7 +151,7 @@ export function freshVerdict(text, result) {
|
|
|
145
151
|
return 'counted';
|
|
146
152
|
}
|
|
147
153
|
if (result === null || marker === null || typeof marker !== 'object' || marker.status !== result.status) return 'counted';
|
|
148
|
-
if (result.status === 'stale' || result.status === 'unavailable') return 'rerun';
|
|
154
|
+
if (result.status === 'stale' || result.status === 'unavailable' || result.status === 'split_required') return 'rerun';
|
|
149
155
|
if (result.status !== 'refused' || typeof result.reason !== 'string') return 'counted';
|
|
150
156
|
if (result.reason === 'review-state-tampered') return 'tampered';
|
|
151
157
|
return FRESH_REFUSED.has(result.reason) || result.reason.startsWith('worktree-') ? 'rerun' : 'counted';
|
|
@@ -166,10 +172,11 @@ export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
|
|
|
166
172
|
* crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
|
|
167
173
|
* `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
|
|
168
174
|
* the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
|
|
169
|
-
*
|
|
175
|
+
* B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
|
|
176
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
|
|
170
177
|
* @returns {number} the exit code.
|
|
171
178
|
*/
|
|
172
|
-
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf }) {
|
|
179
|
+
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
|
|
173
180
|
const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
|
|
174
181
|
const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
|
|
175
182
|
/** @type {'rerun' | 'tampered' | 'counted'} */
|
|
@@ -210,7 +217,7 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
|
|
|
210
217
|
// dropped: queue it again now (a crash right here leaves a pending ticket with no marker, which
|
|
211
218
|
// the next review-file queues anyway); gone: another caller took the marker, a normal enqueue
|
|
212
219
|
const queued = enqueue({ repoRoot, run, block, file, ...where });
|
|
213
|
-
say(outcome === 'dropped' ? { ...queued, fresh: true } : queued);
|
|
220
|
+
say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
|
|
214
221
|
return 0;
|
|
215
222
|
}
|
|
216
223
|
if (verdict === 'tampered') {
|
|
@@ -223,7 +230,7 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
|
|
|
223
230
|
ticket,
|
|
224
231
|
file,
|
|
225
232
|
exits: [...FRESH_EXITS],
|
|
226
|
-
message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
|
|
233
|
+
message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable, round-1 split_required or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
|
|
227
234
|
});
|
|
228
235
|
return 1;
|
|
229
236
|
}
|
|
@@ -352,6 +359,93 @@ export function fixList(result) {
|
|
|
352
359
|
return out;
|
|
353
360
|
}
|
|
354
361
|
|
|
362
|
+
/**
|
|
363
|
+
* B100: read block scope from the workspace mirror, never the private run record. This is a
|
|
364
|
+
* progress summary, not an approval: block close still verifies the authoritative signed rows.
|
|
365
|
+
* @param {string} repoRoot @param {string} cwd @param {string} run @param {string} block
|
|
366
|
+
* @returns {Promise<{entry: Record<string, any>, tree: string}>}
|
|
367
|
+
*/
|
|
368
|
+
async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
369
|
+
const top = await repoRootOf(cwd);
|
|
370
|
+
const candidates = new Set([repoRoot]);
|
|
371
|
+
for (let dir = realpathSync(cwd); ; dir = path.dirname(dir)) {
|
|
372
|
+
candidates.add(dir);
|
|
373
|
+
if (dir === top || dir === path.dirname(dir)) break;
|
|
374
|
+
}
|
|
375
|
+
for (const dir of candidates) {
|
|
376
|
+
let record;
|
|
377
|
+
try {
|
|
378
|
+
record = JSON.parse(readFileSync(path.join(dir, '.code-forge', 'runs', `${run}.json`), 'utf8'));
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (err.code === 'ENOENT') continue;
|
|
381
|
+
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
382
|
+
}
|
|
383
|
+
const entry = record?.blocks?.[block];
|
|
384
|
+
if (record?.run_id !== run || !entry || !Array.isArray(entry.owned_files) || entry.owned_files.length === 0) {
|
|
385
|
+
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
386
|
+
}
|
|
387
|
+
const tree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
|
|
388
|
+
return { entry, tree };
|
|
389
|
+
}
|
|
390
|
+
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** A block wait reads only the marker, never review results or the signing key.
|
|
394
|
+
* @param {string} repoRoot @param {string} ticket @returns {boolean}
|
|
395
|
+
*/
|
|
396
|
+
function blockTicketDone(repoRoot, ticket) {
|
|
397
|
+
try {
|
|
398
|
+
const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
|
|
399
|
+
if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return true;
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (err.code === 'ENOENT') return false;
|
|
402
|
+
}
|
|
403
|
+
throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Join current-content tickets only. Re-scan on every poll, including before returning done,
|
|
408
|
+
* so a file edited while waiting gets a new ticket. The worker alone finishes old tickets.
|
|
409
|
+
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
|
|
410
|
+
*/
|
|
411
|
+
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
412
|
+
const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
|
|
413
|
+
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
414
|
+
const deadline = Date.now() + maxMs;
|
|
415
|
+
let downPolls = 0;
|
|
416
|
+
for (;;) {
|
|
417
|
+
const worker = liveWorker(repoRoot);
|
|
418
|
+
const live = worker?.run === run;
|
|
419
|
+
downPolls = live ? 0 : downPolls + 1;
|
|
420
|
+
const tickets = blockTickets({ repoRoot, run, block, ...where });
|
|
421
|
+
const current = await blockFileSet({ repoRoot: tree, base: entry.base_sha, owned: entry.owned_files, ownedKinds: entry.owned_kinds });
|
|
422
|
+
const files = current.map(({ file }) => {
|
|
423
|
+
const opts = { repoRoot, run, block, file, ...where };
|
|
424
|
+
const { ticket } = resolveTicket(opts);
|
|
425
|
+
const previous = tickets.filter((t) => t.file === file);
|
|
426
|
+
let present = previous.find((t) => t.ticket === ticket);
|
|
427
|
+
if (!present && previous.length > 0 && live) {
|
|
428
|
+
enqueue(opts);
|
|
429
|
+
present = { ...previous[previous.length - 1], ticket };
|
|
430
|
+
}
|
|
431
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketDone(repoRoot, ticket) ? 'done' : 'pending' };
|
|
432
|
+
});
|
|
433
|
+
if (downPolls >= 2) {
|
|
434
|
+
say({ block, files, done: false, status: 'worker_down' });
|
|
435
|
+
return 3;
|
|
436
|
+
}
|
|
437
|
+
if (live && files.every((f) => f.status === 'done')) {
|
|
438
|
+
say({ block, files, done: true });
|
|
439
|
+
return 0;
|
|
440
|
+
}
|
|
441
|
+
if (live && (files.some((f) => f.status === 'not_queued') || Date.now() >= deadline)) {
|
|
442
|
+
say({ block, files, done: false, reason: files.some((f) => f.status === 'not_queued') ? 'not_queued' : 'wait_timeout' });
|
|
443
|
+
return 0;
|
|
444
|
+
}
|
|
445
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
355
449
|
/**
|
|
356
450
|
* @param {string[]} args
|
|
357
451
|
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
|
|
@@ -363,10 +457,17 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
363
457
|
const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
|
|
364
458
|
const cwd = deps.cwd ?? process.cwd();
|
|
365
459
|
try {
|
|
366
|
-
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
460
|
+
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
367
461
|
// the queue and the worker: the main tree, also from inside a block's worktree
|
|
368
462
|
const repoRoot = await mainRootOf(cwd);
|
|
369
463
|
|
|
464
|
+
if (flags['wait-block'] !== undefined) {
|
|
465
|
+
if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
|
|
466
|
+
throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
|
|
467
|
+
}
|
|
468
|
+
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say });
|
|
469
|
+
}
|
|
470
|
+
|
|
370
471
|
if (typeof flags.wait === 'string') {
|
|
371
472
|
if (positionals.length > 0 || flags.block !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true) throw new WorkerError('usage', '--wait takes only a ticket and --max');
|
|
372
473
|
const ticket = assertTicketId(flags.wait);
|
|
@@ -410,6 +511,9 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
410
511
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
411
512
|
if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
412
513
|
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
514
|
+
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'split_required') {
|
|
515
|
+
return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
516
|
+
}
|
|
413
517
|
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
|
|
414
518
|
// `unavailable` is never approval AND never final: drop the done marker so the worker runs
|
|
415
519
|
// the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
|
package/src/config/validate.mjs
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
67
67
|
import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
|
|
68
68
|
import { redact } from '../util/redact.mjs';
|
|
69
|
-
import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
69
|
+
import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
70
70
|
import { checkEffort, displayEffort } from '../engines/efforts.mjs';
|
|
71
71
|
import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
|
|
72
72
|
import { isKnownId, resolveLevel } from './known-ids.mjs';
|
|
@@ -484,10 +484,10 @@ function gateMessages(key, value) {
|
|
|
484
484
|
// full-suite run would be an empty argv
|
|
485
485
|
/** @param {string[]} argv @returns {string[]} */
|
|
486
486
|
const tokenMessages = (argv) =>
|
|
487
|
-
argv.length === 1 && argv[0]
|
|
488
|
-
? [`gates.${key}: '${
|
|
489
|
-
: argv[0]
|
|
490
|
-
? [`gates.${key}: '${
|
|
487
|
+
argv.length === 1 && [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
|
|
488
|
+
? [`gates.${key}: '${argv[0]}' cannot be the only item (the full suite would run an empty command)`]
|
|
489
|
+
: [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
|
|
490
|
+
? [`gates.${key}: '${argv[0]}' cannot be the command (argv[0])`]
|
|
491
491
|
: [];
|
|
492
492
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
493
493
|
if (!isArgvArray(value)) return [`gates.${key} must be a non-empty argv array of strings, an {argv, env} object, or null`];
|
|
@@ -1162,3 +1162,10 @@ export function validateConfig(cfg, opts = {}) {
|
|
|
1162
1162
|
}
|
|
1163
1163
|
|
|
1164
1164
|
export { schema as configSchema };
|
|
1165
|
+
|
|
1166
|
+
/** The effective file loop for a validated config, including opt-in commit watch.
|
|
1167
|
+
* @param {Record<string, any>} cfg @returns {string}
|
|
1168
|
+
*/
|
|
1169
|
+
export function reviewFileLoop(cfg) {
|
|
1170
|
+
return cfg?.review?.file_loop ?? 'wait';
|
|
1171
|
+
}
|
package/src/context/pack.mjs
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
*
|
|
13
13
|
* `assemblePack` turns it into ONE pack for a block, in this order: the header (run, block, level,
|
|
14
14
|
* owned files, test files when the block has them, worktree path and branch when it has one), the
|
|
15
|
-
* commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing),
|
|
16
|
-
* rules file, then the lessons file — every file section headed by its
|
|
17
|
-
* file: the heading alone). Over `max_lines` the lessons are cut first, then the
|
|
15
|
+
* commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), the
|
|
16
|
+
* built-in proof rule, each rules file, then the lessons file — every file section headed by its
|
|
17
|
+
* source path (an empty file: the heading alone). Over `max_lines` the lessons are cut first, then the
|
|
18
18
|
* rules from the last file backwards, each cut section ending in `[cut: N lines dropped]`; the
|
|
19
|
-
* header and
|
|
19
|
+
* header, commands and proof rule are never cut; all count toward the budget.
|
|
20
20
|
*
|
|
21
21
|
* `reviewDigest` is the ONE builder of the review packets' rules digest (`## project rules`, ≤ 60
|
|
22
22
|
* lines, `packet.mjs`): the rules files' text, then a `### lessons` subheading and the lessons.
|
|
@@ -41,6 +41,7 @@ import path from 'node:path';
|
|
|
41
41
|
import { estimateTokens } from '../review/packet.mjs';
|
|
42
42
|
import { splitLines } from '../review/context.mjs';
|
|
43
43
|
import { redact } from '../util/redact.mjs';
|
|
44
|
+
import { PROOF_RULE } from '../proof/rule.mjs';
|
|
44
45
|
|
|
45
46
|
/** The pack's line cap when `context.max_lines` is not set. */
|
|
46
47
|
export const DEFAULT_MAX_LINES = 300;
|
|
@@ -272,6 +273,8 @@ export function assemblePack({ settings, sources, block }) {
|
|
|
272
273
|
// a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
|
|
273
274
|
for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
|
|
274
275
|
|
|
276
|
+
header.push(...splitLines(PROOF_RULE));
|
|
277
|
+
|
|
275
278
|
/** @type {Array<{heading: string, body: string[], dropped: number}>} */
|
|
276
279
|
const sections = sources.rules.map((r) => ({ heading: `## rules: ${r.path}`, body: [...r.lines], dropped: 0 }));
|
|
277
280
|
if (sources.lessons) sections.push({ heading: `## lessons: ${sources.lessons.path}`, body: [...sources.lessons.lines], dropped: 0 });
|