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/events.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""The trajectory event schema. Frozen.
|
|
2
|
+
|
|
3
|
+
This module is the contract between the running pipeline and anything that
|
|
4
|
+
watches it. It imports nothing from Textual and nothing from the TUI, so a
|
|
5
|
+
future consumer — a log renderer, the eval harness, a test — can depend on it
|
|
6
|
+
without dragging a terminal framework in.
|
|
7
|
+
|
|
8
|
+
Two rules keep it stable:
|
|
9
|
+
|
|
10
|
+
* **Events are added, never changed.** A stage that learns to emit a new kind of
|
|
11
|
+
finding adds a new event class. Existing classes keep their fields and their
|
|
12
|
+
meanings, so a renderer written today keeps working.
|
|
13
|
+
* **Renderers dispatch on type through a registry, never a chain of isinstance
|
|
14
|
+
branches.** An event a renderer has never seen must degrade to a plain line
|
|
15
|
+
rather than raising; `describe()` below guarantees every event has one.
|
|
16
|
+
|
|
17
|
+
The event stream is a *view* of the run. It is not how the run works, and
|
|
18
|
+
nothing in `holt.agent` reads it. Deleting this package would leave the engine,
|
|
19
|
+
the CLI and the eval harness untouched.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from typing import Any, Union
|
|
27
|
+
|
|
28
|
+
# ─── the stage vocabulary ───────────────────────────────────────────────────
|
|
29
|
+
#
|
|
30
|
+
# Mirrors the labels the pipeline passes to `ModelClient.complete`, plus the two
|
|
31
|
+
# steps that run no model. Kept as data rather than an enum so that a stage
|
|
32
|
+
# added to the engine shows up as itself instead of crashing the renderer.
|
|
33
|
+
|
|
34
|
+
STAGE_TITLES: dict[str, str] = {
|
|
35
|
+
"classify": "classify",
|
|
36
|
+
"opportunity": "opportunity",
|
|
37
|
+
"outcomes": "outcomes",
|
|
38
|
+
"verify": "verify",
|
|
39
|
+
"verdict": "verdict",
|
|
40
|
+
"narrate": "narrate",
|
|
41
|
+
"pathfinder": "reading order",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#: Display order and gutter marks. Stages not listed here render after these,
|
|
45
|
+
#: in arrival order, with a blank mark — new stages appear rather than vanish.
|
|
46
|
+
STAGE_ORDER: tuple[tuple[str, str], ...] = (
|
|
47
|
+
("classify", "A"),
|
|
48
|
+
("opportunity", "B"),
|
|
49
|
+
("outcomes", "C"),
|
|
50
|
+
("verify", "D"),
|
|
51
|
+
("verdict", " "),
|
|
52
|
+
("narrate", "E"),
|
|
53
|
+
("pathfinder", " "),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
#: Stages that may legitimately not run — the reading order is skipped when a
|
|
57
|
+
#: repository has no issue fixture. Their rows are mounted when they start
|
|
58
|
+
#: rather than sitting at "—" after the run has finished, which would read as a
|
|
59
|
+
#: stage that stalled rather than one that was never asked for.
|
|
60
|
+
OPTIONAL_STAGES: frozenset[str] = frozenset({"pathfinder"})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def stage_title(stage: str) -> str:
|
|
64
|
+
return STAGE_TITLES.get(stage, stage)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def stage_mark(stage: str) -> str:
|
|
68
|
+
for name, mark in STAGE_ORDER:
|
|
69
|
+
if name == stage:
|
|
70
|
+
return mark
|
|
71
|
+
return " "
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ─── events ─────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class RunStarted:
|
|
79
|
+
repo: str
|
|
80
|
+
replayed: bool
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class EvidenceLoaded:
|
|
85
|
+
"""The provider handed over its records. The size of what the agent may see.
|
|
86
|
+
|
|
87
|
+
`cutoff` is the boundary the provider *actually* applied, not the benchmark
|
|
88
|
+
constant. A live provider defaults it to today; only a fixture run or an
|
|
89
|
+
explicit `--as-of` is cut at T. A watcher that assumed T would report a
|
|
90
|
+
holdout the run never applied, so the run states its own.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
count: int
|
|
94
|
+
window: str
|
|
95
|
+
#: Defaulted rather than required: events are added to, never changed, and
|
|
96
|
+
#: a caller written before this field existed still constructs a valid one.
|
|
97
|
+
cutoff: datetime | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class StageStarted:
|
|
102
|
+
stage: str
|
|
103
|
+
model: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class StageFinished:
|
|
108
|
+
stage: str
|
|
109
|
+
seconds: float
|
|
110
|
+
summary: str = ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class ToolResponse:
|
|
115
|
+
"""A structured model response, as it came back, before a stage interprets it."""
|
|
116
|
+
|
|
117
|
+
stage: str
|
|
118
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class FindingEmitted:
|
|
123
|
+
"""A claim a stage is making, with the ids it says support it.
|
|
124
|
+
|
|
125
|
+
Emitted *before* Stage D runs, so the live view can show a claim appear and
|
|
126
|
+
then be taken away again. `evidence_ids` here is what the model cited, not
|
|
127
|
+
what resolved.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
stage: str
|
|
131
|
+
field: str
|
|
132
|
+
value: Any
|
|
133
|
+
evidence_ids: tuple[str, ...] = ()
|
|
134
|
+
note: str = ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class EvidenceResolved:
|
|
139
|
+
"""One lookup in Stage D. `resolved` false is the interesting case."""
|
|
140
|
+
|
|
141
|
+
evidence_id: str
|
|
142
|
+
resolved: bool
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True, slots=True)
|
|
146
|
+
class FindingDropped:
|
|
147
|
+
"""A claim was removed. The moment the whole pipeline exists to make.
|
|
148
|
+
|
|
149
|
+
Two reasons, and the difference matters to a reader watching: the cited
|
|
150
|
+
thread does not exist, or it exists and does not contain the quoted words.
|
|
151
|
+
Defaulted so an older producer of this event still describes itself.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
field: str
|
|
155
|
+
value: Any
|
|
156
|
+
cited: tuple[str, ...] = ()
|
|
157
|
+
reason: str = "unresolved"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True, slots=True)
|
|
161
|
+
class UsageUpdated:
|
|
162
|
+
"""Running total after a model call. Zero throughout a replay.
|
|
163
|
+
|
|
164
|
+
Added after the first version of this schema shipped, which is the case the
|
|
165
|
+
module was arranged for: a new class, no change to any existing one, and
|
|
166
|
+
screens that predate it keep working because they simply have no entry for
|
|
167
|
+
it in their dispatch table.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
input_tokens: int
|
|
171
|
+
output_tokens: int
|
|
172
|
+
cost_usd: float
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True, slots=True)
|
|
176
|
+
class Retry:
|
|
177
|
+
stage: str
|
|
178
|
+
attempt: int
|
|
179
|
+
reason: str = ""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True, slots=True)
|
|
183
|
+
class RunFailed:
|
|
184
|
+
error: str
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class RunCancelled:
|
|
189
|
+
"""The run was stopped on purpose, and is not a failure.
|
|
190
|
+
|
|
191
|
+
Kept distinct from `RunFailed` because the two need opposite treatment: a
|
|
192
|
+
failure is something to report and investigate, a stop is something the
|
|
193
|
+
reader asked for. Merging them would have the interface apologise for
|
|
194
|
+
obeying an instruction.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
#: Stages that had already finished when the stop landed. A live run that
|
|
198
|
+
#: is stopped at stage D has spent real money, and saying so is the
|
|
199
|
+
#: difference between a stop and a loss.
|
|
200
|
+
completed_stages: tuple[str, ...] = ()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True, slots=True)
|
|
204
|
+
class RunFinished:
|
|
205
|
+
"""Carries the finished `Assessment` and the pipeline's own `Trace`.
|
|
206
|
+
|
|
207
|
+
Typed as `Any` so this module stays importable without pulling in the rest
|
|
208
|
+
of the package; the attribute is always a `holt.report.Assessment`.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
assessment: Any
|
|
212
|
+
trace: Any
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
Event = Union[
|
|
216
|
+
RunStarted,
|
|
217
|
+
EvidenceLoaded,
|
|
218
|
+
StageStarted,
|
|
219
|
+
StageFinished,
|
|
220
|
+
ToolResponse,
|
|
221
|
+
FindingEmitted,
|
|
222
|
+
EvidenceResolved,
|
|
223
|
+
FindingDropped,
|
|
224
|
+
UsageUpdated,
|
|
225
|
+
Retry,
|
|
226
|
+
RunFailed,
|
|
227
|
+
RunCancelled,
|
|
228
|
+
RunFinished,
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def describe(event: object) -> str:
|
|
233
|
+
"""A one-line fallback for any event, including ones added after this file.
|
|
234
|
+
|
|
235
|
+
A renderer that has no specific handling for an event still has something
|
|
236
|
+
truthful to print. Nothing in the UI may raise on an unrecognised event.
|
|
237
|
+
"""
|
|
238
|
+
name = type(event).__name__
|
|
239
|
+
fields = getattr(event, "__slots__", None) or ()
|
|
240
|
+
parts = []
|
|
241
|
+
for key in fields:
|
|
242
|
+
value = getattr(event, key, None)
|
|
243
|
+
text = str(value)
|
|
244
|
+
parts.append(f"{key}={text[:60]}")
|
|
245
|
+
return f"{name} " + " ".join(parts) if parts else name
|
holt/tui/mascot.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""The cat, its moods, and the colour it wears today.
|
|
2
|
+
|
|
3
|
+
One line, one kaomoji, no block art — so the mascot costs zero vertical space
|
|
4
|
+
and can sit in a masthead or a status line without a layout of its own.
|
|
5
|
+
|
|
6
|
+
**The face is not decoration, because it reports state.** Each mood below is
|
|
7
|
+
tied to something the engine actually decided: a run in progress, a verdict, a
|
|
8
|
+
claim Stage D removed. That is the whole justification for it being here — a
|
|
9
|
+
mascot that only looked nice would be the one element in an interface built on
|
|
10
|
+
"colour and shape carry meaning" that carried none.
|
|
11
|
+
|
|
12
|
+
Animation works the same way. The cat blinks slowly at rest and looks about
|
|
13
|
+
while a run is going, so motion means *something is happening* exactly as the
|
|
14
|
+
stage spinner does. Nothing loops to be lively.
|
|
15
|
+
|
|
16
|
+
The accent is chosen fresh each launch. That is deliberate and it is the only
|
|
17
|
+
colour in the app allowed to be arbitrary: it identifies a *session*, not a
|
|
18
|
+
state, so it is drawn from cool hues that cannot be mistaken for the verdict
|
|
19
|
+
green, the clay of a negative verdict, the red of a dropped claim, or the teal
|
|
20
|
+
of a resolvable evidence id. Those five keep their meanings whatever the cat is
|
|
21
|
+
wearing today.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import random
|
|
28
|
+
|
|
29
|
+
# ─── moods ──────────────────────────────────────────────────────────────────
|
|
30
|
+
#
|
|
31
|
+
# Frames are held by repetition: a frame listed eight times dwells eight ticks.
|
|
32
|
+
# Reading the tuple tells you the rhythm without a second table of durations.
|
|
33
|
+
|
|
34
|
+
TICK = 0.12
|
|
35
|
+
|
|
36
|
+
IDLE = ("(=^・ω・^=)",) * 9 + ("(=^-ω-^=)",)
|
|
37
|
+
WORKING = (
|
|
38
|
+
("(=^・ω・^=)",) * 2
|
|
39
|
+
+ ("(=^○ω○^=)",) * 2
|
|
40
|
+
+ ("(=^・ω・^=)",) * 2
|
|
41
|
+
+ ("(=^-ω-^=)",)
|
|
42
|
+
)
|
|
43
|
+
WORTH = ("(=^▽^=)",) * 11 + ("(=^-^=)",)
|
|
44
|
+
NOT_WORTH = ("(=^ ̄ω ̄^=)",) * 14 + ("(=^-ω-^=)",)
|
|
45
|
+
NOT_ENOUGH = ("(=^¬_¬^=)",) * 13 + ("(=^-_-^=)",)
|
|
46
|
+
DROPPED = ("(=^○ω○^=)",) * 6 + ("(=^・ω・^=)",) * 2 + ("(=^○ω○^=)",) * 4
|
|
47
|
+
|
|
48
|
+
#: Mood name → frames. Anything not listed falls back to `idle`, so a state the
|
|
49
|
+
#: engine grows later shows a calm cat rather than raising.
|
|
50
|
+
MOODS: dict[str, tuple[str, ...]] = {
|
|
51
|
+
"idle": IDLE,
|
|
52
|
+
"working": WORKING,
|
|
53
|
+
"worth_time": WORTH,
|
|
54
|
+
"not_worth_time": NOT_WORTH,
|
|
55
|
+
"not_enough_evidence": NOT_ENOUGH,
|
|
56
|
+
"claim_dropped": DROPPED,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#: Verdict value → mood. Read through `.get`, never matched exhaustively:
|
|
60
|
+
#: `holt.report.Verdict` owns the enum and may grow a fourth member.
|
|
61
|
+
VERDICT_MOODS: dict[str, str] = {
|
|
62
|
+
"viable": "worth_time",
|
|
63
|
+
"not_viable": "not_worth_time",
|
|
64
|
+
"insufficient_evidence": "not_enough_evidence",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def frames(mood: str) -> tuple[str, ...]:
|
|
69
|
+
return MOODS.get(mood, IDLE)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def still(mood: str) -> str:
|
|
73
|
+
"""The resting frame, for when motion is off or the cat is not animated."""
|
|
74
|
+
return frames(mood)[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def mood_for_verdict(value: str) -> str:
|
|
78
|
+
return VERDICT_MOODS.get(value, "idle")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ─── the accent of the day ──────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
#: Cool hues only. Deliberately excludes green, clay, red and teal, which are
|
|
84
|
+
#: the four colours that mean something elsewhere in the interface.
|
|
85
|
+
ACCENTS: tuple[str, ...] = (
|
|
86
|
+
"#7e87c4", # periwinkle
|
|
87
|
+
"#9a7fc4", # violet
|
|
88
|
+
"#b07cb8", # plum
|
|
89
|
+
"#6f8fd0", # steel blue
|
|
90
|
+
"#8f7fd0", # lilac
|
|
91
|
+
"#7fa0c9", # dusty blue
|
|
92
|
+
"#a87fd0", # amethyst
|
|
93
|
+
"#8ca0d8", # cornflower
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pick_accent() -> str:
|
|
98
|
+
"""A different colour each launch, unless one is pinned.
|
|
99
|
+
|
|
100
|
+
`HOLT_TUI_ACCENT` fixes it — for a screenshot that should not change under
|
|
101
|
+
you, and for the tests, which should not assert against a coin flip.
|
|
102
|
+
"""
|
|
103
|
+
pinned = os.environ.get("HOLT_TUI_ACCENT", "").strip()
|
|
104
|
+
if pinned:
|
|
105
|
+
return pinned
|
|
106
|
+
return random.choice(ACCENTS)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ─── the words beside it ────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
#: From `holt.cli`'s own docstring. The tool's description of itself, not a
|
|
112
|
+
#: second one written for the interface.
|
|
113
|
+
TAGLINE = "is this repository worth your time?"
|
|
114
|
+
|
|
115
|
+
#: The three things worth knowing before you type anything. Facts about how it
|
|
116
|
+
#: behaves, not a feature list.
|
|
117
|
+
FACTS: tuple[str, ...] = (
|
|
118
|
+
"read-only — never writes to GitHub, never opens a pull request",
|
|
119
|
+
"every claim carries an evidence id you can open and check",
|
|
120
|
+
"a claim whose evidence does not resolve is dropped, not softened",
|
|
121
|
+
)
|