Everyone has met the bug that only shows up in production. This one was its exact mirror image: it passed every single scheduled run and died only when I typed the command myself. That inversion is why it stayed invisible for five days — five days in which my posting pipeline was down and my phone stayed completely quiet.
Some context on why that pipeline matters to me: I went from ¥100k/month as a university student to ¥600k/month juggling gigs, got laid off and dropped back to zero, then spent six months building an autonomous Claude Code setup. Revenue is now ¥1.2M/month, and the foundation under all of it is a system that grows articles while I sleep. This is the story of that system failing quietly — failing without telling anyone.
Why this setup works
Most automation write-ups end at "build the system and life gets easier." The biggest thing I learned in the last six months is the opposite. A system starts rotting the moment you build it, and it will not tell you that it is rotting.
article-daily-stock.sh (~/.claude/scripts/article-daily-stock.sh) runs from launchd in two slots — 8:00 and 10:35 every morning — generates one article, and stocks it in ~/content/article/. The core of the design is written in a comment.
# 設計の肝:
# - Zennデプロイ(deploy-next)が詰まっても、ここは止まらない。生成の成否は
# 「content/article にストックが書けたか」だけで判定する。
Pushing to Zenn and generating stock are decoupled. Even if the deploy jams, the generation buffer keeps stacking up. That "separate generation from publishing" split is what supports a stable output of 30 articles a month — or so it was supposed to.
The problem wasn't that this script exited successfully. The problem was that the failure notification died along with the failure.
The inversion: "it only dies when I run it by hand"
What's the nastiest class of bug in software development? "It breaks only in production and never reproduces locally" — the demon everyone meets at least once. What I ran into this time was the perfect flip side of that.
launchd does not set LANG. In other words, scripts under launchd run in the C locale. When you open a terminal and run the script by hand, the shell inherits LANG=ja_JP.UTF-8. That difference produces a failure pattern that is exactly backwards from normal.
- The 8:00 scheduled run (C locale) → passes without incident
- Running it by hand to debug (
ja_JP.UTF-8) → instant death with exit 127
The scheduled run passes every day, so nobody suspects it. It only dies when you run it manually, so you shrug it off as "I must have invoked it wrong." And this time — the place where it died was the failure-notification line itself.
When a post to Threads failed, the script that was supposed to fire a Discord notification (daily_post.sh in scent-media) crashed on the notification line, and the very fact that it had failed got swallowed. For five days posting was stopped, and nothing reached my phone.
投稿が失敗した
↓ 失敗通知スクリプトが起動
↓ 通知行でクラッシュ(exit 127)
↓ アラートが出ない
↓ 5日間誰も気づかない
By its nature, a bug in notification code is "only ever hit when something fails" — so while things are succeeding, it is undetectable, always. Any script that writes logs and notifications in Japanese — which is to say nearly every piece of automation I write — was structurally capable of stepping on this mine.
The overall flow
To understand where the bug lives, let's first walk the whole shape of article-daily-stock.sh. The script is 528 lines, but the skeleton splits into 12 phases.
launchd (com.shun.article-daily)
8:00 JST ─────────────────────────────────────┐
10:35 JST (catch-up) ────────────────────────┤
↓
┌─────────────────────────────┐
│ Phase 0: 本日生成済みチェック │
│ $DONE_MARKER が存在 → SKIP_GEN=1 │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Phase 1: 環境補完 │
│ PATH += nvm / homebrew │
│ caffeinate -i -s │
│ 二重実行ロック (lockdir) │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ audit_repair() │
│ 全ストックの監査 + 自己修復 │
│ サムネ欠損 → 自動再生成 │
│ 本文破損 → QUEUEへ再投入 │
└──────────────┬──────────────┘
↓
SKIP_GEN=1? ────→ exit 0 (audit only)
↓ No
┌─────────────────────────────┐
│ Phase 2: 予算チェック │
│ token-budget-advisor.sh │
│ 🔴 critical → exit 0 │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Phase 3: キュー空なら自動立案 │
│ claude -p でネタ1本を生成 │
│ slug重複チェック → QUEUE追加 │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Phase 4-5: 記事執筆 │
│ QUEUE先頭のtopicを取得 │
│ claude -p --max-turns 40 │
│ 実ファイルをRead/Grepして引用 │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Phase 6: 多段検証 │
│ frontmatter.title ≤ 70字 │
│ 本文 ≥ 1200 bytes │
│ 禁止語 (タイムアウト等) スキャン│
│ 秘密スキャン + 実パス正規化 │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Phase 7-8: ストック化 │
│ ~/content/article/articles/ │
│ gen_note_thumbs.py │
│ ~/content/article/thumbnails│
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Phase 9-12: 後処理 │
│ coverage.json upsert │
│ QUEUE pop → done queue │
│ DONE_MARKER touch │
│ git push (best-effort) │
└─────────────────────────────┘
Because this pipeline drives itself twice every morning, the article buffer keeps growing without me touching a keyboard.
Filling in the environment — launchd's thin runtime
Look at what happens in the very first phase.
# launchd 最小PATH補完(node/git/jq/claude/python3/chrome を通す)
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"
The launchd plist (com.shun.article-daily) does not read shell profiles. It starts in an environment where .zshrc and .bash_profile may as well not exist. So the nvm path and the Homebrew path have to be filled in manually. The same root cause is why daily_generate.sh in scent-media — discovered in the same window — had been wiped out by claude: No such file or directory: .local/bin was not on PATH.
Scripts under launchd cannot assume environment variables. The same applies to the locale. LANG=ja_JP.UTF-8 is set by the terminal. launchd does not set it. So you land in the C locale. That fact — "the terminal and launchd run in different environments" — is the direct reason this bug went undiscovered for five days.
The double-run lock and caffeinate
# 実行中スリープ防止(バッテリ凍結時は次スロットが拾う)
if [ -z "${CAFFEINATED:-}" ]; then
exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi
# 二重実行ロック
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
caffeinate -i -s keeps the MacBook from sleeping, and the CAFFEINATED environment variable tells the script whether it has already re-launched itself via exec. If the 10:35 slot fires while the 8:00 slot is still generating an article, and the pid in lockdir is alive (kill -0), it backs out immediately with exit 0. If a stale lock is left behind, it does rm -rf and re-acquires — riding this decision on the atomicity of mkdir is what prevents races.
audit_repair() — the self-repair that runs every slot
Even on days where article generation is skipped (SKIP_GEN=1), audit_repair() always runs.
# 監査+自己修復を先に走らせる(audit / 本日生成済みは ここで完結)
audit_repair
if [ "$MODE" = "audit" ] || [ "$SKIP_GEN" = "1" ]; then
log "===== article-daily done(audit$([ "$SKIP_GEN" = "1" ] && echo '+gen-skipped')) ====="
exit 0
fi
Inside audit_repair() it sweeps every file in ~/content/article/articles/ and runs body-quality checks (at least 1200 bytes, frontmatter title present, no forbidden words) plus a thumbnail width check (at least 2000px via sips -g pixelWidth). If a thumbnail is missing, it regenerates it on the spot with gen_note_thumbs.py; if the body is broken, it digs the meta out of done-queue and pushes it back to the head of topic-queue — up to two times.
article_ok() {
local f="$1"
[ -s "$f" ] || return 1
[ "$(stat -f%z "$f" 2>/dev/null || echo 0)" -ge "$MIN_ARTICLE_BYTES" ] || return 1
grep -qE '^title:' "$f" || return 1
frontmatter_title_ok "$f" || return 1
grep -qiE 'request timed out|不明な商品|TODO: *本文|\(生成失敗\)' "$f" && return 1
return 0
}
This article_ok() function is the gatekeeper for "stub detection." It explicitly rejects the string "request timed out" that the Claude API emits on timeout, and placeholder-ish text like "TODO: 本文". Because it runs every slot, a state where yesterday's generation was actually broken is guaranteed to be detected the next morning.
When the queue is empty, it plans its own topic from real work
When the topic queue (~/zenn-articles/.topic-queue.json) empties out, claude itself plans the next topic and adds it to the queue.
REPLENISH_PROMPT=$(cat <<EOF
あなたは Lily の「Claude Code環境」技術シリーズの編集者。次に書く記事ネタを1本だけ立案しろ。
ネタは「舜が実際にやった自動化・環境構築・Claude Code運用の工夫」から選ぶ。捏造禁止=必ず実在するファイルやスクリプトを根拠にする。
...
EOF
)
TOPIC_JSON=$(run_to 600 "$CLAUDE" -p "$REPLENISH_PROMPT" \
--model "${ARTICLE_MODEL:-sonnet}" --effort high \
--allowedTools "Read,Grep,Glob,Bash" --max-turns 20 ...)
The model is allowed only Read,Grep,Glob,Bash, forcing it to pick topics grounded in files that actually exist. The output is received under a strict JSON schema (with presence checks for slug, title, sources, thumb_title), and even slug-duplication is judged mechanically. On a duplicate slug it immediately does exit 0 and retries in the next slot — a design that seals off fabrication and duplication at the API level.
From writing to publishing
Once a topic is pulled off the queue, a separate claude -p session is launched with --max-turns 40 to actually write the article.
run_to 1500 "$CLAUDE" -p "$PROMPT" \
--model "${ARTICLE_MODEL:-sonnet}" --effort high \
--output-format text --allowedTools "Read,Grep,Glob,Write,Bash" --max-turns 40 >> "$LOG" 2>&1
The timeout is 1500 seconds (25 minutes). On failure it just leaves a line in log and exit 0s — article_ok() will detect the failure and force a retry in the next slot, so there is no reason to halt here.
The post-generation pipeline is meticulous.
- Get the title character count with
frontmatter_title_chars()(an inline Python3 heredoc) → discard if over 70 characters - Body quality check with
article_ok()→ discard if NG - Force
published: falsewithsed - Inject the footer with
lily-footer.py - Normalize the real home path to
~/withsed -E 's#/Users/[A-Za-z0-9._-]+/#~/#g' - Secret scan (AWS key regex,
api_key =patterns, etc.) → discard immediately on a hit
Finally it stocks the file at ~/content/article/articles/$NO-$SLUG.md, generates the thumbnail with gen_note_thumbs.py, upserts into coverage.json, then pops the QUEUE and moves the entry to the done-queue. The git push is best-effort — if it fails, "generation succeeded" still stands. Because DONE_MARKER is touched before git push, a push failure followed by the next slot restarting will not produce duplicate generation.
This pipeline was built from the design stage on the assumption that things fail. Timeouts, insufficient generation quality, broken thumbnails, network drops — each has a self-repair path, and the audit runs every slot. Even when I'm away from the keyboard, if something is broken, ~/content/article/_NEEDS-FIX.txt will be standing there the next morning and a macOS notification will fly in.
It was supposed to fly in.
Implementation details
Why "$VAR(全角)" dies depending on locale
First, confirm the behavior locally. The reproduction code recorded in ~/Documents/claude-obsidian/wiki/learning/locale-dependent-shell-bugs.md can be used as-is.
# 死ぬ(修正前の形)
LC_ALL=ja_JP.UTF-8 bash -c 'set -u; ID=test; echo "✅ 完了: $ID(要確認)"'
#=> bash: ID(要確認): unbound variable (exit 127)
# 通る(修正後)
LC_ALL=ja_JP.UTF-8 bash -c 'set -u; ID=test; echo "✅ 完了: ${ID}(要確認)"'
#=> ✅ 完了: test(要確認) (exit 0)
When you write $ID(要確認), bash under the ja_JP.UTF-8 locale tries to read full-width characters as part of the variable name when determining where the name ends. ( is not ASCII ( but U+FF08 (FULLWIDTH LEFT PARENTHESIS). With set -u enabled, it decides "there is no variable named ID(要確認)" and dies immediately with exit 127.
Under the C locale (LC_ALL=C), full-width characters are not interpreted as part of a variable name, so expansion stops at $ID and everything works. launchd doesn't set LANG, so you get the C locale — hence the inversion where the scheduled run passes and the manual run dies.
( isn't the only dangerous character. Full-width UTF-8 characters in general — 、, 。, 「, :, ・, %, → and friends — can all become the same trap. Any automation script that writes logs or notifications in Japanese can step on this mine. As long as ASCII characters follow, there's no problem — which means a script written entirely in English never encounters this bug. The more carefully you write in Japanese, the higher your odds of hitting it. An ironic property.
article-daily-stock.sh's line of defense — the fixed form
If you look inside article-daily-stock.sh (~/.claude/scripts/article-daily-stock.sh), the current code already has braces.
# notify() の呼び出し例(修正後の形、スクリプトの検証フェーズより)
notify "title長すぎ: ${SLUG}(再試行)"
notify "記事生成が不完全: ${SLUG}(再試行)"
Before the fix these read $SLUG(再試行). Since ( is full-width, running it by hand from the terminal produces an undefined-variable error for SLUG(再試行), and with set -uo pipefail it dies instantly. Under the scheduled run (C locale) it passes normally, so no matter how many days go by, it is never found.
What was easy to overlook this time is the danger of the notification line. You pay attention to core logic like article_ok() and audit_repair(). But the argument to notify that's called on failure — the string ${SLUG}(再試行) — sits on a path that is never reached on success. Testing only the success path means it is never hit, ever.
One more thing: the claude binary detection code in Phase 1 of article-daily-stock.sh is a line of defense in the same vein.
# claude 解決(3段フォールバック)
CLAUDE="${CLAUDE_BIN:-$(command -v claude 2>/dev/null)}"
[ -z "$CLAUDE" ] && [ -x "$HOME/.local/bin/claude" ] && CLAUDE="$HOME/.local/bin/claude"
[ -z "$CLAUDE" ] && CLAUDE=$(ls -t "$HOME"/.nvm/versions/node/*/bin/claude 2>/dev/null | head -1)
[ -x "$CLAUDE" ] || { log "ABORT: claude binary not found"; notify "claude binaryが無い"; exit 0; }
It's a three-stage fallback: command -v claude → ~/.local/bin/claude → under nvm's bin → ABORT if still not found. Because the launchd plist doesn't read shell profiles, ~/.local/bin isn't on PATH. Unless you write with that knowledge in hand, what you end up with is a script that silently gets wiped out every morning by claude: command not found.
Detection is a one-line rg
The bug pattern is unambiguous. Just collect everything where "a non-ASCII character immediately follows $VAR, and it isn't already in ${VAR} form."
rg -n --no-heading -g '*.sh' -g '!node_modules' \
'\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]' ~/dev ~/.claude/scripts ~/bin | rg -v '\$\{'
There's a reason it's a two-stage pipe. The first regex \$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F] matches everything that "starts with $, continues with alphanumerics and underscores, and is immediately followed by a non-ASCII character." That hits both $VAR and ${VAR}. The second stage, rg -v '\$\{', excludes the already-braced ${VAR} form. Already-fixed occurrences aren't picked up as noise, and only what needs fixing remains.
When I ran this, out came 5 repos, 8 files, 12 sites. When you mass-produce scripts solo, habits spread horizontally by copy-paste. Past judgments like "this repo doesn't have any anymore" go stale. Don't hardcode the target list — always run detection against the current code.
The fix — add one pair of braces
# Before(危険な形)
notify "記事生成が不完全: $SLUG(再試行)"
echo "処理済み: $ID(${DATE})"
# After(安全な形)
notify "記事生成が不完全: ${SLUG}(再試行)"
echo "処理済み: ${ID}(${DATE})"
The only change is $VAR → ${VAR}. Not a single character of the Japanese log wording changes. With braces, bash interprets only what's inside {} as the variable name and is no longer confused by the full-width character that follows.
There is one caveat, though. article-daily-stock.sh contains several inline Python scripts, all written as single-quoted heredocs.
frontmatter_title_chars() {
python3 - "$1" <<'PY'
import sys
# ...(Pythonコード)
PY
}
Inside <<'PY' (with single quotes) the shell does not expand anything, so the $ in there is interpreted by Python. The scope of the fix is strictly limited to contexts the shell expands — check the heredoc's quoting before touching anything.
After fixing, I always demonstrated it before closing out. Confirm that the old form exits 127 under ja_JP.UTF-8, and that the new form prints the same wording and exits 0. Don't stop at "I think I fixed it." The result of actually running it is the evidence.
Where I got stuck
Symptom ①: five days of nothing in Discord
The first sign of trouble was noticed by accident. "Threads engagement feels thin lately" — I opened the dashboard on that hunch and found posting had been stopped for five days.
I opened the log for daily_post.sh (the Threads auto-posting script) in the scent-media project. The post-failure error lines were there. But the "sending Discord notification" log line that should have followed was not. There was no trace whatsoever of the notification function being called.
At first I suspected a changed Discord webhook URL or a rate limit. But the webhook worked fine when hit by hand. Then, the moment I ran the script itself directly from the terminal, the error appeared.
bash: 投稿ID(2026-08-05): unbound variable
The ( in $変数名( was full-width. When a Threads post failed, the script tried to send a notification to Discord — but the very line assembling that notification died with exit 127, so the information that it had failed reached nowhere.
投稿が失敗する
↓ 失敗通知の処理に入る
↓ 通知文字列の組み立てで $VAR(全角)が出現 → exit 127
↓ 通知が送信されない
↓ ログへの書き込みも通知より後ろにあったため残らない
↓ 5日間誰も気づかない
The very fact that "it only dies when run by hand" is what produced the five-day delay. The scheduled run (C locale) passes every day, so you don't suspect it. If a manual run fails, you write it off as "a problem with how I invoked it." This time I happened to open the dashboard and notice; if I hadn't, it could have continued for weeks.
That's where the horror of notification code lies. It sits on a path that's only reached when the core logic fails. No matter how many times you test the success path, the quality of the notification code is guaranteed by nothing at all. No amount of confirming the success path guarantees anything about the quality of the failure path — a lesson I also recorded in locale-dependent-shell-bugs.md as [[silent-success-antipattern]].
Symptom ②: daily_generate.sh was wiped out for a different reason
When I checked another script in the same scent-media project — daily_generate.sh, the one that generates content using the Claude API — it wasn't running either. But the cause wasn't the locale.
~/.claude/scripts/daily_generate.sh: line 12: claude: command not found
The claude binary was not found. Because the launchd plist doesn't read shell profiles, ~/.local/bin isn't on PATH. It's the very problem that article-daily-stock.sh explicitly solves in Phase 1.
The reason two different bugs surfaced in the same project at the same time is the same reason. The person who wrote the scripts had forgotten that scripts under launchd "run in a different environment than the terminal."
Locale differences and PATH differences — both come down to the single fact that "launchd does not inherit the terminal's shell environment." Even when you hold that fact as knowledge, you forget it at the moment you write the script. Because when you test locally, running it from the terminal works. It worked — and you don't dig further. Unless you deliberately reproduce the environment as launchd sees it, your test is nothing more than a check of the success path.
Stuck point ①: I assumed it wasn't under git
When applying the cross-cutting fix to ~/.claude/scripts/, I had assumed this directory was "a junk drawer of scripts not managed by git." So after the fix I decided "no commit needed" and moved on.
Then, just in case, I ran git status after the fix — and changes came out.
On branch main
Changes not staged for commit:
modified: article-daily-stock.sh
modified: token-budget-advisor.sh
~/.claude/scripts/ was a git repository. And two files were managed as tracked files. Proceeding without verifying the assumption "this shouldn't be under git" created rework after the fact.
The lesson is simple. Running git status before you start is faster. Verifying with one command beats deciding in your head — it's quicker and more reliable.
After the fix, I staged only those two files with git add article-daily-stock.sh token-budget-advisor.sh so unrelated diffs wouldn't get swept in, and committed (2b65662). The commit hashes across all 5 repos are recorded in my post-verification notes — lily-line-funnel is 6596c00, autopilot is 56288fa, brand-404 is f0d37f5, and metrics-hub is f9b1a18.
Stuck point ②: one site I couldn't commit
Even while saying I'd swept all 12 sites, exactly one site inside ~/.claude/scripts/article-daily-stock.sh was left in a state that couldn't be committed.
That site was inside a block of the script that hadn't been committed yet. The fix target was contained within a few dozen lines of uncommitted diff from a half-written new feature.
# 未コミット差分の内部(こんな形で存在していた)
# ... 新機能の実装途中 ...
log "処理スキップ: $SLUG(重複)" # ← ここが修正対象
# ... 続く未コミット行 ...
There were two options — (a) commit the whole uncommitted block, or (b) fix just that one line locally and not commit it. Option (a) would "sweep in diffs unrelated to this work," making the commit's intent ambiguous. Option (b) means "leaving it in a safe form in the worktree, uncommitted."
I chose (b), explicitly noting that the only unmet completion criterion is "the repository as a whole is clean." In the local worktree all 12 sites are in ${VAR} form, and I closed it out in a state that won't die under either the scheduled launchd run (C locale) or a manual run (ja_JP.UTF-8). The remaining one site will be closed together with the commit that tidies up the uncommitted block — I noted that explicitly and moved on. Not hiding "part of the completion criteria is unmet" is what helps you later.
Until I chased this bug down, I already knew that "launchd and the terminal have different locales." But it never connected to "therefore the notification line must be protected too." Knowledge and implementation are different things. When you write a script, you're careful with the success path. The failure notification line, you wave off assuming it works — and that assumption caused five days of silent death.
When the notification line dies, "it failed" never arrives. And "never arrived" is indistinguishable from "it succeeded." In automation, notification code has to be more robust than the core code. Because when the notification code goes down, it doesn't even tell you the core went down.
Just as article-daily-stock.sh has "separate generation from publishing" in its design, the notification path needs a design of its own: "don't let a failure of the notification itself slip by." After this incident, I picked up the habit of always checking for ${VAR} form before a notification call. One pair of braces is the difference between five days of silence and same-day detection.
Gotchas
Here are the mines I actually stepped on, plus the traps that are easy to miss. This is the "why you get stuck there" behind the symptoms covered above.
① I wasn't conscious that single-quoted heredocs are out of scope
article-daily-stock.sh has several places that call Python inline, like frontmatter_title_chars().
frontmatter_title_chars() {
python3 - "$1" <<'PY'
import sys
# Pythonコード($はPythonが解釈する)
PY
}
The inside of <<'PY' (with single quotes) is not expanded by the shell. Running the detection command will also hit Python code containing $, but no fix is needed there. Not knowing this at first, I hesitated over "should I add braces here too?" The single criterion is "is it inside a single-quoted heredoc or not?" Double-quoted or unquoted heredocs (<<PY) are expanded, so those are in scope.
② >/dev/null 2>&1 hides notification failures
The script's notify() is defined like this.
notify() { /usr/bin/osascript -e "display notification \"$1\" with title \"Article daily\"" >/dev/null 2>&1; }
Because both stdout and stderr are thrown away, nothing remains even if osascript fails. If the notification argument has a full-width expansion problem, the line itself can die with exit 127 and 2>&1 swallows it. That is exactly the direct cause of this five-day silence. Something I realized after the fix: notification failures in particular should have been left in $LOG.
③ Get the DONE_MARKER timing wrong and you get duplicate generation
In article-daily-stock.sh, the touch of DONE_MARKER happens before the git push.
# 生成成功=この時点で当日doneを確定する
touch "$DONE_MARKER"
# ---- 12. Zennソースを push(best-effort)---------
# ...git push...
touch "$DONE_MARKER" # pushの後にも念のため
Even if git push fails, DONE_MARKER is up, so the 10:35 catch-up slot exits with SKIP_GEN=1 after audit only. Put DONE_MARKER after the push, and a push failure means the next slot regenerates the same topic — duplicate generation. It's an easy point to overlook at design time.
④ Ignore stale lockdirs and you tie your own hands
Inside the lock mechanism built on mkdir's atomicity, there's a stale check.
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
kill -0 checks whether the process is alive; if it isn't, the lock is treated as stale, rm -rf'd, and re-acquired. In an early implementation without this check, after the script was force-killed with SIGKILL (e.g. timing out on budget overrun), the lockdir stuck around and the script never ran again from the next day on.
⑤ The pipefail part of set -uo pipefail kills you in unexpected places
The pipefail in set -uo pipefail means "if any one command in the pipe returns non-zero, the whole thing is non-zero." With combinations like jq ... | grep -q ..., the script can die even in the normal case where grep returns exit 1 for "no match."
# 危険な形
jq -r '.[].slug' "$QUEUE" | grep -qx "$NEW_SLUG"
# 安全な形
used_slugs | grep -qx "$NEW_SLUG"
# ↑ used_slugs()内でpipeのエラーを || true で吸収済み
used_slugs() in article-daily-stock.sh handles this pattern. "Not found" from grep -q isn't an error but the normal case — yet under pipefail it means something different. A bash-specific pitfall.
⑥ I put off detecting the claude binary
In the first implementation, I deferred the handling for "the claude command isn't found" and moved on. When launched from launchd, ~/.local/bin isn't on PATH, so in reality it was being silently wiped out every morning with claude: command not found. The current three-stage fallback was born from that experience.
CLAUDE="${CLAUDE_BIN:-$(command -v claude 2>/dev/null)}"
[ -z "$CLAUDE" ] && [ -x "$HOME/.local/bin/claude" ] && CLAUDE="$HOME/.local/bin/claude"
[ -z "$CLAUDE" ] && CLAUDE=$(ls -t "$HOME"/.nvm/versions/node/*/bin/claude 2>/dev/null | head -1)
[ -x "$CLAUDE" ] || { log "ABORT: claude binary not found"; notify "claude binaryが無い"; exit 0; }
Only after adding this detection did the "claude not found" ABORT log appear and let me grasp the problem. "It should be running" is not running.
⑦ I forgot the ALLOWED_OWNER check and pushed to a fork
An OWNER check sits immediately before the git push.
OWNER=$(printf '%s' "$URL" | sed -nE 's#.*github\.com[:/]+([^/]+)/.*#\1#p')
if [ "$OWNER" = "$ALLOWED_OWNER" ]; then
# pushする
fi
During the period when I didn't have this, an automated run fired while origin was still pointed at a different repository during development, and commits piled up in an unrelated repo. Pinning ALLOWED_OWNER=bokuwalily prevents the worst case even if origin gets changed by mistake.
⑧ I doubted MIN_ARTICLE_BYTES=1200 without knowing where it came from
The body check in article_ok() discards anything under 1200 bytes via stat -f%z. At first I doubted it — "why 1200 bytes?" — and tried to change the value. But these are bytes. In UTF-8 a Japanese character is 3 bytes, so 1200 bytes ≈ 400 characters. It's a floor set at a value that even the opening of a halfway decent technical article should exceed. stat -f%z is a macOS-specific option (the Linux version is stat -c%s), so porting to Linux requires a rewrite.
Best practices
Practical rules derived from actually stepping on this bug and sweeping 5 repos / 8 files / 12 sites.
1. If a full-width character follows a variable expansion, always use ${VAR} form
# NG
notify "生成失敗: $SLUG(再試行)"
# OK
notify "生成失敗: ${SLUG}(再試行)"
One pair of braces. Not a single character of the Japanese log wording has to change.
2. Keep the cross-repo detection command on hand
rg -n --no-heading -g '*.sh' -g '!node_modules' \
'\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]' ~/dev ~/.claude/scripts ~/bin | rg -v '\$\{'
Don't stop at "I fixed one." The same habit spreads horizontally by copy-paste. This time the same pattern was scattered across 5 repos. Don't hardcode the list of targets — always run it against the current code.
3. Reproduce launchd's runtime in the terminal and test there
# launchdと同じ環境を手元で再現
env -i HOME="$HOME" PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" \
CAFFEINATED=1 /bin/bash ~/path/to/script.sh dry
Starting from a nearly empty environment with env -i lets you check launchd-side behavior locally. "It passes in the terminal" is not "it passes under launchd."
4. Set LANG explicitly in the launchd plist to align the environments
<key>EnvironmentVariables</key>
<dict>
<key>LANG</key>
<string>ja_JP.UTF-8</string>
</dict>
Declaring LANG in the plist makes scheduled runs use ja_JP.UTF-8 too. By "running in the same environment as the terminal," locale-difference bugs become detectable in local testing. This time I chose to fix the code, but aligning the runtime environment is also an option.
5. Do PATH completion before set -u, in the script's first phase
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"
The launchd plist doesn't read shell profiles. Treat every tool that lives under nvm, Homebrew, or ~/.local/bin as "invisible" and add them explicitly.
6. Resolve the claude binary with a three-stage fallback, and ABORT if not found
Search in the order command -v → ~/.local/bin → under nvm's bin, and if it's nowhere, exit 0 (retry next slot). I use exit 0 and leave only a log entry, because exit 1 can blow away the notification too.
7. Write notification code to be more robust than the core
If the notification goes down, "it failed" never arrives — and "never arrived" is indistinguishable from "it succeeded." Two design measures I took in article-daily-stock.sh to prevent this:
- If the notification argument contains full-width characters, always use
${VAR}form - Write to
$LOGbefore notifying (if the log write survives, a notification failure is detectable)
log "ABORT: title長すぎ(${TITLE_CHARS}字) → 破棄"
notify "title長すぎ: ${SLUG}(再試行)" # 通知は後
8. Use set -uo pipefail, but know where || true belongs
Because of pipefail, the normal case "grep found no match" can be treated as exit 1. || true exists to absorb that kind of "non-zero that isn't a failure." "|| true everywhere" is out of the question — it destroys the value of set -u. Use it only where "non-zero is normal" is established.
9. touch DONE_MARKER before the git push
touch "$DONE_MARKER" # ← ここでマーカーを立てる
# 以降のpushはbest-effort
git push ...
touch "$DONE_MARKER" # 念のため二重touch(冪等)
This is the linchpin of the design where "generation succeeded" holds even if the push fails. With DONE_MARKER up, the next slot skips generation, so a push failure can't cause duplicate generation.
10. Run audit_repair() even on days when generation is skipped
audit_repair
if [ "$MODE" = "audit" ] || [ "$SKIP_GEN" = "1" ]; then
exit 0 # 生成はスキップ、auditは毎日走る
fi
Even on a day where today's article is already generated, the quality of stock from yesterday and earlier can degrade. Thumbnail width checks (confirming at least 2000px via sips -g pixelWidth) and body stub detection run every slot, and anything problematic gets pushed back into the QUEUE.
11. Don't decide whether something is under git by assumption
git status # 作業開始前に1発打つだけ
This time I proceeded on the assumption that ~/.claude/scripts/ was "a junk location outside git," then ran git status afterwards and got two modified entries. Confirming the fact with one command is faster than second-guessing an assumption.
12. Always demonstrate before closing out a fix
# 旧形がexit 127で死ぬことを確認
LC_ALL=ja_JP.UTF-8 bash -c 'set -u; SLUG=test; echo "生成失敗: $SLUG(再試行)"'
# => bash: SLUG(再試行): unbound variable
# 新形が同じ文言を正常に出すことを確認
LC_ALL=ja_JP.UTF-8 bash -c 'set -u; SLUG=test; echo "生成失敗: ${SLUG}(再試行)"'
# => 生成失敗: test(再試行)
Don't stop at "I think I fixed it." These demonstration commands are also kept in locale-dependent-shell-bugs.md. Even for a one-line diff, the habit of seeing both the old and new behavior with your own eyes prevents the "I fixed it but it still breaks" round trips.
13. Require real-file grounding for topic planning too
The automatic topic-planning prompt in article-daily-stock.sh includes constraints like "no fabrication = always ground it in files or scripts that actually exist" and "list 2–4 real paths in sources," and the output JSON is validated against that. It's a design to keep automation articles from becoming "fictional implementations." Choosing angles from code you actually run changes the density of an article fundamentally.
Wrap-up
The reason this bug went unfound for five days is that two properties overlapped: the usual pattern inverted into "it only dies when run by hand," and "a bug that exists only on the failure path."
- The scheduled run passes every day in the C locale → nobody suspects it
- Running by hand dies under
ja_JP.UTF-8→ written off as "a problem with how I invoked it" - The place it dies is the failure-notification line → the failure itself never arrives
Writing a full-width ( immediately after a variable expansion, as in $SLUG(再試行), becomes more likely the more carefully you write your logs in Japanese. Scripts written only in English never meet this problem. An ironic property.
The fix itself was done by detecting 12 sites with one line of rg and adding one pair of braces (5 commits: 2b65662, 6596c00, 56288fa, f0d37f5, f9b1a18). But what I really learned this time isn't "how to fix it" — it's the principle that notification code must be more robust than core code.
When the notification goes down, the fact that it went down never arrives.
An automated system starts rotting the moment you build it, and it won't tell you it's rotting — which is why audit_repair() runs every slot, DONE_MARKER goes up before the git push, and article_ok() stands by as the gatekeeper for stub detection. The foundation that keeps 30 articles a month generating automatically is protected by a one-line function that rejects anything under 1200 bytes, and by the difference of one pair of braces.
A system is transparent while it's working. You only see it when it breaks. Whether you can see it at that moment is what decides whether the foundation under ¥1.2M/month in revenue stays stable.
I've written up the full picture of the system, 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)