@lifeaitools/rdc-skills 0.24.38 → 0.24.41

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 (196) hide show
  1. package/.claude/settings.json +15 -15
  2. package/.claude-plugin/marketplace.json +21 -21
  3. package/.claude-plugin/plugin.json +1371 -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 +376 -375
  10. package/README.sandbox.md +3 -3
  11. package/RELEASE.md +42 -0
  12. package/assets/watcher/viewer.html +164 -164
  13. package/bin/rdc-skills-mcp.mjs +316 -316
  14. package/commands/build.md +183 -183
  15. package/commands/collab.md +180 -180
  16. package/commands/deploy.md +152 -152
  17. package/commands/design.md +31 -31
  18. package/commands/edit.md +28 -28
  19. package/commands/fixit.md +124 -124
  20. package/commands/handoff.md +173 -173
  21. package/commands/help.md +95 -95
  22. package/commands/overnight.md +220 -220
  23. package/commands/plan.md +158 -158
  24. package/commands/preplan.md +131 -131
  25. package/commands/prototype.md +145 -145
  26. package/commands/release.md +49 -49
  27. package/commands/report.md +99 -99
  28. package/commands/review.md +120 -120
  29. package/commands/self-test.md +113 -113
  30. package/commands/status.md +86 -86
  31. package/commands/watch.md +98 -98
  32. package/commands/workitems.md +137 -137
  33. package/git-sha.json +1 -1
  34. package/guides/agent-bootstrap.md +295 -295
  35. package/guides/agents/backend.md +104 -104
  36. package/guides/agents/content.md +94 -94
  37. package/guides/agents/cs2.md +56 -56
  38. package/guides/agents/data.md +87 -87
  39. package/guides/agents/design.md +77 -77
  40. package/guides/agents/frontend.md +92 -92
  41. package/guides/agents/infrastructure.md +81 -81
  42. package/guides/agents/setup.md +281 -281
  43. package/guides/agents/verify.md +151 -151
  44. package/guides/agents/viz.md +106 -106
  45. package/guides/backend.md +146 -146
  46. package/guides/content.md +147 -147
  47. package/guides/cs2.md +190 -190
  48. package/guides/data.md +123 -123
  49. package/guides/design.md +116 -116
  50. package/guides/engineering-behavior.md +43 -43
  51. package/guides/escalation-protocol.md +125 -125
  52. package/guides/frontend.md +151 -151
  53. package/guides/history-md-spec.md +297 -297
  54. package/guides/infrastructure.md +179 -179
  55. package/guides/lessons-learned-spec.md +151 -145
  56. package/guides/output-contract.md +108 -108
  57. package/guides/publish-md-spec.md +289 -289
  58. package/guides/rdc-skills-startup.md +30 -30
  59. package/guides/verify.md +11 -11
  60. package/hooks/check-cwd.js +31 -31
  61. package/hooks/check-rdc-environment.js +164 -164
  62. package/hooks/check-services.js +6 -6
  63. package/hooks/check-stale-work-items.js +19 -19
  64. package/hooks/foreground-process-gate.js +128 -128
  65. package/hooks/gate-watchdog-selfcheck.js +257 -257
  66. package/hooks/hook-logger.js +25 -25
  67. package/hooks/lib/run-evidence-gate.mjs +241 -241
  68. package/hooks/no-stop-open-epics.js +127 -127
  69. package/hooks/post-tool-batch-gate.js +203 -203
  70. package/hooks/post-work-check.js +21 -21
  71. package/hooks/postcompact-log.js +13 -13
  72. package/hooks/precompact-log.js +13 -13
  73. package/hooks/rate-limit-retry.js +46 -46
  74. package/hooks/rdc-invocation-marker.js +157 -157
  75. package/hooks/rdc-output-contract-gate.js +94 -94
  76. package/hooks/require-work-item-on-commit.js +294 -294
  77. package/hooks/restart-brief.js +19 -19
  78. package/hooks/run-hidden-hook.ps1 +47 -47
  79. package/hooks/task-completed-gate.js +274 -274
  80. package/hooks/work-item-exit-gate.js +944 -944
  81. package/lib/catalog.mjs +236 -236
  82. package/lib/cloud-rewrite.mjs +155 -155
  83. package/package.json +57 -56
  84. package/rules/work-items-rpc.md +520 -520
  85. package/scaffold/templates/HISTORY.md.template +39 -39
  86. package/scaffold/templates/PUBLISH.md.template +21 -21
  87. package/scaffold/templates/brochure-studio-default.html +70 -70
  88. package/scripts/acceptance.mjs +502 -502
  89. package/scripts/fixtures/guides/bad-guide.md +15 -15
  90. package/scripts/fixtures/guides-clean/good-guide.md +16 -16
  91. package/scripts/install-rdc-skills.js +1289 -1289
  92. package/scripts/install.ps1 +202 -202
  93. package/scripts/install.sh +132 -132
  94. package/scripts/lib/assertions.mjs +287 -287
  95. package/scripts/lib/manifest-schema.mjs +754 -754
  96. package/scripts/lib/runner.mjs +465 -465
  97. package/scripts/lib/sandbox.mjs +435 -435
  98. package/scripts/prepack.mjs +32 -32
  99. package/scripts/rdc-brochure.mjs +482 -464
  100. package/scripts/rdc-design-cli.mjs +134 -134
  101. package/scripts/rebuild-mcp.mjs +107 -107
  102. package/scripts/self-test.mjs +1460 -1460
  103. package/scripts/stamp-git-sha.mjs +29 -29
  104. package/scripts/test-guide-validator.mjs +196 -196
  105. package/scripts/test-rdc-hooks.mjs +145 -145
  106. package/scripts/uninstall.ps1 +77 -77
  107. package/scripts/uninstall.sh +69 -69
  108. package/scripts/update.ps1 +43 -43
  109. package/scripts/update.sh +43 -43
  110. package/scripts/validate-place-histories.js +461 -461
  111. package/scripts/validate-publish-manifests.js +424 -424
  112. package/scripts/watch-init.mjs +100 -100
  113. package/skills/brochure/SKILL.md +107 -107
  114. package/skills/build/SKILL.md +563 -563
  115. package/skills/channel-formatter/SKILL.md +533 -533
  116. package/skills/co-develop/SKILL.md +196 -196
  117. package/skills/collab/SKILL.md +239 -239
  118. package/skills/convert/SKILL.md +140 -140
  119. package/skills/deploy/SKILL.md +541 -541
  120. package/skills/design/SKILL.md +211 -211
  121. package/skills/design/reference/ownership.md +16 -16
  122. package/skills/design/reference/rampa.md +92 -92
  123. package/skills/design/reference/studio-model.md +153 -153
  124. package/skills/edit/SKILL.md +98 -98
  125. package/skills/fixit/SKILL.md +165 -165
  126. package/skills/fs-mcp/SKILL.md +148 -148
  127. package/skills/handoff/SKILL.md +236 -200
  128. package/skills/help/SKILL.md +143 -143
  129. package/skills/housekeeping/SKILL.md +219 -160
  130. package/skills/lifeai-brochure-author/SKILL.md +340 -340
  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/rdc-brochure.test.json +34 -34
  148. package/skills/tests/rdc-build.test.json +36 -36
  149. package/skills/tests/rdc-channel-formatter.test.json +45 -45
  150. package/skills/tests/rdc-co-develop.test.json +29 -29
  151. package/skills/tests/rdc-collab.test.json +29 -29
  152. package/skills/tests/rdc-convert.test.json +35 -35
  153. package/skills/tests/rdc-deploy.test.json +30 -30
  154. package/skills/tests/rdc-design.test.json +27 -27
  155. package/skills/tests/rdc-edit.test.json +29 -29
  156. package/skills/tests/rdc-fixit.test.json +36 -36
  157. package/skills/tests/rdc-fs-mcp.test.json +36 -36
  158. package/skills/tests/rdc-handoff.test.json +28 -28
  159. package/skills/tests/rdc-help.test.json +29 -29
  160. package/skills/tests/rdc-housekeeping.test.json +32 -28
  161. package/skills/tests/rdc-lifeai-brochure-author.test.json +35 -35
  162. package/skills/tests/rdc-overnight.test.json +37 -37
  163. package/skills/tests/rdc-plan.test.json +27 -27
  164. package/skills/tests/rdc-preplan.test.json +31 -31
  165. package/skills/tests/rdc-prototype.test.json +28 -28
  166. package/skills/tests/rdc-rdc-brochurify.test.json +23 -23
  167. package/skills/tests/rdc-rdc-extract-verifier-rules.test.json +34 -34
  168. package/skills/tests/rdc-release.test.json +29 -29
  169. package/skills/tests/rdc-report.test.json +28 -28
  170. package/skills/tests/rdc-review.test.json +29 -29
  171. package/skills/tests/rdc-rpms-filemap.test.json +28 -28
  172. package/skills/tests/rdc-self-test.test.json +24 -24
  173. package/skills/tests/rdc-status.test.json +29 -29
  174. package/skills/tests/rdc-terminal-config.test.json +29 -29
  175. package/skills/tests/rdc-watch.test.json +24 -24
  176. package/skills/tests/rdc-workitems.test.json +27 -27
  177. package/skills/watch/SKILL.md +97 -97
  178. package/skills/workitems/SKILL.md +151 -151
  179. package/tests/acceptance.test.mjs +59 -59
  180. package/tests/channel-formatter.contract.test.mjs +251 -251
  181. package/tests/curl-surface.test.mjs +289 -289
  182. package/tests/harness-gates.test.mjs +325 -325
  183. package/tests/help-surface.test.mjs +61 -61
  184. package/tests/housekeeping-lessons-triage.test.mjs +49 -0
  185. package/tests/install-rdc-skills.test.mjs +49 -49
  186. package/tests/lessons-pipeline-contract.test.mjs +26 -0
  187. package/tests/manifest-contract-fields.test.mjs +78 -78
  188. package/tests/mcp.test.mjs +271 -271
  189. package/tests/rdc-brochure.test.mjs +125 -0
  190. package/tests/release-contract.test.mjs +16 -0
  191. package/tests/require-work-item-on-commit.test.mjs +162 -162
  192. package/tests/run-evidence-gate.test.mjs +82 -82
  193. package/tests/skill-test-matrix.test.mjs +66 -66
  194. package/tests/validate-skills.js +27 -27
  195. package/tests/work-item-exit-gate-l2.test.mjs +368 -368
  196. package/tests/work-item-exit-gate-l3.test.mjs +197 -197
@@ -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');