# 30 Minutes Every Morning Lost to Re-Explaining Myself: The 20-Line Auto-Brief That Fixed It

> Source: <https://dev.to/bokuwalily/30-minutes-every-morning-lost-to-re-explaining-myself-the-20-line-auto-brief-that-fixed-it-35bj>
> Published: 2026-09-09 05:00:06+00:00

The thing that moved my numbers wasn't a clever idea — it was a shell script that tells me where I stand at 8:00 a.m. every day. I started with a ¥100k/month side hustle in college, stacked gigs up to ¥600k/month, then got laid off and went back to zero. Six months later, after building an autonomous Claude Code environment, I'm at ¥1.2M/month in revenue. The one thing I built *before* everything else was the morning brief.

Claude Code operates per conversation session. The moment you close the browser tab, the context of the previous session is gone. The next time you open it, Claude starts from a blank slate — "nice to meet you."

This is quietly fatal.

Say yesterday you were halfway through digging into "autolike's Gumroad webhook doesn't go through on staging." You fire up Claude Code the next morning intending to pick up where you left off, but Claude has none of yesterday's context. You end up re-explaining "what is autolike?", "what webhook?", "where did we get stuck?" from scratch. Two to three minutes per round trip, and once that piles up daily, more than 30 minutes a week disappears into "explanation round trips."

But the real problem isn't just time. It's that **the precision of the explanation drifts every time**. The concrete state that yesterday's self had a grip on — "there are 3 files with uncommitted changes," "the production probe is returning 500," "cost consumption is near the weekly limit" — has already faded from today's memory. The coarser the information you can give Claude, the coarser Claude's opening move.

The root of this problem is **the environment, not the work**.

To stack up monthly revenue in solo development, you need to be in a state where you can instantly judge "where do I move from today?" every morning. Which state each of 10 repositories is in, whether 4 products survived the night in production, how far this week's API consumption cost has climbed — checking all of that manually every morning burns 30 minutes on its own. And even if you automate it, copy-pasting the results into Claude Code every time is structurally the same problem.

**The solution is automating context injection.** Every time Claude starts a session, a 20–30 line summary describing the current state of the environment gets automatically inserted into the system prompt. Claude starts the session holding "today's state" instead of a blank page.

What I built for this is the combination of `daily-brief.sh` (a script that collects the environment's current state) and `cc-brief.sh` (a script that injects the collected information into the Claude Code session). The former produces the data; the latter delivers it to Claude. This two-stage structure wiped out the morning "explanation round trip" entirely.

Before building the mechanism, what I agonized over was the design question of "what to put in it." Cram in too much and the injected context balloons, increasing the tokens Claude processes and driving up cost. Trim too much and it's meaningless.

Looking at the actual script, you can see it's organized into **7 categories**.

**1. Live probes (are the production products alive)**

Hit the product URLs with `curl` and get the HTTP status. But a single shot can produce false reports. On Vercel deploys with slow cold starts, the first request would time out and a live product would be misdetected as "dead." To avoid this, the script is designed with 3 retries and a 15-second timeout.

```
for attempt in 1 2 3; do
  code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 "$url" 2>/dev/null || echo 000)
  if [ "$code" = "200" ] || [ "${code:0:1}" = "3" ]; then break; fi
  sleep 2
done
```

The products checked here are four: the job-hunting tracker, the job-hunting navigator, the graduation planner, and AETHERIA RPG. On top of that, autolike's license API gets a POST with the dummy key `HEALTHCHECK-DUMMY`, and the environment-variable configuration state is determined by whether it returns "server configuration error" or "license not found." This distinction matters: the former means the Gumroad connection is broken and the billing flow has stopped.

**2. Improvement log (how many autonomous improvements were recorded yesterday)**

```
YESTERDAY=$(date -v-1d +%Y-%m-%d)
YESTERDAY_COUNT=$(grep -cE "^## ${YESTERDAY} " ~/.claude/improvements/log.md 2>/dev/null || echo 0)
```

`improvements/log.md` is the log of Claude Code autonomously improving the environment. Seeing yesterday's count and the cumulative count every morning lets you confirm at a glance whether the autonomous cycle is turning.

**3. Hook latency (last 24 hours)**

Check whether Claude Code's hooks are completing within the expected time. When hooks lag, session startup stalls, so the 24-hour delay distribution goes into the brief.

**4. API cost (last 7 days)**

`cost-summary.sh` aggregates consumption cost for the last 7 days. If cost is approaching the weekly ceiling, it changes the day's judgment on workload and delegation target (Codex or Sonnet).

**5. launchd status (are the automation tasks running)**

```
launchctl list | grep com.shun | awk '{printf "- %s (last exit=%s)\n", $3, $2}' | head -15
```

Check whether the launchd jobs that run at fixed times each day terminated normally. A non-zero last exit is a sign that something is broken.

**6. git status (current state of 10 repositories)**

```
for repo in \
  ~/Documents/projects/closet-os \
  ~/dev/shukatsu-tracker \
  ~/dev/seo-affiliate-site \
  ~/fantasy-mmo \
  ~/hosei-grad-planner \
  ~/Projects/autolike-license-server \
  ~/Projects/instagram-auto-liker \
  ~/Projects/x-like-demo \
  ~/dev/jobform-autofill \
  ~/lead-finder; do
  ...
  echo "- **${name}** [${branch}]: ${uncommitted} 未コミット — _${last_commit}_"
done
```

For 10 repositories, it outputs branch name, uncommitted file count, and last commit summary on one line each. "Which repository did work stall in yesterday" becomes visible as a list.

**7. auto-skill status (how many autonomous skills have been generated)**

Since I run a mechanism where Claude Code generates its own skills, the current count and the last harvest timestamp go into the brief.

In fact, this brief **does not include a task list**. I initially tried to inject the todo list too, but dropped it for two reasons.

The first is update cost. Tasks are born, die, and shift priority inside conversations. A task list written into a static file quickly diverges from "today's reality."

The second is the question of Claude's role. If you inject "here are today's tasks" in advance, Claude gets pulled toward that list. The room to re-discuss the day's true priorities narrows. **The brief is a description of state, not a preemption of instructions.** That's the crux of the design.

Similarly, it doesn't include the full text of Obsidian notes. Only the first 30 lines of `hot.md` are read in by `cc-brief.sh`. The Obsidian vault holds thousands of notes, so injecting the full text would be a waste of tokens. The design is to consolidate just the key points of "what's moving this week" into hot.md, and read only that.

Here's how the two scripts work together.

```
[launchd]
  │ 毎日 8:00 / 10:30 / ログイン時
  ▼
[daily-brief.sh]
  ├─ probe_url() × 4プロダクト（3回リトライ・max-time 15s）
  ├─ probe_autolike() × 2（instagram / x）
  ├─ probe_scout() ── github-scout-latest.md の mtime + 行数チェック
  ├─ probe_affiliate_audit() ── audit-YYYY-MM-DD.log 読み取り
  ├─ automation-health.sh（timeout 60s）
  ├─ hook-latency-report.sh（直近24h・timeout 60s）
  ├─ cost-summary.sh（直近7d・timeout 60s）
  ├─ launchctl list | grep com.shun
  ├─ git log + status × 10リポジトリ
  ├─ improvements/log.md（昨日/累計件数）
  ├─ auto-skills 件数
  └─ ディスク使用量（~/.claude・disabled-cache）
       │
       ├─→ ~/.claude/logs/daily-brief-latest.md   ←── cc-brief.sh が参照
       ├─→ ~/Desktop/Daily Brief/today-brief-*.md
       └─→ vault/wiki/briefs/daily/today-brief-*.md
            （マーカー <!-- daily-brief YYYYMMDD --> で二重追記防止）

[UserPromptSubmit フック]
  │ Claude Code の全セッション・メッセージ送信ごとに自動起動
  ▼
[cc-brief.sh]
  ├─ ccusage blocks --active --json
  │     → "5h block: {OUT_K}k out / {REMAIN}min left / ≈${COST} API-equiv"
  ├─ ccusage weekly --json
  │     → "Week: {WOUT_M}M out / ≈${WCOST} API-equiv"
  ├─ settings.json → plugins / permissions / hooks の件数
  ├─ claude mcp list → Connected / Failed / Needs-auth の件数
  ├─ improvements/log.md → 直近1エントリ（tail -r で末尾から取得）
  ├─ MEMORY.md → 末尾12行
  └─ hot.md → 先頭30行
       │
       ▼
  [Claude Code の system-reminder に注入]
  → Claudeは白紙でなく「今日の現在地」を持ってセッションを開始する
```

Inside `daily-brief.sh`, three child scripts get called: `automation-health.sh`, `hook-latency-report.sh`, and `cost-summary.sh`. If any of them hangs for some reason, generation of the entire brief stops. Cases where a socket was dead at dawn, or where things jammed waiting on a connection to an external API, actually happened.

To prevent this, the script wraps child process calls in a `run_to` function.

```
TIMEOUT_BIN="/opt/homebrew/bin/timeout"
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""
run_to() { local s=$1; shift; if [ -n "$TIMEOUT_BIN" ]; then "$TIMEOUT_BIN" --kill-after=15 "$s" "$@"; else "$@"; fi; }
```

Each child script is called in the form `run_to 60 ... 2>&1 | head -10`. If it doesn't finish within 60 seconds it gets killed, and 15 seconds after that it's force-terminated. Output is truncated with `head -10`, so even if a child script emits a flood of output, the injected context doesn't balloon.

I run Claude Code on the MAX plan, and the `ccusage` command can retrieve the token balance for the 5-hour block.

```
ACTIVE=$("$CCUSAGE" blocks --active --json 2>/dev/null | jq '.blocks[] | select(.isActive)')
if [ -n "$ACTIVE" ]; then
  OUT=$(echo "$ACTIVE" | jq -r '.tokenCounts.outputTokens')
  REMAIN=$(echo "$ACTIVE" | jq -r '.projection.remainingMinutes')
  COST=$(echo "$ACTIVE" | jq -r '.costUSD | floor')
  OUT_K=$((OUT / 1000))
  echo "5h block: ${OUT_K}k out / ${REMAIN}min left / ≈\$${COST} API-equiv"
fi
```

It divides `outputTokens` by 1000 to display in k-token units, and shows the remaining minutes in the block via `projection.remainingMinutes`. Knowing this at session start makes the judgment "can I start a heavy cross-cutting investigation right now?" instantaneous. With 30 minutes left, I narrow to light tasks and push heavy investigation to the next block.

The weekly aggregate is retrieved from `ccusage weekly --json`, and output tokens are rounded to MB units (one decimal place) for display.

```
WOUT_M=$(jq -n --argjson n "$WOUT" '$n / 1000000 | . * 10 | round / 10')
echo "Week: ${WOUT_M}M out / ≈\$${WCOST} API-equiv"
```

The design where `cc-brief.sh` reads only the last 12 lines from MEMORY.md is intentional trimming.

```
[ -f "$HOME/.claude/projects/$(echo $HOME | sed 's|/|-|g')/memory/MEMORY.md" ] && \
  cat "$HOME/.claude/projects/$(echo $HOME | sed 's|/|-|g')/memory/MEMORY.md" | tail -12
```

MEMORY.md is an append-style index, where newer memories get added at the end. In other words, the last 12 lines mean "recently updated memories." Reading all lines would be complete, but tokens balloon. **By injecting only the most recent context, Claude starts the session holding only the memories relevant to this week's work.**

`/bin/bash` directly
As noted in a comment in `daily-brief.sh`, when executing via a launchd plist, `/bin/bash` is specified directly rather than going through `/bin/zsh`.

```
# Desktop / ~/Documents(vault) は TCC 保護領域 → plist は /bin/bash 直起動（FDA付与済み）。
# /bin/zsh 経由だと FDA 未付与で書き込みに失敗する。
```

Apple's Full Disk Access (FDA) is granted per process. Even if you've granted FDA to `/bin/bash`, a process launched via `/bin/zsh` is treated separately and writes to Desktop or Documents get denied. Having fallen into this trap, the plist is designed to list `/bin/bash` and the absolute path of `daily-brief.sh` directly in `ProgramArguments`.

Also, the environment launchd runs in has a different PATH than the terminal. To use `homebrew` commands, the script explicitly supplements PATH at the top.

```
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
```

Forget this and `timeout` or `jq` dies with "command not found." This is the classic cause of burning time debugging launchd scripts.

`daily-brief.sh` writes the generated brief to 3 places.

`~/.claude/logs/daily-brief-latest.md` — the canonical copy that cc-brief.sh reads`~/Desktop/Daily Brief/today-brief-YYYYMMDD.md` — the delivery for a human to check in the morning`vault/wiki/briefs/daily/today-brief-YYYYMMDD.md` — the long-term archive in Obsidian
Delivery to Desktop is "for human confirmation." You can read the brief from Finder without opening a terminal. The Obsidian archive is "for looking back a week later," so you can reference past product states and cost trends.

To prevent duplicate appends, the marker `<!-- daily-brief YYYYMMDD -->` is embedded in each file, so even if a brief for the same date gets written twice, it's kept as one.

``` php
MARK="<!-- daily-brief ${DATE} -->"
LC_ALL=C grep -qF -- "$MARK" "$f" 2>/dev/null && continue
```

`LC_ALL=C` is attached so that even if invalid bytes have crept into the file, grep won't treat it as binary and will reliably detect the marker.

Finally, the brief is also posted to the Discord `#04_daily-brief` channel.

```
[ -s "$OUT" ] && /usr/bin/python3 "$HOME/.discord/post_brief.py" daily-brief "$OUT" >/dev/null 2>&1 || true
```

It's designed so that a failed Discord post doesn't stop the whole brief generation, by ignoring it with `|| true`. The priority is explicit: generating the brief is primary, posting to Discord is a byproduct.

`cc-brief.sh` is a 53-line script, but internally it's divided into 7 sections. Let's dissect the parts other than the "USAGE section" touched on earlier, in order.

**ENV section**

```
SETTINGS="$HOME/.claude/settings.json"
echo "Plugins enabled:   $(jq -r '.enabledPlugins | length' "$SETTINGS")"
echo "Permissions allow: $(jq -r '.permissions.allow | length' "$SETTINGS")"
echo "Hooks:             $(jq -r '.hooks | keys | length' "$SETTINGS") events"
```

It outputs plugin count, permission count, and hook event count from `settings.json`, one line each. The point is that they're numbers. If the output "8 plugins / 43 allow / 6 events" is the same every morning, you know immediately that no setting you didn't touch last night has changed. Conversely, an unfamiliar number is a sign that something changed. Detecting "I supposedly added a hook, but events didn't change" also starts from this one line.

**MCP section**

```
MCP=$("$HOME/.nvm/versions/node/v24.13.0/bin/claude" mcp list 2>&1 || claude mcp list 2>&1)
echo "Connected: $(echo "$MCP" | grep -c Connected) / \
Failed: $(echo "$MCP" | grep -c 'Failed to connect') / \
Need-auth: $(echo "$MCP" | grep -c 'Needs auth')"
```

It counts MCP server connection states in 3 categories. What's noteworthy is how it's invoked. It first tries the absolute path `$HOME/.nvm/versions/node/v24.13.0/bin/claude`, and falls back to the `claude` command on failure. Because `cc-brief.sh` is called from the UserPromptSubmit hook, there's no guarantee the shell's PATH is fully inherited. Making the absolute path the first candidate ensures the `claude` binary under `nvm` gets used.

There was a case where `Failed: 1` actually showed up and tipped me off. A script that ran in the middle of the night mistakenly overwrote an MCP server config file, and the connection was down as of the next morning's session start. That's a time window I'd never have noticed if I were running `claude mcp list` manually in the terminal. One second into the session, you know "something is off."

**RECENT IMPROVEMENTS section**

```
[ -f "$HOME/.claude/improvements/log.md" ] && \
  tail -r "$HOME/.claude/improvements/log.md" 2>/dev/null \
  | awk '/^## /{count++; if(count>1) exit} {print}' \
  | tail -r \
  | head -30
```

This one-liner is the processing that "extracts only the latest entry from `improvements/log.md`." The structure is distinctive, so let me explain.

`improvements/log.md` is an append-style log where each entry starts with a `## YYYY-MM-DD HH:MM` header. The end of the file is the latest entry. To extract only the latest entry, you need logic that "reads in reverse from the end and stops when it finds the first section boundary."

`tail -r` is a macOS-specific command that outputs a file in reverse line order (`tac` in the GNU world). After reversing, `awk` stops output at the point it finds the section boundary (a `##` line) for the second time. This yields exactly one trailing entry of the file, in reverse. Finally `tail -r` again restores forward order, and `head -30` truncates.

"Why not `grep -A 30 "the last header"`?" — you'd have to grep the entire file once to identify the header line, and that gets slower as entries grow. Reverse + awk always finishes reading just a few lines from the end, so speed doesn't degrade even past 1000 log entries.

**MEMORY INDEX section**

```
[ -f "$HOME/.claude/projects/$(echo $HOME | sed 's|/|-|g')/memory/MEMORY.md" ] && \
  cat ... | tail -12
```

This path construction has a quirk. Claude Code saves per-project memory in `~/.claude/projects/<path encoded with ->/memory/`. For example, if the home directory is `/Users/alice`, the path is encoded to `-Users-alice`. That conversion is what `echo $HOME | sed 's|/|-|g'` does. Because the path is assembled dynamically, the design doesn't break if the home directory path changes.

**OBSIDIAN HOT section**

```
[ -f "$HOME/Documents/claude-obsidian/wiki/hot.md" ] && \
  head -30 "$HOME/Documents/claude-obsidian/wiki/hot.md"
```

It reads only the first 30 lines of the HOT note in the Obsidian vault. `hot.md` is a note I update myself with "the key points of this week's active work," operated under a rule that new incidents, in-progress problems, and this week's focus fit within 30 lines. Injecting only the first 30 lines is intentional trimming — all other notes in the vault are excluded from reference to keep tokens down.

**① UTF-8 safety via `iconv -c`**

```
last_commit=$(git -C "$repo" log -1 --format='%cr %s' 2>/dev/null \
  | cut -c1-70 \
  | /usr/bin/iconv -f UTF-8 -t UTF-8 -c)
```

Because `cut -c` slices by bytes, it can split a byte sequence in the middle of a multibyte character (such as a Japanese commit message). If a broken, invalid UTF-8 sequence lands in the `last_commit` variable, the downstream `LC_ALL=C grep -qF "$MARK"` treats the file as binary and marker detection fails.

`iconv -f UTF-8 -t UTF-8 -c` means "read input as UTF-8, output as UTF-8, and silently discard bytes that can't be converted (`-c`)." This removes byte sequences that got split mid-character, and the subsequent grep works safely. It's called by absolute path `/usr/bin/iconv` for PATH reliability in the launchd environment.

**② Making marker detection safe with `LC_ALL=C`**

```
LC_ALL=C grep -qF -- "$MARK" "$f" 2>/dev/null && continue
```

This is the line that searches for the duplicate-append prevention marker `<!-- daily-brief YYYYMMDD -->`. Without `LC_ALL=C`, if even a single invalid byte (the remnants of UTF-8 splitting described above) is mixed into the file, grep under a UTF-8 locale decides the whole file "can't be processed as text" and skips it. As a result the marker can't be detected, and the same day's brief gets appended over and over.

With `LC_ALL=C`, comparison is byte-wise regardless of locale, so the marker is picked up reliably no matter what's in the file.

**③ The `grep -c` footgun in `probe_scout()`**

There's a part documented as a "known footgun" in the comments of `probe_scout()`.

```
# 注: grep -c は0件でも「0」を出力し exit 1 を返す → `|| echo 0` は二重出力(0\n0)を生み
#     整数比較を壊す既知footgun。grep -c 単体で十分なので付けない。
local items; items=$(grep -cE '^- ' "$f" 2>/dev/null); items=${items:-0}
```

`grep -c` outputs "the number of matched lines" to stdout, but when there are 0 matches it returns **exit 1**. What happens if you attach `|| echo 0` here? `grep -c` has already output "0" and then exits 1, and `|| echo 0` outputs "0" once more. The result is two lines, `"0\n0"`, in the variable, and an integer comparison like `[ "${items}" -lt 1 ]` dies with a `"value too great or invalid"` error.

The correct approach is `items=${items:-0}`, applying a default value only when the variable is empty. When `grep -c` outputs 0, that value is used as-is; only when grep itself fails (missing file, etc.) and garbage creeps in does 0 overwrite it.

On the morning of June 11, 2026, I opened the brief and the GitHub Scout field was completely blank. No error. It didn't say zero results either — the field itself was gone.

Investigating, the Scout crawl script that ran at dawn had, in the part where it calls the API with `claude -p` (pipe mode), kept waiting for a response with a dead socket and jammed with no timeout. The script itself terminated normally without ever noticing it had "failed," and wrote empty content to the output file.

Because the brief output content directly with `cat github-scout-latest.md`, an empty file meant an empty field. The judgment "no Scout field = Scout isn't running" was impossible in that state.

The `probe_scout()` function was born from this incident. It determines liveness with a four-layer approach: checking the output file exists, comparing mtime for whether it was updated today, checking the candidate line count against a minimum, and checking for the presence of a failure marker.

```
local mdate; mdate=$(date -r "$f" +%Y%m%d 2>/dev/null)
elif [ "$mdate" != "$DATE" ]; then
  echo "- ❌ **GitHub Scout**: 出力が当日更新でない(${mdate:-不明})。今朝の巡回が走ってない疑い"
  flag_red "GitHub Scout の出力が当日(${DATE})更新でない→巡回が落ちてる疑い"
```

If the mtime isn't today, it raises a red flag. If it emitted an empty file, zero candidate lines also raises a red flag. With this, "silent blanking" can never be missed again.

`jq` and `timeout` vanished from launchd's PATH
In the early days of developing `daily-brief.sh`, the script stopped partway every time it was launched from launchd. Running it manually from the terminal worked fine.

The difference is PATH. Running `echo $PATH` in the terminal puts `/opt/homebrew/bin` at the front. The shell PATH that launchd starts with only has `/usr/bin:/bin:/usr/sbin:/sbin`. `jq`, `timeout`, and `ccusage` installed via Homebrew aren't in there, so the script was dying with `jq: command not found`.

```
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
```

Putting this one line at the top of the script solved it. What matters is **prepending, not appending**. If you add to the tail of `$PATH`, it may look for `/usr/bin/jq` (which doesn't exist) first and fail to pick up the Homebrew version.

Bugs that only happen under launchd drag out the "run it manually from the terminal → it works → confusion" loop, so I made it a habit to suspect PATH problems first.

Right after implementing Desktop delivery of the brief, writes to `~/Desktop/Daily Brief/` failed with `permission denied`. The launchd plist was written to execute via `/bin/zsh`.

macOS TCC (Transparency, Consent, and Control) manages Full Disk Access per process. Even if you've granted FDA to `/bin/bash`, when a subprocess is launched via `/bin/zsh`, access won't go through unless zsh has FDA granted. Even if you've added `/bin/bash` as an FDA target in System Settings, it's meaningless if the plist says `/bin/zsh`.

``` php
<!-- plist の ProgramArguments（正しい書き方） -->
<array>
  <string>/bin/bash</string>
  <string>/Users/alice/.claude/scripts/daily-brief.sh</string>
</array>
```

Changing `/bin/zsh` to `/bin/bash` and re-granting FDA solved it. The reason this trap is recorded in the comments of `daily-brief.sh` is so that my future self, rebuilding the same configuration six months later, doesn't fall into the same hole.

In the part that outputs yesterday's improvement-log count, one day the brief suddenly stopped with an `integer expression expected` error.

```
YESTERDAY_COUNT=$(grep -cE "^## ${YESTERDAY} " ~/.claude/improvements/log.md 2>/dev/null || echo 0)
```

Japanese commit messages had gotten mixed into the previous day's log file, and invalid bytes had crept into a specific line of `improvements/log.md`. Because of that, grep judged the file to be binary and exited 1. `|| echo 0` output an additional "0", and `"0\n0"` landed in the variable. The subsequent `echo "- 昨日の改善エントリ: ${YESTERDAY_COUNT} 件"` runs peacefully, but a different spot that tried to compare this value numerically threw an error.

The fix had two stages. First, drop `|| echo 0` and switch to `${items:-0}` (the footgun countermeasure described above). Then remove the invalid bytes from `improvements/log.md` with `iconv -c`.

To prevent recurrence, I unified other `grep -c` uses such as `probe_affiliate_audit()` to the same pattern. The work of purging the `|| echo 0` idiom from the codebase took half a day. I was saved by the fact that I noticed via the brief's error message — had it broken silently, I'd never have noticed.

`tail -r` doesn't work on Linux
The `tail -r` used in the RECENT IMPROVEMENTS section of `cc-brief.sh` is a macOS-specific extension. Linux doesn't have it; the equivalent is GNU coreutils' `tac`.

```
tail -r "$HOME/.claude/improvements/log.md" 2>/dev/null | awk '...'
```

My dev environment is macOS, but when I tried to run some automation scripts in a lightweight Linux container, it died with `tail: invalid option -- r`. A test running brief generation in a CI environment failed for the same reason.

In the end I decided to accept it as a mac-only script, but writing `#!/usr/bin/env bash` at the top of a script while using a mac-specific feature like `tail -r` is a trap that "makes readers think it works on Linux too." I learned to spell it out in a comment.

When porting is necessary, you can achieve equivalent behavior with `awk '{lines[NR]=$0} END {for(i=NR;i>=1;i--) print lines[i]}'` instead of `tail -r`. However, it uses memory proportional to line count, so if the log is huge you should check for the existence of the `tac` command first.

Separate from the 5 sticking points explored in the first and middle sections, here's a consolidated list of pitfalls I actually hit in real operation. Read them as clues for not stepping on the same patterns.

**`ccusage` isn't found.** That's why line 1 of `cc-brief.sh` has a fallback for when `command -v ccusage` doesn't find it (specifying `$HOME/.nvm/versions/node/v24.13.0/bin/ccusage` directly). The UserPromptSubmit hook inherits the terminal's PATH, but depending on the timing the hook runs, nvm initialization may not have finished. The correct answer is a two-stage approach: try `command -v` first, and fall back to the absolute path on failure.

**cc-brief.sh's execution time exceeds 1 second and session startup drags.** The UserPromptSubmit hook runs synchronously. If the `claude mcp list` call takes time, session startup is delayed by that much. In environments where MCP connections are unstable, `mcp list` alone can take 2–3 seconds. I later wrapped it with `timeout 5 ...` so it gives up after 5 seconds.

**`jq '.enabledPlugins | length'` returns null and the script dies.** In configurations where `settings.json` doesn't have the `enabledPlugins` key at all, `jq` returns `null`, and passing `null` to `length` errors. A fix applying a default with `// []`, like `jq -r '(.enabledPlugins // []) | length'`, was needed.

**The important information wasn't in the first 30 lines of hot.md.** `cc-brief.sh` is designed to read only `head -30`. Because I wrote hot.md with an "append to the end" habit, the most critical incident information was on line 31 and beyond, and wasn't being injected. I changed hot.md's operating rule to "write the most important items at the top" to match `head -30`.

**The append order into MEMORY.md got reversed and memories became invisible.** `cc-brief.sh` reads the last 12 lines with `tail -12`. I'd built a tool that prepended new memories to the top, which left it in a state where only the oldest memories were injected every time. This design only functions if the index preserves the "append at the end = newest at the end" ordering.

**A Discord post times out and subsequent brief generation stops.** In an early version where I hadn't put a timeout on the `post_brief.py` call, the whole script jammed the moment it hit Discord's rate limit. Currently, beyond just ignoring it with `|| true`, I'm considering throwing it into the background with `python3 ... &`. Posting to Discord isn't the brief's main purpose but a byproduct, so it doesn't get the authority to stop the main line.

**`probe_autolike()`'s dummy-key string check broke.** When POSTing with the dummy key `HEALTHCHECK-DUMMY`, the Gumroad connection state is distinguished by the strings "server configuration error" vs. "license not found." After a refactor of the error wording returned by production code, this string changed and the determination inverted. The brief reported "connection normal" while actual license verification was broken. Liveness checks that depend on error wording require maintenance in lockstep with production code changes.

**You can't check the logs of `daily-brief.sh` itself being launched from launchd.** Until I added a plist setting redirecting `stderr` to `~/Library/Logs/com.shun.daily-brief.log`, errors when launched from launchd vanished. That resolved the mysterious inconsistency of "works when run manually from the terminal, doesn't work from launchd." The first step of launchd debugging is setting StandardErrorPath.

**`cut -c1-70` jams when a commit message contains emoji.** Emoji are 4-byte UTF-8 sequences, and mixing `cut -c` (character-based) with `cut -b` (byte-based) throws off the character-width calculation. What you thought was cut at 70 characters actually split an emoji in half, and the downstream `iconv -c` deleted it — a chain reaction. All the parts that put commit messages into variables are designed on the premise of `iconv -c` post-processing.

**The weekly aggregate's `jq '.weekly[-1]'` returns null on an out-of-range reference.** There's a moment right after the week begins (late Monday night) when the `weekly` array in `ccusage weekly --json` has 0 entries. `[-1]` is valid JSON syntax, but jq returns null for an empty array. Hitting `echo "$WEEK" | jq -r '.outputTokens'` in that state outputs null, which lands in the downstream variable and makes `jq -n '$n / 1000000 ...'` die with a type error. A fix that also checks for the null string, with `if [ -n "$WEEK" ] && [ "$WEEK" != "null" ]; then`, is needed.

Here are the practical rules distilled from six months of operation and dozens of sticking points.

**1. Make timeouts double-layered.** Limit the connection itself with `curl --max-time 15`, and limit the whole child script with `run_to 60`. A single timeout doesn't cover the case of "the connection dropped but the process remains." It's only complete once you include the forced SIGKILL of `--kill-after=15`.

**2. Line 1 of a launchd script is PATH supplementation.**

```
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
```

Prepend to the **front** of $PATH. If you append to the tail, it may look for `/usr/bin/jq` (which doesn't exist) first and fail to pick up the Homebrew version. Knowing this rule saves you from the maze of "works in the terminal, doesn't work in launchd."

**3. Launch `/bin/bash` directly in the plist, and grant FDA to that binary.** Going through `/bin/zsh` gets access to Desktop and Documents denied. Since TCC manages FDA per process binary, it's meaningless unless the executor and the grant target match.

**4. Put `LC_ALL=C` at the head of grep.** If even a single invalid byte is mixed into the file, grep under a UTF-8 locale treats the entire file as binary and skips it. Make all pattern matching such as marker detection and log line counting `LC_ALL=C`-prefixed.

**5. Don't write `|| echo 0` after `grep -c`.** `grep -c` outputs "0" to stdout even for 0 matches, then returns exit 1. `|| echo 0` outputs an additional "0", `"0\n0"` lands in the variable, and integer comparison breaks. The correct form is `count=$(grep -cE '...' file 2>/dev/null); count=${count:-0}`.

**6. Pipe variables containing multibyte characters through `iconv -f UTF-8 -t UTF-8 -c`.** Cutting a Japanese commit message mid-character with `cut -c70` leaves an invalid byte sequence. `iconv -c` silently removes those remnants. Call it by absolute path `/usr/bin/iconv` (a countermeasure for the PATH problem in the launchd environment).

**7. Embed a duplicate-append prevention marker in each file.** Write a marker like `<!-- daily-brief YYYYMMDD -->` and then check for its existence with grep. Because launchd can run multiple times on the same day (3 times: 8:00, 10:30, and at login), without this guard the same brief stacks up three times.

**8. Set StandardErrorPath in the plist to redirect error output to `~/Library/Logs/`.** Without this, debugging launchd-triggered runs is nearly impossible. 80% of the causes of "works in the terminal, doesn't work under launchd" become instantly clear from looking at this log.

**9. Let byproduct posts like Discord fail silently with `|| true`.** Express the priority — brief generation is primary, external posting is a byproduct — in code with `|| true`. Furthermore, give the posting function its own script-level timeout, managed separately from the main line's time limit.

**10. Make the absolute path the first candidate for the `claude` binary called in `cc-brief.sh`.** The UserPromptSubmit hook may run before nvm's shell function initialization has completed. A two-stage approach — try `$HOME/.nvm/versions/node/v24.13.0/bin/claude` first, fall back to `claude` on failure — works reliably.

**11. Concentrate the most important items in hot.md's first 30 lines.** Since `cc-brief.sh` reads only `head -30`, an append-to-the-end habit truncates the newest information. Operate hot.md's update rule as "insert new incidents at the top."

**12. Keep MEMORY.md's append order as 'append at the end = newest at the end'.** Because the design reads the tail with `tail -12`, a reversed append order means only the oldest memories get injected. When building a memory-append tool, fix this direction.

**13. Split output destinations into canonical, human-facing, and archive.** The canonical copy (`~/.claude/logs/daily-brief-latest.md`) is machine-facing for automation scripts to read, the Desktop delivery is for a human to check in the morning, and the Obsidian archive is for looking back — separate the roles. Because each is independent, a failed write in one place doesn't affect the others.

**14. Explicitly state that `tail -r` is macOS-only.** In GNU environments the equivalent is `tac`. Writing "this script is macOS-only (depends on `tail -r`)" in a comment at the top of the script lets you (or your future self) attempting a Linux port notice immediately.

**15. Hold the design philosophy that "the brief is a description of state, not a preemption of instructions."** Including a task list in the brief pulls Claude toward that list and narrows the room to re-discuss the day's true priorities. What goes into the brief is only "where am I now (the environment's current state)"; "what to do today" gets decided anew after each session starts.

`daily-brief.sh` and `cc-brief.sh` together come to under 200 lines. But combining these two solves, at the root, the structural problem of Claude Code starting from a blank slate every time.

For six months after being laid off back to zero, 30 minutes every morning was disappearing into "the round trip of explaining today's current state." The state of each of 10 repositories, the production status of 4 products, remaining API cost budget — if I checked all of that manually every time while relaying it to Claude Code, I'd have burned a substantial share of my energy at that point alone.

I automated the mechanism not "because I wanted efficiency." **It was so the first 30 minutes of every morning could be spent on judgment.** With the brief, Claude Code holds concrete state from the instant a session starts: "autolike's license API has been returning 500 since last night," "this week's API consumption has reached 80% of the ceiling," "commits stalled yesterday in hosei-grad-planner." The judgment of "where do I move from today?" finishes in seconds.

The breakdown of ¥1.2M/month revenue comes from multiple autonomized products running in parallel. What makes that parallel operation possible is the existence of the foundation: "every morning, the current state of everything is visible at a glance." Without the brief mechanism, I'd grasp only 3 of the 10 repositories each morning, and the remaining 7 would sit neglected because I couldn't pay the "cost of checking." Neglect is stagnation, and stagnation connects directly to a revenue ceiling.

Because I built this brief mechanism first, I've been able to keep turning the autonomous cycle I stacked up over the following six months — Scout discovers candidates, Codex implements, launchd runs every night, and the brief reports the results the next morning. The starting point of "a mechanism for building mechanisms" is right here.

What's the first thing *you* check every morning before you start working — and could a script check it for you instead?

I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.

📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
