DEV Community

Lily
Lily

Posted on • Originally published at dev.to

Auto-Retiring Skills You No Longer Use: Weekly Skill Lifecycle Management with launchd

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • Over 30 days: rewrite the frontmatter's status: to stale. 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 nollm as 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>
Enter fullscreen mode Exit fullscreen mode

~/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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Pitfalls I hit

  • grep -rl rescans 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's stat --format='%Y'. This script is written assuming macOS zsh.
  • launchd's PATH is minimal: in a bare launchd environment, the claude command isn't found. Unless you explicitly add nvm's bin directory and ~/.local/bin to the plist's EnvironmentVariables/PATH, it exits immediately.
  • Writing directly into ~/.claude becomes permission denied: while Claude Code is running, it can block writes under ~/.claude. Both harvest and curate are structured to create a staging directory with mktemp and 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)