@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.
- package/README.md +80 -9
- package/bin/__tests__/codex-hooks.test.js +331 -0
- package/bin/__tests__/codex-native.test.js +458 -0
- package/bin/__tests__/installer-safety.test.js +115 -1
- package/bin/install.js +21 -7
- package/bin/lib/backup.js +23 -3
- package/bin/lib/codex-frontmatter.js +56 -0
- package/bin/lib/codex-install.js +195 -0
- package/bin/lib/context.js +65 -8
- package/bin/lib/copy-utils.js +1 -1
- package/bin/lib/i18n.js +12 -3
- package/bin/lib/managed-writer.js +17 -2
- package/bin/lib/manifest.js +21 -4
- package/bin/lib/opencode-install.js +21 -19
- package/bin/lib/path-safety.js +40 -0
- package/bin/lib/version-check.js +25 -12
- package/bin/phases/claude-runtime.js +3 -5
- package/bin/phases/codex-runtime.js +110 -0
- package/bin/phases/copy-payload.js +36 -16
- package/bin/phases/post-install.js +84 -47
- package/bin/phases/root-config.js +4 -2
- package/bin/phases/select-platform.js +92 -58
- package/bin/phases/summary.js +6 -1
- package/package.json +3 -2
- package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
- package/src/codex/AGENTS.md +30 -0
- package/src/codex/agents-gitignore +11 -0
- package/src/codex/gitignore +12 -0
- package/src/codex/hooks/agent.cjs +66 -0
- package/src/codex/hooks/docs-sync.cjs +66 -0
- package/src/codex/hooks/inspect-block.cjs +40 -0
- package/src/codex/hooks/lib/hook-context.cjs +93 -0
- package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
- package/src/codex/hooks/lib/privacy-state.cjs +199 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
- package/src/codex/hooks/lib/spec-utils.cjs +50 -0
- package/src/codex/hooks/lib/state-store.cjs +166 -0
- package/src/codex/hooks/privacy-approval.cjs +59 -0
- package/src/codex/hooks/privacy-block.cjs +182 -0
- package/src/codex/hooks/rules.cjs +79 -0
- package/src/codex/hooks/session.cjs +114 -0
- package/src/codex/hooks/spec-gate.cjs +80 -0
- package/src/codex/hooks/spec-state.cjs +73 -0
- package/src/codex/hooks/state.cjs +157 -0
- package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
- package/src/codex/hooks.json +122 -0
- package/src/codex/rules/hook-protocols.md +32 -0
- package/src/codex/rules/state-sync.md +41 -0
- 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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
135
|
-
if (
|
|
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
|
|
148
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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(
|
|
190
|
-
|
|
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
|
-
|
|
221
|
+
function getInstalledPlatform() {
|
|
222
|
+
return getInstalledPlatforms()[0] || null;
|
|
195
223
|
}
|
|
196
224
|
|
|
197
|
-
module.exports = {
|
|
225
|
+
module.exports = {
|
|
226
|
+
selectLanguage,
|
|
227
|
+
promptPlatformSelection,
|
|
228
|
+
resolvePlatforms,
|
|
229
|
+
getInstalledPlatform,
|
|
230
|
+
getInstalledPlatforms
|
|
231
|
+
};
|
package/bin/phases/summary.js
CHANGED
|
@@ -53,7 +53,12 @@ function printSummary(ctx) {
|
|
|
53
53
|
|
|
54
54
|
const lines = [];
|
|
55
55
|
for (const key of platforms) {
|
|
56
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.15.1",
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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,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
|
+
};
|