grask 0.1.0rc1__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.
grask/SKILL.md ADDED
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: grask
3
+ description: Serve the next pending grask probe — a multiple-choice question about code the developer recently shipped — using the native question UI. Use when the user types /grask.
4
+ ---
5
+
6
+ # Serving a grask probe
7
+
8
+ Grask quizzes the developer on mechanisms they shipped without fully
9
+ understanding. You are the delivery surface only: you serve the question and
10
+ relay the answer. You never grade, never guess, and never see the answer key.
11
+
12
+ ## Hard rules
13
+
14
+ - Never open or query grask's database directly. The two subcommands below are
15
+ the entire interface.
16
+ - Never speculate about which option is correct — not in text, not in labels,
17
+ not in previews, not before or after the pick. Grading happens in
18
+ `grask record`.
19
+ - One native question, one round. No confidence round, no follow-ups.
20
+
21
+ ## Flow
22
+
23
+ 1. Run:
24
+
25
+ ```
26
+ grask serve --json
27
+ ```
28
+
29
+ If the output is `{"pending": null}`, say there is nothing pending and stop.
30
+ If the command is not found, grask is not installed on this PATH — say so and
31
+ stop rather than hunting for a checkout to `cd` into.
32
+
33
+ 2. Ask ONE native question, preview-style (like plan-mode option picks), built
34
+ entirely from the served JSON:
35
+
36
+ - `question`: a context line from `topic` and `created_at`, then the full
37
+ question text — e.g.
38
+ `from 2026-07-21 · idempotency of the retry path — What would happen if …?`
39
+ The question must be readable inside the picker itself; do not rely on
40
+ markdown printed before it.
41
+ - One option per stored option, in stored order:
42
+ - `label`: the letter plus the first few distinguishing words of the
43
+ option (e.g. `a) dedup to a no-op`). Keep labels short; they are not the
44
+ full text.
45
+ - `preview`: the full stored option text, verbatim and unabridged. The
46
+ side-by-side preview pane is where the developer reads the option.
47
+ - In exactly one option's preview (or the question text), append a footer
48
+ note: "Other" accepts `skip`, or `wrong: <what's off>` if the question
49
+ misreads what happened.
50
+
51
+ 3. Record the result:
52
+ - Picked letter L: `grask record <probe_id> --pick L`
53
+ - Skipped: `grask record <probe_id> --skip`
54
+ - Premise rejected: `grask record <probe_id> --wrong --objection
55
+ "<their words>"` (omit `--objection` if they gave no reason).
56
+
57
+ Show the result: ✓ or ✗ from `outcome`, then the `explanation` verbatim. If
58
+ the command prints `{"error": ...}`, show the error and stop — do not retry
59
+ with different flags.
60
+
61
+ 4. Run `serve` again. If another probe is pending, say so and offer to
62
+ continue — do not auto-serve it.
grask/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """grask — one question about your own code, at the end of a session."""
2
+
3
+ __version__ = "0.1.0"
grask/ask.py ADDED
@@ -0,0 +1,224 @@
1
+ """One probe, asked and graded mechanically.
2
+
3
+ Pure logic. The console is injected, exactly as `capture_session` injects its
4
+ stages, so the whole flow is drivable by a scripted console. No argparse, no
5
+ terminal control codes, no TTY — those belong to `cli.py`, and keeping them out
6
+ is what leaves the delivery question open. `ask` does not care whether a human
7
+ typed `grask` or a hook called it.
8
+
9
+ There is no model call anywhere in this path. Picking an option IS the answer:
10
+ the verdict is `pick == correct_idx`, the explanation was written at generation
11
+ time, and the only `error` outcome left is a stored row too malformed to serve.
12
+ That is a property of the design, not of careful coding — a judge cannot be
13
+ slow, expensive, or cowardly if there is no judge.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Protocol
20
+
21
+ from grask.probe import Rubric
22
+
23
+ # Written to a TEXT column and grouped on by hand later, so the strings are the
24
+ # schema. `premise_rejected` is its own outcome rather than a flavour of skip:
25
+ # it is the zealot rate, and a rate you cannot query is a rate nobody checks.
26
+ PASSED = "passed"
27
+ FAILED = "failed"
28
+ SKIPPED = "skipped"
29
+ PREMISE_REJECTED = "premise_rejected"
30
+ ERROR = "error"
31
+
32
+ WRONG = "/wrong"
33
+
34
+ # Option letters, and therefore the hard cap on how many options a stored row
35
+ # may carry and still be served.
36
+ LETTERS = "abcde"
37
+
38
+
39
+ class Console(Protocol):
40
+ """Everything the interrogation needs from the outside world."""
41
+
42
+ def show(self, text: str) -> None: ...
43
+
44
+ def prompt(self, text: str) -> str: ...
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class PendingProbe:
49
+ """A stored probe, ready to be asked.
50
+
51
+ Lives here rather than in `storage.py` so that the import runs
52
+ storage -> ask, matching how storage already imports `Probe` and `Seed` from
53
+ the modules that produce them, and avoiding the cycle the other direction
54
+ would create.
55
+
56
+ `correct_idx` is `int | None` because the row is trusted nowhere: a mangled
57
+ row surfaces as `None`, an empty tuple, or an out-of-range index, and `ask`
58
+ records `error` instead of guessing.
59
+ """
60
+
61
+ probe_id: int
62
+ question: str
63
+ options: tuple[str, ...]
64
+ correct_idx: int | None
65
+ explanation: str
66
+ rubric: Rubric
67
+ created_at: str
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class AnswerTurn:
72
+ """One developer turn: the question, and the option text they chose."""
73
+
74
+ turn: int
75
+ question: str
76
+ answer: str
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Interrogation:
81
+ """Everything that happened, ready to be stored. No database in sight."""
82
+
83
+ probe_id: int
84
+ outcome: str
85
+ # Always None since the confidence round was cut; the column stays nullable
86
+ # so historical rows keep their numbers.
87
+ confidence: int | None
88
+ objection: str | None
89
+ turns: tuple[AnswerTurn, ...]
90
+ cost_usd: float | None
91
+
92
+
93
+ OBJECTION_PROMPT = "what's wrong with it? (enter to skip)"
94
+ MALFORMED = "this probe's stored options are unusable; recording an error."
95
+
96
+
97
+ def context_line(pending: PendingProbe) -> str:
98
+ """One line of orientation above the question.
99
+
100
+ Without it the developer reads a question about work they cannot place, which
101
+ is the version of this tool that feels like a quiz. `created_at` is the
102
+ probe's, which is the session's within a few seconds of it.
103
+ """
104
+ when = pending.created_at[:10]
105
+ return f"from {when} · {pending.rubric.topic}"
106
+
107
+
108
+ def render_options(options: tuple[str, ...]) -> str:
109
+ return "\n".join(f" {LETTERS[i]}) {option}" for i, option in enumerate(options))
110
+
111
+
112
+ def pick_prompt(count: int) -> str:
113
+ return f"pick [a-{LETTERS[count - 1]}] · enter = skip · /wrong"
114
+
115
+
116
+ def pick_hint(count: int) -> str:
117
+ return f"type a single letter, a-{LETTERS[count - 1]}."
118
+
119
+
120
+ def _unservable(pending: PendingProbe) -> bool:
121
+ """A row the ask cannot honestly grade.
122
+
123
+ Storage already filters `options IS NULL`, so what arrives here failed to
124
+ parse or carries an index that names no option. Grading either would invent
125
+ a verdict, which is worse than admitting the row is broken.
126
+ """
127
+ return (
128
+ len(pending.options) < 2
129
+ or len(pending.options) > len(LETTERS)
130
+ or pending.correct_idx is None
131
+ or not 0 <= pending.correct_idx < len(pending.options)
132
+ or not pending.explanation.strip()
133
+ )
134
+
135
+
136
+ def resolution(
137
+ pending: PendingProbe,
138
+ outcome: str,
139
+ *,
140
+ objection: str | None = None,
141
+ turns: tuple[AnswerTurn, ...] = (),
142
+ ) -> Interrogation:
143
+ """An Interrogation for `pending`, however it ended.
144
+
145
+ Shared by the interactive loop and the non-interactive record path so both
146
+ write identically shaped rows. `cost_usd` is always 0.0: there is no model
147
+ call anywhere in an ask, whichever surface drove it.
148
+ """
149
+ return Interrogation(
150
+ probe_id=pending.probe_id,
151
+ outcome=outcome,
152
+ confidence=None,
153
+ objection=objection,
154
+ turns=turns,
155
+ cost_usd=0.0,
156
+ )
157
+
158
+
159
+ def grade(pending: PendingProbe, pick: str) -> Interrogation:
160
+ """(pending, pick letter) -> a mechanically graded Interrogation.
161
+
162
+ The one place a pick becomes a verdict; `ask` and `grask record` both land
163
+ here. Raises ValueError rather than guessing on bad input — the caller
164
+ decides how to surface it. Never call this on a row `_unservable` flags.
165
+ """
166
+ valid = LETTERS[: len(pending.options)]
167
+ letter = pick.strip().lower()
168
+ if len(letter) != 1 or letter not in valid:
169
+ raise ValueError(f"pick must be a single letter, a-{valid[-1]}")
170
+ picked = valid.index(letter)
171
+ return resolution(
172
+ pending,
173
+ PASSED if picked == pending.correct_idx else FAILED,
174
+ turns=(AnswerTurn(turn=0, question=pending.question, answer=pending.options[picked]),),
175
+ )
176
+
177
+
178
+ def ask(pending: PendingProbe, console: Console) -> Interrogation:
179
+ """Run one probe to a verdict: pick, mechanical grade."""
180
+
181
+ def done(
182
+ outcome: str,
183
+ *,
184
+ objection: str | None = None,
185
+ turns: tuple[AnswerTurn, ...] = (),
186
+ ) -> Interrogation:
187
+ return resolution(
188
+ pending,
189
+ outcome,
190
+ objection=objection,
191
+ turns=turns,
192
+ )
193
+
194
+ def ask_objection() -> str | None:
195
+ """Shared `/wrong` handling: prompt once for an optional reason.
196
+
197
+ Optional because requiring an argument to escape is how you get an escape
198
+ hatch nobody uses. The outcome is the signal; the text is a bonus.
199
+ """
200
+ typed = console.prompt(OBJECTION_PROMPT).strip()
201
+ return typed or None
202
+
203
+ if _unservable(pending):
204
+ console.show(MALFORMED)
205
+ return done(ERROR)
206
+
207
+ console.show(context_line(pending))
208
+ console.show(pending.question)
209
+ console.show(render_options(pending.options))
210
+
211
+ valid = LETTERS[: len(pending.options)]
212
+ while True:
213
+ typed = console.prompt(pick_prompt(len(pending.options))).strip().lower()
214
+ if not typed:
215
+ return done(SKIPPED)
216
+ if typed == WRONG:
217
+ return done(PREMISE_REJECTED, objection=ask_objection())
218
+ if len(typed) == 1 and typed in valid:
219
+ break
220
+ console.show(pick_hint(len(pending.options)))
221
+
222
+ graded = grade(pending, typed)
223
+ console.show(f"{'✓' if graded.outcome == PASSED else '✗'} {pending.explanation}")
224
+ return graded
grask/capture.py ADDED
@@ -0,0 +1,158 @@
1
+ """The whole pipeline, end to end, for one session.
2
+
3
+ Runs detached from a SessionEnd hook with nothing watching its exit code. That
4
+ single fact decides the error handling: an exception here reaches no one, so
5
+ every failure has to become a row and a log line instead. `capture_session` does
6
+ not raise. If it ever does, a session ends and grask silently forgets it.
7
+
8
+ Order is extract → triage → seed → probe, cheapest first. Stage 0 is free and
9
+ filters sessions with no human in them; triage is one call and filters the
10
+ majority; only what survives both pays for stages 2 and 3.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ import traceback
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ from grask.dialogue import extract_dialogue as _extract_dialogue
21
+ from grask.probe import probe as _probe
22
+ from grask.seed import seed as _seed
23
+ from grask.select import select
24
+ from grask.storage import Store, grask_home
25
+ from grask.transcript import extract
26
+ from grask.triage import triage as _triage
27
+
28
+
29
+ def log(message: str) -> None:
30
+ """Append to the capture log. Never raises — this is the failure path itself."""
31
+ try:
32
+ path = grask_home() / "grask.log"
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ stamp = datetime.now(UTC).isoformat()
35
+ with path.open("a", encoding="utf-8") as handle:
36
+ handle.write(f"{stamp} {message}\n")
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ def capture_session(
42
+ transcript_path: Path,
43
+ store: Store,
44
+ *,
45
+ triage=_triage,
46
+ seed=_seed,
47
+ probe=_probe,
48
+ extract_dialogue=_extract_dialogue,
49
+ ) -> None:
50
+ """Triage one ended session and persist whatever it earned.
51
+
52
+ The stages are injectable so the control flow can be tested without spending
53
+ money on a model. Defaults are the real thing.
54
+ """
55
+ session_id = Path(transcript_path).stem
56
+ try:
57
+ if store.has_session(session_id):
58
+ return
59
+
60
+ session = extract(Path(transcript_path))
61
+
62
+ if not session.turns:
63
+ # Stage 0's floor. No human said anything, so there is nothing to ask
64
+ # about and no reason to pay triage to confirm it.
65
+ store.record_session(
66
+ session_id=session.session_id,
67
+ transcript_path=str(transcript_path),
68
+ cwd=session.cwd,
69
+ git_branch=session.git_branch,
70
+ verdict="silent",
71
+ )
72
+ return
73
+
74
+ verdict = triage(session)
75
+
76
+ # triage() never raises; it reports failure by returning silent with
77
+ # `.error` set. Recording that as silence would make a broken model call
78
+ # look like a boring session, and the failure-rate number would be a lie.
79
+ if verdict.error:
80
+ log(f"{session_id} triage error: {verdict.error}")
81
+ store.record_session(
82
+ session_id=session.session_id,
83
+ transcript_path=str(transcript_path),
84
+ cwd=session.cwd,
85
+ git_branch=session.git_branch,
86
+ verdict="error",
87
+ cost_usd=verdict.cost_usd,
88
+ duration_ms=verdict.duration_ms,
89
+ )
90
+ return
91
+
92
+ if not verdict.kept:
93
+ store.record_session(
94
+ session_id=session.session_id,
95
+ transcript_path=str(transcript_path),
96
+ cwd=session.cwd,
97
+ git_branch=session.git_branch,
98
+ verdict="silent",
99
+ cost_usd=verdict.cost_usd,
100
+ duration_ms=verdict.duration_ms,
101
+ )
102
+ return
103
+
104
+ # The verdict carries the selected moment's fields but not the Moment
105
+ # itself, and stage 2 needs the object. `select` is pure, so re-running it
106
+ # over the same moments returns the same one triage chose.
107
+ moment = select(verdict.moments)
108
+ if moment is None:
109
+ raise RuntimeError("verdict is 'ask' but no moment survives selection")
110
+
111
+ dialogue = extract_dialogue(Path(transcript_path))
112
+ the_seed = seed(dialogue, moment)
113
+ the_probe = probe(the_seed, dialogue)
114
+
115
+ store.record_session(
116
+ session_id=session.session_id,
117
+ transcript_path=str(transcript_path),
118
+ cwd=session.cwd,
119
+ git_branch=session.git_branch,
120
+ verdict="ask",
121
+ signal=verdict.signal,
122
+ topic=verdict.topic,
123
+ cost_usd=verdict.cost_usd,
124
+ duration_ms=verdict.duration_ms,
125
+ )
126
+ seed_id = store.add_seed(the_seed)
127
+ store.add_probe(seed_id, the_probe)
128
+
129
+ except Exception:
130
+ log(f"{session_id} capture failed:\n{traceback.format_exc()}")
131
+ try:
132
+ store.record_session(
133
+ session_id=session_id,
134
+ transcript_path=str(transcript_path),
135
+ cwd=None,
136
+ git_branch=None,
137
+ verdict="error",
138
+ )
139
+ except Exception:
140
+ log(f"{session_id} could not even record the error:\n{traceback.format_exc()}")
141
+
142
+
143
+ def main(argv: list[str] | None = None) -> int:
144
+ """The detached worker: `python -m grask.capture <transcript_path>`."""
145
+ args = sys.argv[1:] if argv is None else argv
146
+ if not args:
147
+ log("worker started with no transcript path")
148
+ return 0
149
+ try:
150
+ with Store() as store:
151
+ capture_session(Path(args[0]), store)
152
+ except Exception:
153
+ log(f"worker failed before capture:\n{traceback.format_exc()}")
154
+ return 0
155
+
156
+
157
+ if __name__ == "__main__":
158
+ raise SystemExit(main())
grask/capture_run.py ADDED
@@ -0,0 +1,236 @@
1
+ """Run the capture pipeline over transcripts the hook never saw.
2
+
3
+ The SessionEnd hook only ever captures sessions that end from now on, and the
4
+ sessions that end from now on are overwhelmingly grask's own. Waiting for the
5
+ queue to fill measures grask on grask. This walks the corpus already on disk
6
+ instead, skips grask's own project by default, and pays for a bounded number of
7
+ real sessions from other work.
8
+
9
+ Costs money, so it does not spend by default: without `--go` it prints the plan
10
+ and an estimate and stops.
11
+
12
+ Usage:
13
+ uv run python -m grask.capture_run [--limit N] [--go]
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from collections.abc import Callable, Sequence
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from grask.capture import capture_session
26
+ from grask.storage import Store
27
+ from grask.transcript import extract, find_transcripts
28
+
29
+ DEFAULT_WORKERS = 6
30
+ DEFAULT_LIMIT = 40
31
+
32
+ # Any transcript whose project directory contains one of these is not corpus.
33
+ # "grask" keeps the tool from being evaluated on its own construction; the
34
+ # scratchpad dirs are agent side-sessions with no developer in them at all.
35
+ DEFAULT_EXCLUDE = ("grask", "scratchpad")
36
+
37
+ # Measured across this runner's own batches, which is the only population that
38
+ # predicts this runner: 60 sessions selected, 25 kept, $16.36 spent.
39
+ #
40
+ # Deliberately one all-in number per session rather than a triage/seed/probe
41
+ # decomposition. The decomposition looks more principled and fits worse: the two
42
+ # observed batches ($1.62/10 and $14.74/50) cannot be reconciled by any single
43
+ # pair of per-stage costs, because session length varies more than stage price
44
+ # does. A model that cannot fit the two points it was built from should not be
45
+ # dressed up as three constants.
46
+ #
47
+ # The first version of this was fitted to n=14 from a different population (the
48
+ # hook's grask-only sessions) and under-quoted a 50-session run by 64%. Erring
49
+ # low is the harmful direction — it under-quotes spend the developer then
50
+ # authorises — so treat the band, not the point, as the estimate.
51
+ COST_PER_SESSION = 0.27
52
+ COST_BAND = 0.4 # observed batch error against the point estimate, both ways
53
+ KEPT_RATE = 0.42 # 25 of 60; drives the probe-count estimate, not the cost one
54
+
55
+
56
+ @dataclass
57
+ class Plan:
58
+ """What a run would touch, and what it declined to."""
59
+
60
+ selected: list[Path] = field(default_factory=list)
61
+ excluded: int = 0
62
+ already_captured: int = 0
63
+ no_human_turns: int = 0
64
+ over_limit: int = 0
65
+
66
+ def estimate_usd(self) -> float:
67
+ return len(self.selected) * COST_PER_SESSION
68
+
69
+ def estimate_range_usd(self) -> tuple[float, float]:
70
+ """The number to quote. A point estimate here has already misled once."""
71
+ point = self.estimate_usd()
72
+ return point * (1 - COST_BAND), point * (1 + COST_BAND)
73
+
74
+ def estimate_probes(self) -> int:
75
+ return round(len(self.selected) * KEPT_RATE)
76
+
77
+
78
+ def plan(
79
+ paths: Sequence[Path],
80
+ *,
81
+ exclude: Sequence[str],
82
+ is_captured: Callable[[str], bool],
83
+ has_human_turns: Callable[[Path], bool],
84
+ limit: int | None,
85
+ ) -> Plan:
86
+ """Decide which transcripts to spend on. Pure — every side effect is injected.
87
+
88
+ The three filters are ordered by what they cost to evaluate: the project name
89
+ is free, the captured check is one indexed read, and the human-turn check
90
+ parses the whole file. The limit applies last, so `--limit 40` means forty
91
+ sessions that will actually reach triage rather than forty rows of scratchpad.
92
+ """
93
+ result = Plan()
94
+ for path in paths:
95
+ project = path.parent.name
96
+ if any(marker in project for marker in exclude):
97
+ result.excluded += 1
98
+ continue
99
+ if is_captured(path.stem):
100
+ result.already_captured += 1
101
+ continue
102
+ if not has_human_turns(path):
103
+ result.no_human_turns += 1
104
+ continue
105
+ if limit is not None and len(result.selected) >= limit:
106
+ result.over_limit += 1
107
+ continue
108
+ result.selected.append(path)
109
+ return result
110
+
111
+
112
+ def has_human_turns(path: Path) -> bool:
113
+ """Stage 0's filter, run early so empty sessions never eat the limit."""
114
+ try:
115
+ return bool(extract(path).turns)
116
+ except OSError:
117
+ return False
118
+
119
+
120
+ def capture_one(path: Path) -> None:
121
+ """One session, one connection.
122
+
123
+ sqlite3 connections are not shareable across threads, so each task opens its
124
+ own. Writes are brief next to the model calls they follow, and the driver's
125
+ default busy timeout absorbs the overlap.
126
+ """
127
+ with Store() as store:
128
+ capture_session(path, store)
129
+
130
+
131
+ def outcomes(store: Store, session_ids: Sequence[str]) -> str:
132
+ """Read back what the run actually produced.
133
+
134
+ `capture_session` reports nothing — it is built to run detached, where the
135
+ only honest channel is a row. So the report is a query, not a return value.
136
+ """
137
+ if not session_ids:
138
+ return "Nothing ran."
139
+ marks = ",".join("?" * len(session_ids))
140
+ verdicts = store.conn.execute(
141
+ f"SELECT verdict, COUNT(*) n, SUM(COALESCE(cost_usd, 0)) c "
142
+ f"FROM sessions WHERE session_id IN ({marks}) GROUP BY verdict",
143
+ tuple(session_ids),
144
+ ).fetchall()
145
+ probes = store.conn.execute(
146
+ f"SELECT COUNT(*) n, SUM(COALESCE(p.cost_usd, 0) + COALESCE(s.cost_usd, 0)) c "
147
+ f"FROM probes p JOIN seeds s ON s.id = p.seed_id "
148
+ f"WHERE s.session_id IN ({marks})",
149
+ tuple(session_ids),
150
+ ).fetchone()
151
+
152
+ spent = sum(row["c"] or 0.0 for row in verdicts) + (probes["c"] or 0.0)
153
+ lines = [
154
+ "=" * 72,
155
+ f"CAPTURED {len(session_ids)} sessions",
156
+ "=" * 72,
157
+ ]
158
+ for row in sorted(verdicts, key=lambda r: -r["n"]):
159
+ lines.append(f" {row['verdict']:<8} {row['n']:>4d} ${row['c'] or 0.0:.2f}")
160
+ lines += [
161
+ "",
162
+ f" probes minted {probes['n']:>4d}",
163
+ f" spent ${spent:.2f}",
164
+ ]
165
+ if not probes["n"]:
166
+ lines.append("")
167
+ lines.append(" No probes. Either triage kept nothing or every kept session errored —")
168
+ lines.append(" check grask.log before spending on a second batch.")
169
+ return "\n".join(lines)
170
+
171
+
172
+ def describe(result: Plan, *, limit: int | None) -> str:
173
+ low, high = result.estimate_range_usd()
174
+ lines = [
175
+ "=" * 72,
176
+ f"BACKFILL PLAN: {len(result.selected)} sessions to capture",
177
+ "=" * 72,
178
+ f" skipped, excluded project {result.excluded:>5d}",
179
+ f" skipped, already captured {result.already_captured:>5d}",
180
+ f" skipped, no human turns {result.no_human_turns:>5d}",
181
+ f" skipped, over the limit {result.over_limit:>5d}" if limit is not None else "",
182
+ "",
183
+ f" estimated cost ${low:.2f} - ${high:.2f}"
184
+ f" (~${COST_PER_SESSION:.2f}/session, +/-{COST_BAND:.0%})",
185
+ f" expected probes {result.estimate_probes():>5d} (at {KEPT_RATE:.0%} kept)",
186
+ "",
187
+ "-" * 72,
188
+ ]
189
+ for path in result.selected:
190
+ lines.append(f" {path.stem[:8]} {path.parent.name}")
191
+ return "\n".join(line for line in lines if line != "")
192
+
193
+
194
+ def main(argv: list[str] | None = None) -> int:
195
+ parser = argparse.ArgumentParser(description=__doc__)
196
+ parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
197
+ parser.add_argument("--workers", type=int, default=DEFAULT_WORKERS)
198
+ parser.add_argument("--root", type=Path, default=None, help="transcript root")
199
+ parser.add_argument(
200
+ "--exclude",
201
+ action="append",
202
+ default=None,
203
+ help=f"project-name substring to skip; repeatable (default: {DEFAULT_EXCLUDE})",
204
+ )
205
+ parser.add_argument("--go", action="store_true", help="actually spend; otherwise dry run")
206
+ args = parser.parse_args(argv)
207
+
208
+ exclude = tuple(args.exclude) if args.exclude else DEFAULT_EXCLUDE
209
+ with Store() as store:
210
+ result = plan(
211
+ find_transcripts(args.root),
212
+ exclude=exclude,
213
+ is_captured=store.has_session,
214
+ has_human_turns=has_human_turns,
215
+ limit=args.limit,
216
+ )
217
+
218
+ print(describe(result, limit=args.limit))
219
+ if not result.selected:
220
+ return 0
221
+ if not args.go:
222
+ print("\nDry run. Re-run with --go to spend.")
223
+ return 0
224
+
225
+ print(f"\ncapturing on {args.workers} workers...", file=sys.stderr)
226
+ with ThreadPoolExecutor(max_workers=args.workers) as pool:
227
+ list(pool.map(capture_one, result.selected))
228
+
229
+ with Store() as store:
230
+ print()
231
+ print(outcomes(store, [p.stem for p in result.selected]))
232
+ return 0
233
+
234
+
235
+ if __name__ == "__main__":
236
+ raise SystemExit(main())