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,1097 @@
1
+ """Forked reader experiments driven by a project-owned procedure.
2
+
3
+ The operator owns assignment, artifacts and interpretation. A reader sees the
4
+ same page transport and frozen read-through prompt on every arm. Runtime homes
5
+ are copied only from an idle parent and never shared between arms.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import base64
12
+ from collections import Counter
13
+ import fcntl
14
+ import hashlib
15
+ import importlib.util
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ import uuid
25
+
26
+ from quirework.core import usage as usage_records
27
+ from quirework.providers import blind
28
+ from quirework.providers import claude_login
29
+ from quirework.providers import quota
30
+ from quirework.providers import replies
31
+
32
+ # Above every published ceiling on purpose; Codex clamps it down.
33
+ OVER_REQUEST = 1_050_000
34
+ from quirework.project import current_project
35
+
36
+ PROMPT_REVISION = "2026-09-04-reader-understanding"
37
+ PROTOCOL_REVISION = "chapter-experiment-v2"
38
+ VIRTUAL = Path.home() / "tmp" / "reader-workspace"
39
+
40
+ ARMS = ("a", "b", "c")
41
+ CODEX_TRANSPORT_FALLBACK = "Falling back from WebSockets to HTTPS transport."
42
+ SUPPORTED_MODELS = {"codex": "gpt-5.6-sol", "claude": "opus"}
43
+
44
+
45
+ def _settings():
46
+ """Resolve paths from the current operation, without import-time state."""
47
+ from types import SimpleNamespace
48
+
49
+ SOURCE = current_project().path("sops.reader_driver")
50
+ return SimpleNamespace(SOURCE=SOURCE)
51
+
52
+
53
+ def digest(path):
54
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
55
+
56
+
57
+ def write_json(path, value):
58
+ path = Path(path)
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as f:
61
+ json.dump(value, f, indent=2, ensure_ascii=False)
62
+ f.write("\n")
63
+ f.flush()
64
+ os.fsync(f.fileno())
65
+ tmp = f.name
66
+ os.replace(tmp, path)
67
+ fd = os.open(path.parent, os.O_DIRECTORY)
68
+ try:
69
+ os.fsync(fd)
70
+ finally:
71
+ os.close(fd)
72
+
73
+
74
+ def read_json(path):
75
+ return json.loads(Path(path).read_text())
76
+
77
+
78
+ def validate_runner_model(runner, model):
79
+ expected = SUPPORTED_MODELS[runner]
80
+ if model != expected:
81
+ raise RuntimeError(
82
+ f"{runner} chapter experiments require model {expected!r}, not {model!r}"
83
+ )
84
+
85
+
86
+ def tree_hashes(root):
87
+ root = Path(root)
88
+ if any(p.is_symlink() for p in root.rglob("*")):
89
+ raise RuntimeError(f"symlink in experiment state: {root}")
90
+ return {
91
+ str(p.relative_to(root)): digest(p)
92
+ for p in sorted(root.rglob("*"))
93
+ if p.is_file()
94
+ }
95
+
96
+
97
+ def events(path):
98
+ """An incomplete final line is a pending writer, never a completed turn."""
99
+ if not Path(path).exists():
100
+ return []
101
+ lines = Path(path).read_text().splitlines()
102
+ return [json.loads(line) for line in lines if line.strip()]
103
+
104
+
105
+ def prompts(seat):
106
+ spec = importlib.util.spec_from_file_location(
107
+ "reader_instrument", _settings().SOURCE
108
+ )
109
+ module = importlib.util.module_from_spec(spec)
110
+ spec.loader.exec_module(module)
111
+ prefix = seat.upper()
112
+ return {
113
+ "first": getattr(module, f"{prefix}_TURN_ONE"),
114
+ "next": getattr(module, f"{prefix}_TURN_NEXT"),
115
+ "headings": getattr(module, f"{prefix}_CHAPTER_HEADINGS"),
116
+ }
117
+
118
+
119
+ def missing_headings(text, headings):
120
+ """Match heading lines while preserving typographic punctuation in evidence."""
121
+ actual = {
122
+ line.strip().replace("\u2019", "'")
123
+ for line in text.splitlines()
124
+ if line.startswith("## ")
125
+ }
126
+ return [heading for heading in headings if heading not in actual]
127
+
128
+
129
+ def render_pages(pdf, directory):
130
+ directory.mkdir(parents=True)
131
+ subprocess.run(
132
+ ["pdftoppm", "-png", "-scale-to", "1600", str(pdf), str(directory / "page")],
133
+ check=True,
134
+ capture_output=True,
135
+ )
136
+ pages = sorted(
137
+ directory.glob("page-*.png"), key=lambda p: int(p.stem.rsplit("-", 1)[1])
138
+ )
139
+ from pypdf import PdfReader
140
+
141
+ if len(pages) != len(PdfReader(pdf).pages) or not pages:
142
+ raise RuntimeError("incomplete page rasterization")
143
+ return pages
144
+
145
+
146
+ def snapshot(source, target):
147
+ """Copy an idle home, detecting concurrent writes rather than copying a WAL mid-write."""
148
+ before = tree_hashes(source)
149
+ shutil.copytree(source, target)
150
+ if tree_hashes(source) != before or tree_hashes(target) != before:
151
+ raise RuntimeError("parent state changed during snapshot; do not fork it")
152
+ return before
153
+
154
+
155
+ def assert_model(runner, requested, actual, established=None):
156
+ if established is not None:
157
+ valid = actual == established
158
+ elif runner == "claude" and requested in ("opus", "opus[1m]"):
159
+ valid = actual.startswith("claude-opus-")
160
+ else:
161
+ valid = actual == requested
162
+ if not valid:
163
+ raise RuntimeError(
164
+ f"served model {actual!r}, expected {established or requested!r}"
165
+ )
166
+
167
+
168
+ def reject_compaction(rows):
169
+ for row in rows:
170
+ payload = row.get("payload") or {}
171
+ item = payload.get("item") or {}
172
+ fields = [
173
+ row.get("type"),
174
+ row.get("subtype"),
175
+ payload.get("type"),
176
+ item.get("type"),
177
+ ]
178
+ if any("compact" in str(value).lower() for value in fields if value):
179
+ raise RuntimeError("compaction invalidates this experiment")
180
+
181
+
182
+ def codex_item_diagnostics(rows):
183
+ """Return recognized transport notices and reject every other item kind."""
184
+ notices = []
185
+ for row in rows:
186
+ if row.get("type") != "item.completed":
187
+ continue
188
+ item = row.get("item", {})
189
+ if item.get("type") in ("agent_message", "reasoning"):
190
+ continue
191
+ if item.get("type") == "error" and item.get("message", "").startswith(
192
+ CODEX_TRANSPORT_FALLBACK
193
+ ):
194
+ notices.append(item["message"])
195
+ continue
196
+ raise RuntimeError("Codex used a tool or an unexpected output item")
197
+ return notices
198
+
199
+
200
+ def parse_codex(rows, home, model, effort, established):
201
+ started = [r["thread_id"] for r in rows if r.get("type") == "thread.started"]
202
+ if len(started) != 1:
203
+ raise RuntimeError("missing or ambiguous Codex thread identity")
204
+ sid = started[0]
205
+ transport_notices = codex_item_diagnostics(rows)
206
+ answers = [
207
+ r["item"]["text"]
208
+ for r in rows
209
+ if r.get("type") == "item.completed"
210
+ and r.get("item", {}).get("type") == "agent_message"
211
+ ]
212
+ completed = [r for r in rows if r.get("type") == "turn.completed"]
213
+ if not answers or not completed:
214
+ raise RuntimeError("Codex turn has no completed answer")
215
+ matches = list((home / "sessions").glob(f"**/*{sid}.jsonl"))
216
+ if len(matches) != 1:
217
+ raise RuntimeError("missing or ambiguous Codex rollout for this thread")
218
+ roll = events(matches[0])
219
+ reject_compaction(roll)
220
+ contexts = [r["payload"] for r in roll if r.get("type") == "turn_context"]
221
+ if not contexts:
222
+ raise RuntimeError("missing Codex model receipt")
223
+ ctx = contexts[-1]
224
+ actual = ctx.get("model")
225
+ assert_model("codex", model, actual, established)
226
+ if ctx.get("effort") != effort:
227
+ raise RuntimeError("Codex effort changed")
228
+ windows = [
229
+ r["payload"].get("model_context_window", 0)
230
+ for r in roll
231
+ if r.get("type") == "event_msg"
232
+ and r.get("payload", {}).get("type") == "task_started"
233
+ ]
234
+ # STILL A FIXED FLOOR, and deliberately so. Deriving it from the catalog
235
+ # was tried and reverted: this is the receipt verifier, and making it
236
+ # resolve a model would put a live catalog read on the verification path,
237
+ # which the tests rightly refuse by running the whole module on a
238
+ # fictional model. The number is the one hazard the launch side no longer
239
+ # has (a model ceilinged below it would be refused for running at its own
240
+ # maximum), so the fix belongs at prepare time: freeze the resolved
241
+ # ceiling and floor into the plan, and read them here. Every model this
242
+ # experiment has run publishes 872,000 at 95 percent, so nothing is
243
+ # refused today.
244
+ if not windows or windows[-1] < 800_000:
245
+ raise RuntimeError("Codex effective context below the reader requirement")
246
+ meta = next((r["payload"] for r in roll if r.get("type") == "session_meta"), {})
247
+ return answers[-1], {
248
+ "session_id": sid,
249
+ "model": actual,
250
+ "usage": completed[-1]["usage"],
251
+ "effective_context": windows[-1],
252
+ "forked_from": meta.get("forked_from_id"),
253
+ "transport_notices": transport_notices,
254
+ "rollout": str(matches[0]),
255
+ }
256
+
257
+
258
+ def parse_claude(rows, home, model, effort, established):
259
+ reject_compaction(rows)
260
+ results = [r for r in rows if r.get("type") == "result"]
261
+ if len(results) != 1:
262
+ raise RuntimeError("Claude turn has no unique result")
263
+ result = results[0]
264
+ if quota.claude_auth(rows):
265
+ raise RuntimeError(
266
+ "the reader's Claude login was revoked or expired mid-turn, because "
267
+ "the host renewed it or it lapsed. This adapter never continues a "
268
+ "turn: once `claude` on the host is signed in, prepare a distinct "
269
+ "experiment rather than rerunning this one"
270
+ )
271
+ if result.get("is_error") or result.get("subtype") != "success":
272
+ raise RuntimeError(
273
+ f"Claude result is {result.get('subtype')}: "
274
+ f"{str(result.get('result', ''))[:180]}"
275
+ )
276
+ for row in rows:
277
+ if row.get("type") == "assistant":
278
+ content = row.get("message", {}).get("content", [])
279
+ if any(b.get("type") in ("tool_use", "server_tool_use") for b in content):
280
+ raise RuntimeError("Claude used a tool in an attached-pages experiment")
281
+ usage = result.get("modelUsage") or {}
282
+ served = {
283
+ r.get("message", {}).get("model") for r in rows if r.get("type") == "assistant"
284
+ }
285
+ if len(served) != 1:
286
+ raise RuntimeError("Claude missing or mixed assistant-model receipt")
287
+ actual = served.pop()
288
+ if actual not in usage or not usage[actual].get("outputTokens"):
289
+ raise RuntimeError("Claude missing served-model usage receipt")
290
+ # Claude's documented first-turn Haiku preflight is billing overhead,
291
+ # not a fallback reader. Verify the actual assistant messages separately.
292
+ auxiliary = {k: v for k, v in usage.items() if k != actual}
293
+ if any(not k.startswith("claude-haiku-") for k in auxiliary):
294
+ raise RuntimeError("unrecognized auxiliary model usage")
295
+ init = [r for r in rows if r.get("type") == "system" and r.get("subtype") == "init"]
296
+ if len(init) != 1 or init[0].get("tools") != ["Read"] or init[0].get("mcp_servers"):
297
+ raise RuntimeError("Claude tool or MCP configuration drifted")
298
+ if init[0].get("output_style") != "default" or init[0].get("model") != actual:
299
+ raise RuntimeError("Claude initial model or output style drifted")
300
+ assert_model("claude", model, actual, established)
301
+ sid = result["session_id"]
302
+ matches = list((home / "projects").glob(f"**/{sid}.jsonl"))
303
+ if len(matches) != 1:
304
+ raise RuntimeError("Claude session was not durably persisted")
305
+ native = events(matches[0])
306
+ reject_compaction(native)
307
+ # The result row holds only the last response, so a reaction the output
308
+ # ceiling cut is rebuilt from every part of it.
309
+ answer, reply = replies.claude(rows, native)
310
+ return answer, {
311
+ "session_id": sid,
312
+ "model": actual,
313
+ "usage": usage[actual],
314
+ "auxiliary_usage": auxiliary,
315
+ "cost_usd": result.get("total_cost_usd"),
316
+ "rollout": str(matches[0]),
317
+ "red_flags": replies.flags(reply),
318
+ **reply,
319
+ }
320
+
321
+
322
+ from quirework.providers.images import history_image_hashes
323
+
324
+
325
+ def command(runner, model, effort, mode, sid, images, prompt):
326
+ if runner == "codex":
327
+ args = ["codex", "exec"]
328
+ if mode == "start":
329
+ args += ["-s", "read-only"]
330
+ else:
331
+ args += [mode, sid]
332
+ args += [
333
+ "--ignore-user-config",
334
+ "--ignore-rules",
335
+ "--skip-git-repo-check",
336
+ "-m",
337
+ model,
338
+ "-c",
339
+ f'model_reasoning_effort="{effort}"',
340
+ "-c",
341
+ 'model_verbosity="high"',
342
+ "-c",
343
+ 'personality="none"',
344
+ "-c",
345
+ 'web_search="disabled"',
346
+ *blind.CODEX_SEAT,
347
+ # Over-request and let Codex clamp to the catalog maximum,
348
+ # which is what the operator's own config.toml does and says:
349
+ # "Codex clamps this to the model catalog maximum and reserves
350
+ # the model's configured headroom." Resolving the exact ceiling
351
+ # was tried and reverted: it puts a live catalog read on a path
352
+ # the tests exercise with a fictional model. The scope is the
353
+ # setting that matters either way, without which the limit is
354
+ # clamped to 90% and compaction is not disabled at all.
355
+ "-c",
356
+ f"model_context_window={OVER_REQUEST}",
357
+ "-c",
358
+ f"model_auto_compact_token_limit={OVER_REQUEST}",
359
+ "-c",
360
+ 'model_auto_compact_token_limit_scope="body_after_prefix"',
361
+ ]
362
+ for path in images:
363
+ args += ["-i", str(path)]
364
+ return args + ["--json", prompt], b""
365
+ args = [
366
+ "claude",
367
+ "-p",
368
+ "--input-format",
369
+ "stream-json",
370
+ "--output-format",
371
+ "stream-json",
372
+ "--verbose",
373
+ "--effort",
374
+ effort,
375
+ "--tools",
376
+ "Read",
377
+ "--allowedTools",
378
+ "Read",
379
+ *blind.BLIND_SETTINGS,
380
+ *blind.NO_SETTING_SOURCES,
381
+ *blind.NO_MCP,
382
+ ]
383
+ if mode == "start":
384
+ args += ["--model", model, "--session-id", sid]
385
+ else:
386
+ args += ["--resume", sid]
387
+ if mode == "fork":
388
+ args += ["--fork-session"]
389
+ # The host supplies actual image bytes below; no file-read tool is needed.
390
+ return args, None
391
+
392
+
393
+ def claude_environment():
394
+ """Supply only an access token; a blind child must not rotate shared OAuth.
395
+
396
+ No token is put in an argument, receipt, plan or snapshot. The documented
397
+ environment-token path takes precedence over persisted Claude credentials
398
+ and has no refresh token. Renew authentication outside the reader.
399
+ """
400
+ env = os.environ.copy()
401
+ if any(
402
+ env.get(k)
403
+ for k in (
404
+ "CLAUDE_BACKEND",
405
+ "ANTHROPIC_API_KEY",
406
+ "ANTHROPIC_AUTH_TOKEN",
407
+ "ANTHROPIC_BASE_URL",
408
+ "CLAUDE_CODE_USE_BEDROCK",
409
+ "CLAUDE_CODE_USE_VERTEX",
410
+ )
411
+ ):
412
+ raise RuntimeError(
413
+ "this experiment adapter requires direct claude.ai authentication"
414
+ )
415
+ if not env.get("CLAUDE_CODE_OAUTH_TOKEN"):
416
+ # With LAUNCH_MARGIN of life left, so the host does not renew the
417
+ # login out from under the reader (``claude_login``).
418
+ env["CLAUDE_CODE_OAUTH_TOKEN"], _ = claude_login.host_login()
419
+ return env
420
+
421
+
422
+ def canary(runner, label):
423
+ """Prove a reader seat's flags blind on the installed runner (``canary``).
424
+
425
+ The twin is ``command`` with the canary's prompt, model and effort, and
426
+ no page images, which are content rather than flags.
427
+ """
428
+ from quirework.providers import canary as canaries
429
+
430
+ def build(prompt, model, workdir, session):
431
+ args, payload = command(
432
+ runner, model, canaries.EFFORT, "start", session, [], prompt
433
+ )
434
+ if payload is None:
435
+ payload = image_message(prompt, [])
436
+ return canaries.Twin(args, payload)
437
+
438
+ return canaries.require(label, runner, build)
439
+
440
+
441
+ def image_message(prompt, images):
442
+ content = [{"type": "text", "text": prompt}]
443
+ content += [
444
+ {
445
+ "type": "image",
446
+ "source": {
447
+ "type": "base64",
448
+ "media_type": "image/png",
449
+ "data": base64.b64encode(Path(p).read_bytes()).decode(),
450
+ },
451
+ }
452
+ for p in images
453
+ ]
454
+ return (
455
+ json.dumps({"type": "user", "message": {"role": "user", "content": content}})
456
+ + "\n"
457
+ ).encode()
458
+
459
+
460
+ class Experiment:
461
+ def __init__(self, record, progress=False):
462
+ self.record = Path(record).resolve()
463
+ self.plan = read_json(self.record / "plan.json")
464
+ self.root = Path(self.plan["scratch"])
465
+ self.runner = self.plan["runner"]
466
+ self.progress = progress
467
+ self.state_file = self.record / "state.json"
468
+ self.state = (
469
+ read_json(self.state_file)
470
+ if self.state_file.exists()
471
+ else {"sessions": {}, "done": {}, "pending": None}
472
+ )
473
+
474
+ def save(self):
475
+ write_json(self.state_file, self.state)
476
+
477
+ def emit(self, message, terminal=False):
478
+ if self.progress or terminal:
479
+ print(message, flush=True)
480
+
481
+ def workspace(self, seat):
482
+ return self.root / seat / "seat"
483
+
484
+ def home(self, seat):
485
+ return blind.state_root(self.workspace(seat)) / self.runner
486
+
487
+ def banked_history_pages(self, seat):
488
+ pages = [
489
+ p
490
+ for index, item in enumerate(self.plan.get("prefix", []))
491
+ if f"prefix-{index:02d}-{item['stem']}" in self.state["done"]
492
+ for p in item["pages"]
493
+ ]
494
+ if seat in ARMS:
495
+ pages += [
496
+ p
497
+ for index, item in enumerate(self.plan["arms"][seat]["segments"])
498
+ if f"arm-{seat}-{index:02d}-{item['stem']}" in self.state["done"]
499
+ for p in item["pages"]
500
+ ]
501
+ return pages
502
+
503
+ def verify(self):
504
+ if (
505
+ digest(self.record / "plan.json")
506
+ != (self.record / "plan.sha256").read_text().strip()
507
+ ):
508
+ raise RuntimeError("frozen plan or assignment changed")
509
+ if self.plan["driver_sha"] != digest(__file__):
510
+ raise RuntimeError(
511
+ "driver changed since preparation; freeze a new experiment"
512
+ )
513
+ if self.plan["blind_sha"] != digest(blind.__file__):
514
+ raise RuntimeError("shared confinement code changed since preparation")
515
+ if blind.runner_version(self.runner) != self.plan["runner_version"]:
516
+ raise RuntimeError("runner version changed since preparation")
517
+ for rel, expected in self.plan["hashes"].items():
518
+ if digest(self.record / rel) != expected:
519
+ raise RuntimeError(f"frozen experiment input changed: {rel}")
520
+ for key, done in self.state["done"].items():
521
+ if digest(self.record / done["answer"]) != done["answer_sha"]:
522
+ raise RuntimeError(f"earlier reaction changed or disappeared: {key}")
523
+
524
+ def turn(self, seat, key, prompt, pages, mode, headings=()):
525
+ self.verify()
526
+ if key in self.state["done"]:
527
+ return self.state["done"][key]
528
+ pending = self.state["pending"]
529
+ if pending and pending["key"] != key:
530
+ raise RuntimeError(
531
+ f"unresolved submitted turn {pending['key']}; cannot skip it"
532
+ )
533
+ sid = self.state["sessions"].get(seat)
534
+ if mode == "fork":
535
+ sid = self.state["sessions"]["parent"]
536
+ elif mode == "start":
537
+ sid = str(uuid.uuid4())
538
+ if mode in ("resume", "fork") and not pending:
539
+ # A fork continues the parent's session inside the arm's own home,
540
+ # cloned from the checkpoint. Checked before the pages are staged,
541
+ # so a home that is gone leaves the seat as it was. A pending
542
+ # submission is reconciled, never launched again, so it is not.
543
+ blind.require_session(self.workspace(seat), self.runner, sid)
544
+ work = self.workspace(seat)
545
+ work.mkdir(parents=True, exist_ok=True)
546
+ staged = work / "pages"
547
+ staged.mkdir(exist_ok=True)
548
+ originals = [self.record / p for p in pages]
549
+ wanted = {f"page-{index:03d}.png" for index in range(len(originals))}
550
+ for old in staged.iterdir():
551
+ if old.is_symlink() or not old.is_file():
552
+ raise RuntimeError("unexpected entry in isolated page staging")
553
+ if old.name not in wanted:
554
+ old.unlink()
555
+ for index, source in enumerate(originals):
556
+ shutil.copyfile(source, staged / f"page-{index:03d}.png")
557
+ physical_images = sorted(staged.glob("*.png"))
558
+ virtual_images = [VIRTUAL / "pages" / p.name for p in physical_images]
559
+ raw = self.record / "raw" / f"events-{key}.jsonl"
560
+ err = self.record / "raw" / f"stderr-{key}.log"
561
+ raw.parent.mkdir(exist_ok=True)
562
+ args, payload = command(
563
+ self.runner,
564
+ self.plan["model"],
565
+ self.plan["effort"],
566
+ mode,
567
+ sid,
568
+ virtual_images,
569
+ prompt,
570
+ )
571
+ if payload is None:
572
+ payload = image_message(prompt, physical_images)
573
+ request_hash = hashlib.sha256(
574
+ prompt.encode()
575
+ + b"\0"
576
+ + "".join(digest(p) for p in physical_images).encode()
577
+ ).hexdigest()
578
+ if pending:
579
+ if pending["request_sha"] != request_hash:
580
+ raise RuntimeError("pending request differs from frozen request")
581
+ if pending.get("pid"):
582
+ try:
583
+ os.kill(pending["pid"], 0)
584
+ except ProcessLookupError:
585
+ pass
586
+ else:
587
+ raise RuntimeError(
588
+ "submitted process still exists; wait, do not resubmit"
589
+ )
590
+ else:
591
+ if raw.exists():
592
+ raise RuntimeError(
593
+ "unbanked event file exists; reconcile before submitting"
594
+ )
595
+ # Proven before the request is marked pending, so a seat that
596
+ # cannot be shown blind leaves no submission to reconcile.
597
+ proven = canary(self.runner, f"experiment {key}")
598
+ runtime_env = claude_environment() if self.runner == "claude" else None
599
+ self.state["pending"] = {
600
+ "key": key,
601
+ "request_sha": request_hash,
602
+ "mode": mode,
603
+ "parent_id": sid,
604
+ }
605
+ self.save()
606
+ if blind.backend_error():
607
+ raise RuntimeError(blind.backend_error())
608
+ if self.runner == "claude":
609
+ # Claude also keeps runtime configuration beside ~/.claude.
610
+ # Persist a fresh per-seat file; never mount the user's file.
611
+ blind.make_state_root(work)
612
+ config = self.home(seat) / "runtime-config.json"
613
+ config.parent.mkdir(parents=True, exist_ok=True)
614
+ if not config.exists():
615
+ write_json(config, {})
616
+ jail = blind.jail_argv(
617
+ work,
618
+ runners=[self.runner],
619
+ bind_credentials=self.runner != "claude",
620
+ env={"DISABLE_COMPACT": "1", "DISABLE_AUTOUPDATER": "1"},
621
+ canary=proven,
622
+ )
623
+ blind.hold(
624
+ work,
625
+ f"chapter comparison {self.record.name}: seat {seat} continues "
626
+ "this session until the comparison completes",
627
+ )
628
+ # Give every arm the same logical paths without sharing a mount or
629
+ # runtime home. The host's real seat path stays outside the prompt.
630
+ # Replace the existing chdir argument rather than emitting a
631
+ # second one (bwrap warns, and stderr must remain useful).
632
+ jail[jail.index("--chdir") + 1] = str(VIRTUAL)
633
+ jail += ["--bind", str(work), str(VIRTUAL), "--setenv", "PWD", str(VIRTUAL)]
634
+ if self.runner == "claude":
635
+ jail += ["--bind", str(config), str(Path.home() / ".claude.json")]
636
+ binds, missing = blind.runner_binds([self.runner])
637
+ if missing:
638
+ raise RuntimeError(
639
+ "missing blind launcher paths: " + "; ".join(missing)
640
+ )
641
+ usage_records.seat(
642
+ "experiment",
643
+ {
644
+ "harness": self.runner,
645
+ "model": self.plan["model"],
646
+ "effort": self.plan["effort"],
647
+ },
648
+ )
649
+ blind.write_receipt(
650
+ self.record / "raw" / f"blind-{key}.json",
651
+ confined=True,
652
+ cmd=args,
653
+ run_dir=work,
654
+ runners=[self.runner],
655
+ binds=binds,
656
+ reason="",
657
+ canary=proven,
658
+ )
659
+ with raw.open("wb") as out, err.open("wb") as errors:
660
+ proc = subprocess.Popen(
661
+ jail + args,
662
+ stdin=subprocess.PIPE,
663
+ stdout=out,
664
+ stderr=errors,
665
+ cwd=current_project().root,
666
+ start_new_session=True,
667
+ env=runtime_env,
668
+ )
669
+ self.state["pending"]["pid"] = proc.pid
670
+ self.save()
671
+ proc.communicate(payload)
672
+ self.state["pending"]["exit_code"] = proc.returncode
673
+ self.state["pending"].pop("pid", None)
674
+ self.save()
675
+ # Completion can be flushed just after the CLI exits. Reconcile the
676
+ # existing event/rollout files; never generate a second first reaction.
677
+ failure = None
678
+ for attempt in range(30):
679
+ try:
680
+ parser = parse_codex if self.runner == "codex" else parse_claude
681
+ answer, receipt = parser(
682
+ events(raw),
683
+ self.home(seat),
684
+ self.plan["model"],
685
+ self.plan["effort"],
686
+ self.state.get("served_model"),
687
+ )
688
+ break
689
+ except (RuntimeError, json.JSONDecodeError) as exc:
690
+ failure = exc
691
+ if attempt == 29:
692
+ raise RuntimeError(f"{key}: {failure}; inspect {err}") from exc
693
+ time.sleep(1)
694
+ if len(answer.strip()) < 30:
695
+ raise RuntimeError("implausibly short answer")
696
+ missing = missing_headings(answer, headings)
697
+ if missing:
698
+ raise RuntimeError(f"reaction missing headings: {missing}")
699
+ actual_sid = receipt["session_id"]
700
+ previous = self.state["pending"]["parent_id"]
701
+ if mode == "resume" and actual_sid != previous:
702
+ raise RuntimeError("resume silently created a new session")
703
+ if mode == "fork" and (
704
+ actual_sid == previous or actual_sid in self.state["sessions"].values()
705
+ ):
706
+ raise RuntimeError("fork did not create a distinct session")
707
+ if (
708
+ mode == "fork"
709
+ and self.runner == "codex"
710
+ and receipt["forked_from"] != previous
711
+ ):
712
+ raise RuntimeError("Codex fork names the wrong parent")
713
+ previous_pages = self.banked_history_pages(seat)
714
+ expected_images = Counter(
715
+ digest(self.record / p) for p in previous_pages + list(pages)
716
+ )
717
+ observed_images = Counter(
718
+ history_image_hashes(receipt["rollout"], self.home(seat))
719
+ )
720
+ if expected_images - observed_images:
721
+ raise RuntimeError("persisted reading history lost or changed page images")
722
+ receipt["verified_history_images"] = sum(expected_images.values())
723
+ receipt["exit_code"] = self.state["pending"].get("exit_code")
724
+ self.state["served_model"] = receipt["model"]
725
+ self.state["sessions"][seat] = actual_sid
726
+ dest = self.record / f"{key}.md"
727
+ if dest.exists() and dest.read_text() != answer + "\n":
728
+ raise RuntimeError("refusing to overwrite an existing reaction")
729
+ dest.write_text(answer + "\n")
730
+ receipt.update(
731
+ answer=dest.name, answer_sha=digest(dest), request_sha=request_hash
732
+ )
733
+ self.state["done"][key] = receipt
734
+ self.state["pending"] = None
735
+ self.save()
736
+ for image in physical_images:
737
+ image.unlink()
738
+ self.emit(f"{self.runner}: banked {key} ({receipt['model']})")
739
+ for flag in receipt.get("red_flags", []):
740
+ self.emit(f"{self.runner}: {key}: {flag}")
741
+ if receipt["exit_code"] not in (None, 0):
742
+ raise RuntimeError(
743
+ f"{key}: answer banked, but process exited {receipt['exit_code']}; halt"
744
+ )
745
+ return receipt
746
+
747
+ def checkpoint(self):
748
+ if self.state["pending"]:
749
+ raise RuntimeError("cannot checkpoint a pending turn")
750
+ checkpoint = self.root / "checkpoint"
751
+ if "checkpoint" not in self.state:
752
+ self.require_parent()
753
+ # Provider writers must be quiescent, including delayed writes.
754
+ previous = tree_hashes(self.home("parent"))
755
+ for _ in range(30):
756
+ time.sleep(1)
757
+ now = tree_hashes(self.home("parent"))
758
+ if now == previous:
759
+ break
760
+ previous = now
761
+ else:
762
+ raise RuntimeError("parent runtime home is still changing")
763
+ hashes = snapshot(self.home("parent"), checkpoint)
764
+ self.state["checkpoint"] = hashes
765
+ self.save()
766
+ self.check_parent()
767
+
768
+ def require_parent(self):
769
+ """Refuse once the parent's home is gone, rather than misname it.
770
+
771
+ A pruned home hashes as one that changed, which would read as a
772
+ parent that moved after the arms branched from it.
773
+ """
774
+ blind.require_session(
775
+ self.workspace("parent"), self.runner, self.state["sessions"]["parent"]
776
+ )
777
+
778
+ def check_parent(self):
779
+ expected = self.state["checkpoint"]
780
+ if tree_hashes(self.root / "checkpoint") != expected:
781
+ raise RuntimeError("frozen checkpoint changed")
782
+ self.require_parent()
783
+ if tree_hashes(self.home("parent")) != expected:
784
+ raise RuntimeError("parent changed after checkpoint")
785
+
786
+ def fork_home(self, seat):
787
+ self.check_parent()
788
+ home = self.home(seat)
789
+ if not home.exists():
790
+ blind.make_state_root(self.workspace(seat))
791
+ snapshot(self.root / "checkpoint", home)
792
+ elif seat not in self.state["sessions"] and not self.state["pending"]:
793
+ if tree_hashes(home) != self.state["checkpoint"]:
794
+ raise RuntimeError("unsubmitted branch home differs from checkpoint")
795
+
796
+ def run(self):
797
+ self.verify()
798
+ p = self.plan["prompts"]
799
+ for index, item in enumerate(self.plan["prefix"]):
800
+ self.turn(
801
+ "parent",
802
+ f"prefix-{index:02d}-{item['stem']}",
803
+ p["first"] if index == 0 else p["next"],
804
+ item["pages"],
805
+ "start" if index == 0 else "resume",
806
+ p["headings"],
807
+ )
808
+ self.checkpoint()
809
+ for arm in self.plan["order"]:
810
+ self.fork_home(arm)
811
+ for index, item in enumerate(self.plan["arms"][arm]["segments"]):
812
+ self.turn(
813
+ arm,
814
+ f"arm-{arm}-{index:02d}-{item['stem']}",
815
+ p["next"],
816
+ item["pages"],
817
+ "fork" if index == 0 else "resume",
818
+ p["headings"],
819
+ )
820
+ self.check_parent()
821
+ if self.plan.get("canary_markers"):
822
+ for arm in ARMS:
823
+ item = self.plan["arms"][arm]["segments"][0]
824
+ answer = self.record / f"arm-{arm}-00-{item['stem']}.md"
825
+ text = answer.read_text().lower()
826
+ if any(
827
+ marker.lower() not in text for marker in self.plan["canary_markers"]
828
+ ):
829
+ raise RuntimeError(
830
+ "fork did not retain the synthetic reading history"
831
+ )
832
+ self.state["canary_passed"] = True
833
+ self.state["complete"] = True
834
+ self.save()
835
+ for arm in self.plan["order"]:
836
+ blind.release(self.workspace(arm))
837
+ blind.hold(
838
+ self.workspace("parent"),
839
+ f"chapter comparison {self.record.name}: inspect checks every arm "
840
+ "against the parent's home",
841
+ )
842
+ self.emit(f"Complete: {self.record}", terminal=True)
843
+
844
+
845
+ def prepare(args):
846
+ record = args.record_dir.expanduser().resolve()
847
+ validate_runner_model(args.runner, args.model)
848
+ record.mkdir(parents=True, exist_ok=False)
849
+ artifact = record / "raw" / "artifact"
850
+ artifact.mkdir(parents=True)
851
+ scratch = Path(
852
+ tempfile.mkdtemp(prefix="quirework-chapter-", dir=Path.home() / "tmp")
853
+ )
854
+ plan = {
855
+ "protocol": PROTOCOL_REVISION,
856
+ "prompt_revision": PROMPT_REVISION,
857
+ "runner": args.runner,
858
+ "model": args.model,
859
+ "effort": args.effort,
860
+ "runner_version": blind.runner_version(args.runner),
861
+ "driver_sha": digest(__file__),
862
+ "blind_sha": digest(blind.__file__),
863
+ "prompt_source_sha": digest(_settings().SOURCE),
864
+ "scratch": str(scratch),
865
+ "seat": args.seat,
866
+ "prompts": prompts(args.seat),
867
+ "prefix": [],
868
+ "arms": {},
869
+ "order": list(ARMS),
870
+ }
871
+ import random
872
+
873
+ entropy = random.SystemRandom()
874
+ entropy.shuffle(plan["order"])
875
+ if args.canary:
876
+ color = entropy.choice(("ultramarine", "vermilion", "turquoise"))
877
+ drink = entropy.choice(("cocoa", "lemonade", "chamomile"))
878
+ plan["source_sha"] = "synthetic"
879
+ plan["target"] = "synthetic"
880
+ plan["canary_markers"] = [color, drink]
881
+ plan["prompts"] = {
882
+ "first": f"Imagine a harmless fictional scene. Its lighthouse is "
883
+ f"painted {color}, and its keeper drinks {drink}. "
884
+ "Acknowledge both details in one sentence. Do not use tools.",
885
+ "next": "Continue the harmless fictional scene from our previous turn. "
886
+ "In one sentence, name the lighthouse's color and the keeper's "
887
+ "drink, then say that the story is continuing. Do not use tools.",
888
+ "headings": [],
889
+ }
890
+ plan["prefix"] = [{"stem": "canary", "pages": []}]
891
+ plan["arms"] = {
892
+ a: {
893
+ "version": "synthetic",
894
+ "segments": [{"stem": "canary", "version": "synthetic", "pages": []}],
895
+ }
896
+ for a in ARMS
897
+ }
898
+ else:
899
+ manifest = read_json(args.original_kit / "manifest.json")
900
+ if manifest["dirty"]:
901
+ raise RuntimeError("baseline kit must be rendered from clean canon sources")
902
+ segments = [i for i in manifest["chapters"] if i["kind"] == "chapter"]
903
+ start_matches = [i for i, s in enumerate(segments) if s["stem"] == args.chapter]
904
+ end_slug = args.through_chapter or args.chapter
905
+ end_matches = [i for i, s in enumerate(segments) if s["stem"] == end_slug]
906
+ if (
907
+ len(start_matches) != 1
908
+ or len(end_matches) != 1
909
+ or start_matches[0] == 0
910
+ or end_matches[0] < start_matches[0]
911
+ ):
912
+ raise RuntimeError(
913
+ "target span must resolve in order and have a preceding segment"
914
+ )
915
+ start_index, end_index = start_matches[0], end_matches[0]
916
+ targets = segments[start_index : end_index + 1]
917
+ source_stems = [source.stem for source in args.candidate_source]
918
+ target_stems = [item["stem"] for item in targets]
919
+ if len(targets) > 1 and source_stems != target_stems:
920
+ raise RuntimeError(
921
+ "candidate sources must name every target chapter in reading order"
922
+ )
923
+ if len(targets) == 1 and len(source_stems) != 1:
924
+ raise RuntimeError("a single target requires one candidate source")
925
+ plan["source_sha"] = manifest["source_sha"]
926
+ plan["target"] = args.chapter
927
+ plan["targets"] = target_stems
928
+ plan["hypothesis"] = args.hypothesis.read_text()
929
+ plan["baseline_manifest"] = manifest
930
+ originals = []
931
+ for index, item in enumerate(segments[: end_index + 1]):
932
+ name = item["stem"]
933
+ shutil.copyfile(args.original_kit / item["file"], artifact / f"{name}.pdf")
934
+ pages = render_pages(artifact / f"{name}.pdf", artifact / name)
935
+ entry = {
936
+ "stem": name,
937
+ "title": item["title"],
938
+ "pages": [str(p.relative_to(record)) for p in pages],
939
+ }
940
+ if start_index <= index <= end_index:
941
+ originals.append({**entry, "version": "original"})
942
+ else:
943
+ plan["prefix"].append(entry)
944
+ candidates = []
945
+ if args.candidate_kit:
946
+ candidate_manifest = read_json(args.candidate_kit / "manifest.json")
947
+ if candidate_manifest["source_sha"] != manifest["source_sha"]:
948
+ raise RuntimeError("candidate kit is not based on the baseline commit")
949
+ candidate_segments = {
950
+ item["stem"]: item
951
+ for item in candidate_manifest["chapters"]
952
+ if item["kind"] == "chapter"
953
+ }
954
+ candidate_order = [
955
+ item["stem"]
956
+ for item in candidate_manifest["chapters"]
957
+ if item["kind"] == "chapter"
958
+ ]
959
+ if candidate_order != [item["stem"] for item in segments]:
960
+ raise RuntimeError("candidate kit changed the reading order")
961
+ if [item["stem"] for item in targets] != [
962
+ candidate_segments.get(item["stem"], {}).get("stem") for item in targets
963
+ ]:
964
+ raise RuntimeError("candidate kit does not contain the target span")
965
+ plan["candidate_manifest"] = candidate_manifest
966
+ for original in originals:
967
+ name = original["stem"]
968
+ candidate_item = candidate_segments[name]
969
+ target_pdf = artifact / f"candidate-{name}.pdf"
970
+ shutil.copyfile(args.candidate_kit / candidate_item["file"], target_pdf)
971
+ pages = render_pages(target_pdf, artifact / f"candidate-{name}")
972
+ candidates.append(
973
+ {
974
+ "stem": name,
975
+ "title": candidate_item["title"],
976
+ "version": "candidate",
977
+ "pages": [str(p.relative_to(record)) for p in pages],
978
+ }
979
+ )
980
+ else:
981
+ if len(originals) != 1:
982
+ raise RuntimeError("a multi-chapter span requires --candidate-kit")
983
+ shutil.copyfile(args.candidate_pdf, artifact / "candidate.pdf")
984
+ pages = render_pages(artifact / "candidate.pdf", artifact / "candidate")
985
+ candidates = [
986
+ {
987
+ "stem": originals[0]["stem"],
988
+ "title": originals[0]["title"],
989
+ "version": "candidate",
990
+ "pages": [str(p.relative_to(record)) for p in pages],
991
+ }
992
+ ]
993
+ plan["arms"] = {
994
+ "a": {"version": "original", "segments": originals},
995
+ "b": {"version": "original", "segments": originals},
996
+ "c": {"version": "candidate", "segments": candidates},
997
+ }
998
+ source_dir = artifact / "candidate-sources"
999
+ source_dir.mkdir()
1000
+ for source in args.candidate_source:
1001
+ shutil.copyfile(source, source_dir / source.name)
1002
+ shutil.copyfile(args.candidate_patch, record / "_candidate.patch")
1003
+ shutil.copyfile(__file__, artifact / "driver.py")
1004
+ shutil.copyfile(_settings().SOURCE, artifact / "reader_instrument.py")
1005
+ shutil.copyfile(blind.__file__, artifact / "shared_blind.py")
1006
+ if (
1007
+ digest(artifact / "driver.py") != plan["driver_sha"]
1008
+ or digest(artifact / "reader_instrument.py") != plan["prompt_source_sha"]
1009
+ or digest(artifact / "shared_blind.py") != plan["blind_sha"]
1010
+ ):
1011
+ raise RuntimeError("implementation changed during preparation")
1012
+ plan["hashes"] = tree_hashes(artifact)
1013
+ plan["hashes"] = {f"raw/artifact/{k}": v for k, v in plan["hashes"].items()}
1014
+ if (record / "_candidate.patch").exists():
1015
+ plan["hashes"]["_candidate.patch"] = digest(record / "_candidate.patch")
1016
+ write_json(record / "plan.json", plan)
1017
+ (record / "plan.sha256").write_text(digest(record / "plan.json") + "\n")
1018
+ print(f"Prepared: {record}", flush=True)
1019
+ return record
1020
+
1021
+
1022
+ def main(argv=None):
1023
+ parser = argparse.ArgumentParser(
1024
+ prog="quirework publish compare", description=__doc__
1025
+ )
1026
+ sub = parser.add_subparsers(dest="action", required=True)
1027
+ prep = sub.add_parser("prepare", help="stage a forked reader comparison")
1028
+ prep.add_argument("--record-dir", type=Path, required=True)
1029
+ prep.add_argument("--runner", choices=("codex", "claude"), required=True)
1030
+ prep.add_argument("--model", required=True)
1031
+ prep.add_argument("--effort", default="xhigh")
1032
+ prep.add_argument("--seat", choices=("trade",), default="trade")
1033
+ prep.add_argument("--canary", action="store_true")
1034
+ prep.add_argument("--original-kit", type=Path)
1035
+ prep.add_argument("--chapter")
1036
+ prep.add_argument("--through-chapter")
1037
+ prep.add_argument("--candidate-kit", type=Path)
1038
+ prep.add_argument("--candidate-pdf", type=Path)
1039
+ prep.add_argument("--candidate-source", type=Path, action="append")
1040
+ prep.add_argument("--candidate-patch", type=Path)
1041
+ prep.add_argument("--hypothesis", type=Path)
1042
+ for name, text in (
1043
+ ("run", "run the prepared arms"),
1044
+ ("inspect", "each arm's progress and receipts"),
1045
+ ):
1046
+ action = sub.add_parser(name, help=text)
1047
+ action.add_argument("record_dir", type=Path)
1048
+ if name == "run":
1049
+ action.add_argument(
1050
+ "--progress",
1051
+ action="store_true",
1052
+ help="print each banked turn; default output is terminal status only",
1053
+ )
1054
+ args = parser.parse_args(argv)
1055
+ if args.action == "prepare":
1056
+ if not args.canary and not all(
1057
+ [
1058
+ args.original_kit,
1059
+ args.chapter,
1060
+ args.candidate_source,
1061
+ args.candidate_patch,
1062
+ args.hypothesis,
1063
+ args.candidate_kit or args.candidate_pdf,
1064
+ ]
1065
+ ):
1066
+ parser.error(
1067
+ "a book experiment needs both artifacts, source, patch and hypothesis"
1068
+ )
1069
+ prepare(args)
1070
+ elif args.action == "run":
1071
+ with (args.record_dir / ".lock").open("w") as lock:
1072
+ try:
1073
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
1074
+ except BlockingIOError as exc:
1075
+ raise RuntimeError(
1076
+ "another operator is running this experiment"
1077
+ ) from exc
1078
+ experiment = Experiment(args.record_dir, progress=args.progress)
1079
+ experiment.run()
1080
+ if experiment.state.get("canary_passed"):
1081
+ print(
1082
+ "Canary: all three distinct forks retained parent history; parent unchanged"
1083
+ )
1084
+ else:
1085
+ experiment = Experiment(args.record_dir)
1086
+ experiment.verify()
1087
+ if experiment.state.get("checkpoint"):
1088
+ experiment.check_parent()
1089
+ print(json.dumps(experiment.state, indent=2))
1090
+
1091
+
1092
+ if __name__ == "__main__":
1093
+ try:
1094
+ main()
1095
+ except (RuntimeError, OSError, ValueError, subprocess.CalledProcessError) as exc:
1096
+ print(f"STOPPED: {exc}", file=sys.stderr)
1097
+ sys.exit(2)