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/plan_source.py ADDED
@@ -0,0 +1,455 @@
1
+ """The plan-source seam — pluggable discovery of the (plan, phase) rows a plan view audits.
2
+
3
+ Why this exists
4
+ ===============
5
+
6
+ DOS deliberately holds **no plan schema** (CLAUDE.md: "Phased-plan concepts are NOT
7
+ in this package"). `verify()` takes ``(plan, phase)`` positionally and answers from
8
+ git alone when no plan exists. So a screen that wants to show "the shape of the work
9
+ and how far it has shipped" cannot read a plan registry the kernel doesn't believe
10
+ in — it must instead ask a *declared* source for a flat list of candidate rows, then
11
+ let the **oracle** rule on each one's ship status. The plan is a row source; the truth
12
+ is `oracle.is_shipped`. That inversion is the whole point: a plan-status view built on
13
+ the plan's own self-report would be a self-narrating worker; one built on the oracle's
14
+ verdict is the kernel doing its job at the plan altitude.
15
+
16
+ This module is that row source — the **seam**, not a schema. It is the exact analogue
17
+ of `dos.judges` (the JUDGE-rung seam): a domain-neutral Protocol + a frozen value type
18
+ + a fail-safe runner + a by-name resolver over an entry-point group + a single built-in
19
+ that ships in the kernel. Every host-specific bit (where plans live, what a phase
20
+ heading looks like, what a ship stamp reads as) is either CONFIG DATA the built-in
21
+ reads (`config.paths.plans_glob`) or a host's own `dos.plan_sources` plugin — never a
22
+ hardcoded `docs/_plans` literal in a kernel module.
23
+
24
+ The unit a source yields is a `PlanRow` — a domain-neutral
25
+ ``{plan, phase, doc_path, claimed_status}`` quadruple. ``claimed_status`` is the
26
+ **plan's self-report** (the stamp it carries / "open" / "blocked") — the part DOS does
27
+ NOT believe; it is shown only to contrast against the oracle's verdict (the
28
+ believed-vs-adjudicated divergence the plan view is built around). A source NEVER
29
+ returns a ship verdict — that is `oracle.is_shipped`'s job alone, attached downstream.
30
+
31
+ Purity & layering
32
+ ==================
33
+
34
+ Pure kernel, exactly like `judges`: a Protocol, one frozen value type, a built-in that
35
+ harvests markdown, and resolver/runner helpers. The built-in's markdown read is
36
+ **boundary I/O gathered when the source runs** — there is no verdict here to keep pure
37
+ (a row list is data, not an adjudication), but the discipline that matters carries
38
+ over: the source names no host (it reads `config.paths.plans_glob`, declared per
39
+ workspace), and **fail-to-empty** — `run_plan_source` converts any raise / bad return
40
+ into ``[]``, never a partial or fabricated row, so a broken source degrades the plan
41
+ view to its no-plan floor rather than inventing work. Entry-point discovery (the one
42
+ bit of registry I/O) happens at the call boundary in `active_plan_sources`, exactly as
43
+ `active_judges` / `active_predicates` do.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import re
49
+ import sys
50
+ from dataclasses import dataclass
51
+ from pathlib import Path
52
+ from typing import Protocol, runtime_checkable
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # The row a source yields — domain-neutral, frozen, carries NO ship verdict.
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ # The closed self-report vocabulary. A source reports what the PLAN claims about a
61
+ # phase — never a verified fact. `str`-valued so it round-trips through `--json`
62
+ # without a lookup table (the `gate_classify.Verdict` idiom). The oracle's verdict
63
+ # is a SEPARATE axis attached downstream; these never name "shipped-as-fact".
64
+ CLAIMED_SHIPPED = "shipped" # the plan stamps this phase as done (a `· SHIPPED` mark)
65
+ CLAIMED_BLOCKED = "blocked" # the plan marks it gated / soaking / awaiting
66
+ CLAIMED_OPEN = "open" # the plan lists it but claims no status
67
+ CLAIMED_UNKNOWN = "" # the source could not read a status off the plan
68
+
69
+ _CLAIMED_VALUES = frozenset({CLAIMED_SHIPPED, CLAIMED_BLOCKED, CLAIMED_OPEN, CLAIMED_UNKNOWN})
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class PlanRow:
74
+ """One candidate ``(plan, phase)`` the plan view will ask the oracle about.
75
+
76
+ Deliberately NOT a plan-schema node — it is the *flat* shape a plan view needs:
77
+ the positional ``(plan, phase)`` `oracle.is_shipped` takes, the ``doc_path`` the
78
+ row was harvested from (for drill-in / the oracle's doc-aware cross-check), and the
79
+ ``claimed_status`` — the plan's OWN self-report (`shipped`/`blocked`/`open`), the
80
+ narration DOS distrusts and shows only to contrast against the oracle. ``lane`` is
81
+ an OPTIONAL hint a source may carry when the plan names the phase's lane; the plan
82
+ view uses it to join a row to a live lease / decision, and it is "" when unknown
83
+ (the join then falls back to lane-name matching downstream).
84
+
85
+ A `PlanRow` carries no ship verdict on purpose: the source's job is to enumerate
86
+ candidates honestly, not to rule on them. The ruling is the oracle's, attached in
87
+ `plan_board`.
88
+ """
89
+
90
+ plan: str
91
+ phase: str
92
+ doc_path: str = ""
93
+ claimed_status: str = CLAIMED_UNKNOWN
94
+ lane: str = ""
95
+
96
+ def to_dict(self) -> dict:
97
+ return {
98
+ "plan": self.plan,
99
+ "phase": self.phase,
100
+ "doc_path": self.doc_path,
101
+ "claimed_status": self.claimed_status,
102
+ "lane": self.lane,
103
+ }
104
+
105
+
106
+ @runtime_checkable
107
+ class PlanSource(Protocol):
108
+ """The contract a host implements to tell a plan view where its phases live.
109
+
110
+ ``name`` is the token a CLI flag selects and `dos doctor` could list. ``rows`` is
111
+ handed the active ``config`` (read-only — it reads `config.paths.plans_glob` and
112
+ the workspace root; the type gives it nothing to mutate) and returns the candidate
113
+ `PlanRow`s, in a deterministic order (declaration / file order — the plan view
114
+ renders them as given).
115
+
116
+ A source MAY do I/O *inside* ``rows`` (glob the workspace, read markdown) — it is
117
+ the boundary read that turns "where are the plans" into data. The discipline that
118
+ keeps it honest is fail-to-empty (enforced by `run_plan_source`, not by trusting
119
+ the source) and naming no host (the built-in reads the declared glob, never a
120
+ literal), NOT purity — a row list is data, not an adjudication.
121
+ """
122
+
123
+ name: str
124
+
125
+ def rows(self, config: object) -> list[PlanRow]:
126
+ ...
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # The built-in markdown source — harvests `### N. PLAN PHASE` headings + a
131
+ # `· SHIPPED` / `SHIPPED` stamp from the files config.paths.plans_glob names.
132
+ # ---------------------------------------------------------------------------
133
+
134
+ # A numbered packet/plan heading: `### 1. IF IF4.1 — title…` or `## 3. AUTH P2: …`.
135
+ # The first token after the section number is the plan id (starts with a letter), the
136
+ # second is the phase id (the exact positional string the oracle takes). This is the
137
+ # SAME unambiguous shape `timeline._parse_packet_picks` harvests — lifted here as the
138
+ # generic default so a plan view and a dispatch timeline read the same heading grammar.
139
+ #
140
+ # Two guards keep the generic harvest CONSERVATIVE — it must under-harvest a foreign
141
+ # convention (→ "(no plans)" + the git floor, the honest degrade) rather than mine prose
142
+ # for phantom phases (the live-repo failure mode: `### Why never-stall` →
143
+ # `(Why, never-stall)`):
144
+ # * the trailing ``[—–\-:]`` separator is REQUIRED — a real plan heading titles its
145
+ # phase (`### 1. IF IF4.1 — split`); a plain numbered section header
146
+ # (`## 2. Next items`) has no separator after its second word.
147
+ # * the phase token must contain a DIGIT **and** a LETTER (`_looks_like_phase_id`) — a
148
+ # real phase id (`IF4.1`, `P2`, `1a`) carries both a series letter and an ordinal; a
149
+ # prose word (`items`, `Built`, `TOML`) has no digit, and a bare ordinal (the `2` in a
150
+ # prose `### 1. Phase 2 of 3 — done`) has no letter. Both are rejected — the single
151
+ # most effective false-positive cut.
152
+ # A repo whose plan docs use another shape (DOS's OWN `### Phase N:` / `- **1a.**` design
153
+ # docs do) ships a `dos.plan_sources` plugin — the kernel default does not guess.
154
+ _HEADING_RE = re.compile(
155
+ r"^#{2,4}\s+\d+\.\s+([A-Za-z][A-Za-z0-9_\-]*)\s+([A-Za-z0-9][A-Za-z0-9_\-./']*)\s*[—–\-:]",
156
+ )
157
+
158
+ # A bullet sub-phase row under a numbered heading: `- **IF4.2 — …`. The phase id is the
159
+ # bolded leading token; there is no plan id on the bullet, so it inherits the enclosing
160
+ # NUMBERED heading's plan (never a prose `###`). Like the heading, the token must look
161
+ # like a phase id (carry a digit) — without that guard a bolded design principle
162
+ # (`- **Rendering is downstream…`) harvests as a phantom phase (the live-repo failure).
163
+ _BULLET_RE = re.compile(
164
+ r"^\s*-\s+\*\*([A-Za-z0-9][A-Za-z0-9_\-./']*)\s*(?:[—\-–:]|\*\*)",
165
+ )
166
+
167
+
168
+ def _looks_like_phase_id(token: str) -> bool:
169
+ """True iff ``token`` has the generic phase-id shape: a LETTER and a DIGIT.
170
+
171
+ Every real phase id carries both — a series letter and an ordinal: `IF4.1`, `P2`,
172
+ `AUTH4`, `RS4`, `1a`, `MG3'-1`. Requiring both is the conservative false-positive cut
173
+ that keeps the generic default honest against prose the loose heading/bullet regex
174
+ would otherwise mine:
175
+
176
+ * a prose WORD (`items`, `Built`, `TOML`, `release`, `downstream`) has a letter but
177
+ no digit → rejected;
178
+ * a BARE ORDINAL (`2`, `3`) — the second token of a prose `### 1. Phase 2 of 3 —
179
+ done` heading, which would mis-harvest as the phantom phase `(Phase, 2)` — has a
180
+ digit but no letter → rejected.
181
+
182
+ The cost is the bare-ordinal `### Phase 6:` plan dialect (DOS's own design docs): its
183
+ phase token `6` is digit-only, so it is NOT harvested by the default. That is the
184
+ documented tradeoff — under-harvest a digit-less / bare-ordinal convention (ship a
185
+ `dos.plan_sources` plugin) rather than mine prose for phantom work."""
186
+ return any(c.isalpha() for c in token) and any(c.isdigit() for c in token)
187
+
188
+ # A heading section's own SHIPPED stamp lives in the lines under it until the next
189
+ # heading. We read the claimed status off the section text: a `SHIPPED` token (the
190
+ # universal stamp word `phase_shipped` keys on) ⇒ claimed shipped; a soak/gate/await
191
+ # word ⇒ claimed blocked; otherwise open. These are CLAIMED-only reads — the plan's
192
+ # narration, never a verified fact.
193
+ _STAMP_SHIPPED_RE = re.compile(r"\bSHIPPED\b")
194
+ _STAMP_BLOCKED_RE = re.compile(r"\b(?:SOAK|SOAKING|BLOCKED|AWAITING|GATED|DEFERRED)\b", re.IGNORECASE)
195
+
196
+
197
+ def _claimed_status_for(section_text: str) -> str:
198
+ """Read the plan's self-reported status off a phase's section text.
199
+
200
+ Pure. A `SHIPPED` token wins (the plan claims done); else a soak/blocked/await
201
+ word ⇒ blocked; else open. This is the plan's NARRATION — the part the plan view
202
+ distrusts and shows only to contrast against the oracle. An empty section ⇒ open
203
+ (the plan lists the phase but claims nothing).
204
+ """
205
+ if _STAMP_SHIPPED_RE.search(section_text):
206
+ return CLAIMED_SHIPPED
207
+ if _STAMP_BLOCKED_RE.search(section_text):
208
+ return CLAIMED_BLOCKED
209
+ return CLAIMED_OPEN
210
+
211
+
212
+ def _harvest_markdown(text: str, doc_path: str) -> list[PlanRow]:
213
+ """Parse one plan-doc's text into ordered PlanRows. Pure, no I/O.
214
+
215
+ Two recognised shapes, both coded GENERICALLY (no host directory or series literal)
216
+ and both gated on `_looks_like_phase_id` so prose is never mined for phantom phases:
217
+
218
+ * a numbered ``### N. PLAN PHASE — …`` heading yields a row (plan = first token,
219
+ phase = second), claimed status read from the lines under it (until the next
220
+ heading). This sets the enclosing plan for any bullets that follow.
221
+ * a bolded ``- **PHASE — …`` bullet INHERITS the most-recent numbered heading's
222
+ plan, yielding a sub-phase row, its claimed status read from the bullet's line.
223
+
224
+ A bullet with no preceding numbered heading is dropped (there is no honest plan id to
225
+ give it). De-duped on ``(plan, phase)`` preserving first-seen order. A doc with no
226
+ recognised heading yields ``[]`` — the conservative degrade (DOS's own `### Phase N:`
227
+ design-doc dialect lands here, and that is correct: it wants a `dos.plan_sources`
228
+ plugin, not a guess).
229
+ """
230
+ lines = text.splitlines()
231
+ rows: list[PlanRow] = []
232
+ seen: set[tuple[str, str]] = set()
233
+ cur_plan = "" # the plan id from the most-recent NUMBERED heading (bullets inherit it)
234
+
235
+ # Pre-compute each heading line's index so a section's body is the slice up to the
236
+ # next heading — used to read a `### N. PLAN PHASE` row's claimed status.
237
+ heading_idx = [i for i, ln in enumerate(lines) if re.match(r"^#{2,4}\s+", ln)]
238
+ next_heading_after = {}
239
+ for pos, idx in enumerate(heading_idx):
240
+ nxt = heading_idx[pos + 1] if pos + 1 < len(heading_idx) else len(lines)
241
+ next_heading_after[idx] = nxt
242
+
243
+ def _add(plan: str, phase: str, claimed: str) -> None:
244
+ key = (plan, phase)
245
+ if not plan or not phase or key in seen or not _looks_like_phase_id(phase):
246
+ return
247
+ seen.add(key)
248
+ rows.append(PlanRow(plan=plan, phase=phase, doc_path=doc_path, claimed_status=claimed))
249
+
250
+ for i, line in enumerate(lines):
251
+ m = _HEADING_RE.match(line)
252
+ if m:
253
+ plan_tok, phase = m.group(1), m.group(2)
254
+ # Adopt the heading's first token as the enclosing plan ONLY when the heading
255
+ # itself harvested a real phase (its second token passed `_looks_like_phase_id`).
256
+ # A PROSE numbered heading (`### 1. The rationale — why`, second token `why` →
257
+ # no digit) must NOT scope the bullets below it: leaving `cur_plan` set to a
258
+ # prose word ("The") would let a following digit-bearing bullet inherit it as a
259
+ # phantom plan id (`(The, v2.0)`). Clearing it is the conservative degrade — the
260
+ # bullet is dropped (no honest plan), same under-harvest posture as the phase
261
+ # digit-guard. (Cost: a `### 1. IF overview` heading whose own second token is
262
+ # prose no longer scopes its IF bullets — ship a `dos.plan_sources` plugin for
263
+ # that dialect; the default does not guess a plan id off prose.)
264
+ if _looks_like_phase_id(phase):
265
+ cur_plan = plan_tok
266
+ body = "\n".join(lines[i + 1 : next_heading_after.get(i, i + 1)])
267
+ _add(cur_plan, phase, _claimed_status_for(line + "\n" + body))
268
+ else:
269
+ cur_plan = ""
270
+ continue
271
+ bm = _BULLET_RE.match(line)
272
+ if bm and cur_plan:
273
+ _add(cur_plan, bm.group(1), _claimed_status_for(line))
274
+ return rows
275
+
276
+
277
+ class MarkdownPlanSource:
278
+ """The built-in, always-available plan source: harvest the workspace's plan docs.
279
+
280
+ Globs ``config.paths.plans_glob`` (the declared, per-workspace plan location —
281
+ generic default ``docs/**/*-plan.md``, overridable in `dos.toml [paths]`) under the
282
+ workspace root, parses each matched markdown file for ``### N. PLAN PHASE`` headings
283
+ and ``- **PHASE`` bullet sub-phases, and reads each phase's CLAIMED status off its
284
+ section. Names no host directory — the glob is data.
285
+
286
+ The plan-source analogue of `judges.AbstainJudge` / the `text` renderer: a trusted
287
+ fallback a plugin can never shadow (`resolve_plan_source` resolves built-ins first),
288
+ and the honest zero of the seam — a workspace with no plugin still has a resolvable
289
+ source. A repo with no plans (or a non-markdown plan convention with no plugin)
290
+ yields ``[]``, which is the plan view's no-plan floor (the git-ships strip carries
291
+ the screen, exactly as `dos top` degrades).
292
+ """
293
+
294
+ name = "markdown"
295
+
296
+ def rows(self, config: object) -> list[PlanRow]:
297
+ paths = getattr(config, "paths", None)
298
+ if paths is None:
299
+ return []
300
+ root = Path(getattr(paths, "root", "."))
301
+ glob = str(getattr(paths, "plans_glob", "") or "")
302
+ if not glob:
303
+ return []
304
+ try:
305
+ matched = sorted(root.glob(glob))
306
+ except (OSError, ValueError):
307
+ return []
308
+ out: list[PlanRow] = []
309
+ for p in matched:
310
+ try:
311
+ if not p.is_file():
312
+ continue
313
+ text = p.read_text(encoding="utf-8", errors="replace")
314
+ except OSError:
315
+ continue
316
+ try:
317
+ rel = str(p.relative_to(root))
318
+ except ValueError:
319
+ rel = str(p)
320
+ out.extend(_harvest_markdown(text, rel))
321
+ return out
322
+
323
+
324
+ def run_plan_source(source: PlanSource, config: object) -> list[PlanRow]:
325
+ """Run one source, enforcing **fail-to-empty** + a clean, deduped row list.
326
+
327
+ The wrapper EVERY consumer calls instead of `source.rows(...)` directly — it makes
328
+ "a broken source degrades the plan view to its no-plan floor, never to fabricated
329
+ or partial rows" a structural guarantee rather than a hope:
330
+
331
+ * a source that **raises** (bad glob, unreadable tree, a bug) → ``[]``. Never
332
+ propagates; the plan view falls to its git-ships floor.
333
+ * a source that returns **anything that is not a list of `PlanRow`** → its
334
+ non-`PlanRow` items are dropped (a duck-typed look-alike never reaches the
335
+ oracle), and a non-iterable return → ``[]``.
336
+
337
+ The asymmetry note vs `judges.run_judge`: a judge fails to ABSTAIN (punt up the
338
+ ladder); a plan source fails to EMPTY (show no work). Both refuse to let a failure
339
+ fabricate an outcome — a judge never auto-CLEARS, a source never auto-INVENTS a
340
+ phase. Claimed-status values outside the closed set are normalised to UNKNOWN so a
341
+ plugin can't smuggle a free-text status into the divergence logic downstream.
342
+ """
343
+ try:
344
+ rows = source.rows(config)
345
+ except Exception: # fail-to-empty: a source that raises contributes nothing
346
+ return []
347
+ if not isinstance(rows, (list, tuple)):
348
+ return []
349
+ out: list[PlanRow] = []
350
+ for r in rows:
351
+ if not isinstance(r, PlanRow):
352
+ continue
353
+ if r.claimed_status not in _CLAIMED_VALUES:
354
+ out.append(PlanRow(plan=r.plan, phase=r.phase, doc_path=r.doc_path,
355
+ claimed_status=CLAIMED_UNKNOWN, lane=r.lane))
356
+ else:
357
+ out.append(r)
358
+ return out
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # Resolution — built-in first, then the `dos.plan_sources` entry-point group.
363
+ # ---------------------------------------------------------------------------
364
+
365
+ # The entry-point group a host/researcher registers a plan source under.
366
+ PLAN_SOURCE_ENTRY_POINT_GROUP = "dos.plan_sources"
367
+
368
+ # The built-in sources, resolvable by name and UNSHADOWABLE by a plugin (a plugin
369
+ # registering `markdown` cannot displace this one — built-ins resolve first). Only the
370
+ # generic markdown harvester ships in the kernel; a host's bespoke plan format is a
371
+ # plugin (the kernel has no host plan schema).
372
+ _BUILT_IN_PLAN_SOURCES: dict[str, type] = {
373
+ MarkdownPlanSource.name: MarkdownPlanSource,
374
+ }
375
+
376
+
377
+ def _discover_entry_point_plan_sources(*, _stderr=None) -> list[tuple[str, PlanSource]]:
378
+ """Find plan sources registered under the `dos.plan_sources` entry-point group.
379
+
380
+ A plugin registers ``name = "pkg.module:SourceClass"`` in its
381
+ ``[project.entry-points."dos.plan_sources"]``. We load each, instantiate it if it
382
+ is a class, and return ``(entry_point_name, source)`` pairs sorted by name (stable,
383
+ deterministic order). A plugin that fails to load is skipped with a one-line stderr
384
+ note rather than crashing — the same posture `judges._discover_entry_point_judges`
385
+ takes (a broken third-party plugin is the operator's to fix, not a kernel fault).
386
+ """
387
+ stderr = _stderr if _stderr is not None else sys.stderr
388
+ out: list[tuple[str, PlanSource]] = []
389
+ try:
390
+ from importlib.metadata import entry_points
391
+ except Exception: # pragma: no cover - importlib.metadata always present py3.11+
392
+ return out
393
+ try:
394
+ eps = entry_points(group=PLAN_SOURCE_ENTRY_POINT_GROUP)
395
+ except TypeError: # pragma: no cover - py<3.10 selectable-API fallback
396
+ eps = entry_points().get(PLAN_SOURCE_ENTRY_POINT_GROUP, []) # type: ignore[attr-defined]
397
+ except Exception: # pragma: no cover - defensive: never let discovery crash a call
398
+ return out
399
+ for ep in sorted(eps, key=lambda e: e.name):
400
+ try:
401
+ obj = ep.load()
402
+ source = obj() if isinstance(obj, type) else obj
403
+ except Exception as e: # pragma: no cover - depends on third-party plugin
404
+ print(
405
+ f"warning: plan source plugin {ep.name!r} failed to load ({e}); skipping",
406
+ file=stderr,
407
+ )
408
+ continue
409
+ out.append((ep.name, source))
410
+ return out
411
+
412
+
413
+ def resolve_plan_source(name: str, *, _stderr=None) -> PlanSource:
414
+ """Resolve a plan source by name: built-ins first, then `dos.plan_sources` plugins.
415
+
416
+ Built-ins (`markdown`) resolve FIRST and cannot be shadowed by a plugin of the same
417
+ name — the trusted-fallback guarantee, identical to `resolve_judge`. An unknown name
418
+ fails LOUD with the known list (it never silently degrades to `markdown`, which would
419
+ hide a typo'd selector): the caller asked for a specific source and getting a
420
+ different one silently is exactly the unannounced substitution the kernel refuses.
421
+ """
422
+ if name in _BUILT_IN_PLAN_SOURCES:
423
+ return _BUILT_IN_PLAN_SOURCES[name]()
424
+ discovered = dict(_discover_entry_point_plan_sources(_stderr=_stderr))
425
+ if name in discovered:
426
+ return discovered[name]
427
+ known = sorted(set(_BUILT_IN_PLAN_SOURCES) | set(discovered))
428
+ raise ValueError(f"unknown plan source {name!r}; known: {', '.join(known)}")
429
+
430
+
431
+ def active_plan_sources(*, _stderr=None) -> list[tuple[str, PlanSource]]:
432
+ """Every resolvable source as ``(name, source)`` — built-ins THEN discovered plugins.
433
+
434
+ Does ENTRY-POINT DISCOVERY (I/O), so it is a call-boundary helper, never called
435
+ inside a row harvest (the `active_judges` discipline)."""
436
+ built = [(n, cls()) for n, cls in _BUILT_IN_PLAN_SOURCES.items()]
437
+ discovered = _discover_entry_point_plan_sources(_stderr=_stderr)
438
+ return built + discovered
439
+
440
+
441
+ def active_plan_source_names(*, _stderr=None) -> list[str]:
442
+ """The names of every active source (built-in + discovered) — what a `dos doctor`
443
+ listing or a `--plan-source` help text would show."""
444
+ return [name for name, _src in active_plan_sources(_stderr=_stderr)]
445
+
446
+
447
+ def default_rows(config: object, *, _stderr=None) -> list[PlanRow]:
448
+ """The plan view's default row set: the built-in markdown source, run fail-safe.
449
+
450
+ The one call `plan_board.snapshot` makes when no explicit source/phase list was
451
+ given. Kept here (not in `plan_board`) so the "which source is the default" decision
452
+ lives with the seam, and so a future change to compose MULTIPLE active sources is a
453
+ one-line edit here rather than in the projection.
454
+ """
455
+ return run_plan_source(MarkdownPlanSource(), config)