@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
@@ -12,6 +12,10 @@ const {
12
12
  getHookContext,
13
13
  readPayload
14
14
  } = require('./lib/hook-context.cjs');
15
+ const {
16
+ DYNAMIC_READ_PATH,
17
+ commandAccess,
18
+ } = require('./lib/privacy-command-analysis.cjs');
15
19
 
16
20
  const RESTRICTED = [
17
21
  /^\.env(?:[.\[*?{]|$)/i,
@@ -28,7 +32,13 @@ const RESTRICTED = [
28
32
  /\.keystore$/i,
29
33
  /\.jks$/i,
30
34
  /auth\.json$/i,
31
- /token(s)?\.json$/i
35
+ /token(s)?\.json$/i,
36
+ /\.cafekit/i,
37
+ /hmac\.key/i,
38
+ /completion-authority/i,
39
+ /session-state/i,
40
+ /hook-log/i,
41
+ /sessions/i
32
42
  ];
33
43
 
34
44
  const EXEMPT = [/\.env\.(example|sample|template|test)$/i];
@@ -48,6 +58,7 @@ function isSafe(filePath) {
48
58
  }
49
59
 
50
60
  function isSensitive(filePath) {
61
+ if (filePath === DYNAMIC_READ_PATH) return true;
51
62
  return matchesAny(filePath, RESTRICTED);
52
63
  }
53
64
 
@@ -60,18 +71,60 @@ function resolveTarget(filePath, cwd) {
60
71
  }
61
72
  }
62
73
 
63
- function extractCommandPaths(command) {
64
- const paths = new Set();
65
- for (const token of String(command).split(/[\s|;&<>"']+/).filter(Boolean)) {
66
- const cleaned = token.replace(/^["'()`]+|["'(),:]+$/g, '');
67
- for (const candidate of cleaned.split('=')) {
68
- const value = candidate.replace(/^["'()`]+|["'(),:]+$/g, '');
69
- if (value && (isSensitive(value) || isSafe(value))) paths.add(value);
74
+ /**
75
+ * When realpath fails, check whether the requested path is a symlink (or chain)
76
+ * whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed deny);
77
+ * dangling benign link or link to allowed example/template -> false (allow).
78
+ * Limit hops to avoid loops; malformed/looping -> fail closed.
79
+ * TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
80
+ * This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
81
+ */
82
+ function isSymlinkToSensitive(filePath, cwd) {
83
+ try {
84
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
85
+ let current = requested;
86
+ let hops = 0;
87
+ const MAX_HOPS = 8;
88
+ while (hops < MAX_HOPS) {
89
+ let stat;
90
+ try {
91
+ stat = fs.lstatSync(current);
92
+ } catch {
93
+ return false;
94
+ }
95
+ if (!stat.isSymbolicLink()) return false;
96
+ let linkTarget;
97
+ try {
98
+ linkTarget = fs.readlinkSync(current);
99
+ } catch {
100
+ return true;
101
+ }
102
+ if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
103
+ if (isSafe(linkTarget)) return false;
104
+ const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
105
+ if (next === current) return true;
106
+ try {
107
+ const real = fs.realpathSync(next);
108
+ if (isSensitive(real) && !isSafe(real)) return true;
109
+ if (isSafe(real)) return false;
110
+ return false;
111
+ } catch {
112
+ current = next;
113
+ hops += 1;
114
+ continue;
115
+ }
70
116
  }
117
+ return true;
118
+ } catch {
119
+ return true;
71
120
  }
72
- return [...paths];
73
121
  }
74
122
 
123
+ function extractCommandPaths(command) {
124
+ const access = commandAccess(command);
125
+ return access.dynamic ? [...access.paths, DYNAMIC_READ_PATH] : access.paths;
126
+ }
127
+
75
128
  function extractPatchPaths(patch) {
76
129
  return [...String(patch).matchAll(
77
130
  /^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm
@@ -126,6 +179,8 @@ function sensitiveRequest(filePath, cwd) {
126
179
  if (target) {
127
180
  if (isSensitive(target) && !isSafe(target)) return true;
128
181
  if (isSafe(target)) return false;
182
+ } else {
183
+ if (isSymlinkToSensitive(filePath, cwd)) return true;
129
184
  }
130
185
  return !isSafe(filePath) && isSensitive(filePath);
131
186
  }
@@ -152,7 +207,8 @@ try {
152
207
  if (runtime.privacyBlock === false) process.exit(0);
153
208
 
154
209
  const filePaths = sensitivePaths(data.tool_name || '', data.tool_input || {}, sessionCwd);
155
- if (filePaths.length === 0) process.exit(0);
210
+ const effectivePaths = filePaths;
211
+ if (effectivePaths.length === 0) process.exit(0);
156
212
  if (!data.session_id) {
157
213
  deny(
158
214
  'Sensitive file access denied because this hook invocation had no Codex session ID. ' +
@@ -164,7 +220,7 @@ try {
164
220
  projectRoot,
165
221
  sessionCwd,
166
222
  sessionId: data.session_id,
167
- filePaths,
223
+ filePaths: effectivePaths,
168
224
  toolName: data.tool_name
169
225
  };
170
226
  if (consumeToken(tokenInput)) process.exit(0);
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+ const STATE = require('./completion-authority-state.cjs');
8
+ const PATH_SAFETY = require('./lib/runtime-path-safety.cjs');
9
+
10
+ const SCHEMA_VERSION = 1;
11
+ const MARKER = 'CAFEKIT_SEMANTIC_REVIEW_ATTESTATION ';
12
+ const SHA256_RE = /^sha256:[a-f0-9]{64}$/;
13
+ const REVIEWER_ROLE_ALLOWLIST = new Set(['code-auditor', 'code_auditor']);
14
+ const RECORD_FIELDS = ['schema_version', 'kind', 'identity', 'semantic_digest', 'event', 'verdict', 'host_session_id', 'reviewer_agent_id', 'reviewer_agent_type', 'issued_at', 'mac'];
15
+
16
+ function plain(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
17
+ function exactKeys(value, fields) { return plain(value) && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...fields].sort()); }
18
+ function inside(root, target) { const relative = path.relative(root, target); return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); }
19
+
20
+ function projectRoot(payload = {}) {
21
+ const installedRoot = path.resolve(__dirname, '..', '..');
22
+ if (fs.existsSync(path.join(installedRoot, '.codex', 'hooks', path.basename(__filename)))) {
23
+ try { return STATE.canonicalProjectRoot(installedRoot); } catch {}
24
+ }
25
+ for (const candidate of [process.env.CLAUDE_PROJECT_DIR, process.env.PROJECT_ROOT]) {
26
+ if (typeof candidate === 'string' && candidate.trim()) { try { return STATE.canonicalProjectRoot(candidate); } catch { /* continue */ } }
27
+ }
28
+ const cwd = typeof payload.cwd === 'string' && payload.cwd.trim() ? payload.cwd : process.cwd();
29
+ return STATE.canonicalProjectRoot(cwd);
30
+ }
31
+
32
+ function identityFor(root, specFile, featureName) {
33
+ if (typeof featureName !== 'string' || !featureName.trim() || featureName.includes('/') || featureName.includes('\\') || featureName === '.' || featureName === '..' || featureName.includes('..')) throw new Error('feature identity is malformed');
34
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(featureName)) throw new Error('feature identity is malformed');
35
+ const canonicalRoot = STATE.canonicalProjectRoot(root);
36
+ const canonicalSpec = fs.realpathSync(path.resolve(specFile));
37
+ if (!inside(canonicalRoot, canonicalSpec) || path.basename(canonicalSpec) !== 'spec.json') throw new Error('spec identity escapes the project');
38
+ const relative = path.relative(canonicalRoot, canonicalSpec);
39
+ const expected = path.join('specs', featureName, 'spec.json');
40
+ if (relative !== expected) throw new Error(`spec identity must be ${expected}, not ${relative}`);
41
+ const spec = JSON.parse(fs.readFileSync(canonicalSpec, 'utf8'));
42
+ if (spec.feature_name !== featureName) throw new Error('feature identity does not match spec.json');
43
+ return { project_root: canonicalRoot, spec_file: canonicalSpec, feature_name: featureName };
44
+ }
45
+
46
+ function recordPath(root, identity) {
47
+ const name = STATE.digest({ namespace: 'cafekit-semantic-review-observation-v1', identity });
48
+ return path.join(STATE.stateDir(root), 'semantic-reviews', `observation-${name}.json`);
49
+ }
50
+
51
+ function parseClaim(message) {
52
+ const lines = String(message || '').split(/\r?\n/).filter((line) => line.startsWith(MARKER));
53
+ if (lines.length === 0) return null;
54
+ if (lines.length !== 1) throw new Error('exactly one semantic review attestation marker is required');
55
+ const claim = JSON.parse(lines[0].slice(MARKER.length));
56
+ if (!exactKeys(claim, ['feature_name', 'spec_file', 'semantic_digest', 'verdict'])) throw new Error('semantic review claim fields are malformed');
57
+ if (claim.verdict !== 'PASS' || !SHA256_RE.test(claim.semantic_digest || '')) throw new Error('semantic review claim must contain literal PASS and a sha256 digest');
58
+ return claim;
59
+ }
60
+
61
+ function validatorFor(root) {
62
+ const installedRoot = path.join(root, '.codex');
63
+ const installedHook = path.join(installedRoot, 'hooks', path.basename(__filename));
64
+ if (fs.existsSync(installedHook)) {
65
+ const canonicalHook = PATH_SAFETY.canonicalRegularFile(root, installedHook, 'installed semantic hook');
66
+ if (canonicalHook !== fs.realpathSync(__filename)) throw new Error('installed semantic hook identity is invalid');
67
+ const validator = path.join(installedRoot, 'scripts', 'validate-spec-output.cjs');
68
+ PATH_SAFETY.verifyCommonJsClosure(installedRoot, validator);
69
+ return PATH_SAFETY.canonicalRegularFile(installedRoot, validator, 'semantic validator');
70
+ }
71
+
72
+ const sourceRuntime = path.resolve(__dirname, '..', '..', 'claude');
73
+ const sourceRoot = path.resolve(sourceRuntime, '..');
74
+ if (path.basename(sourceRuntime) !== 'claude' || path.basename(sourceRoot) !== 'src') {
75
+ throw new Error('semantic validator runtime root is not recognized');
76
+ }
77
+ const validator = path.join(sourceRuntime, 'scripts', 'validate-spec-output.cjs');
78
+ PATH_SAFETY.verifyCommonJsClosure(sourceRoot, validator);
79
+ return PATH_SAFETY.canonicalRegularFile(sourceRoot, validator, 'semantic validator');
80
+ }
81
+
82
+ function currentDigest(root, specFile) {
83
+ const validator = validatorFor(root);
84
+ const result = spawnSync(process.execPath, [validator, path.dirname(specFile), '--semantic-digest'], { cwd: root, encoding: 'utf8' });
85
+ const digest = result.stdout.trim();
86
+ if (result.status !== 0 || !SHA256_RE.test(digest)) throw new Error(`semantic digest could not be recomputed (${result.stderr.trim() || `exit ${result.status}`})`);
87
+ return digest;
88
+ }
89
+
90
+ function recordFromSubagentStop(payload) {
91
+ // Honest-agent guardrail: the host hook observes this SubagentStop payload.
92
+ // The worker-authored claim is not authenticated input or a security boundary.
93
+ if (!plain(payload) || payload.hook_event_name !== 'SubagentStop') return { recorded: false, reason: 'event' };
94
+ for (const field of ['session_id', 'agent_id', 'agent_type', 'last_assistant_message']) if (typeof payload[field] !== 'string' || !payload[field].trim()) return { recorded: false, reason: `missing-${field}` };
95
+ if (!REVIEWER_ROLE_ALLOWLIST.has(payload.agent_type)) return { recorded: false, reason: 'reviewer-role' };
96
+ const root = projectRoot(payload);
97
+ const claim = parseClaim(payload.last_assistant_message);
98
+ if (claim === null) return { recorded: false, reason: 'no-claim' };
99
+ const identity = identityFor(root, path.resolve(root, claim.spec_file), claim.feature_name);
100
+ const digest = currentDigest(root, identity.spec_file);
101
+ if (digest !== claim.semantic_digest) throw new Error('claimed semantic digest does not match current artifacts');
102
+ const key = STATE.ensureKey();
103
+ const record = { schema_version: SCHEMA_VERSION, kind: 'semantic-review-observation', identity, semantic_digest: digest, event: 'SubagentStop', verdict: 'PASS', host_session_id: payload.session_id, reviewer_agent_id: payload.agent_id, reviewer_agent_type: payload.agent_type, issued_at: new Date().toISOString() };
104
+ record.mac = STATE.recordMac(record, key);
105
+ STATE.atomicWrite(recordPath(root, identity), record);
106
+ return { recorded: true, record };
107
+ }
108
+
109
+ function verifyAttestation(root, specFile, featureName, semanticDigest) {
110
+ let identity;
111
+ try { identity = identityFor(root, specFile, featureName); } catch (error) { return { ok: false, reason: error.message }; }
112
+ let record;
113
+ try { record = JSON.parse(fs.readFileSync(recordPath(root, identity), 'utf8')); } catch (error) { return { ok: false, reason: error.code === 'ENOENT' ? 'matching host-hook observation is missing' : 'host-hook observation is unreadable' }; }
114
+ const key = STATE.readKey();
115
+ if (key.missing) return { ok: false, reason: 'completion authority key is missing' };
116
+ if (!exactKeys(record, RECORD_FIELDS) || !STATE.macMatches(record, key.key)) return { ok: false, reason: 'host-hook observation is malformed or its MAC is invalid' };
117
+ if (record.schema_version !== SCHEMA_VERSION || record.kind !== 'semantic-review-observation' || record.event !== 'SubagentStop' || record.verdict !== 'PASS') return { ok: false, reason: 'host-hook observation event or verdict is invalid' };
118
+ if (STATE.stableStringify(record.identity) !== STATE.stableStringify(identity)) return { ok: false, reason: 'host-hook observation project, feature, or spec identity is invalid' };
119
+ if (record.semantic_digest !== semanticDigest) return { ok: false, reason: 'host-hook observation semantic digest is stale' };
120
+ return { ok: true, record };
121
+ }
122
+
123
+ if (require.main === module) {
124
+ try { const raw = fs.readFileSync(0, 'utf8').trim(); if (raw) recordFromSubagentStop(JSON.parse(raw)); }
125
+ catch (error) { process.stderr.write(`CafeKit semantic review SubagentStop claim rejected: ${error.message}\n`); }
126
+ // Codex SubagentStop requires valid JSON on stdout for every exit-0 hook.
127
+ process.stdout.write('{}\n');
128
+ }
129
+
130
+ module.exports = { MARKER, REVIEWER_ROLE_ALLOWLIST, recordFromSubagentStop, verifyAttestation, identityFor, recordPath };
@@ -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,101 @@ 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 {
13
+ checkFeatureReceipt,
14
+ checkReceiptDetails,
15
+ loadSharedPolicy,
16
+ } = require('./lib/spec-receipt.cjs');
17
+ const { taskStatusMap } = require('./lib/spec-utils.cjs');
18
+ const FINAL_STATE = require('./completion-authority-check.cjs');
19
+
20
+ function emitBlock(reason) {
21
+ process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
22
+ }
23
+
24
+ function sessionIdentity(payload) {
25
+ const candidates = [
26
+ payload && payload.session_id,
27
+ payload && payload.sessionId,
28
+ payload && payload.sessionID,
29
+ payload && payload.session && payload.session.id,
30
+ ];
31
+ return candidates.find((value) => typeof value === 'string' && value.trim() !== '') || null;
32
+ }
18
33
 
19
34
  try {
20
35
  const payload = readPayload();
21
- if (!payload || payload.stop_hook_active === true) process.exit(0);
36
+ if (payload.stop_hook_active === true) process.exit(0);
37
+ const loaded = loadSharedPolicy();
38
+ if (!loaded.policy) {
39
+ logCrash('spec-gate', loaded.error);
40
+ emitBlock(`Completion gate unavailable: shared workflow policy could not be loaded (${loaded.error.message}). Repair ${loaded.path} before completing tasks.`);
41
+ process.exit(0);
42
+ }
43
+ const POLICY = loaded.policy;
22
44
  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
-
45
+ if (typeof POLICY.completionDecisionForSpec !== 'function') {
46
+ throw new Error('shared workflow policy lacks completion authority functions');
47
+ }
48
+ const resolver = require('./lib/spec-utils.cjs');
49
+ const resolved = FINAL_STATE.resolveCandidate({
50
+ resolver,
51
+ projectRoot,
52
+ runtime,
53
+ payload,
54
+ });
55
+ if (!resolved) process.exit(0);
56
+ if (resolved.error === 'multiple_active' || resolved.error === 'multiple_persisted') {
57
+ process.stdout.write(`${JSON.stringify({
58
+ decision: 'block',
59
+ reason: `Completion gate: multiple active specs detected (${resolved.candidates.join(', ')}). Provide explicit feature target or resolve ambiguity before completing tasks.`
60
+ })}\n`);
61
+ process.exit(0);
62
+ }
63
+ if (resolved.error === 'invalid_specs') {
64
+ process.stdout.write(`${JSON.stringify({
65
+ decision: 'block',
66
+ reason: `Completion gate: invalid spec JSON detected (${resolved.candidates.join(', ')}): ${resolved.reason}. Fix or remove malformed spec.json before completing tasks.`
67
+ })}\n`);
68
+ process.exit(0);
69
+ }
70
+ if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
71
+ process.stdout.write(`${JSON.stringify({
72
+ decision: 'block',
73
+ 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.`
74
+ })}\n`);
75
+ process.exit(0);
76
+ }
77
+ if (resolved.error) process.exit(0);
78
+ const active = resolved;
79
+ const lifecyclePhase = active.spec.current_phase || active.spec.phase;
80
+ const explicitCloseout = ['done', 'completed', 'complete'].includes(active.spec.status)
81
+ || ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
82
+ if (active.spec.schema_version === '2.1') {
83
+ const finalState = FINAL_STATE.evaluateCloseout({
84
+ policy: POLICY,
85
+ projectRoot,
86
+ runtime,
87
+ payload: { ...payload, session_id: sessionIdentity(payload) },
88
+ });
89
+ if (!finalState.ok) {
90
+ emitBlock(`Completion gate: ${finalState.reason}`);
91
+ process.exit(0);
92
+ }
93
+ if (finalState.active) process.exit(0);
94
+ }
95
+ const runtimeContext = POLICY.deriveRuntimeContext({
96
+ projectRoot,
97
+ specsRoot: active.specsDir,
98
+ specFile: active.specFile || path.join(active.specsDir, active.featureName, 'spec.json'),
99
+ featureName: active.featureName,
100
+ runtimeSession: sessionIdentity(payload),
101
+ });
102
+ const configuredGate = runtime.spec?.completion_gate;
103
+ if (configuredGate !== undefined && configuredGate !== true) {
104
+ 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.');
105
+ process.exit(0);
106
+ }
27
107
  const registry = active.spec.task_registry || {};
28
108
  const currentStatuses = taskStatusMap(active.spec);
29
109
  const cacheFile = path.join(
@@ -42,7 +122,21 @@ try {
42
122
  // Cache hardening: revalidate every done task on every Stop so a
43
123
  // cached PASS cannot hide later receipt/provenance mutations.
44
124
  // Cache is optimization only - validate every done task even on first run.
45
- const previous = cacheExists ? (cache[active.featureName] || {}) : {};
125
+ const cacheIdentity = {
126
+ project_root: runtimeContext.project_root,
127
+ specs_root: runtimeContext.specs_root,
128
+ spec_file: runtimeContext.spec_file,
129
+ feature_name: runtimeContext.feature_name,
130
+ runtime_session: runtimeContext.runtime_session,
131
+ provenance_mode: runtimeContext.provenance_mode,
132
+ Base: runtimeContext.base,
133
+ Head: runtimeContext.head,
134
+ context_id: runtimeContext.context_id,
135
+ };
136
+ const cacheEntries = cache && cache.entries && typeof cache.entries === 'object' ? cache.entries : {};
137
+ const previous = cacheExists && cacheEntries[runtimeContext.context_id]
138
+ ? cacheEntries[runtimeContext.context_id].tasks || {}
139
+ : {};
46
140
  const staleFlashTasks = Object.entries(registry)
47
141
  .filter(([, task]) => POLICY.isStaleFlashDone(task))
48
142
  .map(([taskPath]) => taskPath);
@@ -57,30 +151,62 @@ try {
57
151
  currentStatuses[taskPath] === 'done'
58
152
  ));
59
153
  const featureDir = path.join(active.specsDir, active.featureName);
154
+ const receiptBodies = new Map();
60
155
  const failures = allDoneTasks
61
- .map((taskPath) => ({
62
- taskPath,
63
- failures: checkReceipt(featureDir, taskPath, registry[taskPath])
64
- }))
156
+ .map((taskPath) => {
157
+ const proof = checkReceiptDetails(featureDir, taskPath, registry[taskPath], runtimeContext);
158
+ if (proof.body) receiptBodies.set(taskPath, proof.body);
159
+ return {
160
+ taskPath,
161
+ failures: proof.failures
162
+ };
163
+ })
65
164
  .filter((result) => result.failures.length);
66
165
 
166
+ const featureCloseoutRequired = explicitCloseout && allDoneTasks.length > 0;
167
+ const featureReceipt = featureCloseoutRequired
168
+ ? checkFeatureReceipt(featureDir, runtimeContext)
169
+ : null;
170
+ if (featureReceipt && featureReceipt.failures.length) {
171
+ failures.push({ taskPath: 'feature-receipt.md', failures: featureReceipt.failures });
172
+ }
173
+ const completion = featureCloseoutRequired && featureReceipt?.failures.length === 0
174
+ && Object.prototype.hasOwnProperty.call(active.spec, 'workflow_policy')
175
+ ? POLICY.completionDecisionForSpec(active.spec, {
176
+ runtimeContext,
177
+ executionReceipt: featureReceipt.body,
178
+ taskContext: {},
179
+ })
180
+ : null;
181
+
67
182
  const next = { ...previous, ...currentStatuses };
68
183
  for (const result of failures) {
69
184
  if (previous[result.taskPath] === undefined) delete next[result.taskPath];
70
185
  else next[result.taskPath] = previous[result.taskPath];
71
186
  }
72
- cache[active.featureName] = next;
73
- atomicWrite(cacheFile, `${JSON.stringify(cache)}\n`);
187
+ atomicWrite(cacheFile, `${JSON.stringify({
188
+ schema_version: '2',
189
+ entries: { ...cacheEntries, [runtimeContext.context_id]: { identity: cacheIdentity, tasks: next } },
190
+ })}\n`);
74
191
 
75
- if (!failures.length) process.exit(0);
192
+ const completionBlocked = completion && completion.completion !== 'complete' && completion.completion !== 'not_applicable';
193
+ if (!failures.length && !completionBlocked) process.exit(0);
76
194
  const lines = [
77
- `Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`
195
+ failures.length > 0
196
+ ? `Completion gate: ${failures.length} done task(s) lack a verification receipt.`
197
+ : 'Completion gate: workflow completion proof is incomplete.'
78
198
  ];
79
199
  for (const result of failures) {
80
- lines.push(
81
- `- \`${result.taskPath}\`: failed check(s) ${result.failures.join(', ')}`,
82
- ` Add \`Verification: PASS\`, commands, and successful outcomes under \`## Evidence\` in \`specs/${active.featureName}/${result.taskPath}\`, then re-sync spec.json.`
83
- );
200
+ lines.push(`- \`${result.taskPath}\`: failed check(s) ${result.failures.join(', ')}`);
201
+ lines.push(result.taskPath === 'feature-receipt.md'
202
+ ? ` Run final integration proof, then write \`specs/${active.featureName}/feature-receipt.md\`.`
203
+ : ` Write canonical proof to \`specs/${active.featureName}/receipts/${path.posix.basename(result.taskPath)}\`; legacy \`## Evidence\` remains read-compatible.`);
204
+ }
205
+ if (completionBlocked) {
206
+ lines.push(`- Completion decision unfinished: ${completion.blocker || 'required workflow proof is missing.'}`);
207
+ if (Array.isArray(completion.missingProof) && completion.missingProof.length > 0) {
208
+ lines.push(` Missing proof: ${completion.missingProof.join(', ')}`);
209
+ }
84
210
  }
85
211
  process.stdout.write(`${JSON.stringify({
86
212
  decision: 'block',
@@ -88,4 +214,5 @@ try {
88
214
  })}\n`);
89
215
  } catch (error) {
90
216
  logCrash('spec-gate', error);
217
+ emitBlock(`Completion gate controlled failure: ${error.message}. Completion is blocked until the hook is repaired.`);
91
218
  }
@@ -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, loadSharedReceipt } = 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,45 @@ 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;
37
+ const receiptLoaded = loadSharedReceipt();
38
+ if (!receiptLoaded.receipt) throw receiptLoaded.error;
39
+ const RECEIPT = receiptLoaded.receipt;
30
40
  const { projectRoot, runtime } = getHookContext(payload);
31
41
  if (runtime.spec?.tollgate === false) process.exit(0);
32
- const active = findActiveSpec(projectRoot, runtime);
33
- if (!active) process.exit(0);
42
+ const { resolveActiveSpec } = require('./lib/spec-utils.cjs');
43
+ const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
44
+ const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
45
+ const resolved = resolveActiveSpec(projectRoot, runtime, explicitFeature, explicitPath);
46
+ if (!resolved) process.exit(0);
47
+ if (resolved.error === 'multiple_active') {
48
+ process.stdout.write(`> ⚠️ Multiple active specs detected: ${resolved.candidates.join(', ')}. Provide explicit feature target or resolve ambiguity. Tollgate paused.\n`);
49
+ process.exit(0);
50
+ }
51
+ if (resolved.error === 'invalid_specs') {
52
+ process.stdout.write(`> ⚠️ Invalid spec JSON detected: ${resolved.candidates.join(', ')}. ${resolved.reason}. Fix or remove malformed spec.json. Tollgate paused.\n`);
53
+ process.exit(0);
54
+ }
55
+ if (resolved.error === 'explicit_not_found' || resolved.error === 'explicit_malformed') {
56
+ const detail = resolved.reason || resolved.explicitFeature || resolved.explicitPath || 'unknown';
57
+ process.stdout.write(`> ⚠️ Explicit spec target invalid (${resolved.error}): ${detail}. Provide a valid feature target inside configured specs root. Tollgate paused.\n`);
58
+ process.exit(0);
59
+ }
60
+ if (resolved.error) process.exit(0);
61
+ const active = resolved;
62
+ const runtimeContext = POLICY.deriveRuntimeContext({
63
+ projectRoot,
64
+ specsRoot: active.specsDir,
65
+ specFile: active.specFile || path.join(active.specsDir, active.featureName, 'spec.json'),
66
+ featureName: active.featureName,
67
+ runtimeSession: payload.session_id || payload.sessionId || payload.sessionID || payload.session?.id,
68
+ });
34
69
 
35
70
  const phase = active.spec.current_phase || active.spec.phase || 'unknown';
36
71
  const taskRegistry = active.spec.task_registry || {};
@@ -49,8 +84,24 @@ try {
49
84
  (task?.status || 'pending') === 'pending'
50
85
  && (task?.dependencies || []).every((dependency) => statuses.get(dependency) === 'done')
51
86
  ));
52
- const stateKey = `${active.featureName}|${phase}|${counts.done || 0}/${tasks.length}`;
53
- const cache = cacheFile(projectRoot, payload.session_id);
87
+ const featureDir = path.join(active.specsDir, active.featureName);
88
+ const featureReceiptPresent = RECEIPT.safeRead(featureDir, 'feature-receipt.md').status === 'ok';
89
+ const stateKey = JSON.stringify({
90
+ project_root: runtimeContext.project_root,
91
+ specs_root: runtimeContext.specs_root,
92
+ spec_file: runtimeContext.spec_file,
93
+ feature_name: runtimeContext.feature_name,
94
+ runtime_session: runtimeContext.runtime_session,
95
+ provenance_mode: runtimeContext.provenance_mode,
96
+ Base: runtimeContext.base,
97
+ Head: runtimeContext.head,
98
+ context_id: runtimeContext.context_id,
99
+ phase,
100
+ done: counts.done || 0,
101
+ total: tasks.length,
102
+ feature_receipt_present: featureReceiptPresent,
103
+ });
104
+ const cache = cacheFile(projectRoot, runtimeContext.runtime_session);
54
105
  let previous = '';
55
106
  try { previous = fs.readFileSync(cache, 'utf8').trim(); } catch { /* first run */ }
56
107
 
@@ -69,14 +120,16 @@ try {
69
120
  ];
70
121
  if (next) lines.push(`- Next unblocked: \`${next[0]}\``);
71
122
  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.`);
123
+ lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. A PASS proof keeps the persisted task in_progress until explicit sync-finalize.`);
73
124
  }
74
125
  lines.push(
75
- '- Sync `spec.json` and the task file only after verified work.',
126
+ '- Sync `spec.json` and task Markdown status only after verified work; task proof belongs in `receipts/<task-basename>.md`.',
127
+ `- Create \`feature-receipt.md\` once after final integration proof${featureReceiptPresent ? ' (present)' : ' (not required before closeout)'}.`,
76
128
  `- Validate with \`node .codex/scripts/validate-spec-output.cjs specs/${active.featureName}\`.`,
77
- '- The Stop completion gate checks receipts for newly-done tasks.'
129
+ '- Hooks revalidate receipt bytes but never grant approval.'
78
130
  );
79
131
  process.stdout.write(`${lines.join('\n')}\n`);
80
132
  } catch (error) {
81
133
  logCrash('spec-state', error);
134
+ emitControlledFailure(error.message);
82
135
  }
@@ -140,6 +140,7 @@ try {
140
140
  refreshGit(data, projectRoot);
141
141
  writeData(dir, data);
142
142
  });
143
+ process.stdout.write('{}\n');
143
144
  process.exit(0);
144
145
  }
145
146
 
@@ -151,6 +152,7 @@ try {
151
152
  writeData(dir, data);
152
153
  archive(dir);
153
154
  });
155
+ process.stdout.write('{}\n');
154
156
  }
155
157
  } catch (error) {
156
158
  logCrash('state', error);