From f5899b8f97312a0f76bd0be0930ca2f34fee5f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 15:17:34 +0900 Subject: [PATCH 01/10] =?UTF-8?q?StopFailure=20=E3=81=AE=20hook=20?= =?UTF-8?q?=E3=81=A7=E5=88=A9=E7=94=A8=E4=B8=8A=E9=99=90=E3=81=AE=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E3=82=92=E3=81=9D=E3=81=AE=E5=A0=B4=E3=81=A7=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=83=BB=E9=80=9A=E7=9F=A5=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?#468=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/record-stop-failure.py が StopFailure の入力を読み、~/.claude/usage-limit-stops.jsonl に 1 行追記して通知する(last_assistant_message と cwd の生の値は書かない)。回帰テスト(変異 8 件)を check-all.py と CI に登録し、設定の手順を docs/usage/usage-limit-stop-hook.md に置いた。 Fixes #468 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/plugins.yml | 4 + CLAUDE.md | 4 + docs/plans/issue-468.md | 106 ++++++++++++++ docs/plugins/dev-loop-cruise.md | 7 +- docs/usage/usage-limit-stop-hook.md | 81 +++++++++++ mkdocs.yml | 1 + scripts/check-all.py | 1 + scripts/record-stop-failure.py | 124 ++++++++++++++++ scripts/test-record-stop-failure.py | 211 ++++++++++++++++++++++++++++ 9 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 docs/plans/issue-468.md create mode 100644 docs/usage/usage-limit-stop-hook.md create mode 100644 scripts/record-stop-failure.py create mode 100644 scripts/test-record-stop-failure.py diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index f1492f6b..ec8edb58 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -111,6 +111,10 @@ jobs: - name: 利用上限の後の継続の計数のテスト(#454) run: python3 scripts/test-count-usage-limit-resumes.py + # 本体は CI から呼ばない(書く先が利用者の ~/.claude にある。hook として登録して使う)。テストだけ回す。 + - name: 利用上限の停止を記録する hook のテスト(#468) + run: python3 scripts/test-record-stop-failure.py + # 本体は CI から呼べない(claude を起こして課金する)。テストだけ回す。 - name: dev-loop の Eval の実行と要約のテスト(#190) run: python3 scripts/test-run-dev-loop-evals.py diff --git a/CLAUDE.md b/CLAUDE.md index 2f902f5a..80a0ded7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,10 @@ Claude Code のセットアップガイドを mkdocs で構築・公開するプ **PR・Issue に貼るなら `--anonymize`**。**何を数え、何を守らないかは docstring を正とする**) - `test-count-usage-limit-resumes.py` - 上の回帰テスト(変異テストを含む。**本体は CI から 呼ばないがテストは回す**) + - `record-stop-failure.py` - **`StopFailure` の hook**。利用上限などで止まったその場で `~/.claude/usage-limit-stops.jsonl` に + 1 行残して通知する(#468。**CI からは呼ばない**——書く先が利用者の `~/.claude` にある。**登録は利用者が行う**—— + 手順は `docs/usage/usage-limit-stop-hook.md`。**何を書き、何を守らないかは docstring を正とする**) + - `test-record-stop-failure.py` - 上の回帰テスト(変異テストを含む。一時 `HOME` と偽の `osascript` で回す) - `run-dev-loop-evals.py` - `dev-loop` の Eval を**陽性対照と一緒に 1 コマンドで回し、貼れる要約を出す** (#190。**CI からは呼ばない**——`claude` を起こして課金する。下の「振る舞いを Eval で当てる」。 **対照の定義と、何を守り何を守らないかは docstring を正とする**) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md new file mode 100644 index 00000000..3dbc7807 --- /dev/null +++ b/docs/plans/issue-468.md @@ -0,0 +1,106 @@ +# Issue #468 — StopFailure の hook で利用上限の停止をその場で記録・通知する + +#456(自動継続の陽性対照)は上限を狙って起こせないので、自然に当たるのを待つしかない。 +いまは利用者が気づくことに頼っており、気づかなければ記録は埋もれ、人間が「継続」と打てば陽性対照にならない。 +`StopFailure` の hook で、止まったその場で手元のファイルに 1 行残し、通知を出す。 + +## 変更範囲 + +触る: + +- `scripts/record-stop-failure.py` — 新規。hook の本体(stdin の JSON → `~/.claude/usage-limit-stops.jsonl` に 1 行・通知) +- `scripts/test-record-stop-failure.py` — 新規。回帰テスト(変異テストを含む。偽の stdin・一時 `HOME`・偽の `osascript`) +- `scripts/check-all.py` — `TESTS` に登録 +- `.github/workflows/plugins.yml` — テストのステップを登録 +- `docs/usage/usage-limit-stop-hook.md` — 新規。設定の手順 +- `docs/plugins/dev-loop-cruise.md` — 「2. 環境を点検する」の節から指す +- `mkdocs.yml` — nav に足す +- `CLAUDE.md` — `scripts/` の一覧に 2 行 +- `docs/plans/issue-468.md` — この計画 + +触らない: + +- `plugins/` 配下(`check-unattended-env.py` に hook の点検を足すのは Issue に無い。版も上げない) +- 利用者の settings(リポジトリから書き換えない——Issue の「しないこと」) +- `scripts/count-usage-limit-resumes.py`(数えるのは後から。この周は合図を置くだけ) + +### 同じことを述べている箇所の数 + +「hook が何を書き、何を書かないか」: 本体の docstring・docs の新ページ・`CLAUDE.md` の 1 行の 3 箇所。 +docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列挙を持たない形にする(分けない理由: 利用者向けの手順は +登録の形が要り、docstring には置けない)。 + +## 乗るデプロイ経路 + +`docs/` の新ページと `dev-loop-cruise.md` は main への push で GitHub Pages に出る(公開サイトへの反映あり)。 +`scripts/` は反映経路なし。プラグインの中身は変えないので版は上げない。 + +## 周の在り処 + +- ブランチ: `issue/468-stopfailure-hook` +- worktree: `/Users/hdknr/Projects/hdknr/claude-code-setup/.claude/worktrees/issue-468` +- 起点: `c495860`(start-point `origin/main`) +- base: `c4958600e80be45a84e38340913c7699bd7d525b` + +## 受入基準 + +不変条件: + +1. **案件の中身を書かない**——`last_assistant_message` と `cwd` の生の値が、記録のファイルにも通知にも出ない。 + `error_details` は先頭だけ(長さに上限) +2. **壊れた入力で例外を出さず、読めなかったことを 1 行残す**——JSON でない・オブジェクトでない・キーが無い・ + stdin が空・非 UTF-8。終了コードは常に 0(fire-and-forget)。読めなかった行にも入力の生の値を書かない +3. **記録の先は `$HOME/.claude/usage-limit-stops.jsonl` で、追記**(前の行を消さない)。`~/.claude` が無ければ作る +4. **通知が失敗しても記録は残る**(`osascript` が無い・失敗する)。通知には「『継続』と打たずにリセットまで待つ(#456)」を添える +5. **テストは実環境の `~/.claude` を対象にしない**——一時 `HOME` をアサートで担保。本物の通知を出さない(偽の `osascript`) +6. **`check-all.py` と CI に登録**——`test-check-all.py` が緑(収録漏れの検査) +7. **docs の手順**: user の settings に `StopFailure`(matcher `rate_limit`)を足す形。登録は利用者が行う。 + `dev-loop-cruise.md` の環境の点検の節から指す。`mkdocs build --strict` が通る(リンク切れなし) +8. **陽性対照**: hook を登録した状態で、実際に `StopFailure` が発火した記録を 1 件取る(上限以外の `StopFailure` で経路だけでも)。 + 取れなければ BLOCKED。**記録が出ないことを「発火しない」と読まない** +9. **文面の事実**: docs と docstring が述べる `StopFailure` の性質(発火条件・入力のキー・matcher の値)は、 + 本体 2.1.311 の文字列か陽性対照で確かめた範囲に留める。確かめていないもの(上限で `rate_limit` になるか・待ちの間の回数)は + 「確かめていない」と書く + +経路(記録のファイル・通知に値が入る入口): + +| 経路 | 守り方 | +| --- | --- | +| hook として Claude Code が起こす(stdin の JSON) | 本体が書くキーを許可リストで選ぶ(1・2) | +| 人が手で走らせる(stdin が端末・空) | 空の入力として「読めなかった」1 行(2) | +| 通知(`osascript` の引数) | 固定の文言と `error` の値だけ(1・4) | +| テスト | 一時 `HOME`・偽の `osascript`(5) | + +検証で証明できるもの: 1〜6(テストと変異)、7(`mkdocs build --strict`・目視)、8(実際に `claude` を起こして観測できれば)、9(関門)。 + +この環境では証明できないもの(**未証明**): 利用上限(claude.ai のセッションの上限)で実際に発火し `error` が `rate_limit` になること・ +待ちの間の発火回数——上限を狙って起こせない(#456 そのもの)。 + +生成物: なし(図の書き出し・`skill-metrics.py` の生成ブロックは触らない。`SKILL.md` も触らない)。 + +## 未解決の判断 + +- なし + +## 手順 8 + +- この周の起票: 0 件 + +## 関門の進捗(再開点) + +- 手順 4: 実装済み(`check-all.py` 52/52 緑・`mkdocs build --strict`(`plans/` を外した一時の設定)が通る) +- 範囲の追加(手を動かす前に記録): `docs/plugins/dev-loop-cruise.md` の見出し「2. 環境を点検する」に明示の id `{ #env-check }` を付けた + ——日本語の見出しはスラグが `2` 程度にしかならず、新ページからのリンクが strict で落ちた。既存のリンクで `#2` を指すものは無い(grep) +- 交絡を潰した手順(受入基準 8 の陽性対照。著者の実験で、関門ではない): + - 一時 `HOME`・無効な `ANTHROPIC_API_KEY`・`claude -p "hi" --settings <一時の settings>`(2.1.311・macOS)。 + scratchpad の `probe-stopfailure.sh` + - matcher なし → 1 行(`error: authentication_failed`・`error_details` 無し)/ `authentication_failed` → 1 行/ `rate_limit` → 記録なし。 + **同じ設定で matcher だけを変えて有無が分かれた**ので、「hook が壊れていて出ない」ではなく matcher が `error` に当たっていると読める + - 確かめていないもの: user の settings の層・対話のセッション・利用上限での発火(docs の「確かめていないこと」に書いた) +- 割り目: (手順 4 の終わりで訊く) + +## 既知の限界・決着済みの論点 + +| 論点 | 決着 | 理由 | +| --- | --- | --- | +| `cwd` のハッシュに塩を入れない | 採らなかった | 同じ `cwd` の停止を手元で束ねられるようにするため。既知のパスなら辞書で戻せるが、ファイルは手元にしか無い | diff --git a/docs/plugins/dev-loop-cruise.md b/docs/plugins/dev-loop-cruise.md index b25f5b19..24ac6c96 100644 --- a/docs/plugins/dev-loop-cruise.md +++ b/docs/plugins/dev-loop-cruise.md @@ -73,7 +73,7 @@ PR を通る場所なので、許可の範囲が利用者の目を通ります [`references/fix-line.md`](https://github.com/hdknr/claude-code-setup/blob/main/plugins/dev-loop/skills/dev-loop/references/fix-line.md) を正とします。 -## 2. 環境を点検する(席を外す前に 1 度) +## 2. 環境を点検する(席を外す前に 1 度) { #env-check } ```bash python3 <スキルの base ディレクトリ>/scripts/check-unattended-env.py @@ -87,6 +87,11 @@ python3 <スキルの base ディレクトリ>/scripts/check-unattended-env.py [リモートコントロールの設定手順(不在中の運用)](../usage/advanced-remote-control.md)と、 スキル本文の「待ち時間・定時処理は別スキル」を正とします。 +利用上限で止まったことをその場で記録・通知したいなら、`StopFailure` の hook を user の settings に足します +([利用上限の停止をその場で記録する](../usage/usage-limit-stop-hook.md)。登録は利用者が行います。 +**上限で実際に発火するかは確かめていません**)。下の「利用上限で止まったとき」の自動継続の確認 +([#456](https://github.com/hdknr/claude-code-setup/issues/456))を取りこぼさないためのものです。 + ## 3. 親 Issue に一覧を書いて起動する 回す Issue を、**親 Issue**(どの Issue でもよい)の本文にチェックリストで並べます。依存は `(depends: …)` で書きます。 diff --git a/docs/usage/usage-limit-stop-hook.md b/docs/usage/usage-limit-stop-hook.md new file mode 100644 index 00000000..9cce9691 --- /dev/null +++ b/docs/usage/usage-limit-stop-hook.md @@ -0,0 +1,81 @@ +# 利用上限の停止をその場で記録する(StopFailure の hook) + +利用上限で止まったとき、**止まったその場で手元のファイルに 1 行残し、通知を出す**ための hook の設定手順です。 +[#456](https://github.com/hdknr/claude-code-setup/issues/456)(上限での自動継続が実際に起きるかの確認)は、 +**上限を狙って起こせない**ので、作業中に自然に当たるのを待つしかありません。 +気づかなければ記録は埋もれ、止まったときに「継続」と打てば確認になりません。この hook はその取りこぼしを減らします(#468)。 + +!!! warning "自動継続が起きたかは、この hook には分かりません" + hook が知っているのは**止まった時点**だけです。その後に自動で続いたか・人間が続けたかは、 + 後から `scripts/count-usage-limit-resumes.py` で数えます。**記録があることが「数える時が来た」の合図**です。 + +## 何をするか + +hook の本体は [`scripts/record-stop-failure.py`](https://github.com/hdknr/claude-code-setup/blob/main/scripts/record-stop-failure.py) です(標準ライブラリのみ)。 + +- `~/.claude/usage-limit-stops.jsonl` に 1 行追記します +- macOS なら通知を出し、「止まっても『継続』と打たずにリセットまで待ってください」と添えます + +**何を書き、何を書かないか(案件の中身は書きません)と、何を守らないかは、スクリプトの docstring を正とします。** + +## 設定する + +登録は**利用者が行います**(リポジトリから利用者の settings を書き換えることはしません)。 +全プロジェクトのセッションで効かせるため、**user の settings**(`~/.claude/settings.json`)の `hooks` に足します。 +`<リポジトリ>` は、このリポジトリを clone した**メインの作業ツリー**の絶対パスに置き換えてください +(worktree のパスを書くと、その worktree を消したときに hook が動かなくなります)。 + +```json +{ + "hooks": { + "StopFailure": [ + { + "matcher": "rate_limit", + "hooks": [ + { + "type": "command", + "command": "python3 <リポジトリ>/scripts/record-stop-failure.py" + } + ] + } + ] + } +} +``` + +既に `hooks` があるなら、その中に `StopFailure` の項目だけを足します。 + +`StopFailure` は、API のエラー(rate limit・認証の失敗など)でターンが終わったときに `Stop` の代わりに発火するイベントで、 +matcher は入力の `error` に当たります(値には `rate_limit` のほか `authentication_failed`・`overloaded`・`billing_error` などがあります。 +Claude Code 2.1.311 の本体の文字列から読みました)。**hook の出力と終了コードは無視されます**——hook が壊れていても、何も表示されません。 + +## 確かめたこと・確かめていないこと + +**確かめたこと**(2026-10-27・Claude Code 2.1.311・macOS。#468): 一時 `HOME` と無効な API キーで `claude -p` を起こし、 +`--settings` で同じ形の hook を渡しました。 + +| matcher | 結果 | +| --- | --- | +| なし | 記録が 1 行出た(`error: authentication_failed`) | +| `authentication_failed` | 記録が 1 行出た | +| `rate_limit` | 記録は出なかった | + +**確かめていないこと**: + +- **利用上限(claude.ai の上限)で実際に発火するか、`error` が `rate_limit` になるか**——上限を狙って起こせません。 + **記録が無いことは「発火しない」の証拠になりません**(hook の登録が誤っていても記録は無いままです) +- 自動継続の待ちの間に何回発火するか +- user の settings に書いた形・対話のセッションでの発火——確かめたのは `--settings` と `claude -p` の組だけです +- 通知が画面に出ること(macOS の通知の設定で出ないことがあります) + +## 記録が出たら + +1. **「継続」と打たずに、リセットの時刻まで待ちます**(自動継続が起きるかを見るため) +2. 時刻を過ぎたら、`python3 scripts/count-usage-limit-resumes.py --anonymize` で数えます +3. 結果を [#456](https://github.com/hdknr/claude-code-setup/issues/456) に貼ります。**hook は Issue を立てたりコメントしたりしません**—— + このリポジトリは公開で、hook は非公開の案件のセッションでも発火するので、何を貼るかは人間が見て決めます + +## 関連 + +- [dev-loop のオートクルーズ — 環境を点検する](../plugins/dev-loop-cruise.md#env-check) +- [拡張機能 — Hook](advanced-extensions.md) diff --git a/mkdocs.yml b/mkdocs.yml index a733dbd3..381a46bb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,7 @@ nav: - 拡張機能: usage/advanced-extensions.md - トークン最適化: usage/advanced-token-management.md - リモートコントロールの設定手順(不在中の運用): usage/advanced-remote-control.md + - 利用上限の停止をその場で記録する(StopFailure の hook): usage/usage-limit-stop-hook.md - プラグイン: - プラグイン一覧: plugins/index.md - dev-loop の設計(ループ志向開発): plugins/dev-loop-design.md diff --git a/scripts/check-all.py b/scripts/check-all.py index cd76c0c8..94d20562 100755 --- a/scripts/check-all.py +++ b/scripts/check-all.py @@ -118,6 +118,7 @@ def nested_jobs(jobs: int) -> int: "test-collect-guard-rejections.py", "test-count-resume-recreations.py", "test-count-usage-limit-resumes.py", + "test-record-stop-failure.py", "test-find-cycle.py", "test-check-unattended-env.py", "test-ledger.py", diff --git a/scripts/record-stop-failure.py b/scripts/record-stop-failure.py new file mode 100644 index 00000000..778fff64 --- /dev/null +++ b/scripts/record-stop-failure.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""`StopFailure` の hook。止まったその場で、手元のファイルに 1 行残して通知を出す。 + + python3 scripts/record-stop-failure.py < 入力の JSON + +**hook として登録して使う**(登録の手順は `docs/usage/usage-limit-stop-hook.md`。**登録は利用者が行う**)。 +**CI からは呼ばない**——書く先が利用者の `~/.claude` にある。テストだけ回す。標準ライブラリのみ。 + +なぜ必要か(#468): #456(利用上限の自動継続の陽性対照)は**上限を狙って起こせない**。気づかなければ記録は埋もれ、 +止まったときに人間が「継続」と打てば陽性対照にならない。**止まった時点を残し、そのとき利用者に知らせる。** +**自動継続が起きたかは、この hook は知らない**——`count-usage-limit-resumes.py` で後から数える。 +記録があることが「数える時が来た」の合図になる。 + +`StopFailure` は「API のエラー(rate limit・認証の失敗など)でターンが終わったとき、`Stop` の代わりに発火する。 +fire-and-forget——hook の出力と終了コードは無視される」(本体 2.1.311 の文字列)。だから**落ちても誰も気づかない**。 + +書く先: `$HOME/.claude/usage-limit-stops.jsonl`(`LOG`)に 1 行 1 件で追記する。1 行に書くのは: + +- `time`(UTC)・`hook_event_name`・`session_id`・`error` +- `cwd_hash`——`cwd` の SHA-256 の先頭 12 文字(同じ場所の停止を束ねるため。**生の値は書かない**) +- `error_details`——先頭 `DETAILS` 文字だけ +- 読めなかったときは `time` と `unreadable`(理由だけ。**入力の値は書かない**) + +守る: + +- `last_assistant_message` の値を、記録にも通知にも出さない +- `cwd` の生の値を、記録にも通知にも出さない +- `error_details` は先頭 `DETAILS` 文字まで +- JSON でない・オブジェクトでない入力で例外を出さず、`unreadable` の 1 行を残す +- `error` が無い入力でも例外を出さず、`unreadable` の 1 行を残す +- 前の行を消さずに追記する(`~/.claude` が無ければ作る) +- 通知が失敗しても(`osascript` が無い・失敗する)記録は残り、0 で終わる +- 通知に「『継続』と打たずにリセットまで待つ」(#456)を添える + +守らない: + +- **`StopFailure` が利用上限で実際に発火すること・`error` が `rate_limit` になること**——確かめていない + (上限を狙って起こせない)。**記録が無いことは「発火しない」の証拠にならない**(hook が壊れていても無い) +- **待ちの間に何回発火するか**——確かめていない。束ねるのは `count-usage-limit-resumes.py` の側 +- **書けないとき(`HOME` が書き込めない等)に知らせること**——fire-and-forget なので伝える先が無い。0 で終わる +- **通知が実際に見えること**——`osascript` が 0 で終わったかまで。macOS の通知の設定で出ないことがある。 + `osascript` の無い環境(macOS 以外)では通知を出さない +- **`cwd_hash` から `cwd` を戻せないこと**——塩を入れていないので、既知のパスなら辞書で戻せる。ファイルは手元にしか無い +""" + +from __future__ import annotations + +import datetime +import hashlib +import json +import shutil +import subprocess +import sys +from pathlib import Path + +LOG = Path(".claude") / "usage-limit-stops.jsonl" +DETAILS = 200 +WAIT = "止まっても「継続」と打たずに、リセットまで待ってください(#456 の陽性対照のため)" + + +def now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +def parse(raw: bytes) -> dict: + """入力から記録の 1 行を作る。読めなければ理由だけの行を返す。""" + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + return {"time": now(), "unreadable": "JSON として読めない"} + if not isinstance(data, dict): + return {"time": now(), "unreadable": "JSON のオブジェクトでない"} + if not isinstance(data.get("error"), str): + return {"time": now(), "unreadable": "error が無い"} + row = {"time": now(), "hook_event_name": data.get("hook_event_name"), + "session_id": data.get("session_id"), "error": data["error"]} + cwd = data.get("cwd") + if isinstance(cwd, str): + row["cwd_hash"] = hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:12] + details = data.get("error_details") + if details is not None: + row["error_details"] = str(details)[:DETAILS] + return row + + +def append(row: dict) -> None: + path = Path.home() / LOG + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def notify(row: dict) -> None: + if not shutil.which("osascript"): + return + error = row.get("error") or "入力を読めなかった" + title = "Claude Code が止まりました(利用上限)" if error == "rate_limit" else "Claude Code が止まりました" + body = f"{error}。{WAIT}" + # 値は argv で渡す(AppleScript の文字列に埋め込まない) + script = ("on run argv\n" + "display notification (item 1 of argv) with title (item 2 of argv)\n" + "end run") + subprocess.run(["osascript", "-e", script, body, title], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + + +def main() -> int: + try: + row = parse(sys.stdin.buffer.read()) + except Exception: # noqa: BLE001 — fire-and-forget。何が来ても落ちない + row = {"time": now(), "unreadable": "入力を読めない"} + try: + append(row) + except OSError: + pass + try: + notify(row) + except (OSError, subprocess.SubprocessError): + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-record-stop-failure.py b/scripts/test-record-stop-failure.py new file mode 100644 index 00000000..f3ac6a09 --- /dev/null +++ b/scripts/test-record-stop-failure.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""`record-stop-failure.py` の回帰テスト。 + + python3 scripts/test-record-stop-failure.py + +**このテストは実環境を触らない。** 本体は子プロセスとして、**一時ディレクトリを `HOME` にして**起こす +(`assert_not_real_home` がそれを担保する)。本体は `$HOME/.claude` に書くので、**歯止めが無いと、 +テストが利用者の `~/.claude` に追記して通ってしまう**。**通知も本物を出さない**——`PATH` を偽の `osascript` +(受け取った引数をファイルに書くだけ)のディレクトリだけにする。 + +**入力は合成である。** キーの名前は本体 2.1.311 の文字列から読んだもの(`hook_event_name`・`error`・ +`error_details`・`last_assistant_message`)と、hook の共通の入力(`session_id`・`cwd`)。 + +**変異テストを含む。** 本体の docstring の「守る」の行数だけ変異を当て、 +**壊したのに観測が変わらなければ失格**とする。行を足したら変異も足す。 +""" +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +REAL_REPO = Path(__file__).resolve().parent.parent +SCRIPT = REAL_REPO / "scripts" / "record-stop-failure.py" +REAL_HOME = Path(os.path.expanduser("~")) +SECRET_MSG = "SECRET-ASSISTANT-MESSAGE" +SECRET_CWD = "/work/secret-project" +WAIT = "「継続」と打たずに" + +GOOD = json.dumps({ + "hook_event_name": "StopFailure", "session_id": "s1", "cwd": SECRET_CWD, + "transcript_path": "/x.jsonl", "error": "rate_limit", "error_details": "D" * 500, + "last_assistant_message": SECRET_MSG, +}).encode() + +# (名前, stdin, 通知の形)。通知の形: "fake" 偽の osascript / "broken" 実行できない osascript / "none" 無い +CASES = [ + ("good", GOOD, "fake"), + ("auth", json.dumps({"hook_event_name": "StopFailure", "error": "authentication_failed"}).encode(), "fake"), + ("not-json", b"not json", "fake"), + ("list", b"[1, 2]", "fake"), + ("no-error", json.dumps({"cwd": SECRET_CWD, "last_assistant_message": SECRET_MSG}).encode(), "fake"), + ("null-error", b'{"error": null}', "fake"), + ("empty", b"", "fake"), + ("non-utf8", b"\xff\xfe{", "fake"), + ("broken-osascript", GOOD, "broken"), + ("no-osascript", GOOD, "none"), +] + +MUTATIONS = { + "last_assistant_message を書く": ( + '"error": data["error"]}', + '"error": data["error"], "message": data.get("last_assistant_message")}'), + "cwd の生の値を書く": ( + 'row["cwd_hash"] = hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:12]', + 'row["cwd_hash"] = cwd'), + "error_details を切らない": ("str(details)[:DETAILS]", "str(details)"), + "オブジェクトでない入力を見分けない": ("if not isinstance(data, dict):", "if data is None:"), + "error が無い入力を見分けない": ('if not isinstance(data.get("error"), str):', "if False:"), + "追記せずに上書きする": ('path.open("a"', 'path.open("w"'), + "通知の失敗を握らない": ("except (OSError, subprocess.SubprocessError):", "except ValueError:"), + "待てと添えない": ('body = f"{error}。{WAIT}"', "body = error"), +} + +failures: list[str] = [] + + +def check(name: str, cond: bool) -> None: + print((" ok " if cond else " FAIL ") + name) + if not cond: + failures.append(name) + + +def assert_not_real_home(home: Path) -> None: + resolved = home.resolve() + assert resolved != REAL_HOME.resolve(), "テストが実ホームを HOME にしている" + assert resolved.is_relative_to(Path(tempfile.gettempdir()).resolve()), "テストの HOME がテンポラリの外にある" + + +def fake_bins(root: Path) -> dict[str, Path]: + bins = {} + for kind in ("fake", "broken", "none"): + d = root / f"bin-{kind}" + d.mkdir() + bins[kind] = d + fake = bins["fake"] / "osascript" + fake.write_text('#!/bin/sh\nprintf \'%s\\0\' "$@" >> "$OSA_LOG"\n', encoding="utf-8") + fake.chmod(0o755) + broken = bins["broken"] / "osascript" + broken.write_bytes(b"\x00\x01 not an executable\n") # 実行ビットはあるが exec できない + broken.chmod(0o755) + return bins + + +def run_case(script: Path, root: Path, bins: dict[str, Path], name: str, stdin: bytes, kind: str, times: int = 1): + home = root / f"home-{name}" + home.mkdir() + assert_not_real_home(home) + osa_log = root / f"osa-{name}" + env = {"HOME": str(home), "PATH": str(bins[kind]), "OSA_LOG": str(osa_log)} + codes, errs = [], [] + for _ in range(times): + p = subprocess.run([sys.executable, str(script)], input=stdin, env=env, capture_output=True, timeout=30) + codes.append(p.returncode) + errs.append(p.stderr.decode("utf-8", "replace")) + log = home / ".claude" / "usage-limit-stops.jsonl" + rows = [] + raw_log = log.read_text(encoding="utf-8") if log.exists() else "" + for line in raw_log.splitlines(): + row = json.loads(line) + row.pop("time", None) + rows.append(row) + args = osa_log.read_bytes().decode("utf-8").split("\0")[:-1] if osa_log.exists() else [] + return {"codes": codes, "errs": errs, "rows": rows, "raw": raw_log, "osa": args} + + +def observe(script: Path, root: Path, bins: dict[str, Path]) -> dict: + out = {} + for name, stdin, kind in CASES: + out[name] = run_case(script, root, bins, name, stdin, kind, times=2 if name == "good" else 1) + return out + + +def without_raw(obs: dict) -> dict: + """比べるための観測。`raw` は時刻を含むので外す(行の中身は `rows` が持つ)。""" + return {n: {k: v for k, v in o.items() if k != "raw"} for n, o in obs.items()} + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "correct").mkdir() + bins = fake_bins(root) + obs = observe(SCRIPT, root / "correct", bins) + + print("記録") + good = obs["good"] + check("何が来ても 0 で終わり、stderr に何も出さない", + all(c == 0 for o in obs.values() for c in o["codes"]) and all(e == "" for o in obs.values() for e in o["errs"])) + check("~/.claude が無くても作って書く", len(good["rows"]) == 2) + check("2 回起こすと 2 行(追記)", good["rows"][0] == good["rows"][1]) + row = good["rows"][0] + check("書くキーは決まったものだけ", + set(row) == {"hook_event_name", "session_id", "error", "cwd_hash", "error_details"}) + check("error・session_id・hook_event_name をそのまま書く", + (row["error"], row["session_id"], row["hook_event_name"]) == ("rate_limit", "s1", "StopFailure")) + check("cwd はハッシュの先頭 12 文字", len(row["cwd_hash"]) == 12 and row["cwd_hash"] != SECRET_CWD) + check("error_details は先頭 200 文字", row["error_details"] == "D" * 200) + leaked = [n for n, o in obs.items() if SECRET_MSG in o["raw"] or SECRET_CWD in o["raw"] + or any(SECRET_MSG in a or SECRET_CWD in a for a in o["osa"])] + check("last_assistant_message と cwd の生の値は記録にも通知にも出ない", leaked == []) + check("cwd が無ければ cwd_hash を書かない", "cwd_hash" not in obs["auth"]["rows"][0]) + + print("\n読めない入力") + expect = {"not-json": "JSON として読めない", "list": "JSON のオブジェクトでない", + "no-error": "error が無い", "null-error": "error が無い", + "empty": "JSON として読めない", "non-utf8": "JSON として読めない"} + for name, reason in expect.items(): + check(f"{name}: 理由だけの 1 行({reason})", obs[name]["rows"] == [{"unreadable": reason}]) + + print("\n通知") + osa = good["osa"] + check("通知の本文に error と「継続と打たずに」を添える", + len(osa) == 2 * 4 and "rate_limit" in osa[2] and WAIT in osa[2]) + check("利用上限なら題に「利用上限」", "利用上限" in osa[3]) + check("利用上限でなければ題に「利用上限」と書かない", + len(obs["auth"]["osa"]) == 4 and "利用上限" not in obs["auth"]["osa"][3]) + check("読めない入力でも通知する", len(obs["not-json"]["osa"]) == 4) + check("osascript が exec できなくても記録は残る", len(obs["broken-osascript"]["rows"]) == 1) + check("osascript が無ければ通知せず、記録は残る", + obs["no-osascript"]["osa"] == [] and len(obs["no-osascript"]["rows"]) == 1) + + print("\n変異テスト(壊したのに観測が変わらなければ失格)") + source = SCRIPT.read_text(encoding="utf-8") + for i, (name, (needle, replacement)) in enumerate(MUTATIONS.items()): + if source.count(needle) != 1: + check(f"変異を当てる先が 1 箇所: {name}", False) + continue + broken = root / f"mut-{i}" / SCRIPT.name + broken.parent.mkdir() + broken.write_text(source.replace(needle, replacement, 1), encoding="utf-8") + (root / f"obs-{i}").mkdir() + mutated = observe(broken, root / f"obs-{i}", bins) + check(f"変異を殺せる: {name}", without_raw(mutated) != without_raw(obs)) + + print("\n「守る」の行数と変異の数が 1 対 1 か(手で数えない)") + body = source.split("守る:")[1].split("守らない:")[0] + promises = [line for line in body.splitlines() if line.startswith("- ")] + check(f"本体の「守る」{len(promises)} 行に対して変異が {len(MUTATIONS)} 件", len(promises) == len(MUTATIONS)) + + print("\n実環境を対象にしない歯止め") + try: + assert_not_real_home(REAL_HOME) + except AssertionError: + check("実ホームを HOME にすると落ちる", True) + else: + check("実ホームを HOME にすると落ちる", False) + + print() + if failures: + print(f"FAILED: {len(failures)} 件") + for name in failures: + print(f" - {name}") + return 1 + print("StopFailure の hook のテスト: すべて合格") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e59f7abd93654839fcf0eeda49df57b416b440e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 15:18:18 +0900 Subject: [PATCH 02/10] =?UTF-8?q?=E8=A8=88=E7=94=BB:=20=E6=89=8B=E9=A0=86?= =?UTF-8?q?=204=20=E3=81=AE=E5=89=B2=E3=82=8A=E7=9B=AE=E3=82=92=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=EF=BC=88#468=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-468.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md index 3dbc7807..c8de6eb6 100644 --- a/docs/plans/issue-468.md +++ b/docs/plans/issue-468.md @@ -97,7 +97,9 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 - matcher なし → 1 行(`error: authentication_failed`・`error_details` 無し)/ `authentication_failed` → 1 行/ `rate_limit` → 記録なし。 **同じ設定で matcher だけを変えて有無が分かれた**ので、「hook が壊れていて出ない」ではなく matcher が `error` に当たっていると読める - 確かめていないもの: user の settings の層・対話のセッション・利用上限での発火(docs の「確かめていないこと」に書いた) -- 割り目: (手順 4 の終わりで訊く) +- 割り目(手順 4 の終わり): 利用者が「ここで割る」を選んだ(2026-10-27) +- 次にすること: 手順 5 の 1 パス目(Verifier。別ティア・`dev-loop:dev-loop-verifier`)と手順 6 の 1 パス目(`/code-review`)。 + どちらも未着手・反証 0 件(まだ当てていない)。base は上の「周の在り処」の SHA ## 既知の限界・決着済みの論点 From fbd6c1ac3af57116f097338830ebd05fea7f8d7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 15:30:51 +0900 Subject: [PATCH 03/10] =?UTF-8?q?StopFailure=20=E3=81=AE=20hook:=20?= =?UTF-8?q?=E7=AC=A6=E5=8F=B7=E5=8C=96=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84?= =?UTF-8?q?=E5=80=A4=E3=83=BB=E5=80=A4=E3=81=AE=E5=9E=8B=E3=81=A8=E9=95=B7?= =?UTF-8?q?=E3=81=95=E3=83=BBmatcher=20=E3=81=AA=E3=81=97=E3=81=AE?= =?UTF-8?q?=E7=99=BB=E9=8C=B2=EF=BC=88#468=20=E3=81=AE=201=20=E3=83=91?= =?UTF-8?q?=E3=82=B9=E7=9B=AE=E3=81=AE=E6=8C=87=E6=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-468.md | 27 ++++++++++++++--- docs/usage/usage-limit-stop-hook.md | 11 +++++-- scripts/record-stop-failure.py | 45 +++++++++++++++++++---------- scripts/test-record-stop-failure.py | 44 +++++++++++++++++++++++----- 4 files changed, 98 insertions(+), 29 deletions(-) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md index c8de6eb6..a7bb184f 100644 --- a/docs/plans/issue-468.md +++ b/docs/plans/issue-468.md @@ -51,10 +51,10 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 2. **壊れた入力で例外を出さず、読めなかったことを 1 行残す**——JSON でない・オブジェクトでない・キーが無い・ stdin が空・非 UTF-8。終了コードは常に 0(fire-and-forget)。読めなかった行にも入力の生の値を書かない 3. **記録の先は `$HOME/.claude/usage-limit-stops.jsonl` で、追記**(前の行を消さない)。`~/.claude` が無ければ作る -4. **通知が失敗しても記録は残る**(`osascript` が無い・失敗する)。通知には「『継続』と打たずにリセットまで待つ(#456)」を添える +4. **通知が失敗しても記録は残る**(`osascript` が無い・失敗する・引数にできない値)。通知には、上限らしい `error`(`rate_limit`・`billing_error`)のときだけ「『継続』と打たずにリセットまで待つ(#456)」を添える(**1 パス目の後に利用者が matcher を外すと決めたのに伴って変えた**——認証の失敗などに「待て」は誤り) 5. **テストは実環境の `~/.claude` を対象にしない**——一時 `HOME` をアサートで担保。本物の通知を出さない(偽の `osascript`) 6. **`check-all.py` と CI に登録**——`test-check-all.py` が緑(収録漏れの検査) -7. **docs の手順**: user の settings に `StopFailure`(matcher `rate_limit`)を足す形。登録は利用者が行う。 +7. **docs の手順**: user の settings に `StopFailure`(**matcher なし**——全部を記録し、`error` の値で後から見分ける。**1 パス目の後に利用者が決めた**(2026-10-27)。本体が `billing_error` を「usage limit reached」に対応づけており、上限がどちらの値で来るかを確かめていないため)を足す形。登録は利用者が行う。 `dev-loop-cruise.md` の環境の点検の節から指す。`mkdocs build --strict` が通る(リンク切れなし) 8. **陽性対照**: hook を登録した状態で、実際に `StopFailure` が発火した記録を 1 件取る(上限以外の `StopFailure` で経路だけでも)。 取れなければ BLOCKED。**記録が出ないことを「発火しない」と読まない** @@ -98,11 +98,30 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 **同じ設定で matcher だけを変えて有無が分かれた**ので、「hook が壊れていて出ない」ではなく matcher が `error` に当たっていると読める - 確かめていないもの: user の settings の層・対話のセッション・利用上限での発火(docs の「確かめていないこと」に書いた) - 割り目(手順 4 の終わり): 利用者が「ここで割る」を選んだ(2026-10-27) -- 次にすること: 手順 5 の 1 パス目(Verifier。別ティア・`dev-loop:dev-loop-verifier`)と手順 6 の 1 パス目(`/code-review`)。 - どちらも未着手・反証 0 件(まだ当てていない)。base は上の「周の在り処」の SHA +- 再開(2026-10-27): `find-cycle.py` が当たり、ブランチ・コミット(`e59f7ab`)・base の祖先を確かめて入り直した +- 手順 5 の 1 パス目(Verifier。`dev-loop:dev-loop-verifier`・sonnet)と手順 6 の 1 パス目(`/code-review` high)を起動済み。 + **報告待ち——反証の件数はまだ無い**(起動済みは進捗ではない) ## 既知の限界・決着済みの論点 | 論点 | 決着 | 理由 | | --- | --- | --- | | `cwd` のハッシュに塩を入れない | 採らなかった | 同じ `cwd` の停止を手元で束ねられるようにするため。既知のパスなら辞書で戻せるが、ファイルは手元にしか無い | + +### 1 パス目の指摘(採否) + +| 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | +| --- | --- | --- | --- | +| V-R1: lone surrogate(`{"error":"\ud800x"}`)で `UnicodeEncodeError`・exit 1・空のファイルが残る | 示せる(著者が再現: scratchpad の `repro-r1.sh` → `exit=1`・`surrogates not allowed`・0 バイト) | 2 | 直す | +| V-R2: `error`・`session_id`・`hook_event_name` の型・長さを見ていない(3000 文字の `error`、辞書の `session_id`、辞書の `error_details` の `str()`) | 示せる(Verifier の実行。辞書の `session_id` がそのまま記録に出る) | 1(`error_details` の上限の趣旨・許可リストが値の形まで見ていない) | 直す | +| V-指摘: 本体は `billing_error` を「usage limit reached — check plan」に対応づける。docs の matcher が `rate_limit` 固定だと上限の停止を取りこぼしうる | 示せる(著者が 2.1.311 のバンドルで `case"billing_error":return{state:"blocked",needs:"usage limit reached — check plan"}` を確認) | 7・9(既定の案内が確かめていない片方に倒れている) | 直す(受入基準 7 を変えるので**利用者に訊いた**——「matcher なし」) | +| CR1: lone surrogate(V-R1 と同じ) | 示せる(同上) | 2 | 直す(V-R1) | +| CR2: `error` に NUL があると `subprocess` が `ValueError`・exit 1 | 示せる(`/code-review` が再現。rc=1・記録は残る) | 2(終了コードは常に 0) | 直す | +| CR3: matcher `rate_limit` が未確かめの値で絞る(V-指摘と同じ) | 示せる(同上) | 7・9 | 直す(V-指摘) | +| CR4: 値の型・長さを見ていない(V-R2 と同じ) | 示せる(`hook_event_name` の辞書がそのまま出る・10 万文字の `session_id`) | 1 | 直す(V-R2) | +| CR5: 認証の失敗などにも「リセットまで待て」と添える | 示せる(matcher なしにすると `authentication_failed` で出る。著者の陽性対照の入力) | 4(matcher を外すと決めた後の受入基準 4) | 直す | +| CR6: CASES に符号化できない値・長い値・文字列でない値が無い | 示せる(CR1・CR2 があるのにテストは緑) | 2・1 | 直す(上の修正に入力を足す) | +| CR7: テストが長さを見ずに添字で引く(`IndexError` で要約が出ない) | 示せない(いまは落ちていない。退行したときの形) | 無し | 残課題 | +| CR8: 「通知が失敗しても記録は残る」の後半を壊す変異が無い | 示せない(いまのテストでも記録の行数で捕まると `/code-review` 自身が書いている) | 無し | 残課題 | +| CR9: `assert_not_real_home` が素の `assert`(`-O` で消える) | 示せない(テストを `-O` で走らせる経路が無い) | 無し | 残課題 | +| CR10: docs のコマンドがパスをクォートしていない | 示せない(空白を含むパスでの失敗は仮定。手元の clone には空白が無い) | 無し | 残課題 | diff --git a/docs/usage/usage-limit-stop-hook.md b/docs/usage/usage-limit-stop-hook.md index 9cce9691..d00c06f8 100644 --- a/docs/usage/usage-limit-stop-hook.md +++ b/docs/usage/usage-limit-stop-hook.md @@ -14,7 +14,8 @@ hook の本体は [`scripts/record-stop-failure.py`](https://github.com/hdknr/claude-code-setup/blob/main/scripts/record-stop-failure.py) です(標準ライブラリのみ)。 - `~/.claude/usage-limit-stops.jsonl` に 1 行追記します -- macOS なら通知を出し、「止まっても『継続』と打たずにリセットまで待ってください」と添えます +- macOS なら通知を出します。上限らしい `error`(`rate_limit`・`billing_error`)のときだけ、 + 「止まっても『継続』と打たずにリセットまで待ってください」と添えます **何を書き、何を書かないか(案件の中身は書きません)と、何を守らないかは、スクリプトの docstring を正とします。** @@ -30,7 +31,6 @@ hook の本体は [`scripts/record-stop-failure.py`](https://github.com/hdknr/cl "hooks": { "StopFailure": [ { - "matcher": "rate_limit", "hooks": [ { "type": "command", @@ -45,6 +45,11 @@ hook の本体は [`scripts/record-stop-failure.py`](https://github.com/hdknr/cl 既に `hooks` があるなら、その中に `StopFailure` の項目だけを足します。 +**matcher は書きません**——`StopFailure` を全部記録し、記録の `error` の値で後から見分けます。 +利用上限がどの値で来るかを確かめていないためです(下の「確かめていないこと」)。 +Claude Code 2.1.311 の本体は `billing_error` を「usage limit reached — check plan」、`rate_limit` を +「rate limited — wait and retry」に対応づけており、**`rate_limit` だけに絞ると、上限の停止を取りこぼすおそれがあります**。 + `StopFailure` は、API のエラー(rate limit・認証の失敗など)でターンが終わったときに `Stop` の代わりに発火するイベントで、 matcher は入力の `error` に当たります(値には `rate_limit` のほか `authentication_failed`・`overloaded`・`billing_error` などがあります。 Claude Code 2.1.311 の本体の文字列から読みました)。**hook の出力と終了コードは無視されます**——hook が壊れていても、何も表示されません。 @@ -62,7 +67,7 @@ Claude Code 2.1.311 の本体の文字列から読みました)。**hook の **確かめていないこと**: -- **利用上限(claude.ai の上限)で実際に発火するか、`error` が `rate_limit` になるか**——上限を狙って起こせません。 +- **利用上限(claude.ai の上限)で実際に発火するか、`error` がどの値(`rate_limit`・`billing_error` ほか)になるか**——上限を狙って起こせません。 **記録が無いことは「発火しない」の証拠になりません**(hook の登録が誤っていても記録は無いままです) - 自動継続の待ちの間に何回発火するか - user の settings に書いた形・対話のセッションでの発火——確かめたのは `--settings` と `claude -p` の組だけです diff --git a/scripts/record-stop-failure.py b/scripts/record-stop-failure.py index 778fff64..c92114b9 100644 --- a/scripts/record-stop-failure.py +++ b/scripts/record-stop-failure.py @@ -16,9 +16,9 @@ 書く先: `$HOME/.claude/usage-limit-stops.jsonl`(`LOG`)に 1 行 1 件で追記する。1 行に書くのは: -- `time`(UTC)・`hook_event_name`・`session_id`・`error` +- `time`(UTC)・`hook_event_name`・`session_id`・`error`——文字列のときだけ、先頭 `SHORT` 文字まで(それ以外は `null`) - `cwd_hash`——`cwd` の SHA-256 の先頭 12 文字(同じ場所の停止を束ねるため。**生の値は書かない**) -- `error_details`——先頭 `DETAILS` 文字だけ +- `error_details`——文字列のときだけ、先頭 `DETAILS` 文字まで - 読めなかったときは `time` と `unreadable`(理由だけ。**入力の値は書かない**) 守る: @@ -26,16 +26,22 @@ - `last_assistant_message` の値を、記録にも通知にも出さない - `cwd` の生の値を、記録にも通知にも出さない - `error_details` は先頭 `DETAILS` 文字まで +- `error`・`session_id`・`hook_event_name` は先頭 `SHORT` 文字まで +- 文字列でない値(辞書など)は書かない - JSON でない・オブジェクトでない入力で例外を出さず、`unreadable` の 1 行を残す - `error` が無い入力でも例外を出さず、`unreadable` の 1 行を残す - 前の行を消さずに追記する(`~/.claude` が無ければ作る) -- 通知が失敗しても(`osascript` が無い・失敗する)記録は残り、0 で終わる -- 通知に「『継続』と打たずにリセットまで待つ」(#456)を添える +- 書けない文字(対にならないサロゲート)が来ても記録は残る +- 通知が失敗しても(`osascript` が無い・失敗する・引数にできない値)記録は残り、0 で終わる +- 上限らしい `error`(`LIMIT`)なら、通知に「『継続』と打たずにリセットまで待つ」(#456)を添える +- 上限らしくない `error` には「待て」を添えない(認証の失敗などに待っても直らない) 守らない: -- **`StopFailure` が利用上限で実際に発火すること・`error` が `rate_limit` になること**——確かめていない - (上限を狙って起こせない)。**記録が無いことは「発火しない」の証拠にならない**(hook が壊れていても無い) +- **`StopFailure` が利用上限で実際に発火すること・`error` がどの値になるか**——確かめていない + (上限を狙って起こせない)。本体 2.1.311 は `billing_error` を「usage limit reached — check plan」、 + `rate_limit` を「rate limited — wait and retry」に対応づけているので、`LIMIT` は両方を持つ。 + **だから matcher で絞らずに登録する**(docs の手順)。**記録が無いことは「発火しない」の証拠にならない**(hook が壊れていても無い) - **待ちの間に何回発火するか**——確かめていない。束ねるのは `count-usage-limit-resumes.py` の側 - **書けないとき(`HOME` が書き込めない等)に知らせること**——fire-and-forget なので伝える先が無い。0 で終わる - **通知が実際に見えること**——`osascript` が 0 で終わったかまで。macOS の通知の設定で出ないことがある。 @@ -55,6 +61,8 @@ LOG = Path(".claude") / "usage-limit-stops.jsonl" DETAILS = 200 +SHORT = 100 +LIMIT = ("rate_limit", "billing_error") WAIT = "止まっても「継続」と打たずに、リセットまで待ってください(#456 の陽性対照のため)" @@ -62,6 +70,11 @@ def now() -> str: return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") +def text(value, limit): + """文字列なら先頭 `limit` 文字、それ以外は `None`(辞書などの中身を書かない)。""" + return value[:limit] if isinstance(value, str) else None + + def parse(raw: bytes) -> dict: """入力から記録の 1 行を作る。読めなければ理由だけの行を返す。""" try: @@ -72,14 +85,14 @@ def parse(raw: bytes) -> dict: return {"time": now(), "unreadable": "JSON のオブジェクトでない"} if not isinstance(data.get("error"), str): return {"time": now(), "unreadable": "error が無い"} - row = {"time": now(), "hook_event_name": data.get("hook_event_name"), - "session_id": data.get("session_id"), "error": data["error"]} + row = {"time": now(), "hook_event_name": text(data.get("hook_event_name"), SHORT), + "session_id": text(data.get("session_id"), SHORT), "error": text(data["error"], SHORT)} cwd = data.get("cwd") if isinstance(cwd, str): row["cwd_hash"] = hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:12] - details = data.get("error_details") + details = text(data.get("error_details"), DETAILS) if details is not None: - row["error_details"] = str(details)[:DETAILS] + row["error_details"] = details return row @@ -87,15 +100,17 @@ def append(row: dict) -> None: path = Path.home() / LOG path.parent.mkdir(parents=True, exist_ok=True) with path.open("a", encoding="utf-8") as f: - f.write(json.dumps(row, ensure_ascii=False) + "\n") + # ASCII に逃がす——対にならないサロゲートは UTF-8 で書けない + f.write(json.dumps(row, ensure_ascii=True) + "\n") def notify(row: dict) -> None: if not shutil.which("osascript"): return error = row.get("error") or "入力を読めなかった" - title = "Claude Code が止まりました(利用上限)" if error == "rate_limit" else "Claude Code が止まりました" - body = f"{error}。{WAIT}" + limit = error in LIMIT + title = "Claude Code が止まりました(利用上限かもしれません)" if limit else "Claude Code が止まりました" + body = f"{error}。{WAIT}" if limit else error # 値は argv で渡す(AppleScript の文字列に埋め込まない) script = ("on run argv\n" "display notification (item 1 of argv) with title (item 2 of argv)\n" @@ -111,11 +126,11 @@ def main() -> int: row = {"time": now(), "unreadable": "入力を読めない"} try: append(row) - except OSError: + except Exception: # noqa: BLE001 — 書けなくても通知は出す pass try: notify(row) - except (OSError, subprocess.SubprocessError): + except Exception: # noqa: BLE001 — 通知の失敗(NUL を含む引数など)でも 0 で終わる pass return 0 diff --git a/scripts/test-record-stop-failure.py b/scripts/test-record-stop-failure.py index f3ac6a09..3f10e2e8 100644 --- a/scripts/test-record-stop-failure.py +++ b/scripts/test-record-stop-failure.py @@ -44,23 +44,36 @@ ("null-error", b'{"error": null}', "fake"), ("empty", b"", "fake"), ("non-utf8", b"\xff\xfe{", "fake"), + ("billing", json.dumps({"hook_event_name": "StopFailure", "error": "billing_error"}).encode(), "fake"), + # 対にならないサロゲート——json.loads は通すが UTF-8 では書けない + ("surrogate", b'{"error": "\\ud800x", "session_id": "\\udc00"}', "fake"), + # NUL——subprocess の引数にできない + ("nul", b'{"error": "a\\u0000b"}', "fake"), + ("odd-values", json.dumps({"error": "E" * 5000, "session_id": {"a": "SECRET-SID"}, + "hook_event_name": {"cwd": SECRET_CWD}, "error_details": {"k": "SECRET-DET"}}).encode(), + "fake"), ("broken-osascript", GOOD, "broken"), ("no-osascript", GOOD, "none"), ] MUTATIONS = { "last_assistant_message を書く": ( - '"error": data["error"]}', - '"error": data["error"], "message": data.get("last_assistant_message")}'), + '"error": text(data["error"], SHORT)}', + '"error": text(data["error"], SHORT), "message": data.get("last_assistant_message")}'), "cwd の生の値を書く": ( 'row["cwd_hash"] = hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:12]', 'row["cwd_hash"] = cwd'), - "error_details を切らない": ("str(details)[:DETAILS]", "str(details)"), + "error_details を切らない": ('text(data.get("error_details"), DETAILS)', 'text(data.get("error_details"), None)'), + "error などを切らない": ("SHORT = 100", "SHORT = None"), + "文字列でない値を書く": ("value[:limit] if isinstance(value, str) else None", + "value[:limit] if isinstance(value, str) else value"), "オブジェクトでない入力を見分けない": ("if not isinstance(data, dict):", "if data is None:"), "error が無い入力を見分けない": ('if not isinstance(data.get("error"), str):', "if False:"), "追記せずに上書きする": ('path.open("a"', 'path.open("w"'), - "通知の失敗を握らない": ("except (OSError, subprocess.SubprocessError):", "except ValueError:"), - "待てと添えない": ('body = f"{error}。{WAIT}"', "body = error"), + "書けない文字で記録を落とす": ("ensure_ascii=True", "ensure_ascii=False"), + "通知の失敗を握らない": ("except Exception: # noqa: BLE001 — 通知の", "except OSError: # 通知の"), + "待てと添えない": ('f"{error}。{WAIT}" if limit', "error if limit"), + "上限でなくても待てと添える": ("if limit else error", 'if limit else f"{error}。{WAIT}"'), } failures: list[str] = [] @@ -111,7 +124,7 @@ def run_case(script: Path, root: Path, bins: dict[str, Path], name: str, stdin: row = json.loads(line) row.pop("time", None) rows.append(row) - args = osa_log.read_bytes().decode("utf-8").split("\0")[:-1] if osa_log.exists() else [] + args = osa_log.read_bytes().decode("utf-8", "replace").split("\0")[:-1] if osa_log.exists() else [] return {"codes": codes, "errs": errs, "rows": rows, "raw": raw_log, "osa": args} @@ -147,11 +160,22 @@ def main() -> int: (row["error"], row["session_id"], row["hook_event_name"]) == ("rate_limit", "s1", "StopFailure")) check("cwd はハッシュの先頭 12 文字", len(row["cwd_hash"]) == 12 and row["cwd_hash"] != SECRET_CWD) check("error_details は先頭 200 文字", row["error_details"] == "D" * 200) - leaked = [n for n, o in obs.items() if SECRET_MSG in o["raw"] or SECRET_CWD in o["raw"] + leaked = [n for n, o in obs.items() if SECRET_MSG in o["raw"] or SECRET_CWD in o["raw"] or "SECRET-" in o["raw"] or any(SECRET_MSG in a or SECRET_CWD in a for a in o["osa"])] check("last_assistant_message と cwd の生の値は記録にも通知にも出ない", leaked == []) check("cwd が無ければ cwd_hash を書かない", "cwd_hash" not in obs["auth"]["rows"][0]) + odd = obs["odd-values"]["rows"] + check("error は先頭 100 文字", len(odd) == 1 and odd[0].get("error") == "E" * 100) + check("文字列でない session_id・hook_event_name・error_details は書かない", + len(odd) == 1 and odd[0].get("session_id") is None and odd[0].get("hook_event_name") is None + and "error_details" not in odd[0]) + check("対にならないサロゲートでも記録は残る", + obs["surrogate"]["rows"] == [{"hook_event_name": None, "session_id": "\udc00", "error": "\ud800x"}]) + check("NUL を含む error でも記録は残る(通知は出せない)", + obs["nul"]["rows"] == [{"hook_event_name": None, "session_id": None, "error": "a\x00b"}] + and obs["nul"]["osa"] == []) + print("\n読めない入力") expect = {"not-json": "JSON として読めない", "list": "JSON のオブジェクトでない", "no-error": "error が無い", "null-error": "error が無い", @@ -163,6 +187,12 @@ def main() -> int: osa = good["osa"] check("通知の本文に error と「継続と打たずに」を添える", len(osa) == 2 * 4 and "rate_limit" in osa[2] and WAIT in osa[2]) + billing = obs["billing"]["osa"] + check("billing_error にも「継続と打たずに」と「利用上限」を添える", + len(billing) == 4 and WAIT in billing[2] and "利用上限" in billing[3]) + auth = obs["auth"]["osa"] + check("上限らしくない error には「継続と打たずに」を添えない", + len(auth) == 4 and "authentication_failed" in auth[2] and WAIT not in auth[2]) check("利用上限なら題に「利用上限」", "利用上限" in osa[3]) check("利用上限でなければ題に「利用上限」と書かない", len(obs["auth"]["osa"]) == 4 and "利用上限" not in obs["auth"]["osa"][3]) From 8ada8ea74e1054d3e9b58ba7a4bac16e109a6bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 15:31:46 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=E8=A8=88=E7=94=BB:=201=20=E3=83=91?= =?UTF-8?q?=E3=82=B9=E7=9B=AE=E3=81=AE=E7=B5=90=E6=9E=9C=E3=81=A8=202=20?= =?UTF-8?q?=E3=83=91=E3=82=B9=E7=9B=AE=E3=81=AE=E5=89=8D=E3=81=AE=E5=89=B2?= =?UTF-8?q?=E3=82=8A=E7=9B=AE=E3=82=92=E8=A8=98=E9=8C=B2=EF=BC=88#468?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-468.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md index a7bb184f..aa72deb9 100644 --- a/docs/plans/issue-468.md +++ b/docs/plans/issue-468.md @@ -99,8 +99,14 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 - 確かめていないもの: user の settings の層・対話のセッション・利用上限での発火(docs の「確かめていないこと」に書いた) - 割り目(手順 4 の終わり): 利用者が「ここで割る」を選んだ(2026-10-27) - 再開(2026-10-27): `find-cycle.py` が当たり、ブランチ・コミット(`e59f7ab`)・base の祖先を確かめて入り直した -- 手順 5 の 1 パス目(Verifier。`dev-loop:dev-loop-verifier`・sonnet)と手順 6 の 1 パス目(`/code-review` high)を起動済み。 - **報告待ち——反証の件数はまだ無い**(起動済みは進捗ではない) +- 手順 5 の 1 パス目(Verifier。`dev-loop:dev-loop-verifier`・sonnet): **反証 2 件・指摘 1 件**(下の採否の表の V-) +- 手順 6 の 1 パス目(`/code-review` high): **指摘 10 件**(下の採否の表の CR-。うち 3 件は Verifier と同じ) +- 受入基準 4・7 を変えた——**利用者が「matcher なし」を選んだ**(2026-10-27。`AskUserQuestion`) +- 1 パス目の指摘対応: `fbd6c1a`(直す 6 種・残課題 4 件)。`check-all.py` 52/52 緑。 + 陽性対照: 同じ入力(`repro-r1.sh`)が直す前 exit 1・0 バイト → 直した後 exit 0・1 行 +- 次にすること: **手順 5 の当て直し(Verifier の 2 パス目)と手順 6 の 2 パス目(`/code-review`)の両方**。base は「周の在り処」の SHA のまま。 + 2 パス目の指摘対応を始める直前の HEAD(狭いパスの起点)は、そのとき書く +- 割り目(2 パス目の前): 利用者が「ここで割る」を選んだ(2026-10-27)。2 パス目はどちらも未着手 ## 既知の限界・決着済みの論点 From 35196a76148ceac03f258e07a4aad4a9b8150686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 15:37:49 +0900 Subject: [PATCH 05/10] =?UTF-8?q?=E8=A8=88=E7=94=BB:=202=20=E3=83=91?= =?UTF-8?q?=E3=82=B9=E7=9B=AE=E3=81=AE=E7=B5=90=E6=9E=9C=E3=81=A8=E6=8E=A1?= =?UTF-8?q?=E5=90=A6=E3=82=92=E8=A8=98=E9=8C=B2=EF=BC=88#468=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-468.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md index aa72deb9..3a2a2752 100644 --- a/docs/plans/issue-468.md +++ b/docs/plans/issue-468.md @@ -107,6 +107,10 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 - 次にすること: **手順 5 の当て直し(Verifier の 2 パス目)と手順 6 の 2 パス目(`/code-review`)の両方**。base は「周の在り処」の SHA のまま。 2 パス目の指摘対応を始める直前の HEAD(狭いパスの起点)は、そのとき書く - 割り目(2 パス目の前): 利用者が「ここで割る」を選んだ(2026-10-27)。2 パス目はどちらも未着手 +- 再開(2026-10-27・2 回目): `find-cycle.py` が当たり、ブランチ・コミット(`8ada8ea`)・base の祖先を確かめて入り直した +- 手順 5 の 2 パス目(Verifier。`dev-loop:dev-loop-verifier`・sonnet): **反証 0 件・軽微な所見 3 件**(下の 2 パス目の表の V2-)。 + テストは 3.14・3.12・3.9 で緑、変異 12 件すべて赤。生成物の検査 2 本は緑。`mkdocs build --strict` は Verifier は走らせていない +- 手順 6 の 2 パス目(`/code-review` high): **指摘 5 件**(下の 2 パス目の表の CR2-。CR2-1 は V2-1 と同じ)。直す 3 件・残課題 2 件 ## 既知の限界・決着済みの論点 @@ -131,3 +135,16 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 | CR8: 「通知が失敗しても記録は残る」の後半を壊す変異が無い | 示せない(いまのテストでも記録の行数で捕まると `/code-review` 自身が書いている) | 無し | 残課題 | | CR9: `assert_not_real_home` が素の `assert`(`-O` で消える) | 示せない(テストを `-O` で走らせる経路が無い) | 無し | 残課題 | | CR10: docs のコマンドがパスをクォートしていない | 示せない(空白を含むパスでの失敗は仮定。手元の clone には空白が無い) | 無し | 残課題 | + +### 2 パス目の指摘(採否) + +| 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | +| --- | --- | --- | --- | +| V2-1: `cwd` が対にならないサロゲートだと `cwd.encode("utf-8")` が例外を出し、記録が `unreadable` の 1 行になって `error` が失われる | 示せる(著者が再現: scratchpad の `repro-v2.py` → exit 0・`unreadable` の 1 行。普通の `cwd` なら `error: rate_limit` が残る) | 4(`rate_limit` なのに通知に「待て」が付かない。著者は最初 2 だけを見て「無し」とした) | 直す(CR2-1) | +| V2-2: `error` が空文字のとき通知が「入力を読めなかった」と出る(記録は正しい) | 示せない(本体の列挙値に空文字は無い) | 無し | 残課題 | +| V2-3: 上限でない `error` が `osascript` の引数にそのまま入る(`-` で始まる値の扱いは未確認) | 示せない(本物を走らせていない。値は本体の列挙値) | 無し | 残課題 | +| CR2-1: `cwd` が対にならないサロゲートだと記録が `unreadable` になり、`rate_limit` でも通知に「待て」が付かない(V2-1 と同じ) | 示せる(`/code-review` と著者が再現。`repro-v2.py`) | 4(上限の `error` なのに「待て」を添えない)・docstring の「書けない文字が来ても記録は残る」 | 直す(V2-1 も同じ修正で直る) | +| CR2-2: `main` の外側の try/except を外す変異がテストで死なない | 示せない(いまのコードでは落ちない。変異体でだけ落ちる) | 無し | 残課題 | +| CR2-3: 「オブジェクトでない」「error が無い」の変異が、約束ではなく理由の文言の違いで死んでいる | 示せない(テストの判別力の話。いまのコードは約束を守っている) | 無し | 残課題 | +| CR2-4: サロゲートの入力が `error`・`session_id` だけで、`cwd` に無い | 示せる(CR2-1 がテストを緑のまま通っている) | 4(CR2-1 と同じ) | 直す(CR2-1 の入力に足す) | +| CR2-5: docs の「hook が壊れていても、何も表示されません」は確かめていない | 示せる(壊れた hook を走らせた実験が無い。本体には hook 失敗時の警告を組む箇所がある) | 9 | 直す(「確かめていない」に弱める) | From 00930e37f1b4863df2dda4b5805baf233c07b8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 15:43:28 +0900 Subject: [PATCH 06/10] =?UTF-8?q?StopFailure=20=E3=81=AE=20hook:=20cwd=20?= =?UTF-8?q?=E3=81=AE=E6=9B=B8=E3=81=91=E3=81=AA=E3=81=84=E6=96=87=E5=AD=97?= =?UTF-8?q?=E3=81=A7=E8=A8=98=E9=8C=B2=E3=82=92=E8=90=BD=E3=81=A8=E3=81=95?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=83=BB=E7=A2=BA=E3=81=8B=E3=82=81=E3=81=A6?= =?UTF-8?q?=E3=81=84=E3=81=AA=E3=81=84=E8=A1=A8=E7=A4=BA=E3=82=92=E5=BC=B1?= =?UTF-8?q?=E3=82=81=E3=82=8B=EF=BC=88#468=20=E3=81=AE=202=20=E3=83=91?= =?UTF-8?q?=E3=82=B9=E7=9B=AE=E3=81=AE=E6=8C=87=E6=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-468.md | 1 + docs/usage/usage-limit-stop-hook.md | 3 ++- scripts/record-stop-failure.py | 3 ++- scripts/test-record-stop-failure.py | 9 ++++++++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md index 3a2a2752..448e5f33 100644 --- a/docs/plans/issue-468.md +++ b/docs/plans/issue-468.md @@ -111,6 +111,7 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 - 手順 5 の 2 パス目(Verifier。`dev-loop:dev-loop-verifier`・sonnet): **反証 0 件・軽微な所見 3 件**(下の 2 パス目の表の V2-)。 テストは 3.14・3.12・3.9 で緑、変異 12 件すべて赤。生成物の検査 2 本は緑。`mkdocs build --strict` は Verifier は走らせていない - 手順 6 の 2 パス目(`/code-review` high): **指摘 5 件**(下の 2 パス目の表の CR2-。CR2-1 は V2-1 と同じ)。直す 3 件・残課題 2 件 +- 2 パス目の指摘対応を始める直前の HEAD(狭いパスの起点): `35196a76148ceac03f258e07a4aad4a9b8150686` ## 既知の限界・決着済みの論点 diff --git a/docs/usage/usage-limit-stop-hook.md b/docs/usage/usage-limit-stop-hook.md index d00c06f8..cc35b05b 100644 --- a/docs/usage/usage-limit-stop-hook.md +++ b/docs/usage/usage-limit-stop-hook.md @@ -52,7 +52,8 @@ Claude Code 2.1.311 の本体は `billing_error` を「usage limit reached — c `StopFailure` は、API のエラー(rate limit・認証の失敗など)でターンが終わったときに `Stop` の代わりに発火するイベントで、 matcher は入力の `error` に当たります(値には `rate_limit` のほか `authentication_failed`・`overloaded`・`billing_error` などがあります。 -Claude Code 2.1.311 の本体の文字列から読みました)。**hook の出力と終了コードは無視されます**——hook が壊れていても、何も表示されません。 +Claude Code 2.1.311 の本体の文字列から読みました)。**hook の出力と終了コードは無視されます**(本体の説明文)。hook が壊れているときに何か表示されるかは確かめていません +——**何も出ないことを「hook が動いた」と読まないでください**(記録のファイルに行が増えたかで確かめます)。 ## 確かめたこと・確かめていないこと diff --git a/scripts/record-stop-failure.py b/scripts/record-stop-failure.py index c92114b9..9f385c62 100644 --- a/scripts/record-stop-failure.py +++ b/scripts/record-stop-failure.py @@ -32,6 +32,7 @@ - `error` が無い入力でも例外を出さず、`unreadable` の 1 行を残す - 前の行を消さずに追記する(`~/.claude` が無ければ作る) - 書けない文字(対にならないサロゲート)が来ても記録は残る +- `cwd` が書けない文字を含んでも、`error` を含む記録を残す(ハッシュを取るところで落とさない) - 通知が失敗しても(`osascript` が無い・失敗する・引数にできない値)記録は残り、0 で終わる - 上限らしい `error`(`LIMIT`)なら、通知に「『継続』と打たずにリセットまで待つ」(#456)を添える - 上限らしくない `error` には「待て」を添えない(認証の失敗などに待っても直らない) @@ -89,7 +90,7 @@ def parse(raw: bytes) -> dict: "session_id": text(data.get("session_id"), SHORT), "error": text(data["error"], SHORT)} cwd = data.get("cwd") if isinstance(cwd, str): - row["cwd_hash"] = hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:12] + row["cwd_hash"] = hashlib.sha256(cwd.encode("utf-8", "surrogatepass")).hexdigest()[:12] details = text(data.get("error_details"), DETAILS) if details is not None: row["error_details"] = details diff --git a/scripts/test-record-stop-failure.py b/scripts/test-record-stop-failure.py index 3f10e2e8..a4cfebc2 100644 --- a/scripts/test-record-stop-failure.py +++ b/scripts/test-record-stop-failure.py @@ -47,6 +47,8 @@ ("billing", json.dumps({"hook_event_name": "StopFailure", "error": "billing_error"}).encode(), "fake"), # 対にならないサロゲート——json.loads は通すが UTF-8 では書けない ("surrogate", b'{"error": "\\ud800x", "session_id": "\\udc00"}', "fake"), + # cwd のサロゲート——ハッシュを取るときに符号化するので、ここで落ちると上限の記録ごと失う + ("surrogate-cwd", b'{"error": "rate_limit", "cwd": "\\ud800"}', "fake"), # NUL——subprocess の引数にできない ("nul", b'{"error": "a\\u0000b"}', "fake"), ("odd-values", json.dumps({"error": "E" * 5000, "session_id": {"a": "SECRET-SID"}, @@ -61,8 +63,9 @@ '"error": text(data["error"], SHORT)}', '"error": text(data["error"], SHORT), "message": data.get("last_assistant_message")}'), "cwd の生の値を書く": ( - 'row["cwd_hash"] = hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:12]', + 'row["cwd_hash"] = hashlib.sha256(cwd.encode("utf-8", "surrogatepass")).hexdigest()[:12]', 'row["cwd_hash"] = cwd'), + "cwd の書けない文字で記録を落とす": ('cwd.encode("utf-8", "surrogatepass")', 'cwd.encode("utf-8")'), "error_details を切らない": ('text(data.get("error_details"), DETAILS)', 'text(data.get("error_details"), None)'), "error などを切らない": ("SHORT = 100", "SHORT = None"), "文字列でない値を書く": ("value[:limit] if isinstance(value, str) else None", @@ -172,6 +175,10 @@ def main() -> int: and "error_details" not in odd[0]) check("対にならないサロゲートでも記録は残る", obs["surrogate"]["rows"] == [{"hook_event_name": None, "session_id": "\udc00", "error": "\ud800x"}]) + sc = obs["surrogate-cwd"] + check("cwd が対にならないサロゲートでも error を残し、通知に「継続と打たずに」を添える", + len(sc["rows"]) == 1 and sc["rows"][0].get("error") == "rate_limit" and len(sc["rows"][0].get("cwd_hash", "")) == 12 + and len(sc["osa"]) == 4 and WAIT in sc["osa"][2]) check("NUL を含む error でも記録は残る(通知は出せない)", obs["nul"]["rows"] == [{"hook_event_name": None, "session_id": None, "error": "a\x00b"}] and obs["nul"]["osa"] == []) From 99f13f071814e486114fa08e4a405ef630b99202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 15:54:56 +0900 Subject: [PATCH 07/10] =?UTF-8?q?=E8=A8=88=E7=94=BB:=202=20=E3=83=91?= =?UTF-8?q?=E3=82=B9=E7=9B=AE=E3=81=AE=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=E3=81=A8=20main=20=E3=81=AE=E5=8F=96=E3=82=8A=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=82=92=E8=A8=98=E9=8C=B2=EF=BC=88#468=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-468.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md index 448e5f33..6ee5ca24 100644 --- a/docs/plans/issue-468.md +++ b/docs/plans/issue-468.md @@ -112,6 +112,10 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 テストは 3.14・3.12・3.9 で緑、変異 12 件すべて赤。生成物の検査 2 本は緑。`mkdocs build --strict` は Verifier は走らせていない - 手順 6 の 2 パス目(`/code-review` high): **指摘 5 件**(下の 2 パス目の表の CR2-。CR2-1 は V2-1 と同じ)。直す 3 件・残課題 2 件 - 2 パス目の指摘対応を始める直前の HEAD(狭いパスの起点): `35196a76148ceac03f258e07a4aad4a9b8150686` +- 2 パス目の指摘対応: `00930e3`(直す 3 件: CR2-1/V2-1・CR2-4・CR2-5)。著者の変異 1 件(`surrogatepass` を外す)は赤。`check-all.py` 52/52 緑 +- main を取り込んだ(利用者の指示。`e7c36c6`=#464。衝突なし・足された検査なし)。取り込み後も `check-all.py` 52/52 緑。 + 利用者が「環境の点検の節から設定手順のページを指す 1 行」を求めたが、既に 90〜93 行にあると伝え、**利用者が「そのまま」を選んだ**(2026-10-27) +- 狭いパス: 取り込みが HEAD に入ったので、差分は `git diff 35196a7 00930e3`(指摘対応のコミットだけ)で当てる ## 既知の限界・決着済みの論点 From 63ee53ec3cf178bfeec0c9d687e8e87fbe77bb6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 16:03:29 +0900 Subject: [PATCH 08/10] =?UTF-8?q?=E8=A8=88=E7=94=BB:=20=E7=8B=AD=E3=81=84?= =?UTF-8?q?=E3=83=91=E3=82=B9=E3=81=AE=E7=B5=90=E6=9E=9C=E3=82=92=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=EF=BC=88#468=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-468.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md index 6ee5ca24..a6cf2bcc 100644 --- a/docs/plans/issue-468.md +++ b/docs/plans/issue-468.md @@ -116,6 +116,9 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 - main を取り込んだ(利用者の指示。`e7c36c6`=#464。衝突なし・足された検査なし)。取り込み後も `check-all.py` 52/52 緑。 利用者が「環境の点検の節から設定手順のページを指す 1 行」を求めたが、既に 90〜93 行にあると伝え、**利用者が「そのまま」を選んだ**(2026-10-27) - 狭いパス: 取り込みが HEAD に入ったので、差分は `git diff 35196a7 00930e3`(指摘対応のコミットだけ)で当てる +- 狭いパス(Verifier・sonnet): **反証 1 件(軽微)**——下の N-1。著者の変異は赤、Verifier の変異 2 件(`replace`・`ignore`)が素通り。直さず人間に渡す +- 2 パス目の後の `mkdocs build --strict`(`plans/` を外した一時の設定): 通る +- 関門はここで打ち切り(2 パス+狭いパス)。次: push・PR 作成・PR コメント ## 既知の限界・決着済みの論点 @@ -153,3 +156,9 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 | CR2-3: 「オブジェクトでない」「error が無い」の変異が、約束ではなく理由の文言の違いで死んでいる | 示せない(テストの判別力の話。いまのコードは約束を守っている) | 無し | 残課題 | | CR2-4: サロゲートの入力が `error`・`session_id` だけで、`cwd` に無い | 示せる(CR2-1 がテストを緑のまま通っている) | 4(CR2-1 と同じ) | 直す(CR2-1 の入力に足す) | | CR2-5: docs の「hook が壊れていても、何も表示されません」は確かめていない | 示せる(壊れた hook を走らせた実験が無い。本体には hook 失敗時の警告を組む箇所がある) | 9 | 直す(「確かめていない」に弱める) | + +### 狭いパスの指摘(採否は人間が決める) + +| 指摘 | 実物で失敗を示せるか | 破る受入基準 | 採否 | +| --- | --- | --- | --- | +| N-1: `surrogatepass` を `replace`/`ignore` に変えた変異が、振る舞いのチェックを素通りする(テストは `cwd_hash` の長さしか見ず、`"\ud800"` と `"?"`/`""` のハッシュの衝突を判別しない)。現行のコードは衝突しない | 示せない(現行のコードは正しい。変異体でだけ起きる。陽性対照: `replace` は `"?"` と、`ignore` は `""` と同じハッシュ、`surrogatepass` は別——著者も確かめた) | 無し | 人間が決める | From 506ba2c8af7faefa575b0c581421972bf3107d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 16:16:11 +0900 Subject: [PATCH 09/10] =?UTF-8?q?=E8=A8=88=E7=94=BB:=20PR=20#471=20?= =?UTF-8?q?=E3=81=A8=20CI=20=E3=81=AE=E7=B5=90=E6=9E=9C=E3=82=92=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=EF=BC=88#468=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-468.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md index a6cf2bcc..85a1bc16 100644 --- a/docs/plans/issue-468.md +++ b/docs/plans/issue-468.md @@ -118,7 +118,8 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 - 狭いパス: 取り込みが HEAD に入ったので、差分は `git diff 35196a7 00930e3`(指摘対応のコミットだけ)で当てる - 狭いパス(Verifier・sonnet): **反証 1 件(軽微)**——下の N-1。著者の変異は赤、Verifier の変異 2 件(`replace`・`ignore`)が素通り。直さず人間に渡す - 2 パス目の後の `mkdocs build --strict`(`plans/` を外した一時の設定): 通る -- 関門はここで打ち切り(2 パス+狭いパス)。次: push・PR 作成・PR コメント +- 関門はここで打ち切り(2 パス+狭いパス) +- PR #471 を作り、関門の結果・未証明・残課題を PR コメントに書いた。CI: `check`・`build` とも pass(`deploy` は PR なので skipping)、`MERGEABLE`。次: 人間のレビューとマージ(手順 7)、手順 8 ## 既知の限界・決着済みの論点 From 51cdc17d76dfa3ab54f5dc7dedced13cb4a36ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=88=E8=89=AF=20=E8=8B=B1=E6=A8=B9?= Date: Tue, 27 Oct 2026 16:52:33 +0900 Subject: [PATCH 10/10] =?UTF-8?q?=E8=A8=88=E7=94=BB:=20#468=20=E3=81=AE?= =?UTF-8?q?=E8=BF=BD=E8=A8=98=E3=82=92=20#473=20=E3=81=AB=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=81=9F=E3=81=93=E3=81=A8=E3=82=92=E8=A8=98=E9=8C=B2?= =?UTF-8?q?=EF=BC=88#468=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plans/issue-468.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plans/issue-468.md b/docs/plans/issue-468.md index 85a1bc16..102ddf69 100644 --- a/docs/plans/issue-468.md +++ b/docs/plans/issue-468.md @@ -84,7 +84,7 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 ## 手順 8 -- この周の起票: 0 件 +- この周の起票: 1 件 ## 関門の進捗(再開点) @@ -120,6 +120,8 @@ docs と `CLAUDE.md` は**本体の docstring を正とする**と書いて列 - 2 パス目の後の `mkdocs build --strict`(`plans/` を外した一時の設定): 通る - 関門はここで打ち切り(2 パス+狭いパス) - PR #471 を作り、関門の結果・未証明・残課題を PR コメントに書いた。CI: `check`・`build` とも pass(`deploy` は PR なので skipping)、`MERGEABLE`。次: 人間のレビューとマージ(手順 7)、手順 8 +- #468 に利用者の追記(認証切れの記録・通知/起動時の認証方式の点検/子の 401 の扱い)。**利用者が「#471 はこのまま、全部を新しい Issue に」を選んだ**(2026-10-27)→ #473 を起票。 + 記録は matcher なしの登録で既に `authentication_failed` として残る(受入基準 8 の陽性対照)。足りないのは通知の文言と、`plugins/` 側の 2 件 ## 既知の限界・決着済みの論点