@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.
- package/.claude-plugin/plugin.json +1 -1
- package/git-sha.json +1 -1
- package/hooks/lib/run-evidence-gate.mjs +174 -0
- package/hooks/require-work-item-on-commit.js +265 -55
- package/hooks/work-item-exit-gate.js +260 -2
- package/package.json +2 -1
- package/scripts/install-rdc-skills.js +8 -3
- package/skills/build/SKILL.md +10 -0
- package/tests/require-work-item-on-commit.test.mjs +153 -0
- package/tests/run-evidence-gate.test.mjs +82 -0
- package/tests/work-item-exit-gate-l2.test.mjs +210 -0
|
@@ -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
|
-
|
|
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.
|
|
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: [
|
|
700
|
-
|
|
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...'),
|
package/skills/build/SKILL.md
CHANGED
|
@@ -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');
|