@haposoft/cafekit 0.16.0-rc.3 → 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 (91) hide show
  1. package/bin/__tests__/codex-hooks.test.js +444 -14
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +615 -185
  4. package/bin/__tests__/installer-safety.test.js +39 -0
  5. package/bin/__tests__/opencode-spec-gate.test.js +24 -16
  6. package/bin/__tests__/package-inventory.test.js +687 -13
  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 +1914 -221
  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 +143 -99
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +291 -58
  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 +115 -40
  22. package/src/claude/hooks/completion-authority-check.cjs +37 -288
  23. package/src/claude/hooks/completion-authority-state.cjs +10 -8
  24. package/src/claude/hooks/completion-authority.cjs +13 -7
  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/spec-gate.cjs +47 -117
  31. package/src/claude/hooks/spec-state.cjs +8 -2
  32. package/src/claude/migration-manifest.json +9 -1
  33. package/src/claude/rules/state-sync.md +52 -21
  34. package/src/claude/rules/workflow.md +17 -9
  35. package/src/claude/scripts/spec-final-state.cjs +244 -0
  36. package/src/claude/scripts/spec-ground.cjs +535 -173
  37. package/src/claude/scripts/spec-readiness.cjs +118 -0
  38. package/src/claude/scripts/spec-receipt.cjs +162 -0
  39. package/src/claude/scripts/spec-resolver.cjs +67 -16
  40. package/src/claude/scripts/spec-scaffold.cjs +1354 -139
  41. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  42. package/src/claude/scripts/validate-spec-output.cjs +2782 -142
  43. package/src/claude/scripts/workflow-policy.cjs +501 -183
  44. package/src/claude/settings/settings.json +4 -0
  45. package/src/claude/skills/code-review/SKILL.md +12 -3
  46. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  47. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  48. package/src/claude/skills/develop/SKILL.md +47 -60
  49. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  50. package/src/claude/skills/develop/references/quality-gate.md +19 -12
  51. package/src/claude/skills/specs/SKILL.md +183 -177
  52. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  53. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  54. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  55. package/src/claude/skills/specs/references/review.md +200 -260
  56. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  57. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  58. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  59. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  60. package/src/claude/skills/specs/rules/design-review.md +42 -97
  61. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  62. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  63. package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
  64. package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
  65. package/src/claude/skills/specs/templates/design.md +49 -263
  66. package/src/claude/skills/specs/templates/requirements.md +26 -36
  67. package/src/claude/skills/specs/templates/research.md +9 -98
  68. package/src/claude/skills/specs/templates/spec-state.json +23 -85
  69. package/src/claude/skills/specs/templates/task.md +30 -102
  70. package/src/claude/skills/sync/SKILL.md +33 -10
  71. package/src/claude/skills/sync/references/sync-protocols.md +31 -13
  72. package/src/claude/skills/test/SKILL.md +17 -7
  73. package/src/codex/AGENTS.md +6 -0
  74. package/src/codex/hooks/completion-authority-check.cjs +43 -240
  75. package/src/codex/hooks/completion-authority-state.cjs +10 -8
  76. package/src/codex/hooks/completion-authority.cjs +13 -7
  77. package/src/codex/hooks/lib/hook-context.cjs +16 -2
  78. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  79. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  80. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  81. package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
  82. package/src/codex/hooks/lib/spec-utils.cjs +56 -5
  83. package/src/codex/hooks/privacy-approval.cjs +1 -1
  84. package/src/codex/hooks/privacy-block.cjs +67 -11
  85. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  86. package/src/codex/hooks/spec-gate.cjs +49 -34
  87. package/src/codex/hooks/spec-state.cjs +10 -3
  88. package/src/codex/hooks/state.cjs +2 -0
  89. package/src/codex/hooks.json +6 -1
  90. package/src/codex/rules/state-sync.md +47 -27
  91. 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,31 @@
3
3
  const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const PATH_SAFETY = require('./runtime-path-safety.cjs');
6
7
 
7
8
  const TTL_MS = 5 * 60 * 1000;
8
9
  const APPROVAL_PREFIX = 'APPROVE CAFEKIT PRIVACY ';
9
10
  function stateDir(projectRoot) {
10
11
  return path.join(projectRoot, '.codex', 'hooks', '.privacy');
11
12
  }
13
+ function secureStateDir(projectRoot, create = false) {
14
+ const root = PATH_SAFETY.canonicalDirectory(projectRoot, 'privacy project root');
15
+ const hooks = path.join(root, '.codex', 'hooks');
16
+ try { PATH_SAFETY.assertComponentPath(root, hooks); }
17
+ catch (error) {
18
+ if (!create && error.code === 'ENOENT') return null;
19
+ throw error;
20
+ }
21
+ const dir = path.join(hooks, '.privacy');
22
+ if (create && !fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700 });
23
+ try { PATH_SAFETY.assertComponentPath(root, dir); }
24
+ catch (error) {
25
+ if (!create && error.code === 'ENOENT') return null;
26
+ throw error;
27
+ }
28
+ if (!fs.lstatSync(dir).isDirectory()) throw new Error('privacy state must be a regular directory');
29
+ return dir;
30
+ }
12
31
  function hash(value) {
13
32
  return crypto.createHash('sha256').update(String(value)).digest('hex');
14
33
  }
@@ -44,16 +63,16 @@ function displayNames(filePaths) {
44
63
  : `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
45
64
  }
46
65
 
47
- function atomicWrite(file, payload) {
48
- const dir = path.dirname(file);
49
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
66
+ function atomicWrite(dir, file, payload) {
67
+ PATH_SAFETY.assertComponentPath(dir, path.dirname(file));
50
68
  const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
51
69
  fs.writeFileSync(temp, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 });
52
70
  fs.renameSync(temp, file);
53
71
  }
54
72
 
55
- function readJson(file) {
73
+ function readJson(dir, file) {
56
74
  try {
75
+ PATH_SAFETY.assertComponentPath(dir, file);
57
76
  return JSON.parse(fs.readFileSync(file, 'utf8'));
58
77
  } catch {
59
78
  return null;
@@ -61,12 +80,12 @@ function readJson(file) {
61
80
  }
62
81
 
63
82
  function cleanup(projectRoot, now = Date.now()) {
64
- const dir = stateDir(projectRoot);
65
- if (!fs.existsSync(dir)) return;
83
+ const dir = secureStateDir(projectRoot);
84
+ if (!dir) return;
66
85
  for (const name of fs.readdirSync(dir)) {
67
86
  if (!/^(pending|token)-[a-f0-9]+\.json$/.test(name)) continue;
68
87
  const file = path.join(dir, name);
69
- const record = readJson(file);
88
+ const record = readJson(dir, file);
70
89
  if (!record || Number(record.expiresAt) <= now) {
71
90
  try {
72
91
  fs.unlinkSync(file);
@@ -78,9 +97,9 @@ function cleanup(projectRoot, now = Date.now()) {
78
97
  }
79
98
 
80
99
  function clearState(projectRoot) {
81
- const dir = stateDir(projectRoot);
82
100
  try {
83
- if (!fs.existsSync(dir)) return;
101
+ const dir = secureStateDir(projectRoot);
102
+ if (!dir) return;
84
103
  for (const name of fs.readdirSync(dir)) {
85
104
  if (/^(pending|token)-[a-f0-9]+\.json$/.test(name)) {
86
105
  try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently consumed */ }
@@ -113,7 +132,8 @@ function createPending({
113
132
  toolName: String(toolName || ''),
114
133
  expiresAt: Date.now() + TTL_MS
115
134
  };
116
- atomicWrite(path.join(stateDir(projectRoot), `pending-${requestId}.json`), record);
135
+ const dir = secureStateDir(projectRoot, true);
136
+ atomicWrite(dir, path.join(dir, `pending-${requestId}.json`), record);
117
137
  return record;
118
138
  }
119
139
 
@@ -122,8 +142,10 @@ function approvePending({ projectRoot, sessionId, requestId }) {
122
142
  if (!sessionId) return { ok: false, reason: 'session' };
123
143
  if (!/^[a-f0-9]{24}$/.test(requestId)) return { ok: false, reason: 'invalid' };
124
144
 
125
- const pendingFile = path.join(stateDir(projectRoot), `pending-${requestId}.json`);
126
- const record = readJson(pendingFile);
145
+ const dir = secureStateDir(projectRoot);
146
+ if (!dir) return { ok: false, reason: 'missing' };
147
+ const pendingFile = path.join(dir, `pending-${requestId}.json`);
148
+ const record = readJson(dir, pendingFile);
127
149
  if (!record) return { ok: false, reason: 'missing' };
128
150
  if (record.sessionId !== String(sessionId || '')) return { ok: false, reason: 'session' };
129
151
  if (Number(record.expiresAt) <= Date.now()) return { ok: false, reason: 'expired' };
@@ -134,7 +156,7 @@ function approvePending({ projectRoot, sessionId, requestId }) {
134
156
  return { ok: false, reason: 'consumed' };
135
157
  }
136
158
 
137
- atomicWrite(path.join(stateDir(projectRoot), `token-${requestId}.json`), {
159
+ atomicWrite(dir, path.join(dir, `token-${requestId}.json`), {
138
160
  version: 2,
139
161
  requestId,
140
162
  sessionId: record.sessionId,
@@ -155,8 +177,8 @@ function consumeToken({
155
177
  }) {
156
178
  cleanup(projectRoot);
157
179
  if (!sessionId) return false;
158
- const dir = stateDir(projectRoot);
159
- if (!fs.existsSync(dir)) return false;
180
+ const dir = secureStateDir(projectRoot);
181
+ if (!dir) return false;
160
182
  const paths = requestPaths(filePaths, filePath);
161
183
  if (paths.length === 0) return false;
162
184
  const expectedPaths = requestPathKeys(paths, sessionCwd);
@@ -164,7 +186,7 @@ function consumeToken({
164
186
  for (const name of fs.readdirSync(dir)) {
165
187
  if (!/^token-[a-f0-9]{24}\.json$/.test(name)) continue;
166
188
  const tokenFile = path.join(dir, name);
167
- const token = readJson(tokenFile);
189
+ const token = readJson(dir, tokenFile);
168
190
  if (
169
191
  !token ||
170
192
  token.sessionId !== String(sessionId || '') ||
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('../../../claude/hooks/lib/runtime-path-safety.cjs');