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/store.py ADDED
@@ -0,0 +1,458 @@
1
+ """Assessments that outlive the process.
2
+
3
+ Two jobs, one file on disk per assessment:
4
+
5
+ * **History.** The interface opens on what you have already looked at, because
6
+ the first question is almost never "assess something new" — it is "what did it
7
+ say about the thing I ran this morning".
8
+ * **Cache.** Assessing a repository costs a minute and real money. Asking for
9
+ one that was assessed four minutes ago should hand back that answer rather
10
+ than spend again, and should say plainly that is what it did.
11
+
12
+ Design rules that matter here:
13
+
14
+ * **Never lie about freshness.** A reused assessment is labelled with its age
15
+ everywhere it appears, and re-running is always one key away. A cache that
16
+ hides its age is indistinguishable from a stale answer.
17
+ * **A corrupt file is skipped, never fatal.** History is a convenience; losing
18
+ it must not stop you assessing something. Every read is defensive.
19
+ * **Writes are atomic.** Temp file then `os.replace`, so a process killed
20
+ mid-write leaves the previous entry intact rather than a truncated one.
21
+ * **The engine's types are converted here, not taught to serialise themselves.**
22
+ `holt.report` is frozen; this module knows how to write an `Assessment` down
23
+ and read one back, and that knowledge lives on this side of the boundary.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ import json
30
+ import os
31
+ import tempfile
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from datetime import datetime
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from holt.evidence.fixtures import record_from_dict, record_to_dict
39
+ from holt.report import Assessment, Claim, EntryPoint, Verdict
40
+ from holt.tui import events as events_module
41
+
42
+ #: Bumped when the on-disk shape changes in a way older readers cannot handle.
43
+ #: Entries with a different version are ignored rather than guessed at.
44
+ SCHEMA_VERSION = 1
45
+
46
+ #: Where history lives. Outside `fixtures/`, outside the reproduction path, and
47
+ #: in `.gitignore` — this is a convenience for one machine, not project content.
48
+ DEFAULT_ROOT = Path(".holt/assessments")
49
+
50
+ #: How long an assessment is considered fresh enough to reuse without asking.
51
+ #: Ten minutes: long enough to cover re-opening the tool while still working on
52
+ #: the same repository, short enough that nobody mistakes it for live data.
53
+ DEFAULT_MAX_AGE_SECONDS = 10 * 60
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class Entry:
58
+ """One stored assessment, plus how it was produced."""
59
+
60
+ repo: str
61
+ mode: str # replay | recorded | live
62
+ created_at: float
63
+ assessment: Assessment
64
+ contributor_days: int = 7
65
+ duration_seconds: float = 0.0
66
+ cost_usd: float = 0.0
67
+ #: The run's own event stream, so the trace survives the process that
68
+ #: produced it. Empty for an assessment stored before this was kept, which
69
+ #: the trace view says rather than showing an empty screen.
70
+ events: list[Any] = field(default_factory=list)
71
+ #: The records behind the ids this assessment cites, as the run saw them.
72
+ #: Not the whole crawl — see `Session.evidence_for_storage`. This is what
73
+ #: makes a reopened report checkable rather than something to take on
74
+ #: trust, including a live one, whose records exist nowhere else.
75
+ evidence: list[Any] = field(default_factory=list)
76
+ path: Path | None = None
77
+
78
+ @property
79
+ def age_seconds(self) -> float:
80
+ # Clamped: a clock change or a file copied from another machine must not
81
+ # produce a negative age that renders as "in 3 minutes".
82
+ return max(0.0, time.time() - self.created_at)
83
+
84
+ def fresh(self, max_age: float = DEFAULT_MAX_AGE_SECONDS) -> bool:
85
+ return self.age_seconds <= max_age
86
+
87
+ @property
88
+ def key(self) -> tuple[str, str, int]:
89
+ """What makes two assessments the same question.
90
+
91
+ The day budget is part of it because `verdict.py` reads it: the same
92
+ repository can be worth a fortnight and not worth an afternoon, and
93
+ serving one answer for the other would be wrong.
94
+ """
95
+ return (self.repo, self.mode, self.contributor_days)
96
+
97
+
98
+ def describe_age(seconds: float) -> str:
99
+ """Human, and never more precise than it is."""
100
+ seconds = max(0.0, seconds)
101
+ if seconds < 45:
102
+ return "just now"
103
+ minutes = seconds / 60
104
+ if minutes < 60:
105
+ n = round(minutes)
106
+ return f"{n} min ago" if n != 1 else "1 min ago"
107
+ hours = minutes / 60
108
+ if hours < 24:
109
+ n = round(hours)
110
+ return f"{n} hours ago" if n != 1 else "1 hour ago"
111
+ days = hours / 24
112
+ n = round(days)
113
+ return f"{n} days ago" if n != 1 else "1 day ago"
114
+
115
+
116
+ # ─── the run's trace ────────────────────────────────────────────────────────
117
+
118
+
119
+ #: The events kept on disk, by class name. Deliberately not all of them:
120
+ #: `ToolResponse` carries a whole model payload that nothing renders, and
121
+ #: `RunFinished` carries the `Assessment` this entry already holds. An event
122
+ #: not named here is dropped on the way out and ignored on the way back, which
123
+ #: is what lets this build read a file written by one that knows more events
124
+ #: than it does — and the reverse.
125
+ TRACE_EVENTS: dict[str, type] = {
126
+ cls.__name__: cls
127
+ for cls in (
128
+ events_module.RunStarted,
129
+ events_module.EvidenceLoaded,
130
+ events_module.StageStarted,
131
+ events_module.StageFinished,
132
+ events_module.FindingEmitted,
133
+ events_module.EvidenceResolved,
134
+ events_module.FindingDropped,
135
+ events_module.UsageUpdated,
136
+ events_module.Retry,
137
+ events_module.RunFailed,
138
+ events_module.RunCancelled,
139
+ )
140
+ }
141
+
142
+ #: Tuples on the event, lists in JSON.
143
+ _TUPLE_FIELDS = frozenset({"evidence_ids", "cited", "completed_stages"})
144
+ #: Datetimes on the event, ISO strings in JSON.
145
+ _TIME_FIELDS = frozenset({"cutoff"})
146
+
147
+
148
+ def _jsonable(value: Any) -> Any:
149
+ """Anything, as something `json.dump` will accept.
150
+
151
+ A finding's `value` is whatever a model put in a field. One unserialisable
152
+ object in it must not take the whole write down — history is a convenience,
153
+ and a stringified value still says what the run reported.
154
+ """
155
+ if value is None or isinstance(value, (str, int, float, bool)):
156
+ return value
157
+ if isinstance(value, (list, tuple)):
158
+ return [_jsonable(v) for v in value]
159
+ if isinstance(value, dict):
160
+ return {str(k): _jsonable(v) for k, v in value.items()}
161
+ return str(value)
162
+
163
+
164
+ def event_to_dict(event: Any) -> dict[str, Any] | None:
165
+ """One event, or `None` if it is not one we keep."""
166
+ cls = TRACE_EVENTS.get(type(event).__name__)
167
+ if cls is None:
168
+ return None
169
+ out: dict[str, Any] = {"event": type(event).__name__}
170
+ for spec in dataclasses.fields(cls):
171
+ value = getattr(event, spec.name, None)
172
+ if spec.name in _TIME_FIELDS:
173
+ value = value.isoformat() if isinstance(value, datetime) else None
174
+ out[spec.name] = _jsonable(value)
175
+ return out
176
+
177
+
178
+ def event_from_dict(raw: dict[str, Any]) -> Any | None:
179
+ """Rebuild an event, or `None` if this build cannot make sense of it."""
180
+ if not isinstance(raw, dict):
181
+ return None
182
+ cls = TRACE_EVENTS.get(raw.get("event", ""))
183
+ if cls is None:
184
+ return None
185
+ kwargs: dict[str, Any] = {}
186
+ for spec in dataclasses.fields(cls):
187
+ if spec.name not in raw:
188
+ # Written by a build from before the field existed. The event class
189
+ # defaults it, which is exactly why the schema only ever adds.
190
+ continue
191
+ value = raw[spec.name]
192
+ if spec.name in _TIME_FIELDS:
193
+ try:
194
+ value = datetime.fromisoformat(value) if value else None
195
+ except (TypeError, ValueError):
196
+ value = None
197
+ elif spec.name in _TUPLE_FIELDS:
198
+ value = tuple(value or ())
199
+ kwargs[spec.name] = value
200
+ try:
201
+ return cls(**kwargs)
202
+ except (TypeError, ValueError):
203
+ return None
204
+
205
+
206
+ # ─── the records behind the claims ──────────────────────────────────────────
207
+
208
+
209
+ def record_or_none(raw: Any) -> Any | None:
210
+ """One evidence record read back, or `None` if the file cannot supply one.
211
+
212
+ Defensive for the same reason every other read here is: a report whose
213
+ stored records are damaged is still a report worth opening, and the
214
+ inspector already has a sentence for an id it cannot look up.
215
+ """
216
+ if not isinstance(raw, dict):
217
+ return None
218
+ try:
219
+ return record_from_dict(raw)
220
+ except (KeyError, TypeError, ValueError):
221
+ return None
222
+
223
+
224
+ def records_to_json(records: list[Any]) -> list[dict[str, Any]]:
225
+ """Records on their way to disk, skipping any that will not serialise."""
226
+ out = []
227
+ for record in records or []:
228
+ try:
229
+ out.append(_jsonable(record_to_dict(record)))
230
+ except (AttributeError, TypeError, ValueError):
231
+ continue
232
+ return out
233
+
234
+
235
+ # ─── serialisation ──────────────────────────────────────────────────────────
236
+
237
+
238
+ def to_dict(entry: Entry) -> dict[str, Any]:
239
+ a = entry.assessment
240
+ return {
241
+ "schema_version": SCHEMA_VERSION,
242
+ "repo": entry.repo,
243
+ "mode": entry.mode,
244
+ "created_at": entry.created_at,
245
+ "contributor_days": entry.contributor_days,
246
+ "duration_seconds": entry.duration_seconds,
247
+ "cost_usd": entry.cost_usd,
248
+ # The run, as it happened. Kept so that reopening a report can still
249
+ # show the trace behind it: the stream is the only record of what was
250
+ # dropped and why, and it used to die with the process.
251
+ "events": [d for d in map(event_to_dict, entry.events) if d is not None],
252
+ # The records the claims point at, so the ids under a reopened report
253
+ # still resolve. A live run's records are on no disk anywhere else:
254
+ # without this, its own report is the one report that cannot be checked.
255
+ "evidence": records_to_json(entry.evidence),
256
+ "assessment": {
257
+ "repo": a.repo,
258
+ "verdict": a.verdict.value,
259
+ "summary": a.summary,
260
+ "bottom_line": getattr(a, "bottom_line", ""),
261
+ "limits": getattr(a, "limits", ""),
262
+ "rules": list(getattr(a, "rules", []) or []),
263
+ "landing": list(getattr(a, "landing", []) or []),
264
+ "contributor_days": getattr(a, "contributor_days", 7),
265
+ "models": list(getattr(a, "models", []) or []),
266
+ "dropped_claims": getattr(a, "dropped_claims", 0),
267
+ "method": a.method,
268
+ "replayed": a.replayed,
269
+ "claims": [
270
+ {"text": c.text, "evidence_id": c.evidence_id} for c in a.claims
271
+ ],
272
+ "entry_points": [
273
+ {
274
+ "evidence_id": p.evidence_id,
275
+ "first_step": p.first_step,
276
+ "why": p.why,
277
+ }
278
+ for p in (getattr(a, "entry_points", []) or [])
279
+ ],
280
+ },
281
+ }
282
+
283
+
284
+ def from_dict(raw: dict[str, Any], path: Path | None = None) -> Entry | None:
285
+ """Rebuild an entry, or `None` if the file is not one we understand.
286
+
287
+ Returns rather than raises: a single unreadable file must not take the
288
+ history list down with it.
289
+ """
290
+ try:
291
+ if raw.get("schema_version") != SCHEMA_VERSION:
292
+ return None
293
+ body = raw["assessment"]
294
+ try:
295
+ verdict = Verdict(body["verdict"])
296
+ except ValueError:
297
+ # A verdict this build has never heard of. Keeping the entry out of
298
+ # history is safer than inventing a member for it.
299
+ return None
300
+
301
+ assessment = Assessment(
302
+ repo=body["repo"],
303
+ verdict=verdict,
304
+ summary=body.get("summary", ""),
305
+ claims=[
306
+ Claim(text=c.get("text", ""), evidence_id=c.get("evidence_id"))
307
+ for c in body.get("claims", [])
308
+ ],
309
+ method=body.get("method", "holt"),
310
+ replayed=bool(body.get("replayed", False)),
311
+ )
312
+ # Fields the engine has added over time. Set only when this build's
313
+ # `Assessment` actually carries them, so an older or newer engine does
314
+ # not turn a cached file into an AttributeError.
315
+ for name, value in (
316
+ ("bottom_line", body.get("bottom_line", "")),
317
+ ("limits", body.get("limits", "")),
318
+ ("rules", list(body.get("rules", []) or [])),
319
+ ("landing", list(body.get("landing", []) or [])),
320
+ ("contributor_days", body.get("contributor_days", 7)),
321
+ ("models", list(body.get("models", []) or [])),
322
+ ("dropped_claims", body.get("dropped_claims", 0)),
323
+ ):
324
+ if hasattr(assessment, name):
325
+ setattr(assessment, name, value)
326
+ if hasattr(assessment, "entry_points"):
327
+ assessment.entry_points = [
328
+ EntryPoint(
329
+ evidence_id=p.get("evidence_id", ""),
330
+ first_step=p.get("first_step", ""),
331
+ why=p.get("why", ""),
332
+ )
333
+ for p in body.get("entry_points", [])
334
+ ]
335
+
336
+ return Entry(
337
+ repo=raw["repo"],
338
+ mode=raw.get("mode", "replay"),
339
+ created_at=float(raw.get("created_at", 0.0)),
340
+ assessment=assessment,
341
+ contributor_days=int(raw.get("contributor_days", 7)),
342
+ duration_seconds=float(raw.get("duration_seconds", 0.0)),
343
+ cost_usd=float(raw.get("cost_usd", 0.0)),
344
+ events=[
345
+ event
346
+ for event in map(event_from_dict, raw.get("events", []) or [])
347
+ if event is not None
348
+ ],
349
+ evidence=[
350
+ record
351
+ for record in map(record_or_none, raw.get("evidence", []) or [])
352
+ if record is not None
353
+ ],
354
+ path=path,
355
+ )
356
+ except (KeyError, TypeError, ValueError):
357
+ return None
358
+
359
+
360
+ # ─── the store ──────────────────────────────────────────────────────────────
361
+
362
+
363
+ @dataclass
364
+ class Store:
365
+ root: Path = DEFAULT_ROOT
366
+ #: Set when the directory cannot be written. The interface stays usable and
367
+ #: says so once, rather than failing every time an assessment finishes.
368
+ read_only: bool = False
369
+ _memory: list[Entry] = field(default_factory=list)
370
+
371
+ def save(self, entry: Entry) -> Entry:
372
+ """Write an assessment down. Always succeeds from the caller's view."""
373
+ self._memory = [e for e in self._memory if e.key != entry.key]
374
+ self._memory.insert(0, entry)
375
+ try:
376
+ self.root.mkdir(parents=True, exist_ok=True)
377
+ path = self.root / _filename(entry)
378
+ _atomic_write(path, to_dict(entry))
379
+ entry.path = path
380
+ self.read_only = False
381
+ except OSError:
382
+ # Kept in memory for this session; history simply will not survive
383
+ # the process. Not worth interrupting anyone over.
384
+ self.read_only = True
385
+ return entry
386
+
387
+ def all(self) -> list[Entry]:
388
+ """Every stored assessment, newest first. Unreadable files are skipped."""
389
+ found: dict[tuple[str, str, int], Entry] = {}
390
+ for entry in self._memory:
391
+ found.setdefault(entry.key, entry)
392
+
393
+ try:
394
+ paths = sorted(self.root.glob("*.json"))
395
+ except OSError:
396
+ paths = []
397
+ for path in paths:
398
+ try:
399
+ raw = json.loads(path.read_text())
400
+ except (OSError, json.JSONDecodeError):
401
+ continue
402
+ entry = from_dict(raw, path)
403
+ if entry is None:
404
+ continue
405
+ existing = found.get(entry.key)
406
+ if existing is None or entry.created_at > existing.created_at:
407
+ found[entry.key] = entry
408
+
409
+ return sorted(found.values(), key=lambda e: e.created_at, reverse=True)
410
+
411
+ def latest(self, repo: str, mode: str, contributor_days: int = 7) -> Entry | None:
412
+ """The most recent assessment of exactly this question, if any."""
413
+ key = (repo, mode, contributor_days)
414
+ for entry in self.all():
415
+ if entry.key == key:
416
+ return entry
417
+ return None
418
+
419
+ def fresh(
420
+ self,
421
+ repo: str,
422
+ mode: str,
423
+ contributor_days: int = 7,
424
+ max_age: float = DEFAULT_MAX_AGE_SECONDS,
425
+ ) -> Entry | None:
426
+ """A reusable answer, or `None`. The caller must still show its age."""
427
+ entry = self.latest(repo, mode, contributor_days)
428
+ return entry if entry is not None and entry.fresh(max_age) else None
429
+
430
+ def forget(self, entry: Entry) -> None:
431
+ self._memory = [e for e in self._memory if e.key != entry.key]
432
+ if entry.path is not None:
433
+ try:
434
+ entry.path.unlink()
435
+ except OSError:
436
+ pass
437
+
438
+
439
+ def _filename(entry: Entry) -> str:
440
+ slug = entry.repo.replace("/", "__")
441
+ return f"{slug}__{entry.mode}__{entry.contributor_days}d.json"
442
+
443
+
444
+ def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
445
+ """Temp file then rename, so a kill mid-write cannot truncate history."""
446
+ handle = tempfile.NamedTemporaryFile(
447
+ "w", dir=path.parent, prefix=".tmp-", suffix=".json", delete=False
448
+ )
449
+ try:
450
+ with handle:
451
+ json.dump(payload, handle, indent=1)
452
+ os.replace(handle.name, path)
453
+ except BaseException:
454
+ try:
455
+ os.unlink(handle.name)
456
+ except OSError:
457
+ pass
458
+ raise