@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.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +19 -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 +10 -4
  7. package/framework/MORPH.md +4 -4
  8. package/framework/evals/rubrics/condensed.md +49 -0
  9. package/framework/hooks/README.md +3 -3
  10. package/framework/hooks/claude-code/core/chain.js +123 -0
  11. package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
  12. package/framework/hooks/claude-code/core/gate-guard.js +21 -25
  13. package/framework/hooks/claude-code/core/loop-detect.js +70 -64
  14. package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
  15. package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
  16. package/framework/hooks/claude-code/core/protect-spec-files.js +119 -103
  17. package/framework/hooks/claude-code/core/state-sync.js +32 -40
  18. package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
  19. package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
  20. package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
  21. package/framework/hooks/claude-code/core/validate-completion.js +147 -107
  22. package/framework/hooks/claude-code/core/worktree-context.js +25 -22
  23. package/framework/hooks/claude-code/statusline.py +26 -4
  24. package/framework/hooks/shared/activity-logger.js +120 -69
  25. package/framework/hooks/shared/claim-registry.js +61 -1
  26. package/framework/hooks/shared/git-paths.js +94 -0
  27. package/framework/hooks/shared/hook-response.js +67 -0
  28. package/framework/hooks/shared/state-reader.js +95 -29
  29. package/framework/hooks/shared/state-writer.js +16 -0
  30. package/framework/rules/morph-task-tracking.md +4 -2
  31. package/framework/schemas/tasks.schema.json +10 -0
  32. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  33. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  34. package/framework/skills/core/morph-apply/SKILL.md +40 -28
  35. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  36. package/framework/skills/core/morph-eval/SKILL.md +16 -13
  37. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  38. package/framework/skills/core/morph-implement/SKILL.md +26 -25
  39. package/framework/skills/core/morph-plan/SKILL.md +14 -3
  40. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  41. package/framework/skills/core/morph-review/SKILL.md +1 -1
  42. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  43. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  44. package/package.json +2 -2
  45. package/src/commands/observability/cost.js +137 -0
  46. package/src/commands/project/standards.js +220 -0
  47. package/src/commands/state/approve.js +30 -9
  48. package/src/dashboard/scanner/hooks-scanner.js +11 -2
  49. package/src/lib/installers/memory-installer.js +1 -1
  50. package/src/lib/tasks/tasks-json-parser.js +26 -0
  51. package/src/lib/telemetry/telemetry-reader.js +213 -0
  52. package/src/utils/hooks-installer.js +84 -79
  53. 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 { injectContext, pass } from '../../shared/hook-response.js';
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 injectContext(
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 pass();
124
+ return null;
134
125
  }
135
- return pass();
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 pass();
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 pass(); // fail-open
149
+ if (!stateExists(cwd)) return null; // fail-open
159
150
  const state = loadState(cwd);
160
- if (!state) return pass();
151
+ if (!state) return null;
161
152
  const feature = featureFromStatePath(filePath) || getActiveFeature(cwd)?.name;
162
- if (!feature) return pass();
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 injectContext(
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 pass();
177
+ return null;
187
178
  }
188
179
 
189
- function main() {
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 = readStdin();
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 pass();
197
+ if (!isGateStateFile(input.tool_input?.file_path)) return null;
202
198
  } else if (toolName !== 'AskUserQuestion') {
203
- return pass();
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 pass();
208
+ return null;
213
209
  }
214
210
 
215
- main();
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 { 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);