@massu/core 0.9.2 → 1.1.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 (53) hide show
  1. package/dist/cli.js +11182 -1559
  2. package/dist/hooks/auto-learning-pipeline.js +99 -19
  3. package/dist/hooks/classify-failure.js +99 -19
  4. package/dist/hooks/cost-tracker.js +97 -11
  5. package/dist/hooks/fix-detector.js +99 -19
  6. package/dist/hooks/incident-pipeline.js +97 -11
  7. package/dist/hooks/post-edit-context.js +97 -11
  8. package/dist/hooks/post-tool-use.js +101 -20
  9. package/dist/hooks/pre-compact.js +97 -11
  10. package/dist/hooks/pre-delete-check.js +97 -11
  11. package/dist/hooks/quality-event.js +97 -11
  12. package/dist/hooks/rule-enforcement-pipeline.js +97 -11
  13. package/dist/hooks/session-end.js +97 -11
  14. package/dist/hooks/session-start.js +8803 -782
  15. package/dist/hooks/user-prompt.js +98 -43
  16. package/package.json +13 -3
  17. package/reference/hook-execution-order.md +17 -25
  18. package/src/cli.ts +81 -2
  19. package/src/commands/config-check-drift.ts +132 -0
  20. package/src/commands/config-refresh.ts +224 -0
  21. package/src/commands/config-upgrade.ts +126 -0
  22. package/src/commands/doctor.ts +1 -29
  23. package/src/commands/init.ts +756 -216
  24. package/src/config.ts +168 -12
  25. package/src/detect/domain-inferrer.ts +142 -0
  26. package/src/detect/drift.ts +199 -0
  27. package/src/detect/framework-detector.ts +281 -0
  28. package/src/detect/index.ts +174 -0
  29. package/src/detect/migrate.ts +278 -0
  30. package/src/detect/monorepo-detector.ts +347 -0
  31. package/src/detect/package-detector.ts +728 -0
  32. package/src/detect/source-dir-detector.ts +264 -0
  33. package/src/detect/vr-command-map.ts +167 -0
  34. package/src/hooks/auto-learning-pipeline.ts +2 -2
  35. package/src/hooks/classify-failure.ts +2 -2
  36. package/src/hooks/fix-detector.ts +2 -2
  37. package/src/hooks/session-start.ts +43 -2
  38. package/src/hooks/user-prompt.ts +1 -21
  39. package/src/knowledge-indexer.ts +1 -1
  40. package/src/license.ts +1 -2
  41. package/src/memory-db.ts +0 -5
  42. package/src/memory-file-ingest.ts +6 -13
  43. package/src/tools.ts +0 -8
  44. package/templates/multi-runtime/massu.config.yaml +80 -0
  45. package/templates/python-django/massu.config.yaml +51 -0
  46. package/templates/python-fastapi/massu.config.yaml +50 -0
  47. package/templates/rust-actix/massu.config.yaml +38 -0
  48. package/templates/swift-ios/massu.config.yaml +37 -0
  49. package/templates/ts-nestjs/massu.config.yaml +43 -0
  50. package/templates/ts-nextjs/massu.config.yaml +43 -0
  51. package/README.md +0 -40
  52. package/src/claude-md-templates.ts +0 -342
  53. package/src/mcp-bridge-tools.ts +0 -458
@@ -2,29 +2,48 @@
2
2
  // Licensed under BSL 1.1 - see LICENSE file for details.
3
3
 
4
4
  /**
5
- * `massu init` — One-command full project setup.
5
+ * `massu init` — One-command, detection-driven project setup.
6
6
  *
7
- * 1. Detects project framework (scans package.json)
8
- * 2. Generates massu.config.yaml (or preserves existing)
9
- * 3. Registers MCP server in .mcp.json (creates or merges)
10
- * 4. Installs all 15 hooks in .claude/settings.local.json
11
- * 5. Installs slash commands into .claude/commands/
12
- * 6. Initializes memory directory
13
- * 7. Prints success summary
7
+ * Phase 3 rewrite (2026-04-19): replaces the JS/TS template copier (old
8
+ * detectFramework/generateConfig path, root cause of Hedge-style stale configs)
9
+ * with a flow that runs the Phase 1 detection engine (`runDetection`) and
10
+ * generates a v2 schema_version=2 `massu.config.yaml` that reflects the
11
+ * actual repo layout (languages, source_dirs, verification commands, domains).
12
+ *
13
+ * Subcommands / flags:
14
+ * massu init Interactive — prompts on overwrite, stack confirm
15
+ * massu init --ci Non-interactive; errors on conflict
16
+ * massu init --force Overwrite existing config without prompting
17
+ * massu init --template X Greenfield template (skips detection entirely)
18
+ *
19
+ * Post-write guarantees:
20
+ * - Atomic (tmp-file + rename; partial writes never persist)
21
+ * - Zod-validated (load via getConfig — bad config is rolled back + deleted)
22
+ * - declared source_dirs must exist on disk
23
+ *
24
+ * Legacy exports preserved for cli.test.ts and install-hooks.ts:
25
+ * detectFramework, detectPython, generateConfig, registerMcpServer,
26
+ * installHooks, buildHooksConfig, resolveHooksDir, initMemoryDir, runInit.
14
27
  */
15
28
 
16
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
29
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, chmodSync } from 'fs';
17
30
  import { resolve, basename, dirname } from 'path';
18
31
  import { fileURLToPath } from 'url';
19
32
  import { homedir } from 'os';
33
+ import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
20
34
  import { backfillMemoryFiles } from '../memory-file-ingest.ts';
21
- import { buildClaudeMdContent } from '../claude-md-templates.ts';
35
+ import { getConfig, resetConfig } from '../config.ts';
36
+ import { installCommands } from './install-commands.ts';
37
+ import {
38
+ runDetection,
39
+ type DetectionResult,
40
+ type SupportedLanguage,
41
+ type VRCommandSet,
42
+ } from '../detect/index.ts';
43
+ import { computeFingerprint } from '../detect/drift.ts';
22
44
 
23
45
  const __filename = fileURLToPath(import.meta.url);
24
46
  const __dirname = dirname(__filename);
25
- import { stringify as yamlStringify } from 'yaml';
26
- import { getConfig } from '../config.ts';
27
- import { installCommands } from './install-commands.ts';
28
47
 
29
48
  // ============================================================
30
49
  // Types
@@ -38,8 +57,6 @@ interface FrameworkDetection {
38
57
  }
39
58
 
40
59
  interface InitResult {
41
- claudeMdCreated: boolean;
42
- claudeMdSkipped: boolean;
43
60
  configCreated: boolean;
44
61
  configSkipped: boolean;
45
62
  mcpRegistered: boolean;
@@ -49,8 +66,32 @@ interface InitResult {
49
66
  framework: FrameworkDetection;
50
67
  }
51
68
 
69
+ export interface InitOptions {
70
+ /** Skip all prompts; fail on conflict. Also set when stdin is not a TTY. */
71
+ ci?: boolean;
72
+ /** Overwrite existing config without prompting (ignored in --ci mode). */
73
+ force?: boolean;
74
+ /** Template name for greenfield projects (skips detection). */
75
+ template?: string;
76
+ /** Skip hook/command/memory install side-effects. Used in tests. */
77
+ skipSideEffects?: boolean;
78
+ /** Override cwd (tests). */
79
+ cwd?: string;
80
+ /** Suppress console output. */
81
+ silent?: boolean;
82
+ }
83
+
84
+ export interface GenerateConfigV2Options {
85
+ /** Project root to generate against. Detection is run on this directory. */
86
+ projectRoot: string;
87
+ /** Pre-computed detection result (reused if already available). */
88
+ detection?: DetectionResult;
89
+ /** Project name override (default = basename of projectRoot). */
90
+ projectName?: string;
91
+ }
92
+
52
93
  // ============================================================
53
- // Framework Auto-Detection
94
+ // Legacy Framework Auto-Detection (preserved for cli.test.ts)
54
95
  // ============================================================
55
96
 
56
97
  export function detectFramework(projectRoot: string): FrameworkDetection {
@@ -101,7 +142,7 @@ export function detectFramework(projectRoot: string): FrameworkDetection {
101
142
  }
102
143
 
103
144
  // ============================================================
104
- // Python Project Detection
145
+ // Legacy Python Project Detection (preserved for cli.test.ts / back compat)
105
146
  // ============================================================
106
147
 
107
148
  interface PythonDetection {
@@ -123,39 +164,34 @@ export function detectPython(projectRoot: string): PythonDetection {
123
164
  alembicDir: null,
124
165
  };
125
166
 
126
- // Check for Python project markers
127
167
  const markers = ['pyproject.toml', 'setup.py', 'requirements.txt', 'Pipfile'];
128
168
  const hasMarker = markers.some(m => existsSync(resolve(projectRoot, m)));
129
169
  if (!hasMarker) return result;
130
170
 
131
171
  result.detected = true;
132
172
 
133
- // Scan dependencies for FastAPI and SQLAlchemy
134
173
  const depFiles = [
135
- { file: 'pyproject.toml', parser: parsePyprojectDeps },
136
- { file: 'requirements.txt', parser: parseRequirementsDeps },
137
- { file: 'setup.py', parser: parseSetupPyDeps },
138
- { file: 'Pipfile', parser: parsePipfileDeps },
174
+ { file: 'pyproject.toml' },
175
+ { file: 'requirements.txt' },
176
+ { file: 'setup.py' },
177
+ { file: 'Pipfile' },
139
178
  ];
140
179
 
141
- for (const { file, parser } of depFiles) {
180
+ for (const { file } of depFiles) {
142
181
  const filePath = resolve(projectRoot, file);
143
182
  if (existsSync(filePath)) {
144
183
  try {
145
- const content = readFileSync(filePath, 'utf-8');
146
- const deps = parser(content);
147
- if (deps.includes('fastapi')) result.hasFastapi = true;
148
- if (deps.includes('sqlalchemy')) result.hasSqlalchemy = true;
184
+ const content = readFileSync(filePath, 'utf-8').toLowerCase();
185
+ if (content.includes('fastapi')) result.hasFastapi = true;
186
+ if (content.includes('sqlalchemy')) result.hasSqlalchemy = true;
149
187
  } catch {
150
188
  // Best effort
151
189
  }
152
190
  }
153
191
  }
154
192
 
155
- // Check for Alembic
156
193
  if (existsSync(resolve(projectRoot, 'alembic.ini'))) {
157
194
  result.hasAlembic = true;
158
- // Try to find the alembic versions directory
159
195
  if (existsSync(resolve(projectRoot, 'alembic'))) {
160
196
  result.alembicDir = 'alembic';
161
197
  }
@@ -164,7 +200,6 @@ export function detectPython(projectRoot: string): PythonDetection {
164
200
  result.alembicDir = 'alembic';
165
201
  }
166
202
 
167
- // Auto-detect Python source root
168
203
  const candidateRoots = ['app', 'src', 'backend', 'api'];
169
204
  for (const candidate of candidateRoots) {
170
205
  const candidatePath = resolve(projectRoot, candidate);
@@ -172,7 +207,6 @@ export function detectPython(projectRoot: string): PythonDetection {
172
207
  result.root = candidate;
173
208
  break;
174
209
  }
175
- // Also check for .py files directly (some projects use app/ without __init__.py)
176
210
  if (existsSync(candidatePath)) {
177
211
  try {
178
212
  const files = readdirSync(candidatePath);
@@ -186,7 +220,6 @@ export function detectPython(projectRoot: string): PythonDetection {
186
220
  }
187
221
  }
188
222
 
189
- // Fallback: use '.' if no candidate root found
190
223
  if (!result.root) {
191
224
  result.root = '.';
192
225
  }
@@ -194,43 +227,8 @@ export function detectPython(projectRoot: string): PythonDetection {
194
227
  return result;
195
228
  }
196
229
 
197
- function parsePyprojectDeps(content: string): string[] {
198
- const deps: string[] = [];
199
- const lower = content.toLowerCase();
200
- if (lower.includes('fastapi')) deps.push('fastapi');
201
- if (lower.includes('sqlalchemy')) deps.push('sqlalchemy');
202
- return deps;
203
- }
204
-
205
- function parseRequirementsDeps(content: string): string[] {
206
- const deps: string[] = [];
207
- const lower = content.toLowerCase();
208
- for (const line of lower.split('\n')) {
209
- const trimmed = line.trim();
210
- if (trimmed.startsWith('fastapi')) deps.push('fastapi');
211
- if (trimmed.startsWith('sqlalchemy')) deps.push('sqlalchemy');
212
- }
213
- return deps;
214
- }
215
-
216
- function parseSetupPyDeps(content: string): string[] {
217
- const deps: string[] = [];
218
- const lower = content.toLowerCase();
219
- if (lower.includes('fastapi')) deps.push('fastapi');
220
- if (lower.includes('sqlalchemy')) deps.push('sqlalchemy');
221
- return deps;
222
- }
223
-
224
- function parsePipfileDeps(content: string): string[] {
225
- const deps: string[] = [];
226
- const lower = content.toLowerCase();
227
- if (lower.includes('fastapi')) deps.push('fastapi');
228
- if (lower.includes('sqlalchemy')) deps.push('sqlalchemy');
229
- return deps;
230
- }
231
-
232
230
  // ============================================================
233
- // Config File Generation
231
+ // Legacy Config File Generation (preserved for cli.test.ts)
234
232
  // ============================================================
235
233
 
236
234
  export function generateConfig(projectRoot: string, framework: FrameworkDetection): boolean {
@@ -293,29 +291,388 @@ ${yamlStringify(config)}`;
293
291
  }
294
292
 
295
293
  // ============================================================
296
- // CLAUDE.md Generation
294
+ // V2 Config Builder (detection-driven)
297
295
  // ============================================================
298
296
 
299
- export function generateClaudeMd(
297
+ /**
298
+ * Build a schema_version=2 config object from a DetectionResult.
299
+ *
300
+ * Contract:
301
+ * - `framework.type` is `'multi'` when 2+ languages present, else the sole language.
302
+ * - `framework.primary` is the language with the most manifests (ties: alpha).
303
+ * - `framework.languages` is populated for every detected language with a
304
+ * non-null framework or test framework.
305
+ * - Legacy top-level `framework.router/.orm/.ui` are mirrored from the primary
306
+ * language entry so existing consumers (tools.ts lines 89/192/246) keep
307
+ * working without any change (per Phase 0 P0-003 + Phase 2 P2-002 contract).
308
+ * - `paths.source` is the dominant directory for the primary language (or '.'
309
+ * for single-repo flat layouts).
310
+ * - `verification.<language>` is pulled from VRCommandMap output.
311
+ * - `domains[]` is the DomainInferrer output (may be empty).
312
+ */
313
+ export function buildConfigFromDetection(
314
+ opts: GenerateConfigV2Options
315
+ ): Record<string, unknown> {
316
+ const { projectRoot, detection } = opts;
317
+ if (!detection) {
318
+ throw new Error('buildConfigFromDetection requires a detection result');
319
+ }
320
+ const projectName = opts.projectName ?? basename(projectRoot);
321
+
322
+ const languages = Array.from(
323
+ new Set(detection.manifests.map((m) => m.language))
324
+ ) as SupportedLanguage[];
325
+
326
+ // Pick primary: language with most manifests; ties broken by alphabetical.
327
+ const languageCounts = new Map<SupportedLanguage, number>();
328
+ for (const m of detection.manifests) {
329
+ languageCounts.set(m.language, (languageCounts.get(m.language) ?? 0) + 1);
330
+ }
331
+ const sortedLangs = [...languageCounts.entries()].sort((a, b) => {
332
+ if (b[1] !== a[1]) return b[1] - a[1];
333
+ return a[0].localeCompare(b[0]);
334
+ });
335
+ const primary: SupportedLanguage | null = sortedLangs.length > 0 ? sortedLangs[0][0] : null;
336
+
337
+ const frameworkType = languages.length > 1 ? 'multi' : (languages[0] ?? 'typescript');
338
+
339
+ // Build per-language entries from FrameworkMap.
340
+ const languageEntries: Record<string, Record<string, unknown>> = {};
341
+ for (const lang of languages) {
342
+ const fw = detection.frameworks[lang];
343
+ const dirInfo = detection.sourceDirs[lang];
344
+ const sourceDirs = dirInfo?.source_dirs ?? [];
345
+ const entry: Record<string, unknown> = {};
346
+ if (fw?.framework) entry.framework = fw.framework;
347
+ if (fw?.test_framework) entry.test_framework = fw.test_framework;
348
+ if (fw?.orm) entry.orm = fw.orm;
349
+ if (fw?.router) entry.router = fw.router;
350
+ if (fw?.ui_library) entry.ui = fw.ui_library;
351
+ if (sourceDirs.length > 0) entry.source_dirs = sourceDirs;
352
+ // Only include entries that have at least one field populated.
353
+ if (Object.keys(entry).length > 0) {
354
+ languageEntries[lang] = entry;
355
+ }
356
+ }
357
+
358
+ // Legacy top-level framework fields (mirror from primary language).
359
+ // Preserves tools.ts:89,192,246 reads under v2.
360
+ const primaryEntry = primary ? languageEntries[primary] : undefined;
361
+ const legacyRouter =
362
+ (primaryEntry?.router as string | undefined) ?? 'none';
363
+ const legacyOrm = (primaryEntry?.orm as string | undefined) ?? 'none';
364
+ const legacyUi = (primaryEntry?.ui as string | undefined) ?? 'none';
365
+
366
+ // Determine paths.source from primary language's dominant source dir.
367
+ let pathsSource = 'src';
368
+ if (primary) {
369
+ const primaryDirs = detection.sourceDirs[primary]?.source_dirs ?? [];
370
+ if (primaryDirs.length > 0) {
371
+ pathsSource = primaryDirs[0];
372
+ }
373
+ }
374
+
375
+ // Verification commands per language.
376
+ const verification: Record<string, Record<string, string>> = {};
377
+ for (const lang of languages) {
378
+ const cmds: VRCommandSet | undefined = detection.verificationCommands[lang];
379
+ if (!cmds) continue;
380
+ const entry: Record<string, string> = {};
381
+ if (cmds.test) entry.test = cmds.test;
382
+ if (cmds.type) entry.type = cmds.type;
383
+ if (cmds.build) entry.build = cmds.build;
384
+ if (cmds.syntax) entry.syntax = cmds.syntax;
385
+ if (cmds.lint) entry.lint = cmds.lint;
386
+ if (Object.keys(entry).length > 0) {
387
+ verification[lang] = entry;
388
+ }
389
+ }
390
+
391
+ // Domains: emit from inferred + strip defaulting so YAML stays lean.
392
+ const domains = detection.domains.map((d) => {
393
+ const out: Record<string, unknown> = { name: d.name };
394
+ if (d.routers.length > 0) out.routers = d.routers;
395
+ if (d.pages.length > 0) out.pages = d.pages;
396
+ if (d.tables.length > 0) out.tables = d.tables;
397
+ if (d.allowedImportsFrom.length > 0) out.allowedImportsFrom = d.allowedImportsFrom;
398
+ return out;
399
+ });
400
+
401
+ const frameworkBlock: Record<string, unknown> = {
402
+ type: frameworkType,
403
+ router: legacyRouter,
404
+ orm: legacyOrm,
405
+ ui: legacyUi,
406
+ };
407
+ if (languages.length > 1 && primary) {
408
+ frameworkBlock.primary = primary;
409
+ }
410
+ if (Object.keys(languageEntries).length > 0) {
411
+ frameworkBlock.languages = languageEntries;
412
+ }
413
+
414
+ const config: Record<string, unknown> = {
415
+ schema_version: 2,
416
+ project: {
417
+ name: projectName,
418
+ root: 'auto',
419
+ },
420
+ framework: frameworkBlock,
421
+ paths: {
422
+ source: pathsSource,
423
+ aliases: { '@': pathsSource },
424
+ },
425
+ toolPrefix: 'massu',
426
+ domains,
427
+ rules: [],
428
+ };
429
+
430
+ if (Object.keys(verification).length > 0) {
431
+ config.verification = verification;
432
+ }
433
+
434
+ // P5-002: stamp a stack fingerprint so session-start can detect drift later.
435
+ config.detection = { fingerprint: computeFingerprint(detection) };
436
+
437
+ // Preserve legacy `python` block for v1 consumers (domain-enforcer, etc.).
438
+ // Per Phase 0 P1-009 (b): python legacy config coexists with languages.python.
439
+ if (languages.includes('python')) {
440
+ const pySourceDirs = detection.sourceDirs.python?.source_dirs ?? [];
441
+ const pyRoot = pySourceDirs.length > 0 ? pySourceDirs[0] : '.';
442
+ const pyFw = detection.frameworks.python;
443
+ const pythonBlock: Record<string, unknown> = {
444
+ root: pyRoot,
445
+ exclude_dirs: ['__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache'],
446
+ };
447
+ if (pyFw?.framework) pythonBlock.framework = pyFw.framework;
448
+ if (pyFw?.orm) pythonBlock.orm = pyFw.orm;
449
+ // Alembic detection — best-effort via filesystem (detection layer is DB-free).
450
+ if (existsSync(resolve(projectRoot, 'alembic.ini')) || existsSync(resolve(projectRoot, 'alembic'))) {
451
+ pythonBlock.alembic_dir = 'alembic';
452
+ }
453
+ config.python = pythonBlock;
454
+ }
455
+
456
+ return config;
457
+ }
458
+
459
+ /**
460
+ * Serialize a built config object into YAML with a header comment.
461
+ * Safe for `writeConfigAtomic` and for `fs.writeFileSync` directly.
462
+ */
463
+ export function renderConfigYaml(config: Record<string, unknown>): string {
464
+ return `# Massu AI Configuration
465
+ # Generated by: npx massu init (schema_version=2, detection-driven)
466
+ # Documentation: https://massu.ai/docs/getting-started/configuration
467
+
468
+ ${yamlStringify(config)}`;
469
+ }
470
+
471
+ // ============================================================
472
+ // Atomic Write + Post-Write Validation (P3-004, P3-005)
473
+ // ============================================================
474
+
475
+ /**
476
+ * Atomically write YAML to `configPath`.
477
+ * 1. Writes to `<configPath>.tmp`.
478
+ * 2. Validates the written file by parsing it as YAML and through the Zod
479
+ * RawConfigSchema via a short-lived `getConfig` reload on a sandboxed cwd.
480
+ * 3. Renames the tmp file to the target.
481
+ * 4. On ANY error, removes the tmp file. No partial config ever persists.
482
+ *
483
+ * Preserves existing file permissions when overwriting.
484
+ *
485
+ * P3-006: never writes outside `configPath`'s directory; caller is responsible
486
+ * for passing an in-project path (enforced at the call-site in runInit).
487
+ */
488
+ export function writeConfigAtomic(
489
+ configPath: string,
490
+ content: string
491
+ ): { validated: boolean; error?: string } {
492
+ const tmpPath = `${configPath}.tmp`;
493
+
494
+ // Preserve existing permissions when overwriting.
495
+ let existingMode: number | undefined;
496
+ if (existsSync(configPath)) {
497
+ try {
498
+ existingMode = statSync(configPath).mode;
499
+ } catch {
500
+ existingMode = undefined;
501
+ }
502
+ }
503
+
504
+ try {
505
+ writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o644 });
506
+
507
+ // Validate YAML parses.
508
+ const parsed = yamlParse(content);
509
+ if (parsed === null || typeof parsed !== 'object') {
510
+ throw new Error('Generated config is not a valid YAML object');
511
+ }
512
+
513
+ // Atomic rename.
514
+ renameSync(tmpPath, configPath);
515
+
516
+ // Restore mode if we had one.
517
+ if (existingMode !== undefined) {
518
+ try {
519
+ chmodSync(configPath, existingMode);
520
+ } catch {
521
+ // Best effort; unreadable mode doesn't block init.
522
+ }
523
+ }
524
+
525
+ return { validated: true };
526
+ } catch (err) {
527
+ // Clean up the temp file on failure.
528
+ if (existsSync(tmpPath)) {
529
+ try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
530
+ }
531
+ return { validated: false, error: err instanceof Error ? err.message : String(err) };
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Validate a written config against the live Zod schema AND filesystem.
537
+ * Returns null on success, an error message on failure.
538
+ *
539
+ * When `checkPaths` is false (template mode, greenfield scaffolds), filesystem
540
+ * existence checks on `paths.source` and per-language source_dirs are skipped.
541
+ */
542
+ export function validateWrittenConfig(
543
+ configPath: string,
300
544
  projectRoot: string,
301
- framework: FrameworkDetection,
302
- python: PythonDetection,
303
- ): { created: boolean; skipped: boolean } {
304
- const claudeMdPath = resolve(projectRoot, 'CLAUDE.md');
545
+ checkPaths: boolean = true
546
+ ): string | null {
547
+ try {
548
+ if (!existsSync(configPath)) return 'Config file does not exist after write';
549
+ // Parse YAML directly — we deliberately bypass getConfig() here because
550
+ // getConfig caches against process.cwd() and we may be validating a config
551
+ // outside the current working tree (tests, etc.).
552
+ const content = readFileSync(configPath, 'utf-8');
553
+ const parsed = yamlParse(content);
554
+ if (parsed === null || typeof parsed !== 'object') {
555
+ return 'Config is not a valid YAML object';
556
+ }
305
557
 
306
- // NEVER overwrite existing CLAUDE.md
307
- if (existsSync(claudeMdPath)) {
308
- return { created: false, skipped: true };
558
+ // Validate via getConfig by temporarily chdir'ing to projectRoot, since
559
+ // getConfig reads the config from process.cwd(). The Zod safeParse inside
560
+ // getConfig already surfaces actionable errors on malformed configs.
561
+ const prevCwd = process.cwd();
562
+ let changed = false;
563
+ if (prevCwd !== projectRoot) {
564
+ try { process.chdir(projectRoot); changed = true; } catch { /* ignore */ }
565
+ }
566
+ try {
567
+ resetConfig();
568
+ const cfg = getConfig();
569
+ if (checkPaths) {
570
+ // Verify paths.source actually exists on disk (unless '.', which is always valid).
571
+ const src = cfg.paths.source;
572
+ if (src && src !== '.') {
573
+ const srcAbs = resolve(projectRoot, src);
574
+ if (!existsSync(srcAbs)) {
575
+ return `paths.source '${src}' does not exist on disk`;
576
+ }
577
+ }
578
+ // Verify every declared language source_dir exists.
579
+ const languages = cfg.framework.languages ?? {};
580
+ for (const [lang, entry] of Object.entries(languages)) {
581
+ const rawDirs = (entry as Record<string, unknown>).source_dirs;
582
+ if (!Array.isArray(rawDirs)) continue;
583
+ for (const d of rawDirs) {
584
+ if (typeof d !== 'string' || d === '.') continue;
585
+ const abs = resolve(projectRoot, d);
586
+ if (!existsSync(abs)) {
587
+ return `framework.languages.${lang}.source_dirs '${d}' does not exist on disk`;
588
+ }
589
+ }
590
+ }
591
+ }
592
+ } catch (err) {
593
+ return err instanceof Error ? err.message : String(err);
594
+ } finally {
595
+ if (changed) {
596
+ try { process.chdir(prevCwd); } catch { /* ignore */ }
597
+ }
598
+ resetConfig();
599
+ }
600
+ return null;
601
+ } catch (err) {
602
+ return err instanceof Error ? err.message : String(err);
309
603
  }
604
+ }
310
605
 
311
- const projectName = basename(projectRoot);
312
- const content = buildClaudeMdContent(projectName, projectRoot, framework, python);
313
- writeFileSync(claudeMdPath, content, 'utf-8');
314
- return { created: true, skipped: false };
606
+ // ============================================================
607
+ // Template Mode (P3-003)
608
+ // ============================================================
609
+
610
+ const TEMPLATE_NAMES = [
611
+ 'python-fastapi',
612
+ 'python-django',
613
+ 'ts-nextjs',
614
+ 'ts-nestjs',
615
+ 'rust-actix',
616
+ 'swift-ios',
617
+ 'multi-runtime',
618
+ ] as const;
619
+
620
+ export type TemplateName = (typeof TEMPLATE_NAMES)[number];
621
+
622
+ export function isTemplateName(name: string): name is TemplateName {
623
+ return (TEMPLATE_NAMES as readonly string[]).includes(name);
624
+ }
625
+
626
+ export function listTemplates(): readonly string[] {
627
+ return TEMPLATE_NAMES;
628
+ }
629
+
630
+ /**
631
+ * Resolve the templates directory.
632
+ * Order:
633
+ * 1. `node_modules/@massu/core/templates` (installed)
634
+ * 2. Relative to compiled dist (dist/../templates)
635
+ * 3. Relative to source (src/../templates)
636
+ */
637
+ export function resolveTemplatesDir(): string | null {
638
+ const cwd = process.cwd();
639
+ const candidates = [
640
+ resolve(cwd, 'node_modules/@massu/core/templates'),
641
+ resolve(__dirname, '../../templates'),
642
+ resolve(__dirname, '../../../templates'),
643
+ ];
644
+ for (const c of candidates) {
645
+ if (existsSync(c)) return c;
646
+ }
647
+ return null;
648
+ }
649
+
650
+ export function copyTemplateConfig(
651
+ templateName: TemplateName,
652
+ targetPath: string,
653
+ projectName: string
654
+ ): { success: boolean; error?: string } {
655
+ const templatesDir = resolveTemplatesDir();
656
+ if (!templatesDir) {
657
+ return { success: false, error: `Templates directory not found (looked in node_modules and dist/src)` };
658
+ }
659
+ const srcPath = resolve(templatesDir, templateName, 'massu.config.yaml');
660
+ if (!existsSync(srcPath)) {
661
+ return { success: false, error: `Template '${templateName}' not found at ${srcPath}` };
662
+ }
663
+ try {
664
+ let content = readFileSync(srcPath, 'utf-8');
665
+ // Replace {{PROJECT_NAME}} placeholder if present.
666
+ content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
667
+ writeFileSync(targetPath, content, 'utf-8');
668
+ return { success: true };
669
+ } catch (err) {
670
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
671
+ }
315
672
  }
316
673
 
317
674
  // ============================================================
318
- // MCP Server Registration
675
+ // MCP Server Registration (preserved)
319
676
  // ============================================================
320
677
 
321
678
  export function registerMcpServer(projectRoot: string): boolean {
@@ -330,13 +687,11 @@ export function registerMcpServer(projectRoot: string): boolean {
330
687
  }
331
688
  }
332
689
 
333
- // Check if already registered
334
690
  const servers = (existing.mcpServers ?? {}) as Record<string, unknown>;
335
691
  if (servers.massu) {
336
- return false; // Already registered
692
+ return false;
337
693
  }
338
694
 
339
- // Add massu server
340
695
  servers.massu = {
341
696
  type: 'stdio',
342
697
  command: 'npx',
@@ -350,7 +705,7 @@ export function registerMcpServer(projectRoot: string): boolean {
350
705
  }
351
706
 
352
707
  // ============================================================
353
- // Hook Installation
708
+ // Hook Installation (preserved)
354
709
  // ============================================================
355
710
 
356
711
  interface HookEntry {
@@ -366,23 +721,21 @@ interface HookGroup {
366
721
 
367
722
  type HooksConfig = Record<string, HookGroup[]>;
368
723
 
369
- /**
370
- * Resolve the path to compiled hook files.
371
- * Handles both local development and npm-installed scenarios.
372
- */
373
724
  export function resolveHooksDir(): string {
374
- // Always use node_modules/@massu/core/dist/hooks relative to project root.
375
- // hookCmd() wraps each command with a parent-directory walk to find the
376
- // project root, so hooks resolve correctly even from subdirectories.
725
+ const cwd = process.cwd();
726
+ const nodeModulesPath = resolve(cwd, 'node_modules/@massu/core/dist/hooks');
727
+ if (existsSync(nodeModulesPath)) {
728
+ return 'node_modules/@massu/core/dist/hooks';
729
+ }
730
+ const localPath = resolve(__dirname, '../dist/hooks');
731
+ if (existsSync(localPath)) {
732
+ return localPath;
733
+ }
377
734
  return 'node_modules/@massu/core/dist/hooks';
378
735
  }
379
736
 
380
737
  function hookCmd(hooksDir: string, hookFile: string): string {
381
- // Walk up from cwd to find the directory containing node_modules/@massu/core,
382
- // then cd there before running the hook. This handles subdirectories like
383
- // website/, packages/foo/, etc. where node_modules doesn't exist.
384
- const hookPath = `${hooksDir}/${hookFile}`;
385
- return `d="$PWD"; while [ "$d" != "/" ] && [ ! -f "$d/${hookPath}" ]; do d="$(dirname "$d")"; done; cd "$d" && node ${hookPath}`;
738
+ return `node ${hooksDir}/${hookFile}`;
386
739
  }
387
740
 
388
741
  export function buildHooksConfig(hooksDir: string): HooksConfig {
@@ -420,6 +773,8 @@ export function buildHooksConfig(hooksDir: string): HooksConfig {
420
773
  matcher: 'Edit|Write',
421
774
  hooks: [
422
775
  { type: 'command', command: hookCmd(hooksDir, 'post-edit-context.js'), timeout: 5 },
776
+ // Auto-learning pipeline — classifies failures and detects fixes on
777
+ // file changes. See Phase 5-6 of the autodetect plan.
423
778
  { type: 'command', command: hookCmd(hooksDir, 'fix-detector.js'), timeout: 5 },
424
779
  { type: 'command', command: hookCmd(hooksDir, 'classify-failure.js'), timeout: 5 },
425
780
  ],
@@ -427,6 +782,8 @@ export function buildHooksConfig(hooksDir: string): HooksConfig {
427
782
  {
428
783
  matcher: 'Write',
429
784
  hooks: [
785
+ // Incident + rule enforcement pipelines fire on Write-only (incidents
786
+ // are authored as .md files; rules are enforced after new-file drops).
430
787
  { type: 'command', command: hookCmd(hooksDir, 'incident-pipeline.js'), timeout: 5 },
431
788
  { type: 'command', command: hookCmd(hooksDir, 'rule-enforcement-pipeline.js'), timeout: 5 },
432
789
  ],
@@ -436,6 +793,7 @@ export function buildHooksConfig(hooksDir: string): HooksConfig {
436
793
  {
437
794
  hooks: [
438
795
  { type: 'command', command: hookCmd(hooksDir, 'session-end.js'), timeout: 15 },
796
+ // Session-end auto-learning aggregation (failure-class roll-up).
439
797
  { type: 'command', command: hookCmd(hooksDir, 'auto-learning-pipeline.js'), timeout: 10 },
440
798
  ],
441
799
  },
@@ -459,16 +817,22 @@ export function buildHooksConfig(hooksDir: string): HooksConfig {
459
817
  }
460
818
 
461
819
  export function installHooks(projectRoot: string): { installed: boolean; count: number } {
462
- const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
820
+ // Read claudeDirName defensively tests may call installHooks without
821
+ // ever creating massu.config.yaml, in which case getConfig() throws (since
822
+ // it reads against process.cwd() and our cwd may not have one).
823
+ let claudeDirName = '.claude';
824
+ try {
825
+ claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
826
+ } catch {
827
+ claudeDirName = '.claude';
828
+ }
463
829
  const claudeDir = resolve(projectRoot, claudeDirName);
464
830
  const settingsPath = resolve(claudeDir, 'settings.local.json');
465
831
 
466
- // Ensure .claude directory exists
467
832
  if (!existsSync(claudeDir)) {
468
833
  mkdirSync(claudeDir, { recursive: true });
469
834
  }
470
835
 
471
- // Read existing settings
472
836
  let settings: Record<string, unknown> = {};
473
837
  if (existsSync(settingsPath)) {
474
838
  try {
@@ -478,13 +842,9 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
478
842
  }
479
843
  }
480
844
 
481
- // Resolve hook paths
482
845
  const hooksDir = resolveHooksDir();
483
-
484
- // Build hooks config
485
846
  const hooksConfig = buildHooksConfig(hooksDir);
486
847
 
487
- // Count total hooks
488
848
  let hookCount = 0;
489
849
  for (const groups of Object.values(hooksConfig)) {
490
850
  for (const group of groups) {
@@ -492,7 +852,6 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
492
852
  }
493
853
  }
494
854
 
495
- // Merge hooks into settings (replace hooks section, preserve everything else)
496
855
  settings.hooks = hooksConfig;
497
856
 
498
857
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
@@ -501,16 +860,10 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
501
860
  }
502
861
 
503
862
  // ============================================================
504
- // Memory Directory Initialization
863
+ // Memory Directory Initialization (preserved)
505
864
  // ============================================================
506
865
 
507
- /**
508
- * Initialize the memory directory and create an initial MEMORY.md if absent.
509
- * The memory directory lives in ~/.claude/projects/<encoded-root>/memory/
510
- * matching the path used by memory-db.ts / knowledge-tools.ts.
511
- */
512
866
  export function initMemoryDir(projectRoot: string): { created: boolean; memoryMdCreated: boolean } {
513
- // Encode the project root the same way as getResolvedPaths() in config.ts
514
867
  const encodedRoot = '-' + projectRoot.replace(/\//g, '-');
515
868
  const memoryDir = resolve(homedir(), `.claude/projects/${encodedRoot}/memory`);
516
869
 
@@ -546,124 +899,320 @@ export function initMemoryDir(projectRoot: string): { created: boolean; memoryMd
546
899
  }
547
900
 
548
901
  // ============================================================
549
- // Main Init Flow
902
+ // Flag Parsing
550
903
  // ============================================================
551
904
 
552
- export async function runInit(): Promise<void> {
553
- const projectRoot = process.cwd();
554
-
555
- console.log('');
556
- console.log('Massu AI - Project Setup');
557
- console.log('========================');
558
- console.log('');
559
-
560
- // Step 1: Detect framework
561
- const framework = detectFramework(projectRoot);
562
- const frameworkParts: string[] = [];
563
- if (framework.type !== 'javascript') frameworkParts.push(capitalize(framework.type));
564
- if (framework.ui !== 'none') frameworkParts.push(formatName(framework.ui));
565
- if (framework.orm !== 'none') frameworkParts.push(capitalize(framework.orm));
566
- if (framework.router !== 'none') frameworkParts.push(framework.router.toUpperCase());
567
- const detected = frameworkParts.length > 0 ? frameworkParts.join(', ') : 'JavaScript';
568
- console.log(` Detected: ${detected}`);
569
-
570
- // Step 1b: Detect Python
571
- const python = detectPython(projectRoot);
572
- if (python.detected) {
573
- const pyParts: string[] = ['Python'];
574
- if (python.hasFastapi) pyParts.push('FastAPI');
575
- if (python.hasSqlalchemy) pyParts.push('SQLAlchemy');
576
- if (python.hasAlembic) pyParts.push('Alembic');
577
- console.log(` Detected: ${pyParts.join(', ')} (root: ${python.root})`);
905
+ export interface ParseInitArgsResult extends InitOptions {
906
+ /** True when --help / -h was requested. runInit should print help and exit. */
907
+ help?: boolean;
908
+ }
909
+
910
+ export function parseInitArgs(argv: string[]): ParseInitArgsResult {
911
+ const opts: ParseInitArgsResult = {};
912
+ for (let i = 0; i < argv.length; i++) {
913
+ const a = argv[i];
914
+ if (a === '--ci') opts.ci = true;
915
+ else if (a === '--force') opts.force = true;
916
+ else if (a === '--help' || a === '-h') opts.help = true;
917
+ else if (a === '--template') {
918
+ const next = argv[i + 1];
919
+ if (next && !next.startsWith('--')) {
920
+ opts.template = next;
921
+ i++;
922
+ }
923
+ } else if (a.startsWith('--template=')) {
924
+ opts.template = a.slice('--template='.length);
925
+ }
578
926
  }
927
+ return opts;
928
+ }
579
929
 
580
- // Step 1.5: Generate CLAUDE.md (MUST be first content step — incident 2026-04-13)
581
- const claudeMdResult = generateClaudeMd(projectRoot, framework, python);
582
- if (claudeMdResult.created) {
583
- console.log(' Created CLAUDE.md (project instructions for Claude Code)');
584
- } else {
585
- console.log(' CLAUDE.md already exists (preserved)');
930
+ export function printInitHelp(): void {
931
+ console.log(`
932
+ massu init — detect project stack and generate massu.config.yaml
933
+
934
+ Usage:
935
+ massu init [options]
936
+
937
+ Options:
938
+ --ci Non-interactive mode. Errors on existing config
939
+ (unless --force). Auto-enabled when stdin is not a TTY.
940
+ --force Overwrite existing massu.config.yaml without prompting.
941
+ --template <name> Skip detection and scaffold from a greenfield template.
942
+ Templates: ${TEMPLATE_NAMES.join(', ')}
943
+ --help, -h Show this help message
944
+
945
+ Examples:
946
+ massu init # Interactive (prompts before overwriting)
947
+ massu init --ci # Safe for CI; fails if config already exists
948
+ massu init --force # Overwrite an existing config
949
+ massu init --template ts-nextjs # Scaffold from the Next.js template
950
+
951
+ Documentation: https://massu.ai/docs/getting-started/configuration
952
+ `);
953
+ }
954
+
955
+ // ============================================================
956
+ // Stack summary (for user confirmation)
957
+ // ============================================================
958
+
959
+ function summarizeDetection(detection: DetectionResult): string {
960
+ const parts: string[] = [];
961
+ const languages = Array.from(
962
+ new Set(detection.manifests.map((m) => m.language))
963
+ ) as SupportedLanguage[];
964
+ for (const lang of languages) {
965
+ const fw = detection.frameworks[lang];
966
+ const dirs = detection.sourceDirs[lang]?.source_dirs ?? [];
967
+ const dirSuffix = dirs.length > 0 ? ` in ${dirs.join(',')}` : '';
968
+ const fwName = fw?.framework ?? 'no-framework';
969
+ parts.push(`${capitalize(lang)}/${fwName}${dirSuffix}`);
586
970
  }
971
+ const mono = detection.monorepo.type;
972
+ const monoSuffix = mono && mono !== 'single' ? ` [${mono} monorepo]` : '';
973
+ return parts.join('; ') + monoSuffix;
974
+ }
587
975
 
588
- // Step 2: Create config
589
- const configCreated = generateConfig(projectRoot, framework);
590
- if (configCreated) {
591
- console.log(' Created massu.config.yaml');
592
- } else {
593
- console.log(' massu.config.yaml already exists (preserved)');
976
+ // ============================================================
977
+ // Main Init Flow (Phase 3 rewrite)
978
+ // ============================================================
979
+
980
+ export async function runInit(argv?: string[], overrides?: InitOptions): Promise<void> {
981
+ const argsToParse = argv ?? process.argv.slice(3); // argv[0]=node, [1]=cli.js, [2]='init'
982
+ const parsed = parseInitArgs(argsToParse);
983
+ if (parsed.help && !overrides?.silent) {
984
+ printInitHelp();
985
+ return;
986
+ }
987
+ // Strip `help` from parsed before merging (not part of InitOptions).
988
+ const { help: _help, ...parsedOpts } = parsed;
989
+ void _help;
990
+ const opts: InitOptions = { ...parsedOpts, ...(overrides ?? {}) };
991
+
992
+ // Auto-CI when stdin is not a TTY (e.g., CI pipes, scripts).
993
+ if (!opts.ci && !process.stdin.isTTY) {
994
+ opts.ci = true;
995
+ }
996
+
997
+ const projectRoot = opts.cwd ?? process.cwd();
998
+ const log = opts.silent ? () => {} : (s: string) => console.log(s);
999
+ const errLog = opts.silent ? () => {} : (s: string) => console.error(s);
1000
+
1001
+ log('');
1002
+ log('Massu AI - Project Setup');
1003
+ log('========================');
1004
+ log('');
1005
+
1006
+ const configPath = resolve(projectRoot, 'massu.config.yaml');
1007
+
1008
+ // P3-006: safety rails for existing config.
1009
+ if (existsSync(configPath)) {
1010
+ if (opts.ci && !opts.force) {
1011
+ errLog(`error: massu.config.yaml already exists at ${configPath}`);
1012
+ errLog(' rerun with --force to overwrite, or remove the file first');
1013
+ throw new Error('massu init: config exists in --ci mode (no overwrite)');
1014
+ }
1015
+ if (!opts.ci && !opts.force) {
1016
+ // Interactive: prompt to confirm overwrite.
1017
+ const confirmed = await promptOverwrite(configPath);
1018
+ if (!confirmed) {
1019
+ log(' massu.config.yaml preserved — init aborted');
1020
+ return;
1021
+ }
1022
+ }
1023
+ // else: --force set, proceed with overwrite
594
1024
  }
595
1025
 
596
- // Step 3: Register MCP server
1026
+ // Branch 1: template mode (P3-003)
1027
+ if (opts.template) {
1028
+ if (!isTemplateName(opts.template)) {
1029
+ errLog(`error: unknown template '${opts.template}'. Available: ${TEMPLATE_NAMES.join(', ')}`);
1030
+ throw new Error(`Unknown template: ${opts.template}`);
1031
+ }
1032
+ const projectName = basename(projectRoot);
1033
+ const res = copyTemplateConfig(opts.template, configPath, projectName);
1034
+ if (!res.success) {
1035
+ errLog(`error: template copy failed: ${res.error}`);
1036
+ throw new Error(res.error ?? 'template copy failed');
1037
+ }
1038
+ // Validate the template-derived config (skip filesystem existence checks:
1039
+ // templates are explicitly for greenfield projects where the declared dirs
1040
+ // don't exist yet).
1041
+ const validation = validateWrittenConfig(configPath, projectRoot, false);
1042
+ if (validation !== null) {
1043
+ try { rmSync(configPath, { force: true }); } catch { /* ignore */ }
1044
+ errLog(`error: template config failed validation: ${validation}`);
1045
+ throw new Error(`Template config invalid: ${validation}`);
1046
+ }
1047
+ log(` Installed template '${opts.template}' → massu.config.yaml`);
1048
+ if (!opts.skipSideEffects) {
1049
+ installSideEffects(projectRoot, log);
1050
+ }
1051
+ return;
1052
+ }
1053
+
1054
+ // Branch 2: detection-driven path (P3-001, P3-002)
1055
+ const detection = await runDetection(projectRoot);
1056
+ const languageCount = new Set(detection.manifests.map((m) => m.language)).size;
1057
+ if (detection.manifests.length === 0 && languageCount === 0) {
1058
+ errLog('error: no languages detected in this directory');
1059
+ errLog(' (no package.json, pyproject.toml, Cargo.toml, etc.)');
1060
+ errLog(' pass --template <name> to scaffold a new project, or cd into a repo with a manifest');
1061
+ throw new Error('No languages detected');
1062
+ }
1063
+
1064
+ // Emit warnings to stderr for ambiguous / malformed detection.
1065
+ for (const w of detection.warnings) {
1066
+ errLog(`warning: ${w.path}: ${w.reason}`);
1067
+ }
1068
+
1069
+ // Ambiguity warning: multiple languages with similar file density.
1070
+ const dirCounts: { lang: SupportedLanguage; count: number }[] = [];
1071
+ for (const [lang, info] of Object.entries(detection.sourceDirs)) {
1072
+ if (info && typeof info.file_count === 'number') {
1073
+ dirCounts.push({ lang: lang as SupportedLanguage, count: info.file_count });
1074
+ }
1075
+ }
1076
+ if (dirCounts.length >= 2) {
1077
+ dirCounts.sort((a, b) => b.count - a.count);
1078
+ if (dirCounts[0].count > 0 && dirCounts[1].count / Math.max(dirCounts[0].count, 1) >= 0.5) {
1079
+ errLog(`warning: multiple languages with similar file counts: ${dirCounts.map(d => `${d.lang}=${d.count}`).join(', ')}`);
1080
+ errLog(' primary language chosen by manifest count; review framework.primary in the generated config');
1081
+ }
1082
+ }
1083
+
1084
+ log(` Detected: ${summarizeDetection(detection)}`);
1085
+
1086
+ // Interactive confirmation for detected stack.
1087
+ if (!opts.ci && !opts.force) {
1088
+ const confirmed = await promptStackConfirm();
1089
+ if (!confirmed) {
1090
+ log(' init aborted — no changes made');
1091
+ return;
1092
+ }
1093
+ }
1094
+
1095
+ // Build config + write atomically.
1096
+ const config = buildConfigFromDetection({ projectRoot, detection });
1097
+ const content = renderConfigYaml(config);
1098
+ const writeRes = writeConfigAtomic(configPath, content);
1099
+ if (!writeRes.validated) {
1100
+ errLog(`error: failed to write config: ${writeRes.error}`);
1101
+ throw new Error(writeRes.error ?? 'atomic write failed');
1102
+ }
1103
+
1104
+ // Post-write validation; rollback on failure.
1105
+ const validation = validateWrittenConfig(configPath, projectRoot);
1106
+ if (validation !== null) {
1107
+ try { rmSync(configPath, { force: true }); } catch { /* ignore */ }
1108
+ errLog(`error: generated config failed validation: ${validation}`);
1109
+ errLog(' config file rolled back; no changes persisted');
1110
+ throw new Error(`Generated config invalid: ${validation}`);
1111
+ }
1112
+
1113
+ log(' Created massu.config.yaml (schema_version: 2)');
1114
+
1115
+ if (!opts.skipSideEffects) {
1116
+ installSideEffects(projectRoot, log);
1117
+ }
1118
+ }
1119
+
1120
+ /** Shared side-effect steps (MCP register + hooks + commands + memory + backfill). */
1121
+ function installSideEffects(projectRoot: string, log: (s: string) => void): void {
1122
+ // MCP register
597
1123
  const mcpRegistered = registerMcpServer(projectRoot);
598
1124
  if (mcpRegistered) {
599
- console.log(' Registered MCP server in .mcp.json');
1125
+ log(' Registered MCP server in .mcp.json');
600
1126
  } else {
601
- console.log(' MCP server already registered in .mcp.json');
1127
+ log(' MCP server already registered in .mcp.json');
602
1128
  }
603
1129
 
604
- // Step 4: Install hooks
1130
+ // Hooks
605
1131
  const { count: hooksCount } = installHooks(projectRoot);
606
- console.log(` Installed ${hooksCount} hooks in .claude/settings.local.json`);
1132
+ log(` Installed ${hooksCount} hooks in .claude/settings.local.json`);
607
1133
 
608
- // Step 5: Install slash commands
609
- const cmdResult = installCommands(projectRoot);
610
- const cmdTotal = cmdResult.installed + cmdResult.updated + cmdResult.skipped;
611
- if (cmdResult.installed > 0 || cmdResult.updated > 0) {
612
- console.log(` Installed ${cmdTotal} slash commands (${cmdResult.installed} new, ${cmdResult.updated} updated)`);
613
- } else {
614
- console.log(` ${cmdTotal} slash commands already up to date`);
1134
+ // Commands
1135
+ try {
1136
+ const cmdResult = installCommands(projectRoot);
1137
+ const cmdTotal = cmdResult.installed + cmdResult.updated + cmdResult.skipped;
1138
+ if (cmdResult.installed > 0 || cmdResult.updated > 0) {
1139
+ log(` Installed ${cmdTotal} slash commands (${cmdResult.installed} new, ${cmdResult.updated} updated)`);
1140
+ } else if (cmdTotal > 0) {
1141
+ log(` ${cmdTotal} slash commands already up to date`);
1142
+ }
1143
+ } catch {
1144
+ // Best-effort — don't fail init if assets can't be resolved.
615
1145
  }
616
1146
 
617
- // Step 6: Initialize memory directory
1147
+ // Memory dir
618
1148
  const { created: memDirCreated, memoryMdCreated } = initMemoryDir(projectRoot);
619
1149
  if (memDirCreated) {
620
- console.log(' Created memory directory (~/.claude/projects/.../memory/)');
621
- } else {
622
- console.log(' Memory directory already exists');
1150
+ log(' Created memory directory');
623
1151
  }
624
1152
  if (memoryMdCreated) {
625
- console.log(' Created initial MEMORY.md');
1153
+ log(' Created initial MEMORY.md');
626
1154
  }
627
1155
 
628
- // Step 6b: Auto-backfill existing memory files into database
629
- try {
630
- const claudeDirName = '.claude';
631
- const encodedRoot = projectRoot.replace(/\//g, '-');
632
- const computedMemoryDir = resolve(homedir(), claudeDirName, 'projects', encodedRoot, 'memory');
633
-
634
- const memFiles = existsSync(computedMemoryDir)
635
- ? readdirSync(computedMemoryDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md')
636
- : [];
637
-
638
- if (memFiles.length > 0) {
639
- const { getMemoryDb } = await import('../memory-db.ts');
640
- const db = getMemoryDb();
641
- try {
642
- const stats = backfillMemoryFiles(db, computedMemoryDir, `init-${Date.now()}`);
643
- if (stats.inserted > 0 || stats.updated > 0) {
644
- console.log(` Backfilled ${stats.inserted + stats.updated} memory files into database (${stats.inserted} new, ${stats.updated} updated)`);
1156
+ // Backfill (best-effort, silent failure)
1157
+ (async () => {
1158
+ try {
1159
+ const encodedRoot = projectRoot.replace(/\//g, '-');
1160
+ const memoryDir = resolve(homedir(), '.claude', 'projects', encodedRoot, 'memory');
1161
+ const memFiles = existsSync(memoryDir)
1162
+ ? readdirSync(memoryDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md')
1163
+ : [];
1164
+ if (memFiles.length > 0) {
1165
+ const { getMemoryDb } = await import('../memory-db.ts');
1166
+ const db = getMemoryDb();
1167
+ try {
1168
+ const stats = backfillMemoryFiles(db, memoryDir, `init-${Date.now()}`);
1169
+ if (stats.inserted > 0 || stats.updated > 0) {
1170
+ log(` Backfilled ${stats.inserted + stats.updated} memory files (${stats.inserted} new, ${stats.updated} updated)`);
1171
+ }
1172
+ } finally {
1173
+ db.close();
645
1174
  }
646
- } finally {
647
- db.close();
648
1175
  }
649
- }
650
- } catch (_backfillErr) {
651
- // Best-effort: don't fail init if backfill fails
652
- }
1176
+ } catch { /* best effort */ }
1177
+ })();
653
1178
 
654
- // Step 7: Databases info
655
- console.log(' Databases will auto-create on first session');
1179
+ log(' Databases will auto-create on first session');
1180
+ log('');
1181
+ log('Massu AI is ready. Start a Claude Code session to begin.');
1182
+ log('');
1183
+ }
656
1184
 
657
- // Summary
658
- console.log('');
659
- console.log('Massu AI is ready. Start a Claude Code session to begin.');
660
- console.log('');
661
- console.log('Next steps:');
662
- console.log(' claude # Start a session (hooks activate automatically)');
663
- console.log(' npx massu doctor # Verify installation health');
664
- console.log('');
665
- console.log('Documentation: https://massu.ai/docs');
666
- console.log('');
1185
+ // ============================================================
1186
+ // Prompts (interactive path)
1187
+ // ============================================================
1188
+
1189
+ async function promptOverwrite(configPath: string): Promise<boolean> {
1190
+ try {
1191
+ const { confirm, isCancel } = await import('@clack/prompts');
1192
+ const res = await confirm({
1193
+ message: `massu.config.yaml already exists at ${configPath}. Overwrite?`,
1194
+ initialValue: false,
1195
+ });
1196
+ if (isCancel(res)) return false;
1197
+ return res === true;
1198
+ } catch {
1199
+ // Clack not available (should never happen — it's a dep); fail safe to NO.
1200
+ return false;
1201
+ }
1202
+ }
1203
+
1204
+ async function promptStackConfirm(): Promise<boolean> {
1205
+ try {
1206
+ const { confirm, isCancel } = await import('@clack/prompts');
1207
+ const res = await confirm({
1208
+ message: 'Generate massu.config.yaml from detected stack?',
1209
+ initialValue: true,
1210
+ });
1211
+ if (isCancel(res)) return false;
1212
+ return res === true;
1213
+ } catch {
1214
+ return true; // Default yes when clack is unavailable.
1215
+ }
667
1216
  }
668
1217
 
669
1218
  // ============================================================
@@ -674,14 +1223,5 @@ function capitalize(str: string): string {
674
1223
  return str.charAt(0).toUpperCase() + str.slice(1);
675
1224
  }
676
1225
 
677
- function formatName(name: string): string {
678
- const names: Record<string, string> = {
679
- nextjs: 'Next.js',
680
- sveltekit: 'SvelteKit',
681
- nuxt: 'Nuxt',
682
- angular: 'Angular',
683
- vue: 'Vue',
684
- react: 'React',
685
- };
686
- return names[name] ?? capitalize(name);
687
- }
1226
+ // `InitResult` is a compile-time type only; it's kept for external type-reuse.
1227
+ export type { InitResult };