Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 を正とする**)
Expand Down
167 changes: 167 additions & 0 deletions docs/plans/issue-468.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion docs/plugins/dev-loop-cruise.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: …)` で書きます。
Expand Down
87 changes: 87 additions & 0 deletions docs/usage/usage-limit-stop-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 利用上限の停止をその場で記録する(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 なら通知を出します。上限らしい `error`(`rate_limit`・`billing_error`)のときだけ、
「止まっても『継続』と打たずにリセットまで待ってください」と添えます

**何を書き、何を書かないか(案件の中身は書きません)と、何を守らないかは、スクリプトの docstring を正とします。**

## 設定する

登録は**利用者が行います**(リポジトリから利用者の settings を書き換えることはしません)。
全プロジェクトのセッションで効かせるため、**user の settings**(`~/.claude/settings.json`)の `hooks` に足します。
`<リポジトリ>` は、このリポジトリを clone した**メインの作業ツリー**の絶対パスに置き換えてください
(worktree のパスを書くと、その worktree を消したときに hook が動かなくなります)。

```json
{
"hooks": {
"StopFailure": [
{
"hooks": [
{
"type": "command",
"command": "python3 <リポジトリ>/scripts/record-stop-failure.py"
}
]
}
]
}
}
```

既に `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 が壊れているときに何か表示されるかは確かめていません
——**何も出ないことを「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`・`billing_error` ほか)になるか**——上限を狙って起こせません。
**記録が無いことは「発火しない」の証拠になりません**(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)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/check-all.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions scripts/record-stop-failure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/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`——文字列のときだけ、先頭 `SHORT` 文字まで(それ以外は `null`)
- `cwd_hash`——`cwd` の SHA-256 の先頭 12 文字(同じ場所の停止を束ねるため。**生の値は書かない**)
- `error_details`——文字列のときだけ、先頭 `DETAILS` 文字まで
- 読めなかったときは `time` と `unreadable`(理由だけ。**入力の値は書かない**)

守る:

- `last_assistant_message` の値を、記録にも通知にも出さない
- `cwd` の生の値を、記録にも通知にも出さない
- `error_details` は先頭 `DETAILS` 文字まで
- `error`・`session_id`・`hook_event_name` は先頭 `SHORT` 文字まで
- 文字列でない値(辞書など)は書かない
- JSON でない・オブジェクトでない入力で例外を出さず、`unreadable` の 1 行を残す
- `error` が無い入力でも例外を出さず、`unreadable` の 1 行を残す
- 前の行を消さずに追記する(`~/.claude` が無ければ作る)
- 書けない文字(対にならないサロゲート)が来ても記録は残る
- `cwd` が書けない文字を含んでも、`error` を含む記録を残す(ハッシュを取るところで落とさない)
- 通知が失敗しても(`osascript` が無い・失敗する・引数にできない値)記録は残り、0 で終わる
- 上限らしい `error`(`LIMIT`)なら、通知に「『継続』と打たずにリセットまで待つ」(#456)を添える
- 上限らしくない `error` には「待て」を添えない(認証の失敗などに待っても直らない)

守らない:

- **`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 の通知の設定で出ないことがある。
`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
SHORT = 100
LIMIT = ("rate_limit", "billing_error")
WAIT = "止まっても「継続」と打たずに、リセットまで待ってください(#456 の陽性対照のため)"


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:
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": 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", "surrogatepass")).hexdigest()[:12]
details = text(data.get("error_details"), DETAILS)
if details is not None:
row["error_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:
# 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 "入力を読めなかった"
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"
"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 Exception: # noqa: BLE001 — 書けなくても通知は出す
pass
try:
notify(row)
except Exception: # noqa: BLE001 — 通知の失敗(NUL を含む引数など)でも 0 で終わる
pass
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading