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