cd /news/developer-tools/your-automation-dies-quietly-a-weekl… · home topics developer-tools article
[ARTICLE · art-107725] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Your Automation Dies Quietly: A Weekly Revenue Gate That Caught One Line Dropping From 14 Files to 0

A developer running 14 content-production lines generating ¥1.2M/month in revenue built an automated weekly report script, revenue-gate.sh, to flag underperforming lines. The script, which runs every Monday via launchd, detected that the ASMR line dropped from 14 files to 0 in three days, a change the developer would have missed without automation. The system is designed to surface facts for human judgment, not to auto-stop jobs.

read25 min views1 publishedAug 23, 2026

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.

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

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.

~/.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.

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.

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.

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

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

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

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/

.

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

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.

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

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

, %G

returns the ISO year (not the calendar year). Take Monday 2026-12-28: it becomes 202701

. Both the marker filename and the zero_weeks

keys 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%V

and 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_revenue

and total_revenue

are open hashes, so they accept any source name. But the report only iterates over entries

(the conf's name column). Type affilIate

(capital I) and it creates an entry separate from the affiliate

line, 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 with jq -r '.source' ~/.claude/data/revenue.jsonl | sort -u

and cross-check against the conf.

A broken symlink turns into an immediate ⚠️. The top of weekly_file_count

is if not root.is_dir(): return None

. A broken symlink (the target directory was deleted, an external SSD isn't connected, etc.) makes is_dir()

return False

, giving None

⚠️ 出力停止?

. 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.walk doesn't follow symlinked directories.** The default for

os.walk(root)

is followlinks=False

. If a production job's output destination is pointed at by a symlink like ~/Desktop/Article/articles

while 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 1

after TMP_REPORT="$(mktemp /tmp/revenue-gate.XXXXXX.md)"

. If /tmp

is exhausted, mktemp

fails 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 uses datetime.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 StandardOutPath

and StandardErrorPath

both point at the same revenue-gate-launchd.log

. But the embedded Python's print()

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 with import 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 in revenue.jsonl

with amount_jpy: 0

and note: "ライン開始"

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's briefs/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 add briefs/revenue-gate

to userIgnoreFilters

in settings.json

, or to add a monthly cron that moves reports older than six months into archive/

.

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.

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*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude api 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/your-automation-dies…] indexed:0 read:25min 2026-08-23 ·