@skillstate/codex 2.0.4 → 2.0.6

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