@mmerterden/multi-agent-pipeline 11.4.0 → 12.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +172 -0
- package/README.md +1 -0
- package/docs/adr/0007-multi-tool-adapter-framework.md +2 -2
- package/index.js +9 -2
- package/install/_common.mjs +107 -5
- package/install/_telemetry.mjs +5 -23
- package/install/claude.mjs +95 -11
- package/install/copilot.mjs +75 -6
- package/install/index.mjs +34 -1
- package/package.json +4 -10
- package/pipeline/commands/multi-agent/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/build-optimize/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/channels/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/dev/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/finish/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +29 -3
- package/pipeline/commands/multi-agent/help/SKILL.md +3 -2
- package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/jira/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/language/SKILL.md +20 -1
- package/pipeline/commands/multi-agent/local/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/log/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/purge/SKILL.md +27 -24
- package/pipeline/commands/multi-agent/refactor/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/review-jira/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/scan/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/search/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/stack/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/status/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +20 -2
- package/pipeline/commands/multi-agent/test/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/uninstall/SKILL.md +87 -0
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -0
- package/pipeline/lib/account-resolver.sh +61 -23
- package/pipeline/lib/channels-multi-repo.sh +7 -2
- package/pipeline/lib/count-lib.sh +27 -0
- package/pipeline/lib/credential-store.sh +23 -6
- package/pipeline/lib/extract-conventions.sh +27 -21
- package/pipeline/lib/fetch-confluence.sh +25 -13
- package/pipeline/lib/fetch-crashlytics.sh +30 -8
- package/pipeline/lib/fetch-fortify.sh +11 -2
- package/pipeline/lib/fetch-swagger.sh +18 -7
- package/pipeline/lib/figma-mcp-refresh.sh +26 -11
- package/pipeline/lib/figma-screenshot.sh +11 -2
- package/pipeline/lib/issue-fetcher.sh +31 -6
- package/pipeline/lib/multi-repo-pipeline.sh +84 -20
- package/pipeline/lib/plan-todos.sh +12 -3
- package/pipeline/lib/post-pr-review.sh +5 -3
- package/pipeline/lib/repo-cache.sh +53 -9
- package/pipeline/lib/review-watch.sh +26 -6
- package/pipeline/lib/shadow-git.sh +45 -4
- package/pipeline/lib/vercel-deploy.sh +10 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +12 -1
- package/pipeline/scripts/audit-log-rotate.sh +38 -10
- package/pipeline/scripts/check-md-links.mjs +34 -9
- package/pipeline/scripts/diff-risk-score.mjs +4 -1
- package/pipeline/scripts/evidence-gate.mjs +10 -1
- package/pipeline/scripts/fixtures/install-layout.tsv +6 -4
- package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
- package/pipeline/scripts/gc-tmp.sh +67 -8
- package/pipeline/scripts/gc-worktrees.sh +140 -0
- package/pipeline/scripts/keychain-save.sh +13 -9
- package/pipeline/scripts/lint-skills.mjs +40 -2
- package/pipeline/scripts/localize-commands.mjs +130 -0
- package/pipeline/scripts/migrate-prefs.mjs +26 -7
- package/pipeline/scripts/phase-tracker.sh +71 -0
- package/pipeline/scripts/pre-commit-check.sh +39 -0
- package/pipeline/scripts/prune-logs.sh +100 -15
- package/pipeline/scripts/purge.sh +235 -0
- package/pipeline/scripts/scan-skills.sh +217 -127
- package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
- package/pipeline/scripts/smoke-description-tr.sh +82 -0
- package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
- package/pipeline/scripts/smoke-gc-tmp.sh +50 -4
- package/pipeline/scripts/smoke-gc-worktrees.sh +125 -0
- package/pipeline/scripts/smoke-install-layout.sh +11 -3
- package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
- package/pipeline/scripts/smoke-pack-contents.sh +140 -0
- package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
- package/pipeline/scripts/smoke-prune-logs.sh +57 -7
- package/pipeline/scripts/smoke-purge.sh +138 -0
- package/pipeline/scripts/smoke-shadow-git.sh +48 -1
- package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
- package/pipeline/scripts/smoke-update-check.sh +13 -0
- package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
- package/pipeline/scripts/test-gap-scan.mjs +12 -1
- package/pipeline/scripts/triage-memory.mjs +10 -1
- package/pipeline/scripts/uninstall.mjs +146 -42
- package/pipeline/scripts/update-check.sh +9 -0
- package/pipeline/scripts/update-issue-progress.sh +60 -41
- package/pipeline/scripts/write-state.mjs +14 -4
- package/pipeline/skills/.skill-manifest.json +7 -7
- package/pipeline/skills/.skills-index.json +9 -9
- package/pipeline/skills/shared/README.md +19 -10
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +1 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +74 -0
- package/pipeline/skills/skills-index.md +1 -1
- package/pipeline/commands/multi-agent/delete/SKILL.md +0 -67
- package/pipeline/skills/shared/core/multi-agent-delete/SKILL.md +0 -66
|
@@ -289,6 +289,61 @@ save_state() {
|
|
|
289
289
|
mv "${TRACKER_FILE}.tmp" "$TRACKER_FILE"
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
# --- read-modify-write lock ---------------------------------------------------
|
|
293
|
+
# tmp+rename makes each save atomic, but two concurrent invocations (e.g. two
|
|
294
|
+
# parallel Phase 4 reviewers reporting `tokens`) both load the same state and
|
|
295
|
+
# the second save silently drops the first delta. Guard every load->save
|
|
296
|
+
# sequence with a portable mkdir spinlock (macOS bash 3.2: no flock builtin).
|
|
297
|
+
# The wait is bounded and the lock FAILS OPEN with a warning so a stuck lock
|
|
298
|
+
# can never hang the pipeline; a dead owner pid or a lock older than 30s is
|
|
299
|
+
# reclaimed.
|
|
300
|
+
TRACKER_LOCK_DIR=""
|
|
301
|
+
TRACKER_LOCK_HELD=0
|
|
302
|
+
|
|
303
|
+
state_lock_stale() {
|
|
304
|
+
local pid mtime now age
|
|
305
|
+
pid=$(cat "$TRACKER_LOCK_DIR/pid" 2>/dev/null || true)
|
|
306
|
+
if [ -n "$pid" ]; then
|
|
307
|
+
kill -0 "$pid" 2>/dev/null && return 1
|
|
308
|
+
return 0
|
|
309
|
+
fi
|
|
310
|
+
mtime=$(stat -f %m "$TRACKER_LOCK_DIR" 2>/dev/null || stat -c %Y "$TRACKER_LOCK_DIR" 2>/dev/null || echo "")
|
|
311
|
+
[ -z "$mtime" ] && return 1
|
|
312
|
+
now=$(date +%s)
|
|
313
|
+
age=$((now - mtime))
|
|
314
|
+
[ "$age" -gt 30 ]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
acquire_state_lock() {
|
|
318
|
+
TRACKER_LOCK_DIR="${TRACKER_FILE}.lock"
|
|
319
|
+
mkdir -p "$TRACKER_DIR" 2>/dev/null
|
|
320
|
+
local tries=0
|
|
321
|
+
# ~5s bound: 50 tries x 0.1s.
|
|
322
|
+
while ! mkdir "$TRACKER_LOCK_DIR" 2>/dev/null; do
|
|
323
|
+
if state_lock_stale; then
|
|
324
|
+
rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
|
|
325
|
+
continue
|
|
326
|
+
fi
|
|
327
|
+
tries=$((tries + 1))
|
|
328
|
+
if [ "$tries" -ge 50 ]; then
|
|
329
|
+
echo "phase-tracker: WARN - state lock busy ($TRACKER_LOCK_DIR); proceeding without lock" >&2
|
|
330
|
+
return 0
|
|
331
|
+
fi
|
|
332
|
+
sleep 0.1
|
|
333
|
+
done
|
|
334
|
+
echo "$$" > "$TRACKER_LOCK_DIR/pid" 2>/dev/null
|
|
335
|
+
TRACKER_LOCK_HELD=1
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
release_state_lock() {
|
|
339
|
+
if [ "$TRACKER_LOCK_HELD" = "1" ]; then
|
|
340
|
+
rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
|
|
341
|
+
TRACKER_LOCK_HELD=0
|
|
342
|
+
fi
|
|
343
|
+
}
|
|
344
|
+
# Never leave a lock behind if a mutating action dies mid-critical-section.
|
|
345
|
+
trap release_state_lock EXIT
|
|
346
|
+
|
|
292
347
|
render() {
|
|
293
348
|
need_jq
|
|
294
349
|
local state
|
|
@@ -509,8 +564,10 @@ case "$ACTION" in
|
|
|
509
564
|
TRACKER_DIR="$(dirname "$TRACKER_FILE")"
|
|
510
565
|
mkdir -p "$TRACKER_DIR" 2>/dev/null
|
|
511
566
|
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
567
|
+
acquire_state_lock
|
|
512
568
|
save_state "$(jq -nc --arg id "$TASK_ID" --arg ts "$NOW" \
|
|
513
569
|
'{task_id:$id,started_at:$ts,phases:[]}')"
|
|
570
|
+
release_state_lock
|
|
514
571
|
# Update pointer so subsequent calls without env can find this tracker.
|
|
515
572
|
# NOTE: the pointer is global; for concurrent runs on the same user account,
|
|
516
573
|
# callers should set MULTI_AGENT_TASK_ID in their shell or prefix every
|
|
@@ -528,6 +585,7 @@ case "$ACTION" in
|
|
|
528
585
|
need_jq
|
|
529
586
|
[ "$#" -ge 2 ] || { echo "add needs <phase_id> <name>" >&2; exit 64; }
|
|
530
587
|
PID="$1"; PNAME="$2"
|
|
588
|
+
acquire_state_lock
|
|
531
589
|
state=$(load_state)
|
|
532
590
|
# Idempotent: a phase id that already exists is a no-op (name, status, and
|
|
533
591
|
# token history are preserved). This is what lets continuation commands
|
|
@@ -538,6 +596,7 @@ case "$ACTION" in
|
|
|
538
596
|
else .phases += [{"id":$id,"name":$name,"status":"pending","subs":[]}]
|
|
539
597
|
end')
|
|
540
598
|
save_state "$new"
|
|
599
|
+
release_state_lock
|
|
541
600
|
render
|
|
542
601
|
;;
|
|
543
602
|
|
|
@@ -550,6 +609,7 @@ case "$ACTION" in
|
|
|
550
609
|
*) echo "bad status: $STATUS" >&2; exit 64 ;;
|
|
551
610
|
esac
|
|
552
611
|
NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
612
|
+
acquire_state_lock
|
|
553
613
|
state=$(load_state)
|
|
554
614
|
# Set status, stamp started_at on first transition to in_progress,
|
|
555
615
|
# stamp completed_at on transition to terminal states (completed/failed/skipped).
|
|
@@ -563,6 +623,7 @@ case "$ACTION" in
|
|
|
563
623
|
)
|
|
564
624
|
')
|
|
565
625
|
save_state "$new"
|
|
626
|
+
release_state_lock
|
|
566
627
|
emit_otel_span "phase.update" "$PID" "$STATUS" "{\"multi_agent.status\": \"$STATUS\"}"
|
|
567
628
|
render
|
|
568
629
|
;;
|
|
@@ -575,6 +636,7 @@ case "$ACTION" in
|
|
|
575
636
|
pending|in_progress|completed|failed|skipped) ;;
|
|
576
637
|
*) echo "bad status: $SSTATUS" >&2; exit 64 ;;
|
|
577
638
|
esac
|
|
639
|
+
acquire_state_lock
|
|
578
640
|
state=$(load_state)
|
|
579
641
|
# If sub with this id exists under this phase, update; else append.
|
|
580
642
|
new=$(echo "$state" | jq \
|
|
@@ -590,6 +652,7 @@ case "$ACTION" in
|
|
|
590
652
|
else . end
|
|
591
653
|
)')
|
|
592
654
|
save_state "$new"
|
|
655
|
+
release_state_lock
|
|
593
656
|
emit_otel_span "phase.sub" "$PID" "$SNAME" "{\"multi_agent.sub_id\": \"$SID\", \"multi_agent.status\": \"$SSTATUS\"}"
|
|
594
657
|
render
|
|
595
658
|
;;
|
|
@@ -602,6 +665,7 @@ case "$ACTION" in
|
|
|
602
665
|
case "$T_IN$T_OUT$T_CACHED" in
|
|
603
666
|
*[!0-9]*) echo "tokens: in/out/cached must be non-negative integers" >&2; exit 64 ;;
|
|
604
667
|
esac
|
|
668
|
+
acquire_state_lock
|
|
605
669
|
state=$(load_state)
|
|
606
670
|
# Additive: add to existing totals so multiple LLM calls accumulate.
|
|
607
671
|
new=$(echo "$state" | jq --arg id "$PID" --argjson ti "$T_IN" --argjson to "$T_OUT" --argjson tc "$T_CACHED" '
|
|
@@ -614,6 +678,7 @@ case "$ACTION" in
|
|
|
614
678
|
)
|
|
615
679
|
')
|
|
616
680
|
save_state "$new"
|
|
681
|
+
release_state_lock
|
|
617
682
|
emit_otel_span "phase.tokens" "$PID" "" "{\"multi_agent.tokens_in_delta\": $T_IN, \"multi_agent.tokens_out_delta\": $T_OUT, \"multi_agent.tokens_cached_delta\": $T_CACHED}"
|
|
618
683
|
# Re-render so live token count is visible on the active phase. Callers
|
|
619
684
|
# that want silent accumulation (e.g. tight loops) can suppress with
|
|
@@ -627,6 +692,7 @@ case "$ACTION" in
|
|
|
627
692
|
need_jq
|
|
628
693
|
[ "$#" -ge 3 ] || { echo "meta needs <phase_id> <key> <value>" >&2; exit 64; }
|
|
629
694
|
PID="$1"; KEY="$2"; VALUE="$3"
|
|
695
|
+
acquire_state_lock
|
|
630
696
|
state=$(load_state)
|
|
631
697
|
new=$(echo "$state" | jq --arg id "$PID" --arg k "$KEY" --arg v "$VALUE" '
|
|
632
698
|
.phases |= map(
|
|
@@ -635,6 +701,7 @@ case "$ACTION" in
|
|
|
635
701
|
else . end
|
|
636
702
|
)')
|
|
637
703
|
save_state "$new"
|
|
704
|
+
release_state_lock
|
|
638
705
|
emit_otel_span "phase.meta" "$PID" "$KEY" "{\"multi_agent.meta_key\": \"$KEY\", \"multi_agent.meta_value\": \"$VALUE\"}"
|
|
639
706
|
if [ "${TRACKER_QUIET:-0}" != "1" ]; then
|
|
640
707
|
render
|
|
@@ -645,12 +712,14 @@ case "$ACTION" in
|
|
|
645
712
|
need_jq
|
|
646
713
|
[ "$#" -ge 2 ] || { echo "model needs <phase_id> <model_name>" >&2; exit 64; }
|
|
647
714
|
PID="$1"; MODEL="$2"
|
|
715
|
+
acquire_state_lock
|
|
648
716
|
state=$(load_state)
|
|
649
717
|
new=$(echo "$state" | jq --arg id "$PID" --arg m "$MODEL" '
|
|
650
718
|
.phases |= map(
|
|
651
719
|
if .id == $id then .model = $m else . end
|
|
652
720
|
)')
|
|
653
721
|
save_state "$new"
|
|
722
|
+
release_state_lock
|
|
654
723
|
emit_otel_span "phase.model" "$PID" "" "{\"multi_agent.model\": \"$MODEL\"}"
|
|
655
724
|
if [ "${TRACKER_QUIET:-0}" != "1" ]; then
|
|
656
725
|
render
|
|
@@ -667,12 +736,14 @@ case "$ACTION" in
|
|
|
667
736
|
if [ "${#TEXT}" -gt 60 ]; then
|
|
668
737
|
TEXT="${TEXT:0:59}…"
|
|
669
738
|
fi
|
|
739
|
+
acquire_state_lock
|
|
670
740
|
state=$(load_state)
|
|
671
741
|
new=$(echo "$state" | jq --arg id "$PID" --arg v "$TEXT" '
|
|
672
742
|
.phases |= map(
|
|
673
743
|
if .id == $id then .meta = ((.meta // {}) + {"Now": $v}) else . end
|
|
674
744
|
)')
|
|
675
745
|
save_state "$new"
|
|
746
|
+
release_state_lock
|
|
676
747
|
emit_otel_span "phase.now" "$PID" "$TEXT" "{}"
|
|
677
748
|
;;
|
|
678
749
|
|
|
@@ -5,6 +5,45 @@
|
|
|
5
5
|
|
|
6
6
|
set -uo pipefail
|
|
7
7
|
|
|
8
|
+
# Hook-mode fast exit. The Claude Code PreToolUse matcher is the tool name
|
|
9
|
+
# ("Bash"), so this script now fires for EVERY Bash call and receives JSON on
|
|
10
|
+
# stdin: {"tool_name":"Bash","tool_input":{"command":"..."}}. Extract the
|
|
11
|
+
# command and exit 0 immediately unless it actually invokes `git commit`.
|
|
12
|
+
# Direct invocations (empty/non-JSON stdin, e.g. smoke tests or manual runs)
|
|
13
|
+
# fall through to the full scan. Extraction failure also falls through - the
|
|
14
|
+
# only cost is an unnecessary scan, never a skipped one on a real commit.
|
|
15
|
+
if [ ! -t 0 ]; then
|
|
16
|
+
HOOK_INPUT="$(cat 2>/dev/null || true)"
|
|
17
|
+
if [ -n "$HOOK_INPUT" ]; then
|
|
18
|
+
HOOK_COMMAND=""
|
|
19
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
20
|
+
HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | python3 -c '
|
|
21
|
+
import json, sys
|
|
22
|
+
try:
|
|
23
|
+
print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
' 2>/dev/null || true)"
|
|
27
|
+
elif command -v node >/dev/null 2>&1; then
|
|
28
|
+
HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | node -e '
|
|
29
|
+
let raw = "";
|
|
30
|
+
process.stdin.on("data", (c) => (raw += c));
|
|
31
|
+
process.stdin.on("end", () => {
|
|
32
|
+
try {
|
|
33
|
+
process.stdout.write(String(JSON.parse(raw)?.tool_input?.command ?? ""));
|
|
34
|
+
} catch { /* fall through to full scan */ }
|
|
35
|
+
});
|
|
36
|
+
' 2>/dev/null || true)"
|
|
37
|
+
fi
|
|
38
|
+
if [ -n "$HOOK_COMMAND" ]; then
|
|
39
|
+
if ! printf '%s\n' "$HOOK_COMMAND" \
|
|
40
|
+
| grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+([^|&;]*[[:space:]])?commit([^[:alnum:]_-]|$)'; then
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
fi
|
|
44
|
+
fi
|
|
45
|
+
fi
|
|
46
|
+
|
|
8
47
|
if [ -z "$(git diff --cached --name-only 2>/dev/null)" ]; then
|
|
9
48
|
exit 0
|
|
10
49
|
fi
|
|
@@ -7,17 +7,26 @@
|
|
|
7
7
|
# These accumulate forever. This prunes whole task dirs, with optional age /
|
|
8
8
|
# project / task filters.
|
|
9
9
|
#
|
|
10
|
-
# PRESERVED ALWAYS: the audit trail (audit.jsonl
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
10
|
+
# PRESERVED ALWAYS: the audit trail (audit.jsonl + rotations), the metrics
|
|
11
|
+
# corpus (metrics.jsonl, metrics-summary.md), and .counter files (per-project
|
|
12
|
+
# task numbering). They live outside task dirs, and as a second line of
|
|
13
|
+
# defense any directory carrying one of those reserved names is skipped even
|
|
14
|
+
# if it looks like a task dir.
|
|
14
15
|
#
|
|
15
|
-
# SAFE BY DEFAULT:
|
|
16
|
-
#
|
|
16
|
+
# SAFE BY DEFAULT:
|
|
17
|
+
# - dry-run: lists what WOULD be removed + space freed; deletes nothing
|
|
18
|
+
# until you pass --yes.
|
|
19
|
+
# - root guard: the log root must be an existing directory and is refused
|
|
20
|
+
# when it resolves to /, $HOME, or a git work tree (a repo checkout is
|
|
21
|
+
# never a log root).
|
|
22
|
+
# - active-task grace: task dirs with files modified in the last
|
|
23
|
+
# PRUNE_GRACE_MIN minutes (default 10) are spared, so pruning while a
|
|
24
|
+
# run is still writing its log dir cannot break resume. An explicit
|
|
25
|
+
# --task=ID bypasses the grace; PRUNE_GRACE_MIN=0 disables it.
|
|
17
26
|
#
|
|
18
27
|
# Usage:
|
|
19
|
-
# prune-logs.sh # dry-run: every task dir
|
|
20
|
-
# prune-logs.sh --yes # delete every task dir
|
|
28
|
+
# prune-logs.sh # dry-run: every idle task dir
|
|
29
|
+
# prune-logs.sh --yes # delete every idle task dir
|
|
21
30
|
# prune-logs.sh --older-than=30 # only task dirs older than 30 days
|
|
22
31
|
# prune-logs.sh --project=my-ios-app # only one project's task dirs
|
|
23
32
|
# prune-logs.sh --task=PROJ-123 # only one task dir
|
|
@@ -25,12 +34,14 @@
|
|
|
25
34
|
#
|
|
26
35
|
# Env:
|
|
27
36
|
# PRUNE_LOG_ROOT override the log root (default $HOME/.claude/logs/multi-agent)
|
|
37
|
+
# PRUNE_GRACE_MIN active-task grace window in minutes (default 10, 0 = off)
|
|
28
38
|
#
|
|
29
|
-
# Exit: 0 on success, 2 on usage error.
|
|
39
|
+
# Exit: 0 on success, 2 on usage error or refused log root.
|
|
30
40
|
|
|
31
41
|
set -uo pipefail
|
|
32
42
|
|
|
33
43
|
LOG_ROOT="${PRUNE_LOG_ROOT:-$HOME/.claude/logs/multi-agent}"
|
|
44
|
+
GRACE_MIN="${PRUNE_GRACE_MIN:-10}"
|
|
34
45
|
DELETE=0
|
|
35
46
|
OLDER_DAYS=0
|
|
36
47
|
PROJECT=""
|
|
@@ -40,6 +51,13 @@ usage() {
|
|
|
40
51
|
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
|
|
41
52
|
}
|
|
42
53
|
|
|
54
|
+
# --project / --task values are compared against path components; only plain
|
|
55
|
+
# names are meaningful. Rejects empty, ".", "..", anything with a slash, and
|
|
56
|
+
# anything starting with "." or "-".
|
|
57
|
+
valid_id() {
|
|
58
|
+
printf '%s' "$1" | grep -qE '^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
for arg in "$@"; do
|
|
44
62
|
case "$arg" in
|
|
45
63
|
--yes | --force) DELETE=1 ;;
|
|
@@ -50,8 +68,20 @@ for arg in "$@"; do
|
|
|
50
68
|
exit 2
|
|
51
69
|
fi
|
|
52
70
|
;;
|
|
53
|
-
--project=*)
|
|
54
|
-
|
|
71
|
+
--project=*)
|
|
72
|
+
PROJECT="${arg#*=}"
|
|
73
|
+
if ! valid_id "$PROJECT"; then
|
|
74
|
+
echo "prune-logs: --project needs a plain directory name (letters/digits then . _ -), got: '$PROJECT'" >&2
|
|
75
|
+
exit 2
|
|
76
|
+
fi
|
|
77
|
+
;;
|
|
78
|
+
--task=*)
|
|
79
|
+
TASK="${arg#*=}"
|
|
80
|
+
if ! valid_id "$TASK"; then
|
|
81
|
+
echo "prune-logs: --task needs a plain directory name (letters/digits then . _ -), got: '$TASK'" >&2
|
|
82
|
+
exit 2
|
|
83
|
+
fi
|
|
84
|
+
;;
|
|
55
85
|
-h | --help)
|
|
56
86
|
usage
|
|
57
87
|
exit 0
|
|
@@ -63,11 +93,29 @@ for arg in "$@"; do
|
|
|
63
93
|
esac
|
|
64
94
|
done
|
|
65
95
|
|
|
96
|
+
if ! printf '%s' "$GRACE_MIN" | grep -qE '^[0-9]+$'; then
|
|
97
|
+
echo "prune-logs: PRUNE_GRACE_MIN needs a whole number of minutes, got: $GRACE_MIN" >&2
|
|
98
|
+
exit 2
|
|
99
|
+
fi
|
|
100
|
+
|
|
66
101
|
if [ ! -d "$LOG_ROOT" ]; then
|
|
67
102
|
echo "prune-logs: no log root at $LOG_ROOT - nothing to do"
|
|
68
103
|
exit 0
|
|
69
104
|
fi
|
|
70
105
|
|
|
106
|
+
# Root guard: resolve symlinks, then refuse roots that can never be a log
|
|
107
|
+
# root. Everything removed below sits strictly inside this resolved path.
|
|
108
|
+
LOG_ROOT="$(cd "$LOG_ROOT" 2>/dev/null && pwd -P)"
|
|
109
|
+
if [ -z "$LOG_ROOT" ]; then
|
|
110
|
+
echo "prune-logs: cannot resolve log root" >&2
|
|
111
|
+
exit 2
|
|
112
|
+
fi
|
|
113
|
+
HOME_REAL="$(cd "$HOME" 2>/dev/null && pwd -P || printf '%s' "$HOME")"
|
|
114
|
+
if [ "$LOG_ROOT" = "/" ] || [ "$LOG_ROOT" = "$HOME_REAL" ] || [ -e "$LOG_ROOT/.git" ]; then
|
|
115
|
+
echo "prune-logs: refusing log root $LOG_ROOT (/, \$HOME, or a git work tree is never a log root)" >&2
|
|
116
|
+
exit 2
|
|
117
|
+
fi
|
|
118
|
+
|
|
71
119
|
# A task dir is any dir (depth 1-2 under the root) carrying a run marker.
|
|
72
120
|
is_task_dir() {
|
|
73
121
|
local d="$1"
|
|
@@ -75,23 +123,47 @@ is_task_dir() {
|
|
|
75
123
|
[ -f "$d/agent-log.md" ] || [ -f "$d/otel-spans.jsonl" ]
|
|
76
124
|
}
|
|
77
125
|
|
|
126
|
+
# Names promised to survive pruning. A directory carrying one of these names
|
|
127
|
+
# is never a legitimate task dir, so it is skipped outright.
|
|
128
|
+
is_reserved_name() {
|
|
129
|
+
case "$1" in
|
|
130
|
+
audit.jsonl | audit.*.jsonl* | audit.jsonl.* | metrics.jsonl | metrics-summary.md | .counter | *.counter) return 0 ;;
|
|
131
|
+
esac
|
|
132
|
+
return 1
|
|
133
|
+
}
|
|
134
|
+
|
|
78
135
|
MTIME_ARGS=()
|
|
79
136
|
if [ "$OLDER_DAYS" -gt 0 ]; then
|
|
80
137
|
MTIME_ARGS=(-mtime "+$OLDER_DAYS")
|
|
81
138
|
fi
|
|
82
139
|
|
|
83
140
|
matches=()
|
|
141
|
+
skipped_active=0
|
|
84
142
|
while IFS= read -r -d '' d; do
|
|
143
|
+
base="$(basename "$d")"
|
|
144
|
+
if is_reserved_name "$base"; then continue; fi
|
|
85
145
|
is_task_dir "$d" || continue
|
|
86
146
|
# --task: dir name must equal the task id
|
|
87
|
-
if [ -n "$TASK" ] && [ "$
|
|
147
|
+
if [ -n "$TASK" ] && [ "$base" != "$TASK" ]; then continue; fi
|
|
88
148
|
# --project: the immediate parent dir name must equal the project
|
|
89
149
|
if [ -n "$PROJECT" ] && [ "$(basename "$(dirname "$d")")" != "$PROJECT" ]; then continue; fi
|
|
150
|
+
# Active-task grace: spare dirs a run touched in the last GRACE_MIN minutes
|
|
151
|
+
# so a concurrent run keeps a resumable log dir. --task is an explicit,
|
|
152
|
+
# deliberate target and bypasses the grace.
|
|
153
|
+
if [ "$GRACE_MIN" -gt 0 ] && [ -z "$TASK" ] &&
|
|
154
|
+
find "$d" -mmin "-$GRACE_MIN" -print 2>/dev/null | head -1 | grep -q .; then
|
|
155
|
+
skipped_active=$((skipped_active + 1))
|
|
156
|
+
continue
|
|
157
|
+
fi
|
|
90
158
|
matches+=("$d")
|
|
91
159
|
done < <(find "$LOG_ROOT" -mindepth 1 -maxdepth 2 -type d ${MTIME_ARGS[@]+"${MTIME_ARGS[@]}"} -print0 2>/dev/null)
|
|
92
160
|
|
|
93
161
|
if [ "${#matches[@]}" -eq 0 ]; then
|
|
94
|
-
|
|
162
|
+
if [ "$skipped_active" -gt 0 ]; then
|
|
163
|
+
echo "prune-logs: only active task dirs matched (touched in the last ${GRACE_MIN}m) - spared, nothing to do"
|
|
164
|
+
else
|
|
165
|
+
echo "prune-logs: no task logs matched under $LOG_ROOT - nothing to do"
|
|
166
|
+
fi
|
|
95
167
|
exit 0
|
|
96
168
|
fi
|
|
97
169
|
|
|
@@ -106,13 +178,26 @@ human="${total_kb} KB"
|
|
|
106
178
|
if [ "$DELETE" -eq 0 ]; then
|
|
107
179
|
echo "DRY-RUN - would remove ${#matches[@]} task log dir(s) (frees ~${human}):"
|
|
108
180
|
for d in "${matches[@]}"; do echo " ${d#"$LOG_ROOT"/}"; done
|
|
181
|
+
[ "$skipped_active" -gt 0 ] && echo "Spared $skipped_active active dir(s) touched in the last ${GRACE_MIN}m."
|
|
109
182
|
echo "Audit trail + metrics corpus are preserved. Re-run with --yes to delete."
|
|
110
183
|
exit 0
|
|
111
184
|
fi
|
|
112
185
|
|
|
113
186
|
removed=0
|
|
114
187
|
for d in "${matches[@]}"; do
|
|
115
|
-
|
|
188
|
+
# Containment guard: resolve the target and require it strictly inside the
|
|
189
|
+
# resolved log root before rm -rf.
|
|
190
|
+
target="$(cd "$d" 2>/dev/null && pwd -P)" || continue
|
|
191
|
+
case "$target" in
|
|
192
|
+
"$LOG_ROOT"/*) ;;
|
|
193
|
+
*)
|
|
194
|
+
echo "prune-logs: skipping path outside log root: $d" >&2
|
|
195
|
+
continue
|
|
196
|
+
;;
|
|
197
|
+
esac
|
|
198
|
+
rm -rf "$target" 2>/dev/null && removed=$((removed + 1))
|
|
116
199
|
done
|
|
117
|
-
|
|
200
|
+
spared_note=""
|
|
201
|
+
[ "$skipped_active" -gt 0 ] && spared_note=", spared $skipped_active active"
|
|
202
|
+
echo "══ prune-logs: removed ${removed}/${#matches[@]} task log dir(s)${spared_note}, freed ~${human} (audit + metrics preserved) ══"
|
|
118
203
|
exit 0
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# purge.sh - deletion backend for /multi-agent:purge (full reset).
|
|
3
|
+
#
|
|
4
|
+
# Wipes every multi-agent worktree under <repo>/.worktrees/ (registered or
|
|
5
|
+
# orphaned), deletes the local branches those worktrees carried, and resets
|
|
6
|
+
# the project's task counter at
|
|
7
|
+
# $HOME/.claude/logs/multi-agent/<project>/.counter
|
|
8
|
+
#
|
|
9
|
+
# The confirmation UX (double confirmation, remote-branch question) lives in
|
|
10
|
+
# the /multi-agent:purge skill; this script is the mechanical part. Task log
|
|
11
|
+
# dirs are /multi-agent:prune-logs territory (prune-logs.sh --project=<name>).
|
|
12
|
+
#
|
|
13
|
+
# PRESERVED ALWAYS:
|
|
14
|
+
# - the repo's main worktree, its checked-out branch, and main/master/develop
|
|
15
|
+
# - everything outside <repo>/.worktrees/ (symlinked entries are never
|
|
16
|
+
# followed; every rm target must resolve strictly inside .worktrees/)
|
|
17
|
+
# - the audit trail + metrics corpus at the log root (only the project's
|
|
18
|
+
# .counter file is removed there)
|
|
19
|
+
#
|
|
20
|
+
# SAFE BY DEFAULT: dry-run. Lists what WOULD be removed; deletes nothing until
|
|
21
|
+
# you pass --yes. Remote branches are deleted only with --delete-remote AND
|
|
22
|
+
# --yes; a missing/unreachable remote is a warning, not a failure.
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
# purge.sh # dry-run in the current repo
|
|
26
|
+
# purge.sh --yes # wipe worktrees + branches + counter
|
|
27
|
+
# purge.sh --repo <path> # operate on another repo
|
|
28
|
+
# purge.sh --delete-remote --yes # also delete the remote branches
|
|
29
|
+
#
|
|
30
|
+
# Env:
|
|
31
|
+
# PURGE_LOG_ROOT override the log root (default $HOME/.claude/logs/multi-agent)
|
|
32
|
+
#
|
|
33
|
+
# Exit: 0 on success (including "nothing to do"), 2 on usage error or refused
|
|
34
|
+
# repo.
|
|
35
|
+
|
|
36
|
+
set -uo pipefail
|
|
37
|
+
|
|
38
|
+
REPO="$PWD"
|
|
39
|
+
DELETE=0
|
|
40
|
+
DELETE_REMOTE=0
|
|
41
|
+
LOG_ROOT="${PURGE_LOG_ROOT:-$HOME/.claude/logs/multi-agent}"
|
|
42
|
+
|
|
43
|
+
usage() {
|
|
44
|
+
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
while [ $# -gt 0 ]; do
|
|
48
|
+
case "$1" in
|
|
49
|
+
--yes | --force) DELETE=1; shift ;;
|
|
50
|
+
--delete-remote) DELETE_REMOTE=1; shift ;;
|
|
51
|
+
--repo)
|
|
52
|
+
REPO="${2:-}"
|
|
53
|
+
[ -z "$REPO" ] && { echo "purge: --repo needs a path" >&2; exit 2; }
|
|
54
|
+
shift 2
|
|
55
|
+
;;
|
|
56
|
+
-h | --help) usage; exit 0 ;;
|
|
57
|
+
*) echo "purge: unknown argument: $1" >&2; exit 2 ;;
|
|
58
|
+
esac
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
62
|
+
echo "purge: $REPO is not a git work tree - nothing to do"
|
|
63
|
+
exit 0
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# Anchor everything on the MAIN worktree (first porcelain entry), so running
|
|
67
|
+
# purge from inside a linked worktree still targets <repo>/.worktrees/.
|
|
68
|
+
MAIN_WT="$(git -C "$REPO" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | head -1)"
|
|
69
|
+
MAIN_WT="$(cd "$MAIN_WT" 2>/dev/null && pwd -P)"
|
|
70
|
+
HOME_REAL="$(cd "$HOME" 2>/dev/null && pwd -P || printf '%s' "$HOME")"
|
|
71
|
+
if [ -z "$MAIN_WT" ] || [ "$MAIN_WT" = "/" ] || [ "$MAIN_WT" = "$HOME_REAL" ]; then
|
|
72
|
+
echo "purge: refusing to operate on repo root '$MAIN_WT'" >&2
|
|
73
|
+
exit 2
|
|
74
|
+
fi
|
|
75
|
+
WT_ROOT="$MAIN_WT/.worktrees"
|
|
76
|
+
CURRENT_BRANCH="$(git -C "$MAIN_WT" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
77
|
+
|
|
78
|
+
# Branches that must survive a purge no matter what a worktree carried.
|
|
79
|
+
is_protected_branch() {
|
|
80
|
+
case "$1" in
|
|
81
|
+
"" | main | master | develop | "$CURRENT_BRANCH") return 0 ;;
|
|
82
|
+
esac
|
|
83
|
+
return 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
resolve_dir() {
|
|
87
|
+
cd "$1" 2>/dev/null && pwd -P
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Collect registered worktrees that live strictly inside .worktrees/, with
|
|
91
|
+
# the branch each carries (empty when detached). bash 3.2: parallel arrays.
|
|
92
|
+
wt_paths=()
|
|
93
|
+
wt_branches=()
|
|
94
|
+
cur_path=""
|
|
95
|
+
cur_branch=""
|
|
96
|
+
flush_block() {
|
|
97
|
+
[ -z "$cur_path" ] && { cur_branch=""; return 0; }
|
|
98
|
+
local resolved
|
|
99
|
+
resolved="$(resolve_dir "$cur_path")"
|
|
100
|
+
case "$resolved" in
|
|
101
|
+
"$WT_ROOT"/*)
|
|
102
|
+
wt_paths+=("$resolved")
|
|
103
|
+
wt_branches+=("$cur_branch")
|
|
104
|
+
;;
|
|
105
|
+
esac
|
|
106
|
+
cur_path=""
|
|
107
|
+
cur_branch=""
|
|
108
|
+
}
|
|
109
|
+
while IFS= read -r line; do
|
|
110
|
+
case "$line" in
|
|
111
|
+
worktree\ *) flush_block; cur_path="${line#worktree }" ;;
|
|
112
|
+
branch\ refs/heads/*) cur_branch="${line#branch refs/heads/}" ;;
|
|
113
|
+
esac
|
|
114
|
+
done <<EOF
|
|
115
|
+
$(git -C "$MAIN_WT" worktree list --porcelain 2>/dev/null)
|
|
116
|
+
EOF
|
|
117
|
+
flush_block
|
|
118
|
+
|
|
119
|
+
# Orphan dirs under .worktrees/ that git no longer registers.
|
|
120
|
+
orphans=()
|
|
121
|
+
if [ -d "$WT_ROOT" ]; then
|
|
122
|
+
for d in "$WT_ROOT"/*/; do
|
|
123
|
+
[ -d "$d" ] || continue
|
|
124
|
+
[ -L "${d%/}" ] && continue # never follow symlinked entries out of the tree
|
|
125
|
+
dir="$(resolve_dir "$d")"
|
|
126
|
+
[ -z "$dir" ] && continue
|
|
127
|
+
case "$dir" in "$WT_ROOT"/*) ;; *) continue ;; esac
|
|
128
|
+
reg=0
|
|
129
|
+
for p in ${wt_paths+"${wt_paths[@]}"}; do
|
|
130
|
+
[ "$p" = "$dir" ] && { reg=1; break; }
|
|
131
|
+
done
|
|
132
|
+
[ "$reg" -eq 1 ] && continue
|
|
133
|
+
orphans+=("$dir")
|
|
134
|
+
done
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# Counter file for this project.
|
|
138
|
+
PROJECT="$(basename "$MAIN_WT")"
|
|
139
|
+
COUNTER=""
|
|
140
|
+
if [ -n "$PROJECT" ] && [ "$PROJECT" != "." ] && [ "$PROJECT" != ".." ] &&
|
|
141
|
+
[ -f "$LOG_ROOT/$PROJECT/.counter" ]; then
|
|
142
|
+
COUNTER="$LOG_ROOT/$PROJECT/.counter"
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
total=$((${#wt_paths[@]} + ${#orphans[@]}))
|
|
146
|
+
if [ "$total" -eq 0 ] && [ -z "$COUNTER" ]; then
|
|
147
|
+
echo "purge: no multi-agent worktrees, branches, or counter in $MAIN_WT - nothing to do"
|
|
148
|
+
exit 0
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
if [ "$DELETE" -eq 0 ]; then
|
|
152
|
+
echo "DRY-RUN - purge would remove from $MAIN_WT:"
|
|
153
|
+
i=0
|
|
154
|
+
while [ "$i" -lt "${#wt_paths[@]}" ]; do
|
|
155
|
+
br="${wt_branches[$i]}"
|
|
156
|
+
echo " worktree ${wt_paths[$i]#"$MAIN_WT"/} (branch: ${br:-detached})"
|
|
157
|
+
if ! is_protected_branch "$br"; then
|
|
158
|
+
echo " local branch $br"
|
|
159
|
+
[ "$DELETE_REMOTE" -eq 1 ] && echo " remote branch origin/$br"
|
|
160
|
+
fi
|
|
161
|
+
i=$((i + 1))
|
|
162
|
+
done
|
|
163
|
+
for d in ${orphans+"${orphans[@]}"}; do
|
|
164
|
+
echo " orphan worktree dir ${d#"$MAIN_WT"/}"
|
|
165
|
+
done
|
|
166
|
+
[ -n "$COUNTER" ] && echo " task counter $COUNTER"
|
|
167
|
+
echo "Main worktree, current branch, and main/master/develop are never touched."
|
|
168
|
+
echo "Re-run with --yes to delete."
|
|
169
|
+
exit 0
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
removed_wt=0
|
|
173
|
+
removed_br=0
|
|
174
|
+
i=0
|
|
175
|
+
while [ "$i" -lt "${#wt_paths[@]}" ]; do
|
|
176
|
+
p="${wt_paths[$i]}"
|
|
177
|
+
br="${wt_branches[$i]}"
|
|
178
|
+
i=$((i + 1))
|
|
179
|
+
wt_gone=0
|
|
180
|
+
if git -C "$MAIN_WT" worktree remove --force "$p" >/dev/null 2>&1; then
|
|
181
|
+
wt_gone=1
|
|
182
|
+
else
|
|
183
|
+
# Fall back to a guarded rm + prune when git refuses (locked, damaged).
|
|
184
|
+
case "$p" in
|
|
185
|
+
"$WT_ROOT"/*)
|
|
186
|
+
rm -rf "$p" 2>/dev/null && wt_gone=1
|
|
187
|
+
git -C "$MAIN_WT" worktree prune >/dev/null 2>&1
|
|
188
|
+
;;
|
|
189
|
+
*) echo "purge: skipping worktree outside $WT_ROOT: $p" >&2 ;;
|
|
190
|
+
esac
|
|
191
|
+
fi
|
|
192
|
+
if [ "$wt_gone" -eq 1 ]; then
|
|
193
|
+
removed_wt=$((removed_wt + 1))
|
|
194
|
+
echo "→ removed worktree ${p#"$MAIN_WT"/}"
|
|
195
|
+
else
|
|
196
|
+
echo "purge: warning: could not remove worktree $p" >&2
|
|
197
|
+
continue
|
|
198
|
+
fi
|
|
199
|
+
if is_protected_branch "$br"; then
|
|
200
|
+
[ -n "$br" ] && echo "→ kept protected branch $br"
|
|
201
|
+
continue
|
|
202
|
+
fi
|
|
203
|
+
if git -C "$MAIN_WT" branch -D "$br" >/dev/null 2>&1; then
|
|
204
|
+
removed_br=$((removed_br + 1))
|
|
205
|
+
echo "→ deleted local branch $br"
|
|
206
|
+
fi
|
|
207
|
+
if [ "$DELETE_REMOTE" -eq 1 ]; then
|
|
208
|
+
if git -C "$MAIN_WT" push origin --delete "$br" >/dev/null 2>&1; then
|
|
209
|
+
echo "→ deleted remote branch origin/$br"
|
|
210
|
+
else
|
|
211
|
+
echo "purge: warning: could not delete remote branch origin/$br (no remote?)" >&2
|
|
212
|
+
fi
|
|
213
|
+
fi
|
|
214
|
+
done
|
|
215
|
+
|
|
216
|
+
for d in ${orphans+"${orphans[@]}"}; do
|
|
217
|
+
# Containment guard: resolve again right before rm.
|
|
218
|
+
dir="$(resolve_dir "$d")"
|
|
219
|
+
case "$dir" in
|
|
220
|
+
"$WT_ROOT"/*)
|
|
221
|
+
rm -rf "$dir" 2>/dev/null && { removed_wt=$((removed_wt + 1)); echo "→ removed orphan worktree dir ${dir#"$MAIN_WT"/}"; }
|
|
222
|
+
;;
|
|
223
|
+
*) echo "purge: skipping orphan outside $WT_ROOT: $d" >&2 ;;
|
|
224
|
+
esac
|
|
225
|
+
done
|
|
226
|
+
|
|
227
|
+
if [ -n "$COUNTER" ]; then
|
|
228
|
+
rm -f "$COUNTER" 2>/dev/null && echo "→ reset task counter $COUNTER"
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
# Drop the now-empty .worktrees/ shell (only when truly empty).
|
|
232
|
+
[ -d "$WT_ROOT" ] && rmdir "$WT_ROOT" 2>/dev/null
|
|
233
|
+
|
|
234
|
+
echo "══ purge: removed $removed_wt worktree(s) and $removed_br local branch(es) in $MAIN_WT ══"
|
|
235
|
+
exit 0
|