# Claude Forgets Everything Overnight — The 3:30 AM Batch That Harvests What It Learned

> Source: <https://dev.to/bokuwalily/claude-forgets-everything-overnight-the-330-am-batch-that-harvests-what-it-learned-13bo>
> Published: 2026-08-24 05:00:06+00:00

My conversation logs are gone by morning. My environment, meanwhile, wakes up smarter than it went to bed.

When I started freelancing on the side, I was burning three to four hours every night to make ¥100,000 a month. Once I started using Claude Code to mass-produce content, I got up to ¥600,000 a month across multiple gigs — and then I was laid off and went back to zero. Six months on, what actually supports my current ¥1.2M/month in revenue is honestly not the code itself. It's the *environment*.

**What do I mean by "environment"?** My definition: a mechanism that lets next week's me start out smarter than this week's me. This is not about taking notes on what you learn. You skip notes. Note-taking interrupts the work. And three weeks later you never look at them again.

When I'm pair-programming with Claude Code, I hit "oh, this is useful" moments several times a day. How to run a batch without tripping an API rate limit. Why the shell PATH dies inside a launchd plist and how to work around it. Why an automation script's writes get blocked unless you copy through a staging directory — none of this is in the docs. It's non-obvious knowledge you only acquire by tripping over it in that specific environment.

The problem is that **this kind of knowledge evaporates across sessions**. Claude Code closes its context per session. Even if today's conversation teaches me that "anything under `~/.claude/`

is write-protected by Claude Code itself, so you have to copy through staging," tomorrow's session doesn't have that knowledge. The next time I hit the same wall, I pay the same cost again.

Most people start out thinking "let's use Claude to work faster." I did too. But work speed has a ceiling — the physical wall of 24 hours in a day.

Investing in the environment moves that ceiling. If this week's me does the work with 100 units of knowledge and next week's me does it with 150, I can handle more complex work in the same time. Once my skill library passed 200 entries, the quality of my instructions to Claude Code visibly changed. Just adding "same pattern as that skill" gets non-obvious procedures executed without any context explanation.

The catch is that doing this "environment investment" by hand falls apart. Re-reading conversation logs and writing notes is not sustainable in a side-hustle setting full of interruptions.

The solution is simple: write a rule into CLAUDE.md telling Claude Code, "when you find a useful procedure, write yourself a skill file." That rule actually exists.

```
# ~/.claude/CLAUDE.md（抜粋）
## スキル自己生成（auto-skills）
再利用価値のある手順（5回以上ツールの非自明タスク完遂・回避策発見・
アプローチ修正された・再利用手順発見）は頼まれなくても
`~/.claude/skills/auto/<kebab-name>/SKILL.md` に自作する。
```

But this alone wasn't enough. Claude Code inside a session only knows the procedures that occurred in that session. The realization that "yesterday's thing and today's thing are actually the same pattern," accumulated across multiple sessions, can't be picked up by per-session auto-generation.

**That's why I needed a batch process that re-reads conversation logs after the fact and harvests skills from them.** `skill-harvest.sh`

plays that role. Every morning at 3:30 AM it runs automatically, extracts non-obvious procedures from that day's conversations, and saves them as skill files under `~/.claude/skills/auto/`

. Humans do nothing. You wake up and the environment has grown.

Even people who are good at using ChatGPT or Claude Code still get the "I looked this up before, didn't I?" feeling. You search, you experiment, you finally get a procedure working — and the next day you can't recall it. This isn't a memory problem; it's a design problem: **the procedure wasn't saved in the right format in the right place**.

That is exactly what `skill-harvest.sh`

solves. It drops discovered procedures into structured files on the spot, and from the next Claude Code session that knowledge is automatically referenceable. It's a design that compensates for Claude's lack of long-term memory with an external filesystem.

Here's the big picture as an ASCII diagram.

```
毎晩の会話ログ (.md)
  ~/Documents/my-knowledge-base/raw/conversations/
         │
         │ find -name '*.md' -newer .harvest-watermark
         ↓
   新着ログを最大3本選定（MAX_LOGS=3）
         │
         │ grep -v system-reminder | head -c 15000
         ↓
   ダイジェスト生成（最大45KB→ノイズ除去）
         │
         │ claude -p --model sonnet --max-budget-usd 1.20
         ↓
   ステージングディレクトリへ SKILL.md を生成
   /tmp/skill-harvest-stg.XXXXX/
     └── <kebab-name>/
           └── SKILL.md
         │
         │ author:auto 確認 + 既存スキルとの重複チェック
         ↓
   ~/.claude/skills/auto/ へコピー
   .harvest-watermark を更新
         │
         │ 週次（手動 or cron）
         ↓
   skill-curate.sh による整理
     ├── 会話ログへの言及が30日ない → status: stale
     ├── 90日ない → .archive へ退避
     └── 新着スキルを LLM で分析 → .curator-proposals.md（提案のみ）
```

launchd kicks off this flow every morning at 3:30. No human involvement is required — you just review the proposals file once a week.

Let's look inside `com.shun.skill-harvest.plist`

.

```
<key>StartCalendarInterval</key>
<dict>
    <key>Hour</key>
    <integer>3</integer>
    <key>Minute</key>
    <integer>30</integer>
</dict>
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
```

3:30 AM is deliberate. It targets the window after Claude Code sessions have ended and before the next morning's first session begins. The combination of `LowPriorityIO: true`

and `Nice: 10`

explicitly tells macOS's IO and CPU schedulers, "this is a low-priority background task." That's consideration for not having the fans spin up while I'm asleep.

The reason for launchd over cron: on macOS, cron doesn't run a job whose scheduled time passed while the machine was suspended, whereas launchd catches up on tasks that "should have been launched" after waking from sleep. Even on a night when the MacBook lid was closed, it runs when the machine wakes the next morning.

The PATH environment variable is set explicitly inside the plist.

```
<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>~/.nvm/versions/node/v24.13.0/bin:
            /opt/homebrew/bin:/opt/homebrew/sbin:
            /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:
            ~/.local/bin</string>
</dict>
```

This is a famous launchd trap. Jobs launched via launchd do not read `~/.zshrc`

. Node installed via `nvm`

isn't on the path either, so the `claude`

command isn't found and the batch quietly exits. In practice I also set `export PATH=...`

a second time inside the script. Writing PATH in both the plist and the shell script looks redundant, but it's an intentional design so the thing works no matter which path invokes it.

The first important piece of logic in `skill-harvest.sh`

is the watermark handling.

```
AUTO="$HOME/.claude/skills/auto"
LOGS="$HOME/Documents/my-knowledge-base/raw/conversations"
WM="$AUTO/.harvest-watermark"

# 前回以降に更新されたログを新しい順に収集（初回は最新 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
# mtime 降順に並べ替えて上位 MAX_LOGS 件に絞る
if (( ${#newlogs} > 0 )); then
  newlogs=("${(@f)$(ls -t "${newlogs[@]}" 2>/dev/null | head -$MAX_LOGS)}")
fi
```

The mtime of a zero-byte file called `.harvest-watermark`

represents "the last run time." `find -newer $WM`

limits the target to logs updated since the last run, and `head -$MAX_LOGS`

narrows it to at most three.

Why three? Cost control for the downstream LLM call. With `MAX_LOGS=3`

and `PER_LOG_BYTES=15000`

, the theoretical upper bound on data handled in one harvest is 45KB. Conversation logs contain huge numbers of `<system-reminder>`

blocks (lists of available skills and so on), and passing them to the LLM as-is means most of it is noise.

```
DIGEST=$(mktemp -t skill-harvest)
for f in "${newlogs[@]}"; do
  {
    echo "===== LOG: ${f:t} ====="
    # 巨大な <system-reminder> ブロックを大まかに除去してからバイト上限で切る
    grep -v -e 'system-reminder' -e '^- [a-z0-9].*:' "$f" 2>/dev/null \
      | head -c $PER_LOG_BYTES
    echo
  } >> "$DIGEST"
done
```

`grep -v -e 'system-reminder'`

removes lines containing the `<system-reminder>`

tag, and the `'^- [a-z0-9].*:'`

pattern additionally filters out bullet-list skill inventory lines. What's left is just the actual conversation content.

Because each log is cut at 15,000 bytes (about 15KB), the tail end of long conversation logs is discarded. That tradeoff is intentional, based on the rule of thumb that "the closer to the start, the more likely important procedures appear." In practice, sessions tend to be structured as problem framing and solution discovery in the first half, implementation in the second.

Here's the skeleton of the prompt that hands the generated digest to the LLM.

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

PROMPT="あなたはスキルライブラリのハーベスターです。
下記の会話ログ抜粋から、将来再利用できる『手順的知識』だけをスキル化してください。

（ダイジェスト本文）

既存の auto スキル（重複作成は禁止。重複するなら新規作成せず既存を patch）:
${existing:-（なし）}

抽出基準:
- 複数手順を要する非自明な作業フロー / エラー回避策 / 繰り返し使えるコマンド列
- 一度きり・自明・雑談・個人情報は対象外
- 該当が無ければ何もファイルを作らず『該当なし』とだけ述べて終了

各スキルの作り方:
- ファイル: ./<kebab-name>/SKILL.md（カレントディレクトリ直下・絶対パス禁止）
- frontmatter: name / description / author: auto / created / version: 1.0.0 / status: active
- 本文: ## Procedure / ## Pitfalls / ## Verification の3節
- 1スキル＝1手順で小さく保つ"
```

The key point is passing the list of existing skill directory names as `existing`

for deduplication. This lets the LLM decide for itself: "this skill already exists as `codex-delegation-handoff`

, so no new file is needed."

Also look at the frontmatter field described as `description`

(when it should fire). This is metadata controlling when Claude Code should use that skill. If the firing condition is written out — "when creating a launchd plist," "when installing a new npm package" — Claude Code automatically references that skill when it starts a similar task.

This is the least obvious design decision in the whole script.

```
# ~/.claude 配下は Claude Code が書き込み保護するため、claude には
# ステージング(cwd)へ相対パスで書かせ、後段で shell が AUTO へコピーする。
STAGING=$(mktemp -d -t skill-harvest-stg)
( cd "$STAGING" && perl -e 'alarm shift @ARGV; exec @ARGV' "$TIMEOUT_SEC" \
  "$CLAUDE" --strict-mcp-config --mcp-config '{"mcpServers":{}}' -p "$PROMPT" \
  --model sonnet \
  --permission-mode acceptEdits \
  --allowedTools "Write Edit Read" \
  --max-budget-usd "$BUDGET_USD" >> "$LOG" 2>&1 < /dev/null )
```

Claude Code protects `~/.claude/`

from external process writes. A subprocess invoked with `claude -p`

that tries to write files directly into `~/.claude/skills/auto/`

gets blocked. So first I create a temp directory, `/tmp/skill-harvest-stg.XXXXX/`

, and launch `claude -p`

with that directory as cwd.

That's why the LLM instructions say "create the file as `./<kebab-name>/SKILL.md`

**directly under the current directory**. Absolute paths forbidden." The LLM writes files into the staging directory with relative paths, and the shell inspects them before copying into `~/.claude/skills/auto/`

.

For the timeout I use `perl -e 'alarm ...; exec ...'`

. zsh's `timeout`

command would work too, but signal propagation behavior on macOS differs subtly in some cases, and perl's alarm more reliably terminates the entire process tree. The configured value is `TIMEOUT_SEC=600`

, i.e. 10 minutes.

The budget cap is `--max-budget-usd 1.20`

. Since I'm on the Claude Max flat-rate plan this doesn't actually get billed, but it's a safety valve in case the batch runs away. The comment even says so explicitly: "Max is flat-rate. This is a runaway-prevention cap."

Several checks run before staging-directory files are copied into `AUTO`

.

```
for sd in "$STAGING"/*(/N); do
  [[ -f "$sd/SKILL.md" ]] || continue
  name="${sd:t}"
  [[ "$name" == .* ]] && continue
  # author: auto を保証（無ければ frontmatter 直後に挿入）
  grep -q '^author:[[:space:]]*auto' "$sd/SKILL.md" || python3 - "$sd/SKILL.md" <<'PY'
import sys,re
p=sys.argv[1]; s=open(p).read()
if s.startswith('---'):
    s=re.sub(r'^---\n', '---\nauthor: auto\n', s, count=1)
...
open(p,'w').write(s)
PY
  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
```

Three validations run.

**1. SKILL.md existence check.** Even if a directory exists, it's skipped when SKILL.md isn't there. If the LLM somehow creates an empty directory, it's ignored.

**2. Dotfile exclusion.** `[[ "$name" == .* ]]`

excludes hidden directories like `.archive`

and `.snapshots`

.

**3. Forcing author: auto.** This is the linchpin of the integration with weekly curation. The

`skill-curate.sh`

described below only treats skills carrying this flag as curation targets. As a safety valve against accidentally touching hand-written skills, skills created via harvesting always get `author: auto`

. If the LLM forgets to write the frontmatter, the inline python3 script inserts it after the fact.**4. No overwriting existing skills.** `[[ -e "$AUTO/$name" ]]`

skips if the directory already exists. Same-name skills never get overwritten. The prompt already tells the LLM to patch the existing skill on duplication, but this is a second shell-side check just in case.

If `skill-harvest.sh`

is in charge of "creating," `skill-curate.sh`

is in charge of "curating." This weekly script does three jobs.

**Job 1: pre-run snapshot.**

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

Before changing anything, it takes a tar.gz of the whole thing. `skill-curate.sh`

is non-destructive by design (no real deletes), but this prevents a mistaken move operation from becoming unrecoverable. Snapshots accumulate in `auto/.snapshots/`

.

**Job 2: staleness detection and archiving.**

```
STALE_DAYS=30
ARCHIVE_DAYS=90

# 最終使用日: スキル名を含む会話ログの最新mtime
lastlog=$(grep -rl -- "$skill" "$LOGS" 2>/dev/null \
  | while read f; do stat -f '%m' "$f" 2>/dev/null; done \
  | sort -rn | head -1)
```

The distinctive part is using "the most recent date on which that skill's name appeared in a conversation log" to determine usage. Not the mtime of the skill file itself — whether it was actually mentioned in conversation. This reliably detects "skills whose files exist but that nobody uses."

If there's been no mention for 30 days or more, the frontmatter's `status:`

field is rewritten to `stale`

. At 90 days or more, it's physically moved into the `.archive/`

directory. Both target only skills carrying `author: auto`

— hand-written skills are never touched.

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

**Job 3: LLM consolidation proposals.**

```
( cd "$STG" && "$CLAUDE" -p "... 重複・低品質・統合候補を洗い出してください ..." \
  --model sonnet \
  --permission-mode acceptEdits \
  --allowedTools "Write Edit Read" \
  --max-budget-usd 5.00 >> "$LOG" 2>&1 < /dev/null )
[[ -f "$STG/curator-proposals.md" ]] && cp "$STG/curator-proposals.md" "$PROP"
```

When there are two or more active skills and at least one skill has been updated since the previous proposals file, it asks the LLM to analyze consolidation candidates. The budget is set generously at `$5.00`

, because once you pass 100 skills there's more to analyze.

The important part: **this LLM call only writes a proposals file — it never actually modifies skills.** The result goes to `~/.claude/skills/auto/.curator-proposals.md`

, and a human (me) reviews it once a week and decides on merges or deletions manually. Fully automatic skill rewriting is too risky, so the final judgment stays with a human.

| Aspect | skill-harvest.sh | skill-curate.sh |
|---|---|---|
Frequency |
Daily, 3:30 AM | Weekly |
Direction |
Create (add) | Curate (stale/archive) |
LLM budget cap |
$1.20 | $5.00 |
Target logs |
New since last run (max 3) | Last-used date of all active skills |
Destructiveness |
No overwrites (skip) | Moves only (no real deletes) |
Human involvement |
None | Proposal review only |
Safety guard |
Forces `author:auto` tagging |
Never touches non-`author:auto`
|

Because the two scripts run on different time axes, short-term discovery and long-term quality maintenance coexist. harvest reaps a little every day; curate keeps quality up weekly. This cycle keeps the skill library in a state where it "grows without rotting."

`${(@f)...}`

Is Necessary
The array-building code at the top of the script looks meaningless at first.

```
newlogs=("${(@f)$(find "$LOGS" -name '*.md' -newer "$WM" 2>/dev/null)}")
```

`${(@f)...}`

is a zsh-specific expansion flag meaning "split on newlines and convert to array elements." Command substitution with `$()`

just returns a string, so a plain `newlogs=($(...))`

would also split on whitespace and break the array for path names containing spaces (e.g. `my conversation log.md`

). Using `(@f)`

to make the newline the only delimiter keeps such paths as a single element.

The next line is the same.

```
newlogs=("${(@f)$(ls -t "${newlogs[@]}" 2>/dev/null | head -$MAX_LOGS)}")
```

Pass the multiple files found by `find`

to `ls -t`

to sort them by descending mtime, and keep only the three newest with `head -3`

. It looks simple, but writing this in bash requires setting `IFS=$'\n'`

and using `mapfile`

, which hurts portability and readability. This conciseness of array handling is one reason I rewrote it as a zsh script.

`perl`

for the Timeout

```
( cd "$STAGING" && perl -e 'alarm shift @ARGV; exec @ARGV' "$TIMEOUT_SEC" \
  "$CLAUDE" ... )
```

Why use perl's alarm when `timeout 600 claude ...`

would be simpler? macOS's `/usr/bin/timeout`

propagates SIGALRM slightly differently from Linux's `timeout`

. In particular, signal delivery to a process replaced via `exec`

isn't guaranteed in some cases, and with a structure that invokes the `claude`

process internally via `exec`

, I saw the parent die while child processes lingered.

`perl -e 'alarm shift @ARGV; exec @ARGV'`

means "schedule SIGALRM N seconds from now, then replace yourself with the remaining arguments via exec." Because perl's alarm targets the process itself, it reliably reaches the claude process that replaced it via exec. The logs retain `exit 142`

(SIGALRM's exit code), so you can also use it to detect timeout firing.

```
"$CLAUDE" --strict-mcp-config --mcp-config '{"mcpServers":{}}' -p "$PROMPT" \
  --permission-mode acceptEdits \
  --allowedTools "Write Edit Read"
```

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

disables all MCP servers, and `--allowedTools "Write Edit Read"`

restricts the LLM to just three tools.

The only operation the harvester needs is creating files in the staging directory. Running the batch with web fetch, Bash, GitHub integration, and so on available creates the risk that the LLM gets dragged along by context in the logs into unintended side effects — "let me also git push," "let me fetch that URL." Applying the principle of least privilege to batches is the intent behind `--allowedTools`

.

Detaching stdin with `< /dev/null`

matters too — without it, the batch can hang waiting on interactive input. Under unattended launchd execution there's no guarantee that stdin is connected to the null device, so I detach it explicitly.

`/*(/N)`

Glob Qualifier
The pre-copy loop looks like this.

```
for sd in "$STAGING"/*(/N); do
```

The zsh glob qualifier `(/N)`

means "directories only, and don't error on no match." With plain `*`

, any files mixed into staging would also be processed — they'd just be skipped by `[[ -f "$sd/SKILL.md" ]]`

, but it's a wasted loop iteration. Adding `/N`

returns only directories from the start and cuts down the conditional checks inside the loop.

The part that computes "how many days since it was last used" for staleness detection turned out unexpectedly robust.

```
lastlog, created, md = (sys.argv + ["","",""])[1:4]
ref = None
if lastlog.strip():
    try: ref = float(lastlog)   # ①会話ログのmtime（unix timestamp）
    except: ref = None
if ref is None and created.strip():
    try: ref = time.mktime(datetime.datetime.strptime(created.strip(), "%Y-%m-%d").timetuple())  # ②frontmatterのcreated
    except: ref = None
if ref is None:
    ref = os.path.getmtime(md)  # ③SKILL.md自体のmtime
```

The priority order is "① appearance in conversation logs → ② the frontmatter `created`

date → ③ the file's mtime."

① matters most: if a skill is actually referenced in real work, its name appears in the conversation logs. `grep -rl -- "$skill" "$LOGS"`

searches all conversation log files containing that skill name and takes the newest mtime. For a skill never mentioned, ① is None and it falls through to ②.

② is the date the skill was created. Harvester-generated skills have frontmatter like `created: 2026-07-15`

, which is treated as the creation date. If neither ① nor ② is available, it falls back to ③, the file's modification time.

Thanks to this three-stage structure, curate.sh keeps running instead of dying with an error even when the conversation-log directory is mounted from another machine and grep can't run.

`stat -f '%m'`

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

`stat -f '%m'`

is macOS/BSD syntax. On Linux (GNU stat) it's `stat -c '%Y'`

. This script is deliberately macOS-only, so it uses BSD syntax directly. If you want to move it into a Docker container, you'll need to rewrite this part.

The design looks clean, but I got stuck many times before this setup actually worked. Symptom, cause, fix — in that order.

`claude`

Not Found, Silent Exit
**Symptom.** The script should be launching via launchd, but nothing gets written to the log file. Checking launchd's status shows "last exit: 0" — treated as a normal exit.

**Cause.** The check at the top of the script, `[[ -x "$CLAUDE" ]] || { echo "claude not found" >> "$LOG"; exit 0; }`

, was firing — but the log's target directory itself didn't exist, so `>> "$LOG"`

also failed and vanished. In other words, the error about the error got swallowed.

The root cause was that `$CLAUDE`

's path wasn't set up, because I hadn't included the nvm-installed node/claude in PATH. Jobs launched via launchd don't read `~/.zshrc`

. I fixed it by adding the following to the plist's `EnvironmentVariables`

.

```
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:
        /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
```

On top of that, the script re-sets `export PATH=...`

at the top — a duplicate setting. Debugging when either one is missing is hell, so writing it in both places is the right answer.

`~/.claude/`

and Gets Blocked
**Symptom.** In the initial design before I introduced the staging pattern, I instructed the LLM to write to the absolute path `~/.claude/skills/auto/<name>/SKILL.md`

. On execution, `claude -p`

would stop midway, log an error equivalent to `Permission denied`

, and exit. Created count was always 0.

**Cause.** Claude Code protects its own config directory (`~/.claude/`

) from external writes. A subprocess invoked with `claude -p`

gets blocked when it tries to create files under `~/.claude/`

with the Write tool.

**Fix.** I rewrote the prompt instructions to "no absolute paths; create `./<kebab-name>/SKILL.md`

directly under the current directory," and changed the design so that `cd "$STAGING"`

moves into the staging directory before launching `claude -p`

. The LLM writes into staging, and the shell copies from there into `~/.claude/skills/auto/`

. Shell copies aren't gated.

This constraint isn't spelled out in the docs — it's non-obvious knowledge I only learned by getting stuck. That itself is saved as the auto-skill `claude-headless-staging-pattern`

.

**Symptom.** In the version before I added the `grep -v 'system-reminder'`

filter to the digest, the LLM was generating obviously bogus "skills." Looking at the contents, they read like "this skill is the procedure for `code-tour`

" — as if copied straight from existing skill descriptions.

**Cause.** Claude Code conversation logs contain huge blocks wrapped in `<system-reminder>`

tags, and inside them is a bulleted "list of available skills." If you don't strip that during digest generation, the skill-list description text ("Use this skill when...", "Typical triggers include...") gets mistaken for harvest material. The LLM then decides it has "discovered a new procedure" and creates a file.

**Fix.** I made the filter double up: `grep -v -e 'system-reminder' -e '^- [a-z0-9].*:'`

. The first removes lines containing the `<system-reminder>`

tag; the second removes bullet-list skill inventory lines like `- code-tour:`

or `- agent-browser: Use when...`

. After adding this filter, the frequency of bogus "skills" dropped dramatically.

**Symptom.** The logs show the LLM saying "I generated the following skills," but the staging directory is empty. It ends with `created=0`

.

**Cause.** Simply telling the LLM to "create a skill" sometimes makes it write the skill content as a Markdown code block in its reply text instead of using the Write tool. That's the LLM's default behavior, triggered when it judges that "the only place to write is the CLI's stdout."

**Fix.** I added the following to the end of the prompt.

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

Naming the concrete tool ("the Write tool") and explicitly forbidding the opposite behavior ("do not paste into the reply") stabilized the file generation rate. That wording is still in the current script verbatim.

**Symptom.** One morning, an important skill I'd written myself (`~/.claude/skills/auto/deploy-preflight/SKILL.md`

) had been moved to `.archive/`

. It contained hand-written notes, and they were gone (to be precise, only moved — but I didn't notice and thought they were gone).

**Cause.** The initial version of `skill-curate.sh`

had no `author: auto`

guard, so it subjected every skill under `auto/`

to staleness detection. Skills I created manually naturally appear less frequently in conversation logs, so they crossed the 90-day threshold and got moved to `.archive/`

.

**Fix.** After I added the single line `grep -q '^author:[[:space:]]*auto' "$md" || continue`

, incorrect operations on hand-written skills disappeared completely. I also added the inline python3 script on the harvester side to insert `author: auto`

after the fact, so the tag is reliably applied even when the LLM forgets the frontmatter.

``` python
import sys,re
p=sys.argv[1]; s=open(p).read()
if s.startswith('---'):
    s=re.sub(r'^---\n', '---\nauthor: auto\n', s, count=1)
else:
    s='---\nname: %s\nauthor: auto\nversion: 1.0.0\n---\n' \
      % __import__("os").path.basename(__import__("os").path.dirname(p)) + s
open(p,'w').write(s)
```

It's a two-stage structure: insert right after `---\n`

when frontmatter exists, and prepend a minimal structure wholesale when frontmatter is entirely absent. Embedding inline python in a shell script looks a bit odd, but it's more self-contained than managing a separate python3 script file, and the script works wherever you put it on its own.

This is a design decision rather than a blocker, but it hurts if you get it wrong. Initially I only did `touch "$WM"`

when the LLM call succeeded. On timeouts or LLM errors, the intent was to leave the watermark alone so the next run would retry.

In actual operation, though, "conversation logs with nothing worth harvesting" kept being retried every single night. The `$1.20`

budget cap keeps it from being unbounded, but running fruitless LLM calls every day is noise — for the MacBook's battery and in an environmental sense.

The current code unconditionally does `touch "$WM"`

on the final line of the loop, regardless of `rc=$?`

(claude's exit code). Record only the fact that "the conversation logs were read"; don't care whether "a skill was born." Do the work, then stamp the watermark. That was the correct design.

Above I covered six blockers in detail (PATH not set, direct writes to `~/.claude/`

blocked, system-reminder misrecognition, pasting into text, mistakenly archiving hand-written skills, watermark timing). Here I'll list the other "you won't know until you try it" gotchas, grounded in the actual code.

**launchd's log and the script's log are separate files.** The plist specifies `StandardErrorPath`

as `~/.claude/logs/com.shun.skill-harvest.log`

. The log the script itself writes is `LOG="$AUTO/.harvest.log"`

, i.e. `~/.claude/skills/auto/.harvest.log`

. Launch failures (cases where zsh itself can't start, for instance) only appear in the plist-side log. Nothing gets written to `harvest.log`

. When debugging you must check both places. I missed launchd-side errors for the first two weeks because I didn't realize this.

**Not knowing curate.sh's nollm option means waiting 10 minutes per edit.** Passing

`nollm`

as the script's first argument skips the LLM call (`RUN_LLM="${1:-llm}"`

). Use it when you only want to verify the staleness detection, archive moves, and snapshot logic. Without knowing it, every edit→test→edit cycle triggers an LLM call with a `$5.00`

cap.**Snapshots pile up.** A tar.gz is generated weekly. Once the library grows to 200 skills, one snapshot is several MB, and in a year 50+ accumulate in `auto/.snapshots/`

. The current script has no auto-deletion logic. Realistically, delete them manually now and then, or append one line to the end of curate.sh: `find "$SNAP" -name '*.tar.gz' -mtime +180 -delete`

.

**Forget --add-dir and curate.sh's LLM can't read under AUTO.** curate.sh uses

`--add-dir "$AUTO"`

to add the AUTO tree to the LLM's readable directories. harvest.sh solves this implicitly by making staging the cwd via `cd "$STAGING"`

, but in curate.sh the working directory is a temp directory under `/tmp`

. Omit `--add-dir`

and the Read tool stops with "access denied" when it tries to reach SKILL.md.**The active >= 2 condition skips the LLM proposals.** curate.sh's branch is

`(( active >= 2 ))`

. With one or fewer active skills, the LLM consolidation proposal doesn't run. That's why running curate.sh manually right after setup doesn't produce a `.curator-proposals.md`

.**Inconsistent kebab-case skill names breed duplicates.** The existing-skill list harvest.sh passes to the LLM is only a comma-separated list of directory names (`existing=$(ls "$AUTO" 2>/dev/null | grep -v '^\.' | tr '\n' ',')`

). Contents aren't passed, so names like `launchd-path-setup`

and `launchd-env-vars`

— similar but not identical — get created as separate skills. Content-level duplication can only be detected by the weekly curate.sh LLM proposals, so you need to review the proposals file regularly and merge.

**Partial matching in grep -rl -- "$skill" throws off staleness detection.** curate.sh treats the newest mtime of conversation logs containing the skill name as the last-used date. If a skill name is a generic word like

`log`

, `api`

, or `test`

, it matches every occurrence in the conversation logs and is falsely judged "always in use." Making skill names as unique and specific as possible (`claude-headless-staging-pattern`

, `launchd-nvm-path-workaround`

) is a precondition for keeping staleness detection accurate.**PER_LOG_BYTES=15000 drops discoveries from the second half.** Each log is cut at 15,000 bytes (`grep ... "$f" | head -c $PER_LOG_BYTES`

). In long conversations, the final solution written in the second half falls outside the harvest scope. Since the common pattern is problem framing in the first half and solution in the second, I actually hit cases where only the solution got dropped. Either split sessions into shorter saved chunks, or tune MAX_LOGS and PER_LOG_BYTES for your environment.

**After an nvm upgrade, the plist's PATH points at the old version.** The plist's `EnvironmentVariables`

includes a node version number like `v24.13.0`

. Every time you update node with nvm, you need to fix the plist and reload it via `launchctl unload`

→ `launchctl load`

. Forget it, and the batch silently fails at the next 3:30 AM. I strongly recommend baking this step into your node upgrade checklist.

**Omit --permission-mode acceptEdits and Write stalls.** Even in

`-p`

(headless) mode, omitting `permission-mode`

makes it try to show a confirmation prompt before running the Write tool, and with stdin at `/dev/null`

it waits forever. Via launchd it gets force-killed by the timeout after 10 minutes. Both harvest.sh and curate.sh specify `--permission-mode acceptEdits`

explicitly; it's a mandatory option to always write alongside `-p`

.Here are the rules that stuck after more than six months of actual operation, paired with the real code.

**① Write PATH in both the plist and the script**

```
# スクリプト冒頭
export PATH="$HOME/.local/bin:$HOME/.nvm/versions/node/v24.13.0/bin:/usr/bin:/bin:/usr/sbin:/sbin"
```

Write the same PATH into the plist's `EnvironmentVariables`

too. It looks redundant, but it's a double setting to guarantee it works both via launchd and via direct invocation. With only one of them, you get the symptom "it works when I run it manually from the terminal but not from launchd." Designing so it reliably works via either path is the iron rule of scheduled batches.

**② Always use the staging pattern for writes under ~/.claude/**

```
STAGING=$(mktemp -d -t skill-harvest-stg)
( cd "$STAGING" && "$CLAUDE" -p "$PROMPT" --permission-mode acceptEdits ... )
cp -R "$sd" "$AUTO/$name"   # shellがコピー
```

Make "the LLM writes to staging, the shell copies" the standing division of labor. Reuse this pattern in other automations whenever you have an LLM create files under `~/.claude/`

.

**③ Guarantee the author: auto tag in two stages**

The LLM sometimes forgets to write frontmatter. harvest.sh adds a shell-side post-check.

```
grep -q '^author:[[:space:]]*auto' "$sd/SKILL.md" || python3 - "$sd/SKILL.md" <<'PY'
import sys, re
p = sys.argv[1]; s = open(p).read()
if s.startswith('---'):
    s = re.sub(r'^---\n', '---\nauthor: auto\n', s, count=1)
else:
    s = '---\nname: %s\nauthor: auto\nversion: 1.0.0\n---\n' \
      % __import__("os").path.basename(__import__("os").path.dirname(p)) + s
open(p, 'w').write(s)
PY
```

Without this tag, curate.sh mistakes the skill for a manual one and stops touching it. Rather than relying on prompt instructions alone, it's safer to design a shell-side fallback that force-applies it when missing.

**④ Update the watermark regardless of success or failure**

```
# スクリプト末尾
touch "$WM"
exit 0
```

`touch "$WM"`

regardless of the LLM's exit code. This avoids the cost of retrying "conversation logs not worth harvesting" every night. Record only the fact that "the conversation logs were read"; don't care whether "a skill was born." That was the correct design.

**⑤ Pass the existing-skill list to the LLM and delegate the dedup judgment**

```
existing=$(ls "$AUTO" 2>/dev/null | grep -v '^\.' | tr '\n' ',')
# プロンプトに渡す
# 既存の auto スキル（重複作成は禁止。重複するなら新規作成せず既存を patch）:
# ${existing:-（なし）}
```

Detecting "effectively duplicate" skills whose names don't match exactly is hard with shell-side logic. It's more practical to show the LLM the list and let it decide "if this resembles one of those, don't create it."

**⑥ Detach stdin with /dev/null**

```
"$CLAUDE" ... >> "$LOG" 2>&1 < /dev/null
```

Via launchd there's no guarantee stdin is connected to the null device. Omit this and the LLM hangs waiting for input. Alongside `--permission-mode acceptEdits`

, it's a mandatory option for headless batch execution. Both harvest.sh and curate.sh have it.

**⑦ Restrict tools to three with --allowedTools**

```
--allowedTools "Write Edit Read"
```

The only operation the harvester needs is creating files in staging. Running the batch with Bash, WebFetch, and MCP available risks unintended side effects (git push, URL fetches) driven by context in the conversation logs. The principle of least privilege applies to batches too.

**⑧ Write concrete firing conditions in SKILL.md's description**

harvest.sh's prompt describes the field as `description`

(when it should fire).

Claude Code looks at this field to decide "read the skills relevant to this task." The more concrete the firing condition — "when creating a launchd plist," "when hitting an nvm PATH problem" — the better Claude Code auto-references it at the right moment. Generic description text doesn't get referenced.

**⑨ Keep curate.sh non-destructive**

```
# 実削除なし。移動のみ。
mv "$d" "$ARCH/"
```

No real deletes — only moves to `.archive/`

. Even if the staleness detection was wrong, you can restore from `auto/.archive/`

to the original location. Add the pre-change snapshot and you have a double safety net. Deleting from an automation script is an irreversible operation. When in doubt, always choose "move."

**⑩ Do test runs quickly with the nollm option**

```
skill-curate.sh nollm
```

After editing the script, use `nollm`

to skip the LLM and check only the staleness detection and archiving. A full test involving LLM calls is enough once or twice a month. Just eliminating the 10-minute wait per edit cycle dramatically lowers the psychological cost of improving the script.

**⑪ Make skill names unique and fairly long kebab-case**

curate.sh's staleness detection searches for the skill name with `grep -rl -- "$skill"`

. Short generic names produce a lot of noise. Unique, specific names like `launchd-nvm-path-workaround`

and `claude-headless-staging-pattern`

markedly improve both staleness accuracy and dedup effectiveness.

**⑫ Disable MCP completely**

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

Both harvest.sh and curate.sh explicitly disable MCP connections with empty JSON. This entirely cuts off the risk of connecting to external services via MCP during batch execution. Thorough application of least privilege.

**⑬ Put an expiry on snapshots**

Just one line appended to the end of curate.sh.

```
find "$SNAP" -name '*.tar.gz' -mtime +180 -delete
```

This auto-deletes snapshots older than 180 days (about six months). The current script doesn't have this line, so if you're running long-term I recommend adding it early.

Looking back at this design where `skill-harvest.sh`

and `skill-curate.sh`

work together, it comes down to three principles.

**Knowledge that spans sessions gets dumped to external files automatically.** Claude Code's context disappears every session. A design that automatically picks up procedural knowledge from conversation logs and saves it as SKILL.md is the simplest way to compensate for the model's memory limits with an external filesystem. The moment a human has to think "I should take a note," saving fails. Saving doesn't last unless it's automatic.

**Separate the creating mechanism from the curating mechanism on different time axes.** The division of labor works: the daily harvest only "adds," the weekly curate only "organizes." harvest's LLM budget is `$1.20`

, curate's is `$5.00`

— different settings for different purposes. Cram both into the same script and the quality of both drops. Separating tasks with different frequencies, different budgets, and different destructiveness is the principle.

**Reserve human involvement for the final judgment only.** curate.sh's LLM call only writes `.curator-proposals.md`

; it makes no real changes. Full automation creates the risk that "an important skill disappeared without me noticing." Keeping a thin layer of human involvement — a once-a-week proposal review — gets you both the speed and the safety of automation.

Now that the skill library has passed 200 entries, yesterday's learnings are already referenceable the moment a morning session starts. The "I looked this up before, didn't I?" feeling is basically gone. Building an environment where next week's you starts out smarter than this week's you changes long-term output far more than increasing how much you get done in a day.

I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and a 30-day walkthrough 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)*
