{"slug": "dead-auto-skills-were-padding-every-conversation-a-weekly-curator-that-flags-at", "title": "Dead Auto-Skills Were Padding Every Conversation: A Weekly Curator That Flags at 30 Days and Archives at 90", "summary": "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.", "body_md": "\"Claude Code gets smarter the more you use it\" is only half the story. The other half is that it gets heavier.\n\nSix 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.\n\nClaude Code has a mechanism I call `auto-skill`\n\n. 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`\n\n.\n\nThe 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.\n\nBut there's a problem.\n\nClaude 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.\n\nConcretely, in Lily's own environment: under `~/.claude/skills/auto/`\n\neach skill sits as a directory, and the `SKILL.md`\n\ninside carries an `author: auto`\n\nfront matter field. Manual skills that lack this `author: auto`\n\nfield 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.**\n\nMost 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.\n\nBut 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.\n\nLet's trace what happens as skills accumulate.\n\n`~/.claude/CLAUDE.md`\n\nhas a section called `スキル自己生成（auto-skills）`\n\n(auto-skill self-generation) that says **\"write reusable procedures yourself without being asked.\"** As long as that instruction is live, skills multiply naturally.\n\nThe 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.\n\n**The design has three core ideas.**\n\n`grep`\n\nfor the skill name across conversation log files in `~/Documents/my-knowledge-base/raw/conversations/`\n\nand treat the newest matching file's mtime as the \"last used\" date.`.archive/`\n\n`status: stale`\n\nto the front matter; archive is an `mv`\n\nto another directory.\n\n```\n[毎週日曜 4:15 AM]\n      ↓\n com.shun.skill-curate (launchd)\n      ↓\n skill-curate.sh\n      │\n      ├─ ① スナップショット取得\n      │    ~/.claude/skills/auto/.snapshots/\n      │    auto-YYYYMMDD-HHMMSS.tar.gz\n      │\n      ├─ ② auto/配下を全スキルスキャン\n      │    author: auto でないものはスキップ\n      │\n      ├─ ③ 最終使用日の算出\n      │    会話ログ grep → mtime\n      │    → created: フロントマター\n      │    → SKILL.md のファイルmtime\n      │\n      ├─ ④ 日数判定\n      │    > 90日 → .archive/ へ mv（非破壊）\n      │    > 30日 → status: stale を追記\n      │    それ以外 → active カウント++\n      │\n      └─ ⑤ LLM統合提案（オプション）\n           active ≥ 2 のとき Claude を呼び出し\n           重複・低品質候補を .curator-proposals.md に書き出し\n           実スキルは変更しない（提案のみ）\n```\n\nThe launchd plist is configured like this:\n\n```\n<key>StartCalendarInterval</key>\n<dict>\n    <key>Hour</key>\n    <integer>4</integer>\n    <key>Minute</key>\n    <integer>15</integer>\n    <key>Weekday</key>\n    <integer>0</integer>\n</dict>\n```\n\n`Weekday: 0`\n\nis Sunday, and with `Hour: 4`\n\n/ `Minute: 15`\n\nit fires **every Sunday at 4:15 AM**. `LowPriorityIO: true`\n\nand `Nice: 10`\n\nmake it a lowest-priority background run. It cleans up while you sleep. Human cost is zero.\n\nThe script takes a snapshot right at the top.\n\n```\nAUTO=\"$HOME/.claude/skills/auto\"\nSNAP=\"$AUTO/.snapshots\"\n\ntar czf \"$SNAP/auto-$(date +%Y%m%d-%H%M%S).tar.gz\" \\\n  -C \"$HOME/.claude/skills\" \\\n  --exclude='auto/.snapshots' \\\n  --exclude='auto/.archive' \\\n  auto 2>/dev/null \\\n  && echo \"[$(ts)] snapshot taken\" >> \"$LOG\"\n```\n\n`.snapshots/`\n\nand `.archive/`\n\nthemselves are excluded from the compression. Without that, you get the recursion problem of archives inside archives. Since `date +%Y%m%d-%H%M%S`\n\ngives the file a timestamped name, snapshots accumulate week over week.\n\nRestoring is simple: just run `tar xzf ~/.claude/skills/auto/.snapshots/auto-20260803-041500.tar.gz -C ~/.claude/skills/`\n\n. The fact that snapshots keep piling up weekly means you need a separate `find`\n\n-based cleanup routine, but that's the next problem.\n\n```\nif ! grep -q '^author:[[:space:]]*auto' \"$md\"; then\n    echo \"[$(ts)] skip (not author:auto): $skill\" >> \"$LOG\"\n    continue\nfi\n```\n\nThanks to this, skills you carefully cultivated by hand are never accidentally archived. The `author: auto`\n\nfront 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:`\n\nfield to something other than `auto`\n\n. Simple and strong.\n\nThis 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.\n\n```\nlastlog=$(grep -rl -- \"$skill\" \"$LOGS\" 2>/dev/null \\\n  | while read f; do stat -f '%m' \"$f\" 2>/dev/null; done \\\n  | sort -rn | head -1)\n```\n\nThis full-text searches `LOGS=\"$HOME/Documents/my-knowledge-base/raw/conversations/\"`\n\nand retrieves, as a Unix timestamp, the mtime of the newest file containing the skill name.\n\nIf nothing is found, there are two fallback stages.\n\n```\n# Pythonインラインスクリプトより（skill-curate.sh 42-56行目）\nref = None\nif lastlog.strip():\n    try: ref = float(lastlog)\n    except: ref = None\nif ref is None and created.strip():\n    try: ref = time.mktime(datetime.datetime.strptime(\n            created.strip(), \"%Y-%m-%d\").timetuple())\n    except: ref = None\nif ref is None:\n    ref = os.path.getmtime(md)\nprint(int((time.time() - ref) // 86400))\n```\n\nThe order is ① conversation log mtime → ② the `created:`\n\nfront matter in `SKILL.md`\n\n→ ③ the mtime of the `SKILL.md`\n\nfile itself. Even for old skills with no `created:`\n\nfield, the file mtime is the last line of defense.\n\n**That said, this proxy metric has a structural limitation.**\n\n\"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`\n\n) and the search pattern doesn't match, it won't be picked up.\n\nKnowing 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.\n\n```\nSTALE_DAYS=30\nARCHIVE_DAYS=90\n\nif (( days > ARCHIVE_DAYS )); then\n    mv \"$d\" \"$ARCH/\" && echo \"[$(ts)] ARCHIVED (${days}d unused): $skill\" >> \"$LOG\"\nelif (( days > STALE_DAYS )); then\n    # SKILL.md の status フィールドを stale に書き換える\n    python3 - \"$md\" <<'PY'\nimport sys, re\np = sys.argv[1]; s = open(p).read()\nif re.search(r'^status:', s, re.M):\n    s = re.sub(r'^status:.*$', 'status: stale', s, count=1, flags=re.M)\nelse:\n    s = re.sub(r'^(author:[ \\t]*auto.*)$', r'\\1\\nstatus: stale',\n               s, count=1, flags=re.M)\nopen(p, 'w').write(s)\nPY\n    echo \"[$(ts)] stale (${days}d unused): $skill\" >> \"$LOG\"\n    ((active++))\nelse\n    ((active++))\nfi\n```\n\n**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\n\n`status: stale`\n\n, or the next cleanup phase can prioritize reviewing it.**Skills unused for 90 days get mv'd to .archive/.** It's\n\n`mv`\n\n, not `rm`\n\n, so nothing disappears from the filesystem. You can check with `ls ~/.claude/skills/auto/.archive/`\n\nand `mv`\n\nthem 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++`\n\n. Stale is only a \"needs attention\" flag; the skill itself still lives under `auto/`\n\n. If another 30 days pass by the next weekly run, it naturally graduates from stale to archive.\n\n```\nif [[ \"$RUN_LLM\" != \"nollm\" ]] && (( active >= 2 )) && [[ -x \"$CLAUDE\" ]]; then\n```\n\nWhen there are 2 or more active skills and the `nollm`\n\nargument wasn't passed, it calls Claude to generate consolidation proposals for duplicate or low-quality skills.\n\n```\nSTG=$(mktemp -d -t skill-curate-stg)\n( cd \"$STG\" && perl -e 'alarm 600; exec @ARGV' \"$CLAUDE\" \\\n    --strict-mcp-config \\\n    --mcp-config '{\"mcpServers\":{}}' \\\n    -p \"${AUTO} 配下の自動生成スキルのうち、前回提案ファイル ${PROP} より後に更新された\n       SKILL.md のみを Read し、重複・低品質・統合候補を洗い出してください。...\" \\\n    --model sonnet \\\n    --permission-mode acceptEdits \\\n    --allowedTools \"Write Edit Read\" \\\n    --add-dir \"$AUTO\" \\\n    --max-budget-usd 5.00 >> \"$LOG\" 2>&1 < /dev/null )\n[[ -f \"$STG/curator-proposals.md\" ]] && cp \"$STG/curator-proposals.md\" \"$PROP\"\n```\n\nThere are several design considerations here.\n\n** --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.\n\n** --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.\n\n** perl -e 'alarm 600; exec @ARGV' sets a 600-second timeout.** A plain\n\n`timeout`\n\ncommand may not terminate the Claude process cleanly depending on how signals propagate. An `exec`\n\nusing Perl's `alarm`\n\nreliably terminates subprocesses too.**Claude writes curator-proposals.md into a staging directory ($STG) and the shell copies it out.** Since\n\n`~/.claude/`\n\ncan 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\n\n`prop_mtime`\n\n(the previous proposal file's mtime) and processing only newer `SKILL.md`\n\nfiles minimizes the cost of the weekly run.For reference when reading the actual code, here are the main variables in the script.\n\n| Variable | Value (from the actual code) | Role |\n|---|---|---|\n`AUTO` |\n`~/.claude/skills/auto` |\nSkill storage root |\n`LOGS` |\n`~/Documents/my-knowledge-base/raw/conversations` |\nConversation log search target |\n`SNAP` |\n`~/.claude/skills/auto/.snapshots` |\nSnapshot destination |\n`ARCH` |\n`~/.claude/skills/auto/.archive` |\nArchive destination |\n`LOG` |\n`~/.claude/skills/auto/.curate.log` |\nRun log |\n`PROP` |\n`~/.claude/skills/auto/.curator-proposals.md` |\nLLM proposal output |\n`STALE_DAYS` |\n`30` |\nStale flag threshold (days) |\n`ARCHIVE_DAYS` |\n`90` |\nArchive threshold (days) |\n`RUN_LLM` |\n1st argument, default `\"llm\"`\n|\n`\"nollm\"` skips the LLM phase |\n\nThe launchd execution log is written to `~/.claude/logs/com.shun.skill-curate.log`\n\n(the plist's `StandardOutPath`\n\n/ `StandardErrorPath`\n\n). It's a separate file from the script's application log (`$LOG`\n\n), and process-level errors from launchd startup land here.\n\nThis 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`\n\n, something failed after that point.\n\nThat 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.**\n\n```\nset -u\nexport PATH=\"$HOME/.local/bin:$HOME/.nvm/versions/node/v24.13.0/bin:/usr/bin:/bin:/usr/sbin:/sbin\"\n```\n\n`set -u`\n\nhalts the script the moment an undefined variable is referenced. It looks unglamorous, but without it an empty `\"$lastlog\"`\n\ngets passed to `float()`\n\n, 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.\n\n`export PATH`\n\nhas a much more immediate reason. The shell launchd starts is a minimal environment, separate from the zsh you use every day. Neither `~/.zshrc`\n\nnor `~/.nvm/nvm.sh`\n\ngets loaded. That means the `claude`\n\ncommand and `node`\n\nare treated as \"nonexistent\" unless you spell them out in PATH. The same PATH is written in the plist (under `<key>EnvironmentVariables</key>`\n\n), 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`\n\nin both places looks redundant and is actually necessary.\n\n```\nfind \"$AUTO\" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print 2>/dev/null\n```\n\nThis find is a four-flag set for \"enumerate only the skill directories directly under `auto/`\n\n.\" Thinking through what happens when you drop each flag shows why they're all needed.\n\n**Drop -mindepth 1** and\n\n`$AUTO`\n\nitself matches, so the loop tries to process the whole `auto/`\n\ndirectory as a single skill. It goes looking for `auto/SKILL.md`\n\n, and if it isn't there it just `continue`\n\ns — but noise piles up in the log.**Drop -maxdepth 1** and already-archived skills under\n\n`.archive/`\n\nget scanned again. Skills you carefully archived enter an infinite loop of \"no conversation-log hit, so archive again,\" and `mv`\n\nstops with a \"destination directory already exists\" error.**Drop -type d** and files like\n\n`.curate.log`\n\nand `.curator-proposals.md`\n\nmatch too. `basename`\n\ntakes the filename and the `SKILL.md`\n\nexistence check filters it out, so actual harm is zero — but the log fills with meaningless `skip`\n\nentries.**Drop ! -name '.*'** and\n\n`.snapshots`\n\nand `.archive`\n\nbecome scan targets. `.snapshots`\n\nhas no `SKILL.md`\n\nso it gets `continue`\n\nd, but the contents of `.archive`\n\nare real (archived) skills. Any of them with `author: auto`\n\ngets re-evaluated for stale/archive, and even though it's already in `.archive/`\n\n, the script tries `mv \"$d\" \"$ARCH/\"`\n\nagain and the paths get mangled.`2>/dev/null`\n\nsilences macOS permission errors. If some files under `~/.claude/`\n\nare locked by another process, `find`\n\nemits `Permission denied`\n\n— and letting that into the log buries the actual curation log.\n\nThere are two places in the script where Python code is embedded via a `<<'PY'`\n\nheredoc. The first question I got was \"why not put it in a separate `.py`\n\nfile?\"\n\nThe reason is **self-containment in a single file**. Drop just `skill-curate.sh`\n\ninto `~/.claude/scripts/`\n\nand 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.\n\nThe single quotes in `<<'PY'`\n\nare important. With `<<PY`\n\n, `$d`\n\nand `$md`\n\ninside 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.\n\n**The reason day calculations are written in Python** is equally clear: bash date arithmetic differs between macOS and GNU/Linux. `date -d`\n\nis GNU, `date -v`\n\nis BSD. Python's `time.time()`\n\nand `os.path.getmtime()`\n\nwork cross-platform (this design is macOS-only for now, but it lowers future porting cost).\n\n```\nif re.search(r'^status:', s, re.M):\n    s = re.sub(r'^status:.*$', 'status: stale', s, count=1, flags=re.M)\nelse:\n    s = re.sub(r'^(author:[ \\t]*auto.*)$', r'\\1\\nstatus: stale',\n               s, count=1, flags=re.M)\n```\n\nSkills that already have a `status:`\n\nfield and those that don't are handled differently.\n\nWhen `status:`\n\nalready exists it's a simple substitution. Whatever is written there — `active`\n\n, `experimental`\n\n, anything — is rewritten to `status: stale`\n\n. `count=1`\n\nreplaces only the first occurrence, so it's safe even if the string `status:`\n\nhappens to appear in the body.\n\nWhen there is no `status:`\n\n, it's inserted on a new line right after `author: auto`\n\n. Why right after `author: auto`\n\n? YAML front matter is a block delimited by `---`\n\n, 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 (`---`\n\n), but doing that requires locating the `---`\n\nand complicates the code. Since the safety guard guarantees `author: auto`\n\nexists in the front matter, using it as the insertion point is the simplest and safest option.\n\nI touched on these earlier; here's a deeper look at each.\n\n`--strict-mcp-config --mcp-config '{\"mcpServers\":{}}'`\n\nOmit this and launch Claude from launchd, and Claude reads `~/.claude/claude_desktop_config.json`\n\nor 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.\n\n`perl -e 'alarm 600; exec @ARGV'`\n\nThis looks almost identical to bash's `timeout 600 claude ...`\n\n, but process group handling differs. On timeout, `timeout`\n\nsends SIGTERM to its direct child process (here, the `claude`\n\ncommand). 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'`\n\n`exec`\n\ns Claude under the same PID, so the signal reaches the entire process group.\n\n**The STG staging directory**\n\n```\nSTG=$(mktemp -d -t skill-curate-stg)\n( cd \"$STG\" && ... \"$CLAUDE\" ... -p \"... ./curator-proposals.md ...\" )\n[[ -f \"$STG/curator-proposals.md\" ]] && cp \"$STG/curator-proposals.md\" \"$PROP\"\nrm -rf \"$STG\"\n```\n\nHaving Claude write directly to `~/.claude/skills/auto/.curator-proposals.md`\n\nfails in the launchd environment in cases where `~/.claude/`\n\nis write-protected. By `cd`\n\n-ing into a temp directory created with `mktemp -d`\n\nbefore launching, Claude's current directory becomes `$STG`\n\n. Write `./curator-proposals.md`\n\n(a relative path) in the instructions to Claude and it lands in `$STG/curator-proposals.md`\n\n. On success, `cp`\n\nputs it in its proper place, and `rm -rf \"$STG\"`\n\ncleans up the temp directory.\n\n`active >= 2`\n\nthreshold\n\n```\nif [[ \"$RUN_LLM\" != \"nollm\" ]] && (( active >= 2 )) && [[ -x \"$CLAUDE\" ]]; then\n```\n\nWhy 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`\n\n), but the best outcome is not calling at all.\n\nThe 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.\n\n**Symptom**: manual execution in the terminal works fine. Register it with launchd, wait a week, and `.curate.log`\n\nis still empty.\n\n**Cause**: the `claude`\n\ncommand wasn't found. The launchd environment's PATH is only `/usr/bin:/bin:/usr/sbin:/sbin`\n\n. Neither `~/.local/bin/claude`\n\nnor the `node`\n\nin `~/.nvm/versions/node/v24.13.0/bin/`\n\nis present. The script's `[[ -x \"$CLAUDE\" ]]`\n\nexistence check returned false and the LLM phase was skipped. But the curation phase uses `python3`\n\n, and when that isn't found either, the `days`\n\ncalculation came out as zero (implicitly, with a non-zero exit code). The result: the snapshot got created, only `snapshot taken`\n\nwas written, and it stopped there.\n\n**Fix**: write the full PATH into the plist's `EnvironmentVariables`\n\n, and also `export PATH`\n\nat 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.\n\n**Symptom**: the `.snapshots/`\n\ndirectory suddenly got heavy in week 3. `ls -lh ~/.claude/skills/auto/.snapshots/`\n\nshowed the newest tar.gz at 10× last week's size.\n\n**Cause**: without `--exclude`\n\n, `.snapshots/`\n\nitself 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.\n\n**Fix**:\n\n```\ntar czf \"$SNAP/auto-$(date +%Y%m%d-%H%M%S).tar.gz\" \\\n  -C \"$HOME/.claude/skills\" \\\n  --exclude='auto/.snapshots' \\\n  --exclude='auto/.archive' \\\n  auto 2>/dev/null\n```\n\nAdding `--exclude='auto/.snapshots'`\n\nand `--exclude='auto/.archive'`\n\nsolved it. tar's `--exclude`\n\ntakes paths as they appear inside the tar.gz. Because `-C \"$HOME/.claude/skills\"`\n\n`cd`\n\ns into the skills directory before archiving, the relative path `auto/.snapshots`\n\nexcludes correctly.\n\nDeleting 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`\n\n. That's left as the next improvement.\n\n`timeout`\n\nleft Claude processes lingering\n**Symptom**: the LLM phase didn't stop at 600 seconds, and `ps aux | grep claude`\n\nstill 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`\n\ncorrupted the file.\n\n**Cause**: the first version wrote `timeout 600 \"$CLAUDE\" ...`\n\n. On timeout, `timeout`\n\nsends 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.\n\n**Fix**:\n\n```\nperl -e 'alarm 600; exec @ARGV' \"$CLAUDE\" ...\n```\n\nWith `exec @ARGV`\n\n, perl is replaced by Claude (perl's PID becomes Claude's PID), and the `alarm`\n\nsignal targets the entire process group. On top of that, treating the whole `( cd \"$STG\" && perl -e ... )`\n\nsubshell as a process group makes lingering processes much less likely. Since this fix, no lingering processes have been observed the next morning.\n\n**Symptom**: I created a skill named `codex`\n\n, and it was permanently judged \"in use (active).\" `grep -rl -- \"codex\"`\n\nover the conversation logs returned every file, so the newest mtime was always \"this week's conversation log.\"\n\n**Cause**: conversation logs contain countless everyday phrases like \"throw it to Codex\" or \"implement it with Codex.\" The skill name `codex`\n\nis too common as a word, and log search can't distinguish skill \"usage\" from a mere \"mention.\"\n\n**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`\n\ninstead of `codex`\n\nmakes grep hit with near-exact-match precision. `grep -rl -- \"codex-delegation-handoff\"`\n\nalmost never hits in general conversation logs.\n\nBy 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.\n\n**Symptom**: opening a certain skill's `SKILL.md`\n\n, `status: stale`\n\nhad suddenly been written into the body rather than the front matter (the part enclosed by `---`\n\n). It's invalid as YAML, and the next grep-based check broke.\n\n**Cause**: that skill's body (an illustrative section of the description) contained the string `author: auto の場合は...`\n\n. The `re.M`\n\nflag on the regex `r'^(author:[ \\t]*auto.*)$'`\n\ntreats `^`\n\nas the start of every line. When the body's `author: auto の場合は...`\n\ncame before the front matter's `author: auto`\n\n, the match landed there and `\\nstatus: stale`\n\nwas inserted at that spot.\n\n**Fix**: `count=1`\n\ndoes 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 `---`\n\nto the first `---`\n\n),\" 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\n\n`^`\n\n, 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.\n\nMaybe 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?\"**\n\nContinuing to answer that is the only way to raise the quality of an autonomous environment.\n\nI 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.\n\n** launchctl load is deprecated but still goes through**. On macOS Monterey and later, the correct registration is\n\n`launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.shun.skill-curate.plist`\n\n. The old `load`\n\ncommand sometimes accepts the job while calling itself \"Deprecated,\" producing non-reproducible symptoms where nothing starts. Check registration state with `launchctl list | grep skill-curate`\n\nand look at the `PID`\n\ncolumn. 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\n\n`LOGS=\"$HOME/Documents/my-knowledge-base/raw/conversations\"`\n\n. If that path doesn't exist, `grep -rl -- \"$skill\" \"$LOGS\"`\n\nreturns zero hits and `lastlog`\n\nbecomes an empty string. `set -u`\n\nis 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:`\n\n→ `mtime`\n\n, 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; }`\n\n.**Binaries mixed into the conversation logs cause grep false positives**. If `.png`\n\nfiles or attachments end up in the log directory, `grep -rl -- \"skill-name\"`\n\nmatches binaries too. The current `grep`\n\nhas no `--include='*.md'`\n\nor `--include='*.txt'`\n\n. The design assumes the conversation log format is pure text. Point `$LOGS`\n\nat a directory containing binaries and skills will be perpetually misjudged as \"recently used.\"\n\n** stat -f '%m' is macOS (BSD)-only syntax**. It's used on line 40 of the script. GNU/Linux's\n\n`stat --format=%Y`\n\nis 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()`\n\n.**Remove Python's defensive sys.argv index padding and it crashes with IndexError**. Line 44 of the actual code:\n\n```\n  lastlog, created, md = (sys.argv + [\"\",\"\",\"\"])[1:4]\n```\n\nWithout the `+ [\"\",\"\",\"\"]`\n\npadding, a single missing argument on the bash side raises `IndexError`\n\nand exits non-zero. If `days`\n\nis empty when it hits `if (( days > ARCHIVE_DAYS ))`\n\n, 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.\n\n**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\n\n`$STG/curator-proposals.md`\n\nis left as partially written Markdown. The script only checks that the file `[[ -f \"$STG/curator-proposals.md\" ]]`\n\nbefore `cp`\n\n, 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\n\n`StandardOutPath`\n\nand `StandardErrorPath`\n\npoint at the same file, `~/.claude/logs/com.shun.skill-curate.log`\n\n(per the plist implementation). Since it's appended to on every weekly run, leaving it a year gives you tens of MB. macOS `newsyslog`\n\nconfiguration isn't implemented either. Current practice is checking only the recent portion with `tail -100 ~/.claude/logs/com.shun.skill-curate.log`\n\n.**Snapshot deletion isn't automated, so they pile up**. The current script only creates snapshots, never deletes them. A manual monthly run is required:\n\n```\n  find ~/.claude/skills/auto/.snapshots -name '*.tar.gz' -mtime +60 -delete\n```\n\nWiring this one line into a monthly launchd job (omit `Weekday`\n\nin the plist and use `Day: 1`\n\n= the 1st of each month) is the next improvement step.\n\n**Concurrent launchd runs cause write contention on .curator-proposals.md**. If the LLM phase exceeds 600 seconds and the next week's\n\n`StartCalendarInterval`\n\nfires, two instances run in parallel. `.curate.log`\n\nis appended to, so the file doesn't break, but two processes run `cp`\n\nagainst the `$PROP`\n\nfile simultaneously. The current implementation has no `flock`\n\nlock file. The clue that this happened is two consecutive `curate done`\n\nlines in `.curate.log`\n\n.**Forget to change author: and skills get archived unintentionally**. Protecting a skill you want to keep only requires changing the\n\n`author:`\n\nfield to something other than `auto`\n\n(e.g. `author: manual`\n\n). But put it off as \"I'll change it later\" and you'll forget. Ninety days later it silently moves to `.archive/`\n\nand you don't notice. You need the habit of checking `author:`\n\nright 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:\n\n```\n  grep -rl -- \"skill-name\" ~/Documents/my-knowledge-base/raw/conversations/ | wc -l\n```\n\nZero 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/`\n\n, so low precision is recoverable.\n\nReproducible guidelines derived from building and running this.\n\n**1. Make skill names specific, long, and hyphen-separated**\n\n`codex-delegation-handoff`\n\nover `codex`\n\n. `grep -rl -- \"codex-delegation-handoff\"`\n\nalmost 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`\n\nis the foundation of that accuracy.\n\n**2. Make the author: auto flag the single flag of the protection mechanism**\n\nKeep exactly one kind of flag marking \"machine-generated, subject to cleanup.\" Anything else — `author: manual`\n\n, `author: lily`\n\n, whatever — is unconditionally skipped by the safety guard on lines 34–37 of the actual code as long as it isn't `auto`\n\n. Adding more flags or complicating the condition widens the blast radius when the protection logic breaks.\n\n**3. set -u is the first line of any batch script**\n\nIf an undefined variable reaches `float()`\n\n, Python silently returns 0 and every skill is treated as \"unused since 1970.\" With `set -u`\n\n, 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.\n\n**4. Write PATH in both the plist and the script**\n\nThe plist's `EnvironmentVariables/PATH`\n\nis the environment variable launchd passes to the process. The `export PATH`\n\nat 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`\n\nboth write the same path.\n\n**5. Use perl -e 'alarm 600; exec @ARGV' to enforce a real timeout**\n\nbash's `timeout`\n\nsends SIGTERM to its direct child. If Claude spawns worker_threads or subprocesses internally, grandchildren survive. `perl alarm exec`\n\nreplaces perl with Claude (`exec`\n\nunder 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.\n\n**6. Have the LLM write from the current directory of a staging dir**\n\nCreate a temp directory with `mktemp -d`\n\n, `cd`\n\ninto it, then launch Claude. Writing `./curator-proposals.md`\n\n(a relative path) in the instructions to Claude lets you receive output without granting direct write permission to `~/.claude/`\n\n. Confirm the file exists with `[[ -f \"$STG/curator-proposals.md\" ]]`\n\n, then `cp`\n\n, then clean up with `rm -rf \"$STG\"`\n\n. Those three steps are the standard pattern for going through staging.\n\n**7. Cap LLM cost with --max-budget-usd 5.00**\n\nThe 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.\"\n\n**8. Disable MCP with --strict-mcp-config --mcp-config '{\"mcpServers\":{}}'**\n\nLaunch 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.\n\n**9. Don't break the four-flag find set**\n\n```\nfind \"$AUTO\" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print\n```\n\n`-mindepth 1`\n\n(exclude `$AUTO`\n\nitself), `-maxdepth 1`\n\n(prevent rescanning under `.archive/`\n\n), `-type d`\n\n(don't mistake files for skills), `! -name '.*'`\n\n(don't scan `.snapshots`\n\nand `.archive`\n\n). Drop even one of the four and you create a bug that looks harmless and is hard to notice later.\n\n**10. Snapshot --exclude must cover both .snapshots and .archive**\n\nWrite 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:\n\n```\ntar czf \"...\" --exclude='auto/.snapshots' --exclude='auto/.archive' auto\n```\n\nSince `-C \"$HOME/.claude/skills\"`\n\nmoves the working directory before specifying `auto`\n\n, the `--exclude`\n\npaths are in the relative form `auto/.snapshots`\n\n.\n\n**11. Leave a nollm argument as a hatch for LLM-free testing**\n\n`RUN_LLM=\"${1:-llm}\"`\n\nlets you skip the LLM phase when the first argument is `nollm`\n\n. For initial setup or verification after a config change, running `~/.claude/scripts/skill-curate.sh nollm`\n\nvalidates 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.\n\n**12. Use a dual-log design to separate \"did it start?\" from \"what did it do?\"**\n\nThe launchd log (`~/.claude/logs/com.shun.skill-curate.log`\n\n) records process-launch-level errors. The script's application log (`~/.claude/skills/auto/.curate.log`\n\n) 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`\n\n→ the error is after that point.\"\n\n**13. Keep the restore commands noted in ~/.claude/scripts/**\n\n\"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:\n\n```\n# 直近スナップショットからフル復元\ntar xzf ~/.claude/skills/auto/.snapshots/auto-YYYYMMDD-HHMMSS.tar.gz \\\n  -C ~/.claude/skills/\n\n# 特定スキルだけ .archive/ から戻す\nmv ~/.claude/skills/auto/.archive/skill-name ~/.claude/skills/auto/\n```\n\n**14. Cover proxy-metric misjudgments with the non-destructiveness of .archive/**\n\nThe 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`\n\nin one second. Lock down reversibility before precision. That's the basic stance for designing autonomous batches.\n\nBoiled down, what `skill-curate.sh`\n\nand `com.shun.skill-curate.plist`\n\ndo 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.\n\nI 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.\"\n\nClaude 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.\n\nThe full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day playbook are collected in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/dead-auto-skills-were-padding-every-conversation-a-weekly-curator-that-flags-at", "canonical_source": "https://dev.to/bokuwalily/dead-auto-skills-were-padding-every-conversation-a-weekly-curator-that-flags-at-30-days-and-lij", "published_at": "2026-08-16 05:00:06+00:00", "updated_at": "2026-08-16 05:11:23.814299+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure", "mlops"], "entities": ["Claude Code", "Anthropic", "Lily"], "alternates": {"html": "https://wpnews.pro/news/dead-auto-skills-were-padding-every-conversation-a-weekly-curator-that-flags-at", "markdown": "https://wpnews.pro/news/dead-auto-skills-were-padding-every-conversation-a-weekly-curator-that-flags-at.md", "text": "https://wpnews.pro/news/dead-auto-skills-were-padding-every-conversation-a-weekly-curator-that-flags-at.txt", "jsonld": "https://wpnews.pro/news/dead-auto-skills-were-padding-every-conversation-a-weekly-curator-that-flags-at.jsonld"}}