@haposoft/cafekit 0.14.2 → 0.15.1

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 (49) hide show
  1. package/README.md +80 -9
  2. package/bin/__tests__/codex-hooks.test.js +331 -0
  3. package/bin/__tests__/codex-native.test.js +458 -0
  4. package/bin/__tests__/installer-safety.test.js +115 -1
  5. package/bin/install.js +21 -7
  6. package/bin/lib/backup.js +23 -3
  7. package/bin/lib/codex-frontmatter.js +56 -0
  8. package/bin/lib/codex-install.js +195 -0
  9. package/bin/lib/context.js +65 -8
  10. package/bin/lib/copy-utils.js +1 -1
  11. package/bin/lib/i18n.js +12 -3
  12. package/bin/lib/managed-writer.js +17 -2
  13. package/bin/lib/manifest.js +21 -4
  14. package/bin/lib/opencode-install.js +21 -19
  15. package/bin/lib/path-safety.js +40 -0
  16. package/bin/lib/version-check.js +25 -12
  17. package/bin/phases/claude-runtime.js +3 -5
  18. package/bin/phases/codex-runtime.js +110 -0
  19. package/bin/phases/copy-payload.js +36 -16
  20. package/bin/phases/post-install.js +84 -47
  21. package/bin/phases/root-config.js +4 -2
  22. package/bin/phases/select-platform.js +92 -58
  23. package/bin/phases/summary.js +6 -1
  24. package/package.json +3 -2
  25. package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
  26. package/src/codex/AGENTS.md +30 -0
  27. package/src/codex/agents-gitignore +11 -0
  28. package/src/codex/gitignore +12 -0
  29. package/src/codex/hooks/agent.cjs +66 -0
  30. package/src/codex/hooks/docs-sync.cjs +66 -0
  31. package/src/codex/hooks/inspect-block.cjs +40 -0
  32. package/src/codex/hooks/lib/hook-context.cjs +93 -0
  33. package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
  34. package/src/codex/hooks/lib/privacy-state.cjs +199 -0
  35. package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
  36. package/src/codex/hooks/lib/spec-utils.cjs +50 -0
  37. package/src/codex/hooks/lib/state-store.cjs +166 -0
  38. package/src/codex/hooks/privacy-approval.cjs +59 -0
  39. package/src/codex/hooks/privacy-block.cjs +182 -0
  40. package/src/codex/hooks/rules.cjs +79 -0
  41. package/src/codex/hooks/session.cjs +114 -0
  42. package/src/codex/hooks/spec-gate.cjs +80 -0
  43. package/src/codex/hooks/spec-state.cjs +73 -0
  44. package/src/codex/hooks/state.cjs +157 -0
  45. package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
  46. package/src/codex/hooks.json +122 -0
  47. package/src/codex/rules/hook-protocols.md +32 -0
  48. package/src/codex/rules/state-sync.md +41 -0
  49. package/src/codex/runtime.json +28 -0
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const {
5
+ APPROVAL_PREFIX,
6
+ approvePending,
7
+ cleanup
8
+ } = require('./lib/privacy-state.cjs');
9
+ const {
10
+ getHookContext,
11
+ readPayload
12
+ } = require('./lib/hook-context.cjs');
13
+
14
+ function context(message) {
15
+ process.stdout.write(`${JSON.stringify({
16
+ hookSpecificOutput: {
17
+ hookEventName: 'UserPromptSubmit',
18
+ additionalContext: message
19
+ }
20
+ })}\n`);
21
+ }
22
+
23
+ try {
24
+ const data = readPayload();
25
+ if (!data) process.exit(0);
26
+ const { projectRoot } = getHookContext(data);
27
+ const prompt = String(data.prompt || '').trim();
28
+ cleanup(projectRoot);
29
+
30
+ if (!prompt.startsWith(APPROVAL_PREFIX)) process.exit(0);
31
+ if (!data.session_id) {
32
+ context('CafeKit privacy approval rejected: this hook invocation had no Codex session ID.');
33
+ process.exit(0);
34
+ }
35
+ const requestId = prompt.slice(APPROVAL_PREFIX.length);
36
+ if (`${APPROVAL_PREFIX}${requestId}` !== prompt || !/^[a-f0-9]{24}$/.test(requestId)) {
37
+ context('CafeKit privacy approval rejected: the prompt did not exactly match a pending request.');
38
+ process.exit(0);
39
+ }
40
+
41
+ const result = approvePending({
42
+ projectRoot,
43
+ sessionId: data.session_id,
44
+ requestId
45
+ });
46
+ if (!result.ok) {
47
+ context('CafeKit privacy approval rejected: request missing, expired, consumed, or from another session.');
48
+ process.exit(0);
49
+ }
50
+
51
+ context(
52
+ `User approved one retry for the pending sensitive access (${result.displayName}). ` +
53
+ `The token is session-bound, path-bound, tool-bound, time-limited, and consumed atomically.`
54
+ );
55
+ process.exit(0);
56
+ } catch {
57
+ context('CafeKit privacy approval failed safely; no token was issued.');
58
+ process.exit(0);
59
+ }
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const {
7
+ APPROVAL_PREFIX,
8
+ createPending,
9
+ consumeToken
10
+ } = require('./lib/privacy-state.cjs');
11
+ const {
12
+ getHookContext,
13
+ readPayload
14
+ } = require('./lib/hook-context.cjs');
15
+
16
+ const RESTRICTED = [
17
+ /^\.env(?:[.\[*?{]|$)/i,
18
+ /^credentials/i,
19
+ /secrets?\.(ya?ml|json)$/i,
20
+ /\.pem$/i,
21
+ /\.key$/i,
22
+ /\.p12$/i,
23
+ /\.pfx$/i,
24
+ /^id_(rsa|ed25519|ecdsa|dsa)$/i,
25
+ /\.netrc$/i,
26
+ /\.pgpass$/i,
27
+ /kubeconfig/i,
28
+ /\.keystore$/i,
29
+ /\.jks$/i,
30
+ /auth\.json$/i,
31
+ /token(s)?\.json$/i
32
+ ];
33
+
34
+ const EXEMPT = [/\.env\.(example|sample|template|test)$/i];
35
+
36
+ function isPathKey(key) {
37
+ return /^(?:file_?paths?|paths?|search_?paths?|director(?:y|ies)|cwd|root|locations?)$/i
38
+ .test(key);
39
+ }
40
+
41
+ function matchesAny(filePath, rules) {
42
+ const base = path.basename(filePath);
43
+ return rules.some((rule) => rule.test(base) || rule.test(filePath));
44
+ }
45
+
46
+ function isSafe(filePath) {
47
+ return matchesAny(filePath, EXEMPT);
48
+ }
49
+
50
+ function isSensitive(filePath) {
51
+ return matchesAny(filePath, RESTRICTED);
52
+ }
53
+
54
+ function resolveTarget(filePath, cwd) {
55
+ try {
56
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
57
+ return fs.realpathSync(requested);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
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);
70
+ }
71
+ }
72
+ return [...paths];
73
+ }
74
+
75
+ function extractPatchPaths(patch) {
76
+ return [...String(patch).matchAll(
77
+ /^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm
78
+ )]
79
+ .map((match) => match[1].trim())
80
+ .filter(Boolean);
81
+ }
82
+
83
+ function shellInput(input) {
84
+ if (typeof input?.command === 'string') return input.command;
85
+ if (typeof input?.cmd === 'string') return input.cmd;
86
+ return '';
87
+ }
88
+
89
+ function patchInput(input) {
90
+ if (typeof input === 'string') return input;
91
+ for (const key of ['command', 'patch', 'input']) {
92
+ if (typeof input?.[key] === 'string') return input[key];
93
+ }
94
+ return '';
95
+ }
96
+
97
+ function collectPathValues(value, key, out) {
98
+ if (typeof value === 'string' && isPathKey(key)) {
99
+ out.push(value);
100
+ return;
101
+ }
102
+ if (Array.isArray(value)) {
103
+ if (isPathKey(key)) out.push(...value.filter((item) => typeof item === 'string'));
104
+ return;
105
+ }
106
+ if (!value || typeof value !== 'object') return;
107
+ for (const [childKey, childValue] of Object.entries(value)) {
108
+ collectPathValues(childValue, childKey, out);
109
+ }
110
+ }
111
+
112
+ function extractPaths(toolName, input) {
113
+ const paths = [];
114
+ collectPathValues(input, '', paths);
115
+ const normalizedTool = String(toolName).toLowerCase();
116
+ if (['bash', 'exec_command', 'shell'].includes(normalizedTool)) {
117
+ paths.push(...extractCommandPaths(shellInput(input)));
118
+ } else if (['apply_patch', 'edit', 'write'].includes(normalizedTool)) {
119
+ paths.push(...extractPatchPaths(patchInput(input)));
120
+ }
121
+ return [...new Set(paths.filter(Boolean))];
122
+ }
123
+
124
+ function sensitiveRequest(filePath, cwd) {
125
+ const target = resolveTarget(filePath, cwd);
126
+ if (target) {
127
+ if (isSensitive(target) && !isSafe(target)) return true;
128
+ if (isSafe(target)) return false;
129
+ }
130
+ return !isSafe(filePath) && isSensitive(filePath);
131
+ }
132
+
133
+ function sensitivePaths(toolName, input, cwd) {
134
+ return extractPaths(toolName, input)
135
+ .filter((filePath) => sensitiveRequest(filePath, cwd));
136
+ }
137
+
138
+ function deny(reason) {
139
+ process.stdout.write(`${JSON.stringify({
140
+ hookSpecificOutput: {
141
+ hookEventName: 'PreToolUse',
142
+ permissionDecision: 'deny',
143
+ permissionDecisionReason: reason
144
+ }
145
+ })}\n`);
146
+ }
147
+
148
+ try {
149
+ const data = readPayload();
150
+ if (!data) process.exit(0);
151
+ const { projectRoot, sessionCwd, runtime } = getHookContext(data);
152
+ if (runtime.privacyBlock === false) process.exit(0);
153
+
154
+ const filePaths = sensitivePaths(data.tool_name || '', data.tool_input || {}, sessionCwd);
155
+ if (filePaths.length === 0) process.exit(0);
156
+ if (!data.session_id) {
157
+ deny(
158
+ 'Sensitive file access denied because this hook invocation had no Codex session ID. ' +
159
+ 'No approval request or token was created.'
160
+ );
161
+ process.exit(0);
162
+ }
163
+ const tokenInput = {
164
+ projectRoot,
165
+ sessionCwd,
166
+ sessionId: data.session_id,
167
+ filePaths,
168
+ toolName: data.tool_name
169
+ };
170
+ if (consumeToken(tokenInput)) process.exit(0);
171
+
172
+ const pending = createPending(tokenInput);
173
+ deny(
174
+ `Sensitive file access denied: ${pending.displayName}. ` +
175
+ `If you approve exactly one retry in this Codex session, send this exact user prompt: ` +
176
+ `${APPROVAL_PREFIX}${pending.requestId}`
177
+ );
178
+ process.exit(0);
179
+ } catch {
180
+ deny('Sensitive access could not be evaluated safely. No file content was read or logged.');
181
+ process.exit(0);
182
+ }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('crypto');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const {
8
+ atomicWrite,
9
+ getHookContext,
10
+ logCrash,
11
+ readPayload,
12
+ resolveProjectPath
13
+ } = require('./lib/hook-context.cjs');
14
+
15
+ const COOLDOWN_MS = 5 * 60 * 1000;
16
+
17
+ function cooldownFile(projectRoot, sessionId) {
18
+ const key = crypto.createHash('sha256')
19
+ .update(String(sessionId || 'unknown'))
20
+ .digest('hex')
21
+ .slice(0, 16);
22
+ return path.join(projectRoot, '.codex', 'hooks', '.logs', `rules-${key}.json`);
23
+ }
24
+
25
+ function recentlyInjected(file) {
26
+ try {
27
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
28
+ return Date.now() - Number(state.ts || 0) < COOLDOWN_MS;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ try {
35
+ const payload = readPayload();
36
+ if (!payload) process.exit(0);
37
+ const { projectRoot, runtime } = getHookContext(payload);
38
+ const cache = cooldownFile(projectRoot, payload.session_id);
39
+ if (recentlyInjected(cache)) process.exit(0);
40
+
41
+ const respondLang = runtime.locale?.responseLanguage || '';
42
+ const thinkLang = runtime.locale?.thinkingLanguage || (respondLang ? 'en' : '');
43
+ const plansPath = resolveProjectPath(projectRoot, runtime.paths?.plans, 'plans');
44
+ const docsPath = resolveProjectPath(projectRoot, runtime.paths?.docs, 'docs');
45
+ const lines = [];
46
+
47
+ if ((thinkLang && thinkLang !== respondLang) || respondLang) {
48
+ lines.push('## Language');
49
+ if (thinkLang && thinkLang !== respondLang) {
50
+ lines.push(`- Thinking: Use ${thinkLang} for reasoning.`);
51
+ }
52
+ if (respondLang) lines.push(`- Response: Respond in ${respondLang}.`);
53
+ lines.push('');
54
+ }
55
+
56
+ lines.push(
57
+ '## Rules',
58
+ `- Markdown: Plans → "${plansPath}/" | Docs → "${docsPath}/"`,
59
+ '- Do not create markdown outside those directories unless explicitly asked.',
60
+ `- docs.maxLoc: ${runtime.docs?.maxLoc || 800} lines per doc file`,
61
+ '- Follow YAGNI · KISS · DRY.',
62
+ '- Reports: concise; unresolved questions last.',
63
+ '',
64
+ '## Skill Routing',
65
+ '- Choose skills using `.codex/rules/skill-workflow-routing.md` and `.codex/rules/skill-domain-routing.md`.',
66
+ '- Inspect installed skills with `node .codex/scripts/generate-skill-catalog.cjs --skills` when needed.',
67
+ '- Explicit user requests override routing suggestions.',
68
+ '',
69
+ '## Modularization',
70
+ '- Consider splitting code files over 200 lines at real concern boundaries.',
71
+ '- Check existing modules first; use descriptive names and comments.',
72
+ '- Skip modularization for markdown, plain text, shell, config, and env files.'
73
+ );
74
+
75
+ process.stdout.write(`${lines.join('\n')}\n`);
76
+ atomicWrite(cache, `${JSON.stringify({ ts: Date.now() })}\n`);
77
+ } catch (error) {
78
+ logCrash('rules', error);
79
+ }
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+ const {
8
+ getHookContext,
9
+ logCrash,
10
+ readPayload
11
+ } = require('./lib/hook-context.cjs');
12
+ const { clearState } = require('./lib/privacy-state.cjs');
13
+
14
+ function readPackage(projectRoot) {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function detectProjectType(projectRoot, pkg) {
23
+ if (
24
+ fs.existsSync(path.join(projectRoot, 'pnpm-workspace.yaml'))
25
+ || fs.existsSync(path.join(projectRoot, 'lerna.json'))
26
+ || pkg.workspaces
27
+ ) return 'monorepo';
28
+ if (pkg.main || pkg.exports || pkg.module) return 'library';
29
+ return 'app';
30
+ }
31
+
32
+ function detectPackageManager(projectRoot) {
33
+ const locks = [
34
+ ['pnpm-lock.yaml', 'pnpm'],
35
+ ['yarn.lock', 'yarn'],
36
+ ['bun.lockb', 'bun'],
37
+ ['bun.lock', 'bun'],
38
+ ['package-lock.json', 'npm']
39
+ ];
40
+ return locks.find(([file]) => fs.existsSync(path.join(projectRoot, file)))?.[1] || '';
41
+ }
42
+
43
+ function detectFramework(pkg) {
44
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
45
+ const candidates = [
46
+ ['next', 'next'],
47
+ ['nuxt', 'nuxt'],
48
+ ['@sveltejs/kit', 'sveltekit'],
49
+ ['react', 'react'],
50
+ ['vue', 'vue'],
51
+ ['svelte', 'svelte'],
52
+ ['express', 'express'],
53
+ ['fastify', 'fastify'],
54
+ ['hono', 'hono']
55
+ ];
56
+ return candidates.find(([dependency]) => deps[dependency])?.[1] || '';
57
+ }
58
+
59
+ function gitBranch(projectRoot) {
60
+ try {
61
+ return execFileSync('git', ['branch', '--show-current'], {
62
+ cwd: projectRoot,
63
+ encoding: 'utf8',
64
+ timeout: 3000,
65
+ stdio: ['ignore', 'pipe', 'ignore']
66
+ }).trim();
67
+ } catch {
68
+ return '';
69
+ }
70
+ }
71
+
72
+ try {
73
+ const payload = readPayload();
74
+ if (!payload) process.exit(0);
75
+ const { projectRoot, runtime, sessionCwd } = getHookContext(payload);
76
+ const pkg = readPackage(projectRoot);
77
+ const configured = runtime.project || {};
78
+ const projectType = configured.type && configured.type !== 'auto'
79
+ ? configured.type
80
+ : detectProjectType(projectRoot, pkg);
81
+ const packageManager = configured.packageManager && configured.packageManager !== 'auto'
82
+ ? configured.packageManager
83
+ : detectPackageManager(projectRoot);
84
+ const framework = configured.framework && configured.framework !== 'auto'
85
+ ? configured.framework
86
+ : detectFramework(pkg);
87
+ const branch = gitBranch(projectRoot);
88
+
89
+ // Approval tokens never survive a session boundary or compaction.
90
+ clearState(projectRoot);
91
+
92
+ const parts = [
93
+ projectType && `Type: ${projectType}`,
94
+ packageManager && `PM: ${packageManager}`,
95
+ framework && `Framework: ${framework}`,
96
+ branch && `Branch: ${branch}`
97
+ ].filter(Boolean);
98
+ process.stdout.write(
99
+ `Session ${payload.source || 'unknown'}. ${parts.join(' | ') || 'No project info detected.'}\n`
100
+ );
101
+
102
+ if (sessionCwd !== projectRoot) {
103
+ process.stdout.write(
104
+ `CafeKit project root: ${projectRoot} (session working directory: ${sessionCwd}).\n`
105
+ );
106
+ }
107
+ if (payload.source === 'compact') {
108
+ process.stdout.write(
109
+ 'Session compacted. Pending CafeKit privacy approvals were invalidated; request fresh user approval if needed.\n'
110
+ );
111
+ }
112
+ } catch (error) {
113
+ logCrash('session', error);
114
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const {
7
+ atomicWrite,
8
+ getHookContext,
9
+ logCrash,
10
+ readPayload
11
+ } = require('./lib/hook-context.cjs');
12
+ const { checkReceipt } = require('./lib/spec-receipt.cjs');
13
+ const { findActiveSpec, taskStatusMap } = require('./lib/spec-utils.cjs');
14
+
15
+ try {
16
+ const payload = readPayload();
17
+ if (!payload || payload.stop_hook_active === true) process.exit(0);
18
+ const { projectRoot, runtime } = getHookContext(payload);
19
+ if (runtime.spec?.completion_gate === false) process.exit(0);
20
+ const active = findActiveSpec(projectRoot, runtime);
21
+ if (!active) process.exit(0);
22
+
23
+ const registry = active.spec.task_registry || {};
24
+ const currentStatuses = taskStatusMap(active.spec);
25
+ const cacheFile = path.join(
26
+ projectRoot,
27
+ '.codex',
28
+ 'hooks',
29
+ '.logs',
30
+ 'spec-gate-last.json'
31
+ );
32
+ const cacheExists = fs.existsSync(cacheFile);
33
+ let cache = {};
34
+ if (cacheExists) {
35
+ try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch { cache = {}; }
36
+ }
37
+
38
+ if (!cacheExists) {
39
+ cache[active.featureName] = currentStatuses;
40
+ atomicWrite(cacheFile, `${JSON.stringify(cache)}\n`);
41
+ process.exit(0);
42
+ }
43
+
44
+ const previous = cache[active.featureName] || {};
45
+ const newlyDone = Object.keys(registry).filter((taskPath) => (
46
+ currentStatuses[taskPath] === 'done' && previous[taskPath] !== 'done'
47
+ ));
48
+ const featureDir = path.join(active.specsDir, active.featureName);
49
+ const failures = newlyDone
50
+ .map((taskPath) => ({
51
+ taskPath,
52
+ failures: checkReceipt(featureDir, taskPath, registry[taskPath])
53
+ }))
54
+ .filter((result) => result.failures.length);
55
+
56
+ const next = { ...previous, ...currentStatuses };
57
+ for (const result of failures) {
58
+ if (previous[result.taskPath] === undefined) delete next[result.taskPath];
59
+ else next[result.taskPath] = previous[result.taskPath];
60
+ }
61
+ cache[active.featureName] = next;
62
+ atomicWrite(cacheFile, `${JSON.stringify(cache)}\n`);
63
+
64
+ if (!failures.length) process.exit(0);
65
+ const lines = [
66
+ `Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`
67
+ ];
68
+ for (const result of failures) {
69
+ lines.push(
70
+ `- \`${result.taskPath}\`: failed check(s) ${result.failures.join(', ')}`,
71
+ ` Add \`Verification: PASS\`, commands, and successful outcomes under \`## Evidence\` in \`specs/${active.featureName}/${result.taskPath}\`, then re-sync spec.json.`
72
+ );
73
+ }
74
+ process.stdout.write(`${JSON.stringify({
75
+ decision: 'block',
76
+ reason: lines.slice(0, 8).join('\n')
77
+ })}\n`);
78
+ } catch (error) {
79
+ logCrash('spec-gate', error);
80
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('crypto');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const {
8
+ atomicWrite,
9
+ getHookContext,
10
+ logCrash,
11
+ readPayload
12
+ } = require('./lib/hook-context.cjs');
13
+ const { findActiveSpec } = require('./lib/spec-utils.cjs');
14
+
15
+ function cacheFile(projectRoot, sessionId) {
16
+ const key = crypto.createHash('sha256')
17
+ .update(String(sessionId || 'unknown'))
18
+ .digest('hex')
19
+ .slice(0, 16);
20
+ return path.join(projectRoot, '.codex', 'hooks', '.logs', `tollgate-${key}.txt`);
21
+ }
22
+
23
+ try {
24
+ const payload = readPayload();
25
+ if (!payload) process.exit(0);
26
+ const { projectRoot, runtime } = getHookContext(payload);
27
+ if (runtime.spec?.tollgate === false) process.exit(0);
28
+ const active = findActiveSpec(projectRoot, runtime);
29
+ if (!active) process.exit(0);
30
+
31
+ const phase = active.spec.current_phase || active.spec.phase || 'unknown';
32
+ const tasks = Object.entries(active.spec.task_registry || {});
33
+ const counts = tasks.reduce((result, [, task]) => {
34
+ const status = task?.status || 'pending';
35
+ result[status] = (result[status] || 0) + 1;
36
+ return result;
37
+ }, {});
38
+ const statuses = new Map(tasks.map(([taskPath, task]) => [
39
+ taskPath,
40
+ task?.status || 'pending'
41
+ ]));
42
+ const next = tasks.find(([, task]) => (
43
+ (task?.status || 'pending') === 'pending'
44
+ && (task?.dependencies || []).every((dependency) => statuses.get(dependency) === 'done')
45
+ ));
46
+ const stateKey = `${active.featureName}|${phase}|${counts.done || 0}/${tasks.length}`;
47
+ const cache = cacheFile(projectRoot, payload.session_id);
48
+ let previous = '';
49
+ try { previous = fs.readFileSync(cache, 'utf8').trim(); } catch { /* first run */ }
50
+
51
+ if (previous === stateKey) {
52
+ process.stdout.write(
53
+ `> Spec \`${active.featureName}\` @ \`${phase}\` `
54
+ + `(${counts.done || 0}/${tasks.length} tasks done). Tollgate active.\n`
55
+ );
56
+ process.exit(0);
57
+ }
58
+
59
+ atomicWrite(cache, `${stateKey}\n`);
60
+ const lines = [
61
+ `### Spec state changed: \`${active.featureName}\``,
62
+ `- Phase: \`${phase}\` | Tasks: ${counts.done || 0} done / ${tasks.length} total`
63
+ ];
64
+ if (next) lines.push(`- Next unblocked: \`${next[0]}\``);
65
+ lines.push(
66
+ '- Sync `spec.json` and the task file only after verified work.',
67
+ `- Validate with \`node .codex/scripts/validate-spec-output.cjs specs/${active.featureName}\`.`,
68
+ '- The Stop completion gate checks receipts for newly-done tasks.'
69
+ );
70
+ process.stdout.write(`${lines.join('\n')}\n`);
71
+ } catch (error) {
72
+ logCrash('spec-state', error);
73
+ }