@haposoft/cafekit 0.14.2 → 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 (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
@@ -4,7 +4,7 @@
4
4
  * Ensures the root .gitignore carries CafeKit-managed patterns:
5
5
  * - workflow dirs (plans, shared research)
6
6
  * - install safety artifacts (backup, lock)
7
- * - runtime folders (.claude/, .opencode/) — reinstall via
7
+ * - runtime folders (.claude/, .opencode/, .codex/, .agents/) — reinstall via
8
8
  * `npx @haposoft/cafekit`; do not commit the local payload
9
9
  *
10
10
  * Layered with the in-folder `.claude/.gitignore` /
@@ -35,7 +35,9 @@ function ensureGitignore(ctx) {
35
35
  '.cafekit.lock',
36
36
  // Local runtime payload — reinstall with npx; keep out of git
37
37
  '.claude/',
38
- '.opencode/'
38
+ '.opencode/',
39
+ '.codex/',
40
+ '.agents/'
39
41
  ];
40
42
  const prefix = ctx.dryRun ? '[dry-run] ' : '';
41
43
 
@@ -22,15 +22,18 @@ const OTHER = '__other__';
22
22
  * Read saved locale label from .claude/runtime.json (written by post-install).
23
23
  * Returns the freeform label string (e.g. "Tiếng Việt") or null.
24
24
  */
25
- function getInstalledLocale() {
26
- const runtimeJson = path.join(process.cwd(), '.claude', 'runtime.json');
27
- if (!fs.existsSync(runtimeJson)) return null;
28
- try {
29
- const data = JSON.parse(fs.readFileSync(runtimeJson, 'utf8'));
30
- return (data && data.locale && typeof data.locale.responseLanguage === 'string')
31
- ? data.locale.responseLanguage
32
- : null;
33
- } catch { return null; }
25
+ function getInstalledLocale(platformKeys = getPlatformKeys()) {
26
+ for (const key of platformKeys) {
27
+ const runtimeJson = path.join(process.cwd(), PLATFORMS[key].folder, 'runtime.json');
28
+ if (!fs.existsSync(runtimeJson)) continue;
29
+ try {
30
+ const data = JSON.parse(fs.readFileSync(runtimeJson, 'utf8'));
31
+ if (data && data.locale && typeof data.locale.responseLanguage === 'string') {
32
+ return data.locale.responseLanguage;
33
+ }
34
+ } catch { /* try the next installed runtime */ }
35
+ }
36
+ return null;
34
37
  }
35
38
 
36
39
  /** First step: pick the installer/UI language (interactive only). */
@@ -40,7 +43,8 @@ async function selectLanguage(ctx) {
40
43
  // ctx at the 'en' default, and patchRuntimeLocale then clobbered the user's
41
44
  // responseLanguage on every upgrade.
42
45
  if (!ctx.options.lang) {
43
- const savedLocale = getInstalledLocale();
46
+ const requested = (ctx.options.platforms || []).filter((key) => PLATFORMS[key]);
47
+ const savedLocale = getInstalledLocale(requested.length ? requested : getPlatformKeys());
44
48
  if (savedLocale) {
45
49
  const code = Object.keys(LANGUAGE_LABELS).find((k) => LANGUAGE_LABELS[k] === savedLocale) || 'en';
46
50
  ctx.setLang(code, savedLocale); // updates ctx.t to the saved language
@@ -104,26 +108,67 @@ async function confirmAllDetected(ctx, detected) {
104
108
  return r;
105
109
  }
106
110
 
111
+ function platformNames(platforms) {
112
+ return platforms.map((key) => PLATFORMS[key].name).join(', ');
113
+ }
114
+
115
+ function reportInstallMode(ctx) {
116
+ if (ctx.options.forceOverwrite) {
117
+ ctx.ui.info(ctx.t('modeForce'));
118
+ } else if (ctx.dryRun) {
119
+ ctx.ui.info(ctx.t('modeDryRun'));
120
+ } else {
121
+ ctx.ui.info(ctx.t('modeInstall'));
122
+ }
123
+ }
124
+
125
+ function cancelSelection(ctx) {
126
+ ctx.ui.outro(ctx.t('cancelled'));
127
+ ctx.cancelled = true;
128
+ return [];
129
+ }
130
+
131
+ async function promptPlatforms(ctx) {
132
+ const platforms = await promptPlatformSelection(ctx);
133
+ return platforms.length > 0 ? platforms : cancelSelection(ctx);
134
+ }
135
+
107
136
  /**
108
137
  * Resolve ctx.platforms via detection + prompts. Sets ctx.cancelled on backout.
109
138
  * If cafekit.json exists (prior install), reads platform from it to skip prompt.
110
139
  * Note: ctx.isUpdate is set later by checkVersions and cannot be used here.
111
140
  */
112
141
  async function resolvePlatforms(ctx) {
142
+ const requested = [...new Set((ctx.options.platforms || []).map((key) => key.trim()).filter(Boolean))];
143
+ if (requested.length > 0) {
144
+ const unknown = requested.filter((key) => !PLATFORMS[key]);
145
+ if (unknown.length > 0) {
146
+ throw new Error(`Unknown platform: ${unknown.join(', ')}. Expected: ${getPlatformKeys().join(', ')}`);
147
+ }
148
+ ctx.platforms = requested;
149
+ ctx.ui.info(ctx.t('installingFor', {
150
+ names: platformNames(requested)
151
+ }));
152
+ warnLegacyClaudeFolder(requested);
153
+ reportInstallMode(ctx);
154
+ return ctx;
155
+ }
156
+
113
157
  // Skip platform prompt when a prior install is detected via cafekit.json
114
- const savedPlatform = getInstalledPlatform();
115
- if (savedPlatform) {
116
- ctx.platforms = [savedPlatform];
117
- const platformNames = PLATFORMS[savedPlatform].name;
118
- ctx.ui.info(ctx.t('platformKept', { names: platformNames }));
119
-
120
- if (ctx.options.forceOverwrite) {
121
- ctx.ui.info(ctx.t('modeForce'));
122
- } else if (ctx.dryRun) {
123
- ctx.ui.info(ctx.t('modeDryRun'));
124
- } else {
125
- ctx.ui.info(ctx.t('modeInstall'));
158
+ const savedPlatforms = getInstalledPlatforms();
159
+ if (savedPlatforms.length > 0) {
160
+ let platforms = [...new Set([...savedPlatforms, ...detectPlatforms()])];
161
+ if (ctx.interactive && platforms.some((key) => !savedPlatforms.includes(key))) {
162
+ const proceed = await confirmAllDetected(ctx, platforms);
163
+ if (!proceed) {
164
+ platforms = await promptPlatforms(ctx);
165
+ if (ctx.cancelled) return ctx;
166
+ }
126
167
  }
168
+ ctx.platforms = platforms;
169
+ ctx.ui.info(ctx.t('platformKept', { names: platformNames(platforms) }));
170
+
171
+ reportInstallMode(ctx);
127
172
  return ctx;
128
173
  }
129
174
 
@@ -131,12 +176,8 @@ async function resolvePlatforms(ctx) {
131
176
 
132
177
  if (platforms.length === 0) {
133
178
  if (ctx.interactive) {
134
- platforms = await promptPlatformSelection(ctx);
135
- if (platforms.length === 0) {
136
- ctx.ui.outro(ctx.t('cancelled'));
137
- ctx.cancelled = true;
138
- return ctx;
139
- }
179
+ platforms = await promptPlatforms(ctx);
180
+ if (ctx.cancelled) return ctx;
140
181
  } else {
141
182
  platforms = ['claude'];
142
183
  ctx.ui.info('No platform detected; defaulting to Claude Code (.claude/).');
@@ -144,28 +185,17 @@ async function resolvePlatforms(ctx) {
144
185
  } else if (platforms.length > 1 && ctx.interactive) {
145
186
  const proceed = await confirmAllDetected(ctx, platforms);
146
187
  if (!proceed) {
147
- platforms = await promptPlatformSelection(ctx);
148
- if (platforms.length === 0) {
149
- ctx.ui.outro(ctx.t('cancelled'));
150
- ctx.cancelled = true;
151
- return ctx;
152
- }
188
+ platforms = await promptPlatforms(ctx);
189
+ if (ctx.cancelled) return ctx;
153
190
  }
154
191
  }
155
192
 
156
193
  ctx.platforms = platforms;
157
194
 
158
- const platformNames = platforms.map((key) => PLATFORMS[key].name).join(', ');
159
- ctx.ui.info(ctx.t('installingFor', { names: platformNames }));
195
+ ctx.ui.info(ctx.t('installingFor', { names: platformNames(platforms) }));
160
196
  warnLegacyClaudeFolder(platforms);
161
197
 
162
- if (ctx.options.forceOverwrite) {
163
- ctx.ui.info(ctx.t('modeForce'));
164
- } else if (ctx.dryRun) {
165
- ctx.ui.info(ctx.t('modeDryRun'));
166
- } else {
167
- ctx.ui.info(ctx.t('modeInstall'));
168
- }
198
+ reportInstallMode(ctx);
169
199
 
170
200
  return ctx;
171
201
  }
@@ -174,24 +204,28 @@ async function resolvePlatforms(ctx) {
174
204
  * Get the installed platform from cafekit.json files.
175
205
  * Returns the platform key (e.g. 'claude', 'opencode') or null.
176
206
  */
177
- function getInstalledPlatform() {
178
- const claudeJson = path.join(process.cwd(), '.claude', 'cafekit.json');
179
- if (fs.existsSync(claudeJson)) {
180
- try {
181
- const data = JSON.parse(fs.readFileSync(claudeJson, 'utf8'));
182
- if (data.platform) return data.platform;
183
- } catch { /* ignore */ }
184
- }
185
-
186
- const opencodeJson = path.join(process.cwd(), '.opencode', 'cafekit.json');
187
- if (fs.existsSync(opencodeJson)) {
207
+ function getInstalledPlatforms() {
208
+ const installed = [];
209
+ for (const key of getPlatformKeys()) {
210
+ const metadataPath = path.join(process.cwd(), PLATFORMS[key].folder, 'cafekit.json');
211
+ if (!fs.existsSync(metadataPath)) continue;
188
212
  try {
189
- const data = JSON.parse(fs.readFileSync(opencodeJson, 'utf8'));
190
- if (data.platform) return data.platform;
213
+ const data = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
214
+ const platformKey = data.platform && PLATFORMS[data.platform] ? data.platform : key;
215
+ if (!installed.includes(platformKey)) installed.push(platformKey);
191
216
  } catch { /* ignore */ }
192
217
  }
218
+ return installed;
219
+ }
193
220
 
194
- return null;
221
+ function getInstalledPlatform() {
222
+ return getInstalledPlatforms()[0] || null;
195
223
  }
196
224
 
197
- module.exports = { selectLanguage, promptPlatformSelection, resolvePlatforms };
225
+ module.exports = {
226
+ selectLanguage,
227
+ promptPlatformSelection,
228
+ resolvePlatforms,
229
+ getInstalledPlatform,
230
+ getInstalledPlatforms
231
+ };
@@ -53,7 +53,12 @@ function printSummary(ctx) {
53
53
 
54
54
  const lines = [];
55
55
  for (const key of platforms) {
56
- lines.push(key === 'claude' ? t('nsClaude') : t('nsOpencode'));
56
+ const messageKey = {
57
+ claude: 'nsClaude',
58
+ opencode: 'nsOpencode',
59
+ codex: 'nsCodex'
60
+ }[key];
61
+ if (messageKey) lines.push(t(messageKey));
57
62
  }
58
63
  // rtk token-saver result (set by phases/setup-rtk.js when setup completed).
59
64
  if (ctx.rtkSetupRan) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.14.2",
4
- "description": "Claude Code-first spec-driven workflow for AI coding assistants. Bundles CafeKit hapo: skills, runtime hooks, agents, and installer scaffolding.",
3
+ "version": "0.15.0",
4
+ "description": "Native spec-driven workflows for Claude Code, Codex CLI, and OpenCode. Bundles CafeKit skills, runtime hooks, agents, and installer scaffolding.",
5
5
  "author": "Haposoft <nghialt@haposoft.com>",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -34,6 +34,7 @@
34
34
  "spec-driven",
35
35
  "workflow",
36
36
  "claude-code",
37
+ "codex-cli",
37
38
  "ai-coding",
38
39
  "specification",
39
40
  "requirements",
@@ -37,9 +37,14 @@ function parseArgs(argv) {
37
37
  }
38
38
 
39
39
  function defaultSkillsRoot() {
40
- const installed = path.resolve(process.cwd(), '.claude', 'skills');
41
- if (fs.existsSync(installed)) return installed;
42
- return path.resolve(__dirname, '..', 'skills');
40
+ const candidates = [
41
+ path.resolve(process.cwd(), '.claude', 'skills'),
42
+ path.resolve(process.cwd(), '.agents', 'skills'),
43
+ path.resolve(process.cwd(), '.opencode', 'skills'),
44
+ path.resolve(__dirname, '..', 'skills'),
45
+ path.resolve(__dirname, '..', '..', '.agents', 'skills')
46
+ ];
47
+ return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
43
48
  }
44
49
 
45
50
  function readText(filePath) {
@@ -0,0 +1,30 @@
1
+ # CafeKit for Codex CLI
2
+
3
+ ## Native runtime mapping
4
+
5
+ - Repository instructions: `AGENTS.md`.
6
+ - CafeKit skills: `.agents/skills/`; invoke explicitly as `$hapo-<name>` or browse with `/skills`.
7
+ - CafeKit agents: native auto-discovered definitions in `.codex/agents/*.toml`.
8
+ - Runtime support: `.codex/rules/`, `.codex/scripts/`, `.codex/references/`, and `.codex/runtime.json`.
9
+ - Lifecycle enforcement: `.codex/hooks.json`. Project hooks run only after the repository and hook definitions are trusted; review them with `/hooks`.
10
+ - Do not create deprecated custom prompts or assume Claude/OpenCode command wrappers exist.
11
+
12
+ ## Operating contract
13
+
14
+ - Read `README.md` and relevant project instructions before non-trivial work.
15
+ - Choose the matching CafeKit skill before improvising a workflow.
16
+ - Read `.codex/rules/workflow.md`, `.codex/rules/ai-dev-rules.md`, and
17
+ `.codex/rules/skill-workflow-routing.md` for non-trivial work. Load the other
18
+ `.codex/rules/*.md` files when their topic applies.
19
+ - Keep scope surgical; prefer YAGNI, KISS, then DRY.
20
+ - Do not claim completion without fresh build, test, runtime, or artifact evidence.
21
+ - For spec tasks, keep `spec.json`, task status, `Completion Criteria`, and `Evidence` synchronized only after verification.
22
+ - Privacy approvals are one request, one session, one retry. Only the user may submit the exact approval phrase emitted by the privacy hook.
23
+
24
+ ## Runtime caveats
25
+
26
+ - Codex custom agents use snake_case names.
27
+ - When selecting a CafeKit custom role explicitly, call `spawn_agent` with `agent_type`, a unique snake_case `task_name`, `message`, and `fork_turns: "none"`. A full-history fork cannot change agent type.
28
+ - Use Codex-native subagent delegation and task-state tools; do not rely on Claude-only tool labels.
29
+ - The project `.codex/` layer—agents, config, rules, and hooks—loads only after the repository is trusted. Never edit the user's global trust configuration on their behalf.
30
+ - Hooks are guardrails, not a complete security boundary. Hosted tools and untrusted project hooks may not enter the local hook path.
@@ -0,0 +1,11 @@
1
+ # CafeKit skill-local secrets and generated dependencies
2
+ skills/**/.env
3
+ skills/**/.env.*
4
+ !skills/**/.env.example
5
+ !skills/**/.env.sample
6
+ !skills/**/.env.template
7
+
8
+ skills/**/.venv/
9
+ skills/**/node_modules/
10
+ skills/**/__pycache__/
11
+ skills/**/*.pyc
@@ -0,0 +1,12 @@
1
+ # CafeKit local Codex runtime
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ !.env.sample
6
+ !.env.template
7
+
8
+ hooks/.logs/
9
+ hooks/.privacy/
10
+ session-state/
11
+ .cafekit-update-cache.json
12
+ *.tmp
@@ -0,0 +1,66 @@
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
+ resolveProjectPath
11
+ } = require('./lib/hook-context.cjs');
12
+
13
+ function resolveVenv(projectRoot) {
14
+ const base = path.join(projectRoot, '.agents', 'skills', '.venv');
15
+ return [
16
+ path.join(base, 'bin', 'python3'),
17
+ path.join(base, 'Scripts', 'python.exe')
18
+ ].find((candidate) => fs.existsSync(candidate)) || null;
19
+ }
20
+
21
+ try {
22
+ const payload = readPayload();
23
+ if (!payload) process.exit(0);
24
+ const { projectRoot, runtime, sessionCwd } = getHookContext(payload);
25
+ const respondLang = runtime.locale?.responseLanguage || '';
26
+ const thinkLang = runtime.locale?.thinkingLanguage || (respondLang ? 'en' : '');
27
+ const plansPath = resolveProjectPath(projectRoot, runtime.paths?.plans, 'plans');
28
+ const docsPath = resolveProjectPath(projectRoot, runtime.paths?.docs, 'docs');
29
+ const venv = resolveVenv(projectRoot);
30
+ const lines = [
31
+ `## Subagent: ${payload.agent_type || 'unknown'}`,
32
+ `ID: ${payload.agent_id || 'unknown'} | CWD: ${sessionCwd}`,
33
+ ''
34
+ ];
35
+
36
+ if ((thinkLang && thinkLang !== respondLang) || respondLang) {
37
+ lines.push('## Language');
38
+ if (thinkLang && thinkLang !== respondLang) {
39
+ lines.push(`- Thinking: Use ${thinkLang} for reasoning.`);
40
+ }
41
+ if (respondLang) lines.push(`- Response: Respond in ${respondLang}.`);
42
+ lines.push('');
43
+ }
44
+
45
+ lines.push(
46
+ '## Rules',
47
+ `- Plans → ${plansPath}/ | Docs → ${docsPath}/`,
48
+ '- YAGNI · KISS · DRY',
49
+ '- Be concise. List unresolved questions at end.'
50
+ );
51
+ if (venv) {
52
+ lines.push(
53
+ `- Python in .agents/skills/: use \`${venv}\``,
54
+ '- Never use global pip install.'
55
+ );
56
+ }
57
+
58
+ process.stdout.write(`${JSON.stringify({
59
+ hookSpecificOutput: {
60
+ hookEventName: 'SubagentStart',
61
+ additionalContext: lines.join('\n')
62
+ }
63
+ })}\n`);
64
+ } catch (error) {
65
+ logCrash('agent', error);
66
+ }
@@ -0,0 +1,66 @@
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
+ resolveProjectPath
12
+ } = require('./lib/hook-context.cjs');
13
+
14
+ function latestSourceHash(projectRoot, docsRelative) {
15
+ try {
16
+ return execFileSync(
17
+ 'git',
18
+ ['log', '-1', '--format=%H', '--', '.', `:(exclude)${docsRelative}`],
19
+ {
20
+ cwd: projectRoot,
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'ignore']
23
+ }
24
+ ).trim();
25
+ } catch {
26
+ return '';
27
+ }
28
+ }
29
+
30
+ try {
31
+ const payload = readPayload();
32
+ if (!payload) process.exit(0);
33
+ const { projectRoot, runtime } = getHookContext(payload);
34
+ const docsDir = resolveProjectPath(projectRoot, runtime.paths?.docs, 'docs');
35
+ const docsRelative = path.relative(projectRoot, docsDir).replace(/\\/g, '/');
36
+ const hasCode = [
37
+ 'src', 'app', 'lib', 'package.json', 'index.js', 'main.py'
38
+ ].some((candidate) => fs.existsSync(path.join(projectRoot, candidate)));
39
+ if (!hasCode) process.exit(0);
40
+
41
+ const lines = [];
42
+ if (!fs.existsSync(docsDir)) {
43
+ lines.push(
44
+ '### Missing docs/',
45
+ '> Source exists but the configured docs directory does not.',
46
+ '> Create baseline architecture and project-overview docs, then record the source Git hash in `docs/.sync_hash`.'
47
+ );
48
+ } else {
49
+ const currentHash = latestSourceHash(projectRoot, docsRelative);
50
+ const trackingFile = path.join(docsDir, '.sync_hash');
51
+ const previousHash = fs.existsSync(trackingFile)
52
+ ? fs.readFileSync(trackingFile, 'utf8').trim()
53
+ : '';
54
+ if (currentHash && currentHash !== previousHash) {
55
+ lines.push(
56
+ '### Docs sync needed',
57
+ `> Source changed (\`${currentHash}\`) since last docs sync (\`${previousHash || 'none'}\`).`,
58
+ '> Review the source changes, update affected docs, then refresh `docs/.sync_hash`.'
59
+ );
60
+ }
61
+ }
62
+
63
+ if (lines.length) process.stdout.write(`${lines.join('\n')}\n`);
64
+ } catch (error) {
65
+ logCrash('docs-sync', error);
66
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const {
5
+ getHookContext,
6
+ logCrash,
7
+ readPayload
8
+ } = require('./lib/hook-context.cjs');
9
+ const { BLOCKED_DIRS, inspectInput } = require('./lib/inspect-paths.cjs');
10
+
11
+ function denyPath(requested, blocked) {
12
+ process.stderr.write(
13
+ `SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden\n`
14
+ + `Requested Path: ${requested}\n`
15
+ + `Restricted zones: ${[...BLOCKED_DIRS].join(', ')}\n`
16
+ );
17
+ process.exit(2);
18
+ }
19
+
20
+ try {
21
+ const payload = readPayload();
22
+ if (!payload) process.exit(0);
23
+ const { runtime } = getHookContext(payload);
24
+ if (runtime.scout?.enabled === false || runtime.inspect?.enabled === false) process.exit(0);
25
+
26
+ const result = inspectInput(payload.tool_input || {});
27
+ if (result.broadGlob) {
28
+ process.stderr.write(
29
+ `SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad\n`
30
+ + `Requested Pattern: ${result.broadGlob}\n`
31
+ + 'Please narrow the scope (for example, src/**/*.ts).\n'
32
+ );
33
+ process.exit(2);
34
+ }
35
+ if (result.blockedPath) {
36
+ denyPath(result.blockedPath.requested, result.blockedPath.blocked);
37
+ }
38
+ } catch (error) {
39
+ logCrash('inspect-block', error);
40
+ }
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ // Installed layout: <project>/.codex/hooks/lib/hook-context.cjs
8
+ const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
9
+
10
+ function readPayload() {
11
+ const raw = fs.readFileSync(0, 'utf8').trim();
12
+ return raw ? JSON.parse(raw) : null;
13
+ }
14
+
15
+ function resolveSessionCwd(payload) {
16
+ const value = typeof payload?.cwd === 'string' ? payload.cwd.trim() : '';
17
+ const resolved = value
18
+ ? path.resolve(process.cwd(), value)
19
+ : process.cwd();
20
+ try {
21
+ return fs.realpathSync(resolved);
22
+ } catch {
23
+ return resolved;
24
+ }
25
+ }
26
+
27
+ function readRuntime(projectRoot = PROJECT_ROOT) {
28
+ try {
29
+ const file = path.join(projectRoot, '.codex', 'runtime.json');
30
+ return fs.existsSync(file)
31
+ ? JSON.parse(fs.readFileSync(file, 'utf8'))
32
+ : {};
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ function getHookContext(payload) {
39
+ return {
40
+ projectRoot: PROJECT_ROOT,
41
+ sessionCwd: resolveSessionCwd(payload),
42
+ runtime: readRuntime(PROJECT_ROOT)
43
+ };
44
+ }
45
+
46
+ function resolveProjectPath(projectRoot, configuredPath, fallback) {
47
+ const requested = typeof configuredPath === 'string' && configuredPath.trim()
48
+ ? configuredPath.trim()
49
+ : fallback;
50
+ const resolved = path.resolve(projectRoot, requested);
51
+ const relative = path.relative(projectRoot, resolved);
52
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
53
+ ? resolved
54
+ : path.resolve(projectRoot, fallback);
55
+ }
56
+
57
+ function atomicWrite(file, content, mode = 0o600) {
58
+ const dir = path.dirname(file);
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ const suffix = `${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
61
+ const temp = `${file}.${suffix}.tmp`;
62
+ fs.writeFileSync(temp, content, { encoding: 'utf8', mode });
63
+ fs.renameSync(temp, file);
64
+ }
65
+
66
+ function logCrash(hook, error) {
67
+ try {
68
+ const dir = path.join(PROJECT_ROOT, '.codex', 'hooks', '.logs');
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ fs.appendFileSync(
71
+ path.join(dir, 'hook-log.jsonl'),
72
+ `${JSON.stringify({
73
+ ts: new Date().toISOString(),
74
+ hook,
75
+ status: 'crash',
76
+ error: String(error?.message || error)
77
+ })}\n`
78
+ );
79
+ } catch {
80
+ // Hook failures remain fail-open unless the hook explicitly denies.
81
+ }
82
+ }
83
+
84
+ module.exports = {
85
+ PROJECT_ROOT,
86
+ atomicWrite,
87
+ getHookContext,
88
+ logCrash,
89
+ readPayload,
90
+ readRuntime,
91
+ resolveProjectPath,
92
+ resolveSessionCwd
93
+ };