{"slug": "claude-forgets-everything-overnight-the-3-30-am-batch-that-harvests-what-it", "title": "Claude Forgets Everything Overnight — The 3:30 AM Batch That Harvests What It Learned", "summary": "A developer built a batch process that runs at 3:30 AM to harvest non-obvious procedures from Claude Code conversation logs and save them as reusable skill files, enabling the AI assistant to start each day with accumulated knowledge. The engineer reports that this environment investment, rather than raw coding speed, drove their revenue from ¥100,000 to ¥1.2 million per month across multiple gigs.", "body_md": "My conversation logs are gone by morning. My environment, meanwhile, wakes up smarter than it went to bed.\n\nWhen 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*.\n\n**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.\n\nWhen 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.\n\nThe 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/`\n\nis 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.\n\nMost 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.\n\nInvesting 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.\n\nThe 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.\n\nThe 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.\n\n```\n# ~/.claude/CLAUDE.md（抜粋）\n## スキル自己生成（auto-skills）\n再利用価値のある手順（5回以上ツールの非自明タスク完遂・回避策発見・\nアプローチ修正された・再利用手順発見）は頼まれなくても\n`~/.claude/skills/auto/<kebab-name>/SKILL.md` に自作する。\n```\n\nBut 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.\n\n**That's why I needed a batch process that re-reads conversation logs after the fact and harvests skills from them.** `skill-harvest.sh`\n\nplays 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/`\n\n. Humans do nothing. You wake up and the environment has grown.\n\nEven 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**.\n\nThat is exactly what `skill-harvest.sh`\n\nsolves. 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.\n\nHere's the big picture as an ASCII diagram.\n\n```\n毎晩の会話ログ (.md)\n  ~/Documents/my-knowledge-base/raw/conversations/\n         │\n         │ find -name '*.md' -newer .harvest-watermark\n         ↓\n   新着ログを最大3本選定（MAX_LOGS=3）\n         │\n         │ grep -v system-reminder | head -c 15000\n         ↓\n   ダイジェスト生成（最大45KB→ノイズ除去）\n         │\n         │ claude -p --model sonnet --max-budget-usd 1.20\n         ↓\n   ステージングディレクトリへ SKILL.md を生成\n   /tmp/skill-harvest-stg.XXXXX/\n     └── <kebab-name>/\n           └── SKILL.md\n         │\n         │ author:auto 確認 + 既存スキルとの重複チェック\n         ↓\n   ~/.claude/skills/auto/ へコピー\n   .harvest-watermark を更新\n         │\n         │ 週次（手動 or cron）\n         ↓\n   skill-curate.sh による整理\n     ├── 会話ログへの言及が30日ない → status: stale\n     ├── 90日ない → .archive へ退避\n     └── 新着スキルを LLM で分析 → .curator-proposals.md（提案のみ）\n```\n\nlaunchd kicks off this flow every morning at 3:30. No human involvement is required — you just review the proposals file once a week.\n\nLet's look inside `com.shun.skill-harvest.plist`\n\n.\n\n```\n<key>StartCalendarInterval</key>\n<dict>\n    <key>Hour</key>\n    <integer>3</integer>\n    <key>Minute</key>\n    <integer>30</integer>\n</dict>\n<key>LowPriorityIO</key>\n<true/>\n<key>Nice</key>\n<integer>10</integer>\n<key>ProcessType</key>\n<string>Background</string>\n```\n\n3: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`\n\nand `Nice: 10`\n\nexplicitly 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.\n\nThe 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.\n\nThe PATH environment variable is set explicitly inside the plist.\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n    <key>PATH</key>\n    <string>~/.nvm/versions/node/v24.13.0/bin:\n            /opt/homebrew/bin:/opt/homebrew/sbin:\n            /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:\n            ~/.local/bin</string>\n</dict>\n```\n\nThis is a famous launchd trap. Jobs launched via launchd do not read `~/.zshrc`\n\n. Node installed via `nvm`\n\nisn't on the path either, so the `claude`\n\ncommand isn't found and the batch quietly exits. In practice I also set `export PATH=...`\n\na 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.\n\nThe first important piece of logic in `skill-harvest.sh`\n\nis the watermark handling.\n\n```\nAUTO=\"$HOME/.claude/skills/auto\"\nLOGS=\"$HOME/Documents/my-knowledge-base/raw/conversations\"\nWM=\"$AUTO/.harvest-watermark\"\n\n# 前回以降に更新されたログを新しい順に収集（初回は最新 MAX_LOGS 件）\nif [[ -f \"$WM\" ]]; then\n  newlogs=(\"${(@f)$(find \"$LOGS\" -name '*.md' -newer \"$WM\" 2>/dev/null)}\")\nelse\n  newlogs=(\"${(@f)$(ls -t \"$LOGS\"/*.md 2>/dev/null)}\")\nfi\n# mtime 降順に並べ替えて上位 MAX_LOGS 件に絞る\nif (( ${#newlogs} > 0 )); then\n  newlogs=(\"${(@f)$(ls -t \"${newlogs[@]}\" 2>/dev/null | head -$MAX_LOGS)}\")\nfi\n```\n\nThe mtime of a zero-byte file called `.harvest-watermark`\n\nrepresents \"the last run time.\" `find -newer $WM`\n\nlimits the target to logs updated since the last run, and `head -$MAX_LOGS`\n\nnarrows it to at most three.\n\nWhy three? Cost control for the downstream LLM call. With `MAX_LOGS=3`\n\nand `PER_LOG_BYTES=15000`\n\n, the theoretical upper bound on data handled in one harvest is 45KB. Conversation logs contain huge numbers of `<system-reminder>`\n\nblocks (lists of available skills and so on), and passing them to the LLM as-is means most of it is noise.\n\n```\nDIGEST=$(mktemp -t skill-harvest)\nfor f in \"${newlogs[@]}\"; do\n  {\n    echo \"===== LOG: ${f:t} =====\"\n    # 巨大な <system-reminder> ブロックを大まかに除去してからバイト上限で切る\n    grep -v -e 'system-reminder' -e '^- [a-z0-9].*:' \"$f\" 2>/dev/null \\\n      | head -c $PER_LOG_BYTES\n    echo\n  } >> \"$DIGEST\"\ndone\n```\n\n`grep -v -e 'system-reminder'`\n\nremoves lines containing the `<system-reminder>`\n\ntag, and the `'^- [a-z0-9].*:'`\n\npattern additionally filters out bullet-list skill inventory lines. What's left is just the actual conversation content.\n\nBecause 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.\n\nHere's the skeleton of the prompt that hands the generated digest to the LLM.\n\n```\nexisting=$(ls \"$AUTO\" 2>/dev/null | grep -v '^\\.' | tr '\\n' ',')\n\nPROMPT=\"あなたはスキルライブラリのハーベスターです。\n下記の会話ログ抜粋から、将来再利用できる『手順的知識』だけをスキル化してください。\n\n（ダイジェスト本文）\n\n既存の auto スキル（重複作成は禁止。重複するなら新規作成せず既存を patch）:\n${existing:-（なし）}\n\n抽出基準:\n- 複数手順を要する非自明な作業フロー / エラー回避策 / 繰り返し使えるコマンド列\n- 一度きり・自明・雑談・個人情報は対象外\n- 該当が無ければ何もファイルを作らず『該当なし』とだけ述べて終了\n\n各スキルの作り方:\n- ファイル: ./<kebab-name>/SKILL.md（カレントディレクトリ直下・絶対パス禁止）\n- frontmatter: name / description / author: auto / created / version: 1.0.0 / status: active\n- 本文: ## Procedure / ## Pitfalls / ## Verification の3節\n- 1スキル＝1手順で小さく保つ\"\n```\n\nThe key point is passing the list of existing skill directory names as `existing`\n\nfor deduplication. This lets the LLM decide for itself: \"this skill already exists as `codex-delegation-handoff`\n\n, so no new file is needed.\"\n\nAlso look at the frontmatter field described as `description`\n\n(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.\n\nThis is the least obvious design decision in the whole script.\n\n```\n# ~/.claude 配下は Claude Code が書き込み保護するため、claude には\n# ステージング(cwd)へ相対パスで書かせ、後段で shell が AUTO へコピーする。\nSTAGING=$(mktemp -d -t skill-harvest-stg)\n( cd \"$STAGING\" && perl -e 'alarm shift @ARGV; exec @ARGV' \"$TIMEOUT_SEC\" \\\n  \"$CLAUDE\" --strict-mcp-config --mcp-config '{\"mcpServers\":{}}' -p \"$PROMPT\" \\\n  --model sonnet \\\n  --permission-mode acceptEdits \\\n  --allowedTools \"Write Edit Read\" \\\n  --max-budget-usd \"$BUDGET_USD\" >> \"$LOG\" 2>&1 < /dev/null )\n```\n\nClaude Code protects `~/.claude/`\n\nfrom external process writes. A subprocess invoked with `claude -p`\n\nthat tries to write files directly into `~/.claude/skills/auto/`\n\ngets blocked. So first I create a temp directory, `/tmp/skill-harvest-stg.XXXXX/`\n\n, and launch `claude -p`\n\nwith that directory as cwd.\n\nThat's why the LLM instructions say \"create the file as `./<kebab-name>/SKILL.md`\n\n**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/`\n\n.\n\nFor the timeout I use `perl -e 'alarm ...; exec ...'`\n\n. zsh's `timeout`\n\ncommand 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`\n\n, i.e. 10 minutes.\n\nThe budget cap is `--max-budget-usd 1.20`\n\n. 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.\"\n\nSeveral checks run before staging-directory files are copied into `AUTO`\n\n.\n\n```\nfor sd in \"$STAGING\"/*(/N); do\n  [[ -f \"$sd/SKILL.md\" ]] || continue\n  name=\"${sd:t}\"\n  [[ \"$name\" == .* ]] && continue\n  # author: auto を保証（無ければ frontmatter 直後に挿入）\n  grep -q '^author:[[:space:]]*auto' \"$sd/SKILL.md\" || python3 - \"$sd/SKILL.md\" <<'PY'\nimport sys,re\np=sys.argv[1]; s=open(p).read()\nif s.startswith('---'):\n    s=re.sub(r'^---\\n', '---\\nauthor: auto\\n', s, count=1)\n...\nopen(p,'w').write(s)\nPY\n  if [[ -e \"$AUTO/$name\" ]]; then\n    echo \"[$(ts)] exists, skip copy: $name\" >> \"$LOG\"\n  else\n    cp -R \"$sd\" \"$AUTO/$name\" && { echo \"[$(ts)] CREATED: $name\" >> \"$LOG\"; ((created++)); }\n  fi\ndone\n```\n\nThree validations run.\n\n**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.\n\n**2. Dotfile exclusion.** `[[ \"$name\" == .* ]]`\n\nexcludes hidden directories like `.archive`\n\nand `.snapshots`\n\n.\n\n**3. Forcing author: auto.** This is the linchpin of the integration with weekly curation. The\n\n`skill-curate.sh`\n\ndescribed 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`\n\n. 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\" ]]`\n\nskips 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.\n\nIf `skill-harvest.sh`\n\nis in charge of \"creating,\" `skill-curate.sh`\n\nis in charge of \"curating.\" This weekly script does three jobs.\n\n**Job 1: pre-run snapshot.**\n\n```\ntar czf \"$SNAP/auto-$(date +%Y%m%d-%H%M%S).tar.gz\" \\\n  -C \"$HOME/.claude/skills\" \\\n  --exclude='auto/.snapshots' --exclude='auto/.archive' auto 2>/dev/null\n```\n\nBefore changing anything, it takes a tar.gz of the whole thing. `skill-curate.sh`\n\nis non-destructive by design (no real deletes), but this prevents a mistaken move operation from becoming unrecoverable. Snapshots accumulate in `auto/.snapshots/`\n\n.\n\n**Job 2: staleness detection and archiving.**\n\n```\nSTALE_DAYS=30\nARCHIVE_DAYS=90\n\n# 最終使用日: スキル名を含む会話ログの最新mtime\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\nThe 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.\"\n\nIf there's been no mention for 30 days or more, the frontmatter's `status:`\n\nfield is rewritten to `stale`\n\n. At 90 days or more, it's physically moved into the `.archive/`\n\ndirectory. Both target only skills carrying `author: auto`\n\n— hand-written skills are never touched.\n\n```\nif ! grep -q '^author:[[:space:]]*auto' \"$md\"; then\n    echo \"[$(ts)] skip (not author:auto): $skill\" >> \"$LOG\"\n    continue\nfi\n```\n\n**Job 3: LLM consolidation proposals.**\n\n```\n( cd \"$STG\" && \"$CLAUDE\" -p \"... 重複・低品質・統合候補を洗い出してください ...\" \\\n  --model sonnet \\\n  --permission-mode acceptEdits \\\n  --allowedTools \"Write Edit Read\" \\\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\nWhen 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`\n\n, because once you pass 100 skills there's more to analyze.\n\nThe 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`\n\n, 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.\n\n| Aspect | skill-harvest.sh | skill-curate.sh |\n|---|---|---|\nFrequency |\nDaily, 3:30 AM | Weekly |\nDirection |\nCreate (add) | Curate (stale/archive) |\nLLM budget cap |\n$1.20 | $5.00 |\nTarget logs |\nNew since last run (max 3) | Last-used date of all active skills |\nDestructiveness |\nNo overwrites (skip) | Moves only (no real deletes) |\nHuman involvement |\nNone | Proposal review only |\nSafety guard |\nForces `author:auto` tagging |\nNever touches non-`author:auto`\n|\n\nBecause 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.\"\n\n`${(@f)...}`\n\nIs Necessary\nThe array-building code at the top of the script looks meaningless at first.\n\n```\nnewlogs=(\"${(@f)$(find \"$LOGS\" -name '*.md' -newer \"$WM\" 2>/dev/null)}\")\n```\n\n`${(@f)...}`\n\nis a zsh-specific expansion flag meaning \"split on newlines and convert to array elements.\" Command substitution with `$()`\n\njust returns a string, so a plain `newlogs=($(...))`\n\nwould also split on whitespace and break the array for path names containing spaces (e.g. `my conversation log.md`\n\n). Using `(@f)`\n\nto make the newline the only delimiter keeps such paths as a single element.\n\nThe next line is the same.\n\n```\nnewlogs=(\"${(@f)$(ls -t \"${newlogs[@]}\" 2>/dev/null | head -$MAX_LOGS)}\")\n```\n\nPass the multiple files found by `find`\n\nto `ls -t`\n\nto sort them by descending mtime, and keep only the three newest with `head -3`\n\n. It looks simple, but writing this in bash requires setting `IFS=$'\\n'`\n\nand using `mapfile`\n\n, which hurts portability and readability. This conciseness of array handling is one reason I rewrote it as a zsh script.\n\n`perl`\n\nfor the Timeout\n\n```\n( cd \"$STAGING\" && perl -e 'alarm shift @ARGV; exec @ARGV' \"$TIMEOUT_SEC\" \\\n  \"$CLAUDE\" ... )\n```\n\nWhy use perl's alarm when `timeout 600 claude ...`\n\nwould be simpler? macOS's `/usr/bin/timeout`\n\npropagates SIGALRM slightly differently from Linux's `timeout`\n\n. In particular, signal delivery to a process replaced via `exec`\n\nisn't guaranteed in some cases, and with a structure that invokes the `claude`\n\nprocess internally via `exec`\n\n, I saw the parent die while child processes lingered.\n\n`perl -e 'alarm shift @ARGV; exec @ARGV'`\n\nmeans \"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`\n\n(SIGALRM's exit code), so you can also use it to detect timeout firing.\n\n```\n\"$CLAUDE\" --strict-mcp-config --mcp-config '{\"mcpServers\":{}}' -p \"$PROMPT\" \\\n  --permission-mode acceptEdits \\\n  --allowedTools \"Write Edit Read\"\n```\n\n`--strict-mcp-config --mcp-config '{\"mcpServers\":{}}'`\n\ndisables all MCP servers, and `--allowedTools \"Write Edit Read\"`\n\nrestricts the LLM to just three tools.\n\nThe 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`\n\n.\n\nDetaching stdin with `< /dev/null`\n\nmatters 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\n`/*(/N)`\n\nGlob Qualifier\nThe pre-copy loop looks like this.\n\n```\nfor sd in \"$STAGING\"/*(/N); do\n```\n\nThe zsh glob qualifier `(/N)`\n\nmeans \"directories only, and don't error on no match.\" With plain `*`\n\n, any files mixed into staging would also be processed — they'd just be skipped by `[[ -f \"$sd/SKILL.md\" ]]`\n\n, but it's a wasted loop iteration. Adding `/N`\n\nreturns only directories from the start and cuts down the conditional checks inside the loop.\n\nThe part that computes \"how many days since it was last used\" for staleness detection turned out unexpectedly robust.\n\n```\nlastlog, created, md = (sys.argv + [\"\",\"\",\"\"])[1:4]\nref = None\nif lastlog.strip():\n    try: ref = float(lastlog)   # ①会話ログのmtime（unix timestamp）\n    except: ref = None\nif ref is None and created.strip():\n    try: ref = time.mktime(datetime.datetime.strptime(created.strip(), \"%Y-%m-%d\").timetuple())  # ②frontmatterのcreated\n    except: ref = None\nif ref is None:\n    ref = os.path.getmtime(md)  # ③SKILL.md自体のmtime\n```\n\nThe priority order is \"① appearance in conversation logs → ② the frontmatter `created`\n\ndate → ③ the file's mtime.\"\n\n① matters most: if a skill is actually referenced in real work, its name appears in the conversation logs. `grep -rl -- \"$skill\" \"$LOGS\"`\n\nsearches 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 ②.\n\n② is the date the skill was created. Harvester-generated skills have frontmatter like `created: 2026-07-15`\n\n, which is treated as the creation date. If neither ① nor ② is available, it falls back to ③, the file's modification time.\n\nThanks 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.\n\n`stat -f '%m'`\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\n`stat -f '%m'`\n\nis macOS/BSD syntax. On Linux (GNU stat) it's `stat -c '%Y'`\n\n. 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.\n\nThe design looks clean, but I got stuck many times before this setup actually worked. Symptom, cause, fix — in that order.\n\n`claude`\n\nNot Found, Silent Exit\n**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.\n\n**Cause.** The check at the top of the script, `[[ -x \"$CLAUDE\" ]] || { echo \"claude not found\" >> \"$LOG\"; exit 0; }`\n\n, was firing — but the log's target directory itself didn't exist, so `>> \"$LOG\"`\n\nalso failed and vanished. In other words, the error about the error got swallowed.\n\nThe root cause was that `$CLAUDE`\n\n'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`\n\n. I fixed it by adding the following to the plist's `EnvironmentVariables`\n\n.\n\n```\n<key>PATH</key>\n<string>~/.nvm/versions/node/v24.13.0/bin:\n        /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>\n```\n\nOn top of that, the script re-sets `export PATH=...`\n\nat the top — a duplicate setting. Debugging when either one is missing is hell, so writing it in both places is the right answer.\n\n`~/.claude/`\n\nand Gets Blocked\n**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`\n\n. On execution, `claude -p`\n\nwould stop midway, log an error equivalent to `Permission denied`\n\n, and exit. Created count was always 0.\n\n**Cause.** Claude Code protects its own config directory (`~/.claude/`\n\n) from external writes. A subprocess invoked with `claude -p`\n\ngets blocked when it tries to create files under `~/.claude/`\n\nwith the Write tool.\n\n**Fix.** I rewrote the prompt instructions to \"no absolute paths; create `./<kebab-name>/SKILL.md`\n\ndirectly under the current directory,\" and changed the design so that `cd \"$STAGING\"`\n\nmoves into the staging directory before launching `claude -p`\n\n. The LLM writes into staging, and the shell copies from there into `~/.claude/skills/auto/`\n\n. Shell copies aren't gated.\n\nThis 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`\n\n.\n\n**Symptom.** In the version before I added the `grep -v 'system-reminder'`\n\nfilter 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`\n\n\" — as if copied straight from existing skill descriptions.\n\n**Cause.** Claude Code conversation logs contain huge blocks wrapped in `<system-reminder>`\n\ntags, 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.\n\n**Fix.** I made the filter double up: `grep -v -e 'system-reminder' -e '^- [a-z0-9].*:'`\n\n. The first removes lines containing the `<system-reminder>`\n\ntag; the second removes bullet-list skill inventory lines like `- code-tour:`\n\nor `- agent-browser: Use when...`\n\n. After adding this filter, the frequency of bogus \"skills\" dropped dramatically.\n\n**Symptom.** The logs show the LLM saying \"I generated the following skills,\" but the staging directory is empty. It ends with `created=0`\n\n.\n\n**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.\"\n\n**Fix.** I added the following to the end of the prompt.\n\n```\n【最重要・厳守】\n- 各スキルは必ず **Write ツール** を使って ./<kebab-name>/SKILL.md として実際にファイル作成すること\n- スキル本文をこの返信メッセージに貼り付けてはいけない。必ずファイルに書き込む\n- ファイルを書き終えたら、作成したスキル名だけを箇条書きで報告する（本文は不要）\n```\n\nNaming 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.\n\n**Symptom.** One morning, an important skill I'd written myself (`~/.claude/skills/auto/deploy-preflight/SKILL.md`\n\n) had been moved to `.archive/`\n\n. It contained hand-written notes, and they were gone (to be precise, only moved — but I didn't notice and thought they were gone).\n\n**Cause.** The initial version of `skill-curate.sh`\n\nhad no `author: auto`\n\nguard, so it subjected every skill under `auto/`\n\nto 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/`\n\n.\n\n**Fix.** After I added the single line `grep -q '^author:[[:space:]]*auto' \"$md\" || continue`\n\n, incorrect operations on hand-written skills disappeared completely. I also added the inline python3 script on the harvester side to insert `author: auto`\n\nafter the fact, so the tag is reliably applied even when the LLM forgets the frontmatter.\n\n``` python\nimport sys,re\np=sys.argv[1]; s=open(p).read()\nif s.startswith('---'):\n    s=re.sub(r'^---\\n', '---\\nauthor: auto\\n', s, count=1)\nelse:\n    s='---\\nname: %s\\nauthor: auto\\nversion: 1.0.0\\n---\\n' \\\n      % __import__(\"os\").path.basename(__import__(\"os\").path.dirname(p)) + s\nopen(p,'w').write(s)\n```\n\nIt's a two-stage structure: insert right after `---\\n`\n\nwhen 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.\n\nThis is a design decision rather than a blocker, but it hurts if you get it wrong. Initially I only did `touch \"$WM\"`\n\nwhen the LLM call succeeded. On timeouts or LLM errors, the intent was to leave the watermark alone so the next run would retry.\n\nIn actual operation, though, \"conversation logs with nothing worth harvesting\" kept being retried every single night. The `$1.20`\n\nbudget 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.\n\nThe current code unconditionally does `touch \"$WM\"`\n\non the final line of the loop, regardless of `rc=$?`\n\n(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.\n\nAbove I covered six blockers in detail (PATH not set, direct writes to `~/.claude/`\n\nblocked, 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.\n\n**launchd's log and the script's log are separate files.** The plist specifies `StandardErrorPath`\n\nas `~/.claude/logs/com.shun.skill-harvest.log`\n\n. The log the script itself writes is `LOG=\"$AUTO/.harvest.log\"`\n\n, i.e. `~/.claude/skills/auto/.harvest.log`\n\n. Launch failures (cases where zsh itself can't start, for instance) only appear in the plist-side log. Nothing gets written to `harvest.log`\n\n. When debugging you must check both places. I missed launchd-side errors for the first two weeks because I didn't realize this.\n\n**Not knowing curate.sh's nollm option means waiting 10 minutes per edit.** Passing\n\n`nollm`\n\nas the script's first argument skips the LLM call (`RUN_LLM=\"${1:-llm}\"`\n\n). 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`\n\ncap.**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/`\n\n. 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`\n\n.\n\n**Forget --add-dir and curate.sh's LLM can't read under AUTO.** curate.sh uses\n\n`--add-dir \"$AUTO\"`\n\nto add the AUTO tree to the LLM's readable directories. harvest.sh solves this implicitly by making staging the cwd via `cd \"$STAGING\"`\n\n, but in curate.sh the working directory is a temp directory under `/tmp`\n\n. Omit `--add-dir`\n\nand 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\n\n`(( active >= 2 ))`\n\n. 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`\n\n.**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' ',')`\n\n). Contents aren't passed, so names like `launchd-path-setup`\n\nand `launchd-env-vars`\n\n— 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.\n\n**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\n\n`log`\n\n, `api`\n\n, or `test`\n\n, 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`\n\n, `launchd-nvm-path-workaround`\n\n) 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`\n\n). 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.\n\n**After an nvm upgrade, the plist's PATH points at the old version.** The plist's `EnvironmentVariables`\n\nincludes a node version number like `v24.13.0`\n\n. Every time you update node with nvm, you need to fix the plist and reload it via `launchctl unload`\n\n→ `launchctl load`\n\n. Forget it, and the batch silently fails at the next 3:30 AM. I strongly recommend baking this step into your node upgrade checklist.\n\n**Omit --permission-mode acceptEdits and Write stalls.** Even in\n\n`-p`\n\n(headless) mode, omitting `permission-mode`\n\nmakes it try to show a confirmation prompt before running the Write tool, and with stdin at `/dev/null`\n\nit waits forever. Via launchd it gets force-killed by the timeout after 10 minutes. Both harvest.sh and curate.sh specify `--permission-mode acceptEdits`\n\nexplicitly; it's a mandatory option to always write alongside `-p`\n\n.Here are the rules that stuck after more than six months of actual operation, paired with the real code.\n\n**① Write PATH in both the plist and the script**\n\n```\n# スクリプト冒頭\nexport PATH=\"$HOME/.local/bin:$HOME/.nvm/versions/node/v24.13.0/bin:/usr/bin:/bin:/usr/sbin:/sbin\"\n```\n\nWrite the same PATH into the plist's `EnvironmentVariables`\n\ntoo. 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.\n\n**② Always use the staging pattern for writes under ~/.claude/**\n\n```\nSTAGING=$(mktemp -d -t skill-harvest-stg)\n( cd \"$STAGING\" && \"$CLAUDE\" -p \"$PROMPT\" --permission-mode acceptEdits ... )\ncp -R \"$sd\" \"$AUTO/$name\"   # shellがコピー\n```\n\nMake \"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/`\n\n.\n\n**③ Guarantee the author: auto tag in two stages**\n\nThe LLM sometimes forgets to write frontmatter. harvest.sh adds a shell-side post-check.\n\n```\ngrep -q '^author:[[:space:]]*auto' \"$sd/SKILL.md\" || python3 - \"$sd/SKILL.md\" <<'PY'\nimport sys, re\np = sys.argv[1]; s = open(p).read()\nif s.startswith('---'):\n    s = re.sub(r'^---\\n', '---\\nauthor: auto\\n', s, count=1)\nelse:\n    s = '---\\nname: %s\\nauthor: auto\\nversion: 1.0.0\\n---\\n' \\\n      % __import__(\"os\").path.basename(__import__(\"os\").path.dirname(p)) + s\nopen(p, 'w').write(s)\nPY\n```\n\nWithout 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.\n\n**④ Update the watermark regardless of success or failure**\n\n```\n# スクリプト末尾\ntouch \"$WM\"\nexit 0\n```\n\n`touch \"$WM\"`\n\nregardless 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.\n\n**⑤ Pass the existing-skill list to the LLM and delegate the dedup judgment**\n\n```\nexisting=$(ls \"$AUTO\" 2>/dev/null | grep -v '^\\.' | tr '\\n' ',')\n# プロンプトに渡す\n# 既存の auto スキル（重複作成は禁止。重複するなら新規作成せず既存を patch）:\n# ${existing:-（なし）}\n```\n\nDetecting \"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.\"\n\n**⑥ Detach stdin with /dev/null**\n\n```\n\"$CLAUDE\" ... >> \"$LOG\" 2>&1 < /dev/null\n```\n\nVia 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`\n\n, it's a mandatory option for headless batch execution. Both harvest.sh and curate.sh have it.\n\n**⑦ Restrict tools to three with --allowedTools**\n\n```\n--allowedTools \"Write Edit Read\"\n```\n\nThe 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.\n\n**⑧ Write concrete firing conditions in SKILL.md's description**\n\nharvest.sh's prompt describes the field as `description`\n\n(when it should fire).\n\nClaude 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.\n\n**⑨ Keep curate.sh non-destructive**\n\n```\n# 実削除なし。移動のみ。\nmv \"$d\" \"$ARCH/\"\n```\n\nNo real deletes — only moves to `.archive/`\n\n. Even if the staleness detection was wrong, you can restore from `auto/.archive/`\n\nto 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.\"\n\n**⑩ Do test runs quickly with the nollm option**\n\n```\nskill-curate.sh nollm\n```\n\nAfter editing the script, use `nollm`\n\nto 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.\n\n**⑪ Make skill names unique and fairly long kebab-case**\n\ncurate.sh's staleness detection searches for the skill name with `grep -rl -- \"$skill\"`\n\n. Short generic names produce a lot of noise. Unique, specific names like `launchd-nvm-path-workaround`\n\nand `claude-headless-staging-pattern`\n\nmarkedly improve both staleness accuracy and dedup effectiveness.\n\n**⑫ Disable MCP completely**\n\n```\n--strict-mcp-config --mcp-config '{\"mcpServers\":{}}'\n```\n\nBoth 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.\n\n**⑬ Put an expiry on snapshots**\n\nJust one line appended to the end of curate.sh.\n\n```\nfind \"$SNAP\" -name '*.tar.gz' -mtime +180 -delete\n```\n\nThis 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.\n\nLooking back at this design where `skill-harvest.sh`\n\nand `skill-curate.sh`\n\nwork together, it comes down to three principles.\n\n**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.\n\n**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`\n\n, curate's is `$5.00`\n\n— 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.\n\n**Reserve human involvement for the final judgment only.** curate.sh's LLM call only writes `.curator-proposals.md`\n\n; 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.\n\nNow 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.\n\nI'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.\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/claude-forgets-everything-overnight-the-3-30-am-batch-that-harvests-what-it", "canonical_source": "https://dev.to/bokuwalily/claude-forgets-everything-overnight-the-330-am-batch-that-harvests-what-it-learned-13bo", "published_at": "2026-08-24 05:00:06+00:00", "updated_at": "2026-08-24 05:14:26.531181+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "developer-tools", "machine-learning"], "entities": ["Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/claude-forgets-everything-overnight-the-3-30-am-batch-that-harvests-what-it", "markdown": "https://wpnews.pro/news/claude-forgets-everything-overnight-the-3-30-am-batch-that-harvests-what-it.md", "text": "https://wpnews.pro/news/claude-forgets-everything-overnight-the-3-30-am-batch-that-harvests-what-it.txt", "jsonld": "https://wpnews.pro/news/claude-forgets-everything-overnight-the-3-30-am-batch-that-harvests-what-it.jsonld"}}