wicked-garden 12.51.1 → 12.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/scripts/_estate_client.py +25 -8
- package/scripts/editor/conformance.py +23 -0
- package/scripts/editor/install.py +15 -0
- package/scripts/learn/index.py +69 -21
- package/scripts/mem/estate_memory.py +54 -52
- package/skills/mem/SKILL.md +5 -3
- package/skills/mem-capture/SKILL.md +8 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, multi-model second opinions (fallback seats labelled). Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.
|
|
1
|
+
{"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, multi-model second opinions (fallback seats labelled). Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.52.1"}], "version": "12.52.1"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-garden",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.52.1",
|
|
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,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [12.52.1] — 2026-10-11
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- **The memory durability gate refuses only the unambiguous (w9-memfix3, PR #1283).** `store` / `capture-batch` refuse a body under 40 characters, a bare id / SHA / version, and first-person progress narration. Ids, versions, SHAs, PIDs and date words inside a body are returned as `warnings` and the store succeeds. "Keep SQLite >= 3.35.0 because RETURNING …" and "CRC32 polynomial edb88320 … database commit …" are stored. The three-month test in the mem skills is the agent's judgment.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Relative-path worktrees resolve to their main checkout's project scope** (`gitdir: ../../.git/worktrees/run` is resolved against the `.git` file, and `commondir` is followed), not `project:..`.
|
|
12
|
+
|
|
13
|
+
## [12.52.0] — 2026-10-11
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **Learn indexing reads what merged and replaces a topic by its source (PR #1280).**
|
|
17
|
+
- `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>`.
|
|
18
|
+
- 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.
|
|
19
|
+
- An older estate keeps the per-revision scope fallback.
|
|
20
|
+
|
|
5
21
|
## [12.51.1] — 2026-10-11
|
|
6
22
|
|
|
7
23
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-garden",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.52.1",
|
|
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": [
|
|
@@ -963,6 +963,30 @@ def context(
|
|
|
963
963
|
return call("ContextBundle", args, timeout=timeout)
|
|
964
964
|
|
|
965
965
|
|
|
966
|
+
def _worktree_main(git_file: Path) -> Optional[Path]:
|
|
967
|
+
"""Main checkout of a linked worktree whose ``.git`` file is ``git_file``.
|
|
968
|
+
|
|
969
|
+
``gitdir:`` may be relative to the ``.git`` file's directory
|
|
970
|
+
(``git worktree add --relative-paths``); the worktree's ``commondir``
|
|
971
|
+
names the shared git dir (relative to the gitdir). The main checkout is
|
|
972
|
+
that git dir's parent. None when the file is not a worktree pointer.
|
|
973
|
+
"""
|
|
974
|
+
try:
|
|
975
|
+
line = git_file.read_text(encoding="utf-8").strip()
|
|
976
|
+
except OSError:
|
|
977
|
+
return None
|
|
978
|
+
if not line.startswith("gitdir:"):
|
|
979
|
+
return None
|
|
980
|
+
gitdir = (git_file.parent / line[len("gitdir:"):].strip()).resolve()
|
|
981
|
+
try:
|
|
982
|
+
common = (gitdir / (gitdir / "commondir").read_text(encoding="utf-8").strip()).resolve()
|
|
983
|
+
except OSError:
|
|
984
|
+
common = gitdir.parent.parent if gitdir.parent.name == "worktrees" else None
|
|
985
|
+
if common is None or common.name != ".git":
|
|
986
|
+
return None
|
|
987
|
+
return common.parent
|
|
988
|
+
|
|
989
|
+
|
|
966
990
|
def checkout_root() -> Optional[Path]:
|
|
967
991
|
"""The git checkout the cwd is in, resolved through linked worktrees.
|
|
968
992
|
|
|
@@ -979,14 +1003,7 @@ def checkout_root() -> Optional[Path]:
|
|
|
979
1003
|
if git.is_dir():
|
|
980
1004
|
return d
|
|
981
1005
|
if git.is_file():
|
|
982
|
-
|
|
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
|
|
1006
|
+
return _worktree_main(git) or d
|
|
990
1007
|
return None
|
|
991
1008
|
|
|
992
1009
|
|
|
@@ -322,6 +322,22 @@ def _torn(page, wait: bool = False):
|
|
|
322
322
|
return None
|
|
323
323
|
|
|
324
324
|
|
|
325
|
+
def python_with_playwright() -> str | None:
|
|
326
|
+
"""The first python3 / python on PATH that can import playwright. Each is probed, never compared by
|
|
327
|
+
path: a venv's python links to the same base binary yet sees other site-packages (codex)."""
|
|
328
|
+
import shutil, subprocess # noqa: E401, PLC0415
|
|
329
|
+
for name in ("python3", "python"):
|
|
330
|
+
exe = shutil.which(name)
|
|
331
|
+
if exe is None:
|
|
332
|
+
continue
|
|
333
|
+
try:
|
|
334
|
+
if subprocess.run([exe, "-c", "import playwright.sync_api"], capture_output=True, timeout=30).returncode == 0:
|
|
335
|
+
return exe
|
|
336
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
337
|
+
continue
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
325
341
|
def manifest_case(pack: Path) -> dict:
|
|
326
342
|
"""Case 0: garden's own `pack check` (the same gate wicked-crew runs before an install)."""
|
|
327
343
|
try:
|
|
@@ -369,6 +385,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
369
385
|
try:
|
|
370
386
|
from playwright.sync_api import sync_playwright # noqa: PLC0415
|
|
371
387
|
except ImportError:
|
|
388
|
+
# The launcher's snapshot venv carries no browser driver (E2E finding F-EDIT-2): run once more
|
|
389
|
+
# under a python on PATH that has one, with the same arguments.
|
|
390
|
+
other = python_with_playwright() if not os.environ.get("WG_EDITOR_CONFORMANCE_REEXEC") else None
|
|
391
|
+
if other is not None:
|
|
392
|
+
import subprocess # noqa: PLC0415
|
|
393
|
+
env = {**os.environ, "WG_EDITOR_CONFORMANCE_REEXEC": "1"}
|
|
394
|
+
return subprocess.call([other, os.path.abspath(__file__), *(argv if argv is not None else sys.argv[1:])], env=env)
|
|
372
395
|
return out({"ok": False, "pack": str(args.pack), "error": "no headless browser: the Python playwright package is "
|
|
373
396
|
"not installed (pip install playwright && playwright install chromium); refusing to report conformance "
|
|
374
397
|
"without the run"}, 3)
|
|
@@ -278,6 +278,17 @@ def do_uninstall(args) -> tuple[int, dict]:
|
|
|
278
278
|
return 0, out
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
def consent_refusal(args, env) -> str | None:
|
|
282
|
+
"""Inside a governed run (``WICKED_RUN_ID`` set) a live install happens only in the consent-gated
|
|
283
|
+
Tool unit, the one unit the engine hands ``WICKED_CONSENT_CHOICE`` (the operator's approved
|
|
284
|
+
choice). Any other unit (a creator in build, a reviewer) may plan with ``--dry-run`` but never
|
|
285
|
+
installs or uninstalls (E2E finding F-EDIT-1: a creator installed before the gate asked)."""
|
|
286
|
+
if not env.get("WICKED_RUN_ID") or env.get("WICKED_CONSENT_CHOICE"):
|
|
287
|
+
return None
|
|
288
|
+
return ("refusing to install inside a governed run outside its consent-gated install step: the operator "
|
|
289
|
+
"approves the install at that gate. Plan it with --dry-run; the install step runs it.")
|
|
290
|
+
|
|
291
|
+
|
|
281
292
|
def main(argv: list[str] | None = None) -> int:
|
|
282
293
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
283
294
|
where = parser.add_mutually_exclusive_group()
|
|
@@ -301,6 +312,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
301
312
|
except Failure as err:
|
|
302
313
|
sys.stderr.write(f"editor install --dry-run: {err}\n")
|
|
303
314
|
return err.code
|
|
315
|
+
refusal = consent_refusal(args, os.environ)
|
|
316
|
+
if refusal is not None:
|
|
317
|
+
print(json.dumps({"ok": False, "error": refusal}))
|
|
318
|
+
return 2
|
|
304
319
|
try:
|
|
305
320
|
code, out = do_uninstall(args) if args.uninstall else do_install(args)
|
|
306
321
|
except Failure as err:
|
package/scripts/learn/index.py
CHANGED
|
@@ -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
|
|
8
|
-
``knowledge.
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
13
|
-
scope ``learn:<repo>/topic:<slug>/rev:<tree hash>`` instead and the output says
|
|
14
|
-
(an older revision stays recallable until estate can
|
|
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
|
|
97
|
+
out["reason"] = "the topic could not be fully read; its indexed knowledge is left as it was"
|
|
91
98
|
return out
|
|
92
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
|
@@ -36,9 +36,10 @@ projects' memories need the explicit opt-in ``scope_prefix: ""``.
|
|
|
36
36
|
|
|
37
37
|
Durability gate
|
|
38
38
|
---------------
|
|
39
|
-
`store` / `capture-batch` refuse
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
`store` / `capture-batch` refuse only the unambiguous (``durability_rejects``):
|
|
40
|
+
an empty or under-40-char body, a bare id / SHA / version, or first-person
|
|
41
|
+
progress narration. Ids, versions, SHAs, PIDs and dates inside a body are
|
|
42
|
+
advisory ``warnings`` (``durability_warnings``); the store still succeeds.
|
|
42
43
|
|
|
43
44
|
Erase guard
|
|
44
45
|
-----------
|
|
@@ -200,65 +201,57 @@ def _parse_budget(args):
|
|
|
200
201
|
|
|
201
202
|
# ── durability gate ─────────────────────────────────────────────────────────
|
|
202
203
|
# A memory must help a fresh session on a different task months from now: it
|
|
203
|
-
# states a rule, why it holds, and when it applies.
|
|
204
|
-
# agent
|
|
205
|
-
#
|
|
204
|
+
# states a rule, why it holds, and when it applies. That judgment is the
|
|
205
|
+
# agent's (the skill text asks it). The backend refuses only what is
|
|
206
|
+
# unambiguous, and WARNS on the rest: an id, version or SHA inside a real rule
|
|
207
|
+
# ("keep SQLite >= 3.35.0 because RETURNING", "CRC32 polynomial edb88320") is
|
|
208
|
+
# fine, so those heuristics never block a store.
|
|
206
209
|
MIN_CONTENT_CHARS = 40
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
r"
|
|
212
|
-
r"|
|
|
213
|
-
#
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
r"
|
|
219
|
-
|
|
210
|
+
# First-person progress narration: "I'll retry", "I'm going to guess again", "We shipped …".
|
|
211
|
+
_NARRATION = re.compile(
|
|
212
|
+
r"\b(?:i['\u2019]ll|i will|we['\u2019]ll|let me"
|
|
213
|
+
r"|(?:i['\u2019]m|i am|we['\u2019]re|we are) (?:going|about) to)\b"
|
|
214
|
+
r"|^(?:i|we)(?: just| finally)? (?:shipped|released|merged|pushed|deployed|landed|tried|retried"
|
|
215
|
+
r"|restarted|bumped|tagged)\b", re.I)
|
|
216
|
+
# One identifier on its own: a SHA, a version, a uuid, a #123 / repo#123.
|
|
217
|
+
_ID_TOKEN = (r"(?:[0-9a-f]{7,40}|v?\d+\.\d+\.\d+(?:[-+][0-9a-z.]+)?"
|
|
218
|
+
r"|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[a-z][\w.-]*#\d+|#\d+)")
|
|
219
|
+
_BARE_ID = re.compile(r"^[\W_]*" + _ID_TOKEN + r"[\W_]*$", re.I)
|
|
220
|
+
_ADVISORIES = (
|
|
221
|
+
(re.compile(r"\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b", re.I), "a commit SHA?"),
|
|
222
|
+
(re.compile(r"(?<![\w.])v?\d+\.\d+\.\d+(?![.\d])"), "a version?"),
|
|
220
223
|
(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),
|
|
221
|
-
"a run/session id
|
|
222
|
-
# x.y.z, but not a dotted quad (127.0.0.1) or a longer dotted run.
|
|
223
|
-
# PR #12 / issue #12 / run 38093054271 / core#880 / a bare #880 — not "run 3 retries".
|
|
224
|
+
"a run/session id?"),
|
|
224
225
|
(re.compile(r"\b(?:pr|pull request|issue|run|job)\s*(?:#\s*\d+|\d{5,})\b"
|
|
225
|
-
r"|\b[a-z][\w.-]*#\d+\b|(?<![\w&])#\d+\b", re.I), "a PR/issue/run id"),
|
|
226
|
-
|
|
227
|
-
(re.compile(r"\bpids?\s*[:=#]?\s*\d{3,}\b", re.I), "a process id"),
|
|
226
|
+
r"|\b[a-z][\w.-]*#\d+\b|(?<![\w&])#\d+\b", re.I), "a PR/issue/run id?"),
|
|
227
|
+
(re.compile(r"\bpids?\s*[:=#]?\s*\d{3,}\b", re.I), "a process id?"),
|
|
228
228
|
(re.compile(r"\b(?:today|yesterday|tonight|this (?:morning|afternoon|evening|week))\b", re.I),
|
|
229
|
-
"a date-relative word"),
|
|
230
|
-
# I'll / we'll / I'm going to — the apostrophe is required ("ill-formed", "works well").
|
|
231
|
-
(re.compile(r"\b(?:i['\u2019]ll|i will|we['\u2019]ll|let me"
|
|
232
|
-
r"|(?:i['\u2019]m|i am|we['\u2019]re|we are) (?:going|about) to)\b", re.I),
|
|
233
|
-
"first-person progress narration"),
|
|
229
|
+
"a date-relative word?"),
|
|
234
230
|
)
|
|
235
231
|
|
|
236
232
|
|
|
237
|
-
def _is_sha(token):
|
|
238
|
-
return any(c.isdigit() for c in token) and any(c.isalpha() for c in token)
|
|
239
|
-
|
|
240
|
-
|
|
241
233
|
def durability_rejects(content):
|
|
242
|
-
"""
|
|
243
|
-
|
|
244
|
-
Deterministic and offline: empty or too-short bodies, commit SHAs (long, or
|
|
245
|
-
in commit context), versions next to release status,
|
|
246
|
-
PR/issue/run ids, PIDs, "today"-style dates, and first-person narration
|
|
247
|
-
("I'll retry", "going to guess again") are records of a moment; the repo,
|
|
248
|
-
CHANGELOG or tracker already holds those.
|
|
234
|
+
"""Hard refusals ([] = it may be stored): an empty or under-40-char body, a
|
|
235
|
+
body that is only an id / SHA / version, or first-person progress narration.
|
|
249
236
|
"""
|
|
250
237
|
text = (content or "").strip()
|
|
251
238
|
if len(text) < MIN_CONTENT_CHARS:
|
|
252
239
|
return [f"too short to state a rule, why it holds and when it applies "
|
|
253
240
|
f"(< {MIN_CONTENT_CHARS} chars)"]
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
241
|
+
if _BARE_ID.match(text):
|
|
242
|
+
return ["only an identifier (a SHA, version or id), not a learning"]
|
|
243
|
+
if _NARRATION.search(text):
|
|
244
|
+
return ["first-person progress narration (what was done or will be done)"]
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def durability_warnings(content):
|
|
249
|
+
"""Advisory only: ids, versions, SHAs, PIDs or dates that MAY mark a moment
|
|
250
|
+
rather than a rule. The store still succeeds; the agent re-checks the
|
|
251
|
+
three-month test and keeps the memory if it holds.
|
|
252
|
+
"""
|
|
253
|
+
text = (content or "").strip()
|
|
254
|
+
return [why for rx, why in _ADVISORIES if rx.search(text)]
|
|
262
255
|
|
|
263
256
|
|
|
264
257
|
def _capture_one(item):
|
|
@@ -274,7 +267,7 @@ def _capture_one(item):
|
|
|
274
267
|
rejects = durability_rejects(content)
|
|
275
268
|
if rejects:
|
|
276
269
|
return None, ("not a durable learning (" + "; ".join(rejects) + "): state the rule, "
|
|
277
|
-
"why it holds and when it applies
|
|
270
|
+
"why it holds and when it applies")
|
|
278
271
|
kind = item.get("kind") or "fact"
|
|
279
272
|
if kind not in KINDS:
|
|
280
273
|
return None, f"unknown kind: {kind} (valid: {', '.join(KINDS)})"
|
|
@@ -300,7 +293,11 @@ def do_store(args):
|
|
|
300
293
|
memory_id, err = _capture_one(args)
|
|
301
294
|
if err:
|
|
302
295
|
return _fail_open(err)
|
|
303
|
-
|
|
296
|
+
out = {"ok": True, "memory_id": memory_id, "scope": args.get("scope") or _default_scope()}
|
|
297
|
+
warnings = durability_warnings(args.get("content"))
|
|
298
|
+
if warnings:
|
|
299
|
+
out["warnings"] = warnings
|
|
300
|
+
_emit(out)
|
|
304
301
|
return 0
|
|
305
302
|
|
|
306
303
|
|
|
@@ -402,12 +399,17 @@ def do_capture_batch(args):
|
|
|
402
399
|
if err:
|
|
403
400
|
failures.append({"index": i, "reason": err})
|
|
404
401
|
else:
|
|
405
|
-
|
|
402
|
+
entry = {"index": i, "memory_id": memory_id}
|
|
403
|
+
warnings = durability_warnings(item.get("content"))
|
|
404
|
+
if warnings:
|
|
405
|
+
entry["warnings"] = warnings
|
|
406
|
+
stored.append(entry)
|
|
406
407
|
_emit({
|
|
407
408
|
"ok": not failures or bool(stored),
|
|
408
409
|
"stored": len(stored),
|
|
409
410
|
"failed": len(failures),
|
|
410
411
|
"ids": [s["memory_id"] for s in stored],
|
|
412
|
+
"warnings": [s for s in stored if s.get("warnings")],
|
|
411
413
|
"failures": failures,
|
|
412
414
|
})
|
|
413
415
|
return 0
|
package/skills/mem/SKILL.md
CHANGED
|
@@ -87,9 +87,11 @@ A chat seat asked by wicked-crew to label the operator's decisions reads
|
|
|
87
87
|
when it applies, in 1–3 self-contained sentences. Never store progress or
|
|
88
88
|
status, release versions ("shipped 2.4.1"), commit SHAs, PR/run ids, PIDs,
|
|
89
89
|
"today", or what the repo,
|
|
90
|
-
CHANGELOG or tracker already records.
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
CHANGELOG or tracker already records. That test is your judgment. The
|
|
91
|
+
backend refuses only a body under 40 characters, a bare id / SHA / version
|
|
92
|
+
and first-person narration (`not a durable learning`: don't store, don't
|
|
93
|
+
work around it); it stores bodies containing ids, versions or SHAs and
|
|
94
|
+
returns `warnings` naming them, so check those before you store.
|
|
93
95
|
Provenance: memory is what we witnessed, were told, or decided. A claim
|
|
94
96
|
read from code, docs or the web is knowledge: use `write`
|
|
95
97
|
(`{"content","class":"concept","source"}`) or `ingest` instead; `store`
|
|
@@ -54,10 +54,14 @@ Reject, and do not rephrase into something that sneaks past:
|
|
|
54
54
|
|
|
55
55
|
A version *constraint* ("needs SQLite >= 3.35.0 because …") and a fixed constant
|
|
56
56
|
(a CRC polynomial, a colour) are durable; keep them.
|
|
57
|
-
The
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
drop
|
|
57
|
+
The test above is YOUR judgment; the backend only backs it up. It refuses
|
|
58
|
+
(`not a durable learning`) a body under 40 characters, a bare id / SHA /
|
|
59
|
+
version, and first-person progress narration ("I'll retry", "We shipped …"):
|
|
60
|
+
drop those. Ids, versions, SHAs, PIDs or dates inside a body are allowed (a
|
|
61
|
+
version constraint or a fixed constant is a real rule); the backend stores
|
|
62
|
+
the memory and returns `warnings` naming them. Check for them yourself
|
|
63
|
+
before the write, since the warning arrives after it, and report any
|
|
64
|
+
warning with the memory it was attached to.
|
|
61
65
|
If nothing passes, store nothing and say so. An empty capture is a valid
|
|
62
66
|
result, and the usual one.
|
|
63
67
|
|