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
@@ -0,0 +1,62 @@
1
+ """A yes/no question, over whatever you were looking at.
2
+
3
+ Used for the two decisions that destroy work: stopping a run, and quitting with
4
+ runs still in flight. Both are cheap to ask about and expensive to get wrong —
5
+ a live run stopped at stage D has already been paid for.
6
+
7
+ Modal rather than a screen you navigate to, because the question is about the
8
+ thing underneath it: dismissing has to put you back exactly where you were.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from rich.text import Text
14
+ from textual.app import ComposeResult
15
+ from textual.containers import Vertical
16
+ from textual.screen import ModalScreen
17
+
18
+ from holt.tui import theme
19
+ from holt.tui.visual import Line
20
+
21
+
22
+ class ConfirmScreen(ModalScreen[bool]):
23
+ """Returns True if the reader confirmed. Escape and `n` both mean no.
24
+
25
+ `n` is bound as well as escape because the question is phrased as a
26
+ question: someone reading "quit anyway? y/n" reaches for `n`, and a key
27
+ that the prompt names has to work.
28
+ """
29
+
30
+ BINDINGS = [
31
+ ("y", "confirm", "yes"),
32
+ ("n", "cancel", "no"),
33
+ ("escape", "cancel", "no"),
34
+ ]
35
+
36
+ def __init__(
37
+ self,
38
+ question: str,
39
+ detail: str = "",
40
+ yes: str = "y stop",
41
+ no: str = "n keep running",
42
+ ) -> None:
43
+ super().__init__()
44
+ self.question = question
45
+ self.detail = detail
46
+ self.yes = yes
47
+ self.no = no
48
+
49
+ def compose(self) -> ComposeResult:
50
+ with Vertical(id="confirm-box"):
51
+ yield Line(Text(self.question, style=theme.DIM), id="confirm-question")
52
+ if self.detail:
53
+ yield Line(Text(self.detail, style=theme.FAINT), id="confirm-detail")
54
+ yield Line(
55
+ Text(f"{self.yes} {self.no}", style=theme.FAINT), id="confirm-keys"
56
+ )
57
+
58
+ def action_confirm(self) -> None:
59
+ self.dismiss(True)
60
+
61
+ def action_cancel(self) -> None:
62
+ self.dismiss(False)
@@ -0,0 +1,444 @@
1
+ """Finding a repository when you do not have one in mind.
2
+
3
+ The rest of the interface assumes you can name what you want assessed. Often you
4
+ cannot — that is the actual starting position — and this is the screen for it.
5
+
6
+ **It opens on a choice, not on a list.** It used to open on a recorded search
7
+ that shipped in the repository: twenty-five repositories somebody else's profile
8
+ turned up, on a date in the past, presented exactly as a result would be. As a
9
+ demo of the screening rule that is honest; as the first thing `ctrl+f` does it
10
+ is not, because those repositories are not an answer to *your* question and no
11
+ amount of labelling makes them one. So the recording is still here, still free,
12
+ and now something you ask for. What the screen offers first is a live search.
13
+
14
+ Live search is free in the sense that matters: `screen_records` runs no model,
15
+ so a sweep costs GitHub API quota and about a minute of waiting. Rows are drawn
16
+ as they land rather than after the last one, because a minute of blank screen
17
+ reads as a hang. The model cost is where it always was — pressing enter on a row
18
+ starts an ordinary assessment run.
19
+
20
+ Two things it will not do:
21
+
22
+ * **Hide what it rejected.** Nine of twenty-five candidates being cut for free
23
+ is the interesting result, not an implementation detail. They stay listed.
24
+ * **Reorder survivors.** Screening says a candidate is worth a closer look; it
25
+ does not say one is better than another, and sorting them would assert that it
26
+ did. They keep the order the search returned them in.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+
33
+ from rich.text import Text
34
+ from textual.app import ComposeResult
35
+ from textual.containers import Horizontal, Vertical, VerticalScroll
36
+ from textual.css.query import NoMatches
37
+ from textual.screen import Screen
38
+ from textual.widgets import Footer, ListItem, ListView
39
+
40
+ from holt import profile as profile_mod
41
+ from holt.profile import Profile
42
+ from holt.tui import animation, discovery, session as session_module, theme
43
+ from holt.tui.visual import Line
44
+ from holt.tui.widgets.candidates import CandidateList
45
+
46
+ #: How often the screen picks up rows the search thread has finished. The sweep
47
+ #: is network-bound and lands a row every few seconds, so this is about not
48
+ #: looking frozen rather than about frame rate.
49
+ POLL_SECONDS = 0.1
50
+
51
+ START_HINT = "enter choose ctrl+o change what you are looking for esc back"
52
+ SEARCH_HINT = "enter assess this one ctrl+x stop searching esc back"
53
+ DONE_HINT = "enter assess this one ctrl+o change what you are looking for esc back"
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class Choice:
58
+ """One way out of the start screen."""
59
+
60
+ action: str
61
+ title: str
62
+ detail: str
63
+
64
+
65
+ class ChoiceItem(ListItem):
66
+ def __init__(self, choice: Choice, **kwargs) -> None:
67
+ super().__init__(**kwargs)
68
+ self.choice = choice
69
+
70
+ def compose(self):
71
+ yield Line(Text(self.choice.title))
72
+ yield Line(Text(f" {self.choice.detail}", style=theme.FAINT))
73
+
74
+
75
+ class ChoiceList(ListView):
76
+ def __init__(self, choices: list[Choice], **kwargs) -> None:
77
+ super().__init__(*[ChoiceItem(c) for c in choices], **kwargs)
78
+ self.choices = choices
79
+
80
+
81
+ class DiscoverScreen(Screen):
82
+ BINDINGS = [
83
+ ("ctrl+o", "profile", "change what you want"),
84
+ ("ctrl+x", "stop", "stop searching"),
85
+ ("escape", "home", "home"),
86
+ ("q", "quit", "quit"),
87
+ ]
88
+
89
+ def __init__(self, session: str | None = None, **kwargs) -> None:
90
+ super().__init__(**kwargs)
91
+ # Not `self.name`: Textual widgets already own that, and assigning to it
92
+ # raises inside the screen's constructor, where the failure surfaces as
93
+ # a screen that simply never appears.
94
+ #
95
+ # `None` means "start on the choice". A name means the caller asked for
96
+ # that recording specifically, which is how the demo is still reachable.
97
+ self.session_name = session
98
+ self.session: discovery.Session | None = None
99
+ self.search: discovery.Search | None = None
100
+ self.error = ""
101
+ #: How many of the search's rows are already on screen.
102
+ self._cursor = 0
103
+ self._timer = None
104
+
105
+ # ─── layout ─────────────────────────────────────────────────────────────
106
+
107
+ def compose(self) -> ComposeResult:
108
+ if self.session_name is not None:
109
+ self._load_recording(self.session_name)
110
+
111
+ with Horizontal(id="chrome"):
112
+ yield Line("holt · discover", id="chrome-left")
113
+ yield Line(self._provenance(), id="chrome-right")
114
+ yield Line("─" * 240, classes="rule")
115
+
116
+ with Vertical(id="discover-body"):
117
+ if self.error:
118
+ yield Line(Text(self.error, style=theme.DROP), classes="empty")
119
+ else:
120
+ # Both panes are built once and shown one at a time. Tearing a
121
+ # pane down and building another in its place mid-search is a
122
+ # race against the poll timer that has nothing to gain.
123
+ with Vertical(id="discover-start"):
124
+ yield Line("", id="start-looking", classes="section-label")
125
+ yield Line(
126
+ Text(START_HINT, style=theme.FAINT), id="start-hint"
127
+ )
128
+ yield ChoiceList(self._choices(), id="choices")
129
+ with Vertical(id="discover-results"):
130
+ yield Line("", id="results-looking", classes="section-label")
131
+ yield Line("", id="results-counts", classes="section-label")
132
+ yield Line("", id="discover-hint")
133
+ with VerticalScroll(id="candidate-scroll"):
134
+ yield CandidateList(id="candidates")
135
+ yield Footer()
136
+
137
+ def on_mount(self) -> None:
138
+ if self.error:
139
+ return
140
+ if self.session is not None:
141
+ self._present_recording()
142
+ else:
143
+ self._show_start()
144
+
145
+ def check_action(self, action: str, parameters):
146
+ """Stopping is only offered while there is something to stop.
147
+
148
+ The footer is read as a statement of what the screen can do right now,
149
+ and advertising a key that does nothing on the start screen made the
150
+ choice look like a search that was already running.
151
+ """
152
+ if action == "stop":
153
+ return self.search is not None and self.search.running
154
+ return True
155
+
156
+ def on_screen_resume(self) -> None:
157
+ """Coming back from the profile screen must not show the old profile."""
158
+ if self.error or self.search is not None or self.session is not None:
159
+ return
160
+ # Resume can arrive before the first mount, when there is nothing on
161
+ # screen to bring up to date and nothing to report either.
162
+ try:
163
+ self.query_one("#start-looking", Line).update(self._looking())
164
+ except NoMatches:
165
+ return
166
+
167
+ # ─── the choice ─────────────────────────────────────────────────────────
168
+
169
+ def _choices(self) -> list[Choice]:
170
+ choices = [
171
+ Choice(
172
+ "live",
173
+ "Search GitHub for repositories",
174
+ "reads GitHub directly and screens every candidate for free",
175
+ ),
176
+ Choice(
177
+ "profile",
178
+ "Change what you are looking for",
179
+ "languages, topics, the kind of work, how long you will wait",
180
+ ),
181
+ ]
182
+ # Offered only when it exists, and described as what it is. A recorded
183
+ # search presented without its date is a result presented as current.
184
+ if discovery.manifest_path_exists():
185
+ choices.append(
186
+ Choice(
187
+ "replay",
188
+ "Replay the recorded example search",
189
+ "somebody else's profile, screened from committed fixtures",
190
+ )
191
+ )
192
+ return choices
193
+
194
+ def _show_start(self) -> None:
195
+ self.query_one("#discover-start").display = True
196
+ self.query_one("#discover-results").display = False
197
+ self.query_one("#start-looking", Line).update(self._looking())
198
+ choices = self.query_one("#choices", ChoiceList)
199
+ for index, item in enumerate(choices.children):
200
+ animation.reveal(item, delay=animation.stagger(index))
201
+ choices.focus()
202
+
203
+ def _looking(self) -> Text:
204
+ stored = profile_mod.load()
205
+ if stored is None:
206
+ return Text(
207
+ "No profile saved — a search would look for any active repository. "
208
+ "Change what you are looking for to narrow it.",
209
+ style=theme.DIM,
210
+ )
211
+ return Text(f"Looking for {stored.describe()}", style=theme.DIM)
212
+
213
+ def _profile(self) -> Profile:
214
+ """What to search for. An unsaved profile is a broad search, not a
215
+ refusal: `build_queries` handles an empty one and returns a real query."""
216
+ return profile_mod.load() or Profile()
217
+
218
+ # ─── the live search ────────────────────────────────────────────────────
219
+
220
+ def _start_live(self) -> None:
221
+ missing = discovery.missing_token()
222
+ if missing:
223
+ # Said on the screen that asked for it, and the choice stays put.
224
+ self.query_one("#start-hint", Line).update(Text(missing, style=theme.DROP))
225
+ return
226
+
227
+ self.search = discovery.Search(profile=self._profile())
228
+ self.search.start()
229
+
230
+ self.query_one("#discover-start").display = False
231
+ self.query_one("#discover-results").display = True
232
+ self.query_one("#results-looking", Line).update(self._looking())
233
+ self.query_one("#results-counts", Line).update(
234
+ Text(self.search.describe(), style=theme.FAINT)
235
+ )
236
+ self.query_one("#discover-hint", Line).update(
237
+ Text(SEARCH_HINT, style=theme.FAINT)
238
+ )
239
+ self.query_one("#chrome-right", Line).update(self._provenance())
240
+ self.query_one("#candidates", CandidateList).focus()
241
+ self._timer = self.set_interval(POLL_SECONDS, self._pump)
242
+ self.refresh_bindings()
243
+
244
+ def _pump(self) -> None:
245
+ """Draw whatever the worker has screened since the last tick."""
246
+ search = self.search
247
+ if search is None:
248
+ return
249
+
250
+ candidates = self.query_one("#candidates", CandidateList)
251
+ pending = search.rows[self._cursor :]
252
+ self._cursor = len(search.rows)
253
+ for row in pending:
254
+ animation.reveal(candidates.add(row))
255
+ if pending and candidates.index is None:
256
+ # Nothing is highlighted until the list has something in it, and
257
+ # without this the first row arrives with no cursor on it.
258
+ candidates.index = 0
259
+
260
+ self.query_one("#results-counts", Line).update(
261
+ Text(search.describe(), style=theme.FAINT)
262
+ )
263
+
264
+ if search.finished and self._cursor == len(search.rows):
265
+ self._finish()
266
+
267
+ def _finish(self) -> None:
268
+ if self._timer is not None:
269
+ self._timer.stop()
270
+ self._timer = None
271
+ self.refresh_bindings()
272
+ search = self.search
273
+ if search is None:
274
+ return
275
+ self.query_one("#chrome-right", Line).update(self._provenance())
276
+ if search.error:
277
+ self.query_one("#discover-hint", Line).update(
278
+ Text(search.error, style=theme.DROP)
279
+ )
280
+ return
281
+ if not search.rows:
282
+ self.query_one("#discover-hint", Line).update(
283
+ Text(
284
+ "Nothing came back. Widening the languages or topics is the "
285
+ "next thing to try — ctrl+o.",
286
+ style=theme.DROP,
287
+ )
288
+ )
289
+ return
290
+ self.query_one("#discover-hint", Line).update(
291
+ Text(DONE_HINT, style=theme.FAINT)
292
+ )
293
+
294
+ # ─── the recording ──────────────────────────────────────────────────────
295
+
296
+ def _load_recording(self, name: str) -> None:
297
+ try:
298
+ self.session = discovery.load(name)
299
+ except FileNotFoundError:
300
+ self.error = (
301
+ f"No recorded search named {name!r}. "
302
+ "`holt discover --live --record <name>` makes one."
303
+ )
304
+ except Exception as exc: # noqa: BLE001 - reported, never a traceback
305
+ self.error = session_module.readable(exc)
306
+
307
+ def _present_recording(self) -> None:
308
+ found = self.session
309
+ if found is None:
310
+ return
311
+ self.query_one("#discover-start").display = False
312
+ self.query_one("#discover-results").display = True
313
+ self.query_one("#results-looking", Line).update(
314
+ Text(f"Looking for {found.profile_description}", style=theme.DIM)
315
+ )
316
+ self.query_one("#results-counts", Line).update(
317
+ Text(self._counts(), style=theme.FAINT)
318
+ )
319
+ self.query_one("#discover-hint", Line).update(
320
+ Text(DONE_HINT, style=theme.FAINT)
321
+ )
322
+ candidates = self.query_one("#candidates", CandidateList)
323
+ for index, row in enumerate(found.rows):
324
+ animation.reveal(candidates.add(row), delay=animation.stagger(index))
325
+ if found.rows:
326
+ candidates.index = 0
327
+ # The list, not the scroll box around it. Focus landed on the container
328
+ # by default, where ↑↓ scrolled past the candidates and enter did
329
+ # nothing — the only way to assess one was to click it.
330
+ candidates.focus()
331
+
332
+ def _counts(self) -> str:
333
+ found = self.session
334
+ if found is None:
335
+ return ""
336
+ total, survived = len(found.rows), len(found.survivors)
337
+ text = (
338
+ f"{total} candidates screened at no model cost · "
339
+ f"{survived} worth a closer look · {total - survived} cut"
340
+ )
341
+ if found.skipped:
342
+ text += f" · {len(found.skipped)} had no recorded evidence"
343
+ return text
344
+
345
+ def _provenance(self) -> Text:
346
+ text = Text()
347
+ if self.session is not None:
348
+ text.append(f"{self.session.name} ", style=theme.FAINT)
349
+ text.append(
350
+ f"recorded {self.session.as_of.date().isoformat()}", style=theme.FAINT
351
+ )
352
+ elif self.search is not None:
353
+ when = "searching" if self.search.running else "live"
354
+ text.append(when, style=theme.FAINT)
355
+ if self.search.as_of is not None:
356
+ text.append(
357
+ f" {self.search.as_of.date().isoformat()}", style=theme.FAINT
358
+ )
359
+ return text
360
+
361
+ # ─── actions ────────────────────────────────────────────────────────────
362
+
363
+ def on_list_view_selected(self, event) -> None:
364
+ choice = getattr(event.item, "choice", None)
365
+ if choice is not None:
366
+ self._choose(choice)
367
+ return
368
+ row = getattr(event.item, "row", None)
369
+ if row is not None:
370
+ self._assess(row)
371
+
372
+ def _choose(self, choice: Choice) -> None:
373
+ if choice.action == "live":
374
+ self._start_live()
375
+ elif choice.action == "profile":
376
+ self.action_profile()
377
+ elif choice.action == "replay":
378
+ self._load_recording(discovery.DEFAULT_SESSION)
379
+ if self.error:
380
+ self.query_one("#start-hint", Line).update(
381
+ Text(self.error, style=theme.DROP)
382
+ )
383
+ self.error = ""
384
+ return
385
+ self.session_name = discovery.DEFAULT_SESSION
386
+ self.query_one("#chrome-right", Line).update(self._provenance())
387
+ self._present_recording()
388
+
389
+ def _assess(self, row) -> None:
390
+ """Assess this candidate the way the search that found it read GitHub.
391
+
392
+ **The mode follows the search, not the disk.** It used to be
393
+ `replay = has_recording(row.slug)`: search GitHub live, press enter on
394
+ a result, and if a trajectory happened to be sitting in `fixtures/` you
395
+ were handed a recorded answer from June instead of an assessment of
396
+ what you had just found. Nothing on screen said so. Which evidence a
397
+ run reads is a choice, and it is not one the contents of a directory
398
+ get to make on the reader's behalf.
399
+
400
+ So: rows from a live sweep are assessed live, rows from a recorded
401
+ session are replayed, and a recorded row with no trajectory says that
402
+ rather than quietly going live and asking for credentials.
403
+ """
404
+ replay = self.session_name is not None
405
+ if replay and not session_module.has_recording(row.slug):
406
+ self._hint(
407
+ f"No recording of an assessment of {row.slug}, so it cannot be "
408
+ "replayed. Assess it live from the home screen."
409
+ )
410
+ return
411
+ options = session_module.RunOptions(
412
+ repo=row.slug, replay=replay, live=not replay
413
+ )
414
+ cached = self.app.store.fresh(
415
+ row.slug, options.mode, options.contributor_days
416
+ )
417
+ if cached is not None:
418
+ self.app.open_stored(cached)
419
+ return
420
+ missing = session_module.missing_credentials(options)
421
+ if missing:
422
+ self._hint(missing[0])
423
+ return
424
+ self.app.start_run(options)
425
+
426
+ def _hint(self, message: str) -> None:
427
+ self.query_one("#discover-hint", Line).update(Text(message, style=theme.DROP))
428
+
429
+ def action_profile(self) -> None:
430
+ """The profile is what this search was built from, so it is edited here."""
431
+ self.app.push_screen("profile")
432
+
433
+ def action_stop(self) -> None:
434
+ """Stop the sweep. Rows already screened stay: they were free, they are
435
+ still true, and throwing them away would punish impatience."""
436
+ if self.search is not None and self.search.running:
437
+ self.search.cancel()
438
+ self.refresh_bindings()
439
+
440
+ def action_home(self) -> None:
441
+ self.app.go_home()
442
+
443
+ def action_quit(self) -> None:
444
+ self.app.exit()