@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
@@ -40,18 +40,216 @@ 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
+
61
+ # The next entry's kind, decided before it starts with the test do_task_body applies
62
+ # (resume_valid): true when it only re-runs QA. A marker the worktree no longer matches is
63
+ # cleared here, so whoever prepares the entry and do_task_body agree that it is a full one.
64
+ resume_check() { # resume_check <plan> <id>
65
+ local tdir="$1/tasks/$2" root
66
+ [ -f "$tdir/resume" ] || return 1
67
+ root="$(cat "$1/wt_root" 2>/dev/null)"
68
+ [ -n "$root" ] && resume_valid "$tdir" "$root/$2" && return 0
69
+ rm -f "$tdir/resume"
70
+ note "$2: the saved review state no longer matches the worktree — the next attempt runs the full task"
71
+ return 1
72
+ }
73
+
74
+ # --- escalation pin -------------------------------------------------------------
75
+ # A human's one-shot choice of the model for a task's next full retry:
76
+ # tasks/<id>/escalation.pin holds one dwarf spec (written by `forge control pin`). The
77
+ # retry claims it with an atomic rename so a writer replacing it meanwhile never races the
78
+ # read, and settles the claim once tasks.tsv and escalations.tsv are written.
79
+ forge_plan_is_fractal() { # forge_plan_is_fractal <plan>
80
+ [ -s "$1/fractal-selection.json" ] || return 1
81
+ python3 -c 'import json, sys
82
+ try:
83
+ sys.exit(0 if json.load(open(sys.argv[1])).get("enabled") else 1)
84
+ except Exception:
85
+ sys.exit(1)' "$1/fractal-selection.json"
86
+ }
87
+
88
+ # The pin's spec: the first line, trimmed, a single token (it lands in a TSV column).
89
+ # Prints nothing when the file is not a regular file or holds no usable spec.
90
+ pin_spec() { # pin_spec <file>
91
+ local spec
92
+ [ -f "$1" ] && [ ! -L "$1" ] || return 0
93
+ spec="$(head -n 1 "$1" 2>/dev/null | tr -d '\r')"
94
+ spec="${spec#"${spec%%[![:space:]]*}"}"; spec="${spec%"${spec##*[![:space:]]}"}"
95
+ case "$spec" in ''|-|UNASSIGNED|*[[:space:][:cntrl:]]*) return 0 ;; esac
96
+ printf '%s\n' "$spec"
97
+ }
98
+
99
+ # claim_pin <tdir> -> prints the spec and returns 0 when a pin was claimed; the file is
100
+ # then escalation.pin.applying until settle_pin. A pin with no usable spec is dropped.
101
+ # The claim is a bare rename(2), not mv: mv moves a file INTO a destination that is a
102
+ # directory or a symlink to one (BSD mv has no -T), which could carry the pin out of the
103
+ # plan. rename replaces a stale file or symlink named .applying and fails on a directory,
104
+ # and then the pin stays where it is for a later retry.
105
+ claim_pin() {
106
+ local tdir="$1" spec
107
+ [ -f "$tdir/escalation.pin" ] && [ ! -L "$tdir/escalation.pin" ] || return 1
108
+ if ! python3 -c 'import os, sys; os.rename(sys.argv[1], sys.argv[2])' \
109
+ "$tdir/escalation.pin" "$tdir/escalation.pin.applying" 2>/dev/null; then
110
+ [ -e "$tdir/escalation.pin" ] && \
111
+ note "$(basename "$tdir"): could not claim the escalation pin (escalation.pin.applying is in the way) — leaving it in place"
112
+ return 1
113
+ fi
114
+ spec="$(pin_spec "$tdir/escalation.pin.applying")"
115
+ if [ -z "$spec" ]; then
116
+ rm -f "$tdir/escalation.pin.applying"
117
+ note "$(basename "$tdir"): the escalation pin holds no usable spec — dropped"
118
+ return 1
119
+ fi
120
+ printf '%s\n' "$spec"
121
+ }
122
+
123
+ # settle_pin <tdir> done|restore. Done deletes the claim. Restore gives the pin back unless
124
+ # a new one appeared meanwhile (hard link: it fails if the target exists, so a pin written
125
+ # during the application is never overwritten); that newer pin is the one for the next retry.
126
+ # python3, like claim_pin's rename: forge already requires it, and `ln` is one tool more.
127
+ settle_pin() {
128
+ local tdir="$1"
129
+ [ "$2" = restore ] && python3 -c 'import os, sys; os.link(sys.argv[1], sys.argv[2])' \
130
+ "$tdir/escalation.pin.applying" "$tdir/escalation.pin" 2>/dev/null
131
+ rm -f "$tdir/escalation.pin.applying"
132
+ return 0
133
+ }
134
+
135
+ # True when the task's next retry only re-runs QA: a `resume` marker (a QA-stage infrastructure
136
+ # stop), or a verdict-less review of committed work, which prepare_retry turns into the marker
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.
141
+ qa_only_next() { # qa_only_next <plan> <id>
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"
169
+ }
170
+
171
+ # Writes the task's implementer column in tasks.tsv: a temp file, then a rename.
172
+ set_dwarf() { # set_dwarf <plan> <id> <spec>
173
+ awk -F'\t' -v OFS='\t' -v id="$2" -v dw="$3" \
174
+ '$0 !~ /^#/ && $1==id { $5=dw } { print }' "$1/tasks.tsv" > "$1/.tasks.tsv.retry" \
175
+ && mv "$1/.tasks.tsv.retry" "$1/tasks.tsv"
176
+ }
177
+
54
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
+
55
253
  # Shared by `retry` (a human asked) and the scheduler's automatic retries. The findings
56
254
  # are the whole point of retrying rather than re-running: the dwarf is told what was
57
255
  # wrong with its own previous attempt.
@@ -66,30 +264,57 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
66
264
  elif [ "$st" = UNKNOWN ] && [ -s "$tdir/reviewed.commit" ]; then
67
265
  # The reviewer never reached a verdict, so the code was not rejected. Re-asking QA is
68
266
  # cheaper and more honest than paying an implementer to rewrite unjudged work; the
69
- # marker is revalidated against the worktree before it is believed.
267
+ # marker is validated against the worktree below before it is believed.
70
268
  echo qa > "$tdir/resume"
71
- note "$id: the reviewer gave no verdict — re-running QA on the unchanged work"
72
269
  # Only a real review replaces the findings. The runner also writes explanatory text
73
270
  # into qa.last when it fails a task without dispatching QA ("produced no changes at
74
271
  # all"), and copying that over would throw away the actual findings the previous
75
272
  # reviewer gave — the one thing a retry exists to carry forward.
76
273
  elif [ "$verdict" = PASS ] || [ "$verdict" = FAIL ]; then
77
- cp "$tdir/qa.last" "$tdir/retry_findings.md"
274
+ carry_findings "$tdir"
78
275
  elif grep -q 'FORGE_VERDICT' "$tdir/qa.last" 2>/dev/null; then
79
- cp "$tdir/qa.last" "$tdir/retry_findings.md"
276
+ carry_findings "$tdir"
80
277
  elif [ -s "$tdir/retry_findings.md" ]; then
81
278
  note "$id: no new review since the last retry — reusing the previous findings"
82
279
  else
83
280
  note "$id: no previous QA findings to pass on (last status was $st)"
84
281
  : > "$tdir/retry_findings.md"
282
+ rm -f "$tdir/retry_findings.commit" "$tdir/retry_findings.session" "$tdir/retry_findings.resolved"
283
+ fi
284
+ # A QA-only resume (an unreviewed verdict, or a QA-stage infrastructure stop) re-asks the
285
+ # reviewer about work that is already committed; the dwarf does not run, so it is no moment
286
+ # to change the implementer. Both branches above wrote the `resume` marker; it holds only
287
+ # while the worktree is still what was reviewed, which do_task_body checks again.
288
+ local qa_only=0
289
+ if resume_check "$PLAN" "$id"; then
290
+ qa_only=1
291
+ [ "$st" = UNKNOWN ] && note "$id: the reviewer gave no verdict — re-running QA on the unchanged work"
85
292
  fi
86
- # The retry policy (forge-route.py escalation): after escalate_after failed attempts on
87
- # one model (default 3), the next attempt goes a tier up in the user's own pools instead
88
- # of repeating the dispatch whose fourth try passes least often, or the task stops with a
89
- # split suggestion. An explicit --dwarf is the human's call and skips the policy.
90
- local cur next rc
293
+ # Who decides the next implementer, in this order: an explicit --dwarf (the human's call,
294
+ # source `retry`), a pin (the human's call, applied once, source `pin`), then the retry
295
+ # policy (forge-route.py escalation): after escalate_after failed attempts on one model
296
+ # (default 3) the next attempt goes a tier up in the user's own pools instead of repeating
297
+ # the dispatch whose fourth try passes least often, or the task stops with a split
298
+ # suggestion.
299
+ local cur next rc esc_source="" pinned="" has_pin=0
91
300
  cur="$(field "$T" "$id" dwarf)"
92
- if [ -z "$newdwarf" ]; then
301
+ [ -f "$tdir/escalation.pin" ] && [ ! -L "$tdir/escalation.pin" ] && has_pin=1
302
+ if [ -n "$newdwarf" ]; then
303
+ esc_source=retry
304
+ rm -f "$tdir/escalation.stop" "$tdir/split.suggested" "$tdir/escalation.next"
305
+ elif [ "$has_pin" = 1 ] && [ "$qa_only" = 1 ]; then
306
+ note "$id: the escalation pin waits for the next full retry (this one only re-runs QA)"
307
+ elif [ "$has_pin" = 1 ] && forge_plan_is_fractal "$PLAN"; then
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
313
+ elif [ "$has_pin" = 1 ] && pinned="$(claim_pin "$tdir")"; then
314
+ esc_source=pin
315
+ newdwarf="$pinned"
316
+ note "$id: applying the pinned escalation to $pinned"
317
+ else
93
318
  next="$(python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$PLAN" --task "$id")"; rc=$?
94
319
  if [ "$rc" = 3 ]; then
95
320
  note "$(cat "$tdir/split.suggested" 2>/dev/null)"
@@ -98,36 +323,97 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
98
323
  if [ -n "$next" ]; then
99
324
  note "$id: escalating from $cur to $next after $(cat "$PLAN/escalate_after" 2>/dev/null || echo 3) failed attempts on one model (--escalate-after 0 turns this off)"
100
325
  newdwarf="$next"
326
+ esc_source=policy
101
327
  fi
102
- else
103
- rm -f "$tdir/escalation.stop" "$tdir/split.suggested" "$tdir/escalation.next"
104
328
  fi
105
- if [ "$newdwarf" = "$cur" ]; then newdwarf=""; fi # the same spec again is no escalation
329
+ # The same spec again is no escalation, except a pin: a human asked for it, so the
330
+ # request is recorded and consumed.
331
+ if [ "$newdwarf" = "$cur" ] && [ "$esc_source" != pin ]; then newdwarf=""; fi
106
332
  if [ -n "$newdwarf" ]; then
107
- # Written into tasks.tsv rather than kept in a side file: the table then shows the
108
- # model that will actually be spent, and plan's rule that an explicit column value
109
- # survives a re-plan protects the escalation for free. Rewritten first, so a failed
110
- # rewrite is never recorded as an escalation that happened.
111
- awk -F'\t' -v OFS='\t' -v id="$id" -v dw="$newdwarf" \
112
- '$0 !~ /^#/ && $1==id { $5=dw } { print }' "$T" > "$PLAN/.tasks.tsv.retry" \
113
- && mv "$PLAN/.tasks.tsv.retry" "$T" \
114
- || { note "$id: could not write $newdwarf into tasks.tsv; nothing was retried"; return 1; }
115
- # The rewrite loses which model failed first, and an escalation is exactly the record
116
- # of "this tier was too weak for this task".
117
- printf '%s\t%s\t%s\t%s\n' "$(date +%s)" "$(cat "$tdir/attempt" 2>/dev/null || echo 0)" \
118
- "$cur" "$newdwarf" >> "$tdir/escalations.tsv"
119
- # Recorded at the rewrite itself: it happens before the retried attempt begins, so no
120
- # attempt capture would ever see it as something that attempt produced.
121
- forge_dataset_event escalation --dir "$PLAN" --task "$id" --set "from=$cur" \
122
- --set "to=$newdwarf" --set "after_attempt=$(cat "$tdir/attempt" 2>/dev/null || echo 0)"
123
- rm -f "$tdir/escalation.next"
333
+ write_escalation "$PLAN" "$id" "$cur" "$newdwarf" "$esc_source" || return 1
124
334
  fi
125
335
  rm -f "$tdir/noretry"
126
336
  }
127
337
 
338
+ # Puts the next implementer in place: tasks.tsv, the escalations.tsv record and the dataset
339
+ # event. Returns 1, with tasks.tsv and a claimed pin given back, when nothing was changed.
340
+ write_escalation() { # write_escalation <plan> <id> <from> <to> <retry|pin|policy>
341
+ local PLAN="$1" id="$2" cur="$3" newdwarf="$4" esc_source="$5" tdir="$1/tasks/$2" line
342
+ # A pin is held to its record: whether the record can be written is learned before
343
+ # anything is changed, so a failure there leaves tasks.tsv and the pin as they were.
344
+ if [ "$esc_source" = pin ] && ! escalation_record_ok "$PLAN" "$id"; then
345
+ note "$id: could not record the escalation in escalations.tsv; nothing was retried"
346
+ settle_pin "$tdir" restore
347
+ return 1
348
+ fi
349
+ # Written into tasks.tsv rather than kept in a side file: the table then shows the
350
+ # model that will actually be spent, and plan's rule that an explicit column value
351
+ # survives a re-plan protects the escalation for free. Rewritten first, so a failed
352
+ # rewrite is never recorded as an escalation that happened.
353
+ if [ "$newdwarf" != "$cur" ]; then
354
+ set_dwarf "$PLAN" "$id" "$newdwarf" || {
355
+ note "$id: could not write $newdwarf into tasks.tsv; nothing was retried"
356
+ [ "$esc_source" = pin ] && settle_pin "$tdir" restore
357
+ return 1
358
+ }
359
+ fi
360
+ # The rewrite loses which model failed first, and an escalation is exactly the record
361
+ # of "this tier was too weak for this task". The last column says who decided: forge's
362
+ # policy, a human's `retry --dwarf`, or a pin.
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
366
+ # The rewrite is undone with the record, so the pin given back is still a real
367
+ # escalation next time, not a same-spec one that hides the model it replaced.
368
+ if [ "$newdwarf" = "$cur" ] || set_dwarf "$PLAN" "$id" "$cur"; then
369
+ note "$id: could not record the escalation in escalations.tsv; nothing was retried"
370
+ settle_pin "$tdir" restore
371
+ return 1
372
+ fi
373
+ note "$id: could not record the escalation in escalations.tsv, and tasks.tsv could not be put back to $cur; it keeps $newdwarf, so the pin is consumed and the retry goes on"
374
+ fi
375
+ # Recorded at the rewrite itself: it happens before the retried attempt begins, so no
376
+ # attempt capture would ever see it as something that attempt produced.
377
+ forge_dataset_event escalation --dir "$PLAN" --task "$id" --set "from=$cur" \
378
+ --set "to=$newdwarf" --set "after_attempt=$(cat "$tdir/attempt" 2>/dev/null || echo 0)" \
379
+ --set "source=$esc_source"
380
+ # A pin, like an explicit --dwarf, settles the retry policy's verdicts about this task.
381
+ [ "$esc_source" = pin ] && rm -f "$tdir/escalation.stop" "$tdir/split.suggested"
382
+ rm -f "$tdir/escalation.next"
383
+ [ "$esc_source" = pin ] && settle_pin "$tdir" done
384
+ return 0
385
+ }
386
+
387
+ # An infrastructure stop dispatched again by the scheduler. It is no retry: no attempt or
388
+ # budget is spent, the findings carried forward stay, and the retry policy is not asked. But
389
+ # unless the stop came at QA (and the reviewed work is intact), the entry is a full
390
+ # implementer attempt, so a pending pin applies here exactly as prepare_retry applies it.
391
+ # Returns 1 when a claimed pin could not be put in place; the task is then not resumed.
392
+ prepare_resume() { # prepare_resume <plan> <id>
393
+ local PLAN="$1" id="$2" tdir="$1/tasks/$2" pinned
394
+ [ -f "$tdir/escalation.pin" ] && [ ! -L "$tdir/escalation.pin" ] || return 0
395
+ if resume_check "$PLAN" "$id"; then
396
+ note "$id: the escalation pin waits for the next full retry (this resume only re-runs QA)"
397
+ elif forge_plan_is_fractal "$PLAN"; then
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
402
+ elif pinned="$(claim_pin "$tdir")"; then
403
+ note "$id: applying the pinned escalation to $pinned"
404
+ write_escalation "$PLAN" "$id" "$(field "$PLAN/tasks.tsv" "$id" dwarf)" "$pinned" pin || return 1
405
+ fi
406
+ return 0
407
+ }
408
+
128
409
  # The scheduler's `_prepare_retry` target: prepare, then clear the status so the task
129
- # reads as PENDING again. `retry` clears it itself, after its own preflight.
130
- retry_for_scheduler() { # retry_for_scheduler <plan> <id>
410
+ # reads as PENDING again. `retry` clears it itself, after its own preflight. With --resume it
411
+ # prepares an infrastructure stop's resume instead (prepare_resume), and the status stays.
412
+ retry_for_scheduler() { # retry_for_scheduler <plan> <id> [--resume]
413
+ if [ "${3:-}" = --resume ]; then
414
+ prepare_resume "$1" "$2"
415
+ return $?
416
+ fi
131
417
  prepare_retry "$1" "$2" || return $?
132
418
  rm -f "$1/tasks/$2/status"
133
419
  }
@@ -173,9 +459,17 @@ guard_after_dwarf() { # guard_after_dwarf <wt> <tdir> <id>
173
459
 
174
460
  # --- QA severity gate -----------------------------------------------------------
175
461
  qa_threshold_for() { # per-task file wins over the plan-level default; prints nothing if unset
176
- local f
462
+ # A per-task "none" exempts the task from a plan-wide threshold (prints nothing, no fallback).
463
+ # A plan-level "none" is the same as no file. Anything but P0-P3 or none is ignored, so a
464
+ # garbage per-task value falls back to the plan file.
465
+ local f v
177
466
  for f in "$1/tasks/$2/qa_threshold" "$1/qa_threshold"; do
178
- [ -s "$f" ] && { head -1 "$f" | tr -d ' \r'; return 0; }
467
+ [ -s "$f" ] || continue
468
+ v="$(head -1 "$f" | tr -d ' \r' | tr 'a-z' 'A-Z')"
469
+ case "$v" in
470
+ P0|P1|P2|P3) printf '%s\n' "$v"; return 0 ;;
471
+ NONE) [ "$f" = "$1/qa_threshold" ] || return 0 ;;
472
+ esac
179
473
  done
180
474
  return 0
181
475
  }
@@ -184,10 +478,13 @@ qa_threshold_for() { # per-task file wins over the plan-level default; prints no
184
478
  # and less severe than the threshold is accepted as PASS-with-known-issues. The literal
185
479
  # status stays PASS, so the merge path, the scheduler and Fractal need no new vocabulary.
186
480
  # Anything unlabelled stays blocking: a finding the gate cannot place is not tolerated.
187
- qa_gate() { # qa_gate <plan> <id> <tdir> <verdict>
481
+ # The optional 5th argument is the threshold the caller already used for this attempt's review
482
+ # prompt; when given (even empty) it is used as-is, so a change to the files between the prompt
483
+ # and the gate cannot split one attempt across two thresholds. Absent, the files are read.
484
+ qa_gate() { # qa_gate <plan> <id> <tdir> <verdict> [threshold]
188
485
  local plan="$1" id="$2" tdir="$3" verdict="$4" thr line
189
486
  rm -f "$tdir/qa.gate" "$tdir/known_issues.md"
190
- thr="$(qa_threshold_for "$plan" "$id")"
487
+ if [ "$#" -ge 5 ]; then thr="$5"; else thr="$(qa_threshold_for "$plan" "$id")"; fi
191
488
  [ -n "$thr" ] || return 0
192
489
  line="$(python3 "$SKILL_DIR/scripts/forge-contract.py" gate --verdict "${verdict:-UNKNOWN}" \
193
490
  --qa-last "$tdir/qa.last" --threshold "$thr" --known-issues "$tdir/known_issues.md" 2>/dev/null)" || return 0
@@ -265,10 +562,30 @@ report_size() { # report_size <plan>
265
562
 
266
563
  # The retry policy's verdicts: a task that stopped with a split suggestion, or whose next
267
564
  # retry would go a tier up. Decided after every attempt, so a spent budget still says it.
565
+ # A pending pin is read from the file itself: it can be set between two attempts, and it
566
+ # outranks both verdicts (the retry applies it first). The converse needs care too: a pin
567
+ # withdrawn since the last attempt leaves the `escalation.next` that `--check` wrote for it,
568
+ # so a marker found with no pin pending is decided again rather than trusted.
268
569
  report_escalations() { # report_escalations <plan>
269
- local plan="$1" id n=0 head
570
+ local plan="$1" id n=0 head pin
270
571
  head="retry policy (after $(cat "$plan/escalate_after" 2>/dev/null || echo 3) failed attempts on one model):"
271
572
  for id in $(all_ids "$plan/tasks.tsv"); do
573
+ pin="$(pin_spec "$plan/tasks/$id/escalation.pin")"
574
+ [ -n "$pin" ] && forge_plan_is_fractal "$plan" && pin=""
575
+ if [ -n "$pin" ] && [ "$(task_status "$plan" "$id")" != MERGED ]; then
576
+ [ "$n" = 0 ] && echo "$head"
577
+ n=$((n+1))
578
+ if qa_only_next "$plan" "$id"; then
579
+ printf ' %-14s its next retry goes to %s (pinned; after the QA-only re-run)\n' "$id" "$pin"
580
+ else
581
+ printf ' %-14s its next retry goes to %s (pinned)\n' "$id" "$pin"
582
+ fi
583
+ continue
584
+ fi
585
+ if [ -s "$plan/tasks/$id/escalation.next" ] && [ "$(task_status "$plan" "$id")" != MERGED ]; then
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
588
+ fi
272
589
  if [ -s "$plan/tasks/$id/split.suggested" ]; then
273
590
  [ "$n" = 0 ] && echo "$head"
274
591
  n=$((n+1)); printf ' %-14s split suggested: %s\n' "$id" "$(cat "$plan/tasks/$id/split.suggested")"
@@ -14,7 +14,9 @@
14
14
  # Persisted options become files in the plan dir, like --verify and --setup, so a later
15
15
  # `retry` or the scheduler's task processes see them. Cost-bearing options that change
16
16
  # what a run is allowed to spend (--retry-failed, --infra-retries) are deliberately NOT
17
- # persisted: they apply to the invocation that asked for them.
17
+ # persisted: they apply to the invocation that asked for them. While the scheduler runs
18
+ # they are mirrored to $PLAN/retry_budget and $PLAN/infra_budget (plus scheduler.live) so
19
+ # `forge control` can change them live; the scheduler removes those files when it exits.
18
20
  #
19
21
  # --timeout S run retry $PLAN/timeout dispatch timeout, seconds (0 = none)
20
22
  # --retry-failed N run (this run only) automatic reviewer-driven retries