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/precursor_gate.py ADDED
@@ -0,0 +1,499 @@
1
+ """PG — the precursor-presence verdict: *did the mandated lookup FIRE before this mutation?*
2
+
3
+ docs/147 (the precursor-presence gate; greenlit by docs/146's slice survey). `arg_provenance`
4
+ (docs/143) asks *"did the model MINT this id, or RESOLVE it?"* — a check on
5
+ **provenance-of-a-string** that vanishes on a strong model (a model that reads-before-it-writes
6
+ mints nothing). docs/146 found one more byte-clean question of the same shape:
7
+
8
+ > does a tool whose name is on a config-declared mandated-precursor set produce ANY result in
9
+ > env-authored bytes before this mutating call's stream index?
10
+
11
+ That is **provenance-of-a-precursor-PRESENCE** — a pure byte question about *env-authored* bytes
12
+ (the gym MCP server authored the result, recording that the precursor tool fired; the judged
13
+ agent did not author the *existence* of that result). So it sidesteps the **mirror-verifier
14
+ trap** (docs/141, docs/143 §5a) for the same reason `arg_provenance` does: it needs no answer
15
+ key, no held-out state, and **no self-authored satisfaction predicate** ("did the precursor
16
+ result *authorize* this action on this resource?" — the forgeable-in-the-agent's-favor question
17
+ this module must never ask). It attacks the named "Missing Prerequisite Lookup" /
18
+ "Cascading State Propagation" failure modes that feed the Policy/Permission verifier slice.
19
+
20
+ The dead-line — firing, NEVER adjudication (docs/147 §3)
21
+ =======================================================
22
+
23
+ This module checks *only* that a mandated-precursor-named tool produced **some** result earlier
24
+ in the call stream. It deliberately does **not**, and structurally **cannot**, ask:
25
+
26
+ * **resource-identity** — "was the precursor about the SAME record the mutation touches?"
27
+ * **clause-satisfaction** — "did the precursor result return a value that AUTHORIZES the act?"
28
+ * **ordering beyond stream-index** — "does the precursor LOGICALLY precede this in the policy?"
29
+
30
+ Each of those binds the precursor *to this action* — a **provenance-of-a-RELATION** the agent
31
+ narrates from agent-visible prose, forgeable in its favor. The verdict's `REFUTED` therefore
32
+ means ONLY "a mandated precursor for this tool produced no result in the stream"
33
+ (presence-absence, a byte fact), never the OS-witnessed disconfirmation `evidence`'s `REFUTED`
34
+ carries for the `os_acceptance` driver. The moment a consumer lets this REFUTED drive an
35
+ actuating rung harder than WARN, it has crossed into the mirror-verifier — which is why the
36
+ intervention map below emits **only WARN** and has no harder rung to reach.
37
+
38
+ Why this is NOT `arg_provenance`'s `_build_env`/`_component_found` fold
39
+ ======================================================================
40
+
41
+ Those fold over a prior result's *text tokens* — they answer "does this id-component trace to
42
+ env bytes?" A precursor tool's NAME is generally absent from its own result payload, so a
43
+ token-trace would systematically MISS a fired precursor (a false REFUTED). The firing question
44
+ is instead a **structural membership scan** over the call stream's `tool_name` fields:
45
+ `any(_canon(c.tool_name) in precursor_name_set for c in stream[:idx])`. This module lifts only
46
+ the *pattern* `arg_provenance`/`tool_stream` share — casefold + alias normalization + a pure
47
+ scan over an already-accumulated env corpus — not the id-matcher.
48
+
49
+ The two errors, and which one is safe
50
+ =====================================
51
+
52
+ * **false-NO_SIGNAL** (a mutating tool absent from the grammar, so never gated) → the call
53
+ dispatches as baseline. SAFE — no worse than not having the gate; the side-effect-suppression
54
+ edge is simply bounded by grammar coverage (what the eval's `missed_precursor_recall`
55
+ measures).
56
+ * **false-REFUTED** (the precursor DID fire, but under an alias the grammar did not list) → an
57
+ unnecessary WARN. Bounded by the `alias_map`, and even when it misses the cost is a redundant
58
+ reminder, not a withheld call (the intervention is WARN-only, §4) — a fire on a feasible task
59
+ is a *correct* "you have not called the mandated check" nudge, not a false-block.
60
+
61
+ So like `arg_provenance`, the whole module is tuned to **under-fire**, and its one actuation is
62
+ the least-disruptive informing rung.
63
+
64
+ ⚓ Pure kernel, I/O on the edge (the dos idiom — mirrors `arg_provenance.classify_call`,
65
+ `liveness.classify`, `tool_stream.classify_stream`): `classify_call(MutatingCall, CallStream,
66
+ PrecursorGrammar, policy) -> PrecursorVerdict` is a frozen datum in, a frozen verdict out. The
67
+ caller flattens each prior tool RESULT to a `(tool_name, result_text)` pair at the boundary; the
68
+ kernel parses no JSON, reads no clock, no disk — replay-testable on frozen fixtures with zero
69
+ benchmark/LLM/MCP access.
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ from dataclasses import dataclass, field
75
+ from pathlib import Path
76
+ from typing import Optional
77
+
78
+ # The verdict vocabulary has ONE home — docs/121's `evidence`. A precursor check is a
79
+ # presence-of-evidence question (did a witness — the precursor result — appear?), so it
80
+ # reuses `EvidenceStance` (ATTESTED / REFUTED / NO_SIGNAL) verbatim rather than fork a
81
+ # parallel three-valued enum. NB the SEMANTICS differ: here REFUTED is presence-absence,
82
+ # NOT the OS-witnessed disconfirmation `os_acceptance` mints it for (see the module doc).
83
+ from dos.evidence import EvidenceStance
84
+
85
+ # The actuation vocabulary — the shipped intervention ladder (docs/144). This gate maps its
86
+ # stance to a rung DIRECTLY (the `tool_stream` precedent), never via `choose_intervention`,
87
+ # which is typed to a `ProvenanceVerdict` this gate does not produce.
88
+ from dos.intervention import Intervention, InterventionDecision
89
+
90
+ __all__ = [
91
+ "PrecursorStance",
92
+ "PrecursorPolicy",
93
+ "DEFAULT_POLICY",
94
+ "PriorCall",
95
+ "CallStream",
96
+ "MutatingCall",
97
+ "PrecursorGrammar",
98
+ "PrecursorVerdict",
99
+ "classify_call",
100
+ "precursor_intervention",
101
+ "grammar_from_table",
102
+ "load_from_toml",
103
+ ]
104
+
105
+
106
+ # The stance is `EvidenceStance` (one home for the presence-of-evidence vocabulary).
107
+ PrecursorStance = EvidenceStance
108
+
109
+
110
+ def _canon(name: str) -> str:
111
+ """Normalize a tool name for matching: casefold + `-`/`.`→`_` (the `dos_react`
112
+ `_normalize_tool_name` convention, so `check-access` / `check.access` / `Check_Access`
113
+ all canonicalize to one key)."""
114
+ return (name or "").strip().casefold().replace("-", "_").replace(".", "_")
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Frozen inputs — the pure datum a caller gathers at the boundary and hands in.
119
+ # ---------------------------------------------------------------------------
120
+ @dataclass(frozen=True)
121
+ class PriorCall:
122
+ """One prior call in the stream, as the pure datum the verdict sees.
123
+
124
+ `tool_name` is the tool the env executed (it returned a result, recording the firing).
125
+ `result_text` is the flattened result bytes — carried ONLY for the legibility note (a
126
+ consumer may quote it); the firing decision keys on `tool_name` alone (a structural
127
+ membership test, not a substring trace over `result_text` — see the module doc).
128
+ """
129
+
130
+ tool_name: str
131
+ result_text: str = ""
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class CallStream:
136
+ """The env-authored call stream accumulated before the call under scrutiny.
137
+
138
+ `calls` is a tuple of `PriorCall` in call order — every prior tool RESULT the agent has
139
+ seen (the same `prior_tool_results` the `arg_provenance` consult already accumulates).
140
+ Empty (`()`) on the first call of an episode, which `classify_call` reads as "nothing
141
+ could have fired yet → NO_SIGNAL" (the load-bearing first-call safe direction, the
142
+ `arg_provenance` empty-corpus / `tool_stream` too-short floor).
143
+ """
144
+
145
+ calls: tuple[PriorCall, ...] = ()
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class MutatingCall:
150
+ """The call under scrutiny — the `ToolCall` / `AdmissionRequest` analogue.
151
+
152
+ `is_mutating` is set by the consumer's fail-open write-verb classifier (the same
153
+ `dos_react.is_mutating_tool`). A read / non-mutating call is never gated — reads are how a
154
+ precursor result ENTERS the stream — so `is_mutating=False` short-circuits to NO_SIGNAL.
155
+ The classifier is deliberately fail-open (when unsure, treat as a read): under-gating
156
+ degrades to baseline (safe), over-gating risks a feasible-task regression.
157
+ """
158
+
159
+ tool_name: str
160
+ is_mutating: bool = True
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class PrecursorPolicy:
165
+ """The knobs — mechanism is kernel, knobs are config (the `LivenessPolicy` /
166
+ `ProvenancePolicy` / `StreamPolicy` seam). Defaults GENERIC; a host declares its own in
167
+ `dos.toml [precursor]` read back through `SubstrateConfig` (the closed-config-as-data
168
+ pattern, like `[tool_stream]` / `[intervention]`).
169
+
170
+ case_sensitive — match tool names case-sensitively. Default False (casefold both sides):
171
+ a precursor declared `Check_Access` still matches a called `check_access`
172
+ (the fewest-false-fires bias, the `arg_provenance` casefold default). NB
173
+ names are ALSO `-`/`.`→`_` normalized regardless (`_canon`), so this knob
174
+ only toggles the casefold step.
175
+
176
+ There is deliberately NO knob for resource-binding, clause-satisfaction, or ordering beyond
177
+ stream-index — those are the off-limits provenance-of-a-RELATION questions (the module doc's
178
+ dead-line, made structural by the absence of the field).
179
+ """
180
+
181
+ case_sensitive: bool = False
182
+
183
+
184
+ DEFAULT_POLICY = PrecursorPolicy()
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # The grammar — config-as-data: which mutating tool requires which precursor(s).
189
+ # ---------------------------------------------------------------------------
190
+ @dataclass(frozen=True)
191
+ class PrecursorGrammar:
192
+ """The mandated-precursor map, as data — the unit a workspace declares.
193
+
194
+ `requires` maps a mutating tool name → the tuple of precursor tool name(s) that satisfy
195
+ its mandate (ANY one present in the stream → ATTESTED — the floor is "at least one mandated
196
+ lookup fired," never "all of them"). `aliases` maps a precursor name → other tool names
197
+ that count as the SAME precursor (the synonym allow-list — the §3 false-REFUTED safety
198
+ valve). Both are **Appendix-C / system-prompt-derived, NEVER inferred** — a host writes the
199
+ map by reading the policy prose once, the way it writes its lane taxonomy from the dir tree.
200
+ Inferring the map from policy text *is* parsing policy = planner-adjacent = off-limits.
201
+
202
+ The map is keyed on canonical names (`_canon`) at construction, so a lookup is a single
203
+ canonical-name membership test with no per-call normalization of the grammar.
204
+ """
205
+
206
+ requires: dict = field(default_factory=dict)
207
+ aliases: dict = field(default_factory=dict)
208
+
209
+ def required_set(self, mutating_tool: str) -> frozenset:
210
+ """The canonical set of tool names whose presence satisfies `mutating_tool`'s mandate —
211
+ the declared precursor(s) UNION every alias of each. Empty frozenset iff the tool has no
212
+ declared precursor (→ the caller NO_SIGNALs it). PURE."""
213
+ key = _canon(mutating_tool)
214
+ declared = self.requires.get(key)
215
+ if not declared:
216
+ return frozenset()
217
+ out: set[str] = set()
218
+ for p in declared:
219
+ cp = _canon(p)
220
+ out.add(cp)
221
+ for alias in self.aliases.get(cp, ()): # aliases keyed canonical (built below)
222
+ out.add(_canon(alias))
223
+ return frozenset(out)
224
+
225
+
226
+ EMPTY_GRAMMAR = PrecursorGrammar()
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Frozen verdict — the folded answer, advisory only (the EvidenceFacts shape).
231
+ # ---------------------------------------------------------------------------
232
+ @dataclass(frozen=True)
233
+ class PrecursorVerdict:
234
+ """The folded answer over a mutating call — the `ProvenanceVerdict` / `StreamVerdict` analogue.
235
+
236
+ `stance` is the typed `PrecursorStance` (= `EvidenceStance`):
237
+ ATTESTED — a mandated precursor for this tool produced a result earlier in the stream.
238
+ REFUTED — the call is mutating, HAS a declared mandated precursor, and NONE fired. The
239
+ one actionable rung (→ a WARN re-surfacing the requirement).
240
+ NO_SIGNAL — a read/non-mutating call, OR a mutating tool with no declared precursor, OR an
241
+ empty stream (first call). The fail-safe zero; never an intervention.
242
+
243
+ `mutating_tool` echoes the call. `required` is the canonical precursor set that would have
244
+ satisfied the mandate (the requirement the WARN names — never a fabricated DB row).
245
+ `present` is the subset of `required` actually found in the stream (empty ⟺ REFUTED among
246
+ mutating-with-grammar calls). `reason` is the one-line operator summary. Advisory: never
247
+ raises, never dispatches — the consumer reads `stance` and decides whether to re-surface.
248
+ """
249
+
250
+ stance: PrecursorStance
251
+ mutating_tool: str
252
+ required: tuple[str, ...]
253
+ present: tuple[str, ...]
254
+ reason: str
255
+
256
+ @property
257
+ def fired(self) -> bool:
258
+ """True iff this is the actionable REFUTED rung (the only stance that drives a WARN)."""
259
+ return self.stance is EvidenceStance.REFUTED
260
+
261
+ def to_dict(self) -> dict:
262
+ return {
263
+ "stance": self.stance.value,
264
+ "mutating_tool": self.mutating_tool,
265
+ "required": list(self.required),
266
+ "present": list(self.present),
267
+ "reason": self.reason,
268
+ }
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # The pure verdict — a structural tool_name-membership scan over the stream.
273
+ # ---------------------------------------------------------------------------
274
+ def classify_call(
275
+ call: MutatingCall,
276
+ stream: CallStream,
277
+ grammar: PrecursorGrammar = EMPTY_GRAMMAR,
278
+ policy: PrecursorPolicy = DEFAULT_POLICY,
279
+ ) -> PrecursorVerdict:
280
+ """Classify whether `call`'s mandated precursor(s) fired earlier in `stream`. PURE — no I/O.
281
+
282
+ Reads the ladder top to bottom:
283
+
284
+ 1. NO_SIGNAL — a read / non-mutating call (reads are how a precursor ENTERS, never gated).
285
+ 2. NO_SIGNAL — a mutating call whose tool has NO declared precursor in the grammar (the
286
+ absent-key safe direction — the gate only speaks where a host declared a mandate; an
287
+ undeclared mutating tool dispatches as baseline). This is the grammar-coverage bound.
288
+ 3. NO_SIGNAL — an empty stream (the first call): nothing could have fired yet, so we never
289
+ accuse (the `arg_provenance` empty-corpus floor).
290
+ 4. ATTESTED — ≥1 of the mandated precursor names appears among the prior calls' tool names.
291
+ 5. REFUTED — none did: the agent is about to mutate before the mandated lookup fired.
292
+
293
+ The match is a structural `tool_name`-membership test (canonicalized; casefolded unless
294
+ `policy.case_sensitive`), NOT a substring trace over result bytes — a precursor's name is
295
+ generally absent from its own result payload, so a token-trace would false-REFUTED.
296
+ """
297
+ required = grammar.required_set(call.tool_name)
298
+
299
+ if not call.is_mutating:
300
+ return PrecursorVerdict(
301
+ stance=EvidenceStance.NO_SIGNAL,
302
+ mutating_tool=call.tool_name,
303
+ required=tuple(sorted(required)),
304
+ present=(),
305
+ reason="read / non-mutating call — precursor not gated (reads source the stream)",
306
+ )
307
+ if not required:
308
+ return PrecursorVerdict(
309
+ stance=EvidenceStance.NO_SIGNAL,
310
+ mutating_tool=call.tool_name,
311
+ required=(),
312
+ present=(),
313
+ reason=(
314
+ f"{call.tool_name!r} has no declared mandated precursor in the grammar — "
315
+ f"not gated (the side-effect-suppression edge is bounded by grammar coverage)"
316
+ ),
317
+ )
318
+ if not stream.calls:
319
+ return PrecursorVerdict(
320
+ stance=EvidenceStance.NO_SIGNAL,
321
+ mutating_tool=call.tool_name,
322
+ required=tuple(sorted(required)),
323
+ present=(),
324
+ reason="empty call stream — first call of the episode, nothing could have fired yet",
325
+ )
326
+
327
+ # The structural membership scan: which mandated precursor names produced a prior result?
328
+ # `required` is canonical (delimiter-normalized + casefolded, via the grammar). When
329
+ # `case_sensitive` is OFF (the default), a prior call's name is matched the same way, so the
330
+ # comparison is like-for-like. When ON, the host has opted into exact-case matching: we
331
+ # compare delimiter-normalized-but-NOT-casefolded names on BOTH sides (re-deriving the
332
+ # required set without casefold) so a `Check_Access` precursor no longer matches a called
333
+ # `check_access`. The common path is the casefold default.
334
+ if policy.case_sensitive:
335
+ def _name(n: str) -> str:
336
+ return (n or "").strip().replace("-", "_").replace(".", "_")
337
+ req_match = frozenset(
338
+ _name(p)
339
+ for p in grammar.requires.get(_canon(call.tool_name), ())
340
+ ) | frozenset(
341
+ _name(a)
342
+ for p in grammar.requires.get(_canon(call.tool_name), ())
343
+ for a in grammar.aliases.get(p, ())
344
+ )
345
+ else:
346
+ _name = _canon
347
+ req_match = required
348
+ present = sorted({
349
+ _name(c.tool_name) for c in stream.calls if _name(c.tool_name) in req_match
350
+ })
351
+
352
+ if present:
353
+ return PrecursorVerdict(
354
+ stance=EvidenceStance.ATTESTED,
355
+ mutating_tool=call.tool_name,
356
+ required=tuple(sorted(required)),
357
+ present=tuple(present),
358
+ reason=(
359
+ f"mandated precursor(s) {present} fired before {call.tool_name!r} — "
360
+ f"the required lookup is present in the stream"
361
+ ),
362
+ )
363
+ return PrecursorVerdict(
364
+ stance=EvidenceStance.REFUTED,
365
+ mutating_tool=call.tool_name,
366
+ required=tuple(sorted(required)),
367
+ present=(),
368
+ reason=(
369
+ f"{call.tool_name!r} is about to mutate, but none of its mandated precursor(s) "
370
+ f"{sorted(required)} produced a result in the stream — the required lookup was "
371
+ f"skipped (Missing Prerequisite Lookup); re-surface the requirement"
372
+ ),
373
+ )
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # The intervention map — a DIRECT stance→rung map (the tool_stream precedent).
378
+ # NOT choose_intervention: that is ProvenanceVerdict-typed and reads fields a
379
+ # PrecursorVerdict does not carry. The only fired output is WARN — there is no
380
+ # harder rung in this map, no ceiling knob, no InterventionPolicy clamp, so BLOCK
381
+ # is unreachable BY CONSTRUCTION (the docs/147 §4 WARN-only-by-output-type guarantee).
382
+ # ---------------------------------------------------------------------------
383
+ def precursor_intervention(verdict: PrecursorVerdict) -> Optional[InterventionDecision]:
384
+ """Map a `PrecursorVerdict` directly to an intervention rung. PURE + ADVISORY.
385
+
386
+ The mapping is a two-line literal — the `tool_stream` precedent (that leaf's consumer maps
387
+ its `StreamState` to a WARN without calling `choose_intervention`):
388
+
389
+ REFUTED -> Intervention.WARN (re-surface the mandated-precursor requirement)
390
+ ATTESTED/NO_SIGNAL -> None (no intervention; dispatch unchanged)
391
+
392
+ Returns `None` (not OBSERVE) on the non-fired stances so a consumer can `if decision:`
393
+ cheaply. The fired rung is **always WARN** — there is no rung above it in this map, no
394
+ confidence to assess (a `PrecursorVerdict` carries none), and no policy object to re-tune,
395
+ so a BLOCK/DEFER is unreachable for this signal by construction. "Mandated precursor absent"
396
+ cannot honestly carry a BLOCK's confidence (you cannot prove a check was *required for this
397
+ specific action* without the resource/clause relation the dead-line cut), and the wiring
398
+ reflects that: a fixed WARN, full stop. The kernel RECOMMENDS this; the consumer ACTS on it.
399
+ """
400
+ if verdict.stance is not EvidenceStance.REFUTED:
401
+ return None
402
+ miss = ", ".join(verdict.required) or "a mandated lookup"
403
+ return InterventionDecision(
404
+ intervention=Intervention.WARN,
405
+ # The fields below are echoed for the InterventionDecision shape; a PrecursorVerdict has
406
+ # no Confidence, so we carry NONE (the honest "no confidence rung for this verdict type")
407
+ # and the precursor tool as the single "unsupported" subject the WARN names.
408
+ confidence=_no_confidence(),
409
+ rung=_warn_spec(),
410
+ disruption_cost=0.0,
411
+ unsupported=(verdict.mutating_tool,),
412
+ reason=(
413
+ f"mandated precursor absent for {verdict.mutating_tool!r} (required: {miss}) "
414
+ f"-> WARN: re-surface the requirement, dispatch preserved (turn not lost)"
415
+ ),
416
+ )
417
+
418
+
419
+ def _no_confidence():
420
+ """The `Confidence.NONE` literal — a `PrecursorVerdict` has no mint-confidence rung, so the
421
+ decision carries NONE honestly (never a fabricated HIGH that a downstream reader might
422
+ escalate on). Imported lazily to keep the module's import surface minimal."""
423
+ from dos.intervention import Confidence
424
+ return Confidence.NONE
425
+
426
+
427
+ def _warn_spec():
428
+ """The shipped WARN `InterventionSpec` from the base ladder — reused, never re-declared, so
429
+ the rung's `dispatches`/`rank`/`actuation` data stays single-sourced (the consumer reads
430
+ `rung.dispatches` to know the call still fires)."""
431
+ from dos.intervention import BASE_INTERVENTIONS
432
+ spec = BASE_INTERVENTIONS.get("WARN")
433
+ if spec is None: # pragma: no cover - WARN is a base rung
434
+ raise KeyError("WARN is not in BASE_INTERVENTIONS")
435
+ return spec
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # The declarative on-ramp — read a grammar out of dos.toml (mirror tool_stream/intervention).
440
+ # ---------------------------------------------------------------------------
441
+ def grammar_from_table(table: dict) -> PrecursorGrammar:
442
+ """Turn a parsed `[precursor]` TOML table into a `PrecursorGrammar`. PURE (no I/O).
443
+
444
+ `table` is `{requires: {tool: [precursor, ...] | precursor}, aliases: {precursor:
445
+ [alias, ...] | alias}, case_sensitive?}` — the shape `tomllib.load(...)["precursor"]`
446
+ yields (`[precursor.requires]` / `[precursor.aliases]` sub-tables). Names are canonicalized
447
+ at load so lookups need no per-call normalization. A scalar value is accepted in place of a
448
+ one-element list (the `see_also` / `ignore_tools` single-string convenience). Missing →
449
+ EMPTY_GRAMMAR (the gate NO_SIGNALs everything = today's behavior). PURE.
450
+ """
451
+ if not table:
452
+ return EMPTY_GRAMMAR
453
+
454
+ def _as_tuple(v) -> tuple:
455
+ if v is None:
456
+ return ()
457
+ if isinstance(v, str):
458
+ return (v,)
459
+ return tuple(v)
460
+
461
+ requires_raw = table.get("requires", {}) or {}
462
+ aliases_raw = table.get("aliases", {}) or {}
463
+ requires = {
464
+ _canon(tool): tuple(_canon(p) for p in _as_tuple(precs))
465
+ for tool, precs in requires_raw.items()
466
+ }
467
+ aliases = {
468
+ _canon(prec): tuple(_canon(a) for a in _as_tuple(als))
469
+ for prec, als in aliases_raw.items()
470
+ }
471
+ return PrecursorGrammar(requires=requires, aliases=aliases)
472
+
473
+
474
+ def load_from_toml(
475
+ path: "Path | str", *, base: PrecursorGrammar = EMPTY_GRAMMAR
476
+ ) -> PrecursorGrammar:
477
+ """Build a `PrecursorGrammar` from a `dos.toml`'s `[precursor]` table.
478
+
479
+ Returns `base` unchanged when the file is absent, has no `[precursor]` table, or `tomllib`
480
+ is unavailable — the declarative path is purely additive, so a missing/empty config degrades
481
+ to the empty grammar (the gate NO_SIGNALs everything), never an error. A *present* table
482
+ REPLACES the grammar wholesale (the `[stamp]`/`[tool_stream]` override shape). Reads with
483
+ `utf-8-sig` to strip a PowerShell-written BOM (the `intervention.load_from_toml` fix).
484
+ """
485
+ p = Path(path)
486
+ if not p.exists():
487
+ return base
488
+ try:
489
+ import tomllib # py3.11+
490
+ except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
491
+ try:
492
+ import tomli as tomllib # type: ignore
493
+ except ModuleNotFoundError:
494
+ return base
495
+ data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
496
+ table = data.get("precursor")
497
+ if not isinstance(table, dict) or not table:
498
+ return base
499
+ return grammar_from_table(table)