@mmerterden/multi-agent-pipeline 11.5.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 (69) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/docs/adr/0007-multi-tool-adapter-framework.md +2 -2
  3. package/install/claude.mjs +25 -1
  4. package/install/index.mjs +34 -1
  5. package/package.json +1 -1
  6. package/pipeline/commands/multi-agent/SKILL.md +1 -0
  7. package/pipeline/commands/multi-agent/analysis/SKILL.md +1 -0
  8. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +1 -0
  9. package/pipeline/commands/multi-agent/autopilot/SKILL.md +1 -0
  10. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +1 -0
  11. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -0
  12. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -0
  13. package/pipeline/commands/multi-agent/dev/SKILL.md +1 -0
  14. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -0
  15. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -0
  16. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -0
  17. package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -0
  18. package/pipeline/commands/multi-agent/finish/SKILL.md +1 -0
  19. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +29 -3
  20. package/pipeline/commands/multi-agent/help/SKILL.md +3 -2
  21. package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
  22. package/pipeline/commands/multi-agent/jira/SKILL.md +1 -0
  23. package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
  24. package/pipeline/commands/multi-agent/language/SKILL.md +20 -1
  25. package/pipeline/commands/multi-agent/local/SKILL.md +1 -0
  26. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +1 -0
  27. package/pipeline/commands/multi-agent/log/SKILL.md +1 -0
  28. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -0
  29. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
  30. package/pipeline/commands/multi-agent/purge/SKILL.md +27 -24
  31. package/pipeline/commands/multi-agent/refactor/SKILL.md +1 -0
  32. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
  33. package/pipeline/commands/multi-agent/review/SKILL.md +1 -0
  34. package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
  35. package/pipeline/commands/multi-agent/review-jira/SKILL.md +1 -0
  36. package/pipeline/commands/multi-agent/scan/SKILL.md +1 -0
  37. package/pipeline/commands/multi-agent/search/SKILL.md +1 -0
  38. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -0
  39. package/pipeline/commands/multi-agent/stack/SKILL.md +1 -0
  40. package/pipeline/commands/multi-agent/status/SKILL.md +1 -0
  41. package/pipeline/commands/multi-agent/sync/SKILL.md +20 -2
  42. package/pipeline/commands/multi-agent/test/SKILL.md +1 -0
  43. package/pipeline/commands/multi-agent/uninstall/SKILL.md +87 -0
  44. package/pipeline/commands/multi-agent/update/SKILL.md +1 -0
  45. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -5
  46. package/pipeline/multi-agent-refs/phases/phase-0-init.md +12 -1
  47. package/pipeline/scripts/fixtures/install-layout.tsv +4 -2
  48. package/pipeline/scripts/gc-tmp.sh +67 -8
  49. package/pipeline/scripts/gc-worktrees.sh +140 -0
  50. package/pipeline/scripts/localize-commands.mjs +130 -0
  51. package/pipeline/scripts/prune-logs.sh +100 -15
  52. package/pipeline/scripts/purge.sh +235 -0
  53. package/pipeline/scripts/smoke-description-tr.sh +82 -0
  54. package/pipeline/scripts/smoke-gc-tmp.sh +50 -4
  55. package/pipeline/scripts/smoke-gc-worktrees.sh +125 -0
  56. package/pipeline/scripts/smoke-prune-logs.sh +57 -7
  57. package/pipeline/scripts/smoke-purge.sh +138 -0
  58. package/pipeline/scripts/smoke-update-check.sh +13 -0
  59. package/pipeline/scripts/uninstall.mjs +41 -6
  60. package/pipeline/scripts/update-check.sh +9 -0
  61. package/pipeline/skills/.skill-manifest.json +7 -7
  62. package/pipeline/skills/.skills-index.json +9 -9
  63. package/pipeline/skills/shared/README.md +19 -10
  64. package/pipeline/skills/shared/core/multi-agent/SKILL.md +1 -0
  65. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
  66. package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +74 -0
  67. package/pipeline/skills/skills-index.md +1 -1
  68. package/pipeline/commands/multi-agent/delete/SKILL.md +0 -67
  69. package/pipeline/skills/shared/core/multi-agent-delete/SKILL.md +0 -66
@@ -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
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-description-tr.sh - localized picker-description contract.
3
+ #
4
+ # Verifies: every shipped command SKILL.md carries a description-tr line
5
+ # (coverage), repo description: lines stay English with no description-en
6
+ # sidecars committed (hygiene), and localize-commands.mjs apply/restore is
7
+ # an idempotent, byte-identical round trip (mechanics).
8
+
9
+ set -uo pipefail
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
13
+ CMD_DIR="$REPO_ROOT/pipeline/commands/multi-agent"
14
+ LOCALIZER="$REPO_ROOT/pipeline/scripts/localize-commands.mjs"
15
+
16
+ PASS=0
17
+ FAIL=0
18
+ record_pass() { PASS=$((PASS + 1)); printf ' \342\234\223 %s\n' "$1"; }
19
+ record_fail() { FAIL=$((FAIL + 1)); printf ' \342\234\227 %s\n' "$1"; }
20
+
21
+ printf '\342\206\222 smoke-description-tr: localized picker-description contract\n'
22
+
23
+ # 1. Coverage: every command SKILL.md (incl. the dispatcher) has description-tr
24
+ missing=""
25
+ count=0
26
+ for f in "$CMD_DIR/SKILL.md" "$CMD_DIR"/*/SKILL.md; do
27
+ [ -f "$f" ] || continue
28
+ count=$((count + 1))
29
+ grep -q '^description-tr:' "$f" || missing="$missing ${f#"$CMD_DIR/"}"
30
+ done
31
+ if [ -z "$missing" ]; then
32
+ record_pass "all $count command SKILL.md files carry description-tr"
33
+ else
34
+ record_fail "missing description-tr in:$missing"
35
+ fi
36
+
37
+ # 2. Hygiene: repo descriptions stay English, no sidecars committed
38
+ if grep -rn '^description:.*[çğıöşüÇĞİÖŞÜ]' "$CMD_DIR" >/dev/null 2>&1; then
39
+ record_fail "repo description: lines must stay English (Turkish characters found)"
40
+ else
41
+ record_pass "repo description: lines are English"
42
+ fi
43
+ if grep -rln '^description-en:' "$CMD_DIR" >/dev/null 2>&1; then
44
+ record_fail "description-en sidecar committed to the repo (installed-tree artifact)"
45
+ else
46
+ record_pass "no description-en sidecar in the repo"
47
+ fi
48
+
49
+ # 3. Translation length within the agent-skills description budget
50
+ long=$(awk '/^description-tr:/ && length($0) > 1044 { print FILENAME }' "$CMD_DIR"/*/SKILL.md "$CMD_DIR/SKILL.md" 2>/dev/null | head -1)
51
+ if [ -z "$long" ]; then
52
+ record_pass "every description-tr is within the 1024-char budget"
53
+ else
54
+ record_fail "description-tr exceeds budget in $long"
55
+ fi
56
+
57
+ # 4. Round trip: apply is idempotent, restore is byte-identical
58
+ tmpdir=$(mktemp -d)
59
+ trap 'rm -rf "$tmpdir"' EXIT
60
+ cp -R "$CMD_DIR" "$tmpdir/cmds"
61
+ node "$LOCALIZER" apply --dir "$tmpdir/cmds" >/dev/null 2>&1
62
+ if grep -q '^description:.*[çğıöşüÇĞİÖŞÜ]' "$tmpdir/cmds/dev/SKILL.md" \
63
+ && grep -q '^description-en:' "$tmpdir/cmds/dev/SKILL.md"; then
64
+ record_pass "apply swaps description and writes the description-en sidecar"
65
+ else
66
+ record_fail "apply did not localize the sample file"
67
+ fi
68
+ second=$(node "$LOCALIZER" apply --dir "$tmpdir/cmds" 2>/dev/null)
69
+ if printf '%s' "$second" | grep -q ' 0 file(s) changed'; then
70
+ record_pass "second apply is a no-op (idempotent)"
71
+ else
72
+ record_fail "second apply changed files again: $second"
73
+ fi
74
+ node "$LOCALIZER" restore --dir "$tmpdir/cmds" >/dev/null 2>&1
75
+ if diff -r "$CMD_DIR" "$tmpdir/cmds" >/dev/null 2>&1; then
76
+ record_pass "restore returns a byte-identical tree"
77
+ else
78
+ record_fail "restore left differences vs the original tree"
79
+ fi
80
+
81
+ printf '\n\342\225\220\342\225\220 smoke-description-tr: %d passed, %d failed \342\225\220\342\225\220\n' "$PASS" "$FAIL"
82
+ [ "$FAIL" -eq 0 ]
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # smoke-gc-tmp.sh - gc-tmp.sh sweeps only known scratch prefixes, is dry-run
3
- # by default, and never touches unrelated files.
3
+ # by default, never touches unrelated files, refuses unsafe scratch roots,
4
+ # never deletes the root itself, and spares fresh scratch by default.
4
5
  #
5
6
  # Exit 0 = all pass, 1 = any failure.
6
7
 
@@ -40,7 +41,7 @@ seed() {
40
41
  echo "→ 1. dry-run lists matches and deletes nothing"
41
42
  seed
42
43
  before=$(find "$SB" -mindepth 1 | wc -l | tr -d ' ')
43
- out=$(GC_TMP_ROOT="$SB" bash "$GC" 2>&1)
44
+ out=$(GC_TMP_ROOT="$SB" GC_TMP_GRACE_MIN=0 bash "$GC" 2>&1)
44
45
  after=$(find "$SB" -mindepth 1 | wc -l | tr -d ' ')
45
46
  echo "$out" | grep -q "DRY-RUN" && pass "dry-run banner shown" || fail "dry-run banner missing"
46
47
  [ "$before" = "$after" ] && pass "dry-run deleted nothing ($after entries intact)" || fail "dry-run changed the tree ($before -> $after)"
@@ -49,7 +50,7 @@ echo "$out" | grep -q "unrelated-file.txt" && fail "dry-run matched an unrelated
49
50
  echo ""
50
51
  echo "→ 2. --yes removes only matched scratch"
51
52
  seed
52
- GC_TMP_ROOT="$SB" bash "$GC" --yes >/dev/null 2>&1
53
+ GC_TMP_ROOT="$SB" GC_TMP_GRACE_MIN=0 bash "$GC" --yes >/dev/null 2>&1
53
54
  [ -f "$SB/unrelated-file.txt" ] && pass "unrelated file preserved" || fail "unrelated file was deleted"
54
55
  [ ! -e "$SB/multi-agent-picker-PROJ-1.json" ] && pass "multi-agent-* file removed" || fail "multi-agent-* file survived"
55
56
  [ ! -e "$SB/multi-agent-review-PROJ-1-x" ] && pass "scratch dir removed" || fail "scratch dir survived"
@@ -61,7 +62,7 @@ remaining=$(find "$SB" -mindepth 1 | wc -l | tr -d ' ')
61
62
  echo ""
62
63
  echo "→ 3. --older-than excludes fresh scratch"
63
64
  seed
64
- out=$(GC_TMP_ROOT="$SB" bash "$GC" --older-than=1 2>&1)
65
+ out=$(GC_TMP_ROOT="$SB" GC_TMP_GRACE_MIN=0 bash "$GC" --older-than=1 2>&1)
65
66
  echo "$out" | grep -q "nothing to do" && pass "freshly-created scratch excluded by --older-than=1" || fail "--older-than did not exclude fresh files"
66
67
 
67
68
  echo ""
@@ -79,6 +80,51 @@ rc=$?
79
80
  rm -rf "$EMPTY"
80
81
  { [ "$rc" = "0" ] && echo "$out" | grep -q "nothing to do"; } && pass "empty root exits 0 with no-op message" || fail "empty root not a clean no-op"
81
82
 
83
+ echo ""
84
+ echo "→ 6. unsafe scratch roots are refused (exit 2)"
85
+ GC_TMP_ROOT="$HOME" bash "$GC" >/dev/null 2>&1
86
+ [ "$?" = "2" ] && pass "\$HOME as scratch root refused" || fail "\$HOME as scratch root not refused"
87
+ GC_TMP_ROOT="/" bash "$GC" >/dev/null 2>&1
88
+ [ "$?" = "2" ] && pass "/ as scratch root refused" || fail "/ as scratch root not refused"
89
+ GITROOT="$(mktemp -d)"
90
+ mkdir -p "$GITROOT/.git"
91
+ touch "$GITROOT/multi-agent-bait.json"
92
+ GC_TMP_ROOT="$GITROOT" bash "$GC" --yes >/dev/null 2>&1
93
+ rc=$?
94
+ [ "$rc" = "2" ] && pass "git work tree as scratch root refused" || fail "git work tree as scratch root not refused"
95
+ [ -f "$GITROOT/multi-agent-bait.json" ] && pass "bait file inside refused root untouched" || fail "refused root was still swept"
96
+ rm -rf "$GITROOT"
97
+
98
+ echo ""
99
+ echo "→ 7. a root itself named like scratch is never deleted"
100
+ SR="$(mktemp -d)"
101
+ mkdir -p "$SR/multi-agent-root"
102
+ touch "$SR/multi-agent-root/multi-agent-inner.json"
103
+ GC_TMP_ROOT="$SR/multi-agent-root" GC_TMP_GRACE_MIN=0 bash "$GC" --yes >/dev/null 2>&1
104
+ [ -d "$SR/multi-agent-root" ] && pass "matching-named root survives --yes" || fail "root dir itself was deleted"
105
+ [ ! -e "$SR/multi-agent-root/multi-agent-inner.json" ] && pass "inner scratch still removed" || fail "inner scratch survived"
106
+ rm -rf "$SR"
107
+
108
+ echo ""
109
+ echo "→ 8. symlinked scratch is unlinked without following it"
110
+ seed
111
+ VICTIM="$(mktemp -d)"
112
+ echo keep >"$VICTIM/keep.txt"
113
+ ln -s "$VICTIM" "$SB/multi-agent-linked"
114
+ GC_TMP_ROOT="$SB" GC_TMP_GRACE_MIN=0 bash "$GC" --yes >/dev/null 2>&1
115
+ [ ! -e "$SB/multi-agent-linked" ] && pass "symlink itself removed" || fail "symlink survived"
116
+ [ -f "$VICTIM/keep.txt" ] && pass "symlink target untouched" || fail "symlink target was deleted"
117
+ rm -rf "$VICTIM"
118
+
119
+ echo ""
120
+ echo "→ 9. default grace spares fresh scratch"
121
+ seed
122
+ out=$(GC_TMP_ROOT="$SB" bash "$GC" --yes 2>&1)
123
+ rc=$?
124
+ [ "$rc" = "0" ] && pass "grace run exits 0" || fail "grace run exited $rc"
125
+ [ -f "$SB/multi-agent-picker-PROJ-1.json" ] && pass "fresh scratch spared by default grace" || fail "default grace deleted fresh scratch"
126
+ echo "$out" | grep -qi "spared" && pass "output mentions spared fresh items" || fail "no spared note in output"
127
+
82
128
  echo ""
83
129
  echo "══ gc-tmp smoke: $PASS passed, $FAIL failed ══"
84
130
  [ "$FAIL" -eq 0 ]