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