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/state_health.py ADDED
@@ -0,0 +1,387 @@
1
+ """State-file health — a pure verdict over an external durable state file.
2
+
3
+ The gap this closes
4
+ ===================
5
+
6
+ `dos doctor` reports the *path* of the workspace's execution-state file but says
7
+ nothing about its *health*. In practice that file (a host's `execution-state.yaml`,
8
+ a registry, any large append-mostly substrate the kernel does not own the schema of)
9
+ accumulates two debts the kernel could flag but doesn't:
10
+
11
+ 1. **Bloat** — cold, never-on-a-hot-path sections (a completed-work log, an
12
+ abandoned-claim log, an archive sink) grow past any size a reader windows to,
13
+ yet every read-modify-write of the file re-parses all of them. This is the
14
+ `should_compact` problem (docs/106 §3.2) pointed at an *external* file instead
15
+ of the lane journal.
16
+ 2. **Mid-flight / deferred-obligation drift** — a multi-step transition was
17
+ *intentionally* left partial ("migrate the rest in a quiet window," "flip the
18
+ flag later") with no run holding it, no deadline, and no detector. It looks
19
+ like a healthy steady state, so nothing surfaces that it is owed. The intent
20
+ ledger (docs/107) cannot catch this: there is no `run_id` and no crash — the
21
+ work was *parked by design*, then orphaned. See docs/133.
22
+
23
+ This module is the read-side verdict for both. It is **pure** — it classifies
24
+ caller-gathered evidence against a declared policy and returns a typed verdict; it
25
+ does no I/O, reads no clock it was not handed, and **never performs a fix** (it
26
+ surfaces; a driver or human acts — the docs/99 advisory-only floor). It is the
27
+ `liveness.classify(evidence, policy) -> verdict` template applied to a file's
28
+ health, with `retention.should_compact`'s monotone-threshold posture for the size
29
+ rung.
30
+
31
+ The obligation rung — the docs/133 prototype
32
+ ============================================
33
+
34
+ An ``Obligation`` is a deferred transition carrying its **completion predicate as
35
+ a pre-evaluated boolean** (the caller, which can touch the world, evaluates the
36
+ predicate at the boundary and hands the result in; this leaf stays pure). The
37
+ verdict re-states it as one of ``SATISFIED`` / ``PENDING`` / ``BLOCKED`` /
38
+ ``STALE`` — the same fail-closed direction every DOS verdict takes (an obligation
39
+ whose predicate the caller could not evaluate degrades toward "still owed," never
40
+ toward "assume done"). A ``PENDING`` obligation past its declared horizon becomes
41
+ ``STALE`` — the rung that makes "we left a migration half-done 30 days ago"
42
+ *structurally visible* the way a treeless lane already is.
43
+
44
+ Why a leaf with no I/O
45
+ ======================
46
+
47
+ Same kernel/driver split as `retention` (the policy + `should_compact` are pure
48
+ data; the reapers that scandir/unlink live in the driver) and `liveness` (the
49
+ `classify` fold is pure; the boundary gathers the beats). A host's adapter gathers
50
+ the file's section sizes + evaluates each obligation's predicate (both of which
51
+ touch disk), then calls `classify_state_file`. Nothing in the kernel imports *down*
52
+ into a driver to use this.
53
+
54
+ Pure stdlib — no third-party imports, no I/O, no `time` read (the clock is handed
55
+ in as `now_ms`, the way a pure verdict is handed a clock).
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import enum
61
+ from dataclasses import dataclass, field
62
+ from typing import Mapping, Sequence
63
+
64
+ _MS_PER_DAY = 86_400_000
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Obligations — the docs/133 deferred-transition record (prototype).
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ class ObligationStatus(str, enum.Enum):
73
+ """The adjudicated state of one deferred obligation.
74
+
75
+ `str`-valued so it round-trips a `--json` token without a lookup (`Liveness` /
76
+ `durable_schema.Readability` idiom). The asymmetry is the whole point: only
77
+ SATISFIED clears the debt; everything else keeps it visible, and STALE
78
+ escalates it.
79
+ """
80
+
81
+ SATISFIED = "SATISFIED" # the completion predicate holds — the debt is paid, drop it
82
+ PENDING = "PENDING" # not complete, still inside its declared horizon — owed, surfaced
83
+ BLOCKED = "BLOCKED" # a precondition is unmet — a human must clear it before it can complete
84
+ STALE = "STALE" # PENDING past its horizon — escalate (the "forgotten migration" rung)
85
+
86
+ def __str__(self) -> str: # pragma: no cover - trivial
87
+ return self.value
88
+
89
+ @property
90
+ def is_cleared(self) -> bool:
91
+ """True iff the obligation no longer needs to be tracked (SATISFIED only)."""
92
+ return self is ObligationStatus.SATISFIED
93
+
94
+ @property
95
+ def needs_attention(self) -> bool:
96
+ """True iff a surfacer should report this (everything that is not cleared)."""
97
+ return self is not ObligationStatus.SATISFIED
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Obligation:
102
+ """A deferred transition the kernel must keep visible until its predicate holds.
103
+
104
+ The caller (at the I/O boundary) evaluates the completion predicate and the
105
+ blocked precondition against the live world and hands the booleans in; this
106
+ record stays pure data. Fields:
107
+
108
+ * ``key`` — a stable id for the obligation (e.g. ``"migration:history-store"``).
109
+ * ``description`` — the human one-liner ("drain YAML history into SQLite, then
110
+ flip JOB_HISTORY_IN_STORE and drop the YAML buckets").
111
+ * ``predicate_summary`` — the completion predicate *as text*, for the surfaced
112
+ line ("store ⊇ yaml ∧ flag_flipped ∧ yaml_buckets_empty"). Carried so a
113
+ reader can see WHAT is owed, not just THAT something is.
114
+ * ``satisfied`` — the caller's evaluation of the completion predicate. None
115
+ means "could not evaluate" → fail-closed to PENDING (never SATISFIED).
116
+ * ``blocked`` — True iff a precondition is unmet (the completion cannot even
117
+ be attempted yet); takes precedence over a not-yet-satisfied PENDING.
118
+ * ``declared_at_ms`` — when the obligation was first recorded (the horizon
119
+ anchor). None disables the staleness rung for this obligation.
120
+ * ``horizon_days`` — how long the deferral is acceptable before it escalates
121
+ to STALE. None = no horizon (PENDING forever, never STALE).
122
+ """
123
+
124
+ key: str
125
+ description: str
126
+ predicate_summary: str = ""
127
+ satisfied: bool | None = None
128
+ blocked: bool = False
129
+ declared_at_ms: int | None = None
130
+ horizon_days: float | None = None
131
+
132
+
133
+ def classify_obligation(ob: Obligation, *, now_ms: int) -> ObligationStatus:
134
+ """Adjudicate one obligation. PURE — the predicate is pre-evaluated by the caller.
135
+
136
+ Order is fail-closed:
137
+ * a satisfied predicate clears it (SATISFIED) — the only exit;
138
+ * else a blocked precondition is BLOCKED (a human must act);
139
+ * else if it has a horizon and is past it, STALE (escalate the forgotten debt);
140
+ * else PENDING (owed, but still within its acceptable deferral window).
141
+
142
+ ``satisfied is None`` (the caller could not evaluate the predicate) is treated
143
+ as not-satisfied — the same direction every DOS verdict takes when evidence is
144
+ missing (degrade toward "redo / still owed," never toward "assume done").
145
+ """
146
+ if ob.satisfied is True:
147
+ return ObligationStatus.SATISFIED
148
+ if ob.blocked:
149
+ return ObligationStatus.BLOCKED
150
+ if (
151
+ ob.declared_at_ms is not None
152
+ and ob.horizon_days is not None
153
+ and (now_ms - ob.declared_at_ms) > ob.horizon_days * _MS_PER_DAY
154
+ ):
155
+ return ObligationStatus.STALE
156
+ return ObligationStatus.PENDING
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Section-size policy — the bloat rung (the `retention.should_compact` shape).
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class StateFilePolicy:
166
+ """Per-file health caps, as immutable data (the `RetentionPolicy` shape).
167
+
168
+ Every field optional-with-a-default; a host overrides what it cares about.
169
+ ``None`` on any cap means "unbounded on this axis" — NOT zero. The caps are
170
+ size tuning; like `RetentionPolicy`, this object carries only thresholds — it
171
+ does not delete anything (a driver does, behind the verdict).
172
+
173
+ * ``max_total_bytes`` — flag the file when its on-disk size exceeds this.
174
+ * ``cold_section_max_rows`` — per-section row cap for the *cold* sections
175
+ (the completed/abandoned/archive logs a reader windows past). A section
176
+ over this is a COMPACTABLE finding.
177
+ * ``cold_sections`` — the names of the sections to apply ``cold_section_max_rows``
178
+ to. Empty ⇒ the size rung only looks at ``max_total_bytes``.
179
+ """
180
+
181
+ max_total_bytes: int | None = 200_000
182
+ cold_section_max_rows: int | None = 150
183
+ cold_sections: tuple[str, ...] = ()
184
+
185
+
186
+ GENERIC_STATE_FILE_POLICY = StateFilePolicy()
187
+
188
+
189
+ class SizeVerdict(str, enum.Enum):
190
+ """Whether a state file is within its size budget."""
191
+
192
+ OK = "OK" # within all size caps
193
+ COMPACTABLE = "COMPACTABLE" # over a cap — worth compacting (the should_compact "True")
194
+
195
+ def __str__(self) -> str: # pragma: no cover - trivial
196
+ return self.value
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Legacy-schema rung — retired field names / enum values still present.
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class LegacySchemaFinding:
206
+ """One retired field name or enum value still present in the file.
207
+
208
+ Generic over what "legacy" means for a given host: the caller declares the
209
+ retired tokens it scanned for and how many it found. Carried as data so the
210
+ surfaced line is legible ("9 entries still on retired `status: KEEP` — renamed
211
+ to ACTIVE/MAINTENANCE/PARK/TOMB").
212
+ """
213
+
214
+ token: str # the retired token, e.g. "status: KEEP" or "slot:"
215
+ count: int # how many occurrences the caller found
216
+ replacement: str = "" # the new form, for the surfaced line
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # The composite health verdict.
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class StateHealthVerdict:
226
+ """The typed result of `classify_state_file` — every rung's finding, foldable.
227
+
228
+ `findings()` renders the human/`--check` lines (the `config_lint.lint`
229
+ shape: a list of strings a `dos doctor --check` appends). `is_healthy` is the
230
+ one-bit rollup. `to_dict` is the `--json` shape.
231
+ """
232
+
233
+ size: SizeVerdict
234
+ total_bytes: int | None
235
+ oversized_sections: tuple[tuple[str, int], ...] # (section, rows) over the cold cap
236
+ legacy: tuple[LegacySchemaFinding, ...]
237
+ obligations: tuple[tuple[Obligation, ObligationStatus], ...]
238
+
239
+ @property
240
+ def is_healthy(self) -> bool:
241
+ """True iff no rung has a finding worth surfacing."""
242
+ return (
243
+ self.size is SizeVerdict.OK
244
+ and not self.oversized_sections
245
+ and not self.legacy
246
+ and not any(st.needs_attention for _, st in self.obligations)
247
+ )
248
+
249
+ @property
250
+ def stale_obligations(self) -> tuple[Obligation, ...]:
251
+ """The obligations past their horizon — the escalation set (the docs/133 point)."""
252
+ return tuple(ob for ob, st in self.obligations if st is ObligationStatus.STALE)
253
+
254
+ def findings(self) -> list[str]:
255
+ """The surfaced lines — what `dos doctor --check` appends. PURE, deterministic.
256
+
257
+ Ordered most-actionable first: stale obligations, then blocked/pending
258
+ obligations, then bloat, then legacy schema. Empty ⇒ the file is healthy.
259
+ """
260
+ out: list[str] = []
261
+ # Obligations first — a forgotten mid-flight transition is the highest-signal.
262
+ for ob, st in self.obligations:
263
+ if st is ObligationStatus.STALE:
264
+ pred = f" — owed: {ob.predicate_summary}" if ob.predicate_summary else ""
265
+ out.append(
266
+ f"obligation '{ob.key}' is STALE (deferred past its "
267
+ f"{ob.horizon_days:g}-day horizon){pred}: {ob.description}"
268
+ )
269
+ for ob, st in self.obligations:
270
+ if st is ObligationStatus.BLOCKED:
271
+ out.append(f"obligation '{ob.key}' is BLOCKED (a precondition is unmet): {ob.description}")
272
+ for ob, st in self.obligations:
273
+ if st is ObligationStatus.PENDING:
274
+ pred = f" — owed: {ob.predicate_summary}" if ob.predicate_summary else ""
275
+ out.append(f"obligation '{ob.key}' is PENDING{pred}: {ob.description}")
276
+ # Bloat.
277
+ if self.size is SizeVerdict.COMPACTABLE and self.total_bytes is not None:
278
+ out.append(
279
+ f"state file is {self.total_bytes:,} bytes — over the size budget; "
280
+ f"compact the cold sections"
281
+ )
282
+ for section, rows in self.oversized_sections:
283
+ out.append(f"cold section '{section}' has {rows} rows — over the per-section cap")
284
+ # Legacy schema.
285
+ for lf in self.legacy:
286
+ repl = f" (→ {lf.replacement})" if lf.replacement else ""
287
+ out.append(f"{lf.count} entries still use retired '{lf.token}'{repl}")
288
+ return out
289
+
290
+ def to_dict(self) -> dict:
291
+ return {
292
+ "size": self.size.value,
293
+ "total_bytes": self.total_bytes,
294
+ "oversized_sections": [list(t) for t in self.oversized_sections],
295
+ "legacy": [
296
+ {"token": lf.token, "count": lf.count, "replacement": lf.replacement}
297
+ for lf in self.legacy
298
+ ],
299
+ "obligations": [
300
+ {**_obligation_to_dict(ob), "status": st.value}
301
+ for ob, st in self.obligations
302
+ ],
303
+ "is_healthy": self.is_healthy,
304
+ }
305
+
306
+
307
+ def _obligation_to_dict(ob: Obligation) -> dict:
308
+ return {
309
+ "key": ob.key,
310
+ "description": ob.description,
311
+ "predicate_summary": ob.predicate_summary,
312
+ "satisfied": ob.satisfied,
313
+ "blocked": ob.blocked,
314
+ "horizon_days": ob.horizon_days,
315
+ }
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class StateFileEvidence:
320
+ """What the boundary gathers about a state file — caller-supplied, pure to fold.
321
+
322
+ The host adapter opens the file (the only I/O), measures it, and evaluates each
323
+ obligation's predicate against the live world, then hands this in. The fold
324
+ below never touches disk.
325
+
326
+ * ``total_bytes`` — the file's on-disk size, or None if unknown.
327
+ * ``section_rows`` — ``{section_name: row_count}`` for the sections the caller
328
+ cares about (it only needs to fill the cold ones the policy names).
329
+ * ``legacy`` — the retired-token findings the caller scanned for (already
330
+ counted; an empty/zero-count finding is dropped by the fold).
331
+ * ``obligations`` — the deferred obligations, predicates already evaluated.
332
+ """
333
+
334
+ total_bytes: int | None = None
335
+ section_rows: Mapping[str, int] = field(default_factory=dict)
336
+ legacy: Sequence[LegacySchemaFinding] = ()
337
+ obligations: Sequence[Obligation] = ()
338
+
339
+
340
+ def classify_state_file(
341
+ evidence: StateFileEvidence,
342
+ policy: StateFilePolicy = GENERIC_STATE_FILE_POLICY,
343
+ *,
344
+ now_ms: int,
345
+ ) -> StateHealthVerdict:
346
+ """Classify one external state file's health. PURE — no I/O, clock handed in.
347
+
348
+ Folds the caller-gathered ``evidence`` against ``policy`` into a
349
+ `StateHealthVerdict`. The size rung is monotone (it only ever asks to compact
350
+ *more* as the file grows — `retention.should_compact`'s posture). The
351
+ obligation rung re-states each pre-evaluated obligation via `classify_obligation`.
352
+ The legacy rung drops zero-count findings (a token scanned-for but absent is not
353
+ a finding). All deterministic and order-stable so `--check` output is stable.
354
+ """
355
+ total = evidence.total_bytes
356
+
357
+ # Size rung — total bytes over cap, OR any named cold section over its row cap.
358
+ over_total = (
359
+ policy.max_total_bytes is not None
360
+ and total is not None
361
+ and total > policy.max_total_bytes
362
+ )
363
+ oversized: list[tuple[str, int]] = []
364
+ if policy.cold_section_max_rows is not None:
365
+ for section in policy.cold_sections:
366
+ rows = evidence.section_rows.get(section)
367
+ if rows is not None and rows > policy.cold_section_max_rows:
368
+ oversized.append((section, rows))
369
+ size_verdict = (
370
+ SizeVerdict.COMPACTABLE if (over_total or oversized) else SizeVerdict.OK
371
+ )
372
+
373
+ # Legacy rung — keep only present (count > 0) findings.
374
+ legacy = tuple(lf for lf in evidence.legacy if lf.count > 0)
375
+
376
+ # Obligation rung — adjudicate each against the handed-in clock.
377
+ obligations = tuple(
378
+ (ob, classify_obligation(ob, now_ms=now_ms)) for ob in evidence.obligations
379
+ )
380
+
381
+ return StateHealthVerdict(
382
+ size=size_verdict,
383
+ total_bytes=total,
384
+ oversized_sections=tuple(oversized),
385
+ legacy=legacy,
386
+ obligations=obligations,
387
+ )
dos/status.py ADDED
@@ -0,0 +1,114 @@
1
+ """The status digest — one fail-closed, folded fact about a run (docs/120).
2
+
3
+ Layer-1 projection: folds four shipped verdicts (liveness / ledger-verified /
4
+ held lease / resume) into one record. PURE — the four verdicts are computed at
5
+ the caller boundary and handed in (the `arbitrate`/`classify` rule: state in,
6
+ verdict out, no I/O). The record carries **no `claimed` field** by construction:
7
+ a consumer of the digest cannot read a self-report, because the output type has
8
+ no slot for one (docs/120 §3, the fail-closed invariant).
9
+
10
+ This is Phase 1 of docs/120: the pure fold only. The CLI boundary that *gathers*
11
+ the four verdicts (`dos status <run_id>`) is Phase 2; the `dos_status` MCP tool is
12
+ Phase 3. Nothing imports this module yet — it is self-contained and cannot affect
13
+ the running kernel.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ from dos.intent_ledger import LedgerState
20
+ from dos.liveness import LivenessVerdict
21
+ from dos.resume import ResumePlan
22
+
23
+ # The durable_schema floor (docs/116 §6): the digest is a record other tools read,
24
+ # so tag it. A consumer that understands an older schema refuses a newer digest
25
+ # rather than misparsing it — refuse, don't guess.
26
+ STATUS_DIGEST_SCHEMA = 1
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ProgressView:
31
+ """The adjudicated progress view — derived from `LedgerState.verified` ONLY.
32
+
33
+ `verified_count` / `declared_count` / `verified_steps` are read off the
34
+ kernel-minted `verified` map (the TRUSTED rung). There is deliberately no
35
+ `claimed` field here: the agent's self-report (`LedgerState.claimed`, a pointer
36
+ to a commit to check, not proof) is never surfaced through the digest.
37
+ """
38
+
39
+ verified_count: int
40
+ declared_count: int
41
+ verified_steps: tuple[str, ...] = ()
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class StatusDigest:
46
+ """One run's status as a single folded, fail-closed fact (docs/120 §5).
47
+
48
+ Every field is an *adjudicated* verdict, never a self-report:
49
+ liveness — the in-flight "is it moving" verdict (`liveness.classify`)
50
+ progress — verified-step view (reads `verified`, never `claimed`)
51
+ region — the run's held lease globs (or () if it holds none)
52
+ resume — the resume verdict once the run has stopped (None while live)
53
+
54
+ `to_dict` is the `--json` A2A contract a peer agent / dashboard parses. The
55
+ one load-bearing property of that shape: `claimed` is absent.
56
+ """
57
+
58
+ run_id: str
59
+ liveness: LivenessVerdict
60
+ progress: ProgressView
61
+ region: tuple[str, ...] = ()
62
+ resume: ResumePlan | None = None
63
+ schema: int = STATUS_DIGEST_SCHEMA
64
+
65
+ def to_dict(self) -> dict:
66
+ # The --json shape. `claimed` is ABSENT — that absence is the point
67
+ # (docs/120 §3): a consumer cannot pick a self-report it is never handed.
68
+ return {
69
+ "schema": self.schema,
70
+ "run_id": self.run_id,
71
+ "liveness": self.liveness.to_dict(),
72
+ "progress": {
73
+ "verified_count": self.progress.verified_count,
74
+ "declared_count": self.progress.declared_count,
75
+ "verified_steps": list(self.progress.verified_steps),
76
+ },
77
+ "region": list(self.region),
78
+ "resume": self.resume.to_dict() if self.resume is not None else None,
79
+ }
80
+
81
+
82
+ def status_digest(
83
+ *,
84
+ run_id: str,
85
+ ledger_state: LedgerState,
86
+ liveness_verdict: LivenessVerdict,
87
+ live_region: tuple[str, ...] = (),
88
+ resume_plan: ResumePlan | None = None,
89
+ ) -> StatusDigest:
90
+ """Fold the four already-computed verdicts into one digest. PURE.
91
+
92
+ The four inputs are gathered at the caller boundary (Phase 2) and handed in;
93
+ this function makes no subprocess, file, or clock call — the same posture as
94
+ `resume.resume_plan` and `liveness.classify`.
95
+
96
+ `progress` is built from `ledger_state.verified` ONLY — `ledger_state.claimed`
97
+ is read by nothing here. Fail-closed: an empty / no-intent `LedgerState` yields
98
+ a zero `ProgressView` (declared 0, verified 0), never a raise and never a
99
+ guessed-optimistic default. A run that declared no adjudicable intent is still
100
+ a valid fact ("nothing verified, nothing declared"), not an error.
101
+ """
102
+ verified = ledger_state.verified # {step_id: VerifiedStep} — the minted rung
103
+ progress = ProgressView(
104
+ verified_count=len(verified),
105
+ declared_count=len(ledger_state.declared_steps),
106
+ verified_steps=tuple(sorted(verified)),
107
+ )
108
+ return StatusDigest(
109
+ run_id=run_id,
110
+ liveness=liveness_verdict,
111
+ progress=progress,
112
+ region=live_region,
113
+ resume=resume_plan,
114
+ )