@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/SKILL.md +4 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +11 -0
  5. package/docs/commands.md +8 -3
  6. package/docs/dataset.md +13 -0
  7. package/docs/decomposed-runs.md +13 -3
  8. package/docs/forge-ui.md +8 -3
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +73 -2
  11. package/forge +16 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +86 -4
  14. package/references/decompose.md +24 -6
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +4 -1
  17. package/scripts/forge-config.py +5 -2
  18. package/scripts/forge-control.py +4 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +344 -16
  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 +132 -14
  24. package/scripts/forge-parallel.sh +96 -11
  25. package/scripts/forge-prompt.py +61 -4
  26. package/scripts/forge-route.py +72 -12
  27. package/scripts/forge-runtime.py +47 -1
  28. package/scripts/forge_dataset/__init__.py +7 -2
  29. package/scripts/forge_dataset/cli.py +44 -1
  30. package/scripts/forge_dataset/export.py +36 -3
  31. package/scripts/forge_dataset/forecast.py +355 -0
  32. package/scripts/forge_dataset/harvest.py +23 -2
  33. package/scripts/forge_dataset/importer.py +20 -8
  34. package/scripts/forge_dataset/reviewers.py +145 -0
  35. package/scripts/forge_dataset/routing.py +1 -0
  36. package/scripts/forge_jev/__init__.py +7 -3
  37. package/scripts/forge_registry.py +15 -10
  38. package/ui/README.md +4 -3
  39. package/ui/docs/api.md +75 -15
  40. package/ui/forge_ui/control.py +12 -0
  41. package/ui/forge_ui/reader.py +184 -11
  42. package/ui/forge_ui/static/app.js +24 -10
  43. package/ui/forge_ui/static/cloud3d.js +4 -1
  44. package/ui/forge_ui/static/cloudmodel.js +1 -1
  45. package/ui/forge_ui/static/cloudui.js +13 -4
  46. package/ui/forge_ui/static/findingsview.js +29 -3
  47. package/ui/forge_ui/static/graph.js +6 -5
  48. package/ui/forge_ui/static/leaderboard.js +18 -0
  49. package/ui/forge_ui/static/modelstats.js +19 -1
  50. package/ui/forge_ui/static/panel.js +52 -10
  51. package/ui/forge_ui/static/runmodel.js +128 -13
  52. package/ui/forge_ui/static/scene3d.js +32 -15
  53. package/ui/forge_ui/static/style.css +7 -3
@@ -34,6 +34,7 @@
34
34
  # forge_pp_flag, SKILL_DIR and DISPATCH.
35
35
 
36
36
  REVIEW_REQ_BUDGET=81920 # bytes of task requirements put in front of the reviewer
37
+ REVIEW_SINCE_LIMIT=204800 # largest "changes since the previous review" placed inline
37
38
  REVIEW_DIFF_LIMIT=409600 # largest combined diff placed inline in the prompt
38
39
 
39
40
  # --- small helpers ---------------------------------------------------------------------
@@ -259,6 +260,80 @@ PY
259
260
  return 0
260
261
  }
261
262
 
263
+ # --- the previous final review -----------------------------------------------------------
264
+ # review_previous <plan> <current-out>: the newest earlier review dir of this plan that
265
+ # reached a verdict (PASS, FAIL or ACCEPTED) and left a report; prints its absolute path, or
266
+ # nothing. Candidates are final-review-*/ and integrate-*/final-review/, ordered by the epoch
267
+ # in the directory name. `final-review-*` never matches the published `final-review` link,
268
+ # and paths are resolved so a link and its target are one candidate, not two.
269
+ review_previous() {
270
+ python3 - "$1" "$2" <<'PY' 2>/dev/null || true
271
+ import glob, os, re, sys
272
+
273
+ plan, cur = os.path.realpath(sys.argv[1]), os.path.realpath(sys.argv[2])
274
+ found = {}
275
+ for pat, rx in (('final-review-*', r'^final-review-(\d+)-(\d+)$'),
276
+ ('integrate-*/final-review', r'^integrate-(\d+)-(\d+)$')):
277
+ for path in glob.glob(os.path.join(glob.escape(plan), pat)):
278
+ real = os.path.realpath(path)
279
+ owner = os.path.basename(path if pat[0] == 'f' else os.path.dirname(path))
280
+ m = re.match(rx, owner)
281
+ if not m or real == cur or not os.path.isdir(real):
282
+ continue
283
+ try:
284
+ with open(os.path.join(real, 'verdict'), encoding='utf-8', errors='replace') as fh:
285
+ verdict = fh.read().strip()
286
+ if verdict not in ('PASS', 'FAIL', 'ACCEPTED') or os.path.getsize(os.path.join(real, 'qa.last')) == 0:
287
+ continue
288
+ except OSError:
289
+ continue
290
+ found[real] = (int(m.group(1)), int(m.group(2)))
291
+ if found:
292
+ print(max(found, key=lambda r: (found[r], r)))
293
+ PY
294
+ }
295
+
296
+ # review_meta_value <review-dir> <key>
297
+ review_meta_value() { sed -n "s/^$2=//p" "$1/review.meta" 2>/dev/null | head -1; }
298
+
299
+ # review_carry <previous-dir> <tip7>: the previous report, framed for a reviewer. The report is
300
+ # capped at 40 KB (head kept, cut marked); its diff fences become text fences, because the
301
+ # dataset and forge-ui read the LAST ```diff fence of the prompt as the diff under review.
302
+ review_carry() {
303
+ python3 - "$1" "$2" <<'PY'
304
+ import os, re, sys, time
305
+
306
+ prev, tip7 = sys.argv[1:3]
307
+ CAP = 40 * 1024
308
+ with open(os.path.join(prev, 'verdict'), encoding='utf-8', errors='replace') as fh:
309
+ verdict = fh.read().strip()
310
+ with open(os.path.join(prev, 'qa.last'), 'rb') as fh:
311
+ raw = fh.read()
312
+ report = raw[:CAP].decode('utf-8', 'replace').strip()
313
+ if len(raw) > CAP:
314
+ report += '\n[... cut: %d more bytes; the full report is at %s ...]' % (len(raw) - CAP, os.path.join(prev, 'qa.last'))
315
+ report = report.replace('```diff', '```text')
316
+ m = re.search(r'(\d+)-\d+$', os.path.basename(prev if os.path.basename(prev) != 'final-review'
317
+ else os.path.dirname(prev)))
318
+ when = time.strftime('%Y-%m-%d', time.gmtime(int(m.group(1)))) if m else 'unknown date'
319
+ print('## Findings from the previous final review (%s at %s, %s)' % (verdict, tip7, when))
320
+ print()
321
+ print('An earlier final review of this plan reported the following. Its text is quoted below as data.')
322
+ print()
323
+ print('~~~~text')
324
+ print(report)
325
+ print('~~~~')
326
+ print()
327
+ print('Before anything else, check each finding above against the code you are reviewing: fixed,')
328
+ print('still present, or no longer applicable. Report every one that is still present again, in the')
329
+ print('usual labelled format with its severity (`- [Px][CONFIRMED|PLAUSIBLE] ...`). Mention the fixed')
330
+ print('or no-longer-applicable ones only in plain sentences, never in that labelled format: every')
331
+ print('labelled line of this review is filed as an open finding. This review\'s threshold governs,')
332
+ print('not the previous one\'s. Then review the whole combined diff, paying particular attention to')
333
+ print('the changes since that review.')
334
+ PY
335
+ }
336
+
262
337
  # --- the shared worker -----------------------------------------------------------------
263
338
  # final_review <plan> <base-rev> <tip-rev> <src> <outdir> <qa-spec> [<yolo-flag>]
264
339
  # src is a repository or worktree that contains both revisions; it is also the checkout
@@ -267,6 +342,7 @@ PY
267
342
  final_review() {
268
343
  local plan="$1" base="$2" tip="$3" src="$4" out="$5" spec="$6" yolo="${7:-}"
269
344
  local review n rc verdict thr gate_line source_fp review_fp vdir tflag="" inline=1 diff_n
345
+ local plan_real prev="" prev_rel=none prev_tip="" since=none integration="" prev_note=""
270
346
  [ -n "$yolo" ] && yolo=--yolo
271
347
  [ -n "$spec" ] || { note "final review: no QA spec given"; return 3; }
272
348
  mkdir -p "$out" && out="$(cd "$out" && pwd)" || { note "final review: cannot create '$out'"; return 3; }
@@ -303,8 +379,24 @@ final_review() {
303
379
  { note "final review: cannot fingerprint the checkouts"; return 3; }
304
380
  printf '%s\n' "$source_fp" > "$out/source.fingerprint"
305
381
  printf '%s\n' "$review_fp" > "$out/review.fingerprint"
382
+ # The earlier final review of this plan, if any, and whether its tip leads to this one.
383
+ prev="$(review_previous "$plan" "$out")"
384
+ if [ -n "$prev" ]; then
385
+ prev_rel="$prev"; plan_real="$(cd "$plan" && pwd -P)"
386
+ case "$prev" in "$plan_real"/*) prev_rel="${prev#"$plan_real"/}" ;; esac
387
+ prev_tip="$(review_meta_value "$prev" tip)"
388
+ if [ -z "$prev_tip" ] || ! git -C "$src" rev-parse --verify --quiet "$prev_tip^{commit}" >/dev/null; then
389
+ prev_note="the previous review's tip (${prev_tip:-not recorded}) is not a commit in this checkout, so the range since it cannot be shown."
390
+ elif git -C "$src" merge-base --is-ancestor "$prev_tip" "$tip" 2>/dev/null; then
391
+ prev_tip="$(git -C "$src" rev-parse "$prev_tip^{commit}")"; since="$prev_tip"
392
+ else
393
+ prev_note="the previous review's tip ($(printf '%.7s' "$prev_tip")) is not an ancestor of this one (it judged a different line of history, for example an integrate candidate that included your branch), so the range since it cannot be shown."
394
+ fi
395
+ fi
396
+ [ -s "$plan/accepted.integration" ] && integration="$(head -1 "$plan/accepted.integration" | tr -d ' \r')"
306
397
  {
307
398
  echo "base=$base"; echo "tip=$tip"; echo "source=$src"; echo "spec=$spec"; echo "threshold=${thr:-none}"
399
+ echo "integration=${integration:-none}"; echo "previous=$prev_rel"; echo "since=$since"
308
400
  } > "$out/review.meta"
309
401
 
310
402
  review_task_table "$plan" > "$out/tasks.summary"
@@ -318,11 +410,44 @@ final_review() {
318
410
  diff_n="$(wc -c < "$out/combined.diff" | tr -d ' ')"
319
411
  if [ "$diff_n" -gt "$REVIEW_DIFF_LIMIT" ]; then inline=0; rm -f "$out/combined.diff"; fi
320
412
 
413
+ # What the earlier review said, and the change since it. Nothing here may open a ```diff
414
+ # fence: when the combined diff is not inlined, the prompt must hold none.
415
+ rm -f "$out/previous.md"
416
+ if [ -n "$prev" ]; then
417
+ {
418
+ review_carry "$prev" "$(printf '%.7s' "${prev_tip:-unknown}")"
419
+ echo
420
+ if [ -n "$prev_note" ]; then
421
+ echo "Note: $prev_note"
422
+ elif [ "$since" = "$tip" ]; then
423
+ echo "### Changes since that review ($(printf '%.7s' "$since")..$(printf '%.7s' "$tip"))"
424
+ echo "There are no changes since that review: it judged this same commit."
425
+ else
426
+ echo "### Changes since that review ($(printf '%.7s' "$since")..$(printf '%.7s' "$tip"))"
427
+ git -C "$src" diff --binary "$since" "$tip" -- . ':(exclude).forge' 2>/dev/null |
428
+ head -c $((REVIEW_SINCE_LIMIT + 1)) > "$out/since.diff"
429
+ if [ "$inline" = 1 ] && [ "$(wc -c < "$out/since.diff" | tr -d ' ')" -le "$REVIEW_SINCE_LIMIT" ] &&
430
+ ! grep -q '```diff' "$out/since.diff"; then
431
+ echo '```patch'; cat "$out/since.diff"; echo '```'
432
+ else
433
+ echo "Too large (or quoting a diff fence) to inline here; the files changed since that review:"
434
+ echo '```text'
435
+ git -C "$src" diff --stat "$since" "$tip" -- . ':(exclude).forge' 2>/dev/null
436
+ echo '```'
437
+ echo "(The previous tip is not in your checkout; use the combined diff to look at those files.)"
438
+ fi
439
+ rm -f "$out/since.diff"
440
+ fi
441
+ echo
442
+ } > "$out/previous.md"
443
+ fi
444
+
321
445
  {
322
446
  cat "$out/context.md"
323
447
  python3 "$SKILL_DIR/scripts/forge-contract.py" qa --part head --kind final
324
448
  echo
325
449
  cat "$out/notes.md"
450
+ [ -s "$out/previous.md" ] && cat "$out/previous.md"
326
451
  python3 "$SKILL_DIR/scripts/forge-contract.py" qa --part tail --kind final ${thr:+--threshold "$thr"}
327
452
  echo
328
453
  if [ "$inline" = 1 ]; then
@@ -338,11 +463,41 @@ final_review() {
338
463
 
339
464
  [ -s "$plan/timeout" ] && tflag="--timeout $(cat "$plan/timeout")"
340
465
  note "final review: $spec on $(git -C "$src" diff --shortstat "$base" "$tip" -- . ':(exclude).forge' 2>/dev/null | sed 's/^ *//')"
466
+ # Opt-in (plan --reviewer-session): continue the previous final review's session when it ran
467
+ # as this one does, by the same rule as per-task QA. Anything else is a fresh dispatch.
468
+ local rs_mode=fresh rs_reason="" rs_id="" rs_cont=()
469
+ if [ -f "$plan/reviewer_session" ]; then
470
+ if [ -n "${FORGE_FRACTAL_RUN:-}" ] || forge_plan_is_fractal "$plan"; then
471
+ rs_reason=fractal
472
+ elif [ -z "$prev" ]; then
473
+ rs_reason=first-round
474
+ else
475
+ IFS=$'\t' read -r rs_mode rs_reason rs_id < <(review_session_decide \
476
+ "$prev/qa.session" "$prev/qa.resolved" "$out/doctor.out")
477
+ [ "$rs_mode" = continued ] && rs_cont=(--continue-session "$rs_id")
478
+ fi
479
+ fi
480
+ local qa_cmd=(/bin/bash "$DISPATCH" qa "$spec" --repo "$review" --run-dir "$out"
481
+ --review-base "$(cat "$out/review.base")" --ripwire-query-file "$out/context.md"
482
+ --prompt-file "$out/qa.input" ${yolo:+"$yolo"} $tflag --output "${FORGE_OUTPUT:-summary}")
341
483
  FORGE_CAPABILITY_CACHE="${FORGE_CAPABILITY_CACHE:-$plan/capabilities}" \
342
- /bin/bash "$DISPATCH" qa "$spec" --repo "$review" --run-dir "$out" \
343
- --review-base "$(cat "$out/review.base")" --ripwire-query-file "$out/context.md" \
344
- --prompt-file "$out/qa.input" $yolo $tflag --output "${FORGE_OUTPUT:-summary}" > "$out/qa.out" 2>&1
484
+ "${qa_cmd[@]}" ${rs_cont[@]+"${rs_cont[@]}"} > "$out/qa.out" 2>&1
345
485
  rc=$?
486
+ if [ "$rc" = 9 ] && [ "$rs_mode" = continued ]; then
487
+ # Not continued (unsupported, or it failed): the same input again, fresh. The record of why
488
+ # is the first dispatch's, which the second one clears.
489
+ if [ "$(sed -n 's/^reason=//p' "$out/qa.continue" 2>/dev/null | head -1)" = unsupported ]; then
490
+ rs_reason=continue-unsupported
491
+ else
492
+ rs_reason=continue-failed
493
+ fi
494
+ rs_mode=fresh
495
+ mv "$out/qa.out" "$out/qa.continue.out" 2>/dev/null || true
496
+ FORGE_CAPABILITY_CACHE="${FORGE_CAPABILITY_CACHE:-$plan/capabilities}" \
497
+ "${qa_cmd[@]}" > "$out/qa.out" 2>&1
498
+ rc=$?
499
+ fi
500
+ [ ! -f "$plan/reviewer_session" ] || { echo "session=$rs_mode"; echo "session_reason=$rs_reason"; } >> "$out/review.meta"
346
501
  if [ "${FORGE_OUTPUT:-summary}" = full ]; then cat "$out/qa.out"; fi
347
502
  case "$rc" in
348
503
  0) ;;
@@ -40,17 +40,24 @@ infra_stop() { # infra_stop <tdir> <id> <role> <attempt>
40
40
  note "$id: $role dispatch hit an infrastructure failure (${cls:-unknown}) — no attempt spent; re-run to resume"
41
41
  }
42
42
 
43
- # A QA-only resume is trusted only while the worktree is still exactly what was
44
- # reviewed. Anything else clears the marker and the full path runs.
45
- resume_valid() { # resume_valid <tdir> <wt>
43
+ # True when the worktree is still exactly what was reviewed. Reads and deletes nothing:
44
+ # the reviewed commit, the fingerprint and changes.diff are present, and the worktree's
45
+ # HEAD and fingerprint equal them.
46
+ reviewed_state_matches() { # reviewed_state_matches <tdir> <wt>
46
47
  local tdir="$1" wt="$2"
47
- [ "$(cat "$tdir/resume" 2>/dev/null)" = qa ] || return 1
48
48
  [ -s "$tdir/reviewed.commit" ] && [ -s "$tdir/source.fingerprint" ] && [ -s "$tdir/changes.diff" ] || return 1
49
49
  [ -e "$wt/.git" ] || return 1
50
50
  [ "$(git -C "$wt" rev-parse HEAD 2>/dev/null)" = "$(cat "$tdir/reviewed.commit")" ] || return 1
51
51
  [ "$(forge_fingerprint "$wt")" = "$(cat "$tdir/source.fingerprint")" ]
52
52
  }
53
53
 
54
+ # A QA-only resume is trusted only while the worktree is still exactly what was
55
+ # reviewed. Anything else clears the marker and the full path runs.
56
+ resume_valid() { # resume_valid <tdir> <wt>
57
+ [ "$(cat "$1/resume" 2>/dev/null)" = qa ] || return 1
58
+ reviewed_state_matches "$1" "$2"
59
+ }
60
+
54
61
  # The next entry's kind, decided before it starts with the test do_task_body applies
55
62
  # (resume_valid): true when it only re-runs QA. A marker the worktree no longer matches is
56
63
  # cleared here, so whoever prepares the entry and do_task_body agree that it is a full one.
@@ -128,10 +135,37 @@ settle_pin() {
128
135
  # True when the task's next retry only re-runs QA: a `resume` marker (a QA-stage infrastructure
129
136
  # stop), or a verdict-less review of committed work, which prepare_retry turns into the marker
130
137
  # but has not yet when the run summary is printed. Such a retry leaves a pin in place.
138
+ # Both are believed only while the worktree is still what was reviewed — the comparison
139
+ # resume_valid uses, and the marker prepare_retry is about to write gets the same test.
140
+ # This never deletes the marker; resume_check owns that.
131
141
  qa_only_next() { # qa_only_next <plan> <id>
132
- local tdir="$1/tasks/$2"
133
- [ "$(cat "$tdir/resume" 2>/dev/null)" = qa ] && return 0
134
- [ "$(task_status "$1" "$2")" = UNKNOWN ] && [ -s "$tdir/reviewed.commit" ]
142
+ local tdir="$1/tasks/$2" root="" wt=""
143
+ root="$(cat "$1/wt_root" 2>/dev/null)"
144
+ [ -n "$root" ] && wt="$root/$2"
145
+ if [ "$(cat "$tdir/resume" 2>/dev/null)" = qa ]; then
146
+ [ -n "$wt" ] && reviewed_state_matches "$tdir" "$wt"
147
+ return
148
+ fi
149
+ if [ "$(task_status "$1" "$2")" = UNKNOWN ] && [ -s "$tdir/reviewed.commit" ]; then
150
+ [ -n "$wt" ] && reviewed_state_matches "$tdir" "$wt"
151
+ return
152
+ fi
153
+ return 1
154
+ }
155
+
156
+ qa_only_next_flag() { # qa_only_next_flag <plan> <id> -> yes | no, for `escalation --check`
157
+ if qa_only_next "$1" "$2"; then printf 'yes\n'; else printf 'no\n'; fi
158
+ }
159
+
160
+ # tasks/<id>/escalations.tsv, probed and appended the way forge-control writes a plan file
161
+ # (forge-route.py record loads that code): every component below the plan is a real
162
+ # directory, and the file is absent or a regular file. A symlink at any level is a refusal.
163
+ escalation_record_ok() { # escalation_record_ok <plan> <id>
164
+ python3 "$SKILL_DIR/scripts/forge-route.py" record --plan "$1" --task "$2" --probe
165
+ }
166
+
167
+ escalation_record_line() { # escalation_record_line <plan> <id> <line>
168
+ python3 "$SKILL_DIR/scripts/forge-route.py" record --plan "$1" --task "$2" --line "$3"
135
169
  }
136
170
 
137
171
  # Writes the task's implementer column in tasks.tsv: a temp file, then a rename.
@@ -142,6 +176,80 @@ set_dwarf() { # set_dwarf <plan> <id> <spec>
142
176
  }
143
177
 
144
178
  # --- retry preparation ----------------------------------------------------------
179
+ # Carries the last review's report forward as the findings of the next attempt, and records
180
+ # which commit that report judged (what reviewed.commit holds now, since qa.last is that
181
+ # review's). The two are written together: the commit first, so a report never sits beside a
182
+ # commit that belongs to an older one. Repeating the call writes the same bytes.
183
+ carry_findings() { # carry_findings <tdir>
184
+ local tdir="$1"
185
+ if [ -s "$tdir/reviewed.commit" ]; then
186
+ cp "$tdir/reviewed.commit" "$tdir/.retry_findings.commit.tmp" \
187
+ && mv "$tdir/.retry_findings.commit.tmp" "$tdir/retry_findings.commit"
188
+ else
189
+ rm -f "$tdir/retry_findings.commit"
190
+ fi
191
+ # The reviewer session that wrote these findings, and what it ran as, kept beside them for a
192
+ # plan with --reviewer-session. Removed first, so an older session never sits next to newer
193
+ # findings; a review that left no session leaves none here.
194
+ rm -f "$tdir/retry_findings.session" "$tdir/retry_findings.resolved"
195
+ cp "$tdir/qa.last" "$tdir/retry_findings.md"
196
+ [ ! -f "$tdir/qa.session" ] || cp "$tdir/qa.session" "$tdir/retry_findings.session" 2>/dev/null || true
197
+ [ ! -f "$tdir/qa.resolved" ] || cp "$tdir/qa.resolved" "$tdir/retry_findings.resolved" 2>/dev/null || true
198
+ }
199
+
200
+ # --- reviewer-session continuation (plan --reviewer-session, an opt-in experiment) ------------
201
+ # review_session_decide <session-file> <resolved-file> <resolution-file>: whether this review
202
+ # round continues the earlier reviewer's session. Prints `mode<TAB>reason<TAB>session`; the
203
+ # session is the id to continue, and empty for every fresh answer.
204
+ # session-file the id the earlier review ran under (retry_findings.session / qa.session)
205
+ # resolved-file that review's resolution (retry_findings.resolved / qa.resolved)
206
+ # resolution-file this dispatch's resolution (doctor output): harness=, model=, effort=
207
+ # The caller has already handled the cases that need no files (first round, Fractal). Anything
208
+ # that cannot be checked is fresh; nothing here aborts.
209
+ review_session_decide() {
210
+ local sf="$1" rf="$2" cf="$3" id="" k a b h
211
+ [ -f "$sf" ] && id="$(head -1 "$sf" 2>/dev/null | tr -d '\r')"
212
+ if [ -z "$id" ]; then printf 'fresh\tno-session\t\n'; return 0; fi
213
+ case "$id" in *[!A-Za-z0-9-]*) printf 'fresh\tinvalid-session\t\n'; return 0 ;; esac
214
+ if [ "${#id}" -lt 8 ] || [ "${#id}" -gt 128 ]; then printf 'fresh\tinvalid-session\t\n'; return 0; fi
215
+ h="$(sed -n 's/^harness=//p' "$cf" 2>/dev/null | head -1)"
216
+ if [ -z "$h" ]; then printf 'fresh\tharness-unsupported\t\n'; return 0; fi
217
+ for k in harness model effort; do
218
+ a="$(sed -n "s/^$k=//p" "$rf" 2>/dev/null | head -1)"
219
+ b="$(sed -n "s/^$k=//p" "$cf" 2>/dev/null | head -1)"
220
+ if [ -z "$a" ] || [ "$a" != "$b" ]; then printf 'fresh\tspec-changed\t\n'; return 0; fi
221
+ done
222
+ case "$h" in
223
+ claude|openclaude|codex) printf 'continued\tsame-spec\t%s\n' "$id" ;;
224
+ *) printf 'fresh\tharness-unsupported\t\n' ;;
225
+ esac
226
+ return 0
227
+ }
228
+
229
+ # The diff between the attempt the carried findings judged and the one under review now, as
230
+ # files a later gate can reuse (tasks/<id>/changes.since.{diff,range,stat}). All three go
231
+ # when there is no valid range: no findings, no recorded commit, a commit that is unknown or
232
+ # no ancestor of the reviewed one, or nothing changed.
233
+ attempt_delta() { # attempt_delta <tdir> <worktree> <reviewed-commit>
234
+ local tdir="$1" wt="$2" reviewed="$3" from="" to=""
235
+ rm -f "$tdir/.changes.since.diff" "$tdir/.changes.since.stat"
236
+ if [ -s "$tdir/retry_findings.md" ] && [ -s "$tdir/retry_findings.commit" ] \
237
+ && from="$(git -C "$wt" rev-parse --verify -q "$(cat "$tdir/retry_findings.commit")^{commit}")" \
238
+ && to="$(git -C "$wt" rev-parse --verify -q "$reviewed^{commit}")" \
239
+ && git -C "$wt" merge-base --is-ancestor "$from" "$to" \
240
+ && git -C "$wt" diff --binary "$from" "$to" -- . ':(exclude).forge' > "$tdir/.changes.since.diff" 2>/dev/null \
241
+ && [ -s "$tdir/.changes.since.diff" ] \
242
+ && git -C "$wt" diff --stat "$from" "$to" -- . ':(exclude).forge' > "$tdir/.changes.since.stat" 2>/dev/null; then
243
+ printf '%s %s\n' "$from" "$to" > "$tdir/changes.since.range"
244
+ mv "$tdir/.changes.since.diff" "$tdir/changes.since.diff"
245
+ mv "$tdir/.changes.since.stat" "$tdir/changes.since.stat"
246
+ return 0
247
+ fi
248
+ rm -f "$tdir/.changes.since.diff" "$tdir/.changes.since.stat" \
249
+ "$tdir/changes.since.diff" "$tdir/changes.since.range" "$tdir/changes.since.stat"
250
+ return 0
251
+ }
252
+
145
253
  # Shared by `retry` (a human asked) and the scheduler's automatic retries. The findings
146
254
  # are the whole point of retrying rather than re-running: the dwarf is told what was
147
255
  # wrong with its own previous attempt.
@@ -163,14 +271,15 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
163
271
  # all"), and copying that over would throw away the actual findings the previous
164
272
  # reviewer gave — the one thing a retry exists to carry forward.
165
273
  elif [ "$verdict" = PASS ] || [ "$verdict" = FAIL ]; then
166
- cp "$tdir/qa.last" "$tdir/retry_findings.md"
274
+ carry_findings "$tdir"
167
275
  elif grep -q 'FORGE_VERDICT' "$tdir/qa.last" 2>/dev/null; then
168
- cp "$tdir/qa.last" "$tdir/retry_findings.md"
276
+ carry_findings "$tdir"
169
277
  elif [ -s "$tdir/retry_findings.md" ]; then
170
278
  note "$id: no new review since the last retry — reusing the previous findings"
171
279
  else
172
280
  note "$id: no previous QA findings to pass on (last status was $st)"
173
281
  : > "$tdir/retry_findings.md"
282
+ rm -f "$tdir/retry_findings.commit" "$tdir/retry_findings.session" "$tdir/retry_findings.resolved"
174
283
  fi
175
284
  # A QA-only resume (an unreviewed verdict, or a QA-stage infrastructure stop) re-asks the
176
285
  # reviewer about work that is already committed; the dwarf does not run, so it is no moment
@@ -197,6 +306,10 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
197
306
  note "$id: the escalation pin waits for the next full retry (this one only re-runs QA)"
198
307
  elif [ "$has_pin" = 1 ] && forge_plan_is_fractal "$PLAN"; then
199
308
  note "$id: an escalation pin is set, but Fractal plans never apply pins — leaving it in place"
309
+ elif [ "$has_pin" = 1 ] && ! escalation_record_ok "$PLAN" "$id"; then
310
+ # Before the claim: a refused record leaves the pin unclaimed and tasks.tsv as it was.
311
+ note "$id: could not record the escalation in escalations.tsv; nothing was retried"
312
+ return 1
200
313
  elif [ "$has_pin" = 1 ] && pinned="$(claim_pin "$tdir")"; then
201
314
  esc_source=pin
202
315
  newdwarf="$pinned"
@@ -225,10 +338,10 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
225
338
  # Puts the next implementer in place: tasks.tsv, the escalations.tsv record and the dataset
226
339
  # event. Returns 1, with tasks.tsv and a claimed pin given back, when nothing was changed.
227
340
  write_escalation() { # write_escalation <plan> <id> <from> <to> <retry|pin|policy>
228
- local PLAN="$1" id="$2" cur="$3" newdwarf="$4" esc_source="$5" tdir="$1/tasks/$2"
341
+ local PLAN="$1" id="$2" cur="$3" newdwarf="$4" esc_source="$5" tdir="$1/tasks/$2" line
229
342
  # A pin is held to its record: whether the record can be written is learned before
230
343
  # anything is changed, so a failure there leaves tasks.tsv and the pin as they were.
231
- if [ "$esc_source" = pin ] && ! : >> "$tdir/escalations.tsv" 2>/dev/null; then
344
+ if [ "$esc_source" = pin ] && ! escalation_record_ok "$PLAN" "$id"; then
232
345
  note "$id: could not record the escalation in escalations.tsv; nothing was retried"
233
346
  settle_pin "$tdir" restore
234
347
  return 1
@@ -247,8 +360,9 @@ write_escalation() { # write_escalation <plan> <id> <from> <to> <retry|pin|polic
247
360
  # The rewrite loses which model failed first, and an escalation is exactly the record
248
361
  # of "this tier was too weak for this task". The last column says who decided: forge's
249
362
  # policy, a human's `retry --dwarf`, or a pin.
250
- if ! printf '%s\t%s\t%s\t%s\t%s\n' "$(date +%s)" "$(cat "$tdir/attempt" 2>/dev/null || echo 0)" \
251
- "$cur" "$newdwarf" "$esc_source" >> "$tdir/escalations.tsv" && [ "$esc_source" = pin ]; then
363
+ line="$(printf '%s\t%s\t%s\t%s\t%s' "$(date +%s)" "$(cat "$tdir/attempt" 2>/dev/null || echo 0)" \
364
+ "$cur" "$newdwarf" "$esc_source")"
365
+ if ! escalation_record_line "$PLAN" "$id" "$line" && [ "$esc_source" = pin ]; then
252
366
  # The rewrite is undone with the record, so the pin given back is still a real
253
367
  # escalation next time, not a same-spec one that hides the model it replaced.
254
368
  if [ "$newdwarf" = "$cur" ] || set_dwarf "$PLAN" "$id" "$cur"; then
@@ -282,6 +396,9 @@ prepare_resume() { # prepare_resume <plan> <id>
282
396
  note "$id: the escalation pin waits for the next full retry (this resume only re-runs QA)"
283
397
  elif forge_plan_is_fractal "$PLAN"; then
284
398
  note "$id: an escalation pin is set, but Fractal plans never apply pins — leaving it in place"
399
+ elif ! escalation_record_ok "$PLAN" "$id"; then
400
+ note "$id: could not record the escalation in escalations.tsv; nothing was retried"
401
+ return 1
285
402
  elif pinned="$(claim_pin "$tdir")"; then
286
403
  note "$id: applying the pinned escalation to $pinned"
287
404
  write_escalation "$PLAN" "$id" "$(field "$PLAN/tasks.tsv" "$id" dwarf)" "$pinned" pin || return 1
@@ -466,7 +583,8 @@ report_escalations() { # report_escalations <plan>
466
583
  continue
467
584
  fi
468
585
  if [ -s "$plan/tasks/$id/escalation.next" ] && [ "$(task_status "$plan" "$id")" != MERGED ]; then
469
- python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$plan" --task "$id" --check >/dev/null 2>&1 || true
586
+ python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$plan" --task "$id" --check \
587
+ --qa-only-next "$(qa_only_next_flag "$plan" "$id")" >/dev/null 2>&1 || true
470
588
  fi
471
589
  if [ -s "$plan/tasks/$id/split.suggested" ]; then
472
590
  [ "$n" = 0 ] && echo "$head"
@@ -9,7 +9,7 @@
9
9
  # forge-parallel.sh run <plan-dir> [--max-parallel N] [--yolo-dwarf] [--yolo-qa]
10
10
  # [--verify <command>] [--setup <command>] [--dry-run]
11
11
  # [--timeout S] [--retry-failed N] [--infra-retries M]
12
- # [--qa-threshold P0|P1|P2|P3] [--verify-retries N]
12
+ # [--qa-threshold P0|P1|P2|P3] [--verify-retries N] [--no-forecast]
13
13
  # forge-parallel.sh retry <plan-dir> <task-id> [--dwarf <spec>] [--setup <command>]
14
14
  # [--timeout S] [--qa-threshold Pn] [--verify-retries N]
15
15
  # forge-parallel.sh integrate <plan-dir> --approved [--verify-retries N] [--final-review <qa-spec>]
@@ -30,6 +30,11 @@
30
30
  # by a listed reviewer of another model family, recorded and
31
31
  # never read (measures reviewer agreement). `none` turns it off
32
32
  # --shadow-rate R that share, 0..1 (default 0.1)
33
+ # --reviewer-session opt-in experiment: a later review round continues the reviewer's
34
+ # earlier session (claude, openclaude, codex) when the QA spec is
35
+ # unchanged, and starts fresh whenever it cannot; each round is
36
+ # logged in tasks/<id>/review_sessions.tsv and tagged in the
37
+ # dataset. `--no-reviewer-session` turns it off (the default)
33
38
  # --explore E on by default at 0.2 (0 turns it off, and is kept): a row left
34
39
  # to forge gets, with probability E/2, its neighbouring model
35
40
  # (one tier away in the given pools) instead of its pool's turn;
@@ -129,7 +134,7 @@ pool_pick() { # pool_pick <comma-list> <index>
129
134
  do_plan() {
130
135
  local PLAN="${1:?plan needs a plan dir}"; shift
131
136
  local REPO="" D_ANY="" D_HI="" D_MD="" D_LO="" Q_ANY="" Q_HI="" Q_MD="" Q_LO=""
132
- local PLANNER="" NOMEM=0 jev_rc=0 DLEVEL="" SHADOW_QA="" SHADOW_RATE="" EXPLORE="" FAIL_FIRST=""
137
+ local PLANNER="" NOMEM=0 jev_rc=0 DLEVEL="" SHADOW_QA="" SHADOW_RATE="" EXPLORE="" FAIL_FIRST="" REVIEWER_SESSION=""
133
138
  while [ $# -gt 0 ]; do
134
139
  case "$1" in
135
140
  --repo) REPO="${2:?}"; shift 2 ;;
@@ -157,6 +162,8 @@ do_plan() {
157
162
  --ui|--no-ui) forge_ui_flag "$1"; shift ;;
158
163
  --fail-first) FAIL_FIRST=on; shift ;;
159
164
  --no-fail-first) FAIL_FIRST=off; shift ;;
165
+ --reviewer-session) REVIEWER_SESSION=on; shift ;;
166
+ --no-reviewer-session) REVIEWER_SESSION=off; shift ;;
160
167
  *)
161
168
  # `case` is not a loop, so `continue` here belongs to the enclosing while.
162
169
  if [ "$JEV_OPTS" = 1 ]; then
@@ -192,6 +199,9 @@ do_plan() {
192
199
  || printf '%s\n' "$SHADOW_RATE" > "$PLAN/shadow_rate"
193
200
  # Fail-first evidence (opt-in, advisory) is part of the plan too: every task's review gets it.
194
201
  case "$FAIL_FIRST" in on) : > "$PLAN/fail_first" ;; off) rm -f "$PLAN/fail_first" ;; esac
202
+ # Reviewer-session continuation (opt-in experiment): the file holds `on`. Readers only
203
+ # test that it exists; the dataset records its content.
204
+ case "$REVIEWER_SESSION" in on) printf '%s\n' on > "$PLAN/reviewer_session" ;; off) rm -f "$PLAN/reviewer_session" ;; esac
195
205
  # Exploration is decided here and only here, so the table and preflight see what will run.
196
206
  # On by default: choosing models from measured pass rates needs some randomized picks, and
197
207
  # history had almost none (9 tasks ever ran on more than one spec). The flag wins, then
@@ -480,6 +490,9 @@ render_table() {
480
490
  echo "fail-first: on, but $(cat "$PLAN/repo")/.forge/verify-subset (a test command with {files}) is missing, so nothing will run"
481
491
  fi
482
492
  fi
493
+ if [ -f "$PLAN/reviewer_session" ]; then
494
+ echo "reviewer-session: a later review round continues the reviewer's earlier session when its QA spec is unchanged (experiment; falls back to a fresh session)"
495
+ fi
483
496
  echo
484
497
  local w id dws starred=0
485
498
  for w in $(awk -F'\t' '{print $1}' "$W" | sort -un); do
@@ -868,8 +881,11 @@ do_task_body() (
868
881
  echo "$review_fp" > "$tdir/review.fingerprint"
869
882
 
870
883
  forge_metric_phase preparation
871
- local jev_loaded_qa=0 subset_cmd="" qa_thr
884
+ local jev_loaded_qa=0 subset_cmd="" qa_thr rs_on=0
872
885
  qa_thr="$(qa_threshold_for "$PLAN" "$id")"
886
+ # Read once per round, like the threshold: continuing the earlier reviewer's session is an
887
+ # opt-in experiment (plan --reviewer-session), and a round never changes its mind midway.
888
+ [ -f "$PLAN/reviewer_session" ] && rs_on=1
873
889
 
874
890
  # A SEPARATE file from .forge/verify, and opt-in. It must contain {files}, because
875
891
  # only the project knows how its runner takes a file list -- `pytest -q {files}` works,
@@ -895,11 +911,29 @@ do_task_body() (
895
911
  # on the review snapshot, like the subset run above; the reviewer gets the evidence.
896
912
  rm -f "$tdir/failfirst.json" "$tdir/failfirst.txt"
897
913
  [ ! -f "$PLAN/fail_first" ] || forge_fail_first "$review" "$tdir" "$REPO"
914
+ # What changed since the review whose findings this reviewer is handed. Fractal keeps
915
+ # today's prompt unless it retries, as changes.prev.diff does. dwarf.last is the reply of
916
+ # the attempt under review: a dwarf run just rewrote it, or $resume (the validated QA-only
917
+ # path, never the raw marker) guarantees the reviewed commit is the one it describes.
918
+ local prompt_extra=()
919
+ if [ -z "${FORGE_FRACTAL_RUN:-}" ] || [ "${FORGE_FRACTAL_RETRY:-}" = 1 ]; then
920
+ prompt_extra+=(--role qa)
921
+ attempt_delta "$tdir" "$wt" "$reviewed"
922
+ if [ -s "$tdir/changes.since.diff" ]; then
923
+ prompt_extra+=(--retry-diff "$tdir/changes.since.diff" --retry-stat "$tdir/changes.since.stat"
924
+ --retry-range "$(awk '{ print substr($1, 1, 7) ".." substr($2, 1, 7) }' "$tdir/changes.since.range")")
925
+ fi
926
+ if [ -s "$tdir/retry_findings.md" ] && [ -s "$tdir/dwarf.last" ]; then
927
+ prompt_extra+=(--reply "$tdir/dwarf.last")
928
+ fi
929
+ else
930
+ rm -f "$tdir/changes.since.diff" "$tdir/changes.since.range" "$tdir/changes.since.stat"
931
+ fi
898
932
  # QA uses the dispatch-time ownership and baseline, never later merge state.
899
933
  /bin/bash "$MEMORY" inject "$REPO" qa --task "$tdir/prompt.md" \
900
934
  --run-dir "$tdir" > "$tdir/qa.memory"
901
935
  {
902
- python3 "$SKILL_DIR/scripts/forge-prompt.py" --requirements "$tdir/prompt.md" --capsule "$tdir/baseline.capsule" --approach "$tdir/approach.md" --retry "$tdir/retry_findings.md" --memory "$tdir/dwarf.memory" --memory "$tdir/qa.memory"
936
+ python3 "$SKILL_DIR/scripts/forge-prompt.py" --requirements "$tdir/prompt.md" --capsule "$tdir/baseline.capsule" --approach "$tdir/approach.md" --retry "$tdir/retry_findings.md" ${prompt_extra[@]+"${prompt_extra[@]}"} --memory "$tdir/dwarf.memory" --memory "$tdir/qa.memory"
903
937
  python3 "$SKILL_DIR/scripts/forge-contract.py" qa --part head --kind task
904
938
  echo
905
939
  if [ -s "$tdir/failfirst.txt" ]; then cat "$tdir/failfirst.txt"; echo; fi
@@ -915,9 +949,10 @@ do_task_body() (
915
949
  echo
916
950
  fi
917
951
  if [ -s "$tdir/guard.txt" ]; then
918
- # Facts forge observed about how the implementer worked. The reviewer never sees the
919
- # implementer's own report, so this is the only way an unfinished measurement or a
920
- # stray process reaches the review.
952
+ # Facts forge observed about how the implementer worked. On a first attempt the reviewer
953
+ # never sees the implementer's own report (a retry's reviewer gets the reply, to check
954
+ # against the code), so this is how an unfinished measurement or a stray process
955
+ # reaches the review.
921
956
  echo "Implementer guard notes (observed by forge, not claimed by the implementer):"
922
957
  sed 's/^/ - /' "$tdir/guard.txt"
923
958
  echo "A promise of later work means a check may never have run; judge the diff on its own merits."
@@ -949,9 +984,45 @@ do_task_body() (
949
984
  shadow_pid=$!
950
985
  fi
951
986
  forge_metric_phase dispatch
952
- /bin/bash "$DISPATCH" qa "$qa" --repo "$review" --run-dir "$tdir" \
953
- --review-base "$(cat "$tdir/review.base")" --ripwire-query-file "$tdir/prompt.md" --prompt-file "$tdir/qa.input" $yq --output "${FORGE_OUTPUT:-summary}" >"$tdir/qa.out" 2>&1
987
+ # Whether this round continues the reviewer's session. Every answer but "continued" is a
988
+ # fresh dispatch, and nothing here can abort the round: a doctor that fails means fresh.
989
+ local rs_mode=fresh rs_reason="" rs_id="" rs_row=1 rs_cont=()
990
+ if [ "$rs_on" = 1 ]; then
991
+ if [ -n "${FORGE_FRACTAL_RUN:-}" ] || forge_plan_is_fractal "$PLAN"; then
992
+ rs_reason=fractal
993
+ elif [ ! -s "$tdir/retry_findings.md" ]; then
994
+ rs_reason=first-round
995
+ else
996
+ FORGE_CAPABILITY_CACHE="${FORGE_CAPABILITY_CACHE:-$PLAN/capabilities}" \
997
+ /bin/bash "$DISPATCH" doctor --spec "$qa" --role qa $yq > "$tdir/qa.doctor" 2>&1 || : > "$tdir/qa.doctor"
998
+ IFS=$'\t' read -r rs_mode rs_reason rs_id < <(review_session_decide \
999
+ "$tdir/retry_findings.session" "$tdir/retry_findings.resolved" "$tdir/qa.doctor")
1000
+ [ "$rs_mode" = continued ] && rs_cont=(--continue-session "$rs_id")
1001
+ fi
1002
+ fi
1003
+ local qa_cmd=(/bin/bash "$DISPATCH" qa "$qa" --repo "$review" --run-dir "$tdir" \
1004
+ --review-base "$(cat "$tdir/review.base")" --ripwire-query-file "$tdir/prompt.md" --prompt-file "$tdir/qa.input" ${yq:+"$yq"} --output "${FORGE_OUTPUT:-summary}")
1005
+ "${qa_cmd[@]}" ${rs_cont[@]+"${rs_cont[@]}"} >"$tdir/qa.out" 2>&1
954
1006
  rc=$?
1007
+ if [ "$rc" = 9 ] && [ "$rs_mode" = continued ]; then
1008
+ # The session was not continued (the harness cannot, or it failed). Same input, fresh: the
1009
+ # fresh dispatch decides the task. Why comes from the first dispatch's record, which the
1010
+ # second one clears.
1011
+ if [ "$(sed -n 's/^reason=//p' "$tdir/qa.continue" 2>/dev/null | head -1)" = unsupported ]; then
1012
+ rs_reason=continue-unsupported
1013
+ else
1014
+ rs_reason=continue-failed
1015
+ fi
1016
+ rs_mode=fresh
1017
+ mv "$tdir/qa.out" "$tdir/qa.continue.out" 2>/dev/null || true
1018
+ "${qa_cmd[@]}" >"$tdir/qa.out" 2>&1
1019
+ rc=$?
1020
+ fi
1021
+ if [ "$rs_on" = 1 ]; then
1022
+ # One row per round, describing the dispatch whose result counts.
1023
+ [ ! -f "$tdir/review_sessions.tsv" ] || rs_row=$(( $(wc -l < "$tdir/review_sessions.tsv") + 1 ))
1024
+ printf '%s\t%s\t%s\t%s\t%s\n' "$rs_row" "$attempt" "$rs_mode" "$rs_reason" "$rs_id" >> "$tdir/review_sessions.tsv"
1025
+ fi
955
1026
  if [ -n "$shadow_pid" ]; then
956
1027
  # On every path below, before any status is written: the shadow learns the primary is
957
1028
  # done (which bounds how much longer it may take), and no shadow outlives its task, so
@@ -1036,7 +1107,8 @@ do_task() {
1036
1107
  record_attempt "$1" "$2"
1037
1108
  # The retry policy's state (escalation.next or escalation.stop), decided now rather than
1038
1109
  # at the next retry so a spent retry budget still reports it and the scheduler sees it.
1039
- python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$1" --task "$2" --check >/dev/null 2>&1 || true
1110
+ python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$1" --task "$2" --check \
1111
+ --qa-only-next "$(qa_only_next_flag "$1" "$2")" >/dev/null 2>&1 || true
1040
1112
  return $rc
1041
1113
  }
1042
1114
 
@@ -1429,13 +1501,23 @@ write_results() { # write_results <plan>
1429
1501
  done
1430
1502
  }
1431
1503
 
1504
+ # forecast_report PLAN: `forge dataset forecast --run`, as note lines. Whatever it does
1505
+ # (no dataset, a broken store, a slow read), the run goes on and its exit code is its own.
1506
+ forecast_report() {
1507
+ local line
1508
+ python3 "$SKILL_DIR/scripts/forge-dataset.py" forecast "$1" --run 2>/dev/null \
1509
+ | while IFS= read -r line; do note "$line"; done
1510
+ return 0
1511
+ }
1512
+
1432
1513
  # --- run ----------------------------------------------------------------------
1433
1514
  do_run() (
1434
1515
  local PLAN="${1:?run needs a plan dir}"; shift
1435
1516
  local fractal_pipeline_args=("$@")
1436
- local MAXP=3 DRY=0 jev_rc=0
1517
+ local MAXP=3 DRY=0 jev_rc=0 FORECAST=1
1437
1518
  while [ $# -gt 0 ]; do
1438
1519
  case "$1" in
1520
+ --no-forecast) FORECAST=0; shift ;;
1439
1521
  --fractal) forge_fractal_flag on || exit $?; shift ;;
1440
1522
  --fractal-auto-decompose) forge_fractal_flag on || exit $?; FRACTAL_ARGS+=("$1"); shift ;;
1441
1523
  --no-fractal) forge_fractal_flag off || exit $?; shift ;;
@@ -1538,6 +1620,9 @@ do_run() (
1538
1620
  fi
1539
1621
  git -C "$int_wt" rev-parse HEAD > "$PLAN/accepted.integration"
1540
1622
  local id failed=0
1623
+ # Also before any model is paid for: what this plan is likely to cost, and which tasks
1624
+ # history says may bounce. Advisory, budgeted and silent without history; it never stops a run.
1625
+ [ "$FORECAST" = 1 ] && forecast_report "$PLAN"
1541
1626
  # Say now, before any model is paid for, which suites the verify command will not run.
1542
1627
  verify_coverage_report "$REPO" "$(verify_command_for "$PLAN" "$REPO")" "$PLAN/verification.coverage.txt"
1543
1628
  local sched_rc=0 paused=0 sched_args=()