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
3 changes: 3 additions & 0 deletions .claude/hooks-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
# "jj-main-guard" — jj new main / jj edit main
# "jj-push-guard" — jj git push / jj push 禁止 (pnpm push に誘導)
# "gh-pr-merge-guard" — gh pr merge ブロック (pnpm merge-pr に誘導)
# "polling-anti-pattern" — until/while ! ... sleep の polling ループをブロック
# (rate-limit 浪費防止、PR #86 T1-1)
# "electron" — Electron GUI 実行ブロック
# プリセット名以外の文字列は正規表現としてカスタムパターン扱い
blocked_patterns = [
Expand All @@ -35,6 +37,7 @@ blocked_patterns = [
"jj-push-guard",
"gh-pr-create-guard",
"gh-pr-merge-guard",
"polling-anti-pattern",
"electron",
]

Expand Down
5 changes: 2 additions & 3 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
|---|---|---|---|---|---|
| 1 | 🚀 Tier 1 | push 前 untracked `__*` ファイル警告 hook (PR #85 T1-4) | todo2.md | Small | なし (PR #85 直接対策) |
| 2 | 🚀 Tier 1 | `cli-push-runner` jj bookmark 未設定 early-exit (PR #85 T1-3) | todo2.md | S | なし |
| 3 | 🚀 Tier 1 | **Polling anti-pattern 検出ルール (PR #86 T1-1)** | todo2.md | XS | なし (PR #86 直接対策) |
| 4 | 🚀 Tier 1 | **Stop hook の `pnpm lint:md` 統合 (PR #88 T1-1)** | todo3.md | XS | なし (PR #88 直接対策、旧順位 1 完了済の gap closure) |
| 5 | 🚀 Tier 1 | **AI 生成一時スクリプト pattern の pre-push 検出 (PR #88 T1-2)** | todo3.md | Small | 順位 1 と関連 (要擦り合わせ) |
| 6 | 🚀 Tier 1 | ADR-032 PR-pre: GitHub Branch Protection 整備 | todo2.md | 設定のみ | なし (依存タスクは完了済) |
Expand All @@ -29,7 +28,7 @@
| 9 | 🔧 Tier 2 | reviewer facet 改善 (review-simplicity / review-security の DRY/YAGNI/security 軸明文化) | todo2.md | S | なし |
| 10 | 🔧 Tier 2 | ADR-032 PR-broken-link: broken-link-check + 内部アンカー検査 統合 | todo2.md | Small-中 | なし (clean baseline 確立済) |
| 11 | 🔧 Tier 2 | `cli-pr-monitor` プロセス正常終了の integration test (PR #85 T2-2) | todo2.md | S | なし |
| 12 | 🔧 Tier 2 | **`cli-pr-monitor` ポーリング延長 + 重複起動ロック (PR #88 T2-4)** ★ rate-limit critical | todo3.md | Medium | なし (順位 3 と補完) |
| 12 | 🔧 Tier 2 | **`cli-pr-monitor` ポーリング延長 + 重複起動ロック (PR #88 T2-4)** ★ rate-limit critical | todo3.md | Medium | なし (Polling anti-pattern 検出 (PR #86 T1-1, 完了済) と補完) |
| 13 | 🔧 Tier 2 | **post-pr-review に rate-limit 自動検出 + 再トリガー (PR #89 T2-1)** ★ rate-limit critical | todo3.md | Medium | なし (順位 12 と補完) |
| 14 | 🔧 Tier 2 | **post-pr-review fix loop の `.claude/` filter + ADR-030 制約明記 (PR #91 T2-1 + T3-2 Bundle)** ★ convergence | todo3.md | S + XS | なし (PR #91 直接対策、analyze facet + ADR 追記の同 PR Bundle) |
| 15 | 🔧 Tier 2 | **cli-pr-monitor 通知 Recovery 経路 (SessionStart hook 拡張)** ★ silent loss prevention | todo3.md | S/M | なし (ADR-030 L2 recovery パターンを cli-pr-monitor に適用) |
Expand All @@ -52,7 +51,7 @@
**Bundle 1 完了 + post-merge-feedback 反映 (2026-04-29)**: PR #91 (Bundle 1: PowerShell + Markdown anchor lint rules) merge 後の post-merge-feedback で **4 件の新規 task を追加** (PowerShell `(?i)` 自動検証 / `.claude/` filter + ADR-030 制約 / cli-pr-monitor 通知 Recovery 経路 / takt REJECT-ESCALATE)。**前 2 件は本 PR で実証された「fix iteration の根因」に対する決定論的防止策で最優先候補**。**日付ベース見出しアンカーのグローバル明文化 task は決定論的防止 (no-mutable-anchor rule) との二重防衛として継続有効**。

**reviewer facet 改善 task は全 PR の review 精度を即時向上させ、Tier 2 内で 週次レビュー Phase B / ADR-032 PR-broken-link / cli-pr-monitor exit test と並列実施可能**。
**rate-limit 系の 2 タスク (cli-pr-monitor ポーリング延長 + 重複起動ロック / post-pr-review rate-limit 自動検出 + 再トリガー) は rate-limit 直撃のため Tier 2 内で最優先候補**。前者 = ポーリング頻度全体の削減、後者 = review 単位での自動再トリガー、Polling anti-pattern 検出 (Tier 1) を含む 3 層で rate-limit を抑制する設計。
**rate-limit 系の 2 タスク (cli-pr-monitor ポーリング延長 + 重複起動ロック / post-pr-review rate-limit 自動検出 + 再トリガー) は rate-limit 直撃のため Tier 2 内で最優先候補**。前者 = ポーリング頻度全体の削減、後者 = review 単位での自動再トリガー、Polling anti-pattern 検出 (PR #86 T1-1、完了済) を含む 3 層で rate-limit を抑制する設計。
**post-pr-review fix loop の `.claude/` filter + Recovery 経路 (SessionStart hook 拡張) は本 PR #91 の直接観測知見**。前者 = path-based filter で 8 step 空費の pathological loop を防止 / 後者 = SessionStart hook で再起動跨ぎの通知ロスト防止。
**Stop hook の `pnpm lint:md` 統合 task は Markdown linter hook 統合 (PR #88 で merged) の gap closure**。**AI 生成一時スクリプト pattern 検出は push 前 untracked `__*` hook (PR #85 T1-4) と関連** (実装前に擦り合わせ要)。
**`.failed` marker 自己文書化 task は ADR-030 soft-fail 機構の運用負荷削減** (PR #89 セッションで recovery が機能した実証から派生、Effort S)。
Expand Down
38 changes: 0 additions & 38 deletions docs/todo2.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,44 +615,6 @@ termination 残留の root cause が未調査 (タスク開始時に最初に調

なし

### Polling anti-pattern 検出ルール (PR #86 T1-1)

> **動機**: 同一セッション内で `run_in_background: true` の Bash 起動直後に `until ... sleep` で polling する pattern が 2 回発生し、Claude Code Max (5x) のレートリミットを 1 時間で 40% 消費した。背景タスクは task-notification ベースで自走するので polling は不要だが、AI が反射的に「完了確認用 polling」を書く傾向がある。決定論的検出ルールで防止する。
>
> **本タスクの位置づけ**: ADR-007 (custom_lint_rule の正規表現/AST 層線引き) に従い、コマンド列の文脈検出 (file 単位ではなく Bash tool call の系列) なので PreToolUse hook 実装 or `.claude/hooks-config.toml` への新ルール追加で対応する。
>
> **参照**: `.claude/feedback-reports/86.md` Tier 1 #1
>
> **実行優先度**: 🚀 **Tier 1** — XS 工数、daily efficiency への直接効果が極めて大 (1 セッションで rate limit 40% 浪費を防止)。post-pr-monitor polling 禁止のグローバル明文化 task と補完関係 (本タスクは決定論的防止、ガイドライン task はドキュメント補完)。

#### 設計決定 (案)

- 配置先候補:
- 第一候補: PreToolUse hook (Bash tool call の context を見られる)
- 第二候補: PostToolUse hook + 直近 N tool calls の履歴 buffering
- 第三候補: `.claude/hooks-config.toml` の新セクション (custom_lint_rule の系列検出版)
- 検出ロジック (案):
- 直近の Bash tool call が `run_in_background: true` で実行された
- 続く Bash tool call が `until.*sleep` パターンを含む (例: `until grep -q ...; do sleep N; done`)
- 該当時に warning を出し、`task-notification ベースで自走するため polling 不要` と提案

#### 作業計画

- [ ] 配置先決定 (PreToolUse hook が最有力)
- [ ] 検出ロジック実装 + dogfood
- [ ] 警告メッセージで具体的な代替手段 (task-notification 待機) を提示
- [ ] 派生プロジェクトへ deploy
- [ ] 本 todo2.md エントリを削除

#### 完了基準

- `run_in_background: true` 直後の `until.*sleep` polling が検出され警告が出る
- 同種事故 (rate limit 大量消費) が再発しない

#### 詰まっている箇所

なし

### post-pr-monitor polling 禁止のグローバル明文化 (PR #86 T3-2)

> **動機**: PR #85 / PR #86 のセッション中、Claude が post-pr-monitor の出力を `until grep -q ...; do sleep N; done` で polling し、takt の verbose な AI 思考ログを context に取り込んで token を浪費した。ADR-018 で「post-pr-monitor は daemon として自走」原則は既述だが、Claude 向けの操作レベル指針が不足。
Expand Down
171 changes: 171 additions & 0 deletions src/hooks-pre-tool-validate/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,51 @@ pnpm create-pr -- --title "タイトル" --body "本文"
]
}

/// プリセット: polling-anti-pattern (rate-limit 浪費を招く polling ループを禁止)
///
/// 検出対象:
/// - `until <cond>; do ... sleep N ... done` (条件達成までの polling)
/// - `while ! <cond>; do ... sleep N ... done` (条件達成までの polling、while 版)
///
/// 動機: 同一セッション内で `run_in_background: true` の Bash 起動直後に
/// `until ... sleep` で polling する pattern が頻発し、Claude Code Max (5x) の
/// レートリミットを 1 時間で 40% 浪費した実例がある (PR #86)。
/// 背景タスクは task-notification ベースで自走するため polling は不要。
fn preset_polling_anti_pattern() -> Vec<BlockedPattern> {
let msg = r#"**Polling ループがブロックされました**

`until ... sleep` / `while ! ... sleep` 形式の polling は、Claude Code の
レートリミットを大量に消費するため禁止されています (1 セッションで 40% 浪費の実例あり)。

**代替手段:**
| 用途 | 推奨方法 |
|------|---------|
| 背景タスクの完了待機 | `run_in_background: true` で起動 → task-notification 経由で自動通知される |
| ログ/イベントのストリーミング | `Monitor` tool を使用 (until ループ不要) |
| 状態の単発確認 | `gh pr view --json` 等の構造化データ取得を 1 回だけ実行 |
| 長時間プロセス | `run_in_background: true` で起動し、完了通知を待つ |

**設計原則:** Claude Code の background task と task-notification はイベント駆動で
完了通知を配信する。polling は token を浪費するだけで何も加速しない。

詳細: ADR-018 (post-pr-monitor は daemon + state file で自走)、
docs/todo.md の「Polling anti-pattern 検出ルール」を参照。"#;
// \bdo\b 制約により以下の false positive を排除:
// - echo "wait until ready"; sleep 1 (string 中の until)
// - git log --until=yesterday; sleep 1 (フラグ引数の until)
// - コメント / 文字列に until/while を含むスクリプト
vec![
BlockedPattern {
pattern: Regex::new(r"(?is)\buntil\b.*?\bdo\b.*?\bsleep\s+\d").unwrap(),
message: msg,
},
BlockedPattern {
pattern: Regex::new(r"(?is)\bwhile\s+!\s.*?\bdo\b.*?\bsleep\s+\d").unwrap(),
message: msg,
},
]
}

/// プリセット: gh-pr-merge-guard (gh pr merge を禁止し pnpm merge-pr に誘導)
fn preset_gh_pr_merge_guard() -> Vec<BlockedPattern> {
let msg = r#"**gh pr merge がブロックされました**
Expand Down Expand Up @@ -342,6 +387,7 @@ fn build_blocked_patterns(config: &Config) -> Vec<BlockedPattern> {
"jj-push-guard" => patterns.extend(preset_jj_push_guard()),
"gh-pr-create-guard" => patterns.extend(preset_gh_pr_create_guard()),
"gh-pr-merge-guard" => patterns.extend(preset_gh_pr_merge_guard()),
"polling-anti-pattern" => patterns.extend(preset_polling_anti_pattern()),
"electron" => patterns.extend(preset_electron()),
custom => {
// プリセット名以外はカスタム正規表現として扱う
Expand Down Expand Up @@ -851,6 +897,131 @@ mod tests {
));
}

// --- polling-anti-pattern ---

#[test]
fn polling_blocks_until_sleep_oneliner() {
// PR #86 で実証された具体的な polling pattern
assert!(is_blocked_with(
"until grep -q done /tmp/log; do sleep 5; done",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_blocks_until_sleep_multiline() {
// 複数行で書かれた polling
let cmd = "until grep -q ready /tmp/state\ndo\n sleep 3\ndone";
assert!(is_blocked_with(cmd, &["polling-anti-pattern"]));
}

#[test]
fn polling_blocks_until_with_test_bracket() {
// [ ... ] 形式の条件
assert!(is_blocked_with(
"until [ -f /tmp/done ]; do sleep 2; done",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_blocks_while_not_sleep() {
// while ! 形式の polling
assert!(is_blocked_with(
"while ! grep -q done /tmp/log; do sleep 5; done",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_blocks_until_with_cat_state_file() {
// pr-monitor-state.json への polling (実際に頻発した pattern)
assert!(is_blocked_with(
"until cat .claude/pr-monitor-state.json | grep -q complete; do sleep 10; done",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_does_not_block_for_loop_with_sleep() {
// for ループ + sleep は countdown / 順次実行のため polling ではない
assert!(!is_blocked_with(
"for i in $(seq 1 3); do echo $i; sleep 1; done",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_does_not_block_simple_sleep() {
// 単純な sleep のみは polling ではない
assert!(!is_blocked_with("sleep 5", &["polling-anti-pattern"]));
}

#[test]
fn polling_does_not_block_until_without_sleep() {
// sleep を含まない until は polling 判定外 (CPU spin だが別問題)
assert!(!is_blocked_with(
"until [ -f /tmp/done ]; do echo waiting; done",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_does_not_block_echo_string_with_until() {
// 文字列リテラル中の until は誤検出しない (\bdo\b 制約により)
assert!(!is_blocked_with(
"echo 'wait until ready' && sleep 5",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_does_not_block_git_log_until_flag() {
// --until=DATE フラグは git log の引数で polling ではない
assert!(!is_blocked_with(
"git log --until=yesterday; sleep 1",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_does_not_block_string_with_while() {
// 文字列中の while を含むコマンドも誤検出しない
assert!(!is_blocked_with(
"echo 'a while later we sleep'; sleep 2",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_does_not_block_while_true_loop() {
// while true; do ... sleep ... done は daemon-like で polling とは別パターン
// (false positive を避けるため明示的に除外)
assert!(!is_blocked_with(
"while true; do work; sleep 5; done",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_blocks_in_chained_command() {
// chain の中の polling もブロック
assert!(is_blocked_with(
"echo start && until grep -q done; do sleep 3; done",
&["polling-anti-pattern"]
));
}

#[test]
fn polling_default_config_does_not_enable() {
// 後方互換: デフォルトフォールバックには polling-anti-pattern を含めない
// (既存リポジトリへの影響を避ける、明示 opt-in)
let config = Config::default();
let patterns = build_blocked_patterns(&config);
// 既存リポでは polling pattern は通る (config が無い場合の挙動)
assert!(validate_command("until grep -q done; do sleep 5; done", &patterns).is_none());
}

#[test]
fn allows_pnpm_lint() {
assert!(!is_blocked("pnpm lint"));
Expand Down