@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.
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
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.14.2-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.15.1-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:
40
48
 
41
49
  ```bash
42
- npx @haposoft/cafekit --upgrade
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:
55
+
56
+ ```bash
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
 
@@ -90,6 +110,33 @@ CLAUDE.md
90
110
  CafeKit owns only its marked block inside the project-root `CLAUDE.md`; project
91
111
  instructions outside that block are preserved across installs and upgrades.
92
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
+
93
140
  OpenCode targets:
94
141
 
95
142
  ```text
@@ -119,6 +166,7 @@ To check the installed CafeKit package version:
119
166
 
120
167
  ```bash
121
168
  cat .claude/cafekit.json
169
+ cat .codex/cafekit.json
122
170
  cat .opencode/cafekit.json
123
171
  ```
124
172
 
@@ -144,6 +192,10 @@ CafeKit uses rule-based skill routing guidance instead of an automatic prompt-sc
144
192
  node .claude/scripts/generate-skill-catalog.cjs --skills
145
193
  ```
146
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
+
147
199
  ## Quick Start
148
200
 
149
201
  Claude Code:
@@ -157,6 +209,17 @@ Claude Code:
157
209
  /hapo:code-review meet-transcript-mvp --pending
158
210
  ```
159
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
+
160
223
  OpenCode uses the generated command names without the Claude `hapo:` prefix:
161
224
 
162
225
  ```bash
@@ -176,6 +239,14 @@ Reconstruct as-is docs from a legacy codebase:
176
239
 
177
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`.
178
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
+
179
250
  ## Spec Output
180
251
 
181
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
+ });