dos-kernel 0.22.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. dos_mcp/server.py +779 -0
dos/scope.py ADDED
@@ -0,0 +1,520 @@
1
+ """SCF — the scope-fidelity verdict: *did the diff stay inside the lane it claims?*
2
+
3
+ docs/85 §4 + docs/86 — a distrust verdict in the `liveness` mold, the
4
+ **footprint sibling of `verify()`**. `verify` distrusts "I shipped P"; SCF
5
+ distrusts "the change I stamped as (plan, phase) stays inside that phase's
6
+ declared lane." The disease it catches is SHIPPED-stamp-drift one level up: an
7
+ agent stamps `phase 3` onto a diff whose blast radius reaches files the phase's
8
+ lane never owned — silently stomping another effort's lane on shared state. The
9
+ self-report ("I touched the picker") is exactly what a believer cannot check; the
10
+ **diff's actual footprint** is ground truth the agent cannot forge, and this
11
+ verdict reads it.
12
+
13
+ This module is `liveness`'s sibling — a **pure** verdict function, the
14
+ `arbitrate()` / `classify` shape:
15
+
16
+ arbiter.arbitrate (request, live_leases, config) -> decision
17
+ liveness.classify (ProgressEvidence, policy) -> LivenessVerdict
18
+ scope.classify (ScopeEvidence, policy) -> ScopeVerdict
19
+ ^ THIS module
20
+
21
+ All I/O — running `git diff --name-only`, reading the lane taxonomy — happens in
22
+ the CALLER (the `dos scope` CLI's evidence-gather, the benchmark's sink), exactly
23
+ as `liveness`'s git read happens outside `classify()`. `classify()` makes no
24
+ subprocess, file, or clock call: it takes the already-gathered touched-file set
25
+ and the already-resolved lane tree as frozen evidence. That is what lets the whole
26
+ verdict be replay-tested on frozen fixtures (the `liveness` design value, restated
27
+ for the footprint axis).
28
+
29
+ The algebra is **reused, not reinvented**: a file is *inside* the lane when some
30
+ normalized directory prefix of the lane's tree (`dos._tree.norm_tree_prefix` — the
31
+ exact normalization the arbiter's `lane_trees_disjoint` runs pairwise) is a
32
+ path-prefix of the file. SCF runs that test one-directionally (file-vs-tree)
33
+ where the arbiter runs it tree-vs-tree.
34
+
35
+ The verdict ladder, top to bottom — the whole point is that a reader holds it in
36
+ their head:
37
+
38
+ 1. IN_SCOPE — every touched file falls under some declared prefix of the
39
+ lane's tree (or there is nothing to judge: an empty diff
40
+ creeps on nothing). The footprint is contained.
41
+ 2. SCOPE_CREEP — the lane's files ARE touched, AND so are files outside the
42
+ tree (beyond an optional tolerance): a superset of the
43
+ declared scope. The stamp is right but the blast radius
44
+ overran it.
45
+ 3. WRONG_TARGET — NONE of the touched files fall in the lane's tree: the stamp
46
+ names a lane the diff never entered. The most severe — the
47
+ claim and the footprint disagree entirely.
48
+
49
+ SCF says where the bytes LANDED, never that they landed *well*: a contained diff
50
+ can still be wrong code, and that is an advisory judge's call (`llm_judge`), never
51
+ this deterministic kernel verb (the distrust-state / distrust-judgment line).
52
+
53
+ SCF (`classify`) is ADVISORY. It reports; it never reverts a commit or refuses a
54
+ lease. A caller may consult it and choose to refuse a write (the natural consumer
55
+ is the arbiter's admission seam — a `ScopePredicate` over ADM's conjunction is a
56
+ possible *separate* opt-in driver policy, not SCF), and the decisions queue may
57
+ surface a SCOPE_CREEP — but the scope verdict and the admission decision stay
58
+ different syscalls (the same line `liveness`/SPINNING holds).
59
+
60
+ The BINDING pre-effect gate — `gate()`, the docs/102 §5 fix.
61
+ -----------------------------------------------------------
62
+ `classify` grades a diff *after* it landed; that is collision-DETECTION, and for
63
+ the irreversible blast radius of a silent clobber the trust law
64
+ (`docs/102_when-to-trust-an-agent.md` §3 clause 3, §5) demands collision-
65
+ PREVENTION instead: *"you cannot un-clobber."* The arbiter admits two lanes at
66
+ contention on their DECLARED trees, but `classify` only checks conformance once
67
+ the commit is in — so two agents that each *under-declare* their trees are
68
+ admitted concurrently, both write, and one silently stomps the other. The
69
+ declared tree is a *prior* commitment (clause 2) but it is not *binding* at the
70
+ moment it matters.
71
+
72
+ `gate()` makes it binding. It is the SAME conformance logic as `classify`, moved
73
+ from after the commit to BEFORE the write: the caller gathers the *proposed*
74
+ write-set (the staged diff's footprint, the patch about to be applied) and asks
75
+ `gate()` whether that write is contained by the lane it claims. A write outside
76
+ the declared tree is **refused, not recorded** — the pre-effect boundary the §4
77
+ trust table assigns to "(detectable, NOT reversible) → the kernel at the
78
+ contention/pre-effect boundary." This is what converts the declared scope from a
79
+ report the arbiter believes into a commitment the work is held to.
80
+
81
+ `gate()` stays PURE for the identical reason `classify` does — the I/O of
82
+ *gathering* the proposed write-set is the caller's (a `git diff --cached`, a
83
+ patch-header parse, the broker's `declared_paths`), exactly as `classify`'s
84
+ `git diff` lives in `verdict_cli._git_diff_names`. The difference between the two
85
+ verbs is **not** the algebra (they share `classify`) and **not** purity — it is
86
+ *when the caller runs them* and *what they do with the answer*: `classify` grades
87
+ a past footprint advisorily; `gate` decides a future write bindingly. A consumer
88
+ that wants prevention calls `gate` before the write and honors the refuse; a
89
+ consumer that only wants a post-hoc report calls `classify`. The natural
90
+ production consumer is a single-writer commit broker / an edit-time hook that
91
+ refuses an out-of-tree patch before applying it (the job repo's
92
+ `scripts/commit_broker.py` fence is exactly this seam; `gate` is its kernel
93
+ verdict).
94
+
95
+ No-plan discipline (`test_verify_no_plan` sibling): SCF must return a verdict with
96
+ nothing but a touched-file set and a lane tree. The GENERIC lane tree is
97
+ `("**/*",)`; `norm_tree_prefix` truncates it at the first `*` to the empty prefix
98
+ `""`, which every path starts with — so a repo that declared no lanes gets the
99
+ honest "no scope to violate" answer (IN_SCOPE), never a crash. Every richer input
100
+ is OPTIONAL.
101
+ """
102
+
103
+ from __future__ import annotations
104
+
105
+ import enum
106
+ from dataclasses import dataclass
107
+
108
+ from . import _tree
109
+
110
+
111
+ class Scope(str, enum.Enum):
112
+ """The typed scope verdict — three states, mutually exclusive.
113
+
114
+ `str`-valued so it round-trips through a CLI stdout token / exit-code map
115
+ without a lookup table (mirrors `liveness.Liveness`, `gate_classify.Verdict`).
116
+ """
117
+
118
+ IN_SCOPE = "IN_SCOPE" # every touched file is inside the lane's tree
119
+ SCOPE_CREEP = "SCOPE_CREEP" # the lane is touched AND so is something outside it
120
+ WRONG_TARGET = "WRONG_TARGET" # nothing touched is inside the lane's tree
121
+
122
+ def __str__(self) -> str: # pragma: no cover - trivial
123
+ return self.value
124
+
125
+
126
+ # Hub files almost every change incidentally edits — config, package markers,
127
+ # the umbrella CLI. A footprint that spills ONLY onto these is not a meaningful
128
+ # scope violation, the same judgement `phase_shipped._SHARED_INFRA_BASENAMES`
129
+ # makes for ship-attribution (a shared-infra touch is not a phase's *distinctive*
130
+ # deliverable). Matched by basename so a path anywhere in the tree is caught.
131
+ # Tolerated only when `ScopePolicy.allow_shared_infra` is set (the default).
132
+ _SHARED_INFRA_BASENAMES = frozenset({
133
+ "config.py", "__init__.py", "settings.py", "constants.py",
134
+ "cli.py", "conftest.py", "pyproject.toml", "setup.py", "setup.cfg",
135
+ })
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class ScopePolicy:
140
+ """The knobs that separate IN_SCOPE/SCOPE_CREEP/WRONG_TARGET — policy, not mechanism.
141
+
142
+ The same "mechanism is kernel, thresholds are config" split as
143
+ `liveness.LivenessPolicy`. The defaults are GENERIC (no host tuning); a
144
+ workspace declares its own in `dos.toml [scope]` read back through
145
+ `SubstrateConfig`, the closed-config-as-data pattern (`[lanes]` / `[stamp]` /
146
+ `[reasons]` / `[liveness]`).
147
+
148
+ allow_shared_infra — when True (default), a footprint that spills ONLY onto
149
+ shared-infra hub files (`config.py`, `__init__.py`, …)
150
+ is still IN_SCOPE: those are touched by nearly every
151
+ change and are never a phase's distinctive deliverable,
152
+ so counting them as creep is a false positive (the
153
+ `phase_shipped` shared-infra judgement, restated).
154
+ creep_tolerance — the number of non-infra out-of-tree files allowed
155
+ before the verdict escalates from IN_SCOPE to
156
+ SCOPE_CREEP. Default 0 — strict: any genuine spill is
157
+ creep. A host that expects small incidental spill can
158
+ raise it.
159
+ """
160
+
161
+ allow_shared_infra: bool = True
162
+ creep_tolerance: int = 0
163
+
164
+ def __post_init__(self) -> None:
165
+ if self.creep_tolerance < 0:
166
+ raise ValueError("creep_tolerance must be non-negative")
167
+
168
+
169
+ DEFAULT_POLICY = ScopePolicy()
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class ScopeEvidence:
174
+ """Everything `classify()` needs, gathered by the CALLER before the call.
175
+
176
+ No git, no config read inside the verdict — the arbiter rule. The CLI's
177
+ evidence-gather (the boundary) runs `git diff --name-only <base>..<head>` (or
178
+ `git show --name-only <sha>`) for the touched set and resolves the lane's tree
179
+ from `SubstrateConfig.lanes.trees[lane]`, then freezes both here and hands
180
+ them to the pure classifier.
181
+
182
+ touched_files — the repo-relative paths the candidate commit(s) changed.
183
+ The agent cannot forge which files a commit object touches;
184
+ this is the unforgeable footprint. An empty set is a diff
185
+ that changed nothing — IN_SCOPE (creeps on nothing).
186
+ lane_tree — the declared path globs of the lane the diff is stamped
187
+ against (`config.lanes.trees[lane]`). The GENERIC default
188
+ `("**/*",)` normalizes to the empty prefix → everything is
189
+ in scope (the no-plan floor). An EMPTY tree is an *unknown*
190
+ blast radius, not a zero one (the `_tree.lane_trees_disjoint`
191
+ stance): with a non-empty diff it yields WRONG_TARGET — we
192
+ cannot certify containment against an undeclared lane.
193
+ lane — the lane name, carried for the operator-facing reason / the
194
+ `--output json` consumer; not an input to the verdict ladder.
195
+ """
196
+
197
+ touched_files: frozenset[str]
198
+ lane_tree: tuple[str, ...]
199
+ lane: str = ""
200
+
201
+ def __post_init__(self) -> None:
202
+ # Normalize to a frozenset of clean, forward-slashed paths so the prefix
203
+ # test matches the `_tree` normalization on the tree side. (A tuple/list
204
+ # passed in is accepted — frozenset() copies it.)
205
+ cleaned = frozenset(
206
+ (p or "").replace("\\", "/").strip()
207
+ for p in self.touched_files
208
+ if p and str(p).strip()
209
+ )
210
+ object.__setattr__(self, "touched_files", cleaned)
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class ScopeVerdict:
215
+ """The single verdict `classify()` returns, with the evidence echoed back.
216
+
217
+ `verdict` is the typed `Scope`. `reason` is a one-line operator-facing summary
218
+ that NAMES the offending files (so the operator sees not just SCOPE_CREEP but
219
+ *which* spill — legible distrust, the RND/Axis-4 renderer seam, exactly like
220
+ `liveness`'s "0 commits, heartbeat 8m fresh"). `evidence` is the
221
+ `ScopeEvidence` that drove the call, carried so `dos scope --output json` can
222
+ emit the verdict AND the facts behind it in one object.
223
+
224
+ in_scope_files — touched files inside the lane tree (sorted, for stable output)
225
+ out_of_scope_files — touched files outside it (the spill that drove the verdict)
226
+ """
227
+
228
+ verdict: Scope
229
+ reason: str
230
+ evidence: ScopeEvidence
231
+ in_scope_files: tuple[str, ...] = ()
232
+ out_of_scope_files: tuple[str, ...] = ()
233
+
234
+ def to_dict(self) -> dict:
235
+ ev = self.evidence
236
+ return {
237
+ "verdict": self.verdict.value,
238
+ "reason": self.reason,
239
+ "in_scope_files": list(self.in_scope_files),
240
+ "out_of_scope_files": list(self.out_of_scope_files),
241
+ "evidence": {
242
+ "lane": ev.lane,
243
+ "touched_files": sorted(ev.touched_files),
244
+ "lane_tree": list(ev.lane_tree),
245
+ },
246
+ }
247
+
248
+
249
+ def _file_in_tree(path: str, prefixes: list[str]) -> bool:
250
+ """True when `path` falls under some normalized directory prefix of the tree.
251
+
252
+ Reuses `dos._tree.norm_tree_prefix`'s normalization (already applied to
253
+ `prefixes`). The empty prefix `""` (from a `**/*` glob) is a prefix of every
254
+ path, which is what makes the GENERIC lane match everything (the no-plan
255
+ floor). A prefix that names a file exactly (`job_search/scoring.py`) matches
256
+ that file and, harmlessly, anything textually under it.
257
+
258
+ The touched ``path`` is run through the SAME `norm_tree_prefix` normalization
259
+ (slash-canonicalized + case-FOLDED) as the prefixes it is compared against, so
260
+ containment is decided on the identical footing — a `Src/Dos/x.py` diff is
261
+ correctly judged inside a `src/**` lane on a case-insensitive FS (and the fold
262
+ is unconditional for the same cross-platform-determinism reason `_tree` folds).
263
+ `norm_tree_prefix` truncates at the first ``*``; a concrete file path has none,
264
+ so for touched files it is exactly "fold + canonicalize slashes".
265
+ """
266
+ folded = _tree.norm_tree_prefix(path)
267
+ return any(folded.startswith(pref) for pref in prefixes)
268
+
269
+
270
+ def classify(ev: ScopeEvidence, policy: ScopePolicy = DEFAULT_POLICY) -> ScopeVerdict:
271
+ """Classify one diff's scope fidelity from already-gathered evidence. PURE.
272
+
273
+ No subprocess, no file, no clock — the arbiter discipline. Reads the ladder
274
+ top to bottom (this function IS the answer to "did it stay in its lane?"):
275
+
276
+ 1. IN_SCOPE — empty diff (nothing to judge), OR every touched file is
277
+ inside the lane tree, OR the only out-of-tree files are
278
+ tolerated shared-infra / within `creep_tolerance`.
279
+ 2. SCOPE_CREEP — at least one touched file IS inside the lane tree AND the
280
+ out-of-tree spill exceeds tolerance: a superset of scope.
281
+ 3. WRONG_TARGET — nothing touched is inside the lane tree (and the diff is
282
+ non-empty): the stamp names a lane the diff never entered.
283
+
284
+ The IN_SCOPE/rest split is pure set membership against the normalized prefix
285
+ tree. The SCOPE_CREEP/WRONG_TARGET split is whether ANY touched file landed in
286
+ the lane: a partial overrun (some in, some out) is creep; a total miss (none
287
+ in) is a wrong target.
288
+ """
289
+ touched = ev.touched_files
290
+ # 1a. Empty diff — nothing to adjudicate. A footprint of zero files creeps on
291
+ # nothing and targets nothing; the benign IN_SCOPE (mirrors liveness's
292
+ # 0-commit floor returning a verdict, not an error).
293
+ if not touched:
294
+ return ScopeVerdict(
295
+ verdict=Scope.IN_SCOPE,
296
+ reason="empty footprint — no files touched, nothing to judge",
297
+ evidence=ev,
298
+ )
299
+
300
+ prefixes = [_tree.norm_tree_prefix(p) for p in ev.lane_tree if p]
301
+
302
+ # An EMPTY (or all-blank) lane tree is an UNKNOWN blast radius, not a zero one
303
+ # — the `_tree.lane_trees_disjoint` conservative stance. We cannot certify a
304
+ # non-empty diff is contained by an undeclared lane, so it is WRONG_TARGET
305
+ # (the caller asked us to check scope against a lane that named no tree).
306
+ if not prefixes:
307
+ return ScopeVerdict(
308
+ verdict=Scope.WRONG_TARGET,
309
+ reason=(
310
+ f"lane {ev.lane or '(unnamed)'} declares no tree — cannot certify "
311
+ f"containment of {len(touched)} touched file(s) (unknown blast radius)"
312
+ ),
313
+ evidence=ev,
314
+ out_of_scope_files=tuple(sorted(touched)),
315
+ )
316
+
317
+ in_tree = sorted(f for f in touched if _file_in_tree(f, prefixes))
318
+ out_tree = sorted(f for f in touched if not _file_in_tree(f, prefixes))
319
+
320
+ # Partition the out-of-tree spill into tolerated shared-infra vs genuine. The
321
+ # basename is case-FOLDED before membership (the set is lowercase) so a mis-cased
322
+ # hub file (`Config.py` == `config.py` on a case-insensitive FS) is correctly
323
+ # tolerated rather than mis-counted as genuine spill — the same fold the in-tree
324
+ # split (`_file_in_tree` → `_tree.norm_tree_prefix`) and `stamp.is_shared_infra`
325
+ # use, so this last membership cannot drift case-sensitive while the rest folds.
326
+ if policy.allow_shared_infra:
327
+ genuine_out = [
328
+ f for f in out_tree if f.rsplit("/", 1)[-1].casefold() not in _SHARED_INFRA_BASENAMES
329
+ ]
330
+ else:
331
+ genuine_out = list(out_tree)
332
+
333
+ # 2/3. There IS out-of-tree spill beyond tolerance.
334
+ if len(genuine_out) > policy.creep_tolerance:
335
+ if in_tree:
336
+ # 2. SCOPE_CREEP — touched the lane AND overran it.
337
+ return ScopeVerdict(
338
+ verdict=Scope.SCOPE_CREEP,
339
+ reason=(
340
+ f"stamped lane {ev.lane or '(unnamed)'} and touched its tree "
341
+ f"({len(in_tree)} file(s)) but ALSO {len(genuine_out)} file(s) "
342
+ f"outside it: {', '.join(genuine_out[:5])}"
343
+ + (" …" if len(genuine_out) > 5 else "")
344
+ ),
345
+ evidence=ev,
346
+ in_scope_files=tuple(in_tree),
347
+ out_of_scope_files=tuple(out_tree),
348
+ )
349
+ # 3. WRONG_TARGET — nothing landed in the lane at all.
350
+ return ScopeVerdict(
351
+ verdict=Scope.WRONG_TARGET,
352
+ reason=(
353
+ f"stamped lane {ev.lane or '(unnamed)'} but NONE of the "
354
+ f"{len(touched)} touched file(s) fall in its tree — "
355
+ f"footprint: {', '.join(genuine_out[:5])}"
356
+ + (" …" if len(genuine_out) > 5 else "")
357
+ ),
358
+ evidence=ev,
359
+ out_of_scope_files=tuple(out_tree),
360
+ )
361
+
362
+ # 1b. IN_SCOPE — no genuine spill (everything is in the tree, or the only
363
+ # out-of-tree files are tolerated shared-infra / within tolerance).
364
+ note = ""
365
+ if out_tree:
366
+ note = (
367
+ f" ({len(out_tree)} shared-infra/tolerated file(s) outside the tree, "
368
+ f"not counted as creep)"
369
+ )
370
+ return ScopeVerdict(
371
+ verdict=Scope.IN_SCOPE,
372
+ reason=(
373
+ f"all {len(in_tree)} touched file(s) fall inside lane "
374
+ f"{ev.lane or '(unnamed)'}'s tree{note}"
375
+ ),
376
+ evidence=ev,
377
+ in_scope_files=tuple(in_tree),
378
+ out_of_scope_files=tuple(out_tree),
379
+ )
380
+
381
+
382
+ # ===========================================================================
383
+ # The binding pre-effect gate (docs/102 §5) — refuse an out-of-tree WRITE
384
+ # before it lands, rather than DETECT it after the commit.
385
+ # ===========================================================================
386
+
387
+ # The verdicts that a binding gate treats as "do not let this write land" by
388
+ # default. IN_SCOPE is the only ALLOW: a contained footprint is the commitment
389
+ # kept. SCOPE_CREEP (overran its tree) and WRONG_TARGET (never entered it / an
390
+ # undeclared lane = unknown blast radius) are both REFUSE — each is a footprint
391
+ # the declared tree did not authorize, which is exactly the under-declaration the
392
+ # §5 silent-clobber needs prevented. (The same frozenset is the policy default and
393
+ # the policy floor; a host can only ADD to it — see `ScopeGatePolicy`.)
394
+ _DEFAULT_REFUSE_ON: frozenset[Scope] = frozenset({Scope.SCOPE_CREEP, Scope.WRONG_TARGET})
395
+
396
+
397
+ @dataclass(frozen=True)
398
+ class ScopeGatePolicy:
399
+ """How the binding gate maps a scope verdict to an ALLOW / REFUSE decision.
400
+
401
+ Mechanism-vs-policy, the kernel's standing split (`ScopePolicy`,
402
+ `LivenessPolicy`): the *containment algebra* is fixed in `classify`; this
403
+ object is the *enforcement strictness* a host tunes. Two knobs, and they
404
+ compose — the inner `scope` policy decides what COUNTS as in-scope (shared-
405
+ infra tolerance, creep tolerance); `refuse_on` decides which resulting
406
+ verdicts BLOCK the write.
407
+
408
+ scope — the underlying `ScopePolicy` handed to `classify` (so a host's
409
+ `allow_shared_infra` / `creep_tolerance` tuning flows straight
410
+ through to the gate — the gate never re-implements containment).
411
+ refuse_on — the set of `Scope` verdicts that REFUSE the write. Default:
412
+ {SCOPE_CREEP, WRONG_TARGET} — i.e. only IN_SCOPE is allowed.
413
+ IN_SCOPE can never be added (allowing the gate to refuse a
414
+ perfectly-contained write would make it refuse *everything*, a
415
+ bricked workspace, never a sound stance) — `__post_init__`
416
+ rejects that, the one-way-safety floor: a host may make the gate
417
+ STRICTER only in the sense of which non-contained verdicts it
418
+ blocks, never make a contained write refusable.
419
+ """
420
+
421
+ scope: ScopePolicy = DEFAULT_POLICY
422
+ refuse_on: frozenset[Scope] = _DEFAULT_REFUSE_ON
423
+
424
+ def __post_init__(self) -> None:
425
+ if Scope.IN_SCOPE in self.refuse_on:
426
+ raise ValueError(
427
+ "refuse_on may not include IN_SCOPE — a gate that refuses a "
428
+ "fully-contained write refuses everything (a bricked workspace). "
429
+ "Tighten containment via the inner ScopePolicy instead."
430
+ )
431
+ # Normalize a passed-in set/iterable to a frozenset so the policy is hashable
432
+ # and immutable like every other frozen kernel policy.
433
+ if not isinstance(self.refuse_on, frozenset):
434
+ object.__setattr__(self, "refuse_on", frozenset(self.refuse_on))
435
+
436
+
437
+ DEFAULT_GATE_POLICY = ScopeGatePolicy()
438
+
439
+
440
+ @dataclass(frozen=True)
441
+ class ScopeGate:
442
+ """The binding pre-effect decision: may this proposed write LAND?
443
+
444
+ The arbiter-`LaneDecision` analogue for the edit boundary — a decision a
445
+ consumer ACTS on (apply the patch / refuse it), not a report it files. It
446
+ carries the underlying advisory `ScopeVerdict` so the refuse is legible
447
+ (the operator sees not just REFUSE but WHICH files escaped the tree — the same
448
+ legible-distrust seam `ScopeVerdict.reason` serves), and so a consumer that
449
+ wants both the binding bit AND the graded verdict gets them from one call.
450
+
451
+ allowed — True iff the write is contained by the lane it claims and
452
+ may land; False iff it must be REFUSED before the effect.
453
+ verdict — the underlying `Scope` (IN_SCOPE / SCOPE_CREEP / WRONG_TARGET)
454
+ that drove the decision (the advisory grade behind the gate).
455
+ reason — one-line operator-facing summary; on a refuse it NAMES the
456
+ out-of-tree spill (carried up from the `ScopeVerdict`).
457
+ scope_verdict — the full `ScopeVerdict`, so a consumer can reach its
458
+ `in_scope_files` / `out_of_scope_files` / `evidence` without
459
+ a second `classify` call.
460
+ refused_files — the out-of-tree files that drove a refusal (empty on ALLOW);
461
+ a convenience projection of `scope_verdict.out_of_scope_files`,
462
+ the set a consumer reports back to the agent ("these writes
463
+ were refused; they are outside lane X's tree").
464
+ """
465
+
466
+ allowed: bool
467
+ verdict: Scope
468
+ reason: str
469
+ scope_verdict: ScopeVerdict
470
+ refused_files: tuple[str, ...] = ()
471
+
472
+ def to_dict(self) -> dict:
473
+ return {
474
+ "allowed": self.allowed,
475
+ "verdict": self.verdict.value,
476
+ "reason": self.reason,
477
+ "refused_files": list(self.refused_files),
478
+ "scope": self.scope_verdict.to_dict(),
479
+ }
480
+
481
+
482
+ def gate(ev: ScopeEvidence, policy: ScopeGatePolicy = DEFAULT_GATE_POLICY) -> ScopeGate:
483
+ """Decide whether a PROPOSED write may land — the binding pre-effect gate. PURE.
484
+
485
+ docs/102 §5: the same conformance logic as `classify`, moved from AFTER the
486
+ commit to BEFORE the write, so an out-of-tree write is *refused, not recorded*.
487
+ The caller gathers the *proposed* footprint (`ev.touched_files` = the staged
488
+ diff / the patch about to apply, NOT the post-commit `git show`), and this
489
+ function answers "is that write contained by the lane it claims?" — `allowed`
490
+ is the bit a consumer acts on at the edit boundary.
491
+
492
+ No subprocess, no file, no clock — `classify`'s purity, inherited by delegation
493
+ (the containment algebra is NOT re-implemented; this is `classify` + a verdict→
494
+ decision map). That is what lets the gate be replay-tested on frozen fixtures
495
+ exactly like `classify`, and what keeps the durability/I/O at the caller's edge
496
+ (the arbiter discipline: state in, decision out).
497
+
498
+ The decision: ALLOW iff the underlying verdict is NOT in `policy.refuse_on`
499
+ (default: refuse SCOPE_CREEP + WRONG_TARGET, i.e. allow only IN_SCOPE). An
500
+ empty footprint is IN_SCOPE (a write of nothing escapes nothing → allowed, the
501
+ benign floor `classify` already returns), so the gate never blocks a no-op.
502
+ An undeclared lane (empty tree) yields WRONG_TARGET → REFUSED: the gate will
503
+ NOT let a write land against a lane whose blast radius it cannot certify (the
504
+ conservative `_tree.lane_trees_disjoint` stance, now enforced pre-effect).
505
+ """
506
+ verdict = classify(ev, policy.scope)
507
+ allowed = verdict.verdict not in policy.refuse_on
508
+ if allowed:
509
+ reason = f"write ALLOWED — {verdict.reason}"
510
+ refused: tuple[str, ...] = ()
511
+ else:
512
+ reason = f"write REFUSED ({verdict.verdict.value}) — {verdict.reason}"
513
+ refused = verdict.out_of_scope_files
514
+ return ScopeGate(
515
+ allowed=allowed,
516
+ verdict=verdict.verdict,
517
+ reason=reason,
518
+ scope_verdict=verdict,
519
+ refused_files=refused,
520
+ )