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/dispatch_top.py ADDED
@@ -0,0 +1,740 @@
1
+ """`dos top` — `top(1)` for a DOS fleet: who holds which lane, what just shipped, what's stuck.
2
+
3
+ The **live-ops** sibling of `dos decisions`. Where the decisions queue answers
4
+ "what is waiting on *me* right now," `dos top` answers "what is *running* right
5
+ now": one near-real-time screen of the lanes, the leases holding them, the recent
6
+ verdicts with a ship-oracle trust cross-check, and any lane that has stopped
7
+ moving. It is the screen an operator leaves open in a side terminal during a
8
+ fleet run — the fleet watchdog the closed-loop-control thesis wants a host to be
9
+ able to build, here as kernel-generic mechanism.
10
+
11
+ It is a **read-only projection** (the `decisions.py` discipline, restated for the
12
+ live axis): it stores nothing, mutates nothing, acquires no lease, launches no
13
+ agent. Every panel is a pure function over an in-memory payload; the only I/O is
14
+ `snapshot()` at the boundary, which reads four already-persisted sources and
15
+ freezes them. Delete this module and you lose the screen, not any data.
16
+
17
+ **Why it is kernel-generic (works in a random new repo).** job's `dispatch_top`
18
+ read its lease world from `fanout_state.py` + `execution-state.yaml` — host
19
+ workflow the kernel is fenced from. This reads the kernel's *own* lease world
20
+ instead:
21
+
22
+ lanes <- config.lanes (the generic `main`/`global` default;
23
+ a workspace's `dos.toml [lanes]` wins)
24
+ leases <- lane_journal.replay(...) (the WAL folded to the live-lease set —
25
+ the same rows execution-state.yaml held)
26
+ liveness <- liveness.classify(...) (per-lane ADVANCING/SPINNING/STALLED,
27
+ the kernel verdict, not a host health)
28
+ verdicts <- .verdict-*.json (recent no-pick/ship envelopes)
29
+ activity <- git_delta.recent_commits (so a zero-lease repo still has content)
30
+
31
+ Nothing here imports a host. In a freshly-`dos init`'d checkout there are no
32
+ leases and no verdicts yet — every reader returns empty and the screen shows the
33
+ lane roster (all FREE) plus the git-activity strip. That is the headline
34
+ contract, pinned in `tests/test_dispatch_top.py`: `snapshot()` against a plain
35
+ git repo with no `dos.toml`, no journal, no plan returns a renderable frame.
36
+
37
+ The rich live-redraw skin + the poll loop live in `dispatch_top_tui` (behind the
38
+ `[tui]` extra); this module is import-light and dependency-free so the plain-text
39
+ renderers are always available — the floor that works everywhere, exactly the
40
+ `decisions` / `decisions_tui` split.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import datetime as dt
46
+ import io
47
+ import json
48
+ import sys
49
+ from dataclasses import dataclass
50
+
51
+ if hasattr(sys.stdout, "reconfigure"):
52
+ try:
53
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
54
+ except Exception: # pragma: no cover
55
+ pass
56
+ elif not isinstance(sys.stdout, io.TextIOWrapper): # pragma: no cover
57
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
58
+
59
+ from dos import config as _config
60
+ from dos import git_delta
61
+ from dos import lane_journal
62
+ from dos import liveness as _liveness
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Status chips — the per-lane verdict, collapsed to one glyph the operator reads
67
+ # at a glance. A held lane takes its chip from the kernel liveness verdict; a
68
+ # lane with no lease is FREE. This is the kernel-honest upgrade over job's
69
+ # dispatch-top, which read STALLED/ORPHANED_WORKING/DEAD from fanout_state's
70
+ # audit: here the chip IS `liveness.classify`, the 4th distrust syscall.
71
+ # ---------------------------------------------------------------------------
72
+
73
+ CHIP_ADVANCING = "🟢 ADVANCING" # held + liveness says ground-truth state moved
74
+ CHIP_SPINNING = "🟡 SPINNING" # held + alive but not moving (burning tokens)
75
+ CHIP_STALLED = "🔴 STALLED" # held + no fresh heartbeat / no commits — dead/hung
76
+ CHIP_SPAWNING = "🔵 SPAWNING" # a run is COMING — a recent OP_SPAWN, no lease yet
77
+ CHIP_FREE = "⚪ FREE" # no lease on this lane
78
+
79
+ # liveness verdict -> chip. One home for the mapping so a new Liveness value
80
+ # surfaces here as a KeyError in tests rather than silently rendering blank.
81
+ _CHIP_BY_LIVENESS = {
82
+ _liveness.Liveness.ADVANCING: CHIP_ADVANCING,
83
+ _liveness.Liveness.SPINNING: CHIP_SPINNING,
84
+ _liveness.Liveness.STALLED: CHIP_STALLED,
85
+ }
86
+
87
+ # How long a journaled OP_SPAWN keeps a lane reading SPAWNING before it ages out.
88
+ # A loop normally goes SPAWN→preflight→ACQUIRE in seconds; once the ACQUIRE lands a
89
+ # held lease WINS the chip (the spawning fold is no-live-lease-only). The TTL is the
90
+ # self-heal for the OTHER case — a launch that DIES in preflight, which never
91
+ # acquires: its SPAWN ages out on its own rather than wedging a phantom SPAWNING
92
+ # forever (the same self-heal `lane_lease._expire_dead` gives a crashed *holder*,
93
+ # here for a never-born one). 120s is generous for any real preflight while still
94
+ # clearing a dead launch within a couple of `dos top` polls.
95
+ SPAWN_TTL_MS = 120_000
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Time helpers (mirrors decisions.py — same tolerant ISO parse + compact age).
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ def _now() -> dt.datetime:
104
+ return dt.datetime.now(dt.timezone.utc)
105
+
106
+
107
+ def _parse_iso(ts: str | None) -> dt.datetime | None:
108
+ if not ts:
109
+ return None
110
+ try:
111
+ return dt.datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
112
+ except (ValueError, TypeError):
113
+ return None
114
+
115
+
116
+ def _age_ms(ts: str | None, *, now: dt.datetime) -> int | None:
117
+ """Age of an ISO stamp in milliseconds as of ``now`` (None if unparseable)."""
118
+ t = _parse_iso(ts)
119
+ if t is None:
120
+ return None
121
+ if t.tzinfo is None:
122
+ t = t.replace(tzinfo=dt.timezone.utc)
123
+ return max(0, int((now - t).total_seconds() * 1000))
124
+
125
+
126
+ def _fmt_age(age_ms: int | None) -> str:
127
+ """Compact age from milliseconds: 45s / 18m / 2h / 3d / '—' when unknown."""
128
+ if age_ms is None:
129
+ return "—"
130
+ s = age_ms // 1000
131
+ if s < 60:
132
+ return f"{s}s"
133
+ if s < 3600:
134
+ return f"{s // 60}m"
135
+ if s < 86400:
136
+ return f"{s // 3600}h"
137
+ return f"{s // 86400}d"
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Lane roster — derived from config.lanes, never hardcoded (the drift the job
142
+ # DTOP6 guard existed to catch). A held lane outside the roster is surfaced last
143
+ # so a live lease can never be invisible (job DTOP1's "unknown-held-never-
144
+ # invisible" rule, restated against the generic taxonomy).
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def lane_roster(config: _config.SubstrateConfig) -> list[str]:
149
+ """The always-shown lane order: concurrent (cluster) lanes, then exclusive.
150
+
151
+ Deduped, declaration-order-preserving. For the generic default this is
152
+ ``["main", "global"]``; a workspace's `dos.toml [lanes]` replaces it. Never
153
+ raises — an empty taxonomy yields ``[]`` and the screen renders "(no lanes)".
154
+ """
155
+ seen: set[str] = set()
156
+ out: list[str] = []
157
+ for lane in tuple(config.lanes.concurrent) + tuple(config.lanes.exclusive):
158
+ if lane and lane not in seen:
159
+ seen.add(lane)
160
+ out.append(lane)
161
+ return out
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Lane state model + the pure adapter from a live-lease payload.
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class LaneState:
171
+ """One rendered lane row — pure data, no rich objects."""
172
+
173
+ lane: str
174
+ chip: str # one of the CHIP_* constants
175
+ loop_ts: str = "" # holding loop/run ts, "" when FREE
176
+ holder: str = "" # host:pid of the holder, "" when FREE
177
+ heartbeat_age_ms: int | None = None
178
+ liveness_reason: str = "" # the liveness verdict's one-line reason
179
+ is_exclusive: bool = False # an exclusive lane (renders a marker)
180
+
181
+ def to_dict(self) -> dict:
182
+ return {
183
+ "lane": self.lane,
184
+ "chip": self.chip,
185
+ "loop_ts": self.loop_ts,
186
+ "holder": self.holder,
187
+ "heartbeat_age_ms": self.heartbeat_age_ms,
188
+ "liveness_reason": self.liveness_reason,
189
+ "is_exclusive": self.is_exclusive,
190
+ }
191
+
192
+
193
+ def _lease_liveness(
194
+ lease: dict, *, events_since: int, now: dt.datetime, policy
195
+ ) -> _liveness.LivenessVerdict:
196
+ """Classify one held lease with the kernel liveness verdict.
197
+
198
+ The boundary builds `ProgressEvidence` from the lease row — the run-start is
199
+ its `acquired_at`, the heartbeat age is `now - heartbeat_at`, and
200
+ ``events_since`` is the count of state-mutating lane-journal events recorded
201
+ *strictly after* this lease was acquired (its own ACQUIRE is the anchor, NOT
202
+ progress — a lease that has only sat there since acquire has 0 events-since)
203
+ — and hands it to the PURE `liveness.classify`. `commits_since_start` is 0
204
+ here: a lease records no start SHA, so `dos top`'s Phase-1 liveness rung is
205
+ the heartbeat/event signal (the honest floor LVN Phase 1 set; a SHA-anchored
206
+ commit rung is a later enrichment, not needed for the watchdog screen). The
207
+ clock is injected (`now_ms`), never read inside the verdict — the arbiter
208
+ discipline.
209
+
210
+ Consequence (the bug the first smoke-test caught): an idle just-acquired lease
211
+ with a stale/absent heartbeat now correctly reads STALLED, not ADVANCING — its
212
+ lone ACQUIRE no longer counts as forward motion.
213
+ """
214
+ now_ms = int(now.timestamp() * 1000)
215
+ started_ms = _age_ms(lease.get("acquired_at"), now=now)
216
+ run_started_ms = (now_ms - started_ms) if started_ms is not None else now_ms
217
+ hb_age = _age_ms(lease.get("heartbeat_at") or lease.get("acquired_at"), now=now)
218
+ ev = _liveness.ProgressEvidence(
219
+ run_started_ms=run_started_ms,
220
+ now_ms=now_ms,
221
+ commits_since_start=0,
222
+ journal_events_since=max(0, events_since),
223
+ last_heartbeat_age_ms=hb_age,
224
+ )
225
+ return _liveness.classify(ev, policy)
226
+
227
+
228
+ def build_lane_states(
229
+ payload: dict,
230
+ *,
231
+ roster: list[str],
232
+ exclusive: tuple[str, ...] = (),
233
+ now: dt.datetime | None = None,
234
+ policy=None,
235
+ ) -> list[LaneState]:
236
+ """Pure adapter: (live-lease payload, roster) → ordered LaneState rows.
237
+
238
+ ``payload`` is ``{"leases": [...], "events_by_lane": {lane: count},
239
+ "spawning_by_lane": {lane: SpawnIntent}}`` — the shape `snapshot()` builds from
240
+ `lane_journal.replay` + the journal folds. Every lane in ``roster`` appears
241
+ exactly once; any *held* lane not in ``roster`` is appended last so a live lease
242
+ is never invisible. A lane's chip is: the kernel liveness verdict when held; else
243
+ SPAWNING when a recent OP_SPAWN says a run is coming (the SPAWN→ACQUIRE window);
244
+ else FREE. The clock is passed in (pure given ``now``).
245
+ """
246
+ now = now or _now()
247
+ policy = policy if policy is not None else _liveness.DEFAULT_POLICY
248
+ leases_by_lane = {str(l.get("lane") or ""): l for l in payload.get("leases", [])}
249
+ events_by_lane = payload.get("events_by_lane", {}) or {}
250
+ spawning_by_lane = payload.get("spawning_by_lane", {}) or {}
251
+
252
+ def _state(lane: str, lease: dict | None) -> LaneState:
253
+ excl = lane in exclusive
254
+ if lease is None:
255
+ # No live lease — but a recent OP_SPAWN means a run is COMING to this
256
+ # lane (the blind SPAWN→ACQUIRE window). Surface it as SPAWNING so the
257
+ # loop is visible the instant it commits to a lane, not only once it has
258
+ # durably acquired. A held lease (above) always wins; a stale SPAWN has
259
+ # already aged out of `spawning_by_lane`.
260
+ intent = spawning_by_lane.get(lane)
261
+ if intent is not None:
262
+ return LaneState(
263
+ lane=lane,
264
+ chip=CHIP_SPAWNING,
265
+ holder=str(getattr(intent, "holder", "") or ""),
266
+ heartbeat_age_ms=getattr(intent, "age_ms", None),
267
+ liveness_reason="a run is spawning — no lease yet",
268
+ is_exclusive=excl,
269
+ )
270
+ return LaneState(lane=lane, chip=CHIP_FREE, is_exclusive=excl)
271
+ verdict = _lease_liveness(
272
+ lease,
273
+ events_since=int(events_by_lane.get(lane, 0) or 0),
274
+ now=now,
275
+ policy=policy,
276
+ )
277
+ return LaneState(
278
+ lane=lane,
279
+ chip=_CHIP_BY_LIVENESS[verdict.verdict],
280
+ loop_ts=str(lease.get("loop_ts") or ""),
281
+ holder=str(lease.get("holder") or ""),
282
+ heartbeat_age_ms=verdict.evidence.last_heartbeat_age_ms,
283
+ liveness_reason=verdict.reason,
284
+ is_exclusive=excl,
285
+ )
286
+
287
+ states: list[LaneState] = []
288
+ seen: set[str] = set()
289
+ for lane in roster:
290
+ seen.add(lane)
291
+ states.append(_state(lane, leases_by_lane.get(lane)))
292
+ for lane, lease in leases_by_lane.items():
293
+ if lane and lane not in seen:
294
+ seen.add(lane)
295
+ states.append(_state(lane, lease))
296
+ # A SPAWNING lane outside the roster (a launcher committed to a lane the
297
+ # workspace taxonomy doesn't name) must also never be invisible — the same
298
+ # rule that surfaces an unknown HELD lane, applied to an unknown COMING one.
299
+ for lane in spawning_by_lane:
300
+ if lane and lane not in seen:
301
+ seen.add(lane)
302
+ states.append(_state(lane, None))
303
+ return states
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Recent verdicts — the .verdict-*.json envelopes, newest-first. dos top shows
308
+ # ALL recent verdicts (a ship/accept as well as a wedge), unlike the decisions
309
+ # queue which keeps only the refusal-shaped ones; the trust column cross-checks a
310
+ # claimed ship against the oracle (evidence-over-narrative as a UI affordance).
311
+ # ---------------------------------------------------------------------------
312
+
313
+ TRUST_OK = "✓oracle" # the oracle confirms the claimed pick shipped
314
+ TRUST_PENDING = "·pending" # an accept/launchable verdict the oracle hasn't seen ship
315
+ TRUST_NA = "—" # nothing to verify (a no-pick wedge/drain)
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class VerdictRow:
320
+ """One recent verdict envelope, normalized to a display row."""
321
+
322
+ tag: str
323
+ lane: str
324
+ verdict: str # ACCEPT | WEDGE | DRAIN | … (envelope's own token)
325
+ reason_token: str = "" # closed reason_class when present
326
+ pick: str = "" # "PLAN PHASE" of the lead pick, when present
327
+ trust: str = TRUST_NA
328
+ age_ms: int | None = None
329
+
330
+ def to_dict(self) -> dict:
331
+ return {
332
+ "tag": self.tag, "lane": self.lane, "verdict": self.verdict,
333
+ "reason_token": self.reason_token, "pick": self.pick,
334
+ "trust": self.trust, "age_ms": self.age_ms,
335
+ }
336
+
337
+
338
+ def _envelope_lane(env: dict) -> str:
339
+ # Normalize to the bare dynamic lane handle (dos/119) so a curated-cluster
340
+ # relic scope ("apply cluster (AFR, …)") renders as its handle, identically to
341
+ # the operator-decision queue (`decisions._dynamic_lane_handle`) — one
342
+ # normalizer, two readers can't drift.
343
+ from dos.decisions import _dynamic_lane_handle
344
+ scope = env.get("scope")
345
+ if isinstance(scope, dict):
346
+ return _dynamic_lane_handle(str(scope.get("lane") or scope.get("label") or ""))
347
+ if isinstance(scope, str):
348
+ return _dynamic_lane_handle(scope)
349
+ return _dynamic_lane_handle(str(env.get("lane") or ""))
350
+
351
+
352
+ def _envelope_lead_pick(env: dict) -> str:
353
+ for key in ("picks", "intended_picks"):
354
+ for p in env.get(key, []) or []:
355
+ if isinstance(p, dict):
356
+ plan = str(p.get("plan_id") or "").strip()
357
+ phase = str(p.get("phase_id") or "").strip()
358
+ if plan:
359
+ return f"{plan} {phase}".strip()
360
+ return ""
361
+
362
+
363
+ def parse_verdict_envelope(env: dict, tag: str, *, now: dt.datetime) -> VerdictRow:
364
+ """Pure: one parsed `.verdict-<tag>.json` dict → a VerdictRow (no trust yet).
365
+
366
+ Handles both shapes the spine writes: the clean ACCEPT envelope
367
+ (``all_clear``/``picks``, no explicit ``verdict``) and the WEDGE envelope
368
+ (``verdict``/``reason_class``/``intended_picks``). Trust is attached
369
+ separately (`attach_trust`) so this stays pure and the oracle call is an
370
+ injected boundary, exactly as job's DTOP2 kept `attach_trust` over an injected
371
+ `verify`.
372
+ """
373
+ verdict = str(env.get("verdict") or "").strip().upper()
374
+ if not verdict:
375
+ if env.get("all_clear") and not env.get("blocked"):
376
+ verdict = "ACCEPT"
377
+ elif env.get("blocked"):
378
+ verdict = "WEDGE"
379
+ else:
380
+ verdict = "UNKNOWN"
381
+ return VerdictRow(
382
+ tag=tag,
383
+ lane=_envelope_lane(env),
384
+ verdict=verdict,
385
+ reason_token=str(env.get("reason_class") or "").strip().upper(),
386
+ pick=_envelope_lead_pick(env),
387
+ age_ms=_age_ms(env.get("generated_at") or env.get("ts"), now=now),
388
+ )
389
+
390
+
391
+ def attach_trust(row: VerdictRow, verify) -> VerdictRow:
392
+ """Attach the ship-oracle trust chip to a verdict row over an injected ``verify``.
393
+
394
+ ``verify`` is a ``(plan, phase) -> bool`` shipped-check (the live path wires
395
+ `oracle.is_shipped`; tests inject a fake). A verdict with no pick has nothing
396
+ to verify (TRUST_NA). A launchable/accept verdict whose pick the oracle has
397
+ not yet seen ship reads ·pending (informational, NOT a false-ship warn — the
398
+ correction job's DTOP2 made: an ACCEPT is a go-ahead, not a ship claim). A
399
+ pick the oracle confirms reads ✓oracle. Never raises — a verify that throws
400
+ degrades the row to its current trust (fail-safe).
401
+ """
402
+ if not row.pick or verify is None:
403
+ return row
404
+ parts = row.pick.split()
405
+ plan, phase = parts[0], (parts[1] if len(parts) > 1 else "")
406
+ try:
407
+ shipped = bool(verify(plan, phase))
408
+ except Exception:
409
+ return row
410
+ chip = TRUST_OK if shipped else TRUST_PENDING
411
+ return VerdictRow(
412
+ tag=row.tag, lane=row.lane, verdict=row.verdict,
413
+ reason_token=row.reason_token, pick=row.pick, trust=chip, age_ms=row.age_ms,
414
+ )
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # The frame — everything one screen shows, as pure data. `snapshot()` builds it
419
+ # from disk; the renderers + the TUI consume it.
420
+ # ---------------------------------------------------------------------------
421
+
422
+
423
+ @dataclass(frozen=True)
424
+ class Frame:
425
+ """A single rendered moment of `dos top` — pure, serializable, testable."""
426
+
427
+ workspace: str
428
+ now_iso: str
429
+ lanes: tuple[LaneState, ...] = ()
430
+ verdicts: tuple[VerdictRow, ...] = ()
431
+ activity: tuple[dict, ...] = () # recent commits [{sha, subject}, …]
432
+ initialized: bool = True # did a dos.toml exist (vs. bare repo)?
433
+
434
+ def to_dict(self) -> dict:
435
+ return {
436
+ "workspace": self.workspace,
437
+ "now": self.now_iso,
438
+ "initialized": self.initialized,
439
+ "lanes": [s.to_dict() for s in self.lanes],
440
+ "verdicts": [v.to_dict() for v in self.verdicts],
441
+ "activity": [dict(c) for c in self.activity],
442
+ }
443
+
444
+
445
+ _WORK_OPS = frozenset({
446
+ lane_journal.OP_ACQUIRE, lane_journal.OP_RELEASE,
447
+ lane_journal.OP_SCAVENGE, lane_journal.OP_RECONCILE,
448
+ })
449
+
450
+
451
+ def _entry_ts(e: dict) -> str:
452
+ return str(e.get("ts") or e.get("heartbeat_at") or e.get("acquired_at") or "")
453
+
454
+
455
+ def _events_by_lane(entries: list[dict], live_by_lane: dict[str, dict]) -> dict[str, int]:
456
+ """Count state-mutating lane-journal events recorded AFTER each lane's acquire.
457
+
458
+ The liveness rung wants *forward* lease-layer work, not the ACQUIRE that created
459
+ the currently-held lease (counting that would make every idle just-acquired lease
460
+ read ADVANCING forever — the first-smoke-test bug). The establishing ACQUIRE is
461
+ excluded by **identity** (the first ACQUIRE we see for this lane in append order
462
+ is its birth), NOT by a `ts > acquired_at` timestamp compare — the exact fix
463
+ `journal_delta.fold_since` adopted (docs/82): the timestamp rule only excluded
464
+ the birth ACQUIRE when its `ts` was not strictly past `acquired_at`, but in a real
465
+ grant those are TWO separate clock reads (`lane_lease.acquire` stamps
466
+ `acquired_at`, then `lane_journal.append` stamps a strictly-later `ts`), so the
467
+ birth ACQUIRE's `ts > acquired_at` is true and it was counted → a held-but-idle
468
+ lane read ADVANCING forever. Identity-keyed birth-skip holds regardless of the
469
+ clock relationship; a later RELEASE/SCAVENGE/RECONCILE or a genuine re-ACQUIRE is
470
+ real work and still counts (a keepalive HEARTBEAT is a beat, not progress, and is
471
+ not in `_WORK_OPS`). A lane with no live lease gets no entry (renders FREE). Pure
472
+ over already-read entries + the replayed live-lease map.
473
+ """
474
+ out: dict[str, int] = {}
475
+ seen_birth: set[str] = set() # lanes whose establishing ACQUIRE we've skipped
476
+ for e in entries:
477
+ op = str(e.get("op") or "")
478
+ if op not in _WORK_OPS:
479
+ continue
480
+ lane = str(e.get("lane") or "")
481
+ live = live_by_lane.get(lane)
482
+ if not lane or live is None:
483
+ continue
484
+ # Skip exactly the FIRST ACQUIRE for this lane (its birth); count everything
485
+ # after — including a later re-ACQUIRE, which is real lease work.
486
+ if op == lane_journal.OP_ACQUIRE and lane not in seen_birth:
487
+ seen_birth.add(lane)
488
+ continue
489
+ out[lane] = out.get(lane, 0) + 1
490
+ return out
491
+
492
+
493
+ @dataclass(frozen=True)
494
+ class SpawnIntent:
495
+ """A lane reading SPAWNING — a recent OP_SPAWN with no live lease yet (pure data)."""
496
+
497
+ holder: str = ""
498
+ age_ms: int | None = None
499
+
500
+
501
+ def _spawning_lanes(
502
+ entries: list[dict],
503
+ live_by_lane: dict[str, dict],
504
+ *,
505
+ now: dt.datetime,
506
+ ttl_ms: int = SPAWN_TTL_MS,
507
+ ) -> dict[str, SpawnIntent]:
508
+ """Fold recent OP_SPAWN intents into the set of lanes that are SPAWNING.
509
+
510
+ A lane is SPAWNING iff it has a journaled OP_SPAWN within ``ttl_ms`` AND holds no
511
+ live lease. Both gates are load-bearing:
512
+
513
+ * **no live lease** — once the eventual ACQUIRE lands, the held lease WINS the
514
+ chip (the liveness verdict is the truth then); SPAWNING is only the
515
+ SPAWN→ACQUIRE window. A later RELEASE that returns the lane to FREE re-exposes
516
+ any *still-fresh* SPAWN, but a launch normally acquires long before that.
517
+ * **within TTL** — a launch that DIES in preflight never acquires and never
518
+ releases; its SPAWN would otherwise wedge a phantom SPAWNING forever. The TTL
519
+ ages it out on its own — the self-heal `_expire_dead` gives a crashed holder,
520
+ here for a never-born one. This is the safety property that lets the SPAWN be
521
+ a pure forensic record (never a lease): a stale intent simply disappears.
522
+
523
+ Carries the MOST RECENT spawn's holder + age for rendering (a re-launch on the
524
+ same lane refreshes the intent). Pure over already-read entries + the replayed
525
+ live-lease map; the clock is injected. A lane that is already held gets no entry.
526
+ """
527
+ # Most-recent SPAWN per lane (append order ⇒ last wins), with its age.
528
+ latest: dict[str, dict] = {}
529
+ for e in entries:
530
+ if str(e.get("op") or "") != lane_journal.OP_SPAWN:
531
+ continue
532
+ lane = str(e.get("lane") or "")
533
+ if not lane or lane in live_by_lane:
534
+ continue # held lanes take the liveness chip, not SPAWNING
535
+ latest[lane] = e # append order ⇒ this overwrites with the newer SPAWN
536
+ out: dict[str, SpawnIntent] = {}
537
+ for lane, e in latest.items():
538
+ age = _age_ms(_entry_ts(e), now=now)
539
+ if age is not None and age > ttl_ms:
540
+ continue # a dead-in-preflight launch ages out — no phantom SPAWNING
541
+ # An unreadable/absent `ts` (age is None) keeps the SPAWN visible rather than
542
+ # aging it out — but `append` ALWAYS stamps `ts`, so a real journaled SPAWN
543
+ # has a parseable age and the TTL gate is live; this only protects a
544
+ # hand-built/torn entry from vanishing silently (the row renders age `—`).
545
+ out[lane] = SpawnIntent(holder=str(e.get("holder") or ""), age_ms=age)
546
+ return out
547
+
548
+
549
+ def snapshot(
550
+ config=None, *, verify=None, verdict_limit: int = 12, activity_limit: int = 10,
551
+ now: dt.datetime | None = None,
552
+ ) -> Frame:
553
+ """Read the four sources and freeze one `Frame`. The only I/O in this module.
554
+
555
+ Every reader degrades to empty on a missing/torn source, so this returns a
556
+ renderable frame in a **brand-new repo with no DOS state at all** (the
557
+ headline contract): no journal → no leases (all lanes FREE), no verdict dir →
558
+ no verdicts, and the git-activity strip from `git_delta.recent_commits` gives
559
+ the screen real content. ``verify`` defaults to the live `oracle.is_shipped`
560
+ bound to this workspace; pass a fake in tests.
561
+ """
562
+ cfg = _config.ensure(config)
563
+ now = now or _now()
564
+
565
+ # --- leases (lane_journal WAL → live-lease set) + per-lane event counts ----
566
+ entries: list[dict] = []
567
+ try:
568
+ entries = lane_journal.read_all(cfg.paths.lane_journal)
569
+ except Exception:
570
+ entries = []
571
+ try:
572
+ leases = lane_journal.replay(entries)
573
+ except Exception:
574
+ leases = []
575
+ live_by_lane = {str(l.get("lane") or ""): l for l in leases}
576
+ payload = {
577
+ "leases": leases,
578
+ "events_by_lane": _events_by_lane(entries, live_by_lane),
579
+ "spawning_by_lane": _spawning_lanes(entries, live_by_lane, now=now),
580
+ }
581
+ roster = lane_roster(cfg)
582
+ states = build_lane_states(
583
+ payload, roster=roster, exclusive=tuple(cfg.lanes.exclusive), now=now
584
+ )
585
+
586
+ # --- recent verdicts (.verdict-*.json) + trust cross-check ----------------
587
+ if verify is None:
588
+ verify = _make_oracle_verify(cfg)
589
+ verdicts = _read_verdicts(cfg, limit=verdict_limit, verify=verify, now=now)
590
+
591
+ # --- git-activity strip (the fresh-repo content) --------------------------
592
+ try:
593
+ activity = git_delta.recent_commits(activity_limit, root=cfg.root)
594
+ except Exception:
595
+ activity = []
596
+
597
+ return Frame(
598
+ workspace=str(cfg.root),
599
+ now_iso=now.replace(microsecond=0).isoformat(),
600
+ lanes=tuple(states),
601
+ verdicts=tuple(verdicts),
602
+ activity=tuple(activity),
603
+ initialized=(cfg.root / "dos.toml").exists(),
604
+ )
605
+
606
+
607
+ def _make_oracle_verify(cfg):
608
+ """Build the live ``(plan, phase) -> bool`` over `oracle.is_shipped`, bound to cfg.
609
+
610
+ Imported lazily (oracle pulls a heavier chain) and wrapped so a missing
611
+ oracle degrades the trust column to NA rather than crashing the screen.
612
+ """
613
+ try:
614
+ from dos import oracle
615
+ except Exception:
616
+ return None
617
+
618
+ def _verify(plan: str, phase: str) -> bool:
619
+ try:
620
+ return bool(oracle.is_shipped(plan, phase, cfg=cfg).shipped)
621
+ except Exception:
622
+ return False
623
+
624
+ return _verify
625
+
626
+
627
+ def _read_verdicts(cfg, *, limit: int, verify, now: dt.datetime) -> list[VerdictRow]:
628
+ """Walk `<next_packets>/.verdict-*.json`, newest-first, → trust-attached rows."""
629
+ ndir = cfg.paths.next_packets
630
+ try:
631
+ if not ndir.exists():
632
+ return []
633
+ files = sorted(ndir.glob(".verdict-*.json"), reverse=True)
634
+ except OSError:
635
+ return []
636
+ rows: list[VerdictRow] = []
637
+ for p in files:
638
+ if len(rows) >= limit:
639
+ break
640
+ try:
641
+ env = json.loads(p.read_text(encoding="utf-8", errors="replace"))
642
+ except (OSError, json.JSONDecodeError):
643
+ continue
644
+ if not isinstance(env, dict):
645
+ continue
646
+ tag = p.name[len(".verdict-"):-len(".json")]
647
+ rows.append(attach_trust(parse_verdict_envelope(env, tag, now=now), verify))
648
+ return rows
649
+
650
+
651
+ # ---------------------------------------------------------------------------
652
+ # Rendering — the plain-text floor (always available; the rich skin is in
653
+ # dispatch_top_tui). Each renderer is pure over its data + a `now` string, so the
654
+ # tests assert byte-identical output (the job DTOP renderer discipline).
655
+ # ---------------------------------------------------------------------------
656
+
657
+ _WIDTH = 78
658
+
659
+
660
+ def render_lanes_text(states: tuple[LaneState, ...]) -> str:
661
+ out = ["LANES"]
662
+ if not states:
663
+ out.append(" (no lanes — declare [lanes] in dos.toml, or `dos init`)")
664
+ for s in states:
665
+ bits: list[str] = []
666
+ if s.loop_ts:
667
+ bits.append(f"loop={s.loop_ts}")
668
+ if s.heartbeat_age_ms is not None:
669
+ # The age field is generic; the LABEL is chip-honest — a SPAWNING lane's
670
+ # age is "how long since the spawn intent", not a heartbeat (it has no
671
+ # lease to beat yet), so it reads `spawn <age>`, a held lane `hb <age>`.
672
+ label = "spawn" if s.chip == CHIP_SPAWNING else "hb"
673
+ bits.append(f"{label} {_fmt_age(s.heartbeat_age_ms)}")
674
+ if s.holder:
675
+ bits.append(s.holder)
676
+ marker = "*" if s.is_exclusive else " "
677
+ detail = " ".join(bits)
678
+ out.append(f" {marker}{s.lane:<13} {s.chip:<13} {detail}".rstrip())
679
+ live = sum(1 for s in states if s.chip == CHIP_ADVANCING)
680
+ spin = sum(1 for s in states if s.chip == CHIP_SPINNING)
681
+ stalled = sum(1 for s in states if s.chip == CHIP_STALLED)
682
+ spawning = sum(1 for s in states if s.chip == CHIP_SPAWNING)
683
+ free = sum(1 for s in states if s.chip == CHIP_FREE)
684
+ tally = (
685
+ f" {len(states)} lanes · {live} advancing · {spin} spinning · "
686
+ f"{stalled} stalled · "
687
+ )
688
+ # Only surface the spawning count when there IS one — keep the steady-state
689
+ # summary (the byte-pinned no-spawn line) unchanged so existing renders/tests
690
+ # are undisturbed; a coming run adds a segment, it doesn't reshape the line.
691
+ if spawning:
692
+ tally += f"{spawning} spawning · "
693
+ tally += f"{free} free"
694
+ out.append(tally)
695
+ return "\n".join(out)
696
+
697
+
698
+ def render_verdicts_text(rows: tuple[VerdictRow, ...], *, limit: int = 12) -> str:
699
+ out = ["RECENT VERDICTS [trust = ship-oracle cross-check]"]
700
+ if not rows:
701
+ out.append(" (no verdicts yet)")
702
+ for r in rows[:limit]:
703
+ reason = f" {r.reason_token}" if r.reason_token else ""
704
+ trust = r.trust if r.trust != TRUST_NA else ""
705
+ out.append(
706
+ f" {_fmt_age(r.age_ms):>4} {(r.lane or '-'):<12} {r.verdict:<8} "
707
+ f"{(r.pick or '-'):<14} {trust:<10}{reason}".rstrip()
708
+ )
709
+ return "\n".join(out)
710
+
711
+
712
+ def render_activity_text(commits: tuple[dict, ...], *, limit: int = 10) -> str:
713
+ out = ["RECENT COMMITS [ground truth — git history]"]
714
+ if not commits:
715
+ out.append(" (no commits — empty or non-git workspace)")
716
+ for c in commits[:limit]:
717
+ sha = str(c.get("sha") or "")[:9]
718
+ subject = str(c.get("subject") or "")
719
+ out.append(f" {sha:<9} {subject}"[: _WIDTH + 2])
720
+ return "\n".join(out)
721
+
722
+
723
+ def render_frame_text(frame: Frame) -> str:
724
+ """The whole `dos top --once` screen as plain text — the always-available floor."""
725
+ # A long workspace path can exceed the rule width; print the header in full
726
+ # (never truncate the path the operator needs to read) and pad with `─` only
727
+ # when there is room. Truncating here mangled long temp-dir paths in testing.
728
+ head = f"┌─ dos top · {frame.workspace} · {frame.now_iso} "
729
+ out = [head + "─" * max(0, _WIDTH - len(head))]
730
+ if not frame.initialized:
731
+ out.append(" (no dos.toml — showing generic main/global; `dos init` to declare lanes)")
732
+ out.append("")
733
+ out.append(render_lanes_text(frame.lanes))
734
+ out.append("")
735
+ out.append(render_verdicts_text(frame.verdicts))
736
+ out.append("")
737
+ out.append(render_activity_text(frame.activity))
738
+ out.append("─" * _WIDTH)
739
+ out.append("read-only · q quit · this screen mutates nothing")
740
+ return "\n".join(out)