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
6 changes: 6 additions & 0 deletions .claude/hooks-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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: コマンド検証 ───

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 / チェックリスト

Expand Down
119 changes: 119 additions & 0 deletions docs/adr/adr-059-hook-system-message-visibility.md
Original file line number Diff line number Diff line change
@@ -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<bool>` を追加し、
**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 点セット
15 changes: 15 additions & 0 deletions docs/weekly-review-notification-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 化
Expand Down
32 changes: 32 additions & 0 deletions src/hooks-session-start/src/hooks_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub(crate) struct WeeklyReviewReminderConfig {
pub(crate) enabled: Option<bool>,
pub(crate) reminder_threshold_days: Option<u64>,
pub(crate) failed_marker_check_enabled: Option<bool>,
/// 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)]
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
}
48 changes: 45 additions & 3 deletions src/hooks-session-start/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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) {
Expand Down Expand Up @@ -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
}

/// シェル用シングルクォート (内部の ' を '\'' にエスケープ)
Expand Down Expand Up @@ -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'");
Expand Down
Loading