@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.
- package/CHANGELOG.md +75 -0
- package/docs/adr/0007-multi-tool-adapter-framework.md +2 -2
- package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +2 -0
- package/docs/adr/README.md +1 -0
- package/docs/features.md +2 -2
- package/install/claude.mjs +51 -1
- package/install/index.mjs +34 -1
- package/package.json +2 -2
- package/pipeline/commands/multi-agent/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/build-optimize/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/channels/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/dev/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/finish/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +29 -3
- package/pipeline/commands/multi-agent/help/SKILL.md +3 -2
- package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/jira/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/language/SKILL.md +20 -1
- package/pipeline/commands/multi-agent/local/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/log/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/purge/SKILL.md +27 -24
- package/pipeline/commands/multi-agent/refactor/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/review-jira/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/scan/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/search/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/stack/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/status/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +20 -2
- package/pipeline/commands/multi-agent/test/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/uninstall/SKILL.md +87 -0
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -0
- package/pipeline/lib/multi-repo-pipeline.sh +6 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +12 -1
- package/pipeline/scripts/_cost.mjs +35 -0
- package/pipeline/scripts/agent-guard.py +8 -3
- package/pipeline/scripts/cost-budget-check.mjs +2 -1
- package/pipeline/scripts/cost-lib.sh +43 -0
- package/pipeline/scripts/fixtures/install-layout.tsv +4 -2
- package/pipeline/scripts/gc-tmp.sh +67 -8
- package/pipeline/scripts/gc-worktrees.sh +140 -0
- package/pipeline/scripts/localize-commands.mjs +130 -0
- package/pipeline/scripts/phase-tracker.sh +10 -19
- package/pipeline/scripts/prune-logs.sh +100 -15
- package/pipeline/scripts/purge.sh +235 -0
- package/pipeline/scripts/render-agent-log-cost.sh +7 -13
- package/pipeline/scripts/render-cost-summary.sh +4 -7
- package/pipeline/scripts/run-aggregator.mjs +2 -3
- package/pipeline/scripts/smoke-description-tr.sh +82 -0
- package/pipeline/scripts/smoke-gc-tmp.sh +50 -4
- package/pipeline/scripts/smoke-gc-worktrees.sh +125 -0
- package/pipeline/scripts/smoke-prune-logs.sh +57 -7
- package/pipeline/scripts/smoke-purge.sh +138 -0
- package/pipeline/scripts/smoke-source-parity.sh +85 -0
- package/pipeline/scripts/smoke-update-check.sh +13 -0
- package/pipeline/scripts/uninstall.mjs +41 -6
- package/pipeline/scripts/update-check.sh +9 -0
- package/pipeline/skills/.skill-manifest.json +7 -7
- package/pipeline/skills/.skills-index.json +9 -9
- package/pipeline/skills/shared/README.md +19 -10
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +1 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +74 -0
- package/pipeline/skills/skills-index.md +1 -1
- package/pipeline/commands/multi-agent/delete/SKILL.md +0 -67
- package/pipeline/skills/shared/core/multi-agent-delete/SKILL.md +0 -66
|
@@ -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:
|
|
10
|
-
#
|
|
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
|
|
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
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
249
|
-
#
|
|
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)
|
|
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 |
|
|
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) |
|
|
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)
|
|
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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
10
|
+
# PRESERVED ALWAYS: the audit trail (audit.jsonl + rotations), the metrics
|
|
11
|
+
# corpus (metrics.jsonl, metrics-summary.md), and .counter files (per-project
|
|
12
|
+
# task numbering). They live outside task dirs, and as a second line of
|
|
13
|
+
# defense any directory carrying one of those reserved names is skipped even
|
|
14
|
+
# if it looks like a task dir.
|
|
14
15
|
#
|
|
15
|
-
# SAFE BY DEFAULT:
|
|
16
|
-
#
|
|
16
|
+
# SAFE BY DEFAULT:
|
|
17
|
+
# - dry-run: lists what WOULD be removed + space freed; deletes nothing
|
|
18
|
+
# until you pass --yes.
|
|
19
|
+
# - root guard: the log root must be an existing directory and is refused
|
|
20
|
+
# when it resolves to /, $HOME, or a git work tree (a repo checkout is
|
|
21
|
+
# never a log root).
|
|
22
|
+
# - active-task grace: task dirs with files modified in the last
|
|
23
|
+
# PRUNE_GRACE_MIN minutes (default 10) are spared, so pruning while a
|
|
24
|
+
# run is still writing its log dir cannot break resume. An explicit
|
|
25
|
+
# --task=ID bypasses the grace; PRUNE_GRACE_MIN=0 disables it.
|
|
17
26
|
#
|
|
18
27
|
# Usage:
|
|
19
|
-
# prune-logs.sh # dry-run: every task dir
|
|
20
|
-
# prune-logs.sh --yes # delete every task dir
|
|
28
|
+
# prune-logs.sh # dry-run: every idle task dir
|
|
29
|
+
# prune-logs.sh --yes # delete every idle task dir
|
|
21
30
|
# prune-logs.sh --older-than=30 # only task dirs older than 30 days
|
|
22
31
|
# prune-logs.sh --project=my-ios-app # only one project's task dirs
|
|
23
32
|
# prune-logs.sh --task=PROJ-123 # only one task dir
|
|
@@ -25,12 +34,14 @@
|
|
|
25
34
|
#
|
|
26
35
|
# Env:
|
|
27
36
|
# PRUNE_LOG_ROOT override the log root (default $HOME/.claude/logs/multi-agent)
|
|
37
|
+
# PRUNE_GRACE_MIN active-task grace window in minutes (default 10, 0 = off)
|
|
28
38
|
#
|
|
29
|
-
# Exit: 0 on success, 2 on usage error.
|
|
39
|
+
# Exit: 0 on success, 2 on usage error or refused log root.
|
|
30
40
|
|
|
31
41
|
set -uo pipefail
|
|
32
42
|
|
|
33
43
|
LOG_ROOT="${PRUNE_LOG_ROOT:-$HOME/.claude/logs/multi-agent}"
|
|
44
|
+
GRACE_MIN="${PRUNE_GRACE_MIN:-10}"
|
|
34
45
|
DELETE=0
|
|
35
46
|
OLDER_DAYS=0
|
|
36
47
|
PROJECT=""
|
|
@@ -40,6 +51,13 @@ usage() {
|
|
|
40
51
|
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
|
|
41
52
|
}
|
|
42
53
|
|
|
54
|
+
# --project / --task values are compared against path components; only plain
|
|
55
|
+
# names are meaningful. Rejects empty, ".", "..", anything with a slash, and
|
|
56
|
+
# anything starting with "." or "-".
|
|
57
|
+
valid_id() {
|
|
58
|
+
printf '%s' "$1" | grep -qE '^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
for arg in "$@"; do
|
|
44
62
|
case "$arg" in
|
|
45
63
|
--yes | --force) DELETE=1 ;;
|
|
@@ -50,8 +68,20 @@ for arg in "$@"; do
|
|
|
50
68
|
exit 2
|
|
51
69
|
fi
|
|
52
70
|
;;
|
|
53
|
-
--project=*)
|
|
54
|
-
|
|
71
|
+
--project=*)
|
|
72
|
+
PROJECT="${arg#*=}"
|
|
73
|
+
if ! valid_id "$PROJECT"; then
|
|
74
|
+
echo "prune-logs: --project needs a plain directory name (letters/digits then . _ -), got: '$PROJECT'" >&2
|
|
75
|
+
exit 2
|
|
76
|
+
fi
|
|
77
|
+
;;
|
|
78
|
+
--task=*)
|
|
79
|
+
TASK="${arg#*=}"
|
|
80
|
+
if ! valid_id "$TASK"; then
|
|
81
|
+
echo "prune-logs: --task needs a plain directory name (letters/digits then . _ -), got: '$TASK'" >&2
|
|
82
|
+
exit 2
|
|
83
|
+
fi
|
|
84
|
+
;;
|
|
55
85
|
-h | --help)
|
|
56
86
|
usage
|
|
57
87
|
exit 0
|
|
@@ -63,11 +93,29 @@ for arg in "$@"; do
|
|
|
63
93
|
esac
|
|
64
94
|
done
|
|
65
95
|
|
|
96
|
+
if ! printf '%s' "$GRACE_MIN" | grep -qE '^[0-9]+$'; then
|
|
97
|
+
echo "prune-logs: PRUNE_GRACE_MIN needs a whole number of minutes, got: $GRACE_MIN" >&2
|
|
98
|
+
exit 2
|
|
99
|
+
fi
|
|
100
|
+
|
|
66
101
|
if [ ! -d "$LOG_ROOT" ]; then
|
|
67
102
|
echo "prune-logs: no log root at $LOG_ROOT - nothing to do"
|
|
68
103
|
exit 0
|
|
69
104
|
fi
|
|
70
105
|
|
|
106
|
+
# Root guard: resolve symlinks, then refuse roots that can never be a log
|
|
107
|
+
# root. Everything removed below sits strictly inside this resolved path.
|
|
108
|
+
LOG_ROOT="$(cd "$LOG_ROOT" 2>/dev/null && pwd -P)"
|
|
109
|
+
if [ -z "$LOG_ROOT" ]; then
|
|
110
|
+
echo "prune-logs: cannot resolve log root" >&2
|
|
111
|
+
exit 2
|
|
112
|
+
fi
|
|
113
|
+
HOME_REAL="$(cd "$HOME" 2>/dev/null && pwd -P || printf '%s' "$HOME")"
|
|
114
|
+
if [ "$LOG_ROOT" = "/" ] || [ "$LOG_ROOT" = "$HOME_REAL" ] || [ -e "$LOG_ROOT/.git" ]; then
|
|
115
|
+
echo "prune-logs: refusing log root $LOG_ROOT (/, \$HOME, or a git work tree is never a log root)" >&2
|
|
116
|
+
exit 2
|
|
117
|
+
fi
|
|
118
|
+
|
|
71
119
|
# A task dir is any dir (depth 1-2 under the root) carrying a run marker.
|
|
72
120
|
is_task_dir() {
|
|
73
121
|
local d="$1"
|
|
@@ -75,23 +123,47 @@ is_task_dir() {
|
|
|
75
123
|
[ -f "$d/agent-log.md" ] || [ -f "$d/otel-spans.jsonl" ]
|
|
76
124
|
}
|
|
77
125
|
|
|
126
|
+
# Names promised to survive pruning. A directory carrying one of these names
|
|
127
|
+
# is never a legitimate task dir, so it is skipped outright.
|
|
128
|
+
is_reserved_name() {
|
|
129
|
+
case "$1" in
|
|
130
|
+
audit.jsonl | audit.*.jsonl* | audit.jsonl.* | metrics.jsonl | metrics-summary.md | .counter | *.counter) return 0 ;;
|
|
131
|
+
esac
|
|
132
|
+
return 1
|
|
133
|
+
}
|
|
134
|
+
|
|
78
135
|
MTIME_ARGS=()
|
|
79
136
|
if [ "$OLDER_DAYS" -gt 0 ]; then
|
|
80
137
|
MTIME_ARGS=(-mtime "+$OLDER_DAYS")
|
|
81
138
|
fi
|
|
82
139
|
|
|
83
140
|
matches=()
|
|
141
|
+
skipped_active=0
|
|
84
142
|
while IFS= read -r -d '' d; do
|
|
143
|
+
base="$(basename "$d")"
|
|
144
|
+
if is_reserved_name "$base"; then continue; fi
|
|
85
145
|
is_task_dir "$d" || continue
|
|
86
146
|
# --task: dir name must equal the task id
|
|
87
|
-
if [ -n "$TASK" ] && [ "$
|
|
147
|
+
if [ -n "$TASK" ] && [ "$base" != "$TASK" ]; then continue; fi
|
|
88
148
|
# --project: the immediate parent dir name must equal the project
|
|
89
149
|
if [ -n "$PROJECT" ] && [ "$(basename "$(dirname "$d")")" != "$PROJECT" ]; then continue; fi
|
|
150
|
+
# Active-task grace: spare dirs a run touched in the last GRACE_MIN minutes
|
|
151
|
+
# so a concurrent run keeps a resumable log dir. --task is an explicit,
|
|
152
|
+
# deliberate target and bypasses the grace.
|
|
153
|
+
if [ "$GRACE_MIN" -gt 0 ] && [ -z "$TASK" ] &&
|
|
154
|
+
find "$d" -mmin "-$GRACE_MIN" -print 2>/dev/null | head -1 | grep -q .; then
|
|
155
|
+
skipped_active=$((skipped_active + 1))
|
|
156
|
+
continue
|
|
157
|
+
fi
|
|
90
158
|
matches+=("$d")
|
|
91
159
|
done < <(find "$LOG_ROOT" -mindepth 1 -maxdepth 2 -type d ${MTIME_ARGS[@]+"${MTIME_ARGS[@]}"} -print0 2>/dev/null)
|
|
92
160
|
|
|
93
161
|
if [ "${#matches[@]}" -eq 0 ]; then
|
|
94
|
-
|
|
162
|
+
if [ "$skipped_active" -gt 0 ]; then
|
|
163
|
+
echo "prune-logs: only active task dirs matched (touched in the last ${GRACE_MIN}m) - spared, nothing to do"
|
|
164
|
+
else
|
|
165
|
+
echo "prune-logs: no task logs matched under $LOG_ROOT - nothing to do"
|
|
166
|
+
fi
|
|
95
167
|
exit 0
|
|
96
168
|
fi
|
|
97
169
|
|
|
@@ -106,13 +178,26 @@ human="${total_kb} KB"
|
|
|
106
178
|
if [ "$DELETE" -eq 0 ]; then
|
|
107
179
|
echo "DRY-RUN - would remove ${#matches[@]} task log dir(s) (frees ~${human}):"
|
|
108
180
|
for d in "${matches[@]}"; do echo " ${d#"$LOG_ROOT"/}"; done
|
|
181
|
+
[ "$skipped_active" -gt 0 ] && echo "Spared $skipped_active active dir(s) touched in the last ${GRACE_MIN}m."
|
|
109
182
|
echo "Audit trail + metrics corpus are preserved. Re-run with --yes to delete."
|
|
110
183
|
exit 0
|
|
111
184
|
fi
|
|
112
185
|
|
|
113
186
|
removed=0
|
|
114
187
|
for d in "${matches[@]}"; do
|
|
115
|
-
|
|
188
|
+
# Containment guard: resolve the target and require it strictly inside the
|
|
189
|
+
# resolved log root before rm -rf.
|
|
190
|
+
target="$(cd "$d" 2>/dev/null && pwd -P)" || continue
|
|
191
|
+
case "$target" in
|
|
192
|
+
"$LOG_ROOT"/*) ;;
|
|
193
|
+
*)
|
|
194
|
+
echo "prune-logs: skipping path outside log root: $d" >&2
|
|
195
|
+
continue
|
|
196
|
+
;;
|
|
197
|
+
esac
|
|
198
|
+
rm -rf "$target" 2>/dev/null && removed=$((removed + 1))
|
|
116
199
|
done
|
|
117
|
-
|
|
200
|
+
spared_note=""
|
|
201
|
+
[ "$skipped_active" -gt 0 ] && spared_note=", spared $skipped_active active"
|
|
202
|
+
echo "══ prune-logs: removed ${removed}/${#matches[@]} task log dir(s)${spared_note}, freed ~${human} (audit + metrics preserved) ══"
|
|
118
203
|
exit 0
|