* plan-10 Phase 1: ship deterministic plugin runtime dependency closure Approach A — commit & ship plugin/bun.lock so the plugin's runtime node_modules install is deterministic, fixing the recurring `Cannot find module 'zod/v3'` (#2730). - align generated plugin zod range to root (^4.4.3) in build-hooks.js - new scripts/gen-plugin-lockfile.cjs generates plugin/bun.lock as a build artifact after build-hooks.js writes plugin/package.json - track & ship plugin/bun.lock (.gitignore negation, .npmignore, files allowlist) - install with `bun install --frozen-lockfile --ignore-scripts` at runtime Refs #2783, #2730 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * plan-10 Phase 2: fail loud at install time on a broken dependency closure Strengthen verifyCriticalModules to assert each dependency is actually importable via require.resolve (not merely a directory), and assert the worker-required zod subpaths resolve: zod/v3, zod/v4, zod/v4-mini. A partial/stale install now fails `npx claude-mem install` immediately instead of surfacing later as a Stop-hook `Cannot find module 'zod/v3'`. Bin-only packages (e.g. tree-sitter-cli, which has no bare-name entry point) fall back to resolving <dep>/package.json so a healthy install isn't falsely rejected. Adds tests/cli/verify-critical-modules.test.ts covering a missing zod/v3 subpath (throws), a complete zod (passes), and a bin-only dep (passes). Refs #2783, #2730 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * plan-10 Phase 3: clean-room install + import smoke test (#2730 backstop) Add scripts/smoke-clean-room.cjs and a `smoke:clean-room` npm script. Against fresh temp dirs (never the repo's node_modules) it: - copies plugin/, runs `bun install --frozen-lockfile --ignore-scripts`, asserts zod, zod/v3, zod/v4, zod/v4-mini resolve, and boots the bundled worker asserting no `Cannot find module` — the direct #2730 regression guard; - `npm pack`s, installs the tarball into a second temp dir, and load-tests the published bin entrypoint, warning loudly on any declared main/exports target missing from the tarball (latent #2537 gap). Exits non-zero naming the missing module on any failure; cleans up all temp dirs and the tarball in a finally. Refs #2783, #2730 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * plan-10 Phase 4: gate CI and publish on the clean-room dependency closure - ci.yml: new `clean-room-deps` job (between build and the docker e2e job) runs a frozen-lockfile drift check on the committed plugin lockfile, then `npm run build` + `npm run smoke:clean-room`. The drift step catches a contributor who changed plugin deps without regenerating plugin/bun.lock. - npm-publish.yml: add setup-bun and run `npm run smoke:clean-room` between build and `npm publish`, so a broken runtime closure cannot be published on a tag push (ci.yml does not run on tags). Secrets block untouched. Refs #2783, #2730 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * plan-10: doc recluster note + Phase 0 execution slice for #2730 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * plans: backlog recluster (2026-06-04) — cross-cluster execution order + plan-13 doc Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * plan-10: gen-plugin-lockfile degrades gracefully when bun is absent The Windows build CI job has no bun on PATH; regenerating the lockfile there threw and failed the build. The committed plugin/bun.lock is already the deterministic closure, so skip regeneration (non-fatal) when bun is missing and a lockfile exists; fail loud only when neither is available. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
plan-10 EXECUTION — Wave 0: Runtime Dependency Closure + Clean-Room CI Smoke
Master: #2783 · Anchor child: #2730 (
Cannot find module 'zod/v3') · Design doc:plans/10-build-artifact-hygiene.mdFormat: make-plan phased plan, executable with/do. Each phase has What-to-implement / Doc references / Verification / Anti-pattern guards. Scope of THIS plan: the runtime-dependency-completeness slice of plan-10 (the thing that blocks every other Wave). The broader plan-10 children (#2584 better-auth bloat, #2570 bundle-size canary, #2538 typecheck-red, #2537 CLAUDE.md tarball leak) are separate slices — see "Deferred plan-10 scope" at the end.
Phase 0 — Documentation Discovery (facts, decisions, allowed APIs)
This phase is already complete (4 discovery subagents, read-only). Consolidated below. A /do Implementation subagent must READ these files before touching anything; do not re-derive.
Ground truth (file:line)
Dependency declaration
- Root
package.json:125-147— 21 runtime deps incl.zod@^4.4.3(line 145). plugin/package.json— the runtime manifest the worker/Stop-hook install resolves; 27 deps incl.zod@^4.3.6. It is generated byscripts/build-hooks.js:202-249(zod at line 209). Never hand-editplugin/package.json— edit the generator.- Version skew: root
^4.4.3vs plugin^4.3.6(build-hooks.js:209).
Why zod is required at runtime (not bundled)
scripts/build-hooks.js:274-292workerexternalarray includes'zod'(line 277). Also external in server-beta (:343) and context-generator (:448). esbuildexternal: ['zod']matches all subpaths.- The
require("zod/v3")/zod/v4/zod/v4-miniliterals are transitive (from bundled@modelcontextprotocol/sdk/@anthropic-ai/claude-agent-sdk); no first-party source importszod/v3. They appear in the built bundleplugin/scripts/worker-service.cjs(~line 950, minified). - The MCP bundle (
build-hooks.js:421-428) intentionally bundles zod and has a guard failing the build ifrequire('zod...')leaks — because Claude Desktop launches it with no pluginnode_modules. Do not break that guard.
How node_modules is materialized
- Install:
src/npx-cli/install/setup-runtime.ts:370→bun install --ignore-scriptsin the cache dir (~/.claude/plugins/cache/thedotmack/claude-mem/<version>/plugin,paths.ts:47-49). No lockfile, no--frozen-lockfile→ unpinned, resolution-time-dependent. - Marketplace fallback:
install.ts:598→npm install --omit=dev --ignore-scripts(npm-install-helper.ts:53-69). - Post-install guard
verifyCriticalModules(setup-runtime.ts:221-236): checks onlyexistsSync(node_modules/<dep>)(lines 227-228). Does not assert subpath resolution or version.
Why it RECURS on auto-update
- claude-mem has no self-updater. Claude Code refreshes plugin files; the Setup hook
plugin/scripts/version-check.js:60-66only prints a hint (run: npx claude-mem@latest install). Nobun installruns against the new manifest → stale/missingnode_modules/zodpersists until a manual reinstall.
Publish surface
- Root
package.json:43-58filesallowlist shipsplugin/package.jsonbut nonode_modules, no lockfile. .npmignoreexcludesplugin/node_modules/andplugin/bun.lock.prepublishOnly(package.json:113):npm run build && node scripts/check-postinstall-allowlist.js— existing precedent for a CI-time dependency guard.
CI / test harness
.github/workflows/ci.yml— PR gate, jobbuild(lines 9-47): checkout → setup-node 20 → setup-bun →npm install --no-audit --no-fund(line 30) →npm run typecheck→npm run build→bun test. Comment atci.yml:25-28documents that the lockfile is gitignored sonpm ci/cache can't be used. Second jobserver-runtime-e2e-docker(49-78) does not gatebuild..github/workflows/npm-publish.yml— tag-triggered (v*, lines 3-6). Singlepublishjob (8-21):npm install --ignore-scripts(17) →npm run build(18) →npm publish(19),NODE_AUTH_TOKEN(20-21). Noneeds:.- Test runner:
bun test(package.json:104); configbunfig.toml([test] smol=true). Convention example:tests/json-utils.test.ts(imports frombun:test, temp-dir setup/teardown). Nonpm pack/ clean-room test exists anywhere.
Decision gate — ✅ RESOLVED 2026-06-04: Approach A (ship plugin/bun.lock) confirmed by maintainer.
/do should implement Approach A below and must NOT re-prompt. Approach B is retained only as documentation of the rejected alternative.
The repo deliberately gitignores lockfiles (.gitignore:19-20, ci.yml:25-28). Two ways to make the runtime closure deterministic:
- Approach A (CHOSEN): commit & ship a
plugin/bun.lock, install with--frozen-lockfile. Carries the full transitive closure; matches the master's "enforce a boundary on what we ship" and the requested goal. Cost: reverses the gitignore policy scoped to the plugin lockfile only. - Approach B (fallback): exact-pin versions in the generated
plugin/package.json(no^), no lockfile. Respects the no-lockfile policy; deterministic for top-level deps (enough for the literal zod/v3 bug) but does not pin transitive deps. Weaker "full closure" guarantee.
This plan implements Approach A. If the maintainer rejects reversing the lockfile policy, switch Phase 1 to Approach B (the generator edit at build-hooks.js:209 becomes exact-version emission and Phases 2-5 are unchanged). Do not proceed past Phase 1 design without this confirmed.
Allowed APIs / tools (cite when used)
bun install --frozen-lockfile --ignore-scripts(Bun CLI) — pinned install.bun installinplugin/to (re)generateplugin/bun.lock.require.resolve(specifier, { paths })(Node) — subpath resolution assertion.npm pack+npm install <tarball> --prefix <tmp>(npm CLI) — clean-room install.bun test,bun:test(describe/it/expect) — repo test convention.
Anti-patterns to avoid (from discovery)
- ❌ Editing
plugin/package.jsondirectly — it is regenerated bybuild-hooks.js:202-249. - ❌ Removing
'zod'from the workerexternallist to "just bundle it" — diverges from the architecture, bloatsworker-service.cjsagainstWORKER_SERVICE_MAX_BYTES(build-hooks.js:322-329), and doesn't generalize to other missing runtime deps. (Noted as a rejected alternative, not the fix.) - ❌ Breaking the MCP no-external-zod guard (
build-hooks.js:421-428). - ❌ Using
npm ciin CI (no lockfile for the root; and the plugin lockfile is bun's). - ❌ Adding a
process.exit(1)or a blocking reinstall on a hook path (version-check.js) — CLAUDE.md forbids it; auto-reinstall-on-update is out of scope (see Deferred).
Phase 1 — Ship a deterministic runtime dependency closure (Approach A)
What to implement
- Make the generated plugin manifest version-consistent: in
scripts/build-hooks.js:209, align the generatedzodrange with root (package.json:145→^4.4.3). Keep all 27 deps; only fix the skew. Copy the existing generator object shape (lines 202-249) — do not restructure it. - Generate the lockfile as a build artifact: after
build-hooks.jswritesplugin/package.json, runbun installinplugin/to produceplugin/bun.lock. Add a build step (new scriptscripts/gen-plugin-lockfile.cjsinvoked from thebuildscript inpackage.json:65, AFTERbuild-hooks.js) — or extendbuild-hooks.jsto shell out tobun install --cwd plugin. Follow the existingexecSyncpattern used inscripts/sync-marketplace.cjs:138-162. - Commit & ship the lockfile:
.gitignore:19-20— narrow the ignore soplugin/bun.lockis tracked (keep ignoring rootbun.lock/package-lock.jsonif desired; un-ignore the plugin one)..npmignore— remove theplugin/bun.lockexclusion.package.json:43-58files— addplugin/bun.lockto the allowlist.
- Install from the lockfile:
src/npx-cli/install/setup-runtime.ts:370— changebun install --ignore-scripts→bun install --frozen-lockfile --ignore-scripts. Keep--ignore-scripts. (copyPluginToCacheatinstall.ts:574-581already copies the wholeplugin/tree recursively, so the committedplugin/bun.lockis carried into the cache dir automatically — verify, don't add new copy logic.)
Documentation references
- Generator object + zod line:
scripts/build-hooks.js:202-249(zod:209). execSyncinstall pattern to copy:scripts/sync-marketplace.cjs:138-162.- Install command to edit:
src/npx-cli/install/setup-runtime.ts:370. - Recursive plugin copy (confirm lockfile is carried):
src/npx-cli/commands/install.ts:574-581. - Allowlist/ignore files:
package.json:43-58,.npmignore,.gitignore:19-20.
Verification checklist
npm run buildproduces a trackedplugin/bun.lockcontaining zod@4.4.x and all 27 deps.git statusshowsplugin/bun.lockas tracked (not ignored):git check-ignore plugin/bun.lockreturns nothing.npm packtarball containsplugin/bun.lockandplugin/package.json:tar -tzf $(npm pack --silent) | grep -E 'plugin/(bun.lock|package.json)'.- In a temp copy of
plugin/,bun install --frozen-lockfile --ignore-scriptssucceeds with no "lockfile had changes" error (proves manifest⇄lockfile are in sync). - After install,
require.resolve('zod/v3', { paths: ['<tmp>/node_modules'] })resolves. - Generated
plugin/package.jsonzod range equals root (grep '"zod"' plugin/package.json package.json).
Anti-pattern guards
- ❌ Do not hand-edit
plugin/package.json(regenerated). - ❌ Do not drop
--ignore-scripts(postinstall-hang lesson,plans/04). - ❌ Do not commit the root
node_modulesor a root lockfile to "fix" this — scope is the plugin runtime closure. - ❌ Do not change the worker
externallist.
Phase 2 — Make a broken install fail LOUD at install time
What to implement
Strengthen verifyCriticalModules (src/npx-cli/install/setup-runtime.ts:221-236) so it asserts the dependency is actually importable, not merely a directory. For each critical dep, resolve it with require.resolve(dep, { paths: [<cacheDir>/node_modules] }); and for zod specifically, also assert the subpaths the worker requires resolve: zod/v3, zod/v4, zod/v4-mini. On failure, throw the existing loud install error (copy the error-emission pattern already used in that function / the installer error taxonomy from plans/04). The point: a partial/stale install fails npx claude-mem install immediately instead of surfacing later as a Stop-hook Cannot find module.
Documentation references
- Function to extend:
src/npx-cli/install/setup-runtime.ts:221-236(current dir-existence check at 227-228). - Exact subpaths to assert: derived from
plugin/scripts/worker-service.cjs(require("zod/v3"|"zod/v4"|"zod/v4-mini")) — see Phase 0. - Installer error-emission style: reuse the same throw/log this file already uses; cross-ref
plans/04-installer-transparency.mdtaxonomy.
Verification checklist
- New unit test (
tests/cli/verify-critical-modules.test.ts,bun:test, copy shape fromtests/json-utils.test.ts): given a tempnode_moduleswithzodpresent butzod/v3export missing/removed,verifyCriticalModulesTHROWS; given a complete zod v4, it passes. bun test tests/cli/green.- Manual: delete
node_modules/zod/v3*in a temp install →verifyCriticalModulesfails with a clear message namingzod/v3.
Anti-pattern guards
- ❌ Do not assume
require.resolveof the package root implies subpaths resolve — assert subpaths explicitly (that's the whole bug). - ❌ Do not swallow the failure (no empty catch) — it must throw/exit non-zero on the install path (NOT a hook path).
- ❌ Do not hardcode a zod version; assert subpath resolution, which is version-agnostic.
Phase 3 — Clean-room install + import smoke test (the regression backstop)
What to implement
A net-new script scripts/smoke-clean-room.cjs (model its execSync/temp-dir style on scripts/sync-marketplace.cjs + tests/json-utils.test.ts temp-dir handling) that, against a fresh temp dir:
- Plugin-runtime closure (the #2730 guard): copy
plugin/→ tmp,bun install --frozen-lockfile --ignore-scripts, then assertrequire.resolveofzod,zod/v3,zod/v4,zod/v4-minifrom<tmp>/node_modules; then spawnbun <tmp>/scripts/worker-service.cjswith a no-op/--help-style invocation and assert it does not printCannot find module. - npm-package completeness:
npm pack, install the tarball into a second temp dir (npm install <tarball> --prefix <tmp2> --ignore-scripts), thennode -e "require('<tmp2>/.../dist/npx-cli/index.js')"-style load of the publishedbin/mainentrypoints (package.json:29-42) to catch missingdistruntime deps. - Exit non-zero with a precise message on any missing module. Add an npm script
"smoke:clean-room": "node scripts/smoke-clean-room.cjs"near the other scripts (package.json:104-110).
Documentation references
- Temp-dir + spawn conventions:
tests/json-utils.test.ts,scripts/sync-marketplace.cjs:138-173. - Published entrypoints to load:
package.json:29-42(bin,main,exports). - Subpaths to assert: Phase 0 (worker-service.cjs requires).
- Existing prepublish-guard precedent:
scripts/check-postinstall-allowlist.js.
Verification checklist
npm run build && npm run smoke:clean-roomexits 0 on a healthy tree.- Fault injection: temporarily remove
zodfrom the generated manifest (or delete it from the tmpnode_modules) → the script exits non-zero and nameszod/v3. Revert. - Script makes no network calls beyond the package installs and runs offline against the local registry cache where possible (note any unavoidable network in the script header).
- Runtime under ~2-3 min on CI (it does two installs).
Anti-pattern guards
- ❌ Do not run the smoke test against the repo's already-installed
node_modules— it must use a fresh temp dir (the existingtests/server/server-runtime-smoke.test.tsruns in-tree and would NOT catch this class). - ❌ Do not assert on minified symbol names inside
worker-service.cjs; assert on module resolution + absence ofCannot find module. - ❌ Do not leave temp dirs behind (clean up in a
finally).
Phase 4 — Wire into CI and gate publish
What to implement
- PR gate: add a new job to
.github/workflows/ci.yml(insert after thebuildjob ends at line 47, beforeserver-runtime-e2e-docker:at line 49). Copy the runner/setup boilerplate from thebuildjob (lines 11-30:runs-on: ubuntu-latest, checkout@v4, setup-node@v4 node 20, setup-bun@v2,npm install --no-audit --no-fund). Steps:npm run build→npm run smoke:clean-room. Name it e.g.clean-room-deps("clean-room dependency closure smoke"). - Frozen-lockfile drift check: in the same job (or the
buildjob), add a step that runsbun install --frozen-lockfile --ignore-scriptsinsideplugin/and fails if the lockfile is out of sync with the generated manifest (catches a contributor who changed deps but didn't regenerate the lockfile). - Publish gate:
.github/workflows/npm-publish.ymlis tag-triggered and independent ofci.yml, so the gate must live inside it. Insertnpm run smoke:clean-roomas a step betweennpm run build(line 18) andnpm publish(line 19). (Alternatively split into a separate job and addneeds:topublish— inline is simpler and sufficient.)
Documentation references
- PR-gate insertion point + boilerplate to copy:
.github/workflows/ci.yml:9-47. - Publish step to gate:
.github/workflows/npm-publish.yml:17-19. - Lockfile-is-gitignored caveat (now partially reversed for plugin):
ci.yml:25-28.
Verification checklist
actionlint/yaml parses (or GitHub "Actions" tab shows the new job on a draft PR).- On a PR that intentionally breaks the closure (drop a dep in the generator), the
clean-room-depsjob FAILS. - On a clean PR,
clean-room-depsPASSES. - The publish workflow contains the smoke step before
npm publish(grep -n 'smoke:clean-room' .github/workflows/npm-publish.yml). - The frozen-lockfile drift step fails when
plugin/package.jsonis edited without regeneratingplugin/bun.lock.
Anti-pattern guards
- ❌ Do not put the publish gate only in
ci.yml— it does not run on tag pushes; the publish job would remain ungated. - ❌ Do not add
cache: 'npm'/npm ci(no root lockfile). - ❌ Do not duplicate secrets handling; reuse the existing
NODE_AUTH_TOKEN/NPM_TOKENblock.
Phase 5 — Final verification
- Implementation matches docs: re-read Phase 0 file:line anchors and confirm every edit landed where specified (
build-hooks.js:209,setup-runtime.ts:370,setup-runtime.ts:221-236,package.json files,.npmignore,.gitignore, both workflow files, new script + npm script). - Anti-pattern grep:
grep -n "bun install --ignore-scripts" src/npx-cli/install/setup-runtime.ts→ must now read--frozen-lockfile --ignore-scripts.grep -n "plugin/bun.lock" .npmignore→ must return nothing (un-excluded).git check-ignore plugin/bun.lock→ returns nothing (tracked).- confirm worker
externalstill lists'zod'(build-hooks.js:277) and MCP guard intact (:421-428).
- Full suite:
npm run typecheck && npm run build && bun test && npm run smoke:clean-roomall green. - End-to-end repro of #2730: in a throwaway dir, simulate the cache install path (
bun install --frozen-lockfile --ignore-scriptsagainst shippedplugin/), launchworker-service.cjsin a Stop-hook-style invocation, confirm noCannot find module 'zod/v3'. - Issue hygiene: PR body
Closes #2783(and references #2730). The clean-room job + frozen-lockfile drift check are the test-matrix cells this slice contributes to CI (perplans/EXECUTION.md).
Deferred plan-10 scope (NOT this PR — separate slices)
- #2584 —
worker-service.cjsbundles unusedbetter-auth(~3.7MB); externalize/gate behind server runtime. - #2570 — bundle-size canary in CI + cross-platform marketplace-sync.
- #2538 — fix the 24 typecheck-drift errors; make
npm run typechecka required gate. - #2537 —
files/.npmignoreso maintainerCLAUDE.mdfiles don't publish; assert tarball contents.
Each is its own /make-plan → /do. They share plan-10's "enforce a boundary on what we ship" architecture but ship independently after this Wave-0 slice unblocks the rest.
Out-of-scope (other masters)
- Auto-reinstall of deps on update (vs. today's hint-only
version-check.js:65) — lifecycle behavior, route to plan-03 #2780. This plan makes the install deterministic and loud; it does not change WHEN the install runs. - Worker runtime crashes unrelated to dep resolution — plan-03.