diff --git a/.github/workflows/nightly-todo.yml b/.github/workflows/nightly-todo.yml new file mode 100644 index 00000000..b3a5ebd0 --- /dev/null +++ b/.github/workflows/nightly-todo.yml @@ -0,0 +1,460 @@ +# 夜間 todo 消化ループ (WP-18 PR 3、ADR-072) +# +# 台帳 (docs/claude-code-web-tasks.md) の「無人可」タスクを 1 件、無人で実装し +# **draft PR 作成で停止**する。マージ判断は人間が行う (ADR-052 の自動実行可クラス = +# commitment 点の手前で止まる操作)。 +# +# 信頼境界の要 (ADR-066 § 決定 3 / ADR-067 と同型): +# - 台帳・ゲート exe・autonomy-config.toml はすべて **master ref の写し** から調達する。 +# claude/ ブランチ側の同名ファイルは判定に一切使わない。自律 actor が自分の選択元や +# 停止フラグを書き換えて自己解除する経路を塞ぐ。 +# - schedule イベントは GitHub の仕様上 default branch の workflow 定義で実行されるため、 +# 本ファイル自体も PR ブランチからは差し替えられない。 +# - push / PR 作成は workflow step が行い、agent は主体にならない。agent には gh / git を +# 与えない。 +name: nightly-todo + +on: + schedule: + # 18:00 UTC = 03:00 JST。毎日 1 回 (2026-08-06 ユーザー確認)。 + # 稼働後 1 週間は run 頻度と Max 枠消費を観測して調整する (ADR-072 § 試験運用判断基準)。 + - cron: '0 18 * * *' + # 実走スモークと反復検証用。マージせずブランチ ref に dispatch できる + # (ADR-067 § 段 2 の知見 2「反復はマージせず ref 指定の dispatch で行う」)。 + workflow_dispatch: + inputs: + dry_run: + description: 'true の場合、ゲート通過まで実行し push / PR 作成は行わない' + type: boolean + default: false + +# draft PR の作成と claude/ ブランチへの push は別の App installation token (Decision 8) で +# 行うため、この job の GITHUB_TOKEN に write は不要。既定の GITHUB_TOKEN は +# 「Count open claude/ drafts」の `gh pr list` (読み取りのみ) と、Implement ステップへ渡す +# `github_token` 入力にしか使われない。後者は agent (未信頼) が触れる唯一の GitHub 資格情報 +# なので、write 権限を持たせたまま渡すと Guard/Integrity/Gate の「agent は push できない」 +# 前提を token 側から崩しうる (security review REJECT, SEC-NEW-nightly-todo-yml-L173)。 +permissions: + contents: read + pull-requests: read + +concurrency: + # 夜間 run が重なると同じ順位を二重に実装しうる。前の run を待たず後勝ちにはしない。 + group: nightly-todo + cancel-in-progress: false + +jobs: + nightly: + # kill-switch の第 1 層 (workflow 式)。Actions variable は admin のみ書き込み可で、 + # 'true' ちょうどの完全一致でなければ job ごと起動しない。exe 側 (第 2 層) は + # 1|true|yes|on を受理するため受理値が異なる点に注意 (ADR-066)。 + if: vars.AUTONOMY_ENABLED == 'true' + runs-on: ubuntu-latest + # Implement (--max-turns 60、他 job の実績比で最大 40 分規模) + + # Verify/publish-tree/guard/integrity/gate 等の後続 step を合わせた上限。 + # 他 workflow は例外なく明示済み (ci.yml:75=60, pr-monitor.yml analyze=15/fix=20, + # release-binaries.yml=30) で、本 job だけ無指定=既定 360 分は無人・毎日回る job として + # ハング検知の遅れと Max 枠浪費のリスクが大きい (pre-push simplicity review + # SIM-NEW-nightly-todo-yml-L52)。 + timeout-minutes: 60 + steps: + # 台帳・ゲート exe・config の唯一の調達元。 + - name: Checkout master (source of truth) + uses: actions/checkout@v4 + with: + ref: master + path: master-ref + persist-credentials: false + + - name: Build deterministic gates from master + run: | + set -euo pipefail + cargo build --release \ + -p cli-nightly-task-select -p cli-autonomy-gate \ + --manifest-path master-ref/Cargo.toml + # 改ざん検知の基準値。Implement ステップ (agent) が master-ref/ 配下のゲート + # exe/config を書き換えていないか、後段の Verify gate integrity で照合する。 + sha256sum master-ref/target/release/cli-autonomy-gate master-ref/autonomy-config.toml \ + > "$RUNNER_TEMP/gate-integrity.sha256" + + # 背圧の実測値と、着手済み順位の除外リストを 1 回の問い合わせから作る。 + # 数える主体 (本 step) と判断する主体 (ゲート exe) を分けるのは ADR-067 と同型。 + # + # **本 step には意図的に continue-on-error を付けない。** 他の停止点 (背圧 deny / + # タスク無し / guard deny) は設計上の正常な結末なので green + [NIGHTLY_SKIP] で終える + # が、gh や network の失敗は**インフラ障害**であって設計された結末ではない。red で + # 出したほうが「見に行くべきものがある」と伝わる。両者を同じ扱いにすると、本当に + # 壊れた夜と何もすることが無かった夜の区別が run 一覧から消える (ADR-072 決定 10)。 + - name: Count open claude/ drafts and in-flight ranks + id: inflight + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + LIMIT=100 + gh pr list --repo "${{ github.repository }}" --state open --limit "$LIMIT" \ + --json headRefName,isDraft > "$RUNNER_TEMP/open-prs.json" + TOTAL=$(jq 'length' "$RUNNER_TEMP/open-prs.json") + # 取得件数が上限に張り付いた場合、数え落としがありうる。背圧を過小に見積もると + # 「積み過ぎているのに作ってよい」へ倒れるため、ここは fail-closed で止める。 + if [ "$TOTAL" -ge "$LIMIT" ]; then + echo "[NIGHTLY_SKIP] open PR が取得上限 ($LIMIT) に達しており正確に数えられません" + exit 1 + fi + OPEN_DRAFTS=$(jq '[.[] | select(.isDraft and (.headRefName | startswith("claude/")))] | length' \ + "$RUNNER_TEMP/open-prs.json") + # 着手済み判定は **PR の状態ではなくブランチの存在**で行う。 + # + # open PR だけを見ると、draft PR が merge されずクローズされた順位が再び選択対象に + # 戻る。ブランチは残っているため push が non-fast-forward で失敗し、毎晩 agent を + # 1 回まるごと走らせて最後に落ちる無駄ループになる。採用率が低い場合にクローズが + # 起きるのは受け入れ基準が前提にしている運用なので、これは必ず踏む。 + # + # ls-remote は一致なしでも exit 0 + 空出力になるため「0 件」と「取得失敗」を + # 取り違えない (gh api の matching-refs は 404 と空配列の区別が実装依存)。 + git ls-remote --heads \ + "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" \ + 'refs/heads/claude/nightly-*' > "$RUNNER_TEMP/nightly-refs.txt" + EXCLUDE=$(sed -n 's#^.*refs/heads/claude/nightly-\([0-9]\{1,\}\)$#\1#p' \ + "$RUNNER_TEMP/nightly-refs.txt" | paste -sd, -) + echo "open_drafts=$OPEN_DRAFTS" >> "$GITHUB_OUTPUT" + echo "exclude_ranks=$EXCLUDE" >> "$GITHUB_OUTPUT" + echo "[NIGHTLY] open claude/ drafts=$OPEN_DRAFTS / 着手済み順位=[$EXCLUDE]" + + # 背圧の pre-flight 判定。**push の権威ではない** (権威は下段の Gate draft PR creation)。 + # ここで先に見るのは Max 枠の節約のため — 背圧が飽和しているのに agent を起動すると、 + # 捨てることが確定した実装のためにサブスク枠を消費する (ADR-071 § コンテキスト)。 + # 同じ exe を同じ入力で 2 回呼ぶだけなので、判定の出所は 1 つに保たれる。 + - name: Pre-flight gate (saves Max quota; not the push authority) + id: preflight + continue-on-error: true + env: + AUTONOMY_ENABLED: ${{ vars.AUTONOMY_ENABLED }} + run: | + master-ref/target/release/cli-autonomy-gate \ + --operation draft-pr \ + --config master-ref/autonomy-config.toml \ + --open-draft-prs "${{ steps.inflight.outputs.open_drafts }}" + + # 何を実装するかの決定。exit 3 = 該当タスク無し (正常な no-op) と exit 2 = 台帳破損 を + # 区別するため continue-on-error で受け、次段で分岐する。 + # if は Pre-flight gate が continue-on-error なため必須 (これが無いと backpressure で + # deny されたときも success() が真になり、この段が実行されてしまう)。 + - name: Select task from the ledger + id: select + continue-on-error: true + if: steps.preflight.outcome == 'success' + run: | + set -euo pipefail + master-ref/target/release/cli-nightly-task-select \ + --ledger master-ref/docs/claude-code-web-tasks.md \ + --exclude-ranks "${{ steps.inflight.outputs.exclude_ranks }}" \ + | tee "$RUNNER_TEMP/selected.txt" + grep -E '^(rank|branch|target_files|summary|caution)=' "$RUNNER_TEMP/selected.txt" \ + >> "$GITHUB_OUTPUT" + + # 本 step は 2 つの経路で発火する — select が非ゼロだった場合と、Pre-flight gate の + # 背圧 deny で select 自体が skip された場合。後者では select の [NIGHTLY_SKIP] 行が + # 存在しないため、「直前の行を見よ」と一律に案内すると誤誘導になる。経路で出し分ける。 + - name: Stop when there is nothing to implement + if: steps.select.outcome != 'success' + env: + PREFLIGHT_OUTCOME: ${{ steps.preflight.outcome }} + run: | + echo "[NIGHTLY_SKIP] 本 run は何も作りません。" + if [ "${PREFLIGHT_OUTCOME:-}" != "success" ]; then + echo "理由: Pre-flight gate が deny したため、タスク選択に到達していません。" + echo " 直前の [AUTONOMY_OFF] 行を参照してください (背圧の飽和 / kill-switch の停止)。" + else + echo "理由: タスクを選べませんでした。" + echo " 直前の [NIGHTLY_SKIP] 行を参照してください (exit 3 = 該当タスク無し / exit 2 = 台帳の解釈失敗)。" + fi + + - name: Checkout master (agent workspace) + if: steps.select.outcome == 'success' + uses: actions/checkout@v4 + with: + ref: master + path: work + # agent 実行中に GITHUB_TOKEN を .git/config へ置かない (Phase B と同じ理由)。 + persist-credentials: false + + # Implement (最大 60 ターン) + Verify の間に master が進む可能性があるため、 + # publish/ を後で作り直す際に「どの commit を基準に agent が編集したか」を + # 固定するための基準値。ここを取り逃すと Prepare a clean publish tree が + # 浮動 --branch master で新たに拾った未知の差分へ agent の編集を上書きし、 + # その間に master へ着地した無関係な変更を静かに revert/delete/resurrect する + # (pre-push simplicity review SIM-NEW-nightly-todo-yml-L268)。 + - name: Record work/ base commit (pins publish/ against master drift) + id: work-base + if: steps.select.outcome == 'success' + run: | + set -euo pipefail + echo "sha=$(git -C work rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + # 実装 agent。gh / git は持たず、Bash も持たない (push もブランチ操作もできない)。 + # 無人 agent に Bash を渡さない方針は Phase B (ADR-067) から踏襲する。 + # + # cargo を許さないのは、`cargo test` が build.rs とテストバイナリ = **agent 自身が直前に + # 書いたコード**を実行するため。agent のターン中はプロセス env に action の資格情報が + # 載っているので、cargo を与えることは「自分で書いたコードを資格情報のある環境で、 + # いかなるゲートより前に実行させる」ことに等しい。次段の Verify deterministically は + # 同じコードを実行するが env: を持たないため、cargo をターンの外へ出すだけで露出が狭まる。 + # 詳細と、資格情報が実際に子プロセスへ渡るかの未検証点は ADR-072 決定 5 を参照。 + # + # テストが自己申告できない分コンパイルの通らない diff で終わる run は増えうるが、 + # Verify deterministically が workflow 側で cargo test/clippy を独立に回し直して + # fail-closed で止めるため、agent の自己申告を信用しない設計は変わらない。 + - name: Implement the task (agent edits and verifies locally; cannot push) + id: implement + if: steps.select.outcome == 'success' + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + prompt: | + チェックアウト済みのリポジトリ (`work/` ディレクトリ) で、以下のタスクを 1 件実装してください。 + + - 順位: ${{ steps.select.outputs.rank }} + - 内容: ${{ steps.select.outputs.summary }} + - 対象ファイル: ${{ steps.select.outputs.target_files }} + - 注意: ${{ steps.select.outputs.caution }} + + 上記は本リポジトリのタスク台帳 `docs/claude-code-web-tasks.md` から決定論的に選ばれたもので、 + 「人間の補助なしで完結する」と人間が判断済みのタスクです。台帳の記述と対象ファイルの現物から + 実装内容が一意に決まるはずです。**決まらないと感じた場合は、推測で広げずに何も変更せず終了してください** — + 範囲外の変更を含む draft PR より、変更なしで止まる方が望ましい結果です。 + + 進め方: + 1. `work/docs/todo-summary.md` / `work/docs/todo-summary2.md` で該当順位の行を探し、 + 詳細エントリのある `work/docs/todoN.md` を読んで完了条件を確認する + 2. 対象ファイルを読み、台帳の「注意」に書かれた既知の落とし穴を踏まえて実装する + + あなたには Bash がありません。`cargo test` / `cargo clippy` を自分で実行して確認することはできません。 + 後続の workflow step が `cargo test --workspace` と `cargo clippy --workspace --all-targets -- -D warnings` を + 独立に回し直して合否を判定するため、実装後は編集内容を目視で確認し直すことに注力してください。 + + 制約 (逸脱すると後続の決定論ゲートが push を拒否します): + - **自律動作のガードレール自体を変更しないこと**: `autonomy-config.toml`、 + `.github/workflows/**`、`src/cli-autonomy-gate/**`、`src/lib-autonomy-policy/**`、 + `src/cli-fix-push-gate/**`、`src/cli-nightly-task-select/**` + - コミット・push・ブランチ操作・PR 操作は行わないこと (あなたの権限外) + - タスクに関係しないファイルを整形・修正しないこと + claude_args: | + --model claude-sonnet-5 + --max-turns 60 + --allowedTools "Read,Edit,Write,Glob,Grep" + --disallowedTools "Bash,WebFetch,WebSearch,NotebookEdit" + + # **これは品質保証ではなくコストフィルタである** (ADR-072 決定 8)。 + # ubuntu 単独・`--ignored` 無し・hooks smoke 無しで、ci.yml の真部分集合でしかない。 + # 目的は「コンパイルも通らない変更で draft PR を作り、背圧の枠 (最大 3 件) と人間の + # レビュー時間を浪費しない」ことに限る。 + # + # 品質の保証は draft PR に紐づく ci.yml (windows-latest + ubuntu-latest の 2 OS matrix、 + # clippy / cargo test / hooks smoke / --ignored 統合テスト) が担う。ADR-065 は 2 OS + # matrix が実バグを 1 件捕捉した実績を持ち、片 OS 検証では代替にならない。 + # + # ここに 2 OS matrix を複製しないのは、検証の定義が ci.yml と本 workflow の 2 箇所に + # 分かれて drift するため。単一の真実源は ci.yml 側に置く。 + - name: Verify deterministically (cost filter; NOT the quality gate) + id: verify + continue-on-error: true + if: steps.select.outcome == 'success' && steps.implement.outcome == 'success' + run: | + set -euo pipefail + cargo test --manifest-path work/Cargo.toml --workspace + cargo clippy --manifest-path work/Cargo.toml --workspace --all-targets -- -D warnings + + # 決定論的な範囲検査。draft PR は commitment 点の手前で止まるため人間のレビューが + # 最終防衛だが、「自分を縛る仕組みを自分で書き換えた diff」だけは人間に見せる前に止める。 + # 台帳の「対象ファイル」列は自由記述の markdown で path allowlist に落とせないため、 + # 許可リストではなく **禁止リスト** で守る (ADR-052 target 軸の補強)。 + # + # **git 操作は agent が触れていない publish/ に対して行う** (ADR-072 決定 9)。 + # git は hooks / filter driver / core.fsmonitor / credential.helper / core.sshCommand / + # *.textconv など多数の設定経路から外部プログラムを起動する。agent は + # $GITHUB_WORKSPACE 全体に Write を持つため work/.git を書き換えられ、しかも + # `git diff --cached --name-only` は .git/ 内部を構造的に見ない。危険なキーの列挙で + # 守ろうとすると、pre-push review が 2 回連続で漏れを見つけたとおり終わらない。 + # publish/ は Implement ステップ終了後に新規 clone するため、agent の手が届かない。 + # + # clone 後に floating `master` のままにしない: Implement (最大 60 ターン) + Verify の + # 間に master が進んでいた場合、work/ (旧 master 基準 + agent の編集) を新しい master + # 上へ rsync --delete で上書きすると、その間に着地した無関係な master の変更が + # 検知なく revert/delete/resurrect される (SIM-NEW-nightly-todo-yml-L268)。 + # work/ を checkout した時点の commit (Record work/ base commit の出力) へ揃えることで、 + # rsync が運ぶ差分を「agent が実際に編集した内容」だけに固定する。 + - name: Prepare a clean publish tree (agent never touched its .git) + id: publish-tree + continue-on-error: true + if: steps.select.outcome == 'success' && steps.verify.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORK_BASE_SHA: ${{ steps.work-base.outputs.sha }} + run: | + set -euo pipefail + # shallow にしない。新規ブランチの push で shallow update が拒否される経路を + # 避ける (ADR-067 § 残課題が未検証としていた懸念をここでは持ち込まない)。 + git clone --branch master \ + "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" publish + # work/ が実際にチェックアウトしていた commit へ揃える (floating master を離れる)。 + git -C publish checkout "$WORK_BASE_SHA" + # agent の成果物は **作業ツリーのファイルだけ**を運ぶ。.git は運ばない。 + rsync -a --delete --exclude '.git/' work/ publish/ + + - name: Guard the guardrails (deterministic path deny-list) + id: guard + continue-on-error: true + if: steps.select.outcome == 'success' && steps.publish-tree.outcome == 'success' + run: | + set -euo pipefail + git -C publish add -A + git -C publish diff --cached --name-only > "$RUNNER_TEMP/changed.txt" + CHANGED=$(wc -l < "$RUNNER_TEMP/changed.txt") + echo "--- changed files ($CHANGED) ---" + cat "$RUNNER_TEMP/changed.txt" + if [ "$CHANGED" -eq 0 ]; then + echo "[NIGHTLY_DENY] 変更がありません (agent が実装を見送ったか失敗しました)" + exit 1 + fi + # docs/claude-code-web-tasks.md (台帳) も対象に含む。台帳は「次に何を実装するか」を + # 決める仕組みであり、config や gate exe と同じく **自分を縛る側**にある。agent が + # 台帳を書き換えた diff が draft PR に紛れ、人間が見落としてマージすると、以後の + # タスク選択が静かに汚染される (ADR-072 決定 6)。 + if grep -Eq '^(autonomy-config\.toml|\.github/workflows/|docs/claude-code-web-tasks\.md|src/cli-autonomy-gate/|src/lib-autonomy-policy/|src/cli-fix-push-gate/|src/cli-nightly-task-select/)' \ + "$RUNNER_TEMP/changed.txt"; then + echo "[NIGHTLY_DENY] 自律動作のガードレールを変更しているため push しません" + exit 1 + fi + + # Implement ステップ (agent) は $GITHUB_WORKSPACE 全体に Read/Edit/Write/Glob/Grep を持ち、 + # master-ref/ 側のゲート exe/config も技術的には書き換え可能 (プロンプト上の「対象は work/」は + # 強制力を持たない)。Guard step は work/ の diff しか見ないためこの改ざんを検知できず、後段の + # Gate (authority) がそのまま改ざん後の exe/config を再実行してしまう。ここで基準値 + # (Build 段で採取した sha256) と照合し、不一致なら Gate (authority) の実行前に止める。 + - name: Verify gate integrity (detect tampering during implement) + id: integrity + continue-on-error: true + if: steps.select.outcome == 'success' && steps.guard.outcome == 'success' + run: | + set -euo pipefail + if ! sha256sum -c "$RUNNER_TEMP/gate-integrity.sha256"; then + echo "[NIGHTLY_DENY] ゲート exe/config が Implement ステップ中に変更されました (改ざん検知)" + exit 1 + fi + + # push の権威。ここが非ゼロなら push step は動かない。pre-flight と同じ exe・同じ入力を + # 使うが、run 中に kill-switch が倒された場合はこちらで止まる (ADR-066 の停止手順が + # 「次の操作境界で効く」ことの実装)。 + - name: Gate draft PR creation (authority) + id: gate + continue-on-error: true + if: steps.select.outcome == 'success' && steps.guard.outcome == 'success' && steps.integrity.outcome == 'success' + env: + AUTONOMY_ENABLED: ${{ vars.AUTONOMY_ENABLED }} + run: | + master-ref/target/release/cli-autonomy-gate \ + --operation draft-pr \ + --config master-ref/autonomy-config.toml \ + --open-draft-prs "${{ steps.inflight.outputs.open_drafts }}" + + # CI を夜間 draft PR に紐づけるための App token (ADR-072 決定 8)。 + # + # GITHUB_TOKEN で作成した PR の pull_request イベントは **承認待ちの run** になり、 + # 人間が Approve を押すまで ci.yml が動かない。無人ループでこれは「CI 未実施のまま + # マージ待ちに見える PR」を生む。GitHub 公式は回避策として GitHub App installation + # token または PAT を挙げており、本リポジトリでは App を選ぶ — PAT はオーナー + # (= Repository admin) として動くため ADR-067 の ruleset backstop を bypass してしまう。 + # App installation は独立した actor で admin ではないので backstop が残る。 + # + # 発行を publish の直前に置くのは、installation token の寿命が 1 時間で、実装 agent が + # 最大 60 ターン走るため。job 冒頭で取ると publish に届く前に失効しうる。 + # + # App の権限は Contents / Pull requests の write のみで **Workflows は付けていない**。 + # .github/workflows/** を含む push は権限側でも通らず、Guard step の禁止リストと + # 二重になる。 + - name: Mint App token (so ci.yml attaches to the draft PR) + id: app-token + continue-on-error: true + if: >- + steps.gate.outcome == 'success' && + github.event.inputs.dry_run != 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.NIGHTLY_APP_ID }} + private-key: ${{ secrets.NIGHTLY_APP_PRIVATE_KEY }} + + - name: Push branch and open draft PR (workflow-authored) + id: publish + continue-on-error: true + if: >- + steps.gate.outcome == 'success' && + steps.app-token.outcome == 'success' && + github.event.inputs.dry_run != 'true' + env: + # push / PR 作成の両方を App token で行う。PR を作った actor が誰かで + # ci.yml が走るかどうかが決まるため、ここだけは GITHUB_TOKEN を使わない。 + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BRANCH: ${{ steps.select.outputs.branch }} + RANK: ${{ steps.select.outputs.rank }} + SUMMARY: ${{ steps.select.outputs.summary }} + run: | + set -euo pipefail + # commit の author 表記は cosmetic。ci.yml が走るかどうかを決めるのは + # **push / PR 作成に使った token の actor** であり、ここでは App token を使っている。 + git -C publish config user.name "github-actions[bot]" + git -C publish config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # commit 直前に add をやり直さない。stage したのは Guard step で、間に挟まる Gate は + # master-ref/ の exe を実行するだけで publish/ に触れないため index は維持される。 + # 再 add しないことが「ゲートが検査した内容そのものを commit する」保証になる。 + git -C publish commit -m "feat: 順位 ${RANK} の無人実装 (nightly-todo)" + git -C publish push \ + "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" \ + "HEAD:refs/heads/${BRANCH}" + { + echo "台帳 (docs/claude-code-web-tasks.md) の無人可タスク **順位 ${RANK}** を" + echo "夜間ループ (nightly-todo workflow) が無人で実装した draft PR です。" + echo + echo "- 内容: ${SUMMARY}" + echo "- 事前フィルタ: 夜間 workflow が ubuntu 上で \`cargo test --workspace\` +" + echo " \`cargo clippy --workspace --all-targets -- -D warnings\` を回して green を確認済み" + echo " (agent の自己申告ではなく workflow が回し直した結果)。**これはコストフィルタで**" + echo " **品質の保証ではありません** — 単一 OS で \`--ignored\` も hooks smoke も含みません" + echo "- 品質の保証: 本 PR に紐づく **ci.yml (windows-latest + ubuntu-latest)** のチェックを見てください" + echo "- ゲート: \`cli-autonomy-gate --operation draft-pr\` (kill-switch + 背圧) を通過" + echo + echo "**マージ判断は人間が行います** (ADR-052 の commitment 点)。CI が緑で内容が台帳の" + echo "意図に沿っていれば ready 化してマージしてください。ずれている場合はクローズを —" + echo "採用率は WP-18 の受け入れ基準の測定対象です。" + echo + echo "run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } > "$RUNNER_TEMP/pr-body.md" + gh pr create --repo "${{ github.repository }}" \ + --draft \ + --base master \ + --head "$BRANCH" \ + --title "feat: 順位 ${RANK} の無人実装 (nightly-todo)" \ + --body-file "$RUNNER_TEMP/pr-body.md" + + # 無音で終わらせない (ADR-064 と同じ論理)。どの段で止まったかを 1 行で出す。 + - name: Report outcome + if: '!cancelled()' + env: + PREFLIGHT_OUTCOME: ${{ steps.preflight.outcome }} + SELECT_OUTCOME: ${{ steps.select.outcome }} + IMPLEMENT_OUTCOME: ${{ steps.implement.outcome }} + VERIFY_OUTCOME: ${{ steps.verify.outcome }} + PUBLISH_TREE_OUTCOME: ${{ steps.publish-tree.outcome }} + GUARD_OUTCOME: ${{ steps.guard.outcome }} + INTEGRITY_OUTCOME: ${{ steps.integrity.outcome }} + GATE_OUTCOME: ${{ steps.gate.outcome }} + APP_TOKEN_OUTCOME: ${{ steps.app-token.outcome }} + PUBLISH_OUTCOME: ${{ steps.publish.outcome }} + run: | + echo "[NIGHTLY] preflight=${PREFLIGHT_OUTCOME:-<未実行>} select=${SELECT_OUTCOME:-<未実行>} implement=${IMPLEMENT_OUTCOME:-<未実行>} verify=${VERIFY_OUTCOME:-<未実行>} publish_tree=${PUBLISH_TREE_OUTCOME:-<未実行>} guard=${GUARD_OUTCOME:-<未実行>} integrity=${INTEGRITY_OUTCOME:-<未実行>} gate=${GATE_OUTCOME:-<未実行>} app_token=${APP_TOKEN_OUTCOME:-<未実行>} publish=${PUBLISH_OUTCOME:-<未実行>}" + if [ "${PUBLISH_OUTCOME:-}" = "success" ]; then + echo "[NIGHTLY] draft PR を作成しました。" + exit 0 + fi + echo "[NIGHTLY_SKIP] 本 run は draft PR を作りませんでした。上の 1 行で停止段を特定してください。" diff --git a/CLAUDE.md b/CLAUDE.md index e01a0f91..4f85caa0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,7 @@ - [ADR-069: PR chain 宣言規約 — 分割チェーンと missing-consumer 検査の両立](docs/adr/adr-069-pr-chain-declaration.md) *(試験運用)* - [ADR-070: weekly-review の分析フェーズを cloud routine へ移行 — 常時性の獲得と成果物デリバリの未解決](docs/adr/adr-070-weekly-review-cloud-routine.md) *(試験運用)* - [ADR-071: 未マージ draft PR 数による背圧 — draft-pr クラスの自主減速](docs/adr/adr-071-draft-pr-backpressure.md) *(試験運用)* +- [ADR-072: 夜間 todo 消化ループ — 無人実装から draft PR までの決定論経路](docs/adr/adr-072-nightly-todo-loop.md) *(試験運用)* ## 開発 convention / チェックリスト diff --git a/Cargo.lock b/Cargo.lock index 6d66555a..120bca63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,6 +168,10 @@ dependencies = [ "toml", ] +[[package]] +name = "cli-nightly-task-select" +version = "0.1.0" + [[package]] name = "cli-pr-monitor" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2340eaaf..9b474573 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "src/cli-finding-classifier", "src/cli-fix-push-gate", "src/cli-merge-pipeline", + "src/cli-nightly-task-select", "src/cli-pr-monitor", "src/cli-push-runner", "src/cli-takt-timings", diff --git a/docs/adr/adr-072-nightly-todo-loop.md b/docs/adr/adr-072-nightly-todo-loop.md new file mode 100644 index 00000000..1b39456a --- /dev/null +++ b/docs/adr/adr-072-nightly-todo-loop.md @@ -0,0 +1,339 @@ +# ADR-072: 夜間 todo 消化ループ — 無人実装から draft PR までの決定論経路 + +## ステータス + +試験運用 (2026-08-06) + +> [ADR-052](adr-052-autonomy-execution-boundary-classes.md) の自動実行可クラスのうち **draft PR 作成**を、[ADR-066](adr-066-autonomy-global-kill-switch.md) の kill-switch と [ADR-071](adr-071-draft-pr-backpressure.md) の背圧の上に実装する。無人 fix push ([ADR-067](adr-067-phase-b-unattended-fix-push.md)) の次の段で、**自律 actor が初めて「新しい成果物」を作る**経路になる。 + +## コンテキスト + +[ADR-067](adr-067-phase-b-unattended-fix-push.md) の Phase B で、PC 電源オフ中でも PR イベントが処理され、docs 指摘なら無人 fix push まで到達する経路が成立した。ただし同 ADR § 欠点が指摘したとおり、**Phase B の実効価値は発火機会の少なさに縛られている** — 対象が既存 PR への docs 修正に限られるため、`claude/` ブランチの PR が存在しない限り何も起きない。 + +一方、`docs/todo-summary.md` には着手されないまま滞留するタスクが積み上がっている。そのうち「成功条件が `cargo test --workspace` で検証完結し、着手時の設計判断を含まない」ものは、人間が対話で補助しなくても完結しうる。この 2 つを繋ぐのが本 ADR の対象である。 + +### なぜ draft PR で止めるのか + +[ADR-052](adr-052-autonomy-execution-boundary-classes.md) 原則 2 は、自律 actor が到達してよい境界を **commitment 点の手前**と定める。draft PR は「成果物は出来ているが、まだ誰も採用していない」状態で、ready 化とマージという 2 つの人間の操作が commitment 点として残る。 + +無人実装の品質を事前に保証する手段はない ([dev-conventions](../dev-conventions.md) § LLM を含む自動化経路は実走でしか検証できない)。保証できないなら、**間違っていた場合のコストを小さくする**方に設計を寄せる。draft PR を閉じるコストはクリック 1 回である。 + +### 実行主体を GitHub Actions にした経緯 + +cloud routine 案は [ADR-070](adr-070-weekly-review-cloud-routine.md) § 実現可能性の未検証点の実測で劣後した — routine の `jj git push` はローカル hook (`jj-push-guard`) に阻まれ、例外新設は「自律 push 経路の新設」として採用バーを超える。GitHub Actions は workflow step が push する Phase B と同構造でこの問題が発生せず、`claude/` prefix ブランチは ruleset 除外とも整合する。 + +## 決定 (試験運用) + +### 1. 「何を実装するか」は Rust 分類関数が決める + +`cli-nightly-task-select` が台帳 (`docs/claude-code-web-tasks.md`) の markdown table を解釈し、「無人可」マークの付いた行から 1 件を選ぶ。選択を LLM にも shell にも委ねない。 + +- **LLM に委ねない根拠**: [ADR-052](adr-052-autonomy-execution-boundary-classes.md) は「分類ロジックを Rust 分類関数を用意せず自律 actor の実行時 LLM 判断に委ねる」ことをアンチパターンとして明示している。何を実装するかは自律動作の起点であり、ここが揺れると下流のゲートがいくら堅くても「意図しないタスクを正しく実装した draft PR」が出てくる。 +- **shell (awk/grep) に委ねない根拠**: markdown table の境界 (列ずれ・エスケープされたパイプ・無関係な表の混在) に回帰テストを書く場が無い。exe なら unit test で固定できる。 + +選択は**文書順の最初**とする。台帳の表が工数昇順に並んでいるためで、乱択や最新順にすると run ごとに選択が変わり失敗の再現ができなくなる。 + +### 2. 台帳の曖昧さはすべて停止側へ + +台帳は人間が手で編集する markdown なので、列ずれ・順位の重複・未知のマーク表記が起こる。これらは読み飛ばさず **exit 2** で止める。読み飛ばした行が本来の選択対象だった場合、ループは黙って別のタスクを実装する。 + +exit コードは 3 種に分ける: + +| code | 意味 | 後続 | +|---|---|---| +| 0 | タスクを選んだ | 実装 step へ進む | +| 2 | 引数不正 / 台帳の読み取り・解釈に失敗 | 進まない | +| 3 | 台帳は読めたが該当タスクが無い (正常な no-op) | 進まない | + +2 と 3 を分けるのは run log で「何もすることが無かった」と「台帳が壊れている」を切り分けるためで、後続を動かさない点は同じ。**「無人可 列を持つ表が 1 つも無い」は 3 ではなく 2 とする** — 台帳の構成変更でループが静かに死ぬのを防ぐ。 + +### 3. 毎晩同じタスクを実装し直さない + +台帳の行はタスクが**マージされるまで**残る。素朴に「無人可の先頭行」を選ぶと毎晩同じタスクを実装する。 + +ブランチ名に順位を埋め (`claude/nightly-<順位>`)、同名ブランチが存在する順位を `--exclude-ranks` で除外することで、決定論のまま解いた。 + +**除外の判定は PR の状態ではなくブランチの存在で行う。** open PR だけを見ると、draft PR がマージされずクローズされた順位が再び選択対象に戻る。ブランチは残っているため push が non-fast-forward で失敗し、**毎晩 agent を 1 回まるごと走らせて最後に落ちる無駄ループ**になる。採用率が低い場合にクローズが起きることは受け入れ基準 (§ 試験運用判断基準) が前提にしている運用なので、この経路は必ず踏む。 + +取得に `git ls-remote` を使うのは、一致なしでも exit 0 + 空出力になり「0 件」と「取得失敗」を取り違えないため。背圧の実測値 (未マージ draft 数) は別の指標なので、そちらは引き続き `gh pr list` で数える。 + +`--exclude-ranks` は**空でも省略できない**。空文字は「数えた結果 0 件」、フラグ欠落は「数えられなかった」で意味が違う。省略可能にすると `gh api` が失敗した run が「開いている draft は無い」と解釈して同じタスクを二重実装する ([ADR-071](adr-071-draft-pr-backpressure.md) § 決定 5 と同じ設計)。 + +### 4. 背圧ゲートは pre-flight と authority の 2 回呼ぶ + +`cli-autonomy-gate --operation draft-pr` を、agent 起動前と push 直前の 2 箇所で同じ入力で呼ぶ。 + +- **pre-flight** は Max 枠の節約。背圧が飽和しているのに agent を起動すると、捨てることが確定した実装のためにサブスク枠を消費する ([ADR-071](adr-071-draft-pr-backpressure.md) § コンテキストの経済的根拠そのもの)。 +- **authority** は push の権威。run 中に kill-switch が倒された場合はこちらで止まる ([ADR-066](adr-066-autonomy-global-kill-switch.md) の「停止は次の操作境界で効く」の実装)。 + +**2 回の呼び出しは同等ではない。** authority 側が読み直すのは kill-switch の 2 拠点 (`vars.AUTONOMY_ENABLED` と master ref の config) だけで、**未マージ draft 数は job 冒頭のスナップショットを使い回す**。実装 step は最大 60 ターン走るため、その間に別経路で draft PR が増えても authority gate は気づかない。結果として閾値を 1 件超えた状態で push が通りうる。 + +再計数しないのは、超過の実害が「レビュー待ちが 1 件多い」に留まる一方、authority gate の直前にネットワーク I/O を挟むと gate 自身が外部要因で落ちる経路を増やすため。**kill-switch は即時・背圧は run 単位**という粒度差として受け入れる。閾値超過が実運用で問題になれば再計数を入れる (§ 残課題)。 + +同じ純粋関数を同じ入力源で 2 回呼ぶだけであり、判定の出所は 1 つに保たれている。「背圧の状態を 2 箇所で持たない」([ADR-071](adr-071-draft-pr-backpressure.md) § 決定 2) と矛盾しない — 持っているのは呼び出し回数であって状態ではない。 + +### 5. agent には Bash を与えない。検証は workflow だけが行う + +実装 agent のツールは `Read` / `Edit` / `Write` / `Glob` / `Grep` のみで、`Bash` / `gh` / `git` / `WebFetch` / `WebSearch` は `--disallowedTools` で明示的に落とす。[ADR-067](adr-067-phase-b-unattended-fix-push.md) の Phase B fix agent と同じ姿勢を取る。 + +当初は「テストを回せない状態で書かせるとコンパイルも通らない diff を毎晩作る」という理由で `Bash(cargo test:*)` / `Bash(cargo build:*)` / `Bash(cargo clippy:*)` を許していた。pre-push security review がこれを REJECT し、Bash を落とす形へ改めた。 + +#### レビューの指摘理由は誤りだった (2026-08-06 検証) + +security review の主張は「**`--allowedTools` の `Bash(cmd:*)` は文字列の前方一致でシェルを解釈しないため `cargo test; curl ...` が許可を通過する**」というものだった。この前提は**公式ドキュメントで否定される**: + +> Claude Code is aware of shell operators, so a rule like `Bash(safe-cmd *)` won't give it permission to run the command `safe-cmd && other-cmd`. The recognized command separators are `&&`, `||`, `;`, `|`, `|&`, `&`, and newlines. **A rule must match each subcommand independently.** + +`--allowedTools` も同じルール体系に属する (「a managed settings deny can't be overridden by `--allowedTools`」)。したがって主張された経路は成立しない。 + +**それを検証せずに設計変更し、ADR の決定として記録したのは誤りだった。** この失敗は指摘の内容とは別に記録する価値がある — 静的レビューは*誤ることがある*のに、REJECT という形式が検証を省く圧力として働いた。さらにこの誤った前提のまま「同じ形が `pr-monitor.yml` にもある」と横展開の警告まで出しており、実在しない穴を production に見つけたと報告しかけた。 + +#### それでも Bash を落とす判断は維持する + +理由は指摘されたものとは別で、こちらは**構造から言える**: + +`cargo test` は `build.rs` とテストバイナリ、すなわち**その agent 自身が直前に書いたコード**を実行する。agent のターン中はプロセス env に action の資格情報が載っている。つまり agent に cargo を与えることは「自分で書いたコードを、資格情報のある環境で、いかなるゲートより前に実行させる」ことに等しい。 + +一方 `Verify deterministically` step は同じコードを実行するが `env:` を持たない。cargo の実行を agent のターンの外へ出すだけで、資格情報への露出は構造的に狭まる。 + +ただし **claude-code-action が子プロセスへ資格情報を渡すか自体は未検証**であり、実走スモークの観測項目に入れてある (§ 実走スモーク)。したがって現時点の判断は「**測るまでは保守側に置く**」であって、確定した脅威への対処ではない。スモークで露出が無いと確認できれば、Bash の再付与 (agent が自分の変更を検証できる利点) を再検討してよい。 + +代償として agent は自分の変更をコンパイル・テストできない。ブラインドで書くぶん失敗率は上がるが、失敗した run は `Verify deterministically` step で落ちて draft PR が作られないだけである。**間違った成果物を作るコストより、無駄な run 1 回のコストの方が小さい。** + +**agent の自己申告は元から採否の根拠にしていない。** workflow step が `cargo test --workspace` と `cargo clippy --workspace --all-targets -- -D warnings` を回し直した結果を使う。この step は `env:` を持たないため、`work/` 側に仕込まれた `build.rs` が実行されてもトークンには到達しない。 + +### 6. ガードレールの自己改変は人間に見せる前に止める + +draft PR は commitment 点の手前なので人間のレビューが最終防衛になるが、「自分を縛る仕組みを自分で書き換えた diff」だけは例外とし、決定論的な path 禁止リストで push を止める。 + +対象: `autonomy-config.toml` / `.github/workflows/**` / **`docs/claude-code-web-tasks.md`** / `src/cli-autonomy-gate/**` / `src/lib-autonomy-policy/**` / `src/cli-fix-push-gate/**` / `src/cli-nightly-task-select/**`。 + +**台帳 (`docs/claude-code-web-tasks.md`) を含めるのは、それが「次に何を実装するか」を決める仕組みだから。** config や gate exe と同じく*自分を縛る側*にある。agent が台帳を書き換えた diff が draft PR に紛れ、人間が見落としてマージすると、以後のタスク選択が静かに汚染される。初版はここを落としていた (§ 静的レビューが捕捉した件)。 + +**許可リストではなく禁止リストにした**のは、台帳の「対象ファイル」列が自由記述の markdown (バッククォート・括弧・複数パスの列挙) で、path allowlist に落とすと正当なタスクまで弾くため。allowlist 化は台帳を機械可読にしてからの課題とする (§ 残課題)。 + +空 diff も deny する — agent が実装を見送った場合と失敗した場合の両方をここで捕まえる。 + +禁止リストが構造的に見えない面 (`work/.git/`) については決定 9 を参照。 + +### 7. ゲート資産の改ざんを authority gate の実行前に検知する + +決定 6 の禁止リストは `work/` の diff しか見ない。ところが実装 agent は `$GITHUB_WORKSPACE` 全体に `Read` / `Edit` / `Write` を持ち、そこには **`master-ref/`**(ゲート exe と `autonomy-config.toml` の調達元)も含まれる。プロンプトに書いた「対象は `work/`」は強制力を持たない。したがって次の自己解除経路が成立する: + +1. agent が `master-ref/target/release/cli-autonomy-gate` または `master-ref/autonomy-config.toml` を書き換える +2. `Guard the guardrails` は `git -C work diff` しか見ないので検知しない +3. `Gate draft PR creation (authority)` が改ざん後の exe / config をそのまま push の権威として実行する + +**これは本 workflow のヘッダコメントが「塞いだ」と主張している経路そのものである**(決定 1 の master ref 契約は、調達元が実行中に書き換わらないことを暗黙の前提にしていた)。 + +対処として、`Build deterministic gates from master` 段で `sha256sum` の基準値を採り、authority gate の直前で照合する。不一致なら gate を実行せずに停止する。基準値は `$RUNNER_TEMP` に置く — ワークスペース外であり、[ADR-067](adr-067-phase-b-unattended-fix-push.md) 段 2 で実測された「Claude Code は作業ディレクトリ外を読めない」制約が、ここでは防御側に働く。 + +**これは検知であって防止ではない。** より強い対処は `master-ref/` を agent のファイルシステムから完全に外す(別 job + artifact 受け渡し)ことだが、workflow を 2 job へ割る構造変更になる。まず検知で塞ぎ、実走スモークで agent の実挙動を観測してから分離の要否を判断する(§ 残課題)。 + +### 8. draft PR は App token で作る — CI を PR に紐づけるため + +**`GITHUB_TOKEN` で作成した PR には CI が自動で走らない。** GitHub 公式ドキュメントの記述: + +> When a pull request is created or updated by a workflow using `GITHUB_TOKEN`, `pull_request` events with the `opened`, `synchronize`, or `reopened` activity types create workflow runs that **require approval**. +> +> events triggered by the `GITHUB_TOKEN` will not create a new workflow run, with the following exceptions […] you can use a **GitHub App installation access token or a personal access token** instead of `GITHUB_TOKEN` to trigger events that require a token. + +無人ループでこれは致命的である。夜間に作られた draft PR が「CI 未実施のままマージ待ちに見える」状態になり、人間が Approve を押すまで検証が始まらない。本プロジェクトは **Windows を主開発環境**とし、[ADR-065](adr-065-ci-matrix-cross-os-regression.md) の 2 OS matrix は初回観測期間に実バグを 1 件捕捉している。Windows 検証を人間の操作待ちにする設計は採れない。 + +#### PAT ではなく App を選ぶ理由 + +公式は App installation token と PAT を等価に挙げるが、本リポジトリでは **PAT を採れない**。[ADR-067](adr-067-phase-b-unattended-fix-push.md) 段 0 の ruleset `phase-b-backstop-restrict-non-claude-push` は `~ALL` ブランチの `creation` / `update` を禁止し `refs/heads/claude/**` を除外、bypass を **Repository admin ロール**にのみ与えている。オーナーの PAT はそのオーナーとして動くため admin 判定になり、**5 層目の防波堤を素通りする**。App installation は独立した actor で admin ではないため、backstop がそのまま効く。 + +#### 権限と発行タイミング + +App の権限は **Contents: write / Pull requests: write / Metadata: read** のみ。**Workflows は付けない** — `.github/workflows/**` を含む push が権限側でも通らなくなり、決定 6 の禁止リストと二重になる。 + +installation token の発行は **publish の直前**に置く。寿命が 1 時間で、実装 agent は最大 60 ターン走るため、job 冒頭で取ると publish に届く前に失効しうる。 + +App token を使うのは push と PR 作成の 2 操作だけで、`gh pr list` による背圧の計数は `GITHUB_TOKEN` のままにする。読み取りに triggering の含意は無く、App token の露出面を広げる理由がない。 + +#### 副次効果: job の `GITHUB_TOKEN` から write を落とせた + +App token の導入は資格情報を 1 つ増やす一方で、**job 自身の権限を縮められる**。push を App token が行うため、workflow の `permissions` は `contents: read` / `pull-requests: read` で足りる。 + +これは単なる最小権限の整理ではない。`GITHUB_TOKEN` は `claude-code-action` の `github_token` 入力として **agent (未信頼) が触れる唯一の GitHub 資格情報**であり、write を持たせたままだと「agent は push できない」という決定 6 / 7 の前提を token 側から崩しうる。read-only にすることで、その経路が権限層で閉じる。 + +前例は [ADR-067](adr-067-phase-b-unattended-fix-push.md) の Phase A (analyze job) で、`contents: read` のまま `claude-code-action` を本番運用できている。Phase B (fix job) が `contents: write` へ昇格しているのは workflow step 自身が `GITHUB_TOKEN` で push するためで、本 workflow はその必要がない。 + +つまり **App token の導入で、Phase B より弱い権限で同じことができる**構成になった。 + +#### 内部 Verify は品質保証ではない + +この決定に伴い、workflow 内の `Verify deterministically` の役割を**コストフィルタ**として再定義する。ubuntu 単独・`--ignored` 無し・hooks smoke 無しで `ci.yml` の真部分集合でしかなく、目的は「コンパイルも通らない変更で draft PR を作り、背圧の枠と人間のレビュー時間を浪費しない」ことに限る。 + +**品質の保証は draft PR に紐づく `ci.yml` が担う。** 2 OS matrix を本 workflow へ複製しないのは、検証の定義が 2 箇所へ分かれて drift するため — 単一の真実源は `ci.yml` 側に置く。この区別は workflow のコメントと PR 本文の両方に明記する (読み手が「workflow が検証済み」を品質保証と誤読しないため)。 + +### 9. git 操作は agent が触れていない作業ツリーで行う + +決定 6 の禁止リストは `git diff --cached --name-only` で実装しており、**`.git/` 内部を構造的に見ない**。一方 `git add` / `commit` / `push` は多数の設定経路から外部プログラムを起動する — `core.hooksPath`、`filter.*`、`core.fsmonitor`、`credential.helper`、`core.sshCommand`、`diff.*.textconv`、`core.pager` など。agent は `$GITHUB_WORKSPACE` 全体に Write を持つため `work/.git/` を書き換えられる。 + +**刺さる先は決定 8 で導入した App token である。** これらが発火するのは `Push branch and open draft PR` step で、そこは Contents:write / PR:write を持つ live な資格情報が env にある唯一の場所になった。CI を PR へ紐づけるために置いた資格情報が、そのまま最も価値の高い標的になっていた。 + +#### 危険なキーの列挙では終わらない + +最初の対処は deny-list だった (`core.hooksPath` / `filter.*` を検知 + `.git/hooks` の非 sample ファイルを検知)。しかし**次のレビューが `alias.*` の欠落を指摘し、その次に `core.fsmonitor` / `credential.helper` が挙がった**。2 回連続で、著者ではなくレビュアーが漏れを見つけている。git の設定空間は広く、列挙で追随する限りこの往復は終わらない。 + +なお指摘のうち `alias.*` の想定経路 (`alias.push` が組み込みの `push` を差し替える) は**成立しない**。git 公式ドキュメント: + +> To avoid confusion and troubles with script usage, aliases that hide existing Git commands are ignored except for deprecated commands. + +指摘の具体例は誤りだったが、**指摘が示した「列挙が漏れる」という構造は正しい**。誤りの部分だけを見て退けると、正しい部分を捨てることになる。 + +#### 構造で断つ + +`publish/` を **Implement ステップの終了後に新規 clone** し、agent の成果物は**作業ツリーのファイルだけ**を `rsync -a --delete --exclude '.git/'` で運ぶ。以後の `add` / `diff` / `commit` / `push` はすべて `publish/` に対して行う。 + +agent はそのターンが終わった時点で何も書けないので、**その後に作られた `.git` は定義上 agent の手が届かない**。危険なキーを 1 つも列挙せずに、クラスごと消える。 + +clone は shallow にしない。新規ブランチの push で shallow update が拒否される経路を避けるためで、[ADR-067](adr-067-phase-b-unattended-fix-push.md) § 残課題が未検証としていた懸念をここでは持ち込まない。 + +#### clone 先は master ではなく agent の base commit へ固定する + +`publish/` を素朴に `--branch master` のまま使うと、**この決定自体が新しいバグを生む**。`work/` の checkout から `publish/` の clone までの間に実装 agent (最大 60 ターン) と `Verify` が走るため、その間に master が進みうる。進んだ master の上へ agent の古い作業ツリーを `rsync --delete` で被せると、**その間に master へ着地した無関係な変更を静かに revert / delete する**。 + +そこで agent 起動前に `git -C work rev-parse HEAD` を記録し、clone 後に `git -C publish checkout <その sha>` で base を揃える。commit の親が agent の base になるので、PR の merge-base も正しく解決し、diff には agent の変更だけが出る。 + +この欠陥は pre-push simplicity review が捕捉した。**セキュリティのために入れた構造変更が、別の正しさを壊しうる**という実例で、決定 9 の対処が「clone するだけ」では足りない理由でもある。 + +**この決定は決定 7 とは非対称である。** `master-ref/` は「実行する exe と config」なので作り直せず sha256 照合 (検知) にとどまるが、`publish/` は「これから作る成果物の入れ物」なので作り直せる。作り直せるものは検知ではなく**排除**する。 + +### 10. 「設計された停止」と「インフラ障害」を run の色で区別する + +停止点のうち **設計上の正常な結末**(背圧 deny / 該当タスク無し / guard deny / 空 diff)は `continue-on-error` で受け、green + `[NIGHTLY_SKIP]` として終える。一方 **インフラ障害**(`gh` や network の失敗、clone 失敗)は `continue-on-error` を付けず red のまま落とす。 + +両者を同じ扱いにすると、run 一覧から「本当に壊れた夜」と「何もすることが無かった夜」の区別が消える。毎晩回る無人ループでは、この 2 つが混ざった時点で run 一覧が読まれなくなる。 + +具体的には `Count open claude/ drafts and in-flight ranks` に `continue-on-error` を**意図的に付けていない**。この step が失敗するのは gh API か network の問題であって、設計された結末ではない。なお `Report outcome` は `if: '!cancelled()'` なので red の場合も 1 行サマリは出る — 診断情報は失われない。 + +pre-push simplicity review はここを「他の停止点と同様に graceful degradation すべき」と指摘したが、上記の理由で**現状を維持する**。指摘が再発しないよう決定として記録しておく。 + +## 試験運用判断基準 (ADR-039) + +| 項目 | 内容 | +|---|---| +| **Config opt-in** | 3 つの独立した条件がすべて要る: Actions variable `AUTONOMY_ENABLED` = `'true'`、`autonomy-config.toml` の `[autonomy] enabled = true`、同 `max_open_draft_prs` の設定。どれか 1 つでも欠ければ draft PR は作られない | +| **Kill-switch** | 夜間ループだけ止めるなら `max_open_draft_prs = 0`。全自律動作を止めるなら `enabled = false` または Actions variable の削除。**新しい停止操作を増やしていない** — 既存 2 拠点 + 背圧の閾値だけで止まる | +| **Bounded lifetime** | decision trigger: **2 週間の試験運用で無人 draft PR の採用率 (人間がマージした割合) を測定し、50% 超で継続・拡大、未満なら対象クラスを絞って再試行**。あわせて (a) 選択が意図どおりの順位に当たること、(b) ガードレール禁止リストが誤検知しないこと、(c) 背圧飽和で実際に停止すること、を観測する。**2026-11-06 までに判定材料が集まらなければ延長 / 却下を判断する** | + +採用率の測定は weekly-review の自律アクション棚卸し (WP-19 ステップ 3) に載せて仕組み化する。 + +## 検証記録 + +### `cli-nightly-task-select` の unit test (25 件) + +台帳パーサ 17 件 / 引数解析 8 件。境界として固定したもの: + +- 文書順の最初を選ぶこと、除外順位を飛ばすこと +- 全候補が除外済み / 無人可マークが 1 つも無い → `None` (正常な no-op) +- **無人可 列を持つ表が 1 つも無い → エラー** (no-op ではない) +- 順位の重複 / 未知のマーク表記 (`✅ (条件付き)` など) / 非数値の順位 / 列ずれ / 区切り行の欠落 → すべてエラー +- 無人可 列を持たない表 (棚卸し履歴など) は無視する +- エスケープされたパイプ (`\|`) をセル区切りにしない +- `--exclude-ranks` の空文字は空集合、フラグ欠落は引数不正 + +### 実データでの選択 (2026-08-06) + +WP-18 PR 2 (#362) の台帳に対し release build の実 exe を走らせた。 + +| 入力 | 結果 | +|---|---| +| 除外なし | `rank=203 branch=claude/nightly-203` (Batch 1 の先頭 ✅ 行) | +| `--exclude-ranks 203` | `rank=240` | +| 無人可 7 件すべて除外 | exit 3 (no-op) | +| **PR 2 未マージの旧台帳** | **exit 2** — 無人可 列が無いため loud に停止 | + +最後の 1 行が重要で、台帳が旧構成のまま夜間ループが動いても「該当タスク無し」として静かに no-op せず、理由を出して止まる。棚卸し履歴の表と「無人可としなかった理由」の表 (どちらも順位 列を持つが 無人可 列を持たない) が正しく無視されることも実データで確認した。 + +### workflow の構文検証 + +js-yaml で 17 step 構成を確認した。 + +### 静的レビューが著者の見落としを 9 件捕捉した (2026-08-06〜07) + +pre-push review を 11 サイクル通す過程で、blocking な欠陥 9 件が見つかった (うち 1 件は non-blocking warning からの拾い上げ)。**いずれも著者 (Claude) が設計時に気づけなかったもの**で、記録しておく価値がある。共通するのは「守っているつもりの範囲」と「実際に守れている範囲」のずれである。 + +なお 9 件のうち 2 件 (#3 / #6) は**指摘の具体例そのものが誤っていた**。#3 は誤りに気づかず設計を動かし、#6 は誤りの中にある正しい構造を拾って設計を変えた。差は前提を検証したかどうかだけである。 + +| # | 指摘 | 何を見落としていたか | +|---|---|---| +| 1 (simplicity) | `Pre-flight gate` だけが `id` / `continue-on-error` を持たず、背圧 deny が job 全体の failure になる | 4 つある停止点のうち 3 つを graceful に設計しておきながら、**最も高頻度で踏まれる最初の 1 つ**だけ落としていた。背圧が効くたびに毎晩赤い × が出て、`Report outcome` も停止段を特定できない | +| 2 (security) | agent の unscoped な file tools が `master-ref/` に届き、ゲート exe / config を改ざんできる | 決定 6 の禁止リストを「ガードレール保護」として設計した時点で、**保護対象を `work/` の diff だけに限定していた**。調達元そのものが書き換え可能である経路を見ていなかった (→ 決定 7 を新設) | +| 3 (security) | `Bash(cargo test:*)` は前方一致でシェルを解釈しないため `cargo test; curl ...` が通り、diff に痕跡を残さず任意コマンド実行できる | **この指摘は誤りだった** (2026-08-06 に公式ドキュメントで検証)。Claude Code は shell operator を解釈し各サブコマンドの独立一致を要求する。ただし著者はそれを検証せず設計変更し ADR の決定として記録した — **静的レビューも誤りうるのに、REJECT という形式が検証を省く圧力として働いた**。決定 5 自体は別の根拠 (cargo が agent 自身の書いたコードを資格情報のある環境で実行する) で維持したが、根拠は差し替えた | +| 4 (security) | job の `GITHUB_TOKEN` が write のままで、agent が触れる `github_token` 入力から「agent は push できない」前提を崩せる | 決定 8 で App token を**足す**ことだけ考え、元の write 権限を**落とす**発想が無かった。資格情報を追加したら既存の権限を見直す、が抜けていた | +| 5 (security) | agent が `work/.git/hooks` / `.git/config` を書けば、App token を持つ publish step で任意コマンドが走る | 決定 6 の禁止リストを `git diff` で実装した時点で、**`.git/` 内部が構造的に見えない**ことに気づいていなかった。しかも決定 8 で同じ step に live な資格情報を置いており、自分で作った穴に自分で価値を積んでいた | +| 6 (security) | 上記の deny-list が `alias.*` / `core.fsmonitor` / `credential.helper` を漏らしている | **列挙で守ろうとしたこと自体が誤り**だった。2 回連続でレビュアーが漏れを見つけた時点で、列挙の追随ではなく構造で断つべきだと気づくべきだった (→ 決定 9)。なお指摘の具体例 (`alias.push` が組み込みを差し替える) は公式ドキュメント上**成立しない**が、示された構造は正しい — 誤りの部分だけを見て退けると正しい部分を捨てることになる | +| 7 (simplicity) | 決定 9 の `publish/` が `--branch master` 固定で、agent 実行中に master が進むと無関係な変更を静かに revert / delete する | **セキュリティのために入れた構造変更が、別の正しさを壊していた**。`work/` の checkout と `publish/` の clone の間に最大 60 ターン + Verify が挟まることを、`rsync --delete` を書いた時点で考えていなかった (→ base commit への固定を追加) | +| 8 (simplicity) | 決定 6 の禁止リストが**台帳自身**を守っていない | 「自分を縛る仕組み」として config と gate exe は列挙したのに、**選択元である台帳**を同じクラスだと認識していなかった。決定 1 で「台帳は master ref から読む」と信頼境界を引いておきながら、その台帳への書き込み経路を塞いでいなかった | +| 9 (simplicity) | job に `timeout-minutes` が無く既定 360 分まで走りうる | 同リポジトリの他 workflow は**例外なく明示済み** (`ci.yml`=60 / `pr-monitor.yml`=15, 20 / `release-binaries.yml`=30)。とくに同じ `claude-code-action` を使う `pr-monitor.yml` の fix job は 30 turns に対し 20 分を課しているのに、本 job は turns 2 倍 (60) で上限なし。決定 4 で Max 枠の節約のために gate を二重に呼ぶ設計にしておきながら、**ハングした run が枠を焼き続ける経路**を空けていた。`timeout-minutes: 60` を明示 | + +修正後、fix step は指摘 1 に対して `id` / `continue-on-error` の追加に加え、`Select task` へ `if: steps.preflight.outcome == 'success'` を足している。これは指摘の Fix Suggestion には無かったが**必要な追加**である — `continue-on-error` を付けただけでは deny 後も後続段が走ってしまう。 + +### integrity 機構の drill 4 シナリオ (2026-08-06) + +決定 7 の照合が「改ざんを検知する」だけでなく「改ざんが無ければ通る」ことも確認した。後者を確認しないと、毎晩必ず止まる誤検知に気づけない。 + +| # | 状況 | 期待 | 結果 | +|---|---|---|---| +| 1 | agent が `work/` だけを変更 | 通過 (exit 0) | 一致 | +| 2 | `autonomy-config.toml` を改ざん (閾値の自己引き上げ) | 検知 (exit 1) | 一致 | +| 3 | ゲート exe を改ざん | 検知 (exit 1) | 一致 | +| 4 | 基準値ファイルごと削除 (検知器への攻撃) | 検知 (exit 1) | 一致 | + +#4 は `sha256sum -c` が基準値ファイルを読めない時点で失敗するため、fail-closed が自然に成立する。加えて基準値は `$RUNNER_TEMP` = ワークスペース外にあり agent のサンドボックスが届かないため、防御は 2 層になっている。 + +### 実走スモーク — **未実施** + +**本 ADR の受け入れ基準のうち実走スモークは、この PR の時点では満たしていない。** 実行には (a) 本 workflow が master に存在するか対象ブランチ ref へ `workflow_dispatch` できること、(b) 台帳に無人可マークがあること (= WP-18 PR 2 のマージ) の両方が要る。 + +反復は [ADR-067](adr-067-phase-b-unattended-fix-push.md) § 段 2 の知見 2 に従い、**マージせずブランチ ref への `workflow_dispatch`** で行う。`dry_run` 入力でゲート通過まで走らせて push を止められるようにしてある。 + +スモークで同梱観測する項目: + +| 観測項目 | 出所 | +|---|---| +| WP-17 残課題: Phase B の自動起動経路が成立するか | [ADR-067](adr-067-phase-b-unattended-fix-push.md) § 検証記録 | +| WP-17 残課題: `coderabbitai[bot]` allowlist の要否 | 同上 | +| **`cargo` サブプロセスから `CLAUDE_CODE_OAUTH_TOKEN` / `GITHUB_TOKEN` が見えるか** | pre-push security review の warning | +| 決定 7 の照合が実 runner 上でも通ること (誤検知で毎晩止まらないこと) | 本 ADR § integrity 機構の drill | +| **App token で作った draft PR に `ci.yml` の 2 OS run が紐づくこと** | 決定 8 (仕様は公式で確認済み、実環境での成立は未観測) | +| Actions variable `AUTONOMY_ENABLED` が `true` ちょうどで設定されており job が起動すること | ADR-066 § 決定 2 (完全一致要件) | +| `claude/nightly-*` の **ref 作成**が App token で通ること (ruleset の除外が creation にも効くこと) | ADR-067 段 0 の ruleset。Phase B が観測したのは既存ブランチへの update のみ | +| `publish/` の clone + rsync が実 runner で成立し、`work/` の変更が過不足なく運ばれること | 決定 9 (`--delete` による削除の反映を含む) | + +3 行目は決定 5 で Bash を落とした後も残る経路の確認である。agent は `cargo` を直接叩けないが、`work/` へ書いた `build.rs` は `Verify deterministically` step の `cargo test` が実行する。同 step に `env:` を置いていないことがトークン非露出の根拠なので、**その前提が実 runner で成立するか**を使い捨ての `build.rs` から `env | grep -i token` を出して実測する。 + +この観測は 2 つの判断に効く。露出があれば `Verify` step の env に明示的な scope 制限が要る。露出が無ければ、決定 5 で保守側に置いた **Bash の再付与 (agent が自分の変更を検証できる利点) を再検討してよい** — 決定 5 の根拠は「資格情報のある環境で agent の書いたコードを走らせない」ことなので、そもそも資格情報が届いていないなら前提が変わる。 + +## 帰結 + +### 利点 + +- Phase B の発火機会の少なさ ([ADR-067](adr-067-phase-b-unattended-fix-push.md) § 欠点) が解消される。夜間ループが `claude/` ブランチの PR を作り始めれば、Phase B はその PR の CodeRabbit 指摘を拾って動く。 +- 滞留タスクの消化が人間の着手時間から切り離される。失敗しても draft PR を閉じるだけで済む。 +- 停止操作を増やしていない。緊急時の反射は [ADR-066](adr-066-autonomy-global-kill-switch.md) から変わらない。 +- 選択が決定論なので、失敗した run を同じ入力で再現できる。 + +### 欠点 / 留意点 + +- **禁止リストは許可リストより弱い**。列挙し忘れたガードレールは守られない。台帳を機械可読にして allowlist へ移行するまでの過渡的な設計である。 +- **agent は自分の変更を検証できない**。決定 5 で Bash を落としたため、コンパイルも通らない diff を書く run が一定数出る。無駄な run 1 回で済む設計にしてあるが、採用率 (§ 試験運用判断基準) はこのぶん下がる。 +- **`work/` に書かれた `build.rs` は `Verify` step で実行される**。agent 自身は Bash を持たないが、`cargo test` はビルドスクリプトを走らせる。同 step は `env:` を持たないためトークンには到達せず、`master-ref/` への書き込みは決定 7 の照合が後段で捕捉する (照合を verify の**後**に置いてあるのはこのため) が、ネットワーク送信自体は塞いでいない。public リポジトリの内容しか送れないことを受容の根拠としている。 +- **agent の file tools が `work/` へ scope されていない**。`--allowedTools` は `Read,Edit,Write,Glob,Grep` を無制限に与えており、`work/` 限定はプロンプトの文言にすぎない。決定 7 の照合はゲート資産の改ざんを**検知**するが、`master-ref/` への書き込み自体を**防止**しない。`cargo test` の build script 経由で書き込む経路も同様に検知側で受けている (照合は verify step の後に置いてある)。 +- **App token の秘密鍵が新しい保護対象になった**。`NIGHTLY_APP_PRIVATE_KEY` が漏れると、`claude/**` への push と PR 作成が任意に行える (ruleset により `claude/**` 以外へは push できず、マージもできない)。GITHUB_TOKEN より寿命の長い資格情報をリポジトリに置くことになる点は、CI を PR へ紐づける対価として受け入れた。 +- **App token の導入は publish step を最も価値の高い標的に変えた**。以前はどの step も write 資格情報を持たなかったが、いまは publish step の env に live な Contents:write / PR:write がある。決定 6 § `work/.git/` の hook / filter 検査はこの step で発火しうる実行面を塞ぐために置いたもので、**資格情報を足すと、その step で何が実行されうるかを洗い直す必要がある**という一般則の実例になっている。 +- **pr-monitor の Phase A は夜間 draft PR で自動起動しない可能性がある**。決定 8 で `ci.yml` は走るようになったが、Phase A の起動条件は `issue_comment` / `pull_request_review` であり、CodeRabbit のコメントが来て初めて起動する。CodeRabbit は GitHub App なので App token 作成の PR にも反応するはずだが、これは実走で確認する。 +- **採用率 50% は根拠のある閾値ではない**。「半分が使い物にならないなら対象クラスの選び方が間違っている」という直感でしかなく、2 週間のサンプル数 (最大 14 件、背圧で実際にはもっと少ない) では統計的な意味を持たない。 + +### 残課題 + +- **実走スモークの完走** (§ 検証記録)。本 ADR の受け入れ基準の中核であり、未実施。 +- **`master-ref/` を agent のファイルシステムから外す**。決定 7 は検知どまりで、防止には別 job + artifact 受け渡しへの構造変更が要る。実走スモークで agent が実際にワークスペース外へ手を伸ばすか観測してから判断する。 +- **authority gate の直前で draft 数を再計数するか**。現状は job 冒頭のスナップショットを使い回す (§ 決定 4)。閾値を 1 件超えて push される事象が実運用で観測されたら入れる。 +- **ガードレール禁止リストの allowlist 化**。台帳の「対象ファイル」列を機械可読にする (別列に正規化パスを持つ等) のが前提。 +- **禁止リストが YAML に埋まっている**。`cli-nightly-task-select` や専用 exe へ移せば unit test で固定できるが、現状は workflow step の `grep` で、回帰テストが無い。リストが育つようなら extract する ([ADR-044](adr-044-subprocess-utility-extraction-boundary.md) 層 1 の判断基準に従う)。 +- **失敗した run の学習が無い**。同じタスクで 3 晩失敗しても 4 晩目に同じことを試す。連続失敗の検出と自動除外は WP-19 ステップ 3 の監査ループで扱う。 diff --git a/docs/harness-improvement-plan.md b/docs/harness-improvement-plan.md index 31962a60..d33d986d 100644 --- a/docs/harness-improvement-plan.md +++ b/docs/harness-improvement-plan.md @@ -80,7 +80,7 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 | WP-15 | 3 | Linux バイナリビルド + クラウド setup script | M | WP-13, 14 | 完了([ADR-063](adr/adr-063-linux-portability-release-binaries.md)。クラウド実測は [ADR-060](adr/adr-060-cloud-harness-sessionstart-dispatcher.md) dogfood で達成、以降は ADR-060 の bounded lifetime で管理。追補の陽性証拠設計は [ADR-064](adr/adr-064-monitor-success-positive-evidence.md) → park 実観測は § 残作業) | | WP-16 | 3 | CI matrix(移植退行防止) | S | WP-13, 14 | 観測中([ADR-065](adr/adr-065-ci-matrix-cross-os-regression.md)。2 OS matrix は PR #342 でマージ済・master 稼働中、初回観測期間に実バグ 1 件捕捉(PR #344 で修正)。観測継続と required check 化は → § 残作業) | | WP-17 | 4 | イベント駆動バックボーン完成(Phase B + routines 移行 + 全体 kill-switch 前倒し) | M-L | WP-09, 10, 11 | **観測中(実装は 2026-08-04 に全 land)** — #347 / #350 / #351 / #352 / #353 / #354、実走バグ修正 #356 / #357 / #358、記帳 #359。実走スモーク段 0〜2 まで完走。**観測待ち**: 停止側の実走 2 点 / 自動起動経路 / 週末またぎ / ADR-066 bounded lifetime(1 of 3〜5 run)→ § WP-17。派生 ADR: [ADR-068](adr/adr-068-fix-step-authority-boundary.md) #348 / [ADR-069](adr/adr-069-pr-chain-declaration.md) #349 | -| WP-18 | 4 | 夜間 todo 消化ループ | M-L | WP-15, 17 | **着手中** — PR 1(背圧 + [ADR-071](adr/adr-071-draft-pr-backpressure.md))実装済み(2026-08-06)。PR 2(タスク台帳 = [claude-code-web-tasks.md](claude-code-web-tasks.md))/ PR 3(夜間 schedule workflow)未着手 → § WP-18 | +| WP-18 | 4 | 夜間 todo 消化ループ | M-L | WP-15, 17 | **実装済(2026-08-06)** — PR 1 = 背圧 + [ADR-071](adr/adr-071-draft-pr-backpressure.md)(#361)/ PR 2 = タスク台帳(#362)/ PR 3 = 夜間 workflow + [ADR-072](adr/adr-072-nightly-todo-loop.md)。**観測待ち**: 実走スモーク(未実施、受け入れ基準の中核)→ 採用率 2 週間測定 → § WP-18 | | WP-19 | 4 | 常時性ガード(自主減速 / 監査ループ。全体 kill-switch は WP-17 PR 1、背圧は WP-18 PR 1 へ前倒し) | S-M | WP-18 | 未着手(残りは監査ループのみ。背圧は WP-18 PR 1 で land 済み) | ## 5. 残作業(観測継続) @@ -154,11 +154,11 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 - **routine 出力の受け渡し手段が未決**: 分析結果が transcript にしか残らずユーザーが読まなければ消える。実行主体を含む 3 択(routine / GitHub Actions schedule / ローカル維持 = 断念)で、**断念も正規の出口**。判定は ADR-070 bounded lifetime (b) の観測後([ADR-070](adr/adr-070-weekly-review-cloud-routine.md) § 残課題)。 - **Phase B の実効価値は WP-18 に依存**: 対象が docs 指摘に限られるため、WP-18 の夜間ループが `claude/` ブランチ PR を作り始めるまで発火機会が小さい(ADR-067 § 欠点)。 -### WP-18: 夜間 todo 消化ループ — 着手中(PR 1 実装済み、2026-08-06) +### WP-18: 夜間 todo 消化ループ — 実装済(2026-08-06、実走スモーク待ち) > 夜間に 1 タスクを無人実装し **draft PR 作成で停止**する(マージ判断は人間)ループを、WP-17 のバックボーン上に組む。 -**進捗**: PR 1(背圧)実装済 → PR 2(タスク台帳)未着手 → PR 3(夜間 workflow)未着手。 +**進捗**: PR 1(背圧、[#361](https://github.com/aloekun/claude-code-hook-test/pull/361))/ PR 2(タスク台帳、[#362](https://github.com/aloekun/claude-code-hook-test/pull/362))/ PR 3(夜間 workflow)すべて実装済み(2026-08-06)。**残るのは実走スモークのみ** — 受け入れ基準の中核であり未実施(→ § 受け入れ基準)。 - **着手前決定(2026-08-05、ユーザー確認済み)**: 1. **実行主体 = GitHub Actions schedule workflow**(cloud routine ではない)。根拠: [ADR-070](adr/adr-070-weekly-review-cloud-routine.md) § 実現可能性の未検証点の実測 — routine の `jj git push` はローカル hook(`jj-push-guard`)に阻まれ、例外新設は「自律 push 経路の新設」= 採用バー超え。Actions は workflow step が push する Phase B([ADR-067](adr/adr-067-phase-b-unattended-fix-push.md))と同構造でこの問題が発生せず、`claude/` prefix ブランチは ruleset 除外とも整合する。 @@ -167,21 +167,37 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 - **PR 構成(新規 3 本)**: 1. **PR 1: 背圧実装 + ADR 起票(M)— 実装済み(2026-08-06、[ADR-071](adr/adr-071-draft-pr-backpressure.md))**。閾値判定の層は実装時判断で **(a) gate 内**を採った(`GateInputs` に実測値と閾値を渡す)。`Operation::backpressure_connected()` は廃し、`requires_draft_backpressure()`(指標の要求のみ・状態を持たない)と `GateInputs::{open_draft_prs, max_open_draft_prs}`(状態)へ分けて二重管理を避けた。閾値は `autonomy-config.toml` の `[autonomy] max_open_draft_prs = 3`。実 exe による drill 12 シナリオと unit test 40 件で実測を固定済み。SaaS 課金・上限事実(§ 2)の最新値再確認と永続化も同 ADR で完了。 - 2. **PR 2: タスク台帳のブラッシュアップ(docs、S)** — [claude-code-web-tasks.md](claude-code-web-tasks.md) の stale 行検証(land 済みタスクの除去)、無人実行可マークの追加(Web 実行可 = 人間が対話で補助できる、無人可 = 補助なしで完結、の 2 段階。最初は 5〜10 件だけ人間がマーク)、lifecycle を ephemeral から定期更新台帳へ改訂、weekly-review パイプラインへ台帳更新手順を接続。 - 3. **PR 3: 夜間 workflow(schedule、M-L)** — タスク選択(台帳の機械判定・fail-closed)→ 実装 → `cargo test` 検証 → draft PR 作成。**push / PR 作成は workflow step が gate 経由で実行し、agent は push の主体にしない**(ADR-067 と同型)。**実走スモーク段を受け入れ基準に含める**([dev-conventions](dev-conventions.md) § LLM を含む自動化経路は実走でしか検証できない)。スモークで WP-17 残課題 2 件(Phase B 自動起動経路の実測 / `coderabbitai[bot]` allowlist 要否)も同梱観測する(ADR-067 § 検証記録に「WP-18 着手時に実測」と記帳済み)。 + 2. **PR 2: タスク台帳のブラッシュアップ(docs、S)— 実装済み(2026-08-06、[#362](https://github.com/aloekun/claude-code-hook-test/pull/362))**。stale 行 2 件(順位 120 / 134、どちらも land 済み)を削除し棚卸し履歴 section を新設。無人可の 2 段階分類を導入して 14 件中 7 件をユーザー承認のうえマーク(見送り 7 件も理由を表で明示)。lifecycle は「空になっても retire しない」定期更新台帳へ改訂。weekly-review への接続は新 step を足さず既存の観点⑤(`review-todo-whole` facet)に Criterion 3 として相乗りさせた。 + 3. **PR 3: 夜間 workflow(schedule、M-L)— 実装済み(2026-08-06、[ADR-072](adr/adr-072-nightly-todo-loop.md))、ただし実走スモークは未実施**。タスク選択は新規 exe `cli-nightly-task-select`(実装時判断で shell ではなく Rust を採用 — markdown table の境界に回帰テストを書く場が要るため)。`.github/workflows/nightly-todo.yml` が 17 step で選択 → 実装 → コストフィルタ(`cargo test` + `cargo clippy`)→ **clean publish tree の用意** → ガードレール禁止リスト → **ゲート資産の改ざん検知** → gate → **App token 発行** → draft PR 作成を回す。schedule は毎日 03:00 JST(2026-08-06 ユーザー確認)。**push / PR 作成は workflow step が gate 経由で実行し、agent は push の主体にしない**(ADR-067 と同型)。 -- **PR chain 宣言([ADR-069](adr/adr-069-pr-chain-declaration.md) 決定 1)**: PR 1 が導入した以下は **PR 3 まで呼び手を持たない**。リポジトリ内の自動化経路に `--open-draft-prs` を渡す呼び手が 1 つも無いため、PR 1 単体では `draft-pr` が許可される経路が動かず運用挙動は変わらない(exe を手動実行して 3 拠点をすべて満たせば allow になる = drill として意図した挙動。[ADR-071](adr/adr-071-draft-pr-backpressure.md) § 残課題)。 + 改ざん検知と clean publish tree はいずれも pre-push security review の REJECT を受けて追加した([ADR-072](adr/adr-072-nightly-todo-loop.md) 決定 7 / 決定 9)。後者は「agent が `work/.git` を書き換えると App token を持つ step で任意コマンドが走る」経路への対処で、危険な設定キーの列挙(deny-list)で 2 回連続レビュー漏れを指摘されたため、**Implement 終了後に新規 clone した作業ツリーで git 操作を行う**構造へ切り替えた。**App token は Windows CI を draft PR へ紐づけるために導入した**(同 決定 8)— `GITHUB_TOKEN` で作成した PR は `pull_request` run が承認待ちになり、人間が Approve するまで `ci.yml` が動かない。Windows を主開発環境とする本プロジェクトで、2 OS 検証を人間の操作待ちにする設計は採れないため(2026-08-07 ユーザー判断)。PAT ではなく App を使うのは、オーナーの PAT が Repository admin として ADR-067 の ruleset backstop を bypass してしまうため。 - | PR 1 が導入するもの | PR 3 の消費側 | + これに伴い workflow 内の検証は**コストフィルタ**(ubuntu 単独、無駄な draft PR を作らないための足切り)と位置づけ直し、**品質の保証は PR に紐づく `ci.yml`(2 OS)**が担う形にした。 + +- **PR chain 宣言([ADR-069](adr/adr-069-pr-chain-declaration.md) 決定 1)— 充足済み**: PR 1 が導入した以下は PR 3 が消費する。PR 3 は PR 1 のブランチ(`feat/draft-pr-backpressure`)にスタックしているため、両者は同一チェーン内で対応が閉じている。 + + | PR 1 が導入するもの | PR 3 の消費側(`.github/workflows/nightly-todo.yml`) | |---|---| - | `cli-autonomy-gate` の `--open-draft-prs ` フラグ | 夜間 workflow(`.github/workflows/` に新設する schedule workflow)の draft PR 作成前 step が、`gh api` で数えた `claude/` prefix の open かつ draft な PR 件数をこのフラグへ渡す | - | `autonomy-config.toml` の `[autonomy] max_open_draft_prs` | 同 workflow が `--config` に渡す master ref の写しを通じて `cli-autonomy-gate` が読む(kill-switch の `enabled` と同じ経路・同じファイル) | - | `lib_autonomy_policy::Operation::DraftPr` の許可経路 | 同 step が `cli-autonomy-gate --operation draft-pr` を実行し、exit 0 のときだけ後続の PR 作成 step へ進む | + | `cli-autonomy-gate` の `--open-draft-prs ` フラグ | `Pre-flight gate` / `Gate draft PR creation` の 2 step が、`Count open claude/ drafts and in-flight ranks` step の `gh pr list` 結果(`isDraft` かつ `claude/` prefix の件数)を渡す | + | `autonomy-config.toml` の `[autonomy] max_open_draft_prs` | 同 2 step が `--config master-ref/autonomy-config.toml` を渡し、master ref の写しから読ませる(kill-switch の `enabled` と同じ経路・同じファイル) | + | `lib_autonomy_policy::Operation::DraftPr` の許可経路 | 同 2 step が `--operation draft-pr` で呼び、`Gate draft PR creation` が exit 0 のときだけ `Push branch and open draft PR` へ進む | この順序は逆にできない。[ADR-052](adr/adr-052-autonomy-execution-boundary-classes.md) 原則 5 が背圧の接続を draft-pr クラス有効化の**前提条件**としているため、背圧が先に land する必要がある(WP-17 の kill-switch 先行と同じ構造)。 + **PR 2 → PR 3 の実行時依存**: PR 3 の workflow は台帳の「無人可」列を読む。PR 2 が未マージのままだと `cli-nightly-task-select` は exit 2(無人可 列を持つ表が無い)で止まる。**これは設計どおりの fail-closed** で、静かな no-op にはならない([ADR-072](adr/adr-072-nightly-todo-loop.md) § 検証記録の実データ確認)。PR 3 のコードは PR 2 に依存しないため、CI は独立に green になる。 + - **運用ノート**: クラウドは使い捨てクローンのため jj workspace 分離は不要。ローカルで同ループを回す場合のみ [ADR-045](adr/adr-045-jj-workspace-parallel-sessions.md) の workspace を使う。稼働後 1 週間は run 頻度と Max 枠消費を観測して頻度調整。 -- **受け入れ基準**: 2 週間の試験運用で無人 draft PR の採用率(人間がマージした割合)を測定。**50% 超で継続・拡大、未満なら対象クラスを絞って再試行**。測定は weekly-review の自律アクション棚卸し(WP-19 ステップ 3)に載せて仕組み化する。 +- **受け入れ基準**: + + | 基準 | 状態 | + |---|---| + | 背圧の決定論層 drill(12 シナリオ、実 exe) | **充足**(PR 1、[ADR-071](adr/adr-071-draft-pr-backpressure.md) § 検証記録) | + | タスク選択の境界固定(unit test 25 件 + 実データ選択) | **充足**(PR 3、[ADR-072](adr/adr-072-nightly-todo-loop.md) § 検証記録) | + | **実走スモーク — 有効時のみ `claude/nightly-*` の draft PR が作られること** | **未実施**。実行には (a) workflow が master にあるか対象 ref へ `workflow_dispatch` できること、(b) 台帳に無人可マークがあること(= PR 2 のマージ)が要る。反復は [ADR-067](adr/adr-067-phase-b-unattended-fix-push.md) 段 2 の知見 2 に従い**マージせず ref 指定の dispatch** で行う(`dry_run` 入力あり) | + | スモークの同梱観測 8 項目(内訳は [ADR-072](adr/adr-072-nightly-todo-loop.md) § 実走スモークの表を参照。主なものは **App token 作成 PR に `ci.yml` の 2 OS run が紐づくこと**、`AUTONOMY_ENABLED` の設定、`claude/nightly-*` の **ref 作成**が通ること、WP-17 残課題 2 件) | **未実施**(一覧は [ADR-072](adr/adr-072-nightly-todo-loop.md) § 実走スモークの表が正。WP-17 の 2 件は ADR-067 § 検証記録に「WP-18 着手時に実測」と記帳済み) | + | **WP 全体**: 2 週間の試験運用で無人 draft PR の採用率(人間がマージした割合)を測定。**50% 超で継続・拡大、未満なら対象クラスを絞って再試行** | **未着手**(スモーク完走後に開始)。測定は weekly-review の自律アクション棚卸し(WP-19 ステップ 3)に載せて仕組み化する | + + なお採用率 50% は根拠のある閾値ではなく、2 週間・最大 14 件(背圧により実際はより少ない)では統計的な意味を持たない([ADR-072](adr/adr-072-nightly-todo-loop.md) § 欠点)。判断材料の 1 つとして扱う。 ### WP-19: 常時性ガード diff --git a/package.json b/package.json index ab480c21..fabcc86e 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,9 @@ "build:cli-telemetry-report": "cargo build --release -p cli-telemetry-report && node scripts/deploy-artifacts.mjs cli-telemetry-report", "build:cli-autonomy-gate": "cargo build --release -p cli-autonomy-gate && node scripts/deploy-artifacts.mjs cli-autonomy-gate", "build:cli-fix-push-gate": "cargo build --release -p cli-fix-push-gate && node scripts/deploy-artifacts.mjs cli-fix-push-gate", + "build:cli-nightly-task-select": "cargo build --release -p cli-nightly-task-select && node scripts/deploy-artifacts.mjs cli-nightly-task-select", "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: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: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", diff --git a/src/cli-nightly-task-select/Cargo.toml b/src/cli-nightly-task-select/Cargo.toml new file mode 100644 index 00000000..2b45fcb8 --- /dev/null +++ b/src/cli-nightly-task-select/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cli-nightly-task-select" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "cli-nightly-task-select" +path = "src/main.rs" + +# 依存なし。台帳の markdown table を読むだけで、外部 crate を必要としない。 +# 依存を足さないこと自体が設計上の制約 — 本 exe は夜間ループの唯一の「何を実装するか」 +# 決定者であり、供給元が増えるほど無人経路の攻撃面が広がる。 + +# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/cli-nightly-task-select/src/ledger.rs b/src/cli-nightly-task-select/src/ledger.rs new file mode 100644 index 00000000..e4f45e9b --- /dev/null +++ b/src/cli-nightly-task-select/src/ledger.rs @@ -0,0 +1,486 @@ +//! 台帳 (`docs/claude-code-web-tasks.md`) から無人可タスクを 1 件選ぶ純粋層。 +//! +//! I/O を行わない。ファイルの読み取りは [`crate::main`] 側が担い、本 module は読み取り済みの +//! markdown 文字列と除外順位だけを受け取って選択結果を返す。 +//! +//! # なぜ LLM ではなくパーサが選ぶのか +//! +//! [ADR-052](../../../docs/adr/adr-052-autonomy-execution-boundary-classes.md) は「分類ロジックを +//! Rust 分類関数を用意せず自律 actor の実行時 LLM 判断に委ねる」ことをアンチパターンとして +//! 挙げている。夜間ループで「何を実装するか」は自律動作の起点であり、ここが揺れると +//! 下流のゲートがいくら堅くても「意図しないタスクを正しく実装した draft PR」が出てくる。 +//! +//! # 曖昧さはすべて停止側へ +//! +//! 台帳は人間が手で編集する markdown で、列ずれ・順位の重複・未知のマーク表記が起こりうる。 +//! 本 module はそれらを**エラー**として返し、呼び手が exit 2 で止める。「解釈できなかった行を +//! 読み飛ばして次の候補を選ぶ」ことはしない — 読み飛ばした行が本来の選択対象だった場合、 +//! 夜間ループは黙って別のタスクを実装する。 + +use std::collections::{BTreeMap, BTreeSet}; + +/// 無人可を表すマーク。台帳の表記と一致させる。 +const MARK_AUTONOMOUS: &str = "✅"; + +/// 無人可ではないことを表す表記。これ以外の値は解釈不能としてエラーにする。 +const MARKS_NOT_AUTONOMOUS: &[&str] = &["—", "-", "–", ""]; + +/// 選ばれたタスク。夜間 workflow が agent への指示とブランチ名の組み立てに使う。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Task { + pub rank: u32, + pub summary: String, + pub target_files: String, + pub caution: String, +} + +impl Task { + /// 自律 actor が使うブランチ名。順位を埋め込むのは、次回以降の run が + /// 「このタスクは着手済み」を open PR の一覧だけから機械的に判定できるようにするため。 + pub fn branch(&self) -> String { + format!("claude/nightly-{}", self.rank) + } +} + +/// 無人可の列を持つ表から、除外順位に含まれない最初のタスクを選ぶ。 +/// +/// 文書順の最初を選ぶのは、台帳の表が工数昇順に並んでいるため (Batch 1 が Batch 2 に +/// 先行する)。乱択や最新順にすると run ごとに選択が変わり、失敗の再現ができなくなる。 +/// +/// `Ok(None)` は「表はあるが該当タスクが無い」= 正常な no-op。台帳の形が壊れている場合は +/// `Err` を返し、`Ok(None)` と区別する。 +pub fn select(markdown: &str, excluded_ranks: &BTreeSet) -> Result, String> { + let lines: Vec<&str> = markdown.lines().collect(); + let mut scan = Scan::new(excluded_ranks); + let mut index = 0usize; + while index < lines.len() { + index = match autonomy_table_header(&lines, index)? { + Some(columns) => scan.consume_rows(&lines, index + 2, &columns)?, + None => index + 1, + }; + } + scan.finish() +} + +/// `index` 行が「無人可 列を持つ表のヘッダ行」ならその列位置を返す。 +/// +/// `Ok(None)` = 表のヘッダではない / 無人可 列を持たない表 (台帳には棚卸し履歴のような +/// 無関係な表もある)。区切り行の欠落だけは `Err` にする — ヘッダに見える行の直後が +/// データ行だと、区切り行のつもりで 1 行読み飛ばして先頭タスクを取りこぼす。 +fn autonomy_table_header(lines: &[&str], index: usize) -> Result, String> { + if !is_table_row(lines[index]) { + return Ok(None); + } + let Some(columns) = header_columns(&split_cells(lines[index])) else { + return Ok(None); + }; + let columns = columns?; + if index + 1 >= lines.len() || !is_separator_row(lines[index + 1]) { + return Err(format!( + "{} 行目: 無人可 列を持つヘッダ行の直後に区切り行がありません", + index + 1 + )); + } + Ok(Some(columns)) +} + +/// 走査の途中状態。表をまたいで順位の重複を検出するため、表ごとにリセットしない。 +struct Scan<'a> { + excluded_ranks: &'a BTreeSet, + tables_scanned: usize, + ranks_seen: BTreeMap, + selected: Option, +} + +impl<'a> Scan<'a> { + fn new(excluded_ranks: &'a BTreeSet) -> Self { + Self { + excluded_ranks, + tables_scanned: 0, + ranks_seen: BTreeMap::new(), + selected: None, + } + } + + /// 区切り行の次から表の終端までを読み、終端の行 index を返す。 + fn consume_rows( + &mut self, + lines: &[&str], + mut index: usize, + columns: &Columns, + ) -> Result { + self.tables_scanned += 1; + while index < lines.len() && is_table_row(lines[index]) { + self.take_row(&split_cells(lines[index]), columns, index + 1)?; + index += 1; + } + Ok(index) + } + + fn take_row( + &mut self, + cells: &[String], + columns: &Columns, + line_number: usize, + ) -> Result<(), String> { + let (task, eligible) = parse_row(cells, columns, line_number)?; + if let Some(previous) = self.ranks_seen.insert(task.rank, line_number) { + return Err(format!( + "順位 {} が {previous} 行目と {line_number} 行目に重複しています (どちらを実装すべきか決まりません)", + task.rank + )); + } + if eligible && self.selected.is_none() && !self.excluded_ranks.contains(&task.rank) { + self.selected = Some(task); + } + Ok(()) + } + + fn finish(self) -> Result, String> { + if self.tables_scanned == 0 { + return Err( + "無人可 列を持つ表が 1 つも見つかりません (台帳の構成が変わった可能性があります)" + .to_string(), + ); + } + Ok(self.selected) + } +} + +/// 無人可の表で使う列の位置。 +struct Columns { + rank: usize, + mark: usize, + summary: usize, + target_files: usize, + caution: Option, +} + +impl Columns { + /// 行が必要な列をすべて含むだけの長さを持つか。 + fn max_index(&self) -> usize { + [self.rank, self.mark, self.summary, self.target_files] + .into_iter() + .chain(self.caution) + .max() + .unwrap_or(0) + } +} + +/// ヘッダ行から列位置を取る。 +/// +/// `None` = 無人可 列が無い = 選択対象の表ではない (台帳には棚卸し履歴など無関係な表もある)。 +/// `Some(Err)` = 無人可 列はあるのに他の必須列が欠けている = 台帳の破損。 +fn header_columns(cells: &[String]) -> Option> { + let position = |name: &str| cells.iter().position(|c| c == name); + let mark = position("無人可")?; + let Some(rank) = position("順位") else { + return Some(Err("無人可 列を持つ表に 順位 列がありません".to_string())); + }; + let Some(summary) = position("内容") else { + return Some(Err("無人可 列を持つ表に 内容 列がありません".to_string())); + }; + let target_files = match resolve_target_files_column(cells) { + Ok(index) => index, + Err(message) => return Some(Err(message)), + }; + Some(Ok(Columns { + rank, + mark, + summary, + target_files, + caution: position("注意"), + })) +} + +/// 「対象ファイル」列を前方一致で探す。 +/// +/// 実表記は「対象ファイル」と「対象ファイル (実パス)」の 2 種があるため、他の列のような +/// 完全一致では docs/claude-code-web-tasks.md の Batch 2 表を読めなくなる。前方一致が +/// 複数ヒットした場合は最初の 1 つを黙って選ばずエラーにする — 将来「対象ファイル案」の +/// ような列が先に追加されると、誤った列を agent の指示に使ってしまうため。 +fn resolve_target_files_column(cells: &[String]) -> Result { + let matches: Vec = cells + .iter() + .enumerate() + .filter(|(_, c)| c.starts_with("対象ファイル")) + .map(|(i, _)| i) + .collect(); + match matches.as_slice() { + [] => Err("無人可 列を持つ表に 対象ファイル 列がありません".to_string()), + [only] => Ok(*only), + _ => Err(format!( + "無人可 列を持つ表に 対象ファイル で始まる列が {} 個あり、どれを使うべきか決まりません", + matches.len() + )), + } +} + +/// データ行を 1 行解釈する。戻り値の bool は「無人可マークが付いているか」。 +fn parse_row( + cells: &[String], + columns: &Columns, + line_number: usize, +) -> Result<(Task, bool), String> { + if cells.len() <= columns.max_index() { + return Err(format!( + "{line_number} 行目: 列数が足りません (必要 {} 列、実際 {} 列)", + columns.max_index() + 1, + cells.len() + )); + } + let rank: u32 = cells[columns.rank].parse().map_err(|_| { + format!( + "{line_number} 行目: 順位 {:?} を整数として読めません", + cells[columns.rank] + ) + })?; + let mark = cells[columns.mark].as_str(); + let eligible = if mark == MARK_AUTONOMOUS { + true + } else if MARKS_NOT_AUTONOMOUS.contains(&mark) { + false + } else { + return Err(format!( + "{line_number} 行目: 無人可 列の値 {mark:?} を解釈できません (受理値: {MARK_AUTONOMOUS:?} または {MARKS_NOT_AUTONOMOUS:?})" + )); + }; + Ok(( + Task { + rank, + summary: cells[columns.summary].clone(), + target_files: cells[columns.target_files].clone(), + caution: columns + .caution + .map(|i| cells[i].clone()) + .unwrap_or_default(), + }, + eligible, + )) +} + +fn is_table_row(line: &str) -> bool { + line.trim_start().starts_with('|') +} + +fn is_separator_row(line: &str) -> bool { + let cells = split_cells(line); + !cells.is_empty() + && cells + .iter() + .all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':')) +} + +/// markdown table の 1 行をセルへ分解する。`\|` は文字としてのパイプとして扱う。 +fn split_cells(line: &str) -> Vec { + let trimmed = line.trim(); + let inner = trimmed.strip_prefix('|').unwrap_or(trimmed); + let inner = inner.strip_suffix('|').unwrap_or(inner); + let mut cells = Vec::new(); + let mut current = String::new(); + let mut escaped = false; + for ch in inner.chars() { + if escaped { + if ch != '|' { + current.push('\\'); + } + current.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '|' { + cells.push(current.trim().to_string()); + current.clear(); + } else { + current.push(ch); + } + } + if escaped { + current.push('\\'); + } + cells.push(current.trim().to_string()); + cells +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ledger(rows: &str) -> String { + format!("# 台帳\n\n| 順位 | Tier | 無人可 | 内容 | 対象ファイル (実パス) | 工数 | 注意 |\n|---|---|---|---|---|---|---|\n{rows}\n") + } + + fn none() -> BTreeSet { + BTreeSet::new() + } + + fn excluding(ranks: &[u32]) -> BTreeSet { + ranks.iter().copied().collect() + } + + #[test] + fn selects_the_first_marked_row_in_document_order() { + let markdown = ledger( + "| 284 | T2 | — | パーステスト | a.rs | XS | 注意 A |\n\ + | 203 | T2 | ✅ | secret テスト | b.rs | XS | 注意 B |\n\ + | 240 | T2 | ✅ | eprintln 追加 | c.rs | XS | 注意 C |", + ); + let task = select(&markdown, &none()).expect("parse").expect("selected"); + assert_eq!(task.rank, 203); + assert_eq!(task.summary, "secret テスト"); + assert_eq!(task.target_files, "b.rs"); + assert_eq!(task.caution, "注意 B"); + } + + /// 除外順位は「その順位の draft PR が既に開いている」を意味する。飛ばして次を選ぶ。 + #[test] + fn skips_excluded_ranks() { + let markdown = ledger( + "| 203 | T2 | ✅ | secret テスト | b.rs | XS | - |\n\ + | 240 | T2 | ✅ | eprintln 追加 | c.rs | XS | - |", + ); + let task = select(&markdown, &excluding(&[203])) + .expect("parse") + .expect("selected"); + assert_eq!(task.rank, 240); + } + + /// 全候補が除外済み = 正常な no-op。エラーではない。 + #[test] + fn all_candidates_excluded_is_none_not_error() { + let markdown = ledger( + "| 203 | T2 | ✅ | secret テスト | b.rs | XS | - |\n\ + | 240 | T2 | ✅ | eprintln 追加 | c.rs | XS | - |", + ); + assert_eq!(select(&markdown, &excluding(&[203, 240])).expect("parse"), None); + } + + #[test] + fn no_marked_row_is_none_not_error() { + let markdown = ledger("| 284 | T2 | — | パーステスト | a.rs | XS | - |"); + assert_eq!(select(&markdown, &none()).expect("parse"), None); + } + + /// 無人可 列を持つ表が複数あっても文書順で連結して扱う (Batch 1 → Batch 2)。 + #[test] + fn scans_every_table_that_has_the_mark_column() { + let markdown = format!( + "{}\n### Batch 2\n\n| 順位 | Tier | 無人可 | 内容 | 対象ファイル | 工数 | 注意 |\n|---|---|---|---|---|---|---|\n| 216 | T2 | ✅ | lint rule | d.toml | S | - |\n", + ledger("| 284 | T2 | — | パーステスト | a.rs | XS | - |") + ); + let task = select(&markdown, &none()).expect("parse").expect("selected"); + assert_eq!(task.rank, 216); + } + + /// 無人可 列を持たない表 (棚卸し履歴など) は選択対象にしない。 + #[test] + fn tables_without_the_mark_column_are_ignored() { + let markdown = format!( + "## 棚卸し履歴\n\n| 順位 | 節 | 判定 |\n|---|---|---|\n| 120 | 採用タスク | 削除 |\n\n{}", + ledger("| 203 | T2 | ✅ | secret テスト | b.rs | XS | - |") + ); + let task = select(&markdown, &none()).expect("parse").expect("selected"); + assert_eq!(task.rank, 203); + } + + /// 台帳の構成が変わって 無人可 列が消えた場合は「候補ゼロ」ではなくエラー。 + /// 黙って no-op になると、ループが止まっていることに誰も気づかない。 + #[test] + fn missing_mark_column_everywhere_is_an_error() { + let markdown = "| 順位 | Tier | 内容 |\n|---|---|---|\n| 203 | T2 | x |\n"; + assert!(select(markdown, &none()).is_err()); + } + + /// 順位の重複はどちらを実装すべきか決まらないため停止する。 + #[test] + fn duplicate_ranks_are_an_error() { + let markdown = ledger( + "| 203 | T2 | ✅ | secret テスト | b.rs | XS | - |\n\ + | 203 | T2 | — | 別の何か | c.rs | XS | - |", + ); + assert!(select(&markdown, &none()).is_err()); + } + + /// 未知のマーク表記は「無人可ではない」へ倒さずエラーにする。 + /// 「✅ (条件付き)」のような書き足しを黙って無視すると、人間の意図と判定がずれる。 + #[test] + fn unrecognized_mark_values_are_an_error() { + for mark in ["✅ (条件付き)", "yes", "○", "TRUE"] { + let markdown = ledger(&format!( + "| 203 | T2 | {mark} | secret テスト | b.rs | XS | - |" + )); + assert!( + select(&markdown, &none()).is_err(), + "マーク {mark:?} がエラーにならない" + ); + } + } + + #[test] + fn non_numeric_rank_is_an_error() { + let markdown = ledger("| 二〇三 | T2 | ✅ | secret テスト | b.rs | XS | - |"); + assert!(select(&markdown, &none()).is_err()); + } + + /// 列ずれ (セル数不足) は読み飛ばさずエラー。ずれた行の値を別の列として読むと、 + /// 無人可 でない行を無人可 と解釈しうる。 + #[test] + fn short_rows_are_an_error() { + let markdown = ledger("| 203 | T2 | ✅ |"); + assert!(select(&markdown, &none()).is_err()); + } + + #[test] + fn header_without_required_columns_is_an_error() { + let markdown = "| 順位 | 無人可 |\n|---|---|\n| 203 | ✅ |\n"; + assert!(select(markdown, &none()).is_err()); + } + + #[test] + fn header_without_separator_row_is_an_error() { + let markdown = "| 順位 | Tier | 無人可 | 内容 | 対象ファイル | 工数 |\n| 203 | T2 | ✅ | x | y | XS |\n"; + assert!(select(markdown, &none()).is_err()); + } + + /// 「対象ファイル」で始まる列が複数あると、どちらを使うべきか決まらないためエラー。 + /// 前方一致の最初のヒットを黙って採用すると、将来「対象ファイル案」のような列が + /// 先に追加された場合に誤った列を agent の指示に使ってしまう (SIM-NEW-ledger-rs-L865)。 + #[test] + fn ambiguous_target_files_prefix_match_is_an_error() { + let markdown = "| 順位 | Tier | 無人可 | 内容 | 対象ファイル案 | 対象ファイル (実パス) | 工数 |\n\ + |---|---|---|---|---|---|---|\n\ + | 203 | T2 | ✅ | x | draft.rs | real.rs | XS |\n"; + assert!(select(markdown, &none()).is_err()); + } + + /// 実表記の 2 種 (「対象ファイル」と「対象ファイル (実パス)」) はどちらも単独では + /// 曖昧ではないので、従来通り選択できる。 + #[test] + fn plain_target_files_header_still_resolves() { + let markdown = format!( + "| 順位 | Tier | 無人可 | 内容 | 対象ファイル | 工数 | 注意 |\n|---|---|---|---|---|---|---|\n{}\n", + "| 203 | T2 | ✅ | secret テスト | b.rs | XS | - |" + ); + let task = select(&markdown, &none()).expect("parse").expect("selected"); + assert_eq!(task.target_files, "b.rs"); + } + + /// セル内のエスケープされたパイプは区切りとして扱わない。台帳には + /// `Option\` のようなエスケープ表記が実際に含まれる。 + #[test] + fn escaped_pipes_do_not_split_cells() { + let cells = split_cells(r"| 203 | a \| b | c\ |"); + assert_eq!(cells, vec!["203", "a | b", r"c\"]); + } + + #[test] + fn branch_name_embeds_the_rank() { + let task = Task { + rank: 203, + summary: String::new(), + target_files: String::new(), + caution: String::new(), + }; + assert_eq!(task.branch(), "claude/nightly-203"); + } +} diff --git a/src/cli-nightly-task-select/src/main.rs b/src/cli-nightly-task-select/src/main.rs new file mode 100644 index 00000000..5848fe5a --- /dev/null +++ b/src/cli-nightly-task-select/src/main.rs @@ -0,0 +1,243 @@ +//! `cli-nightly-task-select` — 夜間 todo 消化ループのタスク選択ゲート (WP-18 PR 3、ADR-072)。 +//! +//! 台帳 (`docs/claude-code-web-tasks.md`) の「無人可」マークが付いた行から 1 件を決定論的に +//! 選び、夜間 workflow の後続 step が使う値 (順位・ブランチ名・対象ファイル・指示文) を +//! `GITHUB_OUTPUT` 形式で出す。選択そのものは [`ledger`] が持ち、本 exe は CLI 面 +//! (引数解析・loud 出力・exit コード) だけを担う。 +//! +//! # 使い方 +//! +//! ```text +//! cli-nightly-task-select --ledger --exclude-ranks +//! ``` +//! +//! `--exclude-ranks` は「既に draft PR が開いている順位」のカンマ区切り。呼び手 (workflow の +//! `gh api` step) が open な `claude/nightly-*` ブランチから機械的に組み立てる。 +//! **空でも省略はできない** — 空文字は「数えた結果 0 件」、フラグ欠落は「数えられなかった」で +//! 意味が違い、後者は引数不正として止める。ここを省略可能にすると、`gh api` が失敗した run が +//! 「開いている draft は無い」と解釈して同じタスクを毎晩実装し直す。 +//! +//! # exit コード +//! +//! - `0` = タスクを選んだ (stdout に選択結果) +//! - `2` = 引数不正 / 台帳の読み取り・解釈に失敗 (fail-closed) +//! - `3` = 台帳は読めたが該当タスクが無い (正常な no-op) +//! +//! **呼び手は `0` 以外をすべて「実装 step へ進まない」として扱うこと。** `3` と `2` を +//! 区別するのは run log で「何もすることが無かった」と「台帳が壊れている」を分けるためで、 +//! どちらも後続を動かさない点は同じ。 +//! +//! # 出力 +//! +//! 選択の有無にかかわらず loud に出す (無音 no-op 禁止、ADR-064 と同じ論理)。選択は stdout の +//! `[NIGHTLY_TASK]`、no-op と失敗は stderr の `[NIGHTLY_SKIP]`。 + +mod ledger; + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use ledger::Task; + +const MARKER_SELECTED: &str = "[NIGHTLY_TASK]"; +const MARKER_SKIP: &str = "[NIGHTLY_SKIP]"; + +const EXIT_SELECTED: i32 = 0; +const EXIT_USAGE: i32 = 2; +const EXIT_NO_TASK: i32 = 3; + +const USAGE: &str = + "usage: cli-nightly-task-select --ledger --exclude-ranks "; + +fn main() { + std::process::exit(run(std::env::args().skip(1).collect())); +} + +/// コマンドライン設定。既定値は設けない — 両方とも明示必須。 +/// +/// `--ledger` を省略可能にして cwd から推測させないのは、CI で master ref の写しを渡し忘れた +/// 呼び手が PR ブランチの台帳を黙って読むのを防ぐため ([ADR-066](../../../docs/adr/adr-066-autonomy-global-kill-switch.md) +/// § 決定 3 と同じ信頼境界)。台帳は「何を実装してよいか」を決める入力なので、自律 actor が +/// 自分で書き換えた版を読ませてはならない。 +struct Cli { + ledger_path: PathBuf, + excluded_ranks: BTreeSet, +} + +fn parse_args(args: &[String]) -> Result { + let mut ledger_path = None; + let mut excluded_ranks = None; + let mut index = 0; + while index < args.len() { + let flag = args[index].as_str(); + let value = args.get(index + 1); + let take = || value.ok_or_else(|| format!("{flag} の値がありません")); + match flag { + "--ledger" => ledger_path = Some(PathBuf::from(take()?)), + "--exclude-ranks" => excluded_ranks = Some(parse_ranks(take()?)?), + other => return Err(format!("未知の引数です: {other:?}")), + } + index += 2; + } + Ok(Cli { + ledger_path: ledger_path.ok_or_else(|| "--ledger が必要です".to_string())?, + excluded_ranks: excluded_ranks.ok_or_else(|| "--exclude-ranks が必要です".to_string())?, + }) +} + +/// `"203,240"` 形式を解釈する。空文字は空集合 (= 開いている draft PR が無い)。 +/// +/// 数値でない要素は黙って捨てず引数不正にする。`gh api` の失敗出力やヘッダ行が混ざった +/// ままだと、除外すべき順位が除外されず同じタスクを二重に実装する。 +fn parse_ranks(raw: &str) -> Result, String> { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + s.parse::() + .map_err(|_| format!("--exclude-ranks の要素 {s:?} を整数として読めません")) + }) + .collect() +} + +fn run(args: Vec) -> i32 { + let cli = match parse_args(&args) { + Ok(cli) => cli, + Err(message) => return skip(EXIT_USAGE, &format!("引数不正: {message}"), true), + }; + let display = cli.ledger_path.display().to_string(); + let markdown = match std::fs::read_to_string(&cli.ledger_path) { + Ok(text) => text, + Err(e) => return skip(EXIT_USAGE, &format!("台帳を読めません ({display}): {e}"), false), + }; + match ledger::select(&markdown, &cli.excluded_ranks) { + Err(message) => skip( + EXIT_USAGE, + &format!("台帳を解釈できません ({display}): {message}"), + false, + ), + Ok(None) => skip( + EXIT_NO_TASK, + &format!( + "実装可能な無人可タスクがありません (台帳: {display}、除外済み: {} 件)", + cli.excluded_ranks.len() + ), + false, + ), + Ok(Some(task)) => { + report_selected(&task, &display); + EXIT_SELECTED + } + } +} + +/// 後続を動かさないすべての経路。理由を stderr へ 1 行で出す。 +fn skip(code: i32, message: &str, with_usage: bool) -> i32 { + eprintln!("{MARKER_SKIP} {message}"); + if with_usage { + eprintln!("{USAGE}"); + } + code +} + +/// 選択結果を `GITHUB_OUTPUT` へそのまま append できる `key=value` 形式で出す。 +/// +/// 改行を含みうる値 (summary / caution) は heredoc 形式にせず 1 行へ潰す。台帳の 1 セルは +/// 定義上 1 行なので改行は入らないが、万一入っても後続の `>> $GITHUB_OUTPUT` が壊れて +/// 別の key を注入されない形にしておく。 +fn report_selected(task: &Task, ledger_display: &str) { + println!( + "{MARKER_SELECTED} rank={} branch={} ledger={ledger_display}", + task.rank, + task.branch() + ); + println!("rank={}", task.rank); + println!("branch={}", task.branch()); + println!("target_files={}", one_line(&task.target_files)); + println!("summary={}", one_line(&task.summary)); + println!("caution={}", one_line(&task.caution)); +} + +fn one_line(value: &str) -> String { + value + .chars() + .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + fn parses_both_required_flags() { + let cli = parse_args(&args(&["--ledger", "a.md", "--exclude-ranks", "203,240"])) + .expect("parse"); + assert_eq!(cli.ledger_path, PathBuf::from("a.md")); + assert_eq!(cli.excluded_ranks, [203, 240].into_iter().collect()); + } + + /// 空文字は「数えた結果 0 件」。フラグ欠落 (= 数えられなかった) と区別する。 + #[test] + fn empty_exclude_list_is_an_empty_set_not_an_error() { + let cli = parse_args(&args(&["--ledger", "a.md", "--exclude-ranks", ""])).expect("parse"); + assert!(cli.excluded_ranks.is_empty()); + } + + #[test] + fn omitting_either_flag_is_a_usage_error() { + assert!(parse_args(&args(&["--ledger", "a.md"])).is_err()); + assert!(parse_args(&args(&["--exclude-ranks", ""])).is_err()); + assert!(parse_args(&args(&[])).is_err()); + } + + #[test] + fn non_numeric_exclude_entries_are_usage_errors() { + for raw in ["203,abc", "claude/nightly-203", "203;240", "-1"] { + assert!( + parse_args(&args(&["--ledger", "a.md", "--exclude-ranks", raw])).is_err(), + "{raw:?} が引数不正として弾かれない" + ); + } + } + + #[test] + fn surrounding_whitespace_in_the_exclude_list_is_tolerated() { + let cli = parse_args(&args(&["--ledger", "a.md", "--exclude-ranks", " 203 , 240 "])) + .expect("parse"); + assert_eq!(cli.excluded_ranks, [203, 240].into_iter().collect()); + } + + #[test] + fn dangling_and_unknown_flags_are_usage_errors() { + assert!(parse_args(&args(&["--ledger"])).is_err()); + assert!(parse_args(&args(&["--force", "1"])).is_err()); + } + + /// 台帳が存在しない場合は「タスク無し」(3) ではなく入力不正 (2)。 + /// パスを間違えた run が毎晩「何もすることが無い」と報告し続けるのを防ぐ。 + #[test] + fn missing_ledger_file_is_a_usage_error_not_a_no_op() { + let dir = std::env::temp_dir().join("cli-nightly-task-select-absent"); + let path = dir.join("absent.md"); + let code = run(args(&[ + "--ledger", + &path.to_string_lossy(), + "--exclude-ranks", + "", + ])); + assert_eq!(code, EXIT_USAGE); + assert_ne!(EXIT_USAGE, EXIT_SELECTED); + assert_ne!(EXIT_NO_TASK, EXIT_SELECTED); + } + + #[test] + fn newlines_in_cells_cannot_inject_extra_output_keys() { + assert_eq!(one_line("a\nrank=999"), "a rank=999"); + assert_eq!(one_line("a\r\nb"), "a b"); + } +}