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
35 changes: 28 additions & 7 deletions .github/workflows/nightly-todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ jobs:
# 許可リスト方式 — exe が出す key のうち、ここに列挙したものだけを転送する。
# 想定外の key 注入を防ぐ fail-closed だが、**exe 側に出力を足したらここも足す**
# 必要がある (片方だけ変えると新しい出力が黙って捨てられ、参照側は空文字になる)。
grep -E '^(rank|branch|target_files|summary|summary_display|caution)=' "$RUNNER_TEMP/selected.txt" \
grep -E '^(rank|branch|target_files|summary|summary_display|pr_title_display|caution)=' "$RUNNER_TEMP/selected.txt" \
>> "$GITHUB_OUTPUT"

# 出力契約の検証と可観測ログ。**この step には continue-on-error を付けない**
Expand All @@ -188,12 +188,16 @@ jobs:
if: steps.select.outcome == 'success'
run: |
set -euo pipefail
# pr_title_display は**値が空でも行そのものは必ず出る**契約 (空 = 台帳の列が
# 無い/空 → 従来タイトルへフォールバック)。行の存在を検査することで、exe 側の
# 出力ドリフトが「毎晩黙ってフォールバックし続ける」形に化けるのを防ぐ。
if ! grep -qE '^\[NIGHTLY_TASK\]' "$RUNNER_TEMP/selected.txt" \
|| ! grep -qE '^summary_display=' "$RUNNER_TEMP/selected.txt"; then
echo "::error::output-contract violation: cli-nightly-task-select の出力に [NIGHTLY_TASK] または summary_display= が見つかりません (出力フォーマットのドリフトの可能性)。台帳選択は成功しているため no-op ではなく job を止めます。" >&2
|| ! grep -qE '^summary_display=' "$RUNNER_TEMP/selected.txt" \
|| ! grep -qE '^pr_title_display=' "$RUNNER_TEMP/selected.txt"; then
echo "::error::output-contract violation: cli-nightly-task-select の出力に [NIGHTLY_TASK] / summary_display= / pr_title_display= のいずれかが見つかりません (出力フォーマットのドリフトの可能性)。台帳選択は成功しているため no-op ではなく job を止めます。" >&2
exit 1
fi
grep -E '^\[NIGHTLY_TASK\]|^summary_display=' "$RUNNER_TEMP/selected.txt"
grep -E '^\[NIGHTLY_TASK\]|^summary_display=|^pr_title_display=' "$RUNNER_TEMP/selected.txt"

# 本 step は 2 つの経路で発火する — select が非ゼロだった場合と、Pre-flight gate の
# 背圧 deny で select 自体が skip された場合。後者では select の [NIGHTLY_SKIP] 行が
Expand Down Expand Up @@ -493,20 +497,37 @@ jobs:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BRANCH: ${{ steps.select.outputs.branch }}
RANK: ${{ steps.select.outputs.rank }}
# 公開面へ出すのは screening 済みの summary_display だけ (ADR-072 決定 14)。
# 公開面へ出すのは screening 済みの *_display だけ (ADR-072 決定 14)。
# 生の summary はここでは使わない — PR は public repo では第三者に可視で、
# 台帳の自由記述が攻撃者制御文字列の公開面になるため。
#
# **出口ごとに別の screening を通す。** summary_display は本文でコードスパンに
# 囲まれる前提、pr_title_display はタイトルで生テキストとして出る前提で、
# それぞれ `screen_for_public_output` / `screen_for_title` を通っている。
SUMMARY_DISPLAY: ${{ steps.select.outputs.summary_display }}
PR_TITLE_DISPLAY: ${{ steps.select.outputs.pr_title_display }}
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"
# **タイトルは実装内容 + 自動作成の 2 つを示す。** 翌朝 PR 一覧を見た人間が、
# 中身を開かずに「何が入っているか」と「夜間ループ由来か」を判別できるようにする。
# 台帳に「PRタイトル」列が無い / 空の行は従来の形へフォールバックする (移行期間)。
if [ -n "$PR_TITLE_DISPLAY" ]; then
PR_TITLE="${PR_TITLE_DISPLAY} (nightly-todo 順位 ${RANK})"
else
PR_TITLE="feat: 順位 ${RANK} の無人実装 (nightly-todo)"
fi
echo "[NIGHTLY] PR title: ${PR_TITLE}"
# commit 直前に add をやり直さない。stage したのは Guard step で、間に挟まる Gate は
# master-ref/ の exe を実行するだけで publish/ に触れないため index は維持される。
# 再 add しないことが「ゲートが検査した内容そのものを commit する」保証になる。
git -C publish commit -m "feat: 順位 ${RANK} の無人実装 (nightly-todo)"
#
# commit message も PR タイトルに揃える。squash マージでは PR タイトルが採用されるため
# 履歴上は後者が正だが、ブランチを直接見たときに食い違わない方がよい。
git -C publish commit -m "$PR_TITLE"
git -C publish push \
"https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" \
"HEAD:refs/heads/${BRANCH}"
Expand Down Expand Up @@ -534,7 +555,7 @@ jobs:
gh pr create --repo "${{ github.repository }}" \
--base master \
--head "$BRANCH" \
--title "feat: 順位 ${RANK} の無人実装 (nightly-todo)" \
--title "$PR_TITLE" \
--body-file "$RUNNER_TEMP/pr-body.md"

# ここに `Request a CodeRabbit review` step があった (ADR-072 決定 11)。**撤回済み。**
Expand Down
43 changes: 43 additions & 0 deletions docs/adr/adr-072-nightly-todo-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,28 @@ pre-push simplicity review はここを「他の停止点と同様に graceful d

**agent プロンプト側はこの screening を通さない。** あちらが必要とするのは完全なタスク記述で、遮断の責務は決定 12 / 13 が持つ。**同じ文字列でも出口ごとに必要な処理が違う**ため、「安全な summary」1 本に統一していない。screening を Rust に置いたのは、順位 382 の injection payload 回帰テストが固定する対象を作るためでもある (shell に置くとテストの場が無い)。

#### 3 つ目の公開面: PR タイトル (2026-08-11 追加)

PR タイトルに台帳由来テキストを出す変更 (決定 17) で、**3 つ目の公開面**が増えた。ここでも「出口ごとに必要な処理が違う」が効く。

| 公開面 | 囲い | 必要な追加処理 | 関数 |
|---|---|---|---|
| PR 本文 | インラインコードスパン | なし (囲いが無害化する) | `screen_for_public_output` |
| step ログ | 固定プレフィックス付き 1 行 | なし (同上を流用) | `screen_for_public_output` |
| **PR タイトル** | **無し (生テキスト)** | 空白の 1 行化 / `@` の全角化 / 短い長さ上限 | `screen_for_title` |

タイトルはコードスパンにできないため、本文用の screening をそのまま流用すると**囲いが無い状態で `@mention` と改行が素通りする**。`@` を全角 `@` へ置換するのは、「PR タイトルからの mention が通知を飛ばすか」という**外部仕様への依存を消す**ためである (飛ばない仕様だとしても、それに依存した設計にしない)。改行の 1 行化は `--title` の引数が壊れるのを防ぐ。長さは PR 一覧で読む 1 行として 60 文字上限にした (本文用の 200 文字とは別の値)。

**「公開面は塞ぐたびに次が見つかる」という決定 14 の観察がここでも当たった。** 新しい出口を足すときは、既存の screening を流用してよいかを囲いの有無から判断すること。

##### 不可視文字の列挙は「見つけた分を足す」形をやめた (2026-08-11)

決定 13 の framing 検査 (`reject_prompt_frame_escape`) と本 screening は、不可視文字を**個別に列挙**して弾いていた。この形は穴が残る — PR [#389](https://github.com/aloekun/claude-code-hook-test/pull/389) のレビューで **INVISIBLE OPERATORS (U+2061-U+2064)** が未カバーと指摘され、`===END_LEDGER<U+2061>_DATA===` が framing 検査を素通りすることを**実 exe で確認した** (exit 0 = タスク選択成功)。

対処として **Unicode 16.0 の `Cf` (format) を全域で列挙する**方式へ切り替えた (不可視だが `Mn` の variation selector 2 ブロックを含む)。本クレートは依存 crate を増やさない制約があり `char::is_control()` は `Cc` しか見ないため、テーブルは自前で持つ。

**この修正の含意は「1 文字足した」ではない。** 「攻撃に使われた文字を後追いで足す」運用は、次の未知の 1 文字で同じ穴が開く。カテゴリ全域を列挙し、**Unicode のバージョンが上がったら追随する**という保守契約に変えたことが要点である。

### 15. 停止点を draft PR から通常 PR へ移す (2026-08-09)

> **前提の訂正 (2026-08-10)**: 本決定は「draft をやめれば `auto_review` の初回レビューに自然に乗る」を根拠にしていたが、**この因果は誤りだった**。実際のブロック要因は draft ではなく **PR の author が bot であること**で、draft を廃止しても夜間 PR にレビューは付かなかった ([#379](https://github.com/aloekun/claude-code-hook-test/pull/379) が `draft=false` で 10 時間 26 分 無反応)。制約の全体像は [ADR-019](adr-019-coderabbit-review-hybrid-policy.md) § CodeRabbit は bot 作成 PR を自動レビューしない、解決は決定 16 を見よ。
Expand Down Expand Up @@ -401,6 +423,27 @@ public リポジトリでは **fork からの PR でも起動し、その時点

**検証 step は実際に働いた** — 1 回目の確認では反応が無く 2 回目で検出している。決定 11 のように投稿の成否だけを見ていれば、この待機は存在しなかった。

### 17. PR タイトルに実装内容を入れる — 台帳の専用列から作る (2026-08-11)

**問題**: タイトルが `feat: 順位 339 の無人実装 (nightly-todo)` 固定で、**自動実行したことしか分からなかった**。翌朝 PR 一覧を見た人間は、中身を開くまで何が入っているか判断できない。人間が作る PR (`feat(review-request): bot 作成 PR へ人間資格情報で CodeRabbit レビューを要求する`) と比べると、一覧上の情報量が明確に劣る。

**決定**: 台帳に optional な **「PRタイトル」列**を足し、そこに書かれた 1 行を使う。タイトルは `<台帳の PRタイトル> (nightly-todo 順位 <RANK>)` の形にする。

検討した 3 案のうち、他の 2 つを採らなかった理由:

| 案 | 不採用の理由 |
|---|---|
| 既存の `内容` 列を切り詰める | `内容` は **agent への依頼文**であってタイトルではない。機械的に切ると「何を実装したか」にならず、conventional commits の prefix も `feat:` 固定のままになる |
| agent にタイトルを書かせる | **agent 生成文字列が初めて公開面に出る**。決定 14 が意図的に避けた構造で、agent は台帳の自由記述を読んでいる以上 injection の出口になりうる |

台帳の列にしたことで、**タイトルは決定論的**(同じ台帳なら同じタイトル)で、prefix の選択と簡潔さを人間がレビュー時に担保できる。列は optional で、未記入行は従来のタイトルへフォールバックする — 台帳 100 行超を一度に埋めなくてよい移行経路を残した。

**接尾辞にしたのは** conventional commits の prefix を先頭に残すためである。接頭辞 (`[nightly-todo] feat: ...`) は PR 一覧で左端が揃う利点があるが、他の PR とタイトル規約がずれる。

**新しい公開面が増えるため screening を分けた** — 決定 14 の § 3 つ目の公開面 を見よ。

**出力契約の allowlist にも足すこと**。`cli-nightly-task-select` の新出力 `pr_title_display` は、workflow の `grep -E '^(...)='` 許可リストと出力契約の検証の両方へ同時に足す必要がある。片方だけだと**新しい出力が黙って捨てられ、毎晩フォールバックし続ける**形で劣化する (workflow のコメントが警告していた失敗モードそのもの)。検証 step は `pr_title_display=` の**行の存在**を見る (値は空でもよい)。

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

| 項目 | 内容 |
Expand Down
Loading
Loading