@massu/core 1.0.0 → 1.2.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 +913 -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 +327 -0
- package/src/commands/config-upgrade.ts +126 -0
- package/src/commands/init.ts +4 -0
- package/src/detect/migrate.ts +37 -4
- package/src/detect/passthrough.ts +108 -0
- package/src/hooks/session-start.ts +42 -1
|
@@ -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')) {
|
package/src/detect/migrate.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { DetectionResult, SupportedLanguage, VRCommandSet } from './index.ts';
|
|
25
|
+
import { copyUnknownKeys, preserveNestedSubkeys } from './passthrough.ts';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Shape accepted for input. We intentionally use `Record<string, unknown>`
|
|
@@ -190,6 +191,9 @@ export function migrateV1ToV2(
|
|
|
190
191
|
if (Object.keys(languageEntries).length > 0) {
|
|
191
192
|
framework.languages = languageEntries;
|
|
192
193
|
}
|
|
194
|
+
// P1-004: preserve any v1Framework subkey the explicit rebuild didn't emit
|
|
195
|
+
// (e.g., hedge's `framework.{python, rust, swift, typescript}` language sub-blocks).
|
|
196
|
+
preserveNestedSubkeys(v1Framework, framework);
|
|
193
197
|
|
|
194
198
|
// Paths: preserve user-set fields; fill `source` from detection if user had 'src' default.
|
|
195
199
|
let pathsSource: string = typeof v1Paths.source === 'string' ? v1Paths.source : 'src';
|
|
@@ -207,16 +211,24 @@ export function migrateV1ToV2(
|
|
|
207
211
|
for (const k of ['routers', 'routerRoot', 'pages', 'middleware', 'schema', 'components', 'hooks']) {
|
|
208
212
|
if (typeof v1Paths[k] === 'string') paths[k] = v1Paths[k];
|
|
209
213
|
}
|
|
214
|
+
// P1-005: preserve any v1Paths subkey the explicit rebuild didn't emit
|
|
215
|
+
// (e.g., hedge's 19 custom `paths.*` entries like adr, plans, monorepo_root).
|
|
216
|
+
preserveNestedSubkeys(v1Paths, paths);
|
|
210
217
|
|
|
211
218
|
const verification = buildVerificationBlock(detection, v1Verification);
|
|
212
219
|
|
|
220
|
+
// P1-006: build project block with nested passthrough so custom subkeys
|
|
221
|
+
// (e.g., hedge's `project.description`) survive the migration.
|
|
222
|
+
const project: Record<string, unknown> = {
|
|
223
|
+
name: typeof v1Project.name === 'string' ? v1Project.name : 'my-project',
|
|
224
|
+
root: typeof v1Project.root === 'string' ? v1Project.root : 'auto',
|
|
225
|
+
};
|
|
226
|
+
preserveNestedSubkeys(v1Project, project);
|
|
227
|
+
|
|
213
228
|
// Construct v2 output.
|
|
214
229
|
const v2: AnyConfig = {
|
|
215
230
|
schema_version: 2,
|
|
216
|
-
project
|
|
217
|
-
name: typeof v1Project.name === 'string' ? v1Project.name : 'my-project',
|
|
218
|
-
root: typeof v1Project.root === 'string' ? v1Project.root : 'auto',
|
|
219
|
-
},
|
|
231
|
+
project,
|
|
220
232
|
framework,
|
|
221
233
|
paths,
|
|
222
234
|
toolPrefix: typeof v1.toolPrefix === 'string' ? v1.toolPrefix : 'massu',
|
|
@@ -229,6 +241,24 @@ export function migrateV1ToV2(
|
|
|
229
241
|
}
|
|
230
242
|
}
|
|
231
243
|
|
|
244
|
+
// P1-001: preserve any v1 top-level key not already handled by the explicit
|
|
245
|
+
// migrator. This is the generalization of PRESERVED_FIELDS — custom sections
|
|
246
|
+
// like `services`, `workflow`, `north_stars` (hedge) now pass through.
|
|
247
|
+
//
|
|
248
|
+
// `detection` is intentionally NOT in handledTopLevel: when a v2 config is
|
|
249
|
+
// fed back in (idempotence check at migrate.ts:16), the existing `detection`
|
|
250
|
+
// block round-trips via this passthrough path. It gets re-stamped with a
|
|
251
|
+
// fresh fingerprint by the caller at config-upgrade.ts:96-99 right after
|
|
252
|
+
// migrateV1ToV2 returns. Any future v2-only top-level key added here must
|
|
253
|
+
// either appear in this list (with explicit handling above) or round-trip
|
|
254
|
+
// through this passthrough — never add a v2-only key that does neither.
|
|
255
|
+
// (A-006 architecture-review follow-up.)
|
|
256
|
+
const handledTopLevel = new Set<string>([
|
|
257
|
+
'schema_version', 'project', 'framework', 'paths', 'toolPrefix',
|
|
258
|
+
'verification', 'python', ...PRESERVED_FIELDS,
|
|
259
|
+
]);
|
|
260
|
+
copyUnknownKeys(v1, v2, handledTopLevel);
|
|
261
|
+
|
|
232
262
|
// Ensure domains / rules exist as arrays (v2 requires them).
|
|
233
263
|
if (!Array.isArray(v2.domains)) {
|
|
234
264
|
v2.domains = [];
|
|
@@ -268,6 +298,9 @@ export function migrateV1ToV2(
|
|
|
268
298
|
} else if (existing.orm !== undefined) {
|
|
269
299
|
pythonBlock.orm = existing.orm;
|
|
270
300
|
}
|
|
301
|
+
// P1-007: preserve any v1 python subkey not already handled above
|
|
302
|
+
// (e.g., `python.test_framework`, `python.database`).
|
|
303
|
+
preserveNestedSubkeys(v1.python, pythonBlock);
|
|
271
304
|
v2.python = pythonBlock;
|
|
272
305
|
} else if (v1.python !== undefined) {
|
|
273
306
|
// Preserve even if detection didn't find python (e.g. non-monorepo-with-python).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared passthrough helpers for config migration + refresh.
|
|
6
|
+
*
|
|
7
|
+
* These helpers exist to prevent the class of bug fixed in @massu/core@1.2.0
|
|
8
|
+
* (incident 2026-04-19-config-upgrade-data-loss): a hand-maintained allow-list
|
|
9
|
+
* in `migrate.ts` silently dropped any v1 top-level key not on the list, and
|
|
10
|
+
* the parallel rebuild blocks inside `framework` / `paths` / `project` /
|
|
11
|
+
* `python` did the same thing at the nested level.
|
|
12
|
+
*
|
|
13
|
+
* Both helpers are TARGET-WINS: the migrator writes the keys it actively owns,
|
|
14
|
+
* then the helper fills in everything else the user had. A user-authored value
|
|
15
|
+
* in `target` is NEVER overwritten by the source.
|
|
16
|
+
*
|
|
17
|
+
* Why two exports instead of one:
|
|
18
|
+
* - `copyUnknownKeys` takes an explicit `handledKeys` set — used for TOP-LEVEL
|
|
19
|
+
* passthrough where the caller enumerates the keys it migrated explicitly
|
|
20
|
+
* (e.g., schema_version, project, framework, paths, toolPrefix, …).
|
|
21
|
+
* - `preserveNestedSubkeys` takes no handled-set — used for NESTED passthrough
|
|
22
|
+
* where the target block was just rebuilt, so `k in target` already skips
|
|
23
|
+
* any key the rebuild populated. Splitting the two makes callsites
|
|
24
|
+
* self-documenting without a verbose `new Set()` argument at every nested
|
|
25
|
+
* call (A-002 architecture-review follow-up).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Keys that would mutate Object.prototype if copied as own properties. Explicit
|
|
29
|
+
* denylist defense-in-depth on top of the existing `k in target` guard and the
|
|
30
|
+
* `yaml@2.8` parser's non-polluting behavior (S-001 security-review follow-up). */
|
|
31
|
+
const UNSAFE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Copy any key from `source` into `target` that target doesn't already have set,
|
|
35
|
+
* skipping keys listed in `handledKeys`. Target values ALWAYS win — this function
|
|
36
|
+
* never overwrites an existing target key.
|
|
37
|
+
*
|
|
38
|
+
* - If source[k] is undefined → skip (undefined is not a preservable value).
|
|
39
|
+
* - If k is an UNSAFE_KEYS entry → skip (prototype-pollution defense).
|
|
40
|
+
* - If handledKeys.has(k) → skip (caller has its own handling).
|
|
41
|
+
* - If target already owns k → skip (target wins).
|
|
42
|
+
* - Otherwise → target[k] = deepClone(source[k]).
|
|
43
|
+
*
|
|
44
|
+
* Values are DEEP-CLONED via structuredClone so that mutating the output v2
|
|
45
|
+
* object never reaches back into the v1 input (S-002 security-review follow-up).
|
|
46
|
+
* Preserves the migrator's "pure data in, pure data out" contract.
|
|
47
|
+
*/
|
|
48
|
+
export function copyUnknownKeys(
|
|
49
|
+
source: Record<string, unknown>,
|
|
50
|
+
target: Record<string, unknown>,
|
|
51
|
+
handledKeys: ReadonlySet<string>
|
|
52
|
+
): void {
|
|
53
|
+
if (source === null || typeof source !== 'object' || Array.isArray(source)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const k of Object.keys(source)) {
|
|
57
|
+
if (UNSAFE_KEYS.has(k)) continue;
|
|
58
|
+
if (source[k] === undefined) continue;
|
|
59
|
+
if (handledKeys.has(k)) continue;
|
|
60
|
+
if (Object.prototype.hasOwnProperty.call(target, k)) continue;
|
|
61
|
+
target[k] = safeClone(source[k]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Preserve every subkey from sourceBlock into targetBlock that targetBlock
|
|
67
|
+
* doesn't already have. Target values ALWAYS win.
|
|
68
|
+
*
|
|
69
|
+
* If sourceBlock is not a plain object (string, array, null, undefined),
|
|
70
|
+
* return early — there are no subkeys to preserve. This matches the coercion
|
|
71
|
+
* semantics of `getRecord` in migrate.ts and prevents throws on loose v1 inputs
|
|
72
|
+
* like `framework: "typescript"`.
|
|
73
|
+
*
|
|
74
|
+
* Values are deep-cloned (see copyUnknownKeys); UNSAFE_KEYS are skipped.
|
|
75
|
+
*/
|
|
76
|
+
export function preserveNestedSubkeys(
|
|
77
|
+
sourceBlock: unknown,
|
|
78
|
+
targetBlock: Record<string, unknown>
|
|
79
|
+
): void {
|
|
80
|
+
if (
|
|
81
|
+
sourceBlock === null ||
|
|
82
|
+
sourceBlock === undefined ||
|
|
83
|
+
typeof sourceBlock !== 'object' ||
|
|
84
|
+
Array.isArray(sourceBlock)
|
|
85
|
+
) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const src = sourceBlock as Record<string, unknown>;
|
|
89
|
+
for (const k of Object.keys(src)) {
|
|
90
|
+
if (UNSAFE_KEYS.has(k)) continue;
|
|
91
|
+
if (src[k] === undefined) continue;
|
|
92
|
+
if (Object.prototype.hasOwnProperty.call(targetBlock, k)) continue;
|
|
93
|
+
targetBlock[k] = safeClone(src[k]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** structuredClone with a fallback for environments without it (Node <17). */
|
|
98
|
+
function safeClone<T>(v: T): T {
|
|
99
|
+
if (typeof structuredClone === 'function') {
|
|
100
|
+
try {
|
|
101
|
+
return structuredClone(v);
|
|
102
|
+
} catch {
|
|
103
|
+
// structuredClone throws on functions, DOM nodes, etc. — YAML never produces
|
|
104
|
+
// those, but if a caller passes something exotic, fall through to shallow.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return v;
|
|
108
|
+
}
|
|
@@ -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);
|