@skillstate/codex 2.0.5 → 2.0.7

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.
@@ -1,260 +1,362 @@
1
1
  /**
2
- * @non-paper adapter — no adapters exist in arXiv 2608.26263v3.
2
+ * OpenAI Codex CLI adapter (codex 0.142 hooks contract, verified against
3
+ * codex-rs `features.hooks` documentation and the hooks schema):
3
4
  *
4
- * This file bridges the paper-exact core (Algorithm 1 prompt, ⊕ merge,
5
- * §7 rollback-retry rejection) into OpenAI Codex CLI sessions.
5
+ * - hooks live in `~/.codex/hooks.json` (or inline `[hooks]` TOML tables,
6
+ * `<repo>/.codex/hooks.json`, plugin-bundled `hooks/hooks.json`);
7
+ * - each hook is a `command` receiving ONE JSON document on stdin
8
+ * (`{ session_id, transcript_path, cwd, hook_event_name, model,
9
+ * permission_mode, ...event-specific }`) and running in the session cwd;
10
+ * - `UserPromptSubmit` (+`input`): JSON stdout
11
+ * `{ hookSpecificOutput: { hookEventName: "UserPromptSubmit",
12
+ * additionalContext } }` is added as developer context (plain stdout is
13
+ * added too); `matcher` is ignored;
14
+ * - `SessionStart` (+`source`): same output shape with
15
+ * `hookEventName: "SessionStart"`; `matcher` matches the source —
16
+ * `^compact$` re-injects state after compaction;
17
+ * - `PostToolUse` (+`turn_id`, `tool_name`, `tool_use_id`, `tool_input`,
18
+ * `tool_response`): `matcher` matches `tool_name` (Bash, apply_patch,
19
+ * mcp__...); stdout may carry `systemMessage` / `hookSpecificOutput`.
6
20
  *
7
- * RESEARCH (github.com/openai/codex, docs "Hooks" / "AGENTS.md"): Codex
8
- * exposes a real hook system configured as `hooks.json` (or inline
9
- * `[hooks]` tables) under `~/.codex/` and `<repo>/.codex/`, plus the
10
- * `AGENTS.md` project-instructions file that Codex loads into the agent's
11
- * context. Hooks relevant to skillstate:
21
+ * There is NO history-trimming hook in Codex (hook outputs are limited to
22
+ * additionalContext / decision / systemMessage), so hooks give O(T) prompts
23
+ * with fresh state injection. The programmatic O(1) path lives in
24
+ * `fork-trim.ts` (codex app-server thread/fork + thread/rollback).
12
25
  *
13
- * - `UserPromptSubmit` → `hookSpecificOutput.additionalContext` is added
14
- * as extra developer context on every prompt submit (state injection).
15
- * - `PostToolUse` → receives `tool_response` on stdin (JSON); a
16
- * command hook can read it, extract `state_patch`, and write the state
17
- * file (state persistence).
18
- * - `SessionStart` → `matcher: "compact"` fires after compaction and
19
- * can emit `additionalContext` (post-compaction re-injection).
20
- * - `PreCompact` → Codex only honours the shared output fields
21
- * (`continue`/`stopReason`/`systemMessage`) — it does NOT take
22
- * `additionalContext` for this event, so compaction injection is done
23
- * via `SessionStart(compact)` instead.
24
- *
25
- * LIMITATION: there is no `messages.transform` equivalent (unlike
26
- * OpenCode). Codex hooks are additive — host history is never trimmed, so
27
- * true O(1) is not possible. The best strategy is: `AGENTS.md` instructs
28
- * the model to read `.skillstate.json` each step and patch it, while the
29
- * hooks keep state injected/persisted around prompt submission and
30
- * compaction. No host history is dropped, hence @non-paper best-effort.
26
+ * @non-paper — no adapters exist in arXiv 2608.26263v3.
31
27
  */
32
28
  import * as path from 'node:path';
33
- import { atomicWriteFile, resolveStatePath, } from '@skillstate/core';
29
+ import { atomicWriteFile, resolveHostStateForCwd, resolveStatePath } from '@skillstate/core';
30
+ /** All {@link CodexHookEvent} values in generation order. */
31
+ export const CODEX_HOOK_EVENTS = [
32
+ 'user-prompt-submit',
33
+ 'session-start-compact',
34
+ 'post-tool-use',
35
+ ];
36
+ /** SessionStart matcher that fires after Codex compacts the conversation. */
37
+ export const CODEX_SESSION_START_MATCHER = '^compact$';
38
+ /** PostToolUse matcher restricted to Bash tool results. */
39
+ export const CODEX_POST_TOOL_USE_MATCHER = '^Bash$';
40
+ /**
41
+ * `additionalContextLimit` written into every generated hook entry — the
42
+ * schema default (2500 chars), spelled out so the budget is explicit.
43
+ */
44
+ export const CODEX_ADDITIONAL_CONTEXT_LIMIT = 2500;
45
+ /** Default hook `timeout` in seconds (the scripts are tiny readers/writers). */
46
+ export const CODEX_HOOK_TIMEOUT_SECONDS = 30;
34
47
  /**
35
- * Canonical `.cjs` filename suffix per Codex hook event, used by
36
- * {@link CodexAdapter.codexHookScriptPath} so `hooks.json` commands and the
37
- * on-disk hook scripts always agree.
48
+ * Resolve the per-project state file for a working directory — the SAME
49
+ * semantics as the OpenCode plugin (`<cwd>/.skillstate/skillstate.json`;
50
+ * the global bucket `<home>/.skillstate/global/skillstate.json` when cwd
51
+ * equals home). Single source of truth: {@link resolveHostStateForCwd} in
52
+ * `@skillstate/core` (the generated hook scripts keep a byte-equivalent
53
+ * embedded copy because they cannot import `@skillstate/*`).
38
54
  */
39
- export const CODEX_HOOK_SCRIPT_SUFFIX = {
40
- UserPromptSubmit: 'user-prompt-submit',
41
- PostToolUse: 'post-tool-use',
42
- SessionStart: 'session-start-compact',
43
- };
55
+ export const resolveStateForCwd = resolveHostStateForCwd;
44
56
  /**
45
57
  * OpenAI Codex platform adapter (@non-paper; see module doc).
46
58
  *
47
- * Codegen mirrors the Claude adapter's shape: every generator accepts a
48
- * raw path or a `{ root, name }` ref confined by
49
- * `resolveStatePath` — `..` escapes throw instead of embedding an unsafe
50
- * path into the generated artifact.
59
+ * Every generated hook script is a SELF-CONTAINED CommonJS file (Node
60
+ * builtins only, no `@skillstate/*` import): Codex executes them directly
61
+ * via `node <script> <event>` with the hook JSON on stdin, and each script
62
+ * resolves the per-project state from `input.cwd` at runtime — so one
63
+ * global `hooks.json` + one script directory serve every project.
51
64
  */
65
+ /** Narrow record check for hooks.json documents (module scope). */
66
+ function isPlainObjectDoc(value) {
67
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
68
+ }
52
69
  export class CodexAdapter {
53
70
  name = 'codex';
54
71
  /**
55
- * Generate an `AGENTS.md`-compatible amendment that puts the agent in
56
- * state-based execution mode: read `.skillstate.json` each step, treat
57
- * reasoning as discarded, and emit a `state_patch` that the hooks merge
58
- * back into the state file.
72
+ * Canonical absolute path of the hook script for `event` inside
73
+ * `scriptDir` (e.g. `~/.codex/hooks/skillstate/post-tool-use.cjs`).
74
+ * {@link generateHooksConfig} and {@link saveHookScript} share this
75
+ * convention so the hooks.json commands and the on-disk scripts agree.
59
76
  */
60
- generateCodexAmendments(statePath, options) {
61
- const resolved = this.resolve(statePath);
62
- const spec = options?.spec;
63
- const sections = [
64
- `## State-based execution (skillstate)`,
65
- ``,
66
- `You are running in state-based execution mode. Your execution state is`,
67
- `persisted in the JSON file at \\\`${resolved}\\\`.`,
68
- ``,
69
- `Read \\\`${resolved}\\\` at the start of EVERY step and trust it over`,
70
- `this conversation; reasoning and history are discarded between steps.`,
71
- ``,
72
- `After each step, respond with a JSON block containing exactly two keys:`,
73
- `\`state_patch\` and \`action\`.`,
74
- ``,
75
- '```json',
76
- `{`,
77
- ` "state_patch": { "field_to_update": "new_value", "obsolete_field": null },`,
78
- ` "action": "next_action_name"`,
79
- `}`,
80
- '```',
81
- ``,
82
- `- In \`state_patch\`, set keys to \`null\` to delete them. Only include`,
83
- ` fields you want to change. Omit fields to leave them unchanged.`,
84
- `- Put anything you need to persist into \`state_patch\`; never rely on`,
85
- ` the model remembering it from history.`,
86
- `- \`action\` names what you will do next.`,
87
- ];
88
- if (spec) {
89
- sections.push('', '## Skill state schema', '');
90
- for (const [name, field] of Object.entries(spec.schema)) {
91
- sections.push(`- \`${name}\` (${field.type}): ${field.description ?? 'no description'}`);
92
- }
93
- }
94
- if (options?.includeHooksNote !== false) {
95
- sections.push('', 'A Codex hooks config (`.codex/hooks.json`) keeps this state injected on', '`UserPromptSubmit` and re-injected after compaction, and persists your', '`state_patch` on `PostToolUse`. Generate one with', '`CodexAdapter.generateCodexHooksConfig`.');
96
- }
97
- return sections.join('\n') + '\n';
77
+ codexHookScriptPath(scriptDir, event) {
78
+ return path.join(scriptDir, `${event}.cjs`);
98
79
  }
99
80
  /**
100
- * Generate a markdown "read the state file" instruction block — the
101
- * inline form of the state-read contract (the core line the model
102
- * must follow), suitable for embedding in an AGENTS.md, a skill body, or
103
- * a system prompt.
104
- */
105
- generateCodexStateRead(statePath) {
106
- const resolved = this.resolve(statePath);
107
- return [
108
- `You operate in state-based execution mode.`,
109
- ``,
110
- `1. Read \\\`${resolved}\\\` at the start of every step.`,
111
- `2. Trust it over conversation history — history is discarded between steps.`,
112
- `3. Reason about the next step, then output a fenced JSON block with`,
113
- ` exactly two keys: \`state_patch\` (sparse update) and \`action\`.`,
114
- `4. Set any key in \`state_patch\` to \`null\` to delete it.`,
115
- ``,
116
- `Never persist reasoning in the state; keep it only in \`state_patch\`.`,
117
- ].join('\n');
118
- }
119
- generateCodexHookScript(eventType, statePathOrRef, schema) {
120
- const resolved = this.resolve(statePathOrRef);
121
- const sp = JSON.stringify(resolved);
122
- if (eventType === 'PostToolUse') {
123
- return this.buildPostToolUse(sp, schema ?? {});
124
- }
125
- // UserPromptSubmit and SessionStart share the same injection body.
126
- const context = this.buildInjection(sp);
127
- return [
128
- `// Codex hook: ${eventType === 'SessionStart'
129
- ? 'SessionStart (matcher: compact)'
130
- : 'UserPromptSubmit'}`,
131
- `// Injects the current skill state as additionalContext so the model`,
132
- `// never has to reconstruct execution context from history.`,
133
- ...context,
134
- ].join('\n');
135
- }
136
- /**
137
- * Generate a Codex `hooks.json` document that wires the state-injection /
138
- * persistence hooks into the agent lifecycle:
81
+ * Generate a Codex `hooks.json` document wiring the state lifecycle:
139
82
  *
140
- * - `UserPromptSubmit` → inject current state (every prompt).
141
- * - `SessionStart` (matcher `compact`) → re-inject state after compaction.
142
- * - `PostToolUse` → extract `state_patch` and persist it.
83
+ * - `UserPromptSubmit` → inject the current state as additionalContext;
84
+ * - `SessionStart` (matcher `^compact$`) → re-inject after compaction;
85
+ * - `PostToolUse` (matcher `^Bash$`) → persist `state_patch` blocks from
86
+ * Bash tool outputs.
87
+ *
88
+ * Commands are absolute `node <script> <event>` lines pointing at the
89
+ * generated `.cjs` scripts in `options.scriptDir` (default: the state
90
+ * file's directory).
143
91
  */
144
- generateCodexHooksConfig(statePath, options) {
92
+ generateHooksConfig(statePath, options) {
145
93
  const resolved = this.resolve(statePath);
146
- const defaultCommand = (eventType) => `node ${JSON.stringify(this.codexHookScriptPath(resolved, eventType))}`;
147
- const sessionStartMatcher = options?.sessionStartMatcher ?? 'compact';
148
- const command = options?.command;
94
+ const scriptDir = options?.scriptDir ?? path.dirname(resolved);
95
+ const command = (event) => options?.command ?? `node ${JSON.stringify(this.codexHookScriptPath(scriptDir, event))} ${event}`;
96
+ const timeout = options?.timeoutSeconds ?? CODEX_HOOK_TIMEOUT_SECONDS;
97
+ const entry = (event, statusMessage) => ({
98
+ type: 'command',
99
+ command: command(event),
100
+ timeout,
101
+ statusMessage,
102
+ additionalContextLimit: CODEX_ADDITIONAL_CONTEXT_LIMIT,
103
+ });
149
104
  const doc = {
150
- description: 'Skillstate lifecycle hooks: inject state per prompt, re-inject after compaction, persist state_patch.',
105
+ description: 'skillstate lifecycle hooks: inject the per-project state on every prompt submit, re-inject after compaction, and persist state_patch blocks from Bash tool outputs.',
151
106
  hooks: {
152
107
  UserPromptSubmit: [
153
108
  {
154
- hooks: [
155
- {
156
- type: 'command',
157
- command: command ?? defaultCommand('UserPromptSubmit'),
158
- statusMessage: 'Injecting skill state',
159
- },
160
- ],
109
+ hooks: [entry('user-prompt-submit', 'Injecting skill state')],
161
110
  },
162
111
  ],
163
112
  SessionStart: [
164
113
  {
165
- matcher: sessionStartMatcher,
114
+ matcher: CODEX_SESSION_START_MATCHER,
166
115
  hooks: [
167
- {
168
- type: 'command',
169
- command: command ?? defaultCommand('SessionStart'),
170
- statusMessage: 'Re-injecting skill state after compaction',
171
- },
116
+ entry('session-start-compact', 'Re-injecting skill state after compaction'),
172
117
  ],
173
118
  },
174
119
  ],
175
120
  PostToolUse: [
176
121
  {
177
- hooks: [
178
- {
179
- type: 'command',
180
- command: command ?? defaultCommand('PostToolUse'),
181
- statusMessage: 'Persisting skill state patch',
182
- },
183
- ],
122
+ matcher: CODEX_POST_TOOL_USE_MATCHER,
123
+ hooks: [entry('post-tool-use', 'Persisting skill state patch')],
184
124
  },
185
125
  ],
186
126
  },
187
127
  };
188
- return JSON.stringify(doc, null, 2) + '\n';
128
+ return `${JSON.stringify(doc, null, 2)}\n`;
189
129
  }
190
130
  /**
191
- * Canonical absolute path of the generated hook script for a Codex event,
192
- * derived from the state file name. Both {@link generateCodexHooksConfig}
193
- * and {@link saveCodexHookScript} use this single convention so the
194
- * `hooks.json` commands and the on-disk scripts always agree.
131
+ * Generate a self-contained CommonJS hook script for a Codex lifecycle
132
+ * event. The script reads ONE hook JSON document from stdin, resolves the
133
+ * state file from `input.cwd` (the session cwd) via the
134
+ * {@link resolveStateForCwd} semantics, and:
195
135
  *
196
- * For `./.skillstate.json`:
197
- * - `UserPromptSubmit` → `.../.codex-.skillstate-user-prompt-submit.cjs`
198
- * - `SessionStart` → `.../.codex-.skillstate-session-start-compact.cjs`
199
- * - `PostToolUse` → `.../.codex-.skillstate-post-tool-use.cjs`
136
+ * - `user-prompt-submit`: emits
137
+ * `{ hookSpecificOutput: { hookEventName: "UserPromptSubmit",
138
+ * additionalContext } }` carrying the current state JSON;
139
+ * - `session-start-compact`: the same injection with
140
+ * `hookEventName: "SessionStart"` (state survives compaction);
141
+ * - `post-tool-use`: extracts a `state_patch` from the tool_response
142
+ * (fenced ```json block or raw JSON), applies the ⊕ null-deletion merge
143
+ * and writes the state file; stdout is `{}` or a `systemMessage` when
144
+ * the patch is invalid.
200
145
  *
201
- * Accepts a raw state path or a `{ root, name }` ref resolved via
202
- * `resolveStatePath`.
146
+ * `statePath` is accepted for `{ root, name }` confinement (traversal
147
+ * refs throw) and documented in the script header; the content itself is
148
+ * cwd-resolving and never bakes an absolute state path in.
203
149
  */
204
- codexHookScriptPath(statePath, eventType) {
205
- const resolved = this.resolve(statePath);
206
- const dir = path.dirname(resolved);
207
- const base = path.basename(resolved, '.json');
208
- return path.join(dir, `.codex-${base}-${CODEX_HOOK_SCRIPT_SUFFIX[eventType]}.cjs`);
150
+ generateHookScript(event, statePath) {
151
+ const resolved = statePath === undefined ? undefined : this.resolve(statePath);
152
+ const header = `// State file (per-project resolver): ${resolved ?? '<cwd>/.skillstate/skillstate.json (global bucket when cwd === home)'}`;
153
+ if (event === 'post-tool-use') {
154
+ return this.buildPostToolUseScript(header);
155
+ }
156
+ const hookEventName = event === 'session-start-compact' ? 'SessionStart' : 'UserPromptSubmit';
157
+ return [
158
+ '#!/usr/bin/env node',
159
+ `// skillstate Codex hook — ${hookEventName} (generated by @skillstate/codex).`,
160
+ '// Self-contained CommonJS: reads one hook JSON document on stdin,',
161
+ '// resolves the state from the SESSION cwd, and emits',
162
+ '// { hookSpecificOutput: { hookEventName, additionalContext } }.',
163
+ header,
164
+ "'use strict';",
165
+ 'const fs = require("fs");',
166
+ 'const os = require("os");',
167
+ 'const path = require("path");',
168
+ '',
169
+ `const HOOK_EVENT_NAME = ${JSON.stringify(hookEventName)};`,
170
+ '',
171
+ 'function resolveStatePathForCwd(cwd) {',
172
+ ' const resolvedCwd = path.resolve(cwd);',
173
+ ' const resolvedHome = path.resolve(os.homedir());',
174
+ ' if (resolvedCwd === resolvedHome) {',
175
+ ' return path.join(resolvedHome, ".skillstate", "global", "skillstate.json");',
176
+ ' }',
177
+ ' return path.join(resolvedCwd, ".skillstate", "skillstate.json");',
178
+ '}',
179
+ '',
180
+ 'function readState(statePath) {',
181
+ ' try {',
182
+ ' if (fs.existsSync(statePath)) {',
183
+ ' const parsed = JSON.parse(fs.readFileSync(statePath, "utf-8"));',
184
+ ' if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {',
185
+ ' if (parsed.state !== null && typeof parsed.state === "object" && !Array.isArray(parsed.state)) {',
186
+ ' return parsed.state;',
187
+ ' }',
188
+ ' return parsed;',
189
+ ' }',
190
+ ' }',
191
+ ' } catch (error) {}',
192
+ ' return {};',
193
+ '}',
194
+ '',
195
+ 'let raw = "";',
196
+ 'process.stdin.setEncoding("utf-8");',
197
+ 'process.stdin.on("data", (chunk) => { raw += chunk; });',
198
+ 'process.stdin.on("end", () => {',
199
+ ' let cwd = process.cwd();',
200
+ ' try {',
201
+ ' const input = JSON.parse(raw);',
202
+ ' if (typeof input.cwd === "string" && input.cwd.length > 0) {',
203
+ ' cwd = input.cwd;',
204
+ ' }',
205
+ ' } catch (error) {}',
206
+ ' const state = readState(resolveStatePathForCwd(cwd));',
207
+ ' const output = {',
208
+ ' hookSpecificOutput: {',
209
+ ' hookEventName: HOOK_EVENT_NAME,',
210
+ ' additionalContext: "Current skill state (JSON): " + JSON.stringify(state)',
211
+ ' + "\\nPersist anything you need into state via the skillstate MCP tools (state.patch). History is not reliable.",',
212
+ ' },',
213
+ ' };',
214
+ ' process.stdout.write(JSON.stringify(output));',
215
+ '});',
216
+ '',
217
+ ].join('\n');
218
+ }
219
+ /**
220
+ * Generate a SKILL.md for Codex's skill directory
221
+ * (`~/.codex/skills/<name>/SKILL.md`). The body instructs the agent to
222
+ * treat the hook-injected state as authoritative (history is not
223
+ * reliable), to read state via the skillstate MCP tool `state.get`, and
224
+ * to persist via `state.patch` — the PostToolUse hook also merges any
225
+ * fenced ```json `state_patch` block printed by a Bash tool call.
226
+ */
227
+ generateSkillMd(spec, statePath) {
228
+ const resolvedStatePath = statePath ?? './.skillstate/skillstate.json';
229
+ const fence = '```';
230
+ return [
231
+ '---',
232
+ `name: ${JSON.stringify(spec.name)}`,
233
+ `description: ${JSON.stringify(spec.instructions)}`,
234
+ `version: ${spec.version}`,
235
+ 'execution_context:',
236
+ ` state_path: ${resolvedStatePath}`,
237
+ ' format: json',
238
+ '---',
239
+ '',
240
+ `# ${spec.name}`,
241
+ '',
242
+ spec.instructions,
243
+ '',
244
+ '## Execution Context',
245
+ '',
246
+ `Your execution state lives at \`${resolvedStatePath}\` (per project; a`,
247
+ 'session started in $HOME uses `~/.skillstate/global/skillstate.json`).',
248
+ 'The skillstate Codex hooks:',
249
+ '',
250
+ '- inject the CURRENT state as developer context on every prompt submit',
251
+ ' (`UserPromptSubmit`) and re-inject it after compaction',
252
+ ' (`SessionStart` matcher `^compact$`);',
253
+ '- watch every Bash tool result and merge a fenced ```json block carrying',
254
+ ' a `state_patch` into the state file (`PostToolUse`).',
255
+ '',
256
+ 'The injected state is authoritative — history is not reliable. Never',
257
+ 'reconstruct execution context from the conversation.',
258
+ '',
259
+ '## Process',
260
+ '',
261
+ '1. Read the current state from the injected context, or fetch it with',
262
+ ' the skillstate MCP tool `state.get`.',
263
+ '2. Observe the result of your last action.',
264
+ '3. Reason about what to do next, given the state and the observation.',
265
+ '4. Persist progress with the skillstate MCP tool `state.patch` (sparse',
266
+ ' patch; set a key to `null` to delete it), and/or emit a fenced JSON',
267
+ ' block with exactly two keys inside a Bash tool call so the',
268
+ ' `PostToolUse` hook merges it:',
269
+ '',
270
+ `${fence}json`,
271
+ '{',
272
+ ' "state_patch": { "key": "new_value", "obsolete_key": null },',
273
+ ' "action": "next_action_name"',
274
+ '}',
275
+ fence,
276
+ '',
277
+ '- In `state_patch`, set keys to `null` to delete them. Only include',
278
+ ' fields you want to change. Omit fields to leave them unchanged.',
279
+ '- Put anything you need to survive into `state_patch`; never rely on',
280
+ ' the conversation remembering it.',
281
+ '- `action` names what you will do next (e.g. "continue", "done").',
282
+ '',
283
+ ].join('\n');
209
284
  }
210
285
  /**
211
- * @non-paper additive helper: generate the AGENTS.md amendment and persist
212
- * it via `atomicWriteFile` (tmp + fsync + rename). Both the destination and
213
- * the embedded state path accept raw strings or
286
+ * Generate the hooks.json document and persist it via `atomicWriteFile`.
287
+ * Both the destination and the embedded state path accept raw strings or
214
288
  * `{ root, name }` refs confined by `resolveStatePath`. Returns the
215
289
  * absolute destination path.
216
290
  */
217
- async saveCodexAmendments(target, statePath, options) {
291
+ async saveHooksConfig(target, statePath, options) {
218
292
  const dest = this.resolve(target);
219
- const resolved = this.resolve(statePath);
220
- const content = this.generateCodexAmendments(resolved, options);
221
- await atomicWriteFile(dest, content);
293
+ await atomicWriteFile(dest, this.generateHooksConfig(statePath, options));
222
294
  return dest;
223
295
  }
224
296
  /**
225
- * @non-paper additive helper: generate the hooks.json document and persist
226
- * it via `atomicWriteFile`. Both the destination and the embedded state
227
- * path accept raw strings or `{ root, name }` refs. Returns the absolute
228
- * destination path.
297
+ * Merge the skillstate hook groups into an existing `hooks.json` text.
298
+ * Idempotent: if any skillstate command is already wired, the document is
299
+ * returned unchanged. Existing (non-skillstate) hooks are preserved.
300
+ */
301
+ mergeHooksConfig(existingJson, options) {
302
+ let doc = {};
303
+ try {
304
+ doc = JSON.parse(existingJson);
305
+ }
306
+ catch {
307
+ // Missing or malformed user file: start from a fresh document.
308
+ }
309
+ if (!isPlainObjectDoc(doc.hooks)) {
310
+ doc.hooks = {};
311
+ }
312
+ const scriptDir = options?.scriptDir ?? '<stateDir>/hooks';
313
+ const command = (event) => `node ${JSON.stringify(this.codexHookScriptPath(scriptDir, event))} ${event}`;
314
+ const skillstateCommands = new Set(CODEX_HOOK_EVENTS.map((event) => JSON.stringify(command(event))));
315
+ let alreadyWired = false;
316
+ for (const groups of Object.values(doc.hooks)) {
317
+ if (!Array.isArray(groups))
318
+ continue;
319
+ for (const group of groups) {
320
+ if (!isPlainObjectDoc(group) || !Array.isArray(group['hooks']))
321
+ continue;
322
+ for (const handler of group['hooks']) {
323
+ if (isPlainObjectDoc(handler) && skillstateCommands.has(JSON.stringify(handler['command']))) {
324
+ alreadyWired = true;
325
+ }
326
+ }
327
+ }
328
+ }
329
+ if (alreadyWired) {
330
+ return `${JSON.stringify(doc, null, 2)}\n`;
331
+ }
332
+ const statePathRef = { root: path.dirname(options?.scriptDir ?? '.'), name: 'skillstate.json' };
333
+ const generated = JSON.parse(this.generateHooksConfig(statePathRef, { ...options, scriptDir }));
334
+ for (const [event, groups] of Object.entries(generated.hooks)) {
335
+ const existing = Array.isArray(doc.hooks[event])
336
+ ? doc.hooks[event]
337
+ : [];
338
+ doc.hooks[event] = [...existing, ...groups];
339
+ }
340
+ return `${JSON.stringify(doc, null, 2)}\n`;
341
+ }
342
+ /**
343
+ * Generate a hook script and persist it via `atomicWriteFile`. `target`
344
+ * is the script destination (usually
345
+ * {@link CodexAdapter.codexHookScriptPath}); `statePath` is forwarded to
346
+ * {@link generateHookScript}. Returns the absolute destination path.
229
347
  */
230
- async saveCodexHooksConfig(target, statePath, options) {
348
+ async saveHookScript(event, target, statePath) {
231
349
  const dest = this.resolve(target);
232
- const resolved = this.resolve(statePath);
233
- const content = this.generateCodexHooksConfig(resolved, options);
234
- await atomicWriteFile(dest, content);
350
+ await atomicWriteFile(dest, this.generateHookScript(event, statePath));
235
351
  return dest;
236
352
  }
237
- async saveCodexHookScript(targetOrEvent, eventTypeOrPath, statePathOrSchema, schema) {
238
- const refForm = typeof targetOrEvent === 'string' &&
239
- (targetOrEvent === 'UserPromptSubmit' ||
240
- targetOrEvent === 'PostToolUse' ||
241
- targetOrEvent === 'SessionStart');
242
- const target = refForm
243
- ? this.codexHookScriptPath(eventTypeOrPath, targetOrEvent)
244
- : targetOrEvent;
245
- const eventType = refForm
246
- ? targetOrEvent
247
- : eventTypeOrPath;
248
- const statePath = refForm
249
- ? eventTypeOrPath
250
- : statePathOrSchema;
251
- const effectiveSchema = refForm
252
- ? statePathOrSchema
253
- : schema;
353
+ /**
354
+ * Generate a SKILL.md and persist it via `atomicWriteFile`. Returns the
355
+ * absolute destination path.
356
+ */
357
+ async saveSkillMd(target, spec, statePath) {
254
358
  const dest = this.resolve(target);
255
- const resolved = this.resolve(statePath);
256
- const content = this.generateCodexHookScript(eventType, resolved, effectiveSchema);
257
- await atomicWriteFile(dest, content);
359
+ await atomicWriteFile(dest, this.generateSkillMd(spec, statePath));
258
360
  return dest;
259
361
  }
260
362
  /* ------------------------------------------------------------------ */
@@ -266,167 +368,168 @@ export class CodexAdapter {
266
368
  ? target
267
369
  : resolveStatePath(target.root, target.name);
268
370
  }
269
- /** Shared injection body for injection-only hooks (read + emit context). */
270
- buildInjection(sp) {
371
+ /** PostToolUse script: extract state_patch, ⊕ merge, persist. */
372
+ buildPostToolUseScript(header) {
373
+ const fence = '```';
271
374
  return [
375
+ '#!/usr/bin/env node',
376
+ '// skillstate Codex hook — PostToolUse (generated by @skillstate/codex).',
377
+ '// Self-contained CommonJS: reads one hook JSON document on stdin,',
378
+ '// extracts state_patch from the tool_response (fenced ```json block or',
379
+ '// raw JSON), applies the null-deletion merge and writes the state file.',
380
+ '// stdout is "{}" or a systemMessage when the patch is invalid.',
381
+ header,
382
+ "'use strict';",
272
383
  'const fs = require("fs");',
273
- `const stateFilePath = ${sp};`,
274
- 'let state = {};',
275
- 'try {',
276
- ' if (fs.existsSync(stateFilePath)) {',
277
- ' state = JSON.parse(fs.readFileSync(stateFilePath, "utf-8"));',
278
- ' }',
279
- '} catch (e) { state = {}; }',
280
- 'const output = {',
281
- ' hookSpecificOutput: {',
282
- ' additionalContext: "Current skill state (JSON): " + JSON.stringify(state)',
283
- ' }',
284
- '};',
285
- 'process.stdout.write(JSON.stringify(output));',
286
- ];
287
- }
288
- /** PostToolUse body: extract, validate (if schema given), merge, persist. */
289
- buildPostToolUse(sp, schema) {
290
- const fence = '`' + '`' + '`';
291
- const schemaJson = JSON.stringify(schema);
292
- return [
293
- '// Codex hook: PostToolUse',
294
- '// Extracts state_patch from the tool_response, validates it against the',
295
- '// embedded schema (when provided), applies the null-deletion merge, and',
296
- '// saves. Malformed output is rejected and never persisted.',
297
- 'const fs = require("fs");',
298
- `const stateFilePath = ${sp};`,
299
- `const schema = ${schemaJson};`,
384
+ 'const os = require("os");',
385
+ 'const path = require("path");',
300
386
  '',
301
- 'function isPlainObject(v) {',
302
- ' return typeof v === "object" && v !== null && !Array.isArray(v);',
387
+ 'function isPlainObject(value) {',
388
+ ' return typeof value === "object" && value !== null && !Array.isArray(value);',
303
389
  '}',
304
390
  '',
305
- 'function mergePatch(base, patch) {',
306
- ' function mergeInto(result, patchObj) {',
307
- ' for (const key of Object.keys(patchObj)) {',
308
- ' const value = patchObj[key];',
309
- ' if (value === null) {',
310
- ' delete result[key];',
311
- ' } else if (isPlainObject(value) && isPlainObject(result[key])) {',
312
- ' result[key] = mergeInto({ ...result[key] }, value);',
313
- ' } else {',
314
- ' result[key] = value;',
391
+ 'function resolveStatePathForCwd(cwd) {',
392
+ ' const resolvedCwd = path.resolve(cwd);',
393
+ ' const resolvedHome = path.resolve(os.homedir());',
394
+ ' if (resolvedCwd === resolvedHome) {',
395
+ ' return path.join(resolvedHome, ".skillstate", "global", "skillstate.json");',
396
+ ' }',
397
+ ' return path.join(resolvedCwd, ".skillstate", "skillstate.json");',
398
+ '}',
399
+ '',
400
+ 'function readState(statePath) {',
401
+ ' try {',
402
+ ' if (fs.existsSync(statePath)) {',
403
+ ' const parsed = JSON.parse(fs.readFileSync(statePath, "utf-8"));',
404
+ ' if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {',
405
+ ' if (parsed.state !== null && typeof parsed.state === "object" && !Array.isArray(parsed.state)) {',
406
+ ' return parsed.state;',
407
+ ' }',
408
+ ' return parsed;',
315
409
  ' }',
316
410
  ' }',
317
- ' return result;',
318
- ' }',
319
- ' return mergeInto({ ...base }, patch);',
411
+ ' } catch (error) {}',
412
+ ' return {};',
320
413
  '}',
321
414
  '',
322
- 'function validatePatchAgainstSchema(patch) {',
415
+ '// Paper ⊕ merge: null deletes a key, nested plain objects merge',
416
+ '// recursively, everything else replaces.',
417
+ 'function mergePatch(base, patch) {',
418
+ ' const result = { ...base };',
323
419
  ' for (const key of Object.keys(patch)) {',
324
- ' const field = schema[key];',
325
- ' if (!field) {',
326
- ' return "Unknown key: " + key;',
327
- ' }',
328
420
  ' const value = patch[key];',
329
- ' if (value === null) continue;',
330
- ' const expected = field.type;',
331
- ' let ok = false;',
332
- ' if (expected === "string") ok = typeof value === "string";',
333
- ' else if (expected === "number") ok = typeof value === "number";',
334
- ' else if (expected === "boolean") ok = typeof value === "boolean";',
335
- ' else if (expected === "array") ok = Array.isArray(value);',
336
- ' else if (expected === "object") ok = isPlainObject(value);',
337
- ' if (!ok) {',
338
- ' return "Invalid type for field \'" + key + "\': expected " + expected +',
339
- ' ", got " + (Array.isArray(value) ? "array" : typeof value);',
421
+ ' if (value === null) {',
422
+ ' delete result[key];',
423
+ ' } else if (isPlainObject(value) && isPlainObject(result[key])) {',
424
+ ' result[key] = mergePatch(result[key], value);',
425
+ ' } else {',
426
+ ' result[key] = value;',
340
427
  ' }',
341
428
  ' }',
342
- ' return null;',
429
+ ' return result;',
343
430
  '}',
344
431
  '',
345
- 'function isJsonObjectWithStatePatch(value) {',
346
- ' return value !== null &&',
347
- ' typeof value === "object" &&',
348
- ' !Array.isArray(value) &&',
349
- ' value.state_patch !== null &&',
350
- ' typeof value.state_patch === "object" &&',
351
- ' !Array.isArray(value.state_patch);',
432
+ 'function readResponseText(response) {',
433
+ ' if (typeof response === "string") return response;',
434
+ ' if (isPlainObject(response)) {',
435
+ ' if (typeof response.content === "string") return response.content;',
436
+ ' if (typeof response.text === "string") return response.text;',
437
+ ' return JSON.stringify(response);',
438
+ ' }',
439
+ ' return response === null || response === undefined ? "" : String(response);',
352
440
  '}',
353
441
  '',
354
- 'function tryParseStandaloneJson(text) {',
355
- ' const trimmed = String(text).trim();',
442
+ `const FENCE = ${JSON.stringify(fence)};`,
443
+ 'const FENCE_RE = /' + '```' + 'json\\s*\\n?([\\s\\S]*?)\\n?\\s*' + '```' + '/;',
444
+ '// An open fence that never closes (truncated output) is still a patch',
445
+ '// attempt: match from ```json to end-of-text so it classifies as invalid.',
446
+ 'const OPEN_FENCE_RE = /' + '```' + 'json\\s*\\n?([\\s\\S]+)$/',
447
+ '',
448
+ '// Look for a fenced ```json block: { patch } when it parses and carries',
449
+ '// an object state_patch, { invalid: true } when a block exists but is',
450
+ '// malformed, { absent: true } when there is no block at all.',
451
+ 'function findFencedPatch(text) {',
452
+ ' const match = text.match(FENCE_RE) || text.match(OPEN_FENCE_RE);',
453
+ ' if (!match) return { absent: true };',
356
454
  ' try {',
357
- ' const parsed = JSON.parse(trimmed);',
358
- ' if (isJsonObjectWithStatePatch(parsed)) return parsed;',
359
- ' } catch (e) {}',
455
+ ' const parsed = JSON.parse(match[1]);',
456
+ ' if (isPlainObject(parsed) && isPlainObject(parsed.state_patch)) {',
457
+ ' return { patch: parsed.state_patch };',
458
+ ' }',
459
+ ' } catch (error) {}',
460
+ ' return { invalid: true };',
461
+ '}',
462
+ '',
463
+ '// Fallback: a raw JSON object with state_patch anywhere in the text.',
464
+ '// Ordinary JSON output without a state_patch key is simply not a patch.',
465
+ 'function findRawPatch(text) {',
466
+ ' const trimmed = text.trim();',
467
+ ' const candidates = [];',
468
+ ' try {',
469
+ ' candidates.push(JSON.parse(trimmed));',
470
+ ' } catch (error) {}',
360
471
  ' const first = trimmed.indexOf("{");',
361
472
  ' const last = trimmed.lastIndexOf("}");',
362
473
  ' if (first !== -1 && last > first) {',
363
474
  ' try {',
364
- ' const parsed = JSON.parse(trimmed.slice(first, last + 1));',
365
- ' if (isJsonObjectWithStatePatch(parsed)) return parsed;',
366
- ' } catch (e) {}',
367
- ' }',
368
- ' return null;',
369
- '}',
370
- '',
371
- 'function extractPatchString(content) {',
372
- ' if (typeof content !== "string") return null;',
373
- ' const match = content.match(/' + fence + 'json\\s*\\n?([\\s\\S]*?)\\n?\\s*' + fence + '/);',
374
- ' if (match) {',
375
- ' try {',
376
- ' const parsed = JSON.parse(match[1]);',
377
- ' if (isJsonObjectWithStatePatch(parsed)) return parsed;',
378
- ' } catch (e) {}',
475
+ ' candidates.push(JSON.parse(trimmed.slice(first, last + 1)));',
476
+ ' } catch (error) {}',
379
477
  ' }',
380
- ' return tryParseStandaloneJson(content);',
381
- '}',
382
- '',
383
- 'function readResponseText(response) {',
384
- ' if (response === null || response === undefined) return "";',
385
- ' if (typeof response === "string") return response;',
386
- ' if (isPlainObject(response)) {',
387
- ' if (typeof response.content === "string") return response.content;',
388
- ' if (typeof response.text === "string") return response.text;',
389
- ' return JSON.stringify(response);',
478
+ ' for (const candidate of candidates) {',
479
+ ' if (isPlainObject(candidate)) {',
480
+ ' if (isPlainObject(candidate.state_patch)) {',
481
+ ' return { patch: candidate.state_patch };',
482
+ ' }',
483
+ ' if (Object.prototype.hasOwnProperty.call(candidate, "state_patch")) {',
484
+ ' return { invalid: true };',
485
+ ' }',
486
+ ' }',
390
487
  ' }',
391
- ' return JSON.stringify(response);',
488
+ ' return { absent: true };',
392
489
  '}',
393
490
  '',
394
- 'let state = {};',
395
- 'try {',
396
- ' if (fs.existsSync(stateFilePath)) {',
397
- ' state = JSON.parse(fs.readFileSync(stateFilePath, "utf-8"));',
398
- ' }',
399
- '} catch (e) { state = {}; }',
400
- 'let input = "";',
491
+ 'let raw = "";',
401
492
  'process.stdin.setEncoding("utf-8");',
402
- 'process.stdin.on("data", (chunk) => { input += chunk; });',
493
+ 'process.stdin.on("data", (chunk) => { raw += chunk; });',
403
494
  'process.stdin.on("end", () => {',
404
495
  ' const output = {};',
405
496
  ' try {',
406
- ' const parsed = JSON.parse(input);',
407
- ' const response = parsed.tool_response ?? parsed.content ?? "";',
408
- ' const text = readResponseText(response);',
409
- ' let json;',
497
+ ' const input = JSON.parse(raw);',
498
+ ' let cwd = process.cwd();',
499
+ ' if (typeof input.cwd === "string" && input.cwd.length > 0) {',
500
+ ' cwd = input.cwd;',
501
+ ' }',
502
+ ' const statePath = resolveStatePathForCwd(cwd);',
503
+ ' const response = input.tool_response;',
504
+ ' let result;',
410
505
  ' if (isPlainObject(response) && isPlainObject(response.state_patch)) {',
411
- ' json = response;',
506
+ ' result = { patch: response.state_patch };',
412
507
  ' } else {',
413
- ' json = extractPatchString(text);',
508
+ ' const text = readResponseText(response);',
509
+ ' result = findFencedPatch(text);',
510
+ ' if (result.absent) result = findRawPatch(text);',
414
511
  ' }',
415
- ' if (json && json.state_patch && typeof json.state_patch === "object" && !Array.isArray(json.state_patch)) {',
416
- ' const validationError = validatePatchAgainstSchema(json.state_patch);',
417
- ' if (validationError) {',
418
- ' output.error = validationError;',
419
- ' process.stdout.write(JSON.stringify(output));',
420
- ' return;',
512
+ ' if (result.patch !== undefined) {',
513
+ ' const merged = mergePatch(readState(statePath), result.patch);',
514
+ ' try {',
515
+ ' fs.mkdirSync(path.dirname(statePath), { recursive: true });',
516
+ ' fs.writeFileSync(statePath, JSON.stringify({ version: 1, state: merged }, null, 2) + "\\n");',
517
+ ' } catch (writeError) {',
518
+ " output.systemMessage = 'skillstate: failed to persist state (' + writeError.message + ')';",
421
519
  ' }',
422
- ' state = mergePatch(state, json.state_patch);',
423
- ' fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2));',
520
+ ' process.stdout.write(JSON.stringify(output));',
521
+ ' return;',
522
+ ' }',
523
+ ' if (result.invalid) {',
524
+ ' output.systemMessage =',
525
+ ' "skillstate: ignored an invalid state patch (expected a " + FENCE + "json block with a state_patch object)";',
424
526
  ' }',
425
- ' } catch (e) {',
426
- ' output.error = "Failed to process PostToolUse input: " + e.message;',
527
+ ' } catch (error) {',
528
+ ' output.systemMessage = "skillstate: failed to process PostToolUse input: " + error.message;',
427
529
  ' }',
428
530
  ' process.stdout.write(JSON.stringify(output));',
429
531
  '});',
532
+ '',
430
533
  ].join('\n');
431
534
  }
432
535
  }