@lifeaitools/rdc-skills 0.24.1 → 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.
package/git-sha.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "e04654eb140982dd3d79fe474a8a681c131e2e3d"
2
+ "sha": "e6ebb365ff8dc12473f5e2a8e0de04dee4003a91"
3
3
  }
@@ -0,0 +1,174 @@
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
+ export default { runEvidenceGate, hashOutput, isMachineArtifact, 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,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.1",
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",
@@ -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,210 @@
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
+ rmSync(repo, { recursive: true, force: true });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ if (failures.length > 0) {
206
+ console.error('\nwork-item-exit-gate L2 tests — FAIL\n');
207
+ for (const f of failures) console.error(` - ${f}`);
208
+ process.exit(1);
209
+ }
210
+ console.log('\nwork-item-exit-gate L2 tests — PASS');