@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.
Files changed (117) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +1 -0
  3. package/docs/adr/0007-multi-tool-adapter-framework.md +2 -2
  4. package/index.js +9 -2
  5. package/install/_common.mjs +107 -5
  6. package/install/_telemetry.mjs +5 -23
  7. package/install/claude.mjs +95 -11
  8. package/install/copilot.mjs +75 -6
  9. package/install/index.mjs +34 -1
  10. package/package.json +4 -10
  11. package/pipeline/commands/multi-agent/SKILL.md +1 -0
  12. package/pipeline/commands/multi-agent/analysis/SKILL.md +1 -0
  13. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +1 -0
  14. package/pipeline/commands/multi-agent/autopilot/SKILL.md +1 -0
  15. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +1 -0
  16. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -0
  17. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -0
  18. package/pipeline/commands/multi-agent/dev/SKILL.md +1 -0
  19. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -0
  20. package/pipeline/commands/multi-agent/dev-local/SKILL.md +2 -1
  21. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -0
  22. package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -0
  23. package/pipeline/commands/multi-agent/finish/SKILL.md +1 -0
  24. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +29 -3
  25. package/pipeline/commands/multi-agent/help/SKILL.md +3 -2
  26. package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
  27. package/pipeline/commands/multi-agent/jira/SKILL.md +1 -0
  28. package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
  29. package/pipeline/commands/multi-agent/language/SKILL.md +20 -1
  30. package/pipeline/commands/multi-agent/local/SKILL.md +1 -0
  31. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +1 -0
  32. package/pipeline/commands/multi-agent/log/SKILL.md +1 -0
  33. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -0
  34. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
  35. package/pipeline/commands/multi-agent/purge/SKILL.md +27 -24
  36. package/pipeline/commands/multi-agent/refactor/SKILL.md +1 -0
  37. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
  38. package/pipeline/commands/multi-agent/review/SKILL.md +1 -0
  39. package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
  40. package/pipeline/commands/multi-agent/review-jira/SKILL.md +1 -0
  41. package/pipeline/commands/multi-agent/scan/SKILL.md +1 -0
  42. package/pipeline/commands/multi-agent/search/SKILL.md +1 -0
  43. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -0
  44. package/pipeline/commands/multi-agent/stack/SKILL.md +1 -0
  45. package/pipeline/commands/multi-agent/status/SKILL.md +1 -0
  46. package/pipeline/commands/multi-agent/sync/SKILL.md +20 -2
  47. package/pipeline/commands/multi-agent/test/SKILL.md +1 -0
  48. package/pipeline/commands/multi-agent/uninstall/SKILL.md +87 -0
  49. package/pipeline/commands/multi-agent/update/SKILL.md +1 -0
  50. package/pipeline/lib/account-resolver.sh +61 -23
  51. package/pipeline/lib/channels-multi-repo.sh +7 -2
  52. package/pipeline/lib/count-lib.sh +27 -0
  53. package/pipeline/lib/credential-store.sh +23 -6
  54. package/pipeline/lib/extract-conventions.sh +27 -21
  55. package/pipeline/lib/fetch-confluence.sh +25 -13
  56. package/pipeline/lib/fetch-crashlytics.sh +30 -8
  57. package/pipeline/lib/fetch-fortify.sh +11 -2
  58. package/pipeline/lib/fetch-swagger.sh +18 -7
  59. package/pipeline/lib/figma-mcp-refresh.sh +26 -11
  60. package/pipeline/lib/figma-screenshot.sh +11 -2
  61. package/pipeline/lib/issue-fetcher.sh +31 -6
  62. package/pipeline/lib/multi-repo-pipeline.sh +84 -20
  63. package/pipeline/lib/plan-todos.sh +12 -3
  64. package/pipeline/lib/post-pr-review.sh +5 -3
  65. package/pipeline/lib/repo-cache.sh +53 -9
  66. package/pipeline/lib/review-watch.sh +26 -6
  67. package/pipeline/lib/shadow-git.sh +45 -4
  68. package/pipeline/lib/vercel-deploy.sh +10 -1
  69. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -5
  70. package/pipeline/multi-agent-refs/phases/phase-0-init.md +12 -1
  71. package/pipeline/scripts/audit-log-rotate.sh +38 -10
  72. package/pipeline/scripts/check-md-links.mjs +34 -9
  73. package/pipeline/scripts/diff-risk-score.mjs +4 -1
  74. package/pipeline/scripts/evidence-gate.mjs +10 -1
  75. package/pipeline/scripts/fixtures/install-layout.tsv +6 -4
  76. package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
  77. package/pipeline/scripts/gc-tmp.sh +67 -8
  78. package/pipeline/scripts/gc-worktrees.sh +140 -0
  79. package/pipeline/scripts/keychain-save.sh +13 -9
  80. package/pipeline/scripts/lint-skills.mjs +40 -2
  81. package/pipeline/scripts/localize-commands.mjs +130 -0
  82. package/pipeline/scripts/migrate-prefs.mjs +26 -7
  83. package/pipeline/scripts/phase-tracker.sh +71 -0
  84. package/pipeline/scripts/pre-commit-check.sh +39 -0
  85. package/pipeline/scripts/prune-logs.sh +100 -15
  86. package/pipeline/scripts/purge.sh +235 -0
  87. package/pipeline/scripts/scan-skills.sh +217 -127
  88. package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
  89. package/pipeline/scripts/smoke-description-tr.sh +82 -0
  90. package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
  91. package/pipeline/scripts/smoke-gc-tmp.sh +50 -4
  92. package/pipeline/scripts/smoke-gc-worktrees.sh +125 -0
  93. package/pipeline/scripts/smoke-install-layout.sh +11 -3
  94. package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
  95. package/pipeline/scripts/smoke-pack-contents.sh +140 -0
  96. package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
  97. package/pipeline/scripts/smoke-prune-logs.sh +57 -7
  98. package/pipeline/scripts/smoke-purge.sh +138 -0
  99. package/pipeline/scripts/smoke-shadow-git.sh +48 -1
  100. package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
  101. package/pipeline/scripts/smoke-update-check.sh +13 -0
  102. package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
  103. package/pipeline/scripts/test-gap-scan.mjs +12 -1
  104. package/pipeline/scripts/triage-memory.mjs +10 -1
  105. package/pipeline/scripts/uninstall.mjs +146 -42
  106. package/pipeline/scripts/update-check.sh +9 -0
  107. package/pipeline/scripts/update-issue-progress.sh +60 -41
  108. package/pipeline/scripts/write-state.mjs +14 -4
  109. package/pipeline/skills/.skill-manifest.json +7 -7
  110. package/pipeline/skills/.skills-index.json +9 -9
  111. package/pipeline/skills/shared/README.md +19 -10
  112. package/pipeline/skills/shared/core/multi-agent/SKILL.md +1 -0
  113. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
  114. package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +74 -0
  115. package/pipeline/skills/skills-index.md +1 -1
  116. package/pipeline/commands/multi-agent/delete/SKILL.md +0 -67
  117. 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, audit.*.jsonl*) and the
11
- # metrics corpus (metrics.jsonl, metrics-summary.md) live at the log-root TOP
12
- # level, not inside task dirs - this only ever removes task dirs, so those are
13
- # never touched. .counter files (per-project task numbering) also survive.
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: dry-run. Lists what WOULD be removed + space freed; deletes
16
- # nothing until you pass --yes.
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=*) PROJECT="${arg#*=}" ;;
54
- --task=*) TASK="${arg#*=}" ;;
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" ] && [ "$(basename "$d")" != "$TASK" ]; then continue; fi
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
- echo "prune-logs: no task logs matched under $LOG_ROOT - nothing to do"
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
- rm -rf "$d" 2>/dev/null && removed=$((removed + 1))
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
- echo "══ prune-logs: removed ${removed}/${#matches[@]} task log dir(s), freed ~${human} (audit + metrics preserved) ══"
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