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.py ADDED
@@ -0,0 +1,578 @@
1
+ """resume — the third ARIES phase: replay-to-the-last-verified-point, then PROPOSE re-dispatch (docs/107 §2,§3.3,§5).
2
+
3
+ > **A crashed or paused run is a stale self-report about unfinished work, so
4
+ > resuming it is the distrust primitive pointed at the run's own intent — record
5
+ > what it *meant* to do (distrusted), re-verify how far the *fossils* say it got,
6
+ > fold the difference into a residual and a non-forgeable re-entry SHA, and
7
+ > *propose* — never perform — the continuation.**
8
+
9
+ `lane_journal.replay` is the ARIES **redo** fold; `94 §3.2` named that DOS does not
10
+ own **undo**. Resume is the *third* phase the WAL framing implies: **analysis →
11
+ redo → CONTINUE**. This module is the pure verdict over a reconstructed intent —
12
+ `liveness.classify`'s sibling, field-for-field:
13
+
14
+ arbiter.arbitrate (request, live_leases, config) -> decision
15
+ liveness.classify (ProgressEvidence, policy) -> LivenessVerdict
16
+ resume.resume_plan (LedgerState, AncestryFacts, policy) -> ResumePlan
17
+ ^ THIS module
18
+
19
+ All I/O — reading the ledger, asking git which claimed SHAs are in ancestry,
20
+ re-verifying a step on the non-forgeable rung — happens in the CALLER (the `dos
21
+ resume` CLI's evidence-gather), exactly as `liveness`'s git/journal reads happen
22
+ outside `classify`. `resume_plan` makes no subprocess, file, or clock call: the
23
+ ancestry membership is a field on `AncestryFacts`, never re-derived inside the
24
+ verdict. That is what lets the whole recovery LOGIC be replay-tested on frozen
25
+ ledger + frozen ancestry fixtures — no live multi-minute crashed run needed
26
+ (`docs/107 §3.3`, the `loop_decide`/`journal_delta` design value, restated for the
27
+ resume axis).
28
+
29
+ The belief/effect line (`docs/107 §2`, the `94 §2` checkpoint/restore split):
30
+
31
+ * **A resume point is a BELIEF the kernel may MINT** — "run R got verifiably as
32
+ far as SHA `abc123` (steps 1–2 of intent I); the residual is steps 3–5." An
33
+ epistemic claim over unforgeable git artifacts. The kernel is allowed to
34
+ produce it; `resume_plan` is where it does.
35
+ * **A resume is an EFFECT the kernel may only PROPOSE.** Re-spawning, re-acquiring
36
+ the lane, handing the worker the residual — those mutate the world. They live
37
+ behind a human (`dos resume --plan` prints the residual + re-entry SHA and
38
+ exits; a `decisions` emit-and-exit row prints the re-dispatch command) or a
39
+ driver. **The kernel never re-spawns and never re-runs the work** (the §8
40
+ non-goal, the `99` advisory-only floor on the resume axis).
41
+
42
+ The safety property (`docs/107 §5`, the load-bearing "*safely*"): the resume point
43
+ stands on the most-accountable fossil (git ancestry), NEVER on the ledger's
44
+ self-reported "I finished step 3." You resume *from the last committed, verified
45
+ SHA*, never the last *claimed* step — so re-execution re-does at most the
46
+ uncommitted tail, which is idempotent by construction (it produced no durable
47
+ effect, or it would be a commit). The dead run's `STEP_CLAIMED` records are treated
48
+ exactly as `103` treats a recalled memory: a prior commitment, re-verified against
49
+ ground truth at read time, never replayed as present fact.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import enum
55
+ from dataclasses import dataclass
56
+
57
+ from dos.intent_ledger import LedgerState
58
+
59
+
60
+ class Resume(str, enum.Enum):
61
+ """The typed resume verdict — four states, mutually exclusive (§3.3).
62
+
63
+ `str`-valued so it round-trips a `--json` token / exit-code map without a
64
+ lookup table (the `Liveness` / `gate_classify.Verdict` idiom).
65
+ """
66
+
67
+ RESUMABLE = "RESUMABLE" # clean resume-point SHA + non-empty residual: continue from here
68
+ COMPLETE = "COMPLETE" # residual empty — every declared step verified; nothing to resume
69
+ DIVERGED = "DIVERGED" # ground truth moved past the resume point — REFUSE, raise a decision
70
+ UNRESUMABLE = "UNRESUMABLE" # no INTENT / corrupt-past-fold / too-new schema: don't guess a residual
71
+
72
+ def __str__(self) -> str: # pragma: no cover - trivial
73
+ return self.value
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class ResumePolicy:
78
+ """The knobs that shape the resume verdict — policy, not mechanism.
79
+
80
+ The `LivenessPolicy` split: mechanism is the kernel, thresholds are config.
81
+ Defaults are GENERIC (no host tuning); a workspace could declare its own in
82
+ `dos.toml [resume]` (a future seam, like `[liveness]`).
83
+
84
+ require_nonforgeable_rung — when True (the §5 req-2 default), a step's
85
+ `STEP_VERIFIED` must have been minted on a NON-forgeable rung (`via` is
86
+ `file-path`/`registry`, never the forgeable subject-grep). A step whose
87
+ verified `via` is forgeable/empty is NOT counted toward the resume point
88
+ (it stays in the residual): a resume point built on a forgeable verdict is
89
+ worse than none — it would skip work that never really happened. The mint
90
+ (`intent_ledger.step_verified_entry`) is supposed to refuse a forgeable
91
+ rung already; this is the verdict-side belt to that suspenders.
92
+ treat_untagged_as_corrupt — when True, an UNTAGGED/legacy ledger record makes
93
+ the fold unsound (UNRESUMABLE). Default False: a legacy record is folded
94
+ permissively (the tolerant side of the `durable_schema.UNTAGGED` contract);
95
+ only an UNREADABLE_NEWER (too-new schema) forces UNRESUMABLE regardless.
96
+ """
97
+
98
+ require_nonforgeable_rung: bool = True
99
+ treat_untagged_as_corrupt: bool = False
100
+
101
+
102
+ DEFAULT_POLICY = ResumePolicy()
103
+
104
+ # The verify rungs a step may stand on (§5 req 2). The forgeable subject-grep rung
105
+ # (`grep`/`subject`/`body-mention`/`direct`/… — anything that matches a phase token
106
+ # in commit PROSE) is DEFEATED by `git commit --allow-empty` (an empty commit whose
107
+ # subject names the step), so it is NOT a safe resume anchor. The non-forgeable
108
+ # rungs prove an ARTEFACT moved: the registry row (a ship-time stamp) or the
109
+ # file-path / diff-content overlap. These are the only `via` values
110
+ # `resume_evidence.verify_step` can MINT (it always writes `file-path`); the set is
111
+ # an ALLOW-LIST (fail-closed: an unknown `via` is treated as forgeable).
112
+ #
113
+ # IMPORTANT (the docs/107 §5 / docs/103 fix): `via` is an UNAUTHENTICATED HINT, not
114
+ # authority. The intent ledger is written BY the distrusted run, so a malicious run
115
+ # can append its own `STEP_VERIFIED {via: "file-path"}` for a step it never did.
116
+ # The kernel therefore RE-ADJUDICATES the non-forgeable rung at READ time
117
+ # (`resume_evidence.adjudicate_verified_steps` re-runs the footprint check on the
118
+ # claimed SHA) and the pure verdict consumes that re-adjudicated set
119
+ # (`AncestryFacts.steps_verified_at_read`), NEVER the stored `via`. `via` survives
120
+ # only as a forensic label + the policy belt-to-suspenders in `_verified_on_safe_rung`.
121
+ NONFORGEABLE_RUNGS = frozenset({"file-path", "registry"})
122
+
123
+ # The minimum hex length a prefix match in `AncestryFacts.contains` will honour
124
+ # (below it, only an exact match counts) — git's own abbreviated-SHA floor, so a
125
+ # 1–2 char claimed token can never spuriously collide with an unrelated ancestry
126
+ # commit. 7 is git's conventional short-SHA length.
127
+ _MIN_PREFIX = 7
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class AncestryFacts:
132
+ """The boundary's RE-ADJUDICATED evidence — gathered by the CALLER (§3.3, §5).
133
+
134
+ Frozen and handed to the pure verdict (the `ProgressEvidence` shape). The CLI's
135
+ evidence-gather (`resume_evidence.gather_ancestry`) decodes the ledger
136
+ (`intent_ledger.replay`), asks git which SHAs are reachable from HEAD AND
137
+ RE-RUNS the non-forgeable footprint check on each claimed step — never trusting
138
+ the agent-written `STEP_VERIFIED` record's authority (it is written by the
139
+ distrusted run; docs/107 §5, the docs/103 move).
140
+
141
+ shas_in_ancestry — the set of commit SHAs reachable from HEAD on the served
142
+ workspace. A claimed step whose SHA is NOT in here is a
143
+ step the agent claimed but never landed — fail-closed, the
144
+ resume must redo it.
145
+ steps_verified_at_read — the set of STEP IDS the boundary RE-ADJUDICATED as
146
+ standing on the non-forgeable rung AT READ TIME (their
147
+ claimed/recorded SHA is in ancestry AND its commit footprint
148
+ is real, not `--allow-empty`). THIS is the authority the
149
+ pure verdict trusts for "done" — NOT the agent-written
150
+ `STEP_VERIFIED.via`. A forged `STEP_VERIFIED` pointing at an
151
+ unrelated real commit fails the footprint-region re-check and
152
+ is absent here, so it is redone. Empty ⇒ no step re-verified
153
+ (the safe floor when the boundary couldn't re-check).
154
+ head_sha — the workspace's current HEAD (DIVERGED framing / forensics).
155
+ lane_advanced_past_resume — True iff ground truth advanced on the run's lane
156
+ PAST the would-be resume point in a way the residual can't
157
+ be cleanly grafted onto. The CALLER computes it; the verdict
158
+ consumes it. True ⇒ DIVERGED (§5 req 3).
159
+ """
160
+
161
+ shas_in_ancestry: frozenset[str] = frozenset()
162
+ steps_verified_at_read: frozenset[str] = frozenset()
163
+ head_sha: str = ""
164
+ lane_advanced_past_resume: bool = False
165
+
166
+ def contains(self, sha: str) -> bool:
167
+ """True iff `sha` is in ancestry. Prefix-tolerant but collision-guarded.
168
+
169
+ Git short SHAs and full SHAs both appear (the ledger stores whatever the
170
+ agent claimed; `git_delta` returns short). Match by prefix in EITHER
171
+ direction so a 7-char claimed sha matches a 40-char ancestry sha and vice
172
+ versa — the same tolerant comparison `oracle` does. To foreclose a SPURIOUS
173
+ prefix collision (a 1–2 char claimed token matching an unrelated ancestry
174
+ sha), a prefix match requires the shorter side to be ≥ `_MIN_PREFIX` hex
175
+ chars; below that only an EXACT match counts. An empty sha never matches.
176
+ """
177
+ s = (sha or "").strip().lower()
178
+ if not s:
179
+ return False
180
+ for a in self.shas_in_ancestry:
181
+ a = (a or "").strip().lower()
182
+ if not a:
183
+ continue
184
+ if s == a:
185
+ return True
186
+ # A prefix match is only honoured when the SHORTER side is long enough
187
+ # to be an unambiguous abbreviated git SHA — git itself rejects ambiguous
188
+ # short SHAs, and a 1–2 char token must not match an unrelated commit.
189
+ shorter = min(len(s), len(a))
190
+ if shorter >= _MIN_PREFIX and (s.startswith(a) or a.startswith(s)):
191
+ return True
192
+ return False
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class ResumePlan:
197
+ """The single verdict `resume_plan` returns, with the derivation echoed back.
198
+
199
+ `verdict` is the typed `Resume`. `reason` is the operator-facing one-liner.
200
+ `resume_sha` is the minted re-entry point (the newest commit backing a
201
+ contiguous prefix of verified steps; "" when none / COMPLETE / UNRESUMABLE).
202
+ `residual` is the ordered remaining step ids (declared-minus-verified, with a
203
+ claimed-but-unverified step staying IN the residual — fail-closed). `verified`
204
+ is the contiguous-verified prefix the resume point rests on. `predecessor_run_id`
205
+ is the dead/parked run; `already_proposed` echoes the §5-req-4 idempotence flag.
206
+ `to_dict` is the `--json` shape (the `LivenessVerdict.to_dict` idiom)."""
207
+
208
+ verdict: Resume
209
+ reason: str
210
+ run_id: str
211
+ resume_sha: str = ""
212
+ residual: tuple[str, ...] = ()
213
+ verified: tuple[str, ...] = ()
214
+ predecessor_run_id: str = ""
215
+ already_proposed: bool = False
216
+
217
+ def to_dict(self) -> dict:
218
+ return {
219
+ "verdict": self.verdict.value,
220
+ "reason": self.reason,
221
+ "run_id": self.run_id,
222
+ "resume_sha": self.resume_sha,
223
+ "residual": list(self.residual),
224
+ "verified": list(self.verified),
225
+ "predecessor_run_id": self.predecessor_run_id,
226
+ "already_proposed": self.already_proposed,
227
+ }
228
+
229
+
230
+ def _verified_on_safe_rung(state: LedgerState, step_id: str, ancestry: AncestryFacts,
231
+ policy: ResumePolicy) -> bool:
232
+ """True iff `step_id` is a safe resume anchor — RE-ADJUDICATED at read, not trusted.
233
+
234
+ The docs/107 §5 / docs/103 fix. The intent ledger is written BY the distrusted
235
+ run, so a `STEP_VERIFIED` record's `via`/`sha` are an UNAUTHENTICATED HINT, never
236
+ authority. A step counts as done ONLY when the BOUNDARY re-adjudicated it at read
237
+ time — i.e. `step_id ∈ ancestry.steps_verified_at_read`, the set
238
+ `resume_evidence.adjudicate_verified_steps` built by RE-RUNNING the non-forgeable
239
+ footprint check (`step_stands_on_nonforgeable_rung`) on the claimed SHA. A forged
240
+ `STEP_VERIFIED {via: "file-path"}` pointing at an unrelated real commit fails that
241
+ re-check (its footprint isn't the step's work) and is absent from the set, so it
242
+ is redone — the §5 break the adversarial review found, closed.
243
+
244
+ The stored `via` survives only as the policy belt-to-suspenders: when
245
+ `require_nonforgeable_rung` is True (the default) a record whose `via` is itself
246
+ forgeable is rejected even if the boundary somehow re-adjudicated it, and the
247
+ in-ancestry guard defends against a re-adjudicated SHA later rewritten out of
248
+ history. The AUTHORITY is `steps_verified_at_read`; `via`/`contains` only narrow
249
+ it further (fail-closed). When the boundary supplied NO re-adjudication (an empty
250
+ `steps_verified_at_read` — a pure test or a boundary that couldn't re-check), the
251
+ safe floor is "nothing verified," so a stored `STEP_VERIFIED` alone never counts.
252
+ """
253
+ if step_id not in ancestry.steps_verified_at_read:
254
+ return False
255
+ vs = state.verified.get(step_id)
256
+ if vs is None or not vs.sha:
257
+ return False
258
+ if not ancestry.contains(vs.sha):
259
+ return False
260
+ if policy.require_nonforgeable_rung and vs.via not in NONFORGEABLE_RUNGS:
261
+ return False
262
+ return True
263
+
264
+
265
+ def resume_plan(
266
+ state: LedgerState,
267
+ ancestry: AncestryFacts,
268
+ policy: ResumePolicy = DEFAULT_POLICY,
269
+ ) -> ResumePlan:
270
+ """Compute the resume verdict from a folded ledger + ancestry facts. PURE — no I/O.
271
+
272
+ The fold (`docs/107 §3.3`):
273
+ 1. **UNRESUMABLE floor first.** No INTENT record (nothing declared → no
274
+ residual to ground), OR an UNREADABLE_NEWER schema record (this kernel is
275
+ too old to soundly read the ledger; refuse, don't guess — §6), OR a corrupt
276
+ fold the policy treats as unsound: return UNRESUMABLE. *Don't guess a
277
+ residual you can't ground* (the `94 §4.2` INSUFFICIENT_DATA twin).
278
+ 2. **Compute the verified set, fail-closed.** A declared step counts as done
279
+ ONLY if `_verified_on_safe_rung` (a `STEP_VERIFIED` whose SHA is in
280
+ ancestry, on a non-forgeable rung). A `STEP_CLAIMED` without such a
281
+ verification — including one the agent claimed but never landed — is NOT
282
+ done; it stays in the residual (the agent must redo it).
283
+ 3. **Residual + resume point.** residual = declared_steps minus the verified
284
+ set (order preserved). The resume-point SHA is the SHA backing the LAST
285
+ step of the *contiguous verified prefix* — the last point past which
286
+ nothing is confirmed. (Contiguous: a hole — step 2 verified but step 1 not
287
+ — means the resume must restart from before the hole, so only the unbroken
288
+ leading run of verified steps anchors the point.)
289
+ 4. **DIVERGED if ground truth moved past it.** If `ancestry
290
+ .lane_advanced_past_resume`, the lane advanced past the resume point in a
291
+ way the residual can't cleanly graft onto → DIVERGED (refuse + raise a
292
+ decision, never overwrite fresh work; §5 req 3).
293
+ 5. **COMPLETE if the residual is empty** — every declared step verified; the
294
+ run finished, it just never wrote a clean terminal record.
295
+ 6. **RESUMABLE otherwise** — a clean resume point (or the start SHA when no
296
+ step is verified yet) + a non-empty residual: continue from here.
297
+
298
+ The verdict is advisory: it MINTS the resume point and computes the residual;
299
+ the act of continuing is a driver's/human's (the §8 non-goal, the `99` floor).
300
+ """
301
+ rid = state.run_id
302
+
303
+ # 1. The UNRESUMABLE floor.
304
+ if state.unreadable_newer:
305
+ return ResumePlan(
306
+ verdict=Resume.UNRESUMABLE,
307
+ reason=(
308
+ "ledger contains a record this kernel is too OLD to read soundly "
309
+ "(schema newer than understood) — refusing to guess a residual from "
310
+ "a misread intent; run the explicit migration fold (§6)"
311
+ ),
312
+ run_id=rid,
313
+ )
314
+ if policy.treat_untagged_as_corrupt and state.corrupt_lines > 0:
315
+ return ResumePlan(
316
+ verdict=Resume.UNRESUMABLE,
317
+ reason=(
318
+ f"ledger has {state.corrupt_lines} corrupt/unreadable record(s) and "
319
+ f"policy treats those as an unsound fold — refusing to guess a residual"
320
+ ),
321
+ run_id=rid,
322
+ )
323
+ if not state.has_intent:
324
+ return ResumePlan(
325
+ verdict=Resume.UNRESUMABLE,
326
+ reason=(
327
+ "no INTENT record in the ledger — the run declared no goal, so there "
328
+ "is no residual to ground; nothing to resume (the honest floor)"
329
+ ),
330
+ run_id=rid,
331
+ )
332
+
333
+ declared = list(state.declared_steps)
334
+ already = bool(state.resume_proposed)
335
+ # The start SHA is the fallback anchor, but it comes from the agent's INTENT
336
+ # record — a SELF-REPORT (docs/107 §3.2). Echoing it as the "non-forgeable
337
+ # re-entry SHA" without checking ancestry would be the docs/103 disease (trusting
338
+ # a self-report) inside the kernel built to refuse it. Gate it: only an
339
+ # in-ancestry start SHA is a real anchor; otherwise drop to "" (force the driver
340
+ # to re-derive from HEAD) — never echo an unverified self-reported SHA.
341
+ safe_start = state.start_sha if ancestry.contains(state.start_sha) else ""
342
+
343
+ # A run with a free-form goal and NO enumerated steps: re-enter from the
344
+ # (ancestry-checked) start SHA with the whole goal as the residual. We cannot
345
+ # compute a step-granular resume point, so the goal itself is the single residual
346
+ # unit. DIVERGED still applies — a free-form resume must NOT overwrite fresh work
347
+ # any more than an enumerated one (the §5 req-3 refusal has no free-form carve-out).
348
+ if not declared:
349
+ residual_goal = (state.goal or f"{state.plan} {state.phase}".strip() or "(declared goal)",)
350
+ if ancestry.lane_advanced_past_resume:
351
+ return ResumePlan(
352
+ verdict=Resume.DIVERGED,
353
+ reason=(
354
+ f"free-form goal but ground truth advanced past the resume point "
355
+ f"{safe_start[:12] or '(start)'} on this run's lane — refusing to "
356
+ f"re-do the whole goal over fresh work (§5 req 3 applies to "
357
+ f"free-form resume too)"
358
+ ),
359
+ run_id=rid, resume_sha=safe_start, residual=residual_goal,
360
+ verified=(), already_proposed=already,
361
+ )
362
+ return ResumePlan(
363
+ verdict=Resume.RESUMABLE,
364
+ reason=(
365
+ "no enumerated steps — re-enter from the run's start SHA with the "
366
+ "whole declared goal as the residual (step-granular resume needs a "
367
+ "declared step list)"
368
+ + ("" if safe_start else "; start SHA not in ancestry, re-derive from HEAD")
369
+ ),
370
+ run_id=rid,
371
+ resume_sha=safe_start,
372
+ residual=residual_goal,
373
+ verified=(),
374
+ already_proposed=already,
375
+ )
376
+
377
+ # 2 + 3. The verified set (fail-closed, RE-ADJUDICATED) and the contiguous prefix.
378
+ contiguous_prefix: list[str] = []
379
+ broken = False
380
+ for sid in declared:
381
+ is_done = _verified_on_safe_rung(state, sid, ancestry, policy)
382
+ if is_done and not broken:
383
+ contiguous_prefix.append(sid)
384
+ elif not is_done:
385
+ broken = True # first hole — nothing at/after it counts as done for resume
386
+
387
+ # The residual = declared MINUS the CONTIGUOUS verified prefix (NOT minus the full
388
+ # verified set). A step verified but DOWNSTREAM of a hole (s2 verified, s1 not)
389
+ # must still be redone — the resume restarts from before the hole, so everything
390
+ # at/after it is residual. Basing the residual on the contiguous prefix keeps the
391
+ # coverage invariant `verified ∪ residual == declared` AND ensures no residual
392
+ # step is excluded while the re-entry SHA sits before it (the disagreement the
393
+ # adversarial review found). `verified` (reported) == the contiguous prefix.
394
+ prefix_set = set(contiguous_prefix)
395
+ residual = tuple(s for s in declared if s not in prefix_set)
396
+
397
+ # The resume-point SHA = the SHA backing the LAST contiguous-verified step (the
398
+ # last point past which nothing is confirmed). No verified prefix ⇒ the
399
+ # ancestry-checked start SHA (re-enter from the start of the run's work).
400
+ if contiguous_prefix:
401
+ resume_sha = state.verified[contiguous_prefix[-1]].sha
402
+ else:
403
+ resume_sha = safe_start
404
+
405
+ # 4. COMPLETE before DIVERGED — a fully-finished run (empty residual) is DONE, not
406
+ # diverged: there is no stale residual to graft, so lane movement past it is
407
+ # irrelevant. (Checking DIVERGED first mislabelled a finished run and defeated
408
+ # its GC — the adversarial-review high finding.)
409
+ if not residual:
410
+ return ResumePlan(
411
+ verdict=Resume.COMPLETE,
412
+ reason=(
413
+ f"all {len(declared)} declared step(s) verified against ancestry — "
414
+ f"nothing to resume; the run finished, it just never wrote a clean "
415
+ f"terminal record"
416
+ ),
417
+ run_id=rid,
418
+ resume_sha=resume_sha,
419
+ residual=(),
420
+ verified=tuple(contiguous_prefix),
421
+ already_proposed=already,
422
+ )
423
+
424
+ # 5. DIVERGED — there IS a residual AND ground truth moved past the resume point.
425
+ if ancestry.lane_advanced_past_resume:
426
+ return ResumePlan(
427
+ verdict=Resume.DIVERGED,
428
+ reason=(
429
+ f"ground truth advanced past the resume point {resume_sha[:12] or '(start)'} "
430
+ f"on this run's lane — a successor or a human committed there; refusing "
431
+ f"to graft {len(residual)} stale residual step(s) over fresh work "
432
+ f"(merge-conflict-as-verdict, §5 req 3)"
433
+ ),
434
+ run_id=rid,
435
+ resume_sha=resume_sha,
436
+ residual=residual,
437
+ verified=tuple(contiguous_prefix),
438
+ already_proposed=already,
439
+ )
440
+
441
+ # 6. RESUMABLE — a clean resume point + a non-empty residual.
442
+ n_claimed_unverified = sum(
443
+ 1 for s in residual if s in state.claimed and s not in prefix_set
444
+ )
445
+ claimed_note = (
446
+ f" ({n_claimed_unverified} were CLAIMED but not re-verified against ancestry "
447
+ f"— the resume must redo them)" if n_claimed_unverified else ""
448
+ )
449
+ return ResumePlan(
450
+ verdict=Resume.RESUMABLE,
451
+ reason=(
452
+ f"re-verified {len(contiguous_prefix)}/{len(declared)} step(s) against "
453
+ f"ancestry; resume from {resume_sha[:12] or '(start SHA)'} with "
454
+ f"{len(residual)} residual step(s){claimed_note}"
455
+ ),
456
+ run_id=rid,
457
+ resume_sha=resume_sha,
458
+ residual=residual,
459
+ verified=tuple(contiguous_prefix),
460
+ already_proposed=already,
461
+ )
462
+
463
+
464
+ # --------------------------------------------------------------------------
465
+ # Reachability — the docs/106 GC verdict, extended from leases to unfinished work (§4).
466
+ # --------------------------------------------------------------------------
467
+
468
+
469
+ class Reachability(str, enum.Enum):
470
+ """Is a run-dir GARBAGE (collectible) or REACHABLE (retain)? — the §4 GC clause.
471
+
472
+ `docs/106`'s reachability-is-a-verdict rule, extended from leases to *unfinished
473
+ work*: **what is reachable is what an adjudicator says can still make progress,
474
+ not what holds a reference or beat a clock.** A refcount/TTL is UNSOUND here — a
475
+ crashed run's `intent.jsonl` holds a resumable residual that no live reference
476
+ points at, yet it is NOT garbage; and a SUSPENDED (parked) run released its lane
477
+ but its ledger is reachable, not collectible. So reachability is ADJUDICATED (the
478
+ `ResumePlan`), never counted.
479
+
480
+ `str`-valued for the `--json` token / exit-code idiom.
481
+ """
482
+
483
+ REACHABLE = "REACHABLE" # still resumable (or parked-and-resumable) — RETAIN regardless of age
484
+ COLLECTIBLE = "COLLECTIBLE" # terminal-COMPLETE or UNRESUMABLE — safe to GC the run-dir
485
+
486
+ def __str__(self) -> str: # pragma: no cover - trivial
487
+ return self.value
488
+
489
+
490
+ @dataclass(frozen=True)
491
+ class ReachabilityVerdict:
492
+ """The typed run-dir reachability verdict + the resume verdict it rests on.
493
+
494
+ Carries the `ResumePlan.verdict` it derived from + whether the run is SUSPENDED
495
+ so a surfaced GC decision is legible ("retained: SUSPENDED-RESUMABLE, parked by
496
+ the operator" vs "collectible: COMPLETE — every step verified"). `to_dict` is
497
+ the `--json` shape.
498
+ """
499
+
500
+ reachability: Reachability
501
+ reason: str
502
+ run_id: str
503
+ resume_verdict: Resume
504
+ suspended: bool = False
505
+
506
+ @property
507
+ def is_collectible(self) -> bool:
508
+ return self.reachability is Reachability.COLLECTIBLE
509
+
510
+ def to_dict(self) -> dict:
511
+ return {
512
+ "reachability": self.reachability.value,
513
+ "reason": self.reason,
514
+ "run_id": self.run_id,
515
+ "resume_verdict": self.resume_verdict.value,
516
+ "suspended": self.suspended,
517
+ }
518
+
519
+
520
+ def classify_run_dir_reachability(
521
+ state: LedgerState,
522
+ plan: ResumePlan,
523
+ ) -> ReachabilityVerdict:
524
+ """Is this run-dir garbage? PURE — over a folded ledger + its resume plan (§4).
525
+
526
+ The single §4 clause, stated exactly: **a run-dir is garbage only if its run is
527
+ terminal-COMPLETE or its resume plan is UNRESUMABLE; a SUSPENDED-with-RESUMABLE
528
+ run-dir is retained regardless of age.** Concretely:
529
+
530
+ * RESUMABLE → REACHABLE (a residual a successor can still make progress on —
531
+ this is the whole point of the ledger; never GC it). A SUSPENDED-RESUMABLE
532
+ run is the same verdict via a different door (`docs/107 §4`): pause made
533
+ scavenge-immune.
534
+ * DIVERGED → REACHABLE (it needs a HUMAN decision, not a reaper — collecting
535
+ it would silently drop a conflict that must be surfaced; the merge-conflict-
536
+ as-verdict rule keeps the evidence around).
537
+ * COMPLETE → COLLECTIBLE (every declared step verified — the run finished; its
538
+ ledger is forensics, safe to reap under the host's retention window).
539
+ * UNRESUMABLE→ COLLECTIBLE (no INTENT / unreadable-newer / unsound fold — there
540
+ is nothing to resume, so the run-dir is not holding recoverable work).
541
+
542
+ The age/TTL is deliberately ABSENT from this verdict (the `docs/106` unsound-
543
+ refcount lesson): retention is decided by the ADJUDICATOR (can it still make
544
+ progress?), never by a clock. A host's reaper may add an age GRACE *on top* —
545
+ "collectible AND older than N days" — but it may never reap a REACHABLE run-dir
546
+ no matter how old, which is the safety property this clause guarantees.
547
+ """
548
+ rid = plan.run_id or state.run_id
549
+ susp = state.suspended
550
+ if plan.verdict is Resume.RESUMABLE:
551
+ door = "SUSPENDED-RESUMABLE (parked, scavenge-immune)" if susp else "RESUMABLE"
552
+ return ReachabilityVerdict(
553
+ reachability=Reachability.REACHABLE,
554
+ reason=(
555
+ f"{door} — a residual a successor can still make progress on; "
556
+ f"retained regardless of age (reachability is adjudicated, not "
557
+ f"refcounted — docs/106)"
558
+ ),
559
+ run_id=rid, resume_verdict=plan.verdict, suspended=susp,
560
+ )
561
+ if plan.verdict is Resume.DIVERGED:
562
+ return ReachabilityVerdict(
563
+ reachability=Reachability.REACHABLE,
564
+ reason=(
565
+ "DIVERGED — needs a human decision, not a reaper; retained so the "
566
+ "conflict is surfaced, never silently collected"
567
+ ),
568
+ run_id=rid, resume_verdict=plan.verdict, suspended=susp,
569
+ )
570
+ # COMPLETE or UNRESUMABLE — nothing recoverable; collectible (host adds an age grace).
571
+ why = ("COMPLETE — every declared step verified; the run finished"
572
+ if plan.verdict is Resume.COMPLETE
573
+ else "UNRESUMABLE — no recoverable work to resume")
574
+ return ReachabilityVerdict(
575
+ reachability=Reachability.COLLECTIBLE,
576
+ reason=f"{why}; the run-dir holds no resumable residual — safe to GC",
577
+ run_id=rid, resume_verdict=plan.verdict, suspended=susp,
578
+ )