@skillstate/claude 2.0.6 → 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,5 +1,68 @@
1
+ /**
2
+ * Claude Code adapter (2.1.260 hooks contract, verified against the Claude
3
+ * Code hooks reference, Sep 2026):
4
+ *
5
+ * - hooks are configured in `~/.claude/settings.json` (user scope) as
6
+ * `{ hooks: { Event: [ { matcher?, if?, hooks: [ { type: "command",
7
+ * command, timeout } ] } ] } }`; handler `timeout` is seconds;
8
+ * - `UserPromptSubmit` (+`prompt`): JSON stdout
9
+ * `{ hookSpecificOutput: { hookEventName: "UserPromptSubmit",
10
+ * additionalContext } }` is added to Claude's context (plain stdout is
11
+ * added too); no matcher support — fires on every prompt; default
12
+ * timeout 30s;
13
+ * - `SessionStart` (+`source`): matcher `^compact$` fires after auto or
14
+ * manual compaction and supports `additionalContext` — this is how state
15
+ * SURVIVES compaction;
16
+ * - `PostToolUse` (+`tool_name`, `tool_input`, `tool_response`): matcher
17
+ * `^Bash$` restricts to Bash results; stdout `{}` or a `systemMessage`
18
+ * when the response carried an invalid state patch.
19
+ *
20
+ * HONEST LIMITATION: history trimming from hooks is IMPOSSIBLE in Claude
21
+ * Code. The compact-adjacent events cannot inject context — the compaction
22
+ * hook supports only `decision: "block"` (forbid compaction) and the
23
+ * post-compaction hook has NO decision control (its `systemMessage` is
24
+ * discarded). So the model here is state-injection per prompt
25
+ * (`UserPromptSubmit`), survival through compaction
26
+ * (`SessionStart` matcher `^compact$`), patch persistence per Bash result
27
+ * (`PostToolUse`), and full read/write via the skillstate MCP tools
28
+ * (`state.get` / `state.patch`). Prompts stay O(T) with fresh state at
29
+ * every turn; true O(1) requires host-side trimming which the host does
30
+ * not expose.
31
+ *
32
+ * Every generated hook script is a SELF-CONTAINED CommonJS file (Node
33
+ * builtins only, no `@skillstate/*` import) that resolves the per-project
34
+ * state from `input.cwd` at runtime — one global `settings.json` hooks
35
+ * section + one script directory serve every project.
36
+ *
37
+ * @non-paper — no adapters exist in arXiv 2608.26263v3.
38
+ */
39
+ import * as path from 'node:path';
1
40
  import { PromptTransformer } from '@skillstate/core';
2
41
  import { atomicWriteFile, resolveStatePath, } from '@skillstate/core';
42
+ export { resolveHostStateForCwd as resolveStateForCwd } from '@skillstate/core';
43
+ /** All {@link ClaudeHookEvent} values in generation order. */
44
+ export const CLAUDE_HOOK_EVENTS = [
45
+ 'user-prompt-submit',
46
+ 'session-start-compact',
47
+ 'post-tool-use',
48
+ ];
49
+ /** SessionStart matcher that fires after Claude Code compacts the conversation. */
50
+ export const CLAUDE_SESSION_START_MATCHER = '^compact$';
51
+ /** PostToolUse matcher restricted to Bash tool results. */
52
+ export const CLAUDE_POST_TOOL_USE_MATCHER = '^Bash$';
53
+ /**
54
+ * Hook `timeout` in seconds written into every generated hook entry — the
55
+ * UserPromptSubmit default, spelled out so the budget is explicit.
56
+ */
57
+ export const CLAUDE_HOOK_TIMEOUT_SECONDS = 30;
58
+ /** Narrow record check for settings.json documents (module scope). */
59
+ function isPlainObjectDoc(value) {
60
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
61
+ }
62
+ /**
63
+ * Claude Code platform adapter (@non-paper; see module doc for the 2.1.260
64
+ * hooks contract and the honest O(T) limitation).
65
+ */
3
66
  export class ClaudeAdapter {
4
67
  name = 'claude';
5
68
  transformer = new PromptTransformer({ platform: 'claude' });
@@ -41,261 +104,308 @@ In \`state_patch\`, set keys to null to delete them. Only include fields you wan
41
104
  return this.transformer.extractAction(response);
42
105
  }
43
106
  /**
44
- * @non-paper — adapter convenience (delegates to the transformer).
107
+ * @non-paper adapter convenience (delegates to the transformer).
45
108
  * Paper-exact callers use `PromptTransformer.formatPaper` (Appendix A.4).
46
109
  */
47
110
  formatPrompt(state, observation, spec) {
48
111
  return this.transformer.formatForClaude(spec, state, observation);
49
112
  }
50
- generateHookScript(eventType, statePathOrRef, schema) {
51
- const statePath = typeof statePathOrRef === 'string'
52
- ? statePathOrRef
53
- : resolveStatePath(statePathOrRef.root, statePathOrRef.name);
54
- const sp = JSON.stringify(statePath);
55
- if (eventType === 'PreToolUse') {
56
- return [
57
- '// Claude hook: PreToolUse',
58
- '// Reads skill state and injects it into the tool\'s additionalContext.',
59
- '// ADDITIVE: the host history is left untouched, so this alone does not',
60
- '// reproduce the paper O(1) footprint (see module doc).',
61
- 'const fs = require("fs");',
62
- 'const stateFilePath = ' + sp + ';',
63
- 'let state = {};',
64
- 'try {',
65
- ' if (fs.existsSync(stateFilePath)) {',
66
- ' state = JSON.parse(fs.readFileSync(stateFilePath, "utf-8"));',
67
- ' }',
68
- '} catch (e) { state = {}; }',
69
- 'const stateJson = JSON.stringify(state);',
70
- 'const output = {',
71
- ' hookSpecificOutput: {',
72
- ' additionalContext: "Current skill state (JSON): " + stateJson',
73
- ' }',
74
- '};',
75
- 'process.stdout.write(JSON.stringify(output));',
76
- ].join('\n');
113
+ /**
114
+ * Canonical absolute path of the hook script for `event` inside
115
+ * `scriptDir` (e.g. `~/.claude/hooks/skillstate/user-prompt-submit.cjs`).
116
+ * {@link generateHooksConfig} and {@link saveHookScript} share this
117
+ * convention so the settings.json commands and the on-disk scripts agree.
118
+ */
119
+ claudeHookScriptPath(scriptDir, event) {
120
+ return path.join(scriptDir, `${event}.cjs`);
121
+ }
122
+ /**
123
+ * Generate the hooks section for `~/.claude/settings.json` (2.1.260
124
+ * schema: `{ hooks: { Event: [ { matcher?, hooks: [ { type: "command",
125
+ * command, timeout } ] } ] } }`). The document carries ONLY the
126
+ * `hooks` key — {@link mergeHooksConfig} is what splices these groups
127
+ * into a live settings.json while preserving every other key:
128
+ *
129
+ * - `UserPromptSubmit` → inject the current state as additionalContext;
130
+ * - `SessionStart` (matcher `^compact$`) → re-inject after compaction;
131
+ * - `PostToolUse` (matcher `^Bash$`) → persist `state_patch` blocks from
132
+ * Bash tool outputs.
133
+ *
134
+ * Commands are absolute `node <script> <event>` lines pointing at the
135
+ * generated `.cjs` scripts in `options.scriptDir` (default: the state
136
+ * file's directory, else a `<stateDir>/hooks` placeholder). No matcher
137
+ * on UserPromptSubmit — the event has no matcher support and fires on
138
+ * every prompt.
139
+ */
140
+ generateHooksConfig(statePath, options) {
141
+ const scriptDir = options?.scriptDir ??
142
+ (statePath === undefined ? '<stateDir>/hooks' : path.dirname(this.resolve(statePath)));
143
+ const command = (event) => options?.command ?? `node ${JSON.stringify(this.claudeHookScriptPath(scriptDir, event))} ${event}`;
144
+ const timeout = options?.timeoutSeconds ?? CLAUDE_HOOK_TIMEOUT_SECONDS;
145
+ const entry = (event) => ({
146
+ type: 'command',
147
+ command: command(event),
148
+ timeout,
149
+ });
150
+ const doc = {
151
+ hooks: {
152
+ UserPromptSubmit: [
153
+ {
154
+ hooks: [entry('user-prompt-submit')],
155
+ },
156
+ ],
157
+ SessionStart: [
158
+ {
159
+ matcher: CLAUDE_SESSION_START_MATCHER,
160
+ hooks: [
161
+ entry('session-start-compact'),
162
+ ],
163
+ },
164
+ ],
165
+ PostToolUse: [
166
+ {
167
+ matcher: CLAUDE_POST_TOOL_USE_MATCHER,
168
+ hooks: [entry('post-tool-use')],
169
+ },
170
+ ],
171
+ },
172
+ };
173
+ return `${JSON.stringify(doc, null, 2)}\n`;
174
+ }
175
+ /**
176
+ * Generate a self-contained CommonJS hook script for a Claude Code
177
+ * lifecycle event. The script reads ONE hook JSON document from stdin,
178
+ * resolves the state file from `input.cwd` (the session cwd) via the
179
+ * {@link resolveStateForCwd} semantics, and:
180
+ *
181
+ * - `user-prompt-submit`: emits
182
+ * `{ hookSpecificOutput: { hookEventName: "UserPromptSubmit",
183
+ * additionalContext } }` carrying the current state JSON;
184
+ * - `session-start-compact`: the same injection with
185
+ * `hookEventName: "SessionStart"` (state survives compaction; wire
186
+ * with the `^compact$` matcher);
187
+ * - `post-tool-use`: extracts a `state_patch` from the tool_response
188
+ * (fenced ```json block or raw JSON), applies the ⊕ null-deletion merge
189
+ * and writes the state file; stdout is `{}` or a `systemMessage` when
190
+ * the patch is invalid.
191
+ *
192
+ * `statePath` is accepted for `{ root, name }` confinement (traversal
193
+ * refs throw) and documented in the script header; the content itself is
194
+ * cwd-resolving and never bakes an absolute state path in.
195
+ */
196
+ generateHookScript(event, statePath) {
197
+ const resolved = statePath === undefined ? undefined : this.resolve(statePath);
198
+ const header = `// State file (per-project resolver): ${resolved ?? '<cwd>/.skillstate/skillstate.json (global bucket when cwd === home)'}`;
199
+ if (event === 'post-tool-use') {
200
+ return this.buildPostToolUseScript(header);
77
201
  }
78
- // PostToolUse — schema-validated null-deletion merge. Malformed outputs
79
- // are rejected and never persisted (paper Limitations: malformed outputs
80
- // cannot corrupt Σt). Self-contained CommonJS (hook scripts run via
81
- // `node script.cjs`). The schema is embedded so unknown keys / wrong
82
- // types are rejected here instead of corrupting the persisted state.
83
- const fence = '`' + '`' + '`';
84
- const schemaJson = JSON.stringify(schema ?? {});
202
+ const hookEventName = event === 'session-start-compact' ? 'SessionStart' : 'UserPromptSubmit';
85
203
  return [
86
- '// Claude hook: PostToolUse',
87
- '// Extracts state_patch from the assistant\'s response, validates it against',
88
- '// the embedded schema, applies the null-deletion merge, and saves.',
204
+ '#!/usr/bin/env node',
205
+ `// skillstate Claude Code hook — ${hookEventName} (generated by @skillstate/claude).`,
206
+ '// Self-contained CommonJS: reads one hook JSON document on stdin,',
207
+ '// resolves the state from the SESSION cwd, and emits',
208
+ '// { hookSpecificOutput: { hookEventName, additionalContext } }.',
209
+ header,
210
+ "'use strict';",
89
211
  'const fs = require("fs");',
90
- 'const stateFilePath = ' + sp + ';',
91
- 'const schema = ' + schemaJson + ';',
212
+ 'const os = require("os");',
213
+ 'const path = require("path");',
92
214
  '',
93
- 'function isPlainObject(v) {',
94
- ' return typeof v === "object" && v !== null && !Array.isArray(v);',
95
- '}',
215
+ `const HOOK_EVENT_NAME = ${JSON.stringify(hookEventName)};`,
96
216
  '',
97
- '// ⊕ merge from the paper: null deletes a key, plain objects merge',
98
- '// recursively, everything else overwrites. Never mutates `base` —',
99
- '// builds and returns a copy.',
100
- 'function mergePatch(base, patch) {',
101
- ' function mergeInto(result, patchObj) {',
102
- ' for (const key of Object.keys(patchObj)) {',
103
- ' const value = patchObj[key];',
104
- ' if (value === null) {',
105
- ' delete result[key];',
106
- ' } else if (isPlainObject(value) && isPlainObject(result[key])) {',
107
- ' result[key] = mergeInto({ ...result[key] }, value);',
108
- ' } else {',
109
- ' result[key] = value;',
110
- ' }',
111
- ' }',
112
- ' return result;',
217
+ 'function resolveStatePathForCwd(cwd) {',
218
+ ' const resolvedCwd = path.resolve(cwd);',
219
+ ' const resolvedHome = path.resolve(os.homedir());',
220
+ ' if (resolvedCwd === resolvedHome) {',
221
+ ' return path.join(resolvedHome, ".skillstate", "global", "skillstate.json");',
113
222
  ' }',
114
- ' return mergeInto({ ...base }, patch);',
223
+ ' return path.join(resolvedCwd, ".skillstate", "skillstate.json");',
115
224
  '}',
116
225
  '',
117
- '// Schema validation: unknown keys rejected; null is always valid (deletion).',
118
- 'function validatePatchAgainstSchema(patch) {',
119
- ' for (const key of Object.keys(patch)) {',
120
- ' const field = schema[key];',
121
- ' if (!field) {',
122
- ' return "Unknown key: " + key;',
123
- ' }',
124
- ' const value = patch[key];',
125
- ' if (value === null) continue;',
126
- ' const expected = field.type;',
127
- ' let ok = false;',
128
- ' if (expected === "string") ok = typeof value === "string";',
129
- ' else if (expected === "number") ok = typeof value === "number";',
130
- ' else if (expected === "boolean") ok = typeof value === "boolean";',
131
- ' else if (expected === "array") ok = Array.isArray(value);',
132
- ' else if (expected === "object") ok = isPlainObject(value);',
133
- ' if (!ok) {',
134
- ' return "Invalid type for field \'" + key + "\': expected " + expected +',
135
- ' ", got " + (Array.isArray(value) ? "array" : typeof value);',
226
+ 'function readState(statePath) {',
227
+ ' try {',
228
+ ' if (fs.existsSync(statePath)) {',
229
+ ' const parsed = JSON.parse(fs.readFileSync(statePath, "utf-8"));',
230
+ ' if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {',
231
+ ' if (parsed.state !== null && typeof parsed.state === "object" && !Array.isArray(parsed.state)) {',
232
+ ' return parsed.state;',
233
+ ' }',
234
+ ' return parsed;',
235
+ ' }',
136
236
  ' }',
137
- ' }',
138
- ' return null;',
237
+ ' } catch (error) {}',
238
+ ' return {};',
139
239
  '}',
140
240
  '',
141
- 'let state = {};',
142
- 'try {',
143
- ' if (fs.existsSync(stateFilePath)) {',
144
- ' state = JSON.parse(fs.readFileSync(stateFilePath, "utf-8"));',
145
- ' }',
146
- '} catch (e) { state = {}; }',
147
- 'let input = "";',
241
+ 'let raw = "";',
148
242
  'process.stdin.setEncoding("utf-8");',
149
- 'process.stdin.on("data", (chunk) => { input += chunk; });',
243
+ 'process.stdin.on("data", (chunk) => { raw += chunk; });',
150
244
  'process.stdin.on("end", () => {',
151
- ' const output = {};',
245
+ ' let cwd = process.cwd();',
152
246
  ' try {',
153
- ' const parsed = JSON.parse(input);',
154
- ' const content = parsed.tool_response || parsed.content || "";',
155
- ' const re = /' + fence + 'json\\s*\\n?([\\s\\S]*?)\\n?\\s*' + fence + '/;',
156
- ' const match = content.match(re);',
157
- ' if (match) {',
158
- ' // Guarded parse: malformed JSON inside the fence leaves state untouched.',
159
- ' let json;',
160
- ' try {',
161
- ' json = JSON.parse(match[1]);',
162
- ' } catch (parseError) {',
163
- ' output.error = "Malformed JSON in state patch block: " + parseError.message;',
164
- ' process.stdout.write(JSON.stringify(output));',
165
- ' return;',
166
- ' }',
167
- ' if (json.state_patch && typeof json.state_patch === "object" && !Array.isArray(json.state_patch)) {',
168
- ' const validationError = validatePatchAgainstSchema(json.state_patch);',
169
- ' if (validationError) {',
170
- ' // Reject: report the error, never write state.',
171
- ' output.error = validationError;',
172
- ' process.stdout.write(JSON.stringify(output));',
173
- ' return;',
174
- ' }',
175
- ' state = mergePatch(state, json.state_patch);',
176
- ' fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2));',
177
- ' }',
247
+ ' const input = JSON.parse(raw);',
248
+ ' if (typeof input.cwd === "string" && input.cwd.length > 0) {',
249
+ ' cwd = input.cwd;',
178
250
  ' }',
179
- ' } catch (e) {',
180
- ' // Malformed stdin JSON or unexpected failure: state stays untouched.',
181
- ' output.error = "Failed to process PostToolUse input: " + e.message;',
182
- ' }',
251
+ ' } catch (error) {}',
252
+ ' const state = readState(resolveStatePathForCwd(cwd));',
253
+ ' const output = {',
254
+ ' hookSpecificOutput: {',
255
+ ' hookEventName: HOOK_EVENT_NAME,',
256
+ ' additionalContext: "Current skill state (JSON): " + JSON.stringify(state)',
257
+ ' + "\\nPersist anything you need into state via the skillstate MCP tools (state.patch) or a fenced ```json state_patch block inside a Bash command. History is not reliable.",',
258
+ ' },',
259
+ ' };',
183
260
  ' process.stdout.write(JSON.stringify(output));',
184
261
  '});',
262
+ '',
185
263
  ].join('\n');
186
264
  }
187
265
  /**
188
- * @non-paper additive helper: generate a hook script and persist it via
189
- * `atomicWriteFile` (tmp + fsync + rename). Both the destination and the
190
- * embedded state path accept raw strings or
191
- * `{ root, name }` refs confined by `resolveStatePath`. Returns the
192
- * absolute destination path.
266
+ * Generate a SKILL.md for Claude Code's skill directory
267
+ * (`~/.claude/skills/<name>/SKILL.md`). The body instructs the agent to
268
+ * treat the hook-injected state as authoritative (history is not
269
+ * reliable), to read state via the skillstate MCP tool `state.get`, and
270
+ * to persist via `state.patch` — the PostToolUse hook also merges any
271
+ * fenced ```json `state_patch` block printed by a Bash tool call.
193
272
  */
194
- async saveHookScript(target, eventType, statePath, schema) {
195
- const dest = typeof target === 'string'
196
- ? target
197
- : resolveStatePath(target.root, target.name);
198
- const resolvedState = typeof statePath === 'string'
199
- ? statePath
200
- : resolveStatePath(statePath.root, statePath.name);
201
- const script = this.generateHookScript(eventType, resolvedState, schema);
202
- await atomicWriteFile(dest, script);
203
- return dest;
204
- }
205
- generateCompactHookScript(statePathOrRef, schema) {
206
- const statePath = typeof statePathOrRef === 'string'
207
- ? statePathOrRef
208
- : resolveStatePath(statePathOrRef.root, statePathOrRef.name);
209
- const sp = JSON.stringify(statePath);
210
- const schemaJson = JSON.stringify(schema ?? {});
211
- const lastCompactPath = JSON.stringify(statePath + '.last-compact.json');
273
+ generateSkillMd(spec, statePath) {
274
+ const resolvedStatePath = statePath ?? './.skillstate/skillstate.json';
275
+ const fence = '```';
212
276
  return [
213
- '// Claude hook: PreCompact',
214
- '// Injects the current skill state into the compaction summary.',
215
- '// Tracks diff since last compact for incremental context.',
216
- 'const fs = require("fs");',
217
- 'const stateFilePath = ' + sp + ';',
218
- 'const lastCompactFilePath = ' + lastCompactPath + ';',
219
- 'const schema = ' + schemaJson + ';',
277
+ '---',
278
+ `name: ${JSON.stringify(spec.name)}`,
279
+ `description: ${JSON.stringify(spec.instructions)}`,
280
+ `version: ${spec.version}`,
281
+ 'execution_context:',
282
+ ` state_path: ${resolvedStatePath}`,
283
+ ' format: json',
284
+ '---',
220
285
  '',
221
- 'function readJsonSafe(filePath) {',
222
- ' try {',
223
- ' if (fs.existsSync(filePath)) {',
224
- ' return JSON.parse(fs.readFileSync(filePath, "utf-8"));',
225
- ' }',
226
- ' } catch {}',
227
- ' return {};',
228
- '}',
286
+ `# ${spec.name}`,
229
287
  '',
230
- 'const current = readJsonSafe(stateFilePath);',
231
- 'const lastCompact = readJsonSafe(lastCompactFilePath);',
288
+ spec.instructions,
232
289
  '',
233
- '// Compute diff: keys added, changed, or deleted since last compact.',
234
- 'const diff = {};',
235
- 'const allKeys = new Set([...Object.keys(current), ...Object.keys(lastCompact)]);',
236
- 'for (const key of allKeys) {',
237
- ' const cur = current[key];',
238
- ' const prev = lastCompact[key];',
239
- ' if (cur === undefined) {',
240
- ' diff[key] = "(deleted)";',
241
- ' } else if (prev === undefined) {',
242
- ' diff[key] = cur;',
243
- ' } else if (JSON.stringify(cur) !== JSON.stringify(prev)) {',
244
- ' diff[key] = { from: prev, to: cur };',
245
- ' }',
246
- '}',
290
+ '## Execution Context',
247
291
  '',
248
- 'const contextParts = [',
249
- ' "Current skill state (JSON): " + JSON.stringify(current),',
250
- '];',
251
- 'if (Object.keys(diff).length > 0) {',
252
- ' contextParts.push("Changes since last compact: " + JSON.stringify(diff));',
253
- '}',
292
+ `Your execution state lives at \`${resolvedStatePath}\` (per project; a`,
293
+ 'session started in $HOME uses `~/.skillstate/global/skillstate.json`).',
294
+ 'The skillstate Claude Code hooks:',
254
295
  '',
255
- 'const output = {',
256
- ' hookSpecificOutput: {',
257
- ' additionalContext: contextParts.join("\\n")',
258
- ' }',
259
- '};',
296
+ '- inject the CURRENT state into context on every prompt submit',
297
+ ' (`UserPromptSubmit`) and re-inject it after compaction',
298
+ ' (`SessionStart` matcher `^compact$`);',
299
+ '- watch every Bash tool result and merge a fenced ```json block carrying',
300
+ ' a `state_patch` into the state file (`PostToolUse`).',
301
+ '',
302
+ 'The injected state is authoritative — history is not reliable. Never',
303
+ 'reconstruct execution context from the conversation.',
260
304
  '',
261
- '// Save current state as the new compact snapshot.',
262
- 'try {',
263
- ' fs.writeFileSync(lastCompactFilePath, JSON.stringify(current, null, 2));',
264
- '} catch {}',
305
+ '## Process',
306
+ '',
307
+ '1. Read the current state from the injected context, or fetch it with',
308
+ ' the skillstate MCP tool `state.get`.',
309
+ '2. Observe the result of your last action.',
310
+ '3. Reason about what to do next, given the state and the observation.',
311
+ '4. Persist progress with the skillstate MCP tool `state.patch` (sparse',
312
+ ' patch; set a key to `null` to delete it), and/or emit a fenced JSON',
313
+ ' block with exactly two keys inside a Bash tool call so the',
314
+ ' `PostToolUse` hook merges it:',
315
+ '',
316
+ `${fence}json`,
317
+ '{',
318
+ ' "state_patch": { "key": "new_value", "obsolete_key": null },',
319
+ ' "action": "next_action_name"',
320
+ '}',
321
+ fence,
322
+ '',
323
+ '- In `state_patch`, set keys to `null` to delete them. Only include',
324
+ ' fields you want to change. Omit fields to leave them unchanged.',
325
+ '- Put anything you need to survive into `state_patch`; never rely on',
326
+ ' the conversation remembering it.',
327
+ '- `action` names what you will do next (e.g. "continue", "done").',
265
328
  '',
266
- 'process.stdout.write(JSON.stringify(output));',
267
329
  ].join('\n');
268
330
  }
269
- generateSessionStartHookScript(statePathOrRef) {
270
- const statePath = typeof statePathOrRef === 'string'
271
- ? statePathOrRef
272
- : resolveStatePath(statePathOrRef.root, statePathOrRef.name);
273
- const sp = JSON.stringify(statePath);
274
- return [
275
- '// Claude hook: SessionStart (source: compact)',
276
- '// Re-injects skill state after compaction so the model retains',
277
- '// execution context even though history was compressed.',
278
- 'const fs = require("fs");',
279
- 'const stateFilePath = ' + sp + ';',
280
- 'let state = {};',
281
- 'try {',
282
- ' if (fs.existsSync(stateFilePath)) {',
283
- ' state = JSON.parse(fs.readFileSync(stateFilePath, "utf-8"));',
284
- ' }',
285
- '} catch {}',
286
- 'const output = {',
287
- ' hookSpecificOutput: {',
288
- ' additionalContext: "Skill state restored after compaction: " + JSON.stringify(state)',
289
- ' }',
290
- '};',
291
- 'process.stdout.write(JSON.stringify(output));',
292
- ].join('\n');
331
+ /**
332
+ * Merge the skillstate hook groups into an existing `settings.json`
333
+ * text. Preserves every other top-level key (env, permissions, model,
334
+ * …) and every existing (non-skillstate) hook. Idempotent: if any
335
+ * skillstate command is already wired, the ORIGINAL text is returned
336
+ * byte-unchanged. Malformed/empty input starts from a fresh hooks-only
337
+ * document (the CLI guards a live settings.json before calling).
338
+ */
339
+ mergeHooksConfig(existingJson, options) {
340
+ let doc = {};
341
+ try {
342
+ doc = JSON.parse(existingJson);
343
+ }
344
+ catch {
345
+ // Missing or malformed input: start from a fresh document.
346
+ }
347
+ if (!isPlainObjectDoc(doc.hooks)) {
348
+ doc.hooks = {};
349
+ }
350
+ const scriptDir = options?.scriptDir ?? '<stateDir>/hooks';
351
+ const command = (event) => `node ${JSON.stringify(this.claudeHookScriptPath(scriptDir, event))} ${event}`;
352
+ const skillstateCommands = new Set(CLAUDE_HOOK_EVENTS.map((event) => JSON.stringify(command(event))));
353
+ let alreadyWired = false;
354
+ for (const groups of Object.values(doc.hooks)) {
355
+ if (!Array.isArray(groups))
356
+ continue;
357
+ for (const group of groups) {
358
+ if (!isPlainObjectDoc(group) || !Array.isArray(group['hooks']))
359
+ continue;
360
+ for (const handler of group['hooks']) {
361
+ if (isPlainObjectDoc(handler) && skillstateCommands.has(JSON.stringify(handler['command']))) {
362
+ alreadyWired = true;
363
+ }
364
+ }
365
+ }
366
+ }
367
+ if (alreadyWired) {
368
+ return existingJson;
369
+ }
370
+ const generated = JSON.parse(this.generateHooksConfig(undefined, { ...options, scriptDir }));
371
+ for (const [event, groups] of Object.entries(generated.hooks)) {
372
+ const existing = Array.isArray(doc.hooks[event])
373
+ ? doc.hooks[event]
374
+ : [];
375
+ doc.hooks[event] = [...existing, ...groups];
376
+ }
377
+ return `${JSON.stringify(doc, null, 2)}\n`;
293
378
  }
294
- generateAllHooksScripts(statePathOrRef, schema) {
295
- return {
296
- preCompact: this.generateCompactHookScript(statePathOrRef, schema),
297
- sessionStartCompact: this.generateSessionStartHookScript(statePathOrRef),
298
- };
379
+ /**
380
+ * Generate a hook script and persist it via `atomicWriteFile`. `target`
381
+ * is the script destination (usually
382
+ * {@link ClaudeAdapter.claudeHookScriptPath}); `statePath` is forwarded
383
+ * to {@link generateHookScript}. Returns the absolute destination path.
384
+ */
385
+ async saveHookScript(event, target, statePath) {
386
+ const dest = this.resolve(target);
387
+ await atomicWriteFile(dest, this.generateHookScript(event, statePath));
388
+ return dest;
389
+ }
390
+ /**
391
+ * Generate the hooks config document and persist it via
392
+ * `atomicWriteFile`. Both the destination and the embedded state path
393
+ * accept raw strings or `{ root, name }` refs confined by
394
+ * `resolveStatePath`. Returns the absolute destination path.
395
+ */
396
+ async saveHooksConfig(target, statePath, options) {
397
+ const dest = this.resolve(target);
398
+ await atomicWriteFile(dest, this.generateHooksConfig(statePath, options));
399
+ return dest;
400
+ }
401
+ /**
402
+ * Generate a SKILL.md and persist it via `atomicWriteFile`. Returns the
403
+ * absolute destination path.
404
+ */
405
+ async saveSkillMd(target, spec, statePath) {
406
+ const dest = this.resolve(target);
407
+ await atomicWriteFile(dest, this.generateSkillMd(spec, statePath));
408
+ return dest;
299
409
  }
300
410
  generateAppendPrompt() {
301
411
  return `You are operating in state-based execution mode. Your state is maintained across steps.
@@ -313,6 +423,179 @@ After each step, you MUST respond with a JSON block containing your State Patch
313
423
  - \`action\` indicates what you want to do next (e.g., "continue", "done", "deploy").
314
424
  - Reasoning is discarded after execution — put anything you need to persist into \`state_patch\`.`;
315
425
  }
426
+ /* ------------------------------------------------------------------ */
427
+ /* Internal helpers */
428
+ /* ------------------------------------------------------------------ */
429
+ /** Resolve a `string | StatePathRef` via `resolveStatePath` (throws on `..`). */
430
+ resolve(target) {
431
+ return typeof target === 'string'
432
+ ? target
433
+ : resolveStatePath(target.root, target.name);
434
+ }
435
+ /** PostToolUse script: extract state_patch, ⊕ merge, persist. */
436
+ buildPostToolUseScript(header) {
437
+ const fence = '```';
438
+ return [
439
+ '#!/usr/bin/env node',
440
+ '// skillstate Claude Code hook — PostToolUse (generated by @skillstate/claude).',
441
+ '// Self-contained CommonJS: reads one hook JSON document on stdin,',
442
+ '// extracts state_patch from the tool_response (fenced ```json block or',
443
+ '// raw JSON), applies the null-deletion merge and writes the state file.',
444
+ '// stdout is "{}" or a systemMessage when the patch is invalid.',
445
+ header,
446
+ "'use strict';",
447
+ 'const fs = require("fs");',
448
+ 'const os = require("os");',
449
+ 'const path = require("path");',
450
+ '',
451
+ 'function isPlainObject(value) {',
452
+ ' return typeof value === "object" && value !== null && !Array.isArray(value);',
453
+ '}',
454
+ '',
455
+ 'function resolveStatePathForCwd(cwd) {',
456
+ ' const resolvedCwd = path.resolve(cwd);',
457
+ ' const resolvedHome = path.resolve(os.homedir());',
458
+ ' if (resolvedCwd === resolvedHome) {',
459
+ ' return path.join(resolvedHome, ".skillstate", "global", "skillstate.json");',
460
+ ' }',
461
+ ' return path.join(resolvedCwd, ".skillstate", "skillstate.json");',
462
+ '}',
463
+ '',
464
+ 'function readState(statePath) {',
465
+ ' try {',
466
+ ' if (fs.existsSync(statePath)) {',
467
+ ' const parsed = JSON.parse(fs.readFileSync(statePath, "utf-8"));',
468
+ ' if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {',
469
+ ' if (parsed.state !== null && typeof parsed.state === "object" && !Array.isArray(parsed.state)) {',
470
+ ' return parsed.state;',
471
+ ' }',
472
+ ' return parsed;',
473
+ ' }',
474
+ ' }',
475
+ ' } catch (error) {}',
476
+ ' return {};',
477
+ '}',
478
+ '',
479
+ '// Paper ⊕ merge: null deletes a key, nested plain objects merge',
480
+ '// recursively, everything else replaces.',
481
+ 'function mergePatch(base, patch) {',
482
+ ' const result = { ...base };',
483
+ ' for (const key of Object.keys(patch)) {',
484
+ ' const value = patch[key];',
485
+ ' if (value === null) {',
486
+ ' delete result[key];',
487
+ ' } else if (isPlainObject(value) && isPlainObject(result[key])) {',
488
+ ' result[key] = mergePatch(result[key], value);',
489
+ ' } else {',
490
+ ' result[key] = value;',
491
+ ' }',
492
+ ' }',
493
+ ' return result;',
494
+ '}',
495
+ '',
496
+ 'function readResponseText(response) {',
497
+ ' if (typeof response === "string") return response;',
498
+ ' if (isPlainObject(response)) {',
499
+ ' if (typeof response.content === "string") return response.content;',
500
+ ' if (typeof response.text === "string") return response.text;',
501
+ ' return JSON.stringify(response);',
502
+ ' }',
503
+ ' return response === null || response === undefined ? "" : String(response);',
504
+ '}',
505
+ '',
506
+ `const FENCE = ${JSON.stringify(fence)};`,
507
+ 'const FENCE_RE = /' + '```' + 'json\\s*\\n?([\\s\\S]*?)\\n?\\s*' + '```' + '/;',
508
+ '// An open fence that never closes (truncated output) is still a patch',
509
+ '// attempt: match from ```json to end-of-text so it classifies as invalid.',
510
+ 'const OPEN_FENCE_RE = /' + '```' + 'json\\s*\\n?([\\s\\S]+)$/',
511
+ '',
512
+ '// Look for a fenced ```json block: { patch } when it parses and carries',
513
+ '// an object state_patch, { invalid: true } when a block exists but is',
514
+ '// malformed, { absent: true } when there is no block at all.',
515
+ 'function findFencedPatch(text) {',
516
+ ' const match = text.match(FENCE_RE) || text.match(OPEN_FENCE_RE);',
517
+ ' if (!match) return { absent: true };',
518
+ ' try {',
519
+ ' const parsed = JSON.parse(match[1]);',
520
+ ' if (isPlainObject(parsed) && isPlainObject(parsed.state_patch)) {',
521
+ ' return { patch: parsed.state_patch };',
522
+ ' }',
523
+ ' } catch (error) {}',
524
+ ' return { invalid: true };',
525
+ '}',
526
+ '',
527
+ '// Fallback: a raw JSON object with state_patch anywhere in the text.',
528
+ '// Ordinary JSON output without a state_patch key is simply not a patch.',
529
+ 'function findRawPatch(text) {',
530
+ ' const trimmed = text.trim();',
531
+ ' const candidates = [];',
532
+ ' try {',
533
+ ' candidates.push(JSON.parse(trimmed));',
534
+ ' } catch (error) {}',
535
+ ' const first = trimmed.indexOf("{");',
536
+ ' const last = trimmed.lastIndexOf("}");',
537
+ ' if (first !== -1 && last > first) {',
538
+ ' try {',
539
+ ' candidates.push(JSON.parse(trimmed.slice(first, last + 1)));',
540
+ ' } catch (error) {}',
541
+ ' }',
542
+ ' for (const candidate of candidates) {',
543
+ ' if (isPlainObject(candidate)) {',
544
+ ' if (isPlainObject(candidate.state_patch)) {',
545
+ ' return { patch: candidate.state_patch };',
546
+ ' }',
547
+ ' if (Object.prototype.hasOwnProperty.call(candidate, "state_patch")) {',
548
+ ' return { invalid: true };',
549
+ ' }',
550
+ ' }',
551
+ ' }',
552
+ ' return { absent: true };',
553
+ '}',
554
+ '',
555
+ 'let raw = "";',
556
+ 'process.stdin.setEncoding("utf-8");',
557
+ 'process.stdin.on("data", (chunk) => { raw += chunk; });',
558
+ 'process.stdin.on("end", () => {',
559
+ ' const output = {};',
560
+ ' try {',
561
+ ' const input = JSON.parse(raw);',
562
+ ' let cwd = process.cwd();',
563
+ ' if (typeof input.cwd === "string" && input.cwd.length > 0) {',
564
+ ' cwd = input.cwd;',
565
+ ' }',
566
+ ' const statePath = resolveStatePathForCwd(cwd);',
567
+ ' const response = input.tool_response;',
568
+ ' let result;',
569
+ ' if (isPlainObject(response) && isPlainObject(response.state_patch)) {',
570
+ ' result = { patch: response.state_patch };',
571
+ ' } else {',
572
+ ' const text = readResponseText(response);',
573
+ ' result = findFencedPatch(text);',
574
+ ' if (result.absent) result = findRawPatch(text);',
575
+ ' }',
576
+ ' if (result.patch !== undefined) {',
577
+ ' const merged = mergePatch(readState(statePath), result.patch);',
578
+ ' try {',
579
+ ' fs.mkdirSync(path.dirname(statePath), { recursive: true });',
580
+ ' fs.writeFileSync(statePath, JSON.stringify({ version: 1, state: merged }, null, 2) + "\\n");',
581
+ ' } catch (writeError) {',
582
+ " output.systemMessage = 'skillstate: failed to persist state (' + writeError.message + ')';",
583
+ ' }',
584
+ ' process.stdout.write(JSON.stringify(output));',
585
+ ' return;',
586
+ ' }',
587
+ ' if (result.invalid) {',
588
+ ' output.systemMessage =',
589
+ ' "skillstate: ignored an invalid state patch (expected a " + FENCE + "json block with a state_patch object)";',
590
+ ' }',
591
+ ' } catch (error) {',
592
+ ' output.systemMessage = "skillstate: failed to process PostToolUse input: " + error.message;',
593
+ ' }',
594
+ ' process.stdout.write(JSON.stringify(output));',
595
+ '});',
596
+ '',
597
+ ].join('\n');
598
+ }
316
599
  describeSchema(schema) {
317
600
  const fields = Object.entries(schema)
318
601
  .map(([name, field]) => `- ${name} (${field.type}): ${field.description ?? 'no description'}`)
@@ -320,4 +603,70 @@ After each step, you MUST respond with a JSON block containing your State Patch
320
603
  return `## Schema\n${fields}`;
321
604
  }
322
605
  }
606
+ /**
607
+ * Surgically remove every skillstate hook handler from an existing
608
+ * `settings.json` text (the uninstall path — restoring a backup would
609
+ * lose live settings, so groups are edited in place):
610
+ *
611
+ * - a handler is skillstate when its command points at a generated
612
+ * `<event>.cjs` script (matched by basename, independent of where the
613
+ * script dir lived);
614
+ * - groups whose handlers are ALL skillstate are dropped; MIXED groups
615
+ * keep their foreign handlers and lose only the skillstate ones;
616
+ * - event arrays left empty are removed from the `hooks` object;
617
+ * - every other top-level key (env, permissions, model, …) survives.
618
+ *
619
+ * Returns the original text untouched (`changed: false`) when it is
620
+ * malformed, has no hooks object, or carries no skillstate handlers.
621
+ */
622
+ export function removeSkillstateHookGroups(existingJson) {
623
+ let doc;
624
+ try {
625
+ doc = JSON.parse(existingJson);
626
+ }
627
+ catch {
628
+ return { text: existingJson, changed: false };
629
+ }
630
+ if (!isPlainObjectDoc(doc) || !isPlainObjectDoc(doc['hooks'])) {
631
+ return { text: existingJson, changed: false };
632
+ }
633
+ const scriptBasenames = CLAUDE_HOOK_EVENTS.map((event) => `${event}.cjs`);
634
+ const isSkillstateCommand = (command) => typeof command === 'string' && scriptBasenames.some((b) => command.includes(b));
635
+ const hooks = doc['hooks'];
636
+ let changed = false;
637
+ for (const event of Object.keys(hooks)) {
638
+ const groups = hooks[event];
639
+ if (!Array.isArray(groups))
640
+ continue;
641
+ const keptGroups = [];
642
+ let eventChanged = false;
643
+ for (const group of groups) {
644
+ if (!isPlainObjectDoc(group) || !Array.isArray(group['hooks'])) {
645
+ keptGroups.push(group);
646
+ continue;
647
+ }
648
+ const handlers = group['hooks'];
649
+ const keptHandlers = handlers.filter((handler) => !(isPlainObjectDoc(handler) && isSkillstateCommand(handler['command'])));
650
+ if (keptHandlers.length === handlers.length) {
651
+ keptGroups.push(group);
652
+ continue;
653
+ }
654
+ eventChanged = true;
655
+ if (keptHandlers.length > 0) {
656
+ keptGroups.push({ ...group, hooks: keptHandlers });
657
+ }
658
+ }
659
+ if (keptGroups.length === 0) {
660
+ delete hooks[event];
661
+ }
662
+ else if (eventChanged) {
663
+ hooks[event] = keptGroups;
664
+ }
665
+ changed = changed || eventChanged;
666
+ }
667
+ if (!changed) {
668
+ return { text: existingJson, changed: false };
669
+ }
670
+ return { text: `${JSON.stringify(doc, null, 2)}\n`, changed: true };
671
+ }
323
672
  //# sourceMappingURL=claude-adapter.js.map