@giamme/forge 0.10.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.
- package/CHANGELOG.md +30 -0
- package/SKILL.md +4 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +11 -0
- package/docs/commands.md +8 -3
- package/docs/dataset.md +13 -0
- package/docs/decomposed-runs.md +13 -3
- package/docs/forge-ui.md +8 -3
- package/docs/memory.md +4 -2
- package/docs/reviews.md +73 -2
- package/forge +16 -1
- package/package.json +1 -1
- package/references/dataset.md +86 -4
- package/references/decompose.md +24 -6
- package/references/harnesses.md +43 -1
- package/references/ui.md +4 -1
- package/scripts/forge-config.py +5 -2
- package/scripts/forge-control.py +4 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +344 -16
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +132 -14
- package/scripts/forge-parallel.sh +96 -11
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +72 -12
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge_dataset/__init__.py +7 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +23 -2
- package/scripts/forge_dataset/importer.py +20 -8
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +15 -10
- package/ui/README.md +4 -3
- package/ui/docs/api.md +75 -15
- package/ui/forge_ui/control.py +12 -0
- package/ui/forge_ui/reader.py +184 -11
- package/ui/forge_ui/static/app.js +24 -10
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/findingsview.js +29 -3
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +52 -10
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/style.css +7 -3
package/references/harnesses.md
CHANGED
|
@@ -22,6 +22,7 @@ provider test. See [Fractal execution](fractal.md).
|
|
|
22
22
|
- [opencode](#opencode)
|
|
23
23
|
- [antigravity (agy)](#antigravity-agy)
|
|
24
24
|
- [grok](#grok)
|
|
25
|
+
- [Continuing a reviewer session](#continuing-a-reviewer-session)
|
|
25
26
|
- [Adding a harness](#adding-a-harness)
|
|
26
27
|
|
|
27
28
|
## At a glance
|
|
@@ -265,7 +266,8 @@ passes 30m and exposes `--agy-timeout` to change it.
|
|
|
265
266
|
|
|
266
267
|
```
|
|
267
268
|
grok --model <model> --prompt-file <prompt> --output-format streaming-messages-json \
|
|
268
|
-
--reasoning-effort <effort>
|
|
269
|
+
--reasoning-effort <effort> --background-wait-timeout <limit> \
|
|
270
|
+
(--permission-mode acceptEdits --allow Bash [--deny Edit --deny Write] | --always-approve)
|
|
269
271
|
```
|
|
270
272
|
|
|
271
273
|
The xAI Grok Build CLI, signed in with `grok login` (or `XAI_API_KEY`); `grok models` lists
|
|
@@ -294,6 +296,15 @@ GitHub Copilot) as a second row.
|
|
|
294
296
|
- **Untested:** whether `--permission-mode dontAsk` (deny without cancelling) together with
|
|
295
297
|
explicit Edit/Write/Bash allows would avoid the cancel. Nothing changes until that is
|
|
296
298
|
proven live.
|
|
299
|
+
- **Background work waits as long as forge's limit allows.** After its last turn a headless run
|
|
300
|
+
waits for the background jobs it started (a test suite run in the background, say) and wakes
|
|
301
|
+
the model with each result. By default it waits only 600 s, then stops the work and exits 1:
|
|
302
|
+
a real dwarf whose full suite ran longer ended as an error. forge passes the role's own limit
|
|
303
|
+
as `--background-wait-timeout`, or a day when the limit is off (`--timeout 0`; grok refuses
|
|
304
|
+
0). grok counts from the last turn and forge from the start, so forge's watchdog always fires
|
|
305
|
+
first and the run ends as a timeout. The flag is missing from `grok --help`; preflight checks
|
|
306
|
+
it with `grok --background-wait-timeout 1 --help` instead, because grok rejects an unknown flag
|
|
307
|
+
that comes before `--help` (exit 2).
|
|
297
308
|
- The prompt goes by `--prompt-file`, never argv, and stdin is `/dev/null`.
|
|
298
309
|
|
|
299
310
|
Verified live on 2026-10-19 with a `FORGE_OK` dispatch: exit 0, `permissionMode` acceptEdits,
|
|
@@ -301,6 +312,37 @@ the answer extracted from the `result` line, tokens recorded, nothing left runni
|
|
|
301
312
|
day, grok-4.7 dwarfs implemented and passed review on real tasks (the forge-ui integration run).
|
|
302
313
|
A grok reviewer has not been proven live yet.
|
|
303
314
|
|
|
315
|
+
## Continuing a reviewer session
|
|
316
|
+
|
|
317
|
+
`forge-dispatch.sh qa <spec> ... --continue-session <id>` continues an earlier reviewer session instead of
|
|
318
|
+
starting a fresh one. `<id>` is the session or thread id a dispatch left in `<run-dir>/<role>.session`
|
|
319
|
+
(8 to 128 characters of `[A-Za-z0-9-]`, not starting with `-`). It is accepted for the `qa` role on claude,
|
|
320
|
+
openclaude and codex in custom-prompt mode only; any other role, harness, `--native-review` or a malformed id
|
|
321
|
+
is a usage error (exit 2). Nothing in forge asks for it by default.
|
|
322
|
+
|
|
323
|
+
Probed 2026-10-22 (claude 2.1.307, codex 0.162.0):
|
|
324
|
+
|
|
325
|
+
| Harness | Argv | Notes |
|
|
326
|
+
|---|---|---|
|
|
327
|
+
| claude, openclaude | the fresh QA argv plus `--resume <id>` | Continues from a different working directory and keeps the same session id. Permission flags and the read-only restriction stay; the prompt is still on stdin. |
|
|
328
|
+
| codex | `codex exec resume <id> --json -m MODEL --skip-git-repo-check -o LAST [-c model_reasoning_effort=…] -c 'sandbox_mode="read-only"' -` | Continues from a different working directory. `exec resume` has **no `-C/--cd` and no `-s/--sandbox`**, so the dispatch's own `cd "$REPO"` sets the directory and the sandbox is passed as a config override (the quotes are TOML). The first turn's sandbox was inherited when probed (read-only); the override makes it explicit. Under `--yolo` the bypass flag replaces the override. |
|
|
329
|
+
|
|
330
|
+
The prompt is preceded by a short fixed preamble (this continues your earlier review, the work has changed,
|
|
331
|
+
the working directory is now `<repo>`, re-read files, this round's instructions win). `<role>.prompt` includes
|
|
332
|
+
it. `<role>.continue` records the attempt as `requested=<id>`, then `mode=continued` or `mode=failed` with
|
|
333
|
+
`reason=<one line>`, and is archived with the attempt.
|
|
334
|
+
|
|
335
|
+
**Exit 9** means the session was not continued and the caller may dispatch fresh. Dispatch never retries fresh
|
|
336
|
+
itself, so each dispatch keeps its own honest record. It happens when:
|
|
337
|
+
|
|
338
|
+
- the harness does not advertise the flags continuing needs (`codex exec resume --help`, or `--resume` in
|
|
339
|
+
claude's help) but would pass preflight for a fresh QA dispatch: `reason=unsupported`, before any model call;
|
|
340
|
+
a harness that fails the fresh preflight too keeps exit 3;
|
|
341
|
+
- the harness ran and exited non-zero: `reason=` the log's last error line.
|
|
342
|
+
|
|
343
|
+
A timeout stays exit 7 and an infrastructure stop stays exit 8; both still write `mode=failed`. `doctor --spec`
|
|
344
|
+
without `--continue-session` is unchanged.
|
|
345
|
+
|
|
304
346
|
## Installing forge into a harness
|
|
305
347
|
|
|
306
348
|
Each harness discovers skills differently, and the differences are not guessable — verify
|
package/references/ui.md
CHANGED
|
@@ -38,7 +38,10 @@ run goes on, with the same exit code.
|
|
|
38
38
|
model; the bell tells you when a run finishes or fails, a task fails or a final review gives its
|
|
39
39
|
verdict, and can raise browser notifications while the tab is in the background.
|
|
40
40
|
- **Plan run:** tasks in their planned waves with dependency cables, then the finish line:
|
|
41
|
-
integration, verification and the whole-run final review, each a node with its status.
|
|
41
|
+
integration, verification and the whole-run final review, each a node with its status. Tasks
|
|
42
|
+
merged with `accept` show as accepted (hollow green), not failed. A final review superseded
|
|
43
|
+
by later task merges into the integration branch shows as OUTDATED (hollow unlit) instead of
|
|
44
|
+
as the run's verdict; changes on the user's own branch after an integrate are not tracked. The
|
|
42
45
|
*Execution* view shows the same run on wall-clock time, one block per attempt in the lane it
|
|
43
46
|
ran in, with projected blocks for what has not started and a strip of busy slots against
|
|
44
47
|
`--max-parallel`.
|
package/scripts/forge-config.py
CHANGED
|
@@ -201,8 +201,11 @@ def dataset_block():
|
|
|
201
201
|
if explicit:
|
|
202
202
|
home = Path(explicit).expanduser()
|
|
203
203
|
else:
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
value = os.environ.get('XDG_DATA_HOME') or '' # an empty or relative value counts as unset
|
|
205
|
+
base = Path(value).expanduser() if value else None
|
|
206
|
+
if base is None or not base.is_absolute():
|
|
207
|
+
base = Path.home() / '.local' / 'share'
|
|
208
|
+
home = base / 'forge'
|
|
206
209
|
enabled = os.environ.get('FORGE_DATASET', 'on').strip().lower() not in ('off', '0', 'no', 'false')
|
|
207
210
|
return dict(path=str(home / 'dataset'), enabled=enabled)
|
|
208
211
|
|
package/scripts/forge-control.py
CHANGED
|
@@ -225,6 +225,10 @@ def remove_safe(plan, rel):
|
|
|
225
225
|
|
|
226
226
|
def append_line(plan, rel, line):
|
|
227
227
|
fd, leaf = _open_parent(plan, rel)
|
|
228
|
+
# A missing parent leaves no directory descriptor. Opening the leaf then would pass
|
|
229
|
+
# dir_fd=None and create it in the process cwd, outside the plan, before the close failed.
|
|
230
|
+
if fd is None:
|
|
231
|
+
raise Invalid(f'{rel}: a parent directory is missing')
|
|
228
232
|
try:
|
|
229
233
|
_regular(fd, leaf, rel)
|
|
230
234
|
afd = os.open(leaf, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o644, dir_fd=fd)
|
|
@@ -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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
[ -n "$
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
#
|
|
417
|
-
#
|
|
418
|
-
#
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
#
|
|
422
|
-
|
|
423
|
-
#
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|