wicked-garden 12.56.0 → 12.57.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.56.0"}], "version": "12.56.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.57.0"}], "version": "12.57.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.56.0",
3
+ "version": "12.57.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",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.57.0] — 2026-10-18
6
+
7
+ ### Added
8
+ - **The estate shim relays through wicked-crew's broker on a container seat (wicked-core#1018).** A seat on the container floor has no estate of its own (no store is mounted, no `wicked-estate-mcp` is installed, no store pin reaches it), so every blast-radius, hotspot and recall lookup and every capture proposal was refused there. When a governed unit holds the broker channel (`WICKED_CREW_URL` + `WICKED_MCP_TOKEN`) and cannot reach estate locally (wicked-core's `WICKED_ESTATE_BROKER` marker, no pinned store, or no local binary), `scripts/_estate_client.py` sends each call to `POST /api/v1/mcp/call` as `mcp:estate/<tool>`; crew answers it on the host, scoped to the run. It is one more transport behind the same seam, so the skills that call the shim are unchanged: `--readonly` keeps its meaning (the read-only lookups plus `proposal.submit`), a `-32602` from estate keeps its code, and `health` asks the broker without spending a call. With neither a pinned store nor the channel, a governed unit still fails closed, and the reason now names both fixes. A relayed call the broker refused returns the usual empty value plus `broker_refusal` with its reason; `mode` reports the `transport`. Needs wicked-crew with the estate relay.
9
+
5
10
  ## [12.56.0] — 2026-10-17
6
11
 
7
12
  ### Added
@@ -1165,7 +1170,8 @@ through `v9.2.18`. Major themes across that span:
1165
1170
 
1166
1171
  For each of those releases, see the corresponding tag in git history.
1167
1172
 
1168
- [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.56.0...HEAD
1173
+ [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.57.0...HEAD
1174
+ [12.57.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.56.0...v12.57.0
1169
1175
  [12.56.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.55.1...v12.56.0
1170
1176
  [12.55.1]: https://github.com/mikeparcewski/wicked-garden/compare/v12.55.0...v12.55.1
1171
1177
  [12.55.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.54.0...v12.55.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.56.0",
3
+ "version": "12.57.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": [
@@ -64,6 +64,24 @@ never through the raw write CLI. In that mode every spawn is
64
64
  invocation only with ``--readonly`` AND a pinned store (core #471); this
65
65
  module produces exactly that argv shape for the spawned MCP.
66
66
 
67
+ Container seats: the broker relay (wicked-core#1018)
68
+ ----------------------------------------------------
69
+ A seat on wicked-crew's container floor has no estate of its own: the host's
70
+ stores are never mounted into it (SQLite WAL across the VM file share is a
71
+ corruption risk), the seat image ships no ``wicked-estate-mcp``, and no store
72
+ pin reaches it. Its one way out is wicked-crew's broker, which every governed
73
+ unit can reach with ``WICKED_CREW_URL`` + ``WICKED_MCP_TOKEN`` (a token bound
74
+ to that unit). So in a governed unit that cannot reach estate locally (the
75
+ container marker ``WICKED_ESTATE_BROKER`` is set, no store is pinned, or no
76
+ ``wicked-estate-mcp`` resolves) and that holds the broker channel, every call
77
+ is RELAYED: ``POST <WICKED_CREW_URL>/api/v1/mcp/call {token, subject:
78
+ "mcp:estate/<tool>", args}``. Crew answers it on the host, scoped to the run
79
+ the token belongs to: the read-only lookups plus ``proposal.submit``, exactly
80
+ what a ``--readonly`` MCP allows. No caller changes: the relay is one more
81
+ transport behind the same seam (`_transport`). With neither a pinned local
82
+ store nor the broker channel, a governed unit still fails closed with a reason
83
+ (`governed_refusal`).
84
+
67
85
  Fail-open contract
68
86
  ------------------
69
87
  Every public function is fail-open: on a missing binary, spawn failure,
@@ -88,6 +106,8 @@ import shutil
88
106
  import subprocess
89
107
  import sys
90
108
  import threading
109
+ import urllib.error
110
+ import urllib.request
91
111
  from pathlib import Path
92
112
  from typing import Any, Callable, Optional
93
113
 
@@ -192,6 +212,22 @@ DB_FLAG = "--db"
192
212
  # capture proposals (`facets.project`), overriding whatever the worker self-derived;
193
213
  # absent/blank (older cores, or a non-run invocation) it changes nothing.
194
214
  RUN_PROJECT_ENV = "WICKED_RUN_PROJECT"
215
+ # The broker relay (wicked-core#1018). wicked-core sets the marker on a container seat whose
216
+ # gateway reaches crew's broker; the URL and the unit-bound token are on every governed unit.
217
+ BROKER_ENV = "WICKED_ESTATE_BROKER"
218
+ CREW_URL_ENV = "WICKED_CREW_URL"
219
+ MCP_TOKEN_ENV = "WICKED_MCP_TOKEN"
220
+ BROKER_SUBJECT_PREFIX = "mcp:estate/"
221
+ # The broker's own per-call deadline is 30 s; the HTTP wait outlasts it so the broker, not the
222
+ # client, decides a slow call (a proposal that timed out client-side may still have landed).
223
+ BROKER_MIN_TIMEOUT_S = 35.0
224
+ # The tools crew relays (`wicked-crew` `src/mcp/estate-relay.ts`): what `list-tools` reports in
225
+ # relay mode. A tool off this list is refused by the broker with its reason.
226
+ BROKER_TOOLS = (
227
+ "SearchEntity", "RetrieveEntity", "TraverseGraph", "Path", "BlastRadius", "Lineage",
228
+ "RankHotspots", "Communities", "RulesInventory", "rules.recall", "memory.recall",
229
+ "knowledge.recall", "proposal.submit",
230
+ )
195
231
 
196
232
  _readonly_flag: bool = False # set by --readonly / set_readonly()
197
233
  _governed_flag: bool = False # set by --governed / set_governed()
@@ -249,20 +285,48 @@ def store_pinned() -> bool:
249
285
  return bool(_db_override) or any(os.environ.get(k, "").strip() for k in STORE_PIN_ENV)
250
286
 
251
287
 
288
+ def broker_channel() -> Optional[tuple]:
289
+ """``(url, token)`` for crew's broker when this unit holds the channel, else None."""
290
+ url = os.environ.get(CREW_URL_ENV, "").strip().rstrip("/")
291
+ token = os.environ.get(MCP_TOKEN_ENV, "").strip()
292
+ if not url or not token or not (url.startswith("http://") or url.startswith("https://")):
293
+ return None
294
+ return url, token
295
+
296
+
297
+ def use_broker() -> bool:
298
+ """Relay through crew's broker? A governed unit that holds the broker channel and cannot
299
+ reach estate locally: the container marker is set, no store is pinned, or no
300
+ `wicked-estate-mcp` resolves (module docstring, wicked-core#1018)."""
301
+ if not is_governed() or broker_channel() is None:
302
+ return False
303
+ return _env_truthy(BROKER_ENV) or not store_pinned() or resolve_mcp_bin() is None
304
+
305
+
306
+ def transport_name() -> Optional[str]:
307
+ """``broker`` (relayed), ``local`` (spawn a pinned `wicked-estate-mcp`), or None (refused)."""
308
+ if use_broker():
309
+ return "broker"
310
+ return None if governed_refusal() else "local"
311
+
312
+
252
313
  def governed_refusal() -> Optional[str]:
253
- """The reason NOT to spawn: a governed run with no pinned store. None = clear to go.
314
+ """The reason NOT to call estate: a governed run with no pinned store and no broker
315
+ channel. None = clear to go.
254
316
 
255
317
  Belt-and-braces with the gate hook (which denies the same shape as `no-pin`):
256
- the shim never resolves the operator's default stores from inside a run.
318
+ the shim never resolves the operator's default stores from inside a run. A unit
319
+ that holds the broker channel relays instead (`use_broker`): crew picks the store.
257
320
  """
258
321
  marker = governed_marker()
259
- if marker is None or store_pinned():
322
+ if marker is None or store_pinned() or use_broker():
260
323
  return None
261
324
  return (
262
- f"governed unit ({marker} is set) but no estate store is pinned: pass "
263
- f"{DB_FLAG} <path> or set {' / '.join(STORE_PIN_ENV)} in the worker environment; "
264
- "refusing to spawn wicked-estate-mcp against an unpinned store (it would resolve "
265
- "the operator's defaults)"
325
+ f"governed unit ({marker} is set) but no estate store is pinned and no broker "
326
+ f"channel is present: pass {DB_FLAG} <path> or set {' / '.join(STORE_PIN_ENV)} in "
327
+ f"the worker environment, or run where wicked-crew hands the unit {CREW_URL_ENV} "
328
+ f"and {MCP_TOKEN_ENV} (its estate relay); refusing to spawn wicked-estate-mcp "
329
+ "against an unpinned store (it would resolve the operator's defaults)"
266
330
  )
267
331
 
268
332
 
@@ -684,6 +748,120 @@ def _maybe_install_broker() -> None:
684
748
  _maybe_install_broker()
685
749
 
686
750
 
751
+ # ─────────────────────────────────────────────────────────────────────────────
752
+ # Broker relay transport (wicked-core#1018) — same contract as `_dispatch`
753
+ # ─────────────────────────────────────────────────────────────────────────────
754
+
755
+ #: The last reason the broker gave for not answering (a refusal, an unreachable broker), so
756
+ #: the CLI can say WHY a relayed call came back empty instead of a bare empty result.
757
+ _last_broker_refusal: Optional[str] = None
758
+
759
+
760
+ def _broker_post(url: str, body: dict, timeout: float) -> "tuple[Optional[int], Optional[dict], Optional[str]]":
761
+ """POST JSON to the broker: ``(status, body, None)``, or ``(None, None, why)`` unreachable."""
762
+ req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), method="POST",
763
+ headers={"Content-Type": "application/json"})
764
+ try:
765
+ with urllib.request.urlopen(req, timeout=timeout) as res:
766
+ status, raw = res.status, res.read()
767
+ except urllib.error.HTTPError as err:
768
+ status, raw = err.code, err.read()
769
+ except (urllib.error.URLError, OSError, ValueError) as err:
770
+ return None, None, f"the broker at {url} did not answer: {getattr(err, 'reason', err)}"
771
+ try:
772
+ parsed = json.loads(raw.decode("utf-8")) if raw else {}
773
+ except (UnicodeDecodeError, json.JSONDecodeError):
774
+ return status, None, f"the broker answered {status} with a body that is not JSON"
775
+ return (status, parsed, None) if isinstance(parsed, dict) else (status, None, "not a JSON object")
776
+
777
+
778
+ def _broker_init(rid: Any) -> dict:
779
+ return {"jsonrpc": "2.0", "id": rid, "result": {
780
+ "protocolVersion": "2024-11-05", "capabilities": {},
781
+ "serverInfo": {"name": "wicked-estate", "version": "relay"}}}
782
+
783
+
784
+ def _broker_dispatch(requests: list, *, db: Optional[str], timeout: float) -> dict:
785
+ """Relay transport: answer a JSON-RPC batch through crew's broker. Return {id: response}.
786
+
787
+ * ``initialize`` is answered once the broker has answered anything in the batch; alone
788
+ (a `health()` probe) it asks the broker for this unit's tool list, which spends no
789
+ call budget, so a dead broker or a revoked token is unhealthy.
790
+ * ``tools/list`` lists the relayed tools (`BROKER_TOOLS`).
791
+ * ``tools/call`` is ``POST /api/v1/mcp/call`` with subject ``mcp:estate/<name>``. An
792
+ ``ok`` answer hands back the envelope crew relayed; a JSON-RPC error estate raised
793
+ (``result.rpcError``) becomes the same ``error`` a local server would send, code kept
794
+ (``-32602`` = fix the arguments). Any refusal becomes an ``error`` with code None
795
+ and its reason and remedy, also kept in `_last_broker_refusal`.
796
+
797
+ ``db`` is ignored: crew picks the store (the run's own). Fail-open: an unreachable
798
+ broker leaves the ids out, as a dead local server does.
799
+ """
800
+ global _last_broker_refusal
801
+ _last_broker_refusal = None
802
+ channel = broker_channel()
803
+ if channel is None:
804
+ return {}
805
+ url, token = channel
806
+ calls = [r for r in requests if r.get("id") is not None and r.get("method") == "tools/call"]
807
+ responses: dict = {}
808
+ if not calls:
809
+ # `health()` / `list_tools()`: ask the broker for this unit's tool list — it checks the
810
+ # token and spends no call budget. A dead broker or a revoked token answers nothing.
811
+ status, body, why = _broker_post(f"{url}/api/v1/mcp/tools", {"token": token}, max(timeout, 5.0))
812
+ if status != 200 or body is None:
813
+ _last_broker_refusal = why or str((body or {}).get("error") or f"the broker answered {status}")
814
+ return {}
815
+ for req in requests:
816
+ rid = req.get("id")
817
+ if rid is None:
818
+ continue
819
+ if req.get("method") == "initialize":
820
+ responses[rid] = _broker_init(rid)
821
+ elif req.get("method") == "tools/list":
822
+ responses[rid] = {"jsonrpc": "2.0", "id": rid,
823
+ "result": {"tools": [{"name": t} for t in BROKER_TOOLS]}}
824
+ return responses
825
+ reached = False
826
+ for req in calls:
827
+ rid = req["id"]
828
+ params = req.get("params") or {}
829
+ tool = str(params.get("name") or "")
830
+ status, body, why = _broker_post(
831
+ f"{url}/api/v1/mcp/call",
832
+ {"token": token, "subject": BROKER_SUBJECT_PREFIX + tool, "args": params.get("arguments") or {}},
833
+ max(timeout, BROKER_MIN_TIMEOUT_S))
834
+ if status is None or body is None:
835
+ _last_broker_refusal = why
836
+ continue
837
+ reached = True
838
+ result = body.get("result")
839
+ if body.get("outcome") == "ok" and isinstance(result, dict):
840
+ rpc = result.get("rpcError")
841
+ if isinstance(rpc, dict):
842
+ responses[rid] = {"jsonrpc": "2.0", "id": rid, "error": {
843
+ "code": rpc.get("code"), "message": rpc.get("message") or "estate error"}}
844
+ else:
845
+ responses[rid] = {"jsonrpc": "2.0", "id": rid, "result": result}
846
+ continue
847
+ outcome = body.get("outcome") or body.get("code") or f"http {status}"
848
+ reason = body.get("reason") or body.get("error") or "refused"
849
+ remedy = body.get("remedy")
850
+ message = f"broker {outcome}: {reason}" + (f" ({remedy})" if remedy else "")
851
+ _last_broker_refusal = message
852
+ responses[rid] = {"jsonrpc": "2.0", "id": rid, "error": {"code": None, "message": message}}
853
+ if reached:
854
+ for req in requests:
855
+ if req.get("method") == "initialize" and req.get("id") is not None:
856
+ responses[req["id"]] = _broker_init(req["id"])
857
+ return responses
858
+
859
+
860
+ def _transport() -> Callable[..., dict]:
861
+ """The transport this call uses: the broker relay when `use_broker`, else the local seam."""
862
+ return _broker_dispatch if use_broker() else _active_dispatch
863
+
864
+
687
865
  # ─────────────────────────────────────────────────────────────────────────────
688
866
  # JSON-RPC — handshake + one tool call, all fail-open
689
867
  # ─────────────────────────────────────────────────────────────────────────────
@@ -721,7 +899,7 @@ def _rpc(method: str, params: dict, *, timeout: float) -> Optional[dict]:
721
899
  {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
722
900
  {"jsonrpc": "2.0", "id": 2, "method": method, "params": params},
723
901
  ]
724
- responses = _active_dispatch(requests, db=resolve_db(), timeout=timeout)
902
+ responses = _transport()(requests, db=resolve_db(), timeout=timeout)
725
903
  if 1 not in responses: # handshake never landed → estate unreachable
726
904
  return None
727
905
  return responses.get(2)
@@ -787,7 +965,7 @@ def health(timeout: float = 5.0) -> bool:
787
965
  """
788
966
  try:
789
967
  requests = [_initialize_request()]
790
- responses = _active_dispatch(requests, db=resolve_db(), timeout=timeout)
968
+ responses = _transport()(requests, db=resolve_db(), timeout=timeout)
791
969
  resp = responses.get(1)
792
970
  return bool(
793
971
  isinstance(resp, dict)
@@ -1189,6 +1367,10 @@ _USAGE = (
1189
1367
 
1190
1368
 
1191
1369
  def _emit(obj: Any) -> None:
1370
+ # A relayed call that came back empty says why (the broker's reason and remedy), additively:
1371
+ # the fail-open empty value is unchanged.
1372
+ if isinstance(obj, dict) and _last_broker_refusal and "broker_refusal" not in obj:
1373
+ obj = {**obj, "broker_refusal": _last_broker_refusal}
1192
1374
  sys.stdout.write(json.dumps(obj))
1193
1375
  sys.stdout.write("\n")
1194
1376
 
@@ -1207,7 +1389,8 @@ def main(argv: list) -> int:
1207
1389
  # A pure local probe — reports the mode without spawning, refusal included.
1208
1390
  _emit({"governed": is_governed(), "marker": governed_marker(),
1209
1391
  "readonly": read_only(), "store_pinned": store_pinned(),
1210
- "db": resolve_db(), "refusal": governed_refusal()})
1392
+ "db": resolve_db(), "transport": transport_name(),
1393
+ "broker": broker_channel() is not None, "refusal": governed_refusal()})
1211
1394
  return 0
1212
1395
  refusal = governed_refusal()
1213
1396
  if refusal:
@@ -41,9 +41,10 @@ wicked-garden run scripts/_estate_client.py --readonly propose '{"kind_type":"kn
41
41
 
42
42
  `--readonly` is literal (the shim spawns `wicked-estate-mcp --readonly`; `proposal.submit`
43
43
  is the safe write it still permits). The store is pinned from the worker environment
44
- (`WICKED_ESTATE_DB` / `WICKED_HOME` / `WICKED_MEMORY_DB`) or `--db <path>`; in a run an
45
- unpinned store is refused with `{"ok": false, "reason": …}` — report it, do not guess a
46
- store. The shim answers `{"ok": true, "id": "<proposal-id>"}` or `{"ok": false,
44
+ (`WICKED_ESTATE_DB` / `WICKED_HOME` / `WICKED_MEMORY_DB`) or `--db <path>`; a container seat
45
+ relays the same `propose` through wicked-crew's broker (`WICKED_CREW_URL` + `WICKED_MCP_TOKEN`),
46
+ which files it under the run's project. With neither, a run refuses it with `{"ok": false,
47
+ "reason": …}` — report it, do not guess a store. The shim answers `{"ok": true, "id": "<proposal-id>"}` or `{"ok": false,
47
48
  "reason": …, "code": <json-rpc code or null>}`: `-32602` is a *your-args* bug (fix the
48
49
  shape), `null` means the estate was unreachable (keep the objects — next section). Pass
49
50
  the JSON as ONE quoted argument, even when it is long: `propose` does not read stdin. A
@@ -87,10 +87,13 @@ wicked-garden run scripts/_estate_client.py --readonly call '{"tool":"BlastRadiu
87
87
  ```
88
88
  `--readonly` is literal (the spawned estate binary reads only; `WICKED_ESTATE_READONLY=1` rides
89
89
  every worker so it spawns read-only by default). The store rides `WICKED_ESTATE_DB` /
90
- `WICKED_HOME` / `WICKED_MEMORY_DB` from the environment, or `--db <path>`; the shim refuses an
91
- unpinned store (`{"ok": false, "reason": …}`) — report that, never guess a store. There is no
92
- other transport: no estate tool is registered on any seat, so there is nothing to "connect",
93
- nothing to fall back to, and no second rung — a grep is not the graph.
90
+ `WICKED_HOME` / `WICKED_MEMORY_DB` from the environment, or `--db <path>`. A container seat has
91
+ no local store: the same command relays through wicked-crew's broker (the run hands the seat
92
+ its address and a unit token), which answers from the run's own graph. With neither, the shim refuses
93
+ (`{"ok": false, "reason": …}`); a relayed call the broker refused names why in
94
+ `broker_refusal` — report either, never guess a store. There is no other transport: no estate
95
+ tool is registered on any seat, so there is nothing to "connect", nothing to fall back to, and
96
+ no second rung — a grep is not the graph.
94
97
 
95
98
  **Resolve the symbol.** Estate tools take symbol **names** directly (a file node's name is its
96
99
  repo-relative path, e.g. `scripts/_bus.py`); when a name is ambiguous or you need the node id,