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