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,98 @@
1
+ """dos.drivers.decision_stop — a reference loop-STOP policy (a `dos.stop_policy` occupant).
2
+
3
+ The kernel scout's default is "an open operator decision is evidence-only, never a
4
+ STOP" (the 2026-06-03 directive). That is right for most decisions — a WEDGE or an
5
+ open soak gate blocks *future* work but wastes nothing while it waits, so halting
6
+ the whole loop on it is over-reach. But one decision class is different: a
7
+ `LIVENESS` row is an `OP_HALT` proposal — a run a watchdog judged SPINNING/hung
8
+ RIGHT NOW, actively burning budget. For a host that wants its loop to halt rather
9
+ than keep launching work alongside a doomed run, "stop on a LIVENESS decision" is a
10
+ legitimate policy.
11
+
12
+ This driver is the reference `dos.stop_policy.StopPolicy` occupant that encodes
13
+ exactly that, configurably:
14
+
15
+ * `DecisionClassStopPolicy(stop_classes=("LIVENESS",))` — reads the live
16
+ `dos.decisions` HUMAN queue and returns `StopVerdict.stop(...)` iff a pending
17
+ decision's `kind` is in `stop_classes`; otherwise `StopVerdict.defer()` (fall
18
+ through to the kernel's evidence-only default — WEDGE/soak/arbiter-refuse only
19
+ surface). The default `stop_classes` is `("LIVENESS",)` — the one urgent class.
20
+
21
+ It lives in a **driver**, not the kernel, because it does I/O (reads the decision
22
+ queue) and encodes host policy (which classes are halt-worthy) — the same reason a
23
+ ruling judge or a model-backed overlap scorer lives here. The kernel seam
24
+ (`dos.stop_policy`) holds only the Protocol + the fail-to-DEFER wrapper + the
25
+ resource-floor AND; this is one concrete policy under it. Registered under the
26
+ `dos.stop_policies` entry-point group so `resolve_stop_policy("decision-class")`
27
+ returns it; a host opts in by naming it in `dos.toml [scout] stop_policy`.
28
+
29
+ Safety: the policy reads the queue inside `decide`, and any fault there is caught
30
+ by the kernel's `run_stop_policy` (fail-to-DEFER) — but we ALSO guard the read here
31
+ so a torn/missing queue degrades to "no halt-worthy decision" (DEFER) with a clear
32
+ reason, rather than relying solely on the wrapper. And whatever this returns, the
33
+ kernel ANDs it under the `resource_blocked` floor, so this policy can only ever
34
+ *add* a halt, never suppress the measured resource STOP.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dos.stop_policy import StopVerdict
40
+
41
+ # The decision classes this policy halts on, by default. LIVENESS = an OP_HALT
42
+ # proposal: a run hung/spinning NOW, burning budget — the one class where letting
43
+ # the loop keep launching work is worse than stopping. Everything else (WEDGE,
44
+ # ARBITER_REFUSE, PREFLIGHT_REFUSE, SOAK_GATE) blocks future work but wastes nothing
45
+ # waiting, so it stays evidence-only (DEFER → the kernel default surfaces it).
46
+ _DEFAULT_STOP_CLASSES = ("LIVENESS",)
47
+
48
+
49
+ class DecisionClassStopPolicy:
50
+ """STOP the loop iff a pending operator decision is of a configured urgent class.
51
+
52
+ The reference `dos.stop_policy.StopPolicy`. `stop_classes` is the set of
53
+ `dos.decisions.DecisionKind` values that warrant a halt (default
54
+ `("LIVENESS",)`). `decide` reads the live HUMAN decision queue for the active
55
+ workspace and returns STOP on the first match, else DEFER.
56
+ """
57
+
58
+ name = "decision-class"
59
+
60
+ def __init__(self, stop_classes: tuple[str, ...] = _DEFAULT_STOP_CLASSES) -> None:
61
+ # Normalize to an upper-cased set for a case-insensitive `kind` match.
62
+ self.stop_classes = frozenset(c.strip().upper() for c in stop_classes if c)
63
+
64
+ def decide(self, state: object, config: object) -> StopVerdict:
65
+ """Read the live HUMAN decision queue; STOP on an urgent-class row, else DEFER.
66
+
67
+ Guarded: a missing/torn queue (or an absent kernel decisions module) →
68
+ DEFER, never a spurious halt. The kernel's `run_stop_policy` would also
69
+ catch a raise, but degrading here gives a clearer reason and keeps the
70
+ policy honest on its own.
71
+ """
72
+ try:
73
+ from dos import config as _config
74
+ from dos import decisions as _decisions
75
+ cfg = config if config is not None else _config.active()
76
+ rows = _decisions.collect_decisions(cfg, resolver="HUMAN")
77
+ except Exception as e:
78
+ return StopVerdict.defer(
79
+ f"decision-class policy could not read the queue ({e!r}) — "
80
+ f"deferring (no halt)."
81
+ )
82
+ for d in rows:
83
+ kind = getattr(getattr(d, "kind", None), "value", "")
84
+ if str(kind).upper() in self.stop_classes:
85
+ lane = getattr(d, "lane", "") or "-"
86
+ text = getattr(d, "reason_text", "") or kind
87
+ return StopVerdict.stop(
88
+ f"a {kind} decision is pending (lane {lane!r}): {text[:120]}. "
89
+ f"Host policy halts the loop on this class — resolve it "
90
+ f"(`dos decisions` / F9) before relaunching.",
91
+ cause_key=f"stop_on_{str(kind).lower()}",
92
+ evidence=(f"{kind} lane={lane}",
93
+ f"stop_classes={sorted(self.stop_classes)}"),
94
+ )
95
+ return StopVerdict.defer(
96
+ f"no pending decision in the halt classes {sorted(self.stop_classes)} "
97
+ f"({len(rows)} HUMAN decision(s) pending, all evidence-only)."
98
+ )
@@ -0,0 +1,173 @@
1
+ """dos.drivers.export_file — the append-to-a-file occupant of `dos.exporter` (docs/266).
2
+
3
+ The first connector behind the verdict exporter, and the KEYSTONE one. Where the
4
+ kernel seam (`dos.exporter`) is transport-agnostic and names no transport, THIS is
5
+ where "append to a path" is allowed to be code — and it names no SPECIFIC vendor: it
6
+ re-emits each `VerdictEvent` as one JSONL line to an operator-chosen path, so the one
7
+ driver reaches *most* of the observability market for free. A JSONL stream at a path is
8
+ the universal adapter — Datadog's agent, Vector, Fluent Bit, Promtail, Splunk's
9
+ forwarder, and `logger(1)` all tail a file. It registers through the `dos.exporters`
10
+ entry-point group, so `resolve_exporter("file")` finds it by name and no kernel module
11
+ imports it.
12
+
13
+ Why it ships in the core (no extra)
14
+ ===================================
15
+
16
+ A file append needs only `pathlib` + `json` from the standard library. So this driver
17
+ adds NO dependency and ships in the core install — a `pip install dos-kernel` can
18
+ already drain its verdict journal to any log shipper. (The StatsD driver is also
19
+ stdlib; only the OTLP driver pulls a real SDK, behind the `[export-otlp]` extra.)
20
+
21
+ The shape it writes
22
+ ===================
23
+
24
+ ONE line per event — the event's own `to_record()` JSON (schema-tagged, byte-clean
25
+ `detail`), the SAME line shape the verdict journal itself writes. That is deliberate:
26
+ the export target is just a SECOND copy of the journal lines at a path a shipper
27
+ already watches, so a downstream parser that already understands a DOS verdict record
28
+ needs no new schema. The append is `O_APPEND` + line-buffered so concurrent appenders
29
+ (a `--follow` drain + a fresh run) never interleave a single line.
30
+
31
+ Disciplines (inherited from the seam — the `notify_webhook` posture, verbatim)
32
+ ==============================================================================
33
+
34
+ * **Fail-soft.** `export` returns an `ExportResult`, never raises — no path, an
35
+ unwritable directory, a full disk, or a non-serializable field all degrade to
36
+ `exported=0` with a one-line reason. (The seam's `export_safely` is the outer net;
37
+ this is the inner one, so even a direct `FileExporter().export(...)` is crash-free.)
38
+ * **Advisory only.** It reads a batch → appends lines. It mutates no DOS state, takes
39
+ no lease, stops no run, adjudicates nothing. It does NOT rotate or cap the file —
40
+ that is the log shipper's job (and docs/262 Phase 4's `[retention]` for the journal
41
+ itself); a file exporter writes, the shipper consumes + truncates.
42
+
43
+ Routing (the `notify_webhook.resolve_url` ladder, generalized to a path)
44
+ ========================================================================
45
+
46
+ * **path**: explicit arg › `$DOS_EXPORT_FILE` › the workspace `.env`
47
+ (`<root>/.env`'s `DOS_EXPORT_FILE`). No path anywhere → a non-exported result. A
48
+ relative path resolves against the workspace `root` (so `--path verdicts.jsonl`
49
+ lands under the workspace, not the cwd of whatever drove the drain).
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ import os
56
+ from pathlib import Path
57
+
58
+ from dos.exporter import ExportResult, _max_seq_cursor
59
+
60
+
61
+ def _read_env_file(root: Path) -> dict[str, str]:
62
+ """Best-effort parse of `<root>/.env` → {KEY: value}. Never raises.
63
+
64
+ The `notify_webhook._read_env_file` twin (same parser, different key)."""
65
+ out: dict[str, str] = {}
66
+ try:
67
+ text = (root / ".env").read_text(encoding="utf-8")
68
+ except OSError:
69
+ return out
70
+ for line in text.splitlines():
71
+ line = line.strip()
72
+ if not line or line.startswith("#") or "=" not in line:
73
+ continue
74
+ k, _, v = line.partition("=")
75
+ out[k.strip()] = v.strip().strip('"').strip("'")
76
+ return out
77
+
78
+
79
+ def resolve_path(explicit: str | None, *, root: Path | None) -> str:
80
+ """Export path: explicit arg › `$DOS_EXPORT_FILE` › `<root>/.env`. "" if none.
81
+
82
+ A relative result is resolved against `root` so the export lands under the workspace
83
+ regardless of the drain's cwd (the `SubstrateConfig.root` anchor; the package never
84
+ assumes it lives in the repo it serves)."""
85
+ raw = explicit or os.environ.get("DOS_EXPORT_FILE") or ""
86
+ if not raw and root is not None:
87
+ raw = _read_env_file(root).get("DOS_EXPORT_FILE", "")
88
+ if not raw:
89
+ return ""
90
+ p = Path(raw)
91
+ if not p.is_absolute() and root is not None:
92
+ p = Path(root) / p
93
+ return str(p)
94
+
95
+
96
+ class FileExporter:
97
+ """Drain a batch of `VerdictEvent`s by appending each as one JSONL line to a path.
98
+
99
+ Parameters
100
+ ----------
101
+ path:
102
+ The export file; defaults to `$DOS_EXPORT_FILE` / the workspace `.env`
103
+ (`resolve_path`). A relative path resolves against `root`.
104
+ root:
105
+ Workspace root for `.env` + relative-path resolution (the `SubstrateConfig.root`).
106
+ dry_run:
107
+ Resolve + count + report what WOULD be written, write NOTHING.
108
+
109
+ The constructor accepts-and-ignores the export CLI's other superset kwargs
110
+ (`host`/`port`/`endpoint`) only by NOT declaring them — `exporter._accepted_kwargs`
111
+ filters the bag to the params below, so a caller can hand the same kwargs to any
112
+ transport without branching per driver.
113
+ """
114
+
115
+ name = "file"
116
+
117
+ def __init__(self, *, path: str = "",
118
+ root: "os.PathLike[str] | str | None" = None,
119
+ dry_run: bool = False):
120
+ self._path_arg = path
121
+ self._root = Path(root) if root is not None else None
122
+ self._dry_run = bool(dry_run)
123
+
124
+ def export(self, events) -> ExportResult:
125
+ """Append each event as a JSONL line. Returns an `ExportResult`; NEVER raises."""
126
+ path = resolve_path(self._path_arg, root=self._root)
127
+ cursor = _max_seq_cursor(events)
128
+ n = len(events)
129
+ if not path:
130
+ return ExportResult(
131
+ exported=0,
132
+ detail="no export path (pass --path, set $DOS_EXPORT_FILE, "
133
+ "or add DOS_EXPORT_FILE to the workspace .env)",
134
+ cursor=cursor,
135
+ )
136
+
137
+ if self._dry_run:
138
+ return ExportResult(
139
+ exported=0,
140
+ detail=f"[dry-run] would append {n} event(s) to {path}",
141
+ cursor=cursor,
142
+ )
143
+
144
+ if n == 0:
145
+ # Nothing to ship — a perfectly normal drain when no new events landed.
146
+ return ExportResult(exported=0, detail=f"no new events for {path}", cursor=cursor)
147
+
148
+ # Build all the lines first; a single non-serializable field fails the whole
149
+ # batch cleanly (fail-soft) rather than writing a partial file then raising.
150
+ try:
151
+ lines = [
152
+ json.dumps(e.to_record(), ensure_ascii=False, sort_keys=True)
153
+ for e in events
154
+ ]
155
+ except Exception as e: # noqa: BLE001 - a bad field must not crash the drain
156
+ return ExportResult(
157
+ exported=0, detail=f"error: event not serializable: {e}", cursor=cursor)
158
+
159
+ try:
160
+ p = Path(path)
161
+ p.parent.mkdir(parents=True, exist_ok=True)
162
+ # O_APPEND keeps concurrent appends from interleaving a single line (the
163
+ # verdict_journal.record discipline); we flush so a tailing shipper sees the
164
+ # lines promptly. No fsync here — the journal is the durable WAL; this is a
165
+ # best-effort outward copy, and an fsync per drain would throttle a follow loop.
166
+ with open(p, "a", encoding="utf-8") as fh:
167
+ fh.write("\n".join(lines) + "\n")
168
+ fh.flush()
169
+ except Exception as e: # noqa: BLE001 - advisory; report, don't crash the producer
170
+ return ExportResult(exported=0, detail=f"error: {e}", cursor=cursor)
171
+
172
+ return ExportResult(
173
+ exported=n, detail=f"appended {n} event(s) to {path}", cursor=cursor)
@@ -0,0 +1,275 @@
1
+ """dos.drivers.export_otlp — the OpenTelemetry occupant of `dos.exporter` (docs/266).
2
+
3
+ The third connector behind the verdict exporter, and the native TRACES/LOGS path. Where
4
+ `export_file` writes JSONL for a shipper to parse and `export_statsd` emits counters,
5
+ THIS driver maps each `VerdictEvent` to an OTLP **log record** — `run_id` as a correlated
6
+ attribute, the byte-clean `detail` counts as attributes — and ships it to an
7
+ OpenTelemetry collector over OTLP/HTTP. So a shop already running an OTel collector
8
+ (Honeycomb, Grafana Tempo/Loki, Datadog's OTLP intake, Jaeger) ingests the verdict
9
+ stream natively, correlated by `run_id`, without a log-tailing or StatsD hop. It
10
+ registers through the `dos.exporters` entry-point group, so `resolve_exporter("otlp")`
11
+ finds it by name and no kernel module imports it.
12
+
13
+ Why this one needs an extra (`[export-otlp]`)
14
+ =============================================
15
+
16
+ Unlike `file` + `statsd` (stdlib-only, in the core), OTLP needs a real SDK — the
17
+ `opentelemetry-sdk` + the OTLP/HTTP log exporter. Those live behind the `[export-otlp]`
18
+ extra (the `[mcp]` / `[notify-slack]` precedent), so `pip install dos-kernel` stays
19
+ near-stdlib. The SDK is imported **lazily, inside the transport's `emit`** — never at
20
+ module load — so:
21
+
22
+ * entry-point discovery of this driver NEVER fails when the extra is absent (the
23
+ `notify_slack` lazy-import discipline — importing the driver must be free);
24
+ * an `export` with the extra absent returns a non-exported `ExportResult` carrying the
25
+ install hint (`pip install dos-kernel[export-otlp]`), NEVER an ImportError.
26
+
27
+ The pure part is import-free
28
+ ============================
29
+
30
+ `build_records(events)` turns the batch into a list of NEUTRAL record dicts (severity +
31
+ body + attributes) with NO OpenTelemetry import — so the mapping is golden-shape testable
32
+ without the SDK, and the OTLP-specific construction (LoggerProvider, LogRecord, the HTTP
33
+ exporter) is confined to `_OtlpTransport.emit`, which a test replaces with a fake.
34
+
35
+ Disciplines (inherited from the seam — the `export_file`/`export_statsd` posture)
36
+ =================================================================================
37
+
38
+ * **Fail-soft.** `export` returns an `ExportResult`, never raises — an absent extra, a
39
+ down collector, a bad endpoint, an SDK error all degrade to `exported=0` with a
40
+ one-line reason. (The seam's `export_safely` is the outer net; this is the inner one.)
41
+ * **Advisory only.** It reads a batch → emits records. It mutates no DOS state, takes no
42
+ lease, stops no run, adjudicates nothing.
43
+
44
+ Routing
45
+ =======
46
+
47
+ * **endpoint**: explicit arg › `$DOS_OTLP_ENDPOINT` › `$OTEL_EXPORTER_OTLP_ENDPOINT`
48
+ (the OTel standard env) › `<root>/.env` › `http://localhost:4318` (the OTLP/HTTP
49
+ default). The `/v1/logs` path is appended by the SDK exporter if not present.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import os
55
+ from pathlib import Path
56
+
57
+ from dos.exporter import ExportResult, _max_seq_cursor
58
+
59
+ _DEFAULT_ENDPOINT = "http://localhost:4318"
60
+ _INSTRUMENTATION_SCOPE = "dos.verdict"
61
+
62
+ # Map the kernel's verdict severity onto the OTLP severity scale. Most verdict tokens are
63
+ # neutral status (ADVANCING/SHIPPED → INFO); the "something is wrong" tokens map to WARN,
64
+ # and the hardest (a run hung / poison) to ERROR. A token not listed is INFO — a verdict
65
+ # is a status record, not inherently an error. (Used by build_records as a NUMBER so the
66
+ # pure part needs no OTel SeverityNumber import; the transport maps it to the enum.)
67
+ _SEVERITY_WARN = {"SPINNING", "DIMINISHING", "COSTLY", "OPEN", "WARN", "DEFER",
68
+ "RECENTLY_ATTEMPTED", "QUIET_INCOMPLETE", "HELD", "DIVERGED"}
69
+ _SEVERITY_ERROR = {"STALLED", "WASTEFUL", "BLOCK", "REJECT_POISON", "UNRESUMABLE",
70
+ "NOT_SHIPPED"}
71
+ # OTLP SeverityNumber values (stable ints from the spec): INFO=9, WARN=13, ERROR=17.
72
+ _SEV_INFO, _SEV_WARN, _SEV_ERROR = 9, 13, 17
73
+
74
+
75
+ def _read_env_file(root: Path) -> dict[str, str]:
76
+ """Best-effort parse of `<root>/.env` → {KEY: value}. Never raises."""
77
+ out: dict[str, str] = {}
78
+ try:
79
+ text = (root / ".env").read_text(encoding="utf-8")
80
+ except OSError:
81
+ return out
82
+ for line in text.splitlines():
83
+ line = line.strip()
84
+ if not line or line.startswith("#") or "=" not in line:
85
+ continue
86
+ k, _, v = line.partition("=")
87
+ out[k.strip()] = v.strip().strip('"').strip("'")
88
+ return out
89
+
90
+
91
+ def resolve_endpoint(explicit: str | None, *, root: Path | None) -> str:
92
+ """OTLP endpoint: explicit › `$DOS_OTLP_ENDPOINT` › `$OTEL_EXPORTER_OTLP_ENDPOINT`
93
+ › `<root>/.env` › http://localhost:4318."""
94
+ if explicit:
95
+ return explicit
96
+ for env_key in ("DOS_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT"):
97
+ v = os.environ.get(env_key)
98
+ if v:
99
+ return v
100
+ if root is not None:
101
+ v = _read_env_file(root).get("DOS_OTLP_ENDPOINT")
102
+ if v:
103
+ return v
104
+ return _DEFAULT_ENDPOINT
105
+
106
+
107
+ def _severity_number(verdict: str) -> int:
108
+ """The OTLP severity NUMBER for a verdict token (INFO/WARN/ERROR). Import-free."""
109
+ v = (verdict or "").upper()
110
+ if v in _SEVERITY_ERROR:
111
+ return _SEV_ERROR
112
+ if v in _SEVERITY_WARN:
113
+ return _SEV_WARN
114
+ return _SEV_INFO
115
+
116
+
117
+ def build_records(events) -> list[dict]:
118
+ """A batch of `VerdictEvent`s → neutral OTLP-shaped record dicts (pure; no OTel import).
119
+
120
+ Each record carries `body` (a human line `"<syscall> <verdict>"`), `severity_number`
121
+ (mapped from the verdict token), and `attributes` — the structured, queryable fields a
122
+ trace/log backend filters on: `dos.syscall`, `dos.verdict`, `dos.run_id` (the
123
+ correlation key), `dos.lane`/`dos.subject` when present, and each byte-clean
124
+ `detail.<k>` count flattened with a `dos.detail.` prefix (never the agent's narration —
125
+ the docs/138 invariant the journal already enforces, carried onto the wire). The
126
+ transport turns each dict into an OTLP LogRecord; this stays import-free + golden
127
+ testable, the spine's analogue of `export_statsd.build_lines`.
128
+ """
129
+ out: list[dict] = []
130
+ for e in events:
131
+ syscall = getattr(e, "syscall", "") or ""
132
+ verdict = getattr(e, "verdict", "") or ""
133
+ attrs: dict[str, object] = {
134
+ "dos.syscall": syscall,
135
+ "dos.verdict": verdict,
136
+ }
137
+ run_id = getattr(e, "run_id", "") or ""
138
+ if run_id:
139
+ attrs["dos.run_id"] = run_id
140
+ lane = getattr(e, "lane", "") or ""
141
+ if lane:
142
+ attrs["dos.lane"] = lane
143
+ subject = getattr(e, "subject", "") or ""
144
+ if subject:
145
+ attrs["dos.subject"] = subject
146
+ source = getattr(e, "source", "") or ""
147
+ if source:
148
+ attrs["dos.source"] = source
149
+ detail = getattr(e, "detail", None) or {}
150
+ if isinstance(detail, dict):
151
+ for k, v in detail.items():
152
+ # OTLP attribute values must be a scalar (or a homogeneous list); coerce
153
+ # anything else to its string form so a nested/odd value still rides along.
154
+ attrs[f"dos.detail.{k}"] = v if isinstance(v, (str, int, float, bool)) else str(v)
155
+ out.append({
156
+ "body": f"{syscall} {verdict}".strip(),
157
+ "severity_number": _severity_number(verdict),
158
+ "attributes": attrs,
159
+ "ts": getattr(e, "ts", "") or "",
160
+ })
161
+ return out
162
+
163
+
164
+ class _OtlpTransport:
165
+ """The lazy OTLP/HTTP log emitter. The ONLY place the OpenTelemetry SDK is imported.
166
+
167
+ `emit(records, endpoint)` returns the count emitted; raises `ImportError` when the
168
+ `[export-otlp]` extra is absent (the driver converts that to an install hint) and any
169
+ other exception on a transport failure (the driver converts that to a soft error).
170
+ Kept behind a method so tests inject a fake with the same `emit(records, endpoint)`
171
+ shape instead of standing up a real collector (the `export_statsd._UdpTransport` /
172
+ `notify_webhook._UrllibTransport` posture).
173
+ """
174
+
175
+ def emit(self, records: list[dict], endpoint: str) -> int:
176
+ # Lazy import — absent extra raises ImportError HERE (caught by the driver), never
177
+ # at module load, so discovering this driver is always free.
178
+ from opentelemetry._logs import SeverityNumber
179
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
180
+ from opentelemetry.sdk._logs import LoggerProvider, LogRecord
181
+ from opentelemetry.sdk._logs.export import SimpleLogRecordProcessor
182
+ from opentelemetry.sdk.resources import Resource
183
+
184
+ resource = Resource.create({"service.name": _INSTRUMENTATION_SCOPE})
185
+ provider = LoggerProvider(resource=resource)
186
+ exporter = OTLPLogExporter(endpoint=endpoint)
187
+ processor = SimpleLogRecordProcessor(exporter)
188
+ provider.add_log_record_processor(processor)
189
+ logger = provider.get_logger(_INSTRUMENTATION_SCOPE)
190
+
191
+ sent = 0
192
+ for rec in records:
193
+ logger.emit(LogRecord(
194
+ body=rec.get("body", ""),
195
+ severity_number=SeverityNumber(int(rec.get("severity_number", _SEV_INFO))),
196
+ attributes=dict(rec.get("attributes", {})),
197
+ ))
198
+ sent += 1
199
+ # Flush so the records leave before the provider is torn down (a short-lived drain,
200
+ # unlike a long-running app's batching processor).
201
+ try:
202
+ provider.force_flush()
203
+ provider.shutdown()
204
+ except Exception: # pragma: no cover - shutdown is best-effort
205
+ pass
206
+ return sent
207
+
208
+
209
+ class OtlpExporter:
210
+ """Drain a batch of `VerdictEvent`s as OTLP log records to an OpenTelemetry collector.
211
+
212
+ Parameters
213
+ ----------
214
+ endpoint:
215
+ OTLP/HTTP endpoint; defaults to `$DOS_OTLP_ENDPOINT` /
216
+ `$OTEL_EXPORTER_OTLP_ENDPOINT` / `.env` / http://localhost:4318.
217
+ root:
218
+ Workspace root for `.env` resolution (the `SubstrateConfig.root`).
219
+ dry_run:
220
+ Build the records + report, emit NOTHING (and never imports the SDK).
221
+ transport:
222
+ Inject a fake with an `emit(records, endpoint) -> int` method in tests; None uses
223
+ the lazy OTLP transport (which needs the `[export-otlp]` extra).
224
+
225
+ The constructor accepts-and-ignores the export CLI's `path`/`host`/`port` superset
226
+ kwargs by NOT declaring them — `exporter._accepted_kwargs` filters the bag to the
227
+ params below, so a caller hands the same kwargs to any transport without branching.
228
+ """
229
+
230
+ name = "otlp"
231
+
232
+ _INSTALL_HINT = ("OTLP exporter needs the [export-otlp] extra — "
233
+ "`pip install dos-kernel[export-otlp]`")
234
+
235
+ def __init__(self, *, endpoint: str = "",
236
+ root: "os.PathLike[str] | str | None" = None,
237
+ dry_run: bool = False, transport=None):
238
+ self._endpoint_arg = endpoint
239
+ self._root = Path(root) if root is not None else None
240
+ self._dry_run = bool(dry_run)
241
+ self._transport = transport
242
+
243
+ def export(self, events) -> ExportResult:
244
+ """Emit one OTLP log record per event. Returns an `ExportResult`; NEVER raises."""
245
+ cursor = _max_seq_cursor(events)
246
+ n = len(events)
247
+ endpoint = resolve_endpoint(self._endpoint_arg, root=self._root)
248
+
249
+ if n == 0:
250
+ return ExportResult(
251
+ exported=0, detail=f"no new events for {endpoint}", cursor=cursor)
252
+
253
+ records = build_records(events)
254
+
255
+ if self._dry_run:
256
+ return ExportResult(
257
+ exported=0,
258
+ detail=f"[dry-run] would emit {n} OTLP log record(s) to {endpoint}",
259
+ cursor=cursor,
260
+ )
261
+
262
+ transport = self._transport if self._transport is not None else _OtlpTransport()
263
+ try:
264
+ sent = transport.emit(records, endpoint)
265
+ except ImportError:
266
+ # The extra is absent — degrade to the install hint, never an ImportError.
267
+ return ExportResult(exported=0, detail=self._INSTALL_HINT, cursor=cursor)
268
+ except Exception as e: # noqa: BLE001 - advisory; report, don't crash the producer
269
+ return ExportResult(exported=0, detail=f"error: {e}", cursor=cursor)
270
+
271
+ return ExportResult(
272
+ exported=int(sent),
273
+ detail=f"emitted {sent} OTLP log record(s) to {endpoint}",
274
+ cursor=cursor,
275
+ )