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/session.py ADDED
@@ -0,0 +1,711 @@
1
+ """Running an analysis, and getting told about it.
2
+
3
+ This is the only file in the TUI that touches `holt.agent`, `holt.model` or the
4
+ evidence layer. Screens read a `Session`; they never build a provider, never
5
+ choose a model, and never call the pipeline. Moving the engine's entry point
6
+ therefore breaks one file rather than every screen.
7
+
8
+ The run happens on a worker thread and reports through a queue, so the interface
9
+ stays responsive while a live run spends a minute on the network, and needs no
10
+ cooperation from the engine to do it.
11
+
12
+ A session can also be *restored* from a stored assessment, in which case no
13
+ thread starts and nothing is spent. That path exists so that opening this
14
+ morning's result and running a new one are the same object to every screen.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import queue
21
+ import threading
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from holt import model as model_module
29
+ from holt.agent import entry, pipeline
30
+ from holt.evidence.fixtures import FixtureProvider, redact_records
31
+ from holt.evidence.provider import EvidenceProvider
32
+ from holt.report import EntryPoint
33
+ from holt.tui import events, store
34
+ from holt.tui.observe import ObservingModel, ObservingProvider, RunCancelled
35
+ from holt.types import Window
36
+
37
+ # Mirrors `holt.cli`. Imported from there rather than restated so that the TUI
38
+ # reads the same fixtures the CLI and the eval harness read.
39
+ from holt.cli import ISSUE_ROOT, PATHFINDER_TRAJECTORIES, normalise
40
+
41
+
42
+ @dataclass
43
+ class RunOptions:
44
+ repo: str
45
+ #: Which evidence this run reads, stated at every call site rather than
46
+ #: defaulted. The default used to be `True`: build a `RunOptions` without
47
+ #: thinking about the question and you got committed fixtures, rendered as
48
+ #: an assessment with no sign that is what they were. Reading a recording
49
+ #: is a choice. It is never what you get for not making one.
50
+ replay: bool
51
+ live: bool = False
52
+ #: Off by default, matching `holt analyze`. The ranking does not beat
53
+ #: GitHub's `good first issue` label, and the engine demoted it to opt-in;
54
+ #: an interface that turned it back on would be quietly overriding a
55
+ #: measured decision.
56
+ entry_points: bool = False
57
+ contributor_days: int = 7
58
+ #: Where a live run records its trajectory.
59
+ #:
60
+ #: Deliberately **not** `fixtures/trajectories/`. `OpenAIModel` appends every
61
+ #: call to the path it is given, so pointing it at the committed fixtures
62
+ #: would have the interface rewrite the evidence the eval harness replays.
63
+ run_root: Path = Path("runs")
64
+ #: Stamped once so every call in a run lands in the same file.
65
+ started: str = field(
66
+ default_factory=lambda: datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
67
+ )
68
+
69
+ @property
70
+ def mode(self) -> str:
71
+ """What this run *is*, in one word, for display and for cache keys.
72
+
73
+ `replay` reads a recording. `recorded` makes real model calls over the
74
+ committed fixtures. `live` additionally reads GitHub.
75
+ """
76
+ if self.replay:
77
+ return "replay"
78
+ return "live" if self.live else "recorded"
79
+
80
+ def recording(self, repo: str, kind: str) -> Path:
81
+ slug = repo.replace("/", "__")
82
+ return self.run_root / slug / self.started / f"{kind}.jsonl"
83
+
84
+
85
+ @dataclass
86
+ class Session:
87
+ """One analysis: its options, its event stream, and its result."""
88
+
89
+ options: RunOptions
90
+ log: list[events.Event] = field(default_factory=list)
91
+ assessment: Any = None
92
+ trace: Any = None
93
+ error: str | None = None
94
+ #: Set when this session is a stored assessment rather than a fresh run.
95
+ #: Every screen showing a result checks it, because a reader must never have
96
+ #: to wonder whether what they are looking at was just computed.
97
+ restored_from: Any = None
98
+ started_at: float = 0.0
99
+ finished_at: float = 0.0
100
+ cost_usd: float = 0.0
101
+ input_tokens: int = 0
102
+ output_tokens: int = 0
103
+ #: The provider the run used. Evidence lookups from the interface go
104
+ #: through it rather than through a fresh one, so a reader checking a claim
105
+ #: sees exactly what Stage D saw — a new provider has fetched nothing and
106
+ #: would report every id as unresolvable.
107
+ provider: Any = None
108
+ #: The provider a reading order read, when one ran. Kept for the same
109
+ #: reason as `provider`: the ids under the reading order come from here and
110
+ #: nowhere else, so storing the records behind them needs this too.
111
+ issue_provider: Any = None
112
+ #: Built on demand for a stored assessment, which has no run behind it.
113
+ #: See `StoredEvidence` and `ReopenedEvidence`.
114
+ _reopened: Any = None
115
+ _reopen_tried: bool = False
116
+ #: The stage the run is in, for anything showing progress without showing
117
+ #: the stream. Set from the events, so it cannot disagree with them.
118
+ stage: str = ""
119
+ #: Set when the run was stopped on purpose. Distinct from `error`: a stop
120
+ #: is not a failure, and nothing about it should read like one.
121
+ cancelled: bool = False
122
+ _queue: queue.Queue = field(default_factory=queue.Queue)
123
+ _thread: threading.Thread | None = None
124
+ _cancel: threading.Event = field(default_factory=threading.Event)
125
+ #: Stages that finished before a stop landed. Recorded on the worker thread,
126
+ #: which is the only thread that writes it.
127
+ _completed: list[str] = field(default_factory=list)
128
+
129
+ # ─── construction ───────────────────────────────────────────────────────
130
+
131
+ @classmethod
132
+ def restored(cls, stored: Any) -> "Session":
133
+ """A finished session backed by a stored assessment. Nothing runs."""
134
+ options = RunOptions(
135
+ repo=stored.repo,
136
+ replay=stored.mode == "replay",
137
+ live=stored.mode == "live",
138
+ contributor_days=stored.contributor_days,
139
+ )
140
+ session = cls(options=options)
141
+ session.assessment = stored.assessment
142
+ session.restored_from = stored
143
+ session.started_at = stored.created_at
144
+ session.finished_at = stored.created_at + stored.duration_seconds
145
+ session.cost_usd = stored.cost_usd
146
+ # The run's own stream, as it was stored. Every screen that renders a
147
+ # trace renders it from the log, so a stored run and a live one are the
148
+ # same thing to look at — a restored session simply has its log already
149
+ # full instead of filling as the events arrive.
150
+ session.log = list(getattr(stored, "events", []) or [])
151
+ return session
152
+
153
+ # ─── lifecycle ──────────────────────────────────────────────────────────
154
+
155
+ def start(self) -> None:
156
+ if self._thread is not None:
157
+ raise RuntimeError("session already started")
158
+ if self.restored_from is not None:
159
+ raise RuntimeError("a restored session has nothing to run")
160
+ self.started_at = time.time()
161
+ self._thread = threading.Thread(target=self._run, daemon=True)
162
+ self._thread.start()
163
+
164
+ def cancel(self) -> None:
165
+ """Ask the run to stop. It stops at the next model call or evidence read.
166
+
167
+ Cannot be undone, and deliberately does not join: the caller is a key
168
+ binding, and blocking the interface until a live model call returns
169
+ would freeze the very screen that has to say `stopping`.
170
+ """
171
+ self._cancel.set()
172
+
173
+ @property
174
+ def running(self) -> bool:
175
+ """A worker is alive and has not yet produced an outcome."""
176
+ return self._thread is not None and self._thread.is_alive()
177
+
178
+ @property
179
+ def stopping(self) -> bool:
180
+ """Stopped by the reader, but the worker has not wound down yet."""
181
+ return self._cancel.is_set() and self.running
182
+
183
+ def drain(self) -> list[events.Event]:
184
+ """Every event that arrived since the last call. Never blocks."""
185
+ out: list[events.Event] = []
186
+ while True:
187
+ try:
188
+ event = self._queue.get_nowait()
189
+ except queue.Empty:
190
+ break
191
+ self.log.append(event)
192
+ if isinstance(event, events.RunFinished):
193
+ self.assessment, self.trace = event.assessment, event.trace
194
+ self.finished_at = time.time()
195
+ elif isinstance(event, events.RunFailed):
196
+ self.error = event.error
197
+ self.finished_at = time.time()
198
+ elif isinstance(event, events.RunCancelled):
199
+ self.cancelled = True
200
+ self.finished_at = time.time()
201
+ elif isinstance(event, events.StageStarted):
202
+ self.stage = event.stage
203
+ elif isinstance(event, events.UsageUpdated):
204
+ self.input_tokens = event.input_tokens
205
+ self.output_tokens = event.output_tokens
206
+ # Cost, but only when a call was actually paid for. A replay
207
+ # reports the token counts the *original* run recorded, and
208
+ # those price out to a real number; carrying it here would let
209
+ # any screen render spend for a run that bought nothing.
210
+ if not self.options.replay:
211
+ self.cost_usd = event.cost_usd
212
+ out.append(event)
213
+ return out
214
+
215
+ @property
216
+ def finished(self) -> bool:
217
+ """Nothing more will arrive: it produced a report, failed, or was stopped."""
218
+ return self.assessment is not None or self.error is not None or self.cancelled
219
+
220
+ @property
221
+ def duration(self) -> float:
222
+ if not self.started_at:
223
+ return 0.0
224
+ return (self.finished_at or time.time()) - self.started_at
225
+
226
+ def evidence(self) -> Any:
227
+ """Where evidence lookups from the interface go, or None.
228
+
229
+ A run hands over the provider it used, so the inspector shows exactly
230
+ what Stage D saw. A stored assessment has no run behind it and reads its
231
+ evidence back on first use — see `StoredEvidence`, which is why
232
+ reopening this morning's report and pressing enter on a claim reads the
233
+ record rather than apologising for not having one.
234
+ """
235
+ if self.provider is not None:
236
+ return self.provider
237
+ if self.restored_from is None:
238
+ return None
239
+ if not self._reopen_tried:
240
+ self._reopen_tried = True
241
+ self._reopened = reopen_evidence(self.restored_from)
242
+ return self._reopened
243
+
244
+ def resolve(self, evidence_id: str):
245
+ """The record behind an id, or None. Read-only, and never raises."""
246
+ source = self.evidence()
247
+ if source is None:
248
+ return None
249
+ try:
250
+ return source.resolve(evidence_id)
251
+ except Exception: # noqa: BLE001 - the inspector reports it, not this
252
+ return None
253
+
254
+ @property
255
+ def evidence_note(self) -> str:
256
+ """Why nothing could be looked up, or "" when a lookup is possible.
257
+
258
+ An id that was asked about and did not resolve is a statement about the
259
+ evidence, and the inspector says so in its own words. This is the other
260
+ case: nobody asked, and the reason is worth a sentence rather than a
261
+ shrug — every one of them ends in something the reader can do.
262
+ """
263
+ source = self.evidence()
264
+ if source is None:
265
+ if self.restored_from is not None:
266
+ return (
267
+ "This assessment read GitHub live and was stored before "
268
+ "holt kept the records behind its claims, which exist "
269
+ "nowhere else. Re-run it — ctrl+r on the report — to read "
270
+ "the record behind this id."
271
+ )
272
+ return (
273
+ "No evidence was loaded for this session, so there is nothing "
274
+ "to look this id up in."
275
+ )
276
+ return getattr(source, "unavailable", "")
277
+
278
+ @property
279
+ def evidence_provenance(self) -> str:
280
+ """Where a record shown to the reader came from, when that is not the
281
+ run itself. Empty for a session that still has its own provider."""
282
+ return getattr(self.evidence(), "provenance", "")
283
+
284
+ def wait(self, timeout: float | None = None) -> None:
285
+ """Block until the worker exits. Used by tests."""
286
+ if self._thread is not None:
287
+ self._thread.join(timeout)
288
+ self.drain()
289
+
290
+ def to_entry(self) -> Any:
291
+ """This session as something the store can keep, if it produced anything.
292
+
293
+ A restored session returns nothing: re-saving it would move its
294
+ timestamp forward and make a stored answer look freshly computed.
295
+ """
296
+ if self.assessment is None or self.restored_from is not None:
297
+ return None
298
+ return store.Entry(
299
+ repo=self.options.repo,
300
+ mode=self.options.mode,
301
+ created_at=self.started_at or time.time(),
302
+ assessment=self.assessment,
303
+ contributor_days=self.options.contributor_days,
304
+ duration_seconds=self.duration,
305
+ cost_usd=self.cost_usd,
306
+ events=list(self.log),
307
+ evidence=self.evidence_for_storage(),
308
+ )
309
+
310
+ def evidence_for_storage(self) -> list[Any]:
311
+ """The records behind this assessment's ids, to be stored with it.
312
+
313
+ Only the ids the report itself carries — every claim, and every row of
314
+ the reading order. A run reads thousands of records and storing all of
315
+ them would put megabytes next to every report, while the interface can
316
+ only ever look up an id that is printed on the page. What it stores is
317
+ therefore bounded by the report, not by the repository.
318
+
319
+ Read back out of the providers the run is still holding, which keep
320
+ every record they fetched. That costs no network even in live mode, and
321
+ it means the record stored is the record the run had rather than a
322
+ fresh read of a window that has since moved.
323
+
324
+ Credentials are stripped on the way out, the same way `write_fixture`
325
+ strips them: this is evidence landing on disk, and it does not matter
326
+ that the directory is a local one.
327
+ """
328
+ assessment = self.assessment
329
+ if assessment is None:
330
+ return []
331
+ wanted = [c.evidence_id for c in assessment.claims if c.evidence_id]
332
+ wanted += [
333
+ point.evidence_id
334
+ for point in (getattr(assessment, "entry_points", None) or [])
335
+ if point.evidence_id
336
+ ]
337
+ found: dict[str, Any] = {}
338
+ for evidence_id in wanted:
339
+ if evidence_id in found:
340
+ continue
341
+ record = self._record_behind(evidence_id)
342
+ if record is not None:
343
+ found[evidence_id] = record
344
+ records, _ = redact_records(found.values())
345
+ return records
346
+
347
+ def _record_behind(self, evidence_id: str) -> Any:
348
+ """One record out of the run's own providers, or None. Never raises.
349
+
350
+ Deliberately not through the observing wrapper: see
351
+ `ObservingProvider.inner`. An id that cannot be looked up is stored as
352
+ nothing at all, which the inspector already has a sentence for — better
353
+ than taking the whole write down over one record.
354
+ """
355
+ for source in (self.provider, self.issue_provider):
356
+ if source is None:
357
+ continue
358
+ try:
359
+ record = getattr(source, "inner", source).resolve(evidence_id)
360
+ except Exception: # noqa: BLE001 - a report is worth storing regardless
361
+ continue
362
+ if record is not None:
363
+ return record
364
+ return None
365
+
366
+ # ─── the run ────────────────────────────────────────────────────────────
367
+
368
+ def _emit(self, event: events.Event) -> None:
369
+ if isinstance(event, events.StageFinished):
370
+ self._completed.append(event.stage)
371
+ self._queue.put(event)
372
+
373
+ def _run(self) -> None:
374
+ opts = self.options
375
+ repo = normalise(opts.repo)
376
+ try:
377
+ provider = ObservingProvider(
378
+ _provider(opts.live), self._emit, self._cancel.is_set
379
+ )
380
+ self.provider = provider
381
+ client = ObservingModel(
382
+ _client(repo, opts, "verdict"), self._emit, repo, self._cancel.is_set
383
+ )
384
+ self._emit(events.RunStarted(repo=repo, replayed=opts.replay))
385
+
386
+ assessment, trace = pipeline.analyze(
387
+ repo, provider, client, contributor_days=opts.contributor_days
388
+ )
389
+
390
+ # Stage D's drops are recovered from the trace rather than guessed
391
+ # from the resolution stream: the engine already decided, and the
392
+ # view must show what it decided, not a reconstruction of it.
393
+ for dropped in trace.dropped:
394
+ self._emit(
395
+ events.FindingDropped(
396
+ field=dropped.field,
397
+ value=dropped.value,
398
+ cited=tuple(dropped.evidence_ids),
399
+ reason="unresolved",
400
+ )
401
+ )
402
+ for invented in trace.invented:
403
+ self._emit(
404
+ events.FindingDropped(
405
+ field=invented.field,
406
+ value=invented.value,
407
+ cited=tuple(invented.evidence_ids),
408
+ reason="unquoted",
409
+ )
410
+ )
411
+ self._emit(
412
+ events.StageFinished(
413
+ stage="verify",
414
+ seconds=0.0,
415
+ summary=(
416
+ f"{trace.before_verification} findings → "
417
+ f"{trace.after_verification} kept, {len(trace.dropped)} dropped"
418
+ + (f", {len(trace.invented)} unquoted" if trace.invented else "")
419
+ ),
420
+ )
421
+ )
422
+ self._emit(
423
+ events.StageFinished(
424
+ stage="verdict", seconds=0.0, summary=assessment.verdict.value
425
+ )
426
+ )
427
+
428
+ if opts.entry_points:
429
+ self._rank(assessment, repo, provider, opts)
430
+
431
+ self._emit(events.RunFinished(assessment=assessment, trace=trace))
432
+ except RunCancelled:
433
+ # Caught ahead of `Exception`: a stop the reader asked for must not
434
+ # be reported as a defect, and nothing partial is stored.
435
+ self._emit(events.RunCancelled(completed_stages=tuple(self._completed)))
436
+ except Exception as exc: # noqa: BLE001 - surfaced in the UI, not swallowed
437
+ self._emit(events.RunFailed(error=readable(exc)))
438
+
439
+ def _rank(self, assessment, repo: str, provider, opts: RunOptions) -> None:
440
+ """Attach a reading order, or say nothing.
441
+
442
+ Silent on a missing fixture for the same reason `holt.cli` is silent:
443
+ a tool that invents an entry point when it has no issues is worse than
444
+ one that omits the section.
445
+ """
446
+ issue_provider = _issue_provider(opts.live)
447
+ try:
448
+ issues = issue_provider.fetch(repo)
449
+ except FileNotFoundError:
450
+ return
451
+ # Held on the session, not just locally: the reading order's ids resolve
452
+ # against this and nothing else, and they are stored with the report.
453
+ self.issue_provider = issue_provider
454
+ try:
455
+ client = _client(repo, opts, PATHFINDER_TRAJECTORIES)
456
+ self._emit(
457
+ events.StageStarted(
458
+ stage="pathfinder", model=model_module.model_for("pathfinder")
459
+ )
460
+ )
461
+ ranked = entry.rank(repo, list(issues), list(provider.fetch(repo)), client)
462
+ except FileNotFoundError:
463
+ return
464
+ assessment.entry_points = [
465
+ EntryPoint(r["evidence_id"], r["first_step"], r.get("why", ""))
466
+ for r in ranked
467
+ ]
468
+ # Same reason as `holt.cli.add_entry_points`: the ranker has its own
469
+ # client and can be pointed at its own model, and the footer names every
470
+ # model that wrote something on the page.
471
+ for name in client.usage.models:
472
+ if name not in assessment.models:
473
+ assessment.models.append(name)
474
+ self._emit(
475
+ events.StageFinished(
476
+ stage="pathfinder", seconds=0.0, summary=f"{len(ranked)} issues ranked"
477
+ )
478
+ )
479
+
480
+
481
+ class StoredEvidence:
482
+ """The records stored with an assessment, as something to look ids up in.
483
+
484
+ A report is only checkable while the records under it can be read, and for
485
+ a live run those records exist nowhere but the process that fetched them.
486
+ So the ids the report prints are stored with it — see
487
+ `Session.evidence_for_storage` — and this is what reads them back. Opening
488
+ this morning's live report and pressing enter on a claim shows the record
489
+ Stage D saw, which is the whole point of citing one.
490
+
491
+ A fixture-backed run additionally keeps its original source, used only for
492
+ an id the stored set does not have. `provenance` follows whichever answered,
493
+ because "the run held this" and "this was read off disk just now" are
494
+ different statements and the reader is owed the true one.
495
+ """
496
+
497
+ #: What the inspector prints under a record that came from the stored set.
498
+ STORED = "stored with this assessment, as the run read it"
499
+
500
+ def __init__(self, records: list[Any], fallback: Any = None) -> None:
501
+ self._records = {
502
+ r.evidence_id: r for r in records if getattr(r, "evidence_id", "")
503
+ }
504
+ #: Consulted only for an id the stored records do not cover.
505
+ self._fallback = fallback
506
+ #: Nothing to report: there are records, so lookups are possible.
507
+ self.unavailable = ""
508
+ self.provenance = self.STORED
509
+
510
+ def resolve(self, evidence_id: str):
511
+ record = self._records.get(evidence_id)
512
+ if record is not None:
513
+ self.provenance = self.STORED
514
+ return record
515
+ if self._fallback is None:
516
+ return None
517
+ record = self._fallback.resolve(evidence_id)
518
+ # Set after the lookup, and read by the inspector after it too, so the
519
+ # line under the record names the source that actually produced it.
520
+ self.provenance = getattr(self._fallback, "provenance", "")
521
+ return record
522
+
523
+
524
+ class ReopenedEvidence:
525
+ """The source a stored assessment was built from, opened again.
526
+
527
+ Second in line behind `StoredEvidence`, and reached only for an id the
528
+ records stored with the report do not cover. For a replay or a recorded run
529
+ that source is a committed fixture — reading it again reads the same bytes
530
+ Stage D read, so the record the inspector shows is still the record the
531
+ claim was drawn from.
532
+
533
+ Two things this is careful about:
534
+
535
+ * **Nothing is read until a claim is opened.** Reopening a report stays
536
+ instant; the fixture load happens on the first `resolve`.
537
+ * **A live run is not re-crawled.** Its records came off GitHub, and a
538
+ remote crawl on the interface's own thread against a window that has
539
+ moved since would not be the run's evidence however it were labelled.
540
+ A live report is checked against the records stored with it, and an old
541
+ one that has none gets no source at all and says so.
542
+ """
543
+
544
+ def __init__(self, repo: str, providers: list[Any]) -> None:
545
+ self.repo = repo
546
+ #: In lookup order. The first is where the report's claims came from;
547
+ #: any others (issue evidence, behind the reading order) are extra, and
548
+ #: their absence is ordinary rather than something to report.
549
+ self._providers = providers
550
+ self._loaded = False
551
+ #: Set when the primary source could not be read at all. Shown by the
552
+ #: inspector in place of "does not resolve", which would blame the
553
+ #: evidence for a file that is simply not there any more.
554
+ self.unavailable = ""
555
+ #: Where a record shown from here actually came from. The inspector
556
+ #: prints it under the record, because "the run held this" and "this
557
+ #: was read back off disk just now" are different statements and only
558
+ #: one of them is true here.
559
+ self.provenance = ""
560
+
561
+ def _load(self) -> None:
562
+ if self._loaded:
563
+ return
564
+ self._loaded = True
565
+ for index, provider in enumerate(self._providers):
566
+ try:
567
+ provider.fetch(self.repo)
568
+ except Exception as exc: # noqa: BLE001 - reported, never raised
569
+ if index == 0:
570
+ self.unavailable = (
571
+ f"The evidence this assessment was built from cannot be "
572
+ f"read back: {readable(exc)}"
573
+ )
574
+
575
+ def resolve(self, evidence_id: str):
576
+ self._load()
577
+ for provider in self._providers:
578
+ try:
579
+ record = provider.resolve(evidence_id)
580
+ except Exception: # noqa: BLE001 - one dead source is not the end
581
+ continue
582
+ if record is not None:
583
+ return record
584
+ return None
585
+
586
+
587
+ def reopen_evidence(stored: Any) -> Any:
588
+ """The sources a stored assessment can be checked against, or None.
589
+
590
+ The records the report cites are stored with it, so they come first and are
591
+ all a live assessment ever has — its crawl is on no disk anywhere else. A
592
+ fixture-backed one keeps its original source behind them, for an id the
593
+ stored set happens not to cover.
594
+
595
+ None is returned only for an assessment written before records were kept
596
+ *and* produced live. That is the one case with nothing to read, and the
597
+ session says which rather than blaming the evidence.
598
+ """
599
+ records = list(getattr(stored, "evidence", None) or [])
600
+ fallback = _original_source(stored)
601
+ if records:
602
+ return StoredEvidence(records, fallback)
603
+ return fallback
604
+
605
+
606
+ def _original_source(stored: Any) -> ReopenedEvidence | None:
607
+ """The source the run itself read, opened again, or None if it cannot be.
608
+
609
+ Keyed off the mode the assessment was produced in, because that is what
610
+ decides where its ids came from. A live run is not re-crawled: see
611
+ `ReopenedEvidence`.
612
+ """
613
+ if getattr(stored, "mode", "") == "live":
614
+ return None
615
+ repo = normalise(stored.repo)
616
+ primary = FixtureProvider(Window.PRE_T)
617
+ source = ReopenedEvidence(
618
+ repo,
619
+ # Issue evidence second: it is where the reading order's ids live, and
620
+ # a repository without an issue fixture is ordinary rather than broken.
621
+ [primary, FixtureProvider(Window.PRE_T, root=Path(ISSUE_ROOT))],
622
+ )
623
+ source.provenance = (
624
+ f"read back from {primary.path_for(repo)}, the fixture this assessment "
625
+ "was built from"
626
+ )
627
+ return source
628
+
629
+
630
+ def readable(exc: BaseException) -> str:
631
+ """Turn an exception into something a person can act on.
632
+
633
+ The failures that actually happen get a sentence saying what to do next.
634
+ Everything else is reported as itself rather than dressed up, because a
635
+ confident wrong guess about a cause is worse than the raw error.
636
+ """
637
+ text = str(exc)
638
+ if isinstance(exc, FileNotFoundError):
639
+ return (
640
+ "No recording for this repository, so it cannot be replayed. "
641
+ "Run it live instead."
642
+ )
643
+ if "not found or not public" in text:
644
+ return f"{text}. Check the owner and name, and that the repository is public."
645
+ if "GITHUB_TOKEN" in text or "OPENAI_API_KEY" in text:
646
+ return text
647
+ if "rate limit" in text.lower():
648
+ return f"{text}. GitHub is rate limiting; wait a few minutes."
649
+ return f"{type(exc).__name__}: {text}"
650
+
651
+
652
+ def _client(repo: str, opts: RunOptions, kind: str):
653
+ """The model client for one part of a run.
654
+
655
+ Replay reads the committed trajectory, exactly as the CLI does. A live run
656
+ records to `runs/` instead, so the interface never appends to the fixtures
657
+ the eval harness replays.
658
+ """
659
+ if opts.replay:
660
+ directory = (
661
+ model_module.TRAJECTORY_DIR
662
+ if kind == "verdict"
663
+ else model_module.TRAJECTORY_DIR / kind
664
+ )
665
+ return model_module.ReplayModel(directory / (repo.replace("/", "__") + ".jsonl"))
666
+ return model_module.OpenAIModel(opts.recording(repo, kind))
667
+
668
+
669
+ def missing_credentials(opts: RunOptions) -> list[str]:
670
+ """What a run needs before it is worth starting.
671
+
672
+ Checked up front so the answer arrives as a sentence rather than a traceback
673
+ part-way through a full-screen interface.
674
+ """
675
+ missing = []
676
+ if not opts.replay and not os.environ.get("OPENAI_API_KEY"):
677
+ missing.append(
678
+ "OPENAI_API_KEY — the stages call a model. Without it, use replay."
679
+ )
680
+ if opts.live and not os.environ.get("GITHUB_TOKEN"):
681
+ missing.append(
682
+ "GITHUB_TOKEN — live mode reads GitHub directly. Without it, only "
683
+ "repositories with committed fixtures can be assessed."
684
+ )
685
+ return missing
686
+
687
+
688
+ def has_recording(repo: str) -> bool:
689
+ """Whether this repository can be replayed for free."""
690
+ try:
691
+ return (
692
+ model_module.TRAJECTORY_DIR / (repo.replace("/", "__") + ".jsonl")
693
+ ).is_file()
694
+ except OSError:
695
+ return False
696
+
697
+
698
+ def _provider(live: bool) -> EvidenceProvider:
699
+ if not live:
700
+ return FixtureProvider(Window.PRE_T)
701
+ from holt.evidence.github_graphql import LiveGitHubProvider
702
+
703
+ return LiveGitHubProvider(Window.PRE_T)
704
+
705
+
706
+ def _issue_provider(live: bool) -> EvidenceProvider:
707
+ if not live:
708
+ return FixtureProvider(Window.PRE_T, root=Path(ISSUE_ROOT))
709
+ from holt.evidence.github_graphql import LiveGitHubIssueProvider
710
+
711
+ return LiveGitHubIssueProvider(Window.PRE_T)