cd /news/ai-agents/dead-auto-skills-were-padding-every-… · home topics ai-agents article
[ARTICLE · art-98463] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Dead Auto-Skills Were Padding Every Conversation: A Weekly Curator That Flags at 30 Days and Archives at 90

A developer who built an autonomous Claude Code environment generating ¥1.2M a month has created a weekly automated curation system to combat 'skill rot' in Claude Code's auto-generated skills. The system scans conversation logs to determine last use, flags skills unused for 30 days as stale, and archives those unused for 90 days, reducing context pollution and improving output quality.

read30 min views1 publishedAug 16, 2026

"Claude Code gets smarter the more you use it" is only half the story. The other half is that it gets heavier.

Six months ago I was a university student making ¥100k a month. Juggling side jobs pushed that to ¥600k, and after being laid off I spent six months building an autonomous Claude Code environment. Today the business runs at ¥1.2M a month. Along the way, one problem hit me hard: skill rot. A tool that fits your hand perfectly at first becomes, three months later, a paperweight nobody uses. And that paperweight keeps getting loaded into every single conversation, quietly squeezing your context. This post is about solving that with a weekly automated curation pass.

Claude Code has a mechanism I call auto-skill

. When it completes a non-obvious task five or more times, discovers a workaround, or gets its approach corrected, the AI autonomously writes the procedure out to ~/.claude/skills/auto/<kebab-name>/SKILL.md

.

The more these notes pile up, the more of that experience is available in the next conversation. Watching your environment's "memory" grow really does feel good.

But there's a problem.

Claude Code's context injection grows with every skill file you add. Once you've accumulated 30 or 50 skills, thousands of tokens get spent on "descriptions of skills you aren't using" before the conversation even starts. On Opus or full-quality Sonnet, context pollution translates directly into degraded output quality. Your monthly token bill goes up while the sharpness of the responses goes down. It's like stacking documents you never open on your desk.

Concretely, in Lily's own environment: under ~/.claude/skills/auto/

each skill sits as a directory, and the SKILL.md

inside carries an author: auto

front matter field. Manual skills that lack this author: auto

field are never touched by the cleanup mechanism. What matters is that the safety guard against friendly fire is placed at the very start of the design.

Most of the ¥1.2M/month work is designing ways to route work to Claude. Building a mechanism that automates the next 100 tasks is worth more than completing individual tasks. I think that principle applies to any solo developer.

But when the "environment" itself rots, maintenance cost swings the other way. Automating auto-skill curation is exactly this meta layer of "maintaining the environment's environment." The more you use Claude Code, the more you'll get that feeling of "where was that skill I made?" or "am I even still using this one?" This is the story of automating that instead of ignoring it.

Let's trace what happens as skills accumulate.

~/.claude/CLAUDE.md

has a section called スキル自己生成(auto-skills)

(auto-skill self-generation) that says "write reusable procedures yourself without being asked." As long as that instruction is live, skills multiply naturally.

The problem is that detecting which skills have stopped being used relies on human eyeballs. Staring at the skills directory by hand doesn't tell you at a glance which are active and which are fossils. Deleting everything is too scary. The weekly curator resolves that dilemma.

The design has three core ideas.

grep

for the skill name across conversation log files in ~/Documents/my-knowledge-base/raw/conversations/

and treat the newest matching file's mtime as the "last used" date..archive/

status: stale

to the front matter; archive is an mv

to another directory.

[毎週日曜 4:15 AM]
      ↓
 com.shun.skill-curate (launchd)
      ↓
 skill-curate.sh
      │
      ├─ ① スナップショット取得
      │    ~/.claude/skills/auto/.snapshots/
      │    auto-YYYYMMDD-HHMMSS.tar.gz
      │
      ├─ ② auto/配下を全スキルスキャン
      │    author: auto でないものはスキップ
      │
      ├─ ③ 最終使用日の算出
      │    会話ログ grep → mtime
      │    → created: フロントマター
      │    → SKILL.md のファイルmtime
      │
      ├─ ④ 日数判定
      │    > 90日 → .archive/ へ mv(非破壊)
      │    > 30日 → status: stale を追記
      │    それ以外 → active カウント++
      │
      └─ ⑤ LLM統合提案(オプション)
           active ≥ 2 のとき Claude を呼び出し
           重複・低品質候補を .curator-proposals.md に書き出し
           実スキルは変更しない(提案のみ)

The launchd plist is configured like this:

<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, and with Hour: 4

/ Minute: 15

it fires every Sunday at 4:15 AM. LowPriorityIO: true

and Nice: 10

make it a lowest-priority background run. It cleans up while you sleep. Human cost is zero.

The script takes a snapshot right at the top.

AUTO="$HOME/.claude/skills/auto"
SNAP="$AUTO/.snapshots"

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"

.snapshots/

and .archive/

themselves are excluded from the compression. Without that, you get the recursion problem of archives inside archives. Since date +%Y%m%d-%H%M%S

gives the file a timestamped name, snapshots accumulate week over week.

Restoring is simple: just run tar xzf ~/.claude/skills/auto/.snapshots/auto-20260803-041500.tar.gz -C ~/.claude/skills/

. The fact that snapshots keep piling up weekly means you need a separate find

-based cleanup routine, but that's the next problem.

if ! grep -q '^author:[[:space:]]*auto' "$md"; then
    echo "[$(ts)] skip (not author:auto): $skill" >> "$LOG"
    continue
fi

Thanks to this, skills you carefully cultivated by hand are never accidentally archived. The author: auto

front marker is the single flag that says "machine-generated, subject to cleanup." Conversely, any skill you want to keep can be protected simply by changing the author:

field to something other than auto

. Simple and strong.

This is the part of the design that took the most thought. Claude Code doesn't expose an API-level log of "which skill was invoked" to the outside. So instead I use whether the skill name appears in the text of the conversation logs as a proxy metric.

lastlog=$(grep -rl -- "$skill" "$LOGS" 2>/dev/null \
  | while read f; do stat -f '%m' "$f" 2>/dev/null; done \
  | sort -rn | head -1)

This full-text searches LOGS="$HOME/Documents/my-knowledge-base/raw/conversations/"

and retrieves, as a Unix timestamp, the mtime of the newest file containing the skill name.

If nothing is found, there are two fallback stages.

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))

The order is ① conversation log mtime → ② the created:

front matter in SKILL.md

→ ③ the mtime of the SKILL.md

file itself. Even for old skills with no created:

field, the file mtime is the last line of defense.

That said, this proxy metric has a structural limitation.

"The skill name appears in a conversation log" and "the skill was actually invoked and did its job" are, strictly speaking, different events. Even if the name merely came up in conversation (e.g. discussing "maybe we don't need this skill"), it counts as usage. In the other direction, if the skill is invoked in command form (like /curate-skills

) and the search pattern doesn't match, it won't be picked up.

Knowing that constraint, I've decided that "conversation logs are still the best available approximation." Choose an operable approximation over a perfect usage log. That's the philosophy of practical automation.

STALE_DAYS=30
ARCHIVE_DAYS=90

if (( days > ARCHIVE_DAYS )); then
    mv "$d" "$ARCH/" && echo "[$(ts)] ARCHIVED (${days}d unused): $skill" >> "$LOG"
elif (( days > STALE_DAYS )); then
    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

Skills unused for 30 days get status: stale written into their front matter. It's neither a delete nor a move — just a flag. With that flag in place, the skill index can display

status: stale

, or the next cleanup phase can prioritize reviewing it.Skills unused for 90 days get mv'd to .archive/. It's

mv

, not rm

, so nothing disappears from the filesystem. You can check with ls ~/.claude/skills/auto/.archive/

and mv

them back if needed. This non-destructiveness matters: it fully covers the "I deleted it and then found out I needed it" scenario.Note that skills flagged as stale still count toward active++

. Stale is only a "needs attention" flag; the skill itself still lives under auto/

. If another 30 days pass by the next weekly run, it naturally graduates from stale to archive.

if [[ "$RUN_LLM" != "nollm" ]] && (( active >= 2 )) && [[ -x "$CLAUDE" ]]; then

When there are 2 or more active skills and the nollm

argument wasn't passed, it calls Claude to generate consolidation proposals for duplicate or low-quality skills.

STG=$(mktemp -d -t skill-curate-stg)
( cd "$STG" && perl -e 'alarm 600; exec @ARGV' "$CLAUDE" \
    --strict-mcp-config \
    --mcp-config '{"mcpServers":{}}' \
    -p "${AUTO} 配下の自動生成スキルのうち、前回提案ファイル ${PROP} より後に更新された
       SKILL.md のみを Read し、重複・低品質・統合候補を洗い出してください。..." \
    --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"

There are several design considerations here.

** --mcp-config '{"mcpServers":{}}' disables MCP.** Having network-dependent MCP servers suddenly spin up inside a weekly batch is a source of instability. Narrow it down to just reading skills and writing proposals.

** --max-budget-usd 5.00 caps the cost.** So Claude API billing can't run away, one proposal generation is limited to $5. There's no need to spend more than that on a task where losing the proposal file wouldn't hurt.

** perl -e 'alarm 600; exec @ARGV' sets a 600-second timeout.** A plain

timeout

command may not terminate the Claude process cleanly depending on how signals propagate. An exec

using Perl's alarm

reliably terminates subprocesses too.Claude writes curator-proposals.md into a staging directory ($STG) and the shell copies it out. Since

~/.claude/

can be write-protected in some cases, having Claude write there directly risks an error. Routing through a temporary directory sidesteps the permission problem.Only SKILL.md files newer than the previous proposal file are in scope. Re-reading every skill every week is a waste of tokens. Diffing against

prop_mtime

(the previous proposal file's mtime) and processing only newer SKILL.md

files minimizes the cost of the weekly run.For reference when reading the actual code, here are the main variables in the script.

Variable Value (from the actual code) Role
AUTO
~/.claude/skills/auto
Skill storage root
LOGS
~/Documents/my-knowledge-base/raw/conversations
Conversation log search target
SNAP
~/.claude/skills/auto/.snapshots
Snapshot destination
ARCH
~/.claude/skills/auto/.archive
Archive destination
LOG
~/.claude/skills/auto/.curate.log
Run log
PROP
~/.claude/skills/auto/.curator-proposals.md
LLM proposal output
STALE_DAYS
30
Stale flag threshold (days)
ARCHIVE_DAYS
90
Archive threshold (days)
RUN_LLM
1st argument, default "llm"
"nollm" skips the LLM phase

The launchd execution log is written to ~/.claude/logs/com.shun.skill-curate.log

(the plist's StandardOutPath

/ StandardErrorPath

). It's a separate file from the script's application log ($LOG

), and process-level errors from launchd startup land here.

This dual-log structure is effective because it lets you track "did the script start?" and "what did the script do?" separately. If nothing is written to the launchd log, the script never started. If the script log stops at snapshot taken

, something failed after that point.

That covers "why this works" and "how it runs." Next I'll dig into the design's biggest weakness — how to validate the "treat a log mention as usage" proxy metric, and the stumbles I actually hit.

set -u
export PATH="$HOME/.local/bin:$HOME/.nvm/versions/node/v24.13.0/bin:/usr/bin:/bin:/usr/sbin:/sbin"

set -u

halts the script the moment an undefined variable is referenced. It looks unglamorous, but without it an empty "$lastlog"

gets passed to float()

, Python silently returns 0, and every skill is treated as "last used: 1970." That one line prevents the accident where every skill gets archived overnight and you wake up to an empty skills directory.

export PATH

has a much more immediate reason. The shell launchd starts is a minimal environment, separate from the zsh you use every day. Neither ~/.zshrc

nor ~/.nvm/nvm.sh

gets loaded. That means the claude

command and node

are treated as "nonexistent" unless you spell them out in PATH. The same PATH is written in the plist (under <key>EnvironmentVariables</key>

), but writing it in the script too is defense in depth. The plist's PATH is an environment variable launchd hands to the process, but it may not carry over when the script launches a subshell. Writing export PATH

in both places looks redundant and is actually necessary.

find "$AUTO" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print 2>/dev/null

This find is a four-flag set for "enumerate only the skill directories directly under auto/

." Thinking through what happens when you drop each flag shows why they're all needed.

Drop -mindepth 1 and

$AUTO

itself matches, so the loop tries to process the whole auto/

directory as a single skill. It goes looking for auto/SKILL.md

, and if it isn't there it just continue

s — but noise piles up in the log.Drop -maxdepth 1 and already-archived skills under

.archive/

get scanned again. Skills you carefully archived enter an infinite loop of "no conversation-log hit, so archive again," and mv

stops with a "destination directory already exists" error.Drop -type d and files like

.curate.log

and .curator-proposals.md

match too. basename

takes the filename and the SKILL.md

existence check filters it out, so actual harm is zero — but the log fills with meaningless skip

entries.Drop ! -name '.*' and

.snapshots

and .archive

become scan targets. .snapshots

has no SKILL.md

so it gets continue

d, but the contents of .archive

are real (archived) skills. Any of them with author: auto

gets re-evaluated for stale/archive, and even though it's already in .archive/

, the script tries mv "$d" "$ARCH/"

again and the paths get mangled.2>/dev/null

silences macOS permission errors. If some files under ~/.claude/

are locked by another process, find

emits Permission denied

— and letting that into the log buries the actual curation log.

There are two places in the script where Python code is embedded via a <<'PY'

heredoc. The first question I got was "why not put it in a separate .py

file?"

The reason is self-containment in a single file. Drop just skill-curate.sh

into ~/.claude/scripts/

and it works. You don't need to separately manage where the Python script lives. When handing the skill curator setup to someone else, this one file is enough.

The single quotes in <<'PY'

are important. With <<PY

, $d

and $md

inside the heredoc would be expanded by the shell and the correct strings wouldn't reach Python. Quoting it passes the heredoc contents to Python as a literal string.

The reason day calculations are written in Python is equally clear: bash date arithmetic differs between macOS and GNU/Linux. date -d

is GNU, date -v

is BSD. Python's time.time()

and os.path.getmtime()

work cross-platform (this design is macOS-only for now, but it lowers future porting cost).

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)

Skills that already have a status:

field and those that don't are handled differently.

When status:

already exists it's a simple substitution. Whatever is written there — active

, experimental

, anything — is rewritten to status: stale

. count=1

replaces only the first occurrence, so it's safe even if the string status:

happens to appear in the body.

When there is no status:

, it's inserted on a new line right after author: auto

. Why right after author: auto

? YAML front matter is a block delimited by ---

, but the Python code doesn't parse with that block boundary in mind (it processes the file as a string with regex). Ideally you'd insert before the last line of the front matter (---

), but doing that requires locating the ---

and complicates the code. Since the safety guard guarantees author: auto

exists in the front matter, using it as the insertion point is the simplest and safest option.

I touched on these earlier; here's a deeper look at each.

--strict-mcp-config --mcp-config '{"mcpServers":{}}'

Omit this and launch Claude from launchd, and Claude reads ~/.claude/claude_desktop_config.json

or a similar MCP config and tries to start servers like Obsidian MCP or Figma MCP. Those require authentication, and in an environment without a UI they either wait forever or time out and fail. Overriding MCP with an empty object lets you start just Claude itself, simply.

perl -e 'alarm 600; exec @ARGV'

This looks almost identical to bash's timeout 600 claude ...

, but process group handling differs. On timeout, timeout

sends SIGTERM to its direct child process (here, the claude

command). But Claude may internally spawn multiple Node.js workers or subprocesses. SIGTERM reaches only the child, and grandchild processes can linger. perl -e 'alarm 600; exec @ARGV'

exec

s Claude under the same PID, so the signal reaches the entire process group.

The STG staging directory

STG=$(mktemp -d -t skill-curate-stg)
( cd "$STG" && ... "$CLAUDE" ... -p "... ./curator-proposals.md ..." )
[[ -f "$STG/curator-proposals.md" ]] && cp "$STG/curator-proposals.md" "$PROP"
rm -rf "$STG"

Having Claude write directly to ~/.claude/skills/auto/.curator-proposals.md

fails in the launchd environment in cases where ~/.claude/

is write-protected. By cd

-ing into a temp directory created with mktemp -d

before launching, Claude's current directory becomes $STG

. Write ./curator-proposals.md

(a relative path) in the instructions to Claude and it lands in $STG/curator-proposals.md

. On success, cp

puts it in its proper place, and rm -rf "$STG"

cleans up the temp directory.

active >= 2

threshold

if [[ "$RUN_LLM" != "nollm" ]] && (( active >= 2 )) && [[ -x "$CLAUDE" ]]; then

Why not call the LLM even with one skill? The job of "surface duplicates, low quality, and consolidation candidates" is meaningless without multiple things to compare. Reading a single skill and asking "is this low quality?" is technically possible, but it isn't worth the cost of a Claude API call. There's a weekly cap of $5 (--max-budget-usd 5.00

), but the best outcome is not calling at all.

The more active skills there are, the more valuable the LLM proposal becomes. Being told "these two have the same content and can be merged" across 10 skills is genuinely useful.

Symptom: manual execution in the terminal works fine. Register it with launchd, wait a week, and .curate.log

is still empty.

Cause: the claude

command wasn't found. The launchd environment's PATH is only /usr/bin:/bin:/usr/sbin:/sbin

. Neither ~/.local/bin/claude

nor the node

in ~/.nvm/versions/node/v24.13.0/bin/

is present. The script's [[ -x "$CLAUDE" ]]

existence check returned false and the LLM phase was skipped. But the curation phase uses python3

, and when that isn't found either, the days

calculation came out as zero (implicitly, with a non-zero exit code). The result: the snapshot got created, only snapshot taken

was written, and it stopped there.

Fix: write the full PATH into the plist's EnvironmentVariables

, and also export PATH

at the top of the script. Writing it in both places looks redundant, but the environment variables launchd passes and the variable re-exported inside the script are different layers. Having only one of them reproduced problems in certain environments, so the current script writes both, which has been stable.

Symptom: the .snapshots/

directory suddenly got heavy in week 3. ls -lh ~/.claude/skills/auto/.snapshots/

showed the newest tar.gz at 10× last week's size.

Cause: without --exclude

, .snapshots/

itself was included in the tar.gz. In other words, last week's snapshot (an archive inside the archive) got folded wholesale into this week's tar.gz. That compounds recursively every week. By week 3 it was a nested "snapshot of a snapshot of a snapshot" eating disk.

Fix:

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

Adding --exclude='auto/.snapshots'

and --exclude='auto/.archive'

solved it. tar's --exclude

takes paths as they appear inside the tar.gz. Because -C "$HOME/.claude/skills"

cd

s into the skills directory before archiving, the relative path auto/.snapshots

excludes correctly.

Deleting old snapshots isn't automated yet. The current practice is a manual monthly run of find ~/.claude/skills/auto/.snapshots -name '*.tar.gz' -mtime +60 -delete

. That's left as the next improvement.

timeout

left Claude processes lingering Symptom: the LLM phase didn't stop at 600 seconds, and ps aux | grep claude

still showed a Claude process at the next morning's launchd run. The following week's run and the previous week's run ran in parallel, and concurrent writes to $PROP

corrupted the file.

Cause: the first version wrote timeout 600 "$CLAUDE" ...

. On timeout, timeout

sends SIGTERM to its direct child. But the Claude CLI (depending on version) can spawn Node.js worker_threads or child processes internally, and SIGTERM reached only the parent while grandchildren survived.

Fix:

perl -e 'alarm 600; exec @ARGV' "$CLAUDE" ...

With exec @ARGV

, perl is replaced by Claude (perl's PID becomes Claude's PID), and the alarm

signal targets the entire process group. On top of that, treating the whole ( cd "$STG" && perl -e ... )

subshell as a process group makes lingering processes much less likely. Since this fix, no lingering processes have been observed the next morning.

Symptom: I created a skill named codex

, and it was permanently judged "in use (active)." grep -rl -- "codex"

over the conversation logs returned every file, so the newest mtime was always "this week's conversation log."

Cause: conversation logs contain countless everyday phrases like "throw it to Codex" or "implement it with Codex." The skill name codex

is too common as a word, and log search can't distinguish skill "usage" from a mere "mention."

Fix (and its limits): this isn't fundamentally solved. The current mitigation is to make skill names as specific and long as possible. Using a hyphenated compound like codex-delegation-handoff

instead of codex

makes grep hit with near-exact-match precision. grep -rl -- "codex-delegation-handoff"

almost never hits in general conversation logs.

By design, "using log mentions as a proxy for usage" is itself an approximation, and this precision is that approximation's structural limit. As long as a perfect usage log isn't available at the API level, covering it with a skill-naming convention is the best option for now.

Symptom: opening a certain skill's SKILL.md

, status: stale

had suddenly been written into the body rather than the front matter (the part enclosed by ---

). It's invalid as YAML, and the next grep-based check broke.

Cause: that skill's body (an illustrative section of the description) contained the string author: auto の場合は...

. The re.M

flag on the regex r'^(author:[ \t]*auto.*)$'

treats ^

as the start of every line. When the body's author: auto の場合は...

came before the front matter's author: auto

, the match landed there and \nstatus: stale

was inserted at that spot.

Fix: count=1

does constrain it to "only the first occurrence." Which means when the body comes first, the flag goes into the body and not into the front matter. The correct fix is "write a parser that only targets the front matter (from ---

to the first ---

)," but that adds code. The realistic mitigation right now is a convention: don't write the string author: auto in a skill body's illustrative code. YAML examples inside code blocks are wrapped in backticks so they aren't at line start and don't match

^

, but inline explanatory text needs care. A complete fix remains outstanding as a front matter parser implementation.Most of the time, automation fails because of assumptions about your tools. It works in the terminal, so it works under launchd. Send SIGTERM and it always stops. grep for the name and you can detect usage. Each of these looks intuitively correct and breaks in a real environment.

Maybe the real value of building this wasn't the weekly automated cleanup, but internalizing the thought process of designing while thinking hard, in advance, about where it will break. Every time I build something that runs unattended, the same question comes up. "When this fails at 4 in the morning, what will I look at when I wake up to figure out why?"

Continuing to answer that is the only way to raise the quality of an autonomous environment.

I dug into five stumbles above, but in real operation plenty of smaller pitfalls stack up too. Here's a comprehensive list from the actual code and lived experience.

** launchctl load is deprecated but still goes through**. On macOS Monterey and later, the correct registration is

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.shun.skill-curate.plist

. The old load

command sometimes accepts the job while calling itself "Deprecated," producing non-reproducible symptoms where nothing starts. Check registration state with launchctl list | grep skill-curate

and look at the PID

column. A zero value means "registered but never started."If the $LOGS directory is empty or missing, every skill is treated as unused. Line 9 of the script defines

LOGS="$HOME/Documents/my-knowledge-base/raw/conversations"

. If that path doesn't exist, grep -rl -- "$skill" "$LOGS"

returns zero hits and lastlog

becomes an empty string. set -u

is an instruction that stops on "reference to an undefined variable" — it doesn't react to "a variable holding an empty string." Python falls back to created:

mtime

, but since that treats the skill's creation date as its last-used date, every skill goes stale at once if 30 days have passed since creation. I should have rejected this up front with an existence check on the log path: [[ -d "$LOGS" ]] || { echo "LOGS missing"; exit 1; }

.Binaries mixed into the conversation logs cause grep false positives. If .png

files or attachments end up in the log directory, grep -rl -- "skill-name"

matches binaries too. The current grep

has no --include='*.md'

or --include='*.txt'

. The design assumes the conversation log format is pure text. Point $LOGS

at a directory containing binaries and skills will be perpetually misjudged as "recently used."

** stat -f '%m' is macOS (BSD)-only syntax**. It's used on line 40 of the script. GNU/Linux's

stat --format=%Y

is written differently. Since this is launchd-based and fixed to macOS, actual harm is zero, but you'd need to rewrite it to carry the same script into a Docker container or a Linux server. It's asymmetric with the Python side, which is written cross-platform via os.path.getmtime()

.Remove Python's defensive sys.argv index padding and it crashes with IndexError. Line 44 of the actual code:

  lastlog, created, md = (sys.argv + ["","",""])[1:4]

Without the + ["","",""]

padding, a single missing argument on the bash side raises IndexError

and exits non-zero. If days

is empty when it hits if (( days > ARCHIVE_DAYS ))

, an arithmetic expression error stops the whole script there. Making the Python side crash-proof lets the bash side's defensive line stay thin while remaining safe.

If --max-budget-usd 5.00 cuts things off mid-run, proposals.md is incomplete. When the LLM hits the $5 cap, Claude is force-terminated and

$STG/curator-proposals.md

is left as partially written Markdown. The script only checks that the file [[ -f "$STG/curator-proposals.md" ]]

before cp

, so a corrupted file overwrites the good one. There's a risk of missing important proposals, but without a cap the weekly batch's API cost is unbounded. $5 is set as "the cap at which a broken proposal file doesn't hurt."** com.shun.skill-curate.log has no rotation configured**. The plist's

StandardOutPath

and StandardErrorPath

point at the same file, ~/.claude/logs/com.shun.skill-curate.log

(per the plist implementation). Since it's appended to on every weekly run, leaving it a year gives you tens of MB. macOS newsyslog

configuration isn't implemented either. Current practice is checking only the recent portion with tail -100 ~/.claude/logs/com.shun.skill-curate.log

.Snapshot deletion isn't automated, so they pile up. The current script only creates snapshots, never deletes them. A manual monthly run is required:

  find ~/.claude/skills/auto/.snapshots -name '*.tar.gz' -mtime +60 -delete

Wiring this one line into a monthly launchd job (omit Weekday

in the plist and use Day: 1

= the 1st of each month) is the next improvement step.

Concurrent launchd runs cause write contention on .curator-proposals.md. If the LLM phase exceeds 600 seconds and the next week's

StartCalendarInterval

fires, two instances run in parallel. .curate.log

is appended to, so the file doesn't break, but two processes run cp

against the $PROP

file simultaneously. The current implementation has no flock

lock file. The clue that this happened is two consecutive curate done

lines in .curate.log

.Forget to change author: and skills get archived unintentionally. Protecting a skill you want to keep only requires changing the

author:

field to something other than auto

(e.g. author: manual

). But put it off as "I'll change it later" and you'll forget. Ninety days later it silently moves to .archive/

and you don't notice. You need the habit of checking author:

right after creating a skill.Skip validating the proxy metric and you won't notice misjudgments. Periodically checking how accurately the "treat log mentions as usage" approximation is working matters. Validation takes one command:

  grep -rl -- "skill-name" ~/Documents/my-knowledge-base/raw/conversations/ | wc -l

Zero hits means either "it's genuinely unused" or "the skill name isn't written in the logs." Even at zero, an archive move can always be undone from .snapshots/

, so low precision is recoverable.

Reproducible guidelines derived from building and running this.

1. Make skill names specific, long, and hyphen-separated

codex-delegation-handoff

over codex

. grep -rl -- "codex-delegation-handoff"

almost never hits in general conversation logs. Half of the proxy metric's accuracy is decided by your naming convention. When creating a new skill, the habit of first checking whether the skill name is a common English word

is the foundation of that accuracy.

2. Make the author: auto flag the single flag of the protection mechanism

Keep exactly one kind of flag marking "machine-generated, subject to cleanup." Anything else — author: manual

, author: lily

, whatever — is unconditionally skipped by the safety guard on lines 34–37 of the actual code as long as it isn't auto

. Adding more flags or complicating the condition widens the blast radius when the protection logic breaks.

3. set -u is the first line of any batch script

If an undefined variable reaches float()

, Python silently returns 0 and every skill is treated as "unused since 1970." With set -u

, referencing an undefined variable halts the script immediately and leaves a line number in the log. Investigation time drops from 10 minutes to 30 seconds.

4. Write PATH in both the plist and the script

The plist's EnvironmentVariables/PATH

is the environment variable launchd passes to the process. The export PATH

at the top of the script is a re-export so it carries into subshells. With only one of them, the symptom "claude/python3 not found in a subshell in certain environments" reproduces. The current plist and the top of skill-curate.sh

both write the same path.

5. Use perl -e 'alarm 600; exec @ARGV' to enforce a real timeout

bash's timeout

sends SIGTERM to its direct child. If Claude spawns worker_threads or subprocesses internally, grandchildren survive. perl alarm exec

replaces perl with Claude (exec

under the same PID), so the signal reaches the whole process group. It prevents the situation where Claude is still lingering at the next week's launchd run.

6. Have the LLM write from the current directory of a staging dir

Create a temp directory with mktemp -d

, cd

into it, then launch Claude. Writing ./curator-proposals.md

(a relative path) in the instructions to Claude lets you receive output without granting direct write permission to ~/.claude/

. Confirm the file exists with [[ -f "$STG/curator-proposals.md" ]]

, then cp

, then clean up with rm -rf "$STG"

. Those three steps are the standard pattern for going through staging.

7. Cap LLM cost with --max-budget-usd 5.00

The weekly batch's LLM phase only "writes proposals" — it doesn't actually modify skills. Even if the proposal file is incomplete, it reruns next week. This task doesn't need more than $5 of billing. Set the cap at "the maximum cost at which failure doesn't hurt."

8. Disable MCP with --strict-mcp-config --mcp-config '{"mcpServers":{}}'

Launch Claude from launchd and interactive MCP servers end up waiting for authentication. In an environment with no UI they either wait forever or time out and take the whole LLM phase down with them. Overriding with an empty MCP config starts Claude itself cleanly. A weekly batch has no need to connect to external services.

9. Don't break the four-flag find set

find "$AUTO" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print

-mindepth 1

(exclude $AUTO

itself), -maxdepth 1

(prevent rescanning under .archive/

), -type d

(don't mistake files for skills), ! -name '.*'

(don't scan .snapshots

and .archive

). Drop even one of the four and you create a bug that looks harmless and is hard to notice later.

10. Snapshot --exclude must cover both .snapshots and .archive

Write only one and you get either recursive bloat or double-archiving of already-archived skills. Specify both explicitly, as in lines 24–26 of the actual code:

tar czf "..." --exclude='auto/.snapshots' --exclude='auto/.archive' auto

Since -C "$HOME/.claude/skills"

moves the working directory before specifying auto

, the --exclude

paths are in the relative form auto/.snapshots

.

11. Leave a nollm argument as a hatch for LLM-free testing

RUN_LLM="${1:-llm}"

lets you skip the LLM phase when the first argument is nollm

. For initial setup or verification after a config change, running ~/.claude/scripts/skill-curate.sh nollm

validates just snapshot creation and stale/archive decisions. Since it doesn't call the Claude API, there's zero risk in cost, time, or lingering processes.

12. Use a dual-log design to separate "did it start?" from "what did it do?"

The launchd log (~/.claude/logs/com.shun.skill-curate.log

) records process-launch-level errors. The script's application log (~/.claude/skills/auto/.curate.log

) records snapshot creation, stale decisions, and archive operations. Diagnosis narrows down in two stages: "nothing in the launchd log → the script never started," "launchd log exists but the app log stops at snapshot taken

→ the error is after that point."

13. Keep the restore commands noted in ~/.claude/scripts/

"I want to bring back an archived skill" will definitely happen during operation. Keep the commands on hand so you don't have to work them out on the spot:

tar xzf ~/.claude/skills/auto/.snapshots/auto-YYYYMMDD-HHMMSS.tar.gz \
  -C ~/.claude/skills/

mv ~/.claude/skills/auto/.archive/skill-name ~/.claude/skills/auto/

14. Cover proxy-metric misjudgments with the non-destructiveness of .archive/

The accuracy of the "treat log mentions as usage" approximation is not perfect. As an honest assessment of the design, this approximation reflects the judgment to "choose an operable metric over a perfect usage log." A skill archived by mistake comes back with mv

in one second. Lock down reversibility before precision. That's the basic stance for designing autonomous batches.

Boiled down, what skill-curate.sh

and com.shun.skill-curate.plist

do comes to three things: take a snapshot before running, never touch anything but author: auto, and archive by moving rather than deleting. Because of those three principles, I can keep running it weekly even on a low-precision proxy metric.

I use the proxy metric despite knowing its limits because "an imperfect mechanism that runs every week" is worth more than "a perfect design." Snapshots accumulate weekly, stale flags get updated, LLM proposals get written out. Look at the log and you know what happened last week. That's an autonomous environment's "memory."

Claude Code accumulates knowledge the more you use it. But without a mechanism to organize that knowledge, it eventually starts eating your performance in the form of context pressure. Automating auto-skill curation is a meta layer of "maintaining the environment's environment." Most of the ¥1.2M/month work is building mechanisms that automate the next 100 tasks rather than completing individual ones. This weekly script is one of the plainest and most effective implementations of that idea.

The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day playbook are collected in a paid note.

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート

*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.

Follow along: Portfolio · X · GitHub*

── more in #ai-agents 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/dead-auto-skills-wer…] indexed:0 read:30min 2026-08-16 ·