@sabaiway/agent-workflow-kit 2.1.0 → 3.1.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 +94 -1
- package/README.md +7 -5
- package/SKILL.md +16 -8
- package/bin/install.mjs +11 -2
- package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +163 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +171 -22
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +156 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +158 -27
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/capability.json +1 -1
- package/migrations/3.0.0-hardened-core-loop.md +44 -0
- package/migrations/README.md +1 -1
- package/package.json +2 -2
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/bootstrap.md +3 -3
- package/references/modes/commit-guard.md +19 -0
- package/references/modes/core-evidence.md +15 -0
- package/references/modes/coverage-check.md +12 -0
- package/references/modes/doc-parity.md +7 -6
- package/references/modes/gates.md +7 -6
- package/references/modes/grounding.md +1 -2
- package/references/modes/hook.md +1 -1
- package/references/modes/recommendations.md +1 -0
- package/references/modes/review-state.md +3 -3
- package/references/modes/upgrade.md +6 -6
- package/references/modes/velocity.md +2 -2
- package/references/modes/worktrees.md +120 -0
- package/references/scripts/archive-decisions.mjs +3 -3
- package/references/scripts/archive-decisions.test.mjs +5 -5
- package/references/scripts/check-docs-size-cli.test.mjs +41 -0
- package/references/scripts/check-docs-size.mjs +46 -29
- package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
- package/references/scripts/install-git-hooks.mjs +90 -18
- package/references/scripts/install-git-hooks.test.mjs +102 -0
- package/references/scripts/migrate-gates-branches.test.mjs +157 -0
- package/references/scripts/migrate-gates.mjs +395 -0
- package/references/scripts/migrate-gates.test.mjs +284 -0
- package/references/templates/agent_rules.md +2 -2
- package/tools/ack-write.mjs +1 -1
- package/tools/atomic-write.mjs +2 -2
- package/tools/autonomy-config.mjs +1 -1
- package/tools/autonomy-doctor.mjs +2 -2
- package/tools/autonomy-write.mjs +1 -1
- package/tools/bridge-settings-read.mjs +1 -1
- package/tools/bridge-settings.mjs +1 -1
- package/tools/changed-surface.mjs +16 -77
- package/tools/cheap-agents.mjs +2 -2
- package/tools/commands.mjs +26 -10
- package/tools/commit-guard.mjs +167 -0
- package/tools/core-evidence.mjs +914 -0
- package/tools/coverage-check.mjs +260 -0
- package/tools/delegation.mjs +1 -1
- package/tools/detect-backends.mjs +3 -3
- package/tools/doc-parity.mjs +11 -27
- package/tools/engine-source.mjs +1 -1
- package/tools/family-members.mjs +1 -1
- package/tools/family-registry.mjs +1 -1
- package/tools/fs-safe.mjs +1 -1
- package/tools/gate-hook.mjs +1 -1
- package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
- package/tools/grounding.mjs +19 -96
- package/tools/hide-footprint.mjs +1 -1
- package/tools/inject-methodology.mjs +72 -41
- package/tools/known-footprint.mjs +1 -1
- package/tools/labels.mjs +1 -1
- package/tools/lcov.mjs +6 -10
- package/tools/lens-region.mjs +1 -1
- package/tools/manifest/validate.mjs +19 -0
- package/tools/migrate-adr-store.mjs +5 -5
- package/tools/orchestration-config.mjs +1 -1
- package/tools/orchestration-write.mjs +1 -1
- package/tools/presentation.mjs +1 -1
- package/tools/procedures.mjs +3 -3
- package/tools/recipes.mjs +62 -11
- package/tools/recommendations.mjs +97 -14
- package/tools/release-scan.mjs +10 -3
- package/tools/renderers.mjs +1 -1
- package/tools/review-state.mjs +291 -343
- package/tools/run-gates.mjs +216 -92
- package/tools/sandbox-masks.mjs +14 -14
- package/tools/semver-lite.mjs +1 -1
- package/tools/set-autonomy.mjs +1 -1
- package/tools/set-recipe.mjs +2 -2
- package/tools/setup-backends.mjs +1 -1
- package/tools/surface.mjs +1 -1
- package/tools/uninstall.mjs +1 -1
- package/tools/velocity-profile.mjs +9 -9
- package/tools/view-model.mjs +1 -1
- package/tools/worktrees.mjs +2292 -0
- package/references/modes/fold-completeness.md +0 -30
- package/references/modes/review-ledger.md +0 -34
- package/references/templates/verification-profile.json +0 -10
- package/tools/fold-completeness-run.mjs +0 -1120
- package/tools/fold-completeness.mjs +0 -672
- package/tools/review-ledger-core.mjs +0 -428
- package/tools/review-ledger-write.mjs +0 -647
- package/tools/review-ledger.mjs +0 -630
- package/tools/sarif.mjs +0 -52
- package/tools/verification-profile.mjs +0 -219
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// core-evidence.mjs — the ONE writer of the git-dir core-evidence store (strip-the-kit, D3(b)/(c) +
|
|
3
|
+
// D6 + D6a + D7). It owns ONE JSONL file (<git dir>/agent-workflow-core-evidence.jsonl) holding the
|
|
4
|
+
// minimal hardened core's evidence records:
|
|
5
|
+
// • red-proof — the D3(c) observed-red DECLARATION: { testId, explicit repo-relative test file,
|
|
6
|
+
// content sha256, observed red N/N, base = HEAD sha, the PRE-FIX tree fingerprint at red
|
|
7
|
+
// observation }. The verb RUNS the named test first and refuses to record anything but an
|
|
8
|
+
// observed red (green / unresolvable / mixed / timed-out are DISTINGUISHED refusals, nothing
|
|
9
|
+
// written — mixed/timeout is QUARANTINE: it never converts and has no override lane).
|
|
10
|
+
// • degrade — the D3(b) explicit per-backend, per-tree escape: { backend, non-empty reason,
|
|
11
|
+
// current tree fingerprint, timestamp }. The ONLY honest escape for an unavailable review
|
|
12
|
+
// backend — review-state consumes these (never all backends).
|
|
13
|
+
// • summary — the D6 observability verb: ONE lightweight end-of-loop render, STATELESS from the
|
|
14
|
+
// review receipts + this store. No ledger, no rounds.
|
|
15
|
+
// Store integrity (D6a): records carry a versioned schema and a per-kind KEY (red-proof:
|
|
16
|
+
// {base, testId}; degrade: {backend, fingerprint}); the LATEST record per key is authoritative
|
|
17
|
+
// (supersession; canonical order = file order), so a re-observation after a test edit is a NEW
|
|
18
|
+
// record, never a permanent hash conflict. A `duplicate` refusal applies ONLY to a byte-identical
|
|
19
|
+
// replayed line. A malformed line fails CLOSED (counted + surfaced, never silently dropped). The
|
|
20
|
+
// D3(a) receipt hashes ride canonicalKindSerialization — the CANONICAL (key-sorted) serialization
|
|
21
|
+
// of the AUTHORITATIVE subset per kind; receipts themselves are excluded from the hashed domain by
|
|
22
|
+
// construction (serialization is per kind).
|
|
23
|
+
// The test-running arm carries the retired fold-runner's safeguards intact (moved, not
|
|
24
|
+
// re-derived): safe repo-relative path resolution, no-follow real-path containment, shell-free
|
|
25
|
+
// argv, per-run timeout, N/N reruns, the quarantine lane (reruns 3, timeout 120s).
|
|
26
|
+
// Import posture: this module is the DAG BOTTOM — it imports only node built-ins + atomic-write +
|
|
27
|
+
// changed-surface, and OWNS the canonical review-domain primitives (tree fingerprint, receipt read
|
|
28
|
+
// path, attesting predicate, verdict vocabulary, testId format, base resolution). review-state
|
|
29
|
+
// RE-EXPORTS its historical public API from here, so its consumers (and the bash-twin parity
|
|
30
|
+
// tests) are unchanged while review-state can import the degrade reader without an import cycle.
|
|
31
|
+
//
|
|
32
|
+
// HONEST residuals: records are forgeable (a self-discipline mechanism in the git dir, not a
|
|
33
|
+
// security boundary); coverage/green verification of red-proof records lives in the final-run
|
|
34
|
+
// checker, not here. Dependency-free. No side effects on import.
|
|
35
|
+
|
|
36
|
+
import { readFileSync, lstatSync, realpathSync, readlinkSync, openSync, readSync, closeSync } from 'node:fs';
|
|
37
|
+
import { join, dirname, isAbsolute, normalize, sep, basename } from 'node:path';
|
|
38
|
+
import { pathToFileURL } from 'node:url';
|
|
39
|
+
import { spawnSync } from 'node:child_process';
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
42
|
+
import { parsePositiveIntKnob, probeVerdict } from './changed-surface.mjs';
|
|
43
|
+
|
|
44
|
+
export const CORE_EVIDENCE_STOP = 'CORE_EVIDENCE_STOP';
|
|
45
|
+
const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'CoreEvidenceStop', code: CORE_EVIDENCE_STOP });
|
|
46
|
+
const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
|
|
47
|
+
|
|
48
|
+
const isoNow = () => new Date().toISOString();
|
|
49
|
+
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / TAP stream can be large; never truncate
|
|
50
|
+
|
|
51
|
+
const gitRaw = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
52
|
+
|
|
53
|
+
const gitBuf = (args, cwd) => {
|
|
54
|
+
const r = gitRaw(args, cwd);
|
|
55
|
+
if (r.error || r.status !== 0) return null;
|
|
56
|
+
return r.stdout;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const gitLine = (args, cwd) => {
|
|
60
|
+
const buf = gitBuf(args, cwd);
|
|
61
|
+
return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── the canonical review-domain primitives (ONE home; review-state re-exports its public API) ─────
|
|
65
|
+
// The uncommitted-state identity + the receipt read path + the attesting predicate live HERE — the
|
|
66
|
+
// DAG bottom every checker (review-state gate, ledger cross-check, this module's summary) imports
|
|
67
|
+
// from, so no two consumers can ever disagree about what a fingerprint or an attestation is.
|
|
68
|
+
|
|
69
|
+
// resolveBase(cwd) → the current HEAD commit sha, or null on an unborn branch / outside a git tree.
|
|
70
|
+
export const resolveBase = (cwd) => gitLine(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
|
|
71
|
+
|
|
72
|
+
// testId FORMAT: "<repo-relative test file>#<test-name-pattern>" — a "#" separator with BOTH halves
|
|
73
|
+
// non-empty. Format-only (no suffix rule); resolvability is the probe's job.
|
|
74
|
+
const TESTID_SEPARATOR = '#';
|
|
75
|
+
export const isWellFormedTestId = (v) => {
|
|
76
|
+
if (typeof v !== 'string') return false;
|
|
77
|
+
const at = v.indexOf(TESTID_SEPARATOR);
|
|
78
|
+
return at > 0 && at < v.length - 1;
|
|
79
|
+
};
|
|
80
|
+
export const splitTestId = (v) => {
|
|
81
|
+
const at = v.indexOf(TESTID_SEPARATOR);
|
|
82
|
+
return { file: v.slice(0, at), pattern: v.slice(at + 1) };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// First 8 KiB contain a NUL byte → binary (git's own heuristic; mirrors the wrappers' is_binary
|
|
86
|
+
// bash twin). An unreadable path reads as text (the fail-safe arm — the payload walk then surfaces
|
|
87
|
+
// its own read failure).
|
|
88
|
+
export const isBinaryFile = (path) => {
|
|
89
|
+
let fd;
|
|
90
|
+
try {
|
|
91
|
+
fd = openSync(path, 'r');
|
|
92
|
+
const buf = Buffer.alloc(8192);
|
|
93
|
+
const n = readSync(fd, buf, 0, 8192, 0);
|
|
94
|
+
return buf.subarray(0, n).includes(0);
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
} finally {
|
|
98
|
+
if (fd !== undefined) closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// The never-committable untracked stat classes: character/block devices, FIFOs, sockets — excluded
|
|
103
|
+
// from the ENTIRE review domain (fingerprint payload, clean check); lstat-keyed because a lying
|
|
104
|
+
// dirent is exactly how sandbox masks surface.
|
|
105
|
+
export const isNeverCommittableStat = (stat) =>
|
|
106
|
+
stat != null &&
|
|
107
|
+
(stat.isCharacterDevice() || stat.isBlockDevice() || stat.isFIFO() || stat.isSocket());
|
|
108
|
+
|
|
109
|
+
// The canonical payload bytes: staged diff + unstaged diff + the untracked-not-ignored section —
|
|
110
|
+
// byte-identical to the wrappers' emit_fingerprint_payload, emitted from the work-tree ROOT. Null
|
|
111
|
+
// outside a git work tree. The lstat is injectable ONLY for the never-committable filter tests.
|
|
112
|
+
export const computeFingerprintPayload = (cwd, { lstat = lstatSync } = {}) => {
|
|
113
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
114
|
+
if (top == null) return null;
|
|
115
|
+
const staged = gitBuf(['diff', '--cached', '--no-ext-diff'], top);
|
|
116
|
+
const unstaged = gitBuf(['diff', '--no-ext-diff'], top);
|
|
117
|
+
const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
|
|
118
|
+
if (staged == null || unstaged == null || untrackedZ == null) return null;
|
|
119
|
+
const chunks = [staged, unstaged];
|
|
120
|
+
for (const rel of untrackedZ.toString('utf8').split('\0').filter(Boolean)) {
|
|
121
|
+
const full = join(top, rel);
|
|
122
|
+
let stat = null;
|
|
123
|
+
try {
|
|
124
|
+
stat = lstat(full);
|
|
125
|
+
} catch {
|
|
126
|
+
stat = null;
|
|
127
|
+
}
|
|
128
|
+
if (isNeverCommittableStat(stat)) continue;
|
|
129
|
+
if (stat?.isSymbolicLink()) {
|
|
130
|
+
let target = '?';
|
|
131
|
+
try {
|
|
132
|
+
target = readlinkSync(full);
|
|
133
|
+
} catch {
|
|
134
|
+
target = '?';
|
|
135
|
+
}
|
|
136
|
+
chunks.push(Buffer.from(`untracked-symlink:${rel} -> ${target}\n`));
|
|
137
|
+
} else if (!stat?.isFile()) {
|
|
138
|
+
chunks.push(Buffer.from(`untracked-nonregular:${rel}\n`));
|
|
139
|
+
} else if (isBinaryFile(full)) {
|
|
140
|
+
chunks.push(Buffer.from(`untracked-binary:${rel}\n`));
|
|
141
|
+
} else {
|
|
142
|
+
chunks.push(Buffer.from(`untracked:${rel}\n`));
|
|
143
|
+
chunks.push(readFileSync(full));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return Buffer.concat(chunks);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// sha256 hex of the canonical payload, or null outside a git work tree.
|
|
150
|
+
export const computeTreeFingerprint = (cwd, fsx) => {
|
|
151
|
+
const payload = computeFingerprintPayload(cwd, fsx);
|
|
152
|
+
return payload == null ? null : createHash('sha256').update(payload).digest('hex');
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Clean = nothing staged, nothing unstaged, no REVIEWABLE untracked-not-ignored paths — the same
|
|
156
|
+
// never-committable filter as the fingerprint, so the two can never disagree about a masks-only
|
|
157
|
+
// tree. Anchored at the work-tree ROOT (ls-files is cwd-scoped). Null when not decidable.
|
|
158
|
+
export const isTreeClean = (cwd, { lstat = lstatSync } = {}) => {
|
|
159
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
160
|
+
if (top == null) return null;
|
|
161
|
+
const staged = gitRaw(['diff', '--cached', '--quiet'], top);
|
|
162
|
+
const unstaged = gitRaw(['diff', '--quiet'], top);
|
|
163
|
+
if (staged.error || unstaged.error || staged.status > 1 || unstaged.status > 1) return null;
|
|
164
|
+
const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
|
|
165
|
+
if (untrackedZ == null) return null;
|
|
166
|
+
const reviewable = untrackedZ
|
|
167
|
+
.toString('utf8')
|
|
168
|
+
.split('\0')
|
|
169
|
+
.filter(Boolean)
|
|
170
|
+
.filter((rel) => {
|
|
171
|
+
try {
|
|
172
|
+
return !isNeverCommittableStat(lstat(join(top, rel)));
|
|
173
|
+
} catch {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
return staged.status === 0 && unstaged.status === 0 && reviewable.length === 0;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// ── the review-receipt read path + attesting predicate (ONE home) ────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export const RECEIPTS_BASENAME = 'agent-workflow-review-receipts.jsonl';
|
|
183
|
+
|
|
184
|
+
export const resolveReceiptsPath = (cwd, env = process.env) => {
|
|
185
|
+
if (env.AW_REVIEW_RECEIPTS) return env.AW_REVIEW_RECEIPTS;
|
|
186
|
+
const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
|
|
187
|
+
return gitDir == null ? null : join(gitDir, RECEIPTS_BASENAME);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Parse the receipt file → { receipts, malformed, readError? }. Absent file → empty (not an
|
|
191
|
+
// error: no review ever ran). A NON-ENOENT read failure surfaces as readError — an unreadable
|
|
192
|
+
// store must never silently read as "no receipts" (the summary withholds its verdicts section on
|
|
193
|
+
// it). A malformed line is counted + reported, never silently dropped.
|
|
194
|
+
export const readReceipts = (path, readFile = readFileSync) => {
|
|
195
|
+
let raw;
|
|
196
|
+
try {
|
|
197
|
+
raw = readFile(path, 'utf8');
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err && err.code === 'ENOENT') return { receipts: [], malformed: 0 };
|
|
200
|
+
return { receipts: [], malformed: 0, readError: (err && err.code) || (err && err.message) || 'read failed' };
|
|
201
|
+
}
|
|
202
|
+
const receipts = [];
|
|
203
|
+
let malformed = 0;
|
|
204
|
+
for (const line of raw.split('\n')) {
|
|
205
|
+
if (line.trim() === '') continue;
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(line);
|
|
208
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.backend === 'string') receipts.push(parsed);
|
|
209
|
+
else malformed += 1;
|
|
210
|
+
} catch {
|
|
211
|
+
malformed += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { receipts, malformed };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// The closed review-verdict vocabulary (the union of both wrappers' closed grammars). Any
|
|
218
|
+
// RECOGNIZED verdict is review EVIDENCE (a recognized negative is an authoritative veto); ONLY
|
|
219
|
+
// ship-class SATISFIES the review-state gate.
|
|
220
|
+
const SHIP_VERDICTS = new Set(['ship', 'ship with nits']);
|
|
221
|
+
const NEGATIVE_VERDICTS = new Set(['revise', 'rethink', 'rework']);
|
|
222
|
+
// Type-strict: only a STRING can enter the closed grammar — String() coercion would admit an
|
|
223
|
+
// array like ["ship"] as ship-class.
|
|
224
|
+
const normalizeVerdict = (verdict) => (typeof verdict === 'string' ? verdict.trim().toLowerCase() : null);
|
|
225
|
+
export const isShipVerdict = (verdict) => {
|
|
226
|
+
const v = normalizeVerdict(verdict);
|
|
227
|
+
return v !== null && SHIP_VERDICTS.has(v);
|
|
228
|
+
};
|
|
229
|
+
export const isRecognizedVerdict = (verdict) => {
|
|
230
|
+
const v = normalizeVerdict(verdict);
|
|
231
|
+
return v !== null && (SHIP_VERDICTS.has(v) || NEGATIVE_VERDICTS.has(v));
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const REVIEW_RECEIPT_CLASS = Object.freeze({
|
|
235
|
+
NOT_CURRENT: 'not-current',
|
|
236
|
+
ATTESTING: 'attesting',
|
|
237
|
+
UNGROUNDED: 'ungrounded',
|
|
238
|
+
UNRECOGNIZED_VERDICT: 'unrecognized-verdict',
|
|
239
|
+
PROBE: 'probe',
|
|
240
|
+
UNMARKED: 'unmarked',
|
|
241
|
+
MALFORMED_MARKER: 'malformed-marker',
|
|
242
|
+
POSTURE_UNMARKED: 'posture-unmarked',
|
|
243
|
+
MALFORMED_POSTURE: 'malformed-posture',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// The D5 posture declaration (strip Phase 4): an object whose `model` is a NON-EMPTY string;
|
|
247
|
+
// `effort` (when present) a non-empty string; `tier` (when present) a non-empty string or null.
|
|
248
|
+
// Backend-agnostic — the wrapper is the writer authority on WHICH keys it declares.
|
|
249
|
+
const isValidReceiptPosture = (posture) => {
|
|
250
|
+
if (posture === null || typeof posture !== 'object' || Array.isArray(posture)) return false;
|
|
251
|
+
if (typeof posture.model !== 'string' || posture.model.length === 0) return false;
|
|
252
|
+
if (Object.hasOwn(posture, 'effort') && (typeof posture.effort !== 'string' || posture.effort.length === 0)) return false;
|
|
253
|
+
if (Object.hasOwn(posture, 'tier') && posture.tier !== null && (typeof posture.tier !== 'string' || posture.tier.length === 0)) return false;
|
|
254
|
+
return true;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Classify ONE receipt against a tree fingerprint. Order is load-bearing: identity first (is this
|
|
258
|
+
// even about the current tree?), then the probe marker (may it attest at all?), then the D5
|
|
259
|
+
// posture marker (same self-declaration doctrine — an absent/invalid posture is non-attesting,
|
|
260
|
+
// fail closed; pre-D5 receipts stop satisfying, the recovery is re-running the review), then the
|
|
261
|
+
// verdict-in-recognized-vocabulary arm BEFORE grounding — an unrecognized verdict is an
|
|
262
|
+
// unconditional refusal that grounding must never reclassify (an ungrounded `unknown` receipt
|
|
263
|
+
// otherwise reads UNGROUNDED and can be masked by another backend's SHIP), then grounding.
|
|
264
|
+
export const classifyReviewReceiptForTree = (receipt, fingerprint) => {
|
|
265
|
+
if (!(receipt !== null && typeof receipt === 'object' && !Array.isArray(receipt)) || receipt.fresh !== true || receipt.artifact !== 'code' || receipt.fingerprint !== fingerprint) {
|
|
266
|
+
return REVIEW_RECEIPT_CLASS.NOT_CURRENT;
|
|
267
|
+
}
|
|
268
|
+
if (!Object.hasOwn(receipt, 'probe')) return REVIEW_RECEIPT_CLASS.UNMARKED;
|
|
269
|
+
if (typeof receipt.probe !== 'boolean') return REVIEW_RECEIPT_CLASS.MALFORMED_MARKER;
|
|
270
|
+
if (receipt.probe === true) return REVIEW_RECEIPT_CLASS.PROBE;
|
|
271
|
+
if (!Object.hasOwn(receipt, 'posture')) return REVIEW_RECEIPT_CLASS.POSTURE_UNMARKED;
|
|
272
|
+
if (!isValidReceiptPosture(receipt.posture)) return REVIEW_RECEIPT_CLASS.MALFORMED_POSTURE;
|
|
273
|
+
if (!isRecognizedVerdict(receipt.verdict)) return REVIEW_RECEIPT_CLASS.UNRECOGNIZED_VERDICT;
|
|
274
|
+
if (receipt.grounded !== true) return REVIEW_RECEIPT_CLASS.UNGROUNDED;
|
|
275
|
+
return REVIEW_RECEIPT_CLASS.ATTESTING;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Summarize one backend's receipts for a tree → { state, receipt, counts… }. The LATEST NORMAL
|
|
279
|
+
// (probe-free, marker-valid, current-fingerprint) receipt is selected FIRST and THEN judged — a
|
|
280
|
+
// later unknown-verdict or ungrounded receipt never lets an earlier SHIP survive, and a probe (or
|
|
281
|
+
// forged marker) written after a real review never becomes the authoritative verdict.
|
|
282
|
+
export const summarizeReviewReceiptsForTree = (receipts, fingerprint) => {
|
|
283
|
+
const classified = receipts
|
|
284
|
+
.map((receipt) => ({ receipt, classification: classifyReviewReceiptForTree(receipt, fingerprint) }))
|
|
285
|
+
.filter(({ classification }) => classification !== REVIEW_RECEIPT_CLASS.NOT_CURRENT);
|
|
286
|
+
const rowsFor = (classification) => classified.filter((row) => row.classification === classification);
|
|
287
|
+
const normal = classified.filter(({ classification }) =>
|
|
288
|
+
classification === REVIEW_RECEIPT_CLASS.ATTESTING ||
|
|
289
|
+
classification === REVIEW_RECEIPT_CLASS.UNGROUNDED ||
|
|
290
|
+
classification === REVIEW_RECEIPT_CLASS.UNRECOGNIZED_VERDICT);
|
|
291
|
+
const probe = rowsFor(REVIEW_RECEIPT_CLASS.PROBE);
|
|
292
|
+
const unmarked = rowsFor(REVIEW_RECEIPT_CLASS.UNMARKED);
|
|
293
|
+
const malformedMarker = rowsFor(REVIEW_RECEIPT_CLASS.MALFORMED_MARKER);
|
|
294
|
+
const postureUnmarked = rowsFor(REVIEW_RECEIPT_CLASS.POSTURE_UNMARKED);
|
|
295
|
+
const malformedPosture = rowsFor(REVIEW_RECEIPT_CLASS.MALFORMED_POSTURE);
|
|
296
|
+
const counts = {
|
|
297
|
+
currentCount: classified.length,
|
|
298
|
+
ungroundedCount: rowsFor(REVIEW_RECEIPT_CLASS.UNGROUNDED).length,
|
|
299
|
+
unrecognizedVerdictCount: rowsFor(REVIEW_RECEIPT_CLASS.UNRECOGNIZED_VERDICT).length,
|
|
300
|
+
probeExcluded: probe.length,
|
|
301
|
+
markerRejected: malformedMarker.length,
|
|
302
|
+
unmarkedRejected: unmarked.length,
|
|
303
|
+
postureRejected: postureUnmarked.length + malformedPosture.length,
|
|
304
|
+
};
|
|
305
|
+
if (normal.length > 0) {
|
|
306
|
+
const latest = normal[normal.length - 1];
|
|
307
|
+
if (latest.classification === REVIEW_RECEIPT_CLASS.ATTESTING) return { state: 'current', receipt: latest.receipt, ...counts };
|
|
308
|
+
if (latest.classification === REVIEW_RECEIPT_CLASS.UNGROUNDED) return { state: 'ungrounded', receipt: latest.receipt, ...counts };
|
|
309
|
+
return { state: 'unrecognized-verdict', receipt: latest.receipt, ...counts };
|
|
310
|
+
}
|
|
311
|
+
if (classified.length > 0) {
|
|
312
|
+
return {
|
|
313
|
+
state: malformedMarker.length > 0 || unmarked.length > 0 || counts.postureRejected > 0 ? 'rejected' : 'probe',
|
|
314
|
+
receipt: null,
|
|
315
|
+
...counts,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return { state: 'none', receipt: null, ...counts };
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Why this backend has no attestation — one stated sentence per distinct recovery.
|
|
322
|
+
export const describeMissingReviewAttestation = (summary) => {
|
|
323
|
+
if (summary.state === 'current') return null;
|
|
324
|
+
const exclusions = [
|
|
325
|
+
summary.probeExcluded > 0 ? `${summary.probeExcluded} probe receipt(s)` : null,
|
|
326
|
+
summary.markerRejected > 0 ? `${summary.markerRejected} receipt(s) with a malformed probe marker` : null,
|
|
327
|
+
summary.unmarkedRejected > 0 ? `${summary.unmarkedRejected} receipt(s) with no probe marker` : null,
|
|
328
|
+
(summary.postureRejected ?? 0) > 0 ? `${summary.postureRejected} receipt(s) with an absent/invalid run posture (a pre-D5 wrapper — re-run the review on the current bridge)` : null,
|
|
329
|
+
].filter(Boolean);
|
|
330
|
+
const exclusionSuffix = exclusions.length > 0 ? `; excluded ${exclusions.join(', ')}` : '';
|
|
331
|
+
if (summary.state === 'ungrounded') return `the latest normal receipt for the current tree is ungrounded${exclusionSuffix}`;
|
|
332
|
+
if (summary.state === 'unrecognized-verdict') return `the latest normal receipt carries an unrecognized verdict (${JSON.stringify(summary.receipt?.verdict ?? null)}) — an unknown verdict never attests${exclusionSuffix}`;
|
|
333
|
+
if (summary.state === 'probe') return 'only probe receipts exist for the current tree — a probe review never attests';
|
|
334
|
+
if (summary.state === 'rejected') return `current-tree receipts have an untrustworthy probe marker or run posture${exclusionSuffix}`;
|
|
335
|
+
return 'no fresh code receipt exists for the current tree';
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// ── the store: ONE git-dir JSONL file (D7) ────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
export const EVIDENCE_BASENAME = 'agent-workflow-core-evidence.jsonl';
|
|
341
|
+
export const EVIDENCE_SCHEMA_VERSION = 1;
|
|
342
|
+
|
|
343
|
+
// AW_CORE_EVIDENCE overrides (the AW_REVIEW_RECEIPTS idiom); else <git dir>/basename; null outside
|
|
344
|
+
// a git work tree.
|
|
345
|
+
export const resolveEvidencePath = (cwd, env = process.env) => {
|
|
346
|
+
if (env.AW_CORE_EVIDENCE) return env.AW_CORE_EVIDENCE;
|
|
347
|
+
const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
|
|
348
|
+
return gitDir == null ? null : join(gitDir, EVIDENCE_BASENAME);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// ── schema validation (closed kinds, per-kind arms; unknown schema/kind fail CLOSED) ──────────────
|
|
352
|
+
|
|
353
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
354
|
+
const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
355
|
+
const HEX64_RE = /^[0-9a-f]{64}$/;
|
|
356
|
+
const HEX40_RE = /^[0-9a-f]{40}$/;
|
|
357
|
+
|
|
358
|
+
// The LEXICAL half of the repo-relative rule — ONE home shared by the record validator (which has
|
|
359
|
+
// no fs to resolve against) and the fs resolver below, so the two can never drift: a forged record
|
|
360
|
+
// carrying an equal-but-absolute (or escaping) testId/file pair is refused at validation, not just
|
|
361
|
+
// at observation time.
|
|
362
|
+
export const lexicalRepoRelative = (rel) => {
|
|
363
|
+
if (typeof rel !== 'string' || rel.length === 0) return { ok: false, reason: 'empty file path' };
|
|
364
|
+
if (isAbsolute(rel)) return { ok: false, reason: `absolute path "${rel}" — the testId file half must be repo-relative` };
|
|
365
|
+
const norm = normalize(rel);
|
|
366
|
+
if (norm === '..' || norm.startsWith(`..${sep}`)) return { ok: false, reason: `path "${rel}" escapes the repo root` };
|
|
367
|
+
return { ok: true };
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
export const validateEvidenceRecord = (record) => {
|
|
371
|
+
if (!isPlainObject(record)) return { ok: false, reason: 'record is not an object' };
|
|
372
|
+
if (record.schema !== EVIDENCE_SCHEMA_VERSION) {
|
|
373
|
+
return { ok: false, reason: `unknown schema ${JSON.stringify(record.schema)} — this reader accepts schema ${EVIDENCE_SCHEMA_VERSION} only (fail closed)` };
|
|
374
|
+
}
|
|
375
|
+
if (record.kind === 'red-proof') {
|
|
376
|
+
if (!isWellFormedTestId(record.testId)) return { ok: false, reason: 'red-proof: testId must be "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty)' };
|
|
377
|
+
if (!isNonEmptyString(record.file)) return { ok: false, reason: 'red-proof: file must be the non-empty repo-relative test-file path' };
|
|
378
|
+
const lex = lexicalRepoRelative(record.file);
|
|
379
|
+
if (!lex.ok) return { ok: false, reason: `red-proof: file must be lexically repo-relative — ${lex.reason}` };
|
|
380
|
+
if (record.file !== splitTestId(record.testId).file) return { ok: false, reason: 'red-proof: file must equal the testId file half — one path source of truth (a mismatched declaration is refused)' };
|
|
381
|
+
if (typeof record.fileHash !== 'string' || !HEX64_RE.test(record.fileHash)) return { ok: false, reason: 'red-proof: fileHash must be a 64-hex sha256 of the test-file bytes' };
|
|
382
|
+
if (!Number.isInteger(record.runs) || record.runs < 1) return { ok: false, reason: 'red-proof: runs must be a positive integer' };
|
|
383
|
+
if (record.reds !== record.runs) return { ok: false, reason: 'red-proof: reds must equal runs — an observed-red declaration is N/N by construction' };
|
|
384
|
+
if (record.base !== null && (typeof record.base !== 'string' || !(HEX40_RE.test(record.base) || HEX64_RE.test(record.base)))) return { ok: false, reason: 'red-proof: base must be the 40- or 64-hex HEAD sha (git sha1/sha256 object formats), or null on an unborn branch' };
|
|
385
|
+
if (typeof record.fingerprint !== 'string' || !HEX64_RE.test(record.fingerprint)) return { ok: false, reason: 'red-proof: fingerprint must be the 64-hex PRE-FIX tree fingerprint' };
|
|
386
|
+
if (!isNonEmptyString(record.timestamp)) return { ok: false, reason: 'red-proof: timestamp must be a non-empty string' };
|
|
387
|
+
return { ok: true };
|
|
388
|
+
}
|
|
389
|
+
if (record.kind === 'degrade') {
|
|
390
|
+
if (typeof record.backend !== 'string' || record.backend.trim() === '') return { ok: false, reason: 'degrade: backend must be a non-empty string (whitespace-only is refused)' };
|
|
391
|
+
if (typeof record.reason !== 'string' || record.reason.trim() === '') return { ok: false, reason: 'degrade: reason must be a non-empty string — a degrade without a stated reason is refused (fail closed)' };
|
|
392
|
+
if (typeof record.fingerprint !== 'string' || !HEX64_RE.test(record.fingerprint)) return { ok: false, reason: 'degrade: fingerprint must be the 64-hex tree fingerprint the degrade attests' };
|
|
393
|
+
if (!isNonEmptyString(record.timestamp)) return { ok: false, reason: 'degrade: timestamp must be a non-empty string' };
|
|
394
|
+
return { ok: true };
|
|
395
|
+
}
|
|
396
|
+
if (record.kind === 'final-start') {
|
|
397
|
+
if (typeof record.fingerprint !== 'string' || !HEX64_RE.test(record.fingerprint)) return { ok: false, reason: 'final-start: fingerprint must be the 64-hex tree fingerprint the attempt targets' };
|
|
398
|
+
if (!isNonEmptyString(record.attempt)) return { ok: false, reason: 'final-start: attempt must be a non-empty id — the completion closes exactly THIS start (linkage)' };
|
|
399
|
+
if (!isNonEmptyString(record.timestamp)) return { ok: false, reason: 'final-start: timestamp must be a non-empty string' };
|
|
400
|
+
return { ok: true };
|
|
401
|
+
}
|
|
402
|
+
if (record.kind === 'final') {
|
|
403
|
+
if (record.status !== 'green' && record.status !== 'red') return { ok: false, reason: `final: status must be "green" or "red" (got ${JSON.stringify(record.status)})` };
|
|
404
|
+
if (!isNonEmptyString(record.attempt)) return { ok: false, reason: 'final: attempt must be the non-empty id of the start this completion closes' };
|
|
405
|
+
for (const field of ['fingerprintBefore', 'fingerprintAfter']) {
|
|
406
|
+
if (typeof record[field] !== 'string' || !HEX64_RE.test(record[field])) return { ok: false, reason: `final: ${field} must be a 64-hex tree fingerprint` };
|
|
407
|
+
}
|
|
408
|
+
if (!Array.isArray(record.declared) || record.declared.length === 0 || !record.declared.every((d) => isPlainObject(d) && isNonEmptyString(d.id) && isNonEmptyString(d.cmd))) {
|
|
409
|
+
return { ok: false, reason: 'final: declared must be the non-empty ordered {id, cmd} array the run executed — an empty declaration never attests' };
|
|
410
|
+
}
|
|
411
|
+
if (!Array.isArray(record.results) || !record.results.every((r) => isPlainObject(r) && isNonEmptyString(r.id) && typeof r.ok === 'boolean' && (r.code === null || Number.isInteger(r.code)))) {
|
|
412
|
+
return { ok: false, reason: 'final: results must be the per-gate {id, ok, code} array (code integer, or null on a spawn failure)' };
|
|
413
|
+
}
|
|
414
|
+
if (record.results.length !== record.declared.length || !record.results.every((r, i) => r.id === record.declared[i].id)) {
|
|
415
|
+
return { ok: false, reason: 'final: results must mirror the declared gates 1:1 IN ORDER — a partial or shuffled attribution never attests' };
|
|
416
|
+
}
|
|
417
|
+
if (record.integrityFailure !== null && !isNonEmptyString(record.integrityFailure)) {
|
|
418
|
+
return { ok: false, reason: 'final: integrityFailure must be null or a non-empty reason (an artifact moved under the run)' };
|
|
419
|
+
}
|
|
420
|
+
const derived = record.results.every((r) => r.ok) && record.integrityFailure === null ? 'green' : 'red';
|
|
421
|
+
if (record.status !== derived) {
|
|
422
|
+
return { ok: false, reason: `final: status ${JSON.stringify(record.status)} contradicts the results/integrity content (derived ${JSON.stringify(derived)}) — status is DERIVED, never asserted` };
|
|
423
|
+
}
|
|
424
|
+
if (!isPlainObject(record.evidenceHashes)
|
|
425
|
+
|| typeof record.evidenceHashes.redProof !== 'string' || !HEX64_RE.test(record.evidenceHashes.redProof)
|
|
426
|
+
|| typeof record.evidenceHashes.degrade !== 'string' || !HEX64_RE.test(record.evidenceHashes.degrade)) {
|
|
427
|
+
return { ok: false, reason: 'final: evidenceHashes must carry 64-hex sha256 of the canonical red-proof and degrade serializations' };
|
|
428
|
+
}
|
|
429
|
+
if (record.lcovSha256 !== null && (typeof record.lcovSha256 !== 'string' || !HEX64_RE.test(record.lcovSha256))) {
|
|
430
|
+
return { ok: false, reason: 'final: lcovSha256 must be a 64-hex sha256 of the consumed lcov file, or null when none was produced' };
|
|
431
|
+
}
|
|
432
|
+
if (!isNonEmptyString(record.timestamp)) return { ok: false, reason: 'final: timestamp must be a non-empty string' };
|
|
433
|
+
return { ok: true };
|
|
434
|
+
}
|
|
435
|
+
return { ok: false, reason: `unknown kind ${JSON.stringify(record.kind)} — closed set: red-proof | degrade | final-start | final (fail closed)` };
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// ── the fail-closed reader ────────────────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
// parseEvidenceText(raw) → { records, malformed, malformedReasons } — the ONE per-line parse +
|
|
441
|
+
// validation pass. readEvidence AND the append preflight consume it over the SAME captured bytes
|
|
442
|
+
// (a second read between check and write would be a TOCTOU gap).
|
|
443
|
+
export const parseEvidenceText = (raw) => {
|
|
444
|
+
const records = [];
|
|
445
|
+
const malformedReasons = [];
|
|
446
|
+
const lines = String(raw).split('\n');
|
|
447
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
448
|
+
if (lines[i].trim() === '') continue;
|
|
449
|
+
let parsed;
|
|
450
|
+
try {
|
|
451
|
+
parsed = JSON.parse(lines[i]);
|
|
452
|
+
} catch {
|
|
453
|
+
malformedReasons.push(`line ${i + 1}: invalid JSON`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const v = validateEvidenceRecord(parsed);
|
|
457
|
+
if (v.ok) records.push(parsed);
|
|
458
|
+
else malformedReasons.push(`line ${i + 1}: ${v.reason}`);
|
|
459
|
+
}
|
|
460
|
+
return { records, malformed: malformedReasons.length, malformedReasons };
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// readEvidence(path) → { records, malformed, malformedReasons, readError? }. Absent file → empty
|
|
464
|
+
// (no evidence yet is not an error). Every non-parsing / non-validating line is COUNTED with its
|
|
465
|
+
// reason — consumers fail closed on malformed > 0, never silently drop.
|
|
466
|
+
export const readEvidence = (path, readFile = readFileSync) => {
|
|
467
|
+
let raw;
|
|
468
|
+
try {
|
|
469
|
+
raw = readFile(path, 'utf8');
|
|
470
|
+
} catch (err) {
|
|
471
|
+
if (err && err.code === 'ENOENT') return { records: [], malformed: 0, malformedReasons: [] };
|
|
472
|
+
return { records: [], malformed: 0, malformedReasons: [], readError: (err && err.code) || (err && err.message) || 'read failed' };
|
|
473
|
+
}
|
|
474
|
+
return parseEvidenceText(raw);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// ── the ONE append (validated, duplicate-refusing, atomic) ────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
export const appendEvidenceRecord = ({ path, record }) => {
|
|
480
|
+
const v = validateEvidenceRecord(record);
|
|
481
|
+
if (!v.ok) throw stop(`refusing to write a malformed evidence record: ${v.reason}`);
|
|
482
|
+
const line = JSON.stringify(record);
|
|
483
|
+
let existing = '';
|
|
484
|
+
try {
|
|
485
|
+
existing = readFileSync(path, 'utf8');
|
|
486
|
+
} catch (err) {
|
|
487
|
+
if (err && err.code === 'ENOENT') existing = '';
|
|
488
|
+
else throw stop(`cannot read the evidence store before appending (${(err && err.code) || (err && err.message) || err}) — refusing to overwrite it (fail closed)`);
|
|
489
|
+
}
|
|
490
|
+
// Write-side D6a fail-closed: never extend a store whose existing lines are unreadable — the
|
|
491
|
+
// SAME captured bytes are validated here and appended below (one snapshot, no second read).
|
|
492
|
+
const existingIssues = parseEvidenceText(existing).malformedReasons;
|
|
493
|
+
if (existingIssues.length > 0) {
|
|
494
|
+
throw stop(`refusing to append to an evidence store carrying ${existingIssues.length} malformed line(s) (${existingIssues[0]}) — inspect ${path}; nothing was written (fail closed)`);
|
|
495
|
+
}
|
|
496
|
+
if (existing.split('\n').some((l) => l === line)) {
|
|
497
|
+
throw stop(`refusing a byte-identical replayed line (duplicate) — a genuine re-observation carries new content or timestamp; nothing was written`);
|
|
498
|
+
}
|
|
499
|
+
const prefix = existing === '' ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
|
500
|
+
writeContainedFileAtomic(dirname(path), path, `${prefix}${line}\n`, {}, { stop, label: path });
|
|
501
|
+
return { writtenPath: path, record };
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// ── D6a: per-kind keys, authoritative selection, canonical serialization ──────────────────────────
|
|
505
|
+
|
|
506
|
+
// Space-joined with the CLOSED (validated hex-or-null) field in the MIDDLE, the free-form field
|
|
507
|
+
// last — content can then never forge a separator collision (base/fingerprint carry no spaces by
|
|
508
|
+
// validation; the last field absorbs anything).
|
|
509
|
+
export const evidenceKey = (r) =>
|
|
510
|
+
r.kind === 'red-proof' ? `red-proof ${r.base} ${r.testId}`
|
|
511
|
+
: r.kind === 'degrade' ? `degrade ${r.fingerprint} ${r.backend}`
|
|
512
|
+
: r.kind === 'final' ? `final ${r.fingerprintBefore}`
|
|
513
|
+
: r.kind === 'final-start' ? `final-start ${r.fingerprint}`
|
|
514
|
+
: null;
|
|
515
|
+
|
|
516
|
+
// The authoritative subset: the LATEST record per key, in file order of that latest appearance.
|
|
517
|
+
export const authoritativeEvidence = (records) => {
|
|
518
|
+
const lastByKey = new Map();
|
|
519
|
+
records.forEach((r, i) => {
|
|
520
|
+
const k = evidenceKey(r);
|
|
521
|
+
if (k != null) lastByKey.set(k, i);
|
|
522
|
+
});
|
|
523
|
+
const keep = new Set(lastByKey.values());
|
|
524
|
+
return records.filter((_, i) => keep.has(i));
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
export const authoritativeOfKind = (records, kind) => authoritativeEvidence(records).filter((r) => r.kind === kind);
|
|
528
|
+
|
|
529
|
+
// Canonical bytes: key-sorted JSON per record, one line each, newline-terminated ('' when empty) —
|
|
530
|
+
// independent of the byte layout the record was originally written with. The D3(a) receipt hashes
|
|
531
|
+
// EXACTLY these bytes per kind, so receipts (a different kind/file) are outside the domain by
|
|
532
|
+
// construction.
|
|
533
|
+
const stableStringify = (v) => {
|
|
534
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
|
|
535
|
+
if (v !== null && typeof v === 'object') {
|
|
536
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(',')}}`;
|
|
537
|
+
}
|
|
538
|
+
return JSON.stringify(v);
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
export const canonicalKindSerialization = (records, kind) => {
|
|
542
|
+
const lines = authoritativeOfKind(records, kind).map(stableStringify);
|
|
543
|
+
return lines.length === 0 ? '' : `${lines.join('\n')}\n`;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// ── the probe safeguards (moved verbatim from the retired fold runner) ────────────────────────────
|
|
547
|
+
|
|
548
|
+
// Node sets NODE_TEST_CONTEXT for any process running UNDER `node --test`; a fresh `node --test`
|
|
549
|
+
// that inherits it silently SKIPS running its files. The probe spawns `node --test`, so it strips
|
|
550
|
+
// that var (a no-op in normal invocation).
|
|
551
|
+
export const childTestEnv = (env, extra = {}) => {
|
|
552
|
+
const out = { ...env, ...extra };
|
|
553
|
+
delete out.NODE_TEST_CONTEXT;
|
|
554
|
+
return out;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const PROBE_RESULT_RE = /^(ok|not ok) \d+ - (.*)$/;
|
|
558
|
+
const PROBE_FAIL_RE = /^# fail (\d+)$/;
|
|
559
|
+
const PROBE_DIRECTIVE_RE = /#\s*(?:skip|todo)\b/i; // a TAP SKIP/TODO directive — the test did NOT run
|
|
560
|
+
|
|
561
|
+
// parseProbeOutput({ stdout, code, fileArg }) → { resolvable, executed, baselineGreen }. The file
|
|
562
|
+
// wrapper is matched by BASENAME (node normalizes the echoed path); a directive-carrying result
|
|
563
|
+
// line never counts (some Node lines emit pattern-filtered tests as SKIP — the guard is
|
|
564
|
+
// version-agnostic on purpose).
|
|
565
|
+
export const parseProbeOutput = ({ stdout, code, fileArg }) => {
|
|
566
|
+
let matched = 0;
|
|
567
|
+
let notOk = 0;
|
|
568
|
+
let failCount = null;
|
|
569
|
+
const wanted = basename(String(fileArg).trim());
|
|
570
|
+
for (const line of String(stdout).split('\n')) {
|
|
571
|
+
const m = PROBE_RESULT_RE.exec(line);
|
|
572
|
+
if (m && !PROBE_DIRECTIVE_RE.test(m[2]) && basename(m[2].trim()) !== wanted) {
|
|
573
|
+
matched += 1;
|
|
574
|
+
if (m[1] === 'not ok') notOk += 1;
|
|
575
|
+
}
|
|
576
|
+
const f = PROBE_FAIL_RE.exec(line.trim());
|
|
577
|
+
if (f) failCount = Number(f[1]);
|
|
578
|
+
}
|
|
579
|
+
const resolvable = matched > 0;
|
|
580
|
+
const fails = (failCount ?? 0) + notOk;
|
|
581
|
+
return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// The shell-free node:test argv. The pattern rides `=`-joined: as a separate token a pattern
|
|
585
|
+
// beginning with "-" would parse as an OPTION and silently select no test.
|
|
586
|
+
export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', `--test-name-pattern=${pattern}`, file];
|
|
587
|
+
|
|
588
|
+
// containsPath(realRoot, realAbs) → strictly INSIDE. Segment-safe ('/a' never contains '/ab') and
|
|
589
|
+
// correct for a repo at the filesystem root.
|
|
590
|
+
export const containsPath = (realRoot, realAbs) => realAbs.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep);
|
|
591
|
+
|
|
592
|
+
// resolveTestFile(rootTop, rel, deps?) → { ok: true, abs } | { ok: false, reason } (never throws).
|
|
593
|
+
// Repo-relative only; a REGULAR file under no-follow lstat; the RESOLVED real path contained under
|
|
594
|
+
// the REAL repo root (a leaf check alone would let a symlinked PARENT directory escape).
|
|
595
|
+
export const resolveTestFile = (rootTop, rel, deps = {}) => {
|
|
596
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
597
|
+
const realpath = deps.realpath ?? realpathSync;
|
|
598
|
+
const lex = lexicalRepoRelative(rel);
|
|
599
|
+
if (!lex.ok) return { ok: false, reason: lex.reason };
|
|
600
|
+
const abs = join(rootTop, normalize(rel));
|
|
601
|
+
let st;
|
|
602
|
+
try {
|
|
603
|
+
st = lstat(abs);
|
|
604
|
+
} catch {
|
|
605
|
+
return { ok: false, reason: `file "${rel}" does not exist` };
|
|
606
|
+
}
|
|
607
|
+
if (!st.isFile()) return { ok: false, reason: `"${rel}" is not a regular file (a symlink/directory/device is never followed — fail closed)` };
|
|
608
|
+
let realAbs;
|
|
609
|
+
let realRoot;
|
|
610
|
+
try {
|
|
611
|
+
realAbs = realpath(abs);
|
|
612
|
+
realRoot = realpath(rootTop);
|
|
613
|
+
} catch {
|
|
614
|
+
return { ok: false, reason: `cannot resolve the real path of "${rel}"` };
|
|
615
|
+
}
|
|
616
|
+
if (!containsPath(realRoot, realAbs)) return { ok: false, reason: `"${rel}" resolves outside the repo root (a symlinked parent directory) — fail closed` };
|
|
617
|
+
return { ok: true, abs: realAbs };
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// sha-256 over the file's BYTES; null on a read failure (the caller reads that as unresolvable).
|
|
621
|
+
export const hashFileBytes = (abs) => {
|
|
622
|
+
try {
|
|
623
|
+
return createHash('sha256').update(readFileSync(abs)).digest('hex');
|
|
624
|
+
} catch {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// N and the per-run timeout — pinned from the old runner's own constants (AW_FOLD_RERUNS default 3,
|
|
630
|
+
// AW_FOLD_PROBE_TIMEOUT_S default 120); zero/negative/fractional/non-numeric refused by name.
|
|
631
|
+
export const probeKnobsFromEnv = (env = process.env) => ({
|
|
632
|
+
reruns: parsePositiveIntKnob(env, 'AW_CORE_EVIDENCE_RERUNS', 3, stop),
|
|
633
|
+
timeoutS: parsePositiveIntKnob(env, 'AW_CORE_EVIDENCE_TIMEOUT_S', 120, stop),
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// The N-rerun probe. The custody hash is taken BEFORE the runs (the content the observation
|
|
637
|
+
// attests); the spawn ALWAYS uses the resolver's canonical absolute path — the executed file must
|
|
638
|
+
// be the hashed file independent of runner path semantics. A timed-out or signal-killed run is
|
|
639
|
+
// neither red nor green — it lands in `timeouts` (quarantine fuel).
|
|
640
|
+
const probeBound = ({ testId, rootTop, env, reruns, timeoutS }) => {
|
|
641
|
+
const { file, pattern } = splitTestId(testId);
|
|
642
|
+
const resolved = resolveTestFile(rootTop, file);
|
|
643
|
+
const fileHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
|
|
644
|
+
let executed = 0;
|
|
645
|
+
let greens = 0;
|
|
646
|
+
let reds = 0;
|
|
647
|
+
let timeouts = 0;
|
|
648
|
+
if (resolved.ok && fileHash != null) {
|
|
649
|
+
for (let i = 0; i < reruns; i += 1) {
|
|
650
|
+
const argv = defaultBoundArgv(resolved.abs, pattern);
|
|
651
|
+
const res = spawnSync(argv[0], argv.slice(1), {
|
|
652
|
+
cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, timeout: timeoutS * 1000,
|
|
653
|
+
});
|
|
654
|
+
if ((res.error && res.error.code === 'ETIMEDOUT') || res.signal != null) {
|
|
655
|
+
timeouts += 1;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const p = parseProbeOutput({ stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file });
|
|
659
|
+
executed = Math.max(executed, p.executed);
|
|
660
|
+
if (!p.resolvable) continue;
|
|
661
|
+
if (p.baselineGreen) greens += 1;
|
|
662
|
+
else reds += 1;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const entry = { executed, runs: reruns, greens, reds, timeouts, fileHash };
|
|
666
|
+
return { entry, file, resolveReason: resolved.ok ? (fileHash == null ? `cannot read "${file}"` : null) : resolved.reason };
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// ── the red-proof verb (D3(c)): observed red or nothing ───────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
// runRedProof({ cwd, env, testId }) → { writtenPath, record }. Observes `testId` on the CURRENT
|
|
672
|
+
// (pre-fix) tree: N/N red → appends the declaration; anything else is a DISTINGUISHED refusal and
|
|
673
|
+
// NOTHING is written.
|
|
674
|
+
export const runRedProof = ({ cwd = process.cwd(), env = process.env, testId } = {}) => {
|
|
675
|
+
if (!isWellFormedTestId(testId)) {
|
|
676
|
+
throw usageFail(`red-proof needs a well-formed testId "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty; got ${JSON.stringify(testId)})`);
|
|
677
|
+
}
|
|
678
|
+
const rootTop = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
679
|
+
if (rootTop == null) throw stop('not a git work tree — nothing to observe');
|
|
680
|
+
const { reruns, timeoutS } = probeKnobsFromEnv(env);
|
|
681
|
+
// Pre-run capture: the record's base/fingerprint and the custody hash must attest ONE tree —
|
|
682
|
+
// captured BEFORE the runs and re-checked after them; any drift refuses below.
|
|
683
|
+
const base = resolveBase(cwd);
|
|
684
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
685
|
+
const { entry, file, resolveReason } = probeBound({ testId, rootTop, env, reruns, timeoutS });
|
|
686
|
+
const verdict = probeVerdict(entry);
|
|
687
|
+
const counts = `${entry.greens} green / ${entry.reds} red / ${entry.timeouts} timed out / ${entry.runs - entry.greens - entry.reds - entry.timeouts} unresolved of ${entry.runs} run(s)`;
|
|
688
|
+
if (verdict === 'unresolvable') {
|
|
689
|
+
throw stop(
|
|
690
|
+
`red-proof refused for "${testId}": unresolvable — ${resolveReason ?? 'the pattern selects no test'} (${counts}). ` +
|
|
691
|
+
`If the test cannot even LOAD pre-fix (it imports an export the fix introduces), author it with a dynamic import() so it loads and FAILS pre-fix. Nothing was recorded.`,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (verdict === 'green') {
|
|
695
|
+
throw stop(
|
|
696
|
+
`red-proof refused for "${testId}": observed GREEN on ${entry.greens}/${entry.runs} runs — the test does not fail on the current (pre-fix) tree, so it proves nothing about the fix. ` +
|
|
697
|
+
`Write a test that FAILS before the fix is applied, then record the red-proof BEFORE folding the fix. Nothing was recorded.`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
if (verdict === 'quarantine') {
|
|
701
|
+
const flavor = entry.timeouts > 0
|
|
702
|
+
? `${entry.timeouts} of ${entry.runs} probe run(s) timed out (AW_CORE_EVIDENCE_TIMEOUT_S=${timeoutS}) — a timed-out run is neither red nor green`
|
|
703
|
+
: `mixed outcomes (${counts}) — a flaky test can launder a fake red`;
|
|
704
|
+
throw stop(
|
|
705
|
+
`red-proof refused for "${testId}": QUARANTINE — ${flavor}. QUARANTINE never converts and has no override lane: ` +
|
|
706
|
+
`${entry.timeouts > 0 ? 'raise the timeout or make the test faster' : 'replace the flaky test'}, then re-observe. Nothing was recorded.`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
if (fingerprint == null) throw stop('cannot compute the tree fingerprint — not a git work tree');
|
|
710
|
+
// Post-run drift recheck: fingerprint, base, and the test-file bytes must all equal the
|
|
711
|
+
// pre-run capture — a mutating test / parallel edit / commit would otherwise mint a record
|
|
712
|
+
// whose fields attest different trees.
|
|
713
|
+
const resolvedAfter = resolveTestFile(rootTop, file);
|
|
714
|
+
const hashAfter = resolvedAfter.ok ? hashFileBytes(resolvedAfter.abs) : null;
|
|
715
|
+
if (computeTreeFingerprint(cwd) !== fingerprint || resolveBase(cwd) !== base || hashAfter !== entry.fileHash) {
|
|
716
|
+
throw stop(
|
|
717
|
+
`red-proof refused for "${testId}": the tree moved during the observation (fingerprint, base, or test-file bytes drifted between the pre-run capture and the post-run recheck) — a record would bind fields from different trees. Re-observe on a quiescent tree. Nothing was recorded.`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
const record = {
|
|
721
|
+
schema: EVIDENCE_SCHEMA_VERSION,
|
|
722
|
+
kind: 'red-proof',
|
|
723
|
+
testId,
|
|
724
|
+
file,
|
|
725
|
+
fileHash: entry.fileHash,
|
|
726
|
+
runs: entry.runs,
|
|
727
|
+
reds: entry.reds,
|
|
728
|
+
base,
|
|
729
|
+
fingerprint,
|
|
730
|
+
timestamp: isoNow(),
|
|
731
|
+
};
|
|
732
|
+
const path = resolveEvidencePath(cwd, env);
|
|
733
|
+
if (path == null) throw stop('cannot resolve the evidence-store path — not a git work tree and AW_CORE_EVIDENCE is unset');
|
|
734
|
+
return appendEvidenceRecord({ path, record });
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
// ── the degrade verb (D3(b)): the explicit per-backend, per-tree escape ───────────────────────────
|
|
738
|
+
|
|
739
|
+
export const runDegrade = ({ cwd = process.cwd(), env = process.env, backend, reason } = {}) => {
|
|
740
|
+
if (typeof backend !== 'string' || backend.trim() === '') throw usageFail('degrade needs --backend <name> — a missing or whitespace-only backend is refused (the gate matches records by backend name)');
|
|
741
|
+
if (typeof reason !== 'string' || reason.trim() === '') throw usageFail('degrade needs a non-empty --reason "<why this backend cannot review this tree>" — a reasonless degrade is refused');
|
|
742
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
743
|
+
if (fingerprint == null) throw stop('not a git work tree — a degrade attests a specific tree fingerprint');
|
|
744
|
+
const record = {
|
|
745
|
+
schema: EVIDENCE_SCHEMA_VERSION,
|
|
746
|
+
kind: 'degrade',
|
|
747
|
+
backend: backend.trim(),
|
|
748
|
+
reason: reason.trim(),
|
|
749
|
+
fingerprint,
|
|
750
|
+
timestamp: isoNow(),
|
|
751
|
+
};
|
|
752
|
+
const path = resolveEvidencePath(cwd, env);
|
|
753
|
+
if (path == null) throw stop('cannot resolve the evidence-store path — not a git work tree and AW_CORE_EVIDENCE is unset');
|
|
754
|
+
return appendEvidenceRecord({ path, record });
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// ── the summary verb (D6): ONE stateless render — receipts + evidence store, no ledger ────────────
|
|
758
|
+
|
|
759
|
+
export const buildSummaryState = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
760
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
761
|
+
const base = resolveBase(cwd);
|
|
762
|
+
const storePath = resolveEvidencePath(cwd, env);
|
|
763
|
+
const store = storePath ? readEvidence(storePath) : { records: [], malformed: 0, malformedReasons: [] };
|
|
764
|
+
const receiptsPath = resolveReceiptsPath(cwd, env);
|
|
765
|
+
const receiptsRead = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
|
|
766
|
+
const { receipts, malformed: receiptsMalformed } = receiptsRead;
|
|
767
|
+
const receiptsReadError = receiptsRead.readError ?? null;
|
|
768
|
+
const backends = [...new Set(receipts.map((r) => r.backend))].sort();
|
|
769
|
+
const verdicts = backends.map((b) => ({
|
|
770
|
+
backend: b,
|
|
771
|
+
summary: summarizeReviewReceiptsForTree(receipts.filter((r) => r.backend === b), fingerprint),
|
|
772
|
+
}));
|
|
773
|
+
const redProofs = authoritativeOfKind(store.records, 'red-proof').filter((r) => r.base === base);
|
|
774
|
+
const degrades = authoritativeOfKind(store.records, 'degrade').filter((r) => r.fingerprint === fingerprint);
|
|
775
|
+
const finalRun = authoritativeOfKind(store.records, 'final').find((r) => r.fingerprintBefore === fingerprint) ?? null;
|
|
776
|
+
// A malformed/unreadable store makes the AUTHORITATIVE selection untrustworthy (a dropped later
|
|
777
|
+
// line could resurrect a superseded record — or hide a newer verdict) — the summary WITHHOLDS
|
|
778
|
+
// the affected sections and exits non-zero instead of rendering a lie. Both stores get the same
|
|
779
|
+
// posture: the evidence store and the receipts store.
|
|
780
|
+
const evidenceUnavailable = store.malformed > 0 || store.readError != null;
|
|
781
|
+
const receiptsUnavailable = receiptsMalformed > 0 || receiptsReadError != null;
|
|
782
|
+
return {
|
|
783
|
+
fingerprint, base, storePath,
|
|
784
|
+
storeRecords: store.records.length, storeMalformed: store.malformed, storeReadError: store.readError ?? null,
|
|
785
|
+
evidenceUnavailable, receiptsUnavailable, receiptsReadError,
|
|
786
|
+
receiptsPath, receiptsMalformed, verdicts, redProofs, degrades, finalRun,
|
|
787
|
+
};
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const verdictLine = ({ backend, summary }) => {
|
|
791
|
+
if (summary.state === 'current') return `${backend}: ${summary.receipt.verdict ?? 'unknown'} (attesting, ${summary.receipt.timestamp ?? '?'})`;
|
|
792
|
+
if (summary.state === 'ungrounded') return `${backend}: ${summary.receipt.verdict ?? 'unknown'} (ungrounded — never attests)`;
|
|
793
|
+
if (summary.state === 'unrecognized-verdict') return `${backend}: unrecognized verdict (${JSON.stringify(summary.receipt?.verdict ?? null)}) — never attests (fail-closed)`;
|
|
794
|
+
if (summary.state === 'probe') return `${backend}: probe receipts only for the current tree (never attest)`;
|
|
795
|
+
if (summary.state === 'rejected') return `${backend}: current-tree receipts rejected (untrustworthy probe marker)`;
|
|
796
|
+
return `${backend}: no attesting receipt for the current tree (stale or missing)`;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
export const renderSummary = (s) => {
|
|
800
|
+
const short = (hex) => (typeof hex === 'string' ? `${hex.slice(0, 12)}…` : String(hex));
|
|
801
|
+
const evidenceSections = s.evidenceUnavailable
|
|
802
|
+
? [` evidence sections WITHHELD — the store is unavailable (${s.storeMalformed} malformed line(s)${s.storeReadError ? `, read error: ${s.storeReadError}` : ''}); a dropped line could resurrect a superseded record — inspect ${s.storePath}`]
|
|
803
|
+
: [
|
|
804
|
+
` red-proof records (current base): ${s.redProofs.length ? '' : '(none)'}`,
|
|
805
|
+
...s.redProofs.map((r) => ` ${r.testId} — ${r.reds}/${r.runs} red, hash ${short(r.fileHash)}, pre-fix fingerprint ${short(r.fingerprint)}`),
|
|
806
|
+
` degrade records (current tree): ${s.degrades.length ? '' : '(none)'}`,
|
|
807
|
+
...s.degrades.map((d) => ` ${d.backend} — ${d.reason} (${d.timestamp})`),
|
|
808
|
+
];
|
|
809
|
+
const verdictsSection = s.receiptsUnavailable
|
|
810
|
+
? [` review verdicts WITHHELD — the receipts store is unavailable (${s.receiptsMalformed} malformed line(s)${s.receiptsReadError ? `, read error: ${s.receiptsReadError}` : ''}); a dropped line could hide a newer verdict — inspect ${s.receiptsPath}`]
|
|
811
|
+
: [
|
|
812
|
+
' review verdicts (current tree):',
|
|
813
|
+
...(s.verdicts.length ? s.verdicts.map((v) => ` ${verdictLine(v)}`) : [' (no receipts)']),
|
|
814
|
+
];
|
|
815
|
+
const finalLine = s.evidenceUnavailable
|
|
816
|
+
? null
|
|
817
|
+
: s.finalRun
|
|
818
|
+
? ` final gate run: ${s.finalRun.status === 'green' ? 'GREEN' : 'RED'} (${s.finalRun.results.filter((r) => r.ok).length}/${s.finalRun.results.length} gates, ${s.finalRun.timestamp})`
|
|
819
|
+
: ' final gate run: (none recorded for the current tree)';
|
|
820
|
+
const lines = [
|
|
821
|
+
'core-evidence summary — stateless render (review receipts + evidence store; no ledger, no rounds)',
|
|
822
|
+
` tree fingerprint: ${s.fingerprint ?? '(not a git work tree)'}`,
|
|
823
|
+
` base: ${s.base ?? '(unborn branch)'}`,
|
|
824
|
+
...(finalLine ? [finalLine] : []),
|
|
825
|
+
...verdictsSection,
|
|
826
|
+
...evidenceSections,
|
|
827
|
+
` evidence store: ${s.storePath ?? '(unresolvable — no git dir)'} (${s.storeRecords} record(s)${s.storeMalformed ? `, ${s.storeMalformed} malformed — inspect the file` : ''}${s.storeReadError ? `, read error: ${s.storeReadError}` : ''})`,
|
|
828
|
+
];
|
|
829
|
+
return lines.join('\n');
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
833
|
+
|
|
834
|
+
const HELP = `core-evidence — the ONE writer of the git-dir core-evidence store (agent-workflow family).
|
|
835
|
+
|
|
836
|
+
Usage:
|
|
837
|
+
node core-evidence.mjs red-proof "<test-file>#<test-name-pattern>" [--cwd <dir>]
|
|
838
|
+
node core-evidence.mjs degrade --backend <name> --reason "<why>" [--cwd <dir>]
|
|
839
|
+
node core-evidence.mjs summary [--cwd <dir>]
|
|
840
|
+
|
|
841
|
+
red-proof observes the named test RED on the CURRENT (pre-fix) tree — N runs (AW_CORE_EVIDENCE_RERUNS,
|
|
842
|
+
default 3; per-run timeout AW_CORE_EVIDENCE_TIMEOUT_S, default 120s), shell-free spawn of the safely
|
|
843
|
+
resolved repo-relative file — and appends the D3(c) declaration { testId, file, content sha256,
|
|
844
|
+
observed red N/N, base = HEAD sha, PRE-FIX tree fingerprint }. Observed green / unresolvable /
|
|
845
|
+
mixed / timed-out are DISTINGUISHED refusals and nothing is written (mixed/timeout = QUARANTINE —
|
|
846
|
+
never converts, no override lane). A new record for the same {base, testId} SUPERSEDES the earlier
|
|
847
|
+
one (re-observation after a test edit is a new record, not a hash conflict).
|
|
848
|
+
|
|
849
|
+
degrade records the EXPLICIT per-backend, per-tree escape { backend, non-empty reason, current tree
|
|
850
|
+
fingerprint } — the only honest lane for an unavailable review backend; the review-state gate
|
|
851
|
+
consumes these (and never accepts all backends degraded).
|
|
852
|
+
|
|
853
|
+
summary renders ONE stateless end-of-loop view from the review receipts + this store: per-backend
|
|
854
|
+
verdicts for the current tree, current-base red-proof records, current-tree degrade records.
|
|
855
|
+
|
|
856
|
+
Store: <git dir>/${EVIDENCE_BASENAME} (AW_CORE_EVIDENCE overrides) — one JSONL file, versioned
|
|
857
|
+
schema, LATEST record per key authoritative (red-proof: {base, testId}; degrade: {backend,
|
|
858
|
+
fingerprint}); a byte-identical replayed line is refused as a duplicate; malformed lines fail
|
|
859
|
+
closed. Records live in the git dir as a self-discipline mechanism, not a security boundary.
|
|
860
|
+
|
|
861
|
+
Sandbox-safe: no network; writes only the git-dir store; spawns read-only git queries and the
|
|
862
|
+
bound-test probe (node --test).
|
|
863
|
+
|
|
864
|
+
Exit codes: 0 written / rendered healthy; 1 a typed refusal (observed green / quarantine /
|
|
865
|
+
unresolvable / tree drift during observation / malformed store / duplicate / fs error — and a
|
|
866
|
+
summary over a malformed/unreadable store); 2 usage.`;
|
|
867
|
+
|
|
868
|
+
const parseFlag = (rest, name) => {
|
|
869
|
+
const i = rest.indexOf(name);
|
|
870
|
+
if (i === -1) return { value: undefined, rest };
|
|
871
|
+
const value = rest[i + 1];
|
|
872
|
+
if (value === undefined) throw usageFail(`${name} needs a value`);
|
|
873
|
+
return { value, rest: [...rest.slice(0, i), ...rest.slice(i + 2)] };
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
export const main = (argv, ctx = {}) => {
|
|
877
|
+
const env = ctx.env ?? process.env;
|
|
878
|
+
try {
|
|
879
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
880
|
+
const [verb, ...restRaw] = argv;
|
|
881
|
+
const { value: cwdFlag, rest } = parseFlag(restRaw, '--cwd');
|
|
882
|
+
const cwd = cwdFlag ?? ctx.cwd ?? process.cwd();
|
|
883
|
+
if (verb === 'red-proof') {
|
|
884
|
+
const [testId, ...extra] = rest;
|
|
885
|
+
if (testId === undefined) throw usageFail('red-proof needs a testId ("<test-file>#<test-name-pattern>")');
|
|
886
|
+
if (extra.length > 0) throw usageFail(`unknown argument: ${extra[0]}`);
|
|
887
|
+
const { writtenPath, record } = runRedProof({ cwd, env, testId });
|
|
888
|
+
return { code: 0, stdout: `core-evidence: recorded a red-proof for "${record.testId}" (${record.reds}/${record.runs} observed red, hash ${record.fileHash.slice(0, 12)}…) → ${writtenPath}`, stderr: '' };
|
|
889
|
+
}
|
|
890
|
+
if (verb === 'degrade') {
|
|
891
|
+
const { value: backend, rest: r1 } = parseFlag(rest, '--backend');
|
|
892
|
+
const { value: reason, rest: r2 } = parseFlag(r1, '--reason');
|
|
893
|
+
if (r2.length > 0) throw usageFail(`unknown argument: ${r2[0]}`);
|
|
894
|
+
const { writtenPath, record } = runDegrade({ cwd, env, backend, reason });
|
|
895
|
+
return { code: 0, stdout: `core-evidence: recorded a degrade for backend "${record.backend}" at fingerprint ${record.fingerprint.slice(0, 12)}… → ${writtenPath}`, stderr: '' };
|
|
896
|
+
}
|
|
897
|
+
if (verb === 'summary') {
|
|
898
|
+
if (rest.length > 0) throw usageFail(`unknown argument: ${rest[0]}`);
|
|
899
|
+
const state = buildSummaryState({ cwd, env });
|
|
900
|
+
return { code: state.evidenceUnavailable || state.receiptsUnavailable ? 1 : 0, stdout: renderSummary(state), stderr: '' };
|
|
901
|
+
}
|
|
902
|
+
throw usageFail(`unknown verb: ${verb ?? '(none)'} — expected red-proof | degrade | summary (see --help)`);
|
|
903
|
+
} catch (err) {
|
|
904
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `core-evidence: ${err.message}` };
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
909
|
+
if (isDirectRun) {
|
|
910
|
+
const r = main(process.argv.slice(2));
|
|
911
|
+
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
912
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
913
|
+
process.exitCode = r.code;
|
|
914
|
+
}
|