<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lily</title>
    <description>The latest articles on DEV Community by Lily (@bokuwalily).</description>
    <link>https://dev.to/bokuwalily</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4024286%2Fdf479f07-d3b3-4271-8cc4-8976224e437e.png</url>
      <title>DEV Community: Lily</title>
      <link>https://dev.to/bokuwalily</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bokuwalily"/>
    <language>en</language>
    <item>
      <title>The Morning My cron Jobs Went Silent: A 97-Line Script That Migrated Everything to launchd</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Tue, 01 Sep 2026 00:00:04 +0000</pubDate>
      <link>https://dev.to/bokuwalily/the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything-to-launchd-4aia</link>
      <guid>https://dev.to/bokuwalily/the-morning-my-cron-jobs-went-silent-a-97-line-script-that-migrated-everything-to-launchd-4aia</guid>
      <description>&lt;p&gt;Six months after being laid off, I'd rebuilt my income from zero to ¥1.2M/month on an autonomous setup. Then one morning at 8:00, it just wasn't there — no error, no alert, nothing. The cause: a macOS update had quietly disabled the cron daemon. My fix was a 97-line shell script that parses &lt;code&gt;crontab&lt;/code&gt; line by line and auto-generates launchd plists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Approach Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "It Should Be Running" Is the Most Dangerous Kind of Confidence
&lt;/h3&gt;

&lt;p&gt;Back when my side business was earning ¥600K/month, nearly every yen of that automation benefit rode on cron jobs. Timing note publications, scheduling social posts, daily data aggregation — all of it lined up in &lt;code&gt;crontab -l&lt;/code&gt;. When I was laid off and dropped to zero, rebuilding the environment with Claude Code, I decided carrying the crontab over as-is was the fastest path.&lt;/p&gt;

&lt;p&gt;Right after upgrading to macOS Sequoia (15.x), nothing appeared to have changed. Run &lt;code&gt;crontab -l&lt;/code&gt; and every entry is still there. But &lt;strong&gt;the daemon isn't running&lt;/strong&gt;. Since macOS Ventura, Apple has been progressively decoupling the cron daemon from the user session, and on Sequoia/Tahoe it's perfectly normal to have &lt;code&gt;/usr/sbin/cron&lt;/code&gt; present while &lt;code&gt;launchctl list | grep cron&lt;/code&gt; returns nothing at all.&lt;/p&gt;

&lt;p&gt;The reason I was slow to notice is that when automation stops, &lt;strong&gt;no error appears&lt;/strong&gt;. My assumption was that if cron isn't running, an error mail lands in &lt;code&gt;/var/mail/&amp;lt;username&amp;gt;&lt;/code&gt; — and that assumption had collapsed. On Sequoia it doesn't reach the post office by default. The 8:00 daily brief doesn't arrive, the 11:00 social post doesn't go out, and only then do you notice. That "silent death" is what scares me.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why launchd Is the Right Answer
&lt;/h3&gt;

&lt;p&gt;On macOS, process launching and management belongs to &lt;code&gt;launchd&lt;/code&gt; (PID 1). cron survives only as historical compatibility; what Apple actually recommends is job management via launchd. launchd handles automatic restarts when a daemon crashes, automatic execution after wake for jobs scheduled while the machine was asleep, direct redirection of stdout/stderr to files, and explicit injection of environment variables — all declaratively, in a single plist file.&lt;/p&gt;

&lt;p&gt;cron lets you write &lt;code&gt;*/5 * * * * cmd&lt;/code&gt; on one line; a launchd plist becomes 20–30 lines of XML. That verbosity is the biggest psychological barrier to migrating to launchd. Rewriting ten of them by hand isn't realistic. So you generate them with a script.&lt;/p&gt;

&lt;p&gt;Looking at one plist that's actually in production makes the structure click. Here's how &lt;code&gt;~/Library/LaunchAgents/com.shun.daily-brief.plist&lt;/code&gt; is composed (excerpted from the real file, paths converted to &lt;code&gt;~&lt;/code&gt; notation):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Label&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.shun.daily-brief&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;PATH&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:
          /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;8&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;30&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/claude-quota-guard.py&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--job&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.shun.daily-brief&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/daily-brief.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;RunAtLoad&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardOutPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/logs/com.shun.daily-brief.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardErrorPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/logs/com.shun.daily-brief.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explicit &lt;code&gt;EnvironmentVariables&lt;/code&gt;.&lt;/strong&gt; launchd does not read your shell configuration (&lt;code&gt;.zshrc&lt;/code&gt;, &lt;code&gt;.bashrc&lt;/code&gt;). A script that uses node installed via nvm has no PATH to it under launchd management and dies with &lt;code&gt;node: command not found&lt;/code&gt;. This accounts for 90% of the cases where a job migrated from cron suddenly stops working. Writing PATH explicitly into the plist guarantees the same binary gets called no matter what the shell is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The array form of &lt;code&gt;StartCalendarInterval&lt;/code&gt;.&lt;/strong&gt; When you want to run multiple times per day, you line up &lt;code&gt;&amp;lt;dict&amp;gt;&lt;/code&gt; entries inside an &lt;code&gt;&amp;lt;array&amp;gt;&lt;/code&gt;. daily-brief runs twice, at 8:00 and 10:30. In cron you'd write &lt;code&gt;0 8,10 * * *&lt;/code&gt;, but launchd requires a dictionary per time. How far the auto-generation script covers this notational gap ties into the pitfalls described later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;LowPriorityIO&lt;/code&gt; and &lt;code&gt;Nice&lt;/code&gt;.&lt;/strong&gt; Background jobs get lowered I/O priority and a CPU scheduler nice value of 10. It's a setting to minimize impact on foreground work (editor, browser), consistent with the "erase your presence" philosophy of an autonomous environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  What It Means to Invest in the Environment, Not the Work
&lt;/h3&gt;

&lt;p&gt;Of that ¥1.2M/month breakdown, almost none of it is me moving my hands. Most of the note series, social updates, and data aggregation are automated. The maintenance cost of this environment comes down to moving cron onto a foundation that actually runs. The goal of being under launchd management is that a scheduled task you wrote once is still running three years later. Apple's launchd is a stable API unchanged since macOS 10.4 (2005), and it doesn't "die unnoticed" the way cron does. &lt;code&gt;launchctl list com.shun.daily-brief&lt;/code&gt; shows you LastExitStatus and the next scheduled run instantly.&lt;/p&gt;

&lt;p&gt;The 90 minutes spent setting up the environment is an investment that buys back 5 minutes × 365 days (= 30 hours) of "let me check whether it's actually running" every morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Overall Flow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Map of the Processing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;crontab -l
  │  grep -vE '^\s*#' | grep -v '^$'  ← コメント行・空行を除外
  ↓
[1行ごとにループ]
  │  awk '{print $1...$5}' で schedule フィールド抽出
  │  cut -d' ' -f6-           で cmd 部分を切り出し
  │  basename からラベル生成  → com.shun.&amp;lt;script-name&amp;gt;
  ↓
StartCalendarInterval XML 組み立て
  │  ※ */N 形式は非対応（固定値のみ）← ここが落とし穴
  ↓
plist ファイル書き出し
  → [dry]   ~/.claude/scripts/launchd-proposed/*.plist
  → [apply] ~/Library/LaunchAgents/*.plist
               + launchctl unload → launchctl load
  ↓
⚠️  警告: crontab から手動削除しないと二重起動
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Dissecting the Script (All 97 Lines)
&lt;/h3&gt;

&lt;p&gt;The script lives at &lt;code&gt;~/.claude/scripts/cron-to-launchd.sh&lt;/code&gt;, and there are two ways to use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 差分確認（ファイルを書くだけ、loadしない）&lt;/span&gt;
~/.claude/scripts/cron-to-launchd.sh dry

&lt;span class="c"&gt;# 本番反映（LaunchAgentsにコピーしてlaunchctl load）&lt;/span&gt;
~/.claude/scripts/cron-to-launchd.sh apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it with no arguments and &lt;code&gt;dry&lt;/code&gt; is the default (&lt;code&gt;MODE="${1:-dry}"&lt;/code&gt;). The iron rule is to not jump straight to &lt;code&gt;apply&lt;/code&gt; — run &lt;code&gt;dry&lt;/code&gt; first and eyeball the generated output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1: Reading and parsing the crontab (lines 20–28)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CRON_LINES&lt;/span&gt;&lt;span class="o"&gt;=()&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; CRON_LINES+&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;crontab &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'^\s*#'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^$'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As the comment &lt;code&gt;bash 3.2 互換&lt;/code&gt; indicates, the bash that ships with macOS is version 3.2 (Apple hasn't updated it for GPLv2 reasons). &lt;code&gt;mapfile&lt;/code&gt; and &lt;code&gt;readarray&lt;/code&gt; aren't available in 3.2, so the array is built with a &lt;code&gt;while IFS= read -r&lt;/code&gt; loop. &lt;code&gt;crontab -l 2&amp;gt;/dev/null&lt;/code&gt; swallows the error when the crontab is empty, &lt;code&gt;grep -vE '^\s*#'&lt;/code&gt; strips comment lines, and &lt;code&gt;grep -v '^$'&lt;/code&gt; strips blank lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2: Splitting each line into schedule and cmd (lines 28–38)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;dom&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;mon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $4}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;dow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $5}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f6-&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cron format &lt;code&gt;min hour dom mon dow cmd...&lt;/code&gt; is pulled apart field by field with awk. Since &lt;code&gt;cmd&lt;/code&gt; takes everything from the sixth field onward via &lt;code&gt;cut -d' ' -f6-&lt;/code&gt;, it picks up the command correctly no matter how many arguments it has.&lt;/p&gt;

&lt;p&gt;The label generation logic (lines 38–40):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;script&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'~/.claude/scripts/[^ ]+'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | xargs &lt;span class="nb"&gt;basename &lt;/span&gt;2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$script&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;script&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt; | xargs &lt;span class="nb"&gt;basename &lt;/span&gt;2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;hour&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"com.shun.&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$script&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/\.[a-z]+$//'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scripts under &lt;code&gt;~/.claude/scripts/&lt;/code&gt; get labeled from the basename with the extension stripped. For example, &lt;code&gt;daily-brief.sh&lt;/code&gt; becomes &lt;code&gt;com.shun.daily-brief&lt;/code&gt;. Other, general-purpose commands (&lt;code&gt;find&lt;/code&gt;, &lt;code&gt;backup-rotate&lt;/code&gt;, and so on) secure uniqueness with command name + minute + hour. Underscores are converted to hyphens (launchd Label convention).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 3: Assembling the StartCalendarInterval XML (lines 44–52)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;cal_xml&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"  &amp;lt;key&amp;gt;StartCalendarInterval&amp;lt;/key&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &amp;lt;dict&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# */N 周期は launchd では複数エントリで再現する必要 — ここでは固定値だけ対応&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$minute&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;cal_xml+&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"    &amp;lt;key&amp;gt;Minute&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hour&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;cal_xml+&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"    &amp;lt;key&amp;gt;Hour&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;hour&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dom&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;    &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;cal_xml+&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"    &amp;lt;key&amp;gt;Day&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dom&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$mon&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;    &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;cal_xml+&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"    &amp;lt;key&amp;gt;Month&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;mon&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dow&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;    &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;cal_xml+&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"    &amp;lt;key&amp;gt;Weekday&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dow&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="k"&gt;fi
&lt;/span&gt;cal_xml+&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"  &amp;lt;/dict&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a field is &lt;code&gt;*&lt;/code&gt; (wildcard), the corresponding key is omitted from the XML — that's the semantics of launchd's &lt;code&gt;StartCalendarInterval&lt;/code&gt;. For instance, &lt;code&gt;0 8 * * *&lt;/code&gt; (8:00 every day) only needs &lt;code&gt;Hour=8, Minute=0&lt;/code&gt;; omitting &lt;code&gt;Day/Month/Weekday&lt;/code&gt; is what makes it "every day."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 4: Writing out the plist body (lines 54–76)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;XMLEOF&lt;/span&gt;&lt;span class="sh"&gt;
&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;
&amp;lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&amp;gt;
&amp;lt;plist version="1.0"&amp;gt;
&amp;lt;dict&amp;gt;
  &amp;lt;key&amp;gt;Label&amp;lt;/key&amp;gt;
  &amp;lt;string&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;&amp;lt;/string&amp;gt;
  &amp;lt;key&amp;gt;ProgramArguments&amp;lt;/key&amp;gt;
  &amp;lt;array&amp;gt;
    &amp;lt;string&amp;gt;/bin/zsh&amp;lt;/string&amp;gt;
    &amp;lt;string&amp;gt;-c&amp;lt;/string&amp;gt;
    &amp;lt;string&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;//&amp;amp;/&amp;amp;amp;&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;&amp;lt;/string&amp;gt;
  &amp;lt;/array&amp;gt;
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cal_xml&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
  &amp;lt;key&amp;gt;StandardOutPath&amp;lt;/key&amp;gt;
  &amp;lt;string&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;log&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;&amp;lt;/string&amp;gt;
  &amp;lt;key&amp;gt;StandardErrorPath&amp;lt;/key&amp;gt;
  &amp;lt;string&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;log&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;&amp;lt;/string&amp;gt;
  &amp;lt;key&amp;gt;ProcessType&amp;lt;/key&amp;gt;
  &amp;lt;string&amp;gt;Background&amp;lt;/string&amp;gt;
&amp;lt;/dict&amp;gt;
&amp;lt;/plist&amp;gt;
&lt;/span&gt;&lt;span class="no"&gt;XMLEOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command is wrapped as &lt;code&gt;/bin/zsh -c "cmd"&lt;/code&gt;. Commands that were running under cron often depend on shell expansion (&lt;code&gt;~&lt;/code&gt; expansion, globbing), and there are cases where passing them directly to &lt;code&gt;ProgramArguments&lt;/code&gt; doesn't work. Going through zsh absorbs that difference. &lt;code&gt;${cmd//&amp;amp;/&amp;amp;amp;}&lt;/code&gt; is XML escaping — a command containing &lt;code&gt;&amp;amp;&lt;/code&gt; would produce invalid XML, so it's substituted here. Logs send both stdout and stderr together to &lt;code&gt;~/.claude/logs/${label}.log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 5: Deployment in apply mode (lines 84–96)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MODE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"apply"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROPOSED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.plist&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
    launchctl unload &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
    launchctl load   &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  loaded: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"🚨 cron 行は **手動で削除してください**:  crontab -e"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"(誤って cron+launchd 両方走るのを避けるため)"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;launchctl unload&lt;/code&gt; is called first for idempotency. Trying to load a plist that's already loaded results in an error. Unloading beforehand means running &lt;code&gt;apply&lt;/code&gt; any number of times produces the same result. But &lt;strong&gt;there's one caveat&lt;/strong&gt; — the script only prints a warning after apply saying "please delete from crontab manually"; it doesn't automate the deletion. Leave the cron lines in place and, whenever macOS eventually revives the cron daemon, you get &lt;strong&gt;double execution from cron + launchd&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;set -uo pipefail&lt;/code&gt; — Why &lt;code&gt;-e&lt;/code&gt; Was Left Out
&lt;/h3&gt;

&lt;p&gt;The declaration at the top of the script is &lt;code&gt;set -uo pipefail&lt;/code&gt; (line 9 of the real file). Some of you may have noticed &lt;code&gt;-e&lt;/code&gt; (exit immediately on error) isn't there. That's an intentional design decision.&lt;/p&gt;

&lt;p&gt;Look at the loop in &lt;code&gt;apply&lt;/code&gt; mode (lines 84–96).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl unload &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
launchctl load   &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;launchctl unload&lt;/code&gt; returns a non-zero exit code if the target plist isn't loaded yet. With &lt;code&gt;-e&lt;/code&gt; enabled, the script dies on the very first unload of the first plist. &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; suppresses the error output, but the exit code remains. Omitting &lt;code&gt;-e&lt;/code&gt; is what delivers the idempotent behavior of "keep the loop going even if unload fails."&lt;/p&gt;

&lt;p&gt;For the same reason, &lt;code&gt;crontab -l 2&amp;gt;/dev/null&lt;/code&gt; (line 22) is safe. In a user environment with an empty crontab, &lt;code&gt;crontab -l&lt;/code&gt; exits non-zero with &lt;code&gt;crontab: no crontab for &amp;lt;username&amp;gt;&lt;/code&gt;, but &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; swallows it and the loop proceeds. With &lt;code&gt;-e&lt;/code&gt;, it would have died right there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;-u&lt;/code&gt; (error on undefined variables) and &lt;code&gt;-o pipefail&lt;/code&gt; (propagating pipe failures) stay.&lt;/strong&gt; Those are guards you need — for catching variable name typos and failures partway through a pipe. Only &lt;code&gt;-e&lt;/code&gt; gets in the way — and that judgment call is a recurring pattern in shell script error handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Label-Generation Regex and Its Absolute-Path Dependency
&lt;/h3&gt;

&lt;p&gt;Read the label generation logic on line 35 precisely and one important specification becomes visible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;script&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'~/.claude/scripts/[^ ]+'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | xargs &lt;span class="nb"&gt;basename &lt;/span&gt;2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the regex &lt;strong&gt;matches on the absolute path&lt;/strong&gt;, not &lt;code&gt;~/.claude/scripts/&lt;/code&gt;. If the crontab entry was written as &lt;code&gt;~/.claude/scripts/daily-brief.sh&lt;/code&gt;, this regex won't match, because &lt;code&gt;~&lt;/code&gt; is recorded as a literal string before the shell expands it. If it doesn't match, the &lt;code&gt;script&lt;/code&gt; variable ends up empty and falls through to the fallback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$script&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;script&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt; | xargs &lt;span class="nb"&gt;basename &lt;/span&gt;2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;hour&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback is "basename of the command + minute + hour." For example, if you registered &lt;code&gt;~/.claude/scripts/daily-brief.sh&lt;/code&gt; with &lt;code&gt;0 8 * * *&lt;/code&gt;, the label becomes &lt;code&gt;com.shun.daily-brief-08&lt;/code&gt;. Not &lt;code&gt;daily-brief&lt;/code&gt; but &lt;code&gt;daily-brief-08&lt;/code&gt;. That discrepancy breeds confusion later when you're chasing logs.&lt;/p&gt;

&lt;p&gt;Always write absolute paths when registering in the crontab — that's the only correct way to coexist with this script.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the &lt;code&gt;*/N&lt;/code&gt; Format Breaks — Traced Through the Code
&lt;/h3&gt;

&lt;p&gt;Let's confirm the "&lt;code&gt;*/N&lt;/code&gt; unsupported" point raised earlier through the actual code flow. Say the crontab has the line &lt;code&gt;*/15 * * * * ~/.claude/scripts/health-check.sh&lt;/code&gt;. What happens?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"*/15 * * * * ~/.claude/scripts/health-check.sh"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# → "*/15"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the conditional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$minute&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;cal_xml+&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"    &amp;lt;key&amp;gt;Minute&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"*/15" != "*"&lt;/code&gt; is true, so it passes the condition, and the generated XML is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;*/15&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The string &lt;code&gt;*/15&lt;/code&gt; ends up inside an &lt;code&gt;&amp;lt;integer&amp;gt;&lt;/code&gt; tag. It parses as XML, more or less, but when launchd loads the plist it gets rejected by the validation that "Minute must be an integer from 0 to 59." &lt;code&gt;launchctl load&lt;/code&gt; returns a non-zero exit code, &lt;code&gt;loaded:&lt;/code&gt; still gets printed, but scheduling was never actually enabled.&lt;/p&gt;

&lt;p&gt;This "looks like the load went through but it isn't actually running" state is nasty, and it shows up again in the next section.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Intent and Limits of the &lt;code&gt;/bin/zsh -c&lt;/code&gt; Wrapper
&lt;/h3&gt;

&lt;p&gt;The ProgramArguments in the generated plist (lines 54–66):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/zsh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;-c&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;${cmd}&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapping the command in zsh is there to get the &lt;code&gt;~&lt;/code&gt; expansion, environment variable references, and glob patterns that tend to appear in cron entries interpreted. Pass a command directly to &lt;code&gt;ProgramArguments&lt;/code&gt; and execvp is called without shell expansion, so &lt;code&gt;~&lt;/code&gt; gets passed through as a literal string and you get a file-not-found error.&lt;/p&gt;

&lt;p&gt;That said, even with &lt;code&gt;/bin/zsh -c&lt;/code&gt;, launchd does not read your &lt;code&gt;.zshrc&lt;/code&gt;. That's launchd's design. It starts zsh in non-login script mode rather than interactive mode, so even if you've written &lt;code&gt;source ~/.zshrc&lt;/code&gt;, it isn't loaded. As a result, processes start in a state where &lt;strong&gt;node managed by nvm, python from pyenv, and the various Homebrew commands have no PATH to them&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Look at the generated plist template and there's no &lt;code&gt;EnvironmentVariables&lt;/code&gt; key (not anywhere across lines 54–76). That's exactly why &lt;code&gt;daily-brief.plist&lt;/code&gt; has &lt;code&gt;EnvironmentVariables&lt;/code&gt; appended by hand. The plists the script auto-generates do not include this PATH injection.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stuck 1: &lt;code&gt;*/15 * * * *&lt;/code&gt; Failed Silently
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Running &lt;code&gt;apply&lt;/code&gt; printed &lt;code&gt;loaded: com.shun.health-check.plist&lt;/code&gt;. But 15 minutes later, and 30 minutes later, nothing was written to &lt;code&gt;~/.claude/logs/com.shun.health-check.log&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl list com.shun.health-check
&lt;span class="c"&gt;# → Could not find service "com.shun.health-check" in domain for port&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A service that should be loaded doesn't exist in launchctl's list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; &lt;code&gt;*/15&lt;/code&gt; was written straight into &lt;code&gt;&amp;lt;integer&amp;gt;*/15&amp;lt;/integer&amp;gt;&lt;/code&gt;, and launchd internally rejected the plist during validation. Because the &lt;code&gt;launchctl load&lt;/code&gt; command itself returned exit code 0 (behavior on macOS Sequoia), the script's &lt;code&gt;echo "loaded:"&lt;/code&gt; ran anyway. With no error shown, the service simply didn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Validating the plist with &lt;code&gt;plutil -lint ~/.claude/scripts/launchd-proposed/com.shun.health-check.plist&lt;/code&gt; rejects it immediately. Lines containing &lt;code&gt;*/15&lt;/code&gt; need to be manually rewritten in the crontab before migrating. For every 15 minutes, either switch to launchd's &lt;code&gt;StartInterval&lt;/code&gt; (interval specified in seconds), or write out the fixed values &lt;code&gt;00,15,30,45&lt;/code&gt; as an array of four entries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;15&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;30&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;45&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or specifying seconds with &lt;code&gt;StartInterval&lt;/code&gt; is simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;900&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;900 seconds = 15 minutes. This form is outside the script's auto-generation scope, but it's a single hand-written spot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck 2: &lt;code&gt;~&lt;/code&gt; Paths in the crontab Caused Label Collisions and Overwrote Old plists
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Inside &lt;code&gt;~/.claude/scripts/launchd-proposed/&lt;/code&gt;, which I was checking in &lt;code&gt;dry&lt;/code&gt; mode, plists with unfamiliar label names had appeared. Names like &lt;code&gt;com.shun.daily-brief-08.plist&lt;/code&gt; and &lt;code&gt;com.shun.note-publish-308.plist&lt;/code&gt; — with a time appended to the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; Because the crontab was written with &lt;code&gt;~&lt;/code&gt; as &lt;code&gt;~/.claude/scripts/daily-brief.sh&lt;/code&gt;, it didn't hit the absolute-path match &lt;code&gt;~/.claude/scripts/[^ ]+&lt;/code&gt; on line 35 and fell into the fallback &lt;code&gt;command-name-minutehour&lt;/code&gt; form. On top of that, the &lt;code&gt;com.shun.daily-brief.plist&lt;/code&gt; generated by a previous &lt;code&gt;apply&lt;/code&gt; was still sitting in &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt;, so &lt;strong&gt;the old plist and the new plist existed in duplicate under different labels&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;launchctl list | grep com.shun&lt;/code&gt; showed two entries calling the same script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Open the crontab with &lt;code&gt;crontab -e&lt;/code&gt; and rewrite &lt;code&gt;~&lt;/code&gt; as an absolute path. Then manually unload and delete the old-label plist in &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl unload ~/Library/LaunchAgents/com.shun.daily-brief-08.plist
&lt;span class="nb"&gt;rm&lt;/span&gt; ~/Library/LaunchAgents/com.shun.daily-brief-08.plist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need the habit of always running &lt;code&gt;dry&lt;/code&gt; before &lt;code&gt;apply&lt;/code&gt; to visually confirm the generated labels and check they're in the expected &lt;code&gt;com.shun.&amp;lt;script-name&amp;gt;&lt;/code&gt; form. If fallback-form names (trailing digits) are mixed in, suspect how the crontab is written.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck 3: node and python Were &lt;code&gt;command not found&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; After &lt;code&gt;apply&lt;/code&gt;, the same error kept appearing every time in &lt;code&gt;~/.claude/logs/com.shun.note-autolike.log&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;/bin/zsh: node: command not found
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the same command manually from the terminal works fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; The generated plist doesn't include &lt;code&gt;EnvironmentVariables&lt;/code&gt;. Even started via &lt;code&gt;/bin/zsh -c&lt;/code&gt;, &lt;code&gt;.zshrc&lt;/code&gt; isn't read, and the &lt;code&gt;~/.nvm/versions/node/v24.13.0/bin&lt;/code&gt; that nvm adds isn't in PATH. Your terminal's shell session and processes under launchd management run in completely different PATH environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Manually edit the generated plist and add &lt;code&gt;EnvironmentVariables&lt;/code&gt; before &lt;code&gt;&amp;lt;key&amp;gt;ProgramArguments&amp;lt;/key&amp;gt;&lt;/code&gt;. &lt;code&gt;daily-brief.plist&lt;/code&gt; (quoted from the real file) is the correct model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;PATH&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Properly, this block should be built into the script's generation template. But "which node version to use" varies by environment, and hardcoding it into the template means rewriting every plist when the environment changes. Perhaps the current script omits it deliberately to avoid that "danger of pinning a version" — at least, that's how I've interpreted it to make peace with it.&lt;/p&gt;

&lt;p&gt;In actual operation, I always hand-add EnvironmentVariables to the plists of jobs that use node. The division of labor is: script generation "builds 90% of the skeleton," and the remaining 10% — PATH injection — is manual.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck 4: cron + launchd Were Double-Running the Same Script
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; note auto-posting was supposed to run twice a day, but the logs showed the posting API being called four times a day. It came to light when I hit the rate limit and error responses started appearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; I'd forgotten to delete the cron lines with &lt;code&gt;crontab -e&lt;/code&gt; after &lt;code&gt;apply&lt;/code&gt;. I'd overlooked the warning at the end of the script (lines 94–95).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;🚨 cron 行は &lt;span class="gs"&gt;**手動で削除してください**&lt;/span&gt;:  crontab -e
(誤って cron+launchd 両方走るのを避けるため)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd convinced myself that "cron lines are safe to leave" because the cron daemon doesn't start in a macOS Sequoia environment. In reality, even on Sequoia there are moments when the cron daemon restarts (mainly after OS updates), and at that point both start running. This time, a macOS minor update was that moment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Check which lines have been migrated to launchd with &lt;code&gt;crontab -l&lt;/code&gt; and either delete them all or comment out the migrated ones. The safest is &lt;code&gt;crontab -r&lt;/code&gt; (delete everything), but if anything hasn't been migrated there's no way back, so I handled it with &lt;code&gt;crontab -e&lt;/code&gt;, checking line by line.&lt;/p&gt;

&lt;p&gt;Since that failure, I run these two commands as a set to confirm &lt;code&gt;apply&lt;/code&gt; is complete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# launchd側の稼働確認&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;com.shun

&lt;span class="c"&gt;# cron側の残骸確認（0行ならOK）&lt;/span&gt;
crontab &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'^\s*#'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^$'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the second command returns 0, no active cron lines exist. That's my criterion for judging the migration complete.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck 5: The plist I Checked in &lt;code&gt;dry&lt;/code&gt; Was a Different File From the One &lt;code&gt;apply&lt;/code&gt; Deployed
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Eyeball the output in &lt;code&gt;dry&lt;/code&gt; → no problems → run &lt;code&gt;apply&lt;/code&gt; → and somehow the schedule has changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; Old plists from a previous &lt;code&gt;dry&lt;/code&gt; were still sitting in the &lt;code&gt;PROPOSED&lt;/code&gt; directory (&lt;code&gt;~/.claude/scripts/launchd-proposed/&lt;/code&gt;). This time's &lt;code&gt;dry&lt;/code&gt; generated from a different set of cron lines, so updated plists and old plists were mixed together. Since &lt;code&gt;apply&lt;/code&gt; deploys all of &lt;code&gt;PROPOSED/*.plist&lt;/code&gt;, unintended older-generation plists also got copied over into &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROPOSED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.plist&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This copy-everything is the origin of the problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Make it a habit to clear the &lt;code&gt;PROPOSED&lt;/code&gt; directory before &lt;code&gt;dry&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.claude/scripts/launchd-proposed/&lt;span class="k"&gt;*&lt;/span&gt;.plist
~/.claude/scripts/cron-to-launchd.sh dry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or check the diff between &lt;code&gt;PROPOSED&lt;/code&gt; and &lt;code&gt;LaunchAgents&lt;/code&gt; with &lt;code&gt;diff&lt;/code&gt; right before &lt;code&gt;apply&lt;/code&gt;. Both are chores, but since there's no cleanup handling on the script side, for now manual discipline is the only way to cover it.&lt;/p&gt;

&lt;p&gt;To sum up the sticking points so far: the only lines the script automates are the ones that are "fixed schedule, absolute path, no PATH needed." The rest — &lt;code&gt;*/N&lt;/code&gt; format, &lt;code&gt;~&lt;/code&gt; paths, nvm/pyenv dependencies — need manual pre- or post-processing. Had I understood that boundary up front, I could have prevented three of the four failures. It's more accurate to read the 97-line script not as something that "fully automates cron migration," but as a tool that "skips 80% of the manual work and throws the remaining 20% into relief."&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;The "where I got stuck" section above covered five episodes. Here I organize the pitfalls systematically so the same failures don't repeat. First let's confirm "the scope the script can automate," then line up the easily-missed traps all at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Can and Can't Be Automated
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;cron-to-launchd.sh&lt;/code&gt; (97 lines) only works correctly for cron lines that satisfy all of the following conditions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The cron expression uses only fixed values — no &lt;code&gt;*/N&lt;/code&gt; format&lt;/li&gt;
&lt;li&gt;The command is written with an absolute path — not a &lt;code&gt;~&lt;/code&gt; expansion&lt;/li&gt;
&lt;li&gt;The command string contains no &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, or &lt;code&gt;&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The same script is registered at only one time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lines that fall outside these four conditions either break auto-generation or require mandatory manual fixes after generation. It's accurate to use it not as something that "fully automates migrating every crontab line," but as "a tool that builds 80% of the skeleton for lines meeting the four conditions and throws the remaining 20% of manual work into relief."&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall List (With Real Code)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;XML escaping only covers &lt;code&gt;&amp;amp;&lt;/code&gt; — plists break on lines containing &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Look at line 65 of the script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&amp;lt;string&amp;gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;//&amp;amp;/&amp;amp;amp;&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&amp;lt;/string&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It converts &lt;code&gt;&amp;amp;&lt;/code&gt; to &lt;code&gt;&amp;amp;amp;&lt;/code&gt;, but there's no conversion for &lt;code&gt;&amp;lt;&lt;/code&gt; → &lt;code&gt;&amp;amp;lt;&lt;/code&gt; or &lt;code&gt;&amp;gt;&lt;/code&gt; → &lt;code&gt;&amp;amp;gt;&lt;/code&gt;. If your crontab has a line with a redirect like &lt;code&gt;cmd &amp;gt; /dev/null 2&amp;gt;&amp;amp;1&lt;/code&gt;, a &lt;code&gt;&amp;gt;&lt;/code&gt; gets mixed into the &lt;code&gt;&amp;lt;string&amp;gt;&lt;/code&gt; tag of the generated plist and the XML parser can't read the plist. &lt;code&gt;launchctl load&lt;/code&gt; returns an error, but since the apply loop moves on to the next plist, it's a structure where a single broken file is easy to miss. For lines containing &lt;code&gt;&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;&lt;/code&gt;, either move the redirect inside the script before migrating, or hand-write the plist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated plists have no &lt;code&gt;RunAtLoad&lt;/code&gt; — you can't verify behavior right after apply&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The auto-generation template (all of lines 54–76) has no &lt;code&gt;RunAtLoad&lt;/code&gt; key. Meanwhile, lines 28–29 of the hand-finished &lt;code&gt;com.shun.daily-brief.plist&lt;/code&gt; real file contain &lt;code&gt;&amp;lt;key&amp;gt;RunAtLoad&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A plist without &lt;code&gt;RunAtLoad&lt;/code&gt; doesn't execute until the next scheduled time. Checking the log right after &lt;code&gt;apply&lt;/code&gt; and finding nothing written isn't a malfunction — it's by design. The problem, though, is that you can't test "does this actually work" on the spot. When you want to check, use &lt;code&gt;launchctl kickstart&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl kickstart &lt;span class="nt"&gt;-k&lt;/span&gt; gui/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/com.shun.xxx
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.claude/logs/com.shun.xxx.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;StartCalendarInterval&lt;/code&gt; is a bare &lt;code&gt;&amp;lt;dict&amp;gt;&lt;/code&gt; — multiple times require manual conversion to &lt;code&gt;&amp;lt;array&amp;gt;&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cal_xml on lines 46–52 of the generation script is complete with a single &lt;code&gt;&amp;lt;dict&amp;gt;&lt;/code&gt;. Expressing "twice, at 8:00 and 10:30" like &lt;code&gt;com.shun.daily-brief.plist&lt;/code&gt; (lines 33–47 of the real file) requires an array, but the script doesn't generate arrays.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- 自動生成物（単一時刻しか表現できない） --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;8&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to assign multiple times to the same script, manually rewrite the plist into array form after generation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Registering the same script at multiple times in the crontab makes the later plist overwrite the earlier one&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Suppose you want &lt;code&gt;daily-brief.sh&lt;/code&gt; to run at 8:00 and 10:30, so you write two lines in the crontab.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0  8  &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /path/to/.claude/scripts/daily-brief.sh
30 10 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /path/to/.claude/scripts/daily-brief.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because label generation (line 40) strips the extension from the script name, both lines become &lt;code&gt;com.shun.daily-brief&lt;/code&gt;. The plist filename is identically &lt;code&gt;com.shun.daily-brief.plist&lt;/code&gt;. The line processed later (the 10:30 one) overwrites the earlier one (8:00), and the 8:00 setting disappears. There's no collision detection on the script side. Eyeballing the generated output in &lt;code&gt;dry&lt;/code&gt; is the only recourse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;*/N&lt;/code&gt; format fails silently — &lt;code&gt;launchctl load&lt;/code&gt; looks successful&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The crux of the episode detailed on p2, in one line. &lt;code&gt;*/15&lt;/code&gt; gets written out as &lt;code&gt;&amp;lt;integer&amp;gt;*/15&amp;lt;/integer&amp;gt;&lt;/code&gt; and launchd rejects the plist during internal validation. The &lt;code&gt;launchctl load&lt;/code&gt; command returns exit code 0 so it looks successful, but if &lt;code&gt;launchctl list com.shun.xxx&lt;/code&gt; can't find the service, it was rejected. Manually converting lines containing &lt;code&gt;*/N&lt;/code&gt; before migration is the only solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;~&lt;/code&gt; paths fall into the label-generation fallback&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The regex on line 35 only matches absolute paths. If you've written &lt;code&gt;~/.claude/scripts/note-autolike.sh&lt;/code&gt;, the fallback (lines 36–39) kicks in and the label gets trailing digits, like &lt;code&gt;com.shun.note-autolike-308&lt;/code&gt;. If a &lt;code&gt;com.shun.note-autolike.plist&lt;/code&gt; generated earlier from an absolute path is still in &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt;, you've created a double-execution state where two different labels call the same script. Always write absolute paths in the crontab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running &lt;code&gt;apply&lt;/code&gt; without clearing &lt;code&gt;PROPOSED&lt;/code&gt; mixes in old plists&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;for f in "$PROPOSED"/*.plist&lt;/code&gt; on line 87 copies every file in PROPOSED indiscriminately. If a plist generated by a previous &lt;code&gt;dry&lt;/code&gt; for a cron line you've since deleted is still there, the job you thought you deleted comes back to life on &lt;code&gt;apply&lt;/code&gt;. Make clearing with &lt;code&gt;rm -f ~/.claude/scripts/launchd-proposed/*.plist&lt;/code&gt; before running &lt;code&gt;dry&lt;/code&gt; a habit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated plists have no &lt;code&gt;EnvironmentVariables&lt;/code&gt; — nvm, pyenv, and Homebrew commands die&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The generation template (lines 54–76) doesn't include the &lt;code&gt;EnvironmentVariables&lt;/code&gt; key. Since launchd doesn't read your &lt;code&gt;.zshrc&lt;/code&gt;, a script calling nvm-managed node falls over immediately at startup with &lt;code&gt;node: command not found&lt;/code&gt;. It works fine when run manually from the terminal but dies via launchd — that asymmetry makes diagnosis hard. Using the PATH string on lines 6–9 of &lt;code&gt;com.shun.daily-brief.plist&lt;/code&gt; as your model, add it to every plist that uses node or python.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated plists have no &lt;code&gt;LowPriorityIO&lt;/code&gt; or &lt;code&gt;Nice&lt;/code&gt; — automation interferes with the foreground&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lines 12–15 of &lt;code&gt;com.shun.daily-brief.plist&lt;/code&gt; have &lt;code&gt;LowPriorityIO&lt;/code&gt; and &lt;code&gt;Nice 10&lt;/code&gt;, but the generation template doesn't. Without the setting, background jobs run at normal I/O priority. If you've ever had a job doing heavy file reads and writes slow down your editor or browser's responsiveness, check whether these keys are present.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting to delete cron lines is a time bomb — the next OS update double-runs everything&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After the script's apply (lines 94–95) it only warns "please delete the cron lines manually"; the deletion isn't automated. Since the cron daemon doesn't start on Sequoia, it's easy to think "leaving them is safe," but there are real cases where a macOS minor update revives the cron daemon. My note auto-posting running four times a day and hitting the API rate limit came out of this failure. I prevent recurrence by including "zero cron leftovers" in the criteria for migration completion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;A rule set distilled from a 97-line script and six months of operation, usable for both migration work and day-to-day operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Write crontab entries with absolute paths&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Write &lt;code&gt;/home/.../.claude/scripts/xxx.sh&lt;/code&gt; instead of &lt;code&gt;~/.claude/scripts/xxx.sh&lt;/code&gt;. It matches the regex on line 35 and the label becomes the intended &lt;code&gt;com.shun.xxx&lt;/code&gt;. Rewriting past cron lines takes effort, but it prevents three things at once: label collisions, double execution, and confusion from fallback naming after migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Manually convert the &lt;code&gt;*/N&lt;/code&gt; format before migrating&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;*/15 * * * *&lt;/code&gt; (every 15 minutes) converts to one of two things. If the interval is fixed, &lt;code&gt;StartInterval&lt;/code&gt; (in seconds) is simplest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;900&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;  &lt;span class="c"&gt;&amp;lt;!-- 900秒 = 15分 --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need execution at specific minutes, enumerate fixed values in an array (minutes 0, 15, 30, 45). Missed conversions can be caught with &lt;code&gt;plutil -lint&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Clear the &lt;code&gt;PROPOSED&lt;/code&gt; directory before &lt;code&gt;dry&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.claude/scripts/launchd-proposed/&lt;span class="k"&gt;*&lt;/span&gt;.plist
~/.claude/scripts/cron-to-launchd.sh dry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running these two lines as a set prevents the problem of older-generation plists getting mixed into &lt;code&gt;apply&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Validate every plist with &lt;code&gt;plutil -lint&lt;/code&gt; after &lt;code&gt;dry&lt;/code&gt;, before &lt;code&gt;apply&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; ~/.claude/scripts/launchd-proposed/&lt;span class="k"&gt;*&lt;/span&gt;.plist&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--- &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  plutil &lt;span class="nt"&gt;-lint&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Catch &lt;code&gt;*/N&lt;/code&gt; contamination, missed XML escaping, and syntax errors up front with Apple's official tool. Don't &lt;code&gt;apply&lt;/code&gt; any plist for which &lt;code&gt;plutil -lint&lt;/code&gt; doesn't return &lt;code&gt;OK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Confirm completion with two commands right after &lt;code&gt;apply&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# launchd側の稼働確認&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;com.shun

&lt;span class="c"&gt;# cron残骸確認（0ならOK）&lt;/span&gt;
crontab &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'^\s*#'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^$'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the second line returns &lt;code&gt;0&lt;/code&gt; and the launchd entry count matches the number of lines targeted for migration, you can judge the migration complete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Manually add &lt;code&gt;EnvironmentVariables&lt;/code&gt; to the plists of jobs that use node&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Insert it immediately before &lt;code&gt;&amp;lt;key&amp;gt;ProgramArguments&amp;lt;/key&amp;gt;&lt;/code&gt; right after generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;PATH&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Match the nvm version number to your actual environment. Lines 6–9 of &lt;code&gt;com.shun.daily-brief.plist&lt;/code&gt; are the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Rewrite &lt;code&gt;StartCalendarInterval&lt;/code&gt; as an array for multi-time plists&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you want to run the same script at two times, use an array in a single plist (don't write two crontab lines and cause a label collision).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;8&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;30&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The description on lines 33–47 of &lt;code&gt;com.shun.daily-brief.plist&lt;/code&gt; is a live example.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Set &lt;code&gt;LowPriorityIO&lt;/code&gt; and &lt;code&gt;Nice 10&lt;/code&gt; on background jobs generally&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add it to every generated plist so it doesn't get in the way of your work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By having the automation environment "erase its presence," you can design it so it doesn't encroach on human working territory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Hand-write plists for lines whose commands contain &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, or &lt;code&gt;&amp;gt;&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't rely on generation; do the XML escaping accurately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;amp;&lt;/code&gt; → &lt;code&gt;&amp;amp;amp;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;&lt;/code&gt; → &lt;code&gt;&amp;amp;lt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;gt;&lt;/code&gt; → &lt;code&gt;&amp;amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Moving redirects inside the shell script being called is cleanest. Try to handle redirects within the plist's XML and you'll almost always hit this escaping problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Periodically check LastExitStatus with &lt;code&gt;launchctl list com.shun.xxx&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl list com.shun.daily-brief
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"LastExitStatus" = 0&lt;/code&gt; is healthy. Anything other than 0, check the log. Weekly bulk check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;com.shun | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3}'&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'launchctl list "{}" 2&amp;gt;/dev/null'&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'"Label"|"LastExitStatus"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;11. Debug with on-demand execution via &lt;code&gt;launchctl kickstart&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you want immediate execution without waiting for the scheduled time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl kickstart &lt;span class="nt"&gt;-k&lt;/span&gt; gui/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/com.shun.xxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-k&lt;/code&gt; is an idempotent option that kills the running instance and restarts it. If nothing appears in the log, it's a PATH problem or a script path problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Verify every service is alive after a macOS update&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Minor updates can change launchd's behavior. If the daily brief doesn't arrive the morning after an update, hit &lt;code&gt;launchctl list | grep com.shun&lt;/code&gt; first. If a service is gone, re-&lt;code&gt;apply&lt;/code&gt; brings it back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. Define three "completion conditions" for the migration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the "end" of migration work is vague, you tend to skip verification. I set the following as completion conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The entry count of &lt;code&gt;launchctl list | grep com.shun&lt;/code&gt; matches the number of cron lines targeted for migration&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crontab -l 2&amp;gt;/dev/null | grep -vE '^\s*#' | grep -v '^$' | wc -l&lt;/code&gt; returns &lt;code&gt;0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Every service's &lt;code&gt;LastExitStatus&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;, at least after its first run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only when all three are satisfied can you say "migration complete."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;14. Estimate the total time for the migration work up front&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Count the number of cron lines, how many contain the &lt;code&gt;*/N&lt;/code&gt; format, and how many jobs depend on nvm before you start. With ten or fewer, the whole sequence of &lt;code&gt;dry&lt;/code&gt; → &lt;code&gt;plutil&lt;/code&gt; validation → manual fixes → &lt;code&gt;apply&lt;/code&gt; → completion check finishes within 90 minutes. With 30 or more, a split strategy is realistic: auto-migrate the lines meeting the four conditions first, then hand-migrate the rest on later days.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The problem of macOS's cron daemon quietly stopping is slow to discover precisely because no error appears. Entries are lined up in &lt;code&gt;crontab -l&lt;/code&gt;, yet the 8:00 daily brief doesn't arrive and the 11:00 social post doesn't go out — and it takes hours before that odd feeling registers. It's more accurate to frame migrating to launchd not as "dealing with it after cron breaks," but as "an up-front investment in getting back onto macOS's native mechanism."&lt;/p&gt;

&lt;p&gt;What the 97-line &lt;code&gt;cron-to-launchd.sh&lt;/code&gt; does is simple. Read the crontab line by line, convert five fields into XML, write it out as a plist. In three steps — dry → plutil validation → apply — you can mass-produce skeletons for lines that are fixed-schedule, absolute-path, and PATH-free. But it's not "fully automatic magic." The &lt;code&gt;*/N&lt;/code&gt; format, &lt;code&gt;~&lt;/code&gt; paths, nvm/pyenv dependencies, multiple times, characters requiring XML escaping — these need manual pre- or post-processing. By having the script "build 90% of the skeleton," the target of the manual work becomes clear. Understanding that structure and using it accordingly is the shortest path to not getting stuck after migration.&lt;/p&gt;

&lt;p&gt;For jobs you've finished moving to launchd, you can check state instantly with &lt;code&gt;launchctl list com.shun.xxx&lt;/code&gt;. &lt;code&gt;LastExitStatus&lt;/code&gt; being 0 proves "it is running," not "it should be running." The reliability of an autonomous environment accumulates by eliminating the discovery that "I thought it was running, but it had stopped."&lt;/p&gt;




&lt;p&gt;I've written up the full picture of the setup, the ¥1.2M/month breakdown, and the 30-day procedure in a paid note.&lt;/p&gt;

&lt;p&gt;📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>macos</category>
      <category>launchd</category>
      <category>shell</category>
    </item>
    <item>
      <title>28 Hours of Green Logs, Zero Replies: How a Single `echo` Line Swallowed Every Exit Code</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Mon, 31 Aug 2026 05:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/28-hours-of-green-logs-zero-replies-how-a-single-echo-line-swallowed-every-exit-code-48gb</link>
      <guid>https://dev.to/bokuwalily/28-hours-of-green-logs-zero-replies-how-a-single-echo-line-swallowed-every-exit-code-48gb</guid>
      <description>&lt;p&gt;For 24 hours, every dashboard was green. Every launchd job reported &lt;code&gt;exit 0&lt;/code&gt;, and the logs lined up neatly with &lt;code&gt;(exit 0)&lt;/code&gt; on every row. Meanwhile, DM replies on X (formerly Twitter) were at zero for the entire day, and YouTrust was the same. The failures were happening. The exit codes just never made it back to the caller.&lt;/p&gt;

&lt;p&gt;Back in university I stacked freelance gigs up to ¥600k/month, then got laid off and went back to zero. Over the following six months I built an autonomous Claude Code environment, and I'm now at ¥1.2M/month in revenue. This article is about the hook wiring that holds that environment together — more precisely, about the time I thought I'd wired it up and nothing was actually plugged in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Mechanism Works
&lt;/h2&gt;

&lt;p&gt;Claude Code has two kinds of hook points: &lt;strong&gt;PreToolUse&lt;/strong&gt; and &lt;strong&gt;Stop&lt;/strong&gt;. PreToolUse interrupts right before Claude invokes a tool; Stop runs right before Claude tries to finish a response. If a hook script returns &lt;code&gt;exit 1&lt;/code&gt;, Claude blocks that tool call or Stop action — by specification.&lt;/p&gt;

&lt;p&gt;When I learned about this, I immediately wanted to use it as a guardrail for my automation environment. "Don't let an implementation be marked complete without an audit." "Stop if a secret is about to slip into a commit." "Cancel the Action if a designated script fails." You write these in code and control Claude's behavior from the outside.&lt;/p&gt;

&lt;p&gt;Speaking frankly as someone who runs this environment: with 171 launchd jobs and multiple Claude Code sessions running at once, something breaks every day without hooks. Config mistakes, environment differences, model whims — hooks let you land each of them. Which is exactly why a state where &lt;strong&gt;a hook is only pretending to work and isn't actually stopping anything&lt;/strong&gt; is the same as a cliff with no guardrail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenarios Readers Will Hit
&lt;/h3&gt;

&lt;p&gt;If you use Claude Code, there's a good chance you'll get caught by one of these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern A: You wrote your hook as a shell script.&lt;/strong&gt; The script returns &lt;code&gt;exit 1&lt;/code&gt;, but Claude doesn't stop. Check the logs and the exit code reads &lt;code&gt;0&lt;/code&gt; — even though you're sure you wrote the script correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern B: You call your hook through a JS wrapper.&lt;/strong&gt; The JS wrapper uses &lt;code&gt;child_process.exec()&lt;/code&gt; or &lt;code&gt;$()&lt;/code&gt; command substitution to call the inner shell script. The inner script returns &lt;code&gt;exit 1&lt;/code&gt;, but the outer JS process receives &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern C: You call the script inside a pipeline.&lt;/strong&gt; You pass stdin through a pipe, like &lt;code&gt;cat input.json | ./hook-script.sh&lt;/code&gt;. Without &lt;code&gt;set -o pipefail&lt;/code&gt;, only the exit code of the right-hand side of the pipe reaches the caller.&lt;/p&gt;

&lt;p&gt;In every case: "I wrote the hook," "it's running," "the logs are there" — and the guard still isn't working. Visually green, actually protecting nothing. That's the essence of a silent bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not "No Failure Occurred," but "The Failure Code Never Arrived"
&lt;/h3&gt;

&lt;p&gt;On the day of the incident, every line of the DM system's log read &lt;code&gt;(exit 0)&lt;/code&gt;. But the problem was in that log line. This is the code that was actually running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$LANE&lt;/span&gt;&lt;span class="s2"&gt; done (exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;echo&lt;/code&gt;'s arguments are evaluated left to right. &lt;code&gt;$(date '+%F %T')&lt;/code&gt; spawns a subshell — and &lt;strong&gt;succeeds&lt;/strong&gt; — and returns. At that instant, &lt;code&gt;$?&lt;/code&gt; is overwritten with &lt;code&gt;0&lt;/code&gt;. &lt;code&gt;$?&lt;/code&gt; is read after that. In other words, whether the preceding &lt;code&gt;node&lt;/code&gt; died with &lt;code&gt;exit 3&lt;/code&gt; or &lt;code&gt;exit 4&lt;/code&gt;, this line is syntactically incapable of printing anything but &lt;code&gt;(exit 0)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;As evidence, I confirmed via a reproduction test that before the fix &lt;code&gt;exit 4&lt;/code&gt; displayed as &lt;code&gt;0&lt;/code&gt;, and after the fix it displayed as &lt;code&gt;4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The frightening part is that it's retroactive.&lt;/strong&gt; As long as that line is in there, not a single &lt;code&gt;(exit 0)&lt;/code&gt; in past logs counts as evidence. "It was green last week and last month too" just means you kept running code that prints green.&lt;/p&gt;

&lt;p&gt;Scanning the 328 shell scripts under &lt;code&gt;~/dev&lt;/code&gt; and &lt;code&gt;~/.claude/scripts&lt;/code&gt; turned up a total of 3 instances of this trap. One around Claude Code's hooks, one in the note paid-bonus ZIP attachment script, and one in a dotfiles snapshot script. All of them were in the state of "the logs were printing, but the exit code was dead."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Overall Flow
&lt;/h2&gt;

&lt;p&gt;First, let's confirm the path by which Claude Code's Stop/PreToolUse hooks call shell scripts.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Full Call Chain
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Code（本体プロセス）
    │
    │  hook event (JSON payload を stdin に渡す)
    ▼
JS hook dispatcher（settings.json で指定）
    │
    │  child_process.spawn() または exec()
    ▼
~/.claude/scripts/hooks/run-with-flags-shell.sh
    │
    │  stdin → パイプ経由で渡す
    │  HOOK_ID / REL_SCRIPT_PATH / PROFILES_CSV を引数で受け取る
    ▼
check-hook-enabled.js（このhookが有効か確認）
    │
    │  enabled ならば
    ▼
$SCRIPT_PATH（実際のフックロジック）
    │
    │  exit 0 / exit 1
    ▼
run-with-flags-shell.sh（終了コードを返す）
    │
    ▼
JS dispatcher（終了コードを受け取る → Claude本体へ）
    │
    ▼
Claude Code（exit 1 なら動作をブロック）
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of this diagram is whether each arrow passes the exit code correctly. If even one link breaks, the terminal &lt;code&gt;exit 1&lt;/code&gt; never reaches Claude.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Actual Code of &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Here is the actual wrapper script (&lt;code&gt;~/.claude/scripts/hooks/run-with-flags-shell.sh&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;HOOK_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;REL_SCRIPT_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;PROFILES_CSV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;standard&lt;/span&gt;&lt;span class="p"&gt;,strict&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_SOURCE&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;PLUGIN_ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_PLUGIN_ROOT&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/../.."&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Preserve stdin for passthrough or script execution&lt;/span&gt;
&lt;span class="nv"&gt;INPUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REL_SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Ask Node helper if this hook is enabled&lt;/span&gt;
&lt;span class="nv"&gt;ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PLUGIN_ROOT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/scripts/hooks/check-hook-enabled.js"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROFILES_CSV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo yes&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENABLED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"yes"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PLUGIN_ROOT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REL_SCRIPT_PATH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[Hook] Script not found for &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOOK_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_PATH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Extract phase prefix from hook ID (e.g., "pre:observe" -&amp;gt; "pre", "post:observe" -&amp;gt; "post")&lt;/span&gt;
&lt;span class="nv"&gt;HOOK_PHASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOOK_ID&lt;/span&gt;&lt;span class="p"&gt;%%&lt;/span&gt;:&lt;span class="p"&gt;*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_PHASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt; is at the top of the file. That's the strictest setting: "exit immediately if a command fails, error on undefined variables, catch failures inside pipes too." The last line, &lt;code&gt;printf '%s' "$INPUT" | "$SCRIPT_PATH" "$HOOK_PHASE"&lt;/code&gt;, uses a pipe, but thanks to &lt;code&gt;pipefail&lt;/code&gt;, if the right-hand &lt;code&gt;$SCRIPT_PATH&lt;/code&gt; returns &lt;code&gt;exit 1&lt;/code&gt;, &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt; itself also ends with &lt;code&gt;exit 1&lt;/code&gt; — &lt;strong&gt;within this script alone&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The problem is &lt;strong&gt;outside&lt;/strong&gt; this script.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Actually Happens Along the Chain
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Point of interest ①: the &lt;code&gt;check-hook-enabled.js&lt;/code&gt; call on line 19&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PLUGIN_ROOT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/scripts/hooks/check-hook-enabled.js"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROFILES_CSV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo yes&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the &lt;code&gt;node&lt;/code&gt; inside &lt;code&gt;$()&lt;/code&gt; dies from some error, &lt;code&gt;|| echo yes&lt;/code&gt; fires and &lt;code&gt;ENABLED&lt;/code&gt; becomes &lt;code&gt;"yes"&lt;/code&gt;. In other words, even when the hook-check script itself is broken, the hook proceeds down the execution path as "enabled." This is intentional as a fallback design, but it's a structure where errors in the hook-check logic get swallowed silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Point of interest ②: the pipe on the last line&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_PHASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;pipefail&lt;/code&gt; is on, &lt;code&gt;$SCRIPT_PATH&lt;/code&gt;'s &lt;code&gt;exit 1&lt;/code&gt; does propagate properly into this script's exit code. But &lt;strong&gt;depending on how the JS dispatcher calls this script&lt;/strong&gt;, whether that code reaches Claude changes.&lt;/p&gt;

&lt;p&gt;If the JS uses &lt;code&gt;child_process.exec()&lt;/code&gt;, it determines success or failure by whether the callback's first argument (&lt;code&gt;error&lt;/code&gt;) is null. Because &lt;code&gt;exec&lt;/code&gt; interposes a shell internally, the shell's exit code arrives as &lt;code&gt;error.code&lt;/code&gt; — &lt;strong&gt;however, when the &lt;code&gt;exec&lt;/code&gt; options set &lt;code&gt;shell: true&lt;/code&gt;, there are cases where the shell itself handles the &lt;code&gt;exit 1&lt;/code&gt; and it never reaches the parent process&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If it uses &lt;code&gt;child_process.spawn()&lt;/code&gt;, you can get the exit code from the &lt;code&gt;code&lt;/code&gt; argument of the &lt;code&gt;close&lt;/code&gt; event. That's mostly accurate, but if the spawned process ends via &lt;code&gt;SIGTERM&lt;/code&gt; or &lt;code&gt;SIGKILL&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt; becomes &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And one more: if JS calls it via command substitution like &lt;code&gt;$(run-with-flags-shell.sh ...)&lt;/code&gt; — as explained earlier — &lt;code&gt;$?&lt;/code&gt; is reliably destroyed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Actual Incident Numbers
&lt;/h3&gt;

&lt;p&gt;The following was confirmed for the incident on 2026-08-29.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;Launches&lt;/th&gt;
&lt;th&gt;Actual replies&lt;/th&gt;
&lt;th&gt;Alerts fired&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;X (formerly Twitter)&lt;/td&gt;
&lt;td&gt;6 (all ABORT)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Fired from the 3rd onward&lt;/strong&gt; (14:33 / 16:15 / 18:15 / 20:15)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTrust&lt;/td&gt;
&lt;td&gt;6 (all failed to launch)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Not a single one&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the X side, the exit code made it through part of the path to the monitoring layer, so from the third run onward a notification appeared in Discord's &lt;code&gt;#01_alerts&lt;/code&gt;. On the YouTrust side, a &lt;code&gt;process.exit(3)&lt;/code&gt; in the library layer bypassed the caller's &lt;code&gt;catch&lt;/code&gt; block, so the &lt;code&gt;read_failures&lt;/code&gt; counter was never incremented and not a single notification reached Discord.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Same day, same root cause (Chrome launch failure), same architectural philosophy — one rang and the other was completely silent.&lt;/strong&gt; The difference is a single line in a library. That one line silenced 24 hours' worth of outreach DMs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's Happening at the Code Level
&lt;/h3&gt;

&lt;p&gt;Here's a minimal sample of the structure where a hook's JS wrapper calls a shell script using &lt;code&gt;$()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ $() 経由では exit code が潰れる&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;execSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;child_process&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scriptPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// execSync はデフォルトで throws on non-zero exit&lt;/span&gt;
    &lt;span class="c1"&gt;// しかし内部で $() を重ねると話が変わる&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;execSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`echo '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' | &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;scriptPath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// ← ここが問題の温床になりやすい&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// e.status が null になるケースがある&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;shell: true&lt;/code&gt; option passes the command string to &lt;code&gt;/bin/sh -c "..."&lt;/code&gt;. Whether that shell wrapper propagates &lt;code&gt;exit 1&lt;/code&gt; as the process's exit code depends on the shell's implementation and how the arguments are assembled. In particular, when you pass a pipe like &lt;code&gt;echo '...' | script.sh&lt;/code&gt; with &lt;code&gt;shell: true&lt;/code&gt;, &lt;code&gt;pipefail&lt;/code&gt; is not inherited into that shell session, so a failure on the left-hand side gets swallowed.&lt;/p&gt;

&lt;p&gt;Meanwhile, the pipe that &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt; itself uses on its last line —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_PHASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— is under the control of the &lt;code&gt;set -euo pipefail&lt;/code&gt; at the top of the script, so &lt;code&gt;$SCRIPT_PATH&lt;/code&gt;'s &lt;code&gt;exit 1&lt;/code&gt; correctly surfaces as the script's exit code. This script on its own is correct. The problem is in the calling layer above it.&lt;/p&gt;

&lt;p&gt;All 3 traps found in the 328-script scan were the same pattern: "an &lt;code&gt;echo&lt;/code&gt; line inside the script mixing &lt;code&gt;$(date)&lt;/code&gt; and &lt;code&gt;$?&lt;/code&gt;." The detection query can be used as-is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'exit \$?'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.sh'&lt;/span&gt; ~/dev ~/.claude/scripts | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'\$('&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This query picks up lines containing &lt;code&gt;exit $?&lt;/code&gt; where &lt;code&gt;$(&lt;/code&gt; also appears on the same line. Assignment patterns like &lt;code&gt;|| rc=$?&lt;/code&gt; (&lt;code&gt;x="$(cmd)" || rc=$?&lt;/code&gt;) are correct usage, so that distinction alone can't be made mechanically — it needs a human eye.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Details
&lt;/h2&gt;

&lt;p&gt;The first half traced the structure of "why exit codes don't arrive." From here we'll look concretely, with real code, at "how to rewrite it so they do." There are 3 fix patterns. Each is a change of 2 lines or less, and each covers a different path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;set -euo pipefail&lt;/code&gt; Alone Doesn't Save the "Outside"
&lt;/h3&gt;

&lt;p&gt;The top of &lt;code&gt;~/.claude/scripts/hooks/run-with-flags-shell.sh&lt;/code&gt; is as follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This line guarantees that "&lt;code&gt;pipefail&lt;/code&gt; is enabled within this script's execution context." Indeed, the final line&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_PHASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is under &lt;code&gt;pipefail&lt;/code&gt;'s control, so if &lt;code&gt;$SCRIPT_PATH&lt;/code&gt; returns &lt;code&gt;exit 1&lt;/code&gt;, &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt; itself also ends with &lt;code&gt;exit 1&lt;/code&gt;. That part is correct.&lt;/p&gt;

&lt;p&gt;The problem is "how the JS dispatcher launches this shell script." If the JS passes &lt;code&gt;shell: true&lt;/code&gt; to &lt;code&gt;child_process.exec()&lt;/code&gt;, the command string is internally wrapped in &lt;code&gt;/bin/sh -c "..."&lt;/code&gt;. That &lt;code&gt;/bin/sh&lt;/code&gt; session does not inherit &lt;code&gt;pipefail&lt;/code&gt;. From JS's point of view, the process tree isn't "&lt;code&gt;/bin/sh&lt;/code&gt; → &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt;" but "shell wrapper → &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt; as its child process." The shell wrapper's own exit code is normally &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And another: when JS extracts a result string via &lt;code&gt;execSync&lt;/code&gt; in a command-substitution-like way —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;execSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`cat payload.json | &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hookScript&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— as long as the left-hand &lt;code&gt;cat payload.json&lt;/code&gt; succeeds, whatever the right-hand &lt;code&gt;hookScript&lt;/code&gt; returns has no effect on &lt;code&gt;execSync&lt;/code&gt;'s error determination. The combination of &lt;code&gt;shell: true&lt;/code&gt; + a pipe + no &lt;code&gt;pipefail&lt;/code&gt; quietly discards the right-hand side's exit code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1: Save &lt;code&gt;$?&lt;/code&gt; Before Any &lt;code&gt;$(...)&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The lightest fix. It applies broadly to "cases where a log line mixes &lt;code&gt;$(date)&lt;/code&gt; and &lt;code&gt;$?&lt;/code&gt;."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ❌ Before：$? が $(date) で上書きされる&lt;/span&gt;
node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$LANE&lt;/span&gt;&lt;span class="s2"&gt; done (exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;

&lt;span class="c"&gt;# ✅ After：STATUS に退避してから $(date) を展開する&lt;/span&gt;
node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;STATUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$LANE&lt;/span&gt;&lt;span class="s2"&gt; done (exit &lt;/span&gt;&lt;span class="nv"&gt;$STATUS&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;STATUS=$?&lt;/code&gt; is just a variable assignment, so it doesn't spawn a subshell. &lt;code&gt;$?&lt;/code&gt; is secured before the next line's &lt;code&gt;$(date)&lt;/code&gt; destroys it. That alone eliminates "the divergence between the code the log prints and the actual exit code."&lt;/p&gt;

&lt;p&gt;There is a caveat, though. The assignment form &lt;code&gt;x="$(cmd)"&lt;/code&gt; has &lt;strong&gt;the exit status of the whole assignment become that of &lt;code&gt;$(cmd)&lt;/code&gt;&lt;/strong&gt;, so this is correct usage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ✅ これは正しい。x の代入ステータスは node のステータスと同じ&lt;/span&gt;
&lt;span class="nv"&gt;x&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;STATUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the other hand, &lt;code&gt;$?&lt;/code&gt; on an &lt;code&gt;echo&lt;/code&gt; line that contains &lt;code&gt;$()&lt;/code&gt; will reliably be zero. The difference between the two forms can't be distinguished mechanically with &lt;code&gt;grep&lt;/code&gt; — after a regex hit, one step of visual inspection is required.&lt;/p&gt;

&lt;p&gt;Detection query (identical to the one shown in the first half):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'exit \$?'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.sh'&lt;/span&gt; ~/dev ~/.claude/scripts | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'\$('&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Among the lines this query returns, exclude the &lt;code&gt;x="$(...)"&lt;/code&gt; form. Everything else needs fixing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 2: Use &lt;code&gt;PIPESTATUS&lt;/code&gt; to Capture Every Command's Code
&lt;/h3&gt;

&lt;p&gt;Use this when you can't rewrite the pipe. &lt;code&gt;PIPESTATUS&lt;/code&gt; is a bash-specific array that holds the exit codes of each command in the preceding pipeline, in left-to-right order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_PHASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;PIPE_STATUS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PIPESTATUS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# PIPE_STATUS[0] = printf の終了コード&lt;/span&gt;
&lt;span class="c"&gt;# PIPE_STATUS[1] = $SCRIPT_PATH の終了コード&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PIPE_STATUS&lt;/span&gt;&lt;span class="p"&gt;[1]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PIPE_STATUS&lt;/span&gt;&lt;span class="p"&gt;[1]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt; has &lt;code&gt;set -euo pipefail&lt;/code&gt; at the top, there's currently no need to add this — with &lt;code&gt;pipefail&lt;/code&gt;, a failure on the right-hand side takes the script itself down. You'd make &lt;code&gt;PIPESTATUS&lt;/code&gt; explicit when "there's a reason you can't turn on &lt;code&gt;pipefail&lt;/code&gt;" or when "you want to record which of the commands failed in the log." If your design records channel-name-and-exit-code pairs in launchd job logs, writing &lt;code&gt;PIPESTATUS[1]&lt;/code&gt; directly into the log makes later tracing easier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3: Rewrite the JS Dispatcher to Call Directly
&lt;/h3&gt;

&lt;p&gt;The most fundamental fix. Replace &lt;code&gt;shell: true&lt;/code&gt; + &lt;code&gt;exec&lt;/code&gt; with &lt;code&gt;spawn&lt;/code&gt; so no shell wrapper is involved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Before：shell: true のため $() 内の pipefail が死ぬ&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;execSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;child_process&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;execSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`echo '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' | &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hookScript&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ After：spawn で直接呼ぶ。終了コードは close イベントの code で取れる&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;spawn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;child_process&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hookScript&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hookScript&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inherit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`hook exited with &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Used without &lt;code&gt;shell: true&lt;/code&gt;, &lt;code&gt;spawn&lt;/code&gt; launches the command directly via &lt;code&gt;execve(2)&lt;/code&gt;. Since no shell wrapper is interposed, the &lt;code&gt;set -euo pipefail&lt;/code&gt; inside &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt; stays in effect. The &lt;code&gt;close&lt;/code&gt; event's &lt;code&gt;code&lt;/code&gt; argument is either &lt;code&gt;null&lt;/code&gt; (terminated by signal) or an integer (normal termination). When it's &lt;code&gt;null&lt;/code&gt;, check the &lt;code&gt;signal&lt;/code&gt; argument to see what happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify the Guard Lands With a Minimal &lt;code&gt;exit 1&lt;/code&gt; Repro
&lt;/h3&gt;

&lt;p&gt;You can't stop at "I fixed it." Verify with a minimal reproduction case that Claude's behavior actually gets blocked. Three steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Create a dummy hook that always returns &lt;code&gt;exit 1&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# /tmp/test-hook.sh&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /tmp/test-hook.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Register that hook as a Stop hook in settings.json.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/tmp/test-hook.sh"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Open Claude Code and have it answer something.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Stop hook runs at the moment Claude tries to finish its response. If &lt;code&gt;/tmp/test-hook.sh&lt;/code&gt; returns &lt;code&gt;exit 1&lt;/code&gt;, Claude emits a message saying the hook blocked it and loops again. If you see that, the wiring is alive.&lt;/p&gt;

&lt;p&gt;If you're calling through a JS dispatcher, passing this test is a separate question from whether the actual hook script goes through &lt;code&gt;spawn&lt;/code&gt;. To check, look at what Claude is launching with &lt;code&gt;ps aux | grep hook&lt;/code&gt;, or trace &lt;code&gt;execve&lt;/code&gt;-family system calls with &lt;code&gt;strace&lt;/code&gt; / &lt;code&gt;dtruss&lt;/code&gt;. On macOS you can check the system calls of child processes a process launches with &lt;code&gt;dtruss -p &amp;lt;PID&amp;gt;&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;p&gt;From here I'll write in the order I actually got stuck. Three cases, each as a set of three: symptom, cause, fix. All of them are stories of getting stuck in the state of "I wrote the hook, it's running, the logs are there."&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 1: 28 Hours of All-Green Logs, Zero Actual Replies
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; On 2026-08-29, I started digging from a single remark: "Aren't DM replies on X (formerly Twitter) slow lately?" Checking the launchd job list, everything was &lt;code&gt;exit 0&lt;/code&gt;. The log file had&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-08-29 06:15:32] inbox-agent done (exit 0)
[2026-08-29 08:15:17] inbox-agent done (exit 0)
[2026-08-29 10:15:41] inbox-agent done (exit 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;lined up neatly. Green. But when I checked the actual reply count through X's own interface, it was zero for the entire day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; &lt;code&gt;inbox-agent/run-medium.sh&lt;/code&gt; contained the following line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$LANE&lt;/span&gt;&lt;span class="s2"&gt; done (exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This. &lt;code&gt;node "$SCRIPT"&lt;/code&gt; was dying with an error. &lt;code&gt;$?&lt;/code&gt; holds &lt;code&gt;node&lt;/code&gt;'s exit code — right up until &lt;strong&gt;just before&lt;/strong&gt; this line is evaluated. When expanding &lt;code&gt;echo&lt;/code&gt;'s arguments, bash evaluates left to right. &lt;code&gt;$(date '+%F %T')&lt;/code&gt; spawns a subshell, returns a datetime string, and succeeds. At that instant, &lt;code&gt;$?&lt;/code&gt; is rewritten to &lt;code&gt;0&lt;/code&gt;. Then &lt;code&gt;$?&lt;/code&gt; is read. So no matter what happens, the log says &lt;code&gt;(exit 0)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;STATUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$LANE&lt;/span&gt;&lt;span class="s2"&gt; done (exit &lt;/span&gt;&lt;span class="nv"&gt;$STATUS&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just add one line to save it into &lt;code&gt;STATUS&lt;/code&gt;. After this fix, reproducing the same Chrome launch failure printed &lt;code&gt;(exit 3)&lt;/code&gt;. launchd received &lt;code&gt;exit 3&lt;/code&gt;, and a notification arrived in the Discord alert channel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What was frightening.&lt;/strong&gt; All logs from the period that line was in place are invalid. "It was green last week and last month too" is not evidence. It only means "last week and last month, I kept running code that only prints green."&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 2: The note Paid-Bonus ZIP Attachment Failure Went Unnoticed for 3 Weeks
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; After fixing case 1, I thought "if I've come this far, other scripts might be stepping in the same trap," and scanned 328 shell scripts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'exit \$?'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.sh'&lt;/span&gt; ~/dev ~/.claude/scripts | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'\$('&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three hits. &lt;code&gt;inbox-agent/run-medium.sh&lt;/code&gt; (the main culprit this time), &lt;code&gt;~/.claude/scripts/dotfiles-snapshot.sh&lt;/code&gt; (exit code on commit failure), and &lt;code&gt;note-autolike/scripts/retry-attach-kit.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Checking &lt;code&gt;retry-attach-kit.sh&lt;/code&gt;, it was a retry script for attaching the note paid-bonus ZIP to a post. When the attachment API returned 503, the script was written to return &lt;code&gt;exit 1&lt;/code&gt;. But because the same trap was in its &lt;code&gt;echo&lt;/code&gt; line, launchd recorded it as &lt;code&gt;exit 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How long had people who bought the paid note been unable to download the bonus ZIP? Going back through the logs, everything is green, so you can't tell. The only option was to cross-reference the actual date the attachment script last succeeded against the attachment file's update date in note's admin screen. The result: it had been putting out green logs while in a failed state for at least 3 weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause and fix.&lt;/strong&gt; Same pattern.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ❌ Before&lt;/span&gt;
python3 attach_kit.py &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POST_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZIP_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] attach done (exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;

&lt;span class="c"&gt;# ✅ After&lt;/span&gt;
python3 attach_kit.py &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POST_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZIP_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;STATUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] attach done (exit &lt;/span&gt;&lt;span class="nv"&gt;$STATUS&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the fix, I made it raise an alert to Discord when &lt;code&gt;STATUS&lt;/code&gt; ends at &lt;code&gt;1&lt;/code&gt; or higher. Failures that affect people who bought a paid note aren't something I can afford not to notice until the next morning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I took away.&lt;/strong&gt; If I hadn't immediately generalized the moment I found one instance, this job would still be failing silently today. The same code pattern always exists in multiple places. The correct procedure is not to stop at "one fix" but to scan the whole repository right then.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 3: YouTrust Was Failing in "Complete Silence" — A Library's &lt;code&gt;process.exit()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; On that same August 29, I confirmed that on the X side "alerts appeared in Discord from the third run onward." But on the YouTrust side, not a single notification reached Discord. Six launches, six failures, and zero alerts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; Line 31 of &lt;code&gt;outreach-multi/src/yt/api.mjs&lt;/code&gt; had &lt;code&gt;process.exit(3)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// outreach-multi/src/yt/api.mjs（修正前）&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;YT_API_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[yt/api] fetch failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// ← ここ&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;process.exit()&lt;/code&gt; is not an exception. Unlike &lt;code&gt;throw&lt;/code&gt;, it doesn't walk back up the call stack. The caller's &lt;code&gt;try/catch&lt;/code&gt; is never executed once.&lt;/p&gt;

&lt;p&gt;The caller's code looked like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// エントリポイント（修正前）&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ここが走ると思っていた&lt;/span&gt;
  &lt;span class="nx"&gt;read_failures&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;read_failures&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;ALERT_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;discord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`YouTrust failure: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The instant &lt;code&gt;process.exit(3)&lt;/code&gt; is called, the node process terminates immediately. The &lt;code&gt;catch&lt;/code&gt; block is never reached. &lt;code&gt;read_failures&lt;/code&gt; is never incremented. Nothing goes to Discord. launchd sees "the process terminated," but because that exit code went down a path where it was recorded nowhere, everything went silent.&lt;/p&gt;

&lt;p&gt;Meanwhile, the reason the X side could fire alerts from the third run onward is that the X module was written with &lt;code&gt;throw new Error(...)&lt;/code&gt;. The &lt;code&gt;catch&lt;/code&gt; block ran, &lt;code&gt;read_failures&lt;/code&gt; incremented, crossed the threshold, and reached Discord. Same root cause of "Chrome launch failure," same architectural philosophy, but with the wiring differing by one line, one rang and the other was completely silent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ After：ライブラリ層は throw する。process.exit() しない&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;YT_API_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`yt/api fetch failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was an option to branch on an environment variable &lt;code&gt;YT_API_THROW=1&lt;/code&gt;, but since there's no reason to write &lt;code&gt;process.exit&lt;/code&gt; in the library layer in the first place, I simply rewrote it to &lt;code&gt;throw&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The general rule I kept.&lt;/strong&gt; The library layer must not hold the authority to terminate. Only the entry point may end the process. The instant a lower layer calls &lt;code&gt;process.exit&lt;/code&gt; / &lt;code&gt;sys.exit&lt;/code&gt; / &lt;code&gt;os.Exit&lt;/code&gt;, all the observation, cleanup, and notification the upper layer prepared gets bypassed. Frameworks and external libraries sometimes do this too, so I've made it a habit to check &lt;code&gt;grep -r 'process\.exit' node_modules/&amp;lt;packagename&amp;gt;/&lt;/code&gt; when adding a new dependency.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Common Pattern Across All 3
&lt;/h3&gt;

&lt;p&gt;The symptom in every case was "the logs are green, the actual result is zero or failed." But the layer of the cause differs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Layer of the cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inbox-agent&lt;/td&gt;
&lt;td&gt;Zero DM replies, all-green logs&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;echo&lt;/code&gt; line in a shell script&lt;/td&gt;
&lt;td&gt;Save first with &lt;code&gt;STATUS=$?&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;note-autolike&lt;/td&gt;
&lt;td&gt;ZIP attachment failure unnoticed for 3 weeks&lt;/td&gt;
&lt;td&gt;Same as above&lt;/td&gt;
&lt;td&gt;Same as above + added a Discord alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTrust&lt;/td&gt;
&lt;td&gt;6 silent failures out of 6&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;process.exit&lt;/code&gt; in a Node.js library layer&lt;/td&gt;
&lt;td&gt;Rewritten to &lt;code&gt;throw&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The shell trap and the Node.js trap look different, but the root is the same: "written without providing a path for the failure signal to reach the caller." Whether a hook's guard actually lands can only be determined by measuring "does the caller stop when it actually returns &lt;code&gt;exit 1&lt;/code&gt;?" — not by green logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;The first and middle sections traced 3 incidents, but there are more places where you can step in the same trap. Here I cover the pitfalls I found scanning 328 scripts, plus the patterns I keep getting caught by in Claude Code hook wiring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(1) Any line where &lt;code&gt;echo "... $?"&lt;/code&gt; has &lt;code&gt;$(...)&lt;/code&gt; mixed in is out&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No matter how short the line, if you write &lt;code&gt;echo "[$(date)] done (exit $?)"&lt;/code&gt;, that line is structurally incapable of printing anything but &lt;code&gt;(exit 0)&lt;/code&gt;. The instant &lt;code&gt;date&lt;/code&gt;'s subshell succeeds, &lt;code&gt;$?&lt;/code&gt; is rewritten. The desire to "write it in one line" is right, but this particular combination simply cannot work. Your only options are to take &lt;code&gt;STATUS=$?&lt;/code&gt; first, or to drive &lt;code&gt;$?&lt;/code&gt; out of the &lt;code&gt;echo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(2) &lt;code&gt;set -euo pipefail&lt;/code&gt; only affects the current shell context&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even with &lt;code&gt;set -euo pipefail&lt;/code&gt; at the top of &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt;, if JS passes &lt;code&gt;shell: true&lt;/code&gt; to &lt;code&gt;child_process.exec()&lt;/code&gt; to call this script, a shell wrapper &lt;code&gt;/bin/sh -c "..."&lt;/code&gt; is interposed. That &lt;code&gt;/bin/sh&lt;/code&gt; session does not inherit &lt;code&gt;pipefail&lt;/code&gt;. Calling the script alone with &lt;code&gt;bash run-with-flags-shell.sh&lt;/code&gt; works correctly, but calling it from JS doesn't — "the script was written correctly" is the truth, and "the way JS calls it breaks it" is the cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(3) The &lt;code&gt;|| echo yes&lt;/code&gt; fallback swallows checker failures&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Line 19 of &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt; reads like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;node &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PLUGIN_ROOT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/scripts/hooks/check-hook-enabled.js"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROFILES_CSV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo yes&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;check-hook-enabled.js&lt;/code&gt; breaks and dies, &lt;code&gt;|| echo yes&lt;/code&gt; fires and &lt;code&gt;ENABLED&lt;/code&gt; becomes &lt;code&gt;"yes"&lt;/code&gt;. It's an intentional fail-safe design, but the state of "the hook-check script itself is broken, yet the hook proceeds as enabled" happens silently. Whether hooks are being judged correctly is predicated on this checker working properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(4) A missing script also passes through with &lt;code&gt;exit 0&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Look at lines 25–30 of the same script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SCRIPT_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PLUGIN_ROOT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REL_SCRIPT_PATH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[Hook] Script not found for &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOOK_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCRIPT_PATH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the hook script's path is wrong and the file doesn't exist, it prints an error to &lt;code&gt;stderr&lt;/code&gt; and ends with &lt;code&gt;exit 0&lt;/code&gt;. Claude doesn't stop. When you've misconfigured something, the logs alone can't distinguish "the hook isn't wired in" from "the hook judged correctly and let it pass."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(5) The instant &lt;code&gt;process.exit()&lt;/code&gt; is called in a library layer, every observation path is wiped out&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Line 31 of &lt;code&gt;outreach-multi/src/yt/api.mjs&lt;/code&gt; was this pattern. The caller's &lt;code&gt;try/catch&lt;/code&gt; block never executed once, the &lt;code&gt;read_failures&lt;/code&gt; counter was never incremented, and not a single notification reached Discord. In contrast, the X side was written with &lt;code&gt;throw new Error(...)&lt;/code&gt;, so it rang. Two modules written with the same architectural philosophy split into "rings / completely silent" over a one-line difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(6) &lt;code&gt;PIPESTATUS&lt;/code&gt; is bash-specific — the name differs in zsh&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PIPESTATUS&lt;/code&gt; is a bash-only array. In zsh, &lt;code&gt;pipestatus&lt;/code&gt; (lowercase) is available as the equivalent array, but with a &lt;code&gt;#!/bin/sh&lt;/code&gt; shebang there are shells where it's unusable. &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt;'s shebang is &lt;code&gt;#!/usr/bin/env bash&lt;/code&gt;, so no problem there, but if the hook script side is written in &lt;code&gt;sh&lt;/code&gt;, referencing &lt;code&gt;PIPESTATUS&lt;/code&gt; will come back empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(7) The exit code launchd receives and the script's exit code are different things&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The exit code shown in a launchd job log is that of the job's outermost process. If there's a multi-layer call chain and the outermost layer ends with &lt;code&gt;exit 0&lt;/code&gt;, launchd records &lt;code&gt;exit 0&lt;/code&gt; no matter how many &lt;code&gt;exit 1&lt;/code&gt;s happened inside. "launchd shows &lt;code&gt;exit 0&lt;/code&gt; for everything" is not equal to "everything down to the end of the hook chain was &lt;code&gt;exit 0&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(8) The trap of assuming &lt;code&gt;execSync&lt;/code&gt;'s default behavior means "an exception means failure"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;execSync&lt;/code&gt; throws on a non-zero exit code, but when you're using a pipe with &lt;code&gt;shell: true&lt;/code&gt;, the shell wrapper can return exit code &lt;code&gt;0&lt;/code&gt;. No exception occurred ≠ success; no exception occurred = the shell returned &lt;code&gt;0&lt;/code&gt;. The inner script's failure isn't transparent through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(9) A new npm package may call &lt;code&gt;process.exit()&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When an external library calls &lt;code&gt;process.exit()&lt;/code&gt; internally, the entry point's &lt;code&gt;try/catch&lt;/code&gt; gets bypassed. Without the habit of checking when you add a dependency, it surfaces as the symptom "alerts that used to ring stopped ringing after the addition."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(10) "The logs are printing" is not "the guard is landing"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the day of the incident, the reason no notification appeared in Discord's &lt;code&gt;#01_alerts&lt;/code&gt; was not "no failure occurred." It was "the failure code never reached the path that rings the alert." Logs printing = the script launched; the guard landing = the exit code propagated to the caller. Those are two different facts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(11) &lt;code&gt;grep&lt;/code&gt; can mechanically knock these out, but the &lt;code&gt;|| rc=$?&lt;/code&gt; form needs human eyes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The detection query &lt;code&gt;grep -rn 'exit \$?' --include='*.sh' ~/dev ~/.claude/scripts | grep '\$('&lt;/code&gt; is effective, but the form &lt;code&gt;x="$(cmd)" || rc=$?&lt;/code&gt; is correct usage, so it must be excluded from the hits. Since an assignment statement's exit status becomes that of the command substitution, reading &lt;code&gt;$?&lt;/code&gt; on the line after &lt;code&gt;x="$(node ...)"&lt;/code&gt; is correct. Only the cases mixing &lt;code&gt;$(...)&lt;/code&gt; and &lt;code&gt;$?&lt;/code&gt; inside an &lt;code&gt;echo&lt;/code&gt; line are the problem. You can't knock everything out automatically — one step of visually inspecting the hits remains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(12) Even after switching to &lt;code&gt;throw&lt;/code&gt; with &lt;code&gt;YT_API_THROW&lt;/code&gt;, it's unproven until a failure actually occurs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The YouTrust fix I wrote about in the middle section — rewriting &lt;code&gt;process.exit(3)&lt;/code&gt; to &lt;code&gt;throw new Error(...)&lt;/code&gt; — has been confirmed statically with &lt;code&gt;node --check&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt;, but whether &lt;code&gt;read_failures&lt;/code&gt; actually increments and shows up in Discord when Chrome really fails to launch will only be proven the next time a launch failure occurs. "The code is correct" and "the path went through in production" are two different facts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;Here are the lessons from 3 incidents and a 328-script scan, organized as guidance for the next time I build the same kind of environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Drill the rule of taking &lt;code&gt;STATUS=$?&lt;/code&gt; first into your body&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you want to reference &lt;code&gt;$?&lt;/code&gt; immediately after a command, always save it on one line with &lt;code&gt;STATUS=$?&lt;/code&gt;. Physically forbid &lt;code&gt;$(...)&lt;/code&gt; and &lt;code&gt;$?&lt;/code&gt; from coexisting inside an &lt;code&gt;echo&lt;/code&gt;. Make "writing a shell script = avoiding this combination" a reflex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Write &lt;code&gt;set -euo pipefail&lt;/code&gt; at the top of every shell script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When creating a new script, the first two lines are always&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When touching an existing script, check the top first when you open the file. Read scripts that lack this as "ready and prepared to fail silently."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use &lt;code&gt;spawn&lt;/code&gt; when calling external scripts from JS&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Calls that pass &lt;code&gt;shell: true&lt;/code&gt; to &lt;code&gt;child_process.exec()&lt;/code&gt; risk the shell wrapper swallowing the exit code. Using &lt;code&gt;child_process.spawn()&lt;/code&gt; without &lt;code&gt;shell: true&lt;/code&gt; launches directly via &lt;code&gt;execve(2)&lt;/code&gt;. The &lt;code&gt;close&lt;/code&gt; event's &lt;code&gt;code&lt;/code&gt; argument is the exit code. When it's &lt;code&gt;null&lt;/code&gt;, check the &lt;code&gt;signal&lt;/code&gt; argument for signal termination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Don't write &lt;code&gt;process.exit()&lt;/code&gt; / &lt;code&gt;sys.exit()&lt;/code&gt; / &lt;code&gt;os.Exit()&lt;/code&gt; in a library layer&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only the entry point may terminate the process. The instant a library calls &lt;code&gt;process.exit&lt;/code&gt;, the &lt;code&gt;catch&lt;/code&gt; blocks, notifications, counter increments, and cleanup the entry point prepared are all bypassed. The iron rule is: the library layer &lt;code&gt;throw&lt;/code&gt;s exceptions and passes them upward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Check for &lt;code&gt;process.exit&lt;/code&gt; when adding a new dependency&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'process\.exit'&lt;/span&gt; node_modules/&amp;lt;packagename&amp;gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it a habit to run this query before adding. If the library calls &lt;code&gt;process.exit&lt;/code&gt; internally, it bypasses the entry point's &lt;code&gt;catch&lt;/code&gt; block. Checking at addition time is cheaper than finding out later via the symptom "alerts stopped ringing."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Measure whether "the guard lands" with a minimal &lt;code&gt;exit 1&lt;/code&gt; dummy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once you've written a hook, always measure before trusting the wiring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# /tmp/test-hook.sh&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register this dummy as a Stop hook in &lt;code&gt;settings.json&lt;/code&gt; and have Claude answer something. If a message saying the hook blocked it appears, the wiring is alive. If it doesn't, the exit code is dead somewhere in the call chain. Take this one step before trusting green logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Generalize the instant you find one&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same code pattern always exists in multiple places. Because I immediately scanned everything the moment I found it in &lt;code&gt;inbox-agent/run-medium.sh&lt;/code&gt;, I discovered that the paid-note bonus ZIP attachment failure in &lt;code&gt;note-autolike/scripts/retry-attach-kit.sh&lt;/code&gt; had been going on for over 3 weeks. Had I stopped at one fix, buyers might still not be receiving their bonus.&lt;/p&gt;

&lt;p&gt;The detection query can be used as-is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'exit \$?'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.sh'&lt;/span&gt; ~/dev ~/.claude/scripts | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'\$('&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visually inspect the hits and exclude the &lt;code&gt;x="$(cmd)" || rc=$?&lt;/code&gt; form; everything else needs fixing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Don't treat "no alert fired" as evidence of health&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the X side, notifications came into Discord's &lt;code&gt;#01_alerts&lt;/code&gt; from the third run onward. On the YouTrust side, not one arrived across 6 launch failures. "Discord was quiet" is not "there was no problem" — it includes the possibility that "the failure code never reached the path that rings the alert." When you add an alert, run the actual alert path once in something close to production before saying "monitoring is working."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Know the script-not-found path in &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lines 27–30 of the real code print an error to &lt;code&gt;stderr&lt;/code&gt; and end with &lt;code&gt;exit 0&lt;/code&gt; when the script file doesn't exist. If you get the hook's configured path wrong, the error goes to stderr but Claude doesn't stop. When a hook feels like it "isn't working," check the &lt;code&gt;stderr&lt;/code&gt; log first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Know the &lt;code&gt;|| echo yes&lt;/code&gt; fallback in &lt;code&gt;check-hook-enabled.js&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fallback on line 19 of &lt;code&gt;run-with-flags-shell.sh&lt;/code&gt; is designed to proceed with the hook enabled even when the hook-check script is broken. That's an intentional fail-safe, but the state of "the checker itself is broken" proceeds silently. If a hook isn't being disabled as expected, check whether this checker is working properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. If you use &lt;code&gt;PIPESTATUS&lt;/code&gt;, align the shebang and the environment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PIPESTATUS&lt;/code&gt; is bash-specific. There are shells where it can't be used in a &lt;code&gt;#!/bin/sh&lt;/code&gt; script. If you want to record each pipeline command's exit code individually, set the shebang to &lt;code&gt;#!/usr/bin/env bash&lt;/code&gt; and make &lt;code&gt;PIPESTATUS&lt;/code&gt; explicit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_PHASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;PIPE_STATUS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PIPESTATUS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# PIPE_STATUS[1] が $SCRIPT_PATH の終了コード&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;pipefail&lt;/code&gt;, &lt;code&gt;$SCRIPT_PATH&lt;/code&gt;'s failure automatically propagates into the script's exit code, but if your design records "which command failed" in a log, make &lt;code&gt;PIPESTATUS&lt;/code&gt; explicit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Don't treat launchd's all-&lt;code&gt;exit 0&lt;/code&gt; as primary evidence&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The exit code launchd records is that of the outermost process. In an environment with multi-layer wrappers, "launchd says &lt;code&gt;exit 0&lt;/code&gt;" ≠ "everything inside was &lt;code&gt;exit 0&lt;/code&gt;" either. Confirm actual success or failure with measured values from what the job produced — reply counts, presence of attached files, API responses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. Distinguish "the code is written correctly" from "the path went through in production"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even if the fixed code is syntactically correct, the error-case path actually runs "the next time a failure happens in production." Reproduce the failure in a test environment, or deliberately cause a failure and confirm the alert arrives, before saying "fix complete." "The code is correct" is not "verified working."&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The structure of the problem in one line: &lt;strong&gt;"The failures were happening. The exit codes just never arrived at the caller."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The single line &lt;code&gt;echo "[$(date)] $LANE done (exit $?)"&lt;/code&gt; in &lt;code&gt;inbox-agent/run-medium.sh&lt;/code&gt; had no syntax errors as a shell script, printed logs, and looked normal. But the &lt;code&gt;(exit 0)&lt;/code&gt; that line was printing wasn't the result of the preceding &lt;code&gt;node&lt;/code&gt; — it was the result of &lt;code&gt;$(date)&lt;/code&gt; succeeding. The cause of 28 hours of zero outreach DM replies was that one line being "code that only prints green."&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;process.exit(3)&lt;/code&gt; at &lt;code&gt;outreach-multi/src/yt/api.mjs:31&lt;/code&gt; is the same. It detected the error. But because it terminated the process at the place of detection, neither the counter nor the notification the entry point had prepared ever ran. From the same root cause of a Chrome launch failure, the X side rang Discord from the third run onward, and the YouTrust side was completely silent across all 6. The difference is a single line of wiring.&lt;/p&gt;

&lt;p&gt;When measuring the "health" of an automation environment, the number of green log lines is not trustworthy. The only trustworthy thing is the measurement: "when it actually returns &lt;code&gt;exit 1&lt;/code&gt;, does that signal reach the calling Claude Code and cause a block?" In an environment where 171 launchd jobs and multiple Claude Code sessions run simultaneously, a hook that hasn't had this measurement done is the same as a cliff with no guardrail.&lt;/p&gt;

&lt;p&gt;It took several hours to scan 328 scripts, find 3 instances of the same trap, and commit fixes to 3 repositories. But the fact that "buyers of the paid note hadn't been able to download the bonus ZIP for over 3 weeks" only came to light because I scanned. Had I stopped at one, the green would still be lining up today.&lt;/p&gt;

&lt;p&gt;An exit code doesn't arrive unless every layer of the propagation path is written correctly. The shell &lt;code&gt;$?&lt;/code&gt; problem, the JS &lt;code&gt;shell: true&lt;/code&gt; problem, the library-layer &lt;code&gt;process.exit&lt;/code&gt; problem — all of them share the same root. Once you write a hook, verify with a minimal &lt;code&gt;exit 1&lt;/code&gt; dummy that it actually lands. That alone crushes most silent bugs like this one in advance.&lt;/p&gt;




&lt;p&gt;I've written up the full picture of the system, the breakdown of ¥1.2M/month, and the 30-day procedure in a paid note.&lt;br&gt;
📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>bash</category>
      <category>node</category>
      <category>debugging</category>
    </item>
    <item>
      <title>8 Runs a Day Became 3: Building a launchd plist Integrity Guard in 73 Lines</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Mon, 31 Aug 2026 00:00:07 +0000</pubDate>
      <link>https://dev.to/bokuwalily/8-runs-a-day-became-3-building-a-launchd-plist-integrity-guard-in-73-lines-27h4</link>
      <guid>https://dev.to/bokuwalily/8-runs-a-day-became-3-building-a-launchd-plist-integrity-guard-in-73-lines-27h4</guid>
      <description>&lt;p&gt;At 12:04 on August 23, 2026, something quietly rewrote my outreach DM schedule down to three runs a day — 2:31 AM, 10:31 AM, and 6:31 PM. For the several days it took me to notice, that cost me dozens of missed opportunities every single day. I still don't know what did it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this mechanism works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The problem: you can't tell when automation has stopped
&lt;/h3&gt;

&lt;p&gt;When you strip away the details, making money as a solo developer comes down to one equation: automation uptime = revenue. I was a college student making 100k yen a month, then 600k a month juggling multiple gigs, then back to zero when I was laid off for company reasons — and the reason I could build a Claude Code autonomous environment in six months and get to 1.2M yen in monthly revenue is that I had an outreach DM lane running while I slept.&lt;/p&gt;

&lt;p&gt;Automated outreach DMs are made up of four launchd jobs. Instagram, Threads, follower prospecting, and YouTube each have their own independent plist and Python script. These control their start times via &lt;code&gt;StartCalendarInterval&lt;/code&gt;. For example, &lt;code&gt;com.lily.outreach-ig&lt;/code&gt; runs eight times at 8:20, 10:20, 12:20, 14:20, 16:20, 18:20, 20:20, and 22:20, and &lt;code&gt;com.lily.outreach-th&lt;/code&gt; runs seven times every two hours from 9:38 to 21:38 — sends happen during daytime hours.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;strong&gt;a launchd plist is just an XML file, and anyone (or anything) can rewrite it&lt;/strong&gt;. macOS does not notify the user when a plist changes. Even when &lt;code&gt;launchctl list&lt;/code&gt; shows a job as "running," the schedule itself may be operating on post-rewrite values. That is exactly what happened on August 23: the job was alive. It was just running on a completely different setting — "three times a day, in the middle of the night."&lt;/p&gt;

&lt;p&gt;What tipped me off was looking at the DM send-count log manually one evening. The noon and 4 PM slots kept showing zero, so I dug in and found that &lt;code&gt;StartCalendarInterval&lt;/code&gt; inside &lt;code&gt;com.lily.outreach-ig&lt;/code&gt;'s plist had been reduced to just three entries: &lt;code&gt;2:31 / 10:31 / 18:31&lt;/code&gt;. At the same time, &lt;code&gt;com.lily.outreach-th&lt;/code&gt; had been cut from seven runs to three. The mtime (last modified time) was 2026-08-23 12:04.&lt;/p&gt;

&lt;p&gt;What I learned from this is that &lt;strong&gt;"running" and "running correctly" are two different things&lt;/strong&gt;. What you need to monitor is not whether the process is alive, but the configuration values inside the plist themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  The value of designing for "record," not just "restore"
&lt;/h3&gt;

&lt;p&gt;If all you want is a simple integrity check, you could implement it with cron or Watch Paths. But what I insisted on this time was a design that &lt;strong&gt;leaves a forensic log at the same moment it restores&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There are two reasons. The first is reproducibility. If the same rewrite happens repeatedly, a snapshot of which processes were running just before it gives me a way to narrow down the suspects. The second is the peace of mind of being able to trace &lt;em&gt;why&lt;/em&gt; it changed after the fact. An automation environment is complex, and I can't rule out that I rewrote it myself by mistake from some script. With logs, I can separate "my own mistake" from "outside interference."&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;outreach-schedule-guard.sh&lt;/code&gt; detects something, it does four things: record the rewrite along with the mtime, capture a list of recently running processes, copy the plist as a timestamped backup, and restore the correct values and make launchd reload them. Of these, the forensic part takes up close to half of the script's 50 lines. I could have taken the "it just needs to work" route and only done the restore, but I didn't want to give up on finding the cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  The structural weak point in a launchd plist
&lt;/h3&gt;

&lt;p&gt;Once you understand the structure of &lt;code&gt;StartCalendarInterval&lt;/code&gt;, you see why reading it with Python's plistlib is the best approach.&lt;/p&gt;

&lt;p&gt;launchd plists are stored in XML format (or in binary format via &lt;code&gt;plutil -convert binary1&lt;/code&gt;). Taking &lt;code&gt;com.shun.self-repair.plist&lt;/code&gt; as an example, it has an array structure like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;9&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;20&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;13&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;30&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;19&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;30&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point is that &lt;code&gt;Hour&lt;/code&gt; and &lt;code&gt;Minute&lt;/code&gt; are stored as integer types. Parsing XML with grep or awk in a shell script gives you no type guarantees, and it doesn't work at all against binary plists. If you read the plist with &lt;code&gt;python3 -c "import plistlib"&lt;/code&gt;, you can handle both text and binary formats through the same API, and you can reliably extract &lt;code&gt;Hour&lt;/code&gt; as an int.&lt;/p&gt;

&lt;p&gt;Rewrite detection is just a string comparison between this read value and the expected value from the SPECS array. The expected value is generated in the form &lt;code&gt;"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"&lt;/code&gt;, and if it doesn't match the value pulled out of the actual plist, an alert fires.&lt;/p&gt;




&lt;h2&gt;
  
  
  The overall flow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Architecture overview
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;定期実行（launchd job）
    │
    ▼
outreach-schedule-guard.sh
    │
    ├─ SPECS配列から期待スケジュールを生成
    │    com.lily.outreach-ig   → 8,10,12,14,16,18,20,22 時 :20
    │    com.lily.outreach-th   → 9,11,13,15,17,19,21    時 :38
    │    com.lily.followers-outreach → 8,10,12,14,16,18,20,22 時 :35
    │    com.lily.outreach-yt   → 10,12,14,16,18,20      時 :52
    │
    ├─ python3 plistlib で各 plist の StartCalendarInterval を実読み取り
    │
    ├─ [一致] → 処理なし・終了
    │
    └─ [不一致] ─────────────────────────────────────────┐
                                                          │
         ① forensicログ出力                              │
            - plist の mtime を記録                       │
            - 現在値 vs 期待値を並べて記録               │
            - ps で python/node/bash/launchctl/plutil      │
              の直近プロセスをキャプチャ（最大25件）     │
                                                          │
         ② バックアップ作成                              │
            $label.plist.bak-guard-YYYYmmdd-HHMMSS       │
                                                          │
         ③ python3 plistlib で正しい値に書き戻し         │
            d['StartCalendarInterval'] = [                │
              {'Hour': int(h), 'Minute': minute}          │
              for h in hours.split(',')                   │
            ]                                             │
                                                          │
         ④ plutil -lint で整合性検証                     │
            OK → launchctl bootout → sleep 1              │
                → launchctl bootstrap → 復元ログ         │
            NG → "🔴 復元後のplistが壊れている"ログ      │
                 → 手動対応に委ねる                       │
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Core logic: the plist reading part
&lt;/h3&gt;

&lt;p&gt;The script processes the four jobs in order with a shell &lt;code&gt;for&lt;/code&gt; loop. The first thing each iteration does is read the plist through a python3 heredoc and print a CSV of &lt;code&gt;Hour:Minute&lt;/code&gt; to standard output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;actual&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import plistlib, sys
try:
    with open(sys.argv[1],'rb') as f: d = plistlib.load(f)
    rows = d.get('StartCalendarInterval') or []
    if isinstance(rows, dict): rows = [rows]
    print(','.join(f"{r.get('Hour')}:{r.get('Minute')}" for r in rows))
except Exception as e:
    print('ERR')
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason for opening in binary mode with &lt;code&gt;open(sys.argv[1], 'rb')&lt;/code&gt; is that &lt;code&gt;plistlib.load()&lt;/code&gt; auto-detects whether the plist is XML or binary. &lt;code&gt;rows = d.get('StartCalendarInterval') or []&lt;/code&gt; guards against None when the entry doesn't exist. And the &lt;code&gt;isinstance(rows, dict)&lt;/code&gt; check is defensive handling so things don't break when &lt;code&gt;StartCalendarInterval&lt;/code&gt; is written as a single &lt;code&gt;&amp;lt;dict&amp;gt;&lt;/code&gt; rather than an array (the case where only one time is registered).&lt;/p&gt;

&lt;p&gt;The resulting &lt;code&gt;actual&lt;/code&gt; becomes a string like &lt;code&gt;"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core logic: generating and comparing expected values
&lt;/h3&gt;

&lt;p&gt;Each entry in the SPECS array is defined in a custom &lt;code&gt;"label:minute:hours"&lt;/code&gt; format.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SPECS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"com.lily.outreach-ig:20:8,10,12,14,16,18,20,22"&lt;/span&gt;
  &lt;span class="s2"&gt;"com.lily.outreach-th:38:9,11,13,15,17,19,21"&lt;/span&gt;
  &lt;span class="s2"&gt;"com.lily.followers-outreach:35:8,10,12,14,16,18,20,22"&lt;/span&gt;
  &lt;span class="s2"&gt;"com.lily.outreach-yt:52:10,12,14,16,18,20"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there, &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;minute&lt;/code&gt;, and &lt;code&gt;hours&lt;/code&gt; are split apart with shell parameter expansion, and &lt;code&gt;expected&lt;/code&gt; is assembled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;%%&lt;/span&gt;:&lt;span class="p"&gt;*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;rest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;#*&lt;/span&gt;:&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;%%&lt;/span&gt;:&lt;span class="p"&gt;*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;#*&lt;/span&gt;:&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, the hours are split into an array with &lt;code&gt;IFS=','&lt;/code&gt;, and a CSV in &lt;code&gt;"Hour:Minute"&lt;/code&gt; format is generated. For &lt;code&gt;outreach-ig&lt;/code&gt;, &lt;code&gt;expected&lt;/code&gt; becomes &lt;code&gt;"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The single line &lt;code&gt;[ "$actual" = "$expected" ] &amp;amp;&amp;amp; continue&lt;/code&gt; is the heart of the check. If they match, that job is skipped and we move to the next. The moment there's a mismatch, the forensic recording and restore sequence kicks off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core logic: the restore part
&lt;/h3&gt;

&lt;p&gt;Writing back is done with a python3 heredoc, same as reading.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$minute&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hours&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import plistlib, sys
path, minute, hours = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with open(path,'rb') as f: d = plistlib.load(f)
d['StartCalendarInterval'] = [{'Hour': int(h), 'Minute': minute} for h in hours.split(',')]
with open(path,'wb') as f: plistlib.dump(d, f)
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason minute is received via &lt;code&gt;sys.argv[2]&lt;/code&gt; and cast with &lt;code&gt;int()&lt;/code&gt; is that the value coming from the SPECS string is a str. &lt;code&gt;plistlib.dump(d, f)&lt;/code&gt; writes in XML format by default (&lt;code&gt;fmt=plistlib.FMT_XML&lt;/code&gt;), which has the side effect of converting an originally binary plist to XML. That said, launchd reads XML-format plists without any problem, so it doesn't matter in practice.&lt;/p&gt;

&lt;p&gt;After writing back, the XML structure is validated with &lt;code&gt;plutil -lint "$plist"&lt;/code&gt;. Feeding a broken plist to launchd causes unpredictable behavior, so this gate can't be skipped. Only when validation passes do we re-register with launchd in the following order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl bootout &lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="nb"&gt;sleep &lt;/span&gt;1
launchctl bootstrap &lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason for &lt;strong&gt;putting &lt;code&gt;sleep 1&lt;/code&gt; between &lt;code&gt;bootout&lt;/code&gt; and &lt;code&gt;bootstrap&lt;/code&gt;&lt;/strong&gt; is to give launchd a grace period to update its internal state. If you omit it, there are cases where &lt;code&gt;bootstrap&lt;/code&gt; returns an error. The failure of &lt;code&gt;bootout&lt;/code&gt; is ignored with &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; because even if it was already unloaded, there's no problem as long as &lt;code&gt;bootstrap&lt;/code&gt; goes through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementing the forensic log
&lt;/h3&gt;

&lt;p&gt;Three kinds of information are recorded the instant a rewrite is detected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;: 書き換え検知 mtime=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'%Sm'&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s1"&gt;'%F %T'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;:   現在   = &lt;/span&gt;&lt;span class="nv"&gt;$actual&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;:   あるべき= &lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
ps &lt;span class="nt"&gt;-Ao&lt;/span&gt; pid,lstart,comm | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +2 | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"python|node|bash|launchctl|plutil"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-25&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; l&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;:   ps&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$l&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stat -f '%Sm' -t '%F %T'&lt;/code&gt; is macOS-specific formatting that gets the plist's last modified time in &lt;code&gt;YYYY-MM-DD HH:MM:SS&lt;/code&gt; format. This is the only physical evidence of &lt;em&gt;when&lt;/em&gt; it was rewritten.&lt;/p&gt;

&lt;p&gt;The ps filter targets &lt;code&gt;python|node|bash|launchctl|plutil&lt;/code&gt; because the means of rewriting a plist are roughly limited to those commands. &lt;code&gt;tail -25&lt;/code&gt; caps it at 25 lines so the log doesn't balloon.&lt;/p&gt;

&lt;p&gt;In the actual incident at 12:04 on August 23, this forensic log would have let me narrow down the suspect processes. Ironically, the guard script itself was built &lt;em&gt;after&lt;/em&gt; the incident, so there's no ps snapshot from that time. The "I wish I'd built this earlier" regret is exactly what drove the design of this forensic recording mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;set -uo pipefail&lt;/code&gt; — why I dropped &lt;code&gt;-e&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The top of the script is &lt;code&gt;set -uo pipefail&lt;/code&gt;. There's no &lt;code&gt;-e&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I originally wrote it as &lt;code&gt;-euo pipefail&lt;/code&gt;, since "exit immediately on error" is supposed to be best practice. But then a bug showed up where the script died abruptly in the forensic ps output section. Here's the cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps &lt;span class="nt"&gt;-Ao&lt;/span&gt; pid,lstart,comm | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +2 | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; p rest2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rest2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"python|node|bash|launchctl|plutil"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-25&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; l&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;:   ps&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$l&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;grep -iE "python|node|bash|..."&lt;/code&gt; returns exit code 1 when there are zero matching lines. Under &lt;code&gt;set -e&lt;/code&gt;, a non-zero exit code means immediate exit — so the bug was that the script died in the perfectly normal state where not a single suspicious process was running. The correct behavior for the forensic part is "output nothing if there's nothing," but &lt;code&gt;-e&lt;/code&gt; turned that into "die if there's nothing."&lt;/p&gt;

&lt;p&gt;By dropping &lt;code&gt;-e&lt;/code&gt; and keeping only &lt;code&gt;pipefail&lt;/code&gt;, I can still detect unintended silent failures (errors partway through a pipe) while letting an empty grep continue as-is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Explicit PATH and the log function
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"&lt;/span&gt;
&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude/logs/outreach-schedule-guard.log"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;launchd jobs do not inherit the user's shell environment. Even if PATH looks fine from Terminal, &lt;code&gt;/opt/homebrew/bin&lt;/code&gt; may not exist in the shell launchd starts. Since &lt;code&gt;python3&lt;/code&gt; is installed via Homebrew, without an explicit PATH the job silently dies with &lt;code&gt;python3: command not found&lt;/code&gt; right after launch. From this experience, I've made it a habit to always override PATH at the top of any shell script meant for launchd.&lt;/p&gt;

&lt;p&gt;The log function is &lt;code&gt;printf&lt;/code&gt;-based.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;log&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I originally wrote it as &lt;code&gt;echo "$(date '+%F %T') $*" &amp;gt;&amp;gt; "$LOG"&lt;/code&gt;. But &lt;code&gt;echo&lt;/code&gt;'s behavior changes depending on the shell implementation and whether the &lt;code&gt;-e&lt;/code&gt; option is present. When the string being appended to a log message contains &lt;code&gt;\n&lt;/code&gt;, the output differs depending on whether echo expands it. &lt;code&gt;printf&lt;/code&gt; cleanly separates the format string from the values, so line breaks and whitespace in log output are identical in every environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guarding against a missing plist
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;: plist が無い"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a label defined in the SPECS array doesn't actually have a plist, python3's &lt;code&gt;open()&lt;/code&gt; raises an error and the script stops. The design is to run an existence check, leave a log, then move on to the next job. Having "plist missing" in the log lets me later determine whether it was unloaded on the launchd side or the file itself was deleted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Naming the backups
&lt;/h3&gt;

&lt;p&gt;The current plist is always copied before restoring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;.bak-guard-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That leaves a file like &lt;code&gt;com.lily.outreach-ig.plist.bak-guard-20260823-120404&lt;/code&gt;. The reason for burning the timestamp into the filename is that when the same plist gets rewritten multiple times, I can tell which point in time each one is from with nothing but &lt;code&gt;ls&lt;/code&gt;. Using &lt;code&gt;.bak-guard-&amp;lt;datetime&amp;gt;&lt;/code&gt; instead of &lt;code&gt;.bak&lt;/code&gt; as the extension is also deliberate — it gives the file a name that's unlikely to be recognized as a plist, so launchd doesn't load it by mistake.&lt;/p&gt;

&lt;p&gt;For now, I haven't added automatic deletion of backups. If the same plist gets rewritten many times in a short period, the &lt;code&gt;.bak&lt;/code&gt; files pile up — but that pile has value in itself as evidence that "the rewrites are recurring," so deletion stays manual.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the self-repair job's plist teaches you
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;com.shun.self-repair.plist&lt;/code&gt; is the plist for the launchd job that periodically starts the outreach-schedule-guard script itself. Reading this plist directly, there are several values worth referencing as background-job design.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProcessType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Background&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;RunAtLoad&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;false/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;LowPriorityIO: true&lt;/code&gt;&lt;/strong&gt; lowers IO priority so it doesn't get in the way of other processes' disk access. The guard script only reads/writes plists and writes logs, so low IO priority is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Nice: 10&lt;/code&gt;&lt;/strong&gt; lowers CPU priority by 10 steps. 0 is the default, and higher values mean lower priority. It would defeat the purpose if the monitoring job hogged CPU and slowed down the main outreach DM scripts, so I lower it explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ProcessType: Background&lt;/code&gt;&lt;/strong&gt; means it's classified under the "Background" category in macOS Activity Monitor. Combined with &lt;code&gt;LowPriorityIO&lt;/code&gt;, it becomes subject to the OS's battery and CPU optimizations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;RunAtLoad: false&lt;/code&gt;&lt;/strong&gt; disables immediate execution right after loading. launchd has a feature that triggers a job once the moment it's loaded; with &lt;code&gt;true&lt;/code&gt;, it runs the instant you &lt;code&gt;launchctl bootstrap&lt;/code&gt;. The guard script only needs to run on schedule via &lt;code&gt;StartCalendarInterval&lt;/code&gt;, so I've turned off the unnecessary immediate run.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;StartCalendarInterval&lt;/code&gt; is three times a day: 9:20, 13:30, and 19:30. Outreach DMs run from 8 AM to 10 PM, yet the guard only runs three times, because rewrites don't happen continuously — the pattern is "everything gets rewritten at once and goes unnoticed for a while." Three times a day is enough to detect it, and since the check itself reads/writes plists, I want to minimize that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I got stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Snag ①: the python heredoc broke from variable expansion
&lt;/h3&gt;

&lt;p&gt;The first code I wrote used &lt;code&gt;&amp;lt;&amp;lt;PY&lt;/code&gt; instead of &lt;code&gt;&amp;lt;&amp;lt;'PY'&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# NG: クォートなしヒアドキュメント&lt;/span&gt;
&lt;span class="nv"&gt;actual&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;
import plistlib, sys
...
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unquoted &lt;code&gt;&amp;lt;&amp;lt;PY&lt;/code&gt; makes the shell expand variables inside the heredoc contents. Even if there isn't a single &lt;code&gt;$()&lt;/code&gt; or &lt;code&gt;$variable&lt;/code&gt; in the Python code, there are cases where, for example, the &lt;code&gt;[1]&lt;/code&gt; in &lt;code&gt;sys.argv[1]&lt;/code&gt; becomes a target for glob expansion in a zsh environment. In practice &lt;code&gt;[1]&lt;/code&gt; wasn't expanded, but on another line the &lt;code&gt;{&lt;/code&gt; and &lt;code&gt;}&lt;/code&gt; inside &lt;code&gt;f"{r.get('Hour')}:{r.get('Minute')}"&lt;/code&gt; were interpreted by the shell and produced a syntax error.&lt;/p&gt;

&lt;p&gt;Wrapping it in single quotes as &lt;code&gt;&amp;lt;&amp;lt;'PY'&lt;/code&gt; passes the heredoc contents to the Python interpreter as-is. Ever since, whenever I pass python/ruby/node code from Bash via a heredoc, I always use the &lt;code&gt;&amp;lt;&amp;lt;'EOXX'&lt;/code&gt; form.&lt;/p&gt;

&lt;h3&gt;
  
  
  Snag ②: &lt;code&gt;StartCalendarInterval&lt;/code&gt; returns a dict when there's a single entry
&lt;/h3&gt;

&lt;p&gt;When you read a plist with plistlib, the value of &lt;code&gt;StartCalendarInterval&lt;/code&gt; is normally a list (a Python array). But when the plist has a &lt;code&gt;&amp;lt;dict&amp;gt;&lt;/code&gt; written directly without an &lt;code&gt;&amp;lt;array&amp;gt;&lt;/code&gt; tag, plistlib returns a dict.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- 1時刻だけ登録したplistの例 --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first I had &lt;code&gt;rows = d.get('StartCalendarInterval') or []&lt;/code&gt; and looped with &lt;code&gt;for r in rows&lt;/code&gt;. Iterating a dict yields the string keys &lt;code&gt;'Hour'&lt;/code&gt; and &lt;code&gt;'Minute'&lt;/code&gt;, so &lt;code&gt;r.get('Hour')&lt;/code&gt; becomes a get on a string and returns None. The output became &lt;code&gt;"None:None"&lt;/code&gt;, the comparison naturally didn't match, and the restore process ran every single time — an endless loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 修正後
&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;StartCalendarInterval&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix was the one line where the &lt;code&gt;isinstance(rows, dict)&lt;/code&gt; check wraps a single dict in a list. Until I added it, restore logs kept appearing every time I verified behavior with a test plist that had only one time set — I initially thought it was a logic bug and spent hours searching in completely the wrong places.&lt;/p&gt;

&lt;h3&gt;
  
  
  Snag ③: not knowing &lt;code&gt;${expected:+,}&lt;/code&gt; and getting a leading comma every time
&lt;/h3&gt;

&lt;p&gt;Here's the code I first wrote for generating the expected CSV.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;h &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;harr&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nv"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;expected&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;h&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;com.lily.outreach-ig&lt;/code&gt;, once the loop ran, &lt;code&gt;expected&lt;/code&gt; became &lt;code&gt;",8:20,10:20,12:20..."&lt;/code&gt;. There's a comma at the front. Naturally it never matched &lt;code&gt;actual&lt;/code&gt; (&lt;code&gt;"8:20,10:20,..."&lt;/code&gt;). Mismatch every time meant restore every time — the guard script itself was rewriting the plist on every run.&lt;/p&gt;

&lt;p&gt;Bash's &lt;code&gt;${var:+value}&lt;/code&gt; parameter expansion means "expand value if var is non-empty, empty string if it's empty."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;h &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;harr&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nv"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;expected&lt;/span&gt;&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;expected&lt;/span&gt;:+,&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;h&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;${expected:+,}&lt;/code&gt;, the behavior becomes "insert a comma first only when there's already something there." On the first element expected is empty so no comma is inserted, and from the second onward a &lt;code&gt;,&lt;/code&gt; goes in. The result is the correct CSV: &lt;code&gt;"8:20,10:20,12:20,14:20,16:20,18:20,20:20,22:20"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I knew Bash parameter expansions like &lt;code&gt;:-&lt;/code&gt; (default value) and &lt;code&gt;:=&lt;/code&gt; (assignment) well, but &lt;code&gt;:+&lt;/code&gt; (expand only when non-empty) was something I'd never been conscious of until I hit this bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Snag ④: &lt;code&gt;launchctl bootstrap&lt;/code&gt; returned an error immediately
&lt;/h3&gt;

&lt;p&gt;The first version of the restore process called &lt;code&gt;bootstrap&lt;/code&gt; right after &lt;code&gt;bootout&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl bootout &lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
launchctl bootstrap &lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;  &lt;span class="c"&gt;# sleep なし&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run in that order, &lt;code&gt;bootstrap&lt;/code&gt; failed with &lt;code&gt;Load failed: 5: Input/output error&lt;/code&gt;. After receiving &lt;code&gt;bootout&lt;/code&gt;, launchd completes the job's termination processing asynchronously and internally. Even when &lt;code&gt;bootout&lt;/code&gt; returns 0, hitting &lt;code&gt;bootstrap&lt;/code&gt; at a moment when launchd's internal state hasn't been cleared yet causes a race and an error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl bootout &lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="nb"&gt;sleep &lt;/span&gt;1
&lt;span class="k"&gt;if &lt;/span&gt;launchctl bootstrap &lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;: 復元して再読込した"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;: 🔴 bootstrap に失敗した（手動確認が要る）"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inserting &lt;code&gt;sleep 1&lt;/code&gt; resolved it. "Wait one second" isn't an elegant solution, but launchd's documentation says nothing about guarantees around asynchronous completion, and I confirmed empirically that one second is a sufficient safety margin, so I adopted it rather than spending more time investigating.&lt;/p&gt;

&lt;p&gt;In the field, "it doesn't work for some reason" → "it worked after I added sleep 1" tends to be the kind of fix where you move on without the reason ever becoming clear. Here, leaving &lt;code&gt;🔴 bootstrap に失敗した（手動確認が要る）&lt;/code&gt; in the log builds a safety net so I'd notice immediately if it failed again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Snag ⑤: why &lt;code&gt;rest2&lt;/code&gt; was needed in &lt;code&gt;read -r p rest2&lt;/code&gt; in the ps pipe
&lt;/h3&gt;

&lt;p&gt;The forensic ps handling actually didn't work when I first tried to write it simply.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 最初のナイーブな実装&lt;/span&gt;
ps &lt;span class="nt"&gt;-Ao&lt;/span&gt; pid,lstart,comm | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"python|node|bash|launchctl|plutil"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looked like it worked, but because &lt;code&gt;bash&lt;/code&gt; is included in the &lt;code&gt;grep&lt;/code&gt;, the &lt;code&gt;bash&lt;/code&gt; process handling the pipe itself matched the grep and got mixed into the output. Also, since ps output column widths vary by environment, reading fields with a while loop using only &lt;code&gt;read -r l&lt;/code&gt; pulled trailing whitespace into the variable and made the log messy.&lt;/p&gt;

&lt;p&gt;The actual code is a two-stage pipe.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps &lt;span class="nt"&gt;-Ao&lt;/span&gt; pid,lstart,comm | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +2 | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; p rest2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rest2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"python|node|bash|launchctl|plutil"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-25&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; l&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;:   ps&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$l&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;rest2&lt;/code&gt; in &lt;code&gt;while read -r p rest2&lt;/code&gt; looks unused, but it's important. With &lt;code&gt;read -r p rest2&lt;/code&gt;, &lt;code&gt;p&lt;/code&gt; gets the first field (PID) and &lt;code&gt;rest2&lt;/code&gt; gets all remaining fields. Re-emitting with &lt;code&gt;echo "$p $rest2"&lt;/code&gt; afterwards produces a formatted line, excluding the header row (already removed with &lt;code&gt;tail -n +2&lt;/code&gt;). If you receive whole lines with just &lt;code&gt;read -r l&lt;/code&gt;, the extra spaces from ps's formatting are preserved; by decomposing into &lt;code&gt;p rest2&lt;/code&gt; and reassembling, runs of spaces are normalized to a single space.&lt;/p&gt;

&lt;p&gt;As a result, the &lt;code&gt;ps&amp;gt;&lt;/code&gt; lines in the forensic log come out in a readable form like &lt;code&gt;12345 Fri Aug 23 12:04:11 2026 python3&lt;/code&gt;. If this format had been in place when the August 23 incident happened, the very first thing I could have done was cross-check that pid, execution time, and command name.&lt;/p&gt;




&lt;p&gt;In the next part, we'll look at the procedure for registering this guard script itself with launchd, along with real examples of the log output from when the guard actually fired (when I deliberately rewrote a plist to test it).&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;In addition to the five snags listed in part 2, here's a roundup of problems I actually ran into while building this. Bullet points, comprehensively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① self-repair.plist's ProgramArguments didn't call the script directly&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I first read &lt;code&gt;com.shun.self-repair.plist&lt;/code&gt;, ProgramArguments wasn't what I expected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/claude-quota-guard.py&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--job&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.shun.self-repair&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/self-repair.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rather than calling &lt;code&gt;/bin/bash self-repair.sh&lt;/code&gt; directly, it goes through &lt;code&gt;claude-quota-guard.py&lt;/code&gt;. At first I didn't understand "why the extra layer," and I was testing by naively writing &lt;code&gt;/bin/bash outreach-schedule-guard.sh&lt;/code&gt; directly into ProgramArguments. It's a design that anticipates the guard script itself having processing that calls the Claude API (including future extensions) and places it under the umbrella of quota management. I later established a rule that all guard-type jobs go through this wrapper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② The job shows as "running" in &lt;code&gt;launchctl list&lt;/code&gt; but the PID stays &lt;code&gt;-&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right after &lt;code&gt;launchctl bootstrap&lt;/code&gt; succeeded, I checked &lt;code&gt;launchctl list | grep com.shun.self-repair&lt;/code&gt; and the PID was &lt;code&gt;-&lt;/code&gt; with status &lt;code&gt;0&lt;/code&gt;. I panicked — "did the load fail?" — but this is normal behavior. With &lt;code&gt;RunAtLoad: false&lt;/code&gt; and scheduled execution via &lt;code&gt;StartCalendarInterval&lt;/code&gt;, launchd just registers the job and doesn't start it until the next scheduled time. A PID of &lt;code&gt;-&lt;/code&gt; means "waiting." Only after one of 9:20, 13:30, or 19:30 has passed and you check &lt;code&gt;launchctl list&lt;/code&gt; again does a &lt;code&gt;PID&lt;/code&gt; value appear. The correct verification procedure is not to check liveness by PID, but to watch the log file with &lt;code&gt;tail -f&lt;/code&gt; after the scheduled time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ &lt;code&gt;$HOME&lt;/code&gt; comes out empty unless you specify &lt;code&gt;HOME&lt;/code&gt; in EnvironmentVariables&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before I put an &lt;code&gt;EnvironmentVariables&lt;/code&gt; block in the plist, I hit a bug where the path to &lt;code&gt;$HOME/.claude/logs/&lt;/code&gt; inside self-repair.sh became an empty string. launchd only partially inherits the GUI session's user environment variables, and there are cases where a shell script starts with &lt;code&gt;HOME&lt;/code&gt; undefined.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;HOME&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/Users/実ユーザー名&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LANG&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;en_US.UTF-8&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;PATH&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/Users/実ユーザー名/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since &lt;code&gt;~&lt;/code&gt; expansion doesn't work inside a plist, &lt;code&gt;HOME&lt;/code&gt; has to be written as an absolute path. If you're calling scripts that use Node.js via nvm, &lt;code&gt;PATH&lt;/code&gt; also needs to include &lt;code&gt;~/.nvm/versions/node/vXX.X.X/bin&lt;/code&gt;. Forget it and &lt;code&gt;node: command not found&lt;/code&gt; piles up silently in the StandardErrorPath log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;④ The job won't start if the StandardOutPath / StandardErrorPath directory doesn't exist&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I once wrote the following into a plist without creating the directory first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardErrorPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/logs/self-repair.launchd.err.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardOutPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/logs/self-repair.launchd.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you &lt;code&gt;launchctl bootstrap&lt;/code&gt; while &lt;code&gt;~/.claude/logs/&lt;/code&gt; doesn't exist, the job loads successfully (it even shows up in &lt;code&gt;launchctl list&lt;/code&gt;), yet the script never runs at the scheduled time. Since the error log can't be written, nothing is left behind — just the result "it isn't running." The fix is to run &lt;code&gt;mkdir -p&lt;/code&gt; first, and to build the &lt;code&gt;mkdir -p "$(dirname "$LOG")"&lt;/code&gt; pattern from the top of outreach-schedule-guard.sh into the setup script that runs before plist registration too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑤ &lt;code&gt;plistlib.dump()&lt;/code&gt; converts binary plists to XML&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the write-back processing, I call &lt;code&gt;plistlib.dump(d, f)&lt;/code&gt; with default arguments. The default is &lt;code&gt;fmt=plistlib.FMT_XML&lt;/code&gt;, so even if the original plist was in binary format (converted with &lt;code&gt;plutil -convert binary1&lt;/code&gt;), it becomes XML format after write-back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plistlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ↑ デフォルトはXML形式で出力。バイナリ維持したい場合は:
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plistlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;plistlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FMT_XML&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# または
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;plistlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;plistlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FMT_BINARY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;launchd reads both XML and binary formats without issue, so there's no actual harm, but the format changes when you inspect the contents with &lt;code&gt;plutil -p&lt;/code&gt;. I once got confused comparing against a backup and thinking "did something rewrite this?" If you want to preserve the original format, you need to detect whether it's binary at read time and branch the output format accordingly. This time I settled on "as long as launchd can read it, standardize on XML."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑥ launchd mistakenly reads &lt;code&gt;.bak&lt;/code&gt; backup files as jobs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If there's a file with the &lt;code&gt;.plist&lt;/code&gt; extension in the LaunchAgents directory, macOS's launchd management features recognize it as a job candidate. In an early version where I gave backup files a &lt;code&gt;.plist&lt;/code&gt; extension, I named them &lt;code&gt;com.lily.outreach-ig.bak.plist&lt;/code&gt; instead of &lt;code&gt;com.lily.outreach-ig.plist.bak&lt;/code&gt;, which caused confusion when launchd GUI tools (LaunchControl and the like) displayed them as jobs. The current naming convention &lt;code&gt;$label.plist.bak-guard-YYYYmmdd-HHMMSS&lt;/code&gt; deliberately ensures the name doesn't end in &lt;code&gt;.plist&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑦ Including &lt;code&gt;bash&lt;/code&gt; in the forensic ps filter makes it match itself&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"python|node|bash|launchctl|plutil"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because this pattern includes &lt;code&gt;bash&lt;/code&gt;, the &lt;code&gt;bash&lt;/code&gt; process handling the pipe matches the grep itself. A line like &lt;code&gt;/bin/bash /path/to/outreach-schedule-guard.sh&lt;/code&gt; always appears in the log. That's noise, but I've deliberately left it in — the very fact that "the guard script's own start time is recorded in ps" can serve as evidence. That said, when looking at a ps snapshot, it's nearly certain that the "bash" line is the guard script itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑧ The gap between the guard's run frequency and the DM send frequency&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;com.shun.self-repair.plist&lt;/code&gt;'s StartCalendarInterval is three times: 9:20, 13:30, 19:30. Meanwhile, for outreach DMs, &lt;code&gt;com.lily.outreach-ig&lt;/code&gt; runs eight times every two hours from 8:20 to 22:20. The math works out to: "if the guard last ran at 19:30 and a rewrite happens at 20:00, the next detection takes 13 hours, until 9:20 the next morning."&lt;/p&gt;

&lt;p&gt;I'm aware of this design flaw. As countermeasures, I'm considering either raising the guard's run frequency (say, hourly) or building a self-consistency check into the outreach DM scripts themselves at startup. For now I've kept it to three runs on the premise that "rewrites don't recur frequently," trading off against the side effects of reading/writing plists. Since the August 23 incident did real damage in the form of "went unnoticed for several days," this judgment may be revisited in the future.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑨ The &lt;code&gt;stat -f '%Sm'&lt;/code&gt; format is macOS-specific&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;stat -f '%Sm' -t '%F %T'&lt;/code&gt; used to get the mtime in the forensic log is macOS &lt;code&gt;stat&lt;/code&gt; command syntax. On GNU Linux, &lt;code&gt;stat -c '%y'&lt;/code&gt; is the equivalent, but this script is macOS-only so it's not a problem. That said, if a colleague on another Mac asks "does this script work on Linux too?", I need to tell them the &lt;code&gt;stat&lt;/code&gt; part won't. If you wanted to make it cross-platform, one option is to unify on &lt;code&gt;os.path.getmtime()&lt;/code&gt; on the Python side.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;p&gt;Here are the decision criteria I picked up through building and operating this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Override PATH at the top of launchd shell scripts&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nearly every case of "works in the terminal but gives &lt;code&gt;command not found&lt;/code&gt; from launchd" comes down to PATH. Node under nvm and Homebrew's python3 don't exist in the system PATH. This is always line one of my launchd script template.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Drop &lt;code&gt;set -e&lt;/code&gt; and keep only &lt;code&gt;pipefail&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For monitoring and restore scripts, &lt;code&gt;-e&lt;/code&gt; is all harm and no benefit. On top of the reason explained in part 2 (the script dies on an empty grep with exit code 1), a &lt;code&gt;launchctl bootout&lt;/code&gt; failure (when it's already unloaded) is also fatal under &lt;code&gt;-e&lt;/code&gt;. Keeping just &lt;code&gt;pipefail&lt;/code&gt; still lets you detect silent errors partway through a pipe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Read and write plists with python3 plistlib&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;XML parsing with shell grep/awk has no type guarantees and can't handle binary plists at all. python3 can auto-detect both XML and binary formats with the standard library's &lt;code&gt;plistlib&lt;/code&gt;, and reliably extract &lt;code&gt;Hour&lt;/code&gt; and &lt;code&gt;Minute&lt;/code&gt; as integers. Since &lt;code&gt;python3 -c "import plistlib"&lt;/code&gt; is complete in one line, there are zero external dependencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Always quote heredocs in the &lt;code&gt;&amp;lt;&amp;lt;'EOF'&lt;/code&gt; form&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When passing Python code from Bash via a heredoc, an unquoted &lt;code&gt;&amp;lt;&amp;lt;EOF&lt;/code&gt; breaks things because the shell tries to variable-expand the Python code. Python code containing &lt;code&gt;{&lt;/code&gt;, &lt;code&gt;}&lt;/code&gt;, or &lt;code&gt;$&lt;/code&gt; is especially dangerous. Making &lt;code&gt;&amp;lt;&amp;lt;'PY'&lt;/code&gt; with single quotes a habit from the start prevents bugs that are very hard to debug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Always create a timestamped backup before restoring&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;.bak-guard-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the restore process malfunctions (for example, a mistake in the SPECS array causes correct settings to be judged as a "mismatch" forever), you can't get back to the original settings without a backup. The reason for burning in the timestamp is that when multiple rewrites occur, you can follow the chronology with nothing but &lt;code&gt;ls -lt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Validate plist structure with &lt;code&gt;plutil -lint&lt;/code&gt; before handing it to launchd&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you feed launchd a plist whose structure broke during write-back, the job silently stops starting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;plutil &lt;span class="nt"&gt;-lint&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="c"&gt;# launchctl bootstrap へ進む&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;log &lt;span class="s2"&gt;"🔴 復元後のplistが壊れている（戻していない）"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this gate in place, you eliminate the risk of polluting launchd with half-written XML from a plistlib bug or an interrupted write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Put &lt;code&gt;sleep 1&lt;/code&gt; between &lt;code&gt;bootout&lt;/code&gt; and &lt;code&gt;bootstrap&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;launchd's &lt;code&gt;bootout&lt;/code&gt; completes asynchronously. Even when &lt;code&gt;bootout&lt;/code&gt; returns 0, hitting &lt;code&gt;bootstrap&lt;/code&gt; at a moment when launchd's internal job-termination processing hasn't finished returns &lt;code&gt;Load failed: 5: Input/output error&lt;/code&gt;. One second of waiting is empirically a sufficient safety margin. Further, catching &lt;code&gt;bootstrap&lt;/code&gt;'s success/failure in an if statement and logging it means you'd notice immediately if it failed again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Always leave a forensic log (mtime + ps snapshot)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An implementation that only restores and calls it a day buries "what happened" in the dark. Recording the plist's mtime at the moment a rewrite is detected, plus a list of running processes, gives you clues for finding the cause. The regret from the August 23 incident — "with a guard script I could have identified the suspect process" — is the motivation for this design. As long as you have the logs, patterns emerge when the same rewrite recurs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Write a &lt;code&gt;printf&lt;/code&gt;-based log function&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;log&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;echo&lt;/code&gt;'s handling of newlines varies with the shell implementation and the presence of the &lt;code&gt;-e&lt;/code&gt; flag. When a string containing backslashes slips into a log message, &lt;code&gt;echo&lt;/code&gt; may or may not expand &lt;code&gt;\n&lt;/code&gt; into a newline depending on the environment. &lt;code&gt;printf&lt;/code&gt;'s separation of format string and values guarantees consistency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Add a guard for a missing plist&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; log &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;: plist が無い"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a plist for a label defined in the SPECS array has disappeared, python3's &lt;code&gt;open()&lt;/code&gt; raises an exception and the whole script stops. Making it an existence check + log + continue to the next loop means that even if one of the four jobs is missing its plist, the other three still get checked normally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. Use &lt;code&gt;RunAtLoad: false&lt;/code&gt; to prevent immediate execution right after registration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;launchd has a feature that starts a job exactly once the moment it's loaded with &lt;code&gt;launchctl bootstrap&lt;/code&gt; (equivalent to &lt;code&gt;RunAtLoad: true&lt;/code&gt;). The guard script only needs to run on schedule, so &lt;code&gt;RunAtLoad: false&lt;/code&gt; turns off the unnecessary immediate run. The restore process is idempotent, but reading/writing plists has a non-zero cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Minimize the monitoring job's resource impact with &lt;code&gt;LowPriorityIO&lt;/code&gt; and &lt;code&gt;Nice: 10&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;com.shun.self-repair.plist&lt;/code&gt; has the following configured.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProcessType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Background&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It defeats the purpose if the monitoring job eats CPU and IO and degrades the performance of the outreach DM scripts it's monitoring. &lt;code&gt;Nice: 10&lt;/code&gt; lowers CPU priority by 10 steps (0 is the default), and &lt;code&gt;LowPriorityIO&lt;/code&gt; lowers IO priority. Combined with &lt;code&gt;ProcessType: Background&lt;/code&gt;, it becomes subject to the OS's battery and CPU optimizations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. Guard the StartCalendarInterval single-entry problem with an &lt;code&gt;isinstance&lt;/code&gt; check&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;StartCalendarInterval&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this one line, the restore process runs every time against a plist with only one time set (a &lt;code&gt;&amp;lt;dict&amp;gt;&lt;/code&gt; written directly without an &lt;code&gt;&amp;lt;array&amp;gt;&lt;/code&gt; tag). Whether plistlib returns a dict or a list depends on the XML structure, so you need to check defensively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;14. Solve the leading-comma CSV problem with &lt;code&gt;${var:+,}&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;h &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;harr&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nv"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;expected&lt;/span&gt;&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;expected&lt;/span&gt;:+,&lt;span class="k"&gt;}${&lt;/span&gt;&lt;span class="nv"&gt;h&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;minute&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;${expected:+,}&lt;/code&gt; is a Bash parameter expansion meaning "insert a comma if &lt;code&gt;expected&lt;/code&gt; is non-empty, do nothing if empty." Writing &lt;code&gt;expected="${expected},${h}:${minute}"&lt;/code&gt; puts a comma at the front, producing an infinite restore loop where expected and actual values never match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15. Always register the guard itself with launchd via &lt;code&gt;bootout → sleep 1 → bootstrap&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When updating an existing job (when you change ProgramArguments or StartCalendarInterval), running &lt;code&gt;bootstrap&lt;/code&gt; without &lt;code&gt;launchctl unload&lt;/code&gt; or &lt;code&gt;bootout&lt;/code&gt; doesn't apply the changes. Bundling the following procedure into a setup script means never having to second-guess it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl bootout &lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/com.shun.self-repair"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="nb"&gt;sleep &lt;/span&gt;1
launchctl bootstrap &lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ~/Library/LaunchAgents/com.shun.self-repair.plist
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;self-repair
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In that final &lt;code&gt;launchctl list&lt;/code&gt; check, a PID of &lt;code&gt;-&lt;/code&gt; with status &lt;code&gt;0&lt;/code&gt; means it registered successfully.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;This mechanism, which began with the schedule rewrite at 12:04 on August 23, 2026, ended up being a step toward "an environment where automation uptime doesn't need human eyes to guarantee it." Whether the four outreach DM lanes are running on the correct schedule — 29 runs a day in total — is something &lt;code&gt;outreach-schedule-guard.sh&lt;/code&gt; checks three times a day at 9:20, 13:30, and 19:30, and automatically restores if anything looks off, without me having to check.&lt;/p&gt;

&lt;p&gt;The core implementation is 73 lines. Read with plistlib, compare against expected values, and if they differ, back up, restore, and re-register with launchd. Leave a forensic log along the way. That's it. Even launchd's seemingly complex internals can be controlled from a shell script, as long as you have python3's standard library to read and write the XML directly.&lt;/p&gt;

&lt;p&gt;The reason I sustain 1.2M yen in monthly revenue as a solo developer is that I've stacked up "mechanisms that keep running autonomously during the hours no human is watching." Each individual mechanism is simple, but because a stoppage translates directly into lost revenue, I design them to repair themselves when they stop. The guard script in this article is one example of that philosophy.&lt;/p&gt;

&lt;p&gt;What's the longest one of your automations has been quietly broken before you noticed?&lt;/p&gt;




&lt;p&gt;I've written up the full picture of the system, the breakdown of the 1.2M yen, and the 30-day procedure in a paid note.&lt;/p&gt;

&lt;p&gt;📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>macos</category>
      <category>automation</category>
      <category>bash</category>
      <category>python</category>
    </item>
    <item>
      <title>Broken Retries: Why 'Just Try Again' Kept 5 Automation Lanes Dead for 63 Minutes</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sun, 30 Aug 2026 11:00:05 +0000</pubDate>
      <link>https://dev.to/bokuwalily/broken-retries-why-just-try-again-kept-5-automation-lanes-dead-for-63-minutes-3mj0</link>
      <guid>https://dev.to/bokuwalily/broken-retries-why-just-try-again-kept-5-automation-lanes-dead-for-63-minutes-3mj0</guid>
      <description>&lt;p&gt;Five automation lanes went down on the same morning. Every log said the same thing: "Failed, so I ran it again." Then again. Then again. Sixty-three minutes after the first failure, all I had was the exact same failure — three more times over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this design works
&lt;/h2&gt;

&lt;p&gt;When I first started writing scripts, I thought retries looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;do_something&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that's correct — as far as it goes. A network blip, a server that got temporarily slow: that kind of failure really does get fixed by this. The problem is that it applies the assumption "the next attempt will succeed" uniformly to &lt;em&gt;every&lt;/em&gt; failure.&lt;/p&gt;

&lt;p&gt;As the number of running jobs grows, failures that break this assumption will inevitably show up.&lt;/p&gt;

&lt;p&gt;In the SNS automation environment I operate, retry fixes came in from five separate lanes at once over two days, August 8–9, 2026. The symptoms all looked alike. The causes were completely different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;note-autolike produced zero rewrites for three days.&lt;/strong&gt; Last success: 2026-08-06T09:03:20Z. There was a 58-character limit validation on article titles, but the generation prompt said nothing at all about a character limit, so the model returned a 63-character title every single time. Three attempts, three 63-character titles. A fourth was never tried — it went straight into the give-up path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ai-portraits spun its wheels for 63 minutes on image generation.&lt;/strong&gt; When a request got rejected by moderation, I had implemented a three-stage fallback that progressively softened the prompt and retried. Up to 21 minutes per concept; up to 63 minutes for three concepts. In practice, when the target content itself is what trips moderation, lowering the tier doesn't move the wall. Trying all three stages produced the same result as stage one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;browser-slot.sh sees 18–34 launch attempts per hour against 3 global slots.&lt;/strong&gt; A single run takes 14–62 minutes, so the slots are always full. The pre-fix implementation printed &lt;code&gt;SKIP: global limit reached&lt;/code&gt; and exited 0 the instant acquire failed. The numbers left in the log: 9 runs in the 9 o'clock hour, 8 in the 11 o'clock hour, and 12 in the 12 o'clock hour never even started. One account hit skip on both of its runs that day, ending with 0 likes and 0 follows.&lt;/p&gt;

&lt;p&gt;All three had "just try again" implemented. None of them were fixed by it.&lt;/p&gt;

&lt;p&gt;Because the &lt;em&gt;kinds&lt;/em&gt; of failure were different. A 63-character title: throw the same prompt at the same model and you get the same 63 characters back. That's a deterministic failure. A moderation block, when the content is the cause, doesn't change no matter how many stages you try unless the conditions change. A slot shortage can only be solved by waiting for someone to free a slot.&lt;/p&gt;

&lt;p&gt;In a retry implementation with no such classification, all three kinds of failure get sucked into the same "loop 3 times, then exit" handling. The result: fixable failures and unfixable failures are counted identically and come out the same exit.&lt;/p&gt;

&lt;p&gt;There's a failure mode in the opposite direction too — cases logged as "failed" that were actually transient. My &lt;code&gt;watch-arb&lt;/code&gt; script recorded only &lt;code&gt;extract_failed&lt;/code&gt; when it couldn't fetch a product page. Look at the dashboard the next morning and it reads as "there are 16 shops we can't fetch." In reality it was just HTTP 429 coming back, and a prober that hit the same shops slowly, one at a time, fetched them correctly. Lining up the similar incidents I confirmed over the same period: &lt;strong&gt;the same 429 was recorded under seven different names in the logs.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lane&lt;/th&gt;
&lt;th&gt;Name in the log&lt;/th&gt;
&lt;th&gt;Actual root cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;watch-arb product page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extract_failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP 429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;watch-arb bootstrap&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FATAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;undici's 10-second ConnectTimeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;social-autolike ig-3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;coverage 0/11 → all sources failed → exit 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;429 from IG private API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;social-autolike ig-sug&lt;/td&gt;
&lt;td&gt;&lt;code&gt;following map fetch failed → run halted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same 429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;outreach-multi enrich&lt;/td&gt;
&lt;td&gt;all records &lt;code&gt;error&lt;/code&gt; → all dropped at qualify → 0 messages total&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;web_profile_info&lt;/code&gt; returning 429 constantly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;outreach-multi igJson&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unparsable json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Response was 512,558 chars but sliced at 400,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If it's recorded as &lt;code&gt;extract_failed&lt;/code&gt;, you read it as "this shop's structure is unusual." In fact the problem was "I hit it too fast," and hitting it slowly the next day works. Because the name was wrong, I kept fixing in the wrong direction.&lt;/p&gt;

&lt;p&gt;Cleaning this up, correct retry design converges on answering three questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(1) Will waiting fix it?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;(2) When do you cut it off?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;(3) What do you change before the next attempt?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Failed, so try again" is an implementation that omits all three. On the morning those five lanes were down together, none of the three had an answer written down.&lt;/p&gt;

&lt;p&gt;An automation environment doesn't keep running because I write code every day. It works because the environment self-corrects and keeps running while I'm asleep. From that premise, the requirements for retry design change. The requirement isn't "the next attempt will succeed after a failure" — it's "decide the next action per kind of failure." Between an implementation that has this design and one that doesn't, whether I have to be personally involved in incident recovery changes fundamentally.&lt;/p&gt;
&lt;h2&gt;
  
  
  The overall flow
&lt;/h2&gt;

&lt;p&gt;Here are the three questions in one diagram.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A failure occurred
      │
      ▼
┌─────────────────────────────────────┐
│ (1) Will waiting fix it?             │
│                                     │
│  Slot shortage → wait for one to free│
│  Network drop → back off and retry   │
│  429 → wait, preferring Retry-After  │
│  Expired login → waiting won't fix it│
│  Validation error → waiting won't fix│
│  Moderation → depends on stage (below)│
└─────────────────────────────────────┘
      │ waiting won't fix it
      ▼
┌─────────────────────────────────────┐
│ (2) When do you cut it off?          │
│                                     │
│  Attempt cap or &amp;gt;3h since intake → stop│
│  Still attempts left → go to (3)     │
└─────────────────────────────────────┘
      │ still attempts left
      ▼
┌─────────────────────────────────────┐
│ (3) What do you change?              │
│                                     │
│  Same input → same result if deterministic│
│  Attach the error → recoverable failures recover│
│  Tier downgrade (vertical) → soften and retry│
│  Swap the task (horizontal) → change the task itself│
└─────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three sections aren't processed in order — the combination that applies changes with the kind of failure. For a network drop, (1) alone is enough. A deterministic validation error skips (1) and enters at (3). A moderation block involves all of it: skipping (1), the threshold in (2), and the way you change things in (3).&lt;/p&gt;

&lt;h3&gt;
  
  
  (1) Wait — the skeleton of the implementation
&lt;/h3&gt;

&lt;p&gt;Here's the core I added in the &lt;code&gt;browser-slot.sh&lt;/code&gt; fix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BROWSER_SLOT_WAIT_SEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_WAIT_SEC&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;600&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

wait_for_slot&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;waited&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; try_acquire_slot&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;jitter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; RANDOM &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;31&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;   &lt;span class="c"&gt;# 15〜45秒ランダム&lt;/span&gt;
    &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$jitter&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;waited&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; waited &lt;span class="o"&gt;+&lt;/span&gt; jitter &lt;span class="k"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$waited&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="nv"&gt;$BROWSER_SLOT_WAIT_SEC&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;log &lt;span class="s2"&gt;"RESULT waited=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;waited&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s SKIP: slot timeout"&lt;/span&gt;
      &lt;span class="k"&gt;return &lt;/span&gt;1
    &lt;span class="k"&gt;fi
  done
  return &lt;/span&gt;0
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Make the retry interval a random 15–45 seconds.&lt;/strong&gt; With a fixed interval, every job waiting for a slot piles in at the same instant (thundering herd). Adding random jitter offsets jobs from each other so they spread out naturally. I made the change only after confirming by measurement that a fixed interval serialized all the jobs and made congestion worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't hold the guard for the whole wait.&lt;/strong&gt; Hold the lock only for the instant of the acquire check, and always release it afterward. Get this wrong and every waiting job holds a lock against every other, and they all stall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leave &lt;code&gt;waited=600s&lt;/code&gt; on the RESULT line in the log.&lt;/strong&gt; "Gave up after waiting 600 seconds" and "discarded immediately" are different events, and if you can't distinguish them you can't evaluate anything after the fact.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BROWSER_SLOT_WAIT_SEC=0&lt;/code&gt; matches the pre-fix immediate-skip behavior exactly. More than 30 launchd jobs share this script, so I made it possible to switch over gradually while preserving backward compatibility.&lt;/p&gt;

&lt;p&gt;On the other hand, there are cases that look transient and are in fact fixed by an immediate retry. I had a job that failed three days running with Cloudflare's &lt;code&gt;Authentication error [code: 10000]&lt;/code&gt;, which looked like "OAuth expired → a human has to intervene." But when I actually ran five probes in both a shell and a minimal launchd environment, all of them exited 0. Re-reading the logs from the failure days, 0.5 seconds after an access token reissue, a different endpoint returned 200 with that same token. It was a &lt;strong&gt;transient 401 right after token reissue&lt;/strong&gt;, and the real root cause was that there was not a single retry. Failure rate: 3 days out of 9 (33%). Snap-judging "it's an auth error, so it's a human task" piles machine-fixable things onto a human queue.&lt;/p&gt;

&lt;h3&gt;
  
  
  (2) Give up — always place a terminator
&lt;/h3&gt;

&lt;p&gt;Take &lt;code&gt;ai-portraits&lt;/code&gt;' tier ladder. Moderation block errors have a &lt;code&gt;moderation_stage&lt;/code&gt; field. Ones that stopped at &lt;code&gt;output&lt;/code&gt; have a track record of getting through with a tier downgrade. Ones that started at &lt;code&gt;input&lt;/code&gt; failed all 9 attempts over the same period.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;concept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moderation_stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# input段 → Tierを変えても壁は動かない、残りをbreak
&lt;/span&gt;        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input-stage block, skip remaining tiers for this concept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moderation_blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# output段 → Tier降格で通る可能性がある
&lt;/span&gt;        &lt;span class="k"&gt;continue&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Decide from the first-stage error body whether climbing down the ladder is worth anything.&lt;/strong&gt; Just breaking on &lt;code&gt;moderation_stage == "input"&lt;/code&gt; turns 63 minutes of spinning into 0. In the same spirit, &lt;code&gt;gsheets_retry.py&lt;/code&gt; is designed to raise immediately on 401/403/404.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;RETRYABLE_HTTP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sheets_call_with_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;HttpError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RETRYABLE_HTTP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;  &lt;span class="c1"&gt;# 401/403/404 は即 raise — リトライで隠さない
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;  &lt;span class="c1"&gt;# 2秒 → 6秒（合計最大8秒）
&lt;/span&gt;        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ConnectionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Decide "never exceed 8 seconds total" first.&lt;/strong&gt; If you set exponential backoff by attempt count, it quietly eats the caller's slot time. Fixing an upper bound in seconds first and back-calculating the attempt count is the safe order.&lt;/p&gt;

&lt;p&gt;If you implement "give up" using attempt count alone, you can't handle failures on the time axis. In &lt;code&gt;kotonoha&lt;/code&gt;'s job queue I had an incident where a job sat &lt;strong&gt;pending for 39 hours&lt;/strong&gt;. Even when it broke off at the cap of 6 attempts, the requeue succeeded, so a failure was never raised, monitoring never fired, and the user's screen stayed on "generating."&lt;/p&gt;

&lt;p&gt;The post-fix predicate looks like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shouldGiveUpForQuota&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;quotaHits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createdAtMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resumeAtMs&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;THREE_HOURS_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;quotaHits&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;                                  &lt;span class="c1"&gt;// 2回目の上限&lt;/span&gt;
    &lt;span class="nx"&gt;resumeAtMs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;createdAtMs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;THREE_HOURS_MS&lt;/span&gt;          &lt;span class="c1"&gt;// 受付から3時間超&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Place the cutoff on both "attempt count" and "absolute time since intake."&lt;/strong&gt; Putting the actual values from the incident day (intake &lt;code&gt;2026-08-19T00:36:57Z&lt;/code&gt;, resume &lt;code&gt;06:37:00Z&lt;/code&gt;) into the tests as a boundary case gives you a machine guarantee that the same incident can never get through again.&lt;/p&gt;

&lt;h3&gt;
  
  
  (3) Feed back — pass what you changed into the next attempt
&lt;/h3&gt;

&lt;p&gt;Here's the &lt;code&gt;note-autolike&lt;/code&gt; case. The reason rewrites were zero for three days was an implementation that just threw the identical prompt three times. Make the error machine-readable and attach correction instructions to attempts from the second onward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// エラーに code / actual / limit を載せる&lt;/span&gt;
&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`タイトルは1〜58字にしてください（実際: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;字）`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title-too-long&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;58&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 2回目以降は修正指示を付けて投げる&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rewriteRetryInstruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title-too-long&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;`前回の出力は次の理由で不採用でした：`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`タイトルが&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;字あり、上限の&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;字を超えています。`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`同じ内容・同じ構成のまま、その点だけを満たす形へ直して、生JSONのみを再出力してください。`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`タイトルは絵文字込みで&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;字以内（前回は&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;字）に必ず収めてください。`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;askClaudeWithRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callClaude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentPrompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;validateRewrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 決定論的な失敗 — スリープなし、修正指示を付けて即再試行&lt;/span&gt;
        &lt;span class="nx"&gt;currentPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;rewriteRetryInstruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// プロセス系の失敗 — 30秒待つ&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Retry deterministic failures immediately, with no sleep.&lt;/strong&gt; Waiting 2 seconds on a validation error just throws 2 seconds away. If you mix "wait" and "fix and resubmit" into the same retry, you pay wait time even on fixable failures. Branch on the kind of failure and choose where the sleep goes.&lt;/p&gt;

&lt;p&gt;The correction instructions you return to the LLM should be "human-language instructions plus the actual strings," not "machine identifiers." Passing &lt;code&gt;"title-too-long"&lt;/code&gt; through as-is loses three times in a row. Passing "the title is 63 characters, exceeding the 58-character limit; keep it within 58 characters" recovers. This is a fact I had already demonstrated in a different script.&lt;/p&gt;

&lt;p&gt;I'll stop the full picture of the three sections here. Next I'll break down how each section breaks and how to fix it, case by case, drawn from the five-lane simultaneous outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "I wrote the retry" isn't done — put fault injection in the same commit
&lt;/h3&gt;

&lt;p&gt;The three sections designed above are, at the moment you write them, nothing but "code that should work." Intermittent failures have no reproduction method, so by default there's no way to confirm whether the retry actually traverses that path. My first four implementations ended there. I looked at &lt;code&gt;git diff&lt;/code&gt;, felt like I'd confirmed it, and called it done.&lt;/p&gt;

&lt;p&gt;What actually worked was bundling fault injection into the same commit as the implementation itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// watch-arb/collector/lib/api.mjs&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_failOnceRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WA_API_FAIL_ONCE&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;req&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_failOnceRemaining&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;_failOnceRemaining&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 合成の障害、本番では絶対に発火しない&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 本来のfetch処理&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The counter is module-scoped and shared across all &lt;code&gt;req&lt;/code&gt; calls. Only for the first N calls after process start, it throws a synthetic &lt;code&gt;TypeError&lt;/code&gt; before calling fetch.&lt;/p&gt;

&lt;p&gt;The completion criteria are a set of two, and I always confirm both.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;① Launch with &lt;code&gt;WA_API_FAIL_ONCE=2&lt;/code&gt;&lt;/strong&gt; and see &lt;code&gt;api retry attempt=1/3&lt;/code&gt; and &lt;code&gt;2/3&lt;/code&gt;, then a full run to exit 0 with no FATAL&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;② With the env var unset, not a single retry log line appears&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Having ② means you can demonstrate "the fault injection hasn't leaked into production" with the same command. Here's the log I actually got.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[INFO] api retry attempt=1/3 path=/api/collector/shops reason=fetch failed
[INFO] api retry attempt=2/3 path=/api/collector/shops reason=fetch failed
[INFO] 32件を ingest して exit 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I rolled this pattern out from &lt;code&gt;watch-arb&lt;/code&gt; to &lt;code&gt;zaiko-radar&lt;/code&gt;, &lt;code&gt;lily-line-funnel&lt;/code&gt;, and &lt;code&gt;social-autolike&lt;/code&gt; — four in total. The rule changed to: "I wrote the retry" doesn't count as done unless it's "I watched it run with fault injection."&lt;/p&gt;

&lt;h3&gt;
  
  
  Confine the retry target to a single function
&lt;/h3&gt;

&lt;p&gt;When you write a retry, the first thing to decide isn't "how many attempts" but "what gets retried."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 判定を1関数に集約する&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isTransientStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;req&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_ATTEMPTS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchWithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isTransientStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;MAX_ATTEMPTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isNetworkError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// ネットワーク例外以外は即throw&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;MAX_ATTEMPTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Throwing immediately on 401/403/404&lt;/strong&gt; isn't a shortcut — it's design that prevents a different kind of incident. Wrap a permission error in a retry and you throw the same request three times and get the same failure three times. The log records only "failed 3 times," and the real cause — "no permission" — gets buried.&lt;/p&gt;

&lt;p&gt;This is the inverse of the "429 recorded under seven names" I documented in &lt;code&gt;transient-failure-recorded-as-permanent.md&lt;/code&gt;. Same structure as a 429 masquerading as &lt;code&gt;extract_failed&lt;/code&gt;: 401/403 masquerades as "sometimes slow."&lt;/p&gt;

&lt;p&gt;The reason to pull &lt;code&gt;isTransientStatus&lt;/code&gt; out into a single function is that when the judgment is scattered across multiple call sites, each site grows a subtly different definition. The constant set &lt;code&gt;RETRYABLE_HTTP = {429, 500, 502, 503, 504}&lt;/code&gt; in &lt;code&gt;gsheets_retry.py&lt;/code&gt; comes from the same idea: it confines the knowledge of "which statuses are retryable" to one place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding retries changes how exceptions propagate
&lt;/h3&gt;

&lt;p&gt;In the commit right after I implemented &lt;code&gt;withRetry&lt;/code&gt; in &lt;code&gt;kotonoha&lt;/code&gt;, three side-effect fixes went in. I thought I had "added a feature"; what I had actually done was "change how exceptions propagate."&lt;/p&gt;

&lt;p&gt;Before the change, &lt;code&gt;purge()&lt;/code&gt; continued on to the subsequent "clean local work directories older than 30 days" step even when a 5xx came back, because the implementation moved to the next line even when &lt;code&gt;res.ok&lt;/code&gt; was false. Once &lt;code&gt;withRetry&lt;/code&gt; was in and it threw the instant a 5xx appeared, the cleanup code became unreachable. The real damage was a quiet degradation of the kind you only notice if you manually clean periodically: "work directories accumulate 30 days' worth."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;purge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`purge upload failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ここで throw しない — 置き場の失敗は記録して続行する&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;cleanWorkDir&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// 例外の有無にかかわらず必ず走らせる&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other one was &lt;code&gt;setContentSafely&lt;/code&gt;, where the gap between the first and second retry was 0 seconds. Retrying immediately under the same conditions right after a failure caused by resource pressure gives the same result as long as the pressure remains. Earlier I wrote "retry deterministic failures immediately with no sleep" — this is the opposite side of that. &lt;strong&gt;For failures caused by resource pressure, a retry is meaningless unless you wait.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Attempt counts are set asymmetrically according to "what happens when it fails." &lt;code&gt;report()&lt;/code&gt; (result reporting) leaves the user's screen stuck on "running" forever if it fails, so &lt;code&gt;attempts=5&lt;/code&gt;; &lt;code&gt;claim()&lt;/code&gt; (reservation acquisition) gets &lt;code&gt;attempts=3&lt;/code&gt;. A uniform attempt count ignores the asymmetry of cost and damage. Timeouts are also split by the weight of the operation: &lt;code&gt;putObject&lt;/code&gt; and &lt;code&gt;getStream&lt;/code&gt; get 120 seconds, &lt;code&gt;getText&lt;/code&gt; and &lt;code&gt;listKeys&lt;/code&gt; get 30 seconds. A fetch with no timeout configured creates the worst un-retryable state of all: hanging forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  You can't retry when you pass a stream
&lt;/h3&gt;

&lt;p&gt;There's a constraint I wasn't conscious of until I implemented this. To make something retryable, the input has to be re-executable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// NG: ストリームは一度消費したら再生できない&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;retryWithStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// 2回目はストリームが空&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// OK: Buffer か string に限定する&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;retryWithBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// 何度でも同じデータを送れる&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I restricted the body passed to &lt;code&gt;putObject&lt;/code&gt; to Buffer or string. If you don't leave the type constraint in a comment, a caller later passes a stream and you get a bug where "the retry is working but it fails every time." Pass a stream into a retry and every attempt after the first sends an empty body. No error surfaces; it just fails every time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I got stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A constant I placed for protection had become a 0%-success-rate wall
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;outreach-multi&lt;/code&gt;'s DM inbox parsing, an &lt;code&gt;unparsable json&lt;/code&gt; error appeared every day. I suspected a broken selector or a changed JSON structure and kept fixing for three days.&lt;/p&gt;

&lt;p&gt;The reality: the inbox-fetching code had a line reading &lt;code&gt;text.slice(0, 400_000)&lt;/code&gt;. It was an upper bound written to protect memory when a huge response came in. When I measured the actual size of the IG DM inbox, it was &lt;strong&gt;512,558 characters&lt;/strong&gt;. Since the limit is 400,000, it gets cut off every single time. Truncated JSON fails &lt;code&gt;JSON.parse&lt;/code&gt; every single time. Success rate: 0%.&lt;/p&gt;

&lt;p&gt;The reason I stared at the same failure for three days is that I had forgotten about the &lt;code&gt;text.slice&lt;/code&gt; line and read it as "parsing is failing → the structure broke." Nor had the symptom started "today." It quietly started failing on the day the inbox size crossed 400,000 characters, and one day I noticed the running total was 0.&lt;/p&gt;

&lt;p&gt;The fix was to complete &lt;code&gt;JSON.parse&lt;/code&gt; inside &lt;code&gt;page.evaluate&lt;/code&gt; and return only the first 300 characters for diagnostics when it fails.&lt;/p&gt;

&lt;p&gt;The design rule I took from this: "&lt;strong&gt;if you place a limit, log the fact that the limit was hit.&lt;/strong&gt;" &lt;code&gt;text.slice&lt;/code&gt; silently truncates and tells no one. Even when a protective constant has turned into a permanent failure the moment its value was exceeded, nothing is left in the log.&lt;/p&gt;

&lt;h3&gt;
  
  
  The quarantine had become an infinite loop
&lt;/h3&gt;

&lt;p&gt;The unfollow processing in &lt;code&gt;unfollow-core.js&lt;/code&gt; had a mechanism where three failures put an account into a 7-day hold (quarantine).&lt;/p&gt;

&lt;p&gt;The problem is "what happens when it comes out of quarantine." A revived account goes back into the candidate list. Fail three more times, and it goes into another 7-day hold. This spins forever.&lt;/p&gt;

&lt;p&gt;There were 2,605 candidates, and I puzzled over the numbers &lt;code&gt;scanned: 8 / unfollowed: 2&lt;/code&gt; for two weeks. Almost none of the candidates were being processed. When I investigated "why isn't this progressing," the answer was "the same accounts keep entering and leaving quarantine, and nothing moves forward."&lt;/p&gt;

&lt;p&gt;The log said &lt;code&gt;circuit-break&lt;/code&gt;. It's contradictory for circuit-break to be functioning while the execution count doesn't rise. I could have noticed much sooner, but I was reading it as "circuit-break is working = it's protected," so I never questioned it.&lt;/p&gt;

&lt;p&gt;The fix was to physically delete from the ledger any account that reached a cumulative failure count (&lt;code&gt;UNFOLLOW_GIVEUP_FAILS = 6&lt;/code&gt;, twice the quarantine failure threshold).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A "hold" only accumulates; it never drains.&lt;/strong&gt; Unless you place a terminator that discards, the ledger keeps filling up with accounts that can't be processed. That said, before discarding, you first have to confirm "is my own processing working correctly?" There's an inverse incident where the session had merely expired and you discard all the healthy accounts. The principle I wrote on the &lt;code&gt;kotonoha&lt;/code&gt; page — "check your own health before discarding" — applies here too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitoring didn't fire for 39 hours
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;kotonoha&lt;/code&gt; job sat stuck on "generating" for 39 hours. Monitoring never fired once. The job was never recorded as a failure.&lt;/p&gt;

&lt;p&gt;The cause: "a retry that doesn't hold the attempt count in state can't have a cutoff placed on it."&lt;/p&gt;

&lt;p&gt;When a job hits a quota limit, &lt;code&gt;requeue&lt;/code&gt; puts it back to pending. The returned job carried no counter for "which attempt is this." A 7th-attempt job and a 1st-attempt job are indistinguishable from the implementation's point of view. Since &lt;code&gt;requeue&lt;/code&gt; succeeds, no failure is raised. It never hits a monitoring threshold. The user's screen keeps displaying "generating" indefinitely.&lt;/p&gt;

&lt;p&gt;The fix came in two stages. First, "hold the attempt count in state." Then, "place the cutoff on both attempt count and absolute time since intake." When I put the &lt;code&gt;shouldGiveUpForQuota&lt;/code&gt; function shown earlier through tests with boundary cases, I included the actual values from the incident day (intake &lt;code&gt;2026-08-19T00:36:57Z&lt;/code&gt;, scheduled resume &lt;code&gt;06:37:00Z&lt;/code&gt;) as one case. That gives a machine guarantee that the same incident can never get through again.&lt;/p&gt;

&lt;p&gt;With a cutoff on attempt count alone, you let through "only the 1st attempt, but resume is 12 hours away." With time alone, you let through "resume is soon, but the limit hits back to back." This incident got caught on the first attempt, on the time-condition side. It's a case where the value of doubling up was measurable in real data.&lt;/p&gt;

&lt;p&gt;"The worker waits" and "give up on that job" are separate decisions. A quota is a failure that waiting fixes, so the worker waits. But that isn't grounds for making a user wait more than three hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  I snap-judged "auth error" and left it in a human queue for three days
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;com.lily.line-pdca&lt;/code&gt; stopped three days running with Cloudflare's &lt;code&gt;Authentication error [code: 10000]&lt;/code&gt;. Since it was an auth error, I judged "OAuth expired, a manual &lt;code&gt;wrangler login&lt;/code&gt; is needed." I also suspected a missing environment variable.&lt;/p&gt;

&lt;p&gt;Classifying it as "a case requiring human GUI operation" before verifying was the mistake.&lt;/p&gt;

&lt;p&gt;When I actually ran &lt;code&gt;wrangler whoami&lt;/code&gt;, &lt;code&gt;kv key list&lt;/code&gt;, and &lt;code&gt;deploy --dry-run&lt;/code&gt; in both a shell and a minimal launchd environment, all exited 0. Even the output byte counts were identical (1,516 bytes). Re-reading the logs from the failure days, KV keys returned 401 &lt;strong&gt;0.5 seconds after&lt;/strong&gt; an access token was reissued. With that same token, &lt;code&gt;/user&lt;/code&gt; 0.5 seconds later returned 200. It was a &lt;strong&gt;transient 401 right after token reissue&lt;/strong&gt;, and the real root cause was that there was not a single retry.&lt;/p&gt;

&lt;p&gt;The failure rate was 3 days out of 9 (about 33%). It doesn't happen every day, but it does happen once every three days. That frequency was itself part of why it "looked like a human task."&lt;/p&gt;

&lt;p&gt;The criterion that changed my judgment was actually hitting the endpoint to confirm "would the same procedure pass if I ran it again right now?" Don't classify something as "unfixable, period" based only on the kind of error (401, Authentication error). Same pattern as the "429 recorded under seven names" documented in &lt;code&gt;transient-failure-recorded-as-permanent.md&lt;/code&gt; — this time the name "auth error" made a transient failure look like a human task.&lt;/p&gt;

&lt;p&gt;Since this incident, the rule changed to: when an auth error appears, run one probe before deciding. The premise behind "don't pile machine-fixable things onto a human work queue" is "I hit it with a machine and checked." Snap-judging without checking is no different from declaring "it won't be fixed" without ever trying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;Here are the structural patterns behind the individual cases above. Only the "name of the place I got stuck" differs; the root is the same pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compressing a failure into "a single name" when recording it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;watch-arb&lt;/code&gt;'s product pages were recorded as &lt;code&gt;extract_failed&lt;/code&gt;, the bootstrap run as &lt;code&gt;FATAL&lt;/code&gt;, and the DM inbox as &lt;code&gt;unparsable json&lt;/code&gt;. All three had HTTP 429 as the actual root cause. Organizing this phenomenon, recorded in &lt;code&gt;transient-failure-recorded-as-permanent.md&lt;/code&gt;, the same 429 was left under 7 different names within the same period. When the HTTP status disappears at the naming step, you can no longer judge whether it's "waiting will fix it" or "the structure needs fixing." The fix is one line. Before recording, classify: &lt;code&gt;429/503 → rate_limited&lt;/code&gt; / &lt;code&gt;401/403 → auth_error&lt;/code&gt; / &lt;code&gt;everything else → extract_failed&lt;/code&gt;. This three-way branch is the precondition sitting in front of the wait/give-up/feed-back branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Throwing away the error body&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;ai-portraits&lt;/code&gt;' &lt;code&gt;generate.py&lt;/code&gt; discarded codex exec's stdout/stderr, the reason no image was produced was recorded only as &lt;code&gt;missing&lt;/code&gt;. If you can see neither &lt;code&gt;moderation_blocked&lt;/code&gt; nor &lt;code&gt;safety_violations: ["sexual"]&lt;/code&gt;, you can't judge whether a tier downgrade will recover it or whether you should cut it off immediately at the input stage. In an implementation that doesn't retain errors, the three sections of "wait / give up / feed back" can't exist in the first place, so error retention is a design precondition, not a feature addition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trying every tier without checking &lt;code&gt;moderation_stage&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Concepts that stopped with &lt;code&gt;moderation_stage: input&lt;/code&gt; were tried through all stages from T1 to T3 and lost all 9 attempts. Ones that stopped with &lt;code&gt;moderation_stage: output&lt;/code&gt; have a track record of passing with a tier downgrade (&lt;code&gt;partial 01_feet_up_window&lt;/code&gt; at T2, &lt;code&gt;03_legs_on_bed_topdown&lt;/code&gt; at T3). A single word in T1's &lt;code&gt;moderation_stage&lt;/code&gt; field determines whether climbing down the ladder is worth anything, yet trying every stage without reading the field wastes up to 21 minutes per concept — up to 63 minutes for three. Judge "is it worth climbing down based on the first-stage error body" before "how many safe-side fallback stages are there."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wiring a fallback without measuring the destination's capacity&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before adding a fallback in &lt;code&gt;social-autolike&lt;/code&gt;'s &lt;code&gt;follow-source-followers&lt;/code&gt;, I first measured that the UI modal's following side grows to 408 entries in 63 seconds while the followers side stops at 12. That's why I adopted only the side with capacity as the fallback. Wire it without measuring and all the traffic flows to a destination with no capacity, producing "I added a fallback but the success rate didn't change." &lt;code&gt;outreach-multi&lt;/code&gt;'s pk resolution likewise builds in the known wall of IG cutting off around 70 entries, limiting to 30 per run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Counting a permanent failure against the "one more try" budget&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;outreach-multi&lt;/code&gt;'s DM sending, when &lt;code&gt;no メッセージ button&lt;/code&gt; (DM-disabled setting) came back, it rode the &lt;code&gt;exhausted after 3 attempts&lt;/code&gt; path and consumed 3 attempts' worth of send budget per recipient. The other party's account settings don't change no matter how much we retry. Read the &lt;code&gt;reason&lt;/code&gt; field in &lt;code&gt;sent.jsonl&lt;/code&gt;, put recipients containing &lt;code&gt;no メッセージ button&lt;/code&gt; / &lt;code&gt;profile gone&lt;/code&gt; / &lt;code&gt;このページは存在しません&lt;/code&gt; into a &lt;code&gt;permanentlyFailed&lt;/code&gt; Set, and place the &lt;code&gt;shouldSkip()&lt;/code&gt; check ahead of &lt;code&gt;exhausted&lt;/code&gt;. This shares a root with the "inverse incident of recording a transient failure as permanent" mentioned earlier: &lt;strong&gt;the design of failure classification is needed before the design of retry counts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuing to hit at the same rate after a 429&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;social-autolike&lt;/code&gt;'s ig-3 fired 17 sources within a few seconds at 0.3–0.5 second intervals and produced zero results over 14 hours. If you don't change the interval after receiving a 429, you pile up the same requests during the limit and extend your own throttling. The fix recorded in &lt;code&gt;retry-and-giveup-design.md&lt;/code&gt; includes a latch that cuts off that process's API calls after 3 consecutive 429s, plus a design that drops &lt;code&gt;concurrency&lt;/code&gt; to 1 for any shop that has seen a 429 even once. Slowing down the rate itself comes as a set with waiting before retrying — not instead of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries stacked in two layers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;watch-arb&lt;/code&gt;'s &lt;code&gt;ingestShop&lt;/code&gt; had its own 3-attempt loop, and &lt;code&gt;req()&lt;/code&gt; had a 3-attempt loop as well. Worst case 3×3 = 9 attempts, with "retry attempt 3/3" appearing twice in the log, making it unreadable where the limit is and which attempt you're on. Even with fault injection in place, you can no longer tell "which layer is running." The fix was to delete &lt;code&gt;ingestShop&lt;/code&gt;'s own loop and consolidate onto the single layer in &lt;code&gt;req()&lt;/code&gt;. The caller just throws immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Counting fallback successes toward the cutoff counter&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The latch that "cuts off API calls after 3 consecutive 429s" was also counting cases where the UI fallback succeeded. Even with an escape route, it stopped at 3 items. The cutoff counter should increment only "when every path failed." Cases that succeeded via the fallback shouldn't be logged against the main failure count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Judging "couldn't fetch (abnormal)" and "had no time to fetch (a normal cutoff)" by the same zero count&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;follow-source-followers&lt;/code&gt; uses up its time budget on source fetching alone and ends with zero candidates, it falls into the existing "all sources failed → halt to prevent mis-operation" path. "Couldn't fetch" and "just ran out of time" are separate events. Judge them by the same zero count and you get false alarms every day and lose the ability to tell real failures apart. Prepare a separate exit called &lt;code&gt;time-budget&lt;/code&gt; to distinguish them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardcoding the give-up predicate inside the &lt;code&gt;catch&lt;/code&gt; block&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the cutoff conditions are written inside a &lt;code&gt;catch&lt;/code&gt;, you can't put them into boundary-case tests. Pull them into a pure function and you can add one case with the actual values from the incident day, giving a machine guarantee that the same condition never gets through again. As long as the logic lives inside the &lt;code&gt;catch&lt;/code&gt;, the test passes in a state where "the case just happened not to be exercised."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error messages carrying no machine-readable information&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Throw a &lt;code&gt;ValidationError&lt;/code&gt; with only a string and you can't extract "what was wrong" from code at retry time. The reason throwing the same prompt from the second attempt onward gives the same result is that "feed back" isn't implemented; put on a structure like &lt;code&gt;code: "title-too-long"&lt;/code&gt;, &lt;code&gt;actual: 63&lt;/code&gt;, &lt;code&gt;limit: 58&lt;/code&gt; and you can assemble correction instructions dynamically. When returning to the LLM, pass a human-language instruction and the measured values, not the identifier (&lt;code&gt;"title-too-long"&lt;/code&gt;). Passing the identifier through as-is loses three times in a row.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;p&gt;Here are the rules distilled from the five-lane simultaneous outage. There are a lot of them, but every one is something I added because I actually got stuck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① Classify failures by HTTP status and exception type before recording them&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;429/503 → rate_limited&lt;/code&gt; / &lt;code&gt;401/403 → auth_error&lt;/code&gt; / everything else → processing error. Just placing this three-way branch first changes the meaning of the "list of failing shops" shown on the dashboard. After adding this classification, &lt;code&gt;watch-arb&lt;/code&gt; also split the wording of shops.last_error into "couldn't fetch due to rate limiting" and "can't extract the structure."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② When you write a retry, put fault injection in the same commit&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_failOnceRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WA_API_FAIL_ONCE&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// 起動後の最初のN回だけ合成のエラーを投げる&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The completion criteria are a set of two. With &lt;code&gt;=2&lt;/code&gt;, &lt;code&gt;retry attempt=1/3&lt;/code&gt; and &lt;code&gt;2/3&lt;/code&gt; appear and the run completes at exit 0; with it unset, not a single retry log line appears. Without ②, you can't confirm with the same command that "the fault injection hasn't leaked into production."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Place the cutoff on both "attempt count" and "absolute time since intake"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Attempt count alone lets through "only the 1st attempt, but resume is 12 hours away." Absolute time alone lets through "resume is soon, but the limit hits back to back." The &lt;code&gt;kotonoha&lt;/code&gt; incident got caught on the first attempt, on the time-condition side (intake &lt;code&gt;2026-08-19T00:36:57Z&lt;/code&gt;, resume &lt;code&gt;06:37:00Z&lt;/code&gt;, outside intake+3 hours). A case where the value of doubling up was measurable in real data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;④ Decide the total wait ceiling in seconds first, then back-calculate the attempt count&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;gsheets_retry.py&lt;/code&gt;, I fixed "never exceed 8 seconds total" in the request text first, then back-calculated 2s → 6s (3 attempts). If you fix exponential backoff by attempt count first, the caller's slot time gets quietly eroded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑤ Throw immediately on 401/403/404 — don't hide them in a retry&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RETRYABLE_HTTP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt;  &lt;span class="c1"&gt;# 権限エラーは即 raise
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap a permission error in a retry and only "failed 3 times" remains, burying the real cause of "no permission." Same structure as the "429 masquerading as &lt;code&gt;extract_failed&lt;/code&gt;" recorded in &lt;code&gt;transient-failure-recorded-as-permanent.md&lt;/code&gt; — this time a 401 masquerades as "sometimes slow."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑥ Use &lt;code&gt;moderation_stage&lt;/code&gt; to judge whether climbing down the ladder is worth it before trying tiers&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moderation_stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input段の拒否。残りTierをbreak&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding one line turns 63 minutes of spinning into 0. Ones that stopped at output are worth running the ladder for. Ones that stopped at input get discarded concept and all, and you move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑦ Consolidate retry layers into one place&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the caller has its own loop and the library also retries, you get up to N×M attempts. When you find duplication, delete the outer loop and consolidate onto the single layer in &lt;code&gt;req()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑧ Measure the destination's capacity before wiring it up&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before adding a fallback, confirm by measurement "how many entries this destination returns" and "where it jams." Routing all traffic to a destination with no capacity won't change the success rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑨ Record permanent failures in a persistent list once and exclude them&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Failures rooted in the other party's settings, like &lt;code&gt;no メッセージ button&lt;/code&gt; / &lt;code&gt;profile gone&lt;/code&gt;, should be excluded before they consume retry attempts. Place the &lt;code&gt;shouldSkip()&lt;/code&gt; check ahead of &lt;code&gt;exhausted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑩ Actually hit it to check "would it pass if I ran it again right now?" before classifying it as a human task&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I snap-judged Cloudflare's &lt;code&gt;Authentication error&lt;/code&gt; as "OAuth expired" and left it in a human queue for three days. Running probes in both a shell and a minimal launchd environment, all exited 0. Don't classify something as "unfixable, period" from the name of the error alone. Check, then judge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑪ Log the fact that a limit constant was hit&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;text.slice(0, 400_000)&lt;/code&gt; just silently truncated. Even when a protective constant becomes the trigger for a permanent failure, nothing is left in the log. Complete &lt;code&gt;JSON.parse&lt;/code&gt; internally and return the first 300 characters for diagnostics only when it fails, and the fact that "it got cut off" survives in the record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑫ After introducing retries, check how the range of exception propagation changed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;withRetry&lt;/code&gt; can make required downstream processing unreachable where the behavior changed from "swallow" to "throw." A retry isn't a feature addition; it's a change to how exceptions propagate. Where there's something downstream that "must happen even on failure," wrap it individually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑬ For failures caused by resource pressure, wait before retrying&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;setContentSafely&lt;/code&gt;'s retry interval at zero seconds, it fails again under the same conditions right after failing under high load. Immediate retry with no sleep is correct for deterministic failures, but on the flip side, a retry is meaningless unless you wait for resources. Choose where the sleep goes based on the kind of failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑭ Pull the give-up predicate into a pure function and put the incident day's actual values into the tests&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Extract it into &lt;code&gt;shouldGiveUpForQuota({quotaHits, createdAtMs, resumeAtMs})&lt;/code&gt; and add intake &lt;code&gt;2026-08-19T00:36:57Z&lt;/code&gt; / resume &lt;code&gt;06:37:00Z&lt;/code&gt; as one case, and the same incident can never get through again. A predicate hardcoded inside a &lt;code&gt;catch&lt;/code&gt; block can't have its boundary cases tested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑮ Leave the fact that you waited, gave up, or cut off on a single log line&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;waited=600s SKIP: slot timeout&lt;/code&gt; and &lt;code&gt;SKIP: global limit reached&lt;/code&gt; (immediate discard) are different events. If both look like the same "SKIP," you can't distinguish afterward whether "slot contention increased" or "it was discarding immediately all along." Emit the wait time you invested as a number in the log.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;When you write "failed, so try again," that code has no answers to three questions. Will waiting fix it? When do you cut it off? What do you change before the next attempt? An implementation missing these three burns resources in front of a deterministic failure and stalls.&lt;/p&gt;

&lt;p&gt;What I noticed when I lined them up on the morning five lanes went down together: whether I have to be personally involved in incident recovery is almost entirely determined by whether this design is present. With it, the script decides its own next action per kind of failure. Without it, the same failure just gets recorded three times, and the next morning it's stuck in the same place.&lt;/p&gt;

&lt;p&gt;An automation environment runs every day not because I write code every day. It works because failures are classified correctly and the machine recovers on its own. From that premise, retry design isn't "a feature you add later" — it belongs in the first implementation.&lt;/p&gt;

&lt;p&gt;"I wrote the retry" counts as done when it's "I watched it run with fault injection."&lt;/p&gt;




&lt;p&gt;What's the failure in your own automation that's been recorded under the wrong name the longest?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>python</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How My Own Script Killed Instagram Sessions Every Night: pkill and a Shared Chrome Profile</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sun, 30 Aug 2026 05:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared-chrome-profile-3df5</link>
      <guid>https://dev.to/bokuwalily/how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared-chrome-profile-3df5</guid>
      <description>&lt;p&gt;I built a fleet of 171 automation jobs to grow my business — and one of them was quietly murdering another one's login session every single night for days before I figured out why.&lt;/p&gt;

&lt;p&gt;Here's the arc: I made ¥100k/month as a university student, stacked side jobs up to ¥600k/month, got laid off and went back to zero, then spent six months building an autonomous Claude Code environment that now does ¥1.2M/month in revenue. The Instagram account underpinning that revenue &lt;strong&gt;was being killed daily by a script I wrote myself&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Setup Matters
&lt;/h2&gt;

&lt;p&gt;When you're building up revenue as a solo developer, the first wall you hit isn't "not enough hands" — it's "&lt;strong&gt;I have no idea what's happening while I sleep&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;In my current environment, 171 jobs launch automatically from launchd every day. Posting, liking, following, unfollowing, and DMs across X / Instagram / Threads / TikTok are all automated, and a Bash script called &lt;code&gt;sns-output-watchdog.sh&lt;/code&gt; monitors whether each one actually produced output that day, based on artifact logs.&lt;/p&gt;

&lt;p&gt;But before I built that monitoring, I have a track record of &lt;strong&gt;not noticing for two weeks&lt;/strong&gt;. The header comment of &lt;code&gt;sns-output-watchdog.sh&lt;/code&gt; says this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# content-watchdog.sh は article/note/maker/series/ameba だけを見ており、
# X/IG/TikTok/Threads の投稿・返信は誰も監視していなかった。その結果
# IG投稿は7/29から、TikTok投稿は7/28から止まったまま2週間気づかれなかった。
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If IG posting stops, new followers stop coming in, the flow into my official LINE account stops, and revenue growth eventually flattens. A single failed slot doesn't mean "I missed one post today" — it means "the entire funnel that was supposed to start there was dead."&lt;/p&gt;

&lt;p&gt;Even after I set up monitoring, the next problem showed up: &lt;strong&gt;exit 0 and "something actually happened" are different things&lt;/strong&gt;. In measurements on 2026-08-08, the IG like lane &lt;code&gt;ig-1&lt;/code&gt; finished with exit 0 and zero likes on 8 out of 12 daily runs. The watchdog at the time only counted "how many times the marker &lt;code&gt;終了 (&lt;/code&gt; appeared," so it judged both circuit-break and need-login as "healthy."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🔴 従来は '終了 (' の出現回数だけを数えていたため、circuit-break でも need-login でも&lt;/span&gt;
&lt;span class="c"&gt;#    「健全」と判定していた。2026-08-08 実測で ig-1 は12run中8回、tt-1/tt-2 は6割が&lt;/span&gt;
&lt;span class="c"&gt;#    いいね0件のまま exit 0 で終わっており、2週間誰も気づけなかった。&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Learning from that, I added &lt;code&gt;count_dead_runs()&lt;/code&gt; to detect runs with zero output, and &lt;code&gt;sum_likes_today()&lt;/code&gt; to total the actual like count. But that still didn't solve the structural problem: &lt;strong&gt;the side doing the breaking and the side reporting the breakage lived in different repositories&lt;/strong&gt;. That's the 2026-08-24 incident. If you run multiple automations on the same Mac, you're at risk of falling into exactly the same hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Overall Picture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Shared Resource Across Repositories
&lt;/h3&gt;

&lt;p&gt;To lay out the problem, here's the structure as a diagram.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scent-media リポジトリ                social-autolike リポジトリ
──────────────────────────            ──────────────────────────────────
scripts/ensure_chrome.sh              config/accounts.json
  └ CDP :9223 へ接続試行                └ "reuseProfile": ".profiles/chrome-ig"
    応答なし → pkill 実行
         │                            th-scent ジョブ（Playwright）
         │                              └ .profiles/chrome-ig を掴んで起動
         │                                ※ CDP 9223 は一切開かない
         ↓                                         ↓
         └──────────────┬───────────────────────────┘
                        ↓
             .profiles/chrome-ig  ← 両者が同じプロファイルを参照
             Cookie SQLite 強制破壊
             instagram.com の sessionid → 0行
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ensure_chrome.sh&lt;/code&gt; in &lt;code&gt;scent-media&lt;/code&gt; checks, before an IG post, whether Chrome is in a CDP-controllable state. The check is an attempted connection to port 9223. If there's no response, it decides "Chrome is dead" and force-kills it with this command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pkill &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$PROFILE&lt;/code&gt; is &lt;code&gt;.profiles/chrome-ig&lt;/code&gt;. &lt;strong&gt;The problem is that the &lt;code&gt;th-scent&lt;/code&gt; job in &lt;code&gt;social-autolike&lt;/code&gt; shared that same profile via a &lt;code&gt;reuseProfile&lt;/code&gt; setting.&lt;/strong&gt; &lt;code&gt;th-scent&lt;/code&gt; launches Chrome through Playwright, but it never opens CDP port 9223. From &lt;code&gt;ensure_chrome.sh&lt;/code&gt;'s point of view, "the Chrome that th-scent is using" always looks like "9223 is closed = a dead process."&lt;/p&gt;

&lt;h3&gt;
  
  
  A Collision at the Same Time Every Day
&lt;/h3&gt;

&lt;p&gt;This collision wasn't random — &lt;strong&gt;the launchd schedule made it happen deterministically at fixed times every day&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5:18 / 13:18 / 21:18&lt;/td&gt;
&lt;td&gt;th-scent autolike&lt;/td&gt;
&lt;td&gt;up to 40 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6:56 / 14:56 / 22:56&lt;/td&gt;
&lt;td&gt;th-scent unfollow&lt;/td&gt;
&lt;td&gt;up to 40 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7:20&lt;/td&gt;
&lt;td&gt;scent-media daily-generate&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19:00 / 21:00 / 23:00&lt;/td&gt;
&lt;td&gt;scent-media daily-post (IG carousel post)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 22:56, &lt;code&gt;th-scent&lt;/code&gt; unfollow grabs &lt;code&gt;.profiles/chrome-ig&lt;/code&gt;; at 23:00, &lt;code&gt;daily-post&lt;/code&gt; calls &lt;code&gt;ensure_chrome.sh&lt;/code&gt;. 9223 doesn't respond, so pkill runs. This repeated every night. The 19:00 and 21:00 slots overlap with th-scent autolike in the same way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Destroying the Cookie SQLite
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pkill -f "user-data-dir=..."&lt;/code&gt; sends SIGTERM and then SIGKILL. Chrome can't complete its shutdown routine and loses the chance to write the profile's Cookie SQLite back in a consistent state.&lt;/p&gt;

&lt;p&gt;On next launch, Chrome &lt;strong&gt;recreates an empty Cookie DB&lt;/strong&gt;. Here are the measured values:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;code&gt;cookies&lt;/code&gt; table shrank to 3 rows total across all hosts, and &lt;code&gt;instagram.com&lt;/code&gt; had 0 cookies. The IG &lt;code&gt;sessionid&lt;/code&gt; was gone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On 2026-08-24, all three slots — 19:00, 21:00, and 23:00 — posted nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the Watchdog Misdiagnosed It as "Logged Out"
&lt;/h3&gt;

&lt;p&gt;The IG post check in &lt;code&gt;sns-output-watchdog.sh&lt;/code&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;check &lt;span class="s2"&gt;"ig-autopost"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CL_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/sns-ig-autopost.retry.log"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_JST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'OK posted'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  1 &lt;span class="s2"&gt;"IGカルーセル投稿"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CL_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/sns-ig-autopost.retry.log"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_JST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;OK posted&lt;/code&gt; doesn't appear even once in the day's log, the lane goes into &lt;code&gt;FAILED&lt;/code&gt; and &lt;code&gt;🚨 SNS当日未出力: ig-autopost&lt;/code&gt; fires to Discord. That alert &lt;em&gt;was&lt;/em&gt; firing. &lt;strong&gt;But the text of the alert says "posting failed," not "session destroyed by pkill."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The session checker correctly reports "there is no IG sessionid." &lt;code&gt;need-login&lt;/code&gt; shows up in the log. A human reads that and concludes "the login expired → let's log in again." The next day, at the same time, pkill runs again and the cookies vanish. This loop kept repeating.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;watchdog: 🚨 ig-autopost 未出力
    ↓
session-liveness: sessionid が無い（正しい報告）
    ↓
人間: GUI 再ログインを実行
    ↓
翌日 22:56: th-scent がプロファイルを掴む
    ↓
翌日 23:00: ensure_chrome.sh → pkill → Cookie 消滅
    ↓
watchdog: 🚨 ig-autopost 未出力（同じ報告が出る）
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;strong&gt;the side doing the breaking (&lt;code&gt;ensure_chrome.sh&lt;/code&gt; / scent-media) and the side reporting the breakage (retry.log / the session checker) live in different repositories&lt;/strong&gt;, reading only one of them will never connect the dots. The &lt;code&gt;reuseProfile&lt;/code&gt; setting written in &lt;code&gt;social-autolike&lt;/code&gt;'s &lt;code&gt;config/accounts.json&lt;/code&gt; appears nowhere in &lt;code&gt;scent-media&lt;/code&gt;'s code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Skeleton of the Fix
&lt;/h3&gt;

&lt;p&gt;The fix comes down to one thing: &lt;strong&gt;eliminate pkill entirely and replace it with waiting and skipping&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 修正後の ensure_chrome.sh（概要）&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="c"&gt;# 他プロセスが掴んでいる → 10秒間隔・最大420秒ポーリング&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 42&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;10
    pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;break
  &lt;/span&gt;&lt;span class="k"&gt;done
  if &lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"他ジョブが使用中(pid=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;)。Cookie破壊を避けるため起動を見送る"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;2   &lt;span class="c"&gt;# 「今スロット見送り」の専用コード&lt;/span&gt;
  &lt;span class="k"&gt;fi
fi&lt;/span&gt;
&lt;span class="c"&gt;# ここまで来たら誰も掴んでいない → SingletonLock等の掃除 → 起動処理へ&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller, &lt;code&gt;daily_post.sh&lt;/code&gt;, receives &lt;code&gt;exit 2&lt;/code&gt; in a separate branch and treats it as "skip this slot, retry at the next one," exiting with &lt;code&gt;exit 0&lt;/code&gt;. For verification I ran two &lt;code&gt;bash -n&lt;/code&gt; syntax checks, confirmed &lt;code&gt;grep -c pkill&lt;/code&gt; returned 0, and confirmed the &lt;code&gt;exit 2&lt;/code&gt; path really exists at line 57, then bundled it into commit &lt;code&gt;15307d7&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why I didn't fold &lt;code&gt;exit 2&lt;/code&gt; into "error," what the "3 slots per day" premise behind the skip design means, and how I reworked the watchdog so it won't have the same structural blind spot again — I break all of that down in the next part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why You Must Never Mix &lt;code&gt;count_today()&lt;/code&gt; and &lt;code&gt;has_today()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The first thing you agonize over when writing a watchdog is distinguishing "zero count = failure" from "it just didn't run." In &lt;code&gt;sns-output-watchdog.sh&lt;/code&gt; I handle this with two independent functions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 当日分のログ行だけに成功マーカーがあるか数える。&lt;/span&gt;
count_today&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;daymark&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;marker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  /usr/bin/awk &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;day&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$daymark&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;mark&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$marker&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'
    index($0, day) { seen = 1 }
    seen &amp;amp;&amp;amp; index($0, mark) { n++ }
    END { print n + 0 }
  '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# そのログに「当日を示す行」自体があるか。&lt;/span&gt;
has_today&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;daymark&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  /usr/bin/grep &lt;span class="nt"&gt;-qF&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$daymark&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;count_today()&lt;/code&gt; uses awk's &lt;code&gt;seen&lt;/code&gt; flag so that "only lines after the day marker appears" are considered. A simple &lt;code&gt;grep -c marker&lt;/code&gt; would mix in success lines from previous days. The logs are designed not to rotate, so if this one-day offset breaks, you get "today judged healthy based on yesterday's post count."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;has_today()&lt;/code&gt; matters because of the branching inside &lt;code&gt;check()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;check&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;lane&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;note&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$4&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;5&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;daymark&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;6&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="c"&gt;# ...&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$min&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;log &lt;span class="s2"&gt;"ok lane=&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt; count=&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return
  fi
  if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; has_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$daymark&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;UNKNOWN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;UNKNOWN&lt;/span&gt;:+&lt;span class="nv"&gt;$UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    log &lt;span class="s2"&gt;"UNKNOWN lane=&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt; 当日行なし"&lt;/span&gt;
    &lt;span class="k"&gt;return
  fi
  &lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;:+&lt;span class="nv"&gt;$FAILED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the count falls below min, if &lt;code&gt;has_today()&lt;/code&gt; returns false the lane goes into UNKNOWN rather than FAILED. If you conflate the two, a lane that only runs three times a week will emit UNHEALTHY every day — Monday, Wednesday, and Friday included. The moment alerts stop being trusted, your monitoring is finished.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;count_dead_runs()&lt;/code&gt; — Catching Runs That Finished but Produced Nothing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;count_dead_runs&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;day&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  /usr/bin/grep &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="nv"&gt;$day&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | /usr/bin/grep &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'終了 \((need-login|circuit-break|error|rate-limit)\)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | /usr/bin/grep &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'いいね:0'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a reason this is a two-stage grep. The first regex, &lt;code&gt;終了 \((need-login|circuit-break|error|rate-limit)\)&lt;/code&gt;, narrows to "lines with a harmful termination reason," and the second, &lt;code&gt;grep -c 'いいね:0'&lt;/code&gt;, narrows to "and zero output." If you counted a run that terminated early on &lt;code&gt;rate-limit&lt;/code&gt; but still managed a few likes as a "dead run," you'd get an alert every time a mild nighttime rate limit kicks in. The point is to AND the two conditions to isolate "genuinely accomplished nothing."&lt;/p&gt;

&lt;p&gt;This function is used in the outcome-based monitoring loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;lane &lt;span class="k"&gt;in &lt;/span&gt;x-1 ig-1 ig-2 ig-3 ig-sug th-1 th-2 tt-1 tt-2 tt-3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;lf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SA_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;.log"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lf&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;likes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;sum_likes_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lf&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_UTC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;dead&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_dead_runs &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lf&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_UTC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$likes&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$likes&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;:+&lt;span class="nv"&gt;$FAILED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;-likes0"&lt;/span&gt;
    log &lt;span class="s2"&gt;"UNHEALTHY lane=&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt; 本日のいいね合計=0 (成果ゼロrun=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dead&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;回)"&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dead&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dead&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 3 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;:+&lt;span class="nv"&gt;$FAILED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;-dead&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dead&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    log &lt;span class="s2"&gt;"UNHEALTHY lane=&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt; 成果ゼロrunが&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dead&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;回 (いいね合計=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;likes&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;log &lt;span class="s2"&gt;"OK lane=&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt; いいね合計=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;likes&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; 成果ゼロrun=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dead&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;回"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The branch differs between &lt;code&gt;likes&lt;/code&gt; being an empty string (the file itself doesn't exist) and being &lt;code&gt;"0"&lt;/code&gt;. In the empty case it simply skips to the next lane rather than reporting UNKNOWN. That subtle distinction is what keeps "a lane not yet promoted to monitored status" from being confused with "a monitored lane that isn't running."&lt;/p&gt;

&lt;h3&gt;
  
  
  The Three-Stage Pipeline of &lt;code&gt;sum_likes_today()&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sum_likes_today&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;day&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  /usr/bin/grep &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="nv"&gt;$day&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | /usr/bin/grep &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'いいね:[0-9]+'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | /usr/bin/grep &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'[0-9]+'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | /usr/bin/awk &lt;span class="s1"&gt;'{s+=$1} END {print s+0}'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log lines start with the &lt;code&gt;2026-08-24 ...&lt;/code&gt; format, so stage one narrows to today's lines with &lt;code&gt;"^$day"&lt;/code&gt;. Stage two extracts marker-prefixed numbers with &lt;code&gt;grep -oE 'いいね:[0-9]+'&lt;/code&gt;, and stage three strips down to the bare digits and sums them with awk. The reason for the intermediate grep is that log lines can contain multiple markers, like &lt;code&gt;いいね:0 フォロー:3&lt;/code&gt; — pulling with just &lt;code&gt;[0-9]+&lt;/code&gt; would also pick up the &lt;code&gt;3&lt;/code&gt; from &lt;code&gt;フォロー:3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;フォロー&lt;/code&gt; total uses the same structure in a separate function, &lt;code&gt;sum_follows_today()&lt;/code&gt;. Likes and follows are independent outcomes, and there are cases where likes are zero but follows are working, so they must always be judged separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  ensure_chrome.sh — From Killing pkill to a Skip Design
&lt;/h3&gt;

&lt;p&gt;Here's the core of the fix in real code. Before the fix, it tried to solve everything in one line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 修正前：CDP 9223 が応答しない → 無条件で殺す&lt;/span&gt;
pkill &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the fix, it's three stages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 修正後&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="c"&gt;# 他プロセスが掴んでいる → 10秒間隔・最大420秒ポーリング&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 42&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;10
    pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;break
  &lt;/span&gt;&lt;span class="k"&gt;done
  if &lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"他ジョブが使用中(pid=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;)。Cookie破壊を避けるため起動を見送る"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;2
  &lt;span class="k"&gt;fi
fi&lt;/span&gt;
&lt;span class="c"&gt;# ここまで来たら誰も掴んでいない → SingletonLock 等の掃除 → 起動処理へ&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason I didn't fold &lt;code&gt;exit 2&lt;/code&gt; into "error" (&lt;code&gt;exit 1&lt;/code&gt;) rests on a design fact. IG carousel posting has three slots a day: 19:00, 21:00, and 23:00. Skipping one slot still leaves the next slot to post the same content. With &lt;code&gt;exit 1&lt;/code&gt;, on the other hand, Discord alerts would keep firing every time the th-scent collision window comes around, and genuinely abnormal alerts would drown in the noise. &lt;strong&gt;"Skipped" is a third state that is neither failure nor success&lt;/strong&gt;, and representing it with a dedicated exit code lets the caller, &lt;code&gt;daily_post.sh&lt;/code&gt;, receive it in an independent branch.&lt;/p&gt;

&lt;p&gt;It also matters that the &lt;code&gt;SingletonLock&lt;/code&gt; cleanup now happens &lt;em&gt;only when nobody is confirmed to be holding the profile&lt;/em&gt;. Before the fix, it unconditionally ran &lt;code&gt;rm -f SingletonLock SingletonSocket SingletonCookie&lt;/code&gt; right after pkill. If you delete lock files while someone is using the profile, the process using it crashes and leaves the profile in a half-broken state.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "I Was Watching err.log" — Two Weeks of Daily False UNKNOWNs
&lt;/h3&gt;

&lt;p&gt;My first watchdog implementation pointed the IG post check at the wrong file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🔴 誤り：err.log は 2026-08-09 で更新が止まっている&lt;/span&gt;
check &lt;span class="s2"&gt;"ig-autopost"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CL_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/sns-ig-autopost.err.log"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_JST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'OK posted'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In reality, post success/failure logs go to &lt;code&gt;retry.log&lt;/code&gt;, and &lt;code&gt;err.log&lt;/code&gt; had stopped updating after 2026-08-09. &lt;code&gt;has_today()&lt;/code&gt; kept returning false, and every morning &lt;code&gt;⚠️ SNS監視が判定不能: ig-autopost（当日行がログに無い）&lt;/code&gt; arrived in Discord.&lt;/p&gt;

&lt;p&gt;Judged by symptoms alone, it looks like "the ig-autopost log is broken." The log wasn't actually broken — I just had the filename wrong. It took me two days to find the cause. I only noticed after checking the file's mtime with &lt;code&gt;ls -la&lt;/code&gt;. The comment is still in the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 成果は err.log ではなく retry.log に出る。err.log は 2026-08-09 で更新が止まっており、&lt;/span&gt;
&lt;span class="c"&gt;# ここを見ている限り毎日 UNKNOWN 誤報になる(実際は当日3本投稿できていた)。&lt;/span&gt;
check &lt;span class="s2"&gt;"ig-autopost"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CL_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/sns-ig-autopost.retry.log"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_JST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'OK posted'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A monitoring script that reads log files can't notice "the file went stale = monitoring is dead" unless there's a &lt;strong&gt;mechanism that periodically checks the mtime of the files it reads&lt;/strong&gt;. Receiving false alarms for two weeks is the same state as receiving no alerts at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Only TikTok Has a Different Date Format
&lt;/h3&gt;

&lt;p&gt;TikTok's log inherited the format launchd emits, so its date format differs from the other lanes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# X・IG・Threads: TODAY_UTC = "2026-08-24"&lt;/span&gt;
&lt;span class="c"&gt;# TikTok だけ: TODAY_HUMAN = "Mon Aug 24" （%e で日を空白詰め → "Mon Aug  8"）&lt;/span&gt;
&lt;span class="nv"&gt;TODAY_HUMAN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%a %b %e'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;%e&lt;/code&gt; pads the day with a space, so August 8th becomes &lt;code&gt;Aug  8&lt;/code&gt; (two spaces). Grepping TikTok's log with &lt;code&gt;"^$TODAY_UTC"&lt;/code&gt; never matched the date format at all, so it always returned 0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;tt_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CL_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/tiktok-bokuwalily.log"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_HUMAN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'run end (exit 0)'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;tt_maybe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CL_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/tiktok-bokuwalily.log"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_HUMAN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'run end (exit 2)'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
check &lt;span class="s2"&gt;"tt-autopost"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;tt_ok &lt;span class="o"&gt;+&lt;/span&gt; tt_maybe&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"TikTok投稿(exit0=&lt;/span&gt;&lt;span class="nv"&gt;$tt_ok&lt;/span&gt;&lt;span class="s2"&gt; exit2=&lt;/span&gt;&lt;span class="nv"&gt;$tt_maybe&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CL_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/tiktok-bokuwalily.log"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_HUMAN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is just splitting out a separate variable that uses &lt;code&gt;TODAY_HUMAN&lt;/code&gt;, but the reason I got stuck finding the cause is that "since every other lane was working fine, the TikTok problem looked like a TikTok-side defect." Format inconsistencies inside a script are invisible unless you line them up against the working lanes and compare.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Zero Follow Successes, 20 Undecidable" — The friendship API Throttle
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;count_undecidable_today()&lt;/code&gt;, which I added on 2026-08-22, was born from this lesson.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;count_undecidable_today&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;day&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"0 0"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  /usr/bin/awk &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;day&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$day&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'
    index($0, day) &amp;amp;&amp;amp; index($0, "follow成功") { success++ }
    index($0, day) &amp;amp;&amp;amp; index($0, "follow判定不能") { undecidable++ }
    END { print success + 0, undecidable + 0 }
  '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The friendship API is Instagram's private API, used to check follow relationships. When it gets throttled, "is this person following me back?" becomes undecidable, and the job records &lt;code&gt;follow判定不能&lt;/code&gt; and moves on. Likes are working fine, so the &lt;code&gt;sum_likes_today()&lt;/code&gt; check passes. From the outside, "ig-1 is healthy again today."&lt;/p&gt;

&lt;p&gt;The actual symptom was &lt;code&gt;2255 uncollected entries piled up in ff-ig-2&lt;/code&gt;. Since unfollows can't happen, the backlog grows, you approach the follow limit, and one day follows suddenly stop too. I added the combination of &lt;code&gt;follow成功=0&lt;/code&gt; AND &lt;code&gt;follow判定不能&amp;gt;=20&lt;/code&gt; as a dedicated detection condition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;lane &lt;span class="k"&gt;in &lt;/span&gt;ff-ig-1 ff-ig-2 ff-ig-3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c"&gt;# ...&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$follow_success&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$undecidable&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 20 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;:+&lt;span class="nv"&gt;$FAILED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;-blocked"&lt;/span&gt;
    log &lt;span class="s2"&gt;"UNHEALTHY lane=&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt; follow成功=0 判定不能=&lt;/span&gt;&lt;span class="nv"&gt;$undecidable&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; friendship APIが絞られている疑い"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  "source-follow: All Sources Failed" — Four Times in a Row, Reported to Nobody
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;source-followers&lt;/code&gt; lane is a subprocess that collects follower lists to find follow targets. Until 2026-08-22 it wasn't included in the monitoring loop at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 2026-08-22 追加前は、このレーンの成果は一度も監視されていなかった。&lt;/span&gt;
&lt;span class="c"&gt;# 実害: ig-sug が 23:14/02:13/11:16/14:16 と4回連続で&lt;/span&gt;
&lt;span class="c"&gt;# 「coverage 0/17 -&amp;gt; 全ソース取得失敗 -&amp;gt; exit 1」になり日次20件で止まっていたのに、&lt;/span&gt;
&lt;span class="c"&gt;# 誰にも報告されなかった（同時刻に ig-3 も14時間ゼロ成果）。&lt;/span&gt;
&lt;span class="c"&gt;# 実体は private API の 429。&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ig-sug&lt;/code&gt; emitted &lt;code&gt;全ソース取得失敗&lt;/code&gt; in four slots: 11 PM, 2 AM, 11 AM, and 2 PM. It was falling over with exit 1 at zero coverage against 17 target accounts, but because &lt;code&gt;sum_likes_today()&lt;/code&gt; on the main lane's &lt;code&gt;ig-sug.log&lt;/code&gt; was working normally on its own, the watchdog summary said &lt;code&gt;OK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The monitoring I added watches &lt;code&gt;source-followers-*.log&lt;/code&gt; in an independent loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;lane &lt;span class="k"&gt;in &lt;/span&gt;ig-1 ig-2 ig-3 ig-nagi ig-sug th-1 th-2 th-3 th-nagi x-1 x-2 x-nagi x-reina&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;sf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SA_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/source-followers-&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;.log"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sf&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;sf_dead&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sf&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_UTC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'全ソース取得失敗'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sf_dead&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 2 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;:+&lt;span class="nv"&gt;$FAILED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;-srcfollow-blocked"&lt;/span&gt;
    log &lt;span class="s2"&gt;"UNHEALTHY lane=&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;-srcfollow 全ソース取得失敗=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;sf_dead&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;回 -&amp;gt; APIスロットリング疑い"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use &lt;code&gt;sf_dead &amp;gt;= 2&lt;/code&gt; as the threshold because a one-off failure (a temporary 429) self-recovers on the next run. Two consecutive failures let you conclude "the throttling is ongoing."&lt;/p&gt;

&lt;h3&gt;
  
  
  Deleting My Neighbor's Cookies Daily via pkill — A Structure Where the Symptom Can Only Look Like "Logged Out"
&lt;/h3&gt;

&lt;p&gt;This was the nastiest failure of all. The symptoms were clear. No &lt;code&gt;OK posted&lt;/code&gt; at all in IG's &lt;code&gt;sns-ig-autopost.retry.log&lt;/code&gt;. &lt;code&gt;🚨 SNS当日未出力: ig-autopost&lt;/code&gt; arriving in Discord from the watchdog. The session checker correctly reporting &lt;code&gt;sessionid がない（need-login）&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I re-logged in via the GUI every time. It would be back the next morning. It would be gone again the next night. This cycle went on for several days, and I was starting to form the hypothesis that "maybe Instagram shortened its session lifetime."&lt;/p&gt;

&lt;p&gt;In reality, &lt;code&gt;ensure_chrome.sh&lt;/code&gt; was pkill-ing the profile used by &lt;code&gt;social-autolike&lt;/code&gt;'s &lt;code&gt;th-scent&lt;/code&gt; job every night. &lt;code&gt;th-scent&lt;/code&gt; launches Chrome with Playwright but never opens CDP port 9223. From &lt;code&gt;ensure_chrome.sh&lt;/code&gt;'s point of view, "9223 is closed = a dead process." pkill sends SIGTERM → SIGKILL, Chrome can't complete its shutdown routine, and it can't write the Cookie SQLite back in a consistent state. On the next launch, Chrome recreates an empty Cookie DB.&lt;/p&gt;

&lt;p&gt;I found the cause when I lined up the log timestamps side by side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;22:56  th-scent unfollow start (.profiles/chrome-ig を掴む)
23:00  daily-post → ensure_chrome.sh → CDP 9223 応答なし
23:00  pkill -f user-data-dir=.profiles/chrome-ig
23:00  Cookie SQLite 破壊 → 空の DB 作り直し
23:00  daily-post: need-login → 投稿ゼロ
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because "the side doing the breaking (&lt;code&gt;ensure_chrome.sh&lt;/code&gt; / scent-media)" and "the side reporting the breakage (&lt;code&gt;retry.log&lt;/code&gt; / the session checker)" live in different repositories, reading only one of them will never connect the dots. The setting &lt;code&gt;"reuseProfile": ".profiles/chrome-ig"&lt;/code&gt; in &lt;code&gt;social-autolike&lt;/code&gt;'s &lt;code&gt;config/accounts.json&lt;/code&gt; appears nowhere in &lt;code&gt;scent-media&lt;/code&gt;'s code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You must not write a presence check for a shared resource as "does the interface I expect respond?"&lt;/strong&gt; — this incident was the first time I learned that lesson. 9223 being closed doesn't mean "dead"; it may mean "not launched in my particular style." Identify the owner with interface-independent means (pgrep / lock files), and allow kill only against processes you started yourself — that principle is what led to the pkill removal in commit &lt;code&gt;15307d7&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;Here are the traps I hit in an environment running multiple repositories on the same Mac. On top of the "wrong err.log filename," "TikTok date format difference," "friendship API throttling," "source-follow monitoring gap," and "Cookie destruction via pkill" detailed in the previous part, the same structural hole showed up in other forms too.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unfollow monitoring didn't exist at all.&lt;/strong&gt; Unfollow lanes like &lt;code&gt;ig-1.unfollow.log&lt;/code&gt; / &lt;code&gt;ig-2.unfollow.log&lt;/code&gt; / &lt;code&gt;tt-1.unfollow.log&lt;/code&gt; weren't in the monitoring loop at all until 2026-08-17. The damage shows up in measured numbers: &lt;code&gt;ig-1&lt;/code&gt; had 712 unfollows uncollected for over 72 hours, &lt;code&gt;ig-2&lt;/code&gt; piled up to 2255, and &lt;code&gt;tt-1&lt;/code&gt; was unable to unfollow 11 times in a row — while the watchdog returned &lt;code&gt;result=healthy&lt;/code&gt; every day. When unfollows jam up, you approach the follow limit, and a few days later follows stop too. Because the direct symptom (follows stopped) is one step removed from the root cause (unfollows jammed first), digging out the cause takes time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Zero like attempts" is a different failure from "zero like results."&lt;/strong&gt; &lt;code&gt;th-2&lt;/code&gt; was launching daily with &lt;code&gt;likeBudget=400&lt;/code&gt;, but the log contained not a single line with the string &lt;code&gt;live like&lt;/code&gt;. It was only attempting follows and ending on &lt;code&gt;circuit-break&lt;/code&gt;. From &lt;code&gt;sum_likes_today()&lt;/code&gt;'s point of view, this looks like one verdict: "total likes = 0." But "attempted and got blocked every time" and "never attempted at all" have completely different causes. The former is an account restriction; the latter is a selector mismatch or an action-flow bug. The detection loop that makes this distinction possible now lives at lines 244–259 of &lt;code&gt;sns-output-watchdog.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 「起動した」記録があるのに「いいね試行」が0回 = セレクタ不一致/アクション制限の疑い&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;lane &lt;span class="k"&gt;in &lt;/span&gt;x-1 x-4 x-5 ig-1 ig-2 ig-3 ig-sug th-1 th-2 tt-1 tt-2 tt-3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;lf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SA_LOGS&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;.log"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lf&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;started&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lf&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_UTC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'開始 ['&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lf&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY_UTC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'live like'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$started&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$attempts&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;:+&lt;span class="nv"&gt;$FAILED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;-noattempt"&lt;/span&gt;
    log &lt;span class="s2"&gt;"UNHEALTHY lane=&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt; いいね試行が0回(セレクタ不一致/アクション制限の疑い) 起動=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;started&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;回"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mixing FAILED and UNKNOWN destroys trust in your alerts.&lt;/strong&gt; If you run a lane that only launches three times a week through &lt;code&gt;check()&lt;/code&gt; every day, the days it doesn't launch have no log lines for that day, so it's treated as zero count and piled into &lt;code&gt;FAILED&lt;/code&gt; daily. When "🚨 SNS当日未出力: lane-X" arrives in Discord four days a week, a genuine outage alert one day gets skimmed past as "there it goes again." The moment alerts stop being trusted, your monitoring infrastructure is finished. After adding the branch that checks "does a log line for today exist?" with &lt;code&gt;has_today()&lt;/code&gt; and routes lanes with no lines to &lt;code&gt;UNKNOWN&lt;/code&gt;, my false-positive rate dropped by what felt like 90%+.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mixing UTC and JST creates a bug that only breaks at certain times of day.&lt;/strong&gt; The gap between &lt;code&gt;TODAY_JST&lt;/code&gt; and &lt;code&gt;TODAY_UTC&lt;/code&gt; is 9 hours. If a job that runs between midnight and 9 AM Japan time aggregates with &lt;code&gt;TODAY_UTC&lt;/code&gt;, the string &lt;code&gt;2026-08-24&lt;/code&gt; in the log matches the previous UTC date, and output completed overnight gets counted as "yesterday's success." From today's monitoring perspective it looks like zero, and a FAILED alert fires. Since it passes without issue when run during the day, it looks like "occasional false alarms" and the cause takes longer to find. I make this mixture explicit at the top of &lt;code&gt;sns-output-watchdog.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TODAY_JST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%d'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;TODAY_UTC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%d'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;TODAY_HUMAN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%a %b %e'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;  &lt;span class="c"&gt;# launchdが出す形式。日は空白詰めなので %e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using the three variables appropriately and matching each log to the one it actually emits resolved it, but every time I add a new lane it needs re-checking.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Discord notifications can silently fail to send.&lt;/strong&gt; The &lt;code&gt;notify()&lt;/code&gt; function does &lt;code&gt;[ -f "$DISCORD" ] || return 0&lt;/code&gt;, so if &lt;code&gt;discord_tool.py&lt;/code&gt; doesn't exist, it exits successfully without notifying. You can end up in a state where the watchdog log correctly records &lt;code&gt;result=unhealthy lanes=ig-1-likes0&lt;/code&gt; but nothing arrives in Discord. I have a track record of the path being wrong on day one of deployment and notifications silently not going out. To verify that a monitoring script is &lt;em&gt;actually raising alerts&lt;/em&gt;, you have to &lt;strong&gt;check on the receiving end (Discord's last-received timestamp)&lt;/strong&gt;. Looking only at the watchdog log, you'll never notice.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Nobody notices when a referenced log file goes stale.&lt;/strong&gt; &lt;code&gt;has_today()&lt;/code&gt; checks "is there a line for today?" but doesn't guarantee "has this file been updated recently?" Right after a job stops, &lt;code&gt;has_today()&lt;/code&gt; also returns false and the lane becomes UNKNOWN — but the state where the file exists and has today's lines, i.e. "it ran once today, but every run after that has silently failed," is undetectable. Without a mechanism to periodically check the &lt;code&gt;mtime&lt;/code&gt; of referenced logs, your monitoring falls into "continuing to judge today on stale evidence."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Shared configuration across multiple repositories is "written only in the config file."&lt;/strong&gt; Even though &lt;code&gt;social-autolike/config/accounts.json&lt;/code&gt; says &lt;code&gt;"reuseProfile": ".profiles/chrome-ig"&lt;/code&gt;, the code in &lt;code&gt;scent-media/scripts/ensure_chrome.sh&lt;/code&gt; contains no mention whatsoever that this profile is also used by another repo. In day-to-day work you look at &lt;code&gt;git log&lt;/code&gt; / &lt;code&gt;git diff&lt;/code&gt; separately for each, so collisions keep happening with no visible point of contact. I wrote this lesson into the learning note &lt;code&gt;learning/shared-resource-kill-corrupts-the-neighbor.md&lt;/code&gt;: "&lt;strong&gt;Write it in the code of the side being shared, not the side doing the sharing&lt;/strong&gt;" — that's the only way to minimize discovery cost.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Among completed runs, "runs that genuinely accomplished nothing" need to be counted separately from zero output.&lt;/strong&gt; If you count a run that terminated early on &lt;code&gt;rate-limit&lt;/code&gt; but still managed 2 likes as a "dead run," you'll get an alert every night from mild nighttime rate limiting. That's exactly why &lt;code&gt;count_dead_runs()&lt;/code&gt; narrows to "a harmful termination reason (need-login / circuit-break / error / rate-limit) AND いいね:0." Changing that AND to an OR alone would send the false-positive rate through the roof.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;Here are the design principles I actually hit and fixed, in reproducible form.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Check the presence of a shared resource in an interface-independent way
&lt;/h3&gt;

&lt;p&gt;"Does CDP port 9223 respond?" is not a check for "is Chrome running" — it's a check for "&lt;strong&gt;is a Chrome that was launched in my particular style running&lt;/strong&gt;." A Chrome on the same profile launched by someone else exists without opening the port. Check process presence on a PID basis, like &lt;code&gt;pgrep -f "user-data-dir=$PROFILE"&lt;/code&gt;, and don't depend on whether an interface responds.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Allow kill only against processes you started yourself
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pkill -f &amp;lt;pattern&amp;gt;&lt;/code&gt; takes down every process matching the pattern. It doesn't ask who started them. If there's even a 1% chance someone else started it, fall back to "wait and skip" instead of kill. The cost of killing the wrong process isn't "this run failed" — it's "every slot until recovery, plus human GUI work."&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Return unacquirable resources with "bounded polling → a dedicated exit code"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 10秒間隔・最大420秒ポーリング&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 42&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;10
  pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;break
&lt;/span&gt;&lt;span class="k"&gt;done
if &lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"user-data-dir=&lt;/span&gt;&lt;span class="nv"&gt;$PROFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"他ジョブが使用中。Cookie破壊を避けるため起動を見送る"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;2   &lt;span class="c"&gt;# 「今スロットは見送り」&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you fold it into &lt;code&gt;exit 1&lt;/code&gt; (error), Discord alerts keep firing on every slot collision and real outages get buried. Only when there's a design fact like "with 3 slots a day, one skip ≈ zero loss" can you treat &lt;code&gt;exit 2&lt;/code&gt; as "skipped = normal." Assign a dedicated code to states not worth alerting on, and have the caller handle it in an independent branch.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Monitor by outcome logs, not exit codes
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;exit 0&lt;/code&gt; only means "it didn't crash." In measurements on 2026-08-08, ig-1 finished 8 of 12 runs — and tt-1 / tt-2 60% of theirs — with exit 0 and zero likes. During the period when monitoring only looked at exit codes, &lt;strong&gt;this state persisted for two weeks and nobody noticed&lt;/strong&gt;. Judge whether output happened by aggregating actual like counts, follow counts, and post counts from the logs with &lt;code&gt;awk&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Always branch between "zero count" and "no lines for today"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;check&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;# ...&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$min&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;        &lt;span class="c"&gt;# 健全&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; has_today &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$daymark&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;UNKNOWN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;UNKNOWN&lt;/span&gt;:+&lt;span class="nv"&gt;$UNKNOWN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;      &lt;span class="c"&gt;# 判定不能&lt;/span&gt;
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FAILED&lt;/span&gt;:+&lt;span class="nv"&gt;$FAILED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;$lane&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;                   &lt;span class="c"&gt;# 異常&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a lane that only runs three times a week emits FAILED four days in a row, the alert becomes "there it goes again." Don't bundle "today was a non-run day" and "it ran today but produced zero" into the same alert.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Split zero-output, zero-attempt, and zero-completion into three tiers
&lt;/h3&gt;

&lt;p&gt;Monitoring precision improves in three stages.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;What's aggregated&lt;/th&gt;
&lt;th&gt;Failure it can detect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stage 1&lt;/td&gt;
&lt;td&gt;Number of completed runs&lt;/td&gt;
&lt;td&gt;Whether the job is crashing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 2&lt;/td&gt;
&lt;td&gt;Total actual like count&lt;/td&gt;
&lt;td&gt;Running but producing nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 3&lt;/td&gt;
&lt;td&gt;Number of like attempts&lt;/td&gt;
&lt;td&gt;Not even attempting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Until I added stage 3, &lt;code&gt;th-2&lt;/code&gt;'s selector mismatch had been invisible for over two weeks.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Keep an independent monitoring loop per type of outcome
&lt;/h3&gt;

&lt;p&gt;Posting, liking, following, unfollowing, and source collection are each different processes with different failure modes. Trying to watch them all in one loop mixes different "definitions of output" and produces misjudgments. &lt;code&gt;sns-output-watchdog.sh&lt;/code&gt; currently has six independent loops: post checks, outcome-based like verification, follow verification, unfollow verification, source-follow verification, and zero-attempt detection. It looks redundant, but without that separation I'd never have found "2255 unfollows piled up in ig-2."&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Periodically inventory whether all lanes are centrally managed
&lt;/h3&gt;

&lt;p&gt;When you add a new lane, it's easy to forget to add it to the monitoring loop. &lt;code&gt;source-followers-*.log&lt;/code&gt; wasn't included in monitoring until 2026-08-22, so ig-sug failing all-source collection four times in a row was reported to nobody. Make it routine to cross-check, once a month, the list of running jobs against the list of monitored lanes and &lt;strong&gt;confirm the number of unmonitored lanes is zero&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Align date formats, and when there's an exception, make it explicit in the variable name
&lt;/h3&gt;

&lt;p&gt;If you're using three variants — &lt;code&gt;date '+%Y-%m-%d'&lt;/code&gt;, &lt;code&gt;date -u '+%Y-%m-%d'&lt;/code&gt;, and &lt;code&gt;date '+%a %b %e'&lt;/code&gt; — confirm first which format each log emits, then map the variables accordingly. Timestamps emitted by launchd can be in &lt;code&gt;%e&lt;/code&gt; (space-padded) format. &lt;code&gt;Aug  8&lt;/code&gt; and &lt;code&gt;Aug 8&lt;/code&gt; do not match under grep. &lt;strong&gt;Putting the meaning "this one's format differs from the others" into the variable name&lt;/strong&gt;, as with &lt;code&gt;TODAY_HUMAN&lt;/code&gt;, makes it easier for a later reader to notice.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Annotate cross-repository sharing in the code of the side being referenced
&lt;/h3&gt;

&lt;p&gt;"Writing it in the config file of the referencing side" alone is invisible to whoever reads the code of the referenced side. If the implementer reading &lt;code&gt;ensure_chrome.sh&lt;/code&gt; knew that "this profile is also used by &lt;code&gt;social-autolike&lt;/code&gt;'s &lt;code&gt;th-scent&lt;/code&gt; via &lt;code&gt;reuseProfile&lt;/code&gt;," they'd stop before writing pkill. Document "who uses it and for what" in a comment on the shared resource, placed inside the code of the side being referenced.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. Design on the assumption that force-killing a process corrupts persistent state
&lt;/h3&gt;

&lt;p&gt;A process that receives SIGKILL can't run its shutdown handlers. In Chrome's case, it exits without writing the Cookie SQLite back in a consistent state and recreates an empty DB on next launch. As a measured value, &lt;code&gt;instagram.com&lt;/code&gt; cookies were confirmed to have shrunk to 0 rows. When force-killing a process that holds SQLite, caches, or session information, either &lt;strong&gt;prepare a recovery procedure in advance on the assumption that persistent state will be corrupted&lt;/strong&gt;, or switch to a design that doesn't force-kill in the first place.&lt;/p&gt;

&lt;h3&gt;
  
  
  12. Verify that your notification infrastructure works from the receiving end
&lt;/h3&gt;

&lt;p&gt;Even when the watchdog log records &lt;code&gt;result=unhealthy&lt;/code&gt;, there are cases where nothing arrives in Discord. The &lt;code&gt;notify()&lt;/code&gt; function silently &lt;code&gt;return 0&lt;/code&gt;s if the path to &lt;code&gt;discord_tool.py&lt;/code&gt; doesn't exist. The script only leaves a record of "attempted to notify." Looking at the watchdog log alone is not enough to confirm monitoring is functioning. You need to separately check the &lt;strong&gt;last-received timestamp in Discord&lt;/strong&gt;, or periodically send a "test notification" and confirm connectivity from the receiving end.&lt;/p&gt;

&lt;h3&gt;
  
  
  13. Skipping is neither failure nor success — express the third state with a dedicated exit code
&lt;/h3&gt;

&lt;p&gt;There are situations where the binary &lt;code&gt;exit 0 = success / exit 1 = failure&lt;/code&gt; isn't enough. "The conditions weren't right for this slot, so I skipped it" is not a success, but it's not a failure either. By assigning &lt;code&gt;exit 2&lt;/code&gt; to "skip this slot" and having the caller keep an independent branch that treats it as "retry at the next slot, exit 0," you can convey state accurately without adding alert noise. The premise that makes this design work is the design fact that &lt;strong&gt;there are multiple slots per day&lt;/strong&gt;. A skip design doesn't fit a job that only has one slot a day.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Compressed into one sentence, this incident is: "&lt;strong&gt;my own automation was destroying the persistent state of my own other automation, every day&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;The breaking side (&lt;code&gt;ensure_chrome.sh&lt;/code&gt; / scent-media) and the reporting side (&lt;code&gt;sns-ig-autopost.retry.log&lt;/code&gt; / the session checker) lived in different repositories, and each was working correctly. &lt;code&gt;ensure_chrome.sh&lt;/code&gt; was correctly detecting "a Chrome process where CDP 9223 doesn't respond." The session checker was correctly reporting "there is no IG sessionid." To a human, it can only look like "IG got logged out."&lt;/p&gt;

&lt;p&gt;This is the nastiest structure a failure can have. "The report is correct, but the cause is somewhere else" — with this shape, no amount of chasing symptoms in detail will get you to the cause. Only when I lined up the outcome logs from &lt;code&gt;sns-output-watchdog.sh&lt;/code&gt; against the launchd start times side by side, and confirmed that the 22:56 &lt;code&gt;th-scent unfollow&lt;/code&gt; start and the 23:00 &lt;code&gt;ensure_chrome.sh&lt;/code&gt; execution were 4 minutes apart, did the two repositories connect. The general form I recorded in the learning note &lt;code&gt;learning/shared-resource-kill-corrupts-the-neighbor.md&lt;/code&gt; is now the criterion for my design decisions going forward.&lt;/p&gt;

&lt;p&gt;The core of the fix is deleting one line of pkill. Commit &lt;code&gt;15307d7&lt;/code&gt;, which removed &lt;code&gt;pkill -f "user-data-dir=$PROFILE"&lt;/code&gt; and replaced it with 10-second-interval polling up to 420 seconds plus &lt;code&gt;exit 2&lt;/code&gt; (skip), doesn't even add 10 lines of code. But without the context that "another repository is using the same profile," you'd never reach the decision to delete that one line.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sns-output-watchdog.sh&lt;/code&gt; is now a script of over 270 lines. X posts, likes, replies / IG posts, likes, follows, unfollows / two Threads accounts / TikTok / 13 source-followers lanes — each has a different definition of output and is judged daily by an independent aggregation function. The first version only looked at "exit code 0 = healthy." From there, each time a function was added — &lt;code&gt;count_today()&lt;/code&gt; / &lt;code&gt;has_today()&lt;/code&gt; / &lt;code&gt;count_dead_runs()&lt;/code&gt; / &lt;code&gt;sum_likes_today()&lt;/code&gt; / &lt;code&gt;count_undecidable_today()&lt;/code&gt; — another invisible "silent failure" surfaced.&lt;/p&gt;

&lt;p&gt;If you treat an SNS foundation as something you merely "build," then from the moment it breaks you're in a state of "stopped while appearing to run." The starting point was IG posting being stopped since 7/29 and not noticing for two weeks. What today's ¥1.2M/month foundation rests on isn't a mechanism for checking whether 171 jobs are running — it's one for checking daily whether those 171 jobs are &lt;strong&gt;actually producing output&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Automation is two processes as a set: "making it run" and "making it observable." If either is missing, you can't tell for yourself whether this month is going well or failing. And if you can't tell, you can't decide your next move.&lt;/p&gt;

&lt;p&gt;If you're running multiple automations on one machine: do you actually know which of them share a resource?&lt;/p&gt;




&lt;p&gt;I've written up the full picture of the system, the ¥1.2M/month breakdown, and the 30-day process in a paid note.&lt;/p&gt;

&lt;p&gt;📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>bash</category>
      <category>devops</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Make Codex Prove It: A Three-File Design That Leaves Evidence on Disk</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sun, 30 Aug 2026 00:00:46 +0000</pubDate>
      <link>https://dev.to/bokuwalily/make-codex-prove-it-a-three-file-design-that-leaves-evidence-on-disk-1pln</link>
      <guid>https://dev.to/bokuwalily/make-codex-prove-it-a-three-file-design-that-leaves-evidence-on-disk-1pln</guid>
      <description>&lt;p&gt;An AI agent telling you "done" is not evidence. When I started delegating work to Codex, I took those reports at face value — until I checked the code and found the change missing, the wrong file edited, or no commit at all. So I stopped trusting language and started making the shell write the facts to disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this design works
&lt;/h2&gt;

&lt;p&gt;When you hand a task to Codex, it comes back with "Completed." At first that satisfied me. But when I actually checked the code, the critical change wasn't there, or a different file had been touched, or &lt;code&gt;git commit&lt;/code&gt; had never run. The output "I did it" and the fact "it was actually done" are two different things.&lt;/p&gt;

&lt;p&gt;This is true of Claude Code too. Whether tool results were read correctly, whether errors were swallowed — even with code I wrote myself, running a self-audit right after declaring completion turns up something every single time. Delegating implementation to an AI amplifies that problem by one more notch.&lt;/p&gt;

&lt;p&gt;The fix is simple: &lt;strong&gt;make it write state to a file, not to language.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even if the AI says "completed," it isn't complete unless &lt;code&gt;State: completed&lt;/code&gt; exists in the status file. If the handoff file doesn't contain the real output of &lt;code&gt;git status --short&lt;/code&gt;, you don't know what changed. If the four sections you specified in the task file (Summary, Files Changed, Validation, Remaining Risks) aren't there, you can't verify it.&lt;/p&gt;

&lt;p&gt;Files don't lie. An AI under pressure will insist "I did it," but the output of &lt;code&gt;cat status-file&lt;/code&gt; can't be forged. Pushing state management down into the filesystem is what makes it possible for &lt;strong&gt;a human to cross-check it in a shell&lt;/strong&gt;. That's the essence of this design.&lt;/p&gt;

&lt;p&gt;The other important piece is &lt;strong&gt;separation of concerns&lt;/strong&gt;. orchestrate-codex-worker.sh takes three arguments up front.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash scripts/orchestrate-codex-worker.sh &amp;lt;task-file&amp;gt; &amp;lt;handoff-file&amp;gt; &amp;lt;status-file&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of these three files has a clear role.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;task-file&lt;/strong&gt;: The work order for Codex. It contains only "what to do."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;handoff-file&lt;/strong&gt;: The handoff note after Codex finishes. Written on success or failure alike, on the assumption that the next person in line (the next Claude Code session, or me) will read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;status-file&lt;/strong&gt;: Machine-readable progress state. It takes only three values: &lt;code&gt;State: running&lt;/code&gt; → &lt;code&gt;State: completed&lt;/code&gt; / &lt;code&gt;State: failed&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What happens without this separation? When instructions and execution results live in the same place, "is this an instruction or post-execution output?" becomes ambiguous. When you run large numbers of tasks in parallel, that ambiguity is fatal. Multiple Codex workers can run in the same directory without interfering as long as each has its own independent task/handoff/status files.&lt;/p&gt;

&lt;p&gt;On top of that, the script starts with &lt;code&gt;set -euo pipefail&lt;/code&gt;. That's a declaration that "the entire script stops the moment any command fails." Without it, Bash ignores errors and moves to the next line. With &lt;code&gt;set -euo pipefail&lt;/code&gt;, the behavior becomes: if git rev-parse fails, stop; if mkdir fails, stop. A design that doesn't swallow errors matters especially in a script built on the premise of AI delegation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The overall flow
&lt;/h2&gt;

&lt;p&gt;Following the script's behavior in order looks like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;呼び出し元（Claude Code / cronジョブ等）
    │
    ├─ task-file を渡す（作業指示）
    ├─ handoff-file のパスを渡す（引き継ぎ先）
    └─ status-file のパスを渡す（状態管理先）
         │
         ▼
orchestrate-codex-worker.sh
    │
    ├─ [起動直後] write_status "running"
    │        └→ status-file: State: running / Branch / Worktree / timestamp
    │
    ├─ task-file の読み込み確認
    │   ├─ [失敗] write_status "failed" + handoff-fileにエラー書き出し → exit 1
    │   └─ [成功] 処理継続
    │
    ├─ mktemp で prompt_file / output_file を作成
    │   └─ trap cleanup EXIT（終了時に自動削除）
    │
    ├─ prompt_file を組み立て（Codexへの指示 + task-fileの内容）
    │
    ├─ codex exec -p yolo -m gpt-5.4 -C $(pwd) -o output_file &amp;lt; prompt_file
    │   │
    │   ├─ [成功]
    │   │     handoff-file に書き出し:
    │   │       - Completed: timestamp
    │   │       - Branch: git rev-parse --abbrev-ref HEAD
    │   │       - Worktree: pwd
    │   │       - output_file の内容（Summary/Files Changed/Validation/Remaining Risks）
    │   │       - git status --short
    │   │     write_status "completed"
    │   │
    │   └─ [失敗]
    │         handoff-file に書き出し:
    │           - Failed: timestamp / Branch / Worktree
    │           - "The Codex worker exited with a non-zero status."
    │         write_status "failed" → exit 1
    │
    └─ 完了
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looking at the &lt;code&gt;write_status&lt;/code&gt; function — the core of the actual code — shows what's being recorded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;write_status&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;details&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
# Status

- State: &lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="sh"&gt;
- Updated: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;timestamp&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
- Branch: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
- Worktree: `&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;`

&lt;/span&gt;&lt;span class="nv"&gt;$details&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git rev-parse --abbrev-ref HEAD&lt;/code&gt; returns the branch name at that moment. &lt;code&gt;$(pwd)&lt;/code&gt; is the absolute path of the worktree. The timestamp comes out as UTC ISO 8601 via &lt;code&gt;date -u +"%Y-%m-%dT%H:%M:%SZ"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In other words, the status file tells you line by line &lt;strong&gt;when, on which branch, in which worktree, and in what state&lt;/strong&gt;. When running in parallel across multiple worktrees, just looking at the Worktree field in the status file identifies which is which.&lt;/p&gt;

&lt;p&gt;The prompt handed to Codex is also assembled directly inside the script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
You are one worker in an ECC tmux/worktree swarm.

Rules:
- Work only in the current git worktree.
- Do not touch sibling worktrees or the parent repo checkout.
- Complete the task from the task file below.
- Do not spawn subagents or external agents for this task.
- Report progress and final results in stdout only.
- Do not write handoff or status files yourself; the launcher manages those artifacts.
- If you change code or docs, keep the scope narrow and defensible.
- In your final response, include exactly these sections:
  1. Summary
  2. Files Changed
  3. Validation
  4. Remaining Risks

Task file: &lt;/span&gt;&lt;span class="nv"&gt;$task_file&lt;/span&gt;&lt;span class="sh"&gt;

&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$task_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Do not write handoff or status files yourself"&lt;/strong&gt; — an explicit prohibition. Who writes the handoff/status files is a key design fork. If you let Codex write them, Codex may output something that merely &lt;em&gt;looks&lt;/em&gt; right. Writing them from the script side means you get the actual output of &lt;code&gt;git status --short&lt;/code&gt;, the actual branch name from &lt;code&gt;git rev-parse --abbrev-ref HEAD&lt;/code&gt;, and the actual time from &lt;code&gt;timestamp&lt;/code&gt;. Those can't be falsified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forcing four sections.&lt;/strong&gt; Requiring "exactly these sections" in Codex's output fixes the positions that downstream processing and review will reference. Open the handoff file, read the "Files Changed" section, and you have the list of changed files; read the "Validation" section and you have the verification commands Codex actually ran and their results. With free-form output, when the next session loads the handoff file you no longer know where to look.&lt;/p&gt;

&lt;p&gt;The codex invocation itself is one line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;codex &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; yolo &lt;span class="nt"&gt;-m&lt;/span&gt; gpt-5.4 &lt;span class="nt"&gt;--color&lt;/span&gt; never &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$output_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; - &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-p yolo&lt;/code&gt; means no confirmation prompts, &lt;code&gt;-m gpt-5.4&lt;/code&gt; specifies the model, &lt;code&gt;-C "$(pwd)"&lt;/code&gt; sets the working directory, &lt;code&gt;-o "$output_file"&lt;/code&gt; sets the output destination, and &lt;code&gt;- &amp;lt; "$prompt_file"&lt;/code&gt; tells it to read the prompt from stdin. &lt;code&gt;--color never&lt;/code&gt; keeps ANSI escape sequences from contaminating the handoff file, so no junk characters get in the way when you grep or parse it later.&lt;/p&gt;

&lt;p&gt;Whether this call succeeds (exit 0) or fails (non-zero) decides the branch that follows. Because the script has &lt;code&gt;set -euo pipefail&lt;/code&gt;, a failing codex command doesn't exit outright — the &lt;code&gt;if codex exec ...; then ... else ... fi&lt;/code&gt; structure routes it into the error branch. Even on failure, the handoff file and status file are always written. Never producing a state of "no file = it was never even run" is what the later cross-check verification requires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What &lt;code&gt;trap cleanup EXIT&lt;/code&gt; protects
&lt;/h3&gt;

&lt;p&gt;There's a mechanism I didn't cover in the first half that you should read first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;prompt_file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;output_file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
cleanup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$output_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap &lt;/span&gt;cleanup EXIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mktemp&lt;/code&gt; creates a temp file like &lt;code&gt;/tmp/tmp.XXXXXX&lt;/code&gt;. &lt;code&gt;trap cleanup EXIT&lt;/code&gt; declares "when the script exits — whether exit 0 or exit 1 — run the &lt;code&gt;cleanup&lt;/code&gt; function."&lt;/p&gt;

&lt;p&gt;Why is this needed? &lt;code&gt;codex exec&lt;/code&gt; reads &lt;code&gt;prompt_file&lt;/code&gt; and writes to &lt;code&gt;output_file&lt;/code&gt;, but Codex sometimes exits non-zero. When Codex fails in a &lt;code&gt;set -euo pipefail&lt;/code&gt; environment, the script enters the else block and ends with exit 1. Without &lt;code&gt;trap&lt;/code&gt;, &lt;code&gt;/tmp/tmp.XXXXXX&lt;/code&gt; would linger. Once is fine, but run 30 workers in parallel and &lt;code&gt;/tmp&lt;/code&gt; bloats. With &lt;code&gt;trap cleanup EXIT&lt;/code&gt;, the temp files disappear no matter which path exits.&lt;/p&gt;

&lt;p&gt;One more point: note that &lt;strong&gt;&lt;code&gt;prompt_file&lt;/code&gt; and &lt;code&gt;output_file&lt;/code&gt; are global variables&lt;/strong&gt;. At definition time, the &lt;code&gt;cleanup&lt;/code&gt; function doesn't know the contents of &lt;code&gt;$prompt_file&lt;/code&gt; / &lt;code&gt;$output_file&lt;/code&gt;. It reads the variable values at exit, when the function runs. That's exactly why the order is: assign the variables right after &lt;code&gt;mktemp&lt;/code&gt;, then set the trap. Reverse the order and cleanup tries to delete empty paths and errors out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the task-file existence check comes before "running"
&lt;/h3&gt;

&lt;p&gt;Reading the script, the task-file existence check comes before &lt;code&gt;write_status "running"&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$handoff_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$task_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;write_status &lt;span class="s2"&gt;"failed"&lt;/span&gt; &lt;span class="s2"&gt;"- Error: task file is missing or unreadable (&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="nv"&gt;$task_file&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
  &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# Handoff"&lt;/span&gt;
    &lt;span class="nb"&gt;echo
    echo&lt;/span&gt; &lt;span class="s2"&gt;"- Failed: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;timestamp&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"- Branch: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"- Worktree: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo
    echo&lt;/span&gt; &lt;span class="s2"&gt;"Task file is missing or unreadable: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="nv"&gt;$task_file&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$handoff_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;write_status &lt;span class="s2"&gt;"running"&lt;/span&gt; &lt;span class="s2"&gt;"- Task file: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="nv"&gt;$task_file&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;write_status "running"&lt;/code&gt; means "submission to Codex has begun." If the task file can't be read, the premise for submitting to Codex has collapsed, so it isn't entitled to be "running." It writes &lt;code&gt;"failed"&lt;/code&gt; directly and exits 1.&lt;/p&gt;

&lt;p&gt;The reason &lt;code&gt;mkdir -p "$(dirname "$handoff_file")" "$(dirname "$status_file")"&lt;/code&gt; comes first is the same. When the task file can't be read and you try to write failed, if the directories for the handoff file or status file don't exist, that write itself fails. Make sure the parent directories exist before any write to the handoff/status files. That's why &lt;code&gt;mkdir -p dirname ...&lt;/code&gt; sits at the top.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the handoff file holds on success
&lt;/h3&gt;

&lt;p&gt;When Codex's exit code is 0, the following gets written to the handoff file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;codex &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; yolo &lt;span class="nt"&gt;-m&lt;/span&gt; gpt-5.4 &lt;span class="nt"&gt;--color&lt;/span&gt; never &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$output_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; - &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# Handoff"&lt;/span&gt;
    &lt;span class="nb"&gt;echo
    echo&lt;/span&gt; &lt;span class="s2"&gt;"- Completed: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;timestamp&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"- Branch: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"- Worktree: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo
    cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$output_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo
    echo&lt;/span&gt; &lt;span class="s2"&gt;"## Git Status"&lt;/span&gt;
    &lt;span class="nb"&gt;echo
    &lt;/span&gt;git status &lt;span class="nt"&gt;--short&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$handoff_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  write_status &lt;span class="s2"&gt;"completed"&lt;/span&gt; &lt;span class="s2"&gt;"- Handoff file: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="nv"&gt;$handoff_file&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cat "$output_file"&lt;/code&gt; pulls in Codex's entire output. Immediately after, &lt;code&gt;echo "## Git Status"&lt;/code&gt; and &lt;code&gt;git status --short&lt;/code&gt; follow.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;git status --short&lt;/code&gt; is the linchpin of verification. Suppose Codex wrote "Files Changed: src/api/index.ts, tests/api.test.ts" in its Summary — if &lt;code&gt;git status --short&lt;/code&gt; shows nothing, that means git doesn't recognize any change to those files.&lt;/p&gt;

&lt;p&gt;The cross-check commands I actually use are these.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# handoff-fileの"## Git Status"以降を確認&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 20 &lt;span class="s2"&gt;"## Git Status"&lt;/span&gt; /path/to/handoff-file

&lt;span class="c"&gt;# 実際のgit diffと比較&lt;/span&gt;
git diff &lt;span class="nt"&gt;--stat&lt;/span&gt; HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the handoff file's &lt;code&gt;## Git Status&lt;/code&gt; and &lt;code&gt;git diff --stat&lt;/code&gt; agree, what Codex said matches git's actual state. If they don't, it's one of two things: "Codex thought it made changes but actually didn't," or "it went all the way through commit, so nothing showed in &lt;code&gt;git status --short&lt;/code&gt; (= clean)." For the latter, I check the &lt;code&gt;Branch&lt;/code&gt; in the status file and trace it with &lt;code&gt;git log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reason for passing &lt;code&gt;--color never&lt;/code&gt; to Codex&lt;/strong&gt; lies here. If ANSI escape sequences (control characters like &lt;code&gt;\e[32m&lt;/code&gt; or &lt;code&gt;\033[0m&lt;/code&gt;) get into output_file, they're transcribed verbatim into the handoff file. When you run &lt;code&gt;grep -A 20 "## Git Status" handoff-file&lt;/code&gt;, invisible control characters break the pattern match. &lt;code&gt;--color never&lt;/code&gt; is the instruction "don't include ANSI codes in output," and without it, mechanical post-processing gets contaminated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't assume "nothing changed anyway" on failure
&lt;/h3&gt;

&lt;p&gt;The handoff file on failure is minimal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# Handoff"&lt;/span&gt;
    &lt;span class="nb"&gt;echo
    echo&lt;/span&gt; &lt;span class="s2"&gt;"- Failed: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;timestamp&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"- Branch: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"- Worktree: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo
    echo&lt;/span&gt; &lt;span class="s2"&gt;"The Codex worker exited with a non-zero status."&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$handoff_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  write_status &lt;span class="s2"&gt;"failed"&lt;/span&gt; &lt;span class="s2"&gt;"- Handoff file: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="nv"&gt;$handoff_file&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just the single line &lt;code&gt;The Codex worker exited with a non-zero status.&lt;/code&gt; Codex's output is partially written to &lt;code&gt;output_file&lt;/code&gt;, but &lt;code&gt;cat "$output_file"&lt;/code&gt; is not executed here. Why?&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;output_file&lt;/code&gt; on failure is incomplete output. The four sections may not all be there. An API error may have hit partway through, leaving everything from Summary onward unwritten. Mixing incomplete output into the handoff file means that when the next session reads it, you can't tell "is this completed output or partial output?" &lt;strong&gt;Record failure as failure, with zero content&lt;/strong&gt; — that's the design intent.&lt;/p&gt;

&lt;p&gt;To investigate what was happening after a failure, you need to capture the stderr of the running &lt;code&gt;codex exec&lt;/code&gt; separately, not &lt;code&gt;output_file&lt;/code&gt;. This script doesn't go that far, so on failure I enable Codex-side logging with an environment variable like &lt;code&gt;CODEX_DEBUG=1&lt;/code&gt; and check separately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I got stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Before I added &lt;code&gt;set -euo pipefail&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When I first wrote this script, I didn't have &lt;code&gt;set -euo pipefail&lt;/code&gt; at the top. Bash's default behavior is to ignore errors and move to the next line.&lt;/p&gt;

&lt;p&gt;What happened? The Codex invocation failed, but the script didn't go into the next &lt;code&gt;if ... then&lt;/code&gt; branch (back then it was a direct call, not an if statement) and &lt;code&gt;write_status "completed"&lt;/code&gt; ran. The status file said &lt;code&gt;State: completed&lt;/code&gt;. The handoff file had &lt;code&gt;Completed: 2026-05-14T08:23:11Z&lt;/code&gt;. But &lt;code&gt;git diff --stat&lt;/code&gt; showed nothing.&lt;/p&gt;

&lt;p&gt;The symptom is "the status file says completed but the code hasn't changed." At first I thought "did Codex commit everything?" and looked at &lt;code&gt;git log&lt;/code&gt; — nothing there either.&lt;/p&gt;

&lt;p&gt;Pinning down the cause took 30 minutes. I ran &lt;code&gt;codex exec&lt;/code&gt; manually and checked the exit code: it was 1. &lt;code&gt;echo $?&lt;/code&gt; returned 1. But inside the script, that 1 was ignored and it moved to the next line.&lt;/p&gt;

&lt;p&gt;The fix was just adding &lt;code&gt;set -euo pipefail&lt;/code&gt; at the top and wrapping the Codex call in &lt;code&gt;if ... then ... else ... fi&lt;/code&gt;. A two-line change. But before I noticed, I'd believed a "falsely completed" status file and stacked further work on top of it, which meant redoing all of it later.&lt;/p&gt;

&lt;p&gt;When you write a script on the premise of delegating work to an AI, &lt;strong&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt; is not an option, it's a requirement&lt;/strong&gt;. Bash's error-ignoring is tolerable when a human is debugging a script by hand, but with AI delegation, "silently continuing after a failure" becomes fatal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before I added &lt;code&gt;--color never&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When I tried to grep the handoff file, I got output like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="c"&gt;# Git Status&lt;/span&gt;
&lt;span class="go"&gt;
?? src/^[[0mapi^[[0m/^[[32mindex.ts^[[0m
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;^[[0m&lt;/code&gt; is the ANSI reset code and &lt;code&gt;^[[32m&lt;/code&gt; specifies green. Dumping Codex's terminal output straight to a file lets ANSI escape sequences in.&lt;/p&gt;

&lt;p&gt;In that state, running &lt;code&gt;grep "index.ts" handoff-file&lt;/code&gt; doesn't match, because the pattern is &lt;code&gt;index.ts&lt;/code&gt; but in the file it's split up as &lt;code&gt;index^[[0m.ts&lt;/code&gt;. It's readable to the eye, but it falls apart when you try to process it with a script.&lt;/p&gt;

&lt;p&gt;At first I tried stripping the ANSI codes in post-processing with &lt;code&gt;sed 's/\x1b\[[0-9;]*m//g'&lt;/code&gt;. That works, but a sed pattern covering every terminal escape sequence is complex, and it breaks if Codex starts using different escapes in the future.&lt;/p&gt;

&lt;p&gt;The fundamental fix is passing &lt;code&gt;--color never&lt;/code&gt; to &lt;code&gt;codex exec&lt;/code&gt;. Tell Codex not to emit ANSI codes in the first place. Controlling it at the entrance is simpler and more robust than post-processing with sed.&lt;/p&gt;

&lt;h3&gt;
  
  
  When Codex wrote the status file itself
&lt;/h3&gt;

&lt;p&gt;There was a period when I put "when you finish the work, write to the status file" in the prompt. The idea was, "if Codex itself can record completion, the script doesn't need if/else."&lt;/p&gt;

&lt;p&gt;What actually happened: Codex wrote &lt;code&gt;State: completed&lt;/code&gt;. But the content wasn't real.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Status&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; State: completed
&lt;span class="p"&gt;-&lt;/span&gt; Updated: 2026-05-20T14:33:00Z
&lt;span class="p"&gt;-&lt;/span&gt; Branch: main
&lt;span class="p"&gt;-&lt;/span&gt; Worktree: &lt;span class="sb"&gt;`/path/to/project`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Branch says &lt;code&gt;main&lt;/code&gt;. But the actual worktree was on the &lt;code&gt;feature/api-refactor&lt;/code&gt; branch. That's not &lt;code&gt;git rev-parse --abbrev-ref HEAD&lt;/code&gt; — it's a string Codex guessed as "probably main." The timestamp wasn't the actual completion time either; it was a plausible-looking time Codex inferred from its training data.&lt;/p&gt;

&lt;p&gt;Worse was the case where the Codex invocation failed partway through. Codex sometimes tries to write the status file "just in case" right before exiting with an error. The result was a state where &lt;code&gt;codex exec&lt;/code&gt; exited non-zero, yet the status file said &lt;code&gt;State: completed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The current prompt has an explicit prohibition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Do not write handoff or status files yourself; the launcher manages those artifacts.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason for that one line is that "if you let Codex write it, the values become guesses instead of measurements." &lt;code&gt;$(git rev-parse --abbrev-ref HEAD)&lt;/code&gt; is actually executed by the shell. The &lt;code&gt;main&lt;/code&gt; Codex writes is guessed by the model. That difference decisively changes verification accuracy. Files that record state should be written by the shell.&lt;/p&gt;

&lt;h3&gt;
  
  
  When I passed the task file as a relative path
&lt;/h3&gt;

&lt;p&gt;I once passed the task file as a relative path like &lt;code&gt;./tasks/refactor-api.md&lt;/code&gt; when calling the script. At that point the behavior of &lt;code&gt;mkdir -p "$(dirname "$handoff_file")"&lt;/code&gt; went wrong.&lt;/p&gt;

&lt;p&gt;Because the handoff file was also passed as a relative path like &lt;code&gt;./handoffs/refactor-api-handoff.md&lt;/code&gt;, when the script changed the working directory with &lt;code&gt;cd&lt;/code&gt; (the version at the time did &lt;code&gt;cd&lt;/code&gt; into the worktree), the path &lt;code&gt;dirname&lt;/code&gt; computed ended up somewhere other than intended.&lt;/p&gt;

&lt;p&gt;The symptom was a &lt;code&gt;Permission denied&lt;/code&gt; or &lt;code&gt;No such file or directory&lt;/code&gt; error meaning "can't write to the handoff file." When I debugged it and echoed the path &lt;code&gt;dirname&lt;/code&gt; returned, it was &lt;code&gt;/handoffs&lt;/code&gt; instead of &lt;code&gt;/worktree/subdir/handoffs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to convert to absolute paths on the caller side before passing them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash scripts/orchestrate-codex-worker.sh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; ./tasks/refactor-api.md&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; ./handoffs/refactor-api-handoff.md&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; ./status/refactor-api.status.md&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;realpath&lt;/code&gt; returns the absolute path of an existing file. &lt;code&gt;realpath -m&lt;/code&gt; computes and returns an absolute path even if the file doesn't exist (&lt;code&gt;-m&lt;/code&gt; = &lt;code&gt;--no-require-file&lt;/code&gt;). The handoff and status files are created by the script, so they don't exist at call time. Using &lt;code&gt;realpath -m&lt;/code&gt; lets you fix the absolute path of a nonexistent file in advance.&lt;/p&gt;

&lt;p&gt;Inside the script, &lt;code&gt;$(dirname "$handoff_file")&lt;/code&gt; then always computes the dirname of an absolute path, so it's safe to call the script from any directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  The loop that detects false completion
&lt;/h3&gt;

&lt;p&gt;Based on these failures, the verification flow I use now is this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. status-fileでState確認&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"State:"&lt;/span&gt; /path/to/status-file

&lt;span class="c"&gt;# 2. 実際のgit diffと突き合わせ&lt;/span&gt;
git diff &lt;span class="nt"&gt;--stat&lt;/span&gt; HEAD

&lt;span class="c"&gt;# 3. handoff-fileのGit Statusセクションと比較&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 10 &lt;span class="s2"&gt;"## Git Status"&lt;/span&gt; /path/to/handoff-file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only when all three agree can I confirm "what Codex said was actually done."&lt;/p&gt;

&lt;p&gt;Status file says &lt;code&gt;State: completed&lt;/code&gt; → &lt;code&gt;git diff --stat&lt;/code&gt; shows changes → the handoff file's &lt;code&gt;## Git Status&lt;/code&gt; lists the same files. That's the three-piece set.&lt;/p&gt;

&lt;p&gt;Conversely, the patterns where it breaks down are fixed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;State: completed&lt;/code&gt; but &lt;code&gt;git diff --stat&lt;/code&gt; is empty → Codex said "completed" without making changes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git diff --stat&lt;/code&gt; shows changes but they're not in the handoff file's &lt;code&gt;## Git Status&lt;/code&gt; → there's a bug in the script's write order (I did this once)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;State: failed&lt;/code&gt; but &lt;code&gt;git diff --stat&lt;/code&gt; shows changes → Codex made partial changes and exited 1 (the dangerous pattern)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last pattern needs the most caution. When Codex crashes after partial changes, &lt;code&gt;git diff --stat&lt;/code&gt; shows changes, but whether those changes are "partway toward a correct change" or "a broken state" isn't clear until you read the code. In that case I read the full diff with &lt;code&gt;git diff HEAD&lt;/code&gt;, then decide whether to fully revert or continue. Often I shelve it with &lt;code&gt;git stash&lt;/code&gt; before running the next Codex worker.&lt;/p&gt;

&lt;p&gt;When the handoff file contains only &lt;code&gt;The Codex worker exited with a non-zero status.&lt;/code&gt;, the cause of Codex's error only exists in stderr. This script currently doesn't capture stderr, so the only option is to reproduce it. When I submit the same task next time, I add one line to the task file: "the previous run exited non-zero; identify the cause of the error before starting." I count on the root cause of the error being written in the "Remaining Risks" of the four sections.&lt;/p&gt;

&lt;p&gt;Since I started using this "leave state in files" design, the state of "I don't know what changed" after delegating to Codex has nearly disappeared. &lt;code&gt;cat&lt;/code&gt; the status file, &lt;code&gt;grep&lt;/code&gt; the handoff file, look at &lt;code&gt;git diff --stat&lt;/code&gt; — with those three commands I can verify any worker's completion state in under 30 seconds. Completion that's "only said" doesn't get engraved into the filesystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The task file's instructions were "plausible-sounding Japanese"
&lt;/h3&gt;

&lt;p&gt;I once handed over a task file saying "please improve the API's error handling." Codex came back in the four-section format. &lt;code&gt;State: completed&lt;/code&gt; was there too. The &lt;code&gt;## Git Status&lt;/code&gt; section existed. But the Git Status field was empty.&lt;/p&gt;

&lt;p&gt;Reading the handoff file's Validation section, it said "after investigation, there are no problems with the current implementation." Because the instruction was vague, "a judgment that no improvement is needed" got processed as "task complete." Doing nothing while adhering to the four-section format functions as a loophole in the design.&lt;/p&gt;

&lt;p&gt;After I standardized the task file into the following format, this pattern nearly vanished.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;対象: ~/dev/myapp/src/api/client.ts
やること: fetchUser関数のcatch節でエラーをconsole.errorに出力し、呼び出し元へrethrowする
完了条件: catch節にconsole.error + throw eが入っていること
検証: grep -n "console.error" ~/dev/myapp/src/api/client.ts &amp;amp;&amp;amp; grep -n "throw e" ~/dev/myapp/src/api/client.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the completion criteria and verification command are written as a pair, Codex actually runs that command in the Validation section and pastes the stdout. The escape hatch of "investigated, no problems found" is closed off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running in parallel without separating git worktrees
&lt;/h3&gt;

&lt;p&gt;orchestrate-codex-worker.sh passes the current worktree to Codex via &lt;code&gt;-C "$(pwd)"&lt;/code&gt;. What happens if you run two workers simultaneously in the same worktree?&lt;/p&gt;

&lt;p&gt;While Codex A is rewriting &lt;code&gt;src/api/client.ts&lt;/code&gt;, Codex B reads the same file and makes a different change. B finishes first and lands a git commit. A, unaware of the state after B's commit, stages the file it wrote out and commits on top. The result is a commit where A overwrote lines it never intended to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git diff --stat&lt;/code&gt; had A's changes and B's changes mixed together, and I could no longer trace which worker wrote which lines. Looking at &lt;code&gt;git log --oneline&lt;/code&gt;, the ordering between commits was a mess too.&lt;/p&gt;

&lt;p&gt;When running parallel workers, always separate worktrees with &lt;code&gt;git worktree add&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ~/dev/myapp-worker-a feature/api-fix-a
git worktree add ~/dev/myapp-worker-b feature/cache-fix-b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you move into each worktree's directory before launching the script, the status file's &lt;code&gt;Worktree&lt;/code&gt; field splits into &lt;code&gt;~/dev/myapp-worker-a&lt;/code&gt; and &lt;code&gt;~/dev/myapp-worker-b&lt;/code&gt;, and it's obvious at a glance which worker's result you're looking at.&lt;/p&gt;

&lt;h3&gt;
  
  
  output_file was empty and nothing went into the handoff file
&lt;/h3&gt;

&lt;p&gt;There were times when &lt;code&gt;codex exec&lt;/code&gt; exited 0, yet there was nothing before &lt;code&gt;## Git Status&lt;/code&gt; in the handoff file. Because &lt;code&gt;cat "$output_file"&lt;/code&gt; transcribed an empty file as is, Codex's four-section output was missing entirely.&lt;/p&gt;

&lt;p&gt;The cause was a timeout on the Codex API side. When processing drags on and the API cuts the session, &lt;code&gt;codex exec&lt;/code&gt; can exit 0 (behavior varies by Codex version). The output_file is created but is 0 bytes.&lt;/p&gt;

&lt;p&gt;I added two countermeasures.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wrap &lt;code&gt;codex exec&lt;/code&gt; from the outside as &lt;code&gt;timeout 600 codex exec ...&lt;/code&gt;. It gets killed at 10 minutes and returns exit 124, so in a &lt;code&gt;set -euo pipefail&lt;/code&gt; environment it enters the else block and failed is recorded. Dangling workers disappear.&lt;/li&gt;
&lt;li&gt;Right after writing the handoff file, check its size with &lt;code&gt;wc -c "$handoff_file"&lt;/code&gt; and, if under 200 bytes, log "the output may have been empty." The judgment is made by a human after looking at the status file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Codex kept saying "Remaining Risks: None"
&lt;/h3&gt;

&lt;p&gt;As I accumulated Codex's four-section outputs, I noticed &lt;code&gt;None&lt;/code&gt; lining up in the Remaining Risks section. Yet multiple cases came up where a later worker reported "there was a bug in the code the previous worker implemented." Adhering to the format while skewing the content positive — that's a common Codex tendency.&lt;/p&gt;

&lt;p&gt;After adding the following line to the task file, concrete risks started appearing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Remaining Risksには必ず1件以上の懸念事項を書くこと。「None」は禁止。
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You start getting things like "the type definitions are provisional," "test coverage is low," "behavior against production data is unverified." By keeping Remaining Risks at one or more items at all times, you close off one form of false completion — completion by concealing problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  A space snuck into the handoff file's absolute path
&lt;/h3&gt;

&lt;p&gt;During a period when I had a folder with a Japanese name directly under my macOS home directory, I passed a handoff file path containing that path. &lt;code&gt;"$(dirname "$handoff_file")"&lt;/code&gt; is wrapped in double quotes so it's safe against spaces, but because the calling script omitted quotes, &lt;code&gt;dirname&lt;/code&gt; received a path split at the space.&lt;/p&gt;

&lt;p&gt;The symptom is &lt;code&gt;No such file or directory&lt;/code&gt; from &lt;code&gt;mkdir -p&lt;/code&gt;. The handoff file's directory can't be created, so the task fails before it even starts.&lt;/p&gt;

&lt;p&gt;I solved it with both thorough quoting on the caller side and realpath conversion. When passing paths containing spaces to a shell script, always wrap them in double quotes, including on the right-hand side of variable assignments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure causes don't survive because stderr isn't captured
&lt;/h3&gt;

&lt;p&gt;The handoff file on failure has only the single line &lt;code&gt;The Codex worker exited with a non-zero status.&lt;/code&gt; Codex's error messages go to stderr, but this script doesn't redirect stderr, so they vanish the moment they hit the terminal.&lt;/p&gt;

&lt;p&gt;The only option is to reproduce and identify the cause. I append "the previous run exited non-zero; identify the cause of the error before starting" to the next task file and hand it to Codex. I resubmit hoping the root cause of the error gets written in the "Remaining Risks" of the four sections.&lt;/p&gt;

&lt;p&gt;Fundamentally fixing this requires a change that routes stderr to a separate file with &lt;code&gt;codex exec ... 2&amp;gt;"$error_file"&lt;/code&gt; and runs &lt;code&gt;cat "$error_file"&lt;/code&gt; into the handoff file on failure. It needs to be understood as an unaddressed area of the current script.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Convert all three arguments with realpath before passing them
&lt;/h3&gt;

&lt;p&gt;Convert the task file with &lt;code&gt;realpath&lt;/code&gt; (errors if the file doesn't exist) and the handoff/status files with &lt;code&gt;realpath -m&lt;/code&gt; (computes an absolute path even if they don't exist). Fixing relative paths into absolute paths at call time means the &lt;code&gt;dirname&lt;/code&gt; inside the script always computes the correct path, no matter which directory it's called from.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TASK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; ./tasks/my-task.md&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;HANDOFF&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; ./handoffs/my-task-handoff.md&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;STATUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; ./status/my-task.status.md&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
bash scripts/orchestrate-codex-worker.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TASK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HANDOFF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Write task files all the way through "completion criteria + verification command"
&lt;/h3&gt;

&lt;p&gt;A task file that ends with "please improve X" leaves the interpretation to Codex. Write the completion criteria and an executable verification command as a pair, and Codex will actually run that command in the validation section and paste the result. The escape hatch of "investigated, no problems" gets structurally closed off.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Wrap it from the outside with timeout 600
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;codex exec&lt;/code&gt; has no CLI-level timeout argument. Putting &lt;code&gt;timeout 600 codex exec ...&lt;/code&gt; around it kills it at 10 minutes and returns exit 124. In a &lt;code&gt;set -euo pipefail&lt;/code&gt; environment it's recorded as failed. It's the minimum defense against creating workers that hang for a long time.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Always separate parallel workers with git worktree add
&lt;/h3&gt;

&lt;p&gt;Don't run multiple workers in the same worktree. Separate worktrees with &lt;code&gt;git worktree add &amp;lt;path&amp;gt; &amp;lt;branch&amp;gt;&lt;/code&gt; and launch the script from each worktree. Because the status file's Worktree field is recorded per worktree, you can trace afterward "which worker wrote what to which branch."&lt;/p&gt;

&lt;h3&gt;
  
  
  5. After a failure, shelve with git stash before resubmitting
&lt;/h3&gt;

&lt;p&gt;When the state is &lt;code&gt;State: failed&lt;/code&gt; and &lt;code&gt;git diff --stat&lt;/code&gt; shows changes, Codex made partial changes and failed. Submitting the next worker as is means overwriting on top of the previous half-broken changes. Shelve it with &lt;code&gt;git stash -u&lt;/code&gt;, then append to the task file "the previous run exited non-zero. The previous changes have been shelved. Start by identifying the cause," and resubmit.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Ban "None" in Remaining Risks
&lt;/h3&gt;

&lt;p&gt;Put &lt;code&gt;Remaining Risksには必ず1件以上の懸念事項を書くこと。Noneは禁止。&lt;/code&gt; in the task file. A handoff file with consecutive Nones is a sign that Codex is omitting risk descriptions. With concerns continually written down, later workers and reviewers inherit the points that deserve attention.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Put the date/time and task ID in the handoff file's filename
&lt;/h3&gt;

&lt;p&gt;Naming like &lt;code&gt;handoff-20260824-api-fix.md&lt;/code&gt; lets you trace multiple worker results chronologically. You can check the latest completion with &lt;code&gt;ls -lt handoffs/&lt;/code&gt;, and combining it with &lt;code&gt;grep -rl "State: failed" status/&lt;/code&gt; gets you a list of failed tasks. Just making filenames meaningful makes post-processing scripts far easier to write.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Wire status file polling to a Slack notification
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"State: completed&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;State: failed"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATUS_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"State:"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATUS_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLACK_WEBHOOK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Worker &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$STATUS_FILE&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;
    &lt;span class="nb"&gt;break
  &lt;/span&gt;&lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;15
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run Codex workers on a late-night cron and receive completion or failure over Slack. When you wake up, just reading the handoff file tells you the results of the overnight batch. With this setup I've actually received "3 tasks completed while I slept, 1 failed" two to three times a week.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Separate, at design time, what Codex writes from what the shell records
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codex writes&lt;/strong&gt;: Summary / Files Changed / Validation / Remaining Risks (the task's content)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shell writes&lt;/strong&gt;: State / timestamp / Branch / Worktree / git status --short (measured values at execution time)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This separation is condensed into the prompt's single line, &lt;code&gt;Do not write handoff or status files yourself; the launcher manages those artifacts.&lt;/code&gt; Break the boundary and Codex's guesses ("probably the main branch") get mixed with the shell's measurements (the result of &lt;code&gt;git rev-parse --abbrev-ref HEAD&lt;/code&gt;), and cross-check verification falls apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Manage prompt changes as git diffs of the script itself
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;cat &amp;gt; "$prompt_file" &amp;lt;&amp;lt;EOF ... EOF&lt;/code&gt; inside the script is embedded in the script body. Every time you change a prompt rule, a diff shows up in &lt;code&gt;git diff scripts/orchestrate-codex-worker.sh&lt;/code&gt;. That's actually an advantage: &lt;code&gt;git log scripts/orchestrate-codex-worker.sh&lt;/code&gt; lets you trace the prompt's change history. Write "why I added the rule banning None in Remaining Risks" in the commit message, and months later when you read the script, the intent is clear.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. Prepare to add stderr capture later
&lt;/h3&gt;

&lt;p&gt;The current script doesn't capture stderr. The cause of a failure vanishes the instant it hits the terminal. It's solved just by routing stderr to a separate file with &lt;code&gt;codex exec ... 2&amp;gt;"$error_file"&lt;/code&gt; and adding a line that runs &lt;code&gt;cat "$error_file"&lt;/code&gt; into the handoff file on failure. Making this change after the script has stabilized dramatically shortens failure-investigation time.&lt;/p&gt;

&lt;h3&gt;
  
  
  12. Templatize task files instead of making them disposable
&lt;/h3&gt;

&lt;p&gt;If you repeat the same type of work (e.g. "add validation to a new API endpoint"), templatize the task file. Create &lt;code&gt;tasks/templates/add-validation.md&lt;/code&gt; and swap in just the target filename with &lt;code&gt;sed&lt;/code&gt; before the call. Because the instructions to Codex stay consistent, the handoff file's structure stays stable too, and &lt;code&gt;grep&lt;/code&gt; and post-processing scripts are less likely to break.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The reasons delegation to Codex falls apart are almost always fixed. You believe the output "completed" and skip the cross-check that follows — that's it. What orchestrate-codex-worker.sh does in 108 lines is a design that structurally refuses to allow that omission.&lt;/p&gt;

&lt;p&gt;Don't hide errors, with &lt;code&gt;set -euo pipefail&lt;/code&gt;. Separate the three roles of task/handoff/status files to divide the concerns. Branch name, worktree path, git status — values that need to be measured are written by the shell, not by Codex. Force four sections to demand a format from Codex's output. Prevent ANSI code contamination at the entrance with &lt;code&gt;--color never&lt;/code&gt;. Reclaim temp files no matter which path exits, with &lt;code&gt;trap cleanup EXIT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each of these design decisions is unremarkable on its own. Combined, they form a loop where you can confirm "whether what Codex said was actually done" in 30 seconds.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>bash</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Editing the plist Changed Nothing: launchd Kept Running My Old 30s Timeout Until 31 Lines of Bash Fixed It</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sat, 29 Aug 2026 11:00:07 +0000</pubDate>
      <link>https://dev.to/bokuwalily/editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until-31-lines-of-bash-10d9</link>
      <guid>https://dev.to/bokuwalily/editing-the-plist-changed-nothing-launchd-kept-running-my-old-30s-timeout-until-31-lines-of-bash-10d9</guid>
      <description>&lt;p&gt;I'm Lily. I made ¥100k/month in college, pushed it to ¥600k/month juggling multiple side gigs, lost all of it when I was laid off for reasons that had nothing to do with me, spent six months rebuilding an autonomous Claude Code environment, and now run ¥1.2M/month in revenue.&lt;/p&gt;

&lt;p&gt;Here's the concrete before/after this article is about: I changed a timeout from &lt;code&gt;30&lt;/code&gt; to &lt;code&gt;45&lt;/code&gt; in a plist, and launchd kept running the job at &lt;code&gt;30&lt;/code&gt; — for as long as the job stayed loaded. The fix is a 31-line shell script that compares the two values and re-syncs only when the job is idle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this mechanism matters
&lt;/h2&gt;

&lt;h3&gt;
  
  
  launchd "freezes" environment variables at load time
&lt;/h3&gt;

&lt;p&gt;Say you're running automated social-media likes across several lanes. You want to stretch one lane's timeout from 30 seconds to 45. You open &lt;code&gt;~/Library/LaunchAgents/com.lily.autolike.lane1.plist&lt;/code&gt; in an editor, change &lt;code&gt;AUTOLIKE_TIMEOUT_SEC&lt;/code&gt; under &lt;code&gt;EnvironmentVariables&lt;/code&gt; from &lt;code&gt;30&lt;/code&gt; to &lt;code&gt;45&lt;/code&gt;, save, and close the window thinking "that should take effect on the next run."&lt;/p&gt;

&lt;p&gt;The next morning, the logs show timeout errors at exactly the same rate as yesterday.&lt;/p&gt;

&lt;p&gt;This comes from a fundamental property of how launchd works. macOS launchd reads the plist at the moment it &lt;code&gt;bootstrap&lt;/code&gt;s a job, and freezes the environment variables into that job's definition. Rewriting the plist file afterwards changes nothing about the environment variables of a job already loaded in memory. A silent divergence opens up between the plist on the filesystem and the execution definition launchd is holding.&lt;/p&gt;

&lt;p&gt;You can confirm that divergence by running &lt;code&gt;launchctl print gui/$(id -u)/com.lily.autolike.lane1&lt;/code&gt;. If the output still contains a line reading &lt;code&gt;AUTOLIKE_TIMEOUT_SEC =&amp;gt; 30&lt;/code&gt;, launchd is still operating on the old value. Even though the plist file says &lt;code&gt;45&lt;/code&gt;, the job keeps running with &lt;code&gt;30&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is simple: unload the job with &lt;code&gt;launchctl bootout&lt;/code&gt; and load it again with &lt;code&gt;launchctl bootstrap&lt;/code&gt;. But that's exactly where the real problem starts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Killing a running job destroys that run's results
&lt;/h3&gt;

&lt;p&gt;Think about how much work the auto-like script does in a single run: authenticating to the target account, fetching the feed, liking items one by one, writing out the log when it's done — all of that in order, as one "run." If you fire &lt;code&gt;launchctl bootout&lt;/code&gt; in the middle of it, the process is force-killed and every like that run performed is lost. The comment at the top of the script spells this out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 走行中のジョブを bootout するとその run のいいねが丸ごと消えるため、必ず待つ。
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;em&gt;"If you bootout a job while it's running, that run's likes are wiped out entirely — so always wait."&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;In an environment where multiple lanes run 24 hours a day, this accumulates. On an automation platform backing ¥1.2M/month in revenue, "I killed one job" is not a joke. Especially late at night when several lanes are running at once, a script that issues &lt;code&gt;bootout&lt;/code&gt; without checking anything is a landmine that shreds your own output.&lt;/p&gt;

&lt;p&gt;But "change the setting, then manually confirm the job is idle before reloading" isn't an autonomous environment either. Check whether it's stopped, and quietly re-sync only the lanes that are stopped — handing that judgment to the script is the design philosophy behind &lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building an "environment," not doing "work"
&lt;/h3&gt;

&lt;p&gt;Rebuilding from zero income taught me something. There are two kinds of people making money on the side: people who &lt;em&gt;do&lt;/em&gt; the work, and people who build an environment that &lt;em&gt;keeps doing&lt;/em&gt; the work.&lt;/p&gt;

&lt;p&gt;The first kind logs in every day and moves their hands; when their hands stop, the income stops. The second kind sets up the environment once and owns a system that keeps running whether they're there or not. Spending six months building an autonomous Claude Code environment came from exactly that idea. Keep multiple automation jobs resident under launchd, and when you change a setting the environment catches up on its own — having a mechanism where the config files and the running state stay in agreement without human intervention is the core of a ¥1.2M/month autonomous platform.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt; is a small, 31-line script, but the "build an environment" mindset is condensed into it. Instead of reloading by hand, the script periodically detects the divergence and repairs it automatically, aiming only at safe moments. That &lt;em&gt;is&lt;/em&gt; an environment where "a human has to step in every time a setting changes" no longer holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The overall flow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Big picture
&lt;/h3&gt;

&lt;p&gt;The script processes &lt;code&gt;com.lily.autolike.*.plist&lt;/code&gt; under &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt; one file at a time. It skips files with &lt;code&gt;.bak-*&lt;/code&gt; and &lt;code&gt;.disabled*&lt;/code&gt; suffixes, and for the remaining files it compares the configured value in the plist against the running value in launchd.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/Library/LaunchAgents/com.lily.autolike.*.plist
  (excluding .bak-* / .disabled*)
          │
          │ ① read the configured value from the plist with PlistBuddy
          ▼
      want=45  ← the target value written in the plist
          │
          │ ② read the value inside launchd with launchctl print
          ▼
      have=30  ← the value launchd is actually holding
          │
          ├─ want == have ──────────── skip (no change)
          │
          └─ want != have
                │
                │ ③ check the PID with launchctl list
                │
                ├─ pid present (running)
                │     └── log a "deferred" line → wait until the next run
                │
                └─ pid absent (stopped)
                      ├── unload with launchctl bootout
                      ├── reload with launchctl bootstrap
                      └── record "reloaded 30 -&amp;gt; 45" in the log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  ① Read the target value from the plist (want)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/libexec/PlistBuddy
&lt;span class="nv"&gt;want&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$PB&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"Print :EnvironmentVariables:AUTOLIKE_TIMEOUT_SEC"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PlistBuddy&lt;/code&gt; is the standard macOS command for manipulating plists. With &lt;code&gt;-c "Print :EnvironmentVariables:AUTOLIKE_TIMEOUT_SEC"&lt;/code&gt; you can reference a nested key directly. &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; throws away errors, and &lt;code&gt;|| continue&lt;/code&gt; skips plists that don't have that key. This &lt;code&gt;want&lt;/code&gt; is "the value that, according to the plist file, ought to be in effect."&lt;/p&gt;

&lt;h3&gt;
  
  
  ② Read the value launchd actually holds (have)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;D&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;have&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl print &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/.*AUTOLIKE_TIMEOUT_SEC =&amp;gt; \([0-9]*\).*/\1/p'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;launchctl print gui/501/com.lily.autolike.lane1&lt;/code&gt; (501 being the UID) dumps that job's complete information in text form. From that, &lt;code&gt;sed&lt;/code&gt; looks for the &lt;code&gt;AUTOLIKE_TIMEOUT_SEC =&amp;gt; 30&lt;/code&gt; pattern and extracts only the numeric part (&lt;code&gt;30&lt;/code&gt;). &lt;code&gt;head -1&lt;/code&gt; prevents multiple matches.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;have&lt;/code&gt; comes back empty (the job itself isn't loaded), &lt;code&gt;|| continue&lt;/code&gt; skips it. Attempting &lt;code&gt;bootout&lt;/code&gt; against a job that isn't loaded makes &lt;code&gt;launchctl&lt;/code&gt; return an error. This &lt;code&gt;have&lt;/code&gt; is "the value that currently exists in launchd's memory and is actually running." The key point is that it reads launchd's internal state directly rather than the filesystem — the runtime reality you could never discover by reading the plist alone becomes visible for the first time here.&lt;/p&gt;

&lt;h3&gt;
  
  
  ③ Comparing values and checking the PID
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
&lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;l&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'$3==l{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt; 実行中(pid=&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;) のため見送り (&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;continue
fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;want == have&lt;/code&gt;, no change is needed, so it skips. If there is a difference, it next checks that job's current PID with &lt;code&gt;launchctl list&lt;/code&gt;. The output of &lt;code&gt;launchctl list&lt;/code&gt; has three columns — &lt;code&gt;PID&lt;/code&gt;, &lt;code&gt;LastStatus&lt;/code&gt;, &lt;code&gt;Label&lt;/code&gt; — and &lt;code&gt;awk&lt;/code&gt; pulls the PID from the row whose label matches. Unless the PID is &lt;code&gt;-&lt;/code&gt; (launchd's conventional notation for "stopped"), the job is considered running and "defer" is chosen.&lt;/p&gt;

&lt;p&gt;The log records lines in this form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-08-23 08:30:00] com.lily.autolike.lane1 実行中(pid=12345) のため見送り (30 -&amp;gt; 45)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;em&gt;"deferred because it's running (pid=12345)"&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;So the intent — "I wanted to change this, but I'm waiting for the right timing" — is still legible the next time you check. Since this script is registered with launchd as a periodic job, even a deferred lane gets checked again at the next launch, and re-syncs automatically as soon as there's an opening.&lt;/p&gt;

&lt;h3&gt;
  
  
  ④ Re-syncing with bootout → bootstrap
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl bootout &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="k"&gt;if &lt;/span&gt;launchctl bootstrap &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt; reloaded &lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;changed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;changed+1&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt; RELOAD FAILED"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only when the job is confirmed stopped does it run &lt;code&gt;bootout&lt;/code&gt; → &lt;code&gt;bootstrap&lt;/code&gt;. &lt;code&gt;bootout&lt;/code&gt; unloads it once, and &lt;code&gt;bootstrap&lt;/code&gt; re-reads the plist and loads it again, which pulls the new value of &lt;code&gt;AUTOLIKE_TIMEOUT_SEC&lt;/code&gt; into launchd's memory.&lt;/p&gt;

&lt;p&gt;A successful log line looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-08-23 08:30:05] com.lily.autolike.lane1 reloaded 30 -&amp;gt; 45
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting the before and after values side by side makes "what changed" obvious at a glance. On failure, &lt;code&gt;RELOAD FAILED&lt;/code&gt; is left behind and the next run retries. At the end of the script, it records a summary of how many re-syncs happened in that single run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] done changed=&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;changed=0&lt;/code&gt; (all lanes already in agreement), it writes nothing. With this design, the log file only accumulates records "when something changed," which suppresses day-to-day noise. In an autonomous environment where you read across dozens of log files to understand state, noise-free logs translate directly into debugging speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Making PATH explicit&lt;/strong&gt; is handled by a single line at the top of the script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin:/bin:/usr/sbin:/sbin&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;PATH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A script running as a launchd job starts in a minimal execution environment that differs from a normal terminal session. Without an explicit PATH, path resolution for &lt;code&gt;PlistBuddy&lt;/code&gt; and &lt;code&gt;launchctl&lt;/code&gt; fails, and you sink into the classic swamp of "it works on my machine but not when launchd starts it." Together with &lt;code&gt;set -uo pipefail&lt;/code&gt;, this structure blocks unintended behavior from the outset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The three ways &lt;code&gt;set -uo pipefail&lt;/code&gt; keeps things from breaking
&lt;/h3&gt;

&lt;p&gt;The second line of the script is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that one line, the script can fall into a state where it "appears to work but does nothing." &lt;code&gt;-u&lt;/code&gt; makes references to undefined variables an error. Suppose you typo'd the variable name used to fetch &lt;code&gt;$have&lt;/code&gt; somewhere. Without &lt;code&gt;-u&lt;/code&gt;, the typo'd variable expands to an empty string, and &lt;code&gt;$have&lt;/code&gt; in &lt;code&gt;[ "$want" = "$have" ]&lt;/code&gt; becomes empty. "want is &lt;code&gt;45&lt;/code&gt;, have is an empty string" doesn't match, so the comparison is false and it proceeds to the PID check. Naturally that label isn't in &lt;code&gt;launchctl list&lt;/code&gt;'s output, so no PID is retrieved, &lt;code&gt;[ -n "$pid" ]&lt;/code&gt; is false — meaning it misjudges the job as "stopped" and runs &lt;code&gt;bootout&lt;/code&gt; → &lt;code&gt;bootstrap&lt;/code&gt;. Sometimes nothing breaks and the re-sync just happens, but the possibility always remains that "something being empty" because of a typo'd variable throws off some other piece of logic. &lt;code&gt;-u&lt;/code&gt; forces early discovery of this class of bug by making the shell stop with an error.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-o pipefail&lt;/code&gt; controls the exit code of the pipeline as a whole. Without &lt;code&gt;-o pipefail&lt;/code&gt;, a pipeline's exit code is that of the last command. That is, even if the output of &lt;code&gt;launchctl print "$D/$L" 2&amp;gt;/dev/null | sed -n '...' | head -1&lt;/code&gt; is empty, &lt;code&gt;$?&lt;/code&gt; is 0 as long as &lt;code&gt;head -1&lt;/code&gt; exits normally (exit code 0). Since this script doesn't use &lt;code&gt;set -e&lt;/code&gt;, that isn't an immediate problem — but because the presence or absence of &lt;code&gt;pipefail&lt;/code&gt; can change behavior when you modify this script in the future, it's safer to state it explicitly from the start.&lt;/p&gt;

&lt;p&gt;Writing plain &lt;code&gt;-uo&lt;/code&gt; without the &lt;code&gt;-o&lt;/code&gt; has the same effect, but writing &lt;code&gt;set -uo pipefail&lt;/code&gt; explicitly conveys the intent that "pipefail was configured deliberately."&lt;/p&gt;

&lt;h3&gt;
  
  
  Exclusion design with glob + case
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;P &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/LaunchAgents/com.lily.autolike.&lt;span class="k"&gt;*&lt;/span&gt;.plist&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;.bak-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.disabled&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After narrowing target files with a glob pattern, there's a second stage that excludes with &lt;code&gt;case&lt;/code&gt;. The reason for using &lt;code&gt;case&lt;/code&gt; rather than &lt;code&gt;find&lt;/code&gt; options or negated glob syntax is clear. Shell negated globs (extglob forms like &lt;code&gt;!(*.bak*)&lt;/code&gt;) are poorly portable, and bash and zsh differ in the options needed to enable them. Scripts launched from launchd run under &lt;code&gt;/bin/bash&lt;/code&gt;, so unless you explicitly enable extglob with &lt;code&gt;set&lt;/code&gt;, you can't use it. Also, when exclusion patterns grow, &lt;code&gt;case&lt;/code&gt; only needs another &lt;code&gt;|&lt;/code&gt;, which keeps it readable.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.bak-&lt;/code&gt; suffix becomes a problem because text editors sometimes leave automatically created backup files inside the LaunchAgents directory. A file like &lt;code&gt;com.lily.autolike.lane1.plist.bak-20260823&lt;/code&gt; doesn't match &lt;code&gt;*.plist&lt;/code&gt;, but depending on the editor, some create the backup under a different name next to the &lt;code&gt;.plist&lt;/code&gt; rather than in &lt;code&gt;.plist.bak&lt;/code&gt; form. &lt;code&gt;.disabled&lt;/code&gt; is a convention for renaming a job when you want to disable it manually; including this exclusion lets you express "this file isn't in use right now" without resorting to &lt;code&gt;launchctl unload&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exactly how the sed extraction works
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;have&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl print &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/.*AUTOLIKE_TIMEOUT_SEC =&amp;gt; \([0-9]*\).*/\1/p'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the output of &lt;code&gt;launchctl print gui/501/com.lily.autolike.lane1&lt;/code&gt;, the part carrying environment variables looks like this (&lt;code&gt;501&lt;/code&gt; being the actual UID):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;AUTOLIKE_TIMEOUT_SEC&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
    &lt;span class="no"&gt;HOME&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/Users/&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sed expression &lt;code&gt;s/.*AUTOLIKE_TIMEOUT_SEC =&amp;gt; \([0-9]*\).*/\1/p&lt;/code&gt; targets the whole line, swallows everything before and after the key name with &lt;code&gt;.*&lt;/code&gt;, captures only the numeric part into a capture group with &lt;code&gt;\([0-9]*\)&lt;/code&gt;, and prints it as &lt;code&gt;\1&lt;/code&gt;. The combination of the &lt;code&gt;-n&lt;/code&gt; flag and &lt;code&gt;p&lt;/code&gt; means "print only matching lines," so nothing is printed if the environment variable doesn't exist.&lt;/p&gt;

&lt;p&gt;Narrowing to the first line with &lt;code&gt;head -1&lt;/code&gt; prevents multiple matches in case a job name or another setting happens to contain a string resembling &lt;code&gt;AUTOLIKE_TIMEOUT_SEC =&amp;gt;&lt;/code&gt;. It can't really happen in practice, but defensively taking a single line eliminates the situation where "multiple lines come back, &lt;code&gt;$have&lt;/code&gt; becomes multi-line, and every subsequent comparison fails."&lt;/p&gt;

&lt;h3&gt;
  
  
  PID extraction with awk, and what "-" means
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;l&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'$3==l{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output of &lt;code&gt;launchctl list&lt;/code&gt; has three columns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PID     Status  Label
12345   0       com.lily.autolike.lane1
-       0       com.lily.autolike.lane2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first column is the PID: a number when the job is currently running, and &lt;code&gt;-&lt;/code&gt; when it's stopped (waiting for the next StartInterval). &lt;code&gt;awk -v l="$L" '$3==l{print $1}'&lt;/code&gt; prints the first column of the row whose third column matches the label.&lt;/p&gt;

&lt;p&gt;Testing with only &lt;code&gt;[ -n "$pid" ]&lt;/code&gt; is not enough. &lt;code&gt;-n&lt;/code&gt; tests "not an empty string," so the string &lt;code&gt;-&lt;/code&gt; is non-empty and evaluates to true. In other words, you get a state where "a stopped job with PID &lt;code&gt;-&lt;/code&gt; is misjudged as running and deferred forever." The additional &lt;code&gt;[ "$pid" != "-" ]&lt;/code&gt; check prevents that misjudgment.&lt;/p&gt;

&lt;h3&gt;
  
  
  The idempotence carried by &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; on bootout
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl bootout &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="k"&gt;if &lt;/span&gt;launchctl bootstrap &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;bootout&lt;/code&gt; line has neither &lt;code&gt;|| exit 1&lt;/code&gt; nor &lt;code&gt;|| continue&lt;/code&gt;. The design throws away errors with &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt;, ignores the exit code, and moves on to &lt;code&gt;bootstrap&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's a reason for this. &lt;code&gt;bootout&lt;/code&gt; may be called when the target job is already unloaded (if another script booted it out first, or right after the job crashed). In that case &lt;code&gt;launchctl bootout&lt;/code&gt; returns an error, but the goal is "getting the job into an unloaded state," and if it's already unloaded, that's equivalent to success. Stopping on the error means the following &lt;code&gt;bootstrap&lt;/code&gt; never runs and the whole re-sync fails. Discarding errors with &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; and unconditionally proceeding to &lt;code&gt;bootstrap&lt;/code&gt; secures idempotence.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bootstrap&lt;/code&gt;, on the other hand, has its success checked with &lt;code&gt;if&lt;/code&gt;. A &lt;code&gt;bootstrap&lt;/code&gt; failure — a syntax error in the plist file, insufficient permissions — means the abnormal state "the re-sync failed," so the design leaves &lt;code&gt;RELOAD FAILED&lt;/code&gt; in the log and retries on the next run.&lt;/p&gt;

&lt;h3&gt;
  
  
  The design philosophy of the changed counter and the logs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;changed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="c"&gt;# ... ループ内で changed=$((changed+1)) ...&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] done changed=&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;changed&lt;/code&gt; is 0, no summary line is printed. That's intentional. This script is registered as a periodic launchd job. All lanes matching on &lt;code&gt;want == have&lt;/code&gt; is the normal state, and if every normal run left one line in the log, you'd have hundreds of lines within a few days. Designing it so "a record is left only when something is wrong" means that the moment you open the log file, only "the timestamp and content of the changes" enters your eyes.&lt;/p&gt;

&lt;p&gt;"Deferred," meanwhile, is logged every time the loop comes around.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt; 実行中(pid=&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;) のため見送り (&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's deliberate. When you're debugging "why hasn't my change taken effect," several consecutive deferral lines immediately tell you "there's a running job and it's waiting for an opening." If a &lt;code&gt;reloaded&lt;/code&gt; line eventually appears, you can confirm it completed normally; if &lt;code&gt;RELOAD FAILED&lt;/code&gt; arrives, you learn about the anomaly. Because log density translates directly into debugging speed, the design is neither "always verbose" nor "always silent" but "record only changes and waits."&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I got stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Misjudging jobs with PID "-" as "running" and deferring forever
&lt;/h3&gt;

&lt;p&gt;When I first wrote the script, the PID check looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;l&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'$3==l{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"実行中のため見送り"&lt;/span&gt;
  &lt;span class="k"&gt;continue
fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The judgment is "if the PID isn't empty, it's running." I registered this and ran it for a day, and the log was nothing but an endless stream of "deferred."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-08-10 01:00:00] com.lily.autolike.lane2 実行中(pid=-) のため見送り (30 -&amp;gt; 45)
[2026-08-10 02:00:00] com.lily.autolike.lane2 実行中(pid=-) のため見送り (30 -&amp;gt; 45)
[2026-08-10 03:00:00] com.lily.autolike.lane2 実行中(pid=-) のため見送り (30 -&amp;gt; 45)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing the output &lt;code&gt;pid=-&lt;/code&gt; is what tipped me off. For a stopped job, &lt;code&gt;launchctl list&lt;/code&gt; doesn't leave the PID column empty — it puts in the single character &lt;code&gt;-&lt;/code&gt;. Since &lt;code&gt;-&lt;/code&gt; isn't an empty string, &lt;code&gt;[ -n "$pid" ]&lt;/code&gt; is always true, and no matter how long you wait it keeps being judged "running."&lt;/p&gt;

&lt;p&gt;The fix is a single added line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it judges "running" only when a PID exists and it isn't the &lt;code&gt;-&lt;/code&gt; that indicates stopped. This is a pit you're guaranteed to fall into if you don't know launchd's conventional notation, and &lt;code&gt;man launchctl&lt;/code&gt; only mentions it in passing. I only noticed after looking at actual &lt;code&gt;launchctl list&lt;/code&gt; output with my own eyes.&lt;/p&gt;

&lt;h3&gt;
  
  
  PlistBuddy not found because PATH wasn't set, so everything got skipped
&lt;/h3&gt;

&lt;p&gt;When I ran the script manually from the terminal, it worked perfectly. Register the same script with launchd for periodic execution, and the log file was never updated at all. Not only was the &lt;code&gt;changed&lt;/code&gt; counter not moving, not even deferral lines were coming out.&lt;/p&gt;

&lt;p&gt;Because of &lt;code&gt;set -uo pipefail&lt;/code&gt;, it had to be stopping on an error. Adding &lt;code&gt;exec &amp;gt;&amp;gt; /tmp/debug.log 2&amp;gt;&amp;amp;1&lt;/code&gt; at the top of the script to capture debug output for the experiment produced this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;/bin/bash: /usr/libexec/PlistBuddy: No such file or directory
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PlistBuddy&lt;/code&gt; uses the absolute path &lt;code&gt;/usr/libexec/PlistBuddy&lt;/code&gt;, yet it wasn't found — the cause was that despite line 14 specifying the absolute path with &lt;code&gt;PB=/usr/libexec/PlistBuddy&lt;/code&gt;, the expansion of &lt;code&gt;$PB&lt;/code&gt; was coming out empty for some reason. The &lt;code&gt;-u&lt;/code&gt; option turned the empty-variable reference into an error, and the &lt;code&gt;for&lt;/code&gt; loop terminated in a way closer to &lt;code&gt;exit&lt;/code&gt; than &lt;code&gt;continue&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Digging in, I found leftover traces before &lt;code&gt;PB=/usr/libexec/PlistBuddy&lt;/code&gt; of an attempt to use &lt;code&gt;PlistBuddy&lt;/code&gt; through a different variable (commented out, but actually causing a separate problem because of &lt;code&gt;-u&lt;/code&gt;). The variable reference had been broken during cleanup — that's the precise cause.&lt;/p&gt;

&lt;p&gt;But the essential problem I discovered in parallel was PATH. A shell script launched from launchd inherits none of the PATH set up by a normal terminal session (things like &lt;code&gt;/opt/homebrew/bin&lt;/code&gt; added in &lt;code&gt;.zshrc&lt;/code&gt; or &lt;code&gt;.zprofile&lt;/code&gt;). Without explicitly setting &lt;code&gt;PATH=/usr/bin:/bin:/usr/sbin:/sbin&lt;/code&gt;, the commands you use inside the script end up in the "works locally, doesn't work under launchd" state.&lt;/p&gt;

&lt;p&gt;Since &lt;code&gt;PlistBuddy&lt;/code&gt; lives in &lt;code&gt;/usr/libexec/&lt;/code&gt;, the absolute-path form &lt;code&gt;PB=/usr/libexec/PlistBuddy&lt;/code&gt; is fine, but &lt;code&gt;launchctl&lt;/code&gt;, &lt;code&gt;sed&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt;, and &lt;code&gt;date&lt;/code&gt; are all resolved via PATH. &lt;code&gt;/usr/bin:/bin:/usr/sbin:/sbin&lt;/code&gt; is standard in launchd's minimal environment, but in some environments it can be even narrower. The explicit PATH on line 2 is written as "insurance against ever stepping on this problem twice."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin:/bin:/usr/sbin:/sbin&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;PATH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;launchctl print&lt;/code&gt; output came back empty and every lane was skipped
&lt;/h3&gt;

&lt;p&gt;One morning I hit a situation where &lt;code&gt;have&lt;/code&gt; was empty for every lane, so the script skipped everything. Nothing was in the log, so the state was "exiting normally but skipping."&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;launchctl print gui/501/com.lily.autolike.lane1&lt;/code&gt; directly in the terminal returned an error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Could not find service "com.lily.autolike.lane1" in domain for port
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a job not loaded into launchd, &lt;code&gt;launchctl print&lt;/code&gt; returns an error rather than an empty string. Since &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; discards the error, &lt;code&gt;have&lt;/code&gt; becomes empty and &lt;code&gt;[ -n "$have" ] || continue&lt;/code&gt; skips it.&lt;/p&gt;

&lt;p&gt;Tracing the cause back, I found that during some other configuration work the night before I had mistakenly run &lt;code&gt;launchctl bootout&lt;/code&gt; against several lanes and reached morning having forgotten the &lt;code&gt;bootstrap&lt;/code&gt;. When the jobs themselves don't exist, &lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt; can do nothing. A feature for "raising an alert when it detects an unloaded job" is outside this script's scope.&lt;/p&gt;

&lt;p&gt;That's a limit of the design, not a bug. This script's responsibility is "fixing divergence in the environment variables of loaded jobs," and recovery when a job doesn't exist is handled by a different mechanism. Clearly separating what the script does from what it doesn't cuts off the temptation for one routine to bloat and solve several problems in one piece of code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Processing backup files flooded the error log
&lt;/h3&gt;

&lt;p&gt;During a period when I was editing plists directly in a text editor, the editor was creating automatic backups inside &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt;, in a form like &lt;code&gt;com.lily.autolike.lane1.plist~&lt;/code&gt; (&lt;code&gt;~&lt;/code&gt; being the backup suffix some editors use). That file doesn't match the glob &lt;code&gt;*.plist&lt;/code&gt;, so it was harmless — but a file left in the form &lt;code&gt;com.lily.autolike.lane1.plist.bak-20260810&lt;/code&gt; did match the glob.&lt;/p&gt;

&lt;p&gt;Because the backup file also starts with &lt;code&gt;com.lily.autolike.&lt;/code&gt; and ends with &lt;code&gt;.plist&lt;/code&gt;, the script treats it as a processing target. Read the backup's &lt;code&gt;want&lt;/code&gt; with &lt;code&gt;PlistBuddy&lt;/code&gt;, read the job's &lt;code&gt;have&lt;/code&gt; with &lt;code&gt;launchctl print&lt;/code&gt;, and — if they naturally agree — skip. That flow is fine, but when the backup file's contents held an older value, the backup's &lt;code&gt;want&lt;/code&gt; differed from the running job's &lt;code&gt;have&lt;/code&gt; and an unnecessary &lt;code&gt;bootout&lt;/code&gt; → &lt;code&gt;bootstrap&lt;/code&gt; fired.&lt;/p&gt;

&lt;p&gt;The exclusion pattern &lt;code&gt;case "$P" in *.bak-*|*.disabled*) continue ;; esac&lt;/code&gt; was added from that experience. It's tuned to the editor's automatic backup naming convention with the &lt;code&gt;.bak-&lt;/code&gt; pattern (with the hyphen, because plain &lt;code&gt;.bak&lt;/code&gt; risks colliding with other cases). In an environment that has a backup strategy, crushing this kind of side effect in advance is directly tied to the stability of an autonomous environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;Here's a complete rundown of the traps I actually stepped on while wiring a launchd × plist re-sync script into an autonomous environment. On top of the four detailed above (misjudging PID &lt;code&gt;-&lt;/code&gt;, unset PATH, empty &lt;code&gt;launchctl print&lt;/code&gt;, backup files sneaking in), I've organized the patterns that keep catching you during the scaling phase.&lt;/p&gt;

&lt;h3&gt;
  
  
  Runtime environment and startup
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;launchd's PATH is only &lt;code&gt;/usr/bin:/bin:/usr/sbin:/sbin&lt;/code&gt;.&lt;/strong&gt; It works in the terminal but gives &lt;code&gt;command not found&lt;/code&gt; when launched by launchd. Nothing starts until you write &lt;code&gt;PATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH&lt;/code&gt; on the first line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PlistBuddy cannot be resolved via PATH.&lt;/strong&gt; Keep it in a variable as the absolute path &lt;code&gt;/usr/libexec/PlistBuddy&lt;/code&gt; (&lt;code&gt;PB=/usr/libexec/PlistBuddy&lt;/code&gt;). Trying to add it to PATH is pointless because &lt;code&gt;/usr/libexec&lt;/code&gt; is outside the default PATH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting to give the script &lt;code&gt;exec&lt;/code&gt; permission.&lt;/strong&gt; launchd executes the path specified in &lt;code&gt;ProgramArguments&lt;/code&gt;, but if you forget &lt;code&gt;chmod +x&lt;/code&gt; it dies instantly with &lt;code&gt;Permission denied&lt;/code&gt;. Nothing is left in the log either, so diagnosis is slow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing plist &lt;code&gt;Program&lt;/code&gt; and &lt;code&gt;ProgramArguments[0]&lt;/code&gt;.&lt;/strong&gt; If you specify &lt;code&gt;Program&lt;/code&gt; without knowing that &lt;code&gt;ProgramArguments[0]&lt;/code&gt; is then treated as argv[0], and write both, your own script — not &lt;code&gt;/bin/bash&lt;/code&gt; — gets passed directly and is treated as a syntax error.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Label / filename mismatches
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The plist's Label value doesn't match the filename.&lt;/strong&gt; The &lt;code&gt;awk -v l="$L"&lt;/code&gt; used to filter &lt;code&gt;launchctl list&lt;/code&gt; takes its value from &lt;code&gt;$L=$(basename "$P" .plist)&lt;/code&gt;, so if the filename and Label don't match, no PID is retrieved, it always misjudges "stopped," and an unnecessary bootout fires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving test plists that the glob pattern catches.&lt;/strong&gt; If you drop in something named &lt;code&gt;com.lily.autolike.test.plist&lt;/code&gt; for verification, it's processed just like production. Unless you follow the convention of excluding it with a &lt;code&gt;.disabled&lt;/code&gt; suffix, a test configuration can overwrite the production environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uppercase letters or spaces sneaking into the Label.&lt;/strong&gt; The third column of &lt;code&gt;launchctl list&lt;/code&gt; prints the Label itself, so uppercase notation that differs from the filename makes the &lt;code&gt;awk&lt;/code&gt; match fail. Standardize the naming convention so everything after &lt;code&gt;com.lily.autolike.&lt;/code&gt; is all lowercase, dot-separated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  launchctl output format
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The output format of launchctl print shifts subtly across macOS versions.&lt;/strong&gt; With the macOS 14 → 15 upgrade, the indentation of &lt;code&gt;environment = {&lt;/code&gt; changed and the sed pattern stopped matching. Because &lt;code&gt;sed -n 's/.*AUTOLIKE_TIMEOUT_SEC =&amp;gt; \([0-9]*\).*/\1/p'&lt;/code&gt; is designed so the &lt;code&gt;.*&lt;/code&gt; swallows both sides, it tolerates most indentation changes — but if the number of spaces around the key name changes, the sed pattern needs revisiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;launchctl list can mix in labels from other processes across domains.&lt;/strong&gt; In an environment where multiple users are logged in, another user's jobs can appear in &lt;code&gt;launchctl list&lt;/code&gt;. Starting label names with &lt;code&gt;com.lily.&lt;/code&gt; makes real damage unlikely, but the premise is using exact matching (&lt;code&gt;$3==l&lt;/code&gt;) so &lt;code&gt;awk&lt;/code&gt; doesn't react to unexpected labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Getting the domain+label format passed to &lt;code&gt;launchctl print&lt;/code&gt; wrong.&lt;/strong&gt; &lt;code&gt;gui/$(id -u)/com.lily.autolike.lane1&lt;/code&gt; is correct, but writing &lt;code&gt;user/$(id -u)/…&lt;/code&gt; points at a different domain. Making &lt;code&gt;D="gui/$(id -u)"&lt;/code&gt; a variable managed in one place erases the risk of a typo propagating across the whole script.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Environment variables
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Writing the monitored key name &lt;code&gt;AUTOLIKE_TIMEOUT_SEC&lt;/code&gt; in multiple places in the script.&lt;/strong&gt; It appears twice: the PlistBuddy command that fetches want (line 14) and the sed pattern that fetches have (line 15). To prevent the accident of fixing only one when you change the key name later, it's safer to put it in a variable and consolidate it in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sed's &lt;code&gt;\([0-9]*\)&lt;/code&gt; fails to match when a non-integer value is used.&lt;/strong&gt; If &lt;code&gt;AUTOLIKE_TIMEOUT_SEC&lt;/code&gt; is written with a unit, like &lt;code&gt;45s&lt;/code&gt;, &lt;code&gt;[0-9]*&lt;/code&gt; comes out empty. The value types diverge between PlistBuddy's want and sed's have, they're always judged different as separate strings, and bootout fires every single time. Manage the value as a pure integer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;EnvironmentVariables&lt;/code&gt; key doesn't exist in the plist.&lt;/strong&gt; The &lt;code&gt;|| continue&lt;/code&gt; in &lt;code&gt;$PB -c "Print :EnvironmentVariables:AUTOLIKE_TIMEOUT_SEC" "$P" 2&amp;gt;/dev/null || continue&lt;/code&gt; handles this case, but if you remove &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; while debugging, a "Does Not Exist" error appears and reveals the cause. Since it's designed to stay silent, note that nothing is left in the log even when everything gets skipped.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Logging and diagnostics
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The log directory doesn't exist.&lt;/strong&gt; If the directory of &lt;code&gt;LOG="$HOME/.claude/logs/autolike-plist-reconcile.log"&lt;/code&gt; (&lt;code&gt;~/.claude/logs/&lt;/code&gt;) hasn't been created, &lt;code&gt;echo … &amp;gt;&amp;gt;"$LOG"&lt;/code&gt; errors and the script dies from the write error rather than from &lt;code&gt;set -uo pipefail&lt;/code&gt;'s &lt;code&gt;-u&lt;/code&gt;. Run &lt;code&gt;mkdir -p ~/.claude/logs&lt;/code&gt; before registering the plist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mistaking a continuous &lt;code&gt;changed=0&lt;/code&gt; for "normal."&lt;/strong&gt; It's normal if all lanes agree on &lt;code&gt;want == have&lt;/code&gt;, but the case where have is empty (jobs not loaded) and everything is skipped also produces &lt;code&gt;changed=0&lt;/code&gt; with no log output. Periodically confirm through a separate channel — &lt;code&gt;launchctl list | grep com.lily.autolike&lt;/code&gt; — that all lanes are loaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving hourly deferral lines alone as "no problem."&lt;/strong&gt; If a job never finishes for a long time, the plist change is never reflected, permanently. Unless you have a mechanism that alerts when deferrals continue for N hours, at least build the habit of eyeballing the log weekly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Boundaries of the PID check
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A PID is retrieved, but that process is already a zombie.&lt;/strong&gt; macOS launchd reaps crashed processes quickly so it's rare in practice, but there's a window where a PID remains in &lt;code&gt;launchctl list&lt;/code&gt; while the actual process no longer exists. In that case bootout succeeds but no trace of "stopping something that was running" is left in the log, which makes later debugging harder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In an environment with only long-StartInterval jobs, it feels like "an opening never comes."&lt;/strong&gt; A job with StartInterval 3600 (one hour) runs only once an hour, so the PID is &lt;code&gt;-&lt;/code&gt; only for a short window. As long as the reconcile script itself runs periodically at a short interval (say, 5 minutes), it catches the next opportunity. Conversely, if that job's execution time is comparable to its StartInterval, it's effectively always running and an opportunity to re-sync never arrives.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;p&gt;Here are the rules I organized from real operation, the ones where I thought "I should have done this from the start." I've written out the design philosophy packed into 31 lines in a reproducible form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① Pin PATH on the first line&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin:/bin:/usr/sbin:/sbin&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;PATH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The biggest trap is verifying in the terminal and assuming you're done. Execution via launchd carries only a minimal PATH environment and inherits nothing from &lt;code&gt;.zshrc&lt;/code&gt; or &lt;code&gt;.zprofile&lt;/code&gt;. Pinning PATH on the leading line structurally eliminates the "works locally, doesn't work under launchd" problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② Always write &lt;code&gt;set -uo pipefail&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-u&lt;/code&gt; turns undefined variables into immediate errors, and &lt;code&gt;pipefail&lt;/code&gt; propagates mid-pipeline failures into the exit code. Without this one line, a typo'd variable name or a failed intermediate command passes through "as if nothing happened," and processing continues in a wrong state. Write it without exception as the safety device of a shell script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Put PlistBuddy in a variable as an absolute path&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/libexec/PlistBuddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since it can't be resolved via PATH, keep it in a variable at the top of the script. Even when used in several places, unifying on a single variable reference means a one-line fix if the path ever changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;④ Put the launchd domain specification in a variable&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;D&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing the &lt;code&gt;gui/UID&lt;/code&gt; form inline every time raises typo risk. Consolidating it into one variable means &lt;code&gt;launchctl print "$D/$L"&lt;/code&gt;, &lt;code&gt;launchctl bootout "$D/$L"&lt;/code&gt;, and &lt;code&gt;launchctl bootstrap "$D" "$P"&lt;/code&gt; all follow from a single change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑤ Manage exclusion patterns with &lt;code&gt;case&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;.bak-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.disabled&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rather than relying on extglob or negated globs, excluding with &lt;code&gt;case&lt;/code&gt; is the most portable design. When exclusion patterns grow, you just append with &lt;code&gt;|&lt;/code&gt;. Matching the editor's backup naming convention with &lt;code&gt;.bak-&lt;/code&gt; (with the hyphen) prevents mistaken matches on plain &lt;code&gt;.bak&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑥ Make "skip on failure" explicit when fetching want and have&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;want&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$PB&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"Print :EnvironmentVariables:AUTOLIKE_TIMEOUT_SEC"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
&lt;/span&gt;&lt;span class="nv"&gt;have&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl print &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed want fetch (a plist without the key) skips immediately via &lt;code&gt;|| continue&lt;/code&gt;. A failed have fetch (job not loaded) skips via the empty-string check. The reason the two failure patterns are handled by different techniques is that PlistBuddy returns a non-zero exit code on failure, whereas the &lt;code&gt;launchctl print&lt;/code&gt; pipe can exit 0 as a whole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑦ Explicitly judge a PID of &lt;code&gt;-&lt;/code&gt; as "stopped"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;launchctl list&lt;/code&gt; puts the string &lt;code&gt;-&lt;/code&gt;, not a numeric PID, for stopped jobs. The &lt;code&gt;-n&lt;/code&gt; test alone misjudges &lt;code&gt;-&lt;/code&gt; as "has a PID" and defers forever. Always add &lt;code&gt;[ "$pid" != "-" ]&lt;/code&gt;. Since it isn't spelled out in &lt;code&gt;man launchctl&lt;/code&gt;, this is a pit you can't notice until you look at real output with your own eyes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑧ Make bootout idempotent; check success only for bootstrap&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl bootout &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="k"&gt;if &lt;/span&gt;launchctl bootstrap &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  …&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;changed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;changed+1&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"… RELOAD FAILED"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bootout&lt;/code&gt; gets called even when the job is already unloaded. Stopping on the error means the following &lt;code&gt;bootstrap&lt;/code&gt; never runs and the whole re-sync fails. Discard errors with &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; and proceed unconditionally to &lt;code&gt;bootstrap&lt;/code&gt; to secure idempotence. Log only &lt;code&gt;bootstrap&lt;/code&gt;'s success or failure and let the next run retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑨ Log only changes and waits&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 見送り → 毎回記録する（デバッグのため）&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt; 実行中(pid=&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;) のため見送り (&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 変更なし → 記録しない&lt;/span&gt;
&lt;span class="c"&gt;# 変更あり → 記録する&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] done changed=&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;em&gt;The comments read: "deferred → record every time (for debugging)", "no change → don't record", "changed → record".&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;Make fully normal runs silent. Keep deferrals, because they're needed to debug "I changed it but it isn't taking effect." &lt;code&gt;reloaded&lt;/code&gt; and &lt;code&gt;RELOAD FAILED&lt;/code&gt; are state changes, so always keep them. These three tiers let you judge "did anything happen" in one line the moment you open the log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑩ Narrow the script's responsibility to one thing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All this script does is "fix divergence in the environment variables of loaded jobs." It has no features for "load the job automatically if it isn't loaded," "send a Slack notification after repeated failures," or "compress and rotate logs." Narrowing responsibility to one thing makes it easier to test and easier to combine with other scripts. If you need alerts, split them into a separate script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑪ Manage the monitored key name in one place&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The key name &lt;code&gt;AUTOLIKE_TIMEOUT_SEC&lt;/code&gt; appears in two places: the PlistBuddy command and the sed pattern. In case you add or change monitored targets in the future, defining it as a variable near the top improves maintainability.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;AUTOLIKE_TIMEOUT_SEC
&lt;span class="nv"&gt;want&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$PB&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"Print :EnvironmentVariables:&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
&lt;/span&gt;&lt;span class="nv"&gt;have&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl print &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"s/.*&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt; =&amp;gt; &lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;([0-9]*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;).*/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;1/p"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current 31-line script is self-contained as a single-file, single-key tool so it isn't variabilized, but if you monitor multiple environment variables you expand it into a loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑫ Derive the periodic interval from the reflection delay you can tolerate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;StartInterval&lt;/code&gt; you use when registering the reconcile script itself with launchd becomes "the maximum wait between rewriting the plist and the environment variable actually taking effect." If you expect reflection within 5 minutes, register with &lt;code&gt;StartInterval 300&lt;/code&gt;. However, because there are cases where a running job is deferred, the actual reflection delay is bounded by "the reconcile interval + the job's maximum execution time."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑬ Don't point the log file at the same place as launchd's StandardOutPath&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;launchd's &lt;code&gt;StandardOutPath&lt;/code&gt; and &lt;code&gt;StandardErrorPath&lt;/code&gt; are stdout/stderr redirection targets specified in the plist. Manage the reconcile script's own application log (the &lt;code&gt;~/.claude/logs/autolike-plist-reconcile.log&lt;/code&gt; pointed at by the &lt;code&gt;LOG&lt;/code&gt; variable) separately from those. If both point at the same file, launchd's system messages and the application log get mixed together and analysis becomes difficult.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑭ Test-run with &lt;code&gt;bash -x&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When verifying behavior before registering with launchd, run it with &lt;code&gt;bash -x&lt;/code&gt; so commands are traced.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash &lt;span class="nt"&gt;-x&lt;/span&gt; ~/.claude/scripts/autolike-plist-reconcile.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because each line's actual expanded values are printed with a &lt;code&gt;+&lt;/code&gt; prefix, you can visually confirm whether variables expand as expected and whether the output of &lt;code&gt;launchctl print&lt;/code&gt; is being passed properly to &lt;code&gt;sed&lt;/code&gt;. Verifying behavior locally before registering with the plist heads off silent bugs (the state where nothing is logged and nothing happens).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑮ End with an explicit &lt;code&gt;exit 0&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting &lt;code&gt;exit 0&lt;/code&gt; at the end of the script guarantees a normal exit code back to launchd. Combined with settings like &lt;code&gt;KeepAlive&lt;/code&gt;, it prevents cases where a non-zero exit steers the job into an unexpected restart loop. If the last command is of the form &lt;code&gt;[ "$changed" -gt 0 ] &amp;amp;&amp;amp; …&lt;/code&gt;, the whole &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; returns false (exit code 1) when the condition is false, so the explicit &lt;code&gt;exit 0&lt;/code&gt; is necessary.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In one sentence, what &lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt; does is: "periodically compare the values in launchd's memory against the plist on the filesystem, and quietly re-sync only when the job is stopped."&lt;/p&gt;

&lt;p&gt;Concretely, four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the plist's target value (&lt;code&gt;want&lt;/code&gt;) with &lt;code&gt;PlistBuddy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Read launchd's running value (&lt;code&gt;have&lt;/code&gt;) with &lt;code&gt;launchctl print&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If there's a difference, check the PID from &lt;code&gt;launchctl list&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If the PID is &lt;code&gt;-&lt;/code&gt; (stopped), re-sync with &lt;code&gt;bootout&lt;/code&gt; → &lt;code&gt;bootstrap&lt;/code&gt;; if the PID is a number (running), defer and just leave a log line&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This "defer while running, wait for an opening" design lets you apply configuration changes without interrupting running automation jobs. You don't have to time the reload by hand — register the reconcile script itself with launchd and it's handled automatically at the next stopping point.&lt;/p&gt;

&lt;p&gt;The point is the cleanliness of the design. All the 31-line script takes on is "fixing divergence in loaded jobs" — it doesn't handle job startup, alerts, or log rotation. One script, one responsibility is the basis of an autonomous environment. In an environment where multiple jobs run 24 hours a day, "build something that doesn't break" has a dramatically lower long-term maintenance cost than "notice it when it breaks."&lt;/p&gt;

&lt;p&gt;A ¥1.2M/month autonomous platform is built out of an accumulation of these "unglamorous but reliably functional 31 lines." Not a single flashy feature. Understand launchd's specification precisely, crush the failure patterns first, design the logs carefully — repeating that is what creates an environment that keeps running without a human present.&lt;/p&gt;

&lt;p&gt;One question for you: how long is your own tolerable reflection delay between "I edited a config file" and "the running job is actually using it" — and do you have anything closing that gap automatically today?&lt;/p&gt;




&lt;p&gt;I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and a 30-day walkthrough in a paid note.&lt;br&gt;
📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>launchd</category>
      <category>macos</category>
      <category>bash</category>
      <category>automation</category>
    </item>
    <item>
      <title>8 Pitfalls I Hit Auto-Repairing 'Plugin directory does not exist' — and the 3-Layer Guard That Stopped a 22 8 Wipeout</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sat, 29 Aug 2026 05:16:51 +0000</pubDate>
      <link>https://dev.to/bokuwalily/8-pitfalls-i-hit-auto-repairing-plugin-directory-does-not-exist-and-the-3-layer-guard-that-4k0k</link>
      <guid>https://dev.to/bokuwalily/8-pitfalls-i-hit-auto-repairing-plugin-directory-does-not-exist-and-the-3-layer-guard-that-4k0k</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>automation</category>
      <category>bash</category>
      <category>macos</category>
      <category>devops</category>
    </item>
    <item>
      <title>A launchd Job That Fires Once on Deprecation Day, Then Deletes Itself — and the 4 Pitfalls I Hit</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sat, 29 Aug 2026 00:00:04 +0000</pubDate>
      <link>https://dev.to/bokuwalily/a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-pitfalls-i-hit-3p3f</link>
      <guid>https://dev.to/bokuwalily/a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-pitfalls-i-hit-3p3f</guid>
      <description>&lt;p&gt;This is a follow-up to my earlier post, "&lt;a href="https://dev.to/bokuwalily/auto-migrating-claude-code-with-a-throwaway-launchd-job-a-script-that-deletes-itself-when-its-e7k"&gt;Automating a config migration with a one-shot launchd job&lt;/a&gt;." This time the trigger is an &lt;strong&gt;external event with a known end-of-life date&lt;/strong&gt; (Fable 5 shutting down on 2026-07-07), and the question is how to design a launchd job you can &lt;strong&gt;set up today, have it fire only on that day, and have it remove itself once it's done&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Some deprecations come with a date stamped on them, and hand-editing a config on that exact day is the kind of chore you forget. But I also didn't want a script waking up every morning to rewrite the same JSON for no reason. What I landed on was a three-part set: &lt;strong&gt;a date gate, a backed-up &lt;code&gt;jq&lt;/code&gt; rewrite, and a self-unload&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: on the day I learn about a deprecation, I want to plant a job that runs only on the deprecation day
&lt;/h2&gt;

&lt;p&gt;Right now &lt;code&gt;~/.claude/settings.json&lt;/code&gt; says this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-fable-5[1m]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment I found out Fable 5 ends on 2026-07-07, putting a calendar reminder to hand-edit that &lt;code&gt;"model"&lt;/code&gt; value felt too flimsy — I'd forget it. On the other hand, a daemon that checks the date on every launch is overkill. What I wanted was a job I could &lt;strong&gt;set once and stop thinking about, that fires when the day arrives and disappears afterward&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;launchd can fire at specified times via &lt;code&gt;StartCalendarInterval&lt;/code&gt;. But there's no way to express "exactly once at 9:00 on 7/7" — you only get recurrence or fixed date components. The standard macOS launchd move is to specify multiple slots and absorb the duplication with idempotency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implementation: the three-part set
&lt;/h2&gt;

&lt;p&gt;Here's &lt;code&gt;~/.claude/scripts/model-transition-0707.sh&lt;/code&gt; in full (comments trimmed).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;SETTINGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude/settings.json"&lt;/span&gt;
&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude/logs/model-transition.log"&lt;/span&gt;
&lt;span class="nv"&gt;PLIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/LaunchAgents/com.shun.model-transition-0707.plist"&lt;/span&gt;

log&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# ① 日付ゲート&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 20260707 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"skip: before 2026-07-07"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# ② バックアップ付き jq 書き換え&lt;/span&gt;
&lt;span class="nv"&gt;current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.model // empty'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s1"&gt;'fable'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;.bak-model-transition"&lt;/span&gt;
  jq &lt;span class="s1"&gt;'.model = "opus"'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; jq &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  log &lt;span class="s2"&gt;"switched model: &lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; opus"&lt;/span&gt;
  /usr/bin/osascript &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'display notification "Fable 5終了に伴いデフォルトモデルをOpusへ切替えました" with title "Claude model transition"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;else
  &lt;/span&gt;log &lt;span class="s2"&gt;"no-op: model is already '&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;'"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# ③ 自己 unload&lt;/span&gt;
launchctl unload &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PLIST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;log &lt;span class="s2"&gt;"done (job unloaded)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's walk through the three parts in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  ① A date gate to block early firings
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 20260707 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"skip: before 2026-07-07"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;date +%Y%m%d&lt;/code&gt; produces a numeric string you can compare as an integer. &lt;code&gt;20260706 &amp;lt; 20260707&lt;/code&gt; → skip. That's all there is to it.&lt;/p&gt;

&lt;p&gt;Why does this matter? Because the plist starts firing the instant you &lt;code&gt;launchctl load&lt;/code&gt; it today. If the 6:50 AM slot comes around right after registration, that firing needs to be a no-op. Without the date gate, you'd get a misfire on the very day you plant the job: it would try to rewrite the model even though the value isn't &lt;code&gt;fable&lt;/code&gt; yet.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
Numeric comparison with &lt;code&gt;date +%Y%m%d&lt;/code&gt; works as-is under macOS's &lt;code&gt;/bin/bash&lt;/code&gt;. &lt;code&gt;-lt&lt;/code&gt; is an arithmetic comparison, so as long as the strings are the same length, lexicographic and integer ordering give the same result.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  ② A &lt;code&gt;jq&lt;/code&gt; rewrite with a backup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;.bak-model-transition"&lt;/span&gt;
jq &lt;span class="s1"&gt;'.model = "opus"'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; jq &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SETTINGS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This breaks into three steps.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp ... .bak-model-transition&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep the original as it was before the rewrite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jq '.model = "opus"' &amp;gt; .tmp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Write out to a temp file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jq . .tmp &amp;gt; /dev/null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verify the generated JSON isn't corrupt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mv .tmp settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replace the original only after verification passes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you write &lt;code&gt;jq ... settings.json &amp;gt; settings.json&lt;/code&gt; directly, the original file is truncated to empty the moment the shell opens the redirect target. Going through a temp file is the basic pattern for avoiding redirect destruction. It also matters that the &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; chaining means &lt;code&gt;mv&lt;/code&gt; never runs if verification fails.&lt;/p&gt;

&lt;p&gt;The reason I test with &lt;code&gt;grep -qi 'fable'&lt;/code&gt; — case-insensitive — is to cover &lt;code&gt;"claude-fable-5[1m]"&lt;/code&gt; as well as any future variant spelling. Here's the value actually sitting in settings.json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-fable-5[1m]"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the rewrite it's just &lt;code&gt;"opus"&lt;/code&gt; (an alias, not a model ID — this follows the "don't hardcode model IDs in scripts" policy from my CLAUDE.md).&lt;/p&gt;

&lt;h2&gt;
  
  
  ③ Deleting itself after success
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl unload &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PLIST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;log &lt;span class="s2"&gt;"done (job unloaded)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;launchctl unload &amp;lt;plist&amp;gt;&lt;/code&gt; detaches that job from the daemon. The plist file itself stays on disk, so you can re-register it with &lt;code&gt;launchctl load&lt;/code&gt; if you need to.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;2&amp;gt;/dev/null || true&lt;/code&gt; is there so an already-unloaded state doesn't abort with an error. Combined with the idempotent design described below, it guarantees the script is safe no matter how many times it's called.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;launchctl unload&lt;/code&gt; detaches the job immediately, even while it's running. That's exactly why the call sits at the end of the script — if you unload before finishing the rewrite, you cut yourself off mid-operation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The plist: why three slots a day is fine
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;6&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;50&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;12&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;50&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;20&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;50&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three slots: 6:50, 12:50, and 20:50. Why not just one? Because launchd skips slots that fall while the Mac is asleep. If I sleep through the morning slot, the midday or evening one can still pick it up.&lt;/p&gt;

&lt;p&gt;The firing flow on 7/7 looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6:50  → 日付ゲート通過 → fable 検出 → opus に書き換え → unload → ジョブ消滅
12:50 → ジョブが存在しないので発火しない（unload済み）
20:50 → 同上
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 7/6 and earlier, each slot just leaves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;skip&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;before 2026-07-07&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;in the log and exits 0 immediately. No rewrite at all.&lt;/p&gt;

&lt;p&gt;Drawn out, it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;7/5         7/6         7/7
6:50  skip  6:50  skip  6:50  書換+unload ←ここで終了
12:50 skip  12:50 skip  12:50 (消滅)
20:50 skip  20:50 skip  20:50 (消滅)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Idempotency is what makes "configure multiple slots and reject early firings with the date gate" work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I'd written the &lt;code&gt;date +%Y%m%d&lt;/code&gt; comparison with a string &lt;code&gt;&amp;lt;&lt;/code&gt;&lt;/strong&gt; → inside bash's &lt;code&gt;[[ ]]&lt;/code&gt;, that's lexicographic ordering, so I switched to &lt;code&gt;-lt&lt;/code&gt;. With consistent 8-digit zero padding there's no actual harm, but use the arithmetic comparison that states the intent clearly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I'd put the temp file in &lt;code&gt;/tmp/&lt;/code&gt;&lt;/strong&gt; → when &lt;code&gt;mv&lt;/code&gt; crosses filesystems, the rename can fail. Putting it in the same directory (&lt;code&gt;$HOME/.claude/&lt;/code&gt;) guarantees the same fs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I'd passed the label as the argument to &lt;code&gt;launchctl unload&lt;/code&gt;&lt;/strong&gt; → you have to pass the plist's full path, not the label (&lt;code&gt;com.shun.model-transition-0707&lt;/code&gt;), or you get "No such process."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I'd only set &lt;code&gt;StandardErrorPath&lt;/code&gt;&lt;/strong&gt; → the script's &lt;code&gt;log()&lt;/code&gt; writes to its own log file, but &lt;code&gt;StandardErrorPath&lt;/code&gt; is still needed as the destination for output when the script itself dies on a syntax error.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A date gate&lt;/strong&gt;, &lt;code&gt;[ "$(date +%Y%m%d)" -lt YYYYMMDD ]&lt;/code&gt;, turns every firing between setup day and the target date into a skip&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A backed-up &lt;code&gt;jq&lt;/code&gt; rewrite&lt;/strong&gt; — "cp → jq &amp;gt; tmp → jq verify → mv" is the minimum safe four-step configuration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;launchctl unload $PLIST&lt;/code&gt; after success&lt;/strong&gt; detaches the job. The plist remains, so re-registering is possible&lt;/li&gt;
&lt;li&gt;Multiple plist slots are sleep insurance. Idempotency is what makes over-specifying them safe&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For anything with a fixed deprecation date, the best move is to plant it the day you find out and then forget about it. It's more reliable than a calendar entry, and easier to cancel than cron.&lt;/p&gt;

&lt;p&gt;Next time I might write about how to read the logs this job leaves behind to confirm the migration succeeded — or, if it failed, the recovery procedure from the backup.&lt;/p&gt;

&lt;p&gt;What deprecation date do you currently have sitting in a calendar reminder instead of in a script?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>launchd</category>
      <category>macos</category>
      <category>automation</category>
      <category>bash</category>
    </item>
    <item>
      <title>3 Token Burn From an Infinite Retry Loop: How I Made Claude Refill My Article Queue When It Runs Dry</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Fri, 28 Aug 2026 11:00:07 +0000</pubDate>
      <link>https://dev.to/bokuwalily/3x-token-burn-from-an-infinite-retry-loop-how-i-made-claude-refill-my-article-queue-when-it-runs-5edl</link>
      <guid>https://dev.to/bokuwalily/3x-token-burn-from-an-infinite-retry-loop-how-i-made-claude-refill-my-article-queue-when-it-runs-5edl</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this setup works
&lt;/h2&gt;

&lt;p&gt;When you try to mass-produce articles on your own, you almost always hit the same wall: &lt;em&gt;when the topics run out, everything stops&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;The strategy I took was to &lt;strong&gt;hand the work over to the environment&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;article-daily-stock.sh&lt;/code&gt; script introduced in this article is the core of that. There are three key points in the design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key point 1: Decouple stocking from deployment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;~/content/article&lt;/code&gt;?" The job that publishes articles (&lt;code&gt;zenn-daily&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key point 2: Use the queue as a buffer&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Topics are stacked as an array of objects in a JSON file at &lt;code&gt;~/zenn-articles/.topic-queue.json&lt;/code&gt;. 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 &lt;code&gt;done-queue&lt;/code&gt;. As long as the queue has entries left, the 8 AM job can jump straight into the real work.&lt;/p&gt;

&lt;p&gt;The problem is &lt;strong&gt;the moment the queue bottoms out&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key point 3: When it's empty, Claude invents the topic itself&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the queue goes empty, the script calls neither an external service nor an API — it calls &lt;code&gt;claude -p&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the whole thing flows
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ASCII diagram
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
         │       │
         │       ├─ &amp;gt;= 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 は立つ）
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The plist design: why there are two slots
&lt;/h3&gt;

&lt;p&gt;If you look at &lt;code&gt;StartCalendarInterval&lt;/code&gt; in &lt;code&gt;~/Library/LaunchAgents/com.shun.article-daily.plist&lt;/code&gt;, the fire times are two slots: 8:00 and 10:35.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;8&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;35&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SKIP_GEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MODE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"apply"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_MARKER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;SKIP_GEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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, &lt;code&gt;~/.claude/logs/.article-daily-done-YYYYMMDD&lt;/code&gt;, and is auto-deleted after 7 days (line 525 of the actual code).&lt;/p&gt;

&lt;h3&gt;
  
  
  The actual code, from empty-queue detection through refill
&lt;/h3&gt;

&lt;p&gt;Lines 286–346 of the script are the implementation of automatic topic planning. Let me walk through the key parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Detect empty&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;QLEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="s1"&gt;'length'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QLEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"queue 空 → 実作業からネタ自動立案"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;jq 'length'&lt;/code&gt; gets the number of elements in the array. If &lt;code&gt;jq&lt;/code&gt; isn't found or there's a syntax error, it falls back to &lt;code&gt;echo 0&lt;/code&gt;, so even on error it safely enters topic-planning mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Gather context&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BRIEF_LATEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Desktop"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;brief&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Documents/claude-obsidian/wiki/briefs/daily/"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.md &lt;span class="se"&gt;\&lt;/span&gt;
  2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To ground everything in "that day's actual work," it looks for the day's daily brief in newest-mtime order. With &lt;code&gt;ls -t&lt;/code&gt; priority it scans both the brief on the Desktop and the ones inside the Obsidian vault, and uses only the single newest file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: The prompt to Claude&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The important part inside the prompt (&lt;code&gt;REPLENISH_PROMPT&lt;/code&gt;) is the constraints block.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 制約
- 既出slugは禁止（重複ネタNG）: ${USED}
- 技術記事として1本で完結する具体的な工夫であること（粒度: 1スクリプト/1仕組み）
- 根拠ファイルは必ず実在パスで2〜4個挙げる（~ 表記）
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;USED&lt;/code&gt; holds the list of already-used slugs collected from the existing queue, done-queue, the articles directory, and &lt;code&gt;coverage.json&lt;/code&gt; (generated by the &lt;code&gt;used_slugs()&lt;/code&gt; function at lines 113–119). If a slug identical to a past article is generated, it gets rejected by the subsequent duplicate check.&lt;/p&gt;

&lt;p&gt;The Claude invocation is this code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOPIC_JSON&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;run_to 600 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPLENISH_PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--strict-mcp-config&lt;/span&gt; &lt;span class="nt"&gt;--mcp-config&lt;/span&gt; &lt;span class="s1"&gt;'{"mcpServers":{}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARTICLE_MODEL&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;sonnet&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--effort&lt;/span&gt; high &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; text &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read,Grep,Glob,Bash"&lt;/span&gt; &lt;span class="nt"&gt;--max-turns&lt;/span&gt; 20 2&amp;gt;&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;run_to 600&lt;/code&gt; is a wrapper around &lt;code&gt;gtimeout 600&lt;/code&gt;, imposing a maximum 10-minute timeout. &lt;code&gt;--strict-mcp-config --mcp-config '{"mcpServers":{}}'&lt;/code&gt; completely disables MCP servers, allowing only the standard Claude Code tools (Read/Grep/Glob/Bash). The model can be overridden with the &lt;code&gt;ARTICLE_MODEL&lt;/code&gt; environment variable; when unset it uses sonnet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Strip the JSON fence and validate&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOPIC_JSON&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOPIC_JSON&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'/{/,/}/p'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOPIC_JSON&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'.slug and .title and (.sources|length&amp;gt;0) and (.thumb_title|length&amp;gt;0)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"ABORT: ネタ自動立案に失敗（JSON不正）。marker無しで次スロット再試行"&lt;/span&gt;
  notify &lt;span class="s2"&gt;"ネタ自動立案に失敗。次スロットで再試行。"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anticipating that Claude will attach code fences or a preamble, &lt;code&gt;sed -n '/{/,/}/p'&lt;/code&gt; cuts out everything from the first &lt;code&gt;{&lt;/code&gt; to the last &lt;code&gt;}&lt;/code&gt;. Validation confirms that the four fields &lt;code&gt;slug&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;sources&lt;/code&gt; (at least one), and &lt;code&gt;thumb_title&lt;/code&gt; (at least one) exist. On validation failure it exits with &lt;code&gt;exit 0&lt;/code&gt; — but the crucial thing is that it &lt;strong&gt;does not write the done-marker&lt;/strong&gt;. Because there's no done-marker, the 10:35 catch-up slot runs the same script again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Overwrite &lt;code&gt;no&lt;/code&gt; and &lt;code&gt;prev_slug&lt;/code&gt; on the machine side before inserting&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOPIC_JSON&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOPIC_JSON&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; no &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NO_NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; prev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PREV_NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'.no=$no | .prev_slug=$prev'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
jq &lt;span class="nt"&gt;--argjson&lt;/span&gt; t &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOPIC_JSON&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'[$t] + .'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
log &lt;span class="s2"&gt;"ネタ追加: &lt;/span&gt;&lt;span class="nv"&gt;$NEW_SLUG&lt;/span&gt;&lt;span class="s2"&gt; (no=&lt;/span&gt;&lt;span class="nv"&gt;$NO_NEW&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because there's a risk the model mixes up &lt;code&gt;no&lt;/code&gt; (article number) and &lt;code&gt;prev_slug&lt;/code&gt; (the previous article's slug), the shell overwrites them with definitive values after generation. &lt;code&gt;jq '[$t] + .'&lt;/code&gt; inserts at the head of the queue, so this topic gets used immediately at the next 8:00 or 10:35 slot. The &lt;code&gt;&amp;gt;.tmp &amp;amp;&amp;amp; mv&lt;/code&gt; pattern rewrites atomically to prevent the JSON from being corrupted if the script is interrupted mid-write.&lt;/p&gt;

&lt;h3&gt;
  
  
  The plist's wrapper structure
&lt;/h3&gt;

&lt;p&gt;Looking at &lt;code&gt;ProgramArguments&lt;/code&gt; in the plist, it doesn't call the script directly — it goes through two layers of wrappers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/claude-quota-guard.py&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--job&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.shun.article-daily&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.discord/run-and-notify.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;zenn&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Zenn記事ストック生成&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/article-daily-stock.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;apply&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claude-quota-guard.py&lt;/code&gt; checks in advance whether the Claude Max plan's tokens are exhausted. If the remaining budget is in a critical state (&lt;code&gt;token-budget-advisor.sh --short&lt;/code&gt; returns &lt;code&gt;🔴&lt;/code&gt; or &lt;code&gt;critical&lt;/code&gt;), 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."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run-and-notify.sh&lt;/code&gt; is a wrapper that notifies Discord of the result. The arguments &lt;code&gt;"zenn"&lt;/code&gt; and &lt;code&gt;"Zenn記事ストック生成"&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Let me also record the plist settings themselves. Because &lt;code&gt;LowPriorityIO: true&lt;/code&gt; and &lt;code&gt;Nice: 10&lt;/code&gt; are set, the Mac doesn't get sluggish to operate while article generation is running. Combined with &lt;code&gt;ProcessType: Background&lt;/code&gt;, even during the heavy load right after waking from sleep the OS defers I/O and prioritizes the foreground task. Since &lt;code&gt;RunAtLoad: false&lt;/code&gt;, merely loading the plist doesn't start it — it fires only at the specified times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The details that hold the implementation together
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Solving the launchd PATH problem dynamically
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# article-daily-stock.sh 56-58行目&lt;/span&gt;
&lt;span class="nv"&gt;NODE_BIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/.nvm/versions/node/&lt;span class="k"&gt;*&lt;/span&gt;/bin 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-V&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NODE_BIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NODE_BIN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A process launched by launchd reads neither &lt;code&gt;.zshrc&lt;/code&gt; nor &lt;code&gt;.bash_profile&lt;/code&gt;. Even if the &lt;code&gt;claude&lt;/code&gt; command is installed at &lt;code&gt;~/.nvm/versions/node/v24.13.0/bin/&lt;/code&gt;, it isn't in launchd's bare PATH. Despite passing PATH explicitly via the plist's &lt;code&gt;EnvironmentVariables&lt;/code&gt; (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 &lt;code&gt;/v24.13.0/bin&lt;/code&gt; in the plist would need to be manually rewritten every time node is upgraded. By dynamically fetching the latest version's bin with &lt;code&gt;sort -V | tail -1&lt;/code&gt;, the design means you never have to touch the plist.&lt;/p&gt;

&lt;h3&gt;
  
  
  Eradicating "interruption by sleep" with caffeinate
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# article-daily-stock.sh 61-63行目&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CAFFEINATED&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; /usr/bin/caffeinate &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nb"&gt;env &lt;/span&gt;&lt;span class="nv"&gt;CAFFEINATED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 /bin/bash &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;claude -p&lt;/code&gt; HTTP connection drops and it's treated as a timeout. &lt;code&gt;caffeinate -i&lt;/code&gt; suppresses idle sleep and &lt;code&gt;-s&lt;/code&gt; suppresses system sleep, and &lt;code&gt;exec&lt;/code&gt; replaces the current process, building a structure where "the original script keeps running under caffeinate's umbrella." The &lt;code&gt;CAFFEINATED=1&lt;/code&gt; check is to prevent infinite recursion. Since adding this one block, interruption logs after waking from sleep have been zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  Preventing double execution with a mkdir-based atomic lock
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# article-daily-stock.sh 66-75行目&lt;/span&gt;
&lt;span class="nv"&gt;LOCKDIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude/locks/article-daily.lock"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; /bin/mkdir &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCKDIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;oldpid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCKDIR&lt;/span&gt;&lt;span class="s2"&gt;/pid"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;oldpid&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$oldpid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;log &lt;span class="s2"&gt;"別インスタンス実行中(pid=&lt;/span&gt;&lt;span class="nv"&gt;$oldpid&lt;/span&gt;&lt;span class="s2"&gt;) — skip"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCKDIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; /bin/mkdir &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCKDIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCKDIR&lt;/span&gt;&lt;span class="s2"&gt;/pid"&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'rm -rf "$LOCKDIR"'&lt;/span&gt; EXIT INT TERM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the 8:00 slot's generation runs long, the 10:35 slot launches the same script. The reason I adopted &lt;code&gt;mkdir&lt;/code&gt; as the locking mechanism rather than a file lock (&lt;code&gt;flock&lt;/code&gt;) is that macOS's &lt;code&gt;mkdir&lt;/code&gt; is a POSIX-guaranteed atomic operation. Even if two processes call &lt;code&gt;mkdir&lt;/code&gt; simultaneously, only one succeeds. Furthermore, even when a lock directory has been left behind, it applies &lt;code&gt;kill -0&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting the title character count accurately with inline Python
&lt;/h3&gt;

&lt;p&gt;Zenn has a constraint that the title must be within 70 characters. The &lt;code&gt;frontmatter_title_chars&lt;/code&gt; function (lines 140–165) that checks this is implemented as an inline Python script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 141-164行目のPythonインライン（要点抜粋）
&lt;/span&gt;&lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in_frontmatter&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason for not using &lt;code&gt;grep | wc -c&lt;/code&gt; is the quoting and multibyte problem. Zenn frontmatter title values are sometimes wrapped in single quotes and sometimes not. &lt;code&gt;wc -c&lt;/code&gt; returns byte count, so it counts one Japanese character (3 bytes) as 3 characters. Using &lt;code&gt;wc -m&lt;/code&gt; 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 &lt;code&gt;len()&lt;/code&gt; of the pure string with quotes removed, both Japanese titles and quoted/unquoted forms can be measured accurately in one go.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running path sanitization &lt;em&gt;before&lt;/em&gt; the secret scan
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# article-daily-stock.sh 431-446行目（順序が肝）&lt;/span&gt;
&lt;span class="c"&gt;# ①先にサニタイズ&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;_f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ART&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ARTICLES&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;.md"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$_f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  /usr/bin/sed &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s#/Users/[A-Za-z0-9._-]+/#~/#g'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$_f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# ②その後で秘密スキャン&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nEi&lt;/span&gt; &lt;span class="s1"&gt;'AKIA[0-9A-Z]{16}|(secret|api_key|...)[[:space:]]*[:=]...'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ART&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"ABORT: 秘密らしき値混入 → 中止"&lt;/span&gt;
  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ART&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="c"&gt;# ③実ホームパスが残っていたら中止&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"/Users/"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ART&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"ABORT: 実ホームパス(/Users/)混入 → 中止"&lt;/span&gt;
  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ART&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even when the generation prompt explicitly says "write it with &lt;code&gt;~&lt;/code&gt; notation," Claude sometimes writes the real path (&lt;code&gt;/Users/…&lt;/code&gt;) into the body. In that case the script doesn't ABORT outright — &lt;strong&gt;it first attempts mechanical sanitization&lt;/strong&gt;. Since &lt;code&gt;/Users/&lt;/code&gt; never carries meaning relevant to a published article, replacing it with &lt;code&gt;~&lt;/code&gt; doesn't break the meaning of the text. It ABORTs only if, after sanitization, an AWS key format (&lt;code&gt;AKIA…&lt;/code&gt;) or a secret-assignment pattern still remains. The philosophy is "fix what can be fixed, stop only for what can't," minimizing unnecessary retries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the done-marker is set &lt;em&gt;before&lt;/em&gt; git push
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# article-daily-stock.sh 509行目と524行目&lt;/span&gt;
&lt;span class="c"&gt;# キューをpopした直後——git push の前——に立てる&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_MARKER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# ← 511行目&lt;/span&gt;

run_to 120 git push &lt;span class="nt"&gt;-q&lt;/span&gt; 2&amp;gt;&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; log &lt;span class="s2"&gt;"WARN: push失敗（ストックは確保済）"&lt;/span&gt;

&lt;span class="c"&gt;# push後にも念のため（べき等な重複touchは無害）&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_MARKER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# ← 524行目&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The done-marker being &lt;code&gt;touch&lt;/code&gt;ed twice is intentional. &lt;strong&gt;The first one happens before git push.&lt;/strong&gt; 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 &lt;em&gt;is&lt;/em&gt; "generation complete"; git push is nothing more than best-effort post-processing. This ordering alone completely prevents duplicate-generation accidents caused by push failures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three pitfalls I got stuck on
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall ①: Claude always wraps the JSON in a code fence
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Automatic topic planning ended in &lt;code&gt;ABORT: JSON不正&lt;/code&gt; every single time. Looking at the log, the contents of &lt;code&gt;TOPIC_JSON&lt;/code&gt; were as follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
json&lt;br&gt;
{&lt;br&gt;
  "no": "17",&lt;br&gt;
  "slug": "caffeinate-wrapper",&lt;br&gt;
  ...&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
shell&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even when the &lt;code&gt;claude -p&lt;/code&gt; prompt explicitly says "no code fence, output only one JSON object," it wraps the output in three backticks and a &lt;code&gt;json&lt;/code&gt; tag with fairly high probability. Passing that straight to &lt;code&gt;jq -e&lt;/code&gt; blows up instantly with a parse error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOPIC_JSON&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOPIC_JSON&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'/{/,/}/p'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sed -n '/{/,/}/p'&lt;/code&gt; outputs "from the line where the first &lt;code&gt;{&lt;/code&gt; appears to the line where the last &lt;code&gt;}&lt;/code&gt; appears." It strips off the code fence, the preamble, and any trailing text, leaving only the JSON object portion. Even with multiple nested &lt;code&gt;{&lt;/code&gt; inside the JSON, the final &lt;code&gt;}&lt;/code&gt; is at the end, so it works correctly. Since adding this, ABORTs due to invalid JSON have been near zero. Lesson: &lt;strong&gt;never trust&lt;/strong&gt; a "strictly follow this format" instruction to a model. Always write code that normalizes the output mechanically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall ②: 8:00 and 10:35 overlapped and generated the same article twice
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The next morning, checking &lt;code&gt;~/content/article/articles/&lt;/code&gt;, there were two files with the same sequence number (&lt;code&gt;17-caffeinate-wrapper.md&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I added the &lt;code&gt;mkdir&lt;/code&gt;-based lock (lines 65–75). &lt;code&gt;mkdir&lt;/code&gt; 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 &lt;code&gt;done-marker&lt;/code&gt; check (lines 51–53) sets &lt;code&gt;SKIP_GEN=1&lt;/code&gt;, 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 &lt;code&gt;generated_at&lt;/code&gt; timestamps in done-queue showed the two entries were 2 seconds apart, so identifying the cause was immediate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall ③: A broken article kept getting pushed back onto the queue, causing an infinite loop
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A few days later, generation for the same SLUG was running every morning but all of them ended in &lt;code&gt;ABORT: 生成本文が不完全&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;audit_repair&lt;/code&gt; 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 &lt;code&gt;bump_attempt&lt;/code&gt; function was supposed to record the retry count in &lt;code&gt;$ATTEMPTS_FILE&lt;/code&gt; and, once it exceeded &lt;code&gt;MAX_ATTEMPTS=2&lt;/code&gt;, divert the item into the &lt;code&gt;needhuman&lt;/code&gt; array — but the root cause was that &lt;code&gt;$ATTEMPTS_FILE&lt;/code&gt; (a JSON file) had been left in a corrupted state by an interruption during a disk write, and was constantly returning &lt;code&gt;n=0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I changed &lt;code&gt;bump_attempt&lt;/code&gt;'s write to the atomic &lt;code&gt;.tmp → mv&lt;/code&gt; pattern.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# article-daily-stock.sh 224-226行目&lt;/span&gt;
jq &lt;span class="nt"&gt;--arg&lt;/span&gt; s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--argjson&lt;/span&gt; n &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'.[$s]=$n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ATTEMPTS_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ATTEMPTS_FILE&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; 2&amp;gt;&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ATTEMPTS_FILE&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ATTEMPTS_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also added an initialization line at line 137, &lt;code&gt;[ -f "$ATTEMPTS_FILE" ] || echo '{}' &amp;gt; "$ATTEMPTS_FILE"&lt;/code&gt;, so that even if the file is corrupted it can restart from an empty object on the next launch. SLUGs exceeding &lt;code&gt;MAX_ATTEMPTS=2&lt;/code&gt; get stacked into the &lt;code&gt;needhuman&lt;/code&gt; array, and writing to &lt;code&gt;_NEEDS-FIX.txt&lt;/code&gt; plus a macOS notification (lines 267–268) run as a set. After this fix, the infinite loop disappeared completely.&lt;/p&gt;




&lt;p&gt;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 &lt;code&gt;dry&lt;/code&gt; mode and following the logs of each step. Passing the argument &lt;code&gt;bash article-daily-stock.sh dry&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sticking points
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① launchd does not expand &lt;code&gt;~&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I first started writing the plist, I put &lt;code&gt;~/.claude/scripts/article-daily-stock.sh&lt;/code&gt; in &lt;code&gt;ProgramArguments&lt;/code&gt;, and launchd decided "the file does not exist" and exited immediately. launchd does not expand &lt;code&gt;~&lt;/code&gt;. The plist must be written with absolute paths of the form &lt;code&gt;/Users/&amp;lt;username&amp;gt;/…&lt;/code&gt; (this is why every &lt;code&gt;&amp;lt;string&amp;gt;&lt;/code&gt; in the actual plist is an absolute path). Inside the script you can reference home via the &lt;code&gt;$HOME&lt;/code&gt; variable, but the values in the plist's &lt;code&gt;ProgramArguments&lt;/code&gt; and &lt;code&gt;EnvironmentVariables&lt;/code&gt; can only use absolute paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② On a Mac without &lt;code&gt;gtimeout&lt;/code&gt;, &lt;code&gt;run_to&lt;/code&gt; does nothing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The script uses GNU coreutils' &lt;code&gt;gtimeout&lt;/code&gt; as a timeout wrapper (lines 82–84). If &lt;code&gt;/opt/homebrew/bin/gtimeout&lt;/code&gt; doesn't exist, &lt;code&gt;TIMEOUT_BIN=""&lt;/code&gt; and &lt;code&gt;run_to()&lt;/code&gt; simply passes &lt;code&gt;"$@"&lt;/code&gt; straight through. In an environment where &lt;code&gt;brew install coreutils&lt;/code&gt; hasn't been done, the timeout in &lt;code&gt;run_to 1500 claude -p …&lt;/code&gt; doesn't function and the Claude call keeps running indefinitely. Confirm with &lt;code&gt;gtimeout --version&lt;/code&gt; before wiring things up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ I didn't add a network-connectivity wait after waking from sleep&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;claude -p&lt;/code&gt; was erroring out immediately. That's why lines 87–91 of the script contain a connectivity wait that loops &lt;code&gt;nc -z -G 3 1.1.1.1 443&lt;/code&gt; up to 18 times (90 seconds). Audit mode doesn't use the network, so it skips the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;④ I kept calling Claude while tokens were exhausted and melted the remaining budget&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;claude -p&lt;/code&gt; 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 &lt;code&gt;token-budget-advisor.sh --short&lt;/code&gt; returns &lt;code&gt;🔴&lt;/code&gt; or &lt;code&gt;critical&lt;/code&gt;, it exits without launching the job body. Checking the remaining token budget before a Claude call is mandatory preprocessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑤ The stock article file paths broke sort order depending on the digit count of &lt;code&gt;NO&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;next_no()&lt;/code&gt; function (lines 122–130) reads the maximum number from the file names in &lt;code&gt;~/content/article/articles/&lt;/code&gt; and does &lt;code&gt;+1&lt;/code&gt;. Initially I created numbers as one digit — &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;2&lt;/code&gt;, … — so &lt;code&gt;ls&lt;/code&gt; sorting made &lt;code&gt;10 &amp;lt; 2&lt;/code&gt; and the numbering went wrong. It's resolved by always zero-padding to two digits with &lt;code&gt;printf '%02d'&lt;/code&gt;. If it ever goes past 99 articles, it will need to change to three digits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑥ &lt;code&gt;article_ok()&lt;/code&gt;'s stub-word check produced a false positive&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Line 179's &lt;code&gt;grep -qiE 'request timed out|不明な商品|TODO: *本文|\(生成失敗\)'&lt;/code&gt; 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 &lt;code&gt;request timed out&lt;/code&gt; error," and the string &lt;code&gt;request timed out&lt;/code&gt; appeared in the body, so a correctly generated article got rejected with &lt;code&gt;ABORT: 生成本文が不完全&lt;/code&gt;. The stub-word check patterns need to be narrowed as much as possible to "words unlikely to appear as example text in the body."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑦ There was a period when I made a &lt;code&gt;git push&lt;/code&gt; failure &lt;code&gt;exit 1&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the initial implementation, if &lt;code&gt;git push&lt;/code&gt; failed I made the whole script &lt;code&gt;exit 1&lt;/code&gt;. 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 &lt;code&gt;|| log "WARN: push失敗…"&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑧ coverage.json bloated and entries already deleted from the queue kept lingering&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I rewrote a few articles and their &lt;code&gt;SLUG&lt;/code&gt; changed, the old entries stayed in &lt;code&gt;coverage.json&lt;/code&gt; forever. There's a stale-cleanup block near the end of &lt;code&gt;audit_repair()&lt;/code&gt; (lines 270–276), but I didn't have it at first, and around the point it exceeded 200 entries the &lt;code&gt;jq&lt;/code&gt; processing got heavy. The correct form for cleaning &lt;code&gt;coverage.json&lt;/code&gt; is to keep only "files that actually exist in the stock directory + slugs still in the queue" and delete everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑨ I forgot to initialize &lt;code&gt;ATTEMPTS_FILE&lt;/code&gt; and got zombie retries&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bump_attempt()&lt;/code&gt; records the retry count in &lt;code&gt;ATTEMPTS_FILE&lt;/code&gt; (&lt;code&gt;.article-repair-attempts.json&lt;/code&gt;). That initialization is done by &lt;code&gt;[ -f "$ATTEMPTS_FILE" ] || echo '{}' &amp;gt; "$ATTEMPTS_FILE"&lt;/code&gt; (line 137), but the initial implementation didn't have it, so when the file didn't exist &lt;code&gt;jq&lt;/code&gt; returned an error and &lt;code&gt;n&lt;/code&gt; was always 0. As a result, &lt;code&gt;MAX_ATTEMPTS=2&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑩ I forgot the &lt;code&gt;sips&lt;/code&gt; command is macOS-only and it failed in a Linux test environment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;thumb_ok()&lt;/code&gt; function (lines 185–188) checks the thumbnail's pixel width with &lt;code&gt;sips -g pixelWidth&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑪ The &lt;code&gt;no&lt;/code&gt; field was passed as a string and zero-padding conversion failed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When passing the number obtained via &lt;code&gt;NO=$(echo "$TOPIC" | jq -r '.no // ""')&lt;/code&gt; into &lt;code&gt;printf '%02d' "$((10#$NO))"&lt;/code&gt;, if &lt;code&gt;no&lt;/code&gt; was an empty string or a string like &lt;code&gt;"01"&lt;/code&gt;, the shell's arithmetic evaluation sometimes failed to interpret &lt;code&gt;10#&lt;/code&gt;. That's why line 355 has the guard &lt;code&gt;[ -z "$NO" ] || ! [[ "$NO" =~ ^[0-9]+$ ]] &amp;amp;&amp;amp; NO=$(next_no)&lt;/code&gt; — "if something non-numeric arrives, re-number with &lt;code&gt;next_no()&lt;/code&gt;."&lt;/p&gt;




&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;p&gt;Here are the design principles verified in actual operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Keep generation and deployment independent&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;~/content/article/articles/&lt;/code&gt;?", 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Set the done-marker before git push&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"The moment the write to the stock completes" is generation completion. Line 509's &lt;code&gt;touch "$DONE_MARKER"&lt;/code&gt; is placed before &lt;code&gt;git push&lt;/code&gt;. This way, even if push fails, the catch-up slot doesn't do a duplicate generation. Line 524's second &lt;code&gt;touch&lt;/code&gt; is a post-push belt-and-suspenders, but the first one is the linchpin protecting the whole design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Protect state files with the &lt;code&gt;.tmp &amp;amp;&amp;amp; mv&lt;/code&gt; pattern&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every write to &lt;code&gt;topic-queue.json&lt;/code&gt;, &lt;code&gt;coverage.json&lt;/code&gt;, and &lt;code&gt;ATTEMPTS_FILE&lt;/code&gt; is done with the atomic pattern &lt;code&gt;jq … &amp;gt; "$FILE.tmp" &amp;amp;&amp;amp; mv "$FILE.tmp" "$FILE"&lt;/code&gt;. This prevents half-written JSON from being left behind on interruption and eradicates &lt;code&gt;jq&lt;/code&gt; parse errors on the next launch. Careful: if you forget the &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; and write &lt;code&gt;&amp;gt; file &amp;amp;&amp;amp; mv&lt;/code&gt;, then even when &lt;code&gt;jq&lt;/code&gt; fails you've already overwritten the file with an empty one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Use a &lt;code&gt;mkdir&lt;/code&gt;-based atomic lock&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;flock&lt;/code&gt; can't be used in environments where macOS's &lt;code&gt;/bin/flock&lt;/code&gt; doesn't exist. &lt;code&gt;/bin/mkdir&lt;/code&gt; 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 &lt;code&gt;kill -0&lt;/code&gt;, and auto-cleaning stale locks (lines 65–75) is the complete form of double-execution prevention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Delegate retries to the OS (launchd)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rather than retrying yourself with a &lt;code&gt;sleep&lt;/code&gt; loop on failure, a design where you &lt;code&gt;exit 0&lt;/code&gt; 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 &lt;code&gt;StartCalendarInterval&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Never trust the model's output — always normalize it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even when you explicitly say "output only JSON," &lt;code&gt;claude -p&lt;/code&gt; attaches code fences and a preamble. Always include the normalization that cuts from the first &lt;code&gt;{&lt;/code&gt; to the last &lt;code&gt;}&lt;/code&gt; with &lt;code&gt;sed -n '/{/,/}/p'&lt;/code&gt; (line 332). Since adding this, invalid-JSON ABORTs have been near zero. Likewise, fields the model easily mixes up, such as &lt;code&gt;no&lt;/code&gt; and &lt;code&gt;prev_slug&lt;/code&gt;, get overwritten with definitive values on the shell side after generation (lines 342–343).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Attempt mechanical repair before ABORTing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Real home paths (&lt;code&gt;/Users/…&lt;/code&gt;) creeping in happens even when the prompt instructs against it. ABORTing immediately for that makes retries expensive. The script first attempts automatic correction with &lt;code&gt;sed -i '' -E 's#/Users/[A-Za-z0-9._-]+/#~/#g'&lt;/code&gt; (lines 434–437), and only aborts if a &lt;code&gt;secret&lt;/code&gt; or &lt;code&gt;AKIA…&lt;/code&gt; pattern still remains afterward. The principle of "fix what can be fixed, stop only for what can't" reduces unnecessary retries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Check the remaining token budget at the top of the script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;token-budget-advisor.sh --short&lt;/code&gt; before calling &lt;code&gt;claude -p&lt;/code&gt;, and make the decision to end the job first if it's &lt;code&gt;🔴&lt;/code&gt;. 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."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Protect long-running processes with &lt;code&gt;caffeinate -i -s exec&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"&lt;/code&gt; (line 62) restarts the script itself under caffeinate's umbrella. Because &lt;code&gt;exec&lt;/code&gt; replaces the process, no extra subshells accumulate. The &lt;code&gt;CAFFEINATED=1&lt;/code&gt; check exists to prevent infinite recursion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Explicitly restrict the push destination owner with &lt;code&gt;ALLOWED_OWNER&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git push&lt;/code&gt; runs only against repositories of the GitHub user matching &lt;code&gt;ALLOWED_OWNER="bokuwalily"&lt;/code&gt; (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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. Manually test failure cases in &lt;code&gt;dry&lt;/code&gt; mode before wiring it to launchd&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bash article-daily-stock.sh dry&lt;/code&gt; causes neither a queue pop nor a git push, and only writes the stock file (lines 496–500). Before &lt;code&gt;launchctl load&lt;/code&gt;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 &lt;code&gt;dry&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Keep the background quiet with &lt;code&gt;LowPriorityIO + Nice 10&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;LowPriorityIO: true&lt;/code&gt;, &lt;code&gt;Nice: 10&lt;/code&gt;, and &lt;code&gt;ProcessType: Background&lt;/code&gt; in the plist means your main work doesn't slow down during article generation. &lt;code&gt;Nice 10&lt;/code&gt; lowers CPU priority, and &lt;code&gt;LowPriorityIO&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. Reliably reject stub articles with &lt;code&gt;MIN_ARTICLE_BYTES=1200&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;article_ok()&lt;/code&gt; function checks the byte count with &lt;code&gt;stat -f%z&lt;/code&gt; and treats anything under &lt;code&gt;1200&lt;/code&gt; bytes as a stub (lines 176–177). &lt;code&gt;MIN_ARTICLE_BYTES&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;14. Record the retry count in ATTEMPTS_FILE and escalate to a human&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;audit_repair()&lt;/code&gt; returns a broken article to the regeneration queue, &lt;code&gt;bump_attempt()&lt;/code&gt; records the attempt count. Once it exceeds &lt;code&gt;MAX_ATTEMPTS=2&lt;/code&gt;, it gets stacked into the &lt;code&gt;needhuman&lt;/code&gt; array, and writing to &lt;code&gt;_NEEDS-FIX.txt&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15. Don't hardcode the nvm version in the plist's PATH — resolve it dynamically in the script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you hardcode &lt;code&gt;/Users/…/.nvm/versions/node/v24.13.0/bin&lt;/code&gt; into the plist's &lt;code&gt;EnvironmentVariables.PATH&lt;/code&gt;, you'll need to rewrite the plist every time you upgrade node. By dynamically fetching the latest node's bin with &lt;code&gt;ls -d ~/.nvm/versions/node/*/bin | sort -V | tail -1&lt;/code&gt; at lines 56–58 of the script, the design means you write the plist once and never touch it again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decouple generation from deployment&lt;/strong&gt;: if one stops, it doesn't chain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On failure, exit without setting the done-marker&lt;/strong&gt;: the OS handles retries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalize the output before validating it&lt;/strong&gt;: don't trust the model's output format&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write state files atomically&lt;/strong&gt;: they don't break on interruption&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap auto-repair and hand off to a human&lt;/strong&gt;: build an exit for when the loop doesn't converge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;.topic-queue.json&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Publishing to Zenn is handled by the separate &lt;code&gt;zenn-daily&lt;/code&gt; job process. All &lt;code&gt;article-daily-stock.sh&lt;/code&gt; knows about is "are the draft in &lt;code&gt;~/content/article/articles/&lt;/code&gt; and the thumbnail in &lt;code&gt;~/content/article/thumbnails/&lt;/code&gt; both in place?" Because of this division of labor, this article series has piled up without a single day's gap.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;




&lt;p&gt;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.&lt;br&gt;
📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>claudecode</category>
      <category>bash</category>
      <category>ai</category>
    </item>
    <item>
      <title>52 Days of Silent Zeros: The Stop Hook Payload Has No usage Field</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Fri, 28 Aug 2026 05:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field-1kg8</link>
      <guid>https://dev.to/bokuwalily/52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field-1kg8</guid>
      <description>&lt;p&gt;I used to be a college student scraping by on ¥100k a month. Then I was laid off. Six months later, after building an autonomous Claude Code environment, I'm clearing ¥1.2M a month in revenue. What closed that gap wasn't talent or capital — it was &lt;strong&gt;continuously growing an environment that thinks and works in my place&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this setup works
&lt;/h2&gt;

&lt;p&gt;The first thing I noticed when trying to grow income through solo development: raising the &lt;em&gt;quality of the environment&lt;/em&gt; has far better ROI than raising the &lt;em&gt;amount of work&lt;/em&gt;. Spending a week building a system that runs autonomously and stacks up deliverables while I'm away beats hammering Claude Code for 8 hours a day — at least when you measure revenue three months out. This series is a record of mass-producing that kind of environment.&lt;/p&gt;

&lt;p&gt;Today's topic is &lt;strong&gt;cost visibility&lt;/strong&gt;. When you live on Claude Code, token consumption happens as naturally as breathing. The problem is that you can't optimize a cost you can't see. "How much did I spend this month?" "Which session was heavy?" "How many dollars a month would I save by shifting a bit more Sonnet work to Haiku?" — without answers to these, gross margin doesn't improve even as revenue grows.&lt;/p&gt;

&lt;p&gt;Claude Code has a hook system. Shell scripts or Node.js scripts configured in &lt;code&gt;~/.claude/settings.json&lt;/code&gt; run automatically when tied to specific events. The &lt;code&gt;Stop&lt;/code&gt; hook is the most important of them, and it &lt;strong&gt;fires every time the assistant completes a turn&lt;/strong&gt;. Not just at session end — once per completed turn (see the comment on line 19 of &lt;code&gt;cost-tracker.js&lt;/code&gt;: &lt;em&gt;"Stop fires per assistant response, not per session"&lt;/em&gt;).&lt;/p&gt;

&lt;p&gt;The natural idea here is: "If I record token counts in the Stop hook, I get automatic cost tracking." The implementation looks simple. The hook receives a JSON payload on stdin. Read &lt;code&gt;usage.input_tokens&lt;/code&gt; and &lt;code&gt;usage.output_tokens&lt;/code&gt; from that payload, append to a JSONL file, done — and building it with that assumption is exactly the mistake the first version made.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Stop hook payload has no &lt;code&gt;usage&lt;/code&gt; field.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what the payload actually looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transcript_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/session.jsonl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cwd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/workdir"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hook_event_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stop"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;session_id&lt;/code&gt;, &lt;code&gt;transcript_path&lt;/code&gt;, &lt;code&gt;cwd&lt;/code&gt;, &lt;code&gt;hook_event_name&lt;/code&gt; — that's it. No model name, no token counts, no cost. Because this isn't spelled out in the docs, if you write code assuming &lt;code&gt;usage&lt;/code&gt; exists, you read a nonexistent field and get &lt;code&gt;undefined&lt;/code&gt;, &lt;code&gt;Number(undefined)&lt;/code&gt; becomes &lt;code&gt;NaN&lt;/code&gt;, you keep adding &lt;code&gt;NaN&lt;/code&gt;, and &lt;code&gt;0&lt;/code&gt; gets recorded. No errors. Just silent zeros, piling up every turn.&lt;/p&gt;

&lt;p&gt;The comment in my &lt;code&gt;cost-tracker.js&lt;/code&gt; preserves the evidence verbatim (lines 12–13):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* The Stop payload does NOT include `usage` or `model` directly. The previous
* version of this hook expected those fields and silently produced zero-filled
* rows (verified: 2,340 rows captured with 0.0% non-zero token rate over 52
* days).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;52 days, 2,340 rows, 0.0% non-zero rate. As a tracker, a total failure. And the whole time, the script kept running without complaint, the log file grew steadily, and running &lt;code&gt;cost-summary.sh&lt;/code&gt; returned "$0.00 / 0 sess". &lt;strong&gt;It looked like it was working while recording nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This isn't merely an implementation bug — it's an architectural mistake stemming from a misunderstanding of what Claude Code's Stop hook is. To fix it, you have to give up on a field that doesn't exist in the payload and go read the place the payload &lt;em&gt;points to&lt;/em&gt; — &lt;code&gt;transcript_path&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That switch is the core of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  The overall flow
&lt;/h2&gt;

&lt;p&gt;Here's the corrected architecture at a glance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────┐
│  Claude Code セッション                              │
│                                                     │
│  アシスタントターン完了                              │
│       │                                             │
│       ▼                                             │
│  Stop フック発火                                    │
│       │                                             │
│       ▼ stdin (JSON)                                │
│  { session_id, transcript_path, cwd, ... }          │
│       │                                             │
│       ▼                                             │
│  cost-tracker.js                                    │
│  ┌──────────────────────────────────────────────┐   │
│  │  1. transcript_path を取得                   │   │
│  │  2. JSONL を読み込み                         │   │
│  │  3. type="assistant" の行だけフィルタ        │   │
│  │  4. message.usage を積算                     │   │
│  │  5. モデル名からレートを引いてコスト計算     │   │
│  │  6. ~/.claude/metrics/costs.jsonl に追記     │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is the shift in understanding: "the Stop hook is not a courier delivering cost information." The hook is strictly an &lt;strong&gt;event notifier&lt;/strong&gt;, and all the notification contains is an address (&lt;code&gt;transcript_path&lt;/code&gt;) telling you which session transcript to read. The cost information lives inside the transcript.&lt;/p&gt;

&lt;h3&gt;
  
  
  The structure of the JSONL that transcript_path points to
&lt;/h3&gt;

&lt;p&gt;Claude Code writes every turn of a session into a single JSONL file. Each line corresponds to one message, and the &lt;code&gt;type&lt;/code&gt; field distinguishes the kind.&lt;/p&gt;

&lt;p&gt;What you need for cost calculation are the &lt;code&gt;type: "assistant"&lt;/code&gt; lines. Their structure is as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-4-6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"usage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12483&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;847&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cache_creation_input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cache_read_input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3200&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Summing the four kinds — &lt;code&gt;input_tokens&lt;/code&gt;, &lt;code&gt;output_tokens&lt;/code&gt;, &lt;code&gt;cache_creation_input_tokens&lt;/code&gt;, &lt;code&gt;cache_read_input_tokens&lt;/code&gt; — across all assistant turns gives you the session's total token consumption. Multiply by the billing rates and you have cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementing the sumUsageFromTranscript function
&lt;/h3&gt;

&lt;p&gt;Lines 57–90 of &lt;code&gt;cost-tracker.js&lt;/code&gt; implement this JSONL accumulation logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sumUsageFromTranscript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcriptPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcriptPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;inputTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;outputTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cacheWriteTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cacheReadTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;inputTokens&lt;/span&gt;      &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;outputTokens&lt;/span&gt;     &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;cacheWriteTokens&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;cacheReadTokens&lt;/span&gt;  &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;inputTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outputTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheWriteTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheReadTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three design decisions are worth noting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swallow parse errors and continue.&lt;/strong&gt; Even if a line partway through the JSONL is corrupted, &lt;code&gt;try { entry = JSON.parse(line); } catch { continue; }&lt;/code&gt; skips it. The Stop hook must be non-blocking. Having Claude Code's session termination fail because the cost log couldn't be captured would be completely backwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prevent NaN with &lt;code&gt;toNumber()&lt;/code&gt;.&lt;/strong&gt; The reason the old implementation kept writing &lt;code&gt;0&lt;/code&gt; was NaN propagating from trying to convert a nonexistent field into a number. In the new implementation, a &lt;code&gt;toNumber()&lt;/code&gt; helper checks with &lt;code&gt;Number.isFinite()&lt;/code&gt; and returns &lt;code&gt;0&lt;/code&gt; if the value isn't a finite number (lines 47–49).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the last model name found.&lt;/strong&gt; Assuming the model can switch mid-session, it keeps overwriting as long as &lt;code&gt;msg.model&lt;/code&gt; isn't &lt;code&gt;'unknown'&lt;/code&gt;. The model from the later part of the session becomes the representative value, but the cost error is generally within acceptable range.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rate table and cost calculation
&lt;/h3&gt;

&lt;p&gt;Per-model billing rates are hardcoded on lines 34–38.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATE_TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;haiku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;cacheWrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;cacheRead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;sonnet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;15.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheWrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;cacheRead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;opus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;15.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;75.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheWrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;18.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheRead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.50&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Units are US dollars per 1M tokens. The &lt;code&gt;getRates()&lt;/code&gt; function (lines 40–45) determines &lt;code&gt;haiku&lt;/code&gt;, &lt;code&gt;opus&lt;/code&gt;, or other (default &lt;code&gt;sonnet&lt;/code&gt;) from the model name string.&lt;/p&gt;

&lt;p&gt;The cost calculation is contained in lines 128–133.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;estimatedCostUsd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputTokens&lt;/span&gt;      &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputTokens&lt;/span&gt;     &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheWriteTokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheWrite&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheReadTokens&lt;/span&gt;  &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheRead&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/ 1e6 * 1e6&lt;/code&gt; round trip is there to round away floating-point error. Rounding at the micro-dollar level keeps errors below &lt;code&gt;0.000001&lt;/code&gt; out of the result.&lt;/p&gt;

&lt;p&gt;The line ultimately appended to &lt;code&gt;~/.claude/metrics/costs.jsonl&lt;/code&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-18T08:45:22.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transcript_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.claude/transcripts/abc123.jsonl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-4-6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12483&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;847&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cache_write_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cache_read_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"estimated_cost_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.051234&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything up to here is recorded automatically on each completed turn. Every time the Stop hook fires, the session's "cumulative cost up to that point" is appended. If you want the final cost for one session, you take the last line with the same &lt;code&gt;session_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the next part, I'll verify the specific failure patterns of the old implementation that spat out zeros for 52 days, and how the output of &lt;code&gt;cost-summary.sh&lt;/code&gt; changed before and after the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The stdin design and the 64KB cap
&lt;/h3&gt;

&lt;p&gt;Input to the Stop hook arrives on stdin. Since Node.js stdin is a stream, data may arrive split across multiple chunks. &lt;code&gt;cost-tracker.js&lt;/code&gt; handles this on lines 92–98 as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_STDIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEncoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_STDIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_STDIN&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 64KB (65,536 byte) cap is a defensive design: the Stop hook payload is a 4-field JSON of a few hundred bytes in practice, but the cap prevents the process from hanging on a future Claude Code spec change or an unexpectedly large payload. The double guard of &lt;code&gt;raw.length &amp;lt; MAX_STDIN&lt;/code&gt; and &lt;code&gt;chunk.substring(0, MAX_STDIN - raw.length)&lt;/code&gt; ensures the read buffer stops at the cap no matter what input arrives.&lt;/p&gt;

&lt;p&gt;If the cap is exceeded and the JSON is cut off partway, &lt;code&gt;JSON.parse(raw)&lt;/code&gt; on line 100 throws. But the outer &lt;code&gt;try { ... } catch { }&lt;/code&gt; catches it and the hook terminates without incident. What matters is the structure on lines 151–156:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Non-blocking — never fail the Stop hook.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Pass stdin through (required by ECC hook convention).&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;process.stdout.write(raw)&lt;/code&gt; is &lt;em&gt;outside&lt;/em&gt; the &lt;code&gt;try&lt;/code&gt;. Even if a parse failure blows away the cost record, the stdin contents always flow to stdout. This is a requirement of the ECC hook convention, and an expression of the design philosophy that &lt;strong&gt;delivering input to downstream links in the hook chain takes top priority&lt;/strong&gt;. Losing one line of cost log versus crashing the entire Stop hook — the magnitudes of impact are literally incomparable.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three-tier fallback for transcript_path
&lt;/h3&gt;

&lt;p&gt;Lines 104–106 hold the logic for obtaining &lt;code&gt;transcript_path&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transcriptPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transcript_path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transcript_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transcript_path&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CLAUDE_TRANSCRIPT_PATH&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first candidate is the payload's &lt;code&gt;input.transcript_path&lt;/code&gt;. It's adopted only after confirming it's a string type and non-empty. The &lt;code&gt;typeof&lt;/code&gt; check is there to prevent an exception from touching &lt;code&gt;undefined&lt;/code&gt; when the payload falls back to an empty object &lt;code&gt;{}&lt;/code&gt; (e.g. stdin was empty).&lt;/p&gt;

&lt;p&gt;The second candidate is the environment variable &lt;code&gt;CLAUDE_TRANSCRIPT_PATH&lt;/code&gt;. This is for testing and debugging. It's unnecessary via the actual Stop hook, but when you want to run the script standalone by hand and check the log, you can set the environment variable manually and run it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CLAUDE_TRANSCRIPT_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.claude/transcripts/abc.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; | node cost-tracker.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old implementation, which tried to read directly from the payload, had none of this design — it didn't even have a variable called transcript_path.&lt;/p&gt;

&lt;h3&gt;
  
  
  The sessionId resolution chain
&lt;/h3&gt;

&lt;p&gt;Session ID retrieval is a three-stage process on lines 108–112.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nf"&gt;sanitizeSessionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nf"&gt;sanitizeSessionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ECC_SESSION_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nf"&gt;sanitizeSessionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CLAUDE_SESSION_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sanitizeSessionId&lt;/code&gt; is a utility that validates and sanitizes UUID format. Getting &lt;code&gt;session_id&lt;/code&gt; from the payload is ideal, but in an ECC session-management environment there may be &lt;code&gt;ECC_SESSION_ID&lt;/code&gt;, and in plain Claude Code there may be &lt;code&gt;CLAUDE_SESSION_ID&lt;/code&gt; as environment variables. If none can be obtained, it falls through to &lt;code&gt;'default'&lt;/code&gt;. Rows with &lt;code&gt;'default'&lt;/code&gt; effectively serve as a signal you can check later that "something is completely broken."&lt;/p&gt;

&lt;h3&gt;
  
  
  Floating-point countermeasures in cost calculation
&lt;/h3&gt;

&lt;p&gt;Repeating the cost calculation from lines 128–133:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;estimatedCostUsd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputTokens&lt;/span&gt;      &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputTokens&lt;/span&gt;     &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheWriteTokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheWrite&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheReadTokens&lt;/span&gt;  &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheRead&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The round trip of &lt;code&gt;* 1e6&lt;/code&gt;, then &lt;code&gt;Math.round&lt;/code&gt;, then &lt;code&gt;/ 1e6&lt;/code&gt; exists to erase floating-point error below the micro-dollar level. It prevents JavaScript's famous &lt;code&gt;0.1 + 0.2 === 0.30000000000000004&lt;/code&gt; trap from leaving damage in the decimals of cost aggregation. You could argue there's no real harm if $0.051234 gets recorded in the JSONL as $0.051234000000000003, but the numbers look ugly when you later aggregate with &lt;code&gt;cost-summary.sh&lt;/code&gt;. It's a single line of processing, but it shows that the quality of the record is being taken seriously.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I got stuck
&lt;/h2&gt;

&lt;p&gt;The fix wasn't finished by simply rewriting the old zero-writing implementation to read the transcript. Even after rewriting to v2, two separate problems stacked up and turned verification into a maze.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck point ①: Fixed to v2, but summary still says "$0.00 / 0 sess"
&lt;/h3&gt;

&lt;p&gt;The morning after implementing and deploying &lt;code&gt;sumUsageFromTranscript&lt;/code&gt;, I ran &lt;code&gt;cost-summary.sh&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;=== cost summary (last 7d) ===
  sessions: 0
&lt;/span&gt;&lt;span class="gp"&gt;  total:    $&lt;/span&gt;0.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thinking "zero again," I first looked directly at &lt;code&gt;~/.claude/metrics/costs.jsonl&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt; ~/.claude/metrics/costs.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file existed, with 3 rows recorded for that day. The &lt;code&gt;estimated_cost_usd&lt;/code&gt; values were &lt;code&gt;0.048291&lt;/code&gt;, &lt;code&gt;0.031457&lt;/code&gt;, &lt;code&gt;0.072139&lt;/code&gt;. v2 was working correctly.&lt;/p&gt;

&lt;p&gt;The cause was what &lt;code&gt;cost-summary.sh&lt;/code&gt; was reading from. Look at line 10 of the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude/logs/cost-log.jsonl"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;code&gt;~/.claude/logs/cost-log.jsonl&lt;/code&gt;. Meanwhile, what &lt;code&gt;cost-tracker.js&lt;/code&gt; writes to is &lt;code&gt;~/.claude/metrics/costs.jsonl&lt;/code&gt;. &lt;strong&gt;Different directories. &lt;code&gt;logs&lt;/code&gt; vs &lt;code&gt;metrics&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The field names didn't match either. The aggregation logic in &lt;code&gt;cost-summary.sh&lt;/code&gt; (lines 39–47) looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads &lt;code&gt;cost_usd&lt;/code&gt; and &lt;code&gt;ts&lt;/code&gt;. But the fields &lt;code&gt;cost-tracker.js&lt;/code&gt; writes are &lt;code&gt;estimated_cost_usd&lt;/code&gt; and &lt;code&gt;timestamp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In other words, at the point I fixed things to v2, &lt;strong&gt;two mismatches existed simultaneously: a file path mismatch and a field name mismatch&lt;/strong&gt;. If it were only one of them, &lt;code&gt;cost-summary.sh&lt;/code&gt; would read an empty file and return "0 sess". With both at once the symptom is unchanged, so I nearly misdiagnosed it as "v2 still isn't working either."&lt;/p&gt;

&lt;p&gt;What was actually working was the v2 hook. What wasn't working was where the summary script was reading from.&lt;/p&gt;

&lt;p&gt;The debugging lesson was "look at the terminal record file directly with &lt;code&gt;tail -f&lt;/code&gt;." Rather than trusting the summary script's output and concluding "it's not working," check the raw JSONL with your own eyes. Without the habit of isolating which stage of the pipeline is broken one step at a time, this double mismatch would have stayed unsolved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck point ②: Why NaN propagation doesn't make the rows look broken
&lt;/h3&gt;

&lt;p&gt;Let me trace a bit more deeply how the v1 code kept writing 2,340 zero rows.&lt;/p&gt;

&lt;p&gt;The old implementation looked (in pseudocode) like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// v1 (旧実装の想定コード)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inputTokens&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// undefined → NaN&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outputTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// undefined → NaN&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputTokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;          &lt;span class="c1"&gt;// NaN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Number(undefined)&lt;/code&gt; returns &lt;code&gt;NaN&lt;/code&gt;. Every arithmetic operation using &lt;code&gt;NaN&lt;/code&gt; returns &lt;code&gt;NaN&lt;/code&gt;. That much is predictable. The problem is the next line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;estimated_cost_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;NaN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;NaN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// → '{"estimated_cost_usd":null,"input_tokens":null,...}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;JSON.stringify&lt;/code&gt; converts &lt;code&gt;NaN&lt;/code&gt; to &lt;code&gt;null&lt;/code&gt;.&lt;/strong&gt; That's the JavaScript spec. Not an error — it quietly becomes &lt;code&gt;null&lt;/code&gt;. The lines written to the JSONL aren't malformed JSON; they're perfectly well-formed lines. It's just that all the values are &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the &lt;code&gt;cost-summary.sh&lt;/code&gt; side, the Python code (lines 40–49) is written as &lt;code&gt;r.get("cost_usd", 0)&lt;/code&gt;, so when the key exists but the value is &lt;code&gt;null&lt;/code&gt;, it returns &lt;code&gt;None&lt;/code&gt;. In Python, &lt;code&gt;total += None&lt;/code&gt; throws a &lt;code&gt;TypeError&lt;/code&gt;, but &lt;code&gt;except Exception: continue&lt;/code&gt; on line 49 skips all exceptions, so that session's row is ignored and &lt;code&gt;total&lt;/code&gt; doesn't move.&lt;/p&gt;

&lt;p&gt;No errors. No exceptions. Just silently skipped from aggregation, over and over. The structure was: &lt;strong&gt;it wasn't that zeros were being recorded — the recorded rows were being continuously excluded from aggregation.&lt;/strong&gt; Writing &lt;code&gt;null&lt;/code&gt; is the JSON spec; skipping &lt;code&gt;null&lt;/code&gt; is Python's try-except. Each is correct behavior on its own, but combined they created a 52-day blind spot.&lt;/p&gt;

&lt;p&gt;The new implementation's &lt;code&gt;toNumber()&lt;/code&gt; helper (lines 47–49) cuts this propagation off at the source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Number.isFinite(NaN)&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;. &lt;code&gt;NaN&lt;/code&gt;, &lt;code&gt;Infinity&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;undefined&lt;/code&gt; all get converted to &lt;code&gt;0&lt;/code&gt; here. The path by which &lt;code&gt;null&lt;/code&gt; could contaminate a record row is sealed at the very top of the conversion chain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck point ③: Unexpected cost overstatement from how "&lt;code&gt;model: unknown&lt;/code&gt;" rows are handled
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sumUsageFromTranscript&lt;/code&gt; scans the assistant turns of a session JSONL and adopts the last &lt;code&gt;msg.model&lt;/code&gt; found as the representative model (line 86: &lt;code&gt;if (msg.model &amp;amp;&amp;amp; msg.model !== 'unknown') model = msg.model&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;But the Stop hook fires "on each completed assistant turn" (comment line 19: &lt;em&gt;"Stop fires per assistant response, not per session"&lt;/em&gt;). When the hook runs right after the session's first turn finishes, the transcript has only one assistant line, and there's no problem as long as the model name was recorded correctly on that turn.&lt;/p&gt;

&lt;p&gt;However, in rare cases the assistant message early in a session has no &lt;code&gt;model&lt;/code&gt; field (streaming interruption, tool-use-only turns, etc.). In that case &lt;code&gt;model&lt;/code&gt; stays &lt;code&gt;'unknown'&lt;/code&gt; and gets passed to &lt;code&gt;getRates('unknown')&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getRates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;haiku&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;RATE_TABLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;haiku&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;opus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;RATE_TABLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;RATE_TABLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sonnet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ← fallthrough&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;'unknown'&lt;/code&gt; matches none of the conditions, so the &lt;code&gt;sonnet&lt;/code&gt; rate is applied. Early-session turns from a session that was using a Haiku model get calculated at the &lt;code&gt;sonnet&lt;/code&gt; rate, and the cost is recorded inflated by 3.75× (&lt;code&gt;in: 0.80&lt;/code&gt; vs &lt;code&gt;in: 3.00&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;I noticed this discrepancy when I was looking directly at &lt;code&gt;costs.jsonl&lt;/code&gt; and saw multiple rows with &lt;code&gt;model: unknown&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"unknown"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4821&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"estimated_cost_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.014463&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calculating 4,821 input tokens at the sonnet rate gives about $0.0145. At the haiku rate it would be about $0.0039. Nearly a 4× difference.&lt;/p&gt;

&lt;p&gt;The root fix would be either "defer cost calculation for rows where the model couldn't be obtained" or "retroactively correct once the model becomes known in a later turn," but implementation complexity jumps sharply. In the current implementation, the policy is to &lt;strong&gt;approximate &lt;code&gt;unknown&lt;/code&gt; rows at the sonnet rate and judge the monthly error to be within acceptable range&lt;/strong&gt;. What I want from a cost tracker is precision at a granularity usable for business decisions, not matching the Anthropic console down to the yen.&lt;/p&gt;

&lt;p&gt;Whether you make that trade-off consciously is, I think, the difference in attitude toward a tool's reliability. Rather than settling for "that's just how it is," it's only once you've traced "the conditions under which this row becomes unknown," "the upper bound of the cost error at that point," and "the impact that has on monthly aggregation" that the numbers become something you can use with confidence.&lt;/p&gt;




&lt;p&gt;In the next part, I'll cover how &lt;code&gt;cost-summary.sh&lt;/code&gt; started returning non-zero values after the fix, and the surprising consumption patterns that emerged from the daily cost graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;The previous two parts dug into four points: "the Stop hook payload has no usage," "the NaN→null JSON.stringify trap," "path/field name mismatches," and "model:unknown cost overstatement." Here I'll cover the actual sticking points that fell outside those. Every one of them was a wall I thought was impossible.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Because the Stop hook fires per turn, dozens of rows accumulate under the same session_id.&lt;/strong&gt;&lt;br&gt;
If there are 30 assistant turns in one session, 30 rows get written to &lt;code&gt;costs.jsonl&lt;/code&gt;. It's stated clearly in comment line 19 of &lt;code&gt;cost-tracker.js&lt;/code&gt; (&lt;em&gt;"Stop fires per assistant response, not per session"&lt;/em&gt;), but if the aggregation script doesn't know this, it sums all rows and produces a value dozens of times the real cost. I also made the error in the opposite direction: I thought "I spent $90 this month" when it was actually $4. To get per-session cost, you must aggregate only the last row for each &lt;code&gt;session_id&lt;/code&gt; (= the maximum cumulative value). The current aggregation logic in &lt;code&gt;cost-summary.sh&lt;/code&gt; (lines 37–49) doesn't account for this cumulative structure and adds every row with &lt;code&gt;for line in open(log):&lt;/code&gt;. Fixing the hook to v2 isn't enough — the summary script needs fixing too.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Passing a &lt;code&gt;transcript_path&lt;/code&gt; with a literal &lt;code&gt;~&lt;/code&gt; to &lt;code&gt;fs.readFileSync&lt;/code&gt; crashes immediately.&lt;/strong&gt;&lt;br&gt;
Node.js's &lt;code&gt;fs.readFileSync('~/.claude/...')&lt;/code&gt; doesn't do shell expansion. &lt;code&gt;~&lt;/code&gt; is treated as a plain string and it throws because the file doesn't exist. After receiving the path on lines 104–106 of &lt;code&gt;cost-tracker.js&lt;/code&gt;, if there's no processing to expand it with &lt;code&gt;os.homedir()&lt;/code&gt; or &lt;code&gt;path.resolve()&lt;/code&gt;, then depending on the Claude Code execution environment, &lt;code&gt;transcript_path&lt;/code&gt; arrives with a tilde. I actually hit this. You should run &lt;code&gt;transcriptPath.replace(/^~/, os.homedir())&lt;/code&gt; before using &lt;code&gt;input.transcript_path&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Writing the hook command in &lt;code&gt;settings.json&lt;/code&gt; as just a path means &lt;code&gt;node&lt;/code&gt; isn't found.&lt;/strong&gt;&lt;br&gt;
If you're using nvm-managed Node, the nvm bin directory may not be in PATH by the time the hook is launched through a shell. The same applies to a &lt;code&gt;#!/usr/bin/env node&lt;/code&gt; shebang — there are cases where the &lt;code&gt;node&lt;/code&gt; that &lt;code&gt;/usr/bin/env&lt;/code&gt; finds points to an old system Node at &lt;code&gt;/usr/local/bin/node&lt;/code&gt; (v16, etc.). The reliable approach is to specify node's absolute path explicitly in settings.json's command.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/Users/&amp;lt;you&amp;gt;/.nvm/versions/node/v24.13.0/bin/node ~/.claude/scripts/hooks/cost-tracker.js"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adjust the path to your own setup. When a hook "doesn't feel like it's firing," this is the first thing I suspect.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Debugging with &lt;code&gt;console.log&lt;/code&gt; breaks the hook chain.&lt;/strong&gt;&lt;br&gt;
The Stop hook's stdout is exclusively for passing the payload through. &lt;code&gt;cost-tracker.js&lt;/code&gt; line 156: &lt;code&gt;process.stdout.write(raw);&lt;/code&gt; — downstream scripts in the hook chain receive their input from this stdout. Insert a single &lt;code&gt;console.log('debug:', something)&lt;/code&gt; and an arbitrary string contaminates stdout, breaking the downstream hook when it tries to parse the JSON. Always write debug output to &lt;code&gt;process.stderr.write(...)&lt;/code&gt; or a dedicated log file.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Not counting &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; makes costs come out low.&lt;/strong&gt;&lt;br&gt;
Because Claude Code makes heavy use of prompt caching, billing for &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; accounts for a non-negligible share. Sonnet's cache write rate is $3.75/1M tokens (1.25× normal input) and the read rate is $0.30/1M tokens (0.1×) (see the RATE table on lines 34–38 of cost-tracker.js). If a session's input is 50,000 tokens with 30,000 of those being cache writes, the write portion alone is about $0.11 — 1.5× the cost of normal input. Leaving &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; out of aggregation makes 20–40% of your real cost invisible.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Calling &lt;code&gt;process.exit()&lt;/code&gt; inside the Stop hook cuts stdin short and loses data.&lt;/strong&gt;&lt;br&gt;
Node.js stdin is a stream. If you detect an error inside the &lt;code&gt;process.stdin.on('data', ...)&lt;/code&gt; callback and call &lt;code&gt;process.exit(1)&lt;/code&gt;, the process ends before the remaining chunks arrive, and the payload gets cut off. Design hooks so all processing completes inside the &lt;code&gt;process.stdin.on('end', () =&amp;gt; { ... })&lt;/code&gt; callback, with no early exit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Even when &lt;code&gt;transcript_path&lt;/code&gt; arrives, the file may not exist.&lt;/strong&gt;&lt;br&gt;
The Stop hook fires simultaneously with session completion, but there's a very short race window between Claude Code writing the transcript and the hook launching. That's what the existence check on line 115 of &lt;code&gt;cost-tracker.js&lt;/code&gt; — &lt;code&gt;if (transcriptPath &amp;amp;&amp;amp; fs.existsSync(transcriptPath))&lt;/code&gt; — is for. Omit it and call &lt;code&gt;readFileSync&lt;/code&gt; directly, and occasionally you'll throw on "file doesn't exist" rather than "the path exists but can't be read." The outer try-catch will catch the error, but that session's cost is lost entirely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trying to match the Anthropic console's numbers is a swamp.&lt;/strong&gt;&lt;br&gt;
The cost &lt;code&gt;cost-tracker.js&lt;/code&gt; calculates is strictly an estimate. Actual billing is based on the token counts Anthropic measures, plus various factors like batch discounts, promotions, and taxes. Start chasing "why is it $2 off from the console" and it will consume infinite time. All I want from this tracker is "monthly trends and identification of high-cost sessions." I decided within ±15% of the Anthropic console is my acceptable range and stopped pursuing precision beyond that.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verifying behavior using only the summary script's output.&lt;/strong&gt;&lt;br&gt;
I touched on this in p2, but I'll restate it as a universal anti-pattern. &lt;code&gt;cost-summary.sh&lt;/code&gt; returns correct numbers only when the read path, field names, and aggregation logic are all correct. If even one of them doesn't match, you get &lt;code&gt;$0.00 / 0 sess&lt;/code&gt;. Whether the hook is working correctly can only be determined by checking &lt;code&gt;~/.claude/metrics/costs.jsonl&lt;/code&gt; directly with &lt;code&gt;tail -5&lt;/code&gt;, not by the summary output. When you think "it's not working," your first command should be checking the raw file, not re-running the summary script.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;p&gt;Here are 10+ design principles for tracking cost with the Stop hook, solidified through implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① Wrap the entire hook in &lt;code&gt;try-catch&lt;/code&gt; and make it absolutely non-blocking.&lt;/strong&gt;&lt;br&gt;
Even if the cost log can't be captured, it must not obstruct Claude Code's session termination. Lines 151–153 of &lt;code&gt;cost-tracker.js&lt;/code&gt; are the core of this design.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Non-blocking — never fail the Stop hook.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Losing the cost log is far more acceptable than crashing the hook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② Put the stdin pass-through outside the &lt;code&gt;try&lt;/code&gt; block.&lt;/strong&gt;&lt;br&gt;
If you put &lt;code&gt;process.stdout.write(raw)&lt;/code&gt; inside the &lt;code&gt;try&lt;/code&gt;, input stops reaching downstream scripts in the hook chain when parsing fails. The placement on line 156 (after the try-catch) is deliberate design. The cost log can be lost, but the hook chain stays alive — that's the priority order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Kill NaN at the source with a &lt;code&gt;toNumber()&lt;/code&gt; helper.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;Number(undefined) === NaN&lt;/code&gt;, and &lt;code&gt;JSON.stringify({v: NaN})&lt;/code&gt; becomes &lt;code&gt;{"v":null}&lt;/code&gt;. Cutting off this propagation with &lt;code&gt;toNumber()&lt;/code&gt; on lines 47–49 seals the path by which &lt;code&gt;null&lt;/code&gt; contaminates a record row, at the source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;④ Take the write path and the read path from the same constant.&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;logs/cost-log.jsonl&lt;/code&gt; vs &lt;code&gt;metrics/costs.jsonl&lt;/code&gt; mismatch I wrote about in p2 was born from two scripts writing the path separately. Ideally you place a constant like &lt;code&gt;COSTS_PATH&lt;/code&gt; in a shared module and import it from both the hook script and the summary script. Between two JavaScript files you can share it with &lt;code&gt;require('../lib/paths')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑤ Manage field names with schema constants too.&lt;/strong&gt;&lt;br&gt;
Name mismatches like &lt;code&gt;ts&lt;/code&gt; vs &lt;code&gt;timestamp&lt;/code&gt; and &lt;code&gt;cost_usd&lt;/code&gt; vs &lt;code&gt;estimated_cost_usd&lt;/code&gt; also don't happen if the schema is defined in one place. It structurally guarantees that JSONL writing and reading reference the same keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑥ Verify the terminal file directly with &lt;code&gt;tail&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
The summary script's output isn't the truth — it's "the world as the summary script sees it." The most reliable way to confirm the hook is working correctly is this one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt; ~/.claude/metrics/costs.jsonl | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;estimated_cost_usd&lt;/code&gt; has a non-zero value, the hook is working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑦ Prevent hangs with a stdin cap (64KB).&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;cost-tracker.js&lt;/code&gt; line 92: &lt;code&gt;const MAX_STDIN = 64 * 1024;&lt;/code&gt;. The current Stop hook payload is a few hundred bytes, but this is a defensive design preventing the process from hanging on a future spec change or unexpectedly large input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑧ Check that &lt;code&gt;transcript_path&lt;/code&gt; exists before reading.&lt;/strong&gt;&lt;br&gt;
Forget the &lt;code&gt;fs.existsSync(transcriptPath)&lt;/code&gt; check (line 115) and a read will run during the race window when the file doesn't exist, losing that session's cost row entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑨ Always count &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; and &lt;code&gt;cache_read_input_tokens&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
Omit these two fields and 20–40% of your real cost becomes invisible on cache-heavy sessions. Because Claude Code automatically caches long prompts, the cache ratio grows the more you use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑩ Use only the last row of each &lt;code&gt;session_id&lt;/code&gt; as the per-session cost.&lt;/strong&gt;&lt;br&gt;
Sum all rows without knowing the cumulative design and you get a value dozens of times the real cost. Always shape the aggregation logic as "group by session_id and take the row with the latest timestamp."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑪ Make &lt;code&gt;model: unknown&lt;/code&gt; rows identifiable to make the location of cost error explicit.&lt;/strong&gt;&lt;br&gt;
Rows whose &lt;code&gt;model&lt;/code&gt; field is &lt;code&gt;'unknown'&lt;/code&gt; are approximated at the sonnet rate (the getRates function's fallthrough). When there are many such rows, the divergence from real cost widens. Displaying the count and computed cost of &lt;code&gt;unknown&lt;/code&gt; rows separately during aggregation makes the scale of the error visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑫ Send debug output to stderr or a log file. Don't touch stdout.&lt;/strong&gt;&lt;br&gt;
A hook's stdout is exclusively for pass-through. &lt;code&gt;console.log&lt;/code&gt; is forbidden; use only &lt;code&gt;console.error&lt;/code&gt; or appending to a dedicated log file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑬ Specify node's absolute path in the hook command.&lt;/strong&gt;&lt;br&gt;
Specifying &lt;code&gt;/path/to/.nvm/versions/node/vX.Y.Z/bin/node&lt;/code&gt; directly ensures the hook launches regardless of whether PATH is expanded. It's mandatory in execution environments where &lt;code&gt;nvm use&lt;/code&gt; doesn't take effect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑭ State your acceptable error range and stop chasing precision.&lt;/strong&gt;&lt;br&gt;
What you want from a cost tracker is precision sufficient to "identify high-cost sessions and grasp monthly trends," not matching the Anthropic console to the yen. Deciding on an error you can accept (e.g. ±15%) saves you from spending time on pointless precision improvements.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;52 days, 2,340 rows, 0.0% non-zero rate — the fact that this confession still lives in the code shows how quietly, and for how long, a misunderstanding of the Stop hook can keep doing real damage.&lt;/p&gt;

&lt;p&gt;The root cause was simple: &lt;strong&gt;the assumption that "the Stop hook payload has a usage field."&lt;/strong&gt; Trying to read a field that doesn't exist in the payload produced &lt;code&gt;NaN&lt;/code&gt;, which &lt;code&gt;JSON.stringify&lt;/code&gt; converted to &lt;code&gt;null&lt;/code&gt;, which was then silently skipped during aggregation. No errors, no warnings. Just zeros quietly stacking up.&lt;/p&gt;

&lt;p&gt;The essence of the fix comes down to one point. &lt;strong&gt;The cost information isn't in the payload — it's in the JSONL that &lt;code&gt;transcript_path&lt;/code&gt; points to.&lt;/strong&gt; The Stop hook is "a notifier that tells you which JSONL to read," not "a courier that delivers cost information." That shift in understanding is the entirety of the v1-to-v2 rewrite.&lt;/p&gt;

&lt;p&gt;Even if v2 is written correctly, the output stays zero unless &lt;code&gt;cost-summary.sh&lt;/code&gt;'s read target and field names match. Until you look at the terminal file directly with &lt;code&gt;tail -5&lt;/code&gt;, you can't tell which of the two is broken. The principle of pipeline debugging is not "trust the summary output" but "work backwards from the most terminal record."&lt;/p&gt;

&lt;p&gt;Once cost becomes visible as numbers, the resolution of your business decisions changes. "How much am I spending on Sonnet per month?" "Which work can be replaced by shifting to Haiku?" "Is the overnight autonomous loop more cost-effective than a morning manual session?" — you can judge these with numbers instead of intuition. Being able to keep optimizing the breakdown of ¥1.2M in monthly revenue is possible because there's a system making gross margin visible.&lt;/p&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>claudecode</category>
      <category>javascript</category>
      <category>debugging</category>
    </item>
    <item>
      <title>3 Wrong Guesses About a Chrome Slot Limit: 3 6 Reserved Lanes</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Fri, 28 Aug 2026 00:00:04 +0000</pubDate>
      <link>https://dev.to/bokuwalily/3-wrong-guesses-about-a-chrome-slot-limit-3-6-reserved-lanes-2jkj</link>
      <guid>https://dev.to/bokuwalily/3-wrong-guesses-about-a-chrome-slot-limit-3-6-reserved-lanes-2jkj</guid>
      <description>&lt;p&gt;Automation doesn't break when you write it. It breaks in production, weeks later, quietly — and my Chrome concurrency limit broke three separate times before it settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Mechanism Matters
&lt;/h2&gt;

&lt;p&gt;Once you run automation seriously as a solo developer, you always hit the same wall: multiple jobs trying to use the same resource at the same time.&lt;/p&gt;

&lt;p&gt;In the context of social media automation, that resource is Chrome. Liking, following, posting, note integration — browser-driven jobs run around the clock. In my setup, a launchd job called &lt;code&gt;com.lily.autolike.ig-1&lt;/code&gt; fires every two hours, 12 times a day (0:36, 2:36, 4:36 … 22:36), and drives automatic likes through Chrome. That alone is 12 processes a day, and jobs of the same kind exist across multiple accounts and multiple social networks.&lt;/p&gt;

&lt;p&gt;With no control at all, overlapping start times mean nine Chrome instances launch simultaneously. It's not hard to imagine the Mac screaming.&lt;/p&gt;

&lt;p&gt;The problem is &lt;em&gt;how&lt;/em&gt; it breaks. If one of them crashed from memory exhaustion, that would at least be legible. What actually happens is that &lt;strong&gt;every process stays alive but becomes extremely slow, and timeouts pile up&lt;/strong&gt;. The run looks like it completed, but effectively nothing happened. The logs fill with &lt;code&gt;TIMEOUT: killed after 900s&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The idea of "control this with a cap on concurrent launches" is itself correct. What I got wrong was &lt;strong&gt;the basis for the cap value, and how I treated priority across job types&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;~/.claude/scripts/browser-slot.sh&lt;/code&gt; carries all three mistakes and their fixes, etched into a single file. The comments serve as a change log, and tracing the numbers and dates reveals the structure of the failures.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Real Problem: Intuitive Resource Blame
&lt;/h3&gt;

&lt;p&gt;When automation gets heavy, your intuition says "something must be eating resources." Chrome sure looks heavy. My decision to set the cap to 3 was &lt;strong&gt;based on the hypothesis that Chrome was devouring memory&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When the hypothesis is wrong, the countermeasure is wrong. The cap of 3 was a number born from the misdiagnosis "throttle Chrome and things get lighter." Until I actually measured, I didn't notice that this misdiagnosis was producing 92 skips a day.&lt;/p&gt;

&lt;p&gt;Put the other way around: &lt;strong&gt;measuring and recording it in a comment makes the misdiagnosis visible&lt;/strong&gt;. The script's comments (lines 5–8) read as a postmortem report on their own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 実測(2026-08-09): Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd(47GB)/&lt;/span&gt;
&lt;span class="c"&gt;# ComfyUI(12GB)/iii(3.5GB)であってブラウザジョブではなかった。上限3は過剰に厳しく&lt;/span&gt;
&lt;span class="c"&gt;# 1日で92回のskip(全体の30%)を出していたので5に緩める。&lt;/span&gt;
&lt;span class="nv"&gt;SLOT_MAX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_MAX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;6&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment says "relax it to 5," but the actual default value of &lt;code&gt;SLOT_MAX&lt;/code&gt; is &lt;code&gt;6&lt;/code&gt;. At the time I wrote the comment I was assuming 5, but I ended up setting 6. That very "gap between comment and value" is the fingerprint of a fix made in one sitting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lock Files Live or Die by Atomicity
&lt;/h3&gt;

&lt;p&gt;For the semaphore mechanism, &lt;code&gt;browser-slot.sh&lt;/code&gt; chose &lt;strong&gt;the atomicity of the filesystem's &lt;code&gt;mkdir&lt;/code&gt;&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GUARD_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLOT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/.guard"&lt;/span&gt;

acquire_guard&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 100 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GUARD_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GUARD_DIR&lt;/span&gt;&lt;span class="s2"&gt;/pid"&lt;/span&gt;
      &lt;span class="nv"&gt;GUARD_HELD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
      &lt;span class="k"&gt;return &lt;/span&gt;0
    &lt;span class="k"&gt;fi&lt;/span&gt;
    &lt;span class="c"&gt;# ...ゾンビガードの掃除...&lt;/span&gt;
    &lt;span class="nv"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;attempt &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
    &lt;span class="nb"&gt;sleep &lt;/span&gt;0.05
  &lt;span class="k"&gt;done
  return &lt;/span&gt;1
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mkdir&lt;/code&gt; is atomic under POSIX. If the directory doesn't exist it is created and succeeds; if it already exists it fails. This mutual exclusion, which doesn't consume a single byte, guarantees that even when multiple jobs call &lt;code&gt;acquire_guard()&lt;/code&gt; simultaneously, exactly one gets through.&lt;/p&gt;

&lt;p&gt;The process that acquires the guard then scans the &lt;code&gt;*.lock&lt;/code&gt; files under &lt;code&gt;~/.cache/lily-browser-slots/&lt;/code&gt; to count how many are currently running. The first line of a lock file is the PID, and liveness is checked with &lt;code&gt;kill -0 $pid&lt;/code&gt;. Lock files belonging to dead processes get cleaned up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;lock &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLOT_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.lock&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;lock_pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'1p'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="c"&gt;# ...&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lock_pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;continue
  fi
  &lt;/span&gt;&lt;span class="nv"&gt;running&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;running &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the running count is below the limit, it writes a &lt;code&gt;.lock&lt;/code&gt; file under its own label name, releases the guard, and runs the command. If the count is at or above the limit, it either skips or waits.&lt;/p&gt;

&lt;p&gt;The advantage of this approach is that &lt;strong&gt;it self-heals even when a process dies abnormally&lt;/strong&gt;. The lock file of a crashed process gets cleaned up by whoever comes next. Files written to &lt;code&gt;/tmp/&lt;/code&gt; disappear on reboot. And unlike &lt;code&gt;flock&lt;/code&gt;, there's no kernel-side FD management involved. For something a shell script has to handle, this "create a file, and clean it up if it dies" approach is the most robust.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't Leave Timeouts to the Shell
&lt;/h3&gt;

&lt;p&gt;The timeout for the executed command is controlled by a Perl subprocess rather than the &lt;code&gt;timeout&lt;/code&gt; command (lines 277–327). The reason is that the &lt;code&gt;timeout&lt;/code&gt; command can kill only the direct child while leaving grandchildren behind.&lt;/p&gt;

&lt;p&gt;Perl's &lt;code&gt;descendants()&lt;/code&gt; function builds a PID-to-PPID table for all processes via &lt;code&gt;/bin/ps -axo pid=,ppid=&lt;/code&gt;, then recursively collects descendants from the root. On timeout it kills every descendant in the order &lt;code&gt;SIGTERM&lt;/code&gt; → wait 1 second → &lt;code&gt;SIGKILL&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nv"&gt;$SIG&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;ALRM&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;sub &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$timed_out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;stop_tree&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nb"&gt;alarm&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code on timeout is &lt;code&gt;exit 124&lt;/code&gt;. The script side detects this and records it in the log as &lt;code&gt;RESULT="timeout:${TIMEOUT_SEC}s"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;com.lily.autolike.ig-1.plist&lt;/code&gt; passes &lt;code&gt;AUTOLIKE_TIMEOUT_SEC=3700&lt;/code&gt; (about 61 minutes) as an environment variable, but the &lt;code&gt;TIMEOUT_SEC&lt;/code&gt; default in &lt;code&gt;browser-slot.sh&lt;/code&gt; is 900 seconds (15 minutes). Unless each job overrides &lt;code&gt;BROWSER_SLOT_TIMEOUT_SEC&lt;/code&gt;, it gets killed at 15 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Overall Flow
&lt;/h2&gt;

&lt;p&gt;Here's a diagram of how a single auto-like job "borrows one Chrome slot, does its work, and returns it."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;launchd
  com.lily.autolike.ig-1  ←── 0:36 / 2:36 / ... / 22:36（12回/日）
         │
         │ /bin/bash ~/dev/social-autolike/scripts/run-account.sh ig-1
         ▼
   run-account.sh ig-1
         │
         │ browser-slot.sh ig-1 --group engage --group-max N -- &amp;lt;chrome-cmd&amp;gt;
         ▼
   browser-slot.sh
         │
         ├─① acquire_guard()
         │     mkdir ~/.cache/lily-browser-slots/.guard  ← アトミック排他
         │
         ├─② *.lock を走査して running を数える
         │     死んだPIDのロックは掃除
         │
         ├─③ 実効上限を決める
         │     reserved group (post)?
         │       YES → effective_max = SLOT_MAX          (= 6)
         │       NO  → effective_max = SLOT_MAX - SLOT_RESERVE_COUNT  (= 5)
         │
         ├─④ running &amp;gt;= effective_max ?
         │     YES + WAIT_SEC &amp;gt; 0 → 15〜45秒ランダム待機してリトライ
         │     YES + 待機限界    → SKIP:global-limit をログに書いて exit 0
         │     NO               → .lock ファイルを書いてガード解放
         │
         ├─⑤ Perl supervisor で &amp;lt;chrome-cmd&amp;gt; を起動
         │     alarm(TIMEOUT_SEC=900)  ← SIGALRM でタイムアウト
         │     子孫PIDを再帰収集して SIGTERM→SIGKILL
         │
         └─⑥ cleanup()
               .lock 削除
               slot.log へ result=exit:0 / timeout:900s 等を記録
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slot acquisition happens only inside &lt;code&gt;acquire_guard&lt;/code&gt;. Even with multiple jobs running at once, only one process holds the guard at a time. Because counting the running total and writing the &lt;code&gt;.lock&lt;/code&gt; file both complete inside the guard, you never get the race where "I counted and was under the limit, but another process passed through at the same time and pushed us over."&lt;/p&gt;

&lt;h3&gt;
  
  
  File Layout and Key Variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SLOT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_DIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.cache/lily-browser-slots&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;SLOT_MAX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_MAX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;6&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;SLOT_RESERVED_GROUPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_RESERVED_GROUPS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;post&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;SLOT_RESERVE_COUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_RESERVE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;TIMEOUT_SEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_TIMEOUT_SEC&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;900&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;WAIT_SEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_WAIT_SEC&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;600&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;SLOT_LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_LOG&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.cache/lily-browser-slots/slot.log&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every variable is designed to be overridable via environment variables. Change &lt;code&gt;BROWSER_SLOT_MAX&lt;/code&gt; and you change the cap without touching the script at all. It's the same mechanism by which &lt;code&gt;com.lily.autolike.ig-1.plist&lt;/code&gt; passes &lt;code&gt;AUTOLIKE_TIMEOUT_SEC=3700&lt;/code&gt; — each job can have its own behavior through launchd's &lt;code&gt;EnvironmentVariables&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The slot label&lt;/strong&gt; is the first command argument (e.g. &lt;code&gt;ig-1&lt;/code&gt;). It becomes the filename &lt;code&gt;ig-1.lock&lt;/code&gt; directly, and a duplicate launch under the same label is prevented with &lt;code&gt;SKIP: slot already held&lt;/code&gt;. launchd starts jobs via &lt;code&gt;StartCalendarInterval&lt;/code&gt;, but even if the previous run is still alive past 60 minutes, a second one won't run at the next start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Group limits&lt;/strong&gt; are the higher-level control. Pass &lt;code&gt;--group engage --group-max 3&lt;/code&gt; and you can layer on a "the engage group gets at most 3" restriction separate from the global cap. Put likes, follows, and note engagement in the same group and you prevent the state where slots are free but too many engage jobs run in parallel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs&lt;/strong&gt; are appended to &lt;code&gt;slot.log&lt;/code&gt; in a one-line format.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-08-15T07:42:11+0900 label=xpilot.autopost group=post result=exit:0
2026-08-15T07:43:05+0900 label=ig-1 group=engage result=skip:global-limit:6/5:waited=600s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This format is the raw material for aggregation. Count the &lt;code&gt;result=skip:global-limit&lt;/code&gt; lines and you know that day's slot contention. If &lt;code&gt;result=timeout:900s&lt;/code&gt; starts increasing, that's a sign something is jammed.&lt;/p&gt;

&lt;h3&gt;
  
  
  How It's Actually Called
&lt;/h3&gt;

&lt;p&gt;When launchd's plist starts &lt;code&gt;run-account.sh ig-1&lt;/code&gt;, that script calls &lt;code&gt;browser-slot.sh&lt;/code&gt; internally. The call looks like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;browser-slot.sh &lt;span class="s2"&gt;"ig-1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group&lt;/span&gt; engage &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-max&lt;/span&gt; 3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--&lt;/span&gt; python3 ~/dev/social-autolike/src/like.py ig-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ig-1&lt;/code&gt; is the slot label. It fits within the &lt;code&gt;engage&lt;/code&gt; group's 3-slot cap, and if it also hits the global cap (effectively 5 slots for engage), it waits. The maximum wait is &lt;code&gt;WAIT_SEC=600&lt;/code&gt; (10 minutes), retrying at random intervals of 15–45 seconds during that window. The randomization exists to avoid re-collision when multiple jobs time out simultaneously and start retrying simultaneously.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;retry_delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;15&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; RANDOM &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;31&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no slot frees up after 600 seconds, it skips and exits 0. It's exit 0 so that launchd doesn't record it as an error. A skip isn't a "failure" — it's "we passed this time," and it gets naturally retried at the next start (two hours later).&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Crux of &lt;code&gt;acquire_slot()&lt;/code&gt;: The Effective-Cap Branch
&lt;/h3&gt;

&lt;p&gt;The heart of &lt;code&gt;acquire_slot()&lt;/code&gt; is that it doesn't use the global cap as-is. The most important branch in the code is lines 177–188.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 予約グループはSLOT_MAXまで使える。それ以外は予約分を差し引いた実効上限で止める。&lt;/span&gt;
&lt;span class="nv"&gt;effective_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLOT_MAX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; is_reserved_group &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;effective_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;SLOT_MAX &lt;span class="o"&gt;-&lt;/span&gt; SLOT_RESERVE_COUNT&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$effective_max&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;effective_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$running&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$effective_max&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;SLOT_BLOCK_MESSAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"SKIP: global limit reached (&lt;/span&gt;&lt;span class="nv"&gt;$running&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$effective_max&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
  &lt;span class="nv"&gt;SLOT_BLOCK_RESULT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"skip:global-limit:&lt;/span&gt;&lt;span class="nv"&gt;$running&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$effective_max&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;SLOT_BLOCK_WAITABLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
  release_guard
  &lt;span class="k"&gt;return &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;is_reserved_group()&lt;/code&gt; scans the whitespace-separated list in &lt;code&gt;SLOT_RESERVED_GROUPS&lt;/code&gt; (default &lt;code&gt;"post"&lt;/code&gt;) and checks whether the given group name is in it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;is_reserved_group&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;_needle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$_needle&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;for &lt;/span&gt;_g &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$SLOT_RESERVED_GROUPS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$_g&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$_needle&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
  &lt;span class="k"&gt;done
  return &lt;/span&gt;1
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unquoted &lt;code&gt;$SLOT_RESERVED_GROUPS&lt;/code&gt; is expanded directly by &lt;code&gt;for&lt;/code&gt;. This is a deliberate use of shell word splitting: if you want to list multiple groups whitespace-separated like &lt;code&gt;"post engage"&lt;/code&gt;, you just add them to the env variable.&lt;/p&gt;

&lt;p&gt;The effective-cap computation is simple. With &lt;code&gt;SLOT_MAX=6&lt;/code&gt; and &lt;code&gt;SLOT_RESERVE_COUNT=1&lt;/code&gt;, the engage group's cap becomes &lt;code&gt;6 - 1 = 5&lt;/code&gt;. Only the post group can use all 6. That one-slot difference creates the guarantee that "even with 5 engage jobs running, a post always gets in."&lt;/p&gt;

&lt;p&gt;The guard &lt;code&gt;[ "$effective_max" -lt 1 ] &amp;amp;&amp;amp; effective_max=1&lt;/code&gt; matters too. It prevents the accident where setting &lt;code&gt;SLOT_RESERVE_COUNT&lt;/code&gt; to a value at or above &lt;code&gt;SLOT_MAX&lt;/code&gt; drives the effective cap to zero or below and stops every job.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Lock File's Three Lines Mean
&lt;/h3&gt;

&lt;p&gt;When a slot is acquired, three lines are written to the lock file named after the label (line 198).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n%s\n%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line 1 is the PID, line 2 is the group name, line 3 is UNIX time (epoch seconds).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PID on line 1&lt;/strong&gt; plays a dual role. One is the liveness check. When scanning lock files, &lt;code&gt;kill -0 "$lock_pid" 2&amp;gt;/dev/null&lt;/code&gt; confirms whether the process is alive. If it's dead, the lock file is deleted and the scan moves on. The other is self-verification at cleanup time. When &lt;code&gt;cleanup()&lt;/code&gt; runs, it checks that the PID read with &lt;code&gt;sed -n '1p'&lt;/code&gt; matches its own &lt;code&gt;$$&lt;/code&gt; before deleting the file. This prevents the accident of mistakenly removing another process's lock file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The group name on line 2&lt;/strong&gt; is for group-limit counting. During the scan, &lt;code&gt;lock_group="$(sed -n '2p' "$lock")"&lt;/code&gt; reads it out, and if it matches your own group, &lt;code&gt;group_running&lt;/code&gt; is incremented. Because the lock file itself carries the group information, there's no need to query the running processes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The epoch seconds on line 3&lt;/strong&gt; are for debugging. When the logs alone don't tell you "when did this slot start," you can &lt;code&gt;cat&lt;/code&gt; the lock file directly and see the start time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why "Random" Works in the Wait Loop
&lt;/h3&gt;

&lt;p&gt;Retries after a failed slot acquisition use a random wait rather than a fixed interval (line 269).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;retry_delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;15&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; RANDOM &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;31&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A random wait of 15–45 seconds.&lt;/p&gt;

&lt;p&gt;Why not fixed? Suppose five engage jobs end up waiting for a slot at the same time. If they all retry on a fixed 20 seconds, then 20 seconds later all five call &lt;code&gt;acquire_guard()&lt;/code&gt; simultaneously. Only one can take the guard with &lt;code&gt;mkdir&lt;/code&gt;; the other four are rejected and wait another 20 seconds. Repeat that and you get a periodic scramble over the guard, and throughput drops. Randomizing spreads out the retry timing, producing a flow where slots fill up in sequence. It's the standard technique for avoiding the simultaneous-retry collision known as the "thundering herd."&lt;/p&gt;

&lt;p&gt;There's also a comparison against the remaining wait time (lines 271–273).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;remaining&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;WAIT_SEC &lt;span class="o"&gt;-&lt;/span&gt; waited&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$retry_delay&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$remaining&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;retry_delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$remaining&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents a "retry in 30 seconds" setting when only 5 seconds remain on the wait timeout. It's designed to keep making meaningful attempts right up to the edge of the remaining time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Perl Wipes Out the Whole Process Tree
&lt;/h3&gt;

&lt;p&gt;The reason timeout handling isn't left to the shell's &lt;code&gt;timeout&lt;/code&gt; command comes down to how grandchild processes are handled.&lt;/p&gt;

&lt;p&gt;The Perl supervisor's &lt;code&gt;descendants()&lt;/code&gt; function (lines 287–303) builds a system-wide PID-to-PPID table via &lt;code&gt;/bin/ps -axo pid=,ppid=&lt;/code&gt; and enumerates all descendants of the given PID with a BFS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;sub &lt;/span&gt;&lt;span class="nf"&gt;descendants&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;@_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;%children&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;open&lt;/span&gt; &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$ps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-|&lt;/span&gt;&lt;span class="p"&gt;",&lt;/span&gt; &lt;span class="p"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/bin/ps&lt;/span&gt;&lt;span class="p"&gt;",&lt;/span&gt; &lt;span class="p"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-axo&lt;/span&gt;&lt;span class="p"&gt;",&lt;/span&gt; &lt;span class="p"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pid=,ppid=&lt;/span&gt;&lt;span class="p"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;$ps&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt; &lt;span class="o"&gt;=~&lt;/span&gt; &lt;span class="sr"&gt;/^\s*(\d+)\s+(\d+)\s*$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;push&lt;/span&gt; &lt;span class="nv"&gt;@&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$children&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;close&lt;/span&gt; &lt;span class="nv"&gt;$ps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;@queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;@found&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;@queue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;shift&lt;/span&gt; &lt;span class="nv"&gt;@queue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$pid&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;@&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$children&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$parent&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[]&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;push&lt;/span&gt; &lt;span class="nv"&gt;@found&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nb"&gt;push&lt;/span&gt; &lt;span class="nv"&gt;@queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;@found&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On timeout, &lt;code&gt;stop_tree()&lt;/code&gt; sends &lt;code&gt;SIGTERM&lt;/code&gt; to the descendants in reverse order (child → parent), waits 1 second, then sends &lt;code&gt;SIGKILL&lt;/code&gt; to any process still alive (lines 306–312).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;sub &lt;/span&gt;&lt;span class="nf"&gt;stop_tree&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;$stopping&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;@pids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;descendants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$child&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="p"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TERM&lt;/span&gt;&lt;span class="p"&gt;",&lt;/span&gt; &lt;span class="nb"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;@pids&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$child&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;select&lt;/span&gt; &lt;span class="nb"&gt;undef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;undef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;undef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="p"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KILL&lt;/span&gt;&lt;span class="p"&gt;",&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="vg"&gt;$_&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nb"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;@pids&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$child&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;return if $stopping++&lt;/code&gt; ensures idempotency. Even if SIGALRM and SIGTERM arrive at the same time and &lt;code&gt;stop_tree()&lt;/code&gt; gets called twice, only the first call executes.&lt;/p&gt;

&lt;p&gt;Chrome spawns many child processes — renderer, GPU, network service, and so on. Because the shell's &lt;code&gt;timeout&lt;/code&gt; command only signals the direct child, grandchildren and below survive. Those leftover Chrome processes created a state where "Chrome is already running" at the next launch, causing a problem where the login session couldn't be obtained. Since switching to Perl killing all descendants together, this zombie-Chrome problem has stopped appearing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't Let launchd See a Skip as an Error
&lt;/h3&gt;

&lt;p&gt;When a slot can't be acquired, the script ends with &lt;code&gt;exit 0&lt;/code&gt; (lines 257–260).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLOT_BLOCK_WAITABLE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WAIT_SEC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLOT_BLOCK_MESSAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;RESULT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLOT_BLOCK_RESULT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;com.lily.autolike.ig-1.plist&lt;/code&gt; uses &lt;code&gt;StartCalendarInterval&lt;/code&gt; for fixed-time launches (0:36, 2:36 … 22:36). launchd treats a non-zero exit code as a "failed job" and in some cases imposes a &lt;code&gt;ThrottleInterval&lt;/code&gt; penalty.&lt;/p&gt;

&lt;p&gt;A skip is "passing this time," not a failure. It gets naturally retried at the next start two hours later. The log records &lt;code&gt;result=skip:global-limit&lt;/code&gt;, so a human can tell the difference. To launchd, it looks like a normal exit. Reconciling both is the reason for &lt;code&gt;exit 0&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Miscalculation 1: "Chrome Is Heavy" Was Just an Assumption
&lt;/h3&gt;

&lt;p&gt;When I first built the slot management, I set the cap to 3. The basis was a feeling. Chrome sure looks heavy, so let's hold it to less than half — that was the judgment.&lt;/p&gt;

&lt;p&gt;The symptoms started quietly. It felt like the number of auto-likes processed was dropping. Aggregating the logs showed 92 &lt;code&gt;result=skip:global-limit&lt;/code&gt; entries a day: about 30% of all runs finished without even acquiring a slot.&lt;/p&gt;

&lt;p&gt;But I had a confirmation bias — "even if it's skipped, the next run should cover it" — and left it alone for a while. It'll try again in two hours, so we're fine.&lt;/p&gt;

&lt;p&gt;The confirmation bias collapsed when I actually measured with &lt;code&gt;vm_stat&lt;/code&gt; and &lt;code&gt;top&lt;/code&gt;. The result is preserved verbatim in the script's comments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 実測(2026-08-09): Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd(47GB)/&lt;/span&gt;
&lt;span class="c"&gt;# ComfyUI(12GB)/iii(3.5GB)であってブラウザジョブではなかった。上限3は過剰に厳しく&lt;/span&gt;
&lt;span class="c"&gt;# 1日で92回のskip(全体の30%)を出していたので5に緩める。&lt;/span&gt;
&lt;span class="nv"&gt;SLOT_MAX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_MAX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;6&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Against 0.7GB total for nine Chrome-family jobs, &lt;code&gt;dasd&lt;/code&gt; (Apple's log daemon) was using 47GB and ComfyUI (an image-generation AI server) 12GB. Chrome's 0.7GB was within the margin of error. The hypothesis that Chrome was the culprit was completely wrong.&lt;/p&gt;

&lt;p&gt;The comment says "relax it to 5" while the variable's default is &lt;code&gt;6&lt;/code&gt;. That's the fingerprint of measuring, thinking "I figured 5 would do, but 6 is fine," changing my mind midway, fixing only the variable, and leaving the comment half-written. When you make a fix you're not confident about, you hesitate while writing the comment. Write "5," then reconsider with "6 would be fine too," change only the variable — and this gap is born.&lt;/p&gt;

&lt;p&gt;The core of the fix was in the variable design. Because I'd built &lt;code&gt;BROWSER_SLOT_MAX&lt;/code&gt; to be overridable via an environment variable, I could change the cap without touching the script at all. Just add one line, &lt;code&gt;BROWSER_SLOT_MAX=5&lt;/code&gt;, to launchd's plist. If I'd hardcoded the number inside the script, every change would have meant editing and redeploying the file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Miscalculation 2: I Raised the Cap Back to 6, and the Post Lane Jammed 9 Times
&lt;/h3&gt;

&lt;p&gt;Six days after relaxing the cap, on 2026-08-15, a new problem appeared.&lt;/p&gt;

&lt;p&gt;Checking the morning logs, I found 9 consecutive &lt;code&gt;result=skip:global-limit:6/5&lt;/code&gt; entries for the post job (&lt;code&gt;xpilot.autopost&lt;/code&gt;). The slot cap was supposed to be 6, but the record said it jammed at "6/5" — at first I thought it was an aggregation bug.&lt;/p&gt;

&lt;p&gt;The record is preserved verbatim in the script's comments (lines 9–14).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 2026-08-15: 朝の7本同時timeoutで枠6が死んだrunに占有され、投稿レーン(xpilot.autopost&lt;/span&gt;
&lt;span class="c"&gt;# 等)が global-limit で9回skipした。いいね/フォローは1回落ちても翌回で取り返せるが、&lt;/span&gt;
&lt;span class="c"&gt;# 投稿はその時間帯の枠が消えると二度と埋まらない。そこで投稿用に枠を予約し、&lt;/span&gt;
&lt;span class="c"&gt;# engage系(いいね/フォロー/note等)はSLOT_MAXより1つ少ない実効上限で動かす。&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the cause. Seven engage-group jobs started simultaneously in the morning window, and each process jammed at &lt;code&gt;TIMEOUT_SEC=900&lt;/code&gt; (15 minutes). For those 15 minutes until timeout, the slots were legitimately occupied. With all 6 slots full, the post job tried to enter, but &lt;code&gt;running=6&lt;/code&gt; is at or above &lt;code&gt;effective_max=6&lt;/code&gt;, so it was rejected. This repeated 9 times.&lt;/p&gt;

&lt;p&gt;The core of the problem is this: "a like can be recovered on the next run, but once a post's window in that time slot is gone, it never gets filled."&lt;/p&gt;

&lt;p&gt;Auto-likes get 12 launch opportunities per day. One skip doesn't change the total processed count much. But a posting schedule is a time specification — "publish at 9 AM." Miss that window and the content becomes either "posted a day late" or "a missing number." Because posting in a specific time slot directly affects engagement rate, the cost of a skip is completely different from that of a like.&lt;/p&gt;

&lt;p&gt;Even with the same &lt;code&gt;skip:global-limit&lt;/code&gt; symptom, the severity differed entirely depending on job type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: I added two env variables to introduce a reserved-slot mechanism. I set up &lt;code&gt;SLOT_RESERVED_GROUPS=post&lt;/code&gt; (the reserved group name) and &lt;code&gt;SLOT_RESERVE_COUNT=1&lt;/code&gt; (the reserve count), making the engage group's &lt;code&gt;effective_max = 6 - 1 = 5&lt;/code&gt;. This guarantees "even with 5 engage jobs running, post always gets the 6th."&lt;/p&gt;

&lt;p&gt;The change to the script was minimal. I only added &lt;code&gt;is_reserved_group()&lt;/code&gt; and inserted a 4-line block into the cap check in &lt;code&gt;acquire_slot()&lt;/code&gt;. It takes effect either by having the calling shell script pass &lt;code&gt;--group post&lt;/code&gt;, or by setting &lt;code&gt;BROWSER_SLOT_RESERVED_GROUPS=post&lt;/code&gt; in the plist's &lt;code&gt;EnvironmentVariables&lt;/code&gt; block.&lt;/p&gt;

&lt;h3&gt;
  
  
  Miscalculation 3: Timeout Values Managed in Two Places
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;com.lily.autolike.ig-1.plist&lt;/code&gt; has the following environment variable set.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;AUTOLIKE_TIMEOUT_SEC&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;3700&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AUTOLIKE_TIMEOUT_SEC=3700&lt;/code&gt; (about 61 minutes). Meanwhile, the variable &lt;code&gt;browser-slot.sh&lt;/code&gt; uses for the Perl supervisor's timeout is &lt;code&gt;BROWSER_SLOT_TIMEOUT_SEC&lt;/code&gt;, defaulting to 900 seconds (15 minutes). The variable names differ.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AUTOLIKE_TIMEOUT_SEC&lt;/code&gt; is the variable &lt;code&gt;run-account.sh&lt;/code&gt; reads, controlling the processing timeout on the Python script side. &lt;code&gt;TIMEOUT_SEC&lt;/code&gt; in &lt;code&gt;browser-slot.sh&lt;/code&gt; is a separate-layer timeout for when the Perl supervisor force-terminates.&lt;/p&gt;

&lt;p&gt;This is where I got stuck. The Python script is built on the premise that it "can take up to 61 minutes," but if the Perl supervisor is left set to "force-terminate at 15 minutes," anything that doesn't finish within that window times out. Conversely, if you forget to set &lt;code&gt;BROWSER_SLOT_TIMEOUT_SEC&lt;/code&gt; and run at the 900-second default, account operations that would have completed get killed at 15 minutes. This misconfiguration was hard to notice because an exit-124 log only records "timeout" — it doesn't tell you "the timeout cap is too small."&lt;/p&gt;

&lt;p&gt;The plist's &lt;code&gt;Nice: 10&lt;/code&gt; and &lt;code&gt;LowPriorityIO: true&lt;/code&gt; settings are also involved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProcessType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Background&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nice value of 10 lowers CPU scheduling priority, and launchd aggressively defers this job's work when the system is under load. It's the right setting for system stability, but it has the side effect that "low priority makes processing slower, which in turn makes it easier to hit the 900-second timeout." It's one of the reasons you need to set a timeout value with headroom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: I added one line inside &lt;code&gt;run-account.sh&lt;/code&gt; that reads &lt;code&gt;AUTOLIKE_TIMEOUT_SEC&lt;/code&gt; and passes it to &lt;code&gt;browser-slot.sh&lt;/code&gt; as &lt;code&gt;BROWSER_SLOT_TIMEOUT_SEC&lt;/code&gt;. Having "the timeout Python uses" and "the timeout the Perl supervisor uses" be separate variables is unavoidable, but writing the handoff from the former to the latter explicitly in code eliminated the state of "I configured it but it isn't taking effect." Env variables in general — not just timeouts — make it hard to trace the route of where they're passed and where they're used. Checking once with &lt;code&gt;printenv&lt;/code&gt; which process a value written in launchd's plist actually reaches is the reliable move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;Miscalculations 1–3 (the Chrome misdiagnosis, the post-lane jam, the duplicated timeout management) were covered in detail above. Here I'll list the other, finer-grained traps I actually stepped on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gap between comment and variable value is the fingerprint of mid-change hesitation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The comment at lines 5–8 of the script says "relax it to 5," but the actual variable is &lt;code&gt;SLOT_MAX="${BROWSER_SLOT_MAX:-6}"&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 実測(2026-08-09): Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd(47GB)/&lt;/span&gt;
&lt;span class="c"&gt;# ComfyUI(12GB)/iii(3.5GB)であってブラウザジョブではなかった。上限3は過剰に厳しく&lt;/span&gt;
&lt;span class="c"&gt;# 1日で92回のskip(全体の30%)を出していたので5に緩める。&lt;/span&gt;
&lt;span class="nv"&gt;SLOT_MAX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_MAX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;6&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a bug — it's the fingerprint of "wrote 5, reconsidered that 6 would be fine, changed only the variable, and left the comment half-written." The smaller the one-line change, the more likely you forget to update the comment. Reading this comment myself later, I spent 5 minutes debugging "it should be 5 but it's 6 — is this a bug?" You need the habit of fixing the comment first when you change a variable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I underestimated the side effects of &lt;code&gt;Nice: 10&lt;/code&gt; and &lt;code&gt;LowPriorityIO: true&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The plist has these three set together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProcessType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Background&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the correct setting for a design that doesn't pressure the system, but it has side effects. When high-load processes like dasd or ComfyUI are running, macOS aggressively defers nice-10 jobs. You get a state where "work that normally finishes in 5–8 minutes takes 12–14 minutes," and the headroom on &lt;code&gt;TIMEOUT_SEC=900&lt;/code&gt; (15 minutes) effectively shrinks to 1–3 minutes. Timeout values need headroom that factors in nice-induced delay. This is often the cause behind the symptom "the settings are right but timeouts keep happening."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's intent behind the zombie-guard cleanup timing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;acquire_guard()&lt;/code&gt;, when the guard directory exists but the contents of the PID file are invalid, cleanup waits until &lt;code&gt;attempt&lt;/code&gt; reaches 20 or more (script lines 110–126).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$guard_pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;[!&lt;/span&gt;0-9]&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 20 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GUARD_DIR&lt;/span&gt;&lt;span class="s2"&gt;/pid"&lt;/span&gt;
      &lt;span class="nb"&gt;rmdir&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GUARD_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
    &lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason it doesn't clean up immediately from &lt;code&gt;attempt=0&lt;/code&gt; is to protect "the tiny window between acquiring the guard with &lt;code&gt;mkdir&lt;/code&gt; and writing with &lt;code&gt;printf '%s\n' "$$" &amp;gt; "$GUARD_DIR/pid"&lt;/code&gt;." If another process peeks during that gap, the PID file looks empty or nonexistent. It waits about 1 second (0.05s × 20) before deciding it's "genuinely an orphaned zombie guard." Not knowing this intent, I started debugging "why can't I take the guard immediately?" and burned 30 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Specifying only one of &lt;code&gt;--group&lt;/code&gt; and &lt;code&gt;--group-max&lt;/code&gt; exits 1&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The argument-parsing section of the script (lines 233–239) has the following interdependency check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP_MAX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--group requires --group-max"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP_MAX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--group-max requires --group"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write only &lt;code&gt;--group engage&lt;/code&gt; and forget &lt;code&gt;--group-max 3&lt;/code&gt;, and the script dies with exit 1. launchd records this as an error exit and may apply a &lt;code&gt;ThrottleInterval&lt;/code&gt; penalty. The error message itself is clear, but you won't notice until you check &lt;code&gt;~/dev/social-autolike/logs/ig-1.launchd.log&lt;/code&gt;. Setting only &lt;code&gt;BROWSER_SLOT_GROUP&lt;/code&gt; in the plist's &lt;code&gt;EnvironmentVariables&lt;/code&gt; and forgetting &lt;code&gt;BROWSER_SLOT_GROUP_MAX&lt;/code&gt; produces the same symptom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SLOT_RESERVE_COUNT &amp;gt;= SLOT_MAX&lt;/code&gt; makes the effective cap 1&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the computation &lt;code&gt;effective_max = SLOT_MAX - SLOT_RESERVE_COUNT&lt;/code&gt;, mistakenly setting &lt;code&gt;SLOT_RESERVE_COUNT&lt;/code&gt; at or above &lt;code&gt;SLOT_MAX&lt;/code&gt; drives the effective cap to zero or below. The script has the fallback &lt;code&gt;[ "$effective_max" -lt 1 ] &amp;amp;&amp;amp; effective_max=1&lt;/code&gt;, so it doesn't stop completely, but you end up in a state where "the entire engage group is limited to 1 slot." Skips spike, but since it returns exit 0 to launchd, you won't find out unless you aggregate &lt;code&gt;slot.log&lt;/code&gt;. Always set &lt;code&gt;SLOT_RESERVE_COUNT&lt;/code&gt; to a value smaller than &lt;code&gt;SLOT_MAX&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The character set usable in slot labels is limited&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the script's argument validation (lines 207–209), a label containing anything other than alphanumerics, dots, underscores, colons, and hyphens dies with exit 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLOT_LABEL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;[!&lt;/span&gt;A-Za-z0-9._:-]&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"invalid slot label: &lt;/span&gt;&lt;span class="nv"&gt;$SLOT_LABEL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the lock filename becomes &lt;code&gt;$SLOT_LABEL.lock&lt;/code&gt;, the design rejects characters that would cause filesystem trouble. I got stuck trying to use an account identifier containing an at sign, like &lt;code&gt;@ig_account&lt;/code&gt;, directly as a label. You need to convert it to a simple identifier like &lt;code&gt;ig-1&lt;/code&gt; before passing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The morning "7 simultaneous timeouts" is hard to avoid by plist design&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Looking at &lt;code&gt;StartCalendarInterval&lt;/code&gt; in &lt;code&gt;com.lily.autolike.ig-1.plist&lt;/code&gt;, this job launches daily at 6:36 (Hour=6, Minute=36). If multiple engage jobs are set to the same &lt;code&gt;Minute=36&lt;/code&gt;, launchd tries to start them simultaneously. launchd does not guarantee start order.&lt;/p&gt;

&lt;p&gt;If 7 engage-family jobs all start at 6:36 and each holds its slot until reaching the &lt;code&gt;TIMEOUT_SEC=900&lt;/code&gt; limit, then for the 75 minutes from 6:36 to 7:51, all 6 slots are occupied by processes that are "running but effectively doing nothing." You could also solve this with a design change that staggers start times by 2–5 minutes per job, but against the cost of rewriting every plist, the reserved slot — which takes just two env variables (&lt;code&gt;BROWSER_SLOT_RESERVED_GROUPS=post&lt;/code&gt; and &lt;code&gt;BROWSER_SLOT_RESERVE=1&lt;/code&gt;) — was the cheaper fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;exit-0 skips are invisible without log aggregation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A skip from failing to get a slot exits 0. Nothing is left in launchd's error log. Unless you aggregate &lt;code&gt;slot.log&lt;/code&gt;, you have no idea "how many were skipped today."&lt;/p&gt;

&lt;p&gt;For the first week, a 30% skip rate was happening and I thought "no errors in launchd, so it's fine." I later added a cron that runs &lt;code&gt;grep 'result=skip' ~/.cache/lily-browser-slots/slot.log | wc -l&lt;/code&gt; every morning, but I should have set that up from day one of the slot management. Automation without monitoring keeps you believing "it's not broken" while it's broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;Here are guidelines, distilled from the implementation and the failures, for building a semaphore mechanism of this kind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Make every variable overridable via environment variables&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Write things in the form &lt;code&gt;SLOT_MAX="${BROWSER_SLOT_MAX:-6}"&lt;/code&gt; and you can change behavior from launchd's &lt;code&gt;EnvironmentVariables&lt;/code&gt; block without touching the script at all. No more editing and re-copying the file every time you change the cap. You can flexibly handle requirements like "I want the cap to be 3 only in this environment" or "I want a longer timeout for this one job." With the four variables &lt;code&gt;BROWSER_SLOT_MAX&lt;/code&gt;, &lt;code&gt;BROWSER_SLOT_TIMEOUT_SEC&lt;/code&gt;, &lt;code&gt;BROWSER_SLOT_RESERVED_GROUPS&lt;/code&gt;, and &lt;code&gt;BROWSER_SLOT_RESERVE&lt;/code&gt; in place, per-environment tuning is complete with zero changes to the script body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Skips should exit 0 so launchd doesn't see an error&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"We passed this time" is not a job failure. launchd records a non-zero exit code as a failure and may apply a &lt;code&gt;ThrottleInterval&lt;/code&gt; penalty. Design skips to exit 0 and leave the human-facing record in &lt;code&gt;slot.log&lt;/code&gt;. You need two layers of recording: "normal exit for launchd" and "details in slot.log."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Write the measurement date and concrete numbers in comments&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 実測(2026-08-09): Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd(47GB)/ComfyUI(12GB)&lt;/span&gt;
&lt;span class="nv"&gt;SLOT_MAX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_MAX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;6&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write the measured values and the date next to the variable and you can trace "why this number" later. The comment stands in for a change log, and it also creates motivation to update it at the next measurement. A "number decided by feel" has no update criterion, but a "number decided by measurement" automatically comes with the condition "measure again next time and change it if it exceeds this value."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Separate timeout layers explicitly by variable name&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The application-side timeout (&lt;code&gt;AUTOLIKE_TIMEOUT_SEC&lt;/code&gt;) and the Perl supervisor's timeout (&lt;code&gt;BROWSER_SLOT_TIMEOUT_SEC&lt;/code&gt;) serve different roles. You need to write code in &lt;code&gt;run-account.sh&lt;/code&gt; that explicitly connects the two.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BROWSER_SLOT_TIMEOUT_SEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AUTOLIKE_TIMEOUT_SEC&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;900&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this one line, you stay in the state "I set 3700 in the plist but Perl kills at 900 seconds." For variable handoffs, checking once with &lt;code&gt;printenv&lt;/code&gt; "which process does the value I wrote here actually reach" is the reliable move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Build a two-tier structure of group limits and global limits&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The global cap (&lt;code&gt;SLOT_MAX=6&lt;/code&gt;) alone gives you coarse control. Pass &lt;code&gt;--group engage --group-max 3&lt;/code&gt; and you get fine-grained control: "engage gets at most 3 even when under the global cap." Effective when you want to prevent jobs of the same kind from over-paralleling and hitting rate limits. Because group limits are evaluated independently of the global limit, you can design it so that in a state of "2 engage, 1 note, 3 total, with global cap 6 to spare," only the individual group limit fires first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Add reserved slots only when jobs of differing weight are mixed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SLOT_RESERVED_GROUPS=post&lt;/code&gt; and &lt;code&gt;SLOT_RESERVE_COUNT=1&lt;/code&gt; reserve one slot exclusively for the post group. Treat "a like that can be recovered the next day" and "a post that can never be filled once its time slot is gone" as equals, and posts get skipped repeatedly. If job weights are uniform, adding reserved slots only complicates the configuration. Introduce it only "when jobs that must not be skipped are mixed with jobs that can be covered on the next run."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Randomize wait intervals to avoid the thundering herd&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;retry_delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;15&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; RANDOM &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;31&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retry on a fixed interval and, when multiple jobs are waiting simultaneously, they all fight over the guard at the same moment. Only one process can take the guard with &lt;code&gt;mkdir&lt;/code&gt;, so the rest are rejected and retry again after the same fixed interval. This collision repeats periodically and throughput drops. Randomizing to 15–45 seconds spreads out retry timing and produces a flow where slots fill up in sequence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Always include a comparison against the remaining wait time&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;remaining&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;WAIT_SEC &lt;span class="o"&gt;-&lt;/span&gt; waited&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$retry_delay&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$remaining&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;retry_delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$remaining&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents a "retry in 30 seconds" setting when only 5 seconds remain on the wait timeout. Without this comparison, you miss opportunities where "one more attempt was worth it" while the remaining time runs out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Killing an entire process tree requires Perl or Python&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The shell's &lt;code&gt;timeout&lt;/code&gt; command only signals the direct child process. Chrome spawns many descendant processes, such as renderer and GPU processes. Kill only the parent with &lt;code&gt;timeout&lt;/code&gt; and grandchildren and below survive, perpetuating the state "Chrome is already running at the next launch." You need an implementation that, like Perl's &lt;code&gt;descendants()&lt;/code&gt; function, builds a PID-to-PPID table with &lt;code&gt;/bin/ps -axo pid=,ppid=&lt;/code&gt;, enumerates all descendants with a BFS, and sends SIGTERM → SIGKILL. The reason for choosing Perl is that it ships with macOS by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Write three lines — PID, group name, epoch seconds — to the lock file&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n%s\n%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PID on line 1 lets you do liveness checks with &lt;code&gt;kill -0&lt;/code&gt;. The group name on line 2 lets you count for group limits. The epoch seconds on line 3 let you debug "when did this slot start." Just &lt;code&gt;cat&lt;/code&gt; the lock file and you know everything about that slot's state. Giving the lock file the group information removes the need to query other running processes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. Always include zombie-guard cleanup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the process holding the guard dies abnormally, the guard directory sticks around forever. The next process can't take the guard, and all slot management stops. Without logic that checks the guard's PID with &lt;code&gt;kill -0&lt;/code&gt; and cleans it up with &lt;code&gt;rmdir&lt;/code&gt; if dead, you get an implementation with zero fault tolerance where "the moment one process crashes, every job jams." Guard cleanup is the single most important part of self-healing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Set up &lt;code&gt;slot.log&lt;/code&gt; aggregation from day one of the automation&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# cronで毎朝実行&lt;/span&gt;
&lt;span class="nv"&gt;skip_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"result=skip:global-limit"&lt;/span&gt; ~/.cache/lily-browser-slots/slot.log | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"today skip:global-limit count = &lt;/span&gt;&lt;span class="nv"&gt;$skip_count&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look only at launchd's logs and everything appears "normal, all exit 0." The skip rate of slot management is only knowable by aggregating &lt;code&gt;slot.log&lt;/code&gt;. To prevent the situation "92 skips a day were happening and I didn't notice for a week," automate the aggregation from the start. A skip rate above 15% is a sign to revisit the cap or the reserved slots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. Stagger start times across jobs by a few minutes each&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If multiple jobs share the same &lt;code&gt;Minute&lt;/code&gt; value in &lt;code&gt;StartCalendarInterval&lt;/code&gt;, launchd starts them simultaneously. When simultaneously started jobs enter a timeout race, all 6 slots stay occupied for a long time. Just staggering each job's &lt;code&gt;Minute&lt;/code&gt; by 2–5 minutes naturally distributes the contention over slots. Combined with reserved slots, post-lane jams drop further. If you have many plist entries there's a bulk-rewrite cost, but it's worth keeping in mind every time you add a new job.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;There were three miscalculations before &lt;code&gt;browser-slot.sh&lt;/code&gt; reached its current form.&lt;/p&gt;

&lt;p&gt;The first was the hypothesis that "Chrome is heavy." It's preserved verbatim in the script's comments. A single &lt;code&gt;vm_stat&lt;/code&gt; revealed the culprits were dasd (47GB) and ComfyUI (12GB), and the 0.7GB total across 9 Chrome instances was noise. The 92 skips a day the cap of 3 was producing was a problem solvable with 5 minutes of measurement.&lt;/p&gt;

&lt;p&gt;The second was the post-lane jam after raising the cap back to 6. Because engage and post were treated as equals, simultaneous morning timeouts caused 9 consecutive post skips. "A like that can be recovered on the next run" and "a post that can't be recovered once its time slot is gone" should never have competed under the same slot cap. The reserved slot — two env variables — was the solution, and the script change was just adding &lt;code&gt;is_reserved_group()&lt;/code&gt; and inserting 4 lines into &lt;code&gt;acquire_slot()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The third was managing the timeout variable in two places. The &lt;code&gt;AUTOLIKE_TIMEOUT_SEC=3700&lt;/code&gt; written in the plist wasn't reaching the Perl supervisor's &lt;code&gt;BROWSER_SLOT_TIMEOUT_SEC&lt;/code&gt;, and it ran that way for several days. A one-line handoff in &lt;code&gt;run-account.sh&lt;/code&gt; solved it, but the symptom "I configured it but it isn't taking effect" is especially slow to discover because the logs don't honestly tell you the cause.&lt;/p&gt;

&lt;p&gt;What all three share is the pattern "configure by intuition → no measurement → quiet malfunction." Chrome's weight, slot contention, the timeout connection — every one presented as "running but not actually functioning," and none surfaced until I aggregated the logs.&lt;/p&gt;

&lt;p&gt;Automation is something that "breaks in production after the code works." The quieter the failure mode, the more measurement and log aggregation become the only diagnostic tools. Deciding up front "what to measure" and "what to aggregate" is a shorter path to automation that runs stably for a long time than the semaphore mechanism itself.&lt;/p&gt;




&lt;p&gt;The full picture of the system, the breakdown behind ¥1.2M/month, and the 30-day walkthrough are collected in a paid note.&lt;br&gt;
📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>bash</category>
      <category>launchd</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
