@polymorphism-tech/morph-spec 8.15.1 → 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.
- package/README.md +2 -2
- package/bin/morph-spec.js +8 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +3 -3
- package/framework/hooks/README.md +3 -3
- package/framework/hooks/claude-code/core/chain.js +123 -0
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
- package/framework/hooks/claude-code/core/gate-guard.js +21 -25
- package/framework/hooks/claude-code/core/loop-detect.js +70 -64
- package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
- package/framework/hooks/claude-code/core/protect-spec-files.js +110 -103
- package/framework/hooks/claude-code/core/state-sync.js +32 -40
- package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
- package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
- package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
- package/framework/hooks/claude-code/core/validate-completion.js +147 -107
- package/framework/hooks/claude-code/core/worktree-context.js +25 -22
- package/framework/hooks/claude-code/statusline.py +26 -4
- package/framework/hooks/shared/activity-logger.js +120 -69
- package/framework/hooks/shared/claim-registry.js +61 -1
- package/framework/hooks/shared/git-paths.js +94 -0
- package/framework/hooks/shared/hook-response.js +67 -0
- package/framework/hooks/shared/state-reader.js +95 -29
- package/framework/hooks/shared/state-writer.js +16 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/observability/cost.js +137 -0
- package/src/commands/state/approve.js +30 -9
- package/src/dashboard/scanner/hooks-scanner.js +11 -2
- package/src/lib/installers/memory-installer.js +1 -1
- package/src/lib/telemetry/telemetry-reader.js +213 -0
- package/src/utils/hooks-installer.js +84 -79
- package/framework/hooks/shared/session-snapshot.js +0 -105
|
@@ -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.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
155
|
-
// gate-guard runs AFTER state-sync so
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
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
|
|
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
|
-
}
|