@wipcomputer/wip-ldm-os 0.4.85-alpha.29 → 0.4.85-alpha.30

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/SKILL.md CHANGED
@@ -77,6 +77,8 @@ First-time CLI bootstrap commands use the same selected track:
77
77
  - beta/latest beta: `npm install -g @wipcomputer/wip-ldm-os@beta`
78
78
  - alpha/latest alpha: `npm install -g @wipcomputer/wip-ldm-os@alpha`
79
79
 
80
+ The `ldm install --<track>` command self-updates the LDM CLI to the matching npm dist-tag before running the install. **Do not run `npm install -g @wipcomputer/wip-ldm-os@latest` ahead of an alpha or beta install:** `@latest` resolves to the stable dist-tag and will downgrade an alpha-pinned CLI to stable.
81
+
80
82
  If the user already named a track, do not force a generic chooser. Show the exact package, available version, track, and command you will run. Then wait for dry-run or install consent as appropriate.
81
83
 
82
84
  If the user has not named a track, show what is installed and what is available, then ask which track they want to dry run or install.
@@ -276,6 +278,11 @@ Tell the user, scaled to the track they're on:
276
278
  - **beta**: stabilization candidate. Same shape as alpha but feature-frozen for the cut.
277
279
  - **stable**: production. The user should be on this unless they've asked otherwise.
278
280
 
281
+ Roadmap caveats that apply to every track right now:
282
+
283
+ - Registry source-type migration is mid-flight. After Phase 2 ships, `ldm status` will categorize every extension by source type (`npm` / `git` / `bundled` / `private`). Until then, some entries appear under "Untracked extensions" with a `ldm doctor --reclassify-sources` remediation pointer.
284
+ - LDM OS is the canonical pattern source for child packages (Codex Remote Control, future tools). Install-prompt structure changes here propagate downstream; child packages should not lead the parent.
285
+
279
286
  ## Reference files
280
287
 
281
288
  For detailed information, read these on demand (not on every activation):
package/bin/ldm.js CHANGED
@@ -1807,6 +1807,7 @@ async function migrateLegacyNpmSources({ dryRun = false } = {}) {
1807
1807
  planLegacyNpmSourcesMigration,
1808
1808
  summaryHasChanges,
1809
1809
  npmPackageExists,
1810
+ executeDirectoryMoves,
1810
1811
  } = await import('../lib/registry-migrations.mjs');
1811
1812
 
1812
1813
  const registry = readJSON(REGISTRY_PATH);
@@ -1836,6 +1837,20 @@ async function migrateLegacyNpmSources({ dryRun = false } = {}) {
1836
1837
  copyFileSync(REGISTRY_PATH, backupPath);
1837
1838
  summary.backupPath = backupPath;
1838
1839
  writeJSON(REGISTRY_PATH, newRegistry);
1840
+
1841
+ // Execute directory moves AFTER the registry write. Each move sends a
1842
+ // deduplicated extension's on-disk directory to ~/.ldm/_trash/<name>-
1843
+ // deduplicated-<timestamp> so autoDetectExtensions (which only scans
1844
+ // ~/.ldm/extensions/) cannot find it on the next install pass.
1845
+ // Without this, the registry dedup reverts within the same install run.
1846
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md
1847
+ const { performed, skipped } = executeDirectoryMoves({
1848
+ directoryMoves: summary.directoryMoves,
1849
+ extensionsRoot: LDM_EXTENSIONS,
1850
+ trashRoot: join(LDM_ROOT, '_trash'),
1851
+ });
1852
+ summary.directoryMovesPerformed.push(...performed);
1853
+ summary.directoryMovesSkipped.push(...skipped);
1839
1854
  }
1840
1855
 
1841
1856
  return summary;
@@ -1879,6 +1894,29 @@ function printLegacyNpmSourcesSummary(summary, { dryRun = false } = {}) {
1879
1894
  console.log(` ${d.removed} (canonical: ${d.keep})`);
1880
1895
  }
1881
1896
  }
1897
+ if (summary.directoryMoves && summary.directoryMoves.length > 0) {
1898
+ if (dryRun) {
1899
+ console.log(` + Would move ${summary.directoryMoves.length} duplicate director${summary.directoryMoves.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (so autoDetectExtensions cannot re-register):`);
1900
+ for (const m of summary.directoryMoves) {
1901
+ console.log(` ~/.ldm/extensions/${m.name} -> ~/.ldm/_trash/${m.trashName}`);
1902
+ }
1903
+ } else {
1904
+ const performed = summary.directoryMovesPerformed || [];
1905
+ const skipped = summary.directoryMovesSkipped || [];
1906
+ if (performed.length > 0) {
1907
+ console.log(` + Moved ${performed.length} duplicate director${performed.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (autoDetectExtensions cannot re-register):`);
1908
+ for (const m of performed) {
1909
+ console.log(` ${m.name} -> ${m.destPath.replace(HOME, '~')}`);
1910
+ }
1911
+ }
1912
+ if (skipped.length > 0) {
1913
+ console.log(` ! ${skipped.length} duplicate director${skipped.length === 1 ? 'y' : 'ies'} could not be moved (registry entries are removed; autoDetect may re-add on next install):`);
1914
+ for (const m of skipped) {
1915
+ console.log(` ${m.name} (${m.reason})`);
1916
+ }
1917
+ }
1918
+ }
1919
+ }
1882
1920
  if (summary.probeFailures && summary.probeFailures.length > 0) {
1883
1921
  console.log(` ! ${summary.probeFailures.length} npm probe${summary.probeFailures.length === 1 ? '' : 's'} could not complete (will retry on next install):`);
1884
1922
  for (const f of summary.probeFailures) {
@@ -4,9 +4,18 @@
4
4
  // Called by bin/ldm.js during `ldm install`. Idempotent: entries that already
5
5
  // carry `updateSource` are skipped.
6
6
  //
7
+ // `planLegacyNpmSourcesMigration` is pure (no filesystem I/O). The companion
8
+ // `executeDirectoryMoves` IS side-effecting; the planner emits a list of
9
+ // directory-move plans and the executor performs them. The split lets tests
10
+ // drive the planner with in-memory fixtures and exercise the executor against
11
+ // a real temp filesystem.
12
+ //
7
13
  // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
8
14
  // and the parent design ai/product/bugs/installer/2026-05-13--cc-mini--installer-registry-source-types-architecture.md
9
15
 
16
+ import { existsSync, mkdirSync, renameSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
10
19
  const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
11
20
 
12
21
  // Phase 1 expedient. The known duplicate pairs surfaced on Parker's machine
@@ -31,6 +40,16 @@ export function emptyLegacyNpmSourcesSummary() {
31
40
  migrated: [],
32
41
  phantomsRemoved: [],
33
42
  duplicatesRemoved: [],
43
+ // Directory moves to perform AFTER the registry write. The planner
44
+ // emits these as a parallel list to duplicatesRemoved (1:1); the
45
+ // wrapper in bin/ldm.js executes them. See
46
+ // ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md
47
+ // for the bug fix: without moving the on-disk directory out of
48
+ // ~/.ldm/extensions/, autoDetectExtensions re-registers the duplicate
49
+ // on the same install run and the dedup never persists.
50
+ directoryMoves: [],
51
+ directoryMovesPerformed: [],
52
+ directoryMovesSkipped: [],
34
53
  probedCount: 0,
35
54
  probeFailures: [],
36
55
  timestamp: new Date().toISOString(),
@@ -96,9 +115,20 @@ export async function planLegacyNpmSourcesMigration({
96
115
 
97
116
  // Step 2: dedupe known pairs. Pure structural fix; the canonical row stays
98
117
  // untouched. Future drift is the hygiene-audit ticket's job, not this one.
118
+ //
119
+ // Each removed duplicate also emits a directoryMoves entry: the wrapper
120
+ // moves ~/.ldm/extensions/<remove> to ~/.ldm/_trash/<remove>-deduplicated-<ts>
121
+ // after the registry write so autoDetectExtensions cannot re-register
122
+ // the duplicate on the same install run.
123
+ const trashStamp = summary.timestamp.replace(/[:.]/g, '-');
99
124
  for (const { keep, remove } of KNOWN_DUPLICATE_PAIRS) {
100
125
  if (newRegistry.extensions[keep] && newRegistry.extensions[remove]) {
101
126
  summary.duplicatesRemoved.push({ keep, removed: remove });
127
+ summary.directoryMoves.push({
128
+ name: remove,
129
+ reason: 'deduplicated',
130
+ trashName: `${remove}-deduplicated-${trashStamp}`,
131
+ });
102
132
  delete newRegistry.extensions[remove];
103
133
  }
104
134
  }
@@ -159,6 +189,58 @@ export async function planLegacyNpmSourcesMigration({
159
189
  return { newRegistry, summary };
160
190
  }
161
191
 
192
+ // Execute the directory-move plans emitted by planLegacyNpmSourcesMigration.
193
+ // Side-effecting: moves directories from `extensionsRoot/<name>` to
194
+ // `trashRoot/<trashName>`. Creates `trashRoot` if needed. Skips moves whose
195
+ // source is missing or whose rename fails. Idempotent: a second call with the
196
+ // same plan returns all-skipped (source-missing) once the moves are done.
197
+ //
198
+ // Returns `{ performed: [...], skipped: [...] }` for the caller to append to
199
+ // the migration summary. Each performed entry gains a `destPath`; each skipped
200
+ // entry gains a `reason`.
201
+ //
202
+ // The contract is split from the planner so:
203
+ // - the planner stays pure and trivially testable with in-memory fixtures,
204
+ // - the executor can be exercised against a real temp filesystem in a test
205
+ // fixture without spawning a full `ldm install` run,
206
+ // - tests can inject `fs` primitives via the optional `fs` option for
207
+ // paranoid scenarios.
208
+ export function executeDirectoryMoves({
209
+ directoryMoves,
210
+ extensionsRoot,
211
+ trashRoot,
212
+ fs,
213
+ }) {
214
+ const _existsSync = fs?.existsSync || existsSync;
215
+ const _mkdirSync = fs?.mkdirSync || mkdirSync;
216
+ const _renameSync = fs?.renameSync || renameSync;
217
+
218
+ const performed = [];
219
+ const skipped = [];
220
+ if (!directoryMoves || directoryMoves.length === 0) {
221
+ return { performed, skipped };
222
+ }
223
+
224
+ _mkdirSync(trashRoot, { recursive: true });
225
+
226
+ for (const move of directoryMoves) {
227
+ const src = join(extensionsRoot, move.name);
228
+ const dest = join(trashRoot, move.trashName);
229
+ if (!_existsSync(src)) {
230
+ skipped.push({ ...move, reason: 'source-missing' });
231
+ continue;
232
+ }
233
+ try {
234
+ _renameSync(src, dest);
235
+ performed.push({ ...move, destPath: dest });
236
+ } catch (err) {
237
+ skipped.push({ ...move, reason: `rename-failed: ${err.message}` });
238
+ }
239
+ }
240
+
241
+ return { performed, skipped };
242
+ }
243
+
162
244
  // Real npm-registry probe. Returns true/false/null per the planner contract.
163
245
  // Mirrors the fetch pattern used by npmViewVersionForStatus in bin/ldm.js.
164
246
  export async function npmPackageExists(pkgName, opts = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.85-alpha.29",
3
+ "version": "0.4.85-alpha.30",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -3,10 +3,15 @@
3
3
  // See lib/registry-migrations.mjs and
4
4
  // ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
5
5
 
6
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
6
10
  import {
7
11
  planLegacyNpmSourcesMigration,
8
12
  summaryHasChanges,
9
13
  emptyLegacyNpmSourcesSummary,
14
+ executeDirectoryMoves,
10
15
  } from '../lib/registry-migrations.mjs';
11
16
 
12
17
  function fail(msg) {
@@ -181,6 +186,29 @@ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
181
186
  if (!newRegistry.extensions['cc-session-export']) fail('canonical cc-session-export removed');
182
187
  if (!newRegistry.extensions['wip-branch-guard']) fail('canonical wip-branch-guard removed');
183
188
 
189
+ // Each removed duplicate must also produce a directoryMoves entry. The
190
+ // wrapper in bin/ldm.js executes these moves after the registry write so
191
+ // autoDetectExtensions cannot re-register the duplicate on the same install
192
+ // run. Without this contract, the registry dedup reverts (see
193
+ // ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
194
+ assertEqual(summary.directoryMoves.length, 2, 'directoryMoves.length');
195
+ const moveNames = summary.directoryMoves.map(m => m.name).sort();
196
+ assertDeepEqual(moveNames, ['package', 'session-export'], 'directoryMoves names');
197
+ for (const m of summary.directoryMoves) {
198
+ assertEqual(m.reason, 'deduplicated', `directoryMoves[${m.name}].reason`);
199
+ if (!m.trashName.startsWith(`${m.name}-deduplicated-`)) {
200
+ fail(`directoryMoves[${m.name}].trashName should start with "${m.name}-deduplicated-" but was "${m.trashName}"`);
201
+ }
202
+ if (!m.trashName.includes('2026-05-13T18-00-00-000Z')) {
203
+ fail(`directoryMoves[${m.name}].trashName should embed the fixed-now timestamp but was "${m.trashName}"`);
204
+ }
205
+ }
206
+
207
+ // The planner is pure: it must NOT pre-populate directoryMovesPerformed or
208
+ // directoryMovesSkipped. Those are wrapper outputs from real filesystem I/O.
209
+ assertEqual(summary.directoryMovesPerformed.length, 0, 'directoryMovesPerformed should be empty from pure planner');
210
+ assertEqual(summary.directoryMovesSkipped.length, 0, 'directoryMovesSkipped should be empty from pure planner');
211
+
184
212
  // Migrated entries: cc-session-export, compaction-indicator, lesa-bridge,
185
213
  // run, plus the two custom-path entries (custom-path-untracked,
186
214
  // legacy-custom-path). `session-export` and `package` were deduped before
@@ -275,6 +303,7 @@ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
275
303
  assertEqual(summary.migrated.length, 0, 'migrated.length on idempotent run');
276
304
  assertEqual(summary.phantomsRemoved.length, 0, 'phantomsRemoved.length on idempotent run');
277
305
  assertEqual(summary.duplicatesRemoved.length, 0, 'duplicatesRemoved.length on idempotent run');
306
+ assertEqual(summary.directoryMoves.length, 0, 'directoryMoves.length on idempotent run');
278
307
  assertEqual(summaryHasChanges(summary), false, 'summaryHasChanges on empty summary');
279
308
  assertDeepEqual(
280
309
  Object.keys(newRegistry.extensions).sort(),
@@ -287,6 +316,9 @@ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
287
316
  {
288
317
  const e = emptyLegacyNpmSourcesSummary();
289
318
  assertDeepEqual(Object.keys(e).sort(), [
319
+ 'directoryMoves',
320
+ 'directoryMovesPerformed',
321
+ 'directoryMovesSkipped',
290
322
  'duplicatesRemoved',
291
323
  'migrated',
292
324
  'phantomsRemoved',
@@ -294,6 +326,135 @@ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
294
326
  'probedCount',
295
327
  'timestamp',
296
328
  ], 'empty summary keys');
329
+ // Wrapper-output fields start empty even though the planner doesn't
330
+ // populate them ... the wrapper is responsible for appending.
331
+ assertEqual(e.directoryMoves.length, 0, 'empty.directoryMoves');
332
+ assertEqual(e.directoryMovesPerformed.length, 0, 'empty.directoryMovesPerformed');
333
+ assertEqual(e.directoryMovesSkipped.length, 0, 'empty.directoryMovesSkipped');
334
+ }
335
+
336
+ // ── Test 4: executeDirectoryMoves against a real temp filesystem ──────────
337
+ // Regression guard for the bug fixed by this PR
338
+ // (ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
339
+ // The planner emits directoryMoves entries; the executor must actually move
340
+ // the on-disk directories into trash so autoDetectExtensions cannot
341
+ // re-register them on the next install scan.
342
+ {
343
+ const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-'));
344
+ try {
345
+ const extensionsRoot = join(tmpHome, 'extensions');
346
+ const trashRoot = join(tmpHome, '_trash');
347
+ mkdirSync(extensionsRoot, { recursive: true });
348
+
349
+ // Stand up the two duplicate directories the planner would dedup.
350
+ for (const name of ['session-export', 'package']) {
351
+ const dir = join(extensionsRoot, name);
352
+ mkdirSync(dir, { recursive: true });
353
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: '1.0.0' }) + '\n');
354
+ }
355
+ // And a non-duplicate that must be left alone (proxy for cc-session-export).
356
+ const ccsePath = join(extensionsRoot, 'cc-session-export');
357
+ mkdirSync(ccsePath, { recursive: true });
358
+ writeFileSync(join(ccsePath, 'package.json'), JSON.stringify({ name: 'cc-session-export', version: '1.0.0' }) + '\n');
359
+
360
+ // Run the planner with the fixture to get a real directoryMoves plan.
361
+ const registry = {
362
+ extensions: {
363
+ 'cc-session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
364
+ 'session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
365
+ 'wip-branch-guard': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
366
+ 'package': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
367
+ },
368
+ };
369
+ const { summary } = await planLegacyNpmSourcesMigration({
370
+ registry,
371
+ probeNpm: (name) => Promise.resolve(name === '@wipcomputer/wip-branch-guard'),
372
+ extensionExists: () => true,
373
+ now: FIXED_NOW,
374
+ });
375
+ assertEqual(summary.directoryMoves.length, 2, 'dedup plan produces 2 directoryMoves');
376
+
377
+ // Execute the moves against the temp filesystem.
378
+ const { performed, skipped } = executeDirectoryMoves({
379
+ directoryMoves: summary.directoryMoves,
380
+ extensionsRoot,
381
+ trashRoot,
382
+ });
383
+ assertEqual(performed.length, 2, 'executor performed 2 moves');
384
+ assertEqual(skipped.length, 0, 'executor did not skip any moves');
385
+
386
+ // Source directories are gone.
387
+ if (existsSync(join(extensionsRoot, 'session-export'))) {
388
+ fail('session-export directory should have been moved out of extensions/');
389
+ }
390
+ if (existsSync(join(extensionsRoot, 'package'))) {
391
+ fail('package directory should have been moved out of extensions/');
392
+ }
393
+
394
+ // Trash directory now has the moved entries with the deduplicated suffix.
395
+ const trashContents = readdirSync(trashRoot);
396
+ if (!trashContents.some(name => name.startsWith('session-export-deduplicated-'))) {
397
+ fail(`trash should contain session-export-deduplicated-* but had: ${trashContents.join(', ')}`);
398
+ }
399
+ if (!trashContents.some(name => name.startsWith('package-deduplicated-'))) {
400
+ fail(`trash should contain package-deduplicated-* but had: ${trashContents.join(', ')}`);
401
+ }
402
+
403
+ // Non-duplicate must NOT have been touched.
404
+ if (!existsSync(ccsePath)) fail('cc-session-export directory should be untouched');
405
+
406
+ // autoDetectExtensions simulation: a fresh scan of extensionsRoot must
407
+ // NOT see the moved duplicates. We replicate the production logic
408
+ // (bin/ldm.js autoDetectExtensions): scan top-level dirs in
409
+ // extensionsRoot, skip dirs named `_trash` or starting with `.` or
410
+ // `ldm-install-`, and treat any remaining dir with a package.json as a
411
+ // candidate for auto-registration.
412
+ const candidatesAfterMove = readdirSync(extensionsRoot, { withFileTypes: true })
413
+ .filter(d => d.isDirectory())
414
+ .map(d => d.name)
415
+ .filter(name => name !== '_trash' && !name.startsWith('.') && !name.startsWith('ldm-install-'))
416
+ .filter(name => existsSync(join(extensionsRoot, name, 'package.json')))
417
+ .sort();
418
+ assertDeepEqual(
419
+ candidatesAfterMove,
420
+ ['cc-session-export'],
421
+ 'autoDetect should see only the non-duplicate after the moves; duplicates must be gone',
422
+ );
423
+
424
+ // Idempotency: running executeDirectoryMoves again must skip all
425
+ // (source-missing) and not fail.
426
+ const second = executeDirectoryMoves({
427
+ directoryMoves: summary.directoryMoves,
428
+ extensionsRoot,
429
+ trashRoot,
430
+ });
431
+ assertEqual(second.performed.length, 0, 'second execute call performs nothing');
432
+ assertEqual(second.skipped.length, 2, 'second execute call skips both moves');
433
+ for (const s of second.skipped) {
434
+ assertEqual(s.reason, 'source-missing', `second-call skip reason for ${s.name}`);
435
+ }
436
+ } finally {
437
+ rmSync(tmpHome, { recursive: true, force: true });
438
+ }
439
+ }
440
+
441
+ // ── Test 5: executeDirectoryMoves with no moves is a no-op ───────────────
442
+ {
443
+ const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-empty-'));
444
+ try {
445
+ const result = executeDirectoryMoves({
446
+ directoryMoves: [],
447
+ extensionsRoot: join(tmpHome, 'extensions'),
448
+ trashRoot: join(tmpHome, '_trash'),
449
+ });
450
+ assertEqual(result.performed.length, 0, 'empty plan -> no performed');
451
+ assertEqual(result.skipped.length, 0, 'empty plan -> no skipped');
452
+ if (existsSync(join(tmpHome, '_trash'))) {
453
+ fail('executor should not pre-create trashRoot when there are no moves');
454
+ }
455
+ } finally {
456
+ rmSync(tmpHome, { recursive: true, force: true });
457
+ }
297
458
  }
298
459
 
299
460
  console.log('test-legacy-npm-sources-migration: all tests passed');