@mmerterden/multi-agent-pipeline 11.5.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/docs/adr/0007-multi-tool-adapter-framework.md +2 -2
  3. package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +2 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/features.md +2 -2
  6. package/install/claude.mjs +51 -1
  7. package/install/index.mjs +34 -1
  8. package/package.json +2 -2
  9. package/pipeline/commands/multi-agent/SKILL.md +1 -0
  10. package/pipeline/commands/multi-agent/analysis/SKILL.md +1 -0
  11. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +1 -0
  12. package/pipeline/commands/multi-agent/autopilot/SKILL.md +1 -0
  13. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +1 -0
  14. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -0
  15. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -0
  16. package/pipeline/commands/multi-agent/dev/SKILL.md +1 -0
  17. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -0
  18. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -0
  19. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -0
  20. package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -0
  21. package/pipeline/commands/multi-agent/finish/SKILL.md +1 -0
  22. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +29 -3
  23. package/pipeline/commands/multi-agent/help/SKILL.md +3 -2
  24. package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
  25. package/pipeline/commands/multi-agent/jira/SKILL.md +1 -0
  26. package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
  27. package/pipeline/commands/multi-agent/language/SKILL.md +20 -1
  28. package/pipeline/commands/multi-agent/local/SKILL.md +1 -0
  29. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +1 -0
  30. package/pipeline/commands/multi-agent/log/SKILL.md +1 -0
  31. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -0
  32. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
  33. package/pipeline/commands/multi-agent/purge/SKILL.md +27 -24
  34. package/pipeline/commands/multi-agent/refactor/SKILL.md +1 -0
  35. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
  36. package/pipeline/commands/multi-agent/review/SKILL.md +1 -0
  37. package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
  38. package/pipeline/commands/multi-agent/review-jira/SKILL.md +1 -0
  39. package/pipeline/commands/multi-agent/scan/SKILL.md +1 -0
  40. package/pipeline/commands/multi-agent/search/SKILL.md +1 -0
  41. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -0
  42. package/pipeline/commands/multi-agent/stack/SKILL.md +1 -0
  43. package/pipeline/commands/multi-agent/status/SKILL.md +1 -0
  44. package/pipeline/commands/multi-agent/sync/SKILL.md +20 -2
  45. package/pipeline/commands/multi-agent/test/SKILL.md +1 -0
  46. package/pipeline/commands/multi-agent/uninstall/SKILL.md +87 -0
  47. package/pipeline/commands/multi-agent/update/SKILL.md +1 -0
  48. package/pipeline/lib/multi-repo-pipeline.sh +6 -1
  49. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -5
  50. package/pipeline/multi-agent-refs/phases/phase-0-init.md +12 -1
  51. package/pipeline/scripts/_cost.mjs +35 -0
  52. package/pipeline/scripts/agent-guard.py +8 -3
  53. package/pipeline/scripts/cost-budget-check.mjs +2 -1
  54. package/pipeline/scripts/cost-lib.sh +43 -0
  55. package/pipeline/scripts/fixtures/install-layout.tsv +4 -2
  56. package/pipeline/scripts/gc-tmp.sh +67 -8
  57. package/pipeline/scripts/gc-worktrees.sh +140 -0
  58. package/pipeline/scripts/localize-commands.mjs +130 -0
  59. package/pipeline/scripts/phase-tracker.sh +10 -19
  60. package/pipeline/scripts/prune-logs.sh +100 -15
  61. package/pipeline/scripts/purge.sh +235 -0
  62. package/pipeline/scripts/render-agent-log-cost.sh +7 -13
  63. package/pipeline/scripts/render-cost-summary.sh +4 -7
  64. package/pipeline/scripts/run-aggregator.mjs +2 -3
  65. package/pipeline/scripts/smoke-description-tr.sh +82 -0
  66. package/pipeline/scripts/smoke-gc-tmp.sh +50 -4
  67. package/pipeline/scripts/smoke-gc-worktrees.sh +125 -0
  68. package/pipeline/scripts/smoke-prune-logs.sh +57 -7
  69. package/pipeline/scripts/smoke-purge.sh +138 -0
  70. package/pipeline/scripts/smoke-source-parity.sh +85 -0
  71. package/pipeline/scripts/smoke-update-check.sh +13 -0
  72. package/pipeline/scripts/uninstall.mjs +41 -6
  73. package/pipeline/scripts/update-check.sh +9 -0
  74. package/pipeline/skills/.skill-manifest.json +7 -7
  75. package/pipeline/skills/.skills-index.json +9 -9
  76. package/pipeline/skills/shared/README.md +19 -10
  77. package/pipeline/skills/shared/core/multi-agent/SKILL.md +1 -0
  78. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
  79. package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +74 -0
  80. package/pipeline/skills/skills-index.md +1 -1
  81. package/pipeline/commands/multi-agent/delete/SKILL.md +0 -67
  82. package/pipeline/skills/shared/core/multi-agent-delete/SKILL.md +0 -66
@@ -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
@@ -32,6 +32,8 @@ done
32
32
 
33
33
  ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
34
34
  COST_TABLE="$ROOT/pipeline/scripts/cost-table.json"
35
+ COST_LIB="$ROOT/pipeline/scripts/cost-lib.sh"
36
+ if [ -f "$COST_LIB" ]; then . "$COST_LIB"; else COST_JQ_DEFS=""; fi
35
37
 
36
38
  command -v jq >/dev/null 2>&1 || { echo "render-agent-log-cost: jq required" >&2; exit 2; }
37
39
 
@@ -94,27 +96,19 @@ if [ "$rows_json" = "[]" ] && [ -n "$SPANS_FILE" ] && [ -f "$SPANS_FILE" ]; then
94
96
  ' "$SPANS_FILE")
95
97
  fi
96
98
 
97
- joined=$(jq -n --argjson rows "$rows_json" --slurpfile prices "$COST_TABLE" '
99
+ joined=$(jq -n --argjson rows "$rows_json" --slurpfile prices "$COST_TABLE" "$COST_JQ_DEFS"'
98
100
  $rows | map(
99
101
  . as $r |
100
102
  ($prices[0].prices[$r.model] // null) as $p |
101
103
  # tokens_in is freshly-billed input (cache-exclusive, matching the host
102
104
  # input_tokens field); tokens_cached is the separate prompt-cache-read
103
- # count, billed at the discounted cacheReadPerMtok rate (default to
104
- # inPerMtok when the field is absent so an old price table still prices it).
105
- # The two counts are disjoint - never subtract one from the other - so this
106
- # renderer and aggregate-metrics.mjs agree on what "total input" means.
107
- $r.tokens_in as $fresh |
105
+ # count, billed at the discounted cacheReadPerMtok rate. The two counts are
106
+ # disjoint - never subtract one from the other - so this renderer and
107
+ # aggregate-metrics.mjs agree on what "total input" means.
108
108
  ($r.tokens_cached // 0) as $cached |
109
109
  . + {
110
110
  tokens_cached: $cached,
111
- usd: (
112
- if $p == null then null
113
- else ( ($fresh / 1000000) * $p.inPerMtok
114
- + ($cached / 1000000) * ($p.cacheReadPerMtok // $p.inPerMtok)
115
- + ($r.tokens_out / 1000000) * $p.outPerMtok )
116
- end
117
- )
111
+ usd: cost_usd_of($p; $r.tokens_in; $r.tokens_out; $cached)
118
112
  }
119
113
  )
120
114
  ')
@@ -26,6 +26,8 @@ done
26
26
 
27
27
  ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
28
28
  COST_TABLE="$ROOT/pipeline/scripts/cost-table.json"
29
+ COST_LIB="$ROOT/pipeline/scripts/cost-lib.sh"
30
+ if [ -f "$COST_LIB" ]; then . "$COST_LIB"; else COST_JQ_DEFS=""; fi
29
31
 
30
32
  command -v jq >/dev/null 2>&1 || { echo "render-cost-summary: jq required" >&2; exit 2; }
31
33
 
@@ -78,17 +80,12 @@ if [ "$rows_json" = "[]" ] && [ -n "$SPANS_FILE" ] && [ -f "$SPANS_FILE" ]; then
78
80
  fi
79
81
 
80
82
  # Compute USD per row using cost-table
81
- joined=$(jq -n --argjson rows "$rows_json" --slurpfile prices "$COST_TABLE" '
83
+ joined=$(jq -n --argjson rows "$rows_json" --slurpfile prices "$COST_TABLE" "$COST_JQ_DEFS"'
82
84
  $rows | map(
83
85
  . as $r |
84
86
  ($prices[0].prices[$r.model] // null) as $p |
85
87
  . + {
86
- usd: (
87
- if $p == null then null
88
- else ( ($r.tokens_in / 1000000) * $p.inPerMtok
89
- + ($r.tokens_out / 1000000) * $p.outPerMtok )
90
- end
91
- )
88
+ usd: cost_usd_of($p; $r.tokens_in; $r.tokens_out; 0)
92
89
  }
93
90
  )
94
91
  ')
@@ -37,6 +37,7 @@
37
37
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
38
38
  import { join, dirname } from "node:path";
39
39
  import { fileURLToPath } from "node:url";
40
+ import { costUsd } from "./_cost.mjs";
40
41
 
41
42
  const __dirname = dirname(fileURLToPath(import.meta.url));
42
43
 
@@ -135,9 +136,7 @@ function computeCost(modelKey, tokensIn, tokensOut, costTable) {
135
136
  if (!price || (typeof price.inPerMtok !== "number" && typeof price.outPerMtok !== "number")) {
136
137
  return { cost_usd: 0, cost_unknown_model: true };
137
138
  }
138
- const usd =
139
- ((tokensIn || 0) / 1_000_000) * (price.inPerMtok || 0) +
140
- ((tokensOut || 0) / 1_000_000) * (price.outPerMtok || 0);
139
+ const usd = costUsd(price, tokensIn, tokensOut);
141
140
  return { cost_usd: Number(usd.toFixed(6)) };
142
141
  }
143
142
 
@@ -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 ]
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-gc-worktrees.sh - contract of the worktree residue sweeper.
3
+ #
4
+ # Verifies gc-worktrees.sh: dry-run-by-default, orphan-dir removal only with
5
+ # --yes, registered worktrees untouched, gitlink residue unstaged, exclude
6
+ # guard added idempotently, --older-than sparing, non-repo no-op, and flag
7
+ # validation.
8
+
9
+ set -uo pipefail
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
+ SCRIPT="$SCRIPT_DIR/gc-worktrees.sh"
13
+
14
+ PASS=0
15
+ FAIL=0
16
+ record_pass() { PASS=$((PASS + 1)); printf ' \342\234\223 %s\n' "$1"; }
17
+ record_fail() { FAIL=$((FAIL + 1)); printf ' \342\234\227 %s\n' "$1"; }
18
+
19
+ printf '\342\206\222 smoke-gc-worktrees: worktree residue sweep contract\n'
20
+
21
+ tmpdir=$(mktemp -d)
22
+ trap 'rm -rf "$tmpdir"' EXIT
23
+
24
+ # Fixture repo: one registered worktree, one orphan dir, one gitlink residue.
25
+ REPO="$tmpdir/repo"
26
+ mkdir -p "$REPO"
27
+ git -C "$REPO" init -q -b main
28
+ git -C "$REPO" config user.email smoke@test
29
+ git -C "$REPO" config user.name smoke
30
+ echo hi > "$REPO/f.txt"
31
+ git -C "$REPO" add f.txt
32
+ git -C "$REPO" commit -qm init
33
+ git -C "$REPO" worktree add -q "$REPO/.worktrees/REG" -b reg-branch
34
+ mkdir -p "$REPO/.worktrees/ORPHAN"
35
+ echo x > "$REPO/.worktrees/ORPHAN/file"
36
+ git -C "$REPO" update-index --add --cacheinfo "160000,$(git -C "$REPO" rev-parse HEAD),.worktrees/GONE"
37
+
38
+ # 1. Script parses
39
+ if bash -n "$SCRIPT" 2>/dev/null; then
40
+ record_pass "gc-worktrees.sh parses (bash -n)"
41
+ else
42
+ record_fail "gc-worktrees.sh has syntax errors"
43
+ fi
44
+
45
+ # 2. Dry-run lists all three findings and deletes nothing
46
+ out=$(bash "$SCRIPT" --repo "$REPO" 2>&1); rc=$?
47
+ if [ "$rc" -eq 0 ] \
48
+ && printf '%s' "$out" | grep -q "would remove orphan worktree dir: .worktrees/ORPHAN" \
49
+ && printf '%s' "$out" | grep -q "would unstage gitlink residue from the index: .worktrees/GONE" \
50
+ && printf '%s' "$out" | grep -q "would add .worktrees/ to .git/info/exclude"; then
51
+ record_pass "dry-run lists orphan dir + gitlink + missing guard"
52
+ else
53
+ record_fail "dry-run should list all three findings (rc=$rc): $out"
54
+ fi
55
+ if [ -d "$REPO/.worktrees/ORPHAN" ] && git -C "$REPO" ls-files -s -- .worktrees | grep -q 160000; then
56
+ record_pass "dry-run changed nothing"
57
+ else
58
+ record_fail "dry-run must not delete or unstage anything"
59
+ fi
60
+
61
+ # 3. --older-than spares the freshly-touched orphan
62
+ out=$(bash "$SCRIPT" --repo "$REPO" --older-than=60 2>&1)
63
+ if printf '%s' "$out" | grep -q "would remove orphan"; then
64
+ record_fail "--older-than=60 should spare the fresh orphan dir"
65
+ else
66
+ record_pass "--older-than spares recently-touched orphan dirs"
67
+ fi
68
+
69
+ # 4. --yes applies all three
70
+ out=$(bash "$SCRIPT" --repo "$REPO" --yes 2>&1); rc=$?
71
+ if [ "$rc" -eq 0 ] && [ ! -d "$REPO/.worktrees/ORPHAN" ]; then
72
+ record_pass "--yes removes the orphan dir"
73
+ else
74
+ record_fail "--yes should remove the orphan dir (rc=$rc): $out"
75
+ fi
76
+ if git -C "$REPO" ls-files -s -- .worktrees | grep -q 160000; then
77
+ record_fail "--yes should unstage the gitlink residue"
78
+ else
79
+ record_pass "--yes unstages the gitlink residue"
80
+ fi
81
+ if [ "$(grep -cxF '.worktrees/' "$REPO/.git/info/exclude" 2>/dev/null)" = "1" ]; then
82
+ record_pass "--yes adds the exclude guard exactly once"
83
+ else
84
+ record_fail "exclude guard missing or duplicated"
85
+ fi
86
+
87
+ # 5. Registered worktree untouched
88
+ if [ -d "$REPO/.worktrees/REG" ] && git -C "$REPO" worktree list | grep -q "REG"; then
89
+ record_pass "registered worktree left untouched"
90
+ else
91
+ record_fail "registered worktree must never be touched"
92
+ fi
93
+
94
+ # 6. Re-run -> nothing to do, guard not duplicated
95
+ out=$(bash "$SCRIPT" --repo "$REPO" 2>&1); rc=$?
96
+ if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q "nothing to do"; then
97
+ record_pass "clean repo -> nothing to do (exit 0)"
98
+ else
99
+ record_fail "clean repo should be a no-op (rc=$rc): $out"
100
+ fi
101
+ bash "$SCRIPT" --repo "$REPO" --yes >/dev/null 2>&1
102
+ if [ "$(grep -cxF '.worktrees/' "$REPO/.git/info/exclude")" = "1" ]; then
103
+ record_pass "exclude guard is idempotent across --yes reruns"
104
+ else
105
+ record_fail "exclude guard duplicated on rerun"
106
+ fi
107
+
108
+ # 7. Non-repo -> silent no-op exit 0
109
+ out=$(bash "$SCRIPT" --repo "$tmpdir" 2>&1); rc=$?
110
+ if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q "not a git work tree"; then
111
+ record_pass "non-repo -> exit 0 no-op"
112
+ else
113
+ record_fail "non-repo should exit 0 with a no-op message (rc=$rc)"
114
+ fi
115
+
116
+ # 8. Unknown flag -> usage error exit 2
117
+ bash "$SCRIPT" --definitely-not-a-flag >/dev/null 2>&1; rc=$?
118
+ if [ "$rc" -eq 2 ]; then
119
+ record_pass "unknown flag -> exit 2"
120
+ else
121
+ record_fail "unknown flag should exit 2 (got $rc)"
122
+ fi
123
+
124
+ printf '\n\342\225\220\342\225\220 smoke-gc-worktrees: %d passed, %d failed \342\225\220\342\225\220\n' "$PASS" "$FAIL"
125
+ [ "$FAIL" -eq 0 ]