@lifeaitools/rdc-skills 0.24.1 → 0.24.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/git-sha.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "e04654eb140982dd3d79fe474a8a681c131e2e3d"
2
+ "sha": "c5d90e5aaa3925c071a380e1dd7d706d76d91bcd"
3
3
  }
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * run-evidence-gate.mjs — Truth Gate 3.0 Layer 2, the FUSED evidence primitive.
4
+ *
5
+ * Nidus / D4 principle: "you cannot submit evidence without running the test."
6
+ * This is ONE atomic call that (1) RUNS a verification command, (2) HASHES its
7
+ * captured output server-side, and (3) records the verdict TOGETHER with the
8
+ * hash and a timestamp. Evidence and execution are produced in the same call —
9
+ * an agent can never hand the gate a hash for a run that never happened.
10
+ *
11
+ * The output of runEvidenceGate() is the only legitimate shape of a
12
+ * machine-parseable `verification` artifact: it carries the exact command, the
13
+ * exit code, an SHA-256 of stdout+stderr, and a pass/fail verdict the CALLER
14
+ * did not author. The exit-gate's L2 verifier recognises this shape (and a few
15
+ * other captured-artifact shapes) and rejects anything that is bare prose.
16
+ *
17
+ * Pure-ish: it shells out to run the command but has no DB or network side
18
+ * effects, so it is unit-testable offline.
19
+ */
20
+ 'use strict';
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { createHash } from 'node:crypto';
24
+
25
+ /** Stable SHA-256 of a string (server-side hash — the caller cannot forge it). */
26
+ export function hashOutput(text) {
27
+ return createHash('sha256').update(String(text == null ? '' : text), 'utf8').digest('hex');
28
+ }
29
+
30
+ /**
31
+ * The discriminant marker stamped on every fused artifact. The exit-gate keys
32
+ * on this to recognise a real run-and-attest result vs. an agent-typed string.
33
+ */
34
+ export const EVIDENCE_KIND = 'run-evidence-gate/v1';
35
+
36
+ /**
37
+ * Run a verification command and atomically produce a hashed, verdicted
38
+ * evidence artifact. There is NO path to a verdict that skips the run: the
39
+ * verdict is derived from the actual exit code of the actual spawn.
40
+ *
41
+ * @param {object} spec
42
+ * @param {string} spec.command executable to run (e.g. "node", "npx")
43
+ * @param {string[]} [spec.args] argv for the command
44
+ * @param {string} [spec.cwd] working directory
45
+ * @param {object} [spec.env] extra env
46
+ * @param {number} [spec.timeoutMs] hard timeout (default 120s)
47
+ * @param {string} [spec.label] human label for the check
48
+ * @param {(spec)=>{status:number,stdout:string,stderr:string}} [runner]
49
+ * injectable runner — defaults to spawnSync. Lets tests drive a fake
50
+ * process WITHOUT removing the "must run" property (the runner is still
51
+ * invoked exactly once and its result is what the verdict is built from).
52
+ * @returns {object} a fused evidence artifact (see EVIDENCE_KIND).
53
+ */
54
+ export function runEvidenceGate(spec, runner) {
55
+ if (!spec || typeof spec !== 'object' || typeof spec.command !== 'string' || !spec.command) {
56
+ // Fail-closed: a malformed request can never produce a "pass".
57
+ return {
58
+ kind: EVIDENCE_KIND,
59
+ ran: false,
60
+ verdict: 'error',
61
+ reason: 'invalid-spec: command is required',
62
+ ts: new Date().toISOString(),
63
+ };
64
+ }
65
+
66
+ const exec = typeof runner === 'function' ? runner : defaultRunner;
67
+
68
+ let result;
69
+ try {
70
+ result = exec(spec);
71
+ } catch (e) {
72
+ // Spawn itself threw — fail-closed.
73
+ return {
74
+ kind: EVIDENCE_KIND,
75
+ ran: false,
76
+ verdict: 'error',
77
+ reason: `runner-threw: ${e && e.message ? e.message : String(e)}`,
78
+ command: renderCommand(spec),
79
+ ts: new Date().toISOString(),
80
+ };
81
+ }
82
+
83
+ // The runner MUST return a numeric status for the verdict to exist. No status
84
+ // (e.g. the process could not be spawned) => no run => fail-closed.
85
+ const status = result && typeof result.status === 'number' ? result.status : null;
86
+ const stdout = result && result.stdout != null ? String(result.stdout) : '';
87
+ const stderr = result && result.stderr != null ? String(result.stderr) : '';
88
+
89
+ if (status === null) {
90
+ return {
91
+ kind: EVIDENCE_KIND,
92
+ ran: false,
93
+ verdict: 'error',
94
+ reason: 'runner-produced-no-exit-status (process did not run)',
95
+ command: renderCommand(spec),
96
+ ts: new Date().toISOString(),
97
+ };
98
+ }
99
+
100
+ const combined = `EXIT:${status}\n--STDOUT--\n${stdout}\n--STDERR--\n${stderr}`;
101
+ return {
102
+ kind: EVIDENCE_KIND,
103
+ ran: true,
104
+ label: spec.label || null,
105
+ command: renderCommand(spec),
106
+ exit_code: status,
107
+ verdict: status === 0 ? 'pass' : 'fail',
108
+ output_sha256: hashOutput(combined),
109
+ output_bytes: Buffer.byteLength(combined, 'utf8'),
110
+ ts: new Date().toISOString(),
111
+ };
112
+ }
113
+
114
+ function renderCommand(spec) {
115
+ return [spec.command, ...(Array.isArray(spec.args) ? spec.args : [])].join(' ');
116
+ }
117
+
118
+ function defaultRunner(spec) {
119
+ const res = spawnSync(spec.command, Array.isArray(spec.args) ? spec.args : [], {
120
+ cwd: spec.cwd || process.cwd(),
121
+ env: { ...process.env, ...(spec.env || {}) },
122
+ encoding: 'utf8',
123
+ timeout: typeof spec.timeoutMs === 'number' ? spec.timeoutMs : 120000,
124
+ maxBuffer: 16 * 1024 * 1024,
125
+ });
126
+ return { status: res.status, stdout: res.stdout, stderr: res.stderr };
127
+ }
128
+
129
+ /**
130
+ * Is `v` a legitimate machine-parseable verification artifact (NOT prose)?
131
+ *
132
+ * Accepts, in order of strength:
133
+ * 1. A fused run-evidence-gate artifact (object or its JSON string) — strongest.
134
+ * 2. A captured-artifact OBJECT with a recognised machine shape:
135
+ * - { exit_code: <number> } (tsc / test-runner exit)
136
+ * - { http_status: <number> } (captured HTTP status)
137
+ * - { rowcount: <number> } / row_count (SQL rowcount)
138
+ * - { passed: <number>, ... } (test-runner JSON, e.g. vitest)
139
+ * (a JSON string of any of these is also accepted)
140
+ * REJECTS:
141
+ * - bare strings ("HTTP 200", "107 nodes", "works", "done") — proxy/prose.
142
+ * - objects with no machine field.
143
+ */
144
+ export function isMachineArtifact(v) {
145
+ if (v == null) return false;
146
+
147
+ // String input: only accepted if it parses to a recognised JSON artifact.
148
+ if (typeof v === 'string') {
149
+ const s = v.trim();
150
+ if (!(s.startsWith('{') || s.startsWith('['))) return false; // bare prose
151
+ let parsed;
152
+ try { parsed = JSON.parse(s); } catch { return false; }
153
+ return isMachineArtifact(parsed);
154
+ }
155
+
156
+ if (typeof v !== 'object') return false;
157
+
158
+ // 1. Fused artifact.
159
+ if (v.kind === EVIDENCE_KIND && v.ran === true && typeof v.output_sha256 === 'string') {
160
+ return true;
161
+ }
162
+
163
+ // 2. Recognised captured-artifact object shapes.
164
+ if (typeof v.exit_code === 'number') return true;
165
+ if (typeof v.http_status === 'number' || typeof v.httpStatus === 'number') return true;
166
+ if (typeof v.status_code === 'number' || typeof v.statusCode === 'number') return true;
167
+ if (typeof v.rowcount === 'number' || typeof v.row_count === 'number' || typeof v.rowCount === 'number') return true;
168
+ if (typeof v.passed === 'number' && (typeof v.failed === 'number' || typeof v.total === 'number')) return true;
169
+ if (typeof v.tsc_errors === 'number' || typeof v.tscErrors === 'number') return true;
170
+
171
+ return false;
172
+ }
173
+
174
+ /**
175
+ * Does `v` represent a verification whose OUTCOME is a PASS — not merely that it
176
+ * ran? This is the outcome gate that complements isMachineArtifact (the shape
177
+ * gate). A failing run (exit_code:1), an error HTTP status (500), a fused
178
+ * artifact with verdict:'fail', or a test artifact with failures must NOT be
179
+ * accepted as evidence of a passing verification.
180
+ *
181
+ * Returns true ONLY when the artifact is a recognised machine shape AND its
182
+ * outcome reads as a pass. Anything ambiguous or non-passing returns false.
183
+ *
184
+ * Pass rules (mirrors isMachineArtifact's accepted shapes):
185
+ * - fused run-evidence-gate/v1 → ran===true && verdict==='pass'
186
+ * - { exit_code } → exit_code === 0
187
+ * - { http_status|status_code }→ 200 <= s <= 399
188
+ * - { passed, failed } → failed === 0
189
+ * - { passed, total } → passed === total
190
+ * - { tsc_errors } → tsc_errors === 0
191
+ * - { rowcount } → a captured rowcount is presence-only evidence;
192
+ * any numeric rowcount counts as a pass.
193
+ */
194
+ export function isPassingArtifact(v) {
195
+ if (v == null) return false;
196
+
197
+ // String input: only accepted if it parses to a recognised JSON artifact.
198
+ if (typeof v === 'string') {
199
+ const s = v.trim();
200
+ if (!(s.startsWith('{') || s.startsWith('['))) return false; // bare prose
201
+ let parsed;
202
+ try { parsed = JSON.parse(s); } catch { return false; }
203
+ return isPassingArtifact(parsed);
204
+ }
205
+
206
+ if (typeof v !== 'object') return false;
207
+
208
+ // Must be a recognised machine shape first.
209
+ if (!isMachineArtifact(v)) return false;
210
+
211
+ // 1. Fused artifact — the verdict is authoritative.
212
+ if (v.kind === EVIDENCE_KIND) {
213
+ return v.ran === true && v.verdict === 'pass';
214
+ }
215
+
216
+ // 2. Captured-artifact shapes — read the outcome, not just the presence.
217
+ // A tsc/test error count is checked even alongside another field.
218
+ if (typeof v.tsc_errors === 'number') return v.tsc_errors === 0;
219
+ if (typeof v.tscErrors === 'number') return v.tscErrors === 0;
220
+
221
+ if (typeof v.exit_code === 'number') return v.exit_code === 0;
222
+
223
+ if (typeof v.http_status === 'number') return v.http_status >= 200 && v.http_status <= 399;
224
+ if (typeof v.httpStatus === 'number') return v.httpStatus >= 200 && v.httpStatus <= 399;
225
+ if (typeof v.status_code === 'number') return v.status_code >= 200 && v.status_code <= 399;
226
+ if (typeof v.statusCode === 'number') return v.statusCode >= 200 && v.statusCode <= 399;
227
+
228
+ if (typeof v.passed === 'number') {
229
+ if (typeof v.failed === 'number') return v.failed === 0;
230
+ if (typeof v.total === 'number') return v.passed === v.total;
231
+ }
232
+
233
+ if (typeof v.rowcount === 'number') return true;
234
+ if (typeof v.row_count === 'number') return true;
235
+ if (typeof v.rowCount === 'number') return true;
236
+
237
+ // Recognised shape but no readable outcome → not a pass (fail-closed).
238
+ return false;
239
+ }
240
+
241
+ export default { runEvidenceGate, hashOutput, isMachineArtifact, isPassingArtifact, EVIDENCE_KIND };
@@ -11,12 +11,21 @@
11
11
  const fs = require('fs');
12
12
  const os = require('os');
13
13
  const path = require('path');
14
+ const { execFileSync } = require('child_process');
14
15
  const hookLog = require('./hook-logger');
15
16
 
16
17
  const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
18
+ const FULL_SHA_RE = /^[0-9a-f]{40}$/i;
17
19
  const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
18
20
  const EVENT_LOG = path.join(os.homedir(), '.claude', 'work-item-checklist-events.jsonl');
19
21
 
22
+ // Truth Gate 3.0 — Layer 2 (FUSED evidence gate).
23
+ // Witness allow-list (D5 / SLSA: the doer cannot sign its own provenance).
24
+ const WITNESS_ALLOWLIST = new Set(['validator-rerun', 'ci', 'human-review']);
25
+ // Repo the gate runs `git` against. The work tree under verification is
26
+ // regen-root; overridable for tests via RDC_TRUTH_GATE_REPO.
27
+ const TRUTH_GATE_REPO = process.env.RDC_TRUTH_GATE_REPO || 'C:/Dev/regen-root';
28
+
20
29
  function readStdin() {
21
30
  return new Promise((resolve) => {
22
31
  let input = '';
@@ -208,6 +217,301 @@ function requiredItems(checklist) {
208
217
  : [];
209
218
  }
210
219
 
220
+ // ===========================================================================
221
+ // Truth Gate 3.0 — Layer 2 (FUSED evidence gate / freeze-the-leak)
222
+ //
223
+ // Asserts, server-checkable and FAIL-CLOSED, that a `done` close corresponds to
224
+ // reality. Baru-trap hardening (from the auditors' OWN false positives):
225
+ // - resolve commits by FULL 40-hex SHA only — never short-prefix compare
226
+ // (prefix collisions falsely flagged real work as fabricated).
227
+ // - locate claimed files WHOLE-REPO (`git log --all -- <path>`), never
228
+ // cited-path-only (wrong-path lookups falsely cried "absent").
229
+ // ===========================================================================
230
+
231
+ /**
232
+ * A Layer-2 denial. Thrown by `deny()` so verifyLayer2 short-circuits without
233
+ * coupling to process.exit — production translates it to block(), tests assert
234
+ * on `.reason`. This keeps the gate logic pure + unit-testable while staying
235
+ * FAIL-CLOSED at the process boundary.
236
+ */
237
+ class GateDenied extends Error {
238
+ constructor(reason, details) {
239
+ super(reason);
240
+ this.name = 'GateDenied';
241
+ this.reason = reason;
242
+ this.details = details || {};
243
+ }
244
+ }
245
+
246
+ function deny(reason, details) {
247
+ throw new GateDenied(reason, details);
248
+ }
249
+
250
+ /**
251
+ * Build the L1-captured SHA set bound to the item's ORIGINATING session, or
252
+ * DENY (throw GateDenied) when there is no originating session to bind to.
253
+ *
254
+ * FAIL-CLOSED: a null/empty originating session must NOT disable the per-session
255
+ * binding (the old `!originatingSession` short-circuit accepted ANY session's
256
+ * captured SHA — a fail-open laundering hole). With no session, there is no
257
+ * provenance to verify, so we DENY. Otherwise we keep ONLY rows whose
258
+ * session_id exactly equals the originating session.
259
+ *
260
+ * @param originatingSession string|null the item's session_id
261
+ * @param capturedRows Array<{sha, session_id}> from work_item_commits
262
+ * @returns Set<string> full-SHA (lowercased) set for this item+session
263
+ */
264
+ function buildCapturedShaSet(originatingSession, capturedRows) {
265
+ const sess = originatingSession || null;
266
+ if (!sess) {
267
+ deny(
268
+ 'L2: done rejected — cannot bind commit provenance: work item has no originating session. ' +
269
+ 'Without a session to bind captured SHAs to, the per-session commit binding cannot be verified; fail-closed.',
270
+ { originatingSession },
271
+ );
272
+ }
273
+ return new Set(
274
+ (Array.isArray(capturedRows) ? capturedRows : [])
275
+ .filter((r) => r && r.session_id === sess)
276
+ .map((r) => String((r && r.sha) || '').toLowerCase())
277
+ .filter((s) => FULL_SHA_RE.test(s)),
278
+ );
279
+ }
280
+
281
+ /** Run a git command in the truth-gate repo; returns trimmed stdout or null.
282
+ * stderr is captured (piped) so a failure carries a usable diagnostic instead
283
+ * of being swallowed. On failure with allowFail, returns null; otherwise the
284
+ * thrown error retains git's stderr in e.stderr / e.message. */
285
+ function git(args, { allowFail = false } = {}) {
286
+ try {
287
+ return execFileSync('git', args, {
288
+ cwd: TRUTH_GATE_REPO,
289
+ encoding: 'utf8',
290
+ stdio: ['ignore', 'pipe', 'pipe'],
291
+ maxBuffer: 16 * 1024 * 1024,
292
+ }).trim();
293
+ } catch (e) {
294
+ if (allowFail) return null;
295
+ throw e;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Verify, once at entry, that TRUTH_GATE_REPO is a real git work tree the gate
301
+ * can interrogate. If git is missing or the path is not a work tree, every
302
+ * downstream SHA/file check would silently mis-verify, so we fail-closed with a
303
+ * clear `truth-gate repo unavailable` block — distinct from a `ref not found`.
304
+ */
305
+ function assertGitRepoAvailable() {
306
+ let out;
307
+ try {
308
+ out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
309
+ cwd: TRUTH_GATE_REPO,
310
+ encoding: 'utf8',
311
+ stdio: ['ignore', 'pipe', 'pipe'],
312
+ maxBuffer: 1024 * 1024,
313
+ }).trim();
314
+ } catch (e) {
315
+ const detail = (e && (e.stderr || e.message)) ? String(e.stderr || e.message).trim().slice(0, 200) : 'git invocation failed';
316
+ deny(
317
+ 'L2: done rejected — truth-gate repo unavailable: cannot run git in "' + TRUTH_GATE_REPO + '" (' + detail + '). ' +
318
+ 'The gate cannot verify commit/file provenance without a working git tree; fail-closed.',
319
+ { repo: TRUTH_GATE_REPO },
320
+ );
321
+ }
322
+ if (out !== 'true') {
323
+ deny(
324
+ 'L2: done rejected — truth-gate repo unavailable: "' + TRUTH_GATE_REPO + '" is not a git work tree (rev-parse returned "' + out + '"). ' +
325
+ 'The gate cannot verify commit/file provenance; fail-closed.',
326
+ { repo: TRUTH_GATE_REPO },
327
+ );
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Resolve a commit ref to its FULL 40-char SHA, or null if it does not exist.
333
+ * Uses `rev-parse --verify <ref>^{commit}` so only real commit objects resolve.
334
+ * NEVER does a prefix/substring compare — the returned value is the canonical
335
+ * full SHA, and all equality downstream is full-SHA equality.
336
+ */
337
+ function resolveFullSha(ref) {
338
+ if (typeof ref !== 'string' || !ref.trim()) return null;
339
+ const full = git(['rev-parse', '--verify', '--quiet', `${ref.trim()}^{commit}`], { allowFail: true });
340
+ return full && FULL_SHA_RE.test(full) ? full.toLowerCase() : null;
341
+ }
342
+
343
+ /** Full set of file paths touched by a commit (`git show --stat`→ name-only). */
344
+ function filesInCommit(fullSha) {
345
+ const out = git(['show', '--no-renames', '--name-only', '--pretty=format:', fullSha], { allowFail: true });
346
+ if (out == null) return null;
347
+ return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean).map(normalizeRepoPath));
348
+ }
349
+
350
+ function normalizeRepoPath(p) {
351
+ return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
352
+ }
353
+
354
+ /**
355
+ * Whole-repo existence check for a claimed file. A file "exists for this repo"
356
+ * when EITHER it is tracked anywhere in history (`git log --all -- <path>`
357
+ * returns a commit) OR it is present on disk. This is deliberately broad to
358
+ * avoid the cited-path-only false "absent" verdict.
359
+ */
360
+ function fileKnownToRepo(repoPath) {
361
+ const p = normalizeRepoPath(repoPath);
362
+ if (!p) return false;
363
+ // On disk now?
364
+ try {
365
+ if (fs.existsSync(path.join(TRUTH_GATE_REPO, p))) return true;
366
+ } catch (_) { /* fall through to history */ }
367
+ // Tracked anywhere in history (whole-repo locator, never cited-path-only)?
368
+ const log = git(['log', '--all', '--oneline', '-1', '--', p], { allowFail: true });
369
+ return Boolean(log && log.length > 0);
370
+ }
371
+
372
+ /** Does the file exist on disk in the work tree right now? */
373
+ function fileOnDisk(repoPath) {
374
+ const p = normalizeRepoPath(repoPath);
375
+ if (!p) return false;
376
+ try { return fs.existsSync(path.join(TRUTH_GATE_REPO, p)); } catch { return false; }
377
+ }
378
+
379
+ let _evidenceLib = null;
380
+ /** Lazy-load the fused primitive's artifact discriminators (ESM from CJS).
381
+ * Uses a file:// URL so the dynamic import works on Windows absolute paths.
382
+ * Returns { isMachineArtifact, isPassingArtifact }. */
383
+ async function loadEvidenceLib() {
384
+ if (_evidenceLib) return _evidenceLib;
385
+ const { pathToFileURL } = require('url');
386
+ const libPath = path.join(__dirname, 'lib', 'run-evidence-gate.mjs');
387
+ const mod = await import(pathToFileURL(libPath).href);
388
+ _evidenceLib = { isMachineArtifact: mod.isMachineArtifact, isPassingArtifact: mod.isPassingArtifact };
389
+ return _evidenceLib;
390
+ }
391
+
392
+ /**
393
+ * Layer-2 verification of a `done` close against the real repo. Throws (caught
394
+ * by verifyDone → block) on any internal error so the gate is FAIL-CLOSED:
395
+ * an inability to verify is a DENY, never a silent pass.
396
+ *
397
+ * @param statusCall parsed update_work_item_status args (has .id, .actorSessionId)
398
+ * @param item the work_items row (has implementation_report)
399
+ * @param capturedShas Set<string> of FULL L1-captured SHAs for this item/session
400
+ */
401
+ async function verifyLayer2(statusCall, item, capturedShas) {
402
+ // Fail-closed: the gate's commit/file checks all shell out to git. If the
403
+ // truth-gate repo is not a usable git work tree, verify nothing — DENY.
404
+ assertGitRepoAvailable();
405
+
406
+ const post = item.implementation_report && item.implementation_report.codeflow_post;
407
+ if (!post || typeof post !== 'object') {
408
+ deny('L2: done rejected — implementation_report.codeflow_post is missing or not an object.', statusCall);
409
+ }
410
+
411
+ // (5) Witness allow-list — the doer cannot self-witness (D5 / SLSA).
412
+ const witness = String(post.witness || '').trim();
413
+ if (!WITNESS_ALLOWLIST.has(witness)) {
414
+ deny(
415
+ 'L2: done rejected — codeflow_post.witness must be one of {validator-rerun, ci, human-review}; got "' +
416
+ (witness || '<missing>') + '". ' +
417
+ 'The party that did the work cannot sign its own provenance (witness:"agent" is never accepted).',
418
+ { ...statusCall, witness },
419
+ );
420
+ }
421
+
422
+ // (1) Commit resolves (FULL SHA) AND was captured by L1 for this item/session.
423
+ const claimedCommit = post.commit;
424
+ const fullSha = resolveFullSha(claimedCommit);
425
+ if (!fullSha) {
426
+ deny(
427
+ 'L2: done rejected — codeflow_post.commit ("' + (claimedCommit || '<missing>') +
428
+ '") does not resolve to a real commit (git cat-file/rev-parse). Free-typed or wrong SHAs are rejected.',
429
+ { ...statusCall, claimedCommit },
430
+ );
431
+ }
432
+ if (!capturedShas.has(fullSha)) {
433
+ deny(
434
+ 'L2: done rejected — commit ' + fullSha + ' was not captured by Layer 1 for this work item + originating session. ' +
435
+ 'The exit gate only accepts a commit SHA the commit-hook recorded against this item (no agent-asserted SHAs). ' +
436
+ 'Captured SHAs for this item/session: ' + (capturedShas.size ? [...capturedShas].join(', ') : '(none)') + '.',
437
+ { ...statusCall, fullSha, capturedCount: capturedShas.size },
438
+ );
439
+ }
440
+
441
+ // (2)/(3) Every files_changed entry is in the commit AND exists on disk.
442
+ const filesChanged = Array.isArray(post.files_changed) ? post.files_changed : [];
443
+ if (filesChanged.length === 0) {
444
+ deny('L2: done rejected — codeflow_post.files_changed is empty; a closure must name the files it changed.', statusCall);
445
+ }
446
+ const commitFiles = filesInCommit(fullSha);
447
+ if (commitFiles == null) {
448
+ deny('L2: done rejected — could not read the file list of commit ' + fullSha + ' (git show failed).', statusCall);
449
+ }
450
+ for (const raw of filesChanged) {
451
+ const p = normalizeRepoPath(raw);
452
+ if (!p) {
453
+ deny('L2: done rejected — a files_changed entry is empty/blank.', { ...statusCall, raw });
454
+ }
455
+ // In the commit? (whole-repo lookup is implicit — commitFiles is the full
456
+ // name-only set of the resolved commit, not a cited-path filter.)
457
+ if (!commitFiles.has(p)) {
458
+ deny(
459
+ 'L2: done rejected — file "' + p + '" is NOT among the files changed by commit ' + fullSha +
460
+ ' (git show --stat). files_changed must match the commit\'s actual contents.',
461
+ { ...statusCall, file: p, fullSha },
462
+ );
463
+ }
464
+ // On disk now?
465
+ if (!fileOnDisk(p)) {
466
+ deny(
467
+ 'L2: done rejected — claimed file "' + p + '" does not exist on disk in the work tree. ' +
468
+ 'A deliverable that is not present is not done.',
469
+ { ...statusCall, file: p },
470
+ );
471
+ }
472
+ // Whole-repo sanity (defense in depth; should always hold if on disk).
473
+ if (!fileKnownToRepo(p)) {
474
+ deny(
475
+ 'L2: done rejected — claimed file "' + p + '" is unknown to the repo (not on disk and not in history).',
476
+ { ...statusCall, file: p },
477
+ );
478
+ }
479
+ }
480
+
481
+ // (4) Every verification entry is a machine-parseable artifact, not prose,
482
+ // AND its OUTCOME is a PASS — not merely that it RAN. A failing run
483
+ // (exit_code:1), an error HTTP status (500), a fused verdict:'fail', or a
484
+ // test artifact with failures must DENY: "ran" is not "passed".
485
+ const { isMachineArtifact, isPassingArtifact } = await loadEvidenceLib();
486
+ const verifications = Array.isArray(post.verification) ? post.verification : [];
487
+ if (verifications.length === 0) {
488
+ deny('L2: done rejected — codeflow_post.verification is empty; closure needs a captured verification artifact.', statusCall);
489
+ }
490
+ for (const v of verifications) {
491
+ // 4a. Shape gate — it must be a captured machine artifact, not prose.
492
+ if (!isMachineArtifact(v)) {
493
+ const shown = typeof v === 'string' ? v.slice(0, 60) : JSON.stringify(v).slice(0, 80);
494
+ deny(
495
+ 'L2: done rejected — verification entry is prose/proxy, not a captured artifact: "' + shown + '". ' +
496
+ 'Each verification must be a run-evidence-gate result or a machine shape ' +
497
+ '({exit_code|http_status|rowcount|passed/total}). Strings like "HTTP 200" / "works" are rejected.',
498
+ { ...statusCall, verification: shown },
499
+ );
500
+ }
501
+ // 4b. Outcome gate — the captured artifact must read as a PASS.
502
+ if (!isPassingArtifact(v)) {
503
+ const shown = typeof v === 'string' ? v.slice(0, 80) : JSON.stringify(v).slice(0, 120);
504
+ deny(
505
+ 'L2: done rejected — verification-not-passing: the captured artifact ran but did NOT pass: "' + shown + '". ' +
506
+ 'A closure requires a PASSING verification (fused verdict:"pass" / exit_code:0 / http 2xx-3xx / ' +
507
+ 'failed:0 / passed===total / tsc_errors:0). A failing or error run is not evidence of done.',
508
+ { ...statusCall, verification: shown },
509
+ );
510
+ }
511
+ }
512
+ // No denial thrown => Layer-2 verification PASSED.
513
+ }
514
+
211
515
  async function verifyDone(statusCall, blob) {
212
516
  if (!statusCall.id) block('`update_work_item_status(..., done)` must include a work item UUID.', statusCall);
213
517
  if (!statusCall.actorSessionId || !statusCall.actorRole) {
@@ -222,9 +526,11 @@ async function verifyDone(statusCall, blob) {
222
526
 
223
527
  let rows;
224
528
  let events;
529
+ let capturedRows;
225
530
  try {
226
- rows = await supabaseGet(`work_items?id=eq.${encodeURIComponent(statusCall.id)}&select=id,status,item_type,implementation_report,checklist`);
531
+ rows = await supabaseGet(`work_items?id=eq.${encodeURIComponent(statusCall.id)}&select=id,status,item_type,session_id,implementation_report,checklist`);
227
532
  events = await supabaseGet(`work_item_checklist_events?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=item_id,checked,actor_session_id,actor_role,created_at&order=created_at.desc&limit=200`);
533
+ capturedRows = await supabaseGet(`work_item_commits?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=sha,session_id`);
228
534
  } catch (e) {
229
535
  block(`Cannot live-verify work item exit gate: ${e.message}. Do not close the item until clauth/Supabase verification is available.`, statusCall);
230
536
  }
@@ -256,6 +562,22 @@ async function verifyDone(statusCall, blob) {
256
562
  { ...statusCall, supervisorReticks: supervisorReticks.map((e) => e.item_id) },
257
563
  );
258
564
  }
565
+
566
+ // --- Truth Gate 3.0 Layer 2 — FUSED evidence gate (freeze-the-leak) ---------
567
+ // The L1-captured SHA set is restricted to the item's ORIGINATING session
568
+ // (the session that ticked the checklist) so a SHA captured by some other
569
+ // session against this item cannot launder a fabricated close. A null
570
+ // originating session is fail-closed (DENY) inside buildCapturedShaSet.
571
+ try {
572
+ const capturedShas = buildCapturedShaSet(item.session_id || null, capturedRows);
573
+ await verifyLayer2(statusCall, item, capturedShas);
574
+ } catch (e) {
575
+ if (e instanceof GateDenied) {
576
+ block(e.reason, e.details); // translate the L2 denial into a hard block
577
+ }
578
+ // Any OTHER error during L2 is an inability to verify => FAIL-CLOSED.
579
+ block('L2: done rejected — Layer-2 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
580
+ }
259
581
  }
260
582
 
261
583
  function validateTick(tick, rawTool) {
@@ -283,6 +605,20 @@ async function main() {
283
605
  const statusCall = extractStatusCall(blob);
284
606
  if (!statusCall) pass({ reason: 'no-status-call' });
285
607
 
608
+ // Ambiguous-parse fail-closed: the tool blob references update_work_item_status
609
+ // AND contains a 'done' literal, but we could not extract a usable id/status.
610
+ // A done-close we cannot parse must NOT slip through as a pass — block.
611
+ if (/update_work_item_status/i.test(blob) && /\bdone\b/i.test(blob)) {
612
+ if (!statusCall.id || !statusCall.status) {
613
+ block(
614
+ 'Work item exit gate could not parse the `update_work_item_status` call that references `done` ' +
615
+ '(missing ' + (!statusCall.id ? 'work item id' : 'status') + '). ' +
616
+ 'Ambiguous done-close parses are fail-closed; re-issue the call in the documented 5-argument RPC shape.',
617
+ statusCall,
618
+ );
619
+ }
620
+ }
621
+
286
622
  const status = String(statusCall.status || '').toLowerCase();
287
623
  if (status === 'review' && (!statusCall.actorSessionId || statusCall.actorRole !== 'agent')) {
288
624
  block('Implementation agents must move completed work to `review` with `p_actor_session_id` and `p_actor_role := agent`.', statusCall);
@@ -294,4 +630,22 @@ async function main() {
294
630
  pass({ status });
295
631
  }
296
632
 
297
- main().catch((e) => block(`Exit gate crashed: ${e.message}`));
633
+ // Run as a hook; export the Layer-2 internals when required by a test.
634
+ if (require.main === module) {
635
+ main().catch((e) => block(`Exit gate crashed: ${e.message}`));
636
+ } else {
637
+ module.exports = {
638
+ GateDenied,
639
+ deny,
640
+ verifyLayer2,
641
+ resolveFullSha,
642
+ filesInCommit,
643
+ fileOnDisk,
644
+ fileKnownToRepo,
645
+ normalizeRepoPath,
646
+ loadEvidenceLib,
647
+ assertGitRepoAvailable,
648
+ buildCapturedShaSet,
649
+ WITNESS_ALLOWLIST,
650
+ };
651
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.24.1",
3
+ "version": "0.24.3",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -34,6 +34,7 @@
34
34
  "validate": "node tests/validate-skills.js",
35
35
  "rdc-design": "node scripts/rdc-design-cli.mjs",
36
36
  "test:hooks": "node scripts/test-rdc-hooks.mjs",
37
+ "test:truth-gate": "node tests/run-evidence-gate.test.mjs && node tests/work-item-exit-gate-l2.test.mjs && node tests/require-work-item-on-commit.test.mjs",
37
38
  "test:mcp": "node tests/mcp.test.mjs",
38
39
  "test:mcp:remote": "node tests/mcp.test.mjs --remote",
39
40
  "mcp": "node bin/rdc-skills-mcp.mjs",
@@ -288,6 +288,16 @@ Read the task title and description, then:
288
288
  Without `max_turns: 70`, agents hit the default turn cap mid-task and stop.
289
289
  `isolation: "worktree"` gives each agent its own git worktree and branch — eliminates push race conditions and index lock contention when multiple agents commit in parallel. The supervisor merges worktree branches after each wave (Step 9).
290
290
 
291
+ ### ✅ PREVENTION FIRST — create worktrees fresh off origin/develop (kills stale-base by construction)
292
+ The repeated stale-base failures below come from creating worktrees off a
293
+ local/old ref. Eliminate the failure mode at the source: ALWAYS create agent
294
+ worktrees with a fresh fetch + `origin/develop` base, e.g.
295
+ `git fetch origin develop && git worktree add <dir> -b <branch> origin/develop`
296
+ — or use the canonical launcher `node scripts/wt.mjs add <name>`, which does
297
+ exactly that. A worktree cut from `origin/develop` HEAD **cannot** be stale.
298
+ The HARD GATE below remains as the blocking backstop (detection), but
299
+ construction-from-`origin/develop` is the primary defense.
300
+
291
301
  ### ⛔ HARD GATE — Worktree base MUST equal develop HEAD (blocking, not advisory)
292
302
  The worktree-isolation harness has shipped worktrees pinned to a STALE base
293
303
  commit (lessons 2026-06-10-build-worktree-stale-base, 2026-06-11-build-worktree-stale-base,
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Truth Gate 3.0 — fused run-evidence-gate primitive tests.
4
+ *
5
+ * Proves the Nidus property: evidence cannot exist without a run.
6
+ * - a verdict is emitted ONLY after the command runs (no run => no pass).
7
+ * - the output hash is server-side and derived from the actual run.
8
+ * - isMachineArtifact rejects prose and accepts captured machine shapes.
9
+ *
10
+ * Run: node tests/run-evidence-gate.test.mjs
11
+ */
12
+ import { resolve, dirname, join } from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const LIB = pathToFileURL(join(resolve(__dirname, '..'), 'hooks', 'lib', 'run-evidence-gate.mjs')).href;
17
+ const { runEvidenceGate, isMachineArtifact, hashOutput, EVIDENCE_KIND } = await import(LIB);
18
+
19
+ const failures = [];
20
+ function assert(name, cond, detail = '') {
21
+ if (!cond) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
22
+ else process.stdout.write(` ok ${name}\n`);
23
+ }
24
+
25
+ // --- 1. A passing run produces verdict:'pass' WITH a hash, only after running.
26
+ let ranCount = 0;
27
+ const passArt = runEvidenceGate(
28
+ { command: 'noop', args: ['x'], label: 'unit-pass' },
29
+ () => { ranCount += 1; return { status: 0, stdout: 'ok', stderr: '' }; },
30
+ );
31
+ assert('runner invoked exactly once', ranCount === 1, String(ranCount));
32
+ assert('pass: kind stamped', passArt.kind === EVIDENCE_KIND);
33
+ assert('pass: ran=true', passArt.ran === true);
34
+ assert('pass: verdict=pass', passArt.verdict === 'pass', passArt.verdict);
35
+ assert('pass: output_sha256 present', typeof passArt.output_sha256 === 'string' && passArt.output_sha256.length === 64, passArt.output_sha256);
36
+
37
+ // --- 2. A failing run -> verdict:'fail', never silently pass.
38
+ const failArt = runEvidenceGate(
39
+ { command: 'noop' },
40
+ () => ({ status: 7, stdout: '', stderr: 'boom' }),
41
+ );
42
+ assert('fail: verdict=fail', failArt.verdict === 'fail', failArt.verdict);
43
+ assert('fail: exit_code preserved', failArt.exit_code === 7, String(failArt.exit_code));
44
+
45
+ // --- 3. NO RUN -> NO verdict (fail-closed). Invalid spec.
46
+ const noCmd = runEvidenceGate({});
47
+ assert('no-command: ran=false', noCmd.ran === false);
48
+ assert('no-command: verdict=error (never pass)', noCmd.verdict === 'error', noCmd.verdict);
49
+
50
+ // --- 4. Runner that yields no exit status -> cannot have run -> error.
51
+ const noStatus = runEvidenceGate({ command: 'noop' }, () => ({ status: null, stdout: '', stderr: '' }));
52
+ assert('no-status: ran=false', noStatus.ran === false);
53
+ assert('no-status: verdict=error', noStatus.verdict === 'error', noStatus.verdict);
54
+
55
+ // --- 5. Runner throws -> fail-closed error, never pass.
56
+ const threw = runEvidenceGate({ command: 'noop' }, () => { throw new Error('spawn failed'); });
57
+ assert('runner-throws: ran=false', threw.ran === false);
58
+ assert('runner-throws: verdict=error', threw.verdict === 'error', threw.verdict);
59
+
60
+ // --- 6. Hash is deterministic + derived from the captured combined output.
61
+ assert('hash deterministic', hashOutput('abc') === hashOutput('abc'));
62
+ assert('hash differs by content', hashOutput('abc') !== hashOutput('abd'));
63
+
64
+ // --- 7. isMachineArtifact discrimination.
65
+ assert('reject bare prose "HTTP 200"', isMachineArtifact('HTTP 200') === false);
66
+ assert('reject bare prose "works"', isMachineArtifact('works') === false);
67
+ assert('reject "107 nodes"', isMachineArtifact('107 nodes') === false);
68
+ assert('accept fused artifact', isMachineArtifact(passArt) === true);
69
+ assert('accept {exit_code}', isMachineArtifact({ exit_code: 0 }) === true);
70
+ assert('accept {http_status}', isMachineArtifact({ http_status: 200 }) === true);
71
+ assert('accept {rowcount}', isMachineArtifact({ rowcount: 12 }) === true);
72
+ assert('accept {passed,total}', isMachineArtifact({ passed: 5, total: 5 }) === true);
73
+ assert('accept JSON string of machine shape', isMachineArtifact('{"exit_code":0}') === true);
74
+ assert('reject plain object', isMachineArtifact({ note: 'done' }) === false);
75
+ assert('reject null', isMachineArtifact(null) === false);
76
+
77
+ if (failures.length > 0) {
78
+ console.error('\nrun-evidence-gate tests — FAIL\n');
79
+ for (const f of failures) console.error(` - ${f}`);
80
+ process.exit(1);
81
+ }
82
+ console.log('\nrun-evidence-gate tests — PASS');
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Truth Gate 3.0 — Layer 2 (FUSED evidence gate) tests.
4
+ *
5
+ * Exercises EVERY rejection branch of work-item-exit-gate.js verifyLayer2 plus
6
+ * the fully-valid close, against a REAL throwaway git repo (no live DB). The
7
+ * gate's git/disk checks run against RDC_TRUTH_GATE_REPO, which we point at the
8
+ * throwaway repo. verifyLayer2 throws GateDenied on rejection; we assert on the
9
+ * thrown reason. A fully-valid close throws nothing.
10
+ *
11
+ * Branches proven:
12
+ * 1. commit-not-resolving (free-typed bad SHA) -> DENY
13
+ * 2. commit-not-captured (real SHA, not in L1 set) -> DENY
14
+ * 3. files ∉ commit -> DENY
15
+ * 4. file not on disk (in commit, deleted from tree) -> DENY
16
+ * 5. prose verification ("HTTP 200") -> DENY
17
+ * 6. witness:"agent" -> DENY
18
+ * 7. fully-valid close (real SHA captured, files in -> ALLOW (no throw)
19
+ * commit + on disk, machine artifact, valid witness)
20
+ * 8. fail-closed: any internal error during L2 -> DENY (via main wrapper)
21
+ *
22
+ * Also a fused-primitive assertion: run_evidence_gate emits a verdict ONLY
23
+ * after running the command (no verdict without a run).
24
+ *
25
+ * Run: node tests/work-item-exit-gate-l2.test.mjs
26
+ */
27
+ import { mkdtempSync, rmSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join, resolve, dirname } from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import { execFileSync } from 'node:child_process';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url));
35
+ const REPO_ROOT = resolve(__dirname, '..');
36
+ const HOOK = join(REPO_ROOT, 'hooks', 'work-item-exit-gate.js');
37
+ const GATE_LIB = pathToFileURL(join(REPO_ROOT, 'hooks', 'lib', 'run-evidence-gate.mjs')).href;
38
+
39
+ const failures = [];
40
+ function assert(name, condition, detail = '') {
41
+ if (!condition) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
42
+ else process.stdout.write(` ok ${name}\n`);
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Build a throwaway git repo: commit two files, capture the FULL HEAD sha.
47
+ // ---------------------------------------------------------------------------
48
+ const repo = mkdtempSync(join(tmpdir(), 'l2-repo-'));
49
+ const g = (...a) => execFileSync('git', a, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
50
+ g('init', '-q');
51
+ g('config', 'user.email', 'test@example.com');
52
+ g('config', 'user.name', 'Test');
53
+ writeFileSync(join(repo, 'a.txt'), 'alpha\n');
54
+ writeFileSync(join(repo, 'b.txt'), 'beta\n');
55
+ g('add', 'a.txt', 'b.txt');
56
+ g('commit', '-q', '-m', 'feat: seed');
57
+ const HEAD = g('rev-parse', 'HEAD').trim(); // real FULL 40-hex SHA
58
+
59
+ // CRITICAL: set the repo the gate runs git against BEFORE requiring the hook,
60
+ // because TRUTH_GATE_REPO is captured at module load.
61
+ process.env.RDC_TRUTH_GATE_REPO = repo;
62
+ const require = createRequire(import.meta.url);
63
+ const gate = require(HOOK);
64
+
65
+ const SESS = 'sess-l2-origin';
66
+ // Build an `item` row shaped like the work_items SELECT the gate reads.
67
+ function makeItem(post) {
68
+ return {
69
+ id: '11111111-2222-3333-4444-555555555555',
70
+ item_type: 'task',
71
+ status: 'review',
72
+ session_id: SESS,
73
+ implementation_report: { codeflow_post: post },
74
+ };
75
+ }
76
+ const statusCall = { id: '11111111-2222-3333-4444-555555555555', actorSessionId: 'validator-x', actorRole: 'validator' };
77
+
78
+ // A machine-parseable verification artifact (exit-code shape).
79
+ const MACHINE_VERIF = { exit_code: 0, label: 'tsc' };
80
+
81
+ // Helper: run verifyLayer2 and return the GateDenied reason, or null if it passed.
82
+ async function runL2(post, capturedShas) {
83
+ try {
84
+ await gate.verifyLayer2(statusCall, makeItem(post), capturedShas);
85
+ return null; // ALLOW
86
+ } catch (e) {
87
+ if (e instanceof gate.GateDenied) return e.reason;
88
+ throw e; // unexpected internal error — surface it
89
+ }
90
+ }
91
+
92
+ const CAPTURED = new Set([HEAD.toLowerCase()]); // L1-captured set for this item/session
93
+
94
+ // ---------------------------------------------------------------------------
95
+ await (async () => {
96
+ // 1. commit does NOT resolve (free-typed bad SHA)
97
+ {
98
+ const reason = await runL2({
99
+ commit: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
100
+ files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
101
+ }, CAPTURED);
102
+ assert('1. free-typed/bad SHA -> DENY', reason && /does not resolve to a real commit/.test(reason), reason || 'no denial');
103
+ }
104
+
105
+ // 2. commit resolves but was NOT captured by L1 for this item/session
106
+ {
107
+ const reason = await runL2({
108
+ commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
109
+ }, new Set()); // empty captured set
110
+ assert('2. commit not captured by L1 -> DENY', reason && /was not captured by Layer 1/.test(reason), reason || 'no denial');
111
+ }
112
+
113
+ // 3. files_changed entry NOT in the commit
114
+ {
115
+ const reason = await runL2({
116
+ commit: HEAD, files_changed: ['nonexistent-in-commit.txt'], verification: [MACHINE_VERIF], witness: 'ci',
117
+ }, CAPTURED);
118
+ assert('3. files not in commit -> DENY', reason && /is NOT among the files changed by commit/.test(reason), reason || 'no denial');
119
+ }
120
+
121
+ // 4. file is in the commit but NOT on disk (delete it from the work tree)
122
+ {
123
+ unlinkSync(join(repo, 'b.txt')); // b.txt is in the commit, now gone from disk
124
+ const reason = await runL2({
125
+ commit: HEAD, files_changed: ['b.txt'], verification: [MACHINE_VERIF], witness: 'human-review',
126
+ }, CAPTURED);
127
+ assert('4. file not on disk -> DENY', reason && /does not exist on disk/.test(reason), reason || 'no denial');
128
+ writeFileSync(join(repo, 'b.txt'), 'beta\n'); // restore for later valid-close test
129
+ }
130
+
131
+ // 5. prose / proxy verification ("HTTP 200")
132
+ {
133
+ const reason = await runL2({
134
+ commit: HEAD, files_changed: ['a.txt'], verification: ['HTTP 200'], witness: 'validator-rerun',
135
+ }, CAPTURED);
136
+ assert('5. prose verification -> DENY', reason && /prose\/proxy, not a captured artifact/.test(reason), reason || 'no denial');
137
+ }
138
+
139
+ // 6. witness:"agent" (self-witness)
140
+ {
141
+ const reason = await runL2({
142
+ commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'agent',
143
+ }, CAPTURED);
144
+ assert('6. witness:agent -> DENY', reason && /witness must be one of/.test(reason), reason || 'no denial');
145
+ }
146
+
147
+ // 7. fully-valid close -> ALLOW (no throw)
148
+ {
149
+ const reason = await runL2({
150
+ commit: HEAD, files_changed: ['a.txt', 'b.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
151
+ }, CAPTURED);
152
+ assert('7. fully-valid close -> ALLOW', reason === null, `unexpected denial: ${reason}`);
153
+ }
154
+
155
+ // 7b. fully-valid close with a REAL fused run-evidence-gate artifact as verification
156
+ {
157
+ const lib = await import(GATE_LIB);
158
+ const fused = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(0)'], label: 'fused-pass' });
159
+ const reason = await runL2({
160
+ commit: HEAD, files_changed: ['a.txt'], verification: [fused], witness: 'ci',
161
+ }, CAPTURED);
162
+ assert('7b. fused artifact verification -> ALLOW', reason === null, `unexpected denial: ${reason}`);
163
+ assert('7b. fused artifact has verdict only after run', fused.ran === true && fused.verdict === 'pass' && typeof fused.output_sha256 === 'string', JSON.stringify(fused));
164
+ }
165
+
166
+ // 8. FUSED primitive: no verdict possible without a run (invalid spec -> error, ran:false)
167
+ {
168
+ const lib = await import(GATE_LIB);
169
+ const bad = lib.runEvidenceGate({}); // no command -> cannot run
170
+ assert('8. fused: no run -> no pass verdict (fail-closed)', bad.ran === false && bad.verdict === 'error', JSON.stringify(bad));
171
+ // and a run that fails yields verdict:'fail', never silently 'pass'
172
+ const failRun = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(2)'] });
173
+ assert('8. fused: failing run -> verdict fail', failRun.ran === true && failRun.verdict === 'fail' && failRun.exit_code === 2, JSON.stringify(failRun));
174
+ }
175
+
176
+ // 9. FAIL-CLOSED contract: empty files_changed / empty verification both DENY
177
+ {
178
+ const r1 = await runL2({ commit: HEAD, files_changed: [], verification: [MACHINE_VERIF], witness: 'ci' }, CAPTURED);
179
+ assert('9a. empty files_changed -> DENY', r1 && /files_changed is empty/.test(r1), r1 || 'no denial');
180
+ const r2 = await runL2({ commit: HEAD, files_changed: ['a.txt'], verification: [], witness: 'ci' }, CAPTURED);
181
+ assert('9b. empty verification -> DENY', r2 && /verification is empty/.test(r2), r2 || 'no denial');
182
+ const r3 = await runL2({ commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF] }, CAPTURED); // no witness
183
+ assert('9c. missing witness -> DENY', r3 && /witness must be one of/.test(r3), r3 || 'no denial');
184
+ }
185
+
186
+ // 10. Baru-trap: a SHORT prefix of a real commit must NOT resolve-and-pass as
187
+ // the captured full SHA. We claim a 8-char prefix; even though it resolves to
188
+ // the same commit, the captured set holds the FULL sha, and the gate compares
189
+ // full-to-full, so a prefix that git expands still equals HEAD -> captured.
190
+ // The hardening we prove: the gate stores/compares the FULL resolved sha, so a
191
+ // WRONG short prefix (one that resolves to a DIFFERENT/absent commit) is caught
192
+ // by branch 1/2 above. Here we assert the FULL-sha resolution itself:
193
+ {
194
+ const full = gate.resolveFullSha(HEAD.slice(0, 8)); // valid short prefix of HEAD
195
+ assert('10. resolveFullSha expands a valid prefix to the FULL 40-hex sha',
196
+ full === HEAD.toLowerCase() && /^[0-9a-f]{40}$/.test(full), full || 'null');
197
+ const none = gate.resolveFullSha('00000000'); // prefix of no commit
198
+ assert('10. resolveFullSha returns null for a non-existent prefix', none === null, none || 'not-null');
199
+ }
200
+
201
+ // -------------------------------------------------------------------------
202
+ // Fix 1 — OUTCOME GATE: a verification that RAN but did NOT pass must DENY.
203
+ // isMachineArtifact passes (correct shape) but isPassingArtifact must fail.
204
+ // -------------------------------------------------------------------------
205
+ {
206
+ // 11a. exit_code:1 (a real machine shape, but a FAILING run) -> DENY
207
+ const r1 = await runL2({
208
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ exit_code: 1 }], witness: 'validator-rerun',
209
+ }, CAPTURED);
210
+ assert('11a. verification exit_code:1 -> DENY (verification-not-passing)',
211
+ r1 && /verification-not-passing/.test(r1), r1 || 'no denial');
212
+
213
+ // 11b. fused run-evidence-gate artifact with verdict:'fail' -> DENY
214
+ const lib = await import(GATE_LIB);
215
+ const fusedFail = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(1)'], label: 'fused-fail' });
216
+ assert('11b. fused failing run has verdict:fail', fusedFail.ran === true && fusedFail.verdict === 'fail', JSON.stringify(fusedFail));
217
+ const r2 = await runL2({
218
+ commit: HEAD, files_changed: ['a.txt'], verification: [fusedFail], witness: 'ci',
219
+ }, CAPTURED);
220
+ assert('11b. fused verdict:fail -> DENY (verification-not-passing)',
221
+ r2 && /verification-not-passing/.test(r2), r2 || 'no denial');
222
+
223
+ // 11c. http_status:500 (error status) -> DENY
224
+ const r3 = await runL2({
225
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ http_status: 500 }], witness: 'human-review',
226
+ }, CAPTURED);
227
+ assert('11c. verification http_status:500 -> DENY (verification-not-passing)',
228
+ r3 && /verification-not-passing/.test(r3), r3 || 'no denial');
229
+
230
+ // 11d. failed test-runner JSON ({passed:3, failed:2}) -> DENY
231
+ const r4 = await runL2({
232
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ passed: 3, failed: 2 }], witness: 'ci',
233
+ }, CAPTURED);
234
+ assert('11d. verification {passed:3,failed:2} -> DENY (verification-not-passing)',
235
+ r4 && /verification-not-passing/.test(r4), r4 || 'no denial');
236
+
237
+ // 11e. tsc_errors:4 -> DENY
238
+ const r5 = await runL2({
239
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ tsc_errors: 4 }], witness: 'ci',
240
+ }, CAPTURED);
241
+ assert('11e. verification tsc_errors:4 -> DENY (verification-not-passing)',
242
+ r5 && /verification-not-passing/.test(r5), r5 || 'no denial');
243
+
244
+ // 11f. PASSING shapes still ALLOW (regression guard for the outcome gate).
245
+ const okExit = await runL2({
246
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ exit_code: 0 }], witness: 'validator-rerun',
247
+ }, CAPTURED);
248
+ assert('11f. exit_code:0 still ALLOWs', okExit === null, `unexpected denial: ${okExit}`);
249
+ const okHttp = await runL2({
250
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ http_status: 200 }], witness: 'ci',
251
+ }, CAPTURED);
252
+ assert('11f. http_status:200 still ALLOWs', okHttp === null, `unexpected denial: ${okHttp}`);
253
+ const okTests = await runL2({
254
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ passed: 5, total: 5 }], witness: 'ci',
255
+ }, CAPTURED);
256
+ assert('11f. {passed:5,total:5} still ALLOWs', okTests === null, `unexpected denial: ${okTests}`);
257
+ }
258
+
259
+ // 11g. isPassingArtifact unit checks (direct, lib-level).
260
+ {
261
+ const lib = await import(GATE_LIB);
262
+ const { isPassingArtifact } = lib;
263
+ assert('11g. isPassingArtifact rejects {exit_code:1}', isPassingArtifact({ exit_code: 1 }) === false);
264
+ assert('11g. isPassingArtifact accepts {exit_code:0}', isPassingArtifact({ exit_code: 0 }) === true);
265
+ assert('11g. isPassingArtifact rejects {http_status:500}', isPassingArtifact({ http_status: 500 }) === false);
266
+ assert('11g. isPassingArtifact accepts {http_status:204}', isPassingArtifact({ http_status: 204 }) === true);
267
+ assert('11g. isPassingArtifact rejects {passed:1,failed:1}', isPassingArtifact({ passed: 1, failed: 1 }) === false);
268
+ assert('11g. isPassingArtifact accepts {passed:5,total:5}', isPassingArtifact({ passed: 5, total: 5 }) === true);
269
+ assert('11g. isPassingArtifact rejects {passed:4,total:5}', isPassingArtifact({ passed: 4, total: 5 }) === false);
270
+ assert('11g. isPassingArtifact rejects prose "HTTP 200"', isPassingArtifact('HTTP 200') === false);
271
+ const fp = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(0)'] });
272
+ const ff = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(1)'] });
273
+ assert('11g. isPassingArtifact accepts fused verdict:pass', isPassingArtifact(fp) === true);
274
+ assert('11g. isPassingArtifact rejects fused verdict:fail', isPassingArtifact(ff) === false);
275
+ }
276
+
277
+ // -------------------------------------------------------------------------
278
+ // Fix 2 — NULL ORIGINATING SESSION must be FAIL-CLOSED. A null session must
279
+ // NOT disable the per-session commit binding (the old `!originatingSession`
280
+ // short-circuit accepted ANY session's captured SHA). buildCapturedShaSet
281
+ // DENIES when the originating session is null/empty.
282
+ // -------------------------------------------------------------------------
283
+ {
284
+ const otherSessionRows = [{ sha: HEAD.toLowerCase(), session_id: 'some-OTHER-session' }];
285
+
286
+ // 12a. session_id=null + a SHA captured by a DIFFERENT session -> DENY
287
+ let denied = null;
288
+ try {
289
+ gate.buildCapturedShaSet(null, otherSessionRows);
290
+ } catch (e) {
291
+ if (e instanceof gate.GateDenied) denied = e.reason; else throw e;
292
+ }
293
+ assert('12a. null originating session -> DENY (no provenance to bind)',
294
+ denied && /no originating session/.test(denied), denied || 'no denial');
295
+
296
+ // 12b. empty-string session is treated the same (fail-closed) -> DENY
297
+ let denied2 = null;
298
+ try {
299
+ gate.buildCapturedShaSet('', otherSessionRows);
300
+ } catch (e) {
301
+ if (e instanceof gate.GateDenied) denied2 = e.reason; else throw e;
302
+ }
303
+ assert('12b. empty originating session -> DENY', denied2 && /no originating session/.test(denied2), denied2 || 'no denial');
304
+
305
+ // 12c. with a real originating session, ONLY that session's SHAs are kept;
306
+ // a DIFFERENT session's captured SHA is excluded (not laundered in).
307
+ const mixed = [
308
+ { sha: HEAD.toLowerCase(), session_id: 'some-OTHER-session' },
309
+ { sha: 'a'.repeat(40), session_id: SESS },
310
+ ];
311
+ const set = gate.buildCapturedShaSet(SESS, mixed);
312
+ assert('12c. only originating-session SHAs bound', set.has('a'.repeat(40)) && !set.has(HEAD.toLowerCase()),
313
+ `set=${[...set].join(',')}`);
314
+
315
+ // 12d. end-to-end consequence: null session + DIFFERENT-session SHA means
316
+ // the SHA is never bound, so even a structurally valid post is denied.
317
+ // (Drive verifyLayer2 with the EMPTY set buildCapturedShaSet would have
318
+ // produced were the session real-but-mismatched; null short-circuits
319
+ // earlier, but this proves a cross-session SHA never reaches capture.)
320
+ const crossSet = gate.buildCapturedShaSet(SESS, otherSessionRows); // SESS has none of these
321
+ const r = await runL2({
322
+ commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
323
+ }, crossSet);
324
+ assert('12d. cross-session-only SHA not captured for this session -> DENY',
325
+ r && /was not captured by Layer 1/.test(r), r || 'no denial');
326
+ }
327
+
328
+ // -------------------------------------------------------------------------
329
+ // Fix 3a — assertGitRepoAvailable: a non-git directory is fail-closed with a
330
+ // distinct `truth-gate repo unavailable` reason (not `ref not found`).
331
+ // Drive it directly with the function's own repo via TRUTH_GATE_REPO capture:
332
+ // since TRUTH_GATE_REPO is module-captured to `repo` (a real git tree), the
333
+ // happy path must NOT throw; assert that, and assert a non-git path denies by
334
+ // re-importing the hook under a fresh env pointed at a non-git dir.
335
+ {
336
+ // happy path: the live throwaway repo is a work tree -> no throw
337
+ let ok = true;
338
+ try { gate.assertGitRepoAvailable(); } catch { ok = false; }
339
+ assert('13a. assertGitRepoAvailable passes for a real git work tree', ok === true);
340
+
341
+ // non-git path: re-load the hook with RDC_TRUTH_GATE_REPO pointed at a
342
+ // brand-new empty (non-git) temp dir; assertGitRepoAvailable must DENY.
343
+ const nonGit = mkdtempSync(join(tmpdir(), 'l2-nongit-'));
344
+ const prevRepo = process.env.RDC_TRUTH_GATE_REPO;
345
+ process.env.RDC_TRUTH_GATE_REPO = nonGit;
346
+ delete require.cache[require.resolve(HOOK)];
347
+ const gate2 = require(HOOK);
348
+ let denied = null;
349
+ try { gate2.assertGitRepoAvailable(); }
350
+ catch (e) { if (e instanceof gate2.GateDenied) denied = e.reason; else throw e; }
351
+ assert('13a. non-git dir -> DENY (truth-gate repo unavailable)',
352
+ denied && /truth-gate repo unavailable/.test(denied), denied || 'no denial');
353
+ // restore env + module cache for any later use
354
+ process.env.RDC_TRUTH_GATE_REPO = prevRepo;
355
+ delete require.cache[require.resolve(HOOK)];
356
+ rmSync(nonGit, { recursive: true, force: true });
357
+ }
358
+ })();
359
+
360
+ rmSync(repo, { recursive: true, force: true });
361
+
362
+ // ---------------------------------------------------------------------------
363
+ if (failures.length > 0) {
364
+ console.error('\nwork-item-exit-gate L2 tests — FAIL\n');
365
+ for (const f of failures) console.error(` - ${f}`);
366
+ process.exit(1);
367
+ }
368
+ console.log('\nwork-item-exit-gate L2 tests — PASS');