@lifeaitools/rdc-skills 0.24.42 → 0.25.0

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.
Files changed (198) hide show
  1. package/.claude/settings.json +15 -15
  2. package/.claude-plugin/marketplace.json +21 -21
  3. package/.claude-plugin/plugin.json +1518 -1371
  4. package/.github/workflows/publish.yml +34 -34
  5. package/.github/workflows/self-test.yml +58 -58
  6. package/CHANGELOG.md +310 -310
  7. package/LICENSE +21 -21
  8. package/MANIFEST.md +221 -221
  9. package/README.md +375 -376
  10. package/README.sandbox.md +3 -3
  11. package/assets/watcher/viewer.html +164 -164
  12. package/bin/rdc-skills-mcp.mjs +316 -316
  13. package/commands/build.md +183 -183
  14. package/commands/collab.md +180 -180
  15. package/commands/deploy.md +152 -152
  16. package/commands/design.md +31 -31
  17. package/commands/edit.md +28 -28
  18. package/commands/fixit.md +150 -124
  19. package/commands/handoff.md +173 -173
  20. package/commands/help.md +95 -95
  21. package/commands/overnight.md +220 -220
  22. package/commands/plan.md +158 -158
  23. package/commands/preplan.md +131 -131
  24. package/commands/prototype.md +145 -145
  25. package/commands/release.md +49 -49
  26. package/commands/report.md +99 -99
  27. package/commands/review.md +120 -120
  28. package/commands/self-test.md +113 -113
  29. package/commands/status.md +86 -86
  30. package/commands/watch.md +98 -98
  31. package/commands/workitems.md +137 -137
  32. package/git-sha.json +1 -1
  33. package/guides/agent-bootstrap.md +295 -295
  34. package/guides/agents/backend.md +104 -104
  35. package/guides/agents/content.md +94 -94
  36. package/guides/agents/cs2.md +56 -56
  37. package/guides/agents/data.md +87 -87
  38. package/guides/agents/design.md +77 -77
  39. package/guides/agents/frontend.md +92 -92
  40. package/guides/agents/infrastructure.md +81 -81
  41. package/guides/agents/setup.md +281 -281
  42. package/guides/agents/verify.md +151 -151
  43. package/guides/agents/viz.md +106 -106
  44. package/guides/backend.md +146 -146
  45. package/guides/content.md +147 -147
  46. package/guides/cs2.md +190 -190
  47. package/guides/data.md +123 -123
  48. package/guides/design.md +116 -116
  49. package/guides/engineering-behavior.md +43 -43
  50. package/guides/escalation-protocol.md +125 -125
  51. package/guides/frontend.md +151 -151
  52. package/guides/history-md-spec.md +297 -297
  53. package/guides/infrastructure.md +179 -179
  54. package/guides/lessons-learned-spec.md +145 -151
  55. package/guides/output-contract.md +108 -108
  56. package/guides/publish-md-spec.md +289 -289
  57. package/guides/rdc-skills-startup.md +30 -30
  58. package/guides/verify.md +11 -11
  59. package/hooks/check-cwd.js +31 -31
  60. package/hooks/check-rdc-environment.js +164 -164
  61. package/hooks/check-services.js +6 -6
  62. package/hooks/check-stale-work-items.js +19 -19
  63. package/hooks/foreground-process-gate.js +128 -128
  64. package/hooks/gate-watchdog-selfcheck.js +257 -257
  65. package/hooks/hook-logger.js +25 -25
  66. package/hooks/lib/run-evidence-gate.mjs +241 -241
  67. package/hooks/no-stop-open-epics.js +127 -127
  68. package/hooks/post-tool-batch-gate.js +203 -203
  69. package/hooks/post-work-check.js +21 -21
  70. package/hooks/postcompact-log.js +13 -13
  71. package/hooks/precompact-log.js +13 -13
  72. package/hooks/rate-limit-retry.js +46 -46
  73. package/hooks/rdc-invocation-marker.js +157 -157
  74. package/hooks/rdc-output-contract-gate.js +94 -94
  75. package/hooks/require-work-item-on-commit.js +294 -294
  76. package/hooks/restart-brief.js +19 -19
  77. package/hooks/run-hidden-hook.ps1 +47 -47
  78. package/hooks/task-completed-gate.js +274 -274
  79. package/hooks/work-item-exit-gate.js +944 -944
  80. package/lib/catalog.mjs +236 -236
  81. package/lib/cloud-rewrite.mjs +155 -155
  82. package/package.json +57 -57
  83. package/rules/work-items-rpc.md +520 -520
  84. package/scaffold/templates/HISTORY.md.template +39 -39
  85. package/scaffold/templates/PUBLISH.md.template +21 -21
  86. package/scaffold/templates/brochure-studio-default.html +70 -70
  87. package/scripts/acceptance.mjs +502 -502
  88. package/scripts/fixtures/guides/bad-guide.md +15 -15
  89. package/scripts/fixtures/guides-clean/good-guide.md +16 -16
  90. package/scripts/install-rdc-skills.js +1289 -1289
  91. package/scripts/install.ps1 +202 -202
  92. package/scripts/install.sh +132 -132
  93. package/scripts/lib/assertions.mjs +287 -287
  94. package/scripts/lib/manifest-schema.mjs +754 -754
  95. package/scripts/lib/runner.mjs +465 -465
  96. package/scripts/lib/sandbox.mjs +435 -435
  97. package/scripts/prepack.mjs +32 -32
  98. package/scripts/rdc-brochure.mjs +482 -482
  99. package/scripts/rdc-design-cli.mjs +134 -134
  100. package/scripts/rebuild-mcp.mjs +107 -107
  101. package/scripts/self-test.mjs +1460 -1460
  102. package/scripts/stamp-git-sha.mjs +29 -29
  103. package/scripts/test-guide-validator.mjs +196 -196
  104. package/scripts/test-rdc-hooks.mjs +145 -145
  105. package/scripts/uninstall.ps1 +77 -77
  106. package/scripts/uninstall.sh +69 -69
  107. package/scripts/update.ps1 +43 -43
  108. package/scripts/update.sh +43 -43
  109. package/scripts/validate-place-histories.js +461 -461
  110. package/scripts/validate-publish-manifests.js +502 -424
  111. package/scripts/watch-init.mjs +100 -100
  112. package/skills/brochure/SKILL.md +107 -107
  113. package/skills/build/SKILL.md +563 -563
  114. package/skills/channel-formatter/SKILL.md +538 -533
  115. package/skills/co-develop/SKILL.md +196 -196
  116. package/skills/collab/SKILL.md +239 -239
  117. package/skills/convert/SKILL.md +167 -140
  118. package/skills/deploy/SKILL.md +541 -541
  119. package/skills/design/SKILL.md +211 -211
  120. package/skills/design/reference/ownership.md +16 -16
  121. package/skills/design/reference/rampa.md +92 -92
  122. package/skills/design/reference/studio-model.md +153 -153
  123. package/skills/edit/SKILL.md +98 -98
  124. package/skills/fixit/SKILL.md +203 -165
  125. package/skills/fs-mcp/SKILL.md +148 -148
  126. package/skills/handoff/SKILL.md +236 -236
  127. package/skills/help/SKILL.md +143 -143
  128. package/skills/housekeeping/SKILL.md +160 -219
  129. package/skills/lifeai-brochure-author/SKILL.md +340 -340
  130. package/skills/onramp/SKILL.md +248 -0
  131. package/skills/overnight/SKILL.md +251 -251
  132. package/skills/plan/SKILL.md +345 -345
  133. package/skills/preplan/SKILL.md +90 -90
  134. package/skills/prototype/SKILL.md +150 -150
  135. package/skills/rdc-brochurify/SKILL.md +245 -245
  136. package/skills/rdc-extract-verifier-rules/SKILL.md +191 -191
  137. package/skills/release/SKILL.md +140 -140
  138. package/skills/report/SKILL.md +100 -100
  139. package/skills/review/SKILL.md +152 -152
  140. package/skills/rpms-filemap/SKILL.cloud.md +111 -111
  141. package/skills/rpms-filemap/SKILL.md +111 -111
  142. package/skills/self-test/SKILL.md +132 -132
  143. package/skills/status/SKILL.md +99 -99
  144. package/skills/terminal-config/SKILL.md +62 -62
  145. package/skills/tests/MATRIX.md +54 -54
  146. package/skills/tests/README.md +47 -47
  147. package/skills/tests/onramp.test.json +87 -0
  148. package/skills/tests/rdc-brochure.test.json +34 -34
  149. package/skills/tests/rdc-build.test.json +36 -36
  150. package/skills/tests/rdc-channel-formatter.test.json +45 -45
  151. package/skills/tests/rdc-co-develop.test.json +29 -29
  152. package/skills/tests/rdc-collab.test.json +29 -29
  153. package/skills/tests/rdc-convert.test.json +35 -35
  154. package/skills/tests/rdc-deploy.test.json +30 -30
  155. package/skills/tests/rdc-design.test.json +27 -27
  156. package/skills/tests/rdc-edit.test.json +29 -29
  157. package/skills/tests/rdc-fixit.test.json +36 -36
  158. package/skills/tests/rdc-fs-mcp.test.json +36 -36
  159. package/skills/tests/rdc-handoff.test.json +28 -28
  160. package/skills/tests/rdc-help.test.json +29 -29
  161. package/skills/tests/rdc-housekeeping.test.json +28 -32
  162. package/skills/tests/rdc-lifeai-brochure-author.test.json +35 -35
  163. package/skills/tests/rdc-overnight.test.json +37 -37
  164. package/skills/tests/rdc-plan.test.json +27 -27
  165. package/skills/tests/rdc-preplan.test.json +31 -31
  166. package/skills/tests/rdc-prototype.test.json +28 -28
  167. package/skills/tests/rdc-rdc-brochurify.test.json +23 -23
  168. package/skills/tests/rdc-rdc-extract-verifier-rules.test.json +34 -34
  169. package/skills/tests/rdc-release.test.json +29 -29
  170. package/skills/tests/rdc-report.test.json +28 -28
  171. package/skills/tests/rdc-review.test.json +29 -29
  172. package/skills/tests/rdc-rpms-filemap.test.json +28 -28
  173. package/skills/tests/rdc-self-test.test.json +24 -24
  174. package/skills/tests/rdc-status.test.json +29 -29
  175. package/skills/tests/rdc-terminal-config.test.json +29 -29
  176. package/skills/tests/rdc-watch.test.json +24 -24
  177. package/skills/tests/rdc-workitems.test.json +27 -27
  178. package/skills/watch/SKILL.md +97 -97
  179. package/skills/workitems/SKILL.md +151 -151
  180. package/tests/acceptance.test.mjs +59 -59
  181. package/tests/channel-formatter.contract.test.mjs +251 -251
  182. package/tests/curl-surface.test.mjs +289 -289
  183. package/tests/harness-gates.test.mjs +325 -325
  184. package/tests/help-surface.test.mjs +61 -61
  185. package/tests/install-rdc-skills.test.mjs +49 -49
  186. package/tests/manifest-contract-fields.test.mjs +78 -78
  187. package/tests/mcp.test.mjs +271 -271
  188. package/tests/rdc-brochure.test.mjs +125 -125
  189. package/tests/require-work-item-on-commit.test.mjs +162 -162
  190. package/tests/run-evidence-gate.test.mjs +82 -82
  191. package/tests/skill-test-matrix.test.mjs +66 -66
  192. package/tests/validate-skills.js +27 -27
  193. package/tests/work-item-exit-gate-l2.test.mjs +368 -368
  194. package/tests/work-item-exit-gate-l3.test.mjs +197 -197
  195. package/RELEASE.md +0 -42
  196. package/tests/housekeeping-lessons-triage.test.mjs +0 -49
  197. package/tests/lessons-pipeline-contract.test.mjs +0 -27
  198. package/tests/release-contract.test.mjs +0 -16
@@ -1,368 +1,368 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Truth Gate 3.0 — Layer 2 (FUSED evidence gate) tests.
4
- *
5
- * Exercises EVERY rejection branch of work-item-exit-gate.js verifyLayer2 plus
6
- * the fully-valid close, against a REAL throwaway git repo (no live DB). The
7
- * gate's git/disk checks run against RDC_TRUTH_GATE_REPO, which we point at the
8
- * throwaway repo. verifyLayer2 throws GateDenied on rejection; we assert on the
9
- * thrown reason. A fully-valid close throws nothing.
10
- *
11
- * Branches proven:
12
- * 1. commit-not-resolving (free-typed bad SHA) -> DENY
13
- * 2. commit-not-captured (real SHA, not in L1 set) -> DENY
14
- * 3. files ∉ commit -> DENY
15
- * 4. file not on disk (in commit, deleted from tree) -> DENY
16
- * 5. prose verification ("HTTP 200") -> DENY
17
- * 6. witness:"agent" -> DENY
18
- * 7. fully-valid close (real SHA captured, files in -> ALLOW (no throw)
19
- * commit + on disk, machine artifact, valid witness)
20
- * 8. fail-closed: any internal error during L2 -> DENY (via main wrapper)
21
- *
22
- * Also a fused-primitive assertion: run_evidence_gate emits a verdict ONLY
23
- * after running the command (no verdict without a run).
24
- *
25
- * Run: node tests/work-item-exit-gate-l2.test.mjs
26
- */
27
- import { mkdtempSync, rmSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
28
- import { tmpdir } from 'node:os';
29
- import { join, resolve, dirname } from 'node:path';
30
- import { fileURLToPath, pathToFileURL } from 'node:url';
31
- import { execFileSync } from 'node:child_process';
32
- import { createRequire } from 'node:module';
33
-
34
- const __dirname = dirname(fileURLToPath(import.meta.url));
35
- const REPO_ROOT = resolve(__dirname, '..');
36
- const HOOK = join(REPO_ROOT, 'hooks', 'work-item-exit-gate.js');
37
- const GATE_LIB = pathToFileURL(join(REPO_ROOT, 'hooks', 'lib', 'run-evidence-gate.mjs')).href;
38
-
39
- const failures = [];
40
- function assert(name, condition, detail = '') {
41
- if (!condition) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
42
- else process.stdout.write(` ok ${name}\n`);
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // Build a throwaway git repo: commit two files, capture the FULL HEAD sha.
47
- // ---------------------------------------------------------------------------
48
- const repo = mkdtempSync(join(tmpdir(), 'l2-repo-'));
49
- const g = (...a) => execFileSync('git', a, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
50
- g('init', '-q');
51
- g('config', 'user.email', 'test@example.com');
52
- g('config', 'user.name', 'Test');
53
- writeFileSync(join(repo, 'a.txt'), 'alpha\n');
54
- writeFileSync(join(repo, 'b.txt'), 'beta\n');
55
- g('add', 'a.txt', 'b.txt');
56
- g('commit', '-q', '-m', 'feat: seed');
57
- const HEAD = g('rev-parse', 'HEAD').trim(); // real FULL 40-hex SHA
58
-
59
- // CRITICAL: set the repo the gate runs git against BEFORE requiring the hook,
60
- // because TRUTH_GATE_REPO is captured at module load.
61
- process.env.RDC_TRUTH_GATE_REPO = repo;
62
- const require = createRequire(import.meta.url);
63
- const gate = require(HOOK);
64
-
65
- const SESS = 'sess-l2-origin';
66
- // Build an `item` row shaped like the work_items SELECT the gate reads.
67
- function makeItem(post) {
68
- return {
69
- id: '11111111-2222-3333-4444-555555555555',
70
- item_type: 'task',
71
- status: 'review',
72
- session_id: SESS,
73
- implementation_report: { codeflow_post: post },
74
- };
75
- }
76
- const statusCall = { id: '11111111-2222-3333-4444-555555555555', actorSessionId: 'validator-x', actorRole: 'validator' };
77
-
78
- // A machine-parseable verification artifact (exit-code shape).
79
- const MACHINE_VERIF = { exit_code: 0, label: 'tsc' };
80
-
81
- // Helper: run verifyLayer2 and return the GateDenied reason, or null if it passed.
82
- async function runL2(post, capturedShas) {
83
- try {
84
- await gate.verifyLayer2(statusCall, makeItem(post), capturedShas);
85
- return null; // ALLOW
86
- } catch (e) {
87
- if (e instanceof gate.GateDenied) return e.reason;
88
- throw e; // unexpected internal error — surface it
89
- }
90
- }
91
-
92
- const CAPTURED = new Set([HEAD.toLowerCase()]); // L1-captured set for this item/session
93
-
94
- // ---------------------------------------------------------------------------
95
- await (async () => {
96
- // 1. commit does NOT resolve (free-typed bad SHA)
97
- {
98
- const reason = await runL2({
99
- commit: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
100
- files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
101
- }, CAPTURED);
102
- assert('1. free-typed/bad SHA -> DENY', reason && /does not resolve to a real commit/.test(reason), reason || 'no denial');
103
- }
104
-
105
- // 2. commit resolves but was NOT captured by L1 for this item/session
106
- {
107
- const reason = await runL2({
108
- commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
109
- }, new Set()); // empty captured set
110
- assert('2. commit not captured by L1 -> DENY', reason && /was not captured by Layer 1/.test(reason), reason || 'no denial');
111
- }
112
-
113
- // 3. files_changed entry NOT in the commit
114
- {
115
- const reason = await runL2({
116
- commit: HEAD, files_changed: ['nonexistent-in-commit.txt'], verification: [MACHINE_VERIF], witness: 'ci',
117
- }, CAPTURED);
118
- assert('3. files not in commit -> DENY', reason && /is NOT among the files changed by commit/.test(reason), reason || 'no denial');
119
- }
120
-
121
- // 4. file is in the commit but NOT on disk (delete it from the work tree)
122
- {
123
- unlinkSync(join(repo, 'b.txt')); // b.txt is in the commit, now gone from disk
124
- const reason = await runL2({
125
- commit: HEAD, files_changed: ['b.txt'], verification: [MACHINE_VERIF], witness: 'human-review',
126
- }, CAPTURED);
127
- assert('4. file not on disk -> DENY', reason && /does not exist on disk/.test(reason), reason || 'no denial');
128
- writeFileSync(join(repo, 'b.txt'), 'beta\n'); // restore for later valid-close test
129
- }
130
-
131
- // 5. prose / proxy verification ("HTTP 200")
132
- {
133
- const reason = await runL2({
134
- commit: HEAD, files_changed: ['a.txt'], verification: ['HTTP 200'], witness: 'validator-rerun',
135
- }, CAPTURED);
136
- assert('5. prose verification -> DENY', reason && /prose\/proxy, not a captured artifact/.test(reason), reason || 'no denial');
137
- }
138
-
139
- // 6. witness:"agent" (self-witness)
140
- {
141
- const reason = await runL2({
142
- commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'agent',
143
- }, CAPTURED);
144
- assert('6. witness:agent -> DENY', reason && /witness must be one of/.test(reason), reason || 'no denial');
145
- }
146
-
147
- // 7. fully-valid close -> ALLOW (no throw)
148
- {
149
- const reason = await runL2({
150
- commit: HEAD, files_changed: ['a.txt', 'b.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
151
- }, CAPTURED);
152
- assert('7. fully-valid close -> ALLOW', reason === null, `unexpected denial: ${reason}`);
153
- }
154
-
155
- // 7b. fully-valid close with a REAL fused run-evidence-gate artifact as verification
156
- {
157
- const lib = await import(GATE_LIB);
158
- const fused = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(0)'], label: 'fused-pass' });
159
- const reason = await runL2({
160
- commit: HEAD, files_changed: ['a.txt'], verification: [fused], witness: 'ci',
161
- }, CAPTURED);
162
- assert('7b. fused artifact verification -> ALLOW', reason === null, `unexpected denial: ${reason}`);
163
- assert('7b. fused artifact has verdict only after run', fused.ran === true && fused.verdict === 'pass' && typeof fused.output_sha256 === 'string', JSON.stringify(fused));
164
- }
165
-
166
- // 8. FUSED primitive: no verdict possible without a run (invalid spec -> error, ran:false)
167
- {
168
- const lib = await import(GATE_LIB);
169
- const bad = lib.runEvidenceGate({}); // no command -> cannot run
170
- assert('8. fused: no run -> no pass verdict (fail-closed)', bad.ran === false && bad.verdict === 'error', JSON.stringify(bad));
171
- // and a run that fails yields verdict:'fail', never silently 'pass'
172
- const failRun = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(2)'] });
173
- assert('8. fused: failing run -> verdict fail', failRun.ran === true && failRun.verdict === 'fail' && failRun.exit_code === 2, JSON.stringify(failRun));
174
- }
175
-
176
- // 9. FAIL-CLOSED contract: empty files_changed / empty verification both DENY
177
- {
178
- const r1 = await runL2({ commit: HEAD, files_changed: [], verification: [MACHINE_VERIF], witness: 'ci' }, CAPTURED);
179
- assert('9a. empty files_changed -> DENY', r1 && /files_changed is empty/.test(r1), r1 || 'no denial');
180
- const r2 = await runL2({ commit: HEAD, files_changed: ['a.txt'], verification: [], witness: 'ci' }, CAPTURED);
181
- assert('9b. empty verification -> DENY', r2 && /verification is empty/.test(r2), r2 || 'no denial');
182
- const r3 = await runL2({ commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF] }, CAPTURED); // no witness
183
- assert('9c. missing witness -> DENY', r3 && /witness must be one of/.test(r3), r3 || 'no denial');
184
- }
185
-
186
- // 10. Baru-trap: a SHORT prefix of a real commit must NOT resolve-and-pass as
187
- // the captured full SHA. We claim a 8-char prefix; even though it resolves to
188
- // the same commit, the captured set holds the FULL sha, and the gate compares
189
- // full-to-full, so a prefix that git expands still equals HEAD -> captured.
190
- // The hardening we prove: the gate stores/compares the FULL resolved sha, so a
191
- // WRONG short prefix (one that resolves to a DIFFERENT/absent commit) is caught
192
- // by branch 1/2 above. Here we assert the FULL-sha resolution itself:
193
- {
194
- const full = gate.resolveFullSha(HEAD.slice(0, 8)); // valid short prefix of HEAD
195
- assert('10. resolveFullSha expands a valid prefix to the FULL 40-hex sha',
196
- full === HEAD.toLowerCase() && /^[0-9a-f]{40}$/.test(full), full || 'null');
197
- const none = gate.resolveFullSha('00000000'); // prefix of no commit
198
- assert('10. resolveFullSha returns null for a non-existent prefix', none === null, none || 'not-null');
199
- }
200
-
201
- // -------------------------------------------------------------------------
202
- // Fix 1 — OUTCOME GATE: a verification that RAN but did NOT pass must DENY.
203
- // isMachineArtifact passes (correct shape) but isPassingArtifact must fail.
204
- // -------------------------------------------------------------------------
205
- {
206
- // 11a. exit_code:1 (a real machine shape, but a FAILING run) -> DENY
207
- const r1 = await runL2({
208
- commit: HEAD, files_changed: ['a.txt'], verification: [{ exit_code: 1 }], witness: 'validator-rerun',
209
- }, CAPTURED);
210
- assert('11a. verification exit_code:1 -> DENY (verification-not-passing)',
211
- r1 && /verification-not-passing/.test(r1), r1 || 'no denial');
212
-
213
- // 11b. fused run-evidence-gate artifact with verdict:'fail' -> DENY
214
- const lib = await import(GATE_LIB);
215
- const fusedFail = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(1)'], label: 'fused-fail' });
216
- assert('11b. fused failing run has verdict:fail', fusedFail.ran === true && fusedFail.verdict === 'fail', JSON.stringify(fusedFail));
217
- const r2 = await runL2({
218
- commit: HEAD, files_changed: ['a.txt'], verification: [fusedFail], witness: 'ci',
219
- }, CAPTURED);
220
- assert('11b. fused verdict:fail -> DENY (verification-not-passing)',
221
- r2 && /verification-not-passing/.test(r2), r2 || 'no denial');
222
-
223
- // 11c. http_status:500 (error status) -> DENY
224
- const r3 = await runL2({
225
- commit: HEAD, files_changed: ['a.txt'], verification: [{ http_status: 500 }], witness: 'human-review',
226
- }, CAPTURED);
227
- assert('11c. verification http_status:500 -> DENY (verification-not-passing)',
228
- r3 && /verification-not-passing/.test(r3), r3 || 'no denial');
229
-
230
- // 11d. failed test-runner JSON ({passed:3, failed:2}) -> DENY
231
- const r4 = await runL2({
232
- commit: HEAD, files_changed: ['a.txt'], verification: [{ passed: 3, failed: 2 }], witness: 'ci',
233
- }, CAPTURED);
234
- assert('11d. verification {passed:3,failed:2} -> DENY (verification-not-passing)',
235
- r4 && /verification-not-passing/.test(r4), r4 || 'no denial');
236
-
237
- // 11e. tsc_errors:4 -> DENY
238
- const r5 = await runL2({
239
- commit: HEAD, files_changed: ['a.txt'], verification: [{ tsc_errors: 4 }], witness: 'ci',
240
- }, CAPTURED);
241
- assert('11e. verification tsc_errors:4 -> DENY (verification-not-passing)',
242
- r5 && /verification-not-passing/.test(r5), r5 || 'no denial');
243
-
244
- // 11f. PASSING shapes still ALLOW (regression guard for the outcome gate).
245
- const okExit = await runL2({
246
- commit: HEAD, files_changed: ['a.txt'], verification: [{ exit_code: 0 }], witness: 'validator-rerun',
247
- }, CAPTURED);
248
- assert('11f. exit_code:0 still ALLOWs', okExit === null, `unexpected denial: ${okExit}`);
249
- const okHttp = await runL2({
250
- commit: HEAD, files_changed: ['a.txt'], verification: [{ http_status: 200 }], witness: 'ci',
251
- }, CAPTURED);
252
- assert('11f. http_status:200 still ALLOWs', okHttp === null, `unexpected denial: ${okHttp}`);
253
- const okTests = await runL2({
254
- commit: HEAD, files_changed: ['a.txt'], verification: [{ passed: 5, total: 5 }], witness: 'ci',
255
- }, CAPTURED);
256
- assert('11f. {passed:5,total:5} still ALLOWs', okTests === null, `unexpected denial: ${okTests}`);
257
- }
258
-
259
- // 11g. isPassingArtifact unit checks (direct, lib-level).
260
- {
261
- const lib = await import(GATE_LIB);
262
- const { isPassingArtifact } = lib;
263
- assert('11g. isPassingArtifact rejects {exit_code:1}', isPassingArtifact({ exit_code: 1 }) === false);
264
- assert('11g. isPassingArtifact accepts {exit_code:0}', isPassingArtifact({ exit_code: 0 }) === true);
265
- assert('11g. isPassingArtifact rejects {http_status:500}', isPassingArtifact({ http_status: 500 }) === false);
266
- assert('11g. isPassingArtifact accepts {http_status:204}', isPassingArtifact({ http_status: 204 }) === true);
267
- assert('11g. isPassingArtifact rejects {passed:1,failed:1}', isPassingArtifact({ passed: 1, failed: 1 }) === false);
268
- assert('11g. isPassingArtifact accepts {passed:5,total:5}', isPassingArtifact({ passed: 5, total: 5 }) === true);
269
- assert('11g. isPassingArtifact rejects {passed:4,total:5}', isPassingArtifact({ passed: 4, total: 5 }) === false);
270
- assert('11g. isPassingArtifact rejects prose "HTTP 200"', isPassingArtifact('HTTP 200') === false);
271
- const fp = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(0)'] });
272
- const ff = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(1)'] });
273
- assert('11g. isPassingArtifact accepts fused verdict:pass', isPassingArtifact(fp) === true);
274
- assert('11g. isPassingArtifact rejects fused verdict:fail', isPassingArtifact(ff) === false);
275
- }
276
-
277
- // -------------------------------------------------------------------------
278
- // Fix 2 — NULL ORIGINATING SESSION must be FAIL-CLOSED. A null session must
279
- // NOT disable the per-session commit binding (the old `!originatingSession`
280
- // short-circuit accepted ANY session's captured SHA). buildCapturedShaSet
281
- // DENIES when the originating session is null/empty.
282
- // -------------------------------------------------------------------------
283
- {
284
- const otherSessionRows = [{ sha: HEAD.toLowerCase(), session_id: 'some-OTHER-session' }];
285
-
286
- // 12a. session_id=null + a SHA captured by a DIFFERENT session -> DENY
287
- let denied = null;
288
- try {
289
- gate.buildCapturedShaSet(null, otherSessionRows);
290
- } catch (e) {
291
- if (e instanceof gate.GateDenied) denied = e.reason; else throw e;
292
- }
293
- assert('12a. null originating session -> DENY (no provenance to bind)',
294
- denied && /no originating session/.test(denied), denied || 'no denial');
295
-
296
- // 12b. empty-string session is treated the same (fail-closed) -> DENY
297
- let denied2 = null;
298
- try {
299
- gate.buildCapturedShaSet('', otherSessionRows);
300
- } catch (e) {
301
- if (e instanceof gate.GateDenied) denied2 = e.reason; else throw e;
302
- }
303
- assert('12b. empty originating session -> DENY', denied2 && /no originating session/.test(denied2), denied2 || 'no denial');
304
-
305
- // 12c. with a real originating session, ONLY that session's SHAs are kept;
306
- // a DIFFERENT session's captured SHA is excluded (not laundered in).
307
- const mixed = [
308
- { sha: HEAD.toLowerCase(), session_id: 'some-OTHER-session' },
309
- { sha: 'a'.repeat(40), session_id: SESS },
310
- ];
311
- const set = gate.buildCapturedShaSet(SESS, mixed);
312
- assert('12c. only originating-session SHAs bound', set.has('a'.repeat(40)) && !set.has(HEAD.toLowerCase()),
313
- `set=${[...set].join(',')}`);
314
-
315
- // 12d. end-to-end consequence: null session + DIFFERENT-session SHA means
316
- // the SHA is never bound, so even a structurally valid post is denied.
317
- // (Drive verifyLayer2 with the EMPTY set buildCapturedShaSet would have
318
- // produced were the session real-but-mismatched; null short-circuits
319
- // earlier, but this proves a cross-session SHA never reaches capture.)
320
- const crossSet = gate.buildCapturedShaSet(SESS, otherSessionRows); // SESS has none of these
321
- const r = await runL2({
322
- commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
323
- }, crossSet);
324
- assert('12d. cross-session-only SHA not captured for this session -> DENY',
325
- r && /was not captured by Layer 1/.test(r), r || 'no denial');
326
- }
327
-
328
- // -------------------------------------------------------------------------
329
- // Fix 3a — assertGitRepoAvailable: a non-git directory is fail-closed with a
330
- // distinct `truth-gate repo unavailable` reason (not `ref not found`).
331
- // Drive it directly with the function's own repo via TRUTH_GATE_REPO capture:
332
- // since TRUTH_GATE_REPO is module-captured to `repo` (a real git tree), the
333
- // happy path must NOT throw; assert that, and assert a non-git path denies by
334
- // re-importing the hook under a fresh env pointed at a non-git dir.
335
- {
336
- // happy path: the live throwaway repo is a work tree -> no throw
337
- let ok = true;
338
- try { gate.assertGitRepoAvailable(); } catch { ok = false; }
339
- assert('13a. assertGitRepoAvailable passes for a real git work tree', ok === true);
340
-
341
- // non-git path: re-load the hook with RDC_TRUTH_GATE_REPO pointed at a
342
- // brand-new empty (non-git) temp dir; assertGitRepoAvailable must DENY.
343
- const nonGit = mkdtempSync(join(tmpdir(), 'l2-nongit-'));
344
- const prevRepo = process.env.RDC_TRUTH_GATE_REPO;
345
- process.env.RDC_TRUTH_GATE_REPO = nonGit;
346
- delete require.cache[require.resolve(HOOK)];
347
- const gate2 = require(HOOK);
348
- let denied = null;
349
- try { gate2.assertGitRepoAvailable(); }
350
- catch (e) { if (e instanceof gate2.GateDenied) denied = e.reason; else throw e; }
351
- assert('13a. non-git dir -> DENY (truth-gate repo unavailable)',
352
- denied && /truth-gate repo unavailable/.test(denied), denied || 'no denial');
353
- // restore env + module cache for any later use
354
- process.env.RDC_TRUTH_GATE_REPO = prevRepo;
355
- delete require.cache[require.resolve(HOOK)];
356
- rmSync(nonGit, { recursive: true, force: true });
357
- }
358
- })();
359
-
360
- rmSync(repo, { recursive: true, force: true });
361
-
362
- // ---------------------------------------------------------------------------
363
- if (failures.length > 0) {
364
- console.error('\nwork-item-exit-gate L2 tests — FAIL\n');
365
- for (const f of failures) console.error(` - ${f}`);
366
- process.exit(1);
367
- }
368
- console.log('\nwork-item-exit-gate L2 tests — PASS');
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Truth Gate 3.0 — Layer 2 (FUSED evidence gate) tests.
4
+ *
5
+ * Exercises EVERY rejection branch of work-item-exit-gate.js verifyLayer2 plus
6
+ * the fully-valid close, against a REAL throwaway git repo (no live DB). The
7
+ * gate's git/disk checks run against RDC_TRUTH_GATE_REPO, which we point at the
8
+ * throwaway repo. verifyLayer2 throws GateDenied on rejection; we assert on the
9
+ * thrown reason. A fully-valid close throws nothing.
10
+ *
11
+ * Branches proven:
12
+ * 1. commit-not-resolving (free-typed bad SHA) -> DENY
13
+ * 2. commit-not-captured (real SHA, not in L1 set) -> DENY
14
+ * 3. files ∉ commit -> DENY
15
+ * 4. file not on disk (in commit, deleted from tree) -> DENY
16
+ * 5. prose verification ("HTTP 200") -> DENY
17
+ * 6. witness:"agent" -> DENY
18
+ * 7. fully-valid close (real SHA captured, files in -> ALLOW (no throw)
19
+ * commit + on disk, machine artifact, valid witness)
20
+ * 8. fail-closed: any internal error during L2 -> DENY (via main wrapper)
21
+ *
22
+ * Also a fused-primitive assertion: run_evidence_gate emits a verdict ONLY
23
+ * after running the command (no verdict without a run).
24
+ *
25
+ * Run: node tests/work-item-exit-gate-l2.test.mjs
26
+ */
27
+ import { mkdtempSync, rmSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join, resolve, dirname } from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import { execFileSync } from 'node:child_process';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url));
35
+ const REPO_ROOT = resolve(__dirname, '..');
36
+ const HOOK = join(REPO_ROOT, 'hooks', 'work-item-exit-gate.js');
37
+ const GATE_LIB = pathToFileURL(join(REPO_ROOT, 'hooks', 'lib', 'run-evidence-gate.mjs')).href;
38
+
39
+ const failures = [];
40
+ function assert(name, condition, detail = '') {
41
+ if (!condition) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
42
+ else process.stdout.write(` ok ${name}\n`);
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Build a throwaway git repo: commit two files, capture the FULL HEAD sha.
47
+ // ---------------------------------------------------------------------------
48
+ const repo = mkdtempSync(join(tmpdir(), 'l2-repo-'));
49
+ const g = (...a) => execFileSync('git', a, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
50
+ g('init', '-q');
51
+ g('config', 'user.email', 'test@example.com');
52
+ g('config', 'user.name', 'Test');
53
+ writeFileSync(join(repo, 'a.txt'), 'alpha\n');
54
+ writeFileSync(join(repo, 'b.txt'), 'beta\n');
55
+ g('add', 'a.txt', 'b.txt');
56
+ g('commit', '-q', '-m', 'feat: seed');
57
+ const HEAD = g('rev-parse', 'HEAD').trim(); // real FULL 40-hex SHA
58
+
59
+ // CRITICAL: set the repo the gate runs git against BEFORE requiring the hook,
60
+ // because TRUTH_GATE_REPO is captured at module load.
61
+ process.env.RDC_TRUTH_GATE_REPO = repo;
62
+ const require = createRequire(import.meta.url);
63
+ const gate = require(HOOK);
64
+
65
+ const SESS = 'sess-l2-origin';
66
+ // Build an `item` row shaped like the work_items SELECT the gate reads.
67
+ function makeItem(post) {
68
+ return {
69
+ id: '11111111-2222-3333-4444-555555555555',
70
+ item_type: 'task',
71
+ status: 'review',
72
+ session_id: SESS,
73
+ implementation_report: { codeflow_post: post },
74
+ };
75
+ }
76
+ const statusCall = { id: '11111111-2222-3333-4444-555555555555', actorSessionId: 'validator-x', actorRole: 'validator' };
77
+
78
+ // A machine-parseable verification artifact (exit-code shape).
79
+ const MACHINE_VERIF = { exit_code: 0, label: 'tsc' };
80
+
81
+ // Helper: run verifyLayer2 and return the GateDenied reason, or null if it passed.
82
+ async function runL2(post, capturedShas) {
83
+ try {
84
+ await gate.verifyLayer2(statusCall, makeItem(post), capturedShas);
85
+ return null; // ALLOW
86
+ } catch (e) {
87
+ if (e instanceof gate.GateDenied) return e.reason;
88
+ throw e; // unexpected internal error — surface it
89
+ }
90
+ }
91
+
92
+ const CAPTURED = new Set([HEAD.toLowerCase()]); // L1-captured set for this item/session
93
+
94
+ // ---------------------------------------------------------------------------
95
+ await (async () => {
96
+ // 1. commit does NOT resolve (free-typed bad SHA)
97
+ {
98
+ const reason = await runL2({
99
+ commit: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
100
+ files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
101
+ }, CAPTURED);
102
+ assert('1. free-typed/bad SHA -> DENY', reason && /does not resolve to a real commit/.test(reason), reason || 'no denial');
103
+ }
104
+
105
+ // 2. commit resolves but was NOT captured by L1 for this item/session
106
+ {
107
+ const reason = await runL2({
108
+ commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
109
+ }, new Set()); // empty captured set
110
+ assert('2. commit not captured by L1 -> DENY', reason && /was not captured by Layer 1/.test(reason), reason || 'no denial');
111
+ }
112
+
113
+ // 3. files_changed entry NOT in the commit
114
+ {
115
+ const reason = await runL2({
116
+ commit: HEAD, files_changed: ['nonexistent-in-commit.txt'], verification: [MACHINE_VERIF], witness: 'ci',
117
+ }, CAPTURED);
118
+ assert('3. files not in commit -> DENY', reason && /is NOT among the files changed by commit/.test(reason), reason || 'no denial');
119
+ }
120
+
121
+ // 4. file is in the commit but NOT on disk (delete it from the work tree)
122
+ {
123
+ unlinkSync(join(repo, 'b.txt')); // b.txt is in the commit, now gone from disk
124
+ const reason = await runL2({
125
+ commit: HEAD, files_changed: ['b.txt'], verification: [MACHINE_VERIF], witness: 'human-review',
126
+ }, CAPTURED);
127
+ assert('4. file not on disk -> DENY', reason && /does not exist on disk/.test(reason), reason || 'no denial');
128
+ writeFileSync(join(repo, 'b.txt'), 'beta\n'); // restore for later valid-close test
129
+ }
130
+
131
+ // 5. prose / proxy verification ("HTTP 200")
132
+ {
133
+ const reason = await runL2({
134
+ commit: HEAD, files_changed: ['a.txt'], verification: ['HTTP 200'], witness: 'validator-rerun',
135
+ }, CAPTURED);
136
+ assert('5. prose verification -> DENY', reason && /prose\/proxy, not a captured artifact/.test(reason), reason || 'no denial');
137
+ }
138
+
139
+ // 6. witness:"agent" (self-witness)
140
+ {
141
+ const reason = await runL2({
142
+ commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'agent',
143
+ }, CAPTURED);
144
+ assert('6. witness:agent -> DENY', reason && /witness must be one of/.test(reason), reason || 'no denial');
145
+ }
146
+
147
+ // 7. fully-valid close -> ALLOW (no throw)
148
+ {
149
+ const reason = await runL2({
150
+ commit: HEAD, files_changed: ['a.txt', 'b.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
151
+ }, CAPTURED);
152
+ assert('7. fully-valid close -> ALLOW', reason === null, `unexpected denial: ${reason}`);
153
+ }
154
+
155
+ // 7b. fully-valid close with a REAL fused run-evidence-gate artifact as verification
156
+ {
157
+ const lib = await import(GATE_LIB);
158
+ const fused = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(0)'], label: 'fused-pass' });
159
+ const reason = await runL2({
160
+ commit: HEAD, files_changed: ['a.txt'], verification: [fused], witness: 'ci',
161
+ }, CAPTURED);
162
+ assert('7b. fused artifact verification -> ALLOW', reason === null, `unexpected denial: ${reason}`);
163
+ assert('7b. fused artifact has verdict only after run', fused.ran === true && fused.verdict === 'pass' && typeof fused.output_sha256 === 'string', JSON.stringify(fused));
164
+ }
165
+
166
+ // 8. FUSED primitive: no verdict possible without a run (invalid spec -> error, ran:false)
167
+ {
168
+ const lib = await import(GATE_LIB);
169
+ const bad = lib.runEvidenceGate({}); // no command -> cannot run
170
+ assert('8. fused: no run -> no pass verdict (fail-closed)', bad.ran === false && bad.verdict === 'error', JSON.stringify(bad));
171
+ // and a run that fails yields verdict:'fail', never silently 'pass'
172
+ const failRun = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(2)'] });
173
+ assert('8. fused: failing run -> verdict fail', failRun.ran === true && failRun.verdict === 'fail' && failRun.exit_code === 2, JSON.stringify(failRun));
174
+ }
175
+
176
+ // 9. FAIL-CLOSED contract: empty files_changed / empty verification both DENY
177
+ {
178
+ const r1 = await runL2({ commit: HEAD, files_changed: [], verification: [MACHINE_VERIF], witness: 'ci' }, CAPTURED);
179
+ assert('9a. empty files_changed -> DENY', r1 && /files_changed is empty/.test(r1), r1 || 'no denial');
180
+ const r2 = await runL2({ commit: HEAD, files_changed: ['a.txt'], verification: [], witness: 'ci' }, CAPTURED);
181
+ assert('9b. empty verification -> DENY', r2 && /verification is empty/.test(r2), r2 || 'no denial');
182
+ const r3 = await runL2({ commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF] }, CAPTURED); // no witness
183
+ assert('9c. missing witness -> DENY', r3 && /witness must be one of/.test(r3), r3 || 'no denial');
184
+ }
185
+
186
+ // 10. Baru-trap: a SHORT prefix of a real commit must NOT resolve-and-pass as
187
+ // the captured full SHA. We claim a 8-char prefix; even though it resolves to
188
+ // the same commit, the captured set holds the FULL sha, and the gate compares
189
+ // full-to-full, so a prefix that git expands still equals HEAD -> captured.
190
+ // The hardening we prove: the gate stores/compares the FULL resolved sha, so a
191
+ // WRONG short prefix (one that resolves to a DIFFERENT/absent commit) is caught
192
+ // by branch 1/2 above. Here we assert the FULL-sha resolution itself:
193
+ {
194
+ const full = gate.resolveFullSha(HEAD.slice(0, 8)); // valid short prefix of HEAD
195
+ assert('10. resolveFullSha expands a valid prefix to the FULL 40-hex sha',
196
+ full === HEAD.toLowerCase() && /^[0-9a-f]{40}$/.test(full), full || 'null');
197
+ const none = gate.resolveFullSha('00000000'); // prefix of no commit
198
+ assert('10. resolveFullSha returns null for a non-existent prefix', none === null, none || 'not-null');
199
+ }
200
+
201
+ // -------------------------------------------------------------------------
202
+ // Fix 1 — OUTCOME GATE: a verification that RAN but did NOT pass must DENY.
203
+ // isMachineArtifact passes (correct shape) but isPassingArtifact must fail.
204
+ // -------------------------------------------------------------------------
205
+ {
206
+ // 11a. exit_code:1 (a real machine shape, but a FAILING run) -> DENY
207
+ const r1 = await runL2({
208
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ exit_code: 1 }], witness: 'validator-rerun',
209
+ }, CAPTURED);
210
+ assert('11a. verification exit_code:1 -> DENY (verification-not-passing)',
211
+ r1 && /verification-not-passing/.test(r1), r1 || 'no denial');
212
+
213
+ // 11b. fused run-evidence-gate artifact with verdict:'fail' -> DENY
214
+ const lib = await import(GATE_LIB);
215
+ const fusedFail = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(1)'], label: 'fused-fail' });
216
+ assert('11b. fused failing run has verdict:fail', fusedFail.ran === true && fusedFail.verdict === 'fail', JSON.stringify(fusedFail));
217
+ const r2 = await runL2({
218
+ commit: HEAD, files_changed: ['a.txt'], verification: [fusedFail], witness: 'ci',
219
+ }, CAPTURED);
220
+ assert('11b. fused verdict:fail -> DENY (verification-not-passing)',
221
+ r2 && /verification-not-passing/.test(r2), r2 || 'no denial');
222
+
223
+ // 11c. http_status:500 (error status) -> DENY
224
+ const r3 = await runL2({
225
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ http_status: 500 }], witness: 'human-review',
226
+ }, CAPTURED);
227
+ assert('11c. verification http_status:500 -> DENY (verification-not-passing)',
228
+ r3 && /verification-not-passing/.test(r3), r3 || 'no denial');
229
+
230
+ // 11d. failed test-runner JSON ({passed:3, failed:2}) -> DENY
231
+ const r4 = await runL2({
232
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ passed: 3, failed: 2 }], witness: 'ci',
233
+ }, CAPTURED);
234
+ assert('11d. verification {passed:3,failed:2} -> DENY (verification-not-passing)',
235
+ r4 && /verification-not-passing/.test(r4), r4 || 'no denial');
236
+
237
+ // 11e. tsc_errors:4 -> DENY
238
+ const r5 = await runL2({
239
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ tsc_errors: 4 }], witness: 'ci',
240
+ }, CAPTURED);
241
+ assert('11e. verification tsc_errors:4 -> DENY (verification-not-passing)',
242
+ r5 && /verification-not-passing/.test(r5), r5 || 'no denial');
243
+
244
+ // 11f. PASSING shapes still ALLOW (regression guard for the outcome gate).
245
+ const okExit = await runL2({
246
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ exit_code: 0 }], witness: 'validator-rerun',
247
+ }, CAPTURED);
248
+ assert('11f. exit_code:0 still ALLOWs', okExit === null, `unexpected denial: ${okExit}`);
249
+ const okHttp = await runL2({
250
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ http_status: 200 }], witness: 'ci',
251
+ }, CAPTURED);
252
+ assert('11f. http_status:200 still ALLOWs', okHttp === null, `unexpected denial: ${okHttp}`);
253
+ const okTests = await runL2({
254
+ commit: HEAD, files_changed: ['a.txt'], verification: [{ passed: 5, total: 5 }], witness: 'ci',
255
+ }, CAPTURED);
256
+ assert('11f. {passed:5,total:5} still ALLOWs', okTests === null, `unexpected denial: ${okTests}`);
257
+ }
258
+
259
+ // 11g. isPassingArtifact unit checks (direct, lib-level).
260
+ {
261
+ const lib = await import(GATE_LIB);
262
+ const { isPassingArtifact } = lib;
263
+ assert('11g. isPassingArtifact rejects {exit_code:1}', isPassingArtifact({ exit_code: 1 }) === false);
264
+ assert('11g. isPassingArtifact accepts {exit_code:0}', isPassingArtifact({ exit_code: 0 }) === true);
265
+ assert('11g. isPassingArtifact rejects {http_status:500}', isPassingArtifact({ http_status: 500 }) === false);
266
+ assert('11g. isPassingArtifact accepts {http_status:204}', isPassingArtifact({ http_status: 204 }) === true);
267
+ assert('11g. isPassingArtifact rejects {passed:1,failed:1}', isPassingArtifact({ passed: 1, failed: 1 }) === false);
268
+ assert('11g. isPassingArtifact accepts {passed:5,total:5}', isPassingArtifact({ passed: 5, total: 5 }) === true);
269
+ assert('11g. isPassingArtifact rejects {passed:4,total:5}', isPassingArtifact({ passed: 4, total: 5 }) === false);
270
+ assert('11g. isPassingArtifact rejects prose "HTTP 200"', isPassingArtifact('HTTP 200') === false);
271
+ const fp = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(0)'] });
272
+ const ff = lib.runEvidenceGate({ command: process.execPath, args: ['-e', 'process.exit(1)'] });
273
+ assert('11g. isPassingArtifact accepts fused verdict:pass', isPassingArtifact(fp) === true);
274
+ assert('11g. isPassingArtifact rejects fused verdict:fail', isPassingArtifact(ff) === false);
275
+ }
276
+
277
+ // -------------------------------------------------------------------------
278
+ // Fix 2 — NULL ORIGINATING SESSION must be FAIL-CLOSED. A null session must
279
+ // NOT disable the per-session commit binding (the old `!originatingSession`
280
+ // short-circuit accepted ANY session's captured SHA). buildCapturedShaSet
281
+ // DENIES when the originating session is null/empty.
282
+ // -------------------------------------------------------------------------
283
+ {
284
+ const otherSessionRows = [{ sha: HEAD.toLowerCase(), session_id: 'some-OTHER-session' }];
285
+
286
+ // 12a. session_id=null + a SHA captured by a DIFFERENT session -> DENY
287
+ let denied = null;
288
+ try {
289
+ gate.buildCapturedShaSet(null, otherSessionRows);
290
+ } catch (e) {
291
+ if (e instanceof gate.GateDenied) denied = e.reason; else throw e;
292
+ }
293
+ assert('12a. null originating session -> DENY (no provenance to bind)',
294
+ denied && /no originating session/.test(denied), denied || 'no denial');
295
+
296
+ // 12b. empty-string session is treated the same (fail-closed) -> DENY
297
+ let denied2 = null;
298
+ try {
299
+ gate.buildCapturedShaSet('', otherSessionRows);
300
+ } catch (e) {
301
+ if (e instanceof gate.GateDenied) denied2 = e.reason; else throw e;
302
+ }
303
+ assert('12b. empty originating session -> DENY', denied2 && /no originating session/.test(denied2), denied2 || 'no denial');
304
+
305
+ // 12c. with a real originating session, ONLY that session's SHAs are kept;
306
+ // a DIFFERENT session's captured SHA is excluded (not laundered in).
307
+ const mixed = [
308
+ { sha: HEAD.toLowerCase(), session_id: 'some-OTHER-session' },
309
+ { sha: 'a'.repeat(40), session_id: SESS },
310
+ ];
311
+ const set = gate.buildCapturedShaSet(SESS, mixed);
312
+ assert('12c. only originating-session SHAs bound', set.has('a'.repeat(40)) && !set.has(HEAD.toLowerCase()),
313
+ `set=${[...set].join(',')}`);
314
+
315
+ // 12d. end-to-end consequence: null session + DIFFERENT-session SHA means
316
+ // the SHA is never bound, so even a structurally valid post is denied.
317
+ // (Drive verifyLayer2 with the EMPTY set buildCapturedShaSet would have
318
+ // produced were the session real-but-mismatched; null short-circuits
319
+ // earlier, but this proves a cross-session SHA never reaches capture.)
320
+ const crossSet = gate.buildCapturedShaSet(SESS, otherSessionRows); // SESS has none of these
321
+ const r = await runL2({
322
+ commit: HEAD, files_changed: ['a.txt'], verification: [MACHINE_VERIF], witness: 'validator-rerun',
323
+ }, crossSet);
324
+ assert('12d. cross-session-only SHA not captured for this session -> DENY',
325
+ r && /was not captured by Layer 1/.test(r), r || 'no denial');
326
+ }
327
+
328
+ // -------------------------------------------------------------------------
329
+ // Fix 3a — assertGitRepoAvailable: a non-git directory is fail-closed with a
330
+ // distinct `truth-gate repo unavailable` reason (not `ref not found`).
331
+ // Drive it directly with the function's own repo via TRUTH_GATE_REPO capture:
332
+ // since TRUTH_GATE_REPO is module-captured to `repo` (a real git tree), the
333
+ // happy path must NOT throw; assert that, and assert a non-git path denies by
334
+ // re-importing the hook under a fresh env pointed at a non-git dir.
335
+ {
336
+ // happy path: the live throwaway repo is a work tree -> no throw
337
+ let ok = true;
338
+ try { gate.assertGitRepoAvailable(); } catch { ok = false; }
339
+ assert('13a. assertGitRepoAvailable passes for a real git work tree', ok === true);
340
+
341
+ // non-git path: re-load the hook with RDC_TRUTH_GATE_REPO pointed at a
342
+ // brand-new empty (non-git) temp dir; assertGitRepoAvailable must DENY.
343
+ const nonGit = mkdtempSync(join(tmpdir(), 'l2-nongit-'));
344
+ const prevRepo = process.env.RDC_TRUTH_GATE_REPO;
345
+ process.env.RDC_TRUTH_GATE_REPO = nonGit;
346
+ delete require.cache[require.resolve(HOOK)];
347
+ const gate2 = require(HOOK);
348
+ let denied = null;
349
+ try { gate2.assertGitRepoAvailable(); }
350
+ catch (e) { if (e instanceof gate2.GateDenied) denied = e.reason; else throw e; }
351
+ assert('13a. non-git dir -> DENY (truth-gate repo unavailable)',
352
+ denied && /truth-gate repo unavailable/.test(denied), denied || 'no denial');
353
+ // restore env + module cache for any later use
354
+ process.env.RDC_TRUTH_GATE_REPO = prevRepo;
355
+ delete require.cache[require.resolve(HOOK)];
356
+ rmSync(nonGit, { recursive: true, force: true });
357
+ }
358
+ })();
359
+
360
+ rmSync(repo, { recursive: true, force: true });
361
+
362
+ // ---------------------------------------------------------------------------
363
+ if (failures.length > 0) {
364
+ console.error('\nwork-item-exit-gate L2 tests — FAIL\n');
365
+ for (const f of failures) console.error(` - ${f}`);
366
+ process.exit(1);
367
+ }
368
+ console.log('\nwork-item-exit-gate L2 tests — PASS');