@massu/core 1.2.1 → 1.4.0-soak.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 (61) hide show
  1. package/README.md +40 -0
  2. package/commands/README.md +137 -0
  3. package/commands/massu-deploy.python-docker.md +170 -0
  4. package/commands/massu-deploy.python-fly.md +189 -0
  5. package/commands/massu-deploy.python-launchd.md +144 -0
  6. package/commands/massu-deploy.python-systemd.md +163 -0
  7. package/commands/massu-deploy.python.md +200 -0
  8. package/commands/massu-scaffold-page.md +172 -59
  9. package/commands/massu-scaffold-page.swift.md +121 -0
  10. package/commands/massu-scaffold-router.python-django.md +153 -0
  11. package/commands/massu-scaffold-router.python-fastapi.md +145 -0
  12. package/commands/massu-scaffold-router.python.md +143 -0
  13. package/dist/cli.js +10170 -4138
  14. package/dist/hooks/auto-learning-pipeline.js +44 -6
  15. package/dist/hooks/classify-failure.js +44 -6
  16. package/dist/hooks/cost-tracker.js +44 -6
  17. package/dist/hooks/fix-detector.js +44 -6
  18. package/dist/hooks/incident-pipeline.js +44 -6
  19. package/dist/hooks/post-edit-context.js +44 -6
  20. package/dist/hooks/post-tool-use.js +44 -6
  21. package/dist/hooks/pre-compact.js +44 -6
  22. package/dist/hooks/pre-delete-check.js +44 -6
  23. package/dist/hooks/quality-event.js +44 -6
  24. package/dist/hooks/rule-enforcement-pipeline.js +44 -6
  25. package/dist/hooks/session-end.js +44 -6
  26. package/dist/hooks/session-start.js +4789 -410
  27. package/dist/hooks/user-prompt.js +44 -6
  28. package/package.json +10 -4
  29. package/src/cli.ts +28 -2
  30. package/src/commands/config-refresh.ts +88 -20
  31. package/src/commands/init.ts +130 -23
  32. package/src/commands/install-commands.ts +482 -42
  33. package/src/commands/refresh-log.ts +37 -0
  34. package/src/commands/show-template.ts +65 -0
  35. package/src/commands/template-engine.ts +262 -0
  36. package/src/commands/watch.ts +430 -0
  37. package/src/config.ts +69 -3
  38. package/src/detect/adapters/nextjs-trpc.ts +166 -0
  39. package/src/detect/adapters/parse-guard.ts +133 -0
  40. package/src/detect/adapters/python-django.ts +208 -0
  41. package/src/detect/adapters/python-fastapi.ts +223 -0
  42. package/src/detect/adapters/query-helpers.ts +170 -0
  43. package/src/detect/adapters/runner.ts +252 -0
  44. package/src/detect/adapters/swift-swiftui.ts +171 -0
  45. package/src/detect/adapters/tree-sitter-loader.ts +348 -0
  46. package/src/detect/adapters/types.ts +174 -0
  47. package/src/detect/codebase-introspector.ts +190 -0
  48. package/src/detect/index.ts +28 -2
  49. package/src/detect/regex-fallback.ts +449 -0
  50. package/src/hooks/session-start.ts +94 -3
  51. package/src/lib/gitToplevel.ts +22 -0
  52. package/src/lib/installLock.ts +179 -0
  53. package/src/lib/pidLiveness.ts +67 -0
  54. package/src/lsp/auto-detect.ts +89 -0
  55. package/src/lsp/client.ts +590 -0
  56. package/src/lsp/enrich.ts +127 -0
  57. package/src/lsp/types.ts +221 -0
  58. package/src/watch/daemon.ts +385 -0
  59. package/src/watch/lockfile-detector.ts +65 -0
  60. package/src/watch/paths.ts +279 -0
  61. package/src/watch/state.ts +178 -0
@@ -8,12 +8,36 @@
8
8
  * Copies all massu assets from the npm package into the project's .claude/
9
9
  * directory. Existing massu files are updated; non-massu files are preserved.
10
10
  * Handles subdirectories recursively (e.g., golden-path/references/).
11
+ *
12
+ * v1.3.0 — Stack-aware variants + local-edit protection (manifest):
13
+ * - Variant resolution at the top level of `commands/`: a template named
14
+ * `<base>.<variant>.md` is preferred over `<base>.md` when the consumer's
15
+ * `massu.config.yaml` declares a matching language. See `pickVariant`.
16
+ * - Local edits are preserved across reinstalls via a per-consumer manifest
17
+ * (`<claudeDir>/.massu/install-manifest.json`) that records the SHA-256 of
18
+ * each file at last install. See "Layer 3: Local-edit protection" in the
19
+ * 2026-04-26 plan doc.
11
20
  */
12
21
 
13
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
22
+ import {
23
+ closeSync,
24
+ existsSync,
25
+ fsyncSync,
26
+ openSync,
27
+ readFileSync,
28
+ rmSync,
29
+ writeSync,
30
+ mkdirSync,
31
+ readdirSync,
32
+ statSync,
33
+ renameSync,
34
+ } from 'fs';
14
35
  import { resolve, dirname, relative, join } from 'path';
15
36
  import { fileURLToPath } from 'url';
37
+ import { createHash } from 'crypto';
16
38
  import { getConfig } from '../config.ts';
39
+ import type { Config } from '../config.ts';
40
+ import { renderTemplate, MissingVariableError, TemplateParseError } from './template-engine.ts';
17
41
 
18
42
  const __filename = fileURLToPath(import.meta.url);
19
43
  const __dirname = dirname(__filename);
@@ -68,6 +92,253 @@ export function resolveCommandsDir(): string | null {
68
92
  return resolveAssetDir('commands');
69
93
  }
70
94
 
95
+ // ============================================================
96
+ // Manifest (local-edit protection)
97
+ // ============================================================
98
+
99
+ const MANIFEST_VERSION = 1;
100
+ const MANIFEST_RELPATH = join('.massu', 'install-manifest.json');
101
+
102
+ /** Manifest file shape — see plan §"Manifest JSON shape". */
103
+ export interface Manifest {
104
+ version: number;
105
+ generatedBy: string;
106
+ generatedAt: string;
107
+ /** key: path relative to the consumer's claudeDir; value: SHA-256 hex digest. */
108
+ entries: Record<string, string>;
109
+ }
110
+
111
+ /** SHA-256 hex digest of a string. */
112
+ export function hashContent(content: string): string {
113
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
114
+ }
115
+
116
+ /** Load the manifest from `<claudeDir>/.massu/install-manifest.json`, or return an empty manifest. */
117
+ export function loadManifest(claudeDir: string): Manifest {
118
+ const path = resolve(claudeDir, MANIFEST_RELPATH);
119
+ if (!existsSync(path)) {
120
+ return emptyManifest();
121
+ }
122
+ try {
123
+ const raw = readFileSync(path, 'utf-8');
124
+ const parsed = JSON.parse(raw) as Manifest;
125
+ if (!parsed || typeof parsed !== 'object' || !parsed.entries) {
126
+ return emptyManifest();
127
+ }
128
+ return parsed;
129
+ } catch {
130
+ return emptyManifest();
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Iter-7 fix: atomic file write — tmp + fsync + rename.
136
+ *
137
+ * Plan 3a §3 Risk #4 ("Watcher writes to .claude/ while editor has it open:
138
+ * editors can lose changes. Mitigation: write to .massu-tmp then atomic rename")
139
+ * AND the watcher spec doc §3 Shutdown Semantics claim ("every file op the
140
+ * refresh issues is already atomic-rename-safe... installAll writes <path>.tmp
141
+ * then renameSync") both demand this. Previously installAll's per-file writes
142
+ * (lines 463/467) and saveManifest were direct `writeFileSync` calls — a
143
+ * SIGINT/power-loss between truncate and complete-write left a partial file.
144
+ * Now both go through this helper so the watcher's iter-6 "we don't await
145
+ * fireRefresh because atomic-rename covers everything" decision is sound.
146
+ *
147
+ * Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
148
+ * data hits the platter before the rename. On any error, removes the tmp file.
149
+ * Tmp filename includes process.pid to avoid clashes with concurrent installs
150
+ * from sibling processes (e.g. a manual `npx massu config refresh` racing the
151
+ * watcher daemon — the install-lock should prevent this, but the file-level
152
+ * tmp name disambiguates if it ever happens).
153
+ */
154
+ function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
155
+ const tmpPath = `${targetPath}.${process.pid}.tmp`;
156
+ try {
157
+ const fd = openSync(tmpPath, 'w', mode);
158
+ try {
159
+ const buf = Buffer.from(content, 'utf-8');
160
+ writeSync(fd, buf, 0, buf.length, 0);
161
+ fsyncSync(fd);
162
+ } finally {
163
+ closeSync(fd);
164
+ }
165
+ renameSync(tmpPath, targetPath);
166
+ } catch (err) {
167
+ if (existsSync(tmpPath)) {
168
+ try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
169
+ }
170
+ throw err;
171
+ }
172
+ }
173
+
174
+ /** Write the manifest atomically: tempfile + fsync + renameSync. */
175
+ export function saveManifest(claudeDir: string, manifest: Manifest): void {
176
+ const dir = resolve(claudeDir, '.massu');
177
+ if (!existsSync(dir)) {
178
+ mkdirSync(dir, { recursive: true });
179
+ }
180
+ const finalPath = resolve(dir, 'install-manifest.json');
181
+ manifest.generatedAt = new Date().toISOString();
182
+ atomicWriteFile(finalPath, JSON.stringify(manifest, null, 2));
183
+ }
184
+
185
+ function emptyManifest(): Manifest {
186
+ return {
187
+ version: MANIFEST_VERSION,
188
+ generatedBy: '@massu/core',
189
+ generatedAt: new Date().toISOString(),
190
+ entries: {},
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Run a function with the manifest loaded; persist atomically afterward.
196
+ * Used by both `installAll` and the legacy `installCommands` so any caller
197
+ * of either entry point gets the manifest written exactly once per run.
198
+ */
199
+ export function runWithManifest<T>(claudeDir: string, fn: (m: Manifest) => T): T {
200
+ const manifest = loadManifest(claudeDir);
201
+ const result = fn(manifest);
202
+ saveManifest(claudeDir, manifest);
203
+ return result;
204
+ }
205
+
206
+ // ============================================================
207
+ // Variant Resolution (Phase 1)
208
+ // ============================================================
209
+
210
+ /** Discriminated-union return shape for `pickVariant`. */
211
+ export type PickVariantResult =
212
+ | { kind: 'hit'; suffix: string } // found a variant (suffix may be "")
213
+ | { kind: 'miss' } // no candidate found, caller SKIPS the file
214
+ | { kind: 'fallback'; reason: string }; // misconfig / safe fallback, caller copies UNSUFFIXED default
215
+
216
+ /** Well-known language keys for the passthrough-fallback step in `pickVariant`. */
217
+ const PASSTHROUGH_LANG_KEYS = [
218
+ 'typescript',
219
+ 'javascript',
220
+ 'python',
221
+ 'swift',
222
+ 'rust',
223
+ 'go',
224
+ ] as const;
225
+
226
+ /**
227
+ * Choose the variant suffix for a base template name.
228
+ *
229
+ * Two-axis priority (Plan #2 P2-001 extends Plan #1's lang-only axis):
230
+ * For each language `L` in priority order (primary, languages.*, passthrough.*):
231
+ * a. If a sub-framework `F` is declared for L, probe `<base>.<L>-<F>.md`.
232
+ * b. Probe `<base>.<L>.md` (lang-only fallback).
233
+ * Then probe the unsuffixed `<base>.md`.
234
+ *
235
+ * Priority order for the language list (Plan #1):
236
+ * 1. `framework.primary` (or `framework.type` if primary undefined). With the
237
+ * sub-framework axis, the candidate framework is the matching
238
+ * `framework.languages[primary].framework` if present, else `framework.router`
239
+ * / `framework.orm` / `framework.ui` heuristics, else just lang-only.
240
+ * 2. Each declared `framework.languages.<lang>` entry with a non-empty `framework`,
241
+ * in YAML declaration order. Sub-framework = `entry.framework`.
242
+ * 3. Passthrough fallback: well-known top-level `framework.<lang>` blocks with a
243
+ * non-empty `framework` field, in fixed order, excluding entries already covered.
244
+ * Sub-framework = top-level block's `framework` field.
245
+ * 4. The unsuffixed default ("").
246
+ *
247
+ * The function NEVER throws. It returns a discriminated union so the caller can
248
+ * distinguish "skip this file" from "copy the default" — see plan §"Error semantics".
249
+ */
250
+ export function pickVariant(
251
+ baseName: string,
252
+ sourceDir: string,
253
+ framework: Config['framework'],
254
+ ): PickVariantResult {
255
+ // Build (lang, subFramework) candidate pairs in priority order. Sub-framework
256
+ // can be undefined — in that case only the lang-only axis is probed for that
257
+ // language.
258
+ type Candidate = { lang: string; subFramework?: string };
259
+ const candidates: Candidate[] = [];
260
+ const seenLangs = new Set<string>();
261
+
262
+ function pushCandidate(lang: string, sub: string | undefined): void {
263
+ if (seenLangs.has(lang)) return;
264
+ seenLangs.add(lang);
265
+ candidates.push({ lang, subFramework: sub && sub.length > 0 ? sub : undefined });
266
+ }
267
+
268
+ // 1. framework.primary (or fall back to framework.type)
269
+ const primary = framework.primary ?? framework.type;
270
+ if (primary && primary !== 'multi') {
271
+ // Best-effort sub-framework detection for the primary lang:
272
+ // - If `framework.languages[primary]` has a `framework`, use that.
273
+ // - Else, fall back to top-level passthrough `framework[primary].framework`.
274
+ let primarySub: string | undefined;
275
+ if (framework.languages && framework.languages[primary]?.framework) {
276
+ primarySub = framework.languages[primary].framework;
277
+ } else {
278
+ const passthrough = framework as unknown as Record<string, unknown>;
279
+ const block = passthrough[primary];
280
+ if (block && typeof block === 'object') {
281
+ const fw = (block as { framework?: unknown }).framework;
282
+ if (typeof fw === 'string' && fw.length > 0) primarySub = fw;
283
+ }
284
+ }
285
+ pushCandidate(primary, primarySub);
286
+ }
287
+
288
+ // 2. framework.languages declaration order
289
+ if (framework.languages) {
290
+ for (const lang of Object.keys(framework.languages)) {
291
+ const entry = framework.languages[lang];
292
+ if (entry && typeof entry.framework === 'string' && entry.framework.length > 0) {
293
+ pushCandidate(lang, entry.framework);
294
+ }
295
+ }
296
+ }
297
+
298
+ // 3. Passthrough fallback — `framework.<lang>` (top-level passthrough block).
299
+ const passthrough = framework as unknown as Record<string, unknown>;
300
+ for (const lang of PASSTHROUGH_LANG_KEYS) {
301
+ if (seenLangs.has(lang)) continue;
302
+ const block = passthrough[lang];
303
+ if (block && typeof block === 'object') {
304
+ const fw = (block as { framework?: unknown }).framework;
305
+ if (typeof fw === 'string' && fw.length > 0) {
306
+ pushCandidate(lang, fw);
307
+ }
308
+ }
309
+ }
310
+
311
+ // 4. Probe disk — for each (lang, sub) pair, try lang-sub first, then lang-only.
312
+ for (const cand of candidates) {
313
+ if (cand.subFramework) {
314
+ const subPath = resolve(sourceDir, `${baseName}.${cand.lang}-${cand.subFramework}.md`);
315
+ if (existsSync(subPath)) {
316
+ return { kind: 'hit', suffix: `.${cand.lang}-${cand.subFramework}` };
317
+ }
318
+ }
319
+ const langPath = resolve(sourceDir, `${baseName}.${cand.lang}.md`);
320
+ if (existsSync(langPath)) {
321
+ return { kind: 'hit', suffix: `.${cand.lang}` };
322
+ }
323
+ }
324
+ // Unsuffixed default
325
+ const defaultPath = resolve(sourceDir, `${baseName}.md`);
326
+ if (existsSync(defaultPath)) {
327
+ return { kind: 'hit', suffix: '' };
328
+ }
329
+
330
+ // No hit. Risk #7: framework.type=multi without primary → safe fallback.
331
+ if (framework.type === 'multi' && !framework.primary) {
332
+ process.stderr.write(
333
+ 'massu: warning - framework.type=multi but framework.primary is undefined; ' +
334
+ 'falling back to default templates\n',
335
+ );
336
+ return { kind: 'fallback', reason: 'multi-without-primary' };
337
+ }
338
+
339
+ return { kind: 'miss' };
340
+ }
341
+
71
342
  // ============================================================
72
343
  // Recursive File Sync
73
344
  // ============================================================
@@ -76,14 +347,37 @@ interface SyncStats {
76
347
  installed: number;
77
348
  updated: number;
78
349
  skipped: number;
350
+ kept: number;
351
+ }
352
+
353
+ /** Returns true if a top-level entry name has the `<base>.<variant>.md` shape. */
354
+ function isVariantFilename(entry: string): boolean {
355
+ // Match exactly one inner dot before `.md`. `_shared-preamble.md` (no inner dot) survives.
356
+ return /^[^.]+\.[^.]+\.md$/.test(entry);
79
357
  }
80
358
 
81
359
  /**
82
360
  * Recursively sync all .md files from sourceDir to targetDir.
83
- * Creates subdirectories as needed. Preserves non-massu files.
361
+ *
362
+ * At top level (`topLevel === true`), apply variant resolution:
363
+ * - Skip entries that match `<base>.<variant>.md` (the variant siblings are
364
+ * selected indirectly via `pickVariant` so they never land in the consumer
365
+ * dir directly).
366
+ * - For each base entry `<base>.md`, call `pickVariant` to choose the source.
367
+ *
368
+ * At depth ≥ 1 (subdirectory recursion), copy files as-is — no variant logic,
369
+ * no dot-skip filter (so future authors can use dotted filenames in subdirs).
84
370
  */
85
- function syncDirectory(sourceDir: string, targetDir: string): SyncStats {
86
- const stats: SyncStats = { installed: 0, updated: 0, skipped: 0 };
371
+ export function syncDirectory(
372
+ sourceDir: string,
373
+ targetDir: string,
374
+ framework: Config['framework'],
375
+ manifest: Manifest,
376
+ manifestKeyPrefix: string,
377
+ topLevel: boolean = true,
378
+ templateVars: Record<string, unknown> = {},
379
+ ): SyncStats {
380
+ const stats: SyncStats = { installed: 0, updated: 0, skipped: 0, kept: 0 };
87
381
 
88
382
  if (!existsSync(targetDir)) {
89
383
  mkdirSync(targetDir, { recursive: true });
@@ -93,30 +387,128 @@ function syncDirectory(sourceDir: string, targetDir: string): SyncStats {
93
387
 
94
388
  for (const entry of entries) {
95
389
  const sourcePath = resolve(sourceDir, entry);
96
- const targetPath = resolve(targetDir, entry);
97
390
  const entryStat = statSync(sourcePath);
98
391
 
99
392
  if (entryStat.isDirectory()) {
100
- // Recurse into subdirectories
101
- const subStats = syncDirectory(sourcePath, targetPath);
393
+ // Recurse depth > 0 disables variant filtering for nested files.
394
+ const subTargetDir = resolve(targetDir, entry);
395
+ const subPrefix = manifestKeyPrefix === ''
396
+ ? entry
397
+ : `${manifestKeyPrefix}/${entry}`;
398
+ const subStats = syncDirectory(
399
+ sourcePath,
400
+ subTargetDir,
401
+ framework,
402
+ manifest,
403
+ subPrefix,
404
+ false,
405
+ templateVars,
406
+ );
102
407
  stats.installed += subStats.installed;
103
408
  stats.updated += subStats.updated;
104
409
  stats.skipped += subStats.skipped;
105
- } else if (entry.endsWith('.md')) {
106
- const sourceContent = readFileSync(sourcePath, 'utf-8');
107
-
108
- if (existsSync(targetPath)) {
109
- const existingContent = readFileSync(targetPath, 'utf-8');
110
- if (existingContent === sourceContent) {
111
- stats.skipped++;
112
- continue;
113
- }
114
- writeFileSync(targetPath, sourceContent, 'utf-8');
115
- stats.updated++;
116
- } else {
117
- writeFileSync(targetPath, sourceContent, 'utf-8');
118
- stats.installed++;
410
+ stats.kept += subStats.kept;
411
+ continue;
412
+ }
413
+
414
+ if (!entry.endsWith('.md')) continue;
415
+
416
+ let sourceFilename = entry;
417
+ let baseName = entry.slice(0, -'.md'.length);
418
+
419
+ if (topLevel) {
420
+ // Skip variant siblings — they are selected indirectly via the base name.
421
+ if (isVariantFilename(entry)) continue;
422
+
423
+ const choice = pickVariant(baseName, sourceDir, framework);
424
+ if (choice.kind === 'miss') {
425
+ // No file to copy.
426
+ continue;
119
427
  }
428
+ // 'hit' or 'fallback' both copy a file:
429
+ // - 'hit' uses the chosen suffix (may be "")
430
+ // - 'fallback' copies the unsuffixed default (same as suffix === "")
431
+ const suffix = choice.kind === 'hit' ? choice.suffix : '';
432
+ sourceFilename = suffix === '' ? `${baseName}.md` : `${baseName}${suffix}.md`;
433
+ }
434
+
435
+ const resolvedSourcePath = resolve(sourceDir, sourceFilename);
436
+ if (!existsSync(resolvedSourcePath)) {
437
+ // Defensive: pickVariant said hit, but file vanished between probe and read.
438
+ continue;
439
+ }
440
+
441
+ // Target filename is always the BASE name (variant suffix is internal to the package).
442
+ const targetFilename = topLevel ? `${baseName}.md` : entry;
443
+ const targetPath = resolve(targetDir, targetFilename);
444
+ const rawContent = readFileSync(resolvedSourcePath, 'utf-8');
445
+
446
+ // Plan #2 P1-003: render any `{{var}}` substitutions BEFORE hashing so
447
+ // the manifest entry hash matches the byte-stream that lands on disk.
448
+ // Engine errors (missing var, malformed token) fail this single file but
449
+ // never abort the whole install — see spec §"Error semantics".
450
+ let sourceContent: string;
451
+ try {
452
+ sourceContent = renderTemplate(rawContent, templateVars);
453
+ } catch (err) {
454
+ if (err instanceof MissingVariableError || err instanceof TemplateParseError) {
455
+ process.stderr.write(
456
+ `massu: skipping ${resolvedSourcePath}: ${err.message}\n`,
457
+ );
458
+ stats.skipped++;
459
+ continue;
460
+ }
461
+ throw err;
462
+ }
463
+ const sourceHash = hashContent(sourceContent);
464
+
465
+ const manifestKey = manifestKeyPrefix === ''
466
+ ? targetFilename
467
+ : `${manifestKeyPrefix}/${targetFilename}`;
468
+ const lastInstalledHash = manifest.entries[manifestKey];
469
+
470
+ if (existsSync(targetPath)) {
471
+ const existingContent = readFileSync(targetPath, 'utf-8');
472
+ const existingHash = hashContent(existingContent);
473
+
474
+ if (existingHash === sourceHash) {
475
+ // Already byte-identical to upstream. Ensure manifest reflects that.
476
+ manifest.entries[manifestKey] = sourceHash;
477
+ stats.skipped++;
478
+ continue;
479
+ }
480
+
481
+ if (lastInstalledHash === undefined) {
482
+ // First-install ambiguity: file exists but no manifest entry.
483
+ // Treat as user-edited: keep, record existing hash, print one-line notice.
484
+ manifest.entries[manifestKey] = existingHash;
485
+ process.stderr.write(
486
+ `First-install heuristic: keeping existing ${targetPath} (differs from upstream).\n` +
487
+ ` To accept upstream: rm ${targetPath} && npx massu install-commands\n`,
488
+ );
489
+ stats.kept++;
490
+ continue;
491
+ }
492
+
493
+ if (existingHash !== lastInstalledHash) {
494
+ // User edited it after the last install. Preserve.
495
+ process.stderr.write(
496
+ `${targetFilename} has local edits - kept your version.\n` +
497
+ ` To accept upstream: rm ${targetPath} && npx massu install-commands\n` +
498
+ ` To diff: diff ${targetPath} <(npx massu show-template ${baseName})\n`,
499
+ );
500
+ stats.kept++;
501
+ continue;
502
+ }
503
+
504
+ // existingHash === lastInstalledHash and sourceHash differs → safe upgrade.
505
+ atomicWriteFile(targetPath, sourceContent);
506
+ manifest.entries[manifestKey] = sourceHash;
507
+ stats.updated++;
508
+ } else {
509
+ atomicWriteFile(targetPath, sourceContent);
510
+ manifest.entries[manifestKey] = sourceHash;
511
+ stats.installed++;
120
512
  }
121
513
  }
122
514
 
@@ -131,12 +523,28 @@ export interface InstallCommandsResult {
131
523
  installed: number;
132
524
  updated: number;
133
525
  skipped: number;
526
+ kept: number;
134
527
  commandsDir: string;
135
528
  }
136
529
 
530
+ /**
531
+ * Build the variable scope passed to the templating engine.
532
+ * See spec §"Variable scope passed to the engine" for the contract.
533
+ */
534
+ export function buildTemplateVars(): Record<string, unknown> {
535
+ const config = getConfig();
536
+ return {
537
+ framework: config.framework,
538
+ paths: config.paths,
539
+ detected: config.detected ?? {},
540
+ config,
541
+ };
542
+ }
543
+
137
544
  export function installCommands(projectRoot: string): InstallCommandsResult {
138
545
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
139
- const targetDir = resolve(projectRoot, claudeDirName, 'commands');
546
+ const claudeDir = resolve(projectRoot, claudeDirName);
547
+ const targetDir = resolve(claudeDir, 'commands');
140
548
 
141
549
  if (!existsSync(targetDir)) {
142
550
  mkdirSync(targetDir, { recursive: true });
@@ -146,10 +554,14 @@ export function installCommands(projectRoot: string): InstallCommandsResult {
146
554
  if (!sourceDir) {
147
555
  console.error(' ERROR: Could not find massu commands directory.');
148
556
  console.error(' Try reinstalling: npm install @massu/core');
149
- return { installed: 0, updated: 0, skipped: 0, commandsDir: targetDir };
557
+ return { installed: 0, updated: 0, skipped: 0, kept: 0, commandsDir: targetDir };
150
558
  }
151
559
 
152
- const stats = syncDirectory(sourceDir, targetDir);
560
+ const framework = getConfig().framework;
561
+ const templateVars = buildTemplateVars();
562
+ const stats = runWithManifest(claudeDir, (manifest) =>
563
+ syncDirectory(sourceDir, targetDir, framework, manifest, 'commands', true, templateVars),
564
+ );
153
565
  return { ...stats, commandsDir: targetDir };
154
566
  }
155
567
 
@@ -162,6 +574,7 @@ export interface InstallAllResult {
162
574
  totalInstalled: number;
163
575
  totalUpdated: number;
164
576
  totalSkipped: number;
577
+ totalKept: number;
165
578
  claudeDir: string;
166
579
  }
167
580
 
@@ -173,23 +586,43 @@ export function installAll(projectRoot: string): InstallAllResult {
173
586
  let totalInstalled = 0;
174
587
  let totalUpdated = 0;
175
588
  let totalSkipped = 0;
176
-
177
- for (const assetType of ASSET_TYPES) {
178
- const sourceDir = resolveAssetDir(assetType.name);
179
- if (!sourceDir) {
180
- continue;
589
+ let totalKept = 0;
590
+
591
+ const framework = getConfig().framework;
592
+ const templateVars = buildTemplateVars();
593
+
594
+ runWithManifest(claudeDir, (manifest) => {
595
+ for (const assetType of ASSET_TYPES) {
596
+ const sourceDir = resolveAssetDir(assetType.name);
597
+ if (!sourceDir) continue;
598
+
599
+ const targetDir = resolve(claudeDir, assetType.targetSubdir);
600
+ const stats = syncDirectory(
601
+ sourceDir,
602
+ targetDir,
603
+ framework,
604
+ manifest,
605
+ assetType.targetSubdir,
606
+ true,
607
+ templateVars,
608
+ );
609
+
610
+ assets[assetType.name] = stats;
611
+ totalInstalled += stats.installed;
612
+ totalUpdated += stats.updated;
613
+ totalSkipped += stats.skipped;
614
+ totalKept += stats.kept;
181
615
  }
182
-
183
- const targetDir = resolve(claudeDir, assetType.targetSubdir);
184
- const stats = syncDirectory(sourceDir, targetDir);
185
-
186
- assets[assetType.name] = stats;
187
- totalInstalled += stats.installed;
188
- totalUpdated += stats.updated;
189
- totalSkipped += stats.skipped;
190
- }
191
-
192
- return { assets, totalInstalled, totalUpdated, totalSkipped, claudeDir };
616
+ });
617
+
618
+ return {
619
+ assets,
620
+ totalInstalled,
621
+ totalUpdated,
622
+ totalSkipped,
623
+ totalKept,
624
+ claudeDir,
625
+ };
193
626
  }
194
627
 
195
628
  // ============================================================
@@ -212,21 +645,28 @@ export async function runInstallCommands(): Promise<void> {
212
645
  if (!stats) {
213
646
  continue;
214
647
  }
215
- const total = stats.installed + stats.updated + stats.skipped;
648
+ const total = stats.installed + stats.updated + stats.skipped + stats.kept;
216
649
  if (total === 0) continue;
217
650
 
218
651
  const parts: string[] = [];
219
652
  if (stats.installed > 0) parts.push(`${stats.installed} new`);
220
653
  if (stats.updated > 0) parts.push(`${stats.updated} updated`);
221
654
  if (stats.skipped > 0) parts.push(`${stats.skipped} current`);
655
+ if (stats.kept > 0) parts.push(`${stats.kept} kept (local edits)`);
222
656
 
223
657
  const description = assetType.description;
224
658
  console.log(` ${description}: ${parts.join(', ')} (${total} total)`);
225
659
  }
226
660
 
227
- const grandTotal = result.totalInstalled + result.totalUpdated + result.totalSkipped;
661
+ const grandTotal =
662
+ result.totalInstalled + result.totalUpdated + result.totalSkipped + result.totalKept;
228
663
  console.log('');
229
664
  console.log(` ${grandTotal} total files synced to ${result.claudeDir}`);
665
+ if (result.totalKept > 0) {
666
+ console.log(
667
+ ` ${result.totalKept} file(s) had local edits and were preserved (see stderr above).`,
668
+ );
669
+ }
230
670
  console.log('');
231
671
  console.log(' Restart your Claude Code session to use them.');
232
672
  console.log('');
@@ -0,0 +1,37 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu refresh-log [N]` — show the last N watcher auto-refresh events.
6
+ *
7
+ * Library discipline: returns a result object; cli.ts owns process.exit.
8
+ */
9
+
10
+ import { gitToplevel } from '../lib/gitToplevel.ts';
11
+ import { readRefreshLog } from './watch.ts';
12
+
13
+ export interface RefreshLogResult {
14
+ exitCode: 0 | 1;
15
+ message?: string;
16
+ }
17
+
18
+ export async function runRefreshLog(args: string[]): Promise<RefreshLogResult> {
19
+ const limitArg = args.find((a) => /^\d+$/.test(a));
20
+ const limit = limitArg ? Math.max(1, Math.min(1000, Number(limitArg))) : 10;
21
+ const root = gitToplevel(process.cwd());
22
+
23
+ const events = readRefreshLog(root, limit);
24
+ if (events.length === 0) {
25
+ process.stdout.write('massu refresh-log: no auto-refresh events recorded yet.\n');
26
+ return { exitCode: 0 };
27
+ }
28
+
29
+ for (const e of events) {
30
+ const from = e.fromFingerprint ? e.fromFingerprint.slice(0, 12) : 'init';
31
+ const to = e.toFingerprint.slice(0, 12);
32
+ process.stdout.write(
33
+ `${e.at} ${from} -> ${to} installed=${e.filesInstalled} updated=${e.filesUpdated} kept=${e.filesKept}\n`,
34
+ );
35
+ }
36
+ return { exitCode: 0 };
37
+ }