holt-cli 0.1.0__py3-none-any.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.
- holt/__init__.py +0 -0
- holt/agent/__init__.py +0 -0
- holt/agent/entry.py +86 -0
- holt/agent/findings.py +49 -0
- holt/agent/landing.py +154 -0
- holt/agent/pipeline.py +244 -0
- holt/agent/progression.py +408 -0
- holt/agent/signals.py +220 -0
- holt/agent/stages.py +533 -0
- holt/agent/verdict.py +226 -0
- holt/agent/verify.py +140 -0
- holt/baseline.py +89 -0
- holt/baseline_matched.py +116 -0
- holt/cli.py +616 -0
- holt/discover.py +497 -0
- holt/evidence/__init__.py +3 -0
- holt/evidence/fixtures.py +154 -0
- holt/evidence/github_graphql.py +538 -0
- holt/evidence/provider.py +77 -0
- holt/evidence/redact.py +79 -0
- holt/issues.py +41 -0
- holt/model.py +516 -0
- holt/profile.py +126 -0
- holt/report.py +157 -0
- holt/tui/__init__.py +0 -0
- holt/tui/animation.py +84 -0
- holt/tui/app.py +294 -0
- holt/tui/clipboard.py +89 -0
- holt/tui/commands.py +134 -0
- holt/tui/discovery.py +305 -0
- holt/tui/env.py +49 -0
- holt/tui/events.py +245 -0
- holt/tui/mascot.py +121 -0
- holt/tui/models.py +590 -0
- holt/tui/observe.py +297 -0
- holt/tui/screens/__init__.py +35 -0
- holt/tui/screens/assessment.py +337 -0
- holt/tui/screens/confirm.py +62 -0
- holt/tui/screens/discover.py +444 -0
- holt/tui/screens/home.py +519 -0
- holt/tui/screens/inspector.py +106 -0
- holt/tui/screens/live.py +335 -0
- holt/tui/screens/models.py +393 -0
- holt/tui/screens/next_steps.py +425 -0
- holt/tui/screens/profile.py +129 -0
- holt/tui/session.py +711 -0
- holt/tui/store.py +458 -0
- holt/tui/theme.py +479 -0
- holt/tui/visual.py +33 -0
- holt/tui/widgets/__init__.py +0 -0
- holt/tui/widgets/candidates.py +78 -0
- holt/tui/widgets/claims.py +59 -0
- holt/tui/widgets/disclosure.py +121 -0
- holt/tui/widgets/evidence.py +121 -0
- holt/tui/widgets/masthead.py +122 -0
- holt/tui/widgets/recent.py +167 -0
- holt/tui/widgets/scrolling.py +38 -0
- holt/tui/widgets/stages.py +232 -0
- holt/types.py +48 -0
- holt_cli-0.1.0.dist-info/METADATA +198 -0
- holt_cli-0.1.0.dist-info/RECORD +65 -0
- holt_cli-0.1.0.dist-info/WHEEL +4 -0
- holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
- holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
holt/tui/observe.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Watching a run without changing it.
|
|
2
|
+
|
|
3
|
+
`pipeline.analyze` takes a provider and a model client and knows nothing about
|
|
4
|
+
being observed. Rather than thread a callback through the engine — which would
|
|
5
|
+
put a UI concern in the reproduction path and give the eval harness a parameter
|
|
6
|
+
it has no use for — these two classes *wrap* the objects the pipeline is handed.
|
|
7
|
+
|
|
8
|
+
Both are pure delegates. They forward every attribute to the object they wrap,
|
|
9
|
+
add no behaviour to it, and never mutate what passes through. Removing them from
|
|
10
|
+
the call gives the byte-identical run the CLI performs, which is what keeps
|
|
11
|
+
`holt tui` from becoming a second way of running a stage.
|
|
12
|
+
|
|
13
|
+
The one place this file mirrors the engine is `_findings_from`, which reads a
|
|
14
|
+
stage's structured response so the live view can show a claim appear *before*
|
|
15
|
+
Stage D decides whether it survives. That mirroring is deliberate and pinned by
|
|
16
|
+
`tests/test_tui_observe.py`, which replays real recorded trajectories and fails
|
|
17
|
+
if a key this file expects has stopped being produced. A silent UI is worse than
|
|
18
|
+
a loud test.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from holt.agent.stages import normalise_citation
|
|
28
|
+
from holt.tui import events
|
|
29
|
+
|
|
30
|
+
Emit = Callable[[events.Event], None]
|
|
31
|
+
|
|
32
|
+
#: Asked before every model call and every evidence read. Returns True once the
|
|
33
|
+
#: reader has stopped the run.
|
|
34
|
+
Cancelled = Callable[[], bool]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RunCancelled(Exception):
|
|
38
|
+
"""Raised out of a wrapper when the reader has stopped the run.
|
|
39
|
+
|
|
40
|
+
A Python thread cannot be killed, so stopping is cooperative: the run is
|
|
41
|
+
interrupted at the next point it asks for something. These wrappers already
|
|
42
|
+
sit at every one of those points — a stage cannot call a model or read
|
|
43
|
+
evidence without passing through them — so the engine needs no cancellation
|
|
44
|
+
parameter and `holt.agent` stays unaware that an interface exists.
|
|
45
|
+
|
|
46
|
+
The cost of that choice is latency, not correctness: a stop lands when the
|
|
47
|
+
call in flight returns, which is instant on a replay and up to a model
|
|
48
|
+
timeout on a live run. The interface says `stopping` until then rather than
|
|
49
|
+
claiming the run has already ended.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ObservingModel:
|
|
54
|
+
"""A `ModelClient` that narrates its own calls.
|
|
55
|
+
|
|
56
|
+
Wraps rather than subclasses, so it works over `OpenAIModel`, `ReplayModel`,
|
|
57
|
+
or anything else satisfying the protocol, without knowing which it has.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
inner: Any,
|
|
63
|
+
emit: Emit,
|
|
64
|
+
repo: str = "",
|
|
65
|
+
cancelled: Cancelled | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._inner = inner
|
|
68
|
+
self._emit = emit
|
|
69
|
+
self._repo = repo
|
|
70
|
+
self._cancelled = cancelled or (lambda: False)
|
|
71
|
+
|
|
72
|
+
def __getattr__(self, name: str) -> Any:
|
|
73
|
+
# `replayed`, `usage`, and anything the client grows later.
|
|
74
|
+
return getattr(self._inner, name)
|
|
75
|
+
|
|
76
|
+
def complete(self, *, label: str, system: str, prompt: str, schema: dict) -> dict:
|
|
77
|
+
# Checked *before* the call and before `StageStarted`, so a stopped run
|
|
78
|
+
# neither pays for a stage nor shows one beginning. Raising inside the
|
|
79
|
+
# try below would additionally emit `RunFailed`, reporting a deliberate
|
|
80
|
+
# stop as a defect.
|
|
81
|
+
if self._cancelled():
|
|
82
|
+
raise RunCancelled(label)
|
|
83
|
+
model_name = _model_name(self._inner, label)
|
|
84
|
+
self._emit(events.StageStarted(stage=label, model=model_name))
|
|
85
|
+
started = time.monotonic()
|
|
86
|
+
try:
|
|
87
|
+
result = self._inner.complete(
|
|
88
|
+
label=label, system=system, prompt=prompt, schema=schema
|
|
89
|
+
)
|
|
90
|
+
except Exception as exc: # noqa: BLE001 - reported, then re-raised unchanged
|
|
91
|
+
self._emit(events.RunFailed(error=f"{label}: {type(exc).__name__}: {exc}"))
|
|
92
|
+
raise
|
|
93
|
+
elapsed = time.monotonic() - started
|
|
94
|
+
|
|
95
|
+
self._emit(events.ToolResponse(stage=label, payload=result))
|
|
96
|
+
usage = getattr(self._inner, "usage", None)
|
|
97
|
+
if usage is not None:
|
|
98
|
+
self._emit(
|
|
99
|
+
events.UsageUpdated(
|
|
100
|
+
input_tokens=usage.input_tokens,
|
|
101
|
+
output_tokens=usage.output_tokens,
|
|
102
|
+
cost_usd=usage.cost_usd,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
for finding in _findings_from(label, result, self._repo):
|
|
106
|
+
self._emit(finding)
|
|
107
|
+
self._emit(
|
|
108
|
+
events.StageFinished(
|
|
109
|
+
stage=label, seconds=elapsed, summary=_summarise(label, result)
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ObservingProvider:
|
|
116
|
+
"""An `EvidenceProvider` that reports what was read and what resolved.
|
|
117
|
+
|
|
118
|
+
Stage D's drop is not a special hook: it is `resolve()` returning `None`.
|
|
119
|
+
Watching resolution is therefore enough to see the pipeline's central
|
|
120
|
+
moment, with nothing added to the engine to expose it.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, inner: Any, emit: Emit, cancelled: Cancelled | None = None) -> None:
|
|
124
|
+
self._inner = inner
|
|
125
|
+
self._emit = emit
|
|
126
|
+
self._cancelled = cancelled or (lambda: False)
|
|
127
|
+
|
|
128
|
+
def __getattr__(self, name: str) -> Any:
|
|
129
|
+
return getattr(self._inner, name)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def inner(self) -> Any:
|
|
133
|
+
"""The provider being watched, for a read that must not be narrated.
|
|
134
|
+
|
|
135
|
+
One caller: `Session.evidence_for_storage`, which looks the report's own
|
|
136
|
+
ids up again after the run has finished so the records can be stored
|
|
137
|
+
with it. Those reads are not part of the run, and going through
|
|
138
|
+
`resolve` below would append them to the trace — a stage D that appears
|
|
139
|
+
to have resolved the same ids twice.
|
|
140
|
+
"""
|
|
141
|
+
return self._inner
|
|
142
|
+
|
|
143
|
+
def fetch(self, request: str, /, **params: object) -> list:
|
|
144
|
+
if self._cancelled():
|
|
145
|
+
raise RunCancelled(f"fetch {request}")
|
|
146
|
+
records = self._inner.fetch(request, **params)
|
|
147
|
+
window = getattr(getattr(self._inner, "window", None), "value", "")
|
|
148
|
+
self._emit(
|
|
149
|
+
events.EvidenceLoaded(
|
|
150
|
+
count=len(records),
|
|
151
|
+
window=window,
|
|
152
|
+
cutoff=getattr(self._inner, "cutoff", None),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return records
|
|
156
|
+
|
|
157
|
+
def resolve(self, evidence_id: str):
|
|
158
|
+
if self._cancelled():
|
|
159
|
+
raise RunCancelled(f"resolve {evidence_id}")
|
|
160
|
+
record = self._inner.resolve(evidence_id)
|
|
161
|
+
self._emit(
|
|
162
|
+
events.EvidenceResolved(evidence_id=evidence_id, resolved=record is not None)
|
|
163
|
+
)
|
|
164
|
+
return record
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ─── reading a stage response ───────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _model_name(inner: Any, label: str) -> str:
|
|
171
|
+
try:
|
|
172
|
+
from holt.model import model_for
|
|
173
|
+
|
|
174
|
+
return model_for(label)
|
|
175
|
+
except Exception: # noqa: BLE001 - a display string is never worth raising over
|
|
176
|
+
return ""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _summarise(label: str, result: dict) -> str:
|
|
180
|
+
"""The one-line status that sits on the stage's own row."""
|
|
181
|
+
if label == "classify":
|
|
182
|
+
return str(result.get("repo_kind", ""))
|
|
183
|
+
if label == "opportunity":
|
|
184
|
+
return str(result.get("onboarding", ""))
|
|
185
|
+
if label == "outcomes":
|
|
186
|
+
threads = result.get("threads", []) or []
|
|
187
|
+
posture = result.get("posture", "")
|
|
188
|
+
return f"{posture} · {len(threads)} threads read" if posture else ""
|
|
189
|
+
if label == "narrate":
|
|
190
|
+
# `narrate` returns three sections now, not one `summary`. The count
|
|
191
|
+
# that matters to a watcher is how much prose came back.
|
|
192
|
+
written = sum(
|
|
193
|
+
len(result.get(key, ""))
|
|
194
|
+
for key in (
|
|
195
|
+
"bottom_line",
|
|
196
|
+
"what_the_evidence_shows",
|
|
197
|
+
"what_could_not_be_determined",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return f"{written} characters"
|
|
201
|
+
if label == "pathfinder":
|
|
202
|
+
issues = result.get("ranked", result.get("issues", [])) or []
|
|
203
|
+
return f"{len(issues)} issues ranked"
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _findings_from(label: str, result: dict, repo: str) -> list[events.FindingEmitted]:
|
|
208
|
+
"""The claims a stage is about to record, read off its structured response.
|
|
209
|
+
|
|
210
|
+
Mirrors `holt.agent.stages`. Every lookup is tolerant: a missing key yields
|
|
211
|
+
fewer events, never an exception, because a run must not fail because
|
|
212
|
+
something was watching it.
|
|
213
|
+
"""
|
|
214
|
+
out: list[events.FindingEmitted] = []
|
|
215
|
+
|
|
216
|
+
def cite(raw) -> tuple[str, ...]:
|
|
217
|
+
try:
|
|
218
|
+
return tuple(normalise_citation(repo, e) for e in (raw or ()))
|
|
219
|
+
except Exception: # noqa: BLE001
|
|
220
|
+
return tuple(str(e) for e in (raw or ()))
|
|
221
|
+
|
|
222
|
+
if label == "classify":
|
|
223
|
+
if "repo_kind" in result:
|
|
224
|
+
out.append(
|
|
225
|
+
events.FindingEmitted(
|
|
226
|
+
stage=label,
|
|
227
|
+
field="repo_kind",
|
|
228
|
+
value=result["repo_kind"],
|
|
229
|
+
evidence_ids=cite(result.get("evidence_ids")),
|
|
230
|
+
note=result.get("rationale", ""),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
flags = [f for f in result.get("governance_flags", []) or [] if f != "none"]
|
|
234
|
+
if flags:
|
|
235
|
+
out.append(
|
|
236
|
+
events.FindingEmitted(
|
|
237
|
+
stage=label,
|
|
238
|
+
field="governance_flags",
|
|
239
|
+
value=flags,
|
|
240
|
+
evidence_ids=cite(result.get("evidence_ids")),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
elif label == "opportunity":
|
|
245
|
+
if "onboarding" in result:
|
|
246
|
+
out.append(
|
|
247
|
+
events.FindingEmitted(
|
|
248
|
+
stage=label,
|
|
249
|
+
field="onboarding",
|
|
250
|
+
value=result["onboarding"],
|
|
251
|
+
evidence_ids=cite(result.get("evidence_ids")),
|
|
252
|
+
note=result.get("rationale", ""),
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
elif label == "outcomes":
|
|
257
|
+
threads = result.get("threads", []) or []
|
|
258
|
+
if "posture" in result:
|
|
259
|
+
out.append(
|
|
260
|
+
events.FindingEmitted(
|
|
261
|
+
stage=label,
|
|
262
|
+
field="outsider_posture",
|
|
263
|
+
value=result["posture"],
|
|
264
|
+
evidence_ids=cite([t.get("pr_id") for t in threads]),
|
|
265
|
+
note=result.get("posture_rationale", ""),
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
for entry in threads:
|
|
269
|
+
out.append(
|
|
270
|
+
events.FindingEmitted(
|
|
271
|
+
stage=label,
|
|
272
|
+
field="thread_outcome",
|
|
273
|
+
value={
|
|
274
|
+
"outcome": entry.get("outcome", ""),
|
|
275
|
+
"signal": entry.get("signal", ""),
|
|
276
|
+
"quote": entry.get("quote", ""),
|
|
277
|
+
},
|
|
278
|
+
evidence_ids=cite([entry.get("pr_id")]),
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
#: The response keys the live view reads, by stage. `tests/test_tui_observe.py`
|
|
286
|
+
#: replays recorded trajectories and asserts each one still appears, so a change
|
|
287
|
+
#: to a stage's schema breaks a test rather than emptying a panel.
|
|
288
|
+
EXPECTED_KEYS: dict[str, tuple[str, ...]] = {
|
|
289
|
+
"classify": ("repo_kind", "rationale", "evidence_ids"),
|
|
290
|
+
"opportunity": ("onboarding", "rationale", "evidence_ids"),
|
|
291
|
+
"outcomes": ("posture", "posture_rationale", "threads"),
|
|
292
|
+
"narrate": (
|
|
293
|
+
"bottom_line",
|
|
294
|
+
"what_the_evidence_shows",
|
|
295
|
+
"what_could_not_be_determined",
|
|
296
|
+
),
|
|
297
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Screen registry.
|
|
2
|
+
|
|
3
|
+
Adding a screen is a new file in this directory and one line in `REGISTRY`.
|
|
4
|
+
`app.py` reads this dict and never learns the names of individual screens, so
|
|
5
|
+
there is no switch statement to grow and no place where adding a view means
|
|
6
|
+
editing a view that already works.
|
|
7
|
+
|
|
8
|
+
Screens that need constructor arguments — the inspector takes an evidence id —
|
|
9
|
+
are pushed as instances instead, so they are deliberately not registered here.
|
|
10
|
+
Only screens that can be opened by name belong in this dict.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from textual.screen import Screen
|
|
16
|
+
|
|
17
|
+
from holt.tui.screens.discover import DiscoverScreen
|
|
18
|
+
from holt.tui.screens.home import HomeScreen
|
|
19
|
+
from holt.tui.screens.models import ModelsScreen
|
|
20
|
+
from holt.tui.screens.profile import ProfileScreen
|
|
21
|
+
|
|
22
|
+
REGISTRY: dict[str, type[Screen]] = {
|
|
23
|
+
"home": HomeScreen,
|
|
24
|
+
"discover": DiscoverScreen,
|
|
25
|
+
"profile": ProfileScreen,
|
|
26
|
+
"models": ModelsScreen,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"REGISTRY",
|
|
31
|
+
"DiscoverScreen",
|
|
32
|
+
"HomeScreen",
|
|
33
|
+
"ModelsScreen",
|
|
34
|
+
"ProfileScreen",
|
|
35
|
+
]
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""The finished report, navigable.
|
|
2
|
+
|
|
3
|
+
The order is the engine's own: the answer, then the one-paragraph reason, then
|
|
4
|
+
what the evidence showed, then which rule decided it, then what could not be
|
|
5
|
+
determined, then where work actually landed, then the claims. That order is not
|
|
6
|
+
a layout preference — `Assessment.render()` uses it, and a reader who has seen
|
|
7
|
+
the markdown should find the same argument in the same sequence here.
|
|
8
|
+
|
|
9
|
+
Two things this screen refuses to do:
|
|
10
|
+
|
|
11
|
+
* **Bury what could not be determined.** It is a section with a heading, in the
|
|
12
|
+
same type as everything else, in the position the engine puts it. A tool that
|
|
13
|
+
hides its own limits into a footnote is doing something different from one
|
|
14
|
+
that states them.
|
|
15
|
+
* **Let a stored assessment pass as a fresh one.** If this came out of the
|
|
16
|
+
store, the age is in the chrome and re-running is one key.
|
|
17
|
+
|
|
18
|
+
Every field beyond `repo`, `verdict`, `summary` and `claims` is read through
|
|
19
|
+
`getattr` with a default. Those four are the contract; the rest the engine has
|
|
20
|
+
added over time, and a screen that hard-required them would break the next time
|
|
21
|
+
one moved.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import textwrap
|
|
27
|
+
|
|
28
|
+
from rich.text import Text
|
|
29
|
+
from textual.app import ComposeResult
|
|
30
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
31
|
+
from textual.screen import Screen
|
|
32
|
+
from textual.widgets import Footer
|
|
33
|
+
|
|
34
|
+
from holt.report import VERDICT_HEADLINES
|
|
35
|
+
from holt.tui import animation, clipboard, mascot, store, theme
|
|
36
|
+
from holt.tui.visual import Line
|
|
37
|
+
from holt.tui.widgets.claims import ClaimList
|
|
38
|
+
from holt.tui.widgets.disclosure import EntryPointRow, MeasuredResult
|
|
39
|
+
from holt.tui.widgets.masthead import Cat
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AssessmentScreen(Screen):
|
|
43
|
+
BINDINGS = [
|
|
44
|
+
("escape", "home", "home"),
|
|
45
|
+
("enter", "inspect", "open evidence"),
|
|
46
|
+
("c", "copy", "copy as markdown"),
|
|
47
|
+
("ctrl+r", "rerun", "re-run"),
|
|
48
|
+
("n", "next", "what next"),
|
|
49
|
+
("t", "trace", "trace"),
|
|
50
|
+
("q", "quit", "quit"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
def compose(self) -> ComposeResult:
|
|
54
|
+
session = self.app.session
|
|
55
|
+
assessment = session.assessment
|
|
56
|
+
verdict = assessment.verdict
|
|
57
|
+
|
|
58
|
+
with Horizontal(id="chrome"):
|
|
59
|
+
yield Cat(
|
|
60
|
+
mascot.mood_for_verdict(verdict.value), id="cat", classes="chrome-cat"
|
|
61
|
+
)
|
|
62
|
+
yield Line("", id="chrome-left")
|
|
63
|
+
yield Line(self._provenance(), id="chrome-right")
|
|
64
|
+
yield Line("─" * 240, classes="rule")
|
|
65
|
+
|
|
66
|
+
with VerticalScroll(id="report"):
|
|
67
|
+
# The answer, in the engine's own words, at the size of a headline.
|
|
68
|
+
yield Line(
|
|
69
|
+
Text(_headline(verdict), style=theme.verdict_colour(verdict.value)),
|
|
70
|
+
id="verdict",
|
|
71
|
+
)
|
|
72
|
+
days = getattr(assessment, "contributor_days", 7)
|
|
73
|
+
yield Line(
|
|
74
|
+
Text(
|
|
75
|
+
f"for a contributor with {days} day{'' if days == 1 else 's'}",
|
|
76
|
+
style=theme.FAINT,
|
|
77
|
+
),
|
|
78
|
+
id="verdict-budget",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
bottom_line = getattr(assessment, "bottom_line", "")
|
|
82
|
+
if bottom_line:
|
|
83
|
+
yield Line(bottom_line, id="bottom-line")
|
|
84
|
+
|
|
85
|
+
if assessment.summary:
|
|
86
|
+
yield from _section("WHAT THE EVIDENCE SHOWS", assessment.summary)
|
|
87
|
+
|
|
88
|
+
rules = getattr(assessment, "rules", None)
|
|
89
|
+
if rules:
|
|
90
|
+
# The deterministic half, shown rather than described. This is
|
|
91
|
+
# what `verdict.py` decided; the prose above could not have
|
|
92
|
+
# changed it, and printing both is how you can tell.
|
|
93
|
+
yield Line(
|
|
94
|
+
Text("WHAT DECIDED IT", style=theme.DIM), classes="section-label"
|
|
95
|
+
)
|
|
96
|
+
for rule in rules:
|
|
97
|
+
yield Line(Text(_bullet(rule)), classes="rule-line")
|
|
98
|
+
|
|
99
|
+
limits = getattr(assessment, "limits", "")
|
|
100
|
+
if limits:
|
|
101
|
+
yield from _section("WHAT COULD NOT BE DETERMINED", limits)
|
|
102
|
+
|
|
103
|
+
landing = getattr(assessment, "landing", None)
|
|
104
|
+
if landing:
|
|
105
|
+
yield Line(
|
|
106
|
+
Text("WHERE WORK LANDED", style=theme.DIM), classes="section-label"
|
|
107
|
+
)
|
|
108
|
+
for line in landing:
|
|
109
|
+
# The engine emits a markdown heading because its primary
|
|
110
|
+
# output is a file. Here the section already has a label,
|
|
111
|
+
# and printing both says the same thing twice.
|
|
112
|
+
if line.strip() and not line.lstrip().startswith("#"):
|
|
113
|
+
yield Line(_plain(line), classes="landing-line")
|
|
114
|
+
|
|
115
|
+
count = len(assessment.claims)
|
|
116
|
+
binned = getattr(assessment, "dropped_claims", 0)
|
|
117
|
+
if count:
|
|
118
|
+
header = Text(
|
|
119
|
+
f"EVIDENCE {count} {'claim' if count == 1 else 'claims'}, "
|
|
120
|
+
"every one carrying an id that resolved and words the "
|
|
121
|
+
"thread actually said",
|
|
122
|
+
style=theme.DIM,
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
# "0 claims, every one carrying an id that resolved" is a
|
|
126
|
+
# sentence about nothing. What a reader needs here is that the
|
|
127
|
+
# prose above them is standing on its own.
|
|
128
|
+
header = Text(
|
|
129
|
+
f"EVIDENCE none survived verification"
|
|
130
|
+
+ (f" — all {binned} dropped" if binned else ""),
|
|
131
|
+
style=theme.DROP if binned else theme.DIM,
|
|
132
|
+
)
|
|
133
|
+
yield Line(header, classes="section-label")
|
|
134
|
+
if count:
|
|
135
|
+
# How to reach the list without a mouse, next to the list. ↑↓
|
|
136
|
+
# here scroll the report, which is what they should do while you
|
|
137
|
+
# are reading it; tab is what moves into the claims, and saying
|
|
138
|
+
# so is the difference between a list you can open and one you
|
|
139
|
+
# cannot. Omitted when nothing survived: there is nothing to
|
|
140
|
+
# step into, and the line above already says why.
|
|
141
|
+
yield Line(
|
|
142
|
+
Text(
|
|
143
|
+
"tab to step into them ↑↓ pick one "
|
|
144
|
+
"enter opens its record",
|
|
145
|
+
style=theme.FAINT,
|
|
146
|
+
),
|
|
147
|
+
classes="field-note",
|
|
148
|
+
)
|
|
149
|
+
yield ClaimList(assessment.claims, repo=assessment.repo, id="claims")
|
|
150
|
+
|
|
151
|
+
# Only rendered when the engine supplied it. The ranking is opt-in
|
|
152
|
+
# now, and nothing about this screen assumes the field exists.
|
|
153
|
+
if getattr(assessment, "entry_points", None):
|
|
154
|
+
yield Line(
|
|
155
|
+
Text("WHERE TO START", style=theme.DIM), classes="section-label"
|
|
156
|
+
)
|
|
157
|
+
yield MeasuredResult()
|
|
158
|
+
for i, point in enumerate(assessment.entry_points, 1):
|
|
159
|
+
yield EntryPointRow(i, point)
|
|
160
|
+
|
|
161
|
+
yield Line(
|
|
162
|
+
Text(f"method {assessment.method}", style=theme.FAINT),
|
|
163
|
+
classes="section-label",
|
|
164
|
+
)
|
|
165
|
+
# The model-written sections are only as good as the model behind
|
|
166
|
+
# them, while the counts and the verdict are not model-derived at
|
|
167
|
+
# all. Naming the model is what lets a reader — or a screenshot —
|
|
168
|
+
# tell those two halves apart afterwards.
|
|
169
|
+
models = getattr(assessment, "models", None)
|
|
170
|
+
if models:
|
|
171
|
+
yield Line(
|
|
172
|
+
Text(f"model {', '.join(models)}", style=theme.FAINT),
|
|
173
|
+
classes="section-label",
|
|
174
|
+
)
|
|
175
|
+
yield Line("─" * 240, classes="rule")
|
|
176
|
+
# Hidden until a key has something to report. It sits below the rule
|
|
177
|
+
# rather than in the chrome because what it has to say — which
|
|
178
|
+
# clipboard mechanism ran, and whether that mechanism can be trusted —
|
|
179
|
+
# is a sentence, and the chrome is a column shared with the provenance.
|
|
180
|
+
yield Line("", id="report-notice", classes="field-note")
|
|
181
|
+
yield Footer()
|
|
182
|
+
|
|
183
|
+
def on_mount(self) -> None:
|
|
184
|
+
self.query_one("#report-notice", Line).display = False
|
|
185
|
+
for index, child in enumerate(self.query_one("#report", VerticalScroll).children):
|
|
186
|
+
animation.reveal(child, delay=animation.stagger(index))
|
|
187
|
+
|
|
188
|
+
def _provenance(self) -> Text:
|
|
189
|
+
"""Where this result came from, and how old it is. Never omitted."""
|
|
190
|
+
session = self.app.session
|
|
191
|
+
text = Text()
|
|
192
|
+
text.append(f"{session.assessment.repo} ", style=theme.FAINT)
|
|
193
|
+
stored = session.restored_from
|
|
194
|
+
if stored is not None:
|
|
195
|
+
text.append(
|
|
196
|
+
f"{store.describe_age(stored.age_seconds)} · {stored.mode}",
|
|
197
|
+
style=theme.DIM,
|
|
198
|
+
)
|
|
199
|
+
text.append(" ctrl+r to re-run", style=theme.FAINT)
|
|
200
|
+
else:
|
|
201
|
+
text.append(session.options.mode, style=theme.FAINT)
|
|
202
|
+
if session.cost_usd:
|
|
203
|
+
text.append(f" ${session.cost_usd:.4f}", style=theme.FAINT)
|
|
204
|
+
return text
|
|
205
|
+
|
|
206
|
+
def _notice(self, message: str, tone: str = "") -> None:
|
|
207
|
+
"""One line under the report, for what a key just did."""
|
|
208
|
+
widget = self.query_one("#report-notice", Line)
|
|
209
|
+
widget.update(Text(message, style=tone or theme.FAINT))
|
|
210
|
+
widget.display = bool(message)
|
|
211
|
+
animation.reveal(widget)
|
|
212
|
+
|
|
213
|
+
# ─── actions ────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
def on_list_view_selected(self, event) -> None:
|
|
216
|
+
"""Enter, when the claims themselves have focus.
|
|
217
|
+
|
|
218
|
+
The screen's own `enter` binding only fires while focus is elsewhere.
|
|
219
|
+
Once you tab into the list, `ListView` takes the key and posts this
|
|
220
|
+
instead — without a handler that is a list you can move around in and
|
|
221
|
+
never open.
|
|
222
|
+
"""
|
|
223
|
+
claim = getattr(event.item, "claim", None)
|
|
224
|
+
if claim is not None and claim.evidence_id:
|
|
225
|
+
self.app.inspect(claim.evidence_id)
|
|
226
|
+
|
|
227
|
+
def action_inspect(self) -> None:
|
|
228
|
+
claim = self.query_one("#claims", ClaimList).selected
|
|
229
|
+
if claim is None or not claim.evidence_id:
|
|
230
|
+
return
|
|
231
|
+
self.app.inspect(claim.evidence_id)
|
|
232
|
+
|
|
233
|
+
def action_copy(self) -> None:
|
|
234
|
+
"""The report as markdown, on the clipboard.
|
|
235
|
+
|
|
236
|
+
`Assessment.render()` — the same text `holt assess` writes to a file, so
|
|
237
|
+
what you paste into an issue is the artefact and not a transcription of
|
|
238
|
+
the screen. What actually reached the clipboard is reported, because
|
|
239
|
+
copying out of a terminal can silently fail.
|
|
240
|
+
"""
|
|
241
|
+
markdown = self.app.session.assessment.render()
|
|
242
|
+
# Counted before the f-string: a backslash inside one is a syntax error
|
|
243
|
+
# on 3.11, which this project still supports.
|
|
244
|
+
lines = len(markdown.splitlines())
|
|
245
|
+
detail = clipboard.copy(self.app, markdown)
|
|
246
|
+
self._notice(f"{detail} {lines} lines.")
|
|
247
|
+
|
|
248
|
+
def action_rerun(self) -> None:
|
|
249
|
+
from holt.tui.session import RunOptions
|
|
250
|
+
|
|
251
|
+
session = self.app.session
|
|
252
|
+
self.app.start_run(
|
|
253
|
+
RunOptions(
|
|
254
|
+
repo=session.assessment.repo,
|
|
255
|
+
replay=session.options.replay,
|
|
256
|
+
live=session.options.live,
|
|
257
|
+
contributor_days=session.options.contributor_days,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def action_next(self) -> None:
|
|
262
|
+
"""Where someone who has already landed work here might look next."""
|
|
263
|
+
from holt.tui.screens.next_steps import NextScreen
|
|
264
|
+
|
|
265
|
+
# The mode travels with it, so the ranking reads the evidence the
|
|
266
|
+
# report in front of you was actually built from.
|
|
267
|
+
session = self.app.session
|
|
268
|
+
self.app.push_screen(
|
|
269
|
+
NextScreen(session.assessment.repo, live=session.options.live)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def action_trace(self) -> None:
|
|
273
|
+
"""The run behind this report: what it read, emitted and dropped.
|
|
274
|
+
|
|
275
|
+
Three cases, and the middle one is the reason this is not a one-liner.
|
|
276
|
+
The run may still be underneath this screen, in which case the trace is
|
|
277
|
+
the screen you came from and going back to it is the whole action. An
|
|
278
|
+
assessment reopened out of the store has no screen underneath — but it
|
|
279
|
+
does carry the run's own event stream, which is stored with it, so the
|
|
280
|
+
trace is rebuilt from that rather than being silently unavailable. An
|
|
281
|
+
assessment stored before traces were kept has neither, and says so
|
|
282
|
+
instead of answering a keypress with nothing at all.
|
|
283
|
+
"""
|
|
284
|
+
from holt.tui.screens.live import LiveScreen, TraceScreen
|
|
285
|
+
|
|
286
|
+
stack = self.app.screen_stack
|
|
287
|
+
below = stack[-2] if len(stack) > 2 else None
|
|
288
|
+
if isinstance(below, LiveScreen):
|
|
289
|
+
self.app.pop_screen()
|
|
290
|
+
return
|
|
291
|
+
if self.app.session.log:
|
|
292
|
+
self.app.push_screen(TraceScreen())
|
|
293
|
+
return
|
|
294
|
+
self._notice(
|
|
295
|
+
"No trace was stored with this assessment — it was produced before "
|
|
296
|
+
"holt kept them. Re-run it (ctrl+r) to watch one."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def action_home(self) -> None:
|
|
300
|
+
self.app.go_home()
|
|
301
|
+
|
|
302
|
+
def action_quit(self) -> None:
|
|
303
|
+
self.app.exit()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _headline(verdict) -> str:
|
|
307
|
+
try:
|
|
308
|
+
return VERDICT_HEADLINES[verdict]
|
|
309
|
+
except (KeyError, TypeError):
|
|
310
|
+
return str(getattr(verdict, "value", verdict)).replace("_", " ")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _section(label: str, body: str):
|
|
314
|
+
yield Line(Text(label, style=theme.DIM), classes="section-label")
|
|
315
|
+
yield Line(body, classes="prose")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _bullet(text: str, width: int = 88) -> str:
|
|
319
|
+
"""A bullet whose continuation lines sit under the text, not the mark.
|
|
320
|
+
|
|
321
|
+
A wrapped rule that starts again in the mark column reads as two rules.
|
|
322
|
+
"""
|
|
323
|
+
return textwrap.fill(
|
|
324
|
+
" ".join(text.split()),
|
|
325
|
+
width=width,
|
|
326
|
+
initial_indent="· ",
|
|
327
|
+
subsequent_indent=" ",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _plain(line: str) -> Text:
|
|
332
|
+
"""Markdown from `holt.agent.landing`, shown as text.
|
|
333
|
+
|
|
334
|
+
The engine emits markdown because its primary output is a file. Rendering
|
|
335
|
+
the asterisks here would be showing the reader the engine's plumbing.
|
|
336
|
+
"""
|
|
337
|
+
return Text(line.replace("**", "").replace("`", ""))
|