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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- 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
|