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