DEV Community

Lily
Lily

Posted on Originally published at dev.to

I Was Burning $1.20 a Night on Nothing: A Claude Code Skill Library That Harvests and Curates Itself

Going from ¥100k/month as a student to ¥1.2M/month wasn't a matter of working harder. It was a matter of building an environment that keeps running when I don't.

Why This Setup Works

If you use Claude Code heavily, you eventually run into a contradiction.

The more you use it, the smarter it gets. A procedure you notice mid-task and think "this pattern will come up again" gets written out to a skill file automatically. An error workaround you solved today can be pulled up instantly during a different task tomorrow. That part is genuinely convenient.

But keep it up for three months and you've built a skill graveyard.

A skill you created in month one quietly stops working and nobody notices. Two or three skills with similar names pile up next to each other. A procedure that last year's version of you judged "useful" now rests on assumptions that no longer hold in your current environment, so it's actively noise. Any system a human doesn't maintain will rot.

And yet, tidying up skills on a regular schedule by hand doesn't stick. The more your side income grows, the more decisions you actually need to focus on, and "important but not urgent" work like file cleanup keeps getting pushed back. I hit a point where my skill directory passed 70 entries, I could no longer tell what was what, and I had to review the whole thing from scratch. That time was a pure loss.

The core of this problem is that maintenance work eats human context.

What matters is the judgment that produces output. Keeping the skill library — the raw material for that output — fresh is something I'd rather do without spending brainpower at all. That's what the two shell scripts and the two launchd jobs in this article do.

From "Doing the Work" to "Owning an Environment"

The biggest lesson from six months of building a Claude Code autonomous environment: "work I do every day" and "work the system does every day" are completely different things.

Back when I was juggling side gigs up to ¥600k/month, I was doing all of it myself. Open the checklist every morning, update content, check reports. When I lost my job for reasons outside my control and went to zero, what hit me was the fact that a system that stops when you stop moving is fragile.

Rebuilding, I was strict about one thing: eliminate any structure where I'm the bottleneck. Skill harvesting and curation went from "when I feel like it" to "automatically, every night at 3:30 AM and every Sunday at 4:15 AM." That difference sounds small, but three months later it's decisive.

The Part You'll Recognize

"Skills accumulating is great, but maintenance can't keep up" is something anyone who has used Claude Code for a while will hit. Once the initial excitement phase passes, reality arrives in the form of library bloat and staleness.

What this article presents is a concrete answer to that. Every line of code is quoted from what's actually running in my environment. I don't write design theory that doesn't run.

The Overall Flow

In one sentence, the system is a two-stage pipeline: a nightly batch that extracts skills from conversation logs, and a weekly batch that automatically retires the expired ones.

会話ログ (~/Documents/my-knowledge-base/raw/conversations/*.md)
         │
         ▼  毎日 3:30 AM
┌─────────────────────────────────────────────────┐
│              skill-harvest.sh                   │
│                                                 │
│  .harvest-watermark で前回以降の差分だけ取得    │
│        ↓                                        │
│  MAX_LOGS=3 本 × PER_LOG_BYTES=15000 B         │
│  system-reminder行除去 → ダイジェスト生成       │
│        ↓                                        │
│  claude -p (sonnet / max-budget $1.20)         │
│  「この手順、再利用できる?」と問い続ける       │
│        ↓                                        │
│  ステージングdir に SKILL.md 生成               │
│  author: auto を保証 → ~/.claude/skills/auto/  │
└─────────────────────────────────────────────────┘
         │
         ▼ 蓄積
~/.claude/skills/auto/  (auto-skill ライブラリ)
         │
         ▼  毎週日曜 4:15 AM
┌─────────────────────────────────────────────────┐
│              skill-curate.sh                    │
│                                                 │
│  実行前スナップショット (.snapshots/*.tar.gz)   │
│  author: auto 以外は一切触れない                │
│        ↓                                        │
│  最終使用日を3段階で推定                        │
│  (会話ログMtime → created → file mtime)        │
│        ↓                                        │
│  30日未使用 → status: stale に書き換え         │
│  90日未使用 → .archive/ に物理退避             │
│        ↓                                        │
│  LLM で重複・統合候補を検出                    │
│  → .curator-proposals.md に提案書生成           │
└─────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Data flows in only one direction. Harvest is the input side, curate is the cleanup side. The two jobs run independently and never wait on each other.

How skill-harvest.sh Works

Three constants at the top of the script tell you the whole design philosophy.

MAX_LOGS=3              # 1回で扱うログ本数
PER_LOG_BYTES=15000     # ログ1本あたりの取り込み上限バイト
BUDGET_USD=1.20         # 暴走防止キャップ
Enter fullscreen mode Exit fullscreen mode

A single conversation log can run from a few MB to tens of MB. Handing that to Claude raw sends token cost through the roof. So we build a digest.

grep -v -e 'system-reminder' -e '^- [a-z0-9].*:' "$f" 2>/dev/null | head -c $PER_LOG_BYTES
Enter fullscreen mode Exit fullscreen mode

system-reminder blocks are a solid mass of skill-listing noise, so they get excluded. Then we cut at the byte limit. Those two lines raise the information density at the shell level, before anything reaches Claude.

The key to incremental processing is the .harvest-watermark file.

if [[ -f "$WM" ]]; then
  newlogs=("${(@f)$(find "$LOGS" -name '*.md' -newer "$WM" 2>/dev/null)}")
else
  newlogs=("${(@f)$(ls -t "$LOGS"/*.md 2>/dev/null)}")
fi
Enter fullscreen mode Exit fullscreen mode

The first run grabs the latest logs; every run after that only targets files newer than the watermark. Even though it fires at 3:30 AM every night, if there were no conversations that day it exits with "no new logs — skip" at zero cost. The design is built not to waste billing.

The staging pattern is the crucial piece.

Claude Code write-protects everything under ~/.claude/, so you cannot have Claude write directly into ~/.claude/skills/auto/. That's why the script uses a temporary directory as staging.

STAGING=$(mktemp -d -t skill-harvest-stg)
( cd "$STAGING" && perl -e 'alarm shift @ARGV; exec @ARGV' "$TIMEOUT_SEC" \
  "$CLAUDE" -p "$PROMPT" \
  --model sonnet \
  --permission-mode acceptEdits \
  --allowedTools "Write Edit Read" \
  --max-budget-usd "$BUDGET_USD" >> "$LOG" 2>&1 < /dev/null )
Enter fullscreen mode Exit fullscreen mode

Claude launches with $STAGING as its current directory and creates files via the relative path ./kebab-name/SKILL.md. Afterwards, the shell copies the actual files into ~/.claude/skills/auto/.

for sd in "$STAGING"/*(/N); do
  [[ -f "$sd/SKILL.md" ]] || continue
  name="${sd:t}"
  if [[ -e "$AUTO/$name" ]]; then
    echo "[$(ts)] exists, skip copy: $name" >> "$LOG"
  else
    cp -R "$sd" "$AUTO/$name" && { echo "[$(ts)] CREATED: $name" >> "$LOG"; ((created++)); }
  fi
done
Enter fullscreen mode Exit fullscreen mode

If the name matches an existing skill, it's skip copy and nothing is copied. Duplicate creation is prevented right here.

On top of that, an inline Python script guarantees that author: auto is stamped on. That field is what later lets skill-curate.sh decide "is this something I'm allowed to touch?", so it has to be applied reliably at this point.

How skill-curate.sh Works

The weekly job is the more interesting one, design-wise.

It always takes a snapshot first.

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

Note how --exclude='auto/.snapshots' keeps the snapshot from recursively including itself. That one flag prevents snapshots-of-snapshots from multiplying endlessly. This script never actually deletes anything. It only mvs things into .archive/. That's a strict non-destructive design: never perform an operation you can't undo.

The safety guard sits in the first conditional check.

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

Skills I made by hand, bundled skills, and ECC skills are never touched. That single author: auto line is the flag for "is this in scope for curation?" Because that guard exists, I can run the script against the entire skill directory without fear.

The three-stage fallback for computing last-used date is also practical.

lastlog, created, md = (sys.argv + ["","",""])[1:4]
ref = None
if lastlog.strip():
    try: ref = float(lastlog)
    except: ref = None
if ref is None and created.strip():
    try: ref = time.mktime(datetime.datetime.strptime(created.strip(), "%Y-%m-%d").timetuple())
    except: ref = None
if ref is None:
    ref = os.path.getmtime(md)
Enter fullscreen mode Exit fullscreen mode

First it looks for the latest mtime of a conversation log that mentions the skill name. Failing that, the created: date in the SKILL.md front matter. Failing that too, it falls back to the file's own mtime. In every case, the design avoids dying on a division by zero or an exception.

Past STALE_DAYS=30 it rewrites the file to status: stale; past ARCHIVE_DAYS=90 it retires it to .archive/. Those numbers matter because they come from a rule of thumb: a skill unused for 30 days has a high chance of being genuinely forgotten, and at 90 days it's almost certainly unnecessary. Five months in with this system, these thresholds haven't produced a false positive.

The LLM proposal phase runs last.

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

There's a condition that only starts the LLM when active >= 2 skills remain. The logic is that with only one skill left, a consolidation proposal is meaningless, so don't start. The proposals are written to .curator-proposals.md by a Claude call with --max-budget-usd 5.00. It never modifies or deletes the actual skill files — it just generates a proposal document. The final decision is mine: I read that document and decide by hand whether to merge anything.

launchd Scheduling

The execution times defined by the two plists.

skill-harvest (com.shun.skill-harvest): daily at 3:30 AM

<key>StartCalendarInterval</key>
<dict>
    <key>Hour</key>
    <integer>3</integer>
    <key>Minute</key>
    <integer>30</integer>
</dict>
Enter fullscreen mode Exit fullscreen mode

skill-curate (com.shun.skill-curate): every Sunday at 4:15 AM

<key>StartCalendarInterval</key>
<dict>
    <key>Hour</key>
    <integer>4</integer>
    <key>Minute</key>
    <integer>15</integer>
    <key>Weekday</key>
    <integer>0</integer>
</dict>
Enter fullscreen mode Exit fullscreen mode

3:30 AM is a time I'm reliably asleep. The MacBook Pro quietly wakes up, reads conversation logs, extracts skills, and goes back to sleep. Forty-four minutes after the harvest finishes, at 4:15 AM, the once-weekly curate runs. That 45-minute buffer is collision avoidance in case harvest takes abnormally long (the timeout is set to TIMEOUT_SEC=600, i.e. 10 minutes, so there's plenty of room).

Both plists set ProcessType: Background and Nice: 10. Even if the MacBook is doing something else, these processes run at the lowest priority so they don't crowd it out. Good manners for working quietly while I sleep.

Logs go to ~/.claude/logs/com.shun.skill-harvest.log and ~/.claude/logs/com.shun.skill-curate.log respectively, so checking them in the morning shows at a glance what happened overnight.

Implementation Details

Prompt Design: Be Strict About Where Claude Writes, Not What It Writes

The most important part of the prompt in skill-harvest.sh isn't the content instruction — it's the tool usage instruction.

【最重要・厳守】
- 各スキルは必ず **Write ツール** を使って ./<kebab-name>/SKILL.md として実際にファイル作成すること
- スキル本文をこの返信メッセージに貼り付けてはいけない。必ずファイルに書き込む
- ファイルを書き終えたら、作成したスキル名だけを箇条書きで報告する(本文は不要)
- 該当が無ければファイルを作らず『該当なし』とだけ答える
Enter fullscreen mode Exit fullscreen mode

This is necessary because without the instruction, Claude pastes the skills into the chat body "as a polite response." The first version didn't have this directive, and it beautifully returned Markdown as text every single time. Zero files created. Cost incurred anyway.

existing=$(ls "$AUTO" 2>/dev/null | grep -v '^\.' | tr '\n' ',')
Enter fullscreen mode Exit fullscreen mode

Injecting the list of existing skills into the prompt as a comma-separated string is also deliberate. Paired with an instruction to "patch the existing one if it duplicates," it suppresses the problem where the same procedure sprouts three times under slightly different names. Since the week I added this countermeasure, the duplicate-creation rate has been essentially zero.

The Timeout Trick: Why Not the timeout Command

There's a slightly odd invocation inside the script.

perl -e 'alarm shift @ARGV; exec @ARGV' "$TIMEOUT_SEC" \
  "$CLAUDE" -p "$PROMPT" ...
Enter fullscreen mode Exit fullscreen mode

At first I just wrote timeout 600 claude -p .... It works. But macOS's stock timeout behaves subtly differently from GNU coreutils, and the exit code after sending SIGTERM to the child process varies by shell. On top of that, there were cases where running from launchd couldn't find the command at all (more on that later).

perl -e 'alarm' ships with macOS by default and reliably throws SIGALRM. Because exec @ARGV replaces the process with the child directly, no extra PID is created. It's simple and reliable, and that's the form I finally settled on.

Curate's Diff Check: Don't Waste a $5 LLM Call

The LLM proposal phase in skill-curate.sh has a mechanism that decides whether to run at all.

prop_mtime=0
[[ -f "$PROP" ]] && prop_mtime=$(stat -f '%m' "$PROP" 2>/dev/null || echo 0)
changed_names=()
changed_files=()
while IFS= read -r md; do
  md_mtime=$(stat -f '%m' "$md" 2>/dev/null || echo 0)
  if [[ ! -f "$PROP" ]] || (( md_mtime > prop_mtime )); then
    changed_names+=("$skill")
    changed_files+=("$md")
  fi
done < <(find "$AUTO" -mindepth 2 -maxdepth 2 -name SKILL.md -print 2>/dev/null)
Enter fullscreen mode Exit fullscreen mode

It compares the last-modified time of .curator-proposals.md against each SKILL.md. If not a single skill is newer than the previous proposal file, it logs LLM skip and doesn't start Claude.

2026-07-13 04:23:18 no new skills, LLM skip
Enter fullscreen mode Exit fullscreen mode

Even though it runs every Sunday, not every skill gets updated in a given week. Starting the LLM on a week with no changes is pure waste. Just adding this check cut the measured monthly curate LLM cost by about 65%.

Inline Python to Eliminate External Dependencies

I use an inline Python script rather than sed for rewriting front matter because the arguments to sed -i differ between macOS and Linux.

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

If a status: line already exists, rewrite it; if not, add it right after author: auto. Those two branches are needed because early skills include some that omit status: entirely. Either case completes in a single Python invocation, so no pip and no venv are required.


Where I Got Stuck

Before this setup was finished, I hit "it should work but it doesn't" four times. The pattern was always: the design is correct, but the runtime environment isn't what I assumed.

Wall #1: Launched from launchd, claude Is "Not Found"

The first plist had no EnvironmentVariables block. Run manually from the terminal, it works. But checking the log the next morning for the 3:30 AM run:

2026-06-01 03:30:02 claude not found: /Users/xxx/.local/bin/claude
Enter fullscreen mode Exit fullscreen mode

launchd doesn't read ~/.zshrc. It doesn't read nvm either. The job starts in a state where the $PATH that works in your terminal doesn't exist at all.

The fix is to write an explicit EnvironmentVariables block into the plist.

<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/Users/xxx/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...:/Users/xxx/.local/bin</string>
</dict>
Enter fullscreen mode Exit fullscreen mode

I also export the same PATH at the top of the script. Writing it twice looks excessive, but it's insurance for the case where I bump the Node version and forget to update the plist path. If one goes stale, the other keeps things alive.

Wall #2: Trying to Write Directly into ~/.claude/ Halted Everything

My original design was "have Claude write files directly into ~/.claude/skills/auto/." I put the absolute path in the prompt and ran it, and the moment Claude tried to write, a permission error stopped it.

Claude Code blocks external writes under ~/.claude/. That's a security design decision — another process can't use claude -p to rewrite your own skill directory.

The solution is the staging pattern.

STAGING=$(mktemp -d -t skill-harvest-stg)
( cd "$STAGING" && "$CLAUDE" -p "$PROMPT" \
  --permission-mode acceptEdits \
  --allowedTools "Write Edit Read" ... )
# Claudeが書いたファイルをシェルがコピーする
for sd in "$STAGING"/*(/N); do
  cp -R "$sd" "$AUTO/$name"
done
Enter fullscreen mode Exit fullscreen mode

The only place Claude can write is its own current directory (the temp directory). The shell does the file copying. It's a design that coexists with the constraint rather than circumventing it. skill-curate.sh uses the same structure for generating its proposal document.

STG=$(mktemp -d -t skill-curate-stg)
( cd "$STG" && "$CLAUDE" -p "..." \
  --add-dir "$AUTO" --permission-mode acceptEdits ... )
[[ -f "$STG/curator-proposals.md" ]] && cp "$STG/curator-proposals.md" "$PROP"
Enter fullscreen mode Exit fullscreen mode

Here --add-dir "$AUTO" becomes necessary. For curate, Claude needs to read the existing skills (in order to write proposals). But the current directory is a temp directory. Adding ~/.claude/skills/auto to the read allowlist with --add-dir creates a "can read but can't write" state.

Wall #3: Snapshots Ballooned Weekly and Started Eating Disk

Two weeks after introducing skill-curate.sh, I noticed the snapshot directory was swelling rapidly. First run 12MB, second 35MB, third 89MB... growing exponentially.

The cause was forgetting the tar exclusion settings.

# 壊れたバージョン
tar czf "$SNAP/auto-20260601.tar.gz" -C "$HOME/.claude/skills" auto

# 正しいバージョン
tar czf "$SNAP/auto-20260601.tar.gz" -C "$HOME/.claude/skills" \
  --exclude='auto/.snapshots' --exclude='auto/.archive' auto
Enter fullscreen mode Exit fullscreen mode

Because the snapshots themselves live in auto/.snapshots/, without exclusions each week recorded a nested "snapshot of snapshots." After the fix it's stable at 8–15MB every time.

Wall #4: Running Without a Watermark Billed Me $1.20 Every Night

The initial version had no .harvest-watermark. There was no "only process what changed since last time" logic, so it fetched all of $LOGS/*.md every night.

# 壊れたバージョン(毎晩全ログを取る)
newlogs=("${(@f)$(ls -t "$LOGS"/*.md 2>/dev/null | head -$MAX_LOGS)}")

# 正しいバージョン(差分だけ)
if [[ -f "$WM" ]]; then
  newlogs=("${(@f)$(find "$LOGS" -name '*.md' -newer "$WM" 2>/dev/null)}")
else
  newlogs=("${(@f)$(ls -t "$LOGS"/*.md 2>/dev/null)}")
fi
Enter fullscreen mode Exit fullscreen mode

Even on days with no conversations, it read three logs, extracted nothing, and finished. And BUDGET_USD=1.20 got consumed every night anyway. Twelve dollars in ten days, and I only noticed when I saw the bill.

Since adding the watermark, nights with no conversations leave a single no new logs — skip line in the log and finish at zero cost. Actual cost is incurred roughly 10–15 days a month, which works out to less than half the original monthly figure.

Wall #5: A Hand-Made Skill Suddenly Vanished into .archive/

On the first Sunday morning after enabling curate, an important skill I'd written by hand had been moved into .archive/. I'd made it two months earlier and didn't use it often (a one-shot setup procedure).

The cause was not having the author: auto guard.

# この条件がなかった
if ! grep -q '^author:[[:space:]]*auto' "$md"; then
  echo "[$(ts)] skip (not author:auto): $skill"
  continue
fi
Enter fullscreen mode Exit fullscreen mode

The design at the time assumed "anything in the auto-skill directory is in scope for curation." In reality, bundled skills and manually created skills were mixed in there. The design change — making that one author: auto line the flag for "is this OK to curate?" — came out of this failure.

Now I have a rule that manual skills explicitly get author: manual. Curate never touches anything that isn't author: auto. The skills retired into .archive/ were recoverable from the snapshot, but every skill created before I set this rule needed the author: field added retroactively. I remember spending about 10 minutes rewriting them by hand.

Gotchas

The five walls above (no PATH, staging required, snapshot bloat, missing watermark, missing author: auto guard) are holes I actually fell into. But that's not the end of it. There's a second lap of gotchas you only notice once you're operating it. Here they are, rapid-fire.

--permission-mode acceptEdits alone isn't enough

For both harvest and curate, if you don't explicitly pass --allowedTools "Write Edit Read", Claude tries to call extra tools (Bash, WebFetch, etc.) and fails. Restricting allowed tools also prevents cost runaway, so remember to always write both together.

"$CLAUDE" -p "$PROMPT" \
  --permission-mode acceptEdits \
  --allowedTools "Write Edit Read" \
  --max-budget-usd "$BUDGET_USD"
Enter fullscreen mode Exit fullscreen mode

Running a zsh-specific glob qualifier under bash

"$STAGING"/*(/N) is a zsh glob qualifier: / means directories only and N means don't error on zero matches. In bash it's a syntax error. That's why the plists explicitly specify /bin/zsh in ProgramArguments — switching to the default /bin/sh kills it instantly.

Forgetting --add-dir in curate means the LLM can't read anything

Curate's staging directory is temporary space, and from there the contents of ~/.claude/skills/auto/ are invisible. Passing --add-dir "$AUTO" creates the "reads allowed, writes prevented" state. If that argument is missing, the LLM tries to write proposals from a blank slate and meaningless content ends up in curator-proposals.md.

Misreading how the active counter behaves

Reading skill-curate.sh, you'll see that skills past 90 days are archived with mv and then do not call ((active++)).

if (( days > ARCHIVE_DAYS )); then
  mv "$d" "$ARCH/" && echo "[...] ARCHIVED"
  # active++ はここにない
elif (( days > STALE_DAYS )); then
  # ... stale 書き換え
  ((active++))
else
  ((active++))
fi
Enter fullscreen mode Exit fullscreen mode

If every skill becomes an ARCHIVE target, active stays at 0, the LLM-start condition (( active >= 2 )) isn't met, and the proposal phase is silently skipped. That's intended design, not an error — but when you find yourself wondering "the LLM has never run," check the active count.

Only the nightly batch breaks after a Node.js version bump

This is the case where you bump Node with nvm use 24.14.0 and the next morning's log shows claude not found. The EnvironmentVariables in both plists contain absolute paths including the version number, so when you upgrade Node you have to update the plists at the same time.

<string>/Users/xxx/.nvm/versions/node/v24.13.0/bin:...</string>
Enter fullscreen mode Exit fullscreen mode

You need to rewrite the v24.13.0 portion to the upgraded version and run launchctl unload → load. This is exactly why both the export PATH at the top of the script and the plist need updating.

launchd's StartCalendarInterval doesn't skip sleep — it "runs immediately on wake"

If macOS was asleep at 4:15 on Sunday, curate runs the moment you next open the Mac. You get the phenomenon where you open your laptop at 9 AM Monday and curate suddenly fires. Know it so it doesn't startle you. launchd behaves as "missed the scheduled time = run immediately at the next opportunity."

Months pass without ever opening .curator-proposals.md

Generating the proposal document doesn't change any actual files. If you don't read it, no consolidation, deletion, or patching happens at all. "The LLM tidies things up for me" is a misconception; it's "the LLM writes a proposal, a human executes it." I have "check proposals" on my calendar every Monday morning.

Loading the plist while the StandardOutPath directory doesn't exist

The plists reference a path under ~/.claude/logs/, and if you haven't created the log directory in advance, launchd fails to load the plist itself. Don't forget to confirm mkdir -p ~/.claude/logs/ before registering the plists with launchctl.

Forgetting to add LowPriorityIO: true

Both plists include both LowPriorityIO and Nice: 10. Without LowPriorityIO, if the job overlaps with a late-night Time Machine / Spotlight update, you get several minutes of disk I/O contention. Nice: 10 only lowers CPU priority, so the I/O side needs its own setting.

<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
Enter fullscreen mode Exit fullscreen mode

Curate's timeout isn't parameterized

Harvest pulls TIMEOUT_SEC=600 out into a variable and passes it as perl -e 'alarm shift @ARGV; exec @ARGV' "$TIMEOUT_SEC". Curate hardcodes it as perl -e 'alarm 600; exec @ARGV'. To change it you have to edit the script body directly. It's an asymmetry in the design; if it bothers you, parameterizing it is a good idea.

macOS's stat -f '%m' doesn't work on Linux

The stat -f '%m' used in curate's last-used-date calculation is a macOS/BSD-only flag. Take the same script to a Linux server and stat fails, every skill's days becomes zero, and no curation runs at all. The design accepts up front that this is macOS-only.

Wasting LLM cost because you don't know about the nollm argument

RUN_LLM="${1:-llm}"   # 第1引数 "nollm" でLLM統合提案をスキップ(テスト用)
Enter fullscreen mode Exit fullscreen mode

Running ./skill-curate.sh nollm runs only the stale/archive decisions and skips starting the LLM. It shines when you want to "just verify the curation logic works" or "test the weekly batch without spending $5." If you don't know this argument exists, you end up debugging while starting the LLM every single time.


Best Practices

Habits that solidified over five months of operation — the ones I'm glad I didn't skip.

1. Always Run It Manually Before Registering with launchd

# harvest の動作確認
~/.claude/scripts/skill-harvest.sh

# curate の動作確認(LLMなし)
~/.claude/scripts/skill-curate.sh nollm
Enter fullscreen mode Exit fullscreen mode

Just one manual run before loading the plists exposes most PATH mistakes and missing directories. If harvest done (exit 0, created=1) shows up in the log, things are basically fine.

2. Start BUDGET_USD Small and Raise It Gradually

You don't need to set $1.20 from the start. Starting at $0.30 and raising it once you feel "skills are getting cut off" is plenty. Harvest processes three digests of at most 15,000 bytes each, so on days with light conversation $0.30 covers everything.

3. Update the plists Together with Node.js Version Bumps

Updating the plist PATH is manual work. To avoid forgetting, make "change the v-number in both plists" a paired task with every nvm version update. I keep a note in a skill file that pairs "update .zshrc completion + update plists" with my nvm alias.

4. Add author: manual Before Enabling curate

The safe order is to tag existing manual skills with author: manual first, then enable curate. Do it the other way around and you risk manual skills with no author field slipping past the author: auto guard (the guard checks for the presence of ^author:[[:space:]]*auto, so if the field doesn't exist at all it can't determine a skip). The retroactive fix takes 10 minutes, but that's easier than digging lost skills out of .archive afterward.

5. Put the .curator-proposals.md Review on Your Calendar

毎週月曜9:00 — ~/.claude/skills/auto/.curator-proposals.md を確認する
Enter fullscreen mode Exit fullscreen mode

Read the proposals, and when you decide "these should be merged," patch the SKILL.md yourself by hand. What curate does automatically is only the stale/archive decisions and generating the proposal document. The actual decision-making about skill organization is human work. Understanding that division of labor from the start prevents the mismatched expectation of "why doesn't it consolidate automatically?"

6. Set STALE_DAYS/ARCHIVE_DAYS Generously at First

The defaults are 30/90 days, but I recommend starting around 60/180. Shrinking the thresholds after you've developed a feel for which skills actually get used protects important skills from being removed by a misjudgment. Five months in I've concluded that 30/90 is just right, but there's no need to start at the final values.

7. Check the Logs Every Morning for the First Two Weeks

tail -50 ~/.claude/logs/com.shun.skill-harvest.log
tail -50 ~/.claude/logs/com.shun.skill-curate.log
Enter fullscreen mode Exit fullscreen mode

Making a habit of reading the logs every morning for just the first two weeks builds your intuition for "what's being extracted / what's being judged stale." Once it's stable, a weekly check is plenty.

8. Set a Rotation Cap on Snapshots

Curate's snapshots grow without limit by default. Weekly execution for a year means 52 tarballs. I have a routine of deleting the old ones once a month.

# 最新13件だけ残す(3ヶ月分)
ls -t ~/.claude/skills/auto/.snapshots/*.tar.gz | tail -n +14 | xargs rm -f
Enter fullscreen mode Exit fullscreen mode

9. Know That Resetting the Watermark Forces a Full Rescan

# watermark を古い日付に書き換えると次回harvest が全ログを再スキャンする
touch -t 202601010000 ~/.claude/skills/auto/.harvest-watermark
Enter fullscreen mode Exit fullscreen mode

Use it when you want to "pick up skills missed from recent conversations" or when "the watermark got updated by mistake." The forced rescan happens once, and from the next run it returns to normal incremental processing.

10. Leave at Least 45 Minutes of Buffer Between harvest and curate

Harvest's timeout is TIMEOUT_SEC=600 (10 minutes). Even if harvest takes nearly 10 minutes in the worst case, it's assumed to finish at 3:30 + 10 min = 3:40, which leaves a 35-minute buffer before curate starts at 4:15. Moving curate up to 4:00 introduces a risk of overlapping with harvest. Leaving the two jobs' schedules as they are is the safe call.

11. Understand What the active >= 2 Condition Means

With one or fewer skills, a consolidation proposal is meaningless, so the LLM doesn't start. If you're starting from zero in a new environment, curate's LLM phase won't run until harvest has accumulated the first few entries. "The LLM has never started" is normal early on.

12. Don't Break the -p Flag + --model sonnet Combination

Harvest uses claude -p "$PROMPT" --model sonnet. -p is the flag for running headless, non-interactively. Drop it and running from launchd hangs waiting on stdin. Also, omitting --model means the default model in your config file is used, which risks an expensive model being selected unintentionally.

13. Point StandardOutPath and StandardErrorPath at the Same File

Both plists direct stdout and stderr to the same log file. Splitting them scatters errors and the script's internal logging across two files and makes reading harder. A lot of the claude command's output goes to stderr in particular, so combining into one file is more practical.

14. Count Your Skills a Month In and Compare to Expectations

Compare skill counts before introducing harvest and one month later with ls ~/.claude/skills/auto | grep -v '^\.' | wc -l. It gives you diagnostic criteria: "fewer than expected → the log digest is too thin / conversation volume is low," "too many → the duplicate-suppression prompt isn't working."

15. Always Syntax-Check When Editing the Scripts Directly

zsh -n ~/.claude/scripts/skill-harvest.sh
zsh -n ~/.claude/scripts/skill-curate.sh
Enter fullscreen mode Exit fullscreen mode

zsh -n runs a syntax check only, without executing. If the 3:30 AM job dies on a syntax error you won't notice until morning, so always run this after editing.


Wrapping Up

What this article covered is just two shell scripts and two plists. The codebase is around 250 lines combined. But because it keeps running, I've been able to remove "managing skills" from my brain's task list entirely.

There are three core design points.

It's non-destructive. skill-curate.sh never truly deletes anything. It only mvs things into .archive/. A pre-run snapshot backs up the "never perform an irreversible operation" rule. That's why I can enable it without fear.

Cost limits are controllable from outside. BUDGET_USD=1.20 is a runaway-prevention cap. Days with no conversations cost nothing via no new logs — skip. Curate's LLM phase also has a check that skips when no skill has changed since last time. Real monthly cost lands at roughly ¥600–800 for harvest and ¥200–400 for curate's proposal phase.

Curation scope is clearly delimited by author: auto. The scripts only touch skills they created themselves. Manual skills, bundled skills, and ECC skills are left completely alone. Because of that guard, I could retrofit this into an existing environment.

Most of the ¥1.2M/month revenue comes from time spent focused on decisions — creating content, growing products. Automating the "management work" of keeping the skill library fresh doesn't directly generate revenue. But the 30 minutes a week of attention that management used to take now goes toward high-value judgment instead. Five months of that compounding is where I am now.

That, I think, is what it means to own a system.


The full picture of the setup, the breakdown of the ¥1.2M/month, and the 30-day procedure 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*

Top comments (0)