0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🗺 自動化が育ちすぎた日のための「環境マップ」を毎朝作る

0
Posted at

月10万のバイト掛け持ちから始めて、60万になって、会社都合で解雇されてゼロになって、半年かけてClaude Code自律環境を組み直したら月商120万になりました。今日は「自動化が増えすぎると何が動いていて何が止まっているか誰も把握できなくなる」という問題と、私がどう解決したかを書きます。


なぜこの仕組みが効くのか

「増えていく」ことの本当の問題

自動化はじわじわ育ちます。最初は1本のlaunchdジョブ、次に2本、気づいたら26本。

私のMacには現在 com.shun.*.plist が26本稼働しています。vault-ingestが毎朝4:55に動き、daily briefが7:30に走り、github-scoutが定期で回り、その上に今回の主役であるenv-mapが4:50と8:10の2回発火する——これだけでも10本近い。残りはMetricsの集計、自動コミッタ、SNS系の定期処理です。

問題は「増えること」ではなく「どれが生きていてどれが死んでいるかわからなくなること」です。

launchdはプロセスが落ちても黙って再起動します。エラーログは ~/.claude/logs/ に吐かれますが、複数のplistが同じディレクトリに書き込んでいるので流れていきます。「なんかMetricsの数字が更新されていないな」と気づくまでに2日かかることもある。問題の検知が遅れるのは、地図がないからです。

PCを管理するのではなく、環境を読む

ここで重要な切り分けがあります。「作業ログを見る」のと「環境を読む」は違います。

作業ログは「昨日何をやったか」の記録です。環境マップは「今この瞬間、何が動いていて何が存在していて何が死んでいるか」のスナップショットです。私が欲しかったのは後者でした。

具体的にいうと——Obsidianを開いたとき、その朝のMacの状態が1ページで読める状態にしたかった。Claude環境に47のプラグインと358のエージェントと112のauto-skillが入っていること、プロジェクトが11本あってそのうち2本はディスクに存在しない(未検出)こと、lead-finderには75件の未コミット変更が積まれていること。これを毎朝コマンドを打って確認するのではなく、Obsidianを開けばそこにある状態にしたかった。

Obsidian vaultのgit差分が「変化の記録」になる

もう一つの仕掛けがあります。私のvaultは毎朝vault-ingestがgitコミットを作ります。environment-map.mdも毎日上書きされるので、昨日は「clean」だったlead-finderが今日「✎75」になっていれば、gitの差分でそれが見えます。

意図的にやったわけではなく、「Obsidianのgit管理はもともとあった」「environment-map.mdはそのvaultに書き出される」という2つの事実が合わさった結果として、変化の記録が自動でできる構造になっていました。これが思ったより強力で、「あの日どんな環境だったか」を後から追えます。


全体の流れ

3層アーキテクチャ

env-map.shは環境をPC層・Claude層・プロジェクト層の3層に分けてMermaid図と表を生成し、Obsidian vaultの wiki/meta/environment-map.md に書き出します。

┌─────────────────────────────────────────────────────────┐
│                    launchd スケジューラ                   │
│  com.shun.env-map.plist                                  │
│  → 04:50 / 08:10 の2回発火                              │
│  → LowPriorityIO=true / Nice=10(バックグラウンド優先度) │
└───────────────────┬─────────────────────────────────────┘
                    │ /bin/bash ~/.claude/scripts/env-map.sh
                    ▼
┌─────────────────────────────────────────────────────────┐
│                   env-map.sh                             │
│                                                          │
│  ① PC 環境収集                                           │
│     sw_vers / sysctl / df / CLI在否チェック              │
│                                                          │
│  ② Claude 環境収集                                       │
│     ~/.claude/settings.json から plugin数・hook数        │
│     find でskill/agent数カウント                         │
│     ls ~/Library/LaunchAgents/com.shun.*.plist で件数   │
│                                                          │
│  ③ プロジェクト環境収集                                   │
│     11 repos × git branch/log/status                    │
│                                                          │
│  ④ Mermaid図 + 表を $TMP に生成                          │
│     → mv $TMP $OUT(アトミック書き込み)                  │
└───────────────────┬─────────────────────────────────────┘
                    │
                    ▼
    ~/Documents/claude-obsidian/wiki/meta/environment-map.md
                    │
                    ▼
    vault-ingest(別ジョブ)がgitコミット
    → 差分 = 前日からの変化記録

スクリプトの冒頭設計:なぜ set -e を使わないか

# env-map.sh 冒頭
set -uo pipefail

set -e ではなく set -uo pipefail を使っています。理由はコメントに明記されています——「何が起きても生成を完走させる(個別の収集失敗は ? で degrade)」。

launchdで早朝に走るスクリプトでは、MCP接続が不安定だったり、あるプロジェクトのgitリポジトリが壊れていたり、ネットワーク依存のコマンドがタイムアウトしたりします。1つのコマンドが失敗したからといってスクリプト全体を止めてしまうと、その日のマップが生成されません。失敗した箇所を ? に差し替えて、残りは正常に出力する——これが degrade 設計です。

実際にMCP接続数の取得はこう書かれています:

MCP_OK="?"
if have claude; then
  _mcp="$(timeout 12 claude mcp list 2>/dev/null)"
  [ -n "$_mcp" ] && MCP_OK="$(printf '%s' "$_mcp" | grep -c 'Connected')"
fi

まず ? で初期化し、claude コマンドが存在すれば12秒のタイムアウト付きで試行、接続できた数だけ上書きする。接続できなければ ? のままMermaid図に出力されます。今日の実際の生成物でも MCP connected | ? になっていて、この経路が使われたことがわかります。

launchdの環境問題とPATH明示解決

launchdは起動時のPATHが非常に貧しいです。GUI経由で起動したターミナルのPATHとは別物で、nvmで管理しているnodeも、Homebrewのツールも入っていません。

plistのEnvironmentVariables:

<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>

しかしスクリプト側でも追加の工夫があります:

NVM_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:${NVM_BIN:+$NVM_BIN:}/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

plistのPATHにはnodeのバージョンが v24.13.0 とハードコードされています。バージョンを上げたらplistを書き直さなければいけない。それを防ぐためスクリプト側で ls -d ... | sort -V | tail -1 で最新版のbinディレクトリを動的に解決し、PATHを上書きします。plistは定数的な初期値、スクリプトは動的な補正——二重に保険をかけています。

Mermaidラベルのサニタイズ

Mermaid図はラベルに特殊文字が入ると図全体が壊れます。プロジェクト名にカッコが入っていたり、ブランチ名にスラッシュが入っていたりするので、次のサニタイズ関数を1行で定義しています:

san() { printf '%s' "$1" | tr '"[]()#|<>' '        ' | tr '\n' ' ' | sed 's/  */ /g; s/ *$//'; }

"[]()#|<> の9文字を空白に置換し、改行を空白に潰し、連続する空白を1つに圧縮します。プロジェクト名「就活ナビ(SEO)」はMermaid上では「就活ナビ SEO 」になります。実際の生成物を見ると p9["就活ナビ SEO<br/>main · 2026-07-02 · ✎1"] となっていて、カッコが消えています。

プロジェクト状態の収集

11本のプロジェクトのメタ情報は proj_meta() 関数で取ります:

proj_meta() {
  local path="$1"
  PROJ_EXISTS=0; PROJ_BRANCH="-"; PROJ_LAST="-"; PROJ_DIRTY=0
  [ -d "$path" ] || return
  PROJ_EXISTS=1
  if git -C "$path" rev-parse --git-dir >/dev/null 2>&1; then
    PROJ_BRANCH="$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '-')"
    PROJ_LAST="$(git -C "$path" log -1 --format=%cd --date=format:%Y-%m-%d 2>/dev/null || echo '-')"
    PROJ_DIRTY="$(git -C "$path" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
  fi
}

ディレクトリが存在しない → PROJ_EXISTS=0 でMermaidに「(未検出)」と赤ノードで描画。存在してもgitリポジトリでない → branch/last/dirty はハイフンのまま。gitリポジトリなら branch・最終コミット日・未コミット件数を取得。

今日の実際の出力では:

  • lead-finder: ✎75(75件の未コミット変更)
  • Closet OS / AETHERIA: 未検出(赤ノード)
  • 法政卒業プランナー / claude-obsidian fork: clean

これが毎朝8:10に自動更新されます。

アトミック書き込みと完走保証

生成は一度 $TMP="$(mktemp)" に全内容を書いてから mv "$TMP" "$OUT" で上書きします:

} > "$TMP"

mkdir -p "$(dirname "$OUT")"
mv "$TMP" "$OUT"
echo "[$(date '+%F %T')] environment-map.md generated ($(wc -l < "$OUT" | tr -d ' ') lines)"

Obsidianがファイルを読み込み中に書き込みが走っても、mv は原子操作なので中間状態が見えません。mkdir -p で出力先ディレクトリを保証し、最後に行数とタイムスタンプをログに出力。このログが ~/.claude/logs/env-map.launchd.log に書き出されるので、cronが実際に動いたかどうかをログで確認できます。

実際に生成されたマップの数字

今朝(2026-08-02 08:10:05)生成された環境マップの数字をそのまま引用します:

PC環境

  • ホスト: MacBook Pro(Apple M5 / 24GB RAM)
  • macOS 27.0 / ディスク: 49Gi free / 926Gi
  • 主要CLI: node, python3, gh, claude, codex, ffmpeg, jq, git, docker, vercel, uv(11本)

Claude環境

項目 値
Plugin skills 3367
Auto skills(自己生成) 112
Agents 358
Enabled plugins 47
Hook events 7
launchd jobs 26

プロジェクト環境(11 repos)

  • 未検出: 2本(Closet OS / AETHERIA)
  • 未コミット変更あり: 7本
  • clean: 2本

これが毎朝Obsidianを開いたとき、最初のページに載っています。


実装の詳細

出力ブロックの構造:heredocと動的生成の混在

スクリプト全体の出力は { ... } > "$TMP" の1ブロックで包まれています。Bashの出力リダイレクトをブロックに適用することで、cat・echo・ループ・関数呼び出しを何度切り替えても、すべて同じ1ファイルへ流れます。

{
cat <<HEADER
---
type: meta
title: "環境マップ(自動生成)"
updated: $ISO
...
HEADER

# CLI ノードを動的に追加
i=0
for c in $CLI_LIST; do
  echo "  CLI --> cli$i[\"$c\"]"
  i=$((i+1))
done

cat <<'PCT'
項目 値
PCT

} > "$TMP"


ポイントは2種類のheredocを使い分けている点です。`<<HEADER`(クォートなし)はシェル変数が展開されるので、`$ISO` や `$OS_VER` をそのまま書けます。一方、`<<'PCT'`(シングルクォート付き)は展開を抑制します。Markdownのバックティックやパイプ記号をそのまま書きたい箇所——たとえばコードブロックの開始/終了行——では後者が必要です。最初これを統一して`<<HEADER`で書いたら、Mermaid図のバッククォートがbash変数として解釈されて構文エラーになりました。

### plistの3つの優先度設定が意味すること

plistには3行、ほぼ「静かに走れ」という設定が並んでいます。

```xml
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>

LowPriorityIO はディスクI/Oの優先度を下げます。4:50はvault-ingest(4:55)の直前で、Obsidianのsyncが動いていることもある。I/O帯域を食い合わないための設定です。Nice=10 はCPU優先度を下げます。通常プロセスのNice=0より低く、バックグラウンド専用。ProcessType=Background はmacOSのQoSクラスをBackgroundに設定し、省電力状態のMacを起こさないようにします。この3つをセットで設定することで「他の何も邪魔しない」動作になります。

また RunAtLoad: false にしているのは意図的です。launchctl load した瞬間に実行されると、plistのPATH設定が効いているか、スクリプトの依存ツールが揃っているかを確認できません。手動で launchctl kickstart gui/$(id -u)/com.shun.env-map を打って一度試してから、スケジュールに任せるという手順を踏むためです。

ログが1本で済む理由

plistの標準出力と標準エラー出力は、どちらも同じファイルに向けています。

<key>StandardErrorPath</key>
<string>~/.claude/logs/env-map.launchd.log</string>
<key>StandardOutPath</key>
<string>~/.claude/logs/env-map.launchd.log</string>

スクリプトの最後には次の行があります。

echo "[$(date '+%F %T')] environment-map.md generated ($(wc -l < "$OUT" | tr -d ' ') lines)"

今日のログを見ると [2026-08-02 08:10:05] environment-map.md generated (148 lines) と記録されています。このログ1行で「いつ動いたか」と「何行生成したか」が確認できます。エラーが出た場合も同じファイルに入るので、tail ~/.claude/logs/env-map.launchd.log を見るだけで状態がわかります。wc -l の後に tr -d ' ' を挟んでいるのは、macOSのwcが先頭にスペースを付ける(例: 148)からです。これを除去しないとMermaidノードのラベルに 148 jobs と入って気持ち悪くなります。

join_nodes()が必要になった理由

Mermaidで複数ノードに同じクラスを適用するには class p1,p2,p5 dirty; とカンマ区切りで書く必要があります。個別に class p1 dirty; class p2 dirty; と書くことも文法上は正しいのですが、件数が多いと行数が増え、生成された図のソースが読みにくくなります。それよりも問題なのは、Mermaidパーサーによっては個別の複数class指定で挙動が不安定になるケースがあったことです。スペース区切りの文字列をカンマ区切りに整形する join_nodes() で一本化しました。

join_nodes() { printf '%s' "$1" | sed 's/^ *//; s/ *$//; s/  */ /g; s/ /,/g'; }
[ -n "$DIRTY_NODES" ] && echo "  class $(join_nodes "$DIRTY_NODES") dirty;"
[ -n "$GONE_NODES" ]  && echo "  class $(join_nodes "$GONE_NODES") gone;"

先頭/末尾の空白を除去し、連続空白を1つに正規化してからカンマに変換します。DIRTY_NODES は "$DIRTY_NODES $nid" と追加するたびに先頭スペースが生じるので、除去処理が必要です。今日の実際の出力では class p1,p3,p4,p6,p7,p8,p9 dirty; と7ノードがきれいに並んでいます。

auto skillのカウントにgrepが要る理由

auto skillのカウント行は少し特殊です。

AUTO_SKILLS="$(ls "$HOME/.claude/skills/auto/" 2>/dev/null | grep -vc README)"

ls でディレクトリ一覧を取って grep -vc README でREADMEを除外しています。~/.claude/skills/auto/ 配下にはスキル名のサブディレクトリと、管理用の README.md が共存しています。単純に ls | wc -l すると README.md を1件として数えてしまい、実数より1多くなります。今日の値は112ですが、これはREADMEを除いた正確な数です。ちなみに grep -v README ではなく -vc(カウントモード)を使うことで、grepの行数カウントとwcを合わせたパイプを1コマンドに省略しています。


私が詰まった話

ここからは実際に詰まったことを書きます。「なんか動いていない」から「なぜ動いていないか」を特定するのに時間がかかった話ばかりです。

詰まり①:MCP接続でスクリプトが4分以上フリーズしていた

症状:Obsidianを朝開いたらマップが昨日のままでした。ログを見たら env-map.launchd.log が空。スクリプトが何も出力していない。

原因:当初、MCP接続数の取得を素の claude mcp list で行っていました。このコマンド、MCP接続が不安定なとき(具体的にはMCPサーバーがタイムアウト待ちをしているとき)、応答を返さずに4分以上ブロックします。早朝4:50に走るスクリプトがMCPの応答待ちで止まり、結果として出力ゼロで終わっていました。

直し方:timeout 12 で強制打ち切りを追加しました。

MCP_OK="?"
if have claude; then
  _mcp="$(timeout 12 claude mcp list 2>/dev/null)"
  [ -n "$_mcp" ] && MCP_OK="$(printf '%s' "$_mcp" | grep -c 'Connected')"
fi

12秒以内に応答がなければ MCP_OK は ? のままMermaid図に出力されます。今日の生成物でも MCP connected | ? になっていて、この朝は接続が不安定だったことがわかります。「?」は「取得失敗」ではなく「このタイミングでは不確定」という意味として扱っています。MCPが死んでいてもマップは生成される、これが優先です。

詰まり②:プロジェクト名のカッコでMermaid図が全滅した

症状:Obsidianでマップページを開いたら、図の部分が全て「Parse error: Expecting 'CloseBrace'」になっていました。テキストの表部分は正常に表示されていたので、Mermaidのブロックだけが壊れているとすぐわかりました。

原因:PROJECTSリストに "就活ナビ(SEO)|..." を追加した直後から発生していました。Mermaidのノードラベル構文では () が特別な意味を持ちます(スタジアム形状ノードを表す記法)。就活ナビ(SEO) を p9["就活ナビ(SEO)<br/>..."] とそのまま埋め込むと、パーサーが (SEO) をネストした形状指定として解釈しようとして壊れます。

直し方:san() 関数で () を含む9文字を空白に変換する処理を追加しました。

san() { printf '%s' "$1" | tr '"[]()#|<>' '        ' | tr '\n' ' ' | sed 's/  */ /g; s/ *$//'; }

tr のターゲット文字列 '"[]()#|<>' に含まれる9文字は、実際にMermaidで壊れたか、壊れそうだと判断した文字のセットです。# はMermaidのコメント、<> はHTMLタグ、| はエッジラベル構文と衝突します。今日の生成物では p9["就活ナビ SEO<br/>main · 2026-07-02 · ✎1"] となっていて、カッコが消えて安全なラベルになっています。

詰まり③:nodeをアップグレードしたらPATHが死んだ

症状:マップの「主要CLI」欄から node が消えて、Claude環境の Agents 数が 0 になっていました。nodeに依存するスクリプトが全滅している状態です。

原因:plistの EnvironmentVariables に書かれたPATHは v24.13.0 のパスをハードコードしています。

<string>.../.nvm/versions/node/v24.13.0/bin:...</string>

nvmで v25.x.x に上げた後、このパスは存在しないディレクトリを指していました。launchdは起動時のPATHしか参照しないので、node コマンドが見つからなくなります。

直し方:スクリプト側でnvmの最新版バイナリを動的に解決し、PATHを上書きする処理を入れました。

NVM_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:${NVM_BIN:+$NVM_BIN:}/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

sort -V はバージョン文字列のソートで、sort だと v9 より v10 が前に来てしまいます。tail -1 で最新版のbinディレクトリを取得します。${NVM_BIN:+$NVM_BIN:} は NVM_BIN が空のとき : だけが残らないようにするBashの変数展開です。plistのPATHは「launchdが自前ツールを何も見つけられない最悪ケースへの初期値」として残していますが、スクリプト内で動的に上書きされるので、plistのバージョン番号が古くても動きます。

詰まり④:set -e でマップが途中で切れていた

症状:Obsidianでマップを開いたらプロジェクト環境の表が途中で終わっていました。11本あるはずのプロジェクトが4行目で切れていて、その後Mermaidの終端タグも閉じられていない。

原因:当初 set -eo pipefail を使っていました。あるプロジェクトのgitリポジトリが破損していて git rev-parse --git-dir がexit 1を返しました。set -e 環境ではこの時点でスクリプトが即終了します。出力ブロック { ... } > "$TMP" の途中で終了するので $TMP への書き込みは中断され、mv "$TMP" "$OUT" に到達しません。結果として前回の $OUT(昨日のマップ)がそのまま残ります——いや、この場合はもっと悪くて、$TMP が中途半端な状態で残り、次の起動で再び問題が起きました。

直し方:

set -uo pipefail

-e を外しました。未定義変数の参照(-u)とパイプの失敗(-o pipefail)は引き続き検知しつつ、個々のコマンドのexit 1は || echo '-' や || echo '?' で明示的に処理します。proj_meta() 内の各コマンドはすべて || echo '-' が付いているか、グローバル変数を-で初期化してから上書きする設計になっています。壊れたgitリポジトリがあっても、そのノードだけ branch=-, last=- として描画され、残りは正常に生成されます。

詰まり⑤:vault-ingestと競合して古いマップがコミットされていた

症状:gitの差分を見ると、今日のenvironment-map.mdのコミット内容が「昨日と全く同じ」でした。変化があったはずの lead-finder の未コミット件数が更新されていません。

原因:最初、env-mapの発火時刻を4:55に設定していました。vault-ingestも4:55で、かつこちらが先に起動することがあります。vault-ingestがgitコミットを走らせる時点でenvironment-map.mdの生成が完了していないと、前日の内容がコミットされます。Macはウォームブートのたびにジョブの起動順序が微妙に変わるため、どちらが先に動くかが非決定的でした。

直し方:env-mapを4:50(vault-ingestの5分前)に前倒しました。plistの StartCalendarInterval に2つのエントリがあるのはそのためです。

<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Hour</key><integer>4</integer>
    <key>Minute</key><integer>50</integer>
  </dict>
  <dict>
    <key>Hour</key><integer>8</integer>
    <key>Minute</key><integer>10</integer>
  </dict>
</array>

4:50に生成し、4:55にvault-ingestがコミット、8:10に朝起きた後の状態を再収集する——という3ポイントのスケジュールが今の設計です。5分の余裕は env-map.sh の最大実行時間(MCP timeout 12秒込みで通常30秒以内)を考慮した上で十分なバッファです。実際、今朝のログには [2026-08-02 04:50:31] environment-map.md generated (148 lines) と記録されていて、vault-ingestの4:55コミットより25秒早く完了しています。


以上5つが実際に遭遇した詰まりです。「launchdで早朝に走る」という制約ひとつで、通常のスクリプト開発では出会わないような問題——PATH、タイムアウト、Mermaid構文、原子性、競合順序——が一気に降ってきます。ひとつひとつ潰すたびにスクリプトが少しずつ頑丈になって、今の形になっています。


つまずきポイント

前節で5つの大きな詰まりを掘り下げました。ここでは「それほど派手ではないが、確実に踏む地雷」を箇条書きで網羅します。早朝バッチ×launchd×Mermaid×Obsidianという組み合わせは、それぞれ単体では問題にならない仕様が交差して初めて壊れるパターンが多いです。

  • macOSのwc -lは先頭にスペースを付ける。 148 のように出力されるため、Mermaidのノードラベルに直接埋め込むと 148 jobs という見た目になります。スクリプト内で行数を使う箇所はすべて wc -l | tr -d ' ' をセットで使う癖をつけてください。env-map.sh のコードを見ると wc -l | tr -d ' ' が5箇所以上登場しています。

  • grep -vcの使い方。auto skillのカウント ls ... | grep -vc README は「READMEを含まない行を数える」ワンライナーです。grep -v README | wc -l と書くと2コマンドになりますが、grep -vcでカウントモードと除外フラグを同時指定すれば1コマンドで済みます。落とし穴は「ファイルが0件のときgrep -cはexit 0を返す(0を出力)」が、grep(マッチなし)の場合はexit 1を返す点。フラグの使い方を誤るとパイプのexit statusが変わり、pipefailと組み合わせて予期せずスクリプトが止まります。

  • ${NVM_BIN:+$NVM_BIN:}の展開を知らないとPATHにコロンが二重になる。単純に $NVM_BIN: と書くと、NVM_BINが空文字のとき /usr/local/bin の前に : だけが残ります。:/usr/local/bin は「カレントディレクトリを優先する」の意味になり、意図しない実行ファイルが呼ばれる原因になります。${NVM_BIN:+$NVM_BIN:} という書き方は「NVM_BINが非空のときだけ 値: を展開する」Bashの変数展開です。launchdスクリプトでPATHを動的に組み立てるときは必ずこの形式を使ってください。

  • StartCalendarIntervalの複数エントリは<array>で包まないと1つ目しか登録されない。plistに複数の発火時刻を設定するとき、<dict>を並べるだけでは2つ目以降が無視されます。必ず <array> でくるんだうえで <dict> を並べてください。com.shun.env-map.plistでも4:50と8:10の2エントリが<array>の中に入っています。launchctl print gui/$(id -u)/com.shun.env-map で登録内容を確認したとき、両方の時刻が表示されるかを必ず確認してください。

  • RunAtLoad: falseを忘れるとlaunchctl loadの瞬間に実行される。初回ロード時に実行されるのは、PATHやスクリプトの依存ツールが揃っているかを確認する前です。com.shun.env-map.plistは明示的に<false/>にしていて、ロード後は launchctl kickstart gui/$(id -u)/com.shun.env-map で手動実行して動作を確認してから、スケジュールに任せる手順を踏んでいます。「plistをロードしたら即実行されてエラーが出た」という状況で原因がわからなくなるのはこのフラグの理解不足が多いです。

  • ログファイルが無制限に成長する。StandardOutPathとStandardErrorPathに指定したファイルは、launchdが自動でローテーションしません。env-map.shは1回の実行でログに1行しか書かないので成長は遅いですが、エラーが頻発すると~/.claude/logs/env-map.launchd.logが数MBに育つことがあります。月1で tail -100 ~/.claude/logs/env-map.launchd.log > /tmp/log.tmp && mv /tmp/log.tmp ~/.claude/logs/env-map.launchd.log のようなトリムをlaunchdジョブとして走らせるか、newsyslogの設定に加えてください。

  • MermaidのclassDefはノード定義より前に書くとObsidianで効かない。Mermaidのパーサー仕様上、classDefはグラフ定義の末尾に置くことを推奨されています。スクリプト側でプロジェクトノードのループ後にclassDef dirtyとclassDef goneを出力しているのは意図的です。順序を入れ替えると一部のObsidianバージョンで色が適用されません。

  • sort -Vをsortにするとv9 > v10になる。NVMのバイナリパスを ls -d ... | sort | tail -1 で解決しようとすると、v9.x.xがv10.x.xより後ろに並んでしまいます(文字列ソートでは9 > 1)。sort -Vはバージョン文字列専用のソートモードで、数値の大小を正しく評価します。nodeを上げるたびにplistを書き直さなくて済む動的解決の肝がここです。

  • mktempの一時ファイルがSIGKILLで残留する。通常の終了なら mv "$TMP" "$OUT" で一時ファイルは移動されますが、launchdがジョブを強制終了した場合(タイムアウトやリソース超過)、$TMP が /tmp/ 以下に残留します。Macは再起動で/tmpが消えますが、長期連続起動環境では /tmp/tmp.XXXXXX が蓄積します。trap 'rm -f "$TMP"' EXIT をスクリプト冒頭に追加しておけば、どんな終了経路でも一時ファイルが消えます。

  • heredocのクォートを混同する。<<HEADER(クォートなし)はシェル変数が展開されます。<<'HEADER'(シングルクォートあり)は展開を抑制します。Mermaidのバックティック(コードブロック開始)やパイプ記号を生出力したい箇所はクォートあり、$ISOや$OS_VERを埋め込みたい箇所はクォートなし——この2種類を意識せずに統一しようとすると、どちらかが壊れます。env-map.shでは<<HEADERと<<'PCT'の2種類が使い分けられていて、この使い分けが出力の整合性を保っています。

  • plistのLabelとファイル名を一致させないとlaunchctl listで見つけにくい。Labelは com.shun.env-map、ファイル名は com.shun.env-map.plist——このルールが崩れると launchctl list | grep com.shun で網羅的に一覧できなくなります。26本のlaunchdジョブを管理するうえで、Label体系の一貫性は必須です。

  • ProcessType=BackgroundでApp Napが発動しネットワーク応答が遅くなる。macOSはバックグラウンドプロセスのCPUとネットワーク割り当てを積極的に絞ります。早朝4:50に走るMCP接続がよりタイムアウトしやすい原因の一つがこれです。timeout 12で打ち切る設計と、?でdegradeする設計の組み合わせがないと、この影響がマップ生成の失敗として出てきます。

  • Obsidianのvaultをgit管理しているとき、auto-generatedファイルを.gitignoreに入れてしまう。environment-map.mdは「自動生成だからgit管理不要」と思って.gitignoreに追加すると、差分で前日との変化が見えなくなります。このファイルをgitで追跡することが「変化の記録」として機能する仕掛けなので、.gitignoreには絶対に追加してはいけません。


ベストプラクティス

ここまでの実装と詰まりから、launchdで早朝バッチを組むときに普遍的に使えるプラクティスを整理します。

① degradeデザインを徹底する

「1つのコマンドが失敗しても全体は完走する」設計が基本です。set -eを外し、各収集コマンドを ? で初期化してから試行するパターンを全コマンドで統一してください。MCP接続数の取得方法がその典型例です。外部依存があるコマンドはすべてこの型で書くと、どんな環境変化にも耐えるスクリプトになります。

② 一時ファイル経由のアトミック書き込みを使う

出力ファイルに直接リダイレクトすると、書き込み途中にObsidianが読み込んで壊れたファイルを参照します。mktempで一時ファイルに全内容を書いてから mv で上書きする設計が正しいです。mvはPOSIX仕様で同一ファイルシステム上であれば原子操作です。trap 'rm -f "$TMP"' EXIT とセットで使ってください。

③ PATHは二重に保険をかける

plistのEnvironmentVariablesに初期PATHを書き、スクリプト冒頭で動的に上書きします。plistはlaunchdが自前ツールを全く見つけられない最悪ケースへの保険、スクリプトはバージョン昇格に追随するための保険です。どちらか一方だけでは「plistが古くなったときに死ぬ」または「launchd起動直後の極小PATHで死ぬ」リスクが残ります。

④ 外部コマンドは必ずtimeoutで囲む

MCPのように応答が不安定なコマンドは、タイムアウトなしで呼ぶとスクリプト全体がブロックします。応答時間の上限をコマンドの性質に合わせて設定してください。env-map.shでは claude mcp list に12秒を設定しています。「12秒以内に答えられないなら今回は諦める」という割り切りが、早朝バッチの安定性を作っています。

⑤ ログの最終行で「完走証跡」を残す

スクリプトの末尾に echo "[$(date '+%F %T')] generated ($(wc -l < "$OUT" | tr -d ' ') lines)" の形式で行数とタイムスタンプを出力してください。「今日動いたか」を確認するとき、ログの末尾を見るだけで確認できます。行数が極端に少ない(たとえば10行以下)場合は生成が途中で終わったサインです。単純なテキスト1行で、監視コストをゼロに近づけられます。

⑥ Mermaidサニタイズ関数を最初に定義する

プロジェクト名にカッコ・シャープ・パイプが入ることは避けられません。スクリプト冒頭で san() 関数を定義して、全てのユーザー入力をこの関数を通してからMermaidに埋め込む設計を徹底してください。サニタイズをしない場所が1箇所でも残ると、その文字列がMermaid全体を壊します。

⑦ RunAtLoad: falseにして初回は必ず手動実行する

新しいplistをロードするときは RunAtLoad: false にしておき、launchctl kickstart gui/$(id -u)/com.shun.<name> で手動実行してログを確認してからスケジュールに任せてください。自動実行で初回が走った場合、エラーが出てもタイミングによってはログに残らないことがあります。初回確認を手動にするだけで「なんで動かないのか」の調査時間が大幅に短くなります。

⑧ LowPriorityIO + Nice + ProcessType の3点セットで「静かに走る」を保証する

早朝バッチは他の処理を邪魔しないことが最優先です。3つのplist設定を必ずセットで入れてください。LowPriorityIO: true(ディスクI/O優先度下げ)、Nice: 10(CPU優先度下げ)、ProcessType: Background(macOS QoSをBackground設定)——どれか一つ欠けても、メインの作業と競合してMacが一時的に重くなります。

⑨ 依存ジョブより発火時刻を前倒す

複数のlaunchdジョブが連携するとき、「AがBより先に完了していること」を保証するにはAの発火時刻をBより早めるしかありません。5分程度の余裕を持たせた上で、初回は両方のログを並べてタイムスタンプを確認してください。env-map.shが4:50に発火し、vault-ingestが4:55にコミットする構成で、ログには [2026-08-02 04:50:31] generated と vault-ingest コミット時刻の差が25秒あったことが記録されています。「なんとなく前倒し」ではなく、実測した余裕に基づいて設定することが重要です。

⑩ 出力先をvaultのgit管理下に置いて変化記録を自動化する

スクリプトの出力ファイルをgit管理されたディレクトリに置くと、毎日のコミットが「環境の変化ログ」になります。infrastructure as code の考え方をスクリプト出力にも適用するイメージです。「昨日まではcleanだったlead-finderが今日75件になっている」という変化をgit diffで追えるのは、この配置があってこそです。ログ専用ディレクトリより、Obsidian vaultのようにすでにgit管理されている場所を活用する方が、追加コストゼロで強力な変化追跡が手に入ります。

⑪ バージョン比較にはsort -Vを使う

nvmのバイナリ、npmのパッケージ、あらゆるバージョン文字列のソートにsortは使わないでください。v9 → v10の順で並べたいなら必ずsort -V(バージョン対応ソート)を使います。tail -1で最新版を取るという読み方はsort -Vが前提です。

⑫ stdoutとstderrを同一ログファイルへ向ける

plistのStandardOutPathとStandardErrorPathを同じファイルに設定してください。2つに分けると、「スクリプトがエラーで止まったがstdoutログには何も出ていない」という見落としが発生します。同一ファイルにすれば tail -f ~/.claude/logs/env-map.launchd.log の1コマンドで全体の状況が見えます。

⑬ プロジェクトリストの形式を"表示名|パス"で統一する

PROJECTS配列のエントリは "表示名|絶対パス" という形式で統一してください。表示名とパスを別変数に分けると配列の管理が煩雑になります。entry%%|*とentry##*|のbash変数展開でパース1行でできます。プロジェクトを追加・削除するときの変更がこの配列1箇所で済む設計です。

⑭ launchctl list | grep com.shunで全ジョブを一覧する習慣をつける

26本のジョブが稼働している状況では「全部で何本あるか」を把握することが管理の基本です。Label命名を com.shun.* で統一しているので、このgrepで自分のジョブだけを抽出できます。environment-map.mdのlaunchd jobs: 26という数字は ls ~/Library/LaunchAgents/com.shun.*.plist | wc -l の実測値です。plistがディスクにあっても launchctl list に出ていないものはロードされていない——この差を定期的に確認することで、「登録したつもりが実は動いていなかった」を早期発見できます。


まとめ

env-map.sh と com.shun.env-map.plist は、200行足らずのBashスクリプトと45行のplistで構成されています。でも今朝(2026-08-02 08:10:05)生成された wiki/meta/environment-map.md には、MacBook Pro(Apple M5 / 24GB RAM)の構成、26本のlaunchdジョブ、47のプラグイン、358のエージェント、112のauto-skill、そして11本のプロジェクトの状態——lead-finderに75件の未コミット変更があること、Closet OSとAETHERIAがディスクに存在しないこと——が148行のMermaid図と表として記録されています。

Obsidianを開けばその日の構成が一覧でき、gitの差分が前日との変化を記録する。仕組みとしてはシンプルです。でも自動化が26本になってから初めて「地図がないと何が生きているかわからない」という問題が顕在化します。私がこの仕組みを作ったのは「管理したい」からではなく「管理しなくても済む状態にしたい」からです。

月商120万の自律環境の根幹は、稼ぐ仕組みより「稼ぐ仕組みを動かし続ける仕組み」にあります。launchdジョブが増えるほど、その土台の重要性が増します。env-mapは26本のジョブの現在地を毎朝Obsidianに書き出すことで、私がターミナルを開かなくてもその日の環境状態を把握できる状態にしてくれています。

「自動化が育ちすぎた」と感じたタイミングが、地図を作る最適なタイミングです。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?