wicked-garden 12.60.0 → 12.61.0

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.
@@ -1 +1 @@
1
- {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, multi-model second opinions (fallback seats labelled). Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.60.0"}], "version": "12.60.0"}
1
+ {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, multi-model second opinions (fallback seats labelled). Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.61.0"}], "version": "12.61.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.60.0",
3
+ "version": "12.61.0",
4
4
  "description": "Your coding agent's harness already plans and swarms; Wicked Garden is the curated toolkit for what it can't do alone. Proof, not claims \u2014 gates re-derive 'done' through the internal loom engine (fail-closed, independently attested via wicked-vault), so a self-asserted pass can't lie its way green. Relationships grep can't see \u2014 the wicked-estate code graph + injected bus/dispatch/capability/archetype edges power blast-radius & lineage. Plus deterministic multi-file refactor (wicked-patch), cross-session memory + knowledge (the mem domain over wicked-estate), multi-model second opinions (fallback seats labelled) (jam:council), portable expertise as on-demand skill-refs, and evidence-gated testing (the in-catalog qe domain). It reads the shape of your work to apply the right rigor \u2014 steering, not a pipeline \u2014 then gets out of the way. The compiler stamps a self-contained evidence gate into any repo that runs with no wicked-garden installed. Don't fight the harness; fill its gaps. The evidence gate requires wicked-vault, a self-contained infra peer wicked-garden installs directly; wicked-bus is an opt-in layer \u2014 install what you'll use, the toolkit works without the rest.",
5
5
  "wicked_vault_version": ">=0.8.0",
6
6
  "wicked_ledger_version": "^0.6.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.60.0",
3
+ "version": "12.61.0",
4
4
  "description": "The skills your coding agent can't build alone: \"done\" re-checked from evidence, graph-aware search and multi-file refactor, 40 QE specialists, review panels from other models, and cross-session memory. Works in Claude Code, Codex, Antigravity, OpenCode and Pi, in CI, or in a wicked-crew job.",
5
5
  "keywords": [
6
6
  "coding-agent",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.61.0] — 2026-10-26
6
+
7
+ ### Fixed
8
+ - **`install.py --from-run` finds the pack in a real run (editor and MCP) (#1351).** The engine runs the install-plan and install Tool phases in the run's worktree and sets `WICKED_TREE` to that worktree's git tree id (wicked-core#776), not a path. Both install scripts read it as a directory, found nothing, and every editor-plugin run failed at install-plan with "found 0 (none)", so the install consent gate was never reached. `--from-run` now searches the worktree (the cwd) and uses `WICKED_TREE` only when it names a directory.
9
+ - **A credential-free MCP server installs (`scripts/mcp/install.py`) (#1351).** The installer assumed every server reads a `<SERVER>_TOKEN` secret. A local-computation server has no upstream, and its config names no `baseUrl` and no auth scheme (`auth` absent, null or `{"scheme": "none"}`). The installer still sent `auth: env:<SERVER>_TOKEN`, crew answered `secret_missing`, and the server was never registered, so a design that correctly invented no credential could not be installed. Such a server now smokes and registers with no secret variable. A server with an upstream, and `--from-npm` installs, are unchanged.
10
+ - **An MCP server delivered without a `package-lock.json` installs (`scripts/mcp/install.py`) (#1352).** A container seat can't reach the npm registry, so the server a `/workflow-mcp-server` run delivers has no lockfile. `npm ci` refuses such a tree, so the install failed after the operator had consented, with only npm's debug-log path as the reason. The installer now copies the server to a transient `<root>/<key>/resolve/`, resolves the dependencies there once with `npm install --package-lock=true --ignore-scripts`, and builds there. The staged copy installs from that lock, and the delivered tree is never written. An npm that writes no lock (`package-lock=false`) fails before anything is built. The plan and the install record say `"lockfile": "resolved at install"`, because those versions were picked at install time and the run never reviewed them. A delivered lock still goes through `npm ci`, and the output says `"lockfile": "delivered"`. A failing npm step now reports npm's own reason instead of its debug-log path. That is the `npm error code` line and the first line after it that isn't metadata, plus the script's own last line (tsc's error) when `npm run build` fails.
11
+
5
12
  ## [12.60.0] — 2026-10-26
6
13
 
7
14
  ### Fixed
@@ -1212,7 +1219,8 @@ through `v9.2.18`. Major themes across that span:
1212
1219
 
1213
1220
  For each of those releases, see the corresponding tag in git history.
1214
1221
 
1215
- [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.60.0...HEAD
1222
+ [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.61.0...HEAD
1223
+ [12.61.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.60.0...v12.61.0
1216
1224
  [12.60.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.59.0...v12.60.0
1217
1225
  [12.59.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.58.0...v12.59.0
1218
1226
  [12.58.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.57.0...v12.58.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.60.0",
3
+ "version": "12.61.0",
4
4
  "type": "module",
5
5
  "description": "The skills your coding agent can't build alone: \"done\" re-checked from evidence, graph-aware search and multi-file refactor, 40 QE specialists, review panels from other models, and cross-session memory. Works in Claude Code, Codex, Antigravity, OpenCode and Pi, in CI, or in a wicked-crew job.",
6
6
  "keywords": [
@@ -9,7 +9,7 @@ An editor plugin is a ``wicked-pack.json`` spec-2 pack with ``editors[]`` (scaff
9
9
  ``scripts/editor/scaffold.py``). Install, idempotent by the pack's ``name``:
10
10
 
11
11
  1. **Locate**: ``--from-run`` finds the one spec-2 ``wicked-pack.json`` with editors under
12
- ``$WICKED_TREE`` (else the cwd), outside node_modules/.git/dist; ``--dir`` names it.
12
+ the run's worktree (the cwd; ``$WICKED_TREE`` when it names a directory), outside node_modules/.git/dist; ``--dir`` names it.
13
13
  2. **Stage** into ``<root>/<name>/next/`` (root: ``--install-root``, ``$WICKED_EDITOR_INSTALL_ROOT``,
14
14
  else ``~/.wicked/editors``, a program-owned place): ``wicked-pack.json``, every editor's entry
15
15
  file at its manifest path, and the README. Nothing else of the tree is copied (an editor is ONE
@@ -127,11 +127,19 @@ def _is_editor_pack(manifest_path: Path) -> bool:
127
127
  return False
128
128
 
129
129
 
130
+ def run_tree() -> Path:
131
+ """The run's worktree for ``--from-run``. The engine runs an install Tool phase IN the worktree and hands
132
+ ``$WICKED_TREE`` as that worktree's git tree id (wicked-core#776), never a path; reading the id as a path found
133
+ no pack in any real run. A ``$WICKED_TREE`` that names a directory is still honoured, else the cwd."""
134
+ named = os.environ.get("WICKED_TREE")
135
+ return Path(named) if named and Path(named).is_dir() else Path.cwd()
136
+
137
+
130
138
  def locate(args) -> tuple[Path, dict]:
131
139
  if args.dir is not None:
132
140
  pack = args.dir
133
141
  elif args.from_run:
134
- tree = Path(os.environ.get("WICKED_TREE") or os.getcwd())
142
+ tree = run_tree()
135
143
  found = []
136
144
  for dirpath, dirnames, filenames in os.walk(tree):
137
145
  dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
@@ -372,7 +380,7 @@ def consent_refusal(args, env) -> str | None:
372
380
  def main(argv: list[str] | None = None) -> int:
373
381
  parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
374
382
  where = parser.add_mutually_exclusive_group()
375
- where.add_argument("--from-run", action="store_true", help="find the one editor pack under $WICKED_TREE (else the cwd)")
383
+ where.add_argument("--from-run", action="store_true", help="find the one editor pack in the run's worktree (the cwd, or $WICKED_TREE when it names a directory)")
376
384
  where.add_argument("--dir", type=Path, help="the pack directory")
377
385
  where.add_argument("--uninstall", metavar="NAME", help="unregister and remove an installed editor pack")
378
386
  parser.add_argument("--target", type=parse_target, default=("daemon", None),
@@ -21,12 +21,18 @@ bound URL for every governed unit). There is no port default: a guessed ``:7701`
21
21
  daemon's registry. Neither set exits 2 naming the remedy.
22
22
 
23
23
  1. **Locate** the server: ``--from-run`` finds the one ``mcp-server.config.json`` under
24
- ``$WICKED_TREE`` (else the cwd), outside node_modules/dist; ``--dir`` names it. The key and
24
+ the run's worktree (the cwd; ``$WICKED_TREE`` when it names a directory), outside node_modules/dist; ``--dir`` names it. The key and
25
25
  version come from that config.
26
26
  2. **Stage** into ``<root>/<key>/next/`` (root: ``--install-root``, ``$WICKED_MCP_INSTALL_ROOT``,
27
27
  else ``~/.wicked/mcp-servers``): ``npm ci --ignore-scripts && npm run build`` in the server
28
28
  dir, copy dist/ + package.json + package-lock.json + tools.json + mcp-server.config.json,
29
29
  ``npm ci --omit=dev --ignore-scripts`` there. ``--from-npm`` installs the package instead.
30
+ A server delivered WITHOUT a package-lock.json (a seat with no npm registry cannot write one) is
31
+ copied to a transient ``<root>/<key>/resolve/`` (removed afterwards, like ``next/``), resolved
32
+ there with ``npm install --package-lock=true --ignore-scripts`` and built there; the staged copy
33
+ installs from that lock. The delivered tree is not written. The plan and the record say so
34
+ (``"lockfile": "resolved at install"``): the versions were chosen at install time, not reviewed
35
+ in the run.
30
36
  3. **Smoke** with ``probe.py``. A failure naming ``<SERVER>_TOKEN`` is a pending credential
31
37
  (disclosed, not failed); any other failure exits 1 with ``next/`` removed and nothing else
32
38
  touched.
@@ -94,6 +100,17 @@ def env_name_for(key: str) -> str:
94
100
  return f"{env_prefix(key)}_TOKEN"
95
101
 
96
102
 
103
+ def credential_free(config: dict) -> bool:
104
+ """A server with no upstream has nothing to authenticate to: its committed config names no ``baseUrl`` and no
105
+ auth scheme (``auth`` absent, null, or ``{"scheme": "none"}``), a local-computation server. It smokes and
106
+ registers with no secret variable. Any other server, and an --from-npm install (no config to read), reads the
107
+ one ``<SERVER>_TOKEN`` variable."""
108
+ if not config or "baseUrl" in config:
109
+ return False
110
+ auth = config.get("auth")
111
+ return auth is None or (isinstance(auth, dict) and auth.get("scheme") == "none")
112
+
113
+
97
114
  def crew_origin(flag: str | None) -> str:
98
115
  """The daemon THIS run belongs to — never a guessed port (garden#1258)."""
99
116
  origin = flag or os.environ.get("WICKED_CREW_URL")
@@ -152,14 +169,40 @@ def _now() -> str:
152
169
  return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
153
170
 
154
171
 
172
+ NPM_META = ("npm error path ", "npm error syscall ", "npm error errno ", "npm error command ", "npm error workspace ",
173
+ "npm error location ", "npm error signal ", "npm error cwd ")
174
+
175
+
176
+ def _lines(output: str) -> list[str]:
177
+ return [ln.strip() for ln in (output or "").splitlines()
178
+ if ln.strip() not in ("", "npm error") and "A complete log of this run" not in ln]
179
+
180
+
181
+ def _why(stderr: str, stdout: str = "") -> str:
182
+ """What went wrong, in one line. npm ends with a pointer at its debug log; its reason is the ``npm error code``
183
+ line and the first line after it that is not metadata (path, syscall, errno, command), e.g. "npm error code
184
+ EUSAGE npm error The `npm ci` command can only install with an existing package-lock.json...". A script that
185
+ failed (``npm error code 1``: npm run build) printed its own reason, usually on stdout (tsc): its last line
186
+ rides along. With nothing useful on stderr, the last line of stdout."""
187
+ err, out = _lines(stderr), _lines(stdout)
188
+ codes = [i for i, ln in enumerate(err) if ln.startswith("npm error code")]
189
+ if codes:
190
+ head = err[codes[0]]
191
+ detail = next((ln for ln in err[codes[0] + 1:] if not ln.startswith(NPM_META)), None)
192
+ parts = [head, *([detail] if detail else [])]
193
+ if head == "npm error code 1" and out:
194
+ parts.append(out[-1])
195
+ return " ".join(parts)
196
+ return (err or out or [""])[-1]
197
+
198
+
155
199
  def _run(argv: list[str], cwd: Path, what: str, timeout: float = NPM_TIMEOUT_S) -> None:
156
200
  try:
157
201
  done = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=timeout)
158
202
  except (OSError, subprocess.TimeoutExpired) as err:
159
203
  raise Failure(1, f"{what} failed: {err}") from err
160
204
  if done.returncode != 0:
161
- tail = (done.stderr or done.stdout).strip().splitlines()[-1:] or [""]
162
- raise Failure(1, f"{what} exited {done.returncode}: {tail[0][:300]}")
205
+ raise Failure(1, f"{what} exited {done.returncode}: {_why(done.stderr, done.stdout)[:300]}")
163
206
 
164
207
 
165
208
  def _npm() -> str:
@@ -224,6 +267,14 @@ def find_configs(tree: Path) -> list[Path]:
224
267
  return found
225
268
 
226
269
 
270
+ def run_tree() -> Path:
271
+ """The run's worktree for ``--from-run``. The engine runs an install Tool phase IN the worktree and hands
272
+ ``$WICKED_TREE`` as that worktree's git tree id (wicked-core#776), never a path; reading the id as a path found
273
+ no server config in any real run. A ``$WICKED_TREE`` that names a directory is still honoured, else the cwd."""
274
+ named = os.environ.get("WICKED_TREE")
275
+ return Path(named) if named and Path(named).is_dir() else Path.cwd()
276
+
277
+
227
278
  def locate(args) -> tuple[Path | None, dict]:
228
279
  if args.dir is not None:
229
280
  config_path = args.dir / CONFIG_NAME
@@ -232,7 +283,7 @@ def locate(args) -> tuple[Path | None, dict]:
232
283
  return None, {}
233
284
  raise Failure(2, f"{args.dir} has no {CONFIG_NAME}; scaffold the server first")
234
285
  elif args.from_run:
235
- tree = Path(os.environ.get("WICKED_TREE") or os.getcwd())
286
+ tree = run_tree()
236
287
  found = find_configs(tree)
237
288
  if len(found) != 1:
238
289
  names = ", ".join(str(p) for p in found) or "none"
@@ -252,8 +303,9 @@ def locate(args) -> tuple[Path | None, dict]:
252
303
 
253
304
  # ── 3. smoke ───────────────────────────────────────────────────────────────────────────────
254
305
 
255
- def smoke(server_js: Path, var: str) -> tuple[str, dict]:
256
- argv = [sys.executable, str(PROBE), "--timeout", str(PROBE_TIMEOUT_S), "--env", var, "--",
306
+ def smoke(server_js: Path, var: str | None) -> tuple[str, dict]:
307
+ env_args = ["--env", var] if var else []
308
+ argv = [sys.executable, str(PROBE), "--timeout", str(PROBE_TIMEOUT_S), *env_args, "--",
257
309
  "node", str(server_js)]
258
310
  try:
259
311
  done = subprocess.run(argv, capture_output=True, text=True, timeout=PROBE_TIMEOUT_S + 30)
@@ -262,7 +314,7 @@ def smoke(server_js: Path, var: str) -> tuple[str, dict]:
262
314
  return "failed", {"ok": False, "error": f"probe did not run: {err}"}
263
315
  if report.get("ok") is True:
264
316
  return "ok", report
265
- if var in (report.get("stderrNames") or []):
317
+ if var and var in (report.get("stderrNames") or []):
266
318
  return "pending_credential", report
267
319
  return "failed", report
268
320
 
@@ -311,9 +363,10 @@ def check_key(origin: str, key: str, server_js: Path) -> dict | None:
311
363
 
312
364
  # ── 5. register ────────────────────────────────────────────────────────────────────────────
313
365
 
314
- def register(origin: str, key: str, server_js: Path, var: str) -> dict:
315
- body = {"name": key, "kind": "mcp-stdio", "command": "node", "args": [str(server_js)],
316
- "auth": {"ref": f"env:{var}", "env": var}}
366
+ def register(origin: str, key: str, server_js: Path, var: str | None) -> dict:
367
+ body: dict = {"name": key, "kind": "mcp-stdio", "command": "node", "args": [str(server_js)]}
368
+ if var:
369
+ body["auth"] = {"ref": f"env:{var}", "env": var}
317
370
  try:
318
371
  status, preview = http_json("POST", f"{origin}/api/v1/mcp/servers/preview", body)
319
372
  except (urllib.error.URLError, OSError):
@@ -396,6 +449,19 @@ def _source(server_dir: Path | None, npm_spec: str | None) -> dict:
396
449
  "dir": str(server_dir)}
397
450
 
398
451
 
452
+ def lockfile_state(server_dir: Path | None, npm_spec: str | None) -> str | None:
453
+ """Whether the server's dependency versions were delivered with it (``"delivered"``) or are chosen when it
454
+ installs (``"resolved at install"``: no package-lock.json in the server dir). ``None`` for --from-npm."""
455
+ if npm_spec or server_dir is None:
456
+ return None
457
+ return "delivered" if (server_dir / "package-lock.json").is_file() else "resolved at install"
458
+
459
+
460
+ def resolve_dir(next_dir: Path) -> Path:
461
+ """Where a server delivered without a lock is copied, resolved and built: beside next/, and as transient."""
462
+ return next_dir.with_name("resolve")
463
+
464
+
399
465
  def stage(server_dir: Path | None, npm_spec: str | None, next_dir: Path) -> Path:
400
466
  """Build + copy into next/; the server.js path inside next/."""
401
467
  npm = _npm()
@@ -407,15 +473,29 @@ def stage(server_dir: Path | None, npm_spec: str | None, next_dir: Path) -> Path
407
473
  server_js = next_dir / "node_modules" / Path(*pkg.split("/")) / "dist" / "server.js"
408
474
  else:
409
475
  assert server_dir is not None
410
- _run([npm, "ci", "--ignore-scripts"], server_dir, "npm ci (server dir)")
411
- _run([npm, "run", "build"], server_dir, "npm run build")
412
- if not (server_dir / "dist").is_dir():
476
+ if lockfile_state(server_dir, None) == "delivered":
477
+ build_dir = server_dir
478
+ _run([npm, "ci", "--ignore-scripts"], build_dir, "npm ci (server dir)")
479
+ else:
480
+ # `npm ci` refuses a tree with no lock, and every server a container seat builds has none (the seat
481
+ # cannot reach the registry; j6-cert, unit-convert, 2026-10-26). The lock is resolved in a throwaway
482
+ # copy beside next/, never in the delivered tree: a lock left there would read as "delivered" on the
483
+ # next plan, and it is a write the consent plan does not list.
484
+ build_dir = resolve_dir(next_dir)
485
+ shutil.copytree(server_dir, build_dir, ignore=shutil.ignore_patterns("node_modules", "dist", ".git"))
486
+ _run([npm, "install", "--package-lock=true", "--ignore-scripts", "--no-audit", "--no-fund"], build_dir,
487
+ "npm install (no package-lock.json delivered)")
488
+ if not (build_dir / "package-lock.json").is_file():
489
+ raise Failure(1, "npm install wrote no package-lock.json, so the staged copy cannot be installed "
490
+ "from a lock; nothing was installed")
491
+ _run([npm, "run", "build"], build_dir, "npm run build")
492
+ if not (build_dir / "dist").is_dir():
413
493
  raise Failure(1, f"npm run build left no dist/ in {server_dir}")
414
494
  next_dir.mkdir(parents=True)
415
- shutil.copytree(server_dir / "dist", next_dir / "dist")
495
+ shutil.copytree(build_dir / "dist", next_dir / "dist")
416
496
  for name in STAGE_FILES:
417
- if (server_dir / name).is_file():
418
- shutil.copy2(server_dir / name, next_dir / name)
497
+ if (build_dir / name).is_file():
498
+ shutil.copy2(build_dir / name, next_dir / name)
419
499
  if not (next_dir / "package-lock.json").is_file():
420
500
  raise Failure(1, f"{server_dir} has no package-lock.json (npm ci needs it)")
421
501
  _run([npm, "ci", "--omit=dev", "--ignore-scripts"], next_dir, "npm ci --omit=dev (staged copy)")
@@ -451,18 +531,20 @@ def do_install(args) -> tuple[int, dict]:
451
531
  version = config.get("version")
452
532
  if args.from_npm:
453
533
  version = NPM_SPEC_RE.match(args.from_npm).group("version")
454
- var = env_name_for(key)
534
+ var = None if credential_free(config) else env_name_for(key)
455
535
  root = install_root(args.install_root)
456
536
  key_root = root / key
457
537
  next_dir = key_root / "next"
458
538
  origin = crew_origin(args.crew_url)
459
539
  check_key(origin, key, key_root / "current" / installed_rel(args.from_npm))
460
- if next_dir.is_symlink():
461
- next_dir.unlink()
462
- elif next_dir.exists():
463
- shutil.rmtree(next_dir) # a stale half-staged copy from an interrupted run
540
+ for stale in (next_dir, resolve_dir(next_dir)): # a half-staged copy from an interrupted run
541
+ if stale.is_symlink():
542
+ stale.unlink()
543
+ elif stale.exists():
544
+ shutil.rmtree(stale)
464
545
  key_root.mkdir(parents=True, exist_ok=True)
465
546
 
547
+ lock = lockfile_state(server_dir, args.from_npm) # before staging: an `npm install` there writes one
466
548
  try:
467
549
  staged_js = stage(server_dir if not args.from_npm else None, args.from_npm, next_dir)
468
550
  smoke_state, probe_report = smoke(staged_js, var)
@@ -471,11 +553,14 @@ def do_install(args) -> tuple[int, dict]:
471
553
  except Failure:
472
554
  shutil.rmtree(next_dir, ignore_errors=True)
473
555
  raise
556
+ finally:
557
+ shutil.rmtree(resolve_dir(next_dir), ignore_errors=True)
474
558
  swap(key_root)
475
559
  current_js = key_root / "current" / staged_js.relative_to(next_dir)
476
560
 
477
561
  record = {"key": key, "version": version, "source": _source(server_dir, args.from_npm),
478
- "command": "node", "args": [str(current_js)], "envNames": [var],
562
+ **({"lockfile": lock} if lock else {}),
563
+ "command": "node", "args": [str(current_js)], "envNames": [var] if var else [],
479
564
  "installedAt": _now()}
480
565
  code = 0
481
566
  try:
@@ -552,7 +637,8 @@ def do_plan(args) -> dict:
552
637
  _cli_rows(installer([*upsert, *worker_home_args()], download=False), root, worker_writes, skipped)
553
638
  operator_writes = list(worker_writes)
554
639
  _cli_rows(installer(upsert, download=False), root, operator_writes, skipped)
555
- return {"dry_run": True, "key": key, "choices": [
640
+ lock = lockfile_state(server_dir, args.from_npm)
641
+ return {"dry_run": True, "key": key, **({"lockfile": lock} if lock else {}), "choices": [
556
642
  {"id": "worker", "label": CHOICE_LABELS["worker"], "default": True, "writes": worker_writes},
557
643
  {"id": "operator", "label": CHOICE_LABELS["operator"], "writes": operator_writes}],
558
644
  "skipped": skipped}
@@ -596,7 +682,7 @@ def main(argv: list[str] | None = None) -> int:
596
682
  parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
597
683
  where = parser.add_mutually_exclusive_group()
598
684
  where.add_argument("--from-run", action="store_true",
599
- help=f"find the one {CONFIG_NAME} under $WICKED_TREE (else the cwd)")
685
+ help=f"find the one {CONFIG_NAME} in the run's worktree (the cwd, or $WICKED_TREE when it names a directory)")
600
686
  where.add_argument("--dir", type=Path, help="the server directory")
601
687
  where.add_argument("--uninstall", metavar="KEY", help="unregister and remove an installed server")
602
688
  parser.add_argument("--key", help="the server key (must equal the config's key)")
@@ -203,8 +203,8 @@ wicked-garden run scripts/mcp/install.py --uninstall <key> [--target worker|oper
203
203
  Installs the built TypeScript server — or updates the installed copy — idempotent by the
204
204
  config's `key`:
205
205
 
206
- 1. **Locate**: `--from-run` finds the one `mcp-server.config.json` under `$WICKED_TREE`
207
- (else the cwd), outside `node_modules`/`dist`; more or fewer than one exits 2 naming the
206
+ 1. **Locate**: `--from-run` finds the one `mcp-server.config.json` in the run's worktree (the
207
+ cwd; `$WICKED_TREE` is the worktree's tree id, used only when it names a directory), outside `node_modules`/`dist`; more or fewer than one exits 2 naming the
208
208
  candidates. `--dir` names it. The version is the config's `version`.
209
209
  2. **Stage**: `npm ci --ignore-scripts && npm run build` in the server dir, then `dist/`,
210
210
  `package.json`, `package-lock.json`, `tools.json` and `mcp-server.config.json` are copied