@wipcomputer/wip-ldm-os 0.4.73-alpha.8 → 0.4.74

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.
Files changed (61) hide show
  1. package/LICENSE +52 -0
  2. package/SKILL.md +8 -1
  3. package/bin/ldm.js +587 -82
  4. package/dist/bridge/chunk-3RG5ZIWI.js +10 -0
  5. package/dist/bridge/{chunk-LF7EMFBY.js → chunk-7NH6JBIO.js} +127 -49
  6. package/dist/bridge/cli.js +2 -1
  7. package/dist/bridge/core.d.ts +13 -1
  8. package/dist/bridge/core.js +4 -1
  9. package/dist/bridge/mcp-server.js +52 -7
  10. package/dist/bridge/openclaw.d.ts +5 -0
  11. package/dist/bridge/openclaw.js +11 -0
  12. package/docs/bridge/TECHNICAL.md +86 -0
  13. package/docs/doc-pipeline/README.md +74 -0
  14. package/docs/doc-pipeline/TECHNICAL.md +79 -0
  15. package/lib/deploy.mjs +175 -13
  16. package/lib/detect.mjs +20 -6
  17. package/package.json +2 -2
  18. package/shared/docs/README.md.tmpl +2 -2
  19. package/shared/docs/how-releases-work.md.tmpl +3 -1
  20. package/shared/docs/how-worktrees-work.md.tmpl +12 -7
  21. package/shared/rules/git-conventions.md +3 -3
  22. package/shared/rules/release-pipeline.md +1 -1
  23. package/shared/rules/security.md +1 -1
  24. package/shared/rules/workspace-boundaries.md +1 -1
  25. package/shared/rules/writing-style.md +1 -1
  26. package/shared/templates/claude-md-level1.md +7 -3
  27. package/src/bridge/core.ts +160 -56
  28. package/src/bridge/mcp-server.ts +93 -8
  29. package/src/bridge/openclaw.ts +14 -0
  30. package/src/hooks/inbox-check-hook.mjs +232 -0
  31. package/src/hooks/inbox-rewake-hook.mjs +388 -0
  32. package/src/hosted-mcp/.env.example +3 -0
  33. package/src/hosted-mcp/demo/agent.html +300 -0
  34. package/src/hosted-mcp/demo/agent.txt +84 -0
  35. package/src/hosted-mcp/demo/fallback.jpg +0 -0
  36. package/src/hosted-mcp/demo/footer.js +74 -0
  37. package/src/hosted-mcp/demo/index.html +1303 -0
  38. package/src/hosted-mcp/demo/login.html +548 -0
  39. package/src/hosted-mcp/demo/privacy.html +223 -0
  40. package/src/hosted-mcp/demo/sprites.jpg +0 -0
  41. package/src/hosted-mcp/demo/sprites.png +0 -0
  42. package/src/hosted-mcp/demo/tos.html +198 -0
  43. package/src/hosted-mcp/deploy.sh +70 -0
  44. package/src/hosted-mcp/ecosystem.config.cjs +14 -0
  45. package/src/hosted-mcp/inbox.mjs +64 -0
  46. package/src/hosted-mcp/legal/internet-services/terms/site.html +205 -0
  47. package/src/hosted-mcp/legal/privacy/en-ww/index.html +230 -0
  48. package/src/hosted-mcp/nginx/mcp-oauth.conf +98 -0
  49. package/src/hosted-mcp/nginx/mcp-server.conf +17 -0
  50. package/src/hosted-mcp/nginx/wip.computer.conf +45 -0
  51. package/src/hosted-mcp/package-lock.json +2092 -0
  52. package/src/hosted-mcp/package.json +23 -0
  53. package/src/hosted-mcp/prisma/migrations/20260406233014_init/migration.sql +68 -0
  54. package/src/hosted-mcp/prisma/migrations/migration_lock.toml +3 -0
  55. package/src/hosted-mcp/prisma/schema.prisma +57 -0
  56. package/src/hosted-mcp/prisma.config.ts +14 -0
  57. package/src/hosted-mcp/server.mjs +2093 -0
  58. package/src/hosted-mcp/shared/kaleidoscope.css +139 -0
  59. package/src/hosted-mcp/shared/kaleidoscope.js +192 -0
  60. package/src/hosted-mcp/tools.mjs +73 -0
  61. package/templates/hooks/pre-commit +5 -0
package/bin/ldm.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * ldm --version Show version
21
21
  */
22
22
 
23
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync, statSync } from 'node:fs';
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync, statSync, lstatSync, symlinkSync } from 'node:fs';
24
24
  import { join, basename, resolve, dirname } from 'node:path';
25
25
  import { execSync } from 'node:child_process';
26
26
  import { fileURLToPath } from 'node:url';
@@ -350,7 +350,7 @@ function cleanStaleHooks() {
350
350
 
351
351
  function syncBootHook() {
352
352
  const srcBoot = join(__dirname, '..', 'src', 'boot', 'boot-hook.mjs');
353
- const destBoot = join(LDM_ROOT, 'shared', 'boot', 'boot-hook.mjs');
353
+ const destBoot = join(LDM_ROOT, 'library', 'boot', 'boot-hook.mjs');
354
354
 
355
355
  if (!existsSync(srcBoot)) return false;
356
356
 
@@ -368,6 +368,176 @@ function syncBootHook() {
368
368
  return false;
369
369
  }
370
370
 
371
+ // ── Inbox check hook sync ──
372
+ //
373
+ // Deploys src/hooks/inbox-check-hook.mjs to ~/.ldm/library/hooks/ and
374
+ // wires it into ~/.claude/settings.json as a UserPromptSubmit hook so
375
+ // that pending bridge messages in ~/.ldm/messages/ are surfaced as
376
+ // additionalContext before CC responds to each user prompt.
377
+ //
378
+ // Closes the loop between lesa-bridge fire-and-forget sends and
379
+ // CC-side message delivery. Without this hook, Claude Code only sees
380
+ // bridge messages when it explicitly calls lesa_check_inbox, which
381
+ // requires manual discipline and loses messages in practice.
382
+ //
383
+ // Idempotent: subsequent installs update the file only if its contents
384
+ // changed, and only add the settings.json entry if it isn't already
385
+ // wired to the exact same command path.
386
+ //
387
+ // See:
388
+ // ai/product/plans-prds/bridge/2026-04-06--cc-mini--bridge-master-product-plan.md
389
+ // ai/product/bugs/bridge/2026-04-06--cc-mini--bridge-async-inbox-plan.md
390
+ // ai/product/bugs/bridge/2026-04-10--cc-mini--bridge-reply-addressing-mismatch.md
391
+ function syncInboxCheckHook() {
392
+ const srcHook = join(__dirname, '..', 'src', 'hooks', 'inbox-check-hook.mjs');
393
+ const destHook = join(LDM_ROOT, 'library', 'hooks', 'inbox-check-hook.mjs');
394
+ let changed = false;
395
+
396
+ if (!existsSync(srcHook)) return false;
397
+
398
+ // 1. File deploy: copy src/hooks/inbox-check-hook.mjs to ~/.ldm/library/hooks/
399
+ try {
400
+ const srcContent = readFileSync(srcHook, 'utf8');
401
+ let destContent = '';
402
+ try { destContent = readFileSync(destHook, 'utf8'); } catch {}
403
+
404
+ if (srcContent !== destContent) {
405
+ mkdirSync(dirname(destHook), { recursive: true });
406
+ writeFileSync(destHook, srcContent);
407
+ changed = true;
408
+ }
409
+ } catch {
410
+ return false;
411
+ }
412
+
413
+ // 2. Settings.json patch: wire the hook into hooks.UserPromptSubmit if absent.
414
+ const settingsPath = join(HOME, '.claude', 'settings.json');
415
+ if (!existsSync(settingsPath)) return changed;
416
+
417
+ try {
418
+ const raw = readFileSync(settingsPath, 'utf8');
419
+ const settings = JSON.parse(raw);
420
+ if (!settings.hooks) settings.hooks = {};
421
+ if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
422
+
423
+ const hookCommand = `node ${destHook}`;
424
+ const alreadyWired = settings.hooks.UserPromptSubmit.some(group =>
425
+ Array.isArray(group.hooks) &&
426
+ group.hooks.some(h => h.type === 'command' && h.command === hookCommand)
427
+ );
428
+
429
+ if (!alreadyWired) {
430
+ settings.hooks.UserPromptSubmit.push({
431
+ hooks: [{
432
+ type: 'command',
433
+ command: hookCommand,
434
+ timeout: 5,
435
+ }],
436
+ });
437
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
438
+ changed = true;
439
+ }
440
+ } catch {
441
+ // Settings file malformed or unreadable. File deploy still succeeded
442
+ // if changed was true; leave settings.json untouched and let the user
443
+ // see the file deploy message without the wire-up.
444
+ }
445
+
446
+ return changed;
447
+ }
448
+
449
+ // ── Inbox rewake hook sync ──
450
+ //
451
+ // Deploys src/hooks/inbox-rewake-hook.mjs to ~/.ldm/library/hooks/ and
452
+ // wires it into ~/.claude/settings.json as a Stop hook with
453
+ // `asyncRewake: true`. This is the autonomous push layer that wakes
454
+ // an idle Claude Code session when a bridge message arrives, without
455
+ // the user having to type anything.
456
+ //
457
+ // Mechanics: the Stop hook fires after every CC turn. The rewake hook
458
+ // acquires a per-session lock file (so concurrent Stop-event spawns do
459
+ // not stack), then holds a long-lived fs.watch on ~/.ldm/messages/.
460
+ // When a matching message file arrives, the hook writes the message to
461
+ // stderr and exits with code 2. The CC harness wraps that stderr into
462
+ // a system-reminder task-notification that wakes the idle model or
463
+ // gets injected mid-query if the model is busy. See Claude Code's
464
+ // `src/utils/hooks.ts` asyncRewake path for the exact mechanism.
465
+ //
466
+ // This closes the layer 1 gap from:
467
+ // ai/product/plans-prds/bridge/2026-04-11--cc-mini--autonomous-push-architecture.md
468
+ //
469
+ // Layers 2-4 (UserPromptSubmit inbox-check hook, SessionStart boot
470
+ // hook, manual lesa_check_inbox) remain as independent fallbacks.
471
+ //
472
+ // Idempotent: subsequent installs update the file only if its contents
473
+ // changed, and only add the settings.json entry if it isn't already
474
+ // wired to the exact same command path.
475
+ function syncInboxRewakeHook() {
476
+ const srcHook = join(__dirname, '..', 'src', 'hooks', 'inbox-rewake-hook.mjs');
477
+ const destHook = join(LDM_ROOT, 'library', 'hooks', 'inbox-rewake-hook.mjs');
478
+ let changed = false;
479
+
480
+ if (!existsSync(srcHook)) return false;
481
+
482
+ // 1. File deploy: copy src/hooks/inbox-rewake-hook.mjs to ~/.ldm/library/hooks/
483
+ try {
484
+ const srcContent = readFileSync(srcHook, 'utf8');
485
+ let destContent = '';
486
+ try { destContent = readFileSync(destHook, 'utf8'); } catch {}
487
+
488
+ if (srcContent !== destContent) {
489
+ mkdirSync(dirname(destHook), { recursive: true });
490
+ writeFileSync(destHook, srcContent);
491
+ changed = true;
492
+ }
493
+ } catch {
494
+ return false;
495
+ }
496
+
497
+ // 2. Settings.json patch: wire the hook into hooks.Stop as an
498
+ // asyncRewake background hook if absent.
499
+ const settingsPath = join(HOME, '.claude', 'settings.json');
500
+ if (!existsSync(settingsPath)) return changed;
501
+
502
+ try {
503
+ const raw = readFileSync(settingsPath, 'utf8');
504
+ const settings = JSON.parse(raw);
505
+ if (!settings.hooks) settings.hooks = {};
506
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
507
+
508
+ const hookCommand = `node ${destHook}`;
509
+ const alreadyWired = settings.hooks.Stop.some(group =>
510
+ Array.isArray(group.hooks) &&
511
+ group.hooks.some(h =>
512
+ h.type === 'command' &&
513
+ h.command === hookCommand &&
514
+ h.asyncRewake === true,
515
+ ),
516
+ );
517
+
518
+ if (!alreadyWired) {
519
+ settings.hooks.Stop.push({
520
+ hooks: [{
521
+ type: 'command',
522
+ command: hookCommand,
523
+ async: true,
524
+ asyncRewake: true,
525
+ // 6 hours: matches the rewake hook's internal hard timeout.
526
+ // The hook self-terminates well before this on parent death,
527
+ // hard cancel, or match, so this is just a runaway guard.
528
+ timeout: 21600,
529
+ }],
530
+ });
531
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
532
+ changed = true;
533
+ }
534
+ } catch {
535
+ // Settings file malformed or unreadable. Leave it alone.
536
+ }
537
+
538
+ return changed;
539
+ }
540
+
371
541
  // ── Catalog helpers ──
372
542
 
373
543
  function loadCatalog() {
@@ -544,20 +714,14 @@ function deployDocs() {
544
714
  return count;
545
715
  }
546
716
 
547
- // Deploy to settings/docs/ (agent reference)
548
- const docsDest = join(workspacePath, 'settings', 'docs');
549
- const docsCount = renderTemplates(docsDest);
550
- if (docsCount > 0) {
551
- console.log(` + ${docsCount} personalized doc(s) deployed to ${docsDest.replace(HOME, '~')}/`);
552
- }
553
-
554
- // Deploy to library/documentation/ (human-readable library copy)
717
+ // Deploy to library/documentation/ (the canonical doc path since Mar 28 rename).
718
+ // Previously also deployed to settings/docs/ which Parker renamed to library/documentation/.
719
+ // That created a ghost folder on every install. Removed 2026-04-05 per INST-1.
555
720
  const libraryDest = join(workspacePath, 'library', 'documentation');
556
- if (existsSync(join(workspacePath, 'library'))) {
557
- const libCount = renderTemplates(libraryDest);
558
- if (libCount > 0) {
559
- console.log(` + ${libCount} doc(s) deployed to ${libraryDest.replace(HOME, '~')}/`);
560
- }
721
+ mkdirSync(libraryDest, { recursive: true });
722
+ const docsCount = renderTemplates(libraryDest);
723
+ if (docsCount > 0) {
724
+ console.log(` + ${docsCount} personalized doc(s) deployed to ${libraryDest.replace(HOME, '~')}/`);
561
725
  }
562
726
 
563
727
  return docsCount;
@@ -632,17 +796,58 @@ function checkBackupHealth() {
632
796
  // After `npm install -g`, the updated files live at the npm package location but
633
797
  // never get copied to ~/.ldm/extensions/lesa-bridge/dist/. This function fixes that.
634
798
 
799
+ function deployRules() {
800
+ const rulesSrc = join(__dirname, '..', 'shared', 'rules');
801
+ const rulesDest = join(LDM_ROOT, 'library', 'rules');
802
+ if (!existsSync(rulesSrc)) return;
803
+ mkdirSync(rulesDest, { recursive: true });
804
+ let rulesCount = 0;
805
+ for (const file of readdirSync(rulesSrc)) {
806
+ if (!file.endsWith('.md')) continue;
807
+ cpSync(join(rulesSrc, file), join(rulesDest, file));
808
+ rulesCount++;
809
+ }
810
+ if (rulesCount > 0) {
811
+ console.log(` + ${rulesCount} shared rules deployed to ~/.ldm/library/rules/`);
812
+ // Deploy to Claude Code harness (~/.claude/rules/)
813
+ const claudeRules = join(HOME, '.claude', 'rules');
814
+ if (existsSync(join(HOME, '.claude'))) {
815
+ mkdirSync(claudeRules, { recursive: true });
816
+ for (const file of readdirSync(rulesDest)) {
817
+ if (!file.endsWith('.md')) continue;
818
+ cpSync(join(rulesDest, file), join(claudeRules, file));
819
+ }
820
+ console.log(` + rules deployed to ~/.claude/rules/`);
821
+ }
822
+ // Deploy to OpenClaw harness (~/.openclaw/workspace/DEV-RULES.md)
823
+ const ocWorkspace = join(HOME, '.openclaw', 'workspace');
824
+ if (existsSync(ocWorkspace)) {
825
+ let combined = '# Dev Rules (deployed by ldm install)\n\n';
826
+ combined += '> Do not edit this file. It is regenerated by `ldm install`.\n';
827
+ combined += '> Source: ~/.ldm/library/rules/\n\n';
828
+ for (const file of readdirSync(rulesDest).sort()) {
829
+ if (!file.endsWith('.md')) continue;
830
+ combined += readFileSync(join(rulesDest, file), 'utf8') + '\n\n---\n\n';
831
+ }
832
+ writeFileSync(join(ocWorkspace, 'DEV-RULES.md'), combined);
833
+ console.log(` + rules deployed to ~/.openclaw/workspace/DEV-RULES.md`);
834
+ }
835
+ }
836
+ }
837
+
635
838
  function deployBridge() {
636
839
  const ldmBridgeDir = join(LDM_EXTENSIONS, 'lesa-bridge');
637
840
  const ocBridgeDir = join(HOME, '.openclaw', 'extensions', 'lesa-bridge');
638
841
 
639
842
  // Deploy targets: LDM path (canonical) and OpenClaw path (where the plugin loads)
843
+ // Create dirs if missing so first-time deploy works (don't skip with filter)
640
844
  const targets = [
641
845
  { dir: ldmBridgeDir, label: '~/.ldm/extensions/lesa-bridge/dist/' },
642
846
  { dir: ocBridgeDir, label: '~/.openclaw/extensions/lesa-bridge/dist/' },
643
- ].filter(t => existsSync(t.dir)); // Only deploy if the extension dir exists
644
-
645
- if (targets.length === 0) return 0;
847
+ ];
848
+ for (const t of targets) {
849
+ if (!existsSync(t.dir)) mkdirSync(t.dir, { recursive: true });
850
+ }
646
851
 
647
852
  // Find the npm package bridge files. Try require.resolve first, fall back to known path.
648
853
  let bridgeSrc = '';
@@ -773,10 +978,10 @@ async function cmdInit() {
773
978
  join(LDM_ROOT, 'state'),
774
979
  join(LDM_ROOT, 'sessions'),
775
980
  join(LDM_ROOT, 'messages'),
776
- join(LDM_ROOT, 'shared', 'boot'),
777
- join(LDM_ROOT, 'shared', 'cron'),
778
- join(LDM_ROOT, 'shared', 'rules'),
779
- join(LDM_ROOT, 'shared', 'prompts'),
981
+ join(LDM_ROOT, 'library', 'boot'),
982
+ join(LDM_ROOT, 'library', 'cron'),
983
+ join(LDM_ROOT, 'library', 'rules'),
984
+ join(LDM_ROOT, 'library', 'prompts'),
780
985
  join(LDM_ROOT, 'hooks'),
781
986
  ];
782
987
 
@@ -820,10 +1025,16 @@ async function cmdInit() {
820
1025
  // Scaffold workspace output dirs if workspace is configured
821
1026
  const workspace = config.workspace;
822
1027
  if (workspace && existsSync(workspace)) {
823
- // Per-agent workspace dirs
824
- const agentNameMap = { 'cc-mini': 'cc-mini', 'cc-air': 'cc-air', 'oc-lesa-mini': 'Lēsa' };
1028
+ // Per-agent workspace dirs.
1029
+ // Resolve the team folder name from config.json agents[id].teamFolder
1030
+ // so agents with unicode names or custom folder names don't get ghost
1031
+ // folders created from their agent ID. Falls back to agent ID if no
1032
+ // override is configured. Fixed 2026-04-05 per INST-1: previously
1033
+ // hardcoded a map that only knew three agents and created ghost folders
1034
+ // for any others.
825
1035
  for (const agentId of agentList) {
826
- const teamName = agentNameMap[agentId] || agentId;
1036
+ const agentObj = typeof agentsObj[agentId] === 'object' ? agentsObj[agentId] : {};
1037
+ const teamName = agentObj.teamFolder || agentObj.name || agentId;
827
1038
  for (const sub of ['journals', 'automated/memory/summaries/daily', 'automated/memory/summaries/weekly', 'automated/memory/summaries/monthly', 'automated/memory/summaries/quarterly']) {
828
1039
  dirs.push(join(workspace, 'team', teamName, sub));
829
1040
  }
@@ -944,68 +1155,29 @@ async function cmdInit() {
944
1155
  // Deploy all scripts from scripts/ to ~/.ldm/bin/ (#119)
945
1156
  deployScripts();
946
1157
 
947
- // Deploy shared rules to ~/.ldm/shared/rules/ and to harnesses
948
- const rulesSrc = join(__dirname, '..', 'shared', 'rules');
949
- const rulesDest = join(LDM_ROOT, 'shared', 'rules');
950
- if (existsSync(rulesSrc)) {
951
- mkdirSync(rulesDest, { recursive: true });
952
- let rulesCount = 0;
953
- for (const file of readdirSync(rulesSrc)) {
954
- if (!file.endsWith('.md')) continue;
955
- cpSync(join(rulesSrc, file), join(rulesDest, file));
956
- rulesCount++;
957
- }
958
- if (rulesCount > 0) {
959
- console.log(` + ${rulesCount} shared rules deployed to ~/.ldm/shared/rules/`);
960
-
961
- // Deploy to Claude Code harness (~/.claude/rules/)
962
- const claudeRules = join(HOME, '.claude', 'rules');
963
- if (existsSync(join(HOME, '.claude'))) {
964
- mkdirSync(claudeRules, { recursive: true });
965
- for (const file of readdirSync(rulesDest)) {
966
- if (!file.endsWith('.md')) continue;
967
- cpSync(join(rulesDest, file), join(claudeRules, file));
968
- }
969
- console.log(` + rules deployed to ~/.claude/rules/`);
970
- }
971
-
972
- // Deploy to OpenClaw harness (~/.openclaw/workspace/DEV-RULES.md)
973
- const ocWorkspace = join(HOME, '.openclaw', 'workspace');
974
- if (existsSync(ocWorkspace)) {
975
- let combined = '# Dev Rules (deployed by ldm install)\n\n';
976
- combined += '> Do not edit this file. It is regenerated by `ldm install`.\n';
977
- combined += '> Source: ~/.ldm/shared/rules/\n\n';
978
- for (const file of readdirSync(rulesDest).sort()) {
979
- if (!file.endsWith('.md')) continue;
980
- combined += readFileSync(join(rulesDest, file), 'utf8') + '\n\n---\n\n';
981
- }
982
- writeFileSync(join(ocWorkspace, 'DEV-RULES.md'), combined);
983
- console.log(` + rules deployed to ~/.openclaw/workspace/DEV-RULES.md`);
984
- }
985
- }
986
- }
1158
+ deployRules();
987
1159
 
988
- // Deploy boot-config.json to ~/.ldm/shared/boot/
1160
+ // Deploy boot-config.json to ~/.ldm/library/boot/
989
1161
  const bootSrc = join(__dirname, '..', 'shared', 'boot');
990
- const bootDest = join(LDM_ROOT, 'shared', 'boot');
1162
+ const bootDest = join(LDM_ROOT, 'library', 'boot');
991
1163
  if (existsSync(bootSrc)) {
992
1164
  mkdirSync(bootDest, { recursive: true });
993
1165
  const bootConfig = join(bootSrc, 'boot-config.json');
994
1166
  if (existsSync(bootConfig)) {
995
1167
  cpSync(bootConfig, join(bootDest, 'boot-config.json'));
996
- console.log(` + boot-config.json deployed to ~/.ldm/shared/boot/`);
1168
+ console.log(` + boot-config.json deployed to ~/.ldm/library/boot/`);
997
1169
  }
998
1170
  }
999
1171
 
1000
- // Deploy Level 1 CLAUDE.md template to ~/.claude/CLAUDE.md
1001
- const claudeMdTemplate = join(__dirname, '..', 'shared', 'templates', 'claude-md-level1.md');
1002
- const claudeMdDest = join(HOME, '.claude', 'CLAUDE.md');
1003
- if (existsSync(claudeMdTemplate) && existsSync(join(HOME, '.claude'))) {
1004
- cpSync(claudeMdTemplate, claudeMdDest);
1005
- console.log(` + Level 1 CLAUDE.md deployed to ~/.claude/CLAUDE.md`);
1006
- }
1172
+ // CLAUDE.md files are NEVER deployed by the installer.
1173
+ // They are git-tracked files in their respective repos:
1174
+ // ~/.claude/CLAUDE.md ... wipcomputer-ldmos-wipcomputerinc-dot-claude-private
1175
+ // ~/wipcomputerinc/CLAUDE.md ... wipcomputerinc repo
1176
+ // ~/.openclaw/CLAUDE.md ... openclaw repo
1177
+ // Changes go through branches and PRs like any other file.
1178
+ // See: 2026-03-27--cc-mini--single-source-of-truth-reversed.md
1007
1179
 
1008
- // Deploy shared templates to workspace settings/templates/
1180
+ // Deploy shared templates to workspace library/templates/
1009
1181
  const templatesSrc = join(__dirname, '..', 'shared', 'templates');
1010
1182
  if (existsSync(templatesSrc)) {
1011
1183
  // Read workspace path from ~/.ldm/config.json
@@ -1015,7 +1187,7 @@ async function cmdInit() {
1015
1187
  workspacePath = (ldmConfig.workspace || '').replace('~', HOME);
1016
1188
  } catch {}
1017
1189
  if (workspacePath && existsSync(workspacePath)) {
1018
- const templatesDest = join(workspacePath, 'settings', 'templates');
1190
+ const templatesDest = join(workspacePath, 'library', 'templates');
1019
1191
  mkdirSync(templatesDest, { recursive: true });
1020
1192
  let templatesCount = 0;
1021
1193
  for (const file of readdirSync(templatesSrc)) {
@@ -1029,9 +1201,9 @@ async function cmdInit() {
1029
1201
  }
1030
1202
  }
1031
1203
 
1032
- // Deploy shared prompts to ~/.ldm/shared/prompts/
1204
+ // Deploy shared prompts to ~/.ldm/library/prompts/
1033
1205
  const promptsSrc = join(__dirname, '..', 'shared', 'prompts');
1034
- const promptsDest = join(LDM_ROOT, 'shared', 'prompts');
1206
+ const promptsDest = join(LDM_ROOT, 'library', 'prompts');
1035
1207
  if (existsSync(promptsSrc)) {
1036
1208
  mkdirSync(promptsDest, { recursive: true });
1037
1209
  let promptsCount = 0;
@@ -1041,7 +1213,33 @@ async function cmdInit() {
1041
1213
  promptsCount++;
1042
1214
  }
1043
1215
  if (promptsCount > 0) {
1044
- console.log(` + ${promptsCount} shared prompts deployed to ~/.ldm/shared/prompts/`);
1216
+ console.log(` + ${promptsCount} shared prompts deployed to ~/.ldm/library/prompts/`);
1217
+ }
1218
+ }
1219
+
1220
+ // Backward-compat symlink: ~/.ldm/shared -> ~/.ldm/library
1221
+ // Anything still referencing shared/ will follow the symlink
1222
+ {
1223
+ const sharedPath = join(LDM_ROOT, 'shared');
1224
+ const libraryPath = join(LDM_ROOT, 'library');
1225
+ try {
1226
+ const stat = lstatSync(sharedPath);
1227
+ if (stat.isSymbolicLink()) {
1228
+ // Already a symlink, update target if needed
1229
+ const target = readlinkSync(sharedPath);
1230
+ if (target !== libraryPath) {
1231
+ unlinkSync(sharedPath);
1232
+ symlinkSync(libraryPath, sharedPath);
1233
+ }
1234
+ } else if (stat.isDirectory()) {
1235
+ // shared/ is a real directory (pre-rename state). Don't touch it.
1236
+ // The migration will handle this in a dedicated session.
1237
+ }
1238
+ } catch {
1239
+ // shared/ doesn't exist. Create symlink.
1240
+ try {
1241
+ symlinkSync(libraryPath, sharedPath);
1242
+ } catch {}
1045
1243
  }
1046
1244
  }
1047
1245
 
@@ -1548,6 +1746,135 @@ function autoDetectExtensions() {
1548
1746
  return found;
1549
1747
  }
1550
1748
 
1749
+ // ── Claude Code env override cleanup ──
1750
+
1751
+ /**
1752
+ * Strip stale Claude Code env overrides from ~/.claude/settings.json.
1753
+ *
1754
+ * These env vars were set manually during the Opus 4.6 era to force max
1755
+ * effort and disable adaptive thinking. With Opus 4.7+ the model picks
1756
+ * sensible defaults on its own and these forced overrides interfere with
1757
+ * adaptive behavior. They were never deployed by a template, so there is
1758
+ * no source-of-truth to fix ... the only place they exist is the user's
1759
+ * deployed settings.json.
1760
+ *
1761
+ * Idempotent: removes only the listed STALE_ENV_KEYS if present, drops
1762
+ * the env block entirely if it becomes empty, preserves any other env
1763
+ * keys untouched, silent no-op if nothing to remove.
1764
+ *
1765
+ * Adding more obsolete env keys to STALE_ENV_KEYS is the maintenance path
1766
+ * if other forced overrides need cleanup later.
1767
+ */
1768
+ function cleanupStaleClaudeCodeEnv() {
1769
+ const settingsPath = join(HOME, '.claude/settings.json');
1770
+ if (!existsSync(settingsPath)) return false;
1771
+
1772
+ const STALE_ENV_KEYS = [
1773
+ 'CLAUDE_CODE_EFFORT_LEVEL',
1774
+ 'CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING',
1775
+ ];
1776
+
1777
+ let settings;
1778
+ try {
1779
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
1780
+ } catch (err) {
1781
+ console.log(` - Could not parse ~/.claude/settings.json: ${err.message}`);
1782
+ return false;
1783
+ }
1784
+
1785
+ if (!settings.env || typeof settings.env !== 'object') return false;
1786
+
1787
+ const removed = [];
1788
+ for (const key of STALE_ENV_KEYS) {
1789
+ if (key in settings.env) {
1790
+ delete settings.env[key];
1791
+ removed.push(key);
1792
+ }
1793
+ }
1794
+
1795
+ if (removed.length === 0) return false;
1796
+
1797
+ if (Object.keys(settings.env).length === 0) {
1798
+ delete settings.env;
1799
+ }
1800
+
1801
+ if (DRY_RUN) {
1802
+ console.log(` [dry run] Would remove stale env keys from ~/.claude/settings.json: ${removed.join(', ')}`);
1803
+ return false;
1804
+ }
1805
+
1806
+ try {
1807
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1808
+ console.log(` + Removed stale env keys from ~/.claude/settings.json: ${removed.join(', ')}`);
1809
+ console.log(` New CC sessions will use Opus 4.7+ default effort and adaptive thinking`);
1810
+ return true;
1811
+ } catch (err) {
1812
+ console.log(` - Could not write ~/.claude/settings.json: ${err.message}`);
1813
+ return false;
1814
+ }
1815
+ }
1816
+
1817
+ // ── 1Password SA token shell profile setup ──
1818
+
1819
+ /**
1820
+ * Ensure the 1Password SA token is exported in the user's shell profile so
1821
+ * Claude Code sessions, MCP servers, cron jobs, and launch agents can all
1822
+ * read secrets on demand via `op` without a biometric popup.
1823
+ *
1824
+ * Background: The op-secrets plugin injects OP_SERVICE_ACCOUNT_TOKEN into
1825
+ * the OpenClaw gateway process env at startup. But processes outside the
1826
+ * gateway's inheritance tree (Claude Code sessions, their hooks, MCPs, cron
1827
+ * jobs) never see it. The cleanest fix is to put it in the user's shell
1828
+ * profile so every shell and every CC session inherits it, and hooks can
1829
+ * then do `op read` on demand to fetch actual API keys. Only the SA token
1830
+ * (the key that unlocks other keys) lands in env; actual API keys stay in
1831
+ * 1Password and are fetched per-process.
1832
+ *
1833
+ * Idempotent. Skips if marker already present. Creates the profile file if
1834
+ * none of the candidates exist.
1835
+ *
1836
+ * See: ai/product/bugs/memory-crystal/2026-04-15--cc-mini--sa-token-env-and-hook-failfast.md
1837
+ */
1838
+ function ensureShellProfileSaToken() {
1839
+ const saTokenPath = join(HOME, '.openclaw/secrets/op-sa-token');
1840
+ if (!existsSync(saTokenPath)) return false;
1841
+
1842
+ const marker = '# LDM OS: 1Password SA token (for headless op CLI lookups)';
1843
+ const block = `\n${marker}\nif [ -f "$HOME/.openclaw/secrets/op-sa-token" ]; then\n export OP_SERVICE_ACCOUNT_TOKEN="$(cat "$HOME/.openclaw/secrets/op-sa-token")"\nfi\n`;
1844
+
1845
+ const shell = process.env.SHELL || '';
1846
+ const isZsh = shell.includes('zsh') || !shell;
1847
+ const candidates = isZsh
1848
+ ? [join(HOME, '.zprofile'), join(HOME, '.zshrc')]
1849
+ : [join(HOME, '.bash_profile'), join(HOME, '.profile'), join(HOME, '.bashrc')];
1850
+
1851
+ let targetPath = candidates.find(p => existsSync(p));
1852
+ if (!targetPath) targetPath = isZsh ? candidates[1] : candidates[0];
1853
+
1854
+ let existing = '';
1855
+ try {
1856
+ if (existsSync(targetPath)) existing = readFileSync(targetPath, 'utf-8');
1857
+ } catch {}
1858
+
1859
+ if (existing.includes(marker)) return false;
1860
+
1861
+ if (DRY_RUN) {
1862
+ console.log(` [dry run] Would append OP_SERVICE_ACCOUNT_TOKEN export to ${targetPath.replace(HOME, '~')}`);
1863
+ return false;
1864
+ }
1865
+
1866
+ try {
1867
+ appendFileSync(targetPath, block);
1868
+ const displayPath = targetPath.replace(HOME, '~');
1869
+ console.log(` + Shell profile updated: appended OP_SERVICE_ACCOUNT_TOKEN export to ${displayPath}`);
1870
+ console.log(` Open a new terminal or run: source ${displayPath}`);
1871
+ return true;
1872
+ } catch (err) {
1873
+ console.log(` - Could not update ${targetPath.replace(HOME, '~')}: ${err.message}`);
1874
+ return false;
1875
+ }
1876
+ }
1877
+
1551
1878
  // ── ldm install (bare): scan system, show real state, update if needed ──
1552
1879
 
1553
1880
  async function cmdInstallCatalog() {
@@ -1604,9 +1931,10 @@ async function cmdInstallCatalog() {
1604
1931
  // in the extension directories. This copies them to both LDM and OpenClaw targets.
1605
1932
  deployBridge();
1606
1933
 
1607
- // Deploy scripts and docs on every install so fixes land without re-init
1934
+ // Deploy scripts, docs, and rules on every install so fixes land without re-init
1608
1935
  deployScripts();
1609
1936
  deployDocs();
1937
+ deployRules();
1610
1938
 
1611
1939
  // Check backup configuration
1612
1940
  checkBackupHealth();
@@ -2377,7 +2705,37 @@ async function cmdInstallCatalog() {
2377
2705
 
2378
2706
  // Sync boot hook from npm package (#49)
2379
2707
  if (syncBootHook()) {
2380
- ok('Boot hook updated (sessions, messages, updates now active)');
2708
+ console.log(' + Boot hook updated (sessions, messages, updates now active)');
2709
+ }
2710
+
2711
+ // Sync inbox-check hook: UserPromptSubmit hook that surfaces pending
2712
+ // bridge messages into CC context on every prompt. Closes the gap
2713
+ // between lesa-bridge writes and CC delivery.
2714
+ if (syncInboxCheckHook()) {
2715
+ console.log(' + Inbox-check hook updated (bridge messages surface automatically)');
2716
+ }
2717
+
2718
+ // Sync inbox-rewake hook: Stop hook with asyncRewake that watches
2719
+ // ~/.ldm/messages/ in the background and wakes the model when a new
2720
+ // bridge message arrives, without requiring user interaction. Layer 1
2721
+ // of the April 11 autonomous-push-architecture plan.
2722
+ if (syncInboxRewakeHook()) {
2723
+ console.log(' + Inbox-rewake hook updated (autonomous push: wakes on new bridge message)');
2724
+ }
2725
+
2726
+ // Ensure 1Password SA token is exported in shell profile so Claude Code
2727
+ // sessions, MCPs, hooks, cron jobs all inherit it and can op read secrets
2728
+ // on demand. Idempotent; no-op if the export line is already present.
2729
+ ensureShellProfileSaToken();
2730
+
2731
+ // Deploy git pre-commit hook on every install (not just init)
2732
+ const hooksDir = join(LDM_ROOT, 'hooks');
2733
+ const preCommitDest = join(hooksDir, 'pre-commit');
2734
+ const preCommitSrc = join(__dirname, '..', 'templates', 'hooks', 'pre-commit');
2735
+ if (existsSync(preCommitSrc)) {
2736
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
2737
+ cpSync(preCommitSrc, preCommitDest);
2738
+ chmodSync(preCommitDest, 0o755);
2381
2739
  }
2382
2740
 
2383
2741
  console.log('');
@@ -2515,6 +2873,11 @@ async function cmdDoctor() {
2515
2873
  }
2516
2874
  }
2517
2875
 
2876
+ // --fix: clean stale Claude Code env overrides (Opus 4.6 era) from ~/.claude/settings.json
2877
+ if (FIX_FLAG) {
2878
+ cleanupStaleClaudeCodeEnv();
2879
+ }
2880
+
2518
2881
  // 4. Check sacred locations
2519
2882
  const sacred = [
2520
2883
  { path: join(LDM_ROOT, 'memory'), label: 'memory/' },
@@ -3865,6 +4228,145 @@ async function main() {
3865
4228
  process.exit(1);
3866
4229
  }
3867
4230
 
4231
+ // ── ldm pair ────────────────────────────────────────────────────────
4232
+ // Device pairing for Bridge Phase A.
4233
+ // Links this machine to the user's Kaleidoscope account via passkey.
4234
+ //
4235
+ // Flow:
4236
+ // 1. Generate a human-readable code (BLUE-FISH-4729)
4237
+ // 2. POST the code to wip.computer/api/pair/request
4238
+ // 3. User goes to wip.computer/pair on their phone, signs in with passkey, enters code
4239
+ // 4. Poll GET /api/pair/status?code=X until approved or expired
4240
+ // 5. Store the device token at ~/.ldm/auth/kaleidoscope.json
4241
+ //
4242
+ // The code is shown in the terminal. The user navigates to the pairing
4243
+ // page themselves (CC does NOT open a URL, to prevent phishing).
4244
+ // The code expires after 120 seconds.
4245
+
4246
+ async function cmdPair() {
4247
+ const PAIR_API = process.env.LDM_PAIR_API || 'https://wip.computer';
4248
+ const AUTH_DIR = join(LDM_ROOT, 'auth');
4249
+ const TOKEN_PATH = join(AUTH_DIR, 'kaleidoscope.json');
4250
+
4251
+ // Check if already paired
4252
+ if (existsSync(TOKEN_PATH)) {
4253
+ try {
4254
+ const existing = JSON.parse(readFileSync(TOKEN_PATH, 'utf8'));
4255
+ if (existing.token) {
4256
+ console.log('');
4257
+ console.log(` Already paired as ${existing.userName || 'unknown'}`);
4258
+ console.log(` Paired: ${existing.pairedAt || 'unknown'}`);
4259
+ console.log(` Token: ${existing.token.slice(0, 8)}...`);
4260
+ console.log('');
4261
+ console.log(' To re-pair, delete ~/.ldm/auth/kaleidoscope.json and run ldm pair again.');
4262
+ console.log('');
4263
+ return;
4264
+ }
4265
+ } catch {}
4266
+ }
4267
+
4268
+ // Generate code
4269
+ const words = [
4270
+ 'BLUE', 'RED', 'GREEN', 'GOLD', 'GRAY', 'PINK', 'DARK', 'WARM', 'COLD', 'WILD',
4271
+ 'FISH', 'BIRD', 'WOLF', 'BEAR', 'DEER', 'HAWK', 'FROG', 'LYNX', 'DOVE', 'CROW',
4272
+ ];
4273
+ const w1 = words[Math.floor(Math.random() * 10)];
4274
+ const w2 = words[10 + Math.floor(Math.random() * 10)];
4275
+ const num = String(Math.floor(1000 + Math.random() * 9000));
4276
+ const code = `${w1}-${w2}-${num}`;
4277
+
4278
+ // Detect device name
4279
+ const { hostname } = await import('node:os');
4280
+ const deviceName = hostname() || 'unknown';
4281
+
4282
+ // Read agent ID from config
4283
+ let agentId = 'cc-mini';
4284
+ try {
4285
+ const config = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
4286
+ const agents = config.agents || {};
4287
+ for (const [id, agent] of Object.entries(agents)) {
4288
+ if (agent.harness === 'claude-code') { agentId = id; break; }
4289
+ }
4290
+ } catch {}
4291
+
4292
+ console.log('');
4293
+ console.log(' Pairing code:');
4294
+ console.log('');
4295
+ console.log(` ${code}`);
4296
+ console.log('');
4297
+ console.log(' Go to wip.computer/pair on your phone.');
4298
+ console.log(' Sign in with your passkey. Enter the code.');
4299
+ console.log('');
4300
+ console.log(' Waiting for approval...');
4301
+
4302
+ // Register the code with the server
4303
+ try {
4304
+ const registerRes = await fetch(`${PAIR_API}/api/pair/request`, {
4305
+ method: 'POST',
4306
+ headers: { 'Content-Type': 'application/json' },
4307
+ body: JSON.stringify({ code, deviceName, agentId }),
4308
+ });
4309
+ if (!registerRes.ok) {
4310
+ const err = await registerRes.text();
4311
+ console.error(` x Failed to register pairing code: ${err}`);
4312
+ process.exit(1);
4313
+ }
4314
+ } catch (err) {
4315
+ console.error(` x Cannot reach ${PAIR_API}: ${err.message}`);
4316
+ console.error(' Make sure the server is running.');
4317
+ process.exit(1);
4318
+ }
4319
+
4320
+ // Poll for approval (every 2 seconds, up to 120 seconds)
4321
+ const maxAttempts = 60;
4322
+ for (let i = 0; i < maxAttempts; i++) {
4323
+ await new Promise(r => setTimeout(r, 2000));
4324
+
4325
+ try {
4326
+ const statusRes = await fetch(`${PAIR_API}/api/pair/status?code=${encodeURIComponent(code)}`);
4327
+ const data = await statusRes.json();
4328
+
4329
+ if (data.status === 'approved' && data.token) {
4330
+ // Store token
4331
+ mkdirSync(AUTH_DIR, { recursive: true });
4332
+ writeFileSync(TOKEN_PATH, JSON.stringify({
4333
+ token: data.token,
4334
+ userId: data.userId,
4335
+ userName: data.userName,
4336
+ deviceName,
4337
+ agentId,
4338
+ pairedAt: new Date().toISOString(),
4339
+ server: PAIR_API,
4340
+ }, null, 2) + '\n');
4341
+
4342
+ console.log('');
4343
+ console.log(` ✓ Paired as ${data.userName || 'User'} (${deviceName} / ${agentId})`);
4344
+ console.log(` Token stored at ~/.ldm/auth/kaleidoscope.json`);
4345
+ console.log('');
4346
+ return;
4347
+ }
4348
+
4349
+ if (statusRes.status === 404 || statusRes.status === 410) {
4350
+ console.error('');
4351
+ console.error(' x Code expired. Run ldm pair again.');
4352
+ console.error('');
4353
+ process.exit(1);
4354
+ }
4355
+
4356
+ // Still pending. Keep polling.
4357
+ process.stdout.write('.');
4358
+ } catch {
4359
+ // Network error. Keep trying.
4360
+ process.stdout.write('x');
4361
+ }
4362
+ }
4363
+
4364
+ console.error('');
4365
+ console.error(' x Timed out waiting for approval. Run ldm pair again.');
4366
+ console.error('');
4367
+ process.exit(1);
4368
+ }
4369
+
3868
4370
  if (command === '--version' || command === '-v') {
3869
4371
  console.log(PKG_VERSION);
3870
4372
  process.exit(0);
@@ -3917,6 +4419,9 @@ async function main() {
3917
4419
  case 'backup':
3918
4420
  await cmdBackup();
3919
4421
  break;
4422
+ case 'pair':
4423
+ await cmdPair();
4424
+ break;
3920
4425
  default:
3921
4426
  console.error(` Unknown command: ${command}`);
3922
4427
  console.error(` Run: ldm --help`);