@haposoft/cafekit 0.14.1 → 0.15.0

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 (100) hide show
  1. package/README.md +86 -11
  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 +259 -0
  5. package/bin/install.js +30 -8
  6. package/bin/lib/backup.js +82 -18
  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 +60 -9
  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 +98 -48
  21. package/bin/phases/root-config.js +4 -2
  22. package/bin/phases/select-platform.js +105 -66
  23. package/bin/phases/summary.js +6 -1
  24. package/package.json +3 -2
  25. package/src/claude/CLAUDE.md +1 -1
  26. package/src/claude/agents/code-auditor.md +1 -1
  27. package/src/claude/agents/debugger.md +1 -0
  28. package/src/claude/agents/god-developer.md +10 -1
  29. package/src/claude/agents/inspector.md +1 -0
  30. package/src/claude/agents/spec-maker.md +1 -1
  31. package/src/claude/agents/test-runner.md +2 -2
  32. package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
  33. package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
  34. package/src/claude/hooks/__tests__/state.test.js +65 -0
  35. package/src/claude/hooks/docs-sync.cjs +15 -21
  36. package/src/claude/hooks/inspect-block.cjs +1 -1
  37. package/src/claude/hooks/lib/config.cjs +0 -5
  38. package/src/claude/hooks/privacy-block.cjs +39 -62
  39. package/src/claude/hooks/spec-gate.cjs +18 -7
  40. package/src/claude/hooks/spec-state.cjs +4 -0
  41. package/src/claude/hooks/state.cjs +13 -3
  42. package/src/claude/hooks/usage.cjs +6 -0
  43. package/src/claude/rules/orchestrator.md +2 -2
  44. package/src/claude/rules/skill-domain-routing.md +27 -86
  45. package/src/claude/rules/skill-workflow-routing.md +11 -82
  46. package/src/claude/rules/state-sync.md +1 -1
  47. package/src/claude/runtime.json +7 -7
  48. package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
  49. package/src/claude/scripts/validate-spec-output.cjs +6 -0
  50. package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
  51. package/src/claude/skills/develop/SKILL.md +26 -5
  52. package/src/claude/skills/develop/references/parallel-waves.md +112 -0
  53. package/src/claude/skills/develop/references/quality-gate.md +10 -2
  54. package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
  55. package/src/claude/skills/inspect/SKILL.md +42 -113
  56. package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
  57. package/src/claude/skills/specs/SKILL.md +134 -386
  58. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  59. package/src/claude/skills/specs/templates/task.md +3 -1
  60. package/src/claude/skills/sync/SKILL.md +2 -2
  61. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  62. package/src/claude/skills/test/SKILL.md +1 -1
  63. package/src/claude/status.cjs +9 -6
  64. package/src/codex/AGENTS.md +30 -0
  65. package/src/codex/agents-gitignore +11 -0
  66. package/src/codex/gitignore +12 -0
  67. package/src/codex/hooks/agent.cjs +66 -0
  68. package/src/codex/hooks/docs-sync.cjs +66 -0
  69. package/src/codex/hooks/inspect-block.cjs +40 -0
  70. package/src/codex/hooks/lib/hook-context.cjs +93 -0
  71. package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
  72. package/src/codex/hooks/lib/privacy-state.cjs +199 -0
  73. package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
  74. package/src/codex/hooks/lib/spec-utils.cjs +50 -0
  75. package/src/codex/hooks/lib/state-store.cjs +166 -0
  76. package/src/codex/hooks/privacy-approval.cjs +59 -0
  77. package/src/codex/hooks/privacy-block.cjs +182 -0
  78. package/src/codex/hooks/rules.cjs +79 -0
  79. package/src/codex/hooks/session.cjs +114 -0
  80. package/src/codex/hooks/spec-gate.cjs +80 -0
  81. package/src/codex/hooks/spec-state.cjs +73 -0
  82. package/src/codex/hooks/state.cjs +157 -0
  83. package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
  84. package/src/codex/hooks.json +122 -0
  85. package/src/codex/rules/hook-protocols.md +32 -0
  86. package/src/codex/rules/state-sync.md +41 -0
  87. package/src/codex/runtime.json +28 -0
  88. package/src/opencode/plugins/docs-sync.ts +10 -16
  89. package/src/claude/archive-command/code/SKILL.md +0 -55
  90. package/src/claude/archive-command/code/references/execution-loop.md +0 -21
  91. package/src/claude/archive-command/code.md +0 -17
  92. package/src/claude/archive-command/docs.md +0 -609
  93. package/src/claude/archive-command/review/codebase/parallel.md +0 -76
  94. package/src/claude/archive-command/spec-design.md +0 -247
  95. package/src/claude/archive-command/spec-init.md +0 -120
  96. package/src/claude/archive-command/spec-requirements.md +0 -138
  97. package/src/claude/archive-command/spec-status.md +0 -98
  98. package/src/claude/archive-command/spec-tasks.md +0 -173
  99. package/src/claude/archive-command/spec-validate.md +0 -118
  100. package/src/claude/archive-command/test.md +0 -8
@@ -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
+ }
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const {
7
+ getHookContext,
8
+ logCrash,
9
+ readPayload
10
+ } = require('./lib/hook-context.cjs');
11
+ const {
12
+ archive,
13
+ readData,
14
+ refreshGit,
15
+ stateDir,
16
+ withStateLock,
17
+ writeData
18
+ } = require('./lib/state-store.cjs');
19
+
20
+ const EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
21
+
22
+ function summary(value, limit = 600) {
23
+ if (value === null || value === undefined) return '';
24
+ let text = '';
25
+ if (typeof value === 'string') {
26
+ text = value;
27
+ } else if (typeof value === 'object') {
28
+ const preferred = value.message ?? value.output ?? value.result ?? value.content;
29
+ text = typeof preferred === 'string' ? preferred : JSON.stringify(value);
30
+ } else {
31
+ text = String(value);
32
+ }
33
+ const clean = text.replace(/\0/g, '').trim();
34
+ return clean.length > limit ? `${clean.slice(0, limit)}…` : clean;
35
+ }
36
+
37
+ function normalizeTodos(items) {
38
+ if (!Array.isArray(items)) return [];
39
+ return items.map((item, index) => ({
40
+ id: String(item?.id ?? item?.task_id ?? item?.taskId ?? index),
41
+ content: summary(item?.step ?? item?.content ?? item?.subject ?? item?.title, 240),
42
+ status: String(item?.status || 'pending')
43
+ })).filter((item) => item.content);
44
+ }
45
+
46
+ function updateTaskState(data, input, response) {
47
+ const completeList = input.plan ?? input.todos ?? input.tasks
48
+ ?? response?.plan ?? response?.todos ?? response?.tasks;
49
+ if (Array.isArray(completeList)) {
50
+ data.todos = normalizeTodos(completeList);
51
+ return;
52
+ }
53
+
54
+ const id = input.task_id ?? input.taskId ?? input.id;
55
+ const content = summary(input.step ?? input.content ?? input.subject ?? input.title, 240);
56
+ const status = input.status;
57
+ if (!id && !content) return;
58
+ const todos = Array.isArray(data.todos) ? data.todos : [];
59
+ const index = todos.findIndex((todo) => (
60
+ (id !== undefined && String(todo.id) === String(id))
61
+ || (content && todo.content === content)
62
+ ));
63
+ const next = {
64
+ id: String(id ?? content),
65
+ content: content || todos[index]?.content || `Task ${id}`,
66
+ status: String(status || todos[index]?.status || 'pending')
67
+ };
68
+ if (index >= 0) todos[index] = next;
69
+ else todos.push(next);
70
+ data.todos = todos;
71
+ }
72
+
73
+ function isTrackedTool(toolName) {
74
+ const name = String(toolName || '').toLowerCase();
75
+ return name === 'agent'
76
+ || name === 'spawn_agent'
77
+ || name === 'update_plan'
78
+ || name.includes('task')
79
+ || name.includes('todo');
80
+ }
81
+
82
+ function loadPrevious(projectRoot, sessionId) {
83
+ try {
84
+ const dir = stateDir(projectRoot, sessionId);
85
+ if (!dir) return '';
86
+ const file = path.join(dir, 'latest.md');
87
+ if (!fs.existsSync(file)) return '';
88
+ const text = fs.readFileSync(file, 'utf8');
89
+ const generated = text.match(/<!-- Generated: (.+?) -->/)?.[1];
90
+ if (!generated || Date.now() - Date.parse(generated) > EXPIRY_MS) return '';
91
+ return text;
92
+ } catch {
93
+ return '';
94
+ }
95
+ }
96
+
97
+ try {
98
+ const payload = readPayload();
99
+ if (!payload) process.exit(0);
100
+ const { projectRoot } = getHookContext(payload);
101
+ const event = payload.hook_event_name || '';
102
+ const sessionId = payload.session_id;
103
+
104
+ if (event === 'SessionStart') {
105
+ const previous = loadPrevious(projectRoot, sessionId);
106
+ if (previous) {
107
+ process.stdout.write(
108
+ `=== Prior Execution Context ===\n${previous.trim()}\n=== End of Prior Context ===\n`
109
+ );
110
+ }
111
+ process.exit(0);
112
+ }
113
+
114
+ if (event === 'PostToolUse') {
115
+ const toolName = payload.tool_name || '';
116
+ if (!isTrackedTool(toolName)) process.exit(0);
117
+ withStateLock(projectRoot, sessionId, (dir) => {
118
+ const data = readData(dir);
119
+ const input = payload.tool_input || {};
120
+ const response = payload.tool_response;
121
+ updateTaskState(data, input, response);
122
+ data.lastToolEvent = `${toolName}: ${summary(response) || summary(input)}`.slice(0, 700);
123
+ refreshGit(data, projectRoot);
124
+ writeData(dir, data);
125
+ });
126
+ process.exit(0);
127
+ }
128
+
129
+ if (event === 'SubagentStop') {
130
+ withStateLock(projectRoot, sessionId, (dir) => {
131
+ const data = readData(dir);
132
+ const id = String(payload.agent_id || 'unknown');
133
+ data.agentResults = (data.agentResults || [])
134
+ .filter((agent) => String(agent.id) !== id);
135
+ data.agentResults.push({
136
+ id,
137
+ type: String(payload.agent_type || 'unknown'),
138
+ message: summary(payload.last_assistant_message)
139
+ });
140
+ refreshGit(data, projectRoot);
141
+ writeData(dir, data);
142
+ });
143
+ process.exit(0);
144
+ }
145
+
146
+ if (event === 'Stop') {
147
+ withStateLock(projectRoot, sessionId, (dir) => {
148
+ const data = readData(dir);
149
+ data.lastAssistant = summary(payload.last_assistant_message);
150
+ refreshGit(data, projectRoot);
151
+ writeData(dir, data);
152
+ archive(dir);
153
+ });
154
+ }
155
+ } catch (error) {
156
+ logCrash('state', error);
157
+ }