@giamme/forge 0.0.0-stage → 0.9.0

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 (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,586 @@
1
+ #!/usr/bin/env python3
2
+ """Offline runtime support; no provider requests or third-party dependencies."""
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ import uuid
16
+
17
+
18
+ def write_json(path: Path, value: dict) -> None:
19
+ temporary = path.with_name(path.name + '.' + uuid.uuid4().hex)
20
+ temporary.write_text(json.dumps(value, indent=2) + '\n')
21
+ temporary.replace(path)
22
+
23
+
24
+ def _dataset(hook: str, *args) -> None:
25
+ """Hand an execution to the run dataset (scripts/forge_dataset/).
26
+
27
+ Fail-open by contract: a capture problem lands in the dataset's errors.log and never
28
+ changes what this command prints, returns or exits with. begin()'s stdout is the
29
+ attempt path the shell reads, so nothing here may print.
30
+ """
31
+ if os.environ.get('FORGE_DATASET', 'on').strip().lower() in ('off', '0', 'no', 'false'):
32
+ return
33
+ try:
34
+ scripts = str(Path(__file__).resolve().parent)
35
+ if scripts not in sys.path:
36
+ sys.path.insert(0, scripts)
37
+ from forge_dataset import capture
38
+ getattr(capture, hook)(*args)
39
+ except Exception:
40
+ pass
41
+
42
+
43
+ def capability_help(cache: str, registry: str, recipe: str, command: list[str]) -> str:
44
+ binary = Path(command[0]).resolve()
45
+ stat = binary.stat()
46
+ identity = [
47
+ str(binary), stat.st_dev, stat.st_ino, stat.st_size,
48
+ stat.st_mtime_ns, stat.st_ctime_ns, command[1:],
49
+ hashlib.sha256(Path(registry).read_bytes()).hexdigest(),
50
+ hashlib.sha256(Path(recipe).read_bytes()).hexdigest(),
51
+ ]
52
+ key = hashlib.sha256(json.dumps(identity).encode()).hexdigest()
53
+ root = Path(cache)
54
+ root.mkdir(parents=True, exist_ok=True)
55
+ path = root / key
56
+ if path.exists():
57
+ return path.read_text()
58
+ result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
59
+ if result.returncode:
60
+ raise RuntimeError(result.stdout.decode(errors='replace'))
61
+ temporary = root / (key + '.' + uuid.uuid4().hex)
62
+ temporary.write_bytes(result.stdout)
63
+ temporary.replace(path)
64
+ return result.stdout.decode(errors='replace')
65
+
66
+
67
+ def begin(root: str, role: str) -> Path:
68
+ attempt = Path(root) / 'attempts' / (role + '-' + uuid.uuid4().hex)
69
+ attempt.mkdir(parents=True)
70
+ now = time.monotonic()
71
+ write_json(attempt / 'metrics.json', {
72
+ 'role': role, 'started': now, 'wall_started': time.time(),
73
+ 'phase': 'preparation', 'phase_started': now,
74
+ 'elapsed_s': {key: 0.0 for key in (
75
+ 'preparation', 'preflight', 'model', 'snapshot', 'verification',
76
+ )},
77
+ 'exit_code': None,
78
+ # A shadow review (forge-shadow.py) is spend like any other dispatch, but no
79
+ # result: report keeps it apart from the reviews whose verdicts decided something.
80
+ **({'shadow': True} if os.environ.get('FORGE_SHADOW') == '1' else {}),
81
+ })
82
+ _dataset('on_begin', str(attempt), root, role)
83
+ return attempt
84
+
85
+
86
+ def phase(attempt: str, name: str, rc: str | None = None) -> None:
87
+ path = Path(attempt) / 'metrics.json'
88
+ data = json.loads(path.read_text())
89
+ now = time.monotonic()
90
+ previous = data['phase']
91
+ duration = now - data['phase_started']
92
+ data['elapsed_s'][previous] = data['elapsed_s'].get(previous, 0) + duration
93
+ data.update(phase=name, phase_started=now)
94
+ if rc is not None:
95
+ data['exit_code'] = int(rc)
96
+ data['elapsed_s']['total'] = now - data['started']
97
+ write_json(path, data)
98
+
99
+
100
+ def token_count(value: object) -> int | None:
101
+ return value if type(value) is int and value >= 0 else None
102
+
103
+
104
+ # Harnesses whose log is a stream of JSON events rather than the answer itself.
105
+ STRUCTURED = ('codex', 'claude', 'openclaude', 'grok', 'opencode')
106
+
107
+
108
+ def _opencode_final(events: list) -> str:
109
+ """opencode's answer: the text of its last step, after its last tool call."""
110
+ final, step = '', []
111
+ for event in events:
112
+ kind = event.get('type')
113
+ if kind in ('step_start', 'tool_use'):
114
+ step = []
115
+ elif kind == 'text':
116
+ part = event.get('part')
117
+ text = part.get('text') if isinstance(part, dict) else None
118
+ if isinstance(text, str) and text.strip():
119
+ step.append(text)
120
+ final = '\n'.join(step)
121
+ return final
122
+
123
+
124
+ def extract(log: str, harness: str) -> tuple[str | None, dict[str, int | None]]:
125
+ usage = dict(input_tokens=None, cached_input_tokens=None, output_tokens=None)
126
+ final = None
127
+ if harness == 'opencode':
128
+ events = _events(log)[0]
129
+ for event in events:
130
+ part = event.get('part')
131
+ tokens = part.get('tokens') if event.get('type') == 'step_finish' and isinstance(part, dict) else None
132
+ if not isinstance(tokens, dict):
133
+ continue
134
+ cache = tokens.get('cache') if isinstance(tokens.get('cache'), dict) else {}
135
+ # input counts what was read from and written to the cache, as claude's does
136
+ for key, parts in (('input_tokens', (tokens.get('input'), cache.get('read'), cache.get('write'))),
137
+ ('cached_input_tokens', (cache.get('read'),)),
138
+ ('output_tokens', (tokens.get('output'), tokens.get('reasoning')))):
139
+ counts = [token_count(v) for v in parts if v is not None]
140
+ if counts and all(v is not None for v in counts):
141
+ usage[key] = (usage[key] or 0) + sum(counts)
142
+ return _opencode_final(events) or None, usage
143
+ for line in log.splitlines():
144
+ try:
145
+ event = json.loads(line)
146
+ except ValueError:
147
+ continue
148
+ if not isinstance(event, dict):
149
+ continue
150
+ native = event.get('usage')
151
+ if not isinstance(native, dict):
152
+ native = {}
153
+ if harness == 'codex' and event.get('type') == 'turn.completed':
154
+ for key in usage:
155
+ value = token_count(native.get(key))
156
+ if value is not None:
157
+ usage[key] = (usage[key] or 0) + value
158
+ if harness == 'codex' and event.get('type') == 'item.completed':
159
+ item = event.get('item')
160
+ if isinstance(item, dict) and item.get('type') == 'agent_message':
161
+ if isinstance(item.get('text'), str):
162
+ final = item['text']
163
+ # claude's and openclaude's stream-json and grok's streaming-messages-json end in the same
164
+ # `result` event, token buckets included.
165
+ if harness in ('claude', 'openclaude', 'grok') and event.get('type') == 'result':
166
+ if isinstance(event.get('result'), str):
167
+ final = event['result']
168
+ # Claude reports uncached, cache-read and cache-creation separately.
169
+ parts = [token_count(native.get(key)) for key in (
170
+ 'input_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens',
171
+ )]
172
+ usage['input_tokens'] = sum(parts) if all(v is not None for v in parts) else None
173
+ usage['cached_input_tokens'] = token_count(native.get('cache_read_input_tokens'))
174
+ usage['output_tokens'] = token_count(native.get('output_tokens'))
175
+ return final, usage
176
+
177
+
178
+ def finish(attempt: str, root: str, role: str, harness: str, rc: str) -> None:
179
+ try:
180
+ _finish(Path(attempt), Path(root), role, harness, rc)
181
+ finally:
182
+ # In `finally`, so an extraction or copy error above cannot lose the record.
183
+ _dataset('on_finish', str(attempt), str(root), role, harness, rc)
184
+
185
+
186
+ def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None:
187
+ log = root / (role + '.log')
188
+ last = root / (role + '.last')
189
+ structured = harness in STRUCTURED
190
+ text = log.read_text(errors='replace') if structured and log.exists() else ''
191
+ final, usage = extract(text, harness)
192
+ if not last.exists() or not last.stat().st_size:
193
+ if final is not None:
194
+ last.write_text(final + '\n')
195
+ elif not structured and harness != 'pipeline' and log.exists():
196
+ shutil.copyfile(log, last)
197
+ path = attempt / 'metrics.json'
198
+ data = json.loads(path.read_text())
199
+ prompt = root / (role + '.prompt')
200
+ prompt_ready = (attempt / 'prompt.ready').exists() and prompt.exists()
201
+ data.update(
202
+ harness=harness,
203
+ prompt_bytes=prompt.stat().st_size if prompt_ready else None,
204
+ **usage,
205
+ )
206
+ infra = root / (role + '.infra')
207
+ data['infra_class'] = None
208
+ if infra.exists():
209
+ for line in infra.read_text(errors='replace').splitlines():
210
+ if line.startswith('class='):
211
+ data['infra_class'] = line[len('class='):].strip() or None
212
+ for suffix in ('prompt', 'last', 'log', 'resolved', 'cmd', 'timedout', 'infra', 'orphans'):
213
+ if suffix == 'prompt' and not prompt_ready:
214
+ continue
215
+ source = root / (role + '.' + suffix)
216
+ if source.exists():
217
+ shutil.copyfile(source, attempt / source.name)
218
+ write_json(path, data)
219
+ # Include extraction and artifact-copy cost in total, even on failed attempts.
220
+ phase(str(attempt), 'complete', rc)
221
+
222
+
223
+ # --- infrastructure failure classification --------------------------------------
224
+ # Called by forge-dispatch.sh right after the harness exits, from the LOG: $ROLE.last
225
+ # only exists later (the EXIT trap's `finish` writes it), and a harness that failed on
226
+ # quota or auth never produced a final message to read anyway.
227
+ #
228
+ # The point of the class is to tell "the model's work was bad" apart from "the model
229
+ # never got to work" so a caller can pause and retry instead of spending an attempt. A
230
+ # false positive pauses a run that should have failed; a false negative is today's
231
+ # behaviour. So every pattern is a strong phrase, and prose is never scanned when the
232
+ # harness exited 0 with a real final message.
233
+ INFRA_TAIL_BYTES = 2048
234
+ RETRY_CAP_S = 24 * 3600
235
+ DETAIL_MAX = 200
236
+
237
+ _P = re.compile
238
+ # "HTTP 401", "HTTP/1.1 401", "status code 401", `"status": 429`, "error: 401", "code 401".
239
+ _HTTP_CODE = r"\b(?:http(?:/\d(?:\.\d)?)?|status(?:[ _]code)?|error(?: code)?|code)\b\W{0,10}%s\b"
240
+ INFRA_PATTERNS = (
241
+ ('auth', (
242
+ _P(r"\bunauthori[sz]ed\b", re.I),
243
+ _P(r"\binvalid[ _-]?(?:x-)?api[ _-]?key\b", re.I),
244
+ _P(r"\binvalid (?:access |auth |bearer )?(?:token|credentials?)\b", re.I),
245
+ _P(r"\b(?:not|never) (?:logged|signed) in\b", re.I),
246
+ _P(r"\blog ?in required\b|\bplease (?:run )?/?log ?in\b|\brun /login\b", re.I),
247
+ _P(r"\bauthenticat\w* (?:failed|failure|error|required)\b|\bfailed to authenticate\b|\bauthentication_error\b", re.I),
248
+ _P(r"\bAuthenticateToken\b"),
249
+ _P(r"\b(?:oauth |access |auth |refresh |api )?token (?:has |is |was )?(?:expired|revoked)\b|\bexpired (?:oauth |access |auth )?token\b", re.I),
250
+ _P(r"\b(?:no|missing) api[ _-]?key\b|\bcould not resolve authentication method\b", re.I),
251
+ _P(_HTTP_CODE % '40[13]' + r"|\b40[13]\b\W{0,3}(?:unauthori[sz]ed|forbidden)\b", re.I),
252
+ )),
253
+ ('quota', (
254
+ _P(r"\busage[ _-]?limit\b", re.I),
255
+ # "You've hit your limit", "hit your session limit", "reached the weekly limit"; but not
256
+ # "reached the context limit", which is the model's problem rather than the account's.
257
+ _P(r"\b(?:hit|reached|exceeded) (?:your|the) (?:(?:usage|session|weekly|daily|monthly|hourly|"
258
+ r"5-hour|five-hour|spend\w*|credit|token|message|request|plan|org\w*) )?limits?\b", re.I),
259
+ _P(r"(?<!context )(?<!rate )(?<!rate-)(?<!rate_)\blimit (?:reached|exceeded)\b", re.I),
260
+ _P(r"\b(?:session|weekly|daily|monthly|5-hour|five-hour) limit\b", re.I),
261
+ _P(r"\bquota (?:exceeded|exhausted|reached)\b|\bexceeded (?:your |the )?(?:current )?quota\b|\binsufficient[ _-]quota\b|\bout of (?:credits|quota)\b", re.I),
262
+ _P(r"\binsufficient (?:balance|credits?|funds)\b", re.I),
263
+ _P(r"\bcredit balance (?:is )?too low\b", re.I),
264
+ _P(r"\bpayment required\b|\bbilling (?:hard )?limit\b|\bbilling (?:issue|error|details|not active)\b|\bcheck your (?:plan and )?billing\b", re.I),
265
+ )),
266
+ ('rate_limit', (
267
+ _P(r"\brate[ _-]?limit(?:s|ed|ing)?\b", re.I),
268
+ _P(r"\btoo many requests\b", re.I),
269
+ _P(r"\boverloaded\b", re.I),
270
+ _P(r"\bat capacity\b", re.I),
271
+ _P(_HTTP_CODE % '429' + r"|\b429\b\W{0,3}too many\b", re.I),
272
+ )),
273
+ ('network', (
274
+ _P(r"\b(?:ECONNRESET|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|ENETDOWN)\b"),
275
+ _P(r"\bgetaddrinfo\b", re.I),
276
+ _P(r"\bconnection (?:refused|reset|timed out)\b|\bnetwork is unreachable\b|\bsocket hang up\b", re.I),
277
+ _P(r"\bcan['’]?t reach the api server\b|\bunable to connect\b|\bfailed to connect\b|\bfetch failed\b", re.I),
278
+ _P(r"\btemporary failure in name resolution\b|\bname or service not known\b", re.I),
279
+ _P(r"\bbad gateway\b|\bservice unavailable\b|\bgateway time-?out\b", re.I),
280
+ _P(r"\binvalid peer certificate\b", re.I),
281
+ )),
282
+ )
283
+ # api_error_status as claude reports it, used only when the text itself matched nothing.
284
+ STATUS_CLASS = {401: 'auth', 403: 'auth', 402: 'quota', 429: 'rate_limit', 529: 'rate_limit',
285
+ 502: 'network', 503: 'network', 504: 'network'}
286
+
287
+ _MONTHS = {m: i + 1 for i, m in enumerate(
288
+ ('jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'))}
289
+ _UNIT_S = {'d': 86400, 'h': 3600, 'm': 60, 's': 1}
290
+ _DURATION = r"(?:\d+(?:\.\d+)?\s*(?:days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![A-Za-z])[\s,]*(?:and\s+)?)+"
291
+ _EPOCH_RE = _P(r"\|\s*(\d{9,13})\b")
292
+ _IN_RE = _P(r"\b(?:try again|retry(?:ing)?|resets?|available|wait(?:ing)?|again|back)\s+(?:again\s+)?(?:in|after|for)\s+(?P<d>%s)" % _DURATION, re.I)
293
+ _WAIT_RE = _P(r"\bwait(?:ing)?\s+(?P<d>%s)" % _DURATION, re.I)
294
+ _AFTER_RE = _P(r"\bretry[ _-]?after\W{0,4}(?P<d>%s|\d+(?:\.\d+)?)" % _DURATION, re.I)
295
+ _CLOCK_RE = _P(
296
+ r"\b(?:resets?|try again|available again|retry)\s*(?:at|on|after)?\s*"
297
+ r"(?:(?P<mon>jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(?P<day>\d{1,2})(?:st|nd|rd|th)?,?\s*(?:at\s+)?)?"
298
+ r"(?P<h>\d{1,2})(?::(?P<m>\d{2}))?\s*(?:(?P<ap>[ap]\.?m\.?)(?![A-Za-z]))?"
299
+ r"(?:\s*\((?P<tz>[A-Za-z0-9_/+-]+)\))?", re.I)
300
+
301
+
302
+ def _duration_seconds(text: str) -> float | None:
303
+ total, found = 0.0, False
304
+ for number, unit in re.findall(r"(\d+(?:\.\d+)?)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![A-Za-z])", text, re.I):
305
+ total += float(number) * _UNIT_S[unit[0].lower()]
306
+ found = True
307
+ if not found and re.fullmatch(r"\s*\d+(?:\.\d+)?\s*", text):
308
+ return float(text) # bare "retry-after: 120" is seconds
309
+ return total if found else None
310
+
311
+
312
+ def _clock_seconds(match: 're.Match', now: float) -> float | None:
313
+ hour, minute, ap = int(match.group('h')), int(match.group('m') or 0), match.group('ap')
314
+ if ap:
315
+ if not 1 <= hour <= 12:
316
+ return None
317
+ hour = hour % 12 + (12 if ap.lower().startswith('p') else 0)
318
+ elif match.group('m') is None or hour > 23:
319
+ return None # "resets 3" with no am/pm and no minutes is not a time
320
+ if minute > 59:
321
+ return None
322
+ tz = None
323
+ if match.group('tz'):
324
+ try:
325
+ from zoneinfo import ZoneInfo
326
+ tz = ZoneInfo(match.group('tz'))
327
+ except Exception:
328
+ tz = None # unknown zone name or no tz database: fall back to local time
329
+ current = datetime.datetime.fromtimestamp(now, tz)
330
+ if match.group('mon'):
331
+ try:
332
+ target = current.replace(month=_MONTHS[match.group('mon').lower()[:3]], day=int(match.group('day')),
333
+ hour=hour, minute=minute, second=0, microsecond=0)
334
+ except ValueError:
335
+ return None
336
+ if target <= current:
337
+ target = target.replace(year=target.year + 1)
338
+ else:
339
+ target = current.replace(hour=hour, minute=minute, second=0, microsecond=0)
340
+ if target <= current:
341
+ target += datetime.timedelta(days=1)
342
+ return target.timestamp() - now
343
+
344
+
345
+ def retry_after(text: str, now: float | None = None) -> int | None:
346
+ """Seconds until the limit lifts, from the message text; None when it says nothing."""
347
+ now = time.time() if now is None else now
348
+ found: float | None = None
349
+ epoch = _EPOCH_RE.search(text)
350
+ if epoch:
351
+ stamp = float(epoch.group(1))
352
+ found = max(0.0, (stamp / 1000 if stamp > 1e11 else stamp) - now)
353
+ if found is None:
354
+ for regex in (_IN_RE, _WAIT_RE, _AFTER_RE):
355
+ match = regex.search(text)
356
+ if match:
357
+ found = _duration_seconds(match.group('d'))
358
+ if found is not None:
359
+ break
360
+ if found is None:
361
+ for match in _CLOCK_RE.finditer(text):
362
+ found = _clock_seconds(match, now)
363
+ if found is not None:
364
+ break
365
+ if found is None:
366
+ return None
367
+ return int(min(RETRY_CAP_S, max(0, found) + 0.999))
368
+
369
+
370
+ def _one_line(text: str, limit: int = DETAIL_MAX) -> str:
371
+ return ' '.join(text.split())[:limit]
372
+
373
+
374
+ def classify_text(text: str, status: object = None, now: float | None = None):
375
+ """(class, retry_after_or_None, detail) for failure text, or None. auth > quota > rate_limit > network."""
376
+ for name, patterns in INFRA_PATTERNS:
377
+ for pattern in patterns:
378
+ match = pattern.search(text)
379
+ if match:
380
+ start = text.rfind('\n', 0, match.start()) + 1
381
+ end = text.find('\n', match.end())
382
+ line = text[start:end if end >= 0 else len(text)]
383
+ if len(line) > DETAIL_MAX: # a minified blob: excerpt around the match
384
+ line = text[max(0, match.start() - 80):match.end() + 80]
385
+ return name, retry_after(text, now), _one_line(line)
386
+ if type(status) is int and status in STATUS_CLASS:
387
+ return STATUS_CLASS[status], retry_after(text, now), _one_line(text or 'HTTP %d' % status)
388
+ return None
389
+
390
+
391
+ def _events(text: str):
392
+ """JSON objects in the log (JSONL, one JSON document, or a JSON array) and the non-JSON lines."""
393
+ events, stray = [], []
394
+ stripped = text.strip()
395
+ if stripped[:1] in ('{', '['):
396
+ try:
397
+ whole = json.loads(stripped)
398
+ except ValueError:
399
+ whole = None
400
+ if isinstance(whole, dict):
401
+ return [whole], []
402
+ if isinstance(whole, list):
403
+ return [x for x in whole if isinstance(x, dict)], []
404
+ for line in text.splitlines():
405
+ candidate = line.strip()
406
+ if not candidate:
407
+ continue
408
+ if candidate[0] == '{':
409
+ try:
410
+ event = json.loads(candidate)
411
+ except ValueError:
412
+ continue # a damaged JSON line is not prose either
413
+ if isinstance(event, dict):
414
+ events.append(event)
415
+ continue
416
+ if candidate[0] == '[':
417
+ continue
418
+ stray.append(candidate)
419
+ return events, stray
420
+
421
+
422
+ def _codex_terminal_errors(events: list) -> list[str]:
423
+ """Messages of error/turn.failed events with nothing but more errors after them.
424
+
425
+ codex emits non-fatal `error` events (reconnect notices) and then carries on, so an
426
+ error counts only when it is where the run stopped: any later item, message or
427
+ completed turn shows it recovered.
428
+ """
429
+ pending: list[str] = []
430
+ for event in events:
431
+ kind = event.get('type')
432
+ if kind == 'error':
433
+ message = event.get('message')
434
+ pending.append(message if isinstance(message, str) else json.dumps(event))
435
+ elif kind == 'turn.failed':
436
+ error = event.get('error')
437
+ message = error.get('message') if isinstance(error, dict) else error
438
+ pending.append(message if isinstance(message, str) else json.dumps(event))
439
+ else:
440
+ pending = [] # any later activity means the run went on past the error
441
+ return pending
442
+
443
+
444
+ def _codex_final(events: list, last: str) -> str:
445
+ try:
446
+ path = Path(last) if last else None
447
+ if path is not None and path.is_file():
448
+ text = path.read_text(errors='replace')
449
+ if text.strip():
450
+ return text
451
+ except OSError:
452
+ pass
453
+ final = ''
454
+ for event in events:
455
+ item = event.get('item')
456
+ if event.get('type') == 'item.completed' and isinstance(item, dict) and item.get('type') == 'agent_message':
457
+ if isinstance(item.get('text'), str) and item['text'].strip():
458
+ final = item['text']
459
+ return final
460
+
461
+
462
+ def _tail(lines_or_text, size: int = INFRA_TAIL_BYTES) -> str:
463
+ text = lines_or_text if isinstance(lines_or_text, str) else '\n'.join(lines_or_text)
464
+ return text[-size:]
465
+
466
+
467
+ def classify(log: str, harness: str, rc: object, last: str = '', role: str = '', now: float | None = None):
468
+ """(class, retry_after_or_None, detail) when this exit was infrastructure, else None."""
469
+ try:
470
+ rc = int(rc)
471
+ except (TypeError, ValueError):
472
+ rc = 1
473
+ if harness == 'codex' and rc == 0 and _codex_final([], last).strip():
474
+ return None # the common case, and codex logs get large: don't even read it
475
+ try:
476
+ text = Path(log).read_text(errors='replace')
477
+ except OSError:
478
+ text = ''
479
+ events, stray = _events(text) if harness in STRUCTURED else ([], [])
480
+
481
+ if harness == 'codex':
482
+ final = _codex_final(events, last)
483
+ pending = _codex_terminal_errors(events)
484
+ if rc == 0:
485
+ if final.strip():
486
+ return None
487
+ if pending:
488
+ hit = classify_text('\n'.join(pending), now=now)
489
+ if hit:
490
+ return hit
491
+ return 'empty', None, 'codex finished with exit 0 but produced no final message'
492
+ return classify_text('\n'.join(pending + [_tail(stray)]), now=now)
493
+
494
+ if harness == 'opencode' and events:
495
+ # Like codex: an error event counts when the run produced no answer after it.
496
+ errors = []
497
+ for event in events:
498
+ error = event.get('error') if event.get('type') == 'error' else None
499
+ if isinstance(error, dict):
500
+ data = error.get('data') if isinstance(error.get('data'), dict) else {}
501
+ errors.append(str(data.get('message') or error.get('name') or json.dumps(error)))
502
+ final = _opencode_final(events)
503
+ if rc == 0:
504
+ if final.strip():
505
+ return None
506
+ hit = classify_text('\n'.join(errors), now=now) if errors else None
507
+ return hit or ('empty', None, 'opencode finished with exit 0 but produced no final message')
508
+ return classify_text('\n'.join(errors + [_tail(stray)]), now=now)
509
+
510
+ results = [e for e in events if e.get('type') == 'result'] if harness in ('claude', 'openclaude', 'grok') else []
511
+ if results:
512
+ event = results[-1]
513
+ body = event.get('result')
514
+ body = body if isinstance(body, str) else ''
515
+ # grok's error subtypes put the reason in errors[] and may leave result empty.
516
+ if not body.strip() and isinstance(event.get('errors'), list):
517
+ body = '\n'.join(str(e) for e in event['errors'])
518
+ if event.get('is_error') is True:
519
+ hit = classify_text(body, event.get('api_error_status'), now)
520
+ if hit or harness != 'grok' or event.get('stop_reason') != 'cancelled':
521
+ return hit
522
+ # Headless grok cancels the whole session when a tool call needs approval it
523
+ # cannot ask for, and exits 0 anyway. Not infrastructure: the model was working
524
+ # when the CLI stopped it, so what it did is still judged. Named, so nobody
525
+ # mistakes the cut-off for a finished run.
526
+ asked = [line.strip() for line in stray if line.strip().startswith('permission required:')]
527
+ if asked:
528
+ return 'cancelled', None, 'grok cancelled the session after a tool call needed approval: ' + asked[-1]
529
+ return 'cancelled', None, 'grok cancelled the session before it finished'
530
+ if rc == 0 and not body.strip():
531
+ return 'empty', None, '%s finished with exit 0 but its final result is empty' % harness
532
+ return None
533
+
534
+ # Raw-log harness (antigravity, an opencode or openclaude from before their streams, or a
535
+ # claude that died before emitting its result): the log is the message, so only the tail of a
536
+ # failing run is evidence. A successful run's prose is never scanned.
537
+ if rc != 0:
538
+ return classify_text(_tail(text), now=now)
539
+ if not text.strip():
540
+ return 'empty', None, '%s finished with exit 0 but wrote nothing' % harness
541
+ return None
542
+
543
+
544
+ def classify_command(log: str, harness: str, rc: str, last: str = '', role: str = '') -> None:
545
+ try:
546
+ hit = classify(log, harness, rc, last, role)
547
+ except Exception: # classification is advisory and must never change an exit code
548
+ hit = None
549
+ if hit:
550
+ name, after, detail = hit
551
+ print('%s\t%s\t%s' % (name, '' if after is None else after, detail.replace('\t', ' ')))
552
+
553
+
554
+ def report(root: str) -> None:
555
+ records = []
556
+ for path in sorted(Path(root).rglob('attempts/*/metrics.json')):
557
+ record = json.loads(path.read_text())
558
+ record['artifact'] = str(path)
559
+ records.append(record)
560
+ print(json.dumps({'attempts': records}, indent=2))
561
+
562
+
563
+ def main() -> None:
564
+ command, *args = sys.argv[1:]
565
+ try:
566
+ if command == 'help':
567
+ print(capability_help(*args[:3], args[3:]), end='')
568
+ elif command == 'begin':
569
+ print(begin(*args))
570
+ elif command == 'phase':
571
+ phase(*args)
572
+ elif command == 'finish':
573
+ finish(*args)
574
+ elif command == 'report':
575
+ report(*args)
576
+ elif command == 'classify':
577
+ classify_command(*args)
578
+ else:
579
+ raise ValueError('unknown runtime command: ' + command)
580
+ except (OSError, ValueError, RuntimeError) as error:
581
+ print('forge: ' + str(error), file=sys.stderr)
582
+ sys.exit(3)
583
+
584
+
585
+ if __name__ == '__main__':
586
+ main()