dos-kernel 0.22.0__py3-none-win_amd64.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 (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. dos_mcp/server.py +779 -0
@@ -0,0 +1,1231 @@
1
+ """dos.drivers.memory_recall — the recall-honesty driver (docs/103).
2
+
3
+ > *The kernel is the part that doesn't believe the agents. Memory is the agent
4
+ > we forgot to stop believing.*
5
+
6
+ An agent's persistent file-based memory is a fleet of self-narrating workers
7
+ writing shared state, read back later without anyone checking whether what they
8
+ wrote is still true (docs/103). A memory file says "FIXED in cli.py:1000"; the
9
+ code moved two commits ago; the memory didn't; and at recall the claim is
10
+ injected into context wearing the authority of a fact. That is the founding DOS
11
+ problem, pointed inward — so the fix is not a new principle, it is the existing
12
+ syscalls re-aimed at the memory store, by a CONSUMER that lives outside the
13
+ kernel (the same one-way arrow as `dos_mcp` and `scripts/`: this module
14
+ `import dos`, nothing under `src/dos/*.py` imports it).
15
+
16
+ What it does
17
+ ============
18
+
19
+ Given one memory file, it (1) parses the frontmatter (STRUCTURE — trusted, per
20
+ docs/102 clause-1), (2) extracts the body's *checkable claims* and the POLARITY
21
+ each asserts (is this code/commit claimed PRESENT, ABSENT, or SHIPPED?), (3)
22
+ re-probes each claim against ground truth NOW (the working tree + git ancestry,
23
+ never the memory's word), and (4) returns ONE closed `RecallVerdict`:
24
+
25
+ RECALL_FRESH — every checkable claim still confirms → safe to inject
26
+ RECALL_STALE — ≥1 checkable claim is contradicted by ground truth →
27
+ withhold or route to the operator, never inject as fact
28
+ RECALL_UNVERIFIABLE — names nothing checkable, every probe abstained, or the
29
+ memory is a preference/positioning note (opinion-typed)
30
+
31
+ The single highest-leverage move (docs/103 §3.3): recall gains a way to say
32
+ "no, or not sure" instead of only "yes."
33
+
34
+ The kernel-discipline split (the `liveness.classify` shape, lifted)
35
+ ===================================================================
36
+
37
+ `classify_recall(RecallEvidence) -> RecallVerdict` is PURE — no git, no file
38
+ read, no clock. All I/O (the file read, the frontmatter parse, every git/grep
39
+ probe, the wall clock) happens in `gather()` at the caller boundary, exactly as
40
+ `liveness`'s evidence-gather happens in the `dos liveness` CLI and `arbitrate`'s
41
+ reads happen outside `arbitrate()`. That is what lets the verdict be
42
+ replay-tested on frozen fixtures, away from anything needing a live repo.
43
+
44
+ `liveness.classify` is the *shape template*, NOT a call dependency: it answers
45
+ "is a RUN moving," a category error for "is a CLAIM still valid," and would need
46
+ a date→SHA map the kernel does not expose. The recall path consumes `oracle`
47
+ (the ONE narrow `PLAN_PHASE` case) + `git_delta` + a comment-aware working-tree
48
+ grep; it never calls `liveness.classify`.
49
+
50
+ Fail-safe is ABSTAIN, never AGREE
51
+ =================================
52
+
53
+ Every probe that cannot run (git missing, no anchor, ambiguous) returns
54
+ `ProbeStatus.UNKNOWN`, which is EXCLUDED from the `checkable` set. `RECALL_FRESH`
55
+ requires *every* checkable claim to affirmatively CONFIRM — so a probe that
56
+ failed can never satisfy FRESH, only fail to lift the verdict off
57
+ UNVERIFIABLE. The dangerous direction (launder an unchecked claim into FRESH) is
58
+ structurally impossible, the same property `run_judge`'s fail-to-abstain gives
59
+ the JUDGE rung.
60
+
61
+ What this does NOT claim (docs/103 §6)
62
+ ======================================
63
+
64
+ It does not make memory trustworthy — it makes recall HONEST about un-trust. It
65
+ does not catch a lie shape-identical to truth (a memory could name a real commit
66
+ and mis-describe it; this raises the forgery cost to "a real artifact of the
67
+ right shape," no further). It governs the READ path only; *what deserves a
68
+ memory* is a write-side policy that stays a policy. And it NEVER auto-deletes —
69
+ STALE routes a *proposal* (archive or update), never an `rm`, the record-and-
70
+ propose stance of the watchdog (docs/101) and `liveness` (docs/82).
71
+
72
+ `RECALL_DRIFTING` (the 4th token docs/103 §3.2 names) is RESERVED, not shipped:
73
+ a true "the named region moved since the memory's date" verdict needs a
74
+ path/date-scoped git-delta the kernel does not yet expose (`git_delta`'s reads
75
+ are SHA-anchored), and approximating it makes a false-STALE machine on hot
76
+ files. v1 ships the three verdicts gatherable with today's surfaces; DRIFTING
77
+ waits for a path-scoped delta reader (the refusal-to-ship-an-uncomputable-verdict
78
+ discipline, the same one that keeps `verify` honest with `source="none"`).
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import enum
84
+ import re
85
+ import subprocess
86
+ import time
87
+ from dataclasses import dataclass
88
+ from pathlib import Path
89
+ from typing import Optional
90
+
91
+ from dos import config as _config
92
+ from dos import git_delta, oracle
93
+
94
+ # git probes are boundary I/O — cap them so a pathological repo can't hang a
95
+ # recall sweep. Matches the 10s bound `git_delta` and the doctor calls use.
96
+ _GIT_TIMEOUT_S = 10
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # The closed vocabularies — str-valued so they round-trip a CLI/JSON token
101
+ # without a lookup table (the `Liveness` / `gate_classify.Verdict` pattern).
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ class ClaimKind(str, enum.Enum):
106
+ """What KIND of checkable thing a body claim names → which probe answers it."""
107
+
108
+ SHA = "SHA" # a 7- or 40-hex commit id → git ancestry probe
109
+ CODE_TOKEN = "CODE_TOKEN" # a literal source token (import line / flag) claimed
110
+ # present in a named file → comment-aware grep
111
+ PATH = "PATH" # a bare repo-relative path → stat / glob
112
+ PLAN_PHASE = "PLAN_PHASE" # an explicit "docs/NN_*-plan <phase> SHIPPED" →
113
+ # oracle.is_shipped (the ONE narrow correct use)
114
+ OPINION = "OPINION" # prose with no checkable referent → never probed
115
+
116
+ def __str__(self) -> str: # pragma: no cover - trivial
117
+ return self.value
118
+
119
+
120
+ class Polarity(str, enum.Enum):
121
+ """What the memory ASSERTS about the artifact NOW.
122
+
123
+ The signal without which "X is broken" (stale once fixed) is
124
+ indistinguishable from "X shipped" (fresh once shipped). `ProbeStatus` is
125
+ computed RELATIVE to the polarity, which is what makes the dogfood case STALE
126
+ on its merits.
127
+ """
128
+
129
+ ASSERTS_PRESENT = "ASSERTS_PRESENT" # "cli.py does `from dos.drivers import watchdog`"
130
+ ASSERTS_ABSENT = "ASSERTS_ABSENT" # "the import is gone / now a comment"
131
+ ASSERTS_SHIPPED = "ASSERTS_SHIPPED" # "FIXED in a7a145d / SHIPPED 2600110"
132
+ NEUTRAL = "NEUTRAL" # a bare reference, no truth-assertion attached
133
+
134
+ def __str__(self) -> str: # pragma: no cover - trivial
135
+ return self.value
136
+
137
+
138
+ class ProbeStatus(str, enum.Enum):
139
+ """The CLOSED outcome of ONE probe against the working tree NOW. Fail-safe atom."""
140
+
141
+ CONFIRMS = "CONFIRMS" # ground truth AGREES with the claim's polarity
142
+ CONTRADICTS = "CONTRADICTS" # ground truth DISAGREES with the claim's polarity
143
+ UNKNOWN = "UNKNOWN" # the probe could not run (git absent / no anchor) — NO signal
144
+
145
+ def __str__(self) -> str: # pragma: no cover - trivial
146
+ return self.value
147
+
148
+
149
+ class Recall(str, enum.Enum):
150
+ """The closed recall verdict — three states (DRIFTING reserved, see module doc)."""
151
+
152
+ RECALL_FRESH = "RECALL_FRESH" # every checkable claim CONFIRMS → inject
153
+ RECALL_STALE = "RECALL_STALE" # ≥1 checkable claim CONTRADICTS → withhold / route
154
+ RECALL_UNVERIFIABLE = "RECALL_UNVERIFIABLE" # opinion-typed, nothing checkable, or all-UNKNOWN
155
+
156
+ def __str__(self) -> str: # pragma: no cover - trivial
157
+ return self.value
158
+
159
+
160
+ # Frontmatter types that are unfalsifiable by construction — a preference or a
161
+ # positioning take, not a checkable fact. Trust the STRUCTURE (the file is a
162
+ # well-formed feedback note), never adjudicate the CONTENT (docs/103 §4 clause-1,
163
+ # §6 bullet-1). Checked FIRST in classify_recall so an incidental path inside an
164
+ # opinion can't drag it onto the verifiable ladder.
165
+ _OPINION_TYPES = frozenset({"user", "feedback"})
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # The evidence dataclasses — frozen values handed to the pure classifier.
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class MemoryClaim:
175
+ """One checkable artifact extracted from a memory body + the polarity it asserts.
176
+
177
+ PURE data — produced by `extract_claims` from the body string alone, before
178
+ any probe runs. `line_hint` is advisory ONLY and is never probed: line
179
+ numbers drift constantly (every edit above shifts them), so verifying a
180
+ `file:line` literally would manufacture false-STALE on every line move. The
181
+ claim binds to the FILE + the TOKEN, not the line.
182
+ """
183
+
184
+ raw: str # the literal matched text ("from dos.drivers import watchdog", "a7a145d")
185
+ kind: ClaimKind
186
+ polarity: Polarity
187
+ target_file: str = "" # repo-relative file the claim is about (CODE_TOKEN/PATH), or a plan id (PLAN_PHASE)
188
+ line_hint: int = 0 # advisory only — NEVER probed
189
+
190
+ def to_dict(self) -> dict:
191
+ return {
192
+ "raw": self.raw,
193
+ "kind": self.kind.value,
194
+ "polarity": self.polarity.value,
195
+ "target_file": self.target_file,
196
+ "line_hint": self.line_hint,
197
+ }
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class ClaimEvidence:
202
+ """One claim + the result of re-probing it against ground truth now."""
203
+
204
+ claim: MemoryClaim
205
+ status: ProbeStatus
206
+ ground_truth: str = "" # operator-facing proof ("removed by a7a145d ('resolve the watchdog…')")
207
+ source: str = "" # which rung answered: "grep" | "ancestry" | "oracle" | "stat" | "none"
208
+
209
+ def to_dict(self) -> dict:
210
+ return {
211
+ "claim": self.claim.to_dict(),
212
+ "status": self.status.value,
213
+ "ground_truth": self.ground_truth,
214
+ "source": self.source,
215
+ }
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class FrontmatterFacts:
220
+ """The trusted STRUCTURE of a memory — parsed once, never re-verified."""
221
+
222
+ name: str = ""
223
+ description: str = ""
224
+ mem_type: str = "" # user | feedback | project | reference
225
+ node_type: str = ""
226
+ origin_session: str = ""
227
+ body_offset: int = 0 # char offset where the body starts (excludes a frontmatter SHA from claims)
228
+
229
+ @staticmethod
230
+ def empty() -> "FrontmatterFacts":
231
+ return FrontmatterFacts()
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class RecallEvidence:
236
+ """Everything `classify_recall()` needs for ONE memory, gathered by the CALLER.
237
+
238
+ The `ProgressEvidence` analogue: frozen, I/O-free to construct in a test, the
239
+ sole input to the pure verdict. `now_ms` is carried for the JSON consumer and
240
+ age framing; the verdict never reads a clock.
241
+ """
242
+
243
+ mem_name: str
244
+ mem_type: str # frontmatter type — the verifiability gate
245
+ body_date_iso: Optional[str] = None # the self-declared "as of" date; advisory in v1
246
+ evidences: tuple[ClaimEvidence, ...] = ()
247
+ now_ms: int = 0
248
+
249
+ @property
250
+ def checkable(self) -> tuple[ClaimEvidence, ...]:
251
+ """The claims that carry verdict weight: not opinions, and actually probed.
252
+
253
+ An UNKNOWN-status claim is EXCLUDED — a probe that could not run gets no
254
+ vote, so it can never satisfy FRESH (the abstain-not-agree property).
255
+ """
256
+ return tuple(
257
+ e for e in self.evidences
258
+ if e.claim.kind is not ClaimKind.OPINION
259
+ and e.status is not ProbeStatus.UNKNOWN
260
+ )
261
+
262
+
263
+ @dataclass(frozen=True)
264
+ class RecallVerdict:
265
+ """The single verdict `classify_recall()` returns, with the evidence echoed.
266
+
267
+ `culprit` is the deciding CONTRADICTS claim on a STALE verdict (so a surface
268
+ can lead with WHY), or None. `to_dict` is the JSON shape the CLI `--json` /
269
+ MCP tool emit — legible distrust: the operator sees not just STALE but the
270
+ ground-truth proof behind it.
271
+ """
272
+
273
+ verdict: Recall
274
+ reason: str
275
+ culprit: Optional[ClaimEvidence]
276
+ evidence: RecallEvidence
277
+
278
+ def to_dict(self) -> dict:
279
+ return {
280
+ "verdict": self.verdict.value,
281
+ "reason": self.reason,
282
+ "memory": self.evidence.mem_name,
283
+ "type": self.evidence.mem_type,
284
+ "culprit": self.culprit.to_dict() if self.culprit is not None else None,
285
+ "claims": [e.to_dict() for e in self.evidence.evidences],
286
+ }
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # The PURE classifier — no I/O. The faithful liveness.classify lift.
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def classify_recall(ev: RecallEvidence) -> RecallVerdict:
295
+ """Classify one memory's recall verdict from already-gathered evidence. PURE.
296
+
297
+ First-match-wins ladder, worst-checkable-claim-wins fold:
298
+
299
+ 0. UNVERIFIABLE (structural) — an opinion-typed memory (user/feedback). Trust
300
+ STRUCTURE, never adjudicate CONTENT. Checked FIRST and unconditionally so
301
+ an incidental path inside a preference note can't drag it onto the ladder.
302
+ 1. UNVERIFIABLE (empty) — names no re-checkable artifact, or every probe
303
+ abstained (all UNKNOWN). Nothing to bind against ground truth.
304
+ 2. STALE — ANY checkable claim CONTRADICTS (worst-wins, NOT majority — the
305
+ "9 fresh + 1 stale = still STALE" rule that defeats the launder).
306
+ 3. FRESH — every checkable claim CONFIRMS.
307
+ """
308
+ # 0. Opinion-typed → unfalsifiable by construction (§4 clause-1, §6 bullet-1).
309
+ if ev.mem_type in _OPINION_TYPES:
310
+ return RecallVerdict(
311
+ Recall.RECALL_UNVERIFIABLE,
312
+ f"frontmatter type={ev.mem_type or '?'}: a preference/positioning note is "
313
+ f"unfalsifiable by construction — surface it, mark it unverifiable, never "
314
+ f"present it as a verified fact",
315
+ None,
316
+ ev,
317
+ )
318
+
319
+ checkable = ev.checkable
320
+
321
+ # 1. Named nothing checkable, or every probe abstained (§7 second clause).
322
+ if not checkable:
323
+ return RecallVerdict(
324
+ Recall.RECALL_UNVERIFIABLE,
325
+ "names no re-checkable artifact (or every probe abstained) — there is "
326
+ "nothing to bind against ground truth; surface it tagged unverifiable",
327
+ None,
328
+ ev,
329
+ )
330
+
331
+ # 2. STALE — any checkable claim contradicted. Worst-wins, first in extraction
332
+ # order (deterministic). The §1/§7 dogfood cell.
333
+ contradicted = [e for e in checkable if e.status is ProbeStatus.CONTRADICTS]
334
+ if contradicted:
335
+ worst = contradicted[0]
336
+ return RecallVerdict(
337
+ Recall.RECALL_STALE,
338
+ f"ground truth disagrees with {worst.claim.raw!r} "
339
+ f"({worst.claim.kind.value}/{worst.claim.polarity.value}, via "
340
+ f"{worst.source or 'none'}): {worst.ground_truth} — withhold or route to "
341
+ f"decisions, do not inject as fact",
342
+ worst,
343
+ ev,
344
+ )
345
+
346
+ # 3. FRESH — every checkable claim affirmatively confirmed.
347
+ return RecallVerdict(
348
+ Recall.RECALL_FRESH,
349
+ f"all {len(checkable)} checkable claim(s) confirmed against the working tree "
350
+ f"— the memory's evidence is intact, safe to inject",
351
+ None,
352
+ ev,
353
+ )
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # Frontmatter parse — boundary I/O lives in `gather`; this is a pure string fold.
358
+ # ---------------------------------------------------------------------------
359
+
360
+ _FM_DELIM = "---"
361
+
362
+
363
+ def parse_frontmatter(text: str) -> FrontmatterFacts:
364
+ """Parse the leading `--- … ---` YAML block. Fail-safe → empty facts.
365
+
366
+ Trusts the STRUCTURE: `yaml.safe_load` over the block, lifting the handful of
367
+ fields the verdict needs. A torn/absent frontmatter, or missing PyYAML, yields
368
+ empty facts (the memory is then treated as un-typed → its body is the only
369
+ signal), never a crash — the defensive posture every kernel loader uses.
370
+ """
371
+ if not text.startswith(_FM_DELIM):
372
+ return FrontmatterFacts.empty()
373
+ # Find the closing delimiter on its own line.
374
+ end = text.find("\n" + _FM_DELIM, len(_FM_DELIM))
375
+ if end == -1:
376
+ return FrontmatterFacts.empty()
377
+ block = text[len(_FM_DELIM):end]
378
+ # The body starts after the closing "---" line.
379
+ close_line_end = text.find("\n", end + 1 + len(_FM_DELIM))
380
+ body_offset = (close_line_end + 1) if close_line_end != -1 else len(text)
381
+ try:
382
+ import yaml # type: ignore
383
+ except ImportError:
384
+ return FrontmatterFacts(body_offset=body_offset)
385
+ try:
386
+ data = yaml.safe_load(block) or {}
387
+ except Exception:
388
+ return FrontmatterFacts(body_offset=body_offset)
389
+ if not isinstance(data, dict):
390
+ return FrontmatterFacts(body_offset=body_offset)
391
+ meta = data.get("metadata") if isinstance(data.get("metadata"), dict) else {}
392
+ return FrontmatterFacts(
393
+ name=str(data.get("name") or ""),
394
+ description=str(data.get("description") or ""),
395
+ mem_type=str(meta.get("type") or "").strip().lower(),
396
+ node_type=str(meta.get("node_type") or ""),
397
+ origin_session=str(meta.get("originSessionId") or ""),
398
+ body_offset=body_offset,
399
+ )
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # The body-confession guard — strip the file's own RECALL_* banner before
404
+ # extraction so the verdict is computed from a re-check, NEVER parroted from a
405
+ # hand-written self-annotation. Without this the driver would read its own prior
406
+ # verdict + the fixing SHA out of the banner and self-confirm circularly.
407
+ # ---------------------------------------------------------------------------
408
+
409
+ _RECALL_TOKEN = re.compile(r"RECALL_(?:FRESH|STALE|UNVERIFIABLE|DRIFTING)")
410
+
411
+
412
+ def strip_recall_banner(body: str) -> str:
413
+ """Remove a leading block-quote region that contains a RECALL_* token. PURE.
414
+
415
+ A memory may carry a hand-written `> **⚠ RECALL_STALE — re-verified … FIXED in
416
+ a7a145d**` banner (the dogfood file does). If extraction read the verdict + the
417
+ fixing SHA out of THAT, it would self-confirm. We drop any contiguous leading
418
+ block of `>`-quoted lines (and the blank lines between/after them) when that
419
+ block names a RECALL_* token, so the verdict is computed from the ORIGINAL
420
+ audit prose below it. Only a LEADING banner is stripped — a `>`-quote deeper in
421
+ the body that happens to mention a token is left alone (it is real content).
422
+ """
423
+ lines = body.splitlines(keepends=True)
424
+ i = 0
425
+ # Skip leading blank lines.
426
+ while i < len(lines) and not lines[i].strip():
427
+ i += 1
428
+ start = i
429
+ saw_token = False
430
+ # Consume a contiguous run of block-quote lines (and blank lines embedded in it).
431
+ while i < len(lines):
432
+ stripped = lines[i].lstrip()
433
+ if stripped.startswith(">"):
434
+ if _RECALL_TOKEN.search(lines[i]):
435
+ saw_token = True
436
+ i += 1
437
+ elif not lines[i].strip():
438
+ # a blank line: part of the banner only if more quote follows
439
+ j = i + 1
440
+ while j < len(lines) and not lines[j].strip():
441
+ j += 1
442
+ if j < len(lines) and lines[j].lstrip().startswith(">"):
443
+ i = j
444
+ else:
445
+ break
446
+ else:
447
+ break
448
+ if not saw_token:
449
+ return body
450
+ return "".join(lines[i:]) if i > start else body
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # The extractor — PURE: regex over the body string. Conservative by design; an
455
+ # ambiguous match is extracted NEUTRAL (→ UNKNOWN probe → no verdict weight),
456
+ # never guessed into a CONFIRMS/CONTRADICTS.
457
+ # ---------------------------------------------------------------------------
458
+
459
+ # A git SHA in prose: the two REAL git widths only (7 short, 40 full). Dropping
460
+ # the 8–39 band kills session-id fragments (e.g. "7d0fa2aa" is 8 → not matched).
461
+ # `(?=[0-9a-f]*[0-9])` requires at least one digit, dropping all-letter "hex
462
+ # words" (facade / decade / defaced / faced). Bounded by a non-alphanumeric on
463
+ # both sides so a substring of a longer token never matches — but a BACKTICK is a
464
+ # valid delimiter, NOT part of the token, so the common backticked citation form
465
+ # `` `a7a145d` `` / `` `9866239` `` matches (the lookbehind excludes alphanumerics
466
+ # only, never the backtick that wraps the most reliable SHA references).
467
+ _SHA = re.compile(
468
+ r"(?<![0-9a-zA-Z])(?=[0-9a-f]*[0-9])(?:[0-9a-f]{7}|[0-9a-f]{40})(?![0-9a-zA-Z])"
469
+ )
470
+ # A ship verb tight before a SHA flips a bare hex into an ASSERTS_SHIPPED claim.
471
+ _SHIP_VERB = re.compile(r"\b(?:fixed|shipped|landed|cut|committed|merged)\b", re.I)
472
+
473
+ # A backticked import statement or flag claimed about a file — the dogfood spine.
474
+ _IMPORT_TOK = re.compile(r"`(from [\w.]+ import [\w, ]+|import [\w.]+)`")
475
+ _FLAG_TOK = re.compile(r"`(--[\w][\w-]*)`")
476
+ # A file:line ref (also matched on its own as a weaker file anchor).
477
+ _FILE_REF = re.compile(
478
+ r"\b((?:[\w./-]+/)?[\w-]+\.(?:py|toml|yaml|yml|md|cfg|txt|sh|ini)):(\d{1,6})\b"
479
+ )
480
+ # A bare repo-relative source path (no line). Anchored on a small set of
481
+ # top-level dir names so an arbitrary "x/y.md" fragment in prose isn't a claim.
482
+ # The left boundary forbids a preceding word-char OR slash, so a FOREIGN-repo
483
+ # prefix (`job/scripts/ship_oracle.py`, the reference userland app) does NOT
484
+ # silently strip to `scripts/ship_oracle.py` and get probed against THIS repo — a
485
+ # backtick or whitespace is still a valid left edge, so a backticked path matches.
486
+ _BARE_PATH = re.compile(
487
+ r"(?<![\w/])((?:src|tests|docs|scripts|examples|benchmark|spikes)/[\w./-]+"
488
+ r"\.(?:py|toml|yaml|yml|md|cfg|txt|sh|ini))\b"
489
+ )
490
+
491
+ # Imports too generic to bind to a file-specific present/absent claim — they
492
+ # appear as both code AND comment in nearly every module, so the comment-aware
493
+ # grep cannot disambiguate a real "X still imports this" claim from incidental
494
+ # prose. The dogfood `from dos.drivers import watchdog` is specific and NOT here.
495
+ _GENERIC_IMPORTS = frozenset({
496
+ "import dos", "import os", "import re", "import sys", "import enum",
497
+ "import json", "import time", "import subprocess",
498
+ })
499
+
500
+ # Presence / absence cues scanned in a window around a code-token match.
501
+ _PRESENT_CUE = re.compile(
502
+ r"\b(?:do|does|did|imports?|import|contains?|has|have|still|carr(?:y|ies)|"
503
+ r"is in|lives? in|present)\b",
504
+ re.I,
505
+ )
506
+ # Absence cues for a PATH/CODE_TOKEN polarity flip. Each NAMES a removal or a
507
+ # comment-only state directly — the unbounded `is now` / `only inside` tokens
508
+ # were dropped (they matched any "is now …" / "only inside …" clause about a
509
+ # DIFFERENT noun, the window-bleed false-STALE source).
510
+ _ABSENT_CUE = re.compile(
511
+ r"\b(?:gone|removed|deleted|no longer|now a comment|now only a comment|"
512
+ r"only inside a comment|inside a comment|inside a docstring|not a static import|"
513
+ r"dropped|stripped|eliminated)\b",
514
+ re.I,
515
+ )
516
+ # A STRONG present/creation cue: prose that ties THIS repo to the artifact ("we
517
+ # wrote/committed/added X"). A bare PATH mention is a REFERENCE, not a claim — only
518
+ # a strong cue makes it an ASSERTS_PRESENT claim about this repo. This is the
519
+ # signal (prose, not filesystem) that separates a TRUE "we created docs/_business/X"
520
+ # from a FALSE "the job repo has docs/_business/Y".
521
+ _STRONG_PRESENT_CUE = re.compile(
522
+ r"\b(?:written|created|wrote|added|committed|shipped|landed|deliverables?|"
523
+ r"refreshed|now exists|sketches|introduces?|emit(?:s|ted)?|built|ships?)\b",
524
+ re.I,
525
+ )
526
+
527
+ _WINDOW = 90 # ±chars scanned around a match for polarity cues
528
+
529
+
530
+ def _window(body: str, start: int, end: int) -> str:
531
+ return body[max(0, start - _WINDOW): min(len(body), end + _WINDOW)]
532
+
533
+
534
+ # A PATH polarity cue must sit TIGHT to the path (the path is the verb's object),
535
+ # not anywhere in the sentence — a wider window lets a cue for a different noun
536
+ # bleed ("the artifact being BUILT is …" near a referenced path; "crashed on the
537
+ # DELETED PDFs" near a present file). These are deliberately small.
538
+ _CUE_LEFT = 40 # chars before the path a creation/removal cue may sit in
539
+ _CUE_RIGHT = 35 # chars after the path a trailing cue ("… already sketches") may sit in
540
+
541
+
542
+ def _clause_window(body: str, start: int, end: int) -> str:
543
+ """The TIGHT window for PATH polarity — the path's immediate neighbourhood only.
544
+
545
+ A cue describing a DIFFERENT noun in the same sentence must not flip the path
546
+ (the `stamp.py` / `release_context.py` / `agent-ops` window-bleed bugs). The
547
+ window is a small span hugging the path, additionally clipped at any clause
548
+ break (`.`/`;`/`—`/`)`/newline) on EITHER side so only the path's own clause
549
+ votes. Now that the path PROBE is git-grounded (created-here-then-removed is
550
+ decided by history, not prose), this cue only has to catch a creation/removal
551
+ verb that is grammatically ABOUT the path — so a tight window is correct, not
552
+ lossy.
553
+ """
554
+ lo = max(0, start - _CUE_LEFT)
555
+ # left clause boundary: the last break char before the path within the span
556
+ left = lo
557
+ for brk in (". ", "; ", "—", "\n", ") ", ": "):
558
+ j = body.rfind(brk, lo, start)
559
+ if j != -1:
560
+ left = max(left, j + len(brk))
561
+ hi = min(len(body), end + _CUE_RIGHT)
562
+ seg = body[left:hi]
563
+ rel_end = end - left
564
+ for brk in (". ", "; ", " — ", ")", "\n", ","):
565
+ i = seg.find(brk, rel_end)
566
+ if i != -1:
567
+ seg = seg[:i]
568
+ return seg
569
+
570
+
571
+ def _nearest_file(body: str, pos: int) -> tuple[str, int]:
572
+ """The file ref closest to `pos` (a code token's home), as (repo_path, line)."""
573
+ best: tuple[str, int] = ("", 0)
574
+ best_dist = 10 ** 9
575
+ for m in _FILE_REF.finditer(body):
576
+ dist = abs(m.start() - pos)
577
+ if dist < best_dist:
578
+ best_dist = dist
579
+ best = (m.group(1), int(m.group(2)))
580
+ return best
581
+
582
+
583
+ def extract_claims(body: str, mem_type: str) -> list[MemoryClaim]:
584
+ """Extract the checkable claims + their polarity from a memory body. PURE.
585
+
586
+ Conservative: only `ASSERTS_*`-polarity claims carry verdict weight; a NEUTRAL
587
+ match is extracted but its probe abstains (UNKNOWN). A body that yields zero
588
+ non-OPINION matches contributes an empty list → classify_recall rung-1
589
+ UNVERIFIABLE (the §7 "names nothing checkable" floor, realized as the absence
590
+ of any extraction).
591
+ """
592
+ claims: list[MemoryClaim] = []
593
+ seen: set[tuple[str, str]] = set()
594
+
595
+ def _add(raw: str, kind: ClaimKind, pol: Polarity, target: str = "", line: int = 0) -> None:
596
+ key = (kind.value, raw)
597
+ if key in seen:
598
+ return
599
+ seen.add(key)
600
+ claims.append(MemoryClaim(raw=raw, kind=kind, polarity=pol, target_file=target, line_hint=line))
601
+
602
+ # --- CODE_TOKEN: a backticked import statement claimed about a nearby file.
603
+ for m in _IMPORT_TOK.finditer(body):
604
+ tok = m.group(1)
605
+ if tok in _GENERIC_IMPORTS:
606
+ continue # too generic to bind to a file-specific present/absent claim
607
+ win = _window(body, m.start(), m.end())
608
+ target, line = _nearest_file(body, m.start())
609
+ if _ABSENT_CUE.search(win):
610
+ pol = Polarity.ASSERTS_ABSENT
611
+ elif _PRESENT_CUE.search(win):
612
+ pol = Polarity.ASSERTS_PRESENT
613
+ else:
614
+ pol = Polarity.NEUTRAL
615
+ _add(tok, ClaimKind.CODE_TOKEN, pol, target, line)
616
+
617
+ # --- CODE_TOKEN: a backticked flag claimed present in a file.
618
+ for m in _FLAG_TOK.finditer(body):
619
+ tok = m.group(1)
620
+ win = _window(body, m.start(), m.end())
621
+ target, line = _nearest_file(body, m.start())
622
+ if not target:
623
+ continue # a flag with no nearby file has no probe anchor → skip
624
+ if _ABSENT_CUE.search(win):
625
+ pol = Polarity.ASSERTS_ABSENT
626
+ elif _PRESENT_CUE.search(win):
627
+ pol = Polarity.ASSERTS_PRESENT
628
+ else:
629
+ pol = Polarity.NEUTRAL
630
+ _add(tok, ClaimKind.CODE_TOKEN, pol, target, line)
631
+
632
+ # --- SHA: ASSERTS_SHIPPED only when a ship verb sits TIGHT before the SHA
633
+ # ("FIXED in `a7a145d`", "SHIPPED commit 9866239") — not anywhere in a wide
634
+ # window, where a verb about a DIFFERENT subject bleeds (a branch-tip SHA in
635
+ # a parenthetical list near "master re-landed …" is NOT a ship claim). A SHA
636
+ # that is a parenthetical branch annotation — `(b571fc6)` — is a bare
637
+ # reference (NEUTRAL → the probe abstains), never an ASSERTS_SHIPPED claim.
638
+ for m in _SHA.finditer(body):
639
+ sha = m.group(0)
640
+ in_parens = m.start() > 0 and body[m.start() - 1] == "(" \
641
+ and m.end() < len(body) and body[m.end()] == ")"
642
+ pre = body[max(0, m.start() - _CUE_LEFT): m.start()]
643
+ # cut the pre-window at a clause break so a far verb can't bleed in
644
+ for brk in (". ", "; ", "\n", ", ", ") "):
645
+ j = pre.rfind(brk)
646
+ if j != -1:
647
+ pre = pre[j + len(brk):]
648
+ ships = bool(_SHIP_VERB.search(pre)) and not in_parens
649
+ pol = Polarity.ASSERTS_SHIPPED if ships else Polarity.NEUTRAL
650
+ _add(sha, ClaimKind.SHA, pol, "")
651
+
652
+ # --- PATH: a bare repo-relative source path. NEUTRAL by DEFAULT — a bare path
653
+ # mention is a REFERENCE, not a claim ("the plan in docs/77", "the host's
654
+ # scripts/", "an example like src/foo.py"). It becomes ASSERTS_PRESENT only
655
+ # on an explicit in-clause creation/ship cue ("we wrote/committed/added X"),
656
+ # ASSERTS_ABSENT only on an in-clause removal cue. The clause-bounded window
657
+ # stops a cue for a DIFFERENT noun from bleeding onto the path.
658
+ for m in _BARE_PATH.finditer(body):
659
+ p = m.group(1)
660
+ win = _clause_window(body, m.start(), m.end())
661
+ if _ABSENT_CUE.search(win):
662
+ pol = Polarity.ASSERTS_ABSENT
663
+ elif _STRONG_PRESENT_CUE.search(win):
664
+ pol = Polarity.ASSERTS_PRESENT
665
+ else:
666
+ pol = Polarity.NEUTRAL
667
+ _add(p, ClaimKind.PATH, pol, p)
668
+
669
+ return claims
670
+
671
+
672
+ def extract_body_date(body: str, fm: FrontmatterFacts) -> Optional[str]:
673
+ """The memory's self-declared "as of" date (ISO `YYYY-MM-DD`), if any. Advisory.
674
+
675
+ Decoupled from the verdict in v1 (the RECALL_DRIFTING axis it would feed is
676
+ reserved, not shipped). Carried for the JSON consumer + a future date-scoped
677
+ delta reader. We prefer the FIRST date in the body (the write stamp) and never
678
+ use the file mtime (forgeable, rejected in docs/95) or git-blame (the store is
679
+ not under git here).
680
+ """
681
+ m = re.search(r"\b(20\d\d-[01]\d-[0-3]\d)\b", body)
682
+ return m.group(1) if m else None
683
+
684
+
685
+ # ---------------------------------------------------------------------------
686
+ # The probes — BOUNDARY I/O. Each is fail-safe → UNKNOWN (never a guessed AGREE).
687
+ # ---------------------------------------------------------------------------
688
+
689
+
690
+ def _grep_code_vs_comment(text: str, literal: str) -> tuple[int, int]:
691
+ """(code_hits, comment_hits) for `literal` in `text`.
692
+
693
+ A line-based heuristic with a triple-quote span tracker: an occurrence counts
694
+ as a COMMENT hit if its line (after lstrip) starts with a hash, or the line
695
+ falls inside an open triple-quoted docstring span, or the occurrence sits after
696
+ a hash on its line. Otherwise it is a CODE hit. This is the FRESH/STALE hinge for the
697
+ dogfood case: a token that survives only inside a docstring is present-as-
698
+ comment, NOT present-as-code, so an ASSERTS_PRESENT claim about it is
699
+ contradicted.
700
+
701
+ Deliberately simple (no full tokenizer) — it errs toward calling an ambiguous
702
+ line CODE, which is the conservative direction for an ASSERTS_PRESENT claim (it
703
+ biases toward CONFIRMS/FRESH, never toward a false STALE).
704
+ """
705
+ code = comment = 0
706
+ in_triple: str = "" # empty, or whichever triple-quote opener is currently open
707
+ for line in text.splitlines():
708
+ stripped = line.lstrip()
709
+ # Track triple-quote spans (count balanced toggles on a line).
710
+ if not in_triple:
711
+ line_is_comment = stripped.startswith("#")
712
+ else:
713
+ line_is_comment = True
714
+ # Find occurrences on this line.
715
+ idx = line.find(literal)
716
+ while idx != -1:
717
+ if in_triple or line_is_comment:
718
+ comment += 1
719
+ else:
720
+ # an inline-# before the occurrence makes it a comment hit
721
+ hash_pos = line.find("#")
722
+ if 0 <= hash_pos < idx:
723
+ comment += 1
724
+ else:
725
+ code += 1
726
+ idx = line.find(literal, idx + 1)
727
+ # Update the triple-quote state AFTER counting this line's hits.
728
+ for q in ('"""', "'''"):
729
+ n = line.count(q)
730
+ if n == 0:
731
+ continue
732
+ if in_triple == q:
733
+ # closes (odd count) or stays (even)
734
+ if n % 2 == 1:
735
+ in_triple = ""
736
+ elif not in_triple:
737
+ if n % 2 == 1:
738
+ in_triple = q
739
+ return code, comment
740
+
741
+
742
+ def _pickaxe_fix(literal: str, repo_file: str, root: Path) -> str:
743
+ """`git log -S<literal> -- <file>` newest match → "shortsha ('subject')". Fail-safe → "".
744
+
745
+ The forensic evidence the §7 litmus demands: when an ASSERTS_PRESENT code token
746
+ is no longer present-as-code, this names the commit that removed it — obtained
747
+ BY RE-CHECK (git pickaxe), never parroted from the memory body.
748
+ """
749
+ try:
750
+ raw = subprocess.run(
751
+ ["git", "log", "-S", literal, "-n", "1", "--pretty=format:%h\t%s", "--", repo_file],
752
+ cwd=str(root), capture_output=True, text=True, check=False, timeout=_GIT_TIMEOUT_S,
753
+ )
754
+ except (OSError, subprocess.TimeoutExpired):
755
+ return ""
756
+ if raw.returncode != 0 or not raw.stdout.strip():
757
+ return ""
758
+ parts = raw.stdout.splitlines()[0].split("\t", 1)
759
+ if len(parts) != 2:
760
+ return ""
761
+ return f"{parts[0]} ({parts[1]!r})"
762
+
763
+
764
+ # Where to look for a bare-basename file ref (the memory writes `cli.py`, the repo
765
+ # path is `src/dos/cli.py`). Searched in order; the first dir containing a UNIQUE
766
+ # match wins. Kept small + repo-shaped so an ambiguous basename abstains rather
767
+ # than guessing the wrong file.
768
+ _BASENAME_SEARCH_DIRS = ("src/dos", "src/dos_mcp", "src", "tests", "docs", "scripts")
769
+
770
+
771
+ def _resolve_repo_file(target: str, root: Path) -> tuple[Optional[Path], str]:
772
+ """A repo-relative or bare-basename file ref → (real file, status). BOUNDARY I/O.
773
+
774
+ Returns `(path, "found")` on a unique resolution; `(None, "absent")` when the
775
+ file genuinely does not exist; `(None, "ambiguous")` when a bare basename
776
+ matches more than one repo file. The two None cases are DISTINCT: an absent
777
+ ASSERTS_PRESENT file is a contradiction, but an ambiguous basename must ABSTAIN
778
+ (it cannot bind to the right file) — collapsing them would manufacture
779
+ false-STALE on a common basename.
780
+ """
781
+ verbatim = root / target
782
+ if verbatim.is_file():
783
+ return verbatim, "found"
784
+ if "/" in target or "\\" in target:
785
+ return None, "absent" # an explicit path that doesn't exist
786
+ base = Path(target).name
787
+ hits: list[Path] = []
788
+ for d in _BASENAME_SEARCH_DIRS:
789
+ cand = root / d / base
790
+ if cand.is_file():
791
+ hits.append(cand)
792
+ if len(hits) == 1:
793
+ return hits[0], "found"
794
+ if not hits:
795
+ return None, "absent"
796
+ return None, "ambiguous"
797
+
798
+
799
+ def _probe_code_token(claim: MemoryClaim, root: Path) -> ClaimEvidence:
800
+ """Re-grep the NAMED file for the literal token NOW, comment-aware."""
801
+ if not claim.target_file:
802
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN, "no file anchor for the token", "none")
803
+ f, fstatus = _resolve_repo_file(claim.target_file, root)
804
+ if fstatus == "ambiguous":
805
+ # A bare basename matching >1 file: cannot bind to the right one → abstain.
806
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN,
807
+ f"file {claim.target_file!r} is ambiguous (matches several repo "
808
+ f"files); cannot bind the token to one", "none")
809
+ if f is None: # genuinely absent
810
+ if claim.polarity is Polarity.ASSERTS_PRESENT:
811
+ return ClaimEvidence(claim, ProbeStatus.CONTRADICTS,
812
+ f"named file {claim.target_file} is absent", "stat")
813
+ if claim.polarity is Polarity.ASSERTS_ABSENT:
814
+ return ClaimEvidence(claim, ProbeStatus.CONFIRMS,
815
+ f"named file {claim.target_file} is absent", "stat")
816
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN,
817
+ f"named file {claim.target_file} is absent; no assertion to bind", "stat")
818
+ rel = f.relative_to(root).as_posix()
819
+ try:
820
+ text = f.read_text(encoding="utf-8", errors="replace")
821
+ except OSError:
822
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN, f"could not read {rel}", "none")
823
+ code_hits, comment_hits = _grep_code_vs_comment(text, claim.raw)
824
+ present_as_code = code_hits > 0
825
+
826
+ if claim.polarity is Polarity.ASSERTS_PRESENT:
827
+ if present_as_code:
828
+ return ClaimEvidence(claim, ProbeStatus.CONFIRMS,
829
+ f"still present as code in {rel} "
830
+ f"({code_hits} occurrence(s))", "grep")
831
+ fix = _pickaxe_fix(claim.raw, rel, root)
832
+ detail = f"no longer present as code in {rel}"
833
+ if comment_hits:
834
+ detail += "; only inside a comment/docstring now"
835
+ if fix:
836
+ detail += f"; removed by {fix}"
837
+ return ClaimEvidence(claim, ProbeStatus.CONTRADICTS, detail, "grep")
838
+
839
+ if claim.polarity is Polarity.ASSERTS_ABSENT:
840
+ if present_as_code:
841
+ return ClaimEvidence(claim, ProbeStatus.CONTRADICTS,
842
+ f"still present as code in {rel}", "grep")
843
+ return ClaimEvidence(claim, ProbeStatus.CONFIRMS,
844
+ f"absent as code in {rel}", "grep")
845
+
846
+ # NEUTRAL — a token reference with no truth-assertion: nothing to confirm.
847
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN, "no truth-assertion on the token", "none")
848
+
849
+
850
+ def _probe_sha(claim: MemoryClaim, root: Path) -> ClaimEvidence:
851
+ """Ancestry, not mere existence: is the SHA on HEAD's history NOW?
852
+
853
+ `git merge-base --is-ancestor` (not bare `cat-file -e`): a trust kernel checks
854
+ ancestry, not existence — an orphaned/dropped commit "exists" but is not on the
855
+ trunk the memory's "SHIPPED" claim asserts. Only an ASSERTS_SHIPPED SHA carries
856
+ weight; a NEUTRAL SHA reference abstains (UNKNOWN — a bare hex is not a claim).
857
+ """
858
+ if claim.polarity is not Polarity.ASSERTS_SHIPPED:
859
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN, "bare SHA reference, no ship assertion", "none")
860
+ sha = claim.raw
861
+ try:
862
+ r = subprocess.run(
863
+ ["git", "merge-base", "--is-ancestor", sha, "HEAD"],
864
+ cwd=str(root), capture_output=True, check=False, timeout=_GIT_TIMEOUT_S,
865
+ )
866
+ except (OSError, subprocess.TimeoutExpired):
867
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN, "git unavailable", "none")
868
+ # `--is-ancestor` exits 0=ancestor, 1=not, 128=bad object (unknown sha).
869
+ if r.returncode == 0:
870
+ subj = ""
871
+ for c in git_delta.recent_commits(300, root=root):
872
+ if c["sha"].startswith(sha[:7]) or sha.startswith(c["sha"]):
873
+ subj = c["subject"]
874
+ break
875
+ gt = f"{sha} is an ancestor of HEAD" + (f" ({subj!r})" if subj else "")
876
+ return ClaimEvidence(claim, ProbeStatus.CONFIRMS, gt, "ancestry")
877
+ if r.returncode == 1:
878
+ return ClaimEvidence(claim, ProbeStatus.CONTRADICTS,
879
+ f"{sha} is NOT an ancestor of HEAD (orphaned, dropped, or rebased away)",
880
+ "ancestry")
881
+ # 128 / anything else: the object is unknown to this repo — can't bind a
882
+ # SHIPPED claim against a SHA git doesn't know. Abstain (it may be a different
883
+ # repo's SHA quoted in prose), never a false CONTRADICTS.
884
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN,
885
+ f"{sha} is unknown to this repo (cannot verify ancestry)", "none")
886
+
887
+
888
+ def _path_deleting_commit(repo_file: str, root: Path) -> str:
889
+ """The commit that DELETED `repo_file` here → "shortsha ('subject')", or "".
890
+
891
+ `git log --diff-filter=D -1 -- <file>` names the commit that removed a path
892
+ that once lived in this tree (a relocation/strip). The path-analogue of
893
+ `_pickaxe_fix`. Fail-safe → "" (git absent / never tracked / still present).
894
+ """
895
+ try:
896
+ raw = subprocess.run(
897
+ ["git", "log", "--diff-filter=D", "-n", "1", "--pretty=format:%h\t%s", "--", repo_file],
898
+ cwd=str(root), capture_output=True, text=True, check=False, timeout=_GIT_TIMEOUT_S,
899
+ )
900
+ except (OSError, subprocess.TimeoutExpired):
901
+ return ""
902
+ if raw.returncode != 0 or not raw.stdout.strip():
903
+ return ""
904
+ parts = raw.stdout.splitlines()[0].split("\t", 1)
905
+ return f"{parts[0]} ({parts[1]!r})" if len(parts) == 2 else ""
906
+
907
+
908
+ def _path_ever_tracked(repo_file: str, root: Path) -> Optional[bool]:
909
+ """Did `repo_file` EVER exist in this repo's history? True/False, or None on error.
910
+
911
+ `git log --all -- <file>` over the served repo. The ground-truth signal that
912
+ separates a path that was CREATED-HERE-then-removed (a real STALE, the memory's
913
+ claim no longer holds) from one that was NEVER HERE (a foreign/illustrative
914
+ reference — the job repo's `scripts/`, the strategy repo's `docs/_business/`, an
915
+ `src/foo.py` example — which the driver must ABSTAIN on, not contradict). This
916
+ replaces fragile prose-cue guessing with what git actually records.
917
+ """
918
+ try:
919
+ raw = subprocess.run(
920
+ ["git", "log", "--all", "-n", "1", "--pretty=format:%h", "--", repo_file],
921
+ cwd=str(root), capture_output=True, text=True, check=False, timeout=_GIT_TIMEOUT_S,
922
+ )
923
+ except (OSError, subprocess.TimeoutExpired):
924
+ return None
925
+ if raw.returncode != 0:
926
+ return None
927
+ return bool(raw.stdout.strip())
928
+
929
+
930
+ def _probe_path(claim: MemoryClaim, root: Path) -> ClaimEvidence:
931
+ """A bare repo-relative path: does it exist NOW, grounded in git history?
932
+
933
+ The honest rule rests on GIT, not prose cues (the driver's own "ground truth
934
+ over narration" discipline). For an absent file:
935
+ * git shows it WAS here and was deleted → STALE, evidence = the deleting
936
+ commit (a created-here-then-relocated/stripped path; the memory's "it's
937
+ here" no longer holds).
938
+ * git shows it was NEVER here → a foreign/illustrative reference (the job
939
+ repo's `scripts/`, the strategy repo's `docs/_business/`, an `src/foo.py`
940
+ example) → ABSTAIN (UNKNOWN), UNLESS an explicit ASSERTS_PRESENT cue claims
941
+ THIS repo created it (then a never-here file genuinely contradicts the claim).
942
+ * git unavailable → fall back to the existence check (fail-safe).
943
+ A present file CONFIRMS an ASSERTS_PRESENT / CONTRADICTS an ASSERTS_ABSENT.
944
+ """
945
+ p = root / claim.target_file
946
+ rel = claim.target_file
947
+ if p.exists():
948
+ if claim.polarity is Polarity.ASSERTS_PRESENT:
949
+ return ClaimEvidence(claim, ProbeStatus.CONFIRMS, f"{rel} exists", "stat")
950
+ if claim.polarity is Polarity.ASSERTS_ABSENT:
951
+ # A path asserted ABSENT that actually EXISTS is almost always cue-bleed
952
+ # — an absence word ("crashed on the DELETED PDFs") describing a nearby
953
+ # noun, not this path. A bare path is rarely the grammatical subject of
954
+ # a removal. Abstain rather than emit a false STALE (the trust-preserving
955
+ # choice); a genuine "X was removed" where X is gone still CONFIRMS below.
956
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN,
957
+ f"{rel} exists, but an absence cue near it likely described "
958
+ f"another noun — abstaining rather than contradict", "stat")
959
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN, "no assertion on the path", "none")
960
+
961
+ # Absent now — let git history decide created-here-then-removed vs never-here.
962
+ ever = _path_ever_tracked(rel, root)
963
+ if ever is None:
964
+ # git unavailable: fall back to the bare existence verdict (fail-safe).
965
+ if claim.polarity is Polarity.ASSERTS_PRESENT:
966
+ return ClaimEvidence(claim, ProbeStatus.CONTRADICTS, f"{rel} is gone", "stat")
967
+ if claim.polarity is Polarity.ASSERTS_ABSENT:
968
+ return ClaimEvidence(claim, ProbeStatus.CONFIRMS, f"{rel} is gone", "stat")
969
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN, "no assertion on the path", "none")
970
+
971
+ if ever:
972
+ # Was here, now gone → relocated/stripped. A real "no longer in this repo".
973
+ delc = _path_deleting_commit(rel, root)
974
+ gt = f"{rel} was in this repo and is now gone" + (f"; removed by {delc}" if delc else "")
975
+ if claim.polarity is Polarity.ASSERTS_ABSENT:
976
+ return ClaimEvidence(claim, ProbeStatus.CONFIRMS, gt, "git")
977
+ # PRESENT or NEUTRAL: the memory points at a path no longer here.
978
+ if claim.polarity is Polarity.ASSERTS_PRESENT:
979
+ return ClaimEvidence(claim, ProbeStatus.CONTRADICTS, gt, "git")
980
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN, f"{rel}: " + gt + " (no assertion)", "git")
981
+
982
+ # NEVER here → a foreign/illustrative reference. Abstain unless the prose
983
+ # explicitly claims THIS repo created it (then never-here contradicts).
984
+ if claim.polarity is Polarity.ASSERTS_PRESENT:
985
+ return ClaimEvidence(claim, ProbeStatus.CONTRADICTS,
986
+ f"{rel} was never in this repo, yet the memory asserts it was "
987
+ f"created here", "git")
988
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN,
989
+ f"{rel} was never in this repo — a foreign/illustrative reference, "
990
+ f"not a claim about this tree", "git")
991
+
992
+
993
+ def _probe_plan_phase(claim: MemoryClaim, cfg: "_config.SubstrateConfig") -> ClaimEvidence:
994
+ """The ONE narrow correct use of `oracle.is_shipped`. source='none' → UNKNOWN.
995
+
996
+ A `source="none"` answer is the oracle ABSTAINING (no registry row, no matching
997
+ commit), NOT disagreeing — so it maps to UNKNOWN, never CONTRADICTS. Only a real
998
+ registry/grep `shipped=False` is a disagreement. This is the false-STALE-by-
999
+ abstention guard.
1000
+ """
1001
+ plan, phase = claim.target_file, claim.raw
1002
+ v = oracle.is_shipped(plan, phase, cfg=cfg)
1003
+ if v.shipped:
1004
+ return ClaimEvidence(claim, ProbeStatus.CONFIRMS,
1005
+ f"oracle: shipped via {v.source} ({v.sha or '-'})", "oracle")
1006
+ if v.source == "none":
1007
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN,
1008
+ "oracle abstained (no registry row, no matching commit)", "oracle")
1009
+ return ClaimEvidence(claim, ProbeStatus.CONTRADICTS, f"oracle: not shipped (via {v.source})", "oracle")
1010
+
1011
+
1012
+ def probe(claim: MemoryClaim, cfg: "_config.SubstrateConfig") -> ClaimEvidence:
1013
+ """Route a claim to its probe. BOUNDARY I/O; each rung is fail-safe → UNKNOWN."""
1014
+ root = cfg.paths.root
1015
+ if claim.kind is ClaimKind.CODE_TOKEN:
1016
+ return _probe_code_token(claim, root)
1017
+ if claim.kind is ClaimKind.SHA:
1018
+ return _probe_sha(claim, root)
1019
+ if claim.kind is ClaimKind.PATH:
1020
+ return _probe_path(claim, root)
1021
+ if claim.kind is ClaimKind.PLAN_PHASE:
1022
+ return _probe_plan_phase(claim, cfg)
1023
+ return ClaimEvidence(claim, ProbeStatus.UNKNOWN, "opinion / no probe", "none")
1024
+
1025
+
1026
+ # ---------------------------------------------------------------------------
1027
+ # The boundary gatherer + public API.
1028
+ # ---------------------------------------------------------------------------
1029
+
1030
+
1031
+ def gather(path: Path, *, cfg: "_config.SubstrateConfig", now_ms: int) -> RecallEvidence:
1032
+ """Read one memory file and gather all its evidence. ALL I/O lives here.
1033
+
1034
+ The boundary, exactly like `cmd_liveness`'s evidence-gather: read the file,
1035
+ parse the frontmatter (structure), strip a self-annotation banner, extract the
1036
+ body's claims, probe each against ground truth, freeze a `RecallEvidence`. The
1037
+ pure `classify_recall` is then called on the result.
1038
+ """
1039
+ try:
1040
+ text = path.read_text(encoding="utf-8", errors="replace")
1041
+ except OSError:
1042
+ return RecallEvidence(mem_name=path.stem, mem_type="", now_ms=now_ms)
1043
+ fm = parse_frontmatter(text)
1044
+ body = strip_recall_banner(text[fm.body_offset:])
1045
+ date_iso = extract_body_date(body, fm)
1046
+ claims = extract_claims(body, fm.mem_type)
1047
+ evidences = tuple(probe(c, cfg) for c in claims)
1048
+ return RecallEvidence(
1049
+ mem_name=fm.name or path.stem,
1050
+ mem_type=fm.mem_type,
1051
+ body_date_iso=date_iso,
1052
+ evidences=evidences,
1053
+ now_ms=now_ms,
1054
+ )
1055
+
1056
+
1057
+ def default_store(cfg: "_config.SubstrateConfig") -> Optional[Path]:
1058
+ """Best-effort guess at this workspace's agent-memory dir. None if not found.
1059
+
1060
+ The memory store is a host/harness convention, not a DOS path, so it is NOT in
1061
+ the config seam. We probe the documented Claude Code layout
1062
+ (`~/.claude/projects/<slugified-workspace>/memory`) where the slug replaces
1063
+ path separators and the drive colon with `-` (a Windows workspace at
1064
+ `<drive>:\a\b` slugifies to `<drive>--a-b`). Returns the dir if it exists, else
1065
+ None — a caller with no store passes `--store` explicitly. Never hardcodes a
1066
+ user path.
1067
+ """
1068
+ root = cfg.paths.root.resolve()
1069
+ slug = str(root).replace(":", "-").replace("\\", "-").replace("/", "-")
1070
+ # collapse a leading separator-dash so "<drive>:\a\b" → "<drive>--a-b"
1071
+ slug = re.sub(r"-{3,}", "--", slug).strip("-")
1072
+ cand = Path.home() / ".claude" / "projects" / f"C--{slug.split('-', 1)[-1]}" / "memory"
1073
+ # Try the exact documented slug first, then a couple of tolerant variants.
1074
+ candidates = [
1075
+ Path.home() / ".claude" / "projects" / slug / "memory",
1076
+ cand,
1077
+ ]
1078
+ for c in candidates:
1079
+ if c.is_dir():
1080
+ return c
1081
+ return None
1082
+
1083
+
1084
+ def _resolve_store(store: Optional[str], cfg: "_config.SubstrateConfig") -> Path:
1085
+ if store:
1086
+ return Path(store)
1087
+ d = default_store(cfg)
1088
+ if d is None:
1089
+ raise ValueError(
1090
+ "could not locate the agent-memory store for this workspace; pass "
1091
+ "--store <dir> (the recall driver does not assume a memory layout — "
1092
+ "it is a harness convention, not a DOS path)")
1093
+ return d
1094
+
1095
+
1096
+ def _resolve_memory_path(name_or_path: str, store: Path) -> Path:
1097
+ """A frontmatter `name`, a bare slug, or a direct path → the memory file."""
1098
+ p = Path(name_or_path)
1099
+ if p.is_file():
1100
+ return p
1101
+ cand = store / (name_or_path if name_or_path.endswith(".md") else f"{name_or_path}.md")
1102
+ if cand.is_file():
1103
+ return cand
1104
+ raise ValueError(f"no memory named {name_or_path!r} under {store}")
1105
+
1106
+
1107
+ def recall_one(
1108
+ name_or_path: str,
1109
+ *,
1110
+ cfg: "Optional[_config.SubstrateConfig]" = None,
1111
+ store: Optional[str] = None,
1112
+ now_ms: Optional[int] = None,
1113
+ ) -> RecallVerdict:
1114
+ """Re-verify ONE memory at recall time → its closed RecallVerdict.
1115
+
1116
+ `name_or_path` is a frontmatter `name` / slug (resolved against the store) or a
1117
+ direct path. `store` overrides the memory dir; default via `default_store`.
1118
+ `now_ms` is injected here at the boundary (clock never read inside the verdict).
1119
+ """
1120
+ cfg = _config.ensure(cfg)
1121
+ nm = now_ms if now_ms is not None else int(time.time() * 1000)
1122
+ store_dir = _resolve_store(store, cfg)
1123
+ path = _resolve_memory_path(name_or_path, store_dir)
1124
+ ev = gather(path, cfg=cfg, now_ms=nm)
1125
+ return classify_recall(ev)
1126
+
1127
+
1128
+ def sweep(
1129
+ *,
1130
+ cfg: "Optional[_config.SubstrateConfig]" = None,
1131
+ store: Optional[str] = None,
1132
+ now_ms: Optional[int] = None,
1133
+ ) -> list[RecallVerdict]:
1134
+ """Re-verify EVERY memory in the store → a list of verdicts (STALE first).
1135
+
1136
+ The whole-store projection: `verify` fanned out over a memory store instead of
1137
+ a plan registry (docs/103 §5). Read-only. Ranked STALE → UNVERIFIABLE → FRESH
1138
+ so the rows that need attention lead.
1139
+ """
1140
+ cfg = _config.ensure(cfg)
1141
+ nm = now_ms if now_ms is not None else int(time.time() * 1000)
1142
+ store_dir = _resolve_store(store, cfg)
1143
+ out: list[RecallVerdict] = []
1144
+ for path in sorted(store_dir.glob("*.md")):
1145
+ if path.name == "MEMORY.md":
1146
+ continue # the index, not a memory record
1147
+ ev = gather(path, cfg=cfg, now_ms=nm)
1148
+ out.append(classify_recall(ev))
1149
+ rank = {Recall.RECALL_STALE: 0, Recall.RECALL_UNVERIFIABLE: 1, Recall.RECALL_FRESH: 2}
1150
+ out.sort(key=lambda v: (rank.get(v.verdict, 9), v.evidence.mem_name))
1151
+ return out
1152
+
1153
+
1154
+ # ---------------------------------------------------------------------------
1155
+ # The agent-facing gloss — lives in the DRIVER, not dos.interpret. RECALL_* is
1156
+ # driver vocabulary the kernel does not know; putting it in the kernel's
1157
+ # presentation seam would import a driver's closed set into the kernel layer.
1158
+ # Single-sourced here; both the CLI (`--explain`) and the MCP tool call it.
1159
+ # ---------------------------------------------------------------------------
1160
+
1161
+
1162
+ def interpret(verdict: dict) -> str:
1163
+ """One line on what a recall verdict means for the next action. PURE presentation."""
1164
+ v = str(verdict.get("verdict", "")).strip().upper()
1165
+ cul = verdict.get("culprit") or {}
1166
+ gt = f" ({cul.get('ground_truth')})" if isinstance(cul, dict) and cul.get("ground_truth") else ""
1167
+ if v == Recall.RECALL_FRESH.value:
1168
+ return ("FRESH — every checkable claim in this memory still confirms against the "
1169
+ "working tree, so its evidence is intact. Safe to rely on. (Still its own "
1170
+ "claim, not proof of good judgment — only that what it points at hasn't moved.)")
1171
+ if v == Recall.RECALL_STALE.value:
1172
+ return ("STALE — git/the working tree DISAGREES with this memory now: something it "
1173
+ "asserts is present/fixed/shipped no longer matches the code" + gt + ". Do NOT "
1174
+ "act on its instruction. Surface it as a stale claim to archive or update; "
1175
+ "never present it as fact.")
1176
+ if v == Recall.RECALL_UNVERIFIABLE.value:
1177
+ return ("UNVERIFIABLE — this memory names no concrete artifact to re-check (or it is a "
1178
+ "preference/positioning note), so it is an opinion, not a checkable fact. Fine "
1179
+ "to surface, but MARK it unfalsifiable — never dress it as something recall confirmed.")
1180
+ return ("UNKNOWN recall verdict — treat the memory as unverified: present it hedged, not as "
1181
+ "a fact, until a real check classifies it.")
1182
+
1183
+
1184
+ # ---------------------------------------------------------------------------
1185
+ # Routing (opt-in) — cross-post a non-FRESH verdict to `dos decisions` via the
1186
+ # EXISTING OP_REFUSE journal source + a host-declared RECALL_* reason token. NO
1187
+ # kernel edit: `decisions._from_lane_journal` already lifts any OP_REFUSE with a
1188
+ # reason_class into the queue. A recall refusal IS a refusal ("I decline to
1189
+ # surface this memory as fact"), so OP_REFUSE is the honest carrier — NOT a fake
1190
+ # OP_HALT (a stale memory is not a hung process). Never auto-deletes: it records
1191
+ # a PROPOSAL (archive or update), the record-and-propose stance (§6).
1192
+ # ---------------------------------------------------------------------------
1193
+
1194
+
1195
+ def route(verdicts: list[RecallVerdict], *, cfg: "Optional[_config.SubstrateConfig]" = None) -> int:
1196
+ """Append an OP_REFUSE for each non-FRESH verdict. Returns the count routed.
1197
+
1198
+ Requires the host to have DECLARED `RECALL_STALE` / `RECALL_UNVERIFIABLE` in
1199
+ `dos.toml [reasons]` (the `dos check-reason` discipline — never auto-declare an
1200
+ unknown token). An undeclared token raises, loudly, rather than emit drift.
1201
+ """
1202
+ from types import SimpleNamespace
1203
+
1204
+ from dos import lane_journal
1205
+
1206
+ cfg = _config.ensure(cfg)
1207
+ reg = cfg.reasons
1208
+ routed = 0
1209
+ for v in verdicts:
1210
+ if v.verdict is Recall.RECALL_FRESH:
1211
+ continue
1212
+ token = v.verdict.value
1213
+ if reg.get(token) is None:
1214
+ raise ValueError(
1215
+ f"cannot route: reason token {token!r} is not declared in this workspace. "
1216
+ f"Add it to dos.toml [reasons] (category STALE_CLAIM) before --route, the "
1217
+ f"same way every refusal reason is declared (dos man wedge).")
1218
+ slug = re.sub(r"[^a-z0-9]+", "-", v.evidence.mem_name.lower()).strip("-")
1219
+ carrier = SimpleNamespace(
1220
+ reason=f"{token}: {v.reason}",
1221
+ lane=f"memory:{slug}",
1222
+ )
1223
+ entry = lane_journal.refuse_entry(
1224
+ carrier,
1225
+ owner="memory-recall",
1226
+ run_id=v.evidence.mem_name,
1227
+ reason_class=token,
1228
+ )
1229
+ lane_journal.append(entry, cfg.paths.lane_journal)
1230
+ routed += 1
1231
+ return routed