A layoff notice doesn't stop the Claude API invoice — that still lands next month, right on schedule. I run 14 content-production lines at the same time and hold ¥1.2M/month in revenue, and the reason isn't that I track how much I earned. It's that I built something first that shows me, automatically every Monday, which line has gone into the red. In the first report (2026-07-03) my ASMR line was producing 14 files a week and showing 🟢. Three days later, on 2026-07-06, it was at 0 files and flagged ⚠️. Without the report I would have kept believing that line was running.
Why this setup works
When you run several production lines at once, something strange happens: the busier you get, the less you look at the numbers.
The affiliate-article line puts out 28 pieces a week. The LINE-stamp line, 19 sets a week. iOS app ideas, 8 a week. The social-posting line spits out more than 120 files a week. Once you're managing that volume by hand, almost nobody can answer "how much did this line earn last month?" on the spot. And not being able to answer is the same thing as unconsciously feeding a money-losing line.
Claude API costs look small. A single prompt run is a few yen. But once 14 lines are generating hundreds of files a week between them, the monthly API bill adds up. At that point, if you can't immediately say which line isn't earning, you can't make a cost-cutting decision.
The typical response here is "start a spreadsheet." I didn't do that. Opening a spreadsheet weekly and filling it in has never once survived as a habit for me. Anything that doesn't stick is relying on willpower rather than environment. Willpower is finite.
That's the problem revenue-gate.sh solves. Every Monday at 8:20, the Mac generates a report automatically and drops it on the Desktop. You just open it. There's nothing to write. The human only makes the judgment call.
This is what building an environment rather than a task means. If the trigger for a decision arrives from outside automatically, you can decide without spending willpower. Once you've built the reflex report arrives = something changed, the weekly revenue check stops being "something to do" and becomes "something that happens."
The other design principle is a division of labor: the human decides what to kill, but the machine finds the lines that should be killed. The script never stops anything by itself. Read the code and you'll find this:
KILL推奨のジョブは「何のため/誰が嬉しい/いくら儲かる/いつ見切る」に
答えられなければ停止する(自動停止はしない。判断は人間)
(Roughly: a job flagged for KILL gets stopped if you can't answer "what is it for / who is happy / how much does it earn / when do you cut it." No automatic stopping — the human decides.)
The no-auto-stop design matters. If a machine kills a line on its own, you can't verify why it stopped, and a false positive could kill your top earner. The machine's job is to put a fact in front of you: "this line has produced plenty of files for two straight weeks with zero revenue." Everything past that is a human judgment.
In the actual first report on 2026-07-03, the ASMR line was producing 14 files a week and was 🟢 (green). In the following report on 2026-07-06, it was at 0 files a week and had turned ⚠️ (output stopped?). That change didn't happen because I deliberately stopped it — it meant the job had stopped for some reason. Without the report I would never have noticed, and I'd have spent my time on some other optimization while assuming "the ASMR line is running."
The overall flow
Here's the whole picture first.
┌─────────────────────────────────────────────────────┐
│ 毎週月曜 8:20 / 13:20 (launchd 二重発火) │
└──────────────────────┬──────────────────────────────┘
│
▼
revenue-gate.sh
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
revenue.jsonl revenue-gate.conf 各ラインの
(売上ログ) (ライン定義) 出力ディレクトリ
(ファイル数カウント)
│ │ │
└──────────────┼──────────────┘
│
▼
revenue-gate-state.json
(連続ゼロ収益週カウント)
│
▼
Markdown レポート生成
│
┌────────────┴────────────┐
▼ ▼
~/Desktop/Revenue Gate/ Obsidian vault
(即時確認用) (briefs/revenue-gate/)
In words: every Monday at 8:20 and 13:20, launchd double-fires revenue-gate.sh. It reads revenue.jsonl (the revenue log), revenue-gate.conf (the line definitions), and each line's output directory (counting files). It updates revenue-gate-state.json (the consecutive-zero-revenue week counter), generates a Markdown report, and copies it to both ~/Desktop/Revenue Gate/ (for immediate review) and the Obsidian vault (briefs/revenue-gate/).
Five kinds of file are involved. Let's go through them in order.
The line definition file (revenue-gate.conf)
~/.claude/data/revenue-gate.conf manages every production line. The format is three pipe-separated columns.
name|出力ディレクトリ|glob
(That is: name|output directory|glob.) Here's an excerpt from the real file:
article|~/Desktop/Article/articles|
note|~/Desktop/Article/note|
maker|~/Desktop/Article/solomaker|
affiliate|~/Desktop/アフィリ記事|
asmr|~/dev/asmr-factory/out|
sns-bokuwalily|~/dev/bokuwalily-sns/out|
senior-tube|~/dev/senior-tube-factory/drafts|
line-stamps|~/digital-products/line-stamps-keigo/out|
lead-outreach|~/lead-finder/runs/_logs
idea-ios|~/Desktop/iosアプリ|
idea-chrome-ext|~/Desktop/Chrome拡張|
affiliate-livedoor|~/dev/affiliate-livedoor/queue|
affiliate-blogger|~/dev/affiliate-blogger/queue|
affiliate-fc2|~/dev/affiliate-fc2/published|
Fourteen lines are defined. If the third column (the glob) is omitted, it's treated as * (all files). If you only want to count particular extensions (say *.mp3 or *.md), you specify that in the third column.
Adding a new line means adding one row to this conf. You never touch the script itself.
The revenue log (revenue.jsonl)
Revenue gets recorded in ~/.claude/data/revenue.jsonl. The format is JSON Lines (one record per line).
{"date":"2026-06-15","source":"note","amount_jpy":3200,"note":"有料記事1本"}
{"date":"2026-06-22","source":"affiliate","amount_jpy":8400,"note":"Amazon報酬"}
The source matching a name in the conf is what ties revenue to a line. Recording is done with the bundled rev.sh command.
rev.sh note 3200 有料記事1本
This is the only manual step. I run it whenever a sales notification comes in. Revenue that could be pulled automatically (ASPs with an API, etc.) could eventually be filled in via cron, but manual is plenty for now. It's a five-second task a few times a week.
The state file (revenue-gate-state.json)
This holds the "consecutive zero-revenue weeks" count.
{
"updated": "2026-07-06T08:20:05",
"week": "202628",
"zero_weeks": {
"affiliate": 2,
"article": 2,
"asmr": 2,
"idea-chrome-ext": 2,
"idea-ios": 2,
"lead-outreach": 2,
"line-stamps": 2,
"maker": 2,
"note": 2,
"senior-tube": 2,
"sns-bokuwalily": 2
}
}
As of 2026-07-06, every line sits at zero_weeks: 2. That's expected, since this is the week I started logging revenue at all. As operation continues, earning lines get reset to 0 and only the counters of non-earning lines keep climbing.
The scheduler (revenue-gate.plist)
This is the automatic-execution definition registered with macOS launchd. The distinctive part is that it fires twice on Monday.
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Weekday</key><integer>1</integer>
<key>Hour</key><integer>8</integer>
<key>Minute</key><integer>20</integer>
</dict>
<dict>
<key>Weekday</key><integer>1</integer>
<key>Hour</key><integer>13</integer>
<key>Minute</key><integer>20</integer>
</dict>
</array>
If the Mac is up at 8:20, the first slot runs. If it wasn't running (the case where you close the lid over the weekend and reach Monday), launchd's default behavior is "if the scheduled time has passed, wait until the next one" — so the 13:20 second slot acts as insurance.
The script itself has a mechanism to prevent duplicate runs within the same week.
WEEK_MARKER="$LOG_DIR/.revenue-gate-done-$(date +%G%V)"
if [ -f "$WEEK_MARKER" ] && [ "${REVENUE_GATE_FORCE:-0}" != "1" ]; then
exit 0
fi
%G%V is the ISO week number (e.g. 202628). Once a report is generated, it drops a marker file named .revenue-gate-done-202628, so a second launch in the same week exits immediately. When you want to force a re-run, you can override it with REVENUE_GATE_FORCE=1 bash ~/.claude/scripts/revenue-gate.sh.
The verdict logic (the Python inside revenue-gate.sh)
The core of the script is a Python heredoc embedded in the shell. Using Python 3 directly makes date math, file scanning, and JSON handling reliable.
def weekly_file_count(directory, glob):
root = pathlib.Path(directory)
if not root.is_dir():
return None
threshold = dt.datetime.now().timestamp() - (7 * 24 * 60 * 60)
count = 0
for current_root, _, files in os.walk(root):
for filename in files:
if not fnmatch.fnmatch(filename, glob):
continue
path = pathlib.Path(current_root) / filename
try:
if path.stat().st_mtime >= threshold:
count += 1
except OSError:
continue
return count
weekly_file_count walks the output directory recursively and counts files whose modification timestamp is within the last 7 days. The important part is that it counts the actual filesystem, not the revenue records in revenue.jsonl. Even if a job "thinks it's running," zero output is detected instantly.
The verdict thresholds are this logic.
if count is not None and zweeks >= 4 and count > 0:
verdict = "🔴 KILL推奨"
elif count is None or count == 0:
verdict = "⚠️ 出力停止?"
elif zweeks >= 2:
verdict = "🟡 要観察"
else:
verdict = "🟢"
Organized, the four verdict levels are:
🟢 (normal) — fewer than 2 consecutive zero-revenue weeks, and there is output. Still early; watch and wait.
🟡 (watch) — 2–3 consecutive zero-revenue weeks. Output continues but no revenue. The signal to start checking cost-effectiveness.
⚠️ (output stopped?) — file count over the last 7 days is zero, or unmeasurable. The job itself may have stopped. It could be a failure, or it could be something you stopped deliberately that's still sitting in the conf.
🔴 (KILL recommended) — 4+ consecutive weeks of zero revenue while files keep coming out. This is the most dangerous state: a line where only API cost keeps draining away.
Lining up the first report (2026-07-03) against the second (2026-07-06) shows how the lines' states changed in three days.
| Line | 07-03 output | 07-06 output | Verdict change |
|---|---|---|---|
| sns-bokuwalily | 237 files | 120 files | 🟢→🟡 |
| note | 40 files | 49 files | 🟢→🟡 |
| maker | 36 files | 30 files | 🟢→🟡 |
| affiliate | 31 files | 28 files | 🟢→🟡 |
| asmr | 14 files | 0 files | 🟢→⚠️ |
| article | 14 files | 11 files | 🟢→🟡 |
Every line moved to 🟡 or ⚠️ because revenue logging hadn't started yet (revenue.jsonl was empty). But there's one important change in there. The ASMR line was producing 14 files a week as of 07-03, and 0 on 07-06. That's an anomaly the script detected automatically. With manual monitoring, I probably wouldn't have noticed for weeks.
Implementation details
Why the Python is embedded in the shell
The core of revenue-gate.sh isn't shell script — it's Python embedded in shell. From line 32 it starts like this:
TMP_REPORT="$(mktemp /tmp/revenue-gate.XXXXXX.md)"
if /usr/bin/python3 - "$CONF" "$REVENUE_FILE" "$STATE_FILE" "$REPORT_DATE" > "$TMP_REPORT" <<'PY'
import datetime as dt
import fnmatch
import json
import os
import pathlib
import sys
...
PY
The single-quoted heredoc <<'PY' is how you stop the shell from expanding $ inside the body. If you write <<PY without quotes, $HOME or ${something} in the Python code gets eaten by the shell. Make this mistake in an automation script and it breaks silently, so be careful.
/usr/bin/python3 is written as a full path because launchd's PATH is impoverished. If you write just python3, it won't be found in an environment where only /usr/bin is on the path. If you use libraries installed under Homebrew's /opt/homebrew/bin, you need to be even more careful (here it's standard library only, so /usr/bin/python3 is fine).
Arguments are received via sys.argv[1] onward so that paths aren't hardcoded as strings inside the Python. When I want to change the conf's path later, I only have to fix one shell variable.
The zero_weeks counter state machine
The update logic for revenue-gate-state.json is lines 84–108. The count is only updated when the week number (%G%V) has changed.
if state.get("week") == current_week:
# 同じ週の再実行 → カウントは変えない
for entry in entries:
name = entry["name"]
try:
zero_weeks[name] = int(zero_weeks.get(name, 0))
except (TypeError, ValueError):
zero_weeks[name] = 0
else:
# 新しい週 → 収益ゼロなら+1、収益あれば0にリセット
for entry in entries:
name = entry["name"]
if weekly_revenue.get(name, 0.0) > 0:
zero_weeks[name] = 0
else:
try:
previous = int(zero_weeks.get(name, 0))
except (TypeError, ValueError):
previous = 0
zero_weeks[name] = previous + 1
(The comments read: same week re-run → don't change the count; new week → +1 if revenue is zero, reset to 0 if there was revenue.)
The state.get("week") == current_week branch is what earns its keep when you force a re-run with REVENUE_GATE_FORCE=1. Running a second time in the same week doesn't double-count zero_weeks.
The try / except (TypeError, ValueError) is a defense against garbage in the JSON. For instance, from a state like "zero_weeks": {"note": null}, calling int(None) raises TypeError. Automation jobs break months later, when you've forgotten about them, so this much defense is worth having.
os.walk and fnmatch in weekly_file_count
weekly_file_count is what looks at the reality of the production jobs (lines 132–148).
def weekly_file_count(directory, glob):
root = pathlib.Path(directory)
if not root.is_dir():
return None
threshold = dt.datetime.now().timestamp() - (7 * 24 * 60 * 60)
count = 0
for current_root, _, files in os.walk(root):
for filename in files:
if not fnmatch.fnmatch(filename, glob):
continue
path = pathlib.Path(current_root) / filename
try:
if path.stat().st_mtime >= threshold:
count += 1
except OSError:
continue
return count
There are three key points.
Return None when the directory doesn't exist. When return None flows into the verdict logic, count is None produces ⚠️ 出力停止?. Trying to encode "is the path wrong, or did the job stop?" as a distinction in the verdict would complicate the design, so mapping None straight to ⚠️ is the reasonable choice.
Cut on the last 7 days via st_mtime. It uses pathlib.Path(...).stat().st_mtime rather than os.path.getmtime. They're equivalent, but since the loop already builds a path object, that's one method call instead of more. Skipping OSError with continue handles files being deleted mid-scan.
Apply the glob filter with fnmatch.fnmatch. Specify *.md and you target only Markdown; *.mp3 and only audio. This function is simpler than shell globbing — there's no recursive ** matching. Recursion is os.walk's job.
The result shows up in this column of the report (excerpted from the real 2026-07-06 report):
| sns-bokuwalily | 120 | 2 | 🟡 要観察 |
| note | 49 | 2 | 🟡 要観察 |
| maker | 30 | 2 | 🟡 要観察 |
| affiliate | 28 | 2 | 🟡 要観察 |
sns-bokuwalily's 120 means the social-posting job is emitting more than 120 files a week. That's the leading candidate for what's pushing up API cost, and if the no-revenue weeks continue it goes 🔴.
The two-step write: mktemp → Python stdout → cp
Writing the report flows like this.
TMP_REPORT="$(mktemp /tmp/revenue-gate.XXXXXX.md)"
if /usr/bin/python3 - ... > "$TMP_REPORT" <<'PY'
...
PY
then
mkdir -p "$DESKTOP_DIR" "$VAULT_DIR"
cp "$TMP_REPORT" "$DESKTOP_DIR/revenue-gate-${REPORT_STAMP}.md"
cp "$TMP_REPORT" "$VAULT_DIR/revenue-gate-${REPORT_STAMP}.md"
touch "$WEEK_MARKER"
echo "[...] revenue-gate archived" >> "$LOG"
else
status=$?
echo "[...] ERROR: report generation failed (${status})" >> "$LOG"
rm -f "$TMP_REPORT"
exit "$status"
fi
rm -f "$TMP_REPORT"
Only when Python succeeds does it cp to both the Desktop and the Obsidian vault. On failure it deletes $TMP_REPORT, leaves an error in the log, and exits non-zero via exit "$status". mktemp exists to prevent an incomplete file being left behind if Python fails partway. Write directly to the destination and you end up with a "half-written, broken report" sitting on the Desktop.
The last line of the script deletes old marker files.
find "$LOG_DIR" -maxdepth 1 -name '.revenue-gate-done-*' -mtime +35 -delete 2>/dev/null
-mtime +35 removes markers older than 35 days. It's housekeeping so that one file per week doesn't accumulate infinitely in ~/.claude/logs/.
Where I got stuck
set -u collided with launchd's environment variables
The initial version wrote the duplicate-run check like this.
if [ "$REVENUE_GATE_FORCE" != "1" ]; then
# チェックロジック
fi
Run manually from the terminal, it works fine. But when it fired at Monday 8:20 via launchd, processing stopped without anything appearing in the log.
The cause was the combination with set -u. launchd starts /bin/bash as a new session, so REVENUE_GATE_FORCE doesn't exist as an environment variable. With set -u in effect, referencing an undefined variable makes the shell exit right at that line. The script was dying before it reached line 24's echo "[...] ===== revenue-gate 開始 =====" >> "$LOG", so nothing was recorded in the log.
The fix is simple.
# 変更前
if [ "$REVENUE_GATE_FORCE" != "1" ]; then
# 変更後
if [ "${REVENUE_GATE_FORCE:-0}" != "1" ]; then
(Before / after.) :-0 is Bash parameter expansion meaning "use 0 if the variable is undefined or null." It's the correct pattern for keeping set -u on globally while giving specific variables a default. The current code (line 20) reads ${REVENUE_GATE_FORCE:-0}.
Putting set -u in an automation script is the right call, but every variable you want to receive optionally from the environment has to use the :- form. The root of this bug was the assumption that "works in the terminal" equals "works under launchd."
.DS_Store was being counted as output
Lines in revenue-gate.conf that omit the glob are treated as * (all files).
sns-bokuwalily|~/dev/bokuwalily-sns/out|
When I opened that line's output directory in Finder, macOS automatically created a .DS_Store. Python's fnmatch.fnmatch(".DS_Store", "*") returns True. os.walk doesn't exclude dotfiles. The result: the job had produced nothing, yet the report showed "weekly output: 1."
With zero_weeks at 1 and output at 1, the verdict comes out 🟢. A false negative: it looks like "no problem" while the job is actually dead.
The stopgap fix is to specify an explicit extension in the conf's third column.
sns-bokuwalily|~/dev/bokuwalily-sns/out|*.md
Fundamentally I should add a dotfile-excluding filter inside weekly_file_count, but for now the design accepts the tradeoff: "if you don't specify a glob, that's on the operator."
I typo'd a source name in revenue.jsonl and the revenue got lost
When recording revenue with rev.sh, the source name must match the name column in revenue-gate.conf exactly.
{"date":"2026-07-01","source":"affiliate","amount_jpy":8400,"note":"Amazon報酬"}
The mistake I actually made was typing affiliate when I meant affiliate-livedoor. ¥8,400 of revenue got booked to the affiliate line instead of the affiliate-livedoor line. I noticed when I looked at the report: affiliate-livedoor's zero_weeks hadn't reset while only affiliate's had.
The characteristic symptom is "total revenue is correct, but the per-line verdicts are off." In aggregate the revenue is there, yet one particular line never climbs out of 🟡.
The fix is editing revenue.jsonl directly. Since it's JSON Lines, you rewrite the wrong line with the correct source name. However, the state file's zero_weeks doesn't recompute the current week's count, so after the fix you need a forced re-run with REVENUE_GATE_FORCE=1.
As a long-term defense I plan to add validation against the conf inside rev.sh, but since this is the only manual step, "if you typo it, fix the jsonl" is how I operate for now.
The ASMR drop from 14 to 0 was actually a counting-window misalignment
Here's the real cause of the "ASMR line fell from 14 files in the 07-03 week to 0 in the 07-06 week" I mentioned earlier. I investigated thinking "the job broke." It hadn't.
weekly_file_count counts files from "the last 7 days" relative to execution time.
- The 07-03 (Thu) report: covers files from 06-26 to 07-03. The ASMR job had generated 14 files in one batch on 06-26 (Thu), so all 14 fell inside the count.
- The 07-06 (Sun) report: covers files from 06-29 to 07-06. The 06-26 files had fallen outside the window. And in the week since, the ASMR job hadn't yet generated its next batch. Result: zero.
The ASMR job was designed to "generate one batch per week," but while launchd was on a Monday-start schedule, 06-26 was a Thursday. Because the first run I did manually was on a Thursday, the 7-day cycle was offset.
The structural problem is this: when the weekly report's counting window and a production job's output cycle don't line up, a healthy job looks broken. A job that emits one batch per week gets detected every week if the report day and the output day are close, but with bad timing it reads as zero every other week.
The current operational workaround is: "when ⚠️ appears, don't immediately conclude the job is broken — check the most recent output timestamp manually first." It's not perfect, but it beats manual monitoring by a mile. And noticing this problem at all is something that, without the report, I would have missed for two or three months. The right distance to keep is to treat the numbers the script shows not as facts but as triggers for investigation.
Gotchas
Beyond the four covered above (set -u colliding with environment variables, .DS_Store contamination, source-name typos, counting-window misalignment), there are more things you hit in real operation. Here they are as a list.
The ISO week-number year-boundary bug. In
date +%G%V,%Greturns the ISO year (not the calendar year). Take Monday 2026-12-28: it becomes202701. Both the marker filename and thezero_weekskeys are unified on%G%V, so the arithmetic itself doesn't break — but it produces the confusion of "I processed the last week of 2026, yet the filename says 2027." Fork it and change it to%Y%Vand it breaks completely. ISO week years are supposed to diverge from calendar years, and there's a reason this script picks%G%V.Calling rev.sh with a source that doesn't exist in the conf sends revenue into the void.
weekly_revenueandtotal_revenueare open hashes, so they accept any source name. But the report only iterates overentries(the conf's name column). TypeaffilIate(capital I) and it creates an entry separate from theaffiliateline, with the revenue showing up nowhere. The symptom is "I recorded it with rev.sh but zero_weeks didn't reset." It resembles the source-name typo covered earlier (mis-recording within an existing line), but this variant is "recording to a name that isn't defined in the conf at all," and the only way to detect it is to list the sources withjq -r '.source' ~/.claude/data/revenue.jsonl | sort -uand cross-check against the conf.A broken symlink turns into an immediate ⚠️. The top of
weekly_file_countisif not root.is_dir(): return None. A broken symlink (the target directory was deleted, an external SSD isn't connected, etc.) makesis_dir()returnFalse, givingNone→⚠️ 出力停止?. When the job is running fine but the report keeps warning, it's usually this or the fourth case above. After changing an output directory or moving it to an external drive, you have to update the conf too.os.walkdoesn't follow symlinked directories. The default foros.walk(root)isfollowlinks=False. If a production job's output destination is pointed at by a symlink like~/Desktop/Article/articleswhile the real files live elsewhere, the count is always 0. If you see "Finder shows the files but weekly output is 0," suspect this. Writing real paths in the conf's output-directory column is the safe option.There's no guard on
mktemp. The current code has no|| exit 1afterTMP_REPORT="$(mktemp /tmp/revenue-gate.XXXXXX.md)". If/tmpis exhausted,mktempfails and returns an empty string, and> "$TMP_REPORT"either creates an empty file in the current directory or throws away Python's output. It doesn't happen on a normal Mac, but it does in CI environments sharing /tmp or on machines tight on space. If you fork this, add|| { echo "ERROR: mktemp failed" >> "$LOG"; exit 1; }.The revenue-aggregation window and the file-count window have different reference points. Revenue aggregation uses
week_start = report_date - timedelta(days=7)(date-based, anchored at 00:00:00); file counting usesdatetime.now().timestamp() - (7 * 24 * 60 * 60)(execution-time-based, anchored at 08:20:00). Which window a file exactly 7 days old lands in can change the verdict. Near the boundary you get a one-day skew producing "revenue this week but zero output" or "output but zero revenue." In practice it's within tolerance, but for a line that generates one file per day in a nightly batch it affects accuracy.Python's stdout doesn't reach the launchd log. The plist's
StandardOutPathandStandardErrorPathboth point at the samerevenue-gate-launchd.log. But the embedded Python'sprint()output is redirected by the shell's> "$TMP_REPORT", so it never lands in launchd.log. That's why adding debug output on the Python side and tailing launchd.log shows nothing. Send Python debug output to stderr withimport sys; sys.stderr.write("debug\n")and it will appear in launchd.log.The week you add a new line, its past zero history isn't carried over. In the week you add a new line to the conf,
zero_weeks.get(name, 0)starts counting from 0. That's correct behavior in code, but the context "this line hadn't earned for weeks before it was added to the conf" disappears. When it eventually goes 🔴, you can't trace "since when has it been unprofitable?" When adding a new line, the habit of leaving a start record inrevenue.jsonlwithamount_jpy: 0andnote: "ライン開始"helps when reading back the history later.Archived files are never auto-deleted. The marker files (
.revenue-gate-done-*) get cleaned up after 35 days by-mtime +35 -delete, but the report bodies copied into~/Desktop/Revenue Gate/and the Obsidian vault'sbriefs/revenue-gate/never go away. One file accumulates per week — over 50 in a year. Once reports start showing up in Obsidian full-text search, the realistic options are to addbriefs/revenue-gatetouserIgnoreFiltersinsettings.json, or to add a monthly cron that moves reports older than six months intoarchive/.
Best practices
Here are the rules I've distilled from three weeks of operation, in 14 items.
1. If you use set -u, give every optional environment variable a :- default. The ${REVENUE_GATE_FORCE:-0} pattern. launchd starts a new session, so its environment variables differ from your terminal's. This is the number one cause of a script that passes manual runs dying silently under launchd.
2. Register launchd with double-fire slots. Register 13:20 in addition to 8:20, and prevent duplicate runs within the week with the marker file (based on %G%V). Essential if you close your Mac over the weekend. That StartCalendarInterval in a plist accepts an array isn't prominent in the official docs, so the snippet above is usable as-is.
3. Never write directly to the destination. Go mktemp → Python stdout → cp. It prevents a half-broken destination file if Python crashes partway. The structure if python3 ... > $TMP; then cp $TMP $DEST; fi is what guarantees atomicity.
4. Write Python heredocs with <<'PY' (single-quoted). With <<PY, $HOME and ${var} inside the Python code get expanded by the shell. Even if there are no variables in the code today, it will quietly break when you add one later. Single quotes are zero-cost insurance.
5. Call Python by full path, /usr/bin/python3. launchd's PATH has roughly only /usr/bin:/bin:/usr/sbin:/sbin. Writing just python3 can fail to resolve in a Homebrew environment. In practice the script adds export PATH="/opt/homebrew/bin:..." at the top so it's fine, but keeping the full path for the Python invocation makes launchd debugging easier.
6. Never omit the conf's glob column — always be explicit. *.md, *.mp3, and so on. Omit it and it's treated as *, letting .DS_Store, .gitkeep, and __pycache__ in. This is the root cause of the false negative where output shows 1 but the job has stopped.
7. Keep the revenue-recording command down to five seconds. The reason the rev.sh note 3200 有料記事1本 format has survived is that recording takes three words. If recording is a chore it stops happening, and zero_weeks becomes meaningless. Anything with ASP auto-integration will eventually be automated via cron, but minimizing the friction of manual recording is the realistic answer for now.
8. Keep JSON Lines in a state where you can health-check it with jq at any time. jq -c . ~/.claude/data/revenue.jsonl > /dev/null detects parse errors instantly. After fixing something by hand, always run this before forcing a re-run with REVENUE_GATE_FORCE=1.
9. Keep the script's no-auto-stop design. 🔴 KILL推奨 is wording in the report; the code holds no action that stops a job. When a machine stops things automatically, the context of "why did it stop?" is lost, and a false positive risks killing your top earner. Stopping a line is done manually by a human. This design principle is best left alone.
10. Don't read ⚠️ as "confirmed broken job." Counting-window misalignment, symlink issues, monthly batch-output timing — there are several patterns where a healthy job shows ⚠️. The first thing to check is the file's last-modified timestamp with ls -lt <output directory> | head -5. If files exist and the mtime is recent, it's window misalignment; if they don't exist, it's a job failure.
11. Check state.json's zero-week counts manually on a regular basis. The report only shows 🟡 要観察, but the number behind it lives in state.json. jq .zero_weeks ~/.claude/data/revenue-gate-state.json shows whether each line is at 2 weeks or 3. At 3, it's about time to start deciding.
12. Set up an alias for the forced re-run. Adding alias rg-force='REVENUE_GATE_FORCE=1 bash ~/.claude/scripts/revenue-gate.sh' to .zshrc makes re-aggregation after a typo fix a one-command job. I misspell REVENUE_GATE_FORCE every time I type it by hand, so the alias is mandatory.
13. After editing the conf, always run it manually once and check the output. Right after adding a new line or changing a glob, generate a report immediately with rg-force and confirm the weekly output count and verdict are what you expect. If you wait until next Monday to discover "the config was actually wrong," you lose a week of data.
14. Read the four-gate reminder every week. The "what is it for / who is happy / how much does it earn / when do you cut it" at the end of the report is wording I wrote myself, so it's easy to skim past. But when you can't answer those four questions for a line entering its third 🟡 week, that is the signal that it's time to stop. The KILL decision starts there.
Summary
I've walked through the structure of revenue-gate.sh at the code level. One thing emerges from all of it: the more production lines you add, the less time you have to look at them. If the numbers don't arrive automatically every Monday, grasping the cost-effectiveness of 14 lines simultaneously is physically impossible.
Just as the ¥1.2M/month figure holds together because I know, every week, both "which line is earning" and "which line is eating API cost," the more you scale automation, the more your revenue is protected by a mechanism that can stop itself. Without the report, I'd have missed the ASMR line dropping from 14 files a week to 0 across the three days from 07-03 to 07-06 for weeks.
The implementation cost is two files — revenue-gate.sh (204 lines) and the plist (37 lines) — and 15 minutes to install. If that gets the information you need for a weekly revenue decision delivered automatically every Monday, the ROI is recovered on day one.
Before the cost of running a zero-revenue line for four weeks exceeds the API cost you'd have saved by stopping it even once a month. Look at this Monday's report, and decide.
I've written up the full picture of the setup, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)