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/lifecycle.py ADDED
@@ -0,0 +1,211 @@
1
+ """`lifecycle` — the plan-class taxonomy + transition triggers, AS DATA (docs/207 §5c).
2
+
3
+ The `job` repo's `class-cycle` hardcodes a 5-class taxonomy (ACTIVE / MAINTENANCE
4
+ / PARK / TOMB / DRAFT) and 9 named triggers (T1..T9) — the plan-classification
5
+ lifecycle. That taxonomy is POLICY: a repo that only wants ACTIVE/DONE should not
6
+ inherit a job-shaped lifecycle. This module lifts the class set + the trigger list
7
+ into per-workspace `[lifecycle]` data, exactly as `[stamp]` lifted the ship
8
+ grammar and `[enumerate]` lifted the phase grammar.
9
+
10
+ The split (docs/207 §"less house-style"):
11
+
12
+ * MECHANISM (the `dos-class-cycle` skill): evaluate triggers → build candidates
13
+ deterministically → spawn a JUDGE-rung adjudicator (`dos.judges`, advisory,
14
+ fail-to-abstain) to approve/defer each → apply gated transitions → log. Domain-
15
+ free; the same shape for any taxonomy.
16
+ * POLICY (this data): WHICH classes exist, WHICH transitions are legal, the
17
+ trigger list, and the failsafes (per-cycle cap, per-plan cooldown, a veto
18
+ class). All declared in `[lifecycle]`; a 2-class repo declares `active`/`done`.
19
+
20
+ The kernel carries the SHAPE + the validation (a transition must name known
21
+ classes; an unknown key raises); the consuming repo declares the taxonomy. The
22
+ judge *content* (the prompt) is a host `dos.judges` driver — forcing it generic
23
+ would re-couple the kernel (docs/207 §"what deliberately does NOT get genericized").
24
+
25
+ Pure stdlib — a near-leaf like `reasons`/`stamp`. No I/O in the verdict path; the
26
+ TOML read is at the `load_from_toml` boundary.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LifecycleTransition:
36
+ """One legal class transition + the trigger that proposes it.
37
+
38
+ ``frm`` / ``to`` are class names (must be members of the declared class set).
39
+ ``trigger`` is an opaque token the skill evaluates (the kernel never interprets
40
+ it — the host's evaluator owns what `idle_30d` means). ``auto`` is whether a
41
+ safe mechanical apply is allowed (gated, one commit) vs surface-for-a-human.
42
+ """
43
+
44
+ frm: str
45
+ to: str
46
+ trigger: str
47
+ auto: bool = False
48
+
49
+ def to_dict(self) -> dict:
50
+ return {"from": self.frm, "to": self.to, "trigger": self.trigger, "auto": self.auto}
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class LifecyclePolicy:
55
+ """The plan-class lifecycle, as data — the `[lifecycle]` table.
56
+
57
+ * ``classes`` — the declared class set (e.g. `("active","done")` or the job
58
+ 5-class set). The FIRST class is the default for a freshly-declared plan.
59
+ * ``transitions`` — the legal `LifecycleTransition`s; each names classes from
60
+ ``classes`` and an opaque trigger token.
61
+ * ``veto_class`` — a class whose plans are NEVER auto-transitioned (the job
62
+ P0-veto): a high-priority plan a human must move by hand. Empty = no veto.
63
+ * ``max_transitions_per_cycle`` — the per-cycle cap (the job daily cap), so a
64
+ runaway judge cannot churn the whole portfolio in one tick. Default 5.
65
+ * ``per_plan_cooldown_hours`` — a plan transitioned within this window is not
66
+ a candidate again (the job 72h cooldown). Default 72.
67
+
68
+ The defaults are GENERIC. A repo declares its own in `dos.toml [lifecycle]`.
69
+ """
70
+
71
+ classes: tuple[str, ...] = ("active", "done")
72
+ transitions: tuple[LifecycleTransition, ...] = ()
73
+ veto_class: str = ""
74
+ max_transitions_per_cycle: int = 5
75
+ per_plan_cooldown_hours: int = 72
76
+
77
+ @property
78
+ def default_class(self) -> str:
79
+ """The class a freshly-declared plan starts in (the first declared class)."""
80
+ return self.classes[0] if self.classes else ""
81
+
82
+ def is_known_class(self, name: str) -> bool:
83
+ return name in self.classes
84
+
85
+ def legal_transition(self, frm: str, to: str) -> bool:
86
+ """True iff `frm → to` is a declared legal transition."""
87
+ return any(t.frm == frm and t.to == to for t in self.transitions)
88
+
89
+ def to_dict(self) -> dict:
90
+ return {
91
+ "classes": list(self.classes),
92
+ "transitions": [t.to_dict() for t in self.transitions],
93
+ "veto_class": self.veto_class,
94
+ "max_transitions_per_cycle": self.max_transitions_per_cycle,
95
+ "per_plan_cooldown_hours": self.per_plan_cooldown_hours,
96
+ }
97
+
98
+
99
+ # The generic default: two classes, one auto transition, no veto. A repo with a
100
+ # richer taxonomy declares it. This is the `[stamp]` GENERIC_ twin.
101
+ GENERIC_LIFECYCLE = LifecyclePolicy(
102
+ classes=("active", "done"),
103
+ transitions=(LifecycleTransition("active", "done", "all_phases_shipped", auto=True),),
104
+ )
105
+
106
+
107
+ def policy_from_table(
108
+ table: dict, *, base: LifecyclePolicy = GENERIC_LIFECYCLE
109
+ ) -> LifecyclePolicy:
110
+ """Build a `LifecyclePolicy` from a parsed `[lifecycle]` TOML table. PURE.
111
+
112
+ Each field the table names overrides ``base``; omitted inherit. An unknown key
113
+ raises (the `stamp.convention_from_table` posture). A transition that names a
114
+ class NOT in the declared set raises (a typo'd class is a host mistake worth
115
+ surfacing — the kernel validates the SHAPE, the repo declares the taxonomy).
116
+ """
117
+ if not isinstance(table, dict):
118
+ raise ValueError(f"[lifecycle] must be a table, got {type(table).__name__}")
119
+ known = {"classes", "transitions", "veto_class",
120
+ "max_transitions_per_cycle", "per_plan_cooldown_hours"}
121
+ unknown = set(table) - known
122
+ if unknown:
123
+ raise ValueError(
124
+ f"[lifecycle] has unknown key(s) {sorted(unknown)}; known keys are {sorted(known)}"
125
+ )
126
+
127
+ classes = base.classes
128
+ if "classes" in table:
129
+ raw = table["classes"]
130
+ if not isinstance(raw, (list, tuple)) or not all(isinstance(x, str) for x in raw) or not raw:
131
+ raise ValueError("[lifecycle].classes must be a non-empty list of strings")
132
+ classes = tuple(raw)
133
+ class_set = set(classes)
134
+
135
+ transitions = base.transitions
136
+ if "transitions" in table:
137
+ raw_t = table["transitions"]
138
+ if not isinstance(raw_t, (list, tuple)):
139
+ raise ValueError("[lifecycle].transitions must be a list of {from,to,trigger,auto} tables")
140
+ out: list[LifecycleTransition] = []
141
+ for entry in raw_t:
142
+ if not isinstance(entry, dict):
143
+ raise ValueError("[lifecycle].transitions entries must be tables")
144
+ frm, to, trig = entry.get("from"), entry.get("to"), entry.get("trigger")
145
+ if not all(isinstance(x, str) and x for x in (frm, to, trig)):
146
+ raise ValueError(
147
+ "[lifecycle] transition needs string from/to/trigger; got "
148
+ f"{entry!r}"
149
+ )
150
+ if frm not in class_set or to not in class_set:
151
+ raise ValueError(
152
+ f"[lifecycle] transition {frm!r}->{to!r} names a class not in "
153
+ f"the declared set {sorted(class_set)}"
154
+ )
155
+ auto = entry.get("auto", False)
156
+ if not isinstance(auto, bool):
157
+ raise ValueError("[lifecycle] transition `auto` must be a boolean")
158
+ out.append(LifecycleTransition(frm=frm, to=to, trigger=trig, auto=auto))
159
+ transitions = tuple(out)
160
+
161
+ veto = base.veto_class
162
+ if "veto_class" in table:
163
+ if not isinstance(table["veto_class"], str):
164
+ raise ValueError("[lifecycle].veto_class must be a string")
165
+ veto = table["veto_class"]
166
+ if veto and veto not in class_set:
167
+ raise ValueError(
168
+ f"[lifecycle].veto_class {veto!r} is not in the declared set {sorted(class_set)}"
169
+ )
170
+
171
+ def _int(key: str, current: int) -> int:
172
+ if key not in table:
173
+ return current
174
+ v = table[key]
175
+ if isinstance(v, bool) or not isinstance(v, int):
176
+ raise ValueError(f"[lifecycle].{key} must be an int, got {type(v).__name__}")
177
+ return v
178
+
179
+ return LifecyclePolicy(
180
+ classes=classes,
181
+ transitions=transitions,
182
+ veto_class=veto,
183
+ max_transitions_per_cycle=_int("max_transitions_per_cycle", base.max_transitions_per_cycle),
184
+ per_plan_cooldown_hours=_int("per_plan_cooldown_hours", base.per_plan_cooldown_hours),
185
+ )
186
+
187
+
188
+ def load_from_toml(
189
+ path, *, base: LifecyclePolicy = GENERIC_LIFECYCLE
190
+ ) -> LifecyclePolicy:
191
+ """Build a `LifecyclePolicy` from a `dos.toml`'s `[lifecycle]` table.
192
+
193
+ Returns ``base`` unchanged when the file is absent, has no `[lifecycle]` table,
194
+ or `tomllib` is unavailable. A present-but-malformed table raises. Mirrors
195
+ `stamp.load_from_toml` (incl. the `utf-8-sig` BOM strip)."""
196
+ from pathlib import Path
197
+ p = Path(path)
198
+ if not p.exists():
199
+ return base
200
+ try:
201
+ import tomllib
202
+ except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
203
+ try:
204
+ import tomli as tomllib # type: ignore
205
+ except ModuleNotFoundError:
206
+ return base
207
+ data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
208
+ table = data.get("lifecycle")
209
+ if not isinstance(table, dict) or not table:
210
+ return base
211
+ return policy_from_table(table, base=base)
dos/liveness.py ADDED
@@ -0,0 +1,352 @@
1
+ """LVN — the liveness verdict: *is the agent actually moving, or just spinning?*
2
+
3
+ docs/82 — the 4th distrust syscall, the **temporal completion of `verify()`**.
4
+ `verify` distrusts a *finished* claim ("I shipped P"); LVN distrusts an
5
+ *in-flight* one ("I'm making progress"). A spinning agent is a systematically
6
+ optimistic narrator of its own motion — it reports "almost there / refining the
7
+ approach" while re-editing the same file and landing zero commits. It cannot see
8
+ its own loop; the operator can, but only by reading output and judging by hand
9
+ the thing DOS exists to adjudicate mechanically. LVN is the verdict that ends the
10
+ watching: it asks the **git history and the lane journal**, never the agent,
11
+ whether ground-truth state advanced.
12
+
13
+ This module is `loop_decide`'s sibling — a **pure** verdict function, the
14
+ `arbitrate()` / `gate_policy` shape:
15
+
16
+ arbiter.arbitrate (request, live_leases, config) -> decision
17
+ loop_decide.decide (LoopState, IterationOutcome) -> LoopDecision
18
+ liveness.classify (ProgressEvidence, policy) -> LivenessVerdict
19
+ ^ THIS module
20
+
21
+ All I/O — reading git, the journal, the clock — happens in the CALLER (the
22
+ `dos liveness` CLI's evidence-gather), exactly as `pick_oracle`'s reads happen
23
+ outside `arbitrate()` and `verify`'s git reads happen outside the classifier.
24
+ `classify()` makes no subprocess, file, or clock call: `now_ms` is a field on the
25
+ evidence, never read inside the verdict. That is what lets the whole verdict be
26
+ replay-tested on frozen fixtures, away from anything that needs a live
27
+ multi-minute agent run to reproduce (the `loop_decide` design value, restated for
28
+ the temporal axis).
29
+
30
+ The verdict ladder, top to bottom — the whole point is that a reader holds it in
31
+ their head:
32
+
33
+ 1. ADVANCING — **any forward delta**: ≥1 commit since the run's start SHA, OR a
34
+ state-mutating lane-journal event since start. State moved. (This rung wins
35
+ over everything below — a run that committed is advancing regardless of how
36
+ fresh its heartbeat is.) Also the **young-and-alive** case: a run with a
37
+ fresh heartbeat that is younger than `grace_ms` has not earned a SPINNING
38
+ accusation yet — it is alive and we decline to judge it stuck (no liveness
39
+ *problem* detected), the grace guard against a false-positive on a run that
40
+ simply hasn't committed in its first minute. NOTE: LVN says bytes *moved* (or
41
+ "no problem yet"), never that they moved *well* — quality is an advisory
42
+ judge's call (`llm_judge`), never this deterministic kernel verb (the
43
+ distrust-state / distrust-judgment line).
44
+ 2. SPINNING — no forward delta, the run is **alive** (a heartbeat fresher than
45
+ `spin_ms`), AND it has been alive long enough to judge (run-age ≥
46
+ `grace_ms`): alive, narrating, not moving — the signal with no existing
47
+ home. This is the rung `loop_decide`'s self-report breakers can't reach,
48
+ because it reads ground truth, not the caller's `IterationOutcome` token.
49
+ 3. STALLED — no forward delta and **not alive**: the newest heartbeat is older
50
+ than `spin_ms`, or there is no heartbeat at all. The run is dead or hung —
51
+ the orphan-sweep's input, not a spin.
52
+
53
+ `SPINNING` is ADVISORY. LVN reports; it never kills a process or refuses a lease.
54
+ A loop may consult LVN and choose to stop (the natural first consumer, LVN-3a),
55
+ and the decisions queue may surface a spinning run — but the liveness verdict and
56
+ the admission decision stay different syscalls (a `LivenessPredicate` over ADM's
57
+ conjunctive seam is a possible *separate* opt-in driver policy, not LVN).
58
+
59
+ No-plan discipline (`test_verify_no_plan` sibling): LVN must return a verdict in a
60
+ plain git repo with a run-id and a start SHA and *nothing else* — no plan, no
61
+ registry, no journal, no telemetry. Commits-since-start alone is a sufficient
62
+ ADVANCING/SPINNING/STALLED signal; every richer input (`journal_events_since`,
63
+ `last_heartbeat_age_ms`, `tokens_spent_since`) is OPTIONAL and the verdict
64
+ degrades to the commit + caller-supplied-heartbeat rungs when they are absent.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import enum
70
+ from dataclasses import dataclass
71
+ from typing import Optional
72
+
73
+
74
+ class Liveness(str, enum.Enum):
75
+ """The typed liveness verdict — three states, mutually exclusive.
76
+
77
+ `str`-valued so it round-trips through a CLI stdout token / exit-code map
78
+ without a lookup table (mirrors `gate_classify.Verdict` and
79
+ `loop_decide.OutcomeKind`).
80
+ """
81
+
82
+ ADVANCING = "ADVANCING" # ground-truth state moved since the run started
83
+ SPINNING = "SPINNING" # the run is alive (heartbeat fresh) but state is NOT moving
84
+ STALLED = "STALLED" # no fresh heartbeat, no commits — dead/hung, not spinning
85
+
86
+ def __str__(self) -> str: # pragma: no cover - trivial
87
+ return self.value
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class LivenessPolicy:
92
+ """The windows that separate ADVANCING/SPINNING/STALLED — policy, not mechanism.
93
+
94
+ The same "mechanism is kernel, thresholds are config" split as
95
+ `loop_decide`'s `max_unclear` / `max_iterations`. The defaults are GENERIC
96
+ (no host tuning); a workspace declares its own in `dos.toml [liveness]`
97
+ (LVN-3c) read back through `SubstrateConfig`, the closed-config-as-data
98
+ pattern (`[lanes]` / `[stamp]` / `[reasons]`).
99
+
100
+ The two windows have distinct jobs (the spec is loose on their interaction;
101
+ this is the resolution Phase 1 pins):
102
+
103
+ spin_ms — the **heartbeat-freshness bound** that proves the run is *alive*.
104
+ A heartbeat younger than this means the process is up; older
105
+ (or absent) means it is not demonstrably alive → STALLED. It is
106
+ the alive/dead boundary.
107
+ grace_ms — the **minimum run-age** an alive-but-idle run must reach before
108
+ LVN will accuse it of SPINNING. Below it, a run with a fresh
109
+ heartbeat and no commits is simply young — alive, not yet stuck —
110
+ and the verdict withholds the accusation (reports ADVANCING:
111
+ "no liveness problem yet"). This is the false-positive guard so a
112
+ run that hasn't committed in its first minute isn't called
113
+ spinning. (Run-age is `now_ms - run_started_ms`, both on the
114
+ evidence — this is the one rung that reads them.)
115
+
116
+ Defaults: 30 min grace, 15 min spin. So: a run is alive while its heartbeat is
117
+ ≤15 min old; an alive run with no forward delta is called SPINNING only once
118
+ it is ≥30 min old; otherwise it is too young to judge (ADVANCING-benign). The
119
+ granularity matches what the spine records (minutes), per the
120
+ no-sub-second-liveness non-goal.
121
+ """
122
+
123
+ grace_ms: int = 30 * 60 * 1000 # 30 minutes — min run-age before SPINNING
124
+ spin_ms: int = 15 * 60 * 1000 # 15 minutes — heartbeat-freshness (alive) bound
125
+
126
+ def __post_init__(self) -> None:
127
+ if self.grace_ms < 0 or self.spin_ms < 0:
128
+ raise ValueError("liveness windows must be non-negative (ms)")
129
+
130
+
131
+ DEFAULT_POLICY = LivenessPolicy()
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class ProgressEvidence:
136
+ """Everything `classify()` needs, gathered by the CALLER before the call.
137
+
138
+ No git, no journal, no clock inside the verdict — the arbiter rule. The CLI's
139
+ evidence-gather (the boundary) decodes `run_id.ts_ms_of(run_id)` for the
140
+ start, counts `git_delta.commits_since(start_sha)` for the commit delta, folds
141
+ the lane journal for the heartbeat/event rungs, and reads the wall clock —
142
+ then freezes all of it here and hands it to the pure classifier.
143
+
144
+ run_started_ms — epoch-ms the run began (`run_id.ts_ms_of`). Carried
145
+ for the `--output json` consumer + age framing; the
146
+ verdict reads ages, not absolute times.
147
+ now_ms — wall-clock epoch-ms, injected at the boundary (the
148
+ env BANS `Date.now()` in reproducible paths; the
149
+ verdict never reads a clock).
150
+ commits_since_start — the authoritative forward delta
151
+ (`len(git_delta.commits_since(start_sha))`). ≥1 ⇒
152
+ ADVANCING, on its own, with everything else absent
153
+ (the no-plan floor).
154
+ journal_events_since — count of lease-*work* lane-journal events
155
+ (ACQUIRE/RELEASE/SCAVENGE/RECONCILE — NOT a keepalive
156
+ HEARTBEAT, which is a *beat* not progress; see
157
+ `journal_delta._EVENT_OPS`) since start. ≥1 ⇒
158
+ ADVANCING even with 0 commits — work is happening at
159
+ the lease layer (wired in LVN Phase 2; 0 in Phase 1).
160
+ last_heartbeat_age_ms — now − newest HEARTBEAT/ACQUIRE ts. None = never
161
+ beat (or the journal rung is absent). Separates
162
+ SPINNING (fresh) from STALLED (stale/None past grace).
163
+ tokens_spent_since — OPTIONAL waste signal: cost burned with no commit. A
164
+ workspace that tracks per-run cost may pass it; one
165
+ that doesn't passes None and the verdict is unaffected
166
+ (never required — the no-telemetry discipline). Echoed
167
+ for the json consumer; not an input to the ladder.
168
+ process_alive — OPTIONAL **unforgeable** OS-process rung (docs/95,
169
+ gathered by `proc_delta.probe` at the boundary):
170
+ True = the OS confirms the run's pid is up, False =
171
+ the OS confirms it is gone, None = could not tell
172
+ (no pid / foreign host / unsupported platform). It is
173
+ **demote-only**: a confident `False` flips an
174
+ otherwise-SPINNING run to STALLED (a fresh heartbeat on
175
+ a dead process is the forgeable-beat gap this closes);
176
+ True/None NEVER promote a dead/stalled run to alive.
177
+ A workspace that can't probe passes None and the
178
+ verdict is byte-identical to before this field existed.
179
+ """
180
+
181
+ run_started_ms: int
182
+ now_ms: int
183
+ commits_since_start: int
184
+ journal_events_since: int = 0
185
+ last_heartbeat_age_ms: Optional[int] = None
186
+ tokens_spent_since: Optional[int] = None
187
+ process_alive: Optional[bool] = None
188
+
189
+ def __post_init__(self) -> None:
190
+ if self.commits_since_start < 0 or self.journal_events_since < 0:
191
+ raise ValueError("evidence counts must be non-negative")
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class LivenessVerdict:
196
+ """The single verdict `classify()` returns, with the evidence echoed back.
197
+
198
+ `verdict` is the typed `Liveness`. `reason` is a one-line operator-facing
199
+ summary (the tally-row string). `evidence` is the `ProgressEvidence` that
200
+ drove the call, carried so `dos liveness --output json` can emit the verdict
201
+ *and the facts behind it* in one object (the renderer seam, RND/Axis-4) —
202
+ legible distrust: the operator sees not just SPINNING but *why* (0 commits,
203
+ heartbeat 8m fresh). `to_dict` is the json shape.
204
+ """
205
+
206
+ verdict: Liveness
207
+ reason: str
208
+ evidence: ProgressEvidence
209
+
210
+ def to_dict(self) -> dict:
211
+ ev = self.evidence
212
+ return {
213
+ "verdict": self.verdict.value,
214
+ "reason": self.reason,
215
+ "evidence": {
216
+ "run_started_ms": ev.run_started_ms,
217
+ "now_ms": ev.now_ms,
218
+ "commits_since_start": ev.commits_since_start,
219
+ "journal_events_since": ev.journal_events_since,
220
+ "last_heartbeat_age_ms": ev.last_heartbeat_age_ms,
221
+ "tokens_spent_since": ev.tokens_spent_since,
222
+ "process_alive": ev.process_alive,
223
+ },
224
+ }
225
+
226
+
227
+ def classify(
228
+ ev: ProgressEvidence, policy: LivenessPolicy = DEFAULT_POLICY
229
+ ) -> LivenessVerdict:
230
+ """Classify one run's liveness from already-gathered evidence. PURE — no I/O.
231
+
232
+ Reads the ladder top to bottom (this function IS the answer to "is it
233
+ moving?"):
234
+
235
+ 1. ADVANCING — any forward delta (≥1 commit OR ≥1 state-mutating journal
236
+ event since start), OR the young-and-alive case (a fresh heartbeat on a
237
+ run younger than `grace_ms`): state moved, or there is no liveness
238
+ problem to flag yet.
239
+ 2. SPINNING — no forward delta, a heartbeat fresher than `spin_ms` (alive),
240
+ AND run-age ≥ `grace_ms` (old enough to judge): alive, narrating, not
241
+ moving.
242
+ 3. STALLED — no forward delta and not alive: the newest heartbeat is older
243
+ than `spin_ms`, or there is none at all. Dead/hung.
244
+
245
+ The ADVANCING/rest boundary is the *forward delta* (never clock-dependent).
246
+ The alive/dead boundary is heartbeat freshness vs `spin_ms`. The
247
+ too-young-to-judge guard is run-age (`now_ms - run_started_ms`) vs `grace_ms`
248
+ — the ONE rung that reads the clock fields, and it reads them as a delta the
249
+ caller pre-stamped, not by calling a clock (the clock-is-injected rule).
250
+ """
251
+ # 1a. ADVANCING (forward delta) — the authoritative rung and the no-plan
252
+ # floor: a commit since start answers in a plain git repo with no journal
253
+ # at all. A state-mutating journal event counts too — work at the lease
254
+ # layer is progress even with no commit yet (wired in Phase 2;
255
+ # journal_events_since is 0 in Phase 1, so this reduces to the commit
256
+ # rung). Checked FIRST so a run that moved is never mislabelled on a
257
+ # stale-heartbeat technicality.
258
+ if ev.commits_since_start >= 1:
259
+ return LivenessVerdict(
260
+ verdict=Liveness.ADVANCING,
261
+ reason=(
262
+ f"{ev.commits_since_start} commit(s) since the run's start SHA "
263
+ f"— ground-truth state moved"
264
+ ),
265
+ evidence=ev,
266
+ )
267
+ if ev.journal_events_since >= 1:
268
+ return LivenessVerdict(
269
+ verdict=Liveness.ADVANCING,
270
+ reason=(
271
+ f"{ev.journal_events_since} state-mutating lane-journal event(s) "
272
+ f"since start — progress at the lease layer (0 commits)"
273
+ ),
274
+ evidence=ev,
275
+ )
276
+
277
+ # No forward delta. Heartbeat freshness decides alive-vs-dead; run-age decides
278
+ # whether an alive run is old enough to be called spinning.
279
+ age = ev.last_heartbeat_age_ms
280
+ alive = age is not None and age <= policy.spin_ms
281
+
282
+ if alive:
283
+ # 1b. STALLED (proc-rung demote, docs/95) — the heartbeat says alive, but
284
+ # the OS says the run's pid is CONFIDENTLY gone. A fresh heartbeat on a
285
+ # dead process is the forgeable-beat gap: the wrapper kept touching the
286
+ # beat (or the crash left a fresh `heartbeat_at`) after the process
287
+ # died. The unforgeable OS rung overrides the forgeable one — DEMOTE
288
+ # to STALLED. Checked BEFORE the young-and-alive guard: a confidently-
289
+ # dead process is dead regardless of run-age — the "too young to judge"
290
+ # grace exists to spare a LIVE young run a false SPINNING, and its
291
+ # premise (the run is up) is exactly what the OS just refuted. Only a
292
+ # confident `False` demotes; `True`/`None` fall through and the
293
+ # heartbeat verdict stands (the rung never PROMOTES dead→alive — it can
294
+ # only make the verdict more skeptical).
295
+ if ev.process_alive is False:
296
+ return LivenessVerdict(
297
+ verdict=Liveness.STALLED,
298
+ reason=(
299
+ f"heartbeat {age} ms old says alive, but the OS reports the "
300
+ f"run's process is gone — STALLED (the unforgeable proc rung "
301
+ f"overrides a forgeable heartbeat; docs/95)"
302
+ ),
303
+ evidence=ev,
304
+ )
305
+
306
+ # 1c. ADVANCING (young-and-alive guard) — an alive run younger than
307
+ # `grace_ms` has not earned a SPINNING accusation. It is up, it just
308
+ # hasn't had time to commit; reporting SPINNING here would be a
309
+ # false positive on a run in its first minutes. We withhold and report
310
+ # "no liveness problem yet" (ADVANCING is the benign/no-action verdict
311
+ # — it explicitly does NOT claim a commit landed; the reason says so).
312
+ run_age = ev.now_ms - ev.run_started_ms
313
+ if run_age < policy.grace_ms:
314
+ return LivenessVerdict(
315
+ verdict=Liveness.ADVANCING,
316
+ reason=(
317
+ f"alive (heartbeat {age} ms old) and only {run_age} ms into "
318
+ f"the run (< grace {policy.grace_ms} ms) — too young to judge "
319
+ f"spinning; no liveness problem yet (0 commits so far)"
320
+ ),
321
+ evidence=ev,
322
+ )
323
+
324
+ # 2. SPINNING — alive, old enough to judge, and not moving. A heartbeat
325
+ # fresher than `spin_ms` proves the run is up; with 0 commits and 0 new
326
+ # journal events past the grace age, it is burning tokens narrating
327
+ # motion it isn't making. The signal with no home in `loop_decide`
328
+ # (which reads the self-report, not ground truth).
329
+ return LivenessVerdict(
330
+ verdict=Liveness.SPINNING,
331
+ reason=(
332
+ f"alive (heartbeat {age} ms old ≤ spin window {policy.spin_ms} ms) "
333
+ f"and {run_age} ms into the run (≥ grace {policy.grace_ms} ms) but "
334
+ f"0 commits and 0 lane events since start — spinning"
335
+ ),
336
+ evidence=ev,
337
+ )
338
+
339
+ # 3. STALLED — no forward delta and not alive: the newest heartbeat is older
340
+ # than `spin_ms`, or there is none at all (None). The run is dead or hung,
341
+ # the orphan-sweep's input — not a spin (a spin requires proof of life).
342
+ if age is None:
343
+ reason = (
344
+ "no heartbeat and 0 commits since start "
345
+ "— run is dead or hung (never beat)"
346
+ )
347
+ else:
348
+ reason = (
349
+ f"heartbeat {age} ms old (> spin window {policy.spin_ms} ms) and "
350
+ f"0 commits since start — run is dead or hung, not spinning"
351
+ )
352
+ return LivenessVerdict(verdict=Liveness.STALLED, reason=reason, evidence=ev)