@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.
- package/README.md +88 -56
- package/dist/claude-adapter.d.ts +131 -50
- package/dist/claude-adapter.d.ts.map +1 -1
- package/dist/claude-adapter.js +567 -218
- package/dist/claude-adapter.js.map +1 -1
- package/package.json +1 -1
package/dist/claude-adapter.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
87
|
-
|
|
88
|
-
'//
|
|
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
|
|
91
|
-
'const
|
|
212
|
+
'const os = require("os");',
|
|
213
|
+
'const path = require("path");',
|
|
92
214
|
'',
|
|
93
|
-
|
|
94
|
-
' return typeof v === "object" && v !== null && !Array.isArray(v);',
|
|
95
|
-
'}',
|
|
215
|
+
`const HOOK_EVENT_NAME = ${JSON.stringify(hookEventName)};`,
|
|
96
216
|
'',
|
|
97
|
-
'
|
|
98
|
-
'
|
|
99
|
-
'
|
|
100
|
-
'
|
|
101
|
-
'
|
|
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
|
|
223
|
+
' return path.join(resolvedCwd, ".skillstate", "skillstate.json");',
|
|
115
224
|
'}',
|
|
116
225
|
'',
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
122
|
-
'
|
|
123
|
-
'
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
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
|
|
237
|
+
' } catch (error) {}',
|
|
238
|
+
' return {};',
|
|
139
239
|
'}',
|
|
140
240
|
'',
|
|
141
|
-
'let
|
|
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) => {
|
|
243
|
+
'process.stdin.on("data", (chunk) => { raw += chunk; });',
|
|
150
244
|
'process.stdin.on("end", () => {',
|
|
151
|
-
'
|
|
245
|
+
' let cwd = process.cwd();',
|
|
152
246
|
' try {',
|
|
153
|
-
' const
|
|
154
|
-
'
|
|
155
|
-
'
|
|
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 (
|
|
180
|
-
'
|
|
181
|
-
'
|
|
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
|
-
*
|
|
189
|
-
* `
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
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
|
-
'
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
'
|
|
218
|
-
|
|
219
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
'const lastCompact = readJsonSafe(lastCompactFilePath);',
|
|
288
|
+
spec.instructions,
|
|
232
289
|
'',
|
|
233
|
-
'
|
|
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
|
-
|
|
249
|
-
'
|
|
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
|
-
'
|
|
256
|
-
'
|
|
257
|
-
'
|
|
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
|
-
'
|
|
262
|
-
'
|
|
263
|
-
'
|
|
264
|
-
'
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|