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