@massu/core 1.0.0 → 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 +791 -26
- package/dist/hooks/session-start.js +8706 -771
- package/package.json +1 -1
- package/src/cli.ts +79 -1
- 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/init.ts +4 -0
- package/src/hooks/session-start.ts +42 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@massu/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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",
|
package/src/cli.ts
CHANGED
|
@@ -52,6 +52,10 @@ async function main(): Promise<void> {
|
|
|
52
52
|
await runValidateConfig();
|
|
53
53
|
break;
|
|
54
54
|
}
|
|
55
|
+
case 'config': {
|
|
56
|
+
await handleConfigSubcommand(args.slice(1));
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
55
59
|
case '--help':
|
|
56
60
|
case '-h': {
|
|
57
61
|
printHelp();
|
|
@@ -70,6 +74,56 @@ async function main(): Promise<void> {
|
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
async function handleConfigSubcommand(configArgs: string[]): Promise<void> {
|
|
78
|
+
const sub = configArgs[0];
|
|
79
|
+
const flags = new Set(configArgs.slice(1));
|
|
80
|
+
switch (sub) {
|
|
81
|
+
case 'refresh': {
|
|
82
|
+
const { runConfigRefresh } = await import('./commands/config-refresh.ts');
|
|
83
|
+
const result = await runConfigRefresh({ dryRun: flags.has('--dry-run') });
|
|
84
|
+
process.exit(result.exitCode);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case 'validate': {
|
|
88
|
+
const { runValidateConfig } = await import('./commands/doctor.ts');
|
|
89
|
+
await runValidateConfig();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
case 'upgrade': {
|
|
93
|
+
const { runConfigUpgrade } = await import('./commands/config-upgrade.ts');
|
|
94
|
+
const result = await runConfigUpgrade({
|
|
95
|
+
rollback: flags.has('--rollback'),
|
|
96
|
+
ci: flags.has('--ci') || flags.has('--yes'),
|
|
97
|
+
});
|
|
98
|
+
process.exit(result.exitCode);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
case 'doctor': {
|
|
102
|
+
const { runDoctor } = await import('./commands/doctor.ts');
|
|
103
|
+
await runDoctor();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
case 'check-drift': {
|
|
107
|
+
const { runConfigCheckDrift } = await import('./commands/config-check-drift.ts');
|
|
108
|
+
const result = await runConfigCheckDrift({ verbose: flags.has('--verbose') });
|
|
109
|
+
process.exit(result.exitCode);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
case '--help':
|
|
113
|
+
case '-h':
|
|
114
|
+
case undefined: {
|
|
115
|
+
printConfigHelp();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
default: {
|
|
119
|
+
process.stderr.write(`massu: unknown config subcommand: ${sub}\n`);
|
|
120
|
+
printConfigHelp();
|
|
121
|
+
process.exit(1);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
73
127
|
function printHelp(): void {
|
|
74
128
|
console.log(`
|
|
75
129
|
Massu AI - Engineering Governance Platform
|
|
@@ -82,7 +136,8 @@ Commands:
|
|
|
82
136
|
doctor Check installation health
|
|
83
137
|
install-hooks Install/update Claude Code hooks
|
|
84
138
|
install-commands Install/update slash commands
|
|
85
|
-
validate-config Validate massu.config.yaml
|
|
139
|
+
validate-config Validate massu.config.yaml (alias: config validate)
|
|
140
|
+
config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
|
|
86
141
|
|
|
87
142
|
Options:
|
|
88
143
|
--help, -h Show this help message
|
|
@@ -91,11 +146,34 @@ Options:
|
|
|
91
146
|
Getting started:
|
|
92
147
|
npx massu init # Full setup in one command
|
|
93
148
|
npx massu init --help # Show all init options (--ci, --force, --template)
|
|
149
|
+
npx massu config --help # Show config subcommands
|
|
94
150
|
|
|
95
151
|
Documentation: https://massu.ai/docs
|
|
96
152
|
`);
|
|
97
153
|
}
|
|
98
154
|
|
|
155
|
+
function printConfigHelp(): void {
|
|
156
|
+
console.log(`
|
|
157
|
+
massu config <subcommand>
|
|
158
|
+
|
|
159
|
+
Subcommands:
|
|
160
|
+
refresh Re-run detection and apply changes to massu.config.yaml.
|
|
161
|
+
--dry-run Print diff and exit without writing.
|
|
162
|
+
validate Validate massu.config.yaml (alias of \`massu validate-config\`).
|
|
163
|
+
upgrade Migrate a v1 config to schema_version=2.
|
|
164
|
+
--rollback Restore from .bak file.
|
|
165
|
+
--ci, --yes Non-interactive mode (no prompts).
|
|
166
|
+
doctor Run the full health check (alias of \`massu doctor\`).
|
|
167
|
+
check-drift CI-safe drift gate; exits 1 on drift.
|
|
168
|
+
--verbose Print detailed changes to stdout.
|
|
169
|
+
|
|
170
|
+
Examples:
|
|
171
|
+
npx massu config refresh --dry-run
|
|
172
|
+
npx massu config upgrade --ci
|
|
173
|
+
npx massu config check-drift --verbose
|
|
174
|
+
`);
|
|
175
|
+
}
|
|
176
|
+
|
|
99
177
|
function printVersion(): void {
|
|
100
178
|
try {
|
|
101
179
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `massu config check-drift` — CI-safe drift gate.
|
|
6
|
+
*
|
|
7
|
+
* Reads massu.config.yaml, runs detection, and compares the result with both
|
|
8
|
+
* the stored fingerprint (if present) and the structural detectDrift() check.
|
|
9
|
+
*
|
|
10
|
+
* Flags:
|
|
11
|
+
* --verbose Emit the full changes[] to stdout (field: before -> after).
|
|
12
|
+
*
|
|
13
|
+
* Exit codes:
|
|
14
|
+
* 0 no drift
|
|
15
|
+
* 1 drift detected
|
|
16
|
+
* 2 config missing or unparseable
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from 'fs';
|
|
20
|
+
import { resolve } from 'path';
|
|
21
|
+
import { parse as parseYaml } from 'yaml';
|
|
22
|
+
import { runDetection } from '../detect/index.ts';
|
|
23
|
+
import { computeFingerprint, detectDrift, type DriftChange } from '../detect/drift.ts';
|
|
24
|
+
import type { AnyConfig } from '../detect/migrate.ts';
|
|
25
|
+
|
|
26
|
+
export interface ConfigCheckDriftOptions {
|
|
27
|
+
verbose?: boolean;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
silent?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConfigCheckDriftResult {
|
|
33
|
+
exitCode: 0 | 1 | 2;
|
|
34
|
+
drifted: boolean;
|
|
35
|
+
changes: DriftChange[];
|
|
36
|
+
storedFingerprint: string | null;
|
|
37
|
+
currentFingerprint: string | null;
|
|
38
|
+
message?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderChanges(changes: DriftChange[]): string {
|
|
42
|
+
if (changes.length === 0) return '(none)\n';
|
|
43
|
+
return changes
|
|
44
|
+
.map((c) => ` ${c.field}: ${JSON.stringify(c.before)} -> ${JSON.stringify(c.after)}`)
|
|
45
|
+
.join('\n') + '\n';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runConfigCheckDrift(
|
|
49
|
+
opts: ConfigCheckDriftOptions = {}
|
|
50
|
+
): Promise<ConfigCheckDriftResult> {
|
|
51
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
52
|
+
const configPath = resolve(cwd, 'massu.config.yaml');
|
|
53
|
+
const log = opts.silent ? () => {} : (s: string) => process.stdout.write(s);
|
|
54
|
+
const err = opts.silent ? () => {} : (s: string) => process.stderr.write(s);
|
|
55
|
+
|
|
56
|
+
if (!existsSync(configPath)) {
|
|
57
|
+
const message = 'massu.config.yaml not found. Run: npx massu init';
|
|
58
|
+
err(message + '\n');
|
|
59
|
+
return {
|
|
60
|
+
exitCode: 2,
|
|
61
|
+
drifted: false,
|
|
62
|
+
changes: [],
|
|
63
|
+
storedFingerprint: null,
|
|
64
|
+
currentFingerprint: null,
|
|
65
|
+
message,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let config: AnyConfig;
|
|
70
|
+
try {
|
|
71
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
72
|
+
const parsed = parseYaml(content);
|
|
73
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
74
|
+
throw new Error('config is not a YAML object');
|
|
75
|
+
}
|
|
76
|
+
config = parsed as AnyConfig;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
const message = `Failed to parse massu.config.yaml: ${e instanceof Error ? e.message : String(e)}`;
|
|
79
|
+
err(message + '\n');
|
|
80
|
+
return {
|
|
81
|
+
exitCode: 2,
|
|
82
|
+
drifted: false,
|
|
83
|
+
changes: [],
|
|
84
|
+
storedFingerprint: null,
|
|
85
|
+
currentFingerprint: null,
|
|
86
|
+
message,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const detection = await runDetection(cwd);
|
|
91
|
+
const currentFp = computeFingerprint(detection);
|
|
92
|
+
const storedFp =
|
|
93
|
+
typeof (config.detection as Record<string, unknown> | undefined)?.fingerprint === 'string'
|
|
94
|
+
? ((config.detection as Record<string, unknown>).fingerprint as string)
|
|
95
|
+
: null;
|
|
96
|
+
|
|
97
|
+
const report = detectDrift(config, detection);
|
|
98
|
+
const fingerprintDrift = storedFp !== null && storedFp !== currentFp;
|
|
99
|
+
const drifted = report.drifted || fingerprintDrift;
|
|
100
|
+
|
|
101
|
+
if (!drifted) {
|
|
102
|
+
log('No drift detected.\n');
|
|
103
|
+
if (opts.verbose) {
|
|
104
|
+
log(`Fingerprint: ${currentFp}\n`);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
exitCode: 0,
|
|
108
|
+
drifted: false,
|
|
109
|
+
changes: report.changes,
|
|
110
|
+
storedFingerprint: storedFp,
|
|
111
|
+
currentFingerprint: currentFp,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
err('Config drift detected; run `npx massu config refresh` to update.\n');
|
|
116
|
+
if (opts.verbose) {
|
|
117
|
+
if (storedFp !== null) {
|
|
118
|
+
log(`Fingerprint: ${storedFp.slice(0, 16)} -> ${currentFp.slice(0, 16)}\n`);
|
|
119
|
+
} else {
|
|
120
|
+
log(`Fingerprint (new): ${currentFp.slice(0, 16)}\n`);
|
|
121
|
+
}
|
|
122
|
+
log('Changes:\n');
|
|
123
|
+
log(renderChanges(report.changes));
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
exitCode: 1,
|
|
127
|
+
drifted: true,
|
|
128
|
+
changes: report.changes,
|
|
129
|
+
storedFingerprint: storedFp,
|
|
130
|
+
currentFingerprint: currentFp,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -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
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type SupportedLanguage,
|
|
41
41
|
type VRCommandSet,
|
|
42
42
|
} from '../detect/index.ts';
|
|
43
|
+
import { computeFingerprint } from '../detect/drift.ts';
|
|
43
44
|
|
|
44
45
|
const __filename = fileURLToPath(import.meta.url);
|
|
45
46
|
const __dirname = dirname(__filename);
|
|
@@ -430,6 +431,9 @@ export function buildConfigFromDetection(
|
|
|
430
431
|
config.verification = verification;
|
|
431
432
|
}
|
|
432
433
|
|
|
434
|
+
// P5-002: stamp a stack fingerprint so session-start can detect drift later.
|
|
435
|
+
config.detection = { fingerprint: computeFingerprint(detection) };
|
|
436
|
+
|
|
433
437
|
// Preserve legacy `python` block for v1 consumers (domain-enforcer, etc.).
|
|
434
438
|
// Per Phase 0 P1-009 (b): python legacy config coexists with languages.python.
|
|
435
439
|
if (languages.includes('python')) {
|
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
import { getMemoryDb, getSessionSummaries, getRecentObservations, getFailedAttempts, getCrossTaskProgress, autoDetectTaskId, linkSessionToTask, createSession } from '../memory-db.ts';
|
|
12
12
|
import { getConfig, getResolvedPaths } from '../config.ts';
|
|
13
13
|
import { readFileSync, existsSync } from 'fs';
|
|
14
|
-
import { join } from 'path';
|
|
14
|
+
import { join, resolve } from 'path';
|
|
15
|
+
import { parse as parseYaml } from 'yaml';
|
|
15
16
|
import type Database from 'better-sqlite3';
|
|
17
|
+
import { runDetection } from '../detect/index.ts';
|
|
18
|
+
import { computeFingerprint } from '../detect/drift.ts';
|
|
16
19
|
|
|
17
20
|
interface HookInput {
|
|
18
21
|
session_id: string;
|
|
@@ -63,6 +66,12 @@ async function main(): Promise<void> {
|
|
|
63
66
|
if (context.trim()) {
|
|
64
67
|
process.stdout.write(context);
|
|
65
68
|
}
|
|
69
|
+
|
|
70
|
+
// P5-001: drift banner (runs after memory context, independent of it).
|
|
71
|
+
const driftBanner = await buildDriftBanner();
|
|
72
|
+
if (driftBanner) {
|
|
73
|
+
process.stdout.write(driftBanner);
|
|
74
|
+
}
|
|
66
75
|
} finally {
|
|
67
76
|
db.close();
|
|
68
77
|
}
|
|
@@ -244,6 +253,38 @@ function readStdin(): Promise<string> {
|
|
|
244
253
|
});
|
|
245
254
|
}
|
|
246
255
|
|
|
256
|
+
/**
|
|
257
|
+
* P5-001: compare the fingerprint stored in massu.config.yaml (detection.fingerprint,
|
|
258
|
+
* stamped by init/refresh/upgrade) against a freshly-computed fingerprint. If they
|
|
259
|
+
* disagree, return a plain-text banner. Returns '' on any error or when the
|
|
260
|
+
* config has no fingerprint (back-compat with v1 configs).
|
|
261
|
+
*/
|
|
262
|
+
async function buildDriftBanner(): Promise<string> {
|
|
263
|
+
try {
|
|
264
|
+
const configPath = resolve(process.cwd(), 'massu.config.yaml');
|
|
265
|
+
if (!existsSync(configPath)) return '';
|
|
266
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
267
|
+
const parsed = parseYaml(content) as Record<string, unknown> | null;
|
|
268
|
+
if (!parsed || typeof parsed !== 'object') return '';
|
|
269
|
+
const det = parsed.detection as Record<string, unknown> | undefined;
|
|
270
|
+
const storedFp = typeof det?.fingerprint === 'string' ? (det.fingerprint as string) : null;
|
|
271
|
+
if (!storedFp) return '';
|
|
272
|
+
const detection = await runDetection(process.cwd());
|
|
273
|
+
const currentFp = computeFingerprint(detection);
|
|
274
|
+
if (currentFp === storedFp) return '';
|
|
275
|
+
return (
|
|
276
|
+
'=== Massu Config Drift ===\n' +
|
|
277
|
+
'Detected stack has changed since last config refresh.\n' +
|
|
278
|
+
`Fingerprint: ${storedFp.slice(0, 16)} -> ${currentFp.slice(0, 16)}\n` +
|
|
279
|
+
'Run: npx massu config refresh\n' +
|
|
280
|
+
'=== END ===\n'
|
|
281
|
+
);
|
|
282
|
+
} catch (_e) {
|
|
283
|
+
// Never block session start on drift-check failure.
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
247
288
|
function safeParseJson(json: string): Record<string, string> | null {
|
|
248
289
|
try {
|
|
249
290
|
return JSON.parse(json);
|