@pcircle/memesh 4.8.2 → 4.8.5

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 (169) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.codex-plugin/mcp.json +7 -0
  4. package/.codex-plugin/plugin.json +6 -0
  5. package/AGENTS.md +18 -8
  6. package/README.de.md +13 -11
  7. package/README.md +25 -12
  8. package/README.zh-TW.md +25 -12
  9. package/dashboard/dist/index.html +3 -3
  10. package/dist/core/agent-message-inbox.d.ts +3 -2
  11. package/dist/core/agent-message-inbox.d.ts.map +1 -1
  12. package/dist/core/agent-message-inbox.js +30 -6
  13. package/dist/core/agent-message-inbox.js.map +1 -1
  14. package/dist/core/agent-messaging.d.ts +7 -0
  15. package/dist/core/agent-messaging.d.ts.map +1 -1
  16. package/dist/core/agent-messaging.js +51 -24
  17. package/dist/core/agent-messaging.js.map +1 -1
  18. package/dist/core/agent-router.d.ts +27 -20
  19. package/dist/core/agent-router.d.ts.map +1 -1
  20. package/dist/core/agent-router.js +114 -43
  21. package/dist/core/agent-router.js.map +1 -1
  22. package/dist/core/agent-scope-id.d.ts +11 -0
  23. package/dist/core/agent-scope-id.d.ts.map +1 -0
  24. package/dist/core/agent-scope-id.js +40 -0
  25. package/dist/core/agent-scope-id.js.map +1 -0
  26. package/dist/core/briefing.d.ts +1 -1
  27. package/dist/core/briefing.d.ts.map +1 -1
  28. package/dist/core/briefing.js +25 -7
  29. package/dist/core/briefing.js.map +1 -1
  30. package/dist/core/citation-rule.d.ts.map +1 -1
  31. package/dist/core/citation-rule.js +5 -2
  32. package/dist/core/citation-rule.js.map +1 -1
  33. package/dist/core/doctor.d.ts +9 -0
  34. package/dist/core/doctor.d.ts.map +1 -1
  35. package/dist/core/doctor.js +461 -24
  36. package/dist/core/doctor.js.map +1 -1
  37. package/dist/core/dreamer.d.ts.map +1 -1
  38. package/dist/core/dreamer.js +3 -1
  39. package/dist/core/dreamer.js.map +1 -1
  40. package/dist/core/embedder.d.ts.map +1 -1
  41. package/dist/core/embedder.js +4 -1
  42. package/dist/core/embedder.js.map +1 -1
  43. package/dist/core/install-channel.d.ts +3 -0
  44. package/dist/core/install-channel.d.ts.map +1 -1
  45. package/dist/core/install-channel.js +79 -2
  46. package/dist/core/install-channel.js.map +1 -1
  47. package/dist/core/install-hooks.d.ts.map +1 -1
  48. package/dist/core/install-hooks.js +10 -6
  49. package/dist/core/install-hooks.js.map +1 -1
  50. package/dist/core/lesson-slug.d.ts.map +1 -1
  51. package/dist/core/lesson-slug.js +6 -4
  52. package/dist/core/lesson-slug.js.map +1 -1
  53. package/dist/core/lifecycle.d.ts.map +1 -1
  54. package/dist/core/lifecycle.js +8 -5
  55. package/dist/core/lifecycle.js.map +1 -1
  56. package/dist/core/llm-client.d.ts.map +1 -1
  57. package/dist/core/llm-client.js +4 -1
  58. package/dist/core/llm-client.js.map +1 -1
  59. package/dist/core/llm-validator.d.ts.map +1 -1
  60. package/dist/core/llm-validator.js +6 -15
  61. package/dist/core/llm-validator.js.map +1 -1
  62. package/dist/core/memory-tool.d.ts.map +1 -1
  63. package/dist/core/memory-tool.js +3 -1
  64. package/dist/core/memory-tool.js.map +1 -1
  65. package/dist/core/ollama-host.d.ts +6 -0
  66. package/dist/core/ollama-host.d.ts.map +1 -0
  67. package/dist/core/ollama-host.js +62 -0
  68. package/dist/core/ollama-host.js.map +1 -0
  69. package/dist/core/operations.d.ts +1 -1
  70. package/dist/core/operations.d.ts.map +1 -1
  71. package/dist/core/operations.js +1 -1
  72. package/dist/core/operations.js.map +1 -1
  73. package/dist/core/project-tags.d.ts +2 -0
  74. package/dist/core/project-tags.d.ts.map +1 -1
  75. package/dist/core/project-tags.js +29 -1
  76. package/dist/core/project-tags.js.map +1 -1
  77. package/dist/core/schema-export.d.ts.map +1 -1
  78. package/dist/core/schema-export.js +10 -8
  79. package/dist/core/schema-export.js.map +1 -1
  80. package/dist/core/version-check.d.ts +1 -0
  81. package/dist/core/version-check.d.ts.map +1 -1
  82. package/dist/core/version-check.js +35 -0
  83. package/dist/core/version-check.js.map +1 -1
  84. package/dist/core/work-topology.d.ts +3 -0
  85. package/dist/core/work-topology.d.ts.map +1 -1
  86. package/dist/core/work-topology.js +24 -2
  87. package/dist/core/work-topology.js.map +1 -1
  88. package/dist/db.d.ts.map +1 -1
  89. package/dist/db.js +5 -2
  90. package/dist/db.js.map +1 -1
  91. package/dist/host-adapters/claude-channel.d.ts +0 -58
  92. package/dist/host-adapters/claude-channel.d.ts.map +1 -1
  93. package/dist/host-adapters/claude-channel.js +0 -242
  94. package/dist/host-adapters/claude-channel.js.map +1 -1
  95. package/dist/host-adapters/codex-cli-queue.d.ts.map +1 -1
  96. package/dist/host-adapters/codex-cli-queue.js +15 -12
  97. package/dist/host-adapters/codex-cli-queue.js.map +1 -1
  98. package/dist/host-runtime/acp.d.ts +2 -0
  99. package/dist/host-runtime/acp.d.ts.map +1 -1
  100. package/dist/host-runtime/acp.js +6 -1
  101. package/dist/host-runtime/acp.js.map +1 -1
  102. package/dist/host-runtime/claude.d.ts +2 -0
  103. package/dist/host-runtime/claude.d.ts.map +1 -1
  104. package/dist/host-runtime/claude.js +12 -13
  105. package/dist/host-runtime/claude.js.map +1 -1
  106. package/dist/host-runtime/codex-session.d.ts +3 -1
  107. package/dist/host-runtime/codex-session.d.ts.map +1 -1
  108. package/dist/host-runtime/codex-session.js +7 -5
  109. package/dist/host-runtime/codex-session.js.map +1 -1
  110. package/dist/host-runtime/codex.d.ts +2 -0
  111. package/dist/host-runtime/codex.d.ts.map +1 -1
  112. package/dist/host-runtime/codex.js +6 -1
  113. package/dist/host-runtime/codex.js.map +1 -1
  114. package/dist/host-runtime/entry.d.ts +5 -0
  115. package/dist/host-runtime/entry.d.ts.map +1 -0
  116. package/dist/host-runtime/entry.js +11 -0
  117. package/dist/host-runtime/entry.js.map +1 -0
  118. package/dist/host-runtime/router-client.d.ts +2 -0
  119. package/dist/host-runtime/router-client.d.ts.map +1 -1
  120. package/dist/host-runtime/router-client.js.map +1 -1
  121. package/dist/knowledge-graph.d.ts +0 -1
  122. package/dist/knowledge-graph.d.ts.map +1 -1
  123. package/dist/knowledge-graph.js +27 -19
  124. package/dist/knowledge-graph.js.map +1 -1
  125. package/dist/skills-manifest.json +40 -25
  126. package/dist/storage/entity-index.d.ts +4 -0
  127. package/dist/storage/entity-index.d.ts.map +1 -0
  128. package/dist/storage/entity-index.js +15 -0
  129. package/dist/storage/entity-index.js.map +1 -0
  130. package/dist/storage/fts-index.d.ts.map +1 -1
  131. package/dist/storage/fts-index.js +5 -0
  132. package/dist/storage/fts-index.js.map +1 -1
  133. package/dist/storage/graph-repairs.d.ts +11 -1
  134. package/dist/storage/graph-repairs.d.ts.map +1 -1
  135. package/dist/storage/graph-repairs.js +241 -30
  136. package/dist/storage/graph-repairs.js.map +1 -1
  137. package/dist/transports/agent-messaging.d.ts +10 -1
  138. package/dist/transports/agent-messaging.d.ts.map +1 -1
  139. package/dist/transports/agent-messaging.js +92 -9
  140. package/dist/transports/agent-messaging.js.map +1 -1
  141. package/dist/transports/cli/cli.d.ts +4 -0
  142. package/dist/transports/cli/cli.d.ts.map +1 -1
  143. package/dist/transports/cli/cli.js +88 -38
  144. package/dist/transports/cli/cli.js.map +1 -1
  145. package/dist/transports/mcp/handlers.d.ts +12 -8
  146. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  147. package/dist/transports/mcp/handlers.js +23 -11
  148. package/dist/transports/mcp/handlers.js.map +1 -1
  149. package/dist/transports/schemas.d.ts +22 -17
  150. package/dist/transports/schemas.d.ts.map +1 -1
  151. package/dist/transports/schemas.js +25 -11
  152. package/dist/transports/schemas.js.map +1 -1
  153. package/docs/platforms/README.md +2 -2
  154. package/docs/platforms/agent-messaging.md +246 -36
  155. package/hooks/hooks.json +10 -0
  156. package/llms-install.md +29 -6
  157. package/package.json +13 -5
  158. package/scripts/hooks/_generated/agent-message-inbox.js +30 -6
  159. package/scripts/hooks/_generated/citation-rule.js +5 -2
  160. package/scripts/hooks/_generated/fts-index.js +5 -0
  161. package/scripts/hooks/_generated/work-topology.js +24 -2
  162. package/scripts/hooks/_shared.js +52 -4
  163. package/scripts/hooks/decision-nudge.js +152 -0
  164. package/scripts/hooks/post-commit.js +11 -0
  165. package/scripts/hooks/pre-compact.js +12 -4
  166. package/scripts/hooks/session-start.js +61 -26
  167. package/scripts/upgrade-plugin.sh +600 -81
  168. package/skills/memesh/SKILL.md +24 -5
  169. /package/{.mcp.json → .claude-plugin/mcp.json} +0 -0
@@ -5,14 +5,22 @@
5
5
  # bash scripts/upgrade-plugin.sh
6
6
  #
7
7
  # What it does:
8
- # 1. Fast-forwards the marketplace cache (~/.claude/plugins/marketplaces/pcircle-memesh)
8
+ # 1. Fast-forwards the marketplace cache under
9
+ # ${CLAUDE_CONFIG_DIR:-~/.claude}/plugins/marketplaces/pcircle-memesh
9
10
  # against origin.
10
- # 2. Reads the new version from .claude-plugin/marketplace.json.
11
- # 3. Stages a new install cache at ~/.claude/plugins/cache/pcircle-memesh/memesh/<new-version>/.
11
+ # 2. Reads the new version from .claude-plugin/marketplace.json at the exact
12
+ # marketplace commit that will be installed.
13
+ # 3. Stages a new install cache under the same Claude config root at
14
+ # plugins/cache/pcircle-memesh/memesh/<new-version>/,
15
+ # from `git archive` of the exact recorded commit (never the marketplace
16
+ # checkout's working tree, which can hold uncommitted or tampered files).
12
17
  # 4. Installs runtime deps inside that cache (npm install --omit=dev).
13
- # 5. Patches ~/.claude/plugins/installed_plugins.json to point at the
14
- # new version + path.
15
- # 6. Leaves the previous version on disk (you can delete it manually if you want).
18
+ # 5. Patches plugins/installed_plugins.json under that config root to point at the
19
+ # new version + path, rolling the cache swap back if the write fails,
20
+ # the registry changed underneath it, or a signal interrupts the swap.
21
+ # A same-version refresh (marketplace moved, version did not) is staged next
22
+ # to the live cache and swapped in only after npm install succeeded.
23
+ # The whole run holds a lock directory so two invocations cannot race.
16
24
  #
17
25
  # Why this exists:
18
26
  # Claude Code's plugin marketplace pins versions at install time and
@@ -22,9 +30,67 @@
22
30
 
23
31
  set -uo pipefail
24
32
 
25
- MARKETPLACE_DIR="$HOME/.claude/plugins/marketplaces/pcircle-memesh"
26
- INSTALL_REGISTRY="$HOME/.claude/plugins/installed_plugins.json"
27
- CACHE_ROOT="$HOME/.claude/plugins/cache/pcircle-memesh/memesh"
33
+ # D9: removes every OTHER stale version directory under $root, not just the
34
+ # one an upgrade just swapped out. Before the atomic-swap rename elsewhere in
35
+ # this script, an interrupted or pre-this-mechanism upgrade could leave a
36
+ # version directory behind with nothing left to remove it later — measured
37
+ # on a real machine: 9 old version directories, 1.2 GB, accumulated with no
38
+ # bound. Only entries whose full name is exactly `<major>.<minor>.<patch>`
39
+ # are touched, so a `.staging-*`/`.previous-*` marker from a genuinely
40
+ # concurrent run (should be impossible under $LOCK_DIR, but this check does
41
+ # not rely on that) or anything else unexpected under $root is left alone.
42
+ # $keep_version is always excluded; an optional $also_keep protects a second
43
+ # name — the registry's OWN recorded install path (however it is spelled),
44
+ # so the noncanonical-path repair in section 2 below can keep leaving that
45
+ # one directory alone for a human to clean up, exactly as it already did
46
+ # before this function existed.
47
+ #
48
+ # Defined this early, and callable on its own, so
49
+ # `tests/upgrade-plugin-cache-sweep.test.ts` can source this file with
50
+ # MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY=1 (below) and call it directly against a
51
+ # throwaway root — testing the shipped function, not a hand-copied
52
+ # reimplementation of it — without running the rest of this script, which
53
+ # talks to a real Claude Code marketplace checkout.
54
+ sweep_stale_cache_versions() {
55
+ local root="$1" keep_version="$2" also_keep="${3:-}" entry name
56
+ [ -d "$root" ] || return 0
57
+ for entry in "$root"/*; do
58
+ [ -d "$entry" ] || continue
59
+ name="$(basename "$entry")"
60
+ [ "$name" = "$keep_version" ] && continue
61
+ [ -n "$also_keep" ] && [ "$name" = "$also_keep" ] && continue
62
+ if printf '%s' "$name" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
63
+ rm -rf "$entry" 2>/dev/null
64
+ if [ -e "$entry" ]; then
65
+ echo "WARNING: could not remove stale cached version at $entry — remove it manually." >&2
66
+ fi
67
+ fi
68
+ done
69
+ }
70
+
71
+ # Test-only escape hatch: source this file with this variable set to load
72
+ # `sweep_stale_cache_versions` (and any other function defined above this
73
+ # guard) without running the rest of the script. `return` exits a sourced
74
+ # file without killing the parent shell; `|| exit 0` is the fallback for the
75
+ # (unsupported, but harmless to guard) case of someone executing the script
76
+ # directly with the variable set.
77
+ if [ "${MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY:-0}" = "1" ]; then
78
+ return 0 2>/dev/null || exit 0
79
+ fi
80
+
81
+ if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
82
+ CLAUDE_CONFIG_ROOT="$CLAUDE_CONFIG_DIR"
83
+ elif [ -n "${HOME:-}" ]; then
84
+ CLAUDE_CONFIG_ROOT="$HOME/.claude"
85
+ else
86
+ echo "ERROR: neither CLAUDE_CONFIG_DIR nor HOME is set, so the Claude Code config root cannot be resolved safely." >&2
87
+ echo " Set CLAUDE_CONFIG_DIR to the active Claude Code config directory, then retry." >&2
88
+ exit 1
89
+ fi
90
+ MARKETPLACE_DIR="$CLAUDE_CONFIG_ROOT/plugins/marketplaces/pcircle-memesh"
91
+ INSTALL_REGISTRY="$CLAUDE_CONFIG_ROOT/plugins/installed_plugins.json"
92
+ CACHE_ROOT="$CLAUDE_CONFIG_ROOT/plugins/cache/pcircle-memesh/memesh"
93
+ LOCK_DIR="$CACHE_ROOT.lock"
28
94
 
29
95
  # ─── Pre-flight ────────────────────────────────────────────────────────────
30
96
  if [ ! -d "$MARKETPLACE_DIR" ]; then
@@ -32,6 +98,11 @@ if [ ! -d "$MARKETPLACE_DIR" ]; then
32
98
  echo " Install the plugin from the Claude Code /plugin UI first." >&2
33
99
  exit 1
34
100
  fi
101
+ if [ -L "$INSTALL_REGISTRY" ]; then
102
+ echo "ERROR: installed_plugins.json is a symlink at $INSTALL_REGISTRY — refusing to replace a host-owned link." >&2
103
+ echo " Restore a regular Claude Code registry file, then retry." >&2
104
+ exit 1
105
+ fi
35
106
  if [ ! -f "$INSTALL_REGISTRY" ]; then
36
107
  echo "ERROR: installed_plugins.json not found at $INSTALL_REGISTRY" >&2
37
108
  echo " Install the plugin from the Claude Code /plugin UI first." >&2
@@ -45,134 +116,582 @@ if ! command -v npm >/dev/null 2>&1; then
45
116
  echo "ERROR: npm is not on PATH (needed to install runtime deps)." >&2
46
117
  exit 1
47
118
  fi
48
- if ! command -v rsync >/dev/null 2>&1; then
49
- echo "ERROR: rsync is not on PATH." >&2
119
+ if ! command -v git >/dev/null 2>&1; then
120
+ echo "ERROR: git is not on PATH (needed to fetch and archive the marketplace commit)." >&2
121
+ exit 1
122
+ fi
123
+ if ! command -v tar >/dev/null 2>&1; then
124
+ echo "ERROR: tar is not on PATH (needed to unpack the exact marketplace commit)." >&2
125
+ exit 1
126
+ fi
127
+
128
+ # ─── Lock: one upgrade at a time ───────────────────────────────────────────
129
+ # `mkdir` is atomic on POSIX. Refuse immediately instead of waiting on an
130
+ # ownerless directory whose process identity cannot be proven.
131
+ STAGE_PATH=""
132
+ PREVIOUS_PATH=""
133
+ NEW_INSTALL_PATH=""
134
+ HAD_LIVE_CACHE=0
135
+ SWAP_PENDING=0
136
+ LOCK_HELD=0
137
+ cleanup_on_exit() {
138
+ exit_status=$?
139
+ trap - EXIT INT TERM HUP
140
+ if [ "$SWAP_PENDING" = 1 ]; then
141
+ should_rollback=0
142
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
143
+ should_rollback=1
144
+ elif [ "$HAD_LIVE_CACHE" = 0 ] && { [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; }; then
145
+ should_rollback=1
146
+ fi
147
+ if [ "$should_rollback" = 1 ]; then
148
+ echo "ERROR: upgrade interrupted during the cache swap — restoring the previous state." >&2
149
+ SWAP_PENDING=0
150
+ if ! rollback_swap; then
151
+ echo "ERROR: automatic rollback failed; follow the recovery instructions above." >&2
152
+ fi
153
+ fi
154
+ fi
155
+ [ -n "$STAGE_PATH" ] && rm -rf "$STAGE_PATH"
156
+ [ "$LOCK_HELD" = 1 ] && rmdir "$LOCK_DIR" 2>/dev/null
157
+ exit "$exit_status"
158
+ }
159
+ handle_signal() { exit "$1"; }
160
+ install_signal_traps() {
161
+ trap 'handle_signal 130' INT
162
+ trap 'handle_signal 143' TERM
163
+ trap 'handle_signal 129' HUP
164
+ }
165
+ trap cleanup_on_exit EXIT
166
+ install_signal_traps
167
+
168
+ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
169
+ if [ -d "$LOCK_DIR" ]; then
170
+ echo "ERROR: could not acquire the upgrade lock at $LOCK_DIR — another upgrade may be running." >&2
171
+ echo " If nothing else is actually running (a previous run crashed and left the lock behind): rmdir \"$LOCK_DIR\"" >&2
172
+ else
173
+ echo "ERROR: could not create the upgrade lock at $LOCK_DIR." >&2
174
+ echo " Its parent must exist and be writable: ${LOCK_DIR%/*}" >&2
175
+ fi
50
176
  exit 1
51
177
  fi
178
+ LOCK_HELD=1
52
179
 
53
180
  # ─── 1. Refresh marketplace cache ─────────────────────────────────────────
54
181
  echo "==> Fetching latest from marketplace origin..."
55
182
  (
56
183
  cd "$MARKETPLACE_DIR" || exit 1
57
- CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)"
58
- git fetch origin "$CURRENT_BRANCH" --quiet || {
184
+ CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null)" || {
185
+ echo "ERROR: marketplace cache is on a detached HEAD — refusing to guess which branch to update." >&2
186
+ exit 1
187
+ }
188
+ git fetch --quiet origin "refs/heads/$CURRENT_BRANCH:refs/remotes/origin/$CURRENT_BRANCH" || {
59
189
  echo "ERROR: git fetch failed in $MARKETPLACE_DIR" >&2
60
190
  exit 1
61
191
  }
62
- git merge --ff-only "origin/$CURRENT_BRANCH" --quiet || {
63
- echo "ERROR: marketplace cache has local commits refusing fast-forward." >&2
64
- echo " Reset it: cd $MARKETPLACE_DIR && git reset --hard origin/$CURRENT_BRANCH" >&2
192
+ MERGE_ERROR="$(git merge --ff-only "origin/$CURRENT_BRANCH" --quiet 2>&1)" || {
193
+ AHEAD_COUNT="$(git rev-list --count "origin/$CURRENT_BRANCH..HEAD" 2>/dev/null || echo unknown)"
194
+ if [[ "$AHEAD_COUNT" =~ ^[0-9]+$ ]] && [ "$AHEAD_COUNT" -gt 0 ]; then
195
+ echo "ERROR: marketplace cache has $AHEAD_COUNT local commit(s), so it cannot fast-forward." >&2
196
+ echo " Reconcile those commits with origin/$CURRENT_BRANCH, then retry." >&2
197
+ else
198
+ echo "ERROR: marketplace fast-forward failed; the checkout may be dirty or its history may not match origin/$CURRENT_BRANCH." >&2
199
+ [ -n "$MERGE_ERROR" ] && echo " $MERGE_ERROR" >&2
200
+ echo " Inspect it without discarding work: git -C \"$MARKETPLACE_DIR\" status --short" >&2
201
+ fi
65
202
  exit 1
66
203
  }
67
204
  ) || exit 1
68
205
 
69
- # ─── 2. Read new version ──────────────────────────────────────────────────
70
- # Pass paths via env vars so bash variables never become JS string literals
71
- # quote-safety + injection-safety in one move. Same pattern as section 5.
72
- NEW_VERSION="$(MARKETPLACE_DIR="$MARKETPLACE_DIR" node -e "
206
+ # ─── 2. Bind the exact marketplace commit and read its version ────────────
207
+ MARKETPLACE_SHA="$(git -C "$MARKETPLACE_DIR" rev-parse HEAD 2>/dev/null)" || {
208
+ echo "ERROR: could not read the marketplace checkout's commit ($MARKETPLACE_DIR)" >&2
209
+ exit 1
210
+ }
211
+ if ! [[ "$MARKETPLACE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
212
+ echo "ERROR: marketplace HEAD is not a full 40-hex commit: $MARKETPLACE_SHA" >&2
213
+ exit 1
214
+ fi
215
+
216
+ # Read from the same immutable commit that section 3 archives. A dirty tracked
217
+ # marketplace.json must never redirect committed bytes into a different
218
+ # version directory or produce a version/commit pair that never coexisted.
219
+ NEW_VERSION="$(
220
+ git -C "$MARKETPLACE_DIR" show "$MARKETPLACE_SHA:.claude-plugin/marketplace.json" |
221
+ node -e "
73
222
  const fs = require('fs');
74
- const p = process.env.MARKETPLACE_DIR + '/.claude-plugin/marketplace.json';
75
- const j = JSON.parse(fs.readFileSync(p, 'utf8'));
223
+ const j = JSON.parse(fs.readFileSync(0, 'utf8'));
76
224
  const e = (j.plugins || []).find(x => x && x.name === 'memesh');
77
225
  if (!e || typeof e.version !== 'string') { process.exit(2); }
226
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(e.version);
227
+ const prerelease = match && match[4] ? match[4].split('.') : [];
228
+ if (!match || prerelease.some(x => /^\d+$/.test(x) && x.length > 1 && x.startsWith('0'))) {
229
+ console.error('ERROR: marketplace.json reported an unsafe version string: ' + e.version);
230
+ process.exit(3);
231
+ }
78
232
  process.stdout.write(e.version);
79
- ")" || {
80
- echo "ERROR: could not read memesh version from marketplace.json" >&2
233
+ "
234
+ )" || {
235
+ echo "ERROR: could not read memesh version from marketplace.json at commit $MARKETPLACE_SHA" >&2
81
236
  exit 1
82
237
  }
83
238
 
84
- if ! [[ "$NEW_VERSION" =~ ^[0-9A-Za-z.+-]+$ ]]; then
85
- echo "ERROR: marketplace.json reported an unsafe version string: $NEW_VERSION" >&2
86
- exit 1
87
- fi
88
-
89
239
  echo "==> Target version: $NEW_VERSION"
240
+ NEW_INSTALL_PATH="$CACHE_ROOT/$NEW_VERSION"
90
241
 
91
- CURRENT_VERSION="$(INSTALL_REGISTRY="$INSTALL_REGISTRY" node -e "
92
- const fs = require('fs');
93
- const j = JSON.parse(fs.readFileSync(process.env.INSTALL_REGISTRY, 'utf8'));
94
- const entries = (j.plugins && j.plugins['memesh@pcircle-memesh']) || [];
242
+ # Which registry entry is THIS install? Claude Code keeps one entry per scope
243
+ # (user / project / local); reading entries[0] on a machine with two of them
244
+ # reports another cache's version and sha, and section 5 would then rewrite
245
+ # the wrong entry. Pick the entry whose installPath sits under this cache
246
+ # root; fall back only to a sole legacy entry with no installPath; refuse when
247
+ # a path points elsewhere or the registry is ambiguous. Resolve authority and
248
+ # capture its index/version/sha and the complete registry digest in this ONE
249
+ # read. The index is used later only after the registry's exact bytes still
250
+ # match this digest, so a concurrent reorder cannot redirect the write.
251
+ ENTRY_SNAPSHOT="$(INSTALL_REGISTRY="$INSTALL_REGISTRY" CACHE_ROOT="$CACHE_ROOT" NEW_VERSION="$NEW_VERSION" node -e "
252
+ const fs = require('fs'), path = require('path'), crypto = require('crypto');
253
+ const registryPath = process.env.INSTALL_REGISTRY;
254
+ let before, fd;
255
+ try {
256
+ before = fs.lstatSync(registryPath, { bigint: true });
257
+ if (!before.isFile() || before.isSymbolicLink()) {
258
+ process.stdout.write('identity-changed');
259
+ process.exit(0);
260
+ }
261
+ fd = fs.openSync(registryPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
262
+ } catch (error) {
263
+ const raceCodes = new Set(['ELOOP', 'ENOENT', 'ENOTDIR']);
264
+ process.stdout.write(raceCodes.has(error && error.code) ? 'identity-changed' : 'unreadable');
265
+ process.exit(0);
266
+ }
267
+ let opened, registryText;
268
+ try {
269
+ opened = fs.fstatSync(fd, { bigint: true });
270
+ registryText = fs.readFileSync(fd, 'utf8');
271
+ } finally {
272
+ fs.closeSync(fd);
273
+ }
274
+ const after = fs.lstatSync(registryPath, { bigint: true });
275
+ const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
276
+ if (!before.isFile() || before.isSymbolicLink() || !sameFile(before, opened) || !sameFile(opened, after)) {
277
+ process.stdout.write('identity-changed');
278
+ process.exit(0);
279
+ }
280
+ const registrySha256 = crypto.createHash('sha256').update(registryText).digest('hex');
281
+ const j = JSON.parse(registryText);
282
+ const raw = j && j.plugins ? j.plugins['memesh@pcircle-memesh'] : undefined;
283
+ const entries = raw === undefined ? [] : raw;
284
+ const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);
285
+ if (!Array.isArray(entries) || entries.some(entry => !isRecord(entry))) {
286
+ process.stdout.write('malformed');
287
+ process.exit(0);
288
+ }
95
289
  if (entries.length === 0) { process.stdout.write('none'); process.exit(0); }
96
- process.stdout.write(entries[0].version || 'unknown');
290
+ const root = path.resolve(process.env.CACHE_ROOT) + path.sep;
291
+ const underRoot = entries
292
+ .map((e, i) => i)
293
+ .filter(i => {
294
+ const e = entries[i];
295
+ return typeof e.installPath === 'string'
296
+ && path.isAbsolute(e.installPath)
297
+ && (path.resolve(e.installPath) + path.sep).startsWith(root);
298
+ });
299
+ // Exactly one entry lives under this cache root: use it. A sole legacy
300
+ // entry with no usable installPath is also identifiable. Anything else — none
301
+ // matching with several entries present, or more than one matching — is
302
+ // ambiguous; guessing which scope to touch is how the wrong one gets
303
+ // rewritten.
304
+ let index;
305
+ if (underRoot.length === 1) index = underRoot[0];
306
+ const sole = entries.length === 1 ? entries[0] : null;
307
+ const soleHasPath = sole && typeof sole.installPath === 'string' && sole.installPath.length > 0;
308
+ if (underRoot.length === 0 && sole && !soleHasPath) index = 0;
309
+ if (underRoot.length === 0 && soleHasPath) { process.stdout.write('outside-root'); process.exit(0); }
310
+ // Two different, accurate refusals: several entries live under this root
311
+ // (which one is THIS install?) vs. none do and there is more than one
312
+ // entry (nothing to disambiguate by at all). Collapsing both into one
313
+ // 'ambiguous' value produced a message that said 'none of them lives
314
+ // under \$CACHE_ROOT' even when the real problem was the opposite —
315
+ // several of them did.
316
+ if (index === undefined) {
317
+ process.stdout.write(underRoot.length > 1 ? 'ambiguous-multiple' : 'ambiguous-none');
318
+ process.exit(0);
319
+ }
320
+ const entry = entries[index];
321
+ const version = typeof entry.version === 'string' && !/[\\r\\n\\t]/.test(entry.version)
322
+ ? entry.version
323
+ : 'unknown';
324
+ const sha = typeof entry.gitCommitSha === 'string' && /^[0-9a-f]{40}$/.test(entry.gitCommitSha)
325
+ ? entry.gitCommitSha
326
+ : 'unknown';
327
+ const expectedPath = path.join(path.resolve(process.env.CACHE_ROOT), process.env.NEW_VERSION);
328
+ const installPathState = typeof entry.installPath === 'string'
329
+ && path.isAbsolute(entry.installPath)
330
+ && path.resolve(entry.installPath) === expectedPath
331
+ ? 'canonical'
332
+ : 'noncanonical';
333
+ // D9's cache sweep must never remove a directory the registry ITSELF
334
+ // still points to, canonical or not — this is the one directory the
335
+ // 'noncanonical … repairing it' path above (section 2) deliberately
336
+ // leaves in place. Reported as a bare basename, and only when that
337
+ // basename resolves back under this same cache root with no '..'
338
+ // segment, so a crafted absolute installPath from a tampered registry
339
+ // cannot smuggle an arbitrary path into the sweep's exclusion list.
340
+ const rootDir = path.resolve(process.env.CACHE_ROOT);
341
+ let recordedBasename = '';
342
+ if (typeof entry.installPath === 'string' && path.isAbsolute(entry.installPath)) {
343
+ const resolved = path.resolve(entry.installPath);
344
+ const base = path.basename(resolved);
345
+ if (path.join(rootDir, base) === resolved && !/[\\r\\n\\t]/.test(base)) {
346
+ recordedBasename = base;
347
+ }
348
+ }
349
+ process.stdout.write(['selected', index, version, sha, registrySha256, installPathState, String(opened.dev), String(opened.ino), recordedBasename].join('\\t'));
97
350
  ")" || {
98
- # Its sibling twelve lines up has this guard; this read did not, so an
99
- # unreadable or malformed installed_plugins.json made CURRENT_VERSION the
100
- # empty string. That compares unequal to every target, so the script
101
- # reported an upgrade from "" and carried on — on a registry it had just
102
- # failed to parse.
103
- echo "ERROR: could not read the installed memesh version from $INSTALL_REGISTRY" >&2
351
+ echo "ERROR: could not read the installed memesh entries from $INSTALL_REGISTRY" >&2
104
352
  exit 1
105
353
  }
354
+ IFS=$'\t' read -r ENTRY_STATE ENTRY_INDEX CURRENT_VERSION INSTALLED_SHA ORIGINAL_REGISTRY_SHA256 INSTALL_PATH_STATE ORIGINAL_REGISTRY_DEV ORIGINAL_REGISTRY_INO RECORDED_INSTALL_BASENAME <<< "$ENTRY_SNAPSHOT"
355
+ case "$ENTRY_STATE" in
356
+ identity-changed)
357
+ echo "ERROR: installed_plugins.json changed file identity while this upgrade was reading it — refusing to continue." >&2
358
+ exit 1
359
+ ;;
360
+ unreadable)
361
+ echo "ERROR: installed_plugins.json could not be opened safely at $INSTALL_REGISTRY — refusing to continue." >&2
362
+ exit 1
363
+ ;;
364
+ ambiguous-multiple)
365
+ echo "ERROR: installed_plugins.json lists several memesh entries under $CACHE_ROOT — refusing to guess which one to upgrade." >&2
366
+ exit 1
367
+ ;;
368
+ ambiguous-none)
369
+ echo "ERROR: installed_plugins.json lists several memesh entries and none of them lives under $CACHE_ROOT — refusing to guess which one to upgrade." >&2
370
+ exit 1
371
+ ;;
372
+ outside-root)
373
+ echo "ERROR: the only memesh entry in installed_plugins.json lives outside $CACHE_ROOT — refusing to guess which scope to upgrade." >&2
374
+ exit 1
375
+ ;;
376
+ malformed)
377
+ echo "ERROR: installed_plugins.json has a malformed memesh@pcircle-memesh entry — refusing to guess which scope to upgrade." >&2
378
+ exit 1
379
+ ;;
380
+ none)
381
+ # Refuse before touching the filesystem: section 6 has no entry to update.
382
+ echo "ERROR: installed_plugins.json has no memesh@pcircle-memesh entry — nothing to upgrade in place." >&2
383
+ echo " Reinstall the plugin from the Claude Code /plugin UI." >&2
384
+ exit 1
385
+ ;;
386
+ esac
387
+ if [ "$ENTRY_STATE" != "selected" ] \
388
+ || ! [[ "$ENTRY_INDEX" =~ ^[0-9]+$ ]] \
389
+ || ! [[ "$ORIGINAL_REGISTRY_SHA256" =~ ^[0-9a-f]{64}$ ]] \
390
+ || ! [[ "$ORIGINAL_REGISTRY_DEV" =~ ^[0-9]+$ ]] \
391
+ || ! [[ "$ORIGINAL_REGISTRY_INO" =~ ^[0-9]+$ ]] \
392
+ || { [ "$INSTALL_PATH_STATE" != "canonical" ] && [ "$INSTALL_PATH_STATE" != "noncanonical" ]; }; then
393
+ echo "ERROR: could not select one installed memesh entry from $INSTALL_REGISTRY" >&2
394
+ exit 1
395
+ fi
106
396
 
107
397
  echo "==> Currently installed: $CURRENT_VERSION"
108
398
 
399
+ # Same version is NOT "same code". Claude Code keys the plugin cache by
400
+ # version, and so did this script — which is exactly how a machine that
401
+ # auto-updated between the `release: prepare v4.8.2` commit and the two fix
402
+ # PRs that merged under the same version kept serving a 4.8.2 that lacked
403
+ # them, and was told "Already at 4.8.2 — nothing to do." The registry
404
+ # records the commit the cache was staged from (section 5 below); compare
405
+ # that, not the version string. CURRENT_VERSION and INSTALLED_SHA came from
406
+ # the same authority-bound registry snapshot above.
407
+
109
408
  if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
110
- echo "==> Already at $NEW_VERSION nothing to do."
111
- exit 0
409
+ if [ "$INSTALLED_SHA" = "$MARKETPLACE_SHA" ] \
410
+ && [ "$INSTALL_PATH_STATE" = "canonical" ] \
411
+ && [ -d "$NEW_INSTALL_PATH" ] \
412
+ && [ ! -L "$NEW_INSTALL_PATH" ]; then
413
+ echo "==> Already at $NEW_VERSION (commit ${MARKETPLACE_SHA:0:8}) — nothing to do."
414
+ exit 0
415
+ fi
416
+ if [ "$INSTALLED_SHA" != "$MARKETPLACE_SHA" ]; then
417
+ echo "==> $NEW_VERSION is installed, but the plugin cache was built from commit ${INSTALLED_SHA:0:8}"
418
+ echo " and the marketplace is at ${MARKETPLACE_SHA:0:8} — refreshing the cache in place."
419
+ elif [ "$INSTALL_PATH_STATE" != "canonical" ]; then
420
+ echo "==> $NEW_VERSION is recorded at a noncanonical cache path — repairing it at $NEW_INSTALL_PATH."
421
+ else
422
+ echo "==> $NEW_VERSION is recorded at $NEW_INSTALL_PATH, but that path is missing or not a real directory — repairing it."
423
+ fi
112
424
  fi
113
425
 
114
- # ─── 3. Stage new install cache ───────────────────────────────────────────
115
- NEW_INSTALL_PATH="$CACHE_ROOT/$NEW_VERSION"
116
- echo "==> Staging $NEW_INSTALL_PATH..."
117
- mkdir -p "$NEW_INSTALL_PATH"
118
- rsync -a --delete \
119
- --exclude 'node_modules' \
120
- --exclude '.git' \
121
- --exclude 'tests' \
122
- --exclude 'benchmarks' \
123
- --exclude 'docs/plans' \
124
- "$MARKETPLACE_DIR/" "$NEW_INSTALL_PATH/" || {
125
- echo "ERROR: rsync failed" >&2
426
+ # ─── 3. Stage into a sibling, never into the live cache ───────────────────
427
+ # A same-version refresh targets the directory Claude Code is running from.
428
+ # Staging straight into it and then a failed npm install would leave new
429
+ # code with old or missing dependencies and the old registry sha — a broken
430
+ # install that the next restart loads. So: build the whole thing next to the
431
+ # live cache, and only swap once every step has succeeded.
432
+ STAGE_PATH="$CACHE_ROOT/.staging-$NEW_VERSION-$$"
433
+ PREVIOUS_PATH="$CACHE_ROOT/.previous-$NEW_VERSION-$$"
434
+
435
+ # Remove the failed candidate before restoring the previous cache. Never move
436
+ # the previous cache while the failed live path still exists: `mv` would nest
437
+ # it below that directory and make the postcondition look successful.
438
+ rollback_swap() {
439
+ # Cache removal + restoration is one short recovery transaction. A second
440
+ # signal after the failed candidate is removed but before the previous cache
441
+ # is restored must not strand the install with only .previous-* left behind.
442
+ trap '' INT TERM HUP
443
+ rm -rf "$NEW_INSTALL_PATH" 2>/dev/null
444
+ if [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; then
445
+ echo "ERROR: could not remove the broken cache at $NEW_INSTALL_PATH." >&2
446
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
447
+ echo " The previous cache is still intact at $PREVIOUS_PATH; installed_plugins.json remains unchanged." >&2
448
+ echo " Remove the broken cache, then restore the previous cache manually:" >&2
449
+ echo " rm -rf \"$NEW_INSTALL_PATH\"" >&2
450
+ echo " mv \"$PREVIOUS_PATH\" \"$NEW_INSTALL_PATH\"" >&2
451
+ else
452
+ echo " installed_plugins.json was not updated to point at it, so it is orphaned. Remove it manually:" >&2
453
+ echo " rm -rf \"$NEW_INSTALL_PATH\"" >&2
454
+ fi
455
+ return 1
456
+ fi
457
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
458
+ mv "$PREVIOUS_PATH" "$NEW_INSTALL_PATH" 2>/dev/null
459
+ if { [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; } \
460
+ && [ ! -e "$PREVIOUS_PATH" ] \
461
+ && [ ! -L "$PREVIOUS_PATH" ]; then
462
+ echo "Rollback succeeded: restored the previous cache; installed_plugins.json remains unchanged." >&2
463
+ return 0
464
+ fi
465
+ echo "ERROR: could not restore the previous cache." >&2
466
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
467
+ echo " It is still intact at $PREVIOUS_PATH — move it back manually:" >&2
468
+ echo " mv \"$PREVIOUS_PATH\" \"$NEW_INSTALL_PATH\"" >&2
469
+ else
470
+ echo " It is gone from $PREVIOUS_PATH and $NEW_INSTALL_PATH is also missing. This should not happen — check $CACHE_ROOT by hand." >&2
471
+ fi
472
+ return 1
473
+ fi
474
+ echo "Cleanup succeeded: no failed cache remains; installed_plugins.json remains unchanged." >&2
475
+ return 0
476
+ }
477
+
478
+ echo "==> Staging $NEW_VERSION next to the cache..."
479
+ if ! rm -rf "$STAGE_PATH"; then
480
+ echo "ERROR: could not clear the staging path at $STAGE_PATH — the live cache was not touched." >&2
481
+ exit 1
482
+ fi
483
+ if ! mkdir -p "$STAGE_PATH"; then
484
+ echo "ERROR: could not create the staging directory at $STAGE_PATH — the live cache was not touched." >&2
485
+ exit 1
486
+ fi
487
+ # `git archive`, not `rsync` of the working tree: rsync would copy whatever
488
+ # is sitting in $MARKETPLACE_DIR right now, including files nobody committed
489
+ # — so a machine with a tampered or half-merged marketplace checkout could
490
+ # have those bytes installed while MARKETPLACE_SHA (recorded above, and
491
+ # written into the registry in section 6) truthfully names a clean commit
492
+ # that never contained them. `git archive` extracts exactly the tree at
493
+ # $MARKETPLACE_SHA; nothing untracked or uncommitted can reach the cache.
494
+ # node_modules and .git are never tracked, so they never appear in the
495
+ # archive; tests/benchmarks/docs/plans are tracked (real source) and are
496
+ # removed after extraction to keep the shipped cache the same shape as
497
+ # before.
498
+ git -C "$MARKETPLACE_DIR" archive "$MARKETPLACE_SHA" | tar -x -C "$STAGE_PATH" || {
499
+ echo "ERROR: git archive failed — the live cache at $NEW_INSTALL_PATH was not touched" >&2
126
500
  exit 1
127
501
  }
502
+ if ! rm -rf "$STAGE_PATH/tests" "$STAGE_PATH/benchmarks" "$STAGE_PATH/docs/plans"; then
503
+ echo "ERROR: could not remove development-only files from the staging copy — the live cache was not touched." >&2
504
+ exit 1
505
+ fi
128
506
 
129
- # ─── 4. Install runtime deps ──────────────────────────────────────────────
507
+ # ─── 4. Install runtime deps in the staging copy ──────────────────────────
130
508
  echo "==> Installing runtime deps (this may take a minute)..."
131
509
  (
132
- cd "$NEW_INSTALL_PATH" || exit 1
510
+ cd "$STAGE_PATH" || exit 1
133
511
  npm install --omit=dev --no-audit --no-fund --silent
134
512
  ) || {
135
- echo "ERROR: npm install failed in $NEW_INSTALL_PATH" >&2
513
+ echo "ERROR: npm install failed in the staging copy — the live cache at $NEW_INSTALL_PATH was not touched" >&2
514
+ exit 1
515
+ }
516
+
517
+ # ─── 5. Swap the staged copy in ───────────────────────────────────────────
518
+ if [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; then
519
+ HAD_LIVE_CACHE=1
520
+ fi
521
+ SWAP_PENDING=1
522
+ if [ "$HAD_LIVE_CACHE" = 1 ]; then
523
+ mv "$NEW_INSTALL_PATH" "$PREVIOUS_PATH" || {
524
+ SWAP_PENDING=0
525
+ echo "ERROR: could not move the live cache aside — nothing was changed" >&2
526
+ exit 1
527
+ }
528
+ fi
529
+ mv "$STAGE_PATH" "$NEW_INSTALL_PATH" || {
530
+ echo "ERROR: could not move the staged copy into place." >&2
531
+ SWAP_PENDING=0
532
+ rollback_swap
136
533
  exit 1
137
534
  }
138
535
 
139
- # ─── 5. Patch installed_plugins.json ─────────────────────────────────────
536
+ # ─── 6. Patch installed_plugins.json (last: the registry names a cache that exists) ──
537
+ # Compare the complete registry snapshot captured during authority selection,
538
+ # then write and rename in one Node process. Re-check the snapshot immediately
539
+ # before rename to detect host writes observed up to that point. Ignore signals
540
+ # only for this short registry critical section;
541
+ # before it, the signal trap restores the cache, and after it the registry and
542
+ # live path already agree.
140
543
  echo "==> Updating installed_plugins.json..."
544
+ trap '' INT TERM HUP
141
545
  NEW_INSTALL_PATH="$NEW_INSTALL_PATH" \
142
546
  NEW_VERSION="$NEW_VERSION" \
143
547
  INSTALL_REGISTRY="$INSTALL_REGISTRY" \
144
- MARKETPLACE_DIR="$MARKETPLACE_DIR" \
548
+ MARKETPLACE_SHA="$MARKETPLACE_SHA" \
549
+ ORIGINAL_REGISTRY_SHA256="$ORIGINAL_REGISTRY_SHA256" \
550
+ ORIGINAL_REGISTRY_DEV="$ORIGINAL_REGISTRY_DEV" \
551
+ ORIGINAL_REGISTRY_INO="$ORIGINAL_REGISTRY_INO" \
552
+ ENTRY_INDEX="$ENTRY_INDEX" \
145
553
  node -e "
146
- const fs = require('fs');
147
- const path = process.env.INSTALL_REGISTRY;
148
- const j = JSON.parse(fs.readFileSync(path, 'utf8'));
149
- if (!j.plugins || !Array.isArray(j.plugins['memesh@pcircle-memesh'])) {
150
- console.error('installed_plugins.json missing memesh@pcircle-memesh entry');
554
+ const fs = require('fs'), path = require('path'), crypto = require('crypto');
555
+ const registryPath = process.env.INSTALL_REGISTRY;
556
+ const sha256 = text => crypto.createHash('sha256').update(text).digest('hex');
557
+ const expectedDev = BigInt(process.env.ORIGINAL_REGISTRY_DEV);
558
+ const expectedIno = BigInt(process.env.ORIGINAL_REGISTRY_INO);
559
+ const assertOriginalFile = stat => {
560
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.dev !== expectedDev || stat.ino !== expectedIno) {
561
+ throw new Error('registry file identity changed since this upgrade started');
562
+ }
563
+ };
564
+ const readOriginalFile = () => {
565
+ const before = fs.lstatSync(registryPath, { bigint: true });
566
+ assertOriginalFile(before);
567
+ const fd = fs.openSync(registryPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
568
+ try {
569
+ const opened = fs.fstatSync(fd, { bigint: true });
570
+ assertOriginalFile(opened);
571
+ const text = fs.readFileSync(fd, 'utf8');
572
+ assertOriginalFile(fs.lstatSync(registryPath, { bigint: true }));
573
+ return { text, mode: Number(opened.mode & 0o777n) };
574
+ } finally {
575
+ fs.closeSync(fd);
576
+ }
577
+ };
578
+ const original = readOriginalFile();
579
+ const registryText = original.text;
580
+ if (sha256(registryText) !== process.env.ORIGINAL_REGISTRY_SHA256) {
581
+ console.error('the complete registry changed since this upgrade started');
582
+ process.exit(2);
583
+ }
584
+ const j = JSON.parse(registryText);
585
+ const registryMode = original.mode;
586
+ const entries = j.plugins && j.plugins['memesh@pcircle-memesh'];
587
+ const idx = Number(process.env.ENTRY_INDEX);
588
+ const entry = Array.isArray(entries) ? entries[idx] : undefined;
589
+ if (!Number.isSafeInteger(idx) || idx < 0 || entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
590
+ console.error('the authority-selected registry entry is no longer structurally valid');
151
591
  process.exit(2);
152
592
  }
153
- // Read the actual commit sha so future doctor calls match the install.
154
- let sha = 'unknown';
155
- try {
156
- sha = require('child_process')
157
- .execFileSync('git', ['-C', process.env.MARKETPLACE_DIR, 'rev-parse', 'HEAD'], { encoding: 'utf8' })
158
- .trim();
159
- } catch {}
160
- const entry = j.plugins['memesh@pcircle-memesh'][0] || {};
161
593
  entry.installPath = process.env.NEW_INSTALL_PATH;
162
594
  entry.version = process.env.NEW_VERSION;
163
595
  entry.lastUpdated = new Date().toISOString();
164
- entry.gitCommitSha = sha;
165
- j.plugins['memesh@pcircle-memesh'][0] = entry;
166
- fs.writeFileSync(path, JSON.stringify(j, null, 4) + '\n');
167
- " || {
168
- echo "ERROR: failed to patch installed_plugins.json" >&2
596
+ // Read once above (MARKETPLACE_SHA); the staleness check compares against it next run.
597
+ entry.gitCommitSha = process.env.MARKETPLACE_SHA;
598
+ entries[idx] = entry;
599
+ const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
600
+ let tmpDir, tmpDirIdentity, tmpPath, tmpFd, tmpIdentity;
601
+ const cleanupTemp = () => {
602
+ if (tmpFd !== undefined) {
603
+ try { fs.closeSync(tmpFd); } catch {}
604
+ tmpFd = undefined;
605
+ }
606
+ if (tmpPath && tmpIdentity) {
607
+ try {
608
+ const named = fs.lstatSync(tmpPath, { bigint: true });
609
+ if (named.isFile() && !named.isSymbolicLink() && sameFile(named, tmpIdentity)) {
610
+ fs.unlinkSync(tmpPath);
611
+ }
612
+ } catch {}
613
+ }
614
+ if (tmpDir && tmpDirIdentity) {
615
+ try {
616
+ const named = fs.lstatSync(tmpDir, { bigint: true });
617
+ if (named.isDirectory() && !named.isSymbolicLink() && sameFile(named, tmpDirIdentity)) {
618
+ fs.rmdirSync(tmpDir);
619
+ }
620
+ } catch {}
621
+ }
622
+ };
623
+ const assertTempDir = () => {
624
+ const named = fs.lstatSync(tmpDir, { bigint: true });
625
+ if (!named.isDirectory() || named.isSymbolicLink() || !sameFile(named, tmpDirIdentity)) {
626
+ throw new Error('registry temporary directory identity changed before the atomic write');
627
+ }
628
+ };
629
+ const assertTempFile = stat => {
630
+ if (!stat.isFile() || stat.isSymbolicLink() || !sameFile(stat, tmpIdentity)) {
631
+ throw new Error('registry temporary file identity changed before the atomic write');
632
+ }
633
+ };
634
+ try {
635
+ const registryDir = path.dirname(registryPath);
636
+ tmpDir = fs.mkdtempSync(path.join(registryDir, '.' + path.basename(registryPath) + '.tmp-'));
637
+ tmpDirIdentity = fs.lstatSync(tmpDir, { bigint: true });
638
+ if (!tmpDirIdentity.isDirectory() || tmpDirIdentity.isSymbolicLink()) {
639
+ throw new Error('registry temporary location is not a real directory');
640
+ }
641
+ tmpPath = path.join(tmpDir, 'registry.json');
642
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL
643
+ | (fs.constants.O_NOFOLLOW || 0);
644
+ tmpFd = fs.openSync(tmpPath, flags, registryMode);
645
+ tmpIdentity = fs.fstatSync(tmpFd, { bigint: true });
646
+ assertTempDir();
647
+ assertTempFile(fs.lstatSync(tmpPath, { bigint: true }));
648
+ fs.writeFileSync(tmpFd, JSON.stringify(j, null, 4) + '\n', 'utf8');
649
+ fs.fchmodSync(tmpFd, registryMode);
650
+ fs.fsyncSync(tmpFd);
651
+ assertTempFile(fs.fstatSync(tmpFd, { bigint: true }));
652
+ assertTempFile(fs.lstatSync(tmpPath, { bigint: true }));
653
+ fs.closeSync(tmpFd);
654
+ tmpFd = undefined;
655
+ if (sha256(readOriginalFile().text) !== process.env.ORIGINAL_REGISTRY_SHA256) {
656
+ throw new Error('the complete registry changed immediately before the atomic write');
657
+ }
658
+ assertOriginalFile(fs.lstatSync(registryPath, { bigint: true }));
659
+ assertTempDir();
660
+ assertTempFile(fs.lstatSync(tmpPath, { bigint: true }));
661
+ fs.renameSync(tmpPath, registryPath);
662
+ // rename is the commit point. Cleanup after it must never turn an applied
663
+ // registry update into a reported failure and trigger a cache rollback.
664
+ try { fs.rmdirSync(tmpDir); } catch {}
665
+ } catch (err) {
666
+ cleanupTemp();
667
+ throw err;
668
+ }
669
+ "
670
+ REGISTRY_STATUS=$?
671
+ if [ "$REGISTRY_STATUS" = 0 ]; then
672
+ SWAP_PENDING=0
673
+ fi
674
+ install_signal_traps
675
+ if [ "$REGISTRY_STATUS" != 0 ]; then
676
+ echo "ERROR: failed to update installed_plugins.json." >&2
677
+ SWAP_PENDING=0
678
+ rollback_swap
169
679
  exit 1
170
- }
680
+ fi
681
+ rm -rf "$PREVIOUS_PATH" 2>/dev/null
682
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
683
+ echo "WARNING: upgrade succeeded, but the previous cache could not be removed at $PREVIOUS_PATH." >&2
684
+ echo " Remove it manually when no Claude Code process is using it: rm -rf \"$PREVIOUS_PATH\"" >&2
685
+ fi
686
+
687
+ # D9: sweep every OTHER stale version directory under $CACHE_ROOT, not just
688
+ # the one this run just swapped out — see `sweep_stale_cache_versions`'s own
689
+ # definition near the top of this file for why.
690
+ sweep_stale_cache_versions "$CACHE_ROOT" "$NEW_VERSION" "$RECORDED_INSTALL_BASENAME"
171
691
 
172
- # ─── 6. Done ─────────────────────────────────────────────────────────────
692
+ # ─── 7. Done ─────────────────────────────────────────────────────────────
173
693
  echo ""
174
- echo "✓ MeMesh upgraded: $CURRENT_VERSION -> $NEW_VERSION"
694
+ echo "✓ MeMesh upgraded: $CURRENT_VERSION (${INSTALLED_SHA:0:8}) -> $NEW_VERSION (${MARKETPLACE_SHA:0:8})"
175
695
  echo " Install path: $NEW_INSTALL_PATH"
176
696
  echo ""
177
697
  echo "Next step: restart Claude Code so the new MCP server picks up."
178
- echo "Old version still on disk at $CACHE_ROOT/$CURRENT_VERSION (safe to delete once verified)."