dos-kernel 0.22.0__py3-none-win_arm64.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/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))
|