@wipcomputer/wip-ldm-os 0.4.75-alpha.1 → 0.4.77

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 (3) hide show
  1. package/SKILL.md +1 -1
  2. package/lib/deploy.mjs +112 -28
  3. package/package.json +1 -1
package/SKILL.md CHANGED
@@ -9,7 +9,7 @@ license: MIT
9
9
  compatibility: Requires git, npm, node. Node.js 18+.
10
10
  metadata:
11
11
  display-name: "LDM OS"
12
- version: "0.4.74"
12
+ version: "0.4.77"
13
13
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
14
14
  author: "Parker Todd Brooks"
15
15
  category: infrastructure
package/lib/deploy.mjs CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  lstatSync, readlinkSync, unlinkSync, chmodSync, readdirSync,
15
15
  renameSync, rmSync, statSync, symlinkSync,
16
16
  } from 'node:fs';
17
+ import { createHash } from 'node:crypto';
17
18
  import { join, basename, resolve, dirname } from 'node:path';
18
19
  import { tmpdir } from 'node:os';
19
20
  import { detectInterfaces, describeInterfaces, detectToolbox } from './detect.mjs';
@@ -750,6 +751,45 @@ function installCLI(repoPath, door) {
750
751
  }
751
752
  }
752
753
 
754
+ // Stable content hash over a directory tree. Used by deployExtension to
755
+ // decide whether to skip a redeploy. Before this, deployExtension skipped
756
+ // when source and deployed had equal versions ... but a prior partial
757
+ // install could have bumped the package.json without finishing the copy,
758
+ // leaving deployed package.json "current" while other files (guard.mjs,
759
+ // core.mjs, etc.) were stale. The 2026-04-20 wip-release 1.9.74 -> 1.9.75
760
+ // rollout hit that (source core.mjs had runNpmPublish, deployed didn't,
761
+ // both package.jsons reported 1.9.75). Skip only when versions match AND
762
+ // content hashes match; otherwise redeploy to heal the drift.
763
+ function computeTreeHash(dir) {
764
+ if (!existsSync(dir)) return null;
765
+ const skipNames = new Set([
766
+ '.git', 'node_modules', 'ai', '_trash', '.worktrees', 'logs', 'test', 'tests', '__tests__',
767
+ ]);
768
+ const hash = createHash('sha256');
769
+ function walk(d, rel) {
770
+ let entries;
771
+ try { entries = readdirSync(d, { withFileTypes: true }); }
772
+ catch { return; }
773
+ entries.sort((a, b) => a.name.localeCompare(b.name));
774
+ for (const e of entries) {
775
+ if (skipNames.has(e.name)) continue;
776
+ const p = join(d, e.name);
777
+ const r = rel ? `${rel}/${e.name}` : e.name;
778
+ if (e.isDirectory()) walk(p, r);
779
+ else if (e.isFile()) {
780
+ try {
781
+ hash.update(r);
782
+ hash.update('\0');
783
+ hash.update(readFileSync(p));
784
+ hash.update('\0');
785
+ } catch {}
786
+ }
787
+ }
788
+ }
789
+ walk(dir, '');
790
+ return hash.digest('hex');
791
+ }
792
+
753
793
  function deployExtension(repoPath, name) {
754
794
  const sourcePkg = readJSON(join(repoPath, 'package.json'));
755
795
  const ldmDest = join(LDM_EXTENSIONS, name);
@@ -759,18 +799,28 @@ function deployExtension(repoPath, name) {
759
799
 
760
800
  const cmp = compareSemver(newVersion, currentVersion);
761
801
  if (newVersion && currentVersion && cmp <= 0) {
762
- skip(`LDM: ${name} already at v${currentVersion}${cmp < 0 ? ` (source is older: v${newVersion})` : ''}`);
763
- // Ensure OC copy exists too
764
- const ocName = resolveOcPluginName(repoPath, name);
765
- const ocDest = join(OC_EXTENSIONS, ocName);
766
- if (!existsSync(ocDest) && !DRY_RUN) {
767
- mkdirSync(ocDest, { recursive: true });
768
- cpSync(ldmDest, ocDest, { recursive: true });
769
- ok(`OpenClaw: synced to ${ocDest}`);
770
- } else {
771
- skip(`OpenClaw: ${ocName} already at v${currentVersion}`);
802
+ // Versions equal or deployed is newer. Verify content hash before
803
+ // short-circuiting ... a prior partial install could have bumped
804
+ // package.json but not copied the other files, leaving deployed
805
+ // apparently "current" while code is stale.
806
+ const srcHash = computeTreeHash(repoPath);
807
+ const dstHash = computeTreeHash(ldmDest);
808
+ if (srcHash && dstHash && srcHash === dstHash) {
809
+ skip(`LDM: ${name} already at v${currentVersion}${cmp < 0 ? ` (source is older: v${newVersion})` : ''}`);
810
+ // Ensure OC copy exists too
811
+ const ocName = resolveOcPluginName(repoPath, name);
812
+ const ocDest = join(OC_EXTENSIONS, ocName);
813
+ if (!existsSync(ocDest) && !DRY_RUN) {
814
+ mkdirSync(ocDest, { recursive: true });
815
+ cpSync(ldmDest, ocDest, { recursive: true });
816
+ ok(`OpenClaw: synced to ${ocDest}`);
817
+ } else {
818
+ skip(`OpenClaw: ${ocName} already at v${currentVersion}`);
819
+ }
820
+ return true;
772
821
  }
773
- return true;
822
+ // Content differs despite matching version; fall through to redeploy.
823
+ ok(`LDM: ${name} v${currentVersion} reports same version but content differs; redeploying`);
774
824
  }
775
825
 
776
826
  if (DRY_RUN) {
@@ -940,7 +990,16 @@ function installClaudeCodeHookEvent(repoPath, door) {
940
990
  // Deploy guard.mjs to ~/.ldm/extensions/{toolName}/ (#85: always update, not just when missing)
941
991
  // Idempotent across multi-door invocations: two doors on the same repo
942
992
  // will both trigger this copy, which is a filesystem no-op after the first.
993
+ //
994
+ // Also recursively copy sibling source subdirectories (e.g. lib/, dist/).
995
+ // Historical behavior only copied guard.mjs + package.json at the root,
996
+ // so a guard.mjs whose imports referenced ./lib/*.mjs loaded fine from
997
+ // source but broke post-install with ERR_MODULE_NOT_FOUND. This caused
998
+ // the wip-branch-guard 1.9.77 incident on 2026-04-20.
943
999
  const srcGuard = join(repoPath, 'guard.mjs');
1000
+ const SKIP_DIRS_FOR_HOOK = new Set([
1001
+ '.git', 'node_modules', 'ai', '_trash', '.worktrees', 'logs', 'test', 'tests', '__tests__',
1002
+ ]);
944
1003
  if (existsSync(srcGuard)) {
945
1004
  try {
946
1005
  if (!existsSync(extDir)) mkdirSync(extDir, { recursive: true });
@@ -948,6 +1007,14 @@ function installClaudeCodeHookEvent(repoPath, door) {
948
1007
  // Also copy package.json for metadata
949
1008
  const srcPkg = join(repoPath, 'package.json');
950
1009
  if (existsSync(srcPkg)) copyFileSync(srcPkg, join(extDir, 'package.json'));
1010
+ // Recurse sibling subdirs so nested imports (e.g. ./lib/foo.mjs) load.
1011
+ for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
1012
+ if (!entry.isDirectory()) continue;
1013
+ if (SKIP_DIRS_FOR_HOOK.has(entry.name)) continue;
1014
+ const srcDir = join(repoPath, entry.name);
1015
+ const destDir = join(extDir, entry.name);
1016
+ try { cpSync(srcDir, destDir, { recursive: true }); } catch {}
1017
+ }
951
1018
  } catch (e) {
952
1019
  // Non-fatal: fall back to source path
953
1020
  }
@@ -967,32 +1034,49 @@ function installClaudeCodeHookEvent(repoPath, door) {
967
1034
  if (!settings.hooks) settings.hooks = {};
968
1035
  if (!settings.hooks[event]) settings.hooks[event] = [];
969
1036
 
970
- // Match existing entries by the guard command path + the matcher, so that
971
- // a single extension registering on multiple events (each with its own
972
- // matcher) creates one entry per event rather than all entries colliding
973
- // on the same hook slot. Before this change the existing-entry check was
974
- // per-extension, not per-extension-per-event.
1037
+ // Match existing entries by command path alone (same extension + same
1038
+ // event). The previous finder required matcher equality too, so when
1039
+ // an extension bumped its matcher (e.g. wip-branch-guard 1.9.78 -> 1.9.79
1040
+ // added Read|Glob to enable onboarding), the finder missed the old entry
1041
+ // and appended a new one, leaving an orphaned old matcher in settings
1042
+ // and doubling hook invocations on overlapping matchers.
1043
+ //
1044
+ // Now: find by extension dir in the command. Update matcher + command +
1045
+ // timeout in place. First pass removes any DUPLICATE entries for the same
1046
+ // extension in this event slot (orphan cleanup; catches post-1.9.78
1047
+ // duplicates on users who already installed the broken version).
975
1048
  const doorMatcher = door.matcher || undefined;
976
- const existingIdx = settings.hooks[event].findIndex(entry => {
977
- const sameMatcher = (entry.matcher || undefined) === doorMatcher;
978
- if (!sameMatcher) return false;
979
- return entry.hooks?.some(h => {
980
- const cmd = h.command || '';
981
- return cmd.includes(`/${toolName}/`) || cmd === hookCommand;
982
- });
1049
+ const toolTag = `/${toolName}/`;
1050
+ const ownedIdxs = [];
1051
+ settings.hooks[event].forEach((entry, i) => {
1052
+ const hooks = entry.hooks || [];
1053
+ if (hooks.some(h => (h.command || '').includes(toolTag))) ownedIdxs.push(i);
983
1054
  });
1055
+ let removed = 0;
1056
+ if (ownedIdxs.length > 1) {
1057
+ // Keep the first, remove the rest. Walk right-to-left so earlier indices stay valid.
1058
+ for (let j = ownedIdxs.length - 1; j >= 1; j--) {
1059
+ settings.hooks[event].splice(ownedIdxs[j], 1);
1060
+ removed++;
1061
+ }
1062
+ }
1063
+ const existingIdx = ownedIdxs.length > 0 ? ownedIdxs[0] : -1;
984
1064
 
985
1065
  if (existingIdx !== -1) {
986
- const existingCmd = settings.hooks[event][existingIdx].hooks?.[0]?.command || '';
987
- if (existingCmd === hookCommand) {
1066
+ const existingEntry = settings.hooks[event][existingIdx];
1067
+ const existingCmd = existingEntry.hooks?.[0]?.command || '';
1068
+ const existingMatcher = existingEntry.matcher || undefined;
1069
+ if (existingCmd === hookCommand && existingMatcher === doorMatcher && removed === 0) {
988
1070
  skip(`Claude Code: ${event} hook already configured`);
989
1071
  return true;
990
1072
  }
991
- settings.hooks[event][existingIdx].hooks[0].command = hookCommand;
992
- settings.hooks[event][existingIdx].hooks[0].timeout = door.timeout || 10;
1073
+ existingEntry.matcher = doorMatcher;
1074
+ existingEntry.hooks[0].command = hookCommand;
1075
+ existingEntry.hooks[0].timeout = door.timeout || 10;
993
1076
  try {
994
1077
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
995
- ok(`Claude Code: ${event} hook updated`);
1078
+ const note = removed > 0 ? ` (removed ${removed} orphan entr${removed === 1 ? 'y' : 'ies'})` : '';
1079
+ ok(`Claude Code: ${event} hook updated${note}`);
996
1080
  return true;
997
1081
  } catch (e) {
998
1082
  fail(`Claude Code: failed to update settings.json. ${e.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.75-alpha.1",
3
+ "version": "0.4.77",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {