diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 085bca09..935bef44 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -303,3 +303,24 @@ check_coderabbit = true # CodeRabbit レビューの監視 # trigger_patterns をコメントアウトまたは未設定 → デフォルトトリガー有効 # trigger_patterns = ["gh pr create", "git push", "jj git push", "pnpm push"] # 明示指定する場合 # trigger_patterns = [] # 空配列 = 全トリガー無効化(モニタリング停止) + +# ─── 発火テレメトリ収集層 (WP-12 step 1、ADR-055、試験運用) ─── +# +# 各 hook が block/warn を発火したイベント (rule / preset / hook) を lib-telemetry が +# .claude/telemetry/firings--.jsonl に append する。ハーネス複雑度 +# (custom rule / preset / hook) の維持判断を発火実績で機械化する ROI 棚卸しの収集層。 +# 記録はメタデータのみ (hook / kind / id / decision / timestamp、任意 session_id)。 +# ファイルパス・編集内容・コマンド本文は記録しない (custom rule ② no-personal-paths の思想)。 +# +# ADR-039 3 点セット: +# - Config opt-in (default OFF): code default は unwrap_or(false)。section 省略で OFF。 +# 本 repo は dogfood のため enabled = true。派生プロジェクト deploy 時は section 省略で OFF。 +# - Kill-switch: 恒久停止は enabled = false。緊急バイパスは env CLAUDE_TELEMETRY_DISABLE=1 +# (truthy 値、受理集合 1|true|yes|on)。 +# - Bounded lifetime: 収集開始から 28 日 warm-up 後に WP-12 step 2 (集計 pre-step) で +# 「発火 0 の rule/preset/hook を削除候補提示」→ step 3 (ADR-039 卒業/廃止の発火数機械化)。 +# +# fail-open: 記録は observation 層でありゲートではない。書き込み失敗・config 欠落は黙って +# 握りつぶし、hook 本来の block/allow 判定を妨げない (ADR-043 の fail-closed はゲート限定)。 +[telemetry] +enabled = true diff --git a/.gitignore b/.gitignore index 2be175f1..90a1af19 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,11 @@ src/*/Cargo.lock .claude/weekly-review-pending.json .claude/weekly-review-deferred.json +# WP-12 / ADR-055: 発火テレメトリ収集層 (lib-telemetry) の JSONL。ローカル運用データ (内部 +# artifact)。per-process/per-day partition (firings--.jsonl)。ROI 棚卸しの +# 集計は後続 PR (WP-12 step 2)。 +.claude/telemetry/ + # ADR-030: takt workflow への入力 (cli-merge-pipeline が生成、workflow が読む) .takt/post-merge-feedback-context.json .takt/post-merge-feedback-transcript.jsonl diff --git a/CLAUDE.md b/CLAUDE.md index f13455ae..6aafd967 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ - [ADR-052: 自律実行境界の 2 クラス分類(ADR-028 の 2 段化)](docs/adr/adr-052-autonomy-execution-boundary-classes.md) *(試験運用)* - [ADR-053: Stop hook による tool call leak 検知](docs/adr/adr-053-stop-tool-call-leak-detection.md) *(試験運用)* - [ADR-054: prompt injection 信頼境界の 3 層防御](docs/adr/adr-054-prompt-injection-trust-boundary-defense.md) *(試験運用)* +- [ADR-055: 発火テレメトリ収集層 — ハーネス ROI 棚卸しの決定論的観測基盤](docs/adr/adr-055-firing-telemetry-collection.md) *(試験運用)* ## 開発 convention / チェックリスト diff --git a/Cargo.lock b/Cargo.lock index 58ffddec..62858613 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,6 +386,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" name = "hooks-post-tool-comment-lint-rust" version = "0.1.0" dependencies = [ + "lib-telemetry", "serde", "serde_json", "tempfile", @@ -399,6 +400,7 @@ name = "hooks-post-tool-jj-op-verify" version = "0.1.0" dependencies = [ "lib-subprocess", + "lib-telemetry", "serde", "serde_json", "toml", @@ -410,6 +412,7 @@ version = "0.1.0" dependencies = [ "globset", "lib-subprocess", + "lib-telemetry", "regex", "serde", "serde_json", @@ -422,6 +425,7 @@ name = "hooks-pre-tool-validate" version = "0.1.0" dependencies = [ "lib-subprocess", + "lib-telemetry", "regex", "serde", "serde_json", @@ -454,6 +458,7 @@ version = "0.1.0" dependencies = [ "lib-jj-helpers", "lib-subprocess", + "lib-telemetry", "serde", "serde_json", "toml", @@ -464,6 +469,7 @@ name = "hooks-stop-tool-call-leak" version = "0.1.0" dependencies = [ "lib-subprocess", + "lib-telemetry", "serde", "serde_json", "tempfile", @@ -729,6 +735,16 @@ dependencies = [ name = "lib-subprocess" version = "0.1.0" +[[package]] +name = "lib-telemetry" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "toml", +] + [[package]] name = "libc" version = "0.2.185" diff --git a/Cargo.toml b/Cargo.toml index 2aa54d45..119fa229 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "src/lib-pending-file", "src/lib-report-formatter", "src/lib-subprocess", + "src/lib-telemetry", ] # workspace 化に伴い [profile.*] は必ず root で定義する必要がある diff --git a/docs/adr/adr-055-firing-telemetry-collection.md b/docs/adr/adr-055-firing-telemetry-collection.md new file mode 100644 index 00000000..2ec09050 --- /dev/null +++ b/docs/adr/adr-055-firing-telemetry-collection.md @@ -0,0 +1,168 @@ +# ADR-055: 発火テレメトリ収集層 — ハーネス ROI 棚卸しの決定論的観測基盤 + +## ステータス + +試験運用 (2026-07-15) + +> 本 ADR は [ADR-039 (試験運用標準パターン)](adr-039-experimental-feature-standard-pattern.md) に従う。 +> Config opt-in / kill-switch / bounded lifetime の 3 点を満たす。 +> +> 採番は land 時に確定する ([順位 135/140](../todo-summary.md) の placeholder 方式)。 +> 現時点で ADR-054 が最新のため 055 を仮採番している。 + +## コンテキスト + +`harness-improvement-plan.md` の WP-12 は「ハーネス複雑度 (hooks 7 本・custom rule 12 本・ +pre-tool preset 群) の維持判断を発火実績で機械化する」ことを目的とする。ルールや preset は +「実 incident 由来で追加された」履歴 (ADR-049) は追えるが、**追加後に実際に発火しているか** +の観測データが無いため、不要になった機構の削除判断を人間の記憶に依存していた。 + +WP-12 は 3 ステップ構成である: + +1. **収集層 (本 ADR)** — 各 hook の block/warn 発火を JSONL に append する共通基盤 +2. **ROI 棚卸し pre-step** — 直近 N 日で発火 0 の rule/preset/hook を削除候補として提示 +3. **卒業/廃止判定の機械化** — ADR-039 の bounded lifetime 判定を発火数で機械化 + +telemetry はマージ後に初めてデータが溜まるため、収集層だけを先行してマージし、28 日の +warm-up 後に実データで棚卸し (step 2/3) を後続 PR で行う。本 ADR / PR のスコープは step 1 に +限定する。step 2/3 は [todo-summary.md](../todo-summary.md) に登録した。 + +## 決定 + +新規共有ライブラリ `lib-telemetry` (`src/lib-telemetry/`、ADR-012 の `lib-` prefix) を作成し、 +各 hook から `lib_telemetry::record(&Firing { .. })` を呼んで発火イベントを +`.claude/telemetry/firings--.jsonl` に 1 行 append する。 + +### JSONL レコード (メタデータのみ) + +各行は 1 発火イベントで、以下のフィールドを持つ: + +| フィールド | 内容 | +|---|---| +| `ts` | UTC ISO 8601 timestamp | +| `hook` | 発火した hook 名 (例 `hooks-pre-tool-validate`) | +| `kind` | `rule` / `preset` / `hook` (発火主体の種類) | +| `id` | rule id / preset 名 / hook 名 | +| `decision` | `block` / `warn` (発火の重み) | +| `session_id` | 相関用 (任意、`.claude/.session-id` から補完) | + +**プライバシー**: 記録はメタデータのみとし、**ファイルパス・編集内容・コマンド本文は記録 +しない**。custom rule ② no-personal-paths (PII パス混入禁止) と同じ思想で、ローカル運用 +データであっても個人情報を残さない。 + +### 計装スコープ — 裁量発火に限定 + +記録対象は「削除候補になり得る裁量的な発火」に限定する: + +| 対象 | hook | kind | decision | +|---|---|---|---| +| custom rule 12 本 | hooks-post-tool-linter | rule | error→block / warning→warn | +| pre-tool preset 群 | hooks-pre-tool-validate | preset | block | +| Stop 品質ゲート | hooks-stop-quality | hook | block | +| tool call leak 検知 | hooks-stop-tool-call-leak | hook | block | +| file-length gate | hooks-post-tool-comment-lint-rust | hook | block | +| jj operation 未記録警告 | hooks-post-tool-jj-op-verify | hook | warn | + +**除外**したもの: + +- **常時 ON の構造チェック** (comment-lint-rust の非 doc コメント / 関数長、post-tool-linter + の file_size_check / utf8_integrity)。これらは編集のたびに発火するコア機構で削除候補に + ならず、記録すると ROI 信号 (「発火 0 = 削除候補」) を希釈するノイズになるため。 +- **nudge-only hook** (session-start reminder / stop-feedback-dispatch / + user-prompt-feedback-recovery)。decision 語彙が block/warn の 2 値のため、nudge (助言 + 出力) は乗らない。将来 decision 語彙を拡張する際に再検討する。 + +`decision` は「hook がツールを実際に停止したか」ではなく「発火の重み」を表す軸である。 +custom rule / jj-op-verify は additionalContext の助言層で実際には block しないが、severity +に応じて block/warn を記録する。逆に stop-quality は infra エラー (stdin/parse 失敗) の +fail-closed 経路でも block を emit するため、「hook が block を emit した総数」として記録 +する。file-length gate の fail-closed 経路 (jj 失敗の判定不能 block) は ROI 信号を汚さない +よう記録しない。 + +### 副作用注入によるテスト可能性 + +[ADR-024 (共通 jj ヘルパー)](adr-024-shared-jj-helpers-library.md) の +`acquire_pipeline_lock_at` と同思想で、純粋 writer `record_to(base_dir, firing, now_epoch)` に +base_dir と now を引数注入し、テストが temp dir へ確定的に書けるようにする。prod 入口 +`record()` は exe 隣の `.claude/` を解決し、opt-in 判定を `OnceLock` で 1 プロセス 1 回に +キャッシュしてから `record_to` を呼ぶ。 + +### Windows 並行書き込み安全性 + +hook は並行実行され得るため、書き込み競合を 3 重で排除する: + +1. **per-process partition** (ファイル名に pid) — プロセス間で別ファイルに書く +2. **日次 partition** (ファイル名に日付) — 集計は `firings-*.jsonl` を glob 走査する前提 +3. **プロセス内 `Mutex` + 単一 `write_all`** — 同一プロセス内マルチスレッドの行 + インターリーブを排除 + +## ADR-039 3 点セット + +### Config opt-in (default OFF) + +`hooks-config.toml` の `[telemetry]` section: + +```toml +[telemetry] +enabled = true # code default は false (unwrap_or(false)) +``` + +section 不在 / `enabled` 未設定 / `false` では完全 skip (何も記録しない)。本リポジトリは +dogfood のため `enabled = true`。派生プロジェクトへの deploy 時は section 省略で OFF を継承 +する (`pnpm deploy:hooks` の配布先で意図せぬ有効化を避ける)。 + +### Kill-switch + +| 停止手段 | 影響範囲 | +|---|---| +| `enabled = false` (or section 削除) | 恒久停止。一切記録しない | +| env `CLAUDE_TELEMETRY_DISABLE=1` (truthy 値) | 緊急バイパス。受理集合 `1|true|yes|on` | + +### Bounded lifetime + +収集層単体では価値が出ず、step 2/3 とセットで初めて ROI 棚卸しが成立する。明示的な +decision trigger: + +- **収集開始から 28 日**の warm-up 後、WP-12 step 2 (集計 pre-step) を実装して + 「発火 0 の rule/preset/hook」を削除候補として週次レビュー ([ADR-031](adr-031-weekly-review-pipeline.md)) + に出力する。incident 由来ルール (ADR-049) は抑止力として発火 0 でも維持推奨とし、 + 非 incident のみ削除候補にする区別を step 2 で入れる。 +- step 3 で ADR-039 の卒業/廃止判定 (試験運用機能の採否) を発火数で機械化する。 +- 上記が実装されず telemetry が死蔵する場合は、収集層ごと撤去する revert PR を作成する。 + +## 帰結 + +### 利点 + +- ルール/preset/hook の維持・削除判断を、人間の記憶ではなく発火実績で機械化する基盤ができる +- 記録が決定論的 (LLM 不使用) で高速 (数十バイトの append)、fail-open のため hook 本来の + 判定を一切妨げない +- 計装が各 hook の choke point (emit_block / run_custom_rules の per-rule / validate_command + の hit) に 1 行 record を差すだけで、opt-in 判定は lib 内部に集約され侵襲が小さい + +### 欠点 / 留意点 + +- 「発火 0」は「不要」と「抑止力として機能 (違反が起きなかった)」の区別がつかない。削除 + 候補の最終判断は人間に委ね、incident 由来ルールは発火 0 でも維持推奨とする (step 2 で区別) +- 本 PR 単体ではデータが溜まらないため、initial run は必ず「観測期間中・全維持」になる + (28 日 warm-up 後に step 2 で初めて棚卸しが機能する) +- `fail-open` の観点は [ADR-043 (fail-closed 原則)](adr-043-security-gates-fail-closed.md) の + 適用対象外である。ADR-043 の fail-closed は「block/allow を決めるゲート関数」限定であり、 + telemetry は observation 層でゲートではないため、記録失敗で hook を止めない fail-open が + 正しい。stop-tool-call-leak (ADR-053) が既に「ゲートでない UX 装置は fail-open」の先例 +- UTC ヘルパーを `lib-pending-file` から最小複製した。観測層が post-merge-feedback ドメイン + 特化 crate に依存して責務結合するのを避けるため意図的に複製したが、これで UTC ヘルパーの + 消費者が 2 crate 目に到達した。[ADR-044 (utility extraction 境界)](adr-044-subprocess-utility-extraction-boundary.md) + 層 1 の「2 つ目の使用例待ち」トリガに到達したため、将来 3 crate 目が現れたら中立 crate + (例 `lib-time`) への抽出候補とする + +## 関連 ADR + +- [ADR-039](adr-039-experimental-feature-standard-pattern.md) — 試験運用標準パターン (opt-in / kill-switch / bounded lifetime) +- [ADR-043](adr-043-security-gates-fail-closed.md) — fail-closed 原則 (本 telemetry は observation 層で適用外 = fail-open) +- [ADR-044](adr-044-subprocess-utility-extraction-boundary.md) — utility extraction 境界 (UTC ヘルパー抽出トリガ到達) +- [ADR-031](adr-031-weekly-review-pipeline.md) — 週次レビューパイプライン (step 2 の棚卸し出力先) +- [ADR-049](adr-049-incident-eval-regression-suite.md) — incident→eval 回帰スイート (発火 0 でも維持する incident 由来ルールの区別) +- [ADR-012](adr-012-src-naming-convention.md) — src/ 命名規約 (`lib-` prefix) +- [ADR-026](adr-026-cargo-workspace.md) — Cargo workspace (新 crate の members 追記) +- [ADR-041](adr-041-test-isolation-patterns.md) — テスト隔離 (env kill-switch テストの serial 化) diff --git a/docs/harness-improvement-plan.md b/docs/harness-improvement-plan.md index ccfdd901..a1c16350 100644 --- a/docs/harness-improvement-plan.md +++ b/docs/harness-improvement-plan.md @@ -71,7 +71,7 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 | WP-09 | 1-C | PR 監視の GitHub Actions 化 Phase A(読み取り専用) | M | なし | 観測中(`.github/workflows/pr-monitor.yml` + ADR-022 原則 6、PR #258 マージ済で master 上で本稼働。トリガーはレビュアー非依存〔pull_request_review 全レビュアー + pull_request opened/ready + issue_comment は coderabbitai 発のみ〕、sonnet。読み取り専用は「エージェント書き込み能力ゼロ + 非エージェント step のデータ投稿」の 2 不変条件で担保〔pre-push security review が token-exfil 含む 3 件を land 前に修正〕。secrets 登録済・スモークテスト成功。dogfood: セッション閉鎖中の無人分析コメント + wakeup 失効の取りこぼしゼロを確認したら完了。follow-up〔pagination ギャップ等〕は WP-10 feedback 時に採否判断) | | WP-10 | 1-C | 自律境界ポリシー ADR(ADR-028 の 2 段化) | S | なし | 実装済(ADR-052 起票: 自律 actor 限定の 2 クラス分類〔自動実行可: docs-only / Tier3 cleanup / `claude/` push / draft PR 作成、ゲート必須: ready 化 / マージ / master push〕+ 分類不能は fail-closed〔ADR-043〕。ADR-028 のゲートを commitment 点へ移設するのが 2 段化の本質。試験運用。Rust 分類関数は呼び手〔自律実行経路〕不在で今回見送り= WP-17/18 着手時に gate.rs の docs-only 判定を lib 切り出しで実装) | | WP-11 | 2 | prompt injection 信頼境界の 3 層防御 | M-L | WP-08 | 実装済([ADR-054](adr/adr-054-prompt-injection-trust-boundary-defense.md): 分類/指示/決定論の 3 層 + security facet + fixture。決定論層は default OFF opt-in、本リポジトリは observe で dogfood 開始。誤検知ゼロ確認後 enforce 昇格が採否判定〔3-5 PR〕) | -| WP-12 | 2 | 発火テレメトリ + ハーネス ROI 棚卸し | M | なし | 未着手 | +| WP-12 | 2 | 発火テレメトリ + ハーネス ROI 棚卸し | M | なし | 実装済(step1 収集層のみ: ADR-055 + lib-telemetry + 6 hook 計装。step2-3〔集計 pre-step / 卒業判定機械化〕は 28 日 warm-up 後着手のため todo 順位 307/308 へ移管) | | WP-13 | 3 | EXE_SUFFIX 抽象化 | M | なし | 未着手 | | WP-14 | 3 | PowerShell 3 本の Rust 化 | S-M ×2 | なし | 未着手 | | WP-15 | 3 | Linux バイナリビルド + クラウド setup script | M | WP-13, 14 | 未着手 | @@ -219,6 +219,8 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 ### WP-12: 発火テレメトリ + ハーネス ROI 棚卸し +> **実装済 (2026-07-15、step1 収集層のみ、[ADR-055](adr/adr-055-firing-telemetry-collection.md))**: ヒアリングで「収集層のみ先行 PR」と確定。共通 lib `lib-telemetry` を新設し、6 hook(pre-tool-validate preset / post-tool-linter custom rule / jj-op-verify warn / stop-quality / stop-tool-call-leak / comment-lint-rust file-length gate)を計装。発火を `.claude/telemetry/firings--.jsonl` に per-process/per-day partition で append(Windows 並行競合を pid+日次+Mutex の 3 重で排除)。記録はメタデータのみ(hook/kind/id/decision/timestamp、パス・内容は非記録)。**記録対象は裁量発火に限定**(常時 ON の構造チェック〔非 doc コメント/関数長/file_size/utf8〕と nudge-only hook は ROI ノイズのため除外)。ADR-039 3 点セット(opt-in default OFF・kill-switch `CLAUDE_TELEMETRY_DISABLE`・bounded lifetime)+ fail-open(ADR-043 の fail-closed はゲート限定)。step 1 の理由: telemetry はマージ後に初めてデータが溜まるため、この PR 単体では必ず発火 0(データ無し)になる。**step 2(集計 pre-step)/ step 3(卒業判定機械化)は 28 日 warm-up 後に実データで着手するため todo 順位 307/308 へ移管**。以下は当初ステップ(記録用)。 + - **目的**: ハーネス複雑度(hooks 7 本・ルール 12 本・crate 19 個)の維持判断を発火実績で機械化する。 - **ステップ**: 1. 共通 telemetry 層を lib に追加: 全 hooks の block/warn 発火を `.claude/telemetry/` 配下の JSONL に append(hook 名・rule/preset・timestamp・decision)。**`.claude/telemetry/` は gitignore する**(ローカル運用データ)。**Windows のファイルロック競合に注意**: hooks は並行実行され得るため、プロセス毎ファイル or append 失敗時 retry で設計する。 diff --git a/docs/todo-summary.md b/docs/todo-summary.md index 6331155f..e462ba4c 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -151,6 +151,8 @@ | 304 | 💎 Tier 3 | **quality gate 実行中に発見したバグ修正が別 PR に混入した際の jj split + jj rebase 復旧パターンを記録 (273.md T3-3 採用)** | todo13.md | XS | なし (PR #272/#273 分離で実証済みの復旧手順、ADR-045 の並列 workspace リスクとは別種の単一 session 内混入事故。復旧は事後対応であり、分離後は混在した変更に対する gate 実行結果を無効化し各 PR で再実行する手順を含む) | | 305 | 💎 Tier 3 | **Metrics violation の pre-existing 判定基準の明文化 (273.md T3-4 採用)** | todo13.md | XS | なし (file_size_check / file_length_gate 等 metrics 系 gate が複数稼働中で反復しうる override 正当性の判定基準を明文化) | | 306 | 💎 Tier 3 | **quality gate isolation 機構を見送り、recovery による risk acceptance とした判断の記録 (negative result) (273.md T3-5 採用)** | todo13.md | S | なし (spike 見送り convention に従い、isolation 機構を却下し recovery コストの低さ (順位304) を理由に risk acceptance した根拠を記録。recovery は isolation の代替ではなく、予防機能の欠如という残存リスクと再検討条件を明記する) | +| 307 | 🔧 Tier 2 | **WP-12 step 2: 発火テレメトリ ROI 棚卸し pre-step (発火 0 の rule/preset/hook を削除候補提示)** | todo13.md | M | なし (**着手条件 = ADR-055 収集層マージから 28 日 warm-up 後**。それ以前は全項目が発火 0 = データ無しで判定無意味。集計は Rust exe、weekly-review に file-length-watchlist 同型 facet で接続、incident 由来ルールは発火 0 でも維持推奨の区別) | +| 308 | 💎 Tier 3 | **WP-12 step 3: ADR-039 bounded lifetime 判定の発火数機械化** | todo13.md | S | 順位 307 (step 2 の集計基盤に依存)。試験運用 ADR 機構の卒業/廃止検討を発火数で自動 promote。step 3 完了で WP-12 完了 | **戦略**: 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/todo13.md b/docs/todo13.md index f9eb590c..d2c946d5 100644 --- a/docs/todo13.md +++ b/docs/todo13.md @@ -1355,6 +1355,59 @@ +### WP-12 step 2: 発火テレメトリ ROI 棚卸し pre-step (28 日 warm-up 後着手) + +> **動機**: WP-12 step 1 ([ADR-055](adr/adr-055-firing-telemetry-collection.md)) で `lib-telemetry` が `.claude/telemetry/firings-*.jsonl` に発火を収集し始めた。その実データを使って「直近 28 日で発火 0 の rule/preset/hook」を削除候補として機械抽出し、ハーネス複雑度の維持判断を発火実績で機械化する (WP-12 の本来目的)。 +> +> **本タスクの位置づけ**: WP-12 step 1 の後続 PR。**着手条件 = step 1 マージから 28 日経過** (warm-up。それ以前は全項目が発火 0 = データ無しになり削除候補判定が無意味)。 +> +> **参照**: [ADR-055](adr/adr-055-firing-telemetry-collection.md) (収集層)、[ADR-031](adr/adr-031-weekly-review-pipeline.md) (棚卸しの出力先 = weekly-review)、`.takt/facets/instructions/file-length-watchlist.md` (同型の「機械層」pre-step = takt facet + Bash パターン)、`.takt/facets/instructions/aggregate-weekly.md` (`### File Length Watchlist (機械的観測)` セクションの隣に発火統計セクションを追加)、[ADR-049](adr/adr-049-incident-eval-regression-suite.md) (incident 由来ルールは発火 0 でも維持推奨の区別)。 +> +> **実行優先度**: 🔧 Tier 2 — Effort M。step 1 の投資回収に必須だが warm-up 待ちのため即着手不可。 + +#### 設計決定 (案) + +- **集計は Rust exe** (ヒアリング確定)。`firings-*.jsonl` を glob 走査し、rule/preset/hook ごとに直近 28 日の発火数を集計する `cli-*` exe (または既存 crate のサブコマンド)。全 rule/preset/hook の一覧 (custom-lint-rules.toml / preset レジストリ / hook レジストリ) との差分で「発火 0 の項目」を導出する。 +- **takt facet + Bash で weekly-review に接続**。file-length-watchlist と同型で、facet の Bash step が集計 exe を呼び watchlist markdown を出力 → aggregate-weekly が `### 発火統計 (機械的観測)` セクションとして転載する。 +- **incident 由来ルールの区別**: `custom-lint-rules.toml` の `[rules.incident]` を持つルールは発火 0 でも「抑止力として維持推奨」とし、非 incident ルールのみ削除候補にする (ADR-049 の思想)。 +- **warm-up 表示**: 収集開始日から 28 日未満の項目は「観測期間中・判定保留」と出力し、誤って削除候補に出さない。 + +#### 作業計画 + +- [ ] 集計 Rust exe を実装 (28 日窓の発火数集計 + 全項目レジストリとの差分 + incident 区別 + warm-up 判定)。ユニットテストで固定 JSONL fixture から集計値を assert。 +- [ ] takt facet (`file-length-watchlist.md` 同型) を新設し weekly-review.yaml の reviewers parallel block に追加。 +- [ ] aggregate-weekly.md に `### 発火統計 (機械的観測)` セクション転載を追加。 +- [ ] dogfood: 週次レビューレポートに発火統計セクションが出力され、初回実行で削除候補 (または全維持の根拠) が特定されることを確認。 +- [ ] 本エントリ削除 + todo-summary.md 行削除 + [harness-improvement-plan.md](harness-improvement-plan.md) の WP-12 状態更新 (step 2 消化)。 + +#### 完了基準 + +- 週次レビューレポートに発火統計セクションが出力され、直近 28 日で発火 0 の rule/preset/hook が (incident 由来を除いて) 削除候補として、または全維持の根拠とともに特定されること。 + +--- + +### WP-12 step 3: ADR-039 bounded lifetime 判定の発火数機械化 (step 2 に依存) + +> **動機**: ADR-039 の試験運用機能の卒業/廃止判定は現状「手動で観測値を閾値照合」する方式で、機械集計機構が無い。WP-12 step 2 で発火数の集計基盤ができるので、これを使って「試験運用 ADR の機構が N 日発火 0 → 卒業 (廃止 or 本採用) の検討を promote」を機械化する。 +> +> **本タスクの位置づけ**: WP-12 step 3。**step 2 (集計基盤) に依存**。step 2 完了後に着手。 +> +> **参照**: [ADR-039](adr/adr-039-experimental-feature-standard-pattern.md) (§ 3 bounded lifetime、現状は手動 3 値判定)、[ADR-055](adr/adr-055-firing-telemetry-collection.md) (収集層)、WP-12 step 2 (集計基盤、本ファイル内)。 +> +> **実行優先度**: 💎 Tier 3 — Effort S。step 2 の集計結果に卒業/廃止判定ロジックを重ねる薄い層。 + +#### 作業計画 + +- [ ] step 2 の集計出力に「試験運用 ADR の機構ごとの発火数 + bounded lifetime 期限との照合」を追加し、卒業/廃止の検討を promote する判定を機械化する。 +- [ ] ADR-039 に「bounded lifetime 判定の発火数機械化」を amendment として記録。 +- [ ] 本エントリ削除 + todo-summary.md 行削除 + harness-improvement-plan.md の WP-12 状態更新 (step 3 消化 = WP-12 完了)。 + +#### 完了基準 + +- 試験運用機能の卒業/廃止検討が発火数に基づいて週次で自動 promote され、ADR-039 の手動閾値照合が機械化されること。 + +--- + ## 既知課題 (記録のみ、本セッションで未対応) (現時点で本ファイルへの既知課題は無し。docs/todo10.md / todo9.md 末尾を参照。) diff --git a/src/hooks-post-tool-comment-lint-rust/Cargo.toml b/src/hooks-post-tool-comment-lint-rust/Cargo.toml index e689ba04..9ecbf44c 100644 --- a/src/hooks-post-tool-comment-lint-rust/Cargo.toml +++ b/src/hooks-post-tool-comment-lint-rust/Cargo.toml @@ -9,6 +9,7 @@ serde_json = "1.0" toml = "0.8" tree-sitter = "0.22" tree-sitter-rust = "0.21" +lib-telemetry = { path = "../lib-telemetry" } [dev-dependencies] tempfile = "3" diff --git a/src/hooks-post-tool-comment-lint-rust/src/modified_files_check.rs b/src/hooks-post-tool-comment-lint-rust/src/modified_files_check.rs index 09d957ee..1a2b66d1 100644 --- a/src/hooks-post-tool-comment-lint-rust/src/modified_files_check.rs +++ b/src/hooks-post-tool-comment-lint-rust/src/modified_files_check.rs @@ -83,10 +83,24 @@ pub(crate) fn run_check_modified_files() -> i32 { if violations.is_empty() { return 0; } + record_file_length_block(); print!("{}", format_violation_report(&base, &violations)); 1 } +/// file-length Stop gate が実 violation を検出して block したことを telemetry に記録する +/// (WP-12、fail-open)。fail-closed 経路 (jj 失敗の判定不能 block) は ROI 信号を汚さないよう +/// 記録しない。 +fn record_file_length_block() { + lib_telemetry::record(&lib_telemetry::Firing { + hook: "hooks-post-tool-comment-lint-rust", + kind: lib_telemetry::FiringKind::Hook, + id: "file-length", + decision: lib_telemetry::Decision::Block, + session_id: None, + }); +} + /// `enabled = Some(true)` のときのみ true。section 不在 / `None` / `Some(false)` は /// すべて false (ADR-039 § 1 default OFF)。 fn gate_enabled(config: &GateConfigFile) -> bool { @@ -116,15 +130,7 @@ fn effective_base(config: &GateConfigFile) -> String { /// user が確認できる。 fn override_value() -> Option { let raw = std::env::var(OVERRIDE_ENV_VAR).ok()?; - is_truthy(&raw).then_some(raw) -} - -/// override env の受理値判定 (順位 151 `pr_size_check::parse_override_env` と同 pattern)。 -fn is_truthy(value: &str) -> bool { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) + lib_telemetry::is_truthy(&raw).then_some(raw) } /// exe と同じ directory の `hooks-config.toml` を読み込む (hooks-stop-quality と同方式)。 @@ -276,20 +282,6 @@ mod tests { assert_eq!(effective_base(&config), "master"); } - #[test] - fn is_truthy_accepts_documented_values() { - for v in ["1", "true", "TRUE", "True", "yes", "on", " on "] { - assert!(is_truthy(v), "{:?} should be truthy", v); - } - } - - #[test] - fn is_truthy_rejects_falsey_values() { - for v in ["0", "false", "no", "off", "", " ", "2", "enable"] { - assert!(!is_truthy(v), "{:?} should be falsey", v); - } - } - #[test] fn parse_changed_rust_files_filters_non_rust_and_blanks() { let stdout = "src/a.rs\ndocs/readme.md\n\nsrc/nested/b.rs\nCargo.toml\n"; diff --git a/src/hooks-post-tool-jj-op-verify/Cargo.toml b/src/hooks-post-tool-jj-op-verify/Cargo.toml index a432fa6e..2f6b8798 100644 --- a/src/hooks-post-tool-jj-op-verify/Cargo.toml +++ b/src/hooks-post-tool-jj-op-verify/Cargo.toml @@ -8,5 +8,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" lib-subprocess = { path = "../lib-subprocess" } +lib-telemetry = { path = "../lib-telemetry" } # [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/hooks-post-tool-jj-op-verify/src/main.rs b/src/hooks-post-tool-jj-op-verify/src/main.rs index a809f8c2..0ec9121e 100644 --- a/src/hooks-post-tool-jj-op-verify/src/main.rs +++ b/src/hooks-post-tool-jj-op-verify/src/main.rs @@ -163,18 +163,44 @@ fn verify_enabled(config_text: &str) -> bool { .unwrap_or(false) } -/// stdin の HookInput とconfig から additionalContext 文字列を決める (純粋部)。 +/// `decide_context` の判定結果。いずれも additionalContext に出す message を持つが、 +/// telemetry (WP-12) は `NotRecorded` の発火のみ記録するため variant で判別する。 +enum Verdict { + Recorded(String), + NotRecorded(String), +} + +impl Verdict { + fn message(&self) -> &str { + match self { + Verdict::Recorded(m) | Verdict::NotRecorded(m) => m, + } + } +} + +/// stdin の HookInput とconfig から判定結果を決める (純粋部)。 /// None = 何も出力しない (対象外コマンド / 無効化 / 検証不能)。 -fn decide_context(command: &str, op_head: Option<&str>) -> Option { +fn decide_context(command: &str, op_head: Option<&str>) -> Option { let op = detect_last_mutating_jj_op(command)?; let head = op_head?; if op_matches_expectation(head, op.expected_op_keyword) { - Some(build_ok_message(&op, head)) + Some(Verdict::Recorded(build_ok_message(&op, head))) } else { - Some(build_not_recorded_warning(&op, head)) + Some(Verdict::NotRecorded(build_not_recorded_warning(&op, head))) } } +/// jj-op-verify が「operation not recorded」警告を発火したことを記録する (WP-12、fail-open)。 +fn record_not_recorded_warning() { + lib_telemetry::record(&lib_telemetry::Firing { + hook: "hooks-post-tool-jj-op-verify", + kind: lib_telemetry::FiringKind::Hook, + id: "jj-op-verify", + decision: lib_telemetry::Decision::Warn, + session_id: None, + }); +} + fn main() { let mut input = String::new(); if std::io::stdin().read_to_string(&mut input).is_err() { @@ -199,13 +225,16 @@ fn main() { return; } let op_head = fetch_op_head(); - let Some(context) = decide_context(&command, op_head.as_deref()) else { + let Some(verdict) = decide_context(&command, op_head.as_deref()) else { return; }; + if matches!(verdict, Verdict::NotRecorded(_)) { + record_not_recorded_warning(); + } let output = serde_json::json!({ "hookSpecificOutput": { "hookEventName": "PostToolUse", - "additionalContext": context, + "additionalContext": verdict.message(), } }); println!("{output}"); @@ -263,17 +292,19 @@ mod tests { /// 受け入れ基準: 操作に対応する op が無い場合に「operation not recorded」警告を出す。 #[test] fn decide_context_warns_when_operation_not_recorded() { - let context = + let verdict = decide_context("jj new -m 'x'", Some("f53cbee0d008 snapshot working copy")).unwrap(); - assert!(context.contains("WARNING: operation not recorded")); - assert!(context.contains("jj op log")); + assert!(matches!(verdict, Verdict::NotRecorded(_))); + assert!(verdict.message().contains("WARNING: operation not recorded")); + assert!(verdict.message().contains("jj op log")); } #[test] fn decide_context_confirms_recorded_operation() { - let context = + let verdict = decide_context("jj new -m 'x'", Some("02911d7f8d4b new empty commit")).unwrap(); - assert!(context.starts_with("[jj-op-verify] OK")); + assert!(matches!(verdict, Verdict::Recorded(_))); + assert!(verdict.message().starts_with("[jj-op-verify] OK")); } #[test] diff --git a/src/hooks-post-tool-linter/Cargo.toml b/src/hooks-post-tool-linter/Cargo.toml index d968998a..1ff416f7 100644 --- a/src/hooks-post-tool-linter/Cargo.toml +++ b/src/hooks-post-tool-linter/Cargo.toml @@ -12,6 +12,7 @@ regex = "1.10" # BurntSushi-maintained, ripgrep の依存。`**/` 形式の recursive 対応が標準で揃う。 globset = "0.4" lib-subprocess = { path = "../lib-subprocess" } +lib-telemetry = { path = "../lib-telemetry" } [dev-dependencies] tempfile = "3" diff --git a/src/hooks-post-tool-linter/src/custom_rules/engine.rs b/src/hooks-post-tool-linter/src/custom_rules/engine.rs index da4468d0..f3e40779 100644 --- a/src/hooks-post-tool-linter/src/custom_rules/engine.rs +++ b/src/hooks-post-tool-linter/src/custom_rules/engine.rs @@ -205,7 +205,11 @@ pub(crate) fn run_custom_rules(file: &str, rules: &[CompiledRule]) -> Vec before { + record_rule_firing(&compiled.rule); + } if violations.len() >= MAX_CUSTOM_VIOLATIONS { break; } @@ -214,6 +218,23 @@ pub(crate) fn run_custom_rules(file: &str, rules: &[CompiledRule]) -> Vec Vec { +/// `BlockedPattern` に発火元の preset 名を付与したもの。発火テレメトリ (WP-12) が +/// 「どの preset が block したか」を id として記録するため、build 層で source をタグ付けする。 +/// preset コンストラクタ (14+ 箇所の struct literal) を無変更に保つための薄い newtype。 +pub(crate) struct SourcedPattern { + pub(crate) source: String, + pub(crate) inner: BlockedPattern, +} + +/// `BlockedPattern` 群を発火元 preset 名で `SourcedPattern` に包む。 +pub(crate) fn tag_source(source: &str, patterns: Vec) -> Vec { + patterns + .into_iter() + .map(|inner| SourcedPattern { + source: source.to_string(), + inner, + }) + .collect() +} + +pub(crate) fn build_blocked_patterns(config: &Config) -> Vec { let preset_names: Vec = config .pre_tool_validate .as_ref() @@ -22,19 +41,24 @@ pub(crate) fn build_blocked_patterns(config: &Config) -> Vec { .unwrap_or_else(default_preset_names); preset_names .iter() - .flat_map(|name| resolve_preset_or_custom(name.as_str())) + .flat_map(|name| tag_source(name, resolve_preset_or_custom(name.as_str()))) .collect() } -pub(crate) fn validate_command(command: &str, patterns: &[BlockedPattern]) -> Option<&'static str> { - for pattern in patterns { +/// command にマッチする最初の `SourcedPattern` を返す (exception 不一致のもの)。 +pub(crate) fn validate_command<'a>( + command: &str, + patterns: &'a [SourcedPattern], +) -> Option<&'a SourcedPattern> { + for sourced in patterns { + let pattern = &sourced.inner; if pattern.pattern.is_match(command) { if let Some(exc) = &pattern.exception { if exc.is_match(command) { continue; } } - return Some(pattern.message); + return Some(sourced); } } None @@ -45,7 +69,7 @@ mod tests { use super::*; use crate::config::{Config, PreToolValidateConfig}; - fn patterns_with_presets(presets: &[&str]) -> Vec { + fn patterns_with_presets(presets: &[&str]) -> Vec { let config = Config { pre_tool_validate: Some(PreToolValidateConfig { blocked_patterns: Some(presets.iter().map(|s| s.to_string()).collect()), @@ -85,4 +109,11 @@ mod tests { assert!(is_blocked_with("docker rm -f container", &[r"docker\s+rm"])); assert!(!is_blocked_with("docker ps", &[r"docker\s+rm"])); } + + #[test] + fn tagged_source_matches_firing_preset() { + let patterns = patterns_with_presets(&["git"]); + let hit = validate_command("git push", &patterns).unwrap(); + assert_eq!(hit.source, "git"); + } } diff --git a/src/hooks-pre-tool-validate/src/handlers.rs b/src/hooks-pre-tool-validate/src/handlers.rs index 26f558f3..76e5e607 100644 --- a/src/hooks-pre-tool-validate/src/handlers.rs +++ b/src/hooks-pre-tool-validate/src/handlers.rs @@ -1,6 +1,6 @@ //! Tool 別 handler (Bash / Write / Edit / PowerShell)。 -use crate::blocked_patterns::{build_blocked_patterns, validate_command}; +use crate::blocked_patterns::{build_blocked_patterns, tag_source, validate_command}; use crate::config::Config; use crate::presets::{default_preset_names, preset_secret_detection}; use crate::protected_files::is_protected_config; @@ -9,14 +9,26 @@ use crate::ToolInput; use std::io::{self, Write}; use std::process::ExitCode; +/// preset が block を発火したことを telemetry に記録する (WP-12、fail-open)。 +fn record_preset_block(source: &str) { + lib_telemetry::record(&lib_telemetry::Firing { + hook: "hooks-pre-tool-validate", + kind: lib_telemetry::FiringKind::Preset, + id: source, + decision: lib_telemetry::Decision::Block, + session_id: None, + }); +} + pub(crate) 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()); + if let Some(hit) = validate_command(&command, &patterns) { + record_preset_block(&hit.source); + let _ = io::stderr().write_all(hit.inner.message.as_bytes()); return ExitCode::from(2); } ExitCode::SUCCESS @@ -35,8 +47,9 @@ pub(crate) fn handle_powershell_tool(config: &Config, tool_input: &ToolInput) -> 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()); + if let Some(hit) = validate_command(&command, &patterns) { + record_preset_block(&hit.source); + let _ = io::stderr().write_all(hit.inner.message.as_bytes()); return ExitCode::from(2); } ExitCode::SUCCESS @@ -95,9 +108,10 @@ fn check_secret_in_content(config: &Config, tool_input: &ToolInput) -> Option Vec { #[cfg(test)] mod tests { - use crate::blocked_patterns::{build_blocked_patterns, validate_command, BlockedPattern}; + use crate::blocked_patterns::{build_blocked_patterns, validate_command, SourcedPattern}; use crate::config::{Config, PreToolValidateConfig}; - fn patterns_with_presets(presets: &[&str]) -> Vec { + fn patterns_with_presets(presets: &[&str]) -> Vec { let config = Config { pre_tool_validate: Some(PreToolValidateConfig { blocked_patterns: Some(presets.iter().map(|s| s.to_string()).collect()), diff --git a/src/hooks-pre-tool-validate/src/presets/gh.rs b/src/hooks-pre-tool-validate/src/presets/gh.rs index dd6162bf..6c1a61dc 100644 --- a/src/hooks-pre-tool-validate/src/presets/gh.rs +++ b/src/hooks-pre-tool-validate/src/presets/gh.rs @@ -87,10 +87,10 @@ pub(crate) fn preset_gh_pr_merge_guard() -> Vec { #[cfg(test)] mod tests { - use crate::blocked_patterns::{build_blocked_patterns, validate_command, BlockedPattern}; + use crate::blocked_patterns::{build_blocked_patterns, validate_command, SourcedPattern}; use crate::config::{Config, PreToolValidateConfig}; - fn patterns_with_presets(presets: &[&str]) -> Vec { + fn patterns_with_presets(presets: &[&str]) -> Vec { let config = Config { pre_tool_validate: Some(PreToolValidateConfig { blocked_patterns: Some(presets.iter().map(|s| s.to_string()).collect()), diff --git a/src/hooks-pre-tool-validate/src/presets/jj.rs b/src/hooks-pre-tool-validate/src/presets/jj.rs index 00210e05..a8331202 100644 --- a/src/hooks-pre-tool-validate/src/presets/jj.rs +++ b/src/hooks-pre-tool-validate/src/presets/jj.rs @@ -143,10 +143,10 @@ pub(crate) fn preset_jj_message_required() -> Vec { #[cfg(test)] mod tests { - use crate::blocked_patterns::{build_blocked_patterns, validate_command, BlockedPattern}; + use crate::blocked_patterns::{build_blocked_patterns, validate_command, SourcedPattern}; use crate::config::{Config, PreToolValidateConfig}; - fn patterns_with_presets(presets: &[&str]) -> Vec { + fn patterns_with_presets(presets: &[&str]) -> Vec { let config = Config { pre_tool_validate: Some(PreToolValidateConfig { blocked_patterns: Some(presets.iter().map(|s| s.to_string()).collect()), diff --git a/src/hooks-pre-tool-validate/src/presets/safety/polling_exe.rs b/src/hooks-pre-tool-validate/src/presets/safety/polling_exe.rs index 7f2a6f03..1f850c93 100644 --- a/src/hooks-pre-tool-validate/src/presets/safety/polling_exe.rs +++ b/src/hooks-pre-tool-validate/src/presets/safety/polling_exe.rs @@ -90,10 +90,10 @@ pub(crate) fn preset_exe_help_block() -> Vec { #[cfg(test)] mod tests { - use crate::blocked_patterns::{build_blocked_patterns, validate_command, BlockedPattern}; + use crate::blocked_patterns::{build_blocked_patterns, validate_command, SourcedPattern}; use crate::config::{Config, PreToolValidateConfig}; - fn patterns_with_presets(presets: &[&str]) -> Vec { + fn patterns_with_presets(presets: &[&str]) -> Vec { let config = Config { pre_tool_validate: Some(PreToolValidateConfig { blocked_patterns: Some(presets.iter().map(|s| s.to_string()).collect()), diff --git a/src/hooks-pre-tool-validate/src/presets/safety/powershell.rs b/src/hooks-pre-tool-validate/src/presets/safety/powershell.rs index edba3328..ff30129c 100644 --- a/src/hooks-pre-tool-validate/src/presets/safety/powershell.rs +++ b/src/hooks-pre-tool-validate/src/presets/safety/powershell.rs @@ -89,10 +89,10 @@ pub(crate) fn preset_powershell_destructive_write() -> Vec { #[cfg(test)] mod tests { - use crate::blocked_patterns::{build_blocked_patterns, validate_command, BlockedPattern}; + use crate::blocked_patterns::{build_blocked_patterns, validate_command, SourcedPattern}; use crate::config::{Config, PreToolValidateConfig}; - fn patterns_with_presets(presets: &[&str]) -> Vec { + fn patterns_with_presets(presets: &[&str]) -> Vec { let config = Config { pre_tool_validate: Some(PreToolValidateConfig { blocked_patterns: Some(presets.iter().map(|s| s.to_string()).collect()), diff --git a/src/hooks-pre-tool-validate/src/presets/safety/secret.rs b/src/hooks-pre-tool-validate/src/presets/safety/secret.rs index 4350a02c..688108a4 100644 --- a/src/hooks-pre-tool-validate/src/presets/safety/secret.rs +++ b/src/hooks-pre-tool-validate/src/presets/safety/secret.rs @@ -68,10 +68,10 @@ pub(crate) fn preset_secret_detection() -> Vec { #[cfg(test)] mod tests { - use crate::blocked_patterns::{build_blocked_patterns, validate_command, BlockedPattern}; + use crate::blocked_patterns::{build_blocked_patterns, validate_command, SourcedPattern}; use crate::config::{Config, PreToolValidateConfig}; - fn patterns_with_presets(presets: &[&str]) -> Vec { + fn patterns_with_presets(presets: &[&str]) -> Vec { let config = Config { pre_tool_validate: Some(PreToolValidateConfig { blocked_patterns: Some(presets.iter().map(|s| s.to_string()).collect()), diff --git a/src/hooks-stop-quality/Cargo.toml b/src/hooks-stop-quality/Cargo.toml index d49df5ba..bae31889 100644 --- a/src/hooks-stop-quality/Cargo.toml +++ b/src/hooks-stop-quality/Cargo.toml @@ -9,5 +9,6 @@ serde_json = "1.0" toml = "0.8" lib-subprocess = { path = "../lib-subprocess" } lib-jj-helpers = { path = "../lib-jj-helpers" } +lib-telemetry = { path = "../lib-telemetry" } # [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/hooks-stop-quality/src/main.rs b/src/hooks-stop-quality/src/main.rs index f2eb79a9..199f8e39 100644 --- a/src/hooks-stop-quality/src/main.rs +++ b/src/hooks-stop-quality/src/main.rs @@ -152,6 +152,7 @@ fn meta_is_fresh(meta_path: &Path) -> bool { /// block 判定を stdout に出力するヘルパー fn emit_block(reason: &str) { + record_block_firing(); let decision = BlockDecision { decision: "block".to_string(), reason: reason.to_string(), @@ -161,6 +162,18 @@ fn emit_block(reason: &str) { } } +/// Stop 品質ゲートが block を発火したこと (品質失敗・fail-closed infra エラーを含む +/// emit 総数) を telemetry に記録する (WP-12、fail-open)。 +fn record_block_firing() { + lib_telemetry::record(&lib_telemetry::Firing { + hook: "hooks-stop-quality", + kind: lib_telemetry::FiringKind::Hook, + id: "hooks-stop-quality", + decision: lib_telemetry::Decision::Block, + session_id: None, + }); +} + /// 設定ファイルのパス解決 fn config_path() -> PathBuf { std::env::current_exe() diff --git a/src/hooks-stop-tool-call-leak/Cargo.toml b/src/hooks-stop-tool-call-leak/Cargo.toml index f3b13eb9..b44cf02a 100644 --- a/src/hooks-stop-tool-call-leak/Cargo.toml +++ b/src/hooks-stop-tool-call-leak/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" +lib-telemetry = { path = "../lib-telemetry" } [dev-dependencies] tempfile = "3" diff --git a/src/hooks-stop-tool-call-leak/src/main.rs b/src/hooks-stop-tool-call-leak/src/main.rs index 57b85958..1f56ab40 100644 --- a/src/hooks-stop-tool-call-leak/src/main.rs +++ b/src/hooks-stop-tool-call-leak/src/main.rs @@ -79,18 +79,10 @@ fn main() { run_check(Path::new(&transcript_path), max_blocks); } -/// override env の受理値判定 (FILE_LENGTH_CHECK_OVERRIDE と同 pattern) -fn is_truthy(value: &str) -> bool { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) -} - /// kill-switch env が設定されていれば skip (stderr に明示) fn kill_switch_active() -> bool { match std::env::var(OVERRIDE_ENV_VAR) { - Ok(value) if is_truthy(&value) => { + Ok(value) if lib_telemetry::is_truthy(&value) => { eprintln!( "[stop-tool-call-leak] {} が設定されているため検査を skip します", OVERRIDE_ENV_VAR @@ -185,6 +177,7 @@ fn build_reason(scan: &TailScan, max_blocks: u32) -> String { /// block 判定を stdout に出力する fn emit_block(reason: &str) { + record_block_firing(); let decision = BlockDecision { decision: "block".to_string(), reason: reason.to_string(), @@ -198,6 +191,17 @@ fn emit_block(reason: &str) { } } +/// tool call leak 検知が block を発火したことを telemetry に記録する (WP-12、fail-open)。 +fn record_block_firing() { + lib_telemetry::record(&lib_telemetry::Firing { + hook: "hooks-stop-tool-call-leak", + kind: lib_telemetry::FiringKind::Hook, + id: "hooks-stop-tool-call-leak", + decision: lib_telemetry::Decision::Block, + session_id: None, + }); +} + #[cfg(test)] mod tests { use super::*; @@ -237,20 +241,6 @@ max_consecutive_blocks = 5 ); } - #[test] - fn is_truthy_accepts_standard_values() { - for value in ["1", "true", "TRUE", " yes ", "on"] { - assert!(is_truthy(value), "{:?} は truthy であるべき", value); - } - } - - #[test] - fn is_truthy_rejects_falsy_values() { - for value in ["", "0", "false", "off", "no", "2"] { - assert!(!is_truthy(value), "{:?} は falsy であるべき", value); - } - } - #[test] fn hook_input_parses_with_extra_fields() { let json = r#"{ diff --git a/src/lib-telemetry/Cargo.toml b/src/lib-telemetry/Cargo.toml new file mode 100644 index 00000000..8a295fe9 --- /dev/null +++ b/src/lib-telemetry/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lib-telemetry" +version = "0.1.0" +edition = "2021" + +[lib] +name = "lib_telemetry" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" + +[dev-dependencies] +tempfile = "3" + +# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/lib-telemetry/src/lib.rs b/src/lib-telemetry/src/lib.rs new file mode 100644 index 00000000..e3da5048 --- /dev/null +++ b/src/lib-telemetry/src/lib.rs @@ -0,0 +1,513 @@ +//! 発火テレメトリ収集層 (WP-12 step 1、ADR-055 firing-telemetry-collection)。 +//! +//! ハーネスの各 hook が block/warn を発火したイベントを `.claude/telemetry/` 配下の +//! JSONL に append する共通層。ROI 棚卸し (直近 N 日で発火 0 の rule/preset/hook を削除 +//! 候補として提示する) のデータ基盤であり、集計は後続 PR (WP-12 step 2) の Rust exe が担う。 +//! 本 crate は「収集の器」だけを提供する。 +//! +//! # 設計原則 +//! - **fail-open**: 記録は observation であってゲートではない。書き込み失敗・config 欠落・ +//! env 異常はすべて黙って握りつぶし、hook 本来の block/allow 判定を妨げない。ADR-043 の +//! fail-closed は「block/allow を決めるゲート関数」限定であり、observation 層は該当しない。 +//! - **opt-in (default OFF)**: `.claude/hooks-config.toml` の `[telemetry] enabled` が真の +//! ときのみ記録する。config 無し / 読めない / section 無し → OFF (ADR-039 標準パターン)。 +//! 派生プロジェクト配布は section 省略で自動的に OFF。 +//! - **kill-switch**: 恒久停止は `enabled = false`、緊急停止は env `CLAUDE_TELEMETRY_DISABLE` +//! (truthy 値)。 +//! - **プライバシー**: 記録はメタデータのみ (hook / kind / id / decision / timestamp、任意 +//! session_id)。ファイルパス・編集内容・コマンド本文は記録しない (custom rule ② +//! no-personal-paths と同じ思想)。 +//! +//! # 副作用注入 (テスト可能性) +//! [`record`] が prod 入口 (exe 隣の `.claude/` を解決 → opt-in 判定 → append)。純粋 writer +//! [`record_to`] と gate 込み [`record_gated_to`] は base_dir / now を引数注入し、テストが +//! temp dir へ確定的に書けるようにする (`lib-jj-helpers::pipeline_lock` の +//! `acquire_pipeline_lock_at` と同思想)。 + +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +/// telemetry 書き込み先ディレクトリ名 (base_dir 配下)。 +const TELEMETRY_DIR: &str = "telemetry"; + +/// 緊急停止用 env の名前 (kill-switch)。truthy 値で telemetry を完全無効化する。 +const KILL_SWITCH_ENV: &str = "CLAUDE_TELEMETRY_DISABLE"; + +/// プロセス内の書き込み直列化ロック。1 行を単一 `write_all` で書くことと合わせて +/// 同一プロセス内マルチスレッドでの行インターリーブを防ぐ。 +static WRITE_LOCK: Mutex<()> = Mutex::new(()); + +/// 発火の重大度。JSONL の `decision` フィールドになる。 +/// +/// hook がツールを実際に停止したかではなく「発火の重み」を表す軸。custom rule の +/// severity=error は Block、warning は Warn にマップする (詳細は ADR-055 スコープ表)。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Decision { + Block, + Warn, +} + +impl Decision { + fn as_str(self) -> &'static str { + match self { + Decision::Block => "block", + Decision::Warn => "warn", + } + } +} + +/// 発火主体の種類。JSONL の `kind` フィールドになる。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FiringKind { + Rule, + Preset, + Hook, +} + +impl FiringKind { + fn as_str(self) -> &'static str { + match self { + FiringKind::Rule => "rule", + FiringKind::Preset => "preset", + FiringKind::Hook => "hook", + } + } +} + +/// 1 件の発火イベント。 +pub struct Firing<'a> { + /// 発火した hook 名 (例 `"hooks-pre-tool-validate"`)。 + pub hook: &'a str, + pub kind: FiringKind, + /// rule id / preset 名 / hook 名。 + pub id: &'a str, + pub decision: Decision, + /// 相関用の session id (任意)。`None` の場合 [`record`] が `.claude/.session-id` から補完する。 + pub session_id: Option<&'a str>, +} + +/// JSONL 1 行の serde 表現。id が custom-lint-rules.toml 由来のユーザ入力を含み得るため、 +/// エスケープ安全性を serde_json に委ねる (手書き文字列連結はしない)。 +#[derive(serde::Serialize)] +struct TelemetryRecord<'a> { + ts: &'a str, + hook: &'a str, + kind: &'a str, + id: &'a str, + decision: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option<&'a str>, +} + +/// hooks-config.toml のトップレベル (telemetry section のみ関心)。 +#[derive(serde::Deserialize)] +struct HooksConfig { + telemetry: Option, +} + +#[derive(serde::Deserialize)] +struct TelemetrySection { + enabled: Option, +} + +/// prod 入口: 実行中 exe 隣の `.claude/` を解決 → opt-in 判定 (1 プロセス 1 回キャッシュ) → +/// 1 行 append。fail-open のため exe 解決失敗・config 欠落・書き込み失敗はすべて黙って無視し、 +/// never panic (`let _ =` で結果を意図的に破棄する)。 +pub fn record(firing: &Firing) { + let Some(base_dir) = exe_dir() else { + return; + }; + if !enabled_cached(&base_dir) { + return; + } + let session_id = firing.session_id.or_else(|| session_id_cached(&base_dir)); + let enriched = Firing { + hook: firing.hook, + kind: firing.kind, + id: firing.id, + decision: firing.decision, + session_id, + }; + let _ = record_to(&base_dir, &enriched, utc_now_epoch_secs()); +} + +/// 純粋 writer: opt-in 判定なしで `base_dir/telemetry/firings--.jsonl` へ +/// 1 行 append する。テストが temp dir へ確定的に書くための注入版。prod では [`record`] を使う。 +/// +/// per-process (pid) + 日次 (date) partition によりプロセス間の書き込み競合を構造的に排除し、 +/// [`WRITE_LOCK`] + 単一 `write_all` でプロセス内の行インターリーブを排除する。集計 (後続 PR) +/// は `firings-*.jsonl` を glob 走査する前提。 +pub fn record_to(base_dir: &Path, firing: &Firing, now_epoch: u64) -> io::Result<()> { + let ts = epoch_secs_to_iso8601(now_epoch); + let record = TelemetryRecord { + ts: &ts, + hook: firing.hook, + kind: firing.kind.as_str(), + id: firing.id, + decision: firing.decision.as_str(), + session_id: firing.session_id, + }; + let mut line = + serde_json::to_string(&record).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + line.push('\n'); + + let date = ts.get(..10).unwrap_or(ts.as_str()); + let pid = std::process::id(); + let path = base_dir + .join(TELEMETRY_DIR) + .join(format!("firings-{date}-{pid}.jsonl")); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let _guard = WRITE_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); + let mut file = OpenOptions::new().append(true).create(true).open(&path)?; + file.write_all(line.as_bytes())?; + Ok(()) +} + +/// gate 込み writer (OnceLock キャッシュ不使用): `base_dir` で opt-in を評価し、有効なら +/// [`record_to`] を呼ぶ。テストが temp dir を変えながら gate 挙動を検証するための版。 +/// fail-open: 書き込み失敗は握りつぶす。 +pub fn record_gated_to(base_dir: &Path, firing: &Firing, now_epoch: u64) { + if !telemetry_enabled(base_dir) { + return; + } + let _ = record_to(base_dir, firing, now_epoch); +} + +/// telemetry が有効かを判定する。 +/// +/// 1. env `CLAUDE_TELEMETRY_DISABLE` が truthy → 常に false (kill-switch)。 +/// 2. `base_dir/hooks-config.toml` の `[telemetry] enabled`。ファイル無し / 読めない / +/// parse 失敗 / section 無し / `enabled` 未指定 → false (default OFF、opt-in 契約)。 +pub fn telemetry_enabled(base_dir: &Path) -> bool { + if let Ok(v) = std::env::var(KILL_SWITCH_ENV) { + if is_truthy(&v) { + return false; + } + } + let Ok(content) = std::fs::read_to_string(base_dir.join("hooks-config.toml")) else { + return false; + }; + let Ok(config) = toml::from_str::(&content) else { + return false; + }; + config.telemetry.and_then(|t| t.enabled).unwrap_or(false) +} + +/// `1|true|yes|on` (前後空白無視・大小無視) を truthy として受理する。 +/// 既存 hook の kill-switch 受理集合と揃える。`pub`: hooks-post-tool-comment-lint-rust / +/// hooks-stop-tool-call-leak の override env 判定と共有する (3 crate 個別実装だった +/// DRY 違反を解消、両 crate は本 crate に既に依存していたため抽出コストが低かった)。 +pub fn is_truthy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +/// 実行中 exe の親ディレクトリ (= `.claude/`)。順位 287 規約 / ADR-010: hook exe はすべて +/// `.claude/` 配下に配置される。 +fn exe_dir() -> Option { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) +} + +/// opt-in 判定を 1 プロセス 1 回だけ評価してキャッシュする。base_dir は exe 由来で +/// プロセス内不変のためキャッシュ安全。custom rule ループ等で複数回 record しても再パースしない。 +fn enabled_cached(base_dir: &Path) -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| telemetry_enabled(base_dir)) +} + +/// `.claude/.session-id` を 1 プロセス 1 回だけ読んでキャッシュする。無ければ `None`。 +fn session_id_cached(base_dir: &Path) -> Option<&'static str> { + static SESSION_ID: OnceLock> = OnceLock::new(); + SESSION_ID + .get_or_init(|| { + std::fs::read_to_string(base_dir.join(".session-id")) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .as_deref() +} + +/// epoch 秒 → ISO 8601 UTC 文字列 (`YYYY-MM-DDTHH:MM:SSZ`)。 +/// +/// Howard Hinnant の proleptic Gregorian civil-date algorithm (pure std, no chrono)。 +/// `lib-pending-file` の同ヘルパーの最小複製。observation 層が post-merge-feedback ドメイン +/// 特化 crate に依存して責務結合するのを避けるため意図的に複製した (ADR-044 層1 の思想)。 +/// UTC ヘルパーの 2 つ目の消費者が現れたので、将来 3 つ目が現れたら中立 crate (例 lib-time) +/// への抽出候補 (抽出トリガ到達を ADR-055 に記録)。 +/// Reference: +fn epoch_secs_to_iso8601(epoch: u64) -> String { + let day_count = (epoch / SECS_PER_DAY) as i64; + let time_of_day = epoch % SECS_PER_DAY; + + let z = day_count + CIVIL_EPOCH_OFFSET; + let era = (if z >= 0 { z } else { z - DAYS_PER_ERA_M1 }) / DAYS_PER_ERA; + let doe = (z - era * DAYS_PER_ERA) as u64; + let yoe = (doe - doe / DAYS_PER_4Y + doe / DAYS_PER_100Y - doe / (DAYS_PER_ERA_M1 as u64)) + / DAYS_PER_YEAR; + let y = yoe as i64 + era * YEARS_PER_ERA; + let doy = doe - (DAYS_PER_YEAR * yoe + yoe / 4 - yoe / 100); + let mp = (MONTH_ENCODE_MUL * doy + 2) / MONTH_ENCODE_DIV; + let d = doy - (MONTH_ENCODE_DIV * mp + 2) / MONTH_ENCODE_MUL + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + let hour = time_of_day / SECS_PER_HOUR; + let min = (time_of_day % SECS_PER_HOUR) / SECS_PER_MIN; + let sec = time_of_day % SECS_PER_MIN; + + format!("{y:04}-{m:02}-{d:02}T{hour:02}:{min:02}:{sec:02}Z") +} + +/// 現在の epoch 秒を返す。時刻取得失敗時は 0 (fail-open)。 +fn utc_now_epoch_secs() -> u64 { + use std::time::SystemTime; + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Days from the proleptic Gregorian epoch (0000-03-01) to the Unix epoch (1970-01-01). +const CIVIL_EPOCH_OFFSET: i64 = 719_468; +/// Days in a 400-year Gregorian era. +const DAYS_PER_ERA: i64 = 146_097; +/// DAYS_PER_ERA - 1; used for the era-floor sign correction. +const DAYS_PER_ERA_M1: i64 = 146_096; +/// Days in a 4-year cycle (excluding century boundaries). +const DAYS_PER_4Y: u64 = 1_460; +/// Days in a 100-year cycle. +const DAYS_PER_100Y: u64 = 36_524; +/// Days in an ordinary year. +const DAYS_PER_YEAR: u64 = 365; +/// Years per 400-year Gregorian era. +const YEARS_PER_ERA: i64 = 400; +/// Multiplier for the month-to-day-of-year encoding: (5*mp + 2) / 153. +const MONTH_ENCODE_MUL: u64 = 5; +/// Divisor for the month-to-day-of-year encoding. +const MONTH_ENCODE_DIV: u64 = 153; +/// Seconds per hour. +const SECS_PER_HOUR: u64 = 3_600; +/// Seconds per minute. +const SECS_PER_MIN: u64 = 60; +/// Seconds per day. +const SECS_PER_DAY: u64 = 86_400; + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// 当該プロセスの firings ファイル内容を読む (テストプロセスは単一 pid)。 + fn read_firings(base: &Path, now: u64) -> String { + let iso = epoch_secs_to_iso8601(now); + let date = iso.get(..10).unwrap_or(iso.as_str()); + let pid = std::process::id(); + let path = base + .join(TELEMETRY_DIR) + .join(format!("firings-{date}-{pid}.jsonl")); + fs::read_to_string(path).unwrap_or_default() + } + + fn sample(id: &str) -> Firing<'_> { + Firing { + hook: "hooks-test", + kind: FiringKind::Preset, + id, + decision: Decision::Block, + session_id: None, + } + } + + /// 2026-04-01T12:00:00Z の epoch 秒。 + const T_2026_04_01_1200: u64 = 1_775_044_800; + + #[test] + fn record_to_writes_one_jsonl_line() { + let dir = tempfile::tempdir().unwrap(); + record_to(dir.path(), &sample("git"), T_2026_04_01_1200).unwrap(); + let content = read_firings(dir.path(), T_2026_04_01_1200); + assert_eq!(content.matches('\n').count(), 1); + let v: serde_json::Value = serde_json::from_str(content.trim_end()).unwrap(); + assert_eq!(v["ts"], "2026-04-01T12:00:00Z"); + assert_eq!(v["hook"], "hooks-test"); + assert_eq!(v["kind"], "preset"); + assert_eq!(v["id"], "git"); + assert_eq!(v["decision"], "block"); + assert!(v.get("session_id").is_none()); + } + + #[test] + fn record_to_appends_accumulates() { + let dir = tempfile::tempdir().unwrap(); + record_to(dir.path(), &sample("git"), T_2026_04_01_1200).unwrap(); + record_to(dir.path(), &sample("jj-push-guard"), T_2026_04_01_1200).unwrap(); + assert_eq!(read_firings(dir.path(), T_2026_04_01_1200).lines().count(), 2); + } + + #[test] + fn filename_contains_pid_and_date() { + let dir = tempfile::tempdir().unwrap(); + record_to(dir.path(), &sample("git"), T_2026_04_01_1200).unwrap(); + let tdir = dir.path().join(TELEMETRY_DIR); + let entries: Vec<_> = fs::read_dir(&tdir).unwrap().filter_map(Result::ok).collect(); + assert_eq!(entries.len(), 1); + let name = entries[0].file_name().into_string().unwrap(); + assert!(name.starts_with("firings-")); + assert!(name.ends_with(".jsonl")); + assert!(name.contains("2026-04-01")); + assert!(name.contains(&std::process::id().to_string())); + } + + #[test] + fn session_id_serialized_when_present() { + let dir = tempfile::tempdir().unwrap(); + let firing = Firing { + hook: "h", + kind: FiringKind::Hook, + id: "x", + decision: Decision::Warn, + session_id: Some("abc-123"), + }; + record_to(dir.path(), &firing, T_2026_04_01_1200).unwrap(); + let content = read_firings(dir.path(), T_2026_04_01_1200); + let v: serde_json::Value = serde_json::from_str(content.trim_end()).unwrap(); + assert_eq!(v["session_id"], "abc-123"); + assert_eq!(v["kind"], "hook"); + assert_eq!(v["decision"], "warn"); + } + + #[test] + fn json_escaping_is_safe() { + let dir = tempfile::tempdir().unwrap(); + let weird = r#"weird"id\with"#; + record_to(dir.path(), &sample(weird), T_2026_04_01_1200).unwrap(); + let content = read_firings(dir.path(), T_2026_04_01_1200); + let v: serde_json::Value = serde_json::from_str(content.trim_end()).unwrap(); + assert_eq!(v["id"], weird); + } + + #[test] + fn is_truthy_accepts_expected_values() { + for v in ["1", "true", "TRUE", "Yes", "on", " on "] { + assert!(is_truthy(v), "{v:?} should be truthy"); + } + for v in ["0", "false", "no", "off", "", "maybe"] { + assert!(!is_truthy(v), "{v:?} should be falsy"); + } + } + + #[test] + fn enabled_false_when_no_config() { + let dir = tempfile::tempdir().unwrap(); + assert!(!telemetry_enabled(dir.path())); + } + + #[test] + fn enabled_false_when_config_disables() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("hooks-config.toml"), + "[telemetry]\nenabled = false\n", + ) + .unwrap(); + assert!(!telemetry_enabled(dir.path())); + } + + #[test] + fn enabled_true_when_config_enables() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("hooks-config.toml"), + "[telemetry]\nenabled = true\n", + ) + .unwrap(); + assert!(telemetry_enabled(dir.path())); + } + + #[test] + fn enabled_false_when_section_missing() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("hooks-config.toml"), "[other]\nfoo = 1\n").unwrap(); + assert!(!telemetry_enabled(dir.path())); + } + + #[test] + fn record_gated_to_noop_when_disabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("hooks-config.toml"), + "[telemetry]\nenabled = false\n", + ) + .unwrap(); + record_gated_to(dir.path(), &sample("git"), T_2026_04_01_1200); + assert!(!dir.path().join(TELEMETRY_DIR).exists()); + } + + #[test] + fn record_gated_to_writes_when_enabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("hooks-config.toml"), + "[telemetry]\nenabled = true\n", + ) + .unwrap(); + record_gated_to(dir.path(), &sample("git"), T_2026_04_01_1200); + assert_eq!(read_firings(dir.path(), T_2026_04_01_1200).lines().count(), 1); + } + + #[test] + fn fail_open_when_base_dir_unwritable() { + let dir = tempfile::tempdir().unwrap(); + let file_as_base = dir.path().join("not-a-dir"); + fs::write(&file_as_base, "x").unwrap(); + assert!(record_to(&file_as_base, &sample("git"), T_2026_04_01_1200).is_err()); + } + + #[test] + fn concurrent_record_to_no_interleaving() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path().to_path_buf(); + let n = 50usize; + std::thread::scope(|s| { + for i in 0..n { + let base = base.clone(); + s.spawn(move || { + let id = format!("rule-{i}"); + record_to(&base, &sample(&id), T_2026_04_01_1200).unwrap(); + }); + } + }); + let content = read_firings(&base, T_2026_04_01_1200); + assert_eq!(content.lines().count(), n); + for line in content.lines() { + serde_json::from_str::(line).unwrap(); + } + } + + #[test] + #[ignore = "env var はプロセス全域のため直列実行 (--test-threads=1) が必要 (ADR-041)"] + fn kill_switch_env_forces_disabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("hooks-config.toml"), + "[telemetry]\nenabled = true\n", + ) + .unwrap(); + assert!(telemetry_enabled(dir.path())); + + std::env::set_var(KILL_SWITCH_ENV, "1"); + assert!(!telemetry_enabled(dir.path())); + std::env::remove_var(KILL_SWITCH_ENV); + assert!(telemetry_enabled(dir.path())); + } +}