dos-kernel 0.22.0__py3-none-win_arm64.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/pick_priority.py ADDED
@@ -0,0 +1,225 @@
1
+ """`pick_priority` — the freshness sort-key producer (docs/254).
2
+
3
+ The picker substrate already answers *is there anything pickable* (`pickable`),
4
+ *have I tried it* (`cooldown`), and *did the claim hold* (`reconcile`). But a fleet
5
+ can still **churn**: a dispatch loop that re-attempts a unit it has already tried —
6
+ one that did not move — instead of picking up new, not-started work. The job repo
7
+ measured this directly (docs/254): over 24h, 19 dispatch runs shipped only 1 pick
8
+ (5.3%); 18 of 19 DRAINED/BLOCKED, re-confirming known-drained units.
9
+
10
+ The root cause is an **ordering** gap, not a gate gap. The host's plan-sort key was
11
+ `(priority, status, id)` — there is no *freshness* term, so a never-attempted plan
12
+ and a plan drained 18× in a row sort identically, and ties break on alphabetical
13
+ `id` (blind to churn). `cooldown` only *gates* a unit after its window; the moment
14
+ that window lapses the churned unit sorts right back to the top next to fresh work,
15
+ because the sort itself never learned it was a repeat offender.
16
+
17
+ This module is the missing **ordering** primitive: it folds the attempt history the
18
+ host ALREADY records (the `cooldown` ledger) into a freshness rank, so the picker
19
+ prefers new work *within each priority tier*. Two signals (docs/254):
20
+
21
+ 1. **Never-attempted first** — a unit with zero recorded attempts outranks any
22
+ attempted unit. The direct "pick up new not-started work" signal.
23
+ 2. **Staler last-attempt first (LRU)** — among attempted units, least-recently-tried
24
+ wins, so attention rotates across the residual and nothing is permanently starved.
25
+
26
+ The safety invariant — why this is safe
27
+ ========================================
28
+
29
+ > **Freshness is a TIE-BREAKER. Its `sort_key` is appended AFTER the host's
30
+ > `(priority, status)` key, so it can only reorder WITHIN a priority/status tier —
31
+ > it never gates a unit in or out, and never reorders across tiers.**
32
+
33
+ The consequences, each load-bearing:
34
+
35
+ * A P1 unit ALWAYS outranks a P2 unit, attempted or not — freshness never overrides
36
+ operator priority. (Contrast a stronger cooldown gate, which could *starve* a
37
+ ready high-priority unit by holding it out entirely.)
38
+ * Freshness changes ORDER, never ADMISSIBILITY. It cannot keep work out and cannot
39
+ let held work in. This is the same shape as the overlap-policy floor ("a policy
40
+ can only refuse-more, never admit"): here the primitive can only
41
+ *reorder-within-tier*, never *gate*. So a bug here degrades to "wrong order,"
42
+ never "starved work" or "double-booked lane."
43
+
44
+ ⚓ Fail-open to never-attempted. A missing / garbled attempt summary degrades a unit
45
+ to `NEVER_ATTEMPTED` (sorts FIRST) — the pre-fix behaviour, never a refusal. This
46
+ matches the `cooldown` ledger's own observability-grade posture (an unreadable row
47
+ can only DELAY, never block): the safe direction for an ordering hint is "treat it
48
+ as fresh," the opposite of the correctness-read refuse-don't-guess floor.
49
+
50
+ ⚓ Pure; host gathers state. `classify(unit_id, summary)` makes no file/git/clock
51
+ call. The host reads the attempt ledger at the boundary (it already does, for
52
+ `cooldown`) and hands in an `AttemptSummary`, exactly like `cooldown.cooldown_verdict`
53
+ is handed its attempt records. So the ordering contract replays on a frozen summary
54
+ list with no disk.
55
+
56
+ ⚓ Parameter-free mechanism. Both signals come straight off the ledger; there are NO
57
+ tunable thresholds (unlike `[cooldown]`'s windows), so there is deliberately no
58
+ `[pick_priority]` config table. A future attempt-count or time-decay variant would
59
+ add one; this leaf does not.
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import enum
65
+ from dataclasses import dataclass
66
+ from typing import Optional
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # AttemptSummary — the per-unit fact the host hands in (derived from the ledger).
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class AttemptSummary:
76
+ """The attempt facts `classify` reads for one unit — PURE data the host gathers.
77
+
78
+ The host derives this from the same attempt ledger `cooldown` already reads
79
+ (`pick_cooldown.latest_attempts` in the job repo, or `lane_journal` OP_ATTEMPT
80
+ records): a unit ABSENT from that map is never-attempted; a unit present carries
81
+ its most-recent attempt's ms-epoch stamp.
82
+
83
+ * ``attempted`` — has this unit EVER been recorded as a pick-attempt?
84
+ * ``last_attempt_ms`` — the most-recent attempt's ms-epoch stamp (``None`` when
85
+ never attempted, or when a present row's stamp was unreadable — treated as
86
+ most-stale so it sorts earliest among attempted units; degrade-never-crash).
87
+ """
88
+
89
+ attempted: bool = False
90
+ last_attempt_ms: Optional[int] = None
91
+
92
+ @classmethod
93
+ def never(cls) -> "AttemptSummary":
94
+ """A never-attempted summary — the fail-open default (sorts FIRST)."""
95
+ return cls(attempted=False, last_attempt_ms=None)
96
+
97
+ @classmethod
98
+ def at(cls, last_attempt_ms: Optional[int]) -> "AttemptSummary":
99
+ """An attempted summary stamped at ``last_attempt_ms`` (None → most-stale)."""
100
+ return cls(attempted=True, last_attempt_ms=last_attempt_ms)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Freshness — the closed two-value verdict.
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class Freshness(str, enum.Enum):
109
+ """Whether a unit has ever been attempted (docs/254).
110
+
111
+ `str`-valued so it round-trips a `--json` token / exit code without a lookup
112
+ table (the `CooldownState` / `Reconciliation` idiom). The two members are the
113
+ only freshness tiers; the LRU ordering among `ATTEMPTED` units lives in the
114
+ `PickPriority.sort_key`, not in a third enum member.
115
+ """
116
+
117
+ NEVER_ATTEMPTED = "NEVER_ATTEMPTED" # zero recorded attempts — pick this first
118
+ ATTEMPTED = "ATTEMPTED" # tried before — demote below fresh work
119
+
120
+ def __str__(self) -> str: # pragma: no cover - trivial
121
+ return self.value
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # PickPriority — the typed verdict carrying the load-bearing sort_key.
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class PickPriority:
131
+ """A unit's freshness verdict + the `sort_key` a picker appends to its own key.
132
+
133
+ Frozen + the kernel verdict idiom. ``freshness`` is the typed tier;
134
+ ``last_attempt_ms`` is the stamp the LRU order reads (0 when never attempted or
135
+ unknown); ``reason`` is the operator-facing one-liner.
136
+
137
+ The load-bearing field is `sort_key` — the tuple a host appends AFTER its
138
+ `(priority, status)` key so freshness breaks ties WITHIN a tier and nowhere else.
139
+ """
140
+
141
+ unit_id: str
142
+ freshness: Freshness
143
+ last_attempt_ms: int = 0
144
+ reason: str = ""
145
+
146
+ @property
147
+ def sort_key(self) -> tuple[int, int]:
148
+ """The lower-wins tuple a picker appends to its `(priority, status, …)` key.
149
+
150
+ * NEVER_ATTEMPTED → ``(0, 0)`` — sorts FIRST (pick new work).
151
+ * ATTEMPTED → ``(1, last_attempt_ms)`` — sorts after all fresh work,
152
+ then ascending by last-attempt stamp = least-recently-tried first (LRU).
153
+
154
+ Lower wins, matching the host's existing lower-wins tuple sort. Because this
155
+ is appended after the priority/status terms, it can ONLY reorder within a
156
+ tier — never across tiers, never in/out of the candidate set (the safety
157
+ invariant in the module docstring).
158
+ """
159
+ if self.freshness is Freshness.NEVER_ATTEMPTED:
160
+ return (0, 0)
161
+ return (1, self.last_attempt_ms)
162
+
163
+ @property
164
+ def is_fresh(self) -> bool:
165
+ """True iff this unit has never been attempted (the inverse a picker reads)."""
166
+ return self.freshness is Freshness.NEVER_ATTEMPTED
167
+
168
+ def to_dict(self) -> dict:
169
+ return {
170
+ "unit_id": self.unit_id,
171
+ "freshness": self.freshness.value,
172
+ "last_attempt_ms": self.last_attempt_ms,
173
+ "sort_key": list(self.sort_key),
174
+ "reason": self.reason,
175
+ }
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # classify — the pure fold over a unit's attempt summary.
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def classify(unit_id: str, summary: Optional[AttemptSummary]) -> PickPriority:
184
+ """Fold a unit's attempt summary into its freshness verdict. PURE — no I/O.
185
+
186
+ ``summary`` is the `AttemptSummary` the host derived from the attempt ledger
187
+ (the same ledger `cooldown` reads). The fold (docs/254):
188
+
189
+ * ``summary`` missing, or ``attempted is False`` → ``NEVER_ATTEMPTED`` (sorts
190
+ first). The fail-open default: a unit the host could not summarise is treated
191
+ as fresh, never refused.
192
+ * ``attempted is True`` → ``ATTEMPTED`` carrying ``last_attempt_ms`` (a missing
193
+ / non-int stamp coerces to 0 → most-stale, sorts earliest among attempted).
194
+
195
+ Returns a `PickPriority`; never raises.
196
+ """
197
+ uid = str(unit_id)
198
+
199
+ # Fail-open: no summary, or an explicitly never-attempted one → fresh.
200
+ if summary is None or not getattr(summary, "attempted", False):
201
+ return PickPriority(
202
+ unit_id=uid,
203
+ freshness=Freshness.NEVER_ATTEMPTED,
204
+ last_attempt_ms=0,
205
+ reason="no recorded pick-attempt — never-attempted; pick this before "
206
+ "any already-tried unit (fresh work first)",
207
+ )
208
+
209
+ # Attempted — carry the last-attempt stamp for the LRU order. A missing / garbled
210
+ # stamp coerces to 0 (most-stale) so a present-but-unstamped row still sorts as
211
+ # attempted, just earliest among them — degrade-never-crash.
212
+ raw = getattr(summary, "last_attempt_ms", None)
213
+ try:
214
+ last_ms = int(raw) if raw is not None else 0
215
+ except (TypeError, ValueError):
216
+ last_ms = 0
217
+
218
+ return PickPriority(
219
+ unit_id=uid,
220
+ freshness=Freshness.ATTEMPTED,
221
+ last_attempt_ms=last_ms,
222
+ reason=(f"already attempted (last at {last_ms}ms) — demoted below "
223
+ f"never-attempted work; among attempted units the least-recently-"
224
+ f"tried sorts first (LRU)"),
225
+ )
dos/pickable.py ADDED
@@ -0,0 +1,369 @@
1
+ """`pickable` — the pre-dispatch gate (docs/168 Concept 2).
2
+
3
+ The kernel already owns four ground-truth syscalls (`verify`/`oracle`,
4
+ `arbitrate`, `liveness`, `scout`/`loop_decide`). But a fleet's throughput is
5
+ lost *before a worker launches* to a question the kernel did not yet own:
6
+
7
+ > "Is there anything here a worker could actually pick up — and if not, *why
8
+ > not*, precisely enough to route?"
9
+
10
+ The `job` host answered this in its own code
11
+ (`fanout_state._phase_universe_has_pickable_phase`,
12
+ `next_up_context._attach_pick_gates`, `plan_pickability._phase_gate_reason`),
13
+ and every bug in that re-implementation was a fleet-wide wedge: the drain-trap
14
+ (FQ-493 / ASI #475 / RTN / FMP — the pick-count oracle counted a DEFERRED /
15
+ DRAFT / operator-gated phase as *pickable*), the FQ-420 un-typed gate-set, the
16
+ picker-invisibility gap. The `picker_oracle` module is a **post-hoc audit** that
17
+ reconstructs ground truth *after* a dispatch emitted a verdict, to *measure*
18
+ picker precision/recall — it is NOT a pre-dispatch gate the picker can call to
19
+ decide what to offer. This module is that gate.
20
+
21
+ The relationship to `picker_oracle`:
22
+
23
+ * `pickable.classify` → the **pre-flight gate** — decide what to offer.
24
+ * `picker_oracle` → the **post-flight audit** — was the gate right?
25
+
26
+ They share ONE vocabulary. `HoldReason` is the FINER closed set; it collapses to
27
+ the coarse `picker_oracle.NoPickCause` via `.to_no_pick_cause`, so the gate and
28
+ the audit can never drift (the same `gate_classify` → `dispatch-loop` shape that
29
+ already worked). Concretely: a `HELD(OPERATOR_GATED)` pre-flight is exactly the
30
+ case `picker_oracle` audits as `OPERATOR_GATE` — one enum, two consumers.
31
+
32
+ The keystone is `HoldReason.is_redispatch_invariant`. The single most expensive
33
+ recurring mistake was a loop that kept re-dispatching a lane whose *only* hold
34
+ reason was one a re-dispatch cannot change (`DRAFT_CLASS`, `OPERATOR_GATED`,
35
+ `SOAK_OPEN`, `DEPENDENCY_UNMET`). `loop_decide` reads that flag and gains a clean
36
+ rung: a lane held only by re-dispatch-invariant reasons is STOP-now, not
37
+ continue — the honest-STOP that was a per-run human override becomes a kernel
38
+ rule (docs/168 §5; the same move docs/145 made for the stall reader).
39
+
40
+ ⚓ Pure; host gathers state. Identical seam to `dos.scout.choose` reading a
41
+ sibling `HealthVerdict`: `classify(unit_state, …)` is `pure(state)`, all the I/O
42
+ (read the plan class, the soak index, the live claims) on the host adapter side.
43
+ The host's `_phase_universe_has_pickable_phase` becomes a thin
44
+ `all(classify(u, …).held for u in units)` instead of bespoke gate logic.
45
+
46
+ ⚓ Degrade, never crash. A missing key in `unit_state` is treated as its falsy
47
+ default; `classify` never raises. The picker-invisibility gap was a *silent*
48
+ drop; the cure here is a typed verdict the picker can always produce.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import enum
54
+ from dataclasses import dataclass
55
+ from typing import Mapping, Optional
56
+
57
+ from dos import picker_oracle
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # HoldReason — the finer closed set of reasons a unit is not offerable.
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ class HoldReason(str, enum.Enum):
66
+ """Why a declared work unit is NOT offerable to a worker right now.
67
+
68
+ The single closed enum of hold reasons — the keystone of docs/168 §2. The
69
+ drain-trap existed because "gated" and "shipped" were collapsed into a single
70
+ boolean ("has a pickable phase: y/n") with no reason. As a kernel enum the
71
+ reasons become the contract every picker shares, and the consequence-routing
72
+ (a `DRAFT_CLASS` hold → `/promote`; an `OPERATOR_GATED` hold → escalate a
73
+ decision; a `SOAK_OPEN` hold → wait, never `/replan`) is derivable from the
74
+ reason instead of re-discovered per incident.
75
+
76
+ `str`-valued so it round-trips through a host's JSON/stdout token without a
77
+ lookup table (mirrors `gate_classify.Verdict`, `picker_oracle.NoPickCause`).
78
+ """
79
+
80
+ SHIPPED = "SHIPPED" # already verified shipped — drop from residual
81
+ IN_FLIGHT = "IN_FLIGHT" # a live worker is already on this unit
82
+ SOFT_CLAIMED_ELSEWHERE = "SOFT_CLAIMED_ELSEWHERE" # a sibling fanout holds a live soft-claim
83
+ DRAFT_CLASS = "DRAFT_CLASS" # plan is DRAFT — phases not greenlit for build (FMP / #493)
84
+ OPERATOR_GATED = "OPERATOR_GATED" # blocked on an open operator decision (ASI / #475)
85
+ SOAK_OPEN = "SOAK_OPEN" # a soak deadline has not yet elapsed (RTN)
86
+ DEPENDENCY_UNMET = "DEPENDENCY_UNMET" # a prerequisite unit has not shipped
87
+ COOLDOWN = "COOLDOWN" # tried recently; per-pick cooldown window not elapsed
88
+ UNPARSEABLE = "UNPARSEABLE" # the unit's declaration could not be parsed (typed, not silent)
89
+ STALE_CLAIM = "STALE_CLAIM" # blocked by a claim that is itself orphaned/stale
90
+
91
+ def __str__(self) -> str: # pragma: no cover - trivial
92
+ return self.value
93
+
94
+ @property
95
+ def is_redispatch_invariant(self) -> bool:
96
+ """True iff a re-dispatch CANNOT change this hold.
97
+
98
+ The keystone for the `loop_decide` honest-STOP rung (docs/168 §5). A lane
99
+ held only by these reasons will re-block identically on the next
100
+ iteration — re-dispatching it is pure waste. The four members:
101
+
102
+ * DRAFT_CLASS — only an operator promotion (DRAFT→ACTIVE) un-gates it.
103
+ * OPERATOR_GATED — only an operator decision un-gates it.
104
+ * SOAK_OPEN — only the passage of wall-clock time un-gates it
105
+ (a re-dispatch *now* cannot fast-forward the soak).
106
+ * DEPENDENCY_UNMET — only shipping the prerequisite un-gates it; a
107
+ re-dispatch of THIS unit cannot.
108
+
109
+ The re-dispatch-CURABLE reasons are deliberately NOT here:
110
+
111
+ * SHIPPED — terminal (drop from residual; the loop is done with it,
112
+ not stuck on it — it never re-enters a dispatch attempt).
113
+ * IN_FLIGHT / SOFT_CLAIMED_ELSEWHERE / STALE_CLAIM — clear when the holder
114
+ finishes / a claim ages out / a scavenge runs.
115
+ * COOLDOWN — clears when the cooldown window elapses.
116
+ * UNPARSEABLE — clears when the host fixes/re-parses the declaration.
117
+ """
118
+ return self in _REDISPATCH_INVARIANT
119
+
120
+ @property
121
+ def to_no_pick_cause(self) -> "picker_oracle.NoPickCause":
122
+ """Map onto the coarse post-hoc `picker_oracle.NoPickCause` vocabulary.
123
+
124
+ docs/168 §2: the pre-flight gate and the post-hoc audit MUST share one
125
+ vocabulary so a `HELD(reason)` the picker emits is the same thing the
126
+ oracle later audits. The finer `HoldReason` collapses onto the coarse
127
+ `NoPickCause` exactly as `picker_oracle._LEGACY_REASON_ALIASES` already
128
+ collapses `OPERATOR_GATED`/`SOAK_OPEN` → `OPERATOR_GATE`:
129
+
130
+ * DRAFT_CLASS / OPERATOR_GATED / SOAK_OPEN → OPERATOR_GATE
131
+ * IN_FLIGHT / SOFT_CLAIMED_ELSEWHERE / STALE_CLAIM → STALE_CLAIM
132
+ * SHIPPED / DEPENDENCY_UNMET → TRUE_DRAIN
133
+ * COOLDOWN → TRUE_DRAIN
134
+ * UNPARSEABLE → UNCLASSIFIED
135
+ """
136
+ return _TO_NO_PICK_CAUSE[self]
137
+
138
+
139
+ _REDISPATCH_INVARIANT: frozenset[HoldReason] = frozenset(
140
+ {
141
+ HoldReason.DRAFT_CLASS,
142
+ HoldReason.OPERATOR_GATED,
143
+ HoldReason.SOAK_OPEN,
144
+ HoldReason.DEPENDENCY_UNMET,
145
+ }
146
+ )
147
+
148
+
149
+ # The coarse mapping (docs/168 §2). Total over `HoldReason` — pinned by
150
+ # `tests/test_pickable.py` (every member maps to a real `NoPickCause`).
151
+ _TO_NO_PICK_CAUSE: dict[HoldReason, "picker_oracle.NoPickCause"] = {
152
+ HoldReason.SHIPPED: picker_oracle.NoPickCause.TRUE_DRAIN,
153
+ HoldReason.IN_FLIGHT: picker_oracle.NoPickCause.STALE_CLAIM,
154
+ HoldReason.SOFT_CLAIMED_ELSEWHERE: picker_oracle.NoPickCause.STALE_CLAIM,
155
+ HoldReason.DRAFT_CLASS: picker_oracle.NoPickCause.OPERATOR_GATE,
156
+ HoldReason.OPERATOR_GATED: picker_oracle.NoPickCause.OPERATOR_GATE,
157
+ HoldReason.SOAK_OPEN: picker_oracle.NoPickCause.OPERATOR_GATE,
158
+ HoldReason.DEPENDENCY_UNMET: picker_oracle.NoPickCause.TRUE_DRAIN,
159
+ HoldReason.COOLDOWN: picker_oracle.NoPickCause.TRUE_DRAIN,
160
+ HoldReason.UNPARSEABLE: picker_oracle.NoPickCause.UNCLASSIFIED,
161
+ HoldReason.STALE_CLAIM: picker_oracle.NoPickCause.STALE_CLAIM,
162
+ }
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Pickability — the typed verdict.
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class Pickability:
172
+ """Whether a unit is offerable to a worker right now, and the typed reason
173
+ it is not.
174
+
175
+ Frozen + classmethod-constructors, the kernel verdict idiom (mirrors
176
+ `admission.AdmissionVerdict.admit()/refuse()`):
177
+
178
+ * `Pickability.OFFERABLE()` — nothing holds the unit; offer it.
179
+ * `Pickability.HELD(reason, ev)` — held by exactly one typed `HoldReason`,
180
+ with operator-facing `evidence`.
181
+
182
+ `held` is the load-bearing field a picker branches on (the inverse of
183
+ `OFFERABLE`); `reason` is `None` iff `held is False`.
184
+ """
185
+
186
+ held: bool
187
+ reason: Optional[HoldReason] = None
188
+ evidence: str = ""
189
+
190
+ @classmethod
191
+ def OFFERABLE(cls) -> "Pickability":
192
+ """An offerable verdict — no hold applies; a worker may pick this up."""
193
+ return cls(held=False, reason=None, evidence="")
194
+
195
+ @classmethod
196
+ def HELD(cls, reason: HoldReason, evidence: str = "") -> "Pickability":
197
+ """A held verdict carrying the single typed `reason` it is not offerable
198
+ and an operator-facing `evidence` line."""
199
+ return cls(held=True, reason=reason, evidence=evidence)
200
+
201
+ @property
202
+ def is_redispatch_invariant(self) -> bool:
203
+ """True iff this verdict is HELD by a re-dispatch-invariant reason.
204
+
205
+ The convenience the `loop_decide` rung reads: `held` AND the reason is
206
+ one a re-dispatch cannot change. `OFFERABLE` is never invariant (it is
207
+ not held at all)."""
208
+ return self.held and self.reason is not None and self.reason.is_redispatch_invariant
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # classify — the pure pre-dispatch gate.
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def classify(
217
+ unit_state: Mapping,
218
+ *,
219
+ now_ms: int,
220
+ policy: Optional[Mapping] = None,
221
+ ) -> Pickability:
222
+ """Decide whether a declared work unit is offerable. PURE — no I/O.
223
+
224
+ `unit_state` is a dict the HOST pre-gathers (all the file/git/registry reads
225
+ happen on the adapter side — the same seam as `dos.scout.choose` reading a
226
+ sibling `HealthVerdict`). Recognised keys, each defaulting to its falsy value
227
+ when absent (degrade-never-crash):
228
+
229
+ * shipped: bool — the unit is already verified shipped.
230
+ * in_flight: bool — a live worker is on this unit now.
231
+ * soft_claimed_elsewhere: bool — a sibling fanout holds a live soft-claim.
232
+ * plan_class: str — the plan's class ("DRAFT" → DRAFT_CLASS).
233
+ * operator_gated: bool — blocked on an open operator decision.
234
+ * soak_open: bool — a soak deadline has not yet elapsed.
235
+ * dependency_unmet: bool — a prerequisite unit has not shipped.
236
+ * cooldown_until_ms: int | None — per-pick cooldown wall; held iff `now_ms`
237
+ is strictly before it.
238
+ * unparseable: bool — the declaration could not be parsed.
239
+
240
+ `now_ms` is the caller's clock (an input, never read from the wall here — the
241
+ same discipline as `liveness.classify`), used only for the COOLDOWN check.
242
+ `policy` is reserved for host-declared knobs (docs/168 §"mechanism-not-
243
+ policy"); unused today, accepted so the signature is stable.
244
+
245
+ Precedence (most-terminal / most-specific first, documented in docs/168 §2):
246
+
247
+ 1. SHIPPED — terminal; nothing else matters once it shipped.
248
+ 2. UNPARSEABLE — a typed "I could not parse this" beats every
249
+ content gate (a gate read off an unparseable
250
+ declaration is meaningless; surface the parse
251
+ failure instead of a derived hold).
252
+ 3. the in-flight family — IN_FLIGHT, then SOFT_CLAIMED_ELSEWHERE, then
253
+ STALE_CLAIM (a live worker / claim wins over a
254
+ class/gate/soak/dep reason — the unit IS being
255
+ worked, the gate would only matter once it frees).
256
+ 4. DRAFT_CLASS — the plan class gate.
257
+ 5. OPERATOR_GATED — an open operator decision.
258
+ 6. SOAK_OPEN — an unelapsed soak.
259
+ 7. DEPENDENCY_UNMET — an unshipped prerequisite.
260
+ 8. COOLDOWN — the per-pick cooldown wall (the most transient,
261
+ curable by time alone — checked last).
262
+
263
+ Returns `Pickability.OFFERABLE()` when no hold applies.
264
+ """
265
+ s = unit_state or {}
266
+
267
+ def _b(key: str) -> bool:
268
+ # Defensive truthiness — a missing key is its falsy default; never raise.
269
+ try:
270
+ return bool(s.get(key))
271
+ except AttributeError: # pragma: no cover - non-Mapping degrade path
272
+ return False
273
+
274
+ # 1. SHIPPED — terminal. Once verified shipped the unit leaves the residual;
275
+ # no later gate can resurrect it.
276
+ if _b("shipped"):
277
+ return Pickability.HELD(
278
+ HoldReason.SHIPPED,
279
+ "unit is verified shipped — drop from the residual",
280
+ )
281
+
282
+ # 2. UNPARSEABLE — a typed parse failure beats every content gate. The
283
+ # picker-invisibility gap was a SILENT drop; this is the cure — surface a
284
+ # refusal reason instead of an empty universe.
285
+ if _b("unparseable"):
286
+ return Pickability.HELD(
287
+ HoldReason.UNPARSEABLE,
288
+ "the unit's declaration could not be parsed — surfaced as a typed "
289
+ "refusal rather than silently dropped",
290
+ )
291
+
292
+ # 3. The in-flight family — a live worker / claim on the unit. It IS being
293
+ # worked (or held by a sibling), so a class/gate/soak reason is moot until
294
+ # it frees; report the live holder, most-specific first.
295
+ if _b("in_flight"):
296
+ return Pickability.HELD(
297
+ HoldReason.IN_FLIGHT,
298
+ "a live worker is already on this unit",
299
+ )
300
+ if _b("soft_claimed_elsewhere"):
301
+ return Pickability.HELD(
302
+ HoldReason.SOFT_CLAIMED_ELSEWHERE,
303
+ "a sibling fanout holds a live soft-claim on this unit",
304
+ )
305
+ if _b("stale_claim"):
306
+ return Pickability.HELD(
307
+ HoldReason.STALE_CLAIM,
308
+ "blocked by a claim that is itself orphaned/stale",
309
+ )
310
+
311
+ # 4. DRAFT_CLASS — the plan is DRAFT; its phases are not greenlit for build.
312
+ # Only an operator promotion (DRAFT→ACTIVE) un-gates it — a /replan cannot
313
+ # (FMP / decision #493). Re-dispatch-invariant.
314
+ plan_class = ""
315
+ try:
316
+ plan_class = str(s.get("plan_class") or "").strip().upper()
317
+ except AttributeError: # pragma: no cover - non-Mapping degrade path
318
+ plan_class = ""
319
+ if plan_class == "DRAFT":
320
+ return Pickability.HELD(
321
+ HoldReason.DRAFT_CLASS,
322
+ "plan is DRAFT-class — phases are not greenlit for build; only an "
323
+ "operator promotion (DRAFT→ACTIVE) un-gates it, not a /replan",
324
+ )
325
+
326
+ # 5. OPERATOR_GATED — an open operator decision blocks it (ASI / #475).
327
+ # Re-dispatch-invariant: only an operator answer un-gates it.
328
+ if _b("operator_gated"):
329
+ return Pickability.HELD(
330
+ HoldReason.OPERATOR_GATED,
331
+ "blocked on an open operator decision — escalate the decision; a "
332
+ "re-dispatch cannot answer it",
333
+ )
334
+
335
+ # 6. SOAK_OPEN — a soak deadline has not yet elapsed (RTN). Re-dispatch-
336
+ # invariant: only the passage of time un-gates it; never /replan, wait.
337
+ if _b("soak_open"):
338
+ return Pickability.HELD(
339
+ HoldReason.SOAK_OPEN,
340
+ "a soak deadline has not yet elapsed — wait for the soak to close; a "
341
+ "re-dispatch now cannot fast-forward it",
342
+ )
343
+
344
+ # 7. DEPENDENCY_UNMET — a prerequisite unit has not shipped. Re-dispatch-
345
+ # invariant: only shipping the prerequisite un-gates THIS unit.
346
+ if _b("dependency_unmet"):
347
+ return Pickability.HELD(
348
+ HoldReason.DEPENDENCY_UNMET,
349
+ "a prerequisite unit has not shipped — ship the dependency first; a "
350
+ "re-dispatch of this unit cannot",
351
+ )
352
+
353
+ # 8. COOLDOWN — the per-pick cooldown wall. The most transient hold (curable
354
+ # by wall-clock time alone), so it is checked last. Held iff `now_ms` is
355
+ # strictly before the wall; a missing/None/zero wall never holds.
356
+ cooldown_until = s.get("cooldown_until_ms")
357
+ if cooldown_until is not None:
358
+ try:
359
+ wall = int(cooldown_until)
360
+ except (TypeError, ValueError): # pragma: no cover - defensive
361
+ wall = 0
362
+ if wall > 0 and now_ms < wall:
363
+ return Pickability.HELD(
364
+ HoldReason.COOLDOWN,
365
+ f"per-pick cooldown active until {wall}ms (now {now_ms}ms) — "
366
+ f"the window has not elapsed",
367
+ )
368
+
369
+ return Pickability.OFFERABLE()