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/env_print.py ADDED
@@ -0,0 +1,378 @@
1
+ """The environment print — a content-addressed record of *under what* a verdict ran (docs/115 §2).
2
+
3
+ > **DOS records *who did what* (the run-id spine, the lease WAL, the intent
4
+ > ledger, git ancestry). It records nothing about *under what* — which kernel,
5
+ > which Python, which OS, which toolchain adjudicated or produced the record. So
6
+ > two runs in different environments can reach different verdicts on the same
7
+ > input with no trace of the divergence in the fossil. An `EnvPrint` is the
8
+ > missing fact: gathered ONCE at the build boundary (a `WorkspaceFacts` sibling),
9
+ > frozen as data on the `SubstrateConfig`, and stamped onto the durable surfaces
10
+ > so every adjudication is contestable — recompute it under the recorded print
11
+ > and see if the verdict holds.**
12
+
13
+ This is the object for docs/115 primitive 1. It is deliberately the
14
+ ``run_id``/``WorkspaceFacts`` shape, not a new pattern:
15
+
16
+ * **A pure, frozen dataclass** (`EnvPrint`) that round-trips through a JSONL line
17
+ (`to_dict`/`from_dict`, the `RunId.to_dict` idiom). Constructible with NO I/O —
18
+ a hand-built print for a unit test never shells `git` (the
19
+ ``WorkspaceFacts(root=…)`` test-construction rule).
20
+ * **One boundary gatherer** (`gather_env_print`) — the ONLY function here that
21
+ touches `sys`/`platform`/`git`/the declared tool binaries. Called by the config
22
+ BUILDERS (`default_config`/`job_config`/`load_workspace_config`), the same
23
+ boundary `gather_workspace_facts` runs at, never inside a pure verdict (the
24
+ "I/O at the boundary, data to the pure core" discipline — cf.
25
+ `git_delta`/`journal_delta` → `liveness.classify`).
26
+ * **A content-addressed `digest`** — a short, stable hash over the print's fields
27
+ (Crockford base32, the run-id token alphabet). Two environments with the same
28
+ `digest` are interchangeable *by declaration*; the kernel does NOT assert they
29
+ are behaviorally identical (the model-id caveat — a pinned weight set is not a
30
+ pinned behavior — applies to the whole print). The `digest` is the *`EnvId`*:
31
+ the cheap key a WAL entry carries, and the value docs/115 primitive 3's
32
+ `FLEET_ENV_MISMATCH` arbiter gate compares against a declared pin.
33
+
34
+ What an `EnvPrint` is NOT (docs/115 §2):
35
+
36
+ * **Not a sandbox manager.** DOS does not create, snapshot, or enforce
37
+ environments — that is the host's container/Nix/devcontainer layer (the docs/99
38
+ actuation boundary: the kernel RECORDS and REFUSES, it does not ACTUATE). This
39
+ module records the *print* of whatever environment it was run in.
40
+ * **Not a behavioral guarantee.** A matching `digest` means "the same declared
41
+ inputs," never "the same output" (the temp-0-nondeterminism + model-id-drift
42
+ caveats forbid that claim). The print is evidence FOR a reproduction attempt,
43
+ not a proof OF reproducibility.
44
+ * **Not mandatory on the pure core.** A `SubstrateConfig` built without gathering
45
+ (the test path) carries ``env=None``; every consumer treats ``None`` as "not
46
+ recorded," exactly as ``WorkspaceFacts=None`` is treated. A pure verdict is
47
+ handed a print to STAMP, the way it is handed a clock — it never REQUIRES one.
48
+
49
+ The `tools` set is DECLARED (``dos.toml [env] tools = ["git", "node"]``), not an
50
+ open probe of everything on PATH: the kernel records only what a workspace says
51
+ matters, keeping the print small, stable, and free of ambient noise (the
52
+ closed-set-as-data discipline `reasons`/`stamp` ride, applied to the env axis).
53
+
54
+ Every `EnvPrint` carries a `durable_schema` family (``"env-print"``, version 1)
55
+ like every other durable record, so a print a newer kernel wrote is
56
+ refused-don't-guessed at read, not misparsed (docs/115 primitive 4 closes the loop
57
+ on the print itself).
58
+
59
+ Pure stdlib + `dos.durable_schema` (a leaf) — no third-party imports. The git read
60
+ is a guarded `subprocess` confined to `gather_env_print`, fail-safe to ``None`` on
61
+ any failure (no git, timeout, non-git dir), the `git_delta` posture.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import hashlib
67
+ import platform
68
+ import subprocess
69
+ import sys
70
+ from dataclasses import dataclass
71
+ from pathlib import Path
72
+ from typing import Any, Iterable, Mapping
73
+
74
+ from dos import durable_schema as _schema
75
+
76
+ # The durable-schema family + version every env-print record carries (§6/docs/115).
77
+ # Bumped ONLY on a NON-additive shape change; a new optional field (a new declared
78
+ # tool, say) is additive and does NOT bump it. A print tagged higher than this is
79
+ # REFUSED at read (`durable_schema.classify`), never guessed.
80
+ SCHEMA_FAMILY = "env-print"
81
+ ENV_PRINT_SCHEMA = 1
82
+
83
+ # The digest alphabet — Crockford base32, the same human-safe, case-folding set the
84
+ # run-id token uses (no I/O/O confusion, sortable). The digest is a fixed-width
85
+ # slice of a SHA-256 over the print's canonical fields, so it is short enough to
86
+ # eyeball in a WAL entry and stable across processes/platforms (a hash, not a
87
+ # Python `hash()` — which is salted per-process and would not match across runs).
88
+ _CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
89
+ _DIGEST_WIDTH = 12 # 12 base32 chars ≈ 60 bits — ample for an interchangeability key
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ToolVersion:
94
+ """One declared tool and the version string probed for it. Pure data.
95
+
96
+ ``name`` — the tool a workspace declared it cares about (``"git"``, ``"node"``).
97
+ ``version`` — the version string `gather_env_print` probed, or ``""`` when the
98
+ tool was declared but not found / did not answer (recorded as absent, not
99
+ dropped — "git was declared and missing" is itself a fact a reproduction
100
+ attempt needs, distinct from "git was never declared").
101
+ """
102
+
103
+ name: str
104
+ version: str = ""
105
+
106
+ def to_dict(self) -> dict:
107
+ return {"name": self.name, "version": self.version}
108
+
109
+ @classmethod
110
+ def from_obj(cls, obj: Any) -> "ToolVersion | None":
111
+ if not isinstance(obj, Mapping):
112
+ return None
113
+ name = obj.get("name")
114
+ if not isinstance(name, str) or not name:
115
+ return None
116
+ ver = obj.get("version")
117
+ return cls(name=name, version=ver if isinstance(ver, str) else "")
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class EnvPrint:
122
+ """A content-addressed record of the environment a verdict was computed in.
123
+
124
+ The `WorkspaceFacts` sibling on `SubstrateConfig` (`.env`): a frozen set of
125
+ facts about the *runtime*, gathered once at the build boundary and stamped onto
126
+ the durable surfaces. Pure — constructible with no I/O for a test.
127
+
128
+ kernel_version — `dos.__version__` (e.g. ``"0.8.0"``); the pip/dist version.
129
+ kernel_sha — the git SHA of the KERNEL's own source tree (HEAD), or
130
+ ``None`` when it cannot be determined (not a git checkout, a
131
+ wheel install). The one fact that catches the stale-editable-
132
+ `.pth` hazard directly: two worktrees at the same
133
+ `kernel_version` but different commits print different SHAs,
134
+ so a verdict from the wrong tree is self-evident in the fossil.
135
+ python — the Python version (``"3.13.1"``), `sys.version_info` joined,
136
+ NOT the full multi-line `sys.version` banner (stable across
137
+ builds of the same x.y.z).
138
+ platform — ``"<system>-<machine>"`` (``"win32-AMD64"`` / ``"linux-x86_64"``).
139
+ tools — the DECLARED tool versions (`ToolVersion`s), in declaration
140
+ order. Empty when a workspace declared none.
141
+
142
+ The `digest` (a property, not a stored field) is the `EnvId`: a stable hash over
143
+ (kernel_version, kernel_sha, python, platform, tools). Computed, never stored, so
144
+ it can never drift out of sync with the fields it summarizes — a record reads the
145
+ fields back and recomputes; a stored digest that disagreed with its fields would
146
+ be the exact silent-drift the kernel forbids.
147
+ """
148
+
149
+ kernel_version: str
150
+ kernel_sha: str | None = None
151
+ python: str = ""
152
+ platform: str = ""
153
+ tools: tuple[ToolVersion, ...] = ()
154
+
155
+ @property
156
+ def digest(self) -> str:
157
+ """The content-addressed `EnvId` — a stable base32 hash over the fields.
158
+
159
+ Deterministic across processes and platforms (a SHA-256 over a canonical
160
+ string, NOT Python's per-process-salted `hash()`), so the same environment
161
+ always prints the same digest and `FLEET_ENV_MISMATCH` (docs/115 §5) can
162
+ compare a worker's digest to a declared pin by equality. The tool set is
163
+ sorted into the canonical string so declaration ORDER does not change the
164
+ digest (two configs that declare ``["git","node"]`` vs ``["node","git"]``
165
+ describe the same environment and must hash alike).
166
+ """
167
+ tools_canon = ",".join(
168
+ f"{t.name}={t.version}" for t in sorted(self.tools, key=lambda t: t.name)
169
+ )
170
+ canon = "\x1f".join((
171
+ self.kernel_version,
172
+ self.kernel_sha or "",
173
+ self.python,
174
+ self.platform,
175
+ tools_canon,
176
+ ))
177
+ h = int.from_bytes(hashlib.sha256(canon.encode("utf-8")).digest(), "big")
178
+ out = []
179
+ for _ in range(_DIGEST_WIDTH):
180
+ out.append(_CROCKFORD[h & 0x1F])
181
+ h >>= 5
182
+ return "".join(reversed(out))
183
+
184
+ def to_dict(self) -> dict:
185
+ """The shape stamped onto a durable record (carries the schema tag).
186
+
187
+ Includes the computed `digest` as a convenience for a `--json` reader that
188
+ wants the key without recomputing — but `from_dict` RECOMPUTES it from the
189
+ fields and ignores any stored value, so a tampered/stale `digest` in a
190
+ record can never be believed (the field is authoritative, the stored digest
191
+ is a courtesy). The `durable_schema` tag rides here so a stamped print
192
+ self-declares its format (the `intent_entry` idiom).
193
+ """
194
+ return {
195
+ **_schema.tag(SCHEMA_FAMILY, ENV_PRINT_SCHEMA),
196
+ "kernel_version": self.kernel_version,
197
+ "kernel_sha": self.kernel_sha,
198
+ "python": self.python,
199
+ "platform": self.platform,
200
+ "tools": [t.to_dict() for t in self.tools],
201
+ "digest": self.digest,
202
+ }
203
+
204
+ @classmethod
205
+ def from_dict(cls, obj: Mapping[str, Any]) -> "EnvPrint | None":
206
+ """Parse an `EnvPrint` from a stamped record. None if absent/malformed.
207
+
208
+ Tolerant the way `SchemaTag.from_obj` is — a missing/garbled print yields
209
+ ``None``, not a crash (a fossil written by a kernel that did not stamp prints
210
+ simply has no print to read). The `digest` is RECOMPUTED from the parsed
211
+ fields; any stored ``"digest"`` is ignored, so the key can never disagree
212
+ with the data it summarizes.
213
+ """
214
+ if not isinstance(obj, Mapping):
215
+ return None
216
+ kv = obj.get("kernel_version")
217
+ if not isinstance(kv, str) or not kv:
218
+ return None
219
+ sha = obj.get("kernel_sha")
220
+ tools_raw = obj.get("tools")
221
+ tools: list[ToolVersion] = []
222
+ if isinstance(tools_raw, Iterable) and not isinstance(tools_raw, (str, bytes)):
223
+ for t in tools_raw:
224
+ tv = ToolVersion.from_obj(t)
225
+ if tv is not None:
226
+ tools.append(tv)
227
+ return cls(
228
+ kernel_version=kv,
229
+ kernel_sha=sha if isinstance(sha, str) and sha else None,
230
+ python=obj.get("python") if isinstance(obj.get("python"), str) else "",
231
+ platform=obj.get("platform") if isinstance(obj.get("platform"), str) else "",
232
+ tools=tuple(tools),
233
+ )
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # The boundary gatherer — the ONE I/O home (the `gather_workspace_facts` rule).
238
+ # Everything above is pure; everything that touches sys/platform/git is here.
239
+ # ---------------------------------------------------------------------------
240
+
241
+ _GIT_TIMEOUT_S = 10 # the `git_delta` cap — a hung git never blocks a config build
242
+
243
+
244
+ def _python_version() -> str:
245
+ """``"3.13.1"`` — the x.y.z, not the full `sys.version` banner."""
246
+ vi = sys.version_info
247
+ return f"{vi.major}.{vi.minor}.{vi.micro}"
248
+
249
+
250
+ def _platform_tag() -> str:
251
+ """``"<system>-<machine>"`` — ``"linux-x86_64"`` / ``"win32-AMD64"``.
252
+
253
+ `sys.platform` for the OS (matches the value DOS already reports in `doctor`'s
254
+ environment block and the `_filelock` win32 branch keys on) + `platform.machine`
255
+ for the arch, so a print distinguishes the same OS on different CPUs.
256
+ """
257
+ machine = platform.machine() or "unknown"
258
+ return f"{sys.platform}-{machine}"
259
+
260
+
261
+ def _kernel_sha(kernel_root: Path | None) -> str | None:
262
+ """The git HEAD SHA of the kernel's OWN tree, or ``None``. Guarded `subprocess`.
263
+
264
+ Anchored on the kernel package's own location (the directory `dos/` lives in),
265
+ NOT the served workspace — the question is "which commit of DOS is running,"
266
+ which is a property of the installed kernel, not of the repo it is adjudicating.
267
+ Fail-safe to ``None`` on every failure (no git, not a checkout, timeout) — a
268
+ wheel-installed kernel has no SHA and that is a recorded fact, not an error (the
269
+ `git_delta` returns-[] posture, lifted to "returns None").
270
+ """
271
+ root = kernel_root or Path(__file__).resolve().parent
272
+ try:
273
+ out = subprocess.run(
274
+ ["git", "-C", str(root), "rev-parse", "HEAD"],
275
+ capture_output=True,
276
+ text=True,
277
+ timeout=_GIT_TIMEOUT_S,
278
+ )
279
+ except (OSError, subprocess.SubprocessError):
280
+ return None
281
+ if out.returncode != 0:
282
+ return None
283
+ sha = out.stdout.strip()
284
+ return sha or None
285
+
286
+
287
+ def _tool_version(name: str) -> str:
288
+ """Probe ``<name> --version`` → its first non-empty output line, or ``""``.
289
+
290
+ Guarded `subprocess`, fail-safe to ``""`` (declared-but-absent is a fact, not an
291
+ error). Returns the raw first line the tool prints — the kernel does not parse a
292
+ semantic version out of it (that would be tool-specific policy); the print
293
+ records what the tool SAID, and two runs of the same tool print the same line.
294
+ """
295
+ try:
296
+ out = subprocess.run(
297
+ [name, "--version"],
298
+ capture_output=True,
299
+ text=True,
300
+ timeout=_GIT_TIMEOUT_S,
301
+ )
302
+ except (OSError, subprocess.SubprocessError):
303
+ return ""
304
+ if out.returncode != 0:
305
+ return ""
306
+ for line in (out.stdout or out.stderr or "").splitlines():
307
+ s = line.strip()
308
+ if s:
309
+ return s
310
+ return ""
311
+
312
+
313
+ # Per-process memo of gathered prints, keyed by (tools, kernel_root). The print
314
+ # describes the RUNTIME — kernel version + kernel-SHA + Python + OS/arch + the
315
+ # declared tools' versions — none of which change while the process lives (the
316
+ # kernel SHA cannot move under a running server; `platform.machine()` is itself
317
+ # CPython-cached). So the docstring's "probe the runtime ONCE" is literally true
318
+ # per process: the FIRST gather pays the `git rev-parse` subprocess + the WMI
319
+ # platform query (~tens of ms on Windows); every later gather returns the frozen
320
+ # print for free. This is the single biggest cost on the MCP server's per-tool-call
321
+ # config build (`load_workspace_config` → `default_config` → here), which used to
322
+ # re-spawn `git` on every call. Cleared by `_clear_env_print_cache()` for the rare
323
+ # test that wants to force a re-probe.
324
+ _GATHER_CACHE: "dict[tuple, EnvPrint]" = {}
325
+
326
+
327
+ def _clear_env_print_cache() -> None:
328
+ """Drop the per-process gather memo (test hook; a real process never needs it)."""
329
+ _GATHER_CACHE.clear()
330
+
331
+
332
+ def gather_env_print(
333
+ *,
334
+ tools: Iterable[str] = (),
335
+ kernel_root: Path | None = None,
336
+ ) -> EnvPrint:
337
+ """Probe the runtime once and freeze the discovered `EnvPrint`. The I/O HOME.
338
+
339
+ Called by the config BUILDERS (the boundary already allowed to touch the disk),
340
+ never by a pure verdict — the `gather_workspace_facts` discipline. `tools` is the
341
+ workspace's DECLARED tool list (from ``dos.toml [env] tools``); each is probed
342
+ via ``<name> --version`` and recorded (present or absent). `kernel_root` overrides
343
+ where the kernel-SHA git read is anchored (tests / an oddly-installed kernel);
344
+ defaults to this module's own directory.
345
+
346
+ Memoized per process on ``(tools, kernel_root)`` (see `_GATHER_CACHE`): the print
347
+ is a property of the running KERNEL, constant for the process's lifetime, so the
348
+ git subprocess + platform probe run ONCE and every later call is free. This is the
349
+ "gathered once at the build boundary" contract made literal — and what keeps a
350
+ long-lived server (the MCP server builds a config per tool call) from re-spawning
351
+ `git rev-parse` on every call.
352
+
353
+ `dos.__version__` is read lazily here (not at module import) to avoid a circular
354
+ import — `dos/__init__.py` imports `config`, which will import this; reaching back
355
+ up to the package at import time would cycle. At CALL time the package is fully
356
+ loaded, the same lazy-resolve `gather_workspace_facts` uses for `self_modify`.
357
+ """
358
+ # Materialize `tools` ONCE — it is typed `Iterable[str]`, so a one-shot
359
+ # generator is legal, and we both key the cache on it and (on a miss) iterate
360
+ # it to probe; reusing the same tuple keeps a generator caller correct.
361
+ tool_names = tuple(tools)
362
+ key = (tool_names, kernel_root)
363
+ cached = _GATHER_CACHE.get(key)
364
+ if cached is not None:
365
+ return cached
366
+
367
+ from dos import __version__ as kernel_version # noqa: PLC0415 — lazy, anti-cycle
368
+
369
+ probed = tuple(ToolVersion(name=n, version=_tool_version(n)) for n in tool_names)
370
+ print_ = EnvPrint(
371
+ kernel_version=kernel_version,
372
+ kernel_sha=_kernel_sha(kernel_root),
373
+ python=_python_version(),
374
+ platform=_platform_tag(),
375
+ tools=probed,
376
+ )
377
+ _GATHER_CACHE[key] = print_
378
+ return print_
dos/event_severity.py ADDED
@@ -0,0 +1,258 @@
1
+ """Operator-value severity for a dispatch-family bookkeeping event (noise filter).
2
+
3
+ The dispatch family (`/dispatch`, `/dispatch-loop`, `/replan`, `/next-up`) commits
4
+ and pushes an archive line for *every* iteration — even a 0-pick drain, a repeated
5
+ rate-limit, or a no-op gardening sweep. Measured 2026-06-03: 82 of the last 200
6
+ commits on `main` (41%) were this bookkeeping, ~20-28/hr at peak, and 67% of
7
+ dispatch-loop archives shipped 0 picks. Peers pulling `main` absorb the whole flood.
8
+
9
+ `classify_event()` is the keystone fix — a **pure** function that turns the event's
10
+ already-computed facts (the `verdict=<X>` token the write step holds, the pick count,
11
+ whether this is the *first* occurrence of a blocker this loop) into one typed
12
+ `Severity`. Each operator-facing *sink* (push, local-commit, terminal, report,
13
+ artifact) then admits or suppresses the event by comparing its severity against a
14
+ per-sink threshold (an env var) — exactly the way a logging framework filters by
15
+ level. The default thresholds make the common case quiet: only `SHIPPED` and a
16
+ *newly-surfaced* blocker reach `origin`; a 0-pick drain or no-op replan still commits
17
+ locally (audit kept) but never pushes.
18
+
19
+ SHIPPED material change landed (>=1 pick, verdict=LIVE, or a plan
20
+ promotion) — the thing the operator wants surfaced first
21
+ BLOCKED-NEW a blocker / operator-decision / crash seen for the FIRST time
22
+ this loop — actionable, may need a decision -> reaches peers
23
+ NOTICE state-changing but routine — a STALE-STAMP false-drain, >=1
24
+ finding/closure, a soft-claim, a --next-up-only packet
25
+ NOOP a non-event — 0-pick DRAIN, a REPEATED blocker/rate-limit, a
26
+ gardening-only quiet-sweep, a 0-net soft-claim
27
+
28
+ ⚓ Severity is `(verdict, first_occurrence)`, not verdict alone. A blocker the
29
+ operator has not seen is high-value; the *same* blocker recurring every iteration is
30
+ pure noise. The `first_occurrence` predicate is what collapses the repeated-blocked /
31
+ repeated-RATE_LIMITED flood (13 of 16 chained archives on 2026-06-03) into `NOOP`.
32
+ `SHIPPED` outranks `BLOCKED-NEW` because a landed pick is what the operator wants on
33
+ top.
34
+
35
+ ⚓ Pure kernel, I/O on the edge (the dos composition idiom — mirrors `classify_packet`
36
+ in `gate_classify.py`): `classify_event(EventState) -> Severity` is a frozen dataclass
37
+ in, an enum out — no subprocess, no file/clock/git call. Every signal (`verdict`,
38
+ `picks_shipped`, `first_occurrence`, the replan/next-up counters) is reduced to a
39
+ scalar/bool by the caller at the write step, which is also the only place that knows
40
+ them. The one concession to I/O is `sink_threshold()`, a single `os.environ.get`
41
+ (the `fanout_state` module-level idiom) — kept beside the classifier so a consumer has
42
+ one import. The classifier itself stays pure and is the unit-test surface.
43
+
44
+ ⚓ Reuse the verdict vocabulary, never re-list it. The classifier *input* is the
45
+ `verdict=<X>` token the archive subject already carries; `normalize_token` (this
46
+ package's `tokens.py`) is the one chokepoint that upper-cases it and folds the legacy
47
+ `WEDGE -> BLOCKED` alias, so a historical `verdict=WEDGE` event classifies identically
48
+ to `BLOCKED`. Do not duplicate the token set here.
49
+ """
50
+ from __future__ import annotations
51
+
52
+ import enum
53
+ import os
54
+ from dataclasses import dataclass
55
+
56
+ from .tokens import normalize_token
57
+
58
+
59
+ class Severity(str, enum.Enum):
60
+ """One typed operator-value level. `str`-valued so it round-trips as a bare
61
+ token through an env var and a commit subject (the `GateVerdict` pattern)."""
62
+
63
+ SHIPPED = "SHIPPED"
64
+ BLOCKED_NEW = "BLOCKED-NEW"
65
+ NOTICE = "NOTICE"
66
+ NOOP = "NOOP"
67
+
68
+ def __str__(self) -> str: # pragma: no cover - trivial
69
+ return self.value
70
+
71
+
72
+ # Threshold ordering — a sink with MIN=NOTICE admits NOTICE/BLOCKED-NEW/SHIPPED and
73
+ # rejects NOOP. Higher rank = higher operator value = harder to suppress.
74
+ _RANK: dict[Severity, int] = {
75
+ Severity.NOOP: 0,
76
+ Severity.NOTICE: 1,
77
+ Severity.BLOCKED_NEW: 2,
78
+ Severity.SHIPPED: 3,
79
+ }
80
+
81
+ # The verdict tokens that mean "a blocker / crash / rate-limit happened" — first
82
+ # occurrence is BLOCKED-NEW, a repeat is NOOP. Canonical (post-`normalize_token`)
83
+ # spellings only; WEDGE folds to BLOCKED upstream so it is covered by "BLOCKED".
84
+ _BLOCKER_VERDICTS = frozenset({"BLOCKED", "RACE", "ERROR", "RATE_LIMITED"})
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class EventState:
89
+ """Every input the write step already holds — no I/O happens to build this.
90
+
91
+ Mirrors `PickDisposition` / the `classify_packet` input: the caller reduces all
92
+ time/git/file state to these scalars at the I/O edge, then hands them in frozen.
93
+ """
94
+
95
+ family: str # "dispatch-loop" | "dispatch" | "replan" | "next-up"
96
+ verdict: str = "" # raw token from the archive subject (LIVE/DRAIN/BLOCKED/...)
97
+ picks_shipped: int = 0
98
+ # False = this verdict was already seen earlier this loop -> demote a blocker to
99
+ # NOOP. Defaults True so an unknown caller fails toward surfacing (never hides).
100
+ first_occurrence: bool = True
101
+ # --- replan §-counters (the gardening-sweep shape) -----------------------
102
+ new_findings: int = 0 # findings closed/added this sweep
103
+ substantive_ships: int = 0 # plan phases this sweep marked shipped
104
+ surfaced: int = 0 # inbox promotions this sweep (a real plan change)
105
+ # --- next-up render --------------------------------------------------------
106
+ soft_claims: int = 0 # picks soft-claimed by the rendered packet
107
+ # Did the staged pathspec actually differ from HEAD? False -> a no-op write.
108
+ staged_changed: bool = True
109
+
110
+
111
+ def classify_event(ev: EventState) -> Severity:
112
+ """PURE — the event -> severity mapping verbatim. No file/git/clock/env call."""
113
+ fam = (ev.family or "").strip().lower()
114
+ v = normalize_token(ev.verdict) or ""
115
+
116
+ if fam in ("dispatch", "dispatch-loop"):
117
+ if ev.picks_shipped > 0 or v == "LIVE":
118
+ return Severity.SHIPPED
119
+ if v in _BLOCKER_VERDICTS:
120
+ # First time this loop -> actionable; a repeat -> noise.
121
+ return Severity.BLOCKED_NEW if ev.first_occurrence else Severity.NOOP
122
+ if v == "STALE-STAMP":
123
+ # A false-drain: routes a /replan (operator-relevant -> NOTICE) but the
124
+ # following /replan, not this stamp, carries the real signal to peers.
125
+ return Severity.NOTICE
126
+ # DRAIN / 0-pick / unknown-token fallthrough — the dominant non-event.
127
+ return Severity.NOOP
128
+
129
+ if fam == "replan":
130
+ if ev.surfaced > 0:
131
+ return Severity.SHIPPED # an inbox promotion is a real plan change
132
+ if ev.new_findings > 0 or ev.substantive_ships > 0:
133
+ return Severity.NOTICE
134
+ # Gardening-only quiet-sweep (closed==0, added==0, surfaced==0): the §1.5
135
+ # SKIP gate already dropped the truly-empty case upstream; this is the
136
+ # "ran but only touched anchors/stale-claims" sweep — local audit, no push.
137
+ return Severity.NOOP
138
+
139
+ if fam == "next-up":
140
+ if not ev.staged_changed:
141
+ return Severity.NOOP # the renderer staged nothing (already-active picks)
142
+ return Severity.NOTICE if ev.soft_claims > 0 else Severity.NOOP
143
+
144
+ # Unknown family — fail safe: surface it rather than silently swallow.
145
+ return Severity.NOTICE
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Subject lead-token — the mechanical commit-subject headline.
150
+ #
151
+ # ⚓ Why this is a kernel function, not a SKILL.md prose rule. The Phase-1 fix
152
+ # pinned a *prose* rule to the replan write-step ("lead with the severity token,
153
+ # the run ordinal NEVER appears"). The live git log on 2026-06-03 proved prose
154
+ # does not fire: `docs/_plans: 185th /replan …` and `… (184th /replan)` still
155
+ # leaked the monotonic ordinal AFTER Phase 1 shipped. A model retyping a subject
156
+ # every iteration drifts; a function cannot. So the lead token is COMPUTED here —
157
+ # the ordinal is structurally absent because this function never takes it as an
158
+ # input. The write-step asks `severity_gate.py subject …` for the headline and
159
+ # prepends only the immutable family prefix (`docs/_plans: replan <date> — `).
160
+ #
161
+ # PURE, like `classify_event` — the same `EventState` in, a short headline string
162
+ # out. No clock/git/env/file call. The token is operator-facing English keyed off
163
+ # the SAME severity the gate computes, so the headline and the gate decision can
164
+ # never disagree about what happened.
165
+ # ---------------------------------------------------------------------------
166
+ def subject_lead_token(ev: EventState) -> str:
167
+ """The severity-shaped headline for `ev`'s commit subject (the lead phrase
168
+ after the immutable `docs/<family>: …` prefix). PURE — derived from the same
169
+ facts `classify_event` reads, so it always agrees with the gate verdict.
170
+
171
+ The run ordinal is *structurally* impossible here: it is not a field of
172
+ `EventState`, so a caller building the subject from this token cannot leak it
173
+ (the recurring `(185th /replan)` flood the prose rule failed to stop)."""
174
+ sev = classify_event(ev)
175
+ fam = (ev.family or "").strip().lower()
176
+ v = normalize_token(ev.verdict) or ""
177
+
178
+ if fam in ("dispatch", "dispatch-loop"):
179
+ if sev is Severity.SHIPPED:
180
+ n = ev.picks_shipped
181
+ return f"{n} pick{'s' if n != 1 else ''} shipped" if n > 0 else "shipped"
182
+ if sev is Severity.BLOCKED_NEW:
183
+ # A FIRST-seen blocker — name the blocker class so the operator can act.
184
+ # A bare "BLOCKED" verdict needs no parenthetical (it would read
185
+ # "blocked (blocked)"); a more specific token (RATE_LIMITED / RACE /
186
+ # ERROR) is surfaced so the operator sees WHICH wall they hit.
187
+ if not v or v == "BLOCKED":
188
+ return "blocked"
189
+ return f"blocked ({v.lower()})"
190
+ if v == "STALE-STAMP":
191
+ return "stale-stamp false-drain (/replan recommended)"
192
+ return "drained" # NOOP — the dominant 0-pick non-event
193
+
194
+ if fam == "replan":
195
+ if sev is Severity.SHIPPED:
196
+ return f"inbox promoted: {ev.surfaced}"
197
+ if sev is Severity.NOTICE:
198
+ # State-changing gardening — lead with the counts that moved.
199
+ return f"{ev.new_findings} closed / {ev.substantive_ships} shipped"
200
+ return "quiet sweep" # NOOP — gardening-only; NO ordinal
201
+
202
+ if fam == "next-up":
203
+ if sev is Severity.NOTICE: # a real soft-claim
204
+ n = ev.soft_claims
205
+ return f"soft-claims ({n} pick{'s' if n != 1 else ''})"
206
+ return "no-op (lane drained)" # NOOP
207
+
208
+ # Unknown family — surface the raw severity so nothing is silently swallowed.
209
+ return sev.value.lower()
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Per-sink thresholds — the one I/O concession (an env read), kept beside the
214
+ # classifier so a consumer has a single import. Defaults make the common case quiet.
215
+ # ---------------------------------------------------------------------------
216
+ # Each sink: (neutral env key, JOB_-prefixed back-compat key, quiet default). The
217
+ # kernel-NEUTRAL `DISPATCH_*_MIN_SEVERITY` is the PRIMARY namespace; the host-branded
218
+ # `JOB_DISPATCH_*_MIN_SEVERITY` is a documented BACK-COMPAT fallback (the same
219
+ # generic-primary / JOB_-fallback shape `lane_journal` uses for its journal-path env).
220
+ # Before the userland-coupling audit 2026-06-08 the JOB_ key was the SOLE namespace,
221
+ # so a generic workspace had no neutral surface for these thresholds.
222
+ _SINK_ENV: dict[str, tuple[str, str, Severity]] = {
223
+ # what peers pulling main see — the highest bar
224
+ "push": ("DISPATCH_PUSH_MIN_SEVERITY", "JOB_DISPATCH_PUSH_MIN_SEVERITY", Severity.BLOCKED_NEW),
225
+ # local history / audit — keep everything (coalesced, not per-iter, in Phase 2)
226
+ "commit": ("DISPATCH_COMMIT_MIN_SEVERITY", "JOB_DISPATCH_COMMIT_MIN_SEVERITY", Severity.NOOP),
227
+ # the live heartbeat stream
228
+ "terminal": ("DISPATCH_TERMINAL_MIN_SEVERITY", "JOB_DISPATCH_TERMINAL_MIN_SEVERITY", Severity.NOTICE),
229
+ # the end-of-run report block (absence-as-signal Attention line)
230
+ "report": ("DISPATCH_REPORT_MIN_SEVERITY", "JOB_DISPATCH_REPORT_MIN_SEVERITY", Severity.NOTICE),
231
+ # the docs/ README tree (the result.json envelope is EXEMPT — load-bearing)
232
+ "artifact": ("DISPATCH_ARTIFACT_MIN_SEVERITY", "JOB_DISPATCH_ARTIFACT_MIN_SEVERITY", Severity.NOTICE),
233
+ }
234
+
235
+ SINKS: tuple[str, ...] = tuple(_SINK_ENV)
236
+
237
+
238
+ def sink_threshold(sink: str) -> Severity:
239
+ """The configured MIN severity a sink admits. The ONLY I/O — an env read. The
240
+ kernel-neutral `DISPATCH_*_MIN_SEVERITY` wins; the host-branded
241
+ `JOB_DISPATCH_*_MIN_SEVERITY` is checked as a documented back-compat fallback.
242
+ An unset or unparseable value falls back to the (quiet) default for that sink."""
243
+ try:
244
+ neutral_key, job_key, default = _SINK_ENV[sink]
245
+ except KeyError as exc:
246
+ raise ValueError(f"unknown sink {sink!r}; known: {', '.join(SINKS)}") from exc
247
+ raw = (os.environ.get(neutral_key) or os.environ.get(job_key) or "").strip().upper()
248
+ if not raw:
249
+ return default
250
+ try:
251
+ return Severity(raw)
252
+ except ValueError:
253
+ return default
254
+
255
+
256
+ def admits(sink: str, sev: Severity) -> bool:
257
+ """True iff an event of `sev` clears `sink`'s threshold (rank >= threshold rank)."""
258
+ return _RANK[sev] >= _RANK[sink_threshold(sink)]