@giamme/forge 0.0.0-stage → 0.8.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 (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. package/ui/forge_ui/static/style.css +618 -0
@@ -0,0 +1,665 @@
1
+ #!/usr/bin/env bash
2
+ # forge-dispatch.sh — resolve a forge spec (alias[:effort[:harness]]) into a real
3
+ # CLI invocation and run it. Harness-agnostic: needs only bash + coreutils, so it
4
+ # behaves identically whether the orchestrator is codex, claude, openclaude or
5
+ # opencode.
6
+ #
7
+ # Usage:
8
+ # forge-dispatch.sh doctor (harnesses: codex claude openclaude opencode antigravity grok)
9
+ # forge-dispatch.sh dwarf <spec> --prompt-file <f> [--repo <dir>] [--run-dir <d>] [--yolo] [--dry-run]
10
+ # forge-dispatch.sh qa <spec> --prompt-file <f> [--repo <dir>] [--run-dir <d>] [--yolo] [--dry-run]
11
+ # [--native-review [--review-base <ref>]]
12
+ # any role: [--no-ripwire] [--ripwire-query-file <requirements>]
13
+ # any role: [--timeout <seconds>] 0 disables the watchdog
14
+ #
15
+ # Timeout precedence: --timeout, then FORGE_TIMEOUT (both used as given, 0 included),
16
+ # then the registry row's optional 5th `timeout` column, then the built-in 2700 (45m).
17
+ # A registry or built-in value is scaled by the resolved effort: <= high x1,
18
+ # xhigh x4/3, max/ultra/ultracode x2. The limit and its source are recorded as
19
+ # `timeout=` / `timeout_source=` in $ROLE.resolved.
20
+ #
21
+ # Environment: FORGE_REGISTRY alternate registry file (default: <skill>/registry.tsv)
22
+ # FORGE_ORPHAN_GUARD off = do not run the harness in its own process group
23
+ # (default on; also skipped under FORGE_FRACTAL_RUN)
24
+ #
25
+ # Exit codes: 0 ok | 2 usage/resolution error | 3 harness missing or unusable
26
+ # 4 backend ran but failed | 7 backend exceeded --timeout and was killed
27
+ # 8 infrastructure failure (auth, quota, rate limit, network, or an empty
28
+ # answer): the model never got to do its work. $ROLE.infra says which.
29
+ # A DWARF that exits 0 with an empty final message also writes $ROLE.infra
30
+ # (class=empty) but still exits 0; consult the file only if the diff is empty.
31
+ # A process still alive in the harness's group after it exited is killed and listed in
32
+ # $ROLE.orphans (only written when there is something to list).
33
+ set -uo pipefail
34
+
35
+ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
36
+ REGISTRY="${FORGE_REGISTRY:-$SKILL_DIR/registry.tsv}"
37
+ RUNTIME="$SKILL_DIR/scripts/forge-runtime.py"
38
+ GUARD="$SKILL_DIR/scripts/forge-guard.py"
39
+
40
+ die() { printf 'forge: %s\n' "$1" >&2; exit "${2:-2}"; }
41
+ note() { printf 'forge: %s\n' "$*" >&2; }
42
+
43
+ # --- effort ladders -----------------------------------------------------------
44
+ # codex does NOT validate model_reasoning_effort: a typo runs at some silent
45
+ # default instead of erroring. So forge validates before dispatch — a run at the
46
+ # wrong effort is expensive and invisible, which is exactly the failure worth
47
+ # spending ten lines to prevent.
48
+ ladder_for() {
49
+ case "$1" in
50
+ codex) echo "low medium high xhigh max ultra" ;;
51
+ claude) echo "low medium high xhigh max" ;;
52
+ openclaude) echo "low medium high xhigh max ultracode" ;;
53
+ antigravity) echo "low medium high" ;;
54
+ grok) echo "low medium high xhigh max" ;; # each model offers a subset (registry)
55
+ opencode) echo "" ;; # --variant is provider-specific; forwarded verbatim
56
+ *) echo "" ;;
57
+ esac
58
+ }
59
+
60
+ # The harness name is not always the binary name.
61
+ binary_for() {
62
+ case "$1" in antigravity) echo agy ;; *) echo "$1" ;; esac
63
+ }
64
+
65
+ # Every effort word forge recognizes, weakest first. Used only to tell "you asked
66
+ # for more thinking than this pairing offers" (clamp, keep going) apart from "that
67
+ # is not an effort level" (stop). ultra and ultracode are each their harness's top
68
+ # tier under different names, so they rank together.
69
+ CANON="low medium high xhigh max ultra ultracode"
70
+ rank_of() {
71
+ case "$1" in
72
+ low) echo 0 ;; medium) echo 1 ;; high) echo 2 ;; xhigh) echo 3 ;;
73
+ max) echo 4 ;; ultra|ultracode) echo 5 ;; *) echo -1 ;;
74
+ esac
75
+ }
76
+
77
+ idx_in() { # idx_in <needle> <space-separated haystack> -> index or -1
78
+ local n="$1" i=0 w
79
+ for w in $2; do [ "$w" = "$n" ] && { echo "$i"; return; }; i=$((i+1)); done
80
+ echo -1
81
+ }
82
+
83
+ # --- registry lookup ----------------------------------------------------------
84
+ # The fourth and fifth fields print "-" when empty, because `IFS=$'\t' read` treats tab as
85
+ # whitespace and would collapse an empty field into its neighbour.
86
+ lookup() { # lookup <alias> [harness] -> "harness<TAB>model<TAB>ceiling<TAB>timeout"
87
+ local alias="$1" want="${2:-}"
88
+ awk -F'\t' -v a="$alias" -v h="$want" '
89
+ /^#/ || NF < 3 { next }
90
+ $1 == a && (h == "" || $2 == h) {
91
+ t = $5; gsub(/[ \t\r]+$/, "", t)
92
+ print $2 "\t" $3 "\t" ($4 == "" ? "-" : $4) "\t" (t == "" ? "-" : t); exit
93
+ }
94
+ ' "$REGISTRY"
95
+ }
96
+
97
+ known_harness() {
98
+ case "$1" in codex|claude|openclaude|opencode|antigravity|grok) return 0 ;; *) return 1 ;; esac
99
+ }
100
+
101
+ # --- doctor -------------------------------------------------------------------
102
+ doctor() {
103
+ local rc=0 h bin
104
+ printf '%-12s %-10s %s\n' HARNESS STATUS DETAIL
105
+ for h in codex claude openclaude opencode antigravity grok; do
106
+ bin="$(command -v "$(binary_for "$h")" 2>/dev/null)"
107
+ if [ -z "$bin" ]; then
108
+ printf '%-12s %-10s %s\n' "$h" MISSING "$(binary_for "$h") not on PATH"; rc=1; continue
109
+ fi
110
+ printf '%-12s %-10s %s\n' "$h" "found" "$bin"
111
+ done
112
+ python3 "$SKILL_DIR/scripts/forge-ripwire.py" --doctor
113
+ cat <<'EOT'
114
+
115
+ Binary presence is not reachability. A harness can be installed and still fail on
116
+ auth or billing (opencode in particular fails this way), and that failure only
117
+ shows up mid-dispatch. To prove a pairing end to end before spending a real run:
118
+
119
+ forge-dispatch.sh dwarf <spec> --prompt-file /dev/stdin --repo <scratch dir> \
120
+ <<< 'Reply with exactly: FORGE_OK'
121
+ EOT
122
+ return $rc
123
+ }
124
+
125
+ [ $# -ge 1 ] || die "usage: forge-dispatch.sh <doctor|dwarf|qa|planner> [spec] [options]"
126
+ ROLE="$1"; shift
127
+ if [ "$ROLE" = report ]; then python3 "$RUNTIME" report "${1:?report needs a run directory}"; exit $?; fi
128
+ PREFLIGHT=0
129
+ if [ "$ROLE" = doctor ]; then
130
+ if [ $# = 0 ]; then doctor; exit $?; fi
131
+ [ "${1:-}" = --spec ] || die "doctor accepts --spec <spec> [--role dwarf|qa|planner]"
132
+ SPEC_CHECK="${2:?--spec needs a spec}"; shift 2
133
+ ROLE=dwarf
134
+ if [ "${1:-}" = --role ]; then ROLE="${2:?}"; shift 2; fi
135
+ PREFLIGHT=1
136
+ CHECK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/forge-doctor.XXXXXX")" || exit 3
137
+ trap 'rm -rf "$CHECK_DIR"' EXIT
138
+ echo 'Offline capability check' > "$CHECK_DIR/input"
139
+ set -- "$SPEC_CHECK" --run-dir "$CHECK_DIR" --prompt-file "$CHECK_DIR/input" "$@"
140
+ fi
141
+ case "$ROLE" in dwarf|qa|planner) ;; *) die "unknown role '$ROLE' (expected dwarf, qa, planner or doctor)" ;; esac
142
+
143
+ # qa and planner both read the repo and write nothing to it: a reviewer that can
144
+ # edit the diff is no longer an independent check of it, and a planner that edits
145
+ # has quietly become the dwarf. They share one permission profile.
146
+ read_only_role() { case "$ROLE" in qa|planner) return 0 ;; *) return 1 ;; esac; }
147
+ [ $# -ge 1 ] || die "role '$ROLE' needs a spec, e.g. sol:xhigh:openclaude"
148
+ SPEC="$1"; shift
149
+
150
+ RIPWIRE_QUERY=""
151
+ OUTPUT=full
152
+ REPO="$PWD"; RUN_DIR=""; PROMPT_FILE=""; YOLO=0; DRY=0; REVIEW_BASE=""; PROMPT_VIA_STDIN=0; NATIVE_REVIEW=0; AGY_TIMEOUT="30m"; TIMEOUT_FLAG=""
153
+ while [ $# -gt 0 ]; do
154
+ case "$1" in
155
+ --no-ripwire) export FORGE_RIPWIRE=off; shift ;;
156
+ --ripwire-query-file) RIPWIRE_QUERY="${2:?}"; shift 2 ;;
157
+ --output) OUTPUT="${2:?}"; shift 2 ;;
158
+ --repo) REPO="${2:?--repo needs a value}"; shift 2 ;;
159
+ --run-dir) RUN_DIR="${2:?--run-dir needs a value}"; shift 2 ;;
160
+ --prompt-file) PROMPT_FILE="${2:?--prompt-file needs a value}"; shift 2 ;;
161
+ --review-base) REVIEW_BASE="${2:?--review-base needs a value}"; shift 2 ;;
162
+ --native-review) NATIVE_REVIEW=1; shift ;;
163
+ --agy-timeout) AGY_TIMEOUT="${2:?--agy-timeout needs a value}"; shift 2 ;;
164
+ --timeout) TIMEOUT_FLAG="${2:?--timeout needs a value in seconds, 0 to disable}"; shift 2 ;;
165
+ --yolo) YOLO=1; shift ;;
166
+ --dry-run) DRY=1; shift ;;
167
+ *) die "unknown option '$1'" ;;
168
+ esac
169
+ done
170
+
171
+ case "$OUTPUT" in summary|full) ;; *) die "--output must be summary or full" ;; esac
172
+ # Where the limit comes from is part of the record, so --timeout and FORGE_TIMEOUT are kept
173
+ # apart from the registry and built-in values instead of being folded into one variable.
174
+ case "$TIMEOUT_FLAG" in *[!0-9]*) die "--timeout must be a nonnegative integer" ;; esac
175
+ case "${FORGE_TIMEOUT:-}" in *[!0-9]*) die "FORGE_TIMEOUT must be a nonnegative integer" ;; esac
176
+ [ -z "${FORGE_REGISTRY:-}" ] || [ -f "$REGISTRY" ] || die "FORGE_REGISTRY '$REGISTRY' is not a file"
177
+
178
+ # --- resolve spec -------------------------------------------------------------
179
+ ALIAS="${SPEC%%:*}"; REST="${SPEC#*:}"
180
+ if [ "$REST" = "$SPEC" ]; then EFFORT=""; HARNESS=""
181
+ else
182
+ EFFORT="${REST%%:*}"; H="${REST#*:}"
183
+ if [ "$H" = "$REST" ]; then HARNESS=""; else HARNESS="$H"; fi
184
+ fi
185
+ [ -n "$ALIAS" ] || die "empty alias in spec '$SPEC'"
186
+ [ -z "$HARNESS" ] || known_harness "$HARNESS" \
187
+ || die "unknown harness '$HARNESS' (expected codex, claude, openclaude, opencode, antigravity or grok)"
188
+
189
+ ROW="$(lookup "$ALIAS" "$HARNESS")"
190
+ REG_TIMEOUT="-"
191
+ if [ -n "$ROW" ]; then
192
+ IFS=$'\t' read -r HARNESS MODEL CEILING REG_TIMEOUT <<< "$ROW"
193
+ PASSTHRU=0
194
+ else
195
+ # Not in the registry. Treat the alias as a literal model id — but only with an
196
+ # explicit harness, because there is no sane default for a model forge has
197
+ # never seen, and guessing wrong burns a real dispatch.
198
+ [ -n "$HARNESS" ] || die "'$ALIAS' is not a known alias. Either add it to $REGISTRY or name the harness explicitly, e.g. --dwarf $ALIAS:high:opencode"
199
+ if [ -n "$(awk -F'\t' -v a="$ALIAS" '!/^#/ && $1 == a {print; exit}' "$REGISTRY")" ]; then
200
+ note "alias '$ALIAS' has no row for harness '$HARNESS' — using it as a literal model id"
201
+ else
202
+ note "'$ALIAS' is not in the registry — using it as a literal model id on $HARNESS"
203
+ fi
204
+ MODEL="$ALIAS"; CEILING="-"; PASSTHRU=1
205
+ fi
206
+
207
+ if [ "$NATIVE_REVIEW" = 1 ] && { [ "$ROLE" != qa ] || [ "$HARNESS" != codex ]; }; then
208
+ die "--native-review requires QA on codex"
209
+ fi
210
+
211
+ # --- resolve effort -----------------------------------------------------------
212
+ LADDER="$(ladder_for "$HARNESS")"
213
+ CLAMPED=""; NO_EFFORT=0
214
+
215
+ # Expand the registry's effort column into the concrete set this pairing accepts.
216
+ # The four forms exist because real backends differ in more than a ceiling: some
217
+ # models offer low and high but no medium, and some take no effort flag at all.
218
+ ALLOWED=""
219
+ case "$CEILING" in
220
+ "-") ALLOWED="" ;; # forwarded verbatim, no validation
221
+ none) NO_EFFORT=1 ;;
222
+ "<="*) top="${CEILING#<=}"
223
+ for w in $LADDER; do ALLOWED="$ALLOWED $w"; [ "$w" = "$top" ] && break; done ;;
224
+ *) ALLOWED="$(printf '%s' "$CEILING" | tr ',' ' ')" ;;
225
+ esac
226
+
227
+ if [ "$NO_EFFORT" = 1 ]; then
228
+ # Not a clamp to report: this model has no effort dial, so an effort the user
229
+ # typed is simply inapplicable. Say so once rather than failing the dispatch.
230
+ [ -n "$EFFORT" ] && note "$MODEL on $HARNESS takes no effort setting — ignoring ':$EFFORT'"
231
+ EFFORT=""
232
+ else
233
+ if [ -z "$EFFORT" ]; then
234
+ # Build at medium, review high: a reviewer that thinks less than the
235
+ # implementer tends to rubber-stamp, which defeats the point of a second model.
236
+ # Planning is the stage where thinking pays for itself most cheaply — a bad
237
+ # plan is executed at full cost by every dwarf downstream of it.
238
+ case "$ROLE" in dwarf) EFFORT="medium" ;; qa|planner) EFFORT="xhigh" ;; esac
239
+ DEFAULTED=1
240
+ [ -n "$LADDER" ] || EFFORT="" # opencode: no ladder, let the provider choose
241
+ else
242
+ DEFAULTED=0
243
+ fi
244
+
245
+ if [ -n "$EFFORT" ] && [ -n "$ALLOWED" ]; then
246
+ if [ "$(idx_in "$EFFORT" "$ALLOWED")" -lt 0 ]; then
247
+ want="$(rank_of "$EFFORT")"
248
+ [ "$want" -ge 0 ] || die "'$EFFORT' is not a recognized effort (known: $CANON)"
249
+ # Step down to the strongest accepted effort at or below what was asked;
250
+ # if even the weakest is stronger, take that. Asking for more thinking than
251
+ # a model offers should cost you thinking, not the whole run.
252
+ pick=""
253
+ for w in $ALLOWED; do
254
+ r="$(rank_of "$w")"
255
+ [ "$r" -le "$want" ] && pick="$w"
256
+ done
257
+ if [ -z "$pick" ]; then for w in $ALLOWED; do pick="$w"; break; done; fi
258
+ [ "$DEFAULTED" = 1 ] || CLAMPED="$EFFORT -> $pick"
259
+ EFFORT="$pick"
260
+ fi
261
+ fi
262
+ fi
263
+
264
+ if [ -n "$EFFORT" ] && [ -n "$LADDER" ] && [ "$(idx_in "$EFFORT" "$LADDER")" -lt 0 ]; then
265
+ die "invalid effort '$EFFORT' for $HARNESS"
266
+ fi
267
+
268
+ # --- resolve timeout ----------------------------------------------------------
269
+ # Explicit (--timeout, FORGE_TIMEOUT) wins as given, including 0. Otherwise the registry
270
+ # row's `timeout` column, then 2700. Only those two defaults scale with the resolved
271
+ # effort, because an xhigh/max run is expected to think for longer; a number the caller
272
+ # typed is never second-guessed.
273
+ if [ -n "$TIMEOUT_FLAG" ]; then LIMIT="$TIMEOUT_FLAG"; LIMIT_SOURCE=explicit
274
+ elif [ -n "${FORGE_TIMEOUT:-}" ]; then LIMIT="$FORGE_TIMEOUT"; LIMIT_SOURCE=env
275
+ elif [ "$REG_TIMEOUT" != "-" ]; then
276
+ case "$REG_TIMEOUT" in ''|*[!0-9]*) die "registry timeout '$REG_TIMEOUT' for $ALIAS on $HARNESS is not a nonnegative integer ($REGISTRY)" ;; esac
277
+ LIMIT="$REG_TIMEOUT"; LIMIT_SOURCE=registry
278
+ else LIMIT=2700; LIMIT_SOURCE=default
279
+ fi
280
+ # Normalize ("007" -> 7) so arithmetic is not read as octal and "00" really means off.
281
+ [ "${#LIMIT}" -gt 9 ] || LIMIT=$((10#$LIMIT))
282
+ if [ "$LIMIT_SOURCE" = registry ] || [ "$LIMIT_SOURCE" = default ]; then
283
+ if [ "$LIMIT" != 0 ] && [ "${#LIMIT}" -le 9 ]; then
284
+ effort_rank=-1; [ -z "$EFFORT" ] || effort_rank="$(rank_of "$EFFORT")"
285
+ if [ "$effort_rank" -ge 4 ]; then LIMIT=$((LIMIT * 2)); LIMIT_SOURCE="$LIMIT_SOURCE+effort-scaled"
286
+ elif [ "$effort_rank" -eq 3 ]; then LIMIT=$((LIMIT * 4 / 3)); LIMIT_SOURCE="$LIMIT_SOURCE+effort-scaled"
287
+ fi
288
+ fi
289
+ fi
290
+
291
+ # --- run dir & prompt ---------------------------------------------------------
292
+ [ -d "$REPO" ] || die "--repo '$REPO' is not a directory"
293
+ REPO="$(cd "$REPO" && pwd)"
294
+ if [ -z "$RUN_DIR" ]; then
295
+ RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/forge-XXXXXX")" || die "could not create run dir"
296
+ fi
297
+ mkdir -p "$RUN_DIR" || die "could not create run dir '$RUN_DIR'"
298
+ RUN_DIR="$(cd "$RUN_DIR" && pwd)" # normalize, so the containment check below is real
299
+ # The run dir deliberately lives outside the repo: anything forge writes inside
300
+ # the working tree would show up in the dwarf's own diff and pollute QA's input.
301
+ case "$RUN_DIR" in "$REPO"|"$REPO"/*) die "--run-dir must be outside --repo, or it lands in the diff under review" ;; esac
302
+
303
+ # Start an immutable attempt record before preparation, including failures.
304
+ ATTEMPT=""
305
+ if [ "$PREFLIGHT" != 1 ] && [ "$DRY" != 1 ]; then
306
+ ATTEMPT="$(python3 "$RUNTIME" begin "$RUN_DIR" "$ROLE")" || exit 3
307
+ rm -f "$RUN_DIR/$ROLE.last" "$RUN_DIR/$ROLE.log" "$RUN_DIR/$ROLE.resolved" "$RUN_DIR/$ROLE.cmd" "$RUN_DIR/$ROLE.timedout" "$RUN_DIR/$ROLE.infra" "$RUN_DIR/$ROLE.orphans"
308
+ trap 'exit 143' TERM
309
+ trap 'exit 130' INT
310
+ # GUARD_ON is 1 only when the harness was started in its own process group (see the
311
+ # launch below), in which case $hpid is also the group id and the whole group is
312
+ # signalled. Every wait here is bounded: this runs on the way out of an interrupted
313
+ # dispatch and must not be the thing that hangs it.
314
+ exit_cleanup() {
315
+ if [ -n "${hpid:-}" ] && kill -0 "$hpid" 2>/dev/null; then
316
+ [ "${GUARD_ON:-0}" != 1 ] || kill -s TERM -- "-$hpid" 2>/dev/null
317
+ pkill -TERM -P "$hpid" 2>/dev/null; kill -TERM "$hpid" 2>/dev/null
318
+ if [ "${GUARD_ON:-0}" = 1 ]; then sleep 1; kill -s KILL -- "-$hpid" 2>/dev/null; fi
319
+ elif [ "${GUARD_ON:-0}" = 1 ] && [ "${reaped:-0}" != 1 ] && [ -n "${hpid:-}" ]; then
320
+ # Interrupted after the harness exited but before the normal reap.
321
+ python3 "$GUARD" reap "$hpid" "$RUN_DIR/$ROLE.orphans" >/dev/null 2>&1
322
+ fi
323
+ if [ -n "${wpid:-}" ]; then pkill -P "$wpid" 2>/dev/null; kill "$wpid" 2>/dev/null; fi
324
+ }
325
+ trap 'result=$?; exit_cleanup; python3 "$RUNTIME" finish "$ATTEMPT" "$RUN_DIR" "$ROLE" "$HARNESS" "$result"; exit "$result"' EXIT
326
+ fi
327
+ [ -n "$PROMPT_FILE" ] || die "role '$ROLE' needs --prompt-file"
328
+ case "$PROMPT_FILE" in -|/dev/stdin) cat > "$RUN_DIR/$ROLE.prompt" ;;
329
+ *) [ -r "$PROMPT_FILE" ] || die "cannot read --prompt-file '$PROMPT_FILE'"
330
+ if [ "$PROMPT_FILE" != "$RUN_DIR/$ROLE.prompt" ]; then
331
+ prompt_copy="$(mktemp "$RUN_DIR/.prompt.XXXXXX")" || die "cannot create prompt copy"
332
+ cat "$PROMPT_FILE" > "$prompt_copy" || { rm -f "$prompt_copy"; die "cannot materialize prompt"; }
333
+ mv "$prompt_copy" "$RUN_DIR/$ROLE.prompt" || die "cannot install prompt copy"
334
+ fi ;;
335
+ esac
336
+ # Reusing the delivered prompt must not accumulate advisory sections or constraints.
337
+ if [ -f "$RUN_DIR/$ROLE.delivered" ] && cmp -s "$RUN_DIR/$ROLE.prompt" "$RUN_DIR/$ROLE.delivered"; then
338
+ cp "$RUN_DIR/$ROLE.original" "$RUN_DIR/$ROLE.prompt"
339
+ fi
340
+ cp "$RUN_DIR/$ROLE.prompt" "$RUN_DIR/$ROLE.original"
341
+ [ -n "$RIPWIRE_QUERY" ] || RIPWIRE_QUERY="$RUN_DIR/$ROLE.original"
342
+ PROMPT_FILE="$RUN_DIR/$ROLE.prompt"
343
+ [ -z "$ATTEMPT" ] || touch "$ATTEMPT/prompt.ready"
344
+ # Streaming POSIX character class, using the caller's locale as Bash did.
345
+ grep -q '[^[:space:]]' "$PROMPT_FILE" || die "prompt file '$PROMPT_FILE' is empty"
346
+ PROMPT=""
347
+
348
+ LAST="$RUN_DIR/$ROLE.last"
349
+ LOG="$RUN_DIR/$ROLE.log"
350
+
351
+ # Some harnesses cannot do things the standard role prompt asks for. Telling the
352
+ # model up front beats letting it discover the wall mid-run: on antigravity a
353
+ # denied command does not degrade gracefully, it aborts the whole dispatch, so a
354
+ # dwarf that dutifully tries to run the test suite ends with an error and an
355
+ # empty diff after burning the full quota.
356
+ CONSTRAINT=""
357
+ if [ "$HARNESS" = "antigravity" ] && [ "$YOLO" != 1 ]; then
358
+ CONSTRAINT="This environment cannot run terminal commands: permission is denied for every
359
+ shell command, and a single attempt aborts this run entirely. Do not try to run tests,
360
+ linters, git, or any other command. Verify your work by reading the code instead, and say
361
+ plainly in your final message that it is unverified by execution."
362
+ fi
363
+ if [ -n "$CONSTRAINT" ]; then
364
+ printf '\n%s\n' "$CONSTRAINT" >> "$RUN_DIR/$ROLE.prompt"
365
+ PROMPT="$(cat "$RUN_DIR/$ROLE.prompt")"
366
+ PROMPT_FILE="$RUN_DIR/$ROLE.prompt"
367
+ note "antigravity without --yolo cannot run commands; told the $ROLE not to try"
368
+ fi
369
+
370
+ if [ -n "$ATTEMPT" ]; then
371
+ python3 "$SKILL_DIR/scripts/forge-ripwire.py" --repo "$REPO" --role "$ROLE" \
372
+ --prompt "$PROMPT_FILE" --query "$RIPWIRE_QUERY" --baseline "$REVIEW_BASE" \
373
+ --artifacts "$ATTEMPT" --native "$NATIVE_REVIEW" || note "Ripwire preparation failed; continuing"
374
+ fi
375
+ cp "$PROMPT_FILE" "$RUN_DIR/$ROLE.delivered"
376
+ case "$HARNESS" in opencode|antigravity) PROMPT="$(cat "$PROMPT_FILE")" ;; esac
377
+
378
+ # --- build argv ---------------------------------------------------------------
379
+ CMD=()
380
+ case "$HARNESS" in
381
+ codex)
382
+ if [ "$ROLE" = "qa" ] && [ "$NATIVE_REVIEW" = 1 ]; then
383
+ # codex's built-in reviewer. Its scope flags are mutually exclusive with a
384
+ # custom PROMPT, so this mode trades away the goal context: it can judge
385
+ # whether the diff is correct, but not whether it is the change that was
386
+ # actually asked for. `codex exec review` also has no --cd, hence the
387
+ # subshell cd below.
388
+ CMD=(codex exec review --json -m "$MODEL" --skip-git-repo-check -o "$LAST")
389
+ [ -n "$EFFORT" ] && CMD+=(-c "model_reasoning_effort=$EFFORT")
390
+ if [ -n "$REVIEW_BASE" ]; then CMD+=(--base "$REVIEW_BASE"); else CMD+=(--uncommitted); fi
391
+ if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-bypass-approvals-and-sandbox); fi
392
+ else
393
+ CMD=(codex exec --json -m "$MODEL" -C "$REPO" --skip-git-repo-check -o "$LAST")
394
+ [ -n "$EFFORT" ] && CMD+=(-c "model_reasoning_effort=$EFFORT")
395
+ # --approve-for-me and --dangerously-bypass-... are mutually exclusive.
396
+ if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-bypass-approvals-and-sandbox)
397
+ elif [ "$ROLE" = "dwarf" ]; then CMD+=(--approve-for-me)
398
+ else CMD+=(-s read-only); fi
399
+ CMD+=(-)
400
+ PROMPT_VIA_STDIN=1
401
+ fi
402
+ ;;
403
+ claude|openclaude)
404
+ # The run dir sits outside the repo (so it stays out of the diff), which also
405
+ # puts it outside the harness's default reach — without this the agent
406
+ # refuses to open the very artifacts forge just wrote for it.
407
+ CMD=("$HARNESS" -p --model "$MODEL" --add-dir "$RUN_DIR")
408
+ [ "$HARNESS" = claude ] && CMD+=(--output-format json)
409
+ [ -n "$EFFORT" ] && CMD+=(--effort "$EFFORT")
410
+ if [ "$YOLO" = 1 ]; then
411
+ CMD+=(--dangerously-skip-permissions)
412
+ else
413
+ # `acceptEdits`, not `auto`. Re-measured against this machine's CLIs:
414
+ # acceptEdits allows both Edit and Bash, whereas `auto` allows Bash but
415
+ # still prompts for Edit — and a headless dwarf has nobody to answer that
416
+ # prompt, so it ends with "I need your permission to edit f.py" and an
417
+ # empty diff. The failure is silent: the harness exits 0 and forge sees a
418
+ # successful run that changed nothing.
419
+ CMD+=(--permission-mode acceptEdits)
420
+ # Neither mode alone is enough, and the gap is silent in both directions:
421
+ # `auto` allows shell but prompts for Edit, so a dwarf ends with "I need
422
+ # your permission to edit" and an empty diff; `acceptEdits` allows Edit but
423
+ # still prompts for any non-trivial shell command, so a dwarf writes code
424
+ # and then stalls on "awaiting approval to run pytest" without ever
425
+ # verifying it. acceptEdits plus an explicit Bash allowance gives both, and
426
+ # QA's --disallowed-tools below still overrides the edit permission.
427
+ CMD+=(--allowedTools "Bash")
428
+ # Disable editing tools as defense in depth. Bash can still write files;
429
+ # runners use disposable snapshots and fingerprint checks for acceptance.
430
+ read_only_role && CMD+=(--disallowed-tools "Edit,Write,NotebookEdit")
431
+ fi
432
+ # The prompt goes on stdin, not argv. --disallowed-tools and friends are
433
+ # variadic, so any positional after them is silently eaten as another tool
434
+ # name — the run then dies on "Input must be provided" if you are lucky, or
435
+ # reviews nothing at all if you are not. stdin also lifts the argv length
436
+ # limit, which a long goal plus an inlined diff will otherwise hit.
437
+ PROMPT_VIA_STDIN=1
438
+ ;;
439
+ opencode)
440
+ CMD=(opencode run -m "$MODEL" --dir "$REPO")
441
+ [ -n "$EFFORT" ] && CMD+=(--variant "$EFFORT")
442
+ # opencode run exposes no sandbox tier — without --auto it blocks on a
443
+ # permission prompt that nothing can answer headlessly, so it is always set.
444
+ CMD+=(--auto)
445
+ read_only_role && [ "$YOLO" != 1 ] && CMD+=(--agent plan)
446
+ CMD+=("$PROMPT")
447
+ ;;
448
+ antigravity)
449
+ # `agy --print` is a Go flag with an OPTIONAL value: written detached it eats
450
+ # whatever token follows as its prompt ("--print took --print-timeout as its
451
+ # prompt"). The attached --print=... form is the only safe spelling, and it
452
+ # goes last so nothing can be captured after it.
453
+ CMD=(agy --model "$MODEL" --print-timeout "$AGY_TIMEOUT")
454
+ [ -n "$EFFORT" ] && CMD+=(--effort "$EFFORT")
455
+ # --add-dir on the repo is not optional. Antigravity is workspace-scoped
456
+ # rather than cwd-scoped, so without it the agent searches the wrong tree and
457
+ # the run dies on "Find command timed out: context deadline exceeded".
458
+ CMD+=(--add-dir "$REPO" --add-dir "$RUN_DIR")
459
+ if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-skip-permissions)
460
+ elif read_only_role; then CMD+=(--mode plan)
461
+ else CMD+=(--mode accept-edits); fi
462
+ CMD+=(--print="$PROMPT")
463
+ ;;
464
+ grok)
465
+ # Headless grok refuses any tool call that needs approval and still exits 0
466
+ # ("permission required: Edit calc.py was not run"): the same silent empty diff
467
+ # claude gives without acceptEdits, so the dwarf gets edits and the whole shell.
468
+ # The Messages-format stream ends in a claude-shaped `result` line, which
469
+ # forge-runtime.py reads for the final message and the tokens.
470
+ CMD=(grok --model "$MODEL" --prompt-file "$PROMPT_FILE" --output-format streaming-messages-json)
471
+ [ -n "$EFFORT" ] && CMD+=(--reasoning-effort "$EFFORT")
472
+ if [ "$YOLO" = 1 ]; then
473
+ CMD+=(--always-approve)
474
+ else
475
+ CMD+=(--permission-mode acceptEdits --allow Bash)
476
+ # A deny rule beats the mode and every allow, so a reviewer keeps its shell for
477
+ # running tests but cannot edit. Bash can still write files, as with claude.
478
+ read_only_role && CMD+=(--deny Edit --deny Write)
479
+ fi
480
+ ;;
481
+ *) die "no dispatch recipe for harness '$HARNESS'" ;;
482
+ esac
483
+
484
+ # Offline validation uses the exact command recipe, never a model request.
485
+ preflight_command() {
486
+ local bin help arg flag
487
+ local help_args=(--help)
488
+ bin="$(command -v "$(binary_for "$HARNESS")")" || die "harness '$HARNESS' is not installed" 3
489
+ case "$HARNESS" in
490
+ codex) help_args=(exec --help); [ "$NATIVE_REVIEW" = 1 ] && help_args=(exec review --help) ;;
491
+ opencode) help_args=(run --help) ;;
492
+ esac
493
+ help="$(python3 "$RUNTIME" help "${FORGE_CAPABILITY_CACHE:-$RUN_DIR/capabilities}" "$REGISTRY" "${BASH_SOURCE[0]}" "$bin" "${help_args[@]}")" || die "cannot read $HARNESS help" 3
494
+ for arg in "${CMD[@]:1}"; do
495
+ [ "$arg" = "$PROMPT" ] && continue
496
+ [ "$arg" = - ] && continue
497
+ case "$arg" in
498
+ -*) flag="${arg%%=*}"
499
+ printf '%s\n' "$help" | grep -E -- "(^|[[:space:],|])$flag([=[:space:],|]|$)" >/dev/null || die "$HARNESS does not advertise required flag $flag" 3 ;;
500
+ esac
501
+ done
502
+ }
503
+ [ -z "$ATTEMPT" ] || python3 "$RUNTIME" phase "$ATTEMPT" preflight
504
+ if [ "$DRY" != 1 ] || [ "$PREFLIGHT" = 1 ]; then preflight_command; fi
505
+
506
+ # --- report the resolution ----------------------------------------------------
507
+ {
508
+ echo "role=$ROLE"
509
+ echo "alias=$ALIAS"
510
+ echo "harness=$HARNESS"
511
+ echo "model=$MODEL"
512
+ echo "effort=${EFFORT:-<harness default>}"
513
+ echo "yolo=$([ "$YOLO" = 1 ] && echo ON || echo off)"
514
+ echo "passthrough=$([ "$PASSTHRU" = 1 ] && echo yes || echo no)"
515
+ [ -n "$CLAMPED" ] && echo "clamped=$CLAMPED"
516
+ echo "timeout=$LIMIT"
517
+ echo "timeout_source=$LIMIT_SOURCE"
518
+ # Native review ignores the assembled prompt, so whether it reached the model is a fact
519
+ # worth recording rather than inferring from the role and harness later.
520
+ echo "delivery=$([ "$NATIVE_REVIEW" = 1 ] && echo native_review || echo custom)"
521
+ echo "repo=$REPO"
522
+ echo "run_dir=$RUN_DIR"
523
+ } | tee "$RUN_DIR/$ROLE.resolved"
524
+
525
+ if [ "$PREFLIGHT" = 1 ]; then
526
+ echo "offline preflight passed; authentication, quota and live model availability unverified"
527
+ exit 0
528
+ fi
529
+ printf '%q ' "${CMD[@]}" > "$RUN_DIR/$ROLE.cmd"; echo >> "$RUN_DIR/$ROLE.cmd"
530
+ if [ "$DRY" = 1 ]; then
531
+ echo "--- dry run, command not executed ---"
532
+ cat "$RUN_DIR/$ROLE.cmd"
533
+ [ "$PROMPT_VIA_STDIN" = 1 ] && echo " (prompt delivered on stdin from $PROMPT_FILE)"
534
+ exit 0
535
+ fi
536
+
537
+ if [ "$YOLO" = 1 ]; then
538
+ note "YOLO: $HARNESS runs with sandbox and approvals disabled in $REPO"
539
+ fi
540
+ [ -n "$(command -v "$(binary_for "$HARNESS")")" ] || die "harness '$HARNESS' is not installed ($(binary_for "$HARNESS") not on PATH)" 3
541
+
542
+ # Harnesses that take the prompt on argv get stdin redirected from /dev/null:
543
+ # codex exec otherwise blocks waiting to append piped input to the prompt, which
544
+ # headless reads as a silent hang rather than an error.
545
+ #
546
+ # The harness runs in the background rather than through `| tee` so that its pid is
547
+ # knowable and therefore killable. `exec` matters: it replaces the subshell with the
548
+ # harness itself, so $hpid IS the backend process and not a shell that merely owns
549
+ # it — otherwise a timeout would kill the wrapper and leave the real process running.
550
+ # The cost is that the log is no longer echoed incrementally; it is printed whole at
551
+ # the end, so anything reading this script's stdout sees the same bytes as before.
552
+ # Clear the previous dispatch's artifacts. Only codex writes $LAST itself (-o);
553
+ # every other harness relies on the copy-from-log fallback below, which is a no-op
554
+ # when a stale $LAST is already there. That is harmless when each run gets a fresh
555
+ # run-dir, and silently wrong for `forge-parallel.sh retry`, which reuses the task
556
+ # directory — it would read the first attempt's result and never notice.
557
+ rm -f "$LAST" "$LOG" "$RUN_DIR/$ROLE.timedout" "$RUN_DIR/$ROLE.infra" "$RUN_DIR/$ROLE.orphans"
558
+ python3 "$RUNTIME" phase "$ATTEMPT" model
559
+ t0="$(date +%s)"
560
+ # Process-group guard. A dwarf that starts `npm test &` and ends its turn leaves that
561
+ # process running after dispatch returns, mutating the tree under whoever reads it next.
562
+ # forge-guard.py does setsid() and then exec()s the harness, so $! is still the harness
563
+ # pid and is now also its group id; after the harness exits, anything left in the group is
564
+ # reaped and listed. Off by kill switch, and under Fractal (which manages its own process
565
+ # groups and would otherwise lose the ability to signal this one). If the helper cannot
566
+ # even start, fall back to the plain exec: a guard failure must never cost the run.
567
+ GUARD_ON=0; reaped=0
568
+ case "${FORGE_ORPHAN_GUARD:-on}" in off|OFF|Off|0|no|false) ;; *)
569
+ if [ -z "${FORGE_FRACTAL_RUN:-}" ] && [ -r "$GUARD" ] && [ "$(python3 "$GUARD" ping 2>/dev/null)" = ok ]; then GUARD_ON=1; fi ;;
570
+ esac
571
+ RUNCMD=("${CMD[@]}")
572
+ [ "$GUARD_ON" != 1 ] || RUNCMD=(python3 "$GUARD" session -- "${CMD[@]}")
573
+ if [ "$PROMPT_VIA_STDIN" = 1 ]; then
574
+ ( cd "$REPO" && exec "${RUNCMD[@]}" < "$PROMPT_FILE" ) > "$LOG" 2>&1 &
575
+ else
576
+ ( cd "$REPO" && exec "${RUNCMD[@]}" </dev/null ) > "$LOG" 2>&1 &
577
+ fi
578
+ hpid=$!
579
+
580
+ # Watchdog. Only antigravity has a time bound of its own, and `timeout` is not
581
+ # present on stock macOS, so without this a hung backend blocks forever — and under
582
+ # forge-parallel's `xargs -P` one hung task silently stalls its whole wave. The
583
+ # default is deliberately generous: killing a slow-but-working dwarf is its own
584
+ # failure, so this is a backstop, not a scheduler.
585
+ wpid=""
586
+ if [ "${LIMIT:-0}" != "0" ]; then
587
+ (
588
+ sleep "$LIMIT"
589
+ kill -0 "$hpid" 2>/dev/null || exit 0
590
+ : > "$RUN_DIR/$ROLE.timedout"
591
+ # The whole process group first when the harness has its own (GUARD_ON), then
592
+ # children, then the process itself, then SIGKILL for a CLI that traps TERM and
593
+ # takes its time. Without the guard there is no group to signal as a unit.
594
+ [ "$GUARD_ON" != 1 ] || kill -s TERM -- "-$hpid" 2>/dev/null
595
+ pkill -TERM -P "$hpid" 2>/dev/null
596
+ kill -TERM "$hpid" 2>/dev/null
597
+ sleep 5
598
+ [ "$GUARD_ON" != 1 ] || kill -s KILL -- "-$hpid" 2>/dev/null
599
+ pkill -KILL -P "$hpid" 2>/dev/null
600
+ kill -KILL "$hpid" 2>/dev/null
601
+ ) >/dev/null 2>&1 &
602
+ wpid=$!
603
+ fi
604
+
605
+ wait "$hpid"; rc=$?
606
+ if [ -n "$wpid" ]; then
607
+ # The watchdog is asleep in a child of its own; kill that too or it outlives the
608
+ # run as an orphan holding the timer.
609
+ pkill -P "$wpid" 2>/dev/null
610
+ kill "$wpid" 2>/dev/null
611
+ wait "$wpid" 2>/dev/null
612
+ fi
613
+ # Anything the harness left running in its group is an abandoned background job. Kill it
614
+ # before reading the tree, and leave the list for the caller. Bounded (~3s) and advisory:
615
+ # a reaper failure changes nothing about the exit code.
616
+ if [ "$GUARD_ON" = 1 ]; then
617
+ orphans_found="$(python3 "$GUARD" reap "$hpid" "$RUN_DIR/$ROLE.orphans" 2>/dev/null)"
618
+ case "$orphans_found" in ''|*[!0-9]*|0) ;; *) note "$HARNESS left $orphans_found process(es) running after it exited; killed — see $RUN_DIR/$ROLE.orphans" ;; esac
619
+ fi
620
+ reaped=1
621
+ python3 "$RUNTIME" phase "$ATTEMPT" finalization
622
+ DURATION=$(( $(date +%s) - t0 ))
623
+ echo "duration_s=$DURATION" >> "$RUN_DIR/$ROLE.resolved"
624
+ [ "$OUTPUT" != full ] || cat "$LOG"
625
+ [ "$OUTPUT" != summary ] || echo "forge: role=$ROLE backend_exit=$rc final=$LAST log=$LOG metrics=$ATTEMPT/metrics.json"
626
+ if [ -f "$RUN_DIR/$ROLE.timedout" ]; then
627
+ note "$HARNESS exceeded the ${LIMIT}s timeout and was killed after ${DURATION}s — see $LOG"
628
+ exit 7
629
+ fi
630
+ # Infrastructure, not the model's work: auth, quota, rate limit, network, or an answer that
631
+ # never arrived. Classified from the LOG because $LAST is only created later, by the EXIT
632
+ # trap's `finish`. Any trouble running the classifier counts as "not infrastructure".
633
+ INFRA="$(python3 "$RUNTIME" classify "$LOG" "$HARNESS" "$rc" "$LAST" "$ROLE" 2>/dev/null)" || INFRA=""
634
+ if [ -n "$INFRA" ]; then
635
+ TAB=$'\t'
636
+ infra_class="${INFRA%%"$TAB"*}"; infra_rest="${INFRA#*"$TAB"}"
637
+ infra_after="${infra_rest%%"$TAB"*}"; infra_detail="${infra_rest#*"$TAB"}"
638
+ {
639
+ echo "class=$infra_class"
640
+ echo "retry_after=$infra_after"
641
+ echo "detail=$infra_detail"
642
+ echo "rc=$rc"
643
+ echo "role=$ROLE"
644
+ echo "harness=$HARNESS"
645
+ echo "model=$MODEL"
646
+ } > "$RUN_DIR/$ROLE.infra"
647
+ # A dwarf may legitimately finish with nothing to say when its work is the diff, so
648
+ # an empty message alone is recorded but is not a failure; the caller decides when
649
+ # the diff turns out to be empty too. Every other role has nothing but its message.
650
+ if [ "$rc" -eq 0 ] && [ "$infra_class" = empty ] && [ "$ROLE" = dwarf ]; then
651
+ note "$HARNESS finished with an empty final message — recorded in $RUN_DIR/$ROLE.infra"
652
+ elif [ "$rc" -eq 0 ] && [ "$infra_class" = cancelled ]; then
653
+ # The CLI stopped a working model: a dwarf's diff so far still goes to review, and a
654
+ # reviewer cut off before its verdict is UNKNOWN, which re-runs only the review.
655
+ note "$HARNESS cancelled the $ROLE before it finished: $infra_detail — recorded in $RUN_DIR/$ROLE.infra"
656
+ else
657
+ note "$HARNESS failed on infrastructure ($infra_class${infra_after:+, retry after ${infra_after}s}): $infra_detail — see $LOG"
658
+ exit 8
659
+ fi
660
+ fi
661
+ if [ "$rc" -ne 0 ]; then
662
+ note "$HARNESS exited $rc — see $LOG"
663
+ exit 4
664
+ fi
665
+ echo "forge: $ROLE done in ${DURATION}s -> $LAST" >&2