From 4cb0478ca1de80197d9d6f508a0a0ef6b9df91b8 Mon Sep 17 00:00:00 2001 From: aloekun Date: Sun, 19 Jul 2026 15:40:10 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(lib-jj-helpers):=20resolve=5Fmain=5Fwo?= =?UTF-8?q?rkspace=5Froot=20=E3=82=92=E8=BF=BD=E5=8A=A0=20(ADR-045=20?= =?UTF-8?q?=E7=8A=B6=E6=85=8B=E5=88=86=E8=A3=82=E5=AF=BE=E7=AD=96=20/=20PR?= =?UTF-8?q?-N2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit secondary jj workspace から canonical な (メイン) workspace root を解決する fs ヘルパーを追加。 gitignore 済み untracked 状態ファイル (weekly-review-last-run.json 等) は per-checkout で materialize され secondary workspace には存在しない問題への基盤 (ADR-045)。 - .jj/repo がディレクトリ → colocated main → 自身を返す - .jj/repo がファイル → 内容の main store パス (相対は .jj/ 基準) の 2 階層上がメイン root - .jj 不在 / 読取失敗 / 導出パス不存在 → None (caller は現 root に fail-open) resolve_git_dir と同じ layout 解釈 (相対基準・verbatim prefix 剥がし) を共有。テストは fixture + 実 jj E2E (ignored) を resolve_git_dir パターンで流用。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib-jj-helpers/src/lib.rs | 153 ++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/src/lib-jj-helpers/src/lib.rs b/src/lib-jj-helpers/src/lib.rs index e7cdebf3..09b1a34a 100644 --- a/src/lib-jj-helpers/src/lib.rs +++ b/src/lib-jj-helpers/src/lib.rs @@ -23,6 +23,8 @@ //! - [`get_jj_bookmarks`]: 上記を組み合わせた high-level エントリポイント //! - [`resolve_git_dir`] / [`inject_git_dir_for_gh`]: 非 colocated jj workspace //! での gh 用 `GIT_DIR` 導出と自動注入 (ADR-045 恒久対策候補 1) +//! - [`resolve_main_workspace_root`]: secondary jj workspace から canonical な (メイン) +//! workspace root を解決 (gitignore 済み untracked 状態ファイルの workspace 分裂対策、ADR-045) //! - [`pipeline_lock`]: 実行中 pipeline と Stop hook 品質ゲートの相互排他 (順位 280) pub mod pipeline_lock; @@ -234,6 +236,40 @@ pub fn resolve_git_dir(workspace_root: &std::path::Path) -> GitDirResolution { } } +/// secondary jj workspace から canonical な (メイン) workspace root を解決する (ADR-045 状態分裂対策)。 +/// +/// `.claude/weekly-review-last-run.json` のような gitignore 済み untracked 状態ファイルは +/// workspace ごとに独立し (per-checkout materialize)、secondary workspace には存在しない。状態を +/// 1 か所 (メイン workspace) に集約するため、`.jj` の on-disk layout からメイン root を導出する。 +/// [`resolve_git_dir`] と同じ layout 解釈 (相対パス基準、verbatim prefix 剥がし) を共有する: +/// +/// 1. `/.jj/repo` がディレクトリ → この root 自身がメイン (colocated) workspace → +/// `Some(root)` をそのまま返す +/// 2. `/.jj/repo` がファイル → 内容が main repo store への (相対なら `/.jj/` 基準の) +/// パス (`
/.jj/repo`)。その 2 階層上がメイン workspace root +/// 3. `.jj/repo` 不在 / 読み取り失敗 / 導出パス不存在 → `None` (caller は現 root に fail-open) +/// +/// `GIT_DIR` を扱う [`resolve_git_dir`] と違い最終 store ではなく **workspace root** を返す点、 +/// および colocated root を `Resolved` ではなく入力そのまま返す点で用途が異なる。 +pub fn resolve_main_workspace_root( + workspace_root: &std::path::Path, +) -> Option { + let repo_entry = workspace_root.join(".jj").join("repo"); + if repo_entry.is_dir() { + return Some(workspace_root.to_path_buf()); + } + if !repo_entry.is_file() { + return None; + } + let content = std::fs::read_to_string(&repo_entry).ok()?; + let store = resolve_relative_to(content.trim(), &workspace_root.join(".jj")); + let main_root = store.parent()?.parent()?; + match main_root.canonicalize() { + Ok(p) => Some(strip_windows_verbatim_prefix(&p)), + Err(_) => None, + } +} + /// パス文字列を解決する: 絶対ならそのまま、相対なら `base` 基準で連結。 fn resolve_relative_to(path_str: &str, base: &std::path::Path) -> std::path::PathBuf { let p = std::path::PathBuf::from(path_str); @@ -615,4 +651,121 @@ mod tests { } } } + + /// [`resolve_main_workspace_root`] の layout 解釈テスト。fixture は `git_dir` と同型。 + mod main_workspace_root { + use super::super::*; + use std::fs; + + fn make_colocated_main(root: &std::path::Path) { + fs::create_dir_all(root.join(".git")).unwrap(); + fs::create_dir_all(root.join(".jj/repo/store")).unwrap(); + } + + fn make_secondary_workspace(ws: &std::path::Path, main_store: &str) { + fs::create_dir_all(ws.join(".jj")).unwrap(); + fs::write(ws.join(".jj/repo"), main_store).unwrap(); + } + + #[test] + fn colocated_main_returns_itself() { + let tmp = tempfile::tempdir().unwrap(); + make_colocated_main(tmp.path()); + let resolved = resolve_main_workspace_root(tmp.path()) + .expect("colocated main (.jj/repo がディレクトリ) は自身を返す"); + assert_eq!(resolved.as_path(), tmp.path()); + } + + #[test] + fn secondary_workspace_resolves_to_main_root() { + let tmp = tempfile::tempdir().unwrap(); + let main = tmp.path().join("main"); + let ws = tmp.path().join("ws"); + make_colocated_main(&main); + make_secondary_workspace(&ws, "../../main/.jj/repo"); + + let resolved = resolve_main_workspace_root(&ws) + .expect("secondary の .jj/repo ファイルからメイン root を導出する"); + assert_eq!( + resolved.canonicalize().unwrap(), + main.canonicalize().unwrap(), + "メイン workspace root (store の 2 階層上) を返すこと" + ); + assert!( + !resolved.to_string_lossy().starts_with(r"\\?\"), + "verbatim prefix は剥がされていること: {:?}", + resolved + ); + } + + #[test] + fn secondary_workspace_with_absolute_store_path_resolves() { + let tmp = tempfile::tempdir().unwrap(); + let main = tmp.path().join("main"); + let ws = tmp.path().join("ws"); + make_colocated_main(&main); + let abs = main.join(".jj").join("repo"); + make_secondary_workspace(&ws, &abs.to_string_lossy()); + + let resolved = resolve_main_workspace_root(&ws) + .expect("絶対パス store でもメイン root を導出する"); + assert_eq!( + resolved.canonicalize().unwrap(), + main.canonicalize().unwrap() + ); + } + + #[test] + fn non_jj_directory_is_none() { + let tmp = tempfile::tempdir().unwrap(); + assert!( + resolve_main_workspace_root(tmp.path()).is_none(), + ".jj 不在は None (caller は現 root に fail-open)" + ); + } + + /// 実 jj で colocated main + secondary workspace を組み、実レイアウトとの齟齬を検出する。 + #[test] + #[ignore = "integration: requires jj in PATH; run via `cargo test -- --ignored --test-threads=1`"] + fn real_jj_secondary_workspace_resolves_to_main_root() { + use std::process::Command as StdCommand; + + let tmp = tempfile::tempdir().unwrap(); + let main = tmp.path().join("main"); + fs::create_dir_all(&main).unwrap(); + + let init_ok = StdCommand::new("jj") + .args(["git", "init", "--colocate"]) + .current_dir(&main) + .status() + .expect("jj git init 実行失敗") + .success(); + assert!(init_ok, "jj git init --colocate が失敗"); + + let ws = tmp.path().join("ws"); + let add_ok = StdCommand::new("jj") + .args(["workspace", "add", ws.to_string_lossy().as_ref()]) + .current_dir(&main) + .status() + .expect("jj workspace add 実行失敗") + .success(); + assert!(add_ok, "jj workspace add が失敗"); + + let resolved = resolve_main_workspace_root(&ws) + .expect("実 jj secondary workspace からメイン root を導出する"); + assert_eq!( + resolved.canonicalize().unwrap(), + main.canonicalize().unwrap(), + "secondary はメイン workspace root を返す" + ); + + let main_resolved = + resolve_main_workspace_root(&main).expect("colocated main は自身を返す"); + assert_eq!( + main_resolved.canonicalize().unwrap(), + main.canonicalize().unwrap(), + "colocated main は自身の root を返す" + ); + } + } } From 0b000d8bf3a2674c49cd404ef7e51852752e5414 Mon Sep 17 00:00:00 2001 From: aloekun Date: Sun, 19 Jul 2026 15:42:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(session-start):=20weekly=20last-run=20?= =?UTF-8?q?=E8=AA=AD=E8=BE=BC=E3=82=92=E3=83=A1=E3=82=A4=E3=83=B3=20worksp?= =?UTF-8?q?ace=20root=20=E3=81=AB=20canonical=20=E5=8C=96=20(ADR-045=20/?= =?UTF-8?q?=20PR-N2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitignore 済み untracked の weekly-review-last-run.json は workspace ローカルで secondary workspace に存在せず、メイン側では常に「未実行」判定で reminder が永久発火していた (2026-07-19 実観測、ADR-045 状態分裂)。 - hooks-session-start に lib-jj-helpers 依存を追加 - compute_weekly_review_reminder_nudge の last-run 読込を resolve_main_workspace_root(cwd).unwrap_or(cwd) 基準に変更 (導出不能は現 root に fail-open) - failed marker / pending JSON はレビュー成果物として workspace ローカルのまま維持 (線引きを doc 明記) - doc comment の「last_run_at は workspace 不変」誤記を訂正 (値は不変だがファイル所在は workspace 依存) secondary レイアウト (.jj/repo ファイル) でメイン root の last-run を読みつつ failed marker は 現 workspace から読むことを検証する unit test を追加。 Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + src/hooks-session-start/Cargo.toml | 1 + src/hooks-session-start/src/hooks_config.rs | 3 +- src/hooks-session-start/src/weekly_review.rs | 80 ++++++++++++++++++-- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 523693cf..3e0d5312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -429,6 +429,7 @@ dependencies = [ name = "hooks-session-start" version = "0.1.0" dependencies = [ + "lib-jj-helpers", "lib-subprocess", "proptest", "serde", diff --git a/src/hooks-session-start/Cargo.toml b/src/hooks-session-start/Cargo.toml index 51eba70b..2a2d8043 100644 --- a/src/hooks-session-start/Cargo.toml +++ b/src/hooks-session-start/Cargo.toml @@ -8,6 +8,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" lib-subprocess = { path = "../lib-subprocess" } +lib-jj-helpers = { path = "../lib-jj-helpers" } [dev-dependencies] proptest = "1" diff --git a/src/hooks-session-start/src/hooks_config.rs b/src/hooks-session-start/src/hooks_config.rs index 290267bb..e2ce34c5 100644 --- a/src/hooks-session-start/src/hooks_config.rs +++ b/src/hooks-session-start/src/hooks_config.rs @@ -30,7 +30,8 @@ pub(crate) struct StalenessConfig { /// `enabled = false` では完全 skip (default-OFF in source、repo config で明示 enable する)。 /// /// 2 種類の reminder を発火: -/// - last-run staleness: `.claude/weekly-review-last-run.json` の mtime が +/// - last-run staleness: メイン workspace の `.claude/weekly-review-last-run.json` の +/// `last_run_at` (内容 timestamp。mtime ではない — CR #233 / ADR-045 PR-N2 で canonical 化) が /// `reminder_threshold_days` を超えていれば「`/weekly-review` の実行を検討」を nudge /// - failed marker: `.claude/weekly-reviews/*.md.failed` が 1 件以上存在すれば /// 「前回 weekly-review が失敗、`/weekly-review` で resume」を nudge diff --git a/src/hooks-session-start/src/weekly_review.rs b/src/hooks-session-start/src/weekly_review.rs index b9a90756..5325ffc8 100644 --- a/src/hooks-session-start/src/weekly_review.rs +++ b/src/hooks-session-start/src/weekly_review.rs @@ -11,7 +11,15 @@ //! jj checkout / workspace materialization (ADR-045) のたびに再マテリアライズされ mtime が //! リセットされるため。mtime に依存すると「実際は 1 か月前の実行なのに fresh」に見え、reminder が //! 永久に発火しない silent-fresh バグ (past_time / reaper と同クラス) を踏む。`last_run_at` は -//! skill が書き込む workspace 不変の値で、欠落データは次回実行で backfill される (self-healing)。 +//! skill が書き込む内容 timestamp で、mtime と違い jj checkout では書き換わらない。欠落データは +//! 次回実行で backfill される (self-healing)。 +//! +//! ただし状態ファイル自体は gitignore 済み untracked で **workspace ローカル** なため +//! secondary workspace には存在しない (PR-N2 以前は「`last_run_at` は workspace 不変」と誤記して +//! いたが、値は不変でもファイル所在が workspace 依存だった、ADR-045 状態分裂)。last-run 読込は +//! [`lib_jj_helpers::resolve_main_workspace_root`] でメイン workspace root に canonical 化する。 +//! 一方 failed marker / pending JSON はレビュー成果物であり実行した workspace に属するため +//! workspace ローカルのまま扱う。 use serde::Deserialize; use std::path::Path; @@ -45,8 +53,9 @@ pub(crate) enum WeeklyLastRunState { /// `.claude/weekly-review-last-run.json` の必要フィールドのみ。 /// /// `last_run_at` は skill Phase 4 が実行完了時刻を RFC 3339 (UTC) で書き込む authoritative -/// timestamp。jj checkout / workspace materialization で書き換わる mtime と違い workspace 不変 -/// なので、staleness 判定の第一情報源とする。 +/// timestamp。jj checkout / workspace materialization で書き換わる mtime と違い内容 timestamp は +/// checkout で変わらないため staleness 判定の第一情報源とする (ファイル自体は workspace ローカルで、 +/// 読込元は [`compute_weekly_review_reminder_nudge`] がメイン workspace root に canonical 化する)。 #[derive(Deserialize)] struct WeeklyLastRunFile { last_run_at: Option, @@ -61,8 +70,8 @@ struct WeeklyLastRunFile { /// 4. `last_run_at` 欠落 / parse 不能 / 未来値 → `Stale` (発火)。mtime にはフォールバックしない /// (mtime は jj workspace で reset され silent-fresh を再導入するため)。欠落データは次回 /// skill 実行で `last_run_at` が書かれて backfill される (self-healing)。 -fn weekly_review_last_run_state(repo_root: &Path, now_unix: i64) -> WeeklyLastRunState { - let path = repo_root.join(WEEKLY_REVIEW_LAST_RUN_PATH); +fn weekly_review_last_run_state(main_root: &Path, now_unix: i64) -> WeeklyLastRunState { + let path = main_root.join(WEEKLY_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 WeeklyLastRunState::Missing, @@ -212,6 +221,13 @@ fn build_weekly_review_system_message( /// 2 経路 (staleness + failed marker) は独立して評価し、両方該当する場合は 1 nudge にまとめる。 /// 該当なし (= last-run が threshold 内 + failed marker なし) は None を返す。 /// +/// ADR-045 (PR-N2): last-run 状態は gitignore 済み untracked で workspace ローカルのため、 +/// `repo_root` (現 workspace) ではなく [`lib_jj_helpers::resolve_main_workspace_root`] で導出した +/// メイン workspace root から読む (secondary workspace でもメイン側の実行記録を共有し、 +/// 「未実行」誤判定で永久発火するのを防ぐ)。導出不能時は現 root に fail-open する。一方 +/// failed marker (`.claude/weekly-reviews/*.md.failed`) はレビュー成果物であり実行した workspace に +/// 属するため `repo_root` のまま読む。 +/// /// ADR-059: 戻り値は `additional_context` (モデル可視、末尾に「ユーザーに伝えよ」明示指示を付す) と /// `system_message` (ユーザー可視 1 行、`system_message_enabled` が真のときのみ `Some`) の 2 層。 pub(crate) fn compute_weekly_review_reminder_nudge( @@ -226,7 +242,9 @@ pub(crate) fn compute_weekly_review_reminder_nudge( .reminder_threshold_days .unwrap_or(WEEKLY_REVIEW_DEFAULT_THRESHOLD_DAYS); let failed_check_enabled = config.failed_marker_check_enabled.unwrap_or(true); - let last_run_state = weekly_review_last_run_state(repo_root, now_unix); + let main_root = lib_jj_helpers::resolve_main_workspace_root(repo_root) + .unwrap_or_else(|| repo_root.to_path_buf()); + let last_run_state = weekly_review_last_run_state(&main_root, now_unix); let staleness_lines = build_weekly_review_staleness_lines(&last_run_state, threshold_days); let failed_markers = if failed_check_enabled { weekly_review_failed_markers(repo_root) @@ -364,6 +382,56 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn last_run_read_from_main_root_while_markers_stay_workspace_local() { + 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 + 18 * 86_400; + std::fs::create_dir_all(main.join(".claude")).unwrap(); + std::fs::write( + main.join(WEEKLY_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(); + std::fs::create_dir_all(ws.join(".claude/weekly-reviews")).unwrap(); + std::fs::write( + ws.join(".claude/weekly-reviews/2026-05-15.md.failed"), + "fail", + ) + .unwrap(); + let config = WeeklyReviewReminderConfig { + enabled: Some(true), + reminder_threshold_days: Some(7), + failed_marker_check_enabled: Some(true), + system_message_enabled: Some(true), + }; + let nudge = compute_weekly_review_reminder_nudge(&ws, &config, now) + .expect("secondary workspace でもメイン root の last-run で発火する"); + assert!( + nudge.additional_context.contains("18 日経過"), + "last-run はメイン workspace root から読む (secondary の未実行に fallback しない): {}", + nudge.additional_context + ); + assert!( + nudge.additional_context.contains("2026-05-15.md.failed"), + "failed marker は現 workspace ローカルから読む" + ); + let msg = nudge + .system_message + .expect("system_message_enabled = true なので systemMessage が付く"); + assert!( + msg.contains("18 日経過"), + "systemMessage も main-root 由来の経過日数: {}", + msg + ); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn compute_weekly_review_reminder_nudge_uses_last_run_at_over_fresh_mtime() { let root = unique_temp_root("last-run-at-stale"); From da1d2012f15b4d7c2392eaa228f360a296bc8727 Mon Sep 17 00:00:00 2001 From: aloekun Date: Sun, 19 Jul 2026 15:44:48 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs(adr):=20=E7=8A=B6=E6=85=8B=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=20workspace=20=E5=88=86?= =?UTF-8?q?=E8=A3=82=E3=82=92=20ADR-031/045=20=E3=81=AB=E8=BF=BD=E8=A8=98?= =?UTF-8?q?=20(PR-N2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit weekly-review last-run が secondary workspace 側にのみ存在しメイン側で永久「未実行」発火した silent bug (2026-07-19 実観測) を ADR に定着。 - ADR-031 § トリガー方式と reminder: last-run のメイン workspace canonical 化の決定を追記。 「last_run_at は workspace 不変」誤記の是正 (値は checkout 不変だがファイル所在は workspace 依存)、 resolve_main_workspace_root による読込 canonical 化、failed marker/pending JSON は per-workspace 維持。 - ADR-045: gitignore 済み untracked 状態ファイルの workspace 分裂を silent bug class として新設。 mtime リセット (CR #233) と対になる実例として対比表を追加し、per-workspace/global の判定基準を明文化。 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/adr/adr-031-weekly-review-pipeline.md | 1 + .../adr-045-jj-workspace-parallel-sessions.md | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/adr/adr-031-weekly-review-pipeline.md b/docs/adr/adr-031-weekly-review-pipeline.md index 37f97b7a..f943bb35 100644 --- a/docs/adr/adr-031-weekly-review-pipeline.md +++ b/docs/adr/adr-031-weekly-review-pipeline.md @@ -258,6 +258,7 @@ ADR-030 の `.failed` marker パターンを流用するが、L2 recovery (UserP - **手動トリガー**: `/weekly-review` skill を明示呼出 - **reminder**: SessionStart hook が `.claude/weekly-review-last-run.json` の `last_run_at` を見て、7 日以上経過していれば `additionalContext` で促す (欠落/不正データは stale 扱いで発火、次回更新で移行。強制起動はしない) - **staleness の情報源は `last_run_at` (内容 timestamp) のみとし、mtime は一切参照しない** (2026-07-01、[CR #233](https://github.com/aloekun/claude-code-hook-test/pull/233) Major で確定): mtime を情報源にすると、状態ファイルが jj checkout / `jj workspace add` ([ADR-045](adr-045-jj-workspace-parallel-sessions.md)) のたびに reset され、実際は数週間前の実行でも「fresh」に見えて reminder が永久に発火しない silent-fresh バグ (`past_time` / `reaper` と同クラス) を踏む。**欠落時に mtime へフォールバックすると legacy 経路で同バグを再導入する**ため、`last_run_at` が欠落/parse 不能/未来値の旧・破損データは `Stale` 扱い (= 発火) にし、次回 skill 実行で `last_run_at` を backfill する (self-healing、fail-open)。有効な過去値のみ `reaper::parse_iso8601_to_unix` + `past_time::PastTime` (未来値を型で reject) で経過日数を算出する。状態ファイルは `.gitignore` 済 (§ 出力)。 + - **last-run 状態はメイン workspace を canonical とする** (2026-07-19、PR-N2 で確定): 上記 CR #233 は「mtime リセット」への対処だったが、`weekly-review-last-run.json` 自体が gitignore 済み untracked = **workspace ローカル**であるという盲点が残っていた ([ADR-045](adr-045-jj-workspace-parallel-sessions.md) 並列 workspace 運用)。前回実行 (2026-07-01) が secondary workspace (`claude-code-hook-test-improve`) 側で書かれ、メイン workspace には状態ファイルが存在せず、メイン側セッションは常に「未実行」判定で reminder を発火し続けた (約 4 週間、ユーザー気付かず。これが本 nudge 可視化改善の発端)。`weekly_review.rs` の doc「`last_run_at` は workspace 不変の値」は **値は checkout 不変だがファイル所在は workspace 依存**という点を取り違えていた。対処: SessionStart hook の last-run 読込を `lib_jj_helpers::resolve_main_workspace_root(cwd).unwrap_or(cwd)` でメイン workspace root に寄せ、secondary からもメイン側の実行記録を共有する (導出不能は現 root に fail-open)。skill (`weekly-review/SKILL.md` Step 5.3) の書込側も同じメイン root 解決を行い、読み書きの canonical を一致させる。**failed marker / pending JSON はレビュー成果物であり実行した workspace に属する**ため workspace ローカルのまま (状態の canonical 化は staleness timestamp のみに限定)。silent bug class としての一般化は [ADR-045 § gitignore 済み untracked 状態ファイルの workspace 分裂](adr-045-jj-workspace-parallel-sessions.md) を参照。 - **将来の自動化**: 機能安定後に schedule スキル (CronCreate-based) や `/loop 7d /weekly-review` を検討するが、MVP では実装しない (YAGNI、機能の安定性を観測してから判断) ### ADR-027 (push-time = simplicity 限定) との関係 diff --git a/docs/adr/adr-045-jj-workspace-parallel-sessions.md b/docs/adr/adr-045-jj-workspace-parallel-sessions.md index d6f380ed..a91db64b 100644 --- a/docs/adr/adr-045-jj-workspace-parallel-sessions.md +++ b/docs/adr/adr-045-jj-workspace-parallel-sessions.md @@ -2,7 +2,7 @@ ## ステータス -試験運用 (2026-06-29) / 改訂 (2026-06-30: 初 PR 運用ケースで判明した secondary workspace の `.git` 不在 → gh ベースコマンドの `GIT_DIR` 必須、および merge-pipeline の bookmark 誤検出を「§ PR 運用時の追加設定」として追記) / 改訂 (2026-07-03: 恒久対策候補 1 = `GIT_DIR` 自動注入を実装。cli-* exe は手動 `GIT_DIR` 前置なしで動作するようになり、手動前置は直接 gh 呼び出し時の fallback に格下げ。PR #238 で `GH_REPO` による場当たり対処が部分故障を招いた実観測を受け `gh-repo-env-guard` preset も追加) / 改訂 (2026-07-13: **再評価 trigger #3 が発火** — 並列セッションの concurrent 操作と重なる時間帯に 2 コミット分の作業が消失する incident が発生 (PR #265 セッション、手動再構築で復旧)。「並行操作は jj が安全にマージする」という調整ポイント 4 の記述を jj 公式の並行モデルに即して是正し、「§ Known operational risks」「§ 並列運用の運用ルール」「§ Operation Verification Checklist」を新設) +試験運用 (2026-06-29) / 改訂 (2026-06-30: 初 PR 運用ケースで判明した secondary workspace の `.git` 不在 → gh ベースコマンドの `GIT_DIR` 必須、および merge-pipeline の bookmark 誤検出を「§ PR 運用時の追加設定」として追記) / 改訂 (2026-07-03: 恒久対策候補 1 = `GIT_DIR` 自動注入を実装。cli-* exe は手動 `GIT_DIR` 前置なしで動作するようになり、手動前置は直接 gh 呼び出し時の fallback に格下げ。PR #238 で `GH_REPO` による場当たり対処が部分故障を招いた実観測を受け `gh-repo-env-guard` preset も追加) / 改訂 (2026-07-13: **再評価 trigger #3 が発火** — 並列セッションの concurrent 操作と重なる時間帯に 2 コミット分の作業が消失する incident が発生 (PR #265 セッション、手動再構築で復旧)。「並行操作は jj が安全にマージする」という調整ポイント 4 の記述を jj 公式の並行モデルに即して是正し、「§ Known operational risks」「§ 並列運用の運用ルール」「§ Operation Verification Checklist」を新設) / 改訂 (2026-07-19: **gitignore 済み untracked 状態ファイルの workspace 分裂** を silent bug class として追記。weekly-review last-run ([ADR-031](adr-031-weekly-review-pipeline.md)) が secondary workspace 側にのみ存在しメイン側で永久「未実行」発火した実例で、mtime リセット (CR #233) と対になる。「§ gitignore 済み untracked 状態ファイルの workspace 分裂」を新設、PR-N2) > ADR-039 (Experimental feature 標準パターン) に準拠: config opt-in なし (本 ADR は workflow 運用ポリシーであり実装機構ではないため該当しない) / kill-switch = 本 ADR を supersede する後続 ADR で停止可能、運用上は単一 workspace へ戻すだけで無効化 / bounded lifetime = 採用判定 3 ヶ月 (2026-09-29) を目安に dogfood 結果から本採用 / 修正 / 却下を判定。 @@ -39,6 +39,26 @@ git には worktree (同一リポジトリを汚染せず複数 working tree で | `/target/` (Cargo workspace) | gitignore | workspace ごとに独立ビルド (disk コストのみ) | | `.git` (colocated git ref) | **secondary workspace には存在しない** (`jj workspace add` は colocated 化しない、`.jj` のみ) | **gh ベースの `pnpm create-pr` / `pnpm merge-pr` / `cli-pr-monitor --monitor-only` は `GIT_DIR` 必須** (後述「§ PR 運用時の追加設定」)。`pnpm push` は `jj git push` backend のため不要 — この非対称が初回検証で見落とされた | +### gitignore 済み untracked 状態ファイルの workspace 分裂 (2026-07-19 追記、PR-N2) + +上表の「state / lock / monitor は workspace ごとに独立」は、**ephemeral な per-session 状態** (`pr-monitor-state.json` / lock / `.session-id` / `feedback-reports/`) にとっては衝突回避の **利点**である。しかし **論理的にグローバルであるべき状態** を gitignore 済み untracked ファイルで持つと、同じ per-workspace 分離が **silent bug** に反転する。 + +**実観測 (2026-07-19)**: weekly-review の last-run timestamp (`.claude/weekly-review-last-run.json`、[ADR-031](adr-031-weekly-review-pipeline.md)) は前回実行 (2026-07-01) が secondary workspace (`claude-code-hook-test-improve`) 側で書かれ、メイン workspace には存在しなかった。SessionStart hook はメイン側で常に「未実行」判定となり、reminder が約 4 週間発火し続けた (ユーザーが気付かず、これが nudge 可視化改善 = PR-N1〜N3 の発端)。 + +これは [ADR-031](adr-031-weekly-review-pipeline.md) が対処した **mtime リセット** ([CR #233](https://github.com/aloekun/claude-code-hook-test/pull/233)) と **対になる silent bug class** である。両者とも「状態が fresh に見えて発火しない」形で表面化するが、原因が異なる: + +| silent bug class | 原因 | 表面化 | 対処 | +|---|---|---|---| +| mtime リセット (CR #233) | 同一ファイルの mtime が checkout で変わる | 実際は数週間前でも fresh に見える | 内容 timestamp (`last_run_at`) のみを情報源にする | +| **workspace 分裂 (本追記)** | ファイル自体が workspace ごとに別物 (secondary に不在) | メイン側で永久「未実行」 | メイン workspace root に canonical 化 | + +**判定基準**: gitignore 済み untracked 状態ファイルを追加するときは、その状態が **per-workspace であるべきか global であるべきか** を必ず区別する。 + +- **global** であるべき状態 → `lib_jj_helpers::resolve_main_workspace_root(cwd).unwrap_or(cwd)` でメイン workspace root に canonical 化して読み書きする (導出不能は現 root に fail-open)。PR-N2 で weekly-review last-run に適用。 +- **per-workspace** であるべき状態 (レビュー成果物 = weekly-review report / `.failed` marker / pending JSON、monitor state、lock、session-id) → 「実行した workspace に属する」ため per-workspace のままが正しい。 + +`resolve_main_workspace_root` は `.jj/repo` の on-disk layout (secondary はメイン store への相対パスを格納したファイル、colocated main はディレクトリ) を辿ってメイン root を導出する。これは後述「§ PR 運用時の追加設定」で `GIT_DIR` を導出する `resolve_git_dir` と同じ layout 解釈を共有する姉妹関数 (最終 store ではなく workspace root を返す点が異なる)。 + ## 検討した選択肢 ### 選択肢 A: 単一セッションで直列処理 From 9d55107c8f89ed16db7734d941084114d12d83f4 Mon Sep 17 00:00:00 2001 From: aloekun Date: Sun, 19 Jul 2026 16:01:55 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20weekly-review-notification-plan=20?= =?UTF-8?q?=E3=81=AB=20PR-N2=20=E4=BD=9C=E6=A5=AD=E8=A8=98=E9=8C=B2?= =?UTF-8?q?=E3=82=92=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 分割コミット粒度・skills repo は内容編集のみ (commit/deploy は skills 側に委任)・ 検証結果 (cargo test --workspace green / hooks-session-start 93 passed / lib-jj-helpers 37+E2E 2 / clippy clean / build:all 成功 / デプロイ exe を secondary レイアウトで駆動しメイン root の last-run を読んで「18 日経過」を end-to-end 確認、対照の未実行 fail-open も確認)・ 残タスク (運用コピー・削除条件 3 = 新セッション目視・skills deploy) を追記。 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/weekly-review-notification-plan.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/weekly-review-notification-plan.md b/docs/weekly-review-notification-plan.md index 8cd7a864..8da10456 100644 --- a/docs/weekly-review-notification-plan.md +++ b/docs/weekly-review-notification-plan.md @@ -170,6 +170,24 @@ - deploy 後、**ccht-improve workspace からセッション起動** → メイン側 last-run が読まれ、 systemMessage / additionalContext の経過日数が実日数になることを確認 (削除条件 3)。 +### 作業記録 (2026-07-19 実装完了) + +- **実装済み (main リポジトリ側)**。コミット粒度 (レビューしやすさ優先で 4 分割): + 1. `feat(lib-jj-helpers)`: `resolve_main_workspace_root` 追加 (fixture + 実 jj E2E テストを `resolve_git_dir` パターンで流用)。この時点では caller なしで挙動不変。 + 2. `feat(session-start)`: hooks-session-start に lib-jj-helpers 依存追加 + `compute_weekly_review_reminder_nudge` の last-run 読込を `resolve_main_workspace_root(cwd).unwrap_or(cwd)` 基準に変更 (failed marker / pending JSON は workspace ローカル維持) + `weekly_review.rs` の「`last_run_at` は workspace 不変」誤記訂正 (値は checkout 不変だがファイル所在は workspace 依存) + `hooks_config.rs` の残存「mtime」記述訂正 (CR #233 drift) + secondary→main 読込の分割挙動テスト追加。 + 3. `docs(adr)`: ADR-031 § トリガー方式と reminder にメイン workspace canonical 化の決定を追記 + ADR-045 に「gitignore 済み untracked 状態ファイルの workspace 分裂」を silent bug class として新設 (mtime リセット CR #233 との対比表)。 + 4. `docs`: 本計画書に PR-N2 作業記録を反映 (本コミット)。 +- **claude-code-skills リポジトリ (別 git repo)**: `weekly-review/SKILL.md` Step 5.3 を **メイン root 解決付き**に変更 (bash snippet で `.jj/repo` を辿って `$MAIN_ROOT` を導出、書込先を `$MAIN_ROOT/.claude/weekly-review-last-run.json` に) + frontmatter の SessionStart hook 経路説明を更新。**内容編集のみ実施し、git commit / `~/.claude/skills` への deploy は skills repo 側の PR/deploy ライフサイクルに委ねる** (本環境は git コマンドが hook で一律 block されるため、ユーザー確認済)。 +- 検証結果: + - `cargo test --workspace`: 全 crate green (hooks-session-start **93 passed** = PR-N1 の 92 + 新規 secondary→main 分割テスト 1、lib-jj-helpers **37 passed + 実 jj E2E 2 passed**)。 + - `cargo clippy --workspace --all-targets -- -D warnings`: クリーン。 + - `pnpm build:all`: 成功 (更新 exe を `.claude/` に配布)。 + - **デプロイ済み exe を secondary workspace レイアウトで駆動して end-to-end 確認済み**: `.jj/repo` がメイン store を指す fixture から exe を起動すると、メイン root の last-run (`2026-07-01`) を読んで additionalContext・systemMessage の経過日数が **「18 日経過」** になることを確認 (fix 前は secondary 自身の不在 last-run を見て「未実行」判定になっていた)。対照として `.jj/repo` 無し + last-run 不在のディレクトリからは現 root に fail-open して「未実行」/「実行記録なし」となり、導出不能時の安全動作も確認。 +- **残タスク**: + - **運用ステップ (land 時に 1 回)**: `claude-code-hook-test-improve/.claude/weekly-review-last-run.json` (2026-07-01 実行記録) をメイン workspace の `.claude/` にコピーして実行履歴を救済する (未実施。land 前にメイン workspace で行うと reminder 表示が変わるため land 時に実施)。 + - **削除条件 3**: land + deploy 後に **ccht-improve workspace から新セッションを起動**し、メイン側 last-run が読まれて経過日数が「未実行」ではなく実日数で表示されることを目視確認。 + - claude-code-skills 側の SKILL.md 変更を skills repo で commit + `~/.claude/skills` へ deploy (skills repo ライフサイクル)。 + --- ## PR-N3: session-start nudge の telemetry 統合