@lifeaitools/rdc-skills 0.24.0 → 0.24.2

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.
@@ -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,216 @@ 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
+ /** Run a git command in the truth-gate repo; returns trimmed stdout or null. */
251
+ function git(args, { allowFail = false } = {}) {
252
+ try {
253
+ return execFileSync('git', args, {
254
+ cwd: TRUTH_GATE_REPO,
255
+ encoding: 'utf8',
256
+ stdio: ['ignore', 'pipe', 'ignore'],
257
+ maxBuffer: 16 * 1024 * 1024,
258
+ }).trim();
259
+ } catch (e) {
260
+ if (allowFail) return null;
261
+ throw e;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Resolve a commit ref to its FULL 40-char SHA, or null if it does not exist.
267
+ * Uses `rev-parse --verify <ref>^{commit}` so only real commit objects resolve.
268
+ * NEVER does a prefix/substring compare — the returned value is the canonical
269
+ * full SHA, and all equality downstream is full-SHA equality.
270
+ */
271
+ function resolveFullSha(ref) {
272
+ if (typeof ref !== 'string' || !ref.trim()) return null;
273
+ const full = git(['rev-parse', '--verify', '--quiet', `${ref.trim()}^{commit}`], { allowFail: true });
274
+ return full && FULL_SHA_RE.test(full) ? full.toLowerCase() : null;
275
+ }
276
+
277
+ /** Full set of file paths touched by a commit (`git show --stat`→ name-only). */
278
+ function filesInCommit(fullSha) {
279
+ const out = git(['show', '--no-renames', '--name-only', '--pretty=format:', fullSha], { allowFail: true });
280
+ if (out == null) return null;
281
+ return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean).map(normalizeRepoPath));
282
+ }
283
+
284
+ function normalizeRepoPath(p) {
285
+ return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
286
+ }
287
+
288
+ /**
289
+ * Whole-repo existence check for a claimed file. A file "exists for this repo"
290
+ * when EITHER it is tracked anywhere in history (`git log --all -- <path>`
291
+ * returns a commit) OR it is present on disk. This is deliberately broad to
292
+ * avoid the cited-path-only false "absent" verdict.
293
+ */
294
+ function fileKnownToRepo(repoPath) {
295
+ const p = normalizeRepoPath(repoPath);
296
+ if (!p) return false;
297
+ // On disk now?
298
+ try {
299
+ if (fs.existsSync(path.join(TRUTH_GATE_REPO, p))) return true;
300
+ } catch (_) { /* fall through to history */ }
301
+ // Tracked anywhere in history (whole-repo locator, never cited-path-only)?
302
+ const log = git(['log', '--all', '--oneline', '-1', '--', p], { allowFail: true });
303
+ return Boolean(log && log.length > 0);
304
+ }
305
+
306
+ /** Does the file exist on disk in the work tree right now? */
307
+ function fileOnDisk(repoPath) {
308
+ const p = normalizeRepoPath(repoPath);
309
+ if (!p) return false;
310
+ try { return fs.existsSync(path.join(TRUTH_GATE_REPO, p)); } catch { return false; }
311
+ }
312
+
313
+ let _isMachineArtifact = null;
314
+ /** Lazy-load the fused primitive's artifact discriminator (ESM from CJS).
315
+ * Uses a file:// URL so the dynamic import works on Windows absolute paths. */
316
+ async function loadIsMachineArtifact() {
317
+ if (_isMachineArtifact) return _isMachineArtifact;
318
+ const { pathToFileURL } = require('url');
319
+ const libPath = path.join(__dirname, 'lib', 'run-evidence-gate.mjs');
320
+ const mod = await import(pathToFileURL(libPath).href);
321
+ _isMachineArtifact = mod.isMachineArtifact;
322
+ return _isMachineArtifact;
323
+ }
324
+
325
+ /**
326
+ * Layer-2 verification of a `done` close against the real repo. Throws (caught
327
+ * by verifyDone → block) on any internal error so the gate is FAIL-CLOSED:
328
+ * an inability to verify is a DENY, never a silent pass.
329
+ *
330
+ * @param statusCall parsed update_work_item_status args (has .id, .actorSessionId)
331
+ * @param item the work_items row (has implementation_report)
332
+ * @param capturedShas Set<string> of FULL L1-captured SHAs for this item/session
333
+ */
334
+ async function verifyLayer2(statusCall, item, capturedShas) {
335
+ const post = item.implementation_report && item.implementation_report.codeflow_post;
336
+ if (!post || typeof post !== 'object') {
337
+ deny('L2: done rejected — implementation_report.codeflow_post is missing or not an object.', statusCall);
338
+ }
339
+
340
+ // (5) Witness allow-list — the doer cannot self-witness (D5 / SLSA).
341
+ const witness = String(post.witness || '').trim();
342
+ if (!WITNESS_ALLOWLIST.has(witness)) {
343
+ deny(
344
+ 'L2: done rejected — codeflow_post.witness must be one of {validator-rerun, ci, human-review}; got "' +
345
+ (witness || '<missing>') + '". ' +
346
+ 'The party that did the work cannot sign its own provenance (witness:"agent" is never accepted).',
347
+ { ...statusCall, witness },
348
+ );
349
+ }
350
+
351
+ // (1) Commit resolves (FULL SHA) AND was captured by L1 for this item/session.
352
+ const claimedCommit = post.commit;
353
+ const fullSha = resolveFullSha(claimedCommit);
354
+ if (!fullSha) {
355
+ deny(
356
+ 'L2: done rejected — codeflow_post.commit ("' + (claimedCommit || '<missing>') +
357
+ '") does not resolve to a real commit (git cat-file/rev-parse). Free-typed or wrong SHAs are rejected.',
358
+ { ...statusCall, claimedCommit },
359
+ );
360
+ }
361
+ if (!capturedShas.has(fullSha)) {
362
+ deny(
363
+ 'L2: done rejected — commit ' + fullSha + ' was not captured by Layer 1 for this work item + originating session. ' +
364
+ 'The exit gate only accepts a commit SHA the commit-hook recorded against this item (no agent-asserted SHAs). ' +
365
+ 'Captured SHAs for this item/session: ' + (capturedShas.size ? [...capturedShas].join(', ') : '(none)') + '.',
366
+ { ...statusCall, fullSha, capturedCount: capturedShas.size },
367
+ );
368
+ }
369
+
370
+ // (2)/(3) Every files_changed entry is in the commit AND exists on disk.
371
+ const filesChanged = Array.isArray(post.files_changed) ? post.files_changed : [];
372
+ if (filesChanged.length === 0) {
373
+ deny('L2: done rejected — codeflow_post.files_changed is empty; a closure must name the files it changed.', statusCall);
374
+ }
375
+ const commitFiles = filesInCommit(fullSha);
376
+ if (commitFiles == null) {
377
+ deny('L2: done rejected — could not read the file list of commit ' + fullSha + ' (git show failed).', statusCall);
378
+ }
379
+ for (const raw of filesChanged) {
380
+ const p = normalizeRepoPath(raw);
381
+ if (!p) {
382
+ deny('L2: done rejected — a files_changed entry is empty/blank.', { ...statusCall, raw });
383
+ }
384
+ // In the commit? (whole-repo lookup is implicit — commitFiles is the full
385
+ // name-only set of the resolved commit, not a cited-path filter.)
386
+ if (!commitFiles.has(p)) {
387
+ deny(
388
+ 'L2: done rejected — file "' + p + '" is NOT among the files changed by commit ' + fullSha +
389
+ ' (git show --stat). files_changed must match the commit\'s actual contents.',
390
+ { ...statusCall, file: p, fullSha },
391
+ );
392
+ }
393
+ // On disk now?
394
+ if (!fileOnDisk(p)) {
395
+ deny(
396
+ 'L2: done rejected — claimed file "' + p + '" does not exist on disk in the work tree. ' +
397
+ 'A deliverable that is not present is not done.',
398
+ { ...statusCall, file: p },
399
+ );
400
+ }
401
+ // Whole-repo sanity (defense in depth; should always hold if on disk).
402
+ if (!fileKnownToRepo(p)) {
403
+ deny(
404
+ 'L2: done rejected — claimed file "' + p + '" is unknown to the repo (not on disk and not in history).',
405
+ { ...statusCall, file: p },
406
+ );
407
+ }
408
+ }
409
+
410
+ // (4) Every verification entry is a machine-parseable artifact, not prose.
411
+ const isMachineArtifact = await loadIsMachineArtifact();
412
+ const verifications = Array.isArray(post.verification) ? post.verification : [];
413
+ if (verifications.length === 0) {
414
+ deny('L2: done rejected — codeflow_post.verification is empty; closure needs a captured verification artifact.', statusCall);
415
+ }
416
+ for (const v of verifications) {
417
+ if (!isMachineArtifact(v)) {
418
+ const shown = typeof v === 'string' ? v.slice(0, 60) : JSON.stringify(v).slice(0, 80);
419
+ deny(
420
+ 'L2: done rejected — verification entry is prose/proxy, not a captured artifact: "' + shown + '". ' +
421
+ 'Each verification must be a run-evidence-gate result or a machine shape ' +
422
+ '({exit_code|http_status|rowcount|passed/total}). Strings like "HTTP 200" / "works" are rejected.',
423
+ { ...statusCall, verification: shown },
424
+ );
425
+ }
426
+ }
427
+ // No denial thrown => Layer-2 verification PASSED.
428
+ }
429
+
211
430
  async function verifyDone(statusCall, blob) {
212
431
  if (!statusCall.id) block('`update_work_item_status(..., done)` must include a work item UUID.', statusCall);
213
432
  if (!statusCall.actorSessionId || !statusCall.actorRole) {
@@ -222,9 +441,11 @@ async function verifyDone(statusCall, blob) {
222
441
 
223
442
  let rows;
224
443
  let events;
444
+ let capturedRows;
225
445
  try {
226
- rows = await supabaseGet(`work_items?id=eq.${encodeURIComponent(statusCall.id)}&select=id,status,item_type,implementation_report,checklist`);
446
+ rows = await supabaseGet(`work_items?id=eq.${encodeURIComponent(statusCall.id)}&select=id,status,item_type,session_id,implementation_report,checklist`);
227
447
  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`);
448
+ capturedRows = await supabaseGet(`work_item_commits?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=sha,session_id`);
228
449
  } catch (e) {
229
450
  block(`Cannot live-verify work item exit gate: ${e.message}. Do not close the item until clauth/Supabase verification is available.`, statusCall);
230
451
  }
@@ -256,6 +477,27 @@ async function verifyDone(statusCall, blob) {
256
477
  { ...statusCall, supervisorReticks: supervisorReticks.map((e) => e.item_id) },
257
478
  );
258
479
  }
480
+
481
+ // --- Truth Gate 3.0 Layer 2 — FUSED evidence gate (freeze-the-leak) ---------
482
+ // The L1-captured SHA set is restricted to the item's ORIGINATING session
483
+ // (the session that ticked the checklist) so a SHA captured by some other
484
+ // session against this item cannot launder a fabricated close.
485
+ const originatingSession = item.session_id || null;
486
+ const capturedShas = new Set(
487
+ (Array.isArray(capturedRows) ? capturedRows : [])
488
+ .filter((r) => !originatingSession || r.session_id === originatingSession)
489
+ .map((r) => String(r.sha || '').toLowerCase())
490
+ .filter((s) => FULL_SHA_RE.test(s)),
491
+ );
492
+ try {
493
+ await verifyLayer2(statusCall, item, capturedShas);
494
+ } catch (e) {
495
+ if (e instanceof GateDenied) {
496
+ block(e.reason, e.details); // translate the L2 denial into a hard block
497
+ }
498
+ // Any OTHER error during L2 is an inability to verify => FAIL-CLOSED.
499
+ block('L2: done rejected — Layer-2 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
500
+ }
259
501
  }
260
502
 
261
503
  function validateTick(tick, rawTool) {
@@ -294,4 +536,20 @@ async function main() {
294
536
  pass({ status });
295
537
  }
296
538
 
297
- main().catch((e) => block(`Exit gate crashed: ${e.message}`));
539
+ // Run as a hook; export the Layer-2 internals when required by a test.
540
+ if (require.main === module) {
541
+ main().catch((e) => block(`Exit gate crashed: ${e.message}`));
542
+ } else {
543
+ module.exports = {
544
+ GateDenied,
545
+ deny,
546
+ verifyLayer2,
547
+ resolveFullSha,
548
+ filesInCommit,
549
+ fileOnDisk,
550
+ fileKnownToRepo,
551
+ normalizeRepoPath,
552
+ loadIsMachineArtifact,
553
+ WITNESS_ALLOWLIST,
554
+ };
555
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
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",
@@ -696,9 +696,14 @@ function buildHooksConfig(hooksDir, profile = 'core') {
696
696
  cmd('require-work-item-on-commit.js'),
697
697
  ]},
698
698
  ],
699
- PostToolUse: [{ hooks: [
700
- cmd('check-services.js'),
701
- ]}],
699
+ PostToolUse: [
700
+ { hooks: [
701
+ cmd('check-services.js'),
702
+ ]},
703
+ { matcher: 'Bash', hooks: [
704
+ cmd('require-work-item-on-commit.js', 'Capturing commit SHA for work item...'),
705
+ ]},
706
+ ],
702
707
  Stop: [{ hooks: [
703
708
  cmd('rdc-output-contract-gate.js', 'Checking RDC output contract...'),
704
709
  cmd('post-work-check.js', 'Checking for undocumented work...'),
@@ -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,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Truth Gate L1 — commit-capture hook tests.
4
+ *
5
+ * Proves:
6
+ * 1. parse: the work-item UUID is extracted from a commit message; absent UUID -> null.
7
+ * 2. capture: on a PostToolUse(git commit), the captured `sha` EQUALS the real
8
+ * `git rev-parse HEAD` of the repo (verified against a throwaway git repo,
9
+ * via the RDC_COMMIT_CAPTURE_SINK file — no live DB required).
10
+ * 3. no-item: capture NO-OPs (writes nothing) when the commit message carries
11
+ * no work-item UUID. No orphan row.
12
+ *
13
+ * Run: node tests/require-work-item-on-commit.test.mjs (or `node --test tests/`)
14
+ */
15
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join, resolve, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawnSync, execFileSync } from 'node:child_process';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const REPO_ROOT = resolve(__dirname, '..');
24
+ const HOOK = join(REPO_ROOT, 'hooks', 'require-work-item-on-commit.js');
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const failures = [];
28
+ function assert(name, condition, detail = '') {
29
+ if (!condition) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // 1. Pure parse assertions
34
+ // ---------------------------------------------------------------------------
35
+ const hook = require(HOOK);
36
+ const WI = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
37
+
38
+ assert('parse: extracts UUID from message',
39
+ hook.parseCommitMessageWorkItem(`feat(x): do thing for ${WI}`) === WI);
40
+ assert('parse: null when no UUID',
41
+ hook.parseCommitMessageWorkItem('feat(x): no work item here') === null);
42
+ assert('isGitCommit: true for real commit',
43
+ hook.isGitCommit('git commit -m "feat: x"') === true);
44
+ assert('isGitCommit: false for --help',
45
+ hook.isGitCommit('git commit --help') === false);
46
+ assert('isGitCommit: false for unrelated',
47
+ hook.isGitCommit('git status') === false);
48
+ assert('commitSucceeded: false on nothing-to-commit',
49
+ hook.commitSucceeded({ stdout: 'nothing to commit, working tree clean' }) === false);
50
+ assert('commitSucceeded: true on exit 0',
51
+ hook.commitSucceeded({ exit_code: 0 }) === true);
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // helper: build a throwaway git repo with one commit
55
+ // ---------------------------------------------------------------------------
56
+ function makeRepo() {
57
+ const dir = mkdtempSync(join(tmpdir(), 'wic-repo-'));
58
+ const g = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
59
+ g('init', '-q');
60
+ g('config', 'user.email', 'test@example.com');
61
+ g('config', 'user.name', 'Test');
62
+ writeFileSync(join(dir, 'f.txt'), 'hello\n');
63
+ g('add', 'f.txt');
64
+ g('commit', '-q', '-m', `feat: seed for ${WI}`);
65
+ const head = g('rev-parse', 'HEAD').trim();
66
+ return { dir, head };
67
+ }
68
+
69
+ function runHook(payload, extraEnv = {}) {
70
+ return spawnSync(process.execPath, [HOOK], {
71
+ input: JSON.stringify(payload),
72
+ encoding: 'utf8',
73
+ env: { ...process.env, ...extraEnv },
74
+ });
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // 2. Capture: sha == git rev-parse HEAD
79
+ // ---------------------------------------------------------------------------
80
+ {
81
+ const { dir, head } = makeRepo();
82
+ const sink = join(dir, 'sink.jsonl');
83
+ // Point Supabase at an unreachable host so the test never touches a real DB;
84
+ // capture still writes the sink, which is what we assert on.
85
+ const res = runHook({
86
+ hook_event_name: 'PostToolUse',
87
+ tool_name: 'Bash',
88
+ session_id: 'sess-capture-1',
89
+ cwd: dir,
90
+ tool_input: { command: `git commit -m "feat: thing for ${WI}"` },
91
+ tool_response: { exit_code: 0, stdout: '1 file changed' },
92
+ }, {
93
+ RDC_COMMIT_CAPTURE_SINK: sink,
94
+ SUPABASE_URL: 'http://127.0.0.1:9', // unreachable -> DB insert fails fast, capture still records sink
95
+ SUPABASE_SERVICE_ROLE_KEY: 'test-key-not-real',
96
+ });
97
+ assert('capture: hook exits zero', res.status === 0, res.stderr);
98
+ assert('capture: sink written', existsSync(sink), 'no sink file');
99
+ if (existsSync(sink)) {
100
+ const row = JSON.parse(readFileSync(sink, 'utf8').trim().split('\n')[0]);
101
+ assert('capture: sha equals real HEAD', row.sha === head, `${row.sha} !== ${head}`);
102
+ assert('capture: work_item_id parsed', row.work_item_id === WI, row.work_item_id);
103
+ assert('capture: session_id recorded', row.session_id === 'sess-capture-1', row.session_id);
104
+ }
105
+ rmSync(dir, { recursive: true, force: true });
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 3. No active work item -> NO-OP (no sink row)
110
+ // ---------------------------------------------------------------------------
111
+ {
112
+ const { dir } = makeRepo();
113
+ const sink = join(dir, 'sink.jsonl');
114
+ const res = runHook({
115
+ hook_event_name: 'PostToolUse',
116
+ tool_name: 'Bash',
117
+ session_id: 'sess-no-item',
118
+ cwd: dir,
119
+ tool_input: { command: 'git commit -m "feat: no work item ref"' },
120
+ tool_response: { exit_code: 0 },
121
+ }, { RDC_COMMIT_CAPTURE_SINK: sink });
122
+ assert('no-item: hook exits zero', res.status === 0, res.stderr);
123
+ assert('no-item: no sink row written', !existsSync(sink), 'orphan capture written for no-item commit');
124
+ rmSync(dir, { recursive: true, force: true });
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // 4. PreToolUse legacy behavior preserved (warn-only, never blocks)
129
+ // ---------------------------------------------------------------------------
130
+ {
131
+ const res = runHook({
132
+ hook_event_name: 'PreToolUse',
133
+ tool_name: 'Bash',
134
+ tool_input: { command: 'git commit -m "no convention and no uuid"' },
135
+ });
136
+ assert('pre: warn exits zero (never blocks)', res.status === 0, res.stderr);
137
+ assert('pre: emits warn systemMessage', /no work item reference/.test(res.stdout), res.stdout);
138
+
139
+ const ok = runHook({
140
+ hook_event_name: 'PreToolUse',
141
+ tool_name: 'Bash',
142
+ tool_input: { command: 'git commit -m "feat(x): conventional"' },
143
+ });
144
+ assert('pre: conventional passes silently', ok.status === 0 && ok.stdout.trim() === '', ok.stdout);
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ if (failures.length > 0) {
149
+ console.error('\ncommit-capture hook tests — FAIL\n');
150
+ for (const f of failures) console.error(` - ${f}`);
151
+ process.exit(1);
152
+ }
153
+ console.log('commit-capture hook tests — PASS');
@@ -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');