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
quirework/sops/room.py ADDED
@@ -0,0 +1,2791 @@
1
+ """A writers room: several seats, one board, one round at a time.
2
+
3
+ Seats never see each other's workspaces. A seat writes to its own outbox and
4
+ the controller promotes that file onto the board between rounds, which is the
5
+ only moment shared state changes. Promotion is mechanical: it copies, hashes
6
+ and attributes, and it never reads a post to decide whether it deserves to be
7
+ there. The board a round was shown is therefore one object with one digest,
8
+ so "what everyone saw in round three" has an answer.
9
+
10
+ Rounds exist because a CLI harness answers once and exits. A round is a resume
11
+ of the session a seat already holds, not a fresh run of the same assignment:
12
+ the seat keeps its reading and its reasoning, and is handed only what changed.
13
+
14
+ The controller appends to ``room.log`` as it goes. `watch` follows a running
15
+ room by reading that file and the per-seat records beside it, without taking a
16
+ lock or touching a session.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import shutil
26
+ import signal
27
+ import subprocess
28
+ import sys
29
+ import textwrap
30
+ import time
31
+ from concurrent.futures import ThreadPoolExecutor
32
+ from pathlib import Path
33
+ from threading import Event, Lock
34
+
35
+ from quirework.project import current_project, submit
36
+ from quirework.core import usage as usage_records
37
+ from quirework.providers import blind
38
+ from quirework.providers import quota
39
+ from quirework.providers import exits
40
+ from quirework.providers import models as drivers
41
+ from quirework.runs import feed
42
+ from quirework.runs import observe
43
+ from quirework.runs.storage import (
44
+ digest,
45
+ hashes,
46
+ identifier,
47
+ lock,
48
+ process_identity,
49
+ read,
50
+ shown,
51
+ slot,
52
+ write,
53
+ )
54
+ from quirework.sops import personas
55
+
56
+ SCHEMA = 1
57
+ _LOG_LOCK = Lock()
58
+
59
+
60
+ def event(root, **fields):
61
+ """Append one complete line to the room's watch trail.
62
+
63
+ One write of a whole line under O_APPEND is what stops a reader from ever
64
+ seeing half a record, which matters because the point of this file is that
65
+ somebody is tailing it while the room runs.
66
+ """
67
+ line = json.dumps({"t": round(time.time(), 3), **fields}, ensure_ascii=False)
68
+ with _LOG_LOCK:
69
+ fd = os.open(root / "room.log", os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
70
+ try:
71
+ os.write(fd, (line + "\n").encode())
72
+ finally:
73
+ os.close(fd)
74
+
75
+
76
+ def load(root, *, verify=True):
77
+ root = Path(root).expanduser().resolve()
78
+ plan = read(root / "plan.json")
79
+ if plan.get("schema") != SCHEMA:
80
+ raise ValueError("unsupported room schema")
81
+ if (
82
+ verify
83
+ and digest(root / "plan.json") != read(root / "plan-digest.json")["sha256"]
84
+ ):
85
+ raise ValueError("frozen room plan changed")
86
+ return root, plan
87
+
88
+
89
+ ROOMS = "rooms"
90
+
91
+ # A seat's runner home is a live harness home: the adapter binds it over
92
+ # `~/.claude` or `~/.codex` for the duration of a turn, with the harness's
93
+ # credentials mounted into it. `prepare` accepts a path as readily as a name,
94
+ # and a room prepared into a checkout puts the seat's sessions, and in a room
95
+ # older than the cache its whole home, one `git add` away from being
96
+ # published. The pattern names no directory, because what sits there now is
97
+ # a link into the cache, or a tombstone once the home is pruned. The room
98
+ # writes this itself because the operator who most needs it is the one who
99
+ # has not yet looked inside a room directory.
100
+ IGNORE = """\
101
+ # Where a seat reaches its private runner home, a live harness home. Never
102
+ # commit it, and never copy one anywhere.
103
+ seats/*.blind-state
104
+ """
105
+
106
+
107
+ def spelled_as_path(value):
108
+ """Whether the operator spelled a path rather than naming a room.
109
+
110
+ The rule lives here rather than inside `room_root` so a caller can say
111
+ which of the two resolutions it used without restating the rule and
112
+ drifting from it.
113
+ """
114
+ text = str(value)
115
+ return "/" in text or text.startswith("~")
116
+
117
+
118
+ def room_root(value, *, must_exist=True):
119
+ """Resolve a room by name under the project, or by an explicit path.
120
+
121
+ A bare name is a room this project owns and is looked up in the project's
122
+ rooms directory. Anything containing a separator is a path the operator
123
+ spelled out. The two never overlap, so `status foo` and `status ./foo`
124
+ name different things and the error says which one was tried.
125
+
126
+ Rooms live outside the checkout, in a directory nobody navigates to by
127
+ hand, so resolving a bare name is the difference between naming a room and
128
+ remembering where the state root hashed it to.
129
+ """
130
+ text = str(value)
131
+ if spelled_as_path(text):
132
+ root = Path(text).expanduser().resolve()
133
+ else:
134
+ root = (current_project().state_root / ROOMS / identifier(text)).resolve()
135
+ if must_exist and not (root / "plan.json").is_file():
136
+ raise ValueError(
137
+ f"no room at {shown(root)}; name a room under "
138
+ f"{shown(current_project().state_root / ROOMS)} or give a path to one"
139
+ )
140
+ return root
141
+
142
+
143
+ def state_path(root, seat):
144
+ return root / "records" / seat / "state.json"
145
+
146
+
147
+ def label(seat):
148
+ return seat["persona_frozen"]["profile"]["label"]
149
+
150
+
151
+ def phases(plan):
152
+ """The room's schedule, as a phase per round.
153
+
154
+ A room without phases is one long phase in which everyone speaks every
155
+ round, which is what every room did before phases existed.
156
+ """
157
+ declared = plan.get("phases")
158
+ if not declared:
159
+ return [{"name": "discussion", "rounds": plan["rounds"]}]
160
+ return declared
161
+
162
+
163
+ def phase_for(plan, n):
164
+ """Which phase round ``n`` belongs to, and nothing about who acts in it."""
165
+ first = 1
166
+ for phase in phases(plan):
167
+ if first <= n < first + phase["rounds"]:
168
+ return phase
169
+ first += phase["rounds"]
170
+ return phases(plan)[-1]
171
+
172
+
173
+ def acting(plan, n):
174
+ """The seats that speak in round ``n``.
175
+
176
+ A phase may seat only some of the room. The rest are not absent and have
177
+ not failed: they are listening, which is a normal thing to do while one
178
+ person reads their draft aloud.
179
+ """
180
+ named = phase_for(plan, n).get("seats")
181
+ every = [s["id"] for s in plan["seats"]]
182
+ if not named:
183
+ return every
184
+ unknown = set(named) - set(every)
185
+ if unknown:
186
+ raise ValueError(
187
+ f"phase names seats this room does not have: {sorted(unknown)}"
188
+ )
189
+ return [s for s in every if s in named]
190
+
191
+
192
+ def start_text(root, plan, seat):
193
+ """What a writer is told once: who they are, what to read, where to post.
194
+
195
+ Deliberately says nothing about rounds, promotion, outboxes as a mechanism
196
+ or the controller that runs them. A writer in a room knows the room, not
197
+ the building's plumbing, and a model told it is executing a procedure
198
+ starts writing like one. `{{persona}}` is a slot personas.compose fills in
199
+ place, which keeps a machine header off the top of a person's character.
200
+ """
201
+ # Relative to the seat's own workspace, which the jail makes its working
202
+ # directory. An absolute path here is the operator's home layout, restated
203
+ # in a writer's context on every turn, naming a machine they cannot see.
204
+ reads = "".join(f"- input/{rel}\n" for rel in seat["reads"])
205
+ text = (
206
+ "{{persona}}\n\n"
207
+ "You are in a room of writers working on the same material.\n\n"
208
+ f"Read:\n\n{reads}\n"
209
+ "Post to outbox/post.md, replacing your last post each\n"
210
+ f"time. That file is what the room sees. About {plan['post_words']} words.\n"
211
+ )
212
+ roster = holdings(plan)
213
+ if roster:
214
+ text += "\nIn the room, and what each person brought:\n\n" + roster
215
+ return personas.compose(text, seat["persona_frozen"])
216
+
217
+
218
+ def holdings(plan):
219
+ """Who is in the room and what each of them was given beyond the common set.
220
+
221
+ Seats are handed different material on purpose, and that asymmetry is what
222
+ makes their disagreement substantive. It also makes a colleague's citation
223
+ impossible to check: a seat without the file cannot tell a private source
224
+ from an invention, and a room told to cite its sources will eventually
225
+ accuse someone of fabricating one. Naming the holdings is the same courtesy
226
+ as seeing a colleague reach for the book they brought.
227
+ """
228
+ common = (
229
+ set.intersection(*(set(s.get("reads", [])) for s in plan["seats"]))
230
+ if plan["seats"]
231
+ else set()
232
+ )
233
+ # A path is only the default name. Whatever is in it reaches every seat,
234
+ # so a filename carrying a model id, an author or a verdict puts that in
235
+ # the room: one measured run named six reader reports after the models
236
+ # that wrote them and had the seats addressing each other as "Sol" and
237
+ # "Astra" within two rounds, each attached to the wrong colleague. A
238
+ # `label` on the file says what it is without saying what it is called.
239
+ named = {
240
+ entry["path"]: entry["label"]
241
+ for seat in plan["seats"]
242
+ for entry in seat.get("files", [])
243
+ if entry.get("label")
244
+ }
245
+ lines = []
246
+ for seat in plan["seats"]:
247
+ extra = sorted(set(seat.get("reads", [])) - common)
248
+ shown_as = [named.get(rel, rel) for rel in extra]
249
+ brought = ", ".join(shown_as) if extra else "nothing the rest of you lack"
250
+ lines.append(f"- {label(seat)}: {brought}\n")
251
+ return "".join(lines)
252
+
253
+
254
+ def chair_note(root, n):
255
+ """What the operator told the room before round ``n``, if anything.
256
+
257
+ A room is a closed conversation on purpose, and the operator is the only
258
+ person who can see both the room and the world it is writing for. The
259
+ packet is hashed, so a fact discovered after preparation cannot be
260
+ corrected in it, and the board carries posts and nothing else. In the
261
+ first measured room four seats put direct questions to the chair in their
262
+ opening round -- a miscounted phrase, a reference with two possible
263
+ referents, a ruling only the editor held -- and nothing could reach them.
264
+
265
+ The note travels with the turn so every acting seat is told the same thing
266
+ at the same instant, and it is copied onto that round's digest so the
267
+ record shows what the room was working from. It is not a post: the chair
268
+ does not take a seat, is not answered by name, and never arrives in the
269
+ middle of a round.
270
+ """
271
+ path = root / "chair" / f"r{n:03d}.md"
272
+ if not path.exists():
273
+ return None
274
+ return path.read_text().strip() or None
275
+
276
+
277
+ def turn_prompt(root, plan, seat, n, *, cold=False):
278
+ """The one line a writer gets when the room moves.
279
+
280
+ Everything standing -- the perspective, the reading, the length, where a
281
+ post goes -- is already in the session from the first turn, so repeating
282
+ it every round would buy nothing and cost tokens on every seat.
283
+ """
284
+ phase = phase_for(plan, n)
285
+ # A phase states the work's length, or `"words": null` (or false) to
286
+ # state none: a figure is an instruction, and a plan that wants the work
287
+ # to find its own length must be able to say nothing. A phase that does
288
+ # not mention words keeps the post's length, as it always has.
289
+ words = phase.get("words", plan["post_words"]) or None
290
+ extra = phase.get("instruction", "").strip()
291
+ # Relative to the seat's workspace, as in the standing assignment: this
292
+ # line is sent on every round, so an absolute path is the operator's home
293
+ # directory spelled out again in a writer's context every time the room
294
+ # moves, for a file they are already standing next to.
295
+ opening = n == 1 or n in plan["silent_rounds"]
296
+ if opening:
297
+ text = (
298
+ "Read START.md and post. Nobody has posted yet, so say "
299
+ "what you think before you hear anyone else."
300
+ )
301
+ elif cold:
302
+ # This seat has no session, so it has none of the standing assignment
303
+ # and none of the conversation. Saying only that the board moved would
304
+ # be addressed to a reader who has never read the brief.
305
+ text = (
306
+ "You are joining a room already in progress. Read START.md "
307
+ "first, then the boards in board/, beginning with "
308
+ f"board/round-{n - 1:03d}/digest.md. Then post as the others "
309
+ "do. Answer your colleagues by name. You were expected earlier "
310
+ "and could not be reached, so the room has not heard from you "
311
+ "yet and nothing it has said is a reply to you."
312
+ )
313
+ elif others_spoke(root, plan, seat["id"], n - 1):
314
+ text = (
315
+ f"The board has moved: board/round-{n - 1:03d}/digest.md. Read it "
316
+ "and post again, replacing what is there. Answer your colleagues "
317
+ "by name."
318
+ )
319
+ else:
320
+ # A phase seating one writer hands that writer a board with no other
321
+ # voice on it, and announcing that the board has moved is then false.
322
+ # A drafter reads that silence as an answer: a measured room had its
323
+ # drafter say "absent an answer I am treating that beat as landed"
324
+ # about a note three colleagues were writing at that moment, one round
325
+ # behind where she had been told to look.
326
+ heard = last_heard(root, plan, seat["id"], n - 1)
327
+ text = (
328
+ "Nobody else has posted since"
329
+ + (
330
+ f" round {heard}: board/round-{heard:03d}/digest.md."
331
+ if heard
332
+ else " the room opened."
333
+ )
334
+ + " Post again, replacing what is there. Anything you asked them "
335
+ "is still outstanding, not declined."
336
+ )
337
+ missed = [r for r in range(1, n) if seat["id"] not in acting(plan, r)]
338
+ if missed and not opening:
339
+ # A seat that sat out a phase comes back to a board it has not read.
340
+ # Saying only that the board moved would have it answer the last round
341
+ # as though it were the whole conversation. A silent round is shown no
342
+ # board at all, deliberately, so it is not told where the others are.
343
+ text += (
344
+ f" You did not post in round{'s' if len(missed) > 1 else ''} "
345
+ f"{', '.join(str(r) for r in missed)}; those boards are in "
346
+ "board/ and are worth reading first."
347
+ )
348
+ made = deliverable(plan, n)
349
+ if made:
350
+ # Two files because they are two things, and the word count belongs to
351
+ # the work rather than to the note about it. Afterwards the work is a
352
+ # file, not a passage somebody has to find inside a conversation.
353
+ length = f", about {words} words" if words else ""
354
+ text += (
355
+ f" Write the work itself to outbox/{made}, as finished text and"
356
+ f" nothing else{length}. Your post is your note to the"
357
+ f" room about it, about {plan['post_words']} words."
358
+ )
359
+ elif words and words != plan["post_words"]:
360
+ text += f" About {words} words this time."
361
+ text = f"{text}{(' ' + extra) if extra else ''}"
362
+ note = chair_note(root, n)
363
+ if note:
364
+ text += f"\n\nFrom the chair, to everyone acting this round:\n\n{note}"
365
+ return f"{text}\n"
366
+
367
+
368
+ def prepare(plan_path, destination, *, resolve_models=True):
369
+ """Stage one workspace per seat and freeze the plan that governs them."""
370
+ plan_path = Path(plan_path).expanduser().resolve()
371
+ destination = Path(destination).expanduser().resolve()
372
+ supplied = read(plan_path)
373
+ if destination.exists():
374
+ raise ValueError("room exists; continue or inspect it, do not replace it")
375
+ source_root = Path(supplied.get("source_root", current_project().root)).resolve()
376
+ rounds = supplied.get("rounds")
377
+ if not isinstance(rounds, int) or rounds < 1:
378
+ raise ValueError("rounds must be a positive integer")
379
+ silent = supplied.get("silent_rounds", [])
380
+ if not isinstance(silent, list) or any(
381
+ r not in range(1, rounds + 1) for r in silent
382
+ ):
383
+ raise ValueError("silent_rounds must name declared rounds")
384
+ if not isinstance(supplied.get("post_words"), int) or supplied["post_words"] < 1:
385
+ raise ValueError("post_words must be a positive integer")
386
+ if not supplied.get("seats"):
387
+ raise ValueError("declare at least one seat")
388
+ declared = supplied.get("phases") or []
389
+ if declared:
390
+ if sum(p["rounds"] for p in declared) != rounds:
391
+ raise ValueError("phase rounds must add up to the room's rounds")
392
+ named = {s["id"] for s in supplied["seats"]}
393
+ for phase in declared:
394
+ if not isinstance(phase.get("rounds"), int) or phase["rounds"] < 1:
395
+ raise ValueError("each phase needs a positive round count")
396
+ if set(phase.get("seats") or named) - named:
397
+ raise ValueError(f"phase {phase.get('name')!r} names unknown seats")
398
+ if not phase.get("seats", named):
399
+ raise ValueError(f"phase {phase.get('name')!r} seats nobody")
400
+ if "deliverable" in phase:
401
+ safe_name(phase["deliverable"])
402
+ length = phase.get("words")
403
+ if not (
404
+ length is None
405
+ or length is False
406
+ or (
407
+ isinstance(length, int)
408
+ and not isinstance(length, bool)
409
+ and length > 0
410
+ )
411
+ ):
412
+ raise ValueError(
413
+ f"phase {phase.get('name')!r}: words is a positive integer,"
414
+ " or null to state no length"
415
+ )
416
+
417
+ fields = ("harness", "model", "effort")
418
+ config = {k: supplied[k] for k in fields}
419
+ if resolve_models:
420
+ config = drivers.resolve(config["harness"], config["model"], config["effort"])
421
+
422
+ profiles = plan_path.parent / supplied.get("profiles_dir", "profiles")
423
+ seats = []
424
+ for item in supplied["seats"]:
425
+ source = profiles / f"{item['persona']}.json"
426
+ if not source.is_file():
427
+ # A persona id is not guessable, and an assistant writing a plan
428
+ # will invent one. Name the ones that exist so the fix is a copy.
429
+ offered = sorted(p.stem for p in profiles.glob("*.json"))
430
+ raise ValueError(
431
+ f"seat {item['id']!r}: no persona {item['persona']!r} in "
432
+ f"{shown(profiles)}; it has {', '.join(offered) or 'none'}"
433
+ )
434
+ profile = personas.validate(personas.parse(source.read_text()))
435
+ # A seat may name its own model. Everything downstream already takes a
436
+ # config rather than reading the room's, so a mixed room needs no
437
+ # special case beyond resolving each seat separately.
438
+ chosen = {
439
+ **{k: supplied[k] for k in fields},
440
+ **{k: item[k] for k in fields if k in item},
441
+ }
442
+ if resolve_models:
443
+ chosen = drivers.resolve(
444
+ chosen["harness"], chosen["model"], chosen["effort"]
445
+ )
446
+ # The digest tells the room which seats can search the web, so the
447
+ # claim has to be true: the policy travels in the seat's config to the
448
+ # launcher, and a harness that cannot enforce it is refused here
449
+ # rather than marked on the board as if it could.
450
+ tools = item.get("tools", "write")
451
+ if tools not in TOOLS:
452
+ raise ValueError(f"seat {item['id']!r}: tools must be one of {TOOLS}")
453
+ if tools == "research" and chosen["harness"] == "agy":
454
+ raise ValueError(
455
+ f"seat {item['id']!r}: agy cannot confine a seat to a tool set, "
456
+ "so a research seat there cannot be told from a writing one"
457
+ )
458
+ chosen = {**chosen, "tools": tools}
459
+ seats.append(
460
+ dict(
461
+ item,
462
+ id=identifier(item["id"]),
463
+ config=chosen,
464
+ persona_frozen=personas.resolve({"inline": profile}),
465
+ )
466
+ )
467
+ if len({s["id"] for s in seats}) != len(seats):
468
+ raise ValueError("duplicate seat id")
469
+ if resolve_models:
470
+ from quirework.providers import canary as canaries
471
+
472
+ canaries.require_models(
473
+ (f"seat {s['id']}", s["config"]["harness"]) for s in seats
474
+ )
475
+
476
+ def staged(entry):
477
+ if "local_source" in entry:
478
+ return (plan_path.parent / entry["local_source"]).read_bytes()
479
+ return (source_root / entry["source"]).read_bytes()
480
+
481
+ shared = {supplied["brief"]["path"]: staged(supplied["brief"])}
482
+ for entry in supplied.get("shared_files", []):
483
+ shared[entry["path"]] = staged(entry)
484
+
485
+ destination.mkdir(parents=True, mode=0o700)
486
+ (destination / ".gitignore").write_text(IGNORE)
487
+ (destination / "board" / "posts").mkdir(parents=True)
488
+ (destination / "board" / "rounds").mkdir(parents=True)
489
+ (destination / "chair").mkdir()
490
+ frozen = dict(
491
+ supplied,
492
+ schema=SCHEMA,
493
+ config=config,
494
+ seats=seats,
495
+ source_root=str(source_root),
496
+ silent_rounds=silent,
497
+ prepared_at=time.time(),
498
+ )
499
+ for seat in seats:
500
+ work = destination / "seats" / seat["id"]
501
+ for sub in ("input", "outbox", "board"):
502
+ (work / sub).mkdir(parents=True)
503
+ files = dict(shared)
504
+ for entry in seat.get("files", []):
505
+ files[entry["path"]] = staged(entry)
506
+ for rel, content in files.items():
507
+ target = work / "input" / rel
508
+ target.parent.mkdir(parents=True, exist_ok=True)
509
+ target.write_bytes(content)
510
+ seat["reads"] = sorted(files)
511
+ seat["input_hashes"] = hashes(work / "input")
512
+ (destination / "board" / "posts" / seat["id"]).mkdir(parents=True)
513
+ (destination / "records" / seat["id"]).mkdir(parents=True)
514
+ write(
515
+ state_path(destination, seat["id"]),
516
+ {
517
+ "status": "prepared",
518
+ "session_id": None,
519
+ "rounds": {},
520
+ "persona": personas.identity(seat["persona_frozen"]),
521
+ },
522
+ )
523
+ # Second pass: the roster in START.md names what every seat holds, which
524
+ # is only knowable once each of them has been staged.
525
+ for seat in seats:
526
+ start = destination / "seats" / seat["id"] / "START.md"
527
+ start.write_text(start_text(destination, frozen, seat))
528
+ seat["start_sha256"] = digest(start)
529
+ write(destination / "plan.json", frozen)
530
+ write(
531
+ destination / "plan-digest.json",
532
+ {"sha256": digest(destination / "plan.json")},
533
+ )
534
+ event(destination, event="prepared", seats=[s["id"] for s in seats], rounds=rounds)
535
+ # The roster is the one part of the packet an operator writes without
536
+ # reading: it is assembled from paths chosen for the filesystem and then
537
+ # shown to every seat. Printing it at the last moment anything can change
538
+ # is what turns a filename back into a decision.
539
+ frozen["roster"] = holdings(frozen)
540
+ return frozen
541
+
542
+
543
+ # A room that has stopped producing positions keeps producing paragraphs, so
544
+ # it cannot be trusted to notice it is finished. Both signals below read the
545
+ # board and nothing else: no model call is spent asking a room whether it is
546
+ # done. They are heuristics fitted to two ten-round rooms, not laws, which is
547
+ # why a plan can move either of them.
548
+ STOP_NOVELTY = 55.0
549
+ STOP_CLOSING = 0.75
550
+ CLOSING = re.compile(
551
+ r"\b(unanimous|our work here|the room has|settled|adjourn|send the contract"
552
+ r"|sign the|close the room|no more objections|verdict)",
553
+ re.IGNORECASE,
554
+ )
555
+ WORD = re.compile(r"[a-z][a-z'-]+")
556
+ COMMON = frozenset(
557
+ [
558
+ "the",
559
+ "a",
560
+ "an",
561
+ "and",
562
+ "or",
563
+ "but",
564
+ "of",
565
+ "to",
566
+ "in",
567
+ "is",
568
+ "it",
569
+ "that",
570
+ "this",
571
+ "for",
572
+ "with",
573
+ "as",
574
+ "on",
575
+ "not",
576
+ "you",
577
+ "we",
578
+ "they",
579
+ "be",
580
+ "are",
581
+ "was",
582
+ "were",
583
+ "has",
584
+ "have",
585
+ "had",
586
+ "will",
587
+ "would",
588
+ "can",
589
+ "could",
590
+ "their",
591
+ "its",
592
+ "his",
593
+ "her",
594
+ "them",
595
+ "me",
596
+ "my",
597
+ "our",
598
+ "your",
599
+ "if",
600
+ "then",
601
+ "than",
602
+ "so",
603
+ "what",
604
+ "which",
605
+ "who",
606
+ "when",
607
+ "how",
608
+ "all",
609
+ "any",
610
+ "each",
611
+ "other",
612
+ "more",
613
+ "most",
614
+ "some",
615
+ "such",
616
+ "no",
617
+ "nor",
618
+ "only",
619
+ "own",
620
+ "same",
621
+ "too",
622
+ "very",
623
+ "just",
624
+ "do",
625
+ "does",
626
+ "did",
627
+ "at",
628
+ "by",
629
+ "from",
630
+ "up",
631
+ "down",
632
+ "out",
633
+ "off",
634
+ "over",
635
+ "under",
636
+ "again",
637
+ "once",
638
+ "here",
639
+ "there",
640
+ "why",
641
+ "while",
642
+ "about",
643
+ "against",
644
+ "between",
645
+ "into",
646
+ "through",
647
+ "during",
648
+ "before",
649
+ "after",
650
+ "above",
651
+ "below",
652
+ ]
653
+ )
654
+
655
+
656
+ def _content_words(text):
657
+ return {w for w in WORD.findall(text.lower()) if w not in COMMON and len(w) > 3}
658
+
659
+
660
+ def spoke(root, plan, n):
661
+ """The seats that both acted in round ``n`` and left a post to read.
662
+
663
+ A seat that was not seated by the phase never had a turn, and a seat whose
664
+ turn produced nothing is recorded absent. Neither has said anything the
665
+ stop rule may read as the room running out of things to say.
666
+ """
667
+ return [
668
+ name
669
+ for name in acting(plan, n)
670
+ if (root / "board" / "posts" / name / f"r{n:03d}.md").exists()
671
+ ]
672
+
673
+
674
+ def others_spoke(root, plan, seat, n):
675
+ """Whether anyone but ``seat`` left a post on round ``n``'s board."""
676
+ return bool([name for name in spoke(root, plan, n) if name != seat])
677
+
678
+
679
+ def last_heard(root, plan, seat, n):
680
+ """The most recent round up to ``n`` where ``seat`` heard somebody else.
681
+
682
+ A drafter coming out of a solo phase needs the board where the room last
683
+ spoke, not the one where it last changed. Those are the same round for
684
+ everyone except the seat that was drafting alone.
685
+ """
686
+ return next((r for r in range(n, 0, -1) if others_spoke(root, plan, seat, r)), None)
687
+
688
+
689
+ def convergence(root, plan, n):
690
+ """How close the room is to having nothing left to say, after round ``n``.
691
+
692
+ Novelty is the share of a post's content words that seat has never used
693
+ before; it falls as writers begin restating themselves. Closing is the
694
+ share of seats reaching for consensus language. Either signal must hold
695
+ for two consecutive rounds, because a single round of agreement is noise:
696
+ one measured room hit three-quarters closing at round four and was back to
697
+ one-quarter at round five, still arguing.
698
+ """
699
+ seats = [s["id"] for s in plan["seats"]]
700
+ seen = {s: set() for s in seats}
701
+ series = []
702
+ for number in range(1, n + 1):
703
+ novel, closers = [], 0
704
+ # Only the seats that spoke. A resting seat scored zero novelty and was
705
+ # averaged in with the rest, so a phase that seats one writer read as a
706
+ # room three-quarters out of things to say: the drafting round of the
707
+ # first real room reported convergence at 15 percent while the drafter
708
+ # was putting the first new page of the book on the board. Silence
709
+ # under a phase is not a seat repeating itself. It is not a turn.
710
+ for name in spoke(root, plan, number):
711
+ text = (root / "board" / "posts" / name / f"r{number:03d}.md").read_text()
712
+ words = _content_words(text)
713
+ novel.append(100 * len(words - seen[name]) / max(1, len(words)))
714
+ seen[name] |= words
715
+ closers += bool(CLOSING.search(text))
716
+ series.append(
717
+ {
718
+ "round": number,
719
+ "novelty": round(sum(novel) / len(novel), 1) if novel else None,
720
+ "closing": round(closers / len(novel), 2) if novel else None,
721
+ }
722
+ )
723
+ quiet_at = plan.get("stop_novelty", STOP_NOVELTY)
724
+ closed_at = plan.get("stop_closing", STOP_CLOSING)
725
+ last = series[-2:]
726
+ # A round nobody spoke in measures nothing, so it cannot be one of the two
727
+ # consecutive rounds that stop a room. Dropping it instead would let two
728
+ # rounds either side of it read as consecutive when they are not.
729
+ usable = last if all(x["novelty"] is not None for x in last) else []
730
+ quiet = len(usable) == 2 and all(x["novelty"] <= quiet_at for x in usable)
731
+ closed = len(usable) == 2 and all(x["closing"] >= closed_at for x in usable)
732
+ return {
733
+ "round": n,
734
+ "novelty": series[-1]["novelty"] if series else None,
735
+ "closing": series[-1]["closing"] if series else None,
736
+ "converged": quiet or closed,
737
+ "reason": "novelty" if quiet else "closing" if closed else None,
738
+ "series": series,
739
+ }
740
+
741
+
742
+ # What a seat may do beyond writing its post: `write` is the room's floor,
743
+ # `research` adds the harness's web tools for a seat that must check sources.
744
+ TOOLS = ("write", "research")
745
+
746
+
747
+ def seat_config(plan, seat):
748
+ """This seat's model. Seats may differ; the room supplies the default."""
749
+ return seat.get("config") or plan["config"]
750
+
751
+
752
+ def budget(root, plan):
753
+ """How many rounds this room may run: what it declared, plus extensions.
754
+
755
+ The frozen plan holds the assignment -- personas, packets, the standing
756
+ brief -- and must stay byte-identical for its digest to mean anything. How
757
+ long the room runs is not part of the assignment; it is a decision the
758
+ operator keeps making. Keeping it in its own file lets the budget move
759
+ without ever touching what the writers were promised.
760
+ """
761
+ path = root / "budget.json"
762
+ return read(path)["rounds"] if path.exists() else plan["rounds"]
763
+
764
+
765
+ def extend(root, rounds):
766
+ """Raise the round budget. One way, because lowering it orphans rounds."""
767
+ root, plan = load(root)
768
+ current = budget(root, plan)
769
+ if not isinstance(rounds, int) or rounds <= current:
770
+ raise ValueError(f"this room already allows {current} rounds")
771
+ write(root / "budget.json", {"rounds": rounds, "declared": plan["rounds"]})
772
+ event(root, event="budget-extended", rounds=rounds, was=current)
773
+ return rounds
774
+
775
+
776
+ def board_order(seats, n):
777
+ """Seat order for round ``n``'s digest, rotated so no seat always leads.
778
+
779
+ Whoever appears first anchors the read. A fixed order hands the same seat
780
+ that advantage in every round; rotating by the round number spreads it
781
+ evenly and keeps the digest deterministic.
782
+ """
783
+ names = [s["id"] for s in seats]
784
+ offset = (n - 1) % len(names)
785
+ return names[offset:] + names[:offset]
786
+
787
+
788
+ def deliverable(plan, n):
789
+ """The file round ``n``'s phase asks its seats to write, if it asks for one.
790
+
791
+ A drafting phase produces two different things that were previously one:
792
+ a note to the room, and the passage itself. Welded together in a post, the
793
+ passage has to be cut back out by hand afterwards, against whatever
794
+ separator the room improvised. Named here, it is a file.
795
+ """
796
+ return phase_for(plan, n).get("deliverable")
797
+
798
+
799
+ def declared_files(plan):
800
+ """Every file this plan asks for anywhere, not only in the current round.
801
+
802
+ A seat that corrects a section after its phase has passed has nowhere to
803
+ put the correction: only one file is asked for per round, and a file left
804
+ in an outbox is invisible until something asks for it again. In the
805
+ measured run the drafter fixed the preface four rounds after the preface
806
+ phase, then spent a round arguing that the book would be bound stale,
807
+ because nothing had acknowledged the file. Taking any file the plan names
808
+ costs nothing and keeps the latest version of every section collected,
809
+ hashed and attributed the moment it exists.
810
+ """
811
+ return sorted({p["deliverable"] for p in phases(plan) if p.get("deliverable")})
812
+
813
+
814
+ def safe_name(value):
815
+ """A deliverable name a seat may write and the room may copy.
816
+
817
+ The seat chooses the contents and the plan chooses the name, but the name
818
+ is a path the controller resolves inside a seat's outbox, so it stays a
819
+ plain filename. Anything else is a way out of the directory.
820
+ """
821
+ text = str(value)
822
+ if not text or "/" in text or text.startswith(".") or text != Path(text).name:
823
+ raise ValueError(f"deliverable must be a plain file name: {value!r}")
824
+ return text
825
+
826
+
827
+ def attributed(text, plan):
828
+ """The board as the operator reads it: each post's seat named by model.
829
+
830
+ Deliberately applied when printing and never when composing. The stored
831
+ digest is what the seats are shown, and a model name on it would tell
832
+ every writer which model wrote each post, which is a different room: a
833
+ seat would start weighing a colleague by its make rather than by what it
834
+ said. The operator has the opposite need, because the seats are the
835
+ instrument and reading the room means knowing which one spoke.
836
+
837
+ It also answers a real failure. A seat invented model nicknames for
838
+ colleagues it could not see and attached one of them to the wrong
839
+ argument, which is invisible until someone holds the board beside the
840
+ plan.
841
+ """
842
+ named = {
843
+ s["id"]: f"{s['config']['harness']}/{s['config'].get('model')}"
844
+ for s in plan["seats"]
845
+ }
846
+ for seat, made in named.items():
847
+ text = text.replace(f"({seat})", f"({seat}, {made})")
848
+ return text
849
+
850
+
851
+ def compose_digest(root, plan, n):
852
+ """Concatenate round ``n``'s posts, attributed and whole. Never summarize.
853
+
854
+ A digest that compressed four posts into a paragraph would make the
855
+ controller the room's narrator, and every later round would be a response
856
+ to its reading rather than to what the seats actually wrote.
857
+ """
858
+ rule = "-" * 68
859
+ labels = {s["id"]: label(s) for s in plan["seats"]}
860
+ research = {s["id"] for s in plan["seats"] if s.get("tools") == "research"}
861
+ # Attribution is the whole frame. A name above a post is what makes it a
862
+ # colleague's opinion rather than loose text next to the brief, and it
863
+ # needs no sentence explaining itself. Describing the order, the promotion
864
+ # or the rotation would only tell a writer about machinery it does not
865
+ # occupy.
866
+ made = deliverable(plan, n)
867
+ text = f"# The board -- round {n}\n\n"
868
+ note = chair_note(root, n)
869
+ if note:
870
+ text += f"{rule}\n## From the chair\n{rule}\n\n{note}\n\n"
871
+ included = {}
872
+ for name in board_order(plan["seats"], n):
873
+ post = root / "board" / "posts" / name / f"r{n:03d}.md"
874
+ if not post.exists():
875
+ continue
876
+ mark = " [can search the web]" if name in research else ""
877
+ text += f"{rule}\n## {labels[name]} ({name}){mark}\n{rule}\n\n"
878
+ text += post.read_text().strip() + "\n\n"
879
+ included[name] = digest(post)
880
+ # A phase's deliverable is the work itself, and the room still has to
881
+ # read it. It goes on the board under its author's name like anything
882
+ # else, so a reviewer sees the note and the thing the note is about,
883
+ # while the file stays separately readable as a file.
884
+ work = root / "board" / "made" / name / f"r{n:03d}" / made if made else None
885
+ if work and work.exists():
886
+ text += f"### {made}\n\n{work.read_text().strip()}\n\n"
887
+ absent = [
888
+ s["id"]
889
+ for s in plan["seats"]
890
+ if s["id"] in acting(plan, n) and s["id"] not in included
891
+ ]
892
+ if absent:
893
+ names = ", ".join(labels[a] for a in absent)
894
+ text += f"{rule}\n\nNo post this round from: {names}.\n"
895
+ return text, included, absent
896
+
897
+
898
+ def trail(root):
899
+ """The room's log rows, oldest first. A partly written last line is
900
+ skipped rather than treated as corruption, because the writer may be
901
+ mid-append."""
902
+ rows = []
903
+ log = Path(root) / "room.log"
904
+ if log.exists():
905
+ for line in log.read_text().splitlines():
906
+ try:
907
+ rows.append(json.loads(line))
908
+ except json.JSONDecodeError:
909
+ continue
910
+ return rows
911
+
912
+
913
+ def held_round(root, n, rows=None):
914
+ """The latest ``round-held`` row for round ``n``, while it is unpublished."""
915
+ if (Path(root) / "board" / "rounds" / f"r{n:03d}").exists():
916
+ return None
917
+ rows = trail(root) if rows is None else rows
918
+ return next(
919
+ (
920
+ r
921
+ for r in reversed(rows)
922
+ if r.get("event") == "round-held" and r.get("round") == n
923
+ ),
924
+ None,
925
+ )
926
+
927
+
928
+ def held_back(root, n, rows=None):
929
+ """Seats round ``n`` held back that have not yet taken their turn.
930
+
931
+ A seat is held back when another stopped on a provider limit first, and
932
+ it stays held back until a later run of the round launches it. Any step
933
+ of its own in the round after the hold counts as taking the turn, a
934
+ failed launch included, since that failure is then the seat's own fact.
935
+ """
936
+ rows = trail(root) if rows is None else rows
937
+ held = held_round(root, n, rows)
938
+ if not held:
939
+ return []
940
+ since = rows[rows.index(held) + 1 :]
941
+ took = {
942
+ r.get("seat")
943
+ for r in since
944
+ if r.get("round") == n and r.get("event") in ("launching", "stopped")
945
+ }
946
+ return [name for name in held.get("held_back") or [] if name not in took]
947
+
948
+
949
+ def promote(root, n):
950
+ """Move every outbox onto the board, then publish the round's digest.
951
+
952
+ This is the only writer of shared state and it runs with every seat
953
+ stopped. It copies, hashes and attributes; it never reads a post to decide
954
+ whether it belongs. A missing or empty outbox means the seat was absent
955
+ for the round, which is a fact about the round and not a failure.
956
+ """
957
+ root, plan = load(root)
958
+ # A seat held back when another stopped on a provider limit never took
959
+ # its turn, so its outbox is empty for a reason that is not its own.
960
+ # Filing it absent would put a false fact on a board that is never
961
+ # rewritten; it takes its turn when the round is run again.
962
+ waiting = held_back(root, n)
963
+ if waiting:
964
+ raise RuntimeError(
965
+ f"round {n} held back {', '.join(waiting)} when a seat stopped on "
966
+ f"a provider limit; run `round {n}` to take their turns and close "
967
+ "the round"
968
+ )
969
+ speaking = acting(plan, n)
970
+ for seat in plan["seats"]:
971
+ if seat["id"] not in speaking:
972
+ continue
973
+ outbox = root / "seats" / seat["id"] / "outbox" / "post.md"
974
+ if not outbox.exists() or not outbox.read_bytes().strip():
975
+ event(root, round=n, seat=seat["id"], event="absent")
976
+ continue
977
+ post = root / "board" / "posts" / seat["id"] / f"r{n:03d}.md"
978
+ if post.exists():
979
+ raise RuntimeError(f"round {n} already promoted for {seat['id']}")
980
+ shutil.copyfile(outbox, post)
981
+ outbox.unlink()
982
+ event(
983
+ root,
984
+ round=n,
985
+ seat=seat["id"],
986
+ event="promoted",
987
+ bytes=post.stat().st_size,
988
+ sha256=digest(post),
989
+ )
990
+ made = deliverable(plan, n)
991
+ for name in declared_files(plan):
992
+ written = root / "seats" / seat["id"] / "outbox" / name
993
+ if not written.exists() or not written.read_bytes().strip():
994
+ if name == made:
995
+ # The seat posted and did not produce the file the phase
996
+ # asked for. That is a fact about the round, like an
997
+ # absence, and not a reason to fail a round whose post is
998
+ # already on the board.
999
+ event(root, round=n, seat=seat["id"], event="unmade", file=name)
1000
+ continue
1001
+ kept = root / "board" / "made" / seat["id"] / f"r{n:03d}" / name
1002
+ kept.parent.mkdir(parents=True, exist_ok=True)
1003
+ shutil.copyfile(written, kept)
1004
+ written.unlink()
1005
+ event(
1006
+ root,
1007
+ round=n,
1008
+ seat=seat["id"],
1009
+ event="made" if name == made else "revised",
1010
+ file=name,
1011
+ bytes=kept.stat().st_size,
1012
+ sha256=digest(kept),
1013
+ )
1014
+ text, included, absent = compose_digest(root, plan, n)
1015
+ out = root / "board" / "rounds" / f"r{n:03d}"
1016
+ out.mkdir(parents=True, exist_ok=True)
1017
+ (out / "digest.md").write_text(text)
1018
+ write(
1019
+ out / "digest.json",
1020
+ {
1021
+ "round": n,
1022
+ "order": board_order(plan["seats"], n),
1023
+ "posts": included,
1024
+ "absent": absent,
1025
+ "digest_sha256": digest(out / "digest.md"),
1026
+ },
1027
+ )
1028
+ signal = convergence(root, plan, n)
1029
+ write(out / "convergence.json", signal)
1030
+ event(root, round=n, event="round-closed", posted=len(included), absent=len(absent))
1031
+ event(
1032
+ root,
1033
+ round=n,
1034
+ event="convergence",
1035
+ novelty=signal["novelty"],
1036
+ closing=signal["closing"],
1037
+ converged=signal["converged"],
1038
+ reason=signal["reason"],
1039
+ )
1040
+ release_if_finished(root, plan)
1041
+ return {
1042
+ "posted": sorted(included),
1043
+ "absent": absent,
1044
+ "convergence": {
1045
+ k: signal[k] for k in ("novelty", "closing", "converged", "reason")
1046
+ },
1047
+ }
1048
+
1049
+
1050
+ def release_if_finished(root, plan):
1051
+ """Let `prune` take the seats' homes once the room is where `run` stops.
1052
+
1053
+ That is the stop rule `status` reports: no round held, and either every
1054
+ round of the budget published or the room converged. Extending a room,
1055
+ or running it past its convergence, is the operator's call, and so is
1056
+ pruning before that; each seat launched again holds its home again.
1057
+ """
1058
+ value = status(root)
1059
+ if value["held"]:
1060
+ return
1061
+ if len(value["published_rounds"]) < value["rounds"] and not value["converged"]:
1062
+ return
1063
+ for seat in plan["seats"]:
1064
+ blind.release(root / "seats" / seat["id"])
1065
+
1066
+
1067
+ def open_round(root, n):
1068
+ """Put the previous round's digest inside every workspace, read-only."""
1069
+ root, plan = load(root)
1070
+ if n == 1 or n in plan["silent_rounds"]:
1071
+ return None
1072
+ source = root / "board" / "rounds" / f"r{n - 1:03d}" / "digest.md"
1073
+ if not source.exists():
1074
+ raise RuntimeError(f"round {n - 1} has not been promoted; nothing to show")
1075
+ for seat in plan["seats"]:
1076
+ target = root / "seats" / seat["id"] / "board" / f"round-{n - 1:03d}"
1077
+ target.mkdir(parents=True, exist_ok=True)
1078
+ shutil.copyfile(source, target / "digest.md")
1079
+ event(root, round=n, event="board-published", digest_sha256=digest(source))
1080
+ return str(source)
1081
+
1082
+
1083
+ def _terminate(proc):
1084
+ """Stop a seat and everything it started; it is its own session leader."""
1085
+ for number in (signal.SIGTERM, signal.SIGKILL):
1086
+ try:
1087
+ os.killpg(os.getpgid(proc.pid), number)
1088
+ except (ProcessLookupError, PermissionError):
1089
+ return
1090
+ try:
1091
+ proc.wait(timeout=20)
1092
+ return
1093
+ except subprocess.TimeoutExpired:
1094
+ continue
1095
+
1096
+
1097
+ def compaction(stream):
1098
+ """Compaction markers in one turn's event stream, if the harness said so.
1099
+
1100
+ A room's whole economy rests on a round being a resume: the seat keeps its
1101
+ reading and its reasoning and is handed only what changed. Compaction is
1102
+ that continuity being silently rewritten mid-conversation, which is why
1103
+ `docs/workflows.md` makes it a red flag for every procedure that invokes a
1104
+ harness. The room had no way to raise it, so a compacted room and a clean
1105
+ one produced identical records.
1106
+
1107
+ Detected, never enforced: the answer is already bought and stays
1108
+ available, and a supervisor decides what a compacted round is worth.
1109
+ """
1110
+ if not stream.exists():
1111
+ return []
1112
+ try:
1113
+ return observe.compaction_markers(observe.native_rows(stream))
1114
+ except (ValueError, OSError):
1115
+ # A malformed or truncated stream is the failure the receipt already
1116
+ # reports. It must not also turn into a missing compaction check that
1117
+ # reads as a clean one.
1118
+ return [{"timestamp": None, "tags": ["unreadable-stream"]}]
1119
+
1120
+
1121
+ def run_seat(root, plan, seat, n, *, timeout=None):
1122
+ """Run one seat's turn for round ``n``: launch it, or resume its session."""
1123
+ name = seat["id"]
1124
+ work, config = root / "seats" / name, seat_config(plan, seat)
1125
+ record = root / "records" / name / f"r{n:03d}"
1126
+ state = read(state_path(root, name))
1127
+ if str(n) in state["rounds"]:
1128
+ return state["rounds"][str(n)]["status"]
1129
+ # The packet and the standing assignment are frozen and checked every
1130
+ # round. The board is deliberately not: it is the one thing that is
1131
+ # supposed to change, which is why it lives outside `input/`.
1132
+ if hashes(work / "input") != seat["input_hashes"]:
1133
+ raise RuntimeError(f"seat input changed: {name}")
1134
+ if digest(work / "START.md") != seat["start_sha256"]:
1135
+ raise RuntimeError(f"standing assignment changed: {name}")
1136
+ resume = state.get("session_id")
1137
+ # A seat with no session is one whose earlier turn produced nothing at
1138
+ # all: a provider refusing before the model ran leaves a failed round and
1139
+ # no conversation to continue. Refusing to run it again costs the seat for
1140
+ # the rest of the room, and the alternative the procedure offered was to
1141
+ # throw away every turn the other seats had already bought. So it opens a
1142
+ # session now and joins in progress, reading the boards it missed. What it
1143
+ # cannot recover is its opening position, which the room heard from
1144
+ # everyone else before it arrived; `status` reports the round it joined so
1145
+ # no later reading mistakes a late arrival for an independent opening.
1146
+ cold = n > 1 and not resume
1147
+ if resume:
1148
+ blind.require_session(work, config["harness"], resume)
1149
+ record.mkdir(parents=True)
1150
+ return launch_turn(
1151
+ root,
1152
+ plan,
1153
+ seat,
1154
+ n,
1155
+ turn_prompt(root, plan, seat, n, cold=cold),
1156
+ resume,
1157
+ timeout=timeout,
1158
+ )
1159
+
1160
+
1161
+ def launch_turn(
1162
+ root,
1163
+ plan,
1164
+ seat,
1165
+ n,
1166
+ prompt,
1167
+ resume,
1168
+ *,
1169
+ timeout=None,
1170
+ workflow="room",
1171
+ readonly=("board",),
1172
+ ):
1173
+ """Run one turn and bank what it produced, however it ended.
1174
+
1175
+ Split from ``run_seat`` so a continuation after a quota stop is the same
1176
+ launch, the same jail, the same receipt and the same records as the turn
1177
+ it replaces. A recovery that took a different path would be evidence
1178
+ about the recovery rather than about the room.
1179
+
1180
+ Public because the roundtable takes its turns through it too: one jail,
1181
+ one receipt and one record layout for every procedure that seats models
1182
+ in rounds. ``readonly`` names the workspace folders bound read-only over
1183
+ the writable workspace, and ``workflow`` is what the usage record calls
1184
+ the procedure that launched the seat.
1185
+ """
1186
+ name = seat["id"]
1187
+ work, config = root / "seats" / name, seat_config(plan, seat)
1188
+ record = root / "records" / name / f"r{n:03d}"
1189
+ record.mkdir(parents=True, exist_ok=True)
1190
+ # A family such as `opus` means whatever is newest when the CLI runs, so
1191
+ # a resumed session names the version its first answer was served on. A
1192
+ # release between rounds would otherwise switch the seat's model while
1193
+ # the room reads it as the same voice. A seat whose plan already names
1194
+ # that exact version needs no pin, and claiming one would add nothing.
1195
+ pinned = read(state_path(root, name)).get("model_served") if resume else None
1196
+ if pinned and pinned != config["model"]:
1197
+ config = {**config, "model_pinned": pinned}
1198
+ proven = drivers.canary(config, f"{workflow} seat {name}")
1199
+ # Open before the jail is built, since its argv names the proxy's socket,
1200
+ # and closed only after the seat's process has exited.
1201
+ with blind.session_bus([config["harness"]]) as bus:
1202
+ args, payload = drivers.command(config, work, prompt, resume=resume)
1203
+ jail, env, private = drivers.launch_context(config, work, bus, proven)
1204
+ blind.hold(
1205
+ work,
1206
+ f"{workflow} {root.name} resumes seat {name}'s session until it finishes",
1207
+ )
1208
+ for folder in readonly:
1209
+ jail += ["--ro-bind", str(work / folder), str(work / folder)]
1210
+ (record / "prompt.md").write_text(prompt)
1211
+ usage_records.seat(workflow, config)
1212
+ write(
1213
+ record / "launch.json",
1214
+ {
1215
+ "argv": args,
1216
+ "jail": jail,
1217
+ "resumed": resume,
1218
+ # Where the session stood before this turn was sent, so a turn
1219
+ # the provider refuses can be cut back out of it and sent
1220
+ # again into the same session (`recovery`).
1221
+ "resume_point": quota.session_marks(
1222
+ blind.session_paths(private, config["harness"], resume)
1223
+ )
1224
+ if resume
1225
+ else None,
1226
+ "harness_version": blind.runner_version(config["harness"]),
1227
+ "private_runtime": str(private),
1228
+ "window_assurance": drivers.window_assurance(config["harness"]),
1229
+ "session_bus": bus.receipt() if bus else None,
1230
+ "canary": proven,
1231
+ },
1232
+ )
1233
+ with slot(config["harness"]):
1234
+ event(root, round=n, seat=name, event="launching", resumed=resume)
1235
+ started = time.time()
1236
+ with (
1237
+ (record / "events.jsonl").open("xb") as out,
1238
+ (record / "stderr.log").open("xb") as err,
1239
+ ):
1240
+ proc = blind.popen(
1241
+ jail + args,
1242
+ bus,
1243
+ stdin=subprocess.PIPE,
1244
+ stdout=out,
1245
+ stderr=err,
1246
+ cwd=current_project().root,
1247
+ env=env,
1248
+ start_new_session=True,
1249
+ )
1250
+ event(
1251
+ root,
1252
+ round=n,
1253
+ seat=name,
1254
+ event="running",
1255
+ pid=proc.pid,
1256
+ process_identity=process_identity(proc.pid),
1257
+ )
1258
+ why = exits.wait(
1259
+ proc,
1260
+ payload,
1261
+ harness=config["harness"],
1262
+ events=record / "events.jsonl",
1263
+ timeout=timeout,
1264
+ )
1265
+ if why:
1266
+ _terminate(proc)
1267
+ proc.communicate()
1268
+ timed_out = why == exits.TIMEOUT
1269
+ if why == exits.ANSWERED:
1270
+ exits.record(
1271
+ record,
1272
+ harness=config["harness"],
1273
+ grace=exits.EXIT_GRACE,
1274
+ exit_code=proc.returncode,
1275
+ )
1276
+ code, seconds = proc.returncode, round(time.time() - started, 2)
1277
+ event(
1278
+ root,
1279
+ round=n,
1280
+ seat=name,
1281
+ event="exited",
1282
+ code=code,
1283
+ seconds=seconds,
1284
+ timed_out=timed_out,
1285
+ stopped_after_answer=why == exits.ANSWERED,
1286
+ )
1287
+ try:
1288
+ got = drivers.receipt(config, record, private, resumed=resume)
1289
+ status, detail = "answered", None
1290
+ except (RuntimeError, ValueError, OSError) as exc:
1291
+ got, status, detail = {}, "failed", str(exc)
1292
+ compacted = compaction(record / "events.jsonl")
1293
+ # A provider limit and a crash both leave a failed turn, and only one of
1294
+ # them is worth waiting out. Classifying here is what lets `continue`
1295
+ # refuse everything else: a timeout re-run looks identical to a quota
1296
+ # re-run at the command line and duplicates work that actually happened.
1297
+ stop = quota.classify(config["harness"], record) if status == "failed" else None
1298
+ if stop:
1299
+ status = "quota"
1300
+ write(
1301
+ record / "receipt.json",
1302
+ {
1303
+ **got,
1304
+ "exit_code": code,
1305
+ "seconds": seconds,
1306
+ "timed_out": timed_out,
1307
+ "stopped_after_answer": exits.recorded(record),
1308
+ "error": detail,
1309
+ "compaction_markers": compacted,
1310
+ "quota_stop": stop["phase"] if stop else None,
1311
+ "quota_scope": stop.get("scope") if stop else None,
1312
+ "quota_resets_at": stop.get("resets_at") if stop else None,
1313
+ "red_flags": got.get("red_flags", [])
1314
+ + ([blind.Bus.flag(bus.receipt())] if bus else []),
1315
+ },
1316
+ )
1317
+ if compacted:
1318
+ event(root, round=n, seat=name, event="compacted", markers=len(compacted))
1319
+ state = read(state_path(root, name))
1320
+ if got.get("session_id"):
1321
+ state["session_id"] = got["session_id"]
1322
+ if status == "answered" and got.get("model") and not state.get("model_served"):
1323
+ state["model_served"] = got["model"]
1324
+ state["rounds"][str(n)] = {
1325
+ "status": status,
1326
+ "seconds": seconds,
1327
+ "error": detail,
1328
+ **(
1329
+ {"quota_stop": stop["phase"], "quota_scope": stop.get("scope")}
1330
+ if stop
1331
+ else {}
1332
+ ),
1333
+ }
1334
+ # A refused stop leaves no session; an interrupted one leaves the session
1335
+ # holding the reasoning, and losing its id here is what turns a
1336
+ # continuation into a cold restart wearing a continuation's receipt.
1337
+ if stop and stop.get("session_id") and not state.get("session_id"):
1338
+ state["session_id"] = stop["session_id"]
1339
+ state["status"] = status
1340
+ write(state_path(root, name), state)
1341
+ event(root, round=n, seat=name, event="collected", status=status, error=detail)
1342
+ return status
1343
+
1344
+
1345
+ def continue_seat(root, n, name, *, timeout=None):
1346
+ """Take one seat's turn again after a provider limit stopped it.
1347
+
1348
+ Two stops, opposite recoveries, and `quota` owns which is which. *Refused*
1349
+ means the provider declined before the model ran, so nothing of the turn
1350
+ exists to continue: the identical turn is delivered again, out of the
1351
+ prompt the stopped attempt was given, into the session it was continuing
1352
+ and with the refused attempt's rows cut from it (`recovery`). *Interrupted*
1353
+ means the model ran and the wall arrived partway, so the session holds the
1354
+ reading and the reasoning and the turn is continued inside it, with no
1355
+ re-delivery of anything the seat has already been shown.
1356
+
1357
+ It refuses everything that is not a classified quota stop. A crash, a
1358
+ timeout and an ambiguous transport fault look identical to this at the
1359
+ command line, and re-running one of those can duplicate work that really
1360
+ happened. The stopped attempt is archived whole beside the new one, so the
1361
+ round reads as two submissions rather than one that changed its mind, and
1362
+ the receipt says so.
1363
+ """
1364
+ root, plan = load(root)
1365
+ # `seat_named` resolves an id or a position and returns the id, so the
1366
+ # seat itself still has to be looked up.
1367
+ name = seat_named(plan, name)
1368
+ seat = next(s for s in plan["seats"] if s["id"] == name)
1369
+ record = root / "records" / name / f"r{n:03d}"
1370
+ state = read(state_path(root, name))
1371
+ attempt = state.get("rounds", {}).get(str(n))
1372
+ if not attempt:
1373
+ raise ValueError(f"{name} has no round {n} to continue")
1374
+ if attempt["status"] == "answered":
1375
+ raise ValueError(f"{name} already answered round {n}")
1376
+ if (root / "board" / "posts" / name / f"r{n:03d}.md").exists():
1377
+ raise RuntimeError(f"round {n} is already published for {name}")
1378
+ config = seat_config(plan, seat)
1379
+ stop = quota.classify(config["harness"], record)
1380
+ if not stop:
1381
+ raise RuntimeError(
1382
+ f"{name} does not carry a provider quota stop in round {n}; "
1383
+ "diagnose the failure rather than taking the turn again"
1384
+ )
1385
+ resume, prompt, rewound = recovery(
1386
+ record, root / "seats" / name, config["harness"], stop
1387
+ )
1388
+ number = len(list((record / "attempts").glob("[0-9][0-9]"))) + 1
1389
+ folder = record / "attempts" / f"{number:02d}"
1390
+ folder.mkdir(parents=True)
1391
+ for item in (
1392
+ "events.jsonl",
1393
+ "stderr.log",
1394
+ "prompt.md",
1395
+ "launch.json",
1396
+ "receipt.json",
1397
+ ):
1398
+ source = record / item
1399
+ if source.exists():
1400
+ shutil.move(str(source), str(folder / item))
1401
+ del state["rounds"][str(n)]
1402
+ state.setdefault("attempts", []).append(
1403
+ {
1404
+ "round": n,
1405
+ "attempt": number,
1406
+ "quota_stop": stop["phase"],
1407
+ "quota_scope": stop.get("scope"),
1408
+ "resumed": resume,
1409
+ "rewound": rewound,
1410
+ }
1411
+ )
1412
+ write(state_path(root, name), state)
1413
+ event(
1414
+ root,
1415
+ round=n,
1416
+ seat=name,
1417
+ event="continuing",
1418
+ quota_stop=stop["phase"],
1419
+ resumed=resume,
1420
+ rewound=rewound,
1421
+ attempt=number,
1422
+ )
1423
+ return launch_turn(root, plan, seat, n, prompt, resume, timeout=timeout)
1424
+
1425
+
1426
+ def recovery(record, work, harness, stop):
1427
+ """How a turn a provider limit stopped is taken again.
1428
+
1429
+ Returns the session to launch into (None for a fresh one), the prompt,
1430
+ and whether the session was rewound first. An *interrupted* turn is
1431
+ continued inside the session that holds its reading, with a short notice.
1432
+ Any other stop is the identical turn delivered again, out of the prompt
1433
+ the stopped attempt was given. A turn that opened the seat's session goes
1434
+ in a fresh one, since nothing of it exists. A turn that was itself a
1435
+ resume, which is every round after a seat's first, goes back into the
1436
+ session it was continuing, because that session holds the seat's earlier
1437
+ rounds. Sending it in a fresh one lost them, and its prompt, written for
1438
+ a seat that remembers them, reached one with no assignment at all.
1439
+
1440
+ The rows the refused attempt appended are cut away first, back to the
1441
+ mark its launch took, as the run engine does. A launch from before marks
1442
+ were taken left none, and the session then still shows the refused
1443
+ prompt once before the same prompt again. The session is checked before
1444
+ anything changes, and the rewind comes last, so a refusal of either kind
1445
+ leaves the turn as it was.
1446
+ """
1447
+ session = quota.resumable_session(stop)
1448
+ if session:
1449
+ blind.require_session(work, harness, session)
1450
+ return session, quota.continuation(), False
1451
+ launch = read(record / "launch.json") if (record / "launch.json").exists() else {}
1452
+ prompt = (record / "prompt.md").read_text()
1453
+ session = launch.get("resumed")
1454
+ if not session:
1455
+ return None, prompt, False
1456
+ blind.require_session(work, harness, session)
1457
+ marks = launch.get("resume_point")
1458
+ if marks is None:
1459
+ return session, prompt, False
1460
+ quota.rewind_sessions(marks)
1461
+ return session, prompt, True
1462
+
1463
+
1464
+ def run_round(root, n, *, jobs=1, timeout=None, past=()):
1465
+ """Open the round, run every seat, then close it by promoting the outboxes.
1466
+
1467
+ Seats run one at a time unless ``jobs`` says otherwise, and the account
1468
+ lease caps them again per harness. Sequential is the default because
1469
+ seats sharing one account drain a rate window several times faster
1470
+ together and the wall then lands mid-turn on all of them at once; it also
1471
+ preserves whatever spend order the plan recorded. The round closes once
1472
+ every seat has stopped, so the board changes at exactly one instant and no
1473
+ seat reads a partial one.
1474
+
1475
+ A seat that stops on a provider limit pauses the round: no seat after it
1476
+ is launched, because what to do next is the operator's decision (wait the
1477
+ window out, or go on without that seat), and launching the rest first
1478
+ spends their allowance before it is made. The round is held with the
1479
+ stopped seats and the held-back seats named. Running the same round
1480
+ again resumes it: a seat that already answered is not launched again, a
1481
+ seat still stopped holds it again, and the held-back seats take their
1482
+ turns in plan order. ``past`` names stopped seats the operator has chosen
1483
+ not to wait for; they no longer hold the others back, and the round stays
1484
+ held on them until ``promote`` files them absent.
1485
+ """
1486
+ root, plan = load(root)
1487
+ with lock(root / "room.lock"):
1488
+ allowed = budget(root, plan)
1489
+ if n < 1 or n > allowed:
1490
+ raise ValueError(f"round must be 1..{allowed}; extend the room first")
1491
+ names = {s["id"] for s in plan["seats"]}
1492
+ past = {seat_named(plan, name) for name in past}
1493
+ # Every seat resumes its session each round. One whose private home
1494
+ # has gone would fail after the round opened and the board went out
1495
+ # to the others, so the round is refused before either happens.
1496
+ for seat in plan["seats"]:
1497
+ state = read(state_path(root, seat["id"]))
1498
+ if seat["id"] in acting(plan, n) and str(n) not in state["rounds"]:
1499
+ if state.get("session_id"):
1500
+ blind.require_session(
1501
+ root / "seats" / seat["id"],
1502
+ seat_config(plan, seat)["harness"],
1503
+ state["session_id"],
1504
+ )
1505
+ resumed = held_round(root, n)
1506
+ if resumed:
1507
+ # The board this round shows was published when it first opened,
1508
+ # and every seat that already spoke read that one; publishing it
1509
+ # again would only repeat the trail.
1510
+ event(root, round=n, event="round-resumed", jobs=jobs)
1511
+ else:
1512
+ event(root, round=n, event="round-opened", jobs=jobs)
1513
+ open_round(root, n)
1514
+ results = {}
1515
+
1516
+ speaking = acting(plan, n)
1517
+ for seat in plan["seats"]:
1518
+ if seat["id"] not in speaking:
1519
+ event(root, round=n, seat=seat["id"], event="resting")
1520
+
1521
+ halted = Event()
1522
+
1523
+ def task(seat):
1524
+ if halted.is_set():
1525
+ event(root, round=n, seat=seat["id"], event="held-back")
1526
+ return seat["id"], "held back"
1527
+ try:
1528
+ outcome = run_seat(root, plan, seat, n, timeout=timeout)
1529
+ except (RuntimeError, ValueError, OSError, subprocess.SubprocessError) as e:
1530
+ event(root, round=n, seat=seat["id"], event="stopped", error=str(e))
1531
+ return seat["id"], f"stopped: {e}"
1532
+ if outcome == "quota" and seat["id"] not in past:
1533
+ halted.set()
1534
+ return seat["id"], outcome
1535
+
1536
+ # `submit` carries the caller's project context into each thread.
1537
+ # A bare pool.map would let a worker re-resolve the project from disk,
1538
+ # which is exactly the cross-thread leak the project layer prevents.
1539
+ with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
1540
+ speakers = [s for s in plan["seats"] if s["id"] in speaking]
1541
+ for future in [submit(pool, task, s) for s in speakers]:
1542
+ name, outcome = future.result()
1543
+ results[name] = outcome
1544
+ # A quota stop is not an absence, and the digest is the durable record
1545
+ # of what the room was shown. Publishing one that files a seat as
1546
+ # silent would put a false fact beyond reach, because a published
1547
+ # board is never rewritten. So the round stays open, the operator
1548
+ # waits the provider out, continues that seat, and runs the round
1549
+ # again to take the held-back seats' turns and close it.
1550
+ stopped = sorted(k for k, v in results.items() if v == "quota")
1551
+ back = [s["id"] for s in speakers if results.get(s["id"]) == "held back"]
1552
+ if stopped or back:
1553
+ event(
1554
+ root,
1555
+ round=n,
1556
+ event="round-held",
1557
+ waiting_on=stopped,
1558
+ held_back=back,
1559
+ past=sorted(past & names & set(stopped)),
1560
+ )
1561
+ results["_board"] = {"held": stopped, "held_back": back}
1562
+ return results
1563
+ results["_board"] = promote(root, n)
1564
+ return results
1565
+
1566
+
1567
+ def run_until(root, *, limit=None, jobs=1, timeout=None):
1568
+ """Run rounds until the room converges or the budget runs out.
1569
+
1570
+ The room is asked to stop, never silenced: a single `round` still runs on
1571
+ request, and this only decides when to stop asking for another. Leaving
1572
+ the decision to the room itself is the one thing that does not work, since
1573
+ a room with nothing left to say goes on writing paragraphs.
1574
+
1575
+ A room that declared phases is not asked. Its schedule already says how
1576
+ long it runs and in what order, and the stop rule reads a board, not a
1577
+ plan: the signal cannot tell "these writers have exhausted the question"
1578
+ from "this phase is two rounds of notes on a draft, and the revision that
1579
+ answers them comes next." Stopping such a room mid-schedule ships the
1580
+ draft nobody rewrote. The signal is still measured and still recorded on
1581
+ every round, because an operator watching a phased room still wants to
1582
+ know when a discussion has gone quiet.
1583
+ """
1584
+ root = room_root(root)
1585
+ played = []
1586
+ while True:
1587
+ _, plan = load(root)
1588
+ ceiling = (
1589
+ budget(root, plan) if limit is None else min(limit, budget(root, plan))
1590
+ )
1591
+ number = len(list((root / "board" / "rounds").glob("r[0-9][0-9][0-9]"))) + 1
1592
+ if number > ceiling:
1593
+ return played, "budget"
1594
+ outcome = run_round(root, number, jobs=jobs, timeout=timeout)
1595
+ played.append((number, outcome))
1596
+ # A held round has no board and no signal: a seat hit a provider
1597
+ # wall and the round stays open until the operator continues it.
1598
+ # Asking for another round here would run it on top of an unclosed
1599
+ # one, so the loop hands back instead of reading a digest that was
1600
+ # deliberately never written.
1601
+ held = outcome["_board"].get("held")
1602
+ if held:
1603
+ return played, f"held on {', '.join(held)}"
1604
+ signal = outcome["_board"]["convergence"]
1605
+ if signal["converged"] and not plan.get("phases"):
1606
+ return played, f"converged on {signal['reason']}"
1607
+
1608
+
1609
+ def status(root):
1610
+ """A read-only view of a room, safe to call while it runs.
1611
+
1612
+ It takes no lock, opens no session and writes nothing, so watching cannot
1613
+ perturb what it watches. A partly written final log line is ignored rather
1614
+ than treated as corruption, because the writer may be mid-append.
1615
+ """
1616
+ root, plan = load(root, verify=False)
1617
+ rows = trail(root)
1618
+ allowed = budget(root, plan)
1619
+ seats = {}
1620
+ for seat in plan["seats"]:
1621
+ state = read(state_path(root, seat["id"]))
1622
+ posts = sorted(
1623
+ p.name for p in (root / "board" / "posts" / seat["id"]).glob("r*.md")
1624
+ )
1625
+ outbox = root / "seats" / seat["id"] / "outbox" / "post.md"
1626
+ receipts = {
1627
+ int(path.parent.name[1:]): read(path)
1628
+ for path in (root / "records" / seat["id"]).glob(
1629
+ "r[0-9][0-9][0-9]/receipt.json"
1630
+ )
1631
+ }
1632
+ seats[seat["id"]] = {
1633
+ "label": label(seat),
1634
+ "status": state.get("status"),
1635
+ "session_id": state.get("session_id"),
1636
+ "rounds": state.get("rounds", {}),
1637
+ # The rounds this seat speaks in. It listens through the others, so
1638
+ # they are not turns it owes, and counting them would report a
1639
+ # phase seat as permanently behind. Read over the budget rather
1640
+ # than the declared count, so an extended room counts its own.
1641
+ "acting_rounds": [
1642
+ n for n in range(1, allowed + 1) if seat["id"] in acting(plan, n)
1643
+ ],
1644
+ "posts": posts,
1645
+ "drafting": outbox.stat().st_size if outbox.exists() else 0,
1646
+ "served": f"{seat['config']['harness']}/{drivers.model_text(seat['config'])}",
1647
+ "red_flags": {
1648
+ n: got["red_flags"]
1649
+ for n, got in sorted(receipts.items())
1650
+ if got.get("red_flags")
1651
+ },
1652
+ "quota_resets_at": {
1653
+ n: got["quota_resets_at"]
1654
+ for n, got in receipts.items()
1655
+ if got.get("quota_resets_at")
1656
+ },
1657
+ # A seat that opened its session after round one heard the room
1658
+ # before it spoke, so its first post is a reply and not an opening
1659
+ # position. Round one is the only independent evidence a room
1660
+ # contains, and a late arrival is the one way a seat can appear in
1661
+ # it and not have supplied any.
1662
+ "joined_late": next(
1663
+ (
1664
+ int(r)
1665
+ for r, v in sorted(
1666
+ state.get("rounds", {}).items(), key=lambda i: int(i[0])
1667
+ )
1668
+ if v.get("status") == "answered"
1669
+ ),
1670
+ None,
1671
+ ),
1672
+ }
1673
+ published = sorted(p.name for p in (root / "board" / "rounds").glob("r*"))
1674
+ # The latest signal carries the whole measured series, so the approach to
1675
+ # convergence can be shown without recomputing anything from the board.
1676
+ signal = (
1677
+ read(root / "board" / "rounds" / published[-1] / "convergence.json")
1678
+ if published
1679
+ and (root / "board" / "rounds" / published[-1] / "convergence.json").exists()
1680
+ else None
1681
+ )
1682
+ # A phased room is not asked whether it converged, so the measured
1683
+ # signal must not end it early here either; `run_until` has the same rule.
1684
+ converged = bool(signal and signal["converged"] and not plan.get("phases"))
1685
+ held = [r for r in rows if r.get("event") == "round-held"]
1686
+ still_held = (
1687
+ dict(held[-1])
1688
+ if held and f"r{held[-1].get('round', 0):03d}" not in published
1689
+ else None
1690
+ )
1691
+ if still_held:
1692
+ # Recorded as held back when the round paused; still owed a turn
1693
+ # only until a later run of the round launches it.
1694
+ still_held["held_back"] = held_back(root, still_held.get("round"), rows)
1695
+ # An operator writes `chair/r<NNN>.md` before opening round NNN and has
1696
+ # nothing else to confirm it will be picked up, or to notice one left over
1697
+ # from a round that has already run.
1698
+ spoken = sorted(p.stem for p in (root / "chair").glob("r[0-9][0-9][0-9].md"))
1699
+ chair = {
1700
+ "delivered": [r for r in spoken if f"{r}" in {p for p in published}],
1701
+ "queued": [r for r in spoken if r not in {p for p in published}],
1702
+ }
1703
+ value = {
1704
+ "room": str(root),
1705
+ "title": plan.get("title"),
1706
+ "rounds": allowed,
1707
+ "declared_rounds": plan["rounds"],
1708
+ "published_rounds": published,
1709
+ "convergence": signal,
1710
+ "converged": converged,
1711
+ "held": still_held,
1712
+ "chair": chair,
1713
+ "seats": seats,
1714
+ "red_flags": sum(
1715
+ len(flags) for one in seats.values() for flags in one["red_flags"].values()
1716
+ ),
1717
+ # Read from the trail rather than from every receipt: the trail is
1718
+ # already open, and a red flag an operator has to go looking for is
1719
+ # one they will find after deciding the room went fine.
1720
+ "compacted": [
1721
+ {"seat": r.get("seat"), "round": r.get("round")}
1722
+ for r in rows
1723
+ if r.get("event") == "compacted"
1724
+ ],
1725
+ "last_events": rows[-8:],
1726
+ }
1727
+ value["next"] = next_step(value)
1728
+ return value
1729
+
1730
+
1731
+ def reset_text(when):
1732
+ """A provider's reset as an operator reads it.
1733
+
1734
+ Harnesses report it as epoch seconds or as their own text; a number is
1735
+ shown in local time, because the operator sets a timer by it, and
1736
+ anything else is shown as the provider gave it.
1737
+ """
1738
+ if isinstance(when, (int, float)) and not isinstance(when, bool):
1739
+ return time.strftime("%Y-%m-%d %H:%M %Z", time.localtime(when))
1740
+ return str(when)
1741
+
1742
+
1743
+ def next_step(value):
1744
+ """The one command to type next.
1745
+
1746
+ Every other room command ends by naming the one that follows it; status
1747
+ read the room and stopped, so a prepared room and a finished one ended
1748
+ the same way and neither said what to do. The branches mirror
1749
+ `run_until`'s stop rule and `continue_seat`'s refusals, so a reader is
1750
+ never sent to a command the room would refuse.
1751
+ """
1752
+ room, command = shown(value["room"]), "quirework sop room"
1753
+ held = value["held"]
1754
+ if held:
1755
+ number, waiting = held.get("round"), held.get("waiting_on") or []
1756
+ back = held.get("held_back") or []
1757
+ waited = [w for w in waiting if w not in (held.get("past") or [])]
1758
+ # Seats the operator chose not to wait for are filed absent by
1759
+ # promote; held-back seats are owed their turns first, and running
1760
+ # the round again is what takes them.
1761
+ close = (
1762
+ f"{command} round {room} {number} to take the held-back seats' "
1763
+ "turns and close it"
1764
+ if back
1765
+ else f"{command} promote {room} {number}"
1766
+ )
1767
+ if not waited:
1768
+ return close
1769
+ first = waited[0]
1770
+ more = f" (and {len(waited) - 1} more)" if len(waited) > 1 else ""
1771
+ return (
1772
+ f"{command} continue {room} {number} {first}{more} once the window "
1773
+ f"resets, then {close}"
1774
+ )
1775
+ published = value["published_rounds"]
1776
+ if len(published) < value["rounds"] and not value["converged"]:
1777
+ return f"{command} rounds {room}"
1778
+ last = int(published[-1][1:]) if published else 1
1779
+ if value["converged"] and len(published) < value["rounds"]:
1780
+ # `rounds` would stop again at once, so say both halves: the room
1781
+ # thinks it is done, and running past that is the operator's call.
1782
+ return (
1783
+ f"{command} board {room} {last}; the room converged, and "
1784
+ f"{command} rounds {room} runs past it"
1785
+ )
1786
+ return f"{command} board {room} {last}"
1787
+
1788
+
1789
+ def _spans(numbers):
1790
+ """[1, 2, 3, 5] -> "1-3, 5", because a Python list repr is not a notation."""
1791
+ spans = []
1792
+ for n in sorted(numbers):
1793
+ if spans and n == spans[-1][1] + 1:
1794
+ spans[-1][1] = n
1795
+ else:
1796
+ spans.append([n, n])
1797
+ return ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in spans)
1798
+
1799
+
1800
+ def _seat_summary(seat):
1801
+ """A seat's record across the room, not just how its last turn ended.
1802
+
1803
+ The seat's state keeps one `status`, overwritten every turn, so a seat
1804
+ that answered twenty rounds and failed the twenty-first used to read
1805
+ `failed`, which looks like a verdict on the seat.
1806
+ """
1807
+ rounds = seat["rounds"]
1808
+ expected = len(seat["acting_rounds"]) or len(rounds)
1809
+ plural = "" if expected == 1 else "s"
1810
+ if not rounds:
1811
+ return f"not started, {expected} round{plural} to speak in"
1812
+ done = sum(1 for v in rounds.values() if v.get("status") == "answered")
1813
+ trouble = [
1814
+ f"r{int(n):03d} {v.get('status')}"
1815
+ for n, v in sorted(rounds.items(), key=lambda i: int(i[0]))
1816
+ if v.get("status") != "answered"
1817
+ ]
1818
+ return f"answered {done} of {expected} round{plural}" + "".join(
1819
+ f", {t}" for t in trouble
1820
+ )
1821
+
1822
+
1823
+ def usage(root):
1824
+ """What each seat spent, per seat and per harness, from its own receipts.
1825
+
1826
+ A mixed room is the case that needs this. Every adapter records what its
1827
+ harness reports and nothing reconciles the three, so an operator asking
1828
+ what a room cost reads whichever seats they happened to parse. Claude is
1829
+ the only harness quoting a price, and a room that omits the other seats
1830
+ rather than saying their cost is unknown reports a cheaper room than it
1831
+ ran.
1832
+ """
1833
+ root, plan = load(root, verify=False)
1834
+ rows = []
1835
+ for seat in plan["seats"]:
1836
+ config = seat_config(plan, seat)
1837
+ totals = {k: 0 for k in ("input", "output", "reasoning", "cache_read")}
1838
+ cost, priced, rounds, inside = 0.0, 0, 0, False
1839
+ for receipt in sorted((root / "records" / seat["id"]).glob("r*/receipt.json")):
1840
+ counted = drivers.usage_totals(
1841
+ config["harness"], read(receipt).get("usage")
1842
+ )
1843
+ if not counted:
1844
+ continue
1845
+ rounds += 1
1846
+ for name in totals:
1847
+ totals[name] += counted[name]
1848
+ inside = inside or counted["cache_inside_input"]
1849
+ if counted["cost_usd"] is not None:
1850
+ cost, priced = cost + counted["cost_usd"], priced + 1
1851
+ rows.append(
1852
+ {
1853
+ "seat": seat["id"],
1854
+ "label": label(seat),
1855
+ "harness": config["harness"],
1856
+ "model": config["model"],
1857
+ "rounds": rounds,
1858
+ **totals,
1859
+ "cache_inside_input": inside,
1860
+ "cost_usd": cost if priced else None,
1861
+ }
1862
+ )
1863
+ return {"room": str(root), "title": plan.get("title"), "seats": rows}
1864
+
1865
+
1866
+ def _render_usage(value):
1867
+ lines = [value["title"], f" {shown(value['room'])}", ""]
1868
+ head = f" {'seat':<16}{'harness':<9}{'rounds':>7}{'output':>10}{'cache read':>12}"
1869
+ lines += [head + f"{'cost':>10}"]
1870
+ unknown = []
1871
+ for row in value["seats"]:
1872
+ price = f"${row['cost_usd']:.2f}" if row["cost_usd"] is not None else "unknown"
1873
+ if row["cost_usd"] is None:
1874
+ unknown.append(row["harness"])
1875
+ lines.append(
1876
+ f" {row['label']:<16}{row['harness']:<9}{row['rounds']:>7}"
1877
+ f"{row['output']:>10,}{row['cache_read']:>12,}{price:>10}"
1878
+ )
1879
+ known = sum(r["cost_usd"] or 0 for r in value["seats"])
1880
+ lines += ["", f" reported cost: ${known:.2f}"]
1881
+ if unknown:
1882
+ # Naming them is the point: a total missing two of four seats looks
1883
+ # exactly like a total of a cheaper room.
1884
+ lines.append(
1885
+ f" no price reported by {', '.join(sorted(set(unknown)))}; "
1886
+ "the room cost more than this"
1887
+ )
1888
+ if any(r["cache_inside_input"] for r in value["seats"]):
1889
+ lines.append(
1890
+ " codex counts cached tokens inside input; claude counts them beside it"
1891
+ )
1892
+ return "\n".join(lines)
1893
+
1894
+
1895
+ NOVELTY_SHOWN = 6
1896
+
1897
+
1898
+ def _render(value):
1899
+ published, allowed = value["published_rounds"], value["rounds"]
1900
+ rounds = f" rounds: {len(published)} of {allowed} published"
1901
+ if published:
1902
+ rounds += f" ({_spans(int(r[1:]) for r in published)})"
1903
+ if allowed != value["declared_rounds"]:
1904
+ rounds += f"; budget raised from the {value['declared_rounds']} declared"
1905
+ lines = [value["title"], f" {shown(value['room'])}", rounds]
1906
+ series = (value.get("convergence") or {}).get("series") or []
1907
+ if len(series) > 1:
1908
+ # Novelty is the room's own stop rule. Printing it only once it has
1909
+ # fired hides the approach, which is the part worth watching. A lone
1910
+ # round is left out: with nothing to differ from it is always 100%.
1911
+ shown_series = series[-NOVELTY_SHOWN:]
1912
+ measured = ", ".join(
1913
+ f"r{s['round']:03d} "
1914
+ + ("-" if s["novelty"] is None else f"{s['novelty']:.0f}%")
1915
+ for s in shown_series
1916
+ )
1917
+ signal = value["convergence"]
1918
+ fired = (
1919
+ f" (converged at round {signal['round']} on {signal['reason']})"
1920
+ if value["converged"]
1921
+ else ""
1922
+ )
1923
+ more = "..., " if len(series) > len(shown_series) else ""
1924
+ lines.append(f" novelty: {more}{measured}{fired}")
1925
+ if value.get("red_flags"):
1926
+ lines.append(f" red flags: {value['red_flags']} (marked ! below)")
1927
+ if value.get("held"):
1928
+ number = value["held"].get("round")
1929
+ waiting = value["held"].get("waiting_on") or []
1930
+ resets = sorted(
1931
+ {
1932
+ when
1933
+ for name in waiting
1934
+ for n, when in value["seats"]
1935
+ .get(name, {})
1936
+ .get("quota_resets_at", {})
1937
+ .items()
1938
+ if n == number
1939
+ }
1940
+ )
1941
+ lines.append(
1942
+ f" HELD: round {number} waiting on {', '.join(waiting)} "
1943
+ f"(provider quota{f'; resets {reset_text(resets[0])}' if resets else ''})"
1944
+ )
1945
+ back = value["held"].get("held_back") or []
1946
+ if back:
1947
+ lines.append(f" held back: {', '.join(back)}, not yet launched this round")
1948
+ lines.append("")
1949
+ width = max(len(v["label"]) for v in value["seats"].values())
1950
+ served = max(len(v["served"]) for v in value["seats"].values())
1951
+ for number, seat in enumerate(value["seats"].values(), 1):
1952
+ drafting = f", drafting {seat['drafting']}B" if seat["drafting"] else ""
1953
+ lines.append(
1954
+ f" {number}. {seat['label']:<{width}} {seat['served']:<{served}} "
1955
+ f"{_seat_summary(seat)}; posts: {len(seat['posts'])}{drafting}"
1956
+ )
1957
+ if (joined := seat.get("joined_late")) and joined > 1:
1958
+ lines.append(f" joined in round {joined}; it has no opening position")
1959
+ for n, flags in seat.get("red_flags", {}).items():
1960
+ for flag in flags:
1961
+ lines.append(f" ! r{n:03d}: {flag}")
1962
+ chair = value.get("chair") or {}
1963
+ if chair.get("queued"):
1964
+ lines += ["", f" chair note queued for {', '.join(chair['queued'])}"]
1965
+ if chair.get("delivered"):
1966
+ lines.append(f" chair spoke in {', '.join(chair['delivered'])}")
1967
+ if value.get("compacted"):
1968
+ where = ", ".join(
1969
+ f"{r['seat']} in round {r['round']}" for r in value["compacted"]
1970
+ )
1971
+ lines += [
1972
+ "",
1973
+ f" COMPACTED: {where}",
1974
+ (
1975
+ " a compacted round lost context it was resumed to keep;"
1976
+ " review before trusting it"
1977
+ ),
1978
+ ]
1979
+ if value["last_events"]:
1980
+ lines += ["", " recent:"]
1981
+ for row in value["last_events"]:
1982
+ when = time.strftime("%H:%M:%S", time.localtime(row["t"]))
1983
+ parts = [
1984
+ f"{k}={v}"
1985
+ for k, v in row.items()
1986
+ if k not in ("t", "event", "seat") and v not in (None, "")
1987
+ ]
1988
+ who = row.get("seat", "-")
1989
+ lines.append(
1990
+ f" {when} {who:<18} {row.get('event', '?'):<16} {' '.join(parts)}"
1991
+ )
1992
+ lines += ["", f"next: {value['next']}"]
1993
+ return "\n".join(lines)
1994
+
1995
+
1996
+ class Feed:
1997
+ """A room told as it runs, for the person following it.
1998
+
1999
+ Read-only, like `status`: no lock, no session, no writes, so following
2000
+ cannot perturb what it follows. The trail gives the room's own steps,
2001
+ each seat's stream what it does inside its turn, and a post is printed
2002
+ whole once its turn is collected: from the outbox while the round is
2003
+ open and from the board once promoted, so a round read back later shows
2004
+ the same words it showed live.
2005
+
2006
+ ``history`` is how much to tell before following: ``round`` from the
2007
+ latest round's opening, ``all`` from the room's preparation, and
2008
+ ``none`` nothing, which is what a running command's own log wants.
2009
+ """
2010
+
2011
+ def __init__(self, root, *, history="round", show=feed.FULL, width=None):
2012
+ self.root, self.plan = self.opened(root)
2013
+ self.history, self.show = history, show
2014
+ self.seats = {s["id"]: s for s in self.plan["seats"]}
2015
+ self.labels = self.names()
2016
+ self.page = feed.Page(width, who=max(map(len, self.labels.values()), default=0))
2017
+ self.trails = self.sources(at_end=history == "none")
2018
+ self.turns, self.exits = {}, {}
2019
+ self.first, self.stamp = True, None
2020
+
2021
+ def opened(self, root):
2022
+ return load(root, verify=False)
2023
+
2024
+ def names(self):
2025
+ return feed.short_names({name: label(s) for name, s in self.seats.items()})
2026
+
2027
+ def described(self, name):
2028
+ """A seat as the header lists it: its column name, then its label."""
2029
+ full = label(self.seats[name])
2030
+ return full if full == self.labels[name] else f"{self.labels[name]} {full}"
2031
+
2032
+ def sources(self, *, at_end):
2033
+ return [("event", feed.Tail(self.root / "room.log", at_end=at_end))]
2034
+
2035
+ def tails(self):
2036
+ return self.trails
2037
+
2038
+ def served(self, name):
2039
+ config = seat_config(self.plan, self.seats[name])
2040
+ return f"{config['harness']}/{drivers.model_text(config)}"
2041
+
2042
+ def header(self):
2043
+ lines = [
2044
+ f"following {self.plan.get('title') or self.root.name}",
2045
+ f" {shown(self.root)} (interrupt to stop)",
2046
+ ]
2047
+ for number, name in enumerate(self.seats, 1):
2048
+ lines.append(f" {number}. {self.described(name)} {self.served(name)}")
2049
+ return lines
2050
+
2051
+ def poll(self):
2052
+ catching_up, self.stamp = self.first, None if self.first else time.time()
2053
+ rows = sorted(
2054
+ (
2055
+ (0 if row is feed.UNREADABLE else row.get("t") or 0, kind, row)
2056
+ for kind, tail in self.tails()
2057
+ for row in tail.rows()
2058
+ ),
2059
+ key=lambda item: item[0],
2060
+ )
2061
+ if catching_up and self.history == "round":
2062
+ opened = [
2063
+ t
2064
+ for t, kind, row in rows
2065
+ if kind == "event"
2066
+ and row is not feed.UNREADABLE
2067
+ and row.get("event") == "round-opened"
2068
+ ]
2069
+ if opened:
2070
+ rows = [item for item in rows if item[0] >= opened[-1]]
2071
+ lines = []
2072
+ for follower in self.turns.values():
2073
+ lines += follower.poll(self.stamp)
2074
+ for _, kind, row in rows:
2075
+ lines += self.handle(kind, row)
2076
+ if catching_up:
2077
+ # A turn still running when following began shows what it has
2078
+ # done so far, then carries on live.
2079
+ for follower in self.turns.values():
2080
+ lines += follower.poll(None)
2081
+ self.first = False
2082
+ return lines
2083
+
2084
+ def handle(self, kind, row):
2085
+ if row is feed.UNREADABLE:
2086
+ return self.page.event(
2087
+ self.stamp, f"an unreadable line in the {kind} record"
2088
+ )
2089
+ name = (row.get("event") or "") if kind == "event" else kind
2090
+ handler = getattr(self, "on_" + name.replace("-", "_"), None)
2091
+ if handler is not None:
2092
+ return handler(row) or []
2093
+ if kind != "event":
2094
+ return []
2095
+ # A step this version does not know is still a step: shown as the
2096
+ # trail has it rather than dropped.
2097
+ rest = {k: v for k, v in row.items() if k not in ("t", "event")}
2098
+ return self.page.event(
2099
+ row.get("t"),
2100
+ f"{name or 'an unnamed step'} {json.dumps(rest, ensure_ascii=False)}",
2101
+ )
2102
+
2103
+ def who(self, row):
2104
+ return self.labels.get(row.get("seat"), row.get("seat") or "a seat")
2105
+
2106
+ def record(self, seat, n):
2107
+ return self.root / "records" / seat / f"r{n:03d}"
2108
+
2109
+ def follower(self, seat, n):
2110
+ config = seat_config(self.plan, self.seats[seat])
2111
+ work = self.root / "seats" / seat
2112
+ return feed.Turn(
2113
+ self.page,
2114
+ self.labels[seat],
2115
+ config["harness"],
2116
+ self.record(seat, n) / "events.jsonl",
2117
+ places=[(work, "", work)],
2118
+ private=blind.state_root(work) / config["harness"],
2119
+ show=self.show,
2120
+ )
2121
+
2122
+ def post(self, seat, n):
2123
+ """The post this turn left: in the outbox, or on the board once promoted."""
2124
+ for path in (
2125
+ self.root / "seats" / seat / "outbox" / "post.md",
2126
+ self.root / "board" / "posts" / seat / f"r{n:03d}.md",
2127
+ ):
2128
+ if path.is_file() and path.read_text().strip():
2129
+ return path.read_text()
2130
+ return None
2131
+
2132
+ def on_prepared(self, row):
2133
+ return self.page.event(
2134
+ row["t"],
2135
+ f"prepared: {len(row.get('seats') or [])} seats, {row.get('rounds')} rounds",
2136
+ )
2137
+
2138
+ def on_round_opened(self, row):
2139
+ n = row["round"]
2140
+ phase = phase_for(self.plan, n).get("name") if self.plan.get("phases") else None
2141
+ speaking = ", ".join(self.labels[s] for s in acting(self.plan, n))
2142
+ jobs = row.get("jobs") or 1
2143
+ pace = "one at a time" if jobs == 1 else f"{jobs} at a time"
2144
+ lines = self.page.event(
2145
+ row["t"],
2146
+ f"round {n} opened"
2147
+ + (f" ({phase})" if phase else "")
2148
+ + f" \u00b7 speaking: {speaking} \u00b7 {pace}",
2149
+ )
2150
+ note = chair_note(self.root, n)
2151
+ if note and self.show.words:
2152
+ lines += self.page.block(f"from the chair, before round {n}", note)
2153
+ return lines
2154
+
2155
+ def on_board_published(self, row):
2156
+ n = row["round"]
2157
+ return self.page.event(
2158
+ row["t"], f"every seat now has board/round-{n - 1:03d}/digest.md to read"
2159
+ )
2160
+
2161
+ def on_resting(self, row):
2162
+ return self.page.event(row["t"], f"{self.who(row)} listens this round")
2163
+
2164
+ def on_launching(self, row):
2165
+ seat, n = row["seat"], row["round"]
2166
+ self.turns[(seat, n)] = self.follower(seat, n)
2167
+ session = "resuming its session" if row.get("resumed") else "in a new session"
2168
+ return self.page.event(
2169
+ row["t"],
2170
+ f"{self.who(row)} [{self.served(seat)}] starts round {n}, {session}",
2171
+ )
2172
+
2173
+ def on_running(self, row):
2174
+ return []
2175
+
2176
+ def on_exited(self, row):
2177
+ follower = self.turns.pop((row.get("seat"), row.get("round")), None)
2178
+ self.exits[(row.get("seat"), row.get("round"))] = row
2179
+ return follower.finish(self.stamp) if follower else []
2180
+
2181
+ def on_compacted(self, row):
2182
+ return self.page.event(
2183
+ row["t"],
2184
+ f"{self.who(row)}'s session was compacted during the turn: a red flag, "
2185
+ "and `status` lists it",
2186
+ )
2187
+
2188
+ def on_collected(self, row):
2189
+ seat, n = row["seat"], row["round"]
2190
+ exited = self.exits.pop((seat, n), {})
2191
+ took = feed.duration(exited.get("seconds")) if "seconds" in exited else None
2192
+ timed = " (timed out)" if exited.get("timed_out") else ""
2193
+ status = row.get("status")
2194
+ if status == "answered":
2195
+ text = f"{self.who(row)} answered" + (f" in {took}" if took else "")
2196
+ elif status == "quota":
2197
+ text = f"{self.who(row)} stopped on a provider limit" + (
2198
+ f" after {took}" if took else ""
2199
+ )
2200
+ else:
2201
+ text = f"{self.who(row)}'s turn failed" + (f" after {took}" if took else "")
2202
+ text += timed + (f": {row['error']}" if row.get("error") else "")
2203
+ lines = self.page.event(row["t"], text)
2204
+ if status == "answered" and self.show.words:
2205
+ lines += self.posted(seat, n)
2206
+ return lines
2207
+
2208
+ def posted(self, seat, n):
2209
+ words = self.post(seat, n)
2210
+ if not words:
2211
+ return self.page.event(None, "no post")
2212
+ return self.page.block(f"{self.labels[seat]}, round {n}", words)
2213
+
2214
+ def on_stopped(self, row):
2215
+ return self.page.event(row["t"], f"{self.who(row)} stopped: {row.get('error')}")
2216
+
2217
+ def on_continuing(self, row):
2218
+ how = "in the same session" if row.get("resumed") else "in a fresh session"
2219
+ attempt = f" (attempt {row['attempt']})" if row.get("attempt") else ""
2220
+ return self.page.event(
2221
+ row["t"],
2222
+ f"{self.who(row)} takes round {row['round']} again after a provider "
2223
+ f"limit, {how}{attempt}",
2224
+ )
2225
+
2226
+ def on_absent(self, row):
2227
+ return self.page.event(row["t"], f"{self.who(row)} left no post this round")
2228
+
2229
+ def on_promoted(self, row):
2230
+ return []
2231
+
2232
+ def on_made(self, row):
2233
+ return self.page.event(
2234
+ row["t"],
2235
+ f"{self.who(row)}'s {row.get('file')} is on the board "
2236
+ f"({feed.size(row.get('bytes') or 0)})",
2237
+ )
2238
+
2239
+ def on_revised(self, row):
2240
+ return self.page.event(
2241
+ row["t"], f"{self.who(row)} revised {row.get('file')}, now on the board"
2242
+ )
2243
+
2244
+ def on_unmade(self, row):
2245
+ return self.page.event(
2246
+ row["t"],
2247
+ f"{self.who(row)} did not write {row.get('file')}, which this phase asks for",
2248
+ )
2249
+
2250
+ def on_round_closed(self, row):
2251
+ posted = row.get("posted") or 0
2252
+ absent = f", {row['absent']} absent" if row.get("absent") else ""
2253
+ return self.page.event(
2254
+ row["t"],
2255
+ f"round {row['round']} closed: {posted} post{'' if posted == 1 else 's'}"
2256
+ f"{absent}",
2257
+ )
2258
+
2259
+ def on_convergence(self, row):
2260
+ text = f"novelty {row.get('novelty')}%, closing {row.get('closing')}"
2261
+ if row.get("converged"):
2262
+ text += f"; converged on {row.get('reason')}"
2263
+ if self.plan.get("phases"):
2264
+ text += ", which a phased room notes and does not stop for"
2265
+ return self.page.event(row["t"], text)
2266
+
2267
+ def on_round_held(self, row):
2268
+ waiting = ", ".join(self.labels.get(s, s) for s in row.get("waiting_on") or [])
2269
+ back = ", ".join(self.labels.get(s, s) for s in row.get("held_back") or [])
2270
+ held = f"; held back, not launched: {back}" if back else ""
2271
+ return self.page.event(
2272
+ row["t"],
2273
+ f"round {row['round']} held on a provider limit, waiting on {waiting}"
2274
+ f"{held}; `status` names the command that continues it",
2275
+ )
2276
+
2277
+ def on_round_resumed(self, row):
2278
+ return self.page.event(
2279
+ row["t"], f"round {row['round']} resumed after a provider limit"
2280
+ )
2281
+
2282
+ def on_held_back(self, row):
2283
+ return self.page.event(
2284
+ row["t"],
2285
+ f"{self.who(row)} held back: a seat before it stopped on a provider limit",
2286
+ )
2287
+
2288
+ def on_budget_extended(self, row):
2289
+ return self.page.event(
2290
+ row["t"],
2291
+ f"the room may now run {row.get('rounds')} rounds (was {row.get('was')})",
2292
+ )
2293
+
2294
+
2295
+ def narrating(root):
2296
+ """A turn-taking command's own progress, on stderr, while it runs.
2297
+
2298
+ Brief, for the log an assistant reads: the round's steps and the files
2299
+ each seat writes, and none of the posts, which `watch` shows.
2300
+ """
2301
+ return feed.narrating(Feed(root, history="none", show=feed.BRIEF))
2302
+
2303
+
2304
+ def watch(root, *, history="round", thinking=False, poll=1.0, width=None):
2305
+ """Follow a room in plain lines until interrupted. Read-only, like `status`."""
2306
+ following = Feed(
2307
+ room_root(root), history=history, show=feed.Show(thinking=thinking), width=width
2308
+ )
2309
+ print("\n".join(following.header()), file=sys.stderr)
2310
+ feed.watch(following, poll=poll)
2311
+
2312
+
2313
+ def seat_named(plan, value):
2314
+ """A seat by its id, or by its position in the room.
2315
+
2316
+ Position exists because following a room means four terminals and four
2317
+ commands, and `follow room 2` is easier to type and to keep straight than
2318
+ a persona's full slug. An id that happens to be a number wins over the
2319
+ position, because a seat's own name is never a guess.
2320
+ """
2321
+ names = [s["id"] for s in plan["seats"]]
2322
+ text = str(value)
2323
+ if text in names:
2324
+ return text
2325
+ if text.isdigit() and 1 <= int(text) <= len(names):
2326
+ return names[int(text) - 1]
2327
+ listed = ", ".join(f"{i}. {n}" for i, n in enumerate(names, 1))
2328
+ raise ValueError(f"no seat {value!r} here; this room seats {listed}")
2329
+
2330
+
2331
+ def transcripts(root, plan, seat):
2332
+ """Every native transcript this seat has, oldest first.
2333
+
2334
+ Not one file: codex opens a fresh rollout each time a session is resumed,
2335
+ so a seat ten rounds in has ten of them and the conversation is their
2336
+ concatenation. Claude keeps one per session but also writes subagent
2337
+ files beside it, which are not this seat talking.
2338
+ """
2339
+ record = next(s for s in plan["seats"] if s["id"] == seat)
2340
+ harness = seat_config(plan, record)["harness"]
2341
+ pattern = observe.NATIVE_TRANSCRIPTS.get(harness)
2342
+ if pattern is None:
2343
+ raise ValueError(f"no known transcript location for {harness}")
2344
+ private = blind.state_root(root / "seats" / seat) / harness
2345
+ found = [p for p in private.glob(pattern) if "subagents" not in p.parts]
2346
+ return sorted(found, key=lambda p: (p.stat().st_mtime, p.name))
2347
+
2348
+
2349
+ def _agy_lines(row, *, thinking, width):
2350
+ """One agy transcript row as the lines a reader wants, or none at all.
2351
+
2352
+ Tool *results* are dropped. A single file read returns two kilobytes of
2353
+ quoted source, and four of those bury the one thing the reader came for,
2354
+ which is what the seat said and what it decided to do next.
2355
+ """
2356
+ kind, status = row.get("type"), row.get("status")
2357
+ content = (row.get("content") or "").strip()
2358
+ if kind == "USER_INPUT":
2359
+ # The harness appends its own blocks -- a clock reading, a note that
2360
+ # the model was selected -- to every prompt. They are its bookkeeping,
2361
+ # not the turn the room sent, and printing them buries the turn.
2362
+ text = re.sub(
2363
+ r"<(ADDITIONAL_METADATA|USER_SETTINGS_CHANGE)>.*?</\1>",
2364
+ "",
2365
+ content,
2366
+ flags=re.DOTALL,
2367
+ )
2368
+ text = re.sub(r"</?USER_REQUEST>", "", text).strip()
2369
+ wrapped = textwrap.wrap(" ".join(text.split()), width - 4)
2370
+ shown = wrapped[:8] + (["..."] if len(wrapped) > 8 else [])
2371
+ return ["", "=" * width, *(f" {line}" for line in shown), "=" * width, ""]
2372
+ if status == "ERROR":
2373
+ # The harness prefixes every result with its own timestamps. Printing
2374
+ # those instead of the message is how an error line ends up saying
2375
+ # nothing at all about what went wrong.
2376
+ detail = " ".join(
2377
+ line
2378
+ for line in content.splitlines()
2379
+ if not line.startswith(("Created At:", "Completed At:"))
2380
+ )
2381
+ # Errors wrap rather than truncate: a cut-off error names the tool
2382
+ # that failed and never the reason, which is the half nobody needs.
2383
+ lines = textwrap.wrap(" ".join(detail.split()), width - 5)
2384
+ return [f" ! {line}" for line in lines]
2385
+ if kind != "PLANNER_RESPONSE":
2386
+ return []
2387
+ out = []
2388
+ if thinking and row.get("thinking"):
2389
+ for line in textwrap.wrap(" ".join(row["thinking"].split()), width - 5):
2390
+ out.append(f" · {line}")
2391
+ for call in row.get("tool_calls") or []:
2392
+ args = call.get("args") or {}
2393
+ # agy's newer file tools carry no summary of their own.
2394
+ what = (
2395
+ args.get("toolSummary")
2396
+ or args.get("toolAction")
2397
+ or feed.agy_names(call.get("name") or "a tool", args)
2398
+ )
2399
+ out.append(f" \u2192 {what}")
2400
+ if content:
2401
+ out.append("")
2402
+ for paragraph in content.splitlines():
2403
+ out.extend(textwrap.wrap(paragraph, width) or [""])
2404
+ out.append("")
2405
+ return out
2406
+
2407
+
2408
+ def _banner(text, width):
2409
+ """A turn the room sent, framed so round boundaries are visible."""
2410
+ wrapped = textwrap.wrap(" ".join(text.split()), width - 4)
2411
+ shown = wrapped[:8] + (["..."] if len(wrapped) > 8 else [])
2412
+ return ["", "=" * width, *(f" {line}" for line in shown), "=" * width, ""]
2413
+
2414
+
2415
+ def _said(text, width):
2416
+ out = [""]
2417
+ for paragraph in text.strip().splitlines():
2418
+ out.extend(textwrap.wrap(paragraph, width) or [""])
2419
+ return out + [""]
2420
+
2421
+
2422
+ def _thought(text, width):
2423
+ return [f" · {line}" for line in textwrap.wrap(" ".join(text.split()), width - 5)]
2424
+
2425
+
2426
+ def _claude_lines(row, *, thinking, width):
2427
+ """One Claude Code transcript row. Tool results are dropped, as for agy."""
2428
+ kind = row.get("type")
2429
+ if kind not in ("user", "assistant"):
2430
+ return []
2431
+ content = (row.get("message") or {}).get("content")
2432
+ if isinstance(content, str):
2433
+ return _banner(content, width) if kind == "user" else _said(content, width)
2434
+ out = []
2435
+ for block in content or []:
2436
+ shape = block.get("type")
2437
+ if shape == "text":
2438
+ out.extend(
2439
+ _banner(block["text"], width)
2440
+ if kind == "user"
2441
+ else _said(block["text"], width)
2442
+ )
2443
+ elif shape == "thinking" and thinking and block.get("thinking", "").strip():
2444
+ out.extend(_thought(block["thinking"], width))
2445
+ elif shape == "tool_use":
2446
+ target = (block.get("input") or {}).get("file_path", "")
2447
+ name = block.get("name", "tool")
2448
+ out.append(
2449
+ f" \u2192 {name} {Path(target).name if target else ''}".rstrip()
2450
+ )
2451
+ return out
2452
+
2453
+
2454
+ def _codex_cmd(text):
2455
+ """The shell command inside a codex exec call, for a one-line summary."""
2456
+ found = re.search(r'cmd:\s*"((?:[^"\\]|\\.)*)"', text or "")
2457
+ return found.group(1) if found else (text or "")[:60]
2458
+
2459
+
2460
+ def _codex_lines(row, *, thinking, width):
2461
+ """One codex rollout row, minus the harness's own developer preamble."""
2462
+ if row.get("type") != "response_item":
2463
+ return []
2464
+ payload = row.get("payload") or {}
2465
+ shape = payload.get("type")
2466
+ if shape == "message":
2467
+ role = payload.get("role")
2468
+ # The CLI injects its own preamble -- skills, plugins, recommendations
2469
+ # -- partly as `developer` and partly as `user`, so the role alone does
2470
+ # not separate it from the turn the room sent. Every such block opens
2471
+ # with a tag and a room prompt never does.
2472
+ if role == "developer":
2473
+ return []
2474
+ text = " ".join(
2475
+ part.get("text", "") for part in payload.get("content") or []
2476
+ ).strip()
2477
+ if not text or text.startswith("<"):
2478
+ return []
2479
+ return _banner(text, width) if role == "user" else _said(text, width)
2480
+ if shape == "reasoning" and thinking:
2481
+ summary = " ".join(
2482
+ part.get("text", "") for part in payload.get("summary") or []
2483
+ ).strip()
2484
+ return _thought(summary, width) if summary else []
2485
+ if shape == "custom_tool_call":
2486
+ return [f" \u2192 {_codex_cmd(payload.get('input'))[: width - 5]}"]
2487
+ return []
2488
+
2489
+
2490
+ _RENDERERS = {"agy": _agy_lines, "claude": _claude_lines, "codex": _codex_lines}
2491
+
2492
+ # Whether a transcript row is finished and safe to print. Only one harness
2493
+ # revises a row in place: agy writes a step, then marks it DONE. Claude and
2494
+ # codex append rows already complete, so waiting for a status they never carry
2495
+ # would print nothing at all -- which is exactly what it did.
2496
+ _SETTLED = {
2497
+ "agy": lambda row: row.get("status") in ("DONE", "ERROR"),
2498
+ "claude": lambda row: True,
2499
+ "codex": lambda row: True,
2500
+ }
2501
+
2502
+
2503
+ def follow(root, seat, *, thinking=True, poll=1.0, width=88):
2504
+ """Print one seat's side of the room as it happens.
2505
+
2506
+ Read-only, like `status` and `watch`: no lock, no session, no writes. Rows
2507
+ are consumed by position and only once they carry a terminal status, so a
2508
+ step still being written is waited for rather than printed half-formed,
2509
+ and a harness that rewrites its transcript instead of appending to it
2510
+ cannot produce duplicates.
2511
+ """
2512
+ root = room_root(root)
2513
+ _, plan = load(root, verify=False)
2514
+ seat = seat_named(plan, seat)
2515
+ record = next(s for s in plan["seats"] if s["id"] == seat)
2516
+ harness = seat_config(plan, record)["harness"]
2517
+ render = _RENDERERS.get(harness)
2518
+ if render is None:
2519
+ raise ValueError(f"following a {harness} seat is not implemented yet")
2520
+ settled = _SETTLED[harness]
2521
+ # Which seat and which model, because a live view is where an operator is
2522
+ # most likely to be asking who is speaking.
2523
+ config = seat_config(plan, record)
2524
+ who = f"{label(record)} [{config['harness']}/{config.get('model')}]"
2525
+ seen, waiting, opened = 0, False, False
2526
+ while True:
2527
+ paths = transcripts(root, plan, seat)
2528
+ if not paths:
2529
+ if not waiting:
2530
+ print(f"{who}: waiting for a session to open", file=sys.stderr)
2531
+ waiting = True
2532
+ time.sleep(poll)
2533
+ continue
2534
+ if not opened:
2535
+ print(f"--- {who} ({seat}, {harness}) ---", file=sys.stderr)
2536
+ opened = True
2537
+ rows = []
2538
+ for path in paths:
2539
+ for line in path.read_text(errors="replace").splitlines():
2540
+ if not line.strip():
2541
+ continue
2542
+ try:
2543
+ rows.append(json.loads(line))
2544
+ except json.JSONDecodeError:
2545
+ break # the writer is mid-row; pick it up next poll
2546
+ while seen < len(rows) and settled(rows[seen]):
2547
+ for out in render(rows[seen], thinking=thinking, width=width):
2548
+ print(out, flush=True)
2549
+ seen += 1
2550
+ time.sleep(poll)
2551
+
2552
+
2553
+ STATUS_HELP = """\
2554
+ Read a room without disturbing it: no lock, no session, no writes, so it
2555
+ is safe while a round runs. A worked example, line by line:
2556
+
2557
+ Lighthouse proposal the room's title
2558
+ ~/rooms/lighthouse where it lives
2559
+ rounds: 3 of 6 published (1-3) published rounds against the budget
2560
+ novelty: r001 100%, r002 71%, r003 58% the stop rule, measured per round
2561
+ red flags: 1 (marked ! below) count of the ! lines further down
2562
+ HELD: round 4 waiting on bo-larke (provider quota; resets 14:20Z)
2563
+
2564
+ 1. Ana Pell claude/opus answered 3 of 3 rounds; posts: 3
2565
+ 2. Bo Larke codex/gpt-5.6-terra (from terra) answered 3 of 4 rounds, r004 quota; posts: 3
2566
+ ! r002: Session bus proxied into the jail for ...
2567
+
2568
+ next: quirework sop room continue ~/rooms/lighthouse 4 bo-larke once ...
2569
+
2570
+ Novelty is the share of words a seat has not used before; two quiet rounds
2571
+ stop an unphased room, and a lone round is not shown because it is always
2572
+ 100%. A seat counts only the rounds its phase lets it speak in, so a seat
2573
+ resting through a drafting phase is not behind. A red flag is a fact about
2574
+ how a turn ran (a door the jail had to open, a compaction), not a judgment
2575
+ of what the seat wrote. The last line names the command to run next.
2576
+ """
2577
+
2578
+
2579
+ def main(argv=None):
2580
+ parser = argparse.ArgumentParser(prog="quirework sop room", description=__doc__)
2581
+ sub = parser.add_subparsers(dest="command", required=True)
2582
+
2583
+ made = sub.add_parser("prepare", help="stage a room from a plan")
2584
+ made.add_argument("plan")
2585
+ made.add_argument("destination")
2586
+
2587
+ turn = sub.add_parser(
2588
+ "round", help="open, run and close one round, or resume a held one"
2589
+ )
2590
+ turn.add_argument("root")
2591
+ turn.add_argument("number", type=int)
2592
+ turn.add_argument("--jobs", type=int, default=1)
2593
+ turn.add_argument("--timeout", type=float, default=None)
2594
+ turn.add_argument(
2595
+ "--past",
2596
+ action="append",
2597
+ default=[],
2598
+ metavar="SEAT",
2599
+ help="a seat stopped on a provider limit that the round should not "
2600
+ "wait for; promote then files it absent (repeatable)",
2601
+ )
2602
+
2603
+ for name, help_text in (
2604
+ ("open", "republish a round's board"),
2605
+ ("promote", "close a round from its outboxes"),
2606
+ ):
2607
+ recover = sub.add_parser(name, help=help_text)
2608
+ recover.add_argument("root")
2609
+ recover.add_argument("number", type=int)
2610
+
2611
+ again = sub.add_parser(
2612
+ "continue", help="take a seat's turn again after a provider quota stop"
2613
+ )
2614
+ again.add_argument("root")
2615
+ again.add_argument("number", type=int)
2616
+ again.add_argument("seat")
2617
+ again.add_argument("--timeout", type=float, default=None)
2618
+
2619
+ loop = sub.add_parser("rounds", help="run rounds until the room converges")
2620
+ loop.add_argument("root")
2621
+ loop.add_argument("--to", type=int, default=None, help="stop at this round")
2622
+ loop.add_argument("--jobs", type=int, default=1)
2623
+ loop.add_argument("--timeout", type=float, default=None)
2624
+
2625
+ more = sub.add_parser("extend", help="raise the room's round budget")
2626
+ more.add_argument("root")
2627
+ more.add_argument("rounds", type=int)
2628
+
2629
+ view = sub.add_parser(
2630
+ "status",
2631
+ help="read a room without disturbing it",
2632
+ description=STATUS_HELP,
2633
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2634
+ )
2635
+ view.add_argument("root")
2636
+ view.add_argument("--json", action="store_true")
2637
+
2638
+ board = sub.add_parser("board", help="print a published round's digest")
2639
+ board.add_argument("root")
2640
+ board.add_argument("number", type=int)
2641
+
2642
+ spend = sub.add_parser("usage", help="what each seat spent, from its receipts")
2643
+ spend.add_argument("root")
2644
+ spend.add_argument("--json", action="store_true")
2645
+
2646
+ trail = sub.add_parser(
2647
+ "watch", help="follow the room as it runs: turns, files and posts"
2648
+ )
2649
+ trail.add_argument("root")
2650
+ trail.add_argument(
2651
+ "--all",
2652
+ action="store_true",
2653
+ help="tell the whole room, not only its latest round",
2654
+ )
2655
+ trail.add_argument(
2656
+ "--thinking", action="store_true", help="show the seats' reasoning"
2657
+ )
2658
+ trail.add_argument("--poll", type=float, default=1.0)
2659
+ trail.add_argument("--width", type=int, default=None)
2660
+
2661
+ voice = sub.add_parser("follow", help="follow one seat's side of the room")
2662
+ voice.add_argument("root")
2663
+ voice.add_argument("seat")
2664
+ voice.add_argument("--poll", type=float, default=1.0)
2665
+ voice.add_argument("--width", type=int, default=88)
2666
+ voice.add_argument(
2667
+ "--no-thinking", action="store_true", help="show only actions and posts"
2668
+ )
2669
+
2670
+ args = parser.parse_args(argv)
2671
+ # Every command resolves its room the same way, at the operation boundary
2672
+ # and before any work starts, so a mistyped name fails identically on all
2673
+ # of them instead of failing late or, in watch's case, not at all.
2674
+ if args.command == "prepare":
2675
+ destination = room_root(args.destination, must_exist=False)
2676
+ value = prepare(args.plan, destination)
2677
+ print(f"prepared {len(value['seats'])} seats in {shown(destination)}")
2678
+ if not spelled_as_path(args.destination):
2679
+ # The destination is printed either way, but a state-root path is
2680
+ # long, unfamiliar and easy to read past. An operator who meant a
2681
+ # directory here finds out now rather than at the next command.
2682
+ print(
2683
+ f"{args.destination!r} names a room in this project's store; "
2684
+ f"write ./{args.destination} for a room in the current "
2685
+ "directory"
2686
+ )
2687
+ return 0
2688
+ root = room_root(args.root)
2689
+ if args.command == "round":
2690
+ with narrating(root):
2691
+ played = run_round(
2692
+ root,
2693
+ args.number,
2694
+ jobs=args.jobs,
2695
+ timeout=args.timeout,
2696
+ past=args.past,
2697
+ )
2698
+ for name, outcome in played.items():
2699
+ detail = json.dumps(outcome) if isinstance(outcome, dict) else outcome
2700
+ print(f"{name}: {detail}")
2701
+ if played["_board"].get("held"):
2702
+ print(f"next: {next_step(status(root))}")
2703
+ elif args.command == "continue":
2704
+ # Not `status`: that name is a function in this module, and binding it
2705
+ # here makes it local to `main` for the whole body, so every other
2706
+ # command's call to it raises UnboundLocalError.
2707
+ with narrating(root):
2708
+ outcome = continue_seat(root, args.number, args.seat, timeout=args.timeout)
2709
+ print(f"{args.seat}: {outcome}")
2710
+ if outcome == "answered":
2711
+ if held_back(root, args.number):
2712
+ print(
2713
+ "now take the held-back seats' turns and close the round: "
2714
+ f"quirework sop room round {shown(root)} {args.number}"
2715
+ )
2716
+ else:
2717
+ print(
2718
+ "now close the round: quirework sop room promote "
2719
+ f"{shown(root)} {args.number}"
2720
+ )
2721
+ elif args.command == "open":
2722
+ print(open_round(root, args.number) or "silent round; no board")
2723
+ elif args.command == "rounds":
2724
+ with narrating(root):
2725
+ played, why = run_until(
2726
+ root, limit=args.to, jobs=args.jobs, timeout=args.timeout
2727
+ )
2728
+ for number, outcome in played:
2729
+ held = outcome["_board"].get("held")
2730
+ if held:
2731
+ back = outcome["_board"].get("held_back") or []
2732
+ close = (
2733
+ f"round {number} again to take {', '.join(back)}'s turns"
2734
+ if back
2735
+ else f"promote {number}"
2736
+ )
2737
+ print(
2738
+ f"round {number}: held, waiting on {', '.join(held)}; "
2739
+ f"continue each with: quirework sop room continue "
2740
+ f"{shown(root)} {number} SEAT, then {close}"
2741
+ )
2742
+ continue
2743
+ signal = outcome["_board"]["convergence"]
2744
+ print(
2745
+ f"round {number}: {len(outcome['_board']['posted'])} posted, "
2746
+ f"novelty {signal['novelty']}%, closing {signal['closing']}"
2747
+ )
2748
+ print(f"stopped: {why}")
2749
+ elif args.command == "extend":
2750
+ print(f"this room may now run {extend(root, args.rounds)} rounds")
2751
+ elif args.command == "promote":
2752
+ print(json.dumps(promote(root, args.number), indent=2))
2753
+ elif args.command == "status":
2754
+ value = status(root)
2755
+ print(json.dumps(value, indent=2) if args.json else _render(value))
2756
+ elif args.command == "usage":
2757
+ value = usage(root)
2758
+ print(json.dumps(value, indent=2) if args.json else _render_usage(value))
2759
+ elif args.command == "board":
2760
+ digest_file = root / "board" / "rounds" / f"r{args.number:03d}" / "digest.md"
2761
+ if not digest_file.is_file():
2762
+ raise ValueError(f"round {args.number} has not been published in {root}")
2763
+ _, plan = load(root, verify=False)
2764
+ print(attributed(digest_file.read_text(), plan))
2765
+ elif args.command == "watch":
2766
+ try:
2767
+ watch(
2768
+ root,
2769
+ history="all" if args.all else "round",
2770
+ thinking=args.thinking,
2771
+ poll=args.poll,
2772
+ width=args.width,
2773
+ )
2774
+ except KeyboardInterrupt:
2775
+ return 0
2776
+ elif args.command == "follow":
2777
+ try:
2778
+ follow(
2779
+ root,
2780
+ args.seat,
2781
+ thinking=not args.no_thinking,
2782
+ poll=args.poll,
2783
+ width=args.width,
2784
+ )
2785
+ except KeyboardInterrupt:
2786
+ return 0
2787
+ return 0
2788
+
2789
+
2790
+ if __name__ == "__main__":
2791
+ sys.exit(main())