@polymorphism-tech/morph-spec 4.10.7 → 4.10.8

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 (122) hide show
  1. package/CLAUDE.md +3 -3
  2. package/LICENSE +71 -71
  3. package/README.md +379 -379
  4. package/bin/morph-spec.js +1 -1
  5. package/claude-plugin.json +14 -14
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +206 -206
  8. package/framework/agents/README.md +14 -14
  9. package/framework/agents/architecture/standards-architect.md +159 -159
  10. package/framework/agents/frontend/blazor-builder.md +13 -0
  11. package/framework/agents/frontend/css-specialist.md +87 -0
  12. package/framework/agents/frontend/nextjs-expert.md +13 -0
  13. package/framework/agents/frontend/nextjs-ui-specialist.md +218 -0
  14. package/framework/agents/frontend/ui-ux-designer.md +80 -10
  15. package/framework/agents/infrastructure/azure-architect.md +149 -149
  16. package/framework/agents/infrastructure/infra-architect.md +48 -48
  17. package/framework/agents.json +2268 -2204
  18. package/framework/hooks/README.md +204 -204
  19. package/framework/hooks/claude-code/session-start/inject-morph-context.js +10 -24
  20. package/framework/hooks/shared/phase-utils.js +139 -139
  21. package/framework/hooks/shared/worktree-helpers.js +30 -8
  22. package/framework/rules/csharp-standards.md +13 -13
  23. package/framework/rules/frontend-standards.md +13 -13
  24. package/framework/rules/infrastructure-standards.md +16 -16
  25. package/framework/rules/testing-standards.md +14 -14
  26. package/framework/skills/level-0-meta/morph-brainstorming/SKILL.md +12 -0
  27. package/framework/skills/level-0-meta/morph-code-review-nextjs/references/review-example-nextjs.md +254 -254
  28. package/framework/skills/level-0-meta/morph-init/SKILL.md +88 -3
  29. package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +247 -247
  30. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +270 -270
  31. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +515 -499
  32. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +508 -500
  33. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +12 -1
  34. package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +272 -262
  35. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +317 -311
  36. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +424 -323
  37. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  38. package/framework/standards/STANDARDS.json +2040 -2040
  39. package/framework/standards/frontend/nextjs/motion-patterns.md +321 -0
  40. package/framework/standards/frontend/nextjs/nextjs-patterns.md +17 -17
  41. package/framework/templates/README.md +814 -814
  42. package/framework/templates/code/dotnet/contracts/Commands.cs +74 -74
  43. package/framework/templates/code/dotnet/contracts/Entities.cs +25 -25
  44. package/framework/templates/code/dotnet/contracts/Queries.cs +74 -74
  45. package/framework/templates/code/dotnet/contracts/README.md +74 -74
  46. package/framework/templates/code/dotnet/contracts/api-contracts.cs +173 -173
  47. package/framework/templates/code/dotnet/frontend/component.razor +239 -239
  48. package/framework/templates/code/dotnet/jobs/agent.cs +163 -163
  49. package/framework/templates/code/dotnet/jobs/job.cs +171 -171
  50. package/framework/templates/code/dotnet/test.cs +239 -239
  51. package/framework/templates/code/sql/rls-policy.sql +57 -57
  52. package/framework/templates/code/sql/supabase-migration.sql +100 -100
  53. package/framework/templates/code/sql/supabase-migration.template.sql +113 -113
  54. package/framework/templates/code/typescript/contracts.ts +168 -168
  55. package/framework/templates/context/CONTEXT.md +181 -181
  56. package/framework/templates/context/design-system.md +211 -0
  57. package/framework/templates/docs/proposal.md +182 -182
  58. package/framework/templates/docs/user-stories.md +34 -34
  59. package/framework/templates/examples/design-system-examples.md +357 -357
  60. package/framework/templates/examples/spec-examples.md +90 -90
  61. package/framework/templates/feature/decisions.md +187 -187
  62. package/framework/templates/feature/recap.md +146 -146
  63. package/framework/templates/feature/tasks.md +199 -199
  64. package/framework/templates/infrastructure/azure/README.md +286 -286
  65. package/framework/templates/infrastructure/azure/deploy-checklist.md +426 -426
  66. package/framework/templates/infrastructure/azure/parameters.dev.json +29 -29
  67. package/framework/templates/infrastructure/azure/parameters.prod.json +29 -29
  68. package/framework/templates/infrastructure/azure/parameters.staging.json +29 -29
  69. package/framework/templates/infrastructure/docker/docker-compose.template.yml +67 -67
  70. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  71. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  72. package/framework/templates/infrastructure/docker/easypanel.template.json +54 -54
  73. package/package.json +82 -82
  74. package/src/commands/agents/index.js +13 -13
  75. package/src/commands/project/install-plugin-cmd.js +28 -28
  76. package/src/commands/project/setup-infra-cmd.js +12 -12
  77. package/src/commands/project/worktree.js +58 -2
  78. package/src/commands/tasks/index.js +4 -4
  79. package/src/commands/templates/index.js +5 -5
  80. package/src/commands/utils/index.js +3 -3
  81. package/src/commands/validation/index.js +4 -4
  82. package/src/core/state/phase-state-machine.js +217 -217
  83. package/src/lib/generators/design-system-generator.js +298 -298
  84. package/src/lib/generators/index.js +10 -10
  85. package/src/lib/generators/metadata-extractor.js +387 -387
  86. package/src/lib/stack-filter.js +65 -65
  87. package/src/lib/standards/index.js +7 -7
  88. package/src/lib/standards/standards-context-injector.js +302 -302
  89. package/src/lib/validators/architecture/architecture-validator.js +387 -387
  90. package/src/lib/validators/architecture/index.js +7 -7
  91. package/src/lib/validators/blazor/blazor-concurrency-analyzer.js +316 -316
  92. package/src/lib/validators/blazor/blazor-state-validator.js +279 -279
  93. package/src/lib/validators/blazor/blazor-validator.js +369 -369
  94. package/src/lib/validators/blazor/index.js +9 -9
  95. package/src/lib/validators/content/content-validator.js +287 -287
  96. package/src/lib/validators/content/index.js +7 -7
  97. package/src/lib/validators/contracts/index.js +7 -7
  98. package/src/lib/validators/css/css-validator.js +352 -352
  99. package/src/lib/validators/css/index.js +7 -7
  100. package/src/lib/validators/design-system/design-system-validator.js +231 -231
  101. package/src/lib/validators/design-system/index.js +7 -7
  102. package/src/lib/validators/nextjs/index.js +6 -6
  103. package/src/lib/validators/nextjs/next-component-validator.js +183 -183
  104. package/src/lib/validators/packages/index.js +7 -7
  105. package/src/lib/validators/packages/package-validator.js +360 -360
  106. package/src/lib/validators/shared/index.js +12 -12
  107. package/src/lib/validators/shared/issue-counter.js +18 -18
  108. package/src/lib/validators/shared/result-formatter.js +124 -124
  109. package/src/lib/validators/ui/index.js +7 -7
  110. package/src/lib/validators/ui/ui-contrast-validator.js +422 -422
  111. package/src/scripts/global-install.js +58 -58
  112. package/src/scripts/install-plugin.js +126 -126
  113. package/src/utils/claude-md-injector.js +90 -90
  114. package/src/utils/file-copier.js +200 -196
  115. package/src/utils/hooks-installer.js +556 -494
  116. package/src/utils/logger.js +32 -32
  117. package/src/utils/skills-installer.js +178 -178
  118. package/src/utils/version-checker.js +175 -175
  119. package/framework/skills/level-1-workflows/morph-phase-implement/.morph/logs/activity.json +0 -38
  120. package/framework/skills/level-1-workflows/morph-phase-tasks/.morph/logs/activity.json +0 -14
  121. package/framework/templates/.idea/morph-templates.xml +0 -92
  122. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
@@ -1,494 +1,556 @@
1
- /**
2
- * Claude Code Hooks Installer (v2)
3
- *
4
- * Registry-based installer for all MORPH-SPEC Claude Code hooks.
5
- * Declarative MORPH_HOOKS array defines all hooks.
6
- * Idempotent merge into .claude/settings.local.json.
7
- *
8
- * Called by `morph-spec init` and `morph-spec update`.
9
- */
10
-
11
- import { join, dirname } from 'path';
12
- import { readFile, writeFile, mkdir, copyFile } from 'fs/promises';
13
- import { existsSync, chmodSync, readdirSync } from 'fs';
14
- import { homedir } from 'os';
15
- import { execSync } from 'child_process';
16
-
17
- /** Current hooks schema version — bump when hook definitions change */
18
- const HOOKS_VERSION = '2.12.0';
19
-
20
- /** Marker for old dispatch.js (v1) */
21
- const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
22
-
23
- /**
24
- * Native permissions.deny entries managed by MORPH-SPEC.
25
- * These replace the protect-readonly-files.js PreToolUse hook,
26
- * using Claude Code's built-in permissions system instead.
27
- */
28
- const MORPH_PERMISSIONS = [
29
- // Core framework state and readonly files
30
- 'Write(.morph/state.json)',
31
- 'Edit(.morph/state.json)',
32
- 'Write(.morph/framework/**)',
33
- 'Edit(.morph/framework/**)',
34
- // .claude/CLAUDE.md (managed copy — source is framework/CLAUDE.md)
35
- 'Write(.claude/CLAUDE.md)',
36
- 'Edit(.claude/CLAUDE.md)',
37
- // .claude/rules/ (copied from framework/rules/)
38
- 'Write(.claude/rules/**)',
39
- 'Edit(.claude/rules/**)',
40
- // .claude/agents/morph-* (generated by agents-installer)
41
- 'Write(.claude/agents/morph-*)',
42
- 'Edit(.claude/agents/morph-*)',
43
- // .claude/skills/ (copied from framework/skills/)
44
- 'Write(.claude/skills/**)',
45
- 'Edit(.claude/skills/**)',
46
- ];
47
-
48
- /**
49
- * All MORPH-SPEC hook definitions.
50
- * Each entry maps to a Claude Code hook event.
51
- */
52
- const MORPH_HOOKS = [
53
- // === SessionStart ===
54
- {
55
- event: 'SessionStart',
56
- matcher: 'startup|resume|compact',
57
- hooks: [{
58
- type: 'command',
59
- command: 'node framework/hooks/claude-code/session-start/inject-morph-context.js'
60
- }]
61
- },
62
-
63
- // === SessionStart: compact only — post-compact context restore ===
64
- {
65
- event: 'SessionStart',
66
- matcher: 'compact',
67
- hooks: [{
68
- type: 'command',
69
- command: 'node framework/hooks/claude-code/session-start/post-compact-restore.js'
70
- }]
71
- },
72
-
73
- // === UserPromptSubmit ===
74
- {
75
- event: 'UserPromptSubmit',
76
- matcher: null,
77
- hooks: [
78
- {
79
- type: 'command',
80
- command: 'node framework/hooks/claude-code/user-prompt/enrich-prompt.js'
81
- },
82
- ]
83
- },
84
-
85
- // === PreToolUse: Write|Edit ===
86
- {
87
- event: 'PreToolUse',
88
- matcher: 'Write|Edit',
89
- hooks: [
90
- // Note: protect-readonly-files.js replaced by native permissions.deny (see MORPH_PERMISSIONS)
91
- // Order matters: cheapest check first
92
- {
93
- type: 'command',
94
- command: 'node framework/hooks/claude-code/pre-tool-use/protect-spec-files.js'
95
- },
96
- {
97
- type: 'command',
98
- command: 'node framework/hooks/claude-code/pre-tool-use/enforce-phase-writes.js'
99
- },
100
- {
101
- type: 'command',
102
- command: 'node framework/hooks/claude-code/pre-tool-use/task-tracking-guard.js'
103
- }
104
- ]
105
- },
106
-
107
- // === PreToolUse: Bash ===
108
- {
109
- event: 'PreToolUse',
110
- matcher: 'Bash',
111
- hooks: [{
112
- type: 'prompt',
113
- prompt: `You are a guard for a morph-spec project. Read the bash command from the hook input and check EXACTLY these 3 conditions. IMPORTANT: reading files (cat, head, tail, grep, less, bat) is NEVER blocked regardless of path.
114
- 1. Does the command DELETE .morph/ or .morph/state.json or .morph/framework/ using rm -r, rm -rf, or rmdir? BLOCK only these exact paths. NOTE: deleting .morph/features/ subdirectories is NOT blocked.
115
- 2. Does the command OVERWRITE .morph/state.json using a shell redirect (>) combined with sed, jq, awk, echo, cat, or printf? If yes, BLOCK.
116
- 3. Does the command WRITE TO .morph/framework/ via a shell redirect (>) or Node.js fs.writeFileSync/appendFileSync on a path under .morph/framework/? READING .morph/framework/ is always allowed. Writing to .morph/features/, .morph/config/, .morph/context/ is allowed.
117
- If ANY of these 3 specific conditions matches, respond: {"ok": false, "reason": "MORPH-SPEC: [explain what was blocked]. To read agents/state use: morph-spec state get <feature> | morph-spec dispatch-agents <feature> <phase> --table | cat .morph/framework/agents.json (reading is always allowed). To update state use: morph-spec state set <feature> <key> <value>."}
118
- Otherwise respond: {"ok": true}`
119
- }]
120
- },
121
-
122
- // === PostToolUse: Bash ===
123
- {
124
- event: 'PostToolUse',
125
- matcher: 'Bash',
126
- hooks: [
127
- {
128
- type: 'command',
129
- command: 'node framework/hooks/claude-code/post-tool-use/dispatch.js'
130
- },
131
- {
132
- type: 'command',
133
- command: 'node framework/hooks/claude-code/post-tool-use/context-refresh.js'
134
- },
135
- {
136
- type: 'command',
137
- command: 'node framework/hooks/claude-code/post-tool-use/skill-reminder.js'
138
- },
139
- {
140
- type: 'command',
141
- command: 'node framework/hooks/claude-code/post-tool-use/validator-feedback.js'
142
- }
143
- ]
144
- },
145
-
146
- // === Stop ===
147
- {
148
- event: 'Stop',
149
- matcher: null,
150
- hooks: [{
151
- type: 'command',
152
- command: 'node framework/hooks/claude-code/stop/validate-completion.js'
153
- }]
154
- },
155
-
156
- // === PreCompact ===
157
- {
158
- event: 'PreCompact',
159
- matcher: null,
160
- hooks: [{
161
- type: 'command',
162
- command: 'node framework/hooks/claude-code/pre-compact/save-morph-context.js'
163
- }]
164
- },
165
-
166
- // === PostToolUseFailure ===
167
- {
168
- event: 'PostToolUseFailure',
169
- matcher: null,
170
- hooks: [{
171
- type: 'command',
172
- command: 'node framework/hooks/claude-code/post-tool-use/handle-tool-failure.js',
173
- timeout: 10000,
174
- }]
175
- },
176
-
177
- // === Notification ===
178
- {
179
- event: 'Notification',
180
- matcher: 'idle_prompt',
181
- hooks: [{
182
- type: 'command',
183
- command: 'node framework/hooks/claude-code/notification/approval-reminder.js'
184
- }]
185
- },
186
-
187
- // === TeammateIdle ===
188
- // Fires when an Agent Team teammate finishes and is about to go idle.
189
- // Exit 2 = send feedback to teammate to continue working (validation failed).
190
- // Exit 0 = teammate may become idle (validation passed).
191
- // Requires CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 (set in env above).
192
- {
193
- event: 'TeammateIdle',
194
- matcher: null,
195
- hooks: [{
196
- type: 'command',
197
- command: 'node framework/hooks/claude-code/teammate-idle/teammate-idle.js'
198
- }]
199
- }
200
- ];
201
-
202
- /**
203
- * Install or update all MORPH-SPEC hooks in .claude/settings.local.json.
204
- *
205
- * - Idempotent: safe to call multiple times
206
- * - Removes old v1 dispatch.js entry if present
207
- * - Tracks installed version via _morph_managed.hooks_version
208
- *
209
- * @param {string} targetPath - Project root directory
210
- * @returns {Promise<{ installed: number, updated: boolean }>}
211
- */
212
- export async function installClaudeHooks(targetPath) {
213
- const claudeDir = join(targetPath, '.claude');
214
- const settingsPath = join(claudeDir, 'settings.local.json');
215
-
216
- // Read existing settings or start empty
217
- let settings = {};
218
- if (existsSync(settingsPath)) {
219
- try {
220
- settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
221
- } catch {
222
- settings = {};
223
- }
224
- }
225
-
226
- settings.hooks = settings.hooks || {};
227
-
228
- // Check if already at current version
229
- const managed = settings._morph_managed || {};
230
- const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
231
-
232
- // Remove old v1 dispatch.js entry
233
- removeOldDispatch(settings);
234
-
235
- // Remove all morph-managed hooks (clean slate for idempotent update)
236
- removeMorphHooks(settings);
237
-
238
- // Install all hooks from the registry
239
- let installed = 0;
240
- for (const hookDef of MORPH_HOOKS) {
241
- const { event, matcher, hooks } = hookDef;
242
-
243
- if (!settings.hooks[event]) {
244
- settings.hooks[event] = [];
245
- }
246
-
247
- const entry = { _morph: true };
248
- if (matcher) {
249
- entry.matcher = matcher;
250
- }
251
- entry.hooks = hooks.map(h => {
252
- // Agent and prompt hooks: pass through as-is (no command path transform)
253
- if (h.type === 'agent' || h.type === 'prompt') {
254
- const agentHook = { type: h.type, prompt: h.prompt };
255
- if (h.timeout !== undefined) agentHook.timeout = h.timeout;
256
- return agentHook;
257
- }
258
- // Command hooks: transform path to use $CLAUDE_PROJECT_DIR
259
- // Hooks are copied to .morph/framework/hooks/ during `morph-spec init/update`
260
- return {
261
- type: h.type,
262
- command: `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/claude-code/${getHookSubpath(h.command)}"`,
263
- ...(h.timeout !== undefined ? { timeout: h.timeout } : {})
264
- };
265
- });
266
-
267
- settings.hooks[event].push(entry);
268
- installed++;
269
- }
270
-
271
- // Install native permissions.deny entries (idempotent)
272
- settings.permissions = settings.permissions || {};
273
- settings.permissions.deny = settings.permissions.deny || [];
274
- // Remove previously managed permissions to avoid duplicates on re-install
275
- settings.permissions.deny = settings.permissions.deny.filter(
276
- p => !MORPH_PERMISSIONS.includes(p)
277
- );
278
- for (const perm of MORPH_PERMISSIONS) {
279
- settings.permissions.deny.push(perm);
280
- }
281
-
282
- // JSON Schema for IDE auto-complete and validation
283
- settings['$schema'] = 'https://json.schemastore.org/claude-code-settings.json';
284
-
285
- // Environment variables — always set MORPH_SPEC_ACTIVE + Agent Teams flag, preserve user additions
286
- settings.env = {
287
- ...settings.env,
288
- MORPH_SPEC_ACTIVE: 'true',
289
- CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
290
- };
291
-
292
- // Attribution — only set if not already customized by user
293
- if (!settings.attribution) {
294
- settings.attribution = {
295
- commit: '🤖 Co-authored by MORPH-SPEC',
296
- pr: '',
297
- };
298
- }
299
-
300
- // Plans directory for Claude Code plan mode
301
- if (!settings.plansDirectory) {
302
- settings.plansDirectory = '.morph/plans';
303
- }
304
-
305
- // Track installed hooks version
306
- settings._morph_managed = settings._morph_managed || {};
307
- settings._morph_managed.hooks_version = HOOKS_VERSION;
308
- settings._morph_managed.hooks_installed = [...new Set(MORPH_HOOKS.map(h => h.event))];
309
- settings._morph_managed.generated_at = new Date().toISOString();
310
-
311
- // Ensure .claude directory exists
312
- if (!existsSync(claudeDir)) {
313
- await mkdir(claudeDir, { recursive: true });
314
- }
315
-
316
- await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
317
- return { installed, updated: !alreadyCurrent };
318
- }
319
-
320
- /**
321
- * Resolve Python executable suitable for the statusLine command.
322
- *
323
- * On non-Windows: returns 'python3' (standard PATH name).
324
- * On Windows: Claude Code runs commands via the system shell where python3/python
325
- * may not be on PATH. We probe with execSync first, then fall back to known
326
- * Windows install locations (AppData\Local\Programs\Python\Python3*).
327
- *
328
- * @returns {string} Python executable (full path on Windows if needed)
329
- */
330
- function resolvePythonForStatusline() {
331
- if (process.platform !== 'win32') {
332
- return 'python3';
333
- }
334
- // Try PATH-visible commands first (works if user added Python to PATH)
335
- // 'py' is the Windows Python Launcher — standard on any Windows Python install
336
- for (const cmd of ['python3', 'py', 'python']) {
337
- try {
338
- const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
339
- if ((ver + '').includes('Python 3')) return cmd;
340
- } catch { /* not on PATH */ }
341
- }
342
- // Probe known Windows install locations
343
- const base = join(homedir(), 'AppData', 'Local', 'Programs', 'Python');
344
- if (existsSync(base)) {
345
- let dirs;
346
- try { dirs = readdirSync(base); } catch { dirs = []; }
347
- const py3 = dirs
348
- .filter(d => /^Python3\d*/i.test(d))
349
- .sort()
350
- .reverse(); // prefer highest version
351
- for (const dir of py3) {
352
- const exe = join(base, dir, 'python.exe');
353
- if (existsSync(exe)) return exe;
354
- }
355
- }
356
- // Last resort: return python3 and let it fail with a clear error
357
- return 'python3';
358
- }
359
-
360
- /**
361
- * Install the morph-spec statusline globally into ~/.claude/.
362
- *
363
- * Copies statusline.sh and statusline.py to ~/.claude/ and registers
364
- * the statusLine config in ~/.claude/settings.json so it applies to
365
- * every Claude Code session, not just morph-spec projects.
366
- *
367
- * Python 3 must be available as `python3` on PATH (global prerequisite).
368
- *
369
- * @param {string} hooksSourceDir - Path to framework/hooks/claude-code/
370
- * @param {string} [globalClaudeDirOverride] - Override ~/.claude/ (for testing)
371
- * @returns {Promise<{ installed: boolean, globalDir: string }>}
372
- */
373
- export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOverride = null) {
374
- const globalDir = globalClaudeDirOverride || join(homedir(), '.claude');
375
- await mkdir(globalDir, { recursive: true });
376
-
377
- // Copy statusline scripts to ~/.claude/
378
- const scripts = ['statusline.sh', 'statusline.py'];
379
- for (const fname of scripts) {
380
- const src = join(hooksSourceDir, fname);
381
- if (existsSync(src)) {
382
- await copyFile(src, join(globalDir, fname));
383
- if (process.platform !== 'win32') {
384
- try { chmodSync(join(globalDir, fname), 0o755); } catch { /* non-critical */ }
385
- }
386
- }
387
- }
388
-
389
- // Register statusLine in ~/.claude/settings.json (global user settings)
390
- const settingsPath = join(globalDir, 'settings.json');
391
- let settings = {};
392
- if (existsSync(settingsPath)) {
393
- try {
394
- settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
395
- } catch {
396
- settings = {};
397
- }
398
- }
399
-
400
- // On Windows, Claude Code cannot execute .sh files directly — call Python instead.
401
- const statuslineCommand = process.platform === 'win32'
402
- ? `"${resolvePythonForStatusline()}" "${join(globalDir, 'statusline.py').replace(/\\/g, '/')}"`
403
- : join(globalDir, 'statusline.sh').replace(/\\/g, '/');
404
-
405
- settings.statusLine = {
406
- type: 'command',
407
- command: statuslineCommand,
408
- padding: 2
409
- };
410
-
411
- await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
412
- return { installed: true, globalDir };
413
- }
414
-
415
-
416
-
417
- /**
418
- * Remove old v1 agent-teams/dispatch.js hook entries.
419
- * @param {Object} settings
420
- */
421
- function removeOldDispatch(settings) {
422
- if (!settings.hooks?.PostToolUse) return;
423
-
424
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
425
- if (!Array.isArray(entry.hooks)) return true;
426
- return !entry.hooks.some(h =>
427
- h.command === OLD_DISPATCH_COMMAND ||
428
- (typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
429
- );
430
- });
431
-
432
- if (settings.hooks.PostToolUse.length === 0) {
433
- delete settings.hooks.PostToolUse;
434
- }
435
- }
436
-
437
- /**
438
- * Remove all morph-managed hook entries.
439
- * Uses _morph:true marker (v2.2+) with legacy path-pattern fallback.
440
- * Preserves any user-added hooks.
441
- * @param {Object} settings
442
- */
443
- function removeMorphHooks(settings) {
444
- const morphPathPattern = /framework\/hooks\/claude-code\//;
445
-
446
- for (const [event, entries] of Object.entries(settings.hooks)) {
447
- if (!Array.isArray(entries)) continue;
448
-
449
- settings.hooks[event] = entries.filter(entry => {
450
- // Remove if explicitly marked as morph-managed
451
- if (entry._morph === true) return false;
452
-
453
- if (!Array.isArray(entry.hooks)) return true;
454
- // Legacy path-pattern detection: remove if all hooks are morph command hooks
455
- // Note: legacy path only covers command-type hooks (those with a path-matching command).
456
- // Agent/prompt hooks without _morph marker are NOT removed by this fallback.
457
- // This is acceptable since all morph hooks since v2.2.0 carry the _morph marker.
458
- const nonMorphHooks = entry.hooks.filter(h =>
459
- typeof h.command !== 'string' || !morphPathPattern.test(h.command)
460
- );
461
- return nonMorphHooks.length > 0;
462
- });
463
-
464
- if (settings.hooks[event].length === 0) {
465
- delete settings.hooks[event];
466
- }
467
- }
468
- }
469
-
470
- /**
471
- * Extract hook subpath from a full command string.
472
- * @param {string} command - e.g., 'node framework/hooks/claude-code/stop/validate-completion.js'
473
- * @returns {string} e.g., 'stop/validate-completion.js'
474
- */
475
- function getHookSubpath(command) {
476
- const match = command.match(/claude-code\/(.+)$/);
477
- return match ? match[1] : command;
478
- }
479
-
480
- /**
481
- * Get the current hooks version.
482
- * @returns {string}
483
- */
484
- export function getHooksVersion() {
485
- return HOOKS_VERSION;
486
- }
487
-
488
- /**
489
- * Get the list of hook definitions (for testing/inspection).
490
- * @returns {Array}
491
- */
492
- export function getHookDefinitions() {
493
- return MORPH_HOOKS;
494
- }
1
+ /**
2
+ * Claude Code Hooks Installer (v2)
3
+ *
4
+ * Registry-based installer for all MORPH-SPEC Claude Code hooks.
5
+ * Declarative MORPH_HOOKS array defines all hooks.
6
+ * Idempotent merge into .claude/settings.local.json.
7
+ *
8
+ * Called by `morph-spec init` and `morph-spec update`.
9
+ */
10
+
11
+ import { join, dirname } from 'path';
12
+ import { readFile, writeFile, mkdir, copyFile } from 'fs/promises';
13
+ import { existsSync, chmodSync, readdirSync } from 'fs';
14
+ import { homedir } from 'os';
15
+ import { execSync } from 'child_process';
16
+
17
+ /** Current hooks schema version — bump when hook definitions change */
18
+ const HOOKS_VERSION = '2.12.0';
19
+
20
+ /** Marker for old dispatch.js (v1) */
21
+ const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
22
+
23
+ /**
24
+ * Native permissions.deny entries managed by MORPH-SPEC.
25
+ * These replace the protect-readonly-files.js PreToolUse hook,
26
+ * using Claude Code's built-in permissions system instead.
27
+ */
28
+ const MORPH_PERMISSIONS = [
29
+ // Core framework state and readonly files
30
+ 'Write(.morph/state.json)',
31
+ 'Edit(.morph/state.json)',
32
+ 'Write(.morph/framework/**)',
33
+ 'Edit(.morph/framework/**)',
34
+ // .claude/CLAUDE.md (managed copy — source is framework/CLAUDE.md)
35
+ 'Write(.claude/CLAUDE.md)',
36
+ 'Edit(.claude/CLAUDE.md)',
37
+ // .claude/rules/ (copied from framework/rules/)
38
+ 'Write(.claude/rules/**)',
39
+ 'Edit(.claude/rules/**)',
40
+ // .claude/agents/morph-* (generated by agents-installer)
41
+ 'Write(.claude/agents/morph-*)',
42
+ 'Edit(.claude/agents/morph-*)',
43
+ // .claude/skills/ (copied from framework/skills/)
44
+ 'Write(.claude/skills/**)',
45
+ 'Edit(.claude/skills/**)',
46
+ ];
47
+
48
+ /**
49
+ * All MORPH-SPEC hook definitions.
50
+ * Each entry maps to a Claude Code hook event.
51
+ */
52
+ const MORPH_HOOKS = [
53
+ // === SessionStart ===
54
+ {
55
+ event: 'SessionStart',
56
+ matcher: 'startup|resume|compact',
57
+ hooks: [{
58
+ type: 'command',
59
+ command: 'node framework/hooks/claude-code/session-start/inject-morph-context.js'
60
+ }]
61
+ },
62
+
63
+ // === SessionStart: compact only — post-compact context restore ===
64
+ {
65
+ event: 'SessionStart',
66
+ matcher: 'compact',
67
+ hooks: [{
68
+ type: 'command',
69
+ command: 'node framework/hooks/claude-code/session-start/post-compact-restore.js'
70
+ }]
71
+ },
72
+
73
+ // === UserPromptSubmit ===
74
+ {
75
+ event: 'UserPromptSubmit',
76
+ matcher: null,
77
+ hooks: [
78
+ {
79
+ type: 'command',
80
+ command: 'node framework/hooks/claude-code/user-prompt/enrich-prompt.js'
81
+ },
82
+ ]
83
+ },
84
+
85
+ // === PreToolUse: Write|Edit ===
86
+ {
87
+ event: 'PreToolUse',
88
+ matcher: 'Write|Edit',
89
+ hooks: [
90
+ // Note: protect-readonly-files.js replaced by native permissions.deny (see MORPH_PERMISSIONS)
91
+ // Order matters: cheapest check first
92
+ {
93
+ type: 'command',
94
+ command: 'node framework/hooks/claude-code/pre-tool-use/protect-spec-files.js'
95
+ },
96
+ {
97
+ type: 'command',
98
+ command: 'node framework/hooks/claude-code/pre-tool-use/enforce-phase-writes.js'
99
+ },
100
+ {
101
+ type: 'command',
102
+ command: 'node framework/hooks/claude-code/pre-tool-use/task-tracking-guard.js'
103
+ }
104
+ ]
105
+ },
106
+
107
+ // === PreToolUse: Bash ===
108
+ {
109
+ event: 'PreToolUse',
110
+ matcher: 'Bash',
111
+ hooks: [{
112
+ type: 'prompt',
113
+ prompt: `You are a guard for a morph-spec project. Read the bash command from the hook input and check EXACTLY these 3 conditions. IMPORTANT: reading files (cat, head, tail, grep, less, bat) is NEVER blocked regardless of path.
114
+ 1. Does the command DELETE .morph/ or .morph/state.json or .morph/framework/ using rm -r, rm -rf, or rmdir? BLOCK only these exact paths. NOTE: deleting .morph/features/ subdirectories is NOT blocked.
115
+ 2. Does the command OVERWRITE .morph/state.json using a shell redirect (>) combined with sed, jq, awk, echo, cat, or printf? If yes, BLOCK.
116
+ 3. Does the command WRITE TO .morph/framework/ via a shell redirect (>) or Node.js fs.writeFileSync/appendFileSync on a path under .morph/framework/? READING .morph/framework/ is always allowed. Writing to .morph/features/, .morph/config/, .morph/context/ is allowed.
117
+ If ANY of these 3 specific conditions matches, respond: {"ok": false, "reason": "MORPH-SPEC: [explain what was blocked]. To read agents/state use: morph-spec state get <feature> | morph-spec dispatch-agents <feature> <phase> --table | cat .morph/framework/agents.json (reading is always allowed). To update state use: morph-spec state set <feature> <key> <value>."}
118
+ Otherwise respond: {"ok": true}`
119
+ }]
120
+ },
121
+
122
+ // === PostToolUse: Bash ===
123
+ {
124
+ event: 'PostToolUse',
125
+ matcher: 'Bash',
126
+ hooks: [
127
+ {
128
+ type: 'command',
129
+ command: 'node framework/hooks/claude-code/post-tool-use/dispatch.js'
130
+ },
131
+ {
132
+ type: 'command',
133
+ command: 'node framework/hooks/claude-code/post-tool-use/context-refresh.js'
134
+ },
135
+ {
136
+ type: 'command',
137
+ command: 'node framework/hooks/claude-code/post-tool-use/skill-reminder.js'
138
+ },
139
+ {
140
+ type: 'command',
141
+ command: 'node framework/hooks/claude-code/post-tool-use/validator-feedback.js'
142
+ }
143
+ ]
144
+ },
145
+
146
+ // === Stop ===
147
+ {
148
+ event: 'Stop',
149
+ matcher: null,
150
+ hooks: [{
151
+ type: 'command',
152
+ command: 'node framework/hooks/claude-code/stop/validate-completion.js'
153
+ }]
154
+ },
155
+
156
+ // === PreCompact ===
157
+ {
158
+ event: 'PreCompact',
159
+ matcher: null,
160
+ hooks: [{
161
+ type: 'command',
162
+ command: 'node framework/hooks/claude-code/pre-compact/save-morph-context.js'
163
+ }]
164
+ },
165
+
166
+ // === PostToolUseFailure ===
167
+ {
168
+ event: 'PostToolUseFailure',
169
+ matcher: null,
170
+ hooks: [{
171
+ type: 'command',
172
+ command: 'node framework/hooks/claude-code/post-tool-use/handle-tool-failure.js',
173
+ timeout: 10000,
174
+ }]
175
+ },
176
+
177
+ // === Notification ===
178
+ {
179
+ event: 'Notification',
180
+ matcher: 'idle_prompt',
181
+ hooks: [{
182
+ type: 'command',
183
+ command: 'node framework/hooks/claude-code/notification/approval-reminder.js'
184
+ }]
185
+ },
186
+
187
+ // === TeammateIdle ===
188
+ // Fires when an Agent Team teammate finishes and is about to go idle.
189
+ // Exit 2 = send feedback to teammate to continue working (validation failed).
190
+ // Exit 0 = teammate may become idle (validation passed).
191
+ // Requires CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 (set in env above).
192
+ {
193
+ event: 'TeammateIdle',
194
+ matcher: null,
195
+ hooks: [{
196
+ type: 'command',
197
+ command: 'node framework/hooks/claude-code/teammate-idle/teammate-idle.js'
198
+ }]
199
+ }
200
+ ];
201
+
202
+ /**
203
+ * Install or update all MORPH-SPEC hooks in .claude/settings.local.json.
204
+ *
205
+ * - Idempotent: safe to call multiple times
206
+ * - Removes old v1 dispatch.js entry if present
207
+ * - Tracks installed version via _morph_managed.hooks_version
208
+ *
209
+ * @param {string} targetPath - Project root directory
210
+ * @returns {Promise<{ installed: number, updated: boolean }>}
211
+ */
212
+ export async function installClaudeHooks(targetPath) {
213
+ const claudeDir = join(targetPath, '.claude');
214
+ const settingsPath = join(claudeDir, 'settings.local.json');
215
+
216
+ // Read existing settings or start empty
217
+ let settings = {};
218
+ if (existsSync(settingsPath)) {
219
+ try {
220
+ settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
221
+ } catch {
222
+ settings = {};
223
+ }
224
+ }
225
+
226
+ settings.hooks = settings.hooks || {};
227
+
228
+ // Check if already at current version
229
+ const managed = settings._morph_managed || {};
230
+ const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
231
+
232
+ // Remove old v1 dispatch.js entry
233
+ removeOldDispatch(settings);
234
+
235
+ // Remove all morph-managed hooks (clean slate for idempotent update)
236
+ removeMorphHooks(settings);
237
+
238
+ // Install all hooks from the registry
239
+ let installed = 0;
240
+ for (const hookDef of MORPH_HOOKS) {
241
+ const { event, matcher, hooks } = hookDef;
242
+
243
+ if (!settings.hooks[event]) {
244
+ settings.hooks[event] = [];
245
+ }
246
+
247
+ const entry = { _morph: true };
248
+ if (matcher) {
249
+ entry.matcher = matcher;
250
+ }
251
+ entry.hooks = hooks.map(h => {
252
+ // Agent and prompt hooks: pass through as-is (no command path transform)
253
+ if (h.type === 'agent' || h.type === 'prompt') {
254
+ const agentHook = { type: h.type, prompt: h.prompt };
255
+ if (h.timeout !== undefined) agentHook.timeout = h.timeout;
256
+ return agentHook;
257
+ }
258
+ // Command hooks: transform path to use $CLAUDE_PROJECT_DIR
259
+ // Hooks are copied to .morph/framework/hooks/ during `morph-spec init/update`
260
+ return {
261
+ type: h.type,
262
+ command: `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/claude-code/${getHookSubpath(h.command)}"`,
263
+ ...(h.timeout !== undefined ? { timeout: h.timeout } : {})
264
+ };
265
+ });
266
+
267
+ settings.hooks[event].push(entry);
268
+ installed++;
269
+ }
270
+
271
+ // Install native permissions.deny entries (idempotent)
272
+ settings.permissions = settings.permissions || {};
273
+ settings.permissions.deny = settings.permissions.deny || [];
274
+ // Remove previously managed permissions to avoid duplicates on re-install
275
+ settings.permissions.deny = settings.permissions.deny.filter(
276
+ p => !MORPH_PERMISSIONS.includes(p)
277
+ );
278
+ for (const perm of MORPH_PERMISSIONS) {
279
+ settings.permissions.deny.push(perm);
280
+ }
281
+
282
+ // JSON Schema for IDE auto-complete and validation
283
+ settings['$schema'] = 'https://json.schemastore.org/claude-code-settings.json';
284
+
285
+ // Environment variables — always set MORPH_SPEC_ACTIVE + Agent Teams flag, preserve user additions
286
+ settings.env = {
287
+ ...settings.env,
288
+ MORPH_SPEC_ACTIVE: 'true',
289
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
290
+ };
291
+
292
+ // Attribution — only set if not already customized by user
293
+ if (!settings.attribution) {
294
+ settings.attribution = {
295
+ commit: '🤖 Co-authored by MORPH-SPEC',
296
+ pr: '',
297
+ };
298
+ }
299
+
300
+ // Plans directory for Claude Code plan mode
301
+ if (!settings.plansDirectory) {
302
+ settings.plansDirectory = '.morph/plans';
303
+ }
304
+
305
+ // Track installed hooks version
306
+ settings._morph_managed = settings._morph_managed || {};
307
+ settings._morph_managed.hooks_version = HOOKS_VERSION;
308
+ settings._morph_managed.hooks_installed = [...new Set(MORPH_HOOKS.map(h => h.event))];
309
+ settings._morph_managed.generated_at = new Date().toISOString();
310
+
311
+ // Ensure .claude directory exists
312
+ if (!existsSync(claudeDir)) {
313
+ await mkdir(claudeDir, { recursive: true });
314
+ }
315
+
316
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
317
+ return { installed, updated: !alreadyCurrent };
318
+ }
319
+
320
+ /**
321
+ * Try to find an existing Python 3 executable.
322
+ *
323
+ * @returns {string|null} Python command/path if found, null otherwise
324
+ */
325
+ function findPython() {
326
+ if (process.platform !== 'win32') {
327
+ for (const cmd of ['python3', 'python']) {
328
+ try {
329
+ const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
330
+ if ((ver + '').includes('Python 3')) return cmd;
331
+ } catch { /* not on PATH */ }
332
+ }
333
+ return null;
334
+ }
335
+ // Windows: try PATH-visible commands first
336
+ // 'py' is the Windows Python Launcher — standard on any Windows Python install
337
+ for (const cmd of ['python3', 'py', 'python']) {
338
+ try {
339
+ const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
340
+ if ((ver + '').includes('Python 3')) return cmd;
341
+ } catch { /* not on PATH */ }
342
+ }
343
+ // Probe known Windows install locations
344
+ const base = join(homedir(), 'AppData', 'Local', 'Programs', 'Python');
345
+ if (existsSync(base)) {
346
+ let dirs;
347
+ try { dirs = readdirSync(base); } catch { dirs = []; }
348
+ const py3 = dirs
349
+ .filter(d => /^Python3\d*/i.test(d))
350
+ .sort()
351
+ .reverse(); // prefer highest version
352
+ for (const dir of py3) {
353
+ const exe = join(base, dir, 'python.exe');
354
+ if (existsSync(exe)) return exe;
355
+ }
356
+ }
357
+ return null;
358
+ }
359
+
360
+ /**
361
+ * Attempt to auto-install Python 3 using the system package manager.
362
+ *
363
+ * On Windows: uses winget (built-in on Windows 10+).
364
+ * On macOS: uses brew if available.
365
+ * On Linux: tries apt-get, then dnf.
366
+ *
367
+ * @returns {boolean} true if installation succeeded
368
+ */
369
+ function tryInstallPython() {
370
+ const log = (msg) => process.stdout.write(` ${msg}\n`);
371
+ log('⚠ Python 3 not found — attempting automatic installation...');
372
+
373
+ try {
374
+ if (process.platform === 'win32') {
375
+ execSync('winget install -e --id Python.Python.3.12 --accept-source-agreements --accept-package-agreements', {
376
+ stdio: 'inherit',
377
+ timeout: 120_000,
378
+ });
379
+ } else if (process.platform === 'darwin') {
380
+ execSync('brew install python3', { stdio: 'inherit', timeout: 120_000 });
381
+ } else {
382
+ // Linux — try apt-get first, then dnf
383
+ try {
384
+ execSync('sudo apt-get update -qq && sudo apt-get install -y python3', { stdio: 'inherit', timeout: 120_000 });
385
+ } catch {
386
+ execSync('sudo dnf install -y python3', { stdio: 'inherit', timeout: 120_000 });
387
+ }
388
+ }
389
+ log('✓ Python 3 installed successfully.');
390
+ return true;
391
+ } catch (err) {
392
+ log(`✗ Automatic Python installation failed: ${err.message}`);
393
+ log(' Please install Python 3 manually: https://www.python.org/downloads/');
394
+ return false;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Resolve Python executable suitable for the statusLine command.
400
+ *
401
+ * Tries to find Python 3 on the system. If not found, attempts automatic
402
+ * installation via the system package manager (winget/brew/apt/dnf).
403
+ *
404
+ * @returns {string} Python executable (full path on Windows if needed)
405
+ */
406
+ function resolvePythonForStatusline() {
407
+ // First pass — check existing installation
408
+ let python = findPython();
409
+ if (python) return python;
410
+
411
+ // Not found — try to install
412
+ if (tryInstallPython()) {
413
+ // Re-probe after installation
414
+ python = findPython();
415
+ if (python) return python;
416
+ }
417
+
418
+ // Last resort: return python3 and let it fail with a clear error
419
+ return 'python3';
420
+ }
421
+
422
+ /**
423
+ * Install the morph-spec statusline globally into ~/.claude/.
424
+ *
425
+ * Copies statusline.sh and statusline.py to ~/.claude/ and registers
426
+ * the statusLine config in ~/.claude/settings.json so it applies to
427
+ * every Claude Code session, not just morph-spec projects.
428
+ *
429
+ * Python 3 must be available as `python3` on PATH (global prerequisite).
430
+ *
431
+ * @param {string} hooksSourceDir - Path to framework/hooks/claude-code/
432
+ * @param {string} [globalClaudeDirOverride] - Override ~/.claude/ (for testing)
433
+ * @returns {Promise<{ installed: boolean, globalDir: string }>}
434
+ */
435
+ export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOverride = null) {
436
+ const globalDir = globalClaudeDirOverride || join(homedir(), '.claude');
437
+ await mkdir(globalDir, { recursive: true });
438
+
439
+ // Copy statusline scripts to ~/.claude/
440
+ const scripts = ['statusline.sh', 'statusline.py'];
441
+ for (const fname of scripts) {
442
+ const src = join(hooksSourceDir, fname);
443
+ if (existsSync(src)) {
444
+ await copyFile(src, join(globalDir, fname));
445
+ if (process.platform !== 'win32') {
446
+ try { chmodSync(join(globalDir, fname), 0o755); } catch { /* non-critical */ }
447
+ }
448
+ }
449
+ }
450
+
451
+ // Register statusLine in ~/.claude/settings.json (global user settings)
452
+ const settingsPath = join(globalDir, 'settings.json');
453
+ let settings = {};
454
+ if (existsSync(settingsPath)) {
455
+ try {
456
+ settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
457
+ } catch {
458
+ settings = {};
459
+ }
460
+ }
461
+
462
+ // On Windows, Claude Code cannot execute .sh files directly — call Python instead.
463
+ const statuslineCommand = process.platform === 'win32'
464
+ ? `"${resolvePythonForStatusline()}" "${join(globalDir, 'statusline.py').replace(/\\/g, '/')}"`
465
+ : join(globalDir, 'statusline.sh').replace(/\\/g, '/');
466
+
467
+ settings.statusLine = {
468
+ type: 'command',
469
+ command: statuslineCommand,
470
+ padding: 2
471
+ };
472
+
473
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
474
+ return { installed: true, globalDir };
475
+ }
476
+
477
+
478
+
479
+ /**
480
+ * Remove old v1 agent-teams/dispatch.js hook entries.
481
+ * @param {Object} settings
482
+ */
483
+ function removeOldDispatch(settings) {
484
+ if (!settings.hooks?.PostToolUse) return;
485
+
486
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
487
+ if (!Array.isArray(entry.hooks)) return true;
488
+ return !entry.hooks.some(h =>
489
+ h.command === OLD_DISPATCH_COMMAND ||
490
+ (typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
491
+ );
492
+ });
493
+
494
+ if (settings.hooks.PostToolUse.length === 0) {
495
+ delete settings.hooks.PostToolUse;
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Remove all morph-managed hook entries.
501
+ * Uses _morph:true marker (v2.2+) with legacy path-pattern fallback.
502
+ * Preserves any user-added hooks.
503
+ * @param {Object} settings
504
+ */
505
+ function removeMorphHooks(settings) {
506
+ const morphPathPattern = /framework\/hooks\/claude-code\//;
507
+
508
+ for (const [event, entries] of Object.entries(settings.hooks)) {
509
+ if (!Array.isArray(entries)) continue;
510
+
511
+ settings.hooks[event] = entries.filter(entry => {
512
+ // Remove if explicitly marked as morph-managed
513
+ if (entry._morph === true) return false;
514
+
515
+ if (!Array.isArray(entry.hooks)) return true;
516
+ // Legacy path-pattern detection: remove if all hooks are morph command hooks
517
+ // Note: legacy path only covers command-type hooks (those with a path-matching command).
518
+ // Agent/prompt hooks without _morph marker are NOT removed by this fallback.
519
+ // This is acceptable since all morph hooks since v2.2.0 carry the _morph marker.
520
+ const nonMorphHooks = entry.hooks.filter(h =>
521
+ typeof h.command !== 'string' || !morphPathPattern.test(h.command)
522
+ );
523
+ return nonMorphHooks.length > 0;
524
+ });
525
+
526
+ if (settings.hooks[event].length === 0) {
527
+ delete settings.hooks[event];
528
+ }
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Extract hook subpath from a full command string.
534
+ * @param {string} command - e.g., 'node framework/hooks/claude-code/stop/validate-completion.js'
535
+ * @returns {string} e.g., 'stop/validate-completion.js'
536
+ */
537
+ function getHookSubpath(command) {
538
+ const match = command.match(/claude-code\/(.+)$/);
539
+ return match ? match[1] : command;
540
+ }
541
+
542
+ /**
543
+ * Get the current hooks version.
544
+ * @returns {string}
545
+ */
546
+ export function getHooksVersion() {
547
+ return HOOKS_VERSION;
548
+ }
549
+
550
+ /**
551
+ * Get the list of hook definitions (for testing/inspection).
552
+ * @returns {Array}
553
+ */
554
+ export function getHookDefinitions() {
555
+ return MORPH_HOOKS;
556
+ }