Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/hooks-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
45 changes: 39 additions & 6 deletions docs/monthly-harness-roi-review-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
90 changes: 87 additions & 3 deletions src/hooks-session-start/src/hooks_config.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -48,10 +48,37 @@ pub(crate) struct WeeklyReviewReminderConfig {
pub(crate) system_message_enabled: Option<bool>,
}

/// 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<bool>,
/// staleness threshold (日)。source default は
/// [`MONTHLY_REVIEW_DEFAULT_THRESHOLD_DAYS`](crate::monthly_review) = 28 日
/// (ADR-053/061 の撤去粒度「4 週間」と整合)。
pub(crate) threshold_days: Option<u64>,
/// 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<bool>,
}

#[derive(Deserialize, Default)]
pub(crate) struct SessionStartConfig {
pub(crate) staleness: Option<StalenessConfig>,
pub(crate) weekly_review_reminder: Option<WeeklyReviewReminderConfig>,
pub(crate) monthly_review_reminder: Option<MonthlyReviewReminderConfig>,
}

#[derive(Deserialize, Default)]
Expand Down Expand Up @@ -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;
Expand Down
99 changes: 90 additions & 9 deletions src/hooks-session-start/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand All @@ -23,13 +24,15 @@ use std::path::{Path, PathBuf};

mod hooks_config;
mod jj_helpers;
mod monthly_review;
mod past_time;
mod pr_monitor;
mod reaper;
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};
Expand Down Expand Up @@ -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,
Expand All @@ -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<SingleLineMessage> {
let mut system_messages: Vec<SingleLineMessage> = 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<SingleLineMessage>) -> Option<SingleLineMessage> {
match messages.len() {
0 => None,
1 => messages.into_iter().next(),
_ => Some(SingleLineMessage::new(
messages
.iter()
.map(SingleLineMessage::as_str)
.collect::<Vec<_>>()
.join(" / "),
)),
}
}

/// nudge の発火を telemetry (ADR-055) に記録する (fail-open)。
Expand Down Expand Up @@ -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'");
Expand Down
Loading
Loading