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
@@ -0,0 +1,299 @@
1
+ """The reason-class *morphology* — rung 2 of the recognizer ladder, as data.
2
+
3
+ This is the hackability seam that closes the deeper half of the picker oracle's
4
+ blind spot (`docs/105`). `picker_oracle.resolve_cause` maps an emitted
5
+ `reason_class` token onto a closed `NoPickCause` so the oracle can grade a
6
+ NO-PICK. Its first two rungs are **exact string match** — the frozen
7
+ `REASON_CLASS_MAP` (the closed `wedge_reason` enum) and the workspace
8
+ `ReasonRegistry` (`dos.toml [reasons]`). But `reason_class` tokens are
9
+ *LLM-authored compounds* — an open, effectively-infinite set
10
+ (`PLAN_ID_COLLISION_FALSE_SHIPPED`,
11
+ `DL_LANE_STALE_STAMP_BODY_VS_META_DRIFT_PLUS_SHIP_ORACLE_SERIES_COLLISION`, …).
12
+ Exact equality over an infinite generator is brittle by construction: every novel
13
+ compound falls to `UNCLASSIFIED`, even when its category is obvious.
14
+
15
+ The tell measured on `job`'s corpus (`docs/105` §1): each token's **category is
16
+ legible in its morphology** — `*FALSE_SHIP*` is a stale-claim shape, `*OPERATOR*`
17
+ is an operator-gate, `*INFLIGHT*` is a stale-claim (the inflight alias), `*SOAK*`
18
+ /`*GATE*` is a gate. So this module is the **rung-2 recognizer**: an ordered set
19
+ of `(substring → category)` rules, declared as DATA, that classifies the legible
20
+ tail the exact rungs miss. It is the direct analogue of `stamp.StampConvention` —
21
+ the host declares *what a stale-claim token looks like in its dialect*; the kernel
22
+ keeps the closed `NoPickCause` set and every cross-check downstream of it
23
+ (`docs/76` §3's line, applied to the reason-class recognizer instead of the
24
+ ship-subject grammar).
25
+
26
+ The category strings a rule may emit are exactly `reasons.KNOWN_CATEGORIES`
27
+ (`TRUE_DRAIN` / `OPERATOR_GATE` / `STALE_CLAIM` / `MISROUTE` / `UNCLASSIFIED`),
28
+ which equal the `NoPickCause` member values by construction. A rule emitting
29
+ anything else is a host mistake surfaced loudly at load.
30
+
31
+ Pure stdlib — no third-party imports, no I/O — so `picker_oracle` imports it as a
32
+ leaf, the same way it imports `stamp` / `wedge_reason`. The aliases the GENERIC
33
+ ruleset encodes mirror the ones already documented in `reasons.py`'s built-in
34
+ `BASE_REASONS` table (`INFLIGHT → STALE_CLAIM`, `SOAK/LEASE_HELD → OPERATOR_GATE`),
35
+ so the morphological rung and the exact rung agree on category by construction.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from dataclasses import dataclass
41
+ from pathlib import Path
42
+
43
+
44
+ # The category vocabulary a morphology rule may emit. Kept as a module constant
45
+ # (not imported from `reasons`/`picker_oracle`) so this leaf stays pure-stdlib and
46
+ # dependency-free; it is pinned EQUAL to `reasons.KNOWN_CATEGORIES` by
47
+ # `tests/test_reason_morphology.py` so the two can never drift. `UNCLASSIFIED` is
48
+ # included so a rule may *deliberately* map a recognized-but-undecidable shape to
49
+ # the honest floor (rare, but legal).
50
+ KNOWN_CATEGORIES: frozenset[str] = frozenset({
51
+ "TRUE_DRAIN", "OPERATOR_GATE", "STALE_CLAIM", "MISROUTE", "UNCLASSIFIED",
52
+ })
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class MorphologyRule:
57
+ """One ``(substring → category)`` rule of the rung-2 recognizer.
58
+
59
+ Fields:
60
+ substring
61
+ A case-insensitive substring of the reason-class token. Matched with a
62
+ plain ``in`` (uppercased on both sides) — NOT a regex — so a host
63
+ declares a token *shape* (``FALSE_SHIP``), never a pattern. Empty is a
64
+ host mistake (it would match everything); rejected at construction of
65
+ the ruleset.
66
+ category
67
+ The `NoPickCause` value-string this shape maps to. Must be one of
68
+ ``KNOWN_CATEGORIES``; anything else raises at ruleset construction (the
69
+ closed-enum discipline — a typo'd category is surfaced, not silently
70
+ carried as an unknown that resolves to UNCLASSIFIED anyway).
71
+ """
72
+
73
+ substring: str
74
+ category: str
75
+
76
+ def matches(self, token_upper: str) -> bool:
77
+ """True iff this rule's (uppercased) substring occurs in ``token_upper``.
78
+
79
+ The caller passes the already-uppercased token so a ruleset scan does not
80
+ re-uppercase per rule. Pure."""
81
+ return self.substring.upper() in token_upper
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class MorphologyRuleset:
86
+ """An ORDERED set of `MorphologyRule`s — the rung-2 recognizer, as data.
87
+
88
+ Order is load-bearing: `classify` returns the FIRST matching rule's category
89
+ (first-match-wins), so a more-specific shape must precede a more-general one
90
+ (e.g. ``STALE_STAMP`` before a bare ``STAMP``, or ``INFLIGHT`` before ``GATE``
91
+ for a token like ``LANE_ALL_SHIPPED_INFLIGHT_OR_SOAK_GATED`` where the author's
92
+ intent is the in-flight state, not the gate). Because the rung *reports itself*
93
+ (`picker_oracle` records ``cause_source="morphological"`` and the matched
94
+ rule), the precedence a given order encodes is auditable, never buried.
95
+
96
+ Construction validates every rule (non-empty substring, known category) so a
97
+ malformed ruleset fails at load — the same loud-on-malformed posture
98
+ `stamp.convention_from_table` takes.
99
+ """
100
+
101
+ rules: tuple[MorphologyRule, ...] = ()
102
+
103
+ def __post_init__(self) -> None:
104
+ for r in self.rules:
105
+ if not r.substring or not r.substring.strip():
106
+ raise ValueError(
107
+ "MorphologyRule.substring must be a non-empty string "
108
+ "(an empty substring would match every token)"
109
+ )
110
+ if r.category not in KNOWN_CATEGORIES:
111
+ raise ValueError(
112
+ f"MorphologyRule.category {r.category!r} is not a known "
113
+ f"category; must be one of {sorted(KNOWN_CATEGORIES)}"
114
+ )
115
+
116
+ def classify(self, token: str | None) -> tuple[str, str] | None:
117
+ """Return ``(category, matched_substring)`` for the first matching rule.
118
+
119
+ Returns ``None`` when no rule matches (the caller then falls through to the
120
+ ``UNCLASSIFIED`` floor — rung 3). The matched substring is returned so the
121
+ caller can record *which* rule fired (the self-reporting `docs/105` §3.1
122
+ requires). Pure; ``None``/empty token → ``None``.
123
+ """
124
+ if not token:
125
+ return None
126
+ token_upper = token.upper().strip()
127
+ if not token_upper:
128
+ return None
129
+ for r in self.rules:
130
+ if r.matches(token_upper):
131
+ return (r.category, r.substring.upper())
132
+ return None
133
+
134
+ # -- serialization (parity with StampConvention; crosses no subprocess today
135
+ # but kept symmetric so a future grep-rung-style boundary is free) --------
136
+ def to_list(self) -> list[dict]:
137
+ """Plain-data form — a JSON-serializable ordered list of rule dicts."""
138
+ return [{"substring": r.substring, "category": r.category} for r in self.rules]
139
+
140
+ @classmethod
141
+ def from_list(cls, data: object) -> "MorphologyRuleset":
142
+ """Rebuild from `to_list` form. Tolerant of a missing/empty value (→ empty
143
+ ruleset); a malformed entry raises via `__post_init__` / `_rule_from`."""
144
+ if not data:
145
+ return cls(())
146
+ if not isinstance(data, (list, tuple)):
147
+ raise ValueError(
148
+ f"[reasons.morphology] must be a list of rules, got "
149
+ f"{type(data).__name__}"
150
+ )
151
+ return cls(tuple(_rule_from(item) for item in data))
152
+
153
+
154
+ def _rule_from(item: object) -> MorphologyRule:
155
+ """Coerce one parsed TOML/JSON entry into a `MorphologyRule`, raising on shape.
156
+
157
+ Accepts a ``{substring, category}`` table (the canonical form) or a
158
+ two-element ``[substring, category]`` pair (a terse TOML form). Anything else
159
+ is a host mistake surfaced loudly, mirroring `stamp._str_tuple`'s posture.
160
+ """
161
+ if isinstance(item, dict):
162
+ try:
163
+ sub = item["substring"]
164
+ cat = item["category"]
165
+ except KeyError as e:
166
+ raise ValueError(
167
+ f"[reasons.morphology] rule {item!r} is missing required key {e}"
168
+ ) from None
169
+ elif isinstance(item, (list, tuple)) and len(item) == 2:
170
+ sub, cat = item[0], item[1]
171
+ else:
172
+ raise ValueError(
173
+ f"[reasons.morphology] rule must be a {{substring, category}} table or "
174
+ f"a [substring, category] pair, got {item!r}"
175
+ )
176
+ if not isinstance(sub, str) or not isinstance(cat, str):
177
+ raise ValueError(
178
+ f"[reasons.morphology] rule {item!r}: substring and category must both "
179
+ f"be strings"
180
+ )
181
+ # `MorphologyRule` is validated for emptiness/known-category by the ruleset's
182
+ # __post_init__; build it here and let that fire.
183
+ return MorphologyRule(substring=sub, category=cat)
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # The GENERIC, domain-free default ruleset — the kernel ships this so EVERY host
188
+ # gets the legible-tail recovery out of the box, with NO host lanes baked in
189
+ # (`APPLY_LANE_*` / `TAILOR_LANE_*` are a host's; they are NOT here — a host
190
+ # declares those via `dos.toml [reasons.morphology]`). Every substring below is a
191
+ # DOMAIN-FREE distrust shape, and every category matches the alias intuitions
192
+ # already encoded in `reasons.BASE_REASONS` (so rung-2 agrees with rung-1).
193
+ #
194
+ # Order is most-specific-first. The category for each shape:
195
+ # *FALSE_SHIP* / *FALSE_SHIPPED* -> STALE_CLAIM (claims-shipped-but-not: the
196
+ # diagnostic picker-bug shape — a stamp the
197
+ # ship oracle would refuse)
198
+ # *STALE_STAMP* / *STAMP_DRIFT* -> STALE_CLAIM (a stamp present but stale)
199
+ # *OPERATOR* -> OPERATOR_GATE (operator-attended / decision-pending)
200
+ # *SOAK* -> OPERATOR_GATE (soak window open)
201
+ # *INFLIGHT* / *IN_FLIGHT* -> STALE_CLAIM (the inflight alias — matches
202
+ # reasons.py: LANE_ALL_INFLIGHT_* -> STALE_CLAIM)
203
+ # *LEASE_HELD* -> OPERATOR_GATE (held by a live loop — the LEASE_HELD alias)
204
+ # *GATE* -> OPERATOR_GATE (a generic gate; AFTER soak/inflight
205
+ # so a *...INFLIGHT...GATE* token reads as inflight)
206
+ # *DRAIN* / *DRAINED* -> TRUE_DRAIN (the lane is drained — genuinely nothing)
207
+ # *NO_DISPATCHABLE* / *NOTHING* -> TRUE_DRAIN (no dispatchable phase)
208
+ # *MISROUTE* / *MIS_ROUT* -> MISROUTE (finding routed to the wrong lane)
209
+ #
210
+ # Note `STALE_STAMP` precedes nothing it would shadow, but `DRAIN` is placed AFTER
211
+ # the stamp/inflight shapes so `STALE_STAMP_LANE_DRAINED` reads as STALE_CLAIM (the
212
+ # stamp is the salient defect), not TRUE_DRAIN. This precedence is the auditable
213
+ # judgment `docs/105` §3.2 calls out; it is DATA precisely so a host can reorder it.
214
+ # ---------------------------------------------------------------------------
215
+ GENERIC_REASON_MORPHOLOGY = MorphologyRuleset((
216
+ MorphologyRule("FALSE_SHIPPED", "STALE_CLAIM"),
217
+ MorphologyRule("FALSE_SHIP", "STALE_CLAIM"),
218
+ MorphologyRule("STALE_STAMP", "STALE_CLAIM"),
219
+ MorphologyRule("STAMP_DRIFT", "STALE_CLAIM"),
220
+ MorphologyRule("STAMPING_DRIFT", "STALE_CLAIM"),
221
+ MorphologyRule("OPERATOR", "OPERATOR_GATE"),
222
+ MorphologyRule("SOAK", "OPERATOR_GATE"),
223
+ MorphologyRule("INFLIGHT", "STALE_CLAIM"),
224
+ MorphologyRule("IN_FLIGHT", "STALE_CLAIM"),
225
+ MorphologyRule("LEASE_HELD", "OPERATOR_GATE"),
226
+ MorphologyRule("GATE", "OPERATOR_GATE"),
227
+ MorphologyRule("DRAINED", "TRUE_DRAIN"),
228
+ MorphologyRule("DRAIN", "TRUE_DRAIN"),
229
+ MorphologyRule("NO_DISPATCHABLE", "TRUE_DRAIN"),
230
+ MorphologyRule("MISROUTE", "MISROUTE"),
231
+ MorphologyRule("MIS_ROUT", "MISROUTE"),
232
+ ))
233
+
234
+
235
+ # The EMPTY ruleset — an explicit "no morphological rung" value. A workspace that
236
+ # wants only the two exact rungs (the pre-`docs/105` behavior) can set this, and a
237
+ # `[reasons.morphology] = []` table degrades to it. Distinct from
238
+ # GENERIC_REASON_MORPHOLOGY: empty means "rung 2 is off", generic means "rung 2 with
239
+ # the kernel's domain-free defaults".
240
+ NO_REASON_MORPHOLOGY = MorphologyRuleset(())
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # The declarative on-ramp: read a `[reasons.morphology]` list out of dos.toml.
245
+ # Mirrors `stamp.load_from_toml` / `reasons.load_from_toml`: a present list
246
+ # OVERRIDES the base (declaring your morphology means declaring it, not appending
247
+ # to the kernel's); absent/empty degrades to the base; present-but-malformed
248
+ # raises. The TOML shape (an array of tables, the natural TOML form for an ordered
249
+ # list of rules):
250
+ #
251
+ # [[reasons.morphology]]
252
+ # substring = "APPLY_LANE_BLOCKED_MESH" # a host's own shape
253
+ # category = "OPERATOR_GATE"
254
+ #
255
+ # [[reasons.morphology]]
256
+ # substring = "RESPAWN"
257
+ # category = "TRUE_DRAIN"
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ def load_from_toml(
262
+ path: Path | str, *, base: MorphologyRuleset = GENERIC_REASON_MORPHOLOGY
263
+ ) -> MorphologyRuleset:
264
+ """Build a `MorphologyRuleset` from a `dos.toml`'s `[reasons.morphology]` list.
265
+
266
+ Returns ``base`` unchanged when the file is absent, has no
267
+ `[reasons.morphology]` key, or `tomllib` is unavailable — the declarative path
268
+ is additive, so a missing config degrades to the supplied base (the kernel's
269
+ generic ruleset), never an error. A *present but malformed* list raises
270
+ (`from_list` / `_rule_from`), because a host that declared its morphology wrong
271
+ wants it surfaced. Mirrors `stamp.load_from_toml` exactly.
272
+
273
+ Override (not merge) semantics: a host that declares a `[reasons.morphology]`
274
+ gets EXACTLY its rules, not its rules plus the kernel's generic ones. To keep
275
+ the generic shapes a host re-lists the ones it wants — declaring your
276
+ morphology means declaring it. (A future `extend = true` key could add merge
277
+ semantics; out of scope for `docs/105`.)
278
+ """
279
+ p = Path(path)
280
+ if not p.exists():
281
+ return base
282
+ try:
283
+ import tomllib # py3.11+
284
+ except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
285
+ try:
286
+ import tomli as tomllib # type: ignore
287
+ except ModuleNotFoundError:
288
+ return base
289
+ data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
290
+ reasons_tbl = data.get("reasons")
291
+ if not isinstance(reasons_tbl, dict):
292
+ return base
293
+ morph = reasons_tbl.get("morphology")
294
+ if morph is None:
295
+ return base
296
+ # An explicit empty list means "turn rung 2 off" → NO_REASON_MORPHOLOGY, not base.
297
+ if isinstance(morph, (list, tuple)) and len(morph) == 0:
298
+ return NO_REASON_MORPHOLOGY
299
+ return MorphologyRuleset.from_list(morph)