@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.
- package/README.md +86 -11
- 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 +259 -0
- package/bin/install.js +30 -8
- package/bin/lib/backup.js +82 -18
- 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 +60 -9
- package/bin/phases/codex-runtime.js +110 -0
- package/bin/phases/copy-payload.js +36 -16
- package/bin/phases/post-install.js +98 -48
- package/bin/phases/root-config.js +4 -2
- package/bin/phases/select-platform.js +105 -66
- package/bin/phases/summary.js +6 -1
- package/package.json +3 -2
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/code-auditor.md +1 -1
- package/src/claude/agents/debugger.md +1 -0
- package/src/claude/agents/god-developer.md +10 -1
- package/src/claude/agents/inspector.md +1 -0
- package/src/claude/agents/spec-maker.md +1 -1
- package/src/claude/agents/test-runner.md +2 -2
- package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
- package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
- package/src/claude/hooks/__tests__/state.test.js +65 -0
- package/src/claude/hooks/docs-sync.cjs +15 -21
- package/src/claude/hooks/inspect-block.cjs +1 -1
- package/src/claude/hooks/lib/config.cjs +0 -5
- package/src/claude/hooks/privacy-block.cjs +39 -62
- package/src/claude/hooks/spec-gate.cjs +18 -7
- package/src/claude/hooks/spec-state.cjs +4 -0
- package/src/claude/hooks/state.cjs +13 -3
- package/src/claude/hooks/usage.cjs +6 -0
- package/src/claude/rules/orchestrator.md +2 -2
- package/src/claude/rules/skill-domain-routing.md +27 -86
- package/src/claude/rules/skill-workflow-routing.md +11 -82
- package/src/claude/rules/state-sync.md +1 -1
- package/src/claude/runtime.json +7 -7
- package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
- package/src/claude/scripts/validate-spec-output.cjs +6 -0
- package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
- package/src/claude/skills/develop/SKILL.md +26 -5
- package/src/claude/skills/develop/references/parallel-waves.md +112 -0
- package/src/claude/skills/develop/references/quality-gate.md +10 -2
- package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
- package/src/claude/skills/inspect/SKILL.md +42 -113
- package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
- package/src/claude/skills/specs/SKILL.md +134 -386
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/task.md +3 -1
- package/src/claude/skills/sync/SKILL.md +2 -2
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +1 -1
- package/src/claude/status.cjs +9 -6
- 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
- package/src/opencode/plugins/docs-sync.ts +10 -16
- package/src/claude/archive-command/code/SKILL.md +0 -55
- package/src/claude/archive-command/code/references/execution-loop.md +0 -21
- package/src/claude/archive-command/code.md +0 -17
- package/src/claude/archive-command/docs.md +0 -609
- package/src/claude/archive-command/review/codebase/parallel.md +0 -76
- package/src/claude/archive-command/spec-design.md +0 -247
- package/src/claude/archive-command/spec-init.md +0 -120
- package/src/claude/archive-command/spec-requirements.md +0 -138
- package/src/claude/archive-command/spec-status.md +0 -98
- package/src/claude/archive-command/spec-tasks.md +0 -173
- package/src/claude/archive-command/spec-validate.md +0 -118
- package/src/claude/archive-command/test.md +0 -8
|
@@ -157,7 +157,7 @@ Every task file MUST contain the Risk Assessment table, even if no risks are ide
|
|
|
157
157
|
|
|
158
158
|
### Mandatory Evidence Section
|
|
159
159
|
|
|
160
|
-
Every new task file MUST include a `## Evidence` section
|
|
160
|
+
Every new task file MUST include a `## Evidence` section (`## Evidence` (legacy heading aliases still parse)).
|
|
161
161
|
|
|
162
162
|
That section is the task-level test plan and proof checklist. It MUST contain:
|
|
163
163
|
1. **Automated proof** — exact command(s) for typecheck, tests, build, or explicit `N/A`
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
This section is both the task-level test plan and the proof checklist. Keep it short, exact, and executable.
|
|
61
61
|
Select the proof by task risk; do not run every test type for every task.
|
|
62
62
|
|
|
63
|
+
Verification: PENDING
|
|
64
|
+
|
|
63
65
|
- Logic/data/validator task: include unit tests.
|
|
64
66
|
- Stateful UI/component task: include component or integration tests.
|
|
65
67
|
- Cross-module/API/state flow task: include integration tests.
|
|
@@ -94,4 +96,4 @@ Select the proof by task risk; do not run every test type for every task.
|
|
|
94
96
|
> **Parallel marker**: Append `(P)` to the title if this task can run concurrently with another (usually when serving different requirements).
|
|
95
97
|
> **Test note**: If a test coverage sub-task can be deferred post-MVP, mark it with `- [ ]*`.
|
|
96
98
|
> **Requirement mapping**: Every sub-task MUST end with `_Requirements: X.X_`. No mapping = invalid task file.
|
|
97
|
-
> **Evidence rule**: No `## Evidence` section = invalid task file.
|
|
99
|
+
> **Evidence rule**: No `## Evidence` section = invalid task file. Use `## Evidence` (legacy heading aliases still parse).
|
|
@@ -39,8 +39,8 @@ Scans the `spec.json` against all physical `task-R*.md` files to detect mismatch
|
|
|
39
39
|
|
|
40
40
|
1. **Precision Edits:** Never overwrite the entire `spec.json` string blindly. Update only the required keys, while keeping JSON valid.
|
|
41
41
|
2. **Machine + Human Sync:** Every task status update MUST modify both `spec.json.task_registry[...]` and the matching markdown task file header/status section.
|
|
42
|
-
3. **Markdown Integrity:** When marking a task `done`, only then turn `[ ]` into `[x]` inside `## Steps` / `## Implementation Steps` and relevant `Completion Criteria` / `Evidence` checkboxes that have actual proof.
|
|
43
|
-
4. **Verification Receipt Rule:** `done` is illegal without a human-readable verification receipt already present in `## Evidence
|
|
42
|
+
3. **Markdown Integrity:** When marking a task `done`, only then turn `[ ]` into `[x]` inside `## Steps` / `## Implementation Steps` and relevant `Completion Criteria` / `Evidence` checkboxes that have actual proof. Use `## Evidence` (legacy heading aliases still parse).
|
|
43
|
+
4. **Verification Receipt Rule:** `done` is illegal without a human-readable verification receipt already present in `## Evidence` (legacy heading aliases still parse) (commands executed, artifact/runtime proof, or equivalent concrete evidence). If proof is missing, keep the task `in_progress` or `blocked`.
|
|
44
44
|
5. **Task Docs Hook:** Every time `hapo:sync` marks a task as `done`, it must flag that a task-level docs checkpoint is now due for that verified task.
|
|
45
45
|
6. **Phase Prompt Rule:** When `hapo:sync` marks the final pending task in the whole feature as `done`, it should automatically prompt the user if they'd like to advance the phase, but only after the docs checkpoint for that last completed task has been considered.
|
|
46
46
|
|
|
@@ -15,7 +15,7 @@ When requested to update a phase or change task configuration, `spec.json` must
|
|
|
15
15
|
- full relative path like `tasks/task-R0-02-extension-shell.md`
|
|
16
16
|
* **Status Update:** If a task changes to `blocked`, the matching `task_registry[path].status` must become `"blocked"`, `task_registry[path].blocker` must record the reason, and `spec.json.status` / `spec.json.blocker` must reflect the top-level block if work is globally blocked.
|
|
17
17
|
* **Timestamp Rule:** Update `task_registry[path].started_at`, `completed_at`, and `last_updated_at` consistently with the new state. Also refresh `spec.json.updated_at`.
|
|
18
|
-
* **Done-State Rule:** Never set `task_registry[path].status = "done"` unless the matching markdown task file already contains a verification receipt in `## Evidence
|
|
18
|
+
* **Done-State Rule:** Never set `task_registry[path].status = "done"` unless the matching markdown task file already contains a verification receipt in `## Evidence` (legacy heading aliases still parse), or the caller explicitly provides proof that can be written there first.
|
|
19
19
|
* **Receipt Integrity Rule:** A valid verification receipt must include the exact commands run, their outcomes, and artifact/runtime proof. Receipts containing `PRECHECK_FAIL`, `FAIL`, `UNVERIFIED`, or explicit "placeholder / simplified for MVP / production later" contract deviations are not eligible for `done`.
|
|
20
20
|
* **Contract Fidelity Rule:** If the task file notes or evidence show that a named framework/auth/runtime choice from the spec was silently replaced, sync MUST refuse `done` until the spec is amended or the implementation is corrected.
|
|
21
21
|
* **Task Docs Rule:** After a task is moved to `done`, emit a short alert that a task-level docs checkpoint is due for this verified task.
|
|
@@ -27,12 +27,12 @@ The structure of `tasks/task.md` relies heavily on exact keyword markers. Follow
|
|
|
27
27
|
### A. Completing a Task
|
|
28
28
|
When `/hapo:sync <feature> <task-id> done`:
|
|
29
29
|
1. Find: `**Status:** pending` (or `in_progress` / `blocked`).
|
|
30
|
-
2. Inspect `## Evidence`
|
|
30
|
+
2. Inspect `## Evidence` (legacy heading aliases still parse) first. If it has no explicit proof lines (commands run, artifact proof, runtime proof, or blockers cleared), STOP and refuse to mark the task done.
|
|
31
31
|
3. Refuse completion if the receipt contains any non-passing marker such as `PRECHECK_FAIL`, `FAIL`, `UNVERIFIED`, or an explicit note that the implementation substituted a named contract with a placeholder/custom simplification.
|
|
32
32
|
4. Replace with: `**Status:** done`.
|
|
33
33
|
5. Locate block: `## Steps` or `## Implementation Steps`.
|
|
34
34
|
6. Convert `- [ ]` into `- [x]` strictly within that section.
|
|
35
|
-
7. Update relevant checkboxes in `## Completion Criteria` and `## Evidence` only when the caller provides or the file already contains real proof.
|
|
35
|
+
7. Update relevant checkboxes in `## Completion Criteria` and `## Evidence` (legacy heading aliases still parse) only when the caller provides or the file already contains real proof.
|
|
36
36
|
8. Surface a note such as: `Docs checkpoint due: task Rn-mm just completed`.
|
|
37
37
|
|
|
38
38
|
### B. Blocking a Task
|
|
@@ -59,7 +59,7 @@ When `/hapo:sync audit <feature>` is activated:
|
|
|
59
59
|
- Missing disk file referenced in registry → remove or flag it
|
|
60
60
|
- Markdown says `done` but registry not done → registry wins only if evidence already exists; otherwise downgrade markdown or flag conflict
|
|
61
61
|
- Registry says `done` but markdown still pending → update markdown only if evidence exists
|
|
62
|
-
- Either side says `done` but `## Evidence`
|
|
62
|
+
- Either side says `done` but `## Evidence` (legacy heading aliases still parse) has no concrete proof → downgrade to `in_progress` or flag conflict instead of preserving fake completion
|
|
63
63
|
- Either side says `done` but the receipt contains `PRECHECK_FAIL`, `FAIL`, `UNVERIFIED`, or explicit contract-substitution notes → downgrade to `in_progress` or flag conflict
|
|
64
64
|
5. **Correction Alert:** Output a brief markdown alert detailing mismatches fixed and any unresolved conflicts requiring manual review.
|
|
65
65
|
6. **Task Docs Alert:** If audit reveals tasks newly marked `done`, include whether task-level docs sync appears still due or already accounted for in the current run summary.
|
|
@@ -177,7 +177,7 @@ It merges the JSON data into `.hapo/test-memory.json` per `references/test-memor
|
|
|
177
177
|
| `hapo:develop` | orchestrates | Spawns hapo:test at Step 4 |
|
|
178
178
|
| `inspector` agent | hapo:test → | Scout test file locations when structure is unfamiliar |
|
|
179
179
|
| `god-developer` agent | hapo:test → | FAIL verdicts route back here for fixing |
|
|
180
|
-
| `test-runner` agent | hapo:test → | Primary executor, spawned via
|
|
180
|
+
| `test-runner` agent | hapo:test → | Primary executor, spawned via the `Agent` tool |
|
|
181
181
|
| chrome-devtools scripts | test-runner → | UI verification (navigate, screenshot, console, network, performance, aria-snapshot, inject-auth) |
|
|
182
182
|
|
|
183
183
|
## References
|
package/src/claude/status.cjs
CHANGED
|
@@ -21,8 +21,10 @@ const { countConfigs } = require('./hooks/lib/counter.cjs');
|
|
|
21
21
|
const { loadConfig } = require('./hooks/lib/config.cjs');
|
|
22
22
|
const { getGitInfo } = require('./hooks/lib/git.cjs');
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
|
|
24
|
+
// Autocompact reserve as a fraction of the ACTUAL window size from the payload.
|
|
25
|
+
// Derived from /context on a 200k window (45000/200000 = 22.5%); expressed as a
|
|
26
|
+
// ratio so 1M-context models are not treated as if they were 200k.
|
|
27
|
+
const AUTOCOMPACT_BUFFER_RATIO = 0.225;
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* Expand home directory to ~
|
|
@@ -411,19 +413,20 @@ async function main() {
|
|
|
411
413
|
}
|
|
412
414
|
} catch {}
|
|
413
415
|
|
|
414
|
-
// Context window - use current_usage fields with
|
|
416
|
+
// Context window - use current_usage fields with a proportional reserve
|
|
415
417
|
const usage = data.context_window?.current_usage || {};
|
|
416
418
|
const contextSize = data.context_window?.context_window_size || 0;
|
|
417
419
|
let contextPercent = 0;
|
|
418
420
|
let totalTokens = 0;
|
|
419
421
|
|
|
420
|
-
if (contextSize > 0
|
|
422
|
+
if (contextSize > 0) {
|
|
421
423
|
totalTokens = (usage.input_tokens ?? 0) +
|
|
422
424
|
(usage.cache_creation_input_tokens ?? 0) +
|
|
423
425
|
(usage.cache_read_input_tokens ?? 0);
|
|
424
426
|
|
|
425
|
-
// Add
|
|
426
|
-
|
|
427
|
+
// Add the reserve to match /context calculation on any window size
|
|
428
|
+
const buffer = Math.round(contextSize * AUTOCOMPACT_BUFFER_RATIO);
|
|
429
|
+
contextPercent = Math.min(100, Math.round(((totalTokens + buffer) / contextSize) * 100));
|
|
427
430
|
}
|
|
428
431
|
|
|
429
432
|
// Write context data to temp file for hooks to read
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const BLOCKED_DIRS = new Set([
|
|
4
|
+
'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
|
|
5
|
+
'__pycache__', '.venv', 'venv', 'vendor', 'target',
|
|
6
|
+
'.git', 'coverage', '.nyc_output'
|
|
7
|
+
]);
|
|
8
|
+
const BROAD_GLOBS = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
|
|
9
|
+
const PATH_KEYS = new RegExp(
|
|
10
|
+
'^(?:file_?paths?|paths?|search_?paths?|'
|
|
11
|
+
+ '(?:source|target|destination)_?paths?|dirs?|director(?:y|ies)|'
|
|
12
|
+
+ 'folders?|cwd|root|locations?|uris?)$',
|
|
13
|
+
'i'
|
|
14
|
+
);
|
|
15
|
+
const GLOB_KEYS = /(?:^|_)(?:glob|globs|pattern|patterns)$/i;
|
|
16
|
+
function clean(value) {
|
|
17
|
+
return String(value || '').trim().replace(/^['"`]+|['"`,:]+$/g, '');
|
|
18
|
+
}
|
|
19
|
+
function blockedSegment(value) {
|
|
20
|
+
return clean(value)
|
|
21
|
+
.replace(/\\/g, '/')
|
|
22
|
+
.split('/')
|
|
23
|
+
.find((segment) => BLOCKED_DIRS.has(segment));
|
|
24
|
+
}
|
|
25
|
+
function isBroadGlob(value) {
|
|
26
|
+
const candidate = clean(value);
|
|
27
|
+
return BROAD_GLOBS.some((rule) => rule.test(candidate));
|
|
28
|
+
}
|
|
29
|
+
function collectStructured(value, key, paths, globs) {
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
if (PATH_KEYS.test(key)) paths.push(value);
|
|
32
|
+
if (GLOB_KEYS.test(key)) globs.push(value);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
if (PATH_KEYS.test(key)) paths.push(...value.filter((item) => typeof item === 'string'));
|
|
37
|
+
if (GLOB_KEYS.test(key)) globs.push(...value.filter((item) => typeof item === 'string'));
|
|
38
|
+
for (const item of value) collectStructured(item, '', paths, globs);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!value || typeof value !== 'object') return;
|
|
42
|
+
for (const [childKey, child] of Object.entries(value)) {
|
|
43
|
+
collectStructured(child, childKey, paths, globs);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function shellTokens(command) {
|
|
47
|
+
const tokens = [];
|
|
48
|
+
let token = '';
|
|
49
|
+
let quote = '';
|
|
50
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
51
|
+
const char = command[index];
|
|
52
|
+
if (quote) {
|
|
53
|
+
if (char === quote) quote = '';
|
|
54
|
+
else if (char === '\\' && quote === '"' && index + 1 < command.length) {
|
|
55
|
+
token += command[index += 1];
|
|
56
|
+
} else token += char;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (char === '"' || char === "'") {
|
|
60
|
+
quote = char;
|
|
61
|
+
} else if (char === '\n') {
|
|
62
|
+
if (token) tokens.push(token);
|
|
63
|
+
token = '';
|
|
64
|
+
tokens.push('|');
|
|
65
|
+
} else if (/\s/.test(char)) {
|
|
66
|
+
if (token) tokens.push(token);
|
|
67
|
+
token = '';
|
|
68
|
+
} else if (';&|'.includes(char)) {
|
|
69
|
+
if (token) tokens.push(token);
|
|
70
|
+
token = '';
|
|
71
|
+
const pair = command.slice(index, index + 2);
|
|
72
|
+
if (pair === '&&' || pair === '||') index += 1;
|
|
73
|
+
tokens.push('|');
|
|
74
|
+
} else {
|
|
75
|
+
token += char;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (token) tokens.push(token);
|
|
79
|
+
return tokens;
|
|
80
|
+
}
|
|
81
|
+
function commandGroups(command) {
|
|
82
|
+
const groups = [[]];
|
|
83
|
+
for (const token of shellTokens(command)) {
|
|
84
|
+
if (token === '|') groups.push([]);
|
|
85
|
+
else groups[groups.length - 1].push(clean(token));
|
|
86
|
+
}
|
|
87
|
+
return groups.filter((group) => group.length);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function positional(tokens, optionsWithValue = new Set()) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
93
|
+
const token = tokens[index];
|
|
94
|
+
if (optionsWithValue.has(token)) index += 1;
|
|
95
|
+
else if (!token.startsWith('-') || token === '-') out.push(token);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readerOptionPaths(name, tokens) {
|
|
101
|
+
if (!['rg', 'grep', 'sed'].includes(name)) return [];
|
|
102
|
+
const paths = [];
|
|
103
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
104
|
+
const token = tokens[index];
|
|
105
|
+
if (token === '-f' || token === '--file') {
|
|
106
|
+
if (tokens[index + 1]) paths.push(tokens[index + 1]);
|
|
107
|
+
index += 1;
|
|
108
|
+
} else if (token.startsWith('--file=')) {
|
|
109
|
+
paths.push(token.slice('--file='.length));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return paths;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findRoots(tokens) {
|
|
116
|
+
const roots = [];
|
|
117
|
+
let index = 1;
|
|
118
|
+
while (['-H', '-L', '-P'].includes(tokens[index]) || /^-O\d+$/.test(tokens[index] || '')) {
|
|
119
|
+
index += 1;
|
|
120
|
+
}
|
|
121
|
+
if (tokens[index] === '-D') index += 2;
|
|
122
|
+
if (tokens[index] === '--') index += 1;
|
|
123
|
+
for (; index < tokens.length; index += 1) {
|
|
124
|
+
const token = tokens[index];
|
|
125
|
+
if (token.startsWith('-') || ['!', '(', ')'].includes(token)) break;
|
|
126
|
+
roots.push(token);
|
|
127
|
+
}
|
|
128
|
+
return roots;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function commandPaths(group, globs) {
|
|
132
|
+
const commandIndex = group.findIndex((token) => (
|
|
133
|
+
token && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)
|
|
134
|
+
));
|
|
135
|
+
if (commandIndex < 0) return [];
|
|
136
|
+
const tokens = group.slice(commandIndex);
|
|
137
|
+
while (['sudo', 'env', 'time', 'command'].includes(tokens[0])) tokens.shift();
|
|
138
|
+
if (!tokens.length) return [];
|
|
139
|
+
const name = tokens[0].replace(/\\/g, '/').split('/').pop();
|
|
140
|
+
const optionPaths = readerOptionPaths(name, tokens);
|
|
141
|
+
|
|
142
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
143
|
+
const token = tokens[index];
|
|
144
|
+
if (token === '-g' || token === '--glob' || token === '--iglob') {
|
|
145
|
+
if (tokens[index + 1]) globs.push(tokens[index + 1]);
|
|
146
|
+
} else if (/^--(?:i?glob)=/.test(token)) {
|
|
147
|
+
globs.push(token.slice(token.indexOf('=') + 1));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (name === 'rg' || name === 'grep') {
|
|
152
|
+
const values = positional(tokens, new Set([
|
|
153
|
+
'-e', '--regexp', '-f', '--file', '-g', '--glob', '--iglob',
|
|
154
|
+
'-t', '--type', '-T', '--type-not'
|
|
155
|
+
]));
|
|
156
|
+
const explicitPattern = tokens.some((token) => token === '-e' || token === '--regexp');
|
|
157
|
+
const fileMode = tokens.includes('--files');
|
|
158
|
+
return [...optionPaths, ...(explicitPattern || fileMode ? values : values.slice(1))];
|
|
159
|
+
}
|
|
160
|
+
if (name === 'sed') {
|
|
161
|
+
const values = positional(tokens, new Set(['-e', '--expression', '-f', '--file']));
|
|
162
|
+
const explicitScript = tokens.some((token) => (
|
|
163
|
+
['-e', '--expression', '-f', '--file'].includes(token)
|
|
164
|
+
));
|
|
165
|
+
return [...optionPaths, ...(explicitScript ? values : values.slice(1))];
|
|
166
|
+
}
|
|
167
|
+
if (name === 'find') return findRoots(tokens);
|
|
168
|
+
if (['cat', 'head', 'tail', 'ls', 'wc', 'du', 'tree', 'file', 'stat', 'cd', 'pushd', 'source', '.'].includes(name)) {
|
|
169
|
+
return positional(tokens, new Set(['-n', '--lines', '-c', '--bytes']));
|
|
170
|
+
}
|
|
171
|
+
return tokens.filter((token) => (
|
|
172
|
+
/^(?:\.{0,2}[\\/]|~[\\/])/.test(token)
|
|
173
|
+
|| (token.includes('/') && blockedSegment(token))
|
|
174
|
+
));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function inspectInput(input) {
|
|
178
|
+
const paths = [];
|
|
179
|
+
const globs = [];
|
|
180
|
+
collectStructured(input, '', paths, globs);
|
|
181
|
+
const command = typeof input?.command === 'string'
|
|
182
|
+
? input.command
|
|
183
|
+
: typeof input?.cmd === 'string' ? input.cmd : '';
|
|
184
|
+
for (const group of commandGroups(command)) {
|
|
185
|
+
paths.push(...commandPaths(group, globs));
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
broadGlob: globs.find(isBroadGlob),
|
|
189
|
+
blockedPath: paths.map((requested) => ({
|
|
190
|
+
requested,
|
|
191
|
+
blocked: blockedSegment(requested)
|
|
192
|
+
})).find((result) => result.blocked)
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
BLOCKED_DIRS,
|
|
198
|
+
inspectInput
|
|
199
|
+
};
|