@polymorphism-tech/morph-spec 8.15.0 → 8.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +8 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -1
  7. package/framework/MORPH.md +4 -3
  8. package/framework/hooks/README.md +3 -3
  9. package/framework/hooks/claude-code/core/chain.js +123 -0
  10. package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
  11. package/framework/hooks/claude-code/core/gate-guard.js +21 -25
  12. package/framework/hooks/claude-code/core/loop-detect.js +70 -64
  13. package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
  14. package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
  15. package/framework/hooks/claude-code/core/protect-spec-files.js +110 -103
  16. package/framework/hooks/claude-code/core/state-sync.js +32 -40
  17. package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
  18. package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
  19. package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
  20. package/framework/hooks/claude-code/core/validate-completion.js +147 -107
  21. package/framework/hooks/claude-code/core/worktree-context.js +25 -22
  22. package/framework/hooks/claude-code/statusline.py +26 -4
  23. package/framework/hooks/shared/activity-logger.js +120 -69
  24. package/framework/hooks/shared/claim-registry.js +61 -1
  25. package/framework/hooks/shared/git-paths.js +94 -0
  26. package/framework/hooks/shared/hook-response.js +67 -0
  27. package/framework/hooks/shared/state-reader.js +95 -29
  28. package/framework/hooks/shared/state-writer.js +16 -0
  29. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  30. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  31. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  32. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  33. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  34. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  35. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  36. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  37. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  38. package/framework/skills/core/morph-review/SKILL.md +1 -1
  39. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  40. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  41. package/package.json +1 -1
  42. package/src/commands/observability/cost.js +137 -0
  43. package/src/commands/project/doctor.js +28 -1
  44. package/src/commands/state/approve.js +30 -9
  45. package/src/commands/state/archive.js +29 -3
  46. package/src/commands/state/delete.js +36 -2
  47. package/src/dashboard/scanner/hooks-scanner.js +11 -2
  48. package/src/lib/installers/memory-installer.js +1 -1
  49. package/src/lib/telemetry/telemetry-reader.js +213 -0
  50. package/src/utils/hooks-installer.js +84 -79
  51. package/framework/hooks/shared/session-snapshot.js +0 -105
@@ -16,9 +16,8 @@
16
16
 
17
17
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
18
18
  import { join } from 'path';
19
- import { readStdin } from '../../shared/stdin-reader.js';
20
19
  import { stateExists } from '../../shared/state-reader.js';
21
- import { injectContext, pass } from '../../shared/hook-response.js';
20
+ import { contextResult, runStandalone } from '../../shared/hook-response.js';
22
21
  import { getCommandSignature } from '../../shared/payload-utils.js';
23
22
  import { logEvent } from '../../shared/activity-logger.js';
24
23
 
@@ -27,78 +26,85 @@ const LOOP_WINDOW = 8;
27
26
  const MAX_UNIQUE_SIGS = 2;
28
27
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
29
28
 
30
- try {
31
- if (!stateExists()) pass();
32
-
33
- const payload = await readStdin();
34
- if (!payload) pass();
35
-
36
- const cwd = process.cwd();
37
- const memoryDir = join(cwd, '.morph', 'memory');
38
- const bufferPath = join(memoryDir, 'action-buffer.json');
39
- const cooldownPath = join(memoryDir, 'loop-cooldown.json');
40
-
41
- mkdirSync(memoryDir, { recursive: true });
42
-
43
- // Read existing buffer
44
- let buffer = [];
29
+ /**
30
+ * @param {object} payload - parsed PostToolUse stdin payload (any tool)
31
+ * @returns {Promise<object|null>} a hookSpecificOutput loop warning, or null
32
+ * (pass/silent).
33
+ */
34
+ export async function run(payload) {
45
35
  try {
46
- if (existsSync(bufferPath)) {
47
- buffer = JSON.parse(readFileSync(bufferPath, 'utf-8'));
48
- if (!Array.isArray(buffer)) buffer = [];
36
+ if (!stateExists()) return null;
37
+ if (!payload) return null;
38
+
39
+ const cwd = process.cwd();
40
+ const memoryDir = join(cwd, '.morph', 'memory');
41
+ const bufferPath = join(memoryDir, 'action-buffer.json');
42
+ const cooldownPath = join(memoryDir, 'loop-cooldown.json');
43
+
44
+ mkdirSync(memoryDir, { recursive: true });
45
+
46
+ // Read existing buffer
47
+ let buffer = [];
48
+ try {
49
+ if (existsSync(bufferPath)) {
50
+ buffer = JSON.parse(readFileSync(bufferPath, 'utf-8'));
51
+ if (!Array.isArray(buffer)) buffer = [];
52
+ }
53
+ } catch {
54
+ buffer = [];
49
55
  }
50
- } catch {
51
- buffer = [];
52
- }
53
56
 
54
- // Append current action
55
- const sig = getCommandSignature(payload);
56
- buffer.push({ sig, ts: new Date().toISOString() });
57
+ // Append current action
58
+ const sig = getCommandSignature(payload);
59
+ buffer.push({ sig, ts: new Date().toISOString() });
57
60
 
58
- // Trim to max size (FIFO)
59
- if (buffer.length > MAX_BUFFER) {
60
- buffer = buffer.slice(-MAX_BUFFER);
61
- }
61
+ // Trim to max size (FIFO)
62
+ if (buffer.length > MAX_BUFFER) {
63
+ buffer = buffer.slice(-MAX_BUFFER);
64
+ }
62
65
 
63
- // Save updated buffer
64
- writeFileSync(bufferPath, JSON.stringify(buffer), 'utf-8');
66
+ // Save updated buffer
67
+ writeFileSync(bufferPath, JSON.stringify(buffer), 'utf-8');
65
68
 
66
- // Check for loop (need at least LOOP_WINDOW actions)
67
- if (buffer.length < LOOP_WINDOW) pass();
69
+ // Check for loop (need at least LOOP_WINDOW actions)
70
+ if (buffer.length < LOOP_WINDOW) return null;
68
71
 
69
- const recent = buffer.slice(-LOOP_WINDOW);
70
- const uniqueSigs = new Set(recent.map(a => a.sig));
72
+ const recent = buffer.slice(-LOOP_WINDOW);
73
+ const uniqueSigs = new Set(recent.map(a => a.sig));
71
74
 
72
- if (uniqueSigs.size > MAX_UNIQUE_SIGS) pass();
75
+ if (uniqueSigs.size > MAX_UNIQUE_SIGS) return null;
73
76
 
74
- // Check cooldown
75
- try {
76
- if (existsSync(cooldownPath)) {
77
- const cooldown = JSON.parse(readFileSync(cooldownPath, 'utf-8'));
78
- const elapsed = Date.now() - new Date(cooldown.lastDetected).getTime();
79
- if (elapsed < COOLDOWN_MS) pass();
77
+ // Check cooldown
78
+ try {
79
+ if (existsSync(cooldownPath)) {
80
+ const cooldown = JSON.parse(readFileSync(cooldownPath, 'utf-8'));
81
+ const elapsed = Date.now() - new Date(cooldown.lastDetected).getTime();
82
+ if (elapsed < COOLDOWN_MS) return null;
83
+ }
84
+ } catch {
85
+ // Ignore corrupt cooldown — proceed with detection
80
86
  }
87
+
88
+ // Loop detected — set cooldown and warn
89
+ writeFileSync(cooldownPath, JSON.stringify({ lastDetected: new Date().toISOString() }), 'utf-8');
90
+
91
+ logEvent('loop_detected', { signatures: uniqueSigs.size }, process.cwd());
92
+
93
+ const sigList = [...uniqueSigs].join(', ');
94
+ return contextResult(
95
+ `⚠️ [morph-spec] LOOP DETECTED — the last ${LOOP_WINDOW} actions repeat only ${uniqueSigs.size} pattern(s): ${sigList}\n\n` +
96
+ `This usually means you're stuck. Stop and try a different approach:\n` +
97
+ ` 1. Re-read the error message carefully — what is it actually saying?\n` +
98
+ ` 2. Try a completely different fix strategy\n` +
99
+ ` 3. If the test keeps failing, check if the test itself is wrong\n` +
100
+ ` 4. If blocked, ask the user for guidance\n\n` +
101
+ `Do NOT continue the same pattern.`,
102
+ 'PostToolUse'
103
+ );
81
104
  } catch {
82
- // Ignore corrupt cooldown — proceed with detection
105
+ // Fail-open
106
+ return null;
83
107
  }
84
-
85
- // Loop detected — set cooldown and warn
86
- writeFileSync(cooldownPath, JSON.stringify({ lastDetected: new Date().toISOString() }), 'utf-8');
87
-
88
- logEvent('loop_detected', { signatures: uniqueSigs.size }, process.cwd());
89
-
90
- const sigList = [...uniqueSigs].join(', ');
91
- injectContext(
92
- `⚠️ [morph-spec] LOOP DETECTED — the last ${LOOP_WINDOW} actions repeat only ${uniqueSigs.size} pattern(s): ${sigList}\n\n` +
93
- `This usually means you're stuck. Stop and try a different approach:\n` +
94
- ` 1. Re-read the error message carefully — what is it actually saying?\n` +
95
- ` 2. Try a completely different fix strategy\n` +
96
- ` 3. If the test keeps failing, check if the test itself is wrong\n` +
97
- ` 4. If blocked, ask the user for guidance\n\n` +
98
- `Do NOT continue the same pattern.`,
99
- 'PostToolUse'
100
- );
101
- } catch {
102
- // Fail-open
103
- process.exit(0);
104
108
  }
109
+
110
+ await runStandalone(run);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * PostToolUse Hook: TypeScript typecheck with debounce
4
+ * PostToolUse Hook: TypeScript typecheck with debounce + build-dirty flag
5
5
  *
6
6
  * Event: PostToolUse | Matcher: Write|Edit
7
7
  *
@@ -9,6 +9,13 @@
9
9
  * so rapid sequential edits (e.g. multiple files in one task) don't each
10
10
  * trigger a full typecheck. Silent on success — only injects on error.
11
11
  *
12
+ * Also touches `.morph/memory/build-dirty.json` on EVERY Write/Edit of a
13
+ * source file (any extension build-check.js knows how to verify — dotnet .cs
14
+ * included, not just TS), even when the tsc check below is skipped/debounced.
15
+ * validate-completion.js (Stop hook) only runs its full build/typecheck when
16
+ * this flag is present — this is the write side of that dirty-flag gate
17
+ * (R1.2 of the performance plan).
18
+ *
12
19
  * Debounce state: .morph/memory/typecheck-debounce.json
13
20
  *
14
21
  * Fail-open: exits 0 on any error.
@@ -17,62 +24,104 @@
17
24
  import { execSync } from 'child_process';
18
25
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
19
26
  import { join } from 'path';
20
- import { readStdin } from '../../shared/stdin-reader.js';
21
- import { pass, injectContext } from '../../shared/hook-response.js';
27
+ import { contextResult, runStandalone } from '../../shared/hook-response.js';
22
28
  import { stateExists } from '../../shared/state-reader.js';
23
29
  import { resolveRootForFile } from '../../shared/payload-utils.js';
24
30
 
25
31
  const DEBOUNCE_MS = 3000;
32
+ const TSC_TIMEOUT_MS = 30000;
26
33
 
27
- try {
28
- if (!stateExists()) pass();
34
+ // Extensions build-check.js knows how to verify (dotnet .cs + the frontend
35
+ // TS/JS family) — broader than the `/\.tsx?$/` gate below on purpose: editing
36
+ // a .cs file never runs tsc here, but it DOES need the next Stop's
37
+ // `dotnet build` to run.
38
+ const SOURCE_FILE_RE = /\.(cs|ts|tsx|js|jsx|mjs|cjs|vue|razor|cshtml)$/i;
29
39
 
30
- const payload = await readStdin();
31
- const toolName = payload?.tool_name || '';
32
- const filePath = payload?.tool_input?.file_path || payload?.tool_input?.path || '';
40
+ /**
41
+ * Mark the build dirty for validate-completion.js's Stop-hook gate.
42
+ * Cheap, best-effort — never blocks, never throws.
43
+ *
44
+ * @param {string} morphRoot - the root whose `.morph/` the Stop hook itself
45
+ * reads (resolved by the `.morph` marker, NOT the tsc/package.json root —
46
+ * they can differ in a monorepo, e.g. editing `frontend/src/Foo.tsx` walks
47
+ * up to `frontend/` for tsc but `.morph/` still lives at the repo root).
48
+ */
49
+ function markBuildDirty(morphRoot) {
50
+ try {
51
+ const memDir = join(morphRoot, '.morph', 'memory');
52
+ mkdirSync(memDir, { recursive: true });
53
+ writeFileSync(join(memDir, 'build-dirty.json'), JSON.stringify({ ts: new Date().toISOString() }), 'utf-8');
54
+ } catch { /* non-blocking */ }
55
+ }
33
56
 
34
- if (!['Write', 'Edit'].includes(toolName)) pass();
35
- if (!filePath.match(/\.tsx?$/)) pass();
57
+ /**
58
+ * @param {object} payload - parsed PostToolUse stdin payload (Write|Edit)
59
+ * @returns {Promise<object|null>} a hookSpecificOutput context object on a
60
+ * typecheck failure, or null (pass/silent). Never prints, never exits —
61
+ * see runStandalone()/chain.js at the bottom.
62
+ */
63
+ export async function run(payload) {
64
+ try {
65
+ if (!stateExists()) return null;
36
66
 
37
- // Resolve the root that OWNS the edited file — when a subagent edits inside
38
- // its own git worktree (isolation: "worktree", §3b-w), tsc must run there,
39
- // not in the main working tree. Falls back to cwd for the normal case.
40
- const root = resolveRootForFile(filePath, process.cwd());
67
+ const toolName = payload?.tool_name || '';
68
+ const filePath = payload?.tool_input?.file_path || payload?.tool_input?.path || '';
41
69
 
42
- // No node_modules under the resolved root → tsc can't run; pass silently
43
- // (a fresh worktree before `morph-spec worktree link` has no node_modules).
44
- if (!existsSync(join(root, 'node_modules'))) pass();
70
+ if (!['Write', 'Edit'].includes(toolName)) return null;
45
71
 
46
- // Debounce (keyed by root): skip if a typecheck ran within the last 3s.
47
- const memDir = join(root, '.morph', 'memory');
48
- const debouncePath = join(memDir, 'typecheck-debounce.json');
72
+ // Dirty-flag: resolved against the nearest ancestor that HAS a `.morph/`
73
+ // dir — the same root validate-completion.js reads from `process.cwd()`.
74
+ // This is deliberately independent of the tsc root below: a monorepo's
75
+ // `frontend/package.json` would otherwise make the flag land under
76
+ // `frontend/.morph/`, where the Stop hook (running from the repo root)
77
+ // never looks — a silently-dark guardrail.
78
+ const morphRoot = resolveRootForFile(filePath, process.cwd(), ['.morph']);
79
+ if (SOURCE_FILE_RE.test(filePath)) markBuildDirty(morphRoot);
49
80
 
50
- try {
51
- if (existsSync(debouncePath)) {
52
- const { lastRun } = JSON.parse(readFileSync(debouncePath, 'utf-8'));
53
- if (Date.now() - new Date(lastRun).getTime() < DEBOUNCE_MS) pass();
54
- }
55
- } catch { /* corrupt file — proceed */ }
81
+ if (!filePath.match(/\.tsx?$/)) return null;
56
82
 
57
- try {
58
- mkdirSync(memDir, { recursive: true });
59
- writeFileSync(debouncePath, JSON.stringify({ lastRun: new Date().toISOString() }), 'utf-8');
60
- } catch { /* non-blocking */ }
83
+ // Resolve the root that OWNS the edited file — when a subagent edits inside
84
+ // its own git worktree (isolation: "worktree", §3b-w), tsc must run there,
85
+ // not in the main working tree. Falls back to cwd for the normal case.
86
+ const root = resolveRootForFile(filePath, process.cwd());
61
87
 
62
- try {
63
- execSync('npx tsc --noEmit', { cwd: root, stdio: 'pipe', timeout: 30000 });
64
- pass();
65
- } catch (err) {
66
- const stderr = err.stderr?.toString() || '';
67
- const stdout = err.stdout?.toString() || '';
68
- const output = (stderr + stdout).trim();
69
- const fileName = filePath.split(/[\\/]/).pop();
70
- const relevant = output.split('\n')
71
- .filter(l => l.includes(fileName) || l.includes('error TS'))
72
- .slice(0, 10)
73
- .join('\n');
74
- injectContext(`TypeScript errors after editing ${fileName}:\n${relevant || output.split('\n').slice(0, 10).join('\n')}`, 'PostToolUse');
88
+ // No node_modules under the resolved root → tsc can't run; pass silently
89
+ // (a fresh worktree before `morph-spec worktree link` has no node_modules).
90
+ if (!existsSync(join(root, 'node_modules'))) return null;
91
+
92
+ // Debounce (keyed by root): skip if a typecheck ran within the last 3s.
93
+ const memDir = join(root, '.morph', 'memory');
94
+ const debouncePath = join(memDir, 'typecheck-debounce.json');
95
+
96
+ try {
97
+ if (existsSync(debouncePath)) {
98
+ const { lastRun } = JSON.parse(readFileSync(debouncePath, 'utf-8'));
99
+ if (Date.now() - new Date(lastRun).getTime() < DEBOUNCE_MS) return null;
100
+ }
101
+ } catch { /* corrupt file — proceed */ }
102
+
103
+ try {
104
+ mkdirSync(memDir, { recursive: true });
105
+ writeFileSync(debouncePath, JSON.stringify({ lastRun: new Date().toISOString() }), 'utf-8');
106
+ } catch { /* non-blocking */ }
107
+
108
+ try {
109
+ execSync('npx tsc --noEmit', { cwd: root, stdio: 'pipe', timeout: TSC_TIMEOUT_MS });
110
+ return null;
111
+ } catch (err) {
112
+ const stderr = err.stderr?.toString() || '';
113
+ const stdout = err.stdout?.toString() || '';
114
+ const output = (stderr + stdout).trim();
115
+ const fileName = filePath.split(/[\\/]/).pop();
116
+ const relevant = output.split('\n')
117
+ .filter(l => l.includes(fileName) || l.includes('error TS'))
118
+ .slice(0, 10)
119
+ .join('\n');
120
+ return contextResult(`TypeScript errors after editing ${fileName}:\n${relevant || output.split('\n').slice(0, 10).join('\n')}`, 'PostToolUse');
121
+ }
122
+ } catch {
123
+ return null;
75
124
  }
76
- } catch {
77
- pass();
78
125
  }
126
+
127
+ await runStandalone(run);
@@ -2,28 +2,34 @@
2
2
  import { readFileSync } from 'fs';
3
3
  import { join, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
- import { readStdin } from '../../shared/stdin-reader.js';
6
- import { pass, block } from '../../shared/hook-response.js';
5
+ import { blockResult, runStandalone } from '../../shared/hook-response.js';
7
6
  import { stateExists } from '../../shared/state-reader.js';
8
7
 
9
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
9
 
11
- try {
12
- if (!stateExists()) pass();
10
+ /**
11
+ * @param {object} payload - parsed PreToolUse stdin payload (Bash matcher)
12
+ * @returns {Promise<object|null>} `{decision:'block',reason}` or null (pass).
13
+ */
14
+ export async function run(payload) {
15
+ try {
16
+ if (!stateExists()) return null;
13
17
 
14
- const payload = await readStdin();
15
- const command = payload?.tool_input?.command || '';
16
- if (!command) pass();
18
+ const command = payload?.tool_input?.command || '';
19
+ if (!command) return null;
17
20
 
18
- const denylist = JSON.parse(readFileSync(join(__dirname, 'denylist.json'), 'utf-8'));
21
+ const denylist = JSON.parse(readFileSync(join(__dirname, 'denylist.json'), 'utf-8'));
19
22
 
20
- for (const pattern of denylist.patterns) {
21
- if (command.includes(pattern)) {
22
- block(`Comando bloqueado pela denylist: padrão '${pattern}' detectado.`);
23
+ for (const pattern of denylist.patterns) {
24
+ if (command.includes(pattern)) {
25
+ return blockResult(`Comando bloqueado pela denylist: padrão '${pattern}' detectado.`);
26
+ }
23
27
  }
24
- }
25
28
 
26
- pass();
27
- } catch {
28
- pass();
29
+ return null;
30
+ } catch {
31
+ return null;
32
+ }
29
33
  }
34
+
35
+ await runStandalone(run);
@@ -24,7 +24,6 @@
24
24
  */
25
25
 
26
26
  import { readFileSync } from 'fs';
27
- import { readStdin } from '../../shared/stdin-reader.js';
28
27
  import { stateExists, isGateApproved } from '../../shared/state-reader.js';
29
28
  import {
30
29
  isFeaturePath,
@@ -33,7 +32,7 @@ import {
33
32
  PROTECTED_SPEC_FILES,
34
33
  MUTABLE_SPEC_FIELDS
35
34
  } from '../../shared/phase-utils.js';
36
- import { block, pass } from '../../shared/hook-response.js';
35
+ import { blockResult, runStandalone } from '../../shared/hook-response.js';
37
36
  import { logHookActivity } from '../../shared/activity-logger.js';
38
37
  import { getFilePath, getToolName, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
39
38
  import { validateTasksDoc, outputsDivergenceViolations } from '../../shared/tasks-validator.js';
@@ -98,124 +97,132 @@ function diffPlanContract(oldDoc, newDoc, mutableFields) {
98
97
  return violations;
99
98
  }
100
99
 
101
- try {
102
- // Amend mode: bypass spec protection for legitimate in-implementation corrections
103
- if (process.env.MORPH_AMEND_PHASE) {
104
- console.error(`[morph-spec] AMEND MODE: bypassing spec protection for phase '${process.env.MORPH_AMEND_PHASE}'`);
105
- pass();
106
- }
107
-
108
- const payload = await readStdin();
109
- if (!payload) pass();
100
+ /**
101
+ * @param {object} payload - parsed PreToolUse stdin payload
102
+ * @returns {Promise<object|null>} `{decision:'block',reason}` or null (pass).
103
+ * Never prints, never exits — see runStandalone()/chain.js at the bottom.
104
+ */
105
+ export async function run(payload) {
106
+ try {
107
+ // Amend mode: bypass spec protection for legitimate in-implementation corrections
108
+ if (process.env.MORPH_AMEND_PHASE) {
109
+ console.error(`[morph-spec] AMEND MODE: bypassing spec protection for phase '${process.env.MORPH_AMEND_PHASE}'`);
110
+ return null;
111
+ }
110
112
 
111
- const filePath = getFilePath(payload);
112
- if (!filePath) pass();
113
+ if (!payload) return null;
113
114
 
114
- // Only check files inside .morph/features/
115
- if (!isFeaturePath(filePath)) pass();
115
+ const filePath = getFilePath(payload);
116
+ if (!filePath) return null;
116
117
 
117
- // The gate that locks this file is recorded in the feature.json of the tree
118
- // that OWNS it. Asking the session's tree instead made this guard go DARK in
119
- // a secondary worktree: the root had no approved gate, so a post-Gate-2
120
- // spec.md rewrite sailed through. Same defect as enforce-phase-writes,
121
- // opposite direction — there it over-blocked, here it under-protected.
122
- const root = resolveMorphRoot(filePath, process.cwd());
123
- if (!stateExists(root)) pass();
118
+ // Only check files inside .morph/features/
119
+ if (!isFeaturePath(filePath)) return null;
124
120
 
125
- const featureName = extractFeatureName(filePath);
126
- if (!featureName) pass();
121
+ // The gate that locks this file is recorded in the feature.json of the tree
122
+ // that OWNS it. Asking the session's tree instead made this guard go DARK in
123
+ // a secondary worktree: the root had no approved gate, so a post-Gate-2
124
+ // spec.md rewrite sailed through. Same defect as enforce-phase-writes,
125
+ // opposite direction — there it over-blocked, here it under-protected.
126
+ const root = resolveMorphRoot(filePath, process.cwd());
127
+ if (!stateExists(root)) return null;
127
128
 
128
- const filename = getBasename(filePath);
129
- const requiredGate = PROTECTED_SPEC_FILES[filename];
129
+ const featureName = extractFeatureName(filePath);
130
+ if (!featureName) return null;
130
131
 
131
- // Not a protected file
132
- if (!requiredGate) pass();
132
+ const filename = getBasename(filePath);
133
+ const requiredGate = PROTECTED_SPEC_FILES[filename];
133
134
 
134
- // Gate not yet approved — allow editing
135
- if (!isGateApproved(featureName, requiredGate, root)) {
136
- logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
137
- pass();
138
- }
135
+ // Not a protected file
136
+ if (!requiredGate) return null;
139
137
 
140
- // ── Gate approved — file is locked ──────────────────────────────────────
141
- const mutableFields = MUTABLE_SPEC_FIELDS[filename];
142
-
143
- // Files with mutable progress fields (e.g. tasks.json): allow a full Write
144
- // that touches only those fields. Anything else stays blocked.
145
- if (mutableFields) {
146
- const toolName = getToolName(payload);
147
-
148
- // Build the proposed document from either a full Write or a targeted Edit,
149
- // then semantic-diff it against the current file on disk. Any failure to
150
- // read / parse / apply → fail-open (pass), never a false block.
151
- const absPath = resolveTargetPath(filePath, root);
152
- let oldDoc, newDoc;
153
- try {
154
- const original = readFileSync(absPath, 'utf-8');
155
- oldDoc = JSON.parse(original);
156
-
157
- if (toolName === 'Edit') {
158
- // Simulate the edit: apply old_string → new_string to the current file.
159
- const oldStr = payload?.tool_input?.old_string;
160
- const newStr = payload?.tool_input?.new_string;
161
- if (typeof oldStr !== 'string' || typeof newStr !== 'string') throw new Error('no edit strings');
162
- const edited = payload?.tool_input?.replace_all
163
- ? original.split(oldStr).join(newStr)
164
- : original.replace(oldStr, newStr);
165
- newDoc = JSON.parse(edited);
166
- } else {
167
- const newContent = payload?.tool_input?.content;
168
- if (typeof newContent !== 'string') throw new Error('no content');
169
- newDoc = JSON.parse(newContent);
170
- }
171
- } catch {
138
+ // Gate not yet approved — allow editing
139
+ if (!isGateApproved(featureName, requiredGate, root)) {
172
140
  logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
173
- pass();
141
+ return null;
174
142
  }
175
143
 
176
- const violations = diffPlanContract(oldDoc, newDoc, mutableFields);
144
+ // ── Gate approved — file is locked ──────────────────────────────────────
145
+ const mutableFields = MUTABLE_SPEC_FIELDS[filename];
146
+
147
+ // Files with mutable progress fields (e.g. tasks.json): allow a full Write
148
+ // that touches only those fields. Anything else stays blocked.
149
+ if (mutableFields) {
150
+ const toolName = getToolName(payload);
151
+
152
+ // Build the proposed document from either a full Write or a targeted Edit,
153
+ // then semantic-diff it against the current file on disk. Any failure to
154
+ // read / parse / apply → fail-open (pass), never a false block.
155
+ const absPath = resolveTargetPath(filePath, root);
156
+ let oldDoc, newDoc;
157
+ try {
158
+ const original = readFileSync(absPath, 'utf-8');
159
+ oldDoc = JSON.parse(original);
160
+
161
+ if (toolName === 'Edit') {
162
+ // Simulate the edit: apply old_string → new_string to the current file.
163
+ const oldStr = payload?.tool_input?.old_string;
164
+ const newStr = payload?.tool_input?.new_string;
165
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') throw new Error('no edit strings');
166
+ const edited = payload?.tool_input?.replace_all
167
+ ? original.split(oldStr).join(newStr)
168
+ : original.replace(oldStr, newStr);
169
+ newDoc = JSON.parse(edited);
170
+ } else {
171
+ const newContent = payload?.tool_input?.content;
172
+ if (typeof newContent !== 'string') throw new Error('no content');
173
+ newDoc = JSON.parse(newContent);
174
+ }
175
+ } catch {
176
+ logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
177
+ return null;
178
+ }
177
179
 
178
- // Schema check on the proposed doc: even mutable-field writes must keep a
179
- // valid status enum (a leaf agent writing status:"completed" was the
180
- // production failure this closes). Only NEWLY introduced schema errors
181
- // block — a pre-existing violation must not deadlock progress writes.
182
- const preExisting = new Set(validateTasksDoc(oldDoc).errors);
183
- violations.push(...validateTasksDoc(newDoc).errors.filter(e => !preExisting.has(e)));
180
+ const violations = diffPlanContract(oldDoc, newDoc, mutableFields);
184
181
 
185
- // Planned-vs-real outputs diff: changing `outputs` is allowed, but the
186
- // divergence must be explained in `notes` in the same edit.
187
- violations.push(...outputsDivergenceViolations(oldDoc, newDoc));
182
+ // Schema check on the proposed doc: even mutable-field writes must keep a
183
+ // valid status enum (a leaf agent writing status:"completed" was the
184
+ // production failure this closes). Only NEWLY introduced schema errors
185
+ // block — a pre-existing violation must not deadlock progress writes.
186
+ const preExisting = new Set(validateTasksDoc(oldDoc).errors);
187
+ violations.push(...validateTasksDoc(newDoc).errors.filter(e => !preExisting.has(e)));
188
188
 
189
- if (violations.length === 0) {
190
- // Only mutable progress fields changed — a legitimate implement-phase
191
- // update via Write OR Edit.
192
- logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
193
- pass();
189
+ // Planned-vs-real outputs diff: changing `outputs` is allowed, but the
190
+ // divergence must be explained in `notes` in the same edit.
191
+ violations.push(...outputsDivergenceViolations(oldDoc, newDoc));
192
+
193
+ if (violations.length === 0) {
194
+ // Only mutable progress fields changed — a legitimate implement-phase
195
+ // update via Write OR Edit.
196
+ logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
197
+ return null;
198
+ }
199
+
200
+ logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
201
+ return blockResult(
202
+ `MORPH-SPEC: '${filename}' write rejected — the '${requiredGate}' gate has been approved.\n` +
203
+ `This ${toolName} violates the plan contract or the tasks schema:\n` +
204
+ violations.map(v => ` • ${v}`).join('\n') + '\n\n' +
205
+ `During the implement phase only these fields may change: ${mutableFields.join(', ')}\n` +
206
+ `(status must stay in the schema enum, and outputs that diverge from the plan\n` +
207
+ `require an explanatory 'notes' in the same edit).\n` +
208
+ `To change the plan itself, ask the user to revoke the '${requiredGate}' gate,\n` +
209
+ `then re-present it for approval.`
210
+ );
194
211
  }
195
212
 
213
+ // Fully-locked file (spec.md, mandate.md, design files): blanket block.
196
214
  logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
197
- block(
198
- `MORPH-SPEC: '${filename}' write rejected — the '${requiredGate}' gate has been approved.\n` +
199
- `This ${toolName} violates the plan contract or the tasks schema:\n` +
200
- violations.map(v => ` • ${v}`).join('\n') + '\n\n' +
201
- `During the implement phase only these fields may change: ${mutableFields.join(', ')}\n` +
202
- `(status must stay in the schema enum, and outputs that diverge from the plan\n` +
203
- `require an explanatory 'notes' in the same edit).\n` +
204
- `To change the plan itself, ask the user to revoke the '${requiredGate}' gate,\n` +
205
- `then re-present it for approval.`
215
+ return blockResult(
216
+ `MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
217
+ `Editing approved specs breaks the spec contract.\n\n` +
218
+ `If changes are truly needed, ask the user to revoke the gate approval\n` +
219
+ `in .morph/state.json (set approvals.${requiredGate} to false), make\n` +
220
+ `your edits, then re-present the gate for approval.`
206
221
  );
222
+ } catch {
223
+ // Fail-open
224
+ return null;
207
225
  }
208
-
209
- // Fully-locked file (spec.md, mandate.md, design files): blanket block.
210
- logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
211
- block(
212
- `MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
213
- `Editing approved specs breaks the spec contract.\n\n` +
214
- `If changes are truly needed, ask the user to revoke the gate approval\n` +
215
- `in .morph/state.json (set approvals.${requiredGate} to false), make\n` +
216
- `your edits, then re-present the gate for approval.`
217
- );
218
- } catch {
219
- // Fail-open
220
- process.exit(0);
221
226
  }
227
+
228
+ await runStandalone(run);