quirework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. quirework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1284 @@
1
+ """Harness adapters for the prose workshop; procedure: docs/workflows.md.
2
+
3
+ Adapters transport a file-based assignment, not a fixed writing instrument.
4
+ Credentials are used at launch and never serialized into experiment records.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from quirework.project import current_project
18
+ from quirework.providers import blind, claude_login, quota, replies
19
+
20
+ HARNESSES = ("codex", "claude", "agy")
21
+
22
+
23
+ def read_json(path):
24
+ return json.loads(Path(path).read_text())
25
+
26
+
27
+ def json_rows(path):
28
+ """Parse complete records. A truncated last record is a visible failure."""
29
+ return [
30
+ json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()
31
+ ]
32
+
33
+
34
+ # A plan names a model family (`opus`, `terra`, `gemini-pro`) and preparation
35
+ # picks the newest member the harness's own catalog lists, so a release needs
36
+ # no change here and a plan written today picks up next month's model. The
37
+ # resolved id is frozen with the family recorded beside it; a name with a
38
+ # version number in it pins instead. Keep no list of model versions in this
39
+ # module: where a harness has no catalog, as Claude Code has none, its own
40
+ # alias passes through and the receipt verifies what was served.
41
+ CLAUDE_ALIASES = ("fable", "opus", "sonnet", "haiku")
42
+ AGY_EFFORTS = ("low", "medium", "high")
43
+
44
+
45
+ def _tokens(name):
46
+ return [t for t in re.split(r"[-_\s]+", str(name).strip().lower()) if t]
47
+
48
+
49
+ def _words(name):
50
+ """The family words of a model name: every token that is not a version."""
51
+ return [t for t in _tokens(name) if not t[0].isdigit()]
52
+
53
+
54
+ def _version(name):
55
+ return tuple(
56
+ int(n) for t in _tokens(name) if t[0].isdigit() for n in re.findall(r"\d+", t)
57
+ )
58
+
59
+
60
+ def is_family(name):
61
+ """Whether a model name names a family rather than a version: no digit."""
62
+ return bool(_tokens(name)) and not re.search(r"\d", str(name))
63
+
64
+
65
+ def families(slugs):
66
+ """The family names a catalog offers, for a refusal to list."""
67
+ return sorted({"-".join(_words(s)) for s in slugs if _words(s)})
68
+
69
+
70
+ def newest_in_family(family, slugs):
71
+ """The newest catalog slug in ``family``, or None when nothing matches.
72
+
73
+ A slug is in the family when every family word is one of its words.
74
+ Among those, the members with the fewest words win, so ``terra`` means
75
+ ``gpt-5.6-terra`` and not a later ``gpt-6-terra-mini``. Two different
76
+ word sets left tied are ambiguous, as ``gemini`` is between Flash and
77
+ Pro, and the refusal names both. Inside the family the highest version
78
+ wins, compared as numbers, so 5.10 is newer than 5.4.
79
+ """
80
+ wanted = set(_words(family))
81
+ members = [s for s in slugs if wanted <= set(_words(s))]
82
+ if not members:
83
+ return None
84
+ fewest = min(len(_words(s)) for s in members)
85
+ members = [s for s in members if len(_words(s)) == fewest]
86
+ shapes = families(members)
87
+ if len(shapes) > 1:
88
+ raise ValueError(
89
+ f"model {family!r} names more than one family ({', '.join(shapes)}); "
90
+ "name one of them"
91
+ )
92
+ return max(members, key=lambda s: (_version(s), s))
93
+
94
+
95
+ def model_text(config):
96
+ """A seat's model as status prints it: the id, and the family it came from."""
97
+ asked = config.get("requested_model")
98
+ model = config.get("model")
99
+ if asked and model and str(asked).strip().lower() != model:
100
+ return f"{model} (from {asked})"
101
+ return model
102
+
103
+
104
+ def resolve_claude(model):
105
+ """What a person calls a Claude model, as Claude Code accepts it.
106
+
107
+ ``opus-5.5`` is what a person says and ``claude-opus-5-5`` is what the
108
+ CLI takes: Claude ids spell versions with dashes, never dots. A family
109
+ alias passes through, because the CLI resolves it to its latest model
110
+ and the receipt records which one answered. Anything that is neither
111
+ is refused here rather than at launch, where it would come back as
112
+ ``unrecognized_model`` after the jail was already built.
113
+ """
114
+ text = str(model).strip().lower()
115
+ if not text:
116
+ raise ValueError("claude model must not be empty")
117
+ bare = text.removeprefix("claude-").replace(".", "-")
118
+ if bare in CLAUDE_ALIASES:
119
+ return bare
120
+ if not is_family(bare) and (
121
+ text.startswith("claude-") or bare.split("-")[0] in CLAUDE_ALIASES
122
+ ):
123
+ return f"claude-{bare}"
124
+ raise ValueError(
125
+ f"claude model {model!r} is not a name Claude Code accepts. Use a family "
126
+ f"alias ({', '.join(CLAUDE_ALIASES)}), which the CLI resolves to its "
127
+ "latest model, or a version such as opus-5.5 or claude-opus-5-5."
128
+ )
129
+
130
+
131
+ def harness_for(model):
132
+ """The harness a model name belongs to when the operator names none.
133
+
134
+ Only Claude names can be recognized without a catalog: its family
135
+ aliases and its ``claude-`` ids. Everything else goes to Codex, whose
136
+ catalog then resolves the family or refuses it and lists the families
137
+ it offers. Antigravity is never inferred, because its effort is part of
138
+ the model id and has to be chosen rather than defaulted.
139
+ """
140
+ text = str(model).strip().lower()
141
+ bare = text.removeprefix("claude-").replace(".", "-")
142
+ if text.startswith("claude-") or bare.split("-")[0] in CLAUDE_ALIASES:
143
+ return "claude"
144
+ return "codex"
145
+
146
+
147
+ CODEX_CACHE = ".codex/models_cache.json"
148
+
149
+
150
+ def _codex_live():
151
+ """``(models, None)`` from ``codex debug models``, or ``(None, why)``."""
152
+ try:
153
+ p = subprocess.run(
154
+ ["codex", "debug", "models"],
155
+ capture_output=True,
156
+ text=True,
157
+ timeout=90,
158
+ check=False,
159
+ )
160
+ except (OSError, subprocess.TimeoutExpired) as exc:
161
+ return None, str(exc)
162
+ if p.returncode:
163
+ return None, (p.stderr.strip() or f"exit status {p.returncode}")[-300:]
164
+ try:
165
+ return json.loads(p.stdout).get("models", []), None
166
+ except (json.JSONDecodeError, AttributeError) as exc:
167
+ return None, f"unreadable output: {exc}"
168
+
169
+
170
+ def codex_catalog():
171
+ """Codex's model list, and a line saying where it was read.
172
+
173
+ A catalog the project configures as ``providers.codex.catalog`` is used
174
+ as given: that is a pinned choice. Otherwise Codex is asked, because the
175
+ file it keeps at ``~/.codex/models_cache.json`` is written only by a
176
+ real Codex session and can trail the provider by days. Resolved against
177
+ that file, an exact slug the provider already served was refused, and
178
+ its family resolved to the previous generation without a word: a model
179
+ substitution at launch. ``codex debug models`` refreshes when its copy
180
+ is stale and prints the result, though it does not write the file back.
181
+ The file is the fallback when Codex cannot be asked, and the source line
182
+ then says how old it is, because an old catalog is how a newer model
183
+ goes unseen.
184
+ """
185
+ project = current_project()
186
+ if project.get("providers.codex.catalog") is not None:
187
+ path = project.path("providers.codex.catalog")
188
+ if not path.exists():
189
+ raise ValueError(f"the configured Codex catalog {path} does not exist")
190
+ return read_json(path).get("models", []), f"{path} (configured)"
191
+ models, failure = _codex_live()
192
+ if models is not None:
193
+ return models, "codex debug models"
194
+ cache = Path.home() / CODEX_CACHE
195
+ if not cache.exists():
196
+ raise ValueError(
197
+ f"codex debug models failed ({failure}) and there is no cached "
198
+ f"catalog at {cache}; check that Codex is logged in with "
199
+ "`codex login status`"
200
+ )
201
+ data = read_json(cache)
202
+ source = (
203
+ f"{cache}, fetched {data.get('fetched_at') or 'at an unrecorded time'}, "
204
+ f"because codex debug models failed: {failure}"
205
+ )
206
+ print(f"models: resolving against {source}", file=sys.stderr)
207
+ return data.get("models", []), source
208
+
209
+
210
+ def resolve(harness, requested, effort, resolved_model=None, declared=None):
211
+ if harness not in HARNESSES:
212
+ raise ValueError(f"unknown harness: {harness}")
213
+ if not shutil.which(harness):
214
+ raise ValueError(f"harness not installed: {harness}")
215
+ model = str(resolved_model or requested).strip().lower()
216
+ evidence = {"requested_model": requested, "requested_effort": effort}
217
+ if harness == "codex":
218
+ model = re.sub(r"^(gpt-\d+)-(\d+)", r"\1.\2", model)
219
+ models, source = codex_catalog()
220
+ evidence["catalog_source"] = source
221
+ item = next((m for m in models if m.get("slug") == model), None)
222
+ # Hidden entries are Codex's own internals; a family never lands on one.
223
+ listed = [
224
+ m["slug"] for m in models if m.get("slug") and m.get("visibility") != "hide"
225
+ ]
226
+ if item is None and is_family(model):
227
+ slug = newest_in_family(model, listed)
228
+ item = next((m for m in models if m.get("slug") == slug), None)
229
+ if item is None:
230
+ raise ValueError(
231
+ f"Codex catalog does not contain {model!r}; it offers the "
232
+ f"families {', '.join(families(listed))}, which resolve to "
233
+ "their newest version, or an exact slug"
234
+ )
235
+ model = item["slug"]
236
+ levels = [r["effort"] for r in item.get("supported_reasoning_levels", [])]
237
+ if effort not in levels:
238
+ raise ValueError(f"{model} supports {levels}, not {effort!r}")
239
+ evidence["context_window"] = item.get("context_window")
240
+ # `context_window` is only the default a session opens with. The
241
+ # ceiling a run may raise it to is `max_context_window`, of which the
242
+ # provider hands back `effective_context_window_percent`. Carry all
243
+ # three so a plan asks for the largest window this model actually has
244
+ # instead of a number someone wrote down once.
245
+ evidence["max_context_window"] = item.get("max_context_window")
246
+ evidence["effective_context_window_percent"] = item.get(
247
+ "effective_context_window_percent"
248
+ )
249
+ evidence["catalog_entry"] = {
250
+ k: item.get(k)
251
+ for k in (
252
+ "slug",
253
+ "supported_reasoning_levels",
254
+ "context_window",
255
+ "max_context_window",
256
+ "effective_context_window_percent",
257
+ )
258
+ }
259
+ elif harness == "agy":
260
+ if effort not in AGY_EFFORTS:
261
+ raise ValueError(
262
+ "agy supports low, medium, high; explicitly map the requested effort"
263
+ )
264
+ p = subprocess.run(
265
+ ["agy", "models"], capture_output=True, text=True, timeout=45, check=False
266
+ )
267
+ if p.returncode:
268
+ raise ValueError(f"agy model lookup failed: {p.stderr[-500:]}")
269
+ available = [
270
+ line.split()[0]
271
+ for line in p.stdout.splitlines()
272
+ if line.startswith(("gemini-", "claude-", "gpt-"))
273
+ ]
274
+ # A slug carries its effort as a suffix (`gemini-3.1-pro-high`); a
275
+ # family is resolved on the model with the suffix taken off, and the
276
+ # seat's effort then completes the slug.
277
+ suffix = re.compile(r"-(" + "|".join(AGY_EFFORTS) + r")$")
278
+ bases = sorted({suffix.sub("", s) for s in available})
279
+ if model not in available and is_family(model):
280
+ model = newest_in_family(model, bases) or model
281
+ if model not in available and f"{model}-{effort}" in available:
282
+ model = f"{model}-{effort}"
283
+ if model not in available:
284
+ offered = sorted(
285
+ m.group(1)
286
+ for s in available
287
+ if (m := suffix.search(s)) and suffix.sub("", s) == model
288
+ )
289
+ if offered:
290
+ raise ValueError(
291
+ f"agy offers {model} at efforts {', '.join(offered)}, "
292
+ f"not {effort!r}"
293
+ )
294
+ raise ValueError(
295
+ f"agy model unavailable: {model!r}; it offers the families "
296
+ f"{', '.join(families(bases))}, which resolve to their newest "
297
+ "version, or an exact slug from `agy models`"
298
+ )
299
+ evidence["catalog_entry"] = model
300
+ else:
301
+ # A model the project declares (``providers.claude.models``) is
302
+ # accepted verbatim: a gateway id is neither a family alias nor a
303
+ # claude version, so the name validation below would refuse the
304
+ # whole route. The receipt still verifies what was served, the way
305
+ # it does for an explicit version.
306
+ if model in {str(m).strip().lower() for m in (declared or ())}:
307
+ evidence["catalog_entry"] = (
308
+ "operator-declared model; the receipt verifies the served identity"
309
+ )
310
+ else:
311
+ model = resolve_claude(model)
312
+ evidence["catalog_entry"] = (
313
+ "family alias; the CLI picks its latest, and the receipt records "
314
+ "the version served"
315
+ if model in CLAUDE_ALIASES
316
+ else "explicit model; verify served identity after launch"
317
+ )
318
+ if effort not in ("low", "medium", "high", "xhigh", "max"):
319
+ raise ValueError(f"invalid Claude effort: {effort}")
320
+ evidence.update(harness=harness, model=model, effort=effort)
321
+ return evidence
322
+
323
+
324
+ # Claude publishes no catalog, so its ceiling is a constant. Kept equal to
325
+ # runs/engine.py's CLAUDE_MAX_WINDOW rather than imported, because that module
326
+ # pulls the whole run engine in behind it and this one is reached from the
327
+ # jail.
328
+ CLAUDE_MAX_WINDOW = 1_000_000
329
+
330
+
331
+ def window_flags(config):
332
+ """Harness flags pinning a run to its model's whole window, no compaction.
333
+
334
+ Two settings doing one job. `model_context_window` lifts the session off
335
+ the default the catalog calls `context_window` -- 272,000 for gpt-6-astra
336
+ -- to the ceiling it calls `max_context_window`, which is 872,000. Setting
337
+ the auto-compact limit to the same number means a run that outgrows its
338
+ context fails loudly at the ceiling instead of quietly compacting below
339
+ it, which is what `providers/harness.py` has always done and what the blind
340
+ drivers do with their own constant.
341
+
342
+ Neither setting was here or in `review.py`, so every workshop and review
343
+ run took the session default and could compact freely; `_no_compaction`
344
+ then caught it after the fact, when the spend was already made. A review
345
+ resume compacted for exactly this reason on 2026-09-10. The resume is the
346
+ turn that needs the ceiling most, because it inherits the whole prior
347
+ transcript before the new delta is added, so the run most likely to
348
+ compact was the one configured lowest.
349
+
350
+ `docs/workflows.md` makes compaction a red flag for every
351
+ harness-invoking SOP, and a red flag that can only be raised afterwards is
352
+ a worse instrument than one that prevents the event.
353
+ """
354
+ harness = config["harness"]
355
+ if harness == "claude":
356
+ return ["--autocompact", str(CLAUDE_MAX_WINDOW)]
357
+ if harness != "codex":
358
+ return []
359
+ # The ceiling only. `context_window` is the session default this helper
360
+ # exists to escape, so accepting it as a substitute emits the very number
361
+ # the guard refuses: a catalog entry carrying only `context_window`
362
+ # silently produced 272,000 for both settings.
363
+ window = config.get("max_context_window")
364
+ if not window:
365
+ raise ValueError(
366
+ f"no context ceiling resolved for {config.get('model')!r}; refresh "
367
+ "the Codex catalog rather than letting the run take the session "
368
+ "default (docs/workflows.md, 'Compaction is a major design "
369
+ "warning')"
370
+ )
371
+ return [
372
+ "-c",
373
+ f"model_context_window={window}",
374
+ "-c",
375
+ f"model_auto_compact_token_limit={window}",
376
+ # THE SETTING THAT MAKES THE LIMIT ABOVE MEAN ANYTHING. Without a
377
+ # scope, Codex applies the total-mode 90 percent threshold clamp, so
378
+ # an auto-compact limit set to the window still fires at nine tenths
379
+ # of it and compaction is not disabled at all: for an 872,000 ceiling
380
+ # at 95 percent usable, the trigger sits at 784,800 rather than
381
+ # 828,400. `body_after_prefix` reads the explicit limit directly. The
382
+ # operator's own ~/.codex/config.toml carries this, researched and
383
+ # cited against the Codex source, and every launcher here passes
384
+ # --ignore-user-config, so all of that was being discarded and none
385
+ # of it re-supplied. Version-specific behaviour, not an off switch:
386
+ # recheck on upgrades (established for 0.153.4; 0.154.0 installed).
387
+ "-c",
388
+ 'model_auto_compact_token_limit_scope="body_after_prefix"',
389
+ ]
390
+
391
+
392
+ # Harnesses whose compaction behaviour a run can actually pin. Antigravity is
393
+ # absent because its CLI exposes no context or compaction setting at all: not
394
+ # a ceiling, not a trigger, not an off switch. A seat there takes the session
395
+ # default, and the only guard available is the one that reads the stream
396
+ # afterwards and reports that it happened. Named here rather than left as a
397
+ # silent `return []`, because that same empty list otherwise means both "this
398
+ # harness needs no flags" and "this harness cannot be steered", and an
399
+ # operator reading a mixed room's evidence has to tell those apart.
400
+ PINNED_WINDOW = ("claude", "codex")
401
+
402
+
403
+ # What a seat may do beyond reading its packet. `write` is the floor,
404
+ # `research` adds the web, and `workbench` adds a shell to that.
405
+ TOOL_POLICIES = ("write", "research", "workbench")
406
+
407
+
408
+ def window_assurance(harness):
409
+ """Whether a run can prevent compaction here, or only detect it after."""
410
+ return "enforced" if harness in PINNED_WINDOW else "detected"
411
+
412
+
413
+ def command(config, workspace, prompt, *, manual=False, resume=None):
414
+ """Return argv and stdin bytes; prompts never pass through a shell.
415
+
416
+ ``resume`` names a session to continue instead of opening a new one. It
417
+ exists for the quota case: a writer stopped by a provider limit has its
418
+ reading, its reasoning and its half-written draft inside that session, and
419
+ a fresh writer would pay for all of it again. The caller checks the
420
+ returned session id against ``resume``, because a harness that silently
421
+ started a new conversation produces evidence that looks continuous and
422
+ is not.
423
+ """
424
+ harness, model, effort = (config[k] for k in ("harness", "model", "effort"))
425
+ # A resumed session names the version it began on, never the family: an
426
+ # alias on resume follows a release, so the seat would change model
427
+ # between turns while its receipt still read as the same seat.
428
+ model = config.get("model_pinned") or model
429
+ tools = config.get("tools", "write")
430
+ if tools not in TOOL_POLICIES:
431
+ raise ValueError(f"tools must be one of {TOOL_POLICIES}, not {tools!r}")
432
+ # `workbench` is `research` plus a shell: the roundtable's seats speak
433
+ # through a command in their workspace, and a seat that cannot run it
434
+ # cannot post. It is also the one policy Antigravity can honour, because
435
+ # it confines nothing that agy could otherwise have used.
436
+ workbench = tools == "workbench"
437
+ research = tools == "research" or workbench
438
+ if resume and manual:
439
+ raise ValueError("resume the writer automatically or relaunch it by hand")
440
+ if tools == "research" and harness == "agy":
441
+ raise ValueError("agy cannot confine a seat to a tool set")
442
+ if harness == "codex":
443
+ args = ["codex"] + (
444
+ []
445
+ if manual
446
+ else [
447
+ "exec",
448
+ *(quota.resume_argv("codex", resume) if resume else []),
449
+ "--ignore-user-config",
450
+ "--ignore-rules",
451
+ ]
452
+ )
453
+ args += window_flags(config)
454
+ args += [
455
+ "-m",
456
+ model,
457
+ "-c",
458
+ f'model_reasoning_effort="{effort}"',
459
+ "-c",
460
+ 'personality="none"',
461
+ "-c",
462
+ 'model_verbosity="high"',
463
+ "-c",
464
+ 'web_search="live"' if research else 'web_search="disabled"',
465
+ # Codex's own sandbox cuts a shell command off the network even
466
+ # where the jail around it does not. A workbench seat was
467
+ # promised the network, not only the built-in search tool.
468
+ *(
469
+ ["-c", "sandbox_workspace_write.network_access=true"]
470
+ if workbench
471
+ else []
472
+ ),
473
+ "-c",
474
+ "project_doc_max_bytes=0",
475
+ "-c",
476
+ "features.multi_agent=false",
477
+ *blind.CODEX_SEAT,
478
+ ]
479
+ # `codex exec resume` accepts neither `-s` nor `-C`: it refuses the
480
+ # argument outright and the turn produces no events at all, which
481
+ # surfaces later as "no unique completed turn" rather than as the
482
+ # argument error it is. The sandbox still travels as config, and the
483
+ # working directory needs no flag because the jail has already chdir'd
484
+ # the process into the workspace.
485
+ if resume and not manual:
486
+ args += ["-c", 'sandbox_mode="workspace-write"']
487
+ else:
488
+ args += ["-s", "workspace-write", "-C", str(workspace)]
489
+ if manual:
490
+ return args + [prompt], None
491
+ return args + ["--skip-git-repo-check", "--json", "-"], prompt.encode()
492
+ if harness == "claude":
493
+ tools = (
494
+ "Read,Write,Edit,Glob,Grep"
495
+ + (",Bash" if workbench else "")
496
+ + (",WebSearch,WebFetch" if research else "")
497
+ )
498
+ args = [
499
+ "claude",
500
+ *(quota.resume_argv("claude", resume) if resume else []),
501
+ "--model",
502
+ model,
503
+ "--effort",
504
+ effort,
505
+ "--tools",
506
+ tools,
507
+ "--allowedTools",
508
+ tools,
509
+ "--disable-slash-commands",
510
+ *window_flags(config),
511
+ *blind.BLIND_SETTINGS,
512
+ *blind.NO_SETTING_SOURCES,
513
+ *blind.NO_MCP,
514
+ ]
515
+ if manual:
516
+ return args + [prompt], None
517
+ return args + [
518
+ "-p",
519
+ "--output-format",
520
+ "stream-json",
521
+ "--verbose",
522
+ ], prompt.encode()
523
+ if harness == "agy":
524
+ args = [
525
+ "agy",
526
+ *(quota.resume_argv("agy", resume) if resume else []),
527
+ "--model",
528
+ model,
529
+ "--effort",
530
+ effort,
531
+ "--disable-slash-commands",
532
+ "--dangerously-skip-permissions",
533
+ ]
534
+ if manual:
535
+ return args + ["--prompt-interactive", prompt], None
536
+ return args + [
537
+ "--print",
538
+ prompt,
539
+ "--output-format",
540
+ "stream-json",
541
+ "--print-timeout",
542
+ "60m",
543
+ ], b""
544
+ raise ValueError(f"unknown harness: {harness}")
545
+
546
+
547
+ def canary(config, label):
548
+ """Prove a writer's or a room seat's flags blind on the installed runner.
549
+
550
+ The twin is ``command`` itself, with the canary's prompt, model and
551
+ effort, the seat's tool policy, and the canary's directory as its
552
+ workspace. A seat run by hand is proven under the automatic form of the
553
+ same command, the only one a canary can drive.
554
+ """
555
+ from quirework.providers import canary as canaries
556
+
557
+ harness = config["harness"]
558
+ tools = config.get("tools", "write")
559
+
560
+ def build(prompt, model, workdir, session):
561
+ twin = {
562
+ "harness": harness,
563
+ "model": model,
564
+ "effort": canaries.EFFORT,
565
+ "tools": tools,
566
+ "max_context_window": canaries.WINDOW,
567
+ }
568
+ args, payload = command(twin, workdir, prompt)
569
+ return canaries.Twin(args, payload)
570
+
571
+ # agy keeps its web tools under any flags; the writer's transcript is read
572
+ # afterwards and a run that used them outside a workbench is refused.
573
+ contract = canaries.Contract(
574
+ web=tools in ("research", "workbench"), web_audited=harness == "agy"
575
+ )
576
+ return canaries.require(label, harness, build, contract)
577
+
578
+
579
+ def launch_context(config, workspace, bus=None, canary=None):
580
+ """Use private runtime state and a read-only packet in the shared jail.
581
+
582
+ ``bus`` is the filtered session bus a runner such as agy needs, opened by
583
+ the caller with ``blind.session_bus`` so it outlives this call and is
584
+ handed to the process through ``blind.popen``. ``canary`` is the record
585
+ ``canary`` above returned for this seat.
586
+ """
587
+ harness = config["harness"]
588
+ if error := blind.backend_error():
589
+ raise RuntimeError(error)
590
+ _, missing = blind.runner_binds([harness])
591
+ if missing:
592
+ raise RuntimeError("missing harness prerequisites: " + "; ".join(missing))
593
+ allowed = (
594
+ "PATH",
595
+ "LANG",
596
+ "LC_ALL",
597
+ "TERM",
598
+ "COLORTERM",
599
+ "TZ",
600
+ "HTTPS_PROXY",
601
+ "HTTP_PROXY",
602
+ "ALL_PROXY",
603
+ "NO_PROXY",
604
+ "SSL_CERT_FILE",
605
+ "SSL_CERT_DIR",
606
+ )
607
+ env = {k: os.environ[k] for k in allowed if k in os.environ}
608
+ env.update(
609
+ HOME=str(Path.home()),
610
+ USER=os.environ.get("USER", ""),
611
+ DISABLE_AUTOUPDATER="1",
612
+ DISABLE_COMPACT="1",
613
+ )
614
+ if harness == "claude":
615
+ # The access token alone, with LAUNCH_MARGIN of life left, so the host
616
+ # does not renew the login out from under the writer (``claude_login``).
617
+ env["CLAUDE_CODE_OAUTH_TOKEN"], _ = claude_login.host_login()
618
+ jail = blind.jail_argv(
619
+ workspace,
620
+ runners=[harness],
621
+ bind_credentials=harness != "claude",
622
+ bus=bus,
623
+ canary=canary,
624
+ )
625
+ private = blind.state_root(workspace) / harness
626
+ if harness == "claude":
627
+ runtime = private / "runtime-config.json"
628
+ if not runtime.exists():
629
+ runtime.write_text(
630
+ json.dumps({"hasCompletedOnboarding": True, "theme": "dark"}) + "\n"
631
+ )
632
+ jail += ["--bind", str(runtime), str(Path.home() / ".claude.json")]
633
+ # Put the read-only mounts after the workspace mount, which is writable.
634
+ for name in ("input", "START.md"):
635
+ path = workspace / name
636
+ jail += ["--ro-bind", str(path), str(path)]
637
+ return jail, env, private
638
+
639
+
640
+ def _model_matches(expected, actual):
641
+ """Whether the served model id satisfies the requested one.
642
+
643
+ A family is satisfied by any served id carrying every family word, so
644
+ ``fable`` accepts ``claude-fable-5-1``. That once failed every fable seat
645
+ after it had spent its quota, because only opus, sonnet and haiku were
646
+ known here. A version must match exactly, or with a date suffix.
647
+ """
648
+ if not actual:
649
+ return False
650
+ if is_family(expected):
651
+ return set(_words(expected)) <= set(_words(actual))
652
+ return actual == expected or bool(
653
+ re.fullmatch(re.escape(expected) + r"-\d{8}", actual)
654
+ )
655
+
656
+
657
+ def compaction_events(rows):
658
+ """The event types in a stream that report a compaction, in order.
659
+
660
+ Read from the fields that name an event, never from what it says: a
661
+ reviewer that reads a procedure about compaction writes the word into its
662
+ stream a dozen times without anything having compacted.
663
+ """
664
+ found = []
665
+ for row in rows:
666
+ payload = row.get("payload") or {}
667
+ fields = (
668
+ row.get("type"),
669
+ row.get("subtype"),
670
+ row.get("event"),
671
+ payload.get("type"),
672
+ row.get("step_update", {}).get("step_type"),
673
+ )
674
+ label = next(
675
+ (str(v) for v in fields if v and "compact" in str(v).lower()), None
676
+ )
677
+ if label:
678
+ found.append(label)
679
+ return found
680
+
681
+
682
+ def _no_compaction(rows):
683
+ if compaction_events(rows):
684
+ raise RuntimeError(
685
+ "session compacted; preserve it as an invalid experimental attempt"
686
+ )
687
+
688
+
689
+ def _rollouts(private, harness, sid, *, resumed=False):
690
+ """Every native transcript belonging to one session.
691
+
692
+ A writer that ran once has exactly one, and anything else means the record
693
+ is not what it claims to be -- so the single-file requirement stays for
694
+ that case. A writer CONTINUED after a quota limit is the exception the
695
+ rule cannot cover: the second submission may land beside the first rather
696
+ than inside it, and both files are then the same session's evidence.
697
+ """
698
+ base = private / ("sessions" if harness == "codex" else "projects")
699
+ paths = sorted(base.rglob(f"*{sid}*.jsonl"))
700
+ if not paths or (len(paths) != 1 and not resumed):
701
+ raise RuntimeError(
702
+ f"expected one native {harness} transcript for {sid}, found {len(paths)}"
703
+ )
704
+ return paths
705
+
706
+
707
+ def request_size(harness, row):
708
+ """Tokens one request carried, from a row of the harness's own accounting.
709
+
710
+ None for a row that reports no request. Shared by the peak measured after
711
+ a run and the figure `review status` shows while one is running, so the
712
+ two cannot disagree about what a request is.
713
+ """
714
+ if harness == "codex":
715
+ payload = row.get("payload") or {}
716
+ if row.get("type") != "event_msg" or payload.get("type") != "token_count":
717
+ return None
718
+ last = (payload.get("info") or {}).get("last_token_usage") or {}
719
+ if not last:
720
+ return None
721
+ return (last.get("input_tokens") or 0) + (last.get("cached_input_tokens") or 0)
722
+ if harness == "claude":
723
+ usage = (row.get("message") or {}).get("usage") or {}
724
+ if not usage:
725
+ return None
726
+ # The three parts of one prompt: cached prefix, newly cached prefix,
727
+ # and the uncached remainder.
728
+ return sum(
729
+ usage.get(k) or 0
730
+ for k in (
731
+ "input_tokens",
732
+ "cache_read_input_tokens",
733
+ "cache_creation_input_tokens",
734
+ )
735
+ )
736
+ if harness == "agy":
737
+ # Each finished model step reports the request it made. The closing
738
+ # `result` row is not a request: it is a running total over every
739
+ # request the session has made, earlier turns included.
740
+ if row.get("event") != "step_update":
741
+ return None
742
+ usage = (row.get("step_update") or {}).get("usage") or {}
743
+ if not usage:
744
+ return None
745
+ return (usage.get("input_tokens") or 0) + (usage.get("cache_read_tokens") or 0)
746
+ return None
747
+
748
+
749
+ def context_peak(harness, record):
750
+ """Largest single request, which is the only figure a window bounds.
751
+
752
+ A receipt's token counters are summed over every request in a session and
753
+ say nothing about occupancy: the highest cumulative input in one run
754
+ belonged to the writer that read the least. What a 200k model has to fit
755
+ is one prompt -- the conversation, its tool output and the prose written
756
+ so far -- so that is what is measured here, from the harness's own
757
+ per-request accounting and never from the packet's size.
758
+ """
759
+ out = {"peak_request": None, "window": None, "evidence": "not reported"}
760
+ if harness == "codex":
761
+ peak = window = 0
762
+ for path in sorted((record / "transcripts").glob("*.jsonl")):
763
+ for row in json_rows(path):
764
+ payload = row.get("payload") or {}
765
+ if (
766
+ row.get("type") != "event_msg"
767
+ or payload.get("type") != "token_count"
768
+ ):
769
+ continue
770
+ info = payload.get("info") or {}
771
+ window = info.get("model_context_window") or window
772
+ peak = max(peak, request_size(harness, row) or 0)
773
+ if peak:
774
+ out.update(
775
+ peak_request=peak,
776
+ window=window or None,
777
+ evidence="rollout token_count.last_token_usage",
778
+ )
779
+ else:
780
+ peak = window = 0
781
+ events = record / "events.jsonl"
782
+ for row in json_rows(events) if events.exists() else []:
783
+ peak = max(peak, request_size(harness, row) or 0)
784
+ for served in (row.get("modelUsage") or {}).values():
785
+ window = max(window, served.get("contextWindow") or 0)
786
+ if peak:
787
+ out.update(
788
+ peak_request=peak,
789
+ window=window or None,
790
+ evidence=REQUEST_EVIDENCE[harness],
791
+ )
792
+ elif harness == "agy":
793
+ # An agy that stops reporting its steps is unmeasured, not small.
794
+ out["evidence"] = "agy reported no per-request usage"
795
+ return out
796
+
797
+
798
+ # Where each harness's per-request size is read from, as a receipt states it.
799
+ REQUEST_EVIDENCE = {
800
+ "codex": "rollout token_count.last_token_usage",
801
+ "claude": "assistant message usage",
802
+ "agy": "step_update usage",
803
+ }
804
+
805
+
806
+ # Context windows no harness states before a turn has run. Codex lists its
807
+ # own in the catalog, but Claude Code reports a window only in a finished
808
+ # turn's result and Antigravity never does, so a procedure that must state a
809
+ # budget before the first turn reads the window here. Claude's figures are
810
+ # Anthropic's model table (cached 2026-10); Claude Code reported the same for
811
+ # Sonnet in the September 2026 pilots and for Haiku 5.5 in October 2026.
812
+ # Gemini's is Google's documented 1M-token input window for Gemini 3 models;
813
+ # Antigravity may cap a session lower than that and has no way to say so.
814
+ #
815
+ # A family's figure is its newest model's, because the CLI resolves a family
816
+ # alias to that model. An older model named by its exact id can have a smaller
817
+ # window than its family's newest, so it is listed under that id: Haiku 4.5
818
+ # kept 200,000 tokens when Haiku 5.5 brought the family to a million.
819
+ KNOWN_WINDOWS = {
820
+ "haiku": 1_000_000,
821
+ "sonnet": 1_000_000,
822
+ "opus": 1_000_000,
823
+ "fable": 1_000_000,
824
+ "mythos": 1_000_000,
825
+ "gemini": 1_048_576,
826
+ }
827
+ KNOWN_EXACT_WINDOWS = {
828
+ "claude-haiku-4-5": 200_000,
829
+ }
830
+
831
+
832
+ def stated_window(config):
833
+ """A model's context window before it has run, and where the figure is from.
834
+
835
+ ``(None, None)`` when nothing here knows it: a caller that has to state a
836
+ budget then asks for the window instead of guessing one.
837
+ """
838
+ if config["harness"] == "codex":
839
+ window = config.get("max_context_window")
840
+ if not window:
841
+ return None, None
842
+ # The share of the ceiling Codex actually hands a session: 95 percent
843
+ # of 872,000 is the 828,400 its own token counts report.
844
+ share = config.get("effective_context_window_percent") or 100
845
+ return int(window * share / 100), "Codex catalog"
846
+ model = str(config["model"]).strip().lower()
847
+ for exact, window in KNOWN_EXACT_WINDOWS.items():
848
+ if model == exact or re.fullmatch(re.escape(exact) + r"-\d{8}", model):
849
+ return window, "documented window"
850
+ words = set(_words(model))
851
+ for family, window in KNOWN_WINDOWS.items():
852
+ if family in words:
853
+ return window, "documented window"
854
+ return None, None
855
+
856
+
857
+ # Characters per token when a transcript's size stands in for a count. It
858
+ # leans low, so the estimate leans high: retiring a seat a little early
859
+ # costs less than letting it run into a wall nobody measured.
860
+ CHARS_PER_TOKEN = 3.5
861
+
862
+
863
+ def agy_estimate(usage, transcripts, before=None):
864
+ """How full an Antigravity session is, estimated, when its steps are silent.
865
+
866
+ The fallback for an agy that reports no per-request usage. Its closing
867
+ usage is a running total over the whole session, so this turn's billing
868
+ is that total less the total after the previous turn, and divided by the
869
+ requests this turn made it is the turn's average context. Growth within
870
+ a turn is roughly even, so the end sits about half the turn's growth
871
+ above that average, and the growth is the transcript's new bytes.
872
+ ``before`` is the transcript's size, request count and running billing
873
+ after the previous turn, which the caller keeps; the returned ``marker``
874
+ is the same figure after this one. A turn with no billed requests adds
875
+ its growth to nothing measured and is reported as unknown.
876
+
877
+ The first version divided the running total by one turn's requests. In
878
+ a four-round table that put two Gemini seats at three times the context
879
+ their own requests reported, and would have retired them early.
880
+ """
881
+ size = responses = 0
882
+ for path in transcripts:
883
+ path = Path(path)
884
+ if not path.exists():
885
+ continue
886
+ size += path.stat().st_size
887
+ responses += sum(
888
+ 1 for row in json_rows(path) if row.get("type") == "PLANNER_RESPONSE"
889
+ )
890
+ total = sum(
891
+ (usage or {}).get(k) or 0 for k in ("input_tokens", "cache_read_tokens")
892
+ )
893
+ marker = {"bytes": size, "responses": responses, "billed": total}
894
+ before = before or {"bytes": 0, "responses": 0}
895
+ requests = responses - before["responses"]
896
+ # A marker kept before the billing was recorded cannot be subtracted, and
897
+ # the old overestimate is the most that can be said then.
898
+ billed = total - (before.get("billed") or 0)
899
+ if requests <= 0 or billed <= 0:
900
+ return None, marker
901
+ growth = max(0, size - before["bytes"]) / CHARS_PER_TOKEN
902
+ return int(billed / requests + growth / 2), marker
903
+
904
+
905
+ def context_now(
906
+ harness, record, transcripts=(), *, usage=None, before=None, estimate=False
907
+ ):
908
+ """How full a session's window is after its latest request.
909
+
910
+ Not the peak. With compaction off, the latest request carries the whole
911
+ conversation so far, so its size is what the next turn starts from, and
912
+ a seat told that figure can decide how much of the rest it can afford.
913
+ ``transcripts`` are the native rollouts a receipt names, which is where
914
+ Codex keeps its per-request accounting; Claude keeps it in the stream.
915
+ Antigravity reports each request in its step events; where those are
916
+ missing it is estimated when ``estimate`` asks for it and unknown
917
+ otherwise, never reported as small.
918
+ """
919
+ out = {"tokens": None, "window": None, "evidence": "not reported"}
920
+ if harness == "codex":
921
+ for path in sorted(Path(p) for p in transcripts):
922
+ for row in json_rows(path):
923
+ size = request_size(harness, row)
924
+ if size is None:
925
+ continue
926
+ info = (row.get("payload") or {}).get("info") or {}
927
+ out.update(
928
+ tokens=size,
929
+ window=info.get("model_context_window") or out["window"],
930
+ evidence="rollout token_count.last_token_usage",
931
+ )
932
+ elif harness == "claude":
933
+ events = record / "events.jsonl"
934
+ for row in json_rows(events) if events.exists() else []:
935
+ if row.get("type") == "assistant":
936
+ size = request_size(harness, row)
937
+ if size:
938
+ out.update(tokens=size, evidence="assistant message usage")
939
+ for served in (row.get("modelUsage") or {}).values():
940
+ out["window"] = (
941
+ max(out["window"] or 0, served.get("contextWindow") or 0) or None
942
+ )
943
+ elif harness == "agy":
944
+ events = record / "events.jsonl"
945
+ sizes = (
946
+ [request_size(harness, r) for r in json_rows(events)]
947
+ if (events.exists())
948
+ else []
949
+ )
950
+ last = next((n for n in reversed(sizes) if n is not None), None)
951
+ # The marker is kept even when the steps are measured, so a later
952
+ # turn whose steps fall silent still has a running total to subtract.
953
+ guess, marker = (
954
+ agy_estimate(usage, transcripts, before) if estimate else (None, None)
955
+ )
956
+ if last is not None:
957
+ out.update(tokens=last, evidence=REQUEST_EVIDENCE[harness])
958
+ elif estimate:
959
+ out.update(
960
+ tokens=guess,
961
+ evidence="estimated from billed tokens and transcript growth",
962
+ estimated=True,
963
+ )
964
+ else:
965
+ out["evidence"] = "agy reported no per-request usage"
966
+ if marker:
967
+ out["marker"] = marker
968
+ return out
969
+
970
+
971
+ def _check_resumed(sid, resumed):
972
+ """A resume must land in the session it named, and this is checked FIRST.
973
+
974
+ Before the transcript lookups, because a resume that quietly opened a new
975
+ conversation fails those with a confusing message about a missing file --
976
+ and because the draft it returns is a cold restart wearing a
977
+ continuation's receipt, which nothing else would notice.
978
+ """
979
+ if resumed and sid != resumed:
980
+ raise RuntimeError(
981
+ f"resume landed in session {sid}, not {resumed}; the writer did "
982
+ "not continue its own session"
983
+ )
984
+
985
+
986
+ def receipt(config, record, private, *, manual=False, resumed=None):
987
+ """Use harness evidence, not the writer's claim about its own identity.
988
+
989
+ ``resumed`` names the session this attempt was supposed to continue. It is
990
+ checked against the session the harness actually reports: a resume that
991
+ quietly opened a new conversation is otherwise indistinguishable from one
992
+ that continued, and the draft it returns would be a cold restart wearing a
993
+ continuation's receipt.
994
+ """
995
+ harness = config["harness"]
996
+ event_file = record / "events.jsonl"
997
+ rows = json_rows(event_file) if event_file.exists() else []
998
+ _no_compaction(rows)
999
+ native = []
1000
+ answer = ""
1001
+ # Only Claude's record says why each response stopped and marks what
1002
+ # the harness itself wrote; see `replies`.
1003
+ reply = dict(output_limit=None, harness_text=[])
1004
+ if harness == "codex":
1005
+ if manual:
1006
+ paths = list((private / "sessions").rglob("*.jsonl"))
1007
+ if len(paths) != 1:
1008
+ raise RuntimeError(
1009
+ "manual Codex session has no unique native transcript"
1010
+ )
1011
+ native_rows = json_rows(paths[0])
1012
+ meta = next(
1013
+ r["payload"] for r in native_rows if r.get("type") == "session_meta"
1014
+ )
1015
+ sid = meta["id"]
1016
+ else:
1017
+ ids = {r["thread_id"] for r in rows if r.get("type") == "thread.started"}
1018
+ if len(ids) != 1 or not any(
1019
+ r.get("type") == "turn.completed" for r in rows
1020
+ ):
1021
+ raise RuntimeError(
1022
+ "Codex has no unique completed turn; inspect events and stderr"
1023
+ )
1024
+ sid = ids.pop()
1025
+ _check_resumed(sid, resumed)
1026
+ paths = _rollouts(private, harness, sid, resumed=bool(resumed))
1027
+ native_rows = [r for p in paths for r in json_rows(p)]
1028
+ answers = [
1029
+ r["item"]["text"]
1030
+ for r in rows
1031
+ if r.get("type") == "item.completed"
1032
+ and r.get("item", {}).get("type") == "agent_message"
1033
+ ]
1034
+ answer = answers[-1] if answers else ""
1035
+ native = paths
1036
+ _no_compaction(native_rows)
1037
+ contexts = [
1038
+ r["payload"] for r in native_rows if r.get("type") == "turn_context"
1039
+ ]
1040
+ models = {c.get("model") for c in contexts}
1041
+ efforts = {c.get("effort") for c in contexts}
1042
+ if models != {config["model"]} or efforts != {config["effort"]}:
1043
+ raise RuntimeError(f"Codex model/effort mismatch: {models}, {efforts}")
1044
+ if manual and not any(
1045
+ r.get("payload", {}).get("type") == "task_complete" for r in native_rows
1046
+ ):
1047
+ raise RuntimeError("manual Codex transcript has no completed task")
1048
+ actual = config["model"]
1049
+ usage = next(
1050
+ (
1051
+ r.get("usage")
1052
+ for r in reversed(rows)
1053
+ if r.get("type") == "turn.completed"
1054
+ ),
1055
+ None,
1056
+ )
1057
+ elif harness == "claude":
1058
+ if manual:
1059
+ paths = list((private / "projects").rglob("*.jsonl"))
1060
+ paths = [p for p in paths if "subagents" not in p.parts]
1061
+ if len(paths) != 1:
1062
+ raise RuntimeError(
1063
+ "manual Claude session has no unique native transcript"
1064
+ )
1065
+ sid = paths[0].stem
1066
+ rows = json_rows(paths[0])
1067
+ if not any(
1068
+ r.get("message", {}).get("stop_reason") == "end_turn" for r in rows
1069
+ ):
1070
+ raise RuntimeError("manual Claude transcript has no completed turn")
1071
+ usage = None
1072
+ else:
1073
+ results = [r for r in rows if r.get("type") == "result"]
1074
+ if (
1075
+ len(results) != 1
1076
+ or results[0].get("is_error")
1077
+ or results[0].get("subtype") != "success"
1078
+ ):
1079
+ # A quota stop and a crash both land here, and the harness
1080
+ # states which in the result text it still returns. Quote it:
1081
+ # an operator told only that a file is absent looks for the
1082
+ # wrong cause. Note that `subtype` can read "success" while
1083
+ # `is_error` is set.
1084
+ said = (results[0].get("result") or "").strip() if results else ""
1085
+ raise RuntimeError(
1086
+ "Claude has no successful completed result"
1087
+ + (f": {said[:300]}" if said else "; inspect events and stderr")
1088
+ )
1089
+ result = results[0]
1090
+ sid, answer, usage = (
1091
+ result["session_id"],
1092
+ result.get("result", ""),
1093
+ result.get("modelUsage"),
1094
+ )
1095
+ _check_resumed(sid, resumed)
1096
+ paths = _rollouts(private, harness, sid, resumed=bool(resumed))
1097
+ # The result row holds only the last response, so a reply the
1098
+ # output ceiling cut is rebuilt from every part of it.
1099
+ answer, reply = replies.claude(
1100
+ rows, [r for p in paths for r in json_rows(p)]
1101
+ )
1102
+ native = paths
1103
+ for path in paths:
1104
+ _no_compaction(json_rows(path))
1105
+ models = {
1106
+ r.get("message", {}).get("model")
1107
+ for r in rows
1108
+ if r.get("type") == "assistant"
1109
+ and r.get("message", {}).get("model") not in (None, "<synthetic>")
1110
+ }
1111
+ expected = config.get("model_pinned") or config["model"]
1112
+ if len(models) != 1 or not _model_matches(expected, next(iter(models))):
1113
+ raise RuntimeError(f"Claude served model mismatch: {models}")
1114
+ actual = models.pop()
1115
+ # Effort is selected by the CLI. Do not claim a served-effort receipt
1116
+ # when the provider does not expose one in its stream.
1117
+ else:
1118
+ paths = list(
1119
+ private.glob(
1120
+ "antigravity-cli/brain/*/.system_generated/logs/transcript_full.jsonl"
1121
+ )
1122
+ )
1123
+ if manual:
1124
+ if len(paths) != 1:
1125
+ raise RuntimeError("manual agy session has no unique native transcript")
1126
+ sid = paths[0].parents[2].name
1127
+ native_rows = json_rows(paths[0])
1128
+ _no_compaction(native_rows)
1129
+ last = native_rows[-1] if native_rows else {}
1130
+ if (
1131
+ last.get("type") != "PLANNER_RESPONSE"
1132
+ or last.get("status") != "DONE"
1133
+ or not last.get("content")
1134
+ ):
1135
+ raise RuntimeError(
1136
+ "manual agy transcript has no completed final response"
1137
+ )
1138
+ answer, usage, actual = last["content"], None, None
1139
+ else:
1140
+ results = [r["result"] for r in rows if r.get("event") == "result"]
1141
+ if len(results) != 1:
1142
+ raise RuntimeError(
1143
+ "agy has no unique final result; inspect events and stderr"
1144
+ )
1145
+ result = results[0]
1146
+ if result.get("status") not in ("SUCCESS", "COMPLETED", "DONE"):
1147
+ raise RuntimeError(f"agy result status: {result.get('status')}")
1148
+ ids = {r["conversation_id"] for r in rows if r.get("conversation_id")}
1149
+ if len(ids) != 1:
1150
+ raise RuntimeError("agy has no unique conversation ID")
1151
+ sid = ids.pop()
1152
+ _check_resumed(sid, resumed)
1153
+ actual = result.get("model") or next(
1154
+ (
1155
+ r.get("init", {}).get("model")
1156
+ for r in rows
1157
+ if r.get("event") == "init"
1158
+ ),
1159
+ None,
1160
+ )
1161
+ if actual != config["model"]:
1162
+ raise RuntimeError(
1163
+ f"agy model mismatch or missing init receipt: {actual}"
1164
+ )
1165
+ answer, usage = result.get("response", ""), result.get("usage")
1166
+ paths = [p for p in paths if p.parents[2].name == sid]
1167
+ if len(paths) != 1:
1168
+ raise RuntimeError("agy native transcript is absent or ambiguous")
1169
+ native = paths
1170
+ native_rows = json_rows(paths[0])
1171
+ _no_compaction(native_rows)
1172
+ forbidden = []
1173
+ # agy cannot be told which tools a seat may use, so its transcript is
1174
+ # read afterwards instead. The web is outside a writing assignment and
1175
+ # inside a workbench one; the browser, MCP servers, subagents and
1176
+ # messaging stay outside both, because each would put something other
1177
+ # than this one model into the seat.
1178
+ outside = r"browser|mcp|invoke_subagent|send_message"
1179
+ if config.get("tools") != "workbench":
1180
+ outside += r"|search_web|url"
1181
+ for row in native_rows:
1182
+ for call in row.get("tool_calls", []):
1183
+ name = call.get("name", "")
1184
+ if re.search(outside, name):
1185
+ forbidden.append(name)
1186
+ if name == "generate_image" and "image-generation" not in config.get(
1187
+ "capabilities", []
1188
+ ):
1189
+ forbidden.append(name)
1190
+ if forbidden:
1191
+ raise RuntimeError(
1192
+ f"agy used tools outside the text assignment: {forbidden}"
1193
+ )
1194
+ return {
1195
+ "session_id": sid,
1196
+ "model": actual,
1197
+ "resumed_from": resumed,
1198
+ "selected_model": config["model"],
1199
+ **(
1200
+ {"model_pinned": config["model_pinned"]}
1201
+ if config.get("model_pinned")
1202
+ else {}
1203
+ ),
1204
+ "model_evidence": (
1205
+ "harness init" if harness == "agy" else "assistant/turn context"
1206
+ )
1207
+ if actual
1208
+ else "explicit CLI selection; no served model receipt",
1209
+ "effort": config["effort"],
1210
+ "effort_evidence": "turn_context"
1211
+ if harness == "codex"
1212
+ else "explicit CLI selection",
1213
+ "usage": usage,
1214
+ "native_transcripts": [str(p) for p in native],
1215
+ "output_limit": reply["output_limit"],
1216
+ "harness_text": reply["harness_text"],
1217
+ "red_flags": replies.flags(reply),
1218
+ "answer": answer,
1219
+ }
1220
+
1221
+
1222
+ # What each harness calls the same quantity, and the one place that knows it.
1223
+ # Three adapters report usage in three native shapes, so a caller adding up a
1224
+ # mixed room either restates this mapping or silently reads only the seats it
1225
+ # happens to understand. A first attempt at totalling a four-seat room summed
1226
+ # two of them and printed a figure that looked complete.
1227
+ #
1228
+ # `input` and `cache_read` are reported as the harness counts them, and the
1229
+ # harnesses do not agree: Codex counts cached tokens inside `input_tokens`,
1230
+ # Claude counts them beside it. Reconciling that here would mean inventing a
1231
+ # number no receipt contains, so the difference is surfaced instead.
1232
+ USAGE_FIELDS = {
1233
+ "codex": {
1234
+ "input": "input_tokens",
1235
+ "output": "output_tokens",
1236
+ "reasoning": "reasoning_output_tokens",
1237
+ "cache_read": "cached_input_tokens",
1238
+ },
1239
+ "agy": {
1240
+ "input": "input_tokens",
1241
+ "output": "output_tokens",
1242
+ "reasoning": "thinking_tokens",
1243
+ "cache_read": "cache_read_tokens",
1244
+ },
1245
+ "claude": {
1246
+ "input": "inputTokens",
1247
+ "output": "outputTokens",
1248
+ "reasoning": "thinkingTokens",
1249
+ "cache_read": "cacheReadInputTokens",
1250
+ "cost_usd": "costUSD",
1251
+ },
1252
+ }
1253
+ CACHE_INSIDE_INPUT = ("codex",)
1254
+
1255
+
1256
+ def usage_totals(harness, usage):
1257
+ """One receipt's usage in common terms, or nothing if it reported none.
1258
+
1259
+ Claude nests usage under the model that served the turn and is the only
1260
+ harness reporting a price, so `cost_usd` is None elsewhere rather than
1261
+ zero: a room whose cost is partly unknown must not read as a cheaper room.
1262
+ """
1263
+ fields = USAGE_FIELDS.get(harness)
1264
+ if not fields or not isinstance(usage, dict) or not usage:
1265
+ return None
1266
+ rows = (
1267
+ [v for v in usage.values() if isinstance(v, dict)]
1268
+ if harness == "claude"
1269
+ else [usage]
1270
+ )
1271
+ if not rows:
1272
+ return None
1273
+ totals = {name: 0 for name in ("input", "output", "reasoning", "cache_read")}
1274
+ cost = 0.0 if "cost_usd" in fields else None
1275
+ for row in rows:
1276
+ for name in totals:
1277
+ totals[name] += row.get(fields[name]) or 0
1278
+ if cost is not None:
1279
+ cost += row.get(fields["cost_usd"]) or 0.0
1280
+ return {
1281
+ **totals,
1282
+ "cost_usd": cost,
1283
+ "cache_inside_input": harness in CACHE_INSIDE_INPUT,
1284
+ }