@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.
@@ -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
+ }
@@ -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')) {
@@ -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);