はじめに(初出と、この記事で扱う範囲)
Claude Code に書かせている社内文書の「日付(曜日)」を全数で数えると、264件中50件で曜日が暦とずれていました。指示文に「日付と曜日は正確に」と足しました。それでも、2月から8月まで直りませんでした。止まったのは、Python の検算スクリプトを Claude Code のフック3段で挟んでからです。
初出は、2026-08-21 に会社ブログへ書いた経営者向けの記事「AIの書く曜日は、約2割ずれていた」です。Qiita 版は、同じ出来事を手順・コード・ログ中心に書き直したものです。
対象読者は、Claude Code や LLM に TODO・計画書・送信文面を書かせているエンジニアです。3段の構成は次のとおりです。
| 段 | 見るタイミング | 仕組み | 不一致のとき |
|---|---|---|---|
| 1段目 | Write・Edit・MultiEdit の直後 | PostToolUse フック | exit 2 で stderr を Claude に返し、再編集させる |
| 2段目 | 毎朝 | 構造 Lint の S8 が --scan を呼ぶ |
1行1件で出力し exit 1 |
| 3段目 | Gmail・Slack の送信ツールの直前 | PreToolUse フック | exit 2 で送信を止める |
3段とも同じスクリプト check-date-weekday.py を呼びます。スクリプトは約120行で、Python の標準ライブラリだけで動きます。
事故: 日付は合っていたのに曜日だけズレた
2026年2月2日(月)に、同じ週の2月6日(金)に開く300人規模の報告会の案内メールを送りました。下書きは AI が書きました。開催日の日付は正しく、曜日だけが「木」になっていました。送信前に人も確認しましたが、誤りは通過しました。同日夕方に訂正メールを全員へ送りました。関係先にも謝罪しています。後で検算を仕組みにした動機は、この1通です。
全数調査: 264件中50件がズレていた
2026年8月21日(金)に、社内の全文書から「日付(曜日)」の表記を全数で抜き出しました。264件のうち50件が暦と一致せず、割合は18.9%でした。
抽出には次の正規表現を1本だけ使います。年の有無、区切りの - / 月、括弧の全角・半角、「曜」「曜日」の有無をまとめて拾います。
PAT = re.compile(
r"(?:(?P<y>20\d{2})[-/年])?(?P<m>\d{1,2})[-/月](?P<d>\d{1,2})日?"
r"\s*[((](?P<w>[月火水木金土日])(?:曜日?)?[))]"
)
この1本で 2026-08-22(金) も 8/22(金) も 8月22日(金曜日) も当たります。件数の分母は PAT に当たった表記の数で、分子はそのうち datetime.date の曜日と合わなかった数です。次のコードは、同じ数え方を手元で再現するための例です。
# 数え直し用の例。分母 = PAT に当たった数、分子 = 暦と合わない数
import datetime
import pathlib
import re
import sys
WD = "月火水木金土日"
PAT = re.compile(
r"(?:(?P<y>20\d{2})[-/年])?(?P<m>\d{1,2})[-/月](?P<d>\d{1,2})日?"
r"\s*[((](?P<w>[月火水木金土日])(?:曜日?)?[))]"
)
year = datetime.date.today().year # 年の省略は今年とみなす
total = bad = 0
for path in pathlib.Path(sys.argv[1]).rglob("*.md"):
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
for m in PAT.finditer(text):
try:
dt = datetime.date(int(m.group("y") or year), int(m.group("m")), int(m.group("d")))
except ValueError: # 存在しない月日は数えない
continue
total += 1
bad += WD[dt.weekday()] != m.group("w")
print(f"{bad}/{total} = {bad / total:.1%}")
このコードで再現できるのは数え方です。元データは社内文書なので公開しません。
本記事の数字は 2026年に実行した結果で、年省略の判定は実行年に依存します。
修正の基準は「日付を正、曜日を従」にしました。基準を決めないと、日付と曜日のどちらが本当の予定かを1件ずつ判断することになります。
この基準で48件を一括で直しました。残りの2件は引用した原文なので、誤りの記録として残しています。
調査では、誤記がコピーで増えていたことも分かりました。ある決裁文書に入った 8/22(金) が、TODO・計画書・送信文面に転記されていました。Claude は筆者への報告でも、同じ曜日を使っていました。転記先は5箇所以上です。1件の誤記を書いた時点で止めないと、直す箇所は後から増えます。
なぜ「注意して」では直らなかったか
少なくとも今回の運用では、LLM が曜日を暦と照合して書いている形跡はありませんでした。出力を見る限り、前後の文脈からそれらしい曜日を補っていました。
そのため、指示文で注意を促しても、暦との照合は始まりません。実際に、2月から8月まで、指示文に「日付と曜日は正確に」と追記して運用していました。8月21日の全数調査の結果は、上のとおり264件中50件のずれです。
一方で、曜日は日付から計算で決まります。Python なら datetime.date(2026, 8, 22).weekday() の1行で曜日が出ます。同じ入力には、毎回同じ答えが返ります。そこで曜日の判定を AI から外し、決定的なコードに任せることにしました。判定を挟む場所は、Claude がファイルを書いた瞬間と、メールやチャットを送る直前です。
1段目: Write/Edit フックで書いた瞬間に差し戻す
1段目は、Claude がファイルを書いた直後にファイル全体を検算し、ずれがあれば Claude に差し戻します。配線は .claude/settings.json の PostToolUse です。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 ~/Claude/scripts/check-date-weekday.py --hook",
"timeout": 15,
"statusMessage": "日付と曜日の整合を検査中"
}
]
}
]
}
}
matcher の Write|Edit|MultiEdit は、ファイルを書く3つのツールに当たります。この実装でファイル全体を検算するのは Write と Edit の2つです。MultiEdit は、この節の後半で書くとおり tool_input の JSON を検査します。timeout は15秒です。フックの入力は stdin に JSON で届き、tool_name と tool_input が入っています。PostToolUse では tool_response も届きますが、このスクリプトは使いません。
# 抜粋: hook() の前半と終わり。送信系の分岐は3段目で載せます
def hook():
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
year = datetime.date.today().year
tool = data.get("tool_name", "")
tool_input = data.get("tool_input", {}) or {}
if tool in ("Write", "Edit"):
fp = tool_input.get("file_path", "")
path = pathlib.Path(fp)
if path.suffix not in TEXT_EXT or not path.is_file():
return 0
try:
hits = check_text(path.read_text(encoding="utf-8"), year)
except (OSError, UnicodeDecodeError):
return 0
label = fp
else: # 送信系ツール(3段目)
...
if not hits:
return 0
print(
"🔴 曜日ずれ検出(日付を正として曜日を直すこと。修正するまで送信・完了しない):\n"
+ "\n".join(fmt(hits, label)),
file=sys.stderr,
)
return 2
Write と Edit のときは、file_path のファイルを読み直して全体を検査します。差分だけでなく全体を見るので、前からあった誤記も、そのファイルを触った時点で出てきます。対象の拡張子は .md .txt .html の3つです。
JSON が壊れているときや読めないファイルは、止めずに exit 0 で通します。
不一致があると、stderr にメッセージを出して exit 2 で終わります。Claude Code は、フックが exit 2 で終わると stderr を Claude に返します。PostToolUse の時点で書き込みは済んでいるので、差し戻しは保存を止める動きではありません。Claude がメッセージを読んで曜日を直し、もう一度書く動きです。
なお、MultiEdit は tool in ("Write", "Edit") の分岐に入っていません。MultiEdit のときは送信系と同じ側に落ち、tool_input の JSON を検査します。
判定の本体は check_text です。
def check_text(text, default_year):
"""テキストを検査し (行番号, 該当表記, 正しい曜日, 年明示あり) のリストを返す"""
hits = []
in_fence = False
for lineno, line in enumerate(text.splitlines(), 1):
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
spans = [(m.start(), m.end()) for m in CODE_SPAN.finditer(line)]
for m in PAT.finditer(line):
if any(s <= m.start() and m.end() <= e for s, e in spans):
continue
y = int(m.group("y") or default_year)
try:
dt = datetime.date(y, int(m.group("m")), int(m.group("d")))
except ValueError:
continue
actual = WD[dt.weekday()]
if actual != m.group("w"):
hits.append((lineno, m.group(0), actual, bool(m.group("y"))))
return hits
check_text は、行頭が ``` の行でフェンスの内外を切り替え、内側の行を飛ばします。インラインコードは CODE_SPAN = re.compile(r"`[^`]*`") で範囲を取ります。範囲の中に収まる一致は捨てます。コードの中を検査しない理由は、例示として書いた誤記を検知しないためです。誤りの記録として残す引用原文も、コードで囲めば修正の対象から外れます。
年が書かれていない表記は、default_year で年を補って判定します。hook() も scan() も、default_year に実行時の今年を渡します。年を補った指摘には、fmt() が「※年は2026と仮定」の注記を付けます。この仕様は来年の予定を誤検知するので、限界としてまとめで書きます。
datetime.date が ValueError を出す日付は、指摘せずに飛ばします。存在しない月日を拾っても、フック自体は例外で落ちません。
導入後も、この段の検知は続いています。10月2日(金)には、Claude が書いた手順書の曜日ずれを PostToolUse フックが検出しました。Claude はその場で曜日を直しています。
2段目: 毎朝の全文書 Lint
2段目は、毎朝の構造 Lint が全文書を --scan で検査し、1段目を通らなかった誤記を拾います。1段目のフックが見るのは、Claude がツールで書いたファイルだけです。人が書いた文書や、フックを入れる前からある文書は、この段で拾います。
2段目は Claude Code のフックではないので、settings.json には配線がありません。毎朝の構造 Lint は、check-workspace-structure.sh というシェルスクリプトです。Lint の S8 という検査項目が、次の1行を呼びます。
python3 ~/Claude/scripts/check-date-weekday.py --scan <root>
配置先は ~/Claude/scripts/ に統一しています。読者は自分の置き場に読み替えてください。
--scan の中身は scan() です。
def scan(root):
year = datetime.date.today().year
out = []
for path in sorted(root.rglob("*.md")):
s = str(path)
if "/.git/" in s or "/node_modules/" in s or "/claude-global/" in s or "/_archive/" in s:
continue
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
out += fmt(check_text(text, year), str(path.relative_to(root)))
print("\n".join(out))
return 1 if out else 0
root 配下の .md を全部読み、.git や _archive などのディレクトリは飛ばします。--scan が読むのは .md だけです。フックが対象にする .txt .html は読みません。判定は1段目と同じ check_text で、指摘は1行1件で標準出力に出ます。指摘が1件でもあれば exit 1、なければ exit 0 です。今回使う PreToolUse では、exit 2 はツール呼び出しのブロックになります。PostToolUse では、exit 2 は Claude へのフィードバックになります。2 を返すのは --hook の側だけです。
出力は次の形です。2026-10-02 に実行した結果から5行を抜き、パスを伏せています。
[.../03_TODO.md:34] S8 曜日ずれ: 「2/4(木)」→ 実際は(水) ※年は2026と仮定
[.../03_TODO.md:56] S8 曜日ずれ: 「1/7(木)」→ 実際は(水) ※年は2026と仮定
[.../03_TODO.md:57] S8 曜日ずれ: 「1/28(木)」→ 実際は(水) ※年は2026と仮定
[.../05_LOG.md:59] S8 曜日ずれ: 「1/28(木)」→ 実際は(水) ※年は2026と仮定
[.../05_LOG.md:59] S8 曜日ずれ: 「2/2(火)」→ 実際は(月) ※年は2026と仮定
1行に「ファイル:行番号」「書かれていた表記」「暦の曜日」が並びます。どの行も年を省略した表記なので、「※年は2026と仮定」の注記が付いています。年を2027として照合し直すと、5行とも曜日が合います。来年の予定を今年として判定した誤検知の疑いがあり、まとめの限界で扱います。
3段目: 送信直前フック
3段目は、Gmail と Slack の送信系ツールが呼ばれる直前に本文を検算し、ずれがあれば送信を止めます。配線は PreToolUse で、matcher に MCP のツール名を列挙します。
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__claude_ai_Gmail__send_message|mcp__claude_ai_Gmail__reply|mcp__claude_ai_Gmail__forward|mcp__claude_ai_Gmail__create_draft|mcp__claude_ai_Gmail__update_draft|mcp__claude_ai_Slack__slack_send_message|mcp__claude_ai_Slack__slack_send_message_draft|mcp__claude_ai_Slack__slack_schedule_message",
"hooks": [
{
"type": "command",
"command": "python3 ~/Claude/scripts/check-date-weekday.py --hook",
"timeout": 15,
"statusMessage": "送信文面の日付と曜日を検査中"
}
]
}
]
}
}
列挙しているのは Gmail の5種と Slack の3種です。送信・返信・転送に加えて、下書きの作成と更新、予約送信も対象に入れています。スクリプトは1段目と同じで、--hook の中で tool_name を見て分岐します。MCP のツール名は環境で異なるので、/hooks の画面やデバッグログで実際の tool_name を確認してから列挙します。
else: # 送信系ツール: 本文全体を検査
hits = check_text(json.dumps(tool_input, ensure_ascii=False), year)
label = tool
送信系では、tool_input を丸ごと JSON 文字列にして check_text に渡します。件名・本文・宛先のキー名がツールごとに違っても、全体を1本の文字列として検査できます。ensure_ascii=False を付けないと日本語が \uXXXX に変わり、PAT の「月」「金」などに当たらなくなります。
不一致のときは、1段目と同じく stderr にメッセージを出して exit 2 で終わります。PreToolUse で exit 2 を返すと、Claude Code はツールの呼び出しを止めます。stderr の内容は Claude に返ります。メールや Slack は送られず、Claude が曜日を直してから呼び直します。
余談です。初出ブログの下書きを書いた日には、下書き自身が例示の 8/22(金) でフックに差し戻されました。この記事では、誤記の例をすべてインラインコードかコードブロックに入れています。
自分の環境に入れるときの変更点は3つです。1つ目は PAT を自分の日付表記に合わせることです。2つ目は TEXT_EXT と scan() の除外ディレクトリです。3つ目は settings.json の matcher です。
スクリプトの全文は次のとおりです。
check-date-weekday.py 全文(約120行)
#!/usr/bin/env python3
# check-date-weekday.py — 「日付(曜日)」表記の曜日が実際の暦と一致するか検証する
#
# 背景: 2026-08-21 制定。LLMは曜日を計算せず書くため、放置すると約2割ずれる
# (初回全数調査: 264件中50件が不一致)。誤りはコピーで増殖するので、
# ①毎朝の構造Lint(S8)と ②Write/Edit・メール送信時のフックの二段で検知する。
#
# 使い方:
# --scan <root> : 配下の全mdを検査し、不一致を1行1件で出力(指摘ありなら exit 1)
# --hook : Claude Code のフックとして stdin のJSONを検査
# (PostToolUse: Write/Edit したファイル / PreToolUse: 送信系ツールの本文)
# 不一致があれば stderr にメッセージを出して exit 2(Claudeに差し戻る)
#
# 除外: バッククォート内・フェンスコードブロック(台帳原文などの引用は「誤りの記録」であり修正対象外)
# 年の扱い: 年の明示がない日付は「今年」と仮定して検証し、その旨を注記する
import datetime
import json
import pathlib
import re
import sys
WD = "月火水木金土日"
PAT = re.compile(
r"(?:(?P<y>20\d{2})[-/年])?(?P<m>\d{1,2})[-/月](?P<d>\d{1,2})日?"
r"\s*[((](?P<w>[月火水木金土日])(?:曜日?)?[))]"
)
CODE_SPAN = re.compile(r"`[^`]*`")
TEXT_EXT = {".md", ".txt", ".html"}
def check_text(text, default_year):
"""テキストを検査し (行番号, 該当表記, 正しい曜日, 年明示あり) のリストを返す"""
hits = []
in_fence = False
for lineno, line in enumerate(text.splitlines(), 1):
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
spans = [(m.start(), m.end()) for m in CODE_SPAN.finditer(line)]
for m in PAT.finditer(line):
if any(s <= m.start() and m.end() <= e for s, e in spans):
continue
y = int(m.group("y") or default_year)
try:
dt = datetime.date(y, int(m.group("m")), int(m.group("d")))
except ValueError:
continue
actual = WD[dt.weekday()]
if actual != m.group("w"):
hits.append((lineno, m.group(0), actual, bool(m.group("y"))))
return hits
def fmt(hits, label):
lines = []
for lineno, frag, actual, has_year in hits:
note = "" if has_year else f" ※年は{datetime.date.today().year}と仮定"
lines.append(f"[{label}:{lineno}] S8 曜日ずれ: 「{frag}」→ 実際は({actual}){note}")
return lines
def scan(root):
year = datetime.date.today().year
out = []
for path in sorted(root.rglob("*.md")):
s = str(path)
if "/.git/" in s or "/node_modules/" in s or "/claude-global/" in s or "/_archive/" in s:
continue
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
out += fmt(check_text(text, year), str(path.relative_to(root)))
print("\n".join(out))
return 1 if out else 0
def hook():
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
year = datetime.date.today().year
tool = data.get("tool_name", "")
tool_input = data.get("tool_input", {}) or {}
if tool in ("Write", "Edit"):
fp = tool_input.get("file_path", "")
path = pathlib.Path(fp)
if path.suffix not in TEXT_EXT or not path.is_file():
return 0
try:
hits = check_text(path.read_text(encoding="utf-8"), year)
except (OSError, UnicodeDecodeError):
return 0
label = fp
else: # 送信系ツール: 本文全体を検査
hits = check_text(json.dumps(tool_input, ensure_ascii=False), year)
label = tool
if not hits:
return 0
print(
"🔴 曜日ずれ検出(日付を正として曜日を直すこと。修正するまで送信・完了しない):\n"
+ "\n".join(fmt(hits, label)),
file=sys.stderr,
)
return 2
if __name__ == "__main__":
if "--hook" in sys.argv:
sys.exit(hook())
if "--scan" in sys.argv:
i = sys.argv.index("--scan")
sys.exit(scan(pathlib.Path(sys.argv[i + 1])))
print("usage: check-date-weekday.py --scan <root> | --hook", file=sys.stderr)
sys.exit(64)
まとめ: 計算できる判定はコードに、計算できない判定は判定 AI に
社内文書の曜日は264件中50件ずれていて、指示文の「注意して」では直りませんでした。check-date-weekday.py を、書いた瞬間・毎朝・送信直前の3段で挟みました。挟んだ後は、ずれが書いた瞬間に止まります。
限界
| 区分 | 件数 | 読み |
|---|---|---|
| 年明示 | 2 | 2月の訂正メール記録に残した誤りの原文。記録として正しい |
| 年省略・2027年で照合すると合う | 16 | 来年の予定の誤検知の疑い |
| 年省略・その他 | 17 | 実際のずれか別形式。未確認 |
| 合計 | 35 | 2026-10-02 の --scan 出力 |
年を省略した表記を今年と仮定するため、来年の予定を誤検知します。2026-10-02 に全体を --scan すると35件が出ました。内訳は年の省略が33件、年の明示が2件です。年を明示した2件は、2月の訂正メールの記録に残した誤りの原文です。記録としては、誤りのまま残すのが正しい表記です。
年を省略した33件のうち、M/D(曜) 形式の23件を2027年で照合すると16件が合いました。年省略の33件のうち、少なくとも16件は来年の予定を今年として判定した誤検知の疑いがあります。残りは実際のずれか別の形式で、まだ確認できていません。
対策の候補は、年の省略時に今年と来年の両方で照合し、どちらかが合えば通す方式です。私の見立てでは、この方式には逆向きの見逃しがあります。今年の予定に来年の曜日を書いた誤記は、来年の照合で合ってしまい通ります。
1段目の PostToolUse フックは、Claude がファイル編集ツールを使った直後しか見ません。人が書いた文書や過去のファイルは、毎朝の --scan まで残ります。その --scan の出力には、上の年省略の誤検知が混ざります。PAT に合わない書き方、たとえば括弧のない 8月22日 金曜 も検査されません。
第1本(Jev)との住み分け
第1本では、フック3本に判定専用AI「Jev」を挟みました。記事は Claude Code の「完了しました」を信用しない です。Jev に聞いたのは「完了を主張しているか」のような、コードで計算できない判断です。曜日は暦から計算できるので、判定を AI に渡さず datetime で決めます。計算できる判定は決定的なコードに、計算できない判定は判定 AI に任せます。どちらの判定も、フックで必ず挟む点は同じです。
1本だけ足すなら、1段目の形を勧めます。PostToolUse の Write|Edit|MultiEdit に、検算スクリプトを登録する形です。10月2日(金)にも、この段が手順書の曜日ずれを書いた瞬間に止めています。
ノンズ株式会社(SES・Web 開発・AI 導入支援)の野々下が書きました。