quirework 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 (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. quirework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1213 @@
1
+ #!/usr/bin/env python3
2
+ """Render a provenance session as a clean, readable transcript.
3
+
4
+ The read-half companion to ``quirework provenance search``: search tells you *which*
5
+ session mentioned X (by short id); show prints that session's human/assistant
6
+ turns in order, with tool calls, tool-result echoes, thinking, and harness
7
+ plumbing stripped, so a session can be *read* without hand-grepping the raw
8
+ JSONL. (Hand-rolling that extraction is exactly what this command exists to
9
+ retire.)
10
+
11
+ quirework provenance show 31cf63ed # cc-session, human + assistant prose
12
+ quirework provenance search kettle --show # find the session, then:
13
+ quirework provenance show 31cf63ed --thinking # include the assistant's thinking
14
+ quirework provenance show 31cf63ed --role human # only the human's turns
15
+ quirework provenance show 31cf63ed --raw # keep harness text, labeled as such
16
+ quirework provenance show 31cf63ed --utc # stamp turns in UTC, not local time
17
+ quirework provenance show 31cf63ed --from 15:47 --to 15:52 # turns in a time window
18
+ quirework provenance show 31cf63ed --turn 14 # the author's turn 14 and its replies
19
+ quirework provenance show c00a7331 # a claude.ai web dialogue (both speakers)
20
+
21
+ The id is the short id search prints (an 8-hex prefix, or the full ``gemini-…``
22
+ name); a full uuid or a path works too. It resolves across the corpus families,
23
+ pending captures included, so a session can be read before it is accepted; the
24
+ source line says when it has not been:
25
+ local CLI sessions (Claude Code, Antigravity, and Codex), claude.ai
26
+ ``*.raw.json`` web dialogues, the already-rendered ``gemini-*.md`` reviews,
27
+ where ``provenance.redpen`` declares the archive, red-pen rounds, read as
28
+ the author's marks one turn each and named ``redpen-`` plus the file's name,
29
+ and, where ``provenance.rulings`` declares one, review-sheet rulings, read as
30
+ the author's ruling on each item one turn each, with what the sheet presented
31
+ as the text the turn answered, and named ``rulings-`` plus the file's name.
32
+
33
+ Every turn carries the timestamp the raw log recorded for it (already present in
34
+ the stored transcripts — cc ``timestamp``, agy/web ``created_at`` — so nothing
35
+ needed re-capturing), and the header states the session's span and duration.
36
+ Times render in the machine's local zone by default; ``--utc`` renders them
37
+ in UTC. The already-rendered ``gemini-*.md`` reviews carry no per-turn stamps,
38
+ so those pass through unstamped.
39
+
40
+ ``--from`` and ``--to`` bound a time window and keep the turns logged inside
41
+ it, both ends included. A time needs no turn at it: ``--from 00:00`` starts at
42
+ the first turn of the day. Any stamp the transcript prints works as a bound,
43
+ and a bound covers the whole unit it is written to, so ``--to 15:52`` keeps a
44
+ turn at 15:52:59. A bare time takes the session's day; in a session that
45
+ crosses midnight it takes the day the session was running at that time. Only
46
+ a session logged over more than a day can be running at one time on two days;
47
+ there a typed time asks for its date, and the transcript prints the date on
48
+ every turn whose time alone would be ambiguous.
49
+
50
+ A bound is read in the zone the transcript is shown in, so a time copied from
51
+ another machine's transcript can name a different moment here. A bound that
52
+ ends in its own zone cannot: ``--from 16:45:26Z`` and ``--from
53
+ '2026-09-16 13:45:26-03:00'`` mean one moment everywhere, and a log's own
54
+ stamp, such as ``2026-09-16T16:45:26.123Z``, is read as the millisecond it
55
+ names.
56
+
57
+ The author's turns are numbered from 1, as ``quirework provenance search``
58
+ numbers them, so a hit's ``turn 14`` is ``## Human 14`` here and ``--turn 14``
59
+ opens it with the replies that follow, up to the author's next turn. A time
60
+ can be shared by turns logged in the same second and shifts with the zone; the
61
+ number does neither, and --role and --raw leave it alone.
62
+
63
+ (``--thinking`` is wired but usually inert. A thinking block's *text* is only
64
+ persisted when the session ran against a **direct API** (Vertex, or an Anthropic
65
+ API key); **subscription**-authenticated Claude Code sessions record the block
66
+ as a bare signature with no text, and that is most of this corpus. So
67
+ ``--thinking`` recovers reasoning only for the handful of direct-API sessions.)
68
+
69
+ PRIVATE: like ``quirework provenance input``, the output is verbatim corpus text (the
70
+ firehose), so it stays inside the private workspace / ~/tmp and is never
71
+ promoted. Curated, short quotes are fine; the whole rendered session is not.
72
+ """
73
+
74
+ from __future__ import annotations
75
+
76
+ import argparse
77
+ import json
78
+ import re
79
+ import sys
80
+ from datetime import date, datetime, time, timedelta, timezone
81
+ from pathlib import Path
82
+
83
+ from quirework.project import current_project
84
+ from quirework.provenance import ccfilter as FLT
85
+ from quirework.provenance import rulings as RULINGS
86
+ from quirework.provenance import store
87
+ from quirework.provenance import codexfilter as CODEX_FLT
88
+ from quirework.provenance import catalog as CAT
89
+ from quirework.provenance.metrics import HARNESS
90
+
91
+ ROLE_LABEL = {
92
+ "human": "Human",
93
+ "assistant": "Assistant",
94
+ "agent": "Agent prompt (machine-written)",
95
+ "subagent": "Sub-agent (machine-written)",
96
+ "harness": "Harness context",
97
+ }
98
+
99
+ # The tool a Claude Code sub-agent hands its final report back through. The
100
+ # report is the sub-agent's own text, so `--raw` shows it with the sub-agent's
101
+ # prose rather than dropping it with the other tool calls.
102
+ _HANDBACK = "SubagentHandback"
103
+
104
+
105
+ def _human_slot(text, raw):
106
+ """``(role, text)`` for a message logged in the human's slot, or ``None``.
107
+
108
+ The harness writes into that slot too: slash-command wrappers, system
109
+ reminders, interrupt notices, and the report a background subagent sends
110
+ back when it finishes. A message with the author's prose in it is
111
+ ``human``, with its wrappers stripped. One with none is dropped, or with
112
+ ``raw`` kept as ``harness``, so it never reads as the author's words. With
113
+ ``raw`` every message keeps its text exactly as logged.
114
+ """
115
+ text = (text or "").strip()
116
+ cleaned = FLT.strip_wrappers(text)
117
+ if cleaned and not cleaned.startswith(HARNESS):
118
+ return "human", text if raw else cleaned
119
+ if raw and text:
120
+ return "harness", text
121
+ return None
122
+
123
+
124
+ # How an author turn reached a Claude Code log, in the order the reader
125
+ # learned each path. A reader that predates a path did not count its turns,
126
+ # so a turn's origin says which readers saw it: the answer to a question the
127
+ # model asked joined the author's turns first, then a prompt typed while the
128
+ # model worked, then a prompt logged as a list of blocks, which is how one
129
+ # sent with a pasted image is logged.
130
+ ORIGINS = ("typed", "answer", "queued", "listed")
131
+
132
+
133
+ def _origin(turn, origin):
134
+ """``origin`` for a ``_human_slot`` row in the author's role, else ``None``."""
135
+ return origin if turn[0] == "human" else None
136
+
137
+
138
+ def _answers(content, asks, raw, ts):
139
+ """``("human", text, ts)`` for each answer in a tool-result echo.
140
+
141
+ Only the result of a question the model asked, an id in ``asks``, is the
142
+ author's; every other tool result stays dropped as tool output.
143
+ """
144
+ out = []
145
+ for b in content if isinstance(content, list) else ():
146
+ if not isinstance(b, dict) or b.get("type") != "tool_result":
147
+ continue
148
+ if b.get("tool_use_id") not in asks:
149
+ continue
150
+ text = FLT.answer_text(b, asks[b.get("tool_use_id")], raw=raw)
151
+ if text:
152
+ out.append(("human", text, ts))
153
+ return out
154
+
155
+
156
+ # --------------------------------------------------------------------------- #
157
+ # turn extraction -> a flat list of (role, text), in conversation order
158
+ # --------------------------------------------------------------------------- #
159
+ def cc_records(path, *, thinking=False, raw=False, origins=False):
160
+ """``(role, text, ts)`` turns from a cc-session JSONL.
161
+
162
+ ``ts`` is the record's ISO-8601 ``timestamp`` (or ``None`` if absent).
163
+ Assistant messages collapse their content blocks into one turn: ``text``
164
+ blocks as prose, ``thinking`` blocks (only with ``thinking``) prefixed and
165
+ set off, ``tool_use`` blocks dropped. Human turns are the messages the
166
+ author typed: a string, or the text blocks of a prompt sent with a
167
+ pasted image, which the harness logs as a list (``ccfilter.typed_text``).
168
+ A list is otherwise a tool-result echo and is skipped, except the result
169
+ of a question the model asked with ``AskUserQuestion``, which is the
170
+ author's pick and so a human turn, as the capture and the Codex reader
171
+ already treat it, with the option previews the model wrote cut from it
172
+ (``ccfilter.answer_text``). Records the
173
+ harness flags as its own (a meta injection, the summary a compacted
174
+ session resumes from, or the notice of a failed request written in the
175
+ model's slot) and ``isSidechain`` (subagent side-threads) records are
176
+ dropped unless ``raw``, which keeps them under the role of whoever wrote
177
+ them: a flagged record is ``harness``, a side-thread's prompt is
178
+ ``agent``, the task the parent handed its subagent, and what the subagent
179
+ wrote back, its prose and the report it hands back, is ``subagent``.
180
+ A prompt the author typed while the model was working is a human turn
181
+ too, though the harness logs it only as a queue operation, at the time
182
+ he typed it (``ccfilter.queued_prompt``). With ``origins`` each row
183
+ carries a fourth item: how a human turn reached the log (``ORIGINS``),
184
+ or ``None`` for any other row.
185
+ """
186
+ out = []
187
+ asks = {} # each question the model asked the author: id -> its input
188
+ queued = {} # queued text -> when it was enqueued
189
+ with open(path, encoding="utf-8", errors="replace") as fh:
190
+ for line in fh:
191
+ line = line.strip()
192
+ if not line:
193
+ continue
194
+ try:
195
+ o = json.loads(line)
196
+ except json.JSONDecodeError:
197
+ continue
198
+ typ = o.get("type")
199
+ prompt = FLT.queued_prompt(o, queued)
200
+ if prompt:
201
+ out.append(("human", prompt[0], prompt[1], "queued"))
202
+ continue
203
+ if typ not in ("user", "assistant"):
204
+ continue
205
+ if not raw and (FLT.harness_flagged(o) or o.get("isSidechain")):
206
+ continue
207
+ ts = o.get("timestamp")
208
+ content = (o.get("message") or {}).get("content")
209
+ if typ == "user":
210
+ if not isinstance(content, str):
211
+ # A tool-result echo, or a prompt sent with a pasted image.
212
+ if not (o.get("isSidechain") or FLT.harness_flagged(o)):
213
+ turn = _human_slot(FLT.typed_text(content, raw), raw)
214
+ if turn:
215
+ out.append((*turn, ts, _origin(turn, "listed")))
216
+ out.extend(
217
+ (*row, "answer") for row in _answers(content, asks, raw, ts)
218
+ )
219
+ continue
220
+ if o.get("isSidechain") or FLT.harness_flagged(o):
221
+ # Reached only with raw; neither was typed by the author.
222
+ # The harness writes into a subagent's slot as it does
223
+ # into the session's, so its flag wins over the thread's.
224
+ role = "harness" if FLT.harness_flagged(o) else "agent"
225
+ if content.strip():
226
+ out.append((role, content.strip(), ts, None))
227
+ continue
228
+ turn = _human_slot(content, raw)
229
+ if turn:
230
+ out.append((*turn, ts, _origin(turn, "typed")))
231
+ continue
232
+ # assistant: merge this message's blocks into one turn
233
+ if isinstance(content, str):
234
+ blocks = [{"type": "text", "text": content}]
235
+ elif isinstance(content, list):
236
+ blocks = content
237
+ else:
238
+ blocks = []
239
+ parts = []
240
+ for b in blocks:
241
+ if not isinstance(b, dict):
242
+ continue
243
+ bt = b.get("type")
244
+ if bt == "text":
245
+ t = (b.get("text") or "").strip()
246
+ if t:
247
+ parts.append(t)
248
+ elif bt == "thinking" and thinking:
249
+ t = (b.get("thinking") or b.get("text") or "").strip()
250
+ if t:
251
+ parts.append("[thinking]\n" + t)
252
+ elif bt == "tool_use" and b.get("name") == FLT.ASK_TOOL:
253
+ asks[b.get("id")] = b.get("input")
254
+ elif (
255
+ bt == "tool_use"
256
+ and b.get("name") == _HANDBACK
257
+ and o.get("isSidechain")
258
+ ):
259
+ report = (b.get("input") or {}).get("message")
260
+ if isinstance(report, str) and report.strip():
261
+ parts.append(report.strip())
262
+ # other tool_use / tool_result: dropped
263
+ body = "\n\n".join(parts).strip()
264
+ if body:
265
+ # Reached flagged or side-thread only with raw: an error
266
+ # notice is the harness's, and a side-thread's reply is the
267
+ # subagent's, never the session's own assistant.
268
+ role = (
269
+ "harness"
270
+ if FLT.harness_flagged(o)
271
+ else "subagent"
272
+ if o.get("isSidechain")
273
+ else "assistant"
274
+ )
275
+ out.append((role, body, ts, None))
276
+ return out if origins else [row[:3] for row in out]
277
+
278
+
279
+ def web_records(path, *, thinking=False, raw=False, **_):
280
+ """``(role, text, ts)`` turns from a claude.ai ``*.raw.json`` dialogue.
281
+
282
+ ``ts`` is the message's ISO-8601 ``created_at`` (or ``None`` if absent).
283
+ """
284
+ o = json.loads(Path(path).read_text(encoding="utf-8", errors="replace"))
285
+ out = []
286
+ for m in o.get("chat_messages", []):
287
+ sender = m.get("sender")
288
+ role = "human" if sender == "human" else "assistant"
289
+ blocks = m.get("content") or []
290
+ txt = " ".join(
291
+ b.get("text", "") for b in blocks if b.get("type") == "text"
292
+ ) or m.get("text", "")
293
+ if role == "human":
294
+ turn = _human_slot(txt, raw)
295
+ if turn:
296
+ out.append((*turn, m.get("created_at")))
297
+ continue
298
+ txt = (txt or "").strip()
299
+ # Web captures DO persist thinking text (unlike most cc sessions), so
300
+ # this is the source where --thinking actually recovers content.
301
+ if thinking:
302
+ th = "\n\n".join(
303
+ (b.get("thinking") or b.get("text") or "").strip()
304
+ for b in blocks
305
+ if b.get("type") == "thinking"
306
+ if (b.get("thinking") or b.get("text") or "").strip()
307
+ )
308
+ if th:
309
+ txt = f"[thinking]\n{th}\n\n{txt}".strip()
310
+ if txt:
311
+ out.append((role, txt, m.get("created_at")))
312
+ return out
313
+
314
+
315
+ def agy_records(path, *, thinking=False, raw=False, **_):
316
+ """``(role, text, ts)`` turns from an Antigravity session JSONL.
317
+
318
+ ``ts`` is the record's ISO-8601 ``created_at`` (or ``None`` if absent).
319
+ """
320
+ out = []
321
+ with open(path, encoding="utf-8", errors="replace") as fh:
322
+ for line in fh:
323
+ line = line.strip()
324
+ if not line:
325
+ continue
326
+ try:
327
+ o = json.loads(line)
328
+ except json.JSONDecodeError:
329
+ continue
330
+ typ = o.get("type")
331
+ ts = o.get("created_at")
332
+ if typ == "USER_INPUT":
333
+ c = o.get("content") or ""
334
+ m = re.search(r"<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>", c, re.DOTALL)
335
+ turn = _human_slot(m.group(1) if m else c, raw)
336
+ if turn:
337
+ out.append((*turn, ts))
338
+ elif typ == "PLANNER_RESPONSE":
339
+ parts = []
340
+ if thinking and o.get("thinking"):
341
+ parts.append("[thinking]\n" + o["thinking"].strip())
342
+ c = o.get("content")
343
+ if isinstance(c, str) and c.strip():
344
+ parts.append(c.strip())
345
+ body = "\n\n".join(parts).strip()
346
+ if body:
347
+ out.append(("assistant", body, ts))
348
+ return out
349
+
350
+
351
+ def codex_records(path, *, thinking=False, raw=False, **_):
352
+ """Dialogue from a Codex rollout, with pseudo-user records relabeled.
353
+
354
+ The injected AGENTS/environment message is omitted (or shown as
355
+ ``harness`` under ``--raw``). A subagent's parent-written task is ``agent``,
356
+ never ``human``; that distinction is what keeps search/input evidentiary.
357
+ """
358
+ return CODEX_FLT.records(path, thinking=thinking, raw=raw)
359
+
360
+
361
+ _READERS = {
362
+ "cc": cc_records,
363
+ "agy": agy_records,
364
+ "codex": codex_records,
365
+ "web": web_records,
366
+ }
367
+
368
+ # --------------------------------------------------------------------------- #
369
+ # red-pen rounds: the author's marks on a rendered copy of the book
370
+ # --------------------------------------------------------------------------- #
371
+ # A round archived by `publish redpen archive` is not a conversation, but every
372
+ # mark in it is the author's own words, typed on the page. It is read as a
373
+ # session of author turns so the tools that follow the author's words (the
374
+ # trail, its backfill) can reach them. The marks channel holds each comment,
375
+ # tracked edit and review-notes edit as a blank-line-separated entry; the
376
+ # verbatim channel after it repeats the review-notes blocks and is not read.
377
+ _REDPEN_MARKS = re.compile(r"^## MARKS\b.*$", re.M)
378
+ _REDPEN_END = re.compile(r"^## (?!MARKS\b)", re.M)
379
+ _REDPEN_DATED = re.compile(r"^marks-dated:\s*(\d{4}-\d{2}-\d{2})", re.M)
380
+ _REDPEN_BYLINE = re.compile(r"^\[[^\]\n]+\]\s+(\d{4}-\d{2}-\d{2})\b")
381
+
382
+
383
+ def is_redpen_round(path):
384
+ """Whether ``path`` is an archived red-pen round rather than its README."""
385
+ try:
386
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
387
+ except OSError:
388
+ return False
389
+ return bool(_REDPEN_MARKS.search(text))
390
+
391
+
392
+ def redpen_marks(path):
393
+ """``(text, day)`` for each mark in a red-pen round, in the archive's order.
394
+
395
+ ``day`` is the date a comment carries in its byline, or the round's own
396
+ ``marks-dated``; the archive records no time of day for any mark.
397
+ """
398
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
399
+ start = _REDPEN_MARKS.search(text)
400
+ if not start:
401
+ return []
402
+ body = text[start.end() :]
403
+ end = _REDPEN_END.search(body)
404
+ body = body[: end.start()] if end else body
405
+ dated = _REDPEN_DATED.search(text)
406
+ round_day = date.fromisoformat(dated.group(1)) if dated else None
407
+ out = []
408
+ for block in re.split(r"\n\s*\n", body):
409
+ block = block.strip("\n")
410
+ if not block.strip() or block.lstrip().startswith("### "):
411
+ continue
412
+ byline = _REDPEN_BYLINE.match(block)
413
+ day = date.fromisoformat(byline.group(1)) if byline else round_day
414
+ out.append((block, day))
415
+ return out
416
+
417
+
418
+ def _redpen_title(path):
419
+ for line in Path(path).read_text(encoding="utf-8", errors="replace").splitlines():
420
+ if line.startswith("# "):
421
+ return line[2:].strip()
422
+ return Path(path).stem
423
+
424
+
425
+ def author_turns(kind, path):
426
+ """``(number, logged, text)`` for each of the author's turns in a session.
427
+
428
+ Numbered as ``render`` numbers them, and so as search does. ``logged`` is
429
+ the aware instant the log recorded, or ``None`` where it recorded none: a
430
+ pre-rendered gemini review never does, and its ``## You`` sections are the
431
+ turns, each counted even when empty.
432
+ """
433
+ return [(n, logged, text) for n, logged, text, _ in exchanges(kind, path)]
434
+
435
+
436
+ def turn_origins(kind, path):
437
+ """How each of the author's turns reached the log, in ``author_turns`` order.
438
+
439
+ One of ``ORIGINS``. They are told apart only in a Claude Code log, the
440
+ one source whose reader learned new paths after it was first used; every
441
+ turn of another source is ``typed``, a Codex answer included.
442
+ """
443
+ if kind != "cc":
444
+ return ["typed"] * len(exchanges(kind, path))
445
+ return [row[3] for row in cc_records(path, origins=True) if row[0] == "human"]
446
+
447
+
448
+ def exchanges(kind, path):
449
+ """``(number, logged, text, reply)``: each author turn and what it answered.
450
+
451
+ ``reply`` is the model's text between the author's previous turn and this
452
+ one, the reply the turn was answering, or empty where there was none. It
453
+ is the context a bare "yes, go" needs before it means anything. The turns
454
+ are exactly ``author_turns``'s, which is built from this.
455
+ """
456
+ if kind == "redpen":
457
+ return [
458
+ (n, None, text, "") for n, (text, _) in enumerate(redpen_marks(path), 1)
459
+ ]
460
+ if kind == "rulings":
461
+ return [
462
+ (n, None, text, presented)
463
+ for n, (text, presented) in enumerate(RULINGS.read(path).turns(), 1)
464
+ ]
465
+ if kind == "gemini":
466
+ lines = Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
467
+ starts = [i for i, line in enumerate(lines) if _GEMINI_SPEAKER.match(line)]
468
+ ends = dict(zip(starts, starts[1:] + [len(lines)]))
469
+ out, number, reply = [], 0, []
470
+ for i in starts:
471
+ body = "\n".join(lines[i + 1 : ends[i]]).strip()
472
+ if _GEMINI_SPEAKER.match(lines[i]).group(1) == "You":
473
+ number += 1
474
+ out.append((number, None, body, "\n\n".join(reply)))
475
+ reply = []
476
+ elif body:
477
+ reply.append(body)
478
+ return out
479
+ out, number, reply = [], 0, []
480
+ for role, text, ts in _READERS[kind](path):
481
+ if role == "human":
482
+ number += 1
483
+ out.append((number, _parse_ts(ts), text, "\n\n".join(reply)))
484
+ reply = []
485
+ elif role == "assistant":
486
+ reply.append(text)
487
+ return out
488
+
489
+
490
+ # --------------------------------------------------------------------------- #
491
+ # resolution: short id / full id / path -> (kind, path)
492
+ # --------------------------------------------------------------------------- #
493
+ def _jsonl_kind(p):
494
+ """``cc`` / ``agy`` / ``codex`` by the store family holding it, or by shape."""
495
+ family = store.family_of(p)
496
+ if family in (store.AGY, store.CC, store.CODEX):
497
+ return store.KIND_OF[family]
498
+ with p.open(encoding="utf-8", errors="replace") as fh:
499
+ for line in fh:
500
+ line = line.strip()
501
+ if not line:
502
+ continue
503
+ try:
504
+ rec = json.loads(line)
505
+ except json.JSONDecodeError:
506
+ break
507
+ if isinstance(rec, dict) and "step_index" in rec:
508
+ return "agy"
509
+ if (
510
+ isinstance(rec, dict)
511
+ and rec.get("type") == "session_meta"
512
+ and isinstance(rec.get("payload"), dict)
513
+ ):
514
+ return "codex"
515
+ return "cc"
516
+ return "cc"
517
+
518
+
519
+ def _candidates(idarg):
520
+ """All (kind, path) whose id prefix-matches ``idarg``, across the sources."""
521
+ p = Path(idarg)
522
+ if p.is_file():
523
+ name = p.name
524
+ if name.endswith(".jsonl"):
525
+ # Both session corpora use `.jsonl`, so the suffix cannot say which
526
+ # reader to use -- the directory can. Read an agy transcript with the
527
+ # cc reader and it yields no turns at all: an empty transcript that
528
+ # looks like a session in which nothing was said.
529
+ return [(_jsonl_kind(p), p)]
530
+ if name.endswith(".raw.json"):
531
+ return [("web", p)]
532
+ if name.endswith(".md"):
533
+ redpen = _redpen_directory()
534
+ if redpen and p.resolve().parent == redpen.resolve():
535
+ return [("redpen", p)]
536
+ rulings = RULINGS.directory()
537
+ if rulings and p.resolve().parent == rulings.resolve():
538
+ return [("rulings", p)]
539
+ return [("gemini", p)]
540
+ return [
541
+ (kind, path)
542
+ for kind, path in sessions(pending=True)
543
+ if session_id(kind, path).startswith(idarg)
544
+ ]
545
+
546
+
547
+ # Each family's sessions as `show` names them. A web conversation's session is
548
+ # its raw export; a gemini review's is its rendered transcript.
549
+ _FAMILIES = (
550
+ ("cc", store.CC, "*.jsonl"),
551
+ ("agy", store.AGY, "*.jsonl"),
552
+ ("codex", store.CODEX, "*.jsonl"),
553
+ ("web", store.WEB, "*.raw.json"),
554
+ ("gemini", store.WEB, "gemini-*.md"),
555
+ )
556
+
557
+
558
+ def sessions(pending=False):
559
+ """Every session as ``(kind, path)``, family by family.
560
+
561
+ The accepted corpus, which is what search, metrics and the catalog read.
562
+ With ``pending``, a session captured since it was accepted is read at its
563
+ pending capture instead, and one never accepted joins: that is the view an
564
+ author reviewing a capture needs, and a trail following a turn made after
565
+ the last acceptance.
566
+ """
567
+ found = []
568
+ for kind, family, pattern in _FAMILIES:
569
+ chosen = {p.name: p for p in store.files(family, pattern)}
570
+ if pending:
571
+ chosen.update({p.name: p for p in store.pending(family, pattern)})
572
+ found += [(kind, chosen[name]) for name in sorted(chosen)]
573
+ redpen = _redpen_directory()
574
+ if redpen is not None:
575
+ found += [
576
+ ("redpen", f) for f in sorted(redpen.glob("*.md")) if is_redpen_round(f)
577
+ ]
578
+ found += [("rulings", f) for f in RULINGS.rounds()]
579
+ return found
580
+
581
+
582
+ def _redpen_directory():
583
+ """The red-pen archive, only where the project declares one.
584
+
585
+ The rounds are private and their author's, so they join the sessions
586
+ only by the project's explicit ``provenance.redpen``, never by default.
587
+ """
588
+ project = current_project()
589
+ if project.get("provenance.redpen") is None:
590
+ return None
591
+ return project.path("provenance.redpen")
592
+
593
+
594
+ REDPEN_PREFIX = "redpen-"
595
+
596
+
597
+ def session_id(kind, path):
598
+ """The full id a session is known by: its file name without the suffix.
599
+
600
+ A red-pen round is named for its archive file with a ``redpen-`` prefix,
601
+ and a review-sheet ruling with ``rulings-``, as a gemini review's file
602
+ already carries ``gemini-``, so the id says what it names wherever it is
603
+ printed.
604
+ """
605
+ name = Path(path).name
606
+ if kind == "web":
607
+ return name[: -len(".raw.json")]
608
+ if kind == "redpen":
609
+ return REDPEN_PREFIX + Path(path).stem
610
+ if kind == "rulings":
611
+ return RULINGS.session_id(path)
612
+ return Path(path).stem
613
+
614
+
615
+ def resolve(idarg):
616
+ """``(kind, path)`` for an id, or raise ``LookupError`` (none / ambiguous)."""
617
+ found = _candidates(idarg)
618
+ if not found:
619
+ raise LookupError(f"no session matches '{idarg}'")
620
+ if len(found) > 1:
621
+ names = ", ".join(f"{k}:{pp.name}" for k, pp in found)
622
+ raise LookupError(f"'{idarg}' is ambiguous ({len(found)} matches): {names}")
623
+ return found[0]
624
+
625
+
626
+ # --------------------------------------------------------------------------- #
627
+ # rendering
628
+ # --------------------------------------------------------------------------- #
629
+ def session_title(kind, path):
630
+ """A session's title as its transcript header shows it, best-effort."""
631
+ return _header(kind, Path(path))[1]
632
+
633
+
634
+ def _header(kind, path):
635
+ """``(short_id, title, date, model)`` for the transcript header, best-effort."""
636
+ if kind == "cc":
637
+ meta = CAT._cc_scan(path)
638
+ return CAT._short_id(path.stem), meta["title"], meta["date"], meta["model"]
639
+ if kind == "agy":
640
+ meta = CAT._agy_scan(path)
641
+ return CAT._short_id(path.stem), meta["title"], meta["date"], meta["model"]
642
+ if kind == "codex":
643
+ meta = CODEX_FLT.scan(path)
644
+ return CAT._short_id(path.stem), meta["title"], meta["date"], meta["model"]
645
+ if kind == "web":
646
+ base = path.name[: -len(".raw.json")]
647
+ # Accepted together, the rendered transcript sits beside its export.
648
+ md = path.parent / (base + ".md")
649
+ if not md.exists():
650
+ md = store.newest(store.WEB, base + ".md") or md
651
+ meta = (
652
+ CAT._web_scan(md)
653
+ if md.exists()
654
+ else {"title": "(untitled)", "date": "?", "model": "?"}
655
+ )
656
+ return base, meta["title"], meta["date"], meta.get("model", "?")
657
+ if kind == "redpen":
658
+ day = session_date(kind, path)
659
+ return (
660
+ session_id(kind, path),
661
+ _redpen_title(path),
662
+ day.isoformat() if day else "?",
663
+ "red-pen round",
664
+ )
665
+ if kind == "rulings":
666
+ day = session_date(kind, path)
667
+ return (
668
+ session_id(kind, path),
669
+ RULINGS.read(path).title,
670
+ day.isoformat() if day else "?",
671
+ "review-sheet ruling",
672
+ )
673
+ meta = (
674
+ CAT._web_scan(path)
675
+ if path.exists()
676
+ else {"title": "(untitled)", "date": "?", "model": "?"}
677
+ )
678
+ return path.stem, meta["title"], meta["date"], meta.get("model", "?")
679
+
680
+
681
+ def _parse_ts(ts):
682
+ """ISO-8601 stamp (``Z`` or offset) -> aware ``datetime``, or ``None``."""
683
+ if not ts:
684
+ return None
685
+ try:
686
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
687
+ except (ValueError, AttributeError):
688
+ return None
689
+
690
+
691
+ def _fmt_dur(seconds):
692
+ """Human-compact duration: ``42s`` / ``6m21s`` / ``2h05m``."""
693
+ seconds = int(seconds)
694
+ h, rem = divmod(seconds, 3600)
695
+ m, s = divmod(rem, 60)
696
+ if h:
697
+ return f"{h}h{m:02d}m"
698
+ if m:
699
+ return f"{m}m{s:02d}s"
700
+ return f"{s}s"
701
+
702
+
703
+ def _local(dt, tz):
704
+ """``dt`` in the zone ``tz``, or the machine's local zone when it is ``None``."""
705
+ return dt.astimezone(tz)
706
+
707
+
708
+ def session_start(kind, path):
709
+ """When a session's first turn was logged, as the transcript header gives it.
710
+
711
+ ``None`` for a pre-rendered gemini review, or a log that records no times.
712
+ """
713
+ if kind not in _READERS:
714
+ return None
715
+ dts = [dt for dt in (_parse_ts(ts) for _, _, ts in _READERS[kind](path)) if dt]
716
+ return min(dts) if dts else None
717
+
718
+
719
+ def session_date(kind, path):
720
+ """The UTC day a session began, or ``None`` where nothing records one.
721
+
722
+ A log's first logged time decides it. A pre-rendered gemini review logs no
723
+ times, but its header carries the date the author supplied at capture; a
724
+ red-pen round carries the date of its marks. Those dates are how a
725
+ timeless session takes its place in date order instead of sorting first.
726
+ """
727
+ start = session_start(kind, path)
728
+ if start is not None:
729
+ return start.astimezone(timezone.utc).date()
730
+ if kind == "redpen":
731
+ days = [day for _, day in redpen_marks(path) if day]
732
+ return min(days) if days else None
733
+ if kind == "rulings":
734
+ return RULINGS.read(path).day()
735
+ if kind == "gemini":
736
+ written = CAT._web_scan(Path(path)).get("date") or ""
737
+ try:
738
+ return date.fromisoformat(written[:10])
739
+ except ValueError:
740
+ return None
741
+ return None
742
+
743
+
744
+ def shown_date(dt, utc=False):
745
+ """``dt``'s calendar date in the zone a transcript is shown in."""
746
+ return _local(dt, timezone.utc if utc else None).strftime("%Y-%m-%d")
747
+
748
+
749
+ def _span_line(dts, date, model, nturns, tz):
750
+ """The header meta line: time span + duration + model + turn count.
751
+
752
+ Falls back to the bare ``(date)`` the catalog scan found when no turn
753
+ carried a parseable timestamp (e.g. a gemini review or a stampless log).
754
+ """
755
+ tail = f"{model} · {nturns} turns"
756
+ if not dts:
757
+ return f"({date}) · {tail}"
758
+ lo, hi = _local(min(dts), tz), _local(max(dts), tz)
759
+ zone = lo.strftime("%Z") or ("UTC" if tz else "local")
760
+ start = lo.strftime("%Y-%m-%d %H:%M")
761
+ end = hi.strftime("%H:%M" if lo.date() == hi.date() else "%Y-%m-%d %H:%M")
762
+ dur = _fmt_dur((hi - lo).total_seconds())
763
+ return f"{start} → {end} {zone} · {dur} · {tail}"
764
+
765
+
766
+ def _stamp_rows(shown, tz, session):
767
+ """``(role, text, dt, stamp, number)`` rows from ``(role, text, dt, number)``.
768
+
769
+ The date is printed when it rolls over from the prior turn, and wherever
770
+ the time alone could fall on another day of ``session`` too, which happens
771
+ only in a session logged over more than a day. So every printed stamp read
772
+ back as ``--from`` / ``--to`` names its own turn. Stamps are computed over
773
+ the whole shown list before any time window is applied, so a turn prints
774
+ the same stamp however the window is cut.
775
+ """
776
+ rows, prev_day = [], None
777
+ for r, text, dt, number in shown:
778
+ stamp = ""
779
+ if dt is not None:
780
+ loc = _local(dt, tz)
781
+ day = loc.strftime("%Y-%m-%d")
782
+ clock = loc.time().replace(microsecond=0)
783
+ bare = (
784
+ day == prev_day and len(_bare_spans(clock, _SECOND, tz, session)) == 1
785
+ )
786
+ stamp = loc.strftime("%H:%M:%S" if bare else "%Y-%m-%d %H:%M:%S")
787
+ prev_day = day
788
+ rows.append((r, text, dt, stamp, number))
789
+ return rows
790
+
791
+
792
+ _SECOND = timedelta(seconds=1)
793
+
794
+ # A mark: a date, a time, or a date and a time; a time may name its zone.
795
+ _MARK = re.compile(
796
+ r"(?:(?P<date>\d{4}-\d{2}-\d{2})(?:[ T](?=\d)|$))?"
797
+ r"(?:(?P<hour>\d{1,2}):(?P<minute>\d{2})"
798
+ r"(?::(?P<second>\d{2})(?:\.(?P<fraction>\d{1,6}))?)?"
799
+ r"(?P<zone>Z|[+-]\d{2}:?\d{2})?)?"
800
+ )
801
+
802
+
803
+ def _offset(zone):
804
+ """The fixed zone a mark names: ``Z`` or ``+HH:MM`` / ``-HHMM``."""
805
+ if zone == "Z":
806
+ return timezone.utc
807
+ sign = -1 if zone[0] == "-" else 1
808
+ digits = zone[1:].replace(":", "")
809
+ return timezone(sign * timedelta(hours=int(digits[:2]), minutes=int(digits[2:])))
810
+
811
+
812
+ def _zoned(naive, tz):
813
+ """A wall-clock ``naive`` datetime read in ``tz``, or in the machine's zone."""
814
+ return naive.astimezone() if tz is None else naive.replace(tzinfo=tz)
815
+
816
+
817
+ def _days(session, tz):
818
+ """The calendar days, in ``tz``, that ``session`` was logged on."""
819
+ first, last = (_local(dt, tz).date() for dt in session)
820
+ return [first + timedelta(days=n) for n in range((last - first).days + 1)]
821
+
822
+
823
+ def _bare_spans(clock, unit, tz, session):
824
+ """``(day, start, end)`` for each day a time without a date could name.
825
+
826
+ ``session`` is the ``(first, last)`` instants the whole session was logged
827
+ at, and days are counted in ``tz``. A session on one day offers that day.
828
+ Across midnight, a day qualifies only if the session was running at
829
+ ``clock`` on it, so more than one qualifies only in a session logged over
830
+ more than a day.
831
+ """
832
+ spans = []
833
+ for day in _days(session, tz):
834
+ start = datetime.combine(day, clock)
835
+ spans.append((day, _zoned(start, tz), _zoned(start + unit, tz)))
836
+ if len(spans) > 1:
837
+ spans = [(d, s, e) for d, s, e in spans if s <= session[1] and e > session[0]]
838
+ return spans
839
+
840
+
841
+ def _mark_span(flag, mark, tz, session):
842
+ """``(start, end)``: the aware instants a ``--from`` / ``--to`` mark covers.
843
+
844
+ A mark covers the whole unit it is written to, so ``15:52`` is the minute
845
+ up to ``15:52:59``, ``2026-09-16`` is the day, and a log's own stamp such
846
+ as ``2026-09-16T15:52:10.123Z`` is its millisecond. It is read in ``tz``,
847
+ the zone the stamps are shown in, unless it names its own with ``Z`` or
848
+ ``+HH:MM``; then it means the same moment on any machine. A bare time takes
849
+ its date from ``session`` as ``_bare_spans`` describes, counting days in
850
+ the mark's zone. Raises ``LookupError`` when the mark is not a time, or
851
+ when a bare time fits no single day.
852
+ """
853
+ m = _MARK.fullmatch(mark.strip())
854
+ try:
855
+ if not m or not (m["date"] or m["hour"]):
856
+ raise ValueError
857
+ day = date.fromisoformat(m["date"]) if m["date"] else None
858
+ fraction = m["fraction"] or ""
859
+ clock = time(
860
+ int(m["hour"] or 0),
861
+ int(m["minute"] or 0),
862
+ int(m["second"] or 0),
863
+ int(fraction.ljust(6, "0")),
864
+ )
865
+ if m["zone"]:
866
+ tz = _offset(m["zone"])
867
+ except ValueError:
868
+ raise LookupError(
869
+ f"{flag} {mark!r} is not a time; write HH:MM[:SS], optionally "
870
+ "after a date as 'YYYY-MM-DD HH:MM[:SS]' and before a zone as "
871
+ "Z or +HH:MM"
872
+ ) from None
873
+ if fraction:
874
+ unit = timedelta(microseconds=10 ** (6 - len(fraction)))
875
+ elif m["second"]:
876
+ unit = _SECOND
877
+ elif m["hour"]:
878
+ unit = timedelta(minutes=1)
879
+ else:
880
+ unit = timedelta(days=1)
881
+ if day:
882
+ start = datetime.combine(day, clock)
883
+ return _zoned(start, tz), _zoned(start + unit, tz)
884
+
885
+ spans = _bare_spans(clock, unit, tz, session)
886
+ if len(spans) != 1:
887
+ # None qualifies when the session was not running at that time on any
888
+ # of its days; then each of them is as good a guess as the other.
889
+ days = [d for d, _, _ in spans] or _days(session, tz)
890
+ first, last = (_local(dt, tz) for dt in session)
891
+ options = " or ".join(f"'{d:%Y-%m-%d} {mark.strip()}'" for d in days)
892
+ raise LookupError(
893
+ f"{flag} {mark!r} could fall on more than one day of a session "
894
+ f"logged from {first:%Y-%m-%d %H:%M:%S} to "
895
+ f"{last:%Y-%m-%d %H:%M:%S}; put the date in front: {options}"
896
+ )
897
+ return spans[0][1:]
898
+
899
+
900
+ def _window(rows, frm, to, tz, session):
901
+ """The ``rows`` whose logged time falls between ``--from`` and ``--to``.
902
+
903
+ Both bounds are inclusive. Returns ``(kept, unstamped)``, where
904
+ ``unstamped`` counts shown turns left out because no time was logged for
905
+ them. Raises ``LookupError`` if the session has no times at all, if the
906
+ bounds cross, or if no shown turn falls inside them.
907
+ """
908
+ if session is None:
909
+ raise LookupError("--from/--to need per-turn timestamps; this log has none")
910
+ lo = _mark_span("--from", frm, tz, session)[0] if frm else None
911
+ hi = _mark_span("--to", to, tz, session)[1] if to else None
912
+ if lo is not None and hi is not None and lo >= hi:
913
+ raise LookupError(f"--from {frm!r} falls after --to {to!r} (empty range)")
914
+ kept = [
915
+ row
916
+ for row in rows
917
+ if row[2] is not None
918
+ and (lo is None or row[2] >= lo)
919
+ and (hi is None or row[2] < hi)
920
+ ]
921
+ if not kept:
922
+ bounds = (f" from {frm!r}" if frm else "") + (f" to {to!r}" if to else "")
923
+ raise LookupError(f"no shown turn falls in the window{bounds}")
924
+ return kept, sum(1 for row in rows if row[2] is None)
925
+
926
+
927
+ def _turn_range(spec, count):
928
+ """``(first, last)`` author-turn numbers named by ``--turn N`` or ``N-M``.
929
+
930
+ Raises ``LookupError`` unless the numbers are a range within the
931
+ ``count`` turns the author took in the session.
932
+ """
933
+ m = re.fullmatch(r"\s*(\d+)\s*(?:-\s*(\d+)\s*)?", spec)
934
+ if not m:
935
+ raise LookupError(f"--turn {spec!r} is not a turn number; write N or N-M")
936
+ first, last = int(m.group(1)), int(m.group(2) or m.group(1))
937
+ if first < 1 or last < first:
938
+ raise LookupError(f"--turn {spec!r} is not a range of turns counted from 1")
939
+ if last > count:
940
+ took = f"{count} turn{'' if count == 1 else 's'}" if count else "no turns"
941
+ raise LookupError(f"--turn {spec!r}: the author took {took} in this session")
942
+ return first, last
943
+
944
+
945
+ # A gemini review's speaker lines; its replies carry `## ` headings of their own.
946
+ _GEMINI_SPEAKER = re.compile(r"^##\s+(You|Gemini)\s+·")
947
+
948
+
949
+ def _gemini_turns(body, spec):
950
+ """The lines of a pre-rendered gemini review from ``--turn`` ``spec``.
951
+
952
+ The author's turns are its ``## You`` sections, numbered as ``search``
953
+ numbers them: every section counts, even one with nothing left to say.
954
+ """
955
+ lines = body.splitlines()
956
+ starts = [i for i, line in enumerate(lines) if _GEMINI_SPEAKER.match(line)]
957
+ yours = [i for i in starts if _GEMINI_SPEAKER.match(lines[i]).group(1) == "You"]
958
+ first, last = _turn_range(spec, len(yours))
959
+ end = yours[last] if last < len(yours) else len(lines)
960
+ return "\n".join(lines[yours[first - 1] : end]).rstrip()
961
+
962
+
963
+ _GEMINI_ROLE = {"human": "You", "assistant": "Gemini"}
964
+
965
+
966
+ def _gemini_role(body, role):
967
+ """Only the speaker sections of a gemini review that ``role`` names.
968
+
969
+ The review is pre-rendered, so its sections are its speakers: ``## You``
970
+ is the author and ``## Gemini`` the model. It holds nothing an agent or
971
+ the harness wrote, so those roles leave it empty.
972
+ """
973
+ speaker = _GEMINI_ROLE.get(role)
974
+ lines = body.splitlines()
975
+ starts = [i for i, line in enumerate(lines) if _GEMINI_SPEAKER.match(line)]
976
+ kept = []
977
+ for i, end in zip(starts, starts[1:] + [len(lines)]):
978
+ if speaker and _GEMINI_SPEAKER.match(lines[i]).group(1) == speaker:
979
+ kept += lines[i:end]
980
+ return "\n".join(kept).rstrip()
981
+
982
+
983
+ def _source(path):
984
+ """The source line, which says when the capture has not been accepted."""
985
+ pending = store.area(path) == "pending"
986
+ return f"<source: {Path(path).name}{' · pending, not accepted' if pending else ''}>"
987
+
988
+
989
+ def render(
990
+ kind,
991
+ path,
992
+ *,
993
+ role="all",
994
+ thinking=False,
995
+ raw=False,
996
+ utc=False,
997
+ frm=None,
998
+ to=None,
999
+ turn=None,
1000
+ ):
1001
+ """The full transcript as one string."""
1002
+ if turn and (frm or to):
1003
+ raise LookupError("--turn names turns by number; drop --from/--to")
1004
+ sid, title, date, model = _header(kind, path)
1005
+ scope = f"turn{'s' * ('-' in turn)} {turn.strip()}" if turn else None
1006
+
1007
+ if kind == "gemini":
1008
+ if frm or to:
1009
+ raise LookupError(
1010
+ "--from/--to need per-turn timestamps; the gemini "
1011
+ "reviews are pre-rendered and carry none"
1012
+ )
1013
+ # The gemini .md is already a rendered transcript; pass it through.
1014
+ body = Path(path).read_text(encoding="utf-8", errors="replace").rstrip()
1015
+ head = f"# {sid} — {title} ({date})"
1016
+ if turn:
1017
+ body = _gemini_turns(body, turn)
1018
+ head += f" · {scope}"
1019
+ if role != "all":
1020
+ body = _gemini_role(body, role)
1021
+ return "\n".join([head, _source(path), "", body]) + "\n"
1022
+
1023
+ if kind == "redpen":
1024
+ if frm or to:
1025
+ raise LookupError(
1026
+ "--from/--to need per-turn timestamps; a red-pen round "
1027
+ "records only the day of each mark"
1028
+ )
1029
+ marks = redpen_marks(path)
1030
+ first, last = _turn_range(turn, len(marks)) if turn else (1, len(marks))
1031
+ head = f"# {sid} — {title} ({date})" + (f" · {scope}" if turn else "")
1032
+ lines = [head, _source(path), ""]
1033
+ if role in ("all", "human"):
1034
+ for n, (text, day) in enumerate(marks, 1):
1035
+ if first <= n <= last:
1036
+ stamp = f" · {day.isoformat()}" if day else ""
1037
+ lines += [f"## {ROLE_LABEL['human']} {n}{stamp}", "", text, ""]
1038
+ return "\n".join(lines).rstrip() + "\n"
1039
+
1040
+ if kind == "rulings":
1041
+ if frm or to:
1042
+ raise LookupError(
1043
+ "--from/--to need per-turn timestamps; a review-sheet ruling "
1044
+ "records one time, when it was marked final"
1045
+ )
1046
+ ruling = RULINGS.read(path)
1047
+ rows = exchanges(kind, path)
1048
+ first, last = _turn_range(turn, len(rows)) if turn else (1, len(rows))
1049
+ head = f"# {sid} — {title} ({date})" + (f" · {scope}" if turn else "")
1050
+ moment = ruling.instant()
1051
+ ruled = (
1052
+ f"ruled {_local(moment, timezone.utc if utc else None):%Y-%m-%d %H:%M %Z}"
1053
+ if moment
1054
+ else "ruled at an unrecorded time"
1055
+ )
1056
+ lines = [head, f"{model} · {ruled} · {len(rows)} turns", _source(path), ""]
1057
+ labels = [item.label for item in ruling.items] + ["notes"]
1058
+ for n, _logged, text, presented in rows:
1059
+ if not first <= n <= last:
1060
+ continue
1061
+ if presented and role in ("all", "assistant"):
1062
+ lines += [f"## Sheet · {labels[n - 1]}", "", presented, ""]
1063
+ if role in ("all", "human"):
1064
+ lines += [
1065
+ f"## {ROLE_LABEL['human']} {n} · {labels[n - 1]}",
1066
+ "",
1067
+ text,
1068
+ "",
1069
+ ]
1070
+ return "\n".join(lines).rstrip() + "\n"
1071
+
1072
+ # Each turn carries the number of the author's latest turn at or before
1073
+ # it: their own on an author's turn, and theirs on the replies that follow.
1074
+ # Counted over the whole session before --role, it is search's numbering.
1075
+ records, number = [], 0
1076
+ for r, text, ts in _READERS[kind](path, thinking=thinking, raw=raw):
1077
+ number += r == "human"
1078
+ records.append((r, text, _parse_ts(ts), number))
1079
+ shown = [rec for rec in records if role == "all" or rec[0] == role]
1080
+ # A bare time picks its day from the whole session, never from the shown
1081
+ # turns, so --role cannot change what a time means or how it is printed.
1082
+ logged = [rec[2] for rec in records if rec[2]]
1083
+ session = (min(logged), max(logged)) if logged else None
1084
+ tz = timezone.utc if utc else None
1085
+ rows = _stamp_rows(shown, tz, session)
1086
+ if frm or to:
1087
+ rows, unstamped = _window(rows, frm, to, tz, session)
1088
+ if turn:
1089
+ first, last = _turn_range(turn, number)
1090
+ rows = [row for row in rows if first <= row[4] <= last]
1091
+ if not rows:
1092
+ raise LookupError(f"--turn {turn!r} holds no {role} turn")
1093
+ dts = [row[2] for row in rows if row[2]]
1094
+
1095
+ span = _span_line(dts, date, model, len(rows), tz)
1096
+ if frm or to:
1097
+ span += " · slice"
1098
+ if unstamped:
1099
+ noun = "turn" if unstamped == 1 else "turns"
1100
+ span += f" ({unstamped} {noun} with no time left out)"
1101
+ if turn:
1102
+ span += f" · {scope}"
1103
+ lines = [f"# {sid} — {title}", span, _source(path), ""]
1104
+ for r, text, _dt, stamp, n in rows:
1105
+ label = f"{ROLE_LABEL[r]} {n}" if r == "human" else ROLE_LABEL[r]
1106
+ lines.append(f"## {label}" + (f" · {stamp}" if stamp else ""))
1107
+ lines.append("")
1108
+ lines.append(text)
1109
+ lines.append("")
1110
+ return "\n".join(lines).rstrip() + "\n"
1111
+
1112
+
1113
+ def main(argv=None):
1114
+ ap = argparse.ArgumentParser(
1115
+ prog="quirework provenance show",
1116
+ description="Render a provenance session as a clean transcript.",
1117
+ )
1118
+ ap.add_argument("session", help="short id, full id, gemini-… name, or path")
1119
+ ap.add_argument(
1120
+ "--role",
1121
+ choices=["all", "human", "assistant", "agent", "subagent", "harness"],
1122
+ default="all",
1123
+ help="restrict to one writer (default: all): human is the author; "
1124
+ "assistant is the model's reply; agent is a task one model wrote "
1125
+ "for a subagent, and subagent is what the subagent wrote back; "
1126
+ "harness is text the tool injected; agent, subagent and harness are "
1127
+ "shown only with --raw",
1128
+ )
1129
+ ap.add_argument(
1130
+ "--thinking",
1131
+ action="store_true",
1132
+ help="include assistant thinking where the capture holds its "
1133
+ "text: web captures and agy sessions do; cc sessions "
1134
+ "only for direct-API runs (subscription ones persist "
1135
+ "an opaque signature, so there it is usually empty)",
1136
+ )
1137
+ ap.add_argument(
1138
+ "--raw",
1139
+ action="store_true",
1140
+ help="keep the session as logged: harness wrappers stay on human "
1141
+ "turns, and injected text and subagent threads appear under the "
1142
+ "harness and agent roles",
1143
+ )
1144
+ ap.add_argument(
1145
+ "--utc",
1146
+ action="store_true",
1147
+ help="stamp turns in UTC (default: the machine's local zone)",
1148
+ )
1149
+ ap.add_argument(
1150
+ "--from",
1151
+ dest="frm",
1152
+ metavar="TIME",
1153
+ default=None,
1154
+ help="show only turns logged at or after TIME: HH:MM[:SS], or "
1155
+ "'YYYY-MM-DD HH:MM[:SS]' when the session crosses midnight. TIME "
1156
+ "is read in the zone the turns are stamped in, so pair it with "
1157
+ "the same --utc setting, unless it ends in its own zone as Z or "
1158
+ "+HH:MM (as in --from 16:45:26Z); then it means the same moment "
1159
+ "on any machine",
1160
+ )
1161
+ ap.add_argument(
1162
+ "--to",
1163
+ dest="to",
1164
+ metavar="TIME",
1165
+ default=None,
1166
+ help="show only turns logged at or before TIME, which covers its "
1167
+ "whole last unit: --to 15:52 keeps a turn at 15:52:59",
1168
+ )
1169
+ ap.add_argument(
1170
+ "--turn",
1171
+ metavar="N[-M]",
1172
+ default=None,
1173
+ help="show the author's turn N, numbered from 1 as search numbers "
1174
+ "it, with the replies up to their next turn; N-M shows a run of "
1175
+ "them. Not combined with --from/--to",
1176
+ )
1177
+ ap.add_argument(
1178
+ "--out", type=Path, default=None, help="write to a file instead of stdout"
1179
+ )
1180
+ args = ap.parse_args(argv)
1181
+
1182
+ try:
1183
+ kind, path = resolve(args.session)
1184
+ except LookupError as e:
1185
+ print(str(e), file=sys.stderr)
1186
+ return 2
1187
+
1188
+ try:
1189
+ text = render(
1190
+ kind,
1191
+ path,
1192
+ role=args.role,
1193
+ thinking=args.thinking,
1194
+ raw=args.raw,
1195
+ utc=args.utc,
1196
+ frm=args.frm,
1197
+ to=args.to,
1198
+ turn=args.turn,
1199
+ )
1200
+ except LookupError as e:
1201
+ print(str(e), file=sys.stderr)
1202
+ return 2
1203
+ if args.out:
1204
+ args.out.parent.mkdir(parents=True, exist_ok=True)
1205
+ args.out.write_text(text, encoding="utf-8")
1206
+ print(f"wrote {args.out}", file=sys.stderr)
1207
+ else:
1208
+ sys.stdout.write(text)
1209
+ return 0
1210
+
1211
+
1212
+ if __name__ == "__main__":
1213
+ sys.exit(main(sys.argv[1:]))