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/data_class.py ADDED
@@ -0,0 +1,397 @@
1
+ """The data-class policy — what KIND of data a path holds, *as data*.
2
+
3
+ This is the direct answer to the recurring "tag agent-trajectory data vs actual
4
+ product changes" problem: a repo's emission tree accretes two very different
5
+ things under the same `docs/_*` (or `.dos/`) roots — **agent-trajectory scratch**
6
+ (run READMEs, result envelopes, per-iteration verdicts, audit reports) that is
7
+ re-derivable and should age out, and **product artifacts** (plans, schemas,
8
+ design docs, baseline anchors) that are deliverables. A reaper can only treat the
9
+ two differently if it can *ask a path which it is*. Before this seam every
10
+ consumer hard-coded its own root list + filename rules (the LC3 sweeper's
11
+ `RUN_DIR_LOG_ROOTS`, the home reaper's `_scratch_classes`), so the classification
12
+ lived in N places and drifted. This module lifts it to ONE declared policy.
13
+
14
+ Why a seam and not a constant
15
+ =============================
16
+
17
+ WHICH paths are trajectory vs product is **policy** — it differs per workspace
18
+ (the reference userland app keeps its runs under `docs/_chained_runs/`; a foreign
19
+ repo keeps none, or keeps them elsewhere). So it rides `SubstrateConfig` next to
20
+ `.reasons`/`.stamp`/`.retention`, declarable in `dos.toml [data_class]`, with a
21
+ **generic default keyed only off `.dos/`-relative shapes** (the kernel's OWN
22
+ emissions) so DOS stays domain-free: the kernel names no host's `docs/` tree, the
23
+ host declares its own patterns. This is the `docs/HACKING.md`
24
+ closed-enum→declared-data pattern that already governs `[reasons]`/`[stamp]`/
25
+ `[retention]`.
26
+
27
+ The four classes
28
+ ================
29
+
30
+ A path classifies into exactly one closed token (the `default_class` when no
31
+ pattern matches):
32
+
33
+ * ``TRAJECTORY`` — re-derivable agent-run scratch (run dirs, result envelopes,
34
+ iteration verdicts, audit reports). The class a retention reaper may age out.
35
+ * ``AUDIT`` — a point-in-time audit/verdict artifact. Re-derivable like
36
+ trajectory but called out separately because some audits are referenced
37
+ (a reaper may keep more of them, or grace them longer).
38
+ * ``BASELINE`` — a measure-then-change anchor (the DD ⚓ baselines.yaml world).
39
+ Re-derivable in principle but load-bearing for the "freeze a baseline before
40
+ you change code" discipline, so the default policy NEVER reaps it — it is
41
+ surfaced for human REVIEW, not auto-collected.
42
+ * ``PRODUCT`` — a deliverable (plan, schema, design doc, source). Never reaped.
43
+
44
+ The shape
45
+ =========
46
+
47
+ A `DataClassPolicy` is the closed set of per-class glob patterns plus one pure
48
+ classifier the kernel exposes:
49
+
50
+ * ``classify(path) -> str`` — match the POSIX-normalized repo-relative path
51
+ against each class's patterns in the fixed priority order
52
+ TRAJECTORY → AUDIT → BASELINE → PRODUCT (first match wins), else
53
+ ``default_class``. Pure, no I/O — a driver (the LC3 sweeper, the home reaper,
54
+ a clutter audit) calls it per path the way `retention.plan_reap` is called per
55
+ scratch class.
56
+
57
+ Patterns are gitignore-flavored globs: ``*`` matches within a path segment, ``**``
58
+ matches across segments (any depth), a trailing ``/`` or ``/**`` matches the dir
59
+ and everything under it. A bare ``foo`` is treated as ``foo`` AND ``foo/**`` (a
60
+ directory pattern matches its contents) — the intuition a host expects when they
61
+ write ``docs/_chained_runs`` and mean "that whole tree."
62
+
63
+ Two named constants ship in the package:
64
+
65
+ * ``GENERIC_DATA_CLASS`` — the generic default: `.dos/`-relative patterns only,
66
+ so a fresh workspace classifies the kernel's own emissions correctly and every
67
+ host path falls through to ``PRODUCT`` until the host declares its own
68
+ ``[data_class]`` patterns. Domain-free — names no host tree.
69
+ * ``NONE_DATA_CLASS`` — every path → ``PRODUCT``. The opt-out / byte-faithful
70
+ baseline for a consumer that wants no classification (everything is a
71
+ deliverable, nothing is reapable by class).
72
+
73
+ Pure stdlib — no third-party imports, no I/O (the `load_from_toml` half opens the
74
+ toml file at the call boundary, exactly as `stamp.load_from_toml` /
75
+ `retention.load_from_toml` do, and is the only function here that touches the
76
+ disk). Leaf module: nothing in the kernel imports *down* into a driver to use it.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ import re
82
+ from dataclasses import dataclass, replace
83
+ from pathlib import Path
84
+ from typing import Any, Mapping
85
+
86
+ # The closed set of data-class tokens. A path is exactly one of these.
87
+ TRAJECTORY = "TRAJECTORY"
88
+ AUDIT = "AUDIT"
89
+ BASELINE = "BASELINE"
90
+ PRODUCT = "PRODUCT"
91
+
92
+ # The priority order the classifier walks: the first class whose patterns match
93
+ # wins. Trajectory before audit before baseline before product so the most
94
+ # aggressively-collectable class is checked first and a path that could read as
95
+ # either (an audit report under a run dir) lands in the more-reapable bucket.
96
+ _CLASS_ORDER = (TRAJECTORY, AUDIT, BASELINE, PRODUCT)
97
+
98
+ # The valid `default_class` values — any of the four tokens.
99
+ _VALID_CLASSES = frozenset(_CLASS_ORDER)
100
+
101
+
102
+ def _normalize(path: str) -> str:
103
+ """Repo-relative path with forward slashes and no leading ``./`` or ``/``.
104
+
105
+ The classifier speaks POSIX so a Windows caller (`docs\\_chained_runs\\...`)
106
+ and a POSIX caller match the same patterns — the same `_rel`-with-forward-
107
+ slashes normalization the LC3 sweeper already does at its I/O boundary.
108
+ """
109
+ s = str(path).replace("\\", "/").strip()
110
+ while s.startswith("./"):
111
+ s = s[2:]
112
+ return s.lstrip("/")
113
+
114
+
115
+ def _glob_to_regex(pattern: str) -> re.Pattern[str]:
116
+ """Translate one gitignore-flavored glob to an anchored regex.
117
+
118
+ Rules (kept deliberately small and unit-tested, not a full gitignore engine):
119
+ * ``**`` matches any number of path segments (including zero), so
120
+ ``a/**/b`` matches ``a/b`` and ``a/x/y/b``.
121
+ * ``*`` matches any run of non-``/`` characters (within one segment).
122
+ * ``?`` matches a single non-``/`` character.
123
+ * a trailing ``/`` means "this dir and everything under it" → the same as
124
+ appending ``**``.
125
+ * everything else is matched literally.
126
+ The result is anchored at both ends (``fullmatch`` semantics) so a pattern
127
+ describes the WHOLE relative path, not a substring.
128
+ """
129
+ pat = pattern.replace("\\", "/").strip()
130
+ # A trailing slash → match the dir and its whole subtree.
131
+ if pat.endswith("/"):
132
+ pat = pat + "**"
133
+ out: list[str] = []
134
+ i = 0
135
+ n = len(pat)
136
+ while i < n:
137
+ c = pat[i]
138
+ if c == "*":
139
+ if i + 1 < n and pat[i + 1] == "*":
140
+ # ``**`` — any depth. Consume an immediately-following ``/`` so
141
+ # ``a/**/b`` allows ``a/b`` (zero segments) as well as ``a/x/b``.
142
+ j = i + 2
143
+ if j < n and pat[j] == "/":
144
+ out.append("(?:.*/)?")
145
+ i = j + 1
146
+ else:
147
+ out.append(".*")
148
+ i = j
149
+ else:
150
+ out.append("[^/]*")
151
+ i += 1
152
+ elif c == "?":
153
+ out.append("[^/]")
154
+ i += 1
155
+ else:
156
+ out.append(re.escape(c))
157
+ i += 1
158
+ return re.compile("^" + "".join(out) + "$")
159
+
160
+
161
+ def _expand_dir_pattern(pattern: str) -> tuple[str, ...]:
162
+ """Treat a pattern as the path it names AND that path's whole subtree.
163
+
164
+ A host writing ``docs/_chained_runs`` (or the wildcarded ``docs/_*_baselines``)
165
+ means "that tree," not "a file named exactly that." So a pattern expands to
166
+ ``(p, p + "/**")`` — match the path itself, and anything under it. A single
167
+ ``*`` stays within a segment, so the base pattern still names a *directory*
168
+ whose contents we want included; the ``/**`` sibling supplies the subtree.
169
+
170
+ A pattern that already spans depth (contains ``**``) or already names its
171
+ subtree (ends with ``/``) is used as-is — appending ``/**`` would be redundant
172
+ (``a/**`` already covers ``a/**/**``) or wrong (``a/`` already → ``a/**`` in
173
+ `_glob_to_regex`).
174
+ """
175
+ p = pattern.replace("\\", "/").strip()
176
+ if not p:
177
+ return ()
178
+ if "**" in p or p.endswith("/"):
179
+ return (p,)
180
+ return (p, p + "/**")
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class DataClassPolicy:
185
+ """The per-workspace path → data-class rules, as immutable data.
186
+
187
+ Each field is a tuple of glob patterns naming the paths in that class; an
188
+ empty tuple means "no path matches this class here." A host overrides only the
189
+ classes it cares about in `dos.toml [data_class]`. The patterns are matched in
190
+ the fixed priority order TRAJECTORY → AUDIT → BASELINE → PRODUCT; the first
191
+ class with a matching pattern wins, else ``default_class``.
192
+
193
+ * ``trajectory_patterns`` — re-derivable agent-run scratch (run dirs,
194
+ result envelopes, iteration verdicts).
195
+ * ``audit_patterns`` — point-in-time audit / verdict artifacts.
196
+ * ``baseline_patterns`` — measure-then-change anchors (NEVER auto-reaped by
197
+ the default policy; surfaced for human review).
198
+ * ``product_patterns`` — explicit product deliverables. Usually empty
199
+ (everything unmatched is product via ``default_class``); present only when
200
+ a host wants a path UNDER a trajectory root pinned as product (an explicit
201
+ keep — checked last, so a more-specific trajectory pattern still wins; use
202
+ a narrower trajectory pattern if you need product to win).
203
+ * ``default_class`` — the class for a path no pattern matches (default
204
+ ``PRODUCT``: unknown ⇒ treat as a deliverable, the safe direction — a
205
+ reaper keying off this class can never reap an unclassified path).
206
+ """
207
+
208
+ trajectory_patterns: tuple[str, ...] = ()
209
+ audit_patterns: tuple[str, ...] = ()
210
+ baseline_patterns: tuple[str, ...] = ()
211
+ product_patterns: tuple[str, ...] = ()
212
+ default_class: str = PRODUCT
213
+
214
+ def _compiled(self) -> dict[str, tuple[re.Pattern[str], ...]]:
215
+ """Per-class compiled regex tuples (dir-expanded). Recomputed per call —
216
+ the policy is small and classify is not a hot loop; keeping it stateless
217
+ avoids caching on a frozen dataclass."""
218
+ raw = {
219
+ TRAJECTORY: self.trajectory_patterns,
220
+ AUDIT: self.audit_patterns,
221
+ BASELINE: self.baseline_patterns,
222
+ PRODUCT: self.product_patterns,
223
+ }
224
+ compiled: dict[str, tuple[re.Pattern[str], ...]] = {}
225
+ for cls, patterns in raw.items():
226
+ regexes: list[re.Pattern[str]] = []
227
+ for p in patterns:
228
+ for expanded in _expand_dir_pattern(p):
229
+ regexes.append(_glob_to_regex(expanded))
230
+ compiled[cls] = tuple(regexes)
231
+ return compiled
232
+
233
+ def classify(self, path: str) -> str:
234
+ """Pure classifier: map a repo-relative path to its data-class token.
235
+
236
+ Normalizes the path to POSIX, then checks each class's patterns in the
237
+ fixed priority order; returns the first class whose pattern matches, or
238
+ ``default_class`` if none do. No I/O.
239
+ """
240
+ rel = _normalize(path)
241
+ compiled = self._compiled()
242
+ for cls in _CLASS_ORDER:
243
+ for rx in compiled[cls]:
244
+ if rx.match(rel):
245
+ return cls
246
+ return self.default_class
247
+
248
+ def with_overrides(self, **changes: Any) -> "DataClassPolicy":
249
+ """Return a copy with the named fields replaced (thin `dataclasses.replace`)."""
250
+ return replace(self, **changes)
251
+
252
+
253
+ # The generic default — `.dos/`-relative patterns ONLY, so DOS stays domain-free.
254
+ # A fresh workspace classifies the kernel's own emissions (run-dirs, verdict
255
+ # sidecars, trajectory-audit reports under `.dos/`) and everything else falls
256
+ # through to PRODUCT until the host declares its own `[data_class]` patterns for
257
+ # its `docs/` tree. Names no host path.
258
+ GENERIC_DATA_CLASS = DataClassPolicy(
259
+ trajectory_patterns=(
260
+ # Bare dir names — dir-expansion matches the dir AND its whole subtree
261
+ # (`.dos/runs` ⇒ `.dos/runs` + `.dos/runs/**`), so both the scratch dir
262
+ # itself and the run-dirs inside it classify as TRAJECTORY.
263
+ ".dos/runs",
264
+ ".dos/fanout_runs",
265
+ ".dos/chained_runs",
266
+ ".dos/dispatch_loops",
267
+ ".dos/verdicts",
268
+ ),
269
+ audit_patterns=(
270
+ ".dos/audits",
271
+ ".dos/picker_audits",
272
+ ".dos/**/*.verdict-*.json", # a verdict sidecar anywhere under .dos/
273
+ ),
274
+ baseline_patterns=(
275
+ ".dos/baselines",
276
+ ),
277
+ product_patterns=(),
278
+ default_class=PRODUCT,
279
+ )
280
+
281
+ # The explicit opt-out: every path classifies as PRODUCT, no class-based handling.
282
+ # The byte-faithful "no data-class seam" baseline — a consumer that installs this
283
+ # sees nothing as trajectory/audit/baseline, so a class-keyed reaper reaps nothing.
284
+ NONE_DATA_CLASS = DataClassPolicy(
285
+ trajectory_patterns=(),
286
+ audit_patterns=(),
287
+ baseline_patterns=(),
288
+ product_patterns=(),
289
+ default_class=PRODUCT,
290
+ )
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # The `dos.toml [data_class]` reader — the data attachment, file I/O at the
295
+ # boundary. Mirrors `stamp.load_from_toml` / `retention.load_from_toml` in shape.
296
+ # ---------------------------------------------------------------------------
297
+
298
+ # The pattern-list fields (each a tuple-of-strings) and the scalar default_class.
299
+ _PATTERN_KEYS = frozenset({
300
+ "trajectory_patterns", "audit_patterns", "baseline_patterns", "product_patterns",
301
+ })
302
+ _ALLOWED_KEYS = _PATTERN_KEYS | {"default_class"}
303
+
304
+
305
+ def _str_tuple(value: object, key: str) -> tuple[str, ...]:
306
+ """Coerce a TOML value to a tuple of strings, or raise naming the bad key.
307
+
308
+ Accepts a single string (wrapped) or a list of strings. Anything else — a
309
+ number, a nested table, a list with a non-string element — is a host mistake
310
+ worth surfacing loudly at load (the same posture `stamp._str_tuple` takes).
311
+ """
312
+ if isinstance(value, str):
313
+ return (value,)
314
+ if isinstance(value, (list, tuple)):
315
+ out: list[str] = []
316
+ for item in value:
317
+ if not isinstance(item, str):
318
+ raise ValueError(
319
+ f"[data_class].{key} must be a list of strings; got a "
320
+ f"{type(item).__name__} element ({item!r})"
321
+ )
322
+ out.append(item)
323
+ return tuple(out)
324
+ raise ValueError(
325
+ f"[data_class].{key} must be a string or list of strings, "
326
+ f"got {type(value).__name__}"
327
+ )
328
+
329
+
330
+ def policy_from_table(
331
+ table: Mapping[str, Any], *, base: DataClassPolicy = GENERIC_DATA_CLASS
332
+ ) -> DataClassPolicy:
333
+ """Build a `DataClassPolicy` from a parsed `[data_class]` table, over ``base``.
334
+
335
+ A present key OVERRIDES the corresponding base field (override, not merge — a
336
+ host that declares ``trajectory_patterns`` gets exactly those, not those plus
337
+ the base's); an absent key inherits it. An UNKNOWN key raises `ValueError` (a
338
+ typo'd field — ``trajctory_patterns`` — is a host mistake worth surfacing
339
+ loudly, the same posture every other seam reader takes). ``default_class``
340
+ must be one of the four tokens.
341
+ """
342
+ if not isinstance(table, Mapping):
343
+ raise ValueError(f"[data_class] must be a table, got {type(table).__name__}")
344
+ unknown = set(table) - _ALLOWED_KEYS
345
+ if unknown:
346
+ raise ValueError(
347
+ f"unknown [data_class] key(s): {', '.join(sorted(unknown))} "
348
+ f"(allowed: {', '.join(sorted(_ALLOWED_KEYS))})"
349
+ )
350
+ changes: dict[str, Any] = {}
351
+ for key in _PATTERN_KEYS & set(table):
352
+ changes[key] = _str_tuple(table[key], key)
353
+ if "default_class" in table:
354
+ raw = table["default_class"]
355
+ if not isinstance(raw, str):
356
+ raise ValueError(
357
+ f"[data_class].default_class must be a string, got {raw!r}"
358
+ )
359
+ if raw not in _VALID_CLASSES:
360
+ raise ValueError(
361
+ f"[data_class].default_class must be one of "
362
+ f"{', '.join(sorted(_VALID_CLASSES))}, got {raw!r}"
363
+ )
364
+ changes["default_class"] = raw
365
+ return replace(base, **changes)
366
+
367
+
368
+ def load_from_toml(
369
+ path: Path | str, *, base: DataClassPolicy = GENERIC_DATA_CLASS
370
+ ) -> DataClassPolicy:
371
+ """Build a `DataClassPolicy` from a `dos.toml`'s `[data_class]` table.
372
+
373
+ Returns ``base`` unchanged when the file is absent, has no `[data_class]`
374
+ table, or `tomllib` is unavailable (Python < 3.11 with no `tomli`) — the
375
+ declarative path is purely additive, so a missing/empty config degrades to the
376
+ supplied base, never an error. A *present but malformed* `[data_class]` table
377
+ raises (`policy_from_table`), surfaced by `load_workspace_config`'s
378
+ warn-and-fall-back. Mirrors `stamp.load_from_toml` / `retention.load_from_toml`
379
+ exactly.
380
+ """
381
+ p = Path(path)
382
+ if not p.exists():
383
+ return base
384
+ try:
385
+ import tomllib # py3.11+
386
+ except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
387
+ try:
388
+ import tomli as tomllib # type: ignore
389
+ except ModuleNotFoundError:
390
+ return base
391
+ # `utf-8-sig` strips a UTF-8 BOM (PowerShell's `utf8` writes one) — the same
392
+ # fix as `config._load_toml_table` / `stamp.load_from_toml`.
393
+ data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
394
+ table = data.get("data_class")
395
+ if not isinstance(table, dict) or not table:
396
+ return base
397
+ return policy_from_table(table, base=base)