@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.4

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 (98) hide show
  1. package/bin/__tests__/codex-hooks.test.js +794 -21
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +1838 -112
  4. package/bin/__tests__/installer-safety.test.js +82 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +799 -0
  6. package/bin/__tests__/package-inventory.test.js +841 -10
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +2469 -71
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +153 -202
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
  22. package/src/claude/hooks/completion-authority-check.cjs +56 -0
  23. package/src/claude/hooks/completion-authority-state.cjs +527 -0
  24. package/src/claude/hooks/completion-authority.cjs +154 -0
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/session.cjs +33 -19
  31. package/src/claude/hooks/spec-gate.cjs +181 -139
  32. package/src/claude/hooks/spec-state.cjs +112 -47
  33. package/src/claude/migration-manifest.json +15 -2
  34. package/src/claude/rules/state-sync.md +52 -21
  35. package/src/claude/rules/workflow.md +17 -9
  36. package/src/claude/scripts/provenance.cjs +315 -0
  37. package/src/claude/scripts/spec-final-state.cjs +244 -0
  38. package/src/claude/scripts/spec-ground.cjs +535 -173
  39. package/src/claude/scripts/spec-readiness.cjs +118 -0
  40. package/src/claude/scripts/spec-receipt.cjs +162 -0
  41. package/src/claude/scripts/spec-resolver.cjs +501 -0
  42. package/src/claude/scripts/spec-scaffold.cjs +1945 -55
  43. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  44. package/src/claude/scripts/validate-spec-output.cjs +3204 -262
  45. package/src/claude/scripts/workflow-policy.cjs +1888 -203
  46. package/src/claude/settings/settings.json +12 -0
  47. package/src/claude/skills/code-review/SKILL.md +94 -66
  48. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  49. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  50. package/src/claude/skills/develop/SKILL.md +138 -283
  51. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  52. package/src/claude/skills/develop/references/quality-gate.md +123 -148
  53. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  54. package/src/claude/skills/specs/SKILL.md +192 -397
  55. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  56. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  57. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  58. package/src/claude/skills/specs/references/review.md +200 -260
  59. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  60. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  61. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  62. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  63. package/src/claude/skills/specs/rules/design-review.md +42 -97
  64. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  65. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  66. package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
  67. package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
  68. package/src/claude/skills/specs/templates/design.md +49 -263
  69. package/src/claude/skills/specs/templates/requirements.md +26 -36
  70. package/src/claude/skills/specs/templates/research.md +9 -98
  71. package/src/claude/skills/specs/templates/spec-state.json +26 -67
  72. package/src/claude/skills/specs/templates/task.md +30 -82
  73. package/src/claude/skills/sync/SKILL.md +45 -15
  74. package/src/claude/skills/sync/references/sync-protocols.md +34 -16
  75. package/src/claude/skills/test/SKILL.md +151 -167
  76. package/src/codex/AGENTS.md +6 -0
  77. package/src/codex/hooks/completion-authority-check.cjs +63 -0
  78. package/src/codex/hooks/completion-authority-state.cjs +527 -0
  79. package/src/codex/hooks/completion-authority.cjs +106 -0
  80. package/src/codex/hooks/lib/hook-context.cjs +58 -4
  81. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  82. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  83. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  84. package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
  85. package/src/codex/hooks/lib/spec-utils.cjs +104 -27
  86. package/src/codex/hooks/privacy-approval.cjs +1 -1
  87. package/src/codex/hooks/privacy-block.cjs +67 -11
  88. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  89. package/src/codex/hooks/session.cjs +2 -0
  90. package/src/codex/hooks/spec-gate.cjs +151 -24
  91. package/src/codex/hooks/spec-state.cjs +65 -12
  92. package/src/codex/hooks/state.cjs +2 -0
  93. package/src/codex/hooks.json +16 -1
  94. package/src/codex/rules/state-sync.md +47 -27
  95. package/src/common/AGENTS.md +1 -1
  96. package/src/opencode/AGENTS.md +4 -2
  97. package/src/opencode/plugins/spec-gate.ts +296 -122
  98. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -5,59 +5,171 @@
5
5
  * Stop Hook — spec-gate.cjs
6
6
  * Implements: https://docs.anthropic.com/en/docs/claude-code/hooks
7
7
  *
8
- * Blocks turn end when *newly-done* tasks lack a verification receipt.
8
+ * Blocks turn end when any done task lacks a verification receipt.
9
9
  * Block contract (exit 0 + stdout): {"decision":"block","reason":"..."}.
10
10
  *
11
- * Safety: stop_hook_active loop guard; runtime.spec.completion_gate=false
12
- * escape hatch (missing key → ON); cache is optimization only - every
13
- * done transition is validated even on first run / empty cache; crash → fail-open + hooks/.logs/hook-log.jsonl. Exit: 0 always.
11
+ * Safety: stop_hook_active loop guard; no worker-writable completion-gate
12
+ * bypass exists; cache is optimization only - every
13
+ * done transition is validated even on first run / empty cache; crash → controlled block + hooks/.logs/hook-log.jsonl. Exit: 0 always.
14
14
  */
15
15
 
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ function projectRoot(payload = {}) {
20
+ const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
21
+ ? process.env.CLAUDE_PROJECT_DIR.trim()
22
+ : '';
23
+ if (configured) {
24
+ try { return fs.realpathSync(path.resolve(configured)); } catch { /* continue */ }
25
+ }
26
+
27
+ const installedRoot = path.resolve(__dirname, '..', '..');
28
+ const installedHook = path.join(installedRoot, '.claude', 'hooks', path.basename(__filename));
29
+ if (fs.existsSync(installedHook)) return installedRoot;
30
+
31
+ const sourceFixture = typeof process.env.PROJECT_ROOT === 'string'
32
+ ? process.env.PROJECT_ROOT.trim()
33
+ : '';
34
+ if (sourceFixture) {
35
+ try { return fs.realpathSync(path.resolve(sourceFixture)); } catch { /* continue */ }
36
+ }
37
+
38
+ const legacy = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
39
+ if (legacy) {
40
+ try { return fs.realpathSync(path.resolve(legacy)); } catch { /* continue */ }
41
+ }
42
+ try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
43
+ }
44
+
45
+ function logCrash(error) {
46
+ try {
47
+ const d = path.join(__dirname, '.logs');
48
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
49
+ fs.appendFileSync(
50
+ path.join(d, 'hook-log.jsonl'),
51
+ JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-gate', status: 'crash', error: error.message }) + '\n'
52
+ );
53
+ } catch (_) {}
54
+ }
55
+
56
+ function emitBlock(reason) {
57
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }) + '\n');
58
+ }
59
+
60
+ function sessionIdentity(payload) {
61
+ const candidates = [
62
+ payload && payload.session_id,
63
+ payload && payload.sessionId,
64
+ payload && payload.sessionID,
65
+ payload && payload.session && payload.session.id,
66
+ ];
67
+ return candidates.find((value) => typeof value === 'string' && value.trim() !== '') || null;
68
+ }
69
+
16
70
  try {
17
- const fs = require('fs');
18
- const path = require('path');
19
71
  const policyPath = path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs');
20
- const POLICY = require(policyPath);
21
72
 
22
73
  const stdin = fs.readFileSync(0, 'utf8').trim();
23
- if (!stdin) process.exit(0);
74
+ if (!stdin) throw new Error('hook payload is empty');
24
75
 
25
76
  const payload = JSON.parse(stdin);
26
- const cwd = payload.cwd || process.cwd();
77
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
78
+ throw new Error('hook payload must be a JSON object');
79
+ }
27
80
 
28
81
  // Never re-block a continuation caused by our own block (infinite-loop guard).
29
82
  if (payload.stop_hook_active === true) process.exit(0);
30
83
 
84
+ let POLICY;
85
+ let RESOLVER;
86
+ let RECEIPT;
87
+ let FINAL_STATE;
88
+ try {
89
+ POLICY = require(policyPath);
90
+ RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
91
+ RECEIPT = require(path.join(__dirname, '..', 'scripts', 'spec-receipt.cjs'));
92
+ FINAL_STATE = require('./completion-authority-check.cjs');
93
+ if (typeof POLICY.validateCanonicalReceipt !== 'function'
94
+ || typeof POLICY.completionDecisionForSpec !== 'function'
95
+ || typeof RECEIPT.checkTaskReceipt !== 'function'
96
+ || typeof FINAL_STATE.evaluateCloseout !== 'function') {
97
+ throw new Error('shared workflow policy lacks completion authority functions');
98
+ }
99
+ } catch (error) {
100
+ logCrash(error);
101
+ emitBlock(`Completion gate unavailable: shared workflow policy could not be loaded (${error.message}). Repair ${policyPath} before completing tasks.`);
102
+ process.exit(0);
103
+ }
104
+
105
+ const cwd = projectRoot(payload);
106
+
31
107
  // Missing/malformed runtime.json keeps the gate ON (fail-closed, valve 3 style).
32
108
  let runtime = {};
33
109
  try {
34
110
  const rp = path.join(cwd, '.claude', 'runtime.json');
35
111
  if (fs.existsSync(rp)) runtime = JSON.parse(fs.readFileSync(rp, 'utf8'));
36
112
  } catch { /* gate stays on */ }
37
- if (runtime.spec && runtime.spec.completion_gate === false) process.exit(0);
38
-
39
- // Active-spec discovery — same rules as spec-state.cjs.
40
- const baseDir = process.env.PROJECT_ROOT || cwd;
41
- const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
42
- if (!fs.existsSync(specsPath)) process.exit(0);
43
-
44
- let activeSpec = null;
45
- let featureName = null;
46
- for (const entry of fs.readdirSync(specsPath, { withFileTypes: true })) {
47
- if (!entry.isDirectory()) continue;
48
- const specFile = path.join(specsPath, entry.name, 'spec.json');
49
- if (!fs.existsSync(specFile)) continue;
50
- try {
51
- const specData = JSON.parse(fs.readFileSync(specFile, 'utf8'));
52
- if (specData.status === 'in_progress' || specData.status === 'in-progress') {
53
- activeSpec = specData;
54
- featureName = entry.name;
55
- break;
56
- }
57
- } catch { /* skip bad JSON */ }
58
- }
59
- if (!activeSpec) process.exit(0);
113
+ // Active-spec discovery via shared resolver — explicit target if present, else fail on ambiguity.
114
+ const baseDir = cwd;
115
+ const resolved = FINAL_STATE.resolveCandidate({ resolver: RESOLVER, projectRoot: baseDir, runtime, payload });
116
+ if (!resolved) process.exit(0);
117
+ if (resolved.error === 'multiple_active' || resolved.error === 'multiple_persisted') {
118
+ process.stdout.write(JSON.stringify({
119
+ decision: 'block',
120
+ reason: `Completion gate: multiple active specs detected (${resolved.candidates.join(', ')}). Provide explicit feature target or resolve ambiguity before completing tasks. Candidates: ${resolved.candidates.join(', ')}`
121
+ }) + '\n');
122
+ process.exit(0);
123
+ }
124
+ if (resolved.error === 'invalid_specs') {
125
+ process.stdout.write(JSON.stringify({
126
+ decision: 'block',
127
+ reason: `Completion gate: invalid spec JSON detected (${resolved.candidates.join(', ')}): ${resolved.reason}. Fix or remove malformed spec.json before completing tasks.`
128
+ }) + '\n');
129
+ process.exit(0);
130
+ }
131
+ if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
132
+ process.stdout.write(JSON.stringify({
133
+ decision: 'block',
134
+ reason: `Completion gate: explicit spec target invalid (${resolved.error}): ${resolved.reason || resolved.explicitFeature || resolved.explicitPath || 'unknown'}. Provide a valid feature target inside configured specs root.`
135
+ }) + '\n');
136
+ process.exit(0);
137
+ }
138
+ if (resolved.error) process.exit(0);
60
139
 
140
+ const activeSpec = resolved.spec;
141
+ const featureName = resolved.featureName;
142
+ const specsPath = resolved.specsDir;
143
+ const lifecyclePhase = activeSpec.current_phase || activeSpec.phase;
144
+ const explicitCloseout = ['done', 'completed', 'complete'].includes(activeSpec.status)
145
+ || ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
146
+ if (activeSpec.schema_version === '2.1') {
147
+ const finalState = FINAL_STATE.evaluateCloseout({
148
+ resolver: RESOLVER,
149
+ policy: POLICY,
150
+ projectRoot: baseDir,
151
+ runtime,
152
+ payload: { ...payload, session_id: sessionIdentity(payload) },
153
+ });
154
+ if (!finalState.ok) {
155
+ emitBlock(`Completion gate: ${finalState.reason}`);
156
+ process.exit(0);
157
+ }
158
+ if (finalState.active) process.exit(0);
159
+ }
160
+ const runtimeContext = POLICY.deriveRuntimeContext({
161
+ projectRoot: baseDir,
162
+ specsRoot: specsPath,
163
+ specFile: resolved.specFile || path.join(specsPath, featureName, 'spec.json'),
164
+ featureName,
165
+ runtimeSession: sessionIdentity(payload),
166
+ });
167
+
168
+ const configuredGate = runtime.spec?.completion_gate;
169
+ if (configuredGate !== undefined && configuredGate !== true) {
170
+ emitBlock('Completion gate: runtime.spec.completion_gate is a worker-writable flag, not an authorization; no completion-gate bypass is supported. Remove the flag and satisfy the gate.');
171
+ process.exit(0);
172
+ }
61
173
  const taskRegistry = activeSpec.task_registry || {};
62
174
  const cacheFile = path.join(__dirname, '.logs', 'spec-gate-last.json');
63
175
  const cacheExists = fs.existsSync(cacheFile);
@@ -90,103 +202,30 @@ try {
90
202
  const allDoneTasks = Object.keys(taskRegistry).filter((tp) =>
91
203
  (taskRegistry[tp]?.status || 'pending') === 'done'
92
204
  );
93
- // /m so ^ matches line starts (Evidence is never at byte 0 of the file).
94
- // legacy heading aliases: read-compat only, no longer advertised
95
- const EVID_RE = /^#{2,3}\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/m;
96
- const PASS_MARKER_RE = /^\s*Verification:\s*PASS\s*$/m;
97
- const LEGACY_SUCCESS_RE = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
98
- const EXPLICIT_FAILURE_RE = /\bFAIL(?:ED|URE|URES|ING)?\b|tests?\s+failed|exit\s+code\s*[:=]?\s*[1-9]\d*|\b0\s+tests?\b/i;
99
-
100
- /** Body of first Evidence heading until next same-or-higher heading. */
101
- function evidenceBody(text) {
102
- const lines = text.split('\n');
103
- let start = -1, level = 0;
104
- for (let i = 0; i < lines.length; i++) {
105
- const m = lines[i].match(/^(#{2,3})\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/);
106
- if (m) { start = i + 1; level = m[1].length; break; }
107
- }
108
- if (start < 0) return null;
109
- let end = lines.length;
110
- for (let i = start; i < lines.length; i++) {
111
- const hm = lines[i].match(/^(#{1,6})\s+/);
112
- if (hm && hm[1].length <= level) { end = i; break; }
113
- }
114
- return lines.slice(start, end).join('\n');
115
- }
116
-
117
- function validateCanonicalReceipt(body) {
118
- const fails = [];
119
- if (!/^\s*Verification:\s*PASS\s*$/m.test(body)) fails.push('verification_state');
120
- if (!/^\s*Command(?:\(s\))?\s*:/m.test(body)) fails.push('command');
121
- if (!/^\s*Exit\s*:|exit\s+code\s*[:=]|\bResult\s*:\s*PASS\b/im.test(body)) fails.push('exit_result');
122
- const hasBase = /^\s*Base[ \t]*:[ \t]*\S/im.test(body);
123
- const hasHead = /^\s*Head[ \t]*:[ \t]*\S/im.test(body);
124
- const hasBaseSha = /\bbase_sha[ \t]*:[ \t]*\S/im.test(body);
125
- const hasHeadSha = /\bhead_sha[ \t]*:[ \t]*\S/im.test(body);
126
- if (!((hasBase && hasHead) || (hasBaseSha && hasHeadSha))) fails.push('provenance');
127
- if (/\bartifact\b/i.test(body) && !/sha256:/i.test(body)) fails.push('artifact_hash');
128
- return fails;
129
- }
130
-
131
- // Receipt checks a–h → failed letter list.
132
- // a: file + Status header has "done"; b: Evidence heading; c: no {{...}} + unambiguous PASS; d: completed_at; e: command; f: exit/result; g: provenance; h: artifact hash
133
- function safeTaskFile(featureDir, taskPath) {
134
- const target = path.resolve(featureDir, taskPath);
135
- const relative = path.relative(featureDir, target);
136
- // sibling-prefix safe: must not be .., ../..., or absolute (cross-platform)
137
- if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
138
- return target;
139
- }
140
-
141
- function checkReceipt(taskPath) {
142
- const fails = [];
143
- const featureDir = path.join(specsPath, featureName);
144
- const abs = safeTaskFile(featureDir, taskPath);
145
- if (!abs || !fs.existsSync(abs)) return ['a'];
146
- let text = '';
147
- try { text = fs.readFileSync(abs, 'utf8'); } catch { return ['a']; }
148
-
149
- const statusLine = text.split('\n').find((l) => /^\s*(\*\*)?Status(\*\*)?\s*:/i.test(l));
150
- if (!statusLine || !/\bdone\b/i.test(statusLine)) fails.push('a');
151
-
152
- const body = evidenceBody(text);
153
- if (body === null || !EVID_RE.test(text)) {
154
- fails.push('b');
155
- } else if (
156
- /\{\{[^}]+\}\}/.test(body) ||
157
- EXPLICIT_FAILURE_RE.test(body) ||
158
- !(PASS_MARKER_RE.test(body) || LEGACY_SUCCESS_RE.test(body))
159
- ) {
160
- fails.push('c');
161
- } else {
162
- // Canonical receipt checks for done tasks
163
- const canonicalFails = validateCanonicalReceipt(body);
164
- const map = { verification_state: 'c', command: 'e', exit_result: 'f', provenance: 'g', artifact_hash: 'h' };
165
- for (const cf of canonicalFails) {
166
- if (cf === 'verification_state') {
167
- if (!PASS_MARKER_RE.test(body) && !fails.includes('c')) fails.push('c');
168
- continue;
169
- }
170
- const letter = map[cf];
171
- if (letter && !fails.includes(letter)) fails.push(letter);
172
- }
173
- // Enforce strict Verification: PASS, not just legacy success
174
- if (!PASS_MARKER_RE.test(body) && !fails.includes('c')) fails.push('c');
175
- }
176
-
177
- const at = taskRegistry[taskPath]?.completed_at;
178
- if (
179
- typeof at !== 'string' ||
180
- !/^\d{4}-\d{2}-\d{2}T/.test(at) ||
181
- Number.isNaN(Date.parse(at))
182
- ) fails.push('d');
183
- return fails;
184
- }
185
-
205
+ const featureDir = path.join(specsPath, featureName);
186
206
  const failures = allDoneTasks
187
- .map((tp) => ({ taskPath: tp, fails: checkReceipt(tp) }))
207
+ .map((taskPath) => ({
208
+ taskPath,
209
+ fails: RECEIPT.checkTaskReceipt(featureDir, taskPath, taskRegistry[taskPath], runtimeContext, POLICY).failures,
210
+ }))
188
211
  .filter((f) => f.fails.length > 0);
189
212
 
213
+ const featureCloseoutRequired = explicitCloseout && allDoneTasks.length > 0;
214
+ const featureReceipt = featureCloseoutRequired
215
+ ? RECEIPT.checkFeatureReceipt(featureDir, runtimeContext, POLICY)
216
+ : null;
217
+ if (featureReceipt && featureReceipt.failures.length) {
218
+ failures.push({ taskPath: 'feature-receipt.md', fails: featureReceipt.failures });
219
+ }
220
+ const completion = featureCloseoutRequired && featureReceipt?.failures.length === 0
221
+ && Object.prototype.hasOwnProperty.call(activeSpec, 'workflow_policy')
222
+ ? POLICY.completionDecisionForSpec(activeSpec, {
223
+ runtimeContext,
224
+ executionReceipt: featureReceipt.body,
225
+ taskContext: {},
226
+ })
227
+ : null;
228
+
190
229
  // Always persist status transitions, including done → pending. Keep
191
230
  // failing done tasks at their previous cached status so a stale valid
192
231
  // entry cannot mask a mutated receipt on the next Stop; with
@@ -202,28 +241,31 @@ try {
202
241
  fs.writeFileSync(cacheFile, JSON.stringify(cache));
203
242
  } catch { /* fail-open */ }
204
243
 
205
- if (allDoneTasks.length === 0) process.exit(0);
206
- if (failures.length === 0) process.exit(0);
244
+ const completionBlocked = completion && completion.completion !== 'complete' && completion.completion !== 'not_applicable';
245
+ if (failures.length === 0 && !completionBlocked) process.exit(0);
207
246
 
208
247
  const lines = [
209
- `⚠️ Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`,
248
+ failures.length > 0
249
+ ? `⚠️ Completion gate: ${failures.length} done task(s) lack a verification receipt.`
250
+ : '⚠️ Completion gate: workflow completion proof is incomplete.',
210
251
  ];
211
252
  for (const { taskPath, fails } of failures) {
212
253
  lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(', ')}`);
213
- lines.push(
214
- ` Fix: add \`Verification: PASS\` plus commands and successful outcomes to \`## Evidence\` in \`specs/${featureName}/${taskPath}\`, then re-sync spec.json`
215
- );
254
+ lines.push(taskPath === 'feature-receipt.md'
255
+ ? ` Fix: run final integration proof, then write \`specs/${featureName}/feature-receipt.md\`.`
256
+ : ` Fix: write canonical proof to \`specs/${featureName}/receipts/${path.posix.basename(taskPath)}\`; legacy \`## Evidence\` remains read-compatible.`);
257
+ }
258
+ if (completionBlocked) {
259
+ lines.push(`- Completion decision unfinished: ${completion.blocker || 'required workflow proof is missing.'}`);
260
+ if (Array.isArray(completion.missingProof) && completion.missingProof.length > 0) {
261
+ lines.push(` Missing proof: ${completion.missingProof.join(', ')}`);
262
+ }
216
263
  }
217
264
  process.stdout.write(JSON.stringify({ decision: 'block', reason: lines.slice(0, 8).join('\n') }) + '\n');
218
265
  process.exit(0);
219
266
 
220
267
  } catch (e) {
221
- try {
222
- const fs = require('fs'), p = require('path');
223
- const d = p.join(__dirname, '.logs');
224
- if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
225
- fs.appendFileSync(p.join(d, 'hook-log.jsonl'),
226
- JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-gate', status: 'crash', error: e.message }) + '\n');
227
- } catch (_) {}
268
+ logCrash(e);
269
+ emitBlock(`Completion gate controlled failure: ${e.message}. Completion is blocked until the hook is repaired.`);
228
270
  process.exit(0);
229
271
  }
@@ -8,14 +8,42 @@
8
8
  * Scans for an active spec in progress and dynamically injects
9
9
  * the State Sync (Tollgate) rule into the agent's context.
10
10
  *
11
- * Exit: 0 always (fail-open)
11
+ * Exit: 0 always; unavailable policy is surfaced as a controlled failure.
12
12
  */
13
13
 
14
- try {
15
- const fs = require('fs');
16
- const path = require('path');
17
- const POLICY = require(path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs'));
14
+ const fs = require('fs');
15
+ const path = require('path');
18
16
 
17
+ function logCrash(error) {
18
+ try {
19
+ const d = path.join(__dirname, '.logs');
20
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
21
+ fs.appendFileSync(
22
+ path.join(d, 'hook-log.jsonl'),
23
+ JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-state', status: 'crash', error: error.message }) + '\n'
24
+ );
25
+ } catch (_) {}
26
+ }
27
+
28
+ function emitControlledFailure(reason) {
29
+ console.log(`\n> ⚠️ Spec tollgate unavailable: ${reason}. Repair the installed workflow policy before continuing.\n`);
30
+ }
31
+
32
+ function sessionIdentity(payload) {
33
+ const candidates = [
34
+ payload && payload.session_id,
35
+ payload && payload.sessionId,
36
+ payload && payload.sessionID,
37
+ payload && payload.session && payload.session.id,
38
+ ];
39
+ return candidates.find((value) => typeof value === 'string' && value.length > 0) || null;
40
+ }
41
+
42
+ function canonicalPath(value) {
43
+ try { return fs.realpathSync(value); } catch { return null; }
44
+ }
45
+
46
+ try {
19
47
  // ── Main ──────────────────────────────────────────────────────────────────
20
48
 
21
49
  const stdin = fs.readFileSync(0, 'utf8').trim();
@@ -24,6 +52,20 @@ try {
24
52
  const payload = JSON.parse(stdin);
25
53
  const cwd = payload.cwd || process.cwd();
26
54
 
55
+ let POLICY;
56
+ let RESOLVER;
57
+ let RECEIPT;
58
+ try {
59
+ POLICY = require(path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs'));
60
+ RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
61
+ RECEIPT = require(path.join(__dirname, '..', 'scripts', 'spec-receipt.cjs'));
62
+ if (typeof POLICY.flashState !== 'function') throw new Error('shared workflow policy has no flashState function');
63
+ } catch (error) {
64
+ logCrash(error);
65
+ emitControlledFailure(error.message);
66
+ process.exit(0);
67
+ }
68
+
27
69
  // Read runtime configuration if exists
28
70
  let runtime = {};
29
71
  try {
@@ -36,37 +78,47 @@ try {
36
78
  if (runtime.spec && runtime.spec.tollgate === false) process.exit(0);
37
79
 
38
80
  const baseDir = process.env.PROJECT_ROOT || cwd;
39
- const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
81
+ const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
82
+ const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
83
+ const resolved = RESOLVER.resolveActiveSpec({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
40
84
 
41
- if (!fs.existsSync(specsPath)) {
85
+ if (!resolved) {
86
+ process.exit(0); // No active spec, do nothing
87
+ }
88
+
89
+ if (resolved.error === 'multiple_active') {
90
+ const candidates = resolved.candidates.join(', ');
91
+ console.log(`\n> ⚠️ Multiple active specs detected: ${candidates}. Provide explicit feature target or resolve ambiguity. Tollgate paused.\n`);
92
+ process.exit(0);
93
+ }
94
+
95
+ if (resolved.error === 'invalid_specs') {
96
+ const cands = resolved.candidates.join(', ');
97
+ console.log(`\n> ⚠️ Invalid spec JSON detected: ${cands}. ${resolved.reason}. Fix or remove malformed spec.json before continuing. Tollgate paused.\n`);
42
98
  process.exit(0);
43
99
  }
44
100
 
45
- // Find the active spec
46
- let activeSpec = null;
47
- let featureName = null;
48
-
49
- const entries = fs.readdirSync(specsPath, { withFileTypes: true });
50
- for (const entry of entries) {
51
- if (entry.isDirectory()) {
52
- const specFile = path.join(specsPath, entry.name, 'spec.json');
53
- if (fs.existsSync(specFile)) {
54
- try {
55
- const specData = JSON.parse(fs.readFileSync(specFile, 'utf8'));
56
- if (specData.status === 'in_progress' || specData.status === 'in-progress') {
57
- activeSpec = specData;
58
- featureName = entry.name;
59
- break; // take the first active one
60
- }
61
- } catch { /* skip bad JSON */ }
62
- }
63
- }
101
+ if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
102
+ const detail = resolved.reason || resolved.explicitFeature || resolved.explicitPath || 'unknown';
103
+ console.log(`\n> ⚠️ Explicit spec target invalid (${resolved.error}): ${detail}. Provide a valid feature target inside configured specs root. Tollgate paused.\n`);
104
+ process.exit(0);
64
105
  }
65
106
 
66
- if (!activeSpec) {
67
- process.exit(0); // No active spec, do nothing
107
+ if (resolved.error) {
108
+ process.exit(0);
68
109
  }
69
110
 
111
+ const activeSpec = resolved.spec;
112
+ const featureName = resolved.featureName;
113
+ const specsPath = resolved.specsDir;
114
+ const runtimeContext = POLICY.deriveRuntimeContext({
115
+ projectRoot: baseDir,
116
+ specsRoot: specsPath,
117
+ specFile: resolved.specFile || path.join(specsPath, featureName, 'spec.json'),
118
+ featureName,
119
+ runtimeSession: sessionIdentity(payload),
120
+ });
121
+
70
122
  const phase = activeSpec.current_phase || activeSpec.phase || 'unknown';
71
123
  const taskRegistry = activeSpec.task_registry || {};
72
124
  const flashTasks = POLICY.flashState(taskRegistry);
@@ -82,27 +134,44 @@ try {
82
134
  const deps = Array.isArray(task?.dependencies) ? task.dependencies : [];
83
135
  return status === 'pending' && deps.every((dep) => taskStatusByPath.get(dep) === 'done');
84
136
  });
137
+ const featureDir = path.join(specsPath, featureName);
138
+ const featureReceiptPresent = RECEIPT.safeRead(featureDir, 'feature-receipt.md').status === 'ok';
85
139
 
86
140
  // ── State-change gate: only emit the full tollgate when spec state changed ──
87
- // This hook runs on EVERY UserPromptSubmit. Re-printing the whole block when
88
- // phase + done/total are unchanged is wasted context (~460 tok/turn). We keep
89
- // a tiny state fingerprint in a temp file: same -> one-line reminder; changed
90
- // -> full block (and refresh the fingerprint). Fail-open at every step.
91
- const stateKey = `${phase}|${taskCounts.done || 0}/${taskEntries.length}`;
141
+ // The cache is shared by installed hook copies, so its key must carry the
142
+ // canonical project, complete session identity, and canonical spec identity.
143
+ // If any identity is unavailable, skip caching and emit the full block.
144
+ const stateKey = JSON.stringify({
145
+ projectRoot: runtimeContext.project_root,
146
+ specsRoot: runtimeContext.specs_root,
147
+ specFile: runtimeContext.spec_file,
148
+ featureName: runtimeContext.feature_name,
149
+ runtimeSession: runtimeContext.runtime_session,
150
+ provenanceMode: runtimeContext.provenance_mode,
151
+ Base: runtimeContext.base,
152
+ Head: runtimeContext.head,
153
+ contextId: runtimeContext.context_id,
154
+ phase,
155
+ done: taskCounts.done || 0,
156
+ total: taskEntries.length,
157
+ featureReceiptPresent,
158
+ });
92
159
  const cacheFile = path.join(__dirname, '.logs', 'tollgate-last.txt');
93
160
 
94
161
  let lastKey = '';
95
- try { lastKey = fs.readFileSync(cacheFile, 'utf8').trim(); } catch { /* first run */ }
162
+ if (stateKey) {
163
+ try { lastKey = fs.readFileSync(cacheFile, 'utf8'); } catch { /* first run */ }
164
+ }
96
165
 
97
- if (lastKey === stateKey) {
166
+ if (stateKey && lastKey === stateKey) {
98
167
  console.log(`\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${taskCounts.done || 0}/${taskEntries.length} tasks done). Tollgate active — sync \`spec.json\` when state changes.\n`);
99
168
  process.exit(0);
100
169
  }
101
170
 
102
171
  try {
103
172
  fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
104
- fs.writeFileSync(cacheFile, stateKey);
105
- } catch { /* fail-open: if the write fails we still print the full block */ }
173
+ if (stateKey) fs.writeFileSync(cacheFile, stateKey);
174
+ } catch { /* cache failure only disables reminder compression */ }
106
175
 
107
176
  // Compact state-change block (enforcement lives on Stop via spec-gate.cjs).
108
177
  const lines = [];
@@ -116,22 +185,18 @@ try {
116
185
  lines.push(`- Next unblocked: \`${nextUnblocked[0]}\``);
117
186
  }
118
187
  if (flashTasks.length > 0) {
119
- lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. PASS promotion keeps task in_progress until explicit sync-finalize.`);
188
+ lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. A PASS proof keeps the persisted task in_progress until explicit sync-finalize.`);
120
189
  }
121
- lines.push(`- Sync \`spec.json\` + task file after verified work; run \`node .claude/scripts/validate-spec-output.cjs specs/${featureName}\` before \`ready_for_implementation=true\`.`);
122
- lines.push(`- A completion gate verifies receipts when you end a turn with newly-done tasks.`);
190
+ lines.push(`- Sync \`spec.json\` + task Markdown status after verified work; task proof belongs in \`receipts/<task-basename>.md\`.`);
191
+ lines.push(`- Create \`feature-receipt.md\` once after final integration proof${featureReceiptPresent ? ' (present)' : ' (not required before closeout)'}.`);
192
+ lines.push(`- Validate with \`node .claude/scripts/validate-spec-output.cjs specs/${featureName}\`; hooks revalidate receipt bytes but never grant approval.`);
123
193
  lines.push('');
124
194
 
125
195
  console.log(lines.join('\n'));
126
196
  process.exit(0);
127
197
 
128
198
  } catch (e) {
129
- try {
130
- const fs = require('fs'), p = require('path');
131
- const d = p.join(__dirname, '.logs');
132
- if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
133
- fs.appendFileSync(p.join(d, 'hook-log.jsonl'),
134
- JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-state', status: 'crash', error: e.message }) + '\n');
135
- } catch (_) {}
199
+ logCrash(e);
200
+ emitControlledFailure(e.message);
136
201
  process.exit(0);
137
202
  }
@@ -61,7 +61,14 @@
61
61
  "generate-skill-catalog.cjs",
62
62
  "validate-spec-output.cjs",
63
63
  "scan-staged-secrets.cjs",
64
- "workflow-policy.cjs"
64
+ "workflow-policy.cjs",
65
+ "spec-resolver.cjs",
66
+ "spec-receipt.cjs",
67
+ "spec-ground.cjs",
68
+ "spec-semantic-model.cjs",
69
+ "spec-final-state.cjs",
70
+ "spec-readiness.cjs",
71
+ "provenance.cjs"
65
72
  ]
66
73
  },
67
74
  "agentReferences": {
@@ -102,6 +109,10 @@
102
109
  "hooks/rules.cjs",
103
110
  "hooks/spec-state.cjs",
104
111
  "hooks/spec-gate.cjs",
112
+ "hooks/completion-authority.cjs",
113
+ "hooks/completion-authority-check.cjs",
114
+ "hooks/completion-authority-state.cjs",
115
+ "hooks/semantic-review-authority.cjs",
105
116
  "hooks/state.cjs",
106
117
  "hooks/lib/color.cjs",
107
118
  "hooks/lib/parser.cjs",
@@ -109,7 +120,9 @@
109
120
  "hooks/lib/git.cjs",
110
121
  "hooks/lib/config.cjs",
111
122
  "hooks/lib/context.cjs",
112
- "hooks/lib/detect.cjs"
123
+ "hooks/lib/detect.cjs",
124
+ "hooks/lib/privacy-command-analysis.cjs",
125
+ "hooks/lib/runtime-path-safety.cjs"
113
126
  ]
114
127
  },
115
128
  "settings": {