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.
Files changed (119) hide show
  1. bantamkit/__init__.py +32 -0
  2. bantamkit/agent.py +458 -0
  3. bantamkit/assets/contracts/default.yaml +90 -0
  4. bantamkit/assets/evals/devteam/manifest.yaml +351 -0
  5. bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. bantamkit/assets/evals/devteam/repo/README.md +12 -0
  7. bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. bantamkit/assets/evals/fixtures/.gitkeep +0 -0
  40. bantamkit/assets/evals/fixtures/catalog.json +6 -0
  41. bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
  42. bantamkit/assets/evals/tasks/.gitkeep +0 -0
  43. bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
  44. bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
  46. bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
  48. bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
  59. bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
  62. bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
  65. bantamkit/assets/profiles/default.yaml +31 -0
  66. bantamkit/assets/profiles/patient.yaml +31 -0
  67. bantamkit/assets/rubrics/.gitkeep +0 -0
  68. bantamkit/assets/rubrics/code-quality.yaml +20 -0
  69. bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
  70. bantamkit/assets/rubrics/task-completion.yaml +28 -0
  71. bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. bantamkit/assets/skills/.gitkeep +0 -0
  73. bantamkit/assets/skills/file-graph.md +7 -0
  74. bantamkit/assets/skills/memory.md +35 -0
  75. bantamkit/assets/tools/.gitkeep +0 -0
  76. bantamkit/assets/tools/bantamkit_read.json +48 -0
  77. bantamkit/assets/tools/bantamkit_status.json +25 -0
  78. bantamkit/assets/tools/build_identity.json +17 -0
  79. bantamkit/assets/tools/document_list.json +12 -0
  80. bantamkit/assets/tools/document_read.json +31 -0
  81. bantamkit/assets/tools/file_graph.json +12 -0
  82. bantamkit/assets/tools/memory_compact.json +31 -0
  83. bantamkit/assets/tools/memory_recall.json +38 -0
  84. bantamkit/assets/tools/memory_save.json +61 -0
  85. bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
  86. bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
  87. bantamkit/assets/tools/shiftwork_status.json +25 -0
  88. bantamkit/assets/tools/skill_audit.json +70 -0
  89. bantamkit/assets/tools/validate_json.json +31 -0
  90. bantamkit/assets.py +67 -0
  91. bantamkit/budget.py +114 -0
  92. bantamkit/client.py +329 -0
  93. bantamkit/contract.py +522 -0
  94. bantamkit/criticreplay.py +3241 -0
  95. bantamkit/critique.py +301 -0
  96. bantamkit/docread.py +1744 -0
  97. bantamkit/evalrun.py +2003 -0
  98. bantamkit/eventlog.py +282 -0
  99. bantamkit/filegraph.py +218 -0
  100. bantamkit/loopguard.py +101 -0
  101. bantamkit/mcpreport.py +763 -0
  102. bantamkit/mcpserver.py +1334 -0
  103. bantamkit/memory/__init__.py +28 -0
  104. bantamkit/memory/__main__.py +291 -0
  105. bantamkit/memory/component.py +569 -0
  106. bantamkit/memory/divergence.py +744 -0
  107. bantamkit/memory/layers.py +257 -0
  108. bantamkit/memory/store.py +940 -0
  109. bantamkit/pdfread.py +1402 -0
  110. bantamkit/profile.py +46 -0
  111. bantamkit/shiftwork.py +212 -0
  112. bantamkit/skillaudit.py +853 -0
  113. bantamkit/statusline.py +313 -0
  114. bantamkit/structured.py +125 -0
  115. bantamkit/textutil.py +30 -0
  116. bantamkit-0.27.0.dist-info/METADATA +207 -0
  117. bantamkit-0.27.0.dist-info/RECORD +119 -0
  118. bantamkit-0.27.0.dist-info/WHEEL +4 -0
  119. 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
+ )