@polymorphism-tech/morph-spec 8.15.1 → 8.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/morph-spec.js +8 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +3 -3
- package/framework/hooks/README.md +3 -3
- package/framework/hooks/claude-code/core/chain.js +123 -0
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
- package/framework/hooks/claude-code/core/gate-guard.js +21 -25
- package/framework/hooks/claude-code/core/loop-detect.js +70 -64
- package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
- package/framework/hooks/claude-code/core/protect-spec-files.js +110 -103
- package/framework/hooks/claude-code/core/state-sync.js +32 -40
- package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
- package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
- package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
- package/framework/hooks/claude-code/core/validate-completion.js +147 -107
- package/framework/hooks/claude-code/core/worktree-context.js +25 -22
- package/framework/hooks/claude-code/statusline.py +26 -4
- package/framework/hooks/shared/activity-logger.js +120 -69
- package/framework/hooks/shared/claim-registry.js +61 -1
- package/framework/hooks/shared/git-paths.js +94 -0
- package/framework/hooks/shared/hook-response.js +67 -0
- package/framework/hooks/shared/state-reader.js +95 -29
- package/framework/hooks/shared/state-writer.js +16 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/observability/cost.js +137 -0
- package/src/commands/state/approve.js +30 -9
- package/src/dashboard/scanner/hooks-scanner.js +11 -2
- package/src/lib/installers/memory-installer.js +1 -1
- package/src/lib/telemetry/telemetry-reader.js +213 -0
- package/src/utils/hooks-installer.js +84 -79
- package/framework/hooks/shared/session-snapshot.js +0 -105
|
@@ -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 {
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 (
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
// Append current action
|
|
58
|
+
const sig = getCommandSignature(payload);
|
|
59
|
+
buffer.push({ sig, ts: new Date().toISOString() });
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// Trim to max size (FIFO)
|
|
62
|
+
if (buffer.length > MAX_BUFFER) {
|
|
63
|
+
buffer = buffer.slice(-MAX_BUFFER);
|
|
64
|
+
}
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
// Save updated buffer
|
|
67
|
+
writeFileSync(bufferPath, JSON.stringify(buffer), 'utf-8');
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
// Check for loop (need at least LOOP_WINDOW actions)
|
|
70
|
+
if (buffer.length < LOOP_WINDOW) return null;
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
const recent = buffer.slice(-LOOP_WINDOW);
|
|
73
|
+
const uniqueSigs = new Set(recent.map(a => a.sig));
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
if (uniqueSigs.size > MAX_UNIQUE_SIGS) return null;
|
|
73
76
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
if (!command) pass();
|
|
18
|
+
const command = payload?.tool_input?.command || '';
|
|
19
|
+
if (!command) return null;
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
const denylist = JSON.parse(readFileSync(join(__dirname, 'denylist.json'), 'utf-8'));
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
} catch {
|
|
28
|
-
|
|
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 {
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
if (!filePath) pass();
|
|
113
|
+
if (!payload) return null;
|
|
113
114
|
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
const filePath = getFilePath(payload);
|
|
116
|
+
if (!filePath) return null;
|
|
116
117
|
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
129
|
+
const featureName = extractFeatureName(filePath);
|
|
130
|
+
if (!featureName) return null;
|
|
130
131
|
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
const filename = getBasename(filePath);
|
|
133
|
+
const requiredGate = PROTECTED_SPEC_FILES[filename];
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
|
|
137
|
-
pass();
|
|
138
|
-
}
|
|
135
|
+
// Not a protected file
|
|
136
|
+
if (!requiredGate) return null;
|
|
139
137
|
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
141
|
+
return null;
|
|
174
142
|
}
|
|
175
143
|
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
`MORPH-SPEC: '${filename}'
|
|
199
|
-
`
|
|
200
|
-
|
|
201
|
-
`
|
|
202
|
-
`
|
|
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);
|