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/arbiter.py ADDED
@@ -0,0 +1,859 @@
1
+ """The lane-admission kernel — `arbitrate(request, live_leases, config) -> decision`.
2
+
3
+ This is the crown jewel (dispatch-os-vision §4 scheduler / ACR Plane ①): a
4
+ **pure** admission policy. State (the live leases) goes in, a decision comes out,
5
+ no I/O — so the concurrency *policy* is unit-tested without spawning a single
6
+ live loop, and you can *prove* properties about it. Almost no production OS
7
+ scheduler has this property.
8
+
9
+ Extracted from the origin repo's `scripts/fanout_state.py` (`arbitrate_lane`,
10
+ ~L2772). The extraction is **lift-and-shift, not redesign**: the decision logic
11
+ is byte-for-byte the proven code. The ONE change is the §5.5.4 mechanism/policy
12
+ split — the origin function reached into `next_up_render._CLUSTERS` and module-
13
+ level `_AUTOPICK_CLUSTERS` / `_EXCLUSIVE_LANES` constants for the *job repo's*
14
+ lane taxonomy. Here those become `SubstrateConfig.lanes` (per-workspace data),
15
+ so the kernel never names a domain lane: point it at a benchmark repo's lanes,
16
+ or a calendar's, or a k8s namespace's, and it arbitrates those unchanged.
17
+
18
+ The vision's **capability-lattice generalization** (every touchable resource a
19
+ lattice node; admit iff the requested capability set is *provably disjoint*) is a
20
+ separate redesign that would sit on top of this pure arbiter — deliberately out
21
+ of scope here (PO4 scope guard / audit G4). This ships the arbiter the lattice
22
+ would later stand on.
23
+
24
+ What this arbiter *is*, named after its mechanism rather than the "lane" metaphor:
25
+ a **lock manager whose granularity is a glob-set** — a lane is a *leased
26
+ predicate-lock over a region of the workspace*, admitted by predicate-disjointness
27
+ (`_tree.prefixes_collide` is the predicate-intersection test; the soft-overlap
28
+ ratio in `lane_overlap` is a *loosened lock-compatibility function*, not a
29
+ swim-lane fudge). The capability-lattice above is then **the same primitive over a
30
+ richer predicate algebra than path-prefixes** — a new `prefixes_collide`, not a new
31
+ arbiter. See `docs/89_the-lane-is-a-region-lock.md` (which is also the forward
32
+ litmus for what belongs in here: a region-lock property, never a swim-lane one).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import Callable
38
+
39
+ from dos._tree import lane_trees_disjoint as _trees_disjoint # noqa: F401
40
+ from dos._tree import tree_disjoint_from_all_live as _disjoint_from_all_live
41
+ from dos.lane_overlap import overlap_verdict
42
+ from dos.config import LaneTaxonomy, SubstrateConfig, ensure
43
+ from dos.admission import (
44
+ AdmissionPredicate,
45
+ AdmissionRequest,
46
+ AdmissionVerdict,
47
+ built_in_predicates,
48
+ run_predicates,
49
+ )
50
+
51
+
52
+ class LaneDecision:
53
+ """Result of `arbitrate` — what a dispatch loop should do at Step 0.
54
+
55
+ ``outcome`` is one of:
56
+ 'acquire' — admitted; ``lane`` is the lane to lease (may differ from the
57
+ requested lane when auto-pick reassigned it).
58
+ 'refuse' — not admitted; ``reason`` explains why, ``free_clusters`` lists
59
+ any cluster lanes the operator could pick instead.
60
+ ``auto_picked`` is True when ``lane`` was chosen by auto-pick.
61
+ ``pick_count`` is the best-effort pick-availability signal (see ``pick_oracle``).
62
+ """
63
+
64
+ __slots__ = ("outcome", "lane", "lane_kind", "tree", "auto_picked",
65
+ "reason", "free_clusters", "pick_count")
66
+
67
+ def __init__(self, outcome: str, *, lane: str = "", lane_kind: str = "",
68
+ tree: list[str] | None = None, auto_picked: bool = False,
69
+ reason: str = "", free_clusters: list[str] | None = None,
70
+ pick_count: int | None = None):
71
+ self.outcome = outcome
72
+ self.lane = lane
73
+ self.lane_kind = lane_kind
74
+ self.tree = tree or []
75
+ self.auto_picked = auto_picked
76
+ self.reason = reason
77
+ self.free_clusters = free_clusters or []
78
+ self.pick_count = pick_count
79
+
80
+ def to_dict(self) -> dict:
81
+ return {
82
+ "outcome": self.outcome, "lane": self.lane,
83
+ "lane_kind": self.lane_kind, "tree": self.tree,
84
+ "auto_picked": self.auto_picked, "reason": self.reason,
85
+ "free_clusters": self.free_clusters,
86
+ "pick_count": self.pick_count,
87
+ }
88
+
89
+
90
+ def _lease_blocks(requested_tree: list[str], lease_tree: list[str]) -> bool:
91
+ """Does an EXISTING lease block a NEW acquire?
92
+
93
+ Empty-tree rules (asymmetric on lease side):
94
+ * empty LEASE tree → does NOT block (a lease that named no blast radius
95
+ cannot claim conflict; otherwise one empty-tree lease wedges every
96
+ subsequent acquire).
97
+ * empty REQUESTED tree vs KNOWN lease tree → blocks (unknown blast radius
98
+ is never safe).
99
+ * both empty → does NOT block (lone-loop safe).
100
+
101
+ Both-known delegates to `dos.lane_overlap.overlap_verdict` — a ratio-only
102
+ soft-overlap policy (admit when ≤30 % of the requested tree shares prefixes
103
+ with the lease). Pure; tested in isolation.
104
+ """
105
+ if not lease_tree:
106
+ return False
107
+ if not requested_tree:
108
+ return True
109
+ return not overlap_verdict(list(requested_tree), list(lease_tree)).admissible
110
+
111
+
112
+ def _admission_verdict(
113
+ *, lane: str, kind: str, tree: list[str], live_leases: list[dict],
114
+ predicates: list[AdmissionPredicate], config: SubstrateConfig,
115
+ ) -> AdmissionVerdict:
116
+ """Run the FULL admission conjunction for a candidate lane (ADM Phase 1).
117
+
118
+ The single seam every collision check in `arbitrate` now routes through: the
119
+ built-in `DisjointnessPredicate` (a behavior-preserving wrap of the old inline
120
+ `_lease_blocks` / `overlap_verdict`) PLUS `SelfModifyPredicate` PLUS any
121
+ workspace-discovered predicate, composed conjunctively by
122
+ `admission.run_predicates` (first refusal wins; all-admit ⇒ admit; a predicate
123
+ that raises is a fail-closed refuse). The disjointness predicate alone
124
+ reproduces the legacy verdict exactly, so the existing suite stays green; the
125
+ extra predicates can only ADD refusals (the conjunctive-only invariant), never
126
+ loosen, so a disjoint job-lane pair still admits.
127
+ """
128
+ request = AdmissionRequest(lane=lane, kind=kind, tree=tuple(tree or ()))
129
+ return run_predicates(predicates, request, live_leases, config)
130
+
131
+
132
+ def _lease_collision(
133
+ *, lane: str, kind: str, tree: list[str], live_leases: list[dict],
134
+ predicates: list[AdmissionPredicate], config: SubstrateConfig,
135
+ ) -> bool:
136
+ """True iff admitting this candidate lane is refused by ANY predicate against
137
+ ANY live lease — the boolean the auto-pick loops want (they only need "is this
138
+ candidate blocked," then move to the next ladder rung). A thin wrapper over
139
+ `_admission_verdict` so the loops read like the old `any(_lease_blocks(...))`."""
140
+ return not _admission_verdict(
141
+ lane=lane, kind=kind, tree=tree, live_leases=live_leases,
142
+ predicates=predicates, config=config,
143
+ ).admitted
144
+
145
+
146
+ def arbitrate(
147
+ *,
148
+ requested_lane: str,
149
+ requested_kind: str,
150
+ requested_tree: list[str],
151
+ live_leases: list[dict],
152
+ config: SubstrateConfig | None = None,
153
+ auto_pick_order: list[tuple[str, str, list[str]]] | None = None,
154
+ named_lanes: tuple[tuple[str, tuple[str, ...]], ...] | None = None,
155
+ derived_lanes: list[tuple[str, list[str]]] | None = None,
156
+ force: bool = False,
157
+ pick_oracle: Callable[[str, str, list[str]], int | None] | None = None,
158
+ rank_key: Callable[[str, str, list[str]], float | None] | None = None,
159
+ predicates: list[AdmissionPredicate] | None = None,
160
+ class_budgets: dict[str, int] | None = None,
161
+ live_siblings: list[dict] | None = None,
162
+ sibling_tree_lookup: Callable[[str], list[str] | None] | None = None,
163
+ ) -> LaneDecision:
164
+ """PURE admission: decide whether a loop may start, and on which lane.
165
+
166
+ No I/O — ``live_leases`` is passed in, the decision is returned. The lane
167
+ taxonomy (which lanes are concurrent clusters, which are exclusive, each
168
+ lane's canonical tree) comes from ``config.lanes`` (defaulting to the active
169
+ workspace config); the kernel never hard-codes a domain lane name.
170
+
171
+ Inputs:
172
+ requested_lane — the lane the operator asked for ('' = bare invocation).
173
+ requested_kind — 'cluster' | 'keyword' | 'global' | '' (bare → auto-pick).
174
+ requested_tree — the file tree of the requested lane.
175
+ live_leases — dicts with at least {lane, lane_kind, tree}.
176
+ config — the SubstrateConfig whose `lanes` taxonomy to arbitrate
177
+ over (None → the process-active config).
178
+ auto_pick_order — optional `(lane_name, lane_kind, tree)` ladder walked for
179
+ BARE invocations; first lane whose tree is disjoint from
180
+ every live lease wins (or, with `rank_key`, the highest-
181
+ ranked such lane — see below). Takes precedence over the
182
+ legacy named_lanes/derived_lanes.
183
+ named_lanes / derived_lanes — LEGACY pre-ladder fallbacks (kept for the
184
+ replay tests); ignored when `auto_pick_order` is supplied.
185
+ force — OPERATOR OVERRIDE. With an explicit `requested_lane`,
186
+ honor it literally and skip the disjointness/overlap/same-
187
+ lane refuses. The one thing force still respects is a live
188
+ exclusive lane (it holds the shared spine). Never auto-picks.
189
+ pick_oracle — BEST-EFFORT pick-availability gate. `(name, kind, tree) ->
190
+ int | None`; the arbiter SKIPS any auto-pick lane the
191
+ oracle confidently reports as 0 picks. `None` (can't tell)
192
+ means DO NOT skip — oracle failure can only add skips,
193
+ never remove a viable fallback. Only consulted on the
194
+ bare/unresolved-keyword auto-pick path. Pure: the oracle
195
+ does any I/O, not us.
196
+ rank_key — OPTIONAL value-aware picker (docs/91, research-area §3).
197
+ `(name, kind, tree) -> float | None`; when supplied, the
198
+ BARE auto-pick walk visits the `auto_pick_order` candidates
199
+ in DESCENDING rank order instead of ladder order, so the
200
+ admitted lane is the highest-ranked one that is *also*
201
+ disjoint + available — i.e. the argmax over the admissible
202
+ set. `None` for a candidate (or a `rank_key` that raises on
203
+ it) means "no opinion" and sinks that candidate below every
204
+ ranked one, ties (and the all-`None` case) preserving ladder
205
+ order — so `rank_key=None` is byte-identical to the legacy
206
+ first-fit walk (the regression guard). **Soundness floor:**
207
+ rank_key only chooses the ORDER among candidates; it is
208
+ applied *before* the unchanged disjointness + availability
209
+ gate, so it can never admit a colliding lane — the worst a
210
+ bad rank can do is pick a suboptimal-but-still-disjoint lane
211
+ (`rank, never re-admit`, docs/89/§90.3). Like `pick_oracle`,
212
+ it is resolved at the CALL BOUNDARY (a driver/host yield
213
+ estimator); the kernel never learns what "value" means — the
214
+ signal stays driver-side (docs/76). Only consulted on the
215
+ bare auto-pick path with `auto_pick_order` supplied.
216
+ predicates — the admission-predicate conjunction to run the collision
217
+ check through (ADM Phase 1). None → the BUILT-IN set only
218
+ (`DisjointnessPredicate` + `SelfModifyPredicate`), which
219
+ are pure and always-on, so `arbitrate` itself does NO I/O
220
+ on its default path — staying pure exactly as documented.
221
+ Workspace-DISCOVERED `dos.predicates` plugins are resolved
222
+ at the CALL BOUNDARY and passed in (the CLI's
223
+ `cmd_arbitrate` does `admission.active_predicates()` and
224
+ threads the full list here, the same place it discovers a
225
+ renderer) — mirroring how `pick_oracle` is resolved by the
226
+ caller, never inside the pure kernel. A test injects an
227
+ explicit list to run a hermetic conjunction. Predicates
228
+ compose CONJUNCTIVELY — every one must admit — and can
229
+ only REFUSE, never force-admit (the one invariant that
230
+ keeps an open predicate set safe); `--force` remains the
231
+ sole override of a predicate refusal, exactly as for the
232
+ disjointness refuse.
233
+ class_budgets — OPTIONAL concurrency-class budgets (docs/97 Phase 1, the
234
+ worked lesson docs/96). `{lane_kind: max_concurrent}` — how
235
+ many live leases of a given KIND may be held at once. On the
236
+ BARE auto-pick walk, a candidate whose kind is already at its
237
+ budget (live count of that kind >= max_concurrent) is SKIPPED,
238
+ so the arbiter cannot mint an (N+1)-th holder of a budgeted
239
+ class. This is the in-kernel home for the `priority` class
240
+ budget the *host* used to enforce by pre-filtering the
241
+ `auto_pick_order` (job `fanout_state.py`'s
242
+ `_priority_budget_exhausted` drop) — exactly the layering the
243
+ plan moves down. WHY a parameter and not an
244
+ `AdmissionPredicate`: a budget is a CROSS-lease count over the
245
+ whole live set, but a predicate is called once per
246
+ `(request, single-live-lease)` pair and cannot count across
247
+ leases (admission.py "called once per (request, live_lease)
248
+ pair") — so the budget is its own pure step here, the same
249
+ shape docs/97's admission model gives it (step 1 "count live
250
+ leases with class == C", separate from the per-lease region
251
+ resolution). PURE: counts the passed-in `live_leases`, no I/O;
252
+ the host supplies the budget VALUE (its `priority_slots:`
253
+ config), the kernel owns the ADMISSION logic — the docs/97
254
+ DOS-vs-host boundary verbatim. `None` (default) → no budgets →
255
+ byte-identical to the pre-budget walk (the regression guard).
256
+ Only consulted on the bare auto-pick path; a directly-named or
257
+ `--force`d lane is never budget-gated (it is the operator's
258
+ explicit choice, and the same-lane/exclusive refuses already
259
+ bound its concurrency). The N candidates that DO pass the
260
+ budget still bind disjoint regions via the unchanged
261
+ disjointness gate, so this is genuine N-way concurrency, safe
262
+ by construction — never a fixed set of pre-named slots (the
263
+ docs/89 §4.4 swim-lane category error this deliberately avoids).
264
+ live_siblings — OPTIONAL un-leased live runs the bare auto-pick should
265
+ PREFER to avoid colliding with (the single-pick-ceiling fix,
266
+ FQ-449). A lease guards a lane; a *sibling* is a live run that
267
+ holds no lease yet (a freshly-launched loop, an un-leased
268
+ `/dispatch` child) but whose tree this loop will collide with
269
+ post-acquire. Dicts with at least `{lane}`; their trees are
270
+ resolved via `sibling_tree_lookup`. When supplied (with the
271
+ lookup), the BARE walk runs a FIRST pass that admits only a
272
+ candidate whose tree is provably disjoint from EVERY sibling
273
+ (via `dos._tree.tree_disjoint_from_all_live`, the same predicate
274
+ the post-acquire sibling-scan escape uses); only if that pass
275
+ finds nothing does it FALL BACK to the unchanged walk (which may
276
+ return a sibling-overlapping lane — today's behavior). So the
277
+ ceiling shifts: a busy fleet spreads across disjoint lanes instead
278
+ of all colliding on the top-priority cluster, and the worst case
279
+ is byte-identical to the pre-fix walk. `None` (default) → no
280
+ first pass → byte-identical to today (the regression guard). Like
281
+ `pick_oracle`/`rank_key`, the sibling state is gathered at the
282
+ CALL BOUNDARY (the host scans live loop dirs) and passed in; the
283
+ kernel does NO I/O. Only consulted on the bare auto-pick path.
284
+ sibling_tree_lookup — `(lane) -> tree | None`; resolves a sibling's lane name to
285
+ its file tree for the `live_siblings` disjointness pass. Same
286
+ callable shape the sibling-scan escape takes. Ignored unless
287
+ `live_siblings` is non-empty.
288
+ """
289
+ cfg = ensure(config)
290
+ # The `predicates=None` default is the built-in conjunction — but it must be
291
+ # WORKSPACE-AWARE so a foreign repo's `**/*` lane is not refused as SELF_MODIFY
292
+ # against kernel files that don't exist under it. We pass `config=cfg` (NOT a
293
+ # bare `workspace=` path), so `built_in_predicates` reads the CACHED
294
+ # `cfg.workspace` facts gathered at config-build time — NO disk I/O here, the
295
+ # arbiter stays pure. A config whose facts are None (a hand-built test config
296
+ # that never probed) degrades to the conservative full static set, unchanged.
297
+ # This closes the foreign-repo over-refusal: the arbiter's own default now
298
+ # matches what the CLI's `active_predicates(workspace=cfg.root)` already did.
299
+ preds = predicates if predicates is not None else built_in_predicates(config=cfg)
300
+ lanes: LaneTaxonomy = cfg.lanes
301
+ autopick_clusters = list(lanes.autopick)
302
+
303
+ # Lane NAMES are compared case-INsensitively, the same fold the lane TREES already
304
+ # use (`_cluster_tree` lowercases, the disjointness/self-modify predicates fold via
305
+ # `_tree.norm_tree_prefix`). Without this, `dos arbitrate --lane Orchestration`
306
+ # did NOT match the canonical exclusive `orchestration` lane on a case-insensitive
307
+ # FS, so the run-alone refuse was silently skipped and the request degraded to
308
+ # auto-pick. `_lane_key` is the single fold every NAME membership test below runs
309
+ # through; the original `requested_lane` string is preserved for display/output.
310
+ def _lane_key(name: str) -> str:
311
+ return str(name or "").casefold()
312
+
313
+ exclusive_lanes = {_lane_key(x) for x in lanes.exclusive}
314
+ requested_lane_key = _lane_key(requested_lane)
315
+
316
+ def _cluster_tree(cluster: str) -> list[str]:
317
+ """The canonical file tree for a cluster lane, from the config taxonomy."""
318
+ return lanes.tree_for(cluster.lower()) or lanes.tree_for(cluster)
319
+
320
+ # Every lane NAME this workspace's taxonomy recognises, case-folded. Used to
321
+ # tell an auto-pick redirect WHY the requested lane was not granted: a name in
322
+ # this set that the picker skipped was *held* ("busy"); a name absent from it
323
+ # was never a lane here at all. Conflating the two makes the kernel narrate a
324
+ # false "was busy" for a typo'd / foreign lane name (docs/104 §4).
325
+ # `global` is the generic exclusive lane every workspace has; any other
326
+ # exclusive lane (e.g. a host's `orchestration`) is already folded in via
327
+ # `*lanes.exclusive`, so no host lane name is hardcoded here.
328
+ _known_lane_keys = {
329
+ _lane_key(n)
330
+ for n in (*lanes.concurrent, *lanes.exclusive, *lanes.autopick,
331
+ *lanes.trees.keys(), *lanes.aliases.keys(),
332
+ "global")
333
+ }
334
+
335
+ def _redirect_why(default_when_busy: str) -> str:
336
+ """Honest parenthetical for an auto-pick redirect away from a NAMED request.
337
+
338
+ ``requested 'X' was busy`` only when X is a real lane this workspace knows
339
+ and the picker found it held; ``'X' is not a lane in this workspace`` when
340
+ the name is unknown — never the former masquerading as a diagnosis of the
341
+ latter.
342
+ """
343
+ if requested_lane and requested_lane_key not in _known_lane_keys:
344
+ return f"(requested {requested_lane!r} is not a lane in this workspace)"
345
+ return default_when_busy
346
+
347
+ live_lanes = {_lane_key(l.get("lane", "")) for l in live_leases}
348
+ live_kinds = {str(l.get("lane_kind", "")) for l in live_leases}
349
+
350
+ def _free_clusters() -> list[str]:
351
+ return [c for c in autopick_clusters if _lane_key(c) not in live_lanes]
352
+
353
+ # Concurrency-class budgets (docs/97 Phase 1). Normalize once: drop non-positive
354
+ # / non-int budgets (a budget of 0 or a garbled value would silently wedge a
355
+ # whole class — the safe direction is "no budget for that kind", matching the
356
+ # host's `_load_priority_slots` fallback-to-default-on-bad-config posture). The
357
+ # live count per kind is computed once over the passed-in leases (PURE — no I/O).
358
+ _budgets: dict[str, int] = {}
359
+ if class_budgets:
360
+ for _k, _v in class_budgets.items():
361
+ if isinstance(_v, int) and not isinstance(_v, bool) and _v > 0:
362
+ _budgets[str(_k)] = _v
363
+ _live_kind_counts: dict[str, int] = {}
364
+ if _budgets:
365
+ for _l in live_leases:
366
+ _lk = str(_l.get("lane_kind", ""))
367
+ _live_kind_counts[_lk] = _live_kind_counts.get(_lk, 0) + 1
368
+
369
+ def _budget_exhausted(kind: str) -> bool:
370
+ """True iff class ``kind`` is at (or over) its concurrency budget — the
371
+ live count of leases of this kind has reached ``max_concurrent``. Kinds
372
+ with no declared budget never exhaust (return False). The cross-lease count
373
+ a per-lease `AdmissionPredicate` structurally cannot express (admission.py),
374
+ so it lives here as the docs/97 step-1 budget gate."""
375
+ cap = _budgets.get(str(kind))
376
+ if cap is None:
377
+ return False
378
+ return _live_kind_counts.get(str(kind), 0) >= cap
379
+
380
+ _saw_any_candidate = False
381
+ _all_disjoint_were_zero = True
382
+ # Bookkeeping for the docs/97 budget refuse: True iff the ONLY thing that kept
383
+ # the bare walk from admitting a candidate was a class budget (every otherwise-
384
+ # viable candidate was budget-skipped). Distinguishes "class at budget, wait for
385
+ # a slot" from the generic "ladder exhausted / all 0-pick" refuses, so the
386
+ # operator sees the real cause (and that waiting — not /replan — is the lever).
387
+ _saw_budget_skip = False
388
+ _saw_non_budget_candidate = False
389
+ # The pick count `_admit_lane` last computed — cached so the admit DECISION
390
+ # and the `pick_count` REPORTED on the resulting LaneDecision come from the
391
+ # SAME oracle call. Re-calling `_picks` at the return site (the old code) let
392
+ # a non-deterministic / side-effecting oracle report a count that disagreed
393
+ # with the value that actually drove admission (adversarial-review finding).
394
+ _last_pick_count: int | None = None
395
+
396
+ def _picks(name: str, kind: str, tree: list[str]) -> int | None:
397
+ if pick_oracle is None:
398
+ return None
399
+ try:
400
+ n = pick_oracle(name, kind, list(tree))
401
+ except Exception:
402
+ return None # best-effort: oracle failure never blocks a lane
403
+ return n if isinstance(n, int) else None
404
+
405
+ def _safe_rank(name: str, kind: str, tree: list[str]) -> float | None:
406
+ """The value-aware picker's rank for one candidate, fail-soft (docs/91).
407
+
408
+ Mirrors `_picks`: a `rank_key` that raises or returns a non-number yields
409
+ `None` ("no opinion"), so a broken estimator degrades to ladder order and
410
+ never blocks or mis-admits a lane (the `pick_oracle` best-effort rule,
411
+ applied to ranking). PURE: the estimator does any I/O, not us.
412
+ """
413
+ if rank_key is None:
414
+ return None
415
+ try:
416
+ r = rank_key(name, kind, list(tree))
417
+ except Exception:
418
+ return None
419
+ return float(r) if isinstance(r, (int, float)) and not isinstance(r, bool) else None
420
+
421
+ def _ranked(order: list[tuple[str, str, list[str]]]) \
422
+ -> list[tuple[str, str, list[str]]]:
423
+ """Reorder the bare auto-pick candidates by descending rank (docs/91 §3).
424
+
425
+ STABLE: candidates keep their relative ladder order within an equal rank,
426
+ and a `None` rank (no opinion) sinks below every ranked candidate — so with
427
+ `rank_key is None` the list is returned UNCHANGED (every rank is `None`, the
428
+ sort key is constant, Python's stable sort is a no-op) and the walk below is
429
+ byte-identical to the legacy first-fit. Ranking only chooses the ORDER the
430
+ unchanged disjointness+availability gate is tried in; it cannot admit a
431
+ colliding lane (the soundness floor — `rank, never re-admit`).
432
+ """
433
+ if rank_key is None:
434
+ return order
435
+ # Sort key: opinionated candidates (rank is not None) first, by rank desc;
436
+ # `enumerate` index makes the sort explicitly stable for ties / no-opinion.
437
+ def _key(item: tuple[int, tuple[str, str, list[str]]]):
438
+ idx, (name, kind, tree_seq) = item
439
+ r = _safe_rank(name, kind or "cluster", list(tree_seq or []))
440
+ has = r is not None
441
+ # descending rank for the opinionated; ladder index ascending as tiebreak
442
+ return (0 if has else 1, -(r or 0.0), idx)
443
+ return [it for _, it in sorted(enumerate(order), key=_key)]
444
+
445
+ def _admit_lane(name: str, kind: str, tree: list[str]) -> bool:
446
+ """A lane that passed the concurrency check — should we admit it?
447
+
448
+ Clusters/slot/priority/derived: admit unless the oracle is CONFIDENT the
449
+ lane has 0 picks (abstain ⇒ admit). The NAMED rung is INVERTED: require a
450
+ positive pick signal — an abstain there is a skip (the oracle is blind to
451
+ named lanes' file-glob trees, so abstain ≠ "has work"). Updates the
452
+ all-skipped-on-zero bookkeeping for the refuse path.
453
+ """
454
+ nonlocal _saw_any_candidate, _all_disjoint_were_zero, _last_pick_count
455
+ _saw_any_candidate = True
456
+ n = _picks(name, kind, tree)
457
+ _last_pick_count = n # cache for the pick_count on the resulting decision
458
+ if n is None:
459
+ if kind == "named" and pick_oracle is not None:
460
+ return False
461
+ _all_disjoint_were_zero = False
462
+ return True
463
+ if n <= 0:
464
+ return False
465
+ _all_disjoint_were_zero = False
466
+ return True
467
+
468
+ # An exclusive lane is live → nothing else may start. (force respects this.)
469
+ # The exclusive set is config-declared (`cfg.lanes.exclusive`, folded into
470
+ # `exclusive_lanes`), never a hardcoded host lane name — `global` is the generic
471
+ # constant; a host's own exclusive lanes (e.g. `orchestration`) come from its
472
+ # taxonomy. Fold the live lease kinds the same way before the membership test
473
+ # (`live_kinds` is raw; `exclusive_lanes` is already case-folded).
474
+ if {_lane_key(k) for k in live_kinds} & exclusive_lanes:
475
+ held = next(l for l in live_leases
476
+ if _lane_key(l.get("lane_kind", "")) in exclusive_lanes)
477
+ return LaneDecision(
478
+ "refuse",
479
+ reason=(f"an exclusive lane is live (lane={held.get('lane')!r}, "
480
+ f"kind={held.get('lane_kind')!r}, loop="
481
+ f"{held.get('loop_ts')!r}); it touches the whole "
482
+ f"portfolio — wait for it to finish."
483
+ + (" (--force cannot override an exclusive live lane.)"
484
+ if force else "")),
485
+ free_clusters=[],
486
+ )
487
+
488
+ # OPERATOR OVERRIDE — `--force` with an explicit lane.
489
+ if force and requested_lane:
490
+ forced_kind = requested_kind or "keyword"
491
+ same_lane = requested_lane_key in live_lanes
492
+ return LaneDecision(
493
+ "acquire", lane=requested_lane, lane_kind=forced_kind,
494
+ tree=requested_tree, auto_picked=False,
495
+ reason=(f"FORCED lane {requested_lane!r} (operator --force; "
496
+ f"lane concern overridden"
497
+ + (", same-lane sibling present — concurrent edits to the "
498
+ "same tree are now the operator's responsibility"
499
+ if same_lane else "")
500
+ + (", tree is EMPTY — unknown blast radius accepted"
501
+ if not requested_tree else "")
502
+ + ")."),
503
+ )
504
+
505
+ # Same-lane collision → refuse (auto-pick can still rescue a cluster request).
506
+ if requested_lane and requested_lane_key in live_lanes:
507
+ if requested_kind == "cluster":
508
+ pass # fall through to auto-pick
509
+ else:
510
+ return LaneDecision(
511
+ "refuse",
512
+ reason=(f"lane {requested_lane!r} is already held by a live "
513
+ f"loop — pick a different --scope or wait."),
514
+ free_clusters=_free_clusters(),
515
+ )
516
+
517
+ # Exclusive-lane request → admit only when nothing else is live. `global` is
518
+ # the generic exclusive KIND every workspace has; any other exclusive lane is
519
+ # recognised via the config-declared `exclusive_lanes` set, never a hardcoded
520
+ # host name.
521
+ if requested_kind == "global" or requested_kind in exclusive_lanes or \
522
+ requested_lane_key in exclusive_lanes:
523
+ if live_leases:
524
+ return LaneDecision(
525
+ "refuse",
526
+ reason=(f"{requested_lane or 'global'!r} is an exclusive lane; "
527
+ f"{len(live_leases)} loop(s) already live. It must run "
528
+ f"alone — wait for them to finish."),
529
+ free_clusters=_free_clusters(),
530
+ )
531
+ # Record the exclusive lane's OWN name as the lease kind (so the live-kind
532
+ # exclusivity check above recognises it), falling back to the generic
533
+ # `global` kind for a bare/global request.
534
+ kind = requested_lane_key if requested_lane_key in exclusive_lanes else "global"
535
+ # Even an exclusive lane (which runs alone, so disjointness is moot) must
536
+ # pass the REQUEST-ABSOLUTE predicates — an `orchestration`/`global` lease
537
+ # whose tree rewrites the kernel's own running code is the SELF_MODIFY
538
+ # hazard, and it must refuse here too (not just on the keyword path). The
539
+ # conjunction runs against the empty-lease sentinel, so disjointness admits
540
+ # and self-modify still fires. `--force` skipped this (handled above).
541
+ verdict = _admission_verdict(
542
+ lane=requested_lane or "global", kind=kind, tree=requested_tree,
543
+ live_leases=live_leases, predicates=preds, config=cfg,
544
+ )
545
+ if not verdict.admitted:
546
+ return LaneDecision(
547
+ "refuse", reason=verdict.reason, free_clusters=_free_clusters(),
548
+ )
549
+ return LaneDecision(
550
+ "acquire", lane=requested_lane or "global", lane_kind=kind,
551
+ tree=requested_tree, auto_picked=False,
552
+ reason=f"exclusive lane {requested_lane or 'global'!r} — no other "
553
+ f"loop live, admitted.",
554
+ )
555
+
556
+ # Cluster request on a FREE lane → admit, but FIRST run the admission
557
+ # conjunction (ADM): the disjointness predicate confirms the cluster's tree is
558
+ # disjoint from every live lease (a free lane NAME can still have an
559
+ # overlapping tree), and the request-absolute predicates (self-modify, budget)
560
+ # fire regardless. Without this gate a `--kind cluster` request would bypass
561
+ # both the collision check AND the SELF_MODIFY guard — the regression the
562
+ # adversarial review caught. `--force` still skips this (handled above).
563
+ if requested_kind == "cluster" and requested_lane_key not in live_lanes:
564
+ tree = requested_tree or _cluster_tree(requested_lane)
565
+ verdict = _admission_verdict(
566
+ lane=requested_lane, kind="cluster", tree=tree,
567
+ live_leases=live_leases, predicates=preds, config=cfg,
568
+ )
569
+ if not verdict.admitted:
570
+ return LaneDecision(
571
+ "refuse", reason=verdict.reason, free_clusters=_free_clusters(),
572
+ )
573
+ return LaneDecision(
574
+ "acquire", lane=requested_lane, lane_kind="cluster",
575
+ tree=tree, auto_picked=False,
576
+ reason=f"cluster lane {requested_lane!r} free — admitted.",
577
+ )
578
+
579
+ # Keyword request with a NON-EMPTY tree → run the admission conjunction
580
+ # (disjointness + self-modify + any workspace predicate). First refusal wins;
581
+ # the disjointness predicate reproduces the old soft-overlap verdict exactly,
582
+ # so a disjoint keyword lane still admits — while a self-modifying one now
583
+ # refuses with the typed SELF_MODIFY reason it could not carry before.
584
+ if requested_kind == "keyword" and requested_tree:
585
+ verdict = _admission_verdict(
586
+ lane=requested_lane, kind="keyword", tree=requested_tree,
587
+ live_leases=live_leases, predicates=preds, config=cfg,
588
+ )
589
+ if not verdict.admitted:
590
+ reason = f"{verdict.reason} Use a free cluster lane instead."
591
+ return LaneDecision(
592
+ "refuse", reason=reason, free_clusters=_free_clusters(),
593
+ )
594
+ return LaneDecision(
595
+ "acquire", lane=requested_lane, lane_kind="keyword",
596
+ tree=requested_tree, auto_picked=False,
597
+ reason=(f"keyword lane {requested_lane!r} admitted (disjoint or "
598
+ f"under the soft-overlap threshold vs all "
599
+ f"{len(live_leases)} live lease(s))."),
600
+ )
601
+
602
+ # Keyword request whose tree is EMPTY → degrade to "take any open plan".
603
+ unresolved_keyword = requested_kind == "keyword" and not requested_tree
604
+
605
+ # UNKNOWN_LANE (docs/104 §4, control-flow arm). An empty-tree keyword splits
606
+ # into two epistemically DIFFERENT requests that the old code conflated:
607
+ # (a) a name THIS workspace knows (a real lane/alias whose live plan just
608
+ # isn't running right now) → legitimate "I wanted that, fall through to
609
+ # auto-pick" — the `_unresolved_suffix` degrade below is correct.
610
+ # (b) a name the taxonomy has NEVER heard of (`playbooks`, a typo, a foreign
611
+ # lane) → the operator asserted a SPECIFIC concern the kernel cannot
612
+ # place. Silently auto-picking a DIFFERENT free lane (CID) is the docs/103
613
+ # disease turned inward: the kernel narrates `acquire` for a region it was
614
+ # not asked about, and the lease then describes the WRONG tree — so
615
+ # disjointness is computed against a region the agent will not touch (a
616
+ # soundness hole, not just a misleading reason). Auto-pick's license is
617
+ # "you expressed NO preference"; it does not extend to substituting a
618
+ # concern you named. The honest verdict is a typed refuse that surfaces
619
+ # the lanes this workspace actually knows — never a guess.
620
+ # This is the control-flow twin of `_redirect_why` (which already fixed the
621
+ # *reason string* for the busy-redirect path but left the DEGRADE in place).
622
+ if (unresolved_keyword and requested_lane
623
+ and requested_lane_key not in _known_lane_keys):
624
+ _known_sorted = sorted(
625
+ {n for n in (*lanes.concurrent, *lanes.exclusive, *lanes.autopick)})
626
+ return LaneDecision(
627
+ "refuse",
628
+ reason=(
629
+ f"UNKNOWN_LANE: {requested_lane!r} is not a lane in this "
630
+ f"workspace, so the kernel will not guess a substitute for it "
631
+ f"(auto-pick only chooses when you express NO preference). "
632
+ f"Known lanes: {', '.join(_known_sorted) or '(none)'}. "
633
+ f"Pass one of those as --scope, run a bare invocation to "
634
+ f"auto-pick any free lane, or register {requested_lane!r} as a "
635
+ f"lane in dos.toml."),
636
+ free_clusters=_free_clusters(),
637
+ )
638
+
639
+ bare = (not requested_lane) or unresolved_keyword
640
+ _unresolved_suffix = (
641
+ f" (requested --scope {requested_lane!r} matched no live plan — "
642
+ f"degraded to auto-pick)" if unresolved_keyword else "")
643
+
644
+ # FQ-449 single-pick-ceiling: the bare auto-pick PREFERS a ladder lane whose
645
+ # tree is provably disjoint from every un-leased live SIBLING, falling back to
646
+ # the unchanged walk only if none is. Active only when the host passed
647
+ # `live_siblings` + `sibling_tree_lookup`; otherwise byte-identical to the
648
+ # pre-fix first-fit. (A lease is already gated by `_lease_collision`; a sibling
649
+ # holds no lease yet but will collide post-acquire — without this, the bare loop
650
+ # picks the top lane, acquires it, then self-arrests at the Step-0 sibling-scan,
651
+ # shipping at most one pick. Spreading the fleet across disjoint lanes at
652
+ # SELECTION time is the structural fix.)
653
+ _sibling_filter_active = bool(live_siblings) and sibling_tree_lookup is not None
654
+
655
+ def _bare_pass(*, require_sibling_disjoint: bool) -> LaneDecision | None:
656
+ """One walk of the bare auto-pick ladder; returns a decision or None.
657
+
658
+ The legacy first-disjoint-wins loop verbatim, with ONE added gate when
659
+ `require_sibling_disjoint` is True: a candidate that passes the lease
660
+ disjointness + availability gates must ALSO be disjoint from every live
661
+ sibling, else it is skipped. Re-establishes the refuse-path bookkeeping
662
+ from scratch on each call (so whichever pass falls through last leaves the
663
+ canonical flags the refuse branches read). The `nonlocal` set mirrors the
664
+ flags the original inline loop mutated.
665
+ """
666
+ nonlocal _saw_budget_skip, _saw_non_budget_candidate
667
+ nonlocal _saw_any_candidate, _all_disjoint_were_zero, _last_pick_count
668
+ _saw_budget_skip = False
669
+ _saw_non_budget_candidate = False
670
+ _saw_any_candidate = False
671
+ _all_disjoint_were_zero = True
672
+ _last_pick_count = None
673
+ # `_ranked` reorders by descending rank when a `rank_key` is supplied,
674
+ # leaving the list UNCHANGED otherwise — so over the reordered list this
675
+ # returns the highest-RANKED lane that is also disjoint+available, and with
676
+ # no `rank_key` it is exactly the old first-fit. Ranking picks the order;
677
+ # the gate still decides.
678
+ ranked_picked = rank_key is not None
679
+ for name, kind, tree_seq in _ranked(auto_pick_order):
680
+ tree_list = list(tree_seq or [])
681
+ if not tree_list:
682
+ continue
683
+ if name in live_lanes:
684
+ continue
685
+ # docs/97 Phase 1 — class budget gate, BEFORE the disjointness
686
+ # check: a candidate whose kind is already at its `max_concurrent`
687
+ # is skipped regardless of whether its tree would be disjoint, so
688
+ # the arbiter never mints an (N+1)-th holder of a budgeted class.
689
+ # The check is on `kind or "cluster"` to match the kind the admit
690
+ # path below leases under (an empty kind defaults to "cluster").
691
+ _cand_kind = kind or "cluster"
692
+ if _budget_exhausted(_cand_kind):
693
+ _saw_budget_skip = True
694
+ continue
695
+ _saw_non_budget_candidate = True
696
+ if not _lease_collision(
697
+ lane=name, kind=_cand_kind, tree=tree_list,
698
+ live_leases=live_leases, predicates=preds, config=cfg):
699
+ # FQ-449 first pass: also require sibling-disjointness. A candidate
700
+ # that overlaps a live sibling is skipped here so a LATER ladder
701
+ # lane (disjoint from both leases AND siblings) can win; the second
702
+ # pass (require=False) re-admits it as the unchanged last resort.
703
+ if require_sibling_disjoint and not _disjoint_from_all_live(
704
+ requested_tree=tree_list,
705
+ live=list(live_siblings or []),
706
+ sibling_tree_lookup=sibling_tree_lookup):
707
+ continue
708
+ if not _admit_lane(name, _cand_kind, tree_list):
709
+ continue
710
+ return LaneDecision(
711
+ "acquire", lane=name, lane_kind=_cand_kind,
712
+ tree=tree_list, auto_picked=True,
713
+ reason=(f"auto-picked {_cand_kind} lane {name!r} "
714
+ + ("by value-aware rank" if ranked_picked
715
+ else "from priority ladder")
716
+ + (" (disjoint from all live siblings)"
717
+ if require_sibling_disjoint else "")
718
+ + "." + _unresolved_suffix),
719
+ pick_count=_last_pick_count, # same call that drove admission
720
+ )
721
+ return None
722
+
723
+ if auto_pick_order is not None:
724
+ if bare:
725
+ if _sibling_filter_active:
726
+ _decided = _bare_pass(require_sibling_disjoint=True)
727
+ if _decided is not None:
728
+ return _decided
729
+ # No sibling-disjoint lane on the ladder — fall back to the
730
+ # unchanged walk (which may pick a sibling-overlapping lane, exactly
731
+ # today's behavior; the post-acquire sibling-scan then handles it).
732
+ _decided = _bare_pass(require_sibling_disjoint=False)
733
+ if _decided is not None:
734
+ return _decided
735
+ else:
736
+ for cand in autopick_clusters:
737
+ if cand in live_lanes:
738
+ continue
739
+ cand_tree = _cluster_tree(cand)
740
+ if not _lease_collision(
741
+ lane=cand, kind="cluster", tree=cand_tree,
742
+ live_leases=live_leases, predicates=preds, config=cfg):
743
+ if not _admit_lane(cand, "cluster", cand_tree):
744
+ continue
745
+ return LaneDecision(
746
+ "acquire", lane=cand, lane_kind="cluster",
747
+ tree=cand_tree, auto_picked=True,
748
+ reason=(f"auto-picked free cluster lane {cand!r} "
749
+ + _redirect_why(
750
+ f"(requested {requested_lane!r} was busy)")
751
+ + "."),
752
+ pick_count=_last_pick_count, # same call that drove admission
753
+ )
754
+
755
+ # docs/97 Phase 1 — the class-budget refuse, FIRST (most specific cause).
756
+ # When the bare walk admitted nothing AND every candidate it would have
757
+ # tried was budget-skipped (no candidate failed for any OTHER reason), the
758
+ # binding constraint is a concurrency budget, not drained work or a tree
759
+ # collision. Surface that honestly: the lever is "wait for a slot of this
760
+ # class to free" — NOT /replan (the work exists; the class is just full) and
761
+ # NOT --scope (a scoped request of the same kind hits the same budget). The
762
+ # CLASS_BUDGET_EXHAUSTED token mirrors docs/97's named refuse so a downstream
763
+ # cause-classifier can route it distinctly from DRAIN / ladder-exhausted.
764
+ if _saw_budget_skip and not _saw_non_budget_candidate:
765
+ _at = sorted(
766
+ f"{k} ({_live_kind_counts.get(k, 0)}/{_budgets[k]})"
767
+ for k in _budgets if _budget_exhausted(k)
768
+ )
769
+ return LaneDecision(
770
+ "refuse",
771
+ reason=("CLASS_BUDGET_EXHAUSTED: every auto-pick candidate belongs "
772
+ "to a concurrency class already at its max_concurrent "
773
+ f"budget ({', '.join(_at)}) — admitting one would exceed the "
774
+ "budget. The work exists and the regions are fine; the class "
775
+ "is simply full. Wait for a holder of that class to release "
776
+ "(do NOT /replan — there is nothing to refill), or raise the "
777
+ "class budget if the concurrency is genuinely safe."
778
+ + _unresolved_suffix),
779
+ free_clusters=[c for c in autopick_clusters if c not in live_lanes],
780
+ )
781
+
782
+ if _saw_any_candidate and _all_disjoint_were_zero:
783
+ return LaneDecision(
784
+ "refuse",
785
+ reason=("every concurrency-free lane on the priority ladder has "
786
+ "0 pickable phases right now (all soak-gated / sibling-"
787
+ "gated / already claimed) — leasing one would only DRAIN. "
788
+ "Refusing at Step 0 instead. Run /replan, wait for an open "
789
+ "soak window to close, or pass --scope <lane-with-work>."
790
+ + _unresolved_suffix),
791
+ free_clusters=[c for c in autopick_clusters if c not in live_lanes],
792
+ pick_count=0,
793
+ )
794
+
795
+ return LaneDecision(
796
+ "refuse",
797
+ reason=("priority ladder exhausted; no lane is free with a tree "
798
+ "disjoint from every live lease. Wait for one to release, "
799
+ "or pass --scope <free-lane> explicitly."
800
+ + _unresolved_suffix),
801
+ free_clusters=[c for c in autopick_clusters if c not in live_lanes],
802
+ )
803
+
804
+ # ── LEGACY PATH — no ladder supplied. Three-rung fallback. ──────────────
805
+ for cand in autopick_clusters:
806
+ if cand in live_lanes:
807
+ continue
808
+ cand_tree = _cluster_tree(cand)
809
+ if not _lease_collision(
810
+ lane=cand, kind="cluster", tree=cand_tree,
811
+ live_leases=live_leases, predicates=preds, config=cfg):
812
+ if unresolved_keyword:
813
+ why = _unresolved_suffix.strip()
814
+ elif requested_lane:
815
+ why = _redirect_why(f"(requested {requested_lane!r} was busy)")
816
+ else:
817
+ why = "(bare invocation)"
818
+ return LaneDecision(
819
+ "acquire", lane=cand, lane_kind="cluster",
820
+ tree=cand_tree, auto_picked=True,
821
+ reason=f"auto-picked free cluster lane {cand!r} {why}.",
822
+ )
823
+ if bare:
824
+ for name, tree_tup in (named_lanes or ()):
825
+ tree_list = list(tree_tup)
826
+ if not tree_list or name in live_lanes:
827
+ continue
828
+ if not _lease_collision(
829
+ lane=name, kind="named", tree=tree_list,
830
+ live_leases=live_leases, predicates=preds, config=cfg):
831
+ return LaneDecision(
832
+ "acquire", lane=name, lane_kind="named",
833
+ tree=tree_list, auto_picked=True,
834
+ reason=(f"auto-picked named non-cluster lane {name!r} "
835
+ f"(legacy fallback path)."),
836
+ )
837
+ for plan_id, tree_list in (derived_lanes or []):
838
+ if not tree_list or plan_id in live_lanes:
839
+ continue
840
+ if not _lease_collision(
841
+ lane=plan_id, kind="derived", tree=list(tree_list),
842
+ live_leases=live_leases, predicates=preds, config=cfg):
843
+ return LaneDecision(
844
+ "acquire", lane=plan_id, lane_kind="derived",
845
+ tree=list(tree_list), auto_picked=True,
846
+ reason=(f"auto-picked derived plan lane {plan_id!r} "
847
+ f"(legacy fallback path)."),
848
+ )
849
+ return LaneDecision(
850
+ "refuse",
851
+ reason=("all concurrent cluster lanes are held by live loops — no free "
852
+ "lane to auto-pick. Wait for one to finish, then re-invoke."
853
+ + _unresolved_suffix),
854
+ free_clusters=[],
855
+ )
856
+
857
+
858
+ # Back-compat alias: the origin function was named `arbitrate_lane`.
859
+ arbitrate_lane = arbitrate