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?

AIに「曜日も注意して」と書いても、264件中50件はズレた。Claude Code のフックで検算を挟んだ話

0
Last updated at Posted at 2026-10-02

はじめに(初出と、この記事で扱う範囲)

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 導入支援)の野々下が書きました。

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?