@polymorphism-tech/morph-spec 8.15.1 → 8.17.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 +19 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +10 -4
- package/framework/MORPH.md +4 -4
- package/framework/evals/rubrics/condensed.md +49 -0
- 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 +119 -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/rules/morph-task-tracking.md +4 -2
- package/framework/schemas/tasks.schema.json +10 -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 +40 -28
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +16 -13
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +26 -25
- package/framework/skills/core/morph-plan/SKILL.md +14 -3
- 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 +2 -2
- package/src/commands/observability/cost.js +137 -0
- package/src/commands/project/standards.js +220 -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/tasks/tasks-json-parser.js +26 -0
- 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
|
@@ -24,21 +24,12 @@
|
|
|
24
24
|
* No persisted decision, non-gate action, or any error → silence.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { readFileSync } from 'fs';
|
|
28
27
|
import { loadState, stateExists, getActiveFeature } from '../../shared/state-reader.js';
|
|
29
|
-
import {
|
|
28
|
+
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
30
29
|
import { logHookActivity } from '../../shared/activity-logger.js';
|
|
31
30
|
import { resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
|
|
32
31
|
import { locateFeatureForQuestion } from '../../shared/feature-locator.js';
|
|
33
32
|
|
|
34
|
-
function readStdin() {
|
|
35
|
-
try {
|
|
36
|
-
return JSON.parse(readFileSync(0, 'utf8') || '{}');
|
|
37
|
-
} catch {
|
|
38
|
-
return {};
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
33
|
function projectDir() {
|
|
43
34
|
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
44
35
|
}
|
|
@@ -118,7 +109,7 @@ function checkAskUserQuestion(input, sessionRoot) {
|
|
|
118
109
|
if (!decision) continue; // no persisted decision → nothing to verify
|
|
119
110
|
if (decision.pause === false) {
|
|
120
111
|
logHookActivity('gate-guard', 'PostToolUse', 'divergent', cwd);
|
|
121
|
-
return
|
|
112
|
+
return contextResult(
|
|
122
113
|
`⚠️ [morph-spec] GATE OVER-PAUSE — você apresentou o Gate "${gate}" via AskUserQuestion, ` +
|
|
123
114
|
`mas a decisão registrada em state.json (gateDecisions.${gate}) era pause:false ` +
|
|
124
115
|
`(trust:${decision.trust ?? 'auto'}, nenhum sinal de risco). ` +
|
|
@@ -130,9 +121,9 @@ function checkAskUserQuestion(input, sessionRoot) {
|
|
|
130
121
|
}
|
|
131
122
|
// The decision said pause:true and the LLM did pause — coherent.
|
|
132
123
|
logHookActivity('gate-guard', 'PostToolUse', 'coherent', cwd);
|
|
133
|
-
return
|
|
124
|
+
return null;
|
|
134
125
|
}
|
|
135
|
-
return
|
|
126
|
+
return null;
|
|
136
127
|
}
|
|
137
128
|
|
|
138
129
|
/** True for edits to .morph/state.json (legacy) or a per-feature feature.json (v5). */
|
|
@@ -149,17 +140,17 @@ function isGateStateFile(filePath) {
|
|
|
149
140
|
*/
|
|
150
141
|
function checkStateEdit(input, sessionRoot) {
|
|
151
142
|
const filePath = input.tool_input?.file_path;
|
|
152
|
-
if (!isGateStateFile(filePath)) return
|
|
143
|
+
if (!isGateStateFile(filePath)) return null;
|
|
153
144
|
|
|
154
145
|
// The edited file names its own tree — and, for the v5 per-feature
|
|
155
146
|
// feature.json, its own feature. Only the legacy `.morph/state.json` shape
|
|
156
147
|
// still needs a most-recently-updated fallback within that tree.
|
|
157
148
|
const cwd = rootOfGateStateFile(filePath, sessionRoot);
|
|
158
|
-
if (!stateExists(cwd)) return
|
|
149
|
+
if (!stateExists(cwd)) return null; // fail-open
|
|
159
150
|
const state = loadState(cwd);
|
|
160
|
-
if (!state) return
|
|
151
|
+
if (!state) return null;
|
|
161
152
|
const feature = featureFromStatePath(filePath) || getActiveFeature(cwd)?.name;
|
|
162
|
-
if (!feature) return
|
|
153
|
+
if (!feature) return null;
|
|
163
154
|
|
|
164
155
|
let checked = false;
|
|
165
156
|
const gates = state.features?.[feature]?.approvalGates ?? {};
|
|
@@ -171,7 +162,7 @@ function checkStateEdit(input, sessionRoot) {
|
|
|
171
162
|
if (!decision) continue;
|
|
172
163
|
if (decision.pause === true) {
|
|
173
164
|
logHookActivity('gate-guard', 'PostToolUse', 'divergent', cwd);
|
|
174
|
-
return
|
|
165
|
+
return contextResult(
|
|
175
166
|
`⚠️ [morph-spec] GATE SKIPPED PAUSE — o Gate "${gate}" foi marcado approvedBy:"auto", ` +
|
|
176
167
|
`mas a decisão registrada (gateDecisions.${gate}) era pause:true ` +
|
|
177
168
|
`(sinal: ${decision.reason || 'risco detectado'}). ` +
|
|
@@ -183,24 +174,29 @@ function checkStateEdit(input, sessionRoot) {
|
|
|
183
174
|
checked = true;
|
|
184
175
|
}
|
|
185
176
|
if (checked) logHookActivity('gate-guard', 'PostToolUse', 'coherent', cwd);
|
|
186
|
-
return
|
|
177
|
+
return null;
|
|
187
178
|
}
|
|
188
179
|
|
|
189
|
-
|
|
180
|
+
/**
|
|
181
|
+
* @param {object} payload - parsed PostToolUse stdin payload (Write|Edit|AskUserQuestion)
|
|
182
|
+
* @returns {Promise<object|null>} a hookSpecificOutput warning on a
|
|
183
|
+
* trust-mode divergence, or null (pass/silent).
|
|
184
|
+
*/
|
|
185
|
+
export async function run(payload) {
|
|
190
186
|
// Starting point only — each branch resolves the tree it actually inspects
|
|
191
187
|
// (from the edited path, or from the feature the question names).
|
|
192
188
|
const sessionRoot = projectDir();
|
|
193
189
|
|
|
194
|
-
const input =
|
|
190
|
+
const input = payload || {};
|
|
195
191
|
const toolName = input.tool_name;
|
|
196
192
|
|
|
197
193
|
// Short-circuit before loading state: a Write/Edit only matters if it
|
|
198
194
|
// touched gate state (state.json legacy, or the v5 per-feature
|
|
199
195
|
// feature.json). Skip the disk read for unrelated edits.
|
|
200
196
|
if (toolName === 'Edit' || toolName === 'Write') {
|
|
201
|
-
if (!isGateStateFile(input.tool_input?.file_path)) return
|
|
197
|
+
if (!isGateStateFile(input.tool_input?.file_path)) return null;
|
|
202
198
|
} else if (toolName !== 'AskUserQuestion') {
|
|
203
|
-
return
|
|
199
|
+
return null;
|
|
204
200
|
}
|
|
205
201
|
|
|
206
202
|
try {
|
|
@@ -209,7 +205,7 @@ function main() {
|
|
|
209
205
|
} catch {
|
|
210
206
|
// fail-open
|
|
211
207
|
}
|
|
212
|
-
return
|
|
208
|
+
return null;
|
|
213
209
|
}
|
|
214
210
|
|
|
215
|
-
|
|
211
|
+
await runStandalone(run);
|
|
@@ -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);
|