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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- 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)]
|