From 4c38b1503d4054b4505b9d6704d11e068adf3b7c Mon Sep 17 00:00:00 2001 From: aloekun Date: Thu, 30 Jul 2026 02:14:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(hooks-session-start):=20=E6=9C=88=E6=AC=A1?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E8=B5=B7=E5=8B=95=20remind?= =?UTF-8?q?er=20(L1)=20=E3=82=92=E8=BF=BD=E5=8A=A0=20(WP-12=20step=202/3,?= =?UTF-8?q?=20ADR-062)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 月次ハーネス ROI レビュー (docs/monthly-harness-roi-review-plan.md) の Phase 2。SessionStart hook に `/monthly-review` skill 起動 reminder を追加する。 - hooks_config.rs: MonthlyReviewReminderConfig (enabled / threshold_days / system_message_enabled) - monthly_review.rs (新 module): last-run staleness の 1 経路。last_run_at 内容 timestamp で判定 (mtime 非依存)、resolve_main_workspace_root で main-root canonical 化 (ADR-045)、SingleLineMessage で systemMessage opt-in (ADR-059)、telemetry id monthly_review_reminder / warn で計装 (ADR-055)。weekly と異なり failed marker 経路なし (設計決定 1)。 - main.rs: append_cwd_nudges から配線、weekly + monthly の systemMessage を combine_system_messages で 1 行合成。50 行ガイドライン遵守で append_review_reminder_nudges に分割。 - hooks-config.toml: enabled=true / threshold_days=28 で dogfood (ADR-039: code default OFF)。 cargo test --workspace / cargo clippy --workspace --all-targets -- -D warnings / pnpm lint:md 全通。 --- .claude/hooks-config.toml | 19 + docs/monthly-harness-roi-review-plan.md | 45 +- src/hooks-session-start/src/hooks_config.rs | 90 ++- src/hooks-session-start/src/main.rs | 99 ++- src/hooks-session-start/src/monthly_review.rs | 566 ++++++++++++++++++ 5 files changed, 801 insertions(+), 18 deletions(-) create mode 100644 src/hooks-session-start/src/monthly_review.rs diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 8d110c4f..1c71ccb7 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -52,6 +52,25 @@ failed_marker_check_enabled = true # 前回失敗 marker 検出 → resume promo # (additionalContext の nudge は継続)。reminder 自体の停止は上の enabled = false。 system_message_enabled = true +# [session_start.monthly_review_reminder] +# - ADR-062 (WP-12 step 2/3): `/monthly-review` skill 起動の reminder (試験運用、ADR-039 準拠)。 +# 1 経路で発火 (weekly と異なり failed marker 経路は持たない、設計決定 1): +# `.claude/monthly-review-last-run.json` の `last_run_at` (内容 timestamp。mtime ではない +# — ADR-031 silent-fresh 教訓 / ADR-045 で main-root canonical 化) が `threshold_days` +# (既定 28) を超過したら telemetry ROI 棚卸しの `/monthly-review` 実行を促す。 +# dogfood 3 回で default-ON 昇格 or 却下を判定 (bounded lifetime、ADR-062)。 +# Kill-switch: `enabled = false` で完全停止。 +[session_start.monthly_review_reminder] +# 試験運用元 (本リポジトリ) では明示的に enable して reminder を実発火させる運用。 +# 派生プロジェクト deploy 時は default OFF (ADR-039 § 1 opt-in 契約) を維持 +# (code default は unwrap_or(false)、本行を置かなければ完全 skip)。 +enabled = true +threshold_days = 28 # ADR-053/061 の撤去粒度「4 週間」と整合 (月次粒度) +# ADR-059: reminder 発火時に systemMessage (ユーザー可視 1 行) を additionalContext と併せて出す。 +# source default OFF。本リポジトリは dogfood のため true。systemMessage のみ止めたい場合は false +# (additionalContext の nudge は継続)。reminder 自体の停止は上の enabled = false。 +system_message_enabled = true + # ─── PreToolUse: コマンド検証 ─── [pre_tool_validate] diff --git a/docs/monthly-harness-roi-review-plan.md b/docs/monthly-harness-roi-review-plan.md index a88992b1..e68782fa 100644 --- a/docs/monthly-harness-roi-review-plan.md +++ b/docs/monthly-harness-roi-review-plan.md @@ -206,13 +206,46 @@ worker thread panic を `QualityViolation` として誤計上しないよう `St → 実測で improve+main 横断集計・leak 13 block / recovery 2 warn の内訳分離・degraded 抑止を確認 (実測時点では main 側にも leak 発火が蓄積し 0 ではなくなっていたが、横断集計は正しく合算)。 -### Phase 2 (PR-3 前半): L1 reminder +### Phase 2 (PR-3 前半): L1 reminder ✅ 実装完了 (未 push、2026-07-30) + +**実施結果**: `hooks-session-start` に月次レビュー reminder を実装。 + +- `hooks_config.rs`: `MonthlyReviewReminderConfig` (`enabled` / `threshold_days` / + `system_message_enabled`) を追加し `SessionStartConfig.monthly_review_reminder` に配線 + + パーステスト 2 件 (section parse / system_message_enabled 省略時 None)。 +- `monthly_review.rs` (新 module): last-run staleness の 1 経路のみ (weekly と異なり failed + marker 経路は持たない、設計決定 1)。`.claude/monthly-review-last-run.json` の `last_run_at` + 内容 timestamp で判定 (mtime 非依存、`PastTime` で未来値を Stale 扱い)、 + `lib_jj_helpers::resolve_main_workspace_root` で main-root canonical 化 (ADR-045)、 + `SingleLineMessage` で systemMessage opt-in (ADR-059)、telemetry id `monthly_review_reminder` + / warn で計装 (ADR-055)。threshold の code default = 28 日 + (`MONTHLY_REVIEW_DEFAULT_THRESHOLD_DAYS`)。unit テスト 21 件 (閾値境界 / Missing=発火 / + Stale=発火 / Unreadable=抑制 / 未来値=Stale / default threshold / main-root canonical / + systemMessage opt-in / tell-user 指示)。 +- `main.rs`: `append_cwd_nudges` から monthly nudge を配線 (weekly の後、両 config は独立 opt-in)。 + weekly + monthly は systemMessage スロットが 1 つのため `combine_system_messages` で ` / ` + 区切りの 1 行に合成。関数長 50 行ガイドライン (順位 48) 遵守のため review reminder 部分を + `append_review_reminder_nudges` に切り出し。unit テスト 3 件 (合成 0/1/複数)。 +- `.claude/hooks-config.toml`: `[session_start.monthly_review_reminder]` を `enabled = true` / + `threshold_days = 28` / `system_message_enabled = true` で dogfood 有効化 (ADR-039: code + default は OFF、派生 deploy では section を置かず完全 skip)。 + +検証: `cargo test --workspace` (全 crate green、hooks-session-start 120 件) / +`cargo clippy --workspace --all-targets -- -D warnings` / `pnpm lint:md` 全通。 +**push / PR 作成は未実施** (通常フロー・ADR-028 ゲート待ち)。実 hook 発火の確認は dogfood に委ねる +(unit テストで閾値境界を固定済み、検証要件どおり)。 -- 設計決定 3 を実装。`hooks_config.rs` に config struct、`monthly_review.rs` (新 module、 - `weekly_review.rs` を参考に。ただし failed marker 経路は持たない)、`main.rs` の nudge 配線、 - unit テスト (weekly の test 構成を踏襲: 閾値境界 / Missing=発火 / Unreadable=抑制 / - main-root canonical / systemMessage opt-in)。 -- 本 repo の hooks-config.toml では enabled = true で dogfood (ADR-039: code default は OFF)。 +実装上の決定 (プラン未指定箇所、Phase 4 で ADR-062 へ反映): + +- **config 閾値フィールド名 = `threshold_days`** (weekly の `reminder_threshold_days` とは + 非対称だが、設計決定 3 の明示表記 `threshold_days=28` に従う)。code default 28 は + `monthly_review.rs` 側に置き、config 欠落時に適用。 +- **weekly + monthly 同時発火時の systemMessage 合成**: 出力 JSON の systemMessage スロットは + 1 つのため ` / ` 区切りで 1 行連結 (単一行不変条件は `SingleLineMessage` が構造的に保証)。 + additionalContext は両 reminder を独立に付す。 +- state file `.claude/monthly-review-last-run.json` の gitignore は Phase 1 で追加済み + (書き手は L3 skill、exe/hook 側は読むのみ)。当初計画どおり (すべて完了): + 設計決定 3 の全教訓 (a)〜(d) を適用、hooks-config.toml で dogfood。 ### Phase 3 (PR-3 後半 + skills repo): L3 skill + docs diff --git a/src/hooks-session-start/src/hooks_config.rs b/src/hooks-session-start/src/hooks_config.rs index e2ce34c5..debd20ce 100644 --- a/src/hooks-session-start/src/hooks_config.rs +++ b/src/hooks-session-start/src/hooks_config.rs @@ -1,8 +1,8 @@ //! `.claude/hooks-config.toml` の deserialization (session_start section)。 //! -//! 各 feature の config struct (StalenessConfig / WeeklyReviewReminderConfig) と -//! repo root からの読込関数を提供する。`[features].enabled` allow-list は -//! 本 crate ではなく `lib-hooks-config` で扱う (PR-3b で導入予定)。 +//! 各 feature の config struct (StalenessConfig / WeeklyReviewReminderConfig / +//! MonthlyReviewReminderConfig) と repo root からの読込関数を提供する。`[features].enabled` +//! allow-list は本 crate ではなく `lib-hooks-config` で扱う (PR-3b で導入予定)。 use serde::Deserialize; use std::path::{Path, PathBuf}; @@ -48,10 +48,37 @@ pub(crate) struct WeeklyReviewReminderConfig { pub(crate) system_message_enabled: Option, } +/// ADR-062 (WP-12 step 2/3): `/monthly-review` skill 起動 reminder 設定 (試験運用、ADR-039 experimental pattern)。 +/// +/// `[session_start.monthly_review_reminder]` section 不在 / `enabled` 未設定 / +/// `enabled = false` では完全 skip (default-OFF in source、repo config で明示 enable する)。 +/// +/// weekly_review_reminder と異なり failed marker 経路は持たない (設計決定 1: L2 の月次集計 +/// (cli-telemetry-report) は決定論 exe で高速なため `.failed` marker / resume 機構を採らない)。 +/// 発火は last-run staleness の 1 経路のみ: メイン workspace の +/// `.claude/monthly-review-last-run.json` の `last_run_at` (内容 timestamp。mtime ではない — +/// ADR-031 silent-fresh 教訓 / ADR-045 で main-root canonical 化) が `threshold_days` を +/// 超えていれば「`/monthly-review` の実行を検討」を nudge。 +/// +/// fail-open: ファイル読込失敗時は warning なしで通過する (session 起動阻害しない)。 +#[derive(Deserialize)] +pub(crate) struct MonthlyReviewReminderConfig { + pub(crate) enabled: Option, + /// staleness threshold (日)。source default は + /// [`MONTHLY_REVIEW_DEFAULT_THRESHOLD_DAYS`](crate::monthly_review) = 28 日 + /// (ADR-053/061 の撤去粒度「4 週間」と整合)。 + pub(crate) threshold_days: Option, + /// systemMessage (ユーザー可視 1 行、ADR-059) を出すか。source default OFF + /// (`unwrap_or(false)`)。`false` でも additionalContext の nudge は継続する + /// (systemMessage のみを止める kill-switch)。`enabled = false` は nudge 自体を止める。 + pub(crate) system_message_enabled: Option, +} + #[derive(Deserialize, Default)] pub(crate) struct SessionStartConfig { pub(crate) staleness: Option, pub(crate) weekly_review_reminder: Option, + pub(crate) monthly_review_reminder: Option, } #[derive(Deserialize, Default)] @@ -150,6 +177,63 @@ system_message_enabled = true let _ = std::fs::remove_dir_all(&root); } + #[test] + fn hooks_config_parses_session_start_monthly_review_reminder_section() { + use std::io::Write; + let root = unique_temp_root("monthly"); + let claude_dir = root.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + let toml_str = r#" +[session_start.monthly_review_reminder] +enabled = true +threshold_days = 28 +system_message_enabled = true +"#; + 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 monthly = config + .session_start + .as_ref() + .and_then(|s| s.monthly_review_reminder.as_ref()) + .expect("monthly_review_reminder section should parse"); + assert_eq!(monthly.enabled, Some(true)); + assert_eq!(monthly.threshold_days, Some(28)); + assert_eq!(monthly.system_message_enabled, Some(true)); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn monthly_review_system_message_enabled_defaults_to_none_when_omitted() { + use std::io::Write; + let root = unique_temp_root("monthly-no-sysmsg"); + let claude_dir = root.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + let toml_str = r#" +[session_start.monthly_review_reminder] +enabled = true +"#; + 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 monthly = config + .session_start + .as_ref() + .and_then(|s| s.monthly_review_reminder.as_ref()) + .expect("monthly_review_reminder section should parse"); + assert_eq!( + monthly.system_message_enabled, None, + "system_message_enabled 未設定は None (source default OFF、ADR-059)" + ); + assert_eq!( + monthly.threshold_days, None, + "threshold_days 未設定は None (code default 28 は monthly_review 側で適用)" + ); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn weekly_review_system_message_enabled_defaults_to_none_when_omitted() { use std::io::Write; diff --git a/src/hooks-session-start/src/main.rs b/src/hooks-session-start/src/main.rs index 2a637b67..16ceb9aa 100644 --- a/src/hooks-session-start/src/main.rs +++ b/src/hooks-session-start/src/main.rs @@ -8,6 +8,7 @@ //! 4. Orphan run reaper (ADR-030 §L2): `reaper` module //! 5. Working copy staleness nudge: `staleness` module //! 6. Weekly review reminder (ADR-031 Phase C): `weekly_review` module +//! 7. Monthly review reminder (ADR-062, WP-12 step 2/3): `monthly_review` module //! //! 各 nudge の発火は `lib-telemetry` (ADR-055) に `warn` として記録され、ROI 棚卸しの //! 観測基盤 (`.claude/telemetry/firings-*.jsonl`) に載る (fail-open)。 @@ -23,6 +24,7 @@ use std::path::{Path, PathBuf}; mod hooks_config; mod jj_helpers; +mod monthly_review; mod past_time; mod pr_monitor; mod reaper; @@ -30,6 +32,7 @@ mod staleness; mod weekly_review; use hooks_config::read_hooks_config; +use monthly_review::compute_monthly_review_reminder_nudge; use pr_monitor::{compute_catchup_nudge, pr_monitor_state_path, read_parked_state}; use reaper::compute_reaper_nudge; use staleness::{compute_staleness_nudge, compute_workspace_stale_nudge}; @@ -134,9 +137,10 @@ fn append_pr_monitor_catchup_nudge(context: &mut String, session_id: &str, now_u } } -/// cwd 依存の nudge 群 (reaper / staleness / workspace_stale / weekly review) を `context` に -/// 追記し、発火時は telemetry に記録する。weekly review のみユーザー可視の systemMessage を -/// 伴うため、それを戻り値として返す (発火しなければ `None`)。 +/// cwd 依存の nudge 群 (reaper / staleness / workspace_stale / weekly review / monthly review) を +/// `context` に追記し、発火時は telemetry に記録する。weekly / monthly review はユーザー可視の +/// systemMessage を伴い、両方発火した場合は 1 行に合成して返す (systemMessage スロットは 1 つのため。 +/// どちらも発火しなければ `None`)。 fn append_cwd_nudges( context: &mut String, session_id: &str, @@ -162,12 +166,60 @@ fn append_cwd_nudges( record_nudge_firing("workspace_stale", session_id); } } - let weekly_config = session_start.weekly_review_reminder.as_ref()?; - let weekly_nudge = compute_weekly_review_reminder_nudge(cwd, weekly_config, now_unix)?; - context.push_str("\n\n"); - context.push_str(&weekly_nudge.additional_context); - record_nudge_firing("weekly_review_reminder", session_id); - weekly_nudge.system_message + append_review_reminder_nudges(context, session_id, cwd, session_start, now_unix) +} + +/// weekly / monthly review reminder を評価して `context` に追記し、発火した systemMessage を +/// 合成して返す。両 reminder は同型 (additionalContext + 任意 systemMessage) で、systemMessage +/// スロットが出力 JSON に 1 つのため合成する。両 config は独立に opt-in される (片方だけ enable 可)。 +fn append_review_reminder_nudges( + context: &mut String, + session_id: &str, + cwd: &Path, + session_start: &hooks_config::SessionStartConfig, + now_unix: i64, +) -> Option { + let mut system_messages: Vec = Vec::new(); + if let Some(weekly_config) = session_start.weekly_review_reminder.as_ref() { + if let Some(weekly_nudge) = + compute_weekly_review_reminder_nudge(cwd, weekly_config, now_unix) + { + context.push_str("\n\n"); + context.push_str(&weekly_nudge.additional_context); + record_nudge_firing("weekly_review_reminder", session_id); + system_messages.extend(weekly_nudge.system_message); + } + } + if let Some(monthly_config) = session_start.monthly_review_reminder.as_ref() { + if let Some(monthly_nudge) = + compute_monthly_review_reminder_nudge(cwd, monthly_config, now_unix) + { + context.push_str("\n\n"); + context.push_str(&monthly_nudge.additional_context); + record_nudge_firing("monthly_review_reminder", session_id); + system_messages.extend(monthly_nudge.system_message); + } + } + combine_system_messages(system_messages) +} + +/// 複数の nudge が返した systemMessage を 1 行に合成する (ADR-059: systemMessage スロットは +/// 出力 JSON に 1 つのため)。 +/// +/// 0 件 → `None`、1 件 → そのまま、複数 → ` / ` 区切りで連結して 1 つの `SingleLineMessage` に +/// する (連結後も単一行不変条件は `SingleLineMessage::new` が構造的に保証する)。 +fn combine_system_messages(messages: Vec) -> Option { + match messages.len() { + 0 => None, + 1 => messages.into_iter().next(), + _ => Some(SingleLineMessage::new( + messages + .iter() + .map(SingleLineMessage::as_str) + .collect::>() + .join(" / "), + )), + } } /// nudge の発火を telemetry (ADR-055) に記録する (fail-open)。 @@ -340,6 +392,35 @@ mod tests { ); } + #[test] + fn combine_system_messages_none_when_empty() { + assert!(combine_system_messages(Vec::new()).is_none()); + } + + #[test] + fn combine_system_messages_passes_single_through_unchanged() { + let msg = combine_system_messages(vec![SingleLineMessage::new("週次レビュー: 実行記録なし")]) + .expect("1 件はそのまま Some"); + assert_eq!(msg.as_str(), "週次レビュー: 実行記録なし"); + } + + #[test] + fn combine_system_messages_joins_multiple_on_one_line() { + let msg = combine_system_messages(vec![ + SingleLineMessage::new("週次レビュー: 実行記録なし"), + SingleLineMessage::new("月次レビュー: 実行記録なし"), + ]) + .expect("複数件は合成して Some"); + assert_eq!( + msg.as_str(), + "週次レビュー: 実行記録なし / 月次レビュー: 実行記録なし" + ); + assert!( + !msg.as_str().contains('\n'), + "合成後も単一行不変条件を満たす (SingleLineMessage が保証)" + ); + } + #[test] fn shell_quote_simple() { assert_eq!(shell_quote("abc-123"), "'abc-123'"); diff --git a/src/hooks-session-start/src/monthly_review.rs b/src/hooks-session-start/src/monthly_review.rs new file mode 100644 index 00000000..b4e135b6 --- /dev/null +++ b/src/hooks-session-start/src/monthly_review.rs @@ -0,0 +1,566 @@ +//! ADR-062 (WP-12 step 2/3): `/monthly-review` skill 起動 reminder。 +//! +//! last-run staleness の 1 経路のみを発火する: +//! - `.claude/monthly-review-last-run.json` の `last_run_at` が `threshold_days` (既定 28) を +//! 超えていれば「`/monthly-review` の実行を検討」を nudge。`last_run_at` が欠落/不正な +//! 旧・破損データは stale 扱い (= 発火) にする。 +//! +//! [`crate::weekly_review`] と異なり failed marker / resume 経路は持たない (設計決定 1: L2 の +//! 月次集計 (cli-telemetry-report) は決定論 exe で高速なため `.failed` marker を採らず、失敗時は +//! skill がエラー報告するのみの best-effort)。 +//! +//! staleness の情報源を mtime にしない (欠落時も mtime にフォールバックしない) のは、状態ファイルが +//! jj checkout / workspace materialization (ADR-045) のたびに再マテリアライズされ mtime が +//! リセットされるため。mtime に依存すると「実際は 1 か月前の実行なのに fresh」に見え、reminder が +//! 永久に発火しない silent-fresh バグ (past_time / reaper / weekly_review と同クラス) を踏む。 +//! `last_run_at` は skill が書き込む内容 timestamp で、mtime と違い jj checkout では書き換わらない。 +//! 欠落データは次回実行で backfill される (self-healing)。 +//! +//! ただし状態ファイル自体は gitignore 済み untracked で **workspace ローカル** なため secondary +//! workspace には存在しない。last-run 読込は [`lib_jj_helpers::resolve_main_workspace_root`] で +//! メイン workspace root に canonical 化する (ADR-045 状態分裂対策。hook 読み側と skill 書き側の両方)。 + +use lib_hook_output::SingleLineMessage; +use serde::Deserialize; +use std::path::Path; + +use crate::hooks_config::MonthlyReviewReminderConfig; +use crate::past_time::PastTime; +use crate::reaper::parse_iso8601_to_unix; + +/// monthly review reminder の threshold (default 28 日、ADR-053/061 の撤去粒度「4 週間」と整合)。 +pub(crate) const MONTHLY_REVIEW_DEFAULT_THRESHOLD_DAYS: u64 = 28; +pub(crate) const MONTHLY_REVIEW_LAST_RUN_PATH: &str = ".claude/monthly-review-last-run.json"; + +/// additionalContext に付す明示指示 (ADR-059 defense-in-depth)。systemMessage が UI に +/// 表示されない環境でも、モデル経由でユーザーへ届くよう「冒頭で一言伝えよ」を義務づける。 +const MONTHLY_REVIEW_TELL_USER_INSTRUCTION: &str = + "セッション最初の応答で、この monthly-review reminder をユーザーに一言伝えること (systemMessage 非表示環境向けの defense-in-depth、ADR-059)。"; + +/// `.claude/monthly-review-last-run.json` の last-run 状態。 +/// +/// `Missing` (= 未実行 / 初回) / `Stale` (= last_run_at 欠落・不正) / `Unreadable` (= 読込失敗) を +/// 区別することで fail-open 方針を正しく適用する: Missing / Stale は reminder 発火 (= 初回利用ナビ / +/// 旧データ移行促し)、Unreadable は reminder 抑制 (= ユーザーを誤通知で煩わせない)。 +pub(crate) enum MonthlyLastRunState { + Missing, + Stale, + ElapsedDays(u64), + Unreadable, +} + +/// `.claude/monthly-review-last-run.json` の必要フィールドのみ。 +/// +/// `last_run_at` は skill Phase 4 が実行完了時刻を RFC 3339 (UTC) で書き込む authoritative +/// timestamp。jj checkout / workspace materialization で書き換わる mtime と違い内容 timestamp は +/// checkout で変わらないため staleness 判定の第一情報源とする (ファイル自体は workspace ローカルで、 +/// 読込元は [`compute_monthly_review_reminder_nudge`] がメイン workspace root に canonical 化する)。 +#[derive(Deserialize)] +struct MonthlyLastRunFile { + last_run_at: Option, +} + +/// `.claude/monthly-review-last-run.json` の状態を判定する。 +/// +/// 判定順: +/// 1. ファイル不在 → `Missing` (初回利用ナビとして reminder 発火) +/// 2. 読込失敗 → `Unreadable` (誤通知抑制) +/// 3. `last_run_at` が parse 可能かつ過去 → その経過日数 (mtime 非依存、jj workspace 耐性) +/// 4. `last_run_at` 欠落 / parse 不能 / 未来値 → `Stale` (発火)。mtime にはフォールバックしない +/// (mtime は jj workspace で reset され silent-fresh を再導入するため)。欠落データは次回 +/// skill 実行で `last_run_at` が書かれて backfill される (self-healing)。 +fn monthly_review_last_run_state(main_root: &Path, now_unix: i64) -> MonthlyLastRunState { + let path = main_root.join(MONTHLY_REVIEW_LAST_RUN_PATH); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return MonthlyLastRunState::Missing, + Err(_) => return MonthlyLastRunState::Unreadable, + }; + last_run_state_from_content(&content, now_unix).unwrap_or(MonthlyLastRunState::Stale) +} + +/// `last_run_at` フィールドから経過日数を導出する。 +/// +/// `None` を返すのは「フィールド欠落 / RFC3339 parse 不能 / 未来 timestamp」の場合で、 +/// caller はこれを `Stale` (発火) 扱いにする (mtime にはフォールバックしない)。未来 timestamp を +/// silent に fresh 扱いしないよう `PastTime::from_parts` で past invariant を型検証する +/// ([`crate::past_time`] と同方針)。 +fn last_run_state_from_content(content: &str, now_unix: i64) -> Option { + let parsed: MonthlyLastRunFile = serde_json::from_str(content).ok()?; + let last_run_at = parsed.last_run_at?; + let epoch = parse_iso8601_to_unix(&last_run_at)?; + let past = PastTime::from_parts(epoch, now_unix)?; + Some(MonthlyLastRunState::ElapsedDays( + (past.age_secs() / 86_400) as u64, + )) +} + +fn monthly_review_staleness_label(state: &MonthlyLastRunState) -> &'static str { + match state { + MonthlyLastRunState::Missing => "未実行", + MonthlyLastRunState::Stale => "last_run_at 欠落/不正/未来 (stale 扱い)", + MonthlyLastRunState::ElapsedDays(_) => "", + MonthlyLastRunState::Unreadable => "読込失敗", + } +} + +pub(crate) fn monthly_review_staleness_hits( + state: &MonthlyLastRunState, + threshold_days: u64, +) -> bool { + match state { + MonthlyLastRunState::Missing => true, + MonthlyLastRunState::Stale => true, + MonthlyLastRunState::ElapsedDays(d) => *d >= threshold_days, + MonthlyLastRunState::Unreadable => false, + } +} + +fn build_monthly_review_staleness_lines( + state: &MonthlyLastRunState, + threshold_days: u64, +) -> Vec { + if !monthly_review_staleness_hits(state, threshold_days) { + return Vec::new(); + } + let elapsed_label = match state { + MonthlyLastRunState::ElapsedDays(d) => format!("{} 日経過", d), + _ => monthly_review_staleness_label(state).to_string(), + }; + vec![ + "[MONTHLY_REVIEW_REMINDER]".to_string(), + format!( + "月次ハーネス ROI レビュー (ADR-062) が threshold ({} 日) を超えました (前回からの経過: {})。\n\ + 推奨: `/monthly-review` skill を起動して telemetry (ADR-055) の発火実績を棚卸しし、発火 0 の rule/preset/hook や bounded-lifetime 機構 (例: tool call leak 検知 ADR-053/061) の非アクティブ化候補を確認する (自動無効化はしない。採否は AskUserQuestion を経る、ADR-022/028)。", + threshold_days, elapsed_label, + ), + ] +} + +/// monthly review reminder の nudge 出力 (ADR-059 の 2 層可視化チャネル)。 +pub(crate) struct MonthlyReviewNudge { + /// モデル可視。`hookSpecificOutput.additionalContext` に載る詳細 + 行動指示。 + pub(crate) additional_context: String, + /// ユーザー可視の 1 行サマリー。`systemMessage` に載る。`system_message_enabled` が + /// 真かつ nudge 発火時のみ `Some`。単一行不変条件は `SingleLineMessage` が構造的に保証する。 + pub(crate) system_message: Option, +} + +/// ADR-059: monthly nudge のユーザー可視 1 行サマリー (systemMessage) を組み立てる。 +/// +/// staleness が無ければ `None` (additionalContext の発火条件と一致)。表示ノイズを抑えるため +/// 1 行に限定する (単一行不変条件は `SingleLineMessage` が構造的に保証し、`\n` / `\r` が混じっても +/// 構築時にサニタイズされる)。詳細は additionalContext に寄せる。 +fn build_monthly_review_system_message( + state: &MonthlyLastRunState, + threshold_days: u64, +) -> Option { + if !monthly_review_staleness_hits(state, threshold_days) { + return None; + } + let elapsed = match state { + MonthlyLastRunState::ElapsedDays(d) => format!("前回実行から {} 日経過", d), + MonthlyLastRunState::Missing => "実行記録なし".to_string(), + _ => "前回実行の記録が不正/欠落".to_string(), + }; + Some(SingleLineMessage::new(format!( + "月次レビュー: {} (threshold {} 日)。`/monthly-review` の実行を検討してください", + elapsed, threshold_days + ))) +} + +/// ADR-062: monthly review reminder の nudge を組み立てる。 +/// +/// 発火は last-run staleness の 1 経路のみ (weekly_review と異なり failed marker 経路は持たない)。 +/// 該当なし (= last-run が threshold 内) は None を返す。 +/// +/// ADR-045: last-run 状態は gitignore 済み untracked で workspace ローカルのため、`repo_root` +/// (現 workspace) ではなく [`lib_jj_helpers::resolve_main_workspace_root`] で導出したメイン +/// workspace root から読む (secondary workspace でもメイン側の実行記録を共有し、「未実行」誤判定で +/// 永久発火するのを防ぐ)。導出不能時は現 root に fail-open する。 +/// +/// ADR-059: 戻り値は `additional_context` (モデル可視、末尾に「ユーザーに伝えよ」明示指示を付す) と +/// `system_message` (ユーザー可視 1 行、`system_message_enabled` が真のときのみ `Some`) の 2 層。 +pub(crate) fn compute_monthly_review_reminder_nudge( + repo_root: &Path, + config: &MonthlyReviewReminderConfig, + now_unix: i64, +) -> Option { + if !config.enabled.unwrap_or(false) { + return None; + } + let threshold_days = config + .threshold_days + .unwrap_or(MONTHLY_REVIEW_DEFAULT_THRESHOLD_DAYS); + let main_root = lib_jj_helpers::resolve_main_workspace_root(repo_root) + .unwrap_or_else(|| repo_root.to_path_buf()); + let last_run_state = monthly_review_last_run_state(&main_root, now_unix); + let staleness_lines = build_monthly_review_staleness_lines(&last_run_state, threshold_days); + if staleness_lines.is_empty() { + return None; + } + let mut lines = staleness_lines; + lines.push(String::new()); + lines.push(MONTHLY_REVIEW_TELL_USER_INSTRUCTION.to_string()); + let additional_context = lines.join("\n"); + + let system_message = if config.system_message_enabled.unwrap_or(false) { + build_monthly_review_system_message(&last_run_state, threshold_days) + } else { + None + }; + + Some(MonthlyReviewNudge { + additional_context, + system_message, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn unique_temp_root(prefix: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "monthly-review-{}-{}-{}", + prefix, + std::process::id(), + nanos + )) + } + + #[test] + fn compute_monthly_review_reminder_nudge_returns_none_when_disabled() { + let root = unique_temp_root("disabled"); + std::fs::create_dir_all(&root).unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(false), + threshold_days: Some(28), + system_message_enabled: Some(false), + }; + assert!(compute_monthly_review_reminder_nudge(&root, &config, 2_000_000_000).is_none()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn compute_monthly_review_reminder_nudge_emits_staleness_when_never_run() { + let root = unique_temp_root("staleness-never"); + std::fs::create_dir_all(&root).unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: Some(28), + system_message_enabled: Some(false), + }; + let nudge = compute_monthly_review_reminder_nudge(&root, &config, 2_000_000_000) + .expect("staleness nudge must be emitted when last-run file missing"); + assert!(nudge.additional_context.contains("[MONTHLY_REVIEW_REMINDER]")); + assert!(nudge.additional_context.contains("threshold (28 日)")); + assert!(nudge.additional_context.contains("未実行")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn compute_monthly_review_reminder_nudge_uses_default_threshold_when_omitted() { + let root = unique_temp_root("default-threshold"); + let last_run_path = root.join(MONTHLY_REVIEW_LAST_RUN_PATH); + std::fs::create_dir_all(last_run_path.parent().unwrap()).unwrap(); + let last_run_str = "2026-06-01T00:00:00Z"; + let then = parse_iso8601_to_unix(last_run_str).unwrap(); + std::fs::write( + &last_run_path, + format!("{{\"last_run_at\": \"{}\"}}", last_run_str), + ) + .unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: None, + system_message_enabled: Some(false), + }; + assert!( + compute_monthly_review_reminder_nudge(&root, &config, then + 20 * 86_400).is_none(), + "20 日経過は code default threshold (28 日) 未満なので発火しない" + ); + let nudge = + compute_monthly_review_reminder_nudge(&root, &config, then + 30 * 86_400).expect( + "30 日経過は code default threshold (28 日) を超えるので発火する", + ); + assert!(nudge.additional_context.contains("threshold (28 日)")); + assert!(nudge.additional_context.contains("30 日経過")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn last_run_read_from_main_root_in_secondary_workspace() { + let base = unique_temp_root("main-root-split"); + let main = base.join("main"); + let ws = base.join("ws"); + let last_run_str = "2026-06-01T00:00:00Z"; + let then = parse_iso8601_to_unix(last_run_str).unwrap(); + let now = then + 40 * 86_400; + std::fs::create_dir_all(main.join(".claude")).unwrap(); + std::fs::write( + main.join(MONTHLY_REVIEW_LAST_RUN_PATH), + format!("{{\"last_run_at\": \"{}\"}}", last_run_str), + ) + .unwrap(); + std::fs::create_dir_all(ws.join(".jj")).unwrap(); + std::fs::write(ws.join(".jj/repo"), "../../main/.jj/repo").unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: Some(28), + system_message_enabled: Some(true), + }; + let nudge = compute_monthly_review_reminder_nudge(&ws, &config, now) + .expect("secondary workspace でもメイン root の last-run で発火する"); + assert!( + nudge.additional_context.contains("40 日経過"), + "last-run はメイン workspace root から読む (secondary の未実行に fallback しない): {}", + nudge.additional_context + ); + let msg = nudge + .system_message + .expect("system_message_enabled = true なので systemMessage が付く"); + assert!( + msg.as_str().contains("40 日経過"), + "systemMessage も main-root 由来の経過日数: {}", + msg + ); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn compute_monthly_review_reminder_nudge_uses_last_run_at_over_fresh_mtime() { + let root = unique_temp_root("last-run-at-stale"); + let last_run_path = root.join(MONTHLY_REVIEW_LAST_RUN_PATH); + std::fs::create_dir_all(last_run_path.parent().unwrap()).unwrap(); + let last_run_str = "2026-06-01T00:00:00Z"; + let then = parse_iso8601_to_unix(last_run_str).unwrap(); + let now = then + 60 * 86_400; + std::fs::write( + &last_run_path, + format!("{{\"last_run_at\": \"{}\"}}", last_run_str), + ) + .unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: Some(28), + system_message_enabled: Some(false), + }; + let nudge = compute_monthly_review_reminder_nudge(&root, &config, now) + .expect("60 日前の last_run_at は fresh な mtime に関わらず staleness を発火させる"); + assert!(nudge.additional_context.contains("[MONTHLY_REVIEW_REMINDER]")); + assert!(nudge.additional_context.contains("60 日経過")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn compute_monthly_review_reminder_nudge_recent_last_run_at_skips_staleness() { + let root = unique_temp_root("last-run-at-recent"); + let last_run_path = root.join(MONTHLY_REVIEW_LAST_RUN_PATH); + std::fs::create_dir_all(last_run_path.parent().unwrap()).unwrap(); + let last_run_str = "2026-06-01T00:00:00Z"; + let then = parse_iso8601_to_unix(last_run_str).unwrap(); + let now = then + 10 * 86_400; + std::fs::write( + &last_run_path, + format!("{{\"last_run_at\": \"{}\"}}", last_run_str), + ) + .unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: Some(28), + system_message_enabled: Some(false), + }; + assert!( + compute_monthly_review_reminder_nudge(&root, &config, now).is_none(), + "10 日前の last_run_at は threshold (28 日) 未満なので発火しない" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn compute_monthly_review_reminder_nudge_treats_missing_last_run_at_as_stale() { + let root = unique_temp_root("missing-last-run-at"); + let last_run_path = root.join(MONTHLY_REVIEW_LAST_RUN_PATH); + std::fs::create_dir_all(last_run_path.parent().unwrap()).unwrap(); + std::fs::write(&last_run_path, "{}").unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: Some(28), + system_message_enabled: Some(false), + }; + let nudge = compute_monthly_review_reminder_nudge(&root, &config, 2_000_000_000) + .expect("last_run_at 欠落は mtime にフォールバックせず stale 扱いで発火する"); + assert!(nudge.additional_context.contains("[MONTHLY_REVIEW_REMINDER]")); + assert!(nudge.additional_context.contains("stale 扱い")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn compute_monthly_review_reminder_nudge_suppresses_when_unreadable() { + let root = unique_temp_root("unreadable"); + let last_run_path = root.join(MONTHLY_REVIEW_LAST_RUN_PATH); + std::fs::create_dir_all(&last_run_path).unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: Some(28), + system_message_enabled: Some(true), + }; + assert!( + compute_monthly_review_reminder_nudge(&root, &config, 2_000_000_000).is_none(), + "読込失敗 (Unreadable) は誤通知を避けるため reminder を抑制する (fail-open)" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn last_run_state_from_content_prefers_last_run_at() { + let then = parse_iso8601_to_unix("2026-06-01T00:00:00Z").unwrap(); + let now = then + 35 * 86_400; + let content = "{\"last_run_at\": \"2026-06-01T00:00:00Z\"}"; + match last_run_state_from_content(content, now) { + Some(MonthlyLastRunState::ElapsedDays(d)) => assert_eq!(d, 35), + _ => panic!("expected ElapsedDays(35) derived from last_run_at"), + } + } + + #[test] + fn last_run_state_from_content_none_when_field_absent() { + assert!(last_run_state_from_content("{}", 2_000_000_000).is_none()); + } + + #[test] + fn last_run_state_from_content_none_when_unparseable() { + assert!( + last_run_state_from_content("{\"last_run_at\": \"not-a-date\"}", 2_000_000_000) + .is_none() + ); + } + + #[test] + fn last_run_state_from_content_none_when_future() { + let now = parse_iso8601_to_unix("2026-06-01T00:00:00Z").unwrap(); + let content = "{\"last_run_at\": \"2026-06-02T00:00:00Z\"}"; + assert!( + last_run_state_from_content(content, now).is_none(), + "未来 timestamp は None を返し caller が Stale 扱いにする (silent-fresh 防止)" + ); + } + + #[test] + fn monthly_review_staleness_hits_for_missing_state() { + assert!(monthly_review_staleness_hits( + &MonthlyLastRunState::Missing, + 28 + )); + } + + #[test] + fn monthly_review_staleness_hits_for_stale_state() { + assert!(monthly_review_staleness_hits( + &MonthlyLastRunState::Stale, + 28 + )); + } + + #[test] + fn monthly_review_staleness_hits_for_elapsed_above_threshold() { + assert!(monthly_review_staleness_hits( + &MonthlyLastRunState::ElapsedDays(30), + 28 + )); + } + + #[test] + fn monthly_review_staleness_skips_for_elapsed_below_threshold() { + assert!(!monthly_review_staleness_hits( + &MonthlyLastRunState::ElapsedDays(10), + 28 + )); + } + + #[test] + fn monthly_review_staleness_skips_for_unreadable_state() { + assert!(!monthly_review_staleness_hits( + &MonthlyLastRunState::Unreadable, + 28 + )); + } + + #[test] + fn system_message_is_some_when_enabled_and_never_run() { + let root = unique_temp_root("sysmsg-never"); + std::fs::create_dir_all(&root).unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: Some(28), + system_message_enabled: Some(true), + }; + let nudge = compute_monthly_review_reminder_nudge(&root, &config, 2_000_000_000) + .expect("nudge must fire when last-run file missing"); + let msg = nudge + .system_message + .expect("system_message_enabled = true なので systemMessage が付く"); + assert!(msg.as_str().contains("月次レビュー")); + assert!(msg.as_str().contains("実行記録なし")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn system_message_is_none_when_disabled_but_additional_context_still_fires() { + let root = unique_temp_root("sysmsg-off"); + std::fs::create_dir_all(&root).unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: Some(28), + system_message_enabled: Some(false), + }; + let nudge = compute_monthly_review_reminder_nudge(&root, &config, 2_000_000_000) + .expect("system_message_enabled = false でも additionalContext の nudge は発火する"); + assert!(nudge.system_message.is_none()); + assert!(nudge + .additional_context + .contains("[MONTHLY_REVIEW_REMINDER]")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn additional_context_includes_tell_user_instruction() { + let root = unique_temp_root("tell-user"); + std::fs::create_dir_all(&root).unwrap(); + let config = MonthlyReviewReminderConfig { + enabled: Some(true), + threshold_days: Some(28), + system_message_enabled: Some(false), + }; + let nudge = compute_monthly_review_reminder_nudge(&root, &config, 2_000_000_000) + .expect("nudge fires"); + assert!( + nudge + .additional_context + .contains("ユーザーに一言伝えること"), + "ADR-059 defense-in-depth の明示指示が additionalContext に含まれる" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn build_monthly_review_system_message_none_when_fresh() { + assert!( + build_monthly_review_system_message(&MonthlyLastRunState::ElapsedDays(10), 28).is_none() + ); + } + + #[test] + fn build_monthly_review_system_message_reports_elapsed_days() { + let msg = build_monthly_review_system_message(&MonthlyLastRunState::ElapsedDays(35), 28) + .expect("staleness があれば Some"); + assert!(msg.as_str().contains("35 日経過")); + assert!(msg.as_str().contains("threshold 28 日")); + } +}