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,588 @@
1
+ """Living with a provider's account limit: what order to spend in, and how
2
+ to carry on after the wall.
3
+
4
+ A quota stop is not a failure of the work. The provider stopped selling
5
+ tokens for a while; the session, its reasoning and whatever the agent had
6
+ already written are all still there. So the recovery is a **resume of the
7
+ same session**, not a fresh run of the same task: the agent is handed one
8
+ short notice that it was interrupted and told to carry on.
9
+
10
+ Two shapes, and they need different recoveries:
11
+
12
+ ``refused``
13
+ The provider declined before the model ran, so nothing was generated.
14
+ The harness has nonetheless already appended the prompt -- and often
15
+ its own refusal notice -- to the session, which is why the shared
16
+ runner rewinds the session file and resubmits the identical turn
17
+ (``rewind_sessions``). Cheap and exact.
18
+
19
+ ``interrupted``
20
+ The model ran, used tools, wrote files, and the wall arrived partway
21
+ through. Nothing can be replayed: the turn's reasoning is banked in
22
+ the session and the only way to keep it is to continue that session.
23
+ Restarting the task throws away the expensive half. One measured
24
+ workshop seat spent 58 percent of its cost on output and reasoning
25
+ and 4.5 percent on cached context reads, so a restart pays the whole
26
+ bill again to re-derive what the session still holds.
27
+
28
+ This module owns the detection, the session-id recovery and the resume
29
+ argv for all three harnesses, so no driver has to recognize a quota stop
30
+ on its own. It also recognizes the one stop that is neither a limit nor a
31
+ fault: a Claude seat whose borrowed login was revoked or expired mid-turn
32
+ (``auth_stop``). That stop is kept apart from ``classify`` on purpose,
33
+ because its recovery is not a wait: the host renews its login on its own,
34
+ and the turn goes on as soon as a newer token exists (``claude_login``). Deliberately stdlib-only and free of project imports: the
35
+ run engine's frozen executor, the workshop and the review driver all
36
+ reach it, and two of those must not pull the engine in behind them.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import hashlib
42
+ import json
43
+ import re
44
+ from datetime import datetime, timedelta
45
+ from pathlib import Path
46
+
47
+ HARNESSES = ("codex", "claude", "agy")
48
+
49
+ # What a seat costs, declared rather than inferred. A table of model ids would
50
+ # be stale within a month -- providers rename models constantly -- and a run
51
+ # that departs from its roster has seats no table could know about. So a plan
52
+ # says which of ITS OWN seats is the cheap one and which is the premium one,
53
+ # and everything else is standard.
54
+ TIERS = ("cheap", "premium", "standard")
55
+
56
+
57
+ def spend_order(items, tier=lambda item: item.get("tier", "standard")):
58
+ """Seats in the order a run should spend money on them.
59
+
60
+ One cheap seat, then the premium seat, then the rest in declared order.
61
+ The two ends of a roster fail differently and both failures are cheaper
62
+ early, which is the whole argument:
63
+
64
+ A **broken assignment** -- a missing file, an ambiguous instruction, an
65
+ output path nobody can write -- is found by whichever seat runs first, so
66
+ that seat should be the one it costs least to throw away. Only ONE cheap
67
+ seat leads, because a second adds wall time and learns nothing the first
68
+ did not.
69
+
70
+ A **quota wall** arrives at the end of a budget, and the seat standing
71
+ there when it lands is whichever ran last. Putting the premium seat second
72
+ spends it into a nearly empty allowance and leaves the remainder available
73
+ to continue it; the seats that then meet the wall are the cheap and
74
+ replaceable ones, which is the loss a run can afford. Prefer a shakedown
75
+ seat billed against a DIFFERENT account from the premium one, or the cheap
76
+ run eats the budget it is supposed to be protecting.
77
+
78
+ Nothing here runs anything, and nothing here is parallel. Seats sharing an
79
+ account exhaust a five-hour window several times faster in parallel, and
80
+ the wall then lands mid-turn on every one of them at once instead of on
81
+ one; three measured workshop runs lost seats exactly that way.
82
+ """
83
+ ranked, led = [], False
84
+ for item in items:
85
+ if tier(item) == "cheap" and not led:
86
+ ranked.append(item)
87
+ led = True
88
+ ranked += [i for i in items if tier(i) == "premium" and i not in ranked]
89
+ ranked += [i for i in items if i not in ranked]
90
+ return ranked
91
+
92
+
93
+ # What a provider says when it is a limit rather than a fault. Claude's
94
+ # wording has been seen as both "You've hit your session limit" and
95
+ # "Claude AI usage limit reached|<epoch>"; codex reports a code, except
96
+ # that 0.161 states a plan's usage limit in prose with none.
97
+ # The structured signals below are preferred wherever a harness emits one,
98
+ # and this is the fallback for a harness that only returns prose.
99
+ LIMIT_TEXT = re.compile(
100
+ r"usage limit|session limit|rate.?limit|quota|too many requests|"
101
+ r"resets? at|429\b",
102
+ re.I,
103
+ )
104
+
105
+ CODEX_LIMIT_CODES = ("rate_limit_exceeded", "insufficient_quota")
106
+
107
+ # Codex names a plan's reset as a time on the clock and nothing more: "...
108
+ # or try again at 1:18 PM." No date and no zone, because the CLI formats it
109
+ # for whoever is at the terminal. It is read as local time, at the next such
110
+ # time at or after the stop. A stop filed with no reset tells the operator
111
+ # nothing about when to come back, and the sentence was right there.
112
+ CODEX_RESET = re.compile(
113
+ r"try again (?:at|after)\s+(\d{1,2}):(\d{2})(?:\s*([ap])\.?\s*m\b\.?)?",
114
+ re.I,
115
+ )
116
+
117
+ # A reset named a few minutes before the stop was recorded is the same
118
+ # reset, reached late (the file is stamped when the harness finishes
119
+ # writing), and not one a whole day away.
120
+ RESET_GRACE = timedelta(minutes=10)
121
+
122
+
123
+ def codex_reset(message, stopped_at):
124
+ """Epoch seconds of the reset a Codex limit message names, or None.
125
+
126
+ ``stopped_at`` is when the stop was recorded, in epoch seconds; the
127
+ clock time in the message is taken as the next one at or after it, in
128
+ local time. A message naming no time, or an impossible one, gives None
129
+ rather than a guess.
130
+ """
131
+ found = CODEX_RESET.search(str(message or ""))
132
+ if not found or stopped_at is None:
133
+ return None
134
+ hour, minute = int(found[1]), int(found[2])
135
+ half = (found[3] or "").lower()
136
+ if minute > 59:
137
+ return None
138
+ if half:
139
+ if not 1 <= hour <= 12:
140
+ return None
141
+ hour = hour % 12 + (12 if half == "p" else 0)
142
+ elif hour > 23:
143
+ return None
144
+ stop = datetime.fromtimestamp(stopped_at)
145
+ reset = stop.replace(hour=hour, minute=minute, second=0, microsecond=0)
146
+ if reset < stop - RESET_GRACE:
147
+ reset += timedelta(days=1)
148
+ return int(reset.timestamp())
149
+
150
+
151
+ # What Claude Code says when the token it was handed stops working. The 401
152
+ # status, the assistant record's ``authentication_failed`` and the retry
153
+ # records' ``error_status`` are the structured signals; this wording is the
154
+ # fallback for a release that drops them. Observed verbatim: "Failed to
155
+ # authenticate: OAuth token revoked. Please log in again or contact your
156
+ # administrator."
157
+ AUTH_TEXT = re.compile(
158
+ r"failed to authenticate|oauth (?:access )?token (?:has been )?(?:revoked|expired)",
159
+ re.I,
160
+ )
161
+
162
+ # A provider declining because the model itself is busy, which is not a usage
163
+ # limit: there is no allowance to wait out and no reset time to wait for. The
164
+ # recovery is the same as a refusal's, because nothing ran either way, and the
165
+ # scope is reported separately so nobody is told to sit out a window that does
166
+ # not exist. A writers room lost a seat for a whole run to one of these, with
167
+ # the controller calling it an ordinary failure and refusing to take the turn
168
+ # again; the model answered normally two minutes later.
169
+ CAPACITY_TEXT = re.compile(
170
+ r"at capacity|overloaded|try a different model|temporarily unavailable",
171
+ re.I,
172
+ )
173
+
174
+
175
+ # The whole prompt a resumed agent gets. It says what happened, that the
176
+ # assignment is unchanged, and that the work already done is still its own
177
+ # -- and it deliberately says nothing about the task itself, because the
178
+ # session already holds the task and restating it invites a fresh start.
179
+ CONTINUATION = (
180
+ "There was a session quota limit event and your previous turn was cut "
181
+ "off partway through. Nothing about the assignment has changed and "
182
+ "nothing you already did has been lost.\n\n"
183
+ "Continue from where you were. Re-read what you have already written, "
184
+ "do not redo work you have already completed, do not start over, and "
185
+ "finish the task you were given."
186
+ )
187
+
188
+
189
+ # The same prompt for a turn continued after any other stop: a dropped
190
+ # connection, an API error, a failure no classifier names. It does not guess
191
+ # at the cause, because the agent needs only to know that it was cut off and
192
+ # that its work survived.
193
+ ERROR_CONTINUATION = (
194
+ "We hit an error and your previous turn was cut off partway through. "
195
+ "Nothing about the assignment has changed and nothing you already did "
196
+ "has been lost.\n\n"
197
+ "Please resume your task. Re-read what you have already written, do not "
198
+ "redo work you have already completed, do not start over, and finish the "
199
+ "task you were given."
200
+ )
201
+
202
+
203
+ # The same prompt for a turn continued after the seat's borrowed login was
204
+ # renewed under it. Honest about the cause and no more: the agent needs to
205
+ # know only that the interruption was the connection's, not its own doing.
206
+ AUTH_CONTINUATION = (
207
+ "The connection's login was renewed and your previous turn was cut off "
208
+ "partway through. Nothing about the assignment has changed and nothing "
209
+ "you already did has been lost.\n\n"
210
+ "Continue from where you were. Re-read what you have already written, "
211
+ "do not redo work you have already completed, do not start over, and "
212
+ "finish the task you were given."
213
+ )
214
+
215
+ _CONTINUATIONS = dict(
216
+ quota=CONTINUATION, error=ERROR_CONTINUATION, auth=AUTH_CONTINUATION
217
+ )
218
+
219
+
220
+ def continuation(note=None, *, cause="quota"):
221
+ """The resume prompt, plus one caller line naming where its work is.
222
+
223
+ `cause` is "quota" for a provider limit, "auth" for a seat whose borrowed
224
+ login was renewed mid-turn, and "error" for any other stop.
225
+ """
226
+ if cause not in _CONTINUATIONS:
227
+ raise ValueError(f"unknown continuation cause: {cause}")
228
+ text = _CONTINUATIONS[cause]
229
+ return text if not note else f"{text}\n\n{note.strip()}\n"
230
+
231
+
232
+ def rows(path):
233
+ """Parse the complete JSON lines; an unterminated last line is dropped.
234
+
235
+ A seat killed mid-flush leaves its stream ending in half a row. The
236
+ receipt already fails on that, loudly, and the classifier runs right
237
+ after it on the same stream. Raising here a second time was worse than
238
+ useless: it escaped the room's launch before the receipt was written,
239
+ so a killed turn banked no ``receipt.json`` and no round state, and the
240
+ round reported a JSON parse error where the seat's exit code belonged.
241
+ Only a line that lacks its newline is dropped; a malformed complete
242
+ line is still the visible failure it always was.
243
+ """
244
+ path = Path(path)
245
+ if not path.exists():
246
+ return []
247
+ out = []
248
+ with path.open("rb") as stream:
249
+ for line in stream:
250
+ if not line.strip():
251
+ continue
252
+ try:
253
+ out.append(json.loads(line))
254
+ except json.JSONDecodeError:
255
+ if not line.endswith(b"\n"):
256
+ break
257
+ raise
258
+ return out
259
+
260
+
261
+ def session_id(harness, stream):
262
+ """The harness session this record belongs to, even when the turn failed.
263
+
264
+ The drivers read the session id out of a *successful* receipt, so a
265
+ quota-stopped turn banks ``session_id: null`` and the one thing a
266
+ resume needs survives only inside the event stream. Recovering it here
267
+ is what makes a stopped turn resumable at all.
268
+ """
269
+ if harness == "codex":
270
+ ids = [r["thread_id"] for r in stream if r.get("type") == "thread.started"]
271
+ elif harness == "claude":
272
+ ids = [r["session_id"] for r in stream if r.get("session_id")]
273
+ elif harness == "agy":
274
+ ids = [r["conversation_id"] for r in stream if r.get("conversation_id")]
275
+ else:
276
+ raise ValueError(f"unknown harness: {harness}")
277
+ unique = list(dict.fromkeys(ids))
278
+ # More than one session in one turn's stream means the record is not
279
+ # what it claims to be; resuming "the" session would be a guess.
280
+ return unique[0] if len(unique) == 1 else None
281
+
282
+
283
+ def _claude(stream):
284
+ results = [r for r in stream if r.get("type") == "result"]
285
+ if len(results) != 1 or not results[0].get("is_error"):
286
+ return None
287
+ result = results[0]
288
+ said = str(result.get("result") or "")
289
+ if CAPACITY_TEXT.search(said) and not LIMIT_TEXT.search(said):
290
+ # Busy, not out of allowance. Nothing ran, so the turn is delivered
291
+ # again rather than continued, and no reset time is reported because
292
+ # there is none to report.
293
+ return dict(
294
+ phase="refused",
295
+ scope="capacity",
296
+ resets_at=None,
297
+ detail=said[:200],
298
+ output_tokens=0,
299
+ )
300
+ limit = [
301
+ r
302
+ for r in stream
303
+ if r.get("type") == "rate_limit_event"
304
+ and (r.get("rate_limit_info") or {}).get("status") == "rejected"
305
+ ]
306
+ if not limit and not LIMIT_TEXT.search(said):
307
+ return None
308
+ info = (limit[-1].get("rate_limit_info") or {}) if limit else {}
309
+ # The receipt that says whether the model ran: a provider refusal
310
+ # reports no output tokens anywhere and an empty `modelUsage`, because
311
+ # nothing was generated. A real turn cannot satisfy that.
312
+ produced = sum(
313
+ v.get("outputTokens", 0) or 0 for v in (result.get("modelUsage") or {}).values()
314
+ )
315
+ return dict(
316
+ phase="refused" if not produced else "interrupted",
317
+ scope=info.get("rateLimitType") or "quota",
318
+ resets_at=info.get("resetsAt"),
319
+ detail=said[:200],
320
+ output_tokens=produced,
321
+ )
322
+
323
+
324
+ def claude_auth(stream):
325
+ """The login-stop detail in a Claude event stream already read, or None.
326
+
327
+ The stream-level half of ``auth_stop``, for a driver that has the rows in
328
+ hand and no record directory to point at.
329
+ """
330
+ results = [r for r in stream if r.get("type") == "result"]
331
+ if len(results) != 1 or not results[0].get("is_error"):
332
+ return None
333
+ result = results[0]
334
+ said = str(result.get("result") or "")
335
+ structured = (
336
+ result.get("api_error_status") == 401
337
+ or any(
338
+ r.get("type") == "assistant" and r.get("error") == "authentication_failed"
339
+ for r in stream
340
+ )
341
+ or any(
342
+ r.get("type") == "system"
343
+ and r.get("subtype") == "api_retry"
344
+ and (
345
+ str(r.get("error_status")) == "401"
346
+ or r.get("error") == "authentication_failed"
347
+ )
348
+ for r in stream
349
+ )
350
+ )
351
+ if not structured and not AUTH_TEXT.search(said):
352
+ return None
353
+ produced = sum(
354
+ v.get("outputTokens", 0) or 0 for v in (result.get("modelUsage") or {}).values()
355
+ )
356
+ return dict(
357
+ phase="refused" if not produced else "interrupted",
358
+ scope="auth",
359
+ resets_at=None,
360
+ detail=said[:200],
361
+ output_tokens=produced,
362
+ )
363
+
364
+
365
+ def auth_stop(harness, record):
366
+ """A Claude login revoked or expired mid-turn in this record, or None.
367
+
368
+ Only a Claude seat borrows a token it cannot renew, so only Claude is
369
+ read; the other harnesses keep their own credentials. ``phase`` follows
370
+ ``classify``'s meaning: ``refused`` when nothing ran, so the identical
371
+ turn is delivered again, and ``interrupted`` when the model ran, so the
372
+ session is continued. Whether to do either now is the caller's question,
373
+ and ``claude_login`` answers it: only once the host holds a newer token.
374
+ """
375
+ if harness not in HARNESSES:
376
+ raise ValueError(f"unknown harness: {harness}")
377
+ if harness != "claude":
378
+ return None
379
+ record = Path(record)
380
+ stream = rows(record if record.is_file() else record / "events.jsonl")
381
+ if not stream:
382
+ return None
383
+ detail = claude_auth(stream)
384
+ if detail is None:
385
+ return None
386
+ detail["harness"] = harness
387
+ detail["session_id"] = session_id(harness, stream)
388
+ return detail
389
+
390
+
391
+ def _codex_limit(error):
392
+ """Whether a failed Codex turn says it stopped for an account limit.
393
+
394
+ The code is the structured signal and wins wherever it is present, so a
395
+ coded failure that is not a limit code stays a fault. Codex 0.161 reports
396
+ a ChatGPT plan's usage limit with no code at all, only the sentence, and a
397
+ seat stopped that way was filed as an ambiguous receipt and could not be
398
+ resumed. A codeless failure therefore falls back to the limit wording the
399
+ other harnesses are already read by.
400
+ """
401
+ code = error.get("code")
402
+ if code:
403
+ return code in CODEX_LIMIT_CODES
404
+ return bool(LIMIT_TEXT.search(str(error.get("message", ""))))
405
+
406
+
407
+ def _codex(stream, stopped_at=None):
408
+ stopped = [r for r in stream if r.get("type") == "turn.failed"]
409
+ failed = [r for r in stopped if _codex_limit(r.get("error") or {})]
410
+ if not failed:
411
+ busy = [
412
+ r
413
+ for r in stopped
414
+ if CAPACITY_TEXT.search(str((r.get("error") or {}).get("message", "")))
415
+ ]
416
+ if not busy:
417
+ return None
418
+ error = busy[-1].get("error") or {}
419
+ return dict(
420
+ phase="refused",
421
+ scope="capacity",
422
+ resets_at=None,
423
+ detail=str(error.get("message", ""))[:200],
424
+ output_tokens=None,
425
+ )
426
+ error = failed[-1].get("error") or {}
427
+ produced = any(r.get("type") == "item.completed" for r in stream)
428
+ return dict(
429
+ phase="interrupted" if produced else "refused",
430
+ scope=error.get("code") or "quota",
431
+ resets_at=error.get("resets_at")
432
+ or codex_reset(error.get("message"), stopped_at),
433
+ detail=str(error.get("message", ""))[:200],
434
+ output_tokens=None,
435
+ )
436
+
437
+
438
+ def _agy(stream):
439
+ results = [r["result"] for r in stream if r.get("event") == "result"]
440
+ if len(results) != 1 or results[0].get("status") != "ERROR":
441
+ return None
442
+ result = results[0]
443
+ said = str(result.get("error") or result.get("response") or "")
444
+ if not LIMIT_TEXT.search(said):
445
+ return None
446
+ produced = (
447
+ bool(result.get("response"))
448
+ or bool((result.get("usage") or {}).get("output_tokens"))
449
+ or any(r.get("step_update", {}).get("step_type") == "tool" for r in stream)
450
+ )
451
+ return dict(
452
+ phase="interrupted" if produced else "refused",
453
+ scope="quota",
454
+ resets_at=None,
455
+ detail=said[:200],
456
+ output_tokens=(result.get("usage") or {}).get("output_tokens"),
457
+ )
458
+
459
+
460
+ def classify(harness, record):
461
+ """A quota stop in this record's event stream, or None.
462
+
463
+ ``record`` is a directory holding ``events.jsonl``, or the file itself.
464
+ Returns ``phase`` (``refused``/``interrupted``), the provider's scope and
465
+ reset time where it gives one, and the session id to resume.
466
+
467
+ Returning None is the safe answer and the common one: an ambiguous
468
+ transport fault must never be treated as a quota stop, because the two
469
+ recoveries differ and resubmitting an ambiguous turn can duplicate work
470
+ that actually happened.
471
+ """
472
+ if harness not in HARNESSES:
473
+ raise ValueError(f"unknown harness: {harness}")
474
+ record = Path(record)
475
+ path = record if record.is_file() else record / "events.jsonl"
476
+ stream = rows(path)
477
+ if not stream:
478
+ return None
479
+ if harness == "codex":
480
+ # Codex's reset is a clock time with no date, so it is read against
481
+ # when the stream was last written: the stop's own moment, give or
482
+ # take the harness finishing its file.
483
+ detail = _codex(stream, stopped_at=path.stat().st_mtime)
484
+ else:
485
+ detail = {"claude": _claude, "agy": _agy}[harness](stream)
486
+ if detail is None:
487
+ return None
488
+ detail["harness"] = harness
489
+ detail["session_id"] = session_id(harness, stream)
490
+ return detail
491
+
492
+
493
+ def resumable(harness, record):
494
+ """The session id to resume after an interrupted turn, or None.
495
+
496
+ A ``refused`` stop is not resumable through this path on purpose: nothing
497
+ ran, so there is no reasoning to continue from and the turn has to be
498
+ delivered again rather than continued.
499
+
500
+ *How* it is delivered again is the caller's, not this module's. Where the
501
+ refused turn was itself a resume -- the shared runner's later turns, and
502
+ every round after a room or roundtable seat's first -- its session holds
503
+ the turns before it. That caller rewinds to the mark it took before
504
+ sending (``session_marks``) and resubmits the identical turn into the
505
+ same session; a fresh one would lose every earlier turn. A driver whose
506
+ unit is one assignment in its own session -- the prose workshop, the
507
+ review driver -- relaunches the turn in a fresh session instead, and
508
+ loses nothing by it, since there is no earlier turn to keep.
509
+ """
510
+ return resumable_session(classify(harness, record))
511
+
512
+
513
+ def resumable_session(detail):
514
+ """The same rule as ``resumable``, over an already-classified stop.
515
+
516
+ Split out so a caller that has paid for ``classify`` already -- the
517
+ workshop's ``inspect`` walks every failed record -- applies the predicate
518
+ without reading the event stream a second time. One owner for the rule,
519
+ two ways in: deriving it from ``session_id`` alone is what made ``inspect``
520
+ report a refused writer as resumable.
521
+ """
522
+ if not detail or detail["phase"] != "interrupted":
523
+ return None
524
+ return detail["session_id"]
525
+
526
+
527
+ def session_marks(paths):
528
+ """Record each native session file's length and the digest of that prefix.
529
+
530
+ The pair is what makes a later rewind safe: length alone would truncate a
531
+ file the client had rewritten rather than appended to, and a digest alone
532
+ could not say where to cut.
533
+ """
534
+ marks = []
535
+ for path in paths:
536
+ data = path.read_bytes()
537
+ marks.append(
538
+ dict(
539
+ path=str(path),
540
+ size=len(data),
541
+ sha256=hashlib.sha256(data).hexdigest(),
542
+ )
543
+ )
544
+ return marks
545
+
546
+
547
+ def rewind_sessions(marks):
548
+ """Return each recorded session file to the state in `marks`.
549
+
550
+ Refuses unless the file still begins with exactly the bytes that were
551
+ there before, so this can only ever remove the refused turn's own
552
+ appended rows. A session that shrank, or whose prefix changed, is not
553
+ rewound: that is no longer a clean resume and the operator decides.
554
+ """
555
+ for mark in marks or []:
556
+ path = Path(mark["path"])
557
+ if not path.exists():
558
+ if mark["size"]:
559
+ raise RuntimeError(f"session file vanished: {path}")
560
+ continue
561
+ data = path.read_bytes()
562
+ if len(data) < mark["size"]:
563
+ raise RuntimeError(f"session file shrank; not a clean rewind: {path}")
564
+ prefix = data[: mark["size"]]
565
+ if hashlib.sha256(prefix).hexdigest() != mark["sha256"]:
566
+ raise RuntimeError(f"session prefix changed; not a clean rewind: {path}")
567
+ if len(data) == mark["size"]:
568
+ continue
569
+ with path.open("r+b") as handle:
570
+ handle.truncate(mark["size"])
571
+
572
+
573
+ def resume_argv(harness, session):
574
+ """The tokens that turn a launch command into a resume of ``session``.
575
+
576
+ Codex takes a subcommand, so its tokens follow ``exec``; Claude and agy
577
+ take a flag anywhere in their argv. Every caller must then check the
578
+ returned session id against ``session``: a harness that silently starts a
579
+ new conversation instead of resuming looks identical until that check,
580
+ and the evidence would be a second session claiming to be the first.
581
+ """
582
+ if not session:
583
+ raise ValueError("a resume needs a session id")
584
+ return {
585
+ "codex": ["resume", session],
586
+ "claude": ["--resume", session],
587
+ "agy": ["--conversation", session],
588
+ }[harness]