@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
@@ -6,8 +6,15 @@
6
6
  # never cleaned up automatically (review.md traps its own, the rest linger), so
7
7
  # a long-lived machine accumulates them. This sweeps the known prefixes.
8
8
  #
9
- # SAFE BY DEFAULT: dry-run. It lists what WOULD be removed and the space it
10
- # would free, and deletes nothing until you pass --yes.
9
+ # SAFE BY DEFAULT:
10
+ # - dry-run: lists what WOULD be removed and the space it would free, and
11
+ # deletes nothing until you pass --yes.
12
+ # - root guard: the scratch root must be an existing directory and is
13
+ # refused when it resolves to /, $HOME, or a git work tree, so a stray
14
+ # GC_TMP_ROOT can never sweep a home dir or a repo checkout.
15
+ # - fresh-scratch grace: items touched in the last GC_TMP_GRACE_MIN minutes
16
+ # (default 10) are spared, so a sweep during an in-flight run cannot pull
17
+ # scratch out from under it. GC_TMP_GRACE_MIN=0 disables the grace.
11
18
  #
12
19
  # Usage:
13
20
  # gc-tmp.sh # dry-run: list matches + total size
@@ -16,13 +23,16 @@
16
23
  # gc-tmp.sh --older-than=60 --yes
17
24
  #
18
25
  # Env:
19
- # GC_TMP_ROOT override the scratch root (default /tmp; tests set this)
26
+ # GC_TMP_ROOT override the scratch root (default /tmp; tests set this)
27
+ # GC_TMP_GRACE_MIN fresh-scratch grace window in minutes (default 10, 0 = off)
20
28
  #
21
- # Exit: 0 on success, 2 on usage error. Always exits 0 when nothing matches.
29
+ # Exit: 0 on success, 2 on usage error or refused scratch root. Always exits 0
30
+ # when nothing matches.
22
31
 
23
32
  set -uo pipefail
24
33
 
25
34
  ROOT="${GC_TMP_ROOT:-/tmp}"
35
+ GRACE_MIN="${GC_TMP_GRACE_MIN:-10}"
26
36
  DELETE=0
27
37
  OLDER_MIN=0
28
38
 
@@ -51,9 +61,35 @@ for arg in "$@"; do
51
61
  esac
52
62
  done
53
63
 
64
+ if ! printf '%s' "$GRACE_MIN" | grep -qE '^[0-9]+$'; then
65
+ echo "gc-tmp: GC_TMP_GRACE_MIN needs a whole number of minutes, got: $GRACE_MIN" >&2
66
+ exit 2
67
+ fi
68
+
69
+ if [ ! -d "$ROOT" ]; then
70
+ echo "gc-tmp: no scratch root at $ROOT - nothing to do"
71
+ exit 0
72
+ fi
73
+
74
+ # Root guard: resolve symlinks (macOS /tmp -> /private/tmp), then refuse
75
+ # roots that can never be a scratch root. Sweeping $HOME by mistake would
76
+ # match real dirs like ~/multi-agent-pipeline via the name prefixes below.
77
+ ROOT="$(cd "$ROOT" 2>/dev/null && pwd -P)"
78
+ if [ -z "$ROOT" ]; then
79
+ echo "gc-tmp: cannot resolve scratch root" >&2
80
+ exit 2
81
+ fi
82
+ HOME_REAL="$(cd "$HOME" 2>/dev/null && pwd -P || printf '%s' "$HOME")"
83
+ if [ "$ROOT" = "/" ] || [ "$ROOT" = "$HOME_REAL" ] || [ -e "$ROOT/.git" ]; then
84
+ echo "gc-tmp: refusing scratch root $ROOT (/, \$HOME, or a git work tree is never a scratch root)" >&2
85
+ exit 2
86
+ fi
87
+
54
88
  # Scratch prefixes the pipeline writes under $ROOT (see jira.md, review.md,
55
89
  # update-issue-progress.sh, channels/*.md, external-context-injection.md,
56
- # analysis.md). maxdepth 1 so we never descend into unrelated trees.
90
+ # analysis.md). mindepth 1 so the root itself is never a candidate even when
91
+ # its own name matches a prefix; maxdepth 1 so we never descend into
92
+ # unrelated trees.
57
93
  NAME_ARGS=(
58
94
  -name 'multi-agent-*'
59
95
  -o -name 'issue-progress-*'
@@ -69,12 +105,24 @@ if [ "$OLDER_MIN" -gt 0 ]; then
69
105
  fi
70
106
 
71
107
  matches=()
108
+ skipped_active=0
72
109
  while IFS= read -r -d '' p; do
110
+ # Fresh-scratch grace: spare items an in-flight run touched recently
111
+ # (checks the item and, for dirs, anything inside it).
112
+ if [ "$GRACE_MIN" -gt 0 ] &&
113
+ find "$p" -mmin "-$GRACE_MIN" -print 2>/dev/null | head -1 | grep -q .; then
114
+ skipped_active=$((skipped_active + 1))
115
+ continue
116
+ fi
73
117
  matches+=("$p")
74
- done < <(find "$ROOT" -maxdepth 1 \( "${NAME_ARGS[@]}" \) ${MTIME_ARGS[@]+"${MTIME_ARGS[@]}"} -print0 2>/dev/null)
118
+ done < <(find "$ROOT" -mindepth 1 -maxdepth 1 \( "${NAME_ARGS[@]}" \) ${MTIME_ARGS[@]+"${MTIME_ARGS[@]}"} -print0 2>/dev/null)
75
119
 
76
120
  if [ "${#matches[@]}" -eq 0 ]; then
77
- echo "gc-tmp: no leftover scratch under $ROOT - nothing to do"
121
+ if [ "$skipped_active" -gt 0 ]; then
122
+ echo "gc-tmp: only fresh scratch matched (touched in the last ${GRACE_MIN}m) - spared, nothing to do"
123
+ else
124
+ echo "gc-tmp: no leftover scratch under $ROOT - nothing to do"
125
+ fi
78
126
  exit 0
79
127
  fi
80
128
 
@@ -90,13 +138,24 @@ human="${total_kb} KB"
90
138
  if [ "$DELETE" -eq 0 ]; then
91
139
  echo "DRY-RUN - would remove ${#matches[@]} item(s) under $ROOT (frees ~${human}):"
92
140
  for p in "${matches[@]}"; do echo " $p"; done
141
+ [ "$skipped_active" -gt 0 ] && echo "Spared $skipped_active fresh item(s) touched in the last ${GRACE_MIN}m."
93
142
  echo "Re-run with --yes to delete."
94
143
  exit 0
95
144
  fi
96
145
 
97
146
  removed=0
98
147
  for p in "${matches[@]}"; do
148
+ # Containment guard: only ever delete direct children of the resolved root.
149
+ case "$p" in
150
+ "$ROOT"/*) ;;
151
+ *)
152
+ echo "gc-tmp: skipping path outside scratch root: $p" >&2
153
+ continue
154
+ ;;
155
+ esac
99
156
  rm -rf "$p" 2>/dev/null && removed=$((removed + 1))
100
157
  done
101
- echo "══ garbage-collect: removed ${removed}/${#matches[@]} item(s), freed ~${human} ══"
158
+ spared_note=""
159
+ [ "$skipped_active" -gt 0 ] && spared_note=", spared $skipped_active fresh"
160
+ echo "══ garbage-collect: removed ${removed}/${#matches[@]} item(s)${spared_note}, freed ~${human} ══"
102
161
  exit 0
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bash
2
+ # gc-worktrees.sh - sweep worktree residue in the current project repo.
3
+ #
4
+ # Complements gc-tmp.sh (which only touches /tmp scratch). This handles the
5
+ # leftovers a worktree lifecycle can strand inside the repo itself:
6
+ #
7
+ # 1. stale worktree admin entries -> git worktree prune
8
+ # 2. orphan .worktrees/* dirs -> dirs no longer registered as
9
+ # worktrees (killed mid-run, manual deletes). Listed in dry-run,
10
+ # removed only with --yes.
11
+ # 3. gitlink residue in the index -> .worktrees/{id} recorded as a
12
+ # "Subproject commit" entry by a blanket `git add -A` before the
13
+ # residue guard existed. --yes runs `git rm --cached` (the commit
14
+ # itself stays yours to make).
15
+ # 4. missing residue guard -> ensures `.worktrees/` is in
16
+ # .git/info/exclude so the gitlink class cannot re-occur.
17
+ #
18
+ # Registered, healthy worktrees are NEVER touched - finishing or killing a
19
+ # task is /multi-agent:finish / /multi-agent:kill territory.
20
+ #
21
+ # SAFE BY DEFAULT: dry-run. Deletes nothing until you pass --yes.
22
+ #
23
+ # Usage:
24
+ # gc-worktrees.sh # dry-run in the current repo
25
+ # gc-worktrees.sh --yes # apply
26
+ # gc-worktrees.sh --repo <path> # operate on another repo
27
+ # gc-worktrees.sh --older-than=60 # only orphan dirs idle > 60 min
28
+ #
29
+ # Exit: 0 on success (including "nothing to do"), 2 on usage error.
30
+
31
+ set -uo pipefail
32
+
33
+ REPO="$PWD"
34
+ DELETE=0
35
+ OLDER_MIN=0
36
+
37
+ usage() {
38
+ grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
39
+ }
40
+
41
+ while [ $# -gt 0 ]; do
42
+ case "$1" in
43
+ --yes | --force) DELETE=1; shift ;;
44
+ --repo)
45
+ REPO="${2:-}"
46
+ [ -z "$REPO" ] && { echo "gc-worktrees: --repo needs a path" >&2; exit 2; }
47
+ shift 2
48
+ ;;
49
+ --older-than=*)
50
+ OLDER_MIN="${1#*=}"
51
+ if ! printf '%s' "$OLDER_MIN" | grep -qE '^[0-9]+$'; then
52
+ echo "gc-worktrees: --older-than needs a whole number of minutes, got: $OLDER_MIN" >&2
53
+ exit 2
54
+ fi
55
+ shift
56
+ ;;
57
+ -h | --help) usage; exit 0 ;;
58
+ *) echo "gc-worktrees: unknown argument: $1" >&2; exit 2 ;;
59
+ esac
60
+ done
61
+
62
+ if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
63
+ echo "gc-worktrees: $REPO is not a git work tree - nothing to do"
64
+ exit 0
65
+ fi
66
+ REPO="$(cd "$REPO" && pwd -P)"
67
+ WT_ROOT="$REPO/.worktrees"
68
+
69
+ changed=0
70
+
71
+ # 1. Stale admin entries (safe on a clean repo, no confirmation needed:
72
+ # prune only drops bookkeeping for dirs that are already gone).
73
+ pruned=$(git -C "$REPO" worktree prune -v 2>&1 | grep -c . || true)
74
+ [ "$pruned" -gt 0 ] && echo "→ pruned $pruned stale worktree admin entr$( [ "$pruned" -eq 1 ] && echo y || echo ies)"
75
+
76
+ # 2. Orphan dirs: present under .worktrees/ but not registered as worktrees.
77
+ orphans=()
78
+ if [ -d "$WT_ROOT" ]; then
79
+ registered=$(git -C "$REPO" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
80
+ for d in "$WT_ROOT"/*/; do
81
+ [ -d "$d" ] || continue
82
+ dir="$(cd "$d" && pwd -P)"
83
+ # Path guard: only ever consider dirs strictly inside <repo>/.worktrees/.
84
+ case "$dir" in "$WT_ROOT"/*) ;; *) continue ;; esac
85
+ if printf '%s\n' "$registered" | grep -qxF "$dir"; then
86
+ continue # healthy registered worktree - kill/finish territory
87
+ fi
88
+ if [ "$OLDER_MIN" -gt 0 ] && find "$dir" -type f -mmin "-$OLDER_MIN" 2>/dev/null | head -1 | grep -q .; then
89
+ continue # recently touched - spare it
90
+ fi
91
+ orphans+=("$dir")
92
+ done
93
+ fi
94
+ for dir in ${orphans+"${orphans[@]}"}; do
95
+ size=$(du -sh "$dir" 2>/dev/null | cut -f1)
96
+ if [ "$DELETE" -eq 1 ]; then
97
+ rm -rf "$dir" && { echo "→ removed orphan worktree dir: ${dir#"$REPO"/} (${size:-?})"; changed=$((changed + 1)); }
98
+ else
99
+ echo "would remove orphan worktree dir: ${dir#"$REPO"/} (${size:-?})"
100
+ fi
101
+ done
102
+
103
+ # 3. Gitlink residue in the index ("Subproject commit" entries under
104
+ # .worktrees/ from a pre-guard `git add -A`).
105
+ gitlinks=$(git -C "$REPO" ls-files -s -- .worktrees 2>/dev/null | awk '$1 == "160000" { print $4 }')
106
+ if [ -n "$gitlinks" ]; then
107
+ while IFS= read -r path; do
108
+ [ -z "$path" ] && continue
109
+ if [ "$DELETE" -eq 1 ]; then
110
+ git -C "$REPO" rm --cached --ignore-unmatch -q -- "$path" \
111
+ && { echo "→ unstaged gitlink residue: $path (commit the removal yourself)"; changed=$((changed + 1)); }
112
+ else
113
+ echo "would unstage gitlink residue from the index: $path"
114
+ fi
115
+ done <<EOF
116
+ $gitlinks
117
+ EOF
118
+ fi
119
+
120
+ # 4. Residue guard: keep .worktrees/ out of the index from now on.
121
+ ex="$(git -C "$REPO" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)/info/exclude"
122
+ if [ -n "${ex%/info/exclude}" ] && ! grep -qxF '.worktrees/' "$ex" 2>/dev/null; then
123
+ if [ "$DELETE" -eq 1 ]; then
124
+ mkdir -p "$(dirname "$ex")" \
125
+ && printf '.worktrees/\n' >> "$ex" \
126
+ && { echo "→ added .worktrees/ to .git/info/exclude (residue guard)"; changed=$((changed + 1)); }
127
+ else
128
+ echo "would add .worktrees/ to .git/info/exclude (residue guard)"
129
+ fi
130
+ fi
131
+
132
+ total=$(( ${#orphans[@]} + $(printf '%s' "$gitlinks" | grep -c . || true) ))
133
+ if [ "$DELETE" -eq 1 ]; then
134
+ echo "══ gc-worktrees: applied $changed change(s) in $REPO ══"
135
+ elif [ "$total" -eq 0 ] && [ "$pruned" -eq 0 ] && { [ -z "${ex%/info/exclude}" ] || grep -qxF '.worktrees/' "$ex" 2>/dev/null; }; then
136
+ echo "gc-worktrees: no worktree residue in $REPO - nothing to do"
137
+ else
138
+ echo "══ gc-worktrees: dry-run - re-run with --yes to apply ══"
139
+ fi
140
+ exit 0
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * localize-commands.mjs - swap slash-command picker descriptions between
4
+ * English and the outputLanguage variant in an INSTALLED command tree.
5
+ *
6
+ * The repo (and the npm package) always keeps English `description:` values;
7
+ * files that ship a `description-tr:` frontmatter line can be localized in
8
+ * place after install. The original English text is preserved in a
9
+ * `description-en:` sidecar line so the swap is fully reversible.
10
+ *
11
+ * apply description-en <- description, description <- description-tr
12
+ * restore description <- description-en, sidecar line removed
13
+ *
14
+ * Both modes are idempotent and only touch the three description* lines of
15
+ * the YAML frontmatter; every other byte is preserved. Files without a
16
+ * description-tr line are left untouched.
17
+ *
18
+ * Usage:
19
+ * node localize-commands.mjs apply [--dir <commandsDir>]
20
+ * node localize-commands.mjs restore [--dir <commandsDir>]
21
+ *
22
+ * Default dir: $HOME/.claude/commands/multi-agent
23
+ * Exit: 0 on success (including missing dir), 2 on usage error.
24
+ */
25
+
26
+ import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from "fs";
27
+ import { join } from "path";
28
+
29
+ const DESC = /^description:[ \t]*(.*)$/;
30
+ const DESC_TR = /^description-tr:[ \t]*(.*)$/;
31
+ const DESC_EN = /^description-en:[ \t]*(.*)$/;
32
+
33
+ /**
34
+ * Swap the description of one SKILL.md. Returns true when the file changed.
35
+ *
36
+ * @param {string} file
37
+ * @param {"apply"|"restore"} mode
38
+ */
39
+ export function localizeFile(file, mode) {
40
+ const original = readFileSync(file, "utf-8");
41
+ const lines = original.split("\n");
42
+ // Only operate inside the first frontmatter block.
43
+ if (lines[0] !== "---") return false;
44
+ const end = lines.indexOf("---", 1);
45
+ if (end < 0) return false;
46
+
47
+ let descIdx = -1;
48
+ let trIdx = -1;
49
+ let enIdx = -1;
50
+ for (let i = 1; i < end; i++) {
51
+ if (descIdx < 0 && DESC.test(lines[i])) descIdx = i;
52
+ else if (trIdx < 0 && DESC_TR.test(lines[i])) trIdx = i;
53
+ else if (enIdx < 0 && DESC_EN.test(lines[i])) enIdx = i;
54
+ }
55
+ if (descIdx < 0) return false;
56
+
57
+ if (mode === "apply") {
58
+ if (trIdx < 0) return false;
59
+ const trValue = lines[trIdx].match(DESC_TR)[1];
60
+ const current = lines[descIdx].match(DESC)[1];
61
+ if (current === trValue) return false; // already applied
62
+ if (enIdx < 0) {
63
+ // Preserve the English original right after the description line.
64
+ lines.splice(descIdx + 1, 0, `description-en: ${current}`);
65
+ }
66
+ lines[descIdx] = `description: ${trValue}`;
67
+ } else {
68
+ if (enIdx < 0) return false;
69
+ const enValue = lines[enIdx].match(DESC_EN)[1];
70
+ lines[descIdx] = `description: ${enValue}`;
71
+ lines.splice(enIdx, 1);
72
+ }
73
+ const next = lines.join("\n");
74
+ if (next === original) return false;
75
+ writeFileSync(file, next);
76
+ return true;
77
+ }
78
+
79
+ /**
80
+ * Localize every SKILL.md directly in `dir` and one level below it.
81
+ *
82
+ * @param {string} dir
83
+ * @param {"apply"|"restore"} mode
84
+ * @returns {number} count of changed files
85
+ */
86
+ export function localizeCommands(dir, mode) {
87
+ if (!existsSync(dir)) return 0;
88
+ const files = [];
89
+ const rootSkill = join(dir, "SKILL.md");
90
+ if (existsSync(rootSkill)) files.push(rootSkill);
91
+ for (const entry of readdirSync(dir)) {
92
+ const sub = join(dir, entry, "SKILL.md");
93
+ try {
94
+ if (statSync(join(dir, entry)).isDirectory() && existsSync(sub)) files.push(sub);
95
+ } catch {
96
+ // unreadable entry - skip
97
+ }
98
+ }
99
+ let changed = 0;
100
+ for (const f of files) {
101
+ try {
102
+ if (localizeFile(f, mode)) changed++;
103
+ } catch {
104
+ // never fail the whole run over one file
105
+ }
106
+ }
107
+ return changed;
108
+ }
109
+
110
+ const invokedDirectly =
111
+ process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop());
112
+ if (invokedDirectly) {
113
+ const args = process.argv.slice(2);
114
+ const mode = args[0];
115
+ if (mode !== "apply" && mode !== "restore") {
116
+ console.error("usage: localize-commands.mjs <apply|restore> [--dir <commandsDir>]");
117
+ process.exit(2);
118
+ }
119
+ let dir = join(process.env.HOME || process.env.USERPROFILE || "", ".claude", "commands", "multi-agent");
120
+ const dirFlag = args.indexOf("--dir");
121
+ if (dirFlag >= 0) {
122
+ if (!args[dirFlag + 1]) {
123
+ console.error("localize-commands: --dir needs a path");
124
+ process.exit(2);
125
+ }
126
+ dir = args[dirFlag + 1];
127
+ }
128
+ const changed = localizeCommands(dir, mode);
129
+ console.log(`localize-commands: ${mode} -> ${changed} file(s) changed in ${dir}`);
130
+ }
@@ -245,12 +245,14 @@ format_elapsed() {
245
245
  }
246
246
 
247
247
  # Cost table ships next to this script in every install tree (repo,
248
- # ~/.claude/scripts, ~/.copilot/scripts). Pricing math matches
249
- # render-agent-log-cost.sh exactly: tokens_in is FRESH input (cache-exclusive,
248
+ # ~/.claude/scripts, ~/.copilot/scripts). Pricing math comes from cost-lib.sh
249
+ # (shared with the other renderers): tokens_in is FRESH input (cache-exclusive,
250
250
  # per the disjoint token-count contract), tokens_cached is priced at the
251
251
  # discounted cacheReadPerMtok rate, floored to cents. A missing/unparseable
252
252
  # table never fails a caller - USD simply renders empty / "-".
253
253
  COST_TABLE="$(cd "$(dirname "$0")" && pwd)/cost-table.json"
254
+ COST_LIB="$(cd "$(dirname "$0")" && pwd)/cost-lib.sh"
255
+ if [ -f "$COST_LIB" ]; then . "$COST_LIB"; else COST_JQ_DEFS=""; fi
254
256
 
255
257
  # Print est. USD ("0.21") for one phase index or, with "total", the sum across
256
258
  # priceable phases. Prints "-" when nothing is priceable. Never non-zero exit.
@@ -258,19 +260,13 @@ phase_usd() {
258
260
  local selector="$1" # integer phase index into .phases[], or "total"
259
261
  [ -f "$COST_TABLE" ] || { echo "-"; return 0; }
260
262
  local state; state="$(load_state)"
261
- echo "$state" | jq -r --slurpfile prices "$COST_TABLE" --arg sel "$selector" '
262
- def usd_of(p): ($prices[0].prices[p.model // ""] // null) as $rate |
263
- if $rate == null then null
264
- else ( ((p.tokens_in // 0) / 1000000) * $rate.inPerMtok
265
- + ((p.tokens_cached // 0) / 1000000) * ($rate.cacheReadPerMtok // $rate.inPerMtok)
266
- + ((p.tokens_out // 0) / 1000000) * $rate.outPerMtok )
267
- end;
268
- def floor_cents(u): if u == null then null else ((u * 100) | floor) / 100 end;
263
+ echo "$state" | jq -r --slurpfile prices "$COST_TABLE" --arg sel "$selector" "$COST_JQ_DEFS"'
264
+ def usd_of(p): cost_usd_of($prices[0].prices[p.model // ""] // null; (p.tokens_in // 0); (p.tokens_out // 0); (p.tokens_cached // 0));
269
265
  if $sel == "total" then
270
- ([.phases[] | usd_of(.) | select(. != null)] | if length == 0 then "-" else (add | floor_cents(.) | tostring) end)
266
+ ([.phases[] | usd_of(.) | select(. != null)] | if length == 0 then "-" else (add | cost_floor_cents(.) | tostring) end)
271
267
  else
272
268
  (.phases[($sel | tonumber)] // null) as $p |
273
- if $p == null then "-" else (usd_of($p) | floor_cents(.) | if . == null then "-" else tostring end) end
269
+ if $p == null then "-" else (usd_of($p) | cost_floor_cents(.) | if . == null then "-" else tostring end) end
274
270
  end
275
271
  ' 2>/dev/null || echo "-"
276
272
  }
@@ -372,13 +368,8 @@ render() {
372
368
  echo "$prices_json" | jq empty 2>/dev/null || prices_json='{"prices":{}}'
373
369
  fi
374
370
  local phase_data
375
- phase_data=$(echo "$state" | jq -r --argjson prices "$prices_json" '
376
- def usd_of(p): ($prices.prices[p.model // ""] // null) as $rate |
377
- if $rate == null then null
378
- else ( ((p.tokens_in // 0) / 1000000) * $rate.inPerMtok
379
- + ((p.tokens_cached // 0) / 1000000) * ($rate.cacheReadPerMtok // $rate.inPerMtok)
380
- + ((p.tokens_out // 0) / 1000000) * $rate.outPerMtok )
381
- end;
371
+ phase_data=$(echo "$state" | jq -r --argjson prices "$prices_json" "$COST_JQ_DEFS"'
372
+ def usd_of(p): cost_usd_of($prices.prices[p.model // ""] // null; (p.tokens_in // 0); (p.tokens_out // 0); (p.tokens_cached // 0));
382
373
  .phases // []
383
374
  | sort_by(.id | (tonumber? // 9999))
384
375
  | .[] | [
@@ -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