--- name: update-issue-tree description: > 既存の GitHub Issue ツリーを棚卸し・更新するスキル。「ツリーを棚卸しして」「イシューツリーを更新して」「トラッキング issue を整理して」で使用。 ルートのトラッキング issue 番号を受け取り、sub_issues API でツリー全体を再帰取得 → closed 親下の残置 open issue 付け替え・孤児の再配置・新 Phase 親の新設・phase ラベル同期 → ルート issue 本文は既存本文を保持し、Phase 別表・棚卸し履歴の管理ブロックのみ更新する。 ツリー新規作成は create-issue-tree、実装消化は implement-issue-tree を参照。 model: opus user-invocable: true argument-hint: "<ルートトラッキング issue 番号> [--granularity <時間>]" --- # update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ## 使い方 ルートのトラッキング issue 番号を引数として渡す。 `--granularity` オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 `2h`・`4h`・`1h` のように正整数+時間単位(`h`)で指定する。優先順位は **`--granularity` 明示 > ルート issue 本文の `` マーカー > 既定 `2h`** の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で `GRANULARITY` として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 `GRANULARITY` の確定値でマーカー行を先頭に 1 行だけ保持する。 ``` update-issue-tree 42 update-issue-tree 42 --granularity 4h ``` ## 前提条件 - `gh` CLI がインストールされ、認証済みであること(`gh auth status` で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは**単一リポジトリ内**で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross-repository sub-issue を**対象外**とする。 親リポジトリへの書き込みは本スキルの前提条件(対象リポジトリへの書き込み権限)の 外側にあり、誤ったリポジトリの同番号 issue を操作する事故を構造的に防ぐため。この契約の実装は `scripts/reassign-sub-issue.sh` の **exit 2 による fail-closed**(下記 Step 3 の終了コード表を参照)。exit 2 は gh/jq 不在・未認証・issue 取得失敗(解消可能な前提不備)とも共有するため、このケースだけ stderr に `reason=cross-repository-parent` の安定マーカー行が追加で出る(終了コード表参照) - 対象 issue の親が**別リポジトリ**にある場合、`scripts/reassign-sub-issue.sh` がその issue を 処理できるのは、親リポジトリ側で親リンクが取り外され `parent_issue_url` が null(どの親にも 紐付いていない)状態になってからである。cross-repository の親リンクの取り外しは **親リポジトリ側の操作**であり、そのリポジトリへの書き込み権限を持つ担当者が行う。本スキルの範囲外 - **同一リポジトリ内での親の付け替えは本スキルの中核機能であり、上記の null 要求の対象外**である。 Step 3 は承認済みの旧親を `--old-parent` に渡して DELETE→POST を実行するため、事前に `parent_issue_url` を null にしておく必要はない(孤児の再配置は Step 4 が `--old-parent` 省略で扱う) ## フロー ### Step 1: ツリー全体を再帰取得する ルート issue から sub_issues API を再帰的に呼び出し、全階層のツリー構造を取得する。 ページネーションを考慮し、`per_page=100` で全件取得する。 ```bash ROOT_NUMBER="<ルート issue 番号>" # --granularity の値は代入前に Claude 側でも同じ正規表現(^[1-9][0-9]*h$)で検証し、 # 不一致なら**このフェンスを実行せず**ユーザーに再指定を求める(未信頼値を生成済みシェルへ # 埋め込まない)。優先順位は --granularity > ルートのマーカー > 既定 2h の順。 # --granularity で渡された時間を実際の値で代入する(実行時に Claude が置き換える) # 例: --granularity 4h が指定された場合 → GRANULARITY_ARG="4h" / 未指定の場合は空文字 GRANULARITY_ARG="<--granularity で渡された時間(未指定なら空文字)>" if [[ -n "${GRANULARITY_ARG}" ]]; then GRANULARITY="${GRANULARITY_ARG}" GRANULARITY_SOURCE="--granularity 引数" else # ルート本文の取得失敗とマーカー不在は分離する。取得失敗は「粒度を既定へ倒さず中止」する # (gh issue view 自体の失敗を || true で握り潰すと、権限エラー・issue 不在等を # 「マーカーなし」と誤読して既定 2h へサイレントに倒れてしまうため) ROOT_BODY=$(gh issue view "${ROOT_NUMBER}" --json body --jq '.body') \ || { echo "エラー: ルート issue #${ROOT_NUMBER} の本文を取得できません。粒度を既定へ倒さず中止します。"; exit 1; } # 既存ルート本文の マーカーから継承する。マーカーが無ければ既定 2h # (|| true は grep のマーカー不在にのみ掛かる。gh issue view 自体の失敗は上で既に処理済み) ROOT_GRANULARITY=$(printf '%s\n' "${ROOT_BODY}" \ | grep -oE '' | head -1 | grep -oE '[1-9][0-9]*h' || true) if [[ -n "${ROOT_GRANULARITY}" ]]; then GRANULARITY="${ROOT_GRANULARITY}" GRANULARITY_SOURCE="ルート issue #${ROOT_NUMBER} のマーカー" else GRANULARITY="2h" GRANULARITY_SOURCE="既定値" fi fi # 許可する構文は正整数 + h のみ(例: 1h / 2h / 4h)。引用符・空白・コマンド置換・0h・単位なしは # ここで拒否する(ROOT_GRANULARITY はマーカー抽出時点で同じ正規表現を通しているが、 # GRANULARITY_ARG はユーザー入力の生値のため、代入元によらずこの検証を必ず通す) if ! printf '%s' "${GRANULARITY}" | grep -qE '^[1-9][0-9]*h$'; then echo "エラー: --granularity の値 '${GRANULARITY}' は不正です(許可: 正整数+h、例 2h)。中止します。" exit 1 fi # 確定値と由来を必ず出力する(既定 2h と読み替えないことをここで可視化する) echo "粒度基準: ${GRANULARITY}(由来: ${GRANULARITY_SOURCE})" # ルート直下の sub-issues を取得(ページネーション対応) fetch_sub_issues() { local PARENT="${1}" local PAGE=1 while true; do RESULT=$(gh api \ "repos/{owner}/{repo}/issues/${PARENT}/sub_issues?per_page=100&page=${PAGE}") echo "${RESULT}" COUNT=$(echo "${RESULT}" | jq 'length') if [ "${COUNT}" -lt 100 ]; then break; fi PAGE=$((PAGE + 1)) done } # ルートから再帰的にツリーを構築 fetch_sub_issues "${ROOT_NUMBER}" ``` 以降の Step は Step 1 で出力された `GRANULARITY` の値を使う(既定 2h と読み替えない)。 各 issue の `state`(open / closed)・ラベル・タイトルを記録してツリーマップを作成する。 ### Step 2: 棚卸し対象を特定する 取得したツリーマップを分析し、以下のケースを特定する。 | ケース | 対応方針 | |--------|---------| | closed 親の下に open issue が残置されている | 適切な open Phase 親へ付け替え | | どの親にも紐付いていない孤児 issue がある | 該当 Phase 親へ紐付け(Phase が不明な場合はユーザーに確認) | | phase ラベルが親と一致しない issue がある | ラベルを同期 | | 既存 Phase に収まらない新規タスクがある | 新 Phase 親の新設を検討 | | 実装時間が `${GRANULARITY}`(Step 1 で確定。既定 2h)超の issue が分解されていない | sub-issue に分解(create-issue-tree と同じ粒度基準) | | 対象 issue の親が別リポジトリにある(cross-repository sub-issue) | 本スキルの対象外。棚卸し対象から除外し、Step 9 の要確認事項へ記載する | 棚卸し対象の一覧をユーザーに提示し、方針確認を取ってから変更を実行する。 **承認された対象を Step 3 / Step 4 が読む 2 つの計画配列へ落とす。** 配列は必ず宣言する (対象 0 件でも空配列として宣言する)。未宣言のまま Step 3 / Step 4 へ進むと `"${REASSIGN_PLAN[@]}"` が空展開され、承認済みの対象があってもエラーなく 0 件で完走して 完了報告まで進んでしまうため、両ステップの冒頭で宣言の有無を検査して fail-closed で停止する。 ```bash # 承認された「closed 親下の付け替え」対象。要素は " "。 # 空白区切りの 1 行 1 件にするのは、Step 3 のループが read で分割して受け取るため。 # issue 番号は全て 10 進の正整数であり、空白・改行を含まないことを Step 1 の取得時に保証する。 REASSIGN_PLAN=( # "123 456 789" ) # 承認された「孤児の再配置」対象。要素は " "。 ORPHAN_PLAN=( # "234 789" ) ``` 対象が 0 件の場合も上記のとおり**空配列として宣言する**。「対象なし(空配列)」と 「計画未設定(未宣言)」は意味が異なり、後者は Step 2 の実行漏れであって正常系ではない。 ### Step 3: closed 親下の残置 open issue を付け替える closed 親の下に残置されている open issue を、対応する open Phase 親へ移動する。 「旧親から DELETE → 新親へ POST」の 2 段操作と、その前後の冪等性判定・事後確認は `scripts/reassign-sub-issue.sh` に集約されている(SKILL.md 本文に素の `gh api` を並べると、 DELETE 失敗検知なしに POST へ進む等の欠陥を生むため)。 その呼び出し側で必要な共有処理(計画配列の存在・型検証、1 件呼び出しの終了ステータス解釈)は `scripts/reassign-lib.sh` に切り出してあり、Step 3 / Step 4 がそれぞれ source して使う。 **各コードフェンスは独立したシェルで実行され得る**(`reassign-sub-issue.sh` の設計前提と同じ)。 そのため共有処理をどちらかのフェンス内で定義して他方が再利用する構成にはしない。Step 4 の 実行時に関数定義が継承されず `command not found` となり、孤児の再配置が一切実行されないため。両 Step のフェンスは同一の前置きで `reassign-lib.sh` を自己完結的に解決・source する。 このスキルの配置ルートは導入形態(本リポジトリのソース/`npx skills add` による vendoring/`.claude/skills/` symlink 経由)で異なる。source 前に 3 レイアウトを順に確認し、 実在するものを採用する(implement-issue-tree の `scriptPath` 3 レイアウト・contribute-skill の `LOCAL_SKILL_DIR` 解決と同じ考え方)。`reassign-sub-issue.sh` のパスはここでは指定しない—— ライブラリが自身の実体位置から兄弟として解決するため、どの導入形態でも対の実体が必ず組み合う。 ```bash # reassign-lib.sh を 3 レイアウトから解決して source する。 # **この前置きは Step 3 / Step 4 の両フェンスが同一の内容で持つ。** 手順書のコードフェンスは # ブロックごとに独立シェルで実行され得るため、片方のフェンスで定義した関数・変数がもう一方へ # 継承される保証がない(Issue #372 / PR #374 codex-review P1)。 REASSIGN_LIB="" for CANDIDATE in \ "skills/update-issue-tree/scripts/reassign-lib.sh" \ ".agents/skills/update-issue-tree/scripts/reassign-lib.sh" \ ".claude/skills/update-issue-tree/scripts/reassign-lib.sh"; do # 存在確認は -f のみで行う(-x にすると、npx skills add 等の vendoring で # 実行ビットが落ちたファイルを「存在しない」と誤検知し、3 レイアウトいずれにも # 見つからないという誤ったエラーメッセージになる) if [[ -f "${CANDIDATE}" ]]; then REASSIGN_LIB="${CANDIDATE}" break fi done if [[ -z "${REASSIGN_LIB}" ]]; then echo "エラー: reassign-lib.sh が見つからない(3 レイアウトいずれにも存在しない)" >&2 exit 1 fi # ライブラリは対の reassign-sub-issue.sh の存在まで確認したうえで、reassign_one / # require_plan_array と REASSIGN_SCRIPT を定義する。欠落していれば非ゼロを返すので中断する。 # このフェンスで set -euo pipefail を使ってはならない(理由は reassign-lib.sh 冒頭のコメント参照。 # set -e があると reassign_one 内の `status=$?` 退避に到達せず、Issue #335 の欠陥が再発する) source "${REASSIGN_LIB}" || exit 1 # 計画配列の存在・型ガード(fail-closed)。詳細は reassign-lib.sh の require_plan_array を参照 require_plan_array REASSIGN_PLAN || exit 1 # 呼び出し側ループ。契約の主体はここにある——「(b) 恒久的な対象外は次の 1 件へ進み、 # (a) 解消可能な前提不備は原因解消まで中断する」を実際に実現するのはこのループである。 # ISSUE_NUMBER / OLD_PARENT / NEW_PARENT は Step 2 で構築した REASSIGN_PLAN から供給する。 SKIPPED=() NEEDS_REVIEW=() for ENTRY in "${REASSIGN_PLAN[@]}"; do # ENTRY は " " 形式。 # zsh は未クォートの展開を単語分割しないため、read で明示的に分割する IFS=' ' read -r ISSUE_NUMBER OLD_PARENT NEW_PARENT <<< "${ENTRY}" # `if reassign_one ...; then ... fi` の形にしてはならない。条件が偽で else が無い場合、 # `fi` の直後の $? は 0 になり(実測済み)、失敗が「成功」として読まれて # (a)/(b)/(c) の判定も中断もすべて素通りする。`|| status=$?` で明示的に退避する。 status=0 reassign_one --issue "${ISSUE_NUMBER}" \ --old-parent "${OLD_PARENT}" \ --new-parent "${NEW_PARENT}" || status=$? if (( status == 0 )); then continue fi if (( status == 9 )); then # (b) cross-repository 親。棚卸し対象から除外して次へ(Step 9 の要確認事項へ記載) SKIPPED+=("${ISSUE_NUMBER}") continue fi if (( status == 10 )); then # (c) 補償復旧が成功し旧親へ復帰済み(本来の付け替えは未達)。fatal にせず要確認事項へ # 記録して次の 1 件へ進む。exit 10 を中断扱いにすると残りの REASSIGN_PLAN が未処理のまま # 中断し、Step 4 の孤児処理・Step 9 の報告経路自体がスキップされてしまう NEEDS_REVIEW+=("#${ISSUE_NUMBER}: exit=10 restored — 旧親 #${OLD_PARENT} へ復帰済み。新親 #${NEW_PARENT} への付け替えは未達") continue fi if (( status == 11 )); then # (c) DELETE 後、第三者が別親を設定済みのため補償せず見送り。同一コマンドでの再実行禁止を # 記録に残したうえで、fatal にせず次の 1 件へ進む NEEDS_REVIEW+=("#${ISSUE_NUMBER}: exit=11 third-party-parent — 旧親 #${OLD_PARENT} から DELETE 済み、第三者が別親を設定済み。同じコマンドで再実行禁止。stderr の実測親を確認しユーザー承認のうえ再実行すること") continue fi # (a) それ以外(exit 1-8。9・10・11 を除く)は握り潰さず中断する。原因を解消してから再実行する echo "エラー: #${ISSUE_NUMBER} の付け替えが exit ${status} で失敗した。中断する" >&2 exit "${status}" done if (( ${#SKIPPED[@]} > 0 )); then echo "対象外(cross-repository 親): ${SKIPPED[*]}" >&2 fi if (( ${#NEEDS_REVIEW[@]} > 0 )); then echo "要確認事項(Step 9 のレポートへ転記すること):" >&2 printf ' %s\n' "${NEEDS_REVIEW[@]}" >&2 fi ``` `reassign_one` は**1 件分の呼び出し**であり、非ゼロ終了は**当該 1 件の失敗**として 関数の返り値へ伝播する。判定に必要な stderr は関数内で捕捉したうえで必ず再出力するため、 診断情報は失われない。呼び出し側ループは Step 9 の要確認事項へ記録したうえで、次の 3 通りに 振り分ける。**(a) 解消可能な前提不備**(exit 2 のうち `gh`/`jq` 不在・未認証・issue 取得失敗等。 9・10・11 を除く exit 1〜8 全般)は原因解消まで中断する。**(b) 恒久的な対象外** (cross-repository 親。exit 9)は要確認事項へ記載して棚卸し対象から除外し、次の 1 件の 呼び出しへ進む。**(c) 部分的に変更済みだが処理は継続してよいもの**(exit 10 = DELETE 後の 補償復旧が成功し旧親へ復帰済み・exit 11 = DELETE 後に第三者が別親を設定済みで補償を見送り)は 要確認事項へ記録したうえで、fatal にせず次の 1 件へ進む。exit 10 / 11 を即 `exit` で中断すると 残りの `REASSIGN_PLAN` が未処理のまま止まり、Step 4 の孤児処理・Step 9 の報告経路自体が スキップされる欠陥になるため、この 2 コードはループを止めない(終了コード表を参照)。 (a)/(b) の判定は stderr に `reason=cross-repository-parent` が出ているかで機械的に行う (無ければ (a))。(c) は返り値がそのまま 10 / 11 であるため、判定に stderr のマーカーは不要。 **(c) だけを継続対象にする理由(exit 3/4/5/7/8 は継続しない)**: 終了コード表を見ると exit 3・4・5・7・8 も「呼び出し側の扱い」欄に「要確認事項へ記載」と書かれており、一見 (c) と同じに見える。しかし止める・止めないの分岐点は「要確認事項へ記載するか」ではなく **当該 1 件の終端状態が実測で確定しているか**である。exit 10 / 11 は補償復旧ルーチンが 実状態を再取得した末に「旧親配下」「第三者親配下」という**確定した終端状態**へ着地しており、 次の 1 件へ進んでも新たな破壊は生じない。対して exit 3(DELETE 失敗)・4(POST 失敗)・ 5(事後確認不一致)・7(POST 時点のレース)・8(補償も失敗し状態不明/孤児)は、無変更・ 孤児化・状態不明のいずれかであり、原因(`gh` 認証切れ・API 障害・レース条件)が **後続の全件に共通して波及する可能性が高い**ため、1 件で切り上げて原因究明を優先する。 この境界線は exit 10 / 11 に限定したものであり、3/4/5/7/8 へ安易に拡張しない。 **引数** | 引数 | 必須 | 意味 | |------|------|------| | `--issue` | 必須 | 付け替え対象の issue 番号 | | `--new-parent` | 必須 | 付け替え先の issue 番号。DELETE の前に新親を GET し、存在すること・`--issue` 自身でないこと(自己参照)・対象 issue と同一リポジトリにあることを検証する。いずれかを満たさない場合は**DELETE を 1 件も撃たずに** exit 1(自己参照)/ exit 2(存在しない・別リポジトリ)で無変更終端する | | `--old-parent` | 任意 | 現在の親。Step 2 でユーザーが承認した旧親を渡す。実測した現在の親と食い違う場合は**何も変更せず exit 6 で停止**する(承認外の親子関係を壊さないため)。省略時は「孤児である」ことを承認した意味になり、実測で親が居れば同じく exit 6 で停止する | | `--repo` | 任意 | `owner/name`。**対象 issue(`--issue`)の所在**であり、親(`--old-parent` / `--new-parent`)の所在ではない。省略時は cwd の git remote から解決。`--repo` は対象 issue の GET だけでなく DELETE / POST を含む全 API パスを切り替えるため、親が別リポジトリにあるからといって親リポジトリの値を入れてはならない(入れると親リポジトリ側の同番号 issue を誤操作する) | **終了コードと `result=` 行** stdout 最終行が `result= issue= new_parent= old_parent=` の形式で 機械可読な内訳を返す。**非ゼロ終了は 1 件も握り潰さず、Step 9 の完了レポートの 「要確認事項」へ必ず記載する。** | 終了コード | `state` | 意味 | 呼び出し側の扱い | 変更の有無 | |-----------|---------|------|----------------|-----------| | 0 | `reassigned` | DELETE→POST を実施 | 「付け替え」件数へ計上 | 変更あり(成功) | | 0 | `already-attached` | 既に新親配下(no-op) | 件数へ計上しない | 無変更(no-op) | | 0 | `posted-only` | 旧親配下になく POST のみ | 「孤児の再配置」件数へ計上(Step 4 と同一スクリプト) | 変更あり(POST のみ) | | 1 | — | 引数・使い方エラー(**`--new-parent` の自己参照を含む**) | 実行者の誤り。修正して再実行 | **無変更**(API 未実行) | | 2 | — | 前提不備。2 類型が混在し、**stderr のマーカー行で機械的に判定する**: `reason=cross-repository-parent` が出ていれば (b)、無ければ (a)。(a) 解消して再実行できるもの(`gh`/`jq` 不在・未認証・issue 取得失敗・**新親が存在しない**・**新親が Pull Request**・**新親が別リポジトリ**)と、(b) **恒久的に対象外**の cross-repository 親(同一コマンドの再実行では解決しない。親リポジトリ側で親リンクが外れるまで本スキルでは処理できない) | (a) は原因を解消して再実行。(b) は要確認事項へ記載し、棚卸し対象から除外する(Step 2 参照) | **無変更**(DELETE / POST 未実行) | | 3 | — | DELETE 失敗(gh の非ゼロ終了)。**POST は実行していない。** DELETE の応答だけを信頼せず実状態を再取得・安定確認したうえで、実測でも旧親配下のままであることを確認して初めて確定する(応答取得だけが失敗しサーバー側では成立していた場合は孤児として POST 工程へ進むため、この exit には到達しない) | 要確認事項へ記載。旧親配下のまま | **無変更**(実測で確認済み。DELETE 失敗・旧親配下のまま) | | 4 | — | POST 失敗(**孤児経路のみ**。DELETE を伴わないため孤児化リスク自体は無い)。POST の応答だけを信頼せず実状態を再取得・安定確認したうえで、実測でも孤児のままであることを確認して初めて確定する(応答取得だけが失敗しサーバー側では成立していた場合は `posted-only` として exit 0 になるため、この exit には到達しない) | 要確認事項へ記載 | **無変更**(実測で確認済み。DELETE 未実行) | | 5 | — | 事後確認で新親配下に見つからない(別リポジトリの親配下にある場合を含む) | 要確認事項へ記載。手動で実状態を確認 | 変更あり(DELETE / POST は実行済み・実状態の手動確認が必要) | | 6 | — | **承認された旧親と実測が食い違う。何も変更していない** | 要確認事項へ記載。**同じコマンドで再実行してはならない。** stderr が示す実測の親をユーザーへ提示して承認を得たうえで、`--old-parent` にその値を入れて再実行する | **無変更** | | 7 | — | POST 時点で別の親が付いていたレース。**DELETE 未実行のため無変更** | 要確認事項へ記載。実測し直して承認を取り直したうえで再実行する | **無変更**(DELETE 未実行) | | 8 | — | 次の 3 系統いずれかの実状態再取得・安定確認が**孤児のまま/状態不明/第三者の親配下で終端**した: (1) DELETE 後の POST 失敗に対する補償復旧(補償 POST 自体の失敗・復旧用の再取得失敗・補償後の検証不一致・反映遅延の再確認不一致のいずれか。`reason=compensation-post-failed`(実測でも孤児)/ `reason=compensation-post-failed-third-party-parent`(補償 POST 失敗中に第三者が別の親を設定済みと実測)で内訳を判別)、(2) DELETE 失敗時の実状態再取得(DELETE はエラー応答だったがサーバー側で成立していた可能性があるため、応答だけで exit 3 と断定せず再取得した結果、再取得自体の失敗・不一致・不安定だった)、(3) 孤児経路の POST 失敗時の実状態再取得(POST はエラー応答だったがサーバー側で成立していた可能性があるため、応答だけで exit 4 と断定せず再取得した結果、再取得自体の失敗・不一致・不安定だった)。(2)(3) はいずれも `reason=recovery-state-unknown` を出す。(1) の再取得系失敗も同じ `reason=recovery-state-unknown` を出す | 要確認事項へ記載。**いずれも無変更ではない(実測で確定できなかった状態不明)。** 実状態を確認し必要なら手で紐付け直す。同一コマンドの再実行では回復しない | (1) **部分変更**(旧親から外れ、新親にも付いていない。第三者配下の場合を除く)。(2)(3) **状態不明**(DELETE / POST がサーバー側で成立していたか未確定のため、無変更とも部分変更とも断定しない) | | 10 | `restored` | DELETE 後の POST 失敗に対する補償復旧が**成功**し、実測で旧親配下へ復帰した(本来の目的だった新親への付け替えは未達) | 要確認事項へ記載(本来の付け替えが未達のため)。件数へは計上しない | **変更あり**(旧親へ復帰。新親への付け替えは不成立) | | 11 | — | 実状態を再取得したところ、**第三者が別の親を設定済み**だったため補償せず見送った(`reason=third-party-parent`)。次のいずれかから到達する: (a) DELETE 後の POST 失敗を受けての再取得、(b) DELETE 失敗そのものを受けての再取得(DELETE の応答だけで exit 3 と断定せず再取得した結果、第三者の親配下と判明した場合)。**新親配下の場合はこの判定より先に成功経路(exit 0 / result=reassigned)へ倒す**ため、この exit 11 に到達するのは実測が旧親でも新親でもない別の親だった場合に限る | 要確認事項へ記載。**同じコマンドで再実行してはならない。** stderr が示す実測の親をユーザーへ提示して承認を得たうえで再実行する | **変更あり**(旧親からは既に DELETE 済み。**「補償 POST を撃たず fail-closed で停止」は新親への POST 側の話であり、DELETE は exit 11 に到達する時点で既に成立している(第三者の親配下に見えている以上、サーバー側のデータモデル上、旧親からは既に外れている)。** 実測では第三者が設定した別親配下にある) | **スクリプト自身は 0〜8・10・11 を返す(9 は使わない)。** `reassign-lib.sh` の `reassign_one` ラッパは、これに加えて **9 = 恒久的に対象外(cross-repository 親)** を返す。9 はスクリプトの終了コードではなく、ラッパが stderr の `reason=cross-repository-parent` マーカーを判定して合成する値であり、呼び出し側ループが 「棚卸し対象から除外して次の 1 件へ進む」のシグナルとして使う。マーカーの判定には スクリプトの stderr が必要なため、ラッパは stderr をファイルへ捕捉したうえで**必ず再出力する** (診断情報は握り潰さない)。10・11 はラッパの「その他はスクリプトの終了コードをそのまま返す」 契約により無加工で透過するため、`reassign-lib.sh` 側の変更は不要。 **DELETE 後の POST 失敗時の補償復旧の契約**: 新親の事前検証は「事前に判定できる 拒否条件」を DELETE 前に潰すが、DELETE と POST の間のレース・一時的な 5xx は事前判定できない 残余として残る。POST 失敗で即終端すると対象 issue が孤児のまま滞留するため、POST 失敗を 検知した時点で**対象 issue の実状態を再取得**してから分岐する(実測を先に確定させてから動く): - 実測の親が新親 → POST は偽陰性だった。通常の成功終端(`exit 0` / `result=reassigned`)と同じ扱い。 ただし旧親・孤児を期待した再確認の途中で新親を初めて観測した場合は、新親を期待値として もう 1 回取得し、2 回連続で新親配下が観測できたときにだけ `reassigned` を確定する。 一致しない・再取得に失敗した場合は成功へ倒さず `exit 8`(状態不明)で終端する - 実測で孤児 → 1 回の読み取りだけでは DELETE/POST の反映遅延による過渡状態を見ている 可能性を排除できないため、補償 POST という書き込みを行う前に短い間隔を空けて再取得し、 2 回連続で親なしが観測できて初めて安定した孤児として確定する。安定確認できれば、承認済み操作の逆操作 (旧親へ戻す)は承認範囲内のため補償 POST を撃つ。成功なら事後確認を取り直し `exit 10` / `result=restored`。補償 POST 自体が失敗する多重障害、または再取得・事後確認・ 安定確認が失敗する状態不明ケースは書き込まずに `exit 8` で終端する - 実測で第三者が別の親(別リポジトリを含む)を設定済み → **補償 POST を撃たず** `exit 11` で fail-closed 停止する。第三者が確定させた親子関係を上書きしない **DELETE 失敗・孤児経路 POST 失敗の実状態再確認**: 上記は DELETE 成功後の POST 失敗(部分変更)に対する補償復旧だが、DELETE 自体の失敗(`reason` 系マーカーを持たない gh の非ゼロ終了)と孤児経路(DELETE を伴わない)の POST 失敗も同様に扱う。 `gh api` は終了コード 0 で返しても応答取得だけが失敗することがある一方、非ゼロ終了は 「サーバー側で処理が成立しなかった」ことを保証しない(応答取得側の失敗も非ゼロになり得る)。 このため DELETE / POST の非ゼロ終了だけを根拠に exit 3 / exit 4(無変更)と即断すると、 実際にはサーバー側で成立していた場合に誤報になる。 対称に実状態を再取得してから分岐する: - **DELETE 失敗** → 実状態を再取得し、旧親配下のまま安定確認できれば `exit 3` (無変更)。孤児であることを安定確認できれば DELETE はサーバー側で成立済みとみなし、 そのまま POST 工程(新親への付け替え)へ進む。**新親配下であることを安定確認できれば、 第三者親判定より先に判定し**、DELETE→POST が既に成立していた(または並行実行が承認済みの 新親へ既に付け替え済みだった)とみなして既存の成功経路と同じ `result=reassigned` で `exit 0` にする(最初の読み取りで直接新親を観測した場合も、安定確認の途中で新親を観測した 場合と同じ成功終端にそろえ、観測タイミングだけで承認外の第三者親(`exit 11`)へ倒れないようにする)。それ以外で第三者が別の親を設定済みなら `exit 11` `reason=third-party-parent`(既存の補償復旧経路と同じ扱い)。再取得の失敗・ 不一致・不安定は `exit 8` `reason=recovery-state-unknown` - **孤児経路(DELETE なし)の POST 失敗** → 実状態を再取得し、孤児のまま安定確認できれば `exit 4`(無変更)。新親配下であることを安定確認できれば POST は偽陰性だった とみなし `result=posted-only` で `exit 0`。この経路には DELETE が無く孤児化リスク自体が 無いため、補償の書き込み(POST)は発生しない(判定のみで完結する)。エラーメッセージ ベースのレース検知(`exit 7`。"only have one parent")は再取得より先に判定する。それ以外 (再取得の失敗・不一致・不安定・メッセージ非該当の第三者親)は `exit 8` `reason=recovery-state-unknown` で終端する **事前検証と事後報告の契約**: 事前に判定できる拒否条件(新親の不存在・自己参照・ 別リポジトリ)は DELETE を撃たずに exit 1 / 2 で無変更終端する。一方、**事前に判定できない 拒否条件(事前 GET と DELETE / POST の間に第三者が状態を動かすレース、循環参照、新親が sub-issue を受け付けない状態など)は exit 4 / exit 7 / exit 8 として事後に報告する** 契約であり、事前検証はこれを置き換えない。DELETE 後の POST 失敗(孤児化しうる部分変更) だけは exit での即報告ではなく、上記の補償復旧ルーチンを経由してから exit 8 / 10 / 11 の いずれかへ着地する。 **GET 回数**: 経路ごとの GET 回数は次のとおり(付け替え・再配置の経路は新親の事前検証の 1 回を含む)。 | 経路 | GET 回数 | |------|---------| | 正常な付け替え(reassigned) | 3 | | 孤児の再配置(posted-only) | 3 | | already-attached(no-op) | 1 | | 承認不一致(exit 6) | 1 | 事前検証の GET は最大 1 回で、`update-issue-tree` の 1 ラン当たりの呼び出し回数は数十件規模。 認証済み REST の 5,000 req/h に対して無視できる。対して防ぐのは**不可逆な孤児化**であり、 割に合う。孤児経路(DELETE を伴わず孤児化リスク自体は無い経路)にも検証を通しているのは、 「事前に判定できる拒否条件は必ず無変更で終端する」契約を経路によらず統一する意図的な判断 であり、新親が存在しない場合は孤児経路でも exit 2 になる。 ### Step 4: 孤児 issue を再配置する どの親にも紐付いていない孤児 issue を適切な Phase 親へ紐付ける。 `--old-parent` を省略して同じスクリプトを呼ぶ(DELETE を飛ばして POST のみ実行される)。 Phase が不明な issue はタイトル・本文を読んで判断し、判断できない場合はユーザーに確認する。 **このフェンスは Step 3 の実行結果に依存せず単体で実行できる。** フェンスは独立シェルで 実行され得るため、Step 3 が定義した関数・変数を継承しない前提で書く。前置きは Step 3 と同一である。 ```bash # reassign-lib.sh を 3 レイアウトから解決して source する。 # **この前置きは Step 3 / Step 4 の両フェンスが同一の内容で持つ。** 手順書のコードフェンスは # ブロックごとに独立シェルで実行され得るため、片方のフェンスで定義した関数・変数がもう一方へ # 継承される保証がない(Issue #372 / PR #374 codex-review P1)。 REASSIGN_LIB="" for CANDIDATE in \ "skills/update-issue-tree/scripts/reassign-lib.sh" \ ".agents/skills/update-issue-tree/scripts/reassign-lib.sh" \ ".claude/skills/update-issue-tree/scripts/reassign-lib.sh"; do # 存在確認は -f のみで行う(-x にすると、npx skills add 等の vendoring で # 実行ビットが落ちたファイルを「存在しない」と誤検知し、3 レイアウトいずれにも # 見つからないという誤ったエラーメッセージになる) if [[ -f "${CANDIDATE}" ]]; then REASSIGN_LIB="${CANDIDATE}" break fi done if [[ -z "${REASSIGN_LIB}" ]]; then echo "エラー: reassign-lib.sh が見つからない(3 レイアウトいずれにも存在しない)" >&2 exit 1 fi # ライブラリは対の reassign-sub-issue.sh の存在まで確認したうえで、reassign_one / # require_plan_array と REASSIGN_SCRIPT を定義する。欠落していれば非ゼロを返すので中断する。 # このフェンスで set -euo pipefail を使ってはならない(理由は reassign-lib.sh 冒頭のコメント参照。 # set -e があると reassign_one 内の `status=$?` 退避に到達せず、Issue #335 の欠陥が再発する) source "${REASSIGN_LIB}" || exit 1 # Step 3 と同じガードを同じ関数で行う(非対称を作らない) require_plan_array ORPHAN_PLAN || exit 1 SKIPPED_ORPHANS=() NEEDS_REVIEW_ORPHANS=() for ENTRY in "${ORPHAN_PLAN[@]}"; do # ENTRY は " " 形式 IFS=' ' read -r ORPHAN_NUMBER PHASE_NUMBER <<< "${ENTRY}" # Step 3 と同じ理由で `if ...; then ... fi` は使わない(`fi` 直後の $? は 0 になる) status=0 reassign_one --issue "${ORPHAN_NUMBER}" --new-parent "${PHASE_NUMBER}" || status=$? if (( status == 0 )); then continue fi if (( status == 9 )); then SKIPPED_ORPHANS+=("${ORPHAN_NUMBER}") continue fi if (( status == 10 )); then # Step 3 と同じ (c) 扱い。孤児経路は --old-parent を渡さないため通常は起こり得ないが、 # ループ構造を Step 3 と非対称にしないため同じ分岐を持つ NEEDS_REVIEW_ORPHANS+=("#${ORPHAN_NUMBER}: exit=10 restored — 補償復旧が発生。Phase 親 #${PHASE_NUMBER} への紐付けは未達。実状態を確認すること") continue fi if (( status == 11 )); then NEEDS_REVIEW_ORPHANS+=("#${ORPHAN_NUMBER}: exit=11 third-party-parent — 第三者が別親を設定済み。同じコマンドで再実行禁止。stderr の実測親を確認しユーザー承認のうえ再実行すること") continue fi echo "エラー: #${ORPHAN_NUMBER} の再配置が exit ${status} で失敗した。中断する" >&2 exit "${status}" done if (( ${#SKIPPED_ORPHANS[@]} > 0 )); then echo "対象外(cross-repository 親): ${SKIPPED_ORPHANS[*]}" >&2 fi if (( ${#NEEDS_REVIEW_ORPHANS[@]} > 0 )); then echo "要確認事項(Step 9 のレポートへ転記すること):" >&2 printf ' %s\n' "${NEEDS_REVIEW_ORPHANS[@]}" >&2 fi ``` Step 3 と同一の関数・同一のループ構造であり、終了コードの扱いも同じ ((a) 解消可能な前提不備のみ原因解消まで中断、(b) 恒久的な対象外(exit 9)は要確認事項へ記載して 次の 1 件へ進む、(c) 部分的に変更済みだが継続してよいもの(exit 10 / 11)も要確認事項へ記録して fatal にせず次の 1 件へ進む。(b) の判定は関数が捕捉した stderr の `reason=cross-repository-parent` マーカーで機械的に行い、返り値 9 として呼び出し側へ伝える。 終了コード表を参照)。 ### Step 5: 必要に応じて新 Phase 親を新設する 既存 Phase に収まらない新規タスクが多い場合、新 Phase 親 issue を作成してルートへ紐付ける。 (この POST は `reassign-sub-issue.sh` を使わない。たった今作成した、親を持たないことが 自明な issue への単発 POST であり、DELETE パス・冪等性判定の対象外のため) ```bash # phase ラベルが存在しないリポジトリでは issue 作成が失敗するため、必ず事前作成する # (作成済みの場合は失敗を無視して続行する) gh label create "phase:N" --color "0075ca" 2>/dev/null || true # gh issue create は URL を出力する(--json 非対応)。URL 末尾から番号を抽出する NEW_PHASE_URL=$(gh issue create \ --title "feat(phase-N): Phase N タイトル" \ --label "phase:N" \ --body "$(cat <<'EOF' ## 概要 Phase N の実装タスクをまとめる親 issue。 ## タスク一覧 | Issue | タイトル | 分解 | |-------|---------|------| EOF )") NEW_PHASE_NUMBER=$(printf '%s' "${NEW_PHASE_URL}" | grep -oE '[0-9]+$') # ルートへ紐付け。sub_issue_id は issue 番号ではなく database id を渡す(GitHub sub-issues API 仕様) NEW_PHASE_ID=$(gh api "repos/{owner}/{repo}/issues/${NEW_PHASE_NUMBER}" --jq '.id') gh api \ --method POST \ "repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues" \ -F "sub_issue_id=${NEW_PHASE_ID}" ``` ### Step 6: phase ラベルを同期する 各 issue の phase ラベルが親 Phase と一致しているか確認し、不一致のラベルを修正する。 ```bash # ラベルを追加 gh issue edit "${ISSUE_NUMBER}" --add-label "phase:1" # 古いラベルを削除 gh issue edit "${ISSUE_NUMBER}" --remove-label "phase:0" ``` ### Step 7: 粒度基準超の issue を sub-issue に分解する 棚卸し中に実装時間が `${GRANULARITY}`(Step 1 で確定。既定 2h)超と判断した issue は、create-issue-tree と同じ粒度基準で sub-issue に分解する。 (この POST も `reassign-sub-issue.sh` を使わない。理由は Step 5 と同じ: 新規作成した 親なし issue への単発 POST) ```bash # phase ラベルが存在しない場合に備えて事前作成する(作成済みなら no-op) gh label create "phase:N" --color "0075ca" 2>/dev/null || true # sub-issue を作成(URL 末尾から番号を抽出) SUB_URL=$(gh issue create \ --title "feat: サブタスク名" \ --label "phase:N" \ --body "...") SUB_NUMBER=$(printf '%s' "${SUB_URL}" | grep -oE '[0-9]+$') # 親 issue へ紐付け(sub_issue_id は database id) SUB_ID=$(gh api "repos/{owner}/{repo}/issues/${SUB_NUMBER}" --jq '.id') gh api \ --method POST \ "repos/{owner}/{repo}/issues/${ISSUE_NUMBER}/sub_issues" \ -F "sub_issue_id=${SUB_ID}" ``` ### Step 8: ルート issue 本文の管理ブロックを更新する(既存本文は保持) ルート issue 本文のうち**本スキルが管理する範囲だけ**を実ツリーから再生成して差し替える。 それ以外の本文(概要・運用・手書きの計画や判断根拠・追記)は 1 行も失わない。本文全体を 固定テンプレートで書き戻してはならない(人が書いた内容が棚卸しのたびに消え、件数・日付の プレースホルダーが本文へ到達し得るため。Issue #545)。処理は `scripts/update-root-body.sh` に集約されている。 **管理範囲と不変条件** | 領域 | マーカー(行全体で一致・コードフェンス外のみ有効) | 更新規則 | |------|-----------------------------------------------|---------| | Phase 別表 | `` / `` | マーカー間を毎回全置換(ルート直下の sub-issue 全件の表と、直下に open の子を持つ節点の詳細表) | | 棚卸し履歴 | `` / `` | 追記専用。既存行は保持し、今回の日付と 5 件数の 1 行を末尾へ足す(直前行と同一なら足さない) | | granularity | `` | 先頭に 1 行だけ維持する。値は `GRANULARITY` の確定値 | - 既存本文から消してよいのは「phase-plan マーカー間の行」と「granularity マーカー行」だけ - `## 概要`・`## 運用` は管理しない。存在すれば逐語で保持し、存在しなくても追加しない (本文が空のときだけ骨格を生成する) - 表・件数・日付は実ツリーと検証済み引数からだけ生成する。5 件数の引数は既定値なしの必須で、 プレースホルダーが残っていれば API 呼び出し前に exit 1 で止まる - issue タイトルは非信頼データ。改行は空白 1 つに、`&`・`<`・`>`・バッククォート・バックスラッシュ・`|` は HTML 文字参照へ変換して書き込む(create-issue-tree と同じ変換)。タイトルが管理マーカーを偽装できず、create-issue-tree の `--root` 再マージも判定範囲の記号で中止しない。旧形式(`\|` 等)の行は管理マーカー区間内であれば次回の実行で書き直される - マーカーの不整合(片方のみ・重複・逆順・入れ子)と、閉じていないコードフェンスは編集せず exit 4 で止まる (閉じていないフェンスは以降を全部コード扱いにして管理ブロックを二重追記するため) **マーカーが無い既存本文の移行**(現存するルートはすべてこの状態): 既存行は捨てない。 - `## Phase 別実装計画` 節があれば、見出し直後へマーカー付きの生成ブロックを挿入する。同節内の 見出し直後〜最初の H3 までの先頭部分と `### Phase ` で始まる H3 は旧生成物の候補として **削除せず**、`
`(要約に退避日と「確認のうえ不要なら削除」を明記)へ逐語で移す。 それ以外の H3(`### 実装ラン (...)` 等)は元の位置・内容のまま残す - 節が無ければ本文末尾へ `## Phase 別実装計画` とマーカー付きブロックを追記する - 棚卸し履歴は `## 棚卸しで実施した整理(update-issue-tree 実行履歴)` 節を Phase 別実装計画の直前へ挿入する。 既存の手書きの棚卸し節には触れない - 退避が起きたら stdout 最終行の `migration=phase-plan-archived` を Step 9 の要確認事項へ転記する **書き込み前の確認と書き込み**: 取得した本文と編集直前に再取得した本文が異なれば並行編集として exit 5 で止まる。変更が無ければ `result=unchanged` で編集しない。`--dry-run` を付けると マージ後の本文を stdout へ出し、書き込み系の `gh` を一切呼ばない(初回の移行前に推奨)。 **件数変数の確定手順**(各コードフェンスは独立したシェルで実行され得るため、Step 3〜7 のフェンス内の 変数は Step 8 へ引き継がれない。実績値は会話上で集計し、下記フェンスの先頭へ**数値を直接書いた代入行として 追記**してから実行する。推測・既定値・プレースホルダーのまま実行しない): | 変数 | 確定する値 | 集計元 | |------|-----------|--------| | `ROOT_NUMBER` | ルート issue 番号 | 引数 | | `GRANULARITY` | 粒度(`2h` 等。`Nh` 形式) | 引数の確定値(既定 `2h`) | | `REASSIGNED_COUNT` | closed 親下の残置 issue 付け替えの成功件数 | Step 3 で `reassign-sub-issue.sh` が `result=reassigned` / `posted-only` を返した回数 | | `ORPHAN_COUNT` | 孤児 issue 再配置の成功件数 | Step 4 で同様に成功した回数 | | `LABEL_SYNC_COUNT` | phase ラベル同期の成功件数 | Step 6 で実際にラベルを付与・変更できた issue 数 | | `NEW_PHASE_COUNT` | 新設した Phase 親の件数 | Step 5 で作成と紐付けが成功した件数 | | `SPLIT_COUNT` | sub-issue 分解の成功件数 | Step 7 で分解(作成と紐付け)まで成功した issue 数 | - 該当 Step を実行しなかった、または対象が無かった場合は `0` を代入する(未設定のまま放置しない) - 失敗・見送り(非ゼロ終了、exit 10 / 11 を含む)は件数へ計上しない(Step 9 の集計規則と同じ) - 代入例(値は実績に置き換える): `ROOT_NUMBER=254 GRANULARITY=2h REASSIGNED_COUNT=1 ORPHAN_COUNT=0 LABEL_SYNC_COUNT=3 NEW_PHASE_COUNT=0 SPLIT_COUNT=0` を `export` 付きでフェンス先頭に置く 各コードフェンスは独立したシェルで実行され得るため、このフェンスは Step 3 / Step 4 と同形に スクリプトを 3 レイアウトから自己完結的に解決する。件数の変数が未設定なら起動前に停止する。 ```bash # Step 1〜7 の確定値。未設定ならここで停止する(プレースホルダーを本文へ流さない) : "${ROOT_NUMBER:?}" "${GRANULARITY:?}" "${REASSIGNED_COUNT:?}" "${ORPHAN_COUNT:?}" \ "${LABEL_SYNC_COUNT:?}" "${NEW_PHASE_COUNT:?}" "${SPLIT_COUNT:?}" UPDATE_ROOT_BODY="" for CANDIDATE in \ "skills/update-issue-tree/scripts/update-root-body.sh" \ ".agents/skills/update-issue-tree/scripts/update-root-body.sh" \ ".claude/skills/update-issue-tree/scripts/update-root-body.sh"; do # 存在確認は -f のみ(vendoring で実行ビットが落ちても bash 経由で起動できる) if [[ -f "${CANDIDATE}" ]]; then UPDATE_ROOT_BODY="${CANDIDATE}" break fi done if [[ -z "${UPDATE_ROOT_BODY}" ]]; then echo "エラー: update-root-body.sh が見つからない(3 レイアウトいずれにも存在しない)" >&2 exit 1 fi # echo を最終コマンドにしない。非ゼロ終了はそのままフェンスの終了ステータスにする status=0 bash "${UPDATE_ROOT_BODY}" \ --root "${ROOT_NUMBER}" --granularity "${GRANULARITY}" \ --reassigned "${REASSIGNED_COUNT}" --orphans "${ORPHAN_COUNT}" \ --labels "${LABEL_SYNC_COUNT}" --new-phases "${NEW_PHASE_COUNT}" \ --split "${SPLIT_COUNT}" || status=$? if (( status != 0 )); then echo "exit=${status}" >&2 exit "${status}" fi ``` **終了コード**(stdout 最終行は `result= root= migration= phases=`) | exit | 意味 | 本文の変更 | |------|------|-----------| | 0 | 成功(updated のみ変更あり) | updated のみ | | 1 | 引数エラー(件数のプレースホルダー残りを含む) | なし | | 2 | 前提不備(gh / jq 不在・未認証・ルート取得失敗・ルートが PR) | なし | | 3 | ツリー取得失敗・深さ超過(8 段)・表の行数不一致・65,536 文字超 | なし | | 4 | 管理マーカーの不整合、または本文のコードフェンスが閉じていない | なし | | 5 | 並行編集を検知 | なし | | 6 | `gh issue edit` が失敗 | 不明(実状態を確認する) | | 7 | 事後確認の不一致(再取得した本文全体が送信した本文と一致しない。末尾改行・CR の差は無視) | あり(編集済み) | 非ゼロ終了は握り潰さず Step 9 の要確認事項へ転記する。exit 6 / 7 は本文の実状態を確認する。 誤って更新した場合は GitHub の本文編集履歴から復元できる。 ### Step 9: 棚卸し結果を報告する ``` ## update-issue-tree 完了レポート ### 対象ルート issue - #N: タイトル ### 棚卸し実施内容 | 操作 | 件数 | |------|------| | closed 親下の残置 issue 付け替え | N 件 | | 孤児 issue の再配置 | N 件 | | phase ラベル同期 | N 件 | | 新 Phase 親の新設 | N 件 | | 粒度基準超 issue の sub-issue 分解 | N 件 | ### 現在の Phase 別サマリー | Phase | 親 issue | open 件数 | |-------|----------|----------| | Phase 1 | #N | N 件 | ### ルート本文の更新結果 - result: updated / unchanged、migration: none / phase-plan-archived / inserted ### 要確認事項(自動配置できなかった issue) - #N: タイトル — 確認理由 - 退避ブロック(`
`)の確認 / Step 8 の exit 5・6・7(該当時) ``` 「closed 親下の残置 issue 付け替え」「孤児 issue の再配置」の件数は、Step 3 / Step 4 で `reassign-sub-issue.sh` を呼んだ回数分の `result=` 行(`reassigned` / `posted-only`)から集計する。 **すべての非ゼロ終了**(exit 1〜8・10・11。今後コードが増えた場合も含む)は 1 件も件数へ含めず、 必ず「要確認事項」へ理由付きで記載する。とくに exit 8 は**部分変更が残っている**ため、 報告を漏らすと壊れたツリーが放置される。exit 10(補償復旧成功)・exit 11(第三者が別親を設定済み) も本来の付け替えは未達のため件数へは計上せず要確認事項へ記載する。**exit 9・10・11 は Step 3 / Step 4 のループ自体が fatal 扱いせず継続する**ため(終了コード表・上記ループを参照)、 これらは計画配列の残り全件を処理し終えたうえで Step 9 に到達する。Step 3 / Step 4 が stderr へ 出力する `SKIPPED` / `NEEDS_REVIEW`(および `SKIPPED_ORPHANS` / `NEEDS_REVIEW_ORPHANS`)の内容を そのままこの節へ転記する。 ## 検証 - ルート issue 本文の Phase 別表が更新されていることを確認する。あわせて**マーカー外の節(概要・運用・手書き節)が更新前後で変わっていない**こと、`YYYY-MM-DD`・`N 件` 等のプレースホルダーが本文に無いことを確認する(初回は `update-root-body.sh ... --dry-run` で出力を確認してから書き込む) - closed Phase 親の下に open issue が残置されていないことを確認する。**ただし exit 10 (補償復旧成功)で要確認事項へ記録された issue は、承認範囲内の逆操作として旧親(closed) 配下へ意図的に復帰しているため、この確認の例外として扱う。** 単独では「残置」に見えるので、 Step 9 の要確認事項の記録と突き合わせて exit 10 由来であることを確認する - Step 3 / Step 4 で呼んだ `reassign-sub-issue.sh` の各回について、`echo "exit=${status}"` の値と `result=` 行を確認する。非ゼロ終了があれば Step 9 の要確認事項へ反映されているか確認する。 加えて、**exit 1〜8(9・10・11 を除く)**の fatal 系はコードブロック自体の終了ステータスが 非ゼロで返ること(`echo` を最後のコマンドにして握り潰していないこと)を確認する。 **exit 9・10・11**は fatal ではなくループ継続の契約であるため、コードブロックはそのまま 正常終了(終了ステータス 0)で最後の `SKIPPED*` / `NEEDS_REVIEW*` echo まで到達すること、 かつ `REASSIGN_PLAN` / `ORPHAN_PLAN` の残り全件が処理されていることを確認する ```bash # 全 sub-issues の state を確認 gh api "repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues" \ --jq '.[] | {number: .number, state: .state, title: .title}' # Phase 親の sub-issues も確認 gh api "repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues" \ --jq '.[] | {number: .number, state: .state}' # phase ラベルの同期確認(各 Phase 親直下で確認) gh api "repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues" \ --jq '.[] | {number: .number, labels: [.labels[].name]}' ``` ## よくある失敗 | 問題 | 回避策 | |------|--------| | 付け替えの DELETE が 404 になり、続く POST が 422 で失敗する | 削除のパスだけ単数形 `sub_issue`。複数形 `sub_issues` は 404 になり、旧親から外れないまま POST するため `Sub issue may only have one parent` で必ず失敗する(`reassign-sub-issue.sh` は DELETE 失敗時に POST へ進まないため、この連鎖失敗自体は起きない。手動で `gh api` を直接叩く場合の注意として記載を残す) | | 管理ブロック内を手で編集したが次回の棚卸しで上書きされた | マーカー間は毎回再生成される。残したい内容はマーカーの外へ書く | | 管理マーカーを片方だけ消して Step 8 が exit 4 になる | 両方を戻すか両方とも消す(マーカーを消すと次回は移行扱いになり、旧ブロックは `
` へ退避される) | | `--granularity` に `2 h`・`2`・`0h` 等を渡して中断される | 正整数+h 形式(`^[1-9][0-9]*h$`。例: `2h`・`4h`)で指定する | ## 注意事項 - **棚卸し前に変更内容をユーザーに提示して確認を取る**(Step 2 参照) - ページネーション: sub-issues が 100 件を超える場合は `per_page=100&page=N` でページングして全件取得する(Step 1 のツリー全体取得に適用。`reassign-sub-issue.sh` は対象 issue の `parent_issue_url` を直接参照するため、付け替え判定自体にはページネーションが不要) - シェルコマンドの変数は必ず `"${var}"` でクォートする(コマンドインジェクション対策) - **`gh issue create` は `--json` 非対応**。issue URL を stdout に出力するため、`| grep -oE '[0-9]+$'` で末尾の番号を抽出する - **sub_issues API(POST / DELETE)の `sub_issue_id` は issue 番号ではなく database id**(GitHub 仕様)。`gh api "repos/{owner}/{repo}/issues/" --jq '.id'` で id を取得してから渡す。番号をそのまま渡すと誤った issue を操作する/404 になる(`reassign-sub-issue.sh` はこれを内部で解決するため、Step 3/4 で手動取得する必要はない) - 孤児 issue の Phase が判断できない場合は推測せずにユーザーへ確認する - sub_issues の DELETE API(付け替え時に旧親から外す操作)はパスが単数形 `sub_issue` である点に注意し、操作対象の issue 番号を必ず確認してから実行する - ツリー更新後は implement-issue-tree が post-order DFS で正しく消化できる構造になっているか確認する - Step 3 / Step 4 の付け替え処理は `scripts/reassign-sub-issue.sh` を使う。SKILL.md 本文へ素の `gh api` DELETE/POST を書き戻さない(状態変数の受け渡しがコードフェンス境界で壊れるクラスの欠陥に戻るため。詳細は `scripts/reassign-sub-issue.sh` 冒頭コメントを参照) - Step 8 は `scripts/update-root-body.sh` を使う。`gh issue edit --body` でルート本文全体を書き戻さない(手書きの本文が消えるため。詳細は `scripts/update-root-body.sh` 冒頭コメントを参照)。誤更新時は GitHub の本文編集履歴から復元できる