@polymorphism-tech/morph-spec 8.15.0 → 8.16.0

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.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +8 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -1
  7. package/framework/MORPH.md +4 -3
  8. package/framework/hooks/README.md +3 -3
  9. package/framework/hooks/claude-code/core/chain.js +123 -0
  10. package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
  11. package/framework/hooks/claude-code/core/gate-guard.js +21 -25
  12. package/framework/hooks/claude-code/core/loop-detect.js +70 -64
  13. package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
  14. package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
  15. package/framework/hooks/claude-code/core/protect-spec-files.js +110 -103
  16. package/framework/hooks/claude-code/core/state-sync.js +32 -40
  17. package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
  18. package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
  19. package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
  20. package/framework/hooks/claude-code/core/validate-completion.js +147 -107
  21. package/framework/hooks/claude-code/core/worktree-context.js +25 -22
  22. package/framework/hooks/claude-code/statusline.py +26 -4
  23. package/framework/hooks/shared/activity-logger.js +120 -69
  24. package/framework/hooks/shared/claim-registry.js +61 -1
  25. package/framework/hooks/shared/git-paths.js +94 -0
  26. package/framework/hooks/shared/hook-response.js +67 -0
  27. package/framework/hooks/shared/state-reader.js +95 -29
  28. package/framework/hooks/shared/state-writer.js +16 -0
  29. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  30. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  31. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  32. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  33. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  34. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  35. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  36. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  37. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  38. package/framework/skills/core/morph-review/SKILL.md +1 -1
  39. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  40. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  41. package/package.json +1 -1
  42. package/src/commands/observability/cost.js +137 -0
  43. package/src/commands/project/doctor.js +28 -1
  44. package/src/commands/state/approve.js +30 -9
  45. package/src/commands/state/archive.js +29 -3
  46. package/src/commands/state/delete.js +36 -2
  47. package/src/dashboard/scanner/hooks-scanner.js +11 -2
  48. package/src/lib/installers/memory-installer.js +1 -1
  49. package/src/lib/telemetry/telemetry-reader.js +213 -0
  50. package/src/utils/hooks-installer.js +84 -79
  51. package/framework/hooks/shared/session-snapshot.js +0 -105
@@ -101,3 +101,216 @@ export function aggregateTelemetry(events, opts = {}) {
101
101
  features: [...features],
102
102
  };
103
103
  }
104
+
105
+ // ─────────────────────────────────────────────────────────────────────────
106
+ // Cost aggregation — sibling to aggregateTelemetry (kept separate rather than
107
+ // folded in: aggregateTelemetry's return shape is pinned by
108
+ // test/commands/telemetry.test.js, which spreads it wholesale into --json
109
+ // output — adding fields there would leak into that contract).
110
+ //
111
+ // Turns the same raw event stream into a cost-facing view: how much
112
+ // wall-clock time was spent inside measured tasks, how many context bytes
113
+ // were re-ingested (by kind), and how many sub-agent dispatches happened
114
+ // (and how many prompt chars they cost).
115
+ //
116
+ // "Bytes re-ingested" is a DETERMINISTIC PROXY for cost, not a token count —
117
+ // hooks can only see what tools read off disk, never the model's actual
118
+ // token usage. The `cost` command surfaces that caveat; this module only
119
+ // computes the numbers.
120
+ // ─────────────────────────────────────────────────────────────────────────
121
+
122
+ // Sentinel used to key the open-task map when an event carries no feature
123
+ // (e.g. telemetry-log's getActiveFeature() found none). Without this, a
124
+ // TaskStart with feature: null would silently fail to open (map keyed on
125
+ // undefined/null never matches itself across two different `null`-feature
126
+ // pairs is fine, but `if (e.feature)` truthiness checks would just drop the
127
+ // event) — keying explicitly avoids that class of "counter never increments"
128
+ // bug.
129
+ const NO_FEATURE = '\u0000no-feature';
130
+
131
+ function featureKey(e) {
132
+ return e && e.feature ? e.feature : NO_FEATURE;
133
+ }
134
+
135
+ function tsToMs(ts) {
136
+ const t = Date.parse(ts);
137
+ return Number.isNaN(t) ? null : t;
138
+ }
139
+
140
+ /**
141
+ * Seconds between two ISO timestamps, rounded to millisecond precision to
142
+ * absorb float noise from the division. null if either ts is unparsable.
143
+ */
144
+ function durationSeconds(startTs, endTs) {
145
+ const a = tsToMs(startTs);
146
+ const b = tsToMs(endTs);
147
+ if (a === null || b === null) return null;
148
+ return Math.round(((b - a) / 1000) * 1000) / 1000;
149
+ }
150
+
151
+ function mapToObj(map) {
152
+ const out = {};
153
+ for (const key of [...map.keys()].sort()) out[key] = map.get(key);
154
+ return out;
155
+ }
156
+
157
+ function sumValues(map) {
158
+ let total = 0;
159
+ for (const v of map.values()) total += v;
160
+ return total;
161
+ }
162
+
163
+ function newTaskState(feature, task, ts) {
164
+ return {
165
+ feature: feature ?? null,
166
+ task,
167
+ startTs: ts,
168
+ endTs: null,
169
+ status: null,
170
+ open: true,
171
+ tools: new Map(),
172
+ bytesByKind: new Map(),
173
+ dispatches: { count: 0, promptChars: 0 },
174
+ };
175
+ }
176
+
177
+ function finalizeTask(state) {
178
+ return {
179
+ feature: state.feature,
180
+ task: state.task,
181
+ startTs: state.startTs,
182
+ endTs: state.endTs,
183
+ durationSeconds: state.endTs ? durationSeconds(state.startTs, state.endTs) : null,
184
+ status: state.status,
185
+ open: state.open,
186
+ tools: rank(state.tools),
187
+ bytesByKind: mapToObj(state.bytesByKind),
188
+ bytesTotal: sumValues(state.bytesByKind),
189
+ dispatches: { ...state.dispatches },
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Summarize an event stream for cost purposes: task segmentation (duration +
195
+ * per-task tool/byte/dispatch aggregates), bytes-re-ingested buckets by
196
+ * `kind` (global, per task, per phase), and sub-agent dispatch counts.
197
+ *
198
+ * Segmentation model: a task is "open" for a feature from its TaskStart
199
+ * event until the matching TaskEnd (same feature + task name). Only one task
200
+ * is assumed open per feature at a time — matches how the implement phase
201
+ * actually runs (auto-advance processes tasks sequentially, never two tasks
202
+ * of the same feature concurrently). Events for a feature with no open task
203
+ * simply don't attribute to any task (they still count toward the
204
+ * global/phase totals). A TaskEnd with no matching open TaskStart is ignored
205
+ * — a truncated stream or a legacy event mix never throws. A task left open
206
+ * at the end of the stream (TaskStart with no TaskEnd yet) is still reported,
207
+ * with `open: true` and `durationSeconds: null`, rather than silently
208
+ * dropped.
209
+ *
210
+ * Byte attribution keys off `typeof e.bytes === 'number'` alone (not a tool
211
+ * check) — today only Read events carry `bytes`, but this stays correct if
212
+ * that widens rather than reporting a silent 0.
213
+ *
214
+ * @param {Object[]} events - raw events (from loadTelemetry)
215
+ * @param {{ feature?: string }} [opts] - optional feature filter
216
+ * @returns {{
217
+ * total: number,
218
+ * features: string[],
219
+ * taskDurationSeconds: number,
220
+ * bytesByKind: Object<string, number>,
221
+ * bytesTotal: number,
222
+ * dispatches: {count:number, promptChars:number},
223
+ * tools: Array<{name:string,count:number}>,
224
+ * tasks: Array<Object>,
225
+ * phases: Array<{phase:string, bytesByKind:Object<string,number>, bytesTotal:number}>,
226
+ * }}
227
+ */
228
+ export function aggregateCost(events, opts = {}) {
229
+ const scoped = opts.feature
230
+ ? (events || []).filter(e => e.feature === opts.feature)
231
+ : (events || []);
232
+
233
+ const features = new Set();
234
+ const toolCounts = new Map();
235
+ const bytesByKindGlobal = new Map();
236
+ const dispatches = { count: 0, promptChars: 0 };
237
+ const phaseBytes = new Map(); // phase -> Map(kind -> bytes)
238
+ const activeByFeature = new Map(); // featureKey -> open task state
239
+ const tasks = [];
240
+
241
+ for (const e of scoped) {
242
+ if (!e || typeof e !== 'object') continue;
243
+ if (e.feature) features.add(e.feature);
244
+
245
+ if (e.event === 'TaskStart' && e.task) {
246
+ activeByFeature.set(featureKey(e), newTaskState(e.feature, e.task, e.ts));
247
+ continue;
248
+ }
249
+
250
+ if (e.event === 'TaskEnd' && e.task) {
251
+ const key = featureKey(e);
252
+ const state = activeByFeature.get(key);
253
+ if (state && state.task === e.task) {
254
+ state.endTs = e.ts;
255
+ state.status = e.status ?? null;
256
+ state.open = false;
257
+ activeByFeature.delete(key);
258
+ tasks.push(finalizeTask(state));
259
+ }
260
+ continue;
261
+ }
262
+
263
+ if (e.tool) toolCounts.set(e.tool, (toolCounts.get(e.tool) || 0) + 1);
264
+ const active = activeByFeature.get(featureKey(e));
265
+ if (active && e.tool) {
266
+ active.tools.set(e.tool, (active.tools.get(e.tool) || 0) + 1);
267
+ }
268
+
269
+ if (typeof e.bytes === 'number' && Number.isFinite(e.bytes)) {
270
+ const kind = e.kind || 'unknown';
271
+ bytesByKindGlobal.set(kind, (bytesByKindGlobal.get(kind) || 0) + e.bytes);
272
+ if (active) active.bytesByKind.set(kind, (active.bytesByKind.get(kind) || 0) + e.bytes);
273
+ if (e.phase) {
274
+ if (!phaseBytes.has(e.phase)) phaseBytes.set(e.phase, new Map());
275
+ const m = phaseBytes.get(e.phase);
276
+ m.set(kind, (m.get(kind) || 0) + e.bytes);
277
+ }
278
+ }
279
+
280
+ if ((e.tool === 'Agent' || e.tool === 'Task') && typeof e.prompt === 'string' && e.prompt) {
281
+ dispatches.count += 1;
282
+ dispatches.promptChars += e.prompt.length;
283
+ if (active) {
284
+ active.dispatches.count += 1;
285
+ active.dispatches.promptChars += e.prompt.length;
286
+ }
287
+ }
288
+ }
289
+
290
+ // Tasks still open when the stream ends (no TaskEnd observed yet) are
291
+ // reported, not dropped — see the "open" contract in the docstring above.
292
+ for (const state of activeByFeature.values()) tasks.push(finalizeTask(state));
293
+ tasks.sort((a, b) => (a.startTs || '').localeCompare(b.startTs || ''));
294
+
295
+ const phases = [...phaseBytes.entries()]
296
+ .map(([phase, kindMap]) => ({
297
+ phase,
298
+ bytesByKind: mapToObj(kindMap),
299
+ bytesTotal: sumValues(kindMap),
300
+ }))
301
+ .sort((a, b) => a.phase.localeCompare(b.phase));
302
+
303
+ const taskDurationSeconds = tasks.reduce((sum, t) => sum + (t.durationSeconds || 0), 0);
304
+
305
+ return {
306
+ total: scoped.length,
307
+ features: [...features],
308
+ taskDurationSeconds,
309
+ bytesByKind: mapToObj(bytesByKindGlobal),
310
+ bytesTotal: sumValues(bytesByKindGlobal),
311
+ dispatches,
312
+ tools: rank(toolCounts),
313
+ tasks,
314
+ phases,
315
+ };
316
+ }
@@ -17,13 +17,18 @@ import { execSync } from 'child_process';
17
17
  /**
18
18
  * Current hooks schema version — bump when hook definitions change.
19
19
  *
20
- * 3.5.0 keeps the same registry entries but changes what the installed hooks
21
- * DO: state is now resolved from the target path's working tree instead of the
22
- * session's. The hook files are re-copied unconditionally (`copyDirectory`
23
- * defaults to `overwrite: true`), so the bump does not gate delivery — it makes
24
- * `morph-spec update` report the change honestly instead of "already current".
20
+ * 3.6.0 (R2 of the performance plan) consolidates spawns: every core hook now
21
+ * exports `run(payload)` (framework/hooks/claude-code/core/*.js) and the
22
+ * registry below routes multi-hook event+matcher groups through the new
23
+ * `core/chain.js`, which imports and runs several hooks' `run()` in ONE node
24
+ * process instead of one process per hook. Same events, same matchers, same
25
+ * observable behavior (block/warn/silent) — only the process count per
26
+ * Write/Edit drops (9 → 4). The hook files are re-copied unconditionally
27
+ * (`copyDirectory` defaults to `overwrite: true`), so the bump does not gate
28
+ * delivery — it makes `morph-spec update` report the change honestly instead
29
+ * of "already current".
25
30
  */
26
- const HOOKS_VERSION = '3.5.0';
31
+ const HOOKS_VERSION = '3.6.0';
27
32
 
28
33
  /** Marker for old dispatch.js (v1) */
29
34
  const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
@@ -83,11 +88,23 @@ const MORPH_PERMISSIONS = MORPH_DENY;
83
88
  /**
84
89
  * All MORPH-SPEC hook definitions.
85
90
  * Each entry maps to a Claude Code hook event.
91
+ *
92
+ * R2 consolidation: an entry whose group has more than one hook now declares
93
+ * ONE `core/chain.js` command with an `args` array (the hook base names, in
94
+ * the ORDER they run) instead of one command per hook — chain.js imports and
95
+ * calls each hook's `run(payload)` in that same process (see its own doc
96
+ * comment for the block-first-wins / context-concatenation rules). A group
97
+ * with exactly one hook stays a direct command — chaining a single link buys
98
+ * nothing. `args` must stay a SEPARATE field from `command`: `getHookSubpath()`
99
+ * below extracts the installed path from `command` alone via a regex anchored
100
+ * on `claude-code/`, and inlining args into that string would get captured
101
+ * into the quoted path.
86
102
  */
87
103
  const MORPH_HOOKS = [
88
104
  // === SessionStart — worktree feature identity (secondary worktree) or
89
105
  // active-feature summary (primary root). Fulfills the SessionStart
90
- // injection role long documented but never wired (inject-morph-context.js). ===
106
+ // injection role long documented but never wired (inject-morph-context.js).
107
+ // Solo (only hook for this event) — nothing to chain. ===
91
108
  {
92
109
  event: 'SessionStart',
93
110
  matcher: null,
@@ -98,23 +115,24 @@ const MORPH_HOOKS = [
98
115
  }]
99
116
  },
100
117
 
101
- // === PreToolUse: Write|Edit — phase + spec protection (silent on success) ===
118
+ // === PreToolUse: Write|Edit — phase + spec protection (silent on success).
119
+ // Chained in the SAME order the previous two-entry registry ran them:
120
+ // protect-spec-files (locked-file check) before enforce-phase-writes (phase
121
+ // folder check) — a block from either one wins and skips the other. Neither
122
+ // hook declared its own timeout (both are pure sync fs checks with no
123
+ // subprocess calls), so the chain declares none either — same platform
124
+ // default both relied on individually. ===
102
125
  {
103
126
  event: 'PreToolUse',
104
127
  matcher: 'Write|Edit',
105
- hooks: [
106
- {
107
- type: 'command',
108
- command: 'node framework/hooks/claude-code/core/protect-spec-files.js'
109
- },
110
- {
111
- type: 'command',
112
- command: 'node framework/hooks/claude-code/core/enforce-phase-writes.js'
113
- }
114
- ]
128
+ hooks: [{
129
+ type: 'command',
130
+ command: 'node framework/hooks/claude-code/core/chain.js',
131
+ args: ['protect-spec-files', 'enforce-phase-writes']
132
+ }]
115
133
  },
116
134
 
117
- // === PreToolUse: Bash — denylist (irreversible commands only) ===
135
+ // === PreToolUse: Bash — denylist (irreversible commands only). Solo. ===
118
136
  {
119
137
  event: 'PreToolUse',
120
138
  matcher: 'Bash',
@@ -129,75 +147,63 @@ const MORPH_HOOKS = [
129
147
  // === PostToolUse: Write|Edit — typecheck (debounced, silent on success) +
130
148
  // tasks.json schema guard (validate-and-warn; status enum, effort enum) +
131
149
  // trace auto-generation (tasks.json write → .morph/traces/, replaces the
132
- // manual trace-writing step the skills used to carry) ===
150
+ // manual trace-writing step the skills used to carry). Chained in the same
151
+ // order as before. post-edit-typecheck declared no timeout of its own (it
152
+ // can legitimately spend up to its internal TSC_TIMEOUT_MS=30000 running
153
+ // `npx tsc --noEmit`), so the chain declares none rather than under-
154
+ // provisioning it with just the sum of the other two links' 3000ms each. ===
133
155
  {
134
156
  event: 'PostToolUse',
135
157
  matcher: 'Write|Edit',
136
- hooks: [
137
- {
138
- type: 'command',
139
- command: 'node framework/hooks/claude-code/core/post-edit-typecheck.js'
140
- },
141
- {
142
- type: 'command',
143
- command: 'node framework/hooks/claude-code/core/tasks-json-guard.js',
144
- timeout: 3000
145
- },
146
- {
147
- type: 'command',
148
- command: 'node framework/hooks/claude-code/core/trace-autogen.js',
149
- timeout: 3000
150
- }
151
- ]
158
+ hooks: [{
159
+ type: 'command',
160
+ command: 'node framework/hooks/claude-code/core/chain.js',
161
+ args: ['post-edit-typecheck', 'tasks-json-guard', 'trace-autogen']
162
+ }]
152
163
  },
153
164
 
154
- // === PostToolUse: Write|Edit|AskUserQuestion — state-sync (autonomous state.json updates) ===
155
- // gate-guard runs AFTER state-sync so it sees the gate that state-sync just
156
- // recorded. Both are silent fail-open guardrails; gate-guard only warns
157
- // (never blocks) when an observed gate action disagrees with the persisted
158
- // gateDecisions (issue #63).
165
+ // === PostToolUse: Write|Edit|AskUserQuestion — state-sync (autonomous
166
+ // state.json updates) then gate-guard. gate-guard runs AFTER state-sync so
167
+ // it sees the gate that state-sync just recorded — now true within a single
168
+ // process too, because state-writer.js's persistFeature() invalidates
169
+ // state-reader.js's per-process loadState() memo on every write (R1.1 added
170
+ // that memo; without the invalidation, a chained gate-guard could read the
171
+ // pre-write snapshot). Both are silent fail-open guardrails; gate-guard only
172
+ // warns (never blocks) when an observed gate action disagrees with the
173
+ // persisted gateDecisions (issue #63). Both links declared 3000ms; sequential
174
+ // execution means the worst case is additive, so the chain declares the sum. ===
159
175
  {
160
176
  event: 'PostToolUse',
161
177
  matcher: 'Write|Edit|AskUserQuestion',
162
- hooks: [
163
- {
164
- type: 'command',
165
- command: 'node framework/hooks/claude-code/core/state-sync.js',
166
- timeout: 3000
167
- },
168
- {
169
- type: 'command',
170
- command: 'node framework/hooks/claude-code/core/gate-guard.js',
171
- timeout: 3000
172
- }
173
- ]
178
+ hooks: [{
179
+ type: 'command',
180
+ command: 'node framework/hooks/claude-code/core/chain.js',
181
+ args: ['state-sync', 'gate-guard'],
182
+ timeout: 6000
183
+ }]
174
184
  },
175
185
 
176
- // === PostToolUse: All — telemetry capture (silent) + loop detection ===
186
+ // === PostToolUse: All — telemetry capture (silent) + loop detection.
177
187
  // telemetry-log is the single generic vantage point of an NLH: it appends
178
188
  // every tool call (skill, sub-agent dispatch, knowledge-base read, …) to
179
189
  // .morph/logs/events.jsonl. Silent and fail-open — never blocks, never
180
- // injects. `morph-spec telemetry` reads the stream back.
190
+ // injects. `morph-spec telemetry` reads the stream back. Both links
191
+ // declared 5000ms; chain declares the sum (sequential worst case). ===
181
192
  {
182
193
  event: 'PostToolUse',
183
194
  matcher: null,
184
- hooks: [
185
- {
186
- type: 'command',
187
- command: 'node framework/hooks/claude-code/core/telemetry-log.js',
188
- timeout: 5000
189
- },
190
- {
191
- type: 'command',
192
- command: 'node framework/hooks/claude-code/core/loop-detect.js',
193
- timeout: 5000
194
- }
195
- ]
195
+ hooks: [{
196
+ type: 'command',
197
+ command: 'node framework/hooks/claude-code/core/chain.js',
198
+ args: ['telemetry-log', 'loop-detect'],
199
+ timeout: 10000
200
+ }]
196
201
  },
197
202
 
198
- // === UserPromptSubmit — telemetry capture of the user's prompt (silent) ===
203
+ // === UserPromptSubmit — telemetry capture of the user's prompt (silent).
199
204
  // The only vantage point on the user's own prompt; completes the execution
200
- // path alongside sub-agent prompts captured on PostToolUse.
205
+ // path alongside sub-agent prompts captured on PostToolUse. Solo (the only
206
+ // hook registered on this event) — nothing to chain. ===
201
207
  {
202
208
  event: 'UserPromptSubmit',
203
209
  matcher: null,
@@ -208,7 +214,7 @@ const MORPH_HOOKS = [
208
214
  }]
209
215
  },
210
216
 
211
- // === Stop — build check + session snapshot ===
217
+ // === Stop — build check + session snapshot. Solo. ===
212
218
  {
213
219
  event: 'Stop',
214
220
  matcher: null,
@@ -269,17 +275,16 @@ export async function installClaudeHooks(targetPath) {
269
275
  entry.matcher = matcher;
270
276
  }
271
277
  entry.hooks = hooks.map(h => {
272
- // Agent and prompt hooks: pass through as-is (no command path transform)
273
- if (h.type === 'agent' || h.type === 'prompt') {
274
- const agentHook = { type: h.type, prompt: h.prompt };
275
- if (h.timeout !== undefined) agentHook.timeout = h.timeout;
276
- return agentHook;
277
- }
278
- // Command hooks: transform path to use $CLAUDE_PROJECT_DIR
279
- // Hooks are copied to .morph/framework/hooks/ during `morph-spec init/update`
278
+ // Command hooks: transform path to use $CLAUDE_PROJECT_DIR.
279
+ // Hooks are copied to .morph/framework/hooks/ during `morph-spec init/update`.
280
+ // `args` (chain.js's hook-name list) is appended AFTER the closing quote —
281
+ // getHookSubpath() below only ever sees the bare `h.command` path, never
282
+ // the args, so it can't accidentally capture them into the quoted command.
283
+ const quotedPath = `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/claude-code/${getHookSubpath(h.command)}"`;
284
+ const command = h.args?.length ? `${quotedPath} ${h.args.join(' ')}` : quotedPath;
280
285
  return {
281
286
  type: h.type,
282
- command: `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/claude-code/${getHookSubpath(h.command)}"`,
287
+ command,
283
288
  ...(h.timeout !== undefined ? { timeout: h.timeout } : {})
284
289
  };
285
290
  });
@@ -1,105 +0,0 @@
1
- /**
2
- * Session Snapshot — cross-session persistence utility
3
- *
4
- * Saves and loads session state for "getting up to speed" on next session.
5
- * Single file (overwrites) — history preserved via git.
6
- *
7
- * Fail-silent: never throws.
8
- */
9
-
10
- import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'fs';
11
- import { join } from 'path';
12
-
13
- const SNAPSHOT_PATH = '.morph/memory/session-snapshot.json';
14
-
15
- /**
16
- * Save session snapshot atomically.
17
- * @param {Object} data - Snapshot data
18
- * @param {string} [projectPath] - Project root (defaults to cwd)
19
- */
20
- export function saveSessionSnapshot(data, projectPath) {
21
- try {
22
- const root = projectPath || process.cwd();
23
- const dir = join(root, '.morph', 'memory');
24
- mkdirSync(dir, { recursive: true });
25
-
26
- const snapshot = { ...data, savedAt: new Date().toISOString() };
27
- const filePath = join(root, SNAPSHOT_PATH);
28
- const tmpPath = `${filePath}.tmp.${process.pid}`;
29
-
30
- writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2), 'utf-8');
31
- try {
32
- renameSync(tmpPath, filePath);
33
- } catch {
34
- writeFileSync(filePath, JSON.stringify(snapshot, null, 2), 'utf-8');
35
- try { unlinkSync(tmpPath); } catch { /* ignore */ }
36
- }
37
- } catch {
38
- // Fail-silent
39
- }
40
- }
41
-
42
- /**
43
- * Load session snapshot.
44
- * @param {string} [projectPath] - Project root (defaults to cwd)
45
- * @returns {Object|null} Parsed snapshot or null
46
- */
47
- export function loadSessionSnapshot(projectPath) {
48
- try {
49
- const filePath = join(projectPath || process.cwd(), SNAPSHOT_PATH);
50
- if (!existsSync(filePath)) return null;
51
- return JSON.parse(readFileSync(filePath, 'utf-8'));
52
- } catch {
53
- return null;
54
- }
55
- }
56
-
57
- /**
58
- * Format snapshot as concise context string for SessionStart injection.
59
- * Budget: max ~150 tokens (~600 chars).
60
- *
61
- * @param {Object|null} snapshot
62
- * @returns {string|null} Formatted context or null
63
- */
64
- export function formatPreviousSession(snapshot) {
65
- if (!snapshot) return null;
66
-
67
- const lines = ['=== PREVIOUS SESSION ==='];
68
-
69
- // Header line
70
- const date = snapshot.savedAt ? new Date(snapshot.savedAt).toISOString().slice(0, 16).replace('T', ' ') : '?';
71
- const phase = snapshot.phase || '?';
72
- const build = snapshot.buildStatus || 'unknown';
73
- lines.push(`Last: ${date} | Phase: ${phase} | Build: ${build}`);
74
-
75
- // Tasks (truncated)
76
- const done = snapshot.tasksCompleted || [];
77
- if (done.length > 0) {
78
- const shown = done.slice(-5).join(', ');
79
- const prefix = done.length > 5 ? `...${done.length} total, last: ` : '';
80
- lines.push(`Done: ${prefix}${shown}`);
81
- }
82
-
83
- const wip = snapshot.tasksInProgress || [];
84
- if (wip.length > 0) {
85
- lines.push(`WIP: ${wip.join(', ')}`);
86
- }
87
-
88
- const remaining = snapshot.tasksRemaining || [];
89
- if (remaining.length > 0) {
90
- const shown = remaining.slice(0, 5).join(', ');
91
- const suffix = remaining.length > 5 ? ` +${remaining.length - 5} more` : '';
92
- lines.push(`Remaining: ${shown}${suffix}`);
93
- }
94
-
95
- // Files (truncated)
96
- const files = snapshot.lastEditedFiles || [];
97
- if (files.length > 0) {
98
- const shown = files.slice(0, 3).join(', ');
99
- const suffix = files.length > 3 ? ` +${files.length - 3} more` : '';
100
- lines.push(`Files: ${shown}${suffix}`);
101
- }
102
-
103
- lines.push('===');
104
- return lines.join('\n');
105
- }