@haposoft/cafekit 0.16.0-rc.1 → 0.16.0-rc.3

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 (51) hide show
  1. package/bin/__tests__/codex-hooks.test.js +355 -12
  2. package/bin/__tests__/codex-native.test.js +1 -1
  3. package/bin/__tests__/develop-contract.test.js +1374 -78
  4. package/bin/__tests__/installer-safety.test.js +43 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +791 -0
  6. package/bin/__tests__/package-inventory.test.js +157 -0
  7. package/bin/__tests__/validator-grounding.test.js +707 -2
  8. package/package.json +1 -1
  9. package/src/claude/agents/code-auditor.md +8 -1
  10. package/src/claude/agents/spec-maker.md +107 -200
  11. package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
  12. package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
  13. package/src/claude/hooks/completion-authority-check.cjs +307 -0
  14. package/src/claude/hooks/completion-authority-state.cjs +525 -0
  15. package/src/claude/hooks/completion-authority.cjs +148 -0
  16. package/src/claude/hooks/session.cjs +33 -19
  17. package/src/claude/hooks/spec-gate.cjs +173 -61
  18. package/src/claude/hooks/spec-state.cjs +104 -45
  19. package/src/claude/migration-manifest.json +6 -1
  20. package/src/claude/scripts/provenance.cjs +315 -0
  21. package/src/claude/scripts/spec-resolver.cjs +450 -0
  22. package/src/claude/scripts/spec-scaffold.cjs +716 -41
  23. package/src/claude/scripts/validate-spec-output.cjs +355 -53
  24. package/src/claude/scripts/workflow-policy.cjs +1529 -162
  25. package/src/claude/settings/settings.json +8 -0
  26. package/src/claude/skills/backend-development/references/backend-security.md +52 -0
  27. package/src/claude/skills/code-review/SKILL.md +85 -66
  28. package/src/claude/skills/develop/SKILL.md +153 -285
  29. package/src/claude/skills/develop/references/quality-gate.md +112 -124
  30. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  31. package/src/claude/skills/specs/SKILL.md +170 -381
  32. package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
  33. package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
  34. package/src/claude/skills/specs/templates/spec-state.json +23 -2
  35. package/src/claude/skills/specs/templates/task.md +20 -0
  36. package/src/claude/skills/sync/SKILL.md +15 -8
  37. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  38. package/src/claude/skills/test/SKILL.md +141 -167
  39. package/src/codex/hooks/completion-authority-check.cjs +260 -0
  40. package/src/codex/hooks/completion-authority-state.cjs +525 -0
  41. package/src/codex/hooks/completion-authority.cjs +100 -0
  42. package/src/codex/hooks/lib/hook-context.cjs +42 -2
  43. package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
  44. package/src/codex/hooks/lib/spec-utils.cjs +53 -27
  45. package/src/codex/hooks/session.cjs +2 -0
  46. package/src/codex/hooks/spec-gate.cjs +132 -20
  47. package/src/codex/hooks/spec-state.cjs +56 -10
  48. package/src/codex/hooks.json +10 -0
  49. package/src/opencode/AGENTS.md +4 -2
  50. package/src/opencode/plugins/spec-gate.ts +296 -122
  51. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -18,10 +18,10 @@ try {
18
18
  const { execSync } = require('child_process');
19
19
 
20
20
  // ── Utilities ─────────────────────────────────────────────────────────────
21
- function run(cmd, fallback = '') {
21
+ function run(cmd, fallback = '', cwd = process.cwd()) {
22
22
  try {
23
23
  return execSync(cmd, {
24
- encoding: 'utf8', timeout: 3000,
24
+ cwd, encoding: 'utf8', timeout: 3000,
25
25
  stdio: ['pipe', 'pipe', 'pipe']
26
26
  }).trim();
27
27
  } catch { return fallback; }
@@ -40,6 +40,21 @@ try {
40
40
  } catch { /* fail-open */ }
41
41
  }
42
42
 
43
+ function projectRoot() {
44
+ const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
45
+ ? process.env.CLAUDE_PROJECT_DIR.trim()
46
+ : '';
47
+ if (configured) {
48
+ try { return fs.realpathSync(path.resolve(configured)); } catch { /* continue */ }
49
+ }
50
+
51
+ const installedRoot = path.resolve(__dirname, '..', '..');
52
+ const installedHook = path.join(installedRoot, '.claude', 'hooks', path.basename(__filename));
53
+ if (fs.existsSync(installedHook)) return installedRoot;
54
+
55
+ try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
56
+ }
57
+
43
58
  /** Read .claude/runtime.json config */
44
59
  function readRuntime(cwd) {
45
60
  try {
@@ -50,8 +65,7 @@ try {
50
65
 
51
66
  // ── Project Detection ──────────────────────────────────────────────────────
52
67
 
53
- function detectProjectType() {
54
- const cwd = process.cwd();
68
+ function detectProjectType(cwd) {
55
69
  if (fs.existsSync(path.join(cwd, 'pnpm-workspace.yaml')) ||
56
70
  fs.existsSync(path.join(cwd, 'lerna.json'))) return 'monorepo';
57
71
  try {
@@ -62,8 +76,7 @@ try {
62
76
  return 'app';
63
77
  }
64
78
 
65
- function detectPackageManager() {
66
- const cwd = process.cwd();
79
+ function detectPackageManager(cwd) {
67
80
  if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm';
68
81
  if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn';
69
82
  if (fs.existsSync(path.join(cwd, 'bun.lockb'))) return 'bun';
@@ -71,10 +84,10 @@ try {
71
84
  return '';
72
85
  }
73
86
 
74
- function detectFramework() {
87
+ function detectFramework(cwd) {
75
88
  try {
76
89
  const pkg = JSON.parse(
77
- fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')
90
+ fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')
78
91
  );
79
92
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
80
93
  if (deps['next']) return 'next';
@@ -185,31 +198,32 @@ try {
185
198
  const payload = stdin ? JSON.parse(stdin) : {};
186
199
  const source = payload.source || 'unknown';
187
200
  const envFile = process.env.CLAUDE_ENV_FILE;
188
- const cwd = process.cwd();
201
+ const cwd = projectRoot();
189
202
  const runtime = readRuntime(cwd);
203
+ try { require('./completion-authority-state.cjs').clearState(cwd, payload.session_id); } catch { /* old installs remain fail-open */ }
190
204
 
191
205
  // Check CafeKit update in parallel with project detection (async, fail-open).
192
206
  const updateCheckPromise = checkCafeKitUpdate(cwd);
193
207
 
194
208
  // Project detection
195
209
  const projectType = runtime.project?.type !== 'auto'
196
- ? (runtime.project?.type || detectProjectType())
197
- : detectProjectType();
210
+ ? (runtime.project?.type || detectProjectType(cwd))
211
+ : detectProjectType(cwd);
198
212
 
199
213
  const packageManager = runtime.project?.packageManager !== 'auto'
200
- ? (runtime.project?.packageManager || detectPackageManager())
201
- : detectPackageManager();
214
+ ? (runtime.project?.packageManager || detectPackageManager(cwd))
215
+ : detectPackageManager(cwd);
202
216
 
203
217
  const framework = runtime.project?.framework !== 'auto'
204
- ? (runtime.project?.framework || detectFramework())
205
- : detectFramework();
218
+ ? (runtime.project?.framework || detectFramework(cwd))
219
+ : detectFramework(cwd);
206
220
 
207
221
  // Static environment
208
- const gitBranch = run('git branch --show-current');
209
- const gitUrl = run('git remote get-url origin');
210
- const gitRoot = run('git rev-parse --show-toplevel');
222
+ const gitBranch = run('git branch --show-current', '', cwd);
223
+ const gitUrl = run('git remote get-url origin', '', cwd);
224
+ const gitRoot = run('git rev-parse --show-toplevel', '', cwd);
211
225
  const nodeVer = process.version;
212
- const pythonVer = run('python3 --version') || run('python --version');
226
+ const pythonVer = run('python3 --version', '', cwd) || run('python --version', '', cwd);
213
227
  const user = process.env.USERNAME || process.env.USER
214
228
  || process.env.LOGNAME || os.userInfo().username;
215
229
  const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -8,56 +8,147 @@
8
8
  * Blocks turn end when *newly-done* tasks lack 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
+ try {
87
+ POLICY = require(policyPath);
88
+ RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
89
+ if (typeof POLICY.validateCanonicalReceipt !== 'function'
90
+ || typeof POLICY.completionDecisionForSpec !== 'function') {
91
+ throw new Error('shared workflow policy lacks completion authority functions');
92
+ }
93
+ } catch (error) {
94
+ logCrash(error);
95
+ emitBlock(`Completion gate unavailable: shared workflow policy could not be loaded (${error.message}). Repair ${policyPath} before completing tasks.`);
96
+ process.exit(0);
97
+ }
98
+
99
+ const cwd = projectRoot(payload);
100
+
31
101
  // Missing/malformed runtime.json keeps the gate ON (fail-closed, valve 3 style).
32
102
  let runtime = {};
33
103
  try {
34
104
  const rp = path.join(cwd, '.claude', 'runtime.json');
35
105
  if (fs.existsSync(rp)) runtime = JSON.parse(fs.readFileSync(rp, 'utf8'));
36
106
  } 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 */ }
107
+ // Active-spec discovery via shared resolver — explicit target if present, else fail on ambiguity.
108
+ const baseDir = cwd;
109
+ const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
110
+ const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
111
+ const resolved = RESOLVER.resolveActiveSpec({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
112
+ if (!resolved) process.exit(0);
113
+ if (resolved.error === 'multiple_active') {
114
+ process.stdout.write(JSON.stringify({
115
+ decision: 'block',
116
+ reason: `Completion gate: multiple active specs detected (${resolved.candidates.join(', ')}). Provide explicit feature target or resolve ambiguity before completing tasks. Candidates: ${resolved.candidates.join(', ')}`
117
+ }) + '\n');
118
+ process.exit(0);
58
119
  }
59
- if (!activeSpec) process.exit(0);
120
+ if (resolved.error === 'invalid_specs') {
121
+ process.stdout.write(JSON.stringify({
122
+ decision: 'block',
123
+ reason: `Completion gate: invalid spec JSON detected (${resolved.candidates.join(', ')}): ${resolved.reason}. Fix or remove malformed spec.json before completing tasks.`
124
+ }) + '\n');
125
+ process.exit(0);
126
+ }
127
+ if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
128
+ process.stdout.write(JSON.stringify({
129
+ decision: 'block',
130
+ 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.`
131
+ }) + '\n');
132
+ process.exit(0);
133
+ }
134
+ if (resolved.error) process.exit(0);
135
+
136
+ const activeSpec = resolved.spec;
137
+ const featureName = resolved.featureName;
138
+ const specsPath = resolved.specsDir;
139
+ const runtimeContext = POLICY.deriveRuntimeContext({
140
+ projectRoot: baseDir,
141
+ specsRoot: specsPath,
142
+ specFile: resolved.specFile || path.join(specsPath, featureName, 'spec.json'),
143
+ featureName,
144
+ runtimeSession: sessionIdentity(payload),
145
+ });
60
146
 
147
+ const configuredGate = runtime.spec?.completion_gate;
148
+ if (configuredGate !== undefined && configuredGate !== true) {
149
+ 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.');
150
+ process.exit(0);
151
+ }
61
152
  const taskRegistry = activeSpec.task_registry || {};
62
153
  const cacheFile = path.join(__dirname, '.logs', 'spec-gate-last.json');
63
154
  const cacheExists = fs.existsSync(cacheFile);
@@ -90,12 +181,12 @@ try {
90
181
  const allDoneTasks = Object.keys(taskRegistry).filter((tp) =>
91
182
  (taskRegistry[tp]?.status || 'pending') === 'done'
92
183
  );
184
+ const receiptBodies = new Map();
93
185
  // /m so ^ matches line starts (Evidence is never at byte 0 of the file).
94
186
  // legacy heading aliases: read-compat only, no longer advertised
95
187
  const EVID_RE = /^#{2,3}\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/m;
96
188
  const PASS_MARKER_RE = /^\s*Verification:\s*PASS\s*$/m;
97
189
  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
190
 
100
191
  /** Body of first Evidence heading until next same-or-higher heading. */
101
192
  function evidenceBody(text) {
@@ -109,32 +200,33 @@ try {
109
200
  let end = lines.length;
110
201
  for (let i = start; i < lines.length; i++) {
111
202
  const hm = lines[i].match(/^(#{1,6})\s+/);
112
- if (hm && hm[1].length <= level) { end = i; break; }
203
+ const isTapMetadata = typeof POLICY.isTapMetadataHeading === 'function'
204
+ && POLICY.isTapMetadataHeading(lines[i]);
205
+ if (hm && hm[1].length <= level && !isTapMetadata) { end = i; break; }
113
206
  }
114
207
  return lines.slice(start, end).join('\n');
115
208
  }
116
209
 
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;
210
+ function validateCanonicalReceipt(body, options) {
211
+ return POLICY.validateCanonicalReceipt(body, options);
129
212
  }
130
213
 
131
214
  // Receipt checks a–h → failed letter list.
132
215
  // 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
216
  function safeTaskFile(featureDir, taskPath) {
217
+ const resolvedFeature = path.resolve(featureDir);
134
218
  const target = path.resolve(featureDir, taskPath);
135
- const relative = path.relative(featureDir, target);
136
- // sibling-prefix safe: must not be .., ../..., or absolute (cross-platform)
219
+ const relative = path.relative(resolvedFeature, target);
137
220
  if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
221
+ if (!fs.existsSync(target)) return null;
222
+ try {
223
+ const canonicalFeature = fs.realpathSync(resolvedFeature);
224
+ const canonicalTarget = fs.realpathSync(target);
225
+ const rel = path.relative(canonicalFeature, canonicalTarget);
226
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
227
+ } catch {
228
+ return null;
229
+ }
138
230
  return target;
139
231
  }
140
232
 
@@ -150,21 +242,28 @@ try {
150
242
  if (!statusLine || !/\bdone\b/i.test(statusLine)) fails.push('a');
151
243
 
152
244
  const body = evidenceBody(text);
245
+ if (body !== null) receiptBodies.set(taskPath, body);
153
246
  if (body === null || !EVID_RE.test(text)) {
154
247
  fails.push('b');
155
248
  } else if (
156
249
  /\{\{[^}]+\}\}/.test(body) ||
157
- EXPLICIT_FAILURE_RE.test(body) ||
158
250
  !(PASS_MARKER_RE.test(body) || LEGACY_SUCCESS_RE.test(body))
159
251
  ) {
160
252
  fails.push('c');
161
253
  } 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' };
254
+ // Canonical receipt checks for done tasks — delegated to shared validator (single authority)
255
+ const canonicalFails = validateCanonicalReceipt(body, POLICY.receiptValidatorOptions(taskRegistry[taskPath], {
256
+ runtimeContext,
257
+ requireProvenanceBinding: true,
258
+ }));
259
+ const map = { verification_state: 'c', command: 'e', exit_result: 'f', provenance: 'g', artifact_hash: 'h', artifact_declaration: 'h', placeholder: 'c', shared_validator: 'c', validator_unavailable: 'c' };
165
260
  for (const cf of canonicalFails) {
166
- if (cf === 'verification_state') {
167
- if (!PASS_MARKER_RE.test(body) && !fails.includes('c')) fails.push('c');
261
+ if (cf === 'verification_state' || cf === 'shared_validator' || cf === 'validator_unavailable') {
262
+ if (!fails.includes('c')) fails.push('c');
263
+ continue;
264
+ }
265
+ if (cf === 'placeholder') {
266
+ if (!fails.includes('c')) fails.push('c');
168
267
  continue;
169
268
  }
170
269
  const letter = map[cf];
@@ -187,6 +286,15 @@ try {
187
286
  .map((tp) => ({ taskPath: tp, fails: checkReceipt(tp) }))
188
287
  .filter((f) => f.fails.length > 0);
189
288
 
289
+ const firstReceiptTask = allDoneTasks.find((taskPath) => receiptBodies.has(taskPath));
290
+ const completion = allDoneTasks.length > 0 && Object.prototype.hasOwnProperty.call(activeSpec, 'workflow_policy')
291
+ ? POLICY.completionDecisionForSpec(activeSpec, {
292
+ runtimeContext,
293
+ executionReceipt: firstReceiptTask ? receiptBodies.get(firstReceiptTask) : null,
294
+ taskContext: firstReceiptTask ? taskRegistry[firstReceiptTask] : null,
295
+ })
296
+ : null;
297
+
190
298
  // Always persist status transitions, including done → pending. Keep
191
299
  // failing done tasks at their previous cached status so a stale valid
192
300
  // entry cannot mask a mutated receipt on the next Stop; with
@@ -203,10 +311,13 @@ try {
203
311
  } catch { /* fail-open */ }
204
312
 
205
313
  if (allDoneTasks.length === 0) process.exit(0);
206
- if (failures.length === 0) process.exit(0);
314
+ const completionBlocked = completion && completion.completion !== 'complete' && completion.completion !== 'not_applicable';
315
+ if (failures.length === 0 && !completionBlocked) process.exit(0);
207
316
 
208
317
  const lines = [
209
- `⚠️ Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`,
318
+ failures.length > 0
319
+ ? `⚠️ Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`
320
+ : '⚠️ Completion gate: workflow completion proof is incomplete.',
210
321
  ];
211
322
  for (const { taskPath, fails } of failures) {
212
323
  lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(', ')}`);
@@ -214,16 +325,17 @@ try {
214
325
  ` Fix: add \`Verification: PASS\` plus commands and successful outcomes to \`## Evidence\` in \`specs/${featureName}/${taskPath}\`, then re-sync spec.json`
215
326
  );
216
327
  }
328
+ if (completionBlocked) {
329
+ lines.push(`- Completion decision unfinished: ${completion.blocker || 'required workflow proof is missing.'}`);
330
+ if (Array.isArray(completion.missingProof) && completion.missingProof.length > 0) {
331
+ lines.push(` Missing proof: ${completion.missingProof.join(', ')}`);
332
+ }
333
+ }
217
334
  process.stdout.write(JSON.stringify({ decision: 'block', reason: lines.slice(0, 8).join('\n') }) + '\n');
218
335
  process.exit(0);
219
336
 
220
337
  } 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 (_) {}
338
+ logCrash(e);
339
+ emitBlock(`Completion gate controlled failure: ${e.message}. Completion is blocked until the hook is repaired.`);
228
340
  process.exit(0);
229
341
  }
@@ -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,18 @@ 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
+ try {
58
+ POLICY = require(path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs'));
59
+ RESOLVER = require(path.join(__dirname, '..', 'scripts', 'spec-resolver.cjs'));
60
+ if (typeof POLICY.flashState !== 'function') throw new Error('shared workflow policy has no flashState function');
61
+ } catch (error) {
62
+ logCrash(error);
63
+ emitControlledFailure(error.message);
64
+ process.exit(0);
65
+ }
66
+
27
67
  // Read runtime configuration if exists
28
68
  let runtime = {};
29
69
  try {
@@ -36,37 +76,47 @@ try {
36
76
  if (runtime.spec && runtime.spec.tollgate === false) process.exit(0);
37
77
 
38
78
  const baseDir = process.env.PROJECT_ROOT || cwd;
39
- const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
79
+ const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
80
+ const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
81
+ const resolved = RESOLVER.resolveActiveSpec({ projectRoot: baseDir, runtime, explicitFeature, explicitPath });
40
82
 
41
- if (!fs.existsSync(specsPath)) {
83
+ if (!resolved) {
84
+ process.exit(0); // No active spec, do nothing
85
+ }
86
+
87
+ if (resolved.error === 'multiple_active') {
88
+ const candidates = resolved.candidates.join(', ');
89
+ console.log(`\n> ⚠️ Multiple active specs detected: ${candidates}. Provide explicit feature target or resolve ambiguity. Tollgate paused.\n`);
90
+ process.exit(0);
91
+ }
92
+
93
+ if (resolved.error === 'invalid_specs') {
94
+ const cands = resolved.candidates.join(', ');
95
+ console.log(`\n> ⚠️ Invalid spec JSON detected: ${cands}. ${resolved.reason}. Fix or remove malformed spec.json before continuing. Tollgate paused.\n`);
42
96
  process.exit(0);
43
97
  }
44
98
 
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
- }
99
+ if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
100
+ const detail = resolved.reason || resolved.explicitFeature || resolved.explicitPath || 'unknown';
101
+ console.log(`\n> ⚠️ Explicit spec target invalid (${resolved.error}): ${detail}. Provide a valid feature target inside configured specs root. Tollgate paused.\n`);
102
+ process.exit(0);
64
103
  }
65
104
 
66
- if (!activeSpec) {
67
- process.exit(0); // No active spec, do nothing
105
+ if (resolved.error) {
106
+ process.exit(0);
68
107
  }
69
108
 
109
+ const activeSpec = resolved.spec;
110
+ const featureName = resolved.featureName;
111
+ const specsPath = resolved.specsDir;
112
+ const runtimeContext = POLICY.deriveRuntimeContext({
113
+ projectRoot: baseDir,
114
+ specsRoot: specsPath,
115
+ specFile: resolved.specFile || path.join(specsPath, featureName, 'spec.json'),
116
+ featureName,
117
+ runtimeSession: sessionIdentity(payload),
118
+ });
119
+
70
120
  const phase = activeSpec.current_phase || activeSpec.phase || 'unknown';
71
121
  const taskRegistry = activeSpec.task_registry || {};
72
122
  const flashTasks = POLICY.flashState(taskRegistry);
@@ -84,25 +134,39 @@ try {
84
134
  });
85
135
 
86
136
  // ── 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}`;
137
+ // The cache is shared by installed hook copies, so its key must carry the
138
+ // canonical project, complete session identity, and canonical spec identity.
139
+ // If any identity is unavailable, skip caching and emit the full block.
140
+ const stateKey = JSON.stringify({
141
+ projectRoot: runtimeContext.project_root,
142
+ specsRoot: runtimeContext.specs_root,
143
+ specFile: runtimeContext.spec_file,
144
+ featureName: runtimeContext.feature_name,
145
+ runtimeSession: runtimeContext.runtime_session,
146
+ provenanceMode: runtimeContext.provenance_mode,
147
+ Base: runtimeContext.base,
148
+ Head: runtimeContext.head,
149
+ contextId: runtimeContext.context_id,
150
+ phase,
151
+ done: taskCounts.done || 0,
152
+ total: taskEntries.length,
153
+ });
92
154
  const cacheFile = path.join(__dirname, '.logs', 'tollgate-last.txt');
93
155
 
94
156
  let lastKey = '';
95
- try { lastKey = fs.readFileSync(cacheFile, 'utf8').trim(); } catch { /* first run */ }
157
+ if (stateKey) {
158
+ try { lastKey = fs.readFileSync(cacheFile, 'utf8'); } catch { /* first run */ }
159
+ }
96
160
 
97
- if (lastKey === stateKey) {
161
+ if (stateKey && lastKey === stateKey) {
98
162
  console.log(`\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${taskCounts.done || 0}/${taskEntries.length} tasks done). Tollgate active — sync \`spec.json\` when state changes.\n`);
99
163
  process.exit(0);
100
164
  }
101
165
 
102
166
  try {
103
167
  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 */ }
168
+ if (stateKey) fs.writeFileSync(cacheFile, stateKey);
169
+ } catch { /* cache failure only disables reminder compression */ }
106
170
 
107
171
  // Compact state-change block (enforcement lives on Stop via spec-gate.cjs).
108
172
  const lines = [];
@@ -116,7 +180,7 @@ try {
116
180
  lines.push(`- Next unblocked: \`${nextUnblocked[0]}\``);
117
181
  }
118
182
  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.`);
183
+ lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. A PASS proof keeps the persisted task in_progress until explicit sync-finalize.`);
120
184
  }
121
185
  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
186
  lines.push(`- A completion gate verifies receipts when you end a turn with newly-done tasks.`);
@@ -126,12 +190,7 @@ try {
126
190
  process.exit(0);
127
191
 
128
192
  } 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 (_) {}
193
+ logCrash(e);
194
+ emitControlledFailure(e.message);
136
195
  process.exit(0);
137
196
  }