@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.
Files changed (53) hide show
  1. package/dist/cli.js +11182 -1559
  2. package/dist/hooks/auto-learning-pipeline.js +99 -19
  3. package/dist/hooks/classify-failure.js +99 -19
  4. package/dist/hooks/cost-tracker.js +97 -11
  5. package/dist/hooks/fix-detector.js +99 -19
  6. package/dist/hooks/incident-pipeline.js +97 -11
  7. package/dist/hooks/post-edit-context.js +97 -11
  8. package/dist/hooks/post-tool-use.js +101 -20
  9. package/dist/hooks/pre-compact.js +97 -11
  10. package/dist/hooks/pre-delete-check.js +97 -11
  11. package/dist/hooks/quality-event.js +97 -11
  12. package/dist/hooks/rule-enforcement-pipeline.js +97 -11
  13. package/dist/hooks/session-end.js +97 -11
  14. package/dist/hooks/session-start.js +8803 -782
  15. package/dist/hooks/user-prompt.js +98 -43
  16. package/package.json +13 -3
  17. package/reference/hook-execution-order.md +17 -25
  18. package/src/cli.ts +81 -2
  19. package/src/commands/config-check-drift.ts +132 -0
  20. package/src/commands/config-refresh.ts +224 -0
  21. package/src/commands/config-upgrade.ts +126 -0
  22. package/src/commands/doctor.ts +1 -29
  23. package/src/commands/init.ts +756 -216
  24. package/src/config.ts +168 -12
  25. package/src/detect/domain-inferrer.ts +142 -0
  26. package/src/detect/drift.ts +199 -0
  27. package/src/detect/framework-detector.ts +281 -0
  28. package/src/detect/index.ts +174 -0
  29. package/src/detect/migrate.ts +278 -0
  30. package/src/detect/monorepo-detector.ts +347 -0
  31. package/src/detect/package-detector.ts +728 -0
  32. package/src/detect/source-dir-detector.ts +264 -0
  33. package/src/detect/vr-command-map.ts +167 -0
  34. package/src/hooks/auto-learning-pipeline.ts +2 -2
  35. package/src/hooks/classify-failure.ts +2 -2
  36. package/src/hooks/fix-detector.ts +2 -2
  37. package/src/hooks/session-start.ts +43 -2
  38. package/src/hooks/user-prompt.ts +1 -21
  39. package/src/knowledge-indexer.ts +1 -1
  40. package/src/license.ts +1 -2
  41. package/src/memory-db.ts +0 -5
  42. package/src/memory-file-ingest.ts +6 -13
  43. package/src/tools.ts +0 -8
  44. package/templates/multi-runtime/massu.config.yaml +80 -0
  45. package/templates/python-django/massu.config.yaml +51 -0
  46. package/templates/python-fastapi/massu.config.yaml +50 -0
  47. package/templates/rust-actix/massu.config.yaml +38 -0
  48. package/templates/swift-ios/massu.config.yaml +37 -0
  49. package/templates/ts-nestjs/massu.config.yaml +43 -0
  50. package/templates/ts-nextjs/massu.config.yaml +43 -0
  51. package/README.md +0 -40
  52. package/src/claude-md-templates.ts +0 -342
  53. package/src/mcp-bridge-tools.ts +0 -458
@@ -0,0 +1,224 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu config refresh` — re-run detection, diff against current config, apply
6
+ * or print-only (--dry-run).
7
+ *
8
+ * Merge semantics:
9
+ * - Detector-owned keys (framework, paths.source, verification, detection) are REFRESHED.
10
+ * - User-authored keys (rules, domains, canonical_paths, accessScopes,
11
+ * knownMismatches, dbAccessPattern, analytics, governance, security, team,
12
+ * regression, cloud, conventions, autoLearning, verification_types,
13
+ * python) are PRESERVED verbatim from the existing config.
14
+ *
15
+ * Flags:
16
+ * --dry-run Emit the diff to stdout, exit 0, never write.
17
+ * (none) Interactive: show diff, prompt for confirmation via @clack/prompts.
18
+ * When stdin is not a TTY, behaves as --dry-run with a note.
19
+ *
20
+ * Exit codes:
21
+ * 0 success (applied, or dry-run completed)
22
+ * 1 missing massu.config.yaml (run `massu init`)
23
+ * 2 unparseable massu.config.yaml
24
+ */
25
+
26
+ import { existsSync, readFileSync } from 'fs';
27
+ import { resolve } from 'path';
28
+ import { parse as parseYaml } from 'yaml';
29
+ import { runDetection } from '../detect/index.ts';
30
+ import { computeFingerprint } from '../detect/drift.ts';
31
+ import type { AnyConfig } from '../detect/migrate.ts';
32
+ import { buildConfigFromDetection, renderConfigYaml, writeConfigAtomic } from './init.ts';
33
+
34
+ const PRESERVED_FIELDS = [
35
+ 'rules',
36
+ 'domains',
37
+ 'canonical_paths',
38
+ 'verification_types',
39
+ 'accessScopes',
40
+ 'knownMismatches',
41
+ 'dbAccessPattern',
42
+ 'analytics',
43
+ 'governance',
44
+ 'security',
45
+ 'team',
46
+ 'regression',
47
+ 'cloud',
48
+ 'conventions',
49
+ 'autoLearning',
50
+ 'python',
51
+ ] as const;
52
+
53
+ export interface ConfigRefreshOptions {
54
+ dryRun?: boolean;
55
+ cwd?: string;
56
+ silent?: boolean;
57
+ }
58
+
59
+ export interface ConfigRefreshResult {
60
+ exitCode: 0 | 1 | 2;
61
+ applied: boolean;
62
+ dryRun: boolean;
63
+ diff: DiffLine[];
64
+ message?: string;
65
+ }
66
+
67
+ export interface DiffLine {
68
+ kind: 'add' | 'remove' | 'change' | 'same';
69
+ path: string;
70
+ before?: unknown;
71
+ after?: unknown;
72
+ }
73
+
74
+ function flatten(obj: unknown, prefix = ''): Record<string, unknown> {
75
+ const out: Record<string, unknown> = {};
76
+ if (obj === null || obj === undefined) {
77
+ out[prefix || '<root>'] = obj;
78
+ return out;
79
+ }
80
+ if (typeof obj !== 'object' || Array.isArray(obj)) {
81
+ out[prefix || '<root>'] = obj;
82
+ return out;
83
+ }
84
+ const rec = obj as Record<string, unknown>;
85
+ for (const [k, v] of Object.entries(rec)) {
86
+ const p = prefix ? `${prefix}.${k}` : k;
87
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
88
+ Object.assign(out, flatten(v, p));
89
+ } else {
90
+ out[p] = v;
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ export function computeDiff(before: AnyConfig, after: AnyConfig): DiffLine[] {
97
+ const b = flatten(before);
98
+ const a = flatten(after);
99
+ const keys = new Set<string>([...Object.keys(b), ...Object.keys(a)]);
100
+ const sorted = [...keys].sort();
101
+ const lines: DiffLine[] = [];
102
+ for (const k of sorted) {
103
+ const bVal = b[k];
104
+ const aVal = a[k];
105
+ const bHas = k in b;
106
+ const aHas = k in a;
107
+ if (bHas && !aHas) {
108
+ lines.push({ kind: 'remove', path: k, before: bVal });
109
+ } else if (!bHas && aHas) {
110
+ lines.push({ kind: 'add', path: k, after: aVal });
111
+ } else if (JSON.stringify(bVal) !== JSON.stringify(aVal)) {
112
+ lines.push({ kind: 'change', path: k, before: bVal, after: aVal });
113
+ }
114
+ }
115
+ return lines;
116
+ }
117
+
118
+ export function mergeRefresh(existing: AnyConfig, refreshed: AnyConfig): AnyConfig {
119
+ const out: AnyConfig = { ...refreshed };
120
+ for (const field of PRESERVED_FIELDS) {
121
+ if (existing[field] !== undefined) {
122
+ out[field] = existing[field];
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function renderDiff(diff: DiffLine[]): string {
129
+ if (diff.length === 0) return '(no changes)\n';
130
+ const lines: string[] = [];
131
+ for (const d of diff) {
132
+ if (d.kind === 'add') lines.push(`+ ${d.path}: ${JSON.stringify(d.after)}`);
133
+ else if (d.kind === 'remove') lines.push(`- ${d.path}: ${JSON.stringify(d.before)}`);
134
+ else if (d.kind === 'change') {
135
+ lines.push(`~ ${d.path}: ${JSON.stringify(d.before)} -> ${JSON.stringify(d.after)}`);
136
+ }
137
+ }
138
+ return lines.join('\n') + '\n';
139
+ }
140
+
141
+ export async function runConfigRefresh(opts: ConfigRefreshOptions = {}): Promise<ConfigRefreshResult> {
142
+ const cwd = opts.cwd ?? process.cwd();
143
+ const configPath = resolve(cwd, 'massu.config.yaml');
144
+ const log = opts.silent ? () => {} : (s: string) => process.stdout.write(s);
145
+
146
+ if (!existsSync(configPath)) {
147
+ const message = 'massu.config.yaml not found. Run: npx massu init';
148
+ if (!opts.silent) process.stderr.write(message + '\n');
149
+ return { exitCode: 1, applied: false, dryRun: !!opts.dryRun, diff: [], message };
150
+ }
151
+
152
+ let existing: AnyConfig;
153
+ try {
154
+ const content = readFileSync(configPath, 'utf-8');
155
+ const parsed = parseYaml(content);
156
+ if (!parsed || typeof parsed !== 'object') {
157
+ throw new Error('config is not a YAML object');
158
+ }
159
+ existing = parsed as AnyConfig;
160
+ } catch (err) {
161
+ const message = `Failed to parse massu.config.yaml: ${err instanceof Error ? err.message : String(err)}`;
162
+ if (!opts.silent) process.stderr.write(message + '\n');
163
+ return { exitCode: 2, applied: false, dryRun: !!opts.dryRun, diff: [], message };
164
+ }
165
+
166
+ const detection = await runDetection(cwd);
167
+ const refreshed = buildConfigFromDetection({
168
+ projectRoot: cwd,
169
+ detection,
170
+ projectName: typeof (existing.project as Record<string, unknown> | undefined)?.name === 'string'
171
+ ? (existing.project as Record<string, unknown>).name as string
172
+ : undefined,
173
+ });
174
+ // buildConfigFromDetection already stamps detection.fingerprint. Double-check.
175
+ if (!(refreshed.detection as Record<string, unknown> | undefined)?.fingerprint) {
176
+ refreshed.detection = { fingerprint: computeFingerprint(detection) };
177
+ }
178
+
179
+ const merged = mergeRefresh(existing, refreshed);
180
+ const diff = computeDiff(existing, merged);
181
+
182
+ if (opts.dryRun) {
183
+ log('Config diff (dry-run; no changes written):\n');
184
+ log(renderDiff(diff));
185
+ return { exitCode: 0, applied: false, dryRun: true, diff };
186
+ }
187
+
188
+ if (diff.length === 0) {
189
+ log('No changes needed — config is already up to date.\n');
190
+ return { exitCode: 0, applied: false, dryRun: false, diff };
191
+ }
192
+
193
+ // Interactive prompt; fall back to dry-run semantics when not a TTY.
194
+ if (!process.stdin.isTTY) {
195
+ log('Config diff (non-interactive; pass --dry-run to suppress this note or run interactively to apply):\n');
196
+ log(renderDiff(diff));
197
+ return {
198
+ exitCode: 0,
199
+ applied: false,
200
+ dryRun: false,
201
+ diff,
202
+ message: 'non-interactive shell; no changes written',
203
+ };
204
+ }
205
+
206
+ log('Config diff:\n');
207
+ log(renderDiff(diff));
208
+ const { confirm } = await import('@clack/prompts');
209
+ const apply = await confirm({ message: 'Apply these changes to massu.config.yaml?' });
210
+ if (apply !== true) {
211
+ log('Aborted; no changes written.\n');
212
+ return { exitCode: 0, applied: false, dryRun: false, diff, message: 'aborted by user' };
213
+ }
214
+
215
+ const yamlContent = renderConfigYaml(merged);
216
+ const writeRes = writeConfigAtomic(configPath, yamlContent);
217
+ if (!writeRes.validated) {
218
+ const message = `Failed to write config: ${writeRes.error}`;
219
+ if (!opts.silent) process.stderr.write(message + '\n');
220
+ return { exitCode: 2, applied: false, dryRun: false, diff, message };
221
+ }
222
+ log('Config refreshed.\n');
223
+ return { exitCode: 0, applied: true, dryRun: false, diff };
224
+ }
@@ -0,0 +1,126 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu config upgrade` — migrate a v1 `massu.config.yaml` to schema_version=2.
6
+ *
7
+ * Flags:
8
+ * --rollback Restore massu.config.yaml from massu.config.yaml.bak.
9
+ * --ci / --yes Non-interactive; no prompts; detector wins on conflicts.
10
+ *
11
+ * Safety:
12
+ * - Writes .bak of the original before overwriting.
13
+ * - Atomic write via writeConfigAtomic (tmp + rename).
14
+ * - Idempotent: running on a schema_version=2 config is a no-op.
15
+ *
16
+ * Exit codes:
17
+ * 0 success (migrated, rolled back, or already current)
18
+ * 1 config missing / rollback source missing
19
+ * 2 parse or write failure
20
+ */
21
+
22
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'fs';
23
+ import { resolve } from 'path';
24
+ import { parse as parseYaml } from 'yaml';
25
+ import { runDetection } from '../detect/index.ts';
26
+ import { computeFingerprint } from '../detect/drift.ts';
27
+ import { migrateV1ToV2, type AnyConfig } from '../detect/migrate.ts';
28
+ import { renderConfigYaml, writeConfigAtomic } from './init.ts';
29
+
30
+ export interface ConfigUpgradeOptions {
31
+ rollback?: boolean;
32
+ ci?: boolean;
33
+ cwd?: string;
34
+ silent?: boolean;
35
+ }
36
+
37
+ export interface ConfigUpgradeResult {
38
+ exitCode: 0 | 1 | 2;
39
+ action: 'migrated' | 'already-current' | 'rolled-back' | 'none';
40
+ message?: string;
41
+ }
42
+
43
+ export async function runConfigUpgrade(opts: ConfigUpgradeOptions = {}): Promise<ConfigUpgradeResult> {
44
+ const cwd = opts.cwd ?? process.cwd();
45
+ const configPath = resolve(cwd, 'massu.config.yaml');
46
+ const bakPath = `${configPath}.bak`;
47
+ const log = opts.silent ? () => {} : (s: string) => process.stdout.write(s);
48
+ const err = opts.silent ? () => {} : (s: string) => process.stderr.write(s);
49
+
50
+ if (opts.rollback) {
51
+ if (!existsSync(bakPath)) {
52
+ const message = `No backup found at ${bakPath}`;
53
+ err(message + '\n');
54
+ return { exitCode: 1, action: 'none', message };
55
+ }
56
+ try {
57
+ copyFileSync(bakPath, configPath);
58
+ unlinkSync(bakPath);
59
+ log('Config restored from backup.\n');
60
+ return { exitCode: 0, action: 'rolled-back' };
61
+ } catch (e) {
62
+ const message = `Rollback failed: ${e instanceof Error ? e.message : String(e)}`;
63
+ err(message + '\n');
64
+ return { exitCode: 2, action: 'none', message };
65
+ }
66
+ }
67
+
68
+ if (!existsSync(configPath)) {
69
+ const message = 'massu.config.yaml not found. Run: npx massu init';
70
+ err(message + '\n');
71
+ return { exitCode: 1, action: 'none', message };
72
+ }
73
+
74
+ let existing: AnyConfig;
75
+ try {
76
+ const content = readFileSync(configPath, 'utf-8');
77
+ const parsed = parseYaml(content);
78
+ if (!parsed || typeof parsed !== 'object') {
79
+ throw new Error('config is not a YAML object');
80
+ }
81
+ existing = parsed as AnyConfig;
82
+ } catch (e) {
83
+ const message = `Failed to parse massu.config.yaml: ${e instanceof Error ? e.message : String(e)}`;
84
+ err(message + '\n');
85
+ return { exitCode: 2, action: 'none', message };
86
+ }
87
+
88
+ const schemaVersion = existing.schema_version;
89
+ if (schemaVersion === 2) {
90
+ log('Config is already at schema_version=2; nothing to do.\n');
91
+ return { exitCode: 0, action: 'already-current' };
92
+ }
93
+
94
+ const detection = await runDetection(cwd);
95
+ const v2 = migrateV1ToV2(existing, detection);
96
+ v2.detection = {
97
+ ...(v2.detection as Record<string, unknown> | undefined ?? {}),
98
+ fingerprint: computeFingerprint(detection),
99
+ };
100
+
101
+ // Back up original before any write.
102
+ try {
103
+ const original = readFileSync(configPath, 'utf-8');
104
+ writeFileSync(bakPath, original, 'utf-8');
105
+ } catch (e) {
106
+ const message = `Failed to write backup: ${e instanceof Error ? e.message : String(e)}`;
107
+ err(message + '\n');
108
+ return { exitCode: 2, action: 'none', message };
109
+ }
110
+
111
+ const yamlContent = renderConfigYaml(v2);
112
+ const writeRes = writeConfigAtomic(configPath, yamlContent);
113
+ if (!writeRes.validated) {
114
+ const message = `Failed to write upgraded config: ${writeRes.error}`;
115
+ err(message + '\n');
116
+ return { exitCode: 2, action: 'none', message };
117
+ }
118
+
119
+ // Non-interactive mode just proceeds; interactive mode currently has no
120
+ // prompt on migrate (the migrator is deterministic and always user-preserving).
121
+ // --ci / --yes remain accepted for script-pipeline safety.
122
+ void opts.ci;
123
+
124
+ log(`Config upgraded to schema_version=2. Backup saved at ${bakPath}\n`);
125
+ return { exitCode: 0, action: 'migrated' };
126
+ }
@@ -8,14 +8,13 @@
8
8
  * 1. massu.config.yaml exists and parses correctly
9
9
  * 2. .mcp.json has massu entry
10
10
  * 3. .claude/settings.local.json has hooks config
11
- * 4. All 15 compiled hook files exist
11
+ * 4. All 11 compiled hook files exist
12
12
  * 5. Knowledge DB exists (.massu/memory.db)
13
13
  * 6. Memory directory exists (~/.claude/projects/.../memory/)
14
14
  * 7. Shell hooks wired in settings.local.json
15
15
  * 8. better-sqlite3 native module loads
16
16
  * 9. Node.js version >= 18
17
17
  * 10. Git repository detected
18
- * 11. CLAUDE.md exists with content
19
18
  */
20
19
 
21
20
  import { existsSync, readFileSync, readdirSync } from 'fs';
@@ -374,32 +373,6 @@ function checkPythonHealth(projectRoot: string): CheckResult | null {
374
373
  };
375
374
  }
376
375
 
377
- function checkClaudeMd(projectRoot: string): CheckResult {
378
- const claudeMdPath = resolve(projectRoot, 'CLAUDE.md');
379
- if (!existsSync(claudeMdPath)) {
380
- return {
381
- name: 'CLAUDE.md',
382
- status: 'warn',
383
- detail: 'CLAUDE.md not found. Run: npx massu init (or create manually)',
384
- };
385
- }
386
-
387
- const content = readFileSync(claudeMdPath, 'utf-8');
388
- if (content.trim().length < 50) {
389
- return {
390
- name: 'CLAUDE.md',
391
- status: 'warn',
392
- detail: 'CLAUDE.md exists but appears empty or minimal',
393
- };
394
- }
395
-
396
- return {
397
- name: 'CLAUDE.md',
398
- status: 'pass',
399
- detail: 'CLAUDE.md found and has content',
400
- };
401
- }
402
-
403
376
  // ============================================================
404
377
  // Main Doctor Flow
405
378
  // ============================================================
@@ -424,7 +397,6 @@ export async function runDoctor(): Promise<void> {
424
397
  checkNodeVersion(),
425
398
  await checkGitRepo(projectRoot),
426
399
  await checkLicenseStatus(),
427
- checkClaudeMd(projectRoot),
428
400
  ];
429
401
 
430
402
  // Add Python health check if configured