@pcircle/memesh 4.9.3 → 4.9.4

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.
@@ -8,7 +8,7 @@
8
8
  "name": "memesh",
9
9
  "source": "./",
10
10
  "description": "MeMesh \u2014 agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.",
11
- "version": "4.9.3",
11
+ "version": "4.9.4",
12
12
  "author": {
13
13
  "name": "PCIRCLE AI"
14
14
  },
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "PCIRCLE AI"
6
6
  },
7
- "version": "4.9.3",
7
+ "version": "4.9.4",
8
8
  "mcpServers": "./.claude-plugin/mcp.json",
9
9
  "homepage": "https://github.com/PCIRCLE-AI/memesh",
10
10
  "repository": "https://github.com/PCIRCLE-AI/memesh",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memesh",
3
3
  "description": "MeMesh — agentic memory for coding agents.",
4
- "version": "4.9.3",
4
+ "version": "4.9.4",
5
5
  "mcpServers": "./.codex-plugin/mcp.json"
6
6
  }
@@ -8,7 +8,7 @@
8
8
  },
9
9
  {
10
10
  "path": ".claude-plugin/plugin.json",
11
- "sha256": "e1ff07fff7c5002f916e6c55172e953e726a24f46a2a705585fef53d726867d6",
11
+ "sha256": "cfb107b54e757ba86356a0defdebf7b1f12fbf45acbf8920d91793d849f33f4e",
12
12
  "bytes": 572
13
13
  },
14
14
  {
@@ -18,7 +18,7 @@
18
18
  },
19
19
  {
20
20
  "path": ".codex-plugin/plugin.json",
21
- "sha256": "c68af5ed07947baf7ef3d66c5262cf3a34ac20f0981cf223ecc652d6e298bca8",
21
+ "sha256": "627cae483533b13ec22c0288c8e1abc2d2a7521bc6ccad7813b1065b5ab91816",
22
22
  "bytes": 154
23
23
  },
24
24
  {
@@ -96,6 +96,11 @@
96
96
  "sha256": "4474f784643a3384a066b508473ff17ee39beecd86d57451300592971528d3c4",
97
97
  "bytes": 48742
98
98
  },
99
+ {
100
+ "path": "scripts/hooks/auto-update-runner.mjs",
101
+ "sha256": "6435dbe0d142f52349da0c2ef8f82c455935be506a1b9e78f09f117c776d02b7",
102
+ "bytes": 7347
103
+ },
99
104
  {
100
105
  "path": "scripts/hooks/decision-nudge.js",
101
106
  "sha256": "424bf5cd4f68cb55e441a5aed1db78b262df2a5ef4a7e60a56db2f9d3f103d5d",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pcircle/memesh",
3
- "version": "4.9.3",
3
+ "version": "4.9.4",
4
4
  "description": "MeMesh — agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -27,6 +27,8 @@
27
27
  "README.md",
28
28
  "LICENSE",
29
29
  "llms-install.md",
30
+ "scripts/check-plugin-hook-artifact.mjs",
31
+ "scripts/lib/npm-bin.mjs",
30
32
  "AGENTS.md"
31
33
  ],
32
34
  "scripts": {
@@ -49,7 +51,8 @@
49
51
  "audit:prod": "node scripts/check-consumer-audit.mjs",
50
52
  "audit:memory": "node scripts/audit/memory-invariants.mjs",
51
53
  "check:entry-points-start": "node scripts/check-entry-points-start.mjs",
52
- "verify:release": "npm run lint && npm run typecheck && node scripts/check-version-coherence.mjs && node scripts/check-generated-mirror.mjs && node scripts/check-agent-message-sync.mjs && npm run check:surface-parity && node scripts/check-doc-claims.mjs && node scripts/audit/verification-audit.mjs && npm run audit:prod && npm run check:entry-points-start",
54
+ "check:plugin-hook-artifact": "node scripts/check-plugin-hook-artifact.mjs",
55
+ "verify:release": "npm run lint && npm run typecheck && node scripts/check-version-coherence.mjs && node scripts/check-generated-mirror.mjs && node scripts/check-agent-message-sync.mjs && npm run check:surface-parity && node scripts/check-doc-claims.mjs && node scripts/audit/verification-audit.mjs && npm run audit:prod && npm run check:entry-points-start && npm run check:plugin-hook-artifact",
53
56
  "release:finish": "node scripts/finish-release.mjs",
54
57
  "verify:artifact": "npm run verify:release && npm run test:isolated && npm run test:packaged && npm run test:packaged:upgrade",
55
58
  "prepublishOnly": "npm run build && npm run verify:artifact",
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ /** Verify plugin hooks and host entrypoints before a cache/artifact swap. */
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { npmSync, envWithNpmCache } from './lib/npm-bin.mjs';
8
+
9
+ export const CLAUDE_PLUGIN_ROOT_PREFIX = '${CLAUDE_PLUGIN_ROOT}/';
10
+
11
+ function isSubpath(parent, child) {
12
+ const relative = path.relative(parent, child);
13
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
14
+ }
15
+
16
+ export function hookTargetsFromManifest(manifest) {
17
+ const targets = [];
18
+ for (const [event, entries] of Object.entries(manifest?.hooks ?? {})) {
19
+ if (!Array.isArray(entries)) throw new Error(`${event} is not an array in hooks/hooks.json`);
20
+ for (const entry of entries) {
21
+ for (const hook of entry?.hooks ?? []) {
22
+ if (hook?.type !== 'command') continue;
23
+ if (typeof hook.command !== 'string' || !hook.command.startsWith(CLAUDE_PLUGIN_ROOT_PREFIX)) {
24
+ throw new Error(`${event} command must start with ${CLAUDE_PLUGIN_ROOT_PREFIX}`);
25
+ }
26
+ const relative = hook.command.slice(CLAUDE_PLUGIN_ROOT_PREFIX.length);
27
+ if (!relative || /\s/.test(relative) || path.posix.normalize(relative) !== relative || relative.startsWith('../')) {
28
+ throw new Error(`${event} command is not a single safe plugin-relative path: ${hook.command}`);
29
+ }
30
+ targets.push({ event, relative });
31
+ }
32
+ }
33
+ }
34
+ if (targets.length === 0) throw new Error('hooks/hooks.json declares no command hooks');
35
+ return targets;
36
+ }
37
+
38
+ export function readHookManifest(root) {
39
+ return JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'hooks.json'), 'utf8'));
40
+ }
41
+
42
+ export function validateHookTargets(root, manifest = readHookManifest(root)) {
43
+ const rootPath = path.resolve(root);
44
+ const targets = hookTargetsFromManifest(manifest);
45
+ const missing = [];
46
+ for (const target of targets) {
47
+ const resolved = path.resolve(rootPath, target.relative);
48
+ try {
49
+ if (isContainedRegularFile(rootPath, resolved)) continue;
50
+ } catch {
51
+ // Report the same bounded finding for missing and unreadable targets.
52
+ }
53
+ missing.push({ ...target, resolved });
54
+ }
55
+ return { targets, missing, ok: missing.length === 0 };
56
+ }
57
+
58
+ /**
59
+ * True only for a regular file whose every path component lives inside root.
60
+ * `lstat` rejects a symlink as the last component; `realpathSync` on both
61
+ * sides rejects a symlinked directory ABOVE it — `scripts/hooks -> /elsewhere`
62
+ * passed the lstat-only check and would have let a staged cache execute
63
+ * ambient bytes. Throws on a missing path, like the fs calls it wraps.
64
+ */
65
+ function isContainedRegularFile(rootPath, resolved) {
66
+ if (!isSubpath(rootPath, resolved) || !fs.lstatSync(resolved).isFile()) return false;
67
+ return isSubpath(fs.realpathSync(rootPath), fs.realpathSync(resolved));
68
+ }
69
+
70
+ function safeRelativePath(value, label) {
71
+ if (typeof value !== 'string' || !value.startsWith('./')) {
72
+ throw new Error(`${label} must be a ./ relative path`);
73
+ }
74
+ const relative = value.slice(2);
75
+ if (!relative || path.posix.normalize(relative) !== relative || relative.startsWith('../') || path.isAbsolute(relative)) {
76
+ throw new Error(`${label} is not a safe plugin-relative path: ${value}`);
77
+ }
78
+ return relative;
79
+ }
80
+
81
+ function regularFile(root, relative, label) {
82
+ const rootPath = path.resolve(root);
83
+ const resolved = path.resolve(rootPath, relative);
84
+ if (!isSubpath(rootPath, resolved)) throw new Error(`${label} escapes plugin root: ${relative}`);
85
+ try {
86
+ if (isContainedRegularFile(rootPath, resolved)) return relative;
87
+ } catch {
88
+ // Fall through to one bounded diagnostic.
89
+ }
90
+ throw new Error(`${label} is missing or not a regular file: ${relative}`);
91
+ }
92
+
93
+ export function validatePluginEntrypoints(root) {
94
+ const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
95
+ const targets = [];
96
+ for (const [relativeManifest, expectedPrefix] of [
97
+ ['.claude-plugin/plugin.json', '${CLAUDE_PLUGIN_ROOT}/'],
98
+ ['.codex-plugin/plugin.json', './'],
99
+ ]) {
100
+ regularFile(root, relativeManifest, relativeManifest);
101
+ const plugin = JSON.parse(fs.readFileSync(path.join(root, relativeManifest), 'utf8'));
102
+ if (plugin.name !== 'memesh') throw new Error(`${relativeManifest} has unexpected plugin name`);
103
+ if (plugin.version !== packageJson.version) throw new Error(`${relativeManifest} version does not match package.json`);
104
+ const mcpManifest = safeRelativePath(plugin.mcpServers, `${relativeManifest} mcpServers`);
105
+ regularFile(root, mcpManifest, `${relativeManifest} mcpServers`);
106
+ targets.push({ kind: 'manifest', relative: relativeManifest });
107
+ targets.push({ kind: 'manifest', relative: mcpManifest });
108
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, mcpManifest), 'utf8'));
109
+ const entry = manifest.mcpServers?.memesh;
110
+ if (!entry || entry.command !== 'node' || !Array.isArray(entry.args) || typeof entry.args[0] !== 'string') {
111
+ throw new Error(`${mcpManifest} has no valid mcpServers.memesh node entry`);
112
+ }
113
+ const rawTarget = entry.args[0];
114
+ if (!rawTarget.startsWith(expectedPrefix)) {
115
+ throw new Error(`${mcpManifest} entry must start with ${expectedPrefix}`);
116
+ }
117
+ const target = rawTarget.slice(expectedPrefix.length);
118
+ if (path.posix.normalize(target) !== target || target.startsWith('../') || path.isAbsolute(target)) {
119
+ throw new Error(`${mcpManifest} entry is not a safe plugin-relative path: ${rawTarget}`);
120
+ }
121
+ regularFile(root, target, `${mcpManifest} entrypoint`);
122
+ targets.push({ kind: 'entrypoint', relative: target });
123
+ }
124
+ return { targets };
125
+ }
126
+
127
+ export function validateArtifactPaths(targets, files) {
128
+ const shipped = new Set(files.map((file) => typeof file === 'string' ? file : file.path));
129
+ const missing = targets.filter((target) => !shipped.has(target.relative));
130
+ return { missing, ok: missing.length === 0 };
131
+ }
132
+
133
+ function packFiles(root) {
134
+ const npmCache = fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-pack-cache-'));
135
+ try {
136
+ const stdout = npmSync(['pack', '--dry-run', '--json', '--ignore-scripts'], {
137
+ cwd: root,
138
+ encoding: 'utf8',
139
+ env: envWithNpmCache(npmCache),
140
+ });
141
+ let report;
142
+ try { report = JSON.parse(String(stdout)); } catch { throw new Error('npm pack --dry-run did not return valid JSON'); }
143
+ const entry = Array.isArray(report) ? report[0] : report;
144
+ if (!entry || !Array.isArray(entry.files)) throw new Error('npm pack --dry-run returned no file list');
145
+ return entry.files;
146
+ } catch (error) {
147
+ if (error instanceof Error && /npm pack --dry-run (?:did not return|returned no)/.test(error.message)) throw error;
148
+ const record = error && typeof error === 'object' ? /** @type {Record<string, unknown>} */ (error) : {};
149
+ const status = typeof record.status === 'number' ? record.status : 'unknown';
150
+ const stderr = typeof record.stderr === 'string' ? record.stderr.trim() : '';
151
+ throw new Error(
152
+ `npm pack --dry-run failed (exit ${status}): ${stderr || (error instanceof Error ? error.message : String(error))}`,
153
+ { cause: error },
154
+ );
155
+ } finally {
156
+ fs.rmSync(npmCache, { recursive: true, force: true });
157
+ }
158
+ }
159
+
160
+ export function checkPluginHookArtifact(root, { checkPack = true } = {}) {
161
+ const source = validateHookTargets(root);
162
+ const plugin = validatePluginEntrypoints(root);
163
+ const allTargets = [...source.targets, ...plugin.targets];
164
+ if (!checkPack) return { ...source, plugin, artifact: null };
165
+ return { ...source, plugin, artifact: validateArtifactPaths(allTargets, packFiles(root)) };
166
+ }
167
+
168
+ function main(argv) {
169
+ const rootIndex = argv.indexOf('--root');
170
+ if (rootIndex !== -1 && (!argv[rootIndex + 1] || argv[rootIndex + 1].startsWith('--'))) {
171
+ console.error('plugin hook integrity: --root requires a directory argument');
172
+ process.exit(2);
173
+ }
174
+ const root = path.resolve(rootIndex === -1 ? process.cwd() : argv[rootIndex + 1]);
175
+ const checkPack = !argv.includes('--skip-pack');
176
+ try {
177
+ const result = checkPluginHookArtifact(root, { checkPack });
178
+ if (!result.ok) {
179
+ for (const item of result.missing) console.error(`Missing hook target: ${item.event} -> ${item.resolved}`);
180
+ process.exit(1);
181
+ }
182
+ if (result.artifact && !result.artifact.ok) {
183
+ for (const item of result.artifact.missing) console.error(`Target omitted from npm artifact: ${item.event ?? item.kind} -> ${item.relative}`);
184
+ process.exit(1);
185
+ }
186
+ console.log(`plugin artifact integrity: PASS (${result.targets.length} hook targets, ${result.plugin.targets.length} plugin/MCP targets${checkPack ? ', npm artifact checked' : ''})`);
187
+ } catch (error) {
188
+ console.error(`plugin hook integrity: ${error instanceof Error ? error.message : String(error)}`);
189
+ process.exit(1);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Run `main` only when this file is the entrypoint. Both sides go through
195
+ * realpath: Node resolves symlinks in `import.meta.url` but not in argv[1],
196
+ * so `node /var/folders/.../check-plugin-hook-artifact.mjs` (macOS tmp is a
197
+ * symlink) compared unequal, skipped `main`, and exited 0 with no output — a
198
+ * silent PASS from a checker that had checked nothing. Pinned by the tarball
199
+ * test in tests/plugin-hook-artifact.test.ts, which asserts on the output.
200
+ */
201
+ function isEntrypoint(argv1) {
202
+ if (!argv1) return false;
203
+ const self = fileURLToPath(import.meta.url);
204
+ const given = path.resolve(argv1);
205
+ if (self === given) return true;
206
+ // A realpath failure must not become "not the entrypoint": that is exit 0
207
+ // with nothing checked, the fail-closed guard's caller only reads the exit
208
+ // code, and a staged plugin would swap in unverified. Let it throw.
209
+ return fs.realpathSync(self) === fs.realpathSync(given);
210
+ }
211
+
212
+ if (isEntrypoint(process.argv[1])) main(process.argv.slice(2));
@@ -0,0 +1,123 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+
3
+ /**
4
+ * Run npm / npx from a build script, on every platform we support.
5
+ *
6
+ * Two separate Windows problems, and fixing only the first is what made this
7
+ * take two attempts:
8
+ *
9
+ * 1. npm ships on Windows as `npm.cmd`. `execFileSync('npm', …)` does not
10
+ * consult `PATHEXT` — that is a shell behaviour and execFile uses no
11
+ * shell — so it fails with `spawnSync npm ENOENT` while the identical
12
+ * command typed into a terminal works.
13
+ *
14
+ * 2. Naming it `npm.cmd` then fails with `spawnSync npm.cmd EINVAL`. Since
15
+ * the fix for CVE-2024-27980, Node refuses to spawn `.cmd` and `.bat`
16
+ * files without `shell: true`, because the Windows command interpreter
17
+ * re-parses the argument list. On Windows there is no shell-free way to
18
+ * invoke npm.
19
+ *
20
+ * So `shell: true` is required there, not a shortcut — and it is scoped to
21
+ * Windows, so macOS and Linux keep passing arguments as an array with no
22
+ * interpreter in the path at all.
23
+ *
24
+ * What `shell: true` costs is argument re-parsing, which matters only for
25
+ * arguments that are not literals. There is exactly one — the tarball name
26
+ * `npm pack` prints — and `assertSafeShellArg()` below is how it is made safe
27
+ * rather than assumed safe. Call it on anything that did not come from this
28
+ * repository's own source.
29
+ *
30
+ * Both scripts on the publish path had written this by hand:
31
+ *
32
+ * check-consumer-audit.mjs -> audit:prod -> verify:release -> prepublishOnly
33
+ * run-tests-isolated.mjs -> test:isolated -> prepublishOnly
34
+ *
35
+ * One owner, so a third caller cannot get it wrong a third way.
36
+ */
37
+ const isWindows = process.platform === 'win32';
38
+
39
+ export const NPM = isWindows ? 'npm.cmd' : 'npm';
40
+ export const NPX = isWindows ? 'npx.cmd' : 'npx';
41
+
42
+ /**
43
+ * Reject anything that could mean something to a command interpreter.
44
+ *
45
+ * Deliberately an allow-list. A deny-list of shell metacharacters has to be
46
+ * complete to be correct, and `cmd.exe` gives `%`, `^` and `&` meanings that a
47
+ * POSIX-shaped deny-list would miss.
48
+ */
49
+ export function assertSafeShellArg(value, what) {
50
+ // `:` and `~` are here for one reason: Windows absolute paths
51
+ // (`C:\Users\RUNNER~1\AppData\Local\Temp\...` — 8.3 short names carry the `~`,
52
+ // and that is literally what `os.tmpdir()` returns on a Windows runner).
53
+ // `smoke-packed-artifact.mjs` passes an absolute `os.tmpdir()` path to
54
+ // `npm pack --pack-destination` and to `npm install`, so without it
55
+ // `npm run test:packaged` throws on Windows before packing anything —
56
+ // fail-closed, but it means the packaged smoke test cannot run there at all.
57
+ // Neither has meaning to cmd.exe (`~` is a POSIX-shell nicety, and the POSIX
58
+ // branch never reaches here — it uses execFileSync with no shell at all).
59
+ // Every character that DOES mean something to cmd.exe (`%^&|<>"'` and
60
+ // whitespace) stays excluded.
61
+ if (typeof value !== 'string' || !/^[A-Za-z0-9._@:~\-+=/\\]+$/.test(value)) {
62
+ throw new Error(
63
+ `${what} is not a safe argument to pass through a shell: ${JSON.stringify(value)}`
64
+ );
65
+ }
66
+ return value;
67
+ }
68
+
69
+ /**
70
+ * Spawn `bin` with `args`.
71
+ *
72
+ * POSIX: no shell, arguments stay an array, nothing is re-parsed.
73
+ *
74
+ * Windows: every argument is validated, then the command line is built here and
75
+ * handed to `execSync`. Passing an ARRAY together with `shell: true` is
76
+ * deprecated (DEP0190) precisely because the arguments are concatenated without
77
+ * escaping — doing the concatenation ourselves, after checking each part, is
78
+ * the same operation with the check that makes it sound, and it does not print
79
+ * a deprecation warning on every release gate.
80
+ *
81
+ * The usual advice — "use execFile with an argument array, never build a shell
82
+ * string" — is right, and is what the POSIX branch does. It does not apply to
83
+ * the Windows branch because there is no execFile that works: npm is a `.cmd`,
84
+ * and Node refuses to exec one without a shell. The choice there is not
85
+ * array-vs-string, it is checked-vs-unchecked. Every argument reaching the
86
+ * concatenation has passed `assertSafeShellArg`, which is an allow-list, so no
87
+ * character with meaning to `cmd.exe` can be in one. `tests/consumer-audit-
88
+ * gate.test.ts` pins that, including the `%` and `^` forms a POSIX-shaped
89
+ * deny-list would miss.
90
+ */
91
+ function runSync(bin, args, opts) {
92
+ if (!isWindows) return execFileSync(bin, args, opts);
93
+ const safeArgs = args.map((arg, i) => assertSafeShellArg(arg, `${bin} argument ${i} (${arg})`));
94
+ return execSync([bin, ...safeArgs].join(' '), opts);
95
+ }
96
+
97
+ /** `npm <args>`, spawned correctly for the platform. */
98
+ export function npmSync(args, opts = {}) {
99
+ return runSync(NPM, args, opts);
100
+ }
101
+
102
+ /** `npx <args>`, spawned correctly for the platform. */
103
+ export function npxSync(args, opts = {}) {
104
+ return runSync(NPX, args, opts);
105
+ }
106
+
107
+ /**
108
+ * `baseEnv` with every spelling of `npm_config_cache` removed and the private
109
+ * cache set. Windows environment names are case-insensitive and Node keeps
110
+ * the lexicographically first of two keys that differ only in case (`N`
111
+ * before `n`); Vitest adds an upper-cased copy of every variable to its
112
+ * Windows workers. So `{ ...process.env, npm_config_cache }` handed npm the
113
+ * runner's `NPM_CONFIG_CACHE=C:\npm\cache` and never the private directory —
114
+ * measured on windows-latest with the fake npm in
115
+ * `tests/consumer-audit-gate.test.ts`.
116
+ */
117
+ export function envWithNpmCache(cacheDir, baseEnv = process.env) {
118
+ const env = Object.fromEntries(
119
+ Object.entries(baseEnv).filter(([key]) => key.toLowerCase() !== 'npm_config_cache'),
120
+ );
121
+ env.npm_config_cache = cacheDir;
122
+ return env;
123
+ }
@@ -91,6 +91,11 @@ MARKETPLACE_DIR="$CLAUDE_CONFIG_ROOT/plugins/marketplaces/pcircle-memesh"
91
91
  INSTALL_REGISTRY="$CLAUDE_CONFIG_ROOT/plugins/installed_plugins.json"
92
92
  CACHE_ROOT="$CLAUDE_CONFIG_ROOT/plugins/cache/pcircle-memesh/memesh"
93
93
  LOCK_DIR="$CACHE_ROOT.lock"
94
+ # Use the checker shipped beside this upgrade script, not a file newly staged
95
+ # from the target commit. This keeps upgrades from legacy marketplace commits
96
+ # testable and prevents a target archive from disabling its own validation.
97
+ UPGRADE_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
98
+ PLUGIN_ARTIFACT_CHECKER="$UPGRADE_SCRIPT_DIR/check-plugin-hook-artifact.mjs"
94
99
 
95
100
  # ─── Pre-flight ────────────────────────────────────────────────────────────
96
101
  if [ ! -d "$MARKETPLACE_DIR" ]; then
@@ -514,6 +519,25 @@ echo "==> Installing runtime deps (this may take a minute)..."
514
519
  exit 1
515
520
  }
516
521
 
522
+ # Validate a real staged plugin before moving it into the live cache. The
523
+ # host registry may load hooks immediately after the swap; a manifest that
524
+ # names a missing script would otherwise surface as a frightening `127` in the
525
+ # user's next session. Tiny legacy marketplace fixtures (and genuinely old
526
+ # plugin archives) have no plugin wiring to validate, so preserve their
527
+ # historical cache-refresh behavior; once wiring is present, the checker is
528
+ # mandatory and fail-closed. The checker itself comes from this script's
529
+ # installed release, never from the target archive being validated.
530
+ if [ -f "$STAGE_PATH/hooks/hooks.json" ] || [ -f "$STAGE_PATH/.claude-plugin/plugin.json" ] || [ -f "$STAGE_PATH/.codex-plugin/plugin.json" ]; then
531
+ if [ ! -f "$PLUGIN_ARTIFACT_CHECKER" ]; then
532
+ echo "ERROR: plugin artifact checker is missing beside the upgrade script — the live cache at $NEW_INSTALL_PATH was not touched" >&2
533
+ exit 1
534
+ fi
535
+ if ! node "$PLUGIN_ARTIFACT_CHECKER" --root "$STAGE_PATH" --skip-pack; then
536
+ echo "ERROR: staged plugin artifact integrity check failed — the live cache at $NEW_INSTALL_PATH was not touched" >&2
537
+ exit 1
538
+ fi
539
+ fi
540
+
517
541
  # ─── 5. Swap the staged copy in ───────────────────────────────────────────
518
542
  if [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; then
519
543
  HAD_LIVE_CACHE=1