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/notify.py ADDED
@@ -0,0 +1,479 @@
1
+ """notify — push a DOS projection (decisions / fleet status) to a transport.
2
+
3
+ > **The verdict is the kernel; *where it lands* is a driver (docs/225).** DOS
4
+ > already has two read-only projections — `dos decisions` ("what needs a human")
5
+ > and `dos top` ("what is running now"). This seam turns either into ONE
6
+ > transport-agnostic `Notification` and hands it to a by-name `Notifier`. Slack is
7
+ > the first driver (`dos.drivers.notify_slack`); PagerDuty / email / a webhook are
8
+ > later drivers. **No transport name appears in this module.**
9
+
10
+ Why this module exists
11
+ ======================
12
+
13
+ A fleet runs unattended; the operator is in Slack, not watching a terminal. The
14
+ two projections that answer "what needs me" / "what's running" render only to the
15
+ local screen today. The notification spine is the seam that pushes them out.
16
+
17
+ This is the kernel's pure-protocol + by-name-resolver pattern, for the FOURTH
18
+ time, now on the DELIVERY side — after `dos.judges` (the JUDGE rung),
19
+ `dos.overlap_policy` (the disjointness scorer), and `dos.hook_dialect` (the
20
+ host-hook renderer). The shape is identical: a pure Protocol + two frozen value
21
+ types + an unshadowable built-in + a by-name resolver; every real transport (which
22
+ names a vendor as code — a `SlackNotifier` is inherently Slack-specific) lives in a
23
+ driver and registers through the `dos.notifiers` entry-point group.
24
+
25
+ The neutral payload — `Notification`
26
+ =====================================
27
+
28
+ A `Notification` is the DOS-shaped fact, NOT a transport's wire format (it is not
29
+ Slack Block Kit — that is the Slack driver's shape). A renderer (this module) turns
30
+ the typed projection data into a `Notification`; a driver turns the `Notification`
31
+ into its transport's bytes. `Notification.key` is the load-bearing field for the
32
+ edit-in-place surface (`dos top` streamed into ONE re-edited message): a notifier
33
+ that can edit keys its single message on `key`; one that cannot ignores it and
34
+ posts.
35
+
36
+ The two adapters are PURE over the *already-built* projection data
37
+ ==================================================================
38
+
39
+ The hard part is already done: `decisions.collect_decisions()` and
40
+ `dispatch_top.snapshot()` return typed, ranked, `to_dict()`-able data, and their
41
+ `render_*` functions produce the plain-text body. So the adapters here take that
42
+ data as ARGUMENTS (duck-typed — they read only the fields they need) and are pure:
43
+ data in, `Notification` out, no I/O. The CLI verb (layer 3) is what calls the
44
+ readers and hands the result in — that keeps `notify.py` a true layer-1 leaf that
45
+ imports no layer-3 module (the dependency-arrow rule; the same reason
46
+ `liveness.classify` takes evidence rather than reading git itself).
47
+
48
+ Failure direction = fail-SOFT
49
+ =============================
50
+
51
+ Unlike `hook_dialect` (fail-LOUD: a wrong dialect is a silent no-op against the
52
+ host, so it raises), a notification is advisory telemetry. A transport that raises
53
+ or is mis-wired must NEVER crash the fleet loop that emitted it. So `send_safely`
54
+ converts any `send` raise into a non-delivered `NotifyResult`. A *resolve* of an
55
+ unknown notifier name still raises (operator error, surfaced at config time, like
56
+ `resolve_judge`); a *send* never does. This is `LiveMessage._warn`'s philosophy —
57
+ "a streaming UI never crashes its producer" — lifted to the seam.
58
+
59
+ The advisory floor (docs/99)
60
+ ============================
61
+
62
+ The notifier REPORTS; it never acts on the fleet. It cannot take a lease, stop a
63
+ run, or mutate state — it is a pure read-of-a-projection → push. A LIVENESS-halt
64
+ notification *describes* a proposed stop and carries the paste-to-stop command in a
65
+ field; enacting it stays the operator's call. `decisions.py`'s locked
66
+ read-only-router model, extended across the network boundary.
67
+
68
+ Pure-stdlib. The renderers + resolver are the unit-test surface; the only I/O in
69
+ the whole spine is inside a driver's `send`.
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import enum
75
+ import sys
76
+ from dataclasses import dataclass
77
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
78
+
79
+ if TYPE_CHECKING: # pragma: no cover - typing only; never imported at runtime
80
+ from dos.decisions import Decision
81
+ from dos.dispatch_top import Frame
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # The closed severity vocabulary. `str`-valued so it round-trips through `--json`
86
+ # without a lookup table (the `gate_classify.Verdict` / `DecisionKind` posture).
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ class Severity(str, enum.Enum):
91
+ """How loud a notification is — the transport maps this to colour/emoji/route."""
92
+
93
+ INFO = "INFO" # a status digest, nothing wrong
94
+ WARN = "WARN" # a refusal / wedge / spinning lane is pending
95
+ URGENT = "URGENT" # a LIVENESS halt / stalled lane — a run is hung NOW
96
+
97
+ def __str__(self) -> str: # pragma: no cover - trivial
98
+ return self.value
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # The neutral payload — the DOS-shaped fact a driver renders to its wire format.
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class Notification:
108
+ """One transport-agnostic notification — pure data, no wire format.
109
+
110
+ `summary` is the plain-text body (reuses the projection's own renderer, so a
111
+ notifier with no rich surface still says everything). `fields` are the TOP rows
112
+ as `(label, value)` pairs — what a Block-Kit/section transport shows at a
113
+ glance. `key` is the stable identity for edit-in-place (a notifier that can
114
+ edit re-uses one message keyed on it); `source` names which projection this is.
115
+ """
116
+
117
+ severity: Severity
118
+ title: str
119
+ summary: str
120
+ fields: tuple[tuple[str, str], ...] = ()
121
+ key: str = ""
122
+ source: str = ""
123
+
124
+ def to_dict(self) -> dict:
125
+ return {
126
+ "severity": self.severity.value,
127
+ "title": self.title,
128
+ "summary": self.summary,
129
+ "fields": [list(f) for f in self.fields],
130
+ "key": self.key,
131
+ "source": self.source,
132
+ }
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # The result a driver returns — fail-soft: delivered=False is normal, never a raise.
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class NotifyResult:
142
+ """The outcome of a `send` — always returned, never raised (fail-soft).
143
+
144
+ `delivered` is True iff the transport accepted it. `detail` is a one-line human
145
+ reason (`"posted ts=…"` / `"edited"` / `"dry-run"` / `"no token — skipped"` /
146
+ `"error: …"`). `ref` is the transport's message id (a Slack `ts`) when one
147
+ exists, so a later edit-in-place can target the same message.
148
+ """
149
+
150
+ delivered: bool
151
+ detail: str = ""
152
+ ref: str = ""
153
+
154
+ def to_dict(self) -> dict:
155
+ return {"delivered": self.delivered, "detail": self.detail, "ref": self.ref}
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # The Notifier protocol + the unshadowable built-in null sink.
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ @runtime_checkable
164
+ class Notifier(Protocol):
165
+ """A transport that delivers a `Notification`. The driver-side seam.
166
+
167
+ `name` is the registered name (`"slack"`); `send` delivers and returns a
168
+ `NotifyResult`. An implementation MAY post a fresh message or edit one in place
169
+ (keyed on `note.key`) — that choice is the driver's, not the kernel's.
170
+ """
171
+
172
+ name: str
173
+
174
+ def send(self, note: Notification) -> NotifyResult: ...
175
+
176
+
177
+ class NullNotifier:
178
+ """The honest zero — delivers nothing, the unshadowable built-in baseline.
179
+
180
+ The default notifier, so a bare `dos notify` is a safe no-op that renders the
181
+ payload and sends it nowhere (the `AbstainJudge` / `prefix`-floor analogue: the
182
+ built-in that can never loosen anything and is always resolvable). A host opts
183
+ IN to a real transport by naming one (`--notifier slack`).
184
+ """
185
+
186
+ name = "null"
187
+
188
+ def send(self, note: Notification) -> NotifyResult:
189
+ return NotifyResult(delivered=False, detail="null sink (no transport configured)")
190
+
191
+
192
+ _BUILT_IN_NOTIFIERS: dict[str, type] = {"null": NullNotifier}
193
+
194
+ NOTIFIER_ENTRY_POINT_GROUP = "dos.notifiers"
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # The pure adapters — typed projection data → a Notification. No I/O; duck-typed
199
+ # over the fields they read (so this module imports no layer-3 module at runtime).
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ def _decision_field(d: "Decision") -> tuple[str, str]:
204
+ """One decision → a `(label, value)` field. `label` is kind@lane, `value` the why.
205
+
206
+ Pure. Reads only `.kind`/`.lane`/`.reason_token`/`.reason_text`/`.dup_count`
207
+ (duck-typed). A LIVENESS row's proposed stop command, when present, is appended
208
+ to the value so the page CARRIES the paste-to-stop (advisory — the operator
209
+ enacts it).
210
+ """
211
+ kind = getattr(getattr(d, "kind", ""), "value", "") or str(getattr(d, "kind", ""))
212
+ lane = getattr(d, "lane", "") or "-"
213
+ label = f"{kind} @ {lane}"
214
+ value = getattr(d, "reason_token", "") or getattr(d, "reason_text", "") or "?"
215
+ dup = getattr(d, "dup_count", 1) or 1
216
+ if dup > 1:
217
+ value = f"{value} (×{dup})"
218
+ cmd = getattr(d, "proposed_command", "") or ""
219
+ if cmd:
220
+ value = f"{value} ⟶ stop: {cmd}"
221
+ return (label, value[:300])
222
+
223
+
224
+ def notification_for_decisions(
225
+ rows: list["Decision"], *, summary: str = "", top: int = 5
226
+ ) -> Notification:
227
+ """Pure: the ranked decision rows → one `Notification` (the digest surface).
228
+
229
+ `rows` is the output of `decisions.collect_decisions(...)` — ALREADY ranked
230
+ (LIVENESS halt first, then refusals, then wedges, then soak gates), so the first
231
+ `top` rows ARE the TOP decisions to surface. `summary` is the prebuilt
232
+ plain-text body (`decisions.render_list_plain(rows)`); the caller passes it so
233
+ this module needs no `decisions` import. Severity rises with the worst row: a
234
+ LIVENESS halt → URGENT, any pending row → WARN, an empty queue → INFO.
235
+
236
+ `key` is workspace-stable so a notifier that edits in place keeps one digest
237
+ message current rather than spamming; the digest surface posts by default
238
+ (see the driver), but the key is available either way.
239
+ """
240
+ n = len(rows)
241
+ has_liveness = any(
242
+ (getattr(getattr(r, "kind", None), "value", "") == "LIVENESS") for r in rows
243
+ )
244
+ if has_liveness:
245
+ severity = Severity.URGENT
246
+ elif n:
247
+ severity = Severity.WARN
248
+ else:
249
+ severity = Severity.INFO
250
+
251
+ if n == 0:
252
+ title = "fleet clear — no pending decisions"
253
+ else:
254
+ title = f"{n} decision{'s' if n != 1 else ''} need you"
255
+ fields = tuple(_decision_field(r) for r in rows[: max(0, top)])
256
+ return Notification(
257
+ severity=severity,
258
+ title=title,
259
+ summary=summary,
260
+ fields=fields,
261
+ key="dos-decisions",
262
+ source="decisions",
263
+ )
264
+
265
+
266
+ def notification_for_top(frame: "Frame", *, summary: str = "") -> Notification:
267
+ """Pure: a `dos top` `Frame` → one `Notification` (the live-status surface).
268
+
269
+ `frame` is the output of `dispatch_top.snapshot(...)`; `summary` is the prebuilt
270
+ screen (`dispatch_top.render_frame_text(frame)`). Severity rises with the worst
271
+ lane: any STALLED lane → URGENT, any SPINNING → WARN, else INFO. `fields` are the
272
+ non-FREE lanes (`lane → chip + holder`) plus a recent-verdict tally — the
273
+ glance view. `key` is workspace-stable so the driver streams the status into ONE
274
+ edited message (the `LiveMessage` use-case) instead of a post per tick.
275
+
276
+ Duck-typed over `frame.lanes` (each with `.lane`/`.chip`/`.holder`) and
277
+ `frame.verdicts`/`frame.workspace` — no `dispatch_top` import at runtime.
278
+ """
279
+ lanes = tuple(getattr(frame, "lanes", ()) or ())
280
+ # The chip strings carry their state word ("🟢 ADVANCING" / "🟡 SPINNING" /
281
+ # "🔴 STALLED" / "⚪ FREE"); we read the WORD, not the glyph, so a chip-format
282
+ # tweak in dispatch_top does not silently break the severity here.
283
+ def _word(chip: str) -> str:
284
+ parts = str(chip or "").split()
285
+ return parts[-1] if parts else ""
286
+
287
+ words = [_word(getattr(s, "chip", "")) for s in lanes]
288
+ advancing = sum(1 for w in words if w == "ADVANCING")
289
+ spinning = sum(1 for w in words if w == "SPINNING")
290
+ stalled = sum(1 for w in words if w == "STALLED")
291
+ free = sum(1 for w in words if w == "FREE")
292
+
293
+ if stalled:
294
+ severity = Severity.URGENT
295
+ elif spinning:
296
+ severity = Severity.WARN
297
+ else:
298
+ severity = Severity.INFO
299
+
300
+ title = (
301
+ f"fleet: {advancing} advancing · {spinning} spinning · "
302
+ f"{stalled} stalled · {free} free"
303
+ )
304
+
305
+ fields: list[tuple[str, str]] = []
306
+ for s in lanes:
307
+ if _word(getattr(s, "chip", "")) == "FREE":
308
+ continue
309
+ holder = getattr(s, "holder", "") or ""
310
+ val = getattr(s, "chip", "")
311
+ if holder:
312
+ val = f"{val} {holder}"
313
+ fields.append((getattr(s, "lane", "") or "-", str(val)[:300]))
314
+ nverd = len(tuple(getattr(frame, "verdicts", ()) or ()))
315
+ if nverd:
316
+ fields.append(("recent verdicts", str(nverd)))
317
+
318
+ return Notification(
319
+ severity=severity,
320
+ title=title,
321
+ summary=summary,
322
+ fields=tuple(fields),
323
+ key=f"dos-top:{getattr(frame, 'workspace', '')}",
324
+ source="top",
325
+ )
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Resolver + discovery — built-ins first, then the `dos.notifiers` plugins. The
330
+ # `resolve_judge` / `resolve_dialect` shape; discovery I/O at the call boundary.
331
+ # ---------------------------------------------------------------------------
332
+
333
+
334
+ def _discover_entry_point_notifiers(*, _stderr=None) -> list[tuple[str, "Notifier"]]:
335
+ """Every `dos.notifiers` plugin as `(name, notifier)`, sorted by name.
336
+
337
+ A plugin that fails to load is SKIPPED with a one-line stderr note rather than
338
+ crashing — the `_discover_entry_point_judges` posture (a broken third-party
339
+ plugin is the operator's to fix, not a kernel fault). Does entry-point I/O, so
340
+ it is a call-boundary helper, never called inside an adapter.
341
+ """
342
+ stderr = _stderr if _stderr is not None else sys.stderr
343
+ out: list[tuple[str, Notifier]] = []
344
+ try:
345
+ from importlib.metadata import entry_points
346
+ except Exception: # pragma: no cover - importlib.metadata always present py3.11+
347
+ return out
348
+ try:
349
+ eps = entry_points(group=NOTIFIER_ENTRY_POINT_GROUP)
350
+ except TypeError: # pragma: no cover - py<3.10 selectable-API fallback
351
+ eps = entry_points().get(NOTIFIER_ENTRY_POINT_GROUP, []) # type: ignore[attr-defined]
352
+ except Exception: # pragma: no cover - defensive: never let discovery crash a call
353
+ return out
354
+ for ep in sorted(eps, key=lambda e: e.name):
355
+ try:
356
+ obj = ep.load()
357
+ notifier = obj() if isinstance(obj, type) else obj
358
+ except Exception as e: # pragma: no cover - depends on third-party plugin
359
+ print(
360
+ f"warning: notifier plugin {ep.name!r} failed to load ({e}); skipping",
361
+ file=stderr,
362
+ )
363
+ continue
364
+ out.append((ep.name, notifier))
365
+ return out
366
+
367
+
368
+ def _accepted_kwargs(ctor: type, kwargs: dict) -> dict:
369
+ """Filter `kwargs` to the parameters `ctor.__init__` actually accepts.
370
+
371
+ The notification CLI builds ONE superset bag (channel/url/token/dry_run/root)
372
+ and hands it to whichever transport was named, so it need not branch per driver.
373
+ But a transport's `__init__` is keyword-only and would raise on an unexpected
374
+ kwarg (`slack` does not take `url`; `webhook` does not take `channel` unless it
375
+ opts in). So we pass only the parameters the constructor declares — unless it
376
+ declares `**kwargs` (a `VAR_KEYWORD` param), in which case it absorbs the rest and
377
+ we forward everything. Pure introspection; no I/O.
378
+ """
379
+ try:
380
+ import inspect
381
+
382
+ params = inspect.signature(ctor).parameters
383
+ except (TypeError, ValueError): # pragma: no cover - builtins without a signature
384
+ return kwargs
385
+ if any(p.kind is p.VAR_KEYWORD for p in params.values()):
386
+ return kwargs
387
+ return {k: v for k, v in kwargs.items() if k in params}
388
+
389
+
390
+ def resolve_notifier(name: str, *, _stderr=None, **kwargs) -> "Notifier":
391
+ """Resolve a notifier by name: built-ins first, then `dos.notifiers` plugins.
392
+
393
+ Built-ins (`null`) resolve FIRST and cannot be shadowed (the trusted-fallback
394
+ guarantee, identical to `resolve_judge`). An unknown name fails LOUD with the
395
+ known list — a typo'd `--notifier` is an operator error, never a silent degrade
396
+ to `null` (which would drop every notification quietly). `kwargs` (e.g.
397
+ `channel=…`, `url=…`, `token=…`, `dry_run=…`) are forwarded to a CONSTRUCTOR-style
398
+ occupant (a `type`), FILTERED to the parameters that constructor accepts (so the
399
+ CLI can hand the same superset to any transport); a pre-built instance ignores
400
+ them.
401
+ """
402
+ if name in _BUILT_IN_NOTIFIERS:
403
+ cls = _BUILT_IN_NOTIFIERS[name]
404
+ accepted = _accepted_kwargs(cls, kwargs)
405
+ return cls(**accepted) if accepted else cls()
406
+ # For discovered plugins we resolve the ENTRY POINT and, if it is a class,
407
+ # construct it with kwargs (so the CLI can pass channel/token). A plugin that
408
+ # exposes a pre-built instance is used as-is.
409
+ stderr = _stderr if _stderr is not None else sys.stderr
410
+ try:
411
+ from importlib.metadata import entry_points
412
+ except Exception: # pragma: no cover
413
+ entry_points = None # type: ignore[assignment]
414
+ found: object | None = None
415
+ if entry_points is not None:
416
+ try:
417
+ eps = entry_points(group=NOTIFIER_ENTRY_POINT_GROUP)
418
+ except TypeError: # pragma: no cover - py<3.10
419
+ eps = entry_points().get(NOTIFIER_ENTRY_POINT_GROUP, []) # type: ignore[attr-defined]
420
+ except Exception: # pragma: no cover
421
+ eps = []
422
+ for ep in eps:
423
+ if ep.name == name:
424
+ try:
425
+ found = ep.load()
426
+ except Exception as e: # pragma: no cover - third-party
427
+ raise ValueError(
428
+ f"notifier {name!r} failed to load: {e}"
429
+ ) from e
430
+ break
431
+ if found is not None:
432
+ if isinstance(found, type):
433
+ accepted = _accepted_kwargs(found, kwargs)
434
+ return found(**accepted) # type: ignore[return-value]
435
+ return found # a pre-built instance ignores kwargs
436
+ discovered = [n for n, _ in _discover_entry_point_notifiers(_stderr=stderr)]
437
+ known = sorted(set(_BUILT_IN_NOTIFIERS) | set(discovered))
438
+ raise ValueError(f"unknown notifier {name!r}; known: {', '.join(known)}")
439
+
440
+
441
+ def active_notifiers(*, _stderr=None) -> list[tuple[str, "Notifier"]]:
442
+ """Every resolvable notifier as `(name, notifier)` — built-ins THEN discovered.
443
+
444
+ The order `dos doctor` would list. Does entry-point discovery (I/O), so it is a
445
+ call-boundary helper, never called inside an adapter (the `active_judges` rule).
446
+ """
447
+ built: list[tuple[str, Notifier]] = [(n, cls()) for n, cls in _BUILT_IN_NOTIFIERS.items()]
448
+ return built + _discover_entry_point_notifiers(_stderr=_stderr)
449
+
450
+
451
+ def active_notifier_names(*, _stderr=None) -> list[str]:
452
+ """The names of every active notifier (built-in + discovered)."""
453
+ return [name for name, _ in active_notifiers(_stderr=_stderr)]
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # send_safely — the fail-soft wrapper. A send NEVER crashes the producer.
458
+ # ---------------------------------------------------------------------------
459
+
460
+
461
+ def send_safely(notifier: "Notifier", note: Notification) -> NotifyResult:
462
+ """Deliver `note` via `notifier`, converting ANY raise to a non-delivered result.
463
+
464
+ The fail-soft floor (`LiveMessage._warn`, lifted): a notification is advisory
465
+ telemetry, so a transport that raises (network down, bad token, a buggy plugin)
466
+ must never propagate into the fleet loop that emitted it. A clean `send` returns
467
+ its own `NotifyResult`; a raise becomes `NotifyResult(delivered=False,
468
+ detail="error: …")`. (Contrast `resolve_notifier`, which DOES raise on an
469
+ unknown name — that is a config-time operator error, surfaced before any send.)
470
+ """
471
+ try:
472
+ result = notifier.send(note)
473
+ except Exception as e: # noqa: BLE001 - advisory telemetry must not crash the producer
474
+ return NotifyResult(delivered=False, detail=f"error: {e}")
475
+ if isinstance(result, NotifyResult):
476
+ return result
477
+ # A misbehaving occupant returned a non-NotifyResult; treat as a soft failure
478
+ # rather than trusting an unknown shape downstream.
479
+ return NotifyResult(delivered=False, detail="notifier returned a non-NotifyResult")
dos/observe.py ADDED
@@ -0,0 +1,175 @@
1
+ """`dos observe` — the verdict-journal projection, read-only (docs/262).
2
+
3
+ The reader over the verdict journal (`verdict_journal`), the way `decisions` reads
4
+ the refusal sources and `trace` reads the spine/ledger/WAL/git. It answers the
5
+ questions the verdict journal was built to make cheap:
6
+
7
+ dos observe # fleet-wide rollup: counts per syscall × verdict
8
+ dos observe --run <run_id> # one run's verdict history, in order
9
+ dos observe --syscall NAME # filter the rollup/history to one dimension
10
+ dos observe --by verdict # fold on a different dimension (verdict/run_id/lane/source)
11
+ dos observe --tail N # the last N raw events
12
+ dos observe --json # machine-readable (for the trajectory-audit)
13
+
14
+ It is a **read-only projection**, never a store — the `decisions`/`trace`/`top`
15
+ contract: it reads the journal only, takes no lease, mints no belief, adjudicates
16
+ *nothing new*. The verdicts it shows were minted by the syscalls; this module only
17
+ folds and renders them. Delete it and you lose the reader, not the data.
18
+
19
+ Pure-where-it-can-be: the fold (`verdict_journal.rollup`/`for_run`) and the render
20
+ functions here are pure and are the unit-test surface; the only I/O is the single
21
+ `verdict_journal.read_all` at the boundary (mirrors `decisions.collect_decisions` /
22
+ `timeline.build_timeline`).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import io
27
+ import sys
28
+ from dataclasses import dataclass
29
+
30
+ if hasattr(sys.stdout, "reconfigure"):
31
+ try:
32
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
33
+ except Exception: # pragma: no cover
34
+ pass
35
+ elif not isinstance(sys.stdout, io.TextIOWrapper): # pragma: no cover
36
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
37
+
38
+ from dos import verdict_journal as _vj
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # The assembled projection — one read, filtered + folded. The render surface.
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ObserveFrame:
48
+ """What every observe renderer consumes — the filtered events + their rollup.
49
+
50
+ `events` is the filtered, append-ordered event list (after `--run`/`--syscall`);
51
+ `rollup` is the fold over them on the requested dimension; `corrupt` is the
52
+ integrity tally (count of `_CORRUPT` sentinel lines in the journal). `run` /
53
+ `syscall_filter` echo the active filters so a renderer can title itself.
54
+ """
55
+
56
+ events: tuple[_vj.VerdictEvent, ...]
57
+ rollup: _vj.VerdictRollup
58
+ corrupt: int
59
+ run: str = ""
60
+ syscall_filter: str = ""
61
+
62
+ def to_dict(self) -> dict:
63
+ return {
64
+ "run": self.run,
65
+ "syscall_filter": self.syscall_filter,
66
+ "corrupt": self.corrupt,
67
+ "rollup": self.rollup.to_dict(),
68
+ "events": [e.to_dict() for e in self.events],
69
+ }
70
+
71
+
72
+ def build_frame(*, run: str = "", syscall: str = "", by: str = "syscall",
73
+ path=None) -> ObserveFrame:
74
+ """Read the journal once, apply the filters, fold the rest. Read-only.
75
+
76
+ `run` filters to one run_id (the `trace` join key); `syscall` filters to one
77
+ dimension; `by` chooses the rollup dimension. A single `read_all` does the I/O;
78
+ everything after is pure (`for_run`/`rollup` + a list-comprehension filter), so
79
+ the assembly is unit-tested without a file the way `decisions.collect_decisions`
80
+ is.
81
+ """
82
+ raw = _vj.read_all(path)
83
+ corrupt = _vj.count_corrupt(raw)
84
+ events = [
85
+ _vj.VerdictEvent.from_record(rec)
86
+ for rec in raw
87
+ if rec.get("op") != "_CORRUPT"
88
+ ]
89
+ if run:
90
+ events = _vj.for_run(events, run)
91
+ if syscall:
92
+ events = [e for e in events if e.syscall == syscall]
93
+ roll = _vj.rollup(events, by=by, corrupt=corrupt)
94
+ return ObserveFrame(
95
+ events=tuple(events),
96
+ rollup=roll,
97
+ corrupt=corrupt,
98
+ run=run,
99
+ syscall_filter=syscall,
100
+ )
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Rendering — the plain-text floor (rollup table + optional event list).
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def render_rollup_text(frame: ObserveFrame) -> str:
109
+ """The fleet-wide rollup: a compact per-dimension verdict-count table.
110
+
111
+ One block per dimension value (a syscall, by default), each listing its verdict
112
+ tokens and counts. Mirrors the small-column idiom of `decisions.render_list_plain`.
113
+ """
114
+ roll = frame.rollup
115
+ out: list[str] = []
116
+ title = "# observe"
117
+ if frame.run:
118
+ title += f" · run {frame.run}"
119
+ if frame.syscall_filter:
120
+ title += f" · syscall {frame.syscall_filter}"
121
+ out.append(title)
122
+ out.append(f" {roll.total} verdict event(s) recorded"
123
+ + (f", folded by {roll.by}" if roll.by != "syscall" else ""))
124
+ if roll.corrupt:
125
+ out.append(f" ⚠ {roll.corrupt} corrupt/unreadable journal line(s) "
126
+ f"(integrity breach — not a torn tail)")
127
+ if not roll.total:
128
+ out.append(" (no verdicts recorded yet — wire a syscall to "
129
+ "verdict_journal.record, or run with DISPATCH_OBSERVE=1)")
130
+ return "\n".join(out)
131
+ out.append("")
132
+ for dim in roll.dimensions:
133
+ bucket = roll.counts.get(dim, {})
134
+ n = sum(bucket.values())
135
+ # "liveness 47 ADVANCING=40 SPINNING=5 STALLED=2"
136
+ pairs = " ".join(f"{tok}={cnt}" for tok, cnt in bucket.items())
137
+ out.append(f" {dim:<16} {n:>4} {pairs}")
138
+ return "\n".join(out)
139
+
140
+
141
+ _TS_W = 20
142
+
143
+
144
+ def render_history_text(frame: ObserveFrame, *, limit: int = 0) -> str:
145
+ """The per-run (or filtered) verdict history, one event per line, in order.
146
+
147
+ `limit > 0` shows only the last `limit` events. Used by `--run` and `--tail`.
148
+ Provenance-first: ts, syscall, verdict, the run/subject/lane, and the evidence
149
+ detail that produced it — the byte-clean counts, never narration.
150
+ """
151
+ out: list[str] = []
152
+ title = "# observe · history"
153
+ if frame.run:
154
+ title += f" · run {frame.run}"
155
+ if frame.syscall_filter:
156
+ title += f" · syscall {frame.syscall_filter}"
157
+ out.append(title)
158
+ events = list(frame.events)
159
+ if limit and limit > 0:
160
+ events = events[-limit:]
161
+ if not events:
162
+ out.append(" (no matching verdict events)")
163
+ return "\n".join(out)
164
+ header = f" {'ts':<{_TS_W}} {'syscall':<12} {'verdict':<14} subject / detail"
165
+ out.append(header)
166
+ out.append(" " + "-" * (len(header) - 2))
167
+ for e in events:
168
+ subj = e.subject or e.lane or ""
169
+ det = ""
170
+ if e.detail:
171
+ det = " {" + ", ".join(f"{k}={v}" for k, v in sorted(e.detail.items())) + "}"
172
+ run_tag = f" [{e.run_id}]" if (e.run_id and not frame.run) else ""
173
+ out.append(f" {(e.ts or '-'):<{_TS_W}} {(e.syscall or '-'):<12} "
174
+ f"{(e.verdict or '-'):<14} {subj}{det}{run_tag}")
175
+ return "\n".join(out)