@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
@@ -252,6 +252,7 @@ var FrameworkConfigSchema = z.object({
252
252
  ui: z.string().default("none"),
253
253
  languages: z.record(z.string(), LanguageFrameworkEntrySchema).optional()
254
254
  }).passthrough();
255
+ var DetectedConfigSchema = z.object({}).passthrough().optional();
255
256
  var VerificationEntrySchema = z.object({
256
257
  type: z.string().optional(),
257
258
  test: z.string().optional(),
@@ -276,6 +277,31 @@ var DetectionConfigSchema = z.object({
276
277
  signal_weights: z.record(z.string(), z.number()).optional(),
277
278
  disable_builtin: z.boolean().optional()
278
279
  }).passthrough().optional();
280
+ var WatchConfigSchema = z.object({
281
+ debounce_ms: z.number().int().positive().default(3e3),
282
+ storm_threshold: z.number().int().positive().default(50),
283
+ deep_storm_threshold: z.number().int().positive().default(500),
284
+ hard_timeout_ms: z.number().int().positive().default(3e5),
285
+ scope: z.enum(["paths", "full"]).default("paths"),
286
+ // Plan 3a hotfix 2026-05-02: refuse to start if the watch surface
287
+ // exceeds this many files. Prevents the misconfig pattern where
288
+ // `paths.source_dirs` includes `.` or otherwise expands to a 60K+
289
+ // file tree, producing 30-100% steady CPU. Override via
290
+ // `paths_full_root_opt_in: true` for users on small repos who genuinely
291
+ // need root-level watching.
292
+ max_watched_files: z.number().int().positive().default(1e4),
293
+ paths_full_root_opt_in: z.boolean().default(false)
294
+ }).passthrough().optional();
295
+ var LSPConfigSchema = z.object({
296
+ enabled: z.boolean().default(false),
297
+ servers: z.array(z.object({
298
+ language: z.string(),
299
+ command: z.string()
300
+ })).default([]),
301
+ autoDetect: z.object({
302
+ viaPortScan: z.boolean().default(false)
303
+ }).optional()
304
+ }).passthrough();
279
305
  var RawConfigSchema = z.object({
280
306
  schema_version: z.union([z.literal(1), z.literal(2)]).default(1),
281
307
  project: z.object({
@@ -308,7 +334,13 @@ var RawConfigSchema = z.object({
308
334
  verification: VerificationConfigSchema,
309
335
  canonical_paths: CanonicalPathsSchema,
310
336
  verification_types: VerificationTypesSchema,
311
- detection: DetectionConfigSchema
337
+ detection: DetectionConfigSchema,
338
+ // Plan #2: detector-owned per-language conventions (free-form passthrough)
339
+ detected: DetectedConfigSchema,
340
+ // Plan 3a: file-watcher daemon tunables
341
+ watch: WatchConfigSchema,
342
+ // Plan 3b Phase 4: optional LSP enrichment of AST adapter results.
343
+ lsp: LSPConfigSchema.optional()
312
344
  }).passthrough();
313
345
  var _config = null;
314
346
  var _projectRoot = null;
@@ -385,13 +417,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
385
417
  name: parsed.project.name,
386
418
  root: projectRoot
387
419
  },
420
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
421
+ // `framework.python`) survive into the consumer-visible Config. Then override
422
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
423
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
424
+ // top-level passthrough language blocks.
388
425
  framework: {
389
- type: fw.type,
426
+ ...fw,
390
427
  router,
391
428
  orm,
392
- ui,
393
- primary: fw.primary,
394
- languages: fw.languages
429
+ ui
395
430
  },
396
431
  paths: parsed.paths,
397
432
  toolPrefix: parsed.toolPrefix,
@@ -412,7 +447,10 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
412
447
  verification: parsed.verification,
413
448
  canonical_paths: parsed.canonical_paths,
414
449
  verification_types: parsed.verification_types,
415
- detection: parsed.detection
450
+ detection: parsed.detection,
451
+ detected: parsed.detected,
452
+ watch: parsed.watch,
453
+ lsp: parsed.lsp
416
454
  };
417
455
  if (!_config.cloud?.apiKey && process.env.MASSU_API_KEY) {
418
456
  _config.cloud = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.2.1",
3
+ "version": "1.4.0-soak.0",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "src/server.ts",
@@ -11,20 +11,26 @@
11
11
  "start": "npx tsx src/server.ts",
12
12
  "test": "vitest run",
13
13
  "build": "tsc --noEmit && npm run build:cli && npm run build:hooks",
14
- "build:cli": "esbuild --bundle --platform=node --format=esm --outfile=dist/cli.js src/cli.ts --external:better-sqlite3 --external:yaml --external:zod --banner:js='#!/usr/bin/env node\nimport{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
15
- "build:hooks": "esbuild --bundle --platform=node --format=esm --outdir=dist/hooks src/hooks/*.ts --external:better-sqlite3 --external:yaml --external:zod --banner:js='import{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
16
- "prepublishOnly": "bash ../../scripts/prepublish-check.sh && npm run build"
14
+ "build:cli": "esbuild --bundle --platform=node --format=esm --outfile=dist/cli.js src/cli.ts --external:better-sqlite3 --external:yaml --external:zod --external:chokidar --external:proper-lockfile --external:fsevents --banner:js='#!/usr/bin/env node\nimport{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
15
+ "build:hooks": "esbuild --bundle --platform=node --format=esm --outdir=dist/hooks src/hooks/*.ts --external:better-sqlite3 --external:yaml --external:zod --external:chokidar --external:proper-lockfile --external:fsevents --banner:js='import{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
16
+ "prepublishOnly": "bash ../../scripts/prepublish-check.sh && npm run build",
17
+ "bench:watch": "tsx test/perf/watch-benchmark.ts"
17
18
  },
18
19
  "dependencies": {
19
20
  "@clack/prompts": "^0.9.1",
20
21
  "better-sqlite3": "^12.6.2",
22
+ "chokidar": "^3.6.0",
21
23
  "fast-glob": "^3.3.0",
24
+ "proper-lockfile": "^4.1.2",
22
25
  "smol-toml": "^1.3.0",
26
+ "vscode-languageserver-protocol": "^3.17.5",
27
+ "web-tree-sitter": "^0.26.8",
23
28
  "yaml": "^2.4.0",
24
29
  "zod": "^3.23.0"
25
30
  },
26
31
  "devDependencies": {
27
32
  "@types/better-sqlite3": "^7.6.13",
33
+ "@types/proper-lockfile": "^4.1.4",
28
34
  "esbuild": "^0.27.3",
29
35
  "tsx": "^4.0.0",
30
36
  "typescript": "^5.4.0",
package/src/cli.ts CHANGED
@@ -47,6 +47,23 @@ async function main(): Promise<void> {
47
47
  await runInstallCommands();
48
48
  break;
49
49
  }
50
+ case 'show-template': {
51
+ const { runShowTemplate } = await import('./commands/show-template.ts');
52
+ await runShowTemplate(args.slice(1));
53
+ break;
54
+ }
55
+ case 'watch': {
56
+ const { runWatch } = await import('./commands/watch.ts');
57
+ const result = await runWatch(args.slice(1));
58
+ process.exit(result.exitCode);
59
+ return;
60
+ }
61
+ case 'refresh-log': {
62
+ const { runRefreshLog } = await import('./commands/refresh-log.ts');
63
+ const result = await runRefreshLog(args.slice(1));
64
+ process.exit(result.exitCode);
65
+ return;
66
+ }
50
67
  case 'validate-config': {
51
68
  const { runValidateConfig } = await import('./commands/doctor.ts');
52
69
  await runValidateConfig();
@@ -80,7 +97,11 @@ async function handleConfigSubcommand(configArgs: string[]): Promise<void> {
80
97
  switch (sub) {
81
98
  case 'refresh': {
82
99
  const { runConfigRefresh } = await import('./commands/config-refresh.ts');
83
- const result = await runConfigRefresh({ dryRun: flags.has('--dry-run') });
100
+ const result = await runConfigRefresh({
101
+ dryRun: flags.has('--dry-run'),
102
+ skipCommands: flags.has('--skip-commands'),
103
+ autoYes: flags.has('--yes') || flags.has('-y'),
104
+ });
84
105
  process.exit(result.exitCode);
85
106
  return;
86
107
  }
@@ -136,6 +157,9 @@ Commands:
136
157
  doctor Check installation health
137
158
  install-hooks Install/update Claude Code hooks
138
159
  install-commands Install/update slash commands
160
+ show-template Print the resolved variant of a bundled template (e.g. for diffs)
161
+ watch Run the file-watcher daemon (auto-refresh on stack changes)
162
+ refresh-log [N] Show the last N watcher auto-refresh events
139
163
  validate-config Validate massu.config.yaml (alias: config validate)
140
164
  config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
141
165
 
@@ -158,7 +182,9 @@ massu config <subcommand>
158
182
 
159
183
  Subcommands:
160
184
  refresh Re-run detection and apply changes to massu.config.yaml.
161
- --dry-run Print diff and exit without writing.
185
+ --dry-run Print diff and exit without writing.
186
+ --skip-commands Don't re-template .claude/commands/.
187
+ --yes, -y Auto-apply without prompting (CI / scripts).
162
188
  validate Validate massu.config.yaml (alias of \`massu validate-config\`).
163
189
  upgrade Migrate a v1 config to schema_version=2.
164
190
  --rollback Restore from .bak file.
@@ -23,7 +23,7 @@
23
23
  * 2 unparseable massu.config.yaml
24
24
  */
25
25
 
26
- import { existsSync, readFileSync } from 'fs';
26
+ import { existsSync, readFileSync, rmSync } from 'fs';
27
27
  import { resolve } from 'path';
28
28
  import { parse as parseYaml } from 'yaml';
29
29
  import { runDetection } from '../detect/index.ts';
@@ -31,6 +31,9 @@ import { computeFingerprint } from '../detect/drift.ts';
31
31
  import type { AnyConfig } from '../detect/migrate.ts';
32
32
  import { copyUnknownKeys, preserveNestedSubkeys } from '../detect/passthrough.ts';
33
33
  import { buildConfigFromDetection, renderConfigYaml, writeConfigAtomic } from './init.ts';
34
+ import { installAll } from './install-commands.ts';
35
+ import { resetConfig } from '../config.ts';
36
+ import { withInstallLock } from '../lib/installLock.ts';
34
37
 
35
38
  const PRESERVED_FIELDS = [
36
39
  'rules',
@@ -56,6 +59,20 @@ export interface ConfigRefreshOptions {
56
59
  dryRun?: boolean;
57
60
  cwd?: string;
58
61
  silent?: boolean;
62
+ /**
63
+ * Plan #2 P4-001: when true, skip the post-refresh `installAll` call so
64
+ * `.claude/commands/` is NOT re-templated. Used by tests to keep file I/O
65
+ * hermetic, and by users who want config-only refresh behavior.
66
+ */
67
+ skipCommands?: boolean;
68
+ /**
69
+ * Plan 3a Phase 6: when true, bypass BOTH the non-TTY bail and the
70
+ * `@clack/prompts` confirm gate. The watcher daemon and `--yes` CLI flag
71
+ * use this to auto-apply detected changes. Combined with
72
+ * `skipCommands: true`, it lets the watcher delegate `installAll` to its
73
+ * own outer call (single install, single lock acquire — see iter-3 G3-A9).
74
+ */
75
+ autoYes?: boolean;
59
76
  }
60
77
 
61
78
  export interface ConfigRefreshResult {
@@ -293,26 +310,34 @@ export async function runConfigRefresh(opts: ConfigRefreshOptions = {}): Promise
293
310
  return { exitCode: 0, applied: false, dryRun: false, diff };
294
311
  }
295
312
 
296
- // Interactive prompt; fall back to dry-run semantics when not a TTY.
297
- if (!process.stdin.isTTY) {
298
- log('Config diff (non-interactive; pass --dry-run to suppress this note or run interactively to apply):\n');
299
- log(renderDiff(diff));
300
- return {
301
- exitCode: 0,
302
- applied: false,
303
- dryRun: false,
304
- diff,
305
- message: 'non-interactive shell; no changes written',
306
- };
307
- }
313
+ // Plan 3a Phase 6: when autoYes=true, skip BOTH the non-TTY bail and the
314
+ // confirm gate so the watcher (daemon stdin is detached) and the
315
+ // `--yes`/-y CLI flag actually apply changes.
316
+ if (!opts.autoYes) {
317
+ // Interactive prompt; fall back to dry-run semantics when not a TTY.
318
+ if (!process.stdin.isTTY) {
319
+ log('Config diff (non-interactive; pass --dry-run to suppress this note or run interactively to apply):\n');
320
+ log(renderDiff(diff));
321
+ return {
322
+ exitCode: 0,
323
+ applied: false,
324
+ dryRun: false,
325
+ diff,
326
+ message: 'non-interactive shell; no changes written',
327
+ };
328
+ }
308
329
 
309
- log('Config diff:\n');
310
- log(renderDiff(diff));
311
- const { confirm } = await import('@clack/prompts');
312
- const apply = await confirm({ message: 'Apply these changes to massu.config.yaml?' });
313
- if (apply !== true) {
314
- log('Aborted; no changes written.\n');
315
- return { exitCode: 0, applied: false, dryRun: false, diff, message: 'aborted by user' };
330
+ log('Config diff:\n');
331
+ log(renderDiff(diff));
332
+ const { confirm } = await import('@clack/prompts');
333
+ const apply = await confirm({ message: 'Apply these changes to massu.config.yaml?' });
334
+ if (apply !== true) {
335
+ log('Aborted; no changes written.\n');
336
+ return { exitCode: 0, applied: false, dryRun: false, diff, message: 'aborted by user' };
337
+ }
338
+ } else {
339
+ log('Config diff (auto-applying via --yes / watcher):\n');
340
+ log(renderDiff(diff));
316
341
  }
317
342
 
318
343
  const yamlContent = renderConfigYaml(merged);
@@ -323,5 +348,48 @@ export async function runConfigRefresh(opts: ConfigRefreshOptions = {}): Promise
323
348
  return { exitCode: 2, applied: false, dryRun: false, diff, message };
324
349
  }
325
350
  log('Config refreshed.\n');
351
+
352
+ // Plan #2 P4-001: re-template `.claude/commands/` against the freshly
353
+ // written config so newly-detected stack changes get the right scaffolds.
354
+ // P4-003 (auto-delete half): if a stack is now declared and the empty-init
355
+ // placeholder still exists, remove it.
356
+ if (!opts.skipCommands) {
357
+ log('Will also re-template command files; pass --skip-commands to opt out.\n');
358
+ // Reset cached config so installAll reads the freshly-written YAML.
359
+ resetConfig();
360
+ try {
361
+ const installResult = withInstallLock(cwd, () => installAll(cwd));
362
+ const total =
363
+ installResult.totalInstalled +
364
+ installResult.totalUpdated +
365
+ installResult.totalSkipped +
366
+ installResult.totalKept;
367
+ log(`Re-templated ${total} command files (${installResult.totalInstalled} new, ${installResult.totalUpdated} updated).\n`);
368
+
369
+ // Auto-delete the empty-init placeholder if at least one stack-specific
370
+ // command was resolved this run (i.e., a non-zero install/update count).
371
+ const stackResolved =
372
+ installResult.totalInstalled > 0 || installResult.totalUpdated > 0;
373
+ if (stackResolved) {
374
+ const placeholderPath = resolve(installResult.claudeDir, 'commands', '_massu-needs-stack.md');
375
+ if (existsSync(placeholderPath)) {
376
+ try {
377
+ rmSync(placeholderPath, { force: true });
378
+ log('Removed _massu-needs-stack.md (stack now declared).\n');
379
+ } catch {
380
+ // Best-effort: never block refresh on placeholder cleanup.
381
+ }
382
+ }
383
+ }
384
+ } catch (err) {
385
+ // Don't fail the whole refresh if re-template breaks; the YAML was already written.
386
+ // InstallLockBusyError.message already follows plan §243 format —
387
+ // `installAll already running (PID=X) — try again in <N>s` — so we
388
+ // surface it verbatim rather than re-wrapping.
389
+ const msg = err instanceof Error ? err.message : String(err);
390
+ if (!opts.silent) process.stderr.write(`Warning: re-template failed: ${msg}\n`);
391
+ }
392
+ }
393
+
326
394
  return { exitCode: 0, applied: true, dryRun: false, diff };
327
395
  }
@@ -26,14 +26,14 @@
26
26
  * installHooks, buildHooksConfig, resolveHooksDir, initMemoryDir, runInit.
27
27
  */
28
28
 
29
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, chmodSync } from 'fs';
29
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, writeFileSync, writeSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, chmodSync } from 'fs';
30
30
  import { resolve, basename, dirname } from 'path';
31
31
  import { fileURLToPath } from 'url';
32
32
  import { homedir } from 'os';
33
33
  import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
34
34
  import { backfillMemoryFiles } from '../memory-file-ingest.ts';
35
35
  import { getConfig, resetConfig } from '../config.ts';
36
- import { installCommands } from './install-commands.ts';
36
+ import { installAll } from './install-commands.ts';
37
37
  import {
38
38
  runDetection,
39
39
  type DetectionResult,
@@ -75,6 +75,11 @@ export interface InitOptions {
75
75
  template?: string;
76
76
  /** Skip hook/command/memory install side-effects. Used in tests. */
77
77
  skipSideEffects?: boolean;
78
+ /**
79
+ * Plan #2 P4-002: when true, skip the asset-install (commands / agents /
80
+ * patterns / etc). MCP register, hooks, and memory init still run.
81
+ */
82
+ skipCommands?: boolean;
78
83
  /** Override cwd (tests). */
79
84
  cwd?: string;
80
85
  /** Suppress console output. */
@@ -508,6 +513,14 @@ export function buildConfigFromDetection(
508
513
  // P5-002: stamp a stack fingerprint so session-start can detect drift later.
509
514
  config.detection = { fingerprint: computeFingerprint(detection) };
510
515
 
516
+ // Plan #2 P3-003: emit detector-owned `detected:` block (per-language
517
+ // conventions sampled from the codebase). Only present when the introspector
518
+ // ran (i.e., not skipped by the session-start hook). Detector-owned →
519
+ // refreshed on every `init`/`config refresh`, NOT in PRESERVED_FIELDS.
520
+ if (detection.detected && Object.keys(detection.detected).length > 0) {
521
+ config.detected = detection.detected;
522
+ }
523
+
511
524
  // Preserve legacy `python` block for v1 consumers (domain-enforcer, etc.).
512
525
  // Per Phase 0 P1-009 (b): python legacy config coexists with languages.python.
513
526
  if (languages.includes('python')) {
@@ -576,7 +589,31 @@ export function writeConfigAtomic(
576
589
  }
577
590
 
578
591
  try {
579
- writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o644 });
592
+ // Iter-8 fix: ensure the parent directory exists. POSIX `rename(2)`
593
+ // requires the target's parent to exist; otherwise the rename fails
594
+ // with ENOENT and we leak the tmp. The watcher's auto-refresh path
595
+ // never hits this (the configPath is always inside an existing repo
596
+ // with massu.config.yaml already there), but `runInit` on a fresh
597
+ // path under a non-existent parent would fall over before this line.
598
+ mkdirSync(dirname(configPath), { recursive: true });
599
+
600
+ // Iter-7 fix: write tmp via openSync + writeSync + fsyncSync + closeSync
601
+ // so the data hits the platter BEFORE renameSync. This matches
602
+ // `writeStateAtomic` (watch/state.ts) and the spec doc claim that the
603
+ // 3a watcher's atomic-rename guarantees universally cover all writes
604
+ // touched during a refresh cycle. Without fsync, on certain filesystems
605
+ // (xfs, ext4 `data=writeback`) the rename can land before data, leaving
606
+ // a zero-byte config on power-loss / SIGKILL between writeFileSync and
607
+ // renameSync — a gap the watcher daemon makes more reachable since
608
+ // refresh writes happen unattended every quiescence window.
609
+ const fd = openSync(tmpPath, 'w', 0o644);
610
+ try {
611
+ const buf = Buffer.from(content, 'utf-8');
612
+ writeSync(fd, buf, 0, buf.length, 0);
613
+ fsyncSync(fd);
614
+ } finally {
615
+ closeSync(fd);
616
+ }
580
617
 
581
618
  // Validate YAML parses.
582
619
  const parsed = yamlParse(content);
@@ -998,6 +1035,7 @@ export function parseInitArgs(argv: string[]): ParseInitArgsResult {
998
1035
  const a = argv[i];
999
1036
  if (a === '--ci') opts.ci = true;
1000
1037
  else if (a === '--force') opts.force = true;
1038
+ else if (a === '--skip-commands') opts.skipCommands = true;
1001
1039
  else if (a === '--help' || a === '-h') opts.help = true;
1002
1040
  else if (a === '--template') {
1003
1041
  const next = argv[i + 1];
@@ -1025,6 +1063,8 @@ Options:
1025
1063
  --force Overwrite existing massu.config.yaml without prompting.
1026
1064
  --template <name> Skip detection and scaffold from a greenfield template.
1027
1065
  Templates: ${TEMPLATE_NAMES.join(', ')}
1066
+ --skip-commands Skip the asset install (.claude/commands etc).
1067
+ MCP register, hooks, and memory init still run.
1028
1068
  --help, -h Show this help message
1029
1069
 
1030
1070
  Examples:
@@ -1131,7 +1171,7 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
1131
1171
  }
1132
1172
  log(` Installed template '${opts.template}' → massu.config.yaml`);
1133
1173
  if (!opts.skipSideEffects) {
1134
- installSideEffects(projectRoot, log);
1174
+ installSideEffects(projectRoot, log, opts.skipCommands);
1135
1175
  }
1136
1176
  return;
1137
1177
  }
@@ -1139,11 +1179,20 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
1139
1179
  // Branch 2: detection-driven path (P3-001, P3-002)
1140
1180
  const detection = await runDetection(projectRoot);
1141
1181
  const languageCount = new Set(detection.manifests.map((m) => m.language)).size;
1142
- if (detection.manifests.length === 0 && languageCount === 0) {
1143
- errLog('error: no languages detected in this directory');
1144
- errLog(' (no package.json, pyproject.toml, Cargo.toml, etc.)');
1145
- errLog(' pass --template <name> to scaffold a new project, or cd into a repo with a manifest');
1146
- throw new Error('No languages detected');
1182
+ const emptyStack = detection.manifests.length === 0 && languageCount === 0;
1183
+ if (emptyStack) {
1184
+ if (opts.ci && !opts.force) {
1185
+ // Plan #2 §"Answer to install-before-stack": interactive `massu init` in
1186
+ // an empty repo is supported. CI mode keeps the strict guard (no
1187
+ // accidental empty-stack configs in pipelines) — pass --force in CI to
1188
+ // explicitly opt into empty-stack init.
1189
+ errLog('error: no languages detected in this directory');
1190
+ errLog(' (no package.json, pyproject.toml, Cargo.toml, etc.)');
1191
+ errLog(' pass --template <name>, --force, or run interactively for empty-stack init');
1192
+ throw new Error('No languages detected');
1193
+ }
1194
+ log(' No languages detected — proceeding with empty-stack init.');
1195
+ log(' After adding a manifest (package.json, pyproject.toml, ...) run: npx massu config refresh');
1147
1196
  }
1148
1197
 
1149
1198
  // Emit warnings to stderr for ambiguous / malformed detection.
@@ -1186,8 +1235,10 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
1186
1235
  throw new Error(writeRes.error ?? 'atomic write failed');
1187
1236
  }
1188
1237
 
1189
- // Post-write validation; rollback on failure.
1190
- const validation = validateWrittenConfig(configPath, projectRoot);
1238
+ // Post-write validation; rollback on failure. Skip filesystem-existence
1239
+ // checks for empty-stack init (no manifests = `paths.source` defaults to
1240
+ // 'src' which legitimately doesn't exist in an empty dir).
1241
+ const validation = validateWrittenConfig(configPath, projectRoot, !emptyStack);
1191
1242
  if (validation !== null) {
1192
1243
  try { rmSync(configPath, { force: true }); } catch { /* ignore */ }
1193
1244
  errLog(`error: generated config failed validation: ${validation}`);
@@ -1198,12 +1249,17 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
1198
1249
  log(' Created massu.config.yaml (schema_version: 2)');
1199
1250
 
1200
1251
  if (!opts.skipSideEffects) {
1201
- installSideEffects(projectRoot, log);
1252
+ installSideEffects(projectRoot, log, opts.skipCommands, emptyStack);
1202
1253
  }
1203
1254
  }
1204
1255
 
1205
1256
  /** Shared side-effect steps (MCP register + hooks + commands + memory + backfill). */
1206
- function installSideEffects(projectRoot: string, log: (s: string) => void): void {
1257
+ function installSideEffects(
1258
+ projectRoot: string,
1259
+ log: (s: string) => void,
1260
+ skipCommands: boolean = false,
1261
+ emptyStack: boolean = false,
1262
+ ): void {
1207
1263
  // MCP register
1208
1264
  const mcpRegistered = registerMcpServer(projectRoot);
1209
1265
  if (mcpRegistered) {
@@ -1216,17 +1272,68 @@ function installSideEffects(projectRoot: string, log: (s: string) => void): void
1216
1272
  const { count: hooksCount } = installHooks(projectRoot);
1217
1273
  log(` Installed ${hooksCount} hooks in .claude/settings.local.json`);
1218
1274
 
1219
- // Commands
1220
- try {
1221
- const cmdResult = installCommands(projectRoot);
1222
- const cmdTotal = cmdResult.installed + cmdResult.updated + cmdResult.skipped;
1223
- if (cmdResult.installed > 0 || cmdResult.updated > 0) {
1224
- log(` Installed ${cmdTotal} slash commands (${cmdResult.installed} new, ${cmdResult.updated} updated)`);
1225
- } else if (cmdTotal > 0) {
1226
- log(` ${cmdTotal} slash commands already up to date`);
1275
+ // Plan #2 P4-002: install all asset types (commands, agents, patterns,
1276
+ // protocols, reference) via installAll — replaces the legacy
1277
+ // installCommands() that only handled commands. Skipped when --skip-commands.
1278
+ // Plan #2 P4-003: when no stack-specific commands resolved (empty-stack init),
1279
+ // write a single `_massu-needs-stack.md` placeholder so consumers know to
1280
+ // run `config refresh` after adding their first manifest.
1281
+ if (!skipCommands) {
1282
+ try {
1283
+ const cmdResult = installAll(projectRoot);
1284
+ const cmdTotal =
1285
+ cmdResult.totalInstalled +
1286
+ cmdResult.totalUpdated +
1287
+ cmdResult.totalSkipped +
1288
+ cmdResult.totalKept;
1289
+ if (cmdResult.totalInstalled > 0 || cmdResult.totalUpdated > 0) {
1290
+ log(` Installed ${cmdTotal} project assets (${cmdResult.totalInstalled} new, ${cmdResult.totalUpdated} updated)`);
1291
+ } else if (cmdTotal > 0) {
1292
+ log(` ${cmdTotal} project assets already up to date`);
1293
+ }
1294
+
1295
+ // Empty-stack init detection: when caller signals an empty stack OR
1296
+ // when NO commands resolved at all, drop the placeholder so the user
1297
+ // understands the next step. The explicit `emptyStack` signal handles
1298
+ // the t=0 case (zero manifests detected) where generic-default commands
1299
+ // still install but no stack-specific scaffolds match the consumer.
1300
+ const commandStats = cmdResult.assets.commands;
1301
+ const stackResolved = !emptyStack && commandStats &&
1302
+ (commandStats.installed > 0 || commandStats.updated > 0 || commandStats.kept > 0);
1303
+ if (!stackResolved) {
1304
+ const placeholderPath = resolve(cmdResult.claudeDir, 'commands', '_massu-needs-stack.md');
1305
+ if (!existsSync(placeholderPath)) {
1306
+ const placeholderBody = [
1307
+ '# Massu — stack not yet detected',
1308
+ '',
1309
+ 'Your stack hasn\'t been detected yet. Most slash commands ship as language-specific',
1310
+ 'variants (e.g., `massu-scaffold-router.python-fastapi.md` for FastAPI projects).',
1311
+ 'When detection finds a manifest, the right variants get installed automatically.',
1312
+ '',
1313
+ 'After you add your first manifest (`package.json`, `pyproject.toml`, `Cargo.toml`,',
1314
+ 'etc.) run:',
1315
+ '',
1316
+ '```bash',
1317
+ 'npx massu config refresh',
1318
+ '```',
1319
+ '',
1320
+ 'This file will be auto-removed on the first refresh that resolves at least one',
1321
+ 'stack-specific command.',
1322
+ '',
1323
+ '— Massu',
1324
+ ].join('\n');
1325
+ try {
1326
+ mkdirSync(resolve(cmdResult.claudeDir, 'commands'), { recursive: true });
1327
+ writeFileSync(placeholderPath, placeholderBody, 'utf-8');
1328
+ log(' Wrote _massu-needs-stack.md placeholder (no stack detected yet)');
1329
+ } catch {
1330
+ // Best-effort.
1331
+ }
1332
+ }
1333
+ }
1334
+ } catch {
1335
+ // Best-effort — don't fail init if assets can't be resolved.
1227
1336
  }
1228
- } catch {
1229
- // Best-effort — don't fail init if assets can't be resolved.
1230
1337
  }
1231
1338
 
1232
1339
  // Memory dir