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
dos/resume_evidence.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""resume-evidence — the boundary I/O for the resume axis (docs/107 §3.3, §5).
|
|
2
|
+
|
|
3
|
+
`resume.resume_plan` is a PURE verdict over `AncestryFacts` (which claimed SHAs are
|
|
4
|
+
in ancestry). SOMETHING has to gather those facts from git, and SOMETHING has to
|
|
5
|
+
mint a `STEP_VERIFIED` by re-checking a claimed SHA against the non-forgeable rung
|
|
6
|
+
(§5 req 2). That is this module — the resume axis's `git_delta`/`journal_delta`:
|
|
7
|
+
boundary I/O (subprocess + the served root) feeding the pure core, never inside the
|
|
8
|
+
verdict.
|
|
9
|
+
|
|
10
|
+
Two boundary jobs:
|
|
11
|
+
|
|
12
|
+
* **`gather_ancestry(...)`** — ask git which of a set of claimed SHAs are
|
|
13
|
+
reachable from HEAD on the served workspace, and freeze the answer into the
|
|
14
|
+
`AncestryFacts` the pure `resume_plan` consumes. The `liveness` evidence-gather
|
|
15
|
+
shape: the subprocess happens HERE, the already-decided membership is handed to
|
|
16
|
+
the classifier.
|
|
17
|
+
* **`verify_step(...)`** — the `STEP_VERIFIED` MINT (§5). Given a claimed
|
|
18
|
+
`(step_id, sha)`, decide whether it may become a minted belief: the SHA must be
|
|
19
|
+
**in ancestry** AND the commit must stand on the **non-forgeable rung** (it
|
|
20
|
+
touched ≥1 distinctive file — NOT an `--allow-empty` commit, NOT a
|
|
21
|
+
bookkeeping/release-bump-only footprint). A step that passes yields a
|
|
22
|
+
`STEP_VERIFIED` entry tagged `via="file-path"`; one that fails yields *nothing*
|
|
23
|
+
(it stays in the residual). **A forged `--allow-empty` step never reaches
|
|
24
|
+
`STEP_VERIFIED`** — the load-bearing Phase-3 guarantee.
|
|
25
|
+
|
|
26
|
+
The served root is passed EXPLICITLY (never the process-global active config), so a
|
|
27
|
+
long-lived caller fielding several workspaces — the MCP server, a fleet daemon —
|
|
28
|
+
gets the right tree (the `git_delta` discipline). Every failure mode degrades to
|
|
29
|
+
the SAFE direction: a SHA we cannot resolve is treated as NOT in ancestry / NOT
|
|
30
|
+
verifiable (fail-closed — a step we cannot prove landed must be redone, never
|
|
31
|
+
skipped), the opposite of `git_delta`'s permissive empty (because here the safe
|
|
32
|
+
direction for a *resume anchor* is "don't trust it," whereas for a *liveness
|
|
33
|
+
delta* it is "no progress observed").
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import subprocess
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Iterable
|
|
41
|
+
|
|
42
|
+
from dos import config as _config
|
|
43
|
+
from dos import intent_ledger as _il
|
|
44
|
+
from dos.intent_ledger import LedgerState
|
|
45
|
+
from dos.resume import AncestryFacts
|
|
46
|
+
|
|
47
|
+
_GIT_TIMEOUT_S = 15
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_ancestor(sha: str, *, root: Path | str) -> bool:
|
|
51
|
+
"""True iff `sha` is reachable from HEAD (an ancestor) on `root`. Fail-closed.
|
|
52
|
+
|
|
53
|
+
`git merge-base --is-ancestor <sha> HEAD` exits 0 iff `sha` is an ancestor of
|
|
54
|
+
HEAD, 1 iff not, and >1 on error (bad sha, not a git dir). We treat ONLY a
|
|
55
|
+
clean exit-0 as "in ancestry" — every other outcome (unknown sha, git missing,
|
|
56
|
+
timeout, non-git dir) is `False` (the safe direction for a resume anchor: a SHA
|
|
57
|
+
we cannot prove is reachable must not anchor a resume point). The opposite of
|
|
58
|
+
`git_delta`'s permissive-empty, because the safe failure here is "don't trust."
|
|
59
|
+
"""
|
|
60
|
+
s = (sha or "").strip()
|
|
61
|
+
if not s:
|
|
62
|
+
return False
|
|
63
|
+
try:
|
|
64
|
+
res = subprocess.run(
|
|
65
|
+
["git", "merge-base", "--is-ancestor", s, "HEAD"],
|
|
66
|
+
cwd=str(root),
|
|
67
|
+
capture_output=True,
|
|
68
|
+
text=True,
|
|
69
|
+
check=False,
|
|
70
|
+
timeout=_GIT_TIMEOUT_S,
|
|
71
|
+
)
|
|
72
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
73
|
+
return False
|
|
74
|
+
return res.returncode == 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _touched_files(sha: str, *, root: Path | str) -> set[str] | None:
|
|
78
|
+
"""The repo-relative paths commit `sha` touched on `root`, or None if unresolvable.
|
|
79
|
+
|
|
80
|
+
The explicit-root sibling of `oracle._git_touched_files` (which reads the
|
|
81
|
+
process-global active config). None means "could not resolve" (unknown sha,
|
|
82
|
+
shallow clone, git missing) — the caller treats it as NOT verifiable
|
|
83
|
+
(fail-closed). An EMPTY set means the commit touched NO files: an `--allow-empty`
|
|
84
|
+
commit — the exact forgeable case §5 req 2 forecloses.
|
|
85
|
+
"""
|
|
86
|
+
s = (sha or "").strip()
|
|
87
|
+
if not s:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
res = subprocess.run(
|
|
91
|
+
["git", "show", "--name-only", "--format=", s],
|
|
92
|
+
cwd=str(root),
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
encoding="utf-8",
|
|
96
|
+
errors="replace",
|
|
97
|
+
timeout=_GIT_TIMEOUT_S,
|
|
98
|
+
check=False,
|
|
99
|
+
)
|
|
100
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
101
|
+
return None
|
|
102
|
+
if res.returncode != 0:
|
|
103
|
+
return None
|
|
104
|
+
return {ln.strip().replace("\\", "/") for ln in res.stdout.splitlines() if ln.strip()}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def step_stands_on_nonforgeable_rung(
|
|
108
|
+
sha: str, *, root: Path | str,
|
|
109
|
+
region: "list[str] | tuple[str, ...] | None" = None,
|
|
110
|
+
touched_files=None,
|
|
111
|
+
is_ancestor=None,
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""True iff `sha` is a SAFE resume anchor — in ancestry AND its footprint is real (§5).
|
|
114
|
+
|
|
115
|
+
The §5-req-2 predicate, the heart of the mint. A claimed step's SHA earns a
|
|
116
|
+
`STEP_VERIFIED` ONLY when all hold:
|
|
117
|
+
|
|
118
|
+
1. **In ancestry.** The commit is reachable from HEAD (`_is_ancestor`). A
|
|
119
|
+
claimed SHA that is not in ancestry is a step the agent SAID it landed but
|
|
120
|
+
never did (or that was rewritten out) — fail-closed, not verified.
|
|
121
|
+
2. **Non-forgeable footprint.** The commit touched ≥1 real file. An
|
|
122
|
+
`--allow-empty` commit (the forgeable rung §5 names: an empty commit whose
|
|
123
|
+
SUBJECT names the step) touches NO files, so it fails this — exactly the
|
|
124
|
+
named attack.
|
|
125
|
+
3. **Footprint INTERSECTS the step's declared region (when one is declared).**
|
|
126
|
+
This closes the residual §5 hole the adversarial review found: requirement 2
|
|
127
|
+
alone defeats `--allow-empty` but NOT a forged record pointing at any *real,
|
|
128
|
+
unrelated* commit (the attacker needs only ANY ancestry SHA). When the step
|
|
129
|
+
declared a file region (a list of repo-relative globs in its INTENT), the
|
|
130
|
+
commit's touched-file set must OVERLAP that region — a commit that touched
|
|
131
|
+
only files OUTSIDE the step's region is not that step's work, even if it is a
|
|
132
|
+
real ancestry commit. Overlap reuses the kernel's ONE collision algebra
|
|
133
|
+
(`_tree.lane_trees_disjoint`, case-folded / leading-glob-aware) so there is
|
|
134
|
+
no second match definition. A step with NO declared region falls back to
|
|
135
|
+
requirement 2 only (the `--allow-empty` defense) — additive, so a region-less
|
|
136
|
+
ledger still gets real protection, just not region-pinned.
|
|
137
|
+
|
|
138
|
+
`touched_files` / `is_ancestor` are injectable (callable(sha)->set|None and
|
|
139
|
+
callable(sha)->bool) so the predicate is unit-testable WITHOUT git — the
|
|
140
|
+
`oracle`/`liveness` injection discipline. Production passes neither and the
|
|
141
|
+
git-backed defaults run against `root`.
|
|
142
|
+
"""
|
|
143
|
+
anc = is_ancestor or (lambda x: _is_ancestor(x, root=root))
|
|
144
|
+
touch = touched_files or (lambda x: _touched_files(x, root=root))
|
|
145
|
+
if not anc(sha):
|
|
146
|
+
return False
|
|
147
|
+
files = touch(sha)
|
|
148
|
+
if not files: # None (unresolvable) OR empty (--allow-empty) → not a safe anchor
|
|
149
|
+
return False
|
|
150
|
+
if region:
|
|
151
|
+
# The footprint must OVERLAP the step's declared region. The concrete touched
|
|
152
|
+
# files are treated as zero-wildcard "globs"; intersection is the negation of
|
|
153
|
+
# the kernel's disjointness verdict — one algebra, no drift.
|
|
154
|
+
from dos._tree import lane_trees_disjoint
|
|
155
|
+
if lane_trees_disjoint(list(files), list(region)):
|
|
156
|
+
return False # commit touched only files OUTSIDE the step's region
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def verify_step(
|
|
161
|
+
run_id: str,
|
|
162
|
+
step_id: str,
|
|
163
|
+
sha: str,
|
|
164
|
+
*,
|
|
165
|
+
cfg: "_config.SubstrateConfig | None" = None,
|
|
166
|
+
path: Path | None = None,
|
|
167
|
+
region: "list[str] | tuple[str, ...] | None" = None,
|
|
168
|
+
touched_files=None,
|
|
169
|
+
is_ancestor=None,
|
|
170
|
+
) -> dict | None:
|
|
171
|
+
"""Mint a `STEP_VERIFIED` for `(step_id, sha)` IFF it stands on the non-forgeable rung.
|
|
172
|
+
|
|
173
|
+
The CLI-boundary write the dispatch loop / `dos resume verify-step` calls after
|
|
174
|
+
an agent claims a step. Re-checks the claimed SHA against ancestry on the
|
|
175
|
+
non-forgeable rung (`step_stands_on_nonforgeable_rung`, incl. the `region`
|
|
176
|
+
intersection when one is declared); on success appends a `STEP_VERIFIED` entry
|
|
177
|
+
(tagged `via="file-path"`) to the run's ledger and returns the stamped entry; on
|
|
178
|
+
failure appends NOTHING and returns None (the step stays in the residual — a
|
|
179
|
+
forged/unverifiable claim is never minted into a belief).
|
|
180
|
+
|
|
181
|
+
This is the §5-req-2 guarantee in code: a step the agent merely CLAIMED — claimed
|
|
182
|
+
with an `--allow-empty` commit, or claimed against a real-but-unrelated commit
|
|
183
|
+
outside its declared region — cannot reach `STEP_VERIFIED`, so it can never become
|
|
184
|
+
a resume anchor that skips work that never happened.
|
|
185
|
+
"""
|
|
186
|
+
cfg = _config.ensure(cfg)
|
|
187
|
+
if not step_stands_on_nonforgeable_rung(
|
|
188
|
+
sha, root=cfg.paths.root, region=region,
|
|
189
|
+
touched_files=touched_files, is_ancestor=is_ancestor,
|
|
190
|
+
):
|
|
191
|
+
return None
|
|
192
|
+
entry = _il.step_verified_entry(step_id, sha, via="file-path")
|
|
193
|
+
return _il.append(run_id, entry, path=path, cfg=cfg)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def adjudicate_verified_steps(
|
|
197
|
+
state: LedgerState,
|
|
198
|
+
*,
|
|
199
|
+
root: Path | str,
|
|
200
|
+
touched_files=None,
|
|
201
|
+
is_ancestor=None,
|
|
202
|
+
) -> frozenset[str]:
|
|
203
|
+
"""Re-adjudicate, at READ time, which steps stand on the non-forgeable rung (§5).
|
|
204
|
+
|
|
205
|
+
The docs/107 §5 / docs/103 fix, and the heart of "the kernel doesn't believe the
|
|
206
|
+
agents." The intent ledger is written BY the distrusted run, so a stored
|
|
207
|
+
`STEP_VERIFIED` record is an UNAUTHENTICATED HINT — a malicious run can append its
|
|
208
|
+
own `STEP_VERIFIED {via: "file-path", sha: <any real ancestry commit>}` for a step
|
|
209
|
+
it never did. This RE-RUNS the non-forgeable footprint check
|
|
210
|
+
(`step_stands_on_nonforgeable_rung`) on each step's SHA, and returns ONLY the step
|
|
211
|
+
ids that pass — the authority the pure `resume_plan` trusts for "done"
|
|
212
|
+
(`AncestryFacts.steps_verified_at_read`), never the agent's say-so.
|
|
213
|
+
|
|
214
|
+
For each declared step we re-check the step's SHA — preferring the
|
|
215
|
+
`STEP_VERIFIED` record's recorded SHA, falling back to the `STEP_CLAIMED` SHA —
|
|
216
|
+
against `step_stands_on_nonforgeable_rung` (in ancestry AND a real, non-empty
|
|
217
|
+
footprint). A forged record pointing at an unrelated empty/foreign commit fails
|
|
218
|
+
the footprint re-check (an `--allow-empty` forgery touches nothing; a record with
|
|
219
|
+
no real SHA resolves to nothing) and is absent from the result, so the step is
|
|
220
|
+
redone. `touched_files`/`is_ancestor` are injectable for tests (no git needed).
|
|
221
|
+
|
|
222
|
+
NOTE on the residual hardening: this re-check confirms the commit is a real
|
|
223
|
+
artefact in ancestry; a future tightening (the review's strongest suggestion)
|
|
224
|
+
would also require the footprint to INTERSECT the step's declared file region, so
|
|
225
|
+
a real-but-unrelated commit can't anchor a step. That needs per-step declared
|
|
226
|
+
regions the ledger doesn't yet carry; the non-empty-footprint + ancestry re-check
|
|
227
|
+
already defeats the `--allow-empty` forgery the §5 attack names, and a real commit
|
|
228
|
+
falsely claimed for a step is still strictly safer than trusting the stored record.
|
|
229
|
+
"""
|
|
230
|
+
out: set[str] = set()
|
|
231
|
+
for sid in state.declared_steps:
|
|
232
|
+
vs = state.verified.get(sid)
|
|
233
|
+
sha = (vs.sha if vs and vs.sha else state.claimed.get(sid, ""))
|
|
234
|
+
if not sha:
|
|
235
|
+
continue
|
|
236
|
+
region = state.step_regions.get(sid) # the step's declared file region (or None)
|
|
237
|
+
if step_stands_on_nonforgeable_rung(
|
|
238
|
+
sha, root=root, region=region,
|
|
239
|
+
touched_files=touched_files, is_ancestor=is_ancestor,
|
|
240
|
+
):
|
|
241
|
+
out.add(sid)
|
|
242
|
+
return frozenset(out)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def gather_ancestry(
|
|
246
|
+
state: LedgerState,
|
|
247
|
+
*,
|
|
248
|
+
cfg: "_config.SubstrateConfig | None" = None,
|
|
249
|
+
extra_shas: Iterable[str] = (),
|
|
250
|
+
lane_advanced_past_resume: bool = False,
|
|
251
|
+
is_ancestor=None,
|
|
252
|
+
touched_files=None,
|
|
253
|
+
head_sha: str = "",
|
|
254
|
+
) -> AncestryFacts:
|
|
255
|
+
"""Freeze the RE-ADJUDICATED ancestry facts `resume_plan` needs (§3.3, §5).
|
|
256
|
+
|
|
257
|
+
The boundary evidence-gather (the `liveness` CLI shape). Two reads, both at this
|
|
258
|
+
boundary, never inside the pure verdict:
|
|
259
|
+
|
|
260
|
+
1. **Ancestry membership** — collect every SHA the ledger mentions (claimed +
|
|
261
|
+
verified + start + `extra_shas`) and ask git which are ancestors of HEAD on
|
|
262
|
+
the served workspace (`_is_ancestor`, explicit root).
|
|
263
|
+
2. **Step re-adjudication (the §5 fix)** — RE-RUN the non-forgeable footprint
|
|
264
|
+
check on each declared step (`adjudicate_verified_steps`), so the pure
|
|
265
|
+
verdict's "done" set comes from a fresh git re-check, NOT from the
|
|
266
|
+
agent-written `STEP_VERIFIED` record. A forged record is rejected here.
|
|
267
|
+
|
|
268
|
+
`lane_advanced_past_resume` is computed by the CALLER (it knows the lane's tree
|
|
269
|
+
and the commits since the resume point); the verdict only consumes it.
|
|
270
|
+
`is_ancestor`/`touched_files` are injectable for tests (the `oracle` injection
|
|
271
|
+
discipline). The result is handed verbatim to the pure `resume.resume_plan`.
|
|
272
|
+
"""
|
|
273
|
+
cfg = _config.ensure(cfg)
|
|
274
|
+
root = cfg.paths.root
|
|
275
|
+
anc = is_ancestor or (lambda x: _is_ancestor(x, root=root))
|
|
276
|
+
|
|
277
|
+
candidates: set[str] = set()
|
|
278
|
+
if state.start_sha:
|
|
279
|
+
candidates.add(state.start_sha)
|
|
280
|
+
candidates.update(s for s in state.claimed.values() if s)
|
|
281
|
+
candidates.update(vs.sha for vs in state.verified.values() if vs.sha)
|
|
282
|
+
candidates.update(s for s in extra_shas if s)
|
|
283
|
+
|
|
284
|
+
in_ancestry = frozenset(s for s in candidates if anc(s))
|
|
285
|
+
verified_at_read = adjudicate_verified_steps(
|
|
286
|
+
state, root=root, touched_files=touched_files, is_ancestor=is_ancestor,
|
|
287
|
+
)
|
|
288
|
+
return AncestryFacts(
|
|
289
|
+
shas_in_ancestry=in_ancestry,
|
|
290
|
+
steps_verified_at_read=verified_at_read,
|
|
291
|
+
head_sha=head_sha,
|
|
292
|
+
lane_advanced_past_resume=lane_advanced_past_resume,
|
|
293
|
+
)
|
dos/retention.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""The retention policy — how much DOS scratch to keep, *as data*.
|
|
2
|
+
|
|
3
|
+
This is the direct answer to the question [`docs/94 §7`](../docs/94_checkpoints-and-recovery-from-slop.md)
|
|
4
|
+
left open and [`docs/106 §3.3`](../docs/106_garbage-collection-and-the-reachability-verdict.md)
|
|
5
|
+
specified: **retention is policy, so it is declared per-workspace and carried on
|
|
6
|
+
the config seam as data** — the `docs/HACKING.md` closed-enum→declared-data
|
|
7
|
+
pattern that already governs `[reasons]` and `[stamp]`.
|
|
8
|
+
|
|
9
|
+
Why a seam and not a constant
|
|
10
|
+
=============================
|
|
11
|
+
|
|
12
|
+
DOS has the garbage-collection *problem* in two shapes the operator feels (the
|
|
13
|
+
append-only lane journal that grows without bound, and the per-project `.dos/`
|
|
14
|
+
scratch — run-dirs, verdict sidecars, **audit reports** — that nobody auto-reaps).
|
|
15
|
+
docs/106 argues the collector itself is NOT new machinery: `replay`+`compact` is
|
|
16
|
+
already a correct mark-and-copy collector, missing only a *trigger*, a
|
|
17
|
+
*generational split*, and a *safe-point*. The trigger needs a *threshold*, and a
|
|
18
|
+
threshold is a number a host should be able to set (a host on a tiny disk keeps
|
|
19
|
+
little; a host that wants a long forensic tail keeps lots). That number is policy,
|
|
20
|
+
so it rides `SubstrateConfig` next to `.reasons`/`.stamp`/`.overlap_ratio_max`,
|
|
21
|
+
declarable in `dos.toml [retention]`, with a **generic default that is never zero**.
|
|
22
|
+
|
|
23
|
+
The floor is NOT these numbers
|
|
24
|
+
==============================
|
|
25
|
+
|
|
26
|
+
The load-bearing safety floor (docs/106 §5) is *reachability*, enforced by the
|
|
27
|
+
collector independently of any retention count: **a live lease is never collected,
|
|
28
|
+
ever**, regardless of how small the caps are set. A misconfigured `[retention]`
|
|
29
|
+
may keep *too much* (waste disk) — `False`-keep is tolerable — but it must never
|
|
30
|
+
cause a `False`-collect of state the kernel still needs. So this module carries
|
|
31
|
+
only the *recency / size* knobs; the "never reap a live lease" invariant lives in
|
|
32
|
+
the collector (the journal `compact` fold and the reaper's liveness gate), not
|
|
33
|
+
here. These numbers tune *how aggressively* to collect the already-collectable;
|
|
34
|
+
they cannot loosen *what* is collectable.
|
|
35
|
+
|
|
36
|
+
The shape
|
|
37
|
+
=========
|
|
38
|
+
|
|
39
|
+
A `RetentionPolicy` is the closed set of size/recency caps, plus one pure
|
|
40
|
+
predicate the kernel exposes for the trigger:
|
|
41
|
+
|
|
42
|
+
* ``should_compact(entries, policy, *, now_ms)`` — `True` when the journal has
|
|
43
|
+
more than ``journal_max_entries`` lines OR its oldest non-checkpoint entry is
|
|
44
|
+
older than ``journal_max_age_days``. Reads ONLY the materialized list
|
|
45
|
+
`read_all` already produced (no extra I/O) — the docs/106 §3.2 threshold,
|
|
46
|
+
pure, so a driver fires it on a cadence the way `dos watch` fires
|
|
47
|
+
`liveness.classify`.
|
|
48
|
+
|
|
49
|
+
The *reapers* that consume the keep-last-N caps (run-dirs / verdicts / audits)
|
|
50
|
+
live in the helper/driver layer (they do filesystem I/O — `os.scandir`, `unlink`),
|
|
51
|
+
never in this pure leaf; this module only declares the numbers and the one pure
|
|
52
|
+
threshold. That is the same kernel/driver split as `overlap_policy` (the seam is
|
|
53
|
+
data; the scorer that does work is a driver) — I/O at the boundary, data to the
|
|
54
|
+
pure core.
|
|
55
|
+
|
|
56
|
+
Two named constants ship in the package:
|
|
57
|
+
|
|
58
|
+
* ``GENERIC_RETENTION`` — the generic default: generous caps, never zero. This
|
|
59
|
+
is what every workspace gets out of the box (the floor is "never reap a live
|
|
60
|
+
lease," which the collector enforces independently of these numbers).
|
|
61
|
+
* ``UNBOUNDED_RETENTION`` — every cap effectively infinite + ``should_compact``
|
|
62
|
+
always `False`. The opt-out for a host that wants today's keep-everything
|
|
63
|
+
behaviour explicitly (and the byte-faithful baseline for any consumer built
|
|
64
|
+
before this seam existed).
|
|
65
|
+
|
|
66
|
+
Pure stdlib — no third-party imports, no I/O (the `load_from_toml` half opens the
|
|
67
|
+
toml file at the call boundary, exactly as `stamp.load_from_toml` does, and is the
|
|
68
|
+
only function here that touches the disk). Leaf module: nothing in the kernel
|
|
69
|
+
imports *down* into a driver to use it.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
from dataclasses import dataclass, replace
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
from typing import Any, Mapping
|
|
77
|
+
|
|
78
|
+
# A day in milliseconds — the journal `ts` rung and `should_compact` both speak ms
|
|
79
|
+
# (the same unit `journal_delta`/`liveness` use), so the age cap is converted once
|
|
80
|
+
# here rather than scattering `* 86_400_000` at the call sites.
|
|
81
|
+
_MS_PER_DAY = 86_400_000
|
|
82
|
+
|
|
83
|
+
# A sentinel "no cap" for the keep-last-N / max-entries knobs. `None` means "keep
|
|
84
|
+
# everything on this axis" — distinct from `0` (which would mean "keep nothing",
|
|
85
|
+
# a foot-gun the floor forbids but the data type should still be able to express
|
|
86
|
+
# for an explicit opt-out). The predicate treats `None` as "this rung never fires."
|
|
87
|
+
NO_CAP: None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class RetentionPolicy:
|
|
92
|
+
"""The per-workspace scratch-retention caps, as immutable data.
|
|
93
|
+
|
|
94
|
+
Every field is optional-with-a-default; a host overrides only what it cares
|
|
95
|
+
about in `dos.toml [retention]`. ``None`` on any cap means "unbounded on this
|
|
96
|
+
axis" (keep everything) — NOT zero. The caps are size/recency tuning; the
|
|
97
|
+
"never collect a live lease" floor is the collector's, not this object's.
|
|
98
|
+
|
|
99
|
+
* ``journal_max_entries`` — compact the WAL when it grows past this many
|
|
100
|
+
lines. ``None`` = never compact by size. (docs/106 §3.2)
|
|
101
|
+
* ``journal_max_age_days`` — …or when its oldest non-checkpoint entry is
|
|
102
|
+
older than this. ``None`` = never compact by age. (IDE checkpointers
|
|
103
|
+
persist ~30d — the docs/94 §7 calibration anchor.)
|
|
104
|
+
* ``runs_keep_last`` — reap `.dos/runs/` run-dirs beyond the newest N
|
|
105
|
+
(liveness-gated by the reaper: a live run is kept even if old). ``None`` =
|
|
106
|
+
keep all run-dirs.
|
|
107
|
+
* ``verdicts_keep_last`` — reap `.dos/**/.verdict-*.json` beyond the newest
|
|
108
|
+
N. A verdict is a point-in-time artifact with no liveness, so recency is
|
|
109
|
+
the honest rule. ``None`` = keep all verdicts.
|
|
110
|
+
* ``audits_keep_last`` — reap `.dos/audits/trajectory-audit-*` beyond the
|
|
111
|
+
newest N. The scratch class the 2026-06-03 trajectory audit surfaced (NOT
|
|
112
|
+
in docs/106 §1.2's original table — the audit's own output is itself an
|
|
113
|
+
unbounded-growth source). Same recency rule as verdicts. ``None`` = keep
|
|
114
|
+
all audit reports.
|
|
115
|
+
* ``projections_compact`` — when ``True``, let `dos reindex` *rewrite* the
|
|
116
|
+
central `~/.dos` projections to their live digest, not only append/prune.
|
|
117
|
+
(docs/106 §3.4)
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
journal_max_entries: int | None = 5000
|
|
121
|
+
journal_max_age_days: float | None = 30.0
|
|
122
|
+
runs_keep_last: int | None = 200
|
|
123
|
+
verdicts_keep_last: int | None = 500
|
|
124
|
+
audits_keep_last: int | None = 200
|
|
125
|
+
projections_compact: bool = True
|
|
126
|
+
|
|
127
|
+
def with_overrides(self, **changes: Any) -> "RetentionPolicy":
|
|
128
|
+
"""Return a copy with the named caps replaced (thin `dataclasses.replace`)."""
|
|
129
|
+
return replace(self, **changes)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# The generic default — generous, never zero. Every workspace gets this out of the
|
|
133
|
+
# box. The numbers are deliberately provisional (docs/106 §6: "generous-and-
|
|
134
|
+
# provisional, floored on 'never collect a live lease,' with the bench as the
|
|
135
|
+
# eventual evidence source"); the floor that makes them SAFE is the collector's
|
|
136
|
+
# reachability gate, not these values.
|
|
137
|
+
GENERIC_RETENTION = RetentionPolicy()
|
|
138
|
+
|
|
139
|
+
# The explicit keep-everything opt-out: every cap unbounded, `should_compact`
|
|
140
|
+
# always False. The byte-faithful "no retention seam" baseline — a consumer that
|
|
141
|
+
# installs this behaves exactly as the kernel did before `[retention]` existed.
|
|
142
|
+
UNBOUNDED_RETENTION = RetentionPolicy(
|
|
143
|
+
journal_max_entries=NO_CAP,
|
|
144
|
+
journal_max_age_days=NO_CAP,
|
|
145
|
+
runs_keep_last=NO_CAP,
|
|
146
|
+
verdicts_keep_last=NO_CAP,
|
|
147
|
+
audits_keep_last=NO_CAP,
|
|
148
|
+
projections_compact=False,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def should_compact(
|
|
153
|
+
entries: list[Mapping[str, Any]],
|
|
154
|
+
policy: RetentionPolicy = GENERIC_RETENTION,
|
|
155
|
+
*,
|
|
156
|
+
now_ms: int,
|
|
157
|
+
) -> bool:
|
|
158
|
+
"""The pure auto-compaction threshold (docs/106 §3.2).
|
|
159
|
+
|
|
160
|
+
`True` when the journal is over ``journal_max_entries`` lines OR its oldest
|
|
161
|
+
non-checkpoint entry is older than ``journal_max_age_days``. Reads ONLY the
|
|
162
|
+
already-materialized ``entries`` list (the one `lane_journal.read_all`
|
|
163
|
+
produces) plus the supplied ``now_ms`` clock — no I/O, so a driver fires it on
|
|
164
|
+
a cadence the way `dos watch` fires `liveness.classify`. The clock is HANDED
|
|
165
|
+
in (the way a pure verdict is handed a clock), never read here.
|
|
166
|
+
|
|
167
|
+
A cap of ``None`` makes its rung never fire. Both caps ``None`` (or an empty
|
|
168
|
+
journal) ⇒ `False`. The predicate is monotone in journal size: it can only ask
|
|
169
|
+
to collect *more* as the log grows, never less — it never blocks a compaction
|
|
170
|
+
the operator triggers by hand, it only decides when one should fire on its own.
|
|
171
|
+
|
|
172
|
+
Note this is a *should-we* signal, not a *may-we* safety check: the SAFE point
|
|
173
|
+
to actually run `compact` (the beat-anchor caveat, docs/106 §3.2(ii)) is the
|
|
174
|
+
collector/driver's concern. A `True` here means "the journal is big/old enough
|
|
175
|
+
to be worth collecting," not "it is safe to collect this instant."
|
|
176
|
+
"""
|
|
177
|
+
n = len(entries)
|
|
178
|
+
if not n:
|
|
179
|
+
return False
|
|
180
|
+
max_entries = policy.journal_max_entries
|
|
181
|
+
if max_entries is not None and n > max_entries:
|
|
182
|
+
return True
|
|
183
|
+
max_age_days = policy.journal_max_age_days
|
|
184
|
+
if max_age_days is not None:
|
|
185
|
+
oldest = _oldest_non_checkpoint_ms(entries)
|
|
186
|
+
if oldest is not None and (now_ms - oldest) > max_age_days * _MS_PER_DAY:
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def plan_reap(
|
|
192
|
+
entries: list[tuple[str, float]], keep_last: int | None
|
|
193
|
+
) -> list[str]:
|
|
194
|
+
"""The pure keep-last-N reaper plan: which entries to DROP by recency.
|
|
195
|
+
|
|
196
|
+
``entries`` is ``[(identifier, mtime_seconds), ...]`` — the reaper gathers it
|
|
197
|
+
at the I/O boundary (`os.scandir`), this function decides. Keeps the ``keep_last``
|
|
198
|
+
newest by ``mtime`` (ties broken by identifier, descending, so the order is
|
|
199
|
+
total and deterministic) and returns the identifiers to drop, NEWEST-DROPPED
|
|
200
|
+
first is NOT guaranteed — the returned list is the drop SET as a list; callers
|
|
201
|
+
that want a stable display sort it. ``keep_last=None`` (unbounded) ⇒ drop
|
|
202
|
+
nothing. ``keep_last=0`` ⇒ drop everything (an explicit "keep none"; the
|
|
203
|
+
collector's reachability floor still spares anything live, but that gate is the
|
|
204
|
+
I/O reaper's, applied BEFORE this — see `home.reap_scratch`).
|
|
205
|
+
|
|
206
|
+
Pure: no I/O, no clock. This is the recency half of docs/106 §3.4 ("a verdict
|
|
207
|
+
is a point-in-time artifact with no liveness, so recency is the honest rule"),
|
|
208
|
+
factored out of the filesystem walk so it is unit-testable in isolation — the
|
|
209
|
+
same kernel/driver split as `should_compact` (pure threshold) vs the driver
|
|
210
|
+
that fires `compact`.
|
|
211
|
+
"""
|
|
212
|
+
if keep_last is None:
|
|
213
|
+
return []
|
|
214
|
+
if keep_last <= 0:
|
|
215
|
+
return [ident for ident, _ in entries]
|
|
216
|
+
# Newest first: primary key mtime desc, secondary identifier desc (total order).
|
|
217
|
+
ordered = sorted(entries, key=lambda em: (em[1], em[0]), reverse=True)
|
|
218
|
+
return [ident for ident, _ in ordered[keep_last:]]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _oldest_non_checkpoint_ms(entries: list[Mapping[str, Any]]) -> int | None:
|
|
222
|
+
"""The smallest ``ts`` over non-CHECKPOINT entries, or None if none carry one.
|
|
223
|
+
|
|
224
|
+
Checkpoints are excluded because a CHECKPOINT line is the *snapshot* a prior
|
|
225
|
+
compaction wrote, not original history — counting its age would make a
|
|
226
|
+
freshly-compacted journal look stale and re-trigger immediately (a compaction
|
|
227
|
+
loop). A line with no parseable integer ``ts`` is skipped (the same forgiving
|
|
228
|
+
posture `journal_delta` takes on a malformed beat) rather than crashing the
|
|
229
|
+
threshold.
|
|
230
|
+
"""
|
|
231
|
+
oldest: int | None = None
|
|
232
|
+
for e in entries:
|
|
233
|
+
if e.get("op") == "CHECKPOINT":
|
|
234
|
+
continue
|
|
235
|
+
ts = e.get("ts")
|
|
236
|
+
if not isinstance(ts, int):
|
|
237
|
+
continue
|
|
238
|
+
if oldest is None or ts < oldest:
|
|
239
|
+
oldest = ts
|
|
240
|
+
return oldest
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# The `dos.toml [retention]` reader — the data attachment, file I/O at the boundary.
|
|
245
|
+
# Mirrors `stamp.load_from_toml` / `config.load_overlap_from_toml` in shape.
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
# The cap fields that take an int|None. `journal_max_age_days` is float|None and is
|
|
249
|
+
# coerced separately; `projections_compact` is a bool. Splitting them keeps the
|
|
250
|
+
# per-field coercion honest (an int cap rejects 1.5; the age accepts it).
|
|
251
|
+
_INT_CAP_KEYS = frozenset({
|
|
252
|
+
"journal_max_entries", "runs_keep_last", "verdicts_keep_last", "audits_keep_last",
|
|
253
|
+
})
|
|
254
|
+
_FLOAT_CAP_KEYS = frozenset({"journal_max_age_days"})
|
|
255
|
+
_BOOL_KEYS = frozenset({"projections_compact"})
|
|
256
|
+
_ALLOWED_KEYS = _INT_CAP_KEYS | _FLOAT_CAP_KEYS | _BOOL_KEYS
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def policy_from_table(
|
|
260
|
+
table: Mapping[str, Any], *, base: RetentionPolicy = GENERIC_RETENTION
|
|
261
|
+
) -> RetentionPolicy:
|
|
262
|
+
"""Build a `RetentionPolicy` from a parsed `[retention]` table, over ``base``.
|
|
263
|
+
|
|
264
|
+
A present key OVERRIDES the corresponding base field; an absent key inherits
|
|
265
|
+
it. An UNKNOWN key raises `ValueError` (a typo'd cap — ``runs_keep_lsat`` —
|
|
266
|
+
is a host mistake worth surfacing loudly, the same posture every other seam
|
|
267
|
+
reader takes). A cap may be set to the TOML value ``-1`` or the string
|
|
268
|
+
``"none"`` to mean "unbounded on this axis" (the `None` sentinel — TOML has no
|
|
269
|
+
null literal, so we accept those two spellings); any other negative is a
|
|
270
|
+
mistake and raises. ``0`` is accepted verbatim (an explicit "keep nothing" the
|
|
271
|
+
collector's reachability floor still overrides for live state).
|
|
272
|
+
"""
|
|
273
|
+
if not isinstance(table, Mapping):
|
|
274
|
+
raise ValueError(f"[retention] must be a table, got {type(table).__name__}")
|
|
275
|
+
unknown = set(table) - _ALLOWED_KEYS
|
|
276
|
+
if unknown:
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"unknown [retention] key(s): {', '.join(sorted(unknown))} "
|
|
279
|
+
f"(allowed: {', '.join(sorted(_ALLOWED_KEYS))})"
|
|
280
|
+
)
|
|
281
|
+
changes: dict[str, Any] = {}
|
|
282
|
+
for key in _INT_CAP_KEYS & set(table):
|
|
283
|
+
changes[key] = _coerce_cap(table[key], key, integral=True)
|
|
284
|
+
for key in _FLOAT_CAP_KEYS & set(table):
|
|
285
|
+
changes[key] = _coerce_cap(table[key], key, integral=False)
|
|
286
|
+
for key in _BOOL_KEYS & set(table):
|
|
287
|
+
raw = table[key]
|
|
288
|
+
if not isinstance(raw, bool):
|
|
289
|
+
raise ValueError(f"[retention] {key} must be a boolean, got {raw!r}")
|
|
290
|
+
changes[key] = raw
|
|
291
|
+
return replace(base, **changes)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _coerce_cap(raw: Any, key: str, *, integral: bool) -> int | float | None:
|
|
295
|
+
"""Coerce one cap value: a number, or the `None`-sentinel spellings.
|
|
296
|
+
|
|
297
|
+
TOML has no null, so ``-1`` and the (case-insensitive) string ``"none"`` both
|
|
298
|
+
mean "unbounded on this axis." A non-negative number is taken as the cap; any
|
|
299
|
+
other negative, or a non-numeric non-``"none"`` value, raises.
|
|
300
|
+
"""
|
|
301
|
+
if isinstance(raw, str) and raw.strip().lower() == "none":
|
|
302
|
+
return None
|
|
303
|
+
if isinstance(raw, bool): # bool is an int subclass — reject it for a cap
|
|
304
|
+
raise ValueError(f"[retention] {key} must be a number or \"none\", got {raw!r}")
|
|
305
|
+
if not isinstance(raw, (int, float)):
|
|
306
|
+
raise ValueError(f"[retention] {key} must be a number or \"none\", got {raw!r}")
|
|
307
|
+
if raw == -1:
|
|
308
|
+
return None
|
|
309
|
+
if raw < 0:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"[retention] {key} must be >= 0 (or -1 / \"none\" for unbounded), got {raw!r}"
|
|
312
|
+
)
|
|
313
|
+
return int(raw) if integral else float(raw)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def load_from_toml(
|
|
317
|
+
path: Path | str, *, base: RetentionPolicy = GENERIC_RETENTION
|
|
318
|
+
) -> RetentionPolicy:
|
|
319
|
+
"""Build a `RetentionPolicy` from a `dos.toml`'s `[retention]` table.
|
|
320
|
+
|
|
321
|
+
Returns ``base`` unchanged when the file is absent, has no `[retention]` table,
|
|
322
|
+
or `tomllib` is unavailable (Python < 3.11 with no `tomli`) — the declarative
|
|
323
|
+
path is purely additive, so a missing/empty config degrades to the supplied
|
|
324
|
+
base, never an error. A *present but malformed* `[retention]` table raises
|
|
325
|
+
(`policy_from_table`), surfaced by `load_workspace_config`'s warn-and-fall-back.
|
|
326
|
+
Mirrors `stamp.load_from_toml` / `reasons.load_from_toml` exactly.
|
|
327
|
+
"""
|
|
328
|
+
p = Path(path)
|
|
329
|
+
if not p.exists():
|
|
330
|
+
return base
|
|
331
|
+
try:
|
|
332
|
+
import tomllib # py3.11+
|
|
333
|
+
except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
|
|
334
|
+
try:
|
|
335
|
+
import tomli as tomllib # type: ignore
|
|
336
|
+
except ModuleNotFoundError:
|
|
337
|
+
return base
|
|
338
|
+
# `utf-8-sig` strips a UTF-8 BOM (PowerShell's `utf8` writes one) — the same
|
|
339
|
+
# fix as `config._load_toml_table` / `stamp.load_from_toml`.
|
|
340
|
+
data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
|
|
341
|
+
table = data.get("retention")
|
|
342
|
+
if not isinstance(table, dict) or not table:
|
|
343
|
+
return base
|
|
344
|
+
return policy_from_table(table, base=base)
|