@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
@@ -10,7 +10,40 @@ const EVIDENCE_NAMES = [
10
10
  ];
11
11
  const PASS_MARKER = /^\s*Verification:\s*PASS\s*$/m;
12
12
  const LEGACY_SUCCESS = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
13
- const EXPLICIT_FAILURE = /\bFAIL(?:ED|URE|URES|ING)?\b|tests?\s+failed|exit\s+code\s*[:=]?\s*[1-9]\d*|\b0\s+tests?\b/i;
13
+
14
+ // Single authority: delegate to shared workflow-policy validator. If an
15
+ // installed policy exists but is broken, do not fall back to another copy.
16
+ function loadSharedPolicy() {
17
+ const installed = path.join(__dirname, '../../scripts/workflow-policy.cjs');
18
+ const repo = path.join(__dirname, '../../../claude/scripts/workflow-policy.cjs');
19
+ const candidates = fs.existsSync(installed) ? [installed] : [repo];
20
+ let lastError = new Error('shared workflow policy is missing');
21
+ for (const candidate of candidates) {
22
+ try {
23
+ const policy = require(candidate);
24
+ if (typeof policy?.validateCanonicalReceipt !== 'function') {
25
+ throw new Error('shared workflow policy has no validateCanonicalReceipt function');
26
+ }
27
+ return { policy, error: null, path: candidate };
28
+ } catch (error) {
29
+ lastError = error;
30
+ }
31
+ }
32
+ return { policy: null, error: lastError, path: candidates[0] };
33
+ }
34
+
35
+ function getSharedPolicy() {
36
+ return loadSharedPolicy().policy;
37
+ }
38
+
39
+ function getSharedValidate() {
40
+ return loadSharedPolicy().policy?.validateCanonicalReceipt || null;
41
+ }
42
+
43
+ function isTapMetadataHeading(line) {
44
+ const policy = getSharedPolicy();
45
+ return typeof policy?.isTapMetadataHeading === 'function' && policy.isTapMetadataHeading(line);
46
+ }
14
47
 
15
48
  function evidenceBody(text) {
16
49
  const lines = text.split('\n');
@@ -28,7 +61,7 @@ function evidenceBody(text) {
28
61
  let end = lines.length;
29
62
  for (let index = start; index < lines.length; index += 1) {
30
63
  const heading = lines[index].match(/^(#{1,6})\s+/);
31
- if (heading && heading[1].length <= level) {
64
+ if (heading && heading[1].length <= level && !isTapMetadataHeading(lines[index])) {
32
65
  end = index;
33
66
  break;
34
67
  }
@@ -37,37 +70,29 @@ function evidenceBody(text) {
37
70
  }
38
71
 
39
72
  function safeTaskFile(featureDir, taskPath) {
73
+ const resolvedFeature = path.resolve(featureDir);
40
74
  const target = path.resolve(featureDir, taskPath);
41
- const relative = path.relative(featureDir, target);
75
+ const relative = path.relative(resolvedFeature, target);
42
76
  if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
77
+ if (!fs.existsSync(target)) return null;
78
+ try {
79
+ const canonicalFeature = fs.realpathSync(resolvedFeature);
80
+ const canonicalTarget = fs.realpathSync(target);
81
+ const rel = path.relative(canonicalFeature, canonicalTarget);
82
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
83
+ } catch {
84
+ return null;
85
+ }
43
86
  return target;
44
87
  }
45
88
 
46
- function validateCanonicalReceipt(body) {
47
- const failures = [];
48
- if (!/^\s*Verification:\s*PASS\s*$/m.test(body)) {
49
- failures.push('verification_state');
50
- }
51
- if (!/^\s*Command(?:\(s\))?\s*:/m.test(body)) {
52
- failures.push('command');
53
- }
54
- if (!/^\s*Exit\s*:|exit\s+code\s*[:=]|\bResult\s*:\s*PASS\b/im.test(body)) {
55
- failures.push('exit_result');
56
- }
57
- const hasBase = /^\s*Base[ \t]*:[ \t]*\S/im.test(body);
58
- const hasHead = /^\s*Head[ \t]*:[ \t]*\S/im.test(body);
59
- const hasBaseSha = /\bbase_sha[ \t]*:[ \t]*\S/im.test(body);
60
- const hasHeadSha = /\bhead_sha[ \t]*:[ \t]*\S/im.test(body);
61
- if (!((hasBase && hasHead) || (hasBaseSha && hasHeadSha))) {
62
- failures.push('provenance');
63
- }
64
- if (/\bartifact\b/i.test(body) && !/sha256:/i.test(body)) {
65
- failures.push('artifact_hash');
66
- }
67
- return failures;
89
+ function validateCanonicalReceipt(body, options = {}) {
90
+ const fn = getSharedValidate();
91
+ if (!fn) return ['shared_validator'];
92
+ return fn(body, options);
68
93
  }
69
94
 
70
- function checkReceipt(featureDir, taskPath, task) {
95
+ function checkReceipt(featureDir, taskPath, task, runtimeContext) {
71
96
  const failures = [];
72
97
  const file = safeTaskFile(featureDir, taskPath);
73
98
  if (!file || !fs.existsSync(file)) return ['a'];
@@ -83,37 +108,38 @@ function checkReceipt(featureDir, taskPath, task) {
83
108
  failures.push('b');
84
109
  } else if (
85
110
  /\{\{[^}]+\}\}/.test(body)
86
- || EXPLICIT_FAILURE.test(body)
87
111
  || !(PASS_MARKER.test(body) || LEGACY_SUCCESS.test(body))
88
112
  ) {
89
113
  failures.push('c');
90
114
  } else {
91
- // Canonical receipt requirements: command, exit/result, provenance, unambiguous PASS
92
- // For done tasks, require canonical fields. If missing, add e/f/g.
93
- const canonical = validateCanonicalReceipt(body);
94
- // Map canonical failures to letters e,f,g,h for backward compat checks
95
- // Use detailed names but also add letters for gate reporting
115
+ const policy = loadSharedPolicy().policy;
116
+ const options = typeof policy?.receiptValidatorOptions === 'function'
117
+ ? policy.receiptValidatorOptions(task, { runtimeContext, requireProvenanceBinding: true })
118
+ : { requireProvenanceBinding: true };
119
+ const canonical = validateCanonicalReceipt(body, options);
96
120
  const map = {
97
- verification_state: 'c', // already covers but keep distinct
121
+ verification_state: 'c',
98
122
  command: 'e',
99
123
  exit_result: 'f',
100
124
  provenance: 'g',
101
125
  artifact_hash: 'h',
126
+ artifact_declaration: 'h',
127
+ placeholder: 'c',
128
+ shared_validator: 'c',
129
+ validator_unavailable: 'c',
102
130
  };
103
131
  for (const fail of canonical) {
104
- const letter = map[fail];
105
- // Avoid duplicating c if already failed, but add specific
106
- if (fail === 'verification_state' && failures.includes('c')) continue;
107
- if (fail === 'verification_state') {
108
- // This should have been caught by c, but if PASS present via legacy, still need strict PASS
109
- if (!PASS_MARKER.test(body)) failures.push('c');
132
+ if (fail === 'verification_state' || fail === 'shared_validator' || fail === 'validator_unavailable') {
133
+ if (!failures.includes('c')) failures.push('c');
110
134
  continue;
111
135
  }
136
+ if (fail === 'placeholder') {
137
+ if (!failures.includes('c')) failures.push('c');
138
+ continue;
139
+ }
140
+ const letter = map[fail];
112
141
  if (letter && !failures.includes(letter)) failures.push(letter);
113
142
  }
114
- // Additional strict check: legacy success alone is not sufficient for canonical
115
- // If body contains only legacy PASS but not canonical Verification: PASS, c already would have caught if no PASS_MARKER?
116
- // But we allow legacy for backward compat in c, but canonical requires PASS_MARKER, so we must enforce.
117
143
  if (!PASS_MARKER.test(body)) {
118
144
  if (!failures.includes('c')) failures.push('c');
119
145
  }
@@ -128,4 +154,11 @@ function checkReceipt(featureDir, taskPath, task) {
128
154
  return failures;
129
155
  }
130
156
 
131
- module.exports = { checkReceipt, evidenceBody, safeTaskFile, validateCanonicalReceipt };
157
+ module.exports = {
158
+ checkReceipt,
159
+ evidenceBody,
160
+ safeTaskFile,
161
+ validateCanonicalReceipt,
162
+ loadSharedPolicy,
163
+ getSharedPolicy,
164
+ };
@@ -2,49 +2,75 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { resolveProjectPath } = require('./hook-context.cjs');
5
+
6
+ // Installed Codex hooks use .codex/scripts; source tests use the Claude-side
7
+ // source path. If an installed resolver exists but is malformed, do not fall
8
+ // back to another copy: the shared authority is unavailable.
9
+ const RESOLVER_CANDIDATES = [
10
+ path.join(__dirname, '../../scripts/spec-resolver.cjs'),
11
+ path.join(__dirname, '../../../claude/scripts/spec-resolver.cjs'),
12
+ ];
13
+
14
+ function sharedResolver() {
15
+ const candidate = RESOLVER_CANDIDATES.find((file) => fs.existsSync(file)) || RESOLVER_CANDIDATES[0];
16
+ try {
17
+ const resolver = require(candidate);
18
+ if (typeof resolver?.resolveActiveSpec !== 'function') {
19
+ throw new Error('shared spec resolver has no resolveActiveSpec function');
20
+ }
21
+ return resolver;
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ throw new Error(`${message} (${candidate})`);
25
+ }
26
+ }
6
27
 
7
28
  function specsDirectory(projectRoot, runtime) {
8
- return resolveProjectPath(
29
+ return sharedResolver().specsDirectory(projectRoot, runtime);
30
+ }
31
+
32
+ function findAllActiveSpecs(projectRoot, runtime) {
33
+ return sharedResolver().findAllActiveSpecs(projectRoot, runtime);
34
+ }
35
+
36
+ function findAllSpecCandidates(projectRoot, runtime) {
37
+ return sharedResolver().findAllSpecCandidates(projectRoot, runtime);
38
+ }
39
+
40
+ function resolveActiveSpec(projectRoot, runtime, explicitFeature, explicitPath) {
41
+ if (projectRoot && typeof projectRoot === 'object') {
42
+ return sharedResolver().resolveActiveSpec(projectRoot);
43
+ }
44
+ return sharedResolver().resolveActiveSpec({
9
45
  projectRoot,
10
- runtime.paths?.specs,
11
- 'specs'
12
- );
46
+ runtime,
47
+ explicitFeature,
48
+ explicitPath,
49
+ });
13
50
  }
14
51
 
15
52
  function findActiveSpec(projectRoot, runtime) {
16
- const specsDir = specsDirectory(projectRoot, runtime);
17
- if (!fs.existsSync(specsDir)) return null;
18
- const entries = fs.readdirSync(specsDir, { withFileTypes: true })
19
- .filter((entry) => entry.isDirectory())
20
- .sort((left, right) => left.name.localeCompare(right.name));
21
-
22
- for (const entry of entries) {
23
- const file = path.join(specsDir, entry.name, 'spec.json');
24
- if (!fs.existsSync(file)) continue;
25
- try {
26
- const spec = JSON.parse(fs.readFileSync(file, 'utf8'));
27
- if (spec.status === 'in_progress' || spec.status === 'in-progress') {
28
- return { featureName: entry.name, spec, specsDir };
29
- }
30
- } catch {
31
- // Ignore an unrelated malformed spec and continue discovery.
32
- }
33
- }
34
- return null;
53
+ const resolved = resolveActiveSpec(projectRoot, runtime);
54
+ if (!resolved) return null;
55
+ if (resolved.error === 'multiple_active' || resolved.error === 'invalid_specs') return resolved;
56
+ if (resolved.error) return null;
57
+ return resolved;
35
58
  }
36
59
 
37
60
  function taskStatusMap(spec) {
38
61
  return Object.fromEntries(
39
62
  Object.entries(spec.task_registry || {}).map(([taskPath, task]) => [
40
63
  taskPath,
41
- task?.status || 'pending'
42
- ])
64
+ task?.status || 'pending',
65
+ ]),
43
66
  );
44
67
  }
45
68
 
46
69
  module.exports = {
47
70
  findActiveSpec,
71
+ findAllActiveSpecs,
72
+ findAllSpecCandidates,
73
+ resolveActiveSpec,
48
74
  specsDirectory,
49
- taskStatusMap
75
+ taskStatusMap,
50
76
  };
@@ -10,6 +10,7 @@ const {
10
10
  readPayload
11
11
  } = require('./lib/hook-context.cjs');
12
12
  const { clearState } = require('./lib/privacy-state.cjs');
13
+ const { clearState: clearCompletionState } = require('./completion-authority-state.cjs');
13
14
 
14
15
  function readPackage(projectRoot) {
15
16
  try {
@@ -88,6 +89,7 @@ try {
88
89
 
89
90
  // Approval tokens never survive a session boundary or compaction.
90
91
  clearState(projectRoot);
92
+ clearCompletionState(projectRoot, payload.session_id);
91
93
 
92
94
  const parts = [
93
95
  projectType && `Type: ${projectType}`,
@@ -9,21 +9,93 @@ const {
9
9
  logCrash,
10
10
  readPayload
11
11
  } = require('./lib/hook-context.cjs');
12
- const { checkReceipt } = require('./lib/spec-receipt.cjs');
13
- const { findActiveSpec, taskStatusMap } = require('./lib/spec-utils.cjs');
14
- const policyPath = path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs');
15
- const POLICY = require(fs.existsSync(policyPath)
16
- ? policyPath
17
- : path.join(__dirname, '../../claude/scripts/workflow-policy.cjs'));
12
+ const { checkReceipt, evidenceBody, loadSharedPolicy } = require('./lib/spec-receipt.cjs');
13
+ const { taskStatusMap } = require('./lib/spec-utils.cjs');
14
+
15
+ function emitBlock(reason) {
16
+ process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
17
+ }
18
+
19
+ function sessionIdentity(payload) {
20
+ const candidates = [
21
+ payload && payload.session_id,
22
+ payload && payload.sessionId,
23
+ payload && payload.sessionID,
24
+ payload && payload.session && payload.session.id,
25
+ ];
26
+ return candidates.find((value) => typeof value === 'string' && value.trim() !== '') || null;
27
+ }
28
+
29
+ function readReceiptBody(featureDir, taskPath) {
30
+ const featureRoot = path.resolve(featureDir);
31
+ const target = path.resolve(featureRoot, taskPath);
32
+ const relative = path.relative(featureRoot, target);
33
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
34
+ try {
35
+ const canonicalFeature = fs.realpathSync(featureRoot);
36
+ const canonicalTarget = fs.realpathSync(target);
37
+ const canonicalRelative = path.relative(canonicalFeature, canonicalTarget);
38
+ if (canonicalRelative === '..' || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative)) return null;
39
+ return evidenceBody(fs.readFileSync(canonicalTarget, 'utf8'));
40
+ } catch (_) {
41
+ return null;
42
+ }
43
+ }
18
44
 
19
45
  try {
20
46
  const payload = readPayload();
21
- if (!payload || payload.stop_hook_active === true) process.exit(0);
47
+ if (payload.stop_hook_active === true) process.exit(0);
48
+ const loaded = loadSharedPolicy();
49
+ if (!loaded.policy) {
50
+ logCrash('spec-gate', loaded.error);
51
+ emitBlock(`Completion gate unavailable: shared workflow policy could not be loaded (${loaded.error.message}). Repair ${loaded.path} before completing tasks.`);
52
+ process.exit(0);
53
+ }
54
+ const POLICY = loaded.policy;
22
55
  const { projectRoot, runtime } = getHookContext(payload);
23
- if (runtime.spec?.completion_gate === false) process.exit(0);
24
- const active = findActiveSpec(projectRoot, runtime);
25
- if (!active) process.exit(0);
26
-
56
+ if (typeof POLICY.completionDecisionForSpec !== 'function') {
57
+ throw new Error('shared workflow policy lacks completion authority functions');
58
+ }
59
+ const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
60
+ const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
61
+ const { resolveActiveSpec } = require('./lib/spec-utils.cjs');
62
+ const resolved = resolveActiveSpec(projectRoot, runtime, explicitFeature, explicitPath);
63
+ if (!resolved) process.exit(0);
64
+ if (resolved.error === 'multiple_active') {
65
+ process.stdout.write(`${JSON.stringify({
66
+ decision: 'block',
67
+ reason: `Completion gate: multiple active specs detected (${resolved.candidates.join(', ')}). Provide explicit feature target or resolve ambiguity before completing tasks.`
68
+ })}\n`);
69
+ process.exit(0);
70
+ }
71
+ if (resolved.error === 'invalid_specs') {
72
+ process.stdout.write(`${JSON.stringify({
73
+ decision: 'block',
74
+ reason: `Completion gate: invalid spec JSON detected (${resolved.candidates.join(', ')}): ${resolved.reason}. Fix or remove malformed spec.json before completing tasks.`
75
+ })}\n`);
76
+ process.exit(0);
77
+ }
78
+ if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
79
+ process.stdout.write(`${JSON.stringify({
80
+ decision: 'block',
81
+ 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.`
82
+ })}\n`);
83
+ process.exit(0);
84
+ }
85
+ if (resolved.error) process.exit(0);
86
+ const active = resolved;
87
+ const runtimeContext = POLICY.deriveRuntimeContext({
88
+ projectRoot,
89
+ specsRoot: active.specsDir,
90
+ specFile: active.specFile || path.join(active.specsDir, active.featureName, 'spec.json'),
91
+ featureName: active.featureName,
92
+ runtimeSession: sessionIdentity(payload),
93
+ });
94
+ const configuredGate = runtime.spec?.completion_gate;
95
+ if (configuredGate !== undefined && configuredGate !== true) {
96
+ 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.');
97
+ process.exit(0);
98
+ }
27
99
  const registry = active.spec.task_registry || {};
28
100
  const currentStatuses = taskStatusMap(active.spec);
29
101
  const cacheFile = path.join(
@@ -42,7 +114,21 @@ try {
42
114
  // Cache hardening: revalidate every done task on every Stop so a
43
115
  // cached PASS cannot hide later receipt/provenance mutations.
44
116
  // Cache is optimization only - validate every done task even on first run.
45
- const previous = cacheExists ? (cache[active.featureName] || {}) : {};
117
+ const cacheIdentity = {
118
+ project_root: runtimeContext.project_root,
119
+ specs_root: runtimeContext.specs_root,
120
+ spec_file: runtimeContext.spec_file,
121
+ feature_name: runtimeContext.feature_name,
122
+ runtime_session: runtimeContext.runtime_session,
123
+ provenance_mode: runtimeContext.provenance_mode,
124
+ Base: runtimeContext.base,
125
+ Head: runtimeContext.head,
126
+ context_id: runtimeContext.context_id,
127
+ };
128
+ const cacheEntries = cache && cache.entries && typeof cache.entries === 'object' ? cache.entries : {};
129
+ const previous = cacheExists && cacheEntries[runtimeContext.context_id]
130
+ ? cacheEntries[runtimeContext.context_id].tasks || {}
131
+ : {};
46
132
  const staleFlashTasks = Object.entries(registry)
47
133
  .filter(([, task]) => POLICY.isStaleFlashDone(task))
48
134
  .map(([taskPath]) => taskPath);
@@ -57,24 +143,43 @@ try {
57
143
  currentStatuses[taskPath] === 'done'
58
144
  ));
59
145
  const featureDir = path.join(active.specsDir, active.featureName);
146
+ const receiptBodies = new Map();
60
147
  const failures = allDoneTasks
61
- .map((taskPath) => ({
62
- taskPath,
63
- failures: checkReceipt(featureDir, taskPath, registry[taskPath])
64
- }))
148
+ .map((taskPath) => {
149
+ const body = readReceiptBody(featureDir, taskPath);
150
+ if (body !== null) receiptBodies.set(taskPath, body);
151
+ return {
152
+ taskPath,
153
+ failures: checkReceipt(featureDir, taskPath, registry[taskPath], runtimeContext)
154
+ };
155
+ })
65
156
  .filter((result) => result.failures.length);
66
157
 
158
+ const firstReceiptTask = allDoneTasks.find((taskPath) => receiptBodies.has(taskPath));
159
+ const completion = allDoneTasks.length > 0 && Object.prototype.hasOwnProperty.call(active.spec, 'workflow_policy')
160
+ ? POLICY.completionDecisionForSpec(active.spec, {
161
+ runtimeContext,
162
+ executionReceipt: firstReceiptTask ? receiptBodies.get(firstReceiptTask) : null,
163
+ taskContext: firstReceiptTask ? registry[firstReceiptTask] : null,
164
+ })
165
+ : null;
166
+
67
167
  const next = { ...previous, ...currentStatuses };
68
168
  for (const result of failures) {
69
169
  if (previous[result.taskPath] === undefined) delete next[result.taskPath];
70
170
  else next[result.taskPath] = previous[result.taskPath];
71
171
  }
72
- cache[active.featureName] = next;
73
- atomicWrite(cacheFile, `${JSON.stringify(cache)}\n`);
172
+ atomicWrite(cacheFile, `${JSON.stringify({
173
+ schema_version: '2',
174
+ entries: { ...cacheEntries, [runtimeContext.context_id]: { identity: cacheIdentity, tasks: next } },
175
+ })}\n`);
74
176
 
75
- if (!failures.length) process.exit(0);
177
+ const completionBlocked = completion && completion.completion !== 'complete' && completion.completion !== 'not_applicable';
178
+ if (!failures.length && !completionBlocked) process.exit(0);
76
179
  const lines = [
77
- `Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`
180
+ failures.length > 0
181
+ ? `Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`
182
+ : 'Completion gate: workflow completion proof is incomplete.'
78
183
  ];
79
184
  for (const result of failures) {
80
185
  lines.push(
@@ -82,10 +187,17 @@ try {
82
187
  ` Add \`Verification: PASS\`, commands, and successful outcomes under \`## Evidence\` in \`specs/${active.featureName}/${result.taskPath}\`, then re-sync spec.json.`
83
188
  );
84
189
  }
190
+ if (completionBlocked) {
191
+ lines.push(`- Completion decision unfinished: ${completion.blocker || 'required workflow proof is missing.'}`);
192
+ if (Array.isArray(completion.missingProof) && completion.missingProof.length > 0) {
193
+ lines.push(` Missing proof: ${completion.missingProof.join(', ')}`);
194
+ }
195
+ }
85
196
  process.stdout.write(`${JSON.stringify({
86
197
  decision: 'block',
87
198
  reason: lines.slice(0, 8).join('\n')
88
199
  })}\n`);
89
200
  } catch (error) {
90
201
  logCrash('spec-gate', error);
202
+ emitBlock(`Completion gate controlled failure: ${error.message}. Completion is blocked until the hook is repaired.`);
91
203
  }
@@ -10,11 +10,11 @@ const {
10
10
  logCrash,
11
11
  readPayload
12
12
  } = require('./lib/hook-context.cjs');
13
- const { findActiveSpec } = require('./lib/spec-utils.cjs');
14
- const policyPath = path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs');
15
- const POLICY = require(fs.existsSync(policyPath)
16
- ? policyPath
17
- : path.join(__dirname, '../../claude/scripts/workflow-policy.cjs'));
13
+ const { loadSharedPolicy } = require('./lib/spec-receipt.cjs');
14
+
15
+ function emitControlledFailure(reason) {
16
+ process.stdout.write(`> ⚠️ Spec tollgate unavailable: ${reason}. Repair the installed workflow policy before continuing.\n`);
17
+ }
18
18
 
19
19
  function cacheFile(projectRoot, sessionId) {
20
20
  const key = crypto.createHash('sha256')
@@ -27,10 +27,42 @@ function cacheFile(projectRoot, sessionId) {
27
27
  try {
28
28
  const payload = readPayload();
29
29
  if (!payload) process.exit(0);
30
+ const loaded = loadSharedPolicy();
31
+ if (!loaded.policy) {
32
+ logCrash('spec-state', loaded.error);
33
+ emitControlledFailure(loaded.error.message);
34
+ process.exit(0);
35
+ }
36
+ const POLICY = loaded.policy;
30
37
  const { projectRoot, runtime } = getHookContext(payload);
31
38
  if (runtime.spec?.tollgate === false) process.exit(0);
32
- const active = findActiveSpec(projectRoot, runtime);
33
- if (!active) process.exit(0);
39
+ const { resolveActiveSpec } = require('./lib/spec-utils.cjs');
40
+ const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
41
+ const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
42
+ const resolved = resolveActiveSpec(projectRoot, runtime, explicitFeature, explicitPath);
43
+ if (!resolved) process.exit(0);
44
+ if (resolved.error === 'multiple_active') {
45
+ process.stdout.write(`> ⚠️ Multiple active specs detected: ${resolved.candidates.join(', ')}. Provide explicit feature target or resolve ambiguity. Tollgate paused.\n`);
46
+ process.exit(0);
47
+ }
48
+ if (resolved.error === 'invalid_specs') {
49
+ process.stdout.write(`> ⚠️ Invalid spec JSON detected: ${resolved.candidates.join(', ')}. ${resolved.reason}. Fix or remove malformed spec.json. Tollgate paused.\n`);
50
+ process.exit(0);
51
+ }
52
+ if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
53
+ const detail = resolved.reason || resolved.explicitFeature || resolved.explicitPath || 'unknown';
54
+ process.stdout.write(`> ⚠️ Explicit spec target invalid (${resolved.error}): ${detail}. Provide a valid feature target inside configured specs root. Tollgate paused.\n`);
55
+ process.exit(0);
56
+ }
57
+ if (resolved.error) process.exit(0);
58
+ const active = resolved;
59
+ const runtimeContext = POLICY.deriveRuntimeContext({
60
+ projectRoot,
61
+ specsRoot: active.specsDir,
62
+ specFile: active.specFile || path.join(active.specsDir, active.featureName, 'spec.json'),
63
+ featureName: active.featureName,
64
+ runtimeSession: payload.session_id || payload.sessionId || payload.sessionID || payload.session?.id,
65
+ });
34
66
 
35
67
  const phase = active.spec.current_phase || active.spec.phase || 'unknown';
36
68
  const taskRegistry = active.spec.task_registry || {};
@@ -49,8 +81,21 @@ try {
49
81
  (task?.status || 'pending') === 'pending'
50
82
  && (task?.dependencies || []).every((dependency) => statuses.get(dependency) === 'done')
51
83
  ));
52
- const stateKey = `${active.featureName}|${phase}|${counts.done || 0}/${tasks.length}`;
53
- const cache = cacheFile(projectRoot, payload.session_id);
84
+ const stateKey = JSON.stringify({
85
+ project_root: runtimeContext.project_root,
86
+ specs_root: runtimeContext.specs_root,
87
+ spec_file: runtimeContext.spec_file,
88
+ feature_name: runtimeContext.feature_name,
89
+ runtime_session: runtimeContext.runtime_session,
90
+ provenance_mode: runtimeContext.provenance_mode,
91
+ Base: runtimeContext.base,
92
+ Head: runtimeContext.head,
93
+ context_id: runtimeContext.context_id,
94
+ phase,
95
+ done: counts.done || 0,
96
+ total: tasks.length,
97
+ });
98
+ const cache = cacheFile(projectRoot, runtimeContext.runtime_session);
54
99
  let previous = '';
55
100
  try { previous = fs.readFileSync(cache, 'utf8').trim(); } catch { /* first run */ }
56
101
 
@@ -69,7 +114,7 @@ try {
69
114
  ];
70
115
  if (next) lines.push(`- Next unblocked: \`${next[0]}\``);
71
116
  if (flashTasks.length > 0) {
72
- lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. PASS promotion keeps task in_progress until explicit sync-finalize.`);
117
+ lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. A PASS proof keeps the persisted task in_progress until explicit sync-finalize.`);
73
118
  }
74
119
  lines.push(
75
120
  '- Sync `spec.json` and the task file only after verified work.',
@@ -79,4 +124,5 @@ try {
79
124
  process.stdout.write(`${lines.join('\n')}\n`);
80
125
  } catch (error) {
81
126
  logCrash('spec-state', error);
127
+ emitControlledFailure(error.message);
82
128
  }
@@ -43,6 +43,11 @@
43
43
  "command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/privacy-approval.cjs\"",
44
44
  "commandWindows": "node \".codex/hooks/privacy-approval.cjs\""
45
45
  },
46
+ {
47
+ "type": "command",
48
+ "command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/completion-authority.cjs\"",
49
+ "commandWindows": "node \".codex/hooks/completion-authority.cjs\""
50
+ },
46
51
  {
47
52
  "type": "command",
48
53
  "command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/rules.cjs\"",
@@ -98,6 +103,11 @@
98
103
  "command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/spec-gate.cjs\"",
99
104
  "commandWindows": "node \".codex/hooks/spec-gate.cjs\""
100
105
  },
106
+ {
107
+ "type": "command",
108
+ "command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/completion-authority.cjs\"",
109
+ "commandWindows": "node \".codex/hooks/completion-authority.cjs\""
110
+ },
101
111
  {
102
112
  "type": "command",
103
113
  "command": "node \"$(git rev-parse --show-toplevel)/.codex/hooks/state.cjs\"",
@@ -28,9 +28,11 @@ The underlying CafeKit skills are still named `hapo:*` in their documentation.
28
28
 
29
29
  ## OpenCode limits
30
30
 
31
- Claude Code hooks, statusline, and settings do not run in OpenCode. Map Claude-only tools to OpenCode built-ins: `TodoWrite` → `todowrite`, `AskUserQuestion` → `question`, `Task` → the agent/subtask flow. The installed plugins under `.opencode/plugins/` provide the privacy, inspect-scope, spec-state, scaffold-guard, session-state, and docs-sync gates; other Claude runtime behavior has no OpenCode equivalent. The completion gate (`spec-gate`: `Verification: PASS` + `## Evidence` + `task_registry.completed_at`) is **advisory in OpenCode** — OpenCode has no Stop-hook equivalent and the `spec-gate` plugin (if present) runs as a best-effort `tool.execute.after` check, not a hard turn block like Claude/Codex.
31
+ Claude Code hooks, statusline, and settings do not run in OpenCode. Map Claude-only tools to OpenCode built-ins: `TodoWrite` → `todowrite`, `AskUserQuestion` → `question`, `Task` → the agent/subtask flow. The installed plugins under `.opencode/plugins/` provide the privacy, inspect-scope, spec-state, scaffold-guard, session-state, and docs-sync gates; other Claude runtime behavior has no OpenCode equivalent.
32
32
 
33
- Runtime tier: OpenCode is **tier-2** for completion enforcement. Do not claim parity with Claude/Codex on `completion_gate`; `done` without a receipt emits an advisory warning/banner but does not hard-block the turn — rely on `spec-state` tollgate + manual `/hapo:test` verification.
33
+ The installed `spec-gate` plugin uses the cancellable `tool.execute.before` hook to hard-block supported completion/state tools (`task`, `taskupdate`, and `todowrite`) with a controlled `CAFEKIT_SPEC_GATE_BLOCKED` result when shared policy/resolver or receipt evidence is invalid or unavailable. Receipt-repair tools remain available where the host boundary permits correction.
34
+
35
+ OpenCode does not expose a cancellable `session.idle`/Stop hook or final assistant-turn cancellation boundary. Those event and prompt-injection paths are observational only; do not claim full-turn parity with Claude/Codex.
34
36
 
35
37
  ## Combined-install boundary
36
38