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/_tree.py ADDED
@@ -0,0 +1,145 @@
1
+ """Pure file-tree prefix algebra — the shared normalization the lane arbiter
2
+ and the overlap policy both stand on.
3
+
4
+ Lifted byte-for-byte (logic-identical) from the origin repo's
5
+ `scripts/next_up_render.py` (`_norm_tree_prefix`, `lane_trees_disjoint`). Those
6
+ two functions are the *only* part of the 3,326-line `next_up_render` the arbiter
7
+ actually needed; the rest is the reference userland app's operator-facing
8
+ `/next-up` rendering, which stays host-side. Pulling these two pure helpers into
9
+ their own leaf module
10
+ (rather than dragging `next_up_render`) is the §4 "port the spine, not the prose"
11
+ discipline applied at function granularity.
12
+
13
+ A *lane* owns a set of repo-relative path globs — its *tree*. Two lanes are safe
14
+ to run concurrently only when their trees are pairwise disjoint at the
15
+ directory-prefix level: no normalized prefix of one is a prefix of the other.
16
+
17
+ This is **predicate/range locking**, not swim-lane separation: a lane is a leased
18
+ predicate-lock over a region of the workspace, and `prefixes_collide` below is the
19
+ (conservative, decidable) predicate-intersection test it admits on — general
20
+ predicate-satisfiability being undecidable, the prefix rule over-approximates it.
21
+ See `docs/89_the-lane-is-a-region-lock.md`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Callable, Optional
27
+
28
+
29
+ def norm_tree_prefix(p: str) -> str:
30
+ """Normalize one tree entry to a comparable directory prefix.
31
+
32
+ ``agents/apply_*.py`` -> ``agents/apply_``; ``go/internal/ui/`` ->
33
+ ``go/internal/ui/``; ``job_search/scoring.py`` -> ``job_search/scoring.py``.
34
+ A glob is truncated at the first ``*`` because everything after it is a
35
+ wildcard — two entries that share a pre-``*`` prefix can name the same file.
36
+
37
+ A **leading-glob** entry like ``**/*`` or ``*.py`` truncates to the EMPTY
38
+ prefix ``""`` — there is no pre-``*`` directory to anchor on. The empty
39
+ prefix is the *universal* prefix: every path starts with it, so it matches
40
+ **everything**. Callers must not silently drop it (that inverts a whole-repo
41
+ tree into "touches nothing"); see `prefixes_collide`.
42
+
43
+ **Case is folded** (``str.casefold``) so the prefix algebra matches the
44
+ semantics of a case-INsensitive filesystem — DOS's documented primary platform
45
+ is Windows, where ``Core/Engine/run.py`` and ``core/engine/run.py`` are the
46
+ SAME file. Without folding, the case-sensitive ``startswith`` in
47
+ `prefixes_collide` judges those two as disjoint, so two lanes editing one real
48
+ file would both be admitted a lease (a false-ADMIT → concurrent writes to one
49
+ file → corruption) and the SELF_MODIFY guard would be bypassable by mixed-case
50
+ paths (``SRC/dos/arbiter.py`` slips past). Folding is **unconditional** (not
51
+ branched on ``os.name``): a lane tree authored on one platform must collide
52
+ identically when the kernel runs on another (deterministic CI), and on a truly
53
+ case-sensitive FS treating two case-variants as colliding is a HARMLESS
54
+ over-refusal — exactly the safe, conservative over-approximation direction this
55
+ module already embraces (`lane_trees_disjoint`'s empty-tree rule). It does NOT
56
+ weaken the filename-prefix discrimination the workshop driver relies on
57
+ (``docs/ui-`` vs ``docs/svc-`` stay distinct after folding); it only ADDS the
58
+ case-variant collisions a case-insensitive FS demands.
59
+ """
60
+ p = (p or "").replace("\\", "/").strip().casefold()
61
+ star = p.find("*")
62
+ if star != -1:
63
+ return p[:star]
64
+ return p
65
+
66
+
67
+ def prefixes_collide(a: str, b: str) -> bool:
68
+ """True iff two normalized prefixes can name the same file.
69
+
70
+ The single definition of "these two tree prefixes overlap," shared by every
71
+ collision check in the kernel (`lane_trees_disjoint`, `lane_overlap`, the
72
+ self-modify guard) so they cannot drift apart — the drift that let
73
+ `lane_overlap` call two ``**/*`` lanes "fully disjoint" while
74
+ `lane_trees_disjoint` (correctly) called them overlapping.
75
+
76
+ Two prefixes collide when one is a prefix of the other (the original rule).
77
+ The **empty prefix** (`""`, from a leading-glob like ``**/*``) is the
78
+ universal prefix — it collides with *everything*, including another empty
79
+ prefix — because ``"".startswith(x)`` is only true for ``x == ""`` but
80
+ ``x.startswith("")`` is true for all ``x``. The asymmetry is handled here so
81
+ every caller treats a whole-repo glob as the maximal blast radius it is.
82
+ """
83
+ return a.startswith(b) or b.startswith(a)
84
+
85
+
86
+ def lane_trees_disjoint(tree_a: list[str], tree_b: list[str]) -> bool:
87
+ """True when two lane file trees cannot edit the same file.
88
+
89
+ **Conservative-by-design — an empty tree is treated as NOT disjoint.** An
90
+ empty tree is an *unknown* blast radius, not a *zero* one, so this returns
91
+ ``False`` (unsafe / overlapping) when either tree is empty: the caller must
92
+ refuse a concurrent admission rather than assume the lane touches nothing.
93
+ """
94
+ if not tree_a or not tree_b:
95
+ # Unknown blast radius — refuse. See the docstring.
96
+ return False
97
+ norm_a = [norm_tree_prefix(p) for p in tree_a if p]
98
+ norm_b = [norm_tree_prefix(p) for p in tree_b if p]
99
+ if not norm_a or not norm_b:
100
+ return False
101
+ for na in norm_a:
102
+ for nb in norm_b:
103
+ if na.startswith(nb) or nb.startswith(na):
104
+ return False
105
+ return True
106
+
107
+
108
+ def tree_disjoint_from_all_live(
109
+ *,
110
+ requested_tree: list[str],
111
+ live: list[dict],
112
+ sibling_tree_lookup: Callable[[str], Optional[list[str]]],
113
+ ) -> bool:
114
+ """True iff ``requested_tree`` is provably disjoint from EVERY live sibling.
115
+
116
+ The shared "can this region run alongside everything currently live" predicate
117
+ — the same posture as `lane_trees_disjoint`, lifted from `sibling_scan`'s own
118
+ `_disjoint_from_all_live` so the lane ARBITER (selection-time) and the SIBLING
119
+ SCAN (post-acquire escape) prove disjointness through one definition and cannot
120
+ drift apart. Conservative on three counts, each mapping an *unknown* to "cannot
121
+ prove disjoint → not safe":
122
+
123
+ * a sibling whose ``lane`` is empty/unknown → no resolvable tree → unknown
124
+ blast radius → NOT provably disjoint (returns ``False``). A read-only
125
+ activity-class sibling (an un-leased ``/replan``) must be filtered out
126
+ UPSTREAM by the caller, not waved through on an empty tree.
127
+ * a sibling whose tree resolves empty (lookup miss) → unknown → ``False``.
128
+ * any sibling whose tree OVERLAPS the requested tree → ``False``.
129
+
130
+ Only when every live sibling has a known, non-empty, disjoint tree is it safe to
131
+ run concurrently. An empty ``live`` (no siblings) is vacuously disjoint → ``True``.
132
+ """
133
+ for s in live:
134
+ lane = str(s.get("lane") or "")
135
+ if not lane:
136
+ return False # unknown blast radius — cannot prove disjoint
137
+ try:
138
+ tree = list(sibling_tree_lookup(lane) or [])
139
+ except Exception:
140
+ tree = []
141
+ if not tree:
142
+ return False # tree did not resolve — unknown — not safe
143
+ if not lane_trees_disjoint(list(requested_tree), tree):
144
+ return False # provable overlap
145
+ return True
dos/admission.py ADDED
@@ -0,0 +1,433 @@
1
+ """The admission-predicate seam — Axis 3 of hackability: pluggable safety hooks (ADM, docs/73).
2
+
3
+ The arbiter's admission logic — `_lease_blocks` + the ≤30 % soft-overlap
4
+ tree-disjointness rule (`lane_overlap.overlap_verdict`) — is the kernel's
5
+ **safety element**: it is what stops two agents editing the same files
6
+ concurrently. That logic used to be fixed. A workspace could not add its own
7
+ admission rule ("refuse a new lease when over the monthly token budget,"
8
+ "refuse a lease that would touch the orchestrator's own running code") without
9
+ forking the arbiter.
10
+
11
+ This module is the seam that lets it *register* one instead. An admission
12
+ predicate is a pure callable ``(request, live_lease, config) -> AdmissionVerdict``
13
+ resolved from the ``dos.predicates`` entry-point group (Phase 3). The arbiter
14
+ runs the built-in disjointness predicate **plus** any registered ones.
15
+
16
+ The one invariant that makes an *open* predicate set safe: **conjunctive-only**
17
+ =====================================================================================
18
+
19
+ This is the highest-risk axis — a buggy predicate that *loosens* admission could
20
+ let two agents collide, the exact failure the arbiter exists to prevent. The
21
+ guardrail is structural, not careful coding:
22
+
23
+ > **A predicate may only REFUSE. It can never force-admit over a built-in
24
+ > refusal.** Predicates compose conjunctively: admission requires the built-in
25
+ > disjointness check **and** every registered predicate to admit. Adding a
26
+ > predicate can only make admission *stricter*, never looser.
27
+
28
+ So the worst a buggy/malicious predicate can do is refuse too much (a visible,
29
+ safe-direction failure an operator notices immediately), never admit a collision.
30
+ The ``--force`` operator override stays the *only* thing that can overrule a
31
+ refusal — a predicate refusal is overridable by ``--force`` the same way a
32
+ disjointness refusal is; a predicate cannot itself force anything. There is
33
+ deliberately no return value that forces admission (`AdmissionVerdict` has only
34
+ ``.admit()`` / ``.refuse(reason)`` — no "admit harder"), so the conjunctive-only
35
+ guarantee is enforced by the *shape of the type*, not by reviewer vigilance.
36
+
37
+ Purity & fail-closed
38
+ ====================
39
+
40
+ A predicate is **pure**, exactly like the arbiter it runs inside (`arbiter.py`
41
+ "No I/O — `live_leases` is passed in, the decision is returned"): any I/O it
42
+ needs (reading a token-budget file) happens *before* the call, with the result
43
+ passed in via ``config`` or a pre-computed input — never inside the predicate
44
+ during arbitration. This mirrors how `pick_oracle` already does its I/O outside
45
+ the arbiter.
46
+
47
+ A predicate that *raises* is caught and converted to a **refuse** naming the
48
+ predicate (fail-closed) — the safe direction for a safety hook. This is the
49
+ *inverse* of the renderer rule (a renderer that raises degrades to ugly text,
50
+ because presentation is downstream of the kernel and can never mis-decide) and
51
+ is deliberate: a safety predicate that cannot answer must not admit. This is the
52
+ same posture as the design-law "oracle failure can only ADD refusals, never
53
+ remove one."
54
+
55
+ Pure stdlib + the kernel leaves it delegates to (`lane_overlap`) — no I/O, no
56
+ host names — so it sits in the kernel layer beside `arbiter`.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import sys
62
+ from dataclasses import dataclass
63
+ from typing import Protocol, runtime_checkable
64
+
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class AdmissionVerdict:
69
+ """One predicate's answer: admit, or refuse with a reason.
70
+
71
+ Frozen and two-valued by design — there is no "force admit" constructor, so
72
+ a predicate is *structurally* incapable of overriding another's refusal
73
+ (the conjunctive-only invariant, enforced by the type). The boolean state is
74
+ read via the ``admitted`` property; the two constructors are ``.admit()`` and
75
+ ``.refuse(reason)`` — the exact spelling the plan's north-star uses.
76
+
77
+ ``reason`` is the operator-facing string a refusal carries (empty on an
78
+ admit). ``reason_class`` optionally carries a typed `reason_class` token (a
79
+ ``dos.reasons`` registry token, e.g. ``SELF_MODIFY``) so a refusal is not
80
+ just prose but a verifiable/refusable/`dos man`-documented reason — the
81
+ Axis-1 mechanism. Built-in predicates set it; a workspace predicate may
82
+ leave it empty (its prose ``reason`` still surfaces).
83
+
84
+ The stored field is named ``_admit`` (private) so the ergonomic ``.admit()``
85
+ CONSTRUCTOR and the ``.admitted`` accessor do not collide with it — a public
86
+ field named ``admit`` would shadow the classmethod of the same name. Callers
87
+ read ``v.admitted`` (or just ``if not v.admitted``), never the underscore.
88
+ """
89
+
90
+ _admit: bool
91
+ reason: str = ""
92
+ reason_class: str = ""
93
+
94
+ @property
95
+ def admitted(self) -> bool:
96
+ """True iff this verdict admits. The public read accessor for the state."""
97
+ return self._admit
98
+
99
+ @classmethod
100
+ def admit(cls) -> "AdmissionVerdict":
101
+ """An admit verdict — the predicate raised no objection to this lease."""
102
+ return cls(_admit=True)
103
+
104
+ @classmethod
105
+ def refuse(cls, reason: str, *, reason_class: str = "") -> "AdmissionVerdict":
106
+ """A refuse verdict carrying an operator-facing ``reason`` (and an
107
+ optional typed ``reason_class`` token). The ONLY non-admit constructor —
108
+ there is deliberately no force-admit (the conjunctive-only invariant)."""
109
+ return cls(_admit=False, reason=reason, reason_class=reason_class)
110
+
111
+
112
+ @runtime_checkable
113
+ class AdmissionPredicate(Protocol):
114
+ """The contract a workspace implements to add an admission rule.
115
+
116
+ ``name`` is the human label `dos doctor` lists and a fail-closed refusal
117
+ names. ``__call__`` is pure: it is handed the requested lease (lane/kind/
118
+ tree), ONE already-live lease to check against, and the active config, and
119
+ returns an `AdmissionVerdict`. It must do NO I/O — any data it needs is
120
+ pre-computed and read off ``config`` (or a field the caller cached there).
121
+
122
+ A predicate is called once per (request, live_lease) pair, the same shape
123
+ the built-in disjointness check has (it compares the request against each
124
+ live lease). A predicate that does not care about a specific lease admits.
125
+ """
126
+
127
+ name: str
128
+
129
+ def __call__(self, request: "AdmissionRequest", live_lease: dict,
130
+ config: object) -> AdmissionVerdict:
131
+ ...
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class AdmissionRequest:
136
+ """The requested lease, as the pure datum a predicate sees.
137
+
138
+ A small frozen value (not the arbiter's loose kwargs) so a predicate has a
139
+ stable, documented shape to read — ``lane`` / ``kind`` / ``tree`` — without
140
+ being handed the arbiter's internals. Built by `arbiter.arbitrate` from its
141
+ ``requested_*`` args just before the predicate sweep.
142
+ """
143
+
144
+ lane: str
145
+ kind: str
146
+ tree: tuple[str, ...]
147
+
148
+
149
+ class DisjointnessPredicate:
150
+ """The built-in tree-disjointness predicate — today's fixed admission rule,
151
+ now the FIRST registered predicate.
152
+
153
+ Delegates the both-known scoring to a resolved `overlap_policy.OverlapPolicy`
154
+ (default `PrefixOverlapPolicy`, AND-ed under the deterministic prefix floor),
155
+ while owning the empty-tree asymmetry itself. With the default policy the
156
+ floor-AND reproduces `lane_overlap.overlap_verdict` exactly, so routing the
157
+ arbiter's collision check through `run_predicates([DisjointnessPredicate()])`
158
+ stays byte-for-byte behavior-preserving (the load-bearing litmus: the entire
159
+ existing arbiter/overlap suite is green through this path).
160
+
161
+ The **empty-tree rules** (asymmetric on the lease side) are owned HERE, not in
162
+ the policy — they are soundness invariants about *unknown blast radius*, not a
163
+ *scoring* choice, so a swappable scorer never sees them (it cannot weaken the
164
+ unknown-blast-radius refusal). Reproduced verbatim from `arbiter._lease_blocks`:
165
+ * empty LEASE tree → does NOT block (a lease naming no blast radius cannot
166
+ claim conflict).
167
+ * empty REQUESTED tree vs a KNOWN lease tree → blocks (unknown blast
168
+ radius is never safe).
169
+ * both empty → does NOT block (lone-loop safe).
170
+ * both known → delegate to the policy via `admissible_under_floor`.
171
+
172
+ ``policy`` is the scorer for the both-known case. It defaults to the built-in
173
+ `PrefixOverlapPolicy` (pure, no I/O — so a `DisjointnessPredicate()` with no
174
+ args is pure and byte-identical to the old inline rule). A boundary caller
175
+ (`built_in_predicates`) resolves a workspace's declared `dos.overlap_policies`
176
+ plugin and passes it in here — the resolve-at-the-boundary, I/O-free-hot-path
177
+ discipline `SelfModifyPredicate`'s `runtime_files` already uses. Whatever the
178
+ policy is, `admissible_under_floor` AND-s it under the unforgeable prefix floor,
179
+ so a misbehaving policy can only refuse-more, never admit a collision.
180
+ """
181
+
182
+ name = "disjointness"
183
+
184
+ def __init__(self, policy=None) -> None:
185
+ # Lazy import keeps the DAG (`overlap_policy` imports `lane_overlap`, the
186
+ # same leaf `admission` already imports — no cycle, but keep it local so a
187
+ # default-constructed predicate has zero extra import cost on the hot path).
188
+ if policy is None:
189
+ from dos.overlap_policy import PrefixOverlapPolicy
190
+ policy = PrefixOverlapPolicy()
191
+ self._policy = policy
192
+
193
+ def __call__(self, request: AdmissionRequest, live_lease: dict,
194
+ config: object) -> AdmissionVerdict:
195
+ from dos.overlap_policy import admissible_under_floor
196
+ requested_tree = list(request.tree)
197
+ lease_tree = list(live_lease.get("tree") or [])
198
+ if not lease_tree:
199
+ return AdmissionVerdict.admit()
200
+ if not requested_tree:
201
+ return AdmissionVerdict.refuse(
202
+ f"lane {request.lane!r} has an EMPTY tree (unknown blast "
203
+ f"radius) and cannot share live lane "
204
+ f"{live_lease.get('lane')!r} — unknown blast radius is never "
205
+ f"safe to admit concurrently."
206
+ )
207
+ ov = admissible_under_floor(self._policy, requested_tree, lease_tree, config)
208
+ if ov.admissible:
209
+ return AdmissionVerdict.admit()
210
+ return AdmissionVerdict.refuse(
211
+ f"lane {request.lane!r} cannot share live lane "
212
+ f"{live_lease.get('lane')!r}: {ov.reason}."
213
+ )
214
+
215
+
216
+ def run_predicates(
217
+ predicates: list[AdmissionPredicate],
218
+ request: AdmissionRequest,
219
+ live_leases: list[dict],
220
+ config: object,
221
+ ) -> AdmissionVerdict:
222
+ """Run the conjunction: every predicate against every live lease.
223
+
224
+ Returns the **first refusal** encountered (conjunctive — first refuse wins,
225
+ the conjunction short-circuits) or an admit if every predicate admits
226
+ against every live lease. The order is stable and documented: for each live
227
+ lease in turn, every predicate in ``predicates`` order is consulted; the
228
+ first ``refuse`` returned is the verdict. (Lease-outer / predicate-inner
229
+ mirrors the arbiter's inline per-lease sweep — `_lease_blocks` was checked
230
+ for each live lease — so the FIRST refusing lease is reported, the same lease
231
+ the inline code would have named.)
232
+
233
+ A predicate that **raises** — OR returns anything that is not an
234
+ `AdmissionVerdict` (a buggy plugin returning ``None`` / a dict / a look-alike
235
+ object) — is caught and converted to a refuse naming the predicate
236
+ (fail-closed): a safety hook that cannot give a well-typed answer must not
237
+ admit. This NEVER propagates the exception and NEVER trusts a foreign object's
238
+ truthiness: a buggy predicate degrades to a (safe-direction) refusal, it never
239
+ crashes arbitration and never sneaks an admit through a duck-typed
240
+ ``.admitted``. The type check is what makes "a predicate can only refuse"
241
+ hold even against a predicate that does not return our type at all.
242
+
243
+ With ``live_leases == []`` there is no lease to compare against, but the
244
+ conjunction is NOT skipped: it runs once against a synthetic empty lease
245
+ (``{}``) so that **request-absolute** predicates — ones that refuse based on
246
+ the request alone, like `SelfModifyPredicate` (a self-modifying lease is a
247
+ hazard whether or not anything else is live) — still fire on an otherwise
248
+ idle repo. **Lease-relative** predicates (like `DisjointnessPredicate`) see
249
+ the empty lease, hit their "empty lease tree ⇒ admit" branch, and contribute
250
+ nothing — so a free lane with no leases still admits, exactly as before. This
251
+ closes the idle-repo gap the adversarial review found: SELF_MODIFY is no
252
+ longer silently bypassed when ``live_leases`` is empty. (A workspace predicate
253
+ that wants to ignore the no-lease case simply admits when ``live_lease`` is
254
+ falsy — `BudgetGuard` and `SelfModifyPredicate` both answer from the request,
255
+ so they are unaffected by the empty sentinel.)
256
+ """
257
+ leases = live_leases if live_leases else [{}]
258
+ for lease in leases:
259
+ for pred in predicates:
260
+ name = getattr(pred, "name", type(pred).__name__)
261
+ try:
262
+ verdict = pred(request, lease, config)
263
+ except Exception as e: # fail-closed: a predicate that raises refuses
264
+ return AdmissionVerdict.refuse(
265
+ f"admission predicate {name!r} raised ({e!r}) — refusing "
266
+ f"fail-closed (a safety hook that cannot answer must not "
267
+ f"admit).",
268
+ )
269
+ # A predicate MUST return our `AdmissionVerdict`. Anything else (None,
270
+ # a dict, a duck-typed look-alike) is fail-closed-refused — we never
271
+ # consult a foreign object's `.admitted`, so no admit can leak through
272
+ # a wrong return type (the conjunctive-only invariant must hold even
273
+ # for a predicate that ignores the contract entirely).
274
+ if not isinstance(verdict, AdmissionVerdict):
275
+ return AdmissionVerdict.refuse(
276
+ f"admission predicate {name!r} returned a "
277
+ f"{type(verdict).__name__}, not an AdmissionVerdict — "
278
+ f"refusing fail-closed (a predicate that does not return the "
279
+ f"verdict type cannot be trusted to admit).",
280
+ )
281
+ if not verdict.admitted:
282
+ return verdict
283
+ return AdmissionVerdict.admit()
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Phase 3 — workspace predicate discovery via the `dos.predicates` entry-point
288
+ # group. Mirrors `render._discover_entry_point_renderers` exactly: load each
289
+ # registered predicate, append it AFTER the built-ins in the conjunction. The
290
+ # conjunctive runner only honors *refusals*, so a discovered predicate is
291
+ # structurally incapable of loosening admission — there is no "admit harder"
292
+ # return value to misuse. That is the safety contract of the open seam.
293
+ # ---------------------------------------------------------------------------
294
+
295
+ # The entry-point group a workspace registers a predicate under.
296
+ PREDICATE_ENTRY_POINT_GROUP = "dos.predicates"
297
+
298
+
299
+ def _discover_entry_point_predicates(*, _stderr=None) -> list[tuple[str, AdmissionPredicate]]:
300
+ """Find workspace predicates registered under the `dos.predicates` group.
301
+
302
+ A predicate plugin registers ``name = "pkg.module:PredicateClass"`` in its
303
+ ``[project.entry-points."dos.predicates"]``. We load each, instantiate it,
304
+ and return ``(entry_point_name, predicate)`` pairs in sorted-by-name order
305
+ (stable, so `dos doctor` and the conjunction are deterministic).
306
+
307
+ A plugin that fails to load (bad import, constructor raises) is skipped with
308
+ a one-line stderr note rather than crashing every `dos arbitrate` (a broken
309
+ third-party plugin is the operator's to fix, not a kernel fault) — the same
310
+ posture `render._discover_entry_point_renderers` takes. There is no
311
+ built-in-name-collision concern here (unlike renderers): predicates are not
312
+ addressed by name, they are all simply appended to the conjunction, so a
313
+ duplicate name cannot shadow a built-in's behavior — it would only add
314
+ another refuse-only voice, which is always safe.
315
+ """
316
+ stderr = _stderr if _stderr is not None else sys.stderr
317
+ out: list[tuple[str, AdmissionPredicate]] = []
318
+ try:
319
+ from importlib.metadata import entry_points
320
+ except Exception: # pragma: no cover - importlib.metadata always present py3.11+
321
+ return out
322
+ try:
323
+ eps = entry_points(group=PREDICATE_ENTRY_POINT_GROUP)
324
+ except TypeError: # pragma: no cover - py<3.10 selectable-API fallback
325
+ eps = entry_points().get(PREDICATE_ENTRY_POINT_GROUP, []) # type: ignore[attr-defined]
326
+ except Exception: # pragma: no cover - defensive: never let discovery crash arbitration
327
+ return out
328
+ for ep in sorted(eps, key=lambda e: e.name):
329
+ try:
330
+ obj = ep.load()
331
+ predicate = obj() if isinstance(obj, type) else obj
332
+ except Exception as e: # pragma: no cover - depends on third-party plugin
333
+ print(
334
+ f"warning: admission predicate plugin {ep.name!r} failed to "
335
+ f"load ({e}); skipping",
336
+ file=stderr,
337
+ )
338
+ continue
339
+ out.append((ep.name, predicate))
340
+ return out
341
+
342
+
343
+ def built_in_predicates(*, workspace=None, config=None) -> list[AdmissionPredicate]:
344
+ """The always-on predicates, in conjunction order.
345
+
346
+ Disjointness FIRST (the original fixed rule — its refusal is the one
347
+ `--force` is documented to skip), then `SelfModifyPredicate` (the
348
+ self-modification guard). Both are always present; a workspace's discovered
349
+ predicates append AFTER these.
350
+
351
+ Two ways to make the SELF_MODIFY guard **workspace-aware**, in precedence:
352
+
353
+ ``config`` (PREFERRED, I/O-FREE) — a `SubstrateConfig` whose
354
+ ``workspace`` facts were already gathered at build time
355
+ (`config.gather_workspace_facts`). The guard reads the CACHED
356
+ `config.kernel_runtime_files`, so NO disk access happens here. This is
357
+ what lets `arbiter.arbitrate` thread the config it already holds and stay
358
+ PURE while still scoping the guard to the served repo — the whole reason
359
+ the facts live on the config (see `config.WorkspaceFacts`). A config whose
360
+ facts are ``None`` (never gathered — a hand-built test config) falls
361
+ through to the conservative full set, exactly as `workspace=None` does.
362
+
363
+ ``workspace`` (LEGACY, performs I/O) — a bare path. Triggers the existence
364
+ probe (`self_modify.existing_runtime_files`) inline. Kept for the
365
+ `active_predicates(workspace=…)` boundary callers (CLI/MCP/doctor) that
366
+ pass a path rather than a built config; their I/O is already boundary I/O.
367
+
368
+ With NEITHER given, the guard uses the full static `_DISPATCH_RUNTIME_FILES`
369
+ set — conservative: a `**/*` lane is treated as self-modifying when we cannot
370
+ prove otherwise (the safe direction for a safety guard). `config` wins over
371
+ `workspace` when both are passed (cached data beats a redundant probe).
372
+
373
+ Imported lazily from `dos.self_modify` to keep the import graph a DAG
374
+ (`self_modify` pulls `admission`; the list is rebuilt cheaply per call —
375
+ these are tiny stateless objects).
376
+
377
+ The **overlap policy** (the both-known disjointness scorer) is resolved HERE
378
+ too, at the boundary, and threaded into `DisjointnessPredicate(policy=…)` — so
379
+ the pure `arbitrate` never does the discovery I/O that resolving a non-`prefix`
380
+ policy needs. With no `config` (or a config naming no policy / the built-in
381
+ `prefix`), `active_overlap_policy` returns `PrefixOverlapPolicy` with NO
382
+ discovery, so the default predicate list is byte-identical to before the seam.
383
+ A workspace that declares `dos.toml [overlap] policy = "import-graph"` (or sets
384
+ `config.overlap_policy_name`) gets its plugin resolved and AND-ed under the
385
+ deterministic prefix floor inside the predicate.
386
+ """
387
+ from dos.self_modify import SelfModifyPredicate, existing_runtime_files
388
+ from dos.overlap_policy import active_overlap_policy
389
+ cached = getattr(config, "kernel_runtime_files", None) if config is not None else None
390
+ if cached is not None:
391
+ # I/O-free path: the config already probed the workspace at build time.
392
+ guard = SelfModifyPredicate(runtime_files=tuple(cached))
393
+ elif workspace is not None:
394
+ # Legacy boundary path: probe the workspace now.
395
+ guard = SelfModifyPredicate(runtime_files=existing_runtime_files(workspace))
396
+ else:
397
+ # Conservative: no workspace info → guard against the full static set.
398
+ guard = SelfModifyPredicate()
399
+ policy = active_overlap_policy(config=config)
400
+ return [DisjointnessPredicate(policy=policy), guard]
401
+
402
+
403
+ def active_predicates(*, workspace=None, config=None, _stderr=None) -> list[AdmissionPredicate]:
404
+ """The full conjunction a CALLER passes into `arbitrate`: built-ins THEN
405
+ discovered plugins.
406
+
407
+ This is the one place the order is composed, and it does ENTRY-POINT
408
+ DISCOVERY (I/O) — so it is called at the CALL BOUNDARY (the CLI's
409
+ `cmd_arbitrate`, `dos doctor`), NOT inside the pure `arbitrate` (whose
410
+ `predicates=None` default is the now-config-aware `built_in_predicates`).
411
+ Built-ins always lead (so a workspace plugin can only ADD a refuse-only voice
412
+ after them, never displace the disjointness/self-modify guards); discovered
413
+ plugins follow in sorted-by-name order.
414
+
415
+ ``config`` (PREFERRED) forwards the built config so the SELF_MODIFY guard reads
416
+ its CACHED workspace facts — no redundant probe. ``workspace`` (a bare path) is
417
+ the legacy form that probes inline; both forward to `built_in_predicates`,
418
+ where `config` wins. A boundary caller that already built the config (the CLI
419
+ after `_apply_workspace`) should pass `config=cfg`; one that only has a path
420
+ passes `workspace=`. Either way the I/O is boundary I/O, the same category as
421
+ the entry-point discovery this function always does.
422
+ """
423
+ discovered = [p for _name, p in _discover_entry_point_predicates(_stderr=_stderr)]
424
+ return built_in_predicates(workspace=workspace, config=config) + discovered
425
+
426
+
427
+ def active_predicate_names(*, _stderr=None) -> list[str]:
428
+ """The names of every active predicate (built-in + discovered), in
429
+ conjunction order — what `dos doctor` lists so an operator can see exactly
430
+ what gates their arbiter (the predicate analogue of "see the active reason
431
+ set")."""
432
+ return [getattr(p, "name", type(p).__name__)
433
+ for p in active_predicates(_stderr=_stderr)]