wicked-garden 12.56.0 → 12.58.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/mcp.json +27 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CHANGELOG.md +17 -1
- package/README.md +12 -2
- package/package.json +2 -2
- package/scripts/_estate_client.py +193 -10
- package/scripts/estate_mcp.mjs +188 -0
- package/skills/governed-worker/SKILL.md +16 -1
- package/skills/repo-learn/refs/capture-proposals.md +4 -3
- package/skills/search/SKILL.md +7 -4
|
@@ -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.
|
|
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.58.0"}], "version": "12.58.0"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-garden",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.58.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",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mcpServers": {
|
|
3
|
+
"wicked-estate": {
|
|
4
|
+
"command": "wicked-garden",
|
|
5
|
+
"args": [
|
|
6
|
+
"run",
|
|
7
|
+
"scripts/estate_mcp.mjs"
|
|
8
|
+
],
|
|
9
|
+
"env_vars": [
|
|
10
|
+
"WICKED_ESTATE_MCP_BIN",
|
|
11
|
+
"WICKED_ESTATE_DB",
|
|
12
|
+
"WICKED_HOME",
|
|
13
|
+
"WICKED_MEMORY_DB",
|
|
14
|
+
"WICKED_KNOWLEDGE_DB",
|
|
15
|
+
"WICKED_XEDGE_DB",
|
|
16
|
+
"WICKED_ESTATE_READONLY",
|
|
17
|
+
"WICKED_GOVERNED",
|
|
18
|
+
"WICKED_RUN_ID",
|
|
19
|
+
"WICKED_RUN_UNIT",
|
|
20
|
+
"WICKED_RUN_AGENT",
|
|
21
|
+
"WICKED_RUN_PROJECT",
|
|
22
|
+
"WICKED_GATE_SCOPE",
|
|
23
|
+
"WICKED_WRITE_ROOTS"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wicked-garden",
|
|
3
|
+
"version": "12.58.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"coding-agent",
|
|
7
|
+
"evidence-gate",
|
|
8
|
+
"code-graph",
|
|
9
|
+
"refactoring",
|
|
10
|
+
"testing",
|
|
11
|
+
"memory",
|
|
12
|
+
"multi-model"
|
|
13
|
+
],
|
|
14
|
+
"skills": "./skills",
|
|
15
|
+
"mcpServers": "./.codex-plugin/mcp.json",
|
|
16
|
+
"hooks": {
|
|
17
|
+
"description": "No hooks on Codex: hooks/hooks.json is Claude Code's event model; garden's skills run the gate themselves on skills-only hosts.",
|
|
18
|
+
"hooks": {}
|
|
19
|
+
},
|
|
20
|
+
"interface": {
|
|
21
|
+
"displayName": "Wicked Garden",
|
|
22
|
+
"shortDescription": "Evidence-gated done, graph-aware search and refactor, QE specialists, review panels, cross-session memory.",
|
|
23
|
+
"developerName": "Mike Parcewski",
|
|
24
|
+
"category": "Developer Tools",
|
|
25
|
+
"websiteURL": "https://github.com/mikeparcewski/wicked-garden"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [12.58.0] — 2026-10-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **garden is a registered Codex plugin (#1326).** `.codex-plugin/plugin.json` (Codex prefers it over `.claude-plugin/plugin.json`) declares the skills directory (`./skills`; Codex namespaces them `wicked-garden:<skill>`), the `wicked-estate` MCP server (`.codex-plugin/mcp.json`) and an explicit empty `hooks` block, because without one Codex would load `hooks/hooks.json`, which is Claude Code's hook model. Codex installs it from the existing `.claude-plugin/marketplace.json`: `codex plugin marketplace add <garden package root>`, then `codex plugin add wicked-garden@wicked-garden`. The MCP server starts through `wicked-garden run scripts/estate_mcp.mjs`. That new launcher finds `wicked-estate-mcp` the way `scripts/_estate_client.py` does (`WICKED_ESTATE_MCP_BIN`, then PATH, then `~/.local/bin`), so no install path is written into the manifest. The server forwards the binary override, estate's store pins (`WICKED_ESTATE_DB`, `WICKED_HOME`, `WICKED_*_DB`), the read-only opt-in and the run markers through `env_vars`, because Codex hands a stdio MCP server only a default allowlist (PATH, HOME, …) plus the variables named there. The launcher also applies `_estate_client.py`'s rules. It accepts only `--readonly`, `--governed` and `--db <path>`/`--db=<path>`, and refuses a misspelt flag. `--readonly` is added for the flag, for `WICKED_ESTATE_READONLY`, `WICKED_GOVERNED` or `--governed`, and for a run marker. A governed unit with no pinned store is refused. The child gets `[--readonly] [--db <path>]`, the only spelling estate reads. It passes stdio, arguments, cwd and the exit code through, and when no binary is found it prints one JSON line and exits 1. A `wicked-estate` server in the user's Codex config takes precedence over the plugin's.
|
|
9
|
+
- **The Codex manifest is kept in version lockstep and checked in CI (#1326).** `sync-plugin-version.mjs` (the npm `version` hook the release runs) now bumps `.codex-plugin/plugin.json` too, and so does the dev `release.py` (with `package.json`). `scripts/ci/validate.py` runs `scripts/ci/codex_plugin.py`, which applies the rules Codex 0.163 uses: name matches the Claude manifest, and the Codex and Claude versions both match `package.json`; paths start with `./` and stay under the root; the hooks opt-out is present; the estate server goes through a launcher script under the root. It also requires that the skills Codex would discover (recursive, hidden directories skipped, string names of at most 64 characters, a string description present) are exactly the catalog's. `tests/test_codex_plugin_manifest.py` checks that the npm package ships those same skills plus the manifest files.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **Governed-worker evaluators defer delivery-only criteria instead of failing them (rule E6, #1328).** A criterion whose evidence exists only after the deliver phase pushes (CI on the opened pull request, the pull request's title or body, a merge, a release) is now DEFERRED, neither PASS nor FAIL. Every part the run can show is still judged, and a failing in-run part still FAILs the item (deny-dominates). The evaluator names each deferred item (`ITEM <n>: DEFERRED — <the artifact it waits for>`, or a "Deferred to delivery" list) so the deliver gate can show it to the operator; a deferred item never fails the verdict by itself. E6 is distinct from E3: an in-run item the creator left as a follow-up is still judged. Pinned by `tests/test_governed_worker_rules.py`.
|
|
13
|
+
|
|
14
|
+
## [12.57.0] — 2026-10-18
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **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.
|
|
18
|
+
|
|
5
19
|
## [12.56.0] — 2026-10-17
|
|
6
20
|
|
|
7
21
|
### Added
|
|
@@ -1165,7 +1179,9 @@ through `v9.2.18`. Major themes across that span:
|
|
|
1165
1179
|
|
|
1166
1180
|
For each of those releases, see the corresponding tag in git history.
|
|
1167
1181
|
|
|
1168
|
-
[Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.
|
|
1182
|
+
[Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.58.0...HEAD
|
|
1183
|
+
[12.58.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.57.0...v12.58.0
|
|
1184
|
+
[12.57.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.56.0...v12.57.0
|
|
1169
1185
|
[12.56.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.55.1...v12.56.0
|
|
1170
1186
|
[12.55.1]: https://github.com/mikeparcewski/wicked-garden/compare/v12.55.0...v12.55.1
|
|
1171
1187
|
[12.55.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.54.0...v12.55.0
|
package/README.md
CHANGED
|
@@ -69,7 +69,17 @@ installs/updates the whole wicked-\* family (garden, its peers, and the rest).
|
|
|
69
69
|
> and the previous copy is put back). Those transient dirs are **not plugins**; a leftover after an interrupted install is
|
|
70
70
|
> safe to delete (`status` lists them).
|
|
71
71
|
|
|
72
|
-
**
|
|
72
|
+
**Codex (codex-cli with `codex plugin`, 0.163+)** — garden is a registered Codex plugin: `.codex-plugin/plugin.json`
|
|
73
|
+
declares the skills (`./skills`, namespaced `wicked-garden:<skill>`), the `wicked-estate` MCP server (started through
|
|
74
|
+
`wicked-garden run scripts/estate_mcp.mjs`, which finds `wicked-estate-mcp` the way garden's scripts do:
|
|
75
|
+
`WICKED_ESTATE_MCP_BIN` → PATH → `~/.local/bin`; that override, estate's store pins and the read-only/run markers are forwarded through `env_vars`, since Codex passes an MCP server only those plus PATH/HOME and the like, and `WICKED_ESTATE_READONLY` or a run marker adds `--readonly` as garden's estate client does), and no hooks (`hooks/hooks.json` is Claude Code's). By hand:
|
|
76
|
+
`npm i -g wicked-garden`, then `codex plugin marketplace add "$(npm root -g)/wicked-garden"` and
|
|
77
|
+
`codex plugin add wicked-garden@wicked-garden`. wicked-installer's Codex script does the same (pick Codex in
|
|
78
|
+
`npx wicked-installer`, or run `node "$(npm root -g)/wicked-installer/dist/install-codex.js" wicked-garden`) and
|
|
79
|
+
removes the loose skill copies it made earlier. A `[mcp_servers.wicked-estate]` entry in your Codex config takes
|
|
80
|
+
precedence over the plugin's server.
|
|
81
|
+
|
|
82
|
+
**Other CLIs (OpenCode, Pi, Antigravity, and Codex without `codex plugin`)** — `npx wicked-installer install-<cli> wicked-garden` copies
|
|
73
83
|
the skills only (flat, by name — no `scripts/`, no venv). Every skill's text works there as written: own files are
|
|
74
84
|
referenced relative to the skill's directory, other skills by name, and the shared runtime only through the
|
|
75
85
|
launcher — `wicked-garden run scripts/<x>.py …`. So for script-backed skills the runtime prerequisite on those
|
|
@@ -130,7 +140,7 @@ false "done" runs into depends on the host and the entry path:
|
|
|
130
140
|
|---|---|---|
|
|
131
141
|
| **Any host — the gate, invoked** (`wicked-garden-prove`, an archetype's produces-gate → `scripts/qe/vault_gate.py`) | Re-hashes the recorded evidence and re-runs its verifier through `wicked-vault`: a false claim is **rejected**, an unresolvable backend **fails closed** (`gate: "unavailable"`). Hard gates also need an attestation under an explicit `--actor` that differs from the author's — a local label, not an authenticated identity. The verdict stops whatever acts on it. | With `--no-require`, a backend that cannot run falls back to the claim-only tracker and says so (`claim-only`, not re-derived); a working backend still re-derives. |
|
|
132
142
|
| **Claude Code — plugin hooks** (plain conversation, "just work") | `PreToolUse` denies a few specific tool calls: native `EnterPlanMode` (always — planning goes through the archetype playbooks), writes to `MEMORY.md` / the auto-memory dir, build-phase writes on a high-complexity crew project with no challenge artifacts, a `phase_manager.py approve` whose gate preflight fails, a worktree cwd leak, and — only in `strict` mode (default `warn`) — invalid task metadata (`WG_TASK_METADATA`) and orphan state writes (`WG_BUS_EMIT_LINT`). Prompts are held until `/wicked-garden-core setup` completes. | Everything else. The archetype detector **steers** (it does not gate), the Stop-time claim sentinel is a fail-open **nudge** when a "done/passing" claim has no verdict for HEAD, `TaskCompleted` never blocks, and output governance is advisory. A completion claim is **not** blocked unless a gate is run. |
|
|
133
|
-
| **Codex · OpenCode · Pi · Antigravity — skills only** (`npx wicked-installer install-<cli> wicked-garden`) | Nothing by itself: no
|
|
143
|
+
| **Codex (plugin) · OpenCode · Pi · Antigravity — skills only** (`npx wicked-installer install-<cli> wicked-garden`) | Nothing by itself: no hooks are installed on those hosts (the Codex plugin opts out of them). | The skill text tells the agent to run the gate; it blocks only when the agent does run it (and the launcher + `wicked-vault` are present). |
|
|
134
144
|
| **Compiled repo gate** (`/wicked-garden-prove compile <repo> --trigger hook,ci`) | `.wicked/gate.py` fails the **git pre-push hook / CI job** it is wired into, with no wicked-garden installed. | Without a trigger it is a script you run by hand. |
|
|
135
145
|
| **wicked-crew governed run** | crew's engine decides each gated phase deny-dominates, on the layers that actually ran — deterministic floors, repo checks, policies, a judge. Review moves to a distinct seat when the roster has an eligible one; team runs refuse without one. | An ordinary run on a too-small roster keeps review on the creator seat and labels it "evaluator ≠ creator not held"; a pinned floor with no distinct judge is labelled "floor only"; a phase nothing gated is "approved by default, not verified". |
|
|
136
146
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-garden",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.58.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": [
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"main": "install.mjs",
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "node scripts/ci/run_pytest.js",
|
|
37
|
-
"version": "node .github/scripts/sync-plugin-version.mjs && git add .claude-plugin/plugin.json .claude-plugin/marketplace.json",
|
|
37
|
+
"version": "node .github/scripts/sync-plugin-version.mjs && git add .claude-plugin/plugin.json .claude-plugin/marketplace.json .codex-plugin/plugin.json",
|
|
38
38
|
"prepublishOnly": "node install.mjs --version"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
@@ -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
|
|
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
|
|
263
|
-
f"{DB_FLAG} <path> or set {' / '.join(STORE_PIN_ENV)} in
|
|
264
|
-
"
|
|
265
|
-
"
|
|
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 =
|
|
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 =
|
|
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(), "
|
|
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:
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* estate_mcp.mjs — start the wicked-estate MCP server over stdio for a host that loads garden as
|
|
4
|
+
* a plugin (Codex: `.codex-plugin/mcp.json` runs `wicked-garden run scripts/estate_mcp.mjs`).
|
|
5
|
+
*
|
|
6
|
+
* The binary is found the way garden's own estate client finds it
|
|
7
|
+
* (`scripts/_estate_client.py` `resolve_mcp_bin`), so the plugin and the skills talk to the same
|
|
8
|
+
* binary and no install path is written into the manifest:
|
|
9
|
+
*
|
|
10
|
+
* 1. WICKED_ESTATE_MCP_BIN, when it names an existing file;
|
|
11
|
+
* 2. `wicked-estate-mcp` on PATH — an executable file, as `shutil.which` requires (PATHEXT on
|
|
12
|
+
* Windows);
|
|
13
|
+
* 3. ~/.local/bin/wicked-estate-mcp (or `.exe`).
|
|
14
|
+
*
|
|
15
|
+
* stdin/stdout/stderr are inherited (the MCP stream passes straight through), the caller's cwd is
|
|
16
|
+
* kept (the binary resolves its store from it), and arguments after the script name are passed
|
|
17
|
+
* on. The read-only and governed-unit rules of `_estate_client.py` apply here too: `--readonly`
|
|
18
|
+
* is added when WICKED_ESTATE_READONLY / WICKED_GOVERNED is truthy or a run marker
|
|
19
|
+
* (WICKED_RUN_ID, WICKED_GATE_SCOPE, WICKED_WRITE_ROOTS) is set — estate only honours the
|
|
20
|
+
* literal flag — and a governed unit with no pinned store (--db, WICKED_ESTATE_DB, WICKED_HOME,
|
|
21
|
+
* WICKED_MEMORY_DB) is refused rather than pointed at the operator's default stores. The exit code is the server's; SIGINT/SIGTERM/SIGHUP are forwarded. When no binary is found
|
|
22
|
+
* it writes one JSON line on stderr — {"ok": false, "reason": …, "tried": […]} — and exits 1, so
|
|
23
|
+
* the host reports a failed MCP server rather than a hung one.
|
|
24
|
+
*
|
|
25
|
+
* Pure helpers are exported for tests (tests/test_codex_plugin_manifest.py).
|
|
26
|
+
*/
|
|
27
|
+
import { accessSync, constants as fsConstants, realpathSync, statSync } from "node:fs";
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
|
|
33
|
+
export const MCP_BIN_NAME = "wicked-estate-mcp";
|
|
34
|
+
export const OVERRIDE_ENV = "WICKED_ESTATE_MCP_BIN";
|
|
35
|
+
|
|
36
|
+
function isFile(p) {
|
|
37
|
+
try {
|
|
38
|
+
return statSync(p).isFile();
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** shutil.which's test: a regular file the user may execute (Windows has no execute bit). */
|
|
45
|
+
function isExecutableFile(p, platform = process.platform) {
|
|
46
|
+
if (!isFile(p)) return false;
|
|
47
|
+
if (platform === "win32") return true;
|
|
48
|
+
try {
|
|
49
|
+
accessSync(p, fsConstants.X_OK);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the estate MCP binary. Returns { bin, tried } — `bin` is null when nothing qualifies.
|
|
58
|
+
* `env`, `platform`, `home` and `fileExists` are seams for tests.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveEstateMcpBin({
|
|
61
|
+
env = process.env,
|
|
62
|
+
platform = process.platform,
|
|
63
|
+
home = homedir(),
|
|
64
|
+
fileExists = isFile,
|
|
65
|
+
isExecutable = (p) => isExecutableFile(p, platform),
|
|
66
|
+
} = {}) {
|
|
67
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
68
|
+
const tried = [];
|
|
69
|
+
const override = env[OVERRIDE_ENV];
|
|
70
|
+
if (override) {
|
|
71
|
+
tried.push(`${OVERRIDE_ENV}=${override}`);
|
|
72
|
+
if (fileExists(override)) return { bin: override, tried };
|
|
73
|
+
}
|
|
74
|
+
const exts = platform === "win32"
|
|
75
|
+
? ["", ...(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((e) => e.toLowerCase())]
|
|
76
|
+
: [""];
|
|
77
|
+
const pathVar = env.PATH ?? env.Path ?? "";
|
|
78
|
+
for (const dir of pathVar.split(platform === "win32" ? ";" : ":")) {
|
|
79
|
+
if (!dir) continue;
|
|
80
|
+
for (const ext of exts) {
|
|
81
|
+
const candidate = p.join(dir, MCP_BIN_NAME + ext);
|
|
82
|
+
// PATH entries must be executable (as shutil.which requires); the override and
|
|
83
|
+
// ~/.local/bin only need to be files, exactly as _estate_client.py checks them.
|
|
84
|
+
if (isExecutable(candidate)) return { bin: candidate, tried: [...tried, "PATH"] };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
tried.push("PATH");
|
|
88
|
+
const local = p.join(home, ".local", "bin");
|
|
89
|
+
for (const name of [MCP_BIN_NAME, `${MCP_BIN_NAME}.exe`]) {
|
|
90
|
+
const candidate = p.join(local, name);
|
|
91
|
+
tried.push(candidate);
|
|
92
|
+
if (fileExists(candidate)) return { bin: candidate, tried };
|
|
93
|
+
}
|
|
94
|
+
return { bin: null, tried };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const GOVERNED_MARKERS = ["WICKED_RUN_ID", "WICKED_GATE_SCOPE", "WICKED_WRITE_ROOTS"];
|
|
98
|
+
const STORE_PIN_ENV = ["WICKED_ESTATE_DB", "WICKED_HOME", "WICKED_MEMORY_DB"];
|
|
99
|
+
const truthy = (v) => !["", "0", "false", "no"].includes(String(v ?? "").trim().toLowerCase());
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The argv to hand estate, or the reason to refuse — `_estate_client.parse_cli_flags()`,
|
|
103
|
+
* `read_only()`, `governed_refusal()` and `mcp_argv()` applied to the launcher's own argv and
|
|
104
|
+
* environment. Accepted flags: `--readonly`, `--governed`, `--db <path>` / `--db=<path>`
|
|
105
|
+
* (anywhere); any other `--flag` is refused (a misspelt `--read-only` must never reach a
|
|
106
|
+
* write-capable server). The child argv is `[--readonly] [--db <path>] …positionals`, the only
|
|
107
|
+
* `--db` spelling estate reads.
|
|
108
|
+
*/
|
|
109
|
+
export function estateArgv(argv, env = process.env) {
|
|
110
|
+
let readonlyFlag = false;
|
|
111
|
+
let governedFlag = false;
|
|
112
|
+
let db = null;
|
|
113
|
+
const rest = [];
|
|
114
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
115
|
+
const tok = argv[i];
|
|
116
|
+
if (tok === "--readonly") readonlyFlag = true;
|
|
117
|
+
else if (tok === "--governed") governedFlag = true;
|
|
118
|
+
else if (tok === "--db") {
|
|
119
|
+
const value = argv[i + 1];
|
|
120
|
+
if (!value || value.startsWith("-")) return { usage: "--db requires a store path" };
|
|
121
|
+
db = value;
|
|
122
|
+
i += 1;
|
|
123
|
+
} else if (tok.startsWith("--db=")) {
|
|
124
|
+
const value = tok.slice("--db=".length);
|
|
125
|
+
if (!value) return { usage: "--db= requires a store path" };
|
|
126
|
+
db = value;
|
|
127
|
+
} else if (tok.startsWith("--") && tok.length > 2) {
|
|
128
|
+
return { usage: `unknown flag ${JSON.stringify(tok)}: the estate launcher accepts --readonly, --governed and --db <path> only (spell --readonly exactly)` };
|
|
129
|
+
} else rest.push(tok);
|
|
130
|
+
}
|
|
131
|
+
const marker = governedFlag ? "--governed"
|
|
132
|
+
: truthy(env.WICKED_GOVERNED) ? "WICKED_GOVERNED"
|
|
133
|
+
: GOVERNED_MARKERS.find((k) => String(env[k] ?? "").trim());
|
|
134
|
+
const pinned = !!db || STORE_PIN_ENV.some((k) => String(env[k] ?? "").trim());
|
|
135
|
+
if (marker && !pinned) {
|
|
136
|
+
return { refusal: `governed unit (${marker} is set) but no estate store is pinned: set ${STORE_PIN_ENV.join(" / ")} or pass --db; refusing to start wicked-estate-mcp against the operator's default stores` };
|
|
137
|
+
}
|
|
138
|
+
const readOnly = readonlyFlag || !!marker || truthy(env.WICKED_ESTATE_READONLY);
|
|
139
|
+
return { argv: [...(readOnly ? ["--readonly"] : []), ...(db ? ["--db", db] : []), ...rest] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function main(argv) {
|
|
143
|
+
const plan = estateArgv(argv);
|
|
144
|
+
if (plan.usage || plan.refusal) {
|
|
145
|
+
process.stderr.write(JSON.stringify({ ok: false, reason: plan.usage ?? plan.refusal, tried: [] }) + "\n");
|
|
146
|
+
return process.exit(plan.usage ? 2 : 1);
|
|
147
|
+
}
|
|
148
|
+
argv = plan.argv;
|
|
149
|
+
const { bin, tried } = resolveEstateMcpBin();
|
|
150
|
+
if (!bin) {
|
|
151
|
+
process.stderr.write(JSON.stringify({
|
|
152
|
+
ok: false,
|
|
153
|
+
reason: `${MCP_BIN_NAME} not found — install wicked-estate (npx wicked-installer install wicked-estate) or set ${OVERRIDE_ENV}`,
|
|
154
|
+
tried,
|
|
155
|
+
}) + "\n");
|
|
156
|
+
return process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const child = spawn(bin, argv, { stdio: "inherit", shell: false });
|
|
159
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
160
|
+
process.on(sig, () => {
|
|
161
|
+
try {
|
|
162
|
+
child.kill(sig);
|
|
163
|
+
} catch {
|
|
164
|
+
/* already gone */
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
child.on("error", (err) => {
|
|
169
|
+
process.stderr.write(JSON.stringify({ ok: false, reason: `could not start ${bin}: ${err.message}`, tried }) + "\n");
|
|
170
|
+
process.exit(1);
|
|
171
|
+
});
|
|
172
|
+
child.on("exit", (code, signal) => {
|
|
173
|
+
process.exit(code ?? (signal ? 128 + (signalNumber(signal) || 1) : 1));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function signalNumber(name) {
|
|
178
|
+
return { SIGHUP: 1, SIGINT: 2, SIGKILL: 9, SIGTERM: 15 }[name];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function invokedDirectly() {
|
|
182
|
+
try {
|
|
183
|
+
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (invokedDirectly()) main(process.argv.slice(2));
|
|
@@ -139,6 +139,19 @@ verdict with reasons. You are output-only.
|
|
|
139
139
|
creator's reason. An unjustified duplicate is at least a Concern. So is new code
|
|
140
140
|
whose output records no built-in check (C8). Code that swaps out the framework the
|
|
141
141
|
user or a policy chose is a Critical.
|
|
142
|
+
- **E6 — Judge what the run can show; defer what only delivery can.** A
|
|
143
|
+
criterion whose evidence exists only after the deliver phase pushes — CI
|
|
144
|
+
results on the opened pull request, the pull request's title or body, a merge,
|
|
145
|
+
a release — is DEFERRED: neither PASS nor FAIL. Judge every part you can check
|
|
146
|
+
before delivery (the tree, your own runs in it, the floor record: tests,
|
|
147
|
+
format, lint, CHANGELOG). An item that mixes the two FAILs when its in-run part
|
|
148
|
+
fails; a deferral never lifts a failing in-run part. Otherwise name what waits:
|
|
149
|
+
`ITEM <n>: DEFERRED — <the artifact it waits for>` when your brief asks for item
|
|
150
|
+
lines, else a "Deferred to delivery" list above the verdict line. A deferred
|
|
151
|
+
item never fails the unit by itself, and a promise that CI will pass is never a
|
|
152
|
+
PASS. The deliver gate shows each deferred check to the operator. This is not
|
|
153
|
+
a creator's deferral (E3): an in-run item the creator left as a follow-up is
|
|
154
|
+
still judged.
|
|
142
155
|
|
|
143
156
|
Method: read the outcome and design, then the changed files; for each, ask
|
|
144
157
|
whether it follows the design, whether tests cover the key paths, and whether
|
|
@@ -146,7 +159,8 @@ the evidence (commands + exit codes) supports the claims; re-run what you can
|
|
|
146
159
|
without mutating; sort findings into Critical (must fix), Concern, Suggestion;
|
|
147
160
|
verdict: make the LAST line of your output exactly `VERDICT: PASS` or
|
|
148
161
|
`VERDICT: FAIL` — one plain-text line (no `**`, backticks or `#` around it),
|
|
149
|
-
findings above it. FAIL needs at least one Critical or a CONDITIONS list;
|
|
162
|
+
findings above it. FAIL needs at least one Critical or a CONDITIONS list; a
|
|
163
|
+
DEFERRED item (E6) is neither, so it never makes the verdict FAIL;
|
|
150
164
|
there is no conditional pass (CONDITIONAL, APPROVE, REJECT and SKIP are not
|
|
151
165
|
verdicts); never quote another VERDICT line in your message; no verdict line
|
|
152
166
|
= FAIL (human gate). If you are the seat that created the work, say so and
|
|
@@ -207,6 +221,7 @@ Rules cite the acceptance-program findings that motivated them.
|
|
|
207
221
|
| E3 | garden#1223 (companion to wicked-core#760) |
|
|
208
222
|
| E4 | garden#1253 (S19a: `before:1` vs the form's `before:2`, passed on presence) |
|
|
209
223
|
| E5 | operator ruling 2026-10-12 (wicked-core POL-2101) |
|
|
224
|
+
| E6 | operator-approved fix, 2026-10-19 (runs 290a7fee and a74f51ac: reviews FAILed "CI green on every OS" and "PR body says Closes #N", which cannot exist before the deliver phase) |
|
|
210
225
|
| N1 | F-RC2-038 |
|
|
211
226
|
|
|
212
227
|
History: the `crew-implementer`, `crew-reviewer` and `crew-researcher` fork-agent definitions
|
|
@@ -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>`;
|
|
45
|
-
|
|
46
|
-
|
|
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
|
package/skills/search/SKILL.md
CHANGED
|
@@ -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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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,
|