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

> Source: <https://dev.to/bokuwalily/dead-auto-skills-were-padding-every-conversation-a-weekly-curator-that-flags-at-30-days-and-lij>
> Published: 2026-08-16 05:00:06+00:00

"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.

```
# Pythonインラインスクリプトより（skill-curate.sh 42-56行目）
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
    # SKILL.md の status フィールドを stale に書き換える
    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/

# 特定スキルだけ .archive/ から戻す
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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

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

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
