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/recurring_wedge.py ADDED
@@ -0,0 +1,206 @@
1
+ """recurring-wedge — the pure "is this blocker recurring?" fold.
2
+
3
+ A host's dispatch loop, when it STOPs on a BLOCKED/STALLED iteration, wants to
4
+ know whether *the same structural cause* has wedged across several recent runs —
5
+ a recurring structural defect worth routing to a remediation sweep — or whether
6
+ it is a one-off (noise the sweep can't help with). That decision is **domain-free
7
+ mechanism**: given a bag of attributed non-ship occurrences (`BlockerHit`s, each
8
+ carrying an opaque `cause_key` string the kernel never interprets), cluster them
9
+ by cause, pick the cluster the *current run* actually hit that spans the most
10
+ distinct runs, and call it recurring iff it spans `>= min_recurrence` runs.
11
+
12
+ This is the `journal_delta.fold_since` shape for the wedge axis: **frozen data
13
+ in, a frozen verdict out, no I/O** — the caller mines the run history (reads the
14
+ READMEs, classifies each Outcome cell into a `cause_key` via its *own* taxonomy)
15
+ at the boundary and passes the materialized `BlockerHit`s here. It is therefore
16
+ replay-testable on frozen hit lists with no disk and no live multi-run loop.
17
+
18
+ WHAT IS KERNEL vs HOST — the boundary that keeps "kernel imports no host":
19
+
20
+ * KERNEL (here): the cluster fold + the recurrence threshold + the
21
+ stall-score ranking (`runs_affected` dominates, cost/wall break ties). A
22
+ `cause_key` is an **opaque string**; the kernel never knows what it *means*.
23
+ * HOST (the caller): the cause TAXONOMY — what each `cause_key` stands for,
24
+ its human label, its proposed fix, its owning plan, and whether it is an
25
+ operator-decision class (routed elsewhere). The host classifies Outcome
26
+ cells into `cause_key`s, calls this fold, and re-attaches the taxonomy by
27
+ key. That split mirrors the shipped `dos.tokens.BlockedReason` (kernel
28
+ catalog) ↔ a host cue table relationship.
29
+
30
+ Distinct from `dos.wedge_reason` (the closed *reason_class token* vocabulary a
31
+ no-pick emits): this module is the *temporal recurrence* fold over already-keyed
32
+ occurrences, not the token enum. Different mechanism, separate leaf.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ from dataclasses import dataclass, field
37
+ from typing import Iterable, Optional
38
+
39
+ # A cause is "recurring" at this many distinct affected runs (this run included).
40
+ DEFAULT_MIN_RECURRENCE = 2
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class BlockerHit:
45
+ """One non-ship occurrence, attributed to a run + iteration.
46
+
47
+ `cause_key` is an OPAQUE string — the kernel groups on it but never
48
+ interprets it (the host's taxonomy owns what it means). `cost_usd`/`wall_min`
49
+ are optional stall-cost signals that only ever break recurrence ties.
50
+ """
51
+
52
+ run: str
53
+ iter_n: int | str
54
+ cause_key: str
55
+ cost_usd: float | None
56
+ wall_min: float | None
57
+ example: str
58
+ source: str
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class WedgeCluster:
63
+ """All hits sharing one `cause_key`, with the derived stall signals.
64
+
65
+ Carries the `cause_key` STRING only — never a host taxonomy object — so the
66
+ kernel cluster stays domain-free. The host re-joins label/fix/owning-plan by
67
+ key after the fold.
68
+ """
69
+
70
+ cause_key: str
71
+ hits: tuple[BlockerHit, ...] = field(default_factory=tuple)
72
+
73
+ @property
74
+ def runs_affected(self) -> int:
75
+ return len({h.run for h in self.hits})
76
+
77
+ @property
78
+ def occurrences(self) -> int:
79
+ return len(self.hits)
80
+
81
+ @property
82
+ def cost_usd(self) -> float:
83
+ return round(sum(h.cost_usd or 0.0 for h in self.hits), 2)
84
+
85
+ @property
86
+ def wall_min(self) -> float:
87
+ return round(sum(h.wall_min or 0.0 for h in self.hits), 1)
88
+
89
+ @property
90
+ def example(self) -> str:
91
+ return self.hits[0].example if self.hits else ""
92
+
93
+ def stall_score(self) -> float:
94
+ """Rank weight: recurrence dominates, cost/wall break ties.
95
+
96
+ `runs_affected` is the load-bearing term (the point is *recurring*
97
+ blockers), scaled so a 3-run cluster always outranks a 1-run one; cost +
98
+ wall add a within-tier ordering.
99
+ """
100
+ return self.runs_affected * 1000 + self.cost_usd * 10 + self.wall_min
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class RecurringWedgeVerdict:
105
+ """Whether the current run's wedge cause is a recurring structural blocker.
106
+
107
+ `recurring` is the load-bearing field a host's stop-path branches on; the
108
+ rest name the winning cluster so the host can re-attach its taxonomy
109
+ (label/fix/owning-plan) by `cause_key`. PURE given the input hits.
110
+ """
111
+
112
+ recurring: bool
113
+ cause_key: str
114
+ runs_affected: int
115
+ occurrences: int
116
+ cost_usd: float
117
+ wall_min: float
118
+ example: str
119
+ reason: str
120
+
121
+
122
+ def build_clusters(hits: Iterable[BlockerHit]) -> tuple[WedgeCluster, ...]:
123
+ """Group hits by `cause_key`, sorted by stall-score (recurrence-dominant).
124
+
125
+ PURE — no taxonomy lookup, no I/O. A `cause_key` is an opaque grouping key.
126
+ """
127
+ by_key: dict[str, list[BlockerHit]] = {}
128
+ for h in hits:
129
+ by_key.setdefault(h.cause_key, []).append(h)
130
+ clusters = [
131
+ WedgeCluster(cause_key=key, hits=tuple(group))
132
+ for key, group in by_key.items()
133
+ ]
134
+ return tuple(sorted(clusters, key=lambda c: -c.stall_score()))
135
+
136
+
137
+ def classify_recurring_wedge(
138
+ *,
139
+ this_run_id: str,
140
+ this_run_cause_keys: Iterable[str],
141
+ prior_hits: Optional[Iterable[BlockerHit]] = None,
142
+ min_recurrence: int = DEFAULT_MIN_RECURRENCE,
143
+ ) -> RecurringWedgeVerdict:
144
+ """Decide whether the current run's wedge cause is recurring.
145
+
146
+ PURE given `prior_hits` (the `BlockerHit`s the caller mined from the recent
147
+ window's OTHER runs) and `this_run_cause_keys` (the current run's wedge
148
+ `cause_key`s — already classified by the host's taxonomy, one per wedging
149
+ iteration; the current run's README may not be written yet, so its keys are
150
+ passed in directly rather than mined). The most-recurring cause across
151
+ (this run's hits + prior hits) wins (recurrence dominates `stall_score`).
152
+
153
+ A cause is "recurring" when its cluster spans `>= min_recurrence` distinct
154
+ runs (this run counts as one). Only causes the CURRENT run actually hit are
155
+ eligible — a prior-only cluster the current loop never hit is not reported.
156
+ When the current run had no wedge cause at all, returns a benign
157
+ non-recurring verdict with an empty cause.
158
+ """
159
+ this_keys = [k for k in this_run_cause_keys if k and k.strip()]
160
+ if not this_keys:
161
+ return RecurringWedgeVerdict(
162
+ recurring=False, cause_key="", runs_affected=0, occurrences=0,
163
+ cost_usd=0.0, wall_min=0.0, example="",
164
+ reason="this run recorded no wedge cause to classify",
165
+ )
166
+
167
+ # Synthesize this run's hits from its keys so they participate in the fold
168
+ # on the same footing as the mined prior hits (cost/wall unknown here).
169
+ this_hits = [
170
+ BlockerHit(
171
+ run=this_run_id, iter_n=i, cause_key=key,
172
+ cost_usd=None, wall_min=None, example=key, source="this-run",
173
+ )
174
+ for i, key in enumerate(this_keys, start=1)
175
+ ]
176
+ all_hits: list[BlockerHit] = list(this_hits) + list(prior_hits or [])
177
+ clusters = build_clusters(all_hits)
178
+
179
+ # Restrict to causes THIS run actually wedged on.
180
+ hit_keys = {h.cause_key for h in this_hits}
181
+ candidates = [c for c in clusters if c.cause_key in hit_keys]
182
+ if not candidates:
183
+ return RecurringWedgeVerdict(
184
+ recurring=False, cause_key="", runs_affected=0, occurrences=0,
185
+ cost_usd=0.0, wall_min=0.0, example="",
186
+ reason="no cluster matched this run's wedge cause",
187
+ )
188
+
189
+ top = max(candidates, key=lambda c: c.stall_score())
190
+ recurring = top.runs_affected >= min_recurrence
191
+ return RecurringWedgeVerdict(
192
+ recurring=recurring,
193
+ cause_key=top.cause_key,
194
+ runs_affected=top.runs_affected,
195
+ occurrences=top.occurrences,
196
+ cost_usd=top.cost_usd,
197
+ wall_min=top.wall_min,
198
+ example=top.example,
199
+ reason=(
200
+ f"cause '{top.cause_key}' spans {top.runs_affected} run(s) "
201
+ f"(>= {min_recurrence} = recurring)"
202
+ if recurring
203
+ else f"cause '{top.cause_key}' is a one-off "
204
+ f"({top.runs_affected} run < {min_recurrence}) — not routed"
205
+ ),
206
+ )
dos/render.py ADDED
@@ -0,0 +1,393 @@
1
+ """The renderer seam — Axis 4 of hackability: pluggable output (RND, docs/72).
2
+
3
+ Output used to be hardcoded: `print` in `cli.py`, `render_text`/`render_json`
4
+ in `timeline.py`. A workspace that wanted a different shape (a one-line terse
5
+ status bar, a colorized TUI, an HTML fragment, a Slack block) had to fork. This
6
+ module is the seam that lets it *register* one instead.
7
+
8
+ The contract is a `Renderer`: a name plus a set of `render_*(decided_object)
9
+ -> str` methods. The kernel resolves a renderer **by name** at output time
10
+ (`resolve_renderer`), so `--output terse` finds a workspace's renderer without
11
+ the package ever importing it. Two renderers ship built-in and are always
12
+ available — `text` (the human form every command prints today) and `json` (the
13
+ machine form). A workspace *adds* renderers; it can never remove or shadow the
14
+ built-in two (they are the trusted fallback).
15
+
16
+ The one invariant that keeps an open renderer set safe (HACKING.md Axis-4 design
17
+ rule): **a renderer is pure presentation.** It is handed an already-decided
18
+ object (`ShipVerdict`, `LaneDecision`, `Timeline`, a man entry) and returns a
19
+ string. It receives no config, no leases, nothing it could decide *with* —
20
+ rendering is strictly downstream of the kernel, so presentation can never leak
21
+ policy back in. The worst a buggy renderer can do is produce ugly text; it can
22
+ never mis-verify a ship or mis-admit a lease.
23
+
24
+ Byte-faithfulness is the load-bearing property of Phase 1/3: the built-in `text`
25
+ and `json` renderers reproduce each command's *current* default output
26
+ character-for-character, so routing a command through the seam with the default
27
+ renderer changes nothing. The methods below are lifted verbatim from the
28
+ `cli.py` / `timeline.py` print sites they replace; the litmus tests in
29
+ `tests/test_render.py` pin the equality.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import sys
36
+ from typing import Protocol, runtime_checkable
37
+
38
+
39
+ @runtime_checkable
40
+ class Renderer(Protocol):
41
+ """The presentation contract a workspace implements to add an output format.
42
+
43
+ `name` is the token `--output <name>` selects. The `render_*` methods each
44
+ take one already-decided kernel object and return a string. Only
45
+ `render_decision` / `render_verdict` are required by the protocol (the
46
+ Phase-1 surfaces); the later surfaces (`render_timeline` / `render_man` /
47
+ `render_decisions`) are OPTIONAL — a renderer that only cares about verdicts
48
+ inherits the text form for the rest by subclassing `BaseRenderer` (Phase 3).
49
+ """
50
+
51
+ name: str
52
+
53
+ def render_decision(self, decision) -> str: # arbiter LaneDecision
54
+ ...
55
+
56
+ def render_verdict(self, verdict) -> str: # ship ShipVerdict
57
+ ...
58
+
59
+
60
+ class BaseRenderer:
61
+ """Shared base giving every renderer a total set of surfaces.
62
+
63
+ A workspace renderer subclasses this and overrides only the surfaces it
64
+ cares about; the optional surfaces (`render_timeline` / `render_man` /
65
+ `render_decisions`) default to the **text** form, so a partial renderer is
66
+ still total — `--output terse` on a `timeline` falls back to readable text
67
+ rather than crashing (RND Phase 3a). The required surfaces
68
+ (`render_decision` / `render_verdict`) are abstract here: a concrete
69
+ renderer must define them (the built-ins below do, and the example
70
+ `TerseRenderer` does).
71
+
72
+ The fallbacks delegate to the module-level built-in `TEXT` renderer, NOT to
73
+ `self`, so a renderer overriding `render_verdict` does not accidentally
74
+ change how its un-overridden `render_timeline` looks — the fallback is
75
+ always the canonical text form.
76
+ """
77
+
78
+ name: str = "base"
79
+
80
+ def render_decision(self, decision) -> str: # pragma: no cover - abstract
81
+ raise NotImplementedError(
82
+ f"{type(self).__name__} must implement render_decision"
83
+ )
84
+
85
+ def render_verdict(self, verdict) -> str: # pragma: no cover - abstract
86
+ raise NotImplementedError(
87
+ f"{type(self).__name__} must implement render_verdict"
88
+ )
89
+
90
+ # --- optional surfaces (Phase 3): default to the canonical text form ----
91
+ def render_timeline(self, timeline) -> str:
92
+ return TEXT.render_timeline(timeline)
93
+
94
+ def render_man(self, entry) -> str:
95
+ return TEXT.render_man(entry)
96
+
97
+ def render_decisions(self, rows) -> str:
98
+ return TEXT.render_decisions(rows)
99
+
100
+
101
+ class TextRenderer(BaseRenderer):
102
+ """The human form — byte-identical to what each command prints today.
103
+
104
+ Each method reproduces the exact print site it replaces:
105
+ * `render_verdict` ← `cli.cmd_verify`'s non-`--json` branch.
106
+ * `render_decision` ← `cli.cmd_arbitrate`'s `json.dumps(..., sort_keys=True)`
107
+ line. Arbitrate has NO human form today (it always prints compact JSON),
108
+ so its "text" form IS that JSON — this keeps `dos arbitrate` (default
109
+ renderer) byte-identical, exactly the Phase-1/2 contract.
110
+ * `render_timeline` ← `timeline.render_text`.
111
+ * `render_man` ← the line block `cli.cmd_man` prints for one entry.
112
+ * `render_decisions`← `decisions.render_list_plain`.
113
+ """
114
+
115
+ name = "text"
116
+
117
+ def render_verdict(self, verdict) -> str:
118
+ mark = "SHIPPED" if verdict.shipped else "NOT_SHIPPED"
119
+ sha = f" {verdict.sha}" if verdict.sha else ""
120
+ src = f" (via {verdict.source})" if verdict.source else ""
121
+ return f"{mark} {verdict.plan} {verdict.phase}{sha}{src}"
122
+
123
+ def render_decision(self, decision) -> str:
124
+ # Arbitrate's current default is compact sorted JSON. The `--pretty`
125
+ # flag (indent=2) is handled at the call site, not here, because the
126
+ # renderer contract is `(object) -> str` with no formatting args; the
127
+ # CLI passes a pre-pretty-printed string straight through when --pretty
128
+ # is set and --output is the default (see cli.cmd_arbitrate).
129
+ return json.dumps(decision.to_dict(), sort_keys=True)
130
+
131
+ def render_timeline(self, timeline) -> str:
132
+ from dos import timeline as _timeline
133
+ return _timeline.render_text(timeline)
134
+
135
+ def render_man(self, entry) -> str:
136
+ # `entry` is a ManEntry (below) — the already-assembled lines a man page
137
+ # prints. Joining is the whole render: the kernel decided the content,
138
+ # the renderer only lays it out.
139
+ return "\n".join(entry.lines)
140
+
141
+ def render_decisions(self, rows) -> str:
142
+ from dos import decisions as _decisions
143
+ return _decisions.render_list_plain(rows)
144
+
145
+
146
+ class JsonRenderer(BaseRenderer):
147
+ """The machine form — byte-identical to each command's `--json` branch.
148
+
149
+ `render_verdict` ← `cli.cmd_verify`'s `--json` branch
150
+ (`json.dumps(to_dict(), sort_keys=True)`); `render_decision` ← arbitrate's
151
+ compact JSON; `render_timeline` ← `timeline.render_json`. For surfaces with
152
+ no native JSON form today (`man`), it emits a structured object so `--output
153
+ json` is always meaningful.
154
+ """
155
+
156
+ name = "json"
157
+
158
+ def render_verdict(self, verdict) -> str:
159
+ return json.dumps(verdict.to_dict(), sort_keys=True)
160
+
161
+ def render_decision(self, decision) -> str:
162
+ return json.dumps(decision.to_dict(), sort_keys=True)
163
+
164
+ def render_timeline(self, timeline) -> str:
165
+ from dos import timeline as _timeline
166
+ return _timeline.render_json(timeline)
167
+
168
+ def render_man(self, entry) -> str:
169
+ return json.dumps(entry.to_dict(), sort_keys=True, default=str)
170
+
171
+ def render_decisions(self, rows) -> str:
172
+ # indent=2 to match `cli.cmd_decisions`'s legacy `--json` branch
173
+ # byte-for-byte, so `--output json` and `--json` coincide for decisions.
174
+ return json.dumps([d.to_dict() for d in rows], indent=2, default=str)
175
+
176
+
177
+ class PlainRenderer(BaseRenderer):
178
+ """A plain-language verdict for a *non-coder* end-user (RND, the adoption floor).
179
+
180
+ The built-in `text` renderer answers a developer ("NOT_SHIPPED P 1 (via none)");
181
+ this answers the person who asked an agent to build something and needs one
182
+ sentence: *did I actually get it?* It is the always-available default behind the
183
+ non-coder authoring story — a dev team shipping a product to non-coders gets a
184
+ legible verdict with `--output plain` and **zero plugin**, then overrides it with
185
+ their own `dos.renderers` renderer when they want their product's exact wording
186
+ (the `examples/dos_ext` `friendly` renderer is that copy-me override).
187
+
188
+ It encodes the three disciplines that separate a trustworthy non-coder surface
189
+ from a confident-lie machine — and, like every renderer, it is pure presentation
190
+ over an already-decided verdict, so it can only phrase the kernel's verdict, never
191
+ change it:
192
+
193
+ 1. **Contrast, never the bare accusation.** A bare `NOT_SHIPPED (via none)`
194
+ reads as an accusation or a broken tool; this states the result and attaches
195
+ a *way forward*, so "no" is a next step.
196
+ 2. **Presence, never correctness.** `verify` answers "is the thing you asked for
197
+ actually IN what was built?" — a presence fact from git, NOT "is it correct /
198
+ safe" (the file-path rung is presence, not goal). So a "yes" here says *it's
199
+ in there* and pointedly does NOT say *it works*. Over-claiming correctness is
200
+ exactly the failure a non-coder surface exists to prevent.
201
+ 3. **Hedge the weak rung.** When the verdict was reached only because a commit
202
+ *subject* mentioned the phase (`source == "grep-subject"`), the deliverable
203
+ may not really be built — a known sharp edge of the grep floor. This lowers
204
+ its confidence and says so rather than passing a soft yes off as a hard one.
205
+
206
+ Decisions (the `arbitrate` surface) render as a plain "started / waiting /
207
+ started-elsewhere, nothing overwritten" so a non-coder reads a collision as a safe
208
+ wait, not an error.
209
+ """
210
+
211
+ name = "plain"
212
+
213
+ def render_verdict(self, verdict) -> str:
214
+ thing = self._thing(verdict)
215
+ if verdict.shipped:
216
+ if verdict.source == "grep-subject":
217
+ return (
218
+ f"Probably yes: {thing} looks like it was added, but the only "
219
+ f"sign is a note in the project history, not the built result "
220
+ f"itself. Worth opening it to confirm it's really there. "
221
+ f"(This checks that it's present, not that it works.)"
222
+ )
223
+ return (
224
+ f"Yes: {thing} is in what was built. (This checks that it's present "
225
+ f"— not that it's correct or safe; that still needs a review.)"
226
+ )
227
+ return (
228
+ f"Not yet: {thing} isn't in what was built. The agent may have said it "
229
+ f"was done, but it isn't in the project yet. Ask it to actually add "
230
+ f"{thing}, then check again."
231
+ )
232
+
233
+ def render_decision(self, decision) -> str:
234
+ if decision.outcome == "acquire":
235
+ if decision.auto_picked:
236
+ return (
237
+ f"Started — working on a free area ('{decision.lane}'), since "
238
+ f"the one first requested was busy. Nothing was overwritten."
239
+ )
240
+ return f"Started — working on '{decision.lane}'."
241
+ first_line = decision.reason.splitlines()[0] if decision.reason else ""
242
+ tail = f" ({first_line})" if first_line else ""
243
+ return (
244
+ f"Waiting — another helper is already changing this part, so this one "
245
+ f"is holding off to avoid clobbering it.{tail}"
246
+ )
247
+
248
+ @staticmethod
249
+ def _thing(verdict) -> str:
250
+ """The user-facing name of the thing checked. A host product passes a human
251
+ title via its own renderer; the built-in uses the phase name (then plan),
252
+ quoted so it reads as a referent, not jargon."""
253
+ name = verdict.phase or verdict.plan or "the change"
254
+ return f"'{name}'"
255
+
256
+
257
+ class ManEntry:
258
+ """A rendered-content envelope for `dos man` (RND Phase 3b).
259
+
260
+ `cmd_man` used to `print(...)` its lines inline. To bring it under the
261
+ renderer seam without changing a byte of default output, the command now
262
+ assembles its lines into a `ManEntry` (the *decided* content) and hands it
263
+ to a renderer. `text` joins the lines (the old output verbatim); `json`
264
+ emits the structured `fields`. This is the same content/presentation split
265
+ the verdict/decision surfaces already have — the kernel decides what a man
266
+ page says, the renderer decides how it looks.
267
+ """
268
+
269
+ __slots__ = ("lines", "fields")
270
+
271
+ def __init__(self, lines: list[str], fields: dict | None = None) -> None:
272
+ self.lines = list(lines)
273
+ self.fields = dict(fields or {})
274
+
275
+ def to_dict(self) -> dict:
276
+ return dict(self.fields)
277
+
278
+
279
+ # The always-available built-ins. A workspace cannot remove or shadow these
280
+ # (resolve_renderer resolves built-in names FIRST), so they are the trusted
281
+ # fallback every command can always reach. `text`/`json` are the developer/machine
282
+ # forms; `plain` is the non-coder end-user form (the adoption floor — a legible
283
+ # verdict with zero plugin, overridable by a workspace `dos.renderers` renderer).
284
+ TEXT = TextRenderer()
285
+ JSON = JsonRenderer()
286
+ PLAIN = PlainRenderer()
287
+ BUILTIN_RENDERERS: dict[str, Renderer] = {"text": TEXT, "json": JSON, "plain": PLAIN}
288
+
289
+ # The entry-point group a workspace registers a renderer under (Phase 2).
290
+ RENDERER_ENTRY_POINT_GROUP = "dos.renderers"
291
+
292
+
293
+ class UnknownRenderer(Exception):
294
+ """`--output <name>` named a renderer that resolves to nothing.
295
+
296
+ Carries the known-renderer list so the CLI can fail loud with an actionable
297
+ message (the completeness posture: an unknown name never silently falls back
298
+ to text — that would hide a typo'd `--output`). Subclasses `Exception` (not
299
+ `KeyError`) so `str(e)` is the clean message, not the `KeyError`-repr'd form
300
+ with surrounding quotes.
301
+ """
302
+
303
+ def __init__(self, name: str, known: list[str]) -> None:
304
+ self.name = name
305
+ self.known = list(known)
306
+ super().__init__(
307
+ f"unknown renderer {name!r}; known: {', '.join(self.known)}"
308
+ )
309
+
310
+
311
+ def _discover_entry_point_renderers(*, _stderr=None) -> dict[str, Renderer]:
312
+ """Find workspace renderers registered under the `dos.renderers` group.
313
+
314
+ A renderer plugin registers `name = "pkg.module:RendererClass"` in its
315
+ `[project.entry-points."dos.renderers"]`. We load each, instantiate it, and
316
+ key it by its declared entry-point name. A plugin whose name collides with a
317
+ built-in (`text`/`json`) is IGNORED with a one-line stderr note — a plugin
318
+ must not be able to silently capture `json` and change what every machine
319
+ consumer parses. A plugin that fails to load (bad import, constructor
320
+ raises) is skipped with a note rather than crashing every `dos` command
321
+ (a broken third-party plugin is the operator's to fix, not a kernel fault).
322
+ """
323
+ stderr = _stderr if _stderr is not None else sys.stderr
324
+ out: dict[str, Renderer] = {}
325
+ try:
326
+ from importlib.metadata import entry_points
327
+ except Exception: # pragma: no cover - importlib.metadata always present py3.11+
328
+ return out
329
+ try:
330
+ eps = entry_points(group=RENDERER_ENTRY_POINT_GROUP)
331
+ except TypeError: # pragma: no cover - py<3.10 selectable-API fallback
332
+ eps = entry_points().get(RENDERER_ENTRY_POINT_GROUP, []) # type: ignore[attr-defined]
333
+ except Exception: # pragma: no cover - defensive: never let discovery crash output
334
+ return out
335
+ for ep in eps:
336
+ if ep.name in BUILTIN_RENDERERS:
337
+ print(
338
+ f"warning: renderer plugin {ep.name!r} collides with a built-in "
339
+ f"renderer and is ignored (built-ins cannot be shadowed)",
340
+ file=stderr,
341
+ )
342
+ continue
343
+ try:
344
+ cls = ep.load()
345
+ renderer = cls() if isinstance(cls, type) else cls
346
+ except Exception as e: # pragma: no cover - depends on third-party plugin
347
+ print(
348
+ f"warning: renderer plugin {ep.name!r} failed to load ({e}); "
349
+ f"skipping",
350
+ file=stderr,
351
+ )
352
+ continue
353
+ out[ep.name] = renderer
354
+ return out
355
+
356
+
357
+ def _names_from(discovered: dict[str, Renderer]) -> list[str]:
358
+ """Built-ins first, then discovered plugin names (built-in collisions already
359
+ filtered out by discovery) — the stable order the `--output bogus` error and
360
+ `dos doctor` both want."""
361
+ return list(BUILTIN_RENDERERS) + [n for n in sorted(discovered)
362
+ if n not in BUILTIN_RENDERERS]
363
+
364
+
365
+ def known_renderers(*, _stderr=None) -> list[str]:
366
+ """Every renderer name resolvable right now (built-ins + discovered), sorted
367
+ with the built-ins first so the `--output bogus` error lists `text, json`
368
+ ahead of any plugin."""
369
+ return _names_from(_discover_entry_point_renderers(_stderr=_stderr))
370
+
371
+
372
+ def resolve_renderer(name: str, *, _stderr=None) -> Renderer:
373
+ """Return the renderer registered as ``name`` — built-ins first, then plugins.
374
+
375
+ Resolution order (Phase 2): the built-in `text`/`json` map is consulted
376
+ FIRST, so a workspace can never shadow the trusted fallback; only on a
377
+ built-in miss do we consult the `dos.renderers` entry points. An unresolved
378
+ name raises `UnknownRenderer` with the known list — it never silently falls
379
+ back to text (a typo'd `--output` must be loud, the completeness posture).
380
+
381
+ Discovery runs at most ONCE per call: a built-in miss discovers the plugins,
382
+ and the same `discovered` dict feeds the `UnknownRenderer` known-list — so a
383
+ colliding plugin's stderr note is emitted once, never duplicated by a second
384
+ discovery pass for the error message.
385
+ """
386
+ builtin = BUILTIN_RENDERERS.get(name)
387
+ if builtin is not None:
388
+ return builtin
389
+ discovered = _discover_entry_point_renderers(_stderr=_stderr)
390
+ plugin = discovered.get(name)
391
+ if plugin is not None:
392
+ return plugin
393
+ raise UnknownRenderer(name, _names_from(discovered))