@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
package/README.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # @haposoft/cafekit
2
2
 
3
- > Claude Code-first spec-driven workflow and runtime bundle for AI coding assistants.
3
+ > Native spec-driven workflow and runtime bundle for Claude Code, Codex CLI, and OpenCode.
4
4
 
5
- [![Version](https://img.shields.io/badge/version-0.13.4-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.15.0-blue.svg)](https://github.com/haposoft/cafekit)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
- [![Claude%20Code](https://img.shields.io/badge/Claude%20Code-Primary-orange.svg)](https://claude.ai/code)
7
+ [![Claude%20Code](https://img.shields.io/badge/Claude%20Code-Native-orange.svg)](https://claude.ai/code)
8
+ [![Codex%20CLI](https://img.shields.io/badge/Codex%20CLI-Native-111111.svg)](https://developers.openai.com/codex)
8
9
 
9
10
  ## Overview
10
11
 
@@ -20,6 +21,13 @@ Claude Code install support:
20
21
  - installs runtime hooks, statusline, and managed settings
21
22
  - merges Claude settings safely on re-run
22
23
 
24
+ Codex CLI install support:
25
+ - installs native skills under `.agents/skills/`
26
+ - installs auto-discovered custom agents under `.codex/agents/*.toml`
27
+ - installs native lifecycle hooks, rules, scripts, references, and runtime config under `.codex/`
28
+ - merges a CafeKit-owned block into root `AGENTS.md`
29
+ - requires no generated `.codex/config.toml` and never changes global trust config
30
+
23
31
  OpenCode install support:
24
32
  - installs command wrappers under `.opencode/commands/`
25
33
  - installs converted OpenCode agents under `.opencode/agents/`
@@ -36,15 +44,22 @@ Run in your project root:
36
44
  npx @haposoft/cafekit
37
45
  ```
38
46
 
39
- Refresh managed files:
47
+ Install Codex explicitly:
48
+
49
+ ```bash
50
+ npx @haposoft/cafekit --platform codex
51
+ ```
52
+
53
+ Re-running the same version selectively refreshes pristine managed files while
54
+ preserving edits. To reset user-modified managed files from a saved backup:
40
55
 
41
56
  ```bash
42
- npx @haposoft/cafekit --upgrade
57
+ npx @haposoft/cafekit --platform codex --force-overwrite
43
58
  ```
44
59
 
45
60
  Requirements:
46
61
  - Node.js 18+
47
- - Claude Code project with `.claude/`, OpenCode project with `.opencode/` or `opencode.json`, or choose a runtime when prompted
62
+ - Claude Code, Codex CLI, or OpenCode; choose a runtime when prompted or use `--platform`
48
63
 
49
64
  ## Git ignore policy
50
65
 
@@ -59,12 +74,17 @@ plans/
59
74
  .cafekit.lock
60
75
  .claude/
61
76
  .opencode/
77
+ .codex/
78
+ .agents/
62
79
  ```
63
80
 
64
81
  Runtime folders are local — reinstall with `npx @haposoft/cafekit` rather than
65
- committing them. Inside the runtime, CafeKit also installs `.claude/.gitignore`
66
- (or `.opencode/.gitignore`) so secrets, skill dependencies, session state, and
67
- hook logs stay out of git even if someone force-adds the folder.
82
+ committing them. Inside the runtime, CafeKit also installs `.claude/.gitignore`,
83
+ `.opencode/.gitignore`, or the Codex pair `.codex/.gitignore` +
84
+ `.agents/.gitignore`. These keep secrets, skill dependencies, session state,
85
+ and hook logs out of git even if someone partially un-ignores a runtime.
86
+ Teams may deliberately commit runtime files, but should also commit the
87
+ ownership manifest so selective updates share the same baseline.
68
88
 
69
89
  ## What Gets Installed
70
90
 
@@ -82,10 +102,41 @@ Claude Code targets:
82
102
  ├── cafekit.json
83
103
  ├── status.cjs
84
104
  ├── runtime.json
85
- ├── settings.json
86
- └── CLAUDE.md
105
+ └── settings.json
106
+
107
+ CLAUDE.md
87
108
  ```
88
109
 
110
+ CafeKit owns only its marked block inside the project-root `CLAUDE.md`; project
111
+ instructions outside that block are preserved across installs and upgrades.
112
+
113
+ Codex CLI targets:
114
+
115
+ ```text
116
+ .agents/
117
+ ├── .gitignore
118
+ └── skills/
119
+
120
+ .codex/
121
+ ├── .gitignore
122
+ ├── agents/*.toml
123
+ ├── hooks.json
124
+ ├── hooks/
125
+ ├── rules/
126
+ ├── scripts/
127
+ ├── references/
128
+ ├── cafekit.json
129
+ └── runtime.json
130
+
131
+ AGENTS.md
132
+ ```
133
+
134
+ CafeKit owns only its marked block in root `AGENTS.md`. Codex discovers skills
135
+ and custom agents natively after the repository is trusted; no project
136
+ `config.toml` is generated. Review and trust project hooks with `/hooks`.
137
+ CafeKit uses Codex's native status and usage UI instead of installing the
138
+ Claude statusline.
139
+
89
140
  OpenCode targets:
90
141
 
91
142
  ```text
@@ -115,6 +166,7 @@ To check the installed CafeKit package version:
115
166
 
116
167
  ```bash
117
168
  cat .claude/cafekit.json
169
+ cat .codex/cafekit.json
118
170
  cat .opencode/cafekit.json
119
171
  ```
120
172
 
@@ -140,6 +192,10 @@ CafeKit uses rule-based skill routing guidance instead of an automatic prompt-sc
140
192
  node .claude/scripts/generate-skill-catalog.cjs --skills
141
193
  ```
142
194
 
195
+ On Codex, invoke the transformed native skill names as `$hapo-<name>` or browse
196
+ them with `/skills`. Explicit custom-agent delegation uses snake_case agent
197
+ names and `fork_turns: "none"`.
198
+
143
199
  ## Quick Start
144
200
 
145
201
  Claude Code:
@@ -153,6 +209,17 @@ Claude Code:
153
209
  /hapo:code-review meet-transcript-mvp --pending
154
210
  ```
155
211
 
212
+ Codex CLI:
213
+
214
+ ```text
215
+ $hapo-question "Which files define the current CafeKit runtime?" --repo
216
+ $hapo-brainstorm Explore approaches for a Google Meet transcript extension
217
+ $hapo-specs Build a Google Meet transcript extension with AI summaries
218
+ $hapo-develop meet-transcript-mvp
219
+ $hapo-test meet-transcript-mvp --full
220
+ $hapo-code-review meet-transcript-mvp --pending
221
+ ```
222
+
156
223
  OpenCode uses the generated command names without the Claude `hapo:` prefix:
157
224
 
158
225
  ```bash
@@ -172,6 +239,14 @@ Reconstruct as-is docs from a legacy codebase:
172
239
 
173
240
  The reconstruct bundle includes as-is markdown/JSON evidence and a self-contained `overview.html` review dashboard before the approved docs are handed to `/hapo:specs`.
174
241
 
242
+ ## Codex hook safety
243
+
244
+ Codex project hooks cover session/prompt/tool/subagent/stop lifecycle events,
245
+ spec and task guardrails, per-session state, and privacy blocking. Sensitive
246
+ access approval is one-time and bound to the session, tool, and exact canonical
247
+ path set. Hooks are guardrails rather than a complete security boundary:
248
+ hosted or specialized tools may not enter the local hook path.
249
+
175
250
  ## Spec Output
176
251
 
177
252
  Specs are stored under:
@@ -0,0 +1,331 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawnSync } = require('node:child_process');
8
+ const { test } = require('node:test');
9
+
10
+ const PACKAGE_ROOT = path.join(__dirname, '../..');
11
+ const CODEX_HOOKS = path.join(PACKAGE_ROOT, 'src/codex/hooks');
12
+ const { stateDir: codexStateDir } = require(
13
+ path.join(CODEX_HOOKS, 'lib', 'state-store.cjs')
14
+ );
15
+
16
+ function runHook(file, cwd, payload) {
17
+ return spawnSync(process.execPath, [file], {
18
+ cwd,
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8'
21
+ });
22
+ }
23
+
24
+ function inHookFixture(run) {
25
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-hooks-'));
26
+ const hooks = path.join(root, '.codex', 'hooks');
27
+ try {
28
+ fs.cpSync(CODEX_HOOKS, hooks, { recursive: true });
29
+ fs.mkdirSync(path.join(root, '.codex', 'scripts'), { recursive: true });
30
+ fs.writeFileSync(path.join(root, '.codex', 'scripts', 'spec-scaffold.cjs'), '');
31
+ return run(root, hooks);
32
+ } finally {
33
+ fs.rmSync(root, { recursive: true, force: true });
34
+ }
35
+ }
36
+
37
+ test('Codex privacy approval is exact, session-bound, and one-time', () => {
38
+ inHookFixture((root, hooks) => {
39
+ const nested = path.join(root, 'packages', 'app');
40
+ fs.mkdirSync(nested, { recursive: true });
41
+ fs.writeFileSync(path.join(nested, '.env'), 'SECRET=value\n');
42
+ const block = path.join(hooks, 'privacy-block.cjs');
43
+ const approve = path.join(hooks, 'privacy-approval.cjs');
44
+ const harmlessPatch = runHook(block, nested, {
45
+ cwd: nested,
46
+ session_id: 'session-a',
47
+ hook_event_name: 'PreToolUse',
48
+ tool_name: 'apply_patch',
49
+ tool_input: {
50
+ command: '*** Begin Patch\n*** Update File: README.md\n+Document `.env` setup.\n*** End Patch\n'
51
+ }
52
+ });
53
+ assert.equal(harmlessPatch.stdout, '');
54
+
55
+ const request = {
56
+ cwd: nested,
57
+ session_id: 'session-a',
58
+ hook_event_name: 'PreToolUse',
59
+ tool_name: 'Bash',
60
+ tool_input: { command: 'cat .env' }
61
+ };
62
+
63
+ const denied = runHook(block, nested, request);
64
+ assert.equal(denied.status, 0);
65
+ const decision = JSON.parse(denied.stdout);
66
+ assert.equal(decision.hookSpecificOutput.permissionDecision, 'deny');
67
+ const prompt = decision.hookSpecificOutput.permissionDecisionReason.match(
68
+ /APPROVE CAFEKIT PRIVACY [a-f0-9]{24}/
69
+ )?.[0];
70
+ assert.ok(prompt);
71
+ assert.equal(fs.existsSync(path.join(nested, '.codex')), false);
72
+ assert.equal(fs.existsSync(path.join(hooks, '.privacy')), true);
73
+
74
+ const wrongSession = runHook(approve, nested, {
75
+ cwd: nested,
76
+ session_id: 'session-b',
77
+ hook_event_name: 'UserPromptSubmit',
78
+ prompt
79
+ });
80
+ assert.match(wrongSession.stdout, /rejected/);
81
+
82
+ const approved = runHook(approve, nested, {
83
+ cwd: nested,
84
+ session_id: 'session-a',
85
+ hook_event_name: 'UserPromptSubmit',
86
+ prompt
87
+ });
88
+ assert.match(approved.stdout, /approved one retry/);
89
+
90
+ const allowedOnce = runHook(block, nested, request);
91
+ assert.equal(allowedOnce.stdout, '');
92
+
93
+ const deniedAgain = runHook(block, nested, request);
94
+ assert.equal(JSON.parse(deniedAgain.stdout).hookSpecificOutput.permissionDecision, 'deny');
95
+
96
+ fs.writeFileSync(path.join(nested, 'credentials.json'), '{"token":"test"}\n');
97
+ const multiRequest = {
98
+ ...request,
99
+ tool_input: { command: 'cat .env credentials.json' }
100
+ };
101
+ const multiDenied = runHook(block, nested, multiRequest);
102
+ const multiDecision = JSON.parse(multiDenied.stdout);
103
+ assert.match(multiDecision.hookSpecificOutput.permissionDecisionReason, /\.env, credentials\.json/);
104
+ const multiPrompt = multiDecision.hookSpecificOutput.permissionDecisionReason.match(
105
+ /APPROVE CAFEKIT PRIVACY [a-f0-9]{24}/
106
+ )?.[0];
107
+ assert.ok(multiPrompt);
108
+
109
+ const multiApproved = runHook(approve, nested, {
110
+ cwd: nested,
111
+ session_id: 'session-a',
112
+ hook_event_name: 'UserPromptSubmit',
113
+ prompt: multiPrompt
114
+ });
115
+ assert.match(multiApproved.stdout, /approved one retry/);
116
+
117
+ // A token for two paths cannot authorize a subset, and the failed subset
118
+ // attempt must not consume the exact-set token.
119
+ const subsetDenied = runHook(block, nested, request);
120
+ assert.equal(JSON.parse(subsetDenied.stdout).hookSpecificOutput.permissionDecision, 'deny');
121
+ fs.writeFileSync(path.join(nested, 'tokens.json'), '{"token":"test"}\n');
122
+ const supersetDenied = runHook(block, nested, {
123
+ ...request,
124
+ tool_input: { command: 'cat .env credentials.json tokens.json' }
125
+ });
126
+ assert.equal(JSON.parse(supersetDenied.stdout).hookSpecificOutput.permissionDecision, 'deny');
127
+ const reordered = {
128
+ ...request,
129
+ tool_input: { command: 'cat credentials.json .env' }
130
+ };
131
+ assert.equal(runHook(block, nested, reordered).stdout, '');
132
+ assert.equal(
133
+ JSON.parse(runHook(block, nested, multiRequest).stdout).hookSpecificOutput.permissionDecision,
134
+ 'deny'
135
+ );
136
+ });
137
+ });
138
+
139
+ test('Codex privacy hook parses native patch fields and move destinations', () => {
140
+ inHookFixture((root, hooks) => {
141
+ const block = path.join(hooks, 'privacy-block.cjs');
142
+ const payload = (patch) => ({
143
+ cwd: root,
144
+ session_id: 'session-a',
145
+ hook_event_name: 'PreToolUse',
146
+ tool_name: 'apply_patch',
147
+ tool_input: { patch }
148
+ });
149
+
150
+ const harmless = runHook(
151
+ block,
152
+ root,
153
+ payload('*** Begin Patch\n*** Update File: README.md\n+Document `.env` setup.\n*** End Patch\n')
154
+ );
155
+ assert.equal(harmless.stdout, '');
156
+
157
+ for (const patch of [
158
+ '*** Begin Patch\n*** Update File: .env\n+SECRET=x\n*** End Patch\n',
159
+ '*** Begin Patch\n*** Update File: README.md\n*** Move to: credentials.json\n*** End Patch\n'
160
+ ]) {
161
+ const denied = runHook(block, root, payload(patch));
162
+ assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny');
163
+ }
164
+ });
165
+ });
166
+
167
+ test('Codex inspect hook scans native shell commands and structured paths', () => {
168
+ inHookFixture((root, hooks) => {
169
+ const inspect = path.join(hooks, 'inspect-block.cjs');
170
+ const payload = (command) => ({
171
+ cwd: root,
172
+ session_id: 'session-a',
173
+ hook_event_name: 'PreToolUse',
174
+ tool_name: 'Bash',
175
+ tool_input: { command }
176
+ });
177
+
178
+ for (const command of [
179
+ 'rg secret node_modules',
180
+ "sed -n '1,20p' dist/bundle.js",
181
+ "rg --glob '**/*' symbol src"
182
+ ]) {
183
+ const result = runHook(inspect, root, payload(command));
184
+ assert.equal(result.status, 2, command);
185
+ assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
186
+ }
187
+
188
+ const normal = runHook(inspect, root, payload('rg symbol src packages'));
189
+ assert.equal(normal.status, 0);
190
+
191
+ const structured = runHook(inspect, root, {
192
+ ...payload(''),
193
+ tool_name: 'mcp__filesystem__read_file',
194
+ tool_input: { path: 'coverage/index.html' }
195
+ });
196
+ assert.equal(structured.status, 2);
197
+ });
198
+ });
199
+
200
+ test('Codex task scaffold hook blocks apply_patch Add File with stderr guidance', () => {
201
+ inHookFixture((root, hooks) => {
202
+ const result = runHook(path.join(hooks, 'task-scaffold-guard.cjs'), root, {
203
+ cwd: root,
204
+ session_id: 'session-a',
205
+ hook_event_name: 'PreToolUse',
206
+ tool_name: 'apply_patch',
207
+ tool_input: {
208
+ command: '*** Begin Patch\n*** Add File: specs/auth/tasks/task-R0-01-auth.md\n+x\n*** End Patch\n'
209
+ }
210
+ });
211
+
212
+ assert.equal(result.status, 2);
213
+ assert.match(result.stderr, /TASK SCAFFOLD REQUIRED/);
214
+ assert.match(result.stderr, /node \.codex\/scripts\/spec-scaffold\.cjs auth/);
215
+ });
216
+ });
217
+
218
+ test('Codex completion gate cannot be bypassed from a nested cwd', () => {
219
+ inHookFixture((root, hooks) => {
220
+ const nested = path.join(root, 'packages', 'app');
221
+ const feature = path.join(root, 'specs', 'auth');
222
+ const taskPath = 'tasks/task-R0-01-auth.md';
223
+ fs.mkdirSync(path.join(feature, 'tasks'), { recursive: true });
224
+ fs.mkdirSync(nested, { recursive: true });
225
+ const spec = {
226
+ status: 'in_progress',
227
+ task_registry: {
228
+ [taskPath]: { status: 'pending', completed_at: null }
229
+ }
230
+ };
231
+ fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
232
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: pending\n');
233
+ const gate = path.join(hooks, 'spec-gate.cjs');
234
+ const payload = {
235
+ cwd: nested,
236
+ session_id: 'session-a',
237
+ hook_event_name: 'Stop',
238
+ stop_hook_active: false,
239
+ last_assistant_message: 'done'
240
+ };
241
+
242
+ const seeded = runHook(gate, nested, payload);
243
+ assert.equal(seeded.status, 0);
244
+ assert.equal(seeded.stdout, '');
245
+
246
+ spec.task_registry[taskPath] = {
247
+ status: 'done',
248
+ completed_at: '2026-07-29T10:00:00.000Z'
249
+ };
250
+ fs.writeFileSync(path.join(feature, 'spec.json'), `${JSON.stringify(spec)}\n`);
251
+ fs.writeFileSync(path.join(feature, taskPath), 'Status: done\n');
252
+
253
+ const blocked = runHook(gate, nested, payload);
254
+ assert.equal(blocked.status, 0);
255
+ assert.equal(JSON.parse(blocked.stdout).decision, 'block');
256
+ assert.match(JSON.parse(blocked.stdout).reason, /verification receipt/);
257
+ });
258
+ });
259
+
260
+ test('Codex state hook persists direct native event fields at project root', () => {
261
+ inHookFixture((root, hooks) => {
262
+ const nested = path.join(root, 'packages', 'app');
263
+ fs.mkdirSync(nested, { recursive: true });
264
+ const state = path.join(hooks, 'state.cjs');
265
+ const planResult = runHook(state, nested, {
266
+ cwd: nested,
267
+ session_id: 'session-a',
268
+ hook_event_name: 'PostToolUse',
269
+ tool_name: 'update_plan',
270
+ tool_input: {
271
+ plan: [
272
+ { step: 'Implement native hooks', status: 'completed' },
273
+ { step: 'Run sandbox smoke', status: 'in_progress' }
274
+ ]
275
+ },
276
+ tool_response: { ok: true }
277
+ });
278
+ assert.equal(planResult.status, 0);
279
+
280
+ const agentResult = runHook(state, nested, {
281
+ cwd: nested,
282
+ session_id: 'session-a',
283
+ hook_event_name: 'SubagentStop',
284
+ agent_id: 'agent-1',
285
+ agent_type: 'test_runner',
286
+ last_assistant_message: 'Native hook tests passed.'
287
+ });
288
+ assert.equal(agentResult.status, 0);
289
+
290
+ const sessionAState = codexStateDir(root, 'session-a');
291
+ const data = JSON.parse(fs.readFileSync(path.join(sessionAState, 'data.json'), 'utf8'));
292
+ assert.deepEqual(data.todos.map(({ content, status }) => ({ content, status })), [
293
+ { content: 'Implement native hooks', status: 'completed' },
294
+ { content: 'Run sandbox smoke', status: 'in_progress' }
295
+ ]);
296
+ assert.equal(data.agentResults[0].message, 'Native hook tests passed.');
297
+ assert.equal(fs.existsSync(path.join(nested, '.codex')), false);
298
+
299
+ const sessionB = runHook(state, nested, {
300
+ cwd: nested,
301
+ session_id: 'session-b',
302
+ hook_event_name: 'PostToolUse',
303
+ tool_name: 'update_plan',
304
+ tool_input: { plan: [{ step: 'Independent task', status: 'in_progress' }] },
305
+ tool_response: { ok: true }
306
+ });
307
+ assert.equal(sessionB.status, 0);
308
+ const sessionBState = codexStateDir(root, 'session-b');
309
+ const dataB = JSON.parse(fs.readFileSync(path.join(sessionBState, 'data.json'), 'utf8'));
310
+ assert.deepEqual(dataB.todos.map(({ content }) => content), ['Independent task']);
311
+ assert.notEqual(sessionAState, sessionBState);
312
+
313
+ const resumeA = runHook(state, nested, {
314
+ cwd: nested,
315
+ session_id: 'session-a',
316
+ hook_event_name: 'SessionStart',
317
+ source: 'resume'
318
+ });
319
+ assert.match(resumeA.stdout, /Implement native hooks/);
320
+ assert.doesNotMatch(resumeA.stdout, /Independent task/);
321
+
322
+ const missingSession = runHook(state, nested, {
323
+ cwd: nested,
324
+ hook_event_name: 'PostToolUse',
325
+ tool_name: 'update_plan',
326
+ tool_input: { plan: [{ step: 'Must not persist', status: 'in_progress' }] }
327
+ });
328
+ assert.equal(missingSession.status, 0);
329
+ assert.equal(codexStateDir(root, ''), null);
330
+ });
331
+ });