@pcircle/memesh 2.9.0 → 2.9.2

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.
@@ -0,0 +1,462 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PreToolUse Hook - Modular Handler Architecture
5
+ *
6
+ * Triggered before each tool execution in Claude Code.
7
+ *
8
+ * Handlers (each returns partial response or null):
9
+ * 1. codeReviewHandler — git commit → review reminder
10
+ * 2. routingHandler — Task → model/background selection
11
+ * 3. planningHandler — Task(Plan)/EnterPlanMode → SDD+BDD template
12
+ * 4. dryRunGateHandler — Task → untested code warning
13
+ *
14
+ * Response Merger combines all handler outputs into a single JSON response:
15
+ * - updatedInput: deep-merged
16
+ * - additionalContext: concatenated
17
+ * - permissionDecision: most-restrictive-wins
18
+ */
19
+
20
+ import {
21
+ HOME_DIR,
22
+ STATE_DIR,
23
+ readJSONFile,
24
+ readStdin,
25
+ logError,
26
+ } from './hook-utils.js';
27
+ import fs from 'fs';
28
+ import path from 'path';
29
+ import { fileURLToPath } from 'url';
30
+
31
+ // ============================================================================
32
+ // Constants
33
+ // ============================================================================
34
+
35
+ const CURRENT_SESSION_FILE = path.join(STATE_DIR, 'current-session.json');
36
+ const ROUTING_CONFIG_FILE = path.join(HOME_DIR, '.memesh', 'routing-config.json');
37
+ const ROUTING_AUDIT_LOG = path.join(HOME_DIR, '.memesh', 'routing-audit.log');
38
+ const PLANNING_TEMPLATE_FILE = path.join(
39
+ path.dirname(fileURLToPath(import.meta.url)),
40
+ 'templates',
41
+ 'planning-template.md'
42
+ );
43
+
44
+ // ============================================================================
45
+ // Response Merger
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Deep-merge two objects (shallow for top-level, recursive for nested).
50
+ * Later values override earlier ones.
51
+ */
52
+ function deepMerge(target, source) {
53
+ if (!source) return target;
54
+ if (!target) return source;
55
+
56
+ const result = { ...target };
57
+ for (const key of Object.keys(source)) {
58
+ if (
59
+ typeof result[key] === 'object' && result[key] !== null &&
60
+ typeof source[key] === 'object' && source[key] !== null &&
61
+ !Array.isArray(result[key])
62
+ ) {
63
+ result[key] = deepMerge(result[key], source[key]);
64
+ } else {
65
+ result[key] = source[key];
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * Get the most restrictive permission decision.
73
+ * Priority: deny > ask > allow > undefined
74
+ */
75
+ function mostRestrictive(decisions) {
76
+ const priority = { deny: 3, ask: 2, allow: 1 };
77
+ let result = undefined;
78
+ let maxPriority = 0;
79
+
80
+ for (const d of decisions) {
81
+ if (d && priority[d] > maxPriority) {
82
+ maxPriority = priority[d];
83
+ result = d;
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+
89
+ /**
90
+ * Merge multiple handler responses into a single hook output.
91
+ * @param {Array<Object|null>} responses - Handler responses
92
+ * @returns {Object|null} Merged response or null if all handlers returned null
93
+ */
94
+ function mergeResponses(responses) {
95
+ const valid = responses.filter(Boolean);
96
+ if (valid.length === 0) return null;
97
+
98
+ let mergedInput = undefined;
99
+ const contextParts = [];
100
+ const decisions = [];
101
+
102
+ for (const r of valid) {
103
+ if (r.updatedInput) {
104
+ mergedInput = deepMerge(mergedInput, r.updatedInput);
105
+ }
106
+ if (r.additionalContext) {
107
+ contextParts.push(r.additionalContext);
108
+ }
109
+ if (r.permissionDecision) {
110
+ decisions.push(r.permissionDecision);
111
+ }
112
+ }
113
+
114
+ const merged = {};
115
+ if (mergedInput) merged.updatedInput = mergedInput;
116
+ if (contextParts.length > 0) merged.additionalContext = contextParts.join('\n\n');
117
+
118
+ const decision = mostRestrictive(decisions);
119
+ if (decision) merged.permissionDecision = decision;
120
+
121
+ return Object.keys(merged).length > 0 ? merged : null;
122
+ }
123
+
124
+ // ============================================================================
125
+ // Routing Config
126
+ // ============================================================================
127
+
128
+ /**
129
+ * Load routing config with fallback defaults.
130
+ * Creates default config if file doesn't exist.
131
+ */
132
+ function loadRoutingConfig() {
133
+ const defaults = {
134
+ version: 1,
135
+ modelRouting: {
136
+ rules: [
137
+ { subagentType: 'Explore', model: 'haiku', reason: 'Fast codebase search' },
138
+ ],
139
+ default: null,
140
+ },
141
+ backgroundRules: [
142
+ { subagentType: 'Explore', forceBackground: false },
143
+ ],
144
+ planningEnforcement: {
145
+ enabled: true,
146
+ triggerSubagents: ['Plan'],
147
+ triggerEnterPlanMode: true,
148
+ },
149
+ dryRunGate: {
150
+ enabled: true,
151
+ skipSubagents: ['Explore', 'Plan', 'claude-code-guide'],
152
+ },
153
+ auditLog: true,
154
+ };
155
+
156
+ try {
157
+ if (fs.existsSync(ROUTING_CONFIG_FILE)) {
158
+ const config = JSON.parse(fs.readFileSync(ROUTING_CONFIG_FILE, 'utf-8'));
159
+ return { ...defaults, ...config };
160
+ }
161
+ } catch (error) {
162
+ logError('loadRoutingConfig', error);
163
+ }
164
+
165
+ // Create default config on first run
166
+ try {
167
+ const dir = path.dirname(ROUTING_CONFIG_FILE);
168
+ if (!fs.existsSync(dir)) {
169
+ fs.mkdirSync(dir, { recursive: true });
170
+ }
171
+ fs.writeFileSync(ROUTING_CONFIG_FILE, JSON.stringify(defaults, null, 2), 'utf-8');
172
+ } catch {
173
+ // Non-critical — works with in-memory defaults
174
+ }
175
+
176
+ return defaults;
177
+ }
178
+
179
+ // ============================================================================
180
+ // Audit Log
181
+ // ============================================================================
182
+
183
+ /**
184
+ * Append an entry to the routing audit log.
185
+ * @param {string} entry - Log entry
186
+ * @param {Object} config - Routing config
187
+ */
188
+ function auditLog(entry, config) {
189
+ if (!config.auditLog) return;
190
+
191
+ try {
192
+ const timestamp = new Date().toISOString();
193
+ const line = `[${timestamp}] ${entry}\n`;
194
+ fs.appendFileSync(ROUTING_AUDIT_LOG, line);
195
+ } catch {
196
+ // Non-critical
197
+ }
198
+ }
199
+
200
+ // ============================================================================
201
+ // Handler 1: Code Review (existing behavior)
202
+ // ============================================================================
203
+
204
+ function codeReviewHandler(toolName, toolInput, _session) {
205
+ // Only applies to git commit commands
206
+ if (toolName !== 'Bash') return null;
207
+
208
+ const cmd = toolInput?.command || '';
209
+ if (!/git\s+commit\s/.test(cmd) || cmd.includes('--amend')) return null;
210
+
211
+ // Check if code review was done this session
212
+ const session = readJSONFile(CURRENT_SESSION_FILE, {});
213
+ if (session.codeReviewDone === true) return null;
214
+
215
+ return {
216
+ additionalContext: [
217
+ '<user-prompt-submit-hook>',
218
+ 'PRE-COMMIT REVIEW REMINDER:',
219
+ 'No comprehensive code review was detected in this session.',
220
+ 'Before committing significant changes, run: @comprehensive-code-review',
221
+ '',
222
+ 'The review checks for:',
223
+ '- Ripple Map: unsynchronized cross-file changes',
224
+ '- Reality Check: phantom imports, ghost methods, schema drift',
225
+ '- Cross-boundary Sync: type parity, client parity, route-SDK match',
226
+ '- Security, concurrency, error handling, and 7 other dimensions',
227
+ '',
228
+ 'Skip only for trivial changes (typo fixes, formatting, comments).',
229
+ '</user-prompt-submit-hook>',
230
+ ].join('\n'),
231
+ };
232
+ }
233
+
234
+ // ============================================================================
235
+ // Handler 2: Model Routing
236
+ // ============================================================================
237
+
238
+ function routingHandler(toolName, toolInput, _session, config) {
239
+ if (toolName !== 'Task') return null;
240
+
241
+ const subagentType = toolInput?.subagent_type || '';
242
+ if (!subagentType) return null;
243
+
244
+ const result = { updatedInput: {} };
245
+ let applied = false;
246
+
247
+ // Model routing
248
+ const modelRules = config.modelRouting?.rules || [];
249
+ for (const rule of modelRules) {
250
+ if (!rule.subagentType) continue;
251
+ if (subagentType.toLowerCase() === rule.subagentType.toLowerCase()) {
252
+ // Never override user's explicit model choice
253
+ if (toolInput.model) {
254
+ auditLog(`Task(${subagentType}) → user override preserved (model: ${toolInput.model})`, config);
255
+ break;
256
+ }
257
+ result.updatedInput.model = rule.model;
258
+ auditLog(`Task(${subagentType}) → model: ${rule.model} (${rule.reason})`, config);
259
+ applied = true;
260
+ break;
261
+ }
262
+ }
263
+
264
+ // Background routing
265
+ const bgRules = config.backgroundRules || [];
266
+ for (const rule of bgRules) {
267
+ if (!rule.subagentType) continue;
268
+ if (subagentType.toLowerCase() === rule.subagentType.toLowerCase()) {
269
+ // Only force background if not explicitly set by user/Claude
270
+ if (rule.forceBackground && toolInput.run_in_background === undefined) {
271
+ result.updatedInput.run_in_background = true;
272
+ auditLog(`Task(${subagentType}) → background: true`, config);
273
+ applied = true;
274
+ }
275
+ break;
276
+ }
277
+ }
278
+
279
+ if (!applied && !toolInput.model) {
280
+ auditLog(`Task(${subagentType}) → no override (no matching rule)`, config);
281
+ }
282
+
283
+ return Object.keys(result.updatedInput).length > 0 ? result : null;
284
+ }
285
+
286
+ // ============================================================================
287
+ // Handler 3: Planning Enforcer
288
+ // ============================================================================
289
+
290
+ function planningHandler(toolName, toolInput, _session, config) {
291
+ const planConfig = config.planningEnforcement;
292
+ if (!planConfig?.enabled) return null;
293
+
294
+ // Case 1: Task tool dispatching a Plan subagent
295
+ if (toolName === 'Task') {
296
+ const subagentType = toolInput?.subagent_type || '';
297
+ const triggerSubagents = planConfig.triggerSubagents || ['Plan'];
298
+
299
+ if (triggerSubagents.some(t => subagentType.toLowerCase() === t.toLowerCase())) {
300
+ const template = loadPlanningTemplate();
301
+ if (!template) return null;
302
+
303
+ auditLog(`Task(${subagentType}) → planning template injected`, config);
304
+
305
+ // Append template to the subagent's prompt via updatedInput.prompt
306
+ const originalPrompt = toolInput?.prompt || '';
307
+ return {
308
+ updatedInput: {
309
+ prompt: originalPrompt + '\n\n---\n\n' + template,
310
+ },
311
+ };
312
+ }
313
+ }
314
+
315
+ // Case 2: EnterPlanMode — inject into main Claude's context
316
+ if (toolName === 'EnterPlanMode' && planConfig.triggerEnterPlanMode) {
317
+ auditLog('EnterPlanMode → planning template context injected', config);
318
+
319
+ return {
320
+ additionalContext: [
321
+ 'PLANNING MODE ACTIVATED — Use this template for your plan:',
322
+ '',
323
+ loadPlanningTemplate() || '(Planning template not found)',
324
+ '',
325
+ 'IMPORTANT: Present the completed plan to the user and wait for',
326
+ 'explicit approval before proceeding to implementation.',
327
+ ].join('\n'),
328
+ };
329
+ }
330
+
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * Load the planning template from file.
336
+ * @returns {string|null}
337
+ */
338
+ function loadPlanningTemplate() {
339
+ try {
340
+ if (fs.existsSync(PLANNING_TEMPLATE_FILE)) {
341
+ return fs.readFileSync(PLANNING_TEMPLATE_FILE, 'utf-8');
342
+ }
343
+ } catch (error) {
344
+ logError('loadPlanningTemplate', error);
345
+ }
346
+ return null;
347
+ }
348
+
349
+ // ============================================================================
350
+ // Handler 4: Dry-Run Gate
351
+ // ============================================================================
352
+
353
+ function dryRunGateHandler(toolName, toolInput, _session, config) {
354
+ const gateConfig = config.dryRunGate;
355
+ if (!gateConfig?.enabled) return null;
356
+
357
+ // Only applies to Task dispatches (heavy operations)
358
+ if (toolName !== 'Task') return null;
359
+
360
+ const subagentType = toolInput?.subagent_type || '';
361
+ const skipTypes = gateConfig.skipSubagents || ['Explore', 'Plan', 'claude-code-guide'];
362
+
363
+ // Skip for research/planning agents that don't need tested code
364
+ if (skipTypes.some(t => subagentType.toLowerCase().includes(t.toLowerCase()))) {
365
+ return null;
366
+ }
367
+
368
+ // Read session state for file tracking
369
+ const session = readJSONFile(CURRENT_SESSION_FILE, {});
370
+ const modifiedFiles = session.modifiedFiles || [];
371
+ const testedFiles = session.testedFiles || [];
372
+
373
+ if (modifiedFiles.length === 0) return null;
374
+
375
+ // Find untested files
376
+ const untestedFiles = modifiedFiles.filter(f => !testedFiles.includes(f));
377
+
378
+ if (untestedFiles.length === 0) return null;
379
+
380
+ // Build warning (advisory only — never deny)
381
+ const fileList = untestedFiles.length <= 5
382
+ ? untestedFiles.map(f => path.basename(f)).join(', ')
383
+ : `${untestedFiles.slice(0, 5).map(f => path.basename(f)).join(', ')} (+${untestedFiles.length - 5} more)`;
384
+
385
+ auditLog(`Task(${subagentType}) → dry-run warning: ${untestedFiles.length} untested files`, config);
386
+
387
+ return {
388
+ additionalContext: [
389
+ 'UNTESTED CODE WARNING:',
390
+ `${untestedFiles.length} modified file(s) have not been tested yet: ${fileList}`,
391
+ '',
392
+ 'Consider running tests before dispatching this task:',
393
+ '- node --check <file> (syntax verification)',
394
+ '- vitest run <test> (unit tests)',
395
+ '- tsc --noEmit (type checking)',
396
+ '',
397
+ 'This is advisory — proceed if you are confident the code is correct.',
398
+ ].join('\n'),
399
+ };
400
+ }
401
+
402
+ // ============================================================================
403
+ // Hook Response Output
404
+ // ============================================================================
405
+
406
+ /**
407
+ * Output hook response as JSON to stdout.
408
+ */
409
+ function respond(hookOutput) {
410
+ process.stdout.write(JSON.stringify({
411
+ hookSpecificOutput: {
412
+ hookEventName: 'PreToolUse',
413
+ ...hookOutput,
414
+ },
415
+ }));
416
+ }
417
+
418
+ // ============================================================================
419
+ // Main
420
+ // ============================================================================
421
+
422
+ async function preToolUse() {
423
+ try {
424
+ const input = await readStdin(3000);
425
+ if (!input || input.trim() === '') {
426
+ process.exit(0);
427
+ }
428
+
429
+ const data = JSON.parse(input);
430
+ const toolName = data.tool_name || data.toolName || '';
431
+ const toolInput = data.tool_input || data.arguments || {};
432
+
433
+ // Load config once for all handlers
434
+ const config = loadRoutingConfig();
435
+
436
+ // Load session state once for handlers that need it
437
+ const session = readJSONFile(CURRENT_SESSION_FILE, {});
438
+
439
+ // Run all handlers
440
+ const responses = [
441
+ codeReviewHandler(toolName, toolInput, session),
442
+ routingHandler(toolName, toolInput, session, config),
443
+ planningHandler(toolName, toolInput, session, config),
444
+ dryRunGateHandler(toolName, toolInput, session, config),
445
+ ];
446
+
447
+ // Merge all responses
448
+ const merged = mergeResponses(responses);
449
+
450
+ // If any handler produced output, send the merged response
451
+ if (merged) {
452
+ respond(merged);
453
+ }
454
+
455
+ process.exit(0);
456
+ } catch (error) {
457
+ logError('PreToolUse', error);
458
+ process.exit(0); // Never block on hook errors
459
+ }
460
+ }
461
+
462
+ preToolUse();