@massu/core 1.2.1 → 1.4.0-soak.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/README.md +40 -0
- package/commands/README.md +137 -0
- package/commands/massu-deploy.python-docker.md +170 -0
- package/commands/massu-deploy.python-fly.md +189 -0
- package/commands/massu-deploy.python-launchd.md +144 -0
- package/commands/massu-deploy.python-systemd.md +163 -0
- package/commands/massu-deploy.python.md +200 -0
- package/commands/massu-scaffold-page.md +172 -59
- package/commands/massu-scaffold-page.swift.md +121 -0
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/commands/massu-scaffold-router.python.md +143 -0
- package/dist/cli.js +10170 -4138
- package/dist/hooks/auto-learning-pipeline.js +44 -6
- package/dist/hooks/classify-failure.js +44 -6
- package/dist/hooks/cost-tracker.js +44 -6
- package/dist/hooks/fix-detector.js +44 -6
- package/dist/hooks/incident-pipeline.js +44 -6
- package/dist/hooks/post-edit-context.js +44 -6
- package/dist/hooks/post-tool-use.js +44 -6
- package/dist/hooks/pre-compact.js +44 -6
- package/dist/hooks/pre-delete-check.js +44 -6
- package/dist/hooks/quality-event.js +44 -6
- package/dist/hooks/rule-enforcement-pipeline.js +44 -6
- package/dist/hooks/session-end.js +44 -6
- package/dist/hooks/session-start.js +4789 -410
- package/dist/hooks/user-prompt.js +44 -6
- package/package.json +10 -4
- package/src/cli.ts +28 -2
- package/src/commands/config-refresh.ts +88 -20
- package/src/commands/init.ts +130 -23
- package/src/commands/install-commands.ts +482 -42
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/show-template.ts +65 -0
- package/src/commands/template-engine.ts +262 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +69 -3
- package/src/detect/adapters/nextjs-trpc.ts +166 -0
- package/src/detect/adapters/parse-guard.ts +133 -0
- package/src/detect/adapters/python-django.ts +208 -0
- package/src/detect/adapters/python-fastapi.ts +223 -0
- package/src/detect/adapters/query-helpers.ts +170 -0
- package/src/detect/adapters/runner.ts +252 -0
- package/src/detect/adapters/swift-swiftui.ts +171 -0
- package/src/detect/adapters/tree-sitter-loader.ts +348 -0
- package/src/detect/adapters/types.ts +174 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- package/src/detect/regex-fallback.ts +449 -0
- package/src/hooks/session-start.ts +94 -3
- package/src/lib/gitToplevel.ts +22 -0
- package/src/lib/installLock.ts +179 -0
- package/src/lib/pidLiveness.ts +67 -0
- package/src/lsp/auto-detect.ts +89 -0
- package/src/lsp/client.ts +590 -0
- package/src/lsp/enrich.ts +127 -0
- package/src/lsp/types.ts +221 -0
- package/src/watch/daemon.ts +385 -0
- package/src/watch/lockfile-detector.ts +65 -0
- package/src/watch/paths.ts +279 -0
- package/src/watch/state.ts +178 -0
|
@@ -252,6 +252,7 @@ var FrameworkConfigSchema = z.object({
|
|
|
252
252
|
ui: z.string().default("none"),
|
|
253
253
|
languages: z.record(z.string(), LanguageFrameworkEntrySchema).optional()
|
|
254
254
|
}).passthrough();
|
|
255
|
+
var DetectedConfigSchema = z.object({}).passthrough().optional();
|
|
255
256
|
var VerificationEntrySchema = z.object({
|
|
256
257
|
type: z.string().optional(),
|
|
257
258
|
test: z.string().optional(),
|
|
@@ -276,6 +277,31 @@ var DetectionConfigSchema = z.object({
|
|
|
276
277
|
signal_weights: z.record(z.string(), z.number()).optional(),
|
|
277
278
|
disable_builtin: z.boolean().optional()
|
|
278
279
|
}).passthrough().optional();
|
|
280
|
+
var WatchConfigSchema = z.object({
|
|
281
|
+
debounce_ms: z.number().int().positive().default(3e3),
|
|
282
|
+
storm_threshold: z.number().int().positive().default(50),
|
|
283
|
+
deep_storm_threshold: z.number().int().positive().default(500),
|
|
284
|
+
hard_timeout_ms: z.number().int().positive().default(3e5),
|
|
285
|
+
scope: z.enum(["paths", "full"]).default("paths"),
|
|
286
|
+
// Plan 3a hotfix 2026-05-02: refuse to start if the watch surface
|
|
287
|
+
// exceeds this many files. Prevents the misconfig pattern where
|
|
288
|
+
// `paths.source_dirs` includes `.` or otherwise expands to a 60K+
|
|
289
|
+
// file tree, producing 30-100% steady CPU. Override via
|
|
290
|
+
// `paths_full_root_opt_in: true` for users on small repos who genuinely
|
|
291
|
+
// need root-level watching.
|
|
292
|
+
max_watched_files: z.number().int().positive().default(1e4),
|
|
293
|
+
paths_full_root_opt_in: z.boolean().default(false)
|
|
294
|
+
}).passthrough().optional();
|
|
295
|
+
var LSPConfigSchema = z.object({
|
|
296
|
+
enabled: z.boolean().default(false),
|
|
297
|
+
servers: z.array(z.object({
|
|
298
|
+
language: z.string(),
|
|
299
|
+
command: z.string()
|
|
300
|
+
})).default([]),
|
|
301
|
+
autoDetect: z.object({
|
|
302
|
+
viaPortScan: z.boolean().default(false)
|
|
303
|
+
}).optional()
|
|
304
|
+
}).passthrough();
|
|
279
305
|
var RawConfigSchema = z.object({
|
|
280
306
|
schema_version: z.union([z.literal(1), z.literal(2)]).default(1),
|
|
281
307
|
project: z.object({
|
|
@@ -308,7 +334,13 @@ var RawConfigSchema = z.object({
|
|
|
308
334
|
verification: VerificationConfigSchema,
|
|
309
335
|
canonical_paths: CanonicalPathsSchema,
|
|
310
336
|
verification_types: VerificationTypesSchema,
|
|
311
|
-
detection: DetectionConfigSchema
|
|
337
|
+
detection: DetectionConfigSchema,
|
|
338
|
+
// Plan #2: detector-owned per-language conventions (free-form passthrough)
|
|
339
|
+
detected: DetectedConfigSchema,
|
|
340
|
+
// Plan 3a: file-watcher daemon tunables
|
|
341
|
+
watch: WatchConfigSchema,
|
|
342
|
+
// Plan 3b Phase 4: optional LSP enrichment of AST adapter results.
|
|
343
|
+
lsp: LSPConfigSchema.optional()
|
|
312
344
|
}).passthrough();
|
|
313
345
|
var _config = null;
|
|
314
346
|
var _projectRoot = null;
|
|
@@ -385,13 +417,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
|
|
|
385
417
|
name: parsed.project.name,
|
|
386
418
|
root: projectRoot
|
|
387
419
|
},
|
|
420
|
+
// Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
|
|
421
|
+
// `framework.python`) survive into the consumer-visible Config. Then override
|
|
422
|
+
// the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
|
|
423
|
+
// variant-resolution `pickVariant` (install-commands.ts) cannot see the
|
|
424
|
+
// top-level passthrough language blocks.
|
|
388
425
|
framework: {
|
|
389
|
-
|
|
426
|
+
...fw,
|
|
390
427
|
router,
|
|
391
428
|
orm,
|
|
392
|
-
ui
|
|
393
|
-
primary: fw.primary,
|
|
394
|
-
languages: fw.languages
|
|
429
|
+
ui
|
|
395
430
|
},
|
|
396
431
|
paths: parsed.paths,
|
|
397
432
|
toolPrefix: parsed.toolPrefix,
|
|
@@ -412,7 +447,10 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
|
|
|
412
447
|
verification: parsed.verification,
|
|
413
448
|
canonical_paths: parsed.canonical_paths,
|
|
414
449
|
verification_types: parsed.verification_types,
|
|
415
|
-
detection: parsed.detection
|
|
450
|
+
detection: parsed.detection,
|
|
451
|
+
detected: parsed.detected,
|
|
452
|
+
watch: parsed.watch,
|
|
453
|
+
lsp: parsed.lsp
|
|
416
454
|
};
|
|
417
455
|
if (!_config.cloud?.apiKey && process.env.MASSU_API_KEY) {
|
|
418
456
|
_config.cloud = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@massu/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0-soak.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
|
|
6
6
|
"main": "src/server.ts",
|
|
@@ -11,20 +11,26 @@
|
|
|
11
11
|
"start": "npx tsx src/server.ts",
|
|
12
12
|
"test": "vitest run",
|
|
13
13
|
"build": "tsc --noEmit && npm run build:cli && npm run build:hooks",
|
|
14
|
-
"build:cli": "esbuild --bundle --platform=node --format=esm --outfile=dist/cli.js src/cli.ts --external:better-sqlite3 --external:yaml --external:zod --banner:js='#!/usr/bin/env node\nimport{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
|
|
15
|
-
"build:hooks": "esbuild --bundle --platform=node --format=esm --outdir=dist/hooks src/hooks/*.ts --external:better-sqlite3 --external:yaml --external:zod --banner:js='import{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
|
|
16
|
-
"prepublishOnly": "bash ../../scripts/prepublish-check.sh && npm run build"
|
|
14
|
+
"build:cli": "esbuild --bundle --platform=node --format=esm --outfile=dist/cli.js src/cli.ts --external:better-sqlite3 --external:yaml --external:zod --external:chokidar --external:proper-lockfile --external:fsevents --banner:js='#!/usr/bin/env node\nimport{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
|
|
15
|
+
"build:hooks": "esbuild --bundle --platform=node --format=esm --outdir=dist/hooks src/hooks/*.ts --external:better-sqlite3 --external:yaml --external:zod --external:chokidar --external:proper-lockfile --external:fsevents --banner:js='import{createRequire as __cr}from\"module\";const require=__cr(import.meta.url);'",
|
|
16
|
+
"prepublishOnly": "bash ../../scripts/prepublish-check.sh && npm run build",
|
|
17
|
+
"bench:watch": "tsx test/perf/watch-benchmark.ts"
|
|
17
18
|
},
|
|
18
19
|
"dependencies": {
|
|
19
20
|
"@clack/prompts": "^0.9.1",
|
|
20
21
|
"better-sqlite3": "^12.6.2",
|
|
22
|
+
"chokidar": "^3.6.0",
|
|
21
23
|
"fast-glob": "^3.3.0",
|
|
24
|
+
"proper-lockfile": "^4.1.2",
|
|
22
25
|
"smol-toml": "^1.3.0",
|
|
26
|
+
"vscode-languageserver-protocol": "^3.17.5",
|
|
27
|
+
"web-tree-sitter": "^0.26.8",
|
|
23
28
|
"yaml": "^2.4.0",
|
|
24
29
|
"zod": "^3.23.0"
|
|
25
30
|
},
|
|
26
31
|
"devDependencies": {
|
|
27
32
|
"@types/better-sqlite3": "^7.6.13",
|
|
33
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
28
34
|
"esbuild": "^0.27.3",
|
|
29
35
|
"tsx": "^4.0.0",
|
|
30
36
|
"typescript": "^5.4.0",
|
package/src/cli.ts
CHANGED
|
@@ -47,6 +47,23 @@ async function main(): Promise<void> {
|
|
|
47
47
|
await runInstallCommands();
|
|
48
48
|
break;
|
|
49
49
|
}
|
|
50
|
+
case 'show-template': {
|
|
51
|
+
const { runShowTemplate } = await import('./commands/show-template.ts');
|
|
52
|
+
await runShowTemplate(args.slice(1));
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case 'watch': {
|
|
56
|
+
const { runWatch } = await import('./commands/watch.ts');
|
|
57
|
+
const result = await runWatch(args.slice(1));
|
|
58
|
+
process.exit(result.exitCode);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
case 'refresh-log': {
|
|
62
|
+
const { runRefreshLog } = await import('./commands/refresh-log.ts');
|
|
63
|
+
const result = await runRefreshLog(args.slice(1));
|
|
64
|
+
process.exit(result.exitCode);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
50
67
|
case 'validate-config': {
|
|
51
68
|
const { runValidateConfig } = await import('./commands/doctor.ts');
|
|
52
69
|
await runValidateConfig();
|
|
@@ -80,7 +97,11 @@ async function handleConfigSubcommand(configArgs: string[]): Promise<void> {
|
|
|
80
97
|
switch (sub) {
|
|
81
98
|
case 'refresh': {
|
|
82
99
|
const { runConfigRefresh } = await import('./commands/config-refresh.ts');
|
|
83
|
-
const result = await runConfigRefresh({
|
|
100
|
+
const result = await runConfigRefresh({
|
|
101
|
+
dryRun: flags.has('--dry-run'),
|
|
102
|
+
skipCommands: flags.has('--skip-commands'),
|
|
103
|
+
autoYes: flags.has('--yes') || flags.has('-y'),
|
|
104
|
+
});
|
|
84
105
|
process.exit(result.exitCode);
|
|
85
106
|
return;
|
|
86
107
|
}
|
|
@@ -136,6 +157,9 @@ Commands:
|
|
|
136
157
|
doctor Check installation health
|
|
137
158
|
install-hooks Install/update Claude Code hooks
|
|
138
159
|
install-commands Install/update slash commands
|
|
160
|
+
show-template Print the resolved variant of a bundled template (e.g. for diffs)
|
|
161
|
+
watch Run the file-watcher daemon (auto-refresh on stack changes)
|
|
162
|
+
refresh-log [N] Show the last N watcher auto-refresh events
|
|
139
163
|
validate-config Validate massu.config.yaml (alias: config validate)
|
|
140
164
|
config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
|
|
141
165
|
|
|
@@ -158,7 +182,9 @@ massu config <subcommand>
|
|
|
158
182
|
|
|
159
183
|
Subcommands:
|
|
160
184
|
refresh Re-run detection and apply changes to massu.config.yaml.
|
|
161
|
-
--dry-run
|
|
185
|
+
--dry-run Print diff and exit without writing.
|
|
186
|
+
--skip-commands Don't re-template .claude/commands/.
|
|
187
|
+
--yes, -y Auto-apply without prompting (CI / scripts).
|
|
162
188
|
validate Validate massu.config.yaml (alias of \`massu validate-config\`).
|
|
163
189
|
upgrade Migrate a v1 config to schema_version=2.
|
|
164
190
|
--rollback Restore from .bak file.
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* 2 unparseable massu.config.yaml
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { existsSync, readFileSync } from 'fs';
|
|
26
|
+
import { existsSync, readFileSync, rmSync } from 'fs';
|
|
27
27
|
import { resolve } from 'path';
|
|
28
28
|
import { parse as parseYaml } from 'yaml';
|
|
29
29
|
import { runDetection } from '../detect/index.ts';
|
|
@@ -31,6 +31,9 @@ import { computeFingerprint } from '../detect/drift.ts';
|
|
|
31
31
|
import type { AnyConfig } from '../detect/migrate.ts';
|
|
32
32
|
import { copyUnknownKeys, preserveNestedSubkeys } from '../detect/passthrough.ts';
|
|
33
33
|
import { buildConfigFromDetection, renderConfigYaml, writeConfigAtomic } from './init.ts';
|
|
34
|
+
import { installAll } from './install-commands.ts';
|
|
35
|
+
import { resetConfig } from '../config.ts';
|
|
36
|
+
import { withInstallLock } from '../lib/installLock.ts';
|
|
34
37
|
|
|
35
38
|
const PRESERVED_FIELDS = [
|
|
36
39
|
'rules',
|
|
@@ -56,6 +59,20 @@ export interface ConfigRefreshOptions {
|
|
|
56
59
|
dryRun?: boolean;
|
|
57
60
|
cwd?: string;
|
|
58
61
|
silent?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Plan #2 P4-001: when true, skip the post-refresh `installAll` call so
|
|
64
|
+
* `.claude/commands/` is NOT re-templated. Used by tests to keep file I/O
|
|
65
|
+
* hermetic, and by users who want config-only refresh behavior.
|
|
66
|
+
*/
|
|
67
|
+
skipCommands?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Plan 3a Phase 6: when true, bypass BOTH the non-TTY bail and the
|
|
70
|
+
* `@clack/prompts` confirm gate. The watcher daemon and `--yes` CLI flag
|
|
71
|
+
* use this to auto-apply detected changes. Combined with
|
|
72
|
+
* `skipCommands: true`, it lets the watcher delegate `installAll` to its
|
|
73
|
+
* own outer call (single install, single lock acquire — see iter-3 G3-A9).
|
|
74
|
+
*/
|
|
75
|
+
autoYes?: boolean;
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
export interface ConfigRefreshResult {
|
|
@@ -293,26 +310,34 @@ export async function runConfigRefresh(opts: ConfigRefreshOptions = {}): Promise
|
|
|
293
310
|
return { exitCode: 0, applied: false, dryRun: false, diff };
|
|
294
311
|
}
|
|
295
312
|
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
313
|
+
// Plan 3a Phase 6: when autoYes=true, skip BOTH the non-TTY bail and the
|
|
314
|
+
// confirm gate so the watcher (daemon stdin is detached) and the
|
|
315
|
+
// `--yes`/-y CLI flag actually apply changes.
|
|
316
|
+
if (!opts.autoYes) {
|
|
317
|
+
// Interactive prompt; fall back to dry-run semantics when not a TTY.
|
|
318
|
+
if (!process.stdin.isTTY) {
|
|
319
|
+
log('Config diff (non-interactive; pass --dry-run to suppress this note or run interactively to apply):\n');
|
|
320
|
+
log(renderDiff(diff));
|
|
321
|
+
return {
|
|
322
|
+
exitCode: 0,
|
|
323
|
+
applied: false,
|
|
324
|
+
dryRun: false,
|
|
325
|
+
diff,
|
|
326
|
+
message: 'non-interactive shell; no changes written',
|
|
327
|
+
};
|
|
328
|
+
}
|
|
308
329
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
330
|
+
log('Config diff:\n');
|
|
331
|
+
log(renderDiff(diff));
|
|
332
|
+
const { confirm } = await import('@clack/prompts');
|
|
333
|
+
const apply = await confirm({ message: 'Apply these changes to massu.config.yaml?' });
|
|
334
|
+
if (apply !== true) {
|
|
335
|
+
log('Aborted; no changes written.\n');
|
|
336
|
+
return { exitCode: 0, applied: false, dryRun: false, diff, message: 'aborted by user' };
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
log('Config diff (auto-applying via --yes / watcher):\n');
|
|
340
|
+
log(renderDiff(diff));
|
|
316
341
|
}
|
|
317
342
|
|
|
318
343
|
const yamlContent = renderConfigYaml(merged);
|
|
@@ -323,5 +348,48 @@ export async function runConfigRefresh(opts: ConfigRefreshOptions = {}): Promise
|
|
|
323
348
|
return { exitCode: 2, applied: false, dryRun: false, diff, message };
|
|
324
349
|
}
|
|
325
350
|
log('Config refreshed.\n');
|
|
351
|
+
|
|
352
|
+
// Plan #2 P4-001: re-template `.claude/commands/` against the freshly
|
|
353
|
+
// written config so newly-detected stack changes get the right scaffolds.
|
|
354
|
+
// P4-003 (auto-delete half): if a stack is now declared and the empty-init
|
|
355
|
+
// placeholder still exists, remove it.
|
|
356
|
+
if (!opts.skipCommands) {
|
|
357
|
+
log('Will also re-template command files; pass --skip-commands to opt out.\n');
|
|
358
|
+
// Reset cached config so installAll reads the freshly-written YAML.
|
|
359
|
+
resetConfig();
|
|
360
|
+
try {
|
|
361
|
+
const installResult = withInstallLock(cwd, () => installAll(cwd));
|
|
362
|
+
const total =
|
|
363
|
+
installResult.totalInstalled +
|
|
364
|
+
installResult.totalUpdated +
|
|
365
|
+
installResult.totalSkipped +
|
|
366
|
+
installResult.totalKept;
|
|
367
|
+
log(`Re-templated ${total} command files (${installResult.totalInstalled} new, ${installResult.totalUpdated} updated).\n`);
|
|
368
|
+
|
|
369
|
+
// Auto-delete the empty-init placeholder if at least one stack-specific
|
|
370
|
+
// command was resolved this run (i.e., a non-zero install/update count).
|
|
371
|
+
const stackResolved =
|
|
372
|
+
installResult.totalInstalled > 0 || installResult.totalUpdated > 0;
|
|
373
|
+
if (stackResolved) {
|
|
374
|
+
const placeholderPath = resolve(installResult.claudeDir, 'commands', '_massu-needs-stack.md');
|
|
375
|
+
if (existsSync(placeholderPath)) {
|
|
376
|
+
try {
|
|
377
|
+
rmSync(placeholderPath, { force: true });
|
|
378
|
+
log('Removed _massu-needs-stack.md (stack now declared).\n');
|
|
379
|
+
} catch {
|
|
380
|
+
// Best-effort: never block refresh on placeholder cleanup.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} catch (err) {
|
|
385
|
+
// Don't fail the whole refresh if re-template breaks; the YAML was already written.
|
|
386
|
+
// InstallLockBusyError.message already follows plan §243 format —
|
|
387
|
+
// `installAll already running (PID=X) — try again in <N>s` — so we
|
|
388
|
+
// surface it verbatim rather than re-wrapping.
|
|
389
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
390
|
+
if (!opts.silent) process.stderr.write(`Warning: re-template failed: ${msg}\n`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
326
394
|
return { exitCode: 0, applied: true, dryRun: false, diff };
|
|
327
395
|
}
|
package/src/commands/init.ts
CHANGED
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
* installHooks, buildHooksConfig, resolveHooksDir, initMemoryDir, runInit.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, chmodSync } from 'fs';
|
|
29
|
+
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, writeFileSync, writeSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, chmodSync } from 'fs';
|
|
30
30
|
import { resolve, basename, dirname } from 'path';
|
|
31
31
|
import { fileURLToPath } from 'url';
|
|
32
32
|
import { homedir } from 'os';
|
|
33
33
|
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
|
34
34
|
import { backfillMemoryFiles } from '../memory-file-ingest.ts';
|
|
35
35
|
import { getConfig, resetConfig } from '../config.ts';
|
|
36
|
-
import {
|
|
36
|
+
import { installAll } from './install-commands.ts';
|
|
37
37
|
import {
|
|
38
38
|
runDetection,
|
|
39
39
|
type DetectionResult,
|
|
@@ -75,6 +75,11 @@ export interface InitOptions {
|
|
|
75
75
|
template?: string;
|
|
76
76
|
/** Skip hook/command/memory install side-effects. Used in tests. */
|
|
77
77
|
skipSideEffects?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Plan #2 P4-002: when true, skip the asset-install (commands / agents /
|
|
80
|
+
* patterns / etc). MCP register, hooks, and memory init still run.
|
|
81
|
+
*/
|
|
82
|
+
skipCommands?: boolean;
|
|
78
83
|
/** Override cwd (tests). */
|
|
79
84
|
cwd?: string;
|
|
80
85
|
/** Suppress console output. */
|
|
@@ -508,6 +513,14 @@ export function buildConfigFromDetection(
|
|
|
508
513
|
// P5-002: stamp a stack fingerprint so session-start can detect drift later.
|
|
509
514
|
config.detection = { fingerprint: computeFingerprint(detection) };
|
|
510
515
|
|
|
516
|
+
// Plan #2 P3-003: emit detector-owned `detected:` block (per-language
|
|
517
|
+
// conventions sampled from the codebase). Only present when the introspector
|
|
518
|
+
// ran (i.e., not skipped by the session-start hook). Detector-owned →
|
|
519
|
+
// refreshed on every `init`/`config refresh`, NOT in PRESERVED_FIELDS.
|
|
520
|
+
if (detection.detected && Object.keys(detection.detected).length > 0) {
|
|
521
|
+
config.detected = detection.detected;
|
|
522
|
+
}
|
|
523
|
+
|
|
511
524
|
// Preserve legacy `python` block for v1 consumers (domain-enforcer, etc.).
|
|
512
525
|
// Per Phase 0 P1-009 (b): python legacy config coexists with languages.python.
|
|
513
526
|
if (languages.includes('python')) {
|
|
@@ -576,7 +589,31 @@ export function writeConfigAtomic(
|
|
|
576
589
|
}
|
|
577
590
|
|
|
578
591
|
try {
|
|
579
|
-
|
|
592
|
+
// Iter-8 fix: ensure the parent directory exists. POSIX `rename(2)`
|
|
593
|
+
// requires the target's parent to exist; otherwise the rename fails
|
|
594
|
+
// with ENOENT and we leak the tmp. The watcher's auto-refresh path
|
|
595
|
+
// never hits this (the configPath is always inside an existing repo
|
|
596
|
+
// with massu.config.yaml already there), but `runInit` on a fresh
|
|
597
|
+
// path under a non-existent parent would fall over before this line.
|
|
598
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
599
|
+
|
|
600
|
+
// Iter-7 fix: write tmp via openSync + writeSync + fsyncSync + closeSync
|
|
601
|
+
// so the data hits the platter BEFORE renameSync. This matches
|
|
602
|
+
// `writeStateAtomic` (watch/state.ts) and the spec doc claim that the
|
|
603
|
+
// 3a watcher's atomic-rename guarantees universally cover all writes
|
|
604
|
+
// touched during a refresh cycle. Without fsync, on certain filesystems
|
|
605
|
+
// (xfs, ext4 `data=writeback`) the rename can land before data, leaving
|
|
606
|
+
// a zero-byte config on power-loss / SIGKILL between writeFileSync and
|
|
607
|
+
// renameSync — a gap the watcher daemon makes more reachable since
|
|
608
|
+
// refresh writes happen unattended every quiescence window.
|
|
609
|
+
const fd = openSync(tmpPath, 'w', 0o644);
|
|
610
|
+
try {
|
|
611
|
+
const buf = Buffer.from(content, 'utf-8');
|
|
612
|
+
writeSync(fd, buf, 0, buf.length, 0);
|
|
613
|
+
fsyncSync(fd);
|
|
614
|
+
} finally {
|
|
615
|
+
closeSync(fd);
|
|
616
|
+
}
|
|
580
617
|
|
|
581
618
|
// Validate YAML parses.
|
|
582
619
|
const parsed = yamlParse(content);
|
|
@@ -998,6 +1035,7 @@ export function parseInitArgs(argv: string[]): ParseInitArgsResult {
|
|
|
998
1035
|
const a = argv[i];
|
|
999
1036
|
if (a === '--ci') opts.ci = true;
|
|
1000
1037
|
else if (a === '--force') opts.force = true;
|
|
1038
|
+
else if (a === '--skip-commands') opts.skipCommands = true;
|
|
1001
1039
|
else if (a === '--help' || a === '-h') opts.help = true;
|
|
1002
1040
|
else if (a === '--template') {
|
|
1003
1041
|
const next = argv[i + 1];
|
|
@@ -1025,6 +1063,8 @@ Options:
|
|
|
1025
1063
|
--force Overwrite existing massu.config.yaml without prompting.
|
|
1026
1064
|
--template <name> Skip detection and scaffold from a greenfield template.
|
|
1027
1065
|
Templates: ${TEMPLATE_NAMES.join(', ')}
|
|
1066
|
+
--skip-commands Skip the asset install (.claude/commands etc).
|
|
1067
|
+
MCP register, hooks, and memory init still run.
|
|
1028
1068
|
--help, -h Show this help message
|
|
1029
1069
|
|
|
1030
1070
|
Examples:
|
|
@@ -1131,7 +1171,7 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
|
|
|
1131
1171
|
}
|
|
1132
1172
|
log(` Installed template '${opts.template}' → massu.config.yaml`);
|
|
1133
1173
|
if (!opts.skipSideEffects) {
|
|
1134
|
-
installSideEffects(projectRoot, log);
|
|
1174
|
+
installSideEffects(projectRoot, log, opts.skipCommands);
|
|
1135
1175
|
}
|
|
1136
1176
|
return;
|
|
1137
1177
|
}
|
|
@@ -1139,11 +1179,20 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
|
|
|
1139
1179
|
// Branch 2: detection-driven path (P3-001, P3-002)
|
|
1140
1180
|
const detection = await runDetection(projectRoot);
|
|
1141
1181
|
const languageCount = new Set(detection.manifests.map((m) => m.language)).size;
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1182
|
+
const emptyStack = detection.manifests.length === 0 && languageCount === 0;
|
|
1183
|
+
if (emptyStack) {
|
|
1184
|
+
if (opts.ci && !opts.force) {
|
|
1185
|
+
// Plan #2 §"Answer to install-before-stack": interactive `massu init` in
|
|
1186
|
+
// an empty repo is supported. CI mode keeps the strict guard (no
|
|
1187
|
+
// accidental empty-stack configs in pipelines) — pass --force in CI to
|
|
1188
|
+
// explicitly opt into empty-stack init.
|
|
1189
|
+
errLog('error: no languages detected in this directory');
|
|
1190
|
+
errLog(' (no package.json, pyproject.toml, Cargo.toml, etc.)');
|
|
1191
|
+
errLog(' pass --template <name>, --force, or run interactively for empty-stack init');
|
|
1192
|
+
throw new Error('No languages detected');
|
|
1193
|
+
}
|
|
1194
|
+
log(' No languages detected — proceeding with empty-stack init.');
|
|
1195
|
+
log(' After adding a manifest (package.json, pyproject.toml, ...) run: npx massu config refresh');
|
|
1147
1196
|
}
|
|
1148
1197
|
|
|
1149
1198
|
// Emit warnings to stderr for ambiguous / malformed detection.
|
|
@@ -1186,8 +1235,10 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
|
|
|
1186
1235
|
throw new Error(writeRes.error ?? 'atomic write failed');
|
|
1187
1236
|
}
|
|
1188
1237
|
|
|
1189
|
-
// Post-write validation; rollback on failure.
|
|
1190
|
-
|
|
1238
|
+
// Post-write validation; rollback on failure. Skip filesystem-existence
|
|
1239
|
+
// checks for empty-stack init (no manifests = `paths.source` defaults to
|
|
1240
|
+
// 'src' which legitimately doesn't exist in an empty dir).
|
|
1241
|
+
const validation = validateWrittenConfig(configPath, projectRoot, !emptyStack);
|
|
1191
1242
|
if (validation !== null) {
|
|
1192
1243
|
try { rmSync(configPath, { force: true }); } catch { /* ignore */ }
|
|
1193
1244
|
errLog(`error: generated config failed validation: ${validation}`);
|
|
@@ -1198,12 +1249,17 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
|
|
|
1198
1249
|
log(' Created massu.config.yaml (schema_version: 2)');
|
|
1199
1250
|
|
|
1200
1251
|
if (!opts.skipSideEffects) {
|
|
1201
|
-
installSideEffects(projectRoot, log);
|
|
1252
|
+
installSideEffects(projectRoot, log, opts.skipCommands, emptyStack);
|
|
1202
1253
|
}
|
|
1203
1254
|
}
|
|
1204
1255
|
|
|
1205
1256
|
/** Shared side-effect steps (MCP register + hooks + commands + memory + backfill). */
|
|
1206
|
-
function installSideEffects(
|
|
1257
|
+
function installSideEffects(
|
|
1258
|
+
projectRoot: string,
|
|
1259
|
+
log: (s: string) => void,
|
|
1260
|
+
skipCommands: boolean = false,
|
|
1261
|
+
emptyStack: boolean = false,
|
|
1262
|
+
): void {
|
|
1207
1263
|
// MCP register
|
|
1208
1264
|
const mcpRegistered = registerMcpServer(projectRoot);
|
|
1209
1265
|
if (mcpRegistered) {
|
|
@@ -1216,17 +1272,68 @@ function installSideEffects(projectRoot: string, log: (s: string) => void): void
|
|
|
1216
1272
|
const { count: hooksCount } = installHooks(projectRoot);
|
|
1217
1273
|
log(` Installed ${hooksCount} hooks in .claude/settings.local.json`);
|
|
1218
1274
|
|
|
1219
|
-
//
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1275
|
+
// Plan #2 P4-002: install all asset types (commands, agents, patterns,
|
|
1276
|
+
// protocols, reference) via installAll — replaces the legacy
|
|
1277
|
+
// installCommands() that only handled commands. Skipped when --skip-commands.
|
|
1278
|
+
// Plan #2 P4-003: when no stack-specific commands resolved (empty-stack init),
|
|
1279
|
+
// write a single `_massu-needs-stack.md` placeholder so consumers know to
|
|
1280
|
+
// run `config refresh` after adding their first manifest.
|
|
1281
|
+
if (!skipCommands) {
|
|
1282
|
+
try {
|
|
1283
|
+
const cmdResult = installAll(projectRoot);
|
|
1284
|
+
const cmdTotal =
|
|
1285
|
+
cmdResult.totalInstalled +
|
|
1286
|
+
cmdResult.totalUpdated +
|
|
1287
|
+
cmdResult.totalSkipped +
|
|
1288
|
+
cmdResult.totalKept;
|
|
1289
|
+
if (cmdResult.totalInstalled > 0 || cmdResult.totalUpdated > 0) {
|
|
1290
|
+
log(` Installed ${cmdTotal} project assets (${cmdResult.totalInstalled} new, ${cmdResult.totalUpdated} updated)`);
|
|
1291
|
+
} else if (cmdTotal > 0) {
|
|
1292
|
+
log(` ${cmdTotal} project assets already up to date`);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Empty-stack init detection: when caller signals an empty stack OR
|
|
1296
|
+
// when NO commands resolved at all, drop the placeholder so the user
|
|
1297
|
+
// understands the next step. The explicit `emptyStack` signal handles
|
|
1298
|
+
// the t=0 case (zero manifests detected) where generic-default commands
|
|
1299
|
+
// still install but no stack-specific scaffolds match the consumer.
|
|
1300
|
+
const commandStats = cmdResult.assets.commands;
|
|
1301
|
+
const stackResolved = !emptyStack && commandStats &&
|
|
1302
|
+
(commandStats.installed > 0 || commandStats.updated > 0 || commandStats.kept > 0);
|
|
1303
|
+
if (!stackResolved) {
|
|
1304
|
+
const placeholderPath = resolve(cmdResult.claudeDir, 'commands', '_massu-needs-stack.md');
|
|
1305
|
+
if (!existsSync(placeholderPath)) {
|
|
1306
|
+
const placeholderBody = [
|
|
1307
|
+
'# Massu — stack not yet detected',
|
|
1308
|
+
'',
|
|
1309
|
+
'Your stack hasn\'t been detected yet. Most slash commands ship as language-specific',
|
|
1310
|
+
'variants (e.g., `massu-scaffold-router.python-fastapi.md` for FastAPI projects).',
|
|
1311
|
+
'When detection finds a manifest, the right variants get installed automatically.',
|
|
1312
|
+
'',
|
|
1313
|
+
'After you add your first manifest (`package.json`, `pyproject.toml`, `Cargo.toml`,',
|
|
1314
|
+
'etc.) run:',
|
|
1315
|
+
'',
|
|
1316
|
+
'```bash',
|
|
1317
|
+
'npx massu config refresh',
|
|
1318
|
+
'```',
|
|
1319
|
+
'',
|
|
1320
|
+
'This file will be auto-removed on the first refresh that resolves at least one',
|
|
1321
|
+
'stack-specific command.',
|
|
1322
|
+
'',
|
|
1323
|
+
'— Massu',
|
|
1324
|
+
].join('\n');
|
|
1325
|
+
try {
|
|
1326
|
+
mkdirSync(resolve(cmdResult.claudeDir, 'commands'), { recursive: true });
|
|
1327
|
+
writeFileSync(placeholderPath, placeholderBody, 'utf-8');
|
|
1328
|
+
log(' Wrote _massu-needs-stack.md placeholder (no stack detected yet)');
|
|
1329
|
+
} catch {
|
|
1330
|
+
// Best-effort.
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
} catch {
|
|
1335
|
+
// Best-effort — don't fail init if assets can't be resolved.
|
|
1227
1336
|
}
|
|
1228
|
-
} catch {
|
|
1229
|
-
// Best-effort — don't fail init if assets can't be resolved.
|
|
1230
1337
|
}
|
|
1231
1338
|
|
|
1232
1339
|
// Memory dir
|