@mindrian_os/install 1.13.0-beta.14 → 1.13.0-beta.16

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mos",
3
3
  "description": "MindrianOS -- Your AI innovation co-founder. Larry thinks with you through PWS methodology, builds your Data Room as you explore, and chains frameworks intelligently. Install and go.",
4
- "version": "1.13.0-beta.14",
4
+ "version": "1.13.0-beta.16",
5
5
  "author": {
6
6
  "name": "Jonathan Sagir",
7
7
  "url": "https://mindrian.ai"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## [1.13.0-beta.16] - 2026-05-14
2
+
3
+ ### Added
4
+ -
5
+
1
6
  ## [1.13.0-beta.14] - 2026-05-14
2
7
 
3
8
  ### Added
@@ -31,10 +31,33 @@
31
31
  *
32
32
  * If installed_plugins.json is unreadable (ENOENT, parse error, or no
33
33
  * mos@mindrian-marketplace entry), the function returns
34
- * { kept: [], removed: [], skipped: true, reason: '...' }
34
+ * { kept: [], removed: [], removedBackups: [], skipped: true, reason: '...' }
35
35
  * with NO mutation. Never guesses; never deletes anything in the absence
36
36
  * of an authoritative active-version answer.
37
37
  *
38
+ * Phase 126 Plan-06 extension -- STALE BACKUP DIR PRUNING.
39
+ * In addition to the marketplace-cache version dirs, this function ALSO
40
+ * prunes Phase 95.2's atomic-swap backup directories at
41
+ * ~/.claude/plugins/mindrian-os.stale-<tag>-<timestamp>/
42
+ * that are older than MOS_CACHE_PRUNE_AGE_DAYS (default 30 days).
43
+ *
44
+ * Background: Phase 95.2 wraps `claude plugin update` in an atomic-swap
45
+ * recovery flow. Each recovery leaves a backup of the prior install at
46
+ * the sibling path above. By Phase 95.2 contract, backups are retained
47
+ * "indefinitely" (after 24h the user can delete manually). Long-running
48
+ * tester installs (Lawrence, Gary, etc) never see manual cleanup, so
49
+ * backups accumulate. 30 days is the next operational threshold beyond
50
+ * the 24h user-driven cleanup window.
51
+ *
52
+ * Pattern match is LITERAL prefix `mindrian-os.stale-` (regex
53
+ * /^mindrian-os\.stale-/). Does NOT match `mindrian-os/` (the live
54
+ * install) or unrelated siblings -- the period after `mindrian-os` is
55
+ * what gates the match.
56
+ *
57
+ * Window override: set process.env.MOS_CACHE_PRUNE_AGE_DAYS to any
58
+ * positive integer. Invalid values (non-numeric, negative) fall back
59
+ * to the 30-day default. v2 may move this to .mos/config.json.
60
+ *
38
61
  * Signature:
39
62
  * pruneMarketplaceCache({
40
63
  * home = os.homedir(),
@@ -42,14 +65,22 @@
42
65
  * retainCount = 2,
43
66
  * dryRun = false,
44
67
  * } = {}) => {
45
- * kept: string[], // version basenames kept on disk (incl. active)
46
- * removed: string[], // version basenames removed (or would be, in dryRun)
47
- * skipped: boolean, // true if we declined to act (see `reason`)
48
- * reason: string|null // human-readable explanation, or null on success
68
+ * kept: string[], // version basenames kept on disk (incl. active)
69
+ * removed: string[], // version basenames removed (or would be, in dryRun)
70
+ * removedBackups: string[], // absolute paths of stale backup dirs removed (Phase 126)
71
+ * skipped: boolean, // true if we declined the marketplace-cache prune
72
+ * reason: string|null, // human-readable explanation, or null on success
73
+ * ageDays: number, // the active stale-backup age window in days (Phase 126)
49
74
  * }
50
75
  *
76
+ * Backward compat: the original Phase 123 consumer at scripts/doctor.cjs
77
+ * line ~2100 reads r.removed.length + r.kept only; both unchanged here.
78
+ * `removedBackups` and `ageDays` are ADDITIVE fields.
79
+ *
51
80
  * Canon Part 8 sanity check (cp.6): the forbidden-network-token grep
52
- * exits 1 (no match) on this source file. Test cp.6 enforces it.
81
+ * exits 1 (no match) on this source file. Test cp.6 enforces it. The
82
+ * Phase 126 extension reads only local fs (stat + readdir + rmSync on
83
+ * scratch / on-disk backup dirs); zero network surface.
53
84
  */
54
85
 
55
86
  const fs = require('node:fs');
@@ -137,10 +168,20 @@ function pruneMarketplaceCache(opts) {
137
168
  const retainCount = (typeof o.retainCount === 'number' && o.retainCount >= 0) ? o.retainCount : 2;
138
169
  const dryRun = !!o.dryRun;
139
170
 
171
+ // Phase 126 Plan-06: stale-backup age window. Read once at the top so the
172
+ // returned `ageDays` field reflects the value that gated the prune.
173
+ // Env override is integer-only; invalid values fall back to the default.
174
+ const ageDaysRaw = process.env.MOS_CACHE_PRUNE_AGE_DAYS;
175
+ const ageDays = (typeof ageDaysRaw === 'string' && /^\d+$/.test(ageDaysRaw))
176
+ ? parseInt(ageDaysRaw, 10)
177
+ : 30;
178
+
140
179
  // Step 1: read active version. Skip entirely if unavailable -- never guess.
180
+ // (When skipped, we also decline the Phase-126 stale-backup pass, mirroring
181
+ // "no mutation in the absence of authoritative state".)
141
182
  const active = readActiveVersion(home);
142
183
  if (active.skipped) {
143
- return { kept: [], removed: [], skipped: true, reason: active.reason };
184
+ return { kept: [], removed: [], removedBackups: [], skipped: true, reason: active.reason, ageDays };
144
185
  }
145
186
  const activeVersion = active.activeVersion;
146
187
 
@@ -149,7 +190,9 @@ function pruneMarketplaceCache(opts) {
149
190
  if (names.length === 0) {
150
191
  // No cache dir to prune. The active version is still "kept" conceptually
151
192
  // (it lives in the cache when it exists; absent cache = nothing to do).
152
- return { kept: [activeVersion], removed: [], skipped: false, reason: 'no cache dir to prune' };
193
+ // Phase 126: still run the stale-backup pass below (returns at end).
194
+ const removedBackups = pruneStaleBackups(home, ageDays, dryRun);
195
+ return { kept: [activeVersion], removed: [], removedBackups, skipped: false, reason: 'no cache dir to prune', ageDays };
153
196
  }
154
197
 
155
198
  // Step 3: build the keep-set.
@@ -191,18 +234,81 @@ function pruneMarketplaceCache(opts) {
191
234
  }
192
235
  }
193
236
 
237
+ // Phase 126 Plan-06: stale-backup pass. ADDITIVE to the cache prune above;
238
+ // walks PLUGIN_HOME (sibling of cache/) for mindrian-os.stale-* dirs older
239
+ // than `ageDays`. Failures are swallowed per-entry (best-effort, mirrors the
240
+ // existing per-version rmSync error path).
241
+ const removedBackups = pruneStaleBackups(home, ageDays, dryRun);
242
+
194
243
  return {
195
244
  kept: Array.from(keep),
196
245
  removed,
246
+ removedBackups,
197
247
  skipped: false,
198
248
  reason: null,
249
+ ageDays,
199
250
  };
200
251
  }
201
252
 
253
+ // ---------------------------------------------------------------------------
254
+ // Phase 126 Plan-06 helper -- prune stale backup directories.
255
+ //
256
+ // Walks <home>/.claude/plugins/ for entries whose basename matches the
257
+ // LITERAL prefix /^mindrian-os\.stale-/ (the period after `mindrian-os`
258
+ // is what gates the match -- does NOT match `mindrian-os/` live install
259
+ // or unrelated siblings such as `mindrian-marketplace/`).
260
+ //
261
+ // Returns an array of absolute paths that were removed (or that would
262
+ // have been removed in dryRun mode). On error (unreadable dir, stat
263
+ // failure, rm failure), the entry is silently skipped -- best-effort
264
+ // semantics mirror the in-loop pattern of the cache-version prune above.
265
+ // ---------------------------------------------------------------------------
266
+ function pruneStaleBackups(home, ageDays, dryRun) {
267
+ const removedBackups = [];
268
+ const pluginsDir = path.join(home, '.claude', 'plugins');
269
+ const cutoffMs = Date.now() - (ageDays * 86400000);
270
+
271
+ let entries = [];
272
+ try {
273
+ entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
274
+ } catch (_) {
275
+ // pluginsDir absent: nothing to prune. Return empty list.
276
+ return removedBackups;
277
+ }
278
+
279
+ for (const e of entries) {
280
+ if (!e.isDirectory()) continue;
281
+ if (!/^mindrian-os\.stale-/.test(e.name)) continue;
282
+ const fullPath = path.join(pluginsDir, e.name);
283
+ let mtimeMs;
284
+ try {
285
+ mtimeMs = fs.statSync(fullPath).mtimeMs;
286
+ } catch (_) {
287
+ continue; // stat failure: leave for inspection
288
+ }
289
+ if (mtimeMs >= cutoffMs) continue; // recent enough; retain
290
+
291
+ if (dryRun) {
292
+ removedBackups.push(fullPath);
293
+ continue;
294
+ }
295
+ try {
296
+ fs.rmSync(fullPath, { recursive: true, force: true });
297
+ removedBackups.push(fullPath);
298
+ } catch (_) {
299
+ // Best-effort: a failed removal does not abort the whole prune.
300
+ // (Mirrors the existing pattern in the cache-version prune above.)
301
+ }
302
+ }
303
+
304
+ return removedBackups;
305
+ }
306
+
202
307
  module.exports = {
203
308
  pruneMarketplaceCache,
204
309
  // Internal helpers exposed for testability + future reuse.
205
310
  readActiveVersion,
206
311
  listCacheVersions,
207
312
  sortByMtimeDesc,
313
+ pruneStaleBackups,
208
314
  };
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * lib/core/install-state.cjs -- install-state.json read / write / migrate
5
+ * module.
6
+ *
7
+ * Extracts the inline session-start read+write+derive logic into a focused
8
+ * single-purpose module; adds the v1 -> v2 schema migration with additive-only
9
+ * semantics + future-version detection + atomic-write crash safety.
10
+ *
11
+ * Phase 126 Plan 07. Canon Part 6 (dog-fooding: schema evolution surfaces only
12
+ * via shipped harness -- v1 was shipped in v1.13.0-beta.13). Canon Part 7
13
+ * (reuse: extracts inline session-start write into a module; does NOT
14
+ * re-architect the write path).
15
+ *
16
+ * The wire shape evolves through a single integer sentinel (`schema_version`).
17
+ * Per CONTEXT.md D3 (LOCKED): integer not semver string (simpler comparison
18
+ * for additive-only migrations). Per Open Question 5 settlement: `===` integer
19
+ * equality is the comparison.
20
+ *
21
+ * The v1 -> v2 additive-only mapping (CONTEXT.md D3 + Plan 07 must_haves):
22
+ *
23
+ * v1 file (Phase 123, NO schema_version):
24
+ * {
25
+ * active_root, active_version, topology, installed_at,
26
+ * snapshot, surfaces?, resolved_at?, ...
27
+ * }
28
+ *
29
+ * v2 file (Phase 126):
30
+ * v1-fields-preserved-byte-identical +
31
+ * {
32
+ * schema_version: 2,
33
+ * topology_class: derived from topology (see deriveTopologyClass below),
34
+ * last_acceptance_run: null, // Plan 03 + Plan 05 fill this
35
+ * renderer_contract_version: 'unknown' // Plan 01 sets this
36
+ * }
37
+ *
38
+ * Future-version detection: schema_version > 2 -> warn on stderr + DEFER to
39
+ * /mos:doctor --fix. Do NOT downgrade. Do NOT touch the file. Return a typed
40
+ * sentinel so callers can decide.
41
+ *
42
+ * Atomic write: write to `<path>.tmp`, fsync, rename. A crash between write
43
+ * and rename leaves the original file untouched. The MOS_TEST_FORCE_FAIL=rename
44
+ * env-var hook mirrors scripts/doctor.cjs lines ~304-307 -- it injects a throw
45
+ * at the rename moment so the crash-recovery contract is testable.
46
+ *
47
+ * Canon Part 8: this module reads + writes LOCAL files only ($HOME/.mindrian/).
48
+ * Zero network calls. Zero Brain queries. Zero side-channel to remote services.
49
+ */
50
+
51
+ const fs = require('node:fs');
52
+ const path = require('node:path');
53
+
54
+ const SCHEMA_VERSION = 2;
55
+ const STATE_FILE_PATH_REL = path.join('.mindrian', 'install-state.json');
56
+
57
+ function statePath(home) {
58
+ return path.join(home, STATE_FILE_PATH_REL);
59
+ }
60
+
61
+ /**
62
+ * readInstallState({ home }) -> object | null
63
+ *
64
+ * Returns the parsed JSON object, or null when the file is absent OR
65
+ * unparseable. Never throws -- the goal is a robust read for downstream
66
+ * consumers (doctor, session-start, the migrator itself). A corrupt file is
67
+ * surfaced as null so the caller can decide whether to derive-from-scratch
68
+ * (session-start's existing behavior) or surface a finding (doctor class I).
69
+ */
70
+ function readInstallState(opts) {
71
+ const home = (opts && opts.home) || '';
72
+ if (!home) return null;
73
+ const p = statePath(home);
74
+ if (!fs.existsSync(p)) return null;
75
+ try {
76
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
77
+ } catch (_) {
78
+ // Corrupt file -> null (mirrors doctor.cjs's class-I "absent" finding).
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * writeInstallState({ home, state }) -> void
85
+ *
86
+ * Atomic write: $HOME/.mindrian/install-state.json.tmp -> fsync -> rename.
87
+ *
88
+ * The MOS_TEST_FORCE_FAIL=rename env injection mirrors the doctor.cjs
89
+ * pattern -- if set, throws AFTER the .tmp write but BEFORE the rename so
90
+ * tests can verify the original file is left untouched.
91
+ *
92
+ * Test-only side note: when MOS_TEST_FORCE_FAIL=rename, the .tmp file is left
93
+ * on disk; the rename never happens; the original target is untouched. A
94
+ * subsequent successful call will overwrite the .tmp via openSync('w') so
95
+ * stale .tmp files are not a long-term hazard.
96
+ */
97
+ function writeInstallState(opts) {
98
+ const home = opts.home;
99
+ const state = opts.state;
100
+ const p = statePath(home);
101
+ const tmp = p + '.tmp';
102
+ // Ensure parent dir exists. Idempotent.
103
+ fs.mkdirSync(path.dirname(p), { recursive: true });
104
+ // Atomic write: write to tmp, fsync (best-effort), rename.
105
+ const fd = fs.openSync(tmp, 'w');
106
+ try {
107
+ fs.writeSync(fd, JSON.stringify(state, null, 2) + '\n');
108
+ try { fs.fsyncSync(fd); } catch (_) { /* fsync best-effort -- some FS lack support */ }
109
+ } finally {
110
+ fs.closeSync(fd);
111
+ }
112
+ // Test injection point (mirrors scripts/doctor.cjs MOS_TEST_FORCE_FAIL).
113
+ // Throw AFTER the .tmp write so the original target is byte-identical.
114
+ if (process.env.MOS_TEST_FORCE_FAIL === 'rename') {
115
+ throw new Error('MOS_TEST_FORCE_FAIL=rename injection (test-only)');
116
+ }
117
+ fs.renameSync(tmp, p);
118
+ }
119
+
120
+ /**
121
+ * deriveTopologyClass(topology) -> 'healthy' | 'missing' | 'drifted'
122
+ *
123
+ * Canonical 4-state taxonomy from CONTEXT.md D3 + Plan 03 needs.
124
+ *
125
+ * Mapping (see PLAN.md Task 2 behavior block):
126
+ * marketplace-cache -> healthy (Claude Code's standard install path)
127
+ * direct -> healthy (npx round-trip install -- @mindrian_os/install)
128
+ * dev-clone -> healthy (developer machine -- a clone with a remote)
129
+ * not-found -> missing (resolver returned null root)
130
+ * legacy -> drifted (the legacy hand-clone path -- pre-Phase 123)
131
+ * <unknown> -> drifted (conservative default for any new topology
132
+ * introduced after this module's release)
133
+ */
134
+ function deriveTopologyClass(topology) {
135
+ switch (topology) {
136
+ case 'marketplace-cache': return 'healthy';
137
+ case 'direct': return 'healthy';
138
+ case 'dev-clone': return 'healthy';
139
+ case 'not-found': return 'missing';
140
+ case 'legacy': return 'drifted';
141
+ default: return 'drifted';
142
+ }
143
+ }
144
+
145
+ /**
146
+ * migrateIfNeeded({ home }) -> {
147
+ * migrated: boolean,
148
+ * // present in v1 -> v2 path:
149
+ * fromVersion?: 1, toVersion?: 2,
150
+ * // present in v2 path:
151
+ * currentVersion?: 2,
152
+ * // present in future-version path:
153
+ * futureVersion?: true, currentVersion?: number, advice?: string,
154
+ * // present in no-file path:
155
+ * fileAbsent?: true,
156
+ * }
157
+ *
158
+ * Behavior matrix:
159
+ *
160
+ * File absent
161
+ * -> { migrated:false, fileAbsent:true }
162
+ * -> Do NOT create the file (creation is session-start's job).
163
+ *
164
+ * File present + no schema_version (or schema_version absent/null)
165
+ * -> Treat as v1. Run additive migration. Write back. Return
166
+ * { migrated:true, fromVersion:1, toVersion:2 }.
167
+ *
168
+ * File present + schema_version === 2
169
+ * -> No-op. Return { migrated:false, currentVersion:2 }.
170
+ *
171
+ * File present + schema_version > 2 (a future-version file)
172
+ * -> Emit stderr warn. Do NOT touch the file. Return
173
+ * { migrated:false, futureVersion:true, currentVersion:<n>,
174
+ * advice:'run /mos:doctor --fix' }.
175
+ *
176
+ * File present + schema_version < 2 (unexpected lower-than-v1, e.g. 0)
177
+ * -> Treat as v1 (coerce). Same outcome as the v1 path.
178
+ *
179
+ * File present + corrupt JSON
180
+ * -> readInstallState returned null -> indistinguishable from absent at
181
+ * this layer -> { migrated:false, fileAbsent:true }. doctor class I
182
+ * is the surface that catches corrupt-but-present and offers --fix.
183
+ */
184
+ function migrateIfNeeded(opts) {
185
+ const home = opts.home;
186
+ const state = readInstallState({ home });
187
+ if (!state) return { migrated: false, fileAbsent: true };
188
+
189
+ const sv = state.schema_version;
190
+ if (typeof sv === 'undefined' || sv === null) {
191
+ // v1 (or unmarked) -> v2 additive migration.
192
+ return _runV1ToV2({ home, state });
193
+ }
194
+ if (sv === SCHEMA_VERSION) {
195
+ return { migrated: false, currentVersion: SCHEMA_VERSION };
196
+ }
197
+ if (typeof sv === 'number' && sv > SCHEMA_VERSION) {
198
+ // Future-version: warn + defer. Do NOT downgrade. Do NOT touch the file.
199
+ process.stderr.write(
200
+ '[install-state] schema_version ' + sv + ' is newer than the plugin understands (' +
201
+ SCHEMA_VERSION + '). Skipping migration; run /mos:doctor --fix.\n'
202
+ );
203
+ return {
204
+ migrated: false,
205
+ futureVersion: true,
206
+ currentVersion: sv,
207
+ advice: 'run /mos:doctor --fix',
208
+ };
209
+ }
210
+ // Unexpected lower-than-v1 (e.g. schema_version: 0 or a non-number) -- coerce
211
+ // to v1 and run the additive migration. This is a safety net; not expected
212
+ // to fire in practice.
213
+ return _runV1ToV2({ home, state });
214
+ }
215
+
216
+ // Internal: perform the v1 -> v2 additive migration + write.
217
+ function _runV1ToV2(opts) {
218
+ const home = opts.home;
219
+ const state = opts.state;
220
+ // Object.assign preserves v1 fields byte-identically; new fields are
221
+ // appended last (since v2's schema_version is the sentinel, the order
222
+ // could be reshuffled but additive-on-top is the simplest invariant).
223
+ const v2 = Object.assign({}, state, {
224
+ schema_version: SCHEMA_VERSION,
225
+ topology_class: deriveTopologyClass(state.topology),
226
+ last_acceptance_run: null,
227
+ renderer_contract_version: 'unknown',
228
+ });
229
+ writeInstallState({ home, state: v2 });
230
+ return { migrated: true, fromVersion: 1, toVersion: SCHEMA_VERSION };
231
+ }
232
+
233
+ module.exports = {
234
+ SCHEMA_VERSION,
235
+ readInstallState,
236
+ writeInstallState,
237
+ migrateIfNeeded,
238
+ deriveTopologyClass,
239
+ // Test-only helpers (intentionally exported with leading underscore so
240
+ // consumers do not depend on them and grep can spot test surface usage).
241
+ _statePath: statePath,
242
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindrian_os/install",
3
- "version": "1.13.0-beta.14",
3
+ "version": "1.13.0-beta.16",
4
4
  "description": "Install MindrianOS into Claude Code with one command -- `npx @mindrian_os/install`. Ships the MindrianOS plugin (Larry + PWS methodology + Data Room) plus a setup/diagnostics CLI (install/doctor/update).",
5
5
  "scripts": {
6
6
  "mcp": "node bin/mindrian-mcp-server.cjs",