@sabaiway/agent-workflow-kit 1.38.0 → 1.39.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 +69 -0
- package/README.md +3 -3
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/fold-completeness.md +3 -3
- package/references/modes/gates.md +5 -3
- package/references/modes/review-ledger.md +10 -6
- package/references/modes/velocity.md +1 -1
- package/tools/changed-surface.mjs +294 -0
- package/tools/commands.mjs +3 -3
- package/tools/fold-completeness-run.mjs +35 -182
- package/tools/fold-completeness.mjs +84 -59
- package/tools/procedures.mjs +1 -1
- package/tools/review-ledger-write.mjs +190 -46
- package/tools/review-ledger.mjs +380 -53
- package/tools/run-gates.mjs +58 -7
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
// 4. probe each of the loop's fixable-bug bound testIds N times (Decision 3 / 10 + D4, shell-free,
|
|
16
16
|
// per-run timeout) for resolvability + an N/N-green baseline, hashing each bound test file
|
|
17
17
|
// (the D5 custody anchor);
|
|
18
|
-
// 5. append ONE machine-only
|
|
19
|
-
//
|
|
18
|
+
// 5. append ONE machine-only v3 run record — segment-framed (base = git rev-parse HEAD, AD-048 D7)
|
|
19
|
+
// and bound to BOTH the tree fingerprint AND the SEGMENT's sorted fixable-bug testId set
|
|
20
|
+
// (Decision 9) — to <git dir>/agent-workflow-fold-completeness.jsonl.
|
|
20
21
|
// A SECOND verb, --red "<testId>" (BUGFREE-1 / AD-047), observes a testId RED on the current
|
|
21
22
|
// (pre-fold) tree and mints a red-probe receipt — the observed-red half of the honest red→green
|
|
22
23
|
// proof; observed-green / unresolvable / mixed / timed-out are distinguished refusals, nothing written.
|
|
@@ -35,7 +36,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
35
36
|
import { createHash } from 'node:crypto';
|
|
36
37
|
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
37
38
|
import { computeTreeFingerprint, plansInFlight } from './review-state.mjs';
|
|
38
|
-
import { resolveLedgerPath, readLedger, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
|
|
39
|
+
import { resolveLedgerPath, resolveBase, readLedger, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
|
|
39
40
|
import {
|
|
40
41
|
RESULT_SCHEMA_VERSION,
|
|
41
42
|
resolveResultsPath,
|
|
@@ -43,6 +44,13 @@ import {
|
|
|
43
44
|
collectBoundTestIds,
|
|
44
45
|
probeVerdict,
|
|
45
46
|
} from './fold-completeness.mjs';
|
|
47
|
+
// The changed-surface computation lives in the NEUTRAL shared module (BUGFREE-2 / AD-048, D4): the
|
|
48
|
+
// review-ledger writer's diff-size cap and this runner's coverage domain consume ONE computation,
|
|
49
|
+
// and the writer never imports this runner (the sole-tree-toucher boundary — import-split pinned).
|
|
50
|
+
// Re-exported below so the runner's tests (and any consumer) keep one entry point per concern.
|
|
51
|
+
import { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface, DIFF_FLAGS, parsePositiveIntKnob } from './changed-surface.mjs';
|
|
52
|
+
|
|
53
|
+
export { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface };
|
|
46
54
|
|
|
47
55
|
const ACTIVITY = 'plan-execution';
|
|
48
56
|
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / full-suite TAP can be large; never truncate
|
|
@@ -67,65 +75,6 @@ const childTestEnv = (env, extra = {}) => {
|
|
|
67
75
|
return out;
|
|
68
76
|
};
|
|
69
77
|
|
|
70
|
-
// ── Decision 5: the CLOSED changed-path classification rule (no heuristics) ───────────────────────
|
|
71
|
-
|
|
72
|
-
const TEST_FILE_RE = /\.(test|spec)\.[^./]+$/; // a.test.mjs, b.spec.js, c.test.cjs, d.spec.ts
|
|
73
|
-
const ASSESSABLE_EXT = new Set(['.mjs', '.cjs', '.js']);
|
|
74
|
-
const UNSUPPORTED_EXT = new Set(['.ts', '.tsx', '.jsx', '.mts', '.cts']);
|
|
75
|
-
|
|
76
|
-
// classifyChangedPath(rel) → 'assessable' | 'unsupported' | 'out-of-domain' | 'excluded-test'.
|
|
77
|
-
export const classifyChangedPath = (rel) => {
|
|
78
|
-
const base = rel.split('/').pop();
|
|
79
|
-
if (TEST_FILE_RE.test(base)) return 'excluded-test';
|
|
80
|
-
const dot = base.lastIndexOf('.');
|
|
81
|
-
const ext = dot >= 0 ? base.slice(dot) : '';
|
|
82
|
-
if (ASSESSABLE_EXT.has(ext)) return 'assessable';
|
|
83
|
-
if (UNSUPPORTED_EXT.has(ext)) return 'unsupported';
|
|
84
|
-
return 'out-of-domain';
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// ── unified-diff → new-side changed line numbers (line numbers only; content lines are ignored) ───
|
|
88
|
-
|
|
89
|
-
const DIFF_HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
90
|
-
|
|
91
|
-
// parseUnifiedDiff(diffText) → Map<rel, number[]>. Robust against a content line that happens to look
|
|
92
|
-
// like a `+++ ` header: a `+++ ` line is a FILE header only in the header region (right after a
|
|
93
|
-
// `diff --git`, before any `@@`); inside a hunk body it is ignored. New-side lines come purely from the
|
|
94
|
-
// `@@` headers, so no content-line disambiguation is needed for the line numbers themselves.
|
|
95
|
-
export const parseUnifiedDiff = (diffText) => {
|
|
96
|
-
const map = new Map();
|
|
97
|
-
let current = null;
|
|
98
|
-
let inHeader = false;
|
|
99
|
-
for (const line of String(diffText).split('\n')) {
|
|
100
|
-
if (line.startsWith('diff --git ')) {
|
|
101
|
-
current = null;
|
|
102
|
-
inHeader = true;
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (inHeader && line.startsWith('--- ')) continue;
|
|
106
|
-
if (inHeader && line.startsWith('+++ ')) {
|
|
107
|
-
const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, '')); // TAB/CR are git artifacts, never filename bytes (agy R6)
|
|
108
|
-
current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
|
|
109
|
-
inHeader = false;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
const m = DIFF_HUNK_RE.exec(line);
|
|
113
|
-
if (m) {
|
|
114
|
-
inHeader = false;
|
|
115
|
-
if (current == null) continue;
|
|
116
|
-
const start = Number(m[1]);
|
|
117
|
-
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
118
|
-
if (count > 0) {
|
|
119
|
-
const arr = map.get(current) ?? [];
|
|
120
|
-
for (let i = 0; i < count; i += 1) arr.push(start + i);
|
|
121
|
-
map.set(current, arr);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
for (const [k, v] of map) map.set(k, [...new Set(v)].sort((a, b) => a - b));
|
|
126
|
-
return map;
|
|
127
|
-
};
|
|
128
|
-
|
|
129
78
|
// ── Decision 6: V8 coverage → uncovered changed lines (innermost-range-wins) ──────────────────────
|
|
130
79
|
|
|
131
80
|
// lineStartOffsets(sourceText) → char offset of each line start (index i == line i+1). Splitting on
|
|
@@ -216,8 +165,12 @@ export const parseProbeOutput = ({ stdout, code, fileArg }) => {
|
|
|
216
165
|
return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
|
|
217
166
|
};
|
|
218
167
|
|
|
219
|
-
// defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a
|
|
220
|
-
|
|
168
|
+
// defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a
|
|
169
|
+
// shell). The pattern rides in the `=`-joined form: as a SEPARATE argv token a pattern beginning
|
|
170
|
+
// with "-"/"--" (a test name like "--telemetry refuses …") parses as an OPTION and the probe
|
|
171
|
+
// silently selects no test — the pattern-half sibling of the AD-047 dash-spawn file fix (found
|
|
172
|
+
// live by this plan's own --red loop).
|
|
173
|
+
export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', `--test-name-pattern=${pattern}`, file];
|
|
221
174
|
|
|
222
175
|
// ── the shared safe test-file resolver (BUGFREE-1, codex R1+R2) ───────────────────────────────────
|
|
223
176
|
// Custody hashing and every probe spawn go through THIS one resolver — the testId format itself is
|
|
@@ -291,12 +244,7 @@ export const resolveBoundArgv = (env = process.env) => {
|
|
|
291
244
|
return (file, pattern) => tmpl.map((a) => a.replace(/\{file\}/g, () => file).replace(/\{pattern\}/g, () => pattern));
|
|
292
245
|
};
|
|
293
246
|
|
|
294
|
-
// ── the changed
|
|
295
|
-
|
|
296
|
-
// The one diff-invocation shape both surface passes use. The a/ b/ prefixes are pinned EXPLICITLY
|
|
297
|
-
// (agy R6): a user's global diff.noprefix=true would otherwise drop them and the parsers would eat
|
|
298
|
-
// a real directory named "a" — user git config must never bend the parse.
|
|
299
|
-
const DIFF_FLAGS = ['--unified=0', '--no-color', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/'];
|
|
247
|
+
// ── read-only git plumbing (the changed-surface computation itself lives in changed-surface.mjs) ──
|
|
300
248
|
|
|
301
249
|
const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
|
|
302
250
|
const gitStdout = (args, cwd) => {
|
|
@@ -317,101 +265,10 @@ const canon = (path) => {
|
|
|
317
265
|
return path;
|
|
318
266
|
}
|
|
319
267
|
};
|
|
320
|
-
// A changed assessable LEAF is assessed only if it is a REGULAR file. lstat (no-follow): a symlinked
|
|
321
|
-
// or non-regular *.mjs must NEVER be read/canonicalized — following it could read outside the work
|
|
322
|
-
// tree or HANG on a FIFO/device (codex R2). A non-regular leaf fails closed (routed to `unsupported`).
|
|
323
|
-
const isRegularLeaf = (abs) => {
|
|
324
|
-
try {
|
|
325
|
-
return lstatSync(abs).isFile();
|
|
326
|
-
} catch {
|
|
327
|
-
return false;
|
|
328
|
-
}
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
// computeChangedSurface(root) → { assessable: Map<rel, number[]>, unsupported: [rel], outOfDomain: [rel] }.
|
|
332
|
-
// Domain = the review-payload domain (tracked working-vs-HEAD changes + untracked-not-ignored files),
|
|
333
|
-
// classified by the CLOSED rule. Tracked changed lines come from `git diff HEAD -U0`; an untracked file
|
|
334
|
-
// is wholly new, so all its lines are "changed".
|
|
335
|
-
export const computeChangedSurface = (root) => {
|
|
336
|
-
const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
|
|
337
|
-
?? gitStdout(['diff', ...DIFF_FLAGS], root) // no HEAD yet (unborn branch)
|
|
338
|
-
?? '';
|
|
339
|
-
const trackedLines = parseUnifiedDiff(trackedDiff);
|
|
340
|
-
const untrackedZ = gitStdout(['ls-files', '--others', '--exclude-standard', '-z'], root) ?? '';
|
|
341
|
-
const untracked = untrackedZ.split('\0').filter(Boolean);
|
|
342
|
-
|
|
343
|
-
const assessable = new Map();
|
|
344
|
-
const unsupported = [];
|
|
345
|
-
const outOfDomain = [];
|
|
346
|
-
const place = (rel, cls, lines) => {
|
|
347
|
-
if (cls === 'excluded-test') return;
|
|
348
|
-
if (cls === 'assessable') {
|
|
349
|
-
if (isRegularLeaf(join(root, rel))) assessable.set(rel, lines);
|
|
350
|
-
else unsupported.push(rel); // a symlinked / non-regular source → fail closed, never followed
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
if (cls === 'unsupported') unsupported.push(rel);
|
|
354
|
-
else outOfDomain.push(rel);
|
|
355
|
-
};
|
|
356
|
-
for (const [rel, lines] of trackedLines) place(rel, classifyChangedPath(rel), lines);
|
|
357
|
-
for (const rel of untracked) {
|
|
358
|
-
const cls = classifyChangedPath(rel);
|
|
359
|
-
if (cls !== 'assessable') {
|
|
360
|
-
place(rel, cls, []);
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
// Guard the leaf BEFORE reading — never follow a symlink to count an untracked file's lines.
|
|
364
|
-
const abs = join(root, rel);
|
|
365
|
-
if (!isRegularLeaf(abs)) {
|
|
366
|
-
unsupported.push(rel);
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
const src = readFileSafe(abs);
|
|
370
|
-
const count = src == null || src.length === 0 ? 0 : src.split('\n').length;
|
|
371
|
-
assessable.set(rel, Array.from({ length: count }, (_, i) => i + 1));
|
|
372
|
-
}
|
|
373
|
-
return { assessable, unsupported: unsupported.sort(), outOfDomain: outOfDomain.sort() };
|
|
374
|
-
};
|
|
375
|
-
|
|
376
268
|
// ── the oracle-tamper surface (BUGFREE-1 Phase 2.2, Approach-3) ───────────────────────────────────
|
|
377
269
|
|
|
378
270
|
const DIFF_HUNK_OLD_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,\d+)? @@/;
|
|
379
271
|
|
|
380
|
-
// Git C-quotes diff-header paths carrying quotes/control/non-ASCII bytes ("a/\321\202….mjs") — an
|
|
381
|
-
// unparsed quoted path compares unequal to its classifier/testId form and would silently escape the
|
|
382
|
-
// tamper/coverage surface (agy R5). Strip the quotes and decode escapes BYTE-wise (octal escapes
|
|
383
|
-
// are UTF-8 bytes). Space-only paths are not quoted (they carry a trailing TAB — trimmed upstream).
|
|
384
|
-
const CQUOTE_SIMPLE = { n: 10, t: 9, r: 13, f: 12, v: 11, b: 8, a: 7, '"': 34, '\\': 92 };
|
|
385
|
-
export const unquoteDiffPath = (p) => {
|
|
386
|
-
if (!(p.length >= 2 && p.startsWith('"') && p.endsWith('"'))) return p;
|
|
387
|
-
const inner = p.slice(1, -1);
|
|
388
|
-
const bytes = [];
|
|
389
|
-
for (let i = 0; i < inner.length; i += 1) {
|
|
390
|
-
const c = inner[i];
|
|
391
|
-
if (c !== '\\') {
|
|
392
|
-
// Consume a full CODE POINT — 16-bit-unit iteration would split a surrogate pair (an
|
|
393
|
-
// unescaped non-BMP char, reachable under core.quotepath=false) into replacement bytes (agy R6).
|
|
394
|
-
const ch = String.fromCodePoint(inner.codePointAt(i));
|
|
395
|
-
for (const b of Buffer.from(ch, 'utf8')) bytes.push(b);
|
|
396
|
-
i += ch.length - 1;
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
const rest = inner.slice(i + 1);
|
|
400
|
-
const oct = /^[0-7]{1,3}/.exec(rest);
|
|
401
|
-
if (oct) {
|
|
402
|
-
bytes.push(Number.parseInt(oct[0], 8) & 0xff);
|
|
403
|
-
i += oct[0].length;
|
|
404
|
-
} else if (rest[0] in CQUOTE_SIMPLE) {
|
|
405
|
-
bytes.push(CQUOTE_SIMPLE[rest[0]]);
|
|
406
|
-
i += 1;
|
|
407
|
-
} else if (rest[0] !== undefined) {
|
|
408
|
-
for (const b of Buffer.from(rest[0], 'utf8')) bytes.push(b);
|
|
409
|
-
i += 1;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
return Buffer.from(bytes).toString('utf8');
|
|
413
|
-
};
|
|
414
|
-
|
|
415
272
|
// parseDiffOldSide(diffText) → Map<oldRel, { removals: boolean }> — the OLD-side view of a -U0
|
|
416
273
|
// tracked diff: which pre-existing (HEAD) files carry any removed/modified line (a hunk whose
|
|
417
274
|
// old-count > 0). A file ADDED by the diff (--- /dev/null) has no old side and never appears; a
|
|
@@ -549,26 +406,15 @@ const appendRecord = (resultsPath, record) => {
|
|
|
549
406
|
|
|
550
407
|
// ── the run ───────────────────────────────────────────────────────────────────────────────────────
|
|
551
408
|
|
|
552
|
-
// The shared fail-closed integer parser for the D4 probe knobs: zero / negative / fractional /
|
|
553
|
-
// non-numeric values are typed refusals by name — the parseInt(...)||default idiom would silently
|
|
554
|
-
// accept bad truthy values (codex R2). Unset → the default; set → a positive integer, exactly.
|
|
555
|
-
const parsePositiveIntKnob = (env, name, fallback) => {
|
|
556
|
-
const raw = env[name];
|
|
557
|
-
if (raw === undefined) return fallback;
|
|
558
|
-
if (!/^\d+$/.test(String(raw).trim()) || Number.parseInt(raw, 10) < 1) {
|
|
559
|
-
throw stop(`${name} must be a positive integer (got "${raw}") — refusing to guess (fail closed)`);
|
|
560
|
-
}
|
|
561
|
-
return Number.parseInt(raw, 10);
|
|
562
|
-
};
|
|
563
|
-
|
|
564
409
|
export const budgetsFromEnv = (env) => ({
|
|
565
410
|
mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
|
|
566
411
|
hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
|
|
567
412
|
timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
|
|
568
413
|
// D4: N reruns per probe side; the per-RUN probe timeout (each of the N runs gets its own budget,
|
|
569
|
-
// never one shared series budget — agy R1). Probes only: the suite run keeps the no-timeout status
|
|
570
|
-
|
|
571
|
-
|
|
414
|
+
// never one shared series budget — agy R1). Probes only: the suite run keeps the no-timeout status
|
|
415
|
+
// quo. The fail-closed parser is the shared one (changed-surface.mjs), thrown as THIS tool's STOP.
|
|
416
|
+
foldReruns: parsePositiveIntKnob(env, 'AW_FOLD_RERUNS', 3, stop),
|
|
417
|
+
probeTimeoutS: parsePositiveIntKnob(env, 'AW_FOLD_PROBE_TIMEOUT_S', 120, stop),
|
|
572
418
|
});
|
|
573
419
|
|
|
574
420
|
// ── the shared N-rerun probe (D4) — ONE helper for both the run's green side and the --red verb ───
|
|
@@ -656,10 +502,13 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
|
|
|
656
502
|
for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
|
|
657
503
|
}
|
|
658
504
|
|
|
659
|
-
// Decision 3 / 10 + D4: probe each fixable-bug bound
|
|
505
|
+
// Decision 3 / 10 + D4: probe each of the SEGMENT's fixable-bug bound testIds N times
|
|
506
|
+
// (shell-free, per-run timeout). Segment scope (D7): a committed phase's folds are closed
|
|
507
|
+
// obligations — only triages recorded at the current base bind.
|
|
660
508
|
const ledgerPath = resolveLedgerPath(cwd, env);
|
|
661
509
|
const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
|
|
662
|
-
const
|
|
510
|
+
const base = resolveBase(cwd);
|
|
511
|
+
const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop, base });
|
|
663
512
|
const testIds = boundTestIds.map(
|
|
664
513
|
(id) => probeBound({ id, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS }).entry,
|
|
665
514
|
);
|
|
@@ -672,6 +521,7 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
|
|
|
672
521
|
schema: RESULT_SCHEMA_VERSION,
|
|
673
522
|
kind: 'run',
|
|
674
523
|
loop,
|
|
524
|
+
base,
|
|
675
525
|
fingerprint,
|
|
676
526
|
boundTestIds,
|
|
677
527
|
testIds,
|
|
@@ -743,6 +593,7 @@ export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } =
|
|
|
743
593
|
schema: RESULT_SCHEMA_VERSION,
|
|
744
594
|
kind: 'red-probe',
|
|
745
595
|
loop,
|
|
596
|
+
base: resolveBase(cwd), // the SEGMENT frame (D7): a receipt attests red within its segment
|
|
746
597
|
testId,
|
|
747
598
|
fileHash: entry.fileHash,
|
|
748
599
|
runs: entry.runs,
|
|
@@ -766,15 +617,17 @@ Usage:
|
|
|
766
617
|
node fold-completeness-run.mjs --red "<test-file>#<test-name-pattern>" [--cwd <dir>]
|
|
767
618
|
|
|
768
619
|
The default run: runs the in-flight plan-execution loop's suite ONCE under coverage, maps every
|
|
769
|
-
changed executable line to covered/uncovered, probes each fixable-bug bound
|
|
770
|
-
(AW_FOLD_RERUNS, default 3) for resolvability + an N/N-green baseline, records each bound test
|
|
771
|
-
content hash (custody), and appends one
|
|
620
|
+
changed executable line to covered/uncovered, probes each of the SEGMENT's fixable-bug bound testIds
|
|
621
|
+
N times (AW_FOLD_RERUNS, default 3) for resolvability + an N/N-green baseline, records each bound test
|
|
622
|
+
file's content hash (custody), and appends one v3 run record — segment-framed (base = git rev-parse
|
|
623
|
+
HEAD; the bound set is the current segment's, AD-048 D7) — to
|
|
772
624
|
<git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS overrides).
|
|
773
625
|
|
|
774
626
|
--red observes a testId RED on the CURRENT (pre-fold) tree — the honest fold-time order is: classify
|
|
775
627
|
the fixable-bug with its testId → write the test → --red observes it FAIL (N/N) BEFORE the fix is
|
|
776
628
|
applied → fold the fix → the normal run observes green → the gate checks receipt + order + custody.
|
|
777
|
-
An N/N red mints a red-probe receipt (testId, counts, content hash, fingerprint
|
|
629
|
+
An N/N red mints a red-probe receipt (testId, counts, content hash, fingerprint, and base — the
|
|
630
|
+
current SEGMENT frame: a receipt never crosses a commit boundary); observed-green /
|
|
778
631
|
unresolvable / mixed / timed-out are DISTINGUISHED typed refusals and nothing is written
|
|
779
632
|
(mixed/timeout = QUARANTINE — never converts, no override lane).
|
|
780
633
|
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
// here). The gate is plan-EXECUTION-scoped, filtered to the in-flight plan's filename stem:
|
|
15
15
|
// exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there);
|
|
16
16
|
// when no plan is in flight; when the tree is clean; when the cwd is not a git work tree;
|
|
17
|
-
// and when the in-flight plan-execution
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// and when the in-flight plan-execution SEGMENT — (loop, base = git rev-parse HEAD),
|
|
18
|
+
// schema v3, AD-048 D7 — has a CURRENT run record (kind-aware: the latest RUN, never a
|
|
19
|
+
// later red-probe) whose BOTH bindings match — the tree fingerprint AND the SEGMENT's
|
|
20
|
+
// sorted fixable-bug testId set recorded in the run — with, per bound testId: an
|
|
21
|
+
// N/N-green probe (D4), an observed-red receipt in this SEGMENT (a committed phase's
|
|
22
|
+
// obligations closed with its commit; a receipt never crosses a commit boundary), that receipt
|
|
21
23
|
// PRECEDING the latest run in ledger order (anti-post-hoc), and content CUSTODY — the run's
|
|
22
24
|
// recorded test-file hash equals the latest custody-eligible red-probe hash on that file
|
|
23
25
|
// (eligible = the receipt's own testId is bound AND it precedes the run, D5); plus 0
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
// (review ledger v3), and the reserved EMPTY mutation shape (no mutation ships). A
|
|
27
29
|
// recorded red-proof override waives the receipt + custody proof for exactly the testId
|
|
28
30
|
// it names (D7) — never the N/N-green requirement, never QUARANTINE.
|
|
29
|
-
// exit 1 for any DIRTY in-flight plan-execution
|
|
31
|
+
// exit 1 for any DIRTY in-flight plan-execution segment lacking such a current run record — including
|
|
30
32
|
// the stale-fingerprint case (a tree edit moves the fingerprint), the same-fingerprint/
|
|
31
33
|
// new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9),
|
|
32
34
|
// and a schema-1 (or tamper-less) record as the loop's latest run (an older runner; D2) —
|
|
@@ -58,24 +60,32 @@
|
|
|
58
60
|
// free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
59
61
|
|
|
60
62
|
import { readFileSync } from 'node:fs';
|
|
61
|
-
import { join } from 'node:path';
|
|
62
63
|
import { pathToFileURL } from 'node:url';
|
|
63
64
|
import { spawnSync } from 'node:child_process';
|
|
64
65
|
import { detectBackends } from './detect-backends.mjs';
|
|
65
66
|
import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
|
|
66
67
|
import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
|
|
67
68
|
import { computeTreeFingerprint, isTreeClean, plansInFlight } from './review-state.mjs';
|
|
68
|
-
import { resolveLedgerPath, readLedger,
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
import { resolveLedgerPath, resolveBase, readLedger, filterSegmentRecords, collectOverrides, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
|
|
70
|
+
// The fold-ledger locator and the D4 probe-verdict algebra live in the NEUTRAL shared module
|
|
71
|
+
// (BUGFREE-2 / AD-048, D8): review-ledger.mjs telemetry reads the fold ledger through THAT module —
|
|
72
|
+
// importing this checker from there would close an import cycle (this file imports review-ledger).
|
|
73
|
+
// Re-exported here so the runner and every existing consumer keep their one entry point.
|
|
74
|
+
import { probeVerdict, RESULTS_BASENAME, resolveResultsPath } from './changed-surface.mjs';
|
|
75
|
+
|
|
76
|
+
export { probeVerdict, RESULTS_BASENAME, resolveResultsPath };
|
|
71
77
|
// SCHEMA v2 (BUGFREE-1 / AD-047): records gain a kind discriminator — `run` (the fold-completeness
|
|
72
78
|
// run, now with per-testId rerun counts + the test file's content hash) | `red-probe` (the
|
|
73
|
-
// observed-red receipt --red mints).
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
// observed-red receipt --red mints). SCHEMA v3 (BUGFREE-2 / AD-048, D7): records carry `base` — the
|
|
80
|
+
// SEGMENT frame — and the gate scopes bound testIds, receipts, custody, and tamper to
|
|
81
|
+
// (loop, base = current HEAD): a committed phase's custody obligations close with its commit (the
|
|
82
|
+
// cross-phase-churn override class dies). RESULT_SCHEMA_VERSION is what the WRITER emits; the
|
|
83
|
+
// reader tolerates every SUPPORTED version under its own per-version rules (the review-ledger
|
|
84
|
+
// v1→v2 precedent), so v1/v2 ledgers never retroactively become malformed — but only a v3 record
|
|
85
|
+
// can enter a segment, so a dirty segment whose loop holds only pre-v3 records fails with a reason
|
|
86
|
+
// naming the schema upgrade (D7 legacy rule).
|
|
87
|
+
export const RESULT_SCHEMA_VERSION = 3;
|
|
88
|
+
export const SUPPORTED_RESULT_SCHEMAS = new Set([1, 2, 3]);
|
|
79
89
|
const ACTIVITY = 'plan-execution';
|
|
80
90
|
const SLOT = 'review';
|
|
81
91
|
|
|
@@ -89,22 +99,17 @@ const gitLine = (args, cwd) => {
|
|
|
89
99
|
|
|
90
100
|
const gitRoot = (cwd) => gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
91
101
|
|
|
92
|
-
// The result-ledger path: AW_FOLD_RESULTS overrides (mirrors AW_REVIEW_LEDGER); else <git dir>/basename.
|
|
93
|
-
export const resolveResultsPath = (cwd, env = process.env) => {
|
|
94
|
-
if (env.AW_FOLD_RESULTS) return env.AW_FOLD_RESULTS;
|
|
95
|
-
const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
|
|
96
|
-
return gitDir == null ? null : join(gitDir, RESULTS_BASENAME);
|
|
97
|
-
};
|
|
98
|
-
|
|
99
102
|
// ── the bound fixable-bug testId set (the SINGLE source of truth — runner + checker share it) ────
|
|
100
103
|
|
|
101
|
-
// collectBoundTestIds(reviewRecords, { activity, loop }) → the sorted, de-duplicated testIds
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
104
|
+
// collectBoundTestIds(reviewRecords, { activity, loop, base }) → the sorted, de-duplicated testIds
|
|
105
|
+
// of the SEGMENT's fixable-bug classifications (D7: pass base — a committed segment's folds are
|
|
106
|
+
// closed obligations; `base: undefined` would match nothing, never legacy records). Pure over
|
|
107
|
+
// review-ledger records. Both the runner (which records the set as a binding) and the checker
|
|
108
|
+
// (which recomputes it for the staleness check) call THIS, so the two can never drift (Decision 9:
|
|
109
|
+
// same-fingerprint/new-testId is stale).
|
|
110
|
+
export const collectBoundTestIds = (reviewRecords, { activity = ACTIVITY, loop, base } = {}) => {
|
|
106
111
|
const ids = new Set();
|
|
107
|
-
for (const r of
|
|
112
|
+
for (const r of filterSegmentRecords(reviewRecords, { activity, loop, base })) {
|
|
108
113
|
if (r.kind !== 'triage') continue;
|
|
109
114
|
for (const c of r.classifications) if (c.class === 'fixable-bug' && typeof c.testId === 'string' && c.testId.length > 0) ids.add(c.testId);
|
|
110
115
|
}
|
|
@@ -120,11 +125,18 @@ const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'st
|
|
|
120
125
|
|
|
121
126
|
const HASH_RE = /^[0-9a-f]{64}$/; // sha-256 hex — the content-custody hash shape
|
|
122
127
|
|
|
123
|
-
// The shared record frame (
|
|
128
|
+
// The shared record frame (every version, both kinds). v3 adds the SEGMENT frame: base is REQUIRED
|
|
129
|
+
// (null on an unborn branch); a v1/v2 record never carries it — an old record never grows new
|
|
130
|
+
// surface (the review-ledger D2 discipline).
|
|
124
131
|
const validateFrame = (obj) => {
|
|
125
132
|
if (!isNonEmptyString(obj.loop)) return 'missing loop';
|
|
126
133
|
if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return 'fingerprint must be null or a non-empty string';
|
|
127
134
|
if (!isNonEmptyString(obj.timestamp)) return 'missing timestamp';
|
|
135
|
+
if (obj.schema >= 3) {
|
|
136
|
+
if (!(obj.base === null || isNonEmptyString(obj.base))) return 'a v3 record requires base — null (unborn branch) or the HEAD commit the dirty tree sits on';
|
|
137
|
+
} else if (obj.base !== undefined) {
|
|
138
|
+
return `base is a v3 frame field — a schema-${obj.schema} record never carries it`;
|
|
139
|
+
}
|
|
128
140
|
return null;
|
|
129
141
|
};
|
|
130
142
|
|
|
@@ -208,9 +220,11 @@ export const validateRunRecord = (obj) => {
|
|
|
208
220
|
if (obj.kind === 'run') {
|
|
209
221
|
const r = validateRunBody(obj, validateV2Entry);
|
|
210
222
|
if (r) return { ok: false, reason: r };
|
|
211
|
-
// The tamper surface
|
|
212
|
-
//
|
|
213
|
-
//
|
|
223
|
+
// The tamper surface is OPTIONAL on a v2 read — a record written by the pre-tamper v2 runner
|
|
224
|
+
// stays readable (never retroactively malformed); the GATE handles the staleness. A v3 runner
|
|
225
|
+
// ALWAYS records it, so a v3 run without one is not this runner's output. When present, the
|
|
226
|
+
// shape is exact.
|
|
227
|
+
if (obj.schema >= 3 && obj.tamper === undefined) return { ok: false, reason: 'a v3 run records its tamper surface' };
|
|
214
228
|
if (obj.tamper !== undefined && (!isPlainObject(obj.tamper) || !isStringArray(obj.tamper.tampered))) {
|
|
215
229
|
return { ok: false, reason: 'tamper.tampered must be an array of strings (when the tamper surface is recorded)' };
|
|
216
230
|
}
|
|
@@ -255,6 +269,12 @@ export const readResults = (path, readFile = readFileSync) => {
|
|
|
255
269
|
// (latest is last).
|
|
256
270
|
export const filterLoopResults = (records, loop) => records.filter((r) => r.loop === loop);
|
|
257
271
|
|
|
272
|
+
// filterSegmentResults(records, loop, base) → the result records of ONE segment (D7), order
|
|
273
|
+
// preserved. STRICT: only a v3+ record carries base, so pre-v3 history can never enter a segment
|
|
274
|
+
// (readable, never re-scoped — the review-ledger filterSegmentRecords discipline).
|
|
275
|
+
export const filterSegmentResults = (records, loop, base) =>
|
|
276
|
+
filterLoopResults(records, loop).filter((r) => r.schema >= 3 && r.base === base);
|
|
277
|
+
|
|
258
278
|
// ── kind-aware selectors (shared: the runner and the checker read the ledger through THESE) ──────
|
|
259
279
|
|
|
260
280
|
// A v1 record IS a run (the kindless AD-046 shape); a v2 record is a run only under kind:"run".
|
|
@@ -271,17 +291,9 @@ export const latestRunRecord = (loopRecords) => {
|
|
|
271
291
|
return null;
|
|
272
292
|
};
|
|
273
293
|
|
|
274
|
-
// probeVerdict
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// resolution is QUARANTINE — it never converts and has no override lane (a flaky pin proves
|
|
278
|
-
// nothing — replace the test). Zero resolved runs (or a defensive runs=0) reads unresolvable.
|
|
279
|
-
export const probeVerdict = (t) => {
|
|
280
|
-
const unresolved = t.runs - t.greens - t.reds - t.timeouts;
|
|
281
|
-
if (unresolved >= t.runs) return 'unresolvable';
|
|
282
|
-
if (t.timeouts > 0 || unresolved > 0 || (t.greens > 0 && t.reds > 0)) return 'quarantine';
|
|
283
|
-
return t.greens === t.runs ? 'green' : 'red';
|
|
284
|
-
};
|
|
294
|
+
// probeVerdict — the D4 verdict algebra, ONE home (now the neutral changed-surface.mjs; re-exported
|
|
295
|
+
// above): the runner (--red mints only on 'red'), this checker (the gate passes only on 'green'),
|
|
296
|
+
// and the review-ledger telemetry (quarantine counts) all read the SAME algebra.
|
|
285
297
|
|
|
286
298
|
// ── the check + report core ─────────────────────────────────────────────────────────────────────
|
|
287
299
|
|
|
@@ -304,6 +316,7 @@ export const buildFoldState = ({ cwd, env = process.env, detect = detectBackends
|
|
|
304
316
|
const plans = plansInFlight(root);
|
|
305
317
|
const fingerprint = computeTreeFingerprint(cwd);
|
|
306
318
|
const clean = fingerprint == null ? null : isTreeClean(cwd);
|
|
319
|
+
const base = resolveBase(cwd);
|
|
307
320
|
const resultsPath = resolveResultsPath(cwd, env);
|
|
308
321
|
const resultRead = resultsPath ? readResults(resultsPath) : { records: [], malformed: 0, malformedReasons: [] };
|
|
309
322
|
const reviewPath = resolveLedgerPath(cwd, env);
|
|
@@ -315,6 +328,7 @@ export const buildFoldState = ({ cwd, env = process.env, detect = detectBackends
|
|
|
315
328
|
plans,
|
|
316
329
|
fingerprint,
|
|
317
330
|
clean,
|
|
331
|
+
base,
|
|
318
332
|
resultsPath,
|
|
319
333
|
resultRecords: resultRead.records,
|
|
320
334
|
resultMalformed: resultRead.malformed,
|
|
@@ -353,20 +367,28 @@ export const decideCheck = (state) => {
|
|
|
353
367
|
if (state.reviewMalformed > 0) return { code: 1, reason: `the review ledger has ${state.reviewMalformed} malformed line(s) — failing closed; inspect ${state.reviewPath}` };
|
|
354
368
|
|
|
355
369
|
const loop = state.plans[0].replace(/\.md$/, '');
|
|
356
|
-
|
|
370
|
+
// SEGMENT scope (D7): the gate judges the current segment — (loop, base = the HEAD the dirty
|
|
371
|
+
// tree sits on). A committed phase's runs, receipts, custody chains, and tamper obligations are
|
|
372
|
+
// closed history; only v3 records at the current base enter.
|
|
373
|
+
const loopResults = filterSegmentResults(state.resultRecords, loop, state.base);
|
|
357
374
|
const sel = latestRunRecord(loopResults);
|
|
358
|
-
if (sel == null)
|
|
375
|
+
if (sel == null) {
|
|
376
|
+
const allLoop = filterLoopResults(state.resultRecords, loop);
|
|
377
|
+
const legacy = allLoop.length > 0 && allLoop.every((r) => !(r.schema >= 3))
|
|
378
|
+
? ' (the loop has only pre-v3 records, which never enter a segment — the schema upgrade requires a fresh run)'
|
|
379
|
+
: '';
|
|
380
|
+
return { code: 1, reason: `dirty plan-execution loop "${loop}" but no fold-completeness run recorded for the current segment — run fold-completeness-run.mjs${legacy}` };
|
|
381
|
+
}
|
|
359
382
|
const latest = sel.record;
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
if (latest.tamper === undefined) return { code: 1, reason: `the loop's latest run has no recorded tamper surface (an older runner) — re-run fold-completeness-run.mjs` };
|
|
383
|
+
// No per-record schema re-checks here (agy Phase-2 nit): only a VALID v3 record can be a segment
|
|
384
|
+
// member — the reader binned anything older or tamper-less before this point (a v1 never carries
|
|
385
|
+
// base; a v3 run without a tamper surface is malformed), and a malformed line already failed the
|
|
386
|
+
// gate closed above.
|
|
365
387
|
|
|
366
388
|
// Decision 9 — the double binding. A tree edit moves the fingerprint; a new fixable-bug triage moves
|
|
367
389
|
// the bound-testId set. Either mismatch is STALE (the run no longer describes the committable tree).
|
|
368
390
|
if (latest.fingerprint !== state.fingerprint) return { code: 1, reason: `no fold-completeness run for the current tree (the tree was edited after the run) — re-run fold-completeness-run.mjs after the last edit` };
|
|
369
|
-
const currentBound = collectBoundTestIds(state.reviewRecords, { activity: ACTIVITY, loop });
|
|
391
|
+
const currentBound = collectBoundTestIds(state.reviewRecords, { activity: ACTIVITY, loop, base: state.base });
|
|
370
392
|
if (!sameSet(latest.boundTestIds, currentBound)) return { code: 1, reason: `a fixable-bug testId was triaged after the run (the bound-testId set changed) — re-run fold-completeness-run.mjs` };
|
|
371
393
|
// Fail CLOSED if the run's probe set does not cover its bound-testId set EXACTLY. A well-formed
|
|
372
394
|
// runner record always probes every bound testId (one testIds[] entry per boundTestId), but a record
|
|
@@ -480,9 +502,9 @@ const formatHuman = (state, check) => {
|
|
|
480
502
|
lines.push(` result ledger: ${state.resultsPath ?? '(unresolvable — no git dir)'} (${state.resultRecords.length} record(s)${state.resultMalformed ? `, ${state.resultMalformed} malformed — inspect the file` : ''})`);
|
|
481
503
|
if (state.plans.length === 1) {
|
|
482
504
|
const loop = state.plans[0].replace(/\.md$/, '');
|
|
483
|
-
const
|
|
484
|
-
const sel = latestRunRecord(
|
|
485
|
-
if (sel) lines.push(runLine(sel.record,
|
|
505
|
+
const segResults = filterSegmentResults(state.resultRecords, loop, state.base); // the current segment (D7)
|
|
506
|
+
const sel = latestRunRecord(segResults); // kind-aware: never render a red-probe as "the run"
|
|
507
|
+
if (sel) lines.push(runLine(sel.record, segResults.filter(isRedProbeRecord)));
|
|
486
508
|
}
|
|
487
509
|
lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
|
|
488
510
|
return lines.join('\n');
|
|
@@ -495,15 +517,18 @@ Usage:
|
|
|
495
517
|
|
|
496
518
|
Reads the result ledger the runner writes (<git dir>/${RESULTS_BASENAME}; AW_FOLD_RESULTS overrides),
|
|
497
519
|
resolves the effective ${ACTIVITY}.${SLOT} recipe, recomputes the canonical uncommitted-state
|
|
498
|
-
fingerprint, and decides whether the in-flight plan-execution
|
|
520
|
+
fingerprint, and decides whether the in-flight plan-execution SEGMENT's changed code is pinned by
|
|
521
|
+
tests — (loop, base = git rev-parse HEAD), schema v3 (AD-048 D7): bound testIds, receipts, custody,
|
|
522
|
+
and tamper all filter to the current segment; a committed phase's obligations close with its commit.
|
|
499
523
|
|
|
500
524
|
--status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary
|
|
501
525
|
(per-testId D4 verdicts + rerun counts), the loop's red-probe receipts, verdict.
|
|
502
526
|
--check → the gate exit code. The normative exit contract lives in the tool header (the single home):
|
|
503
|
-
exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a CURRENT run whose
|
|
504
|
-
AND bound-testId set both match, with — per bound testId — an N/N-green probe,
|
|
505
|
-
receipt that PRECEDES the run, and content custody (run hash == the
|
|
506
|
-
hash on that file); plus 0 uncovered changed lines, 0 changed
|
|
527
|
+
exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a CURRENT segment run whose
|
|
528
|
+
fingerprint AND segment bound-testId set both match, with — per bound testId — an N/N-green probe,
|
|
529
|
+
an observed-red receipt in this segment that PRECEDES the run, and content custody (run hash == the
|
|
530
|
+
latest custody-eligible receipt hash on that file); plus 0 uncovered changed lines, 0 changed
|
|
531
|
+
unsupported source, every tampered
|
|
507
532
|
test-surface file covered by a recorded oracle-change override, and the reserved empty mutation
|
|
508
533
|
shape. A recorded red-proof override (review-ledger-write override) waives receipt + custody for
|
|
509
534
|
exactly its testId — never green-N/N, never QUARANTINE. exit 1 otherwise (stale/missing/older-runner
|
package/tools/procedures.mjs
CHANGED
|
@@ -212,7 +212,7 @@ const reviewLoopAdvice = (slots, activity) =>
|
|
|
212
212
|
' • At the cap, classify every surviving blocking finding: fixable-bug (fold ONCE as a red→green test, re-review) / inherent-layer-residual (document + raise to an acceptance criterion) / escalate (the maintainer decides); a minor never forces triage.',
|
|
213
213
|
...(activity === 'plan-execution'
|
|
214
214
|
? [
|
|
215
|
-
' • The computed instrument for THIS loop: record each round + triage via review-ledger (record / classify); read the stop with review-ledger --status (its render replaces the hand-composed tally); gate the commit with review-ledger --check.',
|
|
215
|
+
' • The computed instrument for THIS loop: run the FULL gate matrix with run-gates --record BEFORE recording a round (the green-baseline receipt the writer demands), then record each round + triage via review-ledger (record / classify); rounds, caps, and teeth are per SEGMENT (base = HEAD — the counter resets only at a gated commit); read the stop with review-ledger --status (its render replaces the hand-composed tally); gate the commit with review-ledger --check.',
|
|
216
216
|
]
|
|
217
217
|
: []),
|
|
218
218
|
]
|