diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 935bef44..2bea4ab2 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -45,6 +45,12 @@ stale_check_enabled = true enabled = true reminder_threshold_days = 7 # ADR-031 § トリガー方式: 「前回実行から 7 日経過で promote」と整合 failed_marker_check_enabled = true # 前回失敗 marker 検出 → resume promote。false で staleness のみに限定可 +# ADR-059 (試験運用、判定期限 2026-08-16): reminder 発火時に systemMessage (ユーザー可視 1 行) を +# additionalContext と併せて出し、「発火しているのにユーザーに見えない」silent 化を解消する。 +# source default OFF (派生 repo deploy 時は本行を置かない = additionalContext のみの従来挙動)。 +# 本リポジトリは dogfood のため true。systemMessage のみ止めたい場合は false +# (additionalContext の nudge は継続)。reminder 自体の停止は上の enabled = false。 +system_message_enabled = true # ─── PreToolUse: コマンド検証 ─── diff --git a/CLAUDE.md b/CLAUDE.md index eb24acef..e881f237 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ - [ADR-056: takt builtin review policy の shadow — policy 層を anomaly 設計に整合させる](docs/adr/adr-056-review-policy-anomaly-shadow.md) *(試験運用)* - [ADR-057: docs-only / 空 diff の決定論 routing — instruction 規約から決定論機構への昇格](docs/adr/adr-057-docs-only-deterministic-routing.md) *(試験運用)* - [ADR-058: fix 後の決定論再ゲート (post-takt re-gate) — pre-push 経路への機械的 backstop 拡張](docs/adr/adr-058-post-takt-regate.md) *(試験運用)* +- [ADR-059: hook 通知の可視化チャネル分離 (systemMessage = ユーザー向け / additionalContext = モデル向け)](docs/adr/adr-059-hook-system-message-visibility.md) *(試験運用)* ## 開発 convention / チェックリスト diff --git a/docs/adr/adr-059-hook-system-message-visibility.md b/docs/adr/adr-059-hook-system-message-visibility.md new file mode 100644 index 00000000..354d7731 --- /dev/null +++ b/docs/adr/adr-059-hook-system-message-visibility.md @@ -0,0 +1,119 @@ +# ADR-059: hook 通知の可視化チャネル分離 (systemMessage = ユーザー向け / additionalContext = モデル向け) + +## ステータス + +試験運用 (2026-07-19) / **dogfood 中 (判定期限 2026-08-16)** + +> 本 ADR は [ADR-039 (試験運用標準パターン)](adr-039-experimental-feature-standard-pattern.md) の +> 対象。ランタイム機能なので 3 点セット (config opt-in / kill-switch / bounded lifetime) を +> そのまま適用する (後述「ADR-039 3 点セットの適用」)。 + +## コンテキスト + +`docs/weekly-review-notification-plan.md` の PR-N1。 + +### 問題: 行動要求系 nudge が「発火しているのにユーザーに見えない」 + +[ADR-031](adr-031-weekly-review-pipeline.md) の weekly-review reminder は SessionStart hook +(`src/hooks-session-start/src/weekly_review.rs`) が `.claude/weekly-review-last-run.json` の +`last_run_at` を見て threshold (7 日) 超過で発火する。**reminder 自体は正しく発火していた**。 + +しかし hook の出力は `hookSpecificOutput.additionalContext` のみで、これは **Claude の +コンテキストに注入されるだけでユーザーの画面には表示されない**。Claude がセッション冒頭で +自発的に言及しない限りユーザーは気付けず、実際に約 4 週間気付かれなかった (2026-07-19 調査の +根本原因)。「発火 = 通知」ではなく「発火 = モデルへの示唆」であり、ユーザー可視の通知チャネルが +欠落していた。 + +同じ構造は weekly reminder に限らない: PR monitor catch-up / post-merge feedback recovery / +failed marker resume など「ユーザーの行動を要求する」nudge は、additionalContext 単独では +「モデルが忘れる or 言及しない」と silent に握りつぶされる。 + +### 裏取り済みの Claude Code hooks 仕様 (公式ドキュメント確認済、2026-07-19) + +- `systemMessage` は hook JSON 出力の **トップレベル共通フィールド** (string 型) で、 + **全 hook イベント (SessionStart 含む) で使用可能**。ユーザーに表示される。 +- `hookSpecificOutput.additionalContext` と同一 JSON で **併用可能**。 +- UI 上の表示スタイル (警告色か通常か等) はドキュメント未明記のため dogfood の目視で確認する。 + +## 決定 (試験運用) + +### hook 通知を 2 層の可視化チャネルに分離する + +| チャネル | 宛先 | 内容 | 型 | +|---|---|---|---| +| `hookSpecificOutput.additionalContext` | モデル (Claude) | 行動指示・詳細・recovery hint | 複数行可 | +| `systemMessage` (トップレベル) | ユーザー | 1 行サマリー | 1 行 | + +ユーザーの行動を要求する nudge は **両方に出す**。additionalContext = 「モデルが何をすべきか」、 +systemMessage = 「ユーザーが今この瞬間に見るべき 1 行」。表示ノイズを抑えるため systemMessage は +1 行 (`\n` を含まない) に限定し、詳細は additionalContext に寄せる。 + +### additionalContext 側にも「ユーザーに伝えよ」を明示する (defense-in-depth) + +systemMessage の UI 表示挙動はまだ実測前 (削除条件で確認する) のため、additionalContext 側の +nudge 文言に **「セッション最初の応答で、この reminder をユーザーに一言伝えること」** を明示する。 +systemMessage が (環境・バージョンで) 表示されない場合でも、モデル経由でユーザーに届く二重化。 + +### 適用範囲は weekly reminder のみ先行 → 段階展開 + +第 1 弾は weekly-review reminder に限定して dogfood する。observation の後、行動要求系 nudge へ +段階展開する: + +1. **第 1 弾 (本 ADR)**: weekly-review reminder (staleness + failed marker) +2. **第 2 弾候補**: PR monitor catch-up / post-merge feedback recovery / weekly failed marker resume + (いずれも「ユーザーの行動を要求する」nudge で、additionalContext 単独で見えなかった実例がある) +3. **対象外の見込み**: working copy staleness / workspace stale などの staleness 系は Claude が + セッション内で自律対処できる (ユーザー操作を要求しない) ため、systemMessage には出さない。 + +展開/却下の判定材料は [ADR-055](adr-055-firing-telemetry-collection.md) の発火テレメトリ +(PR-N3 で session-start nudge を統合) を観測基盤とする。 + +## ADR-039 3 点セットの適用 + +- **Config opt-in**: `WeeklyReviewReminderConfig` に `system_message_enabled: Option` を追加し、 + **source default OFF** (`unwrap_or(false)`)。本リポジトリの `.claude/hooks-config.toml` で + `system_message_enabled = true` に明示 enable して dogfood する。派生 repo は section を置かない + = OFF (additionalContext のみの従来挙動)。 +- **Kill-switch**: 2 段階で停止できる。 + - `system_message_enabled = false` → **systemMessage のみ停止** (additionalContext の nudge は継続)。 + - `enabled = false` (既存) → **weekly reminder nudge 自体を停止** (additionalContext も出さない)。 +- **Bounded lifetime**: dogfood 開始 (2026-07-19) から約 4 週間 = **判定期限 2026-08-16**。 + 観測項目は (a) systemMessage が新セッション起動時にユーザー画面へ実表示されるか + (計画書 削除条件 2 の目視確認)、(b) 通知過多にならないか。結果で「行動要求系 nudge へ展開」 + または「却下」を判定し、本 ADR のステータス行・`.claude/hooks-config.toml` コメント・ + `src/hooks-session-start/src/weekly_review.rs` module doc に反映する。 + +## 影響 + +### 期待効果 + +- weekly reminder が **ユーザーの画面に直接届く**。約 4 週間気付かれなかった silent 化を解消する。 +- additionalContext の defense-in-depth 明示指示で、systemMessage 非対応環境でもモデル経由で届く。 +- 2 層分離の builder (`build_session_start_json`) が確立し、第 2 弾以降の nudge が同じ経路で + systemMessage を出せる (展開コストが小さい)。 + +### リスク + +- **表示挙動が未実測**: systemMessage が実際に UI に表示されるか・どのスタイルかは dogfood の + 目視で確認する (削除条件 2)。表示されない場合は実装を revert せず、表示経路を再調査してから判断する + (defense-in-depth の additionalContext 明示指示が backstop として残る)。 +- **通知過多**: 段階展開で全 nudge を systemMessage 化すると毎セッション冒頭がうるさくなり得る。 + 第 1 弾を weekly のみに絞り、telemetry (PR-N3) の発火頻度を見てから展開範囲を決める。 + +### 検証 + +- `cargo test`: config parse (`system_message_enabled`)、systemMessage 生成の有効/無効/ + Missing/ElapsedDays/failed marker 各分岐、JSON builder の形状 (systemMessage 有り/無し) を固定。 +- `pnpm build:all` → 新セッション起動 → **UI に systemMessage の 1 行が表示されることを目視確認** + (計画書 削除条件 2)。 + +## 関連 + +- [ADR-031: 週次プロジェクト全体レビューパイプライン](adr-031-weekly-review-pipeline.md) + — 本 ADR の第 1 弾適用先 (weekly reminder) +- [ADR-045: jj workspace による並列セッション運用](adr-045-jj-workspace-parallel-sessions.md) + — reminder が silent だった第 2 の原因 (状態ファイルの workspace 分裂) は PR-N2 で対処する +- [ADR-055: 発火テレメトリ収集層](adr-055-firing-telemetry-collection.md) + — 段階展開/却下の判定材料。session-start nudge の telemetry 統合は PR-N3 +- [ADR-039: Experimental feature 標準パターン](adr-039-experimental-feature-standard-pattern.md) + — 本 ADR の 3 点セット diff --git a/docs/weekly-review-notification-plan.md b/docs/weekly-review-notification-plan.md index f86ea8b3..8cd7a864 100644 --- a/docs/weekly-review-notification-plan.md +++ b/docs/weekly-review-notification-plan.md @@ -103,6 +103,21 @@ - `pnpm build:all` → 新セッション起動 → **UI に systemMessage の 1 行が表示されることを目視確認** (削除条件 2)。 表示されない場合は ADR-059 の前提が崩れるため、実装を revert せず表示経路を再調査してから判断する。 +### 作業記録 (2026-07-19 実装完了) + +- **実装済み**。ADR 番号は起案時点の最新が ADR-058 だったため **ADR-059** で確定。 +- コミット粒度 (レビューしやすさ優先で 4 分割): + 1. `docs(adr)`: ADR-059 起案 + CLAUDE.md リンク追記 + 2. `refactor(session-start)`: JSON 組み立てを `build_session_start_json(context, system_message)` に切り出し + builder テスト (この時点では `None` 呼び出しで挙動不変) + 3. `feat(session-start)`: `system_message_enabled` 追加 + `compute_weekly_review_reminder_nudge` を `WeeklyReviewNudge { additional_context, system_message }` に struct 化 + systemMessage 生成 + additionalContext に「ユーザーに一言伝えよ」明示指示 + main.rs 配線 + テスト + 4. `chore(config)`: `.claude/hooks-config.toml` に `system_message_enabled = true` 追記 +- 検証結果: + - `cargo test -p hooks-session-start`: **92 passed** (config parse / systemMessage 生成の Missing・ElapsedDays・failed marker・有効/無効各分岐 / builder 形状 / 明示指示)。 + - `cargo clippy -p hooks-session-start --all-targets -- -D warnings`: クリーン。 + - `pnpm build:all`: 成功 (更新 exe を `.claude/` に配布)。 + - **デプロイ済み exe を実際に駆動して end-to-end 確認済み**: main workspace は last-run 未実行 (Missing) のため、`systemMessage = "週次レビュー: 実行記録なし (threshold 7 日)。/weekly-review の実行を検討してください"` と additionalContext 末尾の defense-in-depth 明示指示の両方が出力されることを確認。 +- **残タスク (削除条件 2)**: land 後に **新セッションを起動して UI 上に systemMessage の 1 行が実表示されるか目視確認**。表示スタイル (警告色か等) はドキュメント未明記のため dogfood で確認する。判定期限 2026-08-16 (ADR-059 bounded lifetime)。 + --- ## PR-N2: last-run 状態のメイン workspace canonical 化 diff --git a/src/hooks-session-start/src/hooks_config.rs b/src/hooks-session-start/src/hooks_config.rs index 00cc67f2..290267bb 100644 --- a/src/hooks-session-start/src/hooks_config.rs +++ b/src/hooks-session-start/src/hooks_config.rs @@ -41,6 +41,10 @@ pub(crate) struct WeeklyReviewReminderConfig { pub(crate) enabled: Option, pub(crate) reminder_threshold_days: Option, pub(crate) failed_marker_check_enabled: 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)] @@ -127,6 +131,7 @@ default_branch = "main" enabled = true reminder_threshold_days = 14 failed_marker_check_enabled = false +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(); @@ -140,6 +145,33 @@ failed_marker_check_enabled = false assert_eq!(weekly.enabled, Some(true)); assert_eq!(weekly.reminder_threshold_days, Some(14)); assert_eq!(weekly.failed_marker_check_enabled, Some(false)); + assert_eq!(weekly.system_message_enabled, Some(true)); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn weekly_review_system_message_enabled_defaults_to_none_when_omitted() { + use std::io::Write; + let root = unique_temp_root("weekly-no-sysmsg"); + let claude_dir = root.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + let toml_str = r#" +[session_start.weekly_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 weekly = config + .session_start + .as_ref() + .and_then(|s| s.weekly_review_reminder.as_ref()) + .expect("weekly_review_reminder section should parse"); + assert_eq!( + weekly.system_message_enabled, None, + "system_message_enabled 未設定は None (source default OFF、ADR-059)" + ); let _ = std::fs::remove_dir_all(&root); } } diff --git a/src/hooks-session-start/src/main.rs b/src/hooks-session-start/src/main.rs index a7675921..725bcc4c 100644 --- a/src/hooks-session-start/src/main.rs +++ b/src/hooks-session-start/src/main.rs @@ -107,6 +107,7 @@ fn main() { /// serde_json で組み立てることで session_id 内の特殊文字を安全にエスケープする。 fn emit_session_start_output(session_id: &str) { let mut context = format!("CLAUDE_CODE_SESSION_ID={}", session_id); + let mut system_message: Option = None; let now_unix = current_unix_secs(); if let Some(state) = read_parked_state(&pr_monitor_state_path()) { if let Some(nudge) = compute_catchup_nudge(&state, now_unix) { @@ -143,17 +144,33 @@ fn emit_session_start_output(session_id: &str) { compute_weekly_review_reminder_nudge(&cwd, weekly_config, now_unix) { context.push_str("\n\n"); - context.push_str(&weekly_nudge); + context.push_str(&weekly_nudge.additional_context); + if weekly_nudge.system_message.is_some() { + system_message = weekly_nudge.system_message; + } } } } - let output = serde_json::json!({ + let output = build_session_start_json(&context, system_message.as_deref()); + println!("{}", output); +} + +/// SessionStart hook の stdout JSON を組み立てる純粋関数 (ADR-059)。 +/// +/// `context` は常に `hookSpecificOutput.additionalContext` (モデル可視) に載せる。 +/// `system_message` が `Some` のときのみトップレベル `systemMessage` (ユーザー可視) を付与し、 +/// `None` のときは従来どおり `systemMessage` を省いた JSON を返す。 +fn build_session_start_json(context: &str, system_message: Option<&str>) -> serde_json::Value { + let mut output = serde_json::json!({ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": context, } }); - println!("{}", output); + if let Some(message) = system_message { + output["systemMessage"] = serde_json::Value::String(message.to_string()); + } + output } /// シェル用シングルクォート (内部の ' を '\'' にエスケープ) @@ -262,6 +279,31 @@ mod tests { assert!(extract_non_empty_session_id(input).is_none()); } + #[test] + fn build_session_start_json_omits_system_message_when_none() { + let output = build_session_start_json("ctx-only", None); + assert_eq!( + output["hookSpecificOutput"]["hookEventName"], + "SessionStart" + ); + assert_eq!(output["hookSpecificOutput"]["additionalContext"], "ctx-only"); + assert!( + output.get("systemMessage").is_none(), + "system_message = None のときトップレベル systemMessage は付与しない" + ); + } + + #[test] + fn build_session_start_json_includes_system_message_when_some() { + let output = build_session_start_json("ctx", Some("週次レビュー: 実行記録なし")); + assert_eq!(output["systemMessage"], "週次レビュー: 実行記録なし"); + assert_eq!(output["hookSpecificOutput"]["additionalContext"], "ctx"); + assert_eq!( + output["hookSpecificOutput"]["hookEventName"], + "SessionStart" + ); + } + #[test] fn shell_quote_simple() { assert_eq!(shell_quote("abc-123"), "'abc-123'"); diff --git a/src/hooks-session-start/src/weekly_review.rs b/src/hooks-session-start/src/weekly_review.rs index 3ff0ba11..b9a90756 100644 --- a/src/hooks-session-start/src/weekly_review.rs +++ b/src/hooks-session-start/src/weekly_review.rs @@ -25,6 +25,11 @@ const WEEKLY_REVIEW_DEFAULT_THRESHOLD_DAYS: u64 = 7; pub(crate) const WEEKLY_REVIEW_LAST_RUN_PATH: &str = ".claude/weekly-review-last-run.json"; const WEEKLY_REVIEW_REVIEWS_DIR: &str = ".claude/weekly-reviews"; +/// additionalContext に付す明示指示 (ADR-059 defense-in-depth)。systemMessage が UI に +/// 表示されない環境でも、モデル経由でユーザーへ届くよう「冒頭で一言伝えよ」を義務づける。 +const WEEKLY_REVIEW_TELL_USER_INSTRUCTION: &str = + "セッション最初の応答で、この weekly-review reminder をユーザーに一言伝えること (systemMessage 非表示環境向けの defense-in-depth、ADR-059)。"; + /// `.claude/weekly-review-last-run.json` の last-run 状態。 /// /// `Missing` (= 未実行 / 初回) / `Stale` (= last_run_at 欠落・不正) / `Unreadable` (= 読込失敗) を @@ -159,15 +164,61 @@ fn build_weekly_review_failed_marker_lines(markers: &[String]) -> Vec { lines } +/// weekly review reminder の nudge 出力 (ADR-059 の 2 層可視化チャネル)。 +pub(crate) struct WeeklyReviewNudge { + /// モデル可視。`hookSpecificOutput.additionalContext` に載る詳細 + 行動指示。 + pub(crate) additional_context: String, + /// ユーザー可視の 1 行サマリー。`systemMessage` に載る。`system_message_enabled` が + /// 真かつ nudge 発火時のみ `Some`。 + pub(crate) system_message: Option, +} + +/// ADR-059: weekly nudge のユーザー可視 1 行サマリー (systemMessage) を組み立てる。 +/// +/// staleness も failed marker も無ければ `None` (additionalContext の発火条件と一致)。 +/// 表示ノイズを抑えるため 1 行 (`\n` を含まない) に限定し、詳細は additionalContext に寄せる。 +fn build_weekly_review_system_message( + state: &WeeklyLastRunState, + threshold_days: u64, + failed_marker_count: usize, +) -> Option { + let staleness = weekly_review_staleness_hits(state, threshold_days); + if !staleness && failed_marker_count == 0 { + return None; + } + let mut parts: Vec = Vec::new(); + if staleness { + let elapsed = match state { + WeeklyLastRunState::ElapsedDays(d) => format!("前回実行から {} 日経過", d), + WeeklyLastRunState::Missing => "実行記録なし".to_string(), + _ => "前回実行の記録が不正/欠落".to_string(), + }; + parts.push(format!("{} (threshold {} 日)", elapsed, threshold_days)); + } + if failed_marker_count > 0 { + parts.push(format!( + "前回実行が失敗 (.failed marker {} 件)", + failed_marker_count + )); + } + Some(format!( + "週次レビュー: {}。`/weekly-review` の実行を検討してください", + parts.join("、") + )) +} + /// ADR-031 Phase C: weekly review reminder の nudge を組み立てる。 /// /// 2 経路 (staleness + failed marker) は独立して評価し、両方該当する場合は 1 nudge にまとめる。 /// 該当なし (= last-run が threshold 内 + failed marker なし) は None を返す。 +/// +/// ADR-059: 戻り値は `additional_context` (モデル可視、末尾に「ユーザーに伝えよ」明示指示を付す) と +/// `system_message` (ユーザー可視 1 行、`system_message_enabled` が真のときのみ `Some`) の 2 層。 pub(crate) fn compute_weekly_review_reminder_nudge( repo_root: &Path, config: &WeeklyReviewReminderConfig, now_unix: i64, -) -> Option { +) -> Option { if !config.enabled.unwrap_or(false) { return None; } @@ -194,7 +245,20 @@ pub(crate) fn compute_weekly_review_reminder_nudge( } lines.extend(build_weekly_review_failed_marker_lines(&failed_markers)); } - Some(lines.join("\n")) + lines.push(String::new()); + lines.push(WEEKLY_REVIEW_TELL_USER_INSTRUCTION.to_string()); + let additional_context = lines.join("\n"); + + let system_message = if config.system_message_enabled.unwrap_or(false) { + build_weekly_review_system_message(&last_run_state, threshold_days, failed_markers.len()) + } else { + None + }; + + Some(WeeklyReviewNudge { + additional_context, + system_message, + }) } #[cfg(test)] @@ -223,6 +287,7 @@ mod tests { enabled: Some(false), reminder_threshold_days: Some(7), failed_marker_check_enabled: Some(true), + system_message_enabled: Some(false), }; assert!(compute_weekly_review_reminder_nudge(&root, &config, 2_000_000_000).is_none()); let _ = std::fs::remove_dir_all(&root); @@ -261,12 +326,13 @@ mod tests { enabled: Some(true), reminder_threshold_days: Some(7), failed_marker_check_enabled: Some(false), + system_message_enabled: Some(false), }; let nudge = compute_weekly_review_reminder_nudge(&root, &config, 2_000_000_000) .expect("staleness nudge must be emitted when last-run file missing"); - assert!(nudge.contains("[WEEKLY_REVIEW_REMINDER]")); - assert!(nudge.contains("threshold (7 日)")); - assert!(nudge.contains("未実行")); + assert!(nudge.additional_context.contains("[WEEKLY_REVIEW_REMINDER]")); + assert!(nudge.additional_context.contains("threshold (7 日)")); + assert!(nudge.additional_context.contains("未実行")); let _ = std::fs::remove_dir_all(&root); } @@ -288,12 +354,13 @@ mod tests { enabled: Some(true), reminder_threshold_days: Some(7), failed_marker_check_enabled: Some(true), + system_message_enabled: Some(false), }; let nudge = compute_weekly_review_reminder_nudge(&root, &config, now) .expect("failed marker nudge must be emitted"); - assert!(nudge.contains("[WEEKLY_REVIEW_REMINDER]")); - assert!(nudge.contains(".failed` marker が 1 件残存")); - assert!(nudge.contains("2026-05-15.md.failed")); + assert!(nudge.additional_context.contains("[WEEKLY_REVIEW_REMINDER]")); + assert!(nudge.additional_context.contains(".failed` marker が 1 件残存")); + assert!(nudge.additional_context.contains("2026-05-15.md.failed")); let _ = std::fs::remove_dir_all(&root); } @@ -314,11 +381,12 @@ mod tests { enabled: Some(true), reminder_threshold_days: Some(7), failed_marker_check_enabled: Some(false), + system_message_enabled: Some(false), }; let nudge = compute_weekly_review_reminder_nudge(&root, &config, now) .expect("40 日前の last_run_at は fresh な mtime に関わらず staleness を発火させる"); - assert!(nudge.contains("[WEEKLY_REVIEW_REMINDER]")); - assert!(nudge.contains("40 日経過")); + assert!(nudge.additional_context.contains("[WEEKLY_REVIEW_REMINDER]")); + assert!(nudge.additional_context.contains("40 日経過")); let _ = std::fs::remove_dir_all(&root); } @@ -339,6 +407,7 @@ mod tests { enabled: Some(true), reminder_threshold_days: Some(7), failed_marker_check_enabled: Some(false), + system_message_enabled: Some(false), }; assert!( compute_weekly_review_reminder_nudge(&root, &config, now).is_none(), @@ -391,12 +460,13 @@ mod tests { enabled: Some(true), reminder_threshold_days: Some(7), failed_marker_check_enabled: Some(false), + system_message_enabled: Some(false), }; let nudge = compute_weekly_review_reminder_nudge(&root, &config, 2_000_000_000).expect( "last_run_at 欠落は mtime にフォールバックせず stale 扱いで発火する (CR #233 Major)", ); - assert!(nudge.contains("[WEEKLY_REVIEW_REMINDER]")); - assert!(nudge.contains("stale 扱い")); + assert!(nudge.additional_context.contains("[WEEKLY_REVIEW_REMINDER]")); + assert!(nudge.additional_context.contains("stale 扱い")); let _ = std::fs::remove_dir_all(&root); } @@ -436,4 +506,107 @@ mod tests { 7 )); } + + #[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 = WeeklyReviewReminderConfig { + enabled: Some(true), + reminder_threshold_days: Some(7), + failed_marker_check_enabled: Some(false), + system_message_enabled: Some(true), + }; + let nudge = compute_weekly_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.contains("週次レビュー")); + assert!(msg.contains("実行記録なし")); + assert!(!msg.contains('\n'), "systemMessage は 1 行に限定する"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn system_message_reports_elapsed_days_when_enabled() { + let root = unique_temp_root("sysmsg-elapsed"); + let last_run_path = root.join(WEEKLY_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 + 18 * 86_400; + std::fs::write( + &last_run_path, + format!("{{\"last_run_at\": \"{}\"}}", last_run_str), + ) + .unwrap(); + let config = WeeklyReviewReminderConfig { + enabled: Some(true), + reminder_threshold_days: Some(7), + failed_marker_check_enabled: Some(false), + system_message_enabled: Some(true), + }; + let nudge = compute_weekly_review_reminder_nudge(&root, &config, now) + .expect("18 日経過で nudge が発火する"); + let msg = nudge.system_message.expect("systemMessage が付く"); + assert!(msg.contains("18 日経過")); + 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 = WeeklyReviewReminderConfig { + enabled: Some(true), + reminder_threshold_days: Some(7), + failed_marker_check_enabled: Some(false), + system_message_enabled: Some(false), + }; + let nudge = compute_weekly_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("[WEEKLY_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 = WeeklyReviewReminderConfig { + enabled: Some(true), + reminder_threshold_days: Some(7), + failed_marker_check_enabled: Some(false), + system_message_enabled: Some(false), + }; + let nudge = compute_weekly_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_weekly_review_system_message_none_when_fresh_and_no_marker() { + assert!( + build_weekly_review_system_message(&WeeklyLastRunState::ElapsedDays(3), 7, 0).is_none() + ); + } + + #[test] + fn build_weekly_review_system_message_combines_staleness_and_marker() { + let msg = build_weekly_review_system_message(&WeeklyLastRunState::Missing, 7, 2) + .expect("staleness or marker があれば Some"); + assert!(msg.contains("実行記録なし")); + assert!(msg.contains("失敗")); + assert!(msg.contains("2 件")); + } }