Back when I was a college student earning ¥100,000 a month, one finished article meant my week was over. Today launchd stocks one every morning at 8:00, and the series that underpins ¥1.2M in monthly revenue hasn't gone dark once. The difference isn't talent or discipline — it's that I built, exactly one time, an environment that refills its own topic queue.
Why this setup works
When you try to mass-produce articles on your own, you almost always hit the same wall: when the topics run out, everything stops. You can block time on the calendar for topic brainstorming, but if you're not in the mood that day, you skip it. Any design where a human is the bottleneck will jam somewhere, guaranteed.
The strategy I took was to hand the work over to the environment. Not the work of writing articles — instead I assembled, one time only, a "detect that topics ran out and refill them" mechanism plus a "convert topics into articles" mechanism, then left them alone. The only action a human performs is the last one: checking the finished article and publishing it to note or Zenn.
The article-daily-stock.sh script introduced in this article is the core of that. There are three key points in the design.
Key point 1: Decouple stocking from deployment
As the comment at the top of the script says, the design is such that "even if the Zenn deploy (deploy-next) jams, this part does not stop" (lines 1–11 of the actual code). Success or failure of generation is judged solely by "was a stock file written to ~/content/article?" The job that publishes articles (zenn-daily) runs in a separate process, and the two are independent of each other. A structure where one job failing doesn't cause a chain stop is what supports stability over long-term operation.
Key point 2: Use the queue as a buffer
Topics are stacked as an array of objects in a JSON file at ~/zenn-articles/.topic-queue.json. The current queue has 16 topics waiting. Every morning at 8:00 it pulls the first one off, converts it into an article, and when done moves that topic to done-queue. As long as the queue has entries left, the 8 AM job can jump straight into the real work.
The problem is the moment the queue bottoms out. Traditionally that would end with "zero articles today." But I wanted to avoid that. Because once you break the streak for even one day, the "I don't have to write today either" collapse of the habit starts.
Key point 3: When it's empty, Claude invents the topic itself
When the queue goes empty, the script calls neither an external service nor an API — it calls claude -p running locally, has it auto-plan exactly one next article topic, and inserts it at the head of the queue. The basis it uses is "that day's actual work." Daily briefs, memory files, the automation scripts themselves — Claude finds "things that could become a technical article" from the concrete artifacts piling up every day. Presenting real, existing files and paths as evidence rather than fabricating them is hardcoded into the instructions.
If the generated JSON doesn't pass required-field validation, the script exits without writing the done-marker. launchd's catch-up slot (10:35) automatically re-runs the same script, so "refill failure → retry at the next slot" is part of one and the same mechanism.
How the whole thing flows
ASCII diagram
launchd com.shun.article-daily
├─ 8:00 StartCalendarInterval
└─ 10:35 StartCalendarInterval(キャッチアップ)
│
▼
claude-quota-guard.py ← Claude Maxトークン枯渇時に即abort
│
▼
run-and-notify.sh ← 完了/失敗をDiscordへ通知
│
▼
article-daily-stock.sh apply
│
├─ [0] done-marker 確認(当日生成済み?)
│ YES → audit のみ実行して exit 0
│
├─ [1] 全ストックを audit + 自己修復
│ サムネ欠落 → gen_note_thumbs.py で再生成
│ 本文不正 → done-queue からネタを取り戻してキュー再投入
│
├─ [2] jq 'length' .topic-queue.json
│ │
│ ├─ >= 1 → [4] キュー先頭取得 へ
│ │
│ └─ == 0 → [3] 自動立案モード
│ │
│ ▼
│ BRIEF_LATEST(当日のdaily brief md)
│ + ~/.claude/memory/
│ + ~/.claude/scripts/ 実ファイル
│ │
│ ▼
│ claude -p REPLENISH_PROMPT
│ --model sonnet --effort high
│ --max-turns 20
│ │
│ JSON検証(slug / title / sources / thumb_title)
│ ├─ NG → done-marker 書かず exit 0
│ │ ↑ 10:35スロットが拾う
│ └─ OK → queue 先頭へ insert
│
├─ [4] キュー先頭 → SLUG / TITLE / EMOJI / NO を取得
│
├─ [5] claude -p で記事執筆(--max-turns 40, 最大1500秒)
│
├─ [6] 検証(title有無・70字以内・1200bytes以上・スタブ語なし)
│ NG → ファイル破棄 / done-marker 書かず exit 0
│
├─ [7] content/article/articles/ へストック
├─ [8] gen_note_thumbs.py でサムネ生成 → thumbnails/
├─ [9] coverage.json を upsert
├─[10] manifest を ready 化
├─[11] queue pop → done-queue へ移動
└─[12] git push(best-effort・失敗しても done-marker は立つ)
The plist design: why there are two slots
If you look at StartCalendarInterval in ~/Library/LaunchAgents/com.shun.article-daily.plist, the fire times are two slots: 8:00 and 10:35.
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>8</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Hour</key><integer>10</integer>
<key>Minute</key><integer>35</integer>
</dict>
</array>
The first (8:00) is the production slot, the second (10:35) is the catch-up slot. Thanks to the done-marker check at the top of the script, if the day's article has already been generated it "runs only audit + self-repair and exits immediately" (lines 51–53 of the actual code).
SKIP_GEN=0
[ "$MODE" = "apply" ] && [ -f "$DONE_MARKER" ] && SKIP_GEN=1
The point of this design is that it forms a double safety valve: "8:00 succeeds → 10:35 ends with audit only" and "8:00 fails → 10:35 takes over as production." The marker file name uses a date-suffix format, ~/.claude/logs/.article-daily-done-YYYYMMDD, and is auto-deleted after 7 days (line 525 of the actual code).
The actual code, from empty-queue detection through refill
Lines 286–346 of the script are the implementation of automatic topic planning. Let me walk through the key parts.
Step 1: Detect empty
QLEN=$(jq 'length' "$QUEUE" 2>/dev/null || echo 0)
if [ "$QLEN" -eq 0 ]; then
log "queue 空 → 実作業からネタ自動立案"
jq 'length' gets the number of elements in the array. If jq isn't found or there's a syntax error, it falls back to echo 0, so even on error it safely enters topic-planning mode.
Step 2: Gather context
BRIEF_LATEST=$(ls -t "$HOME/Desktop"/*brief* \
"$HOME/Documents/claude-obsidian/wiki/briefs/daily/"*.md \
2>/dev/null | head -1)
To ground everything in "that day's actual work," it looks for the day's daily brief in newest-mtime order. With ls -t priority it scans both the brief on the Desktop and the ones inside the Obsidian vault, and uses only the single newest file.
Step 3: The prompt to Claude
The important part inside the prompt (REPLENISH_PROMPT) is the constraints block.
# 制約
- 既出slugは禁止(重複ネタNG): ${USED}
- 技術記事として1本で完結する具体的な工夫であること(粒度: 1スクリプト/1仕組み)
- 根拠ファイルは必ず実在パスで2〜4個挙げる(~ 表記)
USED holds the list of already-used slugs collected from the existing queue, done-queue, the articles directory, and coverage.json (generated by the used_slugs() function at lines 113–119). If a slug identical to a past article is generated, it gets rejected by the subsequent duplicate check.
The Claude invocation is this code:
TOPIC_JSON=$(run_to 600 "$CLAUDE" -p "$REPLENISH_PROMPT" \
--strict-mcp-config --mcp-config '{"mcpServers":{}}' \
--model "${ARTICLE_MODEL:-sonnet}" --effort high \
--output-format text --allowedTools "Read,Grep,Glob,Bash" --max-turns 20 2>>"$LOG")
run_to 600 is a wrapper around gtimeout 600, imposing a maximum 10-minute timeout. --strict-mcp-config --mcp-config '{"mcpServers":{}}' completely disables MCP servers, allowing only the standard Claude Code tools (Read/Grep/Glob/Bash). The model can be overridden with the ARTICLE_MODEL environment variable; when unset it uses sonnet.
Step 4: Strip the JSON fence and validate
TOPIC_JSON=$(printf '%s' "$TOPIC_JSON" | sed -n '/{/,/}/p')
if ! echo "$TOPIC_JSON" | jq -e \
'.slug and .title and (.sources|length>0) and (.thumb_title|length>0)' \
>/dev/null 2>&1; then
log "ABORT: ネタ自動立案に失敗(JSON不正)。marker無しで次スロット再試行"
notify "ネタ自動立案に失敗。次スロットで再試行。"
exit 0
fi
Anticipating that Claude will attach code fences or a preamble, sed -n '/{/,/}/p' cuts out everything from the first { to the last }. Validation confirms that the four fields slug, title, sources (at least one), and thumb_title (at least one) exist. On validation failure it exits with exit 0 — but the crucial thing is that it does not write the done-marker. Because there's no done-marker, the 10:35 catch-up slot runs the same script again.
Step 5: Overwrite no and prev_slug on the machine side before inserting
TOPIC_JSON=$(echo "$TOPIC_JSON" | jq -c \
--arg no "$NO_NEW" --arg prev "$PREV_NEW" \
'.no=$no | .prev_slug=$prev')
jq --argjson t "$TOPIC_JSON" '[$t] + .' "$QUEUE" > "$QUEUE.tmp" && mv "$QUEUE.tmp" "$QUEUE"
log "ネタ追加: $NEW_SLUG (no=$NO_NEW)"
Because there's a risk the model mixes up no (article number) and prev_slug (the previous article's slug), the shell overwrites them with definitive values after generation. jq '[$t] + .' inserts at the head of the queue, so this topic gets used immediately at the next 8:00 or 10:35 slot. The >.tmp && mv pattern rewrites atomically to prevent the JSON from being corrupted if the script is interrupted mid-write.
The plist's wrapper structure
Looking at ProgramArguments in the plist, it doesn't call the script directly — it goes through two layers of wrappers.
<array>
<string>~/.claude/scripts/claude-quota-guard.py</string>
<string>--job</string>
<string>com.shun.article-daily</string>
<string>--</string>
<string>/bin/bash</string>
<string>~/.discord/run-and-notify.sh</string>
<string>zenn</string>
<string>Zenn記事ストック生成</string>
<string>/bin/bash</string>
<string>~/.claude/scripts/article-daily-stock.sh</string>
<string>apply</string>
</array>
claude-quota-guard.py checks in advance whether the Claude Max plan's tokens are exhausted. If the remaining budget is in a critical state (token-budget-advisor.sh --short returns 🔴 or critical), it aborts immediately without launching the script body (lines 94–98 of the actual code). This prevents the situation of "hammering Claude calls while the token budget is bottomed out and burning it for nothing."
run-and-notify.sh is a wrapper that notifies Discord of the result. The arguments "zenn" and "Zenn記事ストック生成" become the channel and the notification title. The script body itself holds no notification logic, and the result arrives in Discord whether it succeeded or failed.
Let me also record the plist settings themselves. Because LowPriorityIO: true and Nice: 10 are set, the Mac doesn't get sluggish to operate while article generation is running. Combined with ProcessType: Background, even during the heavy load right after waking from sleep the OS defers I/O and prioritizes the foreground task. Since RunAtLoad: false, merely loading the plist doesn't start it — it fires only at the specified times.
The details that hold the implementation together
In the first half I traced the core logic of queue refilling. The actual script (528 lines) stacks up a number of supporting design details. Let me take up, one at a time, the places where "why it's written that way" is hard to see.
Solving the launchd PATH problem dynamically
# article-daily-stock.sh 56-58行目
NODE_BIN=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
[ -n "$NODE_BIN" ] && export PATH="${NODE_BIN}:$PATH"
A process launched by launchd reads neither .zshrc nor .bash_profile. Even if the claude command is installed at ~/.nvm/versions/node/v24.13.0/bin/, it isn't in launchd's bare PATH. Despite passing PATH explicitly via the plist's EnvironmentVariables (line 8 of the plist), the script also stacks the NVM bin at the front again. The reason is to avoid pinning a version. A hardcoded /v24.13.0/bin in the plist would need to be manually rewritten every time node is upgraded. By dynamically fetching the latest version's bin with sort -V | tail -1, the design means you never have to touch the plist.
Eradicating "interruption by sleep" with caffeinate
# article-daily-stock.sh 61-63行目
if [ -z "${CAFFEINATED:-}" ]; then
exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi
Article generation, counting the two Claude calls (topic planning + article writing), takes up to 25 minutes. If the Mac sleeps in the middle of that, the claude -p HTTP connection drops and it's treated as a timeout. caffeinate -i suppresses idle sleep and -s suppresses system sleep, and exec replaces the current process, building a structure where "the original script keeps running under caffeinate's umbrella." The CAFFEINATED=1 check is to prevent infinite recursion. Since adding this one block, interruption logs after waking from sleep have been zero.
Preventing double execution with a mkdir-based atomic lock
# article-daily-stock.sh 66-75行目
LOCKDIR="$HOME/.claude/locks/article-daily.lock"
if ! /bin/mkdir "$LOCKDIR" 2>/dev/null; then
oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || true)
if [ -n "${oldpid:-}" ] && kill -0 "$oldpid" 2>/dev/null; then
log "別インスタンス実行中(pid=$oldpid) — skip"; exit 0
fi
rm -rf "$LOCKDIR"; /bin/mkdir "$LOCKDIR" 2>/dev/null || exit 0
fi
echo $$ > "$LOCKDIR/pid"
trap 'rm -rf "$LOCKDIR"' EXIT INT TERM
When the 8:00 slot's generation runs long, the 10:35 slot launches the same script. The reason I adopted mkdir as the locking mechanism rather than a file lock (flock) is that macOS's mkdir is a POSIX-guaranteed atomic operation. Even if two processes call mkdir simultaneously, only one succeeds. Furthermore, even when a lock directory has been left behind, it applies kill -0 to the PID inside to check whether "the process is really alive," and if it's dead it cleans up the stale lock and continues. This is a "zombie PID check" that avoids the stale lock produced when the script is force-killed.
Getting the title character count accurately with inline Python
Zenn has a constraint that the title must be within 70 characters. The frontmatter_title_chars function (lines 140–165) that checks this is implemented as an inline Python script.
# 141-164行目のPythonインライン(要点抜粋)
if (in_frontmatter or idx < 20) and line.startswith("title:"):
title = line.split(":", 1)[1].strip()
if len(title) >= 2 and title[0] == title[-1] and title[0] in ("'", '"'):
title = title[1:-1]
print(len(title))
sys.exit(0)
The reason for not using grep | wc -c is the quoting and multibyte problem. Zenn frontmatter title values are sometimes wrapped in single quotes and sometimes not. wc -c returns byte count, so it counts one Japanese character (3 bytes) as 3 characters. Using wc -m gives you a character count, but then you separately need to strip the quotes, which complicates the code. By designing it to parse the frontmatter in Python and return the len() of the pure string with quotes removed, both Japanese titles and quoted/unquoted forms can be measured accurately in one go.
Running path sanitization before the secret scan
# article-daily-stock.sh 431-446行目(順序が肝)
# ①先にサニタイズ
for _f in "$ART" "$ARTICLES/$SLUG.md"; do
[ -f "$_f" ] || continue
/usr/bin/sed -i '' -E 's#/Users/[A-Za-z0-9._-]+/#~/#g' "$_f"
done
# ②その後で秘密スキャン
if grep -nEi 'AKIA[0-9A-Z]{16}|(secret|api_key|...)[[:space:]]*[:=]...' "$ART" >/dev/null; then
log "ABORT: 秘密らしき値混入 → 中止"
rm -f "$ART"; exit 1
fi
# ③実ホームパスが残っていたら中止
if grep -q "/Users/" "$ART"; then
log "ABORT: 実ホームパス(/Users/)混入 → 中止"
rm -f "$ART"; exit 1
fi
Even when the generation prompt explicitly says "write it with ~ notation," Claude sometimes writes the real path (/Users/…) into the body. In that case the script doesn't ABORT outright — it first attempts mechanical sanitization. Since /Users/ never carries meaning relevant to a published article, replacing it with ~ doesn't break the meaning of the text. It ABORTs only if, after sanitization, an AWS key format (AKIA…) or a secret-assignment pattern still remains. The philosophy is "fix what can be fixed, stop only for what can't," minimizing unnecessary retries.
Why the done-marker is set before git push
# article-daily-stock.sh 509行目と524行目
# キューをpopした直後——git push の前——に立てる
touch "$DONE_MARKER" # ← 511行目
run_to 120 git push -q 2>>"$LOG" || log "WARN: push失敗(ストックは確保済)"
# push後にも念のため(べき等な重複touchは無害)
touch "$DONE_MARKER" # ← 524行目
The done-marker being touched twice is intentional. The first one happens before git push. If push fails and the script exits, then without a done-marker the 10:35 slot won't find one and will run article generation again. Regenerating with the same SLUG would overwrite the existing file, and the queue would get popped twice. The moment the file write to the stock completes is "generation complete"; git push is nothing more than best-effort post-processing. This ordering alone completely prevents duplicate-generation accidents caused by push failures.
Three pitfalls I got stuck on
When you're thinking about the design, "do it this way and it works" is clear in your head. But when you actually run it, you get stuck in places you never imagined. Here are three failures I actually hit, in symptom → cause → fix order.
Pitfall ①: Claude always wraps the JSON in a code fence
Symptom
Automatic topic planning ended in ABORT: JSON不正 every single time. Looking at the log, the contents of TOPIC_JSON were as follows.
json
{
"no": "17",
"slug": "caffeinate-wrapper",
...
}
shell
Cause
Even when the claude -p prompt explicitly says "no code fence, output only one JSON object," it wraps the output in three backticks and a json tag with fairly high probability. Passing that straight to jq -e blows up instantly with a parse error.
Fix
TOPIC_JSON=$(printf '%s' "$TOPIC_JSON" | sed -n '/{/,/}/p')
sed -n '/{/,/}/p' outputs "from the line where the first { appears to the line where the last } appears." It strips off the code fence, the preamble, and any trailing text, leaving only the JSON object portion. Even with multiple nested { inside the JSON, the final } is at the end, so it works correctly. Since adding this, ABORTs due to invalid JSON have been near zero. Lesson: never trust a "strictly follow this format" instruction to a model. Always write code that normalizes the output mechanically.
Pitfall ②: 8:00 and 10:35 overlapped and generated the same article twice
Symptom
The next morning, checking ~/content/article/articles/, there were two files with the same sequence number (17-caffeinate-wrapper.md) whose contents differed subtly. Two entries were gone from the queue as well, and done-queue had two entries with the same SLUG. Discord had also received the "complete" notification twice.
Cause
The generation that started at 8:00 took 25 minutes, and was still running when the 10:35 slot launched. In the early version, before the double-execution lock was added, both processes were reading the head of the queue in parallel. The 10:35 process read the queue before the 8:00 process popped it, and as a result both ran separate Claude calls with the same SLUG; the process that wrote its file later overwrote the existing file and left two done-queue entries.
Fix
I added the mkdir-based lock (lines 65–75). mkdir is a POSIX-guaranteed atomic operation, and when two processes call it simultaneously only one succeeds. On top of that, I set up the flow where the done-marker check (lines 51–53) sets SKIP_GEN=1, so that if the day's article has already been generated it runs only the audit and exits immediately. The duplication came to light after publishing, but looking at the generated_at timestamps in done-queue showed the two entries were 2 seconds apart, so identifying the cause was immediate.
Pitfall ③: A broken article kept getting pushed back onto the queue, causing an infinite loop
Symptom
A few days later, generation for the same SLUG was running every morning but all of them ended in ABORT: 生成本文が不完全. done-queue accumulated one entry with the same SLUG per day, and the article file was discarded every time. At the same time, the token consumption log was 3× the normal amount.
Cause
The audit_repair function (lines 230–277) is designed to "reclaim from done-queue any article judged to have an incomplete body and re-enqueue it" (line 261). But every time regeneration ran it ABORTed with the same breakage, and got re-enqueued again... entering a loop. The bump_attempt function was supposed to record the retry count in $ATTEMPTS_FILE and, once it exceeded MAX_ATTEMPTS=2, divert the item into the needhuman array — but the root cause was that $ATTEMPTS_FILE (a JSON file) had been left in a corrupted state by an interruption during a disk write, and was constantly returning n=0.
Fix
I changed bump_attempt's write to the atomic .tmp → mv pattern.
# article-daily-stock.sh 224-226行目
jq --arg s "$slug" --argjson n "$n" '.[$s]=$n' \
"$ATTEMPTS_FILE" > "$ATTEMPTS_FILE.tmp" 2>>"$LOG" \
&& mv "$ATTEMPTS_FILE.tmp" "$ATTEMPTS_FILE"
I also added an initialization line at line 137, [ -f "$ATTEMPTS_FILE" ] || echo '{}' > "$ATTEMPTS_FILE", so that even if the file is corrupted it can restart from an empty object on the next launch. SLUGs exceeding MAX_ATTEMPTS=2 get stacked into the needhuman array, and writing to _NEEDS-FIX.txt plus a macOS notification (lines 267–268) run as a set. After this fix, the infinite loop disappeared completely.
All three pitfalls were either "timing problems invisible to unit tests" or "problems with how state files break." You can't find them by tracing the flow on paper. If you're building something similar, I recommend first running it manually in dry mode and following the logs of each step. Passing the argument bash article-daily-stock.sh dry means neither the queue pop nor the git push happens — it only writes the stock file and finishes (lines 498–500). Deliberately creating failure cases in this mode before wiring it to production and confirming the behavior for each of "corrupt the JSON / delete the article file / leave a lock file behind" is the shortest route.
Sticking points
In addition to the three covered in p2 — "JSON fences," "double execution," and "the infinite loop" — here is a comprehensive list of the points I got stuck on in actual operation.
① launchd does not expand ~
When I first started writing the plist, I put ~/.claude/scripts/article-daily-stock.sh in ProgramArguments, and launchd decided "the file does not exist" and exited immediately. launchd does not expand ~. The plist must be written with absolute paths of the form /Users/<username>/… (this is why every <string> in the actual plist is an absolute path). Inside the script you can reference home via the $HOME variable, but the values in the plist's ProgramArguments and EnvironmentVariables can only use absolute paths.
② On a Mac without gtimeout, run_to does nothing
The script uses GNU coreutils' gtimeout as a timeout wrapper (lines 82–84). If /opt/homebrew/bin/gtimeout doesn't exist, TIMEOUT_BIN="" and run_to() simply passes "$@" straight through. In an environment where brew install coreutils hasn't been done, the timeout in run_to 1500 claude -p … doesn't function and the Claude call keeps running indefinitely. Confirm with gtimeout --version before wiring things up.
③ I didn't add a network-connectivity wait after waking from sleep
When the MacBook is left asleep and 8:00 the next morning arrives, it wakes from sleep and the script launches — but Wi-Fi reconnection doesn't make it in time and claude -p was erroring out immediately. That's why lines 87–91 of the script contain a connectivity wait that loops nc -z -G 3 1.1.1.1 443 up to 18 times (90 seconds). Audit mode doesn't use the network, so it skips the loop.
④ I kept calling Claude while tokens were exhausted and melted the remaining budget
The Claude Max plan has both a 5-hour window and a 7-day window. When article generation continues into the latter half of the week, the 7-day window gets tight and claude -p starts returning rate limits partway through. At first I ignored the errors and kept retrying at the next slot, so I ended up in a loop of "it doesn't stop → it dies partway every time → it tries again," shaving the remaining budget down further. Now, at lines 93–98, if token-budget-advisor.sh --short returns 🔴 or critical, it exits without launching the job body. Checking the remaining token budget before a Claude call is mandatory preprocessing.
⑤ The stock article file paths broke sort order depending on the digit count of NO
The next_no() function (lines 122–130) reads the maximum number from the file names in ~/content/article/articles/ and does +1. Initially I created numbers as one digit — 1, 2, … — so ls sorting made 10 < 2 and the numbering went wrong. It's resolved by always zero-padding to two digits with printf '%02d'. If it ever goes past 99 articles, it will need to change to three digits.
⑥ article_ok()'s stub-word check produced a false positive
Line 179's grep -qiE 'request timed out|不明な商品|TODO: *本文|\(生成失敗\)' checks whether stub keywords are contained in the article body. On one occasion I wrote "an article introducing an implementation that detects timeouts via the request timed out error," and the string request timed out appeared in the body, so a correctly generated article got rejected with ABORT: 生成本文が不完全. The stub-word check patterns need to be narrowed as much as possible to "words unlikely to appear as example text in the body."
⑦ There was a period when I made a git push failure exit 1
In the initial implementation, if git push failed I made the whole script exit 1. Since everything counted as a failure regardless of whether the push failed due to "offline" or "conflict," the done-marker didn't get set and the 10:35 slot started regenerating the same article. Now, as at line 518, it's designed as || log "WARN: push失敗…", dropping the error to a warning log and continuing the script. It's important to think about generation completion and git push success as separate things.
⑧ coverage.json bloated and entries already deleted from the queue kept lingering
When I rewrote a few articles and their SLUG changed, the old entries stayed in coverage.json forever. There's a stale-cleanup block near the end of audit_repair() (lines 270–276), but I didn't have it at first, and around the point it exceeded 200 entries the jq processing got heavy. The correct form for cleaning coverage.json is to keep only "files that actually exist in the stock directory + slugs still in the queue" and delete everything else.
⑨ I forgot to initialize ATTEMPTS_FILE and got zombie retries
bump_attempt() records the retry count in ATTEMPTS_FILE (.article-repair-attempts.json). That initialization is done by [ -f "$ATTEMPTS_FILE" ] || echo '{}' > "$ATTEMPTS_FILE" (line 137), but the initial implementation didn't have it, so when the file didn't exist jq returned an error and n was always 0. As a result, MAX_ATTEMPTS=2 could never be exceeded, and the broken article kept getting re-enqueued every morning. Always guarantee the existence of state files at the top of the script.
⑩ I forgot the sips command is macOS-only and it failed in a Linux test environment
The thumb_ok() function (lines 185–188) checks the thumbnail's pixel width with sips -g pixelWidth. This command exists only on macOS, and on Linux or in a Docker container it's command not found. I only noticed when I tried to verify behavior in CI. Since the mechanism presupposes launchd there's no real harm, but if you try it in another environment you'll need a stub for this command.
⑪ The no field was passed as a string and zero-padding conversion failed
When passing the number obtained via NO=$(echo "$TOPIC" | jq -r '.no // ""') into printf '%02d' "$((10#$NO))", if no was an empty string or a string like "01", the shell's arithmetic evaluation sometimes failed to interpret 10#. That's why line 355 has the guard [ -z "$NO" ] || ! [[ "$NO" =~ ^[0-9]+$ ]] && NO=$(next_no) — "if something non-numeric arrives, re-number with next_no()."
Best practices
Here are the design principles verified in actual operation.
1. Keep generation and deployment independent
Don't mix writing to the stock and publishing to Zenn into the same job. Generation completion is judged solely by "was a file written to ~/content/article/articles/?", and git push is treated as best-effort post-processing (as the comment at the top of the script says). A design where generation doesn't stop even if deployment jams is the source of stability in long-term operation.
2. Set the done-marker before git push
"The moment the write to the stock completes" is generation completion. Line 509's touch "$DONE_MARKER" is placed before git push. This way, even if push fails, the catch-up slot doesn't do a duplicate generation. Line 524's second touch is a post-push belt-and-suspenders, but the first one is the linchpin protecting the whole design.
3. Protect state files with the .tmp && mv pattern
Every write to topic-queue.json, coverage.json, and ATTEMPTS_FILE is done with the atomic pattern jq … > "$FILE.tmp" && mv "$FILE.tmp" "$FILE". This prevents half-written JSON from being left behind on interruption and eradicates jq parse errors on the next launch. Careful: if you forget the && and write > file && mv, then even when jq fails you've already overwritten the file with an empty one.
4. Use a mkdir-based atomic lock
flock can't be used in environments where macOS's /bin/flock doesn't exist. /bin/mkdir is a POSIX-guaranteed atomic operation and works reliably on macOS. The pattern of writing the PID inside the lock directory, detecting zombie PIDs with kill -0, and auto-cleaning stale locks (lines 65–75) is the complete form of double-execution prevention.
5. Delegate retries to the OS (launchd)
Rather than retrying yourself with a sleep loop on failure, a design where you exit 0 without writing the done-marker and let the next slot pick it up is simpler. By setting two slots, 8:00 and 10:35, in the plist's StartCalendarInterval, the double safety valve of "production → failure → automatic retry 2 hours 35 minutes later" is completed at the OS level. The script-side code stays minimal.
6. Never trust the model's output — always normalize it
Even when you explicitly say "output only JSON," claude -p attaches code fences and a preamble. Always include the normalization that cuts from the first { to the last } with sed -n '/{/,/}/p' (line 332). Since adding this, invalid-JSON ABORTs have been near zero. Likewise, fields the model easily mixes up, such as no and prev_slug, get overwritten with definitive values on the shell side after generation (lines 342–343).
7. Attempt mechanical repair before ABORTing
Real home paths (/Users/…) creeping in happens even when the prompt instructs against it. ABORTing immediately for that makes retries expensive. The script first attempts automatic correction with sed -i '' -E 's#/Users/[A-Za-z0-9._-]+/#~/#g' (lines 434–437), and only aborts if a secret or AKIA… pattern still remains afterward. The principle of "fix what can be fixed, stop only for what can't" reduces unnecessary retries.
8. Check the remaining token budget at the top of the script
If you start Claude calls and then fail due to insufficient budget, all the preparation up to that point is wasted. As at lines 93–98, check the remaining budget with token-budget-advisor.sh --short before calling claude -p, and make the decision to end the job first if it's 🔴. This avoids the situation of "burning through the window all at once with two calls — replenish + article generation — in the latter half of a week when the budget is low."
9. Protect long-running processes with caffeinate -i -s exec
If the Mac sleeps partway through a process that takes up to 25 minutes (10 minutes of topic planning + 25 minutes of article writing), the HTTP connection drops and it's treated as a timeout. The single line exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@" (line 62) restarts the script itself under caffeinate's umbrella. Because exec replaces the process, no extra subshells accumulate. The CAFFEINATED=1 check exists to prevent infinite recursion.
10. Explicitly restrict the push destination owner with ALLOWED_OWNER
git push runs only against repositories of the GitHub user matching ALLOWED_OWNER="bokuwalily" (line 39) (lines 512–522). It's a safety valve preventing accidental pushes to a forked repository or one cloned by mistake. Always check this to prevent your own articles from being published to a repository under someone else's name.
11. Manually test failure cases in dry mode before wiring it to launchd
bash article-daily-stock.sh dry causes neither a queue pop nor a git push, and only writes the stock file (lines 496–500). Before launchctl loading the plist, manually trying the three failure cases — "corrupt the JSON," "delete the article file and run audit," "launch with a lock file left behind" — in dry mode is the shortest route to verifying behavior. If you try to discover failure-case behavior after wiring it to production, you end up in a loop of waiting 24 hours per slot.
12. Keep the background quiet with LowPriorityIO + Nice 10
Setting LowPriorityIO: true, Nice: 10, and ProcessType: Background in the plist means your main work doesn't slow down during article generation. Nice 10 lowers CPU priority, and LowPriorityIO makes the OS defer disk I/O. Even with article generation running while I use Claude Code at the same time, I never feel any sluggishness.
13. Reliably reject stub articles with MIN_ARTICLE_BYTES=1200
When Claude times out for some reason, sometimes only a few dozen bytes of error text saying "the article could not be generated" gets written. The article_ok() function checks the byte count with stat -f%z and treats anything under 1200 bytes as a stub (lines 176–177). MIN_ARTICLE_BYTES is defined as a constant at the top of the file, so when adjusting the minimum article quality you only change it there and it applies everywhere.
14. Record the retry count in ATTEMPTS_FILE and escalate to a human
When audit_repair() returns a broken article to the regeneration queue, bump_attempt() records the attempt count. Once it exceeds MAX_ATTEMPTS=2, it gets stacked into the needhuman array, and writing to _NEEDS-FIX.txt plus a macOS notification (lines 266–268) run as a set. Setting an escalation threshold to a human so that auto-repair doesn't cycle forever is a mandatory design element for machine repair loops.
15. Don't hardcode the nvm version in the plist's PATH — resolve it dynamically in the script
If you hardcode /Users/…/.nvm/versions/node/v24.13.0/bin into the plist's EnvironmentVariables.PATH, you'll need to rewrite the plist every time you upgrade node. By dynamically fetching the latest node's bin with ls -d ~/.nvm/versions/node/*/bin | sort -V | tail -1 at lines 56–58 of the script, the design means you write the plist once and never touch it again.
Wrap-up
To summarize the mechanism introduced in this article: "when the queue goes empty, Claude plans the next topic itself and adds it to the queue" is built on a stack of five decisions.
- Decouple generation from deployment: if one stops, it doesn't chain
- On failure, exit without setting the done-marker: the OS handles retries
- Normalize the output before validating it: don't trust the model's output format
- Write state files atomically: they don't break on interruption
- Cap auto-repair and hand off to a human: build an exit for when the loop doesn't converge
.topic-queue.json currently has 16 topics waiting. Every morning at 8:00 launchd consumes one, and on the morning after it hits 0, Claude plans the next topic grounded in that day's actual work (daily brief, memory, the script collection) and inserts it into the queue. If the generated JSON doesn't pass the four-field validation, it exits without writing the done-marker and the 10:35 catch-up slot picks it up. This "refill fails → retry at the next slot" is part of one and the same set.
Publishing to Zenn is handled by the separate zenn-daily job process. All article-daily-stock.sh knows about is "are the draft in ~/content/article/articles/ and the thumbnail in ~/content/article/thumbnails/ both in place?" Because of this division of labor, this article series has piled up without a single day's gap.
Of the revenue structure behind ¥1.2M a month, "continuous knowledge publishing" in the form of a series is the highest cost-performance means of asset building. The state of "I can't write because I've run out of topics" can be solved with a mechanism.
One question for you: if you've built something like this, which failure finally forced you to add a lock — a duplicated output file, or a corrupted state file?
I've collected the full picture of the mechanism, the breakdown of the ¥1.2M/month, and the 30-day procedure 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)