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.
@@ -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.59.0"}], "version": "12.59.0"}
1
+ {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, multi-model second opinions (fallback seats labelled). Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.60.0"}], "version": "12.60.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.59.0",
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.59.0",
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.59.0...HEAD
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 (Per-Turn)](outgov-per-turn.md) | The advisory hook lane — `WG_OUTGOV` modes (default `warn`), `rules.recall` as the single per-turn rule source, content-hash provenance for the graph-derived rules dir |
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
@@ -1,4 +1,4 @@
1
- # Output Governance — the Per-Turn Advisory Lane
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` per-turn advisory, `guard_pipeline` Check 6) | fail-open — surfaces findings, never blocks |
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. **Per-turn advisory** (`hooks/scripts/stop.py::_check_outgov_compliance`):
27
- the Stop-hook systemMessage directs the model at the estate MCP tool
28
- **`rules.recall`** (`{"rule_type": "policy"}`, severity-ordered,
29
- graph-backed) — the wire twin of wicked-governance's `recall_rules`. If
30
- estate or the tool is unavailable, the advisory says to skip silently.
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 | Per-turn advisory (stop.py) | Check 6 (guard_pipeline) |
43
- |-------|------------------------------|---------------------------|
44
- | *(unset)* → **`warn`** | fires (default-on advisory) | runs |
45
- | `warn` | fires | runs |
46
- | `strict` | fires with stronger wording — still advisory | runs |
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 per-turn directive names
131
- `rules.recall`; default-on; `off` opts out; `strict` stays advisory; any
132
- internal error returns no message.
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-turn advisory)
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
- # Name the memory surface (estate memory.capture). Fail-open to the
284
- # same wording.
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 memory_directive_target
287
- _mem_target = memory_directive_target()
287
+ from _context_backend import MEMORY_PROPOSE_COMMAND as _mem_cmd
288
288
  except Exception:
289
- _mem_target = "the wicked-garden-mem skill (store action)"
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
- "This project uses wicked-garden memory for persistence. "
293
- f"Use {_mem_target} to save decisions, patterns, and gotchas instead."
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 a recent
194
- `classified_at` + `archetypes_v11`, prefer that — the model
195
- already classified through the wicked-garden-classify skill.
196
- 2. **Fallback (regex)**: when no persisted classification exists,
197
- run archetypes_v11.detect_archetypes() against the prompt
198
- directly. If the prompt is non-trivial (intent != simple-edit)
199
- AND the regex result is weak (only triage matched), emit a
200
- directive asking the model to invoke the classify skill —
201
- LLM reasoning beats regex on ambiguous prompts.
202
-
203
- Skips emission when intent is 'simple-edit' (silence is the signal).
204
- Fail-open on any error.
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
- # Filter triage out unless sole match; emit shape-card from persisted.
215
- real = [a for a in persisted if a.get("name") != "triage"]
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
- if len(real) == 1 and float(real[0].get("score", 0)) >= 0.7:
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[:3]])
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, HIGH_CONFIDENCE
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
- real = [(n, s, e) for (n, s, e) in matches if n != "triage"]
253
-
254
- if not real:
255
- # Regex couldn't classify cleanly — ask the model to do it instead.
256
- # This is the LLM-classifier opt-in path: the model invokes the
257
- # wicked-garden-classify skill, persists the result, and on the
258
- # next turn the persisted-tier branch above takes over.
259
- return (
260
- "<wg classify-due />\n"
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, _) in real[:3]]
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, _e) in real[:3]])
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. Ground in the knowledge layer via the wicked-garden-mem skill "
727
- "(recall/answer over wicked-estate knowledge + memory) — concepts, "
728
- "files, and past decisions, with source attribution; use the estate "
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}] Deep context needed before answering.",
735
- "BEFORE drafting a response:",
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
- intent_directive = _build_intent_directive(
946
- _intent or "simple-edit", turn_count, state
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)
@@ -57,27 +57,106 @@ def _session_dir(subdir: str) -> Path:
57
57
 
58
58
 
59
59
  # ---------------------------------------------------------------------------
60
- # Output governance — per-turn policy compliance advisory (garden#984, AW-16)
60
+ # Output governance — once-per-session rules audit (garden#984, AW-16, #1333)
61
61
  # ---------------------------------------------------------------------------
62
62
  #
63
- # When WG_OUTGOV=warn|strict, returns a systemMessage asking Claude to recall
64
- # applicable conformance rules from the estate graph via `rules.recall` — THE
65
- # single per-turn rule source (arch-R14) — and evaluate this turn's output.
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
- # Default is `warn` (default-on advisory, AW-16 after the AW-13 seed corpus).
68
- # Set WG_OUTGOV=off to opt a repo out (P-5 per-repo noise-budget escape hatch;
69
- # see docs/outgov-per-turn.md). Any unrecognized value is treated as off.
70
- #
71
- # Two-tier doctrine (TARGET-ARCHITECTURE contract 3): this hook is fail-open
72
- # ADVISORY — it never blocks, even at `strict` (strict only strengthens the
73
- # wording). Fail-closed enforcement lives in the crew/core gates, never here.
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
- def _check_outgov_compliance(_input_data: dict) -> list:
76
- """Per-turn policy compliance advisory unless WG_OUTGOV is off."""
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] Evaluate this turn's output against applicable conformance rules. "
89
- "If the estate MCP is connected: call the `rules.recall` tool with "
90
- '{"rule_type": "policy"} — the single per-turn rule source (severity-ordered, '
91
- "graph-backed; add language/layer facets if the turn is scoped) — identify applicable "
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.59.0",
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. Ground in the knowledge layer via the wicked-garden-mem skill "
388
- "(recall/answer over wicked-estate knowledge + memory) — concepts, "
389
- "files, and past decisions, with source attribution; use the estate "
390
- "MCP SearchEntity tool for symbol lookup.",
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
- kind_type = args.get("kind_type")
1444
- payload = args.get("payload")
1445
- if not isinstance(kind_type, str) or not kind_type or not isinstance(payload, dict):
1446
- _emit({"error": 'propose requires {"kind_type": "memory"|"policy:<type>", '
1447
- '"payload": {...}, "facets": {...}?} — never "provenance"'})
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:
@@ -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: {} (unfaceted memories only, as before).
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
- return {}
142
- project = (os.environ.get("WICKED_RUN_PROJECT") or "").strip() or root.parent.name
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}
@@ -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: the prompt_submit hook emitted a `<wg classify-due />` directive,
11
- OR explicitly invoked at session start, OR when re-classifying after the
12
- user changes scope mid-session.
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 (always on)** — the `prompt_submit` hook emits `<wg classify-due />`,
53
- you classify + persist here, the parent turn steers on the persisted archetype.
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
@@ -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 — capture learnings as proposals instead (the
54
- `wicked-garden-repo-learn` skill's capture contract). `recall` / `review` / `sources` / `health`
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, even when it is long: `propose` does not read stdin. A
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