bantamkit 0.27.0__py3-none-any.whl
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.
- bantamkit/__init__.py +32 -0
- bantamkit/agent.py +458 -0
- bantamkit/assets/contracts/default.yaml +90 -0
- bantamkit/assets/evals/devteam/manifest.yaml +351 -0
- bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
- bantamkit/assets/evals/devteam/repo/README.md +12 -0
- bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
- bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
- bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- bantamkit/assets/evals/fixtures/.gitkeep +0 -0
- bantamkit/assets/evals/fixtures/catalog.json +6 -0
- bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
- bantamkit/assets/evals/tasks/.gitkeep +0 -0
- bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
- bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
- bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
- bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
- bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
- bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
- bantamkit/assets/profiles/default.yaml +31 -0
- bantamkit/assets/profiles/patient.yaml +31 -0
- bantamkit/assets/rubrics/.gitkeep +0 -0
- bantamkit/assets/rubrics/code-quality.yaml +20 -0
- bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
- bantamkit/assets/rubrics/task-completion.yaml +28 -0
- bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
- bantamkit/assets/skills/.gitkeep +0 -0
- bantamkit/assets/skills/file-graph.md +7 -0
- bantamkit/assets/skills/memory.md +35 -0
- bantamkit/assets/tools/.gitkeep +0 -0
- bantamkit/assets/tools/bantamkit_read.json +48 -0
- bantamkit/assets/tools/bantamkit_status.json +25 -0
- bantamkit/assets/tools/build_identity.json +17 -0
- bantamkit/assets/tools/document_list.json +12 -0
- bantamkit/assets/tools/document_read.json +31 -0
- bantamkit/assets/tools/file_graph.json +12 -0
- bantamkit/assets/tools/memory_compact.json +31 -0
- bantamkit/assets/tools/memory_recall.json +38 -0
- bantamkit/assets/tools/memory_save.json +61 -0
- bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
- bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
- bantamkit/assets/tools/shiftwork_status.json +25 -0
- bantamkit/assets/tools/skill_audit.json +70 -0
- bantamkit/assets/tools/validate_json.json +31 -0
- bantamkit/assets.py +67 -0
- bantamkit/budget.py +114 -0
- bantamkit/client.py +329 -0
- bantamkit/contract.py +522 -0
- bantamkit/criticreplay.py +3241 -0
- bantamkit/critique.py +301 -0
- bantamkit/docread.py +1744 -0
- bantamkit/evalrun.py +2003 -0
- bantamkit/eventlog.py +282 -0
- bantamkit/filegraph.py +218 -0
- bantamkit/loopguard.py +101 -0
- bantamkit/mcpreport.py +763 -0
- bantamkit/mcpserver.py +1334 -0
- bantamkit/memory/__init__.py +28 -0
- bantamkit/memory/__main__.py +291 -0
- bantamkit/memory/component.py +569 -0
- bantamkit/memory/divergence.py +744 -0
- bantamkit/memory/layers.py +257 -0
- bantamkit/memory/store.py +940 -0
- bantamkit/pdfread.py +1402 -0
- bantamkit/profile.py +46 -0
- bantamkit/shiftwork.py +212 -0
- bantamkit/skillaudit.py +853 -0
- bantamkit/statusline.py +313 -0
- bantamkit/structured.py +125 -0
- bantamkit/textutil.py +30 -0
- bantamkit-0.27.0.dist-info/METADATA +207 -0
- bantamkit-0.27.0.dist-info/RECORD +119 -0
- bantamkit-0.27.0.dist-info/WHEEL +4 -0
- bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
"""Do two memory stores hold the same facts? A read-only instrument, owning no writer.
|
|
2
|
+
|
|
3
|
+
This machine keeps the same knowledge in two stores with two layouts, and neither
|
|
4
|
+
writer knows about the other. `MemoryStore` cannot read across the gap: pointed at
|
|
5
|
+
the native root it finds 0 facts (it globs `facts/*.md` and the native store is
|
|
6
|
+
flat), and a single native-shaped file placed under `facts/` makes `_facts()` raise
|
|
7
|
+
`MemoryValidationError` store-wide on `meta["type"]` — one bad file costs all the
|
|
8
|
+
others. Both measured 2026-08-22. So the comparison needs its own parser, and this
|
|
9
|
+
module is it: additive, read-only, and it does not touch `_facts()` or
|
|
10
|
+
`_write_fact()`.
|
|
11
|
+
|
|
12
|
+
Shapes measured 2026-08-22 over the two live stores (64 bantamkit facts, 59 native
|
|
13
|
+
facts plus `MEMORY.md`):
|
|
14
|
+
|
|
15
|
+
bantamkit native
|
|
16
|
+
root <repo>/.bantamkit/memory ~/.claude/projects/<slug>/memory
|
|
17
|
+
facts facts/*.md flat *.md
|
|
18
|
+
index index.md MEMORY.md
|
|
19
|
+
type top level, 64/64 metadata.type, 57/59
|
|
20
|
+
extras created 17/64, metadata.node_type 11,
|
|
21
|
+
last_recalled 32/64, metadata.originSessionId 11,
|
|
22
|
+
links 32/64 metadata.modified 11, links 21
|
|
23
|
+
|
|
24
|
+
The two native files with a top-level `type` are `feedback-gate-counts-are-co-moving`
|
|
25
|
+
and `feedback-prep-probe-before-planning`. That drift is the input, not a bug to fix
|
|
26
|
+
here, which is why shape is decided per file and never per directory.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import ast
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import yaml
|
|
38
|
+
|
|
39
|
+
from bantamkit.memory.layers import MEMORY_DIR_ENV, resolve_project_store
|
|
40
|
+
from bantamkit.memory.store import MemoryValidationError
|
|
41
|
+
|
|
42
|
+
# Both stores keep a generated roll-up beside the facts. It is derived from them, so
|
|
43
|
+
# comparing it would report the same drift twice, and it carries no frontmatter, so
|
|
44
|
+
# leaving it in would make every clean store report one unparseable file.
|
|
45
|
+
#
|
|
46
|
+
# MS4 measured what filtering it by NAME costs. `MemoryStore`'s fact-name regex
|
|
47
|
+
# `^[a-z0-9][a-z0-9-]*$` accepts `index` and `memory`, so a fact legitimately called
|
|
48
|
+
# either one was dropped from BOTH stores' scans -- `save('index')` writes
|
|
49
|
+
# `facts/index.md`, and `compare_stores()` then saw nothing -- and a store holding two
|
|
50
|
+
# such facts compared CLEAN against an empty store. Zero of the 65 live facts hit it, so
|
|
51
|
+
# it was latent, but the direction is false green, which is the one direction this module
|
|
52
|
+
# exists to prevent.
|
|
53
|
+
#
|
|
54
|
+
# So the names below are only CANDIDATES now, and IDENTITY decides: a candidate is the
|
|
55
|
+
# store's own index only if it does not parse as a fact. See `_is_generated_index`.
|
|
56
|
+
INDEX_CANDIDATE_NAMES = {"index.md", "memory.md"}
|
|
57
|
+
|
|
58
|
+
# Claude Code names a project directory by rewriting its absolute path. Measured
|
|
59
|
+
# against all 26 slugs under ~/.claude/projects on 2026-08-22: every character outside
|
|
60
|
+
# [A-Za-z0-9] becomes `-`, not just the separator. The witness is
|
|
61
|
+
# `.../packnplan-mono/.claude/worktrees/feat-render-deploy-followups`, stored as
|
|
62
|
+
# `...-packnplan-mono--claude-worktrees-feat-render-deploy-followups` — the `.` of
|
|
63
|
+
# `.claude` collapsed exactly like the `/` in front of it. No slug on this machine
|
|
64
|
+
# contains a character outside the class.
|
|
65
|
+
_SLUG_RE = re.compile(r"[^A-Za-z0-9]")
|
|
66
|
+
|
|
67
|
+
# The native root is keyed on the *session's launch cwd*, which no file in the repo
|
|
68
|
+
# records: five of the six bantamkit-ish slugs on this machine came from scratchpad
|
|
69
|
+
# probe cwds, not from the repo. The default below reconstructs it from the canonical
|
|
70
|
+
# checkout, which is right for a session launched in the repo or in one of its
|
|
71
|
+
# worktrees; anything else has to say so, and this is how.
|
|
72
|
+
NATIVE_ROOT_ENV = "BANTAMKIT_NATIVE_MEMORY"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class StoredFact:
|
|
77
|
+
"""One fact as it sits on disk, with the shape it was found in recorded."""
|
|
78
|
+
|
|
79
|
+
name: str
|
|
80
|
+
description: str
|
|
81
|
+
type: str
|
|
82
|
+
body: str
|
|
83
|
+
shape: str # "bantamkit" | "native" — which frontmatter this file carried
|
|
84
|
+
path: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class UnparseableFact:
|
|
89
|
+
"""A file that claimed to be a fact and is not. Reported, never skipped silently."""
|
|
90
|
+
|
|
91
|
+
path: str
|
|
92
|
+
reason: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class BodyDiff:
|
|
97
|
+
"""One name both stores hold, disagreeing about the text under the frontmatter."""
|
|
98
|
+
|
|
99
|
+
name: str
|
|
100
|
+
a_path: str
|
|
101
|
+
b_path: str
|
|
102
|
+
a_bytes: int
|
|
103
|
+
b_bytes: int
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class DescriptionDiff:
|
|
108
|
+
"""One name both stores hold, agreeing about the body and disagreeing about the
|
|
109
|
+
line that gets it recalled.
|
|
110
|
+
|
|
111
|
+
This category exists because MS2 hit it and this instrument was blind to it: four
|
|
112
|
+
facts had byte-identical bodies and different `description:` values, so the two
|
|
113
|
+
indexes described the same knowledge differently while `compare_stores()` reported
|
|
114
|
+
clean. It is kept apart from `BodyDiff` because the remedy is different and much
|
|
115
|
+
cheaper -- the knowledge already agrees, only the recall key does -- and because a
|
|
116
|
+
description-only drift is the one class where reading the two bodies tells you
|
|
117
|
+
nothing.
|
|
118
|
+
|
|
119
|
+
`description` is compared even though the rest of the frontmatter is not, and the
|
|
120
|
+
reason is that it is the only other field BOTH shapes carry at the top level and
|
|
121
|
+
that recall actually matches on. Measured over the real pair on 2026-08-22:
|
|
122
|
+
65 of 65 bantamkit facts and 64 of 64 native facts carry a non-empty top-level
|
|
123
|
+
`description`, so comparing it can never fire merely because one shape omits it.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
name: str
|
|
127
|
+
a_path: str
|
|
128
|
+
b_path: str
|
|
129
|
+
a_description: str
|
|
130
|
+
b_description: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# NOT compared, and looked at rather than overlooked: `type`. It is the other field both
|
|
134
|
+
# shapes carry (top level in one, under `metadata:` in the other -- it is what shape
|
|
135
|
+
# detection keys on), and over the 64 names the real pair shares it drifted 0 times when
|
|
136
|
+
# measured on 2026-08-22. Left out to keep this unit's extension to the single class MS2
|
|
137
|
+
# actually hit; it is a two-line addition for whoever measures a nonzero there. Rerun:
|
|
138
|
+
# PYTHONPATH=runtime-py/src .venv/bin/python -c "from bantamkit.memory.divergence \
|
|
139
|
+
# import read_store, bantamkit_store_root, native_store_root; \
|
|
140
|
+
# A={f.name:f for f in read_store(bantamkit_store_root())[0]}; \
|
|
141
|
+
# B={f.name:f for f in read_store(native_store_root())[0]}; \
|
|
142
|
+
# print([n for n in A.keys()&B.keys() if A[n].type!=B[n].type])"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---- can this store be read at all? ----
|
|
146
|
+
#
|
|
147
|
+
# Three answers, not two, and the third is the whole point. A store that is ABSENT means
|
|
148
|
+
# this machine is not the one the real-pair tripwire is for -- a CI runner has neither
|
|
149
|
+
# root, because `.bantamkit/memory/` is gitignored (0 files tracked) and `~/.claude/` is
|
|
150
|
+
# not checked out at all. A store that is present but UNREADABLE is a finding: something
|
|
151
|
+
# put a file, a dangling symlink, or an unlistable directory where a store belongs, and
|
|
152
|
+
# reporting that as "absent" would let a real breakage buy itself a green skip.
|
|
153
|
+
PRESENT = "present"
|
|
154
|
+
ABSENT = "absent"
|
|
155
|
+
UNREADABLE = "unreadable"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class StoreAvailability:
|
|
160
|
+
"""Whether a store root can be read, and -- when it cannot -- which kind of cannot."""
|
|
161
|
+
|
|
162
|
+
root: str
|
|
163
|
+
state: str # PRESENT | ABSENT | UNREADABLE
|
|
164
|
+
reason: str
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def present(self) -> bool:
|
|
168
|
+
return self.state == PRESENT
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def store_availability(root: str | Path) -> StoreAvailability:
|
|
172
|
+
"""Probe a store root without reading a single fact out of it.
|
|
173
|
+
|
|
174
|
+
Separated from `read_store` on purpose: `read_store` raises on a missing root, and a
|
|
175
|
+
caller that has to tell "not this machine" from "broken on this machine" cannot get
|
|
176
|
+
that out of an exception it must catch to survive.
|
|
177
|
+
"""
|
|
178
|
+
root = Path(root)
|
|
179
|
+
try:
|
|
180
|
+
exists = root.exists()
|
|
181
|
+
except OSError as e: # pragma: no cover - a stat that raises needs a hostile fs
|
|
182
|
+
return StoreAvailability(str(root), UNREADABLE, f"cannot stat {root}: {e}")
|
|
183
|
+
if not exists:
|
|
184
|
+
if os.path.lexists(root):
|
|
185
|
+
return StoreAvailability(
|
|
186
|
+
str(root),
|
|
187
|
+
UNREADABLE,
|
|
188
|
+
f"a symlink at {root} points at nothing -- a store was expected here",
|
|
189
|
+
)
|
|
190
|
+
return StoreAvailability(str(root), ABSENT, f"no directory at {root}")
|
|
191
|
+
if not root.is_dir():
|
|
192
|
+
return StoreAvailability(
|
|
193
|
+
str(root), UNREADABLE, f"{root} exists but is not a directory"
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
next(iter(root.iterdir()), None)
|
|
197
|
+
except OSError as e:
|
|
198
|
+
return StoreAvailability(
|
|
199
|
+
str(root), UNREADABLE, f"{root} is a directory that cannot be listed: {e}"
|
|
200
|
+
)
|
|
201
|
+
return StoreAvailability(str(root), PRESENT, "")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class DivergenceReport:
|
|
206
|
+
"""The five ways two stores can disagree, named separately.
|
|
207
|
+
|
|
208
|
+
They are separate because the remedies differ: copy A->B, copy B->A, resolve the
|
|
209
|
+
body by hand, reconcile the recall key, and go look at the file. Collapsing them
|
|
210
|
+
into one count would hide which.
|
|
211
|
+
|
|
212
|
+
`description_differs` was added by MS3 after MS2 reconciled four of them BY HAND
|
|
213
|
+
that this report called clean. See `DescriptionDiff`.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
a_root: str
|
|
217
|
+
b_root: str
|
|
218
|
+
a_count: int
|
|
219
|
+
b_count: int
|
|
220
|
+
only_in_a: list[str] = field(default_factory=list)
|
|
221
|
+
only_in_b: list[str] = field(default_factory=list)
|
|
222
|
+
body_differs: list[BodyDiff] = field(default_factory=list)
|
|
223
|
+
description_differs: list[DescriptionDiff] = field(default_factory=list)
|
|
224
|
+
unparseable: list[UnparseableFact] = field(default_factory=list)
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def clean(self) -> bool:
|
|
228
|
+
return not (
|
|
229
|
+
self.only_in_a
|
|
230
|
+
or self.only_in_b
|
|
231
|
+
or self.body_differs
|
|
232
|
+
or self.description_differs
|
|
233
|
+
or self.unparseable
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def summary(self) -> str:
|
|
237
|
+
return (
|
|
238
|
+
f"A {self.a_root} ({self.a_count} facts) vs B {self.b_root} "
|
|
239
|
+
f"({self.b_count} facts): only_in_a={len(self.only_in_a)} "
|
|
240
|
+
f"only_in_b={len(self.only_in_b)} body_differs={len(self.body_differs)} "
|
|
241
|
+
f"description_differs={len(self.description_differs)} "
|
|
242
|
+
f"unparseable={len(self.unparseable)} clean={self.clean}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def explain(self) -> str:
|
|
246
|
+
"""Every diverging fact BY NAME, with the path to each side of it.
|
|
247
|
+
|
|
248
|
+
A gate that reports "2 facts differ" has told the reader only that there is an
|
|
249
|
+
investigation to do, and they then do the same search this function already did.
|
|
250
|
+
So the counts are the headline and the names are the body, and every name is
|
|
251
|
+
printed -- there is no elision at N, because the one line that got elided is the
|
|
252
|
+
one nobody goes and looks up.
|
|
253
|
+
"""
|
|
254
|
+
lines = [
|
|
255
|
+
self.summary(),
|
|
256
|
+
f" A = {self.a_root}",
|
|
257
|
+
f" B = {self.b_root}",
|
|
258
|
+
]
|
|
259
|
+
if self.clean:
|
|
260
|
+
lines.append(" no divergence.")
|
|
261
|
+
return "\n".join(lines)
|
|
262
|
+
|
|
263
|
+
if self.only_in_a:
|
|
264
|
+
lines.append(f" only in A ({len(self.only_in_a)}) -- B is owed a copy:")
|
|
265
|
+
lines.extend(f" {name}" for name in self.only_in_a)
|
|
266
|
+
if self.only_in_b:
|
|
267
|
+
lines.append(f" only in B ({len(self.only_in_b)}) -- A is owed a copy:")
|
|
268
|
+
lines.extend(f" {name}" for name in self.only_in_b)
|
|
269
|
+
if self.body_differs:
|
|
270
|
+
lines.append(
|
|
271
|
+
f" body differs ({len(self.body_differs)}) -- resolve by hand, never by "
|
|
272
|
+
f"mtime; recall() rewrites the stamps:"
|
|
273
|
+
)
|
|
274
|
+
for d in self.body_differs:
|
|
275
|
+
lines.append(f" {d.name} ({d.a_bytes} B vs {d.b_bytes} B)")
|
|
276
|
+
lines.append(f" A: {d.a_path}")
|
|
277
|
+
lines.append(f" B: {d.b_path}")
|
|
278
|
+
if self.description_differs:
|
|
279
|
+
lines.append(
|
|
280
|
+
f" description differs ({len(self.description_differs)}) -- same body, "
|
|
281
|
+
f"different recall key:"
|
|
282
|
+
)
|
|
283
|
+
for d in self.description_differs:
|
|
284
|
+
lines.append(f" {d.name}")
|
|
285
|
+
lines.append(f" A: {d.a_description!r}")
|
|
286
|
+
lines.append(f" B: {d.b_description!r}")
|
|
287
|
+
if self.unparseable:
|
|
288
|
+
lines.append(f" unparseable ({len(self.unparseable)}) -- go look at the file:")
|
|
289
|
+
for u in self.unparseable:
|
|
290
|
+
lines.append(f" {u.path}")
|
|
291
|
+
lines.append(f" {u.reason}")
|
|
292
|
+
return "\n".join(lines)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---- the writer's levers on WHERE the bantamkit store is ----
|
|
296
|
+
#
|
|
297
|
+
# THE FAILURE THIS SECTION EXISTS FOR, measured by MS4 on the merged tree and reproduced
|
|
298
|
+
# on this one 2026-08-23: with `BANTAMKIT_MEMORY_DIR` live, the writer saved a fact into
|
|
299
|
+
# the pinned store, `bantamkit_store_root()` returned `<repo>/.bantamkit/memory`, and the
|
|
300
|
+
# real-pair tripwire reported `clean=True`. 1829 passed, fully green, while the store
|
|
301
|
+
# actually in use drifted unwatched. A gate that goes green by reading the wrong file is
|
|
302
|
+
# worse than no gate.
|
|
303
|
+
#
|
|
304
|
+
# It was worse than "this resolver ignores the pin". It ignored the pin WHEN THE ANCHOR
|
|
305
|
+
# EXISTED and honoured it WHEN THE ANCHOR DID NOT -- the old fallback to
|
|
306
|
+
# `discover_project_store()` reads the pin -- so one function held two precedences and
|
|
307
|
+
# which one you got depended on whether `<repo>/.bantamkit/memory` happened to be there.
|
|
308
|
+
# Measured 2026-08-23 on this tree, before the fix:
|
|
309
|
+
# anchor EXISTS -> <repo>/.bantamkit/memory (pin ignored)
|
|
310
|
+
# anchor ABSENT -> <the pin> (pin honoured)
|
|
311
|
+
# relative pin, anchor ABSENT -> MemoryValidationError
|
|
312
|
+
# missing pin, anchor EXISTS -> no raise at all
|
|
313
|
+
#
|
|
314
|
+
# THE FIX IS DELEGATION, NOT IMITATION. `bantamkit_store_root()` now asks the writer's own
|
|
315
|
+
# resolver where it would write and takes its answer whenever that answer came from a pin
|
|
316
|
+
# (`origin == "pin"`). It does not read the environment, does not spell the variable's
|
|
317
|
+
# name, and does not restate the writer's three rulings about a pin (a blank value is not
|
|
318
|
+
# a pin, a relative pin raises, a missing pin raises). A SECOND, DIVERGENT COPY of the
|
|
319
|
+
# writer's precedence would be the same false green in a new costume, and nothing would
|
|
320
|
+
# catch it drifting; there is now exactly one copy and this module imports it.
|
|
321
|
+
#
|
|
322
|
+
# THE REGISTER BELOW IS THE SECOND LINE, for the lever that has not been invented yet.
|
|
323
|
+
# Delegation fixes the pin because the pin is expressed through `StoreBinding.origin`; a
|
|
324
|
+
# FUTURE lever need not be. So the population of `BANTAMKIT_*` names the writer's modules
|
|
325
|
+
# wire to anything is registered here, in the pattern this repo already uses for
|
|
326
|
+
# `_WINDOWS_ONLY_SKIPS`, and two CI-reachable nodes in `tests/test_memory_divergence.py`
|
|
327
|
+
# hold it shut from opposite sides:
|
|
328
|
+
#
|
|
329
|
+
# * every name the writer wires must appear in this register -- so a branch that adds
|
|
330
|
+
# one turns the suite red ON ARRIVAL, in the merge commit, and not on some later day
|
|
331
|
+
# when a store has already drifted unwatched;
|
|
332
|
+
# * every name IN this register must MEASURABLY move `bantamkit_store_root()` -- so the
|
|
333
|
+
# register cannot be silenced by editing it. Adding a name without teaching the
|
|
334
|
+
# resolver swaps one red for another.
|
|
335
|
+
#
|
|
336
|
+
# The one name in it today is imported, never retyped, so the register and the writer
|
|
337
|
+
# cannot come to be talking about different variables.
|
|
338
|
+
ACCOUNTED_WRITER_ENV: frozenset[str] = frozenset({MEMORY_DIR_ENV})
|
|
339
|
+
|
|
340
|
+
_ENV_NAME_RE = re.compile(r"BANTAMKIT_[A-Z0-9_]+")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def writer_source_files() -> list[Path]:
|
|
344
|
+
"""Every module in this package that could be part of the writer's store resolution.
|
|
345
|
+
|
|
346
|
+
Globbed rather than listed, so a module added tomorrow is scanned without anybody
|
|
347
|
+
remembering to add it. This module is excluded: `NATIVE_ROOT_ENV` is the INSTRUMENT's
|
|
348
|
+
own knob, not the writer's, and registering it against itself would prove nothing.
|
|
349
|
+
"""
|
|
350
|
+
here = Path(__file__).resolve()
|
|
351
|
+
return sorted(p for p in here.parent.glob("*.py") if p.name != here.name)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def env_names_in_source(source: str) -> frozenset[str]:
|
|
355
|
+
"""Every `BANTAMKIT_*` env name a module NAMES IN CODE. Docstrings do not count.
|
|
356
|
+
|
|
357
|
+
Parsed, not grepped, and the difference is load-bearing. On the sibling branch
|
|
358
|
+
`layers.py` mentions `BANTAMKIT_MEMORY_DIR` in a comment and in a docstring as well as
|
|
359
|
+
in the one assignment that wires it, and mentions `BANTAMKIT_ASSETS` in prose only --
|
|
360
|
+
a text scan hands the merger two names to chase, one of which is nothing to do with
|
|
361
|
+
the memory store. The AST reports the one name that is actually attached to code.
|
|
362
|
+
"""
|
|
363
|
+
tree = ast.parse(source)
|
|
364
|
+
prose = {
|
|
365
|
+
node.value
|
|
366
|
+
for node in ast.walk(tree)
|
|
367
|
+
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)
|
|
368
|
+
}
|
|
369
|
+
return frozenset(
|
|
370
|
+
node.value
|
|
371
|
+
for node in ast.walk(tree)
|
|
372
|
+
if isinstance(node, ast.Constant)
|
|
373
|
+
and node not in prose
|
|
374
|
+
and isinstance(node.value, str)
|
|
375
|
+
and _ENV_NAME_RE.fullmatch(node.value)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def writer_store_env_names() -> frozenset[str]:
|
|
380
|
+
"""The population: every env name the writer's modules wire to anything, right now."""
|
|
381
|
+
names: set[str] = set()
|
|
382
|
+
for path in writer_source_files():
|
|
383
|
+
names |= env_names_in_source(path.read_text(encoding="utf-8"))
|
|
384
|
+
return frozenset(names)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def unaccounted_writer_env() -> frozenset[str]:
|
|
388
|
+
"""Names the writer honours that this resolver has not been taught. Must stay empty."""
|
|
389
|
+
return writer_store_env_names() - ACCOUNTED_WRITER_ENV
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def resolver_honours_env(name: str, target: str | Path) -> bool:
|
|
393
|
+
"""Does `bantamkit_store_root()` actually bind to `target` when `name` names it?
|
|
394
|
+
|
|
395
|
+
RUN, not read. The register's second node exists because a name can be added to
|
|
396
|
+
`ACCOUNTED_WRITER_ENV` in one line while the resolver still ignores it, and a
|
|
397
|
+
register that can be silenced by editing the register is decoration. Nothing short of
|
|
398
|
+
calling the resolver answers this.
|
|
399
|
+
|
|
400
|
+
The variable is set and restored around the single call, including the case where it
|
|
401
|
+
was not set before. This is the only place in this read-only module that writes
|
|
402
|
+
anything at all, and what it writes is one process-local environment slot.
|
|
403
|
+
"""
|
|
404
|
+
target = Path(target)
|
|
405
|
+
previous = os.environ.get(name)
|
|
406
|
+
os.environ[name] = str(target)
|
|
407
|
+
try:
|
|
408
|
+
return bantamkit_store_root().resolve() == target.resolve()
|
|
409
|
+
finally:
|
|
410
|
+
if previous is None:
|
|
411
|
+
os.environ.pop(name, None)
|
|
412
|
+
else:
|
|
413
|
+
os.environ[name] = previous
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---- roots ----
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _canonical_repo_root(start: str | Path | None = None) -> Path:
|
|
420
|
+
"""The main checkout, even when called from one of its worktrees.
|
|
421
|
+
|
|
422
|
+
A linked worktree's `.git` is a *file* reading `gitdir: <main>/.git/worktrees/<n>`
|
|
423
|
+
— measured in this worktree on 2026-08-22, alongside
|
|
424
|
+
`git rev-parse --git-common-dir == <main>/.git`. Following that pointer in pure
|
|
425
|
+
Python keeps the module free of a `git` subprocess, which also keeps it honest on
|
|
426
|
+
a CI runner that has no git on PATH.
|
|
427
|
+
"""
|
|
428
|
+
base = (Path(start) if start is not None else Path.cwd()).resolve()
|
|
429
|
+
for directory in (base, *base.parents):
|
|
430
|
+
marker = directory / ".git"
|
|
431
|
+
if marker.is_dir():
|
|
432
|
+
return directory
|
|
433
|
+
if marker.is_file():
|
|
434
|
+
text = marker.read_text(encoding="utf-8").strip()
|
|
435
|
+
if not text.startswith("gitdir:"):
|
|
436
|
+
return directory
|
|
437
|
+
gitdir = Path(text.split(":", 1)[1].strip())
|
|
438
|
+
if not gitdir.is_absolute():
|
|
439
|
+
gitdir = (directory / gitdir).resolve()
|
|
440
|
+
for parent in (gitdir, *gitdir.parents):
|
|
441
|
+
if parent.name == ".git":
|
|
442
|
+
return parent.parent
|
|
443
|
+
return directory
|
|
444
|
+
return base
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def bantamkit_store_root(start: str | Path | None = None) -> Path:
|
|
448
|
+
"""`<repo>/.bantamkit/memory`, anchored on the repo — never hardcoded, never guessed.
|
|
449
|
+
|
|
450
|
+
The obvious implementation — defer to `discover_project_store`, which already owns
|
|
451
|
+
an ancestor walk for exactly this — is WRONG here, and running it is how that was
|
|
452
|
+
found. From this worktree on 2026-08-22 it returned `/Users/kktest/.bantamkit/memory`:
|
|
453
|
+
a linked worktree has no `.bantamkit/` of its own, so the walk climbed past the repo
|
|
454
|
+
to an EMPTY store sitting in `$HOME`, and `compare_stores()` cheerfully reported
|
|
455
|
+
`0 facts vs 59, only_in_b=59`. A wrong store that exists is worse than no store,
|
|
456
|
+
because it answers.
|
|
457
|
+
|
|
458
|
+
So the repo's own store wins whenever it exists. The ancestor walk is kept only as
|
|
459
|
+
the fallback for a checkout with no `.git` marker (a tarball, a vendored copy),
|
|
460
|
+
where anchoring has nothing to anchor to.
|
|
461
|
+
|
|
462
|
+
A PIN OUTRANKS BOTH, and the answer to whether one is set comes from the writer
|
|
463
|
+
rather than from this module's own reading of the environment. `origin == "pin"` is
|
|
464
|
+
the writer saying "I did not walk anywhere; the operator named this store", and that
|
|
465
|
+
is the store a comparison has to be about. See the section note above for what the
|
|
466
|
+
two-precedence version of this function measured.
|
|
467
|
+
|
|
468
|
+
RAISES `MemoryValidationError`, propagated from the writer, when a pin is set and
|
|
469
|
+
unusable (relative, missing, not a directory). Deliberately not caught here: this
|
|
470
|
+
function's contract is "the store the writer would use", and when the writer has no
|
|
471
|
+
usable answer, inventing one is how a gate comes to read the wrong file. Callers that
|
|
472
|
+
must survive it use `bantamkit_store_availability()`, which turns the raise into the
|
|
473
|
+
UNREADABLE finding it is.
|
|
474
|
+
"""
|
|
475
|
+
binding = resolve_project_store(start)
|
|
476
|
+
if binding.origin == "pin":
|
|
477
|
+
return binding.path
|
|
478
|
+
anchored = _canonical_repo_root(start) / ".bantamkit" / "memory"
|
|
479
|
+
if anchored.is_dir():
|
|
480
|
+
return anchored
|
|
481
|
+
# Same walk the old fallback ran, already done: `binding.path` for a walk binding is
|
|
482
|
+
# what `discover_project_store()` returns, and "designated" is its way of saying the
|
|
483
|
+
# walk found nothing anywhere up the tree.
|
|
484
|
+
if binding.state != "designated":
|
|
485
|
+
return binding.path
|
|
486
|
+
return anchored
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def bantamkit_store_availability(start: str | Path | None = None) -> StoreAvailability:
|
|
490
|
+
"""`store_availability()` for the bantamkit store, with the pin's raise folded in.
|
|
491
|
+
|
|
492
|
+
A pinned store that is relative, missing, or not a directory makes
|
|
493
|
+
`bantamkit_store_root()` raise, and a raise reaching pytest collection is a crash
|
|
494
|
+
where this package already has a vocabulary for the situation: something is named as
|
|
495
|
+
a store and cannot be read, which is UNREADABLE, which `_precondition()` turns into a
|
|
496
|
+
hard fail rather than a skip. A typo'd pin is a finding, not an absence -- exactly the
|
|
497
|
+
distinction the three-state probe was built to make -- and routing it anywhere else
|
|
498
|
+
would let a broken pin buy itself a green skip.
|
|
499
|
+
"""
|
|
500
|
+
try:
|
|
501
|
+
root = bantamkit_store_root(start)
|
|
502
|
+
except MemoryValidationError as e:
|
|
503
|
+
raw = os.environ.get(MEMORY_DIR_ENV) or ""
|
|
504
|
+
return StoreAvailability(
|
|
505
|
+
raw or "<the writer's store>",
|
|
506
|
+
UNREADABLE,
|
|
507
|
+
f"the store the writer is bound to cannot be resolved: {e}",
|
|
508
|
+
)
|
|
509
|
+
return store_availability(root)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def native_store_root(start: str | Path | None = None) -> Path:
|
|
513
|
+
"""`~/.claude/projects/<slug>/memory`, from the canonical checkout or the env.
|
|
514
|
+
|
|
515
|
+
See NATIVE_ROOT_ENV: the slug encodes the session's launch cwd, which the repo does
|
|
516
|
+
not record, so the reconstruction is a good default and not a derivation.
|
|
517
|
+
"""
|
|
518
|
+
override = os.environ.get(NATIVE_ROOT_ENV)
|
|
519
|
+
if override:
|
|
520
|
+
return Path(override)
|
|
521
|
+
slug = _SLUG_RE.sub("-", str(_canonical_repo_root(start)))
|
|
522
|
+
return Path.home() / ".claude" / "projects" / slug / "memory"
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ---- parsing ----
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def parse_fact(path: str | Path) -> StoredFact:
|
|
529
|
+
"""Read one fact file, deciding its shape from its own frontmatter.
|
|
530
|
+
|
|
531
|
+
Raises `MemoryValidationError` — this package's existing error for "this file is
|
|
532
|
+
not what it claims to be". Callers that must survive one bad file use `read_store`,
|
|
533
|
+
which turns the raise into a reported row.
|
|
534
|
+
"""
|
|
535
|
+
path = Path(path)
|
|
536
|
+
try:
|
|
537
|
+
text = path.read_text(encoding="utf-8")
|
|
538
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
539
|
+
raise MemoryValidationError(f"unreadable fact file {path}: {e}") from e
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
head, front, body = text.split("---\n", 2)
|
|
543
|
+
except ValueError as e:
|
|
544
|
+
raise MemoryValidationError(f"malformed fact file {path}: no frontmatter fences") from e
|
|
545
|
+
if head.strip():
|
|
546
|
+
raise MemoryValidationError(
|
|
547
|
+
f"malformed fact file {path}: frontmatter is not at the top of the file"
|
|
548
|
+
)
|
|
549
|
+
try:
|
|
550
|
+
meta = yaml.safe_load(front)
|
|
551
|
+
except yaml.YAMLError as e:
|
|
552
|
+
raise MemoryValidationError(f"malformed fact file {path}: {e}") from e
|
|
553
|
+
if not isinstance(meta, dict):
|
|
554
|
+
raise MemoryValidationError(f"malformed fact file {path}: frontmatter is not a mapping")
|
|
555
|
+
|
|
556
|
+
name = meta.get("name")
|
|
557
|
+
if not isinstance(name, str) or not name.strip():
|
|
558
|
+
raise MemoryValidationError(f"malformed fact file {path}: no usable 'name'")
|
|
559
|
+
|
|
560
|
+
# Shape is read off the file, not off the directory it was found in. A store that
|
|
561
|
+
# has drifted must still be readable, and two of the 59 native facts already have.
|
|
562
|
+
# A top-level `type` wins if a file somehow carries both, because that is the shape
|
|
563
|
+
# `MemoryStore` would itself honour.
|
|
564
|
+
metadata = meta.get("metadata")
|
|
565
|
+
metadata = metadata if isinstance(metadata, dict) else {}
|
|
566
|
+
if isinstance(meta.get("type"), str):
|
|
567
|
+
shape, type_ = "bantamkit", meta["type"]
|
|
568
|
+
elif isinstance(metadata.get("type"), str):
|
|
569
|
+
shape, type_ = "native", metadata["type"]
|
|
570
|
+
else:
|
|
571
|
+
raise MemoryValidationError(
|
|
572
|
+
f"malformed fact file {path}: no 'type' at the top level and none under "
|
|
573
|
+
f"'metadata:' — this file matches neither store's shape"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
description = meta.get("description")
|
|
577
|
+
return StoredFact(
|
|
578
|
+
name=name.strip(),
|
|
579
|
+
description=description if isinstance(description, str) else "",
|
|
580
|
+
type=type_,
|
|
581
|
+
# `.strip()` mirrors `MemoryStore._facts()`, so a fact round-tripped through
|
|
582
|
+
# that writer compares equal to the one it was copied from.
|
|
583
|
+
body=body.strip(),
|
|
584
|
+
shape=shape,
|
|
585
|
+
path=str(path),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _fact_files(root: Path) -> list[Path]:
|
|
590
|
+
"""Every candidate fact file in a store, in both layouts.
|
|
591
|
+
|
|
592
|
+
`archive/` is excluded and not by accident: it is where `compact()` *moves* a fact
|
|
593
|
+
to, so a fact one store retired and the other never had is not a live divergence,
|
|
594
|
+
and counting it would put every archived fact in `only_in_*`. Scanning only the
|
|
595
|
+
root and `facts/` leaves it out by construction.
|
|
596
|
+
|
|
597
|
+
Flat files come first so that a name held in both layouts keeps the flat copy and
|
|
598
|
+
reports the nested one, rather than the answer depending on glob order.
|
|
599
|
+
|
|
600
|
+
Nothing under `facts/` is filtered, and that is the first half of the `index` fix:
|
|
601
|
+
NEITHER store puts its roll-up there. `MemoryStore` writes `index.md` beside
|
|
602
|
+
`facts/`, and the native store writes `MEMORY.md` beside its flat files, so a
|
|
603
|
+
`facts/index.md` is always a fact and excluding it only ever hid one.
|
|
604
|
+
"""
|
|
605
|
+
flat = sorted(
|
|
606
|
+
p
|
|
607
|
+
for p in root.glob("*.md")
|
|
608
|
+
if p.name.lower() not in INDEX_CANDIDATE_NAMES or not _is_generated_index(p)
|
|
609
|
+
)
|
|
610
|
+
nested = sorted((root / "facts").glob("*.md"))
|
|
611
|
+
return flat + nested
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _is_generated_index(path: Path) -> bool:
|
|
615
|
+
"""Is this the store's own roll-up, or a fact that happens to be called `index`?
|
|
616
|
+
|
|
617
|
+
Decided by reading the file, never by its name. Both live indexes were measured
|
|
618
|
+
2026-08-22: `<repo>/.bantamkit/memory/index.md` opens on a `- [[...]]` list item and
|
|
619
|
+
`~/.claude/projects/<slug>/memory/MEMORY.md` opens on a `# Memory index` heading.
|
|
620
|
+
Neither carries frontmatter, so neither parses as a fact, while a fact named `index`
|
|
621
|
+
does -- its writer put frontmatter there. That is the discriminator, and it needs no
|
|
622
|
+
knowledge of which layout the root is in, which matters because on a case-insensitive
|
|
623
|
+
filesystem a native fact named `memory` and the native index `MEMORY.md` ARE THE SAME
|
|
624
|
+
PATH and only the contents can separate them.
|
|
625
|
+
|
|
626
|
+
The residue, stated instead of hidden: a fact named `index` or `memory` whose file is
|
|
627
|
+
CORRUPT parses as neither, so it is dropped here rather than reported unparseable.
|
|
628
|
+
That is one file, in the one store that holds it, and it is the only case this rule
|
|
629
|
+
gets wrong -- the rule it replaces got every WELL-FORMED one wrong, in both stores.
|
|
630
|
+
"""
|
|
631
|
+
try:
|
|
632
|
+
parse_fact(path)
|
|
633
|
+
except MemoryValidationError:
|
|
634
|
+
return True
|
|
635
|
+
return False
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def read_store(root: str | Path) -> tuple[list[StoredFact], list[UnparseableFact]]:
|
|
639
|
+
"""Every fact in a store, plus every file that looked like one and was not.
|
|
640
|
+
|
|
641
|
+
A store that cannot be *opened* still raises: an empty result and a missing
|
|
642
|
+
directory are opposite facts, and returning `[]` for a typo'd path would let a
|
|
643
|
+
later `clean=True` mean "the stores agree" when it meant "I read neither".
|
|
644
|
+
"""
|
|
645
|
+
root = Path(root)
|
|
646
|
+
if not root.is_dir():
|
|
647
|
+
raise MemoryValidationError(f"memory store directory does not exist: {root}")
|
|
648
|
+
|
|
649
|
+
facts: dict[str, StoredFact] = {}
|
|
650
|
+
unparseable: list[UnparseableFact] = []
|
|
651
|
+
for path in _fact_files(root):
|
|
652
|
+
try:
|
|
653
|
+
fact = parse_fact(path)
|
|
654
|
+
except MemoryValidationError as e:
|
|
655
|
+
# One unreadable file must not cost the other sixty-three.
|
|
656
|
+
unparseable.append(UnparseableFact(path=str(path), reason=str(e)))
|
|
657
|
+
continue
|
|
658
|
+
if fact.name in facts:
|
|
659
|
+
unparseable.append(
|
|
660
|
+
UnparseableFact(
|
|
661
|
+
path=str(path),
|
|
662
|
+
reason=(
|
|
663
|
+
f"duplicate fact name '{fact.name}' inside one store; "
|
|
664
|
+
f"already read from {facts[fact.name].path}"
|
|
665
|
+
),
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
continue
|
|
669
|
+
facts[fact.name] = fact
|
|
670
|
+
return [facts[name] for name in sorted(facts)], unparseable
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ---- comparison ----
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def compare_stores(
|
|
677
|
+
a: str | Path | None = None,
|
|
678
|
+
b: str | Path | None = None,
|
|
679
|
+
*,
|
|
680
|
+
start: str | Path | None = None,
|
|
681
|
+
) -> DivergenceReport:
|
|
682
|
+
"""Compare two stores by fact NAME, then by BODY. With no arguments, the real two.
|
|
683
|
+
|
|
684
|
+
Deliberately *not* compared: `last_recalled`, `created`, and the file mtime.
|
|
685
|
+
`recall()` stamps `last_recalled` on every hit and `_stamp` writes the file to do
|
|
686
|
+
it, so all three record who READ a fact, never who wrote it — the store an agent
|
|
687
|
+
happens to recall from would otherwise look uniformly "newer" than the one it does
|
|
688
|
+
not. Which body wins is a hand judgement; this instrument only says where one is
|
|
689
|
+
owed.
|
|
690
|
+
|
|
691
|
+
Frontmatter beyond the name and the description is also not compared: the two shapes
|
|
692
|
+
disagree about the rest by construction (`metadata.originSessionId` has no bantamkit
|
|
693
|
+
counterpart), so a field-by-field diff would report dozens of divergences that mean
|
|
694
|
+
nothing. `description` is the exception because both shapes carry it at the top level
|
|
695
|
+
in 129 of 129 real facts and because it is what recall matches on -- see
|
|
696
|
+
`DescriptionDiff` for the four this instrument used to miss.
|
|
697
|
+
"""
|
|
698
|
+
a_root = Path(a) if a is not None else bantamkit_store_root(start)
|
|
699
|
+
b_root = Path(b) if b is not None else native_store_root(start)
|
|
700
|
+
|
|
701
|
+
a_facts, a_bad = read_store(a_root)
|
|
702
|
+
b_facts, b_bad = read_store(b_root)
|
|
703
|
+
a_by_name = {f.name: f for f in a_facts}
|
|
704
|
+
b_by_name = {f.name: f for f in b_facts}
|
|
705
|
+
|
|
706
|
+
body_differs = []
|
|
707
|
+
description_differs = []
|
|
708
|
+
for name in sorted(a_by_name.keys() & b_by_name.keys()):
|
|
709
|
+
left, right = a_by_name[name], b_by_name[name]
|
|
710
|
+
if left.body != right.body:
|
|
711
|
+
body_differs.append(
|
|
712
|
+
BodyDiff(
|
|
713
|
+
name=name,
|
|
714
|
+
a_path=left.path,
|
|
715
|
+
b_path=right.path,
|
|
716
|
+
a_bytes=len(left.body.encode("utf-8")),
|
|
717
|
+
b_bytes=len(right.body.encode("utf-8")),
|
|
718
|
+
)
|
|
719
|
+
)
|
|
720
|
+
# `.strip()` because one writer hard-wraps the YAML scalar and the other does
|
|
721
|
+
# not, so the two carry the same sentence with different trailing whitespace.
|
|
722
|
+
# A drift that is only whitespace is not a drift a human is owed a look at.
|
|
723
|
+
if left.description.strip() != right.description.strip():
|
|
724
|
+
description_differs.append(
|
|
725
|
+
DescriptionDiff(
|
|
726
|
+
name=name,
|
|
727
|
+
a_path=left.path,
|
|
728
|
+
b_path=right.path,
|
|
729
|
+
a_description=left.description.strip(),
|
|
730
|
+
b_description=right.description.strip(),
|
|
731
|
+
)
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
return DivergenceReport(
|
|
735
|
+
a_root=str(a_root),
|
|
736
|
+
b_root=str(b_root),
|
|
737
|
+
a_count=len(a_facts),
|
|
738
|
+
b_count=len(b_facts),
|
|
739
|
+
only_in_a=sorted(a_by_name.keys() - b_by_name.keys()),
|
|
740
|
+
only_in_b=sorted(b_by_name.keys() - a_by_name.keys()),
|
|
741
|
+
body_differs=body_differs,
|
|
742
|
+
description_differs=description_differs,
|
|
743
|
+
unparseable=a_bad + b_bad,
|
|
744
|
+
)
|