Skip to content

Commit e4c9f8b

Browse files
lroolleclaude
andcommitted
feat(skills): extract deva-clean triage into deterministic script
- scripts/triage.sh: read-only pass one - mount map from /proc/mounts, pressure check, candidate walk, four verdicts per dir, conservative tier proposal with exact commands; never deletes - verdict semantics encode the field traps: pushed test is live ls-remote SHA + merge ancestry (never upstream config), MERGED is monotonic so stale refs still prove it, absence against stale refs is UNVERIFIED not safe - script proposes, judgment disposes: SKILL.md keeps promotions, demotions, stray files, and the approval gate; execution criterion is now a triage rerun reporting empty tiers - shellcheck clean Refs #401 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 01f6985 commit e4c9f8b

3 files changed

Lines changed: 236 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- `skills/deva-clean`: Claude Code skill for mount-safe workspace disk
12-
cleanup. Maps bind mounts from `.deva` + /proc/mounts, git-triages
13-
local worktrees for unpushed/uncommitted work, and reports a tiered
14-
dry-run plan before any deletion (#401)
12+
cleanup. `scripts/triage.sh` (read-only, deterministic) maps bind
13+
mounts from /proc/mounts, git-triages local worktrees for unpushed or
14+
uncommitted work via live ls-remote + merge ancestry, and emits a
15+
tiered dry-run plan; SKILL.md layers the judgment calls and gates
16+
execution behind explicit approval (#401)
1517

1618
## [0.13.0] - 2026-07-07
1719

skills/deva-clean/SKILL.md

Lines changed: 43 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -12,91 +12,58 @@ rm -rf on the wrong one destroys a live repo on the host. And because
1212
the workspace root is itself a host mount, local dirs still consume
1313
host disk - usually the disk that is actually full.
1414

15-
The run is two passes. Pass one triages everything into a dry-run plan
16-
and stops for approval. Pass two executes only the approved tiers.
15+
Two passes. Pass one is deterministic and scripted; pass two executes
16+
only what got approved.
1717

1818
## Pass one: triage
1919

20-
### 1. Map the mounts
21-
22-
Read `.deva` for declared VOLUME=host:container[:mode] lines, then take
23-
ground truth from the kernel - config drifts, /proc/mounts does not:
24-
25-
grep "$(pwd)" /proc/mounts | awk '{print $2}' | sort
26-
27-
The workspace root is usually itself a mount, so everything under it
28-
that is not a separate mount is a local dir living on host disk.
29-
30-
Done when every dir under the workspace is classified mount or local.
31-
Mounts are off limits for the rest of the run.
32-
33-
### 2. Locate the pressure
34-
35-
df -h / "$(pwd)"
36-
37-
Overlay numbers can lie: on shared-VM runtimes (OrbStack and friends)
38-
overlay df reports the whole VM disk. Container-local truth:
39-
40-
sudo du -xh -d1 / | sort -rh | head
41-
42-
Done when you know which disk is full. Host mount full: the workspace
43-
is the target. Overlay genuinely full: container caches (~/.cache,
44-
package stores) are the target instead.
45-
46-
### 3. Size the candidates
47-
48-
Candidates = the local dirs from step 1, plus stray files (shell-typo
49-
artifacts like "&1"). Typical: trees from `git worktree add`, one-off
50-
clones, node_modules, .pnpm-store.
51-
52-
du -sh <candidates> | sort -rh
53-
54-
Done when every candidate has a size.
55-
56-
### 4. Git-triage each candidate
57-
58-
Four verdicts per candidate:
59-
60-
- Type: `.git` file -> linked worktree; `.git` dir -> standalone repo;
61-
neither -> plain dir.
62-
- Dirty: `git status --porcelain`. Untracked generated files (lockfiles,
63-
test output) are lower risk than modified source.
64-
- Pushed: `git branch -r --contains HEAD` - empty output means the
65-
commits exist nowhere but this machine. This is the only pushed test:
66-
upstream config and ahead/behind counts are a trap (a branch created
67-
off origin/develop tracks origin/develop and shows "ahead 1" while
68-
never pushed itself).
69-
- Freshness: remote refs are only as new as the last fetch. Check
70-
`stat .git/FETCH_HEAD` on the parent repo; flag stale refs in the
71-
plan.
72-
73-
Inside keepers, node_modules / dist / coverage are separately prunable:
74-
offer cache-prune as the low-risk alternative to removal. Also run
75-
`git worktree list` on each parent repo - "prunable" entries are stale
76-
metadata from already-deleted trees.
77-
78-
Done when every candidate carries all four verdicts. The smallest dir
79-
gets the same triage as the largest: a 4M worktree can hold the only
80-
copy of uncommitted work.
81-
82-
### 5. Assemble the dry-run plan
83-
84-
- Tier 1 (safe): clean + pushed. Sizes and exact commands.
85-
- Tier 2 (confirm): pushed branch, dirty only with generated artifacts.
86-
Offer full removal and cache-prune-only variants.
87-
- Keep: unpushed commits or uncommitted diffs. Say exactly what would
88-
be lost and suggest pushing the branches.
89-
- Totals: reclaim per tier.
90-
91-
The plan is pass one's deliverable. Present it and stop - pass two
92-
starts only from explicit approval, tier by tier.
20+
Run the bundled script - read-only, emits the tiered plan, never
21+
deletes:
22+
23+
scripts/triage.sh [workspace] [--fetch]
24+
25+
It maps mounts from /proc/mounts (ground truth - .deva drifts), shows
26+
which disk is under pressure, walks the non-mount dirs, gives every
27+
candidate four verdicts (type, dirty, pushed, freshness), and finds
28+
stale worktree metadata in the mounted parent repos. Verdict semantics
29+
live in the script header; two encode traps worth knowing:
30+
31+
- The pushed test is live ls-remote SHA comparison plus ancestry in
32+
the default branch - never upstream config. A branch created off
33+
origin/develop shows "ahead 1" while never pushed itself.
34+
- MERGED is monotonic, provable even against stale refs. Absence is
35+
not: a branch absent on remote with stale refs is UNVERIFIED, not
36+
safe. Rerun with --fetch for a definitive verdict.
37+
38+
Then layer the judgment the script refuses to automate:
39+
40+
- Pressure: container overlay genuinely full (rare - on shared-VM
41+
runtimes overlay df reports the whole VM disk) means container
42+
caches (~/.cache, package stores) are the target, not the workspace.
43+
- Stray files: the script walks dirs only; shell-typo artifacts like
44+
"&1" are yours to spot.
45+
- Promotions: KEEP entries flagged MODIFIED TRACKED FILES move to
46+
tier 2 only when you can name every dirty file as a generated
47+
artifact (lockfiles, test output) and say so in the plan.
48+
- Demotions: tier 1/2 entries that are functionally live - symlink
49+
targets, open-PR checkouts - move to keep.
50+
- Keepers: offer cache-prune (node_modules, dist, coverage) as the
51+
low-risk alternative; for LOCAL-ONLY work, say exactly what would
52+
be lost and suggest pushing it.
53+
54+
Done when every candidate sits in a tier you can defend. Present the
55+
plan and stop - pass two starts only from explicit approval, tier by
56+
tier.
9357

9458
## Pass two: execute approved tiers
9559

9660
- Linked worktrees: `git -C <parent> worktree remove <path>` is the
9761
only removal path. Its refusal on a dirty tree is the safety net, so
9862
no --force - and no rm -rf, which bypasses the net and leaves stale
9963
metadata behind.
64+
- Tier 2 trees: first delete the named generated files (restore
65+
tracked ones with `git checkout --`), so the tree is genuinely clean
66+
and the safety net stays armed.
10067
- Stale metadata: `git -C <parent> worktree prune`.
10168
- Standalone repos (pushed, clean) and plain dirs: rm -rf.
10269

@@ -105,5 +72,4 @@ branch refs live in the parent repo's .git and survive. Committed work
10572
is never lost - only uncommitted files can be. The metadata edit inside
10673
the parent's .git is normal bookkeeping, not touching the mount.
10774

108-
Done when df shows the reclaim and `git worktree list` on each parent
109-
has no leftovers.
75+
Done when df shows the reclaim and a triage rerun reports empty tiers.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#!/usr/bin/env bash
2+
# triage.sh - deva-clean pass one: read-only triage of a deva workspace.
3+
# Emits a tiered dry-run plan on stdout. Never deletes anything.
4+
#
5+
# Usage: triage.sh [WORKSPACE] [--fetch]
6+
#
7+
# WORKSPACE workspace root (default: current directory)
8+
# --fetch refresh each repo's default branch before the merged
9+
# check (writes refs into the repo's .git only; worktrees
10+
# and mounts are untouched)
11+
#
12+
# Verdicts:
13+
# PUSHED-EXACT remote branch tip == local HEAD (live ls-remote)
14+
# MERGED HEAD is ancestor of origin/<default>; monotonic, so
15+
# provable even against stale refs
16+
# LOCAL-ONLY branch absent on remote, HEAD not in fresh default
17+
# UNVERIFIED branch absent on remote, default ref stale and no
18+
# --fetch: could be recently merged - treat as keep
19+
# DIVERGED remote branch exists but tip differs from HEAD
20+
# OFFLINE ls-remote failed: no live evidence
21+
# NO-REMOTE repo has no origin remote
22+
set -uo pipefail
23+
shopt -s nullglob dotglob
24+
25+
WS=$PWD
26+
DO_FETCH=0
27+
while [[ $# -gt 0 ]]; do
28+
case "$1" in
29+
--fetch) DO_FETCH=1 ;;
30+
-h|--help) sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
31+
-*) echo "unknown flag: $1" >&2; exit 2 ;;
32+
*) WS=$(cd "$1" && pwd) || exit 2 ;;
33+
esac
34+
shift
35+
done
36+
NET_TIMEOUT=${DEVA_CLEAN_TIMEOUT:-20}
37+
38+
# --- step 1: mount map (ground truth: the kernel, not .deva) ----------
39+
mapfile -t MOUNTS < <(awk -v ws="$WS" '$2 == ws || index($2, ws"/") == 1 {print $2}' /proc/mounts | sort)
40+
41+
is_mount() {
42+
local m
43+
for m in "${MOUNTS[@]}"; do [[ $m == "$1" ]] && return 0; done
44+
return 1
45+
}
46+
47+
has_mount_below() {
48+
local m
49+
for m in "${MOUNTS[@]}"; do [[ $m == "$1"/* ]] && return 0; done
50+
return 1
51+
}
52+
53+
echo "== deva-clean triage: $WS"
54+
echo "== mounts: ${#MOUNTS[@]} (off limits)"
55+
is_mount "$WS" || echo "!! workspace root is not a bind mount - is this a deva workspace?"
56+
57+
# --- step 2: locate the pressure --------------------------------------
58+
echo
59+
echo "-- pressure"
60+
df -h / "$WS" | awk 'NR==1 || $NF=="/" || $NF=="'"$WS"'"'
61+
62+
# --- step 3: candidates = non-mount dirs; descend only through dirs
63+
# --- that still contain mounts beneath them ----------------------------
64+
CANDIDATES=()
65+
collect() {
66+
local e
67+
for e in "$1"/*/; do
68+
e=${e%/}
69+
[[ ${e##*/} == .git ]] && continue
70+
if is_mount "$e"; then continue; fi
71+
if has_mount_below "$e"; then collect "$e"; else CANDIDATES+=("$e"); fi
72+
done
73+
}
74+
collect "$WS"
75+
76+
# --- step 4: four verdicts per candidate -------------------------------
77+
declare -A DEF_BRANCH FETCH_STATE # cached per remote URL
78+
T1=() T2=() KEEP=() JUDGE=()
79+
T1_KB=0 T2_KB=0
80+
81+
kb() { du -sk "$1" 2>/dev/null | cut -f1; }
82+
hsize() { echo "$1" | awk '{ if ($1>=1048576) printf "%.1fG", $1/1048576; else if ($1>=1024) printf "%.0fM", $1/1024; else printf "%dK", $1 }'; }
83+
84+
for d in "${CANDIDATES[@]}"; do
85+
size_kb=$(kb "$d"); size=$(hsize "$size_kb")
86+
87+
if [[ -f $d/.git ]]; then type=linked
88+
elif [[ -d $d/.git ]]; then type=standalone
89+
else JUDGE+=("$size $d (no git evidence - judgment call)"); continue; fi
90+
91+
head=$(git -C "$d" rev-parse HEAD 2>/dev/null) || { KEEP+=("$size $d unreadable git state"); continue; }
92+
br=$(git -C "$d" rev-parse --abbrev-ref HEAD)
93+
porcelain=$(git -C "$d" status --porcelain 2>/dev/null)
94+
dirty=0; untracked_only=1
95+
while IFS= read -r line; do
96+
[[ -z $line ]] && continue
97+
dirty=$((dirty + 1))
98+
[[ $line == '??'* ]] || untracked_only=0
99+
done <<< "$porcelain"
100+
101+
url=$(git -C "$d" remote get-url origin 2>/dev/null) || url=""
102+
verdict=""
103+
if [[ -z $url ]]; then
104+
verdict=NO-REMOTE
105+
else
106+
if [[ -z ${DEF_BRANCH[$url]:-} ]]; then
107+
DEF_BRANCH[$url]=$(timeout "$NET_TIMEOUT" git -C "$d" ls-remote --symref origin HEAD 2>/dev/null \
108+
| awk '$1=="ref:"{sub("refs/heads/","",$2); print $2; exit}')
109+
fi
110+
def=${DEF_BRANCH[$url]}
111+
if [[ -z $def ]]; then
112+
verdict=OFFLINE
113+
else
114+
remote_sha=""
115+
[[ $br != HEAD ]] && remote_sha=$(timeout "$NET_TIMEOUT" git -C "$d" ls-remote origin "refs/heads/$br" 2>/dev/null | cut -f1)
116+
if [[ -n $remote_sha && $remote_sha == "$head" ]]; then
117+
verdict=PUSHED-EXACT
118+
else
119+
base_ref="" base_note=""
120+
if [[ $DO_FETCH == 1 && -z ${FETCH_STATE[$url]:-} ]]; then
121+
if timeout $((NET_TIMEOUT * 3)) git -C "$d" fetch origin "$def" >/dev/null 2>&1; then
122+
FETCH_STATE[$url]=fresh
123+
else FETCH_STATE[$url]=failed; fi
124+
fi
125+
common=$(git -C "$d" rev-parse --path-format=absolute --git-common-dir)
126+
if [[ ${FETCH_STATE[$url]:-} == fresh ]]; then
127+
base_ref=FETCH_HEAD base_note=fresh
128+
elif git -C "$d" rev-parse -q --verify "origin/$def" >/dev/null 2>&1; then
129+
base_ref="origin/$def"
130+
base_note="refs from $(stat -c %y "$common/FETCH_HEAD" 2>/dev/null | cut -d' ' -f1 || echo unknown)"
131+
fi
132+
if [[ -n $base_ref ]] && git -C "$d" merge-base --is-ancestor "$head" "$base_ref" 2>/dev/null; then
133+
verdict="MERGED($def,$base_note)"
134+
elif [[ -n $remote_sha ]]; then
135+
verdict=DIVERGED
136+
elif [[ $base_note == fresh ]]; then
137+
verdict=LOCAL-ONLY
138+
else
139+
verdict="UNVERIFIED($base_note; rerun with --fetch)"
140+
fi
141+
fi
142+
fi
143+
fi
144+
145+
row="$size $d [$type $br] dirty=$dirty $verdict"
146+
case $verdict in
147+
PUSHED-EXACT|MERGED*)
148+
if [[ $dirty -eq 0 ]]; then
149+
if [[ $type == linked ]]; then
150+
parent=$(dirname "$(git -C "$d" rev-parse --path-format=absolute --git-common-dir)")
151+
T1+=("$row"$'\n'" \$ git -C $parent worktree remove $d")
152+
else
153+
T1+=("$row"$'\n'" \$ rm -rf $d")
154+
fi
155+
T1_KB=$((T1_KB + size_kb))
156+
elif [[ $untracked_only -eq 1 ]]; then
157+
T2+=("$row"$'\n'" untracked: $(echo "$porcelain" | sed 's/^?? //' | head -5 | tr '\n' ' ')")
158+
T2_KB=$((T2_KB + size_kb))
159+
else
160+
KEEP+=("$row - MODIFIED TRACKED FILES (promote to tier 2 only with judgment)")
161+
fi ;;
162+
*) KEEP+=("$row") ;;
163+
esac
164+
done
165+
166+
# --- stale worktree metadata in mounted parent repos -------------------
167+
PRUNABLE=()
168+
for m in "${MOUNTS[@]}"; do
169+
[[ -d $m/.git ]] || continue
170+
n=$(git -C "$m" worktree list --porcelain 2>/dev/null | grep -c '^prunable') || n=0
171+
[[ $n -gt 0 ]] && PRUNABLE+=("$m: $n stale entries -> \$ git -C $m worktree prune")
172+
done
173+
174+
# --- step 5: the plan ---------------------------------------------------
175+
section() {
176+
echo; echo "-- $1"; shift
177+
local x any=0
178+
for x in "$@"; do [[ -n $x ]] && { printf ' %s\n' "$x"; any=1; }; done
179+
[[ $any -eq 0 ]] && echo " (none)"
180+
return 0
181+
}
182+
section "TIER 1: safe (clean + pushed/merged), reclaim ~$(hsize $T1_KB)" "${T1[@]:-}"
183+
section "TIER 2: confirm (pushed/merged, untracked files only), reclaim ~$(hsize $T2_KB)" "${T2[@]:-}"
184+
section "KEEP: would lose the only copy" "${KEEP[@]:-}"
185+
section "JUDGMENT: no git evidence, decide case by case" "${JUDGE[@]:-}"
186+
section "stale worktree metadata" "${PRUNABLE[@]:-}"
187+
echo
188+
echo "== plan only - nothing was deleted. Execution is pass two (see SKILL.md)."

0 commit comments

Comments
 (0)