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
@@ -0,0 +1,373 @@
1
+ """dos.drivers.notify_slack — the Slack occupant of the `dos.notify` seam (docs/225).
2
+
3
+ The first transport behind the notification spine. Where the kernel seam
4
+ (`dos.notify`) is transport-agnostic and names no vendor, THIS is where "Slack" is
5
+ allowed to be code (a `SlackNotifier` is inherently Slack-specific — the
6
+ `GeminiDialect` / `LlmJudge` rule). It registers through the `dos.notifiers`
7
+ entry-point group, so `resolve_notifier("slack")` finds it by name and no kernel
8
+ module imports it.
9
+
10
+ What it delivers
11
+ ================
12
+
13
+ A `Notification` → Slack, in one of two shapes the operator picked (docs/225):
14
+
15
+ * **decisions digest** (`source="decisions"`): a fresh Block-Kit POST per run —
16
+ `chat.postMessage`. Cron/event-driven; each push is its own message.
17
+ * **live fleet status** (`source="top"`): ONE message EDITED in place as the
18
+ fleet moves — a `slack_helpers.LiveMessage` keyed on `note.key` so the status
19
+ stream updates a single message instead of spamming the channel (the
20
+ `LiveMessage` reason-to-exist). The kernel `note.key` is the edit handle.
21
+
22
+ `edit_in_place` overrides the source-based default (None = auto: a `top`
23
+ notification edits, a `decisions` digest posts).
24
+
25
+ Disciplines (inherited from the seam)
26
+ =====================================
27
+
28
+ * **Fail-soft.** `send` returns a `NotifyResult`, never raises — a missing token,
29
+ an absent `slack_helpers` extra, or a transport error all degrade to
30
+ `delivered=False` with a one-line reason. (The seam's `send_safely` is the
31
+ outer net; this is the inner one, so even a direct `SlackNotifier().send(...)`
32
+ is crash-free.)
33
+ * **Advisory only.** It renders a projection → push. It mutates no DOS state, takes
34
+ no lease, stops no run. A LIVENESS-halt field CARRIES the paste-to-stop command
35
+ (built by the seam); it never enacts it.
36
+ * **Lazy import + optional dep.** `slack_helpers` (which pulls `requests`) is in
37
+ the `[notify-slack]` extra, NOT the core. It is imported INSIDE `send`/the
38
+ client builder; absent → a `NotifyResult` with an install hint, never an
39
+ `ImportError` at module load (the `dos_mcp` posture). So importing this driver —
40
+ which entry-point discovery does — never fails for lack of the extra.
41
+
42
+ Credentials / routing (the `slack_helpers` convention)
43
+ =======================================================
44
+
45
+ * **token**: explicit arg › `$SLACK_BOT_TOKEN` › the workspace `.env`
46
+ (`<root>/.env`, the file `slack_helpers` itself reads).
47
+ * **channel**: a logical name resolved through `slack_helpers/slack_config.json`
48
+ (`{"channels": {...}}`), or a raw channel id (`C0…`) passed straight through.
49
+
50
+ Block Kit is built HERE, locally — a small DOS-shaped builder (the spine's analogue
51
+ of `slack_helpers.build_upload_blocks`), so the kernel seam stays Block-Kit-free.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import os
57
+ from pathlib import Path
58
+
59
+ from dos.notify import Notification, NotifyResult
60
+
61
+ # The severity → header glyph map. Matches `dispatch_top`'s chip glyphs so the two
62
+ # surfaces read the same in a channel (🟢/🟡/🔴), with a neutral bell for a plain
63
+ # INFO digest that is not a fleet-status frame.
64
+ _SEV_EMOJI = {
65
+ "INFO": ":large_blue_circle:",
66
+ "WARN": ":large_yellow_circle:",
67
+ "URGENT": ":red_circle:",
68
+ }
69
+
70
+ # Slack section `fields` cap at 10; a header/section text caps well under 3000.
71
+ _MAX_FIELDS = 10
72
+ _MAX_SUMMARY = 2800
73
+
74
+
75
+ def build_blocks(note: Notification) -> list[dict]:
76
+ """A `Notification` → Slack Block Kit blocks (pure; no I/O).
77
+
78
+ A `header` (severity emoji + title), a `section` of the TOP `fields` as
79
+ mrkdwn pairs (capped at Slack's 10), the plain-text `summary` in a fenced code
80
+ block (so a notifier with no rich surface still says everything), and a
81
+ `context` line naming the source projection. Pure — the spine's local analogue
82
+ of `slack_helpers.build_upload_blocks`, kept out of the kernel seam.
83
+ """
84
+ emoji = _SEV_EMOJI.get(note.severity.value, "")
85
+ head = f"{emoji} {note.title}".strip()
86
+ blocks: list[dict] = [
87
+ {"type": "header",
88
+ "text": {"type": "plain_text", "text": head[:150], "emoji": True}},
89
+ ]
90
+ if note.fields:
91
+ blocks.append({
92
+ "type": "section",
93
+ "fields": [
94
+ {"type": "mrkdwn", "text": f"*{label}*\n{value}"}
95
+ for label, value in note.fields[:_MAX_FIELDS]
96
+ ],
97
+ })
98
+ if note.summary:
99
+ body = note.summary[:_MAX_SUMMARY]
100
+ blocks.append({
101
+ "type": "section",
102
+ "text": {"type": "mrkdwn", "text": f"```\n{body}\n```"},
103
+ })
104
+ blocks.append({
105
+ "type": "context",
106
+ "elements": [{"type": "mrkdwn",
107
+ "text": f"dos notify · source=`{note.source or '?'}` · "
108
+ f"severity=`{note.severity.value}`"}],
109
+ })
110
+ return blocks
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Credential / routing resolution — the boundary I/O, kept off the pure builder.
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def _read_env_file(root: Path) -> dict[str, str]:
119
+ """Best-effort parse of `<root>/.env` → {KEY: value}. Never raises."""
120
+ out: dict[str, str] = {}
121
+ try:
122
+ text = (root / ".env").read_text(encoding="utf-8")
123
+ except OSError:
124
+ return out
125
+ for line in text.splitlines():
126
+ line = line.strip()
127
+ if not line or line.startswith("#") or "=" not in line:
128
+ continue
129
+ k, _, v = line.partition("=")
130
+ out[k.strip()] = v.strip().strip('"').strip("'")
131
+ return out
132
+
133
+
134
+ def resolve_token(explicit: str | None, *, root: Path | None) -> str:
135
+ """Bot token: explicit arg › `$SLACK_BOT_TOKEN` › `<root>/.env`. "" if none."""
136
+ if explicit:
137
+ return explicit
138
+ env = os.environ.get("SLACK_BOT_TOKEN")
139
+ if env:
140
+ return env
141
+ if root is not None:
142
+ return _read_env_file(root).get("SLACK_BOT_TOKEN", "")
143
+ return ""
144
+
145
+
146
+ def _slack_config_channels() -> dict[str, str]:
147
+ """The `slack_helpers/slack_config.json` name→id map, or {} if unavailable."""
148
+ try:
149
+ import importlib.util
150
+ import json
151
+
152
+ spec = importlib.util.find_spec("slack_helpers")
153
+ if spec is None or not spec.origin:
154
+ return {}
155
+ cfg = Path(spec.origin).parent / "slack_config.json"
156
+ data = json.loads(cfg.read_text(encoding="utf-8"))
157
+ chans = data.get("channels", {})
158
+ return {str(k): str(v) for k, v in chans.items()} if isinstance(chans, dict) else {}
159
+ except Exception:
160
+ return {}
161
+
162
+
163
+ def resolve_channel(channel: str) -> str:
164
+ """A channel NAME → its id via `slack_config.json`; a raw id passes through.
165
+
166
+ A value that is already a Slack channel id (starts with `C`/`G`/`D` and is
167
+ upper-case-ish) is returned as-is; otherwise it is looked up as a logical name
168
+ in the config map. An unknown name returns "" (the caller skips, fail-soft).
169
+ """
170
+ channel = (channel or "").strip()
171
+ if not channel:
172
+ return ""
173
+ # Raw id heuristic: Slack ids are like C0AJ37QHMFB / G… / D… — letters+digits,
174
+ # no lowercase. A logical name ("ops", "zip-files") has lowercase or a dash.
175
+ if channel[0] in "CGD" and channel.isupper() and channel.isalnum():
176
+ return channel
177
+ return _slack_config_channels().get(channel, "")
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # A dependency-free edit-in-place fallback — used when slack_helpers is absent but
182
+ # a client exists (an injected fake, or a hand-built one). Mirrors the slice of
183
+ # `slack_helpers.LiveMessage` the driver uses (`.update(text, force=)` + `.ts`),
184
+ # minus the throttle (correctness is "one edited message"; the throttle is a
185
+ # rate-limit nicety the real LiveMessage adds). Like LiveMessage, transport errors
186
+ # are swallowed so a streaming UI never crashes its producer.
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ class _InlineLive:
191
+ """Minimal post-then-update: first `update` posts, later ones edit in place."""
192
+
193
+ def __init__(self, client, channel: str):
194
+ self._client = client
195
+ self._channel = channel
196
+ self._ts: str | None = None
197
+ self._sent: str | None = None
198
+
199
+ @property
200
+ def ts(self) -> str | None:
201
+ return self._ts
202
+
203
+ def update(self, text: str, *, force: bool = False) -> None: # noqa: ARG002 - parity
204
+ if self._ts is None:
205
+ try:
206
+ resp = self._client.post_message(self._channel, text)
207
+ except Exception: # noqa: BLE001 - streaming must not crash the producer
208
+ return
209
+ self._ts = str((resp or {}).get("ts") or "")
210
+ self._sent = text
211
+ return
212
+ if text == self._sent: # nothing changed — skip the round-trip
213
+ return
214
+ try:
215
+ self._client.update_message(self._channel, self._ts, text)
216
+ except Exception: # noqa: BLE001
217
+ return
218
+ self._sent = text
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # The notifier.
223
+ # ---------------------------------------------------------------------------
224
+
225
+
226
+ class SlackNotifier:
227
+ """Deliver a `Notification` to Slack — post (digest) or edit-in-place (status).
228
+
229
+ Parameters
230
+ ----------
231
+ channel:
232
+ A logical name (resolved via `slack_config.json`) or a raw channel id.
233
+ token:
234
+ Bot token; defaults to `$SLACK_BOT_TOKEN` / the workspace `.env`
235
+ (`resolve_token`).
236
+ root:
237
+ Workspace root for `.env` resolution (the `SubstrateConfig.root`).
238
+ dry_run:
239
+ Render + report, send NOTHING (no `post_message`/`update_message` call).
240
+ edit_in_place:
241
+ None = auto (a `source="top"` notification edits one message; everything
242
+ else posts). True/False forces.
243
+ client:
244
+ Inject a fake `SlackClient` in tests; None builds a real one lazily from the
245
+ token at first `send`.
246
+ min_interval:
247
+ The `LiveMessage` throttle (seconds) for the edit-in-place surface.
248
+ """
249
+
250
+ name = "slack"
251
+
252
+ def __init__(self, *, channel: str = "", token: str | None = None,
253
+ root: "os.PathLike[str] | str | None" = None,
254
+ dry_run: bool = False, edit_in_place: bool | None = None,
255
+ client=None, min_interval: float = 0.0):
256
+ self._channel_arg = channel
257
+ self._token = token
258
+ self._root = Path(root) if root is not None else None
259
+ self._dry_run = bool(dry_run)
260
+ self._edit_in_place = edit_in_place
261
+ self._client = client # injected fake, or built lazily
262
+ self._client_built = client is not None
263
+ self._min_interval = max(0.0, float(min_interval))
264
+ # Per-key LiveMessage cache for the edit-in-place surface (one re-edited
265
+ # message per note.key for the lifetime of this notifier instance).
266
+ self._live: dict[str, object] = {}
267
+
268
+ # -- transport construction (lazy; the only place slack_helpers loads) ------
269
+
270
+ def _ensure_client(self) -> tuple[object | None, str]:
271
+ """(client, "") on success, or (None, reason) — never raises (fail-soft)."""
272
+ if self._client is not None:
273
+ return self._client, ""
274
+ if self._client_built: # we already tried and failed
275
+ return None, "no slack client"
276
+ token = resolve_token(self._token, root=self._root)
277
+ if not token:
278
+ self._client_built = True
279
+ return None, "no SLACK_BOT_TOKEN (set it in env or the workspace .env)"
280
+ try:
281
+ from slack_helpers import SlackClient
282
+ except Exception:
283
+ self._client_built = True
284
+ return None, ("slack_helpers not installed — "
285
+ "`pip install dos-kernel[notify-slack]`")
286
+ try:
287
+ self._client = SlackClient(token)
288
+ except Exception as e: # pragma: no cover - defensive
289
+ self._client_built = True
290
+ return None, f"slack client init failed: {e}"
291
+ self._client_built = True
292
+ return self._client, ""
293
+
294
+ def _live_message(self, channel: str, key: str):
295
+ """A per-key edit-in-place handle. None if no client (fail-soft).
296
+
297
+ Prefers `slack_helpers.LiveMessage` (it adds the throttle that keeps a
298
+ high-frequency status stream under Slack's `chat.update` rate limit). When
299
+ the extra is absent but a client EXISTS (e.g. an injected fake, or a
300
+ hand-built client), it falls back to `_InlineLive` — a minimal
301
+ post-then-update with the same surface — so the edit path never
302
+ hard-depends on the optional dependency once a transport is in hand.
303
+ """
304
+ if key in self._live:
305
+ return self._live[key]
306
+ client, _ = self._ensure_client()
307
+ if client is None:
308
+ return None
309
+ try:
310
+ from slack_helpers import LiveMessage
311
+ lm: object = LiveMessage(client, channel, min_interval=self._min_interval)
312
+ except Exception:
313
+ lm = _InlineLive(client, channel)
314
+ self._live[key] = lm
315
+ return lm
316
+
317
+ # -- delivery ---------------------------------------------------------------
318
+
319
+ def _wants_edit(self, note: Notification) -> bool:
320
+ if self._edit_in_place is not None:
321
+ return self._edit_in_place
322
+ return note.source == "top" # the live-status surface edits by default
323
+
324
+ def send(self, note: Notification) -> NotifyResult:
325
+ """Deliver `note`. Returns a `NotifyResult`; NEVER raises (fail-soft)."""
326
+ channel = resolve_channel(self._channel_arg)
327
+ if not channel:
328
+ return NotifyResult(
329
+ delivered=False,
330
+ detail=f"no channel (got {self._channel_arg!r}; "
331
+ f"name not in slack_config.json or not a raw id)",
332
+ )
333
+
334
+ edit = self._wants_edit(note)
335
+
336
+ if self._dry_run:
337
+ how = "edit-in-place" if edit else "post"
338
+ return NotifyResult(
339
+ delivered=False,
340
+ detail=f"[dry-run] would {how} to {channel} "
341
+ f"({note.severity.value}: {note.title})",
342
+ )
343
+
344
+ if edit:
345
+ return self._send_edit(channel, note)
346
+ return self._send_post(channel, note)
347
+
348
+ def _send_post(self, channel: str, note: Notification) -> NotifyResult:
349
+ client, reason = self._ensure_client()
350
+ if client is None:
351
+ return NotifyResult(delivered=False, detail=reason)
352
+ blocks = build_blocks(note)
353
+ try:
354
+ resp = client.post_message(channel, note.title, blocks=blocks)
355
+ except Exception as e: # noqa: BLE001 - advisory; report, don't crash
356
+ return NotifyResult(delivered=False, detail=f"error: {e}")
357
+ ts = str((resp or {}).get("ts") or "")
358
+ return NotifyResult(delivered=True, detail=f"posted ts={ts}", ref=ts)
359
+
360
+ def _send_edit(self, channel: str, note: Notification) -> NotifyResult:
361
+ lm = self._live_message(channel, note.key or "dos-notify")
362
+ if lm is None:
363
+ client, reason = self._ensure_client()
364
+ return NotifyResult(delivered=False, detail=reason or "no live message")
365
+ # LiveMessage streams TEXT (its body is the running log); we feed the
366
+ # title + summary, which is the at-a-glance line plus the full screen.
367
+ text = note.title if not note.summary else f"{note.title}\n{note.summary}"
368
+ try:
369
+ lm.update(text, force=True)
370
+ except Exception as e: # noqa: BLE001 - LiveMessage already swallows, double-net
371
+ return NotifyResult(delivered=False, detail=f"error: {e}")
372
+ ts = str(getattr(lm, "ts", "") or "")
373
+ return NotifyResult(delivered=True, detail="edited" if ts else "edit queued", ref=ts)
@@ -0,0 +1,251 @@
1
+ """dos.drivers.notify_webhook — the generic HTTP-POST occupant of `dos.notify` (docs/267).
2
+
3
+ The second transport behind the notification spine, and the *universal* one. Where
4
+ the kernel seam (`dos.notify`) is transport-agnostic and names no vendor, THIS is
5
+ where "an HTTP endpoint" is allowed to be code — but it names no SPECIFIC vendor: it
6
+ renders a `Notification` to a portable JSON body and POSTs it to a configured URL, so
7
+ the one driver reaches every chat platform's incoming webhook (Teams / Discord /
8
+ Mattermost / Slack-incoming render the top-level `text`), every incident bus
9
+ (PagerDuty / Opsgenie / incident.io ingest a POST + routing key), and every automation
10
+ hook (Zapier / n8n / a Lambda Function URL). It registers through the `dos.notifiers`
11
+ entry-point group, so `resolve_notifier("webhook")` finds it by name and no kernel
12
+ module imports it.
13
+
14
+ Why it ships in the core (no extra)
15
+ ===================================
16
+
17
+ Unlike `notify_slack` (which pulls `slack_helpers` → `requests` in the
18
+ `[notify-slack]` extra), a webhook needs only `urllib.request` from the standard
19
+ library. So this driver adds NO dependency and ships in the core install — a
20
+ `pip install dos-kernel` can already deliver notifications to any webhook URL.
21
+
22
+ Disciplines (inherited from the seam — the `notify_slack` posture, verbatim)
23
+ ============================================================================
24
+
25
+ * **Fail-soft.** `send` returns a `NotifyResult`, never raises — no URL, a non-2xx
26
+ response, a network error, or a malformed body all degrade to `delivered=False`
27
+ with a one-line reason. (The seam's `send_safely` is the outer net; this is the
28
+ inner one, so even a direct `WebhookNotifier().send(...)` is crash-free.)
29
+ * **Advisory only.** It renders a projection → POST. It mutates no DOS state, takes
30
+ no lease, stops no run. A LIVENESS-halt field CARRIES the paste-to-stop command
31
+ (built by the seam); it never enacts it. It does NOT retry or queue — a failed
32
+ POST is reported, and the host decides whether to re-`dos notify` (DOS reports; it
33
+ does not own a delivery SLA).
34
+
35
+ Credentials / routing (the `notify_slack.resolve_token` ladder, generalized to a URL)
36
+ =====================================================================================
37
+
38
+ * **url**: explicit arg › `$DOS_WEBHOOK_URL` › the workspace `.env`
39
+ (`<root>/.env`'s `DOS_WEBHOOK_URL`). No URL anywhere → a non-delivered result.
40
+ * **token** (optional): explicit arg › `$DOS_WEBHOOK_TOKEN` › `<root>/.env`. When
41
+ set, sent as `Authorization: Bearer <token>`. Many incoming-webhook URLs carry the
42
+ secret in the PATH (Slack/Teams/Discord style) and need no token; a header-secret
43
+ transport (PagerDuty-style) uses the token. Both are supported.
44
+
45
+ The JSON body is built HERE, locally — a small DOS-shaped builder (`build_payload`,
46
+ the spine's analogue of `notify_slack.build_blocks`), so the kernel seam stays
47
+ wire-format-free. It is a FLAT, portable object (not a vendor wire format): a
48
+ structured consumer reads `severity`/`fields`/`source`; a dumb chat hook renders the
49
+ synthesized top-level `text`.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ import os
56
+ from pathlib import Path
57
+
58
+ from dos.notify import Notification, NotifyResult
59
+
60
+ # A POST body caps at nothing in particular, but keep the summary bounded so a giant
61
+ # `dos top` screen does not produce a multi-megabyte request (the `notify_slack`
62
+ # _MAX_SUMMARY instinct; webhooks vary, so this is generous).
63
+ _MAX_SUMMARY = 8000
64
+
65
+ # Severity → a short tag for the synthesized `text` line (chat hooks render `text`).
66
+ _SEV_TAG = {"INFO": "·", "WARN": "▲", "URGENT": "■"}
67
+
68
+
69
+ def build_payload(note: Notification) -> dict:
70
+ """A `Notification` → a portable JSON body (pure; no I/O).
71
+
72
+ `note.to_dict()` already carries `severity`/`title`/`summary`/`fields`/`key`/
73
+ `source`. We add a synthesized top-level **`text`** (`[SEV] title\\n summary`),
74
+ because most chat webhooks (Teams/Discord/Slack-incoming) render a `text` field
75
+ and ignore the rest — so ONE body serves both a structured consumer (reads
76
+ `fields`/`severity`) and a dumb chat hook (renders `text`). A consumer needing a
77
+ vendor-exact shape (PagerDuty's nested `payload`) is a later payload-shaping
78
+ subclass; this is the 90% generic adapter.
79
+ """
80
+ body = note.to_dict()
81
+ if len(body.get("summary") or "") > _MAX_SUMMARY:
82
+ body["summary"] = body["summary"][:_MAX_SUMMARY]
83
+ tag = _SEV_TAG.get(note.severity.value, "·")
84
+ head = f"{tag} [{note.severity.value}] {note.title}".strip()
85
+ text = head if not body.get("summary") else f"{head}\n{body['summary']}"
86
+ body["text"] = text
87
+ return body
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Credential / routing resolution — boundary I/O, kept off the pure builder
92
+ # (mirrors notify_slack._read_env_file / resolve_token, generalized to URL+token).
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _read_env_file(root: Path) -> dict[str, str]:
97
+ """Best-effort parse of `<root>/.env` → {KEY: value}. Never raises."""
98
+ out: dict[str, str] = {}
99
+ try:
100
+ text = (root / ".env").read_text(encoding="utf-8")
101
+ except OSError:
102
+ return out
103
+ for line in text.splitlines():
104
+ line = line.strip()
105
+ if not line or line.startswith("#") or "=" not in line:
106
+ continue
107
+ k, _, v = line.partition("=")
108
+ out[k.strip()] = v.strip().strip('"').strip("'")
109
+ return out
110
+
111
+
112
+ def resolve_url(explicit: str | None, *, root: Path | None) -> str:
113
+ """Webhook URL: explicit arg › `$DOS_WEBHOOK_URL` › `<root>/.env`. "" if none."""
114
+ if explicit:
115
+ return explicit
116
+ env = os.environ.get("DOS_WEBHOOK_URL")
117
+ if env:
118
+ return env
119
+ if root is not None:
120
+ return _read_env_file(root).get("DOS_WEBHOOK_URL", "")
121
+ return ""
122
+
123
+
124
+ def resolve_token(explicit: str | None, *, root: Path | None) -> str:
125
+ """Optional bearer token: explicit › `$DOS_WEBHOOK_TOKEN` › `<root>/.env`. "" if none."""
126
+ if explicit:
127
+ return explicit
128
+ env = os.environ.get("DOS_WEBHOOK_TOKEN")
129
+ if env:
130
+ return env
131
+ if root is not None:
132
+ return _read_env_file(root).get("DOS_WEBHOOK_TOKEN", "")
133
+ return ""
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # A tiny default transport over urllib — injectable in tests, lazy at call.
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ class _UrllibTransport:
142
+ """The stdlib POST. Returns (status_code, reason); raises on network failure.
143
+
144
+ Kept behind a method so tests inject a fake with the same `post(...)` shape
145
+ instead of patching urllib (the `notify_slack` injected-client posture).
146
+ """
147
+
148
+ def post(self, url: str, body: bytes, headers: dict, timeout: float) -> tuple[int, str]:
149
+ import urllib.error
150
+ import urllib.request
151
+
152
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
153
+ try:
154
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - operator-supplied URL
155
+ return int(getattr(resp, "status", 0) or resp.getcode() or 0), "OK"
156
+ except urllib.error.HTTPError as e:
157
+ # A non-2xx with a response body — surface the code + reason, don't raise.
158
+ return int(e.code), str(getattr(e, "reason", "") or "HTTP error")
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # The notifier.
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ class WebhookNotifier:
167
+ """Deliver a `Notification` by POSTing a portable JSON body to a configured URL.
168
+
169
+ Parameters
170
+ ----------
171
+ url:
172
+ The webhook endpoint; defaults to `$DOS_WEBHOOK_URL` / the workspace `.env`
173
+ (`resolve_url`).
174
+ token:
175
+ Optional bearer secret; defaults to `$DOS_WEBHOOK_TOKEN` / `.env`. Sent as
176
+ `Authorization: Bearer <token>` when present (override via `headers`).
177
+ root:
178
+ Workspace root for `.env` resolution (the `SubstrateConfig.root`).
179
+ dry_run:
180
+ Render + report, POST NOTHING.
181
+ method:
182
+ HTTP method (default POST). Kept for the rare webhook that wants PUT.
183
+ headers:
184
+ Extra/override headers merged over the defaults (`Content-Type:
185
+ application/json` + the bearer header when a token is set).
186
+ timeout:
187
+ Request timeout in seconds (default 10).
188
+ transport:
189
+ Inject a fake with a `post(url, body, headers, timeout) -> (code, reason)`
190
+ method in tests; None uses the stdlib urllib transport.
191
+
192
+ `channel` is accepted-and-ignored (a webhook has no channel) so the generic
193
+ `dos notify` kwarg-forwarding can hand the same bag to any transport without the
194
+ caller branching per driver.
195
+ """
196
+
197
+ name = "webhook"
198
+
199
+ def __init__(self, *, url: str = "", token: str | None = None,
200
+ root: "os.PathLike[str] | str | None" = None,
201
+ dry_run: bool = False, method: str = "POST",
202
+ headers: dict | None = None, timeout: float = 10.0,
203
+ transport=None, channel: str = ""): # noqa: ARG002 - channel ignored (parity)
204
+ self._url_arg = url
205
+ self._token = token
206
+ self._root = Path(root) if root is not None else None
207
+ self._dry_run = bool(dry_run)
208
+ self._method = (method or "POST").upper()
209
+ self._extra_headers = dict(headers or {})
210
+ self._timeout = max(0.1, float(timeout))
211
+ self._transport = transport
212
+
213
+ def _headers(self) -> dict:
214
+ h = {"Content-Type": "application/json", "User-Agent": "dos-notify/webhook"}
215
+ token = resolve_token(self._token, root=self._root)
216
+ if token:
217
+ h["Authorization"] = f"Bearer {token}"
218
+ h.update(self._extra_headers) # operator overrides win
219
+ return h
220
+
221
+ def send(self, note: Notification) -> NotifyResult:
222
+ """Deliver `note`. Returns a `NotifyResult`; NEVER raises (fail-soft)."""
223
+ url = resolve_url(self._url_arg, root=self._root)
224
+ if not url:
225
+ return NotifyResult(
226
+ delivered=False,
227
+ detail="no webhook URL (pass --url, set $DOS_WEBHOOK_URL, "
228
+ "or add DOS_WEBHOOK_URL to the workspace .env)",
229
+ )
230
+
231
+ if self._dry_run:
232
+ return NotifyResult(
233
+ delivered=False,
234
+ detail=f"[dry-run] would POST to {url} "
235
+ f"({note.severity.value}: {note.title})",
236
+ )
237
+
238
+ try:
239
+ body = json.dumps(build_payload(note)).encode("utf-8")
240
+ except Exception as e: # noqa: BLE001 - a non-serializable field must not crash
241
+ return NotifyResult(delivered=False, detail=f"error: payload not serializable: {e}")
242
+
243
+ transport = self._transport if self._transport is not None else _UrllibTransport()
244
+ try:
245
+ code, reason = transport.post(url, body, self._headers(), self._timeout)
246
+ except Exception as e: # noqa: BLE001 - advisory; report, don't crash the producer
247
+ return NotifyResult(delivered=False, detail=f"error: {e}")
248
+
249
+ if 200 <= int(code) < 300:
250
+ return NotifyResult(delivered=True, detail=f"posted HTTP {code}", ref=str(code))
251
+ return NotifyResult(delivered=False, detail=f"HTTP {code}: {reason}")