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/verdict_rollup.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""verdict-rollup — fold many status-bearing items into ONE surface-agnostic
|
|
2
|
+
summary every render surface reproduces identically (C9, the cross-surface-drift fix).
|
|
3
|
+
|
|
4
|
+
A single per-producer verdict reaches the operator on several surfaces — console,
|
|
5
|
+
Slack, an LLM summary, a JSON bundle — and each surface independently decides
|
|
6
|
+
whether to show it and how. They drift: a probe a run *requested* but that never
|
|
7
|
+
produced output shows on one surface and is silent on another (the
|
|
8
|
+
"requested-but-absent → false-pass" class the reference benchmark logged before it
|
|
9
|
+
built a per-feature rollup). The fix is to fold the statuses ONCE, here, into a
|
|
10
|
+
summary object every surface renders from — so they cannot disagree about the
|
|
11
|
+
headline, the all-clean flag, or a missing producer.
|
|
12
|
+
|
|
13
|
+
This is the **domain-free kernel half** of that rollup. It ships the FOLD; the
|
|
14
|
+
status vocabulary is CALLER DATA:
|
|
15
|
+
|
|
16
|
+
* The caller supplies a `StatusRank` — its own closed status set mapped to a
|
|
17
|
+
worst-first severity order (`{"failed": 0, "ok": 5, …}`). The kernel hard-codes
|
|
18
|
+
NO status names (no "blktrace", no "ok"/"empty") — those are workspace data, the
|
|
19
|
+
same closed-enum-as-data seam as `reasons`/`stamp`. Law 1 (kernel imports no
|
|
20
|
+
host) holds: a rolled-up status is a string the kernel never interprets beyond
|
|
21
|
+
"where does it rank?".
|
|
22
|
+
* `requested-but-absent` is a FIRST-CLASS synthesized item, not a silence: a
|
|
23
|
+
caller that *expected* a producer (it was requested) but got no item for it
|
|
24
|
+
passes its key to `rollup(..., absent=[…])`, and the fold emits a typed item
|
|
25
|
+
for it at a caller-named `absent_status`. So "we asked for X and got nothing"
|
|
26
|
+
surfaces as a verdict on every surface, never as a missing row one surface
|
|
27
|
+
happens to omit.
|
|
28
|
+
|
|
29
|
+
The result composes with the `dos.verdict` conventions (a `worst_status` headline
|
|
30
|
+
+ `reason` + `to_dict`) so it drops into the same `--output json` / renderer seam
|
|
31
|
+
as the other verdicts. Pure stdlib leaf — never raises on a malformed item (it
|
|
32
|
+
degrades to a caller-named `unknown_status`), the fail-safe rollup discipline.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class StatusRank:
|
|
42
|
+
"""A caller's closed status vocabulary as a worst-first severity order.
|
|
43
|
+
|
|
44
|
+
`order` maps each status string to an int where SMALLER == more severe (so the
|
|
45
|
+
worst status is the `min`). `unknown_status` is what a malformed/unrankable item
|
|
46
|
+
folds to (must itself be in `order`); `absent_status` is the status a
|
|
47
|
+
requested-but-absent producer is synthesized as (also in `order`). Both default
|
|
48
|
+
to common names but are caller-overridable — the kernel ships no fixed vocabulary.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
order: Mapping[str, int]
|
|
52
|
+
unknown_status: str = "unknown"
|
|
53
|
+
absent_status: str = "requested"
|
|
54
|
+
|
|
55
|
+
def rank(self, status: str) -> int:
|
|
56
|
+
"""Severity rank of `status` (smaller = worse). An unranked status sorts
|
|
57
|
+
LAST (least severe) so an unknown caller status never masquerades as the
|
|
58
|
+
worst — the headline must not be driven by a typo."""
|
|
59
|
+
return self.order.get(status, max(self.order.values(), default=0) + 1)
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
if not self.order:
|
|
63
|
+
raise ValueError("StatusRank.order must be non-empty")
|
|
64
|
+
for name in (self.unknown_status, self.absent_status):
|
|
65
|
+
if name not in self.order:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"StatusRank: {name!r} must appear in `order` "
|
|
68
|
+
f"(got {sorted(self.order)})")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class RollupItem:
|
|
73
|
+
"""One folded item — a producer's status plus why, surface-agnostic.
|
|
74
|
+
|
|
75
|
+
`key` identifies the producer (a device, a check name, …). `status` is one of
|
|
76
|
+
the caller's vocabulary. `reason` is the operator-facing line. `absent` marks a
|
|
77
|
+
synthesized requested-but-absent item (so a surface can style it distinctly).
|
|
78
|
+
`integrity` carries extra flags a surface may highlight even on a status string
|
|
79
|
+
that predates them (the lift's integrity-field idea, generalized to data)."""
|
|
80
|
+
|
|
81
|
+
key: str
|
|
82
|
+
status: str
|
|
83
|
+
reason: str = ""
|
|
84
|
+
absent: bool = False
|
|
85
|
+
integrity: tuple[str, ...] = ()
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
return {
|
|
89
|
+
"key": self.key,
|
|
90
|
+
"status": self.status,
|
|
91
|
+
"reason": self.reason,
|
|
92
|
+
"absent": self.absent,
|
|
93
|
+
"integrity": list(self.integrity),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class VerdictRollup:
|
|
99
|
+
"""The single surface-agnostic summary every render surface reproduces.
|
|
100
|
+
|
|
101
|
+
Composes with the `dos.verdict.TypedVerdict` shape: `worst_status` is the
|
|
102
|
+
headline verdict, `reason` the one-line summary, `to_dict` the JSON seam. Every
|
|
103
|
+
surface reads THESE fields, so they cannot drift in what they claim."""
|
|
104
|
+
|
|
105
|
+
label: str
|
|
106
|
+
items: tuple[RollupItem, ...]
|
|
107
|
+
rank: StatusRank = field(repr=False, default=None) # type: ignore[assignment]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def present(self) -> bool:
|
|
111
|
+
"""True when there is anything worth surfacing at all."""
|
|
112
|
+
return bool(self.items)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def worst_status(self) -> Optional[str]:
|
|
116
|
+
"""The single most-severe status across all items (None when empty)."""
|
|
117
|
+
if not self.items:
|
|
118
|
+
return None
|
|
119
|
+
return min((it.status for it in self.items), key=self.rank.rank)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def all_clean(self) -> bool:
|
|
123
|
+
"""True iff every item is at the LEAST-severe rank and carries no integrity
|
|
124
|
+
flag — i.e. nothing to act on. (Least-severe == the max-rank status, the
|
|
125
|
+
caller's 'ok'.)"""
|
|
126
|
+
if not self.items:
|
|
127
|
+
return True
|
|
128
|
+
best = max(self.rank.order.values())
|
|
129
|
+
return all(
|
|
130
|
+
self.rank.rank(it.status) == best and not it.integrity
|
|
131
|
+
for it in self.items
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def reason(self) -> str:
|
|
136
|
+
"""A counts-by-status headline, worst-first, e.g. ``2 ok, 1 empty``."""
|
|
137
|
+
if not self.items:
|
|
138
|
+
return f"{self.label}: nothing reported"
|
|
139
|
+
counts: dict[str, int] = {}
|
|
140
|
+
for it in self.items:
|
|
141
|
+
counts[it.status] = counts.get(it.status, 0) + 1
|
|
142
|
+
ordered = sorted(counts.items(), key=lambda kv: self.rank.rank(kv[0]))
|
|
143
|
+
return ", ".join(f"{n} {st}" for st, n in ordered)
|
|
144
|
+
|
|
145
|
+
# `verdict` is the TypedVerdict headline (the str-valued status). Kept a
|
|
146
|
+
# property (not a field) so the rollup satisfies the Protocol structurally.
|
|
147
|
+
@property
|
|
148
|
+
def verdict(self) -> Optional[str]:
|
|
149
|
+
return self.worst_status
|
|
150
|
+
|
|
151
|
+
def to_dict(self) -> dict:
|
|
152
|
+
return {
|
|
153
|
+
"label": self.label,
|
|
154
|
+
"verdict": self.worst_status,
|
|
155
|
+
"worst_status": self.worst_status,
|
|
156
|
+
"all_clean": self.all_clean,
|
|
157
|
+
"reason": self.reason,
|
|
158
|
+
"present": self.present,
|
|
159
|
+
"items": [it.to_dict() for it in self.items],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _coerce_status(raw: Any, rank: StatusRank) -> str:
|
|
164
|
+
"""A status the caller's rank knows, or the caller's unknown_status (never raise)."""
|
|
165
|
+
s = str(raw) if raw is not None else rank.unknown_status
|
|
166
|
+
return s if s in rank.order else rank.unknown_status
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def rollup(
|
|
170
|
+
items: Sequence[Any],
|
|
171
|
+
*,
|
|
172
|
+
rank: StatusRank,
|
|
173
|
+
label: str = "rollup",
|
|
174
|
+
absent: Sequence[str] | None = None,
|
|
175
|
+
status_of=lambda x: _get(x, "status"),
|
|
176
|
+
key_of=lambda x: _get(x, "key"),
|
|
177
|
+
reason_of=lambda x: _get(x, "reason", ""),
|
|
178
|
+
integrity_of=lambda x: _get(x, "integrity", ()),
|
|
179
|
+
) -> VerdictRollup:
|
|
180
|
+
"""Fold status-bearing `items` (+ requested-but-absent keys) into one rollup.
|
|
181
|
+
|
|
182
|
+
`items` is any sequence of objects or dicts; `status_of`/`key_of`/`reason_of`/
|
|
183
|
+
`integrity_of` extract the fields (defaulting to attr/dict access by those
|
|
184
|
+
names) — so a caller's own collector objects roll up without adapting them.
|
|
185
|
+
A status not in `rank.order` degrades to `rank.unknown_status`; a malformed
|
|
186
|
+
item degrades to one `unknown` row (never raises — the fail-safe discipline).
|
|
187
|
+
|
|
188
|
+
`absent` is the load-bearing anti-drift feature: each key in it is a producer
|
|
189
|
+
the caller REQUESTED but got no item for. Each becomes a synthesized
|
|
190
|
+
`RollupItem(absent=True, status=rank.absent_status)` so "requested but missing"
|
|
191
|
+
surfaces as a typed verdict on every surface, not as a silently-dropped row.
|
|
192
|
+
"""
|
|
193
|
+
rows: list[RollupItem] = []
|
|
194
|
+
for it in items or ():
|
|
195
|
+
try:
|
|
196
|
+
rows.append(RollupItem(
|
|
197
|
+
key=str(key_of(it) or "?"),
|
|
198
|
+
status=_coerce_status(status_of(it), rank),
|
|
199
|
+
reason=str(reason_of(it) or ""),
|
|
200
|
+
integrity=tuple(str(x) for x in (integrity_of(it) or ())),
|
|
201
|
+
))
|
|
202
|
+
except Exception: # noqa: BLE001 — a bad item must not break the fold
|
|
203
|
+
rows.append(RollupItem(
|
|
204
|
+
key="?", status=rank.unknown_status,
|
|
205
|
+
reason="(unreadable item record)"))
|
|
206
|
+
for k in absent or ():
|
|
207
|
+
rows.append(RollupItem(
|
|
208
|
+
key=str(k), status=rank.absent_status, absent=True,
|
|
209
|
+
reason="requested but no result was produced"))
|
|
210
|
+
return VerdictRollup(label=label, items=tuple(rows), rank=rank)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _get(obj: Any, name: str, default: Any = None) -> Any:
|
|
214
|
+
"""Attr-or-dict access, the duck-typed reader the lift uses."""
|
|
215
|
+
if isinstance(obj, dict):
|
|
216
|
+
return obj.get(name, default)
|
|
217
|
+
return getattr(obj, name, default)
|
dos/verdicts.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""The verdict registry — the OS-extensibility surface for distrust verbs (docs/86 §2).
|
|
2
|
+
|
|
3
|
+
`verdict.py` names the *contract* a typed verdict satisfies; this module is the
|
|
4
|
+
*registry* of verbs that satisfy it. It is the seam that lets a third party add a
|
|
5
|
+
verdict the way you add a device driver: ship a module with a `classify` (the
|
|
6
|
+
`verdict.Classifier` shape) and a short spec, `register()` it, and a consumer —
|
|
7
|
+
the CLI, the `dos decisions` queue, an MCP tool — can enumerate and dispatch it
|
|
8
|
+
WITHOUT the kernel hard-wiring its name.
|
|
9
|
+
|
|
10
|
+
This is the **verb-analogue of the data registries the kernel already ships**:
|
|
11
|
+
`[reasons]` (`ReasonRegistry`), `[lanes]` (`LaneTaxonomy`), `[stamp]`
|
|
12
|
+
(`StampConvention`) declare a workspace's *vocabulary* as data; this declares its
|
|
13
|
+
set of *verdicts* as registered specs. The combination is the one design pattern
|
|
14
|
+
(HACKING.md, the closed-enum-as-data axis) lifted one level up: **an OPEN set of
|
|
15
|
+
verbs, each with a CLOSED verdict shape.**
|
|
16
|
+
|
|
17
|
+
Built *after* there were two real instances to generalize from (`liveness`,
|
|
18
|
+
`scope`) — the "generalize last" discipline (docs/86 §4): a registry built before
|
|
19
|
+
its instances is machinery imported ahead of need. The two are seed-registered
|
|
20
|
+
below, centrally — the registry imports the verbs (consumer → verb, the same
|
|
21
|
+
arrow `cli.py` uses), so the pure verb modules stay registry-UNAWARE and there is
|
|
22
|
+
no import cycle. A verb must never `import dos.verdicts`.
|
|
23
|
+
|
|
24
|
+
The four-gate registration test (docs/85 §2) a verdict must pass to be a kernel
|
|
25
|
+
verb: (1) it answers a claim about ground-truth state; (2) its evidence is
|
|
26
|
+
unforgeable by the agent; (3) it is domain-free; (4) its verdict is a mechanical
|
|
27
|
+
closed enum. Gate (4) is machine-checkable here (`verdict.conforms` on a produced
|
|
28
|
+
sample); gates (1)–(3) are properties of the verb's DESIGN that no runtime check
|
|
29
|
+
can see — they stay a review responsibility, and `register(..., reviewed=...)`
|
|
30
|
+
records that a human signed off so a registry audit can surface anything that
|
|
31
|
+
slipped in unreviewed.
|
|
32
|
+
|
|
33
|
+
NOTE on `verify`: it is the third epistemic verb, but `oracle.ShipVerdict` has not
|
|
34
|
+
yet been harmonized to the contract (`shipped: bool` + `source`, no `.verdict`
|
|
35
|
+
enum / `.reason` — see `verdict.py` and the docs/86 §4 step-1 harmonization). So
|
|
36
|
+
it is deliberately NOT seed-registered here; it would fail gate (4) today. That
|
|
37
|
+
absence is the honest marker of the remaining drift, not an oversight.
|
|
38
|
+
|
|
39
|
+
This module is a CONSUMER, on the same side of the layering line as `cli.py` /
|
|
40
|
+
`dos_mcp`: nothing under `src/dos/*.py` that is a *verb* imports it.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from typing import Any, Callable, Optional
|
|
47
|
+
|
|
48
|
+
from . import verdict as _verdict
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class VerdictSpec:
|
|
53
|
+
"""One registered distrust verb.
|
|
54
|
+
|
|
55
|
+
name — the CLI/MCP verb token (e.g. "liveness", "scope"). Unique.
|
|
56
|
+
classify — the pure `verdict.Classifier`: `(evidence, policy=...) -> Verdict`.
|
|
57
|
+
summary — a one-line help string (the `dos <verb> --help` / MCP description).
|
|
58
|
+
distrusts — the claim this verb refuses to take on faith, in one phrase
|
|
59
|
+
(gate (1) made explicit: "I'm making progress" / "the diff stayed
|
|
60
|
+
in its lane"). Documentation, and the audit's gate-(1) prompt.
|
|
61
|
+
gather — OPTIONAL boundary-I/O callable `(args, cfg) -> Evidence`
|
|
62
|
+
producing the `evidence` argument (the `git_delta`-mold reader).
|
|
63
|
+
The registry stores it so a CLI/MCP consumer can run `gather()`
|
|
64
|
+
then `classify()`; None means the consumer supplies evidence
|
|
65
|
+
itself (e.g. the benchmark sink, which builds evidence inline).
|
|
66
|
+
|
|
67
|
+
The remaining fields are the CLI/MCP ADAPTER — everything a *generic* consumer
|
|
68
|
+
(`verdict_cli.attach`) needs to build this verb's surface WITHOUT hard-wiring
|
|
69
|
+
its name. They are what make the wiring modular: adding a verb is a
|
|
70
|
+
`register(...)` with these filled in, and the dispatcher loop does the rest.
|
|
71
|
+
All optional, so a spec used only as a library classifier (or by the bench)
|
|
72
|
+
omits them.
|
|
73
|
+
|
|
74
|
+
add_arguments — `(parser) -> None`: add this verb's flags to an argparse
|
|
75
|
+
subparser (delegates straight to argparse — a callable, not a
|
|
76
|
+
data-DSL, so we don't reinvent argument parsing). None = no
|
|
77
|
+
verb-specific flags beyond the shared `--workspace`/`--output`.
|
|
78
|
+
policy_from — `(cfg) -> Policy`: build the verb's Policy from the resolved
|
|
79
|
+
`SubstrateConfig` (the `dos.toml [<name>]` seam). None = the
|
|
80
|
+
classifier's own default policy is used.
|
|
81
|
+
exit_codes — `{verdict.value: int}`: the process exit code per verdict
|
|
82
|
+
state (e.g. liveness ADVANCING→0/SPINNING→3/STALLED→4). Empty =
|
|
83
|
+
always exit 0 (the verdict is in stdout regardless).
|
|
84
|
+
reviewed — whether a human confirmed gates (1)–(3) (which no runtime check
|
|
85
|
+
can see). A registry audit flags `reviewed=False` entries.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
classify: "_verdict.Classifier"
|
|
90
|
+
summary: str
|
|
91
|
+
distrusts: str
|
|
92
|
+
gather: Optional[Callable[..., Any]] = None
|
|
93
|
+
add_arguments: Optional[Callable[[Any], None]] = None
|
|
94
|
+
policy_from: Optional[Callable[[Any], Any]] = None
|
|
95
|
+
exit_codes: dict = field(default_factory=dict)
|
|
96
|
+
reviewed: bool = False
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
if not self.name or not self.name.strip():
|
|
100
|
+
raise ValueError("a verdict spec needs a non-empty name")
|
|
101
|
+
if not callable(self.classify):
|
|
102
|
+
raise ValueError(f"verdict {self.name!r}: classify must be callable")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# The registry. Insertion-ordered so `names()` is stable for tests/help output.
|
|
106
|
+
_REGISTRY: dict[str, VerdictSpec] = {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def register(spec: VerdictSpec, *, replace: bool = False) -> VerdictSpec:
|
|
110
|
+
"""Register a distrust verb. Raises on a duplicate name unless `replace`.
|
|
111
|
+
|
|
112
|
+
The callable/name checks are in `VerdictSpec.__post_init__`. The verdict-shape
|
|
113
|
+
gate (gate 4) is NOT run here — it needs a *produced* verdict, which `register`
|
|
114
|
+
has no evidence to make; use `validate_sample()` with a representative verdict
|
|
115
|
+
(every verb's test suite does — see `test_verdicts`). Keeping `register` cheap
|
|
116
|
+
and total (no verdict construction) is what lets a third party register at
|
|
117
|
+
import time without wiring up evidence first.
|
|
118
|
+
"""
|
|
119
|
+
name = spec.name.strip()
|
|
120
|
+
if name in _REGISTRY and not replace:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"verdict {name!r} already registered (pass replace=True to override)"
|
|
123
|
+
)
|
|
124
|
+
_REGISTRY[name] = spec
|
|
125
|
+
return spec
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validate_sample(spec: VerdictSpec, sample: Any) -> bool:
|
|
129
|
+
"""Gate (4): does a verdict this verb produced satisfy the typed contract?
|
|
130
|
+
|
|
131
|
+
`sample` is a verdict the verb's `classify` returned on representative
|
|
132
|
+
evidence. Returns True iff it `verdict.conforms` — a mechanical closed-enum
|
|
133
|
+
`verdict` + a str `reason` + a JSON-shaped `to_dict`. A registry audit / a
|
|
134
|
+
verb's own test calls this; it is the one gate a machine can enforce.
|
|
135
|
+
"""
|
|
136
|
+
return _verdict.conforms(sample)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get(name: str) -> VerdictSpec:
|
|
140
|
+
"""The spec for `name`. Raises KeyError if unregistered."""
|
|
141
|
+
return _REGISTRY[name]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def names() -> list[str]:
|
|
145
|
+
"""Registered verb names, in registration order (stable)."""
|
|
146
|
+
return list(_REGISTRY)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def all_specs() -> dict[str, VerdictSpec]:
|
|
150
|
+
"""A copy of the registry — for a consumer enumerating verbs (cli/mcp/audit)."""
|
|
151
|
+
return dict(_REGISTRY)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def unreviewed() -> list[str]:
|
|
155
|
+
"""Verb names registered without a human gate-(1)–(3) sign-off (audit hook)."""
|
|
156
|
+
return [n for n, s in _REGISTRY.items() if not s.reviewed]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Seed registration — the two instances that prove the shape (docs/86 §4).
|
|
161
|
+
# Central (here), not self-registration in the verb modules, so liveness/scope
|
|
162
|
+
# stay pure and registry-unaware (the one-way arrow). `verify` is intentionally
|
|
163
|
+
# absent until ShipVerdict is harmonized to the contract (it fails gate 4 today).
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
from . import liveness as _liveness # noqa: E402 (consumer import, after the API)
|
|
166
|
+
from . import scope as _scope # noqa: E402
|
|
167
|
+
|
|
168
|
+
register(VerdictSpec(
|
|
169
|
+
name="liveness",
|
|
170
|
+
classify=_liveness.classify,
|
|
171
|
+
summary="is the run advancing, or just spinning? (the temporal verdict)",
|
|
172
|
+
distrusts="I'm making progress",
|
|
173
|
+
reviewed=True,
|
|
174
|
+
))
|
|
175
|
+
register(VerdictSpec(
|
|
176
|
+
name="scope",
|
|
177
|
+
classify=_scope.classify,
|
|
178
|
+
summary="did the diff stay inside the lane it claims? (the footprint verdict)",
|
|
179
|
+
distrusts="the change I stamped as (plan, phase) stays in that phase's lane",
|
|
180
|
+
reviewed=True,
|
|
181
|
+
))
|
dos/wedge_reason.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Closed `reason_class` vocabulary for `/next-up` no-pick (WEDGE / DRAIN) verdicts.
|
|
2
|
+
|
|
3
|
+
⚓ One surface per metric / mechanical-contract-over-prose. Before this module the
|
|
4
|
+
WEDGE refusal — the single most common dispatch-loop outcome (110/128 runs shipped
|
|
5
|
+
0 picks in the 7d window of 2026-05-31, yet the scoreboard read `live_ship_rate=1.0`
|
|
6
|
+
because it only measures *launched* picks) — was authored entirely in `/next-up`
|
|
7
|
+
SKILL Step 2.5/Step 3 LLM prose. Each WEDGE envelope invented its own `reason_class`
|
|
8
|
+
token and its own ad-hoc JSON shape. The three consumers could not agree on the set:
|
|
9
|
+
|
|
10
|
+
* the PRODUCER (`scripts/next_up_render.py`) only emitted typed verdicts for
|
|
11
|
+
`LANE_DRAINED` / `ALREADY_SHIPPED` / `IN_FLIGHT` / `FILE_COLLISION` / `RACE`;
|
|
12
|
+
every LANE_* WEDGE was hand-written;
|
|
13
|
+
* the VERIFIER (`scripts/picker_oracle.py`) recognised ~8 tokens, NONE of the
|
|
14
|
+
LANE_* WEDGE tokens the LLM actually wrote — so every WEDGE classified as
|
|
15
|
+
`UNCLASSIFIED` ("cannot verify — recommend backfill"). The oracle reported
|
|
16
|
+
itself healthy (`oracle_disagrees=0`) precisely because it could not classify
|
|
17
|
+
the rows that mattered;
|
|
18
|
+
* the CONSUMER (`scripts/fanout_preflight_context.py`) never opened the verdict
|
|
19
|
+
envelope at all (FQ-410).
|
|
20
|
+
|
|
21
|
+
This module is the one place the token set is declared. All three consumers import
|
|
22
|
+
it, so a new reason class is added once, here, and is simultaneously:
|
|
23
|
+
emittable by the producer, verifiable by the oracle, and refusable by the preflight.
|
|
24
|
+
|
|
25
|
+
Pure stdlib (no third-party imports) so `next_up_render` / `fanout_state` /
|
|
26
|
+
`picker_oracle` / `fanout_preflight_context` can all import it without dragging in
|
|
27
|
+
their own heavy deps. The companion category vocabulary lives in
|
|
28
|
+
`picker_oracle.NoPickCause`; we mirror the category *strings* here (not the enum) to
|
|
29
|
+
avoid a circular import, and `tests/test_dispatch_pick_observability.py` pins the two in lockstep.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import enum
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NoPickCategory(str, enum.Enum):
|
|
38
|
+
"""Coarse category each `reason_class` rolls up to.
|
|
39
|
+
|
|
40
|
+
Mirrors the string values of `picker_oracle.NoPickCause` so the oracle can map
|
|
41
|
+
a `WedgeReason` straight onto its verification branch. Kept as an independent
|
|
42
|
+
enum (not an import of `NoPickCause`) so this low-level module has zero
|
|
43
|
+
`scripts/`-internal deps; `tests/test_dispatch_pick_observability.py` asserts every value here
|
|
44
|
+
is a member of `NoPickCause`.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
TRUE_DRAIN = "TRUE_DRAIN" # all in-scope plans remaining:[]; no findings
|
|
48
|
+
OPERATOR_GATE = "OPERATOR_GATE" # soak open / operator-attended / env-flag-gated
|
|
49
|
+
STALE_CLAIM = "STALE_CLAIM" # collision with an in-flight/foreign soft|hard claim
|
|
50
|
+
LEASE_HELD = "OPERATOR_GATE" # foreign live /dispatch-loop owns the lane lease
|
|
51
|
+
INFLIGHT = "STALE_CLAIM" # remaining phases all soft-claimed by a sibling packet
|
|
52
|
+
MISROUTE = "MISROUTE" # finding routed to the wrong lane
|
|
53
|
+
RENDERER_BUG = "RENDERER_BUG" # packet rendered picks but the renderer dropped an artifact
|
|
54
|
+
UNCLASSIFIED = "UNCLASSIFIED" # legacy / hand-authored envelope, no known token
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@enum.unique
|
|
58
|
+
class WedgeReason(str, enum.Enum):
|
|
59
|
+
"""The closed set of `reason_class` tokens a no-pick verdict may carry.
|
|
60
|
+
|
|
61
|
+
Membership here is the contract: the producer validates against it before
|
|
62
|
+
writing an envelope, the oracle maps each member onto a `NoPickCause`, and the
|
|
63
|
+
preflight refuses a packet whose envelope carries any of them. A token observed
|
|
64
|
+
in the wild that is NOT here is exactly the prose-drift this module exists to
|
|
65
|
+
end — it surfaces as `UNCLASSIFIED` and is a bug to add, not to tolerate.
|
|
66
|
+
|
|
67
|
+
The tokens below are the union of (a) the deterministic verdicts the renderer
|
|
68
|
+
already wrote and (b) every distinct LANE_* token observed in real
|
|
69
|
+
`output/next-up/.verdict-*.json` envelopes through 2026-05-31.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
# --- deterministic, already code-emitted -------------------------------
|
|
73
|
+
LANE_DRAINED = "LANE_DRAINED" # 0 plans + 0 findings (true drain)
|
|
74
|
+
|
|
75
|
+
# --- the LANE_* family, formerly LLM-prose-only ------------------------
|
|
76
|
+
# Every remaining phase is soak-observation-gated with no dispatchable code
|
|
77
|
+
# follow-up (TM5 7d soak; AR7 blocked on AR5 soak denominator).
|
|
78
|
+
LANE_BLOCKED_ON_SOAK_GATED_PHASES = "LANE_BLOCKED_ON_SOAK_GATED_PHASES"
|
|
79
|
+
# A foreign, live /dispatch-loop holds this cluster's lane lease (racing it is
|
|
80
|
+
# the collision the lane arbiter exists to prevent). Carry the holder in `reason`.
|
|
81
|
+
LANE_LEASE_HELD_BY_LIVE_DISPATCH_LOOP = "LANE_LEASE_HELD_BY_LIVE_DISPATCH_LOOP"
|
|
82
|
+
# Remaining phases are all soft-claimed in-flight by a sibling packet, and/or
|
|
83
|
+
# deferred by the plan body's own gate.
|
|
84
|
+
LANE_ALL_INFLIGHT_OR_DEFERRED = "LANE_ALL_INFLIGHT_OR_DEFERRED"
|
|
85
|
+
# The lane's remaining phases are a mix of shipped-but-unstamped + in-flight +
|
|
86
|
+
# stale-stamped — the apply/tailor "everything's already done or drifting" shape.
|
|
87
|
+
LANE_ALL_SHIPPED_INFLIGHT_OR_STALE_STAMP = "LANE_ALL_SHIPPED_INFLIGHT_OR_STALE_STAMP"
|
|
88
|
+
# Generic "every remaining phase is blocked, or its stamp is drifted" — the
|
|
89
|
+
# catch-all the tailor lane wrote when soak + stamp-drift co-occur.
|
|
90
|
+
LANE_ALL_BLOCKED_OR_STALE_STAMP = "LANE_ALL_BLOCKED_OR_STALE_STAMP"
|
|
91
|
+
# The lane is blocked on an unanswered operator decision (e.g. CD #357), and the
|
|
92
|
+
# routing finding is already soft-claimed by a sibling. No automation clears it.
|
|
93
|
+
LANE_BLOCKED_ON_OPERATOR_DECISION = "LANE_BLOCKED_ON_OPERATOR_DECISION"
|
|
94
|
+
|
|
95
|
+
# --- producer-failure class (NOT a no-pick — picks EXIST) --------------
|
|
96
|
+
# The renderer rendered >= 1 pick but DROPPED the `.prompts.json` prompt
|
|
97
|
+
# sidecar (absent / corrupt / empty bodies), so the orchestrator has no
|
|
98
|
+
# worker prompt to launch. Unlike every LANE_* reason above (the picker
|
|
99
|
+
# decided not to pick), this is a *producer* defect: the picker picked, the
|
|
100
|
+
# renderer failed to serialize the bodies. FQ-419/FQ-420 — the root cause
|
|
101
|
+
# behind the recurring downstream `body_empty_picks` refuse (6+ consecutive
|
|
102
|
+
# dispatch runs wedged across apply/tailor/CD lanes, 2026-06-01). The
|
|
103
|
+
# producer-side verify (`dos.packet_sidecar.assert_packet_shippable`) emits
|
|
104
|
+
# this so the refusal points at the renderer, one rung above /fanout. Routes
|
|
105
|
+
# to /unstick (a structural renderer fix), not /replan (the backlog is fine).
|
|
106
|
+
RENDERER_SIDECAR_DROPPED = "RENDERER_SIDECAR_DROPPED"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Each WedgeReason → its NoPickCategory. Exhaustive over the enum (a test asserts
|
|
110
|
+
# completeness), so adding a member without categorising it fails CI rather than
|
|
111
|
+
# silently degrading to UNCLASSIFIED at runtime.
|
|
112
|
+
REASON_TO_CATEGORY: dict[WedgeReason, NoPickCategory] = {
|
|
113
|
+
WedgeReason.LANE_DRAINED: NoPickCategory.TRUE_DRAIN,
|
|
114
|
+
WedgeReason.LANE_BLOCKED_ON_SOAK_GATED_PHASES: NoPickCategory.OPERATOR_GATE,
|
|
115
|
+
WedgeReason.LANE_LEASE_HELD_BY_LIVE_DISPATCH_LOOP: NoPickCategory.LEASE_HELD,
|
|
116
|
+
WedgeReason.LANE_ALL_INFLIGHT_OR_DEFERRED: NoPickCategory.INFLIGHT,
|
|
117
|
+
WedgeReason.LANE_ALL_SHIPPED_INFLIGHT_OR_STALE_STAMP: NoPickCategory.INFLIGHT,
|
|
118
|
+
WedgeReason.LANE_ALL_BLOCKED_OR_STALE_STAMP: NoPickCategory.OPERATOR_GATE,
|
|
119
|
+
WedgeReason.LANE_BLOCKED_ON_OPERATOR_DECISION: NoPickCategory.OPERATOR_GATE,
|
|
120
|
+
WedgeReason.RENDERER_SIDECAR_DROPPED: NoPickCategory.RENDERER_BUG,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# A WEDGE carrying any of these reason classes means "do not render, route to
|
|
124
|
+
# /replan" — it is a refusal, not a deferred-but-valid packet. (All of them, today;
|
|
125
|
+
# the set is named explicitly so a future advisory-only reason class can be added
|
|
126
|
+
# without auto-refusing.)
|
|
127
|
+
REFUSE_REASONS: frozenset[WedgeReason] = frozenset(REASON_TO_CATEGORY)
|
|
128
|
+
|
|
129
|
+
_ALL_TOKENS: frozenset[str] = frozenset(r.value for r in WedgeReason)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Registry-aware helpers.
|
|
134
|
+
#
|
|
135
|
+
# The `WedgeReason` enum above is the BUILT-IN reason set, kept verbatim so this
|
|
136
|
+
# module stays byte-compatible (every `WedgeReason.X` reference, the lockstep
|
|
137
|
+
# test, the `REASON_TO_CATEGORY`/`REFUSE_REASONS` maps all unchanged). The four
|
|
138
|
+
# helpers below are the hackability seam: they answer for the built-in set first
|
|
139
|
+
# (fast, no I/O), then fall through to the ACTIVE WORKSPACE's `ReasonRegistry`
|
|
140
|
+
# (`dos.config.active().reasons`) so a workspace-DECLARED reason — one that is not
|
|
141
|
+
# a `WedgeReason` enum member — is still known / categorised / refusable through
|
|
142
|
+
# the exact same call. `coerce` still returns an enum member for a built-in token
|
|
143
|
+
# (callers that switch on `WedgeReason.X` are unaffected) and `None` for a
|
|
144
|
+
# registry-only token (use `category_for` / `is_refusal`, which understand both).
|
|
145
|
+
#
|
|
146
|
+
# `dos.config` is imported LAZILY inside each helper so this module keeps its
|
|
147
|
+
# leaf-import character (it does not pull `config` at import time — `picker_oracle`
|
|
148
|
+
# imports `wedge_reason` precisely because it is cheap), and so a process that
|
|
149
|
+
# never installs a custom registry pays nothing.
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _active_reasons():
|
|
154
|
+
"""The active workspace's ReasonRegistry, or None if config is unavailable.
|
|
155
|
+
|
|
156
|
+
Lazy + defensive: if `dos.config` cannot be imported or no config is active,
|
|
157
|
+
return None and the caller falls back to the built-in enum alone — the
|
|
158
|
+
helpers must never crash a consumer just because no workspace was installed.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
from dos import config as _config
|
|
162
|
+
return _config.active().reasons
|
|
163
|
+
except Exception:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_known_reason(token: str | None) -> bool:
|
|
168
|
+
"""True iff `token` is a known reason — a built-in `WedgeReason` member OR a
|
|
169
|
+
reason declared on the active workspace's `ReasonRegistry` (case-insensitive)."""
|
|
170
|
+
if not token:
|
|
171
|
+
return False
|
|
172
|
+
if token.strip().upper() in _ALL_TOKENS:
|
|
173
|
+
return True
|
|
174
|
+
reg = _active_reasons()
|
|
175
|
+
return bool(reg and reg.is_known(token))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def coerce(token: str | None) -> WedgeReason | None:
|
|
179
|
+
"""Return the built-in `WedgeReason` for `token`, or None.
|
|
180
|
+
|
|
181
|
+
Case-insensitive and whitespace-tolerant so a hand-authored envelope (during the
|
|
182
|
+
prose→code transition) still classifies. Returns None for a token that is not a
|
|
183
|
+
BUILT-IN member — including a workspace-declared registry-only reason; such a
|
|
184
|
+
token is real and known (`is_known_reason` is True, `category_for`/`is_refusal`
|
|
185
|
+
answer for it) but has no enum member, so callers that need the typed enum use
|
|
186
|
+
the built-in set while callers that need the verdict use the registry-aware
|
|
187
|
+
helpers below.
|
|
188
|
+
"""
|
|
189
|
+
if not token:
|
|
190
|
+
return None
|
|
191
|
+
try:
|
|
192
|
+
return WedgeReason(token.strip().upper())
|
|
193
|
+
except ValueError:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def category_for(token: str | None) -> NoPickCategory:
|
|
198
|
+
"""Map a `reason_class` token onto its NoPickCategory.
|
|
199
|
+
|
|
200
|
+
Built-in members resolve through `REASON_TO_CATEGORY`. A workspace-declared
|
|
201
|
+
reason resolves through the active `ReasonRegistry` (its category string is a
|
|
202
|
+
`NoPickCategory` value by construction). A token known to neither →
|
|
203
|
+
`UNCLASSIFIED` (forward-compatible: a brand-new label does not crash a
|
|
204
|
+
consumer, it classifies as drift until declared — and `--check` turns that
|
|
205
|
+
drift into a CI failure).
|
|
206
|
+
"""
|
|
207
|
+
reason = coerce(token)
|
|
208
|
+
if reason is not None:
|
|
209
|
+
return REASON_TO_CATEGORY[reason]
|
|
210
|
+
reg = _active_reasons()
|
|
211
|
+
if reg is not None:
|
|
212
|
+
cat = reg.category_for(token) # 'UNCLASSIFIED' for an unknown token
|
|
213
|
+
try:
|
|
214
|
+
return NoPickCategory(cat)
|
|
215
|
+
except ValueError:
|
|
216
|
+
return NoPickCategory.UNCLASSIFIED
|
|
217
|
+
return NoPickCategory.UNCLASSIFIED
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def is_refusal(token: str | None) -> bool:
|
|
221
|
+
"""True iff a verdict carrying `token` must NOT be rendered (route to /replan).
|
|
222
|
+
|
|
223
|
+
Used by `fanout_preflight_context` to refuse a packet whose `.verdict` envelope
|
|
224
|
+
was pre-routed WEDGE, independent of how many picks look live (FQ-410). A
|
|
225
|
+
built-in member honors `REFUSE_REASONS`; a workspace-declared reason honors its
|
|
226
|
+
own `refusal` flag (so a workspace CAN declare an advisory-only reason). Unknown
|
|
227
|
+
tokens are treated as refusals — a no-pick envelope with an unrecognised
|
|
228
|
+
reason_class is still a no-pick, and launching against it is the exact hazard.
|
|
229
|
+
"""
|
|
230
|
+
reason = coerce(token)
|
|
231
|
+
if reason is not None:
|
|
232
|
+
return reason in REFUSE_REASONS
|
|
233
|
+
reg = _active_reasons()
|
|
234
|
+
if reg is not None:
|
|
235
|
+
# The registry returns True for both a declared-refusal reason AND an
|
|
236
|
+
# unknown token (its own conservative default) — exactly the policy here.
|
|
237
|
+
return reg.is_refusal(token)
|
|
238
|
+
# No registry available — an unrecognised token is drift; refuse conservatively.
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# A verdict carrying one of these (or no verdict at all) is launchable; anything
|
|
243
|
+
# else (WEDGE / DRAIN / RACE / …) is a refusal shape. The single source of truth
|
|
244
|
+
# for the launchable set — `preflight` and `decisions` previously each kept their
|
|
245
|
+
# own identical copy of this frozenset (one edit from drift).
|
|
246
|
+
LAUNCHABLE_VERDICTS: frozenset[str] = frozenset({"", "LIVE", "ACCEPT"})
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def envelope_is_refusal(envelope: dict | None) -> tuple[bool, str | None]:
|
|
250
|
+
"""Decide whether a `.verdict-<tag>.json` ENVELOPE means REFUSE (do not launch).
|
|
251
|
+
|
|
252
|
+
The one canonical reader of an envelope's refusal shape — `preflight` (which
|
|
253
|
+
gates a packet launch) and `decisions` (which surfaces a no-pick as an operator
|
|
254
|
+
decision) both need exactly this judgement, and previously each carried its own
|
|
255
|
+
byte-identical copy flagged "kept in lockstep" (the drift hazard this collapses).
|
|
256
|
+
It belongs here beside `is_refusal(token)`: that answers for a bare reason
|
|
257
|
+
*token*, this answers for the whole *envelope* — the token check is one of the
|
|
258
|
+
four rungs below.
|
|
259
|
+
|
|
260
|
+
Refuses when the envelope is a no-pick / blocked shape, most-specific first:
|
|
261
|
+
* `do_not_render` truthy, or `blocked` truthy without `all_clear`;
|
|
262
|
+
* `verdict` is anything other than a launchable token (LIVE / ACCEPT / absent)
|
|
263
|
+
— i.e. WEDGE / DRAIN / RACE;
|
|
264
|
+
* `reason_class` is a known refusal token (`is_refusal`).
|
|
265
|
+
A LIVE-shaped envelope (`all_clear=true` and a launchable verdict) does NOT
|
|
266
|
+
refuse. Returns `(refuse, reason)` where reason is a short machine-readable tag
|
|
267
|
+
(or None when not a refusal), prefixed `verdict_envelope:` for log greppability.
|
|
268
|
+
"""
|
|
269
|
+
if not envelope:
|
|
270
|
+
return (False, None)
|
|
271
|
+
verdict = str(envelope.get("verdict") or "").strip().upper()
|
|
272
|
+
reason_class = envelope.get("reason_class")
|
|
273
|
+
all_clear = bool(envelope.get("all_clear"))
|
|
274
|
+
if envelope.get("do_not_render"):
|
|
275
|
+
return (True, f"verdict_envelope:do_not_render verdict={verdict or '?'}")
|
|
276
|
+
if envelope.get("blocked") and not all_clear:
|
|
277
|
+
return (True, f"verdict_envelope:blocked verdict={verdict or '?'}")
|
|
278
|
+
if verdict and verdict not in LAUNCHABLE_VERDICTS:
|
|
279
|
+
return (True, f"verdict_envelope:verdict={verdict}")
|
|
280
|
+
if reason_class is not None and is_refusal(str(reason_class)):
|
|
281
|
+
return (True, f"verdict_envelope:reason_class={reason_class}")
|
|
282
|
+
return (False, None)
|