wicked-garden 12.51.0 → 12.52.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.51.0"}], "version": "12.51.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.52.0"}], "version": "12.52.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.51.0",
3
+ "version": "12.52.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.5.0",
6
6
  "wicked_ledger_version": "^0.6.0",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.52.0] — 2026-10-11
6
+
7
+ ### Changed
8
+ - **Learn indexing reads what merged and replaces a topic by its source (PR #1280).**
9
+ - `scripts/learn/index.py --ref <ref>` reads `.wicked/knowledge/` at a git ref, never the working copy. wicked-crew's learn schedule passes `origin/<default>`.
10
+ - When estate advertises `knowledge.forget` (estate K1+K2), each topic becomes one `knowledge.ingest {replace_source: true}` carrying `observed_at` and `source_rev`, and a topic with nothing left is forgotten by its source.
11
+ - An older estate keeps the per-revision scope fallback.
12
+
13
+ ## [12.51.1] — 2026-10-11
14
+
15
+ ### Fixed
16
+ - **Memory scope is the checkout, not the cwd (w9-memfix2, codex post-merge review of #1274).** `project:<name>` now comes from the git checkout (a linked worktree resolves to its main checkout), so recalling from `app/src` sees what was stored from `app/`; `WICKED_ESTATE_MEMORY_SCOPE` is the configured project for both capture and recall. Two unrelated checkouts with the same name still share a scope unless one is configured.
17
+ - **The durability gate keeps durable constants and constraints.** A version is refused only next to release status ("released 2.4.1"), so "keep SQLite >= 3.35.0 because RETURNING" is stored; a hex run counts as a commit SHA only when long (>= 12) or in commit context, so a CRC32 polynomial or a colour is kept.
18
+ - **repo-learn no longer submits unapprovable knowledge proposals.** Estate promotes only `memory` and `policy:*`, so the shim refuses `kind_type: "knowledge"` and repo-learn lists those objects as pending in its report until estate's knowledge proposal type ships.
19
+
5
20
  ## [12.51.0] — 2026-10-11
6
21
 
7
22
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.51.0",
3
+ "version": "12.52.0",
4
4
  "type": "module",
5
5
  "description": "Curated toolkit for what AI coding agents can't do alone: evidence-gated work, codegraph relationships, deterministic multi-file refactor, multi-model second opinions, and cross-session memory. Installs as a Claude Code plugin.",
6
6
  "keywords": [
@@ -77,9 +77,6 @@ def _memory_scope() -> str:
77
77
  its ancestors (root-scoped memories included) but not other projects.
78
78
  Override via WICKED_ESTATE_MEMORY_SCOPE.
79
79
  """
80
- pinned = os.environ.get("WICKED_ESTATE_MEMORY_SCOPE", "").strip()
81
- if pinned:
82
- return pinned
83
80
  import _estate_client
84
81
 
85
82
  return _estate_client.project_scope()
@@ -963,15 +963,49 @@ def context(
963
963
  return call("ContextBundle", args, timeout=timeout)
964
964
 
965
965
 
966
- def project_scope() -> str:
967
- """The current project's memory scope, ``project:<cwd-basename>``.
966
+ def checkout_root() -> Optional[Path]:
967
+ """The git checkout the cwd is in, resolved through linked worktrees.
968
968
 
969
- The one default for both directions: memories are stored here, and recall
970
- sees this scope plus its ancestors (root-scoped memories included). Reading
971
- every project's memories is an explicit opt-in (``scope_prefix: ""``).
969
+ Walks up to the nearest ``.git``. A linked worktree's ``.git`` is a file
970
+ (``gitdir: <main>/.git/worktrees/<name>``): its project is the MAIN checkout,
971
+ so a run worktree shares the repo's memories. None outside any checkout.
972
972
  """
973
973
  try:
974
- name = Path.cwd().name or "unknown"
974
+ here = Path.cwd().resolve()
975
+ except OSError:
976
+ return None
977
+ for d in (here, *here.parents):
978
+ git = d / ".git"
979
+ if git.is_dir():
980
+ return d
981
+ if git.is_file():
982
+ try:
983
+ line = git.read_text(encoding="utf-8").strip()
984
+ except OSError:
985
+ return d
986
+ gitdir = line[len("gitdir:"):].strip() if line.startswith("gitdir:") else ""
987
+ marker = f"{os.sep}.git{os.sep}worktrees{os.sep}"
988
+ norm = gitdir.replace("/", os.sep)
989
+ return Path(norm.split(marker)[0]) if marker in norm else d
990
+ return None
991
+
992
+
993
+ def project_scope() -> str:
994
+ """The current project's memory scope: one identity for capture AND recall.
995
+
996
+ ``WICKED_ESTATE_MEMORY_SCOPE`` (the configured project) wins; else
997
+ ``project:<checkout name>`` from :func:`checkout_root` (stable across
998
+ subdirectories and worktrees); else ``project:<cwd-basename>``. Two
999
+ unrelated checkouts with the same name share a scope unless one is
1000
+ configured. Recall sees this scope plus its ancestors (root-scoped
1001
+ memories included); every project is the explicit ``scope_prefix: ""``.
1002
+ """
1003
+ pinned = os.environ.get("WICKED_ESTATE_MEMORY_SCOPE", "").strip()
1004
+ if pinned:
1005
+ return pinned
1006
+ root = checkout_root()
1007
+ try:
1008
+ name = (root.name if root else Path.cwd().name) or "unknown"
975
1009
  except OSError:
976
1010
  name = "unknown"
977
1011
  return f"project:{name}"
@@ -1051,6 +1085,13 @@ def propose(
1051
1085
  run's `WICKED_RUN_PROJECT` supplies): an unscoped proposal is refused here
1052
1086
  with the shape-error code, so it never lands project-less in the queue.
1053
1087
  """
1088
+ if kind_type == "knowledge":
1089
+ # Gate (DES-memory-vs-knowledge K3): estate's approve promotes only memory + policy:*,
1090
+ # so a knowledge proposal would strand in the queue. Lift when estate ships the type.
1091
+ return {"ok": False, "code": -32602,
1092
+ "reason": 'kind_type "knowledge" is not approvable yet (estate promotes only '
1093
+ 'memory and policy:*): keep the object in your report under '
1094
+ '"knowledge (pending)"; do not re-file it as a memory'}
1054
1095
  facets = _scope_facets_to_run_project(facets)
1055
1096
  project = (facets or {}).get("project")
1056
1097
  if not isinstance(project, str) or not project.strip():
@@ -4,14 +4,16 @@
4
4
  wicked-garden run scripts/learn/index.py --repo <checkout> [--name <repo name>] [--slug <slug>] [--dry-run]
5
5
 
6
6
  Reads ``.wicked/knowledge/<slug>/`` from the checkout (run it on the default branch: index what was
7
- MERGED, never an open pull request) and, per topic, REPLACES the estate scope ``learn:<repo>/topic:<slug>``:
8
- ``knowledge.erase`` of that scope, then one ``knowledge.ingest`` whose chunks are the topic's ACTIVE
9
- claims (each chunk ends with its sources and observed dates) and the README's sections. A retired,
10
- superseded or contradicted claim therefore leaves recall at the next index.
7
+ MERGED, never an open pull request) and, per topic, REPLACES its knowledge in estate: one
8
+ ``knowledge.ingest {replace_source: true}`` (source ``repo:<repo>/.wicked/knowledge/<slug>``, scope
9
+ ``learn:<repo>/topic:<slug>``, ``observed_at`` = the topic's refreshed_at, ``source_rev`` = its tree hash)
10
+ whose chunks are the topic's ACTIVE claims (each chunk ends with its sources and observed dates) and the
11
+ README's sections. A retired, superseded or contradicted claim therefore leaves recall at the next
12
+ index; a topic with nothing left is forgotten by its source (``knowledge.forget``).
11
13
 
12
- When the estate binary has no ``knowledge.erase`` yet, the topic is ingested under a per-revision
13
- scope ``learn:<repo>/topic:<slug>/rev:<tree hash>`` instead and the output says ``"replace": "unavailable"``
14
- (an older revision stays recallable until estate can erase it).
14
+ When estate does not advertise ``knowledge.forget`` (before estate K1+K2), the topic is ingested under a
15
+ per-revision scope ``learn:<repo>/topic:<slug>/rev:<tree hash>`` instead and the output says
16
+ ``"replace": "unavailable"`` (an older revision stays recallable until estate can replace it).
15
17
 
16
18
  Never from inside a governed run: a run's estate is read-only, and learned knowledge reaches the
17
19
  store only after its pull request merged. Prints one JSON object ``{ok, repo, topics: [{slug, scope,
@@ -77,40 +79,72 @@ def topic_payload(repo: Path, name: str, d: Path) -> dict:
77
79
  "source": f"repo:{name}/.wicked/knowledge/{t.slug}",
78
80
  "chunks": chunks,
79
81
  "tree_hash": tree_hash(d),
82
+ # when the topic was last researched (topic.json refreshed_at, else created_at)
83
+ "observed_at": (t.meta.get("refreshed_at") or t.meta.get("created_at")) if isinstance(t.meta, dict) else None,
80
84
  "read_errors": t.errors,
81
85
  }
82
86
 
83
87
 
84
- def index_topic(client, p: dict) -> dict:
88
+ def index_topic(client, p: dict, tools: list | None = None) -> dict:
89
+ """Replace one topic in estate. With ``knowledge.forget`` advertised (estate K1+K2), the topic's
90
+ source is replaced in place (``knowledge.ingest {replace_source: true}``, carrying ``observed_at``
91
+ and ``source_rev``), and a topic with nothing left to index is forgotten by its source. Without
92
+ it, the topic is ingested under a per-revision scope and the output says so."""
85
93
  out = {"slug": p["slug"], "scope": p["scope"], "chunks": len(p["chunks"]), "tree_hash": p["tree_hash"]}
86
94
  if p["read_errors"]:
87
95
  out["read_errors"] = p["read_errors"]
88
- if p["read_errors"]:
89
96
  out["ingested"] = False
90
- out["reason"] = "the topic could not be fully read; its indexed scope is left as it was"
97
+ out["reason"] = "the topic could not be fully read; its indexed knowledge is left as it was"
91
98
  return out
92
- erased = client.call("knowledge.erase", {"scope_prefix": p["scope"]}, timeout=30.0)
93
- if erased is None:
94
- out["replace"] = "unavailable"
95
- out["scope"] = f"{p['scope']}/rev:{p['tree_hash']}"
96
- else:
97
- out["replace"] = "erased"
98
- out["erased"] = erased
99
+ replace = "knowledge.forget" in (tools if tools is not None else client.list_tools())
99
100
  if not p["chunks"]:
100
101
  out["ingested"] = False
101
102
  out["reason"] = "no active claims and no README to index"
103
+ if replace:
104
+ forgotten = client.call("knowledge.forget", {"source": p["source"]}, timeout=30.0)
105
+ out["replace"] = "forgotten" if forgotten is not None else "forget-failed"
106
+ out["forgotten"] = forgotten
107
+ out["ok"] = forgotten is not None
102
108
  return out
103
- res = client.call("knowledge.ingest", {"title": p["title"], "chunks": p["chunks"], "scope": out["scope"],
104
- "source": p["source"]}, timeout=60.0)
109
+ args = {"title": p["title"], "chunks": p["chunks"], "source": p["source"]}
110
+ if replace:
111
+ out["replace"] = "replaced"
112
+ args.update(scope=p["scope"], replace_source=True, source_rev=p["tree_hash"])
113
+ if p.get("observed_at"):
114
+ args["observed_at"] = p["observed_at"]
115
+ else:
116
+ out["replace"] = "unavailable"
117
+ out["scope"] = f"{p['scope']}/rev:{p['tree_hash']}"
118
+ args["scope"] = out["scope"]
119
+ res = client.call("knowledge.ingest", args, timeout=60.0)
105
120
  out["ingested"] = res is not None
106
121
  if res is None:
107
122
  out["reason"] = "estate knowledge.ingest failed or estate is unreachable"
108
123
  return out
109
124
 
110
125
 
126
+ def materialize(repo: Path, ref: str, dest: Path) -> None:
127
+ """Copy ``.wicked/knowledge/`` as it is at git ``ref`` (e.g. ``origin/main``: what MERGED) into
128
+ ``dest``, so the index reads the merged tree, never the checkout's working copy."""
129
+ listing = subprocess.run(["git", "-C", str(repo), "ls-tree", "-r", "-z", "--name-only", ref, "--", ".wicked/knowledge"],
130
+ capture_output=True, timeout=30)
131
+ if listing.returncode != 0:
132
+ raise RuntimeError(f"git ls-tree {ref} failed: {listing.stderr.decode('utf-8', 'replace').strip()[:200]}")
133
+ for rel in filter(None, listing.stdout.decode("utf-8", "replace").split("\0")):
134
+ blob = subprocess.run(["git", "-C", str(repo), "show", f"{ref}:{rel}"], capture_output=True, timeout=30)
135
+ if blob.returncode != 0:
136
+ raise RuntimeError(f"git show {ref}:{rel} failed")
137
+ target = dest / rel
138
+ if not target.resolve().is_relative_to(dest.resolve()):
139
+ raise RuntimeError(f"refusing a path outside the index root: {rel}")
140
+ target.parent.mkdir(parents=True, exist_ok=True)
141
+ target.write_bytes(blob.stdout)
142
+
143
+
111
144
  def main(argv: list[str] | None = None) -> int:
112
145
  parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
113
146
  parser.add_argument("--repo", type=Path, default=Path("."))
147
+ parser.add_argument("--ref", help="read the knowledge at this git ref (e.g. origin/main) instead of the working tree")
114
148
  parser.add_argument("--name", help="the repository name in the scope (default: origin's name, else the directory)")
115
149
  parser.add_argument("--slug")
116
150
  parser.add_argument("--dry-run", action="store_true", help="print what would be indexed; touch no store")
@@ -123,13 +157,27 @@ def main(argv: list[str] | None = None) -> int:
123
157
  "learned knowledge is indexed from the merged default branch (DES-learn-workflow §4.2)"}))
124
158
  return 2
125
159
  name = args.name or repo_name(repo)
160
+ if args.ref:
161
+ import tempfile
162
+ with tempfile.TemporaryDirectory(prefix="learn-index-") as tmp:
163
+ try:
164
+ materialize(repo, args.ref, Path(tmp))
165
+ except (RuntimeError, OSError, subprocess.SubprocessError) as e:
166
+ print(json.dumps({"ok": False, "repo": name, "reason": str(e)}))
167
+ return 1
168
+ return _run(client, Path(tmp), name, args)
169
+ return _run(client, repo, name, args)
170
+
171
+
172
+ def _run(client, repo: Path, name: str, args) -> int:
126
173
  payloads = [topic_payload(repo, name, d) for d in topic_dirs(repo, args.slug)]
127
174
  if args.dry_run:
128
175
  print(json.dumps({"ok": True, "repo": name, "dry_run": True,
129
176
  "topics": [{k: v for k, v in p.items() if k != "chunks"} | {"chunks": len(p["chunks"])} for p in payloads]}, indent=2))
130
177
  return 0
131
- results = [index_topic(client, p) for p in payloads]
132
- ok = all(r.get("ingested") or (not r["chunks"] and "read_errors" not in r) for r in results)
178
+ tools = client.list_tools()
179
+ results = [index_topic(client, p, tools) for p in payloads]
180
+ ok = all(r.get("ingested") or (not r["chunks"] and "read_errors" not in r and r.get("ok", True)) for r in results)
133
181
  print(json.dumps({"ok": ok, "repo": name, "topics": results}, indent=2))
134
182
  return 0 if ok else 1
135
183
 
@@ -135,15 +135,11 @@ def _default_intent():
135
135
  (the umbrella, e.g. ``wicked`` for ``wicked/wicked-estate``). Outside a git
136
136
  checkout: {} (unfaceted memories only, as before).
137
137
  """
138
- try:
139
- here = Path.cwd().resolve()
140
- except OSError:
138
+ root = _estate_client.checkout_root()
139
+ if root is None:
141
140
  return {}
142
- for d in (here, *here.parents):
143
- if (d / ".git").exists():
144
- project = (os.environ.get("WICKED_RUN_PROJECT") or "").strip() or d.parent.name
145
- return {k: v for k, v in (("repo", d.name), ("project", project)) if v}
146
- return {}
141
+ project = (os.environ.get("WICKED_RUN_PROJECT") or "").strip() or root.parent.name
142
+ return {k: v for k, v in (("repo", root.name), ("project", project)) if v}
147
143
 
148
144
 
149
145
  def _memory_recall_args(args, query, budget):
@@ -209,11 +205,21 @@ def _parse_budget(args):
209
205
  # narration and status never reach the store even when the judgment slips.
210
206
  MIN_CONTENT_CHARS = 40
211
207
  _HEX_RUN = re.compile(r"\b[0-9a-f]{7,40}\b")
208
+ # A hex run is a commit id when it is long (>= 12) or sits in commit context; a short one
209
+ # alone (a CRC32 polynomial, a colour) is a constant, and constants are durable.
210
+ _COMMIT_CONTEXT = re.compile(
211
+ r"\b(?:commit|sha|rev(?:ision)?|cherry-?pick(?:ed)?|revert(?:ed)?|fixed in|landed in|merged as"
212
+ r"|pin(?:ned)?(?: moved)? (?:to|at))\b|@[0-9a-f]{7}", re.I)
213
+ # A version is narration only next to release status ("released 2.4.1", "shipped 0.7.26"); a
214
+ # version constraint ("keep SQLite >= 3.35.0 because RETURNING") is a durable rule.
215
+ _VERSION = re.compile(r"(?<![\w.])v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?(?![.\d])")
216
+ _RELEASE_STATUS = re.compile(
217
+ r"\b(?:released|shipped|published|tagged|cut|bumped|landed|merged|deployed|rolled out"
218
+ r"|upgraded to|is out|went out|now on)\b", re.I)
212
219
  _NON_DURABLE = (
213
220
  (re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", re.I),
214
221
  "a run/session id (uuid)"),
215
222
  # x.y.z, but not a dotted quad (127.0.0.1) or a longer dotted run.
216
- (re.compile(r"(?<![\w.])v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?(?![.\d])"), "a version number"),
217
223
  # PR #12 / issue #12 / run 38093054271 / core#880 / a bare #880 — not "run 3 retries".
218
224
  (re.compile(r"\b(?:pr|pull request|issue|run|job)\s*(?:#\s*\d+|\d{5,})\b"
219
225
  r"|\b[a-z][\w.-]*#\d+\b|(?<![\w&])#\d+\b", re.I), "a PR/issue/run id"),
@@ -235,7 +241,8 @@ def _is_sha(token):
235
241
  def durability_rejects(content):
236
242
  """Why ``content`` is not a durable learning ([] = it may be stored).
237
243
 
238
- Deterministic and offline: empty or too-short bodies, commit SHAs, versions,
244
+ Deterministic and offline: empty or too-short bodies, commit SHAs (long, or
245
+ in commit context), versions next to release status,
239
246
  PR/issue/run ids, PIDs, "today"-style dates, and first-person narration
240
247
  ("I'll retry", "going to guess again") are records of a moment; the repo,
241
248
  CHANGELOG or tracker already holds those.
@@ -245,8 +252,11 @@ def durability_rejects(content):
245
252
  return [f"too short to state a rule, why it holds and when it applies "
246
253
  f"(< {MIN_CONTENT_CHARS} chars)"]
247
254
  reasons = []
248
- if any(_is_sha(m.group(0)) for m in _HEX_RUN.finditer(text.lower())):
255
+ shas = [m.group(0) for m in _HEX_RUN.finditer(text.lower()) if _is_sha(m.group(0))]
256
+ if any(len(h) >= 12 for h in shas) or (shas and _COMMIT_CONTEXT.search(text)):
249
257
  reasons.append("a commit SHA")
258
+ if _VERSION.search(text) and _RELEASE_STATUS.search(text):
259
+ reasons.append("release status (a version that was shipped/released)")
250
260
  reasons += [why for rx, why in _NON_DURABLE if rx.search(text)]
251
261
  return reasons
252
262
 
@@ -85,7 +85,8 @@ A chat seat asked by wicked-crew to label the operator's decisions reads
85
85
  1. Apply the durability test: *would this help a fresh session, on a
86
86
  different task, three months from now?* Store a rule, why it holds, and
87
87
  when it applies, in 1–3 self-contained sentences. Never store progress or
88
- status, versions, SHAs, PR/run ids, PIDs, "today", or what the repo,
88
+ status, release versions ("shipped 2.4.1"), commit SHAs, PR/run ids, PIDs,
89
+ "today", or what the repo,
89
90
  CHANGELOG or tracker already records. The backend refuses the mechanical
90
91
  cases with `not a durable learning`; treat that as "don't store", not as
91
92
  an error to work around.
@@ -47,11 +47,14 @@ Reject, and do not rephrase into something that sneaks past:
47
47
 
48
48
  - progress and status: what you did, are doing or will do ("I'll retry",
49
49
  "going to try again", "shipped", "merged", "now green");
50
- - versions, commit SHAs, PR / issue / run ids, PIDs, ports picked by hand,
50
+ - release versions ("shipped 2.4.1"), commit SHAs, PR / issue / run ids, PIDs,
51
+ ports picked by hand,
51
52
  and anything dated ("today", "this morning");
52
53
  - anything the repo, its CHANGELOG or the tracker already records.
53
54
 
54
- The backend refuses the mechanical cases (ids, versions, SHAs, PIDs, "today",
55
+ A version *constraint* ("needs SQLite >= 3.35.0 because …") and a fixed constant
56
+ (a CRC polynomial, a colour) are durable; keep them.
57
+ The backend refuses the mechanical cases (ids, release versions, commit SHAs, PIDs, "today",
55
58
  first-person narration, bodies under 40 characters) with
56
59
  `not a durable learning`. A refusal means the candidate failed the test:
57
60
  drop it, unless it holds a real rule you can state without the moment.
@@ -138,9 +138,12 @@ when the proposal is approved, so the learning is lost):
138
138
  - **Knowledge** — how the code works, read from the code (*acquired*, so it is
139
139
  knowledge, not memory: DES-memory-vs-knowledge). `kind_type: "knowledge"`; payload
140
140
  `{"content": "<1–3 sentences>", "class": "concept", "source": "<repo>:<path>",
141
- "source_rev": "<commit sha>"}`. Most repo-learn findings are this kind. Never
142
- propose a graph metric (PageRank rank, churn count, blast-radius size): estate's
143
- tools re-derive those on demand.
141
+ "source_rev": "<commit sha>"}`. Most repo-learn findings are this kind. **Not
142
+ submittable yet:** estate cannot approve a knowledge proposal until its knowledge
143
+ proposal type ships, so the shim refuses `kind_type: "knowledge"`. List these objects
144
+ in your report's fenced `json` block under `knowledge (pending)` and leave them out
145
+ of the `wicked-capture-report` counts. Never propose a graph metric (PageRank rank,
146
+ churn count, blast-radius size): estate's tools re-derive those on demand.
144
147
  - **Memory** — only something *witnessed* while studying (a gotcha you hit, with
145
148
  why) or *told* by the operator. Split a mixed finding: "we chose fail-closed
146
149
  because of incident X" is memory, "the implementation fails closed" is
@@ -71,7 +71,14 @@ estate/steering types — pick the one the rule governs, not a freeform tag.
71
71
  ## Knowledge payload (code facts: the usual case)
72
72
 
73
73
  What you learn by reading code is **acquired**, so it is knowledge, not memory
74
- (DES-memory-vs-knowledge §3). Propose it as:
74
+ (DES-memory-vs-knowledge §3).
75
+
76
+ **Not submittable yet.** Estate's `proposal.approve` promotes only `memory` and
77
+ `policy:*`; a knowledge proposal would sit unapprovable in the queue, so the shim
78
+ refuses `kind_type: "knowledge"` (`-32602`) until estate's knowledge proposal type
79
+ ships. Until then, put each object below in your report's fenced `json` block under
80
+ `knowledge (pending)`, and leave them out of the `wicked-capture-report` counts. Do
81
+ not re-file a code fact as a `memory` to get it through. The shape is:
75
82
 
76
83
  ```json
77
84
  {
@@ -120,7 +127,8 @@ implementation fails closed" is knowledge.
120
127
  `WICKED_RUN_PROJECT` supplies it.
121
128
  - `content` must pass the durability test: a rule, why it holds, and when it
122
129
  applies, useful to a fresh session on a different task months from now. No
123
- versions, SHAs, PR/run ids or progress notes.
130
+ release-status versions ("shipped 2.4.1"), commit SHAs, PR/run ids or progress
131
+ notes. A version *constraint* ("needs SQLite >= 3.35.0 because …") is fine.
124
132
 
125
133
  ## Policy payload + facets
126
134