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,1972 @@
1
+ """Backfilling a spec trail: packets for cheap readers, and a ledger of leads.
2
+
3
+ A trail kept from the start needs none of this. The agent that heard the
4
+ author adds each breadcrumb as it happens. A spec that grew before it had a
5
+ trail needs its history read, and a corpus of hundreds of sessions is too
6
+ large for a strong model to read whole. So the work is split by what it
7
+ costs. A cheap model reads each session once, as a compact packet, and flags
8
+ every author turn that might have shaped any section. A strong model then
9
+ works one section at a time through everything flagged for it, and decides.
10
+ Reading a session costs the same whether it is read for one section or for
11
+ all of them, which is why each session is read once, against the whole menu.
12
+
13
+ This module owns what sits between the two readers:
14
+
15
+ * the packet a reader is handed, and the menu it names sections from;
16
+ * a run plan that hands packets to readers through the run engine, one seat
17
+ per batch, so no reader's context grows from one batch to the next;
18
+ * the ledger: each reader's flags, imported and checked against the corpus,
19
+ and the ruling reader's rejections, so no later pass pays to read the same
20
+ candidate again.
21
+
22
+ A flag is a lead and never a breadcrumb; a breadcrumb is written with
23
+ ``trail add``. Only the ruling reader drops a candidate, and it records why.
24
+
25
+ The ledger lives where ``manuscript.spec_trail_leads`` puts it. It is as private as
26
+ the trail, and more so: a reader's reasons paraphrase the author's turns.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import functools
33
+ import hashlib
34
+ import json
35
+ import os
36
+ import re
37
+ import subprocess
38
+ import sys
39
+ import tempfile
40
+ from collections import Counter
41
+ from dataclasses import dataclass, field
42
+ from datetime import date, datetime, timezone
43
+ from pathlib import Path
44
+
45
+ from quirework.manuscript import notes, trail
46
+ from quirework.project import ConfigurationError, current_project
47
+
48
+ # Bump when a packet shows a reader something materially different, so the
49
+ # ledger can tell a session read from the current packet from an older one.
50
+ PACKET_VERSION = 1
51
+
52
+ # How much of the reply a turn answered is shown. The end is kept, because a
53
+ # reply closes on what it proposes, and that is what a bare "yes" accepts.
54
+ REPLY_CHARS = 1200
55
+ # A long turn is usually pasted material; its head and tail carry the
56
+ # author's own framing of it. The ruling reader opens the whole turn.
57
+ TURN_HEAD = 4000
58
+ TURN_TAIL = 1500
59
+ GIST_CHARS = 280
60
+ BATCH_TOKENS = 30000
61
+ # A seat's answer is bounded by its model's output limit, and its thinking
62
+ # counts against that limit. The token budget bounds it for conversations:
63
+ # across 55 conversation seats in six passes, none held more than 100 author
64
+ # turns, and none wrote more than 58,901 output tokens. Red-pen marks are
65
+ # short, so the same budget packed 124 of them into one seat, and its answer
66
+ # stopped mid-line at 64,853 of its model's 65,536. A cap on turns lowers that
67
+ # risk and cannot remove it. Thinking depends on what the turns hold: a round
68
+ # of 52 marks, alone in its seat, was cut off the same way, and both cut-off
69
+ # seats had stopped thinking within five tokens of 62,910, which no finished
70
+ # seat came near. Import reports such an answer, and a plan with a lower cap
71
+ # reads that session in parts.
72
+ SEAT_TURNS = 80
73
+ # A seat that flags almost nothing over many turns may have written its done
74
+ # lines without reading. Seats in one real pass flagged 12% to 79% of their
75
+ # author turns, by what the sessions were about, so the floor is absolute and
76
+ # low: it catches a seat that skipped, not one that read tooling sessions.
77
+ DILIGENCE_TURNS = 20
78
+ DILIGENCE_RATE = 0.05
79
+
80
+ CONFIDENCE = ("high", "medium", "low")
81
+ READ_KINDS = (*trail.KINDS, "unsure")
82
+
83
+ # The classes search's extractor gives a human turn, as a reader should read
84
+ # them: whether the words are the author's own is the question that matters.
85
+ _SHAPES = {
86
+ "TRIVIAL": "short",
87
+ "STRUCTURED": "document-shaped",
88
+ "RELAY": "mostly pasted",
89
+ "REVIEW": "comments on a quote",
90
+ "AMBIGUOUS": "long",
91
+ "TYPED": "typed",
92
+ }
93
+
94
+ # A review-sheet ruling is read like a session too, and its parts need the
95
+ # same telling: which words the author wrote and which were written for them.
96
+ RULINGS_NOTE = """
97
+ This is not a conversation. It is a review sheet's ruling: items an agent put
98
+ to the author (a proposed edit, a decision between options, a finding), and
99
+ the author's ruling on each, one item per turn. The reply above a turn is what
100
+ the sheet presented, written by an agent. A turn opens with the item's label
101
+ and the option the author chose; that option's wording was written for the
102
+ author to choose, not by the author. Only a comment after it, and the closing
103
+ notes, are the author's own words. A choice still shows what the author
104
+ accepted or refused.
105
+ """
106
+
107
+ # A red-pen round is read like a session, but it is not a conversation, and
108
+ # the reader is told what its parts are before it reads one.
109
+ REDPEN_NOTE = """
110
+ This is not a conversation. It is a red-pen round: the author's marks on a
111
+ rendered copy of the book, one mark per turn, with no replies. A comment's
112
+ anchor quotes the book, not the author; its note is the author's. A tracked
113
+ edit is the author's.
114
+
115
+ A review-notes entry compares a note block as the author left it with the
116
+ same block rebuilt later from the book's source. Its "+ now" lines are the
117
+ author's copy and its "- was" lines are the rebuild's. The copy the author
118
+ was sent can hold more than the rebuild, so a "+ now" line can be something
119
+ the copy already held: a heading that says what a part of the block is for,
120
+ or a note an agent wrote earlier, which usually opens with a label in
121
+ parentheses and speaks of the author in the third person. Those are not the
122
+ author's words. The author's own lines carry no label, and are often in the
123
+ first person or addressed to whoever edits the book. A "- was" line is not
124
+ evidence that the author deleted it.
125
+
126
+ A comment's "->" line is where the archive matched the text the comment is
127
+ anchored to. A short anchor, such as a single word, can match the wrong place,
128
+ so read a comment by what its note says.
129
+ """
130
+
131
+ # A round records the book revision its marks were matched against. A "+ now"
132
+ # line the book already held at that revision was not written in the round:
133
+ # it is a note the build printed into the block, usually an agent's. Shorter
134
+ # lines, such as headings, are too generic to match safely.
135
+ _ROUND_REVISION = re.compile(r"^canon-sha:\s*([0-9a-f]{7,40})\s*$", re.M)
136
+ _HELD_CHARS = 20
137
+ _FOLD = str.maketrans(
138
+ {
139
+ "\u2014": "-",
140
+ "\u2013": "-",
141
+ "\u201c": '"',
142
+ "\u201d": '"',
143
+ "\u2018": "'",
144
+ "\u2019": "'",
145
+ "\u2026": "...",
146
+ }
147
+ )
148
+
149
+
150
+ def _folded(text):
151
+ """Text as a red-pen round and `publish compare` see it: plain punctuation, one space.
152
+
153
+ A round folds a dash to one hyphen, where the source may spell it "--".
154
+ """
155
+ return " ".join(re.sub(r"-{2,}", "-", text.translate(_FOLD)).split())
156
+
157
+
158
+ @functools.lru_cache(maxsize=16)
159
+ def _book_at(root, source, revision):
160
+ """The book's source text at ``revision``, folded; ``None`` if git cannot say."""
161
+ try:
162
+ top = trail._git(root, "rev-parse", "--show-toplevel").strip()
163
+ where = os.path.relpath(source, top)
164
+ names = trail._git(
165
+ top, "ls-tree", "-r", "--name-only", revision, "--", where
166
+ ).split()
167
+ texts = [
168
+ trail._git(top, "show", f"{revision}:{name}")
169
+ for name in names
170
+ if name.endswith((".md", ".qmd"))
171
+ ]
172
+ except (OSError, subprocess.CalledProcessError):
173
+ return None
174
+ return _folded("\n".join(texts)) if texts else None
175
+
176
+
177
+ def _round_book(path):
178
+ """``(revision, the book's text then)`` for a red-pen round, either ``None``."""
179
+ found = _ROUND_REVISION.search(Path(path).read_text(encoding="utf-8"))
180
+ if not found:
181
+ return None, None
182
+ project = current_project()
183
+ held = _book_at(
184
+ str(project.root), str(project.path("manuscript.source")), found.group(1)
185
+ )
186
+ return found.group(1), held
187
+
188
+
189
+ def _unheld(text, held):
190
+ """A mark without the "+ now" lines the book already held; and how many."""
191
+ kept, dropped = [], 0
192
+ for line in text.splitlines():
193
+ now = re.match(r"\s*\+ now: (.*)$", line)
194
+ if (
195
+ now
196
+ and len(now.group(1).strip()) >= _HELD_CHARS
197
+ and _folded(now.group(1)) in held
198
+ ):
199
+ dropped += 1
200
+ continue
201
+ kept.append(line)
202
+ return "\n".join(kept), dropped
203
+
204
+
205
+ INSTRUCTIONS = """\
206
+ You are reading conversations in which an author worked out the ideas of a
207
+ book. The book keeps a spec: a list of sections, each stating one thing the
208
+ book holds. The menu below lists them. The packets below hold the author's
209
+ turns from one or more sessions, each turn with the end of the reply it
210
+ answered.
211
+
212
+ Flag every author turn that may have shaped what a section holds, so that a
213
+ later reader can decide. Flag generously. A turn you are unsure about is worth
214
+ flagging with low confidence, because a turn nobody flags is lost, while a
215
+ later, careful reader rejects what does not belong.
216
+
217
+ A turn shapes a section when the author, in their own words:
218
+ - seed: first raises the idea;
219
+ - sharpen: makes it more precise, or changes its words;
220
+ - correct: rejects a wording or claim the model had proposed;
221
+ - reverse: moves off a position the author held earlier;
222
+ - accept: adopts a wording or claim the model drafted, even with a bare "yes";
223
+ - probe: tries the idea out, or wonders, without settling it.
224
+ Use "unsure" when a turn bears on a section but you cannot tell how.
225
+
226
+ Rules:
227
+ - Only the author's words count. Text the author pasted from elsewhere (a
228
+ review, another model's output, a document) shapes nothing by itself; the
229
+ author's own comment on it may.
230
+ - A bare approval ("yes", "go", "commit") matters only when the reply it
231
+ answered proposed something specific about a section. Then it is an accept.
232
+ - The conversations may use older names for things than the menu does.
233
+ Match by meaning, not by words.
234
+ - Name sections only by the slugs in the menu. A turn may shape several
235
+ sections; list each one.
236
+ - Leave out turns that shape no section.
237
+
238
+ Answer with JSON lines and nothing else. One line per flagged turn:
239
+ {"session": "<the id after '# Session'>", "turn": <its number>, "sections": [{"slug": "<slug>", "kind": "<kind>", "confidence": "high|medium|low", "reason": "<one short sentence>"}]}
240
+ After a session's last flagged turn, one line saying you finished it, with
241
+ one sentence, in your own words, on what the author and the model were doing
242
+ across the whole session:
243
+ {"session": "<id>", "done": true, "about": "<one sentence>"}
244
+ Every session in the packets needs its done line, even one with nothing to
245
+ flag. The about sentence is how a later reader knows the session was read, so
246
+ write it from the turns, not from the first one alone.
247
+ """
248
+
249
+
250
+ # --------------------------------------------------------------------------- #
251
+ # configuration
252
+ # --------------------------------------------------------------------------- #
253
+ def ledger():
254
+ """The ledger's directory: what readers flagged, and what was rejected."""
255
+ project = current_project()
256
+ if not trail.configured():
257
+ raise ConfigurationError(
258
+ "a backfill ledger belongs to a spec trail: set manuscript.spec_trail first"
259
+ )
260
+ if project.get("manuscript.spec_trail_leads") is None:
261
+ raise ConfigurationError(
262
+ "this project keeps no backfill ledger: set manuscript.spec_trail_leads in "
263
+ "quirework.toml to the directory that should hold it"
264
+ )
265
+ return project.path("manuscript.spec_trail_leads")
266
+
267
+
268
+ def _write_private(path, text):
269
+ """Write ``path`` atomically, readable by its owner only, like the trail."""
270
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
271
+ handle, temporary = tempfile.mkstemp(
272
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
273
+ )
274
+ try:
275
+ with os.fdopen(handle, "w", encoding="utf-8") as out:
276
+ out.write(text)
277
+ out.flush()
278
+ os.fsync(out.fileno())
279
+ os.replace(temporary, path)
280
+ except BaseException:
281
+ if os.path.exists(temporary):
282
+ os.unlink(temporary)
283
+ raise
284
+
285
+
286
+ def _utc(moment):
287
+ """A logged time as a reader sees it, to the second."""
288
+ if moment is None:
289
+ return None
290
+ return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
291
+
292
+
293
+ def _instant(moment):
294
+ """A logged time as the ledger stores it: exact, since turns share seconds."""
295
+ if moment is None:
296
+ return None
297
+ return moment.astimezone(timezone.utc).isoformat(timespec="microseconds")
298
+
299
+
300
+ # --------------------------------------------------------------------------- #
301
+ # what a reader is handed
302
+ # --------------------------------------------------------------------------- #
303
+ def _gist(body):
304
+ text = " ".join(re.sub(r"<!--.*?-->", " ", body, flags=re.S).split())
305
+ if len(text) <= GIST_CHARS:
306
+ return text
307
+ return text[: GIST_CHARS - 1].rsplit(" ", 1)[0] + "…"
308
+
309
+
310
+ def menu():
311
+ """The spec's sections as a reader names them.
312
+
313
+ Each line gives the slug, the title, how the section opens, and the
314
+ units that bind it. A conversation often names the chapter or post it was
315
+ working on rather than the idea, and the binding is how a reader gets from
316
+ one to the other. The wording changes nothing a read is keyed on: reads
317
+ are current by `PACKET_VERSION`.
318
+ """
319
+ from quirework.manuscript import units
320
+
321
+ noun = units.noun()
322
+ bound = {}
323
+ for unit, slugs in notes.chapter_bindings().items():
324
+ for slug in slugs:
325
+ bound.setdefault(slug, set()).add(unit)
326
+ lines = [
327
+ "# The spec's sections",
328
+ "",
329
+ (
330
+ "Name a section only by its slug. Each line gives the section's "
331
+ f"title, how it opens, and the {noun}s that express it."
332
+ ),
333
+ "",
334
+ ]
335
+ for section in notes.spec_sections():
336
+ if section.slug:
337
+ named = ", ".join(sorted(bound.get(section.slug, ()))) or "none"
338
+ lines.append(
339
+ f"- `{section.slug}`: {section.title}. {_gist(section.body)} "
340
+ f"[{noun}s: {named}]"
341
+ )
342
+ return "\n".join(lines) + "\n"
343
+
344
+
345
+ def _short(sid):
346
+ """A session as messages name it: a named source whole, an id by its head."""
347
+ return sid if sid.startswith(trail._NAMED) else sid[:8]
348
+
349
+
350
+ def _tail(text, limit):
351
+ text = text.strip()
352
+ return text if len(text) <= limit else "… " + text[-limit:].lstrip()
353
+
354
+
355
+ def _clip(text):
356
+ text = text.strip()
357
+ if len(text) <= TURN_HEAD + TURN_TAIL + 200:
358
+ return text
359
+ cut = len(text) - TURN_HEAD - TURN_TAIL
360
+ return (
361
+ f"{text[:TURN_HEAD].rstrip()}\n\n[… {cut:,} characters not shown …]\n\n"
362
+ f"{text[-TURN_TAIL:].lstrip()}"
363
+ )
364
+
365
+
366
+ def _quoted(text):
367
+ return "\n".join(f"> {line}" if line.strip() else ">" for line in text.splitlines())
368
+
369
+
370
+ def _listed(numbers):
371
+ """Turn numbers as a sentence lists them: "3, 17 and 40"."""
372
+ words = [str(n) for n in numbers]
373
+ return words[0] if len(words) == 1 else f"{', '.join(words[:-1])} and {words[-1]}"
374
+
375
+
376
+ def packet(sid, family, path, only=None):
377
+ """One session as a reader reads it: each author turn, and what it answered.
378
+
379
+ Turns are numbered as ``provenance show`` numbers them, and stamped with
380
+ the logged UTC time, so a flag can be followed back to the turn it names.
381
+ ``only``, a list of turn numbers, hands out just those turns: one part of
382
+ a session too long for one reader, or the turns of a session that no
383
+ reader has seen. The numbers stay the session's own, and the header says
384
+ which turns the packet holds.
385
+ """
386
+ from quirework.provenance import humaninput, show
387
+
388
+ rows = show.exchanges(family, path)
389
+ total = len(rows)
390
+ if only is not None:
391
+ wanted = set(only)
392
+ rows = [row for row in rows if row[0] in wanted]
393
+ if not rows:
394
+ raise ValueError(f"{_short(sid)} has none of the turns asked for")
395
+ held_note = []
396
+ if family == "redpen":
397
+ revision, held = _round_book(path)
398
+ if held is None:
399
+ held_note = [
400
+ "",
401
+ "The book's text at this round's revision could not be read, so "
402
+ "'+ now' lines the book already held are shown with the rest.",
403
+ ]
404
+ else:
405
+ left_out = 0
406
+ for i, (number, logged, text, reply) in enumerate(rows):
407
+ text, dropped = _unheld(text, held)
408
+ rows[i] = (number, logged, text, reply)
409
+ left_out += dropped
410
+ if left_out:
411
+ held_note = [
412
+ "",
413
+ f"{left_out} '+ now' line(s) are left out: the book already "
414
+ f"held them at revision {revision}, so they were not written "
415
+ "in this round.",
416
+ ]
417
+ day = show.session_date(family, path)
418
+ begun = f"started {day:%Y-%m-%d}" if day else "undated"
419
+ lines = [
420
+ f"# Session {sid}",
421
+ "",
422
+ f"{trail._HARNESS_OF[family]} · {begun} · {total} author turn(s)",
423
+ ]
424
+ if only is not None:
425
+ numbers = [row[0] for row in rows]
426
+ if numbers == list(range(numbers[0], numbers[-1] + 1)):
427
+ held = (
428
+ f"This packet holds only turns {numbers[0]} to {numbers[-1]} of "
429
+ f"the session's {total}; other readers read the rest."
430
+ )
431
+ else:
432
+ held = (
433
+ f"This packet holds only {len(numbers)} of the session's {total} "
434
+ f"turns: {_listed(numbers)}. Other readers read the rest, so the "
435
+ "turns between these are not shown."
436
+ )
437
+ lines += [
438
+ "",
439
+ f"{held} Flag only these turns, and write the session's done line "
440
+ "when you have read them.",
441
+ ]
442
+ if family == "redpen":
443
+ lines += ["", REDPEN_NOTE.strip(), *held_note]
444
+ if family == "rulings":
445
+ lines += ["", RULINGS_NOTE.strip()]
446
+ for number, logged, text, reply in rows:
447
+ shape = _SHAPES.get(humaninput.classify(text), "typed")
448
+ lines += [
449
+ "",
450
+ f"## Turn {number} · {_utc(logged) or 'no logged time'} · {shape}",
451
+ ]
452
+ if reply.strip():
453
+ lines += [
454
+ "",
455
+ "It answered the end of this reply:",
456
+ "",
457
+ _quoted(_tail(reply, REPLY_CHARS)),
458
+ ]
459
+ lines += ["", "The author wrote:", "", _clip(text) or "(nothing)"]
460
+ return "\n".join(lines) + "\n"
461
+
462
+
463
+ # --------------------------------------------------------------------------- #
464
+ # the ledger's reads
465
+ # --------------------------------------------------------------------------- #
466
+ def _reads():
467
+ """Every imported read, as the dicts the ledger stores."""
468
+ root = ledger() / "reads"
469
+ return [
470
+ json.loads(p.read_text(encoding="utf-8")) for p in sorted(root.glob("*/*.json"))
471
+ ]
472
+
473
+
474
+ def _current(read):
475
+ """Whether a read was made from today's packet.
476
+
477
+ A section added since is deliberately not a reason to read the session
478
+ again: that would hand out the whole corpus for one section, whose
479
+ history is mostly in the session that added it. ``status`` counts such
480
+ reads so the choice stays visible.
481
+ """
482
+ return read.get("packet_version") == PACKET_VERSION
483
+
484
+
485
+ def _keys(turns):
486
+ """A key for each of a session's turns that a renumbering leaves alone.
487
+
488
+ ``turns`` are ``(logged, text)`` pairs in the session's order. A read
489
+ records the keys of the turns it was handed, not their numbers, because
490
+ numbers move: a reader that learns to count a new kind of turn renumbers
491
+ every turn after it. A turn is known by the instant it was logged. Where
492
+ that does not tell it apart, because its source logs no times or another
493
+ turn shares the instant, the key adds a digest of what the turn says, and
494
+ an ordinal where two turns say the same.
495
+ """
496
+ turns = [(_instant(logged), text) for logged, text in turns]
497
+ shared = Counter(instant for instant, _ in turns)
498
+ keys, used = [], Counter()
499
+ for instant, text in turns:
500
+ if instant is not None and shared[instant] == 1:
501
+ keys.append(instant)
502
+ continue
503
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
504
+ key = f"{instant or 'untimed'}/{digest}"
505
+ used[key] += 1
506
+ keys.append(key if used[key] == 1 else f"{key}#{used[key]}")
507
+ return keys
508
+
509
+
510
+ class _Session:
511
+ """A session's author turns as the ledger compares them, read once."""
512
+
513
+ def __init__(self, family, path):
514
+ from quirework.provenance import show
515
+
516
+ rows = show.exchanges(family, path)
517
+ self.family, self.path = family, path
518
+ self.numbers = [number for number, _, _, _ in rows]
519
+ self.instants = [_instant(logged) for _, logged, _, _ in rows]
520
+ self.keys = _keys((logged, text) for _, logged, text, _ in rows)
521
+ self._origins = None
522
+
523
+ def origins(self):
524
+ """How each turn reached the log (``show.turn_origins``)."""
525
+ if self._origins is None:
526
+ from quirework.provenance import show
527
+
528
+ self._origins = show.turn_origins(self.family, self.path)
529
+ return self._origins
530
+
531
+
532
+ def _seen(read, session):
533
+ """The keys of the turns ``read`` was handed, as ``session`` holds them now.
534
+
535
+ A read records them since reads began keeping keys. An older read kept
536
+ only how many turns it read, the part it read if it read one, and the
537
+ number and logged instant of each turn it flagged. Its turns are found by
538
+ replaying the readers it could have run on: today's, and each one from
539
+ before a path to the author's turns was learned (``show.ORIGINS``), which
540
+ lists the session without the turns that arrived that way. A reader fits
541
+ when it lists as many turns as the read counted, or enough to hold the
542
+ part, and puts every flagged turn at the number the read gave it.
543
+ Readers that fit and disagree on which turns a part held leave it only
544
+ the turns they agree on, so a turn in doubt is handed out again. A read
545
+ no reader fits vouches for no turn, and is ``None``: its session gained
546
+ turns some other way since, as when a capture is rebuilt more completely,
547
+ and only a new read can say which turns it saw.
548
+ """
549
+ if "seen" in read:
550
+ return set(read["seen"])
551
+ from quirework.provenance import show
552
+
553
+ learned = show.ORIGINS[:0:-1]
554
+ part = read.get("part")
555
+ fits = []
556
+ for count in range(len(learned) + 1):
557
+ unknown = set(learned[:count])
558
+ listed = [
559
+ i for i, origin in enumerate(session.origins()) if origin not in unknown
560
+ ]
561
+ if part is None:
562
+ first, last = 1, len(listed)
563
+ if last != read["turns"]:
564
+ continue
565
+ else:
566
+ first, last = part["first"], part["last"]
567
+ if last > len(listed):
568
+ continue
569
+ if all(
570
+ 1 <= flag["turn"] <= len(listed)
571
+ and (
572
+ flag.get("logged") is None
573
+ or session.instants[listed[flag["turn"] - 1]] == flag["logged"]
574
+ )
575
+ for flag in read["candidates"]
576
+ ):
577
+ fits.append({session.keys[i] for i in listed[first - 1 : last]})
578
+ return set.intersection(*fits) if fits else None
579
+
580
+
581
+ def _coverage(sessions, reads=None):
582
+ """What current reads saw of ``sessions``, as ``(cover, stale)``.
583
+
584
+ ``sessions`` maps an id to its ``_Session``. ``cover`` maps every session
585
+ a current read covers to the keys of the turns those reads were handed;
586
+ a session counts as read once that is every turn it holds. ``stale`` is
587
+ the sessions with a read that vouches for no turn (``_seen``).
588
+ """
589
+ cover, stale = {}, set()
590
+ for read in _reads() if reads is None else reads:
591
+ sid = read["session"]
592
+ if not _current(read) or sid not in sessions:
593
+ continue
594
+ seen = _seen(read, sessions[sid])
595
+ if seen is None:
596
+ stale.add(sid)
597
+ cover.setdefault(sid, set()).update(seen or ())
598
+ return cover, stale
599
+
600
+
601
+ def _unseen(session, seen):
602
+ """The numbers of ``session``'s turns whose keys are not in ``seen``."""
603
+ return [n for n, key in zip(session.numbers, session.keys) if key not in seen]
604
+
605
+
606
+ def _chunks(numbers, size):
607
+ """``numbers`` cut into as few runs of at most ``size`` as it takes, of
608
+ nearly equal length, so no part is a stub."""
609
+ count = len(numbers)
610
+ pieces = -(-count // max(size, 1))
611
+ step = -(-count // pieces)
612
+ return [numbers[at : at + step] for at in range(0, count, step)]
613
+
614
+
615
+ def _kept(corpus):
616
+ """Captured sessions curation has accepted, as ``sid -> (family, path)``.
617
+
618
+ Each is read at its accepted version, never at a pending capture made
619
+ since: a lead is recorded against what the corpus holds.
620
+ """
621
+ from quirework.provenance import show
622
+
623
+ return {
624
+ show.session_id(family, path): (family, path)
625
+ for family, path in show.sessions()
626
+ }
627
+
628
+
629
+ # --------------------------------------------------------------------------- #
630
+ # a run plan
631
+ # --------------------------------------------------------------------------- #
632
+ def run_directory(model):
633
+ """A new run directory in the project's private state, named for today.
634
+
635
+ Packets and the run record hold corpus text, so they belong outside the
636
+ checkout; a second pass on the same day takes the next free suffix.
637
+ """
638
+ root = current_project().state_root
639
+ stem = f"trail-read-{date.today().isoformat()}-{re.sub(r'[^A-Za-z0-9.-]+', '-', model)}"
640
+ candidate, n = root / stem, 1
641
+ while candidate.exists():
642
+ n += 1
643
+ candidate = root / f"{stem}-{n}"
644
+ return candidate
645
+
646
+
647
+ def plan(
648
+ out,
649
+ *,
650
+ harness,
651
+ model,
652
+ effort,
653
+ tier="cheap",
654
+ sessions=None,
655
+ scope=None,
656
+ limit=None,
657
+ budget=BATCH_TOKENS,
658
+ turns=SEAT_TURNS,
659
+ materials=(),
660
+ reread=False,
661
+ external=False,
662
+ ):
663
+ """Write packets and a run plan that hands them to readers; return a summary.
664
+
665
+ Sessions are taken oldest first from the curated corpus, or those named
666
+ in ``sessions``, and each hands out only the turns no current read was
667
+ handed (``_coverage``), all of them with ``reread``. A session read
668
+ whole before its reader learned a new kind of turn, or read in parts that
669
+ left some out, hands out the turns no reader saw. A source that logs no
670
+ times but records its date, a gemini review or a red-pen round, takes its
671
+ place by that date; one with neither sorts first, and the summary names
672
+ it. ``out`` of ``None`` plans into a new ``run_directory``, as
673
+ ``RUN/packets``, beside which the record goes. Each seat gets one batch
674
+ of packets up to ``budget`` estimated tokens and ``turns`` author turns,
675
+ with the menu and any book ``materials`` in front, and no tools. A
676
+ session with more turns to hand out than ``turns`` is cut into parts of
677
+ nearly equal length, and a seat never holds two parts of one session.
678
+ The summary names every session handed out in parts, counts the turns
679
+ of sessions read before, and names each session handed out whole again
680
+ because its old read no longer fits its turns.
681
+ """
682
+ from quirework.provenance import scope as SCOPE
683
+ from quirework.provenance import show
684
+
685
+ out = (
686
+ run_directory(model) / "packets"
687
+ if out is None
688
+ else Path(out).expanduser().resolve()
689
+ )
690
+ project = current_project()
691
+ if out.is_relative_to(project.root) and not out.is_relative_to(
692
+ project.path("publish.build", "build")
693
+ ):
694
+ raise ValueError(
695
+ "packets hold private corpus text: write them outside the checkout, "
696
+ "or under build/"
697
+ )
698
+ if out.exists() and any(out.iterdir()):
699
+ raise ValueError(f"{out} is not empty; plan into a new directory")
700
+ ledger()
701
+ corpus = trail.Corpus()
702
+ kept = _kept(corpus)
703
+ slugs = [s.slug for s in notes.spec_sections() if s.slug]
704
+ if sessions:
705
+ chosen = []
706
+ for prefix in sessions:
707
+ sid, family, path, live = corpus.session(prefix)
708
+ if live or sid not in kept:
709
+ raise LookupError(f"{_short(sid)} is not in the curated corpus")
710
+ chosen.append(sid)
711
+ else:
712
+ chosen = [
713
+ sid for sid in kept if scope is None or SCOPE.scope_of_id(sid) == scope
714
+ ]
715
+ every = {sid: _Session(*kept[sid]) for sid in chosen}
716
+ cover, stale = ({}, set()) if reread else _coverage(every)
717
+ dated, undated, spans = [], set(), {}
718
+ for sid in chosen:
719
+ family, path = kept[sid]
720
+ unseen = _unseen(every[sid], cover.get(sid, set()))
721
+ if not unseen:
722
+ continue
723
+ spans[sid] = _chunks(unseen, turns)
724
+ day = show.session_date(family, path)
725
+ start = show.session_start(family, path) or (
726
+ datetime.combine(day, datetime.min.time(), timezone.utc) if day else None
727
+ )
728
+ if start is None:
729
+ undated.add(sid)
730
+ dated.append((start or datetime.min.replace(tzinfo=timezone.utc), sid))
731
+ dated.sort()
732
+ if limit is not None:
733
+ dated = dated[:limit]
734
+
735
+ (out / "packets").mkdir(parents=True, exist_ok=True)
736
+ (out / "menu.md").write_text(menu(), encoding="utf-8")
737
+ extra = [
738
+ {"source": str(Path(m).expanduser().resolve(strict=True)), "delivery": "text"}
739
+ for m in materials
740
+ ]
741
+ batches, current, size, held = [], [], 0, 0
742
+ for _, sid in dated:
743
+ family, path = kept[sid]
744
+ session = every[sid]
745
+ key_of = dict(zip(session.numbers, session.keys))
746
+ for numbers in spans[sid]:
747
+ only = None if numbers == session.numbers else numbers
748
+ text = packet(sid, family, path, only)
749
+ name = (
750
+ f"packets/{sid}.md"
751
+ if only is None
752
+ else f"packets/{sid}.turns-{numbers[0]}-{numbers[-1]}.md"
753
+ )
754
+ (out / name).write_text(text, encoding="utf-8")
755
+ tokens, count = len(text) // 4, len(numbers)
756
+ if current and (
757
+ size + tokens > budget
758
+ or held + count > turns
759
+ or any(handed[0] == sid for handed in current)
760
+ ):
761
+ batches.append(current)
762
+ current, size, held = [], 0, 0
763
+ current.append((sid, name, tokens, only, [key_of[n] for n in numbers]))
764
+ size += tokens
765
+ held += count
766
+ if current:
767
+ batches.append(current)
768
+
769
+ seats = []
770
+ for index, batch in enumerate(batches, 1):
771
+ seat = {
772
+ "id": f"read-{index:03d}",
773
+ "harness": harness,
774
+ "model": model,
775
+ "effort": effort,
776
+ "tier": tier,
777
+ # agy audits file access and cannot enforce isolation; the seat
778
+ # has no tools either way, and its receipt says which it was.
779
+ "policy": {
780
+ "tools": "none",
781
+ "filesystem": "audited" if harness == "agy" else "enforced",
782
+ },
783
+ # agy keeps its login in the keyring, which every agy jail can
784
+ # reach; a seat with no tools cannot use it, so the flag is
785
+ # expected and only another one stops the import.
786
+ **({"accept_red_flags": ["session-bus"]} if harness == "agy" else {}),
787
+ "metadata": {
788
+ "trail_read": {
789
+ "sessions": [sid for sid, _, _, _, _ in batch],
790
+ # The keys of the turns each packet holds, in turn order,
791
+ # and the numbers of a packet that holds only some: what
792
+ # import records as the turns a read was handed.
793
+ "seen": {sid: keys for sid, _, _, _, keys in batch},
794
+ **(
795
+ {
796
+ "parts": {
797
+ sid: only
798
+ for sid, _, _, only, _ in batch
799
+ if only is not None
800
+ }
801
+ }
802
+ if any(only for _, _, _, only, _ in batch)
803
+ else {}
804
+ ),
805
+ "packet_version": PACKET_VERSION,
806
+ "slugs": slugs,
807
+ }
808
+ },
809
+ "turns": [
810
+ {
811
+ "id": "read",
812
+ "prompt": INSTRUCTIONS,
813
+ "materials": [
814
+ {"source": "menu.md", "delivery": "text", "label": "menu"},
815
+ *extra,
816
+ *(
817
+ {"source": name, "delivery": "text", "label": _short(sid)}
818
+ for sid, name, _, _, _ in batch
819
+ ),
820
+ ],
821
+ "min_bytes": 20,
822
+ }
823
+ ],
824
+ }
825
+ if external:
826
+ seat["mode"] = "external"
827
+ seats.append(seat)
828
+ if not seats:
829
+ raise LookupError("no session is left to read")
830
+ document = {
831
+ "title": f"Spec trail reading: {len(dated)} session(s)",
832
+ "metadata": {"trail_read": {"packet_version": PACKET_VERSION}},
833
+ "seats": seats,
834
+ }
835
+ (out / "plan.json").write_text(json.dumps(document, indent=2) + "\n")
836
+ # What every seat is handed besides its packets, estimated as packets are.
837
+ fixed = (
838
+ len(INSTRUCTIONS)
839
+ + len((out / "menu.md").read_text(encoding="utf-8"))
840
+ + sum(Path(m["source"]).stat().st_size for m in extra)
841
+ ) // 4
842
+ packets = sum(t for batch in batches for _, _, t, _, _ in batch)
843
+ harnesses = {}
844
+ for _, sid in dated:
845
+ name = trail._HARNESS_OF[kept[sid][0]]
846
+ harnesses[name] = harnesses.get(name, 0) + 1
847
+ return {
848
+ "plan": str(out / "plan.json"),
849
+ "record": str(out.parent / "record") if out.name == "packets" else None,
850
+ "seats": len(seats),
851
+ "sessions": len(dated),
852
+ "harnesses": harnesses,
853
+ "undated": [sid for _, sid in dated if sid in undated],
854
+ "split": [
855
+ (sid, len(every[sid].numbers), spans[sid])
856
+ for _, sid in dated
857
+ if len(spans[sid]) > 1
858
+ ],
859
+ "partial": [
860
+ (sid, sum(map(len, spans[sid])), len(every[sid].numbers))
861
+ for _, sid in dated
862
+ if cover.get(sid)
863
+ ],
864
+ "stale": [sid for _, sid in dated if sid in stale and not cover.get(sid)],
865
+ "turns": turns,
866
+ "tokens": packets,
867
+ "total_tokens": packets + fixed * len(seats),
868
+ }
869
+
870
+
871
+ def _run(numbers):
872
+ """One part's turns in a plan's summary: "3-9", or "3-40 (12 of them)"."""
873
+ first, last = numbers[0], numbers[-1]
874
+ if len(numbers) == last - first + 1:
875
+ return f"{first}-{last}"
876
+ return f"{first}-{last} ({len(numbers)} of them)"
877
+
878
+
879
+ def plan_lines(done):
880
+ """A plan's summary as printable lines, ending with the commands to run."""
881
+ lines = [
882
+ f"{done['sessions']} session(s) in {done['seats']} seat(s): "
883
+ + ", ".join(f"{n} {h}" for h, n in done["harnesses"].items()),
884
+ f"about {done['tokens']:,} tokens of packets, "
885
+ f"{done['total_tokens']:,} with each seat's menu and instructions",
886
+ ]
887
+ if done["undated"]:
888
+ lines.append(
889
+ f"{len(done['undated'])} session(s) have no start time and are "
890
+ "planned first: " + ", ".join(sid[:16] for sid in done["undated"])
891
+ )
892
+ if done["partial"]:
893
+ lines.append(
894
+ f"{len(done['partial'])} session(s) read before hand out only the "
895
+ f"{sum(n for _, n, _ in done['partial']):,} turn(s) no reader saw"
896
+ )
897
+ if done["stale"]:
898
+ lines.append(
899
+ f"{len(done['stale'])} session(s) are read whole again, because an "
900
+ "earlier read no longer fits the turns they hold: "
901
+ + ", ".join(_short(sid) for sid in done["stale"])
902
+ )
903
+ if done["split"]:
904
+ lines.append(
905
+ f"{len(done['split'])} session(s) handed out in parts of at most "
906
+ f"{done['turns']} turns, one part to a seat: "
907
+ + ", ".join(
908
+ f"{_short(sid)} turns "
909
+ + ", ".join(_run(part) for part in parts)
910
+ + f" of {n}"
911
+ for sid, n, parts in done["split"]
912
+ )
913
+ )
914
+ record = done["record"] or "RECORD"
915
+ lines += [
916
+ f"plan: {done['plan']}",
917
+ "next:",
918
+ f" quirework sop runs prepare {done['plan']} {record}",
919
+ f" quirework sop runs start {record}",
920
+ f" quirework spec trail backfill import {record}",
921
+ ]
922
+ return lines
923
+
924
+
925
+ # --------------------------------------------------------------------------- #
926
+ # importing what readers flagged
927
+ # --------------------------------------------------------------------------- #
928
+ def _objects(answer):
929
+ """The JSON objects in an answer, one per line, tolerating fences and prose."""
930
+ found, skipped = [], []
931
+ for line in answer.splitlines():
932
+ start, end = line.find("{"), line.rfind("}")
933
+ if start < 0 or end <= start:
934
+ if line.strip() and not line.strip().startswith("```"):
935
+ skipped.append(line.strip()[:120])
936
+ continue
937
+ try:
938
+ value = json.loads(line[start : end + 1])
939
+ except json.JSONDecodeError:
940
+ skipped.append(line.strip()[:120])
941
+ continue
942
+ if isinstance(value, dict):
943
+ found.append(value)
944
+ else:
945
+ skipped.append(line.strip()[:120])
946
+ return found, skipped
947
+
948
+
949
+ def _cut_off(answer):
950
+ """Whether an answer stops partway through a line, as one cut off does.
951
+
952
+ A reader that runs out of output leaves its last line unfinished. A last
953
+ line that parses, or is a closing fence, is an answer that ended.
954
+ """
955
+ lines = [line.strip() for line in answer.splitlines() if line.strip()]
956
+ if not lines or lines[-1].startswith("```"):
957
+ return False
958
+ found, _ = _objects(lines[-1])
959
+ return not found
960
+
961
+
962
+ def parse_answer(answer, sessions, corpus, parts=None, seen=None):
963
+ """``{sid: read}`` for each session the answer finished, plus what it dropped.
964
+
965
+ Every flag is checked against the corpus: the session must be one the seat
966
+ was handed, the turn one of the author's, and each slug a spec section. A
967
+ flag that fails is dropped and reported, never guessed into shape. The
968
+ logged time stored with a flag comes from the corpus, not the reader, so
969
+ the flag survives a later renumbering of the session's turns. ``seen``
970
+ maps each session to the keys of the turns its packet held (``_keys``),
971
+ which the read records, and ``parts`` a session handed only some turns to
972
+ their numbers: a flag on a turn the packet did not hold is dropped. A
973
+ plan made before reads kept keys gives no ``seen``, and its ``parts``
974
+ are ``[first, last]``, which the read records as the part it covers.
975
+ """
976
+ titles = trail._spec_titles()
977
+ captured = corpus.captured()
978
+ parts = parts or {}
979
+ handed = {sid: captured[sid] for sid in sessions if sid in captured}
980
+ if seen is None:
981
+ turns = {
982
+ sid: {
983
+ t.number: t
984
+ for t in corpus.turns(sid, *handed[sid])
985
+ if sid not in parts or parts[sid][0] <= t.number <= parts[sid][1]
986
+ }
987
+ for sid in handed
988
+ }
989
+ else:
990
+ # The reader numbers a turn as its packet did; the key finds that turn
991
+ # today, wherever a renumbering since has moved it.
992
+ turns = {}
993
+ for sid, (family, path) in handed.items():
994
+ now = corpus.turns(sid, family, path)
995
+ by_key = dict(zip(_keys((t.logged, t.text) for t in now), now))
996
+ numbers = parts.get(sid) or range(1, len(seen[sid]) + 1)
997
+ turns[sid] = {
998
+ n: by_key[key] for n, key in zip(numbers, seen[sid]) if key in by_key
999
+ }
1000
+ objects, skipped = _objects(answer)
1001
+ dropped = [{"line": s, "why": "not a JSON object"} for s in skipped]
1002
+ dropped += [
1003
+ {"line": sid, "why": "no longer in the corpus"}
1004
+ for sid in sessions
1005
+ if sid not in handed
1006
+ ]
1007
+ flags = {sid: {} for sid in handed}
1008
+ finished = set()
1009
+ about = {}
1010
+
1011
+ def which(value):
1012
+ hits = [
1013
+ s
1014
+ for s in handed
1015
+ if isinstance(value, str) and value and s.startswith(value)
1016
+ ]
1017
+ return hits[0] if len(hits) == 1 else None
1018
+
1019
+ for obj in objects:
1020
+ sid = which(obj.get("session"))
1021
+ if sid is None:
1022
+ dropped.append({"line": json.dumps(obj)[:200], "why": "unknown session"})
1023
+ continue
1024
+ if obj.get("done") is True:
1025
+ finished.add(sid)
1026
+ about[sid] = " ".join(str(obj.get("about") or "").split())[:300] or None
1027
+ continue
1028
+ number = obj.get("turn")
1029
+ turn = turns[sid].get(number) if type(number) is int else None
1030
+ if turn is None:
1031
+ dropped.append(
1032
+ {"line": json.dumps(obj)[:200], "why": "no such author turn"}
1033
+ )
1034
+ continue
1035
+ for item in obj.get("sections") or []:
1036
+ if not isinstance(item, dict) or item.get("slug") not in titles:
1037
+ dropped.append(
1038
+ {"line": json.dumps(item)[:200], "why": "not a spec section"}
1039
+ )
1040
+ continue
1041
+ kind = item.get("kind") if item.get("kind") in READ_KINDS else "unsure"
1042
+ confidence = item.get("confidence")
1043
+ confidence = confidence if confidence in CONFIDENCE else "low"
1044
+ reason = " ".join(str(item.get("reason", "")).split())[:300]
1045
+ entry = flags[sid].setdefault(
1046
+ turn.number,
1047
+ {"turn": turn.number, "logged": _instant(turn.logged), "sections": []},
1048
+ )
1049
+ entry["sections"].append(
1050
+ {
1051
+ "slug": item["slug"],
1052
+ "kind": kind,
1053
+ "confidence": confidence,
1054
+ "reason": reason,
1055
+ }
1056
+ )
1057
+ reads = {
1058
+ sid: {
1059
+ "session": sid,
1060
+ "harness": trail._HARNESS_OF[handed[sid][0]],
1061
+ "turns": len(turns[sid]) if seen is None else len(seen[sid]),
1062
+ "about": about[sid],
1063
+ "candidates": [flags[sid][n] for n in sorted(flags[sid])],
1064
+ **(
1065
+ {"seen": seen[sid]}
1066
+ if seen is not None
1067
+ else {"part": {"first": parts[sid][0], "last": parts[sid][1]}}
1068
+ if sid in parts
1069
+ else {}
1070
+ ),
1071
+ }
1072
+ for sid in finished
1073
+ }
1074
+ return reads, dropped
1075
+
1076
+
1077
+ def import_run(record, accept=()):
1078
+ """Bring each finished seat's flags into the ledger; return one row per seat.
1079
+
1080
+ The record is verified first, so a changed answer or input is refused. A
1081
+ seat with a red flag its plan did not accept is held, and nothing it
1082
+ read is recorded: an unexpected flag is the author's call, not the
1083
+ importer's. ``accept`` names flag codes to accept here instead, for a
1084
+ record prepared before its plan could declare them, and only under the
1085
+ same policy a plan would need. A session whose done line is missing is
1086
+ not recorded as read: it is left for a later plan to hand out again.
1087
+ What an answer held that could not be checked is kept beside the reads,
1088
+ in the seat's own record, for audit.
1089
+ """
1090
+ from quirework.provenance import show
1091
+ from quirework.runs.engine import ACCEPTABLE_FLAGS, Run, flag_code
1092
+
1093
+ unknown = set(accept) - set(ACCEPTABLE_FLAGS)
1094
+ if unknown:
1095
+ raise ValueError(f"no such flag code: {', '.join(sorted(unknown))}")
1096
+ run = Run(record)
1097
+ run.verify()
1098
+ corpus = trail.Corpus()
1099
+ root = ledger()
1100
+ tag = run.plan["id"][:8]
1101
+ report = []
1102
+ for name, seat in run.seats.items():
1103
+ meta = (seat.get("metadata") or {}).get("trail_read")
1104
+ if not meta:
1105
+ continue
1106
+ row = {
1107
+ "seat": name,
1108
+ "read": 0,
1109
+ "unread": [],
1110
+ "dropped": [],
1111
+ "answered": False,
1112
+ "held": [],
1113
+ "accepted": [],
1114
+ "model": None,
1115
+ "turns": 0,
1116
+ "flagged": 0,
1117
+ "empty": [],
1118
+ "sections": [],
1119
+ "sessions": [],
1120
+ "cut": None,
1121
+ "handed": len(meta["sessions"]),
1122
+ }
1123
+ report.append(row)
1124
+ done = run.state(name)["done"].get("read")
1125
+ if not done:
1126
+ continue
1127
+ row["answered"] = True
1128
+ receipt = done["receipt"]
1129
+ row["model"] = receipt.get("model")
1130
+ open_flags, row["accepted"] = run.red_flags(name)
1131
+ for flag in list(open_flags):
1132
+ code = flag_code(flag)
1133
+ if code in accept and ACCEPTABLE_FLAGS[code][1](seat["policy"]):
1134
+ open_flags.remove(flag)
1135
+ row["accepted"].append(flag)
1136
+ if open_flags:
1137
+ row["held"] = open_flags
1138
+ continue
1139
+ answer = (run.record(name, "read") / "answers" / "answer.md").read_text(
1140
+ encoding="utf-8"
1141
+ )
1142
+ reads, dropped = parse_answer(
1143
+ answer, meta["sessions"], corpus, meta.get("parts"), meta.get("seen")
1144
+ )
1145
+ captured = corpus.captured()
1146
+ for sid, read in reads.items():
1147
+ # What the operator checks a quiet seat against: the reader's own
1148
+ # account of each session beside the title the reader never saw.
1149
+ row["sessions"].append(
1150
+ {
1151
+ "session": sid,
1152
+ "turns": read["turns"],
1153
+ "flagged": len(read["candidates"]),
1154
+ "about": read["about"],
1155
+ "title": show.session_title(*captured[sid]),
1156
+ }
1157
+ )
1158
+ row["turns"] += read["turns"]
1159
+ row["flagged"] += len(read["candidates"])
1160
+ if not read["candidates"]:
1161
+ row["empty"].append((sid, read["turns"]))
1162
+ row["sections"] += [
1163
+ (s["slug"], s["confidence"])
1164
+ for c in read["candidates"]
1165
+ for s in c["sections"]
1166
+ ]
1167
+ target = root / "reads" / sid / f"{tag}-{name}.json"
1168
+ if target.exists():
1169
+ continue
1170
+ read.update(
1171
+ packet_version=meta["packet_version"],
1172
+ slugs=meta["slugs"],
1173
+ run=run.plan["id"],
1174
+ seat=name,
1175
+ requested=seat["config"].get("model"),
1176
+ model=receipt.get("model"),
1177
+ effort=seat["config"].get("effort"),
1178
+ red_flags=receipt.get("red_flags", []),
1179
+ imported=date.today().isoformat(),
1180
+ )
1181
+ _write_private(target, json.dumps(read, indent=2) + "\n")
1182
+ row["read"] = len(reads)
1183
+ row["unread"] = [sid for sid in meta["sessions"] if sid not in reads]
1184
+ row["dropped"] = dropped
1185
+ if row["unread"] and _cut_off(answer):
1186
+ usage = receipt.get("usage") or {}
1187
+ row["cut"] = {
1188
+ "output": usage.get("output_tokens"),
1189
+ "thinking": usage.get("thinking_tokens"),
1190
+ }
1191
+ seat_record = {
1192
+ "run": run.plan["id"],
1193
+ "seat": name,
1194
+ "model": receipt.get("model"),
1195
+ "sessions": meta["sessions"],
1196
+ "unread": row["unread"],
1197
+ "dropped": dropped,
1198
+ }
1199
+ _write_private(
1200
+ root / "seats" / f"{tag}-{name}.json",
1201
+ json.dumps(seat_record, indent=2) + "\n",
1202
+ )
1203
+ return report
1204
+
1205
+
1206
+ def import_lines(report):
1207
+ """The import's report as printable lines: each seat, then the whole pass.
1208
+
1209
+ The pass lines are the numbers a ledger commit reports, so the operator
1210
+ copies them rather than recomputing them.
1211
+ """
1212
+ lines = []
1213
+ for row in report:
1214
+ name = row["seat"]
1215
+ if not row["answered"]:
1216
+ lines.append(f" {name}: not answered yet")
1217
+ continue
1218
+ if row["held"]:
1219
+ lines.append(
1220
+ f" {name}: HELD, nothing recorded; a red flag its plan did not accept:"
1221
+ )
1222
+ lines += [f" ! {flag}" for flag in row["held"]]
1223
+ continue
1224
+ lines.append(
1225
+ f" {name}: {row['read']} session(s) read, "
1226
+ f"{row['flagged']} of {row['turns']} author turn(s) flagged"
1227
+ )
1228
+ for sid in row["unread"]:
1229
+ lines.append(f" {_short(sid)}: no done line, so not recorded as read")
1230
+ if row.get("cut"):
1231
+ spent = row["cut"]
1232
+ used = (
1233
+ f" after {spent['output']:,} output tokens"
1234
+ + (
1235
+ f", {spent['thinking']:,} of them thinking"
1236
+ if spent["thinking"]
1237
+ else ""
1238
+ )
1239
+ if spent["output"]
1240
+ else ""
1241
+ )
1242
+ if row.get("handed", 2) > 1:
1243
+ advice = (
1244
+ "The next plan hands its unread sessions out again; give "
1245
+ "them fewer turns per seat (--turns)"
1246
+ )
1247
+ else:
1248
+ advice = (
1249
+ "It held one session: plan it again with fewer turns per "
1250
+ "seat (--turns), and it is read in parts"
1251
+ )
1252
+ lines.append(
1253
+ f" ! the answer stops mid-line{used}: the reader ran out of "
1254
+ f"room, most likely at its model's output limit. {advice}"
1255
+ )
1256
+ for drop in row["dropped"]:
1257
+ lines.append(f" dropped ({drop['why']}): {drop['line'][:100]}")
1258
+ if (
1259
+ row["turns"] >= DILIGENCE_TURNS
1260
+ and row["flagged"] < DILIGENCE_RATE * row["turns"]
1261
+ ):
1262
+ lines.append(
1263
+ f" ! flagged under {DILIGENCE_RATE:.0%} of its turns: check that "
1264
+ "each account below matches its session before trusting the pass"
1265
+ )
1266
+ for s in row["sessions"]:
1267
+ lines += [
1268
+ f" {_short(s['session'])} · {s['turns']} turn(s), "
1269
+ f"{s['flagged']} flagged · {s['title']}",
1270
+ f" about: {s['about'] or '(no about line)'}",
1271
+ ]
1272
+ rows = [r for r in report if r["answered"] and not r["held"]]
1273
+ if not rows:
1274
+ return lines
1275
+ handed = sum(r["read"] + len(r["unread"]) for r in rows)
1276
+ models = sorted({r["model"] or "unknown" for r in rows})
1277
+ sections = [s for r in rows for s in r["sections"]]
1278
+ confidence = {c: sum(1 for _, x in sections if x == c) for c in CONFIDENCE}
1279
+ empty = [e for r in rows for e in r["empty"]]
1280
+ held = [r for r in report if r["held"]]
1281
+ lines += [
1282
+ "pass:",
1283
+ f" {sum(r['read'] for r in rows)} of {handed} session(s) read in "
1284
+ f"{len(rows)} seat(s) on {', '.join(models)}",
1285
+ f" red flags: {sum(len(r['accepted']) for r in rows)} accepted, "
1286
+ f"{len(held)} seat(s) held",
1287
+ f" {len(sections):,} section flag(s) on {sum(r['flagged'] for r in rows):,} "
1288
+ f"of {sum(r['turns'] for r in rows):,} author turn(s), "
1289
+ f"{len({slug for slug, _ in sections})} section(s) "
1290
+ f"({', '.join(f'{n} {c}' for c, n in confidence.items())})",
1291
+ f" {sum(len(r['dropped']) for r in rows)} flag(s) dropped, "
1292
+ f"{len(empty)} session(s) with no flag",
1293
+ ]
1294
+ bare = sum(1 for r in rows for s in r["sessions"] if not s["about"])
1295
+ if bare:
1296
+ lines.append(f" {bare} done line(s) without an about sentence")
1297
+ return lines
1298
+
1299
+
1300
+ # --------------------------------------------------------------------------- #
1301
+ # rulings
1302
+ # --------------------------------------------------------------------------- #
1303
+ def _rulings_path(slug):
1304
+ return ledger() / "rulings" / f"{slug}.json"
1305
+
1306
+
1307
+ def rejections(slug):
1308
+ path = _rulings_path(slug)
1309
+ if not path.exists():
1310
+ return []
1311
+ return json.loads(path.read_text(encoding="utf-8")).get("rejected", [])
1312
+
1313
+
1314
+ def _same(entry, turn):
1315
+ """Whether a stored ``{session, turn, logged}`` names this corpus turn."""
1316
+ if entry["session"] != turn.session:
1317
+ return False
1318
+ if entry.get("logged") and turn.logged is not None:
1319
+ return entry["logged"] == _instant(turn.logged)
1320
+ return entry.get("turn") == turn.number
1321
+
1322
+
1323
+ def _rejection(slug, corpus, *, session, turn, reason):
1324
+ """``(entry, target)`` for one rejection, checked but not yet written."""
1325
+ reason = " ".join(reason.split())
1326
+ if not reason:
1327
+ raise ValueError(
1328
+ "a rejection needs its reason, so no later pass reads it again"
1329
+ )
1330
+ sid, family, path, live = corpus.session(session)
1331
+ if live:
1332
+ raise LookupError(f"{_short(sid)} is not captured yet; capture it first")
1333
+ target = next(
1334
+ (t for t in corpus.turns(sid, family, path) if t.number == turn), None
1335
+ )
1336
+ if target is None:
1337
+ raise LookupError(f"{_short(sid)} has no author turn {turn}")
1338
+ entry = {
1339
+ "session": sid,
1340
+ "turn": turn,
1341
+ "logged": _instant(target.logged),
1342
+ "reason": reason,
1343
+ "date": date.today().isoformat(),
1344
+ }
1345
+ return entry, target
1346
+
1347
+
1348
+ def _record_rejections(slug, pairs):
1349
+ """Write checked rejections to a section's ruling, all or none."""
1350
+ path = _rulings_path(slug)
1351
+ with trail._locked(path):
1352
+ rejected = rejections(slug)
1353
+ for i, (_, target) in enumerate(pairs):
1354
+ if any(_same(e, target) for e in rejected) or any(
1355
+ _same(e, target) for e, _ in pairs[:i]
1356
+ ):
1357
+ raise LookupError(
1358
+ f"{target.short} turn {target.number} is already rejected "
1359
+ f"for #{slug}"
1360
+ )
1361
+ rejected += [entry for entry, _ in pairs]
1362
+ body = {"slug": slug, "rejected": rejected}
1363
+ _write_private(path, json.dumps(body, indent=2) + "\n")
1364
+
1365
+
1366
+ def reject(slug, *, session, turn, reason):
1367
+ """Record that a candidate does not belong on a section's trail, and why."""
1368
+ trail._known_slug(slug, trail._spec_titles())
1369
+ pair = _rejection(slug, trail.Corpus(), session=session, turn=turn, reason=reason)
1370
+ _record_rejections(slug, [pair])
1371
+ return pair[1]
1372
+
1373
+
1374
+ def rule(slug, lines):
1375
+ """Apply a section's rulings from JSON lines; nothing is written unless all hold.
1376
+
1377
+ Each line keeps a turn or rejects it::
1378
+
1379
+ {"keep": "sharpen", "session": "ID", "turn": 4, "note": "…"}
1380
+ {"reject": "why it does not belong", "session": "ID", "turn": 5}
1381
+
1382
+ A keep may carry an ``excerpt``. Every line is checked against the corpus,
1383
+ the trail and the ledger before anything is written, so a mistake on the
1384
+ fortieth line leaves the first thirty-nine unapplied rather than half a
1385
+ section ruled. Returns ``(kept, rejected)`` as lists of turns.
1386
+ """
1387
+ titles = trail._spec_titles()
1388
+ trail._known_slug(slug, titles)
1389
+ corpus = trail.Corpus()
1390
+ keeps, drops, errors = [], [], []
1391
+ for number, line in enumerate(lines, 1):
1392
+ if not line.strip() or line.lstrip().startswith("#"):
1393
+ continue
1394
+ try:
1395
+ item = json.loads(line)
1396
+ if not isinstance(item, dict):
1397
+ raise ValueError("not a JSON object")
1398
+ if ("keep" in item) == ("reject" in item):
1399
+ raise ValueError("give either keep (a kind) or reject (a reason)")
1400
+ if not isinstance(item.get("turn"), int) or not item.get("session"):
1401
+ raise ValueError("name the turn with session and an integer turn")
1402
+ if "keep" in item:
1403
+ keeps.append(
1404
+ trail.prepare(
1405
+ slug,
1406
+ item["keep"],
1407
+ session=item["session"],
1408
+ turn=item["turn"],
1409
+ note=item.get("note", ""),
1410
+ excerpt=item.get("excerpt", ""),
1411
+ corpus=corpus,
1412
+ titles=titles,
1413
+ )
1414
+ )
1415
+ else:
1416
+ drops.append(
1417
+ _rejection(
1418
+ slug,
1419
+ corpus,
1420
+ session=item["session"],
1421
+ turn=item["turn"],
1422
+ reason=str(item["reject"]),
1423
+ )
1424
+ )
1425
+ except (ValueError, LookupError) as exc:
1426
+ errors.append(f"line {number}: {exc}")
1427
+ for _, target in keeps:
1428
+ if any(_same(entry, target) for entry, _ in drops):
1429
+ errors.append(
1430
+ f"{target.short} turn {target.number} is both kept and rejected"
1431
+ )
1432
+ for entry in rejections(slug):
1433
+ for _, target in keeps:
1434
+ if _same(entry, target):
1435
+ errors.append(
1436
+ f"{target.short} turn {target.number} is already rejected "
1437
+ f"for #{slug}"
1438
+ )
1439
+ if errors:
1440
+ raise LookupError(
1441
+ "nothing was written; fix these and run it again:\n " + "\n ".join(errors)
1442
+ )
1443
+ if keeps:
1444
+ trail.append(slug, [crumb for crumb, _ in keeps], titles)
1445
+ if drops:
1446
+ _record_rejections(slug, drops)
1447
+ return [t for _, t in keeps], [t for _, t in drops]
1448
+
1449
+
1450
+ # --------------------------------------------------------------------------- #
1451
+ # a section's worklist
1452
+ # --------------------------------------------------------------------------- #
1453
+ @dataclass
1454
+ class Candidate:
1455
+ turn: trail.Turn
1456
+ flags: list = field(default_factory=list) # (model, flag as stored)
1457
+ commits: list = field(default_factory=list) # (sha8, subject, how)
1458
+ notes: list = field(default_factory=list) # what the ruler should know first
1459
+ reply: str = "" # the model's reply this turn answered
1460
+
1461
+
1462
+ def worklist(slug, *, commits=True, everything=False, also=()):
1463
+ """``(pending, on_trail, rejected, stale)`` candidates for one section.
1464
+
1465
+ Candidates come from the readers' flags and, with ``commits``, from the
1466
+ commits that changed the section. ``also`` names neighbouring sections
1467
+ whose flags are candidates here too: a reader chooses one section for a
1468
+ turn that fits two, and the ruler of the other would never see it. One
1469
+ already on the trail or rejected is left out unless ``everything``. A flag
1470
+ whose turn the corpus no longer holds is ``stale``, reported rather than
1471
+ silently dropped.
1472
+ """
1473
+ titles = trail._spec_titles()
1474
+ trail._known_slug(slug, titles)
1475
+ for other in also:
1476
+ trail._known_slug(other, titles)
1477
+ wanted = {slug, *also}
1478
+ corpus = trail.Corpus()
1479
+ found, stale = {}, []
1480
+
1481
+ def at(sid, number=None, logged=None):
1482
+ captured = corpus.captured().get(sid)
1483
+ if captured is None:
1484
+ return None
1485
+ for t in corpus.turns(sid, *captured):
1486
+ if logged and t.logged is not None:
1487
+ if _instant(t.logged) == logged:
1488
+ return t
1489
+ elif t.number == number:
1490
+ return t
1491
+ return None
1492
+
1493
+ for read in _reads():
1494
+ for entry in read["candidates"]:
1495
+ for item in entry["sections"]:
1496
+ if item["slug"] not in wanted:
1497
+ continue
1498
+ turn = at(read["session"], entry["turn"], entry.get("logged"))
1499
+ if turn is None:
1500
+ stale.append((read["session"], entry["turn"]))
1501
+ continue
1502
+ c = found.setdefault((turn.session, turn.number), Candidate(turn))
1503
+ c.flags.append(
1504
+ (read.get("model") or read.get("requested") or "?", item)
1505
+ )
1506
+ if commits:
1507
+ leads, _ = trail.suggest_leads(slug, corpus)
1508
+ for lead in leads:
1509
+ for sid, _, before in lead.sessions:
1510
+ for t in before:
1511
+ c = found.setdefault((t.session, t.number), Candidate(t))
1512
+ c.commits.append(
1513
+ (lead.sha[:8], lead.subject, "made in this session")
1514
+ )
1515
+ for quote, hits, _ in lead.quotes:
1516
+ for t in hits:
1517
+ c = found.setdefault((t.session, t.number), Candidate(t))
1518
+ c.commits.append(
1519
+ (lead.sha[:8], lead.subject, f"quoted “{quote[:60]}”")
1520
+ )
1521
+
1522
+ _annotate(found.values(), corpus)
1523
+ crumbs = [c for s, c in trail.load().crumbs() if s.slug == slug]
1524
+ rejected = rejections(slug)
1525
+ pending, on_trail, dropped = [], [], []
1526
+ for c in found.values():
1527
+ if any(trail._points_at(crumb, c.turn) for crumb in crumbs):
1528
+ on_trail.append(c)
1529
+ elif any(_same(e, c.turn) for e in rejected):
1530
+ dropped.append(c)
1531
+ else:
1532
+ pending.append(c)
1533
+
1534
+ days = {}
1535
+
1536
+ def order(c):
1537
+ return (_when(c.turn, corpus, days), c.turn.session, c.turn.number)
1538
+
1539
+ if everything:
1540
+ pending = pending + on_trail + dropped
1541
+ return sorted(pending, key=order), on_trail, dropped, stale
1542
+
1543
+
1544
+ def _day(turn, corpus, days):
1545
+ """The day a timeless turn's session is dated, cached per session."""
1546
+ if turn.session not in days:
1547
+ from quirework.provenance import show
1548
+
1549
+ family, path = corpus.captured().get(turn.session, (turn.family, turn.path))
1550
+ days[turn.session] = show.session_date(family, path)
1551
+ return days[turn.session]
1552
+
1553
+
1554
+ def _when(turn, corpus, days):
1555
+ """Where a turn sorts: its logged time, else its session's date, else first."""
1556
+ if turn.logged is not None:
1557
+ return turn.logged
1558
+ day = _day(turn, corpus, days)
1559
+ if day is not None:
1560
+ return datetime.combine(day, datetime.min.time(), timezone.utc)
1561
+ return datetime.min.replace(tzinfo=timezone.utc)
1562
+
1563
+
1564
+ def _annotate(candidates, corpus):
1565
+ """Note the two copies a ruler should see before ruling on either.
1566
+
1567
+ A turn sent twice: the author resent the same words, usually because the
1568
+ first copy drew no reply, and only the copy that was answered is the one
1569
+ the session went on from. A turn captured twice: a resumed or forked
1570
+ session repeats the history it inherited under a new id, logged at the
1571
+ same instant with the same words.
1572
+ """
1573
+ from quirework.provenance import show
1574
+
1575
+ candidates = list(candidates)
1576
+ replies = {}
1577
+ for c in candidates:
1578
+ t = c.turn
1579
+ if (t.family, t.path) not in replies:
1580
+ rows = show.exchanges(t.family, t.path)
1581
+ replies[(t.family, t.path)] = {
1582
+ n: (text, reply) for n, _, text, reply in rows
1583
+ }
1584
+ rows = replies[(t.family, t.path)]
1585
+ if t.number in rows:
1586
+ c.reply = rows[t.number][1]
1587
+ mine = trail._squash(t.text)
1588
+ for other in (t.number - 1, t.number + 1):
1589
+ if other in rows and trail._squash(rows[other][0]) == mine:
1590
+ after = rows.get(t.number + 1)
1591
+ if after is None:
1592
+ reply = "the session ends before its reply"
1593
+ elif after[1].strip():
1594
+ reply = "this copy drew a reply"
1595
+ else:
1596
+ reply = "this copy drew none"
1597
+ c.notes.append(f"same words as Human {other}; {reply}")
1598
+ twins = {}
1599
+ for c in candidates:
1600
+ if c.turn.logged is not None:
1601
+ key = (c.turn.logged, trail._squash(c.turn.text))
1602
+ twins.setdefault(key, []).append(c)
1603
+ for group in twins.values():
1604
+ if len({c.turn.session for c in group}) > 1:
1605
+ for c in group:
1606
+ others = ", ".join(
1607
+ f"{o.turn.short} Human {o.turn.number}"
1608
+ for o in group
1609
+ if o.turn.session != c.turn.session
1610
+ )
1611
+ c.notes.append(f"the same turn is captured again as {others}")
1612
+
1613
+
1614
+ def _worklist_lines(slug, *, commits, everything, utc, also=(), read=False):
1615
+ titles = trail._spec_titles()
1616
+ pending, on_trail, rejected, stale = worklist(
1617
+ slug, commits=commits, everything=everything, also=also
1618
+ )
1619
+ zone = timezone.utc if utc else None
1620
+ lines = [
1621
+ f"#{slug} — {titles[slug]}",
1622
+ (
1623
+ f"{len(pending)} candidate(s) to rule on, oldest first; "
1624
+ f"{len(on_trail)} on the trail, {len(rejected)} rejected. These are "
1625
+ f"leads, not breadcrumbs: read each turn before ruling on it."
1626
+ ),
1627
+ (
1628
+ f" keep: quirework spec trail add {slug} --kind KIND "
1629
+ f'--session ID --turn N --note "…"'
1630
+ ),
1631
+ (
1632
+ f" reject: quirework spec trail backfill reject {slug} "
1633
+ f'--session ID --turn N --reason "…"'
1634
+ ),
1635
+ f" many: quirework spec trail backfill rule {slug} FILE",
1636
+ ]
1637
+ if also:
1638
+ lines.append(
1639
+ " with the flags of "
1640
+ + ", ".join(f"#{s}" for s in also)
1641
+ + ", marked where a reader named that section instead"
1642
+ )
1643
+ if stale:
1644
+ lines.append(
1645
+ f" {len(stale)} flag(s) name a turn the corpus no longer holds: "
1646
+ + ", ".join(f"{_short(s)} turn {n}" for s, n in stale[:5])
1647
+ )
1648
+ corpus, days = trail.Corpus(), {}
1649
+ for c in pending:
1650
+ t = c.turn
1651
+ if t.logged:
1652
+ when = f"{t.logged.astimezone(zone):%Y-%m-%d %H:%M}"
1653
+ else:
1654
+ day = _day(t, corpus, days)
1655
+ when = f"{day:%Y-%m-%d} (day only)" if day else "undated"
1656
+ lines += ["", f"{when} {t.harness} {t.short} Human {t.number}"]
1657
+ for model, item in c.flags:
1658
+ where = f" [#{item['slug']}]" if item["slug"] != slug else ""
1659
+ lines.append(
1660
+ f" {model}{where}: {item['kind']} ({item['confidence']}) "
1661
+ f"{item['reason']}"
1662
+ )
1663
+ for sha, subject, how in c.commits:
1664
+ lines.append(f" commit {sha} {trail._opening(subject, 50)}: {how}")
1665
+ for note in c.notes:
1666
+ lines.append(f" note {note}")
1667
+ if read:
1668
+ lines += _whole(c)
1669
+ else:
1670
+ lines.append(f" opens {trail._opening(t.text, 110)}")
1671
+ lines.append(f" read quirework provenance show {t.short} --turn {t.number}")
1672
+ return lines
1673
+
1674
+
1675
+ def _whole(candidate):
1676
+ """A candidate as the ruler reads it: the turn, and the reply it answered.
1677
+
1678
+ The reply is cut to its end, as a packet cuts it, because a reply closes on
1679
+ what it proposes, and a bare "yes, go" accepts only that. The turn is
1680
+ clipped only where it is long enough to be mostly pasted material.
1681
+ """
1682
+ lines = []
1683
+ if candidate.reply.strip():
1684
+ lines += ["", " It answered the end of this reply:", ""]
1685
+ lines += [
1686
+ f" {line}".rstrip()
1687
+ for line in _quoted(_tail(candidate.reply, REPLY_CHARS)).splitlines()
1688
+ ]
1689
+ lines += ["", " The author wrote:", ""]
1690
+ body = _clip(candidate.turn.text) or "(nothing)"
1691
+ lines += [f" {line}".rstrip() for line in body.splitlines()]
1692
+ return lines + [""]
1693
+
1694
+
1695
+ # --------------------------------------------------------------------------- #
1696
+ # coverage
1697
+ # --------------------------------------------------------------------------- #
1698
+ def status():
1699
+ """How far the backfill has come: sessions read, and sections left to rule."""
1700
+ from quirework.provenance import scope as SCOPE
1701
+
1702
+ corpus = trail.Corpus()
1703
+ kept = _kept(corpus)
1704
+ slugs = [s.slug for s in notes.spec_sections() if s.slug]
1705
+ reads = _reads()
1706
+ every = {sid: _Session(family, path) for sid, (family, path) in kept.items()}
1707
+ cover, stale = _coverage(every, reads)
1708
+ older = {r["session"] for r in reads} - set(cover)
1709
+ unoffered = {
1710
+ slug
1711
+ for r in reads
1712
+ if _current(r)
1713
+ for slug in slugs
1714
+ if slug not in r.get("slugs", [])
1715
+ }
1716
+ rows = {}
1717
+ for sid, (family, path) in kept.items():
1718
+ if not every[sid].numbers:
1719
+ continue
1720
+ key = (trail._HARNESS_OF[family], SCOPE.scope_of_id(sid))
1721
+ row = rows.setdefault(key, [0, 0, 0, 0, 0])
1722
+ unread = _unseen(every[sid], cover[sid]) if cover.get(sid) else None
1723
+ row[0] += 1
1724
+ row[1] += unread == []
1725
+ row[2] += sid in older
1726
+ row[3] += bool(unread)
1727
+ row[4] += len(unread or ())
1728
+ lines = ["sessions with author turns, by harness and scope:"]
1729
+ for (harness, scope), (total, done, old, part, left) in sorted(rows.items()):
1730
+ extra = f", {old} read only from an older packet" if old else ""
1731
+ extra += f", {part} read only in part ({left:,} turns unread)" if part else ""
1732
+ lines.append(f" {harness:<12} {scope:<7} {done:>4} of {total:>4} read{extra}")
1733
+ whole = sorted(sid for sid in stale if not cover.get(sid))
1734
+ if whole:
1735
+ lines.append(
1736
+ f" {len(whole)} session(s) will be read whole again, because an "
1737
+ "earlier read no longer fits the turns they hold: "
1738
+ + ", ".join(_short(sid) for sid in whole)
1739
+ )
1740
+ if unoffered:
1741
+ lines.append(
1742
+ f" {len(unoffered)} section(s) are newer than some reads, which "
1743
+ f"were not offered them: " + ", ".join(f"#{s}" for s in sorted(unoffered))
1744
+ )
1745
+ flagged = {}
1746
+ for read in reads:
1747
+ for entry in read["candidates"]:
1748
+ for item in entry["sections"]:
1749
+ flagged.setdefault(item["slug"], set()).add(
1750
+ (read["session"], entry.get("logged") or entry["turn"])
1751
+ )
1752
+ held = trail.counts()
1753
+ lines += [
1754
+ "",
1755
+ "sections: turns the readers flagged, breadcrumbs, rejections",
1756
+ "(a section's worklist adds the turns its commits lead to):",
1757
+ ]
1758
+ for slug in slugs:
1759
+ n = len(flagged.get(slug, ()))
1760
+ if n or held.get(slug) or rejections(slug):
1761
+ lines.append(
1762
+ f" #{slug:<34} {n:>4} flagged {held.get(slug, 0):>3} on the "
1763
+ f"trail {len(rejections(slug)):>3} rejected"
1764
+ )
1765
+ return lines
1766
+
1767
+
1768
+ # --------------------------------------------------------------------------- #
1769
+ # command line
1770
+ # --------------------------------------------------------------------------- #
1771
+ USAGE = """\
1772
+ backfilling a trail from the corpus, in three steps:
1773
+
1774
+ 1. hand every session to a cheap reader, once:
1775
+ quirework spec trail backfill plan --harness H --model M --effort E [--out DIR]
1776
+ then the prepare, start and import commands it prints; import holds a
1777
+ seat whose receipt carries a red flag its plan did not accept. A plan
1778
+ hands out only the turns no reader saw, so one run after a reader
1779
+ learns a new kind of turn hands out just those turns; --reread hands
1780
+ out every turn of the sessions it picks
1781
+ 2. rule on one section at a time, with a strong reader:
1782
+ quirework spec trail backfill SLUG the section's candidates, oldest first
1783
+ --also OTHER with the turns readers flagged for
1784
+ a neighbouring section instead
1785
+ --read each turn whole, with the end of
1786
+ the reply it answered
1787
+ quirework spec trail add SLUG ... keep one as a breadcrumb
1788
+ quirework spec trail backfill reject SLUG --session ID --turn N --reason TEXT
1789
+ quirework spec trail backfill rule SLUG FILE
1790
+ keep and reject many at once, from
1791
+ JSON lines; all or nothing
1792
+ 3. see how far it has come:
1793
+ quirework spec trail backfill status
1794
+
1795
+ quirework spec trail backfill packet ID... what a reader is handed
1796
+ quirework spec trail backfill menu the sections a reader names
1797
+ """
1798
+
1799
+
1800
+ def main(argv):
1801
+ if not argv or argv[0] in ("-h", "--help"):
1802
+ print(USAGE)
1803
+ return 0
1804
+ command, rest = argv[0], argv[1:]
1805
+ try:
1806
+ if command == "packet":
1807
+ corpus = trail.Corpus()
1808
+ for prefix in rest:
1809
+ sid, family, path, _ = corpus.session(prefix)
1810
+ print(packet(sid, family, path))
1811
+ return 0
1812
+ if command == "menu":
1813
+ print(menu(), end="")
1814
+ return 0
1815
+ if command == "plan":
1816
+ ap = argparse.ArgumentParser(prog="quirework spec trail backfill plan")
1817
+ ap.add_argument(
1818
+ "--out",
1819
+ help="a new directory for packets (default: RUN/packets in a "
1820
+ "new run directory in the project's private state)",
1821
+ )
1822
+ ap.add_argument(
1823
+ "--harness", required=True, choices=("claude", "codex", "agy")
1824
+ )
1825
+ ap.add_argument("--model", required=True, help="a model family or version")
1826
+ ap.add_argument("--effort", required=True)
1827
+ ap.add_argument(
1828
+ "--tier", default="cheap", choices=("cheap", "standard", "premium")
1829
+ )
1830
+ ap.add_argument(
1831
+ "--session",
1832
+ action="append",
1833
+ dest="sessions",
1834
+ metavar="ID",
1835
+ help="only this session, and only its turns no reader saw",
1836
+ )
1837
+ ap.add_argument("--scope", help="only sessions of this provenance scope")
1838
+ ap.add_argument("--limit", type=int, help="at most N sessions")
1839
+ ap.add_argument(
1840
+ "--budget",
1841
+ type=int,
1842
+ default=BATCH_TOKENS,
1843
+ help="estimated tokens of packets per seat",
1844
+ )
1845
+ ap.add_argument(
1846
+ "--turns",
1847
+ type=int,
1848
+ default=SEAT_TURNS,
1849
+ help="author turns per seat; a longer session is read in parts",
1850
+ )
1851
+ ap.add_argument(
1852
+ "--material",
1853
+ action="append",
1854
+ default=[],
1855
+ metavar="FILE",
1856
+ help="book guidance every reader gets, such as retired names",
1857
+ )
1858
+ ap.add_argument(
1859
+ "--reread",
1860
+ action="store_true",
1861
+ help="hand out every turn, including those a current read saw",
1862
+ )
1863
+ ap.add_argument(
1864
+ "--external",
1865
+ action="store_true",
1866
+ help="seats whose answers are imported by hand",
1867
+ )
1868
+ a = ap.parse_args(rest)
1869
+ done = plan(
1870
+ a.out,
1871
+ harness=a.harness,
1872
+ model=a.model,
1873
+ effort=a.effort,
1874
+ tier=a.tier,
1875
+ sessions=a.sessions,
1876
+ scope=a.scope,
1877
+ limit=a.limit,
1878
+ budget=a.budget,
1879
+ turns=a.turns,
1880
+ materials=a.material,
1881
+ reread=a.reread,
1882
+ external=a.external,
1883
+ )
1884
+ print("\n".join(plan_lines(done)))
1885
+ return 0
1886
+ if command == "import":
1887
+ ap = argparse.ArgumentParser(prog="quirework spec trail backfill import")
1888
+ ap.add_argument("record")
1889
+ ap.add_argument(
1890
+ "--accept",
1891
+ action="append",
1892
+ default=[],
1893
+ metavar="CODE",
1894
+ help="a red flag to accept for a record whose plan predates "
1895
+ "declaring it (session-bus)",
1896
+ )
1897
+ a = ap.parse_args(rest)
1898
+ report = import_run(a.record, accept=a.accept)
1899
+ print("\n".join(import_lines(report)))
1900
+ return 1 if any(row["held"] for row in report) else 0
1901
+ if command == "reject":
1902
+ ap = argparse.ArgumentParser(prog="quirework spec trail backfill reject")
1903
+ ap.add_argument("slug")
1904
+ ap.add_argument("--session", required=True, metavar="ID")
1905
+ ap.add_argument("--turn", required=True, type=int, metavar="N")
1906
+ ap.add_argument("--reason", required=True)
1907
+ a = ap.parse_args(rest)
1908
+ t = reject(a.slug, session=a.session, turn=a.turn, reason=a.reason)
1909
+ print(f"rejected for #{a.slug}: {t.harness} {t.short} Human {t.number}")
1910
+ return 0
1911
+ if command == "rule":
1912
+ ap = argparse.ArgumentParser(
1913
+ prog="quirework spec trail backfill rule",
1914
+ description="Keep and reject a section's candidates from a file of "
1915
+ 'JSON lines: {"keep": KIND, "session": ID, "turn": N, "note": '
1916
+ '"…"} or {"reject": REASON, "session": ID, "turn": N}. Every '
1917
+ "line is checked before any is written.",
1918
+ )
1919
+ ap.add_argument("slug")
1920
+ ap.add_argument("file", help="the rulings, or - for standard input")
1921
+ a = ap.parse_args(rest)
1922
+ source = (
1923
+ sys.stdin
1924
+ if a.file == "-"
1925
+ else open(Path(a.file).expanduser(), encoding="utf-8")
1926
+ )
1927
+ with source:
1928
+ kept, dropped = rule(a.slug, source.read().splitlines())
1929
+ print(f"#{a.slug}: {len(kept)} kept on the trail, {len(dropped)} rejected")
1930
+ return 0
1931
+ if command == "status":
1932
+ print("\n".join(status()))
1933
+ return 0
1934
+ ap = argparse.ArgumentParser(prog="quirework spec trail backfill")
1935
+ ap.add_argument("slug")
1936
+ ap.add_argument("--all", action="store_true", help="include ruled candidates")
1937
+ ap.add_argument(
1938
+ "--no-commits",
1939
+ action="store_true",
1940
+ help="only the readers' flags, not the commit history",
1941
+ )
1942
+ ap.add_argument("--utc", action="store_true")
1943
+ ap.add_argument(
1944
+ "--also",
1945
+ action="append",
1946
+ default=[],
1947
+ metavar="SLUG",
1948
+ help="also the turns readers flagged for this neighbouring section",
1949
+ )
1950
+ ap.add_argument(
1951
+ "--read",
1952
+ action="store_true",
1953
+ help="print each turn whole, with the end of the reply it answered",
1954
+ )
1955
+ a = ap.parse_args(argv)
1956
+ print(
1957
+ "\n".join(
1958
+ _worklist_lines(
1959
+ a.slug,
1960
+ commits=not a.no_commits,
1961
+ everything=a.all,
1962
+ utc=a.utc,
1963
+ also=a.also,
1964
+ read=a.read,
1965
+ )
1966
+ )
1967
+ )
1968
+ print("\n" + trail._CONTEXT)
1969
+ return 0
1970
+ except (LookupError, ValueError) as exc:
1971
+ print(f"backfill: {exc}", file=sys.stderr)
1972
+ return 2