@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
package/README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# @haposoft/cafekit
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Native spec-driven workflow and runtime bundle for Claude Code, Codex CLI, and OpenCode.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/haposoft/cafekit)
|
|
6
6
|
[](LICENSE)
|
|
7
|
-
[](https://claude.ai/code)
|
|
8
|
+
[](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
|
-
|
|
47
|
+
Install Codex explicitly:
|
|
40
48
|
|
|
41
49
|
```bash
|
|
42
|
-
npx @haposoft/cafekit --
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
+
});
|