@giamme/forge 0.0.0-stage → 0.9.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 (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,785 @@
1
+ #!/usr/bin/env bash
2
+ # Plan operations for forge-parallel.sh: `accept`, `combine` and `split`.
3
+ #
4
+ # Sourced by forge-parallel.sh, which provides die/note/field/all_ids/task_status,
5
+ # merge_task, compute_waves, write_results, record_attempt, run_worktree_setup,
6
+ # forge_fingerprint and SELF. Functions only; nothing runs at source time.
7
+ #
8
+ # accept <plan> <task> --reason "<why>" --approved (runs under the plan's lock)
9
+ # split <plan> <new-plan> --tasks a,b [--dry-run] (runs under the plan's lock)
10
+ # combine <new-plan> <plan-a> <plan-b>... [--verify CMD] [--dry-run]
11
+ #
12
+ # Exit codes follow forge-parallel.sh: 0 ok, 2 usage, 3 precondition (nothing was
13
+ # changed), 6 integration conflict (anything created was removed again).
14
+ #
15
+ # `combine` and `split` only ever move git refs and worktrees that forge itself made
16
+ # (forge/<run>-integration, forge/<run>/<task>, <wt_root>/...). Nothing they do touches
17
+ # the user's branches or working tree.
18
+
19
+ # --- shared helpers -------------------------------------------------------------
20
+ plans_abs() { # absolute path of a directory that may not exist yet
21
+ if [ -d "$1" ]; then (cd "$1" && pwd)
22
+ else python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$1"
23
+ fi
24
+ }
25
+
26
+ plans_dir_empty() { # plans_dir_empty <dir>: true when it holds nothing at all
27
+ local f
28
+ for f in "$1"/* "$1"/.[!.]* "$1"/..?*; do
29
+ if [ -e "$f" ] || [ -L "$f" ]; then return 1; fi
30
+ done
31
+ return 0
32
+ }
33
+
34
+ plans_run_id() { basename "$1" | sed 's/^forge-//'; }
35
+
36
+ plans_short() { printf '%s' "${1:0:7}"; }
37
+
38
+ # What `run` would call this plan's worktree root: beside the repository, never in TMPDIR.
39
+ plans_wt_root_for() { # plans_wt_root_for <repo path as written in a plan> <run id>
40
+ printf '%s/.forge-worktrees/%s' "$(cd "$1/.." && pwd)" "$2"
41
+ }
42
+
43
+ # Refuses (exit 3, nothing touched) unless <new-plan> and its git names are free to use.
44
+ plans_target_checks() { # plans_target_checks <repo> <new-plan> <run id> <wt root>
45
+ local repo="$1" new="$2" rid="$3" wtr="$4"
46
+ [ -n "$rid" ] || die "'$new' has no usable name: its run id is the directory name minus a leading 'forge-'" 3
47
+ git check-ref-format "refs/heads/forge/$rid-integration" >/dev/null 2>&1 &&
48
+ git check-ref-format "refs/heads/forge/$rid/x" >/dev/null 2>&1 ||
49
+ die "'$rid' (from '$new') cannot be part of a branch name; use a plan directory name of plain letters, digits, '-' and '_'" 3
50
+ if [ -e "$new" ] || [ -L "$new" ]; then
51
+ [ -d "$new" ] || die "'$new' exists and is not a directory" 3
52
+ plans_dir_empty "$new" || die "'$new' already exists and is not empty; pick a new plan directory" 3
53
+ fi
54
+ if git -C "$repo" show-ref --verify --quiet "refs/heads/forge/$rid-integration"; then
55
+ die "branch forge/$rid-integration already exists; pick a different plan directory name" 3
56
+ fi
57
+ if git -C "$repo" show-ref --verify --quiet "refs/heads/forge/$rid"; then
58
+ die "branch forge/$rid would block the task branches forge/$rid/<task> (git refs are files); pick a different plan directory name" 3
59
+ fi
60
+ if [ -n "$(git -C "$repo" for-each-ref --count=1 --format=x "refs/heads/forge/$rid/" 2>/dev/null)" ]; then
61
+ die "branches forge/$rid/... already exist; pick a different plan directory name" 3
62
+ fi
63
+ if [ -e "$wtr" ]; then
64
+ plans_dir_empty "$wtr" || die "worktree root '$wtr' already exists; pick a different plan directory name" 3
65
+ fi
66
+ return 0
67
+ }
68
+
69
+ # Copies a task's artifact directory, minus review snapshots (review-<attempt>-<pid>/ is a
70
+ # full export of the reviewed tree, rebuilt by every review, and nothing reads it back).
71
+ plans_copy_taskdir() { # plans_copy_taskdir <src dir> <dst dir>
72
+ local src="$1" dst="$2" f
73
+ mkdir -p "$dst" || return 1
74
+ for f in "$src"/* "$src"/.[!.]*; do
75
+ if [ -e "$f" ] || [ -L "$f" ]; then
76
+ case "$(basename "$f")" in review-*) continue ;; esac
77
+ cp -pR "$f" "$dst/" || return 1
78
+ fi
79
+ done
80
+ return 0
81
+ }
82
+
83
+ # The state `undo` must reverse. Globals, so an interrupt trap can reach them.
84
+ plans_reset_undo() {
85
+ PLANS_U_REPO=""; PLANS_U_BR=""; PLANS_U_WT=""; PLANS_U_WTR=""; PLANS_U_WTR_NEW=0
86
+ PLANS_U_PLAN=""; PLANS_U_PLAN_NEW=0; PLANS_U_JOURNAL=""
87
+ }
88
+
89
+ # Reverses everything combine/split created, newest first. Best effort and silent: it runs
90
+ # on the way out of a failure that has already been reported.
91
+ plans_undo() {
92
+ local kind a b line
93
+ trap - INT TERM
94
+ while IFS="$(printf '\t')" read -r kind a b; do
95
+ case "$kind" in
96
+ head) git -C "$a" symbolic-ref HEAD "refs/heads/$b" >/dev/null 2>&1 ;;
97
+ move) git -C "$PLANS_U_REPO" worktree move "$b" "$a" >/dev/null 2>&1 ;;
98
+ branch) git -C "$PLANS_U_REPO" branch -D "$a" >/dev/null 2>&1 ;;
99
+ esac
100
+ done <<EOF
101
+ $PLANS_U_JOURNAL
102
+ EOF
103
+ if [ -n "$PLANS_U_WT" ]; then
104
+ git -C "$PLANS_U_REPO" worktree remove --force "$PLANS_U_WT" >/dev/null 2>&1
105
+ rm -rf "$PLANS_U_WT"
106
+ fi
107
+ [ -z "$PLANS_U_BR" ] || git -C "$PLANS_U_REPO" branch -D "$PLANS_U_BR" >/dev/null 2>&1
108
+ if [ "$PLANS_U_WTR_NEW" = 1 ] && [ -n "$PLANS_U_WTR" ]; then rm -rf "$PLANS_U_WTR"; fi
109
+ if [ -n "$PLANS_U_PLAN" ]; then
110
+ if [ "$PLANS_U_PLAN_NEW" = 1 ]; then rm -rf "$PLANS_U_PLAN"
111
+ else rm -rf "$PLANS_U_PLAN"/* "$PLANS_U_PLAN"/.[!.]* 2>/dev/null
112
+ fi
113
+ fi
114
+ [ -z "$PLANS_U_REPO" ] || git -C "$PLANS_U_REPO" worktree prune >/dev/null 2>&1
115
+ return 0
116
+ }
117
+
118
+ plans_journal() { # plans_journal <kind> <a> <b>: newest entry first
119
+ PLANS_U_JOURNAL="$1$(printf '\t')$2$(printf '\t')$3
120
+ $PLANS_U_JOURNAL"
121
+ }
122
+
123
+ # Creates <branch> at <commit> with a worktree at <wt>, both recorded for undo.
124
+ plans_make_integration() { # plans_make_integration <repo> <branch> <commit> <wt>
125
+ git -C "$1" branch "$2" "$3" >/dev/null 2>&1 || return 1
126
+ PLANS_U_BR="$2"
127
+ git -C "$1" worktree add "$4" "$2" >/dev/null 2>&1 || return 1
128
+ PLANS_U_WT="$4"
129
+ }
130
+
131
+ # Per-plan option files carried to a derived plan. The cost-bearing choices a user made
132
+ # for this plan apply to the plan made from it; `planner` does not (nothing plans again).
133
+ PLANS_OPTION_FILES="no_ripwire no_memory yolo_dwarf yolo_qa qa_threshold timeout verify_retries verify_cmd setup_cmd final_qa shadow_qa shadow_rate explore escalate_after fractal-routing.json fractal-selection.json jev-selection.json"
134
+
135
+ # --- accept ---------------------------------------------------------------------
136
+ # A reviewer's FAIL is the QA gate; overriding it is the user's call and never forge's or
137
+ # the orchestrator's, hence both --reason and --approved. Only work the reviewer actually
138
+ # judged can be accepted: the branch tip and the worktree must still be exactly what was
139
+ # reviewed, or the human would be signing off on code nobody reviewed.
140
+ plans_why_not() { # plans_why_not <plan> <id> <status>: exit 3 with advice for a status accept cannot take
141
+ local plan="$1" id="$2" st="$3"
142
+ case "$st" in
143
+ MERGED) die "task '$id' is already merged — nothing to accept" 3 ;;
144
+ PASS) die "task '$id' already passed review; 'forge-parallel.sh run $plan' merges it — nothing to accept" 3 ;;
145
+ INVALIDATED) die "task '$id' is INVALIDATED: its work or the integration branch changed after the review, so the review no longer describes it. Use 'forge-parallel.sh retry $plan $id' for a fresh review" 3 ;;
146
+ CONFLICT) die "task '$id' is CONFLICT: its reviewed work does not merge cleanly, so accepting it cannot help. Resolve the conflict or 'forge-parallel.sh retry $plan $id'" 3 ;;
147
+ ERROR|TIMEOUT|INFRA|INTERRUPTED) die "task '$id' is $st: no reviewer verdict exists to override. Use 'forge-parallel.sh retry $plan $id' (or 'run')" 3 ;;
148
+ BLOCKED|PENDING) die "task '$id' is $st: it has not been reviewed yet, so there is nothing to override" 3 ;;
149
+ RUNNING) die "task '$id' is RUNNING (or a run was killed): re-run 'forge-parallel.sh run $plan' so it settles first" 3 ;;
150
+ *) die "task '$id' has status '$st'; only a FAIL or UNKNOWN task can be accepted" 3 ;;
151
+ esac
152
+ }
153
+
154
+ # Prints the reviewer's text for known_issues.md: the reply minus the verdict line, and,
155
+ # when the last attempt was a no-progress retry whose qa.last is just forge's explanation,
156
+ # the real review that retry carried forward.
157
+ plans_reviewer_text() { # plans_reviewer_text <tdir> <status>
158
+ local tdir="$1" st="$2"
159
+ if [ -s "$tdir/qa.last" ]; then tr -d '\r' < "$tdir/qa.last" | grep -v '^FORGE_VERDICT:'; fi
160
+ if [ "$st" = FAIL ] && [ -s "$tdir/retry_findings.md" ] && ! grep -q '^FORGE_VERDICT:' "$tdir/qa.last" 2>/dev/null; then
161
+ echo
162
+ echo "Earlier review of the same work (retry_findings.md):"
163
+ echo
164
+ tr -d '\r' < "$tdir/retry_findings.md" | grep -v '^FORGE_VERDICT:'
165
+ fi
166
+ return 0
167
+ }
168
+
169
+ do_accept() {
170
+ local PLAN="${1:?accept needs a plan dir}" id="" reason="" approved=0
171
+ shift
172
+ while [ $# -gt 0 ]; do
173
+ case "$1" in
174
+ --reason) [ $# -ge 2 ] || die "accept: --reason needs a text"; reason="$2"; shift 2 ;;
175
+ --approved) approved=1; shift ;;
176
+ -*) die "accept: unknown option '$1'" ;;
177
+ *) [ -z "$id" ] || die "accept: unexpected argument '$1'"; id="$1"; shift ;;
178
+ esac
179
+ done
180
+ [ -n "$id" ] || die "usage: forge-parallel.sh accept <plan-dir> <task-id> --reason \"<why>\" --approved"
181
+ reason="$(printf '%s' "$reason" | tr '\n\r\t' ' ' | sed 's/^ *//; s/ *$//')"
182
+ [ -n "$reason" ] || die "accept needs --reason \"<why this work is acceptable despite the reviewer>\": it is recorded with the work"
183
+ [ "$approved" = 1 ] || die "accept needs --approved: it overrides the reviewer's verdict and is only run on the user's explicit instruction"
184
+
185
+ local T="$PLAN/tasks.tsv" tdir="$PLAN/tasks/$id" REPO run_id wt br int_wt st dep dst
186
+ [ -s "$T" ] || die "no tasks.tsv in '$PLAN'"
187
+ all_ids "$T" | grep -qx "$id" || die "no task '$id' in this plan"
188
+ [ -s "$PLAN/wt_root" ] && [ -s "$PLAN/run_id" ] && [ -s "$PLAN/repo" ] \
189
+ || die "this plan has never been run, so there is no reviewed work to accept" 3
190
+ REPO="$(cat "$PLAN/repo")"; run_id="$(cat "$PLAN/run_id")"
191
+ wt="$(cat "$PLAN/wt_root")/$id"; br="forge/$run_id/$id"
192
+ int_wt="$(cat "$PLAN/wt_root")/_integration"
193
+
194
+ st="$(task_status "$PLAN" "$id")"
195
+ case "$st" in FAIL|UNKNOWN) ;; *) plans_why_not "$PLAN" "$id" "$st" ;; esac
196
+
197
+ # The reviewed work must be exactly what is on disk: same commit on the branch, same
198
+ # tree, index and HEAD in the worktree as when QA finished.
199
+ [ -s "$tdir/reviewed.commit" ] && [ -s "$tdir/source.fingerprint" ] \
200
+ || die "task '$id' has no saved review state (it never reached a reviewed commit); use 'forge-parallel.sh retry $PLAN $id'" 3
201
+ [ -e "$wt/.git" ] || die "task '$id': its worktree $wt is gone, so the reviewed work cannot be verified; use 'forge-parallel.sh retry $PLAN $id'" 3
202
+ [ "$(git -C "$REPO" rev-parse --verify --quiet "refs/heads/$br" 2>/dev/null)" = "$(cat "$tdir/reviewed.commit")" ] \
203
+ || die "task '$id': branch $br is no longer at the commit that was reviewed — the work changed since review. Use 'forge-parallel.sh retry $PLAN $id' for a fresh review" 3
204
+ [ "$(forge_fingerprint "$wt" 2>/dev/null)" = "$(cat "$tdir/source.fingerprint")" ] \
205
+ || die "task '$id': the worktree $wt changed since it was reviewed (edited, staged or committed). Accepting would sign off on code no reviewer saw; use 'forge-parallel.sh retry $PLAN $id' for a fresh review" 3
206
+
207
+ # Checked here, not left to merge_task: it records INVALIDATED/BLOCKED on a task it
208
+ # refuses, and a refused accept must leave the task exactly as it was.
209
+ for dep in $(field "$T" "$id" deps | tr ',' ' '); do
210
+ [ "$dep" = - ] && continue
211
+ dst="$(task_status "$PLAN" "$dep")"
212
+ [ "$dst" = MERGED ] || die "task '$id' depends on '$dep', which is not merged (status $dst); merge or accept '$dep' first" 3
213
+ done
214
+ [ -e "$int_wt/.git" ] || die "the integration worktree $int_wt is missing" 3
215
+ [ -s "$PLAN/accepted.integration" ] && [ "$(git -C "$int_wt" rev-parse HEAD 2>/dev/null)" = "$(cat "$PLAN/accepted.integration")" ] \
216
+ || die "integration branch changed outside this run; refusing to merge onto it" 3
217
+ [ -z "$(git -C "$int_wt" status --porcelain -- . ':(exclude).forge' 2>/dev/null)" ] \
218
+ || die "the integration worktree $int_wt has uncommitted changes; refusing to merge onto it" 3
219
+
220
+ # Record first, merge second, and put it all back if the merge does not happen.
221
+ local ki="$tdir/known_issues.md" kibak="$tdir/.known_issues.before" tmp="$tdir/.known_issues.new"
222
+ local who="${USER:-${LOGNAME:-unknown}}" when body n sha attempt
223
+ when="$(date '+%Y-%m-%dT%H:%M:%S%z')"
224
+ attempt="$(cat "$tdir/attempt" 2>/dev/null || echo 0)"
225
+ sha="$(cat "$tdir/reviewed.commit")"
226
+ body="$(plans_reviewer_text "$tdir" "$st")"
227
+ n="$(printf '%s\n' "$body" | grep -c '^- ')"
228
+ rm -f "$kibak"; [ -f "$ki" ] && cp -p "$ki" "$kibak"
229
+ {
230
+ if [ -s "$ki" ]; then cat "$ki"; echo; fi
231
+ echo "## Accepted by a human despite the reviewer's $st verdict"
232
+ echo
233
+ echo "Reason: $reason"
234
+ echo "Accepted by $who on $when (task $id, attempt $attempt, reviewed commit ${sha:0:12})."
235
+ echo "The reviewer's reply follows, copied from tasks/$id/qa.last. None of it was fixed."
236
+ echo
237
+ [ -z "$body" ] || printf '%s\n' "$body"
238
+ if [ "$n" = 0 ]; then
239
+ echo
240
+ echo "- [$st] the reviewer left no itemised findings; read tasks/$id/qa.last"
241
+ fi
242
+ } > "$tmp" && mv "$tmp" "$ki" || { rm -f "$tmp"; die "could not write $ki" 3; }
243
+ {
244
+ echo "task=$id"
245
+ echo "status=$st"
246
+ echo "reason=$reason"
247
+ echo "by=$who"
248
+ echo "at=$when"
249
+ echo "epoch=$(date +%s)"
250
+ echo "attempt=$attempt"
251
+ echo "reviewed_commit=$sha"
252
+ } > "$tdir/accepted.by"
253
+ echo PASS > "$tdir/status"
254
+
255
+ if ! merge_task "$PLAN" "$id"; then
256
+ # merge_task already wrote CONFLICT or INVALIDATED. The task was not accepted, so the
257
+ # acceptance record goes too; a stale known_issues.md would be listed as accepted work.
258
+ rm -f "$tdir/accepted.by" "$ki"
259
+ [ -f "$kibak" ] && mv "$kibak" "$ki"
260
+ write_results "$PLAN"
261
+ die "task '$id' could not be merged (status now $(task_status "$PLAN" "$id")); nothing was accepted. See 'git -C $int_wt status' and the task branch $br" 6
262
+ fi
263
+ rm -f "$kibak"
264
+ record_attempt "$PLAN" "$id"
265
+ write_results "$PLAN"
266
+ echo "$id accepted by a human and merged"
267
+ echo "known issues recorded in $ki"
268
+ if [ "$(awk -F'\t' '$2!="MERGED" {n++} END {print n+0}' "$PLAN/results.tsv")" -gt 0 ]; then
269
+ echo "tasks that were waiting on it are unchanged; continue with: forge-parallel.sh run $PLAN"
270
+ fi
271
+ return 0
272
+ }
273
+
274
+ # --- combine --------------------------------------------------------------------
275
+ # Every source plan is locked for the whole operation (a concurrent run would move the
276
+ # very integration branch being merged). The lock is taken by re-exec, like the router's
277
+ # own lock for run/retry/integrate, so the file descriptors stay open for bash.
278
+ PLANS_LOCK_PY='
279
+ import fcntl, os, sys
280
+ script, count = sys.argv[1], int(sys.argv[2])
281
+ held = []
282
+ for path in sys.argv[3:3 + count]:
283
+ try:
284
+ handle = open(path, "a")
285
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
286
+ except OSError as error:
287
+ sys.stderr.write("forge: plan %s is locked by another forge command (%s); wait for it to finish\n"
288
+ % (os.path.dirname(path), error))
289
+ sys.exit(3)
290
+ os.set_inheritable(handle.fileno(), True)
291
+ held.append(handle)
292
+ env = dict(os.environ, FORGE_COMBINE_LOCKED="1")
293
+ os.execve("/bin/bash", ["/bin/bash", script, "combine"] + sys.argv[3 + count:], env)
294
+ '
295
+
296
+ plans_predict_conflict() { # 0 merges cleanly, 1 would conflict, 2 cannot tell (old git)
297
+ git -C "$1" merge-tree --write-tree "$2" "$3" >/dev/null 2>&1
298
+ case "$?" in 0) return 0 ;; 1) return 1 ;; *) return 2 ;; esac
299
+ }
300
+
301
+ do_combine() {
302
+ local orig=("$@") NEW="" VERIFY="" DRY=0 SRC=() p f i n id d
303
+ [ $# -ge 1 ] || die "usage: forge-parallel.sh combine <new-plan> <plan-a> <plan-b> [<plan>...] [--verify <command>] [--dry-run]"
304
+ NEW="$1"; shift
305
+ while [ $# -gt 0 ]; do
306
+ case "$1" in
307
+ --verify) [ -n "${2:-}" ] || die "combine: --verify needs a command"; VERIFY="$2"; shift 2 ;;
308
+ --dry-run) DRY=1; shift ;;
309
+ -*) die "combine: unknown option '$1'" ;;
310
+ *) SRC+=("$1"); shift ;;
311
+ esac
312
+ done
313
+ [ "${#SRC[@]}" -ge 2 ] || die "combine needs at least two source plans to combine (got ${#SRC[@]})" 3
314
+ NEW="$(plans_abs "$NEW")"
315
+
316
+ local norm=() seen=""
317
+ for p in "${SRC[@]}"; do
318
+ [ -d "$p" ] || die "combine: plan dir '$p' does not exist" 3
319
+ p="$(cd "$p" && pwd)"
320
+ [ -f "$p/tasks.tsv" ] || die "combine: '$p' is not a forge plan (no tasks.tsv)" 3
321
+ [ "$p" != "$NEW" ] || die "combine: the new plan cannot be one of its sources" 3
322
+ case "$seen" in *"|$p|"*) die "combine: plan '$p' is listed twice" 3 ;; esac
323
+ seen="$seen|$p|"
324
+ norm+=("$p")
325
+ done
326
+ SRC=("${norm[@]}")
327
+
328
+ if [ "${FORGE_COMBINE_LOCKED:-}" != 1 ]; then
329
+ local locks=()
330
+ for p in "${SRC[@]}"; do locks+=("$p/schedule.lock"); done
331
+ exec python3 -c "$PLANS_LOCK_PY" "$SELF" "${#SRC[@]}" "${locks[@]}" "${orig[@]}"
332
+ fi
333
+
334
+ # --- every precondition, before anything is created -------------------------------
335
+ local REPO="" repo_i rid wtr acc br wt tip
336
+ local S_RUN=() S_TIP=() S_NAME=()
337
+ for p in "${SRC[@]}"; do
338
+ for f in repo run_id wt_root accepted.integration; do
339
+ [ -s "$p/$f" ] || die "plan '$p' has no $f: it never ran far enough to have integration state to combine" 3
340
+ done
341
+ repo_i="$(cat "$p/repo")"
342
+ [ -d "$repo_i" ] || die "plan '$p': its repository '$repo_i' does not exist" 3
343
+ repo_i="$(cd "$repo_i" && pwd -P)"
344
+ if [ -z "$REPO" ]; then REPO="$repo_i"
345
+ elif [ "$repo_i" != "$REPO" ]; then
346
+ die "plans from different repositories cannot be combined: '${SRC[0]}' is in $REPO but '$p' is in $repo_i" 3
347
+ fi
348
+ rid="$(cat "$p/run_id")"; wtr="$(cat "$p/wt_root")"; acc="$(cat "$p/accepted.integration")"
349
+ br="forge/$rid-integration"; wt="$wtr/_integration"
350
+ tip="$(git -C "$REPO" rev-parse --verify --quiet "refs/heads/$br^{commit}")" \
351
+ || die "plan '$p': integration branch $br does not exist" 3
352
+ [ "$tip" = "$acc" ] \
353
+ || die "plan '$p': $br is at $(plans_short "$tip") but the run accepted $(plans_short "$acc"); it changed outside forge, so it is not safe to combine" 3
354
+ [ -e "$wt/.git" ] || die "plan '$p': its integration worktree $wt is missing" 3
355
+ [ "$(git -C "$wt" rev-parse HEAD 2>/dev/null)" = "$tip" ] \
356
+ || die "plan '$p': its integration worktree $wt is not at the accepted tip" 3
357
+ [ -z "$(git -C "$wt" status --porcelain -- . ':(exclude).forge' 2>/dev/null)" ] \
358
+ || die "plan '$p': its integration worktree $wt has uncommitted changes; commit or discard them first" 3
359
+ [ -z "$(awk -F'\t' '!/^#/ && NF && NF!=7 {print $1}' "$p/tasks.tsv")" ] \
360
+ || die "plan '$p': tasks.tsv must have exactly seven columns" 3
361
+ S_RUN+=("$rid"); S_TIP+=("$tip"); S_NAME+=("$(basename "$p")")
362
+ done
363
+
364
+ # The new plan's own names are checked before the sources' contents: they are the
365
+ # cheapest refusal and the one most often caused by a typo.
366
+ local NEWRID NEWWTR NEWBR NEWINT repo_text
367
+ repo_text="$(cat "${SRC[0]}/repo")"
368
+ NEWRID="$(plans_run_id "$NEW")"
369
+ NEWWTR="$(plans_wt_root_for "$repo_text" "$NEWRID")"
370
+ NEWBR="forge/$NEWRID-integration"; NEWINT="$NEWWTR/_integration"
371
+ plans_target_checks "$REPO" "$NEW" "$NEWRID" "$NEWWTR"
372
+
373
+ local owners="" owner
374
+ n="${#SRC[@]}"; i=0
375
+ while [ "$i" -lt "$n" ]; do
376
+ for id in $(all_ids "${SRC[$i]}/tasks.tsv"); do
377
+ owner="$(printf '%s' "$owners" | awk -F'\t' -v id="$id" '$1==id {print $2; exit}')"
378
+ [ -z "$owner" ] || die "duplicate task id '$id': it is in both '$owner' and '${SRC[$i]}'; ids must be unique across the plans being combined" 3
379
+ owners="$owners$id$(printf '\t')${SRC[$i]}
380
+ "
381
+ done
382
+ i=$((i+1))
383
+ done
384
+ i=0
385
+ while [ "$i" -lt "$n" ]; do
386
+ for id in $(all_ids "${SRC[$i]}/tasks.tsv"); do
387
+ for d in $(field "${SRC[$i]}/tasks.tsv" "$id" deps | tr ',' ' '); do
388
+ [ "$d" = - ] && continue
389
+ printf '%s' "$owners" | awk -F'\t' -v d="$d" '$1==d {f=1} END {exit !f}' \
390
+ || die "plan '${SRC[$i]}': task '$id' depends on '$d', which is in none of the plans being combined" 3
391
+ done
392
+ done
393
+ i=$((i+1))
394
+ done
395
+
396
+ # Only finished work can be combined: a task that never merged has no place in the result
397
+ # and silently dropping it would hide a failure.
398
+ local left="" st
399
+ i=0
400
+ while [ "$i" -lt "$n" ]; do
401
+ for id in $(all_ids "${SRC[$i]}/tasks.tsv"); do
402
+ st="$(task_status "${SRC[$i]}" "$id")"
403
+ [ "$st" = MERGED ] || left="$left
404
+ ${S_NAME[$i]}: $id ($st)"
405
+ done
406
+ i=$((i+1))
407
+ done
408
+ [ -z "$left" ] || die "tasks left behind: every task of every source plan must be MERGED before plans are combined.$left
409
+ Retry or accept them (or split them off into their own plan) first." 3
410
+
411
+ # The base the new plan's whole-run review diffs from: the oldest base of any source.
412
+ local bases="" b
413
+ i=0
414
+ while [ "$i" -lt "$n" ]; do
415
+ b="$(cat "${SRC[$i]}/integration.base" 2>/dev/null)"
416
+ [ -n "$b" ] || b="$(git -C "$REPO" merge-base "${S_TIP[$i]}" HEAD 2>/dev/null)"
417
+ [ -z "$b" ] || bases="$bases $b"
418
+ i=$((i+1))
419
+ done
420
+ local newbase=""
421
+ [ -z "$bases" ] || newbase="$(git -C "$REPO" merge-base --octopus $bases 2>/dev/null)"
422
+
423
+ local ntasks=0
424
+ for p in "${SRC[@]}"; do ntasks=$((ntasks + $(all_ids "$p/tasks.tsv" | wc -l | tr -d ' '))); done
425
+
426
+ if [ "$DRY" = 1 ]; then
427
+ echo "combine: would create plan $NEW (run forge/$NEWRID) from $n plans, $ntasks tasks, all MERGED"
428
+ echo " integration branch $NEWBR at $(plans_short "${S_TIP[0]}") (${S_NAME[0]}), worktree $NEWINT"
429
+ i=1
430
+ while [ "$i" -lt "$n" ]; do
431
+ echo " then git merge --no-ff ${S_NAME[$i]} ($(plans_short "${S_TIP[$i]}"))"
432
+ i=$((i+1))
433
+ done
434
+ i=0
435
+ while [ "$i" -lt "$n" ]; do
436
+ local j=$((i+1))
437
+ while [ "$j" -lt "$n" ]; do
438
+ plans_predict_conflict "$REPO" "${S_TIP[$i]}" "${S_TIP[$j]}"
439
+ case "$?" in
440
+ 0) echo " merge check: ${S_NAME[$i]} and ${S_NAME[$j]} merge cleanly" ;;
441
+ 1) echo " merge check: ${S_NAME[$i]} and ${S_NAME[$j]} WOULD CONFLICT; the real run would stop with exit 6" ;;
442
+ esac
443
+ j=$((j+1))
444
+ done
445
+ i=$((i+1))
446
+ done
447
+ echo " tasks:$(for p in "${SRC[@]}"; do for id in $(all_ids "$p/tasks.tsv"); do printf ' %s' "$id"; done; done)"
448
+ if [ -n "$VERIFY" ]; then echo " verify: $VERIFY"; fi
449
+ echo "(dry run — nothing created or changed)"
450
+ return 0
451
+ fi
452
+
453
+ # --- build ----------------------------------------------------------------------
454
+ plans_reset_undo
455
+ PLANS_U_REPO="$REPO"; PLANS_U_PLAN="$NEW"; PLANS_U_WTR="$NEWWTR"
456
+ trap 'plans_undo; exit 130' INT TERM
457
+ if [ -d "$NEW" ]; then PLANS_U_PLAN_NEW=0
458
+ else mkdir -p "$NEW" || { plans_undo; die "could not create '$NEW'" 3; }; PLANS_U_PLAN_NEW=1
459
+ fi
460
+ if [ -d "$NEWWTR" ]; then PLANS_U_WTR_NEW=0
461
+ else mkdir -p "$NEWWTR" || { plans_undo; die "could not create '$NEWWTR'" 3; }; PLANS_U_WTR_NEW=1
462
+ fi
463
+ plans_make_integration "$REPO" "$NEWBR" "${S_TIP[0]}" "$NEWINT" \
464
+ || { plans_undo; die "could not create $NEWBR and its worktree" 3; }
465
+
466
+ local mlog="$NEW/.merge.log" files merged_names="${S_NAME[0]}"
467
+ i=1
468
+ while [ "$i" -lt "$n" ]; do
469
+ if ! git -C "$NEWINT" -c user.name=forge -c user.email=forge@local \
470
+ merge --no-ff -m "forge: combine ${S_NAME[$i]}" "${S_TIP[$i]}" > "$mlog" 2>&1; then
471
+ files="$(git -C "$NEWINT" diff --name-only --diff-filter=U 2>/dev/null | tr '\n' ' ')"
472
+ git -C "$NEWINT" merge --abort >/dev/null 2>&1
473
+ local why; why="$(tail -3 "$mlog" 2>/dev/null | tr '\n' ' ')"
474
+ # Recorded before the rollback deletes the new plan: two plans whose work collides
475
+ # is a coupling label nothing else keeps.
476
+ forge_dataset_event combine_conflict --dir "${SRC[0]}" --set new_plan="$NEW" \
477
+ --set plans="${SRC[*]}" --set failed="${S_NAME[$i]}" --set into="$merged_names" --set conflicts="$files"
478
+ plans_undo
479
+ trap - INT TERM
480
+ if [ -n "$files" ]; then
481
+ die "plans conflict: '${S_NAME[$i]}' cannot be merged with $merged_names (conflicting files: $files). Nothing was created; split the overlapping work into one plan or run it in sequence." 6
482
+ fi
483
+ die "merging '${S_NAME[$i]}' into $merged_names failed: $why Nothing was created." 6
484
+ fi
485
+ merged_names="$merged_names + ${S_NAME[$i]}"
486
+ i=$((i+1))
487
+ done
488
+ rm -f "$mlog"
489
+
490
+ local ok=1 tsv="$NEW/tasks.tsv"
491
+ {
492
+ printf '# id\tdeps\tdifficulty\tfiles\tdwarf\tqa\ttitle\n'
493
+ for p in "${SRC[@]}"; do awk -F'\t' '!/^#/ && NF' "$p/tasks.tsv"; done
494
+ } > "$tsv" || ok=0
495
+ printf '%s\n' "$repo_text" > "$NEW/repo" || ok=0
496
+ printf '%s\n' "$NEWRID" > "$NEW/run_id" || ok=0
497
+ printf '%s\n' "$NEWWTR" > "$NEW/wt_root" || ok=0
498
+ git -C "$NEWINT" rev-parse HEAD > "$NEW/accepted.integration" || ok=0
499
+ [ -z "$newbase" ] || printf '%s\n' "$newbase" > "$NEW/integration.base"
500
+ for p in "${SRC[@]}"; do
501
+ for id in $(all_ids "$p/tasks.tsv"); do
502
+ plans_copy_taskdir "$p/tasks/$id" "$NEW/tasks/$id" || ok=0
503
+ done
504
+ done
505
+ [ "$ok" = 1 ] || { plans_undo; trap - INT TERM; die "could not write the combined plan" 3; }
506
+
507
+ # Options: a plan-wide choice carries over when every source made it identically; the
508
+ # two permission flags only when every source granted them.
509
+ local goals="" g
510
+ for p in "${SRC[@]}"; do
511
+ if [ -s "$p/goal.txt" ]; then g="$(tr '\n' ' ' < "$p/goal.txt" | sed 's/ *$//')"; else g="$(basename "$p")"; fi
512
+ if [ -z "$goals" ]; then goals="$g"; else goals="$goals + $g"; fi
513
+ done
514
+ printf '%s\n' "$goals" > "$NEW/goal.txt"
515
+ for f in no_ripwire no_memory; do
516
+ for p in "${SRC[@]}"; do [ -f "$p/$f" ] && { : > "$NEW/$f"; break; }; done
517
+ done
518
+ local all same
519
+ for f in yolo_dwarf yolo_qa; do
520
+ all=1; for p in "${SRC[@]}"; do [ -f "$p/$f" ] || all=0; done
521
+ [ "$all" = 1 ] && : > "$NEW/$f"
522
+ done
523
+ for f in qa_threshold timeout verify_retries; do
524
+ same=1
525
+ for p in "${SRC[@]}"; do
526
+ if [ ! -f "$p/$f" ] || ! cmp -s "$p/$f" "${SRC[0]}/$f"; then same=0; fi
527
+ done
528
+ if [ "$same" = 1 ]; then cp -p "${SRC[0]}/$f" "$NEW/$f"
529
+ else
530
+ for p in "${SRC[@]}"; do
531
+ if [ -f "$p/$f" ]; then note "the plans do not agree on $f; the combined plan leaves it unset"; break; fi
532
+ done
533
+ fi
534
+ done
535
+ # The integration worktree needs a setup step to build and test the combined result, and
536
+ # no task is ever dispatched here, so the first source's is as good as any.
537
+ for p in "${SRC[@]}"; do [ -s "$p/setup_cmd" ] && { cp -p "$p/setup_cmd" "$NEW/setup_cmd"; break; }; done
538
+ if [ -n "$VERIFY" ]; then printf '%s' "$VERIFY" > "$NEW/verify_cmd"
539
+ else
540
+ for p in "${SRC[@]}"; do [ -s "$p/verify_cmd" ] && { cp -p "$p/verify_cmd" "$NEW/verify_cmd"; break; }; done
541
+ fi
542
+ for f in fractal-routing.json jev-selection.json; do
543
+ for p in "${SRC[@]}"; do [ -f "$p/$f" ] && { cp -p "$p/$f" "$NEW/$f"; break; }; done
544
+ done
545
+ {
546
+ i=0
547
+ while [ "$i" -lt "$n" ]; do
548
+ printf '%s\t%s\t%s\n' "${SRC[$i]}" "${S_RUN[$i]}" "${S_TIP[$i]}"
549
+ i=$((i+1))
550
+ done
551
+ } > "$NEW/combined-from.txt"
552
+
553
+ compute_waves "$NEW"
554
+ write_results "$NEW"
555
+ trap - INT TERM
556
+ # The integration worktree validates the combined result, so it needs what a task
557
+ # worktree gets: whatever setup the repository asks for. Never fatal.
558
+ mkdir -p "$NEW/tasks/_integration"
559
+ run_worktree_setup "$NEW" "$repo_text" "$NEWINT" "$NEW/tasks/_integration" _integration
560
+
561
+ echo "combined $n plans into $NEW (run forge/$NEWRID): $ntasks tasks, all MERGED"
562
+ echo "integration branch: $NEWBR at $(plans_short "$(cat "$NEW/accepted.integration")")"
563
+ i=0
564
+ while [ "$i" -lt "$n" ]; do
565
+ echo " from ${S_NAME[$i]}: $(all_ids "${SRC[$i]}/tasks.tsv" | tr '\n' ' ')"
566
+ i=$((i+1))
567
+ done
568
+ echo "task branches keep their original names (forge/<original run>/<task>); results.tsv shows the new run id."
569
+ echo "next: forge-parallel.sh run $NEW (verification only; no model is called)"
570
+ echo " forge-parallel.sh integrate $NEW --approved"
571
+ return 0
572
+ }
573
+
574
+ # --- split ----------------------------------------------------------------------
575
+ # Moves some tasks of a plan, with their branches, worktrees and saved review state, into
576
+ # a new plan that can run on its own. Copy first, delete last: until the new plan is fully
577
+ # written the source is untouched, and any failure on the way puts the moved worktrees and
578
+ # branches back.
579
+ do_split() {
580
+ local PLAN="${1:?split needs a plan dir}" NEW="" LIST="" DRY=0 id f PICKED
581
+ shift
582
+ [ $# -ge 1 ] && [ "${1#-}" = "$1" ] || die "usage: forge-parallel.sh split <plan-dir> <new-plan> --tasks <id,id> [--dry-run]"
583
+ NEW="$1"; shift
584
+ while [ $# -gt 0 ]; do
585
+ case "$1" in
586
+ --tasks) [ -n "${2:-}" ] || die "split: --tasks needs a comma-separated list of task ids"; LIST="$2"; shift 2 ;;
587
+ --dry-run) DRY=1; shift ;;
588
+ *) die "split: unknown option '$1'" ;;
589
+ esac
590
+ done
591
+ [ -n "$LIST" ] || die "split needs --tasks <id,id>: the tasks to move into the new plan"
592
+ NEW="$(plans_abs "$NEW")"
593
+ [ "$NEW" != "$PLAN" ] || die "split: the new plan cannot be the source plan" 3
594
+
595
+ local T="$PLAN/tasks.tsv" SEL=" "
596
+ [ -s "$T" ] || die "no tasks.tsv in '$PLAN'" 3
597
+ for id in $(printf '%s' "$LIST" | tr ',' ' '); do
598
+ all_ids "$T" | grep -qx "$id" || die "task '$id' is not in this plan" 3
599
+ case "$SEL" in *" $id "*) ;; *) SEL="$SEL$id " ;; esac
600
+ done
601
+ PICKED="$(printf '%s' "$SEL" | sed 's/^ *//; s/ *$//')"
602
+
603
+ # Fractal binds a task's state to hashed output paths under the plan directory; moving
604
+ # the task out would orphan it, and no honest way to re-bind it exists.
605
+ if [ -s "$PLAN/fractal-selection.json" ]; then
606
+ python3 -c 'import json, sys
607
+ try:
608
+ sys.exit(0 if json.load(open(sys.argv[1])).get("enabled") is False else 1)
609
+ except Exception:
610
+ sys.exit(1)' "$PLAN/fractal-selection.json" \
611
+ || die "this plan runs under Fractal, which binds each task to output paths under the plan directory; a Fractal-backed plan cannot be split" 3
612
+ fi
613
+ for f in repo run_id wt_root accepted.integration; do
614
+ [ -s "$PLAN/$f" ] || die "plan has no $f: it has not run far enough to split ('run' it first)" 3
615
+ done
616
+ local REPO repo_text run_id old_wtr acc
617
+ repo_text="$(cat "$PLAN/repo")"; run_id="$(cat "$PLAN/run_id")"
618
+ old_wtr="$(cat "$PLAN/wt_root")"; acc="$(cat "$PLAN/accepted.integration")"
619
+ [ -d "$repo_text" ] || die "the plan's repository '$repo_text' does not exist" 3
620
+ REPO="$(cd "$repo_text" && pwd -P)"
621
+ git -C "$REPO" rev-parse --verify --quiet "$acc^{commit}" >/dev/null \
622
+ || die "the accepted integration commit $acc is not in $REPO" 3
623
+
624
+ # Per selected task: what it is, and whether it may move.
625
+ local st dep dst others=0
626
+ for id in $(all_ids "$T"); do
627
+ case "$SEL" in *" $id "*) ;; *) others=$((others+1)) ;; esac
628
+ done
629
+ for id in $(all_ids "$T"); do
630
+ case "$SEL" in *" $id "*) ;; *) continue ;; esac
631
+ st="$(task_status "$PLAN" "$id")"
632
+ case "$st" in
633
+ MERGED) die "task '$id' is MERGED: its work is already in the integration branch and cannot move to another plan" 3 ;;
634
+ RUNNING) die "task '$id' is RUNNING (or a run was killed); 'forge-parallel.sh run $PLAN' settles it first" 3 ;;
635
+ esac
636
+ done
637
+ # Closed under dependents: a task left behind may not depend on a task that moves away.
638
+ for id in $(all_ids "$T"); do
639
+ case "$SEL" in *" $id "*) continue ;; esac
640
+ for dep in $(field "$T" "$id" deps | tr ',' ' '); do
641
+ case "$SEL" in *" $dep "*) die "task '$id' stays behind but depends on '$dep', which is being moved; include '$id' in --tasks (or leave '$dep' too)" 3 ;; esac
642
+ done
643
+ done
644
+ # A moved task may depend on merged work (the new integration branch already has it),
645
+ # but not on a task that is neither moving with it nor merged.
646
+ for id in $(all_ids "$T"); do
647
+ case "$SEL" in *" $id "*) ;; *) continue ;; esac
648
+ for dep in $(field "$T" "$id" deps | tr ',' ' '); do
649
+ [ "$dep" = - ] && continue
650
+ case "$SEL" in *" $dep "*) continue ;; esac
651
+ dst="$(task_status "$PLAN" "$dep")"
652
+ [ "$dst" = MERGED ] || die "task '$id' depends on '$dep', which is not merged (status $dst) and is not being moved; move '$dep' too or finish it first" 3
653
+ done
654
+ done
655
+ local STAYS="" # built in a loop: bash 3.2 cannot parse a `case` inside $( )
656
+ for id in $(all_ids "$T"); do
657
+ case "$SEL" in *" $id "*) ;; *) STAYS="$STAYS $id" ;; esac
658
+ done
659
+ [ "$others" -gt 0 ] || die "that selection is every task in the plan: nothing would stay behind. Choose a subset, or just use a new plan directory" 3
660
+
661
+ local NEWRID NEWWTR NEWBR NEWINT
662
+ NEWRID="$(plans_run_id "$NEW")"
663
+ NEWWTR="$(plans_wt_root_for "$repo_text" "$NEWRID")"
664
+ NEWBR="forge/$NEWRID-integration"; NEWINT="$NEWWTR/_integration"
665
+ plans_target_checks "$REPO" "$NEW" "$NEWRID" "$NEWWTR"
666
+
667
+ if [ "$DRY" = 1 ]; then
668
+ echo "split: would move $PICKED from $PLAN to a new plan $NEW (run forge/$NEWRID)"
669
+ echo " integration branch $NEWBR at $(plans_short "$acc"), worktree $NEWINT"
670
+ for id in $(all_ids "$T"); do
671
+ case "$SEL" in *" $id "*) ;; *) continue ;; esac
672
+ echo " $id ($(task_status "$PLAN" "$id")): branch forge/$run_id/$id -> forge/$NEWRID/$id; worktree $old_wtr/$id -> $NEWWTR/$id"
673
+ done
674
+ echo " stays in $PLAN:$STAYS"
675
+ echo "(dry run — nothing created or changed)"
676
+ return 0
677
+ fi
678
+
679
+ # --- copy ------------------------------------------------------------------------
680
+ plans_reset_undo
681
+ PLANS_U_REPO="$REPO"; PLANS_U_PLAN="$NEW"; PLANS_U_WTR="$NEWWTR"
682
+ trap 'plans_undo; exit 130' INT TERM
683
+ git -C "$REPO" worktree prune >/dev/null 2>&1
684
+ if [ -d "$NEW" ]; then PLANS_U_PLAN_NEW=0
685
+ else mkdir -p "$NEW" || { plans_undo; die "could not create '$NEW'" 3; }; PLANS_U_PLAN_NEW=1
686
+ fi
687
+ if [ -d "$NEWWTR" ]; then PLANS_U_WTR_NEW=0
688
+ else mkdir -p "$NEWWTR" || { plans_undo; die "could not create '$NEWWTR'" 3; }; PLANS_U_WTR_NEW=1
689
+ fi
690
+ plans_make_integration "$REPO" "$NEWBR" "$acc" "$NEWINT" \
691
+ || { plans_undo; trap - INT TERM; die "could not create $NEWBR and its worktree" 3; }
692
+
693
+ local oldbr newbr oldwt newwt head_ref old_branches=""
694
+ for id in $(all_ids "$T"); do
695
+ case "$SEL" in *" $id "*) ;; *) continue ;; esac
696
+ oldbr="forge/$run_id/$id"; newbr="forge/$NEWRID/$id"
697
+ oldwt="$old_wtr/$id"; newwt="$NEWWTR/$id"
698
+ git -C "$REPO" show-ref --verify --quiet "refs/heads/$oldbr" || continue # never ran
699
+ git -C "$REPO" branch "$newbr" "$oldbr" >/dev/null 2>&1 \
700
+ || { plans_undo; trap - INT TERM; die "could not create branch $newbr" 3; }
701
+ plans_journal branch "$newbr" ""
702
+ old_branches="$old_branches $oldbr"
703
+ if [ -e "$oldwt/.git" ]; then
704
+ head_ref="$(git -C "$oldwt" symbolic-ref -q HEAD 2>/dev/null)"
705
+ git -C "$REPO" worktree move "$oldwt" "$newwt" >/dev/null 2>&1 \
706
+ || { plans_undo; trap - INT TERM; die "could not move worktree $oldwt to $newwt" 3; }
707
+ plans_journal move "$oldwt" "$newwt"
708
+ # Same commit, same index, same working tree: only the name HEAD points at changes.
709
+ if [ "$head_ref" = "refs/heads/$oldbr" ]; then
710
+ git -C "$newwt" symbolic-ref HEAD "refs/heads/$newbr" >/dev/null 2>&1 \
711
+ || { plans_undo; trap - INT TERM; die "could not switch $newwt to branch $newbr" 3; }
712
+ plans_journal head "$newwt" "$oldbr"
713
+ fi
714
+ fi
715
+ done
716
+
717
+ # --- write the new plan -----------------------------------------------------------
718
+ local ok=1
719
+ {
720
+ printf '# id\tdeps\tdifficulty\tfiles\tdwarf\tqa\ttitle\n'
721
+ # Dependencies on merged tasks are satisfied by the new integration branch and are
722
+ # dropped; dependencies between moved tasks are kept.
723
+ awk -F'\t' -v OFS='\t' -v ids="$SEL" '
724
+ /^#/ || !NF { next }
725
+ index(ids, " " $1 " ") {
726
+ n = split($2, d, ","); out = ""
727
+ for (i = 1; i <= n; i++)
728
+ if (d[i] != "" && d[i] != "-" && index(ids, " " d[i] " "))
729
+ out = out (out == "" ? "" : ",") d[i]
730
+ $2 = (out == "" ? "-" : out); print
731
+ }' "$T"
732
+ } > "$NEW/tasks.tsv" || ok=0
733
+ printf '%s\n' "$repo_text" > "$NEW/repo" || ok=0
734
+ printf '%s\n' "$NEWRID" > "$NEW/run_id" || ok=0
735
+ printf '%s\n' "$NEWWTR" > "$NEW/wt_root" || ok=0
736
+ printf '%s\n' "$acc" > "$NEW/accepted.integration" || ok=0
737
+ [ ! -s "$PLAN/integration.base" ] || cp -p "$PLAN/integration.base" "$NEW/integration.base" || ok=0
738
+ [ ! -s "$PLAN/goal.txt" ] || cp -p "$PLAN/goal.txt" "$NEW/goal.txt" || ok=0
739
+ for f in $PLANS_OPTION_FILES; do
740
+ [ ! -e "$PLAN/$f" ] || cp -p "$PLAN/$f" "$NEW/$f" || ok=0
741
+ done
742
+ for id in $(all_ids "$T"); do
743
+ case "$SEL" in *" $id "*) ;; *) continue ;; esac
744
+ plans_copy_taskdir "$PLAN/tasks/$id" "$NEW/tasks/$id" || ok=0
745
+ done
746
+ { echo "split from $PLAN"; echo "run_id $run_id"; echo "tasks $PICKED"; } > "$NEW/split-from.txt" || ok=0
747
+ [ "$ok" = 1 ] || { plans_undo; trap - INT TERM; die "could not write the new plan" 3; }
748
+ compute_waves "$NEW"
749
+ write_results "$NEW"
750
+
751
+ # --- delete ----------------------------------------------------------------------
752
+ # Only now does the source lose anything. From here an interrupt must NOT undo the move:
753
+ # the new plan is complete, and undoing it would leave the source without its rows.
754
+ trap - INT TERM
755
+ local srctmp="$PLAN/.tasks.tsv.split" marker="$PLAN/split-$(basename "$NEW").txt"
756
+ if ! awk -F'\t' -v ids="$SEL" '/^#/ || !NF {print; next} index(ids, " " $1 " ") {next} {print}' "$T" > "$srctmp" \
757
+ || ! mv "$srctmp" "$T"; then
758
+ rm -f "$srctmp"; plans_undo; trap - INT TERM
759
+ die "could not update '$T'; nothing was moved" 3
760
+ fi
761
+ {
762
+ echo "moved to $NEW (run forge/$NEWRID) on $(date '+%Y-%m-%dT%H:%M:%S%z')"
763
+ for id in $PICKED; do
764
+ printf '%s\t%s\n' "$id" "forge/$run_id/$id -> forge/$NEWRID/$id"
765
+ done
766
+ } > "$marker"
767
+ for id in $PICKED; do rm -rf "$PLAN/tasks/$id"; done
768
+ compute_waves "$PLAN"
769
+ write_results "$PLAN"
770
+ for oldbr in $old_branches; do
771
+ git -C "$REPO" branch -D "$oldbr" >/dev/null 2>&1 || note "could not delete the old branch $oldbr; it is harmless, delete it by hand"
772
+ done
773
+ mkdir -p "$NEW/tasks/_integration"
774
+ run_worktree_setup "$NEW" "$repo_text" "$NEWINT" "$NEW/tasks/_integration" _integration
775
+
776
+ echo "split: moved $PICKED from $PLAN to $NEW (run forge/$NEWRID)"
777
+ echo "integration branch: $NEWBR at $(plans_short "$acc")"
778
+ for id in $PICKED; do
779
+ echo " $id ($(task_status "$NEW" "$id")): forge/$NEWRID/$id worktree $NEWWTR/$id"
780
+ done
781
+ echo "stays in $PLAN: $(all_ids "$T" | tr '\n' ' ')"
782
+ echo "next: forge-parallel.sh run $NEW [--retry-failed 1] or forge-parallel.sh retry $NEW <task-id>"
783
+ echo " the two plans have separate locks and integration branches; 'combine' joins them again"
784
+ return 0
785
+ }