@polymorphism-tech/morph-spec 7.2.0 → 7.4.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 (74) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +4 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +2 -2
  8. package/framework/MORPH.md +29 -12
  9. package/framework/agents.json +132 -132
  10. package/framework/commands/morph-apply.md +1 -1
  11. package/framework/commands/morph-proposal.md +1 -1
  12. package/framework/evals/rubrics/README.md +9 -8
  13. package/framework/hooks/README.md +3 -1
  14. package/framework/hooks/claude-code/core/gate-guard.js +160 -0
  15. package/framework/hooks/claude-code/core/state-sync.js +15 -3
  16. package/framework/hooks/claude-code/core/validate-completion.js +241 -136
  17. package/framework/hooks/shared/state-reader.js +236 -221
  18. package/framework/phases.json +1 -1
  19. package/framework/skills/README.md +2 -2
  20. package/framework/skills/core/morph-apply/SKILL.md +63 -21
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +5 -1
  22. package/framework/skills/core/morph-eval/SKILL.md +4 -5
  23. package/framework/skills/core/morph-implement/SKILL.md +47 -19
  24. package/framework/skills/core/morph-plan/SKILL.md +8 -2
  25. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  26. package/framework/skills/core/morph-review/SKILL.md +13 -6
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/framework/standards/ai-agents/setup.md +258 -244
  30. package/framework/templates/README.md +708 -709
  31. package/framework/templates/REGISTRY.json +1 -20
  32. package/framework/templates/context/CONTEXT-FEATURE.md +276 -276
  33. package/framework/templates/docs/proposal.md +171 -171
  34. package/framework/templates/feature/recap.md +145 -145
  35. package/package.json +1 -1
  36. package/src/commands/project/doctor.js +941 -946
  37. package/src/commands/project/init.js +517 -515
  38. package/src/commands/project/status.js +373 -376
  39. package/src/commands/state/approve.js +251 -213
  40. package/src/commands/validation/validate-feature.js +356 -287
  41. package/src/core/state/state-manager.js +910 -934
  42. package/src/dashboard/app/App.jsx +29 -31
  43. package/src/dashboard/app/components/Sidebar.jsx +44 -45
  44. package/src/dashboard/scanner/index.js +20 -21
  45. package/src/lib/context/context-builder.js +3 -3
  46. package/src/lib/generators/context-generator.js +2 -3
  47. package/src/lib/generators/mandate-generator.js +48 -92
  48. package/src/lib/generators/metadata-extractor.js +385 -386
  49. package/src/lib/generators/recap-generator.js +285 -285
  50. package/src/lib/risk-detector.js +2 -2
  51. package/src/lib/validators/content/content-validator.js +89 -286
  52. package/src/lib/validators/content/index.js +0 -1
  53. package/src/utils/hooks-installer.js +527 -516
  54. package/framework/templates/feature/tasks.md +0 -199
  55. package/framework/workflows/README.md +0 -1041
  56. package/framework/workflows/configs/fast-track.json +0 -46
  57. package/framework/workflows/configs/full.json +0 -64
  58. package/framework/workflows/configs/quick.json +0 -32
  59. package/framework/workflows/configs/standard.json +0 -57
  60. package/framework/workflows/docs/STORY-DRIVEN-DEVELOPMENT.md +0 -392
  61. package/framework/workflows/docs/design-impl.md +0 -37
  62. package/framework/workflows/docs/enforcement-pipeline.md +0 -668
  63. package/framework/workflows/docs/fast-track.md +0 -29
  64. package/framework/workflows/docs/full-morph.md +0 -69
  65. package/framework/workflows/docs/standard.md +0 -49
  66. package/framework/workflows/docs/ui-refresh.md +0 -39
  67. package/src/commands/state/index.js +0 -7
  68. package/src/core/workflows/workflow-detector.js +0 -586
  69. package/src/core/workflows/workflow-router.js +0 -204
  70. package/src/dashboard/app/pages/Workflows.jsx +0 -346
  71. package/src/dashboard/scanner/workflows-scanner.js +0 -18
  72. package/src/lib/phase-chain/phase-validator.js +0 -359
  73. package/src/lib/validators/content/cross-phase-validator.js +0 -281
  74. package/src/lib/validators/spec-validator.js +0 -312
@@ -1,516 +1,527 @@
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 = '3.0.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
- *
26
- * state.json is NOT in deny because the agent edits it directly as a
27
- * fallback to the state-sync hook (per v7 hybrid state model). The
28
- * framework/ tree remains read-only — only the installer touches it.
29
- */
30
- const MORPH_DENY = [
31
- // Framework files — installer-managed, immutable at runtime
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
- * Native permissions.allow entries managed by MORPH-SPEC.
50
- *
51
- * Pre-authorizes operations the agent legitimately performs during the
52
- * morph-spec flow, so the user does not get permission prompts on every
53
- * proposal.md / spec.md / tasks.json write.
54
- */
55
- const MORPH_ALLOW = [
56
- // Feature artifacts — the agent's working directory during proposal/plan/implement/review
57
- 'Write(.morph/features/**)',
58
- 'Edit(.morph/features/**)',
59
- // state.json — fallback path when state-sync hook can't cover a case
60
- 'Edit(.morph/state.json)',
61
- // Long-lived architectural context — agent may reconcile during brainstorming
62
- 'Write(.morph/context/**)',
63
- 'Edit(.morph/context/**)',
64
- // Folder creation under .morph
65
- 'Bash(mkdir:.morph/**)',
66
- 'Bash(ls:.morph/**)',
67
- ];
68
-
69
- // Backwards-compat alias used by older tests
70
- const MORPH_PERMISSIONS = MORPH_DENY;
71
-
72
- /**
73
- * All MORPH-SPEC hook definitions.
74
- * Each entry maps to a Claude Code hook event.
75
- */
76
- const MORPH_HOOKS = [
77
- // === PreToolUse: Write|Edit — phase + spec protection (silent on success) ===
78
- {
79
- event: 'PreToolUse',
80
- matcher: 'Write|Edit',
81
- hooks: [
82
- {
83
- type: 'command',
84
- command: 'node framework/hooks/claude-code/core/protect-spec-files.js'
85
- },
86
- {
87
- type: 'command',
88
- command: 'node framework/hooks/claude-code/core/enforce-phase-writes.js'
89
- }
90
- ]
91
- },
92
-
93
- // === PreToolUse: Bash — denylist (irreversible commands only) ===
94
- {
95
- event: 'PreToolUse',
96
- matcher: 'Bash',
97
- hooks: [
98
- {
99
- type: 'command',
100
- command: 'node framework/hooks/claude-code/core/pre-bash-denylist.js'
101
- }
102
- ]
103
- },
104
-
105
- // === PostToolUse: Write|Edit — typecheck (debounced, silent on success) ===
106
- {
107
- event: 'PostToolUse',
108
- matcher: 'Write|Edit',
109
- hooks: [{
110
- type: 'command',
111
- command: 'node framework/hooks/claude-code/core/post-edit-typecheck.js'
112
- }]
113
- },
114
-
115
- // === PostToolUse: Write|Edit|AskUserQuestion — state-sync (autonomous state.json updates) ===
116
- {
117
- event: 'PostToolUse',
118
- matcher: 'Write|Edit|AskUserQuestion',
119
- hooks: [{
120
- type: 'command',
121
- command: 'node framework/hooks/claude-code/core/state-sync.js',
122
- timeout: 3000
123
- }]
124
- },
125
-
126
- // === PostToolUse: All — loop detection ===
127
- {
128
- event: 'PostToolUse',
129
- matcher: null,
130
- hooks: [{
131
- type: 'command',
132
- command: 'node framework/hooks/claude-code/core/loop-detect.js',
133
- timeout: 5000
134
- }]
135
- },
136
-
137
- // === Stop — build check + session snapshot ===
138
- {
139
- event: 'Stop',
140
- matcher: null,
141
- hooks: [{
142
- type: 'command',
143
- command: 'node framework/hooks/claude-code/core/validate-completion.js'
144
- }]
145
- }
146
- ];
147
-
148
- /**
149
- * Install or update all MORPH-SPEC hooks in .claude/settings.local.json.
150
- *
151
- * - Idempotent: safe to call multiple times
152
- * - Removes old v1 dispatch.js entry if present
153
- * - Tracks installed version via _morph_managed.hooks_version
154
- *
155
- * @param {string} targetPath - Project root directory
156
- * @returns {Promise<{ installed: number, updated: boolean }>}
157
- */
158
- export async function installClaudeHooks(targetPath) {
159
- const claudeDir = join(targetPath, '.claude');
160
- const settingsPath = join(claudeDir, 'settings.local.json');
161
-
162
- // Read existing settings or start empty
163
- let settings = {};
164
- if (existsSync(settingsPath)) {
165
- try {
166
- settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
167
- } catch {
168
- settings = {};
169
- }
170
- }
171
-
172
- settings.hooks = settings.hooks || {};
173
-
174
- // Check if already at current version
175
- const managed = settings._morph_managed || {};
176
- const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
177
-
178
- // Remove old v1 dispatch.js entry
179
- removeOldDispatch(settings);
180
-
181
- // Remove all morph-managed hooks (clean slate for idempotent update)
182
- removeMorphHooks(settings);
183
-
184
- // Install all hooks from the registry
185
- let installed = 0;
186
- for (const hookDef of MORPH_HOOKS) {
187
- const { event, matcher, hooks } = hookDef;
188
-
189
- if (!settings.hooks[event]) {
190
- settings.hooks[event] = [];
191
- }
192
-
193
- const entry = { _morph: true };
194
- if (matcher) {
195
- entry.matcher = matcher;
196
- }
197
- entry.hooks = hooks.map(h => {
198
- // Agent and prompt hooks: pass through as-is (no command path transform)
199
- if (h.type === 'agent' || h.type === 'prompt') {
200
- const agentHook = { type: h.type, prompt: h.prompt };
201
- if (h.timeout !== undefined) agentHook.timeout = h.timeout;
202
- return agentHook;
203
- }
204
- // Command hooks: transform path to use $CLAUDE_PROJECT_DIR
205
- // Hooks are copied to .morph/framework/hooks/ during `morph-spec init/update`
206
- return {
207
- type: h.type,
208
- command: `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/claude-code/${getHookSubpath(h.command)}"`,
209
- ...(h.timeout !== undefined ? { timeout: h.timeout } : {})
210
- };
211
- });
212
-
213
- settings.hooks[event].push(entry);
214
- installed++;
215
- }
216
-
217
- // Install native permissions (idempotent — preserve user-added entries)
218
- settings.permissions = settings.permissions || {};
219
- settings.permissions.deny = settings.permissions.deny || [];
220
- settings.permissions.allow = settings.permissions.allow || [];
221
-
222
- // Stale-entry cleanup: remove old morph-managed deny entries (e.g. state.json
223
- // was previously in deny — v7 moves it to allow)
224
- const STALE_DENY = [
225
- 'Write(.morph/state.json)',
226
- 'Edit(.morph/state.json)',
227
- ];
228
- settings.permissions.deny = settings.permissions.deny.filter(
229
- p => !MORPH_DENY.includes(p) && !STALE_DENY.includes(p)
230
- );
231
- for (const perm of MORPH_DENY) {
232
- settings.permissions.deny.push(perm);
233
- }
234
-
235
- settings.permissions.allow = settings.permissions.allow.filter(
236
- p => !MORPH_ALLOW.includes(p)
237
- );
238
- for (const perm of MORPH_ALLOW) {
239
- settings.permissions.allow.push(perm);
240
- }
241
-
242
- // JSON Schema for IDE auto-complete and validation
243
- settings['$schema'] = 'https://json.schemastore.org/claude-code-settings.json';
244
-
245
- // Environment variables — always set MORPH_SPEC_ACTIVE + Agent Teams flag, preserve user additions
246
- settings.env = {
247
- ...settings.env,
248
- MORPH_SPEC_ACTIVE: 'true',
249
- CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
250
- };
251
-
252
- // Attribution — only set if not already customized by user
253
- if (!settings.attribution) {
254
- settings.attribution = {
255
- commit: '🤖 Co-authored by MORPH-SPEC',
256
- pr: '',
257
- };
258
- }
259
-
260
- // Plans directory for Claude Code plan mode
261
- if (!settings.plansDirectory) {
262
- settings.plansDirectory = '.morph/plans';
263
- }
264
-
265
- // Track installed hooks version
266
- settings._morph_managed = settings._morph_managed || {};
267
- settings._morph_managed.hooks_version = HOOKS_VERSION;
268
- settings._morph_managed.hooks_installed = [...new Set(MORPH_HOOKS.map(h => h.event))];
269
- settings._morph_managed.generated_at = new Date().toISOString();
270
-
271
- // Ensure .claude directory exists
272
- if (!existsSync(claudeDir)) {
273
- await mkdir(claudeDir, { recursive: true });
274
- }
275
-
276
- await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
277
- return { installed, updated: !alreadyCurrent };
278
- }
279
-
280
- /**
281
- * Try to find an existing Python 3 executable.
282
- *
283
- * @returns {string|null} Python command/path if found, null otherwise
284
- */
285
- function findPython() {
286
- if (process.platform !== 'win32') {
287
- for (const cmd of ['python3', 'python']) {
288
- try {
289
- const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
290
- if ((ver + '').includes('Python 3')) return cmd;
291
- } catch { /* not on PATH */ }
292
- }
293
- return null;
294
- }
295
- // Windows: try PATH-visible commands first
296
- // 'py' is the Windows Python Launcher — standard on any Windows Python install
297
- for (const cmd of ['python3', 'py', 'python']) {
298
- try {
299
- const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
300
- if ((ver + '').includes('Python 3')) return cmd;
301
- } catch { /* not on PATH */ }
302
- }
303
- // Probe known Windows install locations
304
- const base = join(homedir(), 'AppData', 'Local', 'Programs', 'Python');
305
- if (existsSync(base)) {
306
- let dirs;
307
- try { dirs = readdirSync(base); } catch { dirs = []; }
308
- const py3 = dirs
309
- .filter(d => /^Python3\d*/i.test(d))
310
- .sort()
311
- .reverse(); // prefer highest version
312
- for (const dir of py3) {
313
- const exe = join(base, dir, 'python.exe');
314
- if (existsSync(exe)) return exe;
315
- }
316
- }
317
- return null;
318
- }
319
-
320
- /**
321
- * Attempt to auto-install Python 3 using the system package manager.
322
- *
323
- * On Windows: uses winget (built-in on Windows 10+).
324
- * On macOS: uses brew if available.
325
- * On Linux: tries apt-get, then dnf.
326
- *
327
- * @returns {boolean} true if installation succeeded
328
- */
329
- function tryInstallPython() {
330
- const log = (msg) => process.stdout.write(` ${msg}\n`);
331
- log('⚠ Python 3 not found — attempting automatic installation...');
332
-
333
- try {
334
- if (process.platform === 'win32') {
335
- execSync('winget install -e --id Python.Python.3.12 --accept-source-agreements --accept-package-agreements', {
336
- stdio: 'inherit',
337
- timeout: 120_000,
338
- });
339
- } else if (process.platform === 'darwin') {
340
- execSync('brew install python3', { stdio: 'inherit', timeout: 120_000 });
341
- } else {
342
- // Linux — try apt-get first, then dnf
343
- try {
344
- execSync('sudo apt-get update -qq && sudo apt-get install -y python3', { stdio: 'inherit', timeout: 120_000 });
345
- } catch {
346
- execSync('sudo dnf install -y python3', { stdio: 'inherit', timeout: 120_000 });
347
- }
348
- }
349
- log('✓ Python 3 installed successfully.');
350
- return true;
351
- } catch (err) {
352
- log(`✗ Automatic Python installation failed: ${err.message}`);
353
- log(' Please install Python 3 manually: https://www.python.org/downloads/');
354
- return false;
355
- }
356
- }
357
-
358
- /**
359
- * Resolve Python executable suitable for the statusLine command.
360
- *
361
- * Tries to find Python 3 on the system. If not found, attempts automatic
362
- * installation via the system package manager (winget/brew/apt/dnf).
363
- *
364
- * @returns {string} Python executable (full path on Windows if needed)
365
- */
366
- function resolvePythonForStatusline() {
367
- // First pass — check existing installation
368
- let python = findPython();
369
- if (python) return python;
370
-
371
- // Not found — try to install
372
- if (tryInstallPython()) {
373
- // Re-probe after installation
374
- python = findPython();
375
- if (python) return python;
376
- }
377
-
378
- // Last resort: return python3 and let it fail with a clear error
379
- return 'python3';
380
- }
381
-
382
- /**
383
- * Install the morph-spec statusline globally into ~/.claude/.
384
- *
385
- * Copies statusline.sh and statusline.py to ~/.claude/ and registers
386
- * the statusLine config in ~/.claude/settings.json so it applies to
387
- * every Claude Code session, not just morph-spec projects.
388
- *
389
- * Python 3 must be available as `python3` on PATH (global prerequisite).
390
- *
391
- * @param {string} hooksSourceDir - Path to framework/hooks/claude-code/
392
- * @param {string} [globalClaudeDirOverride] - Override ~/.claude/ (for testing)
393
- * @returns {Promise<{ installed: boolean, globalDir: string }>}
394
- */
395
- export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOverride = null) {
396
- const globalDir = globalClaudeDirOverride || join(homedir(), '.claude');
397
- await mkdir(globalDir, { recursive: true });
398
-
399
- // Copy statusline scripts to ~/.claude/
400
- const scripts = ['statusline.sh', 'statusline.py'];
401
- for (const fname of scripts) {
402
- const src = join(hooksSourceDir, fname);
403
- if (existsSync(src)) {
404
- await copyFile(src, join(globalDir, fname));
405
- if (process.platform !== 'win32') {
406
- try { chmodSync(join(globalDir, fname), 0o755); } catch { /* non-critical */ }
407
- }
408
- }
409
- }
410
-
411
- // Register statusLine in ~/.claude/settings.json (global user settings)
412
- const settingsPath = join(globalDir, 'settings.json');
413
- let settings = {};
414
- if (existsSync(settingsPath)) {
415
- try {
416
- settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
417
- } catch {
418
- settings = {};
419
- }
420
- }
421
-
422
- // On Windows, Claude Code cannot execute .sh files directly — call Python instead.
423
- const statuslineCommand = process.platform === 'win32'
424
- ? `"${resolvePythonForStatusline()}" "${join(globalDir, 'statusline.py').replace(/\\/g, '/')}"`
425
- : join(globalDir, 'statusline.sh').replace(/\\/g, '/');
426
-
427
- settings.statusLine = {
428
- type: 'command',
429
- command: statuslineCommand,
430
- padding: 2
431
- };
432
-
433
- await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
434
- return { installed: true, globalDir };
435
- }
436
-
437
-
438
-
439
- /**
440
- * Remove old v1 agent-teams/dispatch.js hook entries.
441
- * @param {Object} settings
442
- */
443
- function removeOldDispatch(settings) {
444
- if (!settings.hooks?.PostToolUse) return;
445
-
446
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
447
- if (!Array.isArray(entry.hooks)) return true;
448
- return !entry.hooks.some(h =>
449
- h.command === OLD_DISPATCH_COMMAND ||
450
- (typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
451
- );
452
- });
453
-
454
- if (settings.hooks.PostToolUse.length === 0) {
455
- delete settings.hooks.PostToolUse;
456
- }
457
- }
458
-
459
- /**
460
- * Remove all morph-managed hook entries.
461
- * Uses _morph:true marker (v2.2+) with legacy path-pattern fallback.
462
- * Preserves any user-added hooks.
463
- * @param {Object} settings
464
- */
465
- function removeMorphHooks(settings) {
466
- const morphPathPattern = /framework\/hooks\/claude-code\//;
467
-
468
- for (const [event, entries] of Object.entries(settings.hooks)) {
469
- if (!Array.isArray(entries)) continue;
470
-
471
- settings.hooks[event] = entries.filter(entry => {
472
- // Remove if explicitly marked as morph-managed
473
- if (entry._morph === true) return false;
474
-
475
- if (!Array.isArray(entry.hooks)) return true;
476
- // Legacy path-pattern detection: remove if all hooks are morph command hooks
477
- // Note: legacy path only covers command-type hooks (those with a path-matching command).
478
- // Agent/prompt hooks without _morph marker are NOT removed by this fallback.
479
- // This is acceptable since all morph hooks since v2.2.0 carry the _morph marker.
480
- const nonMorphHooks = entry.hooks.filter(h =>
481
- typeof h.command !== 'string' || !morphPathPattern.test(h.command)
482
- );
483
- return nonMorphHooks.length > 0;
484
- });
485
-
486
- if (settings.hooks[event].length === 0) {
487
- delete settings.hooks[event];
488
- }
489
- }
490
- }
491
-
492
- /**
493
- * Extract hook subpath from a full command string.
494
- * @param {string} command - e.g., 'node framework/hooks/claude-code/stop/validate-completion.js'
495
- * @returns {string} e.g., 'stop/validate-completion.js'
496
- */
497
- function getHookSubpath(command) {
498
- const match = command.match(/claude-code\/(.+)$/);
499
- return match ? match[1] : command;
500
- }
501
-
502
- /**
503
- * Get the current hooks version.
504
- * @returns {string}
505
- */
506
- export function getHooksVersion() {
507
- return HOOKS_VERSION;
508
- }
509
-
510
- /**
511
- * Get the list of hook definitions (for testing/inspection).
512
- * @returns {Array}
513
- */
514
- export function getHookDefinitions() {
515
- return MORPH_HOOKS;
516
- }
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 = '3.0.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
+ *
26
+ * state.json is NOT in deny because the agent edits it directly as a
27
+ * fallback to the state-sync hook (per v7 hybrid state model). The
28
+ * framework/ tree remains read-only — only the installer touches it.
29
+ */
30
+ const MORPH_DENY = [
31
+ // Framework files — installer-managed, immutable at runtime
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
+ * Native permissions.allow entries managed by MORPH-SPEC.
50
+ *
51
+ * Pre-authorizes operations the agent legitimately performs during the
52
+ * morph-spec flow, so the user does not get permission prompts on every
53
+ * proposal.md / spec.md / tasks.json write.
54
+ */
55
+ const MORPH_ALLOW = [
56
+ // Feature artifacts — the agent's working directory during proposal/plan/implement/review
57
+ 'Write(.morph/features/**)',
58
+ 'Edit(.morph/features/**)',
59
+ // state.json — fallback path when state-sync hook can't cover a case
60
+ 'Edit(.morph/state.json)',
61
+ // Long-lived architectural context — agent may reconcile during brainstorming
62
+ 'Write(.morph/context/**)',
63
+ 'Edit(.morph/context/**)',
64
+ // Folder creation under .morph
65
+ 'Bash(mkdir:.morph/**)',
66
+ 'Bash(ls:.morph/**)',
67
+ ];
68
+
69
+ // Backwards-compat alias used by older tests
70
+ const MORPH_PERMISSIONS = MORPH_DENY;
71
+
72
+ /**
73
+ * All MORPH-SPEC hook definitions.
74
+ * Each entry maps to a Claude Code hook event.
75
+ */
76
+ const MORPH_HOOKS = [
77
+ // === PreToolUse: Write|Edit — phase + spec protection (silent on success) ===
78
+ {
79
+ event: 'PreToolUse',
80
+ matcher: 'Write|Edit',
81
+ hooks: [
82
+ {
83
+ type: 'command',
84
+ command: 'node framework/hooks/claude-code/core/protect-spec-files.js'
85
+ },
86
+ {
87
+ type: 'command',
88
+ command: 'node framework/hooks/claude-code/core/enforce-phase-writes.js'
89
+ }
90
+ ]
91
+ },
92
+
93
+ // === PreToolUse: Bash — denylist (irreversible commands only) ===
94
+ {
95
+ event: 'PreToolUse',
96
+ matcher: 'Bash',
97
+ hooks: [
98
+ {
99
+ type: 'command',
100
+ command: 'node framework/hooks/claude-code/core/pre-bash-denylist.js'
101
+ }
102
+ ]
103
+ },
104
+
105
+ // === PostToolUse: Write|Edit — typecheck (debounced, silent on success) ===
106
+ {
107
+ event: 'PostToolUse',
108
+ matcher: 'Write|Edit',
109
+ hooks: [{
110
+ type: 'command',
111
+ command: 'node framework/hooks/claude-code/core/post-edit-typecheck.js'
112
+ }]
113
+ },
114
+
115
+ // === PostToolUse: Write|Edit|AskUserQuestion — state-sync (autonomous state.json updates) ===
116
+ // gate-guard runs AFTER state-sync so it sees the gate that state-sync just
117
+ // recorded. Both are silent fail-open guardrails; gate-guard only warns
118
+ // (never blocks) when an observed gate action disagrees with the persisted
119
+ // gateDecisions (issue #63).
120
+ {
121
+ event: 'PostToolUse',
122
+ matcher: 'Write|Edit|AskUserQuestion',
123
+ hooks: [
124
+ {
125
+ type: 'command',
126
+ command: 'node framework/hooks/claude-code/core/state-sync.js',
127
+ timeout: 3000
128
+ },
129
+ {
130
+ type: 'command',
131
+ command: 'node framework/hooks/claude-code/core/gate-guard.js',
132
+ timeout: 3000
133
+ }
134
+ ]
135
+ },
136
+
137
+ // === PostToolUse: All — loop detection ===
138
+ {
139
+ event: 'PostToolUse',
140
+ matcher: null,
141
+ hooks: [{
142
+ type: 'command',
143
+ command: 'node framework/hooks/claude-code/core/loop-detect.js',
144
+ timeout: 5000
145
+ }]
146
+ },
147
+
148
+ // === Stop — build check + session snapshot ===
149
+ {
150
+ event: 'Stop',
151
+ matcher: null,
152
+ hooks: [{
153
+ type: 'command',
154
+ command: 'node framework/hooks/claude-code/core/validate-completion.js'
155
+ }]
156
+ }
157
+ ];
158
+
159
+ /**
160
+ * Install or update all MORPH-SPEC hooks in .claude/settings.local.json.
161
+ *
162
+ * - Idempotent: safe to call multiple times
163
+ * - Removes old v1 dispatch.js entry if present
164
+ * - Tracks installed version via _morph_managed.hooks_version
165
+ *
166
+ * @param {string} targetPath - Project root directory
167
+ * @returns {Promise<{ installed: number, updated: boolean }>}
168
+ */
169
+ export async function installClaudeHooks(targetPath) {
170
+ const claudeDir = join(targetPath, '.claude');
171
+ const settingsPath = join(claudeDir, 'settings.local.json');
172
+
173
+ // Read existing settings or start empty
174
+ let settings = {};
175
+ if (existsSync(settingsPath)) {
176
+ try {
177
+ settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
178
+ } catch {
179
+ settings = {};
180
+ }
181
+ }
182
+
183
+ settings.hooks = settings.hooks || {};
184
+
185
+ // Check if already at current version
186
+ const managed = settings._morph_managed || {};
187
+ const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
188
+
189
+ // Remove old v1 dispatch.js entry
190
+ removeOldDispatch(settings);
191
+
192
+ // Remove all morph-managed hooks (clean slate for idempotent update)
193
+ removeMorphHooks(settings);
194
+
195
+ // Install all hooks from the registry
196
+ let installed = 0;
197
+ for (const hookDef of MORPH_HOOKS) {
198
+ const { event, matcher, hooks } = hookDef;
199
+
200
+ if (!settings.hooks[event]) {
201
+ settings.hooks[event] = [];
202
+ }
203
+
204
+ const entry = { _morph: true };
205
+ if (matcher) {
206
+ entry.matcher = matcher;
207
+ }
208
+ entry.hooks = hooks.map(h => {
209
+ // Agent and prompt hooks: pass through as-is (no command path transform)
210
+ if (h.type === 'agent' || h.type === 'prompt') {
211
+ const agentHook = { type: h.type, prompt: h.prompt };
212
+ if (h.timeout !== undefined) agentHook.timeout = h.timeout;
213
+ return agentHook;
214
+ }
215
+ // Command hooks: transform path to use $CLAUDE_PROJECT_DIR
216
+ // Hooks are copied to .morph/framework/hooks/ during `morph-spec init/update`
217
+ return {
218
+ type: h.type,
219
+ command: `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/claude-code/${getHookSubpath(h.command)}"`,
220
+ ...(h.timeout !== undefined ? { timeout: h.timeout } : {})
221
+ };
222
+ });
223
+
224
+ settings.hooks[event].push(entry);
225
+ installed++;
226
+ }
227
+
228
+ // Install native permissions (idempotent — preserve user-added entries)
229
+ settings.permissions = settings.permissions || {};
230
+ settings.permissions.deny = settings.permissions.deny || [];
231
+ settings.permissions.allow = settings.permissions.allow || [];
232
+
233
+ // Stale-entry cleanup: remove old morph-managed deny entries (e.g. state.json
234
+ // was previously in deny — v7 moves it to allow)
235
+ const STALE_DENY = [
236
+ 'Write(.morph/state.json)',
237
+ 'Edit(.morph/state.json)',
238
+ ];
239
+ settings.permissions.deny = settings.permissions.deny.filter(
240
+ p => !MORPH_DENY.includes(p) && !STALE_DENY.includes(p)
241
+ );
242
+ for (const perm of MORPH_DENY) {
243
+ settings.permissions.deny.push(perm);
244
+ }
245
+
246
+ settings.permissions.allow = settings.permissions.allow.filter(
247
+ p => !MORPH_ALLOW.includes(p)
248
+ );
249
+ for (const perm of MORPH_ALLOW) {
250
+ settings.permissions.allow.push(perm);
251
+ }
252
+
253
+ // JSON Schema for IDE auto-complete and validation
254
+ settings['$schema'] = 'https://json.schemastore.org/claude-code-settings.json';
255
+
256
+ // Environment variables — always set MORPH_SPEC_ACTIVE + Agent Teams flag, preserve user additions
257
+ settings.env = {
258
+ ...settings.env,
259
+ MORPH_SPEC_ACTIVE: 'true',
260
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
261
+ };
262
+
263
+ // Attribution — only set if not already customized by user
264
+ if (!settings.attribution) {
265
+ settings.attribution = {
266
+ commit: '🤖 Co-authored by MORPH-SPEC',
267
+ pr: '',
268
+ };
269
+ }
270
+
271
+ // Plans directory for Claude Code plan mode
272
+ if (!settings.plansDirectory) {
273
+ settings.plansDirectory = '.morph/plans';
274
+ }
275
+
276
+ // Track installed hooks version
277
+ settings._morph_managed = settings._morph_managed || {};
278
+ settings._morph_managed.hooks_version = HOOKS_VERSION;
279
+ settings._morph_managed.hooks_installed = [...new Set(MORPH_HOOKS.map(h => h.event))];
280
+ settings._morph_managed.generated_at = new Date().toISOString();
281
+
282
+ // Ensure .claude directory exists
283
+ if (!existsSync(claudeDir)) {
284
+ await mkdir(claudeDir, { recursive: true });
285
+ }
286
+
287
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
288
+ return { installed, updated: !alreadyCurrent };
289
+ }
290
+
291
+ /**
292
+ * Try to find an existing Python 3 executable.
293
+ *
294
+ * @returns {string|null} Python command/path if found, null otherwise
295
+ */
296
+ function findPython() {
297
+ if (process.platform !== 'win32') {
298
+ for (const cmd of ['python3', 'python']) {
299
+ try {
300
+ const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
301
+ if ((ver + '').includes('Python 3')) return cmd;
302
+ } catch { /* not on PATH */ }
303
+ }
304
+ return null;
305
+ }
306
+ // Windows: try PATH-visible commands first
307
+ // 'py' is the Windows Python Launcher — standard on any Windows Python install
308
+ for (const cmd of ['python3', 'py', 'python']) {
309
+ try {
310
+ const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
311
+ if ((ver + '').includes('Python 3')) return cmd;
312
+ } catch { /* not on PATH */ }
313
+ }
314
+ // Probe known Windows install locations
315
+ const base = join(homedir(), 'AppData', 'Local', 'Programs', 'Python');
316
+ if (existsSync(base)) {
317
+ let dirs;
318
+ try { dirs = readdirSync(base); } catch { dirs = []; }
319
+ const py3 = dirs
320
+ .filter(d => /^Python3\d*/i.test(d))
321
+ .sort()
322
+ .reverse(); // prefer highest version
323
+ for (const dir of py3) {
324
+ const exe = join(base, dir, 'python.exe');
325
+ if (existsSync(exe)) return exe;
326
+ }
327
+ }
328
+ return null;
329
+ }
330
+
331
+ /**
332
+ * Attempt to auto-install Python 3 using the system package manager.
333
+ *
334
+ * On Windows: uses winget (built-in on Windows 10+).
335
+ * On macOS: uses brew if available.
336
+ * On Linux: tries apt-get, then dnf.
337
+ *
338
+ * @returns {boolean} true if installation succeeded
339
+ */
340
+ function tryInstallPython() {
341
+ const log = (msg) => process.stdout.write(` ${msg}\n`);
342
+ log('⚠ Python 3 not found — attempting automatic installation...');
343
+
344
+ try {
345
+ if (process.platform === 'win32') {
346
+ execSync('winget install -e --id Python.Python.3.12 --accept-source-agreements --accept-package-agreements', {
347
+ stdio: 'inherit',
348
+ timeout: 120_000,
349
+ });
350
+ } else if (process.platform === 'darwin') {
351
+ execSync('brew install python3', { stdio: 'inherit', timeout: 120_000 });
352
+ } else {
353
+ // Linux — try apt-get first, then dnf
354
+ try {
355
+ execSync('sudo apt-get update -qq && sudo apt-get install -y python3', { stdio: 'inherit', timeout: 120_000 });
356
+ } catch {
357
+ execSync('sudo dnf install -y python3', { stdio: 'inherit', timeout: 120_000 });
358
+ }
359
+ }
360
+ log('✓ Python 3 installed successfully.');
361
+ return true;
362
+ } catch (err) {
363
+ log(`✗ Automatic Python installation failed: ${err.message}`);
364
+ log(' Please install Python 3 manually: https://www.python.org/downloads/');
365
+ return false;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Resolve Python executable suitable for the statusLine command.
371
+ *
372
+ * Tries to find Python 3 on the system. If not found, attempts automatic
373
+ * installation via the system package manager (winget/brew/apt/dnf).
374
+ *
375
+ * @returns {string} Python executable (full path on Windows if needed)
376
+ */
377
+ function resolvePythonForStatusline() {
378
+ // First pass — check existing installation
379
+ let python = findPython();
380
+ if (python) return python;
381
+
382
+ // Not found — try to install
383
+ if (tryInstallPython()) {
384
+ // Re-probe after installation
385
+ python = findPython();
386
+ if (python) return python;
387
+ }
388
+
389
+ // Last resort: return python3 and let it fail with a clear error
390
+ return 'python3';
391
+ }
392
+
393
+ /**
394
+ * Install the morph-spec statusline globally into ~/.claude/.
395
+ *
396
+ * Copies statusline.sh and statusline.py to ~/.claude/ and registers
397
+ * the statusLine config in ~/.claude/settings.json so it applies to
398
+ * every Claude Code session, not just morph-spec projects.
399
+ *
400
+ * Python 3 must be available as `python3` on PATH (global prerequisite).
401
+ *
402
+ * @param {string} hooksSourceDir - Path to framework/hooks/claude-code/
403
+ * @param {string} [globalClaudeDirOverride] - Override ~/.claude/ (for testing)
404
+ * @returns {Promise<{ installed: boolean, globalDir: string }>}
405
+ */
406
+ export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOverride = null) {
407
+ const globalDir = globalClaudeDirOverride || join(homedir(), '.claude');
408
+ await mkdir(globalDir, { recursive: true });
409
+
410
+ // Copy statusline scripts to ~/.claude/
411
+ const scripts = ['statusline.sh', 'statusline.py'];
412
+ for (const fname of scripts) {
413
+ const src = join(hooksSourceDir, fname);
414
+ if (existsSync(src)) {
415
+ await copyFile(src, join(globalDir, fname));
416
+ if (process.platform !== 'win32') {
417
+ try { chmodSync(join(globalDir, fname), 0o755); } catch { /* non-critical */ }
418
+ }
419
+ }
420
+ }
421
+
422
+ // Register statusLine in ~/.claude/settings.json (global user settings)
423
+ const settingsPath = join(globalDir, 'settings.json');
424
+ let settings = {};
425
+ if (existsSync(settingsPath)) {
426
+ try {
427
+ settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
428
+ } catch {
429
+ settings = {};
430
+ }
431
+ }
432
+
433
+ // On Windows, Claude Code cannot execute .sh files directly — call Python instead.
434
+ const statuslineCommand = process.platform === 'win32'
435
+ ? `"${resolvePythonForStatusline()}" "${join(globalDir, 'statusline.py').replace(/\\/g, '/')}"`
436
+ : join(globalDir, 'statusline.sh').replace(/\\/g, '/');
437
+
438
+ settings.statusLine = {
439
+ type: 'command',
440
+ command: statuslineCommand,
441
+ padding: 2
442
+ };
443
+
444
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
445
+ return { installed: true, globalDir };
446
+ }
447
+
448
+
449
+
450
+ /**
451
+ * Remove old v1 agent-teams/dispatch.js hook entries.
452
+ * @param {Object} settings
453
+ */
454
+ function removeOldDispatch(settings) {
455
+ if (!settings.hooks?.PostToolUse) return;
456
+
457
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
458
+ if (!Array.isArray(entry.hooks)) return true;
459
+ return !entry.hooks.some(h =>
460
+ h.command === OLD_DISPATCH_COMMAND ||
461
+ (typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
462
+ );
463
+ });
464
+
465
+ if (settings.hooks.PostToolUse.length === 0) {
466
+ delete settings.hooks.PostToolUse;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Remove all morph-managed hook entries.
472
+ * Uses _morph:true marker (v2.2+) with legacy path-pattern fallback.
473
+ * Preserves any user-added hooks.
474
+ * @param {Object} settings
475
+ */
476
+ function removeMorphHooks(settings) {
477
+ const morphPathPattern = /framework\/hooks\/claude-code\//;
478
+
479
+ for (const [event, entries] of Object.entries(settings.hooks)) {
480
+ if (!Array.isArray(entries)) continue;
481
+
482
+ settings.hooks[event] = entries.filter(entry => {
483
+ // Remove if explicitly marked as morph-managed
484
+ if (entry._morph === true) return false;
485
+
486
+ if (!Array.isArray(entry.hooks)) return true;
487
+ // Legacy path-pattern detection: remove if all hooks are morph command hooks
488
+ // Note: legacy path only covers command-type hooks (those with a path-matching command).
489
+ // Agent/prompt hooks without _morph marker are NOT removed by this fallback.
490
+ // This is acceptable since all morph hooks since v2.2.0 carry the _morph marker.
491
+ const nonMorphHooks = entry.hooks.filter(h =>
492
+ typeof h.command !== 'string' || !morphPathPattern.test(h.command)
493
+ );
494
+ return nonMorphHooks.length > 0;
495
+ });
496
+
497
+ if (settings.hooks[event].length === 0) {
498
+ delete settings.hooks[event];
499
+ }
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Extract hook subpath from a full command string.
505
+ * @param {string} command - e.g., 'node framework/hooks/claude-code/stop/validate-completion.js'
506
+ * @returns {string} e.g., 'stop/validate-completion.js'
507
+ */
508
+ function getHookSubpath(command) {
509
+ const match = command.match(/claude-code\/(.+)$/);
510
+ return match ? match[1] : command;
511
+ }
512
+
513
+ /**
514
+ * Get the current hooks version.
515
+ * @returns {string}
516
+ */
517
+ export function getHooksVersion() {
518
+ return HOOKS_VERSION;
519
+ }
520
+
521
+ /**
522
+ * Get the list of hook definitions (for testing/inspection).
523
+ * @returns {Array}
524
+ */
525
+ export function getHookDefinitions() {
526
+ return MORPH_HOOKS;
527
+ }