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

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__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +531 -29
  3. package/bin/__tests__/codex-native.test.js +228 -7
  4. package/bin/__tests__/develop-contract.test.js +653 -185
  5. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  6. package/bin/__tests__/installer-safety.test.js +39 -0
  7. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  8. package/bin/__tests__/package-inventory.test.js +705 -13
  9. package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
  10. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  11. package/bin/__tests__/specs-v2-execution-closeout.test.js +468 -0
  12. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +665 -0
  13. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +1132 -0
  14. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  15. package/bin/__tests__/validator-grounding.test.js +2013 -221
  16. package/bin/lib/managed-writer.js +12 -3
  17. package/bin/phases/codex-runtime.js +17 -1
  18. package/package.json +5 -2
  19. package/src/claude/CLAUDE.md +6 -0
  20. package/src/claude/agents/code-auditor.md +20 -0
  21. package/src/claude/agents/spec-maker.md +143 -99
  22. package/src/claude/hooks/__tests__/completion-authority.test.js +321 -58
  23. package/src/claude/hooks/__tests__/privacy-block.test.js +254 -0
  24. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  25. package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
  26. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  27. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  28. package/src/claude/hooks/completion-authority.cjs +13 -7
  29. package/src/claude/hooks/docs-sync.cjs +5 -2
  30. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  31. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  32. package/src/claude/hooks/privacy-block.cjs +99 -28
  33. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  34. package/src/claude/hooks/spec-gate.cjs +47 -117
  35. package/src/claude/hooks/spec-state.cjs +8 -2
  36. package/src/claude/migration-manifest.json +9 -1
  37. package/src/claude/rules/state-sync.md +52 -21
  38. package/src/claude/rules/workflow.md +17 -9
  39. package/src/claude/scripts/change-firewall.cjs +1748 -0
  40. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  41. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  42. package/src/claude/scripts/spec-final-state.cjs +290 -0
  43. package/src/claude/scripts/spec-ground.cjs +571 -173
  44. package/src/claude/scripts/spec-readiness.cjs +425 -0
  45. package/src/claude/scripts/spec-receipt.cjs +162 -0
  46. package/src/claude/scripts/spec-resolver.cjs +67 -16
  47. package/src/claude/scripts/spec-scaffold.cjs +1594 -139
  48. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  49. package/src/claude/scripts/validate-spec-output.cjs +3110 -218
  50. package/src/claude/scripts/workflow-policy.cjs +501 -183
  51. package/src/claude/settings/settings.json +4 -0
  52. package/src/claude/skills/code-review/SKILL.md +12 -3
  53. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  54. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  55. package/src/claude/skills/develop/SKILL.md +47 -60
  56. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  57. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  58. package/src/claude/skills/specs/SKILL.md +183 -177
  59. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  60. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  61. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  62. package/src/claude/skills/specs/references/review.md +214 -262
  63. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  64. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  65. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  66. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  67. package/src/claude/skills/specs/rules/design-review.md +42 -97
  68. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  69. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  70. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  71. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  72. package/src/claude/skills/specs/templates/design.md +49 -263
  73. package/src/claude/skills/specs/templates/requirements.md +26 -36
  74. package/src/claude/skills/specs/templates/research.md +9 -98
  75. package/src/claude/skills/specs/templates/spec-state.json +35 -85
  76. package/src/claude/skills/specs/templates/task.md +30 -102
  77. package/src/claude/skills/sync/SKILL.md +33 -10
  78. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  79. package/src/claude/skills/test/SKILL.md +17 -7
  80. package/src/codex/AGENTS.md +6 -0
  81. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  82. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  83. package/src/codex/hooks/completion-authority.cjs +13 -7
  84. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  85. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  86. package/src/codex/hooks/lib/privacy-state.cjs +61 -38
  87. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  88. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  89. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  90. package/src/codex/hooks/privacy-approval.cjs +6 -2
  91. package/src/codex/hooks/privacy-block.cjs +94 -15
  92. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  93. package/src/codex/hooks/spec-gate.cjs +49 -34
  94. package/src/codex/hooks/spec-state.cjs +10 -3
  95. package/src/codex/hooks/state.cjs +2 -0
  96. package/src/codex/hooks.json +6 -1
  97. package/src/codex/rules/state-sync.md +47 -27
  98. package/src/common/AGENTS.md +1 -1
@@ -1,260 +1,63 @@
1
1
  'use strict';
2
2
 
3
- const crypto = require('crypto');
4
3
  const fs = require('fs');
5
4
  const path = require('path');
6
5
  const STATE = require('./completion-authority-state.cjs');
7
- const { checkReceipt, evidenceBody } = require('./lib/spec-receipt.cjs');
8
- const { findAllSpecCandidates, resolveActiveSpec } = require('./lib/spec-utils.cjs');
6
+ const { checkFeatureReceipt, checkReceiptDetails } = require('./lib/spec-receipt.cjs');
7
+ const RESOLVER = require('./lib/spec-utils.cjs');
9
8
 
10
- function plain(value) {
11
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
12
- }
13
-
14
- function inside(root, target) {
15
- const relative = path.relative(path.resolve(root), path.resolve(target));
16
- return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
17
- }
9
+ const installedScripts = path.join(__dirname, '..', 'scripts');
10
+ const scripts = fs.existsSync(path.join(installedScripts, 'spec-final-state.cjs'))
11
+ ? installedScripts
12
+ : path.resolve(__dirname, '..', '..', 'claude', 'scripts');
18
13
 
19
- function readBoundFile(root, target) {
20
- const requested = path.isAbsolute(target) ? target : path.resolve(root, target);
21
- if (!inside(root, requested)) return null;
22
- try {
23
- const canonicalRoot = fs.realpathSync(root);
24
- const canonical = fs.realpathSync(requested);
25
- if (!inside(canonicalRoot, canonical) || !fs.statSync(canonical).isFile()) return null;
26
- return fs.readFileSync(canonical);
27
- } catch { return null; }
14
+ function runtimeModule(root, name) {
15
+ const target = path.join(root, name);
16
+ const stat = fs.lstatSync(target);
17
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`runtime dependency must be a regular non-symlink file: ${name}`);
18
+ const canonicalRoot = fs.realpathSync(root);
19
+ const canonical = fs.realpathSync(target);
20
+ const relative = path.relative(canonicalRoot, canonical);
21
+ if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`runtime dependency escaped scripts root: ${name}`);
22
+ return require(canonical);
28
23
  }
29
24
 
30
- function readTask(featureDir, taskPath) {
31
- const target = path.resolve(featureDir, taskPath);
32
- if (!inside(featureDir, target)) return null;
33
- const bytes = readBoundFile(featureDir, target);
34
- if (!bytes) return null;
35
- return { bytes, text: bytes.toString('utf8'), path: fs.realpathSync(target) };
36
- }
25
+ const FINAL_STATE = runtimeModule(scripts, 'spec-final-state.cjs');
37
26
 
38
- function artifactReferences(body) {
39
- if (typeof body !== 'string') return [];
40
- const references = [];
41
- for (const line of body.split('\n')) {
42
- if (!/^\s*Artifacts?\s*:/i.test(line) && !/^\s*Artifact\s+produced\b/i.test(line)) continue;
43
- const hashes = [...line.matchAll(/\b(?:artifact[_-])?sha-?256\s*:\s*([^\s),;`]+)/gi)].map((match) => match[1]);
44
- const raw = line.replace(/^\s*Artifacts?\s*:\s*/i, '').replace(/^\s*Artifact\s+produced\s*:?[ \t]*/i, '').replace(/\b(?:artifact[_-])?sha-?256\s*:\s*[^\s),;`]+/gi, '').replace(/[()[\]{}]/g, ' ').trim();
45
- const paths = raw.split(/\s*(?:\+|,|;|\band\b)\s*/i).map((value) => value.trim()).filter(Boolean);
46
- paths.forEach((artifactPath, index) => references.push({ path: artifactPath, hash: hashes[index] || null }));
27
+ function explicitTarget(payload) {
28
+ for (const key of ['featureName', 'feature', 'explicitFeature']) {
29
+ if (Object.prototype.hasOwnProperty.call(payload, key)) return { explicitFeature: payload[key] };
47
30
  }
48
- return references;
31
+ for (const key of ['specPath', 'spec_path', 'featurePath']) {
32
+ if (Object.prototype.hasOwnProperty.call(payload, key)) return { explicitPath: payload[key] };
33
+ }
34
+ return null;
49
35
  }
50
36
 
51
37
  function resolveCandidate({ projectRoot, runtime, payload }) {
52
- const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
53
- const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
54
- const candidates = findAllSpecCandidates(projectRoot, runtime);
38
+ const target = explicitTarget(payload);
39
+ if (target) return RESOLVER.resolveActiveSpec({ projectRoot, runtime, ...target });
40
+ const candidates = RESOLVER.findAllSpecCandidates(projectRoot, runtime);
55
41
  if (candidates.length > 1) return { error: 'multiple_persisted', candidates: candidates.map((candidate) => candidate.featureName) };
56
- if (candidates.length === 0) {
57
- if (explicitFeature || explicitPath) return { error: 'explicit_not_found', explicitFeature, explicitPath, reason: 'no globally persisted spec matches the explicit target' };
58
- return null;
59
- }
60
- if (!explicitFeature && !explicitPath) return candidates[0];
61
- const targeted = resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath });
62
- if (!targeted || targeted.error) {
63
- return {
64
- error: 'explicit_mismatch',
65
- explicitFeature,
66
- explicitPath,
67
- reason: targeted?.reason || 'explicit target does not resolve to the persisted candidate',
68
- };
69
- }
70
- if (targeted.featureName !== candidates[0].featureName || path.resolve(targeted.specFile) !== path.resolve(candidates[0].specFile)) {
71
- return {
72
- error: 'explicit_mismatch',
73
- explicitFeature,
74
- explicitPath,
75
- reason: 'explicit target does not match the sole globally persisted candidate',
76
- };
77
- }
78
- return candidates[0];
79
- }
80
-
81
- function policyRank(policy, lane) {
82
- const lanes = Array.isArray(policy.LANES) ? policy.LANES : ['Direct', 'Standard', 'Critical'];
83
- return lanes.indexOf(lane);
84
- }
85
-
86
- function policyMonotonicFailures(policy, baseline, current) {
87
- const failures = [];
88
- if (baseline.version !== current.version) failures.push('workflow_policy.version changed');
89
- if (policyRank(policy, current.lane) < policyRank(policy, baseline.lane)) failures.push(`workflow_policy.lane downgraded from ${baseline.lane} to ${current.lane}`);
90
- if (policyRank(policy, current.automatic_lane) < policyRank(policy, baseline.automatic_lane)) failures.push(`workflow_policy.automatic_lane downgraded from ${baseline.automatic_lane} to ${current.automatic_lane}`);
91
- const currentRisks = new Set(current.risks);
92
- for (const risk of baseline.risks) if (!currentRisks.has(risk)) failures.push(`workflow_policy risk ${risk} was removed`);
93
- const currentObligations = new Set(current.proof_obligations);
94
- for (const obligation of baseline.proof_obligations) if (!currentObligations.has(obligation)) failures.push(`workflow_policy obligation ${obligation} was removed`);
95
- return failures;
96
- }
97
-
98
- function mergePolicyFloor(policy, floor, current) {
99
- const merged = { ...current };
100
- if (policyRank(policy, floor.lane) > policyRank(policy, current.lane)) merged.lane = floor.lane;
101
- if (policyRank(policy, floor.automatic_lane) > policyRank(policy, current.automatic_lane)) merged.automatic_lane = floor.automatic_lane;
102
- merged.risks = [...new Set([...current.risks, ...floor.risks])];
103
- const proofObligations = [];
104
- const actorNeeds = [];
105
- for (const source of [current, floor]) {
106
- for (let index = 0; index < source.proof_obligations.length; index += 1) {
107
- const obligation = source.proof_obligations[index];
108
- if (proofObligations.includes(obligation)) continue;
109
- proofObligations.push(obligation);
110
- actorNeeds.push(source.actor_needs[index]);
111
- }
112
- }
113
- merged.proof_obligations = proofObligations;
114
- merged.actor_needs = actorNeeds;
115
- return merged;
42
+ return candidates[0] || null;
116
43
  }
117
44
 
118
- function observePolicyBaseline({ STATE: state, policy, projectRoot, candidate }) {
119
- if (!Object.prototype.hasOwnProperty.call(candidate.spec, 'workflow_policy')) {
120
- return { ok: false, reason: 'workflow_policy is missing for persisted spec' };
121
- }
122
- const current = candidate.spec.workflow_policy;
123
- const validation = policy.validateWorkflowPolicySnapshot(current);
124
- if (!validation.valid) return { ok: false, reason: `workflow_policy is malformed: ${validation.errors.join('; ')}` };
125
- let identity;
126
- try {
127
- identity = {
128
- project_root: fs.realpathSync(projectRoot),
129
- spec_file: fs.realpathSync(candidate.specFile),
130
- feature_name: candidate.featureName,
131
- };
132
- state.ensureKey();
133
- let authorityState = state.readState(projectRoot);
134
- if (authorityState.malformed || authorityState.keyError || authorityState.keyMissing) {
135
- return { ok: false, reason: authorityState.reason || 'completion authority state is unavailable' };
136
- }
137
- if (!authorityState.floor) {
138
- if (authorityState.baselines.length > 0 || authorityState.pending || authorityState.grant) {
139
- return { ok: false, reason: 'project policy floor is missing for persisted authority state' };
140
- }
141
- state.writePolicyFloor(projectRoot, current);
142
- authorityState = state.readState(projectRoot);
143
- if (authorityState.malformed || authorityState.keyError || authorityState.keyMissing || !authorityState.floor) {
144
- return { ok: false, reason: authorityState.reason || 'project policy floor could not be persisted' };
145
- }
146
- } else {
147
- const floorValidation = policy.validateWorkflowPolicySnapshot(authorityState.floor.policy);
148
- if (!floorValidation.valid) return { ok: false, reason: `persisted project policy floor is malformed: ${floorValidation.errors.join('; ')}` };
149
- const floorFailures = policyMonotonicFailures(policy, authorityState.floor.policy, current);
150
- if (floorFailures.length) return { ok: false, reason: `project policy floor is monotonic and cannot be downgraded (${floorFailures.join('; ')})` };
151
- const mergedFloor = mergePolicyFloor(policy, authorityState.floor.policy, current);
152
- if (state.digest(authorityState.floor.policy) !== state.digest(mergedFloor)) state.writePolicyFloor(projectRoot, mergedFloor);
153
- }
154
- const baseline = state.readBaseline(projectRoot, identity);
155
- if (!baseline) {
156
- state.writeBaseline(projectRoot, identity, current);
157
- return { ok: true };
158
- }
159
- const baselineValidation = policy.validateWorkflowPolicySnapshot(baseline.policy);
160
- if (!baselineValidation.valid) return { ok: false, reason: `persisted workflow policy baseline is malformed: ${baselineValidation.errors.join('; ')}` };
161
- const failures = policyMonotonicFailures(policy, baseline.policy, current);
162
- if (failures.length) return { ok: false, reason: `workflow_policy baseline is monotonic and cannot be downgraded (${failures.join('; ')})` };
163
- if (state.digest(baseline.policy) !== state.digest(current)) state.writeBaseline(projectRoot, identity, current);
164
- return { ok: true };
165
- } catch (error) {
166
- return { ok: false, reason: `workflow_policy baseline unavailable: ${error.message}` };
167
- }
168
- }
169
-
170
- function evaluateCloseout({ policy, projectRoot, runtime, payload }) {
171
- const candidate = resolveCandidate({ projectRoot, runtime, payload });
172
- if (!candidate) return { ok: true, candidate: null };
173
- if (candidate.error) {
174
- const detail = Array.isArray(candidate.candidates) ? candidate.candidates.join(', ') : (candidate.reason || candidate.error);
175
- return { ok: false, candidate, reason: `${candidate.error}: ${detail}` };
176
- }
177
- const sessionId = payload.session_id;
178
- if (typeof sessionId !== 'string' || !sessionId.trim()) return { ok: false, candidate, reason: 'trusted host session ID is unavailable' };
179
- const baseline = observePolicyBaseline({ STATE, policy, projectRoot, candidate });
180
- if (!baseline.ok) return { ok: false, candidate, reason: baseline.reason };
181
- if (runtime.spec?.completion_gate !== undefined && runtime.spec.completion_gate !== true) return { ok: false, candidate, reason: 'runtime.spec.completion_gate is worker-writable and cannot authorize completion' };
182
- const status = candidate.spec.status;
183
- if (!['in_progress', 'in-progress', 'completed', 'complete'].includes(status)) return { ok: false, candidate, reason: `spec status is invalid: ${String(status)}` };
184
- const registry = candidate.spec.task_registry;
185
- if (!plain(registry) || Object.keys(registry).length === 0) return { ok: false, candidate, reason: 'task_registry is missing or empty' };
186
-
187
- let runtimeContext;
188
- try {
189
- runtimeContext = policy.deriveRuntimeContext({
190
- projectRoot,
191
- specsRoot: candidate.specsDir,
192
- specFile: candidate.specFile,
193
- featureName: candidate.featureName,
194
- runtimeSession: sessionId,
195
- });
196
- } catch (error) {
197
- return { ok: false, candidate, reason: `runtime provenance unavailable: ${error.message}` };
198
- }
199
- if (!policy.isTrustedRuntimeContext(runtimeContext)) return { ok: false, candidate, reason: 'fresh trusted runtime Base/Head/context is unavailable' };
200
-
201
- const featureDir = path.join(candidate.specsDir, candidate.featureName);
202
- const receiptBodies = new Map();
203
- const taskFiles = [];
204
- const failures = [];
205
- for (const [taskPath, task] of Object.entries(registry)) {
206
- if (!plain(task) || task.status !== 'done') {
207
- failures.push(`${taskPath}: status is not done`);
208
- continue;
209
- }
210
- if (policy.isStaleFlashDone(task)) failures.push(`${taskPath}: FLASH_UNVERIFIED`);
211
- const file = readTask(featureDir, taskPath);
212
- const body = file ? evidenceBody(file.text) : null;
213
- if (body !== null) receiptBodies.set(taskPath, body);
214
- const receiptFailures = checkReceipt(featureDir, taskPath, task, runtimeContext);
215
- if (receiptFailures.length) failures.push(`${taskPath}: checks ${receiptFailures.join(',')}`);
216
- if (file) taskFiles.push({ path: taskPath, digest: crypto.createHash('sha256').update(file.bytes).digest('hex') });
217
- }
218
- if (failures.length) return { ok: false, candidate, reason: `technical completion proof is incomplete (${failures.slice(0, 4).join('; ')})` };
219
-
220
- const firstTask = Object.keys(registry).find((taskPath) => receiptBodies.has(taskPath));
221
- let completion;
222
- try {
223
- completion = policy.completionDecisionForSpec(candidate.spec, {
224
- runtimeContext,
225
- executionReceipt: firstTask ? receiptBodies.get(firstTask) : null,
226
- taskContext: firstTask ? registry[firstTask] : null,
227
- });
228
- } catch (error) {
229
- return { ok: false, candidate, reason: `workflow completion proof could not be evaluated: ${error.message}` };
230
- }
231
- if (completion.completion !== 'complete' && completion.completion !== 'not_applicable') {
232
- return { ok: false, candidate, reason: completion.blocker || 'workflow completion proof is incomplete' };
233
- }
234
-
235
- const artifactRefs = [...receiptBodies.values()].flatMap(artifactReferences).sort((left, right) => `${left.path}:${left.hash}`.localeCompare(`${right.path}:${right.hash}`));
236
- const artifactFiles = artifactRefs.map((reference) => ({
237
- ...reference,
238
- digest: crypto.createHash('sha256').update(readBoundFile(projectRoot, reference.path) || Buffer.from('<missing>')).digest('hex'),
239
- }));
240
- const specBytes = readBoundFile(projectRoot, candidate.specFile);
241
- const binding = {
242
- project_root: runtimeContext.project_root,
243
- platform: process.platform,
244
- session_id: sessionId,
245
- feature_name: candidate.featureName,
246
- spec_file: candidate.specFile,
247
- runtime: { base: runtimeContext.base, head: runtimeContext.head, context_id: runtimeContext.context_id },
248
- spec_digest: STATE.digest(candidate.spec),
249
- spec_bytes_digest: crypto.createHash('sha256').update(specBytes || Buffer.from('<missing>')).digest('hex'),
250
- policy_digest: STATE.digest(candidate.spec.workflow_policy ?? null),
251
- task_registry_digest: STATE.digest(registry),
252
- task_digest: STATE.digest(taskFiles),
253
- artifact_digest: STATE.digest(artifactFiles),
254
- provenance_digest: STATE.digest({ runtime: runtimeContext, receipts: [...receiptBodies.values()].map((body) => body.match(/^\s*(?:Base|Head|base_sha|head_sha)\s*:.*$/gim) || []) }),
45
+ function dependencies(overrides = {}) {
46
+ if (!overrides.grounder) runtimeModule(scripts, 'spec-semantic-model.cjs');
47
+ const grounder = overrides.grounder || runtimeModule(scripts, 'spec-ground.cjs');
48
+ return {
49
+ validator: overrides.validator || runtimeModule(scripts, 'validate-spec-output.cjs'),
50
+ grounder,
51
+ semanticAuthority: overrides.semanticAuthority || require('./semantic-review-authority.cjs'),
255
52
  };
256
- binding.binding_digest = STATE.digest(binding);
257
- return { ok: true, candidate, runtimeContext, binding };
258
53
  }
259
54
 
260
- module.exports = { evaluateCloseout, resolveCandidate, observePolicyBaseline };
55
+ module.exports = FINAL_STATE.createAuthority({
56
+ state: STATE,
57
+ resolveCandidate,
58
+ dependencies,
59
+ receipts: {
60
+ task: (featureDir, taskPath, task, context) => checkReceiptDetails(featureDir, taskPath, task, context),
61
+ feature: (featureDir, context) => checkFeatureReceipt(featureDir, context),
62
+ },
63
+ });
@@ -10,7 +10,8 @@ const NONCE_RE = /^[a-f0-9]{24}$/;
10
10
  const HASH_RE = /^[a-f0-9]{64}$/;
11
11
  const TTL_MS = 5 * 60 * 1000;
12
12
  const KEY_BYTES = 32;
13
- const SCHEMA_VERSION = 2;
13
+ const SCHEMA_VERSION = 3;
14
+ const LEGACY_SCHEMA_VERSION = 2;
14
15
  const EPHEMERAL_FILE_RE = /^(pending|grant)-([a-f0-9]{24})\.json$/;
15
16
  const BASELINE_FILE_RE = /^baseline-([a-f0-9]{64})\.json$/;
16
17
  const FLOOR_FILE = 'policy-floor.json';
@@ -222,7 +223,7 @@ function validateEphemeral(record, kind, nonce, now, key) {
222
223
  : ['schema_version', 'kind', 'nonce', 'binding', 'issued_at', 'expires_at', 'approved_at', 'mac'];
223
224
  if (!plain(record) || !exactKeys(record, expected)) return { ok: false, reason: 'malformed authority record' };
224
225
  if (!macMatches(record, key)) return { ok: false, reason: 'authority record MAC is invalid' };
225
- if (record.schema_version !== SCHEMA_VERSION || record.kind !== kind || record.nonce !== nonce || !NONCE_RE.test(nonce)) return { ok: false, reason: 'authority record identity is invalid' };
226
+ if (![LEGACY_SCHEMA_VERSION, SCHEMA_VERSION].includes(record.schema_version) || record.kind !== kind || record.nonce !== nonce || !NONCE_RE.test(nonce)) return { ok: false, reason: 'authority record identity is invalid' };
226
227
  if (!plain(record.binding) || typeof record.binding.session_id !== 'string' || !record.binding.session_id.trim()) return { ok: false, reason: 'authority record binding is invalid' };
227
228
  if (!validTimestamp(record.issued_at) || !Number.isFinite(record.expires_at) || record.expires_at <= Date.parse(record.issued_at)) return { ok: false, reason: 'authority record timestamps are invalid' };
228
229
  if (kind === 'grant' && !validTimestamp(record.approved_at)) return { ok: false, reason: 'authority grant approval timestamp is invalid' };
@@ -234,7 +235,7 @@ function validateBaseline(record, filename, projectRoot, key) {
234
235
  const expected = ['schema_version', 'kind', 'identity', 'policy', 'issued_at', 'updated_at', 'mac'];
235
236
  if (!plain(record) || !exactKeys(record, expected)) return { ok: false, reason: 'malformed policy baseline' };
236
237
  if (!macMatches(record, key)) return { ok: false, reason: 'policy baseline MAC is invalid' };
237
- if (record.schema_version !== SCHEMA_VERSION || record.kind !== 'baseline' || !validTimestamp(record.issued_at) || !validTimestamp(record.updated_at)) return { ok: false, reason: 'policy baseline identity or timestamps are invalid' };
238
+ if (![LEGACY_SCHEMA_VERSION, SCHEMA_VERSION].includes(record.schema_version) || record.kind !== 'baseline' || !validTimestamp(record.issued_at) || !validTimestamp(record.updated_at)) return { ok: false, reason: 'policy baseline identity or timestamps are invalid' };
238
239
  let identity;
239
240
  try { identity = normalizeIdentity(record.identity, { allowMissingSpec: true }); } catch (error) { return { ok: false, reason: error.message }; }
240
241
  if (filename !== `baseline-${identityDigest(identity)}.json` || identity.project_root !== canonicalProjectRoot(projectRoot)) return { ok: false, reason: 'policy baseline namespace is invalid' };
@@ -246,7 +247,7 @@ function validatePolicyFloor(record, filename, key) {
246
247
  const expected = ['schema_version', 'kind', 'policy', 'issued_at', 'updated_at', 'mac'];
247
248
  if (!plain(record) || !exactKeys(record, expected)) return { ok: false, reason: 'malformed project policy floor' };
248
249
  if (!macMatches(record, key)) return { ok: false, reason: 'project policy floor MAC is invalid' };
249
- if (filename !== FLOOR_FILE || record.schema_version !== SCHEMA_VERSION || record.kind !== 'policy-floor' || !validTimestamp(record.issued_at) || !validTimestamp(record.updated_at)) {
250
+ if (filename !== FLOOR_FILE || ![LEGACY_SCHEMA_VERSION, SCHEMA_VERSION].includes(record.schema_version) || record.kind !== 'policy-floor' || !validTimestamp(record.issued_at) || !validTimestamp(record.updated_at)) {
250
251
  return { ok: false, reason: 'project policy floor identity or timestamps are invalid' };
251
252
  }
252
253
  if (!plain(record.policy)) return { ok: false, reason: 'project policy floor policy is malformed' };
@@ -344,10 +345,6 @@ function readState(projectRoot, now = Date.now()) {
344
345
  }
345
346
  if (!state.floor) {
346
347
  state.floorMissing = true;
347
- if (state.pending || state.grant || state.baselines.length > 0) {
348
- state.malformed = true;
349
- state.reason ||= 'project policy floor is missing';
350
- }
351
348
  }
352
349
  return state;
353
350
  }
@@ -509,6 +506,11 @@ module.exports = {
509
506
  FLOOR_FILE,
510
507
  stableStringify,
511
508
  digest,
509
+ canonicalProjectRoot,
510
+ atomicWrite,
511
+ readKey,
512
+ recordMac,
513
+ macMatches,
512
514
  userStateRoot,
513
515
  stateDir,
514
516
  clearState,
@@ -45,6 +45,7 @@ function approve(payload) {
45
45
  }
46
46
 
47
47
  function stop(payload) {
48
+ if (payload.stop_hook_active === true) return;
48
49
  const loaded = loadSharedPolicy();
49
50
  if (!loaded.policy) {
50
51
  emitBlock(`Completion authority unavailable: shared workflow policy could not be loaded (${loaded.error.message}). Repair ${loaded.path} before completing tasks`);
@@ -69,6 +70,7 @@ function stop(payload) {
69
70
  }
70
71
  return;
71
72
  }
73
+ if (result.active === false) return;
72
74
  if (!result.ok) {
73
75
  STATE.clearState(projectRoot);
74
76
  emitBlock(`Completion authority: ${result.reason}`);
@@ -84,13 +86,17 @@ let mode = process.argv[2] || null;
84
86
  try {
85
87
  const payload = readPayload();
86
88
  mode = mode || (payload.hook_event_name === 'UserPromptSubmit' ? '--approve' : '--stop');
87
- const dependencyError = loadAuthority();
88
- if (dependencyError) {
89
- if (mode === '--approve') context(`CafeKit completion approval failed safely; authority dependency unavailable (${dependencyError.message}).`);
90
- else emitBlock(`Completion authority unavailable: ${dependencyError.message}. Repair the installed authority before completing tasks`);
91
- } else if (mode === '--approve') approve(payload);
92
- else if (mode === '--stop') stop(payload);
93
- else throw new Error('completion authority mode is required');
89
+ if (mode === '--stop' && payload.stop_hook_active === true) {
90
+ // Host loop prevention is always silent, including when dependencies are unavailable.
91
+ } else {
92
+ const dependencyError = loadAuthority();
93
+ if (dependencyError) {
94
+ if (mode === '--approve') context(`CafeKit completion approval failed safely; authority dependency unavailable (${dependencyError.message}).`);
95
+ else emitBlock(`Completion authority unavailable: ${dependencyError.message}. Repair the installed authority before completing tasks`);
96
+ } else if (mode === '--approve') approve(payload);
97
+ else if (mode === '--stop') stop(payload);
98
+ else throw new Error('completion authority mode is required');
99
+ }
94
100
  } catch (error) {
95
101
  if (mode === '--approve') context(`CafeKit completion approval failed safely; no grant was issued (${error.message}).`);
96
102
  else {
@@ -5,7 +5,18 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
 
7
7
  // Installed layout: <project>/.codex/hooks/lib/hook-context.cjs
8
- const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
8
+ // Canonicalize installed project root via realpath where available; lexical fallback keeps source-tree tests working.
9
+ // This ensures a symlink project root (e.g., /tmp/link -> /real/project) is resolved to its real location,
10
+ // keeping assertConfiguredSpecsPath and state paths consistent with resolveSessionCwd's realpath.
11
+ // Installed-root authority stays PROJECT_ROOT (now canonical); do not change without updating state/approval paths.
12
+ const PROJECT_ROOT = (() => {
13
+ const lexical = path.resolve(__dirname, '..', '..', '..');
14
+ try {
15
+ return fs.realpathSync(lexical);
16
+ } catch {
17
+ return lexical;
18
+ }
19
+ })();
9
20
 
10
21
  function readPayload() {
11
22
  const raw = fs.readFileSync(0, 'utf8').trim();
@@ -104,7 +115,10 @@ function atomicWrite(file, content, mode = 0o600) {
104
115
 
105
116
  function logCrash(hook, error) {
106
117
  try {
107
- const dir = path.join(PROJECT_ROOT, '.codex', 'hooks', '.logs');
118
+ const isSourceTree = PROJECT_ROOT.includes(`${path.sep}packages${path.sep}spec${path.sep}src`);
119
+ const dir = isSourceTree
120
+ ? path.join(require('os').tmpdir(), 'cafekit-hook-logs', 'codex', hook)
121
+ : path.join(PROJECT_ROOT, '.codex', 'hooks', '.logs');
108
122
  fs.mkdirSync(dir, { recursive: true });
109
123
  fs.appendFileSync(
110
124
  path.join(dir, 'hook-log.jsonl'),
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('../../../claude/hooks/lib/privacy-command-analysis.cjs');
@@ -3,12 +3,36 @@
3
3
  const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
-
7
- const TTL_MS = 5 * 60 * 1000;
6
+ const PATH_SAFETY = require('./runtime-path-safety.cjs');
7
+
8
+ // Codex cannot raise an interactive prompt from a hook -- PreToolUse honours no
9
+ // "ask" decision -- so approval costs a full round trip: the agent has to relay
10
+ // a request id and the user has to type it back. A five-minute window expired
11
+ // mid-conversation, leaving the agent repeating a request id that could no
12
+ // longer be approved. An hour outlives a working session's train of thought.
13
+ const TTL_MS = 60 * 60 * 1000;
8
14
  const APPROVAL_PREFIX = 'APPROVE CAFEKIT PRIVACY ';
9
15
  function stateDir(projectRoot) {
10
16
  return path.join(projectRoot, '.codex', 'hooks', '.privacy');
11
17
  }
18
+ function secureStateDir(projectRoot, create = false) {
19
+ const root = PATH_SAFETY.canonicalDirectory(projectRoot, 'privacy project root');
20
+ const hooks = path.join(root, '.codex', 'hooks');
21
+ try { PATH_SAFETY.assertComponentPath(root, hooks); }
22
+ catch (error) {
23
+ if (!create && error.code === 'ENOENT') return null;
24
+ throw error;
25
+ }
26
+ const dir = path.join(hooks, '.privacy');
27
+ if (create && !fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700 });
28
+ try { PATH_SAFETY.assertComponentPath(root, dir); }
29
+ catch (error) {
30
+ if (!create && error.code === 'ENOENT') return null;
31
+ throw error;
32
+ }
33
+ if (!fs.lstatSync(dir).isDirectory()) throw new Error('privacy state must be a regular directory');
34
+ return dir;
35
+ }
12
36
  function hash(value) {
13
37
  return crypto.createHash('sha256').update(String(value)).digest('hex');
14
38
  }
@@ -44,16 +68,16 @@ function displayNames(filePaths) {
44
68
  : `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
45
69
  }
46
70
 
47
- function atomicWrite(file, payload) {
48
- const dir = path.dirname(file);
49
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+ function atomicWrite(dir, file, payload) {
72
+ PATH_SAFETY.assertComponentPath(dir, path.dirname(file));
50
73
  const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
51
74
  fs.writeFileSync(temp, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 });
52
75
  fs.renameSync(temp, file);
53
76
  }
54
77
 
55
- function readJson(file) {
78
+ function readJson(dir, file) {
56
79
  try {
80
+ PATH_SAFETY.assertComponentPath(dir, file);
57
81
  return JSON.parse(fs.readFileSync(file, 'utf8'));
58
82
  } catch {
59
83
  return null;
@@ -61,12 +85,12 @@ function readJson(file) {
61
85
  }
62
86
 
63
87
  function cleanup(projectRoot, now = Date.now()) {
64
- const dir = stateDir(projectRoot);
65
- if (!fs.existsSync(dir)) return;
88
+ const dir = secureStateDir(projectRoot);
89
+ if (!dir) return;
66
90
  for (const name of fs.readdirSync(dir)) {
67
91
  if (!/^(pending|token)-[a-f0-9]+\.json$/.test(name)) continue;
68
92
  const file = path.join(dir, name);
69
- const record = readJson(file);
93
+ const record = readJson(dir, file);
70
94
  if (!record || Number(record.expiresAt) <= now) {
71
95
  try {
72
96
  fs.unlinkSync(file);
@@ -78,9 +102,9 @@ function cleanup(projectRoot, now = Date.now()) {
78
102
  }
79
103
 
80
104
  function clearState(projectRoot) {
81
- const dir = stateDir(projectRoot);
82
105
  try {
83
- if (!fs.existsSync(dir)) return;
106
+ const dir = secureStateDir(projectRoot);
107
+ if (!dir) return;
84
108
  for (const name of fs.readdirSync(dir)) {
85
109
  if (/^(pending|token)-[a-f0-9]+\.json$/.test(name)) {
86
110
  try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently consumed */ }
@@ -96,8 +120,7 @@ function createPending({
96
120
  sessionCwd,
97
121
  sessionId,
98
122
  filePaths,
99
- filePath,
100
- toolName
123
+ filePath
101
124
  }) {
102
125
  cleanup(projectRoot);
103
126
  if (!sessionId) throw new Error('A Codex session ID is required');
@@ -110,10 +133,10 @@ function createPending({
110
133
  sessionId: String(sessionId || ''),
111
134
  pathKeys: requestPathKeys(paths, sessionCwd),
112
135
  displayName: displayNames(paths),
113
- toolName: String(toolName || ''),
114
136
  expiresAt: Date.now() + TTL_MS
115
137
  };
116
- atomicWrite(path.join(stateDir(projectRoot), `pending-${requestId}.json`), record);
138
+ const dir = secureStateDir(projectRoot, true);
139
+ atomicWrite(dir, path.join(dir, `pending-${requestId}.json`), record);
117
140
  return record;
118
141
  }
119
142
 
@@ -122,8 +145,10 @@ function approvePending({ projectRoot, sessionId, requestId }) {
122
145
  if (!sessionId) return { ok: false, reason: 'session' };
123
146
  if (!/^[a-f0-9]{24}$/.test(requestId)) return { ok: false, reason: 'invalid' };
124
147
 
125
- const pendingFile = path.join(stateDir(projectRoot), `pending-${requestId}.json`);
126
- const record = readJson(pendingFile);
148
+ const dir = secureStateDir(projectRoot);
149
+ if (!dir) return { ok: false, reason: 'missing' };
150
+ const pendingFile = path.join(dir, `pending-${requestId}.json`);
151
+ const record = readJson(dir, pendingFile);
127
152
  if (!record) return { ok: false, reason: 'missing' };
128
153
  if (record.sessionId !== String(sessionId || '')) return { ok: false, reason: 'session' };
129
154
  if (Number(record.expiresAt) <= Date.now()) return { ok: false, reason: 'expired' };
@@ -134,55 +159,53 @@ function approvePending({ projectRoot, sessionId, requestId }) {
134
159
  return { ok: false, reason: 'consumed' };
135
160
  }
136
161
 
137
- atomicWrite(path.join(stateDir(projectRoot), `token-${requestId}.json`), {
162
+ // The grant window starts when the user approves, not when the request was
163
+ // raised. Carrying the pending record's deadline over meant a late approval
164
+ // bought only the minutes left on it.
165
+ atomicWrite(dir, path.join(dir, `token-${requestId}.json`), {
138
166
  version: 2,
139
167
  requestId,
140
168
  sessionId: record.sessionId,
141
169
  pathKeys: record.pathKeys,
142
- toolName: record.toolName,
143
- expiresAt: Math.min(record.expiresAt, Date.now() + TTL_MS)
170
+ expiresAt: Date.now() + TTL_MS
144
171
  });
145
172
  return { ok: true, displayName: record.displayName };
146
173
  }
147
174
 
148
- function consumeToken({
175
+ /**
176
+ * A grant is session-bound, path-bound and time-limited, but not single-use:
177
+ * one approval covers repeated access to the same paths until it expires.
178
+ * Consuming it on first use made every re-read a fresh round trip, and the tool
179
+ * name is not part of the identity either -- reading a file with `cat` and with
180
+ * a native read is the same access to the same secret.
181
+ */
182
+ function hasGrant({
149
183
  projectRoot,
150
184
  sessionCwd,
151
185
  sessionId,
152
186
  filePaths,
153
- filePath,
154
- toolName
187
+ filePath
155
188
  }) {
156
189
  cleanup(projectRoot);
157
190
  if (!sessionId) return false;
158
- const dir = stateDir(projectRoot);
159
- if (!fs.existsSync(dir)) return false;
191
+ const dir = secureStateDir(projectRoot);
192
+ if (!dir) return false;
160
193
  const paths = requestPaths(filePaths, filePath);
161
194
  if (paths.length === 0) return false;
162
195
  const expectedPaths = requestPathKeys(paths, sessionCwd);
163
196
 
164
197
  for (const name of fs.readdirSync(dir)) {
165
198
  if (!/^token-[a-f0-9]{24}\.json$/.test(name)) continue;
166
- const tokenFile = path.join(dir, name);
167
- const token = readJson(tokenFile);
199
+ const token = readJson(dir, path.join(dir, name));
168
200
  if (
169
201
  !token ||
170
202
  token.sessionId !== String(sessionId || '') ||
171
203
  !samePathKeys(token.pathKeys, expectedPaths) ||
172
- token.toolName !== String(toolName || '') ||
173
204
  Number(token.expiresAt) <= Date.now()
174
205
  ) {
175
206
  continue;
176
207
  }
177
-
178
- const claimed = `${tokenFile}.claimed-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
179
- try {
180
- fs.renameSync(tokenFile, claimed);
181
- fs.unlinkSync(claimed);
182
- return true;
183
- } catch {
184
- // Only one concurrent tool invocation can rename and consume this token.
185
- }
208
+ return true;
186
209
  }
187
210
  return false;
188
211
  }
@@ -193,7 +216,7 @@ module.exports = {
193
216
  pathKey,
194
217
  createPending,
195
218
  approvePending,
196
- consumeToken,
219
+ hasGrant,
197
220
  cleanup,
198
221
  clearState
199
222
  };
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('../../../claude/hooks/lib/runtime-path-safety.cjs');