@wipcomputer/wip-ldm-os 0.2.2 → 0.2.3

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/README.md CHANGED
@@ -23,16 +23,23 @@ Open your AI and paste this:
23
23
  Read the SKILL.md at github.com/wipcomputer/wip-ldm-os/blob/main/SKILL.md.
24
24
 
25
25
  Then explain to me:
26
- 1. What is LDM OS?
27
- 2. What does it do?
28
- 3. What would it change about how my AI works?
26
+ 1. What are these tools?
27
+ 2. What do they do?
28
+ 3. What would change about how we work together?
29
+ 4. What would it change about how all my AI work?
30
+
31
+ Then ask me:
32
+ - Do you have more questions?
33
+ - Do you want to install it?
29
34
 
30
35
  If I say yes, run: ldm install --dry-run
31
36
 
32
- Show me exactly what will change. When I'm ready, install for real.
37
+ And show me exactly what will change on my system.
38
+
39
+ When I'm ready, I'll tell you to install for real.
33
40
  ```
34
41
 
35
- That's it. Your AI reads the spec, explains what LDM OS is, and walks you through a dry run before touching anything.
42
+ That's it. Your AI reads the spec, explains what it does, and walks you through a dry run before touching anything.
36
43
 
37
44
  ## Included Skills
38
45
 
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ license: MIT
5
5
  interface: [cli, skill]
6
6
  metadata:
7
7
  display-name: "LDM OS"
8
- version: "0.2.2"
8
+ version: "0.2.3"
9
9
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
10
10
  author: "Parker Todd Brooks"
11
11
  category: infrastructure
package/bin/ldm.mjs CHANGED
@@ -399,49 +399,82 @@ function autoDetectExtensions() {
399
399
  return found;
400
400
  }
401
401
 
402
- // ── ldm install (bare): show catalog + update registered ──
402
+ // ── ldm install (bare): scan system, show real state, update if needed ──
403
403
 
404
404
  async function cmdInstallCatalog() {
405
405
  autoDetectExtensions();
406
+
407
+ const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
408
+ const state = detectSystemState();
409
+ const reconciled = reconcileState(state);
410
+
411
+ // Show the real system state
412
+ console.log(formatReconciliation(reconciled));
413
+
414
+ // Show catalog items not yet managed
406
415
  const registry = readJSON(REGISTRY_PATH);
407
- const installed = Object.keys(registry?.extensions || {});
416
+ const registeredNames = Object.keys(registry?.extensions || {});
408
417
  const components = loadCatalog();
418
+ const available = components.filter(c =>
419
+ c.status !== 'coming-soon'
420
+ && !registeredNames.includes(c.id)
421
+ // Also check if it's in reconciled as external (already installed outside LDM)
422
+ && !reconciled[c.id]
423
+ );
409
424
 
410
- console.log('');
411
-
412
- // Show installed
413
- if (installed.length > 0) {
414
- console.log(' Installed components:');
415
- for (const name of installed) {
416
- const info = registry.extensions[name];
417
- console.log(` [x] ${name} v${info?.version || '?'}`);
425
+ if (available.length > 0) {
426
+ console.log(' Available in catalog (not yet installed):');
427
+ for (const c of available) {
428
+ console.log(` [ ] ${c.name} ... ${c.description}`);
418
429
  }
419
430
  console.log('');
420
431
  }
421
432
 
422
- // Show available (not installed)
423
- const available = components.filter(c => !installed.includes(c.id));
424
- if (available.length > 0) {
425
- console.log(' Available components:');
426
- let idx = 1;
427
- const selectable = [];
428
- for (const c of available) {
429
- if (c.status === 'coming-soon') {
430
- console.log(` [ ] ${c.name} (coming soon)`);
431
- } else {
432
- console.log(` [ ] ${c.name}`);
433
- selectable.push(c);
434
- }
435
- idx++;
433
+ if (DRY_RUN) {
434
+ // Show what an update would do
435
+ const updatable = Object.values(reconciled).filter(e =>
436
+ e.status === 'healthy' && e.registryHasSource
437
+ );
438
+ const unlinked = Object.values(reconciled).filter(e =>
439
+ e.status === 'installed-unlinked'
440
+ );
441
+
442
+ if (updatable.length > 0) {
443
+ console.log(` Would update ${updatable.length} extension(s) from source repos.`);
444
+ console.log(' No data (crystal.db, secrets, agent files) would be touched.');
445
+ console.log(' Old versions would be moved to ~/.ldm/_trash/ (never deleted).');
446
+ } else {
447
+ console.log(' Nothing to update from source repos.');
448
+ }
449
+
450
+ if (unlinked.length > 0) {
451
+ console.log('');
452
+ console.log(` ${unlinked.length} extension(s) are installed but have no source repo linked.`);
453
+ console.log(' These are safe. Link them with: ldm install <org/repo>');
436
454
  }
455
+
456
+ console.log('');
457
+ console.log(' Dry run complete. No changes made.');
458
+ console.log('');
459
+ return;
460
+ }
461
+
462
+ // Real update: only touch things with linked source repos
463
+ const updatable = Object.values(reconciled).filter(e =>
464
+ e.registryHasSource
465
+ );
466
+
467
+ if (updatable.length === 0) {
468
+ console.log(' No extensions have linked source repos to update from.');
469
+ console.log(' Link them with: ldm install <org/repo>');
437
470
  console.log('');
438
471
 
439
- // Interactive prompt if TTY and not --yes/--none
440
- if (selectable.length > 0 && !YES_FLAG && !NONE_FLAG && !DRY_RUN && process.stdin.isTTY) {
472
+ // Still offer catalog install if TTY
473
+ if (available.length > 0 && !YES_FLAG && !NONE_FLAG && process.stdin.isTTY) {
441
474
  const { createInterface } = await import('node:readline');
442
475
  const rl = createInterface({ input: process.stdin, output: process.stdout });
443
476
  const answer = await new Promise((resolve) => {
444
- rl.question(' Install more? [number,all,none]: ', (a) => {
477
+ rl.question(' Install from catalog? [number,all,none]: ', (a) => {
445
478
  rl.close();
446
479
  resolve(a.trim().toLowerCase());
447
480
  });
@@ -450,10 +483,10 @@ async function cmdInstallCatalog() {
450
483
  if (answer && answer !== 'none' && answer !== 'n') {
451
484
  let toInstall = [];
452
485
  if (answer === 'all' || answer === 'a') {
453
- toInstall = selectable;
486
+ toInstall = available;
454
487
  } else {
455
488
  const nums = answer.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
456
- toInstall = nums.map(n => selectable[n - 1]).filter(Boolean);
489
+ toInstall = nums.map(n => available[n - 1]).filter(Boolean);
457
490
  }
458
491
  for (const c of toInstall) {
459
492
  console.log(` Installing ${c.name}...`);
@@ -465,51 +498,45 @@ async function cmdInstallCatalog() {
465
498
  }
466
499
  }
467
500
  }
501
+ return;
468
502
  }
469
503
 
470
- // Update installed extensions
471
- if (installed.length > 0) {
472
- await cmdUpdateAll();
473
- }
474
-
475
- if (installed.length === 0 && available.filter(c => c.status !== 'coming-soon').length === 0) {
476
- console.log(' No extensions registered. Nothing to update.');
477
- console.log(' Use: ldm install <org/repo> or ldm install /path/to/repo');
478
- console.log('');
479
- }
480
- }
481
-
482
- async function cmdUpdateAll() {
483
- const registry = readJSON(REGISTRY_PATH);
484
- if (!registry?.extensions || Object.keys(registry.extensions).length === 0) return;
504
+ // Write revert manifest before starting
505
+ const { createRevertManifest } = await import('../lib/safe.mjs');
506
+ const manifestPath = createRevertManifest(
507
+ `ldm install (update ${updatable.length} extensions)`,
508
+ updatable.map(e => ({
509
+ action: 'update',
510
+ name: e.name,
511
+ currentVersion: e.ldmVersion || e.registryVersion,
512
+ source: e.registrySource,
513
+ }))
514
+ );
515
+ console.log(` Revert plan saved: ${manifestPath}`);
516
+ console.log('');
485
517
 
486
518
  const { setFlags, installFromPath } = await import('../lib/deploy.mjs');
487
519
  setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT });
488
520
 
489
- const extensions = Object.entries(registry.extensions);
490
- console.log(` Updating ${extensions.length} registered extension(s)...`);
491
- console.log('');
492
-
493
521
  let updated = 0;
494
- for (const [name, info] of extensions) {
495
- const source = info.source;
496
- if (!source || !existsSync(source)) {
497
- console.log(` x ${name}: source not found at ${source || '(none)'}`);
498
- continue;
499
- }
500
-
501
- await installFromPath(source);
522
+ for (const entry of updatable) {
523
+ await installFromPath(entry.registrySource);
502
524
  updated++;
503
525
  }
504
526
 
505
527
  console.log('');
506
- console.log(` Updated ${updated}/${extensions.length} extension(s).`);
528
+ console.log(` Updated ${updated}/${updatable.length} extension(s).`);
507
529
  console.log('');
508
530
  }
509
531
 
532
+ async function cmdUpdateAll() {
533
+ // Delegate to the catalog flow which now has full state awareness
534
+ return cmdInstallCatalog();
535
+ }
536
+
510
537
  // ── ldm doctor ──
511
538
 
512
- function cmdDoctor() {
539
+ async function cmdDoctor() {
513
540
  console.log('');
514
541
  console.log(' ldm doctor');
515
542
  console.log(' ────────────────────────────────────');
@@ -539,43 +566,19 @@ function cmdDoctor() {
539
566
  console.log(` + version.json: v${version.version} (installed ${version.installed?.split('T')[0]})`);
540
567
  }
541
568
 
542
- // 3. Check registry
543
- const registry = readJSON(REGISTRY_PATH);
544
- if (!registry) {
545
- console.log(' x registry.json missing');
546
- issues++;
547
- } else {
548
- const extCount = Object.keys(registry.extensions || {}).length;
549
- console.log(` + registry.json: ${extCount} extension(s)`);
550
-
551
- // Check each extension
552
- for (const [name, info] of Object.entries(registry.extensions || {})) {
553
- const ldmPath = info.ldmPath || join(LDM_EXTENSIONS, name);
554
- const hasLdm = existsSync(ldmPath);
555
- const hasPkg = hasLdm && existsSync(join(ldmPath, 'package.json'));
556
-
557
- if (!hasLdm) {
558
- console.log(` x ${name}: not found at ${ldmPath}`);
559
- issues++;
560
- } else if (!hasPkg) {
561
- console.log(` x ${name}: deployed but missing package.json`);
562
- issues++;
563
- } else {
564
- const pkg = readJSON(join(ldmPath, 'package.json'));
565
- const ver = pkg?.version || '?';
566
- const registeredVer = info.version || '?';
567
- if (ver !== registeredVer) {
568
- console.log(` ! ${name}: deployed v${ver} but registry says v${registeredVer}`);
569
- } else {
570
- console.log(` + ${name}: v${ver}`);
571
- }
572
- }
569
+ // 3. Full system state scan
570
+ const { detectSystemState, reconcileState, formatReconciliation } = await import('../lib/state.mjs');
571
+ const state = detectSystemState();
572
+ const reconciled = reconcileState(state);
573
573
 
574
- // Check OC copy
575
- if (info.ocPath && !existsSync(info.ocPath)) {
576
- console.log(` x OpenClaw copy missing at ${info.ocPath}`);
577
- issues++;
578
- }
574
+ // Show reconciled view
575
+ console.log(formatReconciliation(reconciled, { verbose: true }));
576
+
577
+ // Count issues from reconciliation
578
+ for (const entry of Object.values(reconciled)) {
579
+ if (entry.status === 'registered-missing') issues++;
580
+ if (entry.issues.length > 0 && entry.status !== 'installed-unlinked' && entry.status !== 'external') {
581
+ issues += entry.issues.length;
579
582
  }
580
583
  }
581
584
 
@@ -591,6 +594,7 @@ function cmdDoctor() {
591
594
  console.log(` + ${s.label} exists`);
592
595
  } else {
593
596
  console.log(` ! ${s.label} missing (run: ldm init)`);
597
+ issues++;
594
598
  }
595
599
  }
596
600
 
@@ -604,12 +608,14 @@ function cmdDoctor() {
604
608
  console.log(` - Claude Code hooks: none configured`);
605
609
  }
606
610
 
607
- // 6. Check MCP servers
608
- const ccUserPath = join(HOME, '.claude.json');
609
- const ccUser = readJSON(ccUserPath);
610
- if (ccUser?.mcpServers) {
611
- const mcpCount = Object.keys(ccUser.mcpServers).length;
612
- console.log(` + MCP servers (user): ${mcpCount} registered`);
611
+ // 6. MCP servers
612
+ const mcpCount = Object.keys(state.mcp).length;
613
+ console.log(` + MCP servers: ${mcpCount} registered`);
614
+
615
+ // 7. CLI binaries
616
+ const binCount = Object.keys(state.cliBinaries).length;
617
+ if (binCount > 0) {
618
+ console.log(` + CLI binaries: ${Object.keys(state.cliBinaries).join(', ')}`);
613
619
  }
614
620
 
615
621
  console.log('');
package/lib/deploy.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  import { join, basename, resolve, dirname } from 'node:path';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { detectInterfaces, describeInterfaces, detectToolbox } from './detect.mjs';
20
+ import { moveToTrash, appendToManifest } from './safe.mjs';
20
21
 
21
22
  const HOME = process.env.HOME || '';
22
23
  const LDM_ROOT = join(HOME, '.ldm');
@@ -199,9 +200,10 @@ function safeDeployDir(repoPath, destDir, name) {
199
200
  }
200
201
  renameSync(tempPath, finalPath);
201
202
 
202
- // 5. Clean up backup
203
+ // 5. Trash the old version (never delete)
203
204
  if (existsSync(backupPath)) {
204
- rmSync(backupPath, { recursive: true, force: true });
205
+ const trashed = moveToTrash(backupPath);
206
+ if (trashed) ok(`Old version moved to ${trashed}`);
205
207
  }
206
208
 
207
209
  return true;
package/lib/safe.mjs ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * lib/safe.mjs
3
+ * Safe file operations. Never delete. Always trash. Always write revert plans.
4
+ * Follows the _trash/ pattern from the DevOps Toolkit.
5
+ * Zero dependencies.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, renameSync, writeFileSync, readFileSync, cpSync } from 'node:fs';
9
+ import { join, basename, dirname } from 'node:path';
10
+
11
+ const HOME = process.env.HOME || '';
12
+ const LDM_ROOT = join(HOME, '.ldm');
13
+ const TRASH_ROOT = join(LDM_ROOT, '_trash');
14
+
15
+ function readJSON(path) {
16
+ try {
17
+ return JSON.parse(readFileSync(path, 'utf8'));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ // ── Trash ──
24
+
25
+ /**
26
+ * Move a file or directory to ~/.ldm/_trash/YYYY-MM-DD/ instead of deleting.
27
+ * Returns the trash destination path, or null if source didn't exist.
28
+ */
29
+ export function moveToTrash(sourcePath) {
30
+ if (!existsSync(sourcePath)) return null;
31
+
32
+ const date = new Date().toISOString().split('T')[0];
33
+ const trashDir = join(TRASH_ROOT, date);
34
+ const name = basename(sourcePath);
35
+
36
+ mkdirSync(trashDir, { recursive: true });
37
+
38
+ let dest = join(trashDir, name);
39
+ if (existsSync(dest)) {
40
+ dest = join(trashDir, `${name}-${Date.now()}`);
41
+ }
42
+
43
+ renameSync(sourcePath, dest);
44
+ return dest;
45
+ }
46
+
47
+ // ── Revert Manifests ──
48
+
49
+ /**
50
+ * Create a revert manifest before a batch of operations.
51
+ * Each operation: { action, name, description, originalPath, trashPath }
52
+ * Returns the manifest file path.
53
+ */
54
+ export function createRevertManifest(description, operations = []) {
55
+ const date = new Date().toISOString().split('T')[0];
56
+ const time = new Date().toISOString().replace(/[:.]/g, '-');
57
+ const manifestDir = join(TRASH_ROOT, date);
58
+ const manifestPath = join(manifestDir, `revert-${time}.json`);
59
+
60
+ mkdirSync(manifestDir, { recursive: true });
61
+
62
+ const manifest = {
63
+ timestamp: new Date().toISOString(),
64
+ description,
65
+ operations,
66
+ howToRevert: 'For each operation with a trashPath, move it back to originalPath.',
67
+ };
68
+
69
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
70
+ return manifestPath;
71
+ }
72
+
73
+ /**
74
+ * Append a completed operation to an existing revert manifest.
75
+ */
76
+ export function appendToManifest(manifestPath, operation) {
77
+ if (!existsSync(manifestPath)) return;
78
+
79
+ try {
80
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
81
+ manifest.operations.push(operation);
82
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
83
+ } catch {}
84
+ }
85
+
86
+ // ── Safe copy (backup before overwrite) ──
87
+
88
+ /**
89
+ * Copy a directory, but if the destination already exists, trash it first.
90
+ * Returns { trashPath, destPath } or null on failure.
91
+ */
92
+ export function safeCopy(srcPath, destPath) {
93
+ let trashPath = null;
94
+
95
+ if (existsSync(destPath)) {
96
+ trashPath = moveToTrash(destPath);
97
+ }
98
+
99
+ try {
100
+ mkdirSync(dirname(destPath), { recursive: true });
101
+ cpSync(srcPath, destPath, {
102
+ recursive: true,
103
+ filter: (s) => !s.includes('.git') && !s.includes('node_modules') && !s.includes('/ai/'),
104
+ });
105
+ return { trashPath, destPath };
106
+ } catch (e) {
107
+ // Rollback: restore from trash if copy failed
108
+ if (trashPath && existsSync(trashPath) && !existsSync(destPath)) {
109
+ try { renameSync(trashPath, destPath); } catch {}
110
+ }
111
+ throw e;
112
+ }
113
+ }
package/lib/state.mjs ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * lib/state.mjs
3
+ * System state detection and reconciliation.
4
+ * Scans the actual system (MCP servers, extensions, CLIs) to find what's
5
+ * really installed, regardless of what the LDM registry thinks.
6
+ * Zero dependencies.
7
+ */
8
+
9
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
10
+ import { join, basename } from 'node:path';
11
+ import { execSync } from 'node:child_process';
12
+
13
+ const HOME = process.env.HOME || '';
14
+ const LDM_ROOT = join(HOME, '.ldm');
15
+ const LDM_EXTENSIONS = join(LDM_ROOT, 'extensions');
16
+ const OC_ROOT = join(HOME, '.openclaw');
17
+ const OC_EXTENSIONS = join(OC_ROOT, 'extensions');
18
+ const CC_USER_PATH = join(HOME, '.claude.json');
19
+ const REGISTRY_PATH = join(LDM_EXTENSIONS, 'registry.json');
20
+
21
+ function readJSON(path) {
22
+ try {
23
+ return JSON.parse(readFileSync(path, 'utf8'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // ── Scanners ──
30
+
31
+ function detectMCPServers() {
32
+ const ccUser = readJSON(CC_USER_PATH);
33
+ const servers = {};
34
+ if (!ccUser?.mcpServers) return servers;
35
+
36
+ for (const [name, config] of Object.entries(ccUser.mcpServers)) {
37
+ const args = config.args || [];
38
+ servers[name] = {
39
+ name,
40
+ command: config.command,
41
+ args,
42
+ path: args[0] || null,
43
+ env: config.env || {},
44
+ };
45
+ }
46
+ return servers;
47
+ }
48
+
49
+ function scanExtensionDir(dir) {
50
+ const extensions = {};
51
+ if (!existsSync(dir)) return extensions;
52
+
53
+ try {
54
+ const entries = readdirSync(dir, { withFileTypes: true });
55
+ for (const entry of entries) {
56
+ if (entry.name === 'registry.json') continue;
57
+ if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
58
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
59
+
60
+ const dirPath = join(dir, entry.name);
61
+ const pkg = readJSON(join(dirPath, 'package.json'));
62
+ const plugin = readJSON(join(dirPath, 'openclaw.plugin.json'));
63
+
64
+ extensions[entry.name] = {
65
+ name: entry.name,
66
+ path: dirPath,
67
+ version: pkg?.version || null,
68
+ packageName: pkg?.name || null,
69
+ pluginId: plugin?.id || null,
70
+ hasPackageJson: !!pkg,
71
+ hasPluginJson: !!plugin,
72
+ hasMcpServer: existsSync(join(dirPath, 'mcp-server.mjs'))
73
+ || existsSync(join(dirPath, 'mcp-server.js'))
74
+ || existsSync(join(dirPath, 'dist', 'mcp-server.js')),
75
+ hasSkill: existsSync(join(dirPath, 'SKILL.md')),
76
+ isSymlink: entry.isSymbolicLink(),
77
+ };
78
+ }
79
+ } catch {}
80
+ return extensions;
81
+ }
82
+
83
+ function detectCLIBinaries() {
84
+ const knownBins = [
85
+ 'crystal', 'mdview', 'wip-release', 'wip-repos', 'wip-file-guard',
86
+ 'ldm', 'ldm-scaffold', 'wip-install', 'wip-license',
87
+ ];
88
+
89
+ const binaries = {};
90
+ for (const bin of knownBins) {
91
+ try {
92
+ const path = execSync(`which ${bin} 2>/dev/null`, { encoding: 'utf8' }).trim();
93
+ if (path) {
94
+ binaries[bin] = { path };
95
+ try {
96
+ const ver = execSync(`${bin} --version 2>/dev/null`, { encoding: 'utf8' }).trim().split('\n')[0];
97
+ binaries[bin].version = ver;
98
+ } catch {}
99
+ }
100
+ } catch {}
101
+ }
102
+ return binaries;
103
+ }
104
+
105
+ function detectSkills() {
106
+ const skillsDir = join(OC_ROOT, 'skills');
107
+ const skills = {};
108
+ if (!existsSync(skillsDir)) return skills;
109
+
110
+ try {
111
+ const entries = readdirSync(skillsDir, { withFileTypes: true });
112
+ for (const entry of entries) {
113
+ if (!entry.isDirectory()) continue;
114
+ const skillPath = join(skillsDir, entry.name, 'SKILL.md');
115
+ if (existsSync(skillPath)) {
116
+ skills[entry.name] = { path: skillPath };
117
+ }
118
+ }
119
+ } catch {}
120
+ return skills;
121
+ }
122
+
123
+ // ── Main detection ──
124
+
125
+ export function detectSystemState() {
126
+ const registry = readJSON(REGISTRY_PATH) || { _format: 'v1', extensions: {} };
127
+
128
+ return {
129
+ mcp: detectMCPServers(),
130
+ ldmExtensions: scanExtensionDir(LDM_EXTENSIONS),
131
+ ocExtensions: scanExtensionDir(OC_EXTENSIONS),
132
+ cliBinaries: detectCLIBinaries(),
133
+ skills: detectSkills(),
134
+ registry: registry.extensions || {},
135
+ };
136
+ }
137
+
138
+ // ── Reconciliation ──
139
+
140
+ export function reconcileState(systemState) {
141
+ const all = new Set();
142
+
143
+ // Collect all known names from every source
144
+ for (const name of Object.keys(systemState.registry)) all.add(name);
145
+ for (const name of Object.keys(systemState.ldmExtensions)) all.add(name);
146
+ for (const name of Object.keys(systemState.ocExtensions)) all.add(name);
147
+ for (const name of Object.keys(systemState.mcp)) all.add(name);
148
+
149
+ const reconciled = {};
150
+
151
+ for (const name of all) {
152
+ const reg = systemState.registry[name];
153
+ const ldm = systemState.ldmExtensions[name];
154
+ const oc = systemState.ocExtensions[name];
155
+ const mcp = systemState.mcp[name];
156
+
157
+ const entry = {
158
+ name,
159
+ // Registry
160
+ inRegistry: !!reg,
161
+ registryVersion: reg?.version || null,
162
+ registrySource: reg?.source || null,
163
+ registryHasSource: !!(reg?.source && existsSync(reg.source)),
164
+ registryInterfaces: reg?.interfaces || [],
165
+ // Deployed
166
+ deployedLdm: !!ldm,
167
+ ldmVersion: ldm?.version || null,
168
+ deployedOc: !!oc,
169
+ ocVersion: oc?.version || null,
170
+ // MCP
171
+ mcpRegistered: !!mcp,
172
+ mcpPath: mcp?.path || null,
173
+ // Computed
174
+ status: 'unknown',
175
+ issues: [],
176
+ };
177
+
178
+ // ── Determine status ──
179
+
180
+ if (entry.inRegistry && entry.deployedLdm && entry.registryHasSource) {
181
+ entry.status = 'healthy';
182
+ } else if (entry.inRegistry && (entry.deployedLdm || entry.deployedOc) && !entry.registryHasSource) {
183
+ // Deployed and registered, but no source repo linked
184
+ entry.status = 'installed-unlinked';
185
+ if (!reg?.source) {
186
+ entry.issues.push('No source repo linked. Run: ldm install <org/repo> to link.');
187
+ } else {
188
+ entry.issues.push(`Source not found at: ${reg.source}`);
189
+ }
190
+ } else if (entry.inRegistry && !entry.deployedLdm && !entry.deployedOc) {
191
+ entry.status = 'registered-missing';
192
+ entry.issues.push('In registry but not deployed anywhere.');
193
+ } else if (!entry.inRegistry && (entry.deployedLdm || entry.deployedOc)) {
194
+ entry.status = 'deployed-unregistered';
195
+ entry.issues.push('Deployed but not in LDM registry.');
196
+ } else if (!entry.inRegistry && !entry.deployedLdm && !entry.deployedOc && entry.mcpRegistered) {
197
+ entry.status = 'mcp-only';
198
+ entry.issues.push('MCP server registered but not managed by LDM.');
199
+ }
200
+
201
+ // Version mismatches
202
+ if (entry.ldmVersion && entry.ocVersion && entry.ldmVersion !== entry.ocVersion) {
203
+ entry.issues.push(`Version mismatch: LDM v${entry.ldmVersion} vs OC v${entry.ocVersion}`);
204
+ }
205
+ if (entry.registryVersion && entry.ldmVersion && entry.registryVersion !== entry.ldmVersion) {
206
+ entry.issues.push(`Registry says v${entry.registryVersion} but deployed is v${entry.ldmVersion}`);
207
+ }
208
+
209
+ // MCP path sanity
210
+ if (entry.mcpRegistered && entry.deployedLdm && entry.mcpPath) {
211
+ const expectedBase = join(LDM_EXTENSIONS, name);
212
+ if (!entry.mcpPath.startsWith(expectedBase)) {
213
+ entry.issues.push(`MCP path does not match LDM extension location.`);
214
+ }
215
+ }
216
+
217
+ reconciled[name] = entry;
218
+ }
219
+
220
+ return reconciled;
221
+ }
222
+
223
+ // ── Display ──
224
+
225
+ export function formatReconciliation(reconciled, { verbose = false } = {}) {
226
+ const healthy = [];
227
+ const unlinked = [];
228
+ const needsAttention = [];
229
+ const external = [];
230
+
231
+ for (const entry of Object.values(reconciled)) {
232
+ switch (entry.status) {
233
+ case 'healthy':
234
+ healthy.push(entry);
235
+ break;
236
+ case 'installed-unlinked':
237
+ unlinked.push(entry);
238
+ break;
239
+ case 'mcp-only':
240
+ case 'deployed-unregistered':
241
+ external.push(entry);
242
+ break;
243
+ default:
244
+ needsAttention.push(entry);
245
+ }
246
+ }
247
+
248
+ const sort = (a, b) => a.name.localeCompare(b.name);
249
+ const lines = [];
250
+
251
+ lines.push('');
252
+ lines.push(' System State');
253
+ lines.push(' ────────────────────────────────────');
254
+
255
+ if (healthy.length > 0) {
256
+ lines.push('');
257
+ lines.push(` Managed by LDM (${healthy.length}):`);
258
+ for (const e of healthy.sort(sort)) {
259
+ const ver = e.ldmVersion || e.registryVersion || '?';
260
+ const ifaces = e.registryInterfaces.length > 0 ? ` (${e.registryInterfaces.join(', ')})` : '';
261
+ const mcp = e.mcpRegistered ? ' [MCP]' : '';
262
+ lines.push(` [x] ${e.name} v${ver}${ifaces}${mcp}`);
263
+ }
264
+ }
265
+
266
+ if (unlinked.length > 0) {
267
+ lines.push('');
268
+ lines.push(` Installed, no source repo linked (${unlinked.length}):`);
269
+ lines.push(` (These work fine. Link them to enable updates via ldm install.)`);
270
+ for (const e of unlinked.sort(sort)) {
271
+ const ver = e.ldmVersion || e.ocVersion || e.registryVersion || '?';
272
+ const mcp = e.mcpRegistered ? ' [MCP]' : '';
273
+ lines.push(` [-] ${e.name} v${ver}${mcp}`);
274
+ if (verbose) {
275
+ for (const issue of e.issues) {
276
+ lines.push(` ${issue}`);
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ if (external.length > 0) {
283
+ lines.push('');
284
+ lines.push(` Not managed by LDM (${external.length}):`);
285
+ for (const e of external.sort(sort)) {
286
+ const ver = e.ldmVersion || e.ocVersion || '?';
287
+ const detail = e.mcpRegistered ? 'MCP registered' : 'extension deployed';
288
+ lines.push(` [ ] ${e.name} v${ver} ... ${detail}`);
289
+ }
290
+ }
291
+
292
+ if (needsAttention.length > 0) {
293
+ lines.push('');
294
+ lines.push(` Needs attention (${needsAttention.length}):`);
295
+ for (const e of needsAttention.sort(sort)) {
296
+ const ver = e.ldmVersion || e.ocVersion || e.registryVersion || '?';
297
+ lines.push(` [!] ${e.name} v${ver} ... ${e.status}`);
298
+ for (const issue of e.issues) {
299
+ lines.push(` ${issue}`);
300
+ }
301
+ }
302
+ }
303
+
304
+ lines.push('');
305
+ return lines.join('\n');
306
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "main": "src/boot/boot-hook.mjs",