dos-kernel 0.22.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. dos_mcp/server.py +779 -0
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)