📅 Claudeが予定を捏造しなくなった日 ― Google Calendar APIでground truthをVaultに注入する — リーダー×
📅

Claudeが予定を捏造しなくなった日 ― Google Calendar APIでground truthをVaultに注入する

#claudecode#automation#googlecalendar2026-07-14 · 約10

前回、会話ログから自動でスキルを書き溜める話を書きました。今回は同じ夜間バッチの中で見つかった、もっと地味で厄介な話です ―― 朝のdaily briefがカレンダーの日付範囲を誤読して、存在しないスケジュールを生成していた問題をどう潰したかです。

困りごと:「7/1〜7/15 インターン」が2週間の拘束に化ける

Vaultのノートに「7/1〜7/15 インターン」と書いてあると、これは開始日と終了日のです。ところがdaily brief生成用のプロンプトは、Vault内の散文ノートとClaude/Codexの会話ログだけを材料に予定を語らせていました。LLMはこの表記を見て「2週間ずっと拘束されている」と解釈し、その期間の毎日にタイムスケジュールを捏造して出力してきました。

原因は明白です。Vaultには「予定の実体(開始・終了時刻)」を持つデータソースがなく、全部が自然文からの推測でした。散文はそもそも構造化データではないので、LLMがどう読むかは運次第です。ここを直すには「ノートより優先される、機械的に正しい予定の正典」を作るしかありません。

これは典型的なground truthの欠如です。LLMに「正しく読み取れ」と念を押しても効きません。効いたのは、構造化データを別ソースから持ってきて「これが唯一の正典、散文より優先しろ」とプロンプトで明示することでした。

設計:gcal-snapshot.py で実体を取ってくる

~/.claude/scripts/gcal-snapshot.py は、Google Calendar APIから向こう21日分の予定を取ってMarkdownに変換するだけのスクリプトです。冒頭のdocstringに設計意図が書いてあります。

"""
Google カレンダー実体スナップショット生成(headless / launchd 可)。

- 認証: gcloud ADC(calendar.readonly スコープ)。MCP/TCC 不要 → 無人実行可。
- 個人アカウント配下の全カレンダーを自動列挙(新規追加も自動で拾う)。
- ノイズ除外: 外部委託のSNS投稿管理カレンダー(日次)・日本の祝日。
- 重複dedup: (タイトル+開始時刻) が同じイベントは1件に畳む(大学カレンダー=学籍の時間割重複対策)。
- 出力: stdout に Markdown 本文(イベント1件以上で正常終了0、0件/失敗は非ゼロ)。
  → 呼び出し側 bash が「成功時のみ正典差し替え・失敗時 last-good 温存」する。
"""

認証は google.auth.default() によるgcloud ADCです。当初は~/.claude/skills/auto/gcal-oauth-desktop-setupのOAuth Desktopフロー(InstalledAppFlow.run_local_serverでブラウザを開いてトークンを取る方式)を検討しましたが、これは初回にブラウザ認証が要る前提の作りで、無人launchd実行とは相性が悪いと判断してADCに切り替えました。ADCならgcloud auth application-default login --scopes=...で一度認証しておけば、以降はブラウザなしでバックグラウンド実行できます。

カレンダー列挙は固定IDのハードコードをやめ、calendarList().list()全カレンダーを自動列挙しています。

def get_service():
    import google.auth
    import googleapiclient.discovery as disc
    creds, _ = google.auth.default(
        scopes=["https://www.googleapis.com/auth/calendar.readonly"]
    )
    return disc.build("calendar", "v3", credentials=creds, cache_discovery=False)

ブロックリストは「これは除外する」という部分一致方式にして、新しいカレンダーが増えても載っていない限り自動採用されるようにしています。

BLOCK_CAL_SUBSTR = (
    "9ea30ef811a373ec8fe120f0633630c8685127f65fee2cb122c7b625ffdbd954",  # 外部委託SNS投稿管理(日次)
    "24d111d02338025b6a82ef07f6971ce30b3e0624c6620ee73d9f88c123adc47f",  # 外部委託SNS投稿管理(管理用)
    "holiday@group.v.calendar.google.com",                                # 日本の祝日
)

HORIZON_DAYS = 21。今日0時から21日先までを取得し、時間ブロック予定(dateTime持ち)と終日予定(date持ち、締切・期間)を分けて出力します。

JST = timezone(timedelta(hours=9))
HORIZON_DAYS = 21

dedupは「大学のカレンダーと学籍システムのカレンダーで同じ講義が二重登録されている」実害への対策です。キーは(タイトル, 開始時刻のISO文字列)

key = (title, s.isoformat())
if key in seen:
    continue
seen.add(key)

終日予定の期間表記も一手間かけています。Google CalendarのAPIでは終日イベントのend.dateは**排他的(翌日0時)**なので、そのまま表示すると終了日が1日ずれます。

if span.days <= 1:
    label = f"{s.month}/{s.day}"
else:
    last = e - timedelta(days=1)  # 終日endは翌日0時=排他
    label = f"{s.month}/{s.day}-{last.month}/{last.day}"

出力はイベント0件ならNO_EVENTSをstderrに吐いて非ゼロ終了(exit 4)、認証失敗はexit 2、カレンダー一覧取得失敗はexit 3。呼び出し側が成功/失敗を機械的に判定できるように、正常系と異常系のexit codeをきっちり分けています。

last-known-good:失敗時にVaultを空で潰さない

このスナップショットをvault-auto-ingest.shから呼び出すところが、実は一番気を遣った部分です。ADCのトークン失効やネットワーク不調で取得に失敗した時、正典ファイルを空や欠損で上書きしてしまうと、daily briefが「予定なし」と誤読するほうに倒れるからです。なので失敗時は前回成功時点の値(last-known-good)を温存します。

CAL_SNAPSHOT="$VAULT/wiki/_calendar-snapshot.md"
CAL_LASTGOOD="$CAL_SNAPSHOT.lastgood"
CAL_TMP="$(mktemp 2>/dev/null || echo "$CAL_SNAPSHOT.tmp.$$")"
CAL_SRC=""
# (1) 第一候補: Google Calendar API
run_to 90 "$PY3" "$HOME/.claude/scripts/gcal-snapshot.py" > "$CAL_TMP" 2>/dev/null \
  && /usr/bin/grep -q "•" "$CAL_TMP" 2>/dev/null && CAL_SRC="gcal-api"
# (2) フォールバック: icalBuddy(ローカル Apple Calendar。対話実行時のみ権限あり)
if [ -z "$CAL_SRC" ]; then
  ICALBUDDY=""
  for c in /opt/homebrew/bin/icalBuddy /usr/local/bin/icalBuddy; do [ -x "$c" ] && ICALBUDDY="$c" && break; done
  if [ -n "$ICALBUDDY" ]; then
    { echo "# カレンダー実体スナップショット(今後21日・自動生成)"
      echo "_generated $(date '+%F %T') by vault-auto-ingest.sh / icalBuddy(fallback)_"
      echo ""
      run_to 60 "$ICALBUDDY" -n -nc -iep "title,datetime,location" -po "datetime,title" eventsToday+21
    } > "$CAL_TMP" 2>/dev/null
    /usr/bin/grep -q "•" "$CAL_TMP" 2>/dev/null && CAL_SRC="icalbuddy"
  fi
fi

が1行でも含まれているかどうかを成功判定に使っているのがポイントです。exit codeだけでなく内容そのものに空判定を仕込むことで、「exit 0だけど中身が空」というすり抜けを防いでいます。

成功時のみ正典と.lastgoodを両方更新し、両方失敗した時だけ前回goodをstale印つきで復元します。

if [ -n "$CAL_SRC" ]; then
  cp "$CAL_TMP" "$CAL_SNAPSHOT" 2>/dev/null
  cp "$CAL_SNAPSHOT" "$CAL_LASTGOOD" 2>/dev/null
elif [ -s "$CAL_LASTGOOD" ]; then
  { echo "# カレンダー実体スナップショット(今後21日・自動生成)"
    echo "_generated $(date '+%F %T') by vault-auto-ingest.sh_"
    echo "⚠️ 本日カレンダー取得失敗(ADC未認証/権限不足の可能性)。以下は前回取得成功時点の値(stale)。"
    echo ""
    tail -n +4 "$CAL_LASTGOOD"
  } > "$CAL_SNAPSHOT" 2>/dev/null
else
  { echo "# カレンダー実体スナップショット(今後21日・自動生成)"; echo "(取得失敗・前回値なし)"; } > "$CAL_SNAPSHOT" 2>/dev/null
fi

stale印は累積させず、その回のヘッダに1回だけ付け直す作りです。3日連続で失敗しても「⚠️⚠️⚠️」にはなりません。

フォールバック順は Google Calendar API → icalBuddy(ローカルApple Calendar)です。icalBuddyはTCC権限がGUIセッションでしか通らないことが多く、無人launchd実行では素通りしがちですが、それでも構成としては残してあります。主力と保険の両方を持ちつつ、主力優先で切り替えるという構造そのものが、この節の学びです。

daily briefへの注入:ノートの散文より優先しろ

正典ファイルができたら、あとはプロンプトに1行足すだけです。ここが実は一番効いています。

CAL_SNAPSHOT_NOTE="予定・締切は ${CAL_SNAPSHOT}(カレンダー実体・時刻付き)を唯一の正典スケジュールとして読み、ノートの散文より優先しろ。冒頭に⚠️staleとあれば取得日時点の値である点を断れ。"

これをdaily brief生成プロンプトの末尾に埋め込み、さらに日付誤読そのものを名指しで禁止しています。

cd "$VAULT" && run_to 900 "$CLAUDE" --strict-mcp-config --mcp-config '{"mcpServers":{}}' -p \
"...【厳守】ノートに無い情報を推測で足すな。特に予定の拘束時間・所要日数・『日中が消える』等の時間コストは、
実際の予定記述が無い限り書くな。ノート中の『M/D〜M/D』は開始と終了の点であって全期間拘束ではない
——拘束日数や所要時間を捏造するな。日付・時間に関する主張は確定情報と推測を明示的に分けろ。
$CAL_SNAPSHOT_NOTE 出力は $VAULT/wiki/today-brief.md に毎回上書き..." \
  --dangerously-skip-permissions >> "$LOG" 2>&1

ポイントは2段構えになっていることです。

  1. 禁止の明文化:「M/D〜M/D は開始と終了の点であって全期間拘束ではない」と、まさに事故った誤読パターンをそのまま名指しで書く
  2. 正典の提示:それでも判断に迷う場面のために、時刻付きの構造化データを「散文より優先」で読める場所に置く

1だけだとLLMの気分次第で再発しますし、2だけだと「散文とどっちを信じるか」の判断基準がないまま両方読ませることになります。禁止事項の明文化と、判断に迷った時の拠り所を両方揃えるのがここでの学びでした。

launchd側:複数スロット発火で取りこぼしを拾う

com.shun.vault-auto-ingest.plistは1日4回、StartCalendarIntervalで発火します。

<key>StartCalendarInterval</key>
<array>
    <dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>55</integer></dict>
    <dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>20</integer></dict>
    <dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>45</integer></dict>
    <dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>15</integer></dict>
</array>

4回のうち、その日最初に成功したスロットがDONE_MARKERを立て、以降は即終了します。カレンダー取得はこの一連の処理の1ステップ(2.45)にすぎませんが、launchdの複数スロット構造にそのまま乗っているので、朝一で取得失敗しても8:20・10:45・12:15のどこかで拾い直せます。取得ステップ自体にはrun_to 90(90秒タイムアウト)を被せてあり、ADCトークン失効などで詰まっても他の工程を巻き込みません。

踏んだ落とし穴

  • OAuth Desktopフローは無人launchdと相性が悪い → ブラウザ不要のgcloud ADCに切り替え。初回のみgcloud auth application-default loginで認証すれば以降はheadlessで通る
  • 固定カレンダーIDのハードコードは新規カレンダーを取りこぼすcalendarList().list()で自動列挙し、ブロックリスト方式(明示除外のみ)に反転
  • 終日イベントのend.dateは排他的(翌日0時) → そのまま表示すると終了日が1日ずれる。timedelta(days=1)を引いて補正
  • 大学カレンダーと学籍システムで同一講義が二重登録(タイトル, 開始時刻)キーでdedup
  • exit codeだけの成功判定は「0件だけど成功扱い」をすり抜ける → 出力にが含まれるかを内容ベースでチェック
  • 取得失敗時に正典を空で上書きすると「予定なし」に誤読される → last-known-good温存+stale印(累積させない)
  • 「散文より優先しろ」だけでは既知の誤読パターンが再発しうる → 事故った具体的な誤読(M/D〜M/Dの全期間拘束化)をプロンプトに名指しで禁止事項として書く

まとめ

  • LLMに「正しく読め」と念を押すだけでは、構造化されていない散文の日付誤読は防げない
  • Google Calendar API(gcloud ADC)で予定の実体を取り、Vaultに_calendar-snapshot.mdとして正典化する
  • 正典データはlast-known-goodで守る。取得失敗を空上書きに倒さない
  • プロンプト側は**禁止事項の明文化(誤読パターンを名指し)正典の提示(散文より優先)**を両方揃える
  • launchdの複数スロット発火にそのまま乗せれば、取得失敗もその日のうちに拾い直せる

次回は、このvault-auto-ingest.sh自体がVaultを太らせる問題 ―― hot.md 416KB / 個別記事176KBまで肥大した実害と、機械的にページを分割・圧縮する仕組みの話を書きます。


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

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