@massu/core 1.10.8 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +1334 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +581 -448
  4. package/dist/detect/adapters/.bundle-shasums.json +4 -7
  5. package/dist/detect/adapters/rails.js +6 -4
  6. package/dist/detect/adapters/spring.js +6 -4
  7. package/dist/detect/adapters/tree-sitter-loader.js +6 -4
  8. package/dist/hooks/auto-learning-pipeline.js +24 -1
  9. package/dist/hooks/classify-failure.js +32 -3
  10. package/dist/hooks/cost-tracker.js +24 -0
  11. package/dist/hooks/fix-detector.js +97 -1
  12. package/dist/hooks/incident-pipeline.js +30 -1
  13. package/dist/hooks/intent-suggester.js +6 -3
  14. package/dist/hooks/post-edit-context.js +24 -1
  15. package/dist/hooks/post-tool-use.js +67 -19
  16. package/dist/hooks/pre-compact.js +28 -3
  17. package/dist/hooks/pre-delete-check.js +53 -27
  18. package/dist/hooks/pre-tool-use-gate.js +962 -0
  19. package/dist/hooks/quality-event.js +24 -0
  20. package/dist/hooks/rule-enforcement-pipeline.js +24 -1
  21. package/dist/hooks/security-gate.js +29 -16
  22. package/dist/hooks/session-end.js +31 -6
  23. package/dist/hooks/session-start.js +8709 -8459
  24. package/dist/hooks/user-prompt.js +25 -1
  25. package/package.json +15 -18
  26. package/commands/README.md +0 -134
  27. package/src/adapter.ts +0 -121
  28. package/src/adr-generator.ts +0 -292
  29. package/src/analytics.ts +0 -373
  30. package/src/audit-trail.ts +0 -450
  31. package/src/backfill-sessions.ts +0 -182
  32. package/src/changelog-generator.ts +0 -178
  33. package/src/cli.ts +0 -246
  34. package/src/cloud-sync.ts +0 -234
  35. package/src/commands/adapters.ts +0 -824
  36. package/src/commands/changelog.ts +0 -165
  37. package/src/commands/config-check-drift.ts +0 -133
  38. package/src/commands/config-refresh.ts +0 -396
  39. package/src/commands/config-upgrade.ts +0 -127
  40. package/src/commands/doctor.ts +0 -485
  41. package/src/commands/hook-runner.ts +0 -145
  42. package/src/commands/init.ts +0 -1823
  43. package/src/commands/install-commands.ts +0 -692
  44. package/src/commands/install-hooks.ts +0 -27
  45. package/src/commands/permissions.ts +0 -150
  46. package/src/commands/refresh-log.ts +0 -37
  47. package/src/commands/show-template.ts +0 -65
  48. package/src/commands/template-engine.ts +0 -301
  49. package/src/commands/watch.ts +0 -430
  50. package/src/config.ts +0 -765
  51. package/src/cost-tracker.ts +0 -355
  52. package/src/db.ts +0 -370
  53. package/src/dependency-scorer.ts +0 -337
  54. package/src/detect/adapters/aspnet.ts +0 -4
  55. package/src/detect/adapters/discover.ts +0 -469
  56. package/src/detect/adapters/file-sampler.ts +0 -225
  57. package/src/detect/adapters/go-chi.ts +0 -4
  58. package/src/detect/adapters/index.ts +0 -51
  59. package/src/detect/adapters/nextjs-trpc.ts +0 -166
  60. package/src/detect/adapters/parse-guard.ts +0 -133
  61. package/src/detect/adapters/phoenix.ts +0 -4
  62. package/src/detect/adapters/python-django.ts +0 -208
  63. package/src/detect/adapters/python-fastapi.ts +0 -223
  64. package/src/detect/adapters/python-flask.ts +0 -235
  65. package/src/detect/adapters/query-helpers.ts +0 -170
  66. package/src/detect/adapters/rails.ts +0 -4
  67. package/src/detect/adapters/runner.ts +0 -284
  68. package/src/detect/adapters/spring.ts +0 -4
  69. package/src/detect/adapters/swift-swiftui.ts +0 -171
  70. package/src/detect/adapters/tree-sitter-loader.ts +0 -517
  71. package/src/detect/adapters/types.ts +0 -191
  72. package/src/detect/codebase-introspector.ts +0 -210
  73. package/src/detect/domain-inferrer.ts +0 -197
  74. package/src/detect/drift.ts +0 -199
  75. package/src/detect/framework-detector.ts +0 -307
  76. package/src/detect/index.ts +0 -209
  77. package/src/detect/manifest-registry.ts +0 -261
  78. package/src/detect/migrate.ts +0 -362
  79. package/src/detect/monorepo-detector.ts +0 -348
  80. package/src/detect/package-detector.ts +0 -828
  81. package/src/detect/passthrough.ts +0 -108
  82. package/src/detect/regex-fallback.ts +0 -449
  83. package/src/detect/source-dir-detector.ts +0 -297
  84. package/src/detect/vr-command-map.ts +0 -167
  85. package/src/docs-map.json +0 -100
  86. package/src/docs-tools.ts +0 -519
  87. package/src/domains.ts +0 -182
  88. package/src/hooks/auto-learning-pipeline.ts +0 -228
  89. package/src/hooks/classify-failure.ts +0 -259
  90. package/src/hooks/cost-tracker.ts +0 -66
  91. package/src/hooks/fix-detector.ts +0 -186
  92. package/src/hooks/incident-pipeline.ts +0 -190
  93. package/src/hooks/intent-suggester.ts +0 -131
  94. package/src/hooks/post-edit-context.ts +0 -91
  95. package/src/hooks/post-tool-use.ts +0 -413
  96. package/src/hooks/pre-compact.ts +0 -168
  97. package/src/hooks/pre-delete-check.ts +0 -243
  98. package/src/hooks/quality-event.ts +0 -127
  99. package/src/hooks/rule-enforcement-pipeline.ts +0 -159
  100. package/src/hooks/security-gate.ts +0 -153
  101. package/src/hooks/session-end.ts +0 -467
  102. package/src/hooks/session-start.ts +0 -436
  103. package/src/hooks/user-prompt.ts +0 -157
  104. package/src/import-resolver.ts +0 -226
  105. package/src/knowledge-db.ts +0 -176
  106. package/src/knowledge-indexer.ts +0 -708
  107. package/src/knowledge-tools.ts +0 -1414
  108. package/src/lib/fileLock.ts +0 -203
  109. package/src/lib/framework-supports.ts +0 -85
  110. package/src/lib/gitToplevel.ts +0 -22
  111. package/src/lib/hook-registry.ts +0 -43
  112. package/src/lib/installLock.ts +0 -66
  113. package/src/lib/memory-path.ts +0 -49
  114. package/src/lib/pidLiveness.ts +0 -67
  115. package/src/lib/settings-local.ts +0 -110
  116. package/src/lib/sql-table-names.ts +0 -71
  117. package/src/license.ts +0 -547
  118. package/src/lsp/auto-detect.ts +0 -98
  119. package/src/lsp/client.ts +0 -776
  120. package/src/lsp/enrich.ts +0 -127
  121. package/src/lsp/types.ts +0 -221
  122. package/src/memory-db.ts +0 -1614
  123. package/src/memory-file-ingest.ts +0 -128
  124. package/src/memory-tools.ts +0 -424
  125. package/src/middleware-tree.ts +0 -71
  126. package/src/observability-tools.ts +0 -343
  127. package/src/observation-extractor.ts +0 -418
  128. package/src/page-deps.ts +0 -285
  129. package/src/permissions.ts +0 -422
  130. package/src/prompt-analyzer.ts +0 -332
  131. package/src/python/coupling-detector.ts +0 -125
  132. package/src/python/domain-enforcer.ts +0 -84
  133. package/src/python/impact-analyzer.ts +0 -96
  134. package/src/python/import-parser.ts +0 -244
  135. package/src/python/import-resolver.ts +0 -136
  136. package/src/python/migration-indexer.ts +0 -116
  137. package/src/python/migration-parser.ts +0 -332
  138. package/src/python/model-indexer.ts +0 -71
  139. package/src/python/model-parser.ts +0 -279
  140. package/src/python/route-indexer.ts +0 -59
  141. package/src/python/route-parser.ts +0 -317
  142. package/src/python-tools.ts +0 -630
  143. package/src/regression-detector.ts +0 -319
  144. package/src/rules.ts +0 -57
  145. package/src/schema-mapper.ts +0 -232
  146. package/src/security/adapter-origin.ts +0 -130
  147. package/src/security/adapter-verifier.ts +0 -319
  148. package/src/security/atomic-write.ts +0 -164
  149. package/src/security/fetcher.ts +0 -200
  150. package/src/security/install-tracking.ts +0 -319
  151. package/src/security/license-pubkey.generated.ts +0 -30
  152. package/src/security/license-response-verifier.ts +0 -149
  153. package/src/security/local-fingerprint.ts +0 -225
  154. package/src/security/manifest-cache.ts +0 -333
  155. package/src/security/manifest-schema.ts +0 -129
  156. package/src/security/registry-pubkey.generated.ts +0 -35
  157. package/src/security/telemetry.ts +0 -320
  158. package/src/security-scorer.ts +0 -405
  159. package/src/security-utils.ts +0 -133
  160. package/src/sentinel-db.ts +0 -580
  161. package/src/sentinel-scanner.ts +0 -406
  162. package/src/sentinel-tools.ts +0 -513
  163. package/src/sentinel-types.ts +0 -140
  164. package/src/server-dispatch.ts +0 -225
  165. package/src/server.ts +0 -107
  166. package/src/session-archiver.ts +0 -111
  167. package/src/session-state-generator.ts +0 -174
  168. package/src/team-knowledge.ts +0 -407
  169. package/src/tool-db-needs.ts +0 -232
  170. package/src/tools.ts +0 -1206
  171. package/src/transcript-parser.ts +0 -458
  172. package/src/trpc-index.ts +0 -215
  173. package/src/validate-features-runner.ts +0 -107
  174. package/src/validation-engine.ts +0 -358
  175. package/src/watch/daemon.ts +0 -385
  176. package/src/watch/lockfile-detector.ts +0 -65
  177. package/src/watch/paths.ts +0 -279
  178. package/src/watch/state.ts +0 -178
@@ -1,1823 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * `massu init` — One-command, detection-driven project setup.
6
- *
7
- * Phase 3 rewrite (2026-04-19): replaces the JS/TS template copier (old
8
- * detectFramework/generateConfig path, root cause of multi-runtime stale-config drift)
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.
27
- */
28
-
29
- import { closeSync, existsSync, fsyncSync, openSync, readFileSync, writeFileSync, writeSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, chmodSync } from 'fs';
30
- import { resolve, basename, dirname } from 'path';
31
- import { fileURLToPath } from 'url';
32
- import { homedir } from 'os';
33
- import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
34
- import { backfillMemoryFiles } from '../memory-file-ingest.ts';
35
- import { getConfig, resetConfig } from '../config.ts';
36
- import { installAll } from './install-commands.ts';
37
- import { readSettingsLocal, writeSettingsLocalAtomic } from '../lib/settings-local.ts';
38
- import { encodeMemoryDirName } from '../lib/memory-path.ts';
39
- import {
40
- runDetection,
41
- type DetectionResult,
42
- type SupportedLanguage,
43
- type VRCommandSet,
44
- } from '../detect/index.ts';
45
- import { computeFingerprint } from '../detect/drift.ts';
46
-
47
- const __filename = fileURLToPath(import.meta.url);
48
- const __dirname = dirname(__filename);
49
-
50
- // ============================================================
51
- // Types
52
- // ============================================================
53
-
54
- interface FrameworkDetection {
55
- type: string;
56
- router: string;
57
- orm: string;
58
- ui: string;
59
- }
60
-
61
- interface InitResult {
62
- configCreated: boolean;
63
- configSkipped: boolean;
64
- mcpRegistered: boolean;
65
- mcpSkipped: boolean;
66
- hooksInstalled: boolean;
67
- hooksCount: number;
68
- framework: FrameworkDetection;
69
- }
70
-
71
- export interface InitOptions {
72
- /** Skip all prompts; fail on conflict. Also set when stdin is not a TTY. */
73
- ci?: boolean;
74
- /** Overwrite existing config without prompting (ignored in --ci mode). */
75
- force?: boolean;
76
- /** Template name for greenfield projects (skips detection). */
77
- template?: string;
78
- /** Skip hook/command/memory install side-effects. Used in tests. */
79
- skipSideEffects?: boolean;
80
- /**
81
- * Plan #2 P4-002: when true, skip the asset-install (commands / agents /
82
- * patterns / etc). MCP register, hooks, and memory init still run.
83
- */
84
- skipCommands?: boolean;
85
- /** Override cwd (tests). */
86
- cwd?: string;
87
- /** Suppress console output. */
88
- silent?: boolean;
89
- /**
90
- * Plan 1.5.4: skip AST adapter introspection that surfaces under
91
- * `detected.<adapter-id>:` blocks. Default false (introspect runs);
92
- * set true via `--no-introspect` for fast sync-only init or when
93
- * the AST tier's grammar download isn't desirable.
94
- */
95
- skipIntrospect?: boolean;
96
- }
97
-
98
- export interface GenerateConfigV2Options {
99
- /** Project root to generate against. Detection is run on this directory. */
100
- projectRoot: string;
101
- /** Pre-computed detection result (reused if already available). */
102
- detection?: DetectionResult;
103
- /** Project name override (default = basename of projectRoot). */
104
- projectName?: string;
105
- }
106
-
107
- // ============================================================
108
- // Legacy Framework Auto-Detection (preserved for cli.test.ts)
109
- // ============================================================
110
-
111
- export function detectFramework(projectRoot: string): FrameworkDetection {
112
- const result: FrameworkDetection = {
113
- type: 'javascript',
114
- router: 'none',
115
- orm: 'none',
116
- ui: 'none',
117
- };
118
-
119
- const pkgPath = resolve(projectRoot, 'package.json');
120
- if (!existsSync(pkgPath)) return result;
121
-
122
- try {
123
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
124
- const allDeps = {
125
- ...pkg.dependencies,
126
- ...pkg.devDependencies,
127
- };
128
-
129
- // Language detection
130
- if (allDeps['typescript']) result.type = 'typescript';
131
-
132
- // UI framework detection
133
- if (allDeps['next']) result.ui = 'nextjs';
134
- else if (allDeps['@sveltejs/kit']) result.ui = 'sveltekit';
135
- else if (allDeps['nuxt']) result.ui = 'nuxt';
136
- else if (allDeps['@angular/core']) result.ui = 'angular';
137
- else if (allDeps['vue']) result.ui = 'vue';
138
- else if (allDeps['react']) result.ui = 'react';
139
-
140
- // Router detection
141
- if (allDeps['@trpc/server']) result.router = 'trpc';
142
- else if (allDeps['graphql'] || allDeps['@apollo/server']) result.router = 'graphql';
143
- else if (allDeps['express'] || allDeps['fastify'] || allDeps['hono']) result.router = 'rest';
144
-
145
- // ORM detection
146
- if (allDeps['@prisma/client'] || allDeps['prisma']) result.orm = 'prisma';
147
- else if (allDeps['drizzle-orm']) result.orm = 'drizzle';
148
- else if (allDeps['typeorm']) result.orm = 'typeorm';
149
- else if (allDeps['sequelize']) result.orm = 'sequelize';
150
- else if (allDeps['mongoose']) result.orm = 'mongoose';
151
- } catch {
152
- // Best effort
153
- }
154
-
155
- return result;
156
- }
157
-
158
- // ============================================================
159
- // Legacy Python Project Detection (preserved for cli.test.ts / back compat)
160
- // ============================================================
161
-
162
- interface PythonDetection {
163
- detected: boolean;
164
- root: string;
165
- hasFastapi: boolean;
166
- hasSqlalchemy: boolean;
167
- hasAlembic: boolean;
168
- alembicDir: string | null;
169
- }
170
-
171
- export function detectPython(projectRoot: string): PythonDetection {
172
- const result: PythonDetection = {
173
- detected: false,
174
- root: '',
175
- hasFastapi: false,
176
- hasSqlalchemy: false,
177
- hasAlembic: false,
178
- alembicDir: null,
179
- };
180
-
181
- const markers = ['pyproject.toml', 'setup.py', 'requirements.txt', 'Pipfile'];
182
- const hasMarker = markers.some(m => existsSync(resolve(projectRoot, m)));
183
- if (!hasMarker) return result;
184
-
185
- result.detected = true;
186
-
187
- const depFiles = [
188
- { file: 'pyproject.toml' },
189
- { file: 'requirements.txt' },
190
- { file: 'setup.py' },
191
- { file: 'Pipfile' },
192
- ];
193
-
194
- for (const { file } of depFiles) {
195
- const filePath = resolve(projectRoot, file);
196
- if (existsSync(filePath)) {
197
- try {
198
- const content = readFileSync(filePath, 'utf-8').toLowerCase();
199
- if (content.includes('fastapi')) result.hasFastapi = true;
200
- if (content.includes('sqlalchemy')) result.hasSqlalchemy = true;
201
- } catch {
202
- // Best effort
203
- }
204
- }
205
- }
206
-
207
- if (existsSync(resolve(projectRoot, 'alembic.ini'))) {
208
- result.hasAlembic = true;
209
- if (existsSync(resolve(projectRoot, 'alembic'))) {
210
- result.alembicDir = 'alembic';
211
- }
212
- } else if (existsSync(resolve(projectRoot, 'alembic'))) {
213
- result.hasAlembic = true;
214
- result.alembicDir = 'alembic';
215
- }
216
-
217
- const candidateRoots = ['app', 'src', 'backend', 'api'];
218
- for (const candidate of candidateRoots) {
219
- const candidatePath = resolve(projectRoot, candidate);
220
- if (existsSync(candidatePath) && existsSync(resolve(candidatePath, '__init__.py'))) {
221
- result.root = candidate;
222
- break;
223
- }
224
- if (existsSync(candidatePath)) {
225
- try {
226
- const files = readdirSync(candidatePath);
227
- if (files.some(f => f.endsWith('.py'))) {
228
- result.root = candidate;
229
- break;
230
- }
231
- } catch {
232
- // Best effort
233
- }
234
- }
235
- }
236
-
237
- if (!result.root) {
238
- result.root = '.';
239
- }
240
-
241
- return result;
242
- }
243
-
244
- // ============================================================
245
- // Legacy Config File Generation (preserved for cli.test.ts)
246
- // ============================================================
247
-
248
- /**
249
- * @deprecated Since @massu/core@1.2.1. Use {@link buildConfigFromDetection}
250
- * with {@link runDetection} for monorepo-aware path resolution and
251
- * schema_version=2 output. This path hardcodes `paths.source = 'src'` and
252
- * cannot emit `paths.monorepo_roots`, so it would roll back on every
253
- * non-`src/` layout. Kept only for the legacy `cli.test.ts` smoke tests;
254
- * new callers must use the v2 builder.
255
- */
256
- export function generateConfig(projectRoot: string, framework: FrameworkDetection): boolean {
257
- console.warn(
258
- '[@massu/core] generateConfig() is deprecated since 1.2.1 — use buildConfigFromDetection instead. It cannot produce valid configs for monorepos.'
259
- );
260
- const configPath = resolve(projectRoot, 'massu.config.yaml');
261
-
262
- if (existsSync(configPath)) {
263
- return false; // Config already exists
264
- }
265
-
266
- const projectName = basename(projectRoot);
267
-
268
- const config: Record<string, unknown> = {
269
- project: {
270
- name: projectName,
271
- root: 'auto',
272
- },
273
- framework: {
274
- type: framework.type,
275
- router: framework.router,
276
- orm: framework.orm,
277
- ui: framework.ui,
278
- },
279
- paths: {
280
- source: 'src',
281
- aliases: { '@': 'src' },
282
- },
283
- toolPrefix: 'massu',
284
- domains: [],
285
- rules: [
286
- {
287
- pattern: 'src/**/*.ts',
288
- rules: ['Use ESM imports, not CommonJS'],
289
- },
290
- ],
291
- };
292
-
293
- // Detect and add Python configuration
294
- const python = detectPython(projectRoot);
295
- if (python.detected) {
296
- const pythonConfig: Record<string, unknown> = {
297
- root: python.root,
298
- exclude_dirs: ['__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache'],
299
- };
300
- if (python.hasFastapi) pythonConfig.framework = 'fastapi';
301
- if (python.hasSqlalchemy) pythonConfig.orm = 'sqlalchemy';
302
- if (python.hasAlembic && python.alembicDir) {
303
- pythonConfig.alembic_dir = python.alembicDir;
304
- }
305
- config.python = pythonConfig;
306
- }
307
-
308
- const yamlContent = `# Massu AI Configuration
309
- # Generated by: npx massu init
310
- # Documentation: https://massu.ai/docs/getting-started/configuration
311
-
312
- ${yamlStringify(config)}`;
313
-
314
- writeFileSync(configPath, yamlContent, 'utf-8');
315
- return true;
316
- }
317
-
318
- // ============================================================
319
- // V2 Config Builder (detection-driven)
320
- // ============================================================
321
-
322
- /**
323
- * Return the common top-level parent directory across every workspace
324
- * package. Returns `'.'` when packages span multiple parents (e.g. a repo
325
- * with both `apps/*` and `packages/*`) — the project root is always a valid
326
- * paths.source value (see validateWrittenConfig at init.ts:572).
327
- */
328
- function monorepoCommonRoot(
329
- packages: ReadonlyArray<{ path: string }>
330
- ): string {
331
- const roots = monorepoDistinctRoots(packages);
332
- return roots.length === 1 ? roots[0] : '.';
333
- }
334
-
335
- /**
336
- * Return the distinct top-level parent directories of every workspace
337
- * package (e.g. `['apps', 'packages']` when both are present). Sorted for
338
- * determinism. Excludes root-level ('.') workspaces.
339
- */
340
- function monorepoDistinctRoots(
341
- packages: ReadonlyArray<{ path: string }>
342
- ): string[] {
343
- const set = new Set<string>();
344
- for (const p of packages) {
345
- const parts = p.path.split('/');
346
- if (parts.length > 1 && parts[0] !== '' && parts[0] !== '.') {
347
- set.add(parts[0]);
348
- }
349
- }
350
- return [...set].sort();
351
- }
352
-
353
- /**
354
- * Build a schema_version=2 config object from a DetectionResult.
355
- *
356
- * Contract:
357
- * - `framework.type` is `'multi'` when 2+ languages present, else the sole language.
358
- * - `framework.primary` is the language with the most manifests (ties: alpha).
359
- * - `framework.languages` is populated for every detected language with a
360
- * non-null framework or test framework.
361
- * - Legacy top-level `framework.router/.orm/.ui` are mirrored from the primary
362
- * language entry so existing consumers (tools.ts lines 89/192/246) keep
363
- * working without any change (per Phase 0 P0-003 + Phase 2 P2-002 contract).
364
- * - `paths.source` is the dominant directory for the primary language (or '.'
365
- * for single-repo flat layouts).
366
- * - `verification.<language>` is pulled from VRCommandMap output.
367
- * - `domains[]` is the DomainInferrer output (may be empty).
368
- */
369
- export function buildConfigFromDetection(
370
- opts: GenerateConfigV2Options
371
- ): Record<string, unknown> {
372
- const { projectRoot, detection } = opts;
373
- if (!detection) {
374
- throw new Error('buildConfigFromDetection requires a detection result');
375
- }
376
- const projectName = opts.projectName ?? basename(projectRoot);
377
-
378
- const languages = Array.from(
379
- new Set(detection.manifests.map((m) => m.language))
380
- ) as SupportedLanguage[];
381
-
382
- // Pick primary: language with most manifests; ties broken by alphabetical.
383
- const languageCounts = new Map<SupportedLanguage, number>();
384
- for (const m of detection.manifests) {
385
- languageCounts.set(m.language, (languageCounts.get(m.language) ?? 0) + 1);
386
- }
387
- const sortedLangs = [...languageCounts.entries()].sort((a, b) => {
388
- if (b[1] !== a[1]) return b[1] - a[1];
389
- return a[0].localeCompare(b[0]);
390
- });
391
- const primary: SupportedLanguage | null = sortedLangs.length > 0 ? sortedLangs[0][0] : null;
392
-
393
- const frameworkType = languages.length > 1 ? 'multi' : (languages[0] ?? 'typescript');
394
-
395
- // Build per-language entries from FrameworkMap.
396
- const languageEntries: Record<string, Record<string, unknown>> = {};
397
- for (const lang of languages) {
398
- const fw = detection.frameworks[lang];
399
- const dirInfo = detection.sourceDirs[lang];
400
- const sourceDirs = dirInfo?.source_dirs ?? [];
401
- const entry: Record<string, unknown> = {};
402
- if (fw?.framework) entry.framework = fw.framework;
403
- if (fw?.test_framework) entry.test_framework = fw.test_framework;
404
- if (fw?.orm) entry.orm = fw.orm;
405
- if (fw?.router) entry.router = fw.router;
406
- if (fw?.ui_library) entry.ui = fw.ui_library;
407
- if (sourceDirs.length > 0) entry.source_dirs = sourceDirs;
408
- // Only include entries that have at least one field populated.
409
- if (Object.keys(entry).length > 0) {
410
- languageEntries[lang] = entry;
411
- }
412
- }
413
-
414
- // Legacy top-level framework fields (mirror from primary language).
415
- // Preserves tools.ts:89,192,246 reads under v2.
416
- const primaryEntry = primary ? languageEntries[primary] : undefined;
417
- const legacyRouter =
418
- (primaryEntry?.router as string | undefined) ?? 'none';
419
- const legacyOrm = (primaryEntry?.orm as string | undefined) ?? 'none';
420
- const legacyUi = (primaryEntry?.ui as string | undefined) ?? 'none';
421
-
422
- // Determine paths.source from primary language's dominant source dir.
423
- // P1-003: when the primary language has no detectable source dir AND the
424
- // repo is a monorepo, fall back to the common parent of workspace packages
425
- // (e.g. 'apps' for turbo + apps/*, 'packages' for pnpm + packages/*). This
426
- // prevents the validator from rejecting a nonexistent top-level 'src/' on
427
- // monorepo shapes where code actually lives under apps/ or packages/.
428
- let pathsSource = 'src';
429
- if (primary) {
430
- const primaryDirs = detection.sourceDirs[primary]?.source_dirs ?? [];
431
- if (primaryDirs.length > 0) {
432
- pathsSource = primaryDirs[0];
433
- } else if (
434
- detection.monorepo.type !== 'single' &&
435
- detection.monorepo.packages.length > 0
436
- ) {
437
- pathsSource = monorepoCommonRoot(detection.monorepo.packages);
438
- }
439
- }
440
-
441
- // P-H004 (plan-stage-c-high-batch): App Router / Pages Router fallback.
442
- // When pathsSource would default to 'src' but src/ doesn't exist, check
443
- // recognized framework conventions before failing validation. Fixes
444
- // `massu init` outright failure on fresh Next.js 14+ App Router repos
445
- // (have `app/` + `package.json`, no `src/`) and Pages Router (`pages/`).
446
- // Final fallback to '.' makes flat-layout projects work too.
447
- if (pathsSource === 'src' && !existsSync(resolve(projectRoot, 'src'))) {
448
- const fallbacks = ['app', 'pages', '.'];
449
- for (const fallback of fallbacks) {
450
- if (fallback === '.' || existsSync(resolve(projectRoot, fallback))) {
451
- pathsSource = fallback;
452
- break;
453
- }
454
- }
455
- }
456
-
457
- // P1-005: emit `paths.monorepo_roots` as the distinct parent directories of
458
- // every workspace package when this is a monorepo. Optional + additive;
459
- // v1 consumers ignore it. When detection identified a monorepo type
460
- // (turbo/nx/pnpm/etc) but no manifested workspace packages were found
461
- // (e.g. fresh-install fixtures with apps/*/main.py that haven't declared
462
- // sub-manifests yet), fall back to deriving roots from the resolved
463
- // paths.source so the field is still accurate for monorepo-aware tools.
464
- let monorepoRoots: string[] | undefined;
465
- if (detection.monorepo.type !== 'single') {
466
- if (detection.monorepo.packages.length > 0) {
467
- monorepoRoots = monorepoDistinctRoots(detection.monorepo.packages);
468
- } else if (pathsSource !== 'src' && pathsSource !== '.') {
469
- // Derive from paths.source when no workspace manifests exist.
470
- monorepoRoots = [pathsSource];
471
- }
472
- }
473
-
474
- // Verification commands per language.
475
- const verification: Record<string, Record<string, string>> = {};
476
- for (const lang of languages) {
477
- const cmds: VRCommandSet | undefined = detection.verificationCommands[lang];
478
- if (!cmds) continue;
479
- const entry: Record<string, string> = {};
480
- if (cmds.test) entry.test = cmds.test;
481
- if (cmds.type) entry.type = cmds.type;
482
- if (cmds.build) entry.build = cmds.build;
483
- if (cmds.syntax) entry.syntax = cmds.syntax;
484
- if (cmds.lint) entry.lint = cmds.lint;
485
- if (Object.keys(entry).length > 0) {
486
- verification[lang] = entry;
487
- }
488
- }
489
-
490
- // Domains: emit from inferred + strip defaulting so YAML stays lean.
491
- const domains = detection.domains.map((d) => {
492
- const out: Record<string, unknown> = { name: d.name };
493
- if (d.routers.length > 0) out.routers = d.routers;
494
- if (d.pages.length > 0) out.pages = d.pages;
495
- if (d.tables.length > 0) out.tables = d.tables;
496
- if (d.allowedImportsFrom.length > 0) out.allowedImportsFrom = d.allowedImportsFrom;
497
- return out;
498
- });
499
-
500
- const frameworkBlock: Record<string, unknown> = {
501
- type: frameworkType,
502
- router: legacyRouter,
503
- orm: legacyOrm,
504
- ui: legacyUi,
505
- };
506
- if (languages.length > 1 && primary) {
507
- frameworkBlock.primary = primary;
508
- }
509
- if (Object.keys(languageEntries).length > 0) {
510
- frameworkBlock.languages = languageEntries;
511
- }
512
-
513
- const pathsBlock: Record<string, unknown> = {
514
- source: pathsSource,
515
- aliases: { '@': pathsSource },
516
- };
517
- if (monorepoRoots && monorepoRoots.length > 0) {
518
- pathsBlock.monorepo_roots = monorepoRoots;
519
- }
520
-
521
- const config: Record<string, unknown> = {
522
- schema_version: 2,
523
- project: {
524
- name: projectName,
525
- root: 'auto',
526
- },
527
- framework: frameworkBlock,
528
- paths: pathsBlock,
529
- toolPrefix: 'massu',
530
- domains,
531
- rules: [],
532
- };
533
-
534
- if (Object.keys(verification).length > 0) {
535
- config.verification = verification;
536
- }
537
-
538
- // P5-002: stamp a stack fingerprint so session-start can detect drift later.
539
- config.detection = { fingerprint: computeFingerprint(detection) };
540
-
541
- // Plan #2 P3-003: emit detector-owned `detected:` block (per-language
542
- // conventions sampled from the codebase). Only present when the introspector
543
- // ran (i.e., not skipped by the session-start hook). Detector-owned →
544
- // refreshed on every `init`/`config refresh`, NOT in PRESERVED_FIELDS.
545
- if (detection.detected && Object.keys(detection.detected).length > 0) {
546
- config.detected = detection.detected;
547
- }
548
-
549
- // Preserve legacy `python` block for v1 consumers (domain-enforcer, etc.).
550
- // Per Phase 0 P1-009 (b): python legacy config coexists with languages.python.
551
- if (languages.includes('python')) {
552
- const pySourceDirs = detection.sourceDirs.python?.source_dirs ?? [];
553
- const pyRoot = pySourceDirs.length > 0 ? pySourceDirs[0] : '.';
554
- const pyFw = detection.frameworks.python;
555
- const pythonBlock: Record<string, unknown> = {
556
- root: pyRoot,
557
- exclude_dirs: ['__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache'],
558
- };
559
- if (pyFw?.framework) pythonBlock.framework = pyFw.framework;
560
- if (pyFw?.orm) pythonBlock.orm = pyFw.orm;
561
- // Alembic detection — best-effort via filesystem (detection layer is DB-free).
562
- if (existsSync(resolve(projectRoot, 'alembic.ini')) || existsSync(resolve(projectRoot, 'alembic'))) {
563
- pythonBlock.alembic_dir = 'alembic';
564
- }
565
- config.python = pythonBlock;
566
- }
567
-
568
- return config;
569
- }
570
-
571
- /**
572
- * Plan 1.5.1 §3 — variant template merge.
573
- *
574
- * Map from detected `framework.languages.<lang>.framework` value → variant
575
- * template directory under `packages/core/templates/`. Most detected
576
- * frameworks map 1:1 to a template dir of the same name, but a few have
577
- * naming divergence (e.g., detection emits `spring-boot` but the template
578
- * dir is `spring`; detection emits `chi` but the template dir is `go-chi`).
579
- *
580
- * The mapping is intentionally tight — only frameworks with an actual
581
- * variant template under `templates/` are listed. Adding a new framework
582
- * = one entry here + one templates/<id>/massu.config.yaml file. The
583
- * `manifest-registry-drift.test.ts` already gates the manifest side; the
584
- * `init-end-to-end.test.ts` gates this map.
585
- */
586
- const FRAMEWORK_TO_TEMPLATE_ID: Record<string, string> = {
587
- rails: 'rails',
588
- phoenix: 'phoenix',
589
- 'aspnet-core': 'aspnet',
590
- 'spring-boot': 'spring',
591
- chi: 'go-chi',
592
- flask: 'python-flask',
593
- };
594
-
595
- /**
596
- * After `buildConfigFromDetection` produces a baseline config, look up the
597
- * variant template for the detected framework (if any) and selectively
598
- * merge fields. The variant wins on a small allowlist:
599
- * - `framework.router`
600
- * - `framework.orm`
601
- * - `framework.ui`
602
- * - `paths.source`
603
- * - `verification.<lang>.{lint,syntax,test,type,build}` — variant lint
604
- * is the canonical project-style command (rubocop, credo, etc.) and
605
- * should not be overridden by the generic detection default.
606
- *
607
- * The variant template's `framework.type`, `framework.languages`,
608
- * `project.name`, `domains`, and `rules` are NOT merged — those come from
609
- * detection and reflect the actual repo state. Allowlist keeps the merge
610
- * precise; future fields require an explicit decision.
611
- */
612
- export function applyVariantTemplate(
613
- config: Record<string, unknown>,
614
- templatesDir: string | null,
615
- ): Record<string, unknown> {
616
- if (!templatesDir) return config;
617
- const fw = config.framework as Record<string, unknown> | undefined;
618
- if (!fw) return config;
619
- const langs = fw.languages as Record<string, unknown> | undefined;
620
- if (!langs || typeof langs !== 'object') return config;
621
-
622
- // Find the first language that has a `framework` value with a known
623
- // variant template. Most projects have ONE primary language; in
624
- // monorepos the detection-driven primary is what we honor.
625
- let templateId: string | null = null;
626
- for (const langEntry of Object.values(langs)) {
627
- if (langEntry && typeof langEntry === 'object') {
628
- const fwName = (langEntry as Record<string, unknown>).framework;
629
- if (typeof fwName === 'string' && FRAMEWORK_TO_TEMPLATE_ID[fwName]) {
630
- templateId = FRAMEWORK_TO_TEMPLATE_ID[fwName];
631
- break;
632
- }
633
- }
634
- }
635
- if (templateId === null) return config;
636
-
637
- const templatePath = resolve(templatesDir, templateId, 'massu.config.yaml');
638
- if (!existsSync(templatePath)) return config;
639
-
640
- let template: Record<string, unknown>;
641
- try {
642
- // pattern-scanner-allow: yaml-parse — reason: this loads a per-framework
643
- // variant config-template shipped inside @massu/core. getConfig() reads
644
- // the project's massu.config.yaml from cwd; this is a SEPARATE file
645
- // (the template) that doesn't pass through that cache and isn't a Zod-
646
- // validated config — it's a partial override map.
647
- template = yamlParse(readFileSync(templatePath, 'utf-8')) as Record<string, unknown>;
648
- } catch {
649
- return config;
650
- }
651
-
652
- const out = { ...config };
653
- const tplFw = template.framework as Record<string, unknown> | undefined;
654
- const outFw = (out.framework as Record<string, unknown>) ?? {};
655
- if (tplFw) {
656
- if (typeof tplFw.router === 'string' && (!outFw.router || outFw.router === 'none')) {
657
- outFw.router = tplFw.router;
658
- }
659
- if (typeof tplFw.orm === 'string' && (!outFw.orm || outFw.orm === 'none')) {
660
- outFw.orm = tplFw.orm;
661
- }
662
- if (typeof tplFw.ui === 'string' && (!outFw.ui || outFw.ui === 'none')) {
663
- outFw.ui = tplFw.ui;
664
- }
665
- }
666
- out.framework = outFw;
667
-
668
- const tplPaths = template.paths as Record<string, unknown> | undefined;
669
- const outPaths = (out.paths as Record<string, unknown>) ?? {};
670
- if (tplPaths && typeof tplPaths.source === 'string' && tplPaths.source) {
671
- outPaths.source = tplPaths.source;
672
- }
673
- out.paths = outPaths;
674
-
675
- const tplVerify = template.verification as Record<string, Record<string, unknown>> | undefined;
676
- const outVerify = (out.verification as Record<string, Record<string, unknown>>) ?? {};
677
- if (tplVerify) {
678
- for (const [lang, tplLangVerify] of Object.entries(tplVerify)) {
679
- if (!tplLangVerify || typeof tplLangVerify !== 'object') continue;
680
- const outLangVerify = outVerify[lang] ?? {};
681
- // Variant wins on lint + syntax (canonical project commands like
682
- // rubocop, credo, golangci-lint that the generic detection layer
683
- // doesn't know to suggest). For test/type/build, prefer detection-
684
- // derived values when present (e.g., monorepo `cd packages/foo`
685
- // prefixing) and fall back to variant template otherwise.
686
- for (const key of ['lint', 'syntax']) {
687
- if (typeof tplLangVerify[key] === 'string' && tplLangVerify[key]) {
688
- outLangVerify[key] = tplLangVerify[key];
689
- }
690
- }
691
- for (const key of ['test', 'type', 'build']) {
692
- if (
693
- typeof tplLangVerify[key] === 'string' &&
694
- tplLangVerify[key] &&
695
- !outLangVerify[key]
696
- ) {
697
- outLangVerify[key] = tplLangVerify[key];
698
- }
699
- }
700
- outVerify[lang] = outLangVerify;
701
- }
702
- }
703
- if (Object.keys(outVerify).length > 0) {
704
- out.verification = outVerify;
705
- }
706
-
707
- return out;
708
- }
709
-
710
- /**
711
- * Serialize a built config object into YAML with a header comment.
712
- * Safe for `writeConfigAtomic` and for `fs.writeFileSync` directly.
713
- */
714
- export function renderConfigYaml(config: Record<string, unknown>): string {
715
- return `# Massu AI Configuration
716
- # Generated by: npx massu init (schema_version=2, detection-driven)
717
- # Documentation: https://massu.ai/docs/getting-started/configuration
718
-
719
- ${yamlStringify(config)}`;
720
- }
721
-
722
- // ============================================================
723
- // Atomic Write + Post-Write Validation (P3-004, P3-005)
724
- // ============================================================
725
-
726
- /**
727
- * Atomically write YAML to `configPath`.
728
- * 1. Writes to `<configPath>.tmp`.
729
- * 2. Validates the written file by parsing it as YAML and through the Zod
730
- * RawConfigSchema via a short-lived `getConfig` reload on a sandboxed cwd.
731
- * 3. Renames the tmp file to the target.
732
- * 4. On ANY error, removes the tmp file. No partial config ever persists.
733
- *
734
- * Preserves existing file permissions when overwriting.
735
- *
736
- * P3-006: never writes outside `configPath`'s directory; caller is responsible
737
- * for passing an in-project path (enforced at the call-site in runInit).
738
- */
739
- export function writeConfigAtomic(
740
- configPath: string,
741
- content: string
742
- ): { validated: boolean; error?: string } {
743
- const tmpPath = `${configPath}.tmp`;
744
-
745
- // Preserve existing permissions when overwriting.
746
- let existingMode: number | undefined;
747
- if (existsSync(configPath)) {
748
- try {
749
- existingMode = statSync(configPath).mode;
750
- } catch {
751
- existingMode = undefined;
752
- }
753
- }
754
-
755
- try {
756
- // Iter-8 fix: ensure the parent directory exists. POSIX `rename(2)`
757
- // requires the target's parent to exist; otherwise the rename fails
758
- // with ENOENT and we leak the tmp. The watcher's auto-refresh path
759
- // never hits this (the configPath is always inside an existing repo
760
- // with massu.config.yaml already there), but `runInit` on a fresh
761
- // path under a non-existent parent would fall over before this line.
762
- mkdirSync(dirname(configPath), { recursive: true });
763
-
764
- // Iter-7 fix: write tmp via openSync + writeSync + fsyncSync + closeSync
765
- // so the data hits the platter BEFORE renameSync. This matches
766
- // `writeStateAtomic` (watch/state.ts) and the spec doc claim that the
767
- // 3a watcher's atomic-rename guarantees universally cover all writes
768
- // touched during a refresh cycle. Without fsync, on certain filesystems
769
- // (xfs, ext4 `data=writeback`) the rename can land before data, leaving
770
- // a zero-byte config on power-loss / SIGKILL between writeFileSync and
771
- // renameSync — a gap the watcher daemon makes more reachable since
772
- // refresh writes happen unattended every quiescence window.
773
- const fd = openSync(tmpPath, 'w', 0o644);
774
- try {
775
- const buf = Buffer.from(content, 'utf-8');
776
- writeSync(fd, buf, 0, buf.length, 0);
777
- fsyncSync(fd);
778
- } finally {
779
- closeSync(fd);
780
- }
781
-
782
- // Validate YAML parses.
783
- // pattern-scanner-allow: yaml-parse — reason: atomic-write post-validator. We just generated `content` and wrote it to a tmp path; before atomic rename to the final config path we re-parse to verify the bytes we serialized are valid YAML. Calling getConfig() here would re-read from cwd and miss the tmp file.
784
- const parsed = yamlParse(content);
785
- if (parsed === null || typeof parsed !== 'object') {
786
- throw new Error('Generated config is not a valid YAML object');
787
- }
788
-
789
- // Atomic rename.
790
- renameSync(tmpPath, configPath);
791
-
792
- // Restore mode if we had one.
793
- if (existingMode !== undefined) {
794
- try {
795
- chmodSync(configPath, existingMode);
796
- } catch {
797
- // Best effort; unreadable mode doesn't block init.
798
- }
799
- }
800
-
801
- return { validated: true };
802
- } catch (err) {
803
- // Clean up the temp file on failure.
804
- if (existsSync(tmpPath)) {
805
- try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
806
- }
807
- return { validated: false, error: err instanceof Error ? err.message : String(err) };
808
- }
809
- }
810
-
811
- /**
812
- * Validate a written config against the live Zod schema AND filesystem.
813
- * Returns null on success, an error message on failure.
814
- *
815
- * When `checkPaths` is false (template mode, greenfield scaffolds), filesystem
816
- * existence checks on `paths.source` and per-language source_dirs are skipped.
817
- */
818
- export function validateWrittenConfig(
819
- configPath: string,
820
- projectRoot: string,
821
- checkPaths: boolean = true
822
- ): string | null {
823
- try {
824
- if (!existsSync(configPath)) return 'Config file does not exist after write';
825
- // Parse YAML directly — we deliberately bypass getConfig() here because
826
- // getConfig caches against process.cwd() and we may be validating a config
827
- // outside the current working tree (tests, etc.).
828
- const content = readFileSync(configPath, 'utf-8');
829
- // pattern-scanner-allow: yaml-parse — reason: see preceding comment block; getConfig() caches against process.cwd() and would either return a stale entry or fail to read a config at an arbitrary projectRoot.
830
- const parsed = yamlParse(content);
831
- if (parsed === null || typeof parsed !== 'object') {
832
- return 'Config is not a valid YAML object';
833
- }
834
-
835
- // Validate via getConfig by temporarily chdir'ing to projectRoot, since
836
- // getConfig reads the config from process.cwd(). The Zod safeParse inside
837
- // getConfig already surfaces actionable errors on malformed configs.
838
- const prevCwd = process.cwd();
839
- let changed = false;
840
- if (prevCwd !== projectRoot) {
841
- try { process.chdir(projectRoot); changed = true; } catch { /* ignore */ }
842
- }
843
- try {
844
- resetConfig();
845
- const cfg = getConfig();
846
- if (checkPaths) {
847
- // Verify paths.source actually exists on disk (unless '.', which is always valid).
848
- const src = cfg.paths.source;
849
- if (src && src !== '.') {
850
- const srcAbs = resolve(projectRoot, src);
851
- if (!existsSync(srcAbs)) {
852
- return `paths.source '${src}' does not exist on disk`;
853
- }
854
- }
855
- // Verify every declared language source_dir exists.
856
- const languages = cfg.framework.languages ?? {};
857
- for (const [lang, entry] of Object.entries(languages)) {
858
- const rawDirs = (entry as Record<string, unknown>).source_dirs;
859
- if (!Array.isArray(rawDirs)) continue;
860
- for (const d of rawDirs) {
861
- if (typeof d !== 'string' || d === '.') continue;
862
- const abs = resolve(projectRoot, d);
863
- if (!existsSync(abs)) {
864
- return `framework.languages.${lang}.source_dirs '${d}' does not exist on disk`;
865
- }
866
- }
867
- }
868
- // P2-001: verify paths.monorepo_roots entries exist on disk (parity
869
- // with paths.source existence check at line 624-631 above).
870
- const mRoots = (cfg.paths as Record<string, unknown>).monorepo_roots;
871
- if (Array.isArray(mRoots)) {
872
- for (const r of mRoots) {
873
- if (typeof r !== 'string' || r === '.') continue;
874
- if (!existsSync(resolve(projectRoot, r))) {
875
- return `paths.monorepo_roots '${r}' does not exist on disk`;
876
- }
877
- }
878
- }
879
- }
880
- } catch (err) {
881
- return err instanceof Error ? err.message : String(err);
882
- } finally {
883
- if (changed) {
884
- try { process.chdir(prevCwd); } catch { /* ignore */ }
885
- }
886
- resetConfig();
887
- }
888
- return null;
889
- } catch (err) {
890
- return err instanceof Error ? err.message : String(err);
891
- }
892
- }
893
-
894
- // ============================================================
895
- // Template Mode (P3-003)
896
- // ============================================================
897
-
898
- const TEMPLATE_NAMES = [
899
- 'python-fastapi',
900
- 'python-django',
901
- 'ts-nextjs',
902
- 'ts-nestjs',
903
- 'rust-actix',
904
- 'swift-ios',
905
- 'multi-runtime',
906
- ] as const;
907
-
908
- export type TemplateName = (typeof TEMPLATE_NAMES)[number];
909
-
910
- export function isTemplateName(name: string): name is TemplateName {
911
- return (TEMPLATE_NAMES as readonly string[]).includes(name);
912
- }
913
-
914
- export function listTemplates(): readonly string[] {
915
- return TEMPLATE_NAMES;
916
- }
917
-
918
- /**
919
- * Resolve the templates directory.
920
- * Order:
921
- * 1. `node_modules/@massu/core/templates` (installed)
922
- * 2. Relative to compiled dist (dist/../templates)
923
- * 3. Relative to source (src/../templates)
924
- */
925
- export function resolveTemplatesDir(): string | null {
926
- const cwd = process.cwd();
927
- const candidates = [
928
- // Project-local install: `<project>/node_modules/@massu/core/templates`.
929
- resolve(cwd, 'node_modules/@massu/core/templates'),
930
- // Bundled cli.js layout: cli.js sits at `<package>/dist/cli.js`, so
931
- // templates live one level up at `<package>/templates`. (Plan 1.5.1
932
- // bug discovery: pre-existing layout assumed `dist/commands/init.js`
933
- // depth which never matched the bundled cli, so resolveTemplatesDir
934
- // returned null in production for both `--template` mode AND the
935
- // applyVariantTemplate path.)
936
- resolve(__dirname, '../templates'),
937
- // Legacy nested layouts retained as fallbacks (in case a future
938
- // build moves cli.js back into a subdirectory).
939
- resolve(__dirname, '../../templates'),
940
- resolve(__dirname, '../../../templates'),
941
- ];
942
- for (const c of candidates) {
943
- if (existsSync(c)) return c;
944
- }
945
- return null;
946
- }
947
-
948
- export function copyTemplateConfig(
949
- templateName: TemplateName,
950
- targetPath: string,
951
- projectName: string
952
- ): { success: boolean; error?: string } {
953
- const templatesDir = resolveTemplatesDir();
954
- if (!templatesDir) {
955
- return { success: false, error: `Templates directory not found (looked in node_modules and dist/src)` };
956
- }
957
- const srcPath = resolve(templatesDir, templateName, 'massu.config.yaml');
958
- if (!existsSync(srcPath)) {
959
- return { success: false, error: `Template '${templateName}' not found at ${srcPath}` };
960
- }
961
- try {
962
- let content = readFileSync(srcPath, 'utf-8');
963
- // Replace {{PROJECT_NAME}} placeholder if present.
964
- content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
965
- writeFileSync(targetPath, content, 'utf-8');
966
- return { success: true };
967
- } catch (err) {
968
- return { success: false, error: err instanceof Error ? err.message : String(err) };
969
- }
970
- }
971
-
972
- // ============================================================
973
- // MCP Server Registration (preserved)
974
- // ============================================================
975
-
976
- /**
977
- * Read the installer's OWN package.json version. The installer runs from
978
- * its compiled `dist/cli.js` (under npx cache or a global install); the
979
- * package.json sits one directory up from that. Used to pin downstream
980
- * MCP server invocations and hook commands so customers don't drift onto
981
- * unpinned `@massu/core` (which would resolve to the latest dist-tag on
982
- * every spawn and silently change behavior across versions).
983
- *
984
- * Hard error if the package.json can't be read or has no version field —
985
- * an unpinned write is a structural drift bug (P-002) and silently
986
- * falling back to an unversioned `@massu/core` is what we're closing.
987
- */
988
- export function getInstallerVersion(): string {
989
- // Walk up from this module's compiled location to find package.json.
990
- // Compiled layout: <root>/dist/cli.js → ../package.json
991
- // TS source layout: <root>/src/commands/init.ts → ../../package.json
992
- const candidates = [
993
- resolve(__dirname, '../package.json'),
994
- resolve(__dirname, '../../package.json'),
995
- ];
996
- for (const candidate of candidates) {
997
- if (existsSync(candidate)) {
998
- try {
999
- const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
1000
- if (typeof pkg.version === 'string' && pkg.version.length > 0 && pkg.name === '@massu/core') {
1001
- return pkg.version;
1002
- }
1003
- } catch { /* try next */ }
1004
- }
1005
- }
1006
- throw new Error(
1007
- 'getInstallerVersion: could not resolve @massu/core package.json. ' +
1008
- 'This indicates a corrupt install. Re-install via `npx -y @massu/core init`.',
1009
- );
1010
- }
1011
-
1012
- export function registerMcpServer(projectRoot: string): boolean {
1013
- const mcpPath = resolve(projectRoot, '.mcp.json');
1014
-
1015
- let existing: Record<string, unknown> = {};
1016
- if (existsSync(mcpPath)) {
1017
- try {
1018
- existing = JSON.parse(readFileSync(mcpPath, 'utf-8'));
1019
- } catch {
1020
- existing = {};
1021
- }
1022
- }
1023
-
1024
- const servers = (existing.mcpServers ?? {}) as Record<string, unknown>;
1025
- if (servers.massu) {
1026
- return false;
1027
- }
1028
-
1029
- // P-002: pin the version so customers don't drift onto unpinned `@massu/core`
1030
- // (which resolves to the latest dist-tag on every spawn). Closes the structural
1031
- // class flagged by feedback_mcp_pin_version_in_mcp_json (precedent: 0b60916).
1032
- const version = getInstallerVersion();
1033
- servers.massu = {
1034
- type: 'stdio',
1035
- command: 'npx',
1036
- args: ['-y', `@massu/core@${version}`],
1037
- };
1038
-
1039
- existing.mcpServers = servers;
1040
-
1041
- writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
1042
- return true;
1043
- }
1044
-
1045
- // ============================================================
1046
- // Hook Installation (preserved)
1047
- // ============================================================
1048
-
1049
- interface HookEntry {
1050
- type: 'command';
1051
- command: string;
1052
- timeout: number;
1053
- }
1054
-
1055
- interface HookGroup {
1056
- matcher?: string;
1057
- hooks: HookEntry[];
1058
- }
1059
-
1060
- type HooksConfig = Record<string, HookGroup[]>;
1061
-
1062
- /**
1063
- * @deprecated P-003 (1.9.4+): the path returned here is unsafe to bake into
1064
- * settings.json — under npx, `__dirname` resolves to whatever cache directory
1065
- * npx happens to use, which is invalidated on cache clear / upgrade / move.
1066
- *
1067
- * Retained only for backward compatibility with `buildHooksConfig(hooksDir)`
1068
- * callers in existing tests. New code should NEVER consume the returned path
1069
- * verbatim in a command line; use `hook-runner` invocations instead (see
1070
- * `buildHooksConfig` which now ignores the argument).
1071
- */
1072
- export function resolveHooksDir(): string {
1073
- const cwd = process.cwd();
1074
- const nodeModulesPath = resolve(cwd, 'node_modules/@massu/core/dist/hooks');
1075
- if (existsSync(nodeModulesPath)) {
1076
- return 'node_modules/@massu/core/dist/hooks';
1077
- }
1078
- const localPath = resolve(__dirname, '../dist/hooks');
1079
- if (existsSync(localPath)) {
1080
- return localPath;
1081
- }
1082
- return 'node_modules/@massu/core/dist/hooks';
1083
- }
1084
-
1085
- /**
1086
- * Build a single hook-command line. P-003: emits `npx -y @massu/core@<version>
1087
- * hook-runner <name>` so the hook file is resolved dynamically at fire-time
1088
- * rather than baked as an absolute path. Pins the version (CR-49-class fix)
1089
- * so customers don't drift onto unpinned `@massu/core` between hook fires.
1090
- */
1091
- function hookCmd(version: string, hookName: string): string {
1092
- return `npx -y @massu/core@${version} hook-runner ${hookName}`;
1093
- }
1094
-
1095
- /**
1096
- * Build the canonical Claude Code hooks configuration. The legacy
1097
- * `hooksDir` parameter is now ignored; we emit `hook-runner` invocations
1098
- * instead of `node <abs-path>` (see P-003). The parameter is retained
1099
- * for backward-compatible call sites (existing tests pass a dir).
1100
- */
1101
- export function buildHooksConfig(_hooksDir?: string): HooksConfig {
1102
- const version = getInstallerVersion();
1103
- return {
1104
- SessionStart: [
1105
- {
1106
- hooks: [
1107
- { type: 'command', command: hookCmd(version, 'session-start'), timeout: 10 },
1108
- ],
1109
- },
1110
- ],
1111
- PreToolUse: [
1112
- {
1113
- matcher: 'Bash',
1114
- hooks: [
1115
- { type: 'command', command: hookCmd(version, 'security-gate'), timeout: 5 },
1116
- ],
1117
- },
1118
- {
1119
- matcher: 'Bash|Write',
1120
- hooks: [
1121
- { type: 'command', command: hookCmd(version, 'pre-delete-check'), timeout: 5 },
1122
- ],
1123
- },
1124
- ],
1125
- PostToolUse: [
1126
- {
1127
- hooks: [
1128
- { type: 'command', command: hookCmd(version, 'post-tool-use'), timeout: 10 },
1129
- { type: 'command', command: hookCmd(version, 'quality-event'), timeout: 5 },
1130
- { type: 'command', command: hookCmd(version, 'cost-tracker'), timeout: 5 },
1131
- ],
1132
- },
1133
- {
1134
- matcher: 'Edit|Write',
1135
- hooks: [
1136
- { type: 'command', command: hookCmd(version, 'post-edit-context'), timeout: 5 },
1137
- // Auto-learning pipeline — classifies failures and detects fixes on
1138
- // file changes. See Phase 5-6 of the autodetect plan.
1139
- { type: 'command', command: hookCmd(version, 'fix-detector'), timeout: 5 },
1140
- { type: 'command', command: hookCmd(version, 'classify-failure'), timeout: 5 },
1141
- ],
1142
- },
1143
- {
1144
- matcher: 'Write',
1145
- hooks: [
1146
- // Incident + rule enforcement pipelines fire on Write-only (incidents
1147
- // are authored as .md files; rules are enforced after new-file drops).
1148
- { type: 'command', command: hookCmd(version, 'incident-pipeline'), timeout: 5 },
1149
- { type: 'command', command: hookCmd(version, 'rule-enforcement-pipeline'), timeout: 5 },
1150
- ],
1151
- },
1152
- ],
1153
- Stop: [
1154
- {
1155
- hooks: [
1156
- { type: 'command', command: hookCmd(version, 'session-end'), timeout: 15 },
1157
- // Session-end auto-learning aggregation (failure-class roll-up).
1158
- { type: 'command', command: hookCmd(version, 'auto-learning-pipeline'), timeout: 10 },
1159
- ],
1160
- },
1161
- ],
1162
- PreCompact: [
1163
- {
1164
- hooks: [
1165
- { type: 'command', command: hookCmd(version, 'pre-compact'), timeout: 10 },
1166
- ],
1167
- },
1168
- ],
1169
- UserPromptSubmit: [
1170
- {
1171
- hooks: [
1172
- { type: 'command', command: hookCmd(version, 'user-prompt'), timeout: 5 },
1173
- { type: 'command', command: hookCmd(version, 'intent-suggester'), timeout: 5 },
1174
- ],
1175
- },
1176
- ],
1177
- };
1178
- }
1179
-
1180
- /**
1181
- * Deep-merge two hooks configurations. P-012 (1.9.4+) — mirrors the 1.8.0
1182
- * permissions merge pattern; closes the structural class where wholesale
1183
- * `settings.hooks = newConfig` silently destroyed customer-defined hooks
1184
- * on every reinstall.
1185
- *
1186
- * Merge semantics:
1187
- * - Top-level keys (event names: SessionStart, PreToolUse, ...) are unioned.
1188
- * - For each event, hook-groups are merged by `matcher` (or "" if no matcher).
1189
- * This is the same identity key Claude Code uses for dispatch — two groups
1190
- * with the same matcher MUST be coalesced or the dispatcher will pick one
1191
- * and silently drop the other.
1192
- * - Within a merged group, hook entries are deduplicated by `command` string
1193
- * (exact match). Massu's own canonical entries are emitted first, then any
1194
- * customer entries that don't collide. This preserves customer hooks while
1195
- * keeping Massu's pipeline behavior deterministic.
1196
- *
1197
- * Massu canonical entries are identified by the `npx -y @massu/core@<version>
1198
- * hook-runner ` prefix. ANY entry not matching that prefix is treated as
1199
- * customer-defined and preserved verbatim across reinstalls — including
1200
- * legacy entries from older `@massu/core` versions, which the customer can
1201
- * clean up at their leisure.
1202
- */
1203
- export function mergeHooksConfig(
1204
- existing: HooksConfig,
1205
- additions: HooksConfig,
1206
- ): HooksConfig {
1207
- const eventNames = new Set<string>([
1208
- ...Object.keys(existing ?? {}),
1209
- ...Object.keys(additions ?? {}),
1210
- ]);
1211
-
1212
- const merged: HooksConfig = {};
1213
- for (const event of eventNames) {
1214
- const existingGroups = (existing?.[event] ?? []) as HookGroup[];
1215
- const additionGroups = (additions?.[event] ?? []) as HookGroup[];
1216
-
1217
- // Index by matcher key (use "" sentinel for groups with no matcher).
1218
- const byMatcher = new Map<string, HookGroup>();
1219
-
1220
- // Pass 1: seed with EXISTING groups (preserves customer order + structure).
1221
- for (const group of existingGroups) {
1222
- const key = group.matcher ?? '';
1223
- const existingGroup = byMatcher.get(key);
1224
- if (existingGroup) {
1225
- // Two existing groups with same matcher — unusual but coalesce defensively.
1226
- existingGroup.hooks = mergeHookEntries(existingGroup.hooks, group.hooks);
1227
- } else {
1228
- byMatcher.set(key, { ...group, hooks: [...(group.hooks ?? [])] });
1229
- }
1230
- }
1231
-
1232
- // Pass 2: merge ADDITIONS into the indexed groups.
1233
- for (const group of additionGroups) {
1234
- const key = group.matcher ?? '';
1235
- const existingGroup = byMatcher.get(key);
1236
- if (existingGroup) {
1237
- existingGroup.hooks = mergeHookEntries(existingGroup.hooks, group.hooks);
1238
- } else {
1239
- byMatcher.set(key, { ...group, hooks: [...(group.hooks ?? [])] });
1240
- }
1241
- }
1242
-
1243
- merged[event] = Array.from(byMatcher.values());
1244
- }
1245
- return merged;
1246
- }
1247
-
1248
- /**
1249
- * Merge two arrays of hook entries, deduplicating by `command`. Customer
1250
- * entries (any command NOT matching the Massu canonical prefix) are
1251
- * always preserved.
1252
- */
1253
- function mergeHookEntries(
1254
- existing: HookEntry[],
1255
- additions: HookEntry[],
1256
- ): HookEntry[] {
1257
- const seen = new Set<string>();
1258
- const result: HookEntry[] = [];
1259
- // Additions go first so Massu's canonical pipeline order is deterministic.
1260
- for (const entry of additions ?? []) {
1261
- if (!entry || typeof entry.command !== 'string') continue;
1262
- if (seen.has(entry.command)) continue;
1263
- seen.add(entry.command);
1264
- result.push(entry);
1265
- }
1266
- for (const entry of existing ?? []) {
1267
- if (!entry || typeof entry.command !== 'string') continue;
1268
- if (seen.has(entry.command)) continue;
1269
- seen.add(entry.command);
1270
- result.push(entry);
1271
- }
1272
- return result;
1273
- }
1274
-
1275
- export function installHooks(projectRoot: string): { installed: boolean; count: number } {
1276
- // Read claudeDirName defensively — tests may call installHooks without
1277
- // ever creating massu.config.yaml, in which case getConfig() throws (since
1278
- // it reads against process.cwd() and our cwd may not have one).
1279
- let claudeDirName = '.claude';
1280
- try {
1281
- claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
1282
- } catch {
1283
- claudeDirName = '.claude';
1284
- }
1285
- const claudeDir = resolve(projectRoot, claudeDirName);
1286
-
1287
- if (!existsSync(claudeDir)) {
1288
- mkdirSync(claudeDir, { recursive: true });
1289
- }
1290
-
1291
- const settings = readSettingsLocal(claudeDir);
1292
-
1293
- // P-003: hooksDir argument is now unused (kept for legacy callers).
1294
- // buildHooksConfig emits `npx -y @massu/core@<version> hook-runner` lines.
1295
- const hooksConfig = buildHooksConfig(resolveHooksDir());
1296
-
1297
- // P-012: deep-merge with existing customer hooks instead of wholesale
1298
- // replacement. Mirrors the 1.8.0 permissions-merge pattern (CR-39 trap class).
1299
- const existingHooks = (settings.hooks as HooksConfig | undefined) ?? {};
1300
- const mergedHooks = mergeHooksConfig(existingHooks, hooksConfig);
1301
-
1302
- let hookCount = 0;
1303
- for (const groups of Object.values(mergedHooks)) {
1304
- for (const group of groups) {
1305
- hookCount += group.hooks.length;
1306
- }
1307
- }
1308
-
1309
- settings.hooks = mergedHooks;
1310
-
1311
- writeSettingsLocalAtomic(claudeDir, settings);
1312
-
1313
- return { installed: true, count: hookCount };
1314
- }
1315
-
1316
- // ============================================================
1317
- // Memory Directory Initialization (preserved)
1318
- // ============================================================
1319
-
1320
- export function initMemoryDir(projectRoot: string): { created: boolean; memoryMdCreated: boolean; migratedFromLegacy: boolean } {
1321
- // P-004 / CR-39: encoding MUST match the reader at `config.ts:getResolvedPaths()`.
1322
- // The legacy writer prepended an extra `-` (producing `--Users-foo-...`) which
1323
- // orphaned MEMORY.md from the reader's canonical single-dash path. Shared helper
1324
- // is the SoT — never re-derive inline.
1325
- const encodedRoot = encodeMemoryDirName(projectRoot);
1326
- const memoryDir = resolve(homedir(), `.claude/projects/${encodedRoot}/memory`);
1327
-
1328
- // Legacy-double-dash migration: if the customer was previously installed by
1329
- // a buggy version (<1.9.4) that wrote to `--<root>`, detect that orphaned
1330
- // sibling directory and move its contents into the canonical `-<root>` form.
1331
- // Idempotent: skips if the legacy dir doesn't exist OR if it's already migrated.
1332
- let migratedFromLegacy = false;
1333
- const legacyDir = resolve(homedir(), `.claude/projects/-${encodedRoot}/memory`);
1334
- if (existsSync(legacyDir) && !existsSync(memoryDir)) {
1335
- try {
1336
- mkdirSync(resolve(memoryDir, '..'), { recursive: true });
1337
- renameSync(legacyDir, memoryDir);
1338
- // Best-effort cleanup of the now-empty parent (only if empty).
1339
- try {
1340
- const legacyParent = resolve(legacyDir, '..');
1341
- if (existsSync(legacyParent) && readdirSync(legacyParent).length === 0) {
1342
- rmSync(legacyParent, { recursive: false });
1343
- }
1344
- } catch { /* best effort */ }
1345
- migratedFromLegacy = true;
1346
- } catch { /* best effort — if migration fails, the new canonical dir is still created below */ }
1347
- }
1348
-
1349
- let created = false;
1350
- if (!existsSync(memoryDir)) {
1351
- mkdirSync(memoryDir, { recursive: true });
1352
- created = true;
1353
- }
1354
-
1355
- const memoryMdPath = resolve(memoryDir, 'MEMORY.md');
1356
- let memoryMdCreated = false;
1357
- if (!existsSync(memoryMdPath)) {
1358
- const projectName = basename(projectRoot);
1359
- const memoryContent = `# ${projectName} - Massu Memory
1360
-
1361
- ## Key Learnings
1362
- <!-- Important patterns and conventions discovered during development -->
1363
-
1364
- ## Common Gotchas
1365
- <!-- Non-obvious issues and how to avoid them -->
1366
-
1367
- ## Corrections
1368
- <!-- Wrong behaviors that were corrected and how to prevent them -->
1369
-
1370
- ## File Index
1371
- <!-- Significant files and directories -->
1372
- `;
1373
- writeFileSync(memoryMdPath, memoryContent, 'utf-8');
1374
- memoryMdCreated = true;
1375
- }
1376
-
1377
- return { created, memoryMdCreated, migratedFromLegacy };
1378
- }
1379
-
1380
- // ============================================================
1381
- // Flag Parsing
1382
- // ============================================================
1383
-
1384
- export interface ParseInitArgsResult extends InitOptions {
1385
- /** True when --help / -h was requested. runInit should print help and exit. */
1386
- help?: boolean;
1387
- }
1388
-
1389
- export function parseInitArgs(argv: string[]): ParseInitArgsResult {
1390
- const opts: ParseInitArgsResult = {};
1391
- for (let i = 0; i < argv.length; i++) {
1392
- const a = argv[i];
1393
- if (a === '--ci') opts.ci = true;
1394
- else if (a === '--force') opts.force = true;
1395
- else if (a === '--skip-commands') opts.skipCommands = true;
1396
- else if (a === '--no-introspect') opts.skipIntrospect = true;
1397
- else if (a === '--help' || a === '-h') opts.help = true;
1398
- else if (a === '--template') {
1399
- const next = argv[i + 1];
1400
- if (next && !next.startsWith('--')) {
1401
- opts.template = next;
1402
- i++;
1403
- }
1404
- } else if (a.startsWith('--template=')) {
1405
- opts.template = a.slice('--template='.length);
1406
- }
1407
- }
1408
- return opts;
1409
- }
1410
-
1411
- export function printInitHelp(): void {
1412
- console.log(`
1413
- massu init — detect project stack and generate massu.config.yaml
1414
-
1415
- Usage:
1416
- massu init [options]
1417
-
1418
- Options:
1419
- --ci Non-interactive mode. Errors on existing config
1420
- (unless --force). Auto-enabled when stdin is not a TTY.
1421
- --force Overwrite existing massu.config.yaml without prompting.
1422
- --template <name> Skip detection and scaffold from a greenfield template.
1423
- Templates: ${TEMPLATE_NAMES.join(', ')}
1424
- --skip-commands Skip the asset install (.claude/commands etc).
1425
- MCP register, hooks, and memory init still run.
1426
- --help, -h Show this help message
1427
-
1428
- Examples:
1429
- massu init # Interactive (prompts before overwriting)
1430
- massu init --ci # Safe for CI; fails if config already exists
1431
- massu init --force # Overwrite an existing config
1432
- massu init --template ts-nextjs # Scaffold from the Next.js template
1433
-
1434
- Documentation: https://massu.ai/docs/getting-started/configuration
1435
- `);
1436
- }
1437
-
1438
- // ============================================================
1439
- // Stack summary (for user confirmation)
1440
- // ============================================================
1441
-
1442
- function summarizeDetection(detection: DetectionResult): string {
1443
- const parts: string[] = [];
1444
- const languages = Array.from(
1445
- new Set(detection.manifests.map((m) => m.language))
1446
- ) as SupportedLanguage[];
1447
- for (const lang of languages) {
1448
- const fw = detection.frameworks[lang];
1449
- const dirs = detection.sourceDirs[lang]?.source_dirs ?? [];
1450
- const dirSuffix = dirs.length > 0 ? ` in ${dirs.join(',')}` : '';
1451
- const fwName = fw?.framework ?? 'no-framework';
1452
- parts.push(`${capitalize(lang)}/${fwName}${dirSuffix}`);
1453
- }
1454
- const mono = detection.monorepo.type;
1455
- const monoSuffix = mono && mono !== 'single' ? ` [${mono} monorepo]` : '';
1456
- return parts.join('; ') + monoSuffix;
1457
- }
1458
-
1459
- // ============================================================
1460
- // Main Init Flow (Phase 3 rewrite)
1461
- // ============================================================
1462
-
1463
- export async function runInit(argv?: string[], overrides?: InitOptions): Promise<void> {
1464
- const argsToParse = argv ?? process.argv.slice(3); // argv[0]=node, [1]=cli.js, [2]='init'
1465
- const parsed = parseInitArgs(argsToParse);
1466
- if (parsed.help && !overrides?.silent) {
1467
- printInitHelp();
1468
- return;
1469
- }
1470
- // Strip `help` from parsed before merging (not part of InitOptions).
1471
- const { help: _help, ...parsedOpts } = parsed;
1472
- void _help;
1473
- const opts: InitOptions = { ...parsedOpts, ...(overrides ?? {}) };
1474
-
1475
- // Auto-CI when stdin is not a TTY (e.g., CI pipes, scripts).
1476
- if (!opts.ci && !process.stdin.isTTY) {
1477
- opts.ci = true;
1478
- }
1479
-
1480
- const projectRoot = opts.cwd ?? process.cwd();
1481
- const log = opts.silent ? () => {} : (s: string) => console.log(s);
1482
- const errLog = opts.silent ? () => {} : (s: string) => console.error(s);
1483
-
1484
- log('');
1485
- log('Massu AI - Project Setup');
1486
- log('========================');
1487
- log('');
1488
-
1489
- const configPath = resolve(projectRoot, 'massu.config.yaml');
1490
-
1491
- // P3-006: safety rails for existing config.
1492
- if (existsSync(configPath)) {
1493
- if (opts.ci && !opts.force) {
1494
- errLog(`error: massu.config.yaml already exists at ${configPath}`);
1495
- errLog(' rerun with --force to overwrite, or remove the file first');
1496
- throw new Error('massu init: config exists in --ci mode (no overwrite)');
1497
- }
1498
- if (!opts.ci && !opts.force) {
1499
- // Interactive: prompt to confirm overwrite.
1500
- const confirmed = await promptOverwrite(configPath);
1501
- if (!confirmed) {
1502
- log(' massu.config.yaml preserved — init aborted');
1503
- return;
1504
- }
1505
- }
1506
- // else: --force set, proceed with overwrite
1507
- }
1508
-
1509
- // Branch 1: template mode (P3-003)
1510
- if (opts.template) {
1511
- if (!isTemplateName(opts.template)) {
1512
- errLog(`error: unknown template '${opts.template}'. Available: ${TEMPLATE_NAMES.join(', ')}`);
1513
- throw new Error(`Unknown template: ${opts.template}`);
1514
- }
1515
- const projectName = basename(projectRoot);
1516
- const res = copyTemplateConfig(opts.template, configPath, projectName);
1517
- if (!res.success) {
1518
- errLog(`error: template copy failed: ${res.error}`);
1519
- throw new Error(res.error ?? 'template copy failed');
1520
- }
1521
- // Validate the template-derived config (skip filesystem existence checks:
1522
- // templates are explicitly for greenfield projects where the declared dirs
1523
- // don't exist yet).
1524
- const validation = validateWrittenConfig(configPath, projectRoot, false);
1525
- if (validation !== null) {
1526
- try { rmSync(configPath, { force: true }); } catch { /* ignore */ }
1527
- errLog(`error: template config failed validation: ${validation}`);
1528
- throw new Error(`Template config invalid: ${validation}`);
1529
- }
1530
- log(` Installed template '${opts.template}' → massu.config.yaml`);
1531
- if (!opts.skipSideEffects) {
1532
- installSideEffects(projectRoot, log, opts.skipCommands);
1533
- }
1534
- return;
1535
- }
1536
-
1537
- // Branch 2: detection-driven path (P3-001, P3-002)
1538
- const detection = await runDetection(projectRoot);
1539
- const languageCount = new Set(detection.manifests.map((m) => m.language)).size;
1540
- const emptyStack = detection.manifests.length === 0 && languageCount === 0;
1541
- if (emptyStack) {
1542
- if (opts.ci && !opts.force) {
1543
- // Plan #2 §"Answer to install-before-stack": interactive `massu init` in
1544
- // an empty repo is supported. CI mode keeps the strict guard (no
1545
- // accidental empty-stack configs in pipelines) — pass --force in CI to
1546
- // explicitly opt into empty-stack init.
1547
- errLog('error: no languages detected in this directory');
1548
- errLog(' (no package.json, pyproject.toml, Cargo.toml, etc.)');
1549
- errLog(' pass --template <name>, --force, or run interactively for empty-stack init');
1550
- throw new Error('No languages detected');
1551
- }
1552
- log(' No languages detected — proceeding with empty-stack init.');
1553
- log(' After adding a manifest (package.json, pyproject.toml, ...) run: npx massu config refresh');
1554
- }
1555
-
1556
- // Emit warnings to stderr for ambiguous / malformed detection.
1557
- for (const w of detection.warnings) {
1558
- errLog(`warning: ${w.path}: ${w.reason}`);
1559
- }
1560
-
1561
- // Ambiguity warning: multiple languages with similar file density.
1562
- const dirCounts: { lang: SupportedLanguage; count: number }[] = [];
1563
- for (const [lang, info] of Object.entries(detection.sourceDirs)) {
1564
- if (info && typeof info.file_count === 'number') {
1565
- dirCounts.push({ lang: lang as SupportedLanguage, count: info.file_count });
1566
- }
1567
- }
1568
- if (dirCounts.length >= 2) {
1569
- dirCounts.sort((a, b) => b.count - a.count);
1570
- if (dirCounts[0].count > 0 && dirCounts[1].count / Math.max(dirCounts[0].count, 1) >= 0.5) {
1571
- errLog(`warning: multiple languages with similar file counts: ${dirCounts.map(d => `${d.lang}=${d.count}`).join(', ')}`);
1572
- errLog(' primary language chosen by manifest count; review framework.primary in the generated config');
1573
- }
1574
- }
1575
-
1576
- log(` Detected: ${summarizeDetection(detection)}`);
1577
-
1578
- // Interactive confirmation for detected stack.
1579
- if (!opts.ci && !opts.force) {
1580
- const confirmed = await promptStackConfirm();
1581
- if (!confirmed) {
1582
- log(' init aborted — no changes made');
1583
- return;
1584
- }
1585
- }
1586
-
1587
- // Build config + apply variant template + write atomically. Plan 1.5.1
1588
- // §3: the framework-specific variant template under
1589
- // packages/core/templates/<id>/massu.config.yaml supplies router,
1590
- // paths.source, and verification.<lang>.lint that the generic
1591
- // detection-derived baseline doesn't know to set. Pre-1.5.1 init
1592
- // emitted configs with `router: none` even for clear-Rails / clear-
1593
- // Phoenix / clear-Spring projects (CR-39 violation per the Plan 1.5.1
1594
- // 5-fixture verification).
1595
- const baseConfig = buildConfigFromDetection({ projectRoot, detection });
1596
- const withVariant = applyVariantTemplate(baseConfig, resolveTemplatesDir());
1597
-
1598
- // Plan 1.5.4 §3: pipe AST adapter introspect output into the emitted
1599
- // config under `detected.<adapter-id>:` blocks. introspectAsync runs
1600
- // the AST adapter pipeline (using the real file sampler from
1601
- // codebase-introspector.ts post-1.5.4); each adapter that returns
1602
- // non-'none' confidence surfaces its conventions + provenance.
1603
- // --no-introspect bypasses for users who want sync init.
1604
- let config = withVariant;
1605
- if (!opts.skipIntrospect) {
1606
- try {
1607
- const { introspectAsync } = await import('../detect/codebase-introspector.ts');
1608
- const introspected = await introspectAsync(detection, projectRoot);
1609
- const detectedBlocks: Record<string, unknown> = {};
1610
- for (const [key, block] of Object.entries(introspected)) {
1611
- // `introspected` includes language-keyed regex-fallback blocks
1612
- // (`python`, `swift`, `typescript`) AND adapter-id-keyed AST
1613
- // blocks. The AST blocks are the ones we want under
1614
- // `detected.<adapter-id>:` — distinguishable by the presence of
1615
- // a `_confidence` field on the block (the regex blocks don't
1616
- // carry it). Filter for AST adapter outputs only.
1617
- if (block && typeof block === 'object' && '_confidence' in (block as Record<string, unknown>)) {
1618
- detectedBlocks[key] = block;
1619
- }
1620
- }
1621
- if (Object.keys(detectedBlocks).length > 0) {
1622
- config = { ...config, detected: detectedBlocks };
1623
- }
1624
- } catch (err) {
1625
- // Non-fatal: AST introspect is enrichment, not core detection.
1626
- // Log to stderr so operators see the warning if it matters.
1627
- errLog(`warning: AST adapter introspection failed: ${err instanceof Error ? err.message : String(err)}`);
1628
- }
1629
- }
1630
-
1631
- const content = renderConfigYaml(config);
1632
- const writeRes = writeConfigAtomic(configPath, content);
1633
- if (!writeRes.validated) {
1634
- errLog(`error: failed to write config: ${writeRes.error}`);
1635
- throw new Error(writeRes.error ?? 'atomic write failed');
1636
- }
1637
-
1638
- // Post-write validation; rollback on failure. Skip filesystem-existence
1639
- // checks for empty-stack init (no manifests = `paths.source` defaults to
1640
- // 'src' which legitimately doesn't exist in an empty dir).
1641
- const validation = validateWrittenConfig(configPath, projectRoot, !emptyStack);
1642
- if (validation !== null) {
1643
- try { rmSync(configPath, { force: true }); } catch { /* ignore */ }
1644
- errLog(`error: generated config failed validation: ${validation}`);
1645
- errLog(' config file rolled back; no changes persisted');
1646
- throw new Error(`Generated config invalid: ${validation}`);
1647
- }
1648
-
1649
- log(' Created massu.config.yaml (schema_version: 2)');
1650
-
1651
- if (!opts.skipSideEffects) {
1652
- installSideEffects(projectRoot, log, opts.skipCommands, emptyStack);
1653
- }
1654
- }
1655
-
1656
- /** Shared side-effect steps (MCP register + hooks + commands + memory + backfill). */
1657
- function installSideEffects(
1658
- projectRoot: string,
1659
- log: (s: string) => void,
1660
- skipCommands: boolean = false,
1661
- emptyStack: boolean = false,
1662
- ): void {
1663
- // MCP register
1664
- const mcpRegistered = registerMcpServer(projectRoot);
1665
- if (mcpRegistered) {
1666
- log(' Registered MCP server in .mcp.json');
1667
- } else {
1668
- log(' MCP server already registered in .mcp.json');
1669
- }
1670
-
1671
- // Hooks
1672
- const { count: hooksCount } = installHooks(projectRoot);
1673
- log(` Installed ${hooksCount} hooks in .claude/settings.local.json`);
1674
-
1675
- // Plan #2 P4-002: install all asset types (commands, agents, patterns,
1676
- // protocols, reference) via installAll — replaces the legacy
1677
- // installCommands() that only handled commands. Skipped when --skip-commands.
1678
- // Plan #2 P4-003: when no stack-specific commands resolved (empty-stack init),
1679
- // write a single `_massu-needs-stack.md` placeholder so consumers know to
1680
- // run `config refresh` after adding their first manifest.
1681
- if (!skipCommands) {
1682
- try {
1683
- const cmdResult = installAll(projectRoot);
1684
- const cmdTotal =
1685
- cmdResult.totalInstalled +
1686
- cmdResult.totalUpdated +
1687
- cmdResult.totalSkipped +
1688
- cmdResult.totalKept;
1689
- if (cmdResult.totalInstalled > 0 || cmdResult.totalUpdated > 0) {
1690
- log(` Installed ${cmdTotal} project assets (${cmdResult.totalInstalled} new, ${cmdResult.totalUpdated} updated)`);
1691
- } else if (cmdTotal > 0) {
1692
- log(` ${cmdTotal} project assets already up to date`);
1693
- }
1694
-
1695
- // Empty-stack init detection: when caller signals an empty stack OR
1696
- // when NO commands resolved at all, drop the placeholder so the user
1697
- // understands the next step. The explicit `emptyStack` signal handles
1698
- // the t=0 case (zero manifests detected) where generic-default commands
1699
- // still install but no stack-specific scaffolds match the consumer.
1700
- const commandStats = cmdResult.assets.commands;
1701
- const stackResolved = !emptyStack && commandStats &&
1702
- (commandStats.installed > 0 || commandStats.updated > 0 || commandStats.kept > 0);
1703
- if (!stackResolved) {
1704
- const placeholderPath = resolve(cmdResult.claudeDir, 'commands', '_massu-needs-stack.md');
1705
- if (!existsSync(placeholderPath)) {
1706
- const placeholderBody = [
1707
- '# Massu — stack not yet detected',
1708
- '',
1709
- 'Your stack hasn\'t been detected yet. Most slash commands ship as language-specific',
1710
- 'variants (e.g., `massu-scaffold-router.python-fastapi.md` for FastAPI projects).',
1711
- 'When detection finds a manifest, the right variants get installed automatically.',
1712
- '',
1713
- 'After you add your first manifest (`package.json`, `pyproject.toml`, `Cargo.toml`,',
1714
- 'etc.) run:',
1715
- '',
1716
- '```bash',
1717
- 'npx massu config refresh',
1718
- '```',
1719
- '',
1720
- 'This file will be auto-removed on the first refresh that resolves at least one',
1721
- 'stack-specific command.',
1722
- '',
1723
- '— Massu',
1724
- ].join('\n');
1725
- try {
1726
- mkdirSync(resolve(cmdResult.claudeDir, 'commands'), { recursive: true });
1727
- writeFileSync(placeholderPath, placeholderBody, 'utf-8');
1728
- log(' Wrote _massu-needs-stack.md placeholder (no stack detected yet)');
1729
- } catch {
1730
- // Best-effort.
1731
- }
1732
- }
1733
- }
1734
- } catch {
1735
- // Best-effort — don't fail init if assets can't be resolved.
1736
- }
1737
- }
1738
-
1739
- // Memory dir
1740
- const { created: memDirCreated, memoryMdCreated, migratedFromLegacy } = initMemoryDir(projectRoot);
1741
- if (memDirCreated) {
1742
- log(' Created memory directory');
1743
- }
1744
- if (memoryMdCreated) {
1745
- log(' Created initial MEMORY.md');
1746
- }
1747
- if (migratedFromLegacy) {
1748
- log(' Migrated memory directory from legacy double-dash path (pre-1.9.4)');
1749
- }
1750
-
1751
- // Backfill (best-effort, silent failure)
1752
- (async () => {
1753
- try {
1754
- // Shared encode helper — must match `initMemoryDir` and `config.ts:getResolvedPaths()`.
1755
- const encodedRoot = encodeMemoryDirName(projectRoot);
1756
- const memoryDir = resolve(homedir(), '.claude', 'projects', encodedRoot, 'memory');
1757
- const memFiles = existsSync(memoryDir)
1758
- ? readdirSync(memoryDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md')
1759
- : [];
1760
- if (memFiles.length > 0) {
1761
- const { getMemoryDb } = await import('../memory-db.ts');
1762
- const db = getMemoryDb();
1763
- try {
1764
- const stats = backfillMemoryFiles(db, memoryDir, `init-${Date.now()}`);
1765
- if (stats.inserted > 0 || stats.updated > 0) {
1766
- log(` Backfilled ${stats.inserted + stats.updated} memory files (${stats.inserted} new, ${stats.updated} updated)`);
1767
- }
1768
- } finally {
1769
- db.close();
1770
- }
1771
- }
1772
- } catch { /* best effort */ }
1773
- })();
1774
-
1775
- log(' Databases will auto-create on first session');
1776
- log('');
1777
- log('Massu AI is ready. Start a Claude Code session to begin.');
1778
- log('');
1779
- }
1780
-
1781
- // ============================================================
1782
- // Prompts (interactive path)
1783
- // ============================================================
1784
-
1785
- async function promptOverwrite(configPath: string): Promise<boolean> {
1786
- try {
1787
- const { confirm, isCancel } = await import('@clack/prompts');
1788
- const res = await confirm({
1789
- message: `massu.config.yaml already exists at ${configPath}. Overwrite?`,
1790
- initialValue: false,
1791
- });
1792
- if (isCancel(res)) return false;
1793
- return res === true;
1794
- } catch {
1795
- // Clack not available (should never happen — it's a dep); fail safe to NO.
1796
- return false;
1797
- }
1798
- }
1799
-
1800
- async function promptStackConfirm(): Promise<boolean> {
1801
- try {
1802
- const { confirm, isCancel } = await import('@clack/prompts');
1803
- const res = await confirm({
1804
- message: 'Generate massu.config.yaml from detected stack?',
1805
- initialValue: true,
1806
- });
1807
- if (isCancel(res)) return false;
1808
- return res === true;
1809
- } catch {
1810
- return true; // Default yes when clack is unavailable.
1811
- }
1812
- }
1813
-
1814
- // ============================================================
1815
- // Helpers
1816
- // ============================================================
1817
-
1818
- function capitalize(str: string): string {
1819
- return str.charAt(0).toUpperCase() + str.slice(1);
1820
- }
1821
-
1822
- // `InitResult` is a compile-time type only; it's kept for external type-reuse.
1823
- export type { InitResult };