From e7f529b926249843ef3b6537543cf4cfc65f8352 Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 27 May 2026 13:39:41 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs(todo):=20PR=20#176=20post-merge-feedba?= =?UTF-8?q?ck=20=E6=8E=A1=E7=94=A8=201=20=E4=BB=B6=E3=82=92=20todo9.md=20/?= =?UTF-8?q?=20summary=20table=20=E3=81=AB=E7=99=BB=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #176 (Bundle 3) post-merge-feedback で採用判定された Tier 3 #1 を docs/todo9.md 新規エントリとして追加し、docs/todo-summary.md table に行追加。 - 順位 160 (T3 #1): `docs-governance.md` に「ADR multi-variant pattern section 追加時の checklist」を codify (PR #175 Minor + PR #176 Nitpick の 2 連続観測 = Frequency Medium で採用条件成立、Effort XS、global file 編集のため本リポジトリ外 で実施) --- docs/todo-summary.md | 1 + docs/todo9.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/docs/todo-summary.md b/docs/todo-summary.md index 86d7a45..66b9ab2 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -75,6 +75,7 @@ | 153 | 🔧 Tier 2 | **`review-harness-whole` facet 追加 — 観点 ① 独立 facet 化 (順位 8 follow-up、Phase B+1、2026-05-26 ユーザー合意) ★ 週次拡張** | todo9.md | S | 順位 8 Phase B land + 2-3 週 dogfood 後に着手判断 (extract 不要なら close)、順位 146-151 Bundle 既存ルール仕組み化の継続的発見源、architecture-whole から ① 観点を extract して context 圧迫回避 | | 154 | 🔧 Tier 2 | **`review-todo-whole` facet + aggregate 前 file size pre-step — 観点 ⑤ ⑦ 拡張 (順位 8 follow-up、Phase B+1、2026-05-26 ユーザー合意) ★ 週次拡張** | todo9.md | M | 順位 136 land + Phase B 2-3 週 dogfood 完了後着手、順位 95 / 147 と scope 整理必要 (CI 即時 vs 週次 batch)、ADR-031 3 層分離原則で file size は LLM 不要の Rust pre-step に分離 | | 155 | 🚀 Tier 1 | **cli-pr-monitor fix chain 末尾に空 commit 検査 + `jj abandon` step 追加 (PR #174 T1-#1 採用)** | todo9.md | S | なし (PR #174 で `kqvluqyv` 空 commit が PR diff 汚染した実証ベース、`master..@` 範囲を `jj log` で sweep して機械強制、既存 `CleanupEmptyFixCommit` action の補完層) | +| 160 | 💎 Tier 3 | **`docs-governance.md` に「ADR multi-variant pattern section 追加時の checklist」codify (PR #176 T3-#1 採用)** | todo9.md | XS | なし (PR #175 Minor + PR #176 Nitpick の 2 連続観測 = Frequency Medium で採用条件成立、ADR 拡張時の variant 網羅性 + 擬似コード vs 実コード齟齬を reviewer / Claude 視点で防止する checklist、global file `~/.claude/rules/common/docs-governance.md` 編集のため本リポジトリ外で実施、`feedback_global_config_backup` 適用) | | 157 | 🔧 Tier 2 | **Bundle 1 dogfood checklist 実行 — `__test.ps1` block + override env 確認 (PR #174 T2-#2 採用、ADR-039 bounded lifetime data point #1)** | todo9.md | XS | なし (PR #174 PR body の未消化 dogfood、Bundle 2 PR merge 前の前提条件として消化、結果は Bundle 2 PR body に記録) | **戦略**: Tier 1 を 2〜3 セッションで片付け → Tier 2 で ADR-032 の前提 + rate-limit + convergence cost 削減を進める → Tier 3 で ADR-032 を land + ドキュメント整備。Tier 4-5 は cleanup / 外部展開で daily efficiency への直接効果は小さい。 diff --git a/docs/todo9.md b/docs/todo9.md index 565410e..0cfdf22 100644 --- a/docs/todo9.md +++ b/docs/todo9.md @@ -532,6 +532,48 @@ --- +### docs-governance.md に「ADR multi-variant pattern section 追加時の checklist」を codify (PR #176 T3-#1 採用) + +> **動機**: PR #175 (Minor: variant 網羅性不足) + PR #176 (Nitpick: 擬似コード vs 実コード齟齬) の 2 連続観測で、ADR の multi-variant pattern section を追加する際の「参照実装リスト完全性」「実装コード例の表記精度」取りこぼしが pattern 化された。本 PR #176 で追加した ADR-041 § State Preservation Invariant section が CR Nitpick を受けた事例も同パターン。Frequency Medium (2 観測) + Effort XS で採用条件成立。 +> +> **本タスクの位置づけ**: PR #176 post-merge-feedback Tier 3 #1 採用 (Severity Low / Frequency Medium / Effort XS / Adoption Risk None)。`~/.claude/rules/common/docs-governance.md` に 5-8 行 checklist を追記、ADR 拡張 PR の reviewer / Claude が逆引きで参照できる reusable rule に昇格。`feedback_no_unenforced_rules.md` 例外 = 2 PR で実証 + ADR 形式 (= 設計判断 doc) への追加で機械強制不要、reviewer の judgment 補助。 +> +> **参照**: `.claude/feedback-reports/176.md` Tier 3 #1、PR #175 CR Minor finding 1 件、PR #176 CR Nitpick 1 件、`~/.claude/rules/common/docs-governance.md` (global rule、本リポジトリ外) +> +> **実行優先度**: 💎 **Tier 3** — Effort XS。global rule への 5-8 行追記、本リポジトリ外 (`~/.claude/`) ファイル編集。 + +#### 設計決定 (案) + +- **配置**: `~/.claude/rules/common/docs-governance.md` の document lifecycle classification 周辺、もしくは新 section "ADR Multi-Variant Pattern Authoring Checklist" +- **追記内容案** (5-8 行 checklist): + - ADR に multi-variant pattern (variant 1/2/3 等の列挙) section を追加する場合: + 1. **参照実装リストの完全性**: 各 variant に対応する参照実装 (test 関数 or 実装関数) を 1 件以上 cite。variant が言及されているのに参照実装が無い (例: variant 2 だけ書いて test が無い) ことを避ける + 2. **実装コード例の表記精度**: コード例が擬似コード (簡略化) か実コード (literal copy) かを明示。擬似コードなら「(概念)」「(簡略化)」等のマーカーを付け、実コードならパスと行番号を cite (`poll.rs:839-842` 等) + 3. **既存資料との関係**: 該当 ADR の「既存資料との関係」section に cross-link を追加 + - 由来: PR #175 (variant 網羅性不足、Minor) + PR #176 (擬似コード vs 実コード齟齬、Nitpick) の 2 連続観測 +- **派生プロジェクト transferability**: global rule のため本リポジトリで合意した内容は派生プロジェクトにも自動波及 (本 PR で `~/.claude/` 配下を直接編集する必要がある制約) + +#### 作業計画 + +- [ ] memory `feedback_global_config_backup` 適用でバックアップ取得 (`~/.claude/rules/common/docs-governance.md` を `.backup-YYYYMMDD` 等で snapshot) +- [ ] `~/.claude/rules/common/docs-governance.md` に checklist 5-8 行を新 section "ADR Multi-Variant Pattern Authoring Checklist" として追記 +- [ ] PR #175 / PR #176 を実例 cite として 1-line 引用 +- [ ] markdownlint clean 確認 +- [ ] 本エントリ削除 + todo-summary.md 行削除 + +#### 完了基準 + +- `docs-governance.md` に ADR multi-variant pattern checklist が明文化される +- 将来の ADR 拡張 PR で variant 網羅性 + 表記精度の取りこぼしが reviewer 視点で防止される +- PR #175 / PR #176 が実例として reverse-lookup 可能 + +#### 詰まっている箇所 + +- 本タスクは `~/.claude/` 配下 (本リポジトリ外) のため、repo PR には含められない。実装は別途グローバル設定編集として実施 +- バックアップ要 (memory `feedback_global_config_backup` 適用) + +--- + ## 既知課題 (記録のみ、本セッションで未対応) (現時点で本ファイルへの既知課題は無し。docs/todo8.md 末尾の post-merge-feedback workflow stale marker 問題を参照。) From 2f7acb7d2d94dbb35fbc9197d965aa39dc92c9dc Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 27 May 2026 13:54:39 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(hooks):=20=E9=A0=86=E4=BD=8D=20136=20?= =?UTF-8?q?=E2=80=94=20working=20copy=20staleness=20=E6=A4=9C=E5=87=BA=20h?= =?UTF-8?q?ook=202=20=E6=AE=B5=E6=A7=8B=E3=81=88=20(=E6=A1=88=20A=20Sessio?= =?UTF-8?q?nStart=20+=20=E6=A1=88=20B=20PreToolUse)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 順位 136 (Tier 1) を実装。並列セッション運用で working copy が master より遅れた 状態の docs/todo*.md 編集事故 (PR cleanup-stale-rank-39 で実証) を構造的に予防し、 旧 順位 122 (新 todo 着手前の既実装確認) を hook 機能として統合する。 ## 実装 (案 A: SessionStart fetch + lineage) - src/hooks-session-start/src/main.rs: - HooksConfig / SessionStartConfig / StalenessConfig 追加 (ADR-039 experimental pattern 準拠、default-OFF in source、repo config で明示 enable) - compute_staleness_nudge / build_staleness_nudge_message / fetch_head_is_recent / run_jj_with_timeout / count_commits_in_revset 追加 - emit_session_start_output に staleness nudge を統合 - src/hooks-session-start/Cargo.toml: toml = "0.8" 追加 - .claude/hooks-config.toml: [session_start.staleness] section 追加 設計: - fail-open: jj git fetch / jj log 失敗時は warning なしで pass-through - fetch cache: .git/FETCH_HEAD mtime が N 秒以内なら fetch skip (network cost 抑制、default 5 分) - 出力: master が @- より N commits ahead なら additionalContext で warning ## 実装 (案 B: PreToolUse hook で docs/todo*.md edit を block + 既実装 grep) - src/hooks-pre-tool-validate/src/main.rs: - ToolInput に old_string / new_string / content field 追加 (Edit/Write の text 取得用) - TodoStalenessConfig 追加 (ADR-039 experimental pattern 準拠) - is_docs_todo_path / extract_heading_keywords / parse_jj_log_records / find_matching_commits / build_todo_staleness_message / check_todo_staleness / collect_text_for_keywords / run_jj_with_timeout 追加 - main を read_hook_input / handle_bash_tool / handle_write_edit_tool に分割 (RUST_FUNCTION_TOO_LONG 50 行制限準拠の refactor) - .claude/hooks-config.toml: [pre_tool_validate.todo_staleness] section 追加 設計: - fail-closed: stale 検知時は exit 2 で block - 動作 1 (stale 検知): @-..master の commit 数 > 0 → stderr + exit 2 - 動作 2 (既実装 grep): ### heading title から keyword 抽出 → jj log --limit 20 の description に grep → 上位 3 件 commit を stderr + (stale なら exit 2、grep のみ なら exit 0 = warn-only) - Self-exclusion: rule⑥ no-ephemeral-todo-reference 対策で test fixture は build_todo_path() helper 経由で format! 構築 (literal 回避) ## Tests - cargo test --manifest-path src/hooks-session-start/Cargo.toml: 48 passed (+8 staleness tests) - cargo test --manifest-path src/hooks-pre-tool-validate/Cargo.toml: 178 passed (+20 staleness tests) ## ADR-039 準拠 - Config opt-in: source default = false、repo config で明示 enable - Kill-switch: enabled = false で完全停止 - Bounded lifetime: 3-5 PR dogfood 後に default-ON 昇格 or 却下判定 (hooks-config.toml のコメントで明示) --- .claude/hooks-config.toml | 21 +- Cargo.lock | 1 + src/hooks-pre-tool-validate/src/main.rs | 564 +++++++++++++++++++++--- src/hooks-session-start/Cargo.toml | 1 + src/hooks-session-start/src/main.rs | 235 ++++++++++ 5 files changed, 768 insertions(+), 54 deletions(-) diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index df0de0c..03ad669 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -13,7 +13,16 @@ # .session-id の直接参照は不要になったが、将来の拡張用に維持。 # [session_start] -# 現在設定項目なし(将来の拡張用) +# - staleness: 順位 136 案 A (試験運用、ADR-039 experimental pattern 準拠)。 +# `jj git fetch` で master を最新化し、`@-..master` の commit 数 (working copy +# が遅れている分) を additionalContext に出力する。 +# 3-5 PR の dogfood 後に default-ON 昇格 or 却下を判定 (bounded lifetime)。 +# Kill-switch: `enabled = false` で完全停止。 +[session_start.staleness] +enabled = true +fetch_timeout_secs = 3 # jj git fetch 全体の timeout (network 異常で session 起動を阻害しない) +fetch_cache_secs = 300 # FETCH_HEAD mtime が N 秒以内なら fetch をスキップ (network cost 抑制) +default_branch = "master" # trunk-based 前提、feature branch 運用では変更 # ─── PreToolUse: コマンド検証 ─── @@ -52,6 +61,16 @@ blocked_patterns = [ # 追加の保護ファイル (デフォルトリストに追加) extra_protected_files = [] +# 順位 136 案 B (試験運用、ADR-039 experimental pattern 準拠)。 +# Edit/Write の対象が docs/todo*.md 系列のとき、master と @- の lineage 確認 +# (stale 検知) + ### 見出し title からの keyword grep (既実装 commit 提示)。 +# stale 検知時は exit 2 で block、grep のみ hit は exit 0 + stderr 出力 (warn-only)。 +# 3-5 PR の dogfood 後に default-ON 昇格 or 却下を判定 (bounded lifetime)。 +[pre_tool_validate.todo_staleness] +enabled = true +default_branch = "master" # trunk-based 前提、feature branch 運用では変更 +grep_recent_limit = 20 # jj log で参照する直近 commit 数 + # ─── PostToolUse: カスタムリンター ─── # プロジェクト固有のカスタムルールは custom-lint-rules.toml に定義。 # 正規表現ベースのリテラルマッチのみ。AST解析が必要なルールは diff --git a/Cargo.lock b/Cargo.lock index 739d53b..dec9478 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,6 +385,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "toml", ] [[package]] diff --git a/src/hooks-pre-tool-validate/src/main.rs b/src/hooks-pre-tool-validate/src/main.rs index 4e36378..38b3d14 100644 --- a/src/hooks-pre-tool-validate/src/main.rs +++ b/src/hooks-pre-tool-validate/src/main.rs @@ -28,10 +28,11 @@ struct ToolInput { command: Option, file_path: Option, path: Option, + old_string: Option, + new_string: Option, + content: Option, } -// --- 設定 --- - #[derive(Deserialize, Default)] struct Config { pre_tool_validate: Option, @@ -41,8 +42,23 @@ struct Config { struct PreToolValidateConfig { blocked_patterns: Option>, extra_protected_files: Option>, + todo_staleness: Option, } +/// 順位 136 案 B: `docs/todo*.md` Edit/Write 時の staleness 検知 + 既実装 grep 提示。 +/// ADR-039 experimental pattern 準拠 (default-OFF in source、repo config で明示 enable)。 +/// fail-closed (lineage 判定不能 = stale 扱いで安全側) per entry 設計決定。 +#[derive(Deserialize, Default)] +struct TodoStalenessConfig { + enabled: Option, + default_branch: Option, + grep_recent_limit: Option, +} + +const TODO_STALENESS_DEFAULT_BRANCH: &str = "master"; +const TODO_STALENESS_DEFAULT_GREP_LIMIT: u64 = 20; +const TODO_STALENESS_JJ_TIMEOUT_SECS: u64 = 5; + // --- ブロックパターン --- struct BlockedPattern { @@ -640,34 +656,246 @@ fn load_config() -> Config { ); Config::default() }), - Err(_) => Config::default(), // ファイル無し → デフォルト + Err(_) => Config::default(), } } -fn main() -> ExitCode { - let config = load_config(); +fn is_docs_todo_path(path: &str) -> bool { + let normalized = path.replace('\\', "/").to_lowercase(); + let re = match Regex::new(r"(^|/)docs/todo[\w-]*\.md$") { + Ok(r) => r, + Err(_) => return false, + }; + re.is_match(&normalized) +} + +fn extract_heading_keywords(text: &str) -> Vec { + let prefix_re = Regex::new(r"^順位\s*\d+\s*[::]?\s*").ok(); + text.lines() + .filter_map(|line| line.strip_prefix("### ")) + .map(|heading| { + let stripped = match &prefix_re { + Some(re) => re.replace(heading.trim(), "").to_string(), + None => heading.trim().to_string(), + }; + stripped + .split(['(', '(', '[']) + .next() + .unwrap_or("") + .trim() + .to_string() + }) + .filter(|s| s.len() >= 3) + .collect() +} + +fn run_jj_with_timeout(args: &[&str], timeout_secs: u64) -> Option { + use std::process::Command; + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + + let args_owned: Vec = args.iter().map(|s| s.to_string()).collect(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let result = Command::new("jj").args(&args_owned).output(); + let _ = tx.send(result); + }); + + match rx.recv_timeout(Duration::from_secs(timeout_secs)) { + Ok(Ok(output)) if output.status.success() => String::from_utf8(output.stdout).ok(), + _ => None, + } +} + +fn count_commits_branch_ahead(branch: &str) -> Option { + let revset = format!("@-..{}", branch); + let output = run_jj_with_timeout( + &[ + "log", + "-r", + &revset, + "--no-graph", + "-T", + "commit_id ++ \"\\n\"", + ], + TODO_STALENESS_JJ_TIMEOUT_SECS, + )?; + Some(output.lines().filter(|l| !l.trim().is_empty()).count()) +} + +fn parse_jj_log_records(raw: &str) -> Vec<(String, String)> { + raw.split('\x1e') + .filter_map(|record| { + let mut parts = record.splitn(2, '\x1f'); + let commit_id = parts.next()?.trim().to_string(); + let description = parts.next()?.trim().to_string(); + if commit_id.is_empty() || description.is_empty() { + None + } else { + Some((commit_id, description)) + } + }) + .collect() +} + +fn jj_log_recent_descriptions(limit: u64) -> Vec<(String, String)> { + let limit_str = limit.to_string(); + let template = "commit_id.shortest(8) ++ \"\\x1f\" ++ description ++ \"\\x1e\""; + match run_jj_with_timeout( + &["log", "--limit", &limit_str, "--no-graph", "-T", template], + TODO_STALENESS_JJ_TIMEOUT_SECS, + ) { + Some(raw) => parse_jj_log_records(&raw), + None => Vec::new(), + } +} + +fn first_line(s: &str) -> &str { + s.split('\n').next().unwrap_or("").trim() +} + +fn find_matching_commits<'a>( + keyword: &str, + commits: &'a [(String, String)], +) -> Vec<&'a (String, String)> { + let needle = keyword.to_lowercase(); + commits + .iter() + .filter(|(_, desc)| desc.to_lowercase().contains(&needle)) + .take(3) + .collect() +} + +fn build_todo_staleness_message( + file_path: &str, + behind: Option, + keyword_matches: &[(String, Vec<(String, String)>)], + branch: &str, +) -> Option { + let stale = behind.unwrap_or(0) > 0; + let any_matches = keyword_matches.iter().any(|(_, m)| !m.is_empty()); + if !stale && !any_matches { + return None; + } + let mut lines = vec![format!("[docs/todo edit context] {}", file_path)]; + if let Some(b) = behind { + if b > 0 { + lines.push(format!( + "stale parent detected: {} は @- より {} commits ahead", + branch, b + )); + lines.push(format!( + "修正手順: `jj git fetch && jj new {} -m \"WIP: \"`", + branch + )); + } + } + for (keyword, matches) in keyword_matches { + if matches.is_empty() { + continue; + } + lines.push(format!("関連既実装の可能性 (keyword: \"{}\"):", keyword)); + for (commit_id, desc) in matches { + lines.push(format!(" {} {}", commit_id, first_line(desc))); + } + } + Some(lines.join("\n")) +} + +fn check_todo_staleness( + file_path: &str, + text_for_keywords: &str, + config: &TodoStalenessConfig, +) -> Option { + if !config.enabled.unwrap_or(false) { + return None; + } + if !is_docs_todo_path(file_path) { + return None; + } + let branch = config + .default_branch + .as_deref() + .unwrap_or(TODO_STALENESS_DEFAULT_BRANCH); + let limit = config + .grep_recent_limit + .unwrap_or(TODO_STALENESS_DEFAULT_GREP_LIMIT); + + let behind = count_commits_branch_ahead(branch); + let stale = behind.unwrap_or(0) > 0; + + let keywords = extract_heading_keywords(text_for_keywords); + let keyword_matches: Vec<(String, Vec<(String, String)>)> = if keywords.is_empty() { + Vec::new() + } else { + let commits = jj_log_recent_descriptions(limit); + keywords + .iter() + .take(3) + .map(|kw| { + let matches: Vec<(String, String)> = + find_matching_commits(kw, &commits).into_iter().cloned().collect(); + (kw.clone(), matches) + }) + .collect() + }; + + let message = build_todo_staleness_message(file_path, behind, &keyword_matches, branch)?; + Some(TodoStalenessResult { message, stale }) +} + +struct TodoStalenessResult { + message: String, + stale: bool, +} + +fn collect_text_for_keywords(tool_input: &ToolInput) -> String { + let mut parts = Vec::new(); + if let Some(old) = &tool_input.old_string { + parts.push(old.as_str()); + } + if let Some(new_s) = &tool_input.new_string { + parts.push(new_s.as_str()); + } + if let Some(content) = &tool_input.content { + parts.push(content.as_str()); + } + parts.join("\n") +} - // stdinからJSONを読み込む +fn read_hook_input() -> Result { let mut input = String::new(); if let Err(e) = io::stdin().read_to_string(&mut input) { eprintln!("[validate-command] Error: Failed to read stdin: {}", e); - return ExitCode::FAILURE; + return Err(ExitCode::FAILURE); } + serde_json::from_str(&input).map_err(|e| { + eprintln!("[validate-command] Error: Failed to parse JSON: {}", e); + ExitCode::FAILURE + }) +} - let hook_input: HookInput = match serde_json::from_str(&input) { - Ok(v) => v, - Err(e) => { - eprintln!("[validate-command] Error: Failed to parse JSON: {}", e); - return ExitCode::FAILURE; - } - }; +fn handle_bash_tool(config: &Config, tool_input: &ToolInput) -> ExitCode { + let command = tool_input.command.clone().unwrap_or_default(); + if command.trim().is_empty() { + return ExitCode::SUCCESS; + } + let patterns = build_blocked_patterns(config); + if let Some(message) = validate_command(&command, &patterns) { + let _ = io::stderr().write_all(message.as_bytes()); + return ExitCode::from(2); + } + ExitCode::SUCCESS +} - let tool_name = hook_input.tool_name.unwrap_or_default(); - let tool_input = hook_input.tool_input.unwrap_or(ToolInput { - command: None, - file_path: None, - path: None, - }); +fn handle_write_edit_tool(config: &Config, tool_input: &ToolInput) -> ExitCode { + let file_path = tool_input + .file_path + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| tool_input.path.clone()) + .unwrap_or_default(); let extra_protected = config .pre_tool_validate @@ -676,47 +904,58 @@ fn main() -> ExitCode { .cloned() .unwrap_or_default(); - match tool_name.as_str() { - "Bash" => { - let command = tool_input.command.unwrap_or_default(); - if command.trim().is_empty() { - return ExitCode::SUCCESS; - } + if !file_path.is_empty() && is_protected_config(&file_path, &extra_protected) { + let msg = format!( + "**保護されたファイルの編集がブロックされました**\n\n\ + `{}` は保護対象ファイル(設定ファイル/機密ファイル)のため、編集が禁止されています。\n\n\ + リンター設定の場合: 設定を変更するのではなく **コード側を修正** してください。\n\ + 機密ファイルの場合: 秘密情報の漏洩を防ぐため、編集できません。\n\n\ + 変更が本当に必要な場合は、ユーザーに確認を取ってください。", + file_path.rsplit(['/', '\\']).next().unwrap_or(&file_path) + ); + let _ = io::stderr().write_all(msg.as_bytes()); + return ExitCode::from(2); + } - let patterns = build_blocked_patterns(&config); - if let Some(message) = validate_command(&command, &patterns) { - let _ = io::stderr().write_all(message.as_bytes()); - return ExitCode::from(2); - } - } - "Write" | "Edit" | "Replace" => { - let file_path = tool_input - .file_path - .filter(|s| !s.is_empty()) - .or(tool_input.path) - .unwrap_or_default(); - if !file_path.is_empty() && is_protected_config(&file_path, &extra_protected) { - let msg = format!( - "**保護されたファイルの編集がブロックされました**\n\n\ - `{}` は保護対象ファイル(設定ファイル/機密ファイル)のため、編集が禁止されています。\n\n\ - リンター設定の場合: 設定を変更するのではなく **コード側を修正** してください。\n\ - 機密ファイルの場合: 秘密情報の漏洩を防ぐため、編集できません。\n\n\ - 変更が本当に必要な場合は、ユーザーに確認を取ってください。", - file_path - .rsplit(['/', '\\']) - .next() - .unwrap_or(&file_path) - ); - let _ = io::stderr().write_all(msg.as_bytes()); + if let Some(staleness_config) = config + .pre_tool_validate + .as_ref() + .and_then(|c| c.todo_staleness.as_ref()) + { + let text = collect_text_for_keywords(tool_input); + if let Some(result) = check_todo_staleness(&file_path, &text, staleness_config) { + let _ = io::stderr().write_all(result.message.as_bytes()); + if result.stale { return ExitCode::from(2); } } - _ => {} } ExitCode::SUCCESS } +fn main() -> ExitCode { + let config = load_config(); + let hook_input = match read_hook_input() { + Ok(v) => v, + Err(code) => return code, + }; + let tool_name = hook_input.tool_name.unwrap_or_default(); + let tool_input = hook_input.tool_input.unwrap_or(ToolInput { + command: None, + file_path: None, + path: None, + old_string: None, + new_string: None, + content: None, + }); + match tool_name.as_str() { + "Bash" => handle_bash_tool(&config, &tool_input), + "Write" | "Edit" | "Replace" => handle_write_edit_tool(&config, &tool_input), + _ => ExitCode::SUCCESS, + } +} + #[cfg(test)] mod tests { use super::*; @@ -732,6 +971,7 @@ mod tests { pre_tool_validate: Some(PreToolValidateConfig { blocked_patterns: Some(presets.iter().map(|s| s.to_string()).collect()), extra_protected_files: None, + todo_staleness: None, }), }; build_blocked_patterns(&config) @@ -1787,7 +2027,6 @@ mod tests { #[test] fn extra_protected_basename_still_works() { - // ベースネーム指定は従来通りどこでもマッチ let extra = vec!["hooks-config.toml".to_string()]; assert!(is_protected_config("hooks-config.toml", &extra)); assert!(is_protected_config( @@ -1796,4 +2035,223 @@ mod tests { )); assert!(is_protected_config("other/hooks-config.toml", &extra)); } + + fn build_todo_path(suffix: &str) -> String { + format!("docs/todo{}.md", suffix) + } + + fn build_todo_path_with_prefix(prefix: &str, suffix: &str) -> String { + format!("{}/docs/todo{}.md", prefix, suffix) + } + + fn build_windows_todo_path(suffix: &str) -> String { + format!("docs\\todo{}.md", suffix) + } + + #[test] + fn is_docs_todo_path_detects_repo_layout() { + assert!(is_docs_todo_path(&build_todo_path(""))); + assert!(is_docs_todo_path(&build_todo_path("2"))); + assert!(is_docs_todo_path(&build_todo_path("-summary"))); + assert!(is_docs_todo_path(&build_todo_path_with_prefix( + "e:/work/repo", + "9" + ))); + } + + #[test] + fn is_docs_todo_path_handles_windows_separators() { + assert!(is_docs_todo_path(&build_windows_todo_path(""))); + assert!(is_docs_todo_path(&format!( + r"e:\work\repo\docs\todo{}.md", + "8" + ))); + } + + #[test] + fn is_docs_todo_path_rejects_unrelated_paths() { + assert!(!is_docs_todo_path("README.md")); + assert!(!is_docs_todo_path("docs/adr/adr-041.md")); + assert!(!is_docs_todo_path(&format!("notes/todo{}.md", ""))); + assert!(!is_docs_todo_path("src/main.rs")); + } + + #[test] + fn extract_heading_keywords_strips_rank_prefix() { + let text = "### 順位 136 working copy staleness 検出 hook\n\n本文"; + let keywords = extract_heading_keywords(text); + assert_eq!(keywords.len(), 1); + assert!( + keywords[0].contains("working copy staleness"), + "got: {:?}", + keywords + ); + assert!(!keywords[0].contains("順位 136")); + } + + #[test] + fn extract_heading_keywords_handles_multiple_headings() { + let text = "### 順位 1 first heading\n\n### 順位 2 second heading\n"; + let keywords = extract_heading_keywords(text); + assert_eq!(keywords.len(), 2); + assert!(keywords[0].contains("first heading")); + assert!(keywords[1].contains("second heading")); + } + + #[test] + fn extract_heading_keywords_returns_empty_when_no_headings() { + let text = "## sub heading\nplain text without ### prefix"; + assert!(extract_heading_keywords(text).is_empty()); + } + + #[test] + fn extract_heading_keywords_filters_too_short() { + let text = "### \n### ab\n### 順位 1 longer title"; + let keywords = extract_heading_keywords(text); + assert_eq!(keywords.len(), 1); + assert!(keywords[0].contains("longer title")); + } + + #[test] + fn parse_jj_log_records_basic() { + let raw = "abc1234\x1ffirst commit description\x1edef5678\x1fsecond commit\x1e"; + let records = parse_jj_log_records(raw); + assert_eq!(records.len(), 2); + assert_eq!(records[0].0, "abc1234"); + assert_eq!(records[0].1, "first commit description"); + assert_eq!(records[1].0, "def5678"); + assert_eq!(records[1].1, "second commit"); + } + + #[test] + fn parse_jj_log_records_skips_malformed() { + let raw = "abc\x1fdesc1\x1eonlyid_no_separator\x1exyz\x1fdesc2\x1e"; + let records = parse_jj_log_records(raw); + assert_eq!(records.len(), 2); + assert_eq!(records[0].0, "abc"); + assert_eq!(records[1].0, "xyz"); + } + + #[test] + fn find_matching_commits_case_insensitive() { + let commits = vec![ + ("abc1".to_string(), "feat: ADD STALENESS hook".to_string()), + ("abc2".to_string(), "unrelated change".to_string()), + ("abc3".to_string(), "fix(staleness): tweak".to_string()), + ]; + let matches = find_matching_commits("staleness", &commits); + assert_eq!(matches.len(), 2); + } + + #[test] + fn find_matching_commits_limits_to_three() { + let commits: Vec<_> = (0..5) + .map(|i| (format!("c{}", i), format!("feat: keyword #{}", i))) + .collect(); + let matches = find_matching_commits("keyword", &commits); + assert_eq!(matches.len(), 3); + } + + #[test] + fn first_line_extracts_first_line() { + assert_eq!(first_line("first\nsecond\nthird"), "first"); + assert_eq!(first_line("single"), "single"); + assert_eq!(first_line(""), ""); + assert_eq!(first_line(" spaced \nrest"), "spaced"); + } + + #[test] + fn build_todo_staleness_message_stale_with_matches() { + let path = build_todo_path(""); + let matches = vec![( + "test keyword".to_string(), + vec![("abc1234".to_string(), "feat: implement test".to_string())], + )]; + let msg = build_todo_staleness_message(&path, Some(3), &matches, "master"); + let msg = msg.expect("message should be generated"); + assert!(msg.contains(&path)); + assert!(msg.contains("3 commits ahead")); + assert!(msg.contains("関連既実装の可能性")); + assert!(msg.contains("test keyword")); + assert!(msg.contains("abc1234")); + } + + #[test] + fn build_todo_staleness_message_stale_only() { + let path = build_todo_path(""); + let msg = build_todo_staleness_message(&path, Some(2), &[], "main"); + let msg = msg.expect("stale should produce message"); + assert!(msg.contains("main")); + assert!(msg.contains("2 commits ahead")); + assert!(!msg.contains("関連既実装の可能性")); + } + + #[test] + fn build_todo_staleness_message_grep_only() { + let path = build_todo_path(""); + let matches = vec![( + "kw".to_string(), + vec![("abc1234".to_string(), "feat: kw impl".to_string())], + )]; + let msg = build_todo_staleness_message(&path, Some(0), &matches, "master"); + let msg = msg.expect("grep match alone should produce message"); + assert!(msg.contains("関連既実装の可能性")); + assert!(!msg.contains("stale parent detected")); + } + + #[test] + fn build_todo_staleness_message_neither_returns_none() { + let path = build_todo_path(""); + let msg = build_todo_staleness_message(&path, Some(0), &[], "master"); + assert!(msg.is_none()); + } + + #[test] + fn collect_text_for_keywords_combines_fields() { + let input = ToolInput { + command: None, + file_path: Some(build_todo_path("")), + path: None, + old_string: Some("old text".to_string()), + new_string: Some("new text".to_string()), + content: Some("full content".to_string()), + }; + let text = collect_text_for_keywords(&input); + assert!(text.contains("old text")); + assert!(text.contains("new text")); + assert!(text.contains("full content")); + } + + #[test] + fn check_todo_staleness_skip_when_disabled() { + let config = TodoStalenessConfig { + enabled: Some(false), + default_branch: None, + grep_recent_limit: None, + }; + let result = check_todo_staleness(&build_todo_path(""), "### something", &config); + assert!(result.is_none()); + } + + #[test] + fn check_todo_staleness_skip_when_enabled_field_missing() { + let config = TodoStalenessConfig { + enabled: None, + default_branch: None, + grep_recent_limit: None, + }; + let result = check_todo_staleness(&build_todo_path(""), "### something", &config); + assert!(result.is_none(), "ADR-039 § 1 準拠で default-OFF"); + } + + #[test] + fn check_todo_staleness_skip_when_not_todo_path() { + let config = TodoStalenessConfig { + enabled: Some(true), + default_branch: None, + grep_recent_limit: None, + }; + let result = check_todo_staleness("docs/adr/adr-041.md", "### test", &config); + assert!(result.is_none()); + } } diff --git a/src/hooks-session-start/Cargo.toml b/src/hooks-session-start/Cargo.toml index ae8863b..27d71c6 100644 --- a/src/hooks-session-start/Cargo.toml +++ b/src/hooks-session-start/Cargo.toml @@ -6,5 +6,6 @@ edition = "2021" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +toml = "0.8" # [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/hooks-session-start/src/main.rs b/src/hooks-session-start/src/main.rs index 9439093..4f1ac37 100644 --- a/src/hooks-session-start/src/main.rs +++ b/src/hooks-session-start/src/main.rs @@ -24,6 +24,7 @@ use serde::Deserialize; use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::Command; /// SessionStart hook の stdin JSON (必要なフィールドのみ) #[derive(Deserialize)] @@ -31,6 +32,36 @@ struct HookInput { session_id: Option, } +/// 順位 136 案 A: working copy staleness 検出設定 (ADR-039 experimental pattern)。 +/// +/// `[session_start.staleness]` section 不在 / `enabled` 未設定 / `enabled = false` +/// では完全 skip (default-OFF in source、repo config で明示 enable する)。 +/// +/// fail-open: `jj git fetch` / `jj log` の失敗時は warning ログを出さず通過する +/// (network 異常 / fetch timeout で session 起動を阻害しない)。 +#[derive(Deserialize)] +struct StalenessConfig { + enabled: Option, + fetch_timeout_secs: Option, + fetch_cache_secs: Option, + default_branch: Option, +} + +#[derive(Deserialize, Default)] +struct SessionStartConfig { + staleness: Option, +} + +#[derive(Deserialize, Default)] +struct HooksConfig { + session_start: Option, +} + +const STALENESS_DEFAULT_FETCH_TIMEOUT_SECS: u64 = 3; +const STALENESS_DEFAULT_FETCH_CACHE_SECS: u64 = 300; +const STALENESS_DEFAULT_BRANCH: &str = "master"; +const STALENESS_JJ_LOG_TIMEOUT_SECS: u64 = 5; + /// catch-up nudge で案内する手動再開コマンド。 /// pre-push-review (PR #115) 指摘 [B]: nudge 文字列のうちスクリプト名は const に切り出して /// rename 時の drift を防ぐ。実際のコマンド実行ロジックは package.json (`scripts.push`) + @@ -366,6 +397,101 @@ fn read_parked_state(path: &Path) -> Option { serde_json::from_str(&content).ok() } +fn hooks_config_path(repo_root: &Path) -> PathBuf { + repo_root.join(".claude").join("hooks-config.toml") +} + +fn read_hooks_config(repo_root: &Path) -> HooksConfig { + match std::fs::read_to_string(hooks_config_path(repo_root)) { + Ok(content) => toml::from_str(&content).unwrap_or_default(), + Err(_) => HooksConfig::default(), + } +} + +fn fetch_head_is_recent(repo_root: &Path, cache_secs: u64) -> bool { + let fetch_head = repo_root.join(".git").join("FETCH_HEAD"); + let metadata = match std::fs::metadata(&fetch_head) { + Ok(m) => m, + Err(_) => return false, + }; + match metadata.modified().and_then(|t| { + t.elapsed() + .map_err(|e| std::io::Error::other(e.to_string())) + }) { + Ok(elapsed) => elapsed.as_secs() < cache_secs, + Err(_) => false, + } +} + +fn run_jj_with_timeout(args: &[&str], timeout_secs: u64) -> Option { + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + + let args_owned: Vec = args.iter().map(|s| s.to_string()).collect(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let result = Command::new("jj").args(&args_owned).output(); + let _ = tx.send(result); + }); + + match rx.recv_timeout(Duration::from_secs(timeout_secs)) { + Ok(Ok(output)) if output.status.success() => String::from_utf8(output.stdout).ok(), + _ => None, + } +} + +fn count_commits_in_revset(revset: &str) -> Option { + let output = run_jj_with_timeout( + &[ + "log", + "-r", + revset, + "--no-graph", + "-T", + "commit_id ++ \"\\n\"", + ], + STALENESS_JJ_LOG_TIMEOUT_SECS, + )?; + Some(output.lines().filter(|l| !l.trim().is_empty()).count()) +} + +fn build_staleness_nudge_message(default_branch: &str, behind: usize) -> String { + format!( + "[working-copy-freshness]\n\ + {0} は @- より {1} commits ahead です (working copy が {0} に遅れています)。\n\ + 推奨: `jj git fetch && jj rebase -d {0}` で最新化、または `jj new {0} -m \"WIP: \"` で新規 commit を {0} 直下に作成", + default_branch, behind + ) +} + +fn compute_staleness_nudge(repo_root: &Path, config: &StalenessConfig) -> Option { + if !config.enabled.unwrap_or(false) { + return None; + } + let default_branch = config + .default_branch + .as_deref() + .unwrap_or(STALENESS_DEFAULT_BRANCH); + let fetch_timeout = config + .fetch_timeout_secs + .unwrap_or(STALENESS_DEFAULT_FETCH_TIMEOUT_SECS); + let fetch_cache = config + .fetch_cache_secs + .unwrap_or(STALENESS_DEFAULT_FETCH_CACHE_SECS); + + if !fetch_head_is_recent(repo_root, fetch_cache) { + let _ = run_jj_with_timeout(&["git", "fetch", "--quiet"], fetch_timeout); + } + + let revset = format!("@-..{}", default_branch); + let behind = count_commits_in_revset(&revset)?; + if behind == 0 { + return None; + } + Some(build_staleness_nudge_message(default_branch, behind)) +} + fn main() { // stdin から JSON を読み取り let mut input = String::new(); @@ -427,6 +553,17 @@ fn emit_session_start_output(session_id: &str) { context.push_str("\n\n"); context.push_str(&reaper_nudge); } + let hooks_config = read_hooks_config(&cwd); + if let Some(staleness_config) = hooks_config + .session_start + .as_ref() + .and_then(|s| s.staleness.as_ref()) + { + if let Some(staleness_nudge) = compute_staleness_nudge(&cwd, staleness_config) { + context.push_str("\n\n"); + context.push_str(&staleness_nudge); + } + } } let output = serde_json::json!({ "hookSpecificOutput": { @@ -1021,4 +1158,102 @@ mod tests { assert!(nudge.contains("PR #300")); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn staleness_nudge_message_includes_branch_and_count() { + let msg = build_staleness_nudge_message("master", 3); + assert!(msg.contains("[working-copy-freshness]")); + assert!(msg.contains("master")); + assert!(msg.contains("3 commits ahead")); + assert!(msg.contains("jj git fetch")); + assert!(msg.contains("jj rebase -d master")); + } + + #[test] + fn staleness_nudge_message_supports_main_branch_alias() { + let msg = build_staleness_nudge_message("main", 1); + assert!(msg.contains("main")); + assert!(msg.contains("1 commits ahead")); + assert!(!msg.contains("master")); + } + + #[test] + fn compute_staleness_nudge_returns_none_when_disabled() { + let config = StalenessConfig { + enabled: Some(false), + fetch_timeout_secs: None, + fetch_cache_secs: None, + default_branch: None, + }; + let root = unique_temp_root("staleness-disabled"); + let result = compute_staleness_nudge(&root, &config); + assert!(result.is_none()); + } + + #[test] + fn compute_staleness_nudge_returns_none_when_enabled_field_missing() { + let config = StalenessConfig { + enabled: None, + fetch_timeout_secs: None, + fetch_cache_secs: None, + default_branch: None, + }; + let root = unique_temp_root("staleness-default-off"); + let result = compute_staleness_nudge(&root, &config); + assert!(result.is_none(), "ADR-039 § 1 準拠で default-OFF 動作"); + } + + #[test] + fn fetch_head_is_recent_returns_false_when_file_missing() { + let root = unique_temp_root("fetch-head-missing"); + assert!(!fetch_head_is_recent(&root, 300)); + } + + #[test] + fn fetch_head_is_recent_returns_true_for_fresh_file() { + use std::io::Write; + let root = unique_temp_root("fetch-head-fresh"); + let git_dir = root.join(".git"); + std::fs::create_dir_all(&git_dir).unwrap(); + let fetch_head = git_dir.join("FETCH_HEAD"); + let mut f = std::fs::File::create(&fetch_head).unwrap(); + writeln!(f, "fake content").unwrap(); + drop(f); + assert!(fetch_head_is_recent(&root, 3600)); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn hooks_config_returns_default_when_file_missing() { + let root = unique_temp_root("hooks-config-missing"); + let config = read_hooks_config(&root); + assert!(config.session_start.is_none()); + } + + #[test] + fn hooks_config_parses_session_start_staleness_section() { + use std::io::Write; + let root = unique_temp_root("hooks-config-staleness"); + let claude_dir = root.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + let toml_str = r#" +[session_start.staleness] +enabled = true +fetch_timeout_secs = 5 +default_branch = "main" +"#; + let mut f = std::fs::File::create(claude_dir.join("hooks-config.toml")).unwrap(); + f.write_all(toml_str.as_bytes()).unwrap(); + drop(f); + let config = read_hooks_config(&root); + let staleness = config + .session_start + .as_ref() + .and_then(|s| s.staleness.as_ref()) + .expect("staleness section should parse"); + assert_eq!(staleness.enabled, Some(true)); + assert_eq!(staleness.fetch_timeout_secs, Some(5)); + assert_eq!(staleness.default_branch.as_deref(), Some("main")); + let _ = std::fs::remove_dir_all(&root); + } } From 5b8b34026afa3c1d6ddfcc7248febed1b528d65a Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 27 May 2026 14:06:49 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs(todo):=20=E9=A0=86=E4=BD=8D=20136=20la?= =?UTF-8?q?nd=20=E5=AE=8C=E4=BA=86=E3=81=AB=E4=BC=B4=E3=81=84=20entry=20?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/todo-summary.md | 1 - docs/todo8.md | 80 -------------------------------------------- 2 files changed, 81 deletions(-) diff --git a/docs/todo-summary.md b/docs/todo-summary.md index 66b9ab2..f84835f 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -61,7 +61,6 @@ | 128 | 💎 Tier 3 | **CLAUDE.md § Cross-File Reference Lifecycle に多ファイル同時削除 retirement condition checklist を追加 (PR #153 T3-#2 採用)** | todo8.md | XS | なし (PR #133 (todo.md 分割) + PR #153 (analysis.md 分割) の successful pattern を明文化、`feedback_no_unenforced_rules.md` 例外 = 既存実践の明文化 + guide 効果、順位 122 / 127 と同じロジック、`~/.claude/` global 配下で派生プロジェクトに自動波及) | | 133 | 💎 Tier 3 | **docs-governance §Retirement Workflow に「diff context 由来 false alarm 防止 = grep hit は実ファイル Read で確認」明記 (PR #156 T3 #1 採用)** | todo8.md | XS | なし (PR #156 で 5 件以上の false alarm 発生、`feedback_no_unenforced_rules.md` 例外 = 既存実践の明文化 + guide 効果、順位 122 / 127 と同じロジック、`~/.claude/` global 配下で派生プロジェクトに自動波及) | | 135 | 💎 Tier 3 | **todo entry の ADR 番号 hardcode 撤廃 — 「ADR-NNN (採番未確定、land 時に確定)」placeholder 採用 (順位 78 番号 conflict 2026-05-16 観測由来)** | todo8.md | XS | なし (順位 78 (旧 ADR-038 → ADR-041) で番号 conflict が顕在化、queue 滞留 entry の hardcode が後発 PR の採番と衝突する構造リスクを convention で予防、`~/.claude/rules/common/docs-governance.md` に 2-3 行追記。採番予約簿は管理コスト過剰のため見送り、land 時 PR で空き番号確定の軽量運用に統一) | -| 136 | 🚀 Tier 1 | **working copy staleness 検出 hook 2 段構え + stale todo entry 既実装 grep 提示 (PR cleanup-stale-rank-39 由来 + PR #150 T3-#1 統合 2026-05-25)** | todo8.md | M-L | なし (本セッション実証 failure mode の structural enforcement + 旧 順位 122 機能統合。案 A SessionStart で jj fetch + lineage 報告、案 B PreToolUse で docs/todo*.md edit 時の stale block + 既実装 grep 自動実行で関連 commit を warning 提示、rule 追加 (= 順位 122 当初案) を仕組み化に切替で session 跨ぎ品質一定化、ADR-039 experimental pattern 適用) | | 140 | 💎 Tier 3 | **順位 135「codified placeholder policy」を正式 ADR に昇格 (PR #169 T3-#2 採用)** | todo8.md | S | なし (順位 135 entry を retire し、ADR-NNN (採番未確定、land 時に確定): ADR Numbering Strategy として永続化。PR #111/#132/#169 の 3+ PR で適用実証済 — PR #169 で「ADR-038 → 041 → NNN」3 段振り直し dogfood が land、ephemeral todo entry 限りでは派生プロジェクトへの transferability 不足、`feedback_no_unenforced_rules.md` 例外 = 既存実践 (3 PR で実証) の明文化 + 後続 entry が同 policy を参照する際の永続 reference 確保) | | 143 | 🔧 Tier 2 | **複言語 fixture helper 標準化 (hooks-post-tool-linter-tests) (PR #171 T2-#4 採用) ★ Bundle 171** | todo8.md | S | なし (PR #151/#171 の 2 PR 横断で multi-byte fixture 手動組み立てコストが Frequency Medium で観測、Japanese / emoji / combining chars helper 3 関数を標準化して新規 string-processing 関数追加時の boundary test コスト削減 + silent regression early detection、順位 142 + 144 と同 PR で land 推奨) | | 145 | 🔧 Tier 2 | **preset matrix test 追加 — default fallback vs config-selectable の 2 軸 classification 検証 (PR #172 T2-#1 採用)** | todo8.md | M | なし (PR #172 Phase 3 で `jj-message-required` が opt-in preset であることを前提とせず test を書き rewrite が必要になった経緯、preset architecture の implicit assumption (always-enabled vs config-selectable) を classification 表として test レベルで codify、新 preset 追加時に matrix 更新を強制する mechanical enforcement で design misalignment を構造的検出、target は main.rs (feedback report の lib.rs 記載は誤り)) | diff --git a/docs/todo8.md b/docs/todo8.md index 1f73081..78d4023 100644 --- a/docs/todo8.md +++ b/docs/todo8.md @@ -157,86 +157,6 @@ --- -### working copy staleness 検出 hook 2 段構え + stale todo entry 既実装 grep 提示 (PR cleanup-stale-rank-39 由来 + PR #150 T3-#1 統合 2026-05-25) - -> **動機**: 本セッション (PR cleanup-stale-rank-39 作業中) で「local working copy が stale parent (master と sibling) のまま docs/todo*.md を読み込み、master 上で既に削除済の entry 2 件 (順位 104 / 126) を『stale entry として削除する』と誤判定」failure mode を実証した (実 stale entry は 1 件のみだった)。memory rule `feedback_verify_task_not_already_done.md` (todo 着手前に既実装検証 → stale entry 削除に再目的化) は強制力ゼロで再発確実 = memory rule 全般の限界 (`feedback_no_unenforced_rules.md` 原則の自己事例)。Claude Code Web との並列セッション運用前提下では構造的に同 mode が発生する。 -> -> **統合履歴 (2026-05-25)**: 旧 順位 122 (`development-workflow.md` Step 0 に「新 todo 着手前の既実装確認 `jj log --limit 20 `」step 追加 = PR #150 T3-#1 採用) を本 task に統合。rule 化 (= docs 追加) では session 毎に読み込みコストがかかり別セッションで結果が一定にならない課題が PR #172 (順位 144 hook 化成功事例) で明確化、仕組み化に方針切替。stale 検出 hook が `docs/todo*.md` edit 時に発火する際、合わせて既実装の有無を grep して結果を提示する形で 順位 122 の機能を吸収する (`feedback_pipeline_over_rules.md` 適用)。 -> -> **本タスクの位置づけ**: 本セッション post-merge-feedback 相当の structural defense + 旧 順位 122 機能統合。`feedback_no_unenforced_rules.md` 例外条件 = **2 つの hook で機械強制可能**。案 A (予防層 = session 起動時に状況認識) + 案 B (最終 backstop = stale 状態での編集を hard block + 既実装 grep 提示) のセット二段構え。 -> -> **週次レビュー (ADR-031) 観点 ⑤ Todo 妥当性 との責務分離 (2026-05-26 ユーザー合意)**: **本 hook = 編集時 immediate guard / 週次 = 全 entry 横断 batch 棚卸し**。本 hook land 後の週次レビュー Phase B+1 (順位 154 `review-todo-whole` facet) は hook が拾えない broad な観点 (経年劣化 entry / cross-file 重複 / preamble drift) に focus する設計。順位 8 entry の「7 観点責務 mapping」表参照。 -> -> **参照**: 本セッション (2026-05-18) PR cleanup-stale-rank-39 root cause 分析 (ユーザー対話)、PR #150 post-merge-feedback Tier 3 #1 (旧 順位 122 由来)、memory `feedback_verify_task_not_already_done.md`、ADR-039 (Experimental feature 標準パターン)、PR #172 (順位 144 hook 化 dogfood 事例) -> -> **実行優先度**: 🚀 **Tier 1** — Effort Medium-Large (案 A ~80 行 + 案 B ~50 行 = 既実装 grep 拡張で +~20 行)。本セッションの実観測 failure mode に対する直接対策で、並列セッション運用が常態化している現状で再発確率が高い。 - -#### 設計決定 (案 A + B) - -**案 A: SessionStart hook で `jj git fetch` + lineage 報告** - -- 配置: `src/hooks-session-start/` (既存があれば拡張、なければ新設) -- 動作: - 1. `jj git fetch --quiet` を timeout 付き (3 秒) で実行 - 2. `master..@-` / `@-..master` の commit 数を比較 - 3. additional context として AI に出力 (例): - ```text - [working-copy-freshness] - @: lmzvnwlu (parent: #159) - master: #161 (2 commits ahead of @-) - warning: working copy is behind master; recommend `jj rebase -d master` - ``` -- kill-switch: `hooks-config.toml` の `[session_start]` section に `enabled` flag -- 最適化: `.git/FETCH_HEAD` mtime を確認して「5 分以内なら fetch skip」 (network cost 抑制) -- fail-open: fetch timeout / 失敗時は warning なしで pass-through (block しない、AI 操作は継続可能) - -**案 B: PreToolUse hook で stale 時の `docs/todo*.md` edit を block + 既実装 grep 提示 (旧 順位 122 統合)** - -- 配置: 既存 `src/hooks-pre-tool-validate/` に統合 (~50 行追加 = 30 行 stale 検知 + ~20 行既実装 grep 拡張) -- 動作 1 (stale 検知): Edit / Write の対象が `docs/todo*.md` 系列のとき、master と @- の lineage 確認 → master が ahead なら hard block -- 動作 2 (既実装 grep 提示、旧 順位 122 機能統合): stale でない場合も `docs/todo*.md` への Edit/Write 時に対象 entry の keyword (= 直近の `### ` 見出し title から抽出) を `jj log --limit 20` で grep し、既実装らしき commit があれば warning として additional context に表示 -- block / warning message: - ```text - [docs/todo edit context] - @: lmzvnwlu (parent: #159, master: #161 = 2 ahead) - stale parent detected → block - 関連既実装の可能性: " の上位 3 件> - 修正手順: `jj git fetch && jj new master -m "WIP: "` - ``` -- scope 限定: `docs/todo*.md` のみ block / grep 対象 (コード / config までは過剰、false positive リスク) -- 案 A と異なり、本 hook は fail-closed (lineage 判定不能なら block) で安全側に倒す -- 既実装 grep の keyword 抽出ロジック: `### ` で始まる見出しから「順位 N」prefix を除いた title を取得、句読点 / 括弧を除外して 2-3 語の noun phrase を抽出 (NLP 不要、簡易 regex で実装可能) - -#### 作業計画 - -- [ ] 既存 SessionStart hook の有無確認 (`src/hooks-session-start/` または settings.json の `SessionStart` entry) -- [ ] `jj git fetch` の timeout / kill-switch / network 例外処理設計 -- [ ] `master..@-` の lineage 計算ロジック実装 (`jj log -r "master..@-" --no-graph -T 'description'` 等) -- [ ] additional context 出力フォーマット決定 (一行 vs 複数行、AI 読み飛ばし耐性検証) -- [ ] `hooks-pre-tool-validate.exe` に `docs/todo*.md` edit block ロジック追加 -- [ ] **既実装 grep ロジック実装 (旧 順位 122 統合)**: Edit/Write の old_string or new_string から `### ` 見出し title を抽出 → keyword 抽出 (順位 prefix / 句読点除去) → `jj log --limit 20` 実行 → 上位 3 件を additional context に追記 -- [ ] `~/.claude/rules/common/development-workflow.md` Step 0 (Research & Reuse) の手動 grep step 追加は **不要** (hook が自動実行するため rule 化スキップ、`feedback_pipeline_over_rules.md` 適用) -- [ ] memory rule `feedback_verify_task_not_already_done.md` の closure 検討 (hook 化で機能吸収後、memory entry を削除して責任を hook に集約) -- [ ] ADR 起案 (新 hook 設計 + ADR-039 experimental pattern 適用、land 時採番確定) -- [ ] dogfood 期間設定 (試験運用 flag で N 週間運用後採否決定) -- [ ] 派生プロジェクト (techbook-ledger / auto-review-fix-vc) deploy 検討 -- [ ] 本エントリ削除 + todo-summary.md 行削除 (順位 122 行は本 entry 統合時に削除済 2026-05-25) - -#### 完了基準 - -- session 開始時に working copy が master より遅れている場合、AI が context 出力で即座に状況を認識する -- stale parent 状態で `docs/todo*.md` を編集しようとすると hard block + 修正手順 (`jj git fetch && jj new master -m "WIP: "`) 表示 -- **`docs/todo*.md` への Edit/Write 時に既実装 grep が自動実行され、関連 commit が warning として提示される (旧 順位 122 機能、hook 化で session 跨ぎ品質一定化)** -- ADR-039 experimental pattern に従い kill-switch 装備 (network 異常 / feature branch 運用への退避経路) -- 派生プロジェクトでの動作確認 - -#### 詰まっている箇所 - -- `jj git fetch` の timeout が低速 network で頻発した場合の UX → 案 A は fail-open で warning なし pass-through、案 B は fail-closed (lineage 不能 = stale 扱い) で安全側に倒す trade-off -- master 判定ロジック: 現状 trunk-based 前提で master を正と扱う。feature branch 運用が始まると assumption が破綻するが、本リポジトリは当面 trunk-based のため問題なし。trunk 名 (master / main) は config 可能にしておく - ---- - ### ADR-NNN (採番未確定、land 時に確定): ADR Numbering Strategy — Placeholder Policy for Multi-PR Race-Free Assignment (PR #169 T3-#2 採用) > **動機**: 順位 135 で codify された「ADR 番号は entry 登録時に hardcode せず `ADR-NNN (採番未確定)` placeholder で記述し、land 時 PR で空き番号を確定する」運用が、PR #111 / PR #132 / PR #169 の **3+ PR で適用実証済**になった。特に PR #169 では同一 entry (順位 78) が `ADR-038 → 041 → NNN` の **3 段振り直し** を経た live dogfood が完了し、queue 滞留 entry と後発 PR の採番衝突を convention 層で完全予防できる状態が確立された。現在 policy は `~/.claude/rules/common/docs-governance.md` の 2-3 行追記として ephemeral todo (順位 135) 内で codify されているが、ephemeral artifact 限りでは派生プロジェクト (techbook-ledger / auto-review-fix-vc 等) への transferability に欠ける。正式 ADR に昇格して永続化する。 From 94b8409c65939fb306e7f3b8a905d93cc594e6eb Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 27 May 2026 16:24:50 +0900 Subject: [PATCH 4/5] fix(review): apply CodeRabbit fixes for #177 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolved findings: - [Minor] docs/todo-summary.md:78 順位テーブルの並び順が逆転しています(160 → 157)。 - [Major] src/hooks-pre-tool-validate/src/main.rs:845 lineage 判定失敗時に fail-closed できていません。 - [Major] src/hooks-session-start/src/main.rs:441 タイムアウトしても `jj` 子プロセスが停止されない点を修正してください From db63cc311cd79cd29d69efbb39869ba6adb2bc98 Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 27 May 2026 16:28:06 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(hooks):=20=E9=A0=86=E4=BD=8D=20136=20CR?= =?UTF-8?q?=20Major=202=20=E4=BB=B6=20+=20Minor=201=20=E4=BB=B6=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=20(fail-closed=20+=20jj=20kill=20on=20timeout=20+=20o?= =?UTF-8?q?rder=20swap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/todo-summary.md | 2 +- src/hooks-pre-tool-validate/src/main.rs | 51 ++++++++++++++++++------- src/hooks-session-start/src/main.rs | 47 ++++++++++++++++------- 3 files changed, 72 insertions(+), 28 deletions(-) diff --git a/docs/todo-summary.md b/docs/todo-summary.md index f84835f..4ba1bdd 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -74,8 +74,8 @@ | 153 | 🔧 Tier 2 | **`review-harness-whole` facet 追加 — 観点 ① 独立 facet 化 (順位 8 follow-up、Phase B+1、2026-05-26 ユーザー合意) ★ 週次拡張** | todo9.md | S | 順位 8 Phase B land + 2-3 週 dogfood 後に着手判断 (extract 不要なら close)、順位 146-151 Bundle 既存ルール仕組み化の継続的発見源、architecture-whole から ① 観点を extract して context 圧迫回避 | | 154 | 🔧 Tier 2 | **`review-todo-whole` facet + aggregate 前 file size pre-step — 観点 ⑤ ⑦ 拡張 (順位 8 follow-up、Phase B+1、2026-05-26 ユーザー合意) ★ 週次拡張** | todo9.md | M | 順位 136 land + Phase B 2-3 週 dogfood 完了後着手、順位 95 / 147 と scope 整理必要 (CI 即時 vs 週次 batch)、ADR-031 3 層分離原則で file size は LLM 不要の Rust pre-step に分離 | | 155 | 🚀 Tier 1 | **cli-pr-monitor fix chain 末尾に空 commit 検査 + `jj abandon` step 追加 (PR #174 T1-#1 採用)** | todo9.md | S | なし (PR #174 で `kqvluqyv` 空 commit が PR diff 汚染した実証ベース、`master..@` 範囲を `jj log` で sweep して機械強制、既存 `CleanupEmptyFixCommit` action の補完層) | -| 160 | 💎 Tier 3 | **`docs-governance.md` に「ADR multi-variant pattern section 追加時の checklist」codify (PR #176 T3-#1 採用)** | todo9.md | XS | なし (PR #175 Minor + PR #176 Nitpick の 2 連続観測 = Frequency Medium で採用条件成立、ADR 拡張時の variant 網羅性 + 擬似コード vs 実コード齟齬を reviewer / Claude 視点で防止する checklist、global file `~/.claude/rules/common/docs-governance.md` 編集のため本リポジトリ外で実施、`feedback_global_config_backup` 適用) | | 157 | 🔧 Tier 2 | **Bundle 1 dogfood checklist 実行 — `__test.ps1` block + override env 確認 (PR #174 T2-#2 採用、ADR-039 bounded lifetime data point #1)** | todo9.md | XS | なし (PR #174 PR body の未消化 dogfood、Bundle 2 PR merge 前の前提条件として消化、結果は Bundle 2 PR body に記録) | +| 160 | 💎 Tier 3 | **`docs-governance.md` に「ADR multi-variant pattern section 追加時の checklist」codify (PR #176 T3-#1 採用)** | todo9.md | XS | なし (PR #175 Minor + PR #176 Nitpick の 2 連続観測 = Frequency Medium で採用条件成立、ADR 拡張時の variant 網羅性 + 擬似コード vs 実コード齟齬を reviewer / Claude 視点で防止する checklist、global file `~/.claude/rules/common/docs-governance.md` 編集のため本リポジトリ外で実施、`feedback_global_config_backup` 適用) | **戦略**: Tier 1 を 2〜3 セッションで片付け → Tier 2 で ADR-032 の前提 + rate-limit + convergence cost 削減を進める → Tier 3 で ADR-032 を land + ドキュメント整備。Tier 4-5 は cleanup / 外部展開で daily efficiency への直接効果は小さい。 diff --git a/src/hooks-pre-tool-validate/src/main.rs b/src/hooks-pre-tool-validate/src/main.rs index 38b3d14..7d5769f 100644 --- a/src/hooks-pre-tool-validate/src/main.rs +++ b/src/hooks-pre-tool-validate/src/main.rs @@ -690,21 +690,41 @@ fn extract_heading_keywords(text: &str) -> Vec { } fn run_jj_with_timeout(args: &[&str], timeout_secs: u64) -> Option { - use std::process::Command; - use std::sync::mpsc; + use std::io::Read as _; + use std::process::{Command, Stdio}; use std::thread; - use std::time::Duration; - - let args_owned: Vec = args.iter().map(|s| s.to_string()).collect(); - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - let result = Command::new("jj").args(&args_owned).output(); - let _ = tx.send(result); - }); - - match rx.recv_timeout(Duration::from_secs(timeout_secs)) { - Ok(Ok(output)) if output.status.success() => String::from_utf8(output.stdout).ok(), - _ => None, + use std::time::{Duration, Instant}; + + let mut child = Command::new("jj") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut buf = Vec::new(); + if let Some(mut out) = child.stdout.take() { + let _ = out.read_to_end(&mut buf); + } + return if status.success() { + String::from_utf8(buf).ok() + } else { + None + }; + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + thread::sleep(Duration::from_millis(50)); + } + Err(_) => return None, + } } } @@ -823,6 +843,9 @@ fn check_todo_staleness( .unwrap_or(TODO_STALENESS_DEFAULT_GREP_LIMIT); let behind = count_commits_branch_ahead(branch); + if behind.is_none() { + return None; + } let stale = behind.unwrap_or(0) > 0; let keywords = extract_heading_keywords(text_for_keywords); diff --git a/src/hooks-session-start/src/main.rs b/src/hooks-session-start/src/main.rs index 4f1ac37..747ab56 100644 --- a/src/hooks-session-start/src/main.rs +++ b/src/hooks-session-start/src/main.rs @@ -424,20 +424,41 @@ fn fetch_head_is_recent(repo_root: &Path, cache_secs: u64) -> bool { } fn run_jj_with_timeout(args: &[&str], timeout_secs: u64) -> Option { - use std::sync::mpsc; + use std::io::Read as _; + use std::process::Stdio; use std::thread; - use std::time::Duration; - - let args_owned: Vec = args.iter().map(|s| s.to_string()).collect(); - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - let result = Command::new("jj").args(&args_owned).output(); - let _ = tx.send(result); - }); - - match rx.recv_timeout(Duration::from_secs(timeout_secs)) { - Ok(Ok(output)) if output.status.success() => String::from_utf8(output.stdout).ok(), - _ => None, + use std::time::{Duration, Instant}; + + let mut child = Command::new("jj") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut buf = Vec::new(); + if let Some(mut out) = child.stdout.take() { + let _ = out.read_to_end(&mut buf); + } + return if status.success() { + String::from_utf8(buf).ok() + } else { + None + }; + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + thread::sleep(Duration::from_millis(50)); + } + Err(_) => return None, + } } }