wicked-garden 12.51.1 → 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.1"}], "version": "12.51.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.52.0"}], "version": "12.52.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.51.1",
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,14 @@
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
+
5
13
  ## [12.51.1] — 2026-10-11
6
14
 
7
15
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.51.1",
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": [
@@ -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