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/config.py ADDED
@@ -0,0 +1,1380 @@
1
+ """The config seam — the one place the substrate learns *which workspace* it serves.
2
+
3
+ This module is the load-bearing half of the Dispatch-OS port (the
4
+ "Stage-1 kernel extraction").
5
+
6
+ The reference userland app's spine bound its state location at import time::
7
+
8
+ REPO_ROOT = Path(__file__).resolve().parent.parent # "the repo I live in"
9
+ STATE_PATH = REPO_ROOT / "docs" / "_plans" / "execution-state.yaml"
10
+
11
+ That single assumption — *my code and my managed state share a tree* — is the
12
+ entire thing standing between a repo-bound script and a separable OS. DOS
13
+ replaces it with "the workspace I was pointed at": an injected workspace root.
14
+
15
+ The mechanism (the verdict enum, the oracle, the lease algebra) lives in the
16
+ `dos` package and carries **no policy**. The policy (which lanes exist, where
17
+ plans live, what counts as a ship stamp) is per-workspace and lives in a
18
+ `SubstrateConfig` the host supplies — the reference userland app builds
19
+ `JOB_CONFIG`, a foreign repo builds its own, a throwaway directory gets
20
+ `default_config()`. Co-location was always about keeping *policy* at its call
21
+ site; a per-workspace config object *is* that call site, expressed as data the
22
+ shared mechanism reads.
23
+
24
+ Resolution order for the active workspace (highest precedence first):
25
+ 1. an explicit `SubstrateConfig` / `--workspace` passed by the caller,
26
+ 2. the ``DISPATCH_WORKSPACE`` environment variable,
27
+ 3. the current working directory.
28
+
29
+ So `dos` run from inside any workspace defaults to serving that workspace, and a
30
+ host that installed `dos` as a dependency points it elsewhere with one env var
31
+ or one constructor argument — never by editing the package.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ import sys
38
+ from dataclasses import dataclass, field, replace
39
+ from pathlib import Path
40
+
41
+ from dos.reasons import ReasonRegistry, BASE_REASONS
42
+ from dos.intervention import InterventionLadder, BASE_INTERVENTIONS
43
+ from dos.tool_stream import StreamPolicy, DEFAULT_POLICY as DEFAULT_STREAM_POLICY
44
+ from dos.marker_gate import MarkerPolicy, DEFAULT_POLICY as DEFAULT_MARKER_POLICY
45
+ from dos.precursor_gate import PrecursorGrammar, EMPTY_GRAMMAR as EMPTY_PRECURSOR_GRAMMAR
46
+ from dos.stamp import StampConvention, JOB_STAMP_CONVENTION, GENERIC_STAMP_CONVENTION
47
+ from dos.enumerate import EnumerateGrammar, GENERIC_GRAMMAR
48
+ from dos.cooldown import CooldownPolicy, DEFAULT_COOLDOWN_POLICY
49
+ from dos.supervise import SupervisePolicy, DEFAULT_POLICY as DEFAULT_SUPERVISE_POLICY
50
+ from dos.lifecycle import LifecyclePolicy, GENERIC_LIFECYCLE
51
+ from dos.reason_morphology import MorphologyRuleset, GENERIC_REASON_MORPHOLOGY
52
+ from dos.concurrency_class import ClassBudgets, NO_CLASS_BUDGETS
53
+ from dos.env_print import EnvPrint, gather_env_print
54
+ from dos.retention import RetentionPolicy, GENERIC_RETENTION
55
+ from dos.data_class import DataClassPolicy, GENERIC_DATA_CLASS
56
+
57
+ # The default soft-overlap tolerance — mirrored from `dos.lane_overlap.
58
+ # OVERLAP_RATIO_MAX` (⅓) by VALUE, not import: `config` (layer 2a) must not import
59
+ # a kernel module (layer 1), or it would couple the config seam to the
60
+ # `admission`→`lane_overlap`→`_tree` chain (see the import-cycle note below).
61
+ # `overlap_policy._ratio_max_from_config` reads the field; `lane_overlap` is the
62
+ # canonical home of the constant. The two are pinned equal by
63
+ # `tests/test_overlap_policy.py` so they cannot drift.
64
+ _DEFAULT_OVERLAP_RATIO_MAX = 1 / 3
65
+
66
+ # The env var a host sets to point the installed package at a workspace that is
67
+ # NOT the cwd (e.g. the reference userland app pointing `dos` at its own tree, or
68
+ # a sidecar checkout pointed at a sibling repo). Mirrors the reference userland
69
+ # app's `JOB_*_PATH` override idiom.
70
+ ENV_WORKSPACE = "DISPATCH_WORKSPACE"
71
+
72
+ # The env var that points the machine-local DOS_HOME (the central projection
73
+ # store: ~/.dos by default) somewhere else — the home-tier analogue of
74
+ # ENV_WORKSPACE. Highest precedence in `resolve_dos_home`.
75
+ ENV_DOS_HOME = "DISPATCH_HOME"
76
+
77
+ # The env var the ship oracle uses to carry the ACTIVE stamp convention into the
78
+ # grep-rung subprocess (`python -m dos.phase_shipped`). That child re-derives its
79
+ # own `active()` config from scratch, so without this it would lose a caller-
80
+ # installed (`set_active`) or `dos.toml`-declared convention and fall back to the
81
+ # reference default. The parent JSON-encodes `cfg.stamp.to_dict()` here; the child's
82
+ # bootstrap reads it back (see `phase_shipped._bootstrap_active_config`). This is
83
+ # what makes the convention authoritative across the process boundary.
84
+ ENV_STAMP_CONVENTION = "DISPATCH_STAMP_CONVENTION"
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class LaneTaxonomy:
89
+ """The concurrency policy as data — which lanes may run together.
90
+
91
+ * ``concurrent`` — cluster lanes that run in parallel iff their file trees
92
+ are provably disjoint (the reference userland app's ``apply`` / ``tailor``
93
+ / ``discovery``).
94
+ * ``exclusive`` — lanes that never run alongside anything (``orchestration`` /
95
+ ``global``): holding one refuses every other request.
96
+ * ``autopick`` — the ordered set a bare (lane-less) request walks to find a
97
+ free, non-empty lane.
98
+ * ``trees`` — each named lane's canonical file tree (repo-relative globs).
99
+ This is what the reference userland app hard-coded in its renderer; here it
100
+ is per-workspace data, so the arbiter never mentions a domain lane name.
101
+ * ``aliases`` — keyword → named-lane routing (e.g. ``ff`` → ``fleet``).
102
+ """
103
+
104
+ concurrent: tuple[str, ...] = ()
105
+ exclusive: tuple[str, ...] = ()
106
+ autopick: tuple[str, ...] = ()
107
+ trees: dict[str, tuple[str, ...]] = field(default_factory=dict)
108
+ aliases: dict[str, str] = field(default_factory=dict)
109
+
110
+ def tree_for(self, lane: str) -> list[str]:
111
+ """The canonical file tree for ``lane`` (empty list if unknown)."""
112
+ return list(self.trees.get(lane, ()))
113
+
114
+ def is_exclusive(self, lane: str) -> bool:
115
+ return lane in self.exclusive
116
+
117
+ def is_concurrent(self, lane: str) -> bool:
118
+ return lane in self.concurrent
119
+
120
+ @classmethod
121
+ def from_table(cls, table: dict) -> "LaneTaxonomy":
122
+ """Build a `LaneTaxonomy` from a parsed `[lanes]` TOML table (WCR Phase 1).
123
+
124
+ Pure (no I/O); mirrors `reasons.specs_from_table` / `stamp.convention_from_table`.
125
+ The table shape mirrors the dataclass::
126
+
127
+ [lanes]
128
+ concurrent = ["api", "worker"] # cluster lanes, parallel iff disjoint
129
+ exclusive = ["infra"] # lanes that run alone
130
+ autopick = ["api", "worker"] # the bare-request walk order
131
+ [lanes.trees]
132
+ api = ["src/api/**"]
133
+ worker = ["src/worker/**"]
134
+ [lanes.aliases]
135
+ ff = "fleet"
136
+
137
+ Tolerant of missing keys (each list defaults to ``()``; ``trees`` /
138
+ ``aliases`` default to ``{}``). Rejects, with a `ValueError` naming the
139
+ offending lane/key, a value that is not the shape the dataclass needs:
140
+
141
+ * a non-table ``table`` (a host wrote ``[lanes]`` as a scalar),
142
+ * a list field (``concurrent``/``exclusive``/``autopick``) that is not a
143
+ list of strings,
144
+ * a ``[lanes.trees]`` entry whose value is not a list of strings,
145
+ * a ``[lanes.aliases]`` entry whose value is not a string.
146
+
147
+ Loud-on-malformed matches the sibling seams: a host that declared its
148
+ taxonomy wrong wants that surfaced at load, not silently dropped to a lane
149
+ with no tree (which the disjointness algebra can't arbitrate). This builds
150
+ a *value* and names no job lane — Law 1 (kernel imports no host) holds: a
151
+ TOML-declared lane is pure workspace data.
152
+ """
153
+ if not isinstance(table, dict):
154
+ raise ValueError(f"[lanes] must be a table, got {type(table).__name__}")
155
+
156
+ def _str_list(value: object, key: str) -> tuple[str, ...]:
157
+ if not isinstance(value, (list, tuple)):
158
+ raise ValueError(
159
+ f"[lanes].{key} must be a list of strings, "
160
+ f"got {type(value).__name__}"
161
+ )
162
+ out: list[str] = []
163
+ for item in value:
164
+ if not isinstance(item, str):
165
+ raise ValueError(
166
+ f"[lanes].{key} must be a list of strings; got a "
167
+ f"{type(item).__name__} element ({item!r})"
168
+ )
169
+ out.append(item)
170
+ return tuple(out)
171
+
172
+ trees_table = table.get("trees", {}) or {}
173
+ if not isinstance(trees_table, dict):
174
+ raise ValueError(
175
+ f"[lanes.trees] must be a table, got {type(trees_table).__name__}"
176
+ )
177
+ trees: dict[str, tuple[str, ...]] = {}
178
+ for lane, globs in trees_table.items():
179
+ if not isinstance(globs, (list, tuple)):
180
+ raise ValueError(
181
+ f"[lanes.trees].{lane} must be a list of glob strings, "
182
+ f"got {type(globs).__name__}"
183
+ )
184
+ tree: list[str] = []
185
+ for g in globs:
186
+ if not isinstance(g, str):
187
+ raise ValueError(
188
+ f"[lanes.trees].{lane} must be a list of glob strings; "
189
+ f"got a {type(g).__name__} element ({g!r})"
190
+ )
191
+ tree.append(g)
192
+ trees[str(lane)] = tuple(tree)
193
+
194
+ aliases_table = table.get("aliases", {}) or {}
195
+ if not isinstance(aliases_table, dict):
196
+ raise ValueError(
197
+ f"[lanes.aliases] must be a table, got {type(aliases_table).__name__}"
198
+ )
199
+ aliases: dict[str, str] = {}
200
+ for keyword, lane in aliases_table.items():
201
+ if not isinstance(lane, str):
202
+ raise ValueError(
203
+ f"[lanes.aliases].{keyword} must be a string lane name, "
204
+ f"got {type(lane).__name__}"
205
+ )
206
+ aliases[str(keyword)] = lane
207
+
208
+ return cls(
209
+ concurrent=_str_list(table.get("concurrent", ()), "concurrent"),
210
+ exclusive=_str_list(table.get("exclusive", ()), "exclusive"),
211
+ autopick=_str_list(table.get("autopick", ()), "autopick"),
212
+ trees=trees,
213
+ aliases=aliases,
214
+ )
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class PathLayout:
219
+ """Where this workspace keeps the state the substrate reads/writes.
220
+
221
+ Every path the ported spine hard-coded relative to ``REPO_ROOT`` is named
222
+ here, resolved against an injected ``root``. The defaults reproduce the
223
+ reference userland app's layout so it is a zero-surprise consumer; a foreign
224
+ repo overrides the ones that differ (a foreign repo's plans live in
225
+ ``docs/active-plans.md``, say) and leaves the rest.
226
+ """
227
+
228
+ root: Path
229
+ execution_state: Path
230
+ plans_glob: str
231
+ findings_queue: Path
232
+ fanout_runs: Path
233
+ dispatch_loops: Path
234
+ chained_runs: Path
235
+ next_packets: Path
236
+ replan_dir: Path
237
+ soaks_index: Path
238
+ picker_audits: Path
239
+ archive_lock: Path
240
+ lane_journal: Path
241
+ # --- new fields (DOS-HOME / docs/74) -----------------------------------
242
+ # Added keyword-only with defaults AT THE END of the dataclass so the
243
+ # 13-field positional construction `for_root` and any positional caller use
244
+ # is byte-unchanged (a non-default field after a default field is a Python
245
+ # error; appending defaulted fields is the only back-compatible widening).
246
+ # leases_dir — where the lease state + archive lock live. In `for_root`
247
+ # this is `docs/_plans` (the lock keeps its literal path,
248
+ # NOT re-derived from this); in `for_dos_dir` it is
249
+ # `.dos/leases` and the lock IS derived from it.
250
+ # project_card — the `.dos/project.json` identity card (None under the
251
+ # reference layout, which has no `.dos/`).
252
+ # style — the layout discriminator: "repo" (the reference docs/
253
+ # layout) vs "dos" (the generic `.dos/` layout). `with_root`
254
+ # branches on this to re-point a config without dragging a
255
+ # `.dos/` layout back onto the reference tree.
256
+ # verdict_journal — the verdict WAL (docs/262), the lane journal's lateral
257
+ # sibling: a durable, append-only, run-id-correlated record of
258
+ # every adjudication the kernel makes (`verify`/`liveness`/…),
259
+ # read by `dos observe`. Defaulted keyword-only (the same
260
+ # back-compatible widening as the DOS-HOME fields above) so a
261
+ # positional caller is byte-unchanged; defaults to a sibling of
262
+ # `lane_journal` under each layout (set in `for_root`/`for_dos_dir`).
263
+ leases_dir: Path | None = None
264
+ project_card: Path | None = None
265
+ style: str = "repo"
266
+ verdict_journal: Path | None = None
267
+
268
+ @property
269
+ def dot_dos(self) -> Path:
270
+ """The per-project `.dos/` home (derived, not stored — never duplicates
271
+ ``root``). Vocabulary for callers; the generic layout's emissions live
272
+ under here."""
273
+ return self.root / ".dos"
274
+
275
+ @property
276
+ def verdicts_dir(self) -> Path:
277
+ """The verdict-envelope directory. It IS ``next_packets`` (one directory,
278
+ one name — a separate field would invite drift); this read-only alias
279
+ gives the `.dos/verdicts` vocabulary without a second source of truth."""
280
+ return self.next_packets
281
+
282
+ @classmethod
283
+ def for_root(cls, root: Path | str) -> "PathLayout":
284
+ """Build the reference-app-shaped default layout under ``root``.
285
+
286
+ A foreign workspace calls this then `dataclasses.replace(...)` for the
287
+ handful of paths that genuinely differ.
288
+ """
289
+ r = Path(root).resolve()
290
+ plans = r / "docs" / "_plans"
291
+ return cls(
292
+ root=r,
293
+ execution_state=plans / "execution-state.yaml",
294
+ plans_glob="docs/**/*-plan.md",
295
+ findings_queue=plans / "findings-followup-queue.md",
296
+ fanout_runs=r / "docs" / "_fanout_runs",
297
+ dispatch_loops=r / "docs" / "_dispatch_loops",
298
+ chained_runs=r / "docs" / "_chained_runs",
299
+ next_packets=r / "output" / "next-up",
300
+ replan_dir=r / "docs" / "_replan",
301
+ soaks_index=r / "docs" / "_soaks" / "index.yaml",
302
+ picker_audits=r / "docs" / "_picker_audits",
303
+ archive_lock=r / "docs" / "_fanout_runs" / ".archive.lock",
304
+ lane_journal=plans / "lane-journal.jsonl",
305
+ leases_dir=plans,
306
+ project_card=None,
307
+ style="repo",
308
+ verdict_journal=plans / "verdict-journal.jsonl",
309
+ )
310
+
311
+ # The fields a `[paths]` table may override, and how each is coerced.
312
+ # * `plans_glob` is a plain string (a glob) — NOT resolved against root.
313
+ # * every other override is a path: a RELATIVE value resolves against
314
+ # `self.root` (so a host writes `planning/*.md`, not an absolute path),
315
+ # an absolute value is taken as-is.
316
+ # `root` and `style` are deliberately NOT here:
317
+ # * `root` — re-pointing the workspace is `with_root`'s job (it rebuilds the
318
+ # whole layout under the new root); letting `[paths]` set `root` would
319
+ # desync `root` from the paths derived off it.
320
+ # * `style` — it is a DERIVED discriminator over the SHAPE of the other path
321
+ # fields (`repo` = reference docs/ layout, `dos` = `.dos/` layout), set by
322
+ # `for_root`/`for_dos_dir`, not an independent value. Letting `[paths]`
323
+ # override it lets the discriminator LIE about the field shapes, and a
324
+ # later `with_root` (which branches on `style`) would then rebuild the
325
+ # layout in the wrong shape — the exact Law-1 hazard `with_root`'s
326
+ # docstring warns against. Same desync rationale as `root`.
327
+ _OVERRIDABLE_STR_FIELDS = frozenset({"plans_glob"})
328
+ _OVERRIDABLE_PATH_FIELDS = frozenset({
329
+ "execution_state", "findings_queue", "fanout_runs", "dispatch_loops",
330
+ "chained_runs", "next_packets", "replan_dir", "soaks_index",
331
+ "picker_audits", "archive_lock", "lane_journal", "leases_dir",
332
+ "project_card", "verdict_journal",
333
+ })
334
+
335
+ def with_overrides(self, table: dict) -> "PathLayout":
336
+ """Return a copy with the layout fields named in ``table`` overridden (WCR Phase 2).
337
+
338
+ Pure. Starts from ``self`` (the caller passes the base layout, already
339
+ built `for_root`/`for_dos_dir` under the workspace), then
340
+ `dataclasses.replace`s only the fields the table names:
341
+
342
+ * ``plans_glob`` / ``style`` are strings, taken verbatim.
343
+ * every other known field is a path — a RELATIVE value resolves against
344
+ ``self.root``, an absolute value is kept as-is. So a foreign repo whose
345
+ plans live in ``planning/`` writes ``plans_glob = "planning/*.md"`` and
346
+ (if it relocates state) ``execution_state = "planning/state.yaml"``.
347
+
348
+ An UNKNOWN key raises `ValueError` (a typo'd path field — ``plnas_glob`` —
349
+ is a host mistake worth surfacing loudly, not silently ignoring; the same
350
+ posture `stamp.convention_from_table` takes on an unknown `[stamp]` key).
351
+ ``root`` is not overridable (see the field-set note above).
352
+ """
353
+ if not isinstance(table, dict):
354
+ raise ValueError(f"[paths] must be a table, got {type(table).__name__}")
355
+ known = self._OVERRIDABLE_STR_FIELDS | self._OVERRIDABLE_PATH_FIELDS
356
+ unknown = set(table) - known
357
+ if unknown:
358
+ raise ValueError(
359
+ f"[paths] has unknown key(s) {sorted(unknown)}; "
360
+ f"known keys are {sorted(known)}"
361
+ )
362
+ changes: dict[str, object] = {}
363
+ for key, value in table.items():
364
+ if key in self._OVERRIDABLE_STR_FIELDS:
365
+ if not isinstance(value, str):
366
+ raise ValueError(
367
+ f"[paths].{key} must be a string, got {type(value).__name__}"
368
+ )
369
+ changes[key] = value
370
+ else: # a path field
371
+ if not isinstance(value, str):
372
+ raise ValueError(
373
+ f"[paths].{key} must be a path string, "
374
+ f"got {type(value).__name__}"
375
+ )
376
+ p = Path(value)
377
+ changes[key] = p if p.is_absolute() else (self.root / p)
378
+ return replace(self, **changes)
379
+
380
+ @classmethod
381
+ def for_dos_dir(cls, root: Path | str) -> "PathLayout":
382
+ """Build the generic ``.dos/`` layout under ``root`` (docs/74).
383
+
384
+ DOS's own emissions (run dirs, the lane WAL, leases, verdict envelopes,
385
+ the soak index, picker audits) move under a single per-project ``.dos/``
386
+ home — a re-derivable, deletable, gitignored-by-default tree separate
387
+ from the served repo's content. The host's plan registry — the truth DOS
388
+ *reads*, not the scratch it *writes* — stays repo-relative, at a
389
+ GENERIC, non-reference-shaped location (``dos.state.yaml`` at the root, NOT
390
+ ``docs/_plans/execution-state.yaml`` — copying the reference app's path
391
+ would re-bake a host's directory dialect into the domain-free default).
392
+
393
+ The three run-dir fields collapse to one value (``.dos/runs``): they
394
+ stay three *fields* for back-compat with `for_root`, but here they alias
395
+ one directory. Run dirs keep their UTC-timestamp NAMES (the run-dir
396
+ consumers — `picker_oracle._list_recent_runs`, `timeline` — parse a
397
+ ``^\\d{8}T\\d{6}Z`` name); the ``run_id`` lineage lives INSIDE each
398
+ run dir's ``run.json``, not in the dir name.
399
+ """
400
+ r = Path(root).resolve()
401
+ d = r / ".dos"
402
+ leases = d / "leases"
403
+ runs = d / "runs"
404
+ return cls(
405
+ root=r,
406
+ # Host registry — repo-relative, generic (NOT under .dos/, NOT reference-shaped).
407
+ execution_state=r / "dos.state.yaml",
408
+ plans_glob="docs/**/*-plan.md",
409
+ findings_queue=r / "dos.findings.md",
410
+ # DOS emissions — all under .dos/. The three run trees alias one dir.
411
+ fanout_runs=runs,
412
+ dispatch_loops=runs,
413
+ chained_runs=runs,
414
+ next_packets=d / "verdicts",
415
+ replan_dir=d / "replan",
416
+ soaks_index=d / "soaks" / "index.yaml",
417
+ picker_audits=d / "picker_audits",
418
+ archive_lock=leases / ".archive.lock",
419
+ lane_journal=d / "lane-journal.jsonl",
420
+ leases_dir=leases,
421
+ project_card=d / "project.json",
422
+ style="dos",
423
+ verdict_journal=d / "verdict-journal.jsonl",
424
+ )
425
+
426
+
427
+ @dataclass(frozen=True)
428
+ class WorkspaceFacts:
429
+ """Facts ABOUT the served workspace, discovered once via I/O at build time.
430
+
431
+ This is the third seam-value on ``SubstrateConfig`` — after ``lanes`` (which
432
+ lanes exist) and ``paths`` (where state lives), it answers *what is true of
433
+ this particular tree*. The motivating fact: **which of the kernel's own
434
+ runtime files actually EXIST under this root.** The SELF_MODIFY admission
435
+ guard must refuse a whole-repo (`**/*`) lease only when DOS is serving its
436
+ OWN repo (those files are present, so a lease really could rewrite the live
437
+ kernel) — and admit the same lease against a foreign repo (they are not, so
438
+ nothing self-modifying is possible). That decision needs a filesystem probe,
439
+ which a *pure* kernel verdict (`arbiter.arbitrate`) may not perform.
440
+
441
+ Resolving it HERE — at config-build time, the same boundary that already does
442
+ the `dos.toml` reads — keeps the arbiter pure: the probe runs once, the result
443
+ is cached as data, and every later admission reads `cfg.workspace` instead of
444
+ re-touching the disk. This is the same "I/O at the boundary, data to the pure
445
+ core" discipline as `git_delta`/`journal_delta` feeding `liveness.classify`,
446
+ lifted to the config seam so the *workspace itself* is a first-class object
447
+ with discovered properties, not a bare root path re-probed ad hoc.
448
+
449
+ ``None`` on a ``SubstrateConfig`` means "facts were not gathered" — the pure,
450
+ I/O-free construction path (the dataclass default, a hand-built test config).
451
+ A consumer that needs a fact treats ``None`` conservatively (the safe
452
+ direction for a *safety* guard: assume the kernel files MIGHT be present when
453
+ we never looked), exactly as `built_in_predicates(workspace=None)` does today.
454
+
455
+ root — the resolved workspace root these facts describe
456
+ (carried so a fact set is self-identifying / never
457
+ silently applied to the wrong tree after a re-point).
458
+ kernel_runtime_files — the subset of `self_modify._DISPATCH_RUNTIME_FILES`
459
+ that exist under ``root``. Empty ⇒ a foreign repo;
460
+ the full set ⇒ DOS serving itself. The one fact that
461
+ makes the SELF_MODIFY guard workspace-aware without
462
+ an I/O call inside the pure arbiter.
463
+ is_kernel_repo — convenience flag (``kernel_runtime_files`` non-empty):
464
+ "is this the DOS kernel's own tree?" A `dos doctor`
465
+ row and a future self-host guard read it.
466
+ """
467
+
468
+ root: Path
469
+ kernel_runtime_files: tuple[str, ...] = ()
470
+ is_kernel_repo: bool = False
471
+
472
+
473
+ def gather_workspace_facts(workspace: Path | str | None = None) -> WorkspaceFacts:
474
+ """Probe ``workspace`` once and freeze the discovered facts (the ONE I/O home).
475
+
476
+ Called by the config BUILDERS (`default_config` / `job_config` /
477
+ `load_workspace_config`) — the boundary that is already allowed to touch the
478
+ disk — never by a pure verdict. Mirrors `self_modify.existing_runtime_files`
479
+ (and reuses it): a foreign repo yields ``kernel_runtime_files=()`` (and
480
+ `is_kernel_repo=False`); the DOS repo serving itself yields the full set.
481
+
482
+ Imported lazily from `dos.self_modify` to keep the import graph a strict DAG
483
+ — `config` is a near-leaf (only `reasons`/`stamp`), and `self_modify` pulls
484
+ `admission`→`lane_overlap`→`_tree`; a top-level import here would couple the
485
+ config seam to the admission chain. The lazy import keeps `config` importable
486
+ on its own (the `admission.built_in_predicates` lazy-import rule, applied in
487
+ the other direction).
488
+ """
489
+ root = resolve_workspace_root(workspace)
490
+ from dos.self_modify import existing_runtime_files
491
+ files = existing_runtime_files(root)
492
+ return WorkspaceFacts(
493
+ root=root,
494
+ kernel_runtime_files=tuple(files),
495
+ is_kernel_repo=bool(files),
496
+ )
497
+
498
+
499
+ @dataclass(frozen=True)
500
+ class SubstrateConfig:
501
+ """The complete per-workspace policy the domain-free mechanism reads.
502
+
503
+ Constructed once by the host and threaded into the spine functions that used
504
+ to read module-level constants. ``plan_meta_schema`` is a forward hook for a
505
+ workspace whose plans carry a different frontmatter grammar (the §3
506
+ derive-from-prose adapter for a brownfield repo); v0 leaves it ``None`` and
507
+ the reference-shaped parsers are used.
508
+
509
+ ``reasons`` is the refusal vocabulary as data (the second mechanism/policy
510
+ split, after ``lanes``): which closed ``reason_class`` tokens a no-pick /
511
+ blocked verdict may carry, each with its category / refusal-ness / man-page
512
+ fields. Defaults to ``BASE_REASONS`` (the seven the reference spine shipped as
513
+ a closed enum) so every existing consumer is byte-unchanged; a workspace adds
514
+ its own with ``reasons=BASE_REASONS.extend([...])`` (or declares them in
515
+ ``dos.toml`` — see ``dos.reasons``). The kernel's emit / verify / refuse / man
516
+ surfaces all read this one declaration, so a declared reason is simultaneously
517
+ emittable, verifiable, and refusable.
518
+
519
+ ``stamp`` is the ship-stamp convention as data (the third mechanism/policy
520
+ split): the grep rung's *subject grammar* — which commit-subject shapes count
521
+ as a direct ship — that ``phase_shipped`` used to hardcode as the reference
522
+ app's ``(docs|go|agents|…)/<SERIES>:`` prefix. Defaults to
523
+ ``JOB_STAMP_CONVENTION`` (that exact grammar, lifted verbatim) so the
524
+ reference userland app and the existing kernel suite are byte-for-byte
525
+ unchanged; a foreign workspace declares its own dirs in
526
+ ``dos.toml`` ``[stamp]`` (``dos.stamp.load_from_toml``) or installs
527
+ ``GENERIC_STAMP_CONVENTION`` to recognise a bare ``<SERIES>: <PHASE>``. The
528
+ truth syscall reads this one declaration, so ``verify`` is domain-free for any
529
+ repo that declares (or inherits the generic) ship grammar.
530
+
531
+ ``reason_morphology`` is rung 2 of the reason-class recognizer as data
532
+ (``docs/105``): an ordered ``(substring → category)`` ``MorphologyRuleset`` the
533
+ picker oracle's ``resolve_cause`` consults AFTER the exact rungs (frozen map +
534
+ ``reasons`` registry) to classify the legible tail of LLM-authored compound
535
+ ``reason_class`` tokens (``*FALSE_SHIP*``/``*OPERATOR*``/…) that exact equality
536
+ misses. Defaults to ``GENERIC_REASON_MORPHOLOGY`` (domain-free shapes, no host
537
+ lanes) so every workspace gets the legible-tail recovery out of the box; a host
538
+ extends it in ``dos.toml`` ``[[reasons.morphology]]``
539
+ (``dos.reason_morphology.load_from_toml``). Same mechanism/policy split as
540
+ ``stamp``: the host widens what is *recognized*; the kernel keeps the closed
541
+ ``NoPickCause`` set and every cross-check downstream of it.
542
+
543
+ ``overlap_ratio_max`` / ``overlap_policy_name`` are the **overlap seam** (Axis
544
+ 7, ``docs/113``) — the pluggable disjointness scorer that decides whether two
545
+ known trees may run concurrently. ``overlap_ratio_max`` (default ⅓) is the
546
+ *data* knob: the soft-overlap tolerance the built-in ``prefix`` scorer admits
547
+ under, declarable in ``dos.toml`` ``[overlap] ratio_max`` — the calibrated
548
+ elbow `docs/90 §2` named a research stand-in, now a value not a hardcode.
549
+ ``overlap_policy_name`` (default ``"prefix"``) names the *scorer* itself: the
550
+ built-in deterministic prefix-ratio, or a workspace's ``dos.overlap_policies``
551
+ entry-point plugin (an import-graph / semantic / model-backed scorer). Whatever
552
+ the scorer, ``overlap_policy.admissible_under_floor`` AND-s it under the
553
+ unforgeable prefix floor, so a swappable scorer can only refuse-MORE, never
554
+ admit a collision (the structural soundness floor — `docs/113 §3`). Both are
555
+ resolved at the call boundary and threaded into the arbiter's
556
+ ``DisjointnessPredicate``; the pure ``arbitrate`` default path is unchanged.
557
+
558
+ ``env`` is the **environment print** (Axis "under-what", ``docs/115``): a
559
+ content-addressed `EnvPrint` of the runtime the config was built in — kernel
560
+ version + kernel git SHA + Python + OS/arch + declared tool versions — gathered
561
+ ONCE at the build boundary (the `gather_workspace_facts` sibling) and stamped
562
+ onto the durable surfaces so an adjudication records *under what* it ran, not
563
+ just *what* it decided. ``None`` on the pure construction path (a hand-built test
564
+ config never probes the runtime), treated as "not recorded" by every consumer —
565
+ exactly as ``workspace=None`` is. A pure verdict is HANDED a print to stamp, the
566
+ way it is handed a clock; it never requires one. The `EnvPrint.digest` is the
567
+ `EnvId` docs/115 primitive 3's `FLEET_ENV_MISMATCH` gate compares to a declared
568
+ pin (not yet wired — Phase 1 records the print; the refuse is a later phase).
569
+
570
+ ``retention`` is the **retention seam** (`docs/106 §3.3`, the answer to
571
+ `docs/94 §7`'s open question): the size/recency caps governing how much DOS
572
+ scratch to keep — the WAL compaction threshold (``journal_max_entries`` /
573
+ ``journal_max_age_days``) and the keep-last-N reaper caps for `.dos/runs/`,
574
+ `.dos/**/.verdict-*.json`, and `.dos/audits/` (the audit-report class the
575
+ 2026-06-03 trajectory audit surfaced). Defaults to ``GENERIC_RETENTION``
576
+ (generous caps, never zero) so every workspace self-bounds out of the box; a
577
+ host declares its own in `dos.toml [retention]` (`dos.retention.load_from_toml`).
578
+ Same mechanism/policy split as ``stamp``/``overlap_*``: this object carries only
579
+ the *numbers* and the one pure threshold (`retention.should_compact`); the
580
+ collector's load-bearing floor — **never reap a live lease** — is enforced
581
+ independently of these caps (the journal `compact` fold + the reaper's liveness
582
+ gate), so a misconfigured cap can waste disk but can never collect live state.
583
+
584
+ ``data_class`` is the **data-class seam** (the "tag agent-trajectory data vs
585
+ actual product changes" answer): WHICH paths hold re-derivable agent-run
586
+ scratch (``TRAJECTORY``/``AUDIT``) vs measure-then-change anchors (``BASELINE``)
587
+ vs deliverables (``PRODUCT``), as declared glob patterns. The retention reaper
588
+ and any clutter audit read this ONE classifier instead of each hard-coding a
589
+ root list. Defaults to ``GENERIC_DATA_CLASS`` — `.dos/`-relative patterns only,
590
+ so DOS stays domain-free (it names no host's `docs/` tree; the host declares its
591
+ own in `dos.toml [data_class]` via `dos.data_class.load_from_toml`). Same
592
+ mechanism/policy split as ``stamp``/``retention``: this carries only the
593
+ *patterns* and the one pure classifier (`DataClassPolicy.classify`); what a
594
+ consumer DOES with a class (reap / keep / grace) is the consumer's policy.
595
+
596
+ ``supervise`` is the **supervisor seam** (`docs/99`, the always-on population
597
+ program): the `SupervisePolicy` that shapes how many dispatch-loop workers the
598
+ supervisor keeps alive across the lane roster — ``target`` (the desired live
599
+ population), ``count_spinning_as_alive`` (whether a SPINNING worker counts as
600
+ up), and ``reap_stalled`` (whether a STALLED worker yields a REAP). Before this
601
+ seam those three were reachable ONLY as a Python parameter / the ``dos loop
602
+ --target`` flag, with the two booleans not surfaced at all. Now a workspace
603
+ declares the standing policy ONCE in `dos.toml [supervise]`
604
+ (`dos.supervise.load_from_toml`) and BOTH the `dos loop` emitter and the
605
+ long-lived watchdog driver read the same declaration; an explicit ``--target``
606
+ still overrides the config target at the call boundary. Defaults to
607
+ ``DEFAULT_SUPERVISE_POLICY`` (target 1, count spinners, reap the dead) — the
608
+ same mechanism/policy split as ``cooldown``/``stamp``: the kernel owns the
609
+ population verdict, the workspace owns the numbers.
610
+
611
+ ``non_git_oracle`` / ``ci`` are the **non-git evidence seam** (`docs/109`/`docs/265`):
612
+ WHICH out-of-kernel witness ``verify`` consults *beyond git*, and that witness's
613
+ own policy knobs. ``non_git_oracle`` is the `dos.evidence_sources` name (e.g.
614
+ ``"ci_status"``) the truth syscall upgrades a git ship-verdict against; default
615
+ ``""`` = **off** = git-only ``verify``, byte-identical to today (the
616
+ back-compatible widening rule, the `test_verify_no_plan.py` contract untouched).
617
+ It is read from `dos.toml [verify] non_git_oracle`. ``ci`` is the raw
618
+ `dos.toml [ci]` table (``provider``/``repo``/``required`` …) passed THROUGH to the
619
+ named driver, never interpreted by the kernel (the `_resolve_driver_config`
620
+ posture — the kernel folds the table to data and hands it to the boundary; the
621
+ driver decides what its keys mean). The asymmetry the seam keeps sound is in
622
+ `oracle.is_shipped`: a non-git rung may only make ``verify`` answer MORE
623
+ skeptically (upgrade a `source` over a commit git ALREADY found, or withhold the
624
+ upgrade), never promote ``shipped=False → True`` — so wiring this can only add
625
+ accountability, never manufacture a ship (`docs/265 §1`). The kernel verb stays
626
+ provider-blind: the `gh api` subprocess lives in the driver's ``gather``/
627
+ ``status_of``, resolved BY NAME at the `cmd_verify` boundary.
628
+ """
629
+
630
+ lanes: LaneTaxonomy
631
+ paths: PathLayout
632
+ plan_meta_schema: object | None = None
633
+ reasons: ReasonRegistry = BASE_REASONS
634
+ stamp: StampConvention = JOB_STAMP_CONVENTION
635
+ reason_morphology: MorphologyRuleset = GENERIC_REASON_MORPHOLOGY
636
+ workspace: "WorkspaceFacts | None" = None
637
+ env: "EnvPrint | None" = None
638
+ overlap_ratio_max: float = _DEFAULT_OVERLAP_RATIO_MAX
639
+ overlap_policy_name: str = "prefix"
640
+ class_budgets: ClassBudgets = NO_CLASS_BUDGETS
641
+ retention: RetentionPolicy = GENERIC_RETENTION
642
+ data_class: DataClassPolicy = GENERIC_DATA_CLASS
643
+ interventions: InterventionLadder = BASE_INTERVENTIONS
644
+ stream_policy: StreamPolicy = DEFAULT_STREAM_POLICY
645
+ precursors: PrecursorGrammar = EMPTY_PRECURSOR_GRAMMAR
646
+ enumerate_grammar: "EnumerateGrammar" = GENERIC_GRAMMAR
647
+ cooldown: "CooldownPolicy" = DEFAULT_COOLDOWN_POLICY
648
+ lifecycle: "LifecyclePolicy" = GENERIC_LIFECYCLE
649
+ supervise: "SupervisePolicy" = DEFAULT_SUPERVISE_POLICY
650
+ marker: MarkerPolicy = DEFAULT_MARKER_POLICY
651
+ non_git_oracle: str = ""
652
+ ci: dict = field(default_factory=dict)
653
+
654
+ @property
655
+ def root(self) -> Path:
656
+ return self.paths.root
657
+
658
+ @property
659
+ def kernel_runtime_files(self) -> tuple[str, ...] | None:
660
+ """The cached subset of kernel-runtime files present under this workspace.
661
+
662
+ ``None`` when facts were never gathered (the pure construction path) — a
663
+ consumer reads that as "unknown, stay conservative." A gathered fact set
664
+ returns the tuple (empty for a foreign repo, full for the DOS repo). The
665
+ SELF_MODIFY guard reads THIS instead of re-probing the disk, which is what
666
+ lets `arbitrate` stay pure while still being workspace-aware (the whole
667
+ point of caching the facts on the config — see `WorkspaceFacts`).
668
+ """
669
+ return self.workspace.kernel_runtime_files if self.workspace else None
670
+
671
+ def state_path(self) -> Path:
672
+ """The execution-state registry this workspace keeps (may not exist).
673
+
674
+ A convenience over ``paths.execution_state`` so callers — and the truth
675
+ syscall's no-plan contract — can ask the *config* for the registry path
676
+ without reaching through the layout. A workspace with no phased plan
677
+ simply has no file here; ``verify`` then answers from git alone.
678
+ """
679
+ return self.paths.execution_state
680
+
681
+ def with_root(self, root: Path | str) -> "SubstrateConfig":
682
+ """Return a copy re-pointed at a different workspace root.
683
+
684
+ Rebuilds the layout under the new root, preserving the layout STYLE: a
685
+ config built with the generic ``.dos/`` layout re-points to a `.dos/`
686
+ layout under the new root, a reference (`for_root`) config re-points to a
687
+ reference layout. We branch on ``paths.style`` rather than always calling
688
+ ``for_root`` — the latter would silently drag a `.dos/`-configured
689
+ workspace back onto the reference ``docs/_plans`` tree (a correctness
690
+ hazard for Law 1: the reference layout must not move, and a `.dos/` config
691
+ must not become a reference config). The common case stays "same layout,
692
+ different tree".
693
+
694
+ Workspace FACTS are root-specific, so a re-point must re-gather them (a
695
+ stale fact set would describe the OLD tree — e.g. claim the new root is
696
+ the kernel repo because the old one was). We only re-probe when this
697
+ config already HAD gathered facts (``workspace is not None``): a pure,
698
+ never-probed config stays pure under re-point (facts remain ``None``), so
699
+ `with_root` does no surprise I/O for a hand-built test config — it gathers
700
+ only if the original did.
701
+ """
702
+ factory = (
703
+ PathLayout.for_dos_dir if self.paths.style == "dos"
704
+ else PathLayout.for_root
705
+ )
706
+ new_facts = (
707
+ gather_workspace_facts(root) if self.workspace is not None else None
708
+ )
709
+ return replace(self, paths=factory(root), workspace=new_facts)
710
+
711
+
712
+ # ---------------------------------------------------------------------------
713
+ # The reference userland app's lane taxonomy now lives in the `dos._job_policy`
714
+ # leaf, NOT in this near-leaf config module — domain lane names (apply/tailor/
715
+ # discovery) do not belong in the kernel core (the 2026-06-01 layering audit in
716
+ # `dos.drivers.job` named exactly this relocation: "a third home BOTH layers may
717
+ # import — a `dos._job_policy` leaf, say"). `config` is layer 2; `_job_policy`
718
+ # imports only `LaneTaxonomy` from here, so a module-top import of the literal
719
+ # back into `config` would cycle. We expose `JOB_LANE_TAXONOMY` as a
720
+ # backward-compatible attribute via PEP-562 `__getattr__` (lazy, resolved on
721
+ # access — after both modules are loaded), so `from dos.config import
722
+ # JOB_LANE_TAXONOMY` still works for legacy callers while the literal's *home* is
723
+ # the leaf. `job_config()` reads it the same lazy way (below).
724
+ # ---------------------------------------------------------------------------
725
+
726
+
727
+ def __getattr__(name: str):
728
+ """PEP-562 lazy attribute: resolve `JOB_LANE_TAXONOMY` from the leaf.
729
+
730
+ Keeps the legacy `from dos.config import JOB_LANE_TAXONOMY` import working
731
+ without a module-load cycle (the literal's home moved to `dos._job_policy`,
732
+ which imports `LaneTaxonomy` from THIS module). Any other unknown attribute
733
+ raises the normal `AttributeError`.
734
+ """
735
+ if name == "JOB_LANE_TAXONOMY":
736
+ from dos._job_policy import JOB_LANE_TAXONOMY # noqa: PLC0415
737
+ return JOB_LANE_TAXONOMY
738
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
739
+
740
+
741
+ def resolve_workspace_root(workspace: Path | str | None = None) -> Path:
742
+ """The active workspace root, per the resolution order in the module doc."""
743
+ if workspace is not None:
744
+ return Path(workspace).resolve()
745
+ env = os.environ.get(ENV_WORKSPACE)
746
+ if env:
747
+ return Path(env).resolve()
748
+ return Path.cwd().resolve()
749
+
750
+
751
+ def resolve_dos_home(home: Path | str | None = None) -> Path:
752
+ """The machine-local DOS_HOME root, per precedence (highest first):
753
+
754
+ 1. an explicit ``home`` arg (a caller / test pointing it directly),
755
+ 2. the ``DISPATCH_HOME`` env var,
756
+ 3. ``$XDG_DATA_HOME/dos`` (the XDG base-dir spec on Linux/macOS),
757
+ 4. (win32 only) ``%APPDATA%\\dos``,
758
+ 5. ``~/.dos`` (the universal fallback).
759
+
760
+ Every branch is ``Path(...).resolve()``'d so a project-id keyed on a path is
761
+ stable. This NEVER creates the directory — a read-only syscall must be able
762
+ to ASK for the home path without a write happening; ``home.ensure_dos_home``
763
+ is the only creator. Mirrors ``resolve_workspace_root``'s precedence idiom.
764
+ """
765
+ if home is not None:
766
+ return Path(home).resolve()
767
+ env = os.environ.get(ENV_DOS_HOME)
768
+ if env:
769
+ return Path(env).resolve()
770
+ xdg = os.environ.get("XDG_DATA_HOME")
771
+ if xdg:
772
+ return (Path(xdg) / "dos").resolve()
773
+ if sys.platform == "win32":
774
+ appdata = os.environ.get("APPDATA")
775
+ if appdata:
776
+ return (Path(appdata) / "dos").resolve()
777
+ return (Path.home() / ".dos").resolve()
778
+
779
+
780
+ @dataclass(frozen=True)
781
+ class HomeLayout:
782
+ """The machine-local DOS_HOME paths — per-MACHINE and root-invariant.
783
+
784
+ Distinct from ``PathLayout`` (which is per-workspace and rebuilt by
785
+ ``with_root``): DOS_HOME does not move when the active workspace changes, so
786
+ it is NOT a field on ``PathLayout``. It holds the central, rebuildable
787
+ projection store (docs/74): a registry of every project DOS has touched and
788
+ a log of resolved-decision digests, plus the cross-process mutex that
789
+ serializes their writes.
790
+ """
791
+
792
+ home: Path
793
+ config_toml: Path # home / "config.toml" — machine-global prefs
794
+ projects_index: Path # home / "projects" / "index.jsonl" (rich, rewritten by reindex)
795
+ roots_log: Path # home / "projects" / "roots.log" (durable path registry, append-only)
796
+ decisions_log: Path # home / "decisions.jsonl"
797
+ home_lock: Path # home / ".home.lock" — cross-process write mutex
798
+
799
+ @classmethod
800
+ def for_home(cls, home: Path | str | None = None) -> "HomeLayout":
801
+ h = resolve_dos_home(home)
802
+ return cls(
803
+ home=h,
804
+ config_toml=h / "config.toml",
805
+ projects_index=h / "projects" / "index.jsonl",
806
+ roots_log=h / "projects" / "roots.log",
807
+ decisions_log=h / "decisions.jsonl",
808
+ home_lock=h / ".home.lock",
809
+ )
810
+
811
+
812
+ def job_config(workspace: Path | str | None = None, *,
813
+ gather_env: bool = True) -> SubstrateConfig:
814
+ """The reference userland app's policy, pointed at ``workspace``.
815
+
816
+ The reference userland app imports this and passes it everywhere. The lane
817
+ taxonomy is sourced from the workspace's ``dos.toml`` ``[lanes]`` table (the
818
+ userland policy now lives where it belongs — in the consumer repo, not baked
819
+ into this kernel package); ``dos._job_policy.JOB_LANE_TAXONOMY`` is only the
820
+ domain-free *structural fallback* used when the workspace has no ``[lanes]``
821
+ declaration (a foreign checkout, a test tmp_path). The path layout is the
822
+ reference app's default. Pointing it at a different root (e.g. for a test
823
+ fixture) is one argument.
824
+
825
+ Implementation: build the base ``SubstrateConfig`` from the structural
826
+ fallback literal, then layer the workspace ``dos.toml`` over it via
827
+ ``load_workspace_config(root, base=base)``. Passing an explicit ``base`` takes
828
+ the ``base is not None`` branch in ``load_workspace_config`` (it never
829
+ re-enters ``job_config``), so this is recursion-safe by construction — and it
830
+ means every direct ``job_config()`` caller (the live arbiter, the TUI,
831
+ ``check_phase_shipped``, ``decisions``) reads the SAME ``dos.toml``-sourced
832
+ taxonomy instead of the raw literal diverging from what the CLI/MCP see.
833
+ """
834
+ # Lazy import: the literal's home is the `dos._job_policy` leaf (it imports
835
+ # `LaneTaxonomy` from here, so a module-top import would cycle). Resolved at
836
+ # call time, after both modules are loaded — same lazy-import discipline as
837
+ # `gather_workspace_facts` deferring `dos.self_modify`.
838
+ from dos._job_policy import JOB_LANE_TAXONOMY # noqa: PLC0415
839
+
840
+ root = resolve_workspace_root(workspace)
841
+ base = SubstrateConfig(
842
+ lanes=JOB_LANE_TAXONOMY,
843
+ paths=PathLayout.for_root(root),
844
+ workspace=gather_workspace_facts(root),
845
+ env=gather_env_print() if gather_env else None,
846
+ )
847
+ # Layer the workspace's `dos.toml` ([lanes] REPLACES the base taxonomy when
848
+ # declared; absent → the structural fallback above stands). `base=` keeps this
849
+ # out of the `job_config()` re-entry path in `load_workspace_config`.
850
+ return load_workspace_config(root, base=base)
851
+
852
+
853
+ def default_config(workspace: Path | str | None = None, *,
854
+ gather_env: bool = True) -> SubstrateConfig:
855
+ """A minimal, domain-free config for an arbitrary workspace.
856
+
857
+ The third-directory / `dos init` case: a folder of plan-markdown with no
858
+ declared lanes yet. One generic ``main`` cluster lane + the standard
859
+ exclusive ``global``, so `dos dispatch` produces a typed verdict out of the
860
+ box without any workspace-specific policy. Hosts that want real concurrency
861
+ declare their own taxonomy.
862
+
863
+ Ship-stamp grammar: the generic config carries ``GENERIC_STAMP_CONVENTION``
864
+ (no dir prefix — a bare ``<SERIES>: <PHASE>`` / ``<slug> Phase <N>:`` counts
865
+ as a direct ship), NOT the reference-strict ``(docs|go|…)/`` grammar the
866
+ `SubstrateConfig` dataclass defaults to. This is what makes ``verify`` work
867
+ **out of the box** against a foreign repo whose commits don't carry the
868
+ reference app's dir prefixes (`hybrid-cache-type Phase 4:`): the no-`dos.toml`
869
+ path now matches the convention the repo actually uses instead of resolving
870
+ every real ship `via none` (F9). The asymmetry with `job_config` is
871
+ deliberate and safe: the reference userland app consumes `job_config` (which
872
+ keeps the strict grammar + its bookkeeping guards), so the reference app and
873
+ the kernel suite are byte-unchanged; only the generic foreign-repo path
874
+ loosens — and the generic convention still
875
+ carries the universal release-bundle + bulk-snapshot guards, so it is not a
876
+ free-for-all. A repo that needs the strict grammar still declares it in
877
+ `dos.toml [stamp]` (or passes `--job`).
878
+
879
+ ``gather_env`` (default ``True``) controls whether the runtime `EnvPrint` is
880
+ probed and stamped onto ``env``. A caller that never reads ``cfg.env`` — the
881
+ MCP server's per-tool-call config build is the motivating one — passes
882
+ ``gather_env=False`` to skip the probe entirely, leaving ``env=None`` (the
883
+ documented "not recorded" state every consumer already handles, identical to
884
+ the pure-construction path). The default stays ``True`` so the CLI / doctor /
885
+ intent-ledger callers are byte-unchanged. (Even when ``True`` the probe is
886
+ cheap after the first call thanks to `env_print.gather_env_print`'s per-process
887
+ memo; ``gather_env=False`` removes it from the path altogether.)
888
+ """
889
+ root = resolve_workspace_root(workspace)
890
+ lanes = LaneTaxonomy(
891
+ concurrent=("main",),
892
+ exclusive=("global",),
893
+ autopick=("main",),
894
+ trees={"main": ("**/*",), "global": ("**/*",)},
895
+ aliases={},
896
+ )
897
+ # The generic default adopts the `.dos/` layout (docs/74): DOS's own
898
+ # emissions live under a per-project `.dos/` home, not scattered into the
899
+ # served repo's `docs/` tree. `job_config` keeps `for_root` — the reference
900
+ # layout must not move. This is the ONLY place the layout flips to `.dos/`.
901
+ return SubstrateConfig(
902
+ lanes=lanes,
903
+ paths=PathLayout.for_dos_dir(root),
904
+ stamp=GENERIC_STAMP_CONVENTION,
905
+ workspace=gather_workspace_facts(root),
906
+ env=gather_env_print() if gather_env else None,
907
+ )
908
+
909
+
910
+ # ---------------------------------------------------------------------------
911
+ # The declarative on-ramp for lanes & paths: read the `[lanes]` / `[paths]`
912
+ # tables out of a workspace's `dos.toml` (WCR — docs/71). These mirror
913
+ # `reasons.load_from_toml` / `stamp.load_from_toml` in shape, but with the
914
+ # deliberate asymmetry the plan calls out:
915
+ #
916
+ # * reasons are ADDITIVE (`base.extend(...)`) — declaring a reason means
917
+ # "these on top of the base set".
918
+ # * lanes/paths are REPLACE / OVERRIDE — a host declaring `[lanes]` means
919
+ # "these are MY lanes" (not the reference app's plus mine); a host declaring
920
+ # `[paths]` overrides only the layout fields it names and inherits the rest.
921
+ #
922
+ # Same additive-degradation guarantee on the file axis, though: absent file,
923
+ # absent/empty table, or no `tomllib` → the supplied ``base`` unchanged, so a
924
+ # workspace that declared nothing is byte-identical to today. A present-but-
925
+ # malformed table raises (surfaced, not swallowed), exactly like the siblings.
926
+ # ---------------------------------------------------------------------------
927
+
928
+
929
+ def _load_toml_table(path: Path | str, key: str) -> dict | None:
930
+ """Read `[<key>]` out of a `dos.toml`, or None if there's nothing to read.
931
+
932
+ Returns None when the file is absent, `tomllib` is unavailable (py<3.11 with
933
+ no `tomli`), or the `[<key>]` table is missing/empty/not-a-table — every
934
+ "degrade to base" case the WCR loaders share. A present, non-empty table is
935
+ returned as the raw dict for the caller's `*_from_table` to validate. Mirrors
936
+ the file-handling half of `reasons.load_from_toml` so the two seams behave
937
+ identically on a missing/garbled config.
938
+ """
939
+ p = Path(path)
940
+ if not p.exists():
941
+ return None
942
+ try:
943
+ import tomllib # py3.11+
944
+ except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
945
+ try:
946
+ import tomli as tomllib # type: ignore
947
+ except ModuleNotFoundError:
948
+ return None
949
+ # Read via `utf-8-sig` so a UTF-8 BOM is transparently stripped (it is a no-op
950
+ # when absent). PowerShell 5.1's `Set-Content -Encoding utf8` writes a BOM by
951
+ # default, and raw `tomllib.load(rb)` chokes on it ("Invalid statement at line
952
+ # 1") — which would silently demote a perfectly valid declared table to the
953
+ # base value (an additive-degradation-law violation: a present, well-formed
954
+ # table must NOT be silently dropped). `loads(read_text(utf-8-sig))` fixes it
955
+ # for both tomllib and tomli.
956
+ data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
957
+ table = data.get(key)
958
+ if not isinstance(table, dict) or not table:
959
+ return None
960
+ return table
961
+
962
+
963
+ def load_lanes_from_toml(
964
+ path: Path | str, *, base: LaneTaxonomy
965
+ ) -> LaneTaxonomy:
966
+ """Build a `LaneTaxonomy` from a `dos.toml`'s `[lanes]` table (WCR Phase 1).
967
+
968
+ A present `[lanes]` table REPLACES ``base`` wholesale — lanes are not additive
969
+ the way reasons are: a host declaring lanes means "these are my lanes," not
970
+ "these plus the reference taxonomy's." Absent file / absent-or-empty table →
971
+ ``base`` unchanged (additive degradation). Present-but-malformed → raise (via
972
+ `LaneTaxonomy.from_table`), surfaced rather than swallowed.
973
+ """
974
+ table = _load_toml_table(path, "lanes")
975
+ if table is None:
976
+ return base
977
+ return LaneTaxonomy.from_table(table)
978
+
979
+
980
+ def load_class_budgets_from_toml(
981
+ path: Path | str, *, base: ClassBudgets = NO_CLASS_BUDGETS
982
+ ) -> ClassBudgets:
983
+ """Build a `ClassBudgets` from a `dos.toml`'s `[[concurrency_class]]` array (C13).
984
+
985
+ `[[concurrency_class]]` parses to a top-level LIST (not a `[key]` dict like
986
+ `[lanes]`), so this reads the raw `data` and pulls the array directly rather than
987
+ via `_load_toml_table`. A present array REPLACES ``base`` (a host declaring its
988
+ classes means "these are my budgets"). Absent file / absent-or-empty array →
989
+ ``base`` (additive degradation: no budgets = today's unbounded-per-kind behavior).
990
+ Present-but-malformed → raise (via `ClassBudgets.from_table`)."""
991
+ p = Path(path)
992
+ if not p.exists():
993
+ return base
994
+ try:
995
+ import tomllib # py3.11+
996
+ except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
997
+ try:
998
+ import tomli as tomllib # type: ignore
999
+ except ModuleNotFoundError:
1000
+ return base
1001
+ data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
1002
+ arr = data.get("concurrency_class")
1003
+ if not arr: # absent or empty → base (degrade)
1004
+ return base
1005
+ return ClassBudgets.from_table(arr)
1006
+
1007
+
1008
+ def load_paths_from_toml(
1009
+ path: Path | str, *, base: PathLayout
1010
+ ) -> PathLayout:
1011
+ """Build a `PathLayout` from a `dos.toml`'s `[paths]` table (WCR Phase 2).
1012
+
1013
+ A present `[paths]` table OVERRIDES only the layout fields it names (relative
1014
+ paths resolve against ``base.root``); every unnamed field inherits ``base``.
1015
+ Absent file / absent-or-empty table → ``base`` unchanged. A present table with
1016
+ an unknown key → raise (a typo'd path field is a host mistake worth surfacing,
1017
+ `PathLayout.with_overrides`' posture).
1018
+ """
1019
+ table = _load_toml_table(path, "paths")
1020
+ if table is None:
1021
+ return base
1022
+ return base.with_overrides(table)
1023
+
1024
+
1025
+ def load_overlap_from_toml(
1026
+ path: Path | str, *, base_ratio_max: float, base_policy_name: str,
1027
+ ) -> tuple[float, str]:
1028
+ """Read the `[overlap]` table (Axis 7, `docs/113`) → ``(ratio_max, policy_name)``.
1029
+
1030
+ The overlap seam's *data* attachment — the soft-overlap tolerance and the
1031
+ named scorer. A present `[overlap]` table OVERRIDES the two fields it names;
1032
+ an absent/empty table inherits ``base_*``. Keys (both optional):
1033
+
1034
+ * ``ratio_max`` — a float in (0, 1], the soft-overlap fraction the built-in
1035
+ ``prefix`` scorer admits under. The kernel default is ⅓.
1036
+ * ``policy`` — the scorer name: ``"prefix"`` (built-in) or a
1037
+ ``dos.overlap_policies`` plugin name.
1038
+
1039
+ Malformed values RAISE ``ValueError`` (surfaced by `load_workspace_config`'s
1040
+ warn-and-fall-back, the shared posture): a ``ratio_max`` that is not a number
1041
+ or sits outside (0, 1] is a host mistake worth a one-line notice, not a silent
1042
+ no-op that would leave the operator believing a looser tolerance is in force.
1043
+ An unknown key is rejected the same way (a typo'd ``[overlap]`` field would
1044
+ otherwise silently do nothing). Note: an out-of-range ``ratio_max`` only ever
1045
+ affects what the *policy* admits — the deterministic floor it is AND-ed against
1046
+ stays ⅓ regardless (`overlap_policy.floor_decision`), so even a malformed
1047
+ config that slipped through could never admit a prefix-colliding pair.
1048
+ """
1049
+ table = _load_toml_table(path, "overlap")
1050
+ if table is None:
1051
+ return base_ratio_max, base_policy_name
1052
+ allowed = {"ratio_max", "policy"}
1053
+ unknown = set(table) - allowed
1054
+ if unknown:
1055
+ raise ValueError(
1056
+ f"unknown [overlap] key(s): {', '.join(sorted(unknown))} "
1057
+ f"(allowed: {', '.join(sorted(allowed))})"
1058
+ )
1059
+ ratio_max = base_ratio_max
1060
+ if "ratio_max" in table:
1061
+ raw = table["ratio_max"]
1062
+ try:
1063
+ ratio_max = float(raw)
1064
+ except (TypeError, ValueError):
1065
+ raise ValueError(f"[overlap] ratio_max must be a number, got {raw!r}")
1066
+ if not (0.0 < ratio_max <= 1.0):
1067
+ raise ValueError(
1068
+ f"[overlap] ratio_max must be in (0, 1], got {ratio_max!r}"
1069
+ )
1070
+ policy_name = base_policy_name
1071
+ if "policy" in table:
1072
+ policy_name = str(table["policy"])
1073
+ return ratio_max, policy_name
1074
+
1075
+
1076
+ def load_workspace_config(
1077
+ workspace: str | Path | None = None,
1078
+ *,
1079
+ job: bool = False,
1080
+ base: SubstrateConfig | None = None,
1081
+ gather_env: bool = True,
1082
+ warn=None,
1083
+ ) -> SubstrateConfig:
1084
+ """Build the config for ``workspace``, folding in its ``dos.toml`` tables.
1085
+
1086
+ The single shared implementation of the four-table readback that BOTH the
1087
+ `dos` CLI (`cli._apply_workspace`) and the MCP server (`dos_mcp`) need — they
1088
+ used to carry byte-identical copies of this loop, which is exactly the drift
1089
+ risk the registry-as-data design exists to kill. Factoring it here removes
1090
+ the duplication; each caller decides what to DO with the result (the CLI
1091
+ `set_active`s it; the server passes it explicitly into each syscall).
1092
+
1093
+ Layering, highest precedence first (WCR Phase 3a):
1094
+ the four `dos.toml` tables › a pre-built ``base`` driver config (from the
1095
+ CLI's ``--driver`` loader) OR the ``--job`` reference taxonomy (``job=True``)
1096
+ › the `default_config` generic. So a `dos.toml [lanes]` overrides whatever
1097
+ base it was given, and declaring nothing degrades cleanly to the generic
1098
+ default. ``base`` is an already-built `SubstrateConfig` the caller resolved
1099
+ (the CLI resolves `dos.drivers.<name>.<name>_config` by convention); this
1100
+ function never learns a host name — it only sees an opaque config to layer
1101
+ over, which keeps the one-way arrow (kernel/config names no driver) intact.
1102
+ When both ``base`` and ``job`` are passed, ``base`` wins.
1103
+
1104
+ The two deliberate asymmetries (kept intact):
1105
+ * `[reasons]` is ADDITIVE onto the base set; `[lanes]`/`[paths]`/`[stamp]`
1106
+ REPLACE/OVERRIDE.
1107
+ * lanes/paths default GENERIC (you declare your real ones — the safe
1108
+ direction); stamp defaults STRICT (you loosen it knowingly).
1109
+
1110
+ A missing/empty table always leaves the built-in base, so a workspace that
1111
+ declared none is byte-identical to the generic default. A *present but
1112
+ malformed* table must not crash a command that never touches that policy
1113
+ axis (a `verify` with a broken `[lanes]`, say), so it is warned and the base
1114
+ is kept — the shared warn-and-fall-back posture. ``warn`` is the sink for
1115
+ that one-line notice ``(label, message)``; it defaults to a stderr print.
1116
+ Pass your own to capture/redirect it (a server may not want stderr noise).
1117
+
1118
+ ``gather_env`` (default ``True``) is forwarded to the underlying
1119
+ ``default_config`` / ``job_config`` builder: pass ``False`` to skip probing
1120
+ the runtime `EnvPrint` (the git-SHA subprocess + platform query) when the
1121
+ caller never reads ``cfg.env`` — the MCP server's per-tool-call build. It is a
1122
+ no-op when ``base`` is supplied (the builder already decided that base's
1123
+ ``env``); the `dos.toml` layering above never touches ``env``.
1124
+ """
1125
+ import dataclasses
1126
+ import sys
1127
+
1128
+ from dos import reasons as _reasons
1129
+ from dos import stamp as _stamp
1130
+ from dos import reason_morphology as _reason_morphology
1131
+ from dos import retention as _retention
1132
+ from dos import data_class as _data_class
1133
+
1134
+ if warn is None:
1135
+ def warn(label: str, message: str) -> None:
1136
+ print(f"warning: ignoring malformed [{label}] in {toml_path}: "
1137
+ f"{message}", file=sys.stderr)
1138
+
1139
+ if base is not None:
1140
+ cfg = base
1141
+ else:
1142
+ cfg = (job_config(workspace, gather_env=gather_env) if job
1143
+ else default_config(workspace, gather_env=gather_env))
1144
+ toml_path = cfg.paths.root / "dos.toml"
1145
+
1146
+ def _layer(label: str, load, current):
1147
+ try:
1148
+ return load()
1149
+ except ValueError as e:
1150
+ warn(label, str(e))
1151
+ return current
1152
+
1153
+ # [reasons] — ADDITIVE onto the base registry.
1154
+ cfg = dataclasses.replace(cfg, reasons=_layer(
1155
+ "reasons", lambda: _reasons.load_from_toml(toml_path, base=cfg.reasons),
1156
+ cfg.reasons))
1157
+ # [stamp] — OVERRIDE the base ship-subject grammar.
1158
+ cfg = dataclasses.replace(cfg, stamp=_layer(
1159
+ "stamp", lambda: _stamp.load_from_toml(toml_path, base=cfg.stamp),
1160
+ cfg.stamp))
1161
+ # [enumerate] — OVERRIDE the phase-list-producer STYLE grammar (docs/207 Phase 2).
1162
+ # The repo declares heading levels / table scan / bare-Phase fallback / rollup;
1163
+ # the per-plan `series` is layered at the call boundary (`enumerate.with_series`),
1164
+ # NOT here. Absent inherits the generic markdown grammar. Malformed warns + keeps base.
1165
+ from dos import enumerate as _enumerate
1166
+ cfg = dataclasses.replace(cfg, enumerate_grammar=_layer(
1167
+ "enumerate",
1168
+ lambda: _enumerate.load_from_toml(toml_path, base=cfg.enumerate_grammar),
1169
+ cfg.enumerate_grammar))
1170
+ # [cooldown] — OVERRIDE the anti-churn windows (docs/207 §3). A present key
1171
+ # overrides that window; absent inherits the generic default (6h / 30m). The
1172
+ # window is a HINT (a too-long window only delays a re-pick, never wedges a
1173
+ # clean unit), so malformed warns + keeps base — the safe direction.
1174
+ from dos import cooldown as _cooldown
1175
+ cfg = dataclasses.replace(cfg, cooldown=_layer(
1176
+ "cooldown",
1177
+ lambda: _cooldown.load_from_toml(toml_path, base=cfg.cooldown),
1178
+ cfg.cooldown))
1179
+ # [supervise] — OVERRIDE the always-on population policy (docs/99): the
1180
+ # target live-worker count + whether a spinner counts as up + whether a
1181
+ # STALLED worker is reaped. A present key overrides; absent inherits the
1182
+ # generic default (target 1, count spinners, reap the dead). Malformed warns +
1183
+ # keeps base — the supervisor is advisory/effect (it emits a plan; even the
1184
+ # driver's reap is idempotent), so a broken policy degrades to the safe
1185
+ # default rather than wedging the roster.
1186
+ from dos import supervise as _supervise
1187
+ cfg = dataclasses.replace(cfg, supervise=_layer(
1188
+ "supervise",
1189
+ lambda: _supervise.load_from_toml(toml_path, base=cfg.supervise),
1190
+ cfg.supervise))
1191
+ # [lifecycle] — OVERRIDE the plan-class taxonomy + transition triggers (docs/207
1192
+ # §5c). A present table replaces the class set / transitions / failsafes; absent
1193
+ # inherits the generic active/done. A transition naming an unknown class raises
1194
+ # (validated shape); malformed warns + keeps base — the safe direction (a broken
1195
+ # lifecycle table can never auto-transition a plan, it just keeps the default).
1196
+ from dos import lifecycle as _lifecycle
1197
+ cfg = dataclasses.replace(cfg, lifecycle=_layer(
1198
+ "lifecycle",
1199
+ lambda: _lifecycle.load_from_toml(toml_path, base=cfg.lifecycle),
1200
+ cfg.lifecycle))
1201
+ # [[reasons.morphology]] — OVERRIDE the rung-2 recognizer (docs/105). A present
1202
+ # list replaces the base ruleset; an explicit empty list turns rung 2 off;
1203
+ # absent inherits the kernel's generic morphology.
1204
+ cfg = dataclasses.replace(cfg, reason_morphology=_layer(
1205
+ "reasons.morphology",
1206
+ lambda: _reason_morphology.load_from_toml(toml_path, base=cfg.reason_morphology),
1207
+ cfg.reason_morphology))
1208
+ # [lanes] — REPLACE the base taxonomy wholesale (WCR Phase 1).
1209
+ cfg = dataclasses.replace(cfg, lanes=_layer(
1210
+ "lanes", lambda: load_lanes_from_toml(toml_path, base=cfg.lanes),
1211
+ cfg.lanes))
1212
+ # [paths] — OVERRIDE only the named layout fields (WCR Phase 2).
1213
+ cfg = dataclasses.replace(cfg, paths=_layer(
1214
+ "paths", lambda: load_paths_from_toml(toml_path, base=cfg.paths),
1215
+ cfg.paths))
1216
+ # [overlap] — OVERRIDE the disjointness scorer's tolerance + named policy
1217
+ # (Axis 7, docs/113). A two-field table, so it layers both at once via a
1218
+ # tuple; a malformed value warns and keeps the base pair (no axis touched).
1219
+ _overlap = _layer(
1220
+ "overlap",
1221
+ lambda: load_overlap_from_toml(
1222
+ toml_path,
1223
+ base_ratio_max=cfg.overlap_ratio_max,
1224
+ base_policy_name=cfg.overlap_policy_name,
1225
+ ),
1226
+ (cfg.overlap_ratio_max, cfg.overlap_policy_name),
1227
+ )
1228
+ cfg = dataclasses.replace(
1229
+ cfg, overlap_ratio_max=_overlap[0], overlap_policy_name=_overlap[1])
1230
+ # [retention] — OVERRIDE the scratch-retention caps (docs/106 §3.3). A present
1231
+ # key overrides that cap; absent inherits the generic default (generous, never
1232
+ # zero). Malformed warns + keeps the base — a bad cap can never loosen the
1233
+ # "never reap a live lease" floor, which is the collector's, not these numbers'.
1234
+ cfg = dataclasses.replace(cfg, retention=_layer(
1235
+ "retention", lambda: _retention.load_from_toml(toml_path, base=cfg.retention),
1236
+ cfg.retention))
1237
+ # [data_class] — OVERRIDE the path → data-class glob patterns (the trajectory-
1238
+ # vs-product tagging seam). A present key replaces that class's patterns;
1239
+ # absent inherits the generic default (.dos/-relative only). Malformed warns +
1240
+ # keeps base — an unclassified path falls through to PRODUCT (the safe
1241
+ # direction: a class-keyed reaper can never reap what it can't classify).
1242
+ cfg = dataclasses.replace(cfg, data_class=_layer(
1243
+ "data_class",
1244
+ lambda: _data_class.load_from_toml(toml_path, base=cfg.data_class),
1245
+ cfg.data_class))
1246
+ # [[concurrency_class]] — REPLACE the per-kind lease budgets (docs/97 Phase 2,
1247
+ # C13). A present array means "these are my class budgets"; absent/empty keeps
1248
+ # the base (no budget = today's unbounded-per-kind). Malformed warns + keeps base.
1249
+ cfg = dataclasses.replace(cfg, class_budgets=_layer(
1250
+ "concurrency_class",
1251
+ lambda: load_class_budgets_from_toml(toml_path, base=cfg.class_budgets),
1252
+ cfg.class_budgets))
1253
+ # [intervention] — EXTEND the actuation ladder (docs/143 §13). A present
1254
+ # [intervention.X] table adds a rung-with-rank to BASE_INTERVENTIONS; absent
1255
+ # inherits the built-in OBSERVE<WARN<BLOCK<DEFER set. Purely additive (the
1256
+ # [reasons] seam shape) — malformed warns + keeps base.
1257
+ from dos import intervention as _intervention
1258
+ cfg = dataclasses.replace(cfg, interventions=_layer(
1259
+ "intervention",
1260
+ lambda: _intervention.load_from_toml(toml_path, base=cfg.interventions),
1261
+ cfg.interventions))
1262
+ # [tool_stream] — OVERRIDE the stall-reader windows (docs/145). A present
1263
+ # [tool_stream] table replaces repeat_n/stall_n/ignore_tools; absent inherits
1264
+ # the generic default (REPEATING at 3, STALLED at 5). Malformed warns + keeps base.
1265
+ from dos import tool_stream as _tool_stream
1266
+ cfg = dataclasses.replace(cfg, stream_policy=_layer(
1267
+ "tool_stream",
1268
+ lambda: _tool_stream.load_from_toml(toml_path, base=cfg.stream_policy),
1269
+ cfg.stream_policy))
1270
+ # [marker] — OVERRIDE the wait-marker budget knobs (docs/274). A present [marker]
1271
+ # table tunes the no-op-turn cap (max_streak, handed to noop_streak), WHICH env
1272
+ # vars arm the budget (arm_on_env — a host names its own loop sentinel), and whether
1273
+ # Claude Code's stop_hook_active backstop is honored. Absent inherits the generic
1274
+ # interactive-safe default (armed only by an explicit loop signal). Malformed warns +
1275
+ # keeps base. The arming DECISION stays the pure marker_gate.decide; this only
1276
+ # supplies its inputs.
1277
+ from dos import marker_gate as _marker_gate
1278
+ cfg = dataclasses.replace(cfg, marker=_layer(
1279
+ "marker",
1280
+ lambda: _marker_gate.load_from_toml(toml_path, base=cfg.marker),
1281
+ cfg.marker))
1282
+ # [precursor] — REPLACE the mandated-precursor grammar (docs/147). A present
1283
+ # [precursor.requires] / [precursor.aliases] table declares which mutating tool
1284
+ # needs which lookup first; absent inherits the EMPTY grammar (the gate
1285
+ # NO_SIGNALs everything = today's behavior). Hand-authored from the policy prose,
1286
+ # NEVER inferred (inferring it is parsing policy = planner-adjacent). Malformed
1287
+ # warns + keeps base.
1288
+ from dos import precursor_gate as _precursor_gate
1289
+ cfg = dataclasses.replace(cfg, precursors=_layer(
1290
+ "precursor",
1291
+ lambda: _precursor_gate.load_from_toml(toml_path, base=cfg.precursors),
1292
+ cfg.precursors))
1293
+ # [verify] / [ci] — OVERRIDE which non-git witness `verify` consults + that
1294
+ # witness's pass-through policy (docs/109/265). `[verify] non_git_oracle` names a
1295
+ # `dos.evidence_sources` driver (a string); absent → "" → git-only `verify`,
1296
+ # byte-identical to today (the no-plan contract is untouched). `[ci]` is the
1297
+ # driver's raw policy table (`provider`/`repo`/`required`), handed THROUGH to the
1298
+ # named driver and never interpreted by the kernel — so a malformed/foreign key
1299
+ # is the driver's to validate, not this fold's. Both reads are inline (a string +
1300
+ # a raw dict, no nested grammar to validate), so they degrade to base on a missing
1301
+ # table the same way the `*_from_toml` siblings do; a tomllib parse fault is
1302
+ # warned + base-kept (the shared warn-and-fall-back posture, the safe direction —
1303
+ # a broken table can never turn the conjunctive rung INTO a false ship, only leave
1304
+ # it off). An explicit non_git_oracle on the `base` config is overridden by a
1305
+ # present `[verify]` table, the same precedence the other tables take.
1306
+ def _load_verify_ci():
1307
+ v_table = _load_toml_table(toml_path, "verify")
1308
+ ci_table = _load_toml_table(toml_path, "ci")
1309
+ oracle_name = cfg.non_git_oracle
1310
+ if v_table is not None:
1311
+ raw = v_table.get("non_git_oracle", oracle_name)
1312
+ if not isinstance(raw, str):
1313
+ raise ValueError(
1314
+ f"[verify] non_git_oracle must be a string, got {type(raw).__name__}")
1315
+ oracle_name = raw.strip()
1316
+ ci = dict(ci_table) if ci_table is not None else cfg.ci
1317
+ return oracle_name, ci
1318
+ _verify_ci = _layer("verify", _load_verify_ci, (cfg.non_git_oracle, cfg.ci))
1319
+ cfg = dataclasses.replace(cfg, non_git_oracle=_verify_ci[0], ci=_verify_ci[1])
1320
+ return cfg
1321
+
1322
+
1323
+ # The process-wide active config. Lazily initialised from the environment so a
1324
+ # bare `import dos; dos.config.active()` works without ceremony, while a host
1325
+ # that wants explicit control calls `set_active(...)` at startup. This mirrors
1326
+ # the reference spine's "module-level STATE_PATH read from env" idiom, but the
1327
+ # value is now a full config object, not a bare path.
1328
+ _ACTIVE: SubstrateConfig | None = None
1329
+
1330
+
1331
+ def active() -> SubstrateConfig:
1332
+ """The process-wide active config (env-resolved on first use)."""
1333
+ global _ACTIVE
1334
+ if _ACTIVE is None:
1335
+ _ACTIVE = default_config()
1336
+ return _ACTIVE
1337
+
1338
+
1339
+ def set_active(config: SubstrateConfig) -> None:
1340
+ """Install ``config`` as the process-wide active config."""
1341
+ global _ACTIVE
1342
+ _ACTIVE = config
1343
+
1344
+
1345
+ def ensure(config: SubstrateConfig | None = None) -> SubstrateConfig:
1346
+ """Return ``config``, or the process-active config when it is ``None``.
1347
+
1348
+ The one-liner behind the ``cfg = config if config is not None else
1349
+ config.active()`` idiom every projection/reader repeats (`decisions`,
1350
+ `dispatch_top`, `timeline`, the watchdog `run`, …). Centralizing it gives
1351
+ those call sites a single, typed entry point — and one place to change how a
1352
+ ``None`` config resolves — instead of the conditional copy-pasted at each
1353
+ boundary. A non-``None`` config is returned unchanged (never re-resolved), so
1354
+ an explicit ``cfg=`` passed by a library caller still wins, exactly as before.
1355
+ """
1356
+ return config if config is not None else active()
1357
+
1358
+
1359
+ # The process-wide active DOS_HOME. Resolved LAZILY (and cached) on first use,
1360
+ # NOT via a `default_factory` on every SubstrateConfig construction — DOS_HOME is
1361
+ # per-machine and root-invariant, so re-resolving it on every config build (every
1362
+ # read-only syscall, every test fixture) would be needless env-churn. A test
1363
+ # redirects it by `set_active_home(...)` or `DISPATCH_HOME` before first use, or —
1364
+ # the robust idiom — by passing the optional `home=` arg every `dos.home`
1365
+ # reader/writer accepts (so it never has to reset this global).
1366
+ _ACTIVE_HOME: HomeLayout | None = None
1367
+
1368
+
1369
+ def active_home() -> HomeLayout:
1370
+ """The process-wide active DOS_HOME layout (env-resolved on first use)."""
1371
+ global _ACTIVE_HOME
1372
+ if _ACTIVE_HOME is None:
1373
+ _ACTIVE_HOME = HomeLayout.for_home()
1374
+ return _ACTIVE_HOME
1375
+
1376
+
1377
+ def set_active_home(home: HomeLayout) -> None:
1378
+ """Install ``home`` as the process-wide active DOS_HOME layout."""
1379
+ global _ACTIVE_HOME
1380
+ _ACTIVE_HOME = home