wicked-garden 12.59.0 → 12.60.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/plugin.json +1 -1
- package/CHANGELOG.md +17 -1
- package/docs/README.md +1 -1
- package/docs/outgov-per-turn.md +26 -18
- package/hooks/scripts/pre_tool.py +10 -8
- package/hooks/scripts/prompt_submit.py +181 -49
- package/hooks/scripts/stop.py +96 -20
- package/package.json +1 -1
- package/scripts/_context_backend.py +16 -4
- package/scripts/_estate_client.py +44 -6
- package/scripts/_session.py +5 -0
- package/scripts/mem/estate_memory.py +75 -4
- package/skills/classify/SKILL.md +8 -5
- package/skills/mem/SKILL.md +20 -2
- package/skills/repo-learn/refs/capture-proposals.md +3 -1
|
@@ -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.60.0"}], "version": "12.60.0"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-garden",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.60.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.
|
|
3
|
+
"version": "12.60.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,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [12.60.0] — 2026-10-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Per-turn hooks stop injecting on prompts they don't fit (#1333).** The prompt and Stop hooks run on every turn, on every seat that loads garden. An injection that misfires costs context and teaches agents to skim past reminders.
|
|
9
|
+
- **Context Assembly is decided per prompt.** The session intent is sticky, so once a session's first prompt read as research, every later prompt got the directive, including "agree", "still waiting" and "how is it going?". It now fires only when the prompt itself names code, files, symbols, repos or past decisions AND has more than 12 words or asks a question. Acknowledgements (including short ones such as "yes, sounds good to me"), status checks and one- or two-word replies get nothing, even when they name a file or symbol ("how is repo_checks.rs going?"). The directive no longer names the estate MCP `SearchEntity` tool (no estate MCP is registered on a seat). It points at the mem and search skills and the read-only shim instead.
|
|
10
|
+
- **Output Governance runs once per session and does the work itself.** The per-turn line ("If the estate MCP is connected: call `rules.recall`…") could never run. The first Stop of a session now calls `rules.recall {"rule_type":"policy"}` through the read-only shim. When the store holds policy rules, the hook names the count and the top three once, with the command that lists them all. The once-per-session claim is an atomic marker file, so a prompt hook saving a stale session state can't re-arm it. Zero rules, an unreachable estate or an error: silent. `WG_OUTGOV=off` still opts out.
|
|
11
|
+
- **No `classify-due` nag.** When the regex router falls through, the hook emits nothing. A medium-confidence regex match (one keyword, such as "outage" in a status question) emits nothing either. Only a high-confidence match (≥ 0.7) tags the turn.
|
|
12
|
+
- **`prove:` lines ride only a confident archetype** (score ≥ 0.7, from the regex or from a persisted model classification), never a fall-through. A persisted classification (the model's own, from `wicked-garden-classify`) is echoed back once per classification, not on every turn, and only its confident archetypes.
|
|
13
|
+
- **The agent memory path is one line (#1333).**
|
|
14
|
+
- `_estate_client.py <action> -` reads its JSON from stdin. It used to parse `-` as a literal string, so `propose -` failed with a generic shape error.
|
|
15
|
+
- `propose` errors name the exact field: `missing kind_type`, `payload must be an object, got str`, `json-args is not valid JSON`; a missing `facets.project` is still reported by name.
|
|
16
|
+
- New `wicked-garden-mem` action `propose`: `wicked-garden run scripts/mem/estate_memory.py --readonly propose '<text>'` (or `propose -` with text or JSON on stdin) builds the envelope itself: `kind_type: memory`, `payload {content, tier}`, `facets {repo, project}` from the cwd's checkout (outside a checkout, `project` = the cwd name; default `recall` now sends that same intent, so the approved memory is recalled from where it was proposed). It applies the same durability refusals as `store`. It is the write a read-only seat still has, and the memory lands pending until an operator approves it.
|
|
17
|
+
- The hook that blocks writes to `MEMORY.md` and the native memory directory now prints that exact command.
|
|
18
|
+
|
|
19
|
+
|
|
5
20
|
## [12.59.0] — 2026-10-25
|
|
6
21
|
|
|
7
22
|
### Added
|
|
@@ -1197,7 +1212,8 @@ through `v9.2.18`. Major themes across that span:
|
|
|
1197
1212
|
|
|
1198
1213
|
For each of those releases, see the corresponding tag in git history.
|
|
1199
1214
|
|
|
1200
|
-
[Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.
|
|
1215
|
+
[Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.60.0...HEAD
|
|
1216
|
+
[12.60.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.59.0...v12.60.0
|
|
1201
1217
|
[12.59.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.58.0...v12.59.0
|
|
1202
1218
|
[12.58.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.57.0...v12.58.0
|
|
1203
1219
|
[12.57.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.56.0...v12.57.0
|
package/docs/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Work in wicked-garden is organized around **10 work-shape archetypes** — not a
|
|
|
14
14
|
| [Required Peers](required-peers.md) | The one required peer (plus the opt-in layers) and the install/runtime stance |
|
|
15
15
|
| [The Compiler](compiler.md) | `/wicked-garden-prove compile` — emit a self-contained vault-backed gate into any repo |
|
|
16
16
|
| [Extending the Catalog](extending.md) | Ship a third-party pack — `{vendor}-{domain}-{role}` skills, the `wicked-pack.json` manifest, the shipped conformance gate, install + crew routing |
|
|
17
|
-
| [Output Governance (
|
|
17
|
+
| [Output Governance (hook-side)](outgov-per-turn.md) | The advisory hook lane — `WG_OUTGOV` modes (default `warn`), a once-per-session `rules.recall` audit through the read-only shim, content-hash provenance for the graph-derived rules dir |
|
|
18
18
|
| [Brain Chunk Format](brain-chunk-format.md) | Historical: how content was chunked for the retired wicked-brain index (the migrated chunks in wicked-estate keep this shape) | <!-- historical -->
|
|
19
19
|
|
|
20
20
|
## Quick Links
|
package/docs/outgov-per-turn.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Output Governance — the
|
|
1
|
+
# Output Governance — the Hook-Side Advisory Lane
|
|
2
2
|
|
|
3
3
|
How the interactive (hook-side) half of output governance works after AW-16
|
|
4
4
|
(arch-R14): **one source of per-turn truth, default-on advisory, fail-open by
|
|
@@ -10,7 +10,7 @@ Enforcement in the wicked platform is two-tier (TARGET-ARCHITECTURE contract 3):
|
|
|
10
10
|
|
|
11
11
|
| Tier | Where | Behavior |
|
|
12
12
|
|------|-------|----------|
|
|
13
|
-
| **Advisory** | garden hooks (`stop.py`
|
|
13
|
+
| **Advisory** | garden hooks (`stop.py` session audit, `guard_pipeline` Check 6) | fail-open — surfaces findings, never blocks |
|
|
14
14
|
| **Enforcing** | crew/core gates (`output-gate-hook`, acceptance gate) | fail-closed — deny dominates |
|
|
15
15
|
|
|
16
16
|
There is **no third tier**. `WG_OUTGOV=strict` only strengthens the advisory's
|
|
@@ -23,11 +23,16 @@ enforced belongs in the crew/core gates, not here.
|
|
|
23
23
|
Both hook-side consumers read the **estate graph** — never an independent
|
|
24
24
|
rulebook:
|
|
25
25
|
|
|
26
|
-
1. **
|
|
27
|
-
the Stop
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
1. **Session audit** (`hooks/scripts/stop.py::_check_outgov_compliance`):
|
|
27
|
+
on the FIRST Stop of a session the hook itself runs **`rules.recall`**
|
|
28
|
+
(`{"rule_type": "policy"}`, severity-ordered, graph-backed, the wire twin
|
|
29
|
+
of wicked-governance's `recall_rules`) through the read-only shim
|
|
30
|
+
(`scripts/_estate_client.py --readonly call`). When the store holds policy
|
|
31
|
+
rules it names the count and the top three once, with the command that
|
|
32
|
+
lists them all. Zero rules, an unreachable estate or any error: silent.
|
|
33
|
+
It used to be a per-turn line telling the model to call the tool "if the
|
|
34
|
+
estate MCP is connected", which no seat ever is (by design), so it could
|
|
35
|
+
never run (garden#1333).
|
|
31
36
|
|
|
32
37
|
2. **Session-close pattern check**
|
|
33
38
|
(`scripts/platform/guard_pipeline.py::check_outgov_pattern`, Check 6):
|
|
@@ -39,13 +44,15 @@ rulebook:
|
|
|
39
44
|
|
|
40
45
|
## `WG_OUTGOV` modes and the default
|
|
41
46
|
|
|
42
|
-
| Value |
|
|
43
|
-
|
|
44
|
-
| *(unset)* → **`warn`** |
|
|
45
|
-
| `warn` |
|
|
46
|
-
| `strict` |
|
|
47
|
+
| Value | Session audit (stop.py) | Check 6 (guard_pipeline) |
|
|
48
|
+
|-------|-------------------------|---------------------------|
|
|
49
|
+
| *(unset)* → **`warn`** | runs once per session (default-on advisory) | runs |
|
|
50
|
+
| `warn` | runs once per session | runs |
|
|
51
|
+
| `strict` | runs once, stronger wording — still advisory | runs |
|
|
47
52
|
| `off` (or any other value) | silent | skips |
|
|
48
53
|
|
|
54
|
+
The audit is also silent when `WICKED_CONTEXT_BACKEND=off`.
|
|
55
|
+
|
|
49
56
|
**The default flipped from `off` to `warn` in AW-16**, after the seed corpus
|
|
50
57
|
(AW-13) gave the advisory real rules to recall. Every mode is fail-open: a
|
|
51
58
|
missing estate, a missing rules dir, or a broken bundle can only *reduce*
|
|
@@ -63,7 +70,8 @@ The flip is **default-on in code, per-repo in practice**:
|
|
|
63
70
|
- **Noise budget owner**: whoever keeps a repo opted in owns its noise budget
|
|
64
71
|
(the repo maintainer by default). The advisory costs at most one
|
|
65
72
|
systemMessage per Stop; Check 6 findings are capped by the guard-pipeline
|
|
66
|
-
budget and profile.
|
|
73
|
+
budget and profile. The audit costs at most one systemMessage per session,
|
|
74
|
+
and none when the store has no policy rules.
|
|
67
75
|
- If recall noise is systemic (bad rules, not bad wiring), fix the corpus —
|
|
68
76
|
retire or re-scope the offending rules in the graph — rather than muting the
|
|
69
77
|
lane ecosystem-wide.
|
|
@@ -127,18 +135,18 @@ Hermetic tests — no live estate needed, fixture rules dirs only:
|
|
|
127
135
|
**missing rules dir ⇒ skip with a note, zero findings, no block**; stale
|
|
128
136
|
provenance ⇒ warn-severity finding only, rules still surfaced; hash
|
|
129
137
|
determinism + generator/consumer recipe equality.
|
|
130
|
-
- `tests/hooks/test_stop_outgov.py` — the
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
- `tests/hooks/test_stop_outgov.py` — the audit runs `rules.recall` once per
|
|
139
|
+
session; silent on zero rules, an unreachable estate or an error; names no
|
|
140
|
+
MCP; default-on; `off` opts out; `strict` stays advisory.
|
|
133
141
|
|
|
134
142
|
## Pointers
|
|
135
143
|
|
|
136
|
-
- `hooks/scripts/stop.py` — `_check_outgov_compliance` (per-
|
|
144
|
+
- `hooks/scripts/stop.py` — `_check_outgov_compliance` (once-per-session audit)
|
|
137
145
|
- `scripts/platform/guard_pipeline.py` — Check 6 (`check_outgov_pattern`),
|
|
138
146
|
`compute_rules_content_hash`, `write_rules_provenance`, the `hash` CLI
|
|
139
147
|
- `skills/engineering/refs/conformance.md` — the agent-half rubric that
|
|
140
148
|
evaluates Check 6's surfaced rules semantically
|
|
141
149
|
- wicked-core `crates/wicked-governance` — the graph side: `rules ingest`,
|
|
142
150
|
`rules fanout` (the manifest + store split), `rules.recall`'s Rust twin
|
|
143
|
-
- History: garden#983 (pattern check), garden#984 (per-turn guardrail, closed),
|
|
151
|
+
- History: garden#983 (pattern check), garden#984 (per-turn guardrail, closed), garden#1333 (per-turn line → session audit),
|
|
144
152
|
AW-16 / arch-R14 (this document's change)
|
|
@@ -280,17 +280,19 @@ def _handle_write_guard(tool_input: dict) -> str:
|
|
|
280
280
|
is_auto_memory = _AUTO_MEMORY_MARKER in file_path and "/memory/" in file_path
|
|
281
281
|
|
|
282
282
|
if is_memory_md or is_auto_memory:
|
|
283
|
-
#
|
|
284
|
-
#
|
|
283
|
+
# garden#1333: hand over the exact one-line command, not a skill name
|
|
284
|
+
# the agent then has to route through (and that refuses `store` on a
|
|
285
|
+
# read-only seat). Fail-open to the same literal command.
|
|
285
286
|
try:
|
|
286
|
-
from _context_backend import
|
|
287
|
-
_mem_target = memory_directive_target()
|
|
287
|
+
from _context_backend import MEMORY_PROPOSE_COMMAND as _mem_cmd
|
|
288
288
|
except Exception:
|
|
289
|
-
|
|
289
|
+
_mem_cmd = ("wicked-garden run scripts/mem/estate_memory.py --readonly propose "
|
|
290
|
+
"'<the rule, why it holds, when it applies>'")
|
|
290
291
|
return _deny(
|
|
291
|
-
"Do not write to MEMORY.md or the auto memory directory
|
|
292
|
-
"
|
|
293
|
-
|
|
292
|
+
"Do not write to MEMORY.md or the auto memory directory; this project keeps "
|
|
293
|
+
"memory in wicked-estate. Save the learning with one command instead "
|
|
294
|
+
"(it files a memory proposal for this project; pipe longer text with `propose -`):\n"
|
|
295
|
+
f"{_mem_cmd}"
|
|
294
296
|
)
|
|
295
297
|
|
|
296
298
|
# Issue #442: challenge-artifacts gate for build-phase writes
|
|
@@ -185,51 +185,73 @@ def _with_prove_hints(directive: str, archetypes: list[str]) -> str:
|
|
|
185
185
|
return directive
|
|
186
186
|
|
|
187
187
|
|
|
188
|
+
# garden#1333: a `prove:` line is steering the agent acts on at the gate
|
|
189
|
+
# phase, so it rides only a confident classification. Mirrors
|
|
190
|
+
# archetypes_v11.HIGH_CONFIDENCE (kept literal so the hook needs no import
|
|
191
|
+
# to decide; tests lock the two together).
|
|
192
|
+
_PROVE_MIN_SCORE = 0.7
|
|
193
|
+
|
|
194
|
+
|
|
188
195
|
def _build_archetype_directive(prompt: str, intent: str, state=None) -> str | None:
|
|
189
196
|
"""v11 — emit a slim work-shape archetype steering directive.
|
|
190
197
|
|
|
191
198
|
Two-tier classification:
|
|
192
199
|
|
|
193
|
-
1. **Persisted (LLM)**: when SessionState carries
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
2. **Fallback (regex)**:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
200
|
+
1. **Persisted (LLM)**: when SessionState carries `archetypes_v11` (the
|
|
201
|
+
model classified through the wicked-garden-classify skill), emit its
|
|
202
|
+
tag / shape card.
|
|
203
|
+
2. **Fallback (regex)**: archetypes_v11.detect_archetypes() on the
|
|
204
|
+
prompt. Emits only a HIGH-confidence match; a fall-through (only
|
|
205
|
+
triage) or a medium keyword hit emits nothing (garden#1333: the
|
|
206
|
+
fall-through used to ask for a model classification on every
|
|
207
|
+
ordinary question, and medium hits misrouted status questions).
|
|
208
|
+
|
|
209
|
+
Both tiers emit only archetypes scored >= _PROVE_MIN_SCORE, so a
|
|
210
|
+
`prove:` line never rides a low-confidence route; the persisted tier
|
|
211
|
+
is echoed once per classification.
|
|
212
|
+
Silent on simple-edit sessions and on chatter (acknowledgements, status
|
|
213
|
+
checks, short replies). Fail-open on any error.
|
|
205
214
|
"""
|
|
206
215
|
if not prompt or not prompt.strip():
|
|
207
216
|
return None
|
|
208
217
|
if intent == "simple-edit":
|
|
209
218
|
return None
|
|
219
|
+
try:
|
|
220
|
+
if _is_chatter(prompt):
|
|
221
|
+
return None
|
|
222
|
+
except Exception:
|
|
223
|
+
return None
|
|
210
224
|
|
|
211
225
|
# Tier 1: persisted classification from the classify skill
|
|
212
226
|
persisted = (getattr(state, "archetypes_v11", None) or []) if state else []
|
|
213
227
|
if persisted:
|
|
214
|
-
#
|
|
215
|
-
|
|
228
|
+
# The model classified this itself (wicked-garden-classify). Echo it
|
|
229
|
+
# back once per classification, and only its confident archetypes:
|
|
230
|
+
# repeating it on every later turn added nothing (garden#1333).
|
|
231
|
+
real = [a for a in persisted
|
|
232
|
+
if a.get("name") != "triage"
|
|
233
|
+
and float(a.get("score", 0)) >= _PROVE_MIN_SCORE][:3]
|
|
216
234
|
if not real:
|
|
217
235
|
return None
|
|
218
|
-
|
|
236
|
+
key = f"{getattr(state, 'classified_at', None)}|" + json.dumps(real, sort_keys=True)
|
|
237
|
+
if getattr(state, "archetype_echoed", None) == key:
|
|
238
|
+
return None
|
|
239
|
+
try:
|
|
240
|
+
state.update(archetype_echoed=key)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
if len(real) == 1:
|
|
219
244
|
n = real[0]["name"]
|
|
220
245
|
s = float(real[0]["score"])
|
|
221
246
|
base = f"<wg archetype=\"{n}\" score=\"{s:.2f}\" classified=\"llm\" />"
|
|
222
247
|
return _with_prove_hints(base, [n])
|
|
223
|
-
shape_lines = [
|
|
224
|
-
f" - {a['name']} ({float(a.get('score', 0)):.2f})"
|
|
225
|
-
for a in real[:3]
|
|
226
|
-
]
|
|
248
|
+
shape_lines = [f" - {a['name']} ({float(a['score']):.2f})" for a in real]
|
|
227
249
|
base = (
|
|
228
250
|
"<wg archetypes classified=\"llm\">\n"
|
|
229
251
|
+ "\n".join(shape_lines)
|
|
230
252
|
+ "\n</wg>"
|
|
231
253
|
)
|
|
232
|
-
return _with_prove_hints(base, [a["name"] for a in real
|
|
254
|
+
return _with_prove_hints(base, [a["name"] for a in real])
|
|
233
255
|
|
|
234
256
|
# Tier 2: regex fallback
|
|
235
257
|
try:
|
|
@@ -240,7 +262,7 @@ def _build_archetype_directive(prompt: str, intent: str, state=None) -> str | No
|
|
|
240
262
|
for p in (_scripts, _crew):
|
|
241
263
|
if str(p) not in _sys.path:
|
|
242
264
|
_sys.path.append(str(p))
|
|
243
|
-
from archetypes_v11 import detect_archetypes
|
|
265
|
+
from archetypes_v11 import detect_archetypes
|
|
244
266
|
except Exception:
|
|
245
267
|
return None
|
|
246
268
|
|
|
@@ -249,33 +271,25 @@ def _build_archetype_directive(prompt: str, intent: str, state=None) -> str | No
|
|
|
249
271
|
except Exception:
|
|
250
272
|
return None
|
|
251
273
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if not
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
"Regex classifier could not route this prompt to a v11 archetype. "
|
|
262
|
-
"Invoke the `wicked-garden-classify` skill to classify with model "
|
|
263
|
-
"reasoning, then run the chosen archetype's playbook."
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
high = [(n, s, e) for (n, s, e) in real if s >= HIGH_CONFIDENCE]
|
|
267
|
-
if len(high) == 1 and len(real) == 1:
|
|
268
|
-
n, s, _ = high[0]
|
|
274
|
+
high = [(n, s) for (n, s, _e) in matches
|
|
275
|
+
if n != "triage" and s >= _PROVE_MIN_SCORE]
|
|
276
|
+
if not high:
|
|
277
|
+
# Fall-through or medium-only: the regex has no confident route, so
|
|
278
|
+
# it says nothing (garden#1333). The classify skill stays available
|
|
279
|
+
# for the agent to invoke when the work shape matters.
|
|
280
|
+
return None
|
|
281
|
+
if len(high) == 1:
|
|
282
|
+
n, s = high[0]
|
|
269
283
|
base = f"<wg archetype=\"{n}\" score=\"{s:.2f}\" classified=\"regex\" />"
|
|
270
284
|
return _with_prove_hints(base, [n])
|
|
271
285
|
|
|
272
|
-
shape_lines = [f" - {n} ({s:.2f})" for (n, s
|
|
286
|
+
shape_lines = [f" - {n} ({s:.2f})" for (n, s) in high[:3]]
|
|
273
287
|
base = (
|
|
274
288
|
"<wg archetypes classified=\"regex\">\n"
|
|
275
289
|
+ "\n".join(shape_lines)
|
|
276
290
|
+ "\n</wg>"
|
|
277
291
|
)
|
|
278
|
-
return _with_prove_hints(base, [n for (n, _s
|
|
292
|
+
return _with_prove_hints(base, [n for (n, _s) in high[:3]])
|
|
279
293
|
|
|
280
294
|
|
|
281
295
|
def _suggest_jam(prompt: str, state) -> str | None:
|
|
@@ -444,6 +458,122 @@ def _has_imperative_technical_verb(prompt_lower: str) -> bool:
|
|
|
444
458
|
return any(verb in prompt_lower for verb in _IMPERATIVE_TECHNICAL_VERBS)
|
|
445
459
|
|
|
446
460
|
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
# garden#1333 — per-prompt grounding gate
|
|
463
|
+
#
|
|
464
|
+
# The session intent above is sticky: once a session's first prompt read as
|
|
465
|
+
# feature/research, every later prompt ("agree", "still waiting", "how is it
|
|
466
|
+
# going?") got the Context Assembly directive. That trained agents to skim
|
|
467
|
+
# past it. The directive is now decided per prompt, from what the prompt
|
|
468
|
+
# itself names: it fires only when the prompt names code, files, symbols,
|
|
469
|
+
# repos or past decisions AND is long enough to need grounding (more than
|
|
470
|
+
# _GROUNDING_MIN_WORDS words) or asks a question about them.
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
_GROUNDING_MIN_WORDS = 12 # "more than ~12 words" (garden#1333 item 1)
|
|
474
|
+
|
|
475
|
+
# Strong code signals: the prompt points at a concrete thing in the codebase.
|
|
476
|
+
_IDENTIFIER_PATTERN = re.compile(
|
|
477
|
+
r"\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b" # snake_case / SCREAMING_CASE
|
|
478
|
+
r"|\b[a-z]+[A-Z][A-Za-z0-9]*\b" # camelCase
|
|
479
|
+
r"|\b[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]*\b" # PascalCase (two humps)
|
|
480
|
+
r"|\b\w+::\w+" # Rust/C++ paths
|
|
481
|
+
r"|\b\w+\(\)" # call()
|
|
482
|
+
r"|\bwicked-[a-z][a-z0-9-]*\b" # a wicked-* repo
|
|
483
|
+
)
|
|
484
|
+
_DECISION_PATTERN = re.compile(
|
|
485
|
+
r"\b(?:why did we|what did we decide|did we decide|we decided|decision|"
|
|
486
|
+
r"adr|rationale|why do we)\b",
|
|
487
|
+
re.IGNORECASE,
|
|
488
|
+
)
|
|
489
|
+
# Weak code signals: code nouns. They ground only a question or a long prompt.
|
|
490
|
+
_CODE_NOUN_PATTERN = re.compile(
|
|
491
|
+
r"\b(?:function|method|class|module|crate|package|codebase|repo|repository|"
|
|
492
|
+
r"hook|endpoint|schema|symbol|api|script|test|tests|struct|trait|interface|"
|
|
493
|
+
r"config|migration|query|branch|commit)s?\b",
|
|
494
|
+
re.IGNORECASE,
|
|
495
|
+
)
|
|
496
|
+
# Status checks and acknowledgements never need grounding.
|
|
497
|
+
_STATUS_PATTERN = re.compile(
|
|
498
|
+
r"\b(?:how(?:'s| is| are)\b.*\bgoing|status|any (?:update|news|progress)|"
|
|
499
|
+
r"still (?:waiting|running|working|going)|where are we|are (?:you|we) done|"
|
|
500
|
+
r"is it done|done yet|how far|eta|progress|update me|what'?s left)\b",
|
|
501
|
+
re.IGNORECASE,
|
|
502
|
+
)
|
|
503
|
+
_ACKNOWLEDGEMENTS = frozenset({
|
|
504
|
+
"agree", "agreed", "yes", "ok", "okay", "sure", "yep", "yup", "go",
|
|
505
|
+
"go ahead", "do it", "continue", "proceed", "thanks", "thank you", "ty",
|
|
506
|
+
"lgtm", "looks good", "sounds good", "great", "perfect", "nice", "cool",
|
|
507
|
+
"approved", "approve", "no", "nope", "stop", "wait", "next", "done",
|
|
508
|
+
"keep going", "carry on", "makes sense", "got it", "right", "correct",
|
|
509
|
+
})
|
|
510
|
+
# `do` opens a question only with a subject ("do we…"), never an imperative
|
|
511
|
+
# ("do not touch the config").
|
|
512
|
+
_QUESTION_START = re.compile(
|
|
513
|
+
r"^\s*(?:why|how|what|where|which|who|when|does|is|are|can|could|should|"
|
|
514
|
+
r"would|will|do\s+(?:we|you|i|they|these|those|it)\b)\b",
|
|
515
|
+
re.IGNORECASE,
|
|
516
|
+
)
|
|
517
|
+
# A reply that opens with an acknowledgement and stays short ("yes, sounds
|
|
518
|
+
# good to me", "thanks for doing that") is still an acknowledgement.
|
|
519
|
+
_ACK_PREFIX = re.compile(
|
|
520
|
+
r"^\s*(?:yes|yep|yup|ok|okay|sure|thanks|thank you|ty|agreed?|i agree|"
|
|
521
|
+
r"sounds good|looks good|lgtm|great|perfect|nice|cool|go ahead|do it|"
|
|
522
|
+
r"makes sense|got it)\b",
|
|
523
|
+
re.IGNORECASE,
|
|
524
|
+
)
|
|
525
|
+
_ACK_PREFIX_MAX_WORDS = 8
|
|
526
|
+
_SHORT_REPLY_MAX_WORDS = 2 # "go", "still waiting", "repo_checks.rs?"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _normalize(prompt: str) -> str:
|
|
530
|
+
return re.sub(r"[^\w\s'-]", "", prompt.strip().lower()).strip()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _has_strong_code_signal(prompt: str) -> bool:
|
|
534
|
+
return (
|
|
535
|
+
_has_file_path(prompt)
|
|
536
|
+
or _has_code_markers(prompt)
|
|
537
|
+
or bool(_IDENTIFIER_PATTERN.search(prompt))
|
|
538
|
+
or bool(_DECISION_PATTERN.search(prompt))
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _is_chatter(prompt: str) -> bool:
|
|
543
|
+
"""True for acknowledgements, status checks and one- or two-word replies,
|
|
544
|
+
whatever they name. No per-turn directive belongs on these."""
|
|
545
|
+
if not prompt or not prompt.strip():
|
|
546
|
+
return True
|
|
547
|
+
if _normalize(prompt) in _ACKNOWLEDGEMENTS:
|
|
548
|
+
return True
|
|
549
|
+
if _STATUS_PATTERN.search(prompt):
|
|
550
|
+
return True
|
|
551
|
+
words = len(prompt.split())
|
|
552
|
+
if words <= _SHORT_REPLY_MAX_WORDS:
|
|
553
|
+
return True
|
|
554
|
+
return (
|
|
555
|
+
words <= _ACK_PREFIX_MAX_WORDS
|
|
556
|
+
and bool(_ACK_PREFIX.match(prompt))
|
|
557
|
+
and not _has_strong_code_signal(prompt)
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _prompt_needs_grounding(prompt: str) -> bool:
|
|
562
|
+
"""garden#1333: the Context Assembly gate, decided per prompt.
|
|
563
|
+
|
|
564
|
+
True only when the prompt names code, files, symbols, repos or past
|
|
565
|
+
decisions AND either has more than _GROUNDING_MIN_WORDS words or asks a
|
|
566
|
+
question. Never on acknowledgements, status checks or one-word replies.
|
|
567
|
+
"""
|
|
568
|
+
if _is_chatter(prompt):
|
|
569
|
+
return False
|
|
570
|
+
strong = _has_strong_code_signal(prompt)
|
|
571
|
+
if not strong and not _CODE_NOUN_PATTERN.search(prompt):
|
|
572
|
+
return False
|
|
573
|
+
question = "?" in prompt or bool(_QUESTION_START.match(prompt))
|
|
574
|
+
return question or len(prompt.split()) > _GROUNDING_MIN_WORDS
|
|
575
|
+
|
|
576
|
+
|
|
447
577
|
# ---------------------------------------------------------------------------
|
|
448
578
|
# Setup gate
|
|
449
579
|
# ---------------------------------------------------------------------------
|
|
@@ -723,16 +853,15 @@ def _build_intent_directive(intent: str, turn_count: int, state=None) -> str:
|
|
|
723
853
|
grounding = grounding_directive_lines()
|
|
724
854
|
except Exception:
|
|
725
855
|
grounding = [
|
|
726
|
-
"1.
|
|
727
|
-
"(recall/answer
|
|
728
|
-
"
|
|
729
|
-
"MCP SearchEntity tool for symbol lookup.",
|
|
856
|
+
"1. Recall what is already known: the wicked-garden-mem skill "
|
|
857
|
+
"(`recall` / `answer`) for past decisions, the wicked-garden-search "
|
|
858
|
+
"skill for symbols and blast radius.",
|
|
730
859
|
"2. Open the cited sources with Read before answering; do not answer "
|
|
731
860
|
"from recall snippets alone.",
|
|
732
861
|
]
|
|
733
862
|
directive_lines = [
|
|
734
|
-
f"[Context Assembly — intent={intent}]
|
|
735
|
-
"
|
|
863
|
+
f"[Context Assembly — intent={intent}] This prompt names code or past "
|
|
864
|
+
"decisions; ground it before answering:",
|
|
736
865
|
*grounding,
|
|
737
866
|
]
|
|
738
867
|
if intent == "rigor":
|
|
@@ -942,9 +1071,12 @@ def main():
|
|
|
942
1071
|
# turns produce an empty directive; feature/research/rigor produce
|
|
943
1072
|
# the synthesis directive.
|
|
944
1073
|
_intent = getattr(state, "intent", None) if state else None
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1074
|
+
# garden#1333: the sticky session intent only labels the directive;
|
|
1075
|
+
# whether it fires is decided by THIS prompt (_prompt_needs_grounding).
|
|
1076
|
+
intent_directive = ""
|
|
1077
|
+
if _prompt_needs_grounding(prompt):
|
|
1078
|
+
label = _intent if _intent in ("feature", "research", "rigor") else "research"
|
|
1079
|
+
intent_directive = _build_intent_directive(label, turn_count, state)
|
|
948
1080
|
|
|
949
1081
|
# WIP recovery: surface native in-progress tasks (v5 ticket rail gone)
|
|
950
1082
|
wip_block = _build_wip_recovery_block(session_id, project)
|
package/hooks/scripts/stop.py
CHANGED
|
@@ -57,27 +57,106 @@ def _session_dir(subdir: str) -> Path:
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
# ---------------------------------------------------------------------------
|
|
60
|
-
# Output governance — per-
|
|
60
|
+
# Output governance — once-per-session rules audit (garden#984, AW-16, #1333)
|
|
61
61
|
# ---------------------------------------------------------------------------
|
|
62
62
|
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
63
|
+
# The per-turn line ("If the estate MCP is connected: call rules.recall…") is
|
|
64
|
+
# gone: no estate MCP is registered on a seat by design, so it was an
|
|
65
|
+
# instruction that could never run, repeated every turn (garden#1333). In its
|
|
66
|
+
# place, the FIRST Stop of a session runs `rules.recall` itself through the
|
|
67
|
+
# read-only shim (the one way to estate on every seat) and, only when the store
|
|
68
|
+
# holds policy rules, names them once with the command that lists them all.
|
|
69
|
+
# Zero rules, an unreachable estate, or any error: silent.
|
|
66
70
|
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
# WG_OUTGOV=warn (default) | strict | off (per-repo opt-out, P-5). Any
|
|
72
|
+
# unrecognized value is treated as off. Two-tier doctrine (TARGET-ARCHITECTURE
|
|
73
|
+
# contract 3): fail-open ADVISORY at every mode; `strict` only strengthens the
|
|
74
|
+
# wording. Fail-closed enforcement lives in the crew/core gates, never here.
|
|
75
|
+
|
|
76
|
+
_OUTGOV_RULES_ARGS = {"rule_type": "policy", "limit": 5}
|
|
77
|
+
_OUTGOV_LIST_CMD = (
|
|
78
|
+
"wicked-garden run scripts/_estate_client.py --readonly call "
|
|
79
|
+
'\'{"tool":"rules.recall","arguments":{"rule_type":"policy"}}\''
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _recall_policy_rules(timeout: float = 15.0) -> Optional[dict]:
|
|
84
|
+
"""`rules.recall {"rule_type":"policy"}` through the read-only shim.
|
|
85
|
+
|
|
86
|
+
Returns the tool's result (`{"rules": [...], "total": N, ...}`) or None on
|
|
87
|
+
any failure (shim missing, estate unreachable, timeout, bad JSON).
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
import subprocess
|
|
91
|
+
shim = _PLUGIN_ROOT / "scripts" / "_estate_client.py"
|
|
92
|
+
if not shim.is_file():
|
|
93
|
+
return None
|
|
94
|
+
req = json.dumps({"tool": "rules.recall", "arguments": _OUTGOV_RULES_ARGS,
|
|
95
|
+
"all_pages": False})
|
|
96
|
+
proc = subprocess.run(
|
|
97
|
+
[sys.executable, str(shim), "--readonly", "call", req],
|
|
98
|
+
capture_output=True, text=True, timeout=timeout,
|
|
99
|
+
)
|
|
100
|
+
if proc.returncode != 0:
|
|
101
|
+
return None
|
|
102
|
+
out = json.loads((proc.stdout or "").strip().splitlines()[-1])
|
|
103
|
+
result = out.get("result") if isinstance(out, dict) else None
|
|
104
|
+
return result if isinstance(result, dict) else None
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
74
107
|
|
|
75
|
-
|
|
76
|
-
|
|
108
|
+
|
|
109
|
+
def _claim_outgov_audit(input_data: dict) -> bool:
|
|
110
|
+
"""Atomically claim this session's one audit. True exactly once per session.
|
|
111
|
+
|
|
112
|
+
An O_EXCL marker file, not a SessionState flag: Stop runs async, and a
|
|
113
|
+
prompt hook that loaded the state before Stop saved the flag would write
|
|
114
|
+
the old value back on its own save, re-arming the audit. Two concurrent
|
|
115
|
+
Stops can't both create the file either.
|
|
116
|
+
"""
|
|
117
|
+
raw = str(input_data.get("session_id") or os.environ.get("CLAUDE_SESSION_ID") or "default")
|
|
118
|
+
sid = "".join(c if c.isalnum() or c in "-_" else "_" for c in raw)[:128] or "default"
|
|
119
|
+
marker_dir = Path(os.environ.get("TMPDIR") or __import__("tempfile").gettempdir()) / "wicked-garden-outgov"
|
|
120
|
+
marker_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
try:
|
|
122
|
+
fd = os.open(str(marker_dir / f"{sid}.audited"), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
123
|
+
except FileExistsError:
|
|
124
|
+
return False
|
|
125
|
+
os.close(fd)
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_outgov_compliance(input_data: dict, recall=None, claim=None) -> list:
|
|
130
|
+
"""Once-per-session policy-rules audit unless WG_OUTGOV is off.
|
|
131
|
+
|
|
132
|
+
`recall` / `claim` are test seams (default: the shim, the marker file).
|
|
133
|
+
"""
|
|
77
134
|
try:
|
|
78
135
|
mode = os.environ.get("WG_OUTGOV", "warn").strip().lower()
|
|
79
136
|
if mode not in ("warn", "strict"):
|
|
80
137
|
return []
|
|
138
|
+
if os.environ.get("WICKED_CONTEXT_BACKEND", "").strip().lower() == "off":
|
|
139
|
+
return [] # operator turned estate-backed context off: designed silence
|
|
140
|
+
# Claim BEFORE the call: a slow or failing estate costs one attempt per
|
|
141
|
+
# session, never one per turn.
|
|
142
|
+
if not (claim or _claim_outgov_audit)(input_data or {}):
|
|
143
|
+
return []
|
|
144
|
+
result = (recall or _recall_policy_rules)()
|
|
145
|
+
if not isinstance(result, dict):
|
|
146
|
+
return []
|
|
147
|
+
rules = [r for r in (result.get("rules") or []) if isinstance(r, dict)]
|
|
148
|
+
try:
|
|
149
|
+
total = int(result.get("total") or len(rules))
|
|
150
|
+
except (TypeError, ValueError):
|
|
151
|
+
total = len(rules)
|
|
152
|
+
if total <= 0 or not rules:
|
|
153
|
+
return []
|
|
154
|
+
shown = []
|
|
155
|
+
for r in rules[:3]:
|
|
156
|
+
statement = " ".join(str(r.get("statement") or "").split())
|
|
157
|
+
if len(statement) > 100:
|
|
158
|
+
statement = statement[:97] + "..."
|
|
159
|
+
shown.append(f"{r.get('id') or '?'} ({r.get('severity') or '?'}): {statement}")
|
|
81
160
|
deny_hint = (
|
|
82
161
|
" For CRITICAL severity violations: stop and explain the policy conflict "
|
|
83
162
|
"before writing or executing the action that would worsen it."
|
|
@@ -85,13 +164,10 @@ def _check_outgov_compliance(_input_data: dict) -> list:
|
|
|
85
164
|
else ""
|
|
86
165
|
)
|
|
87
166
|
return [
|
|
88
|
-
"[Output Governance]
|
|
89
|
-
"
|
|
90
|
-
|
|
91
|
-
"
|
|
92
|
-
"rules, and report any violations with rule ID, severity, and a brief explanation. "
|
|
93
|
-
"This is advisory only. "
|
|
94
|
-
"If estate or `rules.recall` is unavailable: skip silently." + deny_hint
|
|
167
|
+
f"[Output Governance] {total} policy rule(s) apply in this store; check "
|
|
168
|
+
"this session's changes against them (advisory). Top: "
|
|
169
|
+
+ "; ".join(shown)
|
|
170
|
+
+ f". Full list: {_OUTGOV_LIST_CMD}" + deny_hint
|
|
95
171
|
]
|
|
96
172
|
except Exception:
|
|
97
173
|
return []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-garden",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.60.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": [
|
|
@@ -381,13 +381,25 @@ def memory_directive_target() -> str:
|
|
|
381
381
|
return "the wicked-garden-mem skill (store action)"
|
|
382
382
|
|
|
383
383
|
|
|
384
|
+
#: garden#1333: the exact one-line memory command an agent seat can run. A seat
|
|
385
|
+
#: is read-only (the mem skill refuses `store` under --readonly), so the path
|
|
386
|
+
#: that works everywhere is a pending memory proposal an operator approves.
|
|
387
|
+
MEMORY_PROPOSE_COMMAND = (
|
|
388
|
+
"wicked-garden run scripts/mem/estate_memory.py --readonly propose "
|
|
389
|
+
"'<the rule, why it holds, when it applies>'"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
384
393
|
def grounding_directive_lines() -> List[str]:
|
|
385
394
|
"""Numbered grounding steps for the Context Assembly directive."""
|
|
395
|
+
# No estate MCP is registered on a seat (by design): every pointer here is
|
|
396
|
+
# a skill or the read-only shim, never an MCP tool name (garden#1333).
|
|
386
397
|
return [
|
|
387
|
-
"1.
|
|
388
|
-
"(recall/answer
|
|
389
|
-
"
|
|
390
|
-
"
|
|
398
|
+
"1. Recall what is already known: the wicked-garden-mem skill "
|
|
399
|
+
"(`recall` / `answer`) for past decisions, the wicked-garden-search "
|
|
400
|
+
"skill for symbols and blast radius (or the read-only shim: "
|
|
401
|
+
"`wicked-garden run scripts/_estate_client.py --readonly search "
|
|
402
|
+
"'{\"name\":\"<symbol>\"}'`).",
|
|
391
403
|
"2. Open the cited sources with Read before answering; do not answer "
|
|
392
404
|
"from recall snippets alone.",
|
|
393
405
|
]
|
|
@@ -1366,6 +1366,32 @@ _USAGE = (
|
|
|
1366
1366
|
)
|
|
1367
1367
|
|
|
1368
1368
|
|
|
1369
|
+
def _propose_arg_problems(args: dict) -> list:
|
|
1370
|
+
"""Every field `propose` is missing or has the wrong type, named exactly.
|
|
1371
|
+
|
|
1372
|
+
garden#1333: one generic "requires {kind_type…}" error hid which field was
|
|
1373
|
+
wrong (and was also what `-` produced before stdin was read).
|
|
1374
|
+
"""
|
|
1375
|
+
if set(args) == {"_"}:
|
|
1376
|
+
return ["json-args is not valid JSON (pass one JSON object, or `-` and pipe it on stdin)"]
|
|
1377
|
+
problems = []
|
|
1378
|
+
kind_type = args.get("kind_type")
|
|
1379
|
+
if not isinstance(kind_type, str) or not kind_type.strip():
|
|
1380
|
+
problems.append("missing kind_type" if kind_type is None
|
|
1381
|
+
else "kind_type must be a non-empty string")
|
|
1382
|
+
payload = args.get("payload")
|
|
1383
|
+
if payload is None:
|
|
1384
|
+
problems.append("missing payload")
|
|
1385
|
+
elif not isinstance(payload, dict):
|
|
1386
|
+
problems.append(f"payload must be an object, got {type(payload).__name__}")
|
|
1387
|
+
facets = args.get("facets")
|
|
1388
|
+
if facets is not None and not isinstance(facets, dict):
|
|
1389
|
+
problems.append(f"facets must be an object, got {type(facets).__name__}")
|
|
1390
|
+
# A missing facets.project is named by propose() itself ({"ok": false,
|
|
1391
|
+
# "code": -32602, "reason": "proposal needs facets.project …"}).
|
|
1392
|
+
return problems
|
|
1393
|
+
|
|
1394
|
+
|
|
1369
1395
|
def _emit(obj: Any) -> None:
|
|
1370
1396
|
# A relayed call that came back empty says why (the broker's reason and remedy), additively:
|
|
1371
1397
|
# the fail-open empty value is unchanged.
|
|
@@ -1398,11 +1424,19 @@ def main(argv: list) -> int:
|
|
|
1398
1424
|
_emit({"ok": False, "reason": refusal, "governed": True})
|
|
1399
1425
|
return 0
|
|
1400
1426
|
raw = argv[1] if len(argv) > 1 else "{}"
|
|
1427
|
+
if raw == "-":
|
|
1428
|
+
# `-` reads the json-args from stdin (garden#1333: long or quote-heavy
|
|
1429
|
+
# JSON never fights shell quoting). It used to be parsed as the literal
|
|
1430
|
+
# string "-", so `propose -` failed with a misleading shape error.
|
|
1431
|
+
raw = sys.stdin.read()
|
|
1401
1432
|
try:
|
|
1402
|
-
args = json.loads(raw) if raw else {}
|
|
1433
|
+
args = json.loads(raw) if raw.strip() else {}
|
|
1403
1434
|
except json.JSONDecodeError:
|
|
1404
1435
|
# Convenience: a bare positional is treated as the primary string arg.
|
|
1405
1436
|
args = {"_": raw}
|
|
1437
|
+
if not isinstance(args, dict):
|
|
1438
|
+
_emit({"error": f"json-args must be a JSON object, got {type(args).__name__}; {_USAGE}"})
|
|
1439
|
+
return 1
|
|
1406
1440
|
|
|
1407
1441
|
if action == "health":
|
|
1408
1442
|
_emit({"reachable": health()})
|
|
@@ -1440,12 +1474,16 @@ def main(argv: list) -> int:
|
|
|
1440
1474
|
_emit({"result": blocks["result"] if blocks else None,
|
|
1441
1475
|
"diagnostics": blocks["diagnostics"] if blocks else []})
|
|
1442
1476
|
elif action == "propose":
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
'"
|
|
1477
|
+
problems = _propose_arg_problems(args)
|
|
1478
|
+
if problems:
|
|
1479
|
+
_emit({"error": "propose: " + "; ".join(problems) + ' (shape: {"kind_type": '
|
|
1480
|
+
'"memory"|"knowledge"|"policy:<type>", "payload": {...}, '
|
|
1481
|
+
'"facets": {"project": "<name>", ...}} — never "provenance"; '
|
|
1482
|
+
"for a plain memory use `wicked-garden run scripts/mem/"
|
|
1483
|
+
"estate_memory.py --readonly propose '<text>'`)"})
|
|
1448
1484
|
return 1
|
|
1485
|
+
kind_type = args["kind_type"]
|
|
1486
|
+
payload = args["payload"]
|
|
1449
1487
|
# `provenance` is deliberately not forwarded (server-stamped from WICKED_RUN_*).
|
|
1450
1488
|
_emit(propose(kind_type, payload, args.get("facets")))
|
|
1451
1489
|
else:
|
package/scripts/_session.py
CHANGED
|
@@ -154,6 +154,11 @@ class SessionState:
|
|
|
154
154
|
# One-per-session gate for jam suggestion (reset each session).
|
|
155
155
|
jam_hint_shown: bool = False
|
|
156
156
|
|
|
157
|
+
# The persisted classification prompt_submit last echoed (garden#1333):
|
|
158
|
+
# "<classified_at>|<archetypes json>". The model's own classification is
|
|
159
|
+
# echoed back once, not on every turn.
|
|
160
|
+
archetype_echoed: str | None = None
|
|
161
|
+
|
|
157
162
|
# Operational log verbosity level for this session.
|
|
158
163
|
# "" means "not set" — the logger defaults to "normal".
|
|
159
164
|
# Valid values: "normal", "verbose", "debug"
|
|
@@ -76,6 +76,7 @@ Usage
|
|
|
76
76
|
-----
|
|
77
77
|
python3 scripts/mem/estate_memory.py [--readonly] [--governed] [--db <path>] <action> '<json-args>'
|
|
78
78
|
printf '%s' '<json-args>' | python3 scripts/mem/estate_memory.py <action> -
|
|
79
|
+
python3 scripts/mem/estate_memory.py --readonly propose '<memory text>' # garden#1333
|
|
79
80
|
"""
|
|
80
81
|
|
|
81
82
|
import json
|
|
@@ -106,7 +107,7 @@ KIND_DEFAULT_TIER = {
|
|
|
106
107
|
|
|
107
108
|
_USAGE = (
|
|
108
109
|
"usage: estate_memory.py [--readonly] [--governed] [--db <path>] "
|
|
109
|
-
"<store|recall|review|forget|maintain|capture-batch|ingest|write|sources|health> "
|
|
110
|
+
"<store|propose|recall|review|forget|maintain|capture-batch|ingest|write|sources|health> "
|
|
110
111
|
"['<json-args>' | -]"
|
|
111
112
|
)
|
|
112
113
|
|
|
@@ -134,12 +135,19 @@ def _default_intent():
|
|
|
134
135
|
from the enclosing git checkout: ``repo`` = its directory name, ``project``
|
|
135
136
|
= the run's `WICKED_RUN_PROJECT`, else the checkout's parent directory
|
|
136
137
|
(the umbrella, e.g. ``wicked`` for ``wicked/wicked-estate``). Outside a git
|
|
137
|
-
checkout: {}
|
|
138
|
+
checkout: ``{project}`` = the run's project, else the cwd's directory name,
|
|
139
|
+
the same facet `propose` files a memory under there (garden#1333), so a
|
|
140
|
+
memory proposed from a directory is recalled from it.
|
|
138
141
|
"""
|
|
142
|
+
run_project = (os.environ.get("WICKED_RUN_PROJECT") or "").strip()
|
|
139
143
|
root = _estate_client.checkout_root()
|
|
140
144
|
if root is None:
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
try:
|
|
146
|
+
project = run_project or Path.cwd().name
|
|
147
|
+
except OSError:
|
|
148
|
+
project = run_project
|
|
149
|
+
return {"project": project} if project else {}
|
|
150
|
+
project = run_project or root.parent.name
|
|
143
151
|
return {k: v for k, v in (("repo", root.name), ("project", project)) if v}
|
|
144
152
|
|
|
145
153
|
|
|
@@ -301,6 +309,66 @@ def do_store(args):
|
|
|
301
309
|
return 0
|
|
302
310
|
|
|
303
311
|
|
|
312
|
+
def _propose_facets():
|
|
313
|
+
"""``facets`` for a memory proposal: exactly the intent default recall
|
|
314
|
+
admits against (:func:`_default_intent`), so the approved memory is
|
|
315
|
+
recalled from the same place. ``project`` falls back to ``unknown`` only
|
|
316
|
+
when the cwd has no name."""
|
|
317
|
+
facets = dict(_default_intent())
|
|
318
|
+
facets.setdefault("project", "unknown")
|
|
319
|
+
# The same override the shim applies, so the reported facets are the sent ones.
|
|
320
|
+
return _estate_client._scope_facets_to_run_project(facets)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def do_propose(args):
|
|
324
|
+
"""garden#1333 — the one-line memory path for an agent seat.
|
|
325
|
+
|
|
326
|
+
``propose '<text>'`` (or ``propose -`` with the text or a JSON object on
|
|
327
|
+
stdin) builds the whole ``proposal.submit`` envelope itself: ``kind_type:
|
|
328
|
+
memory``, ``payload {content, tier}``, ``facets.project`` (+ ``repo``) from
|
|
329
|
+
the cwd. It is the write a ``--readonly`` seat still has: the memory lands
|
|
330
|
+
``pending`` until an operator approves it. JSON args may set ``content``
|
|
331
|
+
(or ``text``), ``kind`` (fact → semantic, skill → procedural), ``tier``,
|
|
332
|
+
``about`` and ``facets`` (merged over the derived ones).
|
|
333
|
+
"""
|
|
334
|
+
content = args.get("content") or args.get("text") or args.get("_") or ""
|
|
335
|
+
if not isinstance(content, str):
|
|
336
|
+
return _fail_open(f"content must be a string, got {type(content).__name__}")
|
|
337
|
+
content = content.strip()
|
|
338
|
+
if not content:
|
|
339
|
+
return _fail_open('nothing to propose: pass the memory text, e.g. propose '
|
|
340
|
+
'"<rule, why it holds, when it applies>"')
|
|
341
|
+
if args.get("source"):
|
|
342
|
+
return _fail_open("acquired from a source, so it is knowledge, not memory: use the "
|
|
343
|
+
"`write` action or a knowledge proposal")
|
|
344
|
+
rejects = durability_rejects(content)
|
|
345
|
+
if rejects:
|
|
346
|
+
return _fail_open("not a durable learning (" + "; ".join(rejects) + "): state the rule, "
|
|
347
|
+
"why it holds and when it applies")
|
|
348
|
+
kind = args.get("kind") or "fact"
|
|
349
|
+
if kind not in KINDS:
|
|
350
|
+
return _fail_open(f"unknown kind: {kind} (valid: {', '.join(KINDS)})")
|
|
351
|
+
tier = args.get("tier") or KIND_DEFAULT_TIER[kind]
|
|
352
|
+
if tier not in TIERS:
|
|
353
|
+
return _fail_open(f"unknown tier: {tier} (valid: {', '.join(TIERS)})")
|
|
354
|
+
payload = {"content": content, "tier": tier}
|
|
355
|
+
about = args.get("about")
|
|
356
|
+
if isinstance(about, list) and about:
|
|
357
|
+
payload["about"] = [str(a) for a in about]
|
|
358
|
+
facets = _propose_facets()
|
|
359
|
+
extra = args.get("facets")
|
|
360
|
+
if isinstance(extra, dict):
|
|
361
|
+
facets.update({k: v for k, v in extra.items() if isinstance(v, str) and v.strip()})
|
|
362
|
+
out = _estate_client.propose("memory", payload, facets)
|
|
363
|
+
if out.get("ok"):
|
|
364
|
+
out["facets"] = facets
|
|
365
|
+
warnings = durability_warnings(content)
|
|
366
|
+
if warnings:
|
|
367
|
+
out["warnings"] = warnings
|
|
368
|
+
_emit(out)
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
304
372
|
def do_recall(args):
|
|
305
373
|
query = (args.get("query") or args.get("_") or "").strip()
|
|
306
374
|
if not query:
|
|
@@ -497,6 +565,7 @@ def do_health(_args):
|
|
|
497
565
|
|
|
498
566
|
_ACTIONS = {
|
|
499
567
|
"store": do_store,
|
|
568
|
+
"propose": do_propose,
|
|
500
569
|
"recall": do_recall,
|
|
501
570
|
"review": do_review,
|
|
502
571
|
"forget": do_forget,
|
|
@@ -529,6 +598,8 @@ def main(argv):
|
|
|
529
598
|
raw = sys.stdin.read()
|
|
530
599
|
try:
|
|
531
600
|
args = json.loads(raw) if raw.strip() else {}
|
|
601
|
+
if args is None or isinstance(args, (str, int, float, bool)):
|
|
602
|
+
args = {"_": raw.strip()} # a JSON scalar is the primary string arg too
|
|
532
603
|
except json.JSONDecodeError:
|
|
533
604
|
# Convenience: a bare positional is the primary string arg.
|
|
534
605
|
args = {"_": raw}
|
package/skills/classify/SKILL.md
CHANGED
|
@@ -7,9 +7,9 @@ description: |
|
|
|
7
7
|
reversibility, etc.), and persists to SessionState so subsequent turns
|
|
8
8
|
steer correctly.
|
|
9
9
|
|
|
10
|
-
Use when:
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
Use when: explicitly invoked at session start, when the work shape is
|
|
11
|
+
unclear and matters, OR when re-classifying after the user changes scope
|
|
12
|
+
mid-session.
|
|
13
13
|
metadata:
|
|
14
14
|
role: worker
|
|
15
15
|
---
|
|
@@ -49,8 +49,11 @@ When a routing or decision call is genuinely hard, ambiguous, or high-stakes,
|
|
|
49
49
|
garden tools for additional processing. The council is the *escalation*, not a
|
|
50
50
|
per-prompt router — convening 20 CLIs on every prompt would be absurd cost.
|
|
51
51
|
|
|
52
|
-
- **Fast path (
|
|
53
|
-
|
|
52
|
+
- **Fast path (on demand)** — you invoke this skill when the work shape matters,
|
|
53
|
+
classify + persist here, and the parent turn steers on the persisted archetype.
|
|
54
|
+
(The `prompt_submit` hook no longer emits `<wg classify-due />` when its regex
|
|
55
|
+
falls through: that nag fired on ordinary questions, garden#1333. It stays
|
|
56
|
+
silent unless the regex is confident.)
|
|
54
57
|
- **Escalation (on demand)** — `wicked-garden-jam council <question> --options "…"`.
|
|
55
58
|
|
|
56
59
|
## What the catalog declares
|
package/skills/mem/SKILL.md
CHANGED
|
@@ -50,8 +50,9 @@ A human operator at a terminal may omit it to `store` / `ingest` / `forget`. The
|
|
|
50
50
|
read-only with the store pinned from
|
|
51
51
|
the environment (`WICKED_ESTATE_DB` / `WICKED_HOME` / `WICKED_MEMORY_DB`) or `--db <path>`; an
|
|
52
52
|
unpinned store is refused (`ok: false`), and `store` / `capture-batch` / `ingest` / `write` /
|
|
53
|
-
`forget` are refused by the read-only engine —
|
|
54
|
-
|
|
53
|
+
`forget` are refused by the read-only engine — save a learning with `propose` instead
|
|
54
|
+
(§ Propose: one line, the backend builds the proposal), or file knowledge/policy proposals
|
|
55
|
+
through the `wicked-garden-repo-learn` skill's capture contract. `recall` / `review` / `sources` / `health`
|
|
55
56
|
work unchanged. Spell `--readonly` exactly (`--read-only` is a usage error). This is the one way
|
|
56
57
|
to the stores on every seat and in every session kind: no estate tool is registered on a seat,
|
|
57
58
|
nothing to connect, nothing to fall back to.
|
|
@@ -66,6 +67,7 @@ Relative paths in this skill are relative to the directory that contains this SK
|
|
|
66
67
|
| Action | Use for | How |
|
|
67
68
|
|--------|---------|-----|
|
|
68
69
|
| `store` | persist one learning (decision, pattern, gotcha, discovery) | § Store |
|
|
70
|
+
| `propose` | save one learning from a read-only seat (lands pending for operator approval) | § Propose |
|
|
69
71
|
| `recall` | find memories ("what do we know about X") | § Recall |
|
|
70
72
|
| `answer` | grounded, cited answer from the stores | the `wicked-garden-search` skill's `refs/answer.md` |
|
|
71
73
|
| `review` | browse what's stored (counts by kind/tier, scope digest) | § Review / Maintain |
|
|
@@ -111,6 +113,22 @@ A chat seat asked by wicked-crew to label the operator's decisions reads
|
|
|
111
113
|
4. Confirm the returned `memory_id`; on `ok: false` report the degrade — do
|
|
112
114
|
not silently drop the learning.
|
|
113
115
|
|
|
116
|
+
## Propose — the one-line memory path on a seat
|
|
117
|
+
|
|
118
|
+
A seat is read-only, so `store` is refused there. `propose` is the write it still has:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
wicked-garden run scripts/mem/estate_memory.py --readonly propose '<the rule, why it holds, when it applies>'
|
|
122
|
+
printf '%s' '<long text>' | wicked-garden run scripts/mem/estate_memory.py --readonly propose -
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The backend builds the whole proposal: `kind_type: "memory"`, `payload {content, tier}`
|
|
126
|
+
(`fact` → `semantic`; pass `{"content": …, "kind": "skill"}` for a how-to → `procedural`),
|
|
127
|
+
and `facets` `{repo, project}` from the cwd's checkout (`project` = the cwd name outside one;
|
|
128
|
+
a run's `WICKED_RUN_PROJECT` wins). The § Store durability test applies and the backend
|
|
129
|
+
enforces the same refusals. It answers `{"ok": true, "id": …, "facets": …}`; the memory is
|
|
130
|
+
`pending` until an operator approves it. On `ok: false`, report the reason.
|
|
131
|
+
|
|
114
132
|
## Recall
|
|
115
133
|
|
|
116
134
|
```bash
|
|
@@ -47,7 +47,9 @@ which files it under the run's project. With neither, a run refuses it with `{"o
|
|
|
47
47
|
"reason": …}` — report it, do not guess a store. The shim answers `{"ok": true, "id": "<proposal-id>"}` or `{"ok": false,
|
|
48
48
|
"reason": …, "code": <json-rpc code or null>}`: `-32602` is a *your-args* bug (fix the
|
|
49
49
|
shape), `null` means the estate was unreachable (keep the objects — next section). Pass
|
|
50
|
-
the JSON as ONE quoted argument,
|
|
50
|
+
the JSON as ONE quoted argument, or pass `-` and pipe it on stdin (`printf '%s' '<json>' |
|
|
51
|
+
… propose -`). A plain memory needs no envelope at all: `wicked-garden run
|
|
52
|
+
scripts/mem/estate_memory.py --readonly propose '<text>'` builds it. A
|
|
51
53
|
`provenance` key is dropped.
|
|
52
54
|
|
|
53
55
|
### When `propose` fails in a governed run — no file fallback
|