@sabaiway/agent-workflow-kit 14.4.1 → 14.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 14.4.3 — cleanup after the two splits: one false doc pointer, a test title, a list order, comments and whitespace; nothing the kit decides, prints or records changes
8
+
9
+ **Six small review findings from the review-state and core-evidence splits are fixed.** The only shipped doc change is in
10
+ `references/modes/commit-guard.md`: it now says the fingerprint argv lives in `core-evidence-tree.mjs` and that the
11
+ `core-evidence.mjs` facade re-exports it. The earlier text named the facade as its home. Everything else is one
12
+ test title, one list order, comments and whitespace.
13
+
14
+ - **Tests.** In `tools/review-state.test.mjs`, the block that runs `--check` and proves a later ungrounded receipt
15
+ outranks an earlier grounded SHIP no longer carries the name `backendReceiptStatus`. In
16
+ `test/package-content.test.mjs`, `tools/grounding.mjs` sits under the AD-038 pair comment again, and two comments
17
+ use a real em dash instead of the literal `\u2014` escape. The `cleanState` helper in `tools/review-state-judge.test.mjs`
18
+ loses a stray indent, and a blank line before a closing brace in `tools/core-evidence-tree.test.mjs` is gone.
19
+ - **Sources.** The first-line comments of `review-state-build.mjs`, `review-state-flow.mjs` and `review-state-render.mjs`
20
+ use the em dash the other modules use, and `core-evidence-store-read.mjs` loses a doubled blank line.
21
+
22
+ ## 14.4.2 — `core-evidence.mjs` becomes a facade over six leaves; nothing the tool decides, prints or records changes (AD-150)
23
+
24
+ **The core-evidence writer is now six small modules behind its old entry point.** `tools/core-evidence.mjs` was one
25
+ 1251-line file. 14.4.2 moves its code into six library modules and keeps a 143-line facade with the unchanged header,
26
+ the command line and the same 49 exported names, so no import site outside the core-evidence suites moves. Exit codes,
27
+ messages, records and the tree fingerprint are the same as in 14.4.1. Story S2 of the epic
28
+ `REVIEW-STATE-AND-CORE-EVIDENCE-ARE-MONOLITHS`; contract `docs/ai/specs/kit/core-evidence.md` (revision 1).
29
+
30
+ - **The leaves.** `core-evidence-tree.mjs` (git queries, base, tree fingerprint, working state),
31
+ `core-evidence-receipts.mjs` (receipt path and reader, verdict vocabulary, receipt classes),
32
+ `core-evidence-store-read.mjs` (testId format, store path, record schema, reader, keys), `core-evidence-store.mjs`
33
+ (the one append and the `red-proof` and `degrade` verbs), `core-evidence-red-proof.mjs` (the test-file safeguards
34
+ and the repeated test runs) and `core-evidence-summary.mjs` (the `summary` state and render). The summary module does not
35
+ import the append module. None of the six imports the facade or runs as a script; each is under the 400-line cap, and
36
+ the size records of the old module and its suite are gone.
37
+ - **Tests.** The 96 cases of `core-evidence.test.mjs` move unchanged: 75 into five module suites, 21 stay in the
38
+ facade suite, with shared fixtures in `core-evidence-harness.test.mjs`; no case is added or removed. `core-evidence-hostile.test.mjs` imports the
39
+ modules it calls. `test/kit-readonly-tools.test.mjs` now looks for the `--absolute-git-dir` query in the receipts
40
+ module, and `test/package-content.test.mjs` names the six modules and pins 324 packed files. A comment in `tools/exec-producer.mjs`
41
+ that pointed at a line range of the old module now names `flaggedIndexLag` in the tree module.
42
+
7
43
  ## 14.4.1 — `review-state.mjs` becomes a facade over five tested leaves; nothing the tool decides, prints or records changes (AD-149)
8
44
 
9
45
  **The review-receipt checker is now five small modules behind its old entry point.** `tools/review-state.mjs` was one
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.4.1'
6
+ version: '14.4.3'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "14.4.1",
6
+ "version": "14.4.3",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "14.4.1",
3
+ "version": "14.4.3",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -24,7 +24,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/commit-guard.mjs --check [--cwd <dir>]` —
24
24
 
25
25
  **Boundary — what «the commit captures the tree» means for submodules.** The guard proves the SUPERPROJECT commit captures the SUPERPROJECT working tree. A superproject commit stores a submodule as a gitlink OID and never captures its file content — that content is the submodule's own commit boundary. The dirty-submodule refusal is therefore a best-effort COURTESY beyond the guard's boundary, and its completeness is bounded by what the submodule itself reports: a file held behind a `skip-worktree` / `assume-unchanged` bit in the SUBMODULE's own index is invisible to the submodule's status and so to this refusal. Deepening the nested probe was tried and abandoned — three consecutive review rounds each produced a new way for it to answer wrongly — so the honest contract is the boundary, not a completeness claim. Tracked as its own class.
26
26
 
27
- **Stated residual — the payload flags close the config-blind class.** The fingerprint argv in the ONE node home, `core-evidence.mjs`, carries `--no-textconv --ignore-submodules=none` beside `--no-ext-diff`; both bash twins are parity-pinned to that set for their fingerprint and review-payload diffs, so host textconv and submodule-ignore configuration cannot hide staged bytes. Refusal 2 remains the fail-closed backstop for any DIRTY index whose staged content the payload still cannot see: it names making the change visible to `git diff --cached` and re-running the final gate, without guessing a flag or configuration key.
27
+ **Stated residual — the payload flags close the config-blind class.** The fingerprint argv in the ONE node home, `core-evidence-tree.mjs` (re-exported by the `core-evidence.mjs` facade), carries `--no-textconv --ignore-submodules=none` beside `--no-ext-diff`; both bash twins are parity-pinned to that set for their fingerprint and review-payload diffs, so host textconv and submodule-ignore configuration cannot hide staged bytes. Refusal 2 remains the fail-closed backstop for any DIRTY index whose staged content the payload still cannot see: it names making the change visible to `git diff --cached` and re-running the final gate, without guessing a flag or configuration key.
28
28
 
29
29
  **Human residual (stated, accepted):** `git commit --no-verify` bypasses any pre-commit hook — a self-discipline mechanism, not a security boundary.
30
30
 
@@ -0,0 +1,191 @@
1
+ // core-evidence-receipts.mjs — the review-receipt read path, the verdict vocabulary and the receipt classes; the contract is the header of core-evidence.mjs.
2
+
3
+ import { join } from 'node:path';
4
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
5
+ import { gitLine } from './core-evidence-tree.mjs';
6
+
7
+ export const RECEIPTS_BASENAME = 'agent-workflow-review-receipts.jsonl';
8
+
9
+ export const resolveReceiptsPath = (cwd, env = process.env) => {
10
+ if (env.AW_REVIEW_RECEIPTS) return env.AW_REVIEW_RECEIPTS;
11
+ const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
12
+ return gitDir == null ? null : join(gitDir, RECEIPTS_BASENAME);
13
+ };
14
+
15
+ // Parse the receipt file → { receipts, malformed, readError? }. Absent file → empty (not an
16
+ // error: no review ever ran). The read rides the fs-read-nofollow leaf (RECEIPTS-READER-NOFOLLOW):
17
+ // a symlinked/FIFO/directory receipts path surfaces as readError — never content, never an empty
18
+ // success — and any NON-ENOENT failure surfaces as readError too; an unreadable store must never
19
+ // silently read as "no receipts" (the summary withholds its verdicts section on it). A malformed
20
+ // line is counted + reported, never silently dropped. `io` is the injectable-read test seam
21
+ // (readRegularFileNoFollow's descriptor-level io).
22
+ export const readReceipts = (path, io = {}) => {
23
+ const read = readRegularFileNoFollow(path, io);
24
+ if (read.outcome === 'absent') return { receipts: [], malformed: 0 };
25
+ if (read.outcome === 'foreign') {
26
+ return { receipts: [], malformed: 0, readError: `the receipts store is a ${read.className}, not a regular file — refusing to read it (fail closed)` };
27
+ }
28
+ if (read.outcome === 'error') return { receipts: [], malformed: 0, readError: read.code };
29
+ const raw = read.content;
30
+ const receipts = [];
31
+ let malformed = 0;
32
+ for (const line of raw.split('\n')) {
33
+ if (line.trim() === '') continue;
34
+ try {
35
+ const parsed = JSON.parse(line);
36
+ if (parsed && typeof parsed === 'object' && typeof parsed.backend === 'string') receipts.push(parsed);
37
+ else malformed += 1;
38
+ } catch {
39
+ malformed += 1;
40
+ }
41
+ }
42
+ return { receipts, malformed };
43
+ };
44
+
45
+ // The closed review-verdict vocabulary (the union of both wrappers' closed grammars). Any
46
+ // RECOGNIZED verdict is review EVIDENCE (a recognized negative is an authoritative veto); ONLY
47
+ // ship-class SATISFIES the review-state gate.
48
+ const SHIP_VERDICTS = new Set(['ship', 'ship with nits']);
49
+ const NEGATIVE_VERDICTS = new Set(['revise', 'rethink', 'rework']);
50
+ // Type-strict: only a STRING can enter the closed grammar — String() coercion would admit an
51
+ // array like ["ship"] as ship-class.
52
+ const normalizeVerdict = (verdict) => (typeof verdict === 'string' ? verdict.trim().toLowerCase() : null);
53
+ export const isShipVerdict = (verdict) => {
54
+ const v = normalizeVerdict(verdict);
55
+ return v !== null && SHIP_VERDICTS.has(v);
56
+ };
57
+ export const isRecognizedVerdict = (verdict) => {
58
+ const v = normalizeVerdict(verdict);
59
+ return v !== null && (SHIP_VERDICTS.has(v) || NEGATIVE_VERDICTS.has(v));
60
+ };
61
+
62
+ export const REVIEW_RECEIPT_CLASS = Object.freeze({
63
+ NOT_CURRENT: 'not-current',
64
+ ATTESTING: 'attesting',
65
+ UNGROUNDED: 'ungrounded',
66
+ UNRECOGNIZED_VERDICT: 'unrecognized-verdict',
67
+ PROBE: 'probe',
68
+ UNMARKED: 'unmarked',
69
+ MALFORMED_MARKER: 'malformed-marker',
70
+ POSTURE_UNMARKED: 'posture-unmarked',
71
+ MALFORMED_POSTURE: 'malformed-posture',
72
+ DELIVERY_UNMARKED: 'delivery-unmarked',
73
+ MALFORMED_DELIVERY: 'malformed-delivery',
74
+ });
75
+
76
+ // Backends whose `code` receipts must SELF-DECLARE how the change set reached the model (D8b).
77
+ // Scoped, not universal: agy's oversized lane was the one observed returning a confident
78
+ // fabrication, and codex's receipt semantics are deliberately untouched.
79
+ const DELIVERY_DECLARING_BACKENDS = new Set(['agy']);
80
+
81
+ // A delivery declaration is a lowercase token naming HOW delivery was established (`inline` when
82
+ // the whole change set rode one prompt, `fed` when a chunked feed proved it by echo). The gate
83
+ // requires PRESENT and WELL-FORMED, never a PARTICULAR value — which lane was used is the
84
+ // wrapper's business; that the receipt declares one at all is the gate's.
85
+ const isValidReceiptDelivery = (delivery) =>
86
+ typeof delivery === 'string' && delivery.length > 0 && delivery.length <= 32 && /^[a-z][a-z0-9-]*$/.test(delivery);
87
+
88
+ // The D5 posture declaration (strip Phase 4): an object whose `model` is a NON-EMPTY string;
89
+ // `effort` (when present) a non-empty string; `tier` (when present) a non-empty string or null.
90
+ // Backend-agnostic — the wrapper is the writer authority on WHICH keys it declares.
91
+ const isValidReceiptPosture = (posture) => {
92
+ if (posture === null || typeof posture !== 'object' || Array.isArray(posture)) return false;
93
+ if (typeof posture.model !== 'string' || posture.model.length === 0) return false;
94
+ if (Object.hasOwn(posture, 'effort') && (typeof posture.effort !== 'string' || posture.effort.length === 0)) return false;
95
+ if (Object.hasOwn(posture, 'tier') && posture.tier !== null && (typeof posture.tier !== 'string' || posture.tier.length === 0)) return false;
96
+ return true;
97
+ };
98
+
99
+ // Classify ONE receipt against a tree fingerprint. Order is load-bearing: identity first (is this
100
+ // even about the current tree?), then the probe marker (may it attest at all?), then the D5
101
+ // posture marker (same self-declaration doctrine — an absent/invalid posture is non-attesting,
102
+ // fail closed; pre-D5 receipts stop satisfying, the recovery is re-running the review), then the
103
+ // verdict-in-recognized-vocabulary arm BEFORE grounding — an unrecognized verdict is an
104
+ // unconditional refusal that grounding must never reclassify (an ungrounded `unknown` receipt
105
+ // otherwise reads UNGROUNDED and can be masked by another backend's SHIP), then grounding.
106
+ export const classifyReviewReceiptForTree = (receipt, fingerprint) => {
107
+ if (!(receipt !== null && typeof receipt === 'object' && !Array.isArray(receipt)) || receipt.fresh !== true || receipt.artifact !== 'code' || receipt.fingerprint !== fingerprint) {
108
+ return REVIEW_RECEIPT_CLASS.NOT_CURRENT;
109
+ }
110
+ if (!Object.hasOwn(receipt, 'probe')) return REVIEW_RECEIPT_CLASS.UNMARKED;
111
+ if (typeof receipt.probe !== 'boolean') return REVIEW_RECEIPT_CLASS.MALFORMED_MARKER;
112
+ if (receipt.probe === true) return REVIEW_RECEIPT_CLASS.PROBE;
113
+ if (!Object.hasOwn(receipt, 'posture')) return REVIEW_RECEIPT_CLASS.POSTURE_UNMARKED;
114
+ if (!isValidReceiptPosture(receipt.posture)) return REVIEW_RECEIPT_CLASS.MALFORMED_POSTURE;
115
+ if (!isRecognizedVerdict(receipt.verdict)) return REVIEW_RECEIPT_CLASS.UNRECOGNIZED_VERDICT;
116
+ if (receipt.grounded !== true) return REVIEW_RECEIPT_CLASS.UNGROUNDED;
117
+ // LAST on purpose. The delivery arm may only intercept a receipt that would otherwise ATTEST:
118
+ // placing it earlier pulled delivery-less `unrecognized-verdict` / `ungrounded` agy receipts out
119
+ // of summarize's latest-NORMAL selection, so an EARLIER ship survived a LATER bad receipt — the
120
+ // selection-first doctrine, broken silently. Those two classes stay byte-identical to before;
121
+ // the ONE class this arm changes is the previously-attesting one, which is the point.
122
+ if (DELIVERY_DECLARING_BACKENDS.has(receipt.backend)) {
123
+ if (!Object.hasOwn(receipt, 'delivery')) return REVIEW_RECEIPT_CLASS.DELIVERY_UNMARKED;
124
+ if (!isValidReceiptDelivery(receipt.delivery)) return REVIEW_RECEIPT_CLASS.MALFORMED_DELIVERY;
125
+ }
126
+ return REVIEW_RECEIPT_CLASS.ATTESTING;
127
+ };
128
+
129
+ // Summarize one backend's receipts for a tree → { state, receipt, counts… }. The LATEST NORMAL
130
+ // (probe-free, marker-valid, current-fingerprint) receipt is selected FIRST and THEN judged — a
131
+ // later unknown-verdict or ungrounded receipt never lets an earlier SHIP survive, and a probe (or
132
+ // forged marker) written after a real review never becomes the authoritative verdict.
133
+ export const summarizeReviewReceiptsForTree = (receipts, fingerprint) => {
134
+ const classified = receipts
135
+ .map((receipt) => ({ receipt, classification: classifyReviewReceiptForTree(receipt, fingerprint) }))
136
+ .filter(({ classification }) => classification !== REVIEW_RECEIPT_CLASS.NOT_CURRENT);
137
+ const rowsFor = (classification) => classified.filter((row) => row.classification === classification);
138
+ const normal = classified.filter(({ classification }) =>
139
+ classification === REVIEW_RECEIPT_CLASS.ATTESTING ||
140
+ classification === REVIEW_RECEIPT_CLASS.UNGROUNDED ||
141
+ classification === REVIEW_RECEIPT_CLASS.UNRECOGNIZED_VERDICT);
142
+ const probe = rowsFor(REVIEW_RECEIPT_CLASS.PROBE);
143
+ const unmarked = rowsFor(REVIEW_RECEIPT_CLASS.UNMARKED);
144
+ const malformedMarker = rowsFor(REVIEW_RECEIPT_CLASS.MALFORMED_MARKER);
145
+ const postureUnmarked = rowsFor(REVIEW_RECEIPT_CLASS.POSTURE_UNMARKED);
146
+ const malformedPosture = rowsFor(REVIEW_RECEIPT_CLASS.MALFORMED_POSTURE);
147
+ const deliveryUnmarked = rowsFor(REVIEW_RECEIPT_CLASS.DELIVERY_UNMARKED);
148
+ const malformedDelivery = rowsFor(REVIEW_RECEIPT_CLASS.MALFORMED_DELIVERY);
149
+ const counts = {
150
+ currentCount: classified.length,
151
+ ungroundedCount: rowsFor(REVIEW_RECEIPT_CLASS.UNGROUNDED).length,
152
+ unrecognizedVerdictCount: rowsFor(REVIEW_RECEIPT_CLASS.UNRECOGNIZED_VERDICT).length,
153
+ probeExcluded: probe.length,
154
+ markerRejected: malformedMarker.length,
155
+ unmarkedRejected: unmarked.length,
156
+ postureRejected: postureUnmarked.length + malformedPosture.length,
157
+ deliveryRejected: deliveryUnmarked.length + malformedDelivery.length,
158
+ };
159
+ if (normal.length > 0) {
160
+ const latest = normal[normal.length - 1];
161
+ if (latest.classification === REVIEW_RECEIPT_CLASS.ATTESTING) return { state: 'current', receipt: latest.receipt, ...counts };
162
+ if (latest.classification === REVIEW_RECEIPT_CLASS.UNGROUNDED) return { state: 'ungrounded', receipt: latest.receipt, ...counts };
163
+ return { state: 'unrecognized-verdict', receipt: latest.receipt, ...counts };
164
+ }
165
+ if (classified.length > 0) {
166
+ return {
167
+ state: malformedMarker.length > 0 || unmarked.length > 0 || counts.postureRejected > 0 || counts.deliveryRejected > 0 ? 'rejected' : 'probe',
168
+ receipt: null,
169
+ ...counts,
170
+ };
171
+ }
172
+ return { state: 'none', receipt: null, ...counts };
173
+ };
174
+
175
+ // Why this backend has no attestation — one stated sentence per distinct recovery.
176
+ export const describeMissingReviewAttestation = (summary) => {
177
+ if (summary.state === 'current') return null;
178
+ const exclusions = [
179
+ summary.probeExcluded > 0 ? `${summary.probeExcluded} probe receipt(s)` : null,
180
+ summary.markerRejected > 0 ? `${summary.markerRejected} receipt(s) with a malformed probe marker` : null,
181
+ summary.unmarkedRejected > 0 ? `${summary.unmarkedRejected} receipt(s) with no probe marker` : null,
182
+ (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,
183
+ (summary.deliveryRejected ?? 0) > 0 ? `${summary.deliveryRejected} agy receipt(s) with an absent/invalid delivery declaration (minted before the change set was PROVEN delivered — re-run the review on the current bridge)` : null,
184
+ ].filter(Boolean);
185
+ const exclusionSuffix = exclusions.length > 0 ? `; excluded ${exclusions.join(', ')}` : '';
186
+ if (summary.state === 'ungrounded') return `the latest normal receipt for the current tree is ungrounded${exclusionSuffix}`;
187
+ 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}`;
188
+ if (summary.state === 'probe') return 'only probe receipts exist for the current tree — a probe review never attests';
189
+ if (summary.state === 'rejected') return `current-tree receipts have an untrustworthy probe marker, run posture or delivery declaration${exclusionSuffix}`;
190
+ return 'no fresh code receipt exists for the current tree';
191
+ };
@@ -0,0 +1,125 @@
1
+ // core-evidence-red-proof.mjs — the red-proof probe: the test-file safeguards and the N-rerun observation; the contract is the header of core-evidence.mjs.
2
+
3
+ import { readFileSync, lstatSync, realpathSync } from 'node:fs';
4
+ import { join, normalize, sep, basename } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { lexicalRepoRelative } from './repo-lex.mjs';
8
+ import { GIT_MAX_BUFFER } from './git-env.mjs';
9
+ import { splitTestId } from './core-evidence-store-read.mjs';
10
+
11
+ // ── the probe safeguards (moved verbatim from the retired fold runner) ────────────────────────────
12
+
13
+ // Node sets NODE_TEST_CONTEXT for any process running UNDER `node --test`; a fresh `node --test`
14
+ // that inherits it silently SKIPS running its files. The probe spawns `node --test`, so it strips
15
+ // that var (a no-op in normal invocation).
16
+ export const childTestEnv = (env, extra = {}) => {
17
+ const out = { ...env, ...extra };
18
+ delete out.NODE_TEST_CONTEXT;
19
+ return out;
20
+ };
21
+
22
+ const PROBE_RESULT_RE = /^(ok|not ok) \d+ - (.*)$/;
23
+ const PROBE_FAIL_RE = /^# fail (\d+)$/;
24
+ const PROBE_DIRECTIVE_RE = /#\s*(?:skip|todo)\b/i; // a TAP SKIP/TODO directive — the test did NOT run
25
+
26
+ // parseProbeOutput({ stdout, code, fileArg }) → { resolvable, executed, baselineGreen }. The file
27
+ // wrapper is matched by BASENAME (node normalizes the echoed path); a directive-carrying result
28
+ // line never counts (some Node lines emit pattern-filtered tests as SKIP — the guard is
29
+ // version-agnostic on purpose).
30
+ export const parseProbeOutput = ({ stdout, code, fileArg }) => {
31
+ let matched = 0;
32
+ let notOk = 0;
33
+ let failCount = null;
34
+ const wanted = basename(String(fileArg).trim());
35
+ for (const line of String(stdout).split('\n')) {
36
+ const m = PROBE_RESULT_RE.exec(line);
37
+ if (m && !PROBE_DIRECTIVE_RE.test(m[2]) && basename(m[2].trim()) !== wanted) {
38
+ matched += 1;
39
+ if (m[1] === 'not ok') notOk += 1;
40
+ }
41
+ const f = PROBE_FAIL_RE.exec(line.trim());
42
+ if (f) failCount = Number(f[1]);
43
+ }
44
+ const resolvable = matched > 0;
45
+ const fails = (failCount ?? 0) + notOk;
46
+ return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
47
+ };
48
+
49
+ // The shell-free node:test argv. The pattern rides `=`-joined: as a separate token a pattern
50
+ // beginning with "-" would parse as an OPTION and silently select no test.
51
+ export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', `--test-name-pattern=${pattern}`, file];
52
+
53
+ // containsPath(realRoot, realAbs) → strictly INSIDE. Segment-safe ('/a' never contains '/ab') and
54
+ // correct for a repo at the filesystem root.
55
+ export const containsPath = (realRoot, realAbs) => realAbs.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep);
56
+
57
+ // resolveTestFile(rootTop, rel, deps?) → { ok: true, abs } | { ok: false, reason } (never throws).
58
+ // Repo-relative only; a REGULAR file under no-follow lstat; the RESOLVED real path contained under
59
+ // the REAL repo root (a leaf check alone would let a symlinked PARENT directory escape).
60
+ export const resolveTestFile = (rootTop, rel, deps = {}) => {
61
+ const lstat = deps.lstat ?? lstatSync;
62
+ const realpath = deps.realpath ?? realpathSync;
63
+ const lex = lexicalRepoRelative(rel);
64
+ if (!lex.ok) return { ok: false, reason: lex.reason };
65
+ const abs = join(rootTop, normalize(rel));
66
+ let st;
67
+ try {
68
+ st = lstat(abs);
69
+ } catch {
70
+ return { ok: false, reason: `file "${rel}" does not exist` };
71
+ }
72
+ if (!st.isFile()) return { ok: false, reason: `"${rel}" is not a regular file (a symlink/directory/device is never followed — fail closed)` };
73
+ let realAbs;
74
+ let realRoot;
75
+ try {
76
+ realAbs = realpath(abs);
77
+ realRoot = realpath(rootTop);
78
+ } catch {
79
+ return { ok: false, reason: `cannot resolve the real path of "${rel}"` };
80
+ }
81
+ if (!containsPath(realRoot, realAbs)) return { ok: false, reason: `"${rel}" resolves outside the repo root (a symlinked parent directory) — fail closed` };
82
+ return { ok: true, abs: realAbs };
83
+ };
84
+
85
+ // sha-256 over the file's BYTES; null on a read failure (the caller reads that as unresolvable).
86
+ export const hashFileBytes = (abs) => {
87
+ try {
88
+ return createHash('sha256').update(readFileSync(abs)).digest('hex');
89
+ } catch {
90
+ return null;
91
+ }
92
+ };
93
+
94
+ // The N-rerun probe. The custody hash is taken BEFORE the runs (the content the observation
95
+ // attests); the spawn ALWAYS uses the resolver's canonical absolute path — the executed file must
96
+ // be the hashed file independent of runner path semantics. A timed-out or signal-killed run is
97
+ // neither red nor green — it lands in `timeouts` (quarantine fuel).
98
+ export const probeBound = ({ testId, rootTop, env, reruns, timeoutS }) => {
99
+ const { file, pattern } = splitTestId(testId);
100
+ const resolved = resolveTestFile(rootTop, file);
101
+ const fileHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
102
+ let executed = 0;
103
+ let greens = 0;
104
+ let reds = 0;
105
+ let timeouts = 0;
106
+ if (resolved.ok && fileHash != null) {
107
+ for (let i = 0; i < reruns; i += 1) {
108
+ const argv = defaultBoundArgv(resolved.abs, pattern);
109
+ const res = spawnSync(argv[0], argv.slice(1), {
110
+ cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, timeout: timeoutS * 1000,
111
+ });
112
+ if ((res.error && res.error.code === 'ETIMEDOUT') || res.signal != null) {
113
+ timeouts += 1;
114
+ continue;
115
+ }
116
+ const p = parseProbeOutput({ stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file });
117
+ executed = Math.max(executed, p.executed);
118
+ if (!p.resolvable) continue;
119
+ if (p.baselineGreen) greens += 1;
120
+ else reds += 1;
121
+ }
122
+ }
123
+ const entry = { executed, runs: reruns, greens, reds, timeouts, fileHash };
124
+ return { entry, file, resolveReason: resolved.ok ? (fileHash == null ? `cannot read "${file}"` : null) : resolved.reason };
125
+ };
@@ -0,0 +1,207 @@
1
+ // core-evidence-store-read.mjs — the read half of the evidence store: testId format, path, schema, reader and keys; the contract is the header of core-evidence.mjs.
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { lexicalRepoRelative } from './repo-lex.mjs';
6
+ // The coverage vocabulary leaf: run-gates RECORDS the token this validator checks, and run-gates
7
+ // imports core-evidence (the sole-writer boundary), so their shared home sits below both.
8
+ import { COVERAGE, FINAL_COVERAGE_STATES } from './coverage-state.mjs';
9
+ import { gitLine } from './core-evidence-tree.mjs';
10
+
11
+ // testId FORMAT: "<repo-relative test file>#<test-name-pattern>" — a "#" separator with BOTH halves
12
+ // non-empty. Format-only (no suffix rule); resolvability is the probe's job.
13
+ const TESTID_SEPARATOR = '#';
14
+ export const isWellFormedTestId = (v) => {
15
+ if (typeof v !== 'string') return false;
16
+ const at = v.indexOf(TESTID_SEPARATOR);
17
+ return at > 0 && at < v.length - 1;
18
+ };
19
+ export const splitTestId = (v) => {
20
+ const at = v.indexOf(TESTID_SEPARATOR);
21
+ return { file: v.slice(0, at), pattern: v.slice(at + 1) };
22
+ };
23
+
24
+ // ── the store: ONE git-dir JSONL file (D7) ────────────────────────────────────────────────────────
25
+
26
+ export const EVIDENCE_BASENAME = 'agent-workflow-core-evidence.jsonl';
27
+ export const EVIDENCE_SCHEMA_VERSION = 1;
28
+
29
+ // AW_CORE_EVIDENCE overrides (the AW_REVIEW_RECEIPTS idiom); else <git dir>/basename; null outside
30
+ // a git work tree.
31
+ export const resolveEvidencePath = (cwd, env = process.env) => {
32
+ if (env.AW_CORE_EVIDENCE) return env.AW_CORE_EVIDENCE;
33
+ const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
34
+ return gitDir == null ? null : join(gitDir, EVIDENCE_BASENAME);
35
+ };
36
+
37
+ // ── schema validation (closed kinds, per-kind arms; unknown schema/kind fail CLOSED) ──────────────
38
+
39
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
40
+ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
41
+ const HEX64_RE = /^[0-9a-f]{64}$/;
42
+ const HEX40_RE = /^[0-9a-f]{40}$/;
43
+
44
+ export const validateEvidenceRecord = (record) => {
45
+ if (!isPlainObject(record)) return { ok: false, reason: 'record is not an object' };
46
+ if (record.schema !== EVIDENCE_SCHEMA_VERSION) {
47
+ return { ok: false, reason: `unknown schema ${JSON.stringify(record.schema)} — this reader accepts schema ${EVIDENCE_SCHEMA_VERSION} only (fail closed)` };
48
+ }
49
+ if (record.kind === 'red-proof') {
50
+ if (!isWellFormedTestId(record.testId)) return { ok: false, reason: 'red-proof: testId must be "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty)' };
51
+ if (!isNonEmptyString(record.file)) return { ok: false, reason: 'red-proof: file must be the non-empty repo-relative test-file path' };
52
+ const lex = lexicalRepoRelative(record.file);
53
+ if (!lex.ok) return { ok: false, reason: `red-proof: file must be lexically repo-relative — ${lex.reason}` };
54
+ 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)' };
55
+ 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' };
56
+ if (!Number.isInteger(record.runs) || record.runs < 1) return { ok: false, reason: 'red-proof: runs must be a positive integer' };
57
+ if (record.reds !== record.runs) return { ok: false, reason: 'red-proof: reds must equal runs — an observed-red declaration is N/N by construction' };
58
+ 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' };
59
+ 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' };
60
+ if (!isNonEmptyString(record.timestamp)) return { ok: false, reason: 'red-proof: timestamp must be a non-empty string' };
61
+ return { ok: true };
62
+ }
63
+ if (record.kind === 'degrade') {
64
+ if (typeof record.backend !== 'string' || record.backend.trim() === '') return { ok: false, reason: 'degrade: backend must be a non-empty string (whitespace-only is refused)' };
65
+ 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)' };
66
+ 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' };
67
+ if (!isNonEmptyString(record.timestamp)) return { ok: false, reason: 'degrade: timestamp must be a non-empty string' };
68
+ return { ok: true };
69
+ }
70
+ if (record.kind === 'final-start') {
71
+ 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' };
72
+ if (!isNonEmptyString(record.attempt)) return { ok: false, reason: 'final-start: attempt must be a non-empty id — the completion closes exactly THIS start (linkage)' };
73
+ if (!isNonEmptyString(record.timestamp)) return { ok: false, reason: 'final-start: timestamp must be a non-empty string' };
74
+ return { ok: true };
75
+ }
76
+ if (record.kind === 'final') {
77
+ if (record.status !== 'green' && record.status !== 'red') return { ok: false, reason: `final: status must be "green" or "red" (got ${JSON.stringify(record.status)})` };
78
+ if (!isNonEmptyString(record.attempt)) return { ok: false, reason: 'final: attempt must be the non-empty id of the start this completion closes' };
79
+ for (const field of ['fingerprintBefore', 'fingerprintAfter']) {
80
+ if (typeof record[field] !== 'string' || !HEX64_RE.test(record[field])) return { ok: false, reason: `final: ${field} must be a 64-hex tree fingerprint` };
81
+ }
82
+ if (!Array.isArray(record.declared) || record.declared.length === 0 || !record.declared.every((d) => isPlainObject(d) && isNonEmptyString(d.id) && isNonEmptyString(d.cmd))) {
83
+ return { ok: false, reason: 'final: declared must be the non-empty ordered {id, cmd} array the run executed — an empty declaration never attests' };
84
+ }
85
+ 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)))) {
86
+ return { ok: false, reason: 'final: results must be the per-gate {id, ok, code} array (code integer, or null on a spawn failure)' };
87
+ }
88
+ if (record.results.length !== record.declared.length || !record.results.every((r, i) => r.id === record.declared[i].id)) {
89
+ return { ok: false, reason: 'final: results must mirror the declared gates 1:1 IN ORDER — a partial or shuffled attribution never attests' };
90
+ }
91
+ if (record.integrityFailure !== null && !isNonEmptyString(record.integrityFailure)) {
92
+ return { ok: false, reason: 'final: integrityFailure must be null or a non-empty reason (an artifact moved under the run)' };
93
+ }
94
+ const derived = record.results.every((r) => r.ok) && record.integrityFailure === null ? 'green' : 'red';
95
+ if (record.status !== derived) {
96
+ return { ok: false, reason: `final: status ${JSON.stringify(record.status)} contradicts the results/integrity content (derived ${JSON.stringify(derived)}) — status is DERIVED, never asserted` };
97
+ }
98
+ if (!isPlainObject(record.evidenceHashes)
99
+ || typeof record.evidenceHashes.redProof !== 'string' || !HEX64_RE.test(record.evidenceHashes.redProof)
100
+ || typeof record.evidenceHashes.degrade !== 'string' || !HEX64_RE.test(record.evidenceHashes.degrade)) {
101
+ return { ok: false, reason: 'final: evidenceHashes must carry 64-hex sha256 of the canonical red-proof and degrade serializations' };
102
+ }
103
+ // The D10 flow binding (Plan 4 Decision 2) is ADDITIVE: absent = a pre-flow-binding final
104
+ // (still valid); present must be the 64-hex owner-scoped projection hash.
105
+ if ('flow' in record.evidenceHashes && (typeof record.evidenceHashes.flow !== 'string' || !HEX64_RE.test(record.evidenceHashes.flow))) {
106
+ return { ok: false, reason: 'final: evidenceHashes.flow, when present, must be a 64-hex sha256 of the owner-scoped flow projection' };
107
+ }
108
+ if (record.lcovSha256 !== null && (typeof record.lcovSha256 !== 'string' || !HEX64_RE.test(record.lcovSha256))) {
109
+ return { ok: false, reason: 'final: lcovSha256 must be a 64-hex sha256 of the consumed lcov file, or null when none was produced' };
110
+ }
111
+ // The run's coverage token is ADDITIVE (absent = a pre-token receipt, still valid) and CLOSED.
112
+ // `certified` is cross-checked against the bound digest — a run that certified a verdict read
113
+ // bytes, so the two fields can never disagree in the store.
114
+ if ('coverage' in record) {
115
+ if (!FINAL_COVERAGE_STATES.includes(record.coverage)) {
116
+ return { ok: false, reason: `final: coverage, when present, must be one of ${FINAL_COVERAGE_STATES.join(' | ')} (got ${JSON.stringify(record.coverage)}; a final run always selects the canonical checker, so "none" never rides a final receipt)` };
117
+ }
118
+ if (record.coverage === COVERAGE.certified && record.lcovSha256 === null) {
119
+ return { ok: false, reason: 'final: coverage "certified" requires a bound lcovSha256 — a certified verdict was issued over lcov bytes that were read' };
120
+ }
121
+ }
122
+ if (!isNonEmptyString(record.timestamp)) return { ok: false, reason: 'final: timestamp must be a non-empty string' };
123
+ return { ok: true };
124
+ }
125
+ return { ok: false, reason: `unknown kind ${JSON.stringify(record.kind)} — closed set: red-proof | degrade | final-start | final (fail closed)` };
126
+ };
127
+
128
+ // ── the fail-closed reader ────────────────────────────────────────────────────────────────────────
129
+
130
+ // parseEvidenceText(raw) → { records, malformed, malformedReasons } — the ONE per-line parse +
131
+ // validation pass. readEvidence AND the append preflight consume it over the SAME captured bytes
132
+ // (a second read between check and write would be a TOCTOU gap).
133
+ export const parseEvidenceText = (raw) => {
134
+ const records = [];
135
+ const malformedReasons = [];
136
+ const lines = String(raw).split('\n');
137
+ for (let i = 0; i < lines.length; i += 1) {
138
+ if (lines[i].trim() === '') continue;
139
+ let parsed;
140
+ try {
141
+ parsed = JSON.parse(lines[i]);
142
+ } catch {
143
+ malformedReasons.push(`line ${i + 1}: invalid JSON`);
144
+ continue;
145
+ }
146
+ const v = validateEvidenceRecord(parsed);
147
+ if (v.ok) records.push(parsed);
148
+ else malformedReasons.push(`line ${i + 1}: ${v.reason}`);
149
+ }
150
+ return { records, malformed: malformedReasons.length, malformedReasons };
151
+ };
152
+
153
+ // readEvidence(path) → { records, malformed, malformedReasons, readError? }. Absent file → empty
154
+ // (no evidence yet is not an error). Every non-parsing / non-validating line is COUNTED with its
155
+ // reason — consumers fail closed on malformed > 0, never silently drop.
156
+ export const readEvidence = (path, readFile = readFileSync) => {
157
+ let raw;
158
+ try {
159
+ raw = readFile(path, 'utf8');
160
+ } catch (err) {
161
+ if (err && err.code === 'ENOENT') return { records: [], malformed: 0, malformedReasons: [] };
162
+ return { records: [], malformed: 0, malformedReasons: [], readError: (err && err.code) || (err && err.message) || 'read failed' };
163
+ }
164
+ return parseEvidenceText(raw);
165
+ };
166
+
167
+ // ── D6a: per-kind keys, authoritative selection, canonical serialization ──────────────────────────
168
+
169
+ // Space-joined with the CLOSED (validated hex-or-null) field in the MIDDLE, the free-form field
170
+ // last — content can then never forge a separator collision (base/fingerprint carry no spaces by
171
+ // validation; the last field absorbs anything).
172
+ export const evidenceKey = (r) =>
173
+ r.kind === 'red-proof' ? `red-proof ${r.base} ${r.testId}`
174
+ : r.kind === 'degrade' ? `degrade ${r.fingerprint} ${r.backend}`
175
+ : r.kind === 'final' ? `final ${r.fingerprintBefore}`
176
+ : r.kind === 'final-start' ? `final-start ${r.fingerprint}`
177
+ : null;
178
+
179
+ // The authoritative subset: the LATEST record per key, in file order of that latest appearance.
180
+ export const authoritativeEvidence = (records) => {
181
+ const lastByKey = new Map();
182
+ records.forEach((r, i) => {
183
+ const k = evidenceKey(r);
184
+ if (k != null) lastByKey.set(k, i);
185
+ });
186
+ const keep = new Set(lastByKey.values());
187
+ return records.filter((_, i) => keep.has(i));
188
+ };
189
+
190
+ export const authoritativeOfKind = (records, kind) => authoritativeEvidence(records).filter((r) => r.kind === kind);
191
+
192
+ // Canonical bytes: key-sorted JSON per record, one line each, newline-terminated ('' when empty) —
193
+ // independent of the byte layout the record was originally written with. The D3(a) receipt hashes
194
+ // EXACTLY these bytes per kind, so receipts (a different kind/file) are outside the domain by
195
+ // construction.
196
+ const stableStringify = (v) => {
197
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
198
+ if (v !== null && typeof v === 'object') {
199
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(',')}}`;
200
+ }
201
+ return JSON.stringify(v);
202
+ };
203
+
204
+ export const canonicalKindSerialization = (records, kind) => {
205
+ const lines = authoritativeOfKind(records, kind).map(stableStringify);
206
+ return lines.length === 0 ? '' : `${lines.join('\n')}\n`;
207
+ };