# 3 Token Burn From an Infinite Retry Loop: How I Made Claude Refill My Article Queue When It Runs Dry

> Source: <https://dev.to/bokuwalily/3x-token-burn-from-an-infinite-retry-loop-how-i-made-claude-refill-my-article-queue-when-it-runs-5edl>
> Published: 2026-08-28 11:00:07+00:00

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.

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.

```
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 は立つ）
```

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).

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.

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.

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.

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

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

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

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.

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

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

ed 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.

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.

**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.

**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.

**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.

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

."

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

ing 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.

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.

`.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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](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)*
