@massu/core 0.9.2 → 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.
- package/dist/cli.js +10519 -1661
- package/dist/hooks/auto-learning-pipeline.js +99 -19
- package/dist/hooks/classify-failure.js +99 -19
- package/dist/hooks/cost-tracker.js +97 -11
- package/dist/hooks/fix-detector.js +99 -19
- package/dist/hooks/incident-pipeline.js +97 -11
- package/dist/hooks/post-edit-context.js +97 -11
- package/dist/hooks/post-tool-use.js +101 -20
- package/dist/hooks/pre-compact.js +97 -11
- package/dist/hooks/pre-delete-check.js +97 -11
- package/dist/hooks/quality-event.js +97 -11
- package/dist/hooks/rule-enforcement-pipeline.js +97 -11
- package/dist/hooks/session-end.js +97 -11
- package/dist/hooks/session-start.js +98 -12
- package/dist/hooks/user-prompt.js +98 -43
- package/package.json +13 -3
- package/reference/hook-execution-order.md +17 -25
- package/src/cli.ts +2 -1
- package/src/commands/doctor.ts +1 -29
- package/src/commands/init.ts +752 -216
- package/src/config.ts +168 -12
- package/src/detect/domain-inferrer.ts +142 -0
- package/src/detect/drift.ts +199 -0
- package/src/detect/framework-detector.ts +281 -0
- package/src/detect/index.ts +174 -0
- package/src/detect/migrate.ts +278 -0
- package/src/detect/monorepo-detector.ts +347 -0
- package/src/detect/package-detector.ts +728 -0
- package/src/detect/source-dir-detector.ts +264 -0
- package/src/detect/vr-command-map.ts +167 -0
- package/src/hooks/auto-learning-pipeline.ts +2 -2
- package/src/hooks/classify-failure.ts +2 -2
- package/src/hooks/fix-detector.ts +2 -2
- package/src/hooks/session-start.ts +1 -1
- package/src/hooks/user-prompt.ts +1 -21
- package/src/knowledge-indexer.ts +1 -1
- package/src/license.ts +1 -2
- package/src/memory-db.ts +0 -5
- package/src/memory-file-ingest.ts +6 -13
- package/src/tools.ts +0 -8
- package/templates/multi-runtime/massu.config.yaml +80 -0
- package/templates/python-django/massu.config.yaml +51 -0
- package/templates/python-fastapi/massu.config.yaml +50 -0
- package/templates/rust-actix/massu.config.yaml +38 -0
- package/templates/swift-ios/massu.config.yaml +37 -0
- package/templates/ts-nestjs/massu.config.yaml +43 -0
- package/templates/ts-nextjs/massu.config.yaml +43 -0
- package/README.md +0 -40
- package/src/claude-md-templates.ts +0 -342
- package/src/mcp-bridge-tools.ts +0 -458
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
5
|
+
* `massu init` — One-command, detection-driven project setup.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
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'
|
|
136
|
-
{ file: 'requirements.txt'
|
|
137
|
-
{ file: 'setup.py'
|
|
138
|
-
{ file: 'Pipfile'
|
|
173
|
+
{ file: 'pyproject.toml' },
|
|
174
|
+
{ file: 'requirements.txt' },
|
|
175
|
+
{ file: 'setup.py' },
|
|
176
|
+
{ file: 'Pipfile' },
|
|
139
177
|
];
|
|
140
178
|
|
|
141
|
-
for (const { file
|
|
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
|
-
|
|
147
|
-
if (
|
|
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
|
-
//
|
|
293
|
+
// V2 Config Builder (detection-driven)
|
|
297
294
|
// ============================================================
|
|
298
295
|
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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;
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
898
|
+
// Flag Parsing
|
|
550
899
|
// ============================================================
|
|
551
900
|
|
|
552
|
-
export
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1121
|
+
log(' Registered MCP server in .mcp.json');
|
|
600
1122
|
} else {
|
|
601
|
-
|
|
1123
|
+
log(' MCP server already registered in .mcp.json');
|
|
602
1124
|
}
|
|
603
1125
|
|
|
604
|
-
//
|
|
1126
|
+
// Hooks
|
|
605
1127
|
const { count: hooksCount } = installHooks(projectRoot);
|
|
606
|
-
|
|
1128
|
+
log(` Installed ${hooksCount} hooks in .claude/settings.local.json`);
|
|
607
1129
|
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
|
|
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
|
-
//
|
|
1143
|
+
// Memory dir
|
|
618
1144
|
const { created: memDirCreated, memoryMdCreated } = initMemoryDir(projectRoot);
|
|
619
1145
|
if (memDirCreated) {
|
|
620
|
-
|
|
621
|
-
} else {
|
|
622
|
-
console.log(' Memory directory already exists');
|
|
1146
|
+
log(' Created memory directory');
|
|
623
1147
|
}
|
|
624
1148
|
if (memoryMdCreated) {
|
|
625
|
-
|
|
1149
|
+
log(' Created initial MEMORY.md');
|
|
626
1150
|
}
|
|
627
1151
|
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
}
|
|
651
|
-
// Best-effort: don't fail init if backfill fails
|
|
652
|
-
}
|
|
1172
|
+
} catch { /* best effort */ }
|
|
1173
|
+
})();
|
|
653
1174
|
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
678
|
-
|
|
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 };
|