@giamme/forge 0.9.0 → 0.11.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 (67) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/SKILL.md +22 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +28 -2
  5. package/docs/commands.md +55 -0
  6. package/docs/dataset.md +22 -0
  7. package/docs/decomposed-runs.md +54 -2
  8. package/docs/forge-ui.md +48 -7
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +87 -2
  11. package/forge +31 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +103 -4
  14. package/references/decompose.md +41 -5
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +31 -6
  17. package/scripts/forge-config.py +350 -0
  18. package/scripts/forge-control.py +649 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +1134 -0
  21. package/scripts/forge-lib-plans.sh +6 -2
  22. package/scripts/forge-lib-review.sh +158 -3
  23. package/scripts/forge-lib-task.sh +358 -41
  24. package/scripts/forge-parallel-options.sh +3 -1
  25. package/scripts/forge-parallel.sh +99 -13
  26. package/scripts/forge-prompt.py +61 -4
  27. package/scripts/forge-route.py +245 -28
  28. package/scripts/forge-runs-rm.py +840 -0
  29. package/scripts/forge-runs.py +19 -1
  30. package/scripts/forge-runtime.py +47 -1
  31. package/scripts/forge-schedule.py +227 -11
  32. package/scripts/forge-ui.py +85 -19
  33. package/scripts/forge_dataset/__init__.py +84 -8
  34. package/scripts/forge_dataset/capture.py +5 -2
  35. package/scripts/forge_dataset/cli.py +44 -1
  36. package/scripts/forge_dataset/export.py +36 -3
  37. package/scripts/forge_dataset/forecast.py +355 -0
  38. package/scripts/forge_dataset/harvest.py +45 -2
  39. package/scripts/forge_dataset/importer.py +431 -17
  40. package/scripts/forge_dataset/reviewers.py +145 -0
  41. package/scripts/forge_dataset/routing.py +1 -0
  42. package/scripts/forge_jev/__init__.py +7 -3
  43. package/scripts/forge_registry.py +183 -26
  44. package/ui/README.md +41 -7
  45. package/ui/docs/api.md +259 -21
  46. package/ui/forge_ui/__main__.py +10 -2
  47. package/ui/forge_ui/control.py +587 -0
  48. package/ui/forge_ui/export.py +17 -3
  49. package/ui/forge_ui/reader.py +210 -12
  50. package/ui/forge_ui/server.py +125 -4
  51. package/ui/forge_ui/static/app.js +104 -18
  52. package/ui/forge_ui/static/cloud3d.js +4 -1
  53. package/ui/forge_ui/static/cloudmodel.js +1 -1
  54. package/ui/forge_ui/static/cloudui.js +13 -4
  55. package/ui/forge_ui/static/controls.js +619 -0
  56. package/ui/forge_ui/static/findingsview.js +438 -0
  57. package/ui/forge_ui/static/graph.js +6 -5
  58. package/ui/forge_ui/static/index.html +7 -1
  59. package/ui/forge_ui/static/leaderboard.js +18 -0
  60. package/ui/forge_ui/static/modelstats.js +19 -1
  61. package/ui/forge_ui/static/panel.js +76 -14
  62. package/ui/forge_ui/static/rundelete.js +388 -0
  63. package/ui/forge_ui/static/runmodel.js +128 -13
  64. package/ui/forge_ui/static/scene3d.js +32 -15
  65. package/ui/forge_ui/static/settings.js +399 -0
  66. package/ui/forge_ui/static/style.css +184 -5
  67. package/ui/forge_ui/static/uikit.js +161 -0
@@ -9,6 +9,8 @@
9
9
  # forge-dispatch.sh dwarf <spec> --prompt-file <f> [--repo <dir>] [--run-dir <d>] [--yolo] [--dry-run]
10
10
  # forge-dispatch.sh qa <spec> --prompt-file <f> [--repo <dir>] [--run-dir <d>] [--yolo] [--dry-run]
11
11
  # [--native-review [--review-base <ref>]]
12
+ # forge-dispatch.sh qa <spec> ... --continue-session <id> continue that reviewer session
13
+ # (claude, openclaude, codex; custom prompt only)
12
14
  # any role: [--no-ripwire] [--ripwire-query-file <requirements>]
13
15
  # any role: [--timeout <seconds>] 0 disables the watchdog
14
16
  #
@@ -28,6 +30,9 @@
28
30
  # answer): the model never got to do its work. $ROLE.infra says which.
29
31
  # A DWARF that exits 0 with an empty final message also writes $ROLE.infra
30
32
  # (class=empty) but still exits 0; consult the file only if the diff is empty.
33
+ # 9 the session was not continued (--continue-session only): the harness cannot
34
+ # continue one, or it ran and failed. The caller may dispatch fresh; dispatch
35
+ # never does, so each dispatch keeps its own record. $ROLE.continue says why.
31
36
  # A process still alive in the harness's group after it exited is killed and listed in
32
37
  # $ROLE.orphans (only written when there is something to list).
33
38
  set -uo pipefail
@@ -149,7 +154,7 @@ SPEC="$1"; shift
149
154
 
150
155
  RIPWIRE_QUERY=""
151
156
  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=""
157
+ REPO="$PWD"; RUN_DIR=""; PROMPT_FILE=""; YOLO=0; DRY=0; REVIEW_BASE=""; PROMPT_VIA_STDIN=0; NATIVE_REVIEW=0; CONTINUE_ID=""; CONTINUE_GIVEN=0; AGY_TIMEOUT="30m"; TIMEOUT_FLAG=""
153
158
  while [ $# -gt 0 ]; do
154
159
  case "$1" in
155
160
  --no-ripwire) export FORGE_RIPWIRE=off; shift ;;
@@ -160,6 +165,7 @@ while [ $# -gt 0 ]; do
160
165
  --prompt-file) PROMPT_FILE="${2:?--prompt-file needs a value}"; shift 2 ;;
161
166
  --review-base) REVIEW_BASE="${2:?--review-base needs a value}"; shift 2 ;;
162
167
  --native-review) NATIVE_REVIEW=1; shift ;;
168
+ --continue-session) [ $# -ge 2 ] || die "--continue-session needs a session id"; CONTINUE_ID="$2"; CONTINUE_GIVEN=1; shift 2 ;;
163
169
  --agy-timeout) AGY_TIMEOUT="${2:?--agy-timeout needs a value}"; shift 2 ;;
164
170
  --timeout) TIMEOUT_FLAG="${2:?--timeout needs a value in seconds, 0 to disable}"; shift 2 ;;
165
171
  --yolo) YOLO=1; shift ;;
@@ -207,6 +213,12 @@ fi
207
213
  if [ "$NATIVE_REVIEW" = 1 ] && { [ "$ROLE" != qa ] || [ "$HARNESS" != codex ]; }; then
208
214
  die "--native-review requires QA on codex"
209
215
  fi
216
+ if [ "$CONTINUE_GIVEN" = 1 ]; then
217
+ [ "$ROLE" = qa ] && [ "$NATIVE_REVIEW" != 1 ] && { [ "$HARNESS" = codex ] || [ "$HARNESS" = claude ] || [ "$HARNESS" = openclaude ]; } \
218
+ || die "--continue-session needs the qa role on codex, claude or openclaude, without --native-review"
219
+ case "$CONTINUE_ID" in ''|*[!A-Za-z0-9-]*) die "--continue-session id must be letters, digits and '-' only" ;; esac
220
+ [ "${#CONTINUE_ID}" -ge 8 ] && [ "${#CONTINUE_ID}" -le 128 ] || die "--continue-session id must be 8 to 128 characters"
221
+ fi
210
222
 
211
223
  # --- resolve effort -----------------------------------------------------------
212
224
  LADDER="$(ladder_for "$HARNESS")"
@@ -301,10 +313,11 @@ RUN_DIR="$(cd "$RUN_DIR" && pwd)" # normalize, so the containment check below
301
313
  case "$RUN_DIR" in "$REPO"|"$REPO"/*) die "--run-dir must be outside --repo, or it lands in the diff under review" ;; esac
302
314
 
303
315
  # Start an immutable attempt record before preparation, including failures.
316
+ write_continue() { :; } # redefined below once there is an attempt to archive it into
304
317
  ATTEMPT=""
305
318
  if [ "$PREFLIGHT" != 1 ] && [ "$DRY" != 1 ]; then
306
319
  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"
320
+ 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" "$RUN_DIR/$ROLE.session" "$RUN_DIR/$ROLE.continue"
308
321
  trap 'exit 143' TERM
309
322
  trap 'exit 130' INT
310
323
  # GUARD_ON is 1 only when the harness was started in its own process group (see the
@@ -322,6 +335,14 @@ if [ "$PREFLIGHT" != 1 ] && [ "$DRY" != 1 ]; then
322
335
  fi
323
336
  if [ -n "${wpid:-}" ]; then pkill -P "$wpid" 2>/dev/null; kill "$wpid" 2>/dev/null; fi
324
337
  }
338
+ # What the EXIT trap's `finish` archives. Pessimistic until an outcome is known, so every
339
+ # path out of a continue request (a signal, a usage error after this point) leaves a record.
340
+ write_continue() { # write_continue <continued|failed> [reason]
341
+ [ -n "$CONTINUE_ID" ] || return 0
342
+ { echo "requested=$CONTINUE_ID"; echo "mode=$1"; [ -z "${2:-}" ] || echo "reason=$2"; } > "$RUN_DIR/$ROLE.continue.tmp" \
343
+ && mv "$RUN_DIR/$ROLE.continue.tmp" "$RUN_DIR/$ROLE.continue"
344
+ }
345
+ write_continue failed "dispatch did not complete"
325
346
  trap 'result=$?; exit_cleanup; python3 "$RUNTIME" finish "$ATTEMPT" "$RUN_DIR" "$ROLE" "$HARNESS" "$result"; exit "$result"' EXIT
326
347
  fi
327
348
  [ -n "$PROMPT_FILE" ] || die "role '$ROLE' needs --prompt-file"
@@ -372,139 +393,219 @@ if [ -n "$ATTEMPT" ]; then
372
393
  --prompt "$PROMPT_FILE" --query "$RIPWIRE_QUERY" --baseline "$REVIEW_BASE" \
373
394
  --artifacts "$ATTEMPT" --native "$NATIVE_REVIEW" || note "Ripwire preparation failed; continuing"
374
395
  fi
396
+ # A continued reviewer remembers the earlier round, including its instructions. $ROLE.prompt
397
+ # records what was sent, so the preamble goes into the file itself.
398
+ if [ -n "$CONTINUE_ID" ]; then
399
+ continued_prompt="$(mktemp "$RUN_DIR/.prompt.XXXXXX")" || die "cannot create continued prompt"
400
+ {
401
+ printf 'This continues your earlier review of this work. The work has changed since you last\n'
402
+ printf 'looked. Your working directory is now %s. Re-read files rather than relying on what\n' "$REPO"
403
+ printf 'you read before. Review this round exactly as instructed below; where it differs from\n'
404
+ printf "earlier instructions (a threshold, for example), this round's instructions win.\n\n"
405
+ cat "$PROMPT_FILE"
406
+ } > "$continued_prompt" && mv "$continued_prompt" "$RUN_DIR/$ROLE.prompt" || { rm -f "$continued_prompt"; die "cannot install continued prompt"; }
407
+ fi
375
408
  cp "$PROMPT_FILE" "$RUN_DIR/$ROLE.delivered"
376
409
  case "$HARNESS" in opencode|antigravity) PROMPT="$(cat "$PROMPT_FILE")" ;; esac
377
410
 
378
411
  # --- 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+=(-)
412
+ # Sets CMD (and PROMPT_VIA_STDIN) for the dispatch, or for the fresh one when CONTINUE_ID is empty.
413
+ build_cmd() {
414
+ CMD=()
415
+ case "$HARNESS" in
416
+ codex)
417
+ if [ "$ROLE" = "qa" ] && [ "$NATIVE_REVIEW" = 1 ]; then
418
+ # codex's built-in reviewer. Its scope flags are mutually exclusive with a
419
+ # custom PROMPT, so this mode trades away the goal context: it can judge
420
+ # whether the diff is correct, but not whether it is the change that was
421
+ # actually asked for. `codex exec review` also has no --cd, hence the
422
+ # subshell cd below.
423
+ CMD=(codex exec review --json -m "$MODEL" --skip-git-repo-check -o "$LAST")
424
+ [ -n "$EFFORT" ] && CMD+=(-c "model_reasoning_effort=$EFFORT")
425
+ if [ -n "$REVIEW_BASE" ]; then CMD+=(--base "$REVIEW_BASE"); else CMD+=(--uncommitted); fi
426
+ if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-bypass-approvals-and-sandbox); fi
427
+ elif [ -n "$CONTINUE_ID" ]; then
428
+ # `codex exec resume` has no -C and no -s, so it runs where it is started (the
429
+ # subshell cd below) and the sandbox is spelled out as a config override instead
430
+ # of being left to what the first turn recorded. The quotes are TOML.
431
+ # An id that starts with '-' would be read as a flag, so it goes after a `--`.
432
+ CMD=(codex exec resume)
433
+ case "$CONTINUE_ID" in -*) ;; *) CMD+=("$CONTINUE_ID") ;; esac
434
+ CMD+=(--json -m "$MODEL" --skip-git-repo-check -o "$LAST")
435
+ [ -n "$EFFORT" ] && CMD+=(-c "model_reasoning_effort=$EFFORT")
436
+ if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-bypass-approvals-and-sandbox)
437
+ else CMD+=(-c 'sandbox_mode="read-only"'); fi
438
+ case "$CONTINUE_ID" in -*) CMD+=(-- "$CONTINUE_ID") ;; esac
439
+ CMD+=(-)
440
+ PROMPT_VIA_STDIN=1
441
+ else
442
+ CMD=(codex exec --json -m "$MODEL" -C "$REPO" --skip-git-repo-check -o "$LAST")
443
+ [ -n "$EFFORT" ] && CMD+=(-c "model_reasoning_effort=$EFFORT")
444
+ # --approve-for-me and --dangerously-bypass-... are mutually exclusive.
445
+ if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-bypass-approvals-and-sandbox)
446
+ elif [ "$ROLE" = "dwarf" ]; then CMD+=(--approve-for-me)
447
+ else CMD+=(-s read-only); fi
448
+ CMD+=(-)
449
+ PROMPT_VIA_STDIN=1
450
+ fi
451
+ ;;
452
+ claude|openclaude)
453
+ # The run dir sits outside the repo (so it stays out of the diff), which also
454
+ # puts it outside the harness's default reach — without this the agent
455
+ # refuses to open the very artifacts forge just wrote for it.
456
+ CMD=("$HARNESS" -p --model "$MODEL" --add-dir "$RUN_DIR")
457
+ # One JSON message per line: what the agent said and every tool call, so the run keeps
458
+ # its conversation (forge-ui reads it), ending in the `result` line forge-runtime.py
459
+ # takes the final message and the tokens from. stream-json needs --verbose with -p.
460
+ CMD+=(--output-format stream-json --verbose)
461
+ [ -n "$EFFORT" ] && CMD+=(--effort "$EFFORT")
462
+ # Before the variadic flags below, which would otherwise swallow it.
463
+ # --resume takes an optional value, so an id that starts with '-' is joined with '='.
464
+ case "$CONTINUE_ID" in
465
+ '') ;;
466
+ -*) CMD+=("--resume=$CONTINUE_ID") ;;
467
+ *) CMD+=(--resume "$CONTINUE_ID") ;;
468
+ esac
469
+ if [ "$YOLO" = 1 ]; then
470
+ CMD+=(--dangerously-skip-permissions)
471
+ else
472
+ # `acceptEdits`, not `auto`. Re-measured against this machine's CLIs:
473
+ # acceptEdits allows both Edit and Bash, whereas `auto` allows Bash but
474
+ # still prompts for Edit — and a headless dwarf has nobody to answer that
475
+ # prompt, so it ends with "I need your permission to edit f.py" and an
476
+ # empty diff. The failure is silent: the harness exits 0 and forge sees a
477
+ # successful run that changed nothing.
478
+ CMD+=(--permission-mode acceptEdits)
479
+ # Neither mode alone is enough, and the gap is silent in both directions:
480
+ # `auto` allows shell but prompts for Edit, so a dwarf ends with "I need
481
+ # your permission to edit" and an empty diff; `acceptEdits` allows Edit but
482
+ # still prompts for any non-trivial shell command, so a dwarf writes code
483
+ # and then stalls on "awaiting approval to run pytest" without ever
484
+ # verifying it. acceptEdits plus an explicit Bash allowance gives both, and
485
+ # QA's --disallowed-tools below still overrides the edit permission.
486
+ CMD+=(--allowedTools "Bash")
487
+ # Disable editing tools as defense in depth. Bash can still write files;
488
+ # runners use disposable snapshots and fingerprint checks for acceptance.
489
+ read_only_role && CMD+=(--disallowed-tools "Edit,Write,NotebookEdit")
490
+ fi
491
+ # The prompt goes on stdin, not argv. --disallowed-tools and friends are
492
+ # variadic, so any positional after them is silently eaten as another tool
493
+ # name — the run then dies on "Input must be provided" if you are lucky, or
494
+ # reviews nothing at all if you are not. stdin also lifts the argv length
495
+ # limit, which a long goal plus an inlined diff will otherwise hit.
400
496
  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
- # One JSON message per line: what the agent said and every tool call, so the run keeps
409
- # its conversation (forge-ui reads it), ending in the `result` line forge-runtime.py
410
- # takes the final message and the tokens from. stream-json needs --verbose with -p.
411
- CMD+=(--output-format stream-json --verbose)
412
- [ -n "$EFFORT" ] && CMD+=(--effort "$EFFORT")
413
- if [ "$YOLO" = 1 ]; then
414
- CMD+=(--dangerously-skip-permissions)
415
- else
416
- # `acceptEdits`, not `auto`. Re-measured against this machine's CLIs:
417
- # acceptEdits allows both Edit and Bash, whereas `auto` allows Bash but
418
- # still prompts for Edit — and a headless dwarf has nobody to answer that
419
- # prompt, so it ends with "I need your permission to edit f.py" and an
420
- # empty diff. The failure is silent: the harness exits 0 and forge sees a
421
- # successful run that changed nothing.
422
- CMD+=(--permission-mode acceptEdits)
423
- # Neither mode alone is enough, and the gap is silent in both directions:
424
- # `auto` allows shell but prompts for Edit, so a dwarf ends with "I need
425
- # your permission to edit" and an empty diff; `acceptEdits` allows Edit but
426
- # still prompts for any non-trivial shell command, so a dwarf writes code
427
- # and then stalls on "awaiting approval to run pytest" without ever
428
- # verifying it. acceptEdits plus an explicit Bash allowance gives both, and
429
- # QA's --disallowed-tools below still overrides the edit permission.
430
- CMD+=(--allowedTools "Bash")
431
- # Disable editing tools as defense in depth. Bash can still write files;
432
- # runners use disposable snapshots and fingerprint checks for acceptance.
433
- read_only_role && CMD+=(--disallowed-tools "Edit,Write,NotebookEdit")
434
- fi
435
- # The prompt goes on stdin, not argv. --disallowed-tools and friends are
436
- # variadic, so any positional after them is silently eaten as another tool
437
- # name — the run then dies on "Input must be provided" if you are lucky, or
438
- # reviews nothing at all if you are not. stdin also lifts the argv length
439
- # limit, which a long goal plus an inlined diff will otherwise hit.
440
- PROMPT_VIA_STDIN=1
441
- ;;
442
- opencode)
443
- CMD=(opencode run -m "$MODEL" --dir "$REPO")
444
- [ -n "$EFFORT" ] && CMD+=(--variant "$EFFORT")
445
- # opencode run exposes no sandbox tier — without --auto it blocks on a
446
- # permission prompt that nothing can answer headlessly, so it is always set.
447
- CMD+=(--auto)
448
- read_only_role && [ "$YOLO" != 1 ] && CMD+=(--agent plan)
449
- # One JSON event per line (text, tool_use, step_finish, error): the conversation, the
450
- # final message and the tokens, read by forge-runtime.py and forge-ui.
451
- CMD+=(--format json)
452
- CMD+=("$PROMPT")
453
- ;;
454
- antigravity)
455
- # `agy --print` is a Go flag with an OPTIONAL value: written detached it eats
456
- # whatever token follows as its prompt ("--print took --print-timeout as its
457
- # prompt"). The attached --print=... form is the only safe spelling, and it
458
- # goes last so nothing can be captured after it.
459
- CMD=(agy --model "$MODEL" --print-timeout "$AGY_TIMEOUT")
460
- [ -n "$EFFORT" ] && CMD+=(--effort "$EFFORT")
461
- # --add-dir on the repo is not optional. Antigravity is workspace-scoped
462
- # rather than cwd-scoped, so without it the agent searches the wrong tree and
463
- # the run dies on "Find command timed out: context deadline exceeded".
464
- CMD+=(--add-dir "$REPO" --add-dir "$RUN_DIR")
465
- if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-skip-permissions)
466
- elif read_only_role; then CMD+=(--mode plan)
467
- else CMD+=(--mode accept-edits); fi
468
- CMD+=(--print="$PROMPT")
469
- ;;
470
- grok)
471
- # Headless grok refuses any tool call that needs approval and still exits 0
472
- # ("permission required: Edit calc.py was not run"): the same silent empty diff
473
- # claude gives without acceptEdits, so the dwarf gets edits and the whole shell.
474
- # The Messages-format stream ends in a claude-shaped `result` line, which
475
- # forge-runtime.py reads for the final message and the tokens.
476
- CMD=(grok --model "$MODEL" --prompt-file "$PROMPT_FILE" --output-format streaming-messages-json)
477
- [ -n "$EFFORT" ] && CMD+=(--reasoning-effort "$EFFORT")
478
- if [ "$YOLO" = 1 ]; then
479
- CMD+=(--always-approve)
480
- else
481
- CMD+=(--permission-mode acceptEdits --allow Bash)
482
- # A deny rule beats the mode and every allow, so a reviewer keeps its shell for
483
- # running tests but cannot edit. Bash can still write files, as with claude.
484
- read_only_role && CMD+=(--deny Edit --deny Write)
485
- fi
486
- ;;
487
- *) die "no dispatch recipe for harness '$HARNESS'" ;;
488
- esac
497
+ ;;
498
+ opencode)
499
+ CMD=(opencode run -m "$MODEL" --dir "$REPO")
500
+ [ -n "$EFFORT" ] && CMD+=(--variant "$EFFORT")
501
+ # opencode run exposes no sandbox tier — without --auto it blocks on a
502
+ # permission prompt that nothing can answer headlessly, so it is always set.
503
+ CMD+=(--auto)
504
+ read_only_role && [ "$YOLO" != 1 ] && CMD+=(--agent plan)
505
+ # One JSON event per line (text, tool_use, step_finish, error): the conversation, the
506
+ # final message and the tokens, read by forge-runtime.py and forge-ui.
507
+ CMD+=(--format json)
508
+ CMD+=("$PROMPT")
509
+ ;;
510
+ antigravity)
511
+ # `agy --print` is a Go flag with an OPTIONAL value: written detached it eats
512
+ # whatever token follows as its prompt ("--print took --print-timeout as its
513
+ # prompt"). The attached --print=... form is the only safe spelling, and it
514
+ # goes last so nothing can be captured after it.
515
+ CMD=(agy --model "$MODEL" --print-timeout "$AGY_TIMEOUT")
516
+ [ -n "$EFFORT" ] && CMD+=(--effort "$EFFORT")
517
+ # --add-dir on the repo is not optional. Antigravity is workspace-scoped
518
+ # rather than cwd-scoped, so without it the agent searches the wrong tree and
519
+ # the run dies on "Find command timed out: context deadline exceeded".
520
+ CMD+=(--add-dir "$REPO" --add-dir "$RUN_DIR")
521
+ if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-skip-permissions)
522
+ elif read_only_role; then CMD+=(--mode plan)
523
+ else CMD+=(--mode accept-edits); fi
524
+ CMD+=(--print="$PROMPT")
525
+ ;;
526
+ grok)
527
+ # Headless grok refuses any tool call that needs approval and still exits 0
528
+ # ("permission required: Edit calc.py was not run"): the same silent empty diff
529
+ # claude gives without acceptEdits, so the dwarf gets edits and the whole shell.
530
+ # The Messages-format stream ends in a claude-shaped `result` line, which
531
+ # forge-runtime.py reads for the final message and the tokens.
532
+ CMD=(grok --model "$MODEL" --prompt-file "$PROMPT_FILE" --output-format streaming-messages-json)
533
+ [ -n "$EFFORT" ] && CMD+=(--reasoning-effort "$EFFORT")
534
+ # After its last turn a headless run waits for the background work it started (a test
535
+ # suite, say) and wakes the model with each result, but by default for only 600 s: then
536
+ # it stops that work and exits 1, and a dwarf whose suite outlasts the wait ends as an
537
+ # error. forge's own limit bounds the run instead. grok counts from the last turn and
538
+ # forge from the start, so forge's watchdog always fires first; a day stands in for no
539
+ # limit (grok refuses 0).
540
+ if [ "$LIMIT" = 0 ]; then CMD+=(--background-wait-timeout 86400)
541
+ else CMD+=(--background-wait-timeout "$LIMIT"); fi
542
+ if [ "$YOLO" = 1 ]; then
543
+ CMD+=(--always-approve)
544
+ else
545
+ CMD+=(--permission-mode acceptEdits --allow Bash)
546
+ # A deny rule beats the mode and every allow, so a reviewer keeps its shell for
547
+ # running tests but cannot edit. Bash can still write files, as with claude.
548
+ read_only_role && CMD+=(--deny Edit --deny Write)
549
+ fi
550
+ ;;
551
+ *) die "no dispatch recipe for harness '$HARNESS'" ;;
552
+ esac
553
+ }
554
+ build_cmd
489
555
 
490
556
  # Offline validation uses the exact command recipe, never a model request.
491
- preflight_command() {
492
- local bin help arg flag
493
- local help_args=(--help)
494
- bin="$(command -v "$(binary_for "$HARNESS")")" || die "harness '$HARNESS' is not installed" 3
495
- case "$HARNESS" in
496
- codex) help_args=(exec --help); [ "$NATIVE_REVIEW" = 1 ] && help_args=(exec review --help) ;;
497
- opencode) help_args=(run --help) ;;
498
- esac
499
- help="$(python3 "$RUNTIME" help "${FORGE_CAPABILITY_CACHE:-$RUN_DIR/capabilities}" "$REGISTRY" "${BASH_SOURCE[0]}" "$bin" "${help_args[@]}")" || die "cannot read $HARNESS help" 3
557
+ capability_help() { # capability_help <binary> <help args...>
558
+ python3 "$RUNTIME" help "${FORGE_CAPABILITY_CACHE:-$RUN_DIR/capabilities}" "$REGISTRY" "${BASH_SOURCE[0]}" "$@"
559
+ }
560
+ # Prints the first flag of CMD the help text does not advertise; fails when all are.
561
+ unadvertised_flag() { # unadvertised_flag <help text>
562
+ local arg flag
500
563
  for arg in "${CMD[@]:1}"; do
501
564
  [ "$arg" = "$PROMPT" ] && continue
502
565
  [ "$arg" = - ] && continue
566
+ [ "$arg" = -- ] && continue
567
+ [ -n "$CONTINUE_ID" ] && [ "$arg" = "$CONTINUE_ID" ] && continue
503
568
  case "$arg" in
504
569
  -*) flag="${arg%%=*}"
505
- printf '%s\n' "$help" | grep -E -- "(^|[[:space:],|])$flag([=[:space:],|]|$)" >/dev/null || die "$HARNESS does not advertise required flag $flag" 3 ;;
570
+ # grok's help hides this one; preflight_command checks it with a probe instead.
571
+ [ "$HARNESS $flag" = "grok --background-wait-timeout" ] && continue
572
+ printf '%s\n' "$1" | grep -E -- "(^|[[:space:],|])$flag([=[:space:],|]|$)" >/dev/null || { printf '%s' "$flag"; return 0; } ;;
506
573
  esac
507
574
  done
575
+ return 1
576
+ }
577
+ preflight_command() {
578
+ local bin help flag request="$CONTINUE_ID"
579
+ local help_args=(--help) continue_args=(--help)
580
+ bin="$(command -v "$(binary_for "$HARNESS")")" || die "harness '$HARNESS' is not installed" 3
581
+ case "$HARNESS" in
582
+ codex) help_args=(exec --help); continue_args=(exec resume --help); [ "$NATIVE_REVIEW" = 1 ] && help_args=(exec review --help) ;;
583
+ opencode) help_args=(run --help) ;;
584
+ esac
585
+ # grok's help hides --background-wait-timeout, which every grok recipe passes. grok checks
586
+ # every flag and its value before it answers --help, so a help call with the flag in front
587
+ # proves it exists.
588
+ if [ "$HARNESS" = grok ]; then
589
+ capability_help "$bin" --background-wait-timeout 1 --help >/dev/null 2>&1 \
590
+ || die "$HARNESS does not accept required flag --background-wait-timeout (update $HARNESS)" 3
591
+ fi
592
+ if [ -n "$CONTINUE_ID" ]; then
593
+ # codex's resume subcommand has its own help, so its flags are checked against that one.
594
+ if help="$(capability_help "$bin" "${continue_args[@]}" 2>/dev/null)" && ! unadvertised_flag "$help" >/dev/null; then
595
+ return 0
596
+ fi
597
+ # Cannot continue. Say so only if a fresh dispatch would have worked; otherwise this is
598
+ # the harness being unusable, which keeps its exit 3 below.
599
+ CONTINUE_ID=""; build_cmd
600
+ preflight_command
601
+ CONTINUE_ID="$request"
602
+ write_continue failed unsupported
603
+ note "$HARNESS does not advertise what continuing a session needs; the caller may dispatch fresh"
604
+ exit 9
605
+ fi
606
+ help="$(capability_help "$bin" "${help_args[@]}")" || die "cannot read $HARNESS help" 3
607
+ flag="$(unadvertised_flag "$help")" && die "$HARNESS does not advertise required flag $flag" 3
608
+ return 0
508
609
  }
509
610
  [ -z "$ATTEMPT" ] || python3 "$RUNTIME" phase "$ATTEMPT" preflight
510
611
  if [ "$DRY" != 1 ] || [ "$PREFLIGHT" = 1 ]; then preflight_command; fi
@@ -631,6 +732,7 @@ echo "duration_s=$DURATION" >> "$RUN_DIR/$ROLE.resolved"
631
732
  [ "$OUTPUT" != summary ] || echo "forge: role=$ROLE backend_exit=$rc final=$LAST log=$LOG metrics=$ATTEMPT/metrics.json"
632
733
  if [ -f "$RUN_DIR/$ROLE.timedout" ]; then
633
734
  note "$HARNESS exceeded the ${LIMIT}s timeout and was killed after ${DURATION}s — see $LOG"
735
+ write_continue failed timeout
634
736
  exit 7
635
737
  fi
636
738
  # Infrastructure, not the model's work: auth, quota, rate limit, network, or an answer that
@@ -661,11 +763,23 @@ if [ -n "$INFRA" ]; then
661
763
  note "$HARNESS cancelled the $ROLE before it finished: $infra_detail — recorded in $RUN_DIR/$ROLE.infra"
662
764
  else
663
765
  note "$HARNESS failed on infrastructure ($infra_class${infra_after:+, retry after ${infra_after}s}): $infra_detail — see $LOG"
766
+ write_continue failed "infrastructure: $infra_class"
664
767
  exit 8
665
768
  fi
666
769
  fi
667
770
  if [ "$rc" -ne 0 ]; then
668
771
  note "$HARNESS exited $rc — see $LOG"
772
+ if [ -n "$CONTINUE_ID" ]; then
773
+ # The log's last error-looking line, else its last line, on one line.
774
+ reason="$(grep -i -E 'error|fail|not found|no conversation|invalid|unknown' "$LOG" 2>/dev/null | tail -n 1)"
775
+ [ -n "$reason" ] || reason="$(grep '[^[:space:]]' "$LOG" 2>/dev/null | tail -n 1)"
776
+ reason="$(printf '%s' "$reason" | tr '\r\t\n' ' ')"
777
+ [ -n "${reason// }" ] || reason="$HARNESS exited $rc"
778
+ write_continue failed "${reason:0:200}"
779
+ note "the session was not continued; the caller may dispatch fresh"
780
+ exit 9
781
+ fi
669
782
  exit 4
670
783
  fi
784
+ write_continue continued
671
785
  echo "forge: $ROLE done in ${DURATION}s -> $LAST" >&2