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.
Files changed (65) hide show
  1. holt/__init__.py +0 -0
  2. holt/agent/__init__.py +0 -0
  3. holt/agent/entry.py +86 -0
  4. holt/agent/findings.py +49 -0
  5. holt/agent/landing.py +154 -0
  6. holt/agent/pipeline.py +244 -0
  7. holt/agent/progression.py +408 -0
  8. holt/agent/signals.py +220 -0
  9. holt/agent/stages.py +533 -0
  10. holt/agent/verdict.py +226 -0
  11. holt/agent/verify.py +140 -0
  12. holt/baseline.py +89 -0
  13. holt/baseline_matched.py +116 -0
  14. holt/cli.py +616 -0
  15. holt/discover.py +497 -0
  16. holt/evidence/__init__.py +3 -0
  17. holt/evidence/fixtures.py +154 -0
  18. holt/evidence/github_graphql.py +538 -0
  19. holt/evidence/provider.py +77 -0
  20. holt/evidence/redact.py +79 -0
  21. holt/issues.py +41 -0
  22. holt/model.py +516 -0
  23. holt/profile.py +126 -0
  24. holt/report.py +157 -0
  25. holt/tui/__init__.py +0 -0
  26. holt/tui/animation.py +84 -0
  27. holt/tui/app.py +294 -0
  28. holt/tui/clipboard.py +89 -0
  29. holt/tui/commands.py +134 -0
  30. holt/tui/discovery.py +305 -0
  31. holt/tui/env.py +49 -0
  32. holt/tui/events.py +245 -0
  33. holt/tui/mascot.py +121 -0
  34. holt/tui/models.py +590 -0
  35. holt/tui/observe.py +297 -0
  36. holt/tui/screens/__init__.py +35 -0
  37. holt/tui/screens/assessment.py +337 -0
  38. holt/tui/screens/confirm.py +62 -0
  39. holt/tui/screens/discover.py +444 -0
  40. holt/tui/screens/home.py +519 -0
  41. holt/tui/screens/inspector.py +106 -0
  42. holt/tui/screens/live.py +335 -0
  43. holt/tui/screens/models.py +393 -0
  44. holt/tui/screens/next_steps.py +425 -0
  45. holt/tui/screens/profile.py +129 -0
  46. holt/tui/session.py +711 -0
  47. holt/tui/store.py +458 -0
  48. holt/tui/theme.py +479 -0
  49. holt/tui/visual.py +33 -0
  50. holt/tui/widgets/__init__.py +0 -0
  51. holt/tui/widgets/candidates.py +78 -0
  52. holt/tui/widgets/claims.py +59 -0
  53. holt/tui/widgets/disclosure.py +121 -0
  54. holt/tui/widgets/evidence.py +121 -0
  55. holt/tui/widgets/masthead.py +122 -0
  56. holt/tui/widgets/recent.py +167 -0
  57. holt/tui/widgets/scrolling.py +38 -0
  58. holt/tui/widgets/stages.py +232 -0
  59. holt/types.py +48 -0
  60. holt_cli-0.1.0.dist-info/METADATA +198 -0
  61. holt_cli-0.1.0.dist-info/RECORD +65 -0
  62. holt_cli-0.1.0.dist-info/WHEEL +4 -0
  63. holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
  64. holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  65. 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
+ )