@mmerterden/multi-agent-pipeline 14.0.0 → 14.1.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.
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # worktree-finalize.sh - salvage a finished task's artefacts, then remove its
4
+ # worktree. Called by Phase 6 once the PR is open.
5
+ #
6
+ # The problem it solves: a task's worktree is dead weight the moment its PR
7
+ # exists, but people forget to remove it, so `.worktrees/` accumulates full
8
+ # checkouts. Removing it at PR-open is safe ONLY if the files Phase 7 and
9
+ # `:resume` read are moved somewhere that outlives it - hence salvage first.
10
+ #
11
+ # What it deliberately does NOT do:
12
+ # * NO `git checkout` of the task branch. `git worktree remove` leaves the
13
+ # branch as an ordinary local branch, so nothing is lost and the user's own
14
+ # HEAD and uncommitted work are untouched. Phase 5's flow checks the branch
15
+ # out on purpose (it is a test handoff); doing that here would move the
16
+ # user's HEAD out from under them mid-task, which is the one thing they
17
+ # asked not to happen.
18
+ # * NO `git branch -D`. The branch is the deliverable.
19
+ # * NO `rm -rf`. `git worktree remove` refuses on a dirty tree, which is a
20
+ # safety feature; `rm -rf` would silently destroy uncommitted work.
21
+ #
22
+ # Usage:
23
+ # worktree-finalize.sh --worktree <path> --project-root <path>
24
+ # --task-id <id> [--project <slug>] [--branch <name>]
25
+ # [--logs-root <dir>] [--dry-run] [--json]
26
+ #
27
+ # Exit codes:
28
+ # 0 removed (or dry-run that would remove)
29
+ # 3 SKIPPED by a safety precondition - not an error, reason is reported
30
+ # 1 usage / hard error
31
+ set -uo pipefail
32
+
33
+ WORKTREE=""; PROJECT_ROOT=""; TASK_ID=""; PROJECT=""; BRANCH=""
34
+ LOGS_ROOT="${HOME}/.claude/logs/multi-agent"
35
+ DRY=0; JSON=0
36
+
37
+ while [ $# -gt 0 ]; do
38
+ case "$1" in
39
+ --worktree) WORKTREE="${2:-}"; shift 2 ;;
40
+ --project-root) PROJECT_ROOT="${2:-}"; shift 2 ;;
41
+ --task-id) TASK_ID="${2:-}"; shift 2 ;;
42
+ --project) PROJECT="${2:-}"; shift 2 ;;
43
+ --branch) BRANCH="${2:-}"; shift 2 ;;
44
+ --logs-root) LOGS_ROOT="${2:-}"; shift 2 ;;
45
+ --dry-run) DRY=1; shift ;;
46
+ --json) JSON=1; shift ;;
47
+ *) echo "usage: worktree-finalize.sh --worktree <p> --project-root <p> --task-id <id> [--project <s>] [--branch <b>] [--logs-root <d>] [--dry-run] [--json]" >&2; exit 1 ;;
48
+ esac
49
+ done
50
+
51
+ [ -n "$WORKTREE" ] && [ -n "$PROJECT_ROOT" ] && [ -n "$TASK_ID" ] || {
52
+ echo "ERR: --worktree, --project-root and --task-id are required" >&2; exit 1; }
53
+
54
+ SALVAGED=""
55
+ REMOVED=false
56
+ REASON=""
57
+
58
+ emit() {
59
+ if [ "$JSON" = "1" ]; then
60
+ # Built by node, not printf: a skip reason embeds a filename straight from
61
+ # `git status --porcelain`, which quotes unusual names, so a file like
62
+ # `"my file".swift` produced invalid JSON and the caller's jq step errored
63
+ # instead of recording a safe skip.
64
+ REMOVED="$REMOVED" REASON="$REASON" BRANCH="$BRANCH" DEST="${DEST:-}" \
65
+ SALVAGED_RAW="$SALVAGED" DRYF="$([ "$DRY" = 1 ] && echo true || echo false)" \
66
+ node -e '
67
+ const s = process.env.SALVAGED_RAW || "";
68
+ const salvaged = s ? s.split(",").map((x) => x.replace(/^"|"$/g, "")) : [];
69
+ process.stdout.write(JSON.stringify({
70
+ removed: process.env.REMOVED === "true",
71
+ reason: process.env.REASON || "",
72
+ salvaged,
73
+ branch: process.env.BRANCH || "",
74
+ artifactsPath: process.env.DEST || "",
75
+ dryRun: process.env.DRYF === "true",
76
+ }) + "\n");
77
+ ' 2>/dev/null || printf '{"removed":false,"reason":"emit failed","salvaged":[],"branch":"","artifactsPath":"","dryRun":false}\n'
78
+ else
79
+ if [ "$REMOVED" = "true" ]; then
80
+ echo "worktree-finalize: removed $WORKTREE"
81
+ echo " branch kept locally: ${BRANCH:-<unknown>} (no checkout performed)"
82
+ [ -n "${DEST:-}" ] && echo " artefacts: $DEST"
83
+ else
84
+ echo "worktree-finalize: SKIPPED - $REASON"
85
+ fi
86
+ fi
87
+ }
88
+
89
+ skip() { REASON="$1"; emit; exit 3; }
90
+
91
+ # --- preference gate -------------------------------------------------------
92
+ # Read here rather than in the caller so EVERY caller honours it. Declared in
93
+ # prefs.schema.json as settings.worktreeAutoRemoveOnPr; a preference that is
94
+ # declared, migrated and documented but never consulted is worse than no
95
+ # preference at all, because turning it off appears to work and does nothing.
96
+ PREFS="${PREFS_FILE:-$HOME/.claude/multi-agent-preferences.json}"
97
+ if [ -f "$PREFS" ] && command -v node >/dev/null 2>&1; then
98
+ enabled="$(node -e '
99
+ const fs=require("fs");
100
+ try {
101
+ const s=(JSON.parse(fs.readFileSync(process.argv[1],"utf8")).global||{}).settings||{};
102
+ process.stdout.write(s.worktreeAutoRemoveOnPr === false ? "false" : "true");
103
+ } catch { process.stdout.write("true"); }
104
+ ' "$PREFS" 2>/dev/null || echo true)"
105
+ if [ "$enabled" = "false" ]; then
106
+ REASON="disabled by prefs.global.settings.worktreeAutoRemoveOnPr"
107
+ emit; exit 3
108
+ fi
109
+ fi
110
+
111
+ # --- preconditions ---------------------------------------------------------
112
+
113
+ # `--local` mode has no worktree: worktreePath equals the project root. Removing
114
+ # it would delete the user's checkout.
115
+ rp_wt="$(cd "$WORKTREE" 2>/dev/null && pwd -P || echo "")"
116
+ rp_pr="$(cd "$PROJECT_ROOT" 2>/dev/null && pwd -P || echo "")"
117
+ [ -n "$rp_wt" ] || skip "worktree path does not exist: $WORKTREE"
118
+ [ -n "$rp_pr" ] || skip "project root does not exist: $PROJECT_ROOT"
119
+ [ "$rp_wt" != "$rp_pr" ] || skip "worktree equals project root (--local mode) - nothing to remove"
120
+
121
+ # Refuse if the caller is standing inside the tree being removed. A shell left on
122
+ # a deleted inode is worse than a leftover directory, and Phase 6 legitimately
123
+ # cd's into the worktree earlier, so this is a real case rather than a theoretical
124
+ # one. The caller must cd out first.
125
+ cwd_rp="$(pwd -P)"
126
+ case "$cwd_rp/" in
127
+ "$rp_wt"/*) skip "current directory is inside the worktree - cd to the project root first" ;;
128
+ esac
129
+
130
+ # Must be a worktree git actually knows about, so a mistyped path cannot delete an
131
+ # unrelated directory.
132
+ if ! git -C "$rp_pr" worktree list --porcelain 2>/dev/null \
133
+ | awk '/^worktree /{print substr($0,10)}' \
134
+ | while IFS= read -r w; do (cd "$w" 2>/dev/null && pwd -P); done \
135
+ | grep -qxF "$rp_wt"; then
136
+ skip "not a registered worktree of $rp_pr"
137
+ fi
138
+
139
+ [ -n "$BRANCH" ] || BRANCH="$(git -C "$rp_wt" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
140
+
141
+ # A detached worktree resolves BRANCH to the literal "HEAD", which then builds
142
+ # refs/remotes/origin/HEAD - the remote's default-branch symref, unrelated to this
143
+ # work. The pushed-HEAD check would pass against the wrong ref and the
144
+ # "branch is kept" guarantee would be vacuous, because no branch ref exists.
145
+ [ "$BRANCH" != "HEAD" ] || skip "detached HEAD - no branch would survive the removal, refusing to finalize"
146
+
147
+ # Uncommitted work is never discarded. Phase 5 states the same rule; this is the
148
+ # second place it has to hold, because here there is no WIP-commit step to fall
149
+ # back on - the PR is already open and a surprise WIP commit would not be in it.
150
+ #
151
+ # The pipeline's OWN artefacts are excluded from that judgement. They live inside
152
+ # the worktree and are untracked, so a raw `status --porcelain` is never empty at
153
+ # PR-open and the removal would never fire - the feature would look implemented
154
+ # and do nothing. They are also exactly the files salvaged below, so losing them
155
+ # from the worktree is the intent rather than a risk.
156
+ #
157
+ # Only these specific paths are forgiven. `--untracked-files=no` would have been
158
+ # one character cheaper and wrong: a source file the dev created but never
159
+ # `git add`ed is invisible to it, and that file would be destroyed silently.
160
+ # Matched on NAME ALONE this forgave a project's own tracked files: a repo that
161
+ # tracks `.build.log` or `.pipeline/` had its uncommitted modification forgiven
162
+ # here and the file deleted below, leaving a kept worktree with ` D .build.log`
163
+ # and the only copy of the change in a salvage dir nobody was told about.
164
+ # So the carve-out applies only to paths git reports as UNTRACKED (`??`), and the
165
+ # alternatives are end-anchored - unanchored, `.build.log.old` was forgiven here
166
+ # but not deletable below, which made every removal refuse forever.
167
+ ARTIFACTS_RE='^(agent-state\.json|phase-tracker\.json|triage-output\.json|\.review-diff\.txt|\.build\.log|\.test\.log)$|^\.pipeline/'
168
+ dirty="$(git -C "$rp_wt" status --porcelain 2>/dev/null | awk '
169
+ {
170
+ st = substr($0, 1, 2); path = substr($0, 4);
171
+ if (st == "??") { untracked = 1 } else { untracked = 0 }
172
+ print (untracked ? "U" : "T") "\t" path;
173
+ }' | while IFS="$(printf '\t')" read -r kind path; do
174
+ if [ "$kind" = "U" ] && printf '%s' "$path" | grep -Eq "$ARTIFACTS_RE"; then
175
+ continue # our own untracked artefact
176
+ fi
177
+ printf '%s\n' "$path"
178
+ done)"
179
+ if [ -n "$dirty" ]; then
180
+ first="$(printf '%s\n' "$dirty" | head -1)"
181
+ skip "worktree has uncommitted changes (e.g. $first) - refusing to remove"
182
+ fi
183
+
184
+ # HEAD must be on the remote. Removing a worktree whose commits exist nowhere else
185
+ # is not cleanup, it is data loss: the branch ref survives locally, but a later
186
+ # `branch -D` or a fresh clone loses the work.
187
+ # --verify --quiet, not a bare rev-parse: on a missing ref a bare rev-parse
188
+ # prints the ref NAME to stdout and errors, so `2>/dev/null` leaves the name in
189
+ # the variable and it gets compared against a sha - producing the nonsense
190
+ # "HEAD differs from remote (refs/rem...)" instead of "no remote counterpart".
191
+ head_sha="$(git -C "$rp_wt" rev-parse --verify --quiet HEAD || echo "")"
192
+ upstream_sha="$(git -C "$rp_wt" rev-parse --verify --quiet '@{u}' || echo "")"
193
+ if [ -z "$upstream_sha" ] && [ -n "$BRANCH" ]; then
194
+ # No upstream configured: accept a remote ref that already points at HEAD.
195
+ upstream_sha="$(git -C "$rp_wt" rev-parse --verify --quiet "refs/remotes/origin/$BRANCH" || echo "")"
196
+ fi
197
+ [ -n "$upstream_sha" ] || skip "branch has no remote counterpart - push before finalizing"
198
+ [ "$head_sha" = "$upstream_sha" ] || skip "HEAD ($(echo "$head_sha" | cut -c1-8)) differs from remote ($(echo "$upstream_sha" | cut -c1-8)) - push before finalizing"
199
+
200
+ # --- salvage ---------------------------------------------------------------
201
+ # Everything Phase 7, :resume, :status and :log read out of the worktree. Each is
202
+ # copied only if present; a task that never reached Phase 4 has no triage output
203
+ # and that is not an error.
204
+ DEST="$LOGS_ROOT/${PROJECT:+$PROJECT/}$TASK_ID/artifacts"
205
+
206
+ copy_one() {
207
+ src="$rp_wt/$1"
208
+ [ -e "$src" ] || return 0
209
+ if [ "$DRY" = "1" ]; then
210
+ SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
211
+ return 0
212
+ fi
213
+ mkdir -p "$DEST/$(dirname "$1")" 2>/dev/null
214
+ cp -R "$src" "$DEST/$1" 2>/dev/null && SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
215
+ }
216
+
217
+ for a in agent-state.json phase-tracker.json triage-output.json \
218
+ .review-diff.txt .build.log .test.log .pipeline; do
219
+ copy_one "$a"
220
+ done
221
+
222
+ # --- remove ----------------------------------------------------------------
223
+ if [ "$DRY" = "1" ]; then
224
+ REMOVED=true; REASON="dry-run"; emit; exit 0
225
+ fi
226
+
227
+ # The artefacts are now safely in the log dir, and `git worktree remove` refuses
228
+ # on untracked files just as it does on modified ones. Drop exactly the paths that
229
+ # were salvaged - nothing else - so the removal can proceed on a genuinely clean
230
+ # tree. This is what keeps `--force` off the table: a refusal after this point
231
+ # means unexpected dirt, and forcing past unexpected dirt is how work gets lost.
232
+ DROPPED=""
233
+ for a in agent-state.json phase-tracker.json triage-output.json \
234
+ .review-diff.txt .build.log .test.log .pipeline; do
235
+ [ -e "$rp_wt/$a" ] || continue
236
+ case "$a" in
237
+ */*|"") continue ;; # refuse anything that could escape the worktree
238
+ esac
239
+ # Tracked means it belongs to the project, not to us - even when the name
240
+ # collides with one of ours. Deleting it destroyed a real file in testing.
241
+ if git -C "$rp_wt" ls-files --error-unmatch -- "$a" >/dev/null 2>&1; then
242
+ continue
243
+ fi
244
+ # ${var:?} so an empty rp_wt can never expand to `rm -rf /<name>`.
245
+ rm -rf -- "${rp_wt:?}/$a"
246
+ DROPPED="${DROPPED:+$DROPPED }$a"
247
+ done
248
+
249
+ # Put them back if the removal is refused. The artefacts were dropped BEFORE
250
+ # knowing `git worktree remove` would succeed, so on the refusal path (submodule,
251
+ # lock, permissions) a still-live run lost its agent-state.json and
252
+ # phase-tracker.json from the location every consumer prefers - and a later
253
+ # write-state.mjs merge against that path would recreate the file holding only the
254
+ # patch, corrupting the run record.
255
+ restore_dropped() {
256
+ [ -n "$DROPPED" ] || return 0
257
+ for a in $DROPPED; do
258
+ [ -e "$DEST/$a" ] || continue
259
+ cp -R "$DEST/$a" "$rp_wt/$a" 2>/dev/null || true
260
+ done
261
+ }
262
+
263
+ if git -C "$rp_pr" worktree remove "$rp_wt" 2>/dev/null; then
264
+ git -C "$rp_pr" worktree prune 2>/dev/null || true
265
+ REMOVED=true; REASON="removed after PR"
266
+
267
+ # Stamp the outcome into the SALVAGED agent-state.json here, rather than leaving
268
+ # the caller to write it. In single-repo worktree mode the caller's $STATE_FILE
269
+ # is the copy that just went away with the worktree, so a write there fails and
270
+ # `worktreeRemovedAt` / `artifactsPath` land nowhere - which then makes Phase 7
271
+ # read a dead path, fall back to the removed worktree, and silently skip the
272
+ # triage-memory ingest. Exactly the [ -f ]-guarded degradation this step exists
273
+ # to prevent.
274
+ if [ -f "$DEST/agent-state.json" ] && command -v node >/dev/null 2>&1; then
275
+ DEST="$DEST" BRANCH="$BRANCH" node -e '
276
+ const fs = require("fs");
277
+ const p = process.env.DEST + "/agent-state.json";
278
+ try {
279
+ const s = JSON.parse(fs.readFileSync(p, "utf8"));
280
+ s.worktreeRemovedAt = new Date().toISOString();
281
+ s.artifactsPath = process.env.DEST;
282
+ s.worktreePath = null;
283
+ fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
284
+ } catch { /* leave the salvaged copy untouched rather than corrupt it */ }
285
+ ' 2>/dev/null || true
286
+ fi
287
+
288
+ emit
289
+ exit 0
290
+ fi
291
+
292
+ restore_dropped
293
+
294
+ # `worktree remove` refused. Do NOT escalate to --force: the clean-tree check
295
+ # above already passed, so a refusal here means something unexpected (a lock, a
296
+ # submodule, a permission problem) and forcing would destroy whatever it is.
297
+ REASON="git worktree remove refused - left in place (artefacts already salvaged)"
298
+ emit
299
+ exit 3
@@ -227,10 +227,10 @@ When invoked with the `release` argument:
227
227
 
228
228
  ```
229
229
  analysis, analysis-resolve, autopilot, build-optimize, channels, create-jira, design-check, dev,
230
- dev-autopilot, dev-local, dev-local-autopilot, diff-explain, finish, forget, garbage-collect,
230
+ dev-autopilot, dev-local, dev-local-autopilot, diff-explain, forget, garbage-collect,
231
231
  help, ios-coding-standard, issue, jira, kill, language, local,
232
232
  local-autopilot, log, manual-test, prune-logs, purge, refactor, resume, review, review-issue, review-jira,
233
- routines, save, scan, search, setup, stack, status, sync, test, testflight-validation, uninstall, update
233
+ routines, save, scan, search, setup, ship, stack, status, sync, test, testflight-validation, uninstall, update
234
234
  ```
235
235
 
236
236
  **NOT synced**: `refs/*` - Lazy-load references, Claude Code specific