@massu/core 1.10.8 → 1.12.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 (178) hide show
  1. package/CHANGELOG.md +1303 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +517 -397
  4. package/dist/detect/adapters/.bundle-shasums.json +4 -7
  5. package/dist/detect/adapters/rails.js +6 -4
  6. package/dist/detect/adapters/spring.js +6 -4
  7. package/dist/detect/adapters/tree-sitter-loader.js +6 -4
  8. package/dist/hooks/auto-learning-pipeline.js +24 -1
  9. package/dist/hooks/classify-failure.js +32 -3
  10. package/dist/hooks/cost-tracker.js +24 -0
  11. package/dist/hooks/fix-detector.js +97 -1
  12. package/dist/hooks/incident-pipeline.js +30 -1
  13. package/dist/hooks/intent-suggester.js +6 -3
  14. package/dist/hooks/post-edit-context.js +24 -1
  15. package/dist/hooks/post-tool-use.js +66 -18
  16. package/dist/hooks/pre-compact.js +27 -2
  17. package/dist/hooks/pre-delete-check.js +48 -22
  18. package/dist/hooks/pre-tool-use-gate.js +962 -0
  19. package/dist/hooks/quality-event.js +24 -0
  20. package/dist/hooks/rule-enforcement-pipeline.js +24 -1
  21. package/dist/hooks/security-gate.js +29 -16
  22. package/dist/hooks/session-end.js +24 -0
  23. package/dist/hooks/session-start.js +8710 -8460
  24. package/dist/hooks/user-prompt.js +24 -0
  25. package/package.json +15 -18
  26. package/commands/README.md +0 -134
  27. package/src/adapter.ts +0 -121
  28. package/src/adr-generator.ts +0 -292
  29. package/src/analytics.ts +0 -373
  30. package/src/audit-trail.ts +0 -450
  31. package/src/backfill-sessions.ts +0 -182
  32. package/src/changelog-generator.ts +0 -178
  33. package/src/cli.ts +0 -246
  34. package/src/cloud-sync.ts +0 -234
  35. package/src/commands/adapters.ts +0 -824
  36. package/src/commands/changelog.ts +0 -165
  37. package/src/commands/config-check-drift.ts +0 -133
  38. package/src/commands/config-refresh.ts +0 -396
  39. package/src/commands/config-upgrade.ts +0 -127
  40. package/src/commands/doctor.ts +0 -485
  41. package/src/commands/hook-runner.ts +0 -145
  42. package/src/commands/init.ts +0 -1823
  43. package/src/commands/install-commands.ts +0 -692
  44. package/src/commands/install-hooks.ts +0 -27
  45. package/src/commands/permissions.ts +0 -150
  46. package/src/commands/refresh-log.ts +0 -37
  47. package/src/commands/show-template.ts +0 -65
  48. package/src/commands/template-engine.ts +0 -301
  49. package/src/commands/watch.ts +0 -430
  50. package/src/config.ts +0 -765
  51. package/src/cost-tracker.ts +0 -355
  52. package/src/db.ts +0 -370
  53. package/src/dependency-scorer.ts +0 -337
  54. package/src/detect/adapters/aspnet.ts +0 -4
  55. package/src/detect/adapters/discover.ts +0 -469
  56. package/src/detect/adapters/file-sampler.ts +0 -225
  57. package/src/detect/adapters/go-chi.ts +0 -4
  58. package/src/detect/adapters/index.ts +0 -51
  59. package/src/detect/adapters/nextjs-trpc.ts +0 -166
  60. package/src/detect/adapters/parse-guard.ts +0 -133
  61. package/src/detect/adapters/phoenix.ts +0 -4
  62. package/src/detect/adapters/python-django.ts +0 -208
  63. package/src/detect/adapters/python-fastapi.ts +0 -223
  64. package/src/detect/adapters/python-flask.ts +0 -235
  65. package/src/detect/adapters/query-helpers.ts +0 -170
  66. package/src/detect/adapters/rails.ts +0 -4
  67. package/src/detect/adapters/runner.ts +0 -284
  68. package/src/detect/adapters/spring.ts +0 -4
  69. package/src/detect/adapters/swift-swiftui.ts +0 -171
  70. package/src/detect/adapters/tree-sitter-loader.ts +0 -517
  71. package/src/detect/adapters/types.ts +0 -191
  72. package/src/detect/codebase-introspector.ts +0 -210
  73. package/src/detect/domain-inferrer.ts +0 -197
  74. package/src/detect/drift.ts +0 -199
  75. package/src/detect/framework-detector.ts +0 -307
  76. package/src/detect/index.ts +0 -209
  77. package/src/detect/manifest-registry.ts +0 -261
  78. package/src/detect/migrate.ts +0 -362
  79. package/src/detect/monorepo-detector.ts +0 -348
  80. package/src/detect/package-detector.ts +0 -828
  81. package/src/detect/passthrough.ts +0 -108
  82. package/src/detect/regex-fallback.ts +0 -449
  83. package/src/detect/source-dir-detector.ts +0 -297
  84. package/src/detect/vr-command-map.ts +0 -167
  85. package/src/docs-map.json +0 -100
  86. package/src/docs-tools.ts +0 -519
  87. package/src/domains.ts +0 -182
  88. package/src/hooks/auto-learning-pipeline.ts +0 -228
  89. package/src/hooks/classify-failure.ts +0 -259
  90. package/src/hooks/cost-tracker.ts +0 -66
  91. package/src/hooks/fix-detector.ts +0 -186
  92. package/src/hooks/incident-pipeline.ts +0 -190
  93. package/src/hooks/intent-suggester.ts +0 -131
  94. package/src/hooks/post-edit-context.ts +0 -91
  95. package/src/hooks/post-tool-use.ts +0 -413
  96. package/src/hooks/pre-compact.ts +0 -168
  97. package/src/hooks/pre-delete-check.ts +0 -243
  98. package/src/hooks/quality-event.ts +0 -127
  99. package/src/hooks/rule-enforcement-pipeline.ts +0 -159
  100. package/src/hooks/security-gate.ts +0 -153
  101. package/src/hooks/session-end.ts +0 -467
  102. package/src/hooks/session-start.ts +0 -436
  103. package/src/hooks/user-prompt.ts +0 -157
  104. package/src/import-resolver.ts +0 -226
  105. package/src/knowledge-db.ts +0 -176
  106. package/src/knowledge-indexer.ts +0 -708
  107. package/src/knowledge-tools.ts +0 -1414
  108. package/src/lib/fileLock.ts +0 -203
  109. package/src/lib/framework-supports.ts +0 -85
  110. package/src/lib/gitToplevel.ts +0 -22
  111. package/src/lib/hook-registry.ts +0 -43
  112. package/src/lib/installLock.ts +0 -66
  113. package/src/lib/memory-path.ts +0 -49
  114. package/src/lib/pidLiveness.ts +0 -67
  115. package/src/lib/settings-local.ts +0 -110
  116. package/src/lib/sql-table-names.ts +0 -71
  117. package/src/license.ts +0 -547
  118. package/src/lsp/auto-detect.ts +0 -98
  119. package/src/lsp/client.ts +0 -776
  120. package/src/lsp/enrich.ts +0 -127
  121. package/src/lsp/types.ts +0 -221
  122. package/src/memory-db.ts +0 -1614
  123. package/src/memory-file-ingest.ts +0 -128
  124. package/src/memory-tools.ts +0 -424
  125. package/src/middleware-tree.ts +0 -71
  126. package/src/observability-tools.ts +0 -343
  127. package/src/observation-extractor.ts +0 -418
  128. package/src/page-deps.ts +0 -285
  129. package/src/permissions.ts +0 -422
  130. package/src/prompt-analyzer.ts +0 -332
  131. package/src/python/coupling-detector.ts +0 -125
  132. package/src/python/domain-enforcer.ts +0 -84
  133. package/src/python/impact-analyzer.ts +0 -96
  134. package/src/python/import-parser.ts +0 -244
  135. package/src/python/import-resolver.ts +0 -136
  136. package/src/python/migration-indexer.ts +0 -116
  137. package/src/python/migration-parser.ts +0 -332
  138. package/src/python/model-indexer.ts +0 -71
  139. package/src/python/model-parser.ts +0 -279
  140. package/src/python/route-indexer.ts +0 -59
  141. package/src/python/route-parser.ts +0 -317
  142. package/src/python-tools.ts +0 -630
  143. package/src/regression-detector.ts +0 -319
  144. package/src/rules.ts +0 -57
  145. package/src/schema-mapper.ts +0 -232
  146. package/src/security/adapter-origin.ts +0 -130
  147. package/src/security/adapter-verifier.ts +0 -319
  148. package/src/security/atomic-write.ts +0 -164
  149. package/src/security/fetcher.ts +0 -200
  150. package/src/security/install-tracking.ts +0 -319
  151. package/src/security/license-pubkey.generated.ts +0 -30
  152. package/src/security/license-response-verifier.ts +0 -149
  153. package/src/security/local-fingerprint.ts +0 -225
  154. package/src/security/manifest-cache.ts +0 -333
  155. package/src/security/manifest-schema.ts +0 -129
  156. package/src/security/registry-pubkey.generated.ts +0 -35
  157. package/src/security/telemetry.ts +0 -320
  158. package/src/security-scorer.ts +0 -405
  159. package/src/security-utils.ts +0 -133
  160. package/src/sentinel-db.ts +0 -580
  161. package/src/sentinel-scanner.ts +0 -406
  162. package/src/sentinel-tools.ts +0 -513
  163. package/src/sentinel-types.ts +0 -140
  164. package/src/server-dispatch.ts +0 -225
  165. package/src/server.ts +0 -107
  166. package/src/session-archiver.ts +0 -111
  167. package/src/session-state-generator.ts +0 -174
  168. package/src/team-knowledge.ts +0 -407
  169. package/src/tool-db-needs.ts +0 -232
  170. package/src/tools.ts +0 -1206
  171. package/src/transcript-parser.ts +0 -458
  172. package/src/trpc-index.ts +0 -215
  173. package/src/validate-features-runner.ts +0 -107
  174. package/src/validation-engine.ts +0 -358
  175. package/src/watch/daemon.ts +0 -385
  176. package/src/watch/lockfile-detector.ts +0 -65
  177. package/src/watch/paths.ts +0 -279
  178. package/src/watch/state.ts +0 -178
@@ -1,385 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * `massu watch` daemon main loop.
6
- *
7
- * Combines Layer B (chokidar watcher + state persistence) and Layer C
8
- * (quiescence detector: debounce, storm detection, lockfile + git
9
- * mid-write hard-stops) into a single foreground process. The CLI
10
- * wrapper in commands/watch.ts spawns this under claude-bg so it's
11
- * registered for lifecycle reaping.
12
- */
13
-
14
- import * as chokidar from 'chokidar';
15
- import { resetConfig, getConfig } from '../config.ts';
16
- import { gitMidOperation, lockfileMidWrite } from './lockfile-detector.ts';
17
- import { deriveWatchGlobs, enforceWatchSurfaceCap, WatchSurfaceTooLargeError } from './paths.ts';
18
- import { updateState } from './state.ts';
19
-
20
- export const STORM_WINDOW_MS = 1_000;
21
- export const DEEP_STORM_WINDOW_MS = 10_000;
22
- export const STORM_WAIT_MS = 30_000;
23
- export const DEEP_STORM_WAIT_MS = 120_000;
24
- export const TICK_INTERVAL_MS = 10_000;
25
- export const TICK_GAP_THRESHOLD_MS = 30_000;
26
-
27
- export interface DaemonHooks {
28
- /** Called after quiescence + hard-stops pass. Implementations refresh + install. */
29
- onQuiescent: () => Promise<void> | void;
30
- /** Optional override for current time, used in tests. */
31
- now?: () => number;
32
- /** Stderr writer. Defaults to process.stderr.write. */
33
- writeStderr?: (s: string) => void;
34
- /** When true, skip the chokidar setup (used by tests that drive events manually). */
35
- noWatcher?: boolean;
36
- }
37
-
38
- export interface DaemonHandle {
39
- /** Synthetic event ingest — used by tests; in prod, chokidar drives this. */
40
- pushEvent: (path: string) => void;
41
- /** Force the quiescence timer to fire NOW (for `--apply-now`). */
42
- flushNow: () => Promise<void>;
43
- /** Stop the watcher and clear timers. */
44
- stop: () => Promise<void>;
45
- /** Explicit reconciliation pass — used after sleep/wake gap. */
46
- forceReconciliation: () => Promise<void>;
47
- }
48
-
49
- interface QuiescenceContext {
50
- /** Pending event timestamps within the recent storm windows. */
51
- recent: number[];
52
- /** Pending refresh timer. */
53
- debounceTimer: NodeJS.Timeout | null;
54
- /** Hard-timeout (5 min) timer that fires even when events don't stop. */
55
- hardTimeoutAt: number | null;
56
- /** When in storm/deep-storm, do not schedule another refresh until this ts. */
57
- stormCooldownUntil: number;
58
- /** Last setInterval tick epoch — used by tick-gap heuristic. */
59
- lastTickAt: number;
60
- /** Set after a sleep/wake gap is detected, before the reconciliation runs. */
61
- reconciliationPending: boolean;
62
- }
63
-
64
- export interface DaemonConfig {
65
- projectRoot: string;
66
- debounceMs: number;
67
- stormThreshold: number;
68
- deepStormThreshold: number;
69
- hardTimeoutMs: number;
70
- }
71
-
72
- function readDaemonConfig(projectRoot: string): DaemonConfig {
73
- // resetConfig() is the caller's responsibility (we want to read the
74
- // freshest YAML after every refresh cycle).
75
- const cfg = getConfig();
76
- const w = (cfg.watch as Record<string, unknown> | undefined) ?? {};
77
- const num = (k: string, fallback: number): number => {
78
- const v = w[k];
79
- return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
80
- };
81
- return {
82
- projectRoot,
83
- debounceMs: num('debounce_ms', 3_000),
84
- stormThreshold: num('storm_threshold', 50),
85
- deepStormThreshold: num('deep_storm_threshold', 500),
86
- hardTimeoutMs: num('hard_timeout_ms', 300_000),
87
- };
88
- }
89
-
90
- /**
91
- * Start the daemon. Returns a handle for graceful shutdown / test injection.
92
- *
93
- * In production: chokidar drives `pushEvent` via the file-watcher.
94
- * In tests: pass `noWatcher: true` and call `pushEvent` directly.
95
- */
96
- export async function startDaemon(projectRoot: string, hooks: DaemonHooks): Promise<DaemonHandle> {
97
- const now = hooks.now ?? Date.now;
98
- const writeStderr = hooks.writeStderr ?? ((s: string) => { process.stderr.write(s); });
99
-
100
- const cfg = readDaemonConfig(projectRoot);
101
- const ctx: QuiescenceContext = {
102
- recent: [],
103
- debounceTimer: null,
104
- hardTimeoutAt: null,
105
- stormCooldownUntil: 0,
106
- lastTickAt: now(),
107
- reconciliationPending: false,
108
- };
109
-
110
- let watcher: chokidar.FSWatcher | null = null;
111
- let tickTimer: NodeJS.Timeout | null = null;
112
- let stopped = false;
113
- // Mutex to prevent overlapping reruns of the quiescence callback.
114
- let runningRefresh = false;
115
-
116
- function clearDebounce(): void {
117
- if (ctx.debounceTimer) {
118
- clearTimeout(ctx.debounceTimer);
119
- ctx.debounceTimer = null;
120
- }
121
- }
122
-
123
- function pruneRecent(t: number): void {
124
- const cutoff = t - DEEP_STORM_WINDOW_MS;
125
- if (ctx.recent.length === 0 || ctx.recent[0] >= cutoff) return;
126
- // Single-pass filter beats repeated O(n) Array.shift() calls when many
127
- // events fall outside the window at once (iter-9 simplify finding E2).
128
- ctx.recent = ctx.recent.filter((x) => x >= cutoff);
129
- }
130
-
131
- function detectStorm(t: number): 'normal' | 'storm' | 'deep_storm' {
132
- pruneRecent(t);
133
- const lastSecond = ctx.recent.filter((x) => t - x <= STORM_WINDOW_MS).length;
134
- const lastTen = ctx.recent.length;
135
- if (lastTen > cfg.deepStormThreshold) return 'deep_storm';
136
- if (lastSecond > cfg.stormThreshold) return 'storm';
137
- return 'normal';
138
- }
139
-
140
- async function fireRefresh(): Promise<void> {
141
- if (runningRefresh) {
142
- // Observability: surface the skip so users investigating "why didn't a
143
- // refresh fire?" can see it in stderr instead of silent suppression.
144
- writeStderr('[massu] refresh skipped (previous refresh still running)\n');
145
- // Iter-2 correctness fix: don't drop the deferred refresh on the floor.
146
- // Re-arm the debounce so the watcher will retry after the current
147
- // refresh resolves. Without this, a fresh-event-burst arriving while a
148
- // previous refresh is in flight would consume the debounce timer and
149
- // never fire — file changes would be silently lost until the NEXT
150
- // unrelated event woke the daemon back up.
151
- scheduleDebounce(cfg.debounceMs);
152
- return;
153
- }
154
- runningRefresh = true;
155
- try {
156
- // Hard-stops (G3-A12 of plan + Layer C semantics):
157
- if (gitMidOperation(cfg.projectRoot)) {
158
- writeStderr('[massu] git operation in progress (.git/MERGE_HEAD or REBASE_HEAD); skipping refresh\n');
159
- // Iter-3 (third pass) G3-iter3-E6: re-arm the debounce so the
160
- // watcher will retry once the merge/rebase completes. Otherwise,
161
- // if zero file events fire AFTER the git operation finishes (e.g.,
162
- // user resolved conflicts inside the editor and the editor's
163
- // chokidar events all hit during the op), the refresh would never
164
- // re-trigger until an unrelated event woke the daemon.
165
- scheduleDebounce(cfg.debounceMs);
166
- return;
167
- }
168
- // Lockfile mid-write — defer one more debounce cycle.
169
- if (lockfileMidWrite(cfg.projectRoot, now())) {
170
- writeStderr('[massu] lockfile mid-write detected; deferring refresh by debounce\n');
171
- scheduleDebounce(cfg.debounceMs);
172
- return;
173
- }
174
-
175
- await hooks.onQuiescent();
176
- ctx.recent = [];
177
- ctx.hardTimeoutAt = null;
178
- } catch (err) {
179
- const msg = err instanceof Error ? err.message : String(err);
180
- writeStderr(`[massu] refresh error: ${msg}\n`);
181
- try {
182
- updateState(cfg.projectRoot, { lastError: msg });
183
- } catch {
184
- // best-effort
185
- }
186
- // Iter-5 correctness fix: re-arm the debounce so a transient error
187
- // (e.g., runDetection throws on a fs blip / NFS hiccup) does not
188
- // permanently strand the daemon waiting for the next file event.
189
- // Mirrors the git-mid-op (G3-iter3-E6) and lockfile-mid-write paths
190
- // above. Without this re-arm, a one-shot detection error during a
191
- // quiet period would silently suppress refreshes until a wholly
192
- // unrelated event re-armed the debounce timer.
193
- scheduleDebounce(cfg.debounceMs);
194
- } finally {
195
- runningRefresh = false;
196
- }
197
- }
198
-
199
-
200
- function scheduleDebounce(delayMs: number): void {
201
- // Iter-6 fix: do not schedule new refreshes after stop(). Otherwise an
202
- // in-flight refresh that hits a re-arm path (transient error, lockfile
203
- // mid-write, etc.) during shutdown would create a setTimeout that fires
204
- // AFTER the daemon was told to stop — leaking a timer and (worse) firing
205
- // a refresh against a torn-down state.
206
- if (stopped) return;
207
- clearDebounce();
208
- ctx.debounceTimer = setTimeout(() => {
209
- ctx.debounceTimer = null;
210
- void fireRefresh();
211
- }, delayMs);
212
- }
213
-
214
- function pushEvent(_path: string): void {
215
- if (stopped) return;
216
- const t = now();
217
- ctx.recent.push(t);
218
-
219
- // Hard-timeout: from the FIRST event in the current burst.
220
- if (ctx.hardTimeoutAt === null) {
221
- ctx.hardTimeoutAt = t + cfg.hardTimeoutMs;
222
- }
223
-
224
- // Already in storm cooldown — do not reschedule, just accumulate.
225
- if (t < ctx.stormCooldownUntil) return;
226
-
227
- const intensity = detectStorm(t);
228
- let delay = cfg.debounceMs;
229
- if (intensity === 'storm') {
230
- delay = STORM_WAIT_MS;
231
- ctx.stormCooldownUntil = t + STORM_WAIT_MS;
232
- } else if (intensity === 'deep_storm') {
233
- delay = DEEP_STORM_WAIT_MS;
234
- ctx.stormCooldownUntil = t + DEEP_STORM_WAIT_MS;
235
- }
236
-
237
- // Hard-timeout floor: if we've been debouncing past the budget, fire now.
238
- if (ctx.hardTimeoutAt !== null && t >= ctx.hardTimeoutAt) {
239
- clearDebounce();
240
- void fireRefresh();
241
- return;
242
- }
243
-
244
- scheduleDebounce(delay);
245
- }
246
-
247
- async function forceReconciliation(): Promise<void> {
248
- ctx.reconciliationPending = false;
249
- clearDebounce();
250
- await fireRefresh();
251
- }
252
-
253
- async function flushNow(): Promise<void> {
254
- clearDebounce();
255
- ctx.stormCooldownUntil = 0;
256
- await fireRefresh();
257
- }
258
-
259
- async function stop(): Promise<void> {
260
- stopped = true;
261
- clearDebounce();
262
- if (tickTimer) {
263
- clearInterval(tickTimer);
264
- tickTimer = null;
265
- }
266
- if (watcher) {
267
- await watcher.close();
268
- watcher = null;
269
- }
270
- // Iter-6 SIGINT graceful-shutdown: ideally we would `await` an in-flight
271
- // fireRefresh here so SIGINT/SIGTERM doesn't cut a refresh mid-write.
272
- // However, three forces work in the OPPOSITE direction:
273
- // 1. Every file op the refresh issues is already atomic-rename-safe:
274
- // `runConfigRefresh` writes `<path>.tmp` then `renameSync`;
275
- // `installAll` writes `<path>.tmp` then `renameSync`; `updateState`
276
- // uses `writeStateAtomic` (tmp + fsync + rename); `appendRefreshLog`
277
- // is JSONL-append (partial trailing line tolerated by readers).
278
- // 2. If a refresh is interrupted partway through `installAll`, the next
279
- // run completes the remainder — that's by design.
280
- // 3. The `installAll.lock` (proper-lockfile) ensures another caller
281
- // arriving during a partial-completion can't race against the
282
- // original writer's process.
283
- // Plus: a Promise-tracking implementation chained to fireRefresh adds
284
- // microtasks that interact poorly with vitest's `advanceTimersByTimeAsync`
285
- // in tests where mock `onQuiescent` returns a forever-pending promise
286
- // (the iter-2 deferred-fire test breaks). A polling implementation hangs
287
- // when fake timers freeze `Date.now()`. Both approaches were tried in
288
- // iter-6 and rejected.
289
- // Decision: rely on per-file atomic-rename + the install-lock. Document
290
- // the residual SIGINT semantics in the spec doc so users understand a
291
- // mid-refresh kill leaves a partially-applied .claude/ that the next
292
- // run completes. The `stopped` guard in `scheduleDebounce` and
293
- // `pushEvent` still prevents NEW refreshes from being scheduled after
294
- // shutdown, which is the leak-prevention concern that IS reachable.
295
- }
296
-
297
- function tick(): void {
298
- if (stopped) return;
299
- const t = now();
300
- const gap = t - ctx.lastTickAt;
301
- ctx.lastTickAt = t;
302
- try {
303
- updateState(cfg.projectRoot, { tickedAt: new Date(t).toISOString() });
304
- } catch {
305
- // best-effort — never let tick failure crash the daemon.
306
- }
307
- if (gap > TICK_GAP_THRESHOLD_MS && !ctx.reconciliationPending) {
308
- ctx.reconciliationPending = true;
309
- writeStderr(`[massu] tick gap detected (${gap}ms, likely sleep/wake); reconciling\n`);
310
- void forceReconciliation();
311
- }
312
- }
313
-
314
- if (!hooks.noWatcher) {
315
- const cfgYaml = getConfig();
316
- const globs = deriveWatchGlobs(cfgYaml);
317
- if (globs.usedFallback) {
318
- writeStderr(`[massu] watching default globs (paths.*_source unset): ${globs.watch.join(', ')}\n`);
319
- }
320
- if (globs.rootWatchDetected) {
321
- writeStderr(
322
- `[massu] root sentinel ('.', '**', etc.) detected in source_dirs — ` +
323
- `effective scope is now 'full'. Surface cap still applies (set ` +
324
- `watch.paths_full_root_opt_in: true to override).\n`
325
- );
326
- }
327
-
328
- // Plan 3a hotfix 2026-05-02: preflight surface cap. Refuses to start
329
- // (throws WatchSurfaceTooLargeError) if the configured globs would
330
- // monitor more files than `watch.max_watched_files` AND the user has
331
- // not set `watch.paths_full_root_opt_in: true`. Prevents the misconfig
332
- // pattern that produced 30-100% sustained CPU on a large monorepo.
333
- const cap = cfgYaml.watch?.max_watched_files ?? 10_000;
334
- const optedIn = cfgYaml.watch?.paths_full_root_opt_in ?? false;
335
- const t0 = now();
336
- const surfaceCount = await enforceWatchSurfaceCap(globs, cfg.projectRoot, cap, optedIn);
337
- const surfaceMs = now() - t0;
338
- const countLabel = surfaceCount === Infinity ? `>${cap}` : String(surfaceCount);
339
- writeStderr(
340
- `[massu] watch surface: ${countLabel} files (cap ${cap}, ` +
341
- `opted-in: ${optedIn}, scan ${surfaceMs}ms, scope: ${globs.effectiveScope})\n`
342
- );
343
-
344
- watcher = chokidar.watch(globs.watch, {
345
- cwd: cfg.projectRoot,
346
- ignored: globs.ignore,
347
- ignoreInitial: true,
348
- persistent: true,
349
- awaitWriteFinish: false,
350
- });
351
- watcher.on('all', (_event: string, path: string) => pushEvent(path));
352
- watcher.on('error', (err: unknown) => {
353
- const msg = err instanceof Error ? err.message : String(err);
354
- writeStderr(`[massu] chokidar error: ${msg}\n`);
355
- // Persist so `massu watch --status` surfaces it; never let a state
356
- // write throw out of the error handler (best-effort).
357
- try {
358
- updateState(cfg.projectRoot, { lastError: `chokidar: ${msg}` });
359
- } catch {
360
- // best-effort
361
- }
362
- });
363
- }
364
-
365
- // Reset cached config so Layer-B picks up watch.* tunables changed at runtime.
366
- resetConfig();
367
-
368
- // Persist startup state so refresh-log + status subcommands can read it.
369
- try {
370
- updateState(cfg.projectRoot, {
371
- daemonPid: process.pid,
372
- startedAt: new Date(now()).toISOString(),
373
- tickedAt: new Date(now()).toISOString(),
374
- lastError: null,
375
- });
376
- } catch {
377
- // best-effort
378
- }
379
-
380
- tickTimer = setInterval(tick, TICK_INTERVAL_MS);
381
- // Don't keep the event loop alive solely for this timer.
382
- if (typeof tickTimer.unref === 'function') tickTimer.unref();
383
-
384
- return { pushEvent, flushNow, stop, forceReconciliation };
385
- }
@@ -1,65 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * Lockfile mid-write detector.
6
- *
7
- * If a `*.lock` file's mtime has shifted within the last LOCKFILE_WINDOW_MS,
8
- * a writer is still active and the watcher should defer applying refresh
9
- * for another debounce cycle.
10
- *
11
- * This is the canonical "wait for writer to settle" hook (chokidar's
12
- * `awaitWriteFinish` is intentionally disabled — see watcher spec §2).
13
- */
14
-
15
- import { existsSync, statSync } from 'fs';
16
- import { resolve } from 'path';
17
-
18
- export const LOCKFILE_WINDOW_MS = 500;
19
-
20
- export const KNOWN_LOCKFILES = [
21
- 'package-lock.json',
22
- 'yarn.lock',
23
- 'pnpm-lock.yaml',
24
- 'bun.lockb',
25
- 'poetry.lock',
26
- 'Pipfile.lock',
27
- 'uv.lock',
28
- 'Cargo.lock',
29
- 'composer.lock',
30
- 'Gemfile.lock',
31
- 'mix.lock',
32
- 'go.sum',
33
- ] as const;
34
-
35
- /**
36
- * Returns true when at least one known lockfile under projectRoot has
37
- * been modified within `windowMs` of `now`. Used by the quiescence
38
- * detector to extend the debounce when a package manager is mid-write.
39
- */
40
- export function lockfileMidWrite(projectRoot: string, now = Date.now(), windowMs = LOCKFILE_WINDOW_MS): boolean {
41
- for (const lf of KNOWN_LOCKFILES) {
42
- const p = resolve(projectRoot, lf);
43
- if (!existsSync(p)) continue;
44
- try {
45
- const stat = statSync(p);
46
- const delta = now - stat.mtimeMs;
47
- if (delta >= 0 && delta < windowMs) return true;
48
- } catch {
49
- // Race with rename; treat as not-mid-write. Next debounce cycle re-checks.
50
- }
51
- }
52
- return false;
53
- }
54
-
55
- /**
56
- * Returns true when a git operation that must not be interrupted is active.
57
- * Watcher hard-stops (refuses to apply) until this returns false.
58
- */
59
- export function gitMidOperation(projectRoot: string): boolean {
60
- const sentinels = ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD', 'rebase-apply', 'rebase-merge'];
61
- for (const s of sentinels) {
62
- if (existsSync(resolve(projectRoot, '.git', s))) return true;
63
- }
64
- return false;
65
- }