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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,30 @@ jobs:
- name: Run ignored (integration) tests
shell: bash
run: cargo test --workspace -- --ignored --test-threads=1

# 夜間 PR (claude/nightly-<順位>) に台帳の後始末が含まれているかを見る。
#
# **完了を表現するのは台帳削除コミットのマージだけ** (ADR-072 決定 19) なのに、
# その削除はブランチに載って運ばれるデータなので、運搬中に失われても誰も
# 気づかなかった。2026-08-30 に `jj rebase -r <先端>` が親の `chore(ledger)`
# コミットを置き去りにし、#427 / #459 / #461 の 3 本で実装だけがマージされ、
# 2026-09-01 の夜間 run が残った台帳行から順位 324 を再選択して red で終わった
# (ADR-072 決定 21)。
#
# **ubuntu だけで回す。** 判定は markdown の走査で OS 差が無く、両 OS で回しても
# 同じ結果にしかならない。step の skip は job の状態に影響しないため、required
# check の pending 問題 (§ 冒頭の paths フィルタの注記) も起きない。
#
# **夜間ブランチ以外では exe 自身が SKIP を出して exit 0 で終わる** (early-success)。
# job/step を条件で回さない形にはしない — 「回さない」と「緑」は区別できないため。
- name: Verify nightly ledger cleanup
if: matrix.os == 'ubuntu-latest'
shell: bash
env:
# pull_request では head_ref、push では ref_name。どちらも無い場合は
# 夜間ブランチ名にならないので検査対象外として緑で抜ける。
BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
set -euo pipefail
cargo run --release -p cli-ledger-removal-check -- \
--branch "$BRANCH" --docs-dir docs
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ members = [
"src/cli-finding-classifier",
"src/cli-ledger-candidates",
"src/cli-ledger-cleanup",
"src/cli-ledger-removal-check",
"src/cli-fix-push-gate",
"src/cli-merge-pipeline",
"src/cli-nightly-outcome",
Expand Down
17 changes: 17 additions & 0 deletions docs/adr/adr-072-nightly-todo-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,23 @@ public リポジトリでは **fork からの PR でも起動し、その時点
| タスク選択時に台帳へ試行日を直 push する (試行日列の新設) | 決定 6 のガード対象ファイルへ自律 actor が書き込む経路の新設にあたる。同型の案は [ADR-070](adr-070-weekly-review-cloud-routine.md) で却下済み |
| 再挑戦上限 N 回 | 再投入が人間の操作になったため、回数を数える主体も置き場所も無くなった |

### 21. 後始末がマージされたかをマージ境界で検査する (2026-09-02)

**完了を表現するのは台帳削除コミットのマージだけ**である (決定 19)。ところがその削除は**ブランチに載って運ばれるデータ**なので、運搬中に失われても検知する層が無かった。

**実際に失われた (2026-08-30)。** 夜間 PR は `chore(ledger) 台帳削除` (親) → `実装` (子) の 2 コミット構成である。人間が `jj rebase -r <先端>` でリベースしたため**親が置き去りになり**、[#427](https://github.com/aloekun/claude-code-hook-test/pull/427) / [#459](https://github.com/aloekun/claude-code-hook-test/pull/459) / [#461](https://github.com/aloekun/claude-code-hook-test/pull/461) の 3 本すべてで実装だけがマージされた。**衝突は 1 度も起きていない** — 捨てたのではなく拾い忘れた形である。

結果、順位 324 / 412 / 457 が台帳・順位 table・詳細エントリの 3 箇所とも残り、2026-09-01 の夜間 run が順位 324 を再選択した。実装は既に master に在るので agent は 5 ターン・30 秒で何も変更せず終わり、空 diff として red になった (run 90894308468)。**既存の防御はどれも当たらない** — 除外集合はリモートブランチの存在だけを見る (マージすると消える)、順位 table 照合 (決定 18 の backstop) は台帳と順位 table が**両方残る**と素通り、`cli-ledger-cleanup` の採点はブランチの diff だけを見て master の内容を見ない。

**したがって、マージ境界に検査を置く。** `claude/nightly-<順位>` を head とする PR に対し、CI (`ci.yml` の `Verify nightly ledger cleanup`) が `cli-ledger-removal-check` を走らせ、**その順位が台帳・順位 table・詳細エントリのどこにも残っていないこと**を要求する。

- **diff ではなく head の状態を見る。** 「削除行が diff に在るか」ではなく「順位がどこにも無いか」を見るので、行番号にも文脈行にも運び方 (リベース / squash / 手作業) にも依存しない
- **順位で引く。** 決定 18 以降、選択・除外・ブランチ名・`--ranks`・詳細エントリの照合はすべて順位に統一されている ([ADR-033](adr-033-todo-numbering-simplification.md))。検査もその鍵に乗る
- **書式の解釈を増やさない。** 詳細エントリの見出し判定は `cli-ledger-cleanup` が消すときに使う関数 (`lib_ledger::detail_entry_ranks`) をそのまま使い、順位 table の識別は `cli_docs_lint::docs_files` から借りる。消す側と見る側で解釈が割れると検査が意味を失う
- **夜間ブランチ以外は exe が SKIP を出して緑で抜ける** (early-success)。job/step を条件で回さない形にはしない — GitHub は「回さなかった」を success ではなく pending として扱うため、required check にすると PR が永久にマージ不能になる

**残る射程外**: 人間が別名ブランチで同じ作業を実装して後始末を忘れた場合は、ブランチ名から順位が引けないため検査できない。この経路は weekly-review / 夜間 preflight での「merged PR × 台帳」照合が受け持つ (別途実装)。

## 試験運用判断基準 (ADR-039)

| 項目 | 内容 |
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@
"build:cli-nightly-task-select": "cargo build --release -p cli-nightly-task-select && node scripts/deploy-artifacts.mjs cli-nightly-task-select",
"build:cli-branch-cleanup": "cargo build --release -p cli-branch-cleanup && node scripts/deploy-artifacts.mjs cli-branch-cleanup",
"build:cli-stale-branch-scan": "cargo build --release -p cli-stale-branch-scan && node scripts/deploy-artifacts.mjs cli-stale-branch-scan",
"build:cli-ledger-removal-check": "cargo build --release -p cli-ledger-removal-check && node scripts/deploy-artifacts.mjs cli-ledger-removal-check",
"build:cli-ledger-cleanup": "cargo build --release -p cli-ledger-cleanup && node scripts/deploy-artifacts.mjs cli-ledger-cleanup",
"build:hooks-settings": "node scripts/build-hooks-settings.mjs",
"build:all": "pnpm build:hooks-session-start && pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-post-tool-comment-lint-rust && pnpm build:hooks-post-tool-jj-op-verify && pnpm build:hooks-stop-quality && pnpm build:hooks-stop-feedback-dispatch && pnpm build:hooks-stop-tool-call-leak && pnpm build:hooks-user-prompt-feedback-recovery && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && pnpm build:cli-finding-classifier && pnpm build:cli-docs-lint && pnpm build:cli-takt-timings && pnpm build:cli-telemetry-report && pnpm build:cli-autonomy-gate && pnpm build:cli-fix-push-gate && pnpm build:cli-nightly-task-select && pnpm build:cli-stale-branch-scan && pnpm build:cli-branch-cleanup && pnpm build:cli-ledger-cleanup && pnpm build:cli-ledger-candidates && pnpm build:hooks-settings",
"build:all": "pnpm build:hooks-session-start && pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-post-tool-comment-lint-rust && pnpm build:hooks-post-tool-jj-op-verify && pnpm build:hooks-stop-quality && pnpm build:hooks-stop-feedback-dispatch && pnpm build:hooks-stop-tool-call-leak && pnpm build:hooks-user-prompt-feedback-recovery && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && pnpm build:cli-finding-classifier && pnpm build:cli-docs-lint && pnpm build:cli-takt-timings && pnpm build:cli-telemetry-report && pnpm build:cli-autonomy-gate && pnpm build:cli-fix-push-gate && pnpm build:cli-nightly-task-select && pnpm build:cli-stale-branch-scan && pnpm build:cli-branch-cleanup && pnpm build:cli-ledger-cleanup && pnpm build:cli-ledger-removal-check && pnpm build:cli-ledger-candidates && pnpm build:hooks-settings",
"push": "node scripts/run-artifact.mjs cli-push-runner && node scripts/run-artifact.mjs cli-pr-monitor --monitor-only",
"create-pr": "node scripts/run-artifact.mjs cli-pr-monitor",
"mark-notified": "node scripts/run-artifact.mjs cli-pr-monitor --mark-notified",
Expand Down
16 changes: 16 additions & 0 deletions src/cli-ledger-removal-check/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "cli-ledger-removal-check"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "cli-ledger-removal-check"
path = "src/main.rs"

[dependencies]
lib-ledger = { path = "../lib-ledger" }
# docs/ の列挙と「どれが順位 table か」の定義を借りる。定義を増やさないための依存で、
# cli-docs-lint 側の docs_files が唯一の定義 (Phase F の F1)。
cli-docs-lint = { path = "../cli-docs-lint" }

# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026)
154 changes: 154 additions & 0 deletions src/cli-ledger-removal-check/src/detect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//! 夜間 PR に台帳の後始末が含まれているかを判定する純粋層 (I/O なし)。
//!
//! # なぜ要るか
//!
//! 夜間ループで**完了を表現するのは、台帳削除コミットがマージされること 1 つだけ**
//! ([ADR-072](../../../docs/adr/adr-072-nightly-todo-loop.md) 決定 19)。ところがその
//! 削除は「ブランチに載って運ばれるデータ」なので、運搬中に失われても誰も気づかない。
//!
//! 2026-08-30 に実際に失われた: 夜間 PR は `chore(ledger) 台帳削除` (親) →
//! `実装` (子) の 2 コミット構成で、人間が `jj rebase -r <先端>` を使ったため
//! **親が置き去りになった**。#427 / #459 / #461 の 3 本すべてで同じことが起き、
//! 実装だけがマージされて台帳行が残った。2026-09-01 の夜間 run は残った行から
//! 順位 324 を再選択し、実装済みなので diff が空になり red で終わった。
//!
//! # 何を見るか — 行ではなく順位
//!
//! diff のテキストではなく **head の状態**を見る。「順位 N が台帳・順位 table・詳細
//! エントリのどこにも残っていないこと」だけを要求するので、行番号にも文脈行にも
//! 依存しない。運び方 (リベース / squash / 手作業) が何であれ、結果だけを見る。

use std::collections::BTreeSet;

/// 夜間ブランチの命名規約。
const NIGHTLY_BRANCH_PREFIX: &str = "claude/nightly-";

/// 順位が残っていた場所の種別。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Place {
/// 台帳 `docs/claude-code-web-tasks.md` のタスク表。**再選択の条件はここ**。
Ledger,
/// 順位 table `docs/todo-summary*.md`。
Summary,
/// 詳細エントリ `docs/todoN.md` の `### 順位 N:` 見出し。
Detail,
}

impl Place {
pub(crate) fn label(self) -> &'static str {
match self {
Place::Ledger => "台帳",
Place::Summary => "順位 table",
Place::Detail => "詳細エントリ",
}
}
}

/// 1 ファイルを走査した結果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Scan {
pub(crate) place: Place,
pub(crate) file: String,
pub(crate) ranks: BTreeSet<u32>,
}

/// ブランチ名から順位を読む (I/O なし)。夜間ブランチでなければ `None`。
///
/// **数字だけを受ける。** `claude/nightly-324-retry` のような派生名を順位 324 と
/// 読むと、別物の PR に後始末を要求してしまう。
pub(crate) fn rank_from_branch(branch: &str) -> Option<u32> {
let digits = branch.trim().strip_prefix(NIGHTLY_BRANCH_PREFIX)?;
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}

/// 順位が残っている箇所を列挙する (I/O なし)。空なら後始末済み。
pub(crate) fn residue(rank: u32, scans: &[Scan]) -> Vec<&Scan> {
scans
.iter()
.filter(|scan| scan.ranks.contains(&rank))
.collect()
}

#[cfg(test)]
mod tests {
use super::*;

fn scan(place: Place, file: &str, ranks: &[u32]) -> Scan {
Scan {
place,
file: file.to_string(),
ranks: ranks.iter().copied().collect(),
}
}

#[test]
fn a_nightly_branch_yields_its_rank() {
assert_eq!(rank_from_branch("claude/nightly-324"), Some(324));
}

#[test]
fn other_branches_are_not_nightly() {
assert_eq!(rank_from_branch("master"), None);
assert_eq!(rank_from_branch("fix/branch-cleanup-exe"), None);
assert_eq!(rank_from_branch("claude/nightly-"), None);
}

/// **派生名を順位として読まない。** 読むと無関係な PR に後始末を要求する。
#[test]
fn a_suffixed_branch_name_is_not_a_rank() {
assert_eq!(rank_from_branch("claude/nightly-324-retry"), None);
assert_eq!(rank_from_branch("claude/nightly-324/fix"), None);
}

/// **incident 再現 (2026-08-30)**: 実装だけがマージされ、3 箇所とも残った形。
#[test]
fn the_incident_shape_is_reported_in_all_three_places() {
let scans = vec![
scan(Place::Ledger, "docs/claude-code-web-tasks.md", &[324, 455]),
scan(Place::Summary, "docs/summary-fixture.md", &[324]),
scan(Place::Detail, "docs/detail-fixture.md", &[324]),
];
let found = residue(324, &scans);
assert_eq!(found.len(), 3, "{found:?}");
assert_eq!(found[0].place, Place::Ledger);
}

/// 後始末が済んでいれば空。
#[test]
fn a_cleaned_up_rank_has_no_residue() {
let scans = vec![
scan(Place::Ledger, "docs/claude-code-web-tasks.md", &[455]),
scan(Place::Summary, "docs/summary-fixture.md", &[455]),
scan(Place::Detail, "docs/detail-fixture.md", &[455]),
];
assert!(residue(324, &scans).is_empty());
}

/// **部分的な残りも見逃さない。** 台帳だけ消して順位 table を残す形は、台帳から
/// 見えない孤児になる (詳細エントリとの 1:1 は D3 の entry_pairing が別途見る)。
#[test]
fn a_partial_cleanup_still_reports_what_is_left() {
let scans = vec![
scan(Place::Ledger, "docs/claude-code-web-tasks.md", &[]),
scan(Place::Summary, "docs/summary-fixture.md", &[324]),
scan(Place::Detail, "docs/detail-fixture.md", &[324]),
];
let found = residue(324, &scans);
assert_eq!(found.len(), 2, "{found:?}");
assert!(found.iter().all(|s| s.place != Place::Ledger));
}

/// 他の順位は判定に影響しない。
#[test]
fn unrelated_ranks_are_ignored() {
let scans = vec![scan(
Place::Ledger,
"docs/claude-code-web-tasks.md",
&[199, 356, 455],
)];
assert!(residue(324, &scans).is_empty());
}
}
Loading
Loading