@lifeaitools/rdc-skills 0.24.42 → 0.25.1

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 (202) hide show
  1. package/.claude/settings.json +15 -15
  2. package/.claude-plugin/marketplace.json +21 -21
  3. package/.claude-plugin/plugin.json +1560 -1371
  4. package/.github/workflows/publish.yml +34 -34
  5. package/.github/workflows/self-test.yml +58 -58
  6. package/CHANGELOG.md +322 -310
  7. package/LICENSE +21 -21
  8. package/MANIFEST.md +224 -221
  9. package/README.md +377 -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 +1401 -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 +578 -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/env/SKILL.md +141 -0
  125. package/skills/fixit/SKILL.md +203 -165
  126. package/skills/fs-mcp/SKILL.md +183 -148
  127. package/skills/handoff/SKILL.md +236 -236
  128. package/skills/help/SKILL.md +143 -143
  129. package/skills/housekeeping/SKILL.md +160 -219
  130. package/skills/lifeai-brochure-author/SKILL.md +340 -340
  131. package/skills/new-model/SKILL.md +49 -0
  132. package/skills/onramp/SKILL.md +1459 -0
  133. package/skills/overnight/SKILL.md +251 -251
  134. package/skills/plan/SKILL.md +345 -345
  135. package/skills/preplan/SKILL.md +90 -90
  136. package/skills/prototype/SKILL.md +150 -150
  137. package/skills/rdc-brochurify/SKILL.md +245 -245
  138. package/skills/rdc-extract-verifier-rules/SKILL.md +191 -191
  139. package/skills/regen-media/SKILL.md +94 -0
  140. package/skills/release/SKILL.md +140 -140
  141. package/skills/report/SKILL.md +100 -100
  142. package/skills/review/SKILL.md +160 -152
  143. package/skills/rpms-filemap/SKILL.cloud.md +111 -111
  144. package/skills/rpms-filemap/SKILL.md +111 -111
  145. package/skills/self-test/SKILL.md +132 -132
  146. package/skills/status/SKILL.md +99 -99
  147. package/skills/terminal-config/SKILL.md +62 -62
  148. package/skills/tests/MATRIX.md +55 -54
  149. package/skills/tests/README.md +47 -47
  150. package/skills/tests/onramp.test.json +87 -0
  151. package/skills/tests/rdc-brochure.test.json +34 -34
  152. package/skills/tests/rdc-build.test.json +36 -36
  153. package/skills/tests/rdc-channel-formatter.test.json +45 -45
  154. package/skills/tests/rdc-co-develop.test.json +29 -29
  155. package/skills/tests/rdc-collab.test.json +29 -29
  156. package/skills/tests/rdc-convert.test.json +35 -35
  157. package/skills/tests/rdc-deploy.test.json +30 -30
  158. package/skills/tests/rdc-design.test.json +27 -27
  159. package/skills/tests/rdc-edit.test.json +29 -29
  160. package/skills/tests/rdc-fixit.test.json +36 -36
  161. package/skills/tests/rdc-fs-mcp.test.json +36 -36
  162. package/skills/tests/rdc-handoff.test.json +28 -28
  163. package/skills/tests/rdc-help.test.json +29 -29
  164. package/skills/tests/rdc-housekeeping.test.json +28 -32
  165. package/skills/tests/rdc-lifeai-brochure-author.test.json +35 -35
  166. package/skills/tests/rdc-overnight.test.json +37 -37
  167. package/skills/tests/rdc-plan.test.json +27 -27
  168. package/skills/tests/rdc-preplan.test.json +31 -31
  169. package/skills/tests/rdc-prototype.test.json +28 -28
  170. package/skills/tests/rdc-rdc-brochurify.test.json +23 -23
  171. package/skills/tests/rdc-rdc-extract-verifier-rules.test.json +34 -34
  172. package/skills/tests/rdc-regen-media.test.json +29 -0
  173. package/skills/tests/rdc-release.test.json +29 -29
  174. package/skills/tests/rdc-report.test.json +28 -28
  175. package/skills/tests/rdc-review.test.json +29 -29
  176. package/skills/tests/rdc-rpms-filemap.test.json +28 -28
  177. package/skills/tests/rdc-self-test.test.json +24 -24
  178. package/skills/tests/rdc-status.test.json +29 -29
  179. package/skills/tests/rdc-terminal-config.test.json +29 -29
  180. package/skills/tests/rdc-watch.test.json +24 -24
  181. package/skills/tests/rdc-workitems.test.json +27 -27
  182. package/skills/watch/SKILL.md +97 -97
  183. package/skills/workitems/SKILL.md +151 -151
  184. package/tests/acceptance.test.mjs +59 -59
  185. package/tests/channel-formatter.contract.test.mjs +251 -251
  186. package/tests/curl-surface.test.mjs +289 -289
  187. package/tests/harness-gates.test.mjs +325 -325
  188. package/tests/help-surface.test.mjs +61 -61
  189. package/tests/install-rdc-skills.test.mjs +49 -49
  190. package/tests/manifest-contract-fields.test.mjs +78 -78
  191. package/tests/mcp.test.mjs +271 -271
  192. package/tests/rdc-brochure.test.mjs +125 -125
  193. package/tests/require-work-item-on-commit.test.mjs +162 -162
  194. package/tests/run-evidence-gate.test.mjs +82 -82
  195. package/tests/skill-test-matrix.test.mjs +66 -66
  196. package/tests/validate-skills.js +27 -27
  197. package/tests/work-item-exit-gate-l2.test.mjs +368 -368
  198. package/tests/work-item-exit-gate-l3.test.mjs +197 -197
  199. package/RELEASE.md +0 -42
  200. package/tests/housekeeping-lessons-triage.test.mjs +0 -49
  201. package/tests/lessons-pipeline-contract.test.mjs +0 -27
  202. package/tests/release-contract.test.mjs +0 -16
@@ -1,944 +1,944 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PreToolUse hook — hard-stop unsafe work item exit transitions.
4
- *
5
- * This gate runs before tool calls and blocks legacy or supervisor-mutated
6
- * CodeFlow/work-item close patterns before they reach Supabase. The database
7
- * remains the final authority; this hook gives agents an immediate stop sign.
8
- */
9
- 'use strict';
10
-
11
- const fs = require('fs');
12
- const os = require('os');
13
- const path = require('path');
14
- const crypto = require('crypto');
15
- const { execFileSync } = require('child_process');
16
- const hookLog = require('./hook-logger');
17
-
18
- const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
19
- const FULL_SHA_RE = /^[0-9a-f]{40}$/i;
20
- const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
21
- const EVENT_LOG = path.join(os.homedir(), '.claude', 'work-item-checklist-events.jsonl');
22
-
23
- // Truth Gate 3.0 — Layer 2 (FUSED evidence gate).
24
- // Witness allow-list (D5 / SLSA: the doer cannot sign its own provenance).
25
- const WITNESS_ALLOWLIST = new Set(['validator-rerun', 'ci', 'human-review']);
26
- // Repo the gate runs `git` against. The work tree under verification is
27
- // regen-root; overridable for tests via RDC_TRUTH_GATE_REPO.
28
- const TRUTH_GATE_REPO = process.env.RDC_TRUTH_GATE_REPO || 'C:/Dev/regen-root';
29
-
30
- // Truth Gate 3.0 — Layer 3 (validator re-run receipt).
31
- // Ratified contract (c): "no done without a validator re-run receipt with a
32
- // matching live nonce + running git_sha." This layer is FLAG-GATED and
33
- // default-OFF so it does not break in-flight build closures; it activates at
34
- // deploy by flipping the flag ON. When ON it is FAIL-CLOSED.
35
- // Running-brain health endpoint — the source of the actually-running git_sha
36
- // to pin against (mirrors .claude/hooks/truth-gate.mjs ~line 112).
37
- const BRAIN_HEALTH_URL = process.env.RDC_BRAIN_HEALTH_URL || 'http://127.0.0.1:3109/health';
38
- // Canonical signed field order for a VALIDATOR re-run receipt. MUST match
39
- // .claude/hooks/lib/receipt.mjs VALIDATOR_SIGNED_FIELDS exactly (the gate has
40
- // no access to that ESM lib, so the contract is mirrored here, byte-for-byte).
41
- const VALIDATOR_SIGNED_FIELDS = [
42
- 'claim', 'witness', 'git_sha', 'nonce', 'command', 'result', 'nonce_in_output', 'ts',
43
- ];
44
-
45
- function readStdin() {
46
- return new Promise((resolve) => {
47
- let input = '';
48
- process.stdin.setEncoding('utf8');
49
- process.stdin.on('data', (chunk) => { input += chunk; });
50
- process.stdin.on('end', () => resolve(input));
51
- process.stdin.resume();
52
- });
53
- }
54
-
55
- function block(message, details = {}) {
56
- hookLog('work-item-exit-gate', 'PreToolUse', 'block', details);
57
- process.stdout.write(JSON.stringify({
58
- systemMessage: `HARD BLOCK — Work item exit gate rejected this tool call.\n\n${message}`,
59
- }));
60
- process.exit(1);
61
- }
62
-
63
- function pass(details = {}) {
64
- hookLog('work-item-exit-gate', 'PreToolUse', 'pass', details);
65
- process.exit(0);
66
- }
67
-
68
- function stringifyTool(toolInput) {
69
- if (typeof toolInput === 'string') return toolInput;
70
- try { return JSON.stringify(toolInput); } catch { return ''; }
71
- }
72
-
73
- function stripCasts(value) {
74
- return String(value || '')
75
- .replace(/::\s*[a-z_][\w.]*/gi, '')
76
- .trim();
77
- }
78
-
79
- function unquote(value) {
80
- const v = stripCasts(value);
81
- const m = v.match(/^'(.*)'$/s) || v.match(/^"(.*)"$/s);
82
- return (m ? m[1] : v).replace(/''/g, "'").trim();
83
- }
84
-
85
- function getNamedArg(text, name) {
86
- const re = new RegExp(`${name}\\s*:?=\\s*('(?:''|[^'])*'|"(?:\\\\"|[^"])*"|true|false|null|[0-9a-f-]{36})`, 'i');
87
- const m = text.match(re);
88
- return m ? unquote(m[1]) : null;
89
- }
90
-
91
- function splitArgs(argsText) {
92
- const args = [];
93
- let current = '';
94
- let quote = null;
95
- let depth = 0;
96
- for (let i = 0; i < argsText.length; i++) {
97
- const ch = argsText[i];
98
- const next = argsText[i + 1];
99
- if (quote) {
100
- current += ch;
101
- if (ch === quote && !(quote === "'" && next === "'")) quote = null;
102
- else if (ch === "'" && next === "'") current += argsText[++i];
103
- continue;
104
- }
105
- if (ch === "'" || ch === '"') {
106
- quote = ch;
107
- current += ch;
108
- continue;
109
- }
110
- if (ch === '(' || ch === '[' || ch === '{') depth++;
111
- if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
112
- if (ch === ',' && depth === 0) {
113
- args.push(current.trim());
114
- current = '';
115
- continue;
116
- }
117
- current += ch;
118
- }
119
- if (current.trim()) args.push(current.trim());
120
- return args;
121
- }
122
-
123
- function findCall(text, name) {
124
- const idx = text.toLowerCase().indexOf(name.toLowerCase());
125
- if (idx < 0) return null;
126
- const open = text.indexOf('(', idx + name.length);
127
- if (open < 0) return null;
128
- let quote = null;
129
- let depth = 0;
130
- for (let i = open; i < text.length; i++) {
131
- const ch = text[i];
132
- const next = text[i + 1];
133
- if (quote) {
134
- if (ch === quote && !(quote === "'" && next === "'")) quote = null;
135
- else if (ch === "'" && next === "'") i++;
136
- continue;
137
- }
138
- if (ch === "'" || ch === '"') {
139
- quote = ch;
140
- continue;
141
- }
142
- if (ch === '(') depth++;
143
- if (ch === ')') {
144
- depth--;
145
- if (depth === 0) return text.slice(open + 1, i);
146
- }
147
- }
148
- return null;
149
- }
150
-
151
- function objectArg(blob, name) {
152
- const re = new RegExp(`"${name}"\\s*:\\s*("([^"]*)"|true|false|null)`, 'i');
153
- const m = blob.match(re);
154
- if (!m) return null;
155
- if (m[1] === 'true' || m[1] === 'false' || m[1] === 'null') return m[1];
156
- return m[2] || null;
157
- }
158
-
159
- function extractStatusCall(blob) {
160
- if (!/update_work_item_status/i.test(blob)) return null;
161
- const call = findCall(blob, 'update_work_item_status');
162
- const args = call ? splitArgs(call) : [];
163
- return {
164
- id: objectArg(blob, 'p_id') || getNamedArg(blob, 'p_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
165
- status: objectArg(blob, 'p_status') || getNamedArg(blob, 'p_status') || unquote(args[1] || ''),
166
- actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
167
- actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
168
- };
169
- }
170
-
171
- function extractTickCall(blob) {
172
- if (!/update_checklist_item/i.test(blob)) return null;
173
- const call = findCall(blob, 'update_checklist_item');
174
- const args = call ? splitArgs(call) : [];
175
- return {
176
- workItemId: objectArg(blob, 'p_work_item_id') || getNamedArg(blob, 'p_work_item_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
177
- itemId: objectArg(blob, 'p_item_id') || getNamedArg(blob, 'p_item_id') || unquote(args[1] || ''),
178
- checked: (objectArg(blob, 'p_checked') || getNamedArg(blob, 'p_checked') || unquote(args[2] || '')).toLowerCase() === 'true',
179
- actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
180
- actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
181
- };
182
- }
183
-
184
- function logTick(tick, rawTool) {
185
- try {
186
- fs.mkdirSync(path.dirname(EVENT_LOG), { recursive: true });
187
- fs.appendFileSync(EVENT_LOG, JSON.stringify({
188
- ts: new Date().toISOString(),
189
- work_item_id: tick.workItemId || null,
190
- item_id: tick.itemId || null,
191
- checked: tick.checked,
192
- actor_session_id: tick.actorSessionId || null,
193
- actor_role: tick.actorRole || null,
194
- tool_name: rawTool.tool_name || null,
195
- }) + '\n');
196
- } catch (_) {}
197
- }
198
-
199
- async function getServiceKey() {
200
- if (process.env.SUPABASE_SERVICE_ROLE_KEY) return process.env.SUPABASE_SERVICE_ROLE_KEY;
201
- if (process.env.SUPABASE_SERVICE_KEY) return process.env.SUPABASE_SERVICE_KEY;
202
- for (const endpoint of ['supabase-service', 'supabase-service-role']) {
203
- try {
204
- const res = await fetch(`http://127.0.0.1:52437/v/${endpoint}`, { signal: AbortSignal.timeout(2000) });
205
- if (res.ok) {
206
- const text = (await res.text()).trim();
207
- if (text && !text.startsWith('{')) return text;
208
- }
209
- } catch (_) {}
210
- }
211
- return null;
212
- }
213
-
214
- async function supabaseGet(pathname) {
215
- const key = await getServiceKey();
216
- if (!key) throw new Error('clauth/env did not provide a Supabase service key');
217
- const base = (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL).replace(/\/$/, '');
218
- const res = await fetch(`${base}/rest/v1/${pathname}`, {
219
- headers: {
220
- apikey: key,
221
- Authorization: `Bearer ${key}`,
222
- Accept: 'application/json',
223
- },
224
- signal: AbortSignal.timeout(3500),
225
- });
226
- if (!res.ok) throw new Error(`Supabase verification HTTP ${res.status}`);
227
- return res.json();
228
- }
229
-
230
- function requiredItems(checklist) {
231
- return Array.isArray(checklist)
232
- ? checklist.filter((item) => item && item.required === true)
233
- : [];
234
- }
235
-
236
- // ===========================================================================
237
- // Truth Gate 3.0 — Layer 2 (FUSED evidence gate / freeze-the-leak)
238
- //
239
- // Asserts, server-checkable and FAIL-CLOSED, that a `done` close corresponds to
240
- // reality. Baru-trap hardening (from the auditors' OWN false positives):
241
- // - resolve commits by FULL 40-hex SHA only — never short-prefix compare
242
- // (prefix collisions falsely flagged real work as fabricated).
243
- // - locate claimed files WHOLE-REPO (`git log --all -- <path>`), never
244
- // cited-path-only (wrong-path lookups falsely cried "absent").
245
- // ===========================================================================
246
-
247
- /**
248
- * A Layer-2 denial. Thrown by `deny()` so verifyLayer2 short-circuits without
249
- * coupling to process.exit — production translates it to block(), tests assert
250
- * on `.reason`. This keeps the gate logic pure + unit-testable while staying
251
- * FAIL-CLOSED at the process boundary.
252
- */
253
- class GateDenied extends Error {
254
- constructor(reason, details) {
255
- super(reason);
256
- this.name = 'GateDenied';
257
- this.reason = reason;
258
- this.details = details || {};
259
- }
260
- }
261
-
262
- function deny(reason, details) {
263
- throw new GateDenied(reason, details);
264
- }
265
-
266
- /**
267
- * Build the L1-captured SHA set bound to the item's ORIGINATING session, or
268
- * DENY (throw GateDenied) when there is no originating session to bind to.
269
- *
270
- * FAIL-CLOSED: a null/empty originating session must NOT disable the per-session
271
- * binding (the old `!originatingSession` short-circuit accepted ANY session's
272
- * captured SHA — a fail-open laundering hole). With no session, there is no
273
- * provenance to verify, so we DENY. Otherwise we keep ONLY rows whose
274
- * session_id exactly equals the originating session.
275
- *
276
- * @param originatingSession string|null the item's session_id
277
- * @param capturedRows Array<{sha, session_id}> from work_item_commits
278
- * @returns Set<string> full-SHA (lowercased) set for this item+session
279
- */
280
- function buildCapturedShaSet(originatingSession, capturedRows) {
281
- const sess = originatingSession || null;
282
- if (!sess) {
283
- deny(
284
- 'L2: done rejected — cannot bind commit provenance: work item has no originating session. ' +
285
- 'Without a session to bind captured SHAs to, the per-session commit binding cannot be verified; fail-closed.',
286
- { originatingSession },
287
- );
288
- }
289
- return new Set(
290
- (Array.isArray(capturedRows) ? capturedRows : [])
291
- .filter((r) => r && r.session_id === sess)
292
- .map((r) => String((r && r.sha) || '').toLowerCase())
293
- .filter((s) => FULL_SHA_RE.test(s)),
294
- );
295
- }
296
-
297
- /** Run a git command in the truth-gate repo; returns trimmed stdout or null.
298
- * stderr is captured (piped) so a failure carries a usable diagnostic instead
299
- * of being swallowed. On failure with allowFail, returns null; otherwise the
300
- * thrown error retains git's stderr in e.stderr / e.message. */
301
- function git(args, { allowFail = false } = {}) {
302
- try {
303
- return execFileSync('git', args, {
304
- cwd: TRUTH_GATE_REPO,
305
- encoding: 'utf8',
306
- stdio: ['ignore', 'pipe', 'pipe'],
307
- maxBuffer: 16 * 1024 * 1024,
308
- }).trim();
309
- } catch (e) {
310
- if (allowFail) return null;
311
- throw e;
312
- }
313
- }
314
-
315
- /**
316
- * Verify, once at entry, that TRUTH_GATE_REPO is a real git work tree the gate
317
- * can interrogate. If git is missing or the path is not a work tree, every
318
- * downstream SHA/file check would silently mis-verify, so we fail-closed with a
319
- * clear `truth-gate repo unavailable` block — distinct from a `ref not found`.
320
- */
321
- function assertGitRepoAvailable() {
322
- let out;
323
- try {
324
- out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
325
- cwd: TRUTH_GATE_REPO,
326
- encoding: 'utf8',
327
- stdio: ['ignore', 'pipe', 'pipe'],
328
- maxBuffer: 1024 * 1024,
329
- }).trim();
330
- } catch (e) {
331
- const detail = (e && (e.stderr || e.message)) ? String(e.stderr || e.message).trim().slice(0, 200) : 'git invocation failed';
332
- deny(
333
- 'L2: done rejected — truth-gate repo unavailable: cannot run git in "' + TRUTH_GATE_REPO + '" (' + detail + '). ' +
334
- 'The gate cannot verify commit/file provenance without a working git tree; fail-closed.',
335
- { repo: TRUTH_GATE_REPO },
336
- );
337
- }
338
- if (out !== 'true') {
339
- deny(
340
- 'L2: done rejected — truth-gate repo unavailable: "' + TRUTH_GATE_REPO + '" is not a git work tree (rev-parse returned "' + out + '"). ' +
341
- 'The gate cannot verify commit/file provenance; fail-closed.',
342
- { repo: TRUTH_GATE_REPO },
343
- );
344
- }
345
- }
346
-
347
- /**
348
- * Resolve a commit ref to its FULL 40-char SHA, or null if it does not exist.
349
- * Uses `rev-parse --verify <ref>^{commit}` so only real commit objects resolve.
350
- * NEVER does a prefix/substring compare — the returned value is the canonical
351
- * full SHA, and all equality downstream is full-SHA equality.
352
- */
353
- function resolveFullSha(ref) {
354
- if (typeof ref !== 'string' || !ref.trim()) return null;
355
- const full = git(['rev-parse', '--verify', '--quiet', `${ref.trim()}^{commit}`], { allowFail: true });
356
- return full && FULL_SHA_RE.test(full) ? full.toLowerCase() : null;
357
- }
358
-
359
- /** Full set of file paths touched by a commit (`git show --stat`→ name-only). */
360
- function filesInCommit(fullSha) {
361
- const out = git(['show', '--no-renames', '--name-only', '--pretty=format:', fullSha], { allowFail: true });
362
- if (out == null) return null;
363
- return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean).map(normalizeRepoPath));
364
- }
365
-
366
- function normalizeRepoPath(p) {
367
- return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
368
- }
369
-
370
- /**
371
- * Whole-repo existence check for a claimed file. A file "exists for this repo"
372
- * when EITHER it is tracked anywhere in history (`git log --all -- <path>`
373
- * returns a commit) OR it is present on disk. This is deliberately broad to
374
- * avoid the cited-path-only false "absent" verdict.
375
- */
376
- function fileKnownToRepo(repoPath) {
377
- const p = normalizeRepoPath(repoPath);
378
- if (!p) return false;
379
- // On disk now?
380
- try {
381
- if (fs.existsSync(path.join(TRUTH_GATE_REPO, p))) return true;
382
- } catch (_) { /* fall through to history */ }
383
- // Tracked anywhere in history (whole-repo locator, never cited-path-only)?
384
- const log = git(['log', '--all', '--oneline', '-1', '--', p], { allowFail: true });
385
- return Boolean(log && log.length > 0);
386
- }
387
-
388
- /** Does the file exist on disk in the work tree right now? */
389
- function fileOnDisk(repoPath) {
390
- const p = normalizeRepoPath(repoPath);
391
- if (!p) return false;
392
- try { return fs.existsSync(path.join(TRUTH_GATE_REPO, p)); } catch { return false; }
393
- }
394
-
395
- let _evidenceLib = null;
396
- /** Lazy-load the fused primitive's artifact discriminators (ESM from CJS).
397
- * Uses a file:// URL so the dynamic import works on Windows absolute paths.
398
- * Returns { isMachineArtifact, isPassingArtifact }. */
399
- async function loadEvidenceLib() {
400
- if (_evidenceLib) return _evidenceLib;
401
- const { pathToFileURL } = require('url');
402
- const libPath = path.join(__dirname, 'lib', 'run-evidence-gate.mjs');
403
- const mod = await import(pathToFileURL(libPath).href);
404
- _evidenceLib = { isMachineArtifact: mod.isMachineArtifact, isPassingArtifact: mod.isPassingArtifact };
405
- return _evidenceLib;
406
- }
407
-
408
- // ===========================================================================
409
- // Truth Gate 3.0 — Layer 3 (validator re-run receipt). FLAG-GATED, default-OFF.
410
- //
411
- // Ratified contract (c): a `done` close requires a chain-stored, HMAC-valid,
412
- // fresh-nonce, running-sha-pinned validator receipt. Nothing consumed the
413
- // receipt layer before this. To avoid breaking in-flight closures, the check is
414
- // behind a flag (default OFF → behaves exactly as today). When the flag is ON it
415
- // is FAIL-CLOSED: a missing / forged / stale / replayed / wrong-sha receipt, or
416
- // an inability to evaluate, DENIES the close.
417
- // ===========================================================================
418
-
419
- /**
420
- * Is the Layer-3 validator-receipt requirement enabled?
421
- * Default OFF. Turned on at deploy via either:
422
- * - env RDC_TRUTHGATE_REQUIRE_VALIDATOR_RECEIPT in {1,true,on,yes}, OR
423
- * - a DB row in public.truthgate_flags(flag,enabled) with flag =
424
- * 'require_validator_receipt' and enabled = true (best-effort; a lookup
425
- * failure does NOT enable the flag — absence is OFF).
426
- * The env is the authoritative, test-stable switch; the DB lookup is an optional
427
- * deploy-time toggle. Either being true enables it.
428
- */
429
- function truthGateFlagEnabled(flag) {
430
- const envName = 'RDC_TRUTHGATE_' + String(flag || '').toUpperCase();
431
- const v = String(process.env[envName] || '').trim().toLowerCase();
432
- return v === '1' || v === 'true' || v === 'on' || v === 'yes';
433
- }
434
-
435
- /** Best-effort DB toggle for the flag. Absence / error → false (stays OFF). */
436
- async function truthGateFlagEnabledDb(flag) {
437
- try {
438
- const rows = await supabaseGet(
439
- `truthgate_flags?flag=eq.${encodeURIComponent(flag)}&select=enabled&limit=1`,
440
- );
441
- return Array.isArray(rows) && rows[0] && rows[0].enabled === true;
442
- } catch (_) {
443
- return false; // a missing table or a lookup failure must NOT enable the gate
444
- }
445
- }
446
-
447
- /** Canonical JSON the HMAC is computed over — mirrors receipt.mjs validatorCanonical(). */
448
- function validatorCanonical(receipt) {
449
- const picked = {};
450
- for (const k of VALIDATOR_SIGNED_FIELDS) picked[k] = receipt[k] ?? null;
451
- return JSON.stringify(picked);
452
- }
453
-
454
- /** Verify the validator receipt HMAC with the truth-gate secret (constant-time). */
455
- function verifyValidatorSig(receipt, secret) {
456
- if (!secret || !receipt || !receipt.hmac) return false;
457
- let expected;
458
- try {
459
- expected = crypto.createHmac('sha256', secret).update(validatorCanonical(receipt)).digest('hex');
460
- } catch (_) { return false; }
461
- let a, b;
462
- try {
463
- a = Buffer.from(expected, 'hex');
464
- b = Buffer.from(String(receipt.hmac), 'hex');
465
- } catch (_) { return false; }
466
- return a.length === b.length && crypto.timingSafeEqual(a, b);
467
- }
468
-
469
- /** Does a re-run result object read as a PASS? Mirrors receipt.mjs resultIsPass(). */
470
- function resultIsPass(result) {
471
- if (!result || typeof result !== 'object') return false;
472
- if (typeof result.exit_code === 'number') return result.exit_code === 0;
473
- if (typeof result.exitCode === 'number') return result.exitCode === 0;
474
- if (typeof result.passed === 'number') {
475
- if (typeof result.failed === 'number') return result.failed === 0;
476
- if (typeof result.total === 'number') return result.passed === result.total;
477
- }
478
- if (typeof result.tsc_errors === 'number') return result.tsc_errors === 0;
479
- return false;
480
- }
481
-
482
- /**
483
- * Validate a VALIDATOR re-run receipt for a `done` close. Pure + unit-testable;
484
- * throws GateDenied on any failure so the caller fail-closes. ALL must hold:
485
- * - a secret is available (else INFRA → DENY, never a silent pass)
486
- * - HMAC valid (signed by the validator path, not hand-written)
487
- * - witness ∈ allow-list (never 'agent' / self-witnessed)
488
- * - git_sha === actualRunningSha (re-run pinned to the running brain HEAD)
489
- * - nonce_in_output === true (fresh nonce surfaced in captured output)
490
- * - result reads as a pass
491
- * - nonce NOT in seenNonces (durable replay set, loaded from the chain store)
492
- * - ts within maxAgeMin (fresh)
493
- *
494
- * @param {object} receipt
495
- * @param {object} opts
496
- * @param {string} opts.secret HMAC secret (truth-gate-secret)
497
- * @param {string} opts.actualRunningSha live brain HEAD to pin against (required)
498
- * @param {string[]} [opts.seenNonces] durable nonces already consumed
499
- * @param {number} [opts.maxAgeMin=30]
500
- * @param {number} [opts.nowMs]
501
- */
502
- function assertValidatorReceipt(receipt, opts = {}) {
503
- const { secret, actualRunningSha, seenNonces = [], maxAgeMin = 30, nowMs } = opts;
504
- if (!secret) {
505
- deny(
506
- 'L3: done rejected — INFRA: no truth-gate HMAC secret available to verify the validator receipt. ' +
507
- 'Cannot evaluate the Layer-3 receipt; fail-closed (INFRA is a BLOCK, not an allow).',
508
- { layer: 3 },
509
- );
510
- }
511
- if (!receipt || typeof receipt !== 'object') {
512
- deny('L3: done rejected — no validator re-run receipt found for this work item (flag is ON).', { layer: 3 });
513
- }
514
- if (!verifyValidatorSig(receipt, secret)) {
515
- deny('L3: done rejected — validator receipt HMAC invalid/absent (hand-written or tampered receipt).', { layer: 3 });
516
- }
517
- if (!WITNESS_ALLOWLIST.has(String(receipt.witness || ''))) {
518
- deny(
519
- 'L3: done rejected — validator receipt witness "' + (receipt.witness || '<missing>') +
520
- '" is not in {validator-rerun, ci, human-review} (the doer cannot self-witness).',
521
- { layer: 3, witness: receipt.witness },
522
- );
523
- }
524
- if (!receipt.git_sha) {
525
- deny('L3: done rejected — validator receipt git_sha missing.', { layer: 3 });
526
- }
527
- if (actualRunningSha && String(receipt.git_sha) !== String(actualRunningSha)) {
528
- deny(
529
- 'L3: done rejected — validator receipt git_sha ' + receipt.git_sha +
530
- ' != the actually-running brain HEAD ' + actualRunningSha + ' (receipt not pinned to the live runtime).',
531
- { layer: 3, receiptSha: receipt.git_sha, runningSha: actualRunningSha },
532
- );
533
- }
534
- if (receipt.nonce_in_output !== true) {
535
- deny('L3: done rejected — validator receipt nonce_in_output != true (fresh nonce absent from captured output: cached/replayed artifact).', { layer: 3 });
536
- }
537
- if (!resultIsPass(receipt.result)) {
538
- deny('L3: done rejected — validator receipt result did not read as a pass (re-run failed or unparseable result).', { layer: 3 });
539
- }
540
- if (receipt.nonce && Array.isArray(seenNonces) && seenNonces.includes(receipt.nonce)) {
541
- deny('L3: done rejected — validator receipt nonce ' + receipt.nonce + ' already consumed in the chain (replayed) — stale.', { layer: 3, nonce: receipt.nonce });
542
- }
543
- const t = Date.parse(receipt.ts);
544
- if (Number.isNaN(t)) deny('L3: done rejected — validator receipt ts is unparseable.', { layer: 3 });
545
- const now = typeof nowMs === 'number' ? nowMs : Date.now();
546
- if (now - t > maxAgeMin * 60_000) {
547
- deny('L3: done rejected — validator receipt is stale (> ' + maxAgeMin + 'm old).', { layer: 3 });
548
- }
549
- // No denial → the Layer-3 receipt is valid, fresh, pinned, and unreplayed.
550
- }
551
-
552
- /** Read the truth-gate HMAC secret from clauth/env. null when unavailable. */
553
- async function getTruthGateSecret() {
554
- if (process.env.TRUTH_GATE_SECRET) return process.env.TRUTH_GATE_SECRET;
555
- try {
556
- const res = await fetch('http://127.0.0.1:52437/v/truth-gate-secret', { signal: AbortSignal.timeout(2500) });
557
- if (res.ok) {
558
- const text = (await res.text()).trim();
559
- if (text && !text.startsWith('{')) return text;
560
- }
561
- } catch (_) {}
562
- return null;
563
- }
564
-
565
- /** Pin against the running brain's /health git_sha (mirrors truth-gate.mjs). null on failure. */
566
- async function getRunningBrainSha() {
567
- try {
568
- const res = await fetch(BRAIN_HEALTH_URL, { signal: AbortSignal.timeout(3000) });
569
- if (!res.ok) return null;
570
- const j = await res.json();
571
- const sha = String(j && j.git_sha ? j.git_sha : '').trim();
572
- return sha || null;
573
- } catch (_) { return null; }
574
- }
575
-
576
- /** Newest validator receipt stored for this work item, or null. Loaded from the chain store. */
577
- function pickNewestValidatorReceipt(receiptRows) {
578
- if (!Array.isArray(receiptRows) || receiptRows.length === 0) return null;
579
- let best = null;
580
- let bestT = -Infinity;
581
- for (const row of receiptRows) {
582
- const payload = row && row.payload;
583
- if (!payload || typeof payload !== 'object') continue;
584
- const t = Date.parse(payload.ts);
585
- const key = Number.isNaN(t) ? -Infinity : t;
586
- if (key >= bestT) { bestT = key; best = payload; }
587
- }
588
- return best;
589
- }
590
-
591
- /**
592
- * Layer-3 verification of a `done` close: require a chain-stored, HMAC-valid,
593
- * fresh-nonce, running-sha-pinned validator receipt for THIS work item. Only
594
- * runs when the flag is ON; otherwise it is a no-op (today's behaviour). When ON
595
- * it is FAIL-CLOSED — any inability to load/evaluate the receipt is a DENY.
596
- *
597
- * Dependencies are injectable for unit tests via `deps`:
598
- * deps.flagEnabled() → boolean
599
- * deps.getSecret() → Promise<string|null>
600
- * deps.getRunningSha() → Promise<string|null>
601
- * deps.loadReceiptRows(id) → Promise<rows for this work item>
602
- * deps.loadSeenNonces() → Promise<string[]> (durable consumed nonces)
603
- */
604
- async function verifyLayer3(statusCall, item, deps = {}, nowMs) {
605
- const flagEnabled = deps.flagEnabled ? await deps.flagEnabled() : false;
606
- if (!flagEnabled) return; // default-OFF: behaves exactly as today
607
-
608
- const secret = deps.getSecret ? await deps.getSecret() : await getTruthGateSecret();
609
- const runningSha = deps.getRunningSha ? await deps.getRunningSha() : await getRunningBrainSha();
610
- if (!runningSha) {
611
- deny(
612
- 'L3: done rejected — INFRA: could not read the running brain git_sha from ' + BRAIN_HEALTH_URL +
613
- '. Cannot pin the validator receipt to the live runtime; fail-closed (INFRA is a BLOCK).',
614
- { layer: 3 },
615
- );
616
- }
617
-
618
- let receiptRows;
619
- let seenNonces;
620
- try {
621
- receiptRows = deps.loadReceiptRows
622
- ? await deps.loadReceiptRows(statusCall.id)
623
- : await supabaseGet(`truth_gate_receipts?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=payload&order=id.desc&limit=50`);
624
- seenNonces = deps.loadSeenNonces
625
- ? await deps.loadSeenNonces(statusCall.id)
626
- : await loadSeenNonces(statusCall.id, pickNewestValidatorReceipt(receiptRows));
627
- } catch (e) {
628
- deny(
629
- 'L3: done rejected — INFRA: could not load validator receipts/seen-nonces (' +
630
- (e && e.message ? e.message : String(e)) + '); fail-closed.',
631
- { layer: 3 },
632
- );
633
- }
634
-
635
- const receipt = pickNewestValidatorReceipt(receiptRows);
636
- assertValidatorReceipt(receipt, { secret, actualRunningSha: runningSha, seenNonces, nowMs });
637
- }
638
-
639
- /**
640
- * Durable replay set: every validator-receipt nonce already consumed in the
641
- * chain, EXCLUDING the candidate receipt's own nonce (so the candidate is not
642
- * spuriously flagged as a replay of itself). Replay rejection is therefore
643
- * durable (chain-backed), not in-memory-only.
644
- */
645
- async function loadSeenNonces(workItemId, candidateReceipt) {
646
- const ownNonce = candidateReceipt && candidateReceipt.nonce ? String(candidateReceipt.nonce) : null;
647
- const rows = await supabaseGet(
648
- `truth_gate_receipts?select=payload&payload->>nonce=not.is.null&limit=1000`,
649
- );
650
- const seen = [];
651
- for (const r of Array.isArray(rows) ? rows : []) {
652
- const n = r && r.payload && r.payload.nonce ? String(r.payload.nonce) : null;
653
- if (n && n !== ownNonce) seen.push(n);
654
- }
655
- return seen;
656
- }
657
-
658
- /**
659
- * Layer-2 verification of a `done` close against the real repo. Throws (caught
660
- * by verifyDone → block) on any internal error so the gate is FAIL-CLOSED:
661
- * an inability to verify is a DENY, never a silent pass.
662
- *
663
- * @param statusCall parsed update_work_item_status args (has .id, .actorSessionId)
664
- * @param item the work_items row (has implementation_report)
665
- * @param capturedShas Set<string> of FULL L1-captured SHAs for this item/session
666
- */
667
- async function verifyLayer2(statusCall, item, capturedShas) {
668
- // Fail-closed: the gate's commit/file checks all shell out to git. If the
669
- // truth-gate repo is not a usable git work tree, verify nothing — DENY.
670
- assertGitRepoAvailable();
671
-
672
- const post = item.implementation_report && item.implementation_report.codeflow_post;
673
- if (!post || typeof post !== 'object') {
674
- deny('L2: done rejected — implementation_report.codeflow_post is missing or not an object.', statusCall);
675
- }
676
-
677
- // (5) Witness allow-list — the doer cannot self-witness (D5 / SLSA).
678
- const witness = String(post.witness || '').trim();
679
- if (!WITNESS_ALLOWLIST.has(witness)) {
680
- deny(
681
- 'L2: done rejected — codeflow_post.witness must be one of {validator-rerun, ci, human-review}; got "' +
682
- (witness || '<missing>') + '". ' +
683
- 'The party that did the work cannot sign its own provenance (witness:"agent" is never accepted).',
684
- { ...statusCall, witness },
685
- );
686
- }
687
-
688
- // (1) Commit resolves (FULL SHA) AND was captured by L1 for this item/session.
689
- const claimedCommit = post.commit;
690
- const fullSha = resolveFullSha(claimedCommit);
691
- if (!fullSha) {
692
- deny(
693
- 'L2: done rejected — codeflow_post.commit ("' + (claimedCommit || '<missing>') +
694
- '") does not resolve to a real commit (git cat-file/rev-parse). Free-typed or wrong SHAs are rejected.',
695
- { ...statusCall, claimedCommit },
696
- );
697
- }
698
- if (!capturedShas.has(fullSha)) {
699
- deny(
700
- 'L2: done rejected — commit ' + fullSha + ' was not captured by Layer 1 for this work item + originating session. ' +
701
- 'The exit gate only accepts a commit SHA the commit-hook recorded against this item (no agent-asserted SHAs). ' +
702
- 'Captured SHAs for this item/session: ' + (capturedShas.size ? [...capturedShas].join(', ') : '(none)') + '.',
703
- { ...statusCall, fullSha, capturedCount: capturedShas.size },
704
- );
705
- }
706
-
707
- // (2)/(3) Every files_changed entry is in the commit AND exists on disk.
708
- const filesChanged = Array.isArray(post.files_changed) ? post.files_changed : [];
709
- if (filesChanged.length === 0) {
710
- deny('L2: done rejected — codeflow_post.files_changed is empty; a closure must name the files it changed.', statusCall);
711
- }
712
- const commitFiles = filesInCommit(fullSha);
713
- if (commitFiles == null) {
714
- deny('L2: done rejected — could not read the file list of commit ' + fullSha + ' (git show failed).', statusCall);
715
- }
716
- for (const raw of filesChanged) {
717
- const p = normalizeRepoPath(raw);
718
- if (!p) {
719
- deny('L2: done rejected — a files_changed entry is empty/blank.', { ...statusCall, raw });
720
- }
721
- // In the commit? (whole-repo lookup is implicit — commitFiles is the full
722
- // name-only set of the resolved commit, not a cited-path filter.)
723
- if (!commitFiles.has(p)) {
724
- deny(
725
- 'L2: done rejected — file "' + p + '" is NOT among the files changed by commit ' + fullSha +
726
- ' (git show --stat). files_changed must match the commit\'s actual contents.',
727
- { ...statusCall, file: p, fullSha },
728
- );
729
- }
730
- // On disk now?
731
- if (!fileOnDisk(p)) {
732
- deny(
733
- 'L2: done rejected — claimed file "' + p + '" does not exist on disk in the work tree. ' +
734
- 'A deliverable that is not present is not done.',
735
- { ...statusCall, file: p },
736
- );
737
- }
738
- // Whole-repo sanity (defense in depth; should always hold if on disk).
739
- if (!fileKnownToRepo(p)) {
740
- deny(
741
- 'L2: done rejected — claimed file "' + p + '" is unknown to the repo (not on disk and not in history).',
742
- { ...statusCall, file: p },
743
- );
744
- }
745
- }
746
-
747
- // (4) Every verification entry is a machine-parseable artifact, not prose,
748
- // AND its OUTCOME is a PASS — not merely that it RAN. A failing run
749
- // (exit_code:1), an error HTTP status (500), a fused verdict:'fail', or a
750
- // test artifact with failures must DENY: "ran" is not "passed".
751
- const { isMachineArtifact, isPassingArtifact } = await loadEvidenceLib();
752
- const verifications = Array.isArray(post.verification) ? post.verification : [];
753
- if (verifications.length === 0) {
754
- deny('L2: done rejected — codeflow_post.verification is empty; closure needs a captured verification artifact.', statusCall);
755
- }
756
- for (const v of verifications) {
757
- // 4a. Shape gate — it must be a captured machine artifact, not prose.
758
- if (!isMachineArtifact(v)) {
759
- const shown = typeof v === 'string' ? v.slice(0, 60) : JSON.stringify(v).slice(0, 80);
760
- deny(
761
- 'L2: done rejected — verification entry is prose/proxy, not a captured artifact: "' + shown + '". ' +
762
- 'Each verification must be a run-evidence-gate result or a machine shape ' +
763
- '({exit_code|http_status|rowcount|passed/total}). Strings like "HTTP 200" / "works" are rejected.',
764
- { ...statusCall, verification: shown },
765
- );
766
- }
767
- // 4b. Outcome gate — the captured artifact must read as a PASS.
768
- if (!isPassingArtifact(v)) {
769
- const shown = typeof v === 'string' ? v.slice(0, 80) : JSON.stringify(v).slice(0, 120);
770
- deny(
771
- 'L2: done rejected — verification-not-passing: the captured artifact ran but did NOT pass: "' + shown + '". ' +
772
- 'A closure requires a PASSING verification (fused verdict:"pass" / exit_code:0 / http 2xx-3xx / ' +
773
- 'failed:0 / passed===total / tsc_errors:0). A failing or error run is not evidence of done.',
774
- { ...statusCall, verification: shown },
775
- );
776
- }
777
- }
778
- // No denial thrown => Layer-2 verification PASSED.
779
- }
780
-
781
- async function verifyDone(statusCall, blob) {
782
- if (!statusCall.id) block('`update_work_item_status(..., done)` must include a work item UUID.', statusCall);
783
- if (!statusCall.actorSessionId || !statusCall.actorRole) {
784
- block('`done` requires the 5-argument RPC shape with `p_actor_session_id` and `p_actor_role`.', statusCall);
785
- }
786
- if (statusCall.actorRole !== 'validator') {
787
- block('Only a validator may transition a non-epic work item to `done`; implementation agents move work to `review`.', statusCall);
788
- }
789
- if (/submit_implementation_report/i.test(blob)) {
790
- block('Submit the implementation report in a separate tool call before `done`; the gate verifies committed DB state.', statusCall);
791
- }
792
-
793
- let rows;
794
- let events;
795
- let capturedRows;
796
- try {
797
- rows = await supabaseGet(`work_items?id=eq.${encodeURIComponent(statusCall.id)}&select=id,status,item_type,session_id,implementation_report,checklist`);
798
- events = await supabaseGet(`work_item_checklist_events?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=item_id,checked,actor_session_id,actor_role,created_at&order=created_at.desc&limit=200`);
799
- capturedRows = await supabaseGet(`work_item_commits?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=sha,session_id`);
800
- } catch (e) {
801
- block(`Cannot live-verify work item exit gate: ${e.message}. Do not close the item until clauth/Supabase verification is available.`, statusCall);
802
- }
803
-
804
- const item = rows && rows[0];
805
- if (!item) block(`Work item ${statusCall.id} was not found.`, statusCall);
806
- if (item.item_type === 'epic') return;
807
- if (item.status !== 'review') block('Non-epic work items must be in `review` before a validator may mark them `done`.', { ...statusCall, currentStatus: item.status });
808
-
809
- const report = item.implementation_report;
810
- if (!report || typeof report !== 'object') block('`done` rejected because `implementation_report` is null or not an object.', statusCall);
811
- if (!report.codeflow_post || typeof report.codeflow_post !== 'object') block('`done` rejected because `implementation_report.codeflow_post` is missing.', statusCall);
812
-
813
- const missing = requiredItems(item.checklist).filter((ci) => ci.checked !== true);
814
- if (missing.length > 0) {
815
- block(`Required checklist items are unchecked: ${missing.map((ci) => ci.id || ci.text || '<unknown>').join(', ')}`, statusCall);
816
- }
817
-
818
- const latestByItem = new Map();
819
- for (const event of Array.isArray(events) ? events : []) {
820
- if (!latestByItem.has(event.item_id)) latestByItem.set(event.item_id, event);
821
- }
822
- const supervisorReticks = requiredItems(item.checklist)
823
- .map((ci) => latestByItem.get(ci.id))
824
- .filter((event) => event && event.checked === true && event.actor_role !== 'agent');
825
- if (supervisorReticks.length > 0) {
826
- block(
827
- `Required checklist items were last ticked by a non-agent session: ${supervisorReticks.map((e) => e.item_id).join(', ')}`,
828
- { ...statusCall, supervisorReticks: supervisorReticks.map((e) => e.item_id) },
829
- );
830
- }
831
-
832
- // --- Truth Gate 3.0 Layer 2 — FUSED evidence gate (freeze-the-leak) ---------
833
- // The L1-captured SHA set is restricted to the item's ORIGINATING session
834
- // (the session that ticked the checklist) so a SHA captured by some other
835
- // session against this item cannot launder a fabricated close. A null
836
- // originating session is fail-closed (DENY) inside buildCapturedShaSet.
837
- try {
838
- const capturedShas = buildCapturedShaSet(item.session_id || null, capturedRows);
839
- await verifyLayer2(statusCall, item, capturedShas);
840
- } catch (e) {
841
- if (e instanceof GateDenied) {
842
- block(e.reason, e.details); // translate the L2 denial into a hard block
843
- }
844
- // Any OTHER error during L2 is an inability to verify => FAIL-CLOSED.
845
- block('L2: done rejected — Layer-2 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
846
- }
847
-
848
- // --- Truth Gate 3.0 Layer 3 — validator re-run receipt (FLAG-GATED) ---------
849
- // Default-OFF: a no-op until the flag is flipped at deploy, so in-flight build
850
- // closures are unaffected. When ON it is FAIL-CLOSED: requires a chain-stored,
851
- // HMAC-valid, fresh-nonce, running-sha-pinned validator receipt.
852
- try {
853
- await verifyLayer3(statusCall, item, {
854
- flagEnabled: async () =>
855
- truthGateFlagEnabled('require_validator_receipt') ||
856
- (await truthGateFlagEnabledDb('require_validator_receipt')),
857
- });
858
- } catch (e) {
859
- if (e instanceof GateDenied) {
860
- block(e.reason, e.details); // translate the L3 denial into a hard block
861
- }
862
- // Any OTHER error during L3 (flag ON) is an inability to verify => FAIL-CLOSED.
863
- block('L3: done rejected — Layer-3 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
864
- }
865
- }
866
-
867
- function validateTick(tick, rawTool) {
868
- logTick(tick, rawTool);
869
- if (!tick.checked) return;
870
- if (!tick.workItemId || !tick.itemId) {
871
- block('Checklist ticks must include `p_work_item_id` and `p_item_id`.', tick);
872
- }
873
- if (!tick.actorSessionId) {
874
- block('Checklist ticks must include `p_actor_session_id`; unaudited ticks are rejected.', tick);
875
- }
876
- if (tick.actorRole !== 'agent') {
877
- block('Only the originating implementation agent may tick required checklist evidence. Supervisors/validators reopen or review; they do not re-tick.', tick);
878
- }
879
- }
880
-
881
- async function main() {
882
- let raw;
883
- try { raw = JSON.parse(await readStdin()); } catch { process.exit(0); }
884
-
885
- const blob = stringifyTool(raw.tool_input || raw);
886
- const tick = extractTickCall(blob);
887
- if (tick) validateTick(tick, raw);
888
-
889
- const statusCall = extractStatusCall(blob);
890
- if (!statusCall) pass({ reason: 'no-status-call' });
891
-
892
- // Ambiguous-parse fail-closed: the tool blob references update_work_item_status
893
- // AND contains a 'done' literal, but we could not extract a usable id/status.
894
- // A done-close we cannot parse must NOT slip through as a pass — block.
895
- if (/update_work_item_status/i.test(blob) && /\bdone\b/i.test(blob)) {
896
- if (!statusCall.id || !statusCall.status) {
897
- block(
898
- 'Work item exit gate could not parse the `update_work_item_status` call that references `done` ' +
899
- '(missing ' + (!statusCall.id ? 'work item id' : 'status') + '). ' +
900
- 'Ambiguous done-close parses are fail-closed; re-issue the call in the documented 5-argument RPC shape.',
901
- statusCall,
902
- );
903
- }
904
- }
905
-
906
- const status = String(statusCall.status || '').toLowerCase();
907
- if (status === 'review' && (!statusCall.actorSessionId || statusCall.actorRole !== 'agent')) {
908
- block('Implementation agents must move completed work to `review` with `p_actor_session_id` and `p_actor_role := agent`.', statusCall);
909
- }
910
- if (status === 'done') {
911
- await verifyDone(statusCall, blob);
912
- }
913
-
914
- pass({ status });
915
- }
916
-
917
- // Run as a hook; export the Layer-2 internals when required by a test.
918
- if (require.main === module) {
919
- main().catch((e) => block(`Exit gate crashed: ${e.message}`));
920
- } else {
921
- module.exports = {
922
- GateDenied,
923
- deny,
924
- verifyLayer2,
925
- resolveFullSha,
926
- filesInCommit,
927
- fileOnDisk,
928
- fileKnownToRepo,
929
- normalizeRepoPath,
930
- loadEvidenceLib,
931
- assertGitRepoAvailable,
932
- buildCapturedShaSet,
933
- WITNESS_ALLOWLIST,
934
- // Truth Gate 3.0 Layer 3 (validator re-run receipt) — exported for tests.
935
- verifyLayer3,
936
- assertValidatorReceipt,
937
- truthGateFlagEnabled,
938
- validatorCanonical,
939
- verifyValidatorSig,
940
- resultIsPass,
941
- pickNewestValidatorReceipt,
942
- VALIDATOR_SIGNED_FIELDS,
943
- };
944
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse hook — hard-stop unsafe work item exit transitions.
4
+ *
5
+ * This gate runs before tool calls and blocks legacy or supervisor-mutated
6
+ * CodeFlow/work-item close patterns before they reach Supabase. The database
7
+ * remains the final authority; this hook gives agents an immediate stop sign.
8
+ */
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+ const { execFileSync } = require('child_process');
16
+ const hookLog = require('./hook-logger');
17
+
18
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
19
+ const FULL_SHA_RE = /^[0-9a-f]{40}$/i;
20
+ const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
21
+ const EVENT_LOG = path.join(os.homedir(), '.claude', 'work-item-checklist-events.jsonl');
22
+
23
+ // Truth Gate 3.0 — Layer 2 (FUSED evidence gate).
24
+ // Witness allow-list (D5 / SLSA: the doer cannot sign its own provenance).
25
+ const WITNESS_ALLOWLIST = new Set(['validator-rerun', 'ci', 'human-review']);
26
+ // Repo the gate runs `git` against. The work tree under verification is
27
+ // regen-root; overridable for tests via RDC_TRUTH_GATE_REPO.
28
+ const TRUTH_GATE_REPO = process.env.RDC_TRUTH_GATE_REPO || 'C:/Dev/regen-root';
29
+
30
+ // Truth Gate 3.0 — Layer 3 (validator re-run receipt).
31
+ // Ratified contract (c): "no done without a validator re-run receipt with a
32
+ // matching live nonce + running git_sha." This layer is FLAG-GATED and
33
+ // default-OFF so it does not break in-flight build closures; it activates at
34
+ // deploy by flipping the flag ON. When ON it is FAIL-CLOSED.
35
+ // Running-brain health endpoint — the source of the actually-running git_sha
36
+ // to pin against (mirrors .claude/hooks/truth-gate.mjs ~line 112).
37
+ const BRAIN_HEALTH_URL = process.env.RDC_BRAIN_HEALTH_URL || 'http://127.0.0.1:3109/health';
38
+ // Canonical signed field order for a VALIDATOR re-run receipt. MUST match
39
+ // .claude/hooks/lib/receipt.mjs VALIDATOR_SIGNED_FIELDS exactly (the gate has
40
+ // no access to that ESM lib, so the contract is mirrored here, byte-for-byte).
41
+ const VALIDATOR_SIGNED_FIELDS = [
42
+ 'claim', 'witness', 'git_sha', 'nonce', 'command', 'result', 'nonce_in_output', 'ts',
43
+ ];
44
+
45
+ function readStdin() {
46
+ return new Promise((resolve) => {
47
+ let input = '';
48
+ process.stdin.setEncoding('utf8');
49
+ process.stdin.on('data', (chunk) => { input += chunk; });
50
+ process.stdin.on('end', () => resolve(input));
51
+ process.stdin.resume();
52
+ });
53
+ }
54
+
55
+ function block(message, details = {}) {
56
+ hookLog('work-item-exit-gate', 'PreToolUse', 'block', details);
57
+ process.stdout.write(JSON.stringify({
58
+ systemMessage: `HARD BLOCK — Work item exit gate rejected this tool call.\n\n${message}`,
59
+ }));
60
+ process.exit(1);
61
+ }
62
+
63
+ function pass(details = {}) {
64
+ hookLog('work-item-exit-gate', 'PreToolUse', 'pass', details);
65
+ process.exit(0);
66
+ }
67
+
68
+ function stringifyTool(toolInput) {
69
+ if (typeof toolInput === 'string') return toolInput;
70
+ try { return JSON.stringify(toolInput); } catch { return ''; }
71
+ }
72
+
73
+ function stripCasts(value) {
74
+ return String(value || '')
75
+ .replace(/::\s*[a-z_][\w.]*/gi, '')
76
+ .trim();
77
+ }
78
+
79
+ function unquote(value) {
80
+ const v = stripCasts(value);
81
+ const m = v.match(/^'(.*)'$/s) || v.match(/^"(.*)"$/s);
82
+ return (m ? m[1] : v).replace(/''/g, "'").trim();
83
+ }
84
+
85
+ function getNamedArg(text, name) {
86
+ const re = new RegExp(`${name}\\s*:?=\\s*('(?:''|[^'])*'|"(?:\\\\"|[^"])*"|true|false|null|[0-9a-f-]{36})`, 'i');
87
+ const m = text.match(re);
88
+ return m ? unquote(m[1]) : null;
89
+ }
90
+
91
+ function splitArgs(argsText) {
92
+ const args = [];
93
+ let current = '';
94
+ let quote = null;
95
+ let depth = 0;
96
+ for (let i = 0; i < argsText.length; i++) {
97
+ const ch = argsText[i];
98
+ const next = argsText[i + 1];
99
+ if (quote) {
100
+ current += ch;
101
+ if (ch === quote && !(quote === "'" && next === "'")) quote = null;
102
+ else if (ch === "'" && next === "'") current += argsText[++i];
103
+ continue;
104
+ }
105
+ if (ch === "'" || ch === '"') {
106
+ quote = ch;
107
+ current += ch;
108
+ continue;
109
+ }
110
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
111
+ if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
112
+ if (ch === ',' && depth === 0) {
113
+ args.push(current.trim());
114
+ current = '';
115
+ continue;
116
+ }
117
+ current += ch;
118
+ }
119
+ if (current.trim()) args.push(current.trim());
120
+ return args;
121
+ }
122
+
123
+ function findCall(text, name) {
124
+ const idx = text.toLowerCase().indexOf(name.toLowerCase());
125
+ if (idx < 0) return null;
126
+ const open = text.indexOf('(', idx + name.length);
127
+ if (open < 0) return null;
128
+ let quote = null;
129
+ let depth = 0;
130
+ for (let i = open; i < text.length; i++) {
131
+ const ch = text[i];
132
+ const next = text[i + 1];
133
+ if (quote) {
134
+ if (ch === quote && !(quote === "'" && next === "'")) quote = null;
135
+ else if (ch === "'" && next === "'") i++;
136
+ continue;
137
+ }
138
+ if (ch === "'" || ch === '"') {
139
+ quote = ch;
140
+ continue;
141
+ }
142
+ if (ch === '(') depth++;
143
+ if (ch === ')') {
144
+ depth--;
145
+ if (depth === 0) return text.slice(open + 1, i);
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function objectArg(blob, name) {
152
+ const re = new RegExp(`"${name}"\\s*:\\s*("([^"]*)"|true|false|null)`, 'i');
153
+ const m = blob.match(re);
154
+ if (!m) return null;
155
+ if (m[1] === 'true' || m[1] === 'false' || m[1] === 'null') return m[1];
156
+ return m[2] || null;
157
+ }
158
+
159
+ function extractStatusCall(blob) {
160
+ if (!/update_work_item_status/i.test(blob)) return null;
161
+ const call = findCall(blob, 'update_work_item_status');
162
+ const args = call ? splitArgs(call) : [];
163
+ return {
164
+ id: objectArg(blob, 'p_id') || getNamedArg(blob, 'p_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
165
+ status: objectArg(blob, 'p_status') || getNamedArg(blob, 'p_status') || unquote(args[1] || ''),
166
+ actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
167
+ actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
168
+ };
169
+ }
170
+
171
+ function extractTickCall(blob) {
172
+ if (!/update_checklist_item/i.test(blob)) return null;
173
+ const call = findCall(blob, 'update_checklist_item');
174
+ const args = call ? splitArgs(call) : [];
175
+ return {
176
+ workItemId: objectArg(blob, 'p_work_item_id') || getNamedArg(blob, 'p_work_item_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
177
+ itemId: objectArg(blob, 'p_item_id') || getNamedArg(blob, 'p_item_id') || unquote(args[1] || ''),
178
+ checked: (objectArg(blob, 'p_checked') || getNamedArg(blob, 'p_checked') || unquote(args[2] || '')).toLowerCase() === 'true',
179
+ actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
180
+ actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
181
+ };
182
+ }
183
+
184
+ function logTick(tick, rawTool) {
185
+ try {
186
+ fs.mkdirSync(path.dirname(EVENT_LOG), { recursive: true });
187
+ fs.appendFileSync(EVENT_LOG, JSON.stringify({
188
+ ts: new Date().toISOString(),
189
+ work_item_id: tick.workItemId || null,
190
+ item_id: tick.itemId || null,
191
+ checked: tick.checked,
192
+ actor_session_id: tick.actorSessionId || null,
193
+ actor_role: tick.actorRole || null,
194
+ tool_name: rawTool.tool_name || null,
195
+ }) + '\n');
196
+ } catch (_) {}
197
+ }
198
+
199
+ async function getServiceKey() {
200
+ if (process.env.SUPABASE_SERVICE_ROLE_KEY) return process.env.SUPABASE_SERVICE_ROLE_KEY;
201
+ if (process.env.SUPABASE_SERVICE_KEY) return process.env.SUPABASE_SERVICE_KEY;
202
+ for (const endpoint of ['supabase-service', 'supabase-service-role']) {
203
+ try {
204
+ const res = await fetch(`http://127.0.0.1:52437/v/${endpoint}`, { signal: AbortSignal.timeout(2000) });
205
+ if (res.ok) {
206
+ const text = (await res.text()).trim();
207
+ if (text && !text.startsWith('{')) return text;
208
+ }
209
+ } catch (_) {}
210
+ }
211
+ return null;
212
+ }
213
+
214
+ async function supabaseGet(pathname) {
215
+ const key = await getServiceKey();
216
+ if (!key) throw new Error('clauth/env did not provide a Supabase service key');
217
+ const base = (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL).replace(/\/$/, '');
218
+ const res = await fetch(`${base}/rest/v1/${pathname}`, {
219
+ headers: {
220
+ apikey: key,
221
+ Authorization: `Bearer ${key}`,
222
+ Accept: 'application/json',
223
+ },
224
+ signal: AbortSignal.timeout(3500),
225
+ });
226
+ if (!res.ok) throw new Error(`Supabase verification HTTP ${res.status}`);
227
+ return res.json();
228
+ }
229
+
230
+ function requiredItems(checklist) {
231
+ return Array.isArray(checklist)
232
+ ? checklist.filter((item) => item && item.required === true)
233
+ : [];
234
+ }
235
+
236
+ // ===========================================================================
237
+ // Truth Gate 3.0 — Layer 2 (FUSED evidence gate / freeze-the-leak)
238
+ //
239
+ // Asserts, server-checkable and FAIL-CLOSED, that a `done` close corresponds to
240
+ // reality. Baru-trap hardening (from the auditors' OWN false positives):
241
+ // - resolve commits by FULL 40-hex SHA only — never short-prefix compare
242
+ // (prefix collisions falsely flagged real work as fabricated).
243
+ // - locate claimed files WHOLE-REPO (`git log --all -- <path>`), never
244
+ // cited-path-only (wrong-path lookups falsely cried "absent").
245
+ // ===========================================================================
246
+
247
+ /**
248
+ * A Layer-2 denial. Thrown by `deny()` so verifyLayer2 short-circuits without
249
+ * coupling to process.exit — production translates it to block(), tests assert
250
+ * on `.reason`. This keeps the gate logic pure + unit-testable while staying
251
+ * FAIL-CLOSED at the process boundary.
252
+ */
253
+ class GateDenied extends Error {
254
+ constructor(reason, details) {
255
+ super(reason);
256
+ this.name = 'GateDenied';
257
+ this.reason = reason;
258
+ this.details = details || {};
259
+ }
260
+ }
261
+
262
+ function deny(reason, details) {
263
+ throw new GateDenied(reason, details);
264
+ }
265
+
266
+ /**
267
+ * Build the L1-captured SHA set bound to the item's ORIGINATING session, or
268
+ * DENY (throw GateDenied) when there is no originating session to bind to.
269
+ *
270
+ * FAIL-CLOSED: a null/empty originating session must NOT disable the per-session
271
+ * binding (the old `!originatingSession` short-circuit accepted ANY session's
272
+ * captured SHA — a fail-open laundering hole). With no session, there is no
273
+ * provenance to verify, so we DENY. Otherwise we keep ONLY rows whose
274
+ * session_id exactly equals the originating session.
275
+ *
276
+ * @param originatingSession string|null the item's session_id
277
+ * @param capturedRows Array<{sha, session_id}> from work_item_commits
278
+ * @returns Set<string> full-SHA (lowercased) set for this item+session
279
+ */
280
+ function buildCapturedShaSet(originatingSession, capturedRows) {
281
+ const sess = originatingSession || null;
282
+ if (!sess) {
283
+ deny(
284
+ 'L2: done rejected — cannot bind commit provenance: work item has no originating session. ' +
285
+ 'Without a session to bind captured SHAs to, the per-session commit binding cannot be verified; fail-closed.',
286
+ { originatingSession },
287
+ );
288
+ }
289
+ return new Set(
290
+ (Array.isArray(capturedRows) ? capturedRows : [])
291
+ .filter((r) => r && r.session_id === sess)
292
+ .map((r) => String((r && r.sha) || '').toLowerCase())
293
+ .filter((s) => FULL_SHA_RE.test(s)),
294
+ );
295
+ }
296
+
297
+ /** Run a git command in the truth-gate repo; returns trimmed stdout or null.
298
+ * stderr is captured (piped) so a failure carries a usable diagnostic instead
299
+ * of being swallowed. On failure with allowFail, returns null; otherwise the
300
+ * thrown error retains git's stderr in e.stderr / e.message. */
301
+ function git(args, { allowFail = false } = {}) {
302
+ try {
303
+ return execFileSync('git', args, {
304
+ cwd: TRUTH_GATE_REPO,
305
+ encoding: 'utf8',
306
+ stdio: ['ignore', 'pipe', 'pipe'],
307
+ maxBuffer: 16 * 1024 * 1024,
308
+ }).trim();
309
+ } catch (e) {
310
+ if (allowFail) return null;
311
+ throw e;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Verify, once at entry, that TRUTH_GATE_REPO is a real git work tree the gate
317
+ * can interrogate. If git is missing or the path is not a work tree, every
318
+ * downstream SHA/file check would silently mis-verify, so we fail-closed with a
319
+ * clear `truth-gate repo unavailable` block — distinct from a `ref not found`.
320
+ */
321
+ function assertGitRepoAvailable() {
322
+ let out;
323
+ try {
324
+ out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
325
+ cwd: TRUTH_GATE_REPO,
326
+ encoding: 'utf8',
327
+ stdio: ['ignore', 'pipe', 'pipe'],
328
+ maxBuffer: 1024 * 1024,
329
+ }).trim();
330
+ } catch (e) {
331
+ const detail = (e && (e.stderr || e.message)) ? String(e.stderr || e.message).trim().slice(0, 200) : 'git invocation failed';
332
+ deny(
333
+ 'L2: done rejected — truth-gate repo unavailable: cannot run git in "' + TRUTH_GATE_REPO + '" (' + detail + '). ' +
334
+ 'The gate cannot verify commit/file provenance without a working git tree; fail-closed.',
335
+ { repo: TRUTH_GATE_REPO },
336
+ );
337
+ }
338
+ if (out !== 'true') {
339
+ deny(
340
+ 'L2: done rejected — truth-gate repo unavailable: "' + TRUTH_GATE_REPO + '" is not a git work tree (rev-parse returned "' + out + '"). ' +
341
+ 'The gate cannot verify commit/file provenance; fail-closed.',
342
+ { repo: TRUTH_GATE_REPO },
343
+ );
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Resolve a commit ref to its FULL 40-char SHA, or null if it does not exist.
349
+ * Uses `rev-parse --verify <ref>^{commit}` so only real commit objects resolve.
350
+ * NEVER does a prefix/substring compare — the returned value is the canonical
351
+ * full SHA, and all equality downstream is full-SHA equality.
352
+ */
353
+ function resolveFullSha(ref) {
354
+ if (typeof ref !== 'string' || !ref.trim()) return null;
355
+ const full = git(['rev-parse', '--verify', '--quiet', `${ref.trim()}^{commit}`], { allowFail: true });
356
+ return full && FULL_SHA_RE.test(full) ? full.toLowerCase() : null;
357
+ }
358
+
359
+ /** Full set of file paths touched by a commit (`git show --stat`→ name-only). */
360
+ function filesInCommit(fullSha) {
361
+ const out = git(['show', '--no-renames', '--name-only', '--pretty=format:', fullSha], { allowFail: true });
362
+ if (out == null) return null;
363
+ return new Set(out.split('\n').map((l) => l.trim()).filter(Boolean).map(normalizeRepoPath));
364
+ }
365
+
366
+ function normalizeRepoPath(p) {
367
+ return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
368
+ }
369
+
370
+ /**
371
+ * Whole-repo existence check for a claimed file. A file "exists for this repo"
372
+ * when EITHER it is tracked anywhere in history (`git log --all -- <path>`
373
+ * returns a commit) OR it is present on disk. This is deliberately broad to
374
+ * avoid the cited-path-only false "absent" verdict.
375
+ */
376
+ function fileKnownToRepo(repoPath) {
377
+ const p = normalizeRepoPath(repoPath);
378
+ if (!p) return false;
379
+ // On disk now?
380
+ try {
381
+ if (fs.existsSync(path.join(TRUTH_GATE_REPO, p))) return true;
382
+ } catch (_) { /* fall through to history */ }
383
+ // Tracked anywhere in history (whole-repo locator, never cited-path-only)?
384
+ const log = git(['log', '--all', '--oneline', '-1', '--', p], { allowFail: true });
385
+ return Boolean(log && log.length > 0);
386
+ }
387
+
388
+ /** Does the file exist on disk in the work tree right now? */
389
+ function fileOnDisk(repoPath) {
390
+ const p = normalizeRepoPath(repoPath);
391
+ if (!p) return false;
392
+ try { return fs.existsSync(path.join(TRUTH_GATE_REPO, p)); } catch { return false; }
393
+ }
394
+
395
+ let _evidenceLib = null;
396
+ /** Lazy-load the fused primitive's artifact discriminators (ESM from CJS).
397
+ * Uses a file:// URL so the dynamic import works on Windows absolute paths.
398
+ * Returns { isMachineArtifact, isPassingArtifact }. */
399
+ async function loadEvidenceLib() {
400
+ if (_evidenceLib) return _evidenceLib;
401
+ const { pathToFileURL } = require('url');
402
+ const libPath = path.join(__dirname, 'lib', 'run-evidence-gate.mjs');
403
+ const mod = await import(pathToFileURL(libPath).href);
404
+ _evidenceLib = { isMachineArtifact: mod.isMachineArtifact, isPassingArtifact: mod.isPassingArtifact };
405
+ return _evidenceLib;
406
+ }
407
+
408
+ // ===========================================================================
409
+ // Truth Gate 3.0 — Layer 3 (validator re-run receipt). FLAG-GATED, default-OFF.
410
+ //
411
+ // Ratified contract (c): a `done` close requires a chain-stored, HMAC-valid,
412
+ // fresh-nonce, running-sha-pinned validator receipt. Nothing consumed the
413
+ // receipt layer before this. To avoid breaking in-flight closures, the check is
414
+ // behind a flag (default OFF → behaves exactly as today). When the flag is ON it
415
+ // is FAIL-CLOSED: a missing / forged / stale / replayed / wrong-sha receipt, or
416
+ // an inability to evaluate, DENIES the close.
417
+ // ===========================================================================
418
+
419
+ /**
420
+ * Is the Layer-3 validator-receipt requirement enabled?
421
+ * Default OFF. Turned on at deploy via either:
422
+ * - env RDC_TRUTHGATE_REQUIRE_VALIDATOR_RECEIPT in {1,true,on,yes}, OR
423
+ * - a DB row in public.truthgate_flags(flag,enabled) with flag =
424
+ * 'require_validator_receipt' and enabled = true (best-effort; a lookup
425
+ * failure does NOT enable the flag — absence is OFF).
426
+ * The env is the authoritative, test-stable switch; the DB lookup is an optional
427
+ * deploy-time toggle. Either being true enables it.
428
+ */
429
+ function truthGateFlagEnabled(flag) {
430
+ const envName = 'RDC_TRUTHGATE_' + String(flag || '').toUpperCase();
431
+ const v = String(process.env[envName] || '').trim().toLowerCase();
432
+ return v === '1' || v === 'true' || v === 'on' || v === 'yes';
433
+ }
434
+
435
+ /** Best-effort DB toggle for the flag. Absence / error → false (stays OFF). */
436
+ async function truthGateFlagEnabledDb(flag) {
437
+ try {
438
+ const rows = await supabaseGet(
439
+ `truthgate_flags?flag=eq.${encodeURIComponent(flag)}&select=enabled&limit=1`,
440
+ );
441
+ return Array.isArray(rows) && rows[0] && rows[0].enabled === true;
442
+ } catch (_) {
443
+ return false; // a missing table or a lookup failure must NOT enable the gate
444
+ }
445
+ }
446
+
447
+ /** Canonical JSON the HMAC is computed over — mirrors receipt.mjs validatorCanonical(). */
448
+ function validatorCanonical(receipt) {
449
+ const picked = {};
450
+ for (const k of VALIDATOR_SIGNED_FIELDS) picked[k] = receipt[k] ?? null;
451
+ return JSON.stringify(picked);
452
+ }
453
+
454
+ /** Verify the validator receipt HMAC with the truth-gate secret (constant-time). */
455
+ function verifyValidatorSig(receipt, secret) {
456
+ if (!secret || !receipt || !receipt.hmac) return false;
457
+ let expected;
458
+ try {
459
+ expected = crypto.createHmac('sha256', secret).update(validatorCanonical(receipt)).digest('hex');
460
+ } catch (_) { return false; }
461
+ let a, b;
462
+ try {
463
+ a = Buffer.from(expected, 'hex');
464
+ b = Buffer.from(String(receipt.hmac), 'hex');
465
+ } catch (_) { return false; }
466
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
467
+ }
468
+
469
+ /** Does a re-run result object read as a PASS? Mirrors receipt.mjs resultIsPass(). */
470
+ function resultIsPass(result) {
471
+ if (!result || typeof result !== 'object') return false;
472
+ if (typeof result.exit_code === 'number') return result.exit_code === 0;
473
+ if (typeof result.exitCode === 'number') return result.exitCode === 0;
474
+ if (typeof result.passed === 'number') {
475
+ if (typeof result.failed === 'number') return result.failed === 0;
476
+ if (typeof result.total === 'number') return result.passed === result.total;
477
+ }
478
+ if (typeof result.tsc_errors === 'number') return result.tsc_errors === 0;
479
+ return false;
480
+ }
481
+
482
+ /**
483
+ * Validate a VALIDATOR re-run receipt for a `done` close. Pure + unit-testable;
484
+ * throws GateDenied on any failure so the caller fail-closes. ALL must hold:
485
+ * - a secret is available (else INFRA → DENY, never a silent pass)
486
+ * - HMAC valid (signed by the validator path, not hand-written)
487
+ * - witness ∈ allow-list (never 'agent' / self-witnessed)
488
+ * - git_sha === actualRunningSha (re-run pinned to the running brain HEAD)
489
+ * - nonce_in_output === true (fresh nonce surfaced in captured output)
490
+ * - result reads as a pass
491
+ * - nonce NOT in seenNonces (durable replay set, loaded from the chain store)
492
+ * - ts within maxAgeMin (fresh)
493
+ *
494
+ * @param {object} receipt
495
+ * @param {object} opts
496
+ * @param {string} opts.secret HMAC secret (truth-gate-secret)
497
+ * @param {string} opts.actualRunningSha live brain HEAD to pin against (required)
498
+ * @param {string[]} [opts.seenNonces] durable nonces already consumed
499
+ * @param {number} [opts.maxAgeMin=30]
500
+ * @param {number} [opts.nowMs]
501
+ */
502
+ function assertValidatorReceipt(receipt, opts = {}) {
503
+ const { secret, actualRunningSha, seenNonces = [], maxAgeMin = 30, nowMs } = opts;
504
+ if (!secret) {
505
+ deny(
506
+ 'L3: done rejected — INFRA: no truth-gate HMAC secret available to verify the validator receipt. ' +
507
+ 'Cannot evaluate the Layer-3 receipt; fail-closed (INFRA is a BLOCK, not an allow).',
508
+ { layer: 3 },
509
+ );
510
+ }
511
+ if (!receipt || typeof receipt !== 'object') {
512
+ deny('L3: done rejected — no validator re-run receipt found for this work item (flag is ON).', { layer: 3 });
513
+ }
514
+ if (!verifyValidatorSig(receipt, secret)) {
515
+ deny('L3: done rejected — validator receipt HMAC invalid/absent (hand-written or tampered receipt).', { layer: 3 });
516
+ }
517
+ if (!WITNESS_ALLOWLIST.has(String(receipt.witness || ''))) {
518
+ deny(
519
+ 'L3: done rejected — validator receipt witness "' + (receipt.witness || '<missing>') +
520
+ '" is not in {validator-rerun, ci, human-review} (the doer cannot self-witness).',
521
+ { layer: 3, witness: receipt.witness },
522
+ );
523
+ }
524
+ if (!receipt.git_sha) {
525
+ deny('L3: done rejected — validator receipt git_sha missing.', { layer: 3 });
526
+ }
527
+ if (actualRunningSha && String(receipt.git_sha) !== String(actualRunningSha)) {
528
+ deny(
529
+ 'L3: done rejected — validator receipt git_sha ' + receipt.git_sha +
530
+ ' != the actually-running brain HEAD ' + actualRunningSha + ' (receipt not pinned to the live runtime).',
531
+ { layer: 3, receiptSha: receipt.git_sha, runningSha: actualRunningSha },
532
+ );
533
+ }
534
+ if (receipt.nonce_in_output !== true) {
535
+ deny('L3: done rejected — validator receipt nonce_in_output != true (fresh nonce absent from captured output: cached/replayed artifact).', { layer: 3 });
536
+ }
537
+ if (!resultIsPass(receipt.result)) {
538
+ deny('L3: done rejected — validator receipt result did not read as a pass (re-run failed or unparseable result).', { layer: 3 });
539
+ }
540
+ if (receipt.nonce && Array.isArray(seenNonces) && seenNonces.includes(receipt.nonce)) {
541
+ deny('L3: done rejected — validator receipt nonce ' + receipt.nonce + ' already consumed in the chain (replayed) — stale.', { layer: 3, nonce: receipt.nonce });
542
+ }
543
+ const t = Date.parse(receipt.ts);
544
+ if (Number.isNaN(t)) deny('L3: done rejected — validator receipt ts is unparseable.', { layer: 3 });
545
+ const now = typeof nowMs === 'number' ? nowMs : Date.now();
546
+ if (now - t > maxAgeMin * 60_000) {
547
+ deny('L3: done rejected — validator receipt is stale (> ' + maxAgeMin + 'm old).', { layer: 3 });
548
+ }
549
+ // No denial → the Layer-3 receipt is valid, fresh, pinned, and unreplayed.
550
+ }
551
+
552
+ /** Read the truth-gate HMAC secret from clauth/env. null when unavailable. */
553
+ async function getTruthGateSecret() {
554
+ if (process.env.TRUTH_GATE_SECRET) return process.env.TRUTH_GATE_SECRET;
555
+ try {
556
+ const res = await fetch('http://127.0.0.1:52437/v/truth-gate-secret', { signal: AbortSignal.timeout(2500) });
557
+ if (res.ok) {
558
+ const text = (await res.text()).trim();
559
+ if (text && !text.startsWith('{')) return text;
560
+ }
561
+ } catch (_) {}
562
+ return null;
563
+ }
564
+
565
+ /** Pin against the running brain's /health git_sha (mirrors truth-gate.mjs). null on failure. */
566
+ async function getRunningBrainSha() {
567
+ try {
568
+ const res = await fetch(BRAIN_HEALTH_URL, { signal: AbortSignal.timeout(3000) });
569
+ if (!res.ok) return null;
570
+ const j = await res.json();
571
+ const sha = String(j && j.git_sha ? j.git_sha : '').trim();
572
+ return sha || null;
573
+ } catch (_) { return null; }
574
+ }
575
+
576
+ /** Newest validator receipt stored for this work item, or null. Loaded from the chain store. */
577
+ function pickNewestValidatorReceipt(receiptRows) {
578
+ if (!Array.isArray(receiptRows) || receiptRows.length === 0) return null;
579
+ let best = null;
580
+ let bestT = -Infinity;
581
+ for (const row of receiptRows) {
582
+ const payload = row && row.payload;
583
+ if (!payload || typeof payload !== 'object') continue;
584
+ const t = Date.parse(payload.ts);
585
+ const key = Number.isNaN(t) ? -Infinity : t;
586
+ if (key >= bestT) { bestT = key; best = payload; }
587
+ }
588
+ return best;
589
+ }
590
+
591
+ /**
592
+ * Layer-3 verification of a `done` close: require a chain-stored, HMAC-valid,
593
+ * fresh-nonce, running-sha-pinned validator receipt for THIS work item. Only
594
+ * runs when the flag is ON; otherwise it is a no-op (today's behaviour). When ON
595
+ * it is FAIL-CLOSED — any inability to load/evaluate the receipt is a DENY.
596
+ *
597
+ * Dependencies are injectable for unit tests via `deps`:
598
+ * deps.flagEnabled() → boolean
599
+ * deps.getSecret() → Promise<string|null>
600
+ * deps.getRunningSha() → Promise<string|null>
601
+ * deps.loadReceiptRows(id) → Promise<rows for this work item>
602
+ * deps.loadSeenNonces() → Promise<string[]> (durable consumed nonces)
603
+ */
604
+ async function verifyLayer3(statusCall, item, deps = {}, nowMs) {
605
+ const flagEnabled = deps.flagEnabled ? await deps.flagEnabled() : false;
606
+ if (!flagEnabled) return; // default-OFF: behaves exactly as today
607
+
608
+ const secret = deps.getSecret ? await deps.getSecret() : await getTruthGateSecret();
609
+ const runningSha = deps.getRunningSha ? await deps.getRunningSha() : await getRunningBrainSha();
610
+ if (!runningSha) {
611
+ deny(
612
+ 'L3: done rejected — INFRA: could not read the running brain git_sha from ' + BRAIN_HEALTH_URL +
613
+ '. Cannot pin the validator receipt to the live runtime; fail-closed (INFRA is a BLOCK).',
614
+ { layer: 3 },
615
+ );
616
+ }
617
+
618
+ let receiptRows;
619
+ let seenNonces;
620
+ try {
621
+ receiptRows = deps.loadReceiptRows
622
+ ? await deps.loadReceiptRows(statusCall.id)
623
+ : await supabaseGet(`truth_gate_receipts?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=payload&order=id.desc&limit=50`);
624
+ seenNonces = deps.loadSeenNonces
625
+ ? await deps.loadSeenNonces(statusCall.id)
626
+ : await loadSeenNonces(statusCall.id, pickNewestValidatorReceipt(receiptRows));
627
+ } catch (e) {
628
+ deny(
629
+ 'L3: done rejected — INFRA: could not load validator receipts/seen-nonces (' +
630
+ (e && e.message ? e.message : String(e)) + '); fail-closed.',
631
+ { layer: 3 },
632
+ );
633
+ }
634
+
635
+ const receipt = pickNewestValidatorReceipt(receiptRows);
636
+ assertValidatorReceipt(receipt, { secret, actualRunningSha: runningSha, seenNonces, nowMs });
637
+ }
638
+
639
+ /**
640
+ * Durable replay set: every validator-receipt nonce already consumed in the
641
+ * chain, EXCLUDING the candidate receipt's own nonce (so the candidate is not
642
+ * spuriously flagged as a replay of itself). Replay rejection is therefore
643
+ * durable (chain-backed), not in-memory-only.
644
+ */
645
+ async function loadSeenNonces(workItemId, candidateReceipt) {
646
+ const ownNonce = candidateReceipt && candidateReceipt.nonce ? String(candidateReceipt.nonce) : null;
647
+ const rows = await supabaseGet(
648
+ `truth_gate_receipts?select=payload&payload->>nonce=not.is.null&limit=1000`,
649
+ );
650
+ const seen = [];
651
+ for (const r of Array.isArray(rows) ? rows : []) {
652
+ const n = r && r.payload && r.payload.nonce ? String(r.payload.nonce) : null;
653
+ if (n && n !== ownNonce) seen.push(n);
654
+ }
655
+ return seen;
656
+ }
657
+
658
+ /**
659
+ * Layer-2 verification of a `done` close against the real repo. Throws (caught
660
+ * by verifyDone → block) on any internal error so the gate is FAIL-CLOSED:
661
+ * an inability to verify is a DENY, never a silent pass.
662
+ *
663
+ * @param statusCall parsed update_work_item_status args (has .id, .actorSessionId)
664
+ * @param item the work_items row (has implementation_report)
665
+ * @param capturedShas Set<string> of FULL L1-captured SHAs for this item/session
666
+ */
667
+ async function verifyLayer2(statusCall, item, capturedShas) {
668
+ // Fail-closed: the gate's commit/file checks all shell out to git. If the
669
+ // truth-gate repo is not a usable git work tree, verify nothing — DENY.
670
+ assertGitRepoAvailable();
671
+
672
+ const post = item.implementation_report && item.implementation_report.codeflow_post;
673
+ if (!post || typeof post !== 'object') {
674
+ deny('L2: done rejected — implementation_report.codeflow_post is missing or not an object.', statusCall);
675
+ }
676
+
677
+ // (5) Witness allow-list — the doer cannot self-witness (D5 / SLSA).
678
+ const witness = String(post.witness || '').trim();
679
+ if (!WITNESS_ALLOWLIST.has(witness)) {
680
+ deny(
681
+ 'L2: done rejected — codeflow_post.witness must be one of {validator-rerun, ci, human-review}; got "' +
682
+ (witness || '<missing>') + '". ' +
683
+ 'The party that did the work cannot sign its own provenance (witness:"agent" is never accepted).',
684
+ { ...statusCall, witness },
685
+ );
686
+ }
687
+
688
+ // (1) Commit resolves (FULL SHA) AND was captured by L1 for this item/session.
689
+ const claimedCommit = post.commit;
690
+ const fullSha = resolveFullSha(claimedCommit);
691
+ if (!fullSha) {
692
+ deny(
693
+ 'L2: done rejected — codeflow_post.commit ("' + (claimedCommit || '<missing>') +
694
+ '") does not resolve to a real commit (git cat-file/rev-parse). Free-typed or wrong SHAs are rejected.',
695
+ { ...statusCall, claimedCommit },
696
+ );
697
+ }
698
+ if (!capturedShas.has(fullSha)) {
699
+ deny(
700
+ 'L2: done rejected — commit ' + fullSha + ' was not captured by Layer 1 for this work item + originating session. ' +
701
+ 'The exit gate only accepts a commit SHA the commit-hook recorded against this item (no agent-asserted SHAs). ' +
702
+ 'Captured SHAs for this item/session: ' + (capturedShas.size ? [...capturedShas].join(', ') : '(none)') + '.',
703
+ { ...statusCall, fullSha, capturedCount: capturedShas.size },
704
+ );
705
+ }
706
+
707
+ // (2)/(3) Every files_changed entry is in the commit AND exists on disk.
708
+ const filesChanged = Array.isArray(post.files_changed) ? post.files_changed : [];
709
+ if (filesChanged.length === 0) {
710
+ deny('L2: done rejected — codeflow_post.files_changed is empty; a closure must name the files it changed.', statusCall);
711
+ }
712
+ const commitFiles = filesInCommit(fullSha);
713
+ if (commitFiles == null) {
714
+ deny('L2: done rejected — could not read the file list of commit ' + fullSha + ' (git show failed).', statusCall);
715
+ }
716
+ for (const raw of filesChanged) {
717
+ const p = normalizeRepoPath(raw);
718
+ if (!p) {
719
+ deny('L2: done rejected — a files_changed entry is empty/blank.', { ...statusCall, raw });
720
+ }
721
+ // In the commit? (whole-repo lookup is implicit — commitFiles is the full
722
+ // name-only set of the resolved commit, not a cited-path filter.)
723
+ if (!commitFiles.has(p)) {
724
+ deny(
725
+ 'L2: done rejected — file "' + p + '" is NOT among the files changed by commit ' + fullSha +
726
+ ' (git show --stat). files_changed must match the commit\'s actual contents.',
727
+ { ...statusCall, file: p, fullSha },
728
+ );
729
+ }
730
+ // On disk now?
731
+ if (!fileOnDisk(p)) {
732
+ deny(
733
+ 'L2: done rejected — claimed file "' + p + '" does not exist on disk in the work tree. ' +
734
+ 'A deliverable that is not present is not done.',
735
+ { ...statusCall, file: p },
736
+ );
737
+ }
738
+ // Whole-repo sanity (defense in depth; should always hold if on disk).
739
+ if (!fileKnownToRepo(p)) {
740
+ deny(
741
+ 'L2: done rejected — claimed file "' + p + '" is unknown to the repo (not on disk and not in history).',
742
+ { ...statusCall, file: p },
743
+ );
744
+ }
745
+ }
746
+
747
+ // (4) Every verification entry is a machine-parseable artifact, not prose,
748
+ // AND its OUTCOME is a PASS — not merely that it RAN. A failing run
749
+ // (exit_code:1), an error HTTP status (500), a fused verdict:'fail', or a
750
+ // test artifact with failures must DENY: "ran" is not "passed".
751
+ const { isMachineArtifact, isPassingArtifact } = await loadEvidenceLib();
752
+ const verifications = Array.isArray(post.verification) ? post.verification : [];
753
+ if (verifications.length === 0) {
754
+ deny('L2: done rejected — codeflow_post.verification is empty; closure needs a captured verification artifact.', statusCall);
755
+ }
756
+ for (const v of verifications) {
757
+ // 4a. Shape gate — it must be a captured machine artifact, not prose.
758
+ if (!isMachineArtifact(v)) {
759
+ const shown = typeof v === 'string' ? v.slice(0, 60) : JSON.stringify(v).slice(0, 80);
760
+ deny(
761
+ 'L2: done rejected — verification entry is prose/proxy, not a captured artifact: "' + shown + '". ' +
762
+ 'Each verification must be a run-evidence-gate result or a machine shape ' +
763
+ '({exit_code|http_status|rowcount|passed/total}). Strings like "HTTP 200" / "works" are rejected.',
764
+ { ...statusCall, verification: shown },
765
+ );
766
+ }
767
+ // 4b. Outcome gate — the captured artifact must read as a PASS.
768
+ if (!isPassingArtifact(v)) {
769
+ const shown = typeof v === 'string' ? v.slice(0, 80) : JSON.stringify(v).slice(0, 120);
770
+ deny(
771
+ 'L2: done rejected — verification-not-passing: the captured artifact ran but did NOT pass: "' + shown + '". ' +
772
+ 'A closure requires a PASSING verification (fused verdict:"pass" / exit_code:0 / http 2xx-3xx / ' +
773
+ 'failed:0 / passed===total / tsc_errors:0). A failing or error run is not evidence of done.',
774
+ { ...statusCall, verification: shown },
775
+ );
776
+ }
777
+ }
778
+ // No denial thrown => Layer-2 verification PASSED.
779
+ }
780
+
781
+ async function verifyDone(statusCall, blob) {
782
+ if (!statusCall.id) block('`update_work_item_status(..., done)` must include a work item UUID.', statusCall);
783
+ if (!statusCall.actorSessionId || !statusCall.actorRole) {
784
+ block('`done` requires the 5-argument RPC shape with `p_actor_session_id` and `p_actor_role`.', statusCall);
785
+ }
786
+ if (statusCall.actorRole !== 'validator') {
787
+ block('Only a validator may transition a non-epic work item to `done`; implementation agents move work to `review`.', statusCall);
788
+ }
789
+ if (/submit_implementation_report/i.test(blob)) {
790
+ block('Submit the implementation report in a separate tool call before `done`; the gate verifies committed DB state.', statusCall);
791
+ }
792
+
793
+ let rows;
794
+ let events;
795
+ let capturedRows;
796
+ try {
797
+ rows = await supabaseGet(`work_items?id=eq.${encodeURIComponent(statusCall.id)}&select=id,status,item_type,session_id,implementation_report,checklist`);
798
+ events = await supabaseGet(`work_item_checklist_events?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=item_id,checked,actor_session_id,actor_role,created_at&order=created_at.desc&limit=200`);
799
+ capturedRows = await supabaseGet(`work_item_commits?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=sha,session_id`);
800
+ } catch (e) {
801
+ block(`Cannot live-verify work item exit gate: ${e.message}. Do not close the item until clauth/Supabase verification is available.`, statusCall);
802
+ }
803
+
804
+ const item = rows && rows[0];
805
+ if (!item) block(`Work item ${statusCall.id} was not found.`, statusCall);
806
+ if (item.item_type === 'epic') return;
807
+ if (item.status !== 'review') block('Non-epic work items must be in `review` before a validator may mark them `done`.', { ...statusCall, currentStatus: item.status });
808
+
809
+ const report = item.implementation_report;
810
+ if (!report || typeof report !== 'object') block('`done` rejected because `implementation_report` is null or not an object.', statusCall);
811
+ if (!report.codeflow_post || typeof report.codeflow_post !== 'object') block('`done` rejected because `implementation_report.codeflow_post` is missing.', statusCall);
812
+
813
+ const missing = requiredItems(item.checklist).filter((ci) => ci.checked !== true);
814
+ if (missing.length > 0) {
815
+ block(`Required checklist items are unchecked: ${missing.map((ci) => ci.id || ci.text || '<unknown>').join(', ')}`, statusCall);
816
+ }
817
+
818
+ const latestByItem = new Map();
819
+ for (const event of Array.isArray(events) ? events : []) {
820
+ if (!latestByItem.has(event.item_id)) latestByItem.set(event.item_id, event);
821
+ }
822
+ const supervisorReticks = requiredItems(item.checklist)
823
+ .map((ci) => latestByItem.get(ci.id))
824
+ .filter((event) => event && event.checked === true && event.actor_role !== 'agent');
825
+ if (supervisorReticks.length > 0) {
826
+ block(
827
+ `Required checklist items were last ticked by a non-agent session: ${supervisorReticks.map((e) => e.item_id).join(', ')}`,
828
+ { ...statusCall, supervisorReticks: supervisorReticks.map((e) => e.item_id) },
829
+ );
830
+ }
831
+
832
+ // --- Truth Gate 3.0 Layer 2 — FUSED evidence gate (freeze-the-leak) ---------
833
+ // The L1-captured SHA set is restricted to the item's ORIGINATING session
834
+ // (the session that ticked the checklist) so a SHA captured by some other
835
+ // session against this item cannot launder a fabricated close. A null
836
+ // originating session is fail-closed (DENY) inside buildCapturedShaSet.
837
+ try {
838
+ const capturedShas = buildCapturedShaSet(item.session_id || null, capturedRows);
839
+ await verifyLayer2(statusCall, item, capturedShas);
840
+ } catch (e) {
841
+ if (e instanceof GateDenied) {
842
+ block(e.reason, e.details); // translate the L2 denial into a hard block
843
+ }
844
+ // Any OTHER error during L2 is an inability to verify => FAIL-CLOSED.
845
+ block('L2: done rejected — Layer-2 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
846
+ }
847
+
848
+ // --- Truth Gate 3.0 Layer 3 — validator re-run receipt (FLAG-GATED) ---------
849
+ // Default-OFF: a no-op until the flag is flipped at deploy, so in-flight build
850
+ // closures are unaffected. When ON it is FAIL-CLOSED: requires a chain-stored,
851
+ // HMAC-valid, fresh-nonce, running-sha-pinned validator receipt.
852
+ try {
853
+ await verifyLayer3(statusCall, item, {
854
+ flagEnabled: async () =>
855
+ truthGateFlagEnabled('require_validator_receipt') ||
856
+ (await truthGateFlagEnabledDb('require_validator_receipt')),
857
+ });
858
+ } catch (e) {
859
+ if (e instanceof GateDenied) {
860
+ block(e.reason, e.details); // translate the L3 denial into a hard block
861
+ }
862
+ // Any OTHER error during L3 (flag ON) is an inability to verify => FAIL-CLOSED.
863
+ block('L3: done rejected — Layer-3 verification could not complete (' + (e && e.message ? e.message : String(e)) + '). Fail-closed: not closing.', statusCall);
864
+ }
865
+ }
866
+
867
+ function validateTick(tick, rawTool) {
868
+ logTick(tick, rawTool);
869
+ if (!tick.checked) return;
870
+ if (!tick.workItemId || !tick.itemId) {
871
+ block('Checklist ticks must include `p_work_item_id` and `p_item_id`.', tick);
872
+ }
873
+ if (!tick.actorSessionId) {
874
+ block('Checklist ticks must include `p_actor_session_id`; unaudited ticks are rejected.', tick);
875
+ }
876
+ if (tick.actorRole !== 'agent') {
877
+ block('Only the originating implementation agent may tick required checklist evidence. Supervisors/validators reopen or review; they do not re-tick.', tick);
878
+ }
879
+ }
880
+
881
+ async function main() {
882
+ let raw;
883
+ try { raw = JSON.parse(await readStdin()); } catch { process.exit(0); }
884
+
885
+ const blob = stringifyTool(raw.tool_input || raw);
886
+ const tick = extractTickCall(blob);
887
+ if (tick) validateTick(tick, raw);
888
+
889
+ const statusCall = extractStatusCall(blob);
890
+ if (!statusCall) pass({ reason: 'no-status-call' });
891
+
892
+ // Ambiguous-parse fail-closed: the tool blob references update_work_item_status
893
+ // AND contains a 'done' literal, but we could not extract a usable id/status.
894
+ // A done-close we cannot parse must NOT slip through as a pass — block.
895
+ if (/update_work_item_status/i.test(blob) && /\bdone\b/i.test(blob)) {
896
+ if (!statusCall.id || !statusCall.status) {
897
+ block(
898
+ 'Work item exit gate could not parse the `update_work_item_status` call that references `done` ' +
899
+ '(missing ' + (!statusCall.id ? 'work item id' : 'status') + '). ' +
900
+ 'Ambiguous done-close parses are fail-closed; re-issue the call in the documented 5-argument RPC shape.',
901
+ statusCall,
902
+ );
903
+ }
904
+ }
905
+
906
+ const status = String(statusCall.status || '').toLowerCase();
907
+ if (status === 'review' && (!statusCall.actorSessionId || statusCall.actorRole !== 'agent')) {
908
+ block('Implementation agents must move completed work to `review` with `p_actor_session_id` and `p_actor_role := agent`.', statusCall);
909
+ }
910
+ if (status === 'done') {
911
+ await verifyDone(statusCall, blob);
912
+ }
913
+
914
+ pass({ status });
915
+ }
916
+
917
+ // Run as a hook; export the Layer-2 internals when required by a test.
918
+ if (require.main === module) {
919
+ main().catch((e) => block(`Exit gate crashed: ${e.message}`));
920
+ } else {
921
+ module.exports = {
922
+ GateDenied,
923
+ deny,
924
+ verifyLayer2,
925
+ resolveFullSha,
926
+ filesInCommit,
927
+ fileOnDisk,
928
+ fileKnownToRepo,
929
+ normalizeRepoPath,
930
+ loadEvidenceLib,
931
+ assertGitRepoAvailable,
932
+ buildCapturedShaSet,
933
+ WITNESS_ALLOWLIST,
934
+ // Truth Gate 3.0 Layer 3 (validator re-run receipt) — exported for tests.
935
+ verifyLayer3,
936
+ assertValidatorReceipt,
937
+ truthGateFlagEnabled,
938
+ validatorCanonical,
939
+ verifyValidatorSig,
940
+ resultIsPass,
941
+ pickNewestValidatorReceipt,
942
+ VALIDATOR_SIGNED_FIELDS,
943
+ };
944
+ }