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/scout.py ADDED
@@ -0,0 +1,982 @@
1
+ """The pre-dispatch CHOOSER — the missing third kernel half of the loop spine.
2
+
3
+ The `dos` kernel already answers two of the three loop questions:
4
+
5
+ dos.health.check → "should I START?" (overlap + recurrence)
6
+ dos.loop_decide.decide → "should I CONTINUE?" (post-iteration stop)
7
+
8
+ `dos.scout.choose` answers the third — **"what should I start?"** — *before* a
9
+ lane is leased and before any child is launched. Today that question is answered
10
+ only by a hardcoded `iter-1 mode = dispatch` in the loop's SKILL; scout makes it
11
+ a first-class, typed, pure decision over the full activity menu.
12
+
13
+ WHY (the incident this generalizes): `dos.health` exists because a `/dispatch-loop`
14
+ burned ~$9/40min launching a `/dispatch` child to rediscover a blocker knowable at
15
+ second zero. But `health` runs *after* `dispatch-lane acquire` and only picks from
16
+ a 4-outcome menu (proceed/unstick/replan/overlap) — two of which are
17
+ "do-X-then-dispatch" detours. The operator's own override log (the `job` repo's
18
+ MEMORY feedback entries) is ~30 transcripts of a human, *before* leasing a lane,
19
+ reading the scoreboard / same-day-unstick-count / open-decision state and choosing
20
+ replan-vs-unstick-vs-dispatch. Scout is that human override, made a pure function.
21
+ It runs FIRST, with no lane held, over the full menu, and may route to a different
22
+ activity (unstick / replan / wait) instead of dispatching.
23
+
24
+ KEY INVARIANT (operator directive 2026-06-03): the chooser's job is to pick *what
25
+ to start* — so it **never self-STOPs on an analytic/portfolio signal**. A noisy 7d
26
+ scoreboard, an open (already-escalated) operator decision, and a saturated
27
+ `/unstick` are ROUTING signals, not halt signals: scout routes to a productive
28
+ activity for that context (dispatch the lane's other work; route away from a
29
+ looping `/unstick`) and surfaces the signal as evidence. The ONLY condition under
30
+ which the chooser refuses to launch anything is a measured can't-launch wall the
31
+ host hit (`ScoutState.resource_blocked` — RAM/slot pool exhausted, spawn cap, a
32
+ usage window truly spent). "Give up" is never the chooser's call to make on its own.
33
+
34
+ Design — this module is the **reference example** of the dos composition idiom
35
+ (the way `cat` was the first program written for Unix). It demonstrates how a
36
+ host-app composes kernel decisions:
37
+
38
+ * `choose(state)` is a **pure function**: a frozen `ScoutState` of facts in
39
+ (a pre-computed sibling `HealthVerdict`, pre-reduced scalar signals), one
40
+ typed `ScoutDecision` out. No I/O, no clock, no oracle/health call inside —
41
+ every time-derived signal is reduced to a scalar/bool/tuple by the caller at
42
+ the I/O edge (exactly as `liveness.classify(now_ms)` takes `now` at the edge,
43
+ never inside the pure core). So `choose` is replay-tested in isolation.
44
+ * The sibling `HealthVerdict` is carried as an **input field**
45
+ (`ScoutState.health`), not recomputed — the precise analogue of
46
+ `loop_decide.LoopState.liveness: Optional[Liveness]`. Per CLAUDE.md the litmus
47
+ is "no host, no I/O", NOT "no sibling import": scout READS a verdict value,
48
+ it never computes one. The host adapter runs `dos.health.check` once and hands
49
+ the verdict in. One git gather, one verdict; scout's pure boundary never
50
+ touches a `RunRecord`.
51
+ * `check(...)` is the thin in-kernel composition (kwargs → `ScoutState` →
52
+ `choose`), and `cmd_check` / `build_parser` are the CLI — the same three-tier
53
+ shape (`lane_health` / `check` / `cmd_check`) a `dos.health` reader already
54
+ knows.
55
+
56
+ Scout only **routes**; the chosen skill does the work. The moment it renders a
57
+ packet or reads a plan deeply it has reinvented `/next-up` — so there is no
58
+ `command` field here (rendering a shell command is a host concern), and the wider
59
+ menu lives in a bounded `_phase2_menu` annex so the live 9-rule spine stays
60
+ legible. This is the structural guard against scout becoming the very
61
+ "tail wagging the dog" a chooser-of-everything would be.
62
+
63
+ Spec / design record: the `job` repo's `docs/_design/dispatch-scout-concept.md`.
64
+ """
65
+ from __future__ import annotations
66
+
67
+ import argparse
68
+ import enum
69
+ import json
70
+ import sys
71
+ from dataclasses import dataclass, field
72
+ from typing import Optional
73
+
74
+ # Sibling-kernel TYPE import only. `choose()` reads a `HealthVerdict` value; it
75
+ # NEVER calls `lane_health`/`check` (those do git I/O — the caller runs them and
76
+ # hands the verdict in). This mirrors `loop_decide`'s `from dos.liveness import
77
+ # Liveness`. The litmus (CLAUDE.md) is "no host, no I/O", not "no sibling import".
78
+ from dos.health import HealthAction, HealthVerdict
79
+ # Sibling-kernel seam (TYPE + the floor-AND helper). `choose()` reads a resolved
80
+ # `StopPolicy` value off the state and runs it via `stop_under_resource_floor`
81
+ # (which enforces fail-to-DEFER + the resource_blocked floor) — it NEVER resolves
82
+ # a policy (that is the adapter's call-boundary job, `active_stop_policy`). One-way
83
+ # import: scout imports stop_policy, never the reverse (stop_policy reads the state
84
+ # by attribute to stay cycle-free). The litmus is "no host, no I/O" — a STOP policy
85
+ # VALUE is neither.
86
+ from dos.stop_policy import StopPolicy, stop_under_resource_floor
87
+
88
+
89
+ # ───────────────────────────── the menu (enums) ──────────────────────────────
90
+ class ScoutActivity(str, enum.Enum):
91
+ """The activity scout routes to. `str`-valued so it serializes to its value.
92
+
93
+ The Phase-1 LIVE set is the only thing `choose()` returns today; the
94
+ RESERVED slots ship in the schema now (so widening the menu in Phase 2 is a
95
+ no-schema-break change) but `choose()` never emits them until their
96
+ `_phase2_menu` rungs are wired.
97
+ """
98
+
99
+ # Phase-1 LIVE — the only values choose() returns today.
100
+ DISPATCH = "dispatch" # lane is healthy to start — dispatch the next pick
101
+ UNSTICK = "unstick" # fresh recurring structural blocker — /unstick first
102
+ REPLAN = "replan" # soak/data-gated or replan-due — /replan first
103
+ WAIT = "wait" # overlap / open soak — re-pick a disjoint lane or wait
104
+ STOP = "stop" # surface to operator; a loop can't resolve this
105
+
106
+ # Phase-2 RESERVED — enum slots ship now; rungs live in _phase2_menu.
107
+ NEXT_UP = "next-up"
108
+ JUDGE = "judge"
109
+ PLAN_AUDIT = "plan-audit"
110
+ TAIL_WAG = "tail-wag"
111
+ TRAJECTORY_AUDIT = "trajectory-audit"
112
+
113
+ def __str__(self) -> str: # so f"{activity}" is the value, not "ScoutActivity.STOP"
114
+ return self.value
115
+
116
+
117
+ class Confidence(str, enum.Enum):
118
+ """Auto-run gate axis. The boundary is REVERSIBILITY, not certainty-of-correctness
119
+ — verbatim the `JO_AUTO_ACCEPT` design: a HIGH decision is reversible/safe to
120
+ auto-run; a LOW decision is a judgement call that must SURFACE to the operator
121
+ (never auto-run). Actuation of the gate lives in the host adapter (a
122
+ `SCOUT_AUTO_RUN` flag parallel to `JO_AUTO_ACCEPT`); the kernel only LABELS."""
123
+
124
+ HIGH = "high"
125
+ LOW = "low"
126
+
127
+ def __str__(self) -> str:
128
+ return self.value
129
+
130
+
131
+ # ───────────────────────────── the output type ───────────────────────────────
132
+ @dataclass(frozen=True)
133
+ class ScoutDecision:
134
+ """The single typed output. `activity` is a *destination*, not a detour.
135
+
136
+ There is deliberately NO `command` field: rendering a shell command is a host
137
+ concern (the kernel must not know `/dispatch --scope X` syntax — that is the
138
+ god-skill guard, made structural). The adapter builds the command from
139
+ `activity` + `scope` + `focus` + `gate`.
140
+ """
141
+
142
+ activity: ScoutActivity
143
+ rule_id: int # 1..9 (concept §3 numbering) — the test anchor:
144
+ # which rung fired, not just which activity.
145
+ confidence: Confidence
146
+ reason: str # one line, operator-facing
147
+ evidence: tuple[str, ...] = field(default_factory=tuple) # dated/queryable artefacts
148
+ scope: Optional[str] = None # the lane the activity runs on
149
+ focus: Optional[str] = None # not-started|priority-first[:N]|nearly-done|stale-stamp|none
150
+ gate: Optional[str] = None # only meaningful when activity == DISPATCH
151
+ cause_key: str = "" # only when activity in {STOP, WAIT} — clusters the
152
+ # surfaced cause (mirrors HealthVerdict.cause_key)
153
+
154
+ @property
155
+ def needs_lane(self) -> bool:
156
+ """Activities that require a leased lane to run (the host acquires only
157
+ for these — the whole point of choosing BEFORE acquire)."""
158
+ return self.activity in (
159
+ ScoutActivity.DISPATCH, ScoutActivity.UNSTICK, ScoutActivity.NEXT_UP,
160
+ )
161
+
162
+ @property
163
+ def auto_runnable(self) -> bool:
164
+ """HIGH-confidence ⇒ a host with SCOUT_AUTO_RUN set MAY auto-run; LOW ⇒
165
+ always surface. The kernel only reports the bit; the host actuates."""
166
+ return self.confidence is Confidence.HIGH
167
+
168
+ def __post_init__(self) -> None:
169
+ if self.gate is not None and self.activity is not ScoutActivity.DISPATCH:
170
+ raise ValueError(
171
+ f"a {self.activity} decision must not carry a gate "
172
+ f"(only DISPATCH has a gate policy)"
173
+ )
174
+ if self.cause_key and self.activity not in (ScoutActivity.STOP, ScoutActivity.WAIT):
175
+ raise ValueError(
176
+ f"a {self.activity} decision must not carry a cause_key "
177
+ f"(only STOP/WAIT name a blocking cause)"
178
+ )
179
+
180
+
181
+ # ───────────────────────── reserved Phase-2 input slices ──────────────────────
182
+ @dataclass(frozen=True)
183
+ class ScoreboardShape:
184
+ """The typed slice of `SCOREBOARD.json` rule 8 reads — never a raw dict
185
+ (data-trust-floor: a decision stands on a named, typed artefact)."""
186
+
187
+ runs_7d: int = 0
188
+ zero_ship_7d: int = 0
189
+ false_drain_runs: int = 0
190
+ runs_shipped: int = 0
191
+
192
+ @property
193
+ def zero_ship_frac(self) -> float:
194
+ return (self.zero_ship_7d / self.runs_7d) if self.runs_7d else 0.0
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class WedgeSignal:
199
+ """Phase-2 — narrows `dos.recurring_wedge`'s verdict to the booleans scout
200
+ consumes (the same don't-recompute seam as `health`: read the verdict, don't
201
+ re-derive it)."""
202
+
203
+ recurring: bool = False
204
+ operator_decision: bool = False
205
+ cause_key: str = ""
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class ClosedLoopSignal:
210
+ """The lane's available CLOSED-LOOP work, as a typed slice (data-trust-floor:
211
+ a decision stands on a named, typed artefact, never a raw dict).
212
+
213
+ WHY this is a first-class scout input (operator directive 2026-06-04): the
214
+ highest-leverage work in a self-improving system is the kind that converts a
215
+ *recurring observation* into a *durable mechanism* — an oracle, a preflight,
216
+ a gate, a learned-answer promoter — so the same failure stops recurring
217
+ instead of being re-paid every run. The worked example: the apply learning
218
+ engine extracted the visa/degree screening-answer fix at conf=1.0 ~50× but
219
+ only ever fed it to the LLM prompt (an OPEN loop), so the field failed ~50×;
220
+ closing the loop (FQ-467: promote the lesson to a config write the
221
+ deterministic path reads) removes the failure class outright. Open-loop work
222
+ bleeds at a constant rate; closed-loop work pays down the rate.
223
+
224
+ Scout cannot itself know what "a closed-loop primitive" is (that is host
225
+ knowledge — which plans/findings are loop-closers, measured from plan-meta /
226
+ findings tags by the adapter at the I/O edge). The adapter reduces that to
227
+ this slice and hands it in; `choose()` only WEIGHTS it — biasing the
228
+ in-lane `focus` toward the loop-closing work when the lane is otherwise
229
+ clean to dispatch. It never manufactures a route on its own (a lane with
230
+ closed-loop work still has to pass every gate above), and it never STOPs.
231
+
232
+ Default `available=False` ⇒ the rung is inert ⇒ `choose()` is byte-identical
233
+ to before this field existed (the reserved-input convention).
234
+ """
235
+
236
+ available: bool = False # the lane has ≥1 pickable loop-closing item
237
+ count: int = 0 # how many (lets the magnitude ride in evidence)
238
+ top_item: str = "" # the highest-value loop-closer (operator-facing)
239
+
240
+ @property
241
+ def is_actionable(self) -> bool:
242
+ return self.available and self.count > 0
243
+
244
+
245
+ @dataclass(frozen=True)
246
+ class LaneOutcomeShape:
247
+ """THIS lane's recent decision→outcome history, as a typed slice (data-trust-
248
+ floor: a decision stands on a named, typed artefact, never a raw dict).
249
+
250
+ WHY this is a first-class scout input (operator directive 2026-06-09, the
251
+ "10x self-improving loop" goal): the fleet was richly INSTRUMENTED but not
252
+ feedback-RESPONSIVE — it recorded every iteration's outcome but never read it
253
+ back to ROUTE differently. This slice closes that gap for the per-lane case:
254
+ the adapter reduces the decision→outcome ledger (`decision_outcomes.jsonl`) to
255
+ "over the last K runs on THIS lane, how often did it actually ship", and the
256
+ chooser uses it to PRE-EMPT — a lane that keeps shipping nothing *but has
257
+ pickable work* gets a /replan (re-prioritize its backlog) BEFORE another heavy
258
+ non-shipping child, instead of discovering the unproductivity inside repeated
259
+ $10+ dispatches.
260
+
261
+ Distinct from `LaneYield` (instantaneous confirmed-empty pick-yield → refill)
262
+ and from `ScoreboardShape` (fleet-wide 7d zero-ship → noise, never a route):
263
+ this is HISTORICAL per-lane ship-rate ("there IS work but it never ships →
264
+ re-prioritize"). Like every analytic input it ROUTES, never STOPs (the
265
+ 2026-06-03 STOP-discipline: only a measured resource wall halts).
266
+
267
+ Default `window=0` ⇒ `is_actionable` False ⇒ the rung is inert ⇒ `choose()` is
268
+ byte-identical to before this field existed (the reserved-input convention).
269
+ """
270
+
271
+ window: int = 0 # K runs considered (0 ⇒ no data ⇒ inert)
272
+ runs: int = 0 # rows in window for this lane
273
+ shipped_runs: int = 0 # rows with verdict SHIPPED
274
+ drained_runs: int = 0 # rows with verdict DRAIN
275
+ blocked_runs: int = 0 # rows with verdict BLOCK
276
+ top_blocked_cause: str = "" # modal blocked_cause_key (operator-facing)
277
+
278
+ @property
279
+ def ship_rate(self) -> float:
280
+ return (self.shipped_runs / self.runs) if self.runs else 0.0
281
+
282
+ # Minimum real runs needed to trust the per-lane rate (a floor, NOT the
283
+ # requested window): a 0-ship signal over 3 runs is already actionable; we do
284
+ # not demand the full lookback was available (a lane that has only run K<window
285
+ # times still has a real, weighable history).
286
+ _MIN_RUNS = 3
287
+
288
+ @property
289
+ def is_actionable(self) -> bool:
290
+ # enough signal to weigh: at least _MIN_RUNS real runs in the slice. (The
291
+ # `window` is the lookback the adapter REQUESTED; what matters here is how
292
+ # many runs it actually FOUND — `runs` — so a lane with fewer runs than the
293
+ # requested window is still actionable once it clears the floor.)
294
+ return self.runs >= self._MIN_RUNS
295
+
296
+
297
+ # ───────────────────────────── the input type ────────────────────────────────
298
+ @dataclass(frozen=True)
299
+ class ScoutState:
300
+ """Every pure signal `choose()` needs, as a field. Nothing is gathered inside
301
+ `choose` — the host adapter does all I/O and reduces each time-derived signal
302
+ to a scalar/bool/tuple here (so `choose` is timeless and the override-log
303
+ acceptance tests are hermetic one-liners).
304
+
305
+ The Phase-1 LIVE signals are `health` + the two named new signals
306
+ (`same_day_unstick_count`/`fixes_landing_since_unstick`, plus
307
+ `operator_owned_cause`). The rest are present-but-dormant: their defaults
308
+ (empty tuple / None / False) make their rungs inert, so Phase 2 wires them in
309
+ with ZERO schema change.
310
+ """
311
+
312
+ # request context (constant for this choice)
313
+ scope: Optional[str] = None
314
+ focus: str = "not-started"
315
+ gate: str = "hard" # hard|soft|drive
316
+
317
+ # THE ONE input that lets choose() emit STOP. Scout NEVER self-STOPs on an
318
+ # analytic/portfolio signal (a noisy scoreboard, an open decision, a saturated
319
+ # /unstick) — those are ROUTING signals, handled by re-routing to a productive
320
+ # activity for that context (see choose()). The only legitimate reason for the
321
+ # CHOOSER to refuse to launch anything is that the host genuinely *cannot*
322
+ # launch more right now — a hard resource/capacity wall the adapter measured
323
+ # (RAM/slot pool exhausted, the spawn cap hit, a usage window truly spent).
324
+ # Default False ⇒ choose() never STOPs in normal operation. The adapter sets
325
+ # this ONLY from a measured can't-launch condition, never from history shape.
326
+ resource_blocked: bool = False
327
+ resource_block_reason: str = "" # operator-facing, when resource_blocked
328
+
329
+ # The pluggable loop-STOP seam (`dos.stop_policy`). OPTIONAL: when None, the
330
+ # scout skips the STOP-policy rung entirely and behaves byte-identically to
331
+ # before the seam (an open decision is evidence-only — the 2026-06-03 default).
332
+ # An adapter that wants a host to be able to turn a decision class into a real
333
+ # halt resolves a policy at the call boundary (`active_stop_policy`) and passes
334
+ # it here; `choose` runs it via `stop_under_resource_floor`, so a host policy
335
+ # can only ADD a STOP on top of the `resource_blocked` floor, never remove it,
336
+ # and a failing policy degrades to DEFER (never a spurious halt). Mechanism is
337
+ # the kernel's; whether to stop on THIS decision is the host's choice.
338
+ stop_policy: Optional[StopPolicy] = None
339
+
340
+ # THE dos.health reuse seam (input-field): the adapter ran dos.health.check
341
+ # and put the verdict here. Optional only for the degenerate-adapter
342
+ # fail-safe (a None verdict ⇒ PROCEED, matching health's git-fail→[]→proceed).
343
+ health: Optional[HealthVerdict] = None
344
+
345
+ # escalated + unanswered operator decision IDs, e.g. ("362",). A SURFACED note
346
+ # on the dispatch decision — NOT a STOP. The decision is already escalated (the
347
+ # JO path carried it to the operator); halting the whole loop on it is the
348
+ # over-reach the operator called out. Scout dispatches the lane's other work and
349
+ # names the open decision in evidence.
350
+ open_escalated_decisions: tuple[str, ...] = field(default_factory=tuple)
351
+
352
+ # the /unstick-saturation signals. These no longer manufacture a STOP — a
353
+ # saturated /unstick is a ROUTING signal: when sweeps are not landing, scout
354
+ # routes AWAY from /unstick (to dispatch), because the doom-loop was *caused by*
355
+ # re-routing to UNSTICK. "Route for that specific context" = break the unstick
356
+ # loop by doing real work, never halt. They gate rule 4 (suppress a fresh
357
+ # /unstick once saturated) and ride along in evidence.
358
+ operator_owned_cause: bool = False # set from recurring_wedge.operator_decision
359
+ # OR a real decision-ownership check — NOT a
360
+ # brittle 'operator' substring of cause_key.
361
+ same_day_unstick_count: int = 0
362
+ latest_unstick_stamp: str = ""
363
+ fixes_landing_since_unstick: bool = True # default True = conservative: absent
364
+ # evidence never suppresses a fresh /unstick.
365
+
366
+ # rules 6/7
367
+ open_soaks: tuple[str, ...] = field(default_factory=tuple)
368
+ replan_due: bool = False
369
+ replan_desc: str = ""
370
+
371
+ # ── replan-redundancy signals (route-forward, never a STOP) ───────────────
372
+ # A `/replan` runs UNSCOPED (portfolio-wide) and so holds NO lane lease — the
373
+ # lease registry the health gate reads cannot see one in flight. Two of these
374
+ # gardening the same global docs/_plans/ tree at once is pure waste: the second
375
+ # races the first on plans.yaml / findings-queue / execution-state and (per the
376
+ # job repo's replan-state.yaml field notes) ends up DEFERRING its own unsafe
377
+ # writes "due to concurrent fleet". So scout must not route a redundant /replan.
378
+ # Per the 2026-06-03 directive this is a ROUTING signal, not a STOP: when a
379
+ # replan is redundant, rule 7 falls through to DISPATCH (rule 9) carrying the
380
+ # signal as surfaced evidence — do real lane work instead of a duplicate sweep.
381
+ #
382
+ # `replan_in_flight`: a /replan child is running RIGHT NOW (measured by the
383
+ # adapter — scan live chained-run/loop dirs for an active replan). Suppresses
384
+ # ALL replan routing, including a health ROUTE_REPLAN: a concurrent sweep will
385
+ # garden whatever the lane needs; a second one only collides.
386
+ # `recent_replan_unproductive`: a /replan finished very recently and produced
387
+ # nothing (0 closed / 0 surfaced — the zero-surface-streak shape). Suppresses
388
+ # ONLY the cooldown arm (`replan_due`), NOT a fresh health ROUTE_REPLAN: if the
389
+ # lane is soak/data-gated right now, a recent empty sweep doesn't change that
390
+ # dispatch still can't proceed — but it DOES mean "≥3 fanouts elapsed" alone is
391
+ # not a reason to re-sweep a portfolio a near-identical sweep just gardened.
392
+ # `recent_replan_ran`: a /replan finished very recently REGARDLESS of yield
393
+ # (productive OR empty). The cooldown arm (`replan_due`) keys on
394
+ # `fanouts_since_last_run >= 3`, and that counter does NOT reset when a /replan
395
+ # runs — it tracks fanout activity, not sweeps. So a replan that JUST gardened
396
+ # the portfolio still leaves the cooldown armed, and the cooldown arm re-routes
397
+ # /replan forever (`last_run=0h ago` but `fanouts_since=3`) — the first-iter-
398
+ # replan re-route loop that BLOCKED a real loop (job-repo loop 20260606T170657Z
399
+ # "cooldown never reset"). `recent_replan_unproductive` only caught the EMPTY
400
+ # case; a PRODUCTIVE recent sweep (added >=1) slipped through. This is the
401
+ # superset signal: a recent sweep of EITHER yield vetoes ONLY the cooldown arm
402
+ # (same scope as the unproductive veto — a live health ROUTE_REPLAN is never
403
+ # suppressed: if the lane is gated NOW a recent sweep can't change that). The
404
+ # fix for the loop is "a sweep already ran this cooldown window → dispatch the
405
+ # refilled work, don't re-sweep purely because the fanout counter is still high".
406
+ replan_in_flight: bool = False
407
+ recent_replan_unproductive: bool = False
408
+ recent_replan_ran: bool = False
409
+ recent_replan_desc: str = "" # operator-facing, when either fires
410
+
411
+ # scoreboard 7d shape — a fleet-level ANALYTIC artefact. Surfaced as evidence on
412
+ # the dispatch decision; NEVER a STOP (a noisy rolling scoreboard says nothing
413
+ # about whether THIS lane has clean isolated work right now). Typed slice, never
414
+ # a raw dict (data-trust-floor).
415
+ scoreboard: Optional[ScoreboardShape] = None
416
+
417
+ # PHASE-2 RESERVED inputs (None in P1 ⇒ their menu rungs never fire ⇒
418
+ # byte-identical to P1).
419
+ recurring_wedge: Optional[WedgeSignal] = None # → rule-2 operator_owned + future JUDGE
420
+ class_drift: Optional[bool] = None # → JUDGE
421
+ trajectory_pathology: Optional[bool] = None # → TRAJECTORY_AUDIT
422
+ plan_surface_drift: Optional[bool] = None # → PLAN_AUDIT
423
+ inverted_priority: Optional[bool] = None # → TAIL_WAG
424
+
425
+ # CLOSED-LOOP weighting (operator directive 2026-06-04): when the lane has
426
+ # pickable loop-closing work (convert-observation-into-mechanism — an oracle,
427
+ # a gate, a learned-answer promoter), bias the in-lane `focus` toward it on
428
+ # the rule-9 DISPATCH terminal. Reserved-input convention: default None ⇒ the
429
+ # bias never fires ⇒ byte-identical to before. The adapter measures which
430
+ # pickable items are loop-closers (from plan-meta / findings tags) and hands
431
+ # this slice in; the kernel only weights it (it never decides what counts as
432
+ # closed-loop, and a lane with closed-loop work still passes every gate above).
433
+ closed_loop: Optional[ClosedLoopSignal] = None # → rule-9 focus bias
434
+
435
+ # OUTCOME-DRIVEN ROUTING (operator directive 2026-06-09, the 10x self-improving
436
+ # loop goal): this lane's recent ship history. When the lane has shipped 0 of
437
+ # its last K runs BUT still has pickable work, pre-empt with /replan (re-
438
+ # prioritize the backlog) instead of dispatching another non-shipping child —
439
+ # moving the unproductivity discovery from inside repeated $10+ children to a
440
+ # $0 pre-acquire read. Reserved-input convention: default None ⇒ the rung is
441
+ # inert ⇒ byte-identical to before. ROUTES, never STOPs (a lane that keeps
442
+ # shipping nothing is a routing signal; if /replan also yields nothing the
443
+ # existing drained-twice machinery stops the loop). The adapter reduces the
444
+ # decision→outcome ledger to this per-lane slice; the kernel only routes on it.
445
+ lane_outcome: Optional[LaneOutcomeShape] = None # → rule-10 replan pre-empt
446
+
447
+ @property
448
+ def health_action(self) -> HealthAction:
449
+ """The sibling verdict's action, or PROCEED if no verdict was supplied
450
+ (the degenerate-adapter fail-safe — mirrors health's own
451
+ git-failure→proceed direction: a missing input never invents a route)."""
452
+ return self.health.action if self.health is not None else HealthAction.PROCEED
453
+
454
+ def __post_init__(self) -> None:
455
+ if self.gate not in ("hard", "soft", "drive"):
456
+ raise ValueError(f"unknown gate {self.gate!r} — expected hard|soft|drive")
457
+
458
+
459
+ # ───────────────────────────── the chooser ───────────────────────────────────
460
+ def choose(state: ScoutState) -> ScoutDecision:
461
+ """Choose the next activity. PURE — no I/O, no clock, no oracle/health call.
462
+
463
+ DESIGN INVARIANT (operator directive 2026-06-03): **scout NEVER self-STOPs on an
464
+ analytic/portfolio signal.** A noisy 7d scoreboard, an open operator decision,
465
+ and a saturated `/unstick` are all ROUTING signals, not halt signals — the
466
+ chooser's job is to pick *what to start*, so when a context looks bad it routes
467
+ to a *productive* activity for that context, it does not give up. The ONLY thing
468
+ that makes the chooser refuse to launch anything is a measured can't-launch wall
469
+ the host hit (`state.resource_blocked` — RAM/slot pool exhausted, spawn cap, a
470
+ usage window truly spent). Everything else routes forward.
471
+
472
+ What this means for the conditions that USED to STOP:
473
+ * scoreboard zero-ship storm → it's a fleet-stats artefact; surface it as
474
+ evidence on the DISPATCH decision, never block (the storm says nothing about
475
+ whether THIS lane has clean isolated work right now — that's the very thing
476
+ the operator pointed out).
477
+ * open escalated operator decision → already escalated to the operator; scout
478
+ DISPATCHes the lane's other work and names the decision in evidence. Halting
479
+ the whole loop on a decision that's already in the operator's queue is the
480
+ over-reach.
481
+ * `/unstick` saturated / not landing (the doom-loop) → route AWAY from
482
+ `/unstick` (suppress the fresh-unstick rung) and DISPATCH instead. The
483
+ doom-loop was *caused by* re-routing to UNSTICK; the fix is to break the
484
+ loop by doing real work, not to halt.
485
+
486
+ Decision order (first-match-wins; `rule_id` pins which rung fired):
487
+
488
+ 0. resource_blocked (measured can't-launch) → STOP (the ONLY STOP)
489
+ 4. route_unstick (fresh, NOT saturated) → UNSTICK
490
+ 5. overlap_block → WAIT (re-pick disjoint)
491
+ 6. route_replan AND soaks open → WAIT (replan no-ops vs soak)
492
+ 7. (route_replan OR replan-due) AND NOT redundant → REPLAN
493
+ (redundant = a /replan is in flight, or one just ran empty — then fall
494
+ through to DISPATCH; in-flight vetoes both replan arms, recently-empty
495
+ vetoes only the cooldown arm)
496
+ [Phase-2 menu annex]
497
+ 9. default → DISPATCH
498
+ (open-decision / scoreboard-storm / unstick-saturation / vetoed-replan all
499
+ funnel here, carried as surfaced evidence — they inform the operator, they
500
+ don't block)
501
+
502
+ The rule_id numbers keep their concept-doc §3 identities (0 is the new
503
+ resource-STOP rung; the old STOP rungs 1/2/3/8 are RETIRED as STOP triggers and
504
+ fold into rung 9's evidence) so a test pins exactly which rung fired.
505
+ """
506
+ h = state.health
507
+ ha = state.health_action
508
+ foc = state.focus or "not-started"
509
+ sb = state.scoreboard
510
+
511
+ # ── 0. THE ONLY STOP: the host measured a can't-launch wall ───────────────
512
+ # Resource/capacity exhaustion (RAM/slot pool, spawn cap, spent usage
513
+ # window). This is the *sole* condition under which the CHOOSER refuses to
514
+ # launch anything — every analytic/portfolio signal below routes forward
515
+ # instead. confidence=HIGH (a measured wall is not a judgement call) but
516
+ # needs_lane is False (STOP never leases).
517
+ if state.resource_blocked:
518
+ why = state.resource_block_reason or "host cannot launch more work right now"
519
+ return ScoutDecision(
520
+ activity=ScoutActivity.STOP, rule_id=0, confidence=Confidence.HIGH,
521
+ scope=state.scope, focus=foc,
522
+ cause_key="resource_blocked",
523
+ reason=(f"can't launch more — {why}. The only condition under which the "
524
+ f"scout halts; wait for capacity to free, then re-run."),
525
+ evidence=(f"resource_blocked: {why}",),
526
+ )
527
+
528
+ # ── 1. the host's pluggable STOP policy (opt-in; under the resource floor) ──
529
+ # The seam that makes "stop-on-this-decision" a host CHOICE, not a kernel
530
+ # constant. Skipped entirely when no policy is wired (the default) — so the
531
+ # reference host's behavior is unchanged (an open decision is evidence-only,
532
+ # rule 9 below). When a policy IS wired, it is run through
533
+ # `stop_under_resource_floor`, which (a) cannot dilute the rule-0 floor (we
534
+ # already returned above if resource_blocked) and (b) fail-DEFERs a raising/
535
+ # bad-return policy, so it can only ADD a halt, never manufacture or suppress
536
+ # one. confidence rides from the verdict's own signal: a policy reads live
537
+ # state, so a HIGH stop auto-halts while a non-HIGH one is surfaced via the
538
+ # adapter's `confidence: low → AskUserQuestion` path (the STOP-discipline).
539
+ if state.stop_policy is not None:
540
+ sv = stop_under_resource_floor(state.stop_policy, state, config=None)
541
+ if sv.should_stop:
542
+ return ScoutDecision(
543
+ activity=ScoutActivity.STOP, rule_id=1, confidence=Confidence.HIGH,
544
+ scope=state.scope, focus=foc,
545
+ cause_key=sv.cause_key or "stop_policy",
546
+ reason=(sv.reason or "host STOP policy halted the loop on a pending "
547
+ "decision (configured to halt on this class)."),
548
+ evidence=sv.evidence or ("stop_policy",),
549
+ )
550
+
551
+ # Is /unstick saturated? (the former rule-2/rule-3 doom-loop conditions). When
552
+ # true we DO NOT route to a fresh /unstick (rule 4) — re-unsticking is exactly
553
+ # what caused the doom-loop. Instead we fall through to DISPATCH and carry the
554
+ # saturation as surfaced evidence. The operator's "route for that specific
555
+ # context" = break the unstick loop by doing real work.
556
+ unstick_op_owned_saturated = (
557
+ ha is HealthAction.ROUTE_UNSTICK and state.same_day_unstick_count >= 2
558
+ and (state.operator_owned_cause or (h is not None and "operator" in h.cause_key))
559
+ )
560
+ unstick_doomloop_saturated = (
561
+ ha is HealthAction.ROUTE_UNSTICK and state.same_day_unstick_count >= 3
562
+ and not state.fixes_landing_since_unstick
563
+ )
564
+ unstick_saturated = unstick_op_owned_saturated or unstick_doomloop_saturated
565
+
566
+ # ── 4. route_unstick on a FRESH recurring cause → /unstick first ──────────
567
+ # Suppressed once saturated (above) — a saturated cause funnels to DISPATCH.
568
+ if ha is HealthAction.ROUTE_UNSTICK and not unstick_saturated:
569
+ return ScoutDecision(
570
+ activity=ScoutActivity.UNSTICK, rule_id=4, confidence=Confidence.HIGH,
571
+ scope=state.scope, focus=foc,
572
+ reason=(f"recurring structural blocker on {(h.cause_key if h else '')!r} "
573
+ f"({(h.reason[:120] if h else '')}). First sweep on this cause is "
574
+ f"worth it before launching a child."),
575
+ evidence=(tuple(h.evidence[:4]) if h and h.evidence
576
+ else ((h.cause_key,) if h and h.cause_key else ())),
577
+ )
578
+
579
+ # ── 5. overlap_block → re-pick a disjoint lane (wait) ─────────────────────
580
+ if ha is HealthAction.OVERLAP_BLOCK:
581
+ ol = h.overlap_lane if h else ""
582
+ return ScoutDecision(
583
+ activity=ScoutActivity.WAIT, rule_id=5, confidence=Confidence.HIGH,
584
+ scope=state.scope, focus=foc,
585
+ cause_key="lane_overlap_collision",
586
+ reason=(f"granted lane's tree overlaps a live foreign lease "
587
+ f"{ol!r} — {(h.reason[:140] if h else '')}. Re-pick a disjoint "
588
+ f"lane or wait for the foreign lease to release."),
589
+ evidence=(tuple(h.evidence[:4]) if h and h.evidence
590
+ else ((f"overlap:{ol}",) if ol else ("overlap",))),
591
+ )
592
+
593
+ # ── 6. route_replan but soaks are open → replan no-ops; wait ──────────────
594
+ if ha is HealthAction.ROUTE_REPLAN and state.open_soaks:
595
+ soaks = state.open_soaks[:3]
596
+ return ScoutDecision(
597
+ activity=ScoutActivity.WAIT, rule_id=6, confidence=Confidence.HIGH,
598
+ scope=state.scope, focus=foc,
599
+ cause_key="lane_soak_or_data_gated",
600
+ reason=(f"lane is soak/data-gated and {len(state.open_soaks)} soak "
601
+ f"window(s) are open ({', '.join(soaks)}) — /replan gardens "
602
+ f"nothing a soak gate cares about; the window closes on time. Wait."),
603
+ evidence=tuple(f"open-soak:{s}" for s in soaks),
604
+ )
605
+
606
+ # ── 7. route_replan, or replan otherwise due by cooldown → /replan ────────
607
+ # UNLESS the replan would be redundant (a concurrent sweep is in flight, or
608
+ # one already ran this cooldown window) — then fall through to DISPATCH (rule
609
+ # 9), carrying the redundancy as surfaced evidence. A /replan holds no lane
610
+ # lease, so the only way scout learns "another sweep is happening / already
611
+ # happened" is these adapter-measured signals; routing forward (not halting)
612
+ # is the 2026-06-03 directive. `replan_in_flight` vetoes BOTH replan arms;
613
+ # `recent_replan_ran` and `recent_replan_unproductive` veto ONLY the cooldown
614
+ # arm (a live health ROUTE_REPLAN means the lane is gated NOW — a recent sweep
615
+ # can't clear that, so it still routes to /replan).
616
+ #
617
+ # The cooldown-arm veto is `recent_replan_ran OR recent_replan_unproductive`:
618
+ # the cooldown keys on `fanouts_since_last_run >= 3`, a counter that does NOT
619
+ # reset when a /replan runs, so a sweep that JUST gardened the portfolio (of
620
+ # EITHER yield) leaves the cooldown armed and the arm re-routes /replan forever
621
+ # — the first-iter-replan re-route loop. `recent_replan_unproductive` only
622
+ # caught the empty case; `recent_replan_ran` is the superset that also catches
623
+ # a PRODUCTIVE recent sweep (the case that BLOCKED a real loop). Either ⇒ the
624
+ # cooldown arm is spent for this window → DISPATCH the refilled work instead of
625
+ # re-sweeping purely because the fanout counter is still high. (`recent_replan_
626
+ # unproductive` is kept in the OR for back-compat: an adapter that sets only it
627
+ # — never the new superset — still vetoes, so no regression.)
628
+ health_routed_replan = ha is HealthAction.ROUTE_REPLAN
629
+ cooldown_replan = state.replan_due and not health_routed_replan
630
+ recent_replan_this_window = (
631
+ state.recent_replan_ran or state.recent_replan_unproductive)
632
+ replan_vetoed = state.replan_in_flight or (
633
+ cooldown_replan and recent_replan_this_window)
634
+ if (health_routed_replan or state.replan_due) and not replan_vetoed:
635
+ why = ("health gate route_replan" if health_routed_replan
636
+ else f"replan cooldown elapsed ({state.replan_desc})")
637
+ return ScoutDecision(
638
+ activity=ScoutActivity.REPLAN, rule_id=7, confidence=Confidence.HIGH,
639
+ scope=None, focus=None, # /replan is portfolio-wide
640
+ reason=f"{why} — sweep findings + garden the portfolio before more dispatch.",
641
+ evidence=(state.replan_desc or "replan_due",
642
+ f"health={ha.value}"),
643
+ )
644
+
645
+ # ── 10. OUTCOME-DRIVEN pre-empt (route, never STOP) ───────────────────────
646
+ # A lane that has shipped 0 of its last K runs BUT still has pickable work
647
+ # is not empty (rung 8'/yield handles empty) and not a fleet-stats artefact
648
+ # (the scoreboard funnel handles that, never blocking) — it has work that
649
+ # keeps NOT shipping. The lever is /replan (re-prioritize THIS lane's
650
+ # backlog) BEFORE dispatching yet another heavy non-shipping child. This is
651
+ # a ROUTING change, never a STOP: it picks a different activity, and if
652
+ # /replan also yields nothing the existing drained-twice machinery stops the
653
+ # loop. Honors the 2026-06-03 STOP-discipline (an analytic signal routes,
654
+ # never halts). Inert when the slice is absent/insufficient (window=0 ⇒
655
+ # is_actionable False ⇒ falls through ⇒ byte-identical to before).
656
+ lo = state.lane_outcome
657
+ if (lo is not None and lo.is_actionable
658
+ and lo.shipped_runs == 0 and lo.drained_runs < lo.runs):
659
+ return ScoutDecision(
660
+ activity=ScoutActivity.REPLAN, rule_id=10, confidence=Confidence.HIGH,
661
+ scope=None, focus=None, # /replan is portfolio-wide (matches rung 7)
662
+ reason=(f"lane {state.scope!r} shipped 0 of its last {lo.runs} runs "
663
+ f"(top block: {lo.top_blocked_cause or 'none'}) yet has pickable "
664
+ "work — re-prioritize the backlog before dispatching another "
665
+ "non-shipping child."),
666
+ evidence=(f"lane_ship_rate=0/{lo.runs}",
667
+ f"top_blocked_cause={lo.top_blocked_cause or 'none'}"),
668
+ )
669
+
670
+ # ── Phase-2 menu annex (returns None in Phase 1 → falls through to rule 9) ─
671
+ p2 = _phase2_menu(state, foc)
672
+ if p2 is not None:
673
+ return p2
674
+
675
+ # ── 9. default: DISPATCH — and the funnel for every retired-STOP condition ─
676
+ # open-decision / scoreboard-storm / unstick-saturation all land here. They
677
+ # are SURFACED (named in evidence + reason so the operator sees them) but
678
+ # they do NOT block — the chooser's answer to "what should I start" is "the
679
+ # next pick", with these as context.
680
+ notes: list[str] = []
681
+ ev: list[str] = [f"health={ha.value}", f"replan_due={state.replan_due}"]
682
+ # A replan was vetoed above (in-flight, or one already ran this cooldown window)
683
+ # and we funneled here: name it so the operator sees DISPATCH was chosen
684
+ # *instead of* a duplicate/loop sweep.
685
+ if (state.replan_in_flight or state.recent_replan_ran
686
+ or state.recent_replan_unproductive):
687
+ if state.replan_in_flight:
688
+ notes.append(f"a /replan is already in flight"
689
+ f"{f' ({state.recent_replan_desc})' if state.recent_replan_desc else ''}"
690
+ f" — a concurrent sweep gardens the portfolio; dispatching "
691
+ f"the lane's work instead of racing a second /replan")
692
+ ev.append("replan_in_flight=1")
693
+ elif state.recent_replan_unproductive:
694
+ notes.append(f"a /replan ran recently to no effect"
695
+ f"{f' ({state.recent_replan_desc})' if state.recent_replan_desc else ''}"
696
+ f" — cooldown (≥3 fanouts) alone is not a reason to re-sweep "
697
+ f"a portfolio a near-identical sweep just gardened; dispatching")
698
+ ev.append("recent_replan_unproductive=1")
699
+ elif state.recent_replan_ran:
700
+ # The productive-recent-sweep case: a /replan already ran this cooldown
701
+ # window and refilled the backlog, but `fanouts_since_last_run` does not
702
+ # reset on a sweep so the cooldown stayed armed. Routing /replan again
703
+ # would re-enter the first-iter-replan loop; dispatch the refilled work.
704
+ notes.append(f"a /replan already ran this cooldown window"
705
+ f"{f' ({state.recent_replan_desc})' if state.recent_replan_desc else ''}"
706
+ f" — the fanout counter (≥3) does not reset on a sweep, so the "
707
+ f"cooldown stayed armed after a sweep already gardened the "
708
+ f"portfolio; dispatching the refilled work, not re-sweeping")
709
+ ev.append("recent_replan_ran=1")
710
+ if state.open_escalated_decisions:
711
+ ids = state.open_escalated_decisions[:4]
712
+ notes.append(f"{len(state.open_escalated_decisions)} open operator "
713
+ f"decision(s) (#{', #'.join(ids)}) — already escalated; "
714
+ f"dispatching the lane's other work")
715
+ ev.extend(f"open-decision:#{d}" for d in ids)
716
+ if unstick_saturated:
717
+ notes.append(f"/unstick saturated ({state.same_day_unstick_count}× today, "
718
+ f"last {state.latest_unstick_stamp}"
719
+ f"{', not landing' if unstick_doomloop_saturated else ''}) — "
720
+ f"routing away from re-unsticking, dispatching instead")
721
+ ev.append(f"unstick_saturated={state.same_day_unstick_count}")
722
+ if sb is not None and sb.runs_7d >= 20 and sb.zero_ship_frac >= 0.75:
723
+ notes.append(f"7d scoreboard {sb.zero_ship_7d}:{sb.runs_7d} "
724
+ f"({sb.zero_ship_frac * 100:.0f}% zero-ship, "
725
+ f"{sb.false_drain_runs} false-drains) — fleet-stats noise, not "
726
+ f"a per-lane signal; if it persists the lever is "
727
+ f"concurrency / lane allocation")
728
+ ev.append(f"scoreboard.7d={sb.zero_ship_7d}:{sb.runs_7d}")
729
+ else:
730
+ ev.append(f"scoreboard.7d={sb.zero_ship_7d}:{sb.runs_7d}" if sb else "scoreboard=none")
731
+
732
+ # ── closed-loop focus bias (operator directive 2026-06-04) ────────────────
733
+ # When the lane has pickable loop-closing work, prefer it WITHIN the lane:
734
+ # promote `focus` to "closed-loop-first" and surface why. Closed-loop work
735
+ # (convert-observation-into-mechanism) pays down the failure rate, where
736
+ # open-loop work re-pays it every run — so given a clean lane, it is the
737
+ # higher-leverage pick. Guard: a `stale-stamp` focus is a correctness fix
738
+ # that must not be displaced (a stale soft-claim blocks picks), so it wins;
739
+ # every other default focus yields to closed-loop. The bias only re-orders
740
+ # work WITHIN an already-chosen DISPATCH — it never manufactures a route.
741
+ cl = state.closed_loop
742
+ foc_out = foc
743
+ if cl is not None and cl.is_actionable and foc != "stale-stamp":
744
+ foc_out = "closed-loop-first"
745
+ notes.append(
746
+ f"{cl.count} closed-loop item(s) pickable"
747
+ f"{f' (top: {cl.top_item})' if cl.top_item else ''} — preferring "
748
+ f"loop-closing work (converts a recurring failure into a durable "
749
+ f"mechanism; pays down the rate instead of re-paying it each run)"
750
+ )
751
+ ev.append(
752
+ f"closed_loop={cl.count}"
753
+ + (f":{cl.top_item}" if cl.top_item else "")
754
+ )
755
+
756
+ # Low (but non-zero) per-lane ship-rate: SURFACE it on the DISPATCH decision
757
+ # (rung-10 only pre-empts on a hard 0/K; a lane shipping 1/K still dispatches,
758
+ # but the operator should see the weak rate). Pure evidence — no behavior
759
+ # change, the same "surface, don't block" treatment the scoreboard-storm gets.
760
+ if (lo is not None and lo.is_actionable and lo.shipped_runs > 0
761
+ and lo.ship_rate < 0.34):
762
+ notes.append(f"lane ship-rate {lo.shipped_runs}/{lo.runs} "
763
+ f"({lo.ship_rate * 100:.0f}%) over recent runs — weak; if it "
764
+ "persists, a /replan to re-prioritize may beat more dispatch")
765
+ ev.append(f"lane_ship_rate={lo.shipped_runs}/{lo.runs}")
766
+
767
+ reason = ("dispatch the next pick — "
768
+ + ("; ".join(notes) if notes
769
+ else "health gate clean, no escalated decision, replan not due, "
770
+ "scoreboard not in a storm"))
771
+ return ScoutDecision(
772
+ activity=ScoutActivity.DISPATCH, rule_id=9, confidence=Confidence.HIGH,
773
+ scope=state.scope, focus=foc_out, gate=state.gate,
774
+ reason=reason,
775
+ evidence=tuple(ev),
776
+ )
777
+
778
+
779
+ def _phase2_menu(state: ScoutState, foc: str) -> Optional[ScoutDecision]:
780
+ """The wider-menu rungs — bounded ANNEX so the live 9-rule spine above stays
781
+ legible (the god-skill guard, made structural). Returns None in Phase 1: every
782
+ reserved input defaults None, so every rung here is skipped and `choose` is
783
+ byte-identical to the Phase-1 spine. Phase 2 adds opt-in rungs, each guarded by
784
+ `if state.<reserved_field> is not None:` — a None field is a skipped rung, never
785
+ an accidental route.
786
+
787
+ Phase-2 intent (not yet wired):
788
+ - state.class_drift → JUDGE (class-cycle: ACTIVE count low / class drift)
789
+ - state.trajectory_pathology → TRAJECTORY_AUDIT (cross-run token-waste / read-loop)
790
+ - state.plan_surface_drift → PLAN_AUDIT (plan-meta vs ship-oracle drift)
791
+ - state.inverted_priority → TAIL_WAG (inverted-priority driver suspected)
792
+ """
793
+ return None
794
+
795
+
796
+ # ───────────────────────────── CLI (I/O composition) ─────────────────────────
797
+ # Exit code per activity so a shell caller can branch on the exit alone. The
798
+ # unstick/replan/overlap codes (3/4/5) are kept legible alongside dos.health's
799
+ # (3 unstick, 4 replan); STOP gets 6 (health's overlap exit), and a LOW-confidence
800
+ # pick of ANY activity is FLIPPED to 6 (surface) at the boundary — the
801
+ # JO_AUTO_ACCEPT actuation line lives in the host, but the CLI exposes the bit.
802
+ _ACTIVITY_EXIT = {
803
+ ScoutActivity.DISPATCH: 0,
804
+ ScoutActivity.UNSTICK: 3,
805
+ ScoutActivity.REPLAN: 4,
806
+ ScoutActivity.WAIT: 5,
807
+ ScoutActivity.STOP: 6,
808
+ ScoutActivity.NEXT_UP: 7,
809
+ ScoutActivity.JUDGE: 8,
810
+ ScoutActivity.PLAN_AUDIT: 9,
811
+ ScoutActivity.TAIL_WAG: 10,
812
+ ScoutActivity.TRAJECTORY_AUDIT: 11,
813
+ }
814
+
815
+
816
+ def check(
817
+ *,
818
+ scope: Optional[str] = None,
819
+ focus: str = "not-started",
820
+ gate: str = "hard",
821
+ resource_blocked: bool = False,
822
+ resource_block_reason: str = "",
823
+ health: Optional[HealthVerdict] = None,
824
+ open_escalated_decisions: tuple[str, ...] = (),
825
+ operator_owned_cause: bool = False,
826
+ same_day_unstick_count: int = 0,
827
+ latest_unstick_stamp: str = "",
828
+ fixes_landing_since_unstick: bool = True,
829
+ open_soaks: tuple[str, ...] = (),
830
+ replan_due: bool = False,
831
+ replan_desc: str = "",
832
+ replan_in_flight: bool = False,
833
+ recent_replan_unproductive: bool = False,
834
+ recent_replan_ran: bool = False,
835
+ recent_replan_desc: str = "",
836
+ scoreboard: Optional[ScoreboardShape] = None,
837
+ closed_loop: Optional[ClosedLoopSignal] = None,
838
+ lane_outcome: Optional[LaneOutcomeShape] = None,
839
+ ) -> ScoutDecision:
840
+ """Thin in-kernel composition: ALREADY-gathered field values in (the host
841
+ adapter did the I/O), one `ScoutDecision` out. Does no I/O itself — it just
842
+ builds a `ScoutState` and calls `choose`, giving a `dos.health` reader the
843
+ exact three-tier shape (`lane_health` / `check` / `cmd_check`). Phase-2 kwargs
844
+ are added here when their rungs land."""
845
+ return choose(ScoutState(
846
+ scope=scope, focus=focus, gate=gate,
847
+ resource_blocked=resource_blocked,
848
+ resource_block_reason=resource_block_reason,
849
+ health=health,
850
+ open_escalated_decisions=tuple(open_escalated_decisions),
851
+ operator_owned_cause=operator_owned_cause,
852
+ same_day_unstick_count=same_day_unstick_count,
853
+ latest_unstick_stamp=latest_unstick_stamp,
854
+ fixes_landing_since_unstick=fixes_landing_since_unstick,
855
+ open_soaks=tuple(open_soaks),
856
+ replan_due=replan_due, replan_desc=replan_desc,
857
+ replan_in_flight=replan_in_flight,
858
+ recent_replan_unproductive=recent_replan_unproductive,
859
+ recent_replan_ran=recent_replan_ran,
860
+ recent_replan_desc=recent_replan_desc,
861
+ scoreboard=scoreboard,
862
+ closed_loop=closed_loop,
863
+ lane_outcome=lane_outcome,
864
+ ))
865
+
866
+
867
+ def decision_to_dict(d: ScoutDecision) -> dict:
868
+ return {
869
+ "activity": d.activity.value,
870
+ "rule_id": d.rule_id,
871
+ "confidence": d.confidence.value,
872
+ "auto_runnable": d.auto_runnable,
873
+ "needs_lane": d.needs_lane,
874
+ "scope": d.scope,
875
+ "focus": d.focus,
876
+ "gate": d.gate,
877
+ "reason": d.reason,
878
+ "cause_key": d.cause_key,
879
+ "evidence": list(d.evidence),
880
+ }
881
+
882
+
883
+ def _health_from_dict(obj: dict | None) -> Optional[HealthVerdict]:
884
+ """Rebuild a HealthVerdict from the JSON `dos health` emits (verdict_to_dict),
885
+ or None. The host normally passes the verdict in-process; the CLI path lets a
886
+ shell caller pipe `dos health` JSON straight in."""
887
+ if not obj:
888
+ return None
889
+ action_raw = obj.get("action", "proceed")
890
+ try:
891
+ action = HealthAction(action_raw)
892
+ except ValueError:
893
+ action = HealthAction.PROCEED
894
+ return HealthVerdict(
895
+ action=action,
896
+ reason=str(obj.get("reason", "")),
897
+ cause_key=str(obj.get("cause_key", "")),
898
+ runs_considered=int(obj.get("runs_considered", 0) or 0),
899
+ blocker_runs=int(obj.get("blocker_runs", 0) or 0),
900
+ overlap_lane=str(obj.get("overlap_lane", "")),
901
+ evidence=tuple(obj.get("evidence", []) or ()),
902
+ )
903
+
904
+
905
+ def _state_from_json(blob: str) -> ScoutState:
906
+ """Build a ScoutState from a JSON object (the fixture-driven CLI). `health` is
907
+ a nested `dos health` verdict dict; `scoreboard` a nested shape dict; the rest
908
+ are scalars/lists matching the ScoutState fields."""
909
+ obj = json.loads(blob) if blob else {}
910
+ sb = obj.get("scoreboard")
911
+ cl = obj.get("closed_loop")
912
+ return ScoutState(
913
+ scope=obj.get("scope"),
914
+ focus=obj.get("focus", "not-started"),
915
+ gate=obj.get("gate", "hard"),
916
+ resource_blocked=bool(obj.get("resource_blocked", False)),
917
+ resource_block_reason=str(obj.get("resource_block_reason", "")),
918
+ health=_health_from_dict(obj.get("health")),
919
+ open_escalated_decisions=tuple(obj.get("open_escalated_decisions", []) or ()),
920
+ operator_owned_cause=bool(obj.get("operator_owned_cause", False)),
921
+ same_day_unstick_count=int(obj.get("same_day_unstick_count", 0) or 0),
922
+ latest_unstick_stamp=str(obj.get("latest_unstick_stamp", "")),
923
+ fixes_landing_since_unstick=bool(obj.get("fixes_landing_since_unstick", True)),
924
+ open_soaks=tuple(obj.get("open_soaks", []) or ()),
925
+ replan_due=bool(obj.get("replan_due", False)),
926
+ replan_desc=str(obj.get("replan_desc", "")),
927
+ replan_in_flight=bool(obj.get("replan_in_flight", False)),
928
+ recent_replan_unproductive=bool(obj.get("recent_replan_unproductive", False)),
929
+ recent_replan_ran=bool(obj.get("recent_replan_ran", False)),
930
+ recent_replan_desc=str(obj.get("recent_replan_desc", "")),
931
+ scoreboard=(ScoreboardShape(
932
+ runs_7d=int(sb.get("runs_7d", 0) or 0),
933
+ zero_ship_7d=int(sb.get("zero_ship_7d", 0) or 0),
934
+ false_drain_runs=int(sb.get("false_drain_runs", 0) or 0),
935
+ runs_shipped=int(sb.get("runs_shipped", 0) or 0),
936
+ ) if isinstance(sb, dict) else None),
937
+ closed_loop=(ClosedLoopSignal(
938
+ available=bool(cl.get("available", False)),
939
+ count=int(cl.get("count", 0) or 0),
940
+ top_item=str(cl.get("top_item", "")),
941
+ ) if isinstance(cl, dict) else None),
942
+ )
943
+
944
+
945
+ def cmd_check(args: argparse.Namespace) -> int:
946
+ """`dos scout --state-json '{...}'` → decision JSON + exit-per-activity.
947
+
948
+ The whole state arrives as JSON (so even the CLI is fixture-driven and the
949
+ host's gathering stays on the host side). A LOW-confidence pick is flipped to
950
+ exit 6 (surface) regardless of activity — the JO_AUTO_ACCEPT boundary made
951
+ visible to a shell caller."""
952
+ state = _state_from_json(args.state_json)
953
+ d = choose(state)
954
+ print(json.dumps(decision_to_dict(d), indent=2, sort_keys=True))
955
+ if not d.auto_runnable:
956
+ return 6 # LOW ⇒ surface, never auto-run (the reversibility gate)
957
+ return _ACTIVITY_EXIT[d.activity]
958
+
959
+
960
+ def build_parser() -> argparse.ArgumentParser:
961
+ p = argparse.ArgumentParser(
962
+ prog="dos-scout",
963
+ description="Pre-dispatch CHOOSER — pick the next activity BEFORE leasing a lane.",
964
+ )
965
+ p.add_argument(
966
+ "--state-json", default="",
967
+ help="JSON ScoutState: {scope, focus, gate, health:{...dos health...}, "
968
+ "open_escalated_decisions:[], operator_owned_cause, same_day_unstick_count, "
969
+ "latest_unstick_stamp, fixes_landing_since_unstick, open_soaks:[], replan_due, "
970
+ "replan_desc, replan_in_flight, recent_replan_unproductive, recent_replan_ran, recent_replan_desc, "
971
+ "scoreboard:{runs_7d,zero_ship_7d,false_drain_runs,runs_shipped}}",
972
+ )
973
+ return p
974
+
975
+
976
+ def main(argv: list[str] | None = None) -> int:
977
+ args = build_parser().parse_args(argv)
978
+ return cmd_check(args)
979
+
980
+
981
+ if __name__ == "__main__": # pragma: no cover
982
+ sys.exit(main())