Generating skills automatically is the easy part. Deciding when to throw them away is where everything falls apart. In my last post I covered auto-detecting Claude Code model retirements and switching aliases. This time I use the same launchd machinery to build lifecycle management that keeps a bloating pile of auto-skills from rotting.
As I described in an earlier article, auto-skills are generated automatically from conversation logs by a nightly batch. Leave it running and ~/.claude/skills/auto/ swells without limit, and the context load at Claude Code startup gets heavier and heavier. But manually thinning them out never sticks. So I fully automated it with "automatic last-used-date computation → two-stage retirement."
The problem: generation is automatic, disposal is manual
The nightly batch skill-harvest.sh runs every day at 3:30 and keeps adding skills. Meanwhile, there is nothing that tells you "this skill isn't used anymore." Before you know it, you end up here:
- Nobody knows which skills are alive and which are dead
- Every skill loads into context and slows down startup
- Deleting feels risky, so manual cleanup keeps getting pushed off
What I needed was non-destructive two-stage retirement that satisfies the requirement "I don't want to delete anything, but I want the rotten stuff out of my field of view."
Overall design: separate harvest (daily) from curate (weekly)
Daily 3:30 skill-harvest.sh conversation logs → generate new skills
Weekly Sun 4:15 skill-curate.sh reverse-lookup skill names in logs → compute last-used → retire
The key is splitting generation and cleanup into separate processes. Harvest is addition; curate is subtraction (really, "transport to the graveyard"). The reason curate runs 45 minutes after harvest is to keep freshly born status: active skills from being evaluated by the same day's curate run.
harvest: creating the source record for last-used
For skill-curate.sh to decide "when did I last use this skill," it needs evidence. That evidence is "the latest mtime at which the skill name appeared in a conversation log."
The harvest side accumulates conversation logs into ~/Documents/my-knowledge-base/raw/conversations/*.md, and curate greps there.
The harvest parameters are as follows (from skill-harvest.sh).
MAX_LOGS=3 # 1回で扱うログ本数
PER_LOG_BYTES=15000 # ログ1本あたりの取り込み上限バイト
BUDGET_USD=1.20 # 暴走防止のコストキャップ
TIMEOUT_SEC=600
Logs are cleaned with grep -v to strip the noise of system-reminder lines and skill-list lines, then truncated by the byte limit and passed to claude -p.
grep -v -e 'system-reminder' -e '^- [a-z0-9].*:' "$f" 2>/dev/null | head -c $PER_LOG_BYTES
curate: the last-used computation logic
The heart of curate is the part that greps conversation logs keyed by skill name and reverse-looks-up the mtime (from skill-curate.sh).
lastlog=$(grep -rl -- "$skill" "$LOGS" 2>/dev/null \
| while read f; do stat -f '%m' "$f" 2>/dev/null; done \
| sort -rn | head -1)
grep -rl enumerates the files containing the skill name, stat -f '%m' pulls out each file's mtime (Unix timestamp), and the maximum is selected.
This value is converted into elapsed days with a Python3 one-liner.
import sys, os, time, datetime
lastlog, created, md = (sys.argv + ["","",""])[1:4]
ref = None
if lastlog.strip():
try: ref = float(lastlog)
except: ref = None
if ref is None and created.strip():
try: ref = time.mktime(datetime.datetime.strptime(created.strip(), "%Y-%m-%d").timetuple())
except: ref = None
if ref is None:
ref = os.path.getmtime(md)
print(int((time.time() - ref) // 86400))
There are three levels of fallback: ① the conversation log's mtime → ② the frontmatter's created: → ③ the mtime of the SKILL.md file itself. Even for a new skill that has never once appeared in a log, the count starts from its created date.
Two-stage retirement: 30 days → stale / 90 days → .archive
Once elapsed days is computed, it's processed by the following conditional branch.
if (( days > ARCHIVE_DAYS )); then # 90日超
mv "$d" "$ARCH/" && echo "[$(ts)] ARCHIVED (${days}d unused): $skill" >> "$LOG"
elif (( days > STALE_DAYS )); then # 30日超
python3 - "$md" <<'PY'
import sys, re
p = sys.argv[1]; s = open(p).read()
if re.search(r'^status:', s, re.M):
s = re.sub(r'^status:.*$', 'status: stale', s, count=1, flags=re.M)
else:
s = re.sub(r'^(author:[ \t]*auto.*)$', r'\1\nstatus: stale', s, count=1, flags=re.M)
open(p, 'w').write(s)
PY
echo "[$(ts)] stale (${days}d unused): $skill" >> "$LOG"
((active++))
else
((active++))
fi
-
Over 30 days: rewrite the frontmatter's
status:tostale. The file is not moved. -
Over 90 days: physically move it to
~/.claude/skills/auto/.archive/. It is never actually deleted.
status: stale is a marker for future filtering on the skill-loader side. Excluding stale can lighten the context load.
Note: "Untouched for 30 days" doesn't necessarily mean the skill is bad. You may just not have been doing that kind of work. So the final judgment is left to a human, and curate only handles demotion and transport.
Non-destructive safety measures
Pre-run snapshot
curate always passes through here before doing anything.
tar czf "$SNAP/auto-$(date +%Y%m%d-%H%M%S).tar.gz" \
-C "$HOME/.claude/skills" \
--exclude='auto/.snapshots' --exclude='auto/.archive' \
auto 2>/dev/null \
&& echo "[$(ts)] snapshot taken" >> "$LOG"
It takes a snapshot at ~/.claude/skills/auto/.snapshots/auto-YYYYMMDD-HHMMSS.tar.gz before starting to process. Even if something gets retired by mistake, you can instantly restore it with tar xzf.
Never touch anything but author: auto
if ! grep -q '^author:[[:space:]]*auto' "$md"; then
echo "[$(ts)] skip (not author:auto): $skill" >> "$LOG"
continue
fi
Hand-written skills and bundled skills don't carry author: auto, so they are skipped entirely.
LLM integration-proposal phase
On top of the mechanical, day-count-based retirement, there is a phase that has an LLM surface semantically duplicated skills.
if [[ "$RUN_LLM" != "nollm" ]] && (( active >= 2 )) && [[ -x "$CLAUDE" ]]; then
# 前回提案ファイルより後に更新されたSKILL.mdだけを対象にする
...
STG=$(mktemp -d -t skill-curate-stg)
( cd "$STG" && perl -e 'alarm 600; exec @ARGV' "$CLAUDE" -p "..." \
--model sonnet \
--permission-mode acceptEdits \
--allowedTools "Write Edit Read" \
--add-dir "$AUTO" \
--max-budget-usd 5.00 >> "$LOG" 2>&1 < /dev/null )
[[ -f "$STG/curator-proposals.md" ]] \
&& cp "$STG/curator-proposals.md" "$PROP" \
&& echo "[$(ts)] proposals written -> $PROP" >> "$LOG"
rm -rf "$STG"
fi
There are two design touches here.
Pass only the diff: only SKILL.md files updated after the previous .curator-proposals.md are targeted. Having the LLM read every skill every week would pile up cost, so only changed skills are processed.
Let it write proposals, but not apply them: claude is made to Write ./curator-proposals.md, and the shell copies it to ~/.claude/skills/auto/.curator-proposals.md — that's it. It never touches the actual skill files. Reviewing and applying is a human's job.
Note: Passing
nollmas the first argument skips the entire LLM phase. Handy for testing.
skill-curate.sh nollm
launchd registration
~/Library/LaunchAgents/com.shun.skill-harvest.plist (schedule portion)
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>30</integer>
</dict>
~/Library/LaunchAgents/com.shun.skill-curate.plist (schedule portion)
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>4</integer>
<key>Minute</key><integer>15</integer>
<key>Weekday</key><integer>0</integer>
</dict>
Weekday: 0 is Sunday. Both plists run with ProcessType: Background, and logs are emitted separately to ~/.claude/logs/com.shun.skill-harvest.log and ~/.claude/logs/com.shun.skill-curate.log.
Registration and an immediate test are done as follows.
launchctl load ~/Library/LaunchAgents/com.shun.skill-harvest.plist
launchctl load ~/Library/LaunchAgents/com.shun.skill-curate.plist
# 今すぐ走らせて動作確認
launchctl start com.shun.skill-harvest
launchctl start com.shun.skill-curate
Pitfalls I hit
-
grep -rlrescans every log on each run: the watermark is for the harvest side; given that curate looks things up keyed by skill name, it can't be used. As the number of logs grows, it gets slower. It's within tolerance for now, but I'll need to build an index in the future. -
stat -f '%m'is macOS-only: on Linux it'sstat --format='%Y'. This script is written assuming macOS zsh. -
launchd's PATH is minimal: in a bare launchd environment, the
claudecommand isn't found. Unless you explicitly add nvm's bin directory and~/.local/binto the plist'sEnvironmentVariables/PATH, it exits immediately. -
Writing directly into
~/.claudebecomes permission denied: while Claude Code is running, it can block writes under~/.claude. Both harvest and curate are structured to create a staging directory withmktempand have the shell copy from it. If you make it write directly into~/.claude/skills/auto/, it falls over. -
Handing every skill to the LLM makes cost spike: it's made readable via
--add-dir "$AUTO", but the prompt instructs "Read only the SKILL.md files updated after the previous proposal file." Without that instruction, the LLM goes and reads all of them.
Summary
- The auto-skill lifecycle is managed in two stages: 30 days →
status: stale/ 90 days → transport to.archive/. - "Last-used date" is computed by reverse-looking-up the mtime of the skill name's appearance in conversation logs with
grep -rl. - Non-destruction is guaranteed by a pre-run tar snapshot plus never touching anything but
author: auto. - The LLM integration proposal passes only the diff and has it written to
.curator-proposals.md. Applying to the actual skill files is done by a human. - harvest (daily 3:30) and curate (weekly Sunday 4:15) are registered separately in launchd.
Next time I plan to write about how Claude Code itself got heavy after skills, memory, and conversation logs piled up — auditing and cutting down the amount of injected context.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)