@massu/core 1.3.0 → 1.4.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 (57) hide show
  1. package/commands/README.md +23 -11
  2. package/commands/massu-deploy.python-docker.md +170 -0
  3. package/commands/massu-deploy.python-fly.md +189 -0
  4. package/commands/massu-deploy.python-launchd.md +144 -0
  5. package/commands/massu-deploy.python-systemd.md +163 -0
  6. package/commands/massu-scaffold-page.swift.md +10 -10
  7. package/commands/massu-scaffold-router.python-django.md +153 -0
  8. package/commands/massu-scaffold-router.python-fastapi.md +145 -0
  9. package/dist/cli.js +9914 -4133
  10. package/dist/hooks/auto-learning-pipeline.js +45 -2
  11. package/dist/hooks/classify-failure.js +45 -2
  12. package/dist/hooks/cost-tracker.js +45 -2
  13. package/dist/hooks/fix-detector.js +45 -2
  14. package/dist/hooks/incident-pipeline.js +45 -2
  15. package/dist/hooks/post-edit-context.js +45 -2
  16. package/dist/hooks/post-tool-use.js +45 -2
  17. package/dist/hooks/pre-compact.js +45 -2
  18. package/dist/hooks/pre-delete-check.js +45 -2
  19. package/dist/hooks/quality-event.js +45 -2
  20. package/dist/hooks/rule-enforcement-pipeline.js +45 -2
  21. package/dist/hooks/session-end.js +45 -2
  22. package/dist/hooks/session-start.js +4790 -406
  23. package/dist/hooks/user-prompt.js +45 -2
  24. package/package.json +13 -4
  25. package/src/cli.ts +22 -2
  26. package/src/commands/config-refresh.ts +91 -23
  27. package/src/commands/init.ts +131 -24
  28. package/src/commands/install-commands.ts +142 -26
  29. package/src/commands/refresh-log.ts +37 -0
  30. package/src/commands/template-engine.ts +260 -0
  31. package/src/commands/watch.ts +430 -0
  32. package/src/config.ts +71 -0
  33. package/src/detect/adapters/nextjs-trpc.ts +166 -0
  34. package/src/detect/adapters/parse-guard.ts +133 -0
  35. package/src/detect/adapters/python-django.ts +208 -0
  36. package/src/detect/adapters/python-fastapi.ts +223 -0
  37. package/src/detect/adapters/query-helpers.ts +170 -0
  38. package/src/detect/adapters/runner.ts +252 -0
  39. package/src/detect/adapters/swift-swiftui.ts +171 -0
  40. package/src/detect/adapters/tree-sitter-loader.ts +467 -0
  41. package/src/detect/adapters/types.ts +173 -0
  42. package/src/detect/codebase-introspector.ts +190 -0
  43. package/src/detect/index.ts +28 -2
  44. package/src/detect/migrate.ts +4 -4
  45. package/src/detect/regex-fallback.ts +449 -0
  46. package/src/hooks/session-start.ts +94 -3
  47. package/src/lib/gitToplevel.ts +22 -0
  48. package/src/lib/installLock.ts +179 -0
  49. package/src/lib/pidLiveness.ts +67 -0
  50. package/src/lsp/auto-detect.ts +98 -0
  51. package/src/lsp/client.ts +776 -0
  52. package/src/lsp/enrich.ts +127 -0
  53. package/src/lsp/types.ts +221 -0
  54. package/src/watch/daemon.ts +385 -0
  55. package/src/watch/lockfile-detector.ts +65 -0
  56. package/src/watch/paths.ts +279 -0
  57. package/src/watch/state.ts +178 -0
@@ -0,0 +1,449 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Plan 3b — Phase 1: Regex Fallback Introspector.
6
+ *
7
+ * Verbatim move of the per-language regex helpers that were previously in
8
+ * `codebase-introspector.ts` (Plan #2 P3-001). NO regex logic changes — only
9
+ * the import path. This preserves Plan #2's 13 vitest cases as-is (re-imports
10
+ * are updated in `codebase-introspector.test.ts` only if needed; the moved
11
+ * functions retain their original signatures).
12
+ *
13
+ * The new 2-tier introspector (`codebase-introspector.ts`) calls these
14
+ * functions ONLY for fields where AST adapters returned 'none' confidence.
15
+ * AST adapters produce values into separate `detected.<adapter.id>` blocks;
16
+ * regex output continues to go into `detected.python` / `.swift` /
17
+ * `.typescript` blocks.
18
+ *
19
+ * Design rules carried over from Plan #2:
20
+ * - File size cap: 256KB per file (skip silently if larger).
21
+ * - Sample cap: at most 3 files per adapter (sorted, deterministic order).
22
+ * - Total wall-clock budget: <2s on a 10K-file repo.
23
+ * - Filesystem-only. No network, no child processes, no DB.
24
+ * - Returns `null` for any field it can't confidently extract.
25
+ */
26
+
27
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
28
+ import { resolve, join, basename } from 'path';
29
+ import type { DetectionResult } from './index.ts';
30
+
31
+ export const MAX_FILE_BYTES = 256 * 1024;
32
+ export const MAX_SAMPLES_PER_ADAPTER = 3;
33
+ export const MAX_DIR_DEPTH = 6;
34
+
35
+ // ============================================================
36
+ // Public types (re-exported from codebase-introspector for compat)
37
+ // ============================================================
38
+
39
+ export interface DetectedPython {
40
+ auth_dep?: string;
41
+ api_prefix_base?: string;
42
+ test_async_pattern?: string;
43
+ _provenance?: Record<string, string>;
44
+ }
45
+
46
+ export interface DetectedSwift {
47
+ api_client_class?: string;
48
+ biometric_policy?: string;
49
+ _provenance?: Record<string, string>;
50
+ }
51
+
52
+ export interface DetectedTypeScript {
53
+ trpc_router_builder?: string;
54
+ procedure_pattern?: string;
55
+ _provenance?: Record<string, string>;
56
+ }
57
+
58
+ // ============================================================
59
+ // Python adapter (FastAPI + Django)
60
+ // ============================================================
61
+
62
+ /**
63
+ * Introspect Python sources. Probes both FastAPI router files (`routers/*.py`,
64
+ * `api/*.py`) and Django views (`views.py`). Returns the most-extracted shape.
65
+ */
66
+ export function introspectPython(
67
+ detection: DetectionResult,
68
+ projectRoot: string,
69
+ ): DetectedPython | null {
70
+ const sourceDir = resolveSourceDir(detection, 'python', projectRoot);
71
+ if (!sourceDir) return null;
72
+
73
+ // Sample router-shaped files first (FastAPI / Flask), then views.py (Django),
74
+ // then any .py file as a last resort. Match on PATH, not just filename:
75
+ // FastAPI projects typically name routers by resource (`options.py`, `tax.py`)
76
+ // and place them under a `routers/` directory, so basename-only matching
77
+ // misses them. Also accept files where the basename itself is router-shaped
78
+ // (e.g., `endpoints.py`, `api.py`).
79
+ const routerFiles = sampleFiles(sourceDir, /\.py$/, (absPath, name) =>
80
+ /\/(routers?|api|endpoints?|views)\//.test(absPath) ||
81
+ /^(routers?|api|endpoints?)\.py$/.test(name),
82
+ );
83
+ const viewFiles = sampleFiles(sourceDir, /^views\.py$/);
84
+ const fallbackFiles = routerFiles.length === 0 && viewFiles.length === 0
85
+ ? sampleFiles(sourceDir, /\.py$/)
86
+ : [];
87
+ const candidates = [...routerFiles, ...viewFiles, ...fallbackFiles].slice(
88
+ 0,
89
+ MAX_SAMPLES_PER_ADAPTER,
90
+ );
91
+
92
+ if (candidates.length === 0) return null;
93
+
94
+ const authDeps = new Map<string, string>(); // value → first source path
95
+ const prefixBases = new Map<string, string>();
96
+ const testAsyncPatterns = new Map<string, string>();
97
+
98
+ for (const path of candidates) {
99
+ const body = readSafe(path);
100
+ if (body === null) continue;
101
+
102
+ // Auth dependency: `Depends(<name>)` — capture the call's argument.
103
+ // ReDoS-safe: anchored, short window, no nested quantifiers.
104
+ const authRegex = /\bDepends\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gu;
105
+ forEachMatch(authRegex, body, m => {
106
+ const name = m[1];
107
+ if (!authDeps.has(name)) authDeps.set(name, path);
108
+ });
109
+
110
+ // Django: `@login_required` or `@permission_required` decorators.
111
+ const djangoAuthRegex = /^@\s*([a-z_][a-z0-9_]*(?:_required|_login))\b/gmu;
112
+ forEachMatch(djangoAuthRegex, body, m => {
113
+ const name = m[1];
114
+ if (!authDeps.has(name)) authDeps.set(name, path);
115
+ });
116
+
117
+ // API prefix base: `APIRouter(prefix="/api/...")` — capture just the BASE
118
+ // (everything up to the second `/` from the start).
119
+ const prefixRegex = /\bAPIRouter\s*\(\s*[^)]*?prefix\s*=\s*["']([^"']+)["']/gu;
120
+ forEachMatch(prefixRegex, body, m => {
121
+ const fullPrefix = m[1];
122
+ const base = extractPrefixBase(fullPrefix);
123
+ if (base && !prefixBases.has(base)) prefixBases.set(base, path);
124
+ });
125
+
126
+ // Test async pattern: `@pytest.mark.asyncio` (with possible parens).
127
+ const asyncRegex = /^(@pytest\.mark\.asyncio(?:\s*\([^)]*\))?)/gmu;
128
+ forEachMatch(asyncRegex, body, m => {
129
+ const pat = m[1].trim();
130
+ if (!testAsyncPatterns.has(pat)) testAsyncPatterns.set(pat, path);
131
+ });
132
+ }
133
+
134
+ const authDep = pickBestSingleton(authDeps);
135
+ const apiPrefixBase = pickBestSingleton(prefixBases);
136
+ const testAsyncPattern = pickBestSingleton(testAsyncPatterns);
137
+
138
+ const result: DetectedPython = {};
139
+ const provenance: Record<string, string> = {};
140
+
141
+ if (authDep) {
142
+ result.auth_dep = authDep.value;
143
+ provenance.auth_dep_source = relativeTo(projectRoot, authDep.source);
144
+ }
145
+ if (apiPrefixBase) {
146
+ result.api_prefix_base = apiPrefixBase.value;
147
+ provenance.api_prefix_base_source = relativeTo(projectRoot, apiPrefixBase.source);
148
+ }
149
+ if (testAsyncPattern) {
150
+ result.test_async_pattern = testAsyncPattern.value;
151
+ provenance.test_async_pattern_source = relativeTo(projectRoot, testAsyncPattern.source);
152
+ }
153
+
154
+ // Only emit a language block when at least one real field was extracted.
155
+ // An empty result (provenance-only) clutters the YAML for no value.
156
+ if (Object.keys(result).length === 0) return null;
157
+ if (Object.keys(provenance).length > 0) result._provenance = provenance;
158
+ return result;
159
+ }
160
+
161
+ /**
162
+ * Reduce `/api/foo/bar` to `/api`. Returns null if the path doesn't have at
163
+ * least one slash-segment.
164
+ */
165
+ function extractPrefixBase(prefix: string): string | null {
166
+ if (!prefix.startsWith('/')) return null;
167
+ const stripped = prefix.replace(/^\/+/, '');
168
+ const firstSeg = stripped.split('/')[0];
169
+ if (!firstSeg) return null;
170
+ return '/' + firstSeg;
171
+ }
172
+
173
+ // ============================================================
174
+ // Swift adapter
175
+ // ============================================================
176
+
177
+ export function introspectSwift(
178
+ detection: DetectionResult,
179
+ projectRoot: string,
180
+ ): DetectedSwift | null {
181
+ const sourceDir = resolveSourceDir(detection, 'swift', projectRoot);
182
+ if (!sourceDir) return null;
183
+
184
+ // Prefer View files first, then any .swift. Path-aware: also match files
185
+ // under a `Views/` directory, since SwiftUI projects often name files by
186
+ // feature (e.g., `OrdersList.swift` inside `Features/Orders/Views/`).
187
+ const viewFiles = sampleFiles(sourceDir, /\.swift$/, (absPath, name) =>
188
+ /View\.swift$/.test(name) || /\/Views\//.test(absPath),
189
+ );
190
+ const fallbackFiles = viewFiles.length === 0
191
+ ? sampleFiles(sourceDir, /\.swift$/)
192
+ : [];
193
+ const candidates = [...viewFiles, ...fallbackFiles].slice(
194
+ 0,
195
+ MAX_SAMPLES_PER_ADAPTER,
196
+ );
197
+
198
+ if (candidates.length === 0) return null;
199
+
200
+ const apiClasses = new Map<string, string>();
201
+ const biometricPolicies = new Map<string, string>();
202
+
203
+ for (const path of candidates) {
204
+ const body = readSafe(path);
205
+ if (body === null) continue;
206
+
207
+ // API client class: looks like `let api = SomeAPI()` or
208
+ // `@StateObject var api: SomeAPI = .shared` etc.
209
+ // Extract `SomeAPI` from `SomeAPI(`/`SomeAPI.shared`/`: SomeAPI`.
210
+ const apiRegex = /\b([A-Z][A-Za-z0-9_]*API)\s*(?:\(|\.shared|\b)/gu;
211
+ forEachMatch(apiRegex, body, m => {
212
+ const name = m[1];
213
+ if (!apiClasses.has(name)) apiClasses.set(name, path);
214
+ });
215
+
216
+ // LocalAuthentication policy: `LAPolicy.<name>` or `.<name>` after
217
+ // `evaluatePolicy(`. Whitelist known good values to avoid false positives.
218
+ const policyRegex = /\.(deviceOwnerAuthentication(?:WithBiometrics)?)\b/gu;
219
+ forEachMatch(policyRegex, body, m => {
220
+ const name = m[1];
221
+ if (!biometricPolicies.has(name)) biometricPolicies.set(name, path);
222
+ });
223
+ }
224
+
225
+ const apiClass = pickBestSingleton(apiClasses);
226
+ const biometricPolicy = pickBestSingleton(biometricPolicies);
227
+
228
+ const result: DetectedSwift = {};
229
+ const provenance: Record<string, string> = {};
230
+
231
+ if (apiClass) {
232
+ result.api_client_class = apiClass.value;
233
+ provenance.api_client_class_source = relativeTo(projectRoot, apiClass.source);
234
+ }
235
+ if (biometricPolicy) {
236
+ result.biometric_policy = biometricPolicy.value;
237
+ provenance.biometric_policy_source = relativeTo(projectRoot, biometricPolicy.source);
238
+ }
239
+
240
+ if (Object.keys(result).length === 0) return null;
241
+ if (Object.keys(provenance).length > 0) result._provenance = provenance;
242
+ return result;
243
+ }
244
+
245
+ // ============================================================
246
+ // TypeScript / Next.js + tRPC adapter
247
+ // ============================================================
248
+
249
+ export function introspectTypeScript(
250
+ detection: DetectionResult,
251
+ projectRoot: string,
252
+ ): DetectedTypeScript | null {
253
+ const sourceDir = resolveSourceDir(detection, 'typescript', projectRoot)
254
+ ?? resolveSourceDir(detection, 'javascript', projectRoot);
255
+ if (!sourceDir) return null;
256
+
257
+ // Look for tRPC router files first. Match on PATH or filename: tRPC
258
+ // projects typically place routers under `server/api/routers/<name>.ts`
259
+ // where filenames are resource-named (e.g., `accounts.ts`).
260
+ const routerFiles = sampleFiles(sourceDir, /\.tsx?$/, (absPath, name) =>
261
+ /(router|trpc)/i.test(name) ||
262
+ /\/(routers|trpc|server\/api)\//.test(absPath),
263
+ );
264
+ const candidates = routerFiles.slice(0, MAX_SAMPLES_PER_ADAPTER);
265
+
266
+ if (candidates.length === 0) return null;
267
+
268
+ const builders = new Map<string, string>();
269
+ const procedurePatterns = new Map<string, string>();
270
+
271
+ for (const path of candidates) {
272
+ const body = readSafe(path);
273
+ if (body === null) continue;
274
+
275
+ // tRPC router builder: `createTRPCRouter({ ... })`. Whitelist known names
276
+ // (createTRPCRouter, router, t.router) — never grep for arbitrary identifiers.
277
+ const builderRegex = /\b(createTRPCRouter|router|t\.router)\s*\(/gu;
278
+ forEachMatch(builderRegex, body, m => {
279
+ const name = m[1];
280
+ if (!builders.has(name)) builders.set(name, path);
281
+ });
282
+
283
+ // Procedure pattern: `publicProcedure.input(...).query(...)` →
284
+ // capture just the procedure name (`publicProcedure`/`protectedProcedure`).
285
+ const procRegex = /\b([a-z]+Procedure)\b/gu;
286
+ forEachMatch(procRegex, body, m => {
287
+ const name = m[1];
288
+ if (!procedurePatterns.has(name)) procedurePatterns.set(name, path);
289
+ });
290
+ }
291
+
292
+ const builder = pickBestSingleton(builders);
293
+ const proc = pickBestSingleton(procedurePatterns);
294
+
295
+ const result: DetectedTypeScript = {};
296
+ const provenance: Record<string, string> = {};
297
+
298
+ if (builder) {
299
+ result.trpc_router_builder = builder.value;
300
+ provenance.trpc_router_builder_source = relativeTo(projectRoot, builder.source);
301
+ }
302
+ if (proc) {
303
+ result.procedure_pattern = proc.value;
304
+ provenance.procedure_pattern_source = relativeTo(projectRoot, proc.source);
305
+ }
306
+
307
+ if (Object.keys(result).length === 0) return null;
308
+ if (Object.keys(provenance).length > 0) result._provenance = provenance;
309
+ return result;
310
+ }
311
+
312
+ // ============================================================
313
+ // Helpers
314
+ // ============================================================
315
+
316
+ /**
317
+ * Resolve the dominant source directory for a language, falling back to the
318
+ * project root if detection didn't surface anything specific.
319
+ */
320
+ function resolveSourceDir(
321
+ detection: DetectionResult,
322
+ lang: string,
323
+ projectRoot: string,
324
+ ): string | null {
325
+ const dirs = (detection.sourceDirs as unknown as Record<string, { source_dirs?: string[] }>);
326
+ const info = dirs[lang];
327
+ const list = info?.source_dirs ?? [];
328
+ if (list.length > 0) {
329
+ const first = list[0];
330
+ const abs = resolve(projectRoot, first);
331
+ return existsSync(abs) ? abs : null;
332
+ }
333
+ return existsSync(projectRoot) ? projectRoot : null;
334
+ }
335
+
336
+ /**
337
+ * Walk `dir` and return up to MAX_SAMPLES_PER_ADAPTER files matching `nameRegex`,
338
+ * filtered further by `pathFilter` (called with absolute path AND basename, so
339
+ * filters can match either the filename (e.g., `views.py`) or a path
340
+ * component (e.g., a `routers/` parent directory). Skips dot-dirs,
341
+ * node_modules, .venv, etc. Bounded depth.
342
+ */
343
+ export function sampleFiles(
344
+ dir: string,
345
+ nameRegex: RegExp,
346
+ pathFilter?: (absPath: string, basename: string) => boolean,
347
+ ): string[] {
348
+ const out: string[] = [];
349
+ const stack: { path: string; depth: number }[] = [{ path: dir, depth: 0 }];
350
+
351
+ while (stack.length > 0 && out.length < MAX_SAMPLES_PER_ADAPTER * 4) {
352
+ const { path, depth } = stack.pop()!;
353
+ if (depth > MAX_DIR_DEPTH) continue;
354
+
355
+ let entries: string[];
356
+ try {
357
+ entries = readdirSync(path);
358
+ } catch {
359
+ continue;
360
+ }
361
+
362
+ for (const entry of entries) {
363
+ if (entry.startsWith('.')) continue;
364
+ if (entry === 'node_modules') continue;
365
+ if (entry === '__pycache__') continue;
366
+ if (entry === 'venv' || entry === '.venv') continue;
367
+ if (entry === 'dist' || entry === 'build') continue;
368
+
369
+ const child = join(path, entry);
370
+ let st;
371
+ try {
372
+ st = statSync(child);
373
+ } catch {
374
+ continue;
375
+ }
376
+
377
+ if (st.isDirectory()) {
378
+ stack.push({ path: child, depth: depth + 1 });
379
+ continue;
380
+ }
381
+
382
+ if (!nameRegex.test(entry)) continue;
383
+ if (pathFilter && !pathFilter(child, entry)) continue;
384
+ if (st.size > MAX_FILE_BYTES) continue;
385
+ out.push(child);
386
+ if (out.length >= MAX_SAMPLES_PER_ADAPTER * 4) break;
387
+ }
388
+ }
389
+
390
+ // Stable ordering — deterministic test fixtures.
391
+ out.sort();
392
+ return out.slice(0, MAX_SAMPLES_PER_ADAPTER * 2);
393
+ }
394
+
395
+ /** Read a file as UTF-8. Returns null on any error or if too large. */
396
+ export function readSafe(path: string): string | null {
397
+ try {
398
+ const st = statSync(path);
399
+ if (st.size > MAX_FILE_BYTES) return null;
400
+ return readFileSync(path, 'utf-8');
401
+ } catch {
402
+ return null;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Run a global regex over `body` and call `cb` for each match. Caps iteration
408
+ * at 1000 matches per regex to defend against pathological inputs.
409
+ */
410
+ export function forEachMatch(
411
+ re: RegExp,
412
+ body: string,
413
+ cb: (m: RegExpExecArray) => void,
414
+ ): void {
415
+ if (!re.global) return;
416
+ re.lastIndex = 0;
417
+ let count = 0;
418
+ let m: RegExpExecArray | null;
419
+ while ((m = re.exec(body)) !== null) {
420
+ cb(m);
421
+ count++;
422
+ if (count > 1000) break;
423
+ // Defensive: zero-width match → bump lastIndex to avoid infinite loop.
424
+ if (m.index === re.lastIndex) re.lastIndex++;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * If `samples` has exactly one distinct value, return it.
430
+ * If it has 2 values, return the first-seen (stable, deterministic order from
431
+ * file walk).
432
+ * If it has 3+ distinct values, return null — Risk #6 (auth-dep ambiguity).
433
+ */
434
+ export function pickBestSingleton(
435
+ samples: Map<string, string>,
436
+ ): { value: string; source: string } | null {
437
+ if (samples.size === 0) return null;
438
+ if (samples.size >= 3) return null;
439
+ const [firstKey, firstSource] = samples.entries().next().value as [string, string];
440
+ return { value: firstKey, source: firstSource };
441
+ }
442
+
443
+ /** Make a file path relative to the project root. */
444
+ export function relativeTo(projectRoot: string, absPath: string): string {
445
+ if (absPath.startsWith(projectRoot + '/')) {
446
+ return absPath.slice(projectRoot.length + 1);
447
+ }
448
+ return basename(absPath);
449
+ }
@@ -16,6 +16,7 @@ import { parse as parseYaml } from 'yaml';
16
16
  import type Database from 'better-sqlite3';
17
17
  import { runDetection } from '../detect/index.ts';
18
18
  import { computeFingerprint } from '../detect/drift.ts';
19
+ import { isPidAlive } from '../lib/pidLiveness.ts';
19
20
 
20
21
  interface HookInput {
21
22
  session_id: string;
@@ -68,9 +69,14 @@ async function main(): Promise<void> {
68
69
  }
69
70
 
70
71
  // P5-001: drift banner (runs after memory context, independent of it).
72
+ // Plan 3a Phase 6: when a live watcher daemon exists, the drift banner
73
+ // is suppressed in favor of a compact watcher banner.
71
74
  const driftBanner = await buildDriftBanner();
72
75
  if (driftBanner) {
73
76
  process.stdout.write(driftBanner);
77
+ } else {
78
+ const watcherBanner = buildWatcherBanner();
79
+ if (watcherBanner) process.stdout.write(watcherBanner);
74
80
  }
75
81
  } finally {
76
82
  db.close();
@@ -261,6 +267,15 @@ function readStdin(): Promise<string> {
261
267
  */
262
268
  async function buildDriftBanner(): Promise<string> {
263
269
  try {
270
+ // Plan #2 P4-004: explicit opt-out for users in deliberate mid-migration windows.
271
+ // Stays at the top so MASSU_DRIFT_QUIET=1 remains the strongest signal
272
+ // (iter-1 G8: env-var override beats watcher-state suppression).
273
+ if (process.env.MASSU_DRIFT_QUIET === '1') return '';
274
+
275
+ // Plan 3a Phase 6: if a live watcher daemon refreshed within the last 24h,
276
+ // suppress this banner — the watcher already keeps the config current.
277
+ if (watcherIsLiveAndFresh()) return '';
278
+
264
279
  const configPath = resolve(process.cwd(), 'massu.config.yaml');
265
280
  if (!existsSync(configPath)) return '';
266
281
  const content = readFileSync(configPath, 'utf-8');
@@ -269,7 +284,10 @@ async function buildDriftBanner(): Promise<string> {
269
284
  const det = parsed.detection as Record<string, unknown> | undefined;
270
285
  const storedFp = typeof det?.fingerprint === 'string' ? (det.fingerprint as string) : null;
271
286
  if (!storedFp) return '';
272
- const detection = await runDetection(process.cwd());
287
+ // Plan #2 P4-006: skip the codebase introspector pass — the drift banner
288
+ // only needs the fingerprint, not the introspected detail. Saves up to 2s
289
+ // wall-clock from the hook's 5-second budget.
290
+ const detection = await runDetection(process.cwd(), undefined, { skipIntrospect: true });
273
291
  const currentFp = computeFingerprint(detection);
274
292
  if (currentFp === storedFp) return '';
275
293
  return (
@@ -277,6 +295,9 @@ async function buildDriftBanner(): Promise<string> {
277
295
  'Detected stack has changed since last config refresh.\n' +
278
296
  `Fingerprint: ${storedFp.slice(0, 16)} -> ${currentFp.slice(0, 16)}\n` +
279
297
  'Run: npx massu config refresh\n' +
298
+ '(this will update massu.config.yaml AND any commands that need\n' +
299
+ ' re-templating for your new stack)\n' +
300
+ 'Tip: set MASSU_DRIFT_QUIET=1 to suppress this banner during mid-migration.\n' +
280
301
  '=== END ===\n'
281
302
  );
282
303
  } catch (_e) {
@@ -306,8 +327,11 @@ function loadCorrectionsPreventionRules(): string[] {
306
327
  const cwd = process.cwd();
307
328
  const config = getConfig();
308
329
  const claudeDirName = config.conventions?.claudeDirName ?? '.claude';
309
- // Convert cwd to Claude's directory format: /Users/x/project -> -Users-x-project
310
- const projectDirName = cwd.replace(/\//g, '-').replace(/^-/, '');
330
+ // Convert cwd to Claude's directory format: /Users/x/project -> -Users-x-project.
331
+ // Match both forward slashes (POSIX) and backslashes (Windows) so the lookup
332
+ // works cross-platform — without this, Windows users silently miss prevention
333
+ // rules because their projectDirName never resolves.
334
+ const projectDirName = cwd.replace(/[/\\]/g, '-').replace(/^-/, '');
311
335
  const correctionsPath = join(homeDir, claudeDirName, 'projects', projectDirName, 'memory', 'corrections.md');
312
336
 
313
337
  if (!existsSync(correctionsPath)) return [];
@@ -341,4 +365,71 @@ function loadCorrectionsPreventionRules(): string[] {
341
365
  }
342
366
  }
343
367
 
368
+ // ============================================================
369
+ // Plan 3a Phase 6: watcher-aware banner support
370
+ // ============================================================
371
+
372
+ interface WatchStateShape {
373
+ schema_version?: number;
374
+ daemonPid?: number | null;
375
+ lastRefreshAt?: string | null;
376
+ startedAt?: string | null;
377
+ }
378
+
379
+ function readWatchStateRaw(cwd: string): WatchStateShape | null {
380
+ try {
381
+ const path = resolve(cwd, '.massu', 'watch-state.json');
382
+ if (!existsSync(path)) return null;
383
+ const obj = JSON.parse(readFileSync(path, 'utf-8'));
384
+ if (!obj || typeof obj !== 'object') return null;
385
+ return obj as WatchStateShape;
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+
391
+ function watcherIsLiveAndFresh(): boolean {
392
+ // MASSU_DRIFT_QUIET takes precedence (caller already short-circuited).
393
+ // Fresh = last refresh within 24h AND daemonPid is alive.
394
+ const state = readWatchStateRaw(process.cwd());
395
+ if (!state) return false;
396
+ if (typeof state.daemonPid !== 'number' || state.daemonPid <= 0) return false;
397
+ if (!isPidAlive(state.daemonPid)) return false;
398
+ if (typeof state.lastRefreshAt !== 'string') return false;
399
+ const last = Date.parse(state.lastRefreshAt);
400
+ if (!Number.isFinite(last)) return false;
401
+ const ageMs = Date.now() - last;
402
+ return ageMs >= 0 && ageMs < 24 * 60 * 60 * 1000;
403
+ }
404
+
405
+ function buildWatcherBanner(): string {
406
+ // P4-004 ordering: MASSU_DRIFT_QUIET wins everywhere.
407
+ if (process.env.MASSU_DRIFT_QUIET === '1') return '';
408
+ const state = readWatchStateRaw(process.cwd());
409
+ if (!state) return '';
410
+ if (typeof state.daemonPid !== 'number' || state.daemonPid <= 0) return '';
411
+ if (!isPidAlive(state.daemonPid)) return '';
412
+ if (typeof state.lastRefreshAt !== 'string') return '';
413
+ const last = Date.parse(state.lastRefreshAt);
414
+ if (!Number.isFinite(last)) return '';
415
+ const ageMs = Date.now() - last;
416
+ if (ageMs < 0 || ageMs >= 24 * 60 * 60 * 1000) return '';
417
+
418
+ const ageStr = formatAge(ageMs);
419
+ return (
420
+ '=== Massu Watcher ===\n' +
421
+ `[massu] watcher running, last refresh: ${ageStr} ago (pid ${state.daemonPid})\n` +
422
+ '=== END ===\n'
423
+ );
424
+ }
425
+
426
+ function formatAge(ms: number): string {
427
+ const sec = Math.round(ms / 1000);
428
+ if (sec < 60) return `${sec}s`;
429
+ const min = Math.round(sec / 60);
430
+ if (min < 60) return `${min}m`;
431
+ const hr = Math.round(min / 60);
432
+ return `${hr}h`;
433
+ }
434
+
344
435
  main();
@@ -0,0 +1,22 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Resolve the git repository root for a given working directory, falling
6
+ * back to the cwd itself when not inside a git repo.
7
+ *
8
+ * Used by `massu watch` and `massu refresh-log` so the watcher root and the
9
+ * refresh-log path always anchor on the same toplevel rather than wherever
10
+ * the user happened to invoke the CLI from.
11
+ */
12
+
13
+ import { spawnSync } from 'child_process';
14
+
15
+ export function gitToplevel(cwd: string): string {
16
+ const res = spawnSync('git', ['rev-parse', '--show-toplevel'], {
17
+ cwd,
18
+ encoding: 'utf-8',
19
+ });
20
+ if (res.status === 0 && res.stdout) return res.stdout.trim();
21
+ return cwd;
22
+ }