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/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)