update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

; then\n echo \"エラー: --granularity の値 '${GRANULARITY}' は不正です(許可: 正整数+h、例 2h)。中止します。\"\n exit 1\nfi\n\n# 確定値と由来を必ず出力する(既定 2h と読み替えないことをここで可視化する)\necho \"粒度基準: ${GRANULARITY}(由来: ${GRANULARITY_SOURCE})\"\n\n# ルート直下の sub-issues を取得(ページネーション対応)\nfetch_sub_issues() {\n local PARENT=\"${1}\"\n local PAGE=1\n while true; do\n RESULT=$(gh api \\\n \"repos/{owner}/{repo}/issues/${PARENT}/sub_issues?per_page=100&page=${PAGE}\")\n echo \"${RESULT}\"\n COUNT=$(echo \"${RESULT}\" | jq 'length')\n if [ \"${COUNT}\" -lt 100 ]; then break; fi\n PAGE=$((PAGE + 1))\n done\n}\n\n# ルートから再帰的にツリーを構築\nfetch_sub_issues \"${ROOT_NUMBER}\"\n```\n\n以降の Step は Step 1 で出力された `GRANULARITY` の値を使う(既定 2h と読み替えない)。\n\n各 issue の `state`(open / closed)・ラベル・タイトルを記録してツリーマップを作成する。\n\n### Step 2: 棚卸し対象を特定する\n\n取得したツリーマップを分析し、以下のケースを特定する。\n\n| ケース | 対応方針 |\n|--------|---------|\n| closed 親の下に open issue が残置されている | 適切な open Phase 親へ付け替え |\n| どの親にも紐付いていない孤児 issue がある | 該当 Phase 親へ紐付け(Phase が不明な場合はユーザーに確認) |\n| phase ラベルが親と一致しない issue がある | ラベルを同期 |\n| 既存 Phase に収まらない新規タスクがある | 新 Phase 親の新設を検討 |\n| 実装時間が `${GRANULARITY}`(Step 1 で確定。既定 2h)超の issue が分解されていない | sub-issue に分解(create-issue-tree と同じ粒度基準) |\n| 対象 issue の親が別リポジトリにある(cross-repository sub-issue) | 本スキルの対象外。棚卸し対象から除外し、Step 9 の要確認事項へ記載する |\n| `owner:human` / `owner:joint` ラベル付きの open issue がある | 一覧化して Step 9 の要確認事項へ記載するだけ。ラベルは自動で付け外ししない(人手の判断を機械が書き換えない) |\n\n棚卸し対象の一覧をユーザーに提示し、方針確認を取ってから変更を実行する。\n\nowner ラベル付き issue は、Step 1 のツリーマップ(sub_issues API の応答の `labels`)に記録したラベルからだけ抽出する。\n対象は Step 1 のツリーに含まれる番号に限る(リポジトリ全体の `gh issue list` は 30 件上限で別ツリーも混ざるため使わない)。\nラベルが不明な番号だけ `gh issue view \"${N}\" --json number,labels` で取得し、gh のエラーは stdout に出るため\n**終了コード**で成否を判定する(失敗を「該当なし」と読まない)。\nこれらの issue は `implement-issue-tree` を `skipLabels: [\"owner:human\",\"owner:joint\"]` 付きで呼ぶと自動実装の対象外になる。\n\n**承認された対象を Step 3 / Step 4 が読む 2 つの計画配列へ落とす。** 配列は必ず宣言する\n(対象 0 件でも空配列として宣言する)。未宣言のまま Step 3 / Step 4 へ進むと\n`\"${REASSIGN_PLAN[@]}\"` が空展開され、承認済みの対象があってもエラーなく 0 件で完走して\n完了報告まで進んでしまうため、両ステップの冒頭で宣言の有無を検査して fail-closed で停止する。\n\n```bash\n# 承認された「closed 親下の付け替え」対象。要素は \"\u003cissue> \u003cold-parent> \u003cnew-parent>\"。\n# 空白区切りの 1 行 1 件にするのは、Step 3 のループが read で分割して受け取るため。\n# issue 番号は全て 10 進の正整数であり、空白・改行を含まないことを Step 1 の取得時に保証する。\nREASSIGN_PLAN=(\n # \"123 456 789\"\n)\n\n# 承認された「孤児の再配置」対象。要素は \"\u003corphan-issue> \u003cphase-parent>\"。\nORPHAN_PLAN=(\n # \"234 789\"\n)\n```\n\n対象が 0 件の場合も上記のとおり**空配列として宣言する**。「対象なし(空配列)」と\n「計画未設定(未宣言)」は意味が異なり、後者は Step 2 の実行漏れであって正常系ではない。\n\n### Step 3: closed 親下の残置 open issue を付け替える\n\nclosed 親の下に残置されている open issue を、対応する open Phase 親へ移動する。\n「旧親から DELETE → 新親へ POST」の 2 段操作と、その前後の冪等性判定・事後確認は\n`scripts/reassign-sub-issue.sh` に集約されている(SKILL.md 本文に素の `gh api` を並べると、\nDELETE 失敗検知なしに POST へ進む等の欠陥を生むため)。\nその呼び出し側で必要な共有処理(計画配列の存在・型検証、1 件呼び出しの終了ステータス解釈)は\n`scripts/reassign-lib.sh` に切り出してあり、Step 3 / Step 4 がそれぞれ source して使う。\n\n**各コードフェンスは独立したシェルで実行され得る**(`reassign-sub-issue.sh` の設計前提と同じ)。\nそのため共有処理をどちらかのフェンス内で定義して他方が再利用する構成にはしない。Step 4 の\n実行時に関数定義が継承されず `command not found` となり、孤児の再配置が一切実行されないため。両 Step のフェンスは同一の前置きで\n`reassign-lib.sh` を自己完結的に解決・source する。\n\nこのスキルの配置ルートは導入形態(本リポジトリのソース/`npx skills add` による\nvendoring/`.claude/skills/` symlink 経由)で異なる。source 前に 3 レイアウトを順に確認し、\n実在するものを採用する(implement-issue-tree の `scriptPath` 3 レイアウト・contribute-skill の\n`LOCAL_SKILL_DIR` 解決と同じ考え方)。`reassign-sub-issue.sh` のパスはここでは指定しない——\nライブラリが自身の実体位置から兄弟として解決するため、どの導入形態でも対の実体が必ず組み合う。\n\n```bash\n# reassign-lib.sh を 3 レイアウトから解決して source する。\n# **この前置きは Step 3 / Step 4 の両フェンスが同一の内容で持つ。** 手順書のコードフェンスは\n# ブロックごとに独立シェルで実行され得るため、片方のフェンスで定義した関数・変数がもう一方へ\n# 継承される保証がない(Issue #372 / PR #374 codex-review P1)。\nREASSIGN_LIB=\"\"\nfor CANDIDATE in \\\n \"skills/update-issue-tree/scripts/reassign-lib.sh\" \\\n \".agents/skills/update-issue-tree/scripts/reassign-lib.sh\" \\\n \".claude/skills/update-issue-tree/scripts/reassign-lib.sh\"; do\n # 存在確認は -f のみで行う(-x にすると、npx skills add 等の vendoring で\n # 実行ビットが落ちたファイルを「存在しない」と誤検知し、3 レイアウトいずれにも\n # 見つからないという誤ったエラーメッセージになる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n REASSIGN_LIB=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${REASSIGN_LIB}\" ]]; then\n echo \"エラー: reassign-lib.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n# ライブラリは対の reassign-sub-issue.sh の存在まで確認したうえで、reassign_one /\n# require_plan_array と REASSIGN_SCRIPT を定義する。欠落していれば非ゼロを返すので中断する。\n# このフェンスで set -euo pipefail を使ってはならない(理由は reassign-lib.sh 冒頭のコメント参照。\n# set -e があると reassign_one 内の `status=$?` 退避に到達せず、Issue #335 の欠陥が再発する)\nsource \"${REASSIGN_LIB}\" || exit 1\n\n# 計画配列の存在・型ガード(fail-closed)。詳細は reassign-lib.sh の require_plan_array を参照\nrequire_plan_array REASSIGN_PLAN || exit 1\n\n# 呼び出し側ループ。契約の主体はここにある——「(b) 恒久的な対象外は次の 1 件へ進み、\n# (a) 解消可能な前提不備は原因解消まで中断する」を実際に実現するのはこのループである。\n# ISSUE_NUMBER / OLD_PARENT / NEW_PARENT は Step 2 で構築した REASSIGN_PLAN から供給する。\nSKIPPED=()\nNEEDS_REVIEW=()\nfor ENTRY in \"${REASSIGN_PLAN[@]}\"; do\n # ENTRY は \"\u003cissue> \u003cold-parent> \u003cnew-parent>\" 形式。\n # zsh は未クォートの展開を単語分割しないため、read で明示的に分割する\n IFS=' ' read -r ISSUE_NUMBER OLD_PARENT NEW_PARENT \u003c\u003c\u003c \"${ENTRY}\"\n # `if reassign_one ...; then ... fi` の形にしてはならない。条件が偽で else が無い場合、\n # `fi` の直後の $? は 0 になり(実測済み)、失敗が「成功」として読まれて\n # (a)/(b)/(c) の判定も中断もすべて素通りする。`|| status=$?` で明示的に退避する。\n status=0\n reassign_one --issue \"${ISSUE_NUMBER}\" \\\n --old-parent \"${OLD_PARENT}\" \\\n --new-parent \"${NEW_PARENT}\" || status=$?\n if (( status == 0 )); then\n continue\n fi\n if (( status == 9 )); then\n # (b) cross-repository 親。棚卸し対象から除外して次へ(Step 9 の要確認事項へ記載)\n SKIPPED+=(\"${ISSUE_NUMBER}\")\n continue\n fi\n if (( status == 10 )); then\n # (c) 補償復旧が成功し旧親へ復帰済み(本来の付け替えは未達)。fatal にせず要確認事項へ\n # 記録して次の 1 件へ進む。exit 10 を中断扱いにすると残りの REASSIGN_PLAN が未処理のまま\n # 中断し、Step 4 の孤児処理・Step 9 の報告経路自体がスキップされてしまう\n NEEDS_REVIEW+=(\"#${ISSUE_NUMBER}: exit=10 restored — 旧親 #${OLD_PARENT} へ復帰済み。新親 #${NEW_PARENT} への付け替えは未達\")\n continue\n fi\n if (( status == 11 )); then\n # (c) DELETE 後、第三者が別親を設定済みのため補償せず見送り。同一コマンドでの再実行禁止を\n # 記録に残したうえで、fatal にせず次の 1 件へ進む\n NEEDS_REVIEW+=(\"#${ISSUE_NUMBER}: exit=11 third-party-parent — 旧親 #${OLD_PARENT} から DELETE 済み、第三者が別親を設定済み。同じコマンドで再実行禁止。stderr の実測親を確認しユーザー承認のうえ再実行すること\")\n continue\n fi\n # (a) それ以外(exit 1-8。9・10・11 を除く)は握り潰さず中断する。原因を解消してから再実行する\n echo \"エラー: #${ISSUE_NUMBER} の付け替えが exit ${status} で失敗した。中断する\" >&2\n exit \"${status}\"\ndone\nif (( ${#SKIPPED[@]} > 0 )); then\n echo \"対象外(cross-repository 親): ${SKIPPED[*]}\" >&2\nfi\nif (( ${#NEEDS_REVIEW[@]} > 0 )); then\n echo \"要確認事項(Step 9 のレポートへ転記すること):\" >&2\n printf ' %s\\n' \"${NEEDS_REVIEW[@]}\" >&2\nfi\n```\n\n`reassign_one` は**1 件分の呼び出し**であり、非ゼロ終了は**当該 1 件の失敗**として\n関数の返り値へ伝播する。判定に必要な stderr は関数内で捕捉したうえで必ず再出力するため、\n診断情報は失われない。呼び出し側ループは Step 9 の要確認事項へ記録したうえで、次の 3 通りに\n振り分ける。**(a) 解消可能な前提不備**(exit 2 のうち `gh`/`jq` 不在・未認証・issue 取得失敗等。\n9・10・11 を除く exit 1〜8 全般)は原因解消まで中断する。**(b) 恒久的な対象外**\n(cross-repository 親。exit 9)は要確認事項へ記載して棚卸し対象から除外し、次の 1 件の\n呼び出しへ進む。**(c) 部分的に変更済みだが処理は継続してよいもの**(exit 10 = DELETE 後の\n補償復旧が成功し旧親へ復帰済み・exit 11 = DELETE 後に第三者が別親を設定済みで補償を見送り)は\n要確認事項へ記録したうえで、fatal にせず次の 1 件へ進む。exit 10 / 11 を即 `exit` で中断すると\n残りの `REASSIGN_PLAN` が未処理のまま止まり、Step 4 の孤児処理・Step 9 の報告経路自体が\nスキップされる欠陥になるため、この 2 コードはループを止めない(終了コード表を参照)。\n(a)/(b) の判定は stderr に `reason=cross-repository-parent` が出ているかで機械的に行う\n(無ければ (a))。(c) は返り値がそのまま 10 / 11 であるため、判定に stderr のマーカーは不要。\n\n**(c) だけを継続対象にする理由(exit 3/4/5/7/8 は継続しない)**: 終了コード表を見ると\nexit 3・4・5・7・8 も「呼び出し側の扱い」欄に「要確認事項へ記載」と書かれており、一見\n(c) と同じに見える。しかし止める・止めないの分岐点は「要確認事項へ記載するか」ではなく\n**当該 1 件の終端状態が実測で確定しているか**である。exit 10 / 11 は補償復旧ルーチンが\n実状態を再取得した末に「旧親配下」「第三者親配下」という**確定した終端状態**へ着地しており、\n次の 1 件へ進んでも新たな破壊は生じない。対して exit 3(DELETE 失敗)・4(POST 失敗)・\n5(事後確認不一致)・7(POST 時点のレース)・8(補償も失敗し状態不明/孤児)は、無変更・\n孤児化・状態不明のいずれかであり、原因(`gh` 認証切れ・API 障害・レース条件)が\n**後続の全件に共通して波及する可能性が高い**ため、1 件で切り上げて原因究明を優先する。\nこの境界線は exit 10 / 11 に限定したものであり、3/4/5/7/8 へ安易に拡張しない。\n\n**引数**\n\n| 引数 | 必須 | 意味 |\n|------|------|------|\n| `--issue` | 必須 | 付け替え対象の issue 番号 |\n| `--new-parent` | 必須 | 付け替え先の issue 番号。DELETE の前に新親を GET し、存在すること・`--issue` 自身でないこと(自己参照)・対象 issue と同一リポジトリにあることを検証する。いずれかを満たさない場合は**DELETE を 1 件も撃たずに** exit 1(自己参照)/ exit 2(存在しない・別リポジトリ)で無変更終端する |\n| `--old-parent` | 任意 | 現在の親。Step 2 でユーザーが承認した旧親を渡す。実測した現在の親と食い違う場合は**何も変更せず exit 6 で停止**する(承認外の親子関係を壊さないため)。省略時は「孤児である」ことを承認した意味になり、実測で親が居れば同じく exit 6 で停止する |\n| `--repo` | 任意 | `owner/name`。**対象 issue(`--issue`)の所在**であり、親(`--old-parent` / `--new-parent`)の所在ではない。省略時は cwd の git remote から解決。`--repo` は対象 issue の GET だけでなく DELETE / POST を含む全 API パスを切り替えるため、親が別リポジトリにあるからといって親リポジトリの値を入れてはならない(入れると親リポジトリ側の同番号 issue を誤操作する) |\n\n**終了コードと `result=` 行**\n\nstdout 最終行が `result=\u003cstate> issue=\u003cn> new_parent=\u003cn> old_parent=\u003cn|->` の形式で\n機械可読な内訳を返す。**非ゼロ終了は 1 件も握り潰さず、Step 9 の完了レポートの\n「要確認事項」へ必ず記載する。**\n\n| 終了コード | `state` | 意味 | 呼び出し側の扱い | 変更の有無 |\n|-----------|---------|------|----------------|-----------|\n| 0 | `reassigned` | DELETE→POST を実施 | 「付け替え」件数へ計上 | 変更あり(成功) |\n| 0 | `already-attached` | 既に新親配下(no-op) | 件数へ計上しない | 無変更(no-op) |\n| 0 | `posted-only` | 旧親配下になく POST のみ | 「孤児の再配置」件数へ計上(Step 4 と同一スクリプト) | 変更あり(POST のみ) |\n| 1 | — | 引数・使い方エラー(**`--new-parent` の自己参照を含む**) | 実行者の誤り。修正して再実行 | **無変更**(API 未実行) |\n| 2 | — | 前提不備。2 類型が混在し、**stderr のマーカー行で機械的に判定する**: `reason=cross-repository-parent` が出ていれば (b)、無ければ (a)。(a) 解消して再実行できるもの(`gh`/`jq` 不在・未認証・issue 取得失敗・**新親が存在しない**・**新親が Pull Request**・**新親が別リポジトリ**)と、(b) **恒久的に対象外**の cross-repository 親(同一コマンドの再実行では解決しない。親リポジトリ側で親リンクが外れるまで本スキルでは処理できない) | (a) は原因を解消して再実行。(b) は要確認事項へ記載し、棚卸し対象から除外する(Step 2 参照) | **無変更**(DELETE / POST 未実行) |\n| 3 | — | DELETE 失敗(gh の非ゼロ終了)。**POST は実行していない。** DELETE の応答だけを信頼せず実状態を再取得・安定確認したうえで、実測でも旧親配下のままであることを確認して初めて確定する(応答取得だけが失敗しサーバー側では成立していた場合は孤児として POST 工程へ進むため、この exit には到達しない) | 要確認事項へ記載。旧親配下のまま | **無変更**(実測で確認済み。DELETE 失敗・旧親配下のまま) |\n| 4 | — | POST 失敗(**孤児経路のみ**。DELETE を伴わないため孤児化リスク自体は無い)。POST の応答だけを信頼せず実状態を再取得・安定確認したうえで、実測でも孤児のままであることを確認して初めて確定する(応答取得だけが失敗しサーバー側では成立していた場合は `posted-only` として exit 0 になるため、この exit には到達しない) | 要確認事項へ記載 | **無変更**(実測で確認済み。DELETE 未実行) |\n| 5 | — | 事後確認で新親配下に見つからない(別リポジトリの親配下にある場合を含む) | 要確認事項へ記載。手動で実状態を確認 | 変更あり(DELETE / POST は実行済み・実状態の手動確認が必要) |\n| 6 | — | **承認された旧親と実測が食い違う。何も変更していない** | 要確認事項へ記載。**同じコマンドで再実行してはならない。** stderr が示す実測の親をユーザーへ提示して承認を得たうえで、`--old-parent` にその値を入れて再実行する | **無変更** |\n| 7 | — | POST 時点で別の親が付いていたレース。**DELETE 未実行のため無変更** | 要確認事項へ記載。実測し直して承認を取り直したうえで再実行する | **無変更**(DELETE 未実行) |\n| 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 がサーバー側で成立していたか未確定のため、無変更とも部分変更とも断定しない) |\n| 10 | `restored` | DELETE 後の POST 失敗に対する補償復旧が**成功**し、実測で旧親配下へ復帰した(本来の目的だった新親への付け替えは未達) | 要確認事項へ記載(本来の付け替えが未達のため)。件数へは計上しない | **変更あり**(旧親へ復帰。新親への付け替えは不成立) |\n| 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 に到達する時点で既に成立している(第三者の親配下に見えている以上、サーバー側のデータモデル上、旧親からは既に外れている)。** 実測では第三者が設定した別親配下にある) |\n\n**スクリプト自身は 0〜8・10・11 を返す(9 は使わない)。** `reassign-lib.sh` の\n`reassign_one` ラッパは、これに加えて **9 = 恒久的に対象外(cross-repository 親)** を返す。9 はスクリプトの終了コードではなく、ラッパが stderr の\n`reason=cross-repository-parent` マーカーを判定して合成する値であり、呼び出し側ループが\n「棚卸し対象から除外して次の 1 件へ進む」のシグナルとして使う。マーカーの判定には\nスクリプトの stderr が必要なため、ラッパは stderr をファイルへ捕捉したうえで**必ず再出力する**\n(診断情報は握り潰さない)。10・11 はラッパの「その他はスクリプトの終了コードをそのまま返す」\n契約により無加工で透過するため、`reassign-lib.sh` 側の変更は不要。\n\n**DELETE 後の POST 失敗時の補償復旧の契約**: 新親の事前検証は「事前に判定できる\n拒否条件」を DELETE 前に潰すが、DELETE と POST の間のレース・一時的な 5xx は事前判定できない\n残余として残る。POST 失敗で即終端すると対象 issue が孤児のまま滞留するため、POST 失敗を\n検知した時点で**対象 issue の実状態を再取得**してから分岐する(実測を先に確定させてから動く):\n- 実測の親が新親 → POST は偽陰性だった。通常の成功終端(`exit 0` / `result=reassigned`)と同じ扱い。\n ただし旧親・孤児を期待した再確認の途中で新親を初めて観測した場合は、新親を期待値として\n もう 1 回取得し、2 回連続で新親配下が観測できたときにだけ `reassigned` を確定する。\n 一致しない・再取得に失敗した場合は成功へ倒さず `exit 8`(状態不明)で終端する\n- 実測で孤児 → 1 回の読み取りだけでは DELETE/POST の反映遅延による過渡状態を見ている\n 可能性を排除できないため、補償 POST という書き込みを行う前に短い間隔を空けて再取得し、\n 2 回連続で親なしが観測できて初めて安定した孤児として確定する。安定確認できれば、承認済み操作の逆操作\n (旧親へ戻す)は承認範囲内のため補償 POST を撃つ。成功なら事後確認を取り直し\n `exit 10` / `result=restored`。補償 POST 自体が失敗する多重障害、または再取得・事後確認・\n 安定確認が失敗する状態不明ケースは書き込まずに `exit 8` で終端する\n- 実測で第三者が別の親(別リポジトリを含む)を設定済み → **補償 POST を撃たず** `exit 11` で\n fail-closed 停止する。第三者が確定させた親子関係を上書きしない\n\n**DELETE 失敗・孤児経路 POST 失敗の実状態再確認**: 上記は DELETE 成功後の\nPOST 失敗(部分変更)に対する補償復旧だが、DELETE 自体の失敗(`reason` 系マーカーを持たない\ngh の非ゼロ終了)と孤児経路(DELETE を伴わない)の POST 失敗も同様に扱う。\n`gh api` は終了コード 0 で返しても応答取得だけが失敗することがある一方、非ゼロ終了は\n「サーバー側で処理が成立しなかった」ことを保証しない(応答取得側の失敗も非ゼロになり得る)。\nこのため DELETE / POST の非ゼロ終了だけを根拠に exit 3 / exit 4(無変更)と即断すると、\n実際にはサーバー側で成立していた場合に誤報になる。\n対称に実状態を再取得してから分岐する:\n- **DELETE 失敗** → 実状態を再取得し、旧親配下のまま安定確認できれば `exit 3`\n (無変更)。孤児であることを安定確認できれば DELETE はサーバー側で成立済みとみなし、\n そのまま POST 工程(新親への付け替え)へ進む。**新親配下であることを安定確認できれば、\n 第三者親判定より先に判定し**、DELETE→POST が既に成立していた(または並行実行が承認済みの\n 新親へ既に付け替え済みだった)とみなして既存の成功経路と同じ `result=reassigned` で\n `exit 0` にする(最初の読み取りで直接新親を観測した場合も、安定確認の途中で新親を観測した\n 場合と同じ成功終端にそろえ、観測タイミングだけで承認外の第三者親(`exit 11`)へ倒れないようにする)。それ以外で第三者が別の親を設定済みなら\n `exit 11` `reason=third-party-parent`(既存の補償復旧経路と同じ扱い)。再取得の失敗・\n 不一致・不安定は `exit 8` `reason=recovery-state-unknown`\n- **孤児経路(DELETE なし)の POST 失敗** → 実状態を再取得し、孤児のまま安定確認できれば\n `exit 4`(無変更)。新親配下であることを安定確認できれば POST は偽陰性だった\n とみなし `result=posted-only` で `exit 0`。この経路には DELETE が無く孤児化リスク自体が\n 無いため、補償の書き込み(POST)は発生しない(判定のみで完結する)。エラーメッセージ\n ベースのレース検知(`exit 7`。\"only have one parent\")は再取得より先に判定する。それ以外\n (再取得の失敗・不一致・不安定・メッセージ非該当の第三者親)は `exit 8`\n `reason=recovery-state-unknown` で終端する\n\n**事前検証と事後報告の契約**: 事前に判定できる拒否条件(新親の不存在・自己参照・\n別リポジトリ)は DELETE を撃たずに exit 1 / 2 で無変更終端する。一方、**事前に判定できない\n拒否条件(事前 GET と DELETE / POST の間に第三者が状態を動かすレース、循環参照、新親が\nsub-issue を受け付けない状態など)は exit 4 / exit 7 / exit 8 として事後に報告する**\n契約であり、事前検証はこれを置き換えない。DELETE 後の POST 失敗(孤児化しうる部分変更)\nだけは exit での即報告ではなく、上記の補償復旧ルーチンを経由してから exit 8 / 10 / 11 の\nいずれかへ着地する。\n\n**GET 回数**: 経路ごとの GET 回数は次のとおり(付け替え・再配置の経路は新親の事前検証の 1 回を含む)。\n\n| 経路 | GET 回数 |\n|------|---------|\n| 正常な付け替え(reassigned) | 3 |\n| 孤児の再配置(posted-only) | 3 |\n| already-attached(no-op) | 1 |\n| 承認不一致(exit 6) | 1 |\n\n事前検証の GET は最大 1 回で、`update-issue-tree` の 1 ラン当たりの呼び出し回数は数十件規模。\n認証済み REST の 5,000 req/h に対して無視できる。対して防ぐのは**不可逆な孤児化**であり、\n割に合う。孤児経路(DELETE を伴わず孤児化リスク自体は無い経路)にも検証を通しているのは、\n「事前に判定できる拒否条件は必ず無変更で終端する」契約を経路によらず統一する意図的な判断\nであり、新親が存在しない場合は孤児経路でも exit 2 になる。\n\n### Step 4: 孤児 issue を再配置する\n\nどの親にも紐付いていない孤児 issue を適切な Phase 親へ紐付ける。\n`--old-parent` を省略して同じスクリプトを呼ぶ(DELETE を飛ばして POST のみ実行される)。\nPhase が不明な issue はタイトル・本文を読んで判断し、判断できない場合はユーザーに確認する。\n\n**このフェンスは Step 3 の実行結果に依存せず単体で実行できる。** フェンスは独立シェルで\n実行され得るため、Step 3 が定義した関数・変数を継承しない前提で書く。前置きは Step 3 と同一である。\n\n```bash\n# reassign-lib.sh を 3 レイアウトから解決して source する。\n# **この前置きは Step 3 / Step 4 の両フェンスが同一の内容で持つ。** 手順書のコードフェンスは\n# ブロックごとに独立シェルで実行され得るため、片方のフェンスで定義した関数・変数がもう一方へ\n# 継承される保証がない(Issue #372 / PR #374 codex-review P1)。\nREASSIGN_LIB=\"\"\nfor CANDIDATE in \\\n \"skills/update-issue-tree/scripts/reassign-lib.sh\" \\\n \".agents/skills/update-issue-tree/scripts/reassign-lib.sh\" \\\n \".claude/skills/update-issue-tree/scripts/reassign-lib.sh\"; do\n # 存在確認は -f のみで行う(-x にすると、npx skills add 等の vendoring で\n # 実行ビットが落ちたファイルを「存在しない」と誤検知し、3 レイアウトいずれにも\n # 見つからないという誤ったエラーメッセージになる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n REASSIGN_LIB=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${REASSIGN_LIB}\" ]]; then\n echo \"エラー: reassign-lib.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n# ライブラリは対の reassign-sub-issue.sh の存在まで確認したうえで、reassign_one /\n# require_plan_array と REASSIGN_SCRIPT を定義する。欠落していれば非ゼロを返すので中断する。\n# このフェンスで set -euo pipefail を使ってはならない(理由は reassign-lib.sh 冒頭のコメント参照。\n# set -e があると reassign_one 内の `status=$?` 退避に到達せず、Issue #335 の欠陥が再発する)\nsource \"${REASSIGN_LIB}\" || exit 1\n\n# Step 3 と同じガードを同じ関数で行う(非対称を作らない)\nrequire_plan_array ORPHAN_PLAN || exit 1\n\nSKIPPED_ORPHANS=()\nNEEDS_REVIEW_ORPHANS=()\nfor ENTRY in \"${ORPHAN_PLAN[@]}\"; do\n # ENTRY は \"\u003corphan-issue> \u003cphase-parent>\" 形式\n IFS=' ' read -r ORPHAN_NUMBER PHASE_NUMBER \u003c\u003c\u003c \"${ENTRY}\"\n # Step 3 と同じ理由で `if ...; then ... fi` は使わない(`fi` 直後の $? は 0 になる)\n status=0\n reassign_one --issue \"${ORPHAN_NUMBER}\" --new-parent \"${PHASE_NUMBER}\" || status=$?\n if (( status == 0 )); then\n continue\n fi\n if (( status == 9 )); then\n SKIPPED_ORPHANS+=(\"${ORPHAN_NUMBER}\")\n continue\n fi\n if (( status == 10 )); then\n # Step 3 と同じ (c) 扱い。孤児経路は --old-parent を渡さないため通常は起こり得ないが、\n # ループ構造を Step 3 と非対称にしないため同じ分岐を持つ\n NEEDS_REVIEW_ORPHANS+=(\"#${ORPHAN_NUMBER}: exit=10 restored — 補償復旧が発生。Phase 親 #${PHASE_NUMBER} への紐付けは未達。実状態を確認すること\")\n continue\n fi\n if (( status == 11 )); then\n NEEDS_REVIEW_ORPHANS+=(\"#${ORPHAN_NUMBER}: exit=11 third-party-parent — 第三者が別親を設定済み。同じコマンドで再実行禁止。stderr の実測親を確認しユーザー承認のうえ再実行すること\")\n continue\n fi\n echo \"エラー: #${ORPHAN_NUMBER} の再配置が exit ${status} で失敗した。中断する\" >&2\n exit \"${status}\"\ndone\nif (( ${#SKIPPED_ORPHANS[@]} > 0 )); then\n echo \"対象外(cross-repository 親): ${SKIPPED_ORPHANS[*]}\" >&2\nfi\nif (( ${#NEEDS_REVIEW_ORPHANS[@]} > 0 )); then\n echo \"要確認事項(Step 9 のレポートへ転記すること):\" >&2\n printf ' %s\\n' \"${NEEDS_REVIEW_ORPHANS[@]}\" >&2\nfi\n```\n\nStep 3 と同一の関数・同一のループ構造であり、終了コードの扱いも同じ\n((a) 解消可能な前提不備のみ原因解消まで中断、(b) 恒久的な対象外(exit 9)は要確認事項へ記載して\n次の 1 件へ進む、(c) 部分的に変更済みだが継続してよいもの(exit 10 / 11)も要確認事項へ記録して\nfatal にせず次の 1 件へ進む。(b) の判定は関数が捕捉した stderr の\n`reason=cross-repository-parent` マーカーで機械的に行い、返り値 9 として呼び出し側へ伝える。\n終了コード表を参照)。\n\n### Step 5: 必要に応じて新 Phase 親を新設する\n\n既存 Phase に収まらない新規タスクが多い場合、新 Phase 親 issue を作成してルートへ紐付ける。\n(この POST は `reassign-sub-issue.sh` を使わない。たった今作成した、親を持たないことが\n自明な issue への単発 POST であり、DELETE パス・冪等性判定の対象外のため)\n\n```bash\n# phase ラベルが存在しないリポジトリでは issue 作成が失敗するため、必ず事前作成する\n# (作成済みの場合は失敗を無視して続行する)\ngh label create \"phase:N\" --color \"0075ca\" 2>/dev/null || true\n\n# gh issue create は URL を出力する(--json 非対応)。URL 末尾から番号を抽出する\nNEW_PHASE_URL=$(gh issue create \\\n --title \"feat(phase-N): Phase N タイトル\" \\\n --label \"phase:N\" \\\n --body \"$(cat \u003c\u003c'EOF'\n## 概要\n\nPhase N の実装タスクをまとめる親 issue。\n\n## タスク一覧\n\n| Issue | タイトル | 分解 |\n|-------|---------|------|\nEOF\n)\")\nNEW_PHASE_NUMBER=$(printf '%s' \"${NEW_PHASE_URL}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

)\n\n# ルートへ紐付け。sub_issue_id は issue 番号ではなく database id を渡す(GitHub sub-issues API 仕様)\nNEW_PHASE_ID=$(gh api \"repos/{owner}/{repo}/issues/${NEW_PHASE_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${NEW_PHASE_ID}\"\n```\n\n### Step 6: phase ラベルを同期する\n\n各 issue の phase ラベルが親 Phase と一致しているか確認し、不一致のラベルを修正する。\n\n```bash\n# ラベルを追加\ngh issue edit \"${ISSUE_NUMBER}\" --add-label \"phase:1\"\n\n# 古いラベルを削除\ngh issue edit \"${ISSUE_NUMBER}\" --remove-label \"phase:0\"\n```\n\n### Step 7: 粒度基準超の issue を sub-issue に分解する\n\n棚卸し中に実装時間が `${GRANULARITY}`(Step 1 で確定。既定 2h)超と判断した issue は、create-issue-tree と同じ粒度基準で sub-issue に分解する。\n(この POST も `reassign-sub-issue.sh` を使わない。理由は Step 5 と同じ: 新規作成した\n親なし issue への単発 POST)\n\n```bash\n# phase ラベルが存在しない場合に備えて事前作成する(作成済みなら no-op)\ngh label create \"phase:N\" --color \"0075ca\" 2>/dev/null || true\n\n# sub-issue を作成(URL 末尾から番号を抽出)\nSUB_URL=$(gh issue create \\\n --title \"feat: サブタスク名\" \\\n --label \"phase:N\" \\\n --body \"...\")\nSUB_NUMBER=$(printf '%s' \"${SUB_URL}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

)\n\n# 親 issue へ紐付け(sub_issue_id は database id)\nSUB_ID=$(gh api \"repos/{owner}/{repo}/issues/${SUB_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${ISSUE_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${SUB_ID}\"\n```\n\n### Step 8: ルート issue 本文の管理ブロックを更新する(既存本文は保持)\n\nルート issue 本文のうち**本スキルが管理する範囲だけ**を実ツリーから再生成して差し替える。\nそれ以外の本文(概要・運用・手書きの計画や判断根拠・追記)は 1 行も失わない。本文全体を\n固定テンプレートで書き戻してはならない(人が書いた内容が棚卸しのたびに消え、件数・日付の\nプレースホルダーが本文へ到達し得るため。Issue #545)。処理は `scripts/update-root-body.sh`\nに集約されている。\n\n**管理範囲と不変条件**\n\n| 領域 | マーカー(行全体で一致・コードフェンス外のみ有効) | 更新規則 |\n|------|-----------------------------------------------|---------|\n| Phase 別表 | `\u003c!-- update-issue-tree:phase-plan:begin -->` / `\u003c!-- update-issue-tree:phase-plan:end -->` | マーカー間を毎回全置換(ルート直下の sub-issue 全件の表と、直下に open の子を持つ節点の詳細表) |\n| 棚卸し履歴 | `\u003c!-- update-issue-tree:inventory:begin -->` / `\u003c!-- update-issue-tree:inventory:end -->` | 追記専用。既存行は保持し、今回の日付と 5 件数の 1 行を末尾へ足す(直前行と同一なら足さない) |\n| granularity | `\u003c!-- granularity: Nh -->` | 先頭に 1 行だけ維持する。値は `GRANULARITY` の確定値 |\n\n- 既存本文から消してよいのは「phase-plan マーカー間の行」と「granularity マーカー行」だけ\n- `## 概要`・`## 運用` は管理しない。存在すれば逐語で保持し、存在しなくても追加しない\n (本文が空のときだけ骨格を生成する)\n- 表・件数・日付は実ツリーと検証済み引数からだけ生成する。5 件数の引数は既定値なしの必須で、\n プレースホルダーが残っていれば API 呼び出し前に exit 1 で止まる\n- issue タイトルは非信頼データ。改行は空白 1 つに、`&`・`\u003c`・`>`・バッククォート・バックスラッシュ・`|` は HTML 文字参照へ変換して書き込む(create-issue-tree と同じ変換)。タイトルが管理マーカーを偽装できず、create-issue-tree の `--root` 再マージも判定範囲の記号で中止しない。旧形式(`\\|` 等)の行は管理マーカー区間内であれば次回の実行で書き直される\n- マーカーの不整合(片方のみ・重複・逆順・入れ子)と、閉じていないコードフェンスは編集せず exit 4 で止まる\n (閉じていないフェンスは以降を全部コード扱いにして管理ブロックを二重追記するため)\n\n**マーカーが無い既存本文の移行**(現存するルートはすべてこの状態): 既存行は捨てない。\n\n- `## Phase 別実装計画` 節があれば、見出し直後へマーカー付きの生成ブロックを挿入する。同節内の\n 見出し直後〜最初の H3 までの先頭部分と `### Phase ` で始まる H3 は旧生成物の候補として\n **削除せず**、`\u003cdetails>`(要約に退避日と「確認のうえ不要なら削除」を明記)へ逐語で移す。\n それ以外の H3(`### 実装ラン (...)` 等)は元の位置・内容のまま残す\n- 節が無ければ本文末尾へ `## Phase 別実装計画` とマーカー付きブロックを追記する\n- 棚卸し履歴は `## 棚卸しで実施した整理(update-issue-tree 実行履歴)` 節を Phase 別実装計画の直前へ挿入する。\n 既存の手書きの棚卸し節には触れない\n- 退避が起きたら stdout 最終行の `migration=phase-plan-archived` を Step 9 の要確認事項へ転記する\n\n**書き込み前の確認と書き込み**: 取得した本文と編集直前に再取得した本文が異なれば並行編集として\nexit 5 で止まる。変更が無ければ `result=unchanged` で編集しない。`--dry-run` を付けると\nマージ後の本文を stdout へ出し、書き込み系の `gh` を一切呼ばない(初回の移行前に推奨)。\n\n**件数変数の確定手順**(各コードフェンスは独立したシェルで実行され得るため、Step 3〜7 のフェンス内の\n変数は Step 8 へ引き継がれない。実績値は会話上で集計し、下記フェンスの先頭へ**数値を直接書いた代入行として\n追記**してから実行する。推測・既定値・プレースホルダーのまま実行しない):\n\n| 変数 | 確定する値 | 集計元 |\n|------|-----------|--------|\n| `ROOT_NUMBER` | ルート issue 番号 | 引数 |\n| `GRANULARITY` | 粒度(`2h` 等。`Nh` 形式) | 引数の確定値(既定 `2h`) |\n| `REASSIGNED_COUNT` | closed 親下の残置 issue 付け替えの成功件数 | Step 3 で `reassign-sub-issue.sh` が `result=reassigned` / `posted-only` を返した回数 |\n| `ORPHAN_COUNT` | 孤児 issue 再配置の成功件数 | Step 4 で同様に成功した回数 |\n| `LABEL_SYNC_COUNT` | phase ラベル同期の成功件数 | Step 6 で実際にラベルを付与・変更できた issue 数 |\n| `NEW_PHASE_COUNT` | 新設した Phase 親の件数 | Step 5 で作成と紐付けが成功した件数 |\n| `SPLIT_COUNT` | sub-issue 分解の成功件数 | Step 7 で分解(作成と紐付け)まで成功した issue 数 |\n\n- 該当 Step を実行しなかった、または対象が無かった場合は `0` を代入する(未設定のまま放置しない)\n- 失敗・見送り(非ゼロ終了、exit 10 / 11 を含む)は件数へ計上しない(Step 9 の集計規則と同じ)\n- 代入例(値は実績に置き換える): `ROOT_NUMBER=254 GRANULARITY=2h REASSIGNED_COUNT=1 ORPHAN_COUNT=0 LABEL_SYNC_COUNT=3 NEW_PHASE_COUNT=0 SPLIT_COUNT=0`\n を `export` 付きでフェンス先頭に置く\n\n各コードフェンスは独立したシェルで実行され得るため、このフェンスは Step 3 / Step 4 と同形に\nスクリプトを 3 レイアウトから自己完結的に解決する。件数の変数が未設定なら起動前に停止する。\n\n```bash\n# Step 1〜7 の確定値。未設定ならここで停止する(プレースホルダーを本文へ流さない)\n: \"${ROOT_NUMBER:?}\" \"${GRANULARITY:?}\" \"${REASSIGNED_COUNT:?}\" \"${ORPHAN_COUNT:?}\" \\\n \"${LABEL_SYNC_COUNT:?}\" \"${NEW_PHASE_COUNT:?}\" \"${SPLIT_COUNT:?}\"\n\nUPDATE_ROOT_BODY=\"\"\nfor CANDIDATE in \\\n \"skills/update-issue-tree/scripts/update-root-body.sh\" \\\n \".agents/skills/update-issue-tree/scripts/update-root-body.sh\" \\\n \".claude/skills/update-issue-tree/scripts/update-root-body.sh\"; do\n # 存在確認は -f のみ(vendoring で実行ビットが落ちても bash 経由で起動できる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n UPDATE_ROOT_BODY=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${UPDATE_ROOT_BODY}\" ]]; then\n echo \"エラー: update-root-body.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n\n# echo を最終コマンドにしない。非ゼロ終了はそのままフェンスの終了ステータスにする\nstatus=0\nbash \"${UPDATE_ROOT_BODY}\" \\\n --root \"${ROOT_NUMBER}\" --granularity \"${GRANULARITY}\" \\\n --reassigned \"${REASSIGNED_COUNT}\" --orphans \"${ORPHAN_COUNT}\" \\\n --labels \"${LABEL_SYNC_COUNT}\" --new-phases \"${NEW_PHASE_COUNT}\" \\\n --split \"${SPLIT_COUNT}\" || status=$?\nif (( status != 0 )); then\n echo \"exit=${status}\" >&2\n exit \"${status}\"\nfi\n```\n\n**終了コード**(stdout 最終行は `result=\u003cupdated|unchanged|dry-run> root=\u003cn> migration=\u003cnone|phase-plan-archived|inserted> phases=\u003ck>`)\n\n| exit | 意味 | 本文の変更 |\n|------|------|-----------|\n| 0 | 成功(updated のみ変更あり) | updated のみ |\n| 1 | 引数エラー(件数のプレースホルダー残りを含む) | なし |\n| 2 | 前提不備(gh / jq 不在・未認証・ルート取得失敗・ルートが PR) | なし |\n| 3 | ツリー取得失敗・深さ超過(8 段)・表の行数不一致・65,536 文字超 | なし |\n| 4 | 管理マーカーの不整合、または本文のコードフェンスが閉じていない | なし |\n| 5 | 並行編集を検知 | なし |\n| 6 | `gh issue edit` が失敗 | 不明(実状態を確認する) |\n| 7 | 事後確認の不一致(再取得した本文全体が送信した本文と一致しない。末尾改行・CR の差は無視) | あり(編集済み) |\n\n非ゼロ終了は握り潰さず Step 9 の要確認事項へ転記する。exit 6 / 7 は本文の実状態を確認する。\n誤って更新した場合は GitHub の本文編集履歴から復元できる。\n\n### Step 9: 棚卸し結果を報告する\n\n```\n## update-issue-tree 完了レポート\n\n### 対象ルート issue\n- #N: タイトル\n\n### 棚卸し実施内容\n| 操作 | 件数 |\n|------|------|\n| closed 親下の残置 issue 付け替え | N 件 |\n| 孤児 issue の再配置 | N 件 |\n| phase ラベル同期 | N 件 |\n| 新 Phase 親の新設 | N 件 |\n| 粒度基準超 issue の sub-issue 分解 | N 件 |\n\n### 現在の Phase 別サマリー\n| Phase | 親 issue | open 件数 |\n|-------|----------|----------|\n| Phase 1 | #N | N 件 |\n\n### ルート本文の更新結果\n- result: updated / unchanged、migration: none / phase-plan-archived / inserted\n\n### 要確認事項(自動配置できなかった issue)\n- #N: タイトル — 確認理由\n- owner ラベル付き open issue(`owner:human` / `owner:joint`): #N: タイトル — 人手対応待ち(ラベルは変更しない)\n- 退避ブロック(`\u003cdetails>`)の確認 / Step 8 の exit 5・6・7(該当時)\n```\n\n「closed 親下の残置 issue 付け替え」「孤児 issue の再配置」の件数は、Step 3 / Step 4 で\n`reassign-sub-issue.sh` を呼んだ回数分の `result=` 行(`reassigned` / `posted-only`)から集計する。\n**すべての非ゼロ終了**(exit 1〜8・10・11。今後コードが増えた場合も含む)は 1 件も件数へ含めず、\n必ず「要確認事項」へ理由付きで記載する。とくに exit 8 は**部分変更が残っている**ため、\n報告を漏らすと壊れたツリーが放置される。exit 10(補償復旧成功)・exit 11(第三者が別親を設定済み)\nも本来の付け替えは未達のため件数へは計上せず要確認事項へ記載する。**exit 9・10・11 は Step 3 /\nStep 4 のループ自体が fatal 扱いせず継続する**ため(終了コード表・上記ループを参照)、\nこれらは計画配列の残り全件を処理し終えたうえで Step 9 に到達する。Step 3 / Step 4 が stderr へ\n出力する `SKIPPED` / `NEEDS_REVIEW`(および `SKIPPED_ORPHANS` / `NEEDS_REVIEW_ORPHANS`)の内容を\nそのままこの節へ転記する。\n\n## 検証\n\n- ルート issue 本文の Phase 別表が更新されていることを確認する。あわせて**マーカー外の節(概要・運用・手書き節)が更新前後で変わっていない**こと、`YYYY-MM-DD`・`N 件` 等のプレースホルダーが本文に無いことを確認する(初回は `update-root-body.sh ... --dry-run` で出力を確認してから書き込む)\n- closed Phase 親の下に open issue が残置されていないことを確認する。**ただし exit 10\n (補償復旧成功)で要確認事項へ記録された issue は、承認範囲内の逆操作として旧親(closed)\n 配下へ意図的に復帰しているため、この確認の例外として扱う。** 単独では「残置」に見えるので、\n Step 9 の要確認事項の記録と突き合わせて exit 10 由来であることを確認する\n- Step 3 / Step 4 で呼んだ `reassign-sub-issue.sh` の各回について、`echo \"exit=${status}\"`\n の値と `result=` 行を確認する。非ゼロ終了があれば Step 9 の要確認事項へ反映されているか確認する。\n 加えて、**exit 1〜8(9・10・11 を除く)**の fatal 系はコードブロック自体の終了ステータスが\n 非ゼロで返ること(`echo` を最後のコマンドにして握り潰していないこと)を確認する。\n **exit 9・10・11**は fatal ではなくループ継続の契約であるため、コードブロックはそのまま\n 正常終了(終了ステータス 0)で最後の `SKIPPED*` / `NEEDS_REVIEW*` echo まで到達すること、\n かつ `REASSIGN_PLAN` / `ORPHAN_PLAN` の残り全件が処理されていることを確認する\n\n```bash\n# 全 sub-issues の state を確認\ngh api \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues\" \\\n --jq '.[] | {number: .number, state: .state, title: .title}'\n\n# Phase 親の sub-issues も確認\ngh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues\" \\\n --jq '.[] | {number: .number, state: .state}'\n\n# phase ラベルの同期確認(各 Phase 親直下で確認)\ngh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues\" \\\n --jq '.[] | {number: .number, labels: [.labels[].name]}'\n```\n\n## よくある失敗\n\n| 問題 | 回避策 |\n|------|--------|\n| 付け替えの 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` を直接叩く場合の注意として記載を残す) |\n| 管理ブロック内を手で編集したが次回の棚卸しで上書きされた | マーカー間は毎回再生成される。残したい内容はマーカーの外へ書く |\n| 管理マーカーを片方だけ消して Step 8 が exit 4 になる | 両方を戻すか両方とも消す(マーカーを消すと次回は移行扱いになり、旧ブロックは `\u003cdetails>` へ退避される) |\n| `--granularity` に `2 h`・`2`・`0h` 等を渡して中断される | 正整数+h 形式(`^[1-9][0-9]*h update-issue-tree — Skillopedia 。例: `2h`・`4h`)で指定する |\n\n## 注意事項\n\n- **棚卸し前に変更内容をユーザーに提示して確認を取る**(Step 2 参照)\n- ページネーション: sub-issues が 100 件を超える場合は `per_page=100&page=N` でページングして全件取得する(Step 1 のツリー全体取得に適用。`reassign-sub-issue.sh` は対象 issue の `parent_issue_url` を直接参照するため、付け替え判定自体にはページネーションが不要)\n- シェルコマンドの変数は必ず `\"${var}\"` でクォートする(コマンドインジェクション対策)\n- **`gh issue create` は `--json` 非対応**。issue URL を stdout に出力するため、`| grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

` で末尾の番号を抽出する\n- **sub_issues API(POST / DELETE)の `sub_issue_id` は issue 番号ではなく database id**(GitHub 仕様)。`gh api \"repos/{owner}/{repo}/issues/\u003cnumber>\" --jq '.id'` で id を取得してから渡す。番号をそのまま渡すと誤った issue を操作する/404 になる(`reassign-sub-issue.sh` はこれを内部で解決するため、Step 3/4 で手動取得する必要はない)\n- 孤児 issue の Phase が判断できない場合は推測せずにユーザーへ確認する\n- sub_issues の DELETE API(付け替え時に旧親から外す操作)はパスが単数形 `sub_issue` である点に注意し、操作対象の issue 番号を必ず確認してから実行する\n- ツリー更新後は implement-issue-tree が post-order DFS で正しく消化できる構造になっているか確認する\n- Step 3 / Step 4 の付け替え処理は `scripts/reassign-sub-issue.sh` を使う。SKILL.md 本文へ素の `gh api` DELETE/POST を書き戻さない(状態変数の受け渡しがコードフェンス境界で壊れるクラスの欠陥に戻るため。詳細は `scripts/reassign-sub-issue.sh` 冒頭コメントを参照)\n- Step 8 は `scripts/update-root-body.sh` を使う。`gh issue edit --body` でルート本文全体を書き戻さない(手書きの本文が消えるため。詳細は `scripts/update-root-body.sh` 冒頭コメントを参照)。誤更新時は GitHub の本文編集履歴から復元できる","attachment_filenames":["scripts/reassign-lib.sh","scripts/reassign-sub-issue.sh","scripts/update-root-body.sh","tests/lib/gh-stub.mjs","tests/lib/tmp-track.mjs","tests/reassign-sub-issue.test.mjs","tests/skill-invocation-block.test.mjs","tests/skill-prerequisites.test.mjs","tests/step8-root-body.test.mjs"],"attachments":[{"filename":"scripts/reassign-lib.sh","content":"#!/usr/bin/env bash\n# reassign-lib.sh — SKILL.md の呼び出し側ループが共有するヘルパー関数群\n#\n# 呼び出し元: skills/update-issue-tree/SKILL.md Step 3(closed 親下の残置 open issue の付け替え)・\n# Step 4(孤児 issue の再配置)。両 Step のコードフェンスがそれぞれ source する。\n#\n# 背景(Issue #372 / PR #374 codex-review P1 第 3 ラウンド):\n# これらの関数は当初 Step 3 のコードフェンス内で定義し、Step 4 のフェンスが再利用する構成だった。\n# しかし手順書のコードフェンスは**ブロックごとに独立シェルで実行され得る**(reassign-sub-issue.sh\n# の設計前提と同じ)。その前提の下では Step 4 の実行時に関数定義が継承されず、最初の\n# require_plan_array が command not found となって孤児の再配置が一切実行されない。\n# フェンス間の暗黙の依存を消すため、共有部分をこのファイルへ切り出し、両 Step が自己完結的に\n# source する構成へ変更した。\n#\n# reassign-sub-issue.sh との役割分担:\n# reassign-sub-issue.sh … issue 1 件の付け替えを実行する単体実行スクリプト(DELETE→POST を集約)\n# reassign-lib.sh … その呼び出し側(計画配列の検証・1 件呼び出しの終了ステータス解釈)\n# 両者は必ず同一ディレクトリに同居する。本ファイルは自身の位置から兄弟スクリプトを解決するため、\n# 導入形態(本リポジトリのソース/vendoring/symlink 経由)によらず対の実体が組み合う。\n#\n# **このファイルで set -euo pipefail を使ってはならない。** set -e があると reassign_one 内で\n# 失敗した bash 起動の時点でシェルが即終了し、直後の `status=$?` 退避に到達しない。\n# 「非ゼロ終了を握り潰さない」ために入れたつもりの設定が、まさにその見落としを再発させる\n# (Issue #335 で修正した欠陥と同型)。呼び出し側フェンスでも同様に使わない。\n\n# 自身の実体があるディレクトリを絶対パスで解決する。\n# `${BASH_SOURCE[0]%/*}` を使ってはならない——`source reassign-lib.sh` のようにスラッシュを\n# 含まない形で source された場合、パターンが一致せずファイル名がそのまま残って不正なディレクトリになる。\n# 絶対パス化することで、source 後に呼び出し側が cd しても解決済みのパスが壊れない。\nREASSIGN_LIB_DIR=$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd) || return 1\nREASSIGN_SCRIPT=\"${REASSIGN_LIB_DIR}/reassign-sub-issue.sh\"\n\nif [[ ! -f \"${REASSIGN_SCRIPT}\" ]]; then\n echo \"エラー: ${REASSIGN_SCRIPT} が存在しない(reassign-lib.sh と対のスクリプトが欠落している)\" >&2\n return 1\nfi\nif [[ ! -x \"${REASSIGN_SCRIPT}\" ]]; then\n echo \"警告: ${REASSIGN_SCRIPT} に実行権限がない(vendoring で実行ビットが失われた可能性)。bash 経由で実行する\" >&2\nfi\n\n# 1 件分の呼び出しを関数に閉じ込める。Step 3(付け替え)と Step 4(孤児の再配置)が同じ関数を\n# 使うことで、呼び出し方だけでなく stderr の扱い・終了ステータスの意味づけまで対称になる\n# (Issue #335 / #372)。\n#\n# 返り値の契約:\n# 0 … 成功(result= 行の state を読んで件数へ計上する)\n# 9 … 恒久的に対象外(cross-repository 親)。呼び出し側は要確認事項へ記録して次の 1 件へ進む\n# その他 … スクリプトの終了コードをそのまま返す(1〜8・10・11。SKILL.md の終了コード表を参照。\n# 10・11 は Issue #352 の補償復旧で新設。加工なしで透過するためこのラッパの変更は不要)\n#\n# 9 を使う理由: スクリプト自身は 0〜8・10・11 しか返さないため衝突しない。exit 2 は\n# (a) 解消可能な前提不備と (b) 恒久的な対象外が混在しており、終了コード単独では区別できない。\n# 判定は stderr の `reason=cross-repository-parent` マーカー行で機械的に行うが、\n# **stderr を捕捉していなければ呼び出し側はこの判定を実行できない**。そのためファイルへ\n# 捕捉し、判定した後に必ず再出力する(診断情報を握り潰さない)。\nreassign_one() {\n local err status\n err=$(mktemp) || return 2\n # 実行ビットの有無に関わらず bash 経由で起動する(上記の理由により、\n # 直接実行 \"${REASSIGN_SCRIPT}\" に依存すると Permission denied になり得るため)\n bash \"${REASSIGN_SCRIPT}\" \"$@\" 2>\"${err}\"\n # echo を最後のコマンドにすると終了ステータスが常に 0 になり、実行基盤が\n # 「最終ステータス」で成否を判定した場合に非ゼロ終了を見落とす。\n # 直後に $? を退避してから出力する(Issue #335)。\n status=$?\n # 捕捉した stderr は必ず再出力する(判定のために捕捉しただけであり、隠さない)\n cat \"${err}\" >&2\n echo \"exit=${status}\"\n if (( status == 2 )) && grep -qF 'reason=cross-repository-parent' \"${err}\"; then\n rm -f \"${err}\"\n return 9\n fi\n rm -f \"${err}\"\n return \"${status}\"\n}\n\n# 計画配列の存在と型を検証する(fail-closed)。Step 3 / Step 4 の両方が呼ぶ。\n#\n# 2 段階で検査する:\n# 1. 宣言されているか — bash では未定義配列の \"${ARR[@]}\" が空へ展開されるため、\n# Step 2 を実行し忘れてもループが 0 回で回り、承認済みの対象があってもエラーなく\n# 完走して完了報告まで進む。declare -p は宣言済みなら空配列でも成功するため、\n# 「対象なし(空配列)」と「計画未設定(未宣言)」を正しく区別できる\n# 2. 添字配列か — declare -p は同名の**スカラー変数**が宣言済みでも成功する。\n# その場合 \"${ARR[@]}\" はスカラー値 1 個へ展開され、計画として構築していない値が\n# 1 件の付け替え指示として実行されてしまう(実測: REASSIGN_PLAN=\"123 1 2\" は\n# ガードを通過し、ループが 1 回まわる)。属性が添字配列(declare -a)であることまで\n# 確認する。連想配列(declare -A)はループが添字順を前提にするため受け付けない\nrequire_plan_array() {\n local name=\"$1\" attrs\n if ! declare -p \"${name}\" >/dev/null 2>&1; then\n echo \"エラー: ${name} が未宣言(Step 2 の計画が構築されていない)。\" >&2\n echo \"対象 0 件と区別できないため停止する。対象が無い場合も ${name}=() と明示すること\" >&2\n return 1\n fi\n # declare -p の 2 番目のフィールドが属性(例: -a / -ar / -- / -A)。\n # 添字配列は小文字 a、連想配列は大文字 A で区別される\n attrs=$(declare -p \"${name}\" | awk '{print $2}')\n case \"${attrs}\" in\n -*a*) return 0 ;;\n *)\n echo \"エラー: ${name} が添字配列ではない(属性: ${attrs})。\" >&2\n echo \"スカラーや連想配列を計画として実行しないため停止する。${name}=( ... ) で宣言すること\" >&2\n return 1\n ;;\n esac\n}\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":7459,"content_sha256":"438164bfbb3c4017f9691268b09d8fe686d38fbfe1075b278085547ac70bd125"},{"filename":"scripts/reassign-sub-issue.sh","content":"#!/usr/bin/env bash\n# reassign-sub-issue.sh — 1 件の sub-issue を安全に付け替える(DELETE→POST の 2 段操作を集約)\n#\n# 呼び出し元: skills/update-issue-tree/SKILL.md Step 3(closed 親下の残置 open issue の付け替え)・\n# Step 4(孤児 issue の再配置。--old-parent を省略して呼ぶ)\n#\n# 背景: SKILL.md 本文に「DELETE→POST」を素の gh api 呼び出しとして並べる旧方式(PR #295)は、\n# 手順書のコードフェンスがブロックごとに独立シェルで実行され得るため、状態変数の受け渡しが\n# 構造的に壊れやすかった(DELETE 失敗を検知できず POST へ進む・冪等性判定が実行より後に\n# 走る等)。このスクリプトへ切り出すことで単一プロセスに閉じ込め、そのクラスの欠陥を消す\n# (Issue #297 参照)。\n#\n# 現在の親の解決方法(実装着手前の実測で判明した事実):\n# GET /repos/{owner}/{repo}/issues/{n} のレスポンスに `parent_issue_url` フィールドが含まれ、\n# 対象 issue の現在の親を追加のページング呼び出しなしで直接判別できる(親なしなら null)。\n# これにより「新親配下の全件取得で存在確認する」「旧親配下の全件取得で所属確認する」という\n# listing ベースの冪等性判定は不要になった。冪等性判定・第三の親の検知は、この 1 フィールドの\n# 実測値を先に確定させてから分岐する(3.3 節の処理順序どおり、判定を DELETE/POST の実行より\n# 必ず先に行う)。--old-parent と実測値が食い違う場合、および --old-parent 省略(孤児として\n# 承認)なのに実測では親が居る場合は、承認されていない親子関係を壊さないため exit 6 で\n# fail-closed に停止する(何も変更しない)。呼び出し側は実測した親を改めてユーザーへ提示して\n# 承認を取り直したうえで再実行する。実測で親が居ないのに --old-parent が指定された場合だけは、\n# 承認された操作の部分集合(DELETE 不要の POST のみ)となり破壊が起きないため警告して続行する。\n#\n# 使い方:\n# ./reassign-sub-issue.sh --issue \u003c対象 issue 番号> --new-parent \u003c新親 issue 番号> \\\n# [--old-parent \u003c旧親 issue 番号>] [--repo \u003cowner/name>]\n#\n# --repo は対象 issue(--issue)の所在を指す。親(--old-parent / --new-parent)の所在では\n# ない。--repo は対象 issue の GET だけでなく DELETE / POST を含む全 API パスを切り替える\n# ため、親リポジトリの値を入れると親リポジトリ側の同番号 issue を誤操作する(PR #314 P0)。\n#\n# cross-repository sub-issue(対象 issue の親が別リポジトリにある場合)は対象外。\n# 実測した現在の親が別リポジトリなら exit 2 で fail-closed に停止する(下記参照)。\n# exit 2 は gh/jq 不在・未認証・issue 取得失敗(解消可能な前提不備)とも共有するため、\n# このケースだけ stderr に `reason=cross-repository-parent` の安定マーカー行を追加で出す\n# (呼び出し側が exit 2 の中で「中断すべきか」「記録して次へ進むべきか」を機械的に判定\n# できるようにするため。skills/update-issue-tree/SKILL.md の終了コード表を参照)。\n#\n# 終了コードと stdout 最終行(result=\u003cstate> ...)は skills/update-issue-tree/SKILL.md の\n# 「付け替えスクリプトの呼び出し規約」節を正とする。呼び出し側は非ゼロ終了を 1 件も握り潰さず、\n# 完了レポートの「要確認事項」へ記載すること。\n#\n# 前提:\n# - gh CLI がインストールされ認証済みであること\n# - カレントディレクトリがリポジトリ内であること(--repo 省略時、gh の {owner}/{repo} 展開に依存)\n\nset -euo pipefail\n\nNUM_RE='^[1-9][0-9]*

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

\nREPO_RE='^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

\n\nusage() {\n cat >&2 \u003c\u003c'EOF'\n使い方: reassign-sub-issue.sh --issue \u003cn> --new-parent \u003cn> [--old-parent \u003cn>] [--repo \u003cowner/name>]\n --repo は対象 issue(--issue)の所在。親(--old-parent / --new-parent)の所在ではない。\nEOF\n}\n\nISSUE=\"\"\nNEW_PARENT=\"\"\nOLD_PARENT=\"\"\nREPO_ARG=\"\"\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --issue|--new-parent|--old-parent|--repo)\n # 各フラグは値を 1 つ取る名前付き引数。位置引数にしない理由: 中央の引数が\n # 省略可という形は、旧親省略のつもりが新親の位置にずれる取り違えを招き、\n # 別 issue の親子関係を破壊する事故につながる(計画 3.1 参照)\n if [[ $# -lt 2 ]]; then\n echo \"エラー: $1 には値が必要\" >&2\n usage\n exit 1\n fi\n case \"$1\" in\n --issue) ISSUE=\"$2\" ;;\n --new-parent) NEW_PARENT=\"$2\" ;;\n --old-parent) OLD_PARENT=\"$2\" ;;\n --repo) REPO_ARG=\"$2\" ;;\n esac\n shift 2\n ;;\n -h|--help)\n usage\n exit 0\n ;;\n *)\n echo \"エラー: 不明な引数 $1\" >&2\n usage\n exit 1\n ;;\n esac\ndone\n\nif [[ -z \"${ISSUE}\" || -z \"${NEW_PARENT}\" ]]; then\n echo \"エラー: --issue と --new-parent は必須\" >&2\n usage\n exit 1\nfi\n\nif ! [[ \"${ISSUE}\" =~ ${NUM_RE} ]]; then\n echo \"エラー: --issue は正の整数で指定する(例: 42)\" >&2\n exit 1\nfi\nif ! [[ \"${NEW_PARENT}\" =~ ${NUM_RE} ]]; then\n echo \"エラー: --new-parent は正の整数で指定する(例: 42)\" >&2\n exit 1\nfi\nif [[ -n \"${OLD_PARENT}\" ]] && ! [[ \"${OLD_PARENT}\" =~ ${NUM_RE} ]]; then\n echo \"エラー: --old-parent は正の整数で指定する(例: 42)\" >&2\n exit 1\nfi\n\n# 自己参照(--new-parent が --issue と同一)は API を 1 件も叩かずに確定できる純粋な\n# 引数の誤り。DELETE を撃つ前に潰す事前検証の一種だが、他の検証(新親の存在・同一リポ\n# ジトリ)と異なり gh 呼び出しを要しないため、ここ(引数検証ブロック)に置く(Issue #333)\nif [[ \"${NEW_PARENT}\" == \"${ISSUE}\" ]]; then\n echo \"エラー: --new-parent に --issue 自身は指定できない(自己参照)\" >&2\n exit 1\nfi\n\nif [[ -n \"${REPO_ARG}\" ]]; then\n if ! [[ \"${REPO_ARG}\" =~ ${REPO_RE} ]]; then\n echo \"エラー: --repo は owner/name 形式で指定する\" >&2\n exit 1\n fi\n # gh api に -R フラグは無いため、検証済みの owner/name を全 API パスへそのまま埋め込む。\n # ここで REPO_PATH を一度だけ確定し、以降は必ずこの変数経由で組み立てる\n # (取り違えると黙って別リポを操作する)\n REPO_PATH=\"${REPO_ARG}\"\nelse\n # gh api の {owner}/{repo} は gh 自身が cwd の git remote から解決するプレースホルダ\n REPO_PATH=\"{owner}/{repo}\"\nfi\n\nif ! command -v gh &> /dev/null; then\n echo \"エラー: gh CLI がインストールされていません\" >&2\n exit 2\nfi\n\nif ! command -v jq &> /dev/null; then\n echo \"エラー: jq がインストールされていません\" >&2\n exit 2\nfi\n\nif ! gh auth status &> /dev/null; then\n echo \"エラー: gh CLI が認証されていません。gh auth login を実行してください\" >&2\n exit 2\nfi\n\n# result=\u003cstate> issue=\u003cn> new_parent=\u003cn> old_parent=\u003cn|-> の形式で最終行を出す。\n# SKILL.md 側はこの 1 行から完了レポートの内訳(付け替え N 件 / 孤児の再配置 N 件)を集計する\nemit_result() {\n local state=\"$1\"\n local old_parent_out=\"${2:--}\"\n echo \"result=${state} issue=${ISSUE} new_parent=${NEW_PARENT} old_parent=${old_parent_out}\"\n}\n\n# 対象 issue の GET 応答から parent_issue_url を取り出す共通ヘルパー。\n#\n# 背景: `gh api` は終了コード 0 で返しても、応答が空・`{}`・`null`・(リダイレクト等で)別\n# issue のオブジェクトになることがある。`jq -r '.parent_issue_url // empty'` はそのいずれでも\n# 終了コード 0 で空文字列を返すため、復旧取得と安定確認がともにその応答を受け入れると\n# 「孤児を 2 回確認済み」と誤認し、旧親への補償 POST という承認外になり得る書き込みへ進む\n# (fail-open。下流同期 PR Fandhe-AI/articles#119 の codex P1 指摘)。値の有無ではなく\n# **応答が対象 issue の JSON オブジェクトであること**を識別情報(`.number`)で検証する。\n#\n# 契約:\n# 引数 $1: エラーメッセージに使う文脈ラベル 引数 $2: gh api の生の応答本文\n# stdout: 検証済みの parent_issue_url(親なしなら空文字列)\n# 戻り値 0: 応答が対象 issue #ISSUE の JSON オブジェクトであると確認できた\n# (親なしの判定はこの場合にのみ成立する)\n# 戻り値 1: 空・`{}`・`null`・配列・別 issue のオブジェクト・不正 JSON のいずれか。\n# 「親なし」ではなく**状態不明**であり、呼び出し元は孤児と断定せず書き込み\n# なしで終端する(fail-closed)\n# 呼び出し元は ISSUE / GH_ERR_FILE を設定済みであること。復旧・安定確認の 4 経路すべてが\n# この関数を経由する(個別に検証を書き散らさない)\nextract_parent_url() {\n local label=\"$1\"\n local json=\"$2\"\n local out rc=0\n # 空応答(空白のみ含む)は jq に渡す前に拒否する。jq -e の「入力なし → exit 4」は版に依存し、\n # jq 1.6(Ubuntu 22.04 既定)では exit 0 で空出力になり、親なし(孤児)と区別できなくなる\n # (cursor[bot] Medium 指摘 PR #488)。\n if [[ -z \"${json//[[:space:]]/}\" ]]; then\n echo \"エラー: ${label}の応答が空(gh api は成功終了したが本文なし)。親なし(孤児)とは区別し、状態不明として扱う\" >&2\n return 1\n fi\n # jq -e: 値が 1 件も出力されなければ exit 4(空応答)、error() は exit 5。どちらも非 0 に\n # なるため、`.parent_issue_url` の値の有無とは独立に「応答そのものが不正」を判別できる。\n # `// \"\"` で親なしを空文字列に落とすため、正常系(親なし)は exit 0 で空文字列を返す\n out=$(printf '%s' \"${json}\" | jq -e -r --argjson n \"${ISSUE}\" \\\n 'if (type == \"object\") and (.number == $n) then (.parent_issue_url // \"\") else error(\"not-target-issue\") end' \\\n 2>\"${GH_ERR_FILE}\") || rc=$?\n if [[ \"${rc}\" -ne 0 ]]; then\n echo \"エラー: ${label}の応答が対象 issue #${ISSUE} の JSON オブジェクトではない(空・{}・null・別 issue・不正 JSON のいずれか。jq 終了コード ${rc})。親なし(孤児)とは区別し、状態不明として扱う\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n return 1\n fi\n printf '%s' \"${out}\"\n}\n\n# 実測で期待した親配下に見えた場合でも、DELETE/POST の反映遅延による過渡状態を見ている\n# 可能性を排除できない。短い間隔を空けて再取得し、2 回連続で同じ結果が得られて初めて\n# 安定した状態として確定する(codex-review P1 指摘 PR #391)。呼び出し文脈が異なる複数箇所\n# (補償 POST 前の孤児観測の安定確認 / 補償 POST 失敗直後の確認 / 実測で既に旧親配下だった\n# 場合の確認 / 新親への偽陰性確認)で同一ロジックが必要になるため共通化した。孤児観測の\n# 安定確認は expected=\"\" を渡すことで「2 回連続で親なしが観測できるか」を同じ関数で判定\n# する(cursor[bot] Medium 指摘「Orphan restore skips stability check」)。期待どおりで\n# 安定確認できれば戻り値 0(stdout 出力なし)。\n# 期待値とは異なるが実測が本来の新親 #NEW_PARENT だった場合は、新親を期待値として同じ\n# 手順をもう 1 回通し、2 回連続で新親配下が観測できたときにだけ戻り値 2 とし、情報行のみ\n# stderr へ出す(呼び出し元はこれを「元の POST が偽陰性で実際には成功していた」ことを\n# 意味する成功終端として扱う。cursor[bot] Medium 指摘 PR #391 — 旧親への復旧確認・偽陰性\n# 確認のいずれでも、実測が新親であることを第三者による割り込みと誤ラベルしていた)。\n# 新親側の再確認が一致しない・再取得に失敗した場合は成功へ倒さず戻り値 1(状態不明)とする\n# (Issue #482。新親の初観測 1 回だけで reassigned を確定していた非対称の是正)。\n# それ以外の不一致(第三者の別親・孤児への転落)は戻り値 1 とし、原因を stderr へ出力する\n# (呼び出し元は exit 8 で終端する)。\n# 引数 $1: ログメッセージに使う文脈ラベル(例: \"補償復旧後の確認\") 引数 $2: 期待する親 issue 番号\nconfirm_stable_parent() {\n local label=\"$1\"\n local expected=\"$2\"\n # 待機秒数は使用前に非負整数へ検証し、sleep の失敗も捕捉する — set -e 下で無条件実行すると、\n # 不正値・実行失敗の非 0 終了が呼び出し元の exit 8 / reason=recovery-state-unknown への変換より\n # 先にスクリプトを即終了させ、終了コード契約外の exit 1 になるため(codex-review P1 指摘\n # PR #391)。検証・実行の失敗は状態不明(return 1)として既存の fail-closed 経路へ流す\n # 上限 60 秒: 非負整数の検証だけでは、継承環境の極端な値(例: 999999999)で sleep が\n # 事実上停止し、DELETE 済みの部分変更状態を報告できないまま処理が止まる(codex-review\n # P1 指摘 PR #391 第 4 ラウンド)。再確認待機に 60 秒超の値は運用上あり得ないため、\n # 範囲外は状態不明(return 1)の fail-closed 経路へ流す\n # 検証は算術比較ではなく文字列表現(最大 2 桁)で行う — `18446744073709551616` のような\n # 巨大な数字列は Bash の算術比較で整数オーバーフローし `> 60` の上限検証を素通りするため\n # (codex-review P1 指摘 PR #391 第 5 ラウンド)。2 桁以内へ字句で制限すればオーバーフロー\n # 経路そのものが存在しない\n local recheck_delay=\"${RECOVERY_RECHECK_DELAY_SEC:-2}\"\n if ! [[ \"${recheck_delay}\" =~ ^[0-9]{1,2}$ ]] || (( recheck_delay > 60 )); then\n echo \"エラー: RECOVERY_RECHECK_DELAY_SEC が 0〜60 の整数でない(値: ${recheck_delay})。${label}を実施できず状態不明のまま終端する\" >&2\n return 1\n fi\n if ! sleep \"${recheck_delay}\"; then\n echo \"エラー: ${label}のための待機(sleep ${recheck_delay})に失敗した。状態不明のまま終端する\" >&2\n return 1\n fi\n local recheck_json\n if ! recheck_json=$(gh api \"repos/${REPO_PATH}/issues/${ISSUE}\" 2>\"${GH_ERR_FILE}\"); then\n echo \"エラー: ${label}のための再取得に失敗した。#${ISSUE} が #${expected} 配下で安定しているか未確認のまま終端する\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n return 1\n fi\n # 同一リポジトリ判定は「親の存在判定」から分離する(cursor[bot] Medium 指摘 PR #391。\n # cross-repo 判定時のみ recheck_parent を空にすると、実際には別リポジトリの\n # 第三者親配下にあるのに「実測 parent=なし」=孤児と誤報する)\n local recheck_parent_url recheck_parent recheck_same_repo=1\n # jq 解析は set -e で即終了させず明示捕捉する — GET が終了コード 0 でも空・不正 JSON を\n # 返した場合、素の代入だと契約外の exit 1 で終端するため(codex-review P1 指摘 PR #391)。\n # 応答が対象 issue の JSON であることの検証は extract_parent_url に集約している\n if ! recheck_parent_url=$(extract_parent_url \"${label}\" \"${recheck_json}\"); then\n echo \"エラー: #${ISSUE} が #${expected} 配下で安定しているか未確認のまま終端する\" >&2\n return 1\n fi\n recheck_parent=\"\"\n if [[ -n \"${recheck_parent_url}\" ]]; then\n recheck_parent=$(printf '%s' \"${recheck_parent_url}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

|| true)\n # 「URL なし(孤児)」と「URL はあるが番号を抽出できない(解析不能)」を区別する。\n # 後者を空のまま通すと孤児と誤認され、実際には親が存在し得る状態で後続の書き込みを\n # 許してしまう(codex-review P1 指摘 PR #391。fail-open)\n if [[ -z \"${recheck_parent}\" ]]; then\n echo \"エラー: ${label}の parent_issue_url から親 issue 番号を抽出できない(解析不能な応答形式)。#${ISSUE} の親子状態は未確認のまま終端する\" >&2\n return 1\n fi\n if [[ \"${recheck_parent_url%/issues/*}\" != \"${SELF_REPO_URL}\" ]]; then\n recheck_same_repo=0\n fi\n fi\n if [[ \"${recheck_same_repo}\" -eq 1 && \"${recheck_parent}\" == \"${expected}\" ]]; then\n return 0\n fi\n if [[ \"${expected}\" != \"${NEW_PARENT}\" && \"${recheck_same_repo}\" -eq 1 && \"${recheck_parent}\" == \"${NEW_PARENT}\" ]]; then\n # 新親の観測はこの時点でまだ 1 回きりであり、期待値側へ適用している「2 回連続で同じ親を\n # 観測する」条件が新親側には適用されていない。POST 失敗後に 孤児 → 新親 → 孤児 と推移する\n # 反映途中の過渡状態を成功として確定してしまうため、新親を期待値として同じ関数をもう一度\n # 通し、2 回連続で新親配下が観測できて初めて偽陰性成功を確定する(Issue #482。articles#119\n # の codex P1 指摘)。再帰は 1 段で止まる — 内側は expected == NEW_PARENT のためこの分岐の\n # 条件を満たさず、戻り値 0 / 1 のいずれかで必ず終わる\n echo \"情報: ${label}で期待した #${expected} ではなく本来の新親 #${NEW_PARENT} 配下にあることが判明した(元の POST が偽陰性で実際には成功していた可能性。新親側の安定確認へ進む)\" >&2\n local nps_rc=0\n confirm_stable_parent \"${label}に続く新親 #${NEW_PARENT} の安定確認\" \"${NEW_PARENT}\" || nps_rc=$?\n if [[ \"${nps_rc}\" -eq 0 ]]; then\n return 2\n fi\n echo \"エラー: ${label}で観測した新親 #${NEW_PARENT} 配下を安定確認できなかった(反映途中の過渡状態だった可能性)。成功とはみなさず状態不明のまま終端する\" >&2\n return 1\n fi\n if [[ -n \"${recheck_parent}\" ]]; then\n local recheck_scope\n if [[ \"${recheck_same_repo}\" -eq 1 ]]; then\n recheck_scope=\"同一リポジトリの #${recheck_parent}\"\n else\n recheck_scope=\"別リポジトリの ${recheck_parent_url}\"\n fi\n echo \"エラー: ${label}で期待した #${expected} 配下ではなく ${recheck_scope} 配下だった(第三者が付け替えた可能性)。復旧成立とはみなさず停止する\" >&2\n else\n echo \"エラー: ${label}で期待した #${expected} 配下から外れていた(実測 parent=なし)。直前の読み取りは反映前の過渡状態だった可能性が高く、復旧成立とはみなさず停止する\" >&2\n fi\n return 1\n}\n\n# DELETE 失敗の応答だけを信頼しない補償復旧(articles#119 codex-review P1 指摘・Issue #489)。\n# DELETE がサーバー側で成立した後に応答取得(ネットワーク断・タイムアウト等)だけが失敗すると、\n# gh が非ゼロで返っても対象は既に孤児であり得る。ここで即座に exit 3(無変更)と断定すると、\n# 実際には孤児化しているのに「旧親配下のまま無変更」と誤報する(fail-open)。新親 POST 失敗時の\n# recover_after_post_failure と同じ設計思想(諦めて exit する前に実状態を再取得する)を、\n# DELETE 失敗にも対称に適用する。\n#\n# 契約: 呼び出し元は ISSUE / CURRENT_PARENT / NEW_PARENT / REPO_PATH / SELF_REPO_URL /\n# GH_ERR_FILE を設定済みの状態でこの関数を呼ぶ。\n# 戻り値 0: 実測で孤児であることを安定確認できた(DELETE はサーバー側で成立済み)。\n# 呼び出し元はこの戻り値を受けて POST 工程(新親への付け替え)へ処理を続ける\n# それ以外: この関数の内部で exit する(3・8・11 のいずれか。安定確認で新親への\n# 偽陰性が判明した場合は 0 / result=reassigned で exit する)\nrecover_after_delete_failure() {\n local delfail_json delfail_parent_url delfail_parent delfail_same_repo=1\n\n if ! delfail_json=$(gh api \"repos/${REPO_PATH}/issues/${ISSUE}\" 2>\"${GH_ERR_FILE}\"); then\n echo \"エラー: DELETE 失敗後の実状態再取得に失敗した。#${ISSUE} が旧親 #${CURRENT_PARENT} 配下のままかは未確認\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n # 状態不明のまま「旧親配下のまま無変更」と断定すると、実際には孤児化していた場合に\n # 呼び出し側が復旧不要と誤判断しかねない。fail-closed のため状態不明として終端する\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n if ! delfail_parent_url=$(extract_parent_url \"DELETE 失敗後の実状態再取得\" \"${delfail_json}\"); then\n echo \"エラー: #${ISSUE} の親子状態は未確認のまま終端する(旧親配下のままと断定しない)\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n delfail_parent=\"\"\n if [[ -n \"${delfail_parent_url}\" ]]; then\n delfail_parent=$(printf '%s' \"${delfail_parent_url}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

|| true)\n if [[ -z \"${delfail_parent}\" ]]; then\n echo \"エラー: parent_issue_url から親 issue 番号を抽出できない(解析不能な応答形式: ${delfail_parent_url})。#${ISSUE} の親子状態は未確認\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n if [[ \"${delfail_parent_url%/issues/*}\" != \"${SELF_REPO_URL}\" ]]; then\n delfail_same_repo=0\n fi\n fi\n\n if [[ \"${delfail_same_repo}\" -eq 1 && \"${delfail_parent}\" == \"${CURRENT_PARENT}\" ]]; then\n # 実測でも旧親配下のまま。この 1 回の読み取りだけでは反映遅延による過渡状態を見ている\n # 可能性を排除できないため、他の分岐と同じく confirm_stable_parent で安定確認してから\n # 無変更を確定する\n local ddo_rc=0\n confirm_stable_parent \"DELETE 失敗後の旧親配下の安定確認\" \"${CURRENT_PARENT}\" || ddo_rc=$?\n if [[ \"${ddo_rc}\" -eq 0 ]]; then\n echo \"エラー: 旧親 #${CURRENT_PARENT} からの取り外しに失敗した(実測でも旧親配下のままと安定確認済み)\" >&2\n exit 3\n elif [[ \"${ddo_rc}\" -eq 2 ]]; then\n # 安定確認の途中で実は本来の新親 #NEW_PARENT に付いていたことが判明した\n # (DELETE→POST が既に成功していた可能性。他の偽陰性確認と同じ扱い)\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n if [[ \"${delfail_same_repo}\" -eq 1 && -z \"${delfail_parent}\" ]]; then\n # 実測で孤児に見える(DELETE がサーバー側で成立していた可能性がある)。1 回の読み取り\n # だけでは反映遅延の過渡状態を排除できないため、孤児観測の安定確認(既存の補償復旧経路と\n # 同じ expected=\"\" のロジック)を経てから DELETE 成立済みと確定する\n local ddn_rc=0\n confirm_stable_parent \"DELETE 失敗後の孤児観測の安定確認\" \"\" || ddn_rc=$?\n if [[ \"${ddn_rc}\" -eq 0 ]]; then\n echo \"情報: DELETE はエラー応答だったが実測では孤児であることを安定確認した(DELETE はサーバー側で成立していた可能性)。POST 工程へ進む\" >&2\n return 0\n elif [[ \"${ddn_rc}\" -eq 2 ]]; then\n # 安定確認の途中で実は本来の新親 #NEW_PARENT に付いていたことが判明した\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n if [[ \"${delfail_same_repo}\" -eq 1 && \"${delfail_parent}\" == \"${NEW_PARENT}\" ]]; then\n # 第三者親(third-party-parent)判定の前に新親一致を判定する(codex-review P2 指摘\n # PR #491・Issue #489)。この判定が無いと、旧親・孤児で始まり安定確認の途中で新親を\n # 観測した場合は(上の ddo_rc==2 / ddn_rc==2 経路により)成功終端になるのに、最初の\n # 読み取りが先に新親を観測しただけの場合は同じ状態(新親配下)にもかかわらず承認外の\n # 第三者親として exit 11 になってしまう。観測タイミングだけで結果が変わる非対称は、\n # 承認済みの新親へ並行処理が既に付け替えていた場合に、成功のはずが再承認必須のエラー\n # へ変わってしまう欠陥になる。DELETE はエラー応答だったが実測では既に新親配下\n # (DELETE→POST が既に成立していた可能性)であり、1 回の読み取りだけでは反映遅延の\n # 過渡状態を排除できないため、他の偽陰性確認と同じ confirm_stable_parent で安定確認\n # してから、既存の成功経路(`result=reassigned`)と同じ結果を返す。CURRENT_PARENT は\n # 依然として非空(このパスは DELETE 実行対象、すなわち旧親ありの経路でのみ呼ばれる)\n # であり、`emit_result` の \"posted-only\" 判定は CURRENT_PARENT が空かどうかで分岐する\n # ため、\"already-attached\" 相当ではなく通常の \"reassigned\" が契約表と整合する\n local ddp_rc=0\n confirm_stable_parent \"DELETE 失敗後の新親偽陰性確認\" \"${NEW_PARENT}\" || ddp_rc=$?\n if [[ \"${ddp_rc}\" -eq 0 ]]; then\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n # 実測で第三者が別の親を設定済み(同一リポジトリの別 issue、または別リポジトリへの転送)。\n # 承認外の親子関係を壊さないため、旧親へ戻すことも新親へ POST し直すこともせず\n # 書き込みなしで停止する(fail-closed)\n echo \"エラー: DELETE 失敗を受けて実状態を再取得したところ、第三者が別の親を設定済みだった(parent_issue_url=${delfail_parent_url})。承認外の親子関係を壊さないため補償せず停止する\" >&2\n echo \"対処: 実測した親をユーザーへ提示して承認を得たうえで再実行する\" >&2\n echo \"reason=third-party-parent\" >&2\n exit 11\n}\n\n# 孤児経路(DELETE を伴わない)の POST 失敗の応答だけを信頼しない補償復旧(articles#119\n# codex-review P1 指摘・Issue #489)。POST がサーバー側で成立した後に応答取得だけが失敗すると、\n# 対象は既に新親配下であり得る。ここで即座に exit 4(無変更)と断定すると誤報になる。\n# DELETE 経路の recover_after_post_failure と同じ設計を適用するが、この経路には DELETE が\n# 存在せず孤児化リスク自体が無いため、判定のみで完結し補償の書き込みは発生しない。\n#\n# 契約: 呼び出し元は ISSUE / NEW_PARENT / REPO_PATH / SELF_REPO_URL / GH_ERR_FILE を\n# 設定済みの状態でこの関数を呼ぶ。関数は必ず exit する(呼び出し元へは戻らない)\nrecover_after_orphan_post_failure() {\n local opf_json opf_parent_url opf_parent opf_same_repo=1\n\n if ! opf_json=$(gh api \"repos/${REPO_PATH}/issues/${ISSUE}\" 2>\"${GH_ERR_FILE}\"); then\n echo \"エラー: 孤児経路 POST 失敗後の実状態再取得に失敗した。#${ISSUE} が孤児のままかは未確認\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n if ! opf_parent_url=$(extract_parent_url \"孤児経路 POST 失敗後の実状態再取得\" \"${opf_json}\"); then\n echo \"エラー: #${ISSUE} の親子状態は未確認のまま終端する(孤児のままと断定しない)\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n opf_parent=\"\"\n if [[ -n \"${opf_parent_url}\" ]]; then\n opf_parent=$(printf '%s' \"${opf_parent_url}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

|| true)\n if [[ -z \"${opf_parent}\" ]]; then\n echo \"エラー: parent_issue_url から親 issue 番号を抽出できない(解析不能な応答形式: ${opf_parent_url})。#${ISSUE} の親子状態は未確認\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n if [[ \"${opf_parent_url%/issues/*}\" != \"${SELF_REPO_URL}\" ]]; then\n opf_same_repo=0\n fi\n fi\n\n if [[ \"${opf_same_repo}\" -eq 1 && \"${opf_parent}\" == \"${NEW_PARENT}\" ]]; then\n # POST は偽陰性だった可能性がある。1 回の読み取りだけでは反映遅延による過渡状態を\n # 排除できないため、他の成功判定経路と同じ共通関数で安定確認してから確定する\n local opfn_rc=0\n confirm_stable_parent \"孤児経路 POST 失敗後の新親偽陰性確認\" \"${NEW_PARENT}\" || opfn_rc=$?\n if [[ \"${opfn_rc}\" -eq 0 ]]; then\n emit_result \"posted-only\" \"-\"\n exit 0\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n if [[ \"${opf_same_repo}\" -eq 1 && -z \"${opf_parent}\" ]]; then\n # 実測でも孤児のまま。反映遅延による過渡状態を排除するため安定確認してから\n # 無変更を確定する(DELETE を伴わない経路のため、補償の書き込みは不要)\n local opfo_rc=0\n confirm_stable_parent \"孤児経路 POST 失敗後の孤児観測の安定確認\" \"\" || opfo_rc=$?\n if [[ \"${opfo_rc}\" -eq 0 ]]; then\n exit 4\n elif [[ \"${opfo_rc}\" -eq 2 ]]; then\n emit_result \"posted-only\" \"-\"\n exit 0\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n # 実測で第三者/別リポジトリの親が設定済み。この経路のレース検知は既存の exit 7\n # (POST エラーメッセージの \"only have one parent\" 判定)に限定されており、実状態からの\n # 再判定は本 issue の変更対象外(#489 設計)。安全側の状態不明として終端する\n echo \"エラー: 孤児経路 POST 失敗を受けて実状態を再取得したところ、第三者/別リポジトリの親が設定済みだった(parent_issue_url=${opf_parent_url})\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n}\n\n# DELETE 成功後の POST 失敗(旧親から外れ新親にも付かない部分変更)に対する補償復旧\n# (Issue #352)。#333 は「事前に判定できる拒否条件」を DELETE 前に潰したが、DELETE と\n# POST の間のレース・一時的な 5xx は事前判定できない残余として残っていた。ここで諦めて\n# exit すると孤児化が確定するため、実状態を再取得してから安全に判断できる範囲でのみ\n# 旧親へ戻す。呼び出し元(DELETE 経路の POST 失敗ブロック)は必ず ISSUE / NEW_PARENT /\n# CURRENT_PARENT / ISSUE_ID / REPO_PATH / SELF_REPO_URL / GH_ERR_FILE / POST_OUT を\n# 設定済みの状態でこの関数を呼ぶ。関数は必ず exit するため呼び出し元へは戻らない\nrecover_after_post_failure() {\n if printf '%s' \"${POST_OUT}\" | grep -qi \"only have one parent\"; then\n # メッセージ文字列は GitHub 側の表現変更に追随できず脆いため、分岐の根拠にはしない。\n # 「第三者による付け替えの可能性が高い」というヒントに格下げし、実測値で確定させる\n echo \"情報: エラーメッセージから第三者による付け替えの可能性が示唆されている(実測値で確定させる)\" >&2\n fi\n\n if ! RECOVERY_JSON=$(gh api \"repos/${REPO_PATH}/issues/${ISSUE}\" 2>\"${GH_ERR_FILE}\"); then\n echo \"エラー: 復旧のための実状態再取得に失敗した。#${ISSUE} は #${CURRENT_PARENT} から外れたまま孤児で残っている可能性がある\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n # 状態不明のまま補償 POST を撃つと、実は既に別の親が付いている場合に承認外の\n # 親子関係を壊しかねない。fail-closed のため補償を試みずに終端する\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n local recovery_parent_url recovery_parent recovery_same_repo=1\n # jq 解析失敗は状態不明として exit 8 へ統一する(codex-review P1 指摘 PR #391。素の代入だと\n # set -e で契約外の exit 1 になり、DELETE 済みの部分変更が残ったことが呼び出し元へ伝わらない)\n if ! recovery_parent_url=$(extract_parent_url \"復旧のための実状態再取得\" \"${RECOVERY_JSON}\"); then\n echo \"エラー: #${ISSUE} の親子状態は未確認のまま終端する(孤児と断定せず補償 POST を撃たない)\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n recovery_parent=\"\"\n if [[ -n \"${recovery_parent_url}\" ]]; then\n recovery_parent=$(printf '%s' \"${recovery_parent_url}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

|| true)\n # 非空 URL から番号を抽出できない場合は孤児と誤認せず状態不明で停止する(codex-review\n # P1 指摘 PR #391。空のまま通すと孤児分岐へ入り、実際には親が存在し得る状態で旧親への\n # 補償 POST という承認外の書き込みを実行してしまう fail-open)\n if [[ -z \"${recovery_parent}\" ]]; then\n echo \"エラー: parent_issue_url から親 issue 番号を抽出できない(解析不能な応答形式: ${recovery_parent_url})。#${ISSUE} の親子状態は未確認\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n if [[ \"${recovery_parent_url%/issues/*}\" != \"${SELF_REPO_URL}\" ]]; then\n recovery_same_repo=0\n fi\n fi\n\n if [[ \"${recovery_same_repo}\" -eq 1 && \"${recovery_parent}\" == \"${NEW_PARENT}\" ]]; then\n # POST は偽陰性だった可能性がある(応答は失敗扱いでも実際には成立していたかも\n # しれない)。ただしこの 1 回の読み取りだけでは DELETE/POST の反映遅延による\n # 過渡状態を見ている可能性を排除できない。他の成功判定経路と非対称に即時確定\n # させず、共通関数で安定確認してから成功終端する(codex-review P1 指摘 PR #391。\n # CI 失敗の直接原因)\n local rfp_rc=0\n confirm_stable_parent \"復旧確認での新親偽陰性確認\" \"${NEW_PARENT}\" || rfp_rc=$?\n if [[ \"${rfp_rc}\" -eq 0 ]]; then\n # 本来の目的(新親への付け替え)は達成済みのため、通常の成功終端と同じ扱いにする\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n if [[ \"${recovery_same_repo}\" -eq 1 && -z \"${recovery_parent}\" ]]; then\n # 実測でも孤児(想定どおり DELETE のみ成立し POST が未達)に見える。ただしこの\n # 1 回の読み取りだけでは DELETE/POST の反映遅延による過渡状態を見ている可能性を\n # 排除できず、他の分岐(新親偽陰性確認・旧親配下の再確認)は書き込み前に必ず\n # confirm_stable_parent で安定確認しているのに対し、ここだけは単発の観測を信頼して\n # 即座に補償 POST という書き込みを実行していた(cursor[bot] Medium 指摘「Orphan\n # restore skips stability check」)。expected=\"\" は「親なし(孤児)」を表し、\n # confirm_stable_parent は 2 回連続で親なしが観測できて初めて 0 を返す\n local orc_rc=0\n confirm_stable_parent \"孤児観測の安定確認(補償 POST 前)\" \"\" || orc_rc=$?\n if [[ \"${orc_rc}\" -eq 2 ]]; then\n # 安定確認中に本来の新親 #NEW_PARENT への遅延反映が見えた(元の POST の偽陰性が\n # ここで確定した。cursor[bot] Medium 指摘 PR #391 と同種の経路)\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n elif [[ \"${orc_rc}\" -ne 0 ]]; then\n # 安定確認できない(第三者が別親を設定・応答解析不能・再取得失敗等)。孤児と\n # 断定して補償 POST という書き込みを行うと、実際には別の親が付いている場合に\n # 承認外の親子関係を壊しかねないため、書き込まずに状態不明として終端する\n # (fail-closed)\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n # 実測でも孤児(想定どおり DELETE のみ成立し POST が未達)と安定確認できた。\n # 承認済みの操作の逆操作(旧親へ戻す)は承認範囲内であるため、ここでのみ補償 POST を撃つ\n if ! COMP_POST_OUT=$(gh api --method POST \"repos/${REPO_PATH}/issues/${CURRENT_PARENT}/sub_issues\" -F \"sub_issue_id=${ISSUE_ID}\" 2>&1); then\n echo \"エラー: 旧親 #${CURRENT_PARENT} への補償復旧 POST が失敗した\" >&2\n echo \"${COMP_POST_OUT}\" >&2\n # 補償 POST が失敗したことだけを根拠に「孤児のまま」と断定しない。失敗している間に\n # 第三者が別の親を設定した可能性があるため、実状態を再取得してから正確な内容を\n # 報告する(cursor[bot] Medium 指摘 PR #391。再取得自体が失敗した場合は状態不明の\n # まま孤児と断定せず reason=compensation-post-failed のみで終端する)\n if COMP_FAIL_JSON=$(gh api \"repos/${REPO_PATH}/issues/${ISSUE}\" 2>\"${GH_ERR_FILE}\"); then\n # リポジトリ一致は「同一リポジトリの親か」の判定にのみ使い、「親が存在するか」の\n # 判定からは分離する。空でない parent_issue_url は同一・別リポジトリを問わず\n # 「孤児ではない」ことを意味するため、別リポジトリという理由だけで comp_fail_parent\n # を空にすると third-party-parent を見逃し「実測でも孤児」と誤報告してしまう\n # (codex-review P1 指摘 PR #391。CI 失敗の直接原因)\n local comp_fail_parent_url comp_fail_parent comp_fail_same_repo=1\n # jq 解析失敗は状態不明として exit 8 へ統一する(codex-review P1 指摘 PR #391)\n if ! comp_fail_parent_url=$(extract_parent_url \"補償 POST 失敗後の実状態再取得\" \"${COMP_FAIL_JSON}\"); then\n echo \"エラー: #${ISSUE} が孤児のままかは未確認\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n comp_fail_parent=\"\"\n if [[ -n \"${comp_fail_parent_url}\" ]]; then\n comp_fail_parent=$(printf '%s' \"${comp_fail_parent_url}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

|| true)\n # 非空 URL の番号抽出不能は孤児と誤認せず状態不明で停止する(codex-review P1\n # 指摘 PR #391)\n if [[ -z \"${comp_fail_parent}\" ]]; then\n echo \"エラー: parent_issue_url から親 issue 番号を抽出できない(解析不能な応答形式: ${comp_fail_parent_url})。#${ISSUE} が孤児のままかは未確認\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n if [[ \"${comp_fail_parent_url%/issues/*}\" != \"${SELF_REPO_URL}\" ]]; then\n comp_fail_same_repo=0\n fi\n fi\n if [[ -n \"${comp_fail_parent}\" && \"${comp_fail_same_repo}\" -eq 1 && \"${comp_fail_parent}\" == \"${NEW_PARENT}\" ]]; then\n # 補償 POST はエラー応答だったが、実測では本来の新親 #NEW_PARENT に既に付いている。\n # これは元の POST(新親への付け替え)が偽陰性で実際には成功していたことを意味し、\n # 第三者の割り込みではない。下の third-party-parent 分岐で誤ラベルしないよう、\n # 先にこの成功ケースを判定する(cursor[bot] Medium 指摘 PR #391 「New parent\n # misread as failure」)。1 回の読み取りだけでは過渡状態の可能性を排除できないため\n # 共通関数で再確認してから確定する\n echo \"情報: 旧親 #${CURRENT_PARENT} への補償復旧 POST はエラー応答だったが、実測では本来の新親 #${NEW_PARENT} に既に付いていた(元の POST が偽陰性だった可能性。反映遅延を考慮し再確認する)\" >&2\n local nfp_rc=0\n confirm_stable_parent \"補償 POST 失敗後の新親偽陰性確認\" \"${NEW_PARENT}\" || nfp_rc=$?\n if [[ \"${nfp_rc}\" -eq 0 ]]; then\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n if [[ -n \"${comp_fail_parent}\" && \"${comp_fail_same_repo}\" -eq 1 && \"${comp_fail_parent}\" == \"${CURRENT_PARENT}\" ]]; then\n # 補償 POST はエラー応答だったが、実測では既に旧親 #CURRENT_PARENT 配下にある\n # (応答が偽陰性だった、または反映が追いついた)。この場合を third-party-parent\n # として報告すると、実際には承認済みの復旧が成立しているのに誤って部分変更・\n # 要確認扱いにしてしまう(cursor[bot] Medium 指摘 PR #391 の逆方向ケース)。\n # ただしこの 1 回の読み取りだけでは、DELETE の反映が遅延しているだけの過渡状態を\n # 見ている可能性を排除できない。その場合、少し後には実際に孤児化するにも\n # かかわらずここで「復旧済み」と確定させると誤報になる(codex-review P1 指摘\n # PR #391。通常の DELETE 経路(下の recovery_parent 分岐)と同じ安定確認を\n # 共通関数で適用し、2 回連続で旧親配下が観測できて初めて確定する)\n echo \"情報: 旧親 #${CURRENT_PARENT} への補償復旧 POST はエラー応答だったが、実測では既に旧親配下に復旧していた(応答が偽陰性だった可能性。反映遅延を考慮し再確認する)\" >&2\n local cfp_rc=0\n confirm_stable_parent \"補償 POST 失敗後の偽陰性確認\" \"${CURRENT_PARENT}\" || cfp_rc=$?\n if [[ \"${cfp_rc}\" -eq 0 ]]; then\n echo \"result=restored issue=${ISSUE} new_parent=${NEW_PARENT} old_parent=${CURRENT_PARENT}\"\n exit 10\n elif [[ \"${cfp_rc}\" -eq 2 ]]; then\n # 反映遅延の再確認で、実は本来の新親 #NEW_PARENT に付いていたことが判明した\n # (元の POST の偽陰性がここで初めて見える経路。cursor[bot] Medium 指摘 PR #391)\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n if [[ -n \"${comp_fail_parent}\" ]]; then\n local comp_fail_scope\n if [[ \"${comp_fail_same_repo}\" -eq 1 ]]; then\n comp_fail_scope=\"同一リポジトリの #${comp_fail_parent}\"\n else\n comp_fail_scope=\"別リポジトリの ${comp_fail_parent_url}\"\n fi\n echo \"エラー: #${ISSUE} は孤児ではなく、実測では ${comp_fail_scope} 配下にある(補償 POST 失敗の間に第三者が付け替えた可能性)。承認外の親子関係を壊さないためこのまま停止する\" >&2\n echo \"reason=compensation-post-failed-third-party-parent\" >&2\n exit 8\n fi\n echo \"エラー: #${ISSUE} は実測でも孤児のまま残っている\" >&2\n echo \"reason=compensation-post-failed\" >&2\n exit 8\n fi\n # 再取得自体が失敗した場合、孤児か否かを一切測定できていない。この分岐を\n # reason=compensation-post-failed で終端すると、呼び出し元が「親子関係を\n # 確認済みの孤児」と誤認しうる(cursor[bot] Medium 指摘 PR #391)。\n # 上の JSON 解析失敗パスと同じ reason=recovery-state-unknown に統一する\n echo \"エラー: 補償復旧 POST 失敗後の実状態再取得にも失敗した。#${ISSUE} が孤児のままかは未確認\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n # 補償 POST の成功応答だけを信頼しない。事後確認は必ず取り直す(#295 の設計哲学を\n # 復旧経路にも一貫させる)\n if ! RECOVERY_VERIFY_JSON=$(gh api \"repos/${REPO_PATH}/issues/${ISSUE}\" 2>\"${GH_ERR_FILE}\"); then\n echo \"エラー: 補償復旧後の確認取得に失敗した\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n # 同一リポジトリ判定は「親の存在判定」から分離する(cursor[bot] Medium 指摘 PR #391。\n # comp_fail_parent と同じ欠陥がここにもあった: cross-repo 判定時のみ rv_parent を\n # 空にすると、実際には別リポジトリの第三者親配下にあるのに「実測 parent=なし」と\n # 誤報し、孤児と紐付いていない状態を「孤児」として報告してしまう)\n local rv_parent_url rv_parent rv_same_repo=1\n # jq 解析失敗は状態不明として exit 8 へ統一する(codex-review P1 指摘 PR #391)\n if ! rv_parent_url=$(extract_parent_url \"補償復旧後の確認取得\" \"${RECOVERY_VERIFY_JSON}\"); then\n echo \"エラー: #${ISSUE} の親子状態は未確認\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n rv_parent=\"\"\n if [[ -n \"${rv_parent_url}\" ]]; then\n rv_parent=$(printf '%s' \"${rv_parent_url}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

|| true)\n # 非空 URL の番号抽出不能は孤児と誤認せず状態不明で停止する(codex-review P1\n # 指摘 PR #391)\n if [[ -z \"${rv_parent}\" ]]; then\n echo \"エラー: parent_issue_url から親 issue 番号を抽出できない(解析不能な応答形式: ${rv_parent_url})。#${ISSUE} の親子状態は未確認\" >&2\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n if [[ \"${rv_parent_url%/issues/*}\" != \"${SELF_REPO_URL}\" ]]; then\n rv_same_repo=0\n fi\n fi\n if [[ \"${rv_same_repo}\" -eq 1 && \"${rv_parent}\" == \"${NEW_PARENT}\" ]]; then\n # 補償 POST は成功応答だったが、事後確認では旧親ではなく本来の新親 #NEW_PARENT に\n # 付いていた。旧親への補償 POST と競合するタイミングで、元の POST の偽陰性が遅れて\n # 反映された可能性がある。第三者の割り込みではなく本来の目的が達成された状態のため、\n # third-party 誤ラベルを避けて成功終端する(cursor[bot] Medium 指摘 PR #391)。\n # ただしこの 1 回の読み取りだけでは反映遅延による過渡状態を排除できないため、他の\n # 成功判定経路と同様に共通関数で安定確認してから確定する(codex-review P1 指摘\n # PR #391。CI 失敗の直接原因)\n echo \"情報: 補償復旧後の確認で旧親ではなく本来の新親 #${NEW_PARENT} 配下にあることが判明した(元の POST が偽陰性で実際には成功していた可能性。反映遅延を考慮し再確認する)\" >&2\n local pvfp_rc=0\n confirm_stable_parent \"補償 POST 成功後の新親偽陰性確認\" \"${NEW_PARENT}\" || pvfp_rc=$?\n if [[ \"${pvfp_rc}\" -eq 0 ]]; then\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n if [[ \"${rv_same_repo}\" -ne 1 || \"${rv_parent}\" != \"${CURRENT_PARENT}\" ]]; then\n if [[ -n \"${rv_parent}\" ]]; then\n local rv_scope\n if [[ \"${rv_same_repo}\" -eq 1 ]]; then\n rv_scope=\"同一リポジトリの #${rv_parent}\"\n else\n rv_scope=\"別リポジトリの ${rv_parent_url}\"\n fi\n echo \"エラー: 補償復旧後の確認で旧親 #${CURRENT_PARENT} 配下ではなく ${rv_scope} 配下だった(補償復旧と前後して第三者が付け替えた可能性)。状態を手で確認する\" >&2\n else\n echo \"エラー: 補償復旧後の確認で旧親 #${CURRENT_PARENT} 配下に見つからない(実測 parent=なし)。状態を手で確認する\" >&2\n fi\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n\n # 補償 POST の成功応答 + 直後の GET 1 回だけでは、DELETE の反映遅延で旧親配下に\n # 見えている過渡状態を排除できない。直後に孤児化または新親への遅延反映が起きても\n # restored と誤報するため、他の成功判定経路(補償 POST 失敗後・POST 偽陰性・補償不要)\n # と同じ共通関数で安定確認してから確定する(codex-review P1 指摘 PR #391。成功応答\n # 経路だけ契約が非対称だった)\n local pvr_rc=0\n confirm_stable_parent \"補償復旧後の安定確認\" \"${CURRENT_PARENT}\" || pvr_rc=$?\n if [[ \"${pvr_rc}\" -eq 2 ]]; then\n # 安定確認中に本来の新親 #NEW_PARENT への遅延反映が見えた(元の POST の偽陰性)\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n elif [[ \"${pvr_rc}\" -ne 0 ]]; then\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n echo \"情報: #${ISSUE} を旧親 #${CURRENT_PARENT} へ補償復旧した(本来の目的だった #${NEW_PARENT} への付け替えは未達。反映遅延を考慮し再確認済み)\" >&2\n echo \"result=restored issue=${ISSUE} new_parent=${NEW_PARENT} old_parent=${CURRENT_PARENT}\"\n exit 10\n fi\n\n if [[ \"${recovery_same_repo}\" -eq 1 && \"${recovery_parent}\" == \"${CURRENT_PARENT}\" ]]; then\n # 実測で既に旧親配下(DELETE が実は不成立だった、または応答後に反映が追いついた等)。\n # ただしこの 1 回の読み取りだけでは、DELETE の反映が遅延しているだけの過渡状態を\n # 見ている可能性を排除できない。その場合、少し後には実際に孤児化するにもかかわらず\n # ここで「復旧済み」と確定させると誤報になる(codex-review P1 指摘 PR #391)。\n # 短い間隔を空けて再取得し、2 回連続で同じ結果(旧親配下)が得られて初めて安定した\n # 状態として確定する(共通関数化。上の補償 POST 失敗後の偽陰性確認と同一ロジック)\n local sop_rc=0\n confirm_stable_parent \"反映遅延を考慮した再確認\" \"${CURRENT_PARENT}\" || sop_rc=$?\n if [[ \"${sop_rc}\" -eq 2 ]]; then\n # 再確認で、実は本来の新親 #NEW_PARENT に付いていたことが判明した(元の POST の\n # 偽陰性がここで初めて見える経路。cursor[bot] Medium 指摘 PR #391)\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\n exit 0\n elif [[ \"${sop_rc}\" -ne 0 ]]; then\n echo \"reason=recovery-state-unknown\" >&2\n exit 8\n fi\n echo \"情報: #${ISSUE} は実測で既に旧親 #${CURRENT_PARENT} 配下にあった(補償 POST は不要。反映遅延を考慮し再確認済み)\" >&2\n echo \"result=restored issue=${ISSUE} new_parent=${NEW_PARENT} old_parent=${CURRENT_PARENT}\"\n exit 10\n fi\n\n # 実測で第三者が別の親を設定済み(同一リポジトリの別 issue、または別リポジトリへの\n # 転送)。承認外の親子関係を壊さないため、旧親へ戻すことも新親へ POST し直すことも\n # せず書き込みなしで停止する(fail-closed)\n echo \"エラー: DELETE 後の POST 失敗を受けて実状態を再取得したところ、第三者が別の親を設定済みだった(parent_issue_url=${recovery_parent_url})。承認外の親子関係を壊さないため補償せず停止する\" >&2\n echo \"対処: 実測した親をユーザーへ提示して承認を得たうえで再実行する\" >&2\n echo \"reason=third-party-parent\" >&2\n exit 11\n}\n\n# JSON を jq でパースする GET 呼び出しは stdout/stderr を分離して取得する(2>&1 で\n# マージすると、gh が stderr へ何か出力しただけで JSON パースが壊れ、本来成功している\n# 呼び出しが偽陽性で中断する。エラー本文の表示用途は err ファイル側に閉じ込める)\nGH_ERR_FILE=$(mktemp)\ntrap 'rm -f \"${GH_ERR_FILE}\"' EXIT\n\n# 対象 issue の database id(sub_issues API が要求する識別子。issue 番号ではない)と\n# 現在の親を 1 回の GET で確定する\nif ! ISSUE_JSON=$(gh api \"repos/${REPO_PATH}/issues/${ISSUE}\" 2>\"${GH_ERR_FILE}\"); then\n echo \"エラー: イシュー #${ISSUE} の取得に失敗した\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n # gh api は失敗時も stdout に詳細なエラー本文(JSON)を返すことがある。\n # stderr の簡潔なメッセージだけでは診断情報が失われるため、stdout 側に\n # 内容があれば併せて表示する(ISSUE_JSON は失敗時も stdout 由来の値を保持する)\n if [[ -n \"${ISSUE_JSON:-}\" ]]; then\n echo \"${ISSUE_JSON}\" >&2\n fi\n exit 2\nfi\n\nISSUE_ID=$(printf '%s' \"${ISSUE_JSON}\" | jq -r '.id')\nif ! [[ \"${ISSUE_ID}\" =~ ^[0-9]+$ ]]; then\n echo \"エラー: イシュー #${ISSUE} の database id を解決できない\" >&2\n exit 2\nfi\n\n# 対象リポジトリの同定に使う。事前判定と事後確認の双方が参照するため、親の有無に関わらず\n# ここで一度だけ解決する(--repo 省略時の REPO_PATH は {owner}/{repo} プレースホルダのため\n# 比較に使えない。対象 issue 自身の repository_url なら追加の API 呼び出しも要らない)\nSELF_REPO_URL=$(printf '%s' \"${ISSUE_JSON}\" | jq -r '.repository_url // empty')\nif [[ -z \"${SELF_REPO_URL}\" ]]; then\n echo \"エラー: イシュー #${ISSUE} の repository_url を解決できない\" >&2\n exit 2\nfi\n\nPARENT_URL=$(printf '%s' \"${ISSUE_JSON}\" | jq -r '.parent_issue_url // empty')\nCURRENT_PARENT=\"\"\nif [[ -n \"${PARENT_URL}\" ]]; then\n # sub-issue はリポジトリを跨いで紐付けられる。parent_issue_url は\n # https://api.github.com/repos/\u003cowner>/\u003crepo>/issues/\u003cn> の形で親の owner/repo を含むため、\n # 番号だけを取り出すと (a) 別リポの親に対して本リポ宛の DELETE を撃って失敗する\n # (b) 番号が偶然 --new-parent と一致すると already-attached と誤判定する、の 2 つが起きる。\n # 対象リポジトリの同定には、追加の API を叩かずに済む対象 issue 自身の repository_url を使う\n # (--repo 省略時の REPO_PATH は {owner}/{repo} プレースホルダのため比較に使えない)。\n if [[ \"${PARENT_URL%/issues/*}\" != \"${SELF_REPO_URL}\" ]]; then\n # cross-repository sub-issue はスキルの契約として対象外(SKILL.md 前提条件を参照。\n # Issue #332)。親リポジトリへの書き込みは本スキルの前提条件(対象リポジトリへの\n # 書き込み権限)の外側にあるため、誤ったリポジトリへ DELETE を撃つ前に\n # fail-closed で停止する\n echo \"エラー: 現在の親が別リポジトリにある(${PARENT_URL})。本スクリプトは同一リポジトリ内の付け替えのみを扱う\" >&2\n # --repo を親リポジトリへ変えて再実行する案内はしない。--repo は親の所在だけでなく\n # 対象 issue の GET / DELETE / POST を含む全 API パスを切り替えるため、親リポジトリに\n # 同番号の無関係な issue があるとそれを操作してしまう\n echo \"対処: 親リポジトリ側で手動で取り外してから再実行する(本スクリプトでは対応しない)\" >&2\n # exit 2 は gh/jq 不在・未認証・issue 取得失敗(解消可能)とこのケース(恒久的に\n # 対象外)の両方で使われ、終了コード単独では呼び出し側が区別できない(Issue #335\n # codex-review 指摘)。人間可読メッセージの文言に依存させず機械可読に判定できるよう、\n # stderr に安定したマーカー行を出す。SKILL.md の終了コード表・Step 3/4 はこの行の\n # 有無で (a) 解消可能な前提不備 / (b) 恒久的な対象外を判定する\n echo \"reason=cross-repository-parent\" >&2\n exit 2\n fi\n CURRENT_PARENT=$(printf '%s' \"${PARENT_URL}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

|| true)\nfi\n\nif [[ -n \"${OLD_PARENT}\" && -z \"${CURRENT_PARENT}\" ]]; then\n # --old-parent 指定時に実測した現在の親が空(孤児)の非対称ケース。承認された操作\n # (旧親から外して新親へ付ける)の部分集合であり、承認外の親子関係を壊さないため続行する。\n # ただし不整合の事実は伏せない\n echo \"警告: 指定された --old-parent #${OLD_PARENT} に反し、実測した現在の親は無い(孤児)。POST のみを実行する\" >&2\nfi\n\n# 冪等性判定を DELETE/POST の実行より先に確定する(#295 で「判定が実行より後で 1 巡遅れる」と\n# 指摘された欠陥の回避)。既に新親配下なら DELETE も POST も撃たない\nif [[ \"${CURRENT_PARENT}\" == \"${NEW_PARENT}\" ]]; then\n emit_result \"already-attached\" \"${CURRENT_PARENT}\"\n exit 0\nfi\n\n# 承認境界のガード(fail-closed)。呼び出し側(SKILL.md Step 2)はユーザーへ「#X から #Y へ\n# 付け替える」と提示して承認を得ている。実測した現在の親がその承認内容と食い違う場合、\n# ここで DELETE を撃つと**承認されていない親子関係を壊す**ため、何も変更せずに停止する。\n# 状態が動いていた場合は、実測した親を改めてユーザーへ提示して承認を取り直す(PR #314 codex P1)\nif [[ -n \"${CURRENT_PARENT}\" ]]; then\n if [[ -z \"${OLD_PARENT}\" ]]; then\n echo \"エラー: 孤児として指定されたが、実測では #${CURRENT_PARENT} 配下にある。承認外の親子関係を壊さないため中止する\" >&2\n echo \"対処: 現在の親 #${CURRENT_PARENT} をユーザーへ提示して承認を得たうえで --old-parent ${CURRENT_PARENT} を付けて再実行する\" >&2\n exit 6\n elif [[ \"${OLD_PARENT}\" != \"${CURRENT_PARENT}\" ]]; then\n echo \"エラー: 実測した現在の親 #${CURRENT_PARENT} が指定された --old-parent #${OLD_PARENT} と異なる。承認外の親子関係を壊さないため中止する\" >&2\n echo \"対処: 現在の親 #${CURRENT_PARENT} をユーザーへ提示して承認を得たうえで --old-parent ${CURRENT_PARENT} で再実行する\" >&2\n exit 6\n fi\nfi\n\n# 新親の事前検証(DELETE を撃つ前の最後の関門)。ここまでの分岐(already-attached・\n# 承認境界ガード)は既に通過しており、この先は必ず DELETE または POST を伴う。新親が\n# 存在しない・別リポジトリにある場合、DELETE だけ成功して POST が失敗すると対象 issue が\n# 孤児化する(不可逆)ため、DELETE の**前**に新親を GET して判定する(Issue #333 AC1/AC2)。\n# 孤児経路(CURRENT_PARENT が空、DELETE を伴わない)にも通す。DELETE が無いため孤児化\n# リスク自体は無いが、「事前に判定できる拒否条件は必ず無変更で終端する」契約を経路によらず\n# 統一するため(SKILL.md 参照)\nif ! NEW_PARENT_JSON=$(gh api \"repos/${REPO_PATH}/issues/${NEW_PARENT}\" 2>\"${GH_ERR_FILE}\"); then\n echo \"エラー: 新親 #${NEW_PARENT} の取得に失敗した(存在しない番号の可能性)\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n if [[ -n \"${NEW_PARENT_JSON:-}\" ]]; then\n echo \"${NEW_PARENT_JSON}\" >&2\n fi\n exit 2\nfi\n\nNEW_PARENT_REPO_URL=$(printf '%s' \"${NEW_PARENT_JSON}\" | jq -r '.repository_url // empty')\nif [[ -z \"${NEW_PARENT_REPO_URL}\" ]]; then\n echo \"エラー: 新親 #${NEW_PARENT} の repository_url を解決できない\" >&2\n exit 2\nfi\n\nif [[ \"${NEW_PARENT_REPO_URL}\" != \"${SELF_REPO_URL}\" ]]; then\n # 既存の旧親側 cross-repo ガード(現 L184 相当)と同じ完全一致比較。転送済み issue は\n # GET がリダイレクトされ別リポジトリのレスポンスを返すため、レスポンス側の実測値でしか\n # 判定できない(--repo 等の静的な値からは導けない)\n echo \"エラー: 新親 #${NEW_PARENT} が別リポジトリにある(${NEW_PARENT_REPO_URL})。本スクリプトは同一リポジトリ内の付け替えのみを扱う\" >&2\n # --repo を新親リポジトリへ変えて再実行する案内はしない。--repo は対象 issue の\n # GET/DELETE/POST を含む全 API パスを切り替えるため、同番号の無関係な issue を\n # 操作させる危険な案内になる(PR #314 codex P0 の再発防止と同じ理由)\n exit 2\nfi\n\n# 新親が Pull Request でないことを確認する。GitHub の `GET /repos/{o}/{r}/issues/{n}` は\n# **PR も返す**(issue と PR は番号空間を共有し、PR のレスポンスには `.pull_request` が付く)。\n# そのため --new-parent に PR 番号を渡すと、直前の存在確認も同一リポジトリ確認も通過して\n# しまう。旧親がある経路ではこの後 DELETE が成功したうえで sub_issues への POST が失敗し、\n# 対象 issue が旧親から外れたまま新親にも付かない部分変更(実害は exit 8 相当)に陥る。\n# これは本スクリプトが予防対象としている孤児化そのものであり、判別に使う `.pull_request` は\n# 既に取得済みの NEW_PARENT_JSON に含まれているため追加の API 呼び出しなしで弾ける。\nif printf '%s' \"${NEW_PARENT_JSON}\" | jq -e 'has(\"pull_request\")' >/dev/null 2>&1; then\n echo \"エラー: 新親 #${NEW_PARENT} は issue ではなく Pull Request である。sub-issue の親には指定できない\" >&2\n echo \"対処: 親として使う issue の番号を指定して再実行する(issue と PR は番号空間を共有するため取り違えやすい)\" >&2\n exit 2\nfi\n\nif [[ -z \"${CURRENT_PARENT}\" ]]; then\n # 孤児(どの親にも属していない): DELETE を飛ばして POST のみ\n if ! POST_OUT=$(gh api --method POST \"repos/${REPO_PATH}/issues/${NEW_PARENT}/sub_issues\" -F \"sub_issue_id=${ISSUE_ID}\" 2>&1); then\n echo \"${POST_OUT}\" >&2\n if printf '%s' \"${POST_OUT}\" | grep -qi \"only have one parent\"; then\n # 事前の実測では孤児だったが POST 時点で別の親が付いていたレース。DELETE は 1 度も\n # 撃っていないため**ツリーは無変更**。exit 6(承認不一致・無変更)とは区別する\n echo \"エラー: POST 時点で別の親が付いていた(レース)。DELETE は実行していないためツリーは無変更\" >&2\n exit 7\n fi\n # POST の応答(gh の非ゼロ終了)だけを信頼しない。応答取得だけが失敗して実際には\n # 成立している可能性があるため、実状態を再取得して成立済み・無変更・状態不明を\n # 区別する。関数は必ず exit する(0 / 4 / 8 のいずれか)ため、この if ブロックには\n # 戻らない(articles#119 codex-review P1 指摘・Issue #489)\n recover_after_orphan_post_failure\n fi\nelse\n # DELETE のパスは単数形 sub_issue(複数形 sub_issues を渡すと 404 になり、旧親から\n # 外れないまま POST して「Sub issue may only have one parent」で必ず失敗する。GitHub 側の\n # 仕様上の単複非対称。POST 側は複数形 sub_issues のまま)\n if ! DEL_OUT=$(gh api --method DELETE \"repos/${REPO_PATH}/issues/${CURRENT_PARENT}/sub_issue\" -F \"sub_issue_id=${ISSUE_ID}\" 2>&1); then\n echo \"エラー: 旧親 #${CURRENT_PARENT} からの取り外しに失敗した\" >&2\n echo \"${DEL_OUT}\" >&2\n # DELETE の応答(gh の非ゼロ終了)だけを信頼しない。DELETE がサーバー側で成立した後に\n # 応答取得だけが失敗した場合、実際には既に孤児であり得る。実状態を再取得してから\n # 成立済み・無変更・状態不明を区別する(articles#119 codex-review P1 指摘・Issue #489)。\n # 戻り値 0 は「実測で孤児と安定確認できた(DELETE 成立済み)」を意味し、その場合のみ\n # 呼び出し元(ここ)へ処理が戻り、以降の POST 工程(次のブロック)へ進む。それ以外は\n # 関数の内部で exit する(fail-closed)\n recover_after_delete_failure\n fi\n\n if ! POST_OUT=$(gh api --method POST \"repos/${REPO_PATH}/issues/${NEW_PARENT}/sub_issues\" -F \"sub_issue_id=${ISSUE_ID}\" 2>&1); then\n echo \"エラー: 新親 #${NEW_PARENT} への紐付けに失敗した\" >&2\n echo \"${POST_OUT}\" >&2\n # DELETE は既に成立済みのため、ここで即終端すると孤児化が確定してしまう。\n # 実状態を再取得してから安全に判断できる範囲でのみ旧親へ戻す(Issue #352)。\n # 関数は必ず exit する(0 / 8 / 10 / 11 のいずれか)ため、この if ブロックには戻らない\n recover_after_post_failure\n fi\nfi\n\n# 事後確認は必ず取り直す。DELETE/POST 前に取得した ISSUE_JSON を使い回さない\n# (#295 の「スナップショットの追記型再利用による汚染」の回避)\nif ! VERIFY_JSON=$(gh api \"repos/${REPO_PATH}/issues/${ISSUE}\" 2>\"${GH_ERR_FILE}\"); then\n echo \"エラー: 事後確認のための再取得に失敗した\" >&2\n cat \"${GH_ERR_FILE}\" >&2\n if [[ -n \"${VERIFY_JSON:-}\" ]]; then\n echo \"${VERIFY_JSON}\" >&2\n fi\n exit 5\nfi\n\nVERIFY_PARENT_URL=$(printf '%s' \"${VERIFY_JSON}\" | jq -r '.parent_issue_url // empty')\nVERIFY_PARENT=\"\"\nif [[ -n \"${VERIFY_PARENT_URL}\" ]]; then\n # 事前判定と同じくリポジトリ部分まで照合する。番号だけを見ると、POST 後の競合で対象が\n # 別リポジトリの同番号 issue(例: other/repo#7)へ移されていても VERIFY_PARENT == NEW_PARENT\n # となり、誤ったツリー状態を成功として報告してしまう\n if [[ \"${VERIFY_PARENT_URL%/issues/*}\" != \"${SELF_REPO_URL}\" ]]; then\n echo \"エラー: 事後確認で対象が別リポジトリの親配下にある(${VERIFY_PARENT_URL})。新親 #${NEW_PARENT} への紐付けは成立していない\" >&2\n echo \"対処: 実状態を手で確認する\" >&2\n exit 5\n fi\n VERIFY_PARENT=$(printf '%s' \"${VERIFY_PARENT_URL}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

|| true)\nfi\n\nif [[ \"${VERIFY_PARENT}\" != \"${NEW_PARENT}\" ]]; then\n echo \"エラー: 事後確認で新親 #${NEW_PARENT} 配下に見つからない(実測 parent=#${VERIFY_PARENT:-なし})\" >&2\n exit 5\nfi\n\nif [[ -z \"${CURRENT_PARENT}\" ]]; then\n emit_result \"posted-only\" \"-\"\nelse\n emit_result \"reassigned\" \"${CURRENT_PARENT}\"\nfi\nexit 0\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":67846,"content_sha256":"4d890713ad2244c807bbcabefb7e51252ccc5ecabf3e368bcb01eca5141a0b16"},{"filename":"scripts/update-root-body.sh","content":"#!/usr/bin/env bash\n# update-root-body.sh — ルート(トラッキング)issue 本文の「管理ブロックだけ」を更新する\n#\n# 呼び出し元: skills/update-issue-tree/SKILL.md Step 8。\n# 役割: 旧 Step 8 は固定テンプレートで本文を全置換し、人が書いた計画・判断根拠・追記を棚卸しの\n# たびに消していた(Issue #545)。本スクリプトは既存本文を取得し、スキルが管理する範囲\n# だけを実ツリー(sub_issues API)から再生成して差し替え、それ以外の行は 1 行も失わない。\n#\n# 不変条件:\n# - 既存本文から消してよいのは「phase-plan マーカー間の行」と「granularity マーカー行」だけ。\n# マーカーが無い本文の移行時も同じで、旧 Phase 別表の候補は削除せず \u003cdetails> へ逐語で退避する。\n# - 棚卸し履歴(inventory マーカー間)は追記専用。既存行は保持し、今回の 1 行だけ末尾へ足す。\n# - 本文に載せる値は実ツリーと検証済み引数だけから作る。件数引数は既定値なしの必須で、\n# プレースホルダーが残っていれば API を呼ぶ前に exit 1 で止まる。\n# - fail-closed: 取得失敗・マーカー不整合・並行編集・サイズ超過は、本文を変更せず停止する。\n#\n# 終了コードの正は SKILL.md Step 8 の表(0 成功 / 1 引数 / 2 前提 / 3 ツリー / 4 マーカー不整合 /\n# 5 並行編集 / 6 編集失敗 / 7 事後確認不一致)。stdout の最終行は機械可読な result= 行。\n#\n# このスクリプトは単体実行用であり source しない(set -euo pipefail を使う)。\nset -euo pipefail\n\nPB='\u003c!-- update-issue-tree:phase-plan:begin -->'\nPE='\u003c!-- update-issue-tree:phase-plan:end -->'\nMAX_BODY=65536\nMAX_DEPTH=8\n\ndie() {\n local code=\"$1\"\n shift\n echo \"エラー: $*\" >&2\n exit \"${code}\"\n}\n\nusage() {\n echo \"使い方: update-root-body.sh --root \u003cn> --granularity \u003cNh> --reassigned \u003cn> --orphans \u003cn> --labels \u003cn> --new-phases \u003cn> --split \u003cn> [--date YYYY-MM-DD] [--dry-run]\" >&2\n}\n\n# ---- 引数検証(API 未実行) ----\nROOT=\"\" GRAN=\"\" C_REASSIGNED=\"\" C_ORPHANS=\"\" C_LABELS=\"\" C_NEWPH=\"\" C_SPLIT=\"\" DATE_ARG=\"\" DRY=0\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --dry-run)\n DRY=1\n shift\n ;;\n --root | --granularity | --reassigned | --orphans | --labels | --new-phases | --split | --date)\n [[ $# -ge 2 ]] || {\n usage\n die 1 \"$1 に値がない\"\n }\n case \"$1\" in\n --root) ROOT=\"$2\" ;;\n --granularity) GRAN=\"$2\" ;;\n --reassigned) C_REASSIGNED=\"$2\" ;;\n --orphans) C_ORPHANS=\"$2\" ;;\n --labels) C_LABELS=\"$2\" ;;\n --new-phases) C_NEWPH=\"$2\" ;;\n --split) C_SPLIT=\"$2\" ;;\n --date) DATE_ARG=\"$2\" ;;\n esac\n shift 2\n ;;\n *)\n usage\n die 1 \"未知の引数: $1\"\n ;;\n esac\ndone\n\n[[ \"${ROOT}\" =~ ^[1-9][0-9]*$ ]] || die 1 \"--root は正整数で指定する\"\n[[ \"${GRAN}\" =~ ^[1-9][0-9]*h$ ]] || die 1 \"--granularity は正整数+h(例: 2h)で指定する\"\nfor pair in \"reassigned:${C_REASSIGNED}\" \"orphans:${C_ORPHANS}\" \"labels:${C_LABELS}\" \"new-phases:${C_NEWPH}\" \"split:${C_SPLIT}\"; do\n [[ \"${pair#*:}\" =~ ^(0|[1-9][0-9]*)$ ]] || die 1 \"--${pair%%:*} は非負整数の実績値で指定する(プレースホルダーが残っていないか確認)\"\ndone\nif [[ -z \"${DATE_ARG}\" ]]; then\n DATE_ARG=$(date +%Y-%m-%d)\nfi\n[[ \"${DATE_ARG}\" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die 1 \"--date は YYYY-MM-DD で指定する\"\n\n# ---- 前提確認 ----\ncommand -v gh > /dev/null 2>&1 || die 2 \"gh が見つからない\"\ncommand -v jq > /dev/null 2>&1 || die 2 \"jq が見つからない\"\ngh auth status > /dev/null 2>&1 || die 2 \"gh が未認証(gh auth login)\"\n\nWORK=$(mktemp -d) || die 2 \"一時ディレクトリを作成できない\"\ntrap 'rm -rf \"${WORK}\"' EXIT\n\n# ---- ルート本文の取得 ----\nfetch_root() {\n # $1: 出力先 JSON ファイル。取得失敗・PR は呼び出し側で exit 2 にする。\n gh api \"repos/{owner}/{repo}/issues/${ROOT}\" > \"$1\" 2> /dev/null\n}\n\nROOT_JSON=\"${WORK}/root.json\"\nfetch_root \"${ROOT_JSON}\" || die 2 \"ルート issue #${ROOT} を取得できない\"\njq -e 'type == \"object\"' \"${ROOT_JSON}\" > /dev/null 2>&1 || die 2 \"ルート issue #${ROOT} の応答が不正\"\nif jq -e '.pull_request != null' \"${ROOT_JSON}\" > /dev/null 2>&1; then\n die 2 \"#${ROOT} は PR であり issue ではない\"\nfi\nROOT_REPO_URL=$(jq -r '.repository_url // \"\"' \"${ROOT_JSON}\")\n\nBODY_FILE=\"${WORK}/body.md\"\n# 原文を逐語で保持する(CR・末尾改行を削らない)。コマンド置換は末尾改行を落とすため、\n# 本文は jq からファイルへ直接書き出し、以降の比較もすべてファイル内容で行う。\n# 解析時の CRLF 吸収は merge.awk 側で行う。\njq -j '.body // \"\"' \"${ROOT_JSON}\" > \"${BODY_FILE}\" || die 2 \"ルート本文を取り出せない\"\n\n# 末尾の改行だけを無視した比較用の正規化($1 入力ファイル / $2 出力ファイル / $3 \"nocr\" で CR も除去)。\n# unchanged 判定と事後確認が、改行 1 個の有無(GitHub 側の正規化を含む)で誤判定しないようにする。\nnorm_to() {\n if [[ \"${3:-}\" == \"nocr\" ]]; then\n jq -Rj -s 'gsub(\"\\r\"; \"\") | sub(\"\\n+\\\\z\"; \"\")' \"$1\" > \"$2\"\n else\n jq -Rj -s 'sub(\"\\n+\\\\z\"; \"\")' \"$1\" > \"$2\"\n fi\n}\n\n# ---- ツリー取得 ----\n# 子一覧は番号ごとにキャッシュし、同じ issue を再取得しない。\nfetch_children() {\n local n=\"$1\" f=\"${WORK}/c_${1}.json\" p=1 page cnt\n local pages=()\n [[ -f \"${f}\" ]] && return 0\n while :; do\n page=\"${WORK}/p_${n}_${p}.json\"\n gh api \"repos/{owner}/{repo}/issues/${n}/sub_issues?per_page=100&page=${p}\" > \"${page}\" 2> /dev/null \\\n || die 3 \"#${n} の sub-issues を取得できない(page=${p})\"\n jq -e 'type == \"array\"' \"${page}\" > /dev/null 2>&1 || die 3 \"#${n} の sub-issues 応答が配列でない\"\n pages+=(\"${page}\")\n cnt=$(jq 'length' \"${page}\")\n if [[ \"${cnt}\" -lt 100 ]]; then\n break\n fi\n p=$((p + 1))\n [[ \"${p}\" -le 50 ]] || die 3 \"#${n} の sub-issues が 5000 件を超えた\"\n done\n jq -s 'add // []' \"${pages[@]}\" > \"${f}\"\n}\n\n# 子の列挙(number state same/other summary_total)。repository_url 欠落は同一リポ扱い。\nchildren_tsv() {\n jq -r --arg ru \"${ROOT_REPO_URL}\" '.[] | [\n .number,\n .state,\n (if ((.repository_url // $ru) == $ru) then \"same\" else \"other\" end),\n (.sub_issues_summary.total // -1)\n ] | @tsv' \"${WORK}/c_${1}.json\"\n}\n\n# walk \u003cn> \u003cdepth> → \"直下 open 数\u003c空白>全子孫 open 数\" を stdout へ出す。\n# summary.total == 0 の節点と別リポジトリの sub-issue は API を呼ばず再帰しない。\nwalk() {\n local n=\"$1\" depth=\"$2\" direct=0 total=0 num state same stotal out d t\n [[ \"${depth}\" -le \"${MAX_DEPTH}\" ]] || die 3 \"ツリーの深さが ${MAX_DEPTH} を超えた(#${n})\"\n fetch_children \"${n}\"\n children_tsv \"${n}\" > \"${WORK}/w_${n}.tsv\"\n while IFS=

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

\\t' read -r num state same stotal; do\n [[ -n \"${num}\" ]] || continue\n if [[ \"${state}\" == \"open\" ]]; then\n direct=$((direct + 1))\n total=$((total + 1))\n fi\n if [[ \"${same}\" == \"same\" && \"${stotal}\" != \"0\" ]]; then\n out=$(walk \"${num}\" $((depth + 1)))\n read -r d t \u003c\u003c\u003c \"${out}\"\n total=$((total + t))\n fi\n done \u003c \"${WORK}/w_${n}.tsv\"\n echo \"${direct} ${total}\"\n}\n\nfetch_children \"${ROOT}\"\nROOT_CHILDREN=\"${WORK}/c_${ROOT}.json\"\nCHILD_COUNT=$(jq 'length' \"${ROOT_CHILDREN}\")\nSTATS=\"${WORK}/stats.tsv\"\nDETAILS=\"${WORK}/details.jsonl\"\n: > \"${STATS}\"\n: > \"${DETAILS}\"\nchildren_tsv \"${ROOT}\" > \"${WORK}/rootrows.tsv\"\n\nwhile IFS=

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

\\t' read -r num state same stotal; do\n [[ -n \"${num}\" ]] || continue\n d=0\n t=0\n if [[ \"${same}\" == \"same\" && \"${stotal}\" != \"0\" ]]; then\n out=$(walk \"${num}\" 1)\n read -r d t \u003c\u003c\u003c \"${out}\"\n fi\n printf '%s\\t%s\\t%s\\n' \"${num}\" \"${d}\" \"${t}\" >> \"${STATS}\"\n\n # 直下に open の子がある節点は、その open の子を詳細表にする。\n if [[ \"${same}\" == \"same\" && \"${d}\" -gt 0 ]]; then\n DEC=\"${WORK}/dec_${num}.tsv\"\n : > \"${DEC}\"\n children_tsv \"${num}\" > \"${WORK}/g_${num}.tsv\"\n while IFS=

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

\\t' read -r gnum gstate gsame gtotal; do\n [[ -n \"${gnum}\" && \"${gstate}\" == \"open\" ]] || continue\n dec=0\n if [[ \"${gsame}\" == \"same\" && \"${gtotal}\" != \"0\" ]]; then\n gout=$(walk \"${gnum}\" 2)\n read -r _ gt \u003c\u003c\u003c \"${gout}\"\n if [[ \"${gt}\" -gt 0 ]]; then\n dec=1\n fi\n fi\n printf '%s\\t%s\\n' \"${gnum}\" \"${dec}\" >> \"${DEC}\"\n done \u003c \"${WORK}/g_${num}.tsv\"\n jq -c --arg ru \"${ROOT_REPO_URL}\" --argjson parent \"${num}\" --rawfile dec \"${DEC}\" '\n ($dec | split(\"\\n\") | map(select(length > 0) | split(\"\\t\")) | map({key: .[0], value: .[1]}) | from_entries) as $m\n | {parent: $parent, items: [ .[] | select(.state == \"open\") | {\n number: .number,\n title: .title,\n other: ((.repository_url // $ru) != $ru),\n repo: ((.repository_url // $ru) | sub(\"^.*/repos/\"; \"\")),\n decomp: (($m[(.number | tostring)] // \"0\") == \"1\")\n } ]}' \"${WORK}/c_${num}.json\" >> \"${DETAILS}\"\n fi\ndone \u003c \"${WORK}/rootrows.tsv\"\n\n# ---- 管理ブロックの生成 ----\n# issue タイトル・ラベルは非信頼データ。jq 内でデータとして無害化し、シェル展開へ載せない。\n# create-issue-tree の tree-lib.sh の CELL と同じ HTML 文字参照変換(& \u003c > ` \\ | と改行)を行う。変更するときは両方を揃える\n# (スキルは個別に配布されるため、source でまたいで共有しない)。`&` を最初に置換するのは、後続の置換が出す文字参照の & を\n# 二重変換しないため。生成行にバッククォート・`\u003c`・`>`・`\\`・列区切りの `|` が残らないので、タイトルはマーカーや HTML 構造を\n# 偽装できず、merge-root-body.sh の判定(exit 9)にも当たらない。\nBLOCK_FILE=\"${WORK}/block.md\"\njq -r -n \\\n --arg pb \"${PB}\" --arg pe \"${PE}\" \\\n --rawfile stats \"${STATS}\" \\\n --slurpfile det \"${DETAILS}\" \\\n --slurpfile ch \"${ROOT_CHILDREN}\" \\\n --arg ru \"${ROOT_REPO_URL}\" '\n def cell: tostring | gsub(\"[\\r\\n]+\"; \" \") | gsub(\"&\"; \"&\") | gsub(\"\u003c\"; \"<\") | gsub(\">\"; \">\") | gsub(\"`\"; \"`\") | gsub(\"\\\\\\\\\"; \"\\") | gsub(\"\\\\|\"; \"|\");\n def phasex: ([ (.labels // [])[] | (.name? // empty) | select(startswith(\"phase:\")) ]) as $p\n | if ($p | length) == 1 and ($p[0] | ltrimstr(\"phase:\") | test(\"^[0-9A-Za-z._-]{1,32}$\"))\n then ($p[0] | ltrimstr(\"phase:\")) else null end;\n def ref: if ((.repository_url // $ru) != $ru)\n then ((.repository_url | sub(\"^.*/repos/\"; \"\")) + \"#\" + (.number | tostring))\n else (\"#\" + (.number | tostring)) end;\n ($stats | split(\"\\n\") | map(select(length > 0) | split(\"\\t\"))) as $st\n | ($det | map({key: (.parent | tostring), value: .items}) | from_entries) as $dm\n | $ch[0] as $kids\n | [\n $pb,\n \"| Phase | 親 issue | 直下 open | 総 open |\",\n \"|-------|----------|-----------|---------|\"\n ]\n + [ $kids | to_entries[] | .key as $i | .value as $k\n | ($k | ref) as $r\n | ((($k | phasex) // null) | if . == null then \"-\" else \"Phase \" + . end) as $ph\n | ($k.title | cell) as $t\n | (if $k.state == \"closed\" then \"(closed)\" else \"\" end) as $cl\n | (if ((($k.repository_url // $ru) != $ru)) then \"-\" else $st[$i][1] end) as $d\n | (if ((($k.repository_url // $ru) != $ru)) then \"-\" else $st[$i][2] end) as $tt\n | \"| \\($ph) | \\($r) \\($t)\\($cl) | \\($d) | \\($tt) |\" ]\n + [ $kids | to_entries[] | .key as $i | .value as $k\n | select(($dm[($k.number | tostring)] // []) | length > 0)\n | ($k | phasex) as $px\n | ($k.title | cell) as $th\n | (if $px == null then \"### #\\($k.number): \\($th)\" else \"### Phase \\($px): \\($th)\" end),\n \"\",\n \"| Issue | タイトル | 分解 |\",\n \"|-------|---------|------|\",\n ( $dm[($k.number | tostring)][]\n | \"| \\(if .other then \"\\(.repo)#\\(.number)\" else \"#\\(.number)\" end) | \\(.title | cell) | \\(if .decomp then \"sub-issue あり\" else \"-\" end) |\" ),\n \"\" ]\n + [ $pe ]\n | .[]' > \"${BLOCK_FILE}\" || die 3 \"管理ブロックの生成に失敗した\"\n\n# 自己検査: 表のデータ行数がルート直下の件数と一致すること。\nROWS=$(awk 'NR > 1 && /^\\| /{ c++ } NR > 1 && /^$/{ exit } END { print c + 0 }' \"${BLOCK_FILE}\")\nif [[ $((ROWS - 1)) -ne \"${CHILD_COUNT}\" ]]; then\n die 3 \"生成した表の行数($((ROWS - 1)))がルート直下の件数(${CHILD_COUNT})と一致しない\"\nfi\n\nROW_FILE=\"${WORK}/row.txt\"\nprintf '| %s | %s | %s | %s | %s | %s |\\n' \\\n \"${DATE_ARG}\" \"${C_REASSIGNED}\" \"${C_ORPHANS}\" \"${C_LABELS}\" \"${C_NEWPH}\" \"${C_SPLIT}\" > \"${ROW_FILE}\"\n\n# ---- マージ ----\n# 本文はコードフェンスの内外を追跡して処理する(フェンス内のマーカー様文字列・見出しは対象外)。\n# 置換テキストは -v ではなくファイル経由で渡す(エスケープ解釈を避ける)。\ncat > \"${WORK}/merge.awk\" \u003c\u003c 'AWK'\nfunction rtrim(s) {\n sub(/[ \\t]+$/, \"\", s)\n return s\n}\nfunction readfile(path, line, out, first) {\n out = \"\"\n first = 1\n while ((getline line \u003c path) > 0) {\n out = out (first ? \"\" : \"\\n\") line\n first = 0\n }\n close(path)\n return out\n}\nfunction ish(i, lvl, s) {\n s = A[i]\n if (lvl == 1) return (substr(s, 1, 2) == \"# \")\n if (lvl == 2) return (substr(s, 1, 3) == \"## \")\n return (substr(s, 1, 4) == \"### \")\n}\nfunction addarch(a, b, j) {\n for (j = a; j \u003c= b; j++) {\n arch = arch (arch == \"\" ? \"\" : \"\\n\") L[j]\n skip[j] = 1\n }\n}\n{ n++; L[n] = $0; A[n] = $0; sub(/\\r$/, \"\", A[n]) }\nEND {\n PB = \"\u003c!-- update-issue-tree:phase-plan:begin -->\"\n PE = \"\u003c!-- update-issue-tree:phase-plan:end -->\"\n IB = \"\u003c!-- update-issue-tree:inventory:begin -->\"\n IE = \"\u003c!-- update-issue-tree:inventory:end -->\"\n BLOCK = readfile(ENVIRON[\"BLOCKF\"])\n ROW = readfile(ENVIRON[\"ROWF\"])\n DATE = ENVIRON[\"DATE\"]\n INVHEAD = \"| 日付 | 付け替え | 孤児再配置 | phase ラベル同期 | 新 Phase 親 | sub-issue 分解 |\\n|------|---------|-----------|----------------|------------|---------------|\"\n INVSEC = \"## 棚卸しで実施した整理(update-issue-tree 実行履歴)\\n\\n\" IB \"\\n\" INVHEAD \"\\n\" ROW \"\\n\" IE\n\n infence = 0\n for (i = 1; i \u003c= n; i++) {\n t = A[i]\n sub(/^ ? ? ?/, \"\", t)\n c = substr(t, 1, 1)\n run = 0\n if (c == \"`\" || c == \"~\") {\n while (substr(t, run + 1, 1) == c) run++\n }\n if (!infence) {\n F[i] = 0\n if (run >= 3) {\n rest = substr(t, run + 1)\n if (c == \"~\" || index(rest, \"`\") == 0) {\n infence = 1; fch = c; flen = run; F[i] = 1\n }\n }\n } else {\n F[i] = 1\n if (c == fch && run >= flen && substr(t, run + 1) ~ /^[ \\t]*$/) infence = 0\n }\n }\n\n # 閉じていないフェンスは以降をすべてコード扱いにする。本物の見出し・管理マーカーを見落として\n # 管理ブロックを二重に追記し、追記分も描画上コードに飲まれるため、本文を変えずに止める。\n if (infence) {\n print \"コードフェンスが閉じていない。管理マーカーの位置を判定できないため本文は変更しない(本文のフェンスを閉じてから再実行する)\" > \"/dev/stderr\"\n exit 4\n }\n\n hfound = 0\n nonblank = 0\n for (i = 1; i \u003c= n; i++) {\n if (F[i]) { nonblank++; continue }\n s = rtrim(A[i])\n if (s == PB) { npb++; pb = i }\n else if (s == PE) { npe++; pe = i }\n else if (s == IB) { nib++; ib = i }\n else if (s == IE) { nie++; ie = i }\n else if (s ~ /^\u003c!--[ ]*granularity:[ ]*[0-9]+h[ ]*-->$/) { skip[i] = 1; continue }\n if (s !~ /^[ \\t]*$/) nonblank++\n if (!hfound && index(A[i], \"## Phase 別実装計画\") == 1) { h = i; hfound = 1 }\n }\n\n bad = 0\n if (npb > 1 || npe > 1 || npb != npe) bad = 1\n if (nib > 1 || nie > 1 || nib != nie) bad = 1\n if (npb == 1 && pb >= pe) bad = 1\n if (nib == 1 && ib >= ie) bad = 1\n if (npb == 1 && nib == 1 && !(ie \u003c pb || pe \u003c ib)) bad = 1\n if (bad) {\n print \"管理マーカーが不整合(片方のみ・重複・逆順・入れ子のいずれか)。本文は変更しない\" > \"/dev/stderr\"\n exit 4\n }\n\n tail = \"\"\n if (nonblank == 0) {\n mig = \"inserted\"\n print \"## 概要\\n\\n全 open issue を Phase 別に整理したトラッキング issue。各 Phase 親 issue を sub-issues として紐付ける。\\n\"\n print INVSEC \"\\n\"\n print \"## Phase 別実装計画\\n\"\n print BLOCK \"\\n\"\n print \"## 運用\\n\\n- 新規 issue は起票時に Phase 親へ紐付ける\\n- 実行順は sub-issues リスト順が正\\n- closed 親の下に open issue を残置しない\\n- implement-issue-tree が post-order DFS で消化可能な構造を維持する\"\n print mig > ENVIRON[\"MIGF\"]\n exit 0\n }\n\n mig = \"none\"\n if (npb == 1) {\n for (i = pb; i \u003c= pe; i++) skip[i] = 1\n before[pb] = BLOCK\n anchor = (hfound && h \u003c pb) ? h : pb\n } else {\n if (hfound) {\n e = n\n for (j = h + 1; j \u003c= n; j++) {\n if (!F[j] && (ish(j, 1) || ish(j, 2))) { e = j - 1; break }\n }\n first3 = e + 1\n for (j = h + 1; j \u003c= e; j++) {\n if (!F[j] && ish(j, 3)) { first3 = j; break }\n }\n arch = \"\"\n pre_nb = 0\n for (j = h + 1; j \u003c first3; j++) if (A[j] !~ /^[ \\t]*$/) pre_nb = 1\n if (pre_nb) addarch(h + 1, first3 - 1)\n j = first3\n while (j \u003c= e) {\n ce = e\n for (k = j + 1; k \u003c= e; k++) {\n if (!F[k] && ish(k, 3)) { ce = k - 1; break }\n }\n if (substr(A[j], 1, 10) == \"### Phase \") addarch(j, ce)\n j = ce + 1\n }\n aft = \"\\n\" BLOCK \"\\n\"\n if (arch != \"\") {\n aft = aft \"\\n\u003cdetails>\u003csummary>移行前の Phase 別表(update-issue-tree が \" DATE \" に退避。確認のうえ不要なら削除)\u003c/summary>\\n\\n\" arch \"\\n\\n\u003c/details>\\n\"\n mig = \"phase-plan-archived\"\n } else {\n mig = \"inserted\"\n }\n after[h] = aft\n anchor = h\n } else {\n tail = \"## Phase 別実装計画\\n\\n\" BLOCK\n mig = \"inserted\"\n anchor = n + 1\n }\n }\n\n if (nib == 1) {\n last = \"\"\n for (j = ib + 1; j \u003c ie; j++) if (A[j] !~ /^[ \\t]*$/) last = rtrim(A[j])\n if (last != ROW) before[ie] = ROW\n } else {\n if (anchor \u003c= n) pre[anchor] = INVSEC \"\\n\"\n else tail = INVSEC \"\\n\\n\" tail\n if (mig == \"none\") mig = \"inserted\"\n }\n\n for (i = 1; i \u003c= n; i++) {\n if (pre[i] != \"\") print pre[i]\n if (before[i] != \"\") print before[i]\n if (!skip[i]) print L[i]\n if (after[i] != \"\") print after[i]\n }\n if (tail != \"\") {\n if (n > 0) print \"\"\n print tail\n }\n print mig > ENVIRON[\"MIGF\"]\n}\nAWK\n\nMERGED_FILE=\"${WORK}/merged.md\"\nMIG_FILE=\"${WORK}/migration.txt\"\nrc=0\nBLOCKF=\"${BLOCK_FILE}\" ROWF=\"${ROW_FILE}\" DATE=\"${DATE_ARG}\" MIGF=\"${MIG_FILE}\" \\\n awk -f \"${WORK}/merge.awk\" \"${BODY_FILE}\" > \"${MERGED_FILE}\" || rc=$?\nif [[ \"${rc}\" -eq 4 ]]; then\n exit 4\nelif [[ \"${rc}\" -ne 0 ]]; then\n die 3 \"本文のマージに失敗した(awk exit ${rc})\"\nfi\nMIGRATION=$(cat \"${MIG_FILE}\")\n\n# granularity マーカーは先頭に 1 行だけ維持する(値は引数どおり)。\n# 送信本文はコマンド置換を介さずファイルとして組み立てる(末尾改行を含め逐語で保つ)。\nOUT_FILE=\"${WORK}/new-body.md\"\n{\n printf '\u003c!-- granularity: %s -->\\n' \"${GRAN}\"\n cat \"${MERGED_FILE}\"\n} > \"${OUT_FILE}\"\n\n# 実際に送る内容の長さで判定する。長さは jq でコードポイント数を数える\n# (bash の ${#} は C ロケールだとバイト数になる)。\nWRITE_LEN=$(jq -Rs 'length' \"${OUT_FILE}\") || die 3 \"本文の長さを計測できない\"\nif [[ \"${WRITE_LEN}\" -gt \"${MAX_BODY}\" ]]; then\n die 3 \"更新後の本文が ${MAX_BODY} 文字を超える(${WRITE_LEN} 文字)。編集しない\"\nfi\n\nemit_result() {\n echo \"result=$1 root=${ROOT} migration=${MIGRATION} phases=${CHILD_COUNT}\"\n}\n\nif [[ \"${DRY}\" -eq 1 ]]; then\n cat \"${OUT_FILE}\"\n emit_result dry-run\n exit 0\nfi\n\nnorm_to \"${OUT_FILE}\" \"${WORK}/n_new.txt\" || die 3 \"送信本文を正規化できない\"\nnorm_to \"${BODY_FILE}\" \"${WORK}/n_old.txt\" || die 3 \"取得本文を正規化できない\"\nif cmp -s \"${WORK}/n_new.txt\" \"${WORK}/n_old.txt\"; then\n emit_result unchanged\n exit 0\nfi\n\n# ---- 編集直前の再取得で並行編集を検知する ----\n# 条件付き更新が API に無いため、取得時点の本文と一致する場合に限って書き込む。\n# 比較はファイル内容の厳密一致(コマンド置換を介さないため末尾改行・CR も差として検出する)。\nRECHECK_JSON=\"${WORK}/root_recheck.json\"\nfetch_root \"${RECHECK_JSON}\" || die 5 \"編集直前のルート再取得に失敗した。編集しない\"\nRECHECK_BODY=\"${WORK}/body_recheck.md\"\njq -j '.body // \"\"' \"${RECHECK_JSON}\" > \"${RECHECK_BODY}\" || die 5 \"編集直前のルート本文を取り出せない。編集しない\"\nif ! cmp -s \"${BODY_FILE}\" \"${RECHECK_BODY}\"; then\n die 5 \"取得後にルート本文が変更された(並行編集)。編集しない。再実行して差分を確認する\"\nfi\n\nif ! gh issue edit \"${ROOT}\" --body-file \"${OUT_FILE}\" > /dev/null; then\n die 6 \"gh issue edit が失敗した。本文の実状態を確認する\"\nfi\n\n# ---- 事後確認 ----\n# 取得した本文全体を送信した本文と比較する。欠落・第三者による改変・マーカー崩れはすべて\n# 不一致になる。末尾改行と CR だけは GitHub 側の正規化を許容して無視する。\nVERIFY_JSON=\"${WORK}/root_verify.json\"\nfetch_root \"${VERIFY_JSON}\" || die 7 \"事後確認の再取得に失敗した(本文は編集済み)\"\nVERIFY_BODY=\"${WORK}/body_verify.md\"\njq -j '.body // \"\"' \"${VERIFY_JSON}\" > \"${VERIFY_BODY}\" || die 7 \"事後確認の本文を取り出せない(本文は編集済み)\"\nnorm_to \"${VERIFY_BODY}\" \"${WORK}/n_verify.txt\" nocr || die 7 \"事後確認の本文を正規化できない(本文は編集済み)\"\nnorm_to \"${OUT_FILE}\" \"${WORK}/n_sent.txt\" nocr || die 7 \"送信本文を正規化できない(本文は編集済み)\"\nif ! cmp -s \"${WORK}/n_verify.txt\" \"${WORK}/n_sent.txt\"; then\n die 7 \"事後確認の不一致(取得した本文が送信した本文と一致しない。本文は編集済み)\"\nfi\n\nemit_result updated\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":22589,"content_sha256":"0254c09f2f6216e4ee17e96cff2ce437b455174dd8c9d6890a68b6cc73e9c9c7"},{"filename":"tests/lib/gh-stub.mjs","content":"// gh-stub.mjs — reassign-sub-issue.sh の決定的回帰テスト用に、実 API を叩かずに `gh` を\n// 差し替えるスタブを生成する。テストは実行ログ($GH_CALL_LOG)に対して直接アサートすることで、\n// 「DELETE が失敗したら POST が 1 件も呼ばれない」等の呼び出し順序・回数の契約を検証する。\n//\n// ルーティングはパスベース(Issue #333)。スクリプトは必ず対象 issue の GET を最初に撃つため、\n// スタブが最初に受けた api GET のパスを「対象 issue のパス」として一時ファイルへ記録し、以降\n// 同じパスの GET は従来どおり「1 回目=事前確認・2 回目以降=事後確認」として応答する。記録済み\n// パスと一致しない GET は新親の事前検証(DELETE 前に撃たれる新設の GET。Issue #333 Step 2)と\n// みなし、newParentGetFail / newParentRepo で応答する。\n// 記録は失敗分岐の**前**に行う。記録がスタブの応答内容から独立していることで、「1 回目の GET\n// のパス = 対象 issue のパス」という不変条件が fixture の組み合わせによらず保たれる。\n// 記録を失敗分岐の後ろへ動かすと、対象 issue の GET を失敗させる fixture では対象パスが\n// 記録されないまま次の GET が来た場合にそれが対象パスとして記録され、ルーティングが崩れる。\n// 現在のスクリプトでは対象 issue の GET 失敗時に exit 2 で終了するため、その組み合わせは\n// 実際には到達しない。ここで守っているのは「将来スクリプトが対象 GET の失敗から回復して\n// 続行するようになっても、スタブ側のルーティング前提が壊れない」という耐性であり、\n// 特定の fixture 組み合わせを想定した記述ではない\n\nimport { mkdtempSync, writeFileSync, chmodSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join } from 'node:path'\nimport { trackTmp } from './tmp-track.mjs'\n\n// シェルの単一引用符リテラル内に安全に埋め込むためのエスケープ( ' → '\\'' )\nfunction shQuote(value) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\n/**\n * @param {object} fixture\n * @param {boolean} [fixture.authFail] gh auth status を失敗させる\n * @param {boolean} [fixture.getFail] 事前 GET(1 回目)を失敗させる\n * @param {boolean} [fixture.verifyGetFail] 事後 GET(2 回目)を失敗させる\n * @param {boolean} [fixture.thirdGetFail] 3 回目の GET(孤児観測の安定確認 / 補償 POST 後の\n * 事後確認 / 補償 POST 失敗後の実状態再取得のいずれか、シナリオにより異なる)を失敗させる\n * @param {boolean} [fixture.fourthGetFail] 4 回目の GET(補償 POST 失敗後の実状態再取得、\n * 孤児観測の安定確認より後で発生する再取得、または Issue #482 の新親側の安定確認)を失敗させる\n * @param {string} [fixture.issueId] GET が返す database id\n * @param {string} [fixture.parentBefore] 事前 GET が返す現在の親 issue 番号('' = 親なし)\n * @param {string} [fixture.parentAfter] 対象 issue の 2 回目 GET(成功経路の事後確認 / 失敗経路の\n * 補償復旧のための実状態再取得のいずれか。両者は排他的なシナリオのため 1 フィールドで足りる)\n * が返す親 issue 番号(未指定なら parentBefore を継続)\n * @param {string} [fixture.parentAfter2] 対象 issue の 3 回目の GET(補償復旧 POST 後の事後\n * 確認、または補償 POST 失敗後の実状態確認)が返す親 issue 番号(未指定なら parentAfter を継続)\n * @param {string} [fixture.parentAfter3] 対象 issue の 4 回目の GET(Issue #352 の\n * confirm_stable_old_parent が撃つ反映遅延の再確認・偽陰性の再確認、補償 POST 成功後の\n * 事後確認 RECOVERY_VERIFY_JSON、または Issue #482 で追加した「3 回目の GET で新親を初観測\n * した場合に新親を期待値として撃つ安定確認」)が返す親 issue 番号(未指定なら parentAfter2 を継続)\n * @param {string} [fixture.parentAfter4] 対象 issue の 5 回目以降の GET(補償復旧後の最終安定確認\n * 等、confirm_stable_parent の追加呼び出しが撃つ再取得)が返す親 issue 番号\n * (未指定なら parentAfter3 を継続)\n * @param {string} [fixture.parentRepo] 事前 GET が返す親の owner/repo(既定 'o/r' = 対象 issue と同一)\n * @param {string} [fixture.parentRepoAfter] 事後 GET(2 回目)が返す親の owner/repo(未指定なら parentRepo を継続)\n * @param {string} [fixture.parentRepoAfter2] 3 回目の GET が返す親の owner/repo(未指定なら parentRepoAfter を継続)\n * @param {string} [fixture.parentRepoAfter3] 4 回目の GET が返す親の owner/repo(未指定なら parentRepoAfter2 を継続)\n * @param {string} [fixture.parentRepoAfter4] 5 回目以降の GET が返す親の owner/repo(未指定なら parentRepoAfter3 を継続)\n * @param {number} [fixture.deleteExit] DELETE の終了コード\n * @param {string} [fixture.deleteBody] DELETE 失敗時に stderr へ出す本文\n * @param {number} [fixture.postExit] 1 回目の POST(新親への付け替え)の終了コード\n * @param {string} [fixture.postBody] 1 回目の POST 失敗時に stderr へ出す本文(\"only have one parent\" 判定に使う)\n * @param {number} [fixture.compPostExit] 2 回目以降の POST(Issue #352 の補償復旧で旧親へ戻す POST)の終了コード\n * @param {string} [fixture.compPostBody] 2 回目以降の POST 失敗時に stderr へ出す本文\n * @param {string} [fixture.rawAfter] 対象 issue の 2 回目 GET(復旧のための実状態再取得)が返す\n * 生の JSON 本文を丸ごと差し替える('{}' ・'null' ・'' ・別 issue 番号のオブジェクト等、\n * 「gh は成功終了したが応答が対象 issue の JSON ではない」ケースの再現。空文字列は\n * 「成功終了だが stdout が空」を表す(未指定 undefined とは区別する)。未指定なら\n * parentAfter / parentRepoAfter から生成した通常の応答を返す)\n * @param {string} [fixture.rawAfter2] 対象 issue の 3 回目 GET(安定確認の再取得)が返す生の\n * JSON 本文を丸ごと差し替える(未指定なら通常の応答)\n * @param {boolean} [fixture.newParentGetFail] 新親 GET を非ゼロ終了させる(存在しない番号の再現)\n * @param {string} [fixture.newParentRepo] 新親の repository_url の owner/repo(既定 'o/r' = 対象 issue と同一。'other/repo' で転送済み issue を再現)\n * @param {boolean} [fixture.newParentIsPullRequest] 新親 GET のレスポンスへ `.pull_request` を含める(issues API が PR も返す仕様の再現)\n */\nexport function createGhStub(fixture = {}) {\n const f = {\n authFail: false,\n getFail: false,\n verifyGetFail: false,\n thirdGetFail: false,\n fourthGetFail: false,\n issueId: '999',\n parentBefore: '',\n parentAfter: undefined,\n parentAfter2: undefined,\n parentAfter3: undefined,\n parentAfter4: undefined,\n // 親が属するリポジトリ。既定は対象 issue と同一(repository_url の o/r と一致)。\n // 'other/repo' 等を渡すと cross-repository sub-issue を再現できる\n parentRepo: 'o/r',\n parentRepoAfter: undefined,\n parentRepoAfter2: undefined,\n parentRepoAfter3: undefined,\n parentRepoAfter4: undefined,\n deleteExit: 0,\n deleteBody: '',\n postExit: 0,\n postBody: '',\n compPostExit: 0,\n compPostBody: '',\n // 既定は undefined(差し替えなし)。空文字列は「stdout が空の成功応答」という有効な\n // 指定値のため、`-n` による有無判定では両者を区別できない(下の raw*Set を使う)\n rawAfter: undefined,\n rawAfter2: undefined,\n newParentGetFail: false,\n newParentRepo: 'o/r',\n // issues API は PR も返す(issue と PR は番号空間を共有する)。true にすると\n // 新親 GET のレスポンスへ `.pull_request` を含め、--new-parent に PR 番号を\n // 渡したケースを再現する\n newParentIsPullRequest: false,\n ...fixture,\n }\n if (f.parentAfter === undefined) f.parentAfter = f.parentBefore\n if (f.parentRepoAfter === undefined) f.parentRepoAfter = f.parentRepo\n if (f.parentAfter2 === undefined) f.parentAfter2 = f.parentAfter\n if (f.parentRepoAfter2 === undefined) f.parentRepoAfter2 = f.parentRepoAfter\n if (f.parentAfter3 === undefined) f.parentAfter3 = f.parentAfter2\n if (f.parentRepoAfter3 === undefined) f.parentRepoAfter3 = f.parentRepoAfter2\n if (f.parentAfter4 === undefined) f.parentAfter4 = f.parentAfter3\n if (f.parentRepoAfter4 === undefined) f.parentRepoAfter4 = f.parentRepoAfter3\n\n // テストプロセスの終了時に必ず消す(1 回の全実行で 60 個以上が残っていた)\n const dir = trackTmp(mkdtempSync(join(tmpdir(), 'reassign-gh-stub-')))\n const ghPath = join(dir, 'gh')\n const logPath = join(dir, 'calls.log')\n const getCountPath = join(dir, 'get_count')\n const postCountPath = join(dir, 'post_count')\n const targetPathPath = join(dir, 'target_path')\n writeFileSync(getCountPath, '0')\n writeFileSync(postCountPath, '0')\n writeFileSync(targetPathPath, '')\n\n // raw の「未指定」と「空文字列の指定」を stub スクリプト内で区別するためのフラグ\n const rawAfterSet = f.rawAfter !== undefined ? '1' : '0'\n const rawAfter2Set = f.rawAfter2 !== undefined ? '1' : '0'\n\n const authBranch = f.authFail ? 'exit 1' : 'exit 0'\n const getFailBranch = f.getFail ? \"echo 'stub: get failed' >&2; exit 1\" : ':'\n const verifyGetFailBranch = f.verifyGetFail ? \"echo 'stub: verify get failed' >&2; exit 1\" : ':'\n const thirdGetFailBranch = f.thirdGetFail ? \"echo 'stub: third get failed' >&2; exit 1\" : ':'\n const fourthGetFailBranch = f.fourthGetFail ? \"echo 'stub: fourth get failed' >&2; exit 1\" : ':'\n const newParentGetFailBranch = f.newParentGetFail\n ? \"echo 'stub: new parent get failed' >&2; exit 1\"\n : ':'\n\n const script = `#!/usr/bin/env bash\n# 生成スタブ。実 gh の代わりに PATH の先頭へ差し込んで使う(テスト専用・実行ビット付き)\nset -u\necho \"$*\" >> ${shQuote(logPath)}\ncmd=\"\\${1:-}\"\n\nif [[ \"\\${cmd}\" == \"auth\" ]]; then\n ${authBranch}\nfi\n\nif [[ \"\\${cmd}\" != \"api\" ]]; then\n exit 0\nfi\nshift\n\nmethod=\"GET\"\npath=\"\"\nwhile [[ $# -gt 0 ]]; do\n case \"\\${1:-}\" in\n --method) method=\"$2\"; shift 2 ;;\n -F) shift 2 ;;\n *) path=\"$1\"; shift ;;\n esac\ndone\n\nif [[ \"\\${path}\" == *\"/sub_issue\" && \"\\${method}\" == \"DELETE\" ]]; then\n printf '%s' ${shQuote(f.deleteBody)} >&2\n exit ${f.deleteExit}\nfi\n\nif [[ \"\\${path}\" == *\"/sub_issues\" && \"\\${method}\" == \"POST\" ]]; then\n # 1 回目 = 新親への付け替え POST、2 回目以降 = Issue #352 の補償復旧 POST(旧親へ戻す)。\n # 呼び出し先の issue 番号ではなく呼び出し順で分岐する(宛先はテスト側で calls.log の\n # パスを直接 assert する)\n post_count=$(cat ${shQuote(postCountPath)})\n post_count=$((post_count + 1))\n echo \"\\${post_count}\" > ${shQuote(postCountPath)}\n if [[ \"\\${post_count}\" -eq 1 ]]; then\n printf '%s' ${shQuote(f.postBody)} >&2\n exit ${f.postExit}\n else\n printf '%s' ${shQuote(f.compPostBody)} >&2\n exit ${f.compPostExit}\n fi\nfi\n\n# 対象 issue のパスをまだ記録していなければ、この GET が対象 issue の GET(スクリプトは\n# 必ず対象 issue の GET を最初に撃つ)。失敗分岐より前に記録することで、getFail 時にも\n# 対象パスが確定した状態で exit する\ntarget_path=$(cat ${shQuote(targetPathPath)})\nif [[ -z \"\\${target_path}\" ]]; then\n printf '%s' \"\\${path}\" > ${shQuote(targetPathPath)}\n target_path=\"\\${path}\"\nfi\n\nif [[ \"\\${path}\" != \"\\${target_path}\" ]]; then\n # 記録済みの対象パスと一致しない GET = 新親の事前検証(Issue #333 Step 2)\n ${newParentGetFailBranch}\n printf '{\"repository_url\": \"https://api.github.com/repos/%s\"%s}\\\\n' ${shQuote(f.newParentRepo)} ${shQuote(f.newParentIsPullRequest ? ', \"pull_request\": {\"url\": \"https://api.github.com/repos/o/r/pulls/77\"}' : '')}\n exit 0\nfi\n\n# 対象 issue の GET(1 回目=事前確認・2 回目=事後確認 or 補償復旧のための実状態再取得・\n# 3 回目=孤児観測の安定確認(補償 POST 前。confirm_stable_parent) or 補償復旧 POST 後の\n# 事後確認 or 補償 POST 失敗後の実状態確認・4 回目=confirm_stable_old_parent(Issue #352)\n# が撃つ反映遅延・偽陰性の再確認 or 補償 POST 成功後の事後確認・5 回目以降=それらに続く\n# 追加の安定確認。2 回目は成功経路の事後確認と失敗経路の復旧再取得の両方を兼ねる\n# (両シナリオは排他的なため verifyGetFail / parentAfter を共用できる)\ncount=$(cat ${shQuote(getCountPath)})\ncount=$((count + 1))\necho \"\\${count}\" > ${shQuote(getCountPath)}\n\nraw=\"\"\nraw_set=0\nif [[ \"\\${count}\" -eq 1 ]]; then\n ${getFailBranch}\n parent=${shQuote(f.parentBefore)}\n prepo=${shQuote(f.parentRepo)}\nelif [[ \"\\${count}\" -eq 2 ]]; then\n ${verifyGetFailBranch}\n parent=${shQuote(f.parentAfter)}\n prepo=${shQuote(f.parentRepoAfter)}\n raw=${shQuote(f.rawAfter ?? '')}\n raw_set=${rawAfterSet}\nelif [[ \"\\${count}\" -eq 3 ]]; then\n ${thirdGetFailBranch}\n parent=${shQuote(f.parentAfter2)}\n prepo=${shQuote(f.parentRepoAfter2)}\n raw=${shQuote(f.rawAfter2 ?? '')}\n raw_set=${rawAfter2Set}\nelif [[ \"\\${count}\" -eq 4 ]]; then\n ${fourthGetFailBranch}\n parent=${shQuote(f.parentAfter3)}\n prepo=${shQuote(f.parentRepoAfter3)}\nelse\n parent=${shQuote(f.parentAfter4)}\n prepo=${shQuote(f.parentRepoAfter4)}\nfi\n\n# raw が指定された回は、生成した通常応答の代わりにその本文をそのまま返す(空文字列なら\n# 「gh は成功終了したが stdout は空」の再現)\nif [[ \"\\${raw_set}\" -eq 1 ]]; then\n printf '%s' \"\\${raw}\"\n exit 0\nfi\n\n# number は実 API の応答に含まれる対象 issue の識別情報。スクリプトは「応答が対象 issue の\n# JSON オブジェクトか」をこのフィールドで検証するため、パス末尾の issue 番号から生成する\n# (articles#119 の codex P1 指摘対応)\nnum=\"\\${path##*/}\"\nif [[ -n \"\\${parent}\" ]]; then\n printf '{\"id\": ${f.issueId}, \"number\": %s, \"repository_url\": \"https://api.github.com/repos/o/r\", \"parent_issue_url\": \"https://api.github.com/repos/%s/issues/%s\"}\\\\n' \"\\${num}\" \"\\${prepo}\" \"\\${parent}\"\nelse\n printf '{\"id\": ${f.issueId}, \"number\": %s, \"repository_url\": \"https://api.github.com/repos/o/r\", \"parent_issue_url\": null}\\\\n' \"\\${num}\"\nfi\n`\n\n writeFileSync(ghPath, script)\n chmodSync(ghPath, 0o755)\n\n return {\n dir,\n ghPath,\n logPath,\n env: { PATH: `${dir}:${process.env.PATH ?? ''}` },\n }\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":15181,"content_sha256":"c78e01557872b71c1d679695940be30acc2cfe49261a9775f46995dac1f03d58"},{"filename":"tests/lib/tmp-track.mjs","content":"// テストが mkdtempSync で作った一時ディレクトリを、プロセス終了時に必ず消すための登録簿。\n// (skills/implement-issue-tree/tests/lib/tmp-track.mjs と同じ内容。スキルは個別に配布されるため、スキルをまたぐ import はせず、\n// このスキルのテストが自分のコピーを使う。変更するときは両方を揃える)\n//\n// 各テストファイルは node:test により独立したプロセスで走る。モジュール先頭(await import の前)や\n// ヘルパー内で作るディレクトリは、テストの失敗・トップレベルの例外・after フックが走らない経路でも\n// 消えなければならないため、'exit' イベントで後始末する(同期処理だけが許される)。\n//\n// 消すのは trackTmp に渡されたパスだけである。パスを組み立てて消さない。\nimport { rmSync } from 'node:fs'\n\nconst tracked = new Set()\nlet registered = false\n\nfunction cleanup() {\n for (const dir of tracked) {\n rmSync(dir, { recursive: true, force: true })\n }\n tracked.clear()\n}\n\n// mkdtempSync の戻り値をそのまま包んで使う: trackTmp(mkdtempSync(join(tmpdir(), 'iit-x-')))\n// 戻り値は渡した dir と同一。\nexport function trackTmp(dir) {\n if (typeof dir !== 'string' || dir === '') {\n throw new TypeError(`trackTmp: 一時ディレクトリのパスが文字列でない(${String(dir)})`)\n }\n tracked.add(dir)\n if (!registered) {\n registered = true\n process.on('exit', cleanup)\n }\n return dir\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":1580,"content_sha256":"fb1b14c1a1c027032e5e5535146dfdd5cc7b5c4954f7cb019538f9645fc19cb0"},{"filename":"tests/reassign-sub-issue.test.mjs","content":"// reassign-sub-issue.test.mjs — Issue #297 対応の決定的回帰テスト。\n// 先行 PR #295(blocked)で codex に指摘された 4 弱点への直接的な回答:\n// 1. 変数の初期化と参照が別コードフェンスに分かれる → 単一プロセスのスクリプトで検証可能に\n// 2. DELETE 失敗検知なしに POST へ進む → ケース 6 で「POST が 1 件も呼ばれない」ことを実測\n// 3. 冪等性判定が実行より 1 巡遅れる → ケース 3 で DELETE/POST 双方が呼ばれないことを実測\n// 4. 事後確認のスナップショット汚染 → 事前 GET と事後 GET を呼び出しログで区別して検証\n//\n// 実 API には触れず、tests/lib/gh-stub.mjs が生成する差し替え gh をスクリプトへ渡す。\n// スクリプトは exec ビット + shebang 経由でパス直接実行する(bash 経由のラップはしない)。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { readFileSync, existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { createGhStub } from './lib/gh-stub.mjs'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'reassign-sub-issue.sh',\n)\n\nfunction run(args, fixture) {\n const stub = createGhStub(fixture)\n let status = 0\n let stdout = ''\n let stderr = ''\n try {\n stdout = execFileSync(SCRIPT_PATH, args, {\n // RECOVERY_RECHECK_DELAY_SEC=0: 補償復旧の反映遅延再確認(Issue #352 追加分)が\n // 挟む sleep をテストではゼロにし、決定性・速度を保つ(本番既定値は変えない)\n env: { ...process.env, ...stub.env, RECOVERY_RECHECK_DELAY_SEC: '0' },\n encoding: 'utf8',\n })\n } catch (err) {\n status = err.status ?? 1\n stdout = err.stdout ?? ''\n stderr = err.stderr ?? ''\n }\n return { status, stdout, stderr, logPath: stub.logPath }\n}\n\nfunction calls(logPath) {\n if (!existsSync(logPath)) return []\n return readFileSync(logPath, 'utf8').trim().split('\\n').filter(Boolean)\n}\n\ntest('ケース1: 引数なし → exit 1、API 呼び出しゼロ', () => {\n const r = run([], {})\n assert.equal(r.status, 1)\n assert.deepEqual(calls(r.logPath), [])\n})\n\ntest('ケース2: issue 番号に非数値(インジェクション試行)→ exit 1、API 呼び出しゼロ', () => {\n const r = run(['--issue', '12; rm -rf /', '--new-parent', '1'], {})\n assert.equal(r.status, 1)\n assert.deepEqual(calls(r.logPath), [])\n})\n\ntest('ケース2b: --repo に不正な形式 → exit 1、API 呼び出しゼロ', () => {\n const r = run(['--issue', '1', '--new-parent', '2', '--repo', 'not a repo'], {})\n assert.equal(r.status, 1)\n assert.deepEqual(calls(r.logPath), [])\n})\n\ntest('ケース3: 既に新親配下(already-attached)→ exit 0、DELETE も POST も呼ばれない', () => {\n const r = run(['--issue', '10', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '7',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=already-attached issue=10 new_parent=7 old_parent=7$/)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が呼ばれていないこと')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST が呼ばれていないこと')\n})\n\ntest('ケース4: 孤児(--old-parent 省略)→ exit 0 posted-only、POST のみ・DELETE なし', () => {\n const r = run(['--issue', '11', '--new-parent', '7'], {\n parentBefore: '',\n parentAfter: '7',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=posted-only issue=11 new_parent=7 old_parent=-$/)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が呼ばれていないこと')\n assert.ok(c.some((l) => l.includes('--method POST')), 'POST が呼ばれていること')\n})\n\ntest('ケース5: 正常な付け替え → exit 0 reassigned、呼び出し順が GET→GET(新親)→DELETE→POST→GET', () => {\n const r = run(['--issue', '12', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '7',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=reassigned issue=12 new_parent=7 old_parent=5$/)\n const c = calls(r.logPath).filter((l) => l.startsWith('api'))\n const kinds = c.map((l) => {\n if (l.includes('--method DELETE')) return 'DELETE'\n if (l.includes('--method POST')) return 'POST'\n return 'GET'\n })\n // 2 件目の GET が Issue #333 で追加した新親の事前検証(DELETE の前に撃つ)\n assert.deepEqual(kinds, ['GET', 'GET', 'DELETE', 'POST', 'GET'])\n})\n\ntest('ケース6: DELETE が非ゼロ(404) → exit 3、POST は 1 件も呼ばれない(受入条件の核)', () => {\n const r = run(['--issue', '13', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n deleteExit: 1,\n deleteBody: '404 Not Found',\n })\n assert.equal(r.status, 3)\n const c = calls(r.logPath)\n assert.ok(c.some((l) => l.includes('--method DELETE')), 'DELETE は呼ばれていること')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST は 1 件も呼ばれていないこと')\n})\n\ntest('ケース7: DELETE 後の POST が非ゼロ(一般エラー)→ 補償復旧ルーチンへ入り、実測で既に旧親配下(補償 POST 不要)→ exit 10 restored(Issue #352。旧 exit 4 の契約から変更)', () => {\n // fixture で parentAfter を省略すると parentBefore(旧親)を継続するスタブの既定挙動により、\n // 復旧のための再取得 GET は「実測で既に旧親配下」を返す。この場合は補償 POST を撃たずに\n // 復旧成立として終端する(3.1 節の「親 == OLD_PARENT」分岐)\n const r = run(['--issue', '14', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 10)\n assert.match(r.stdout.trim(), /^result=restored issue=14 new_parent=7 old_parent=5$/)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償 POST は撃たれていないこと(1 回目の POST のみ)')\n})\n\ntest('ケース8: 事後確認 GET で対象が新親配下に見えない → exit 5', () => {\n const r = run(['--issue', '15', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '5', // POST は成功したことにするがスタブ上は親が変わっていない体\n })\n assert.equal(r.status, 5)\n})\n\ntest('ケース9: 事前 GET 自体が失敗(前提不備)→ exit 2', () => {\n const r = run(['--issue', '16', '--new-parent', '7'], {\n getFail: true,\n })\n assert.equal(r.status, 2)\n})\n\ntest('ケース10: DELETE 後の POST が \"only have one parent\" → 補償復旧ルーチンで実測すると第三者が別の親を設定済み → exit 11 で fail-closed 停止(補償 POST を撃たない。Issue #352。旧 exit 8 の契約から変更)', () => {\n // DELETE は成功しているため旧親からは外れている。エラーメッセージの文字列だけでは\n // 「第三者に取られた」のか「一時的な障害」なのか判別できないため、実測(再取得 GET)で\n // 確定させる。実測で第三者が別の親(#9)を設定済みなら、承認外の親子関係を壊さないため\n // 補償 POST を撃たずに書き込みなしで停止する(PR #314 Bugbot Medium の懸念を「無変更と\n // 誤認させない」から一歩進め、実測ベースで安全側に倒す)\n const r = run(['--issue', '17', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '9',\n postExit: 1,\n postBody: 'Validation Failed: Sub issue may only have one parent',\n })\n assert.equal(r.status, 11)\n assert.match(r.stderr, /reason=third-party-parent/)\n assert.match(r.stderr, /issues\\/9/)\n const c = calls(r.logPath)\n assert.ok(c.some((l) => l.includes('--method DELETE')), 'DELETE は実行済みであること')\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償 POST が呼ばれていないこと(1 回目の POST のみ)')\n})\n\ntest('ケース49: DELETE が非ゼロ(応答取得のみ失敗)→ 実状態再取得で孤児と安定確認 → POST 工程へ進み成功 → exit 0 reassigned(articles#119 codex P1 指摘・Issue #489。DELETE 応答の失敗だけで無変更と断定しない)', () => {\n const r = run(['--issue', '60', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n deleteExit: 1,\n deleteBody: '504 Gateway Timeout',\n parentAfter: '', // DELETE 失敗後の実状態再取得は孤児を返す(DELETE はサーバー側で成立済み)\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 孤児観測の安定確認は\n // 2 回連続で親なしが観測できて成立し、POST 工程へ進む\n parentAfter3: '7', // POST 成功後の事後確認(VERIFY_JSON)は新親配下を返す\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=reassigned issue=60 new_parent=7 old_parent=5$/)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method DELETE')).length, 1, 'DELETE は 1 回のみ呼ばれていること(DELETE を再試行しない)')\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, 'POST は 1 回のみ呼ばれていること(補償 POST ではなく通常の新親 POST)')\n})\n\ntest('ケース50: DELETE が非ゼロ → 実状態再取得でも旧親配下のまま安定確認 → exit 3(無変更を実測で確認済み。articles#119 codex P1 指摘・Issue #489)', () => {\n const r = run(['--issue', '61', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n deleteExit: 1,\n deleteBody: '500 Internal Server Error',\n parentAfter: '5', // DELETE 失敗後の実状態再取得は旧親配下のままを返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = '5'): 安定確認も旧親配下で一致\n })\n assert.equal(r.status, 3)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST は 1 件も呼ばれていないこと(実測でも無変更のため)')\n})\n\ntest('ケース51: DELETE が非ゼロ → 実状態の再取得自体が失敗 → exit 8 reason=recovery-state-unknown(無変更と断定しない。articles#119 codex P1 指摘・Issue #489)', () => {\n const r = run(['--issue', '62', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n deleteExit: 1,\n deleteBody: '500 Internal Server Error',\n verifyGetFail: true, // DELETE 失敗後の実状態再取得(対象 issue の 2 回目 GET)を失敗させる\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST は 1 件も呼ばれていないこと(状態不明のまま書き込みを行わない)')\n})\n\ntest('ケース52: DELETE が非ゼロ → 実状態再取得で第三者が別の親を設定済み → exit 11 reason=third-party-parent(承認外の親子関係を壊さず補償せず停止する。articles#119 codex P1 指摘・Issue #489)', () => {\n const r = run(['--issue', '63', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n deleteExit: 1,\n deleteBody: '500 Internal Server Error',\n parentAfter: '9', // DELETE 失敗後の実状態再取得で第三者が設定した親 #9 が判明する\n })\n assert.equal(r.status, 11)\n assert.match(r.stderr, /reason=third-party-parent/)\n assert.match(r.stderr, /issues\\/9/)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST は 1 件も呼ばれていないこと(第三者配下を上書きしない)')\n})\n\ntest('ケース55: DELETE が非ゼロ → 最初の実状態再取得で新親配下と判明 → 安定確認も一致 → exit 0 reassigned(第三者親判定より先に新親一致を判定する。codex-review P2 指摘 PR #491・Issue #489。観測タイミングだけで結果が変わる非対称の回帰)', () => {\n // 旧親・孤児から始まり安定確認の途中で新親を観測した場合は ddo_rc==2 / ddn_rc==2 経路で\n // 既に成功終端になる(ケース49・50 等)。このケースは「最初の読み取りが直接新親を観測する」\n // 経路を検証する。修正前はこの経路だけが CURRENT_PARENT 一致にも孤児にも該当せず、\n // 第三者親(exit 11)へ倒れていた\n const r = run(['--issue', '66', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n deleteExit: 1,\n deleteBody: '500 Internal Server Error',\n parentAfter: '7', // DELETE 失敗後の最初の実状態再取得で新親配下と判明する\n // parentAfter2 は未指定(既定で parentAfter を継続 = '7'): 新親偽陰性の安定確認も一致\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=reassigned issue=66 new_parent=7 old_parent=5$/)\n assert.doesNotMatch(r.stderr, /reason=third-party-parent/)\n assert.doesNotMatch(r.stderr, /reason=recovery-state-unknown/)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST は 1 件も呼ばれていないこと(新親への POST は既に成立済みのため再試行しない)')\n})\n\ntest('ケース28: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST 成功 → exit 10 restored(Issue #352 の補償復旧成功。受入基準の核)', () => {\n const r = run(['--issue', '30', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST を撃つ前の\n // 孤児観測の安定確認 GET(Issue #352 追加分)は 2 回連続で親なしが観測できる\n parentAfter3: '5', // 補償 POST 後の事後確認 GET は旧親配下を返す\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 10)\n assert.match(r.stdout.trim(), /^result=restored issue=30 new_parent=7 old_parent=5$/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 2, '1 回目(新親)と 2 回目(補償・旧親)の POST が呼ばれていること')\n assert.match(posts[1], /issues\\/5\\/sub_issues/, '2 回目の POST の宛先パスが旧親 #5 であること')\n})\n\ntest('ケース28b: DELETE 後の POST 失敗 → 実測では孤児に見えるが安定確認で旧親配下に戻っていた(過渡状態)→ 補償 POST を撃たずに exit 8 reason=recovery-state-unknown(Cursor Bugbot Medium 指摘「Orphan restore skips stability check」の回帰。単発の孤児観測だけを信頼して復旧 POST という書き込みを行わないこと)', () => {\n const r = run(['--issue', '54', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児に見える(DELETE 反映直後の過渡状態)\n parentAfter2: '5', // 孤児観測の安定確認 GET では実は旧親 #5 配下のまま(不安定)\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 1, '孤児観測が不安定と判明した場合、補償 POST(書き込み)を撃たないこと(1 回目の新親 POST のみ)')\n assert.doesNotMatch(r.stdout, /result=restored/, '安定確認前に result=restored を出力しないこと')\n})\n\ntest('ケース28c: DELETE 後の POST 失敗 → 実測では孤児に見えるが安定確認では実は本来の新親 #7 に付いていた(元の POST の偽陰性)→ 補償 POST を撃たずに exit 0 reassigned(Cursor Bugbot Medium 指摘「Orphan restore skips stability check」の回帰。孤児観測の安定確認にも新親偽陰性検知を適用する)', () => {\n const r = run(['--issue', '55', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児に見える\n parentAfter2: '7', // 孤児観測の安定確認 GET では実は本来の新親 #7 配下だった\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=reassigned issue=55 new_parent=7 old_parent=5$/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 1, '本来の新親へ既に付いていたと判明した場合、補償 POST(書き込み)を撃たないこと(1 回目の新親 POST のみ)')\n})\n\ntest('ケース29: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST も失敗(多重障害)→ exit 8 reason=compensation-post-failed(Issue #352。孤児のまま終端)', () => {\n const r = run(['--issue', '31', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '',\n postExit: 1,\n postBody: '500 Internal Server Error',\n compPostExit: 1,\n compPostBody: '503 Service Unavailable',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=compensation-post-failed/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 2, '補償 POST も試みられていること')\n})\n\ntest('ケース29b: DELETE 後の POST 失敗 → 実測で孤児(安定確認も成立)→ 補償 POST も失敗 → 失敗後の実状態再取得 GET も失敗 → exit 8 reason=recovery-state-unknown(孤児か否か未測定のまま reason=compensation-post-failed を出さない。cursor[bot] Medium 指摘 PR #391)', () => {\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET(3 回目)は 2 回連続で親なしが観測できて成立し、補償 POST まで進む。\n // 失敗させたいのは補償 POST 失敗後の実状態再取得(4 回目)のため fourthGetFail を使う\n const r = run(['--issue', '31', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '',\n postExit: 1,\n postBody: '500 Internal Server Error',\n compPostExit: 1,\n compPostBody: '503 Service Unavailable',\n fourthGetFail: true,\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n assert.doesNotMatch(r.stderr, /reason=compensation-post-failed(?!-third-party-parent)/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 2, '安定確認が成立し補償 POST は試みられていること(1 回目の新親 POST + 補償 POST)')\n})\n\ntest('ケース30: DELETE 後の POST 失敗 → 復旧のための実状態再取得 GET も失敗(状態不明)→ exit 8 reason=recovery-state-unknown、補償 POST は撃たれない(Issue #352)', () => {\n const r = run(['--issue', '32', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n postExit: 1,\n postBody: '500 Internal Server Error',\n verifyGetFail: true, // 対象 issue の 2 回目 GET(=復旧のための再取得)を失敗させる\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償 POST は撃たれていないこと(1 回目の POST のみ)')\n})\n\ntest('ケース31: DELETE 後の POST 失敗(偽陰性)→ 実測すると既に新親配下 → exit 0 reassigned(Issue #352。通常の成功終端と同じ扱い)', () => {\n const r = run(['--issue', '33', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '7', // 復旧のための再取得 GET は既に新親配下を返す(POST は実は成立していた)\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=reassigned issue=33 new_parent=7 old_parent=5$/)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償 POST は撃たれていないこと(1 回目の POST のみ)')\n})\n\ntest('ケース31b: DELETE 後の POST 失敗 → 復旧のための実状態再取得 GET は新親配下を返すが、反映遅延の再確認では不安定(別状態)と判明 → 1 回の読み取りだけで reassigned 確定しない → exit 8 reason=recovery-state-unknown(codex-review P1 指摘 PR #391。ケース31 の成功判定経路にも安定確認を適用する回帰)', () => {\n const r = run(['--issue', '33', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '7', // 復旧のための再取得 GET は新親配下に見える(過渡状態の可能性)\n parentAfter2: '', // 反映遅延を考慮した再確認では実は孤児だった(不安定)\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償 POST は撃たれていないこと(1 回目の POST のみ)')\n})\n\ntest('ケース32: 孤児経路(DELETE なし)の POST が非ゼロ(一般エラー)→ 実状態再取得でも孤児のまま安定確認 → exit 4(Issue #352 → #489 で recover_after_orphan_post_failure により対称化。無変更のため書き込み(補償 POST)は発生しない)', () => {\n // Issue #489 P1: 孤児経路の POST 失敗も応答(gh の非ゼロ終了)だけを信頼せず実状態を\n // 再取得するようになったため、DELETE 経路の recover_after_post_failure と同様に\n // 追加の GET(実状態再取得 + 孤児観測の安定確認)が発生する。ただしこの経路には\n // DELETE が無く孤児化リスク自体が無いため、判定のみで完結し POST(補償の書き込み)は\n // 一切発生しない(DELETE も 1 件も呼ばれない)\n const r = run(['--issue', '29', '--new-parent', '7'], {\n parentBefore: '',\n postExit: 1,\n postBody: '500 Internal Server Error',\n // parentAfter / parentAfter2 は未指定(既定で parentBefore を継続 = ''):\n // 実状態再取得・孤児観測の安定確認のいずれも孤児を返し、2 回連続一致で確定する\n })\n assert.equal(r.status, 4)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.startsWith('api') && !l.includes('--method')).length, 4, '対象 issue の事前 GET + 新親の事前検証 GET + 実状態再取得 GET + 孤児観測の安定確認 GET の計 4 件であること')\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償の書き込みは発生していないこと(1 回目の POST のみ)')\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が 1 件も呼ばれていないこと')\n})\n\ntest('ケース53: 孤児経路の POST が非ゼロ → 実状態再取得で新親配下と判明 → 安定確認も一致 → exit 0 posted-only(POST は偽陰性だった。articles#119 codex P1 指摘・Issue #489)', () => {\n const r = run(['--issue', '64', '--new-parent', '7'], {\n parentBefore: '',\n postExit: 1,\n postBody: '500 Internal Server Error',\n parentAfter: '7', // 実状態再取得は新親配下を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = '7'): 新親偽陰性の安定確認も一致\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=posted-only issue=64 new_parent=7 old_parent=-$/)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償の書き込みは発生していないこと(1 回目の POST のみ)')\n})\n\ntest('ケース54: 孤児経路の POST が非ゼロ → 実状態の再取得自体が失敗 → exit 8 reason=recovery-state-unknown(無変更と断定しない。articles#119 codex P1 指摘・Issue #489)', () => {\n const r = run(['--issue', '65', '--new-parent', '7'], {\n parentBefore: '',\n postExit: 1,\n postBody: '500 Internal Server Error',\n verifyGetFail: true, // 孤児経路 POST 失敗後の実状態再取得(対象 issue の 2 回目 GET)を失敗させる\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償の書き込みは発生していないこと(1 回目の POST のみ)')\n})\n\ntest('ケース33: DELETE 後の POST 失敗 → 復旧の再取得では旧親配下に見えるが、反映遅延の再確認で実は孤児だった → 復旧成立とみなさず exit 8 reason=recovery-state-unknown(Issue #352 codex-review 追加指摘 PR #391。1 回の読み取りだけで restored 確定しないことの回帰)', () => {\n const r = run(['--issue', '40', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n // 2 回目 GET(復旧のための再取得)は旧親配下、3 回目 GET(反映遅延の再確認)では\n // 実は孤児だったことが判明する、という過渡状態を再現する\n parentAfter: '5',\n parentAfter2: '',\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償 POST は撃たれていないこと(1 回目の POST のみ。復旧成立と誤認して即終端していないことの裏返し)')\n})\n\ntest('ケース34: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST も失敗 → 失敗後の再取得で実は第三者が別の親を設定済み → exit 8 reason=compensation-post-failed-third-party-parent(cursor[bot] Medium 指摘 PR #391)', () => {\n const r = run(['--issue', '41', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET は 2 回連続で親なしが観測できる\n parentAfter3: '9', // 補償 POST 失敗後の再取得 GET は第三者が設定した親 #9 を返す\n postExit: 1,\n postBody: '500 Internal Server Error',\n compPostExit: 1,\n compPostBody: '503 Service Unavailable',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=compensation-post-failed-third-party-parent/)\n assert.match(r.stderr, /#9/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 2, '補償 POST は試みられていること(1 回目の新親 POST + 補償 POST)')\n})\n\ntest('ケース35: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST も失敗 → 失敗後の再取得で実は「別リポジトリ」の第三者親が設定済み → exit 8 reason=compensation-post-failed-third-party-parent(codex-review P1 指摘 PR #391。同一リポジトリ判定を親の存在判定から分離する回帰)', () => {\n const r = run(['--issue', '42', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET は 2 回連続で親なしが観測できる\n parentAfter3: '9', // 補償 POST 失敗後の再取得 GET は第三者が設定した親を返す\n parentRepoAfter3: 'other/repo', // 別リポジトリの親(cross-repository third-party)\n postExit: 1,\n postBody: '500 Internal Server Error',\n compPostExit: 1,\n compPostBody: '503 Service Unavailable',\n })\n assert.equal(r.status, 8, '別リポジトリの親でも「実測でも孤児」ではなく third-party-parent として exit 8 になること')\n assert.match(r.stderr, /reason=compensation-post-failed-third-party-parent/)\n assert.match(r.stderr, /別リポジトリ/)\n assert.match(r.stderr, /other\\/repo/)\n})\n\ntest('ケース36: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST がエラー応答(実は成立していた偽陰性)→ 失敗後の再取得で旧親配下に復旧済みと判明 → exit 10 restored(cursor[bot] Medium 指摘 PR #391。third-party-parent への誤ラベルの回帰)', () => {\n const r = run(['--issue', '43', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET は 2 回連続で親なしが観測できる\n parentAfter3: '5', // 補償 POST 失敗後の再取得 GET は実は旧親 #5 配下(偽陰性)\n postExit: 1,\n postBody: '500 Internal Server Error',\n compPostExit: 1,\n compPostBody: '503 Service Unavailable',\n })\n assert.equal(r.status, 10, '旧親配下への復旧が実測で確認できた場合は third-party-parent ではなく restored 扱いになること')\n assert.match(r.stdout.trim(), /^result=restored issue=43 new_parent=7 old_parent=5$/)\n assert.doesNotMatch(r.stderr, /reason=compensation-post-failed-third-party-parent/)\n})\n\ntest('ケース36b: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST がエラー応答 → 失敗後の再取得では旧親配下に見えるが、反映遅延を考慮した再確認では実は孤児だった → 1 回の読み取りだけで restored 確定しない(codex-review P1 指摘 PR #391。偽陰性経路にも再確認を必須化する回帰)', () => {\n const r = run(['--issue', '46', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET は 2 回連続で親なしが観測できる\n parentAfter3: '5', // 補償 POST 失敗後の再取得 GET は旧親 #5 配下に見える(過渡状態の可能性)\n parentAfter4: '', // 反映遅延を考慮した再確認では実は孤児のまま(過渡状態だった)\n postExit: 1,\n postBody: '500 Internal Server Error',\n compPostExit: 1,\n compPostBody: '503 Service Unavailable',\n })\n assert.notEqual(r.status, 10, '1 回の読み取りだけで restored を確定しないこと')\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n assert.doesNotMatch(r.stdout, /result=restored/, '再確認で不安定と判明した場合は result=restored を出力しないこと')\n})\n\ntest('ケース39: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST も失敗 → 失敗後の再取得で実は本来の新親 #7 に既に付いていた(元の POST の偽陰性)→ 反映遅延の再確認でも安定 → exit 0 reassigned(cursor[bot] Medium 指摘 PR #391「New parent misread as failure」の回帰。third-party-parent へ誤ラベルしない)', () => {\n const r = run(['--issue', '50', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET は 2 回連続で親なしが観測できる\n parentAfter3: '7', // 補償 POST 失敗後の再取得 GET は本来の新親 #7 配下(元の POST の偽陰性)\n parentAfter4: '7', // 反映遅延を考慮した再確認でも同じく新親 #7 配下(安定)\n postExit: 1,\n postBody: '500 Internal Server Error',\n compPostExit: 1,\n compPostBody: '503 Service Unavailable',\n })\n assert.equal(r.status, 0, '実測が本来の新親であれば third-party-parent(exit 8)ではなく通常の成功終端になること')\n assert.match(r.stdout.trim(), /^result=reassigned issue=50 new_parent=7 old_parent=5$/)\n assert.doesNotMatch(r.stderr, /reason=compensation-post-failed-third-party-parent/)\n})\n\ntest('ケース40: DELETE 後の POST 失敗 → 実測で旧親配下に見える → 反映遅延の再確認では実は本来の新親 #7 に付いていた(元の POST の偽陰性が再確認で初めて見える)→ exit 0 reassigned(cursor[bot] Medium 指摘 PR #391「New parent misread as failure」の回帰)', () => {\n const r = run(['--issue', '51', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '5', // 復旧のための再取得 GET は旧親 #5 配下に見える → 反映遅延の再確認へ進む\n parentAfter2: '7', // 反映遅延を考慮した再確認では実は本来の新親 #7 配下だった\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 0, '再確認で本来の新親が実測されれば third-party-parent 誤報や recovery-state-unknown にしないこと')\n assert.match(r.stdout.trim(), /^result=reassigned issue=51 new_parent=7 old_parent=5$/)\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償 POST は撃たれていないこと(旧親配下に見えた分岐は補償 POST 前に確定する)')\n})\n\ntest('ケース41: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST 成功 → 事後確認では旧親ではなく本来の新親 #7 に付いていた → exit 0 reassigned(cursor[bot] Medium 指摘 PR #391「New parent misread as failure」の回帰。rv_parent の third-party 誤ラベルを避ける)', () => {\n // Issue #489 P2: parentAfter2 に新親を設定すると、補償 POST 前の孤児観測の安定確認\n // (3 回目 GET)が新親を観測して orc_rc=2 の偽陰性経路で確定してしまい、補償 POST\n // (2 回目の POST)に到達しないまま exit 0 になる。意図した「補償 POST 成功後の\n // 事後確認(RECOVERY_VERIFY_JSON = 4 回目 GET)で新親判定」を検証するには、\n // parentAfter2 は孤児(既定で parentAfter を継続)のまま維持し、4 回目 GET に\n // 対応する parentAfter3 へ新親を設定する\n const r = run(['--issue', '52', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET は 2 回連続で親なしが観測できて成立し、補償 POST(2 回目の POST)へ進む\n parentAfter3: '7', // 補償 POST 成功後の確認(4 回目 GET)では実は本来の新親 #7 配下だった\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=reassigned issue=52 new_parent=7 old_parent=5$/)\n assert.doesNotMatch(r.stderr, /reason=recovery-state-unknown/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 2, '1 回目(新親)と 2 回目(補償・旧親)の POST が呼ばれていること(補償 POST に到達していることの実測)')\n})\n\ntest('ケース41b: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST 成功 → 事後確認では本来の新親 #7 に付いているように見えるが、反映遅延の再確認では不安定(別状態)と判明 → 1 回の読み取りだけで reassigned 確定しない → exit 8 reason=recovery-state-unknown(codex-review P1 指摘 PR #391。ケース41 の成功判定経路にも安定確認を適用する回帰)', () => {\n const r = run(['--issue', '52', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET は 2 回連続で親なしが観測できる\n parentAfter3: '7', // 補償 POST 成功後の確認では本来の新親 #7 配下に見える(過渡状態の可能性)\n parentAfter4: '', // 反映遅延を考慮した再確認では実は孤児だった(不安定)\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n})\n\ntest('ケース42: DELETE 後の POST 失敗 → 実測で孤児 → 補償 POST も失敗 → 失敗後の再取得では旧親 #5 配下に見えるが、反映遅延の再確認では実は本来の新親 #7 に付いていた → exit 0 reassigned(cursor[bot] Medium 指摘 PR #391「New parent misread as failure」の回帰。偽陰性確認の再確認自体で新親が判明する経路)', () => {\n const r = run(['--issue', '53', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得 GET は孤児を返す\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET は 2 回連続で親なしが観測できる\n parentAfter3: '5', // 補償 POST 失敗後の再取得 GET は旧親 #5 配下に見える(過渡状態の可能性)\n parentAfter4: '7', // 反映遅延を考慮した再確認では実は本来の新親 #7 配下だった\n postExit: 1,\n postBody: '500 Internal Server Error',\n compPostExit: 1,\n compPostBody: '503 Service Unavailable',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=reassigned issue=53 new_parent=7 old_parent=5$/)\n assert.doesNotMatch(r.stderr, /reason=compensation-post-failed-third-party-parent/)\n})\n\ntest('ケース37: DELETE 後の POST 失敗 → 補償 POST 成功後の確認 GET で「別リポジトリ」の第三者親が実測される → exit 8 reason=recovery-state-unknown、「実測 parent=なし」と誤報しない(cursor[bot] Medium 指摘 PR #391。compensation-verify の same-repo 判定を親の存在判定から分離する回帰)', () => {\n const r = run(['--issue', '44', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得は孤児 → 補償 POST を撃つ\n // parentAfter2 は未指定(既定で parentAfter を継続 = ''): 補償 POST 前の孤児観測の\n // 安定確認 GET は 2 回連続で親なしが観測できる\n parentAfter3: '9',\n parentRepoAfter3: 'other/repo', // 補償 POST 成功後の確認では実は別リポジトリの第三者親配下\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n assert.match(r.stderr, /別リポジトリの.*other\\/repo.*issues\\/9/, '別リポジトリの第三者親であることが stderr から読み取れること')\n assert.doesNotMatch(r.stderr, /parent=なし/, 'cross-repo の実測親を「parent=なし」(孤児)と誤報しないこと')\n})\n\ntest('ケース38: DELETE 後の POST 失敗 → 復旧の再取得では旧親配下に見えるが、反映遅延の再確認では「別リポジトリ」の第三者親が実測される → exit 8 reason=recovery-state-unknown、「実測 parent=なし」と誤報しない(cursor[bot] Medium 指摘 PR #391。delayed-recheck の same-repo 判定を親の存在判定から分離する回帰)', () => {\n const r = run(['--issue', '45', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '5', // 復旧のための再取得は旧親配下に見える → 反映遅延の再確認へ進む\n parentAfter2: '9',\n parentRepoAfter2: 'other/repo', // 再確認では実は別リポジトリの第三者親配下\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n assert.match(r.stderr, /別リポジトリの.*other\\/repo.*issues\\/9/, '別リポジトリの第三者親であることが stderr から読み取れること')\n assert.doesNotMatch(r.stderr, /parent=なし/, 'cross-repo の実測親を「parent=なし」(孤児)と誤報しないこと')\n const c = calls(r.logPath)\n assert.equal(c.filter((l) => l.includes('--method POST')).length, 1, '補償 POST は撃たれていないこと(recheck 分岐は復旧成立済みとみなした場合のみ通る経路のため)')\n})\n\ntest('ケース15: 孤児経路の POST が \"only have one parent\" → exit 7(DELETE 未実行で無変更)', () => {\n // 事前実測では孤児だったが POST 時点で別の親が付いていたレース。DELETE を 1 度も\n // 撃っていないためツリーは無変更であり、exit 8(部分変更)とは復旧手順が異なる\n const r = run(['--issue', '21', '--new-parent', '7'], {\n parentBefore: '',\n postExit: 1,\n postBody: 'Validation Failed: Sub issue may only have one parent',\n })\n assert.equal(r.status, 7)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が 1 件も呼ばれていないこと')\n})\n\ntest('ケース16: 親が別リポジトリ → exit 2、DELETE も POST も呼ばれない', () => {\n // sub-issue はリポジトリを跨いで紐付けられる。番号だけを見ると本リポ宛の DELETE を\n // 撃って失敗する(PR #314 codex P1)\n const r = run(['--issue', '22', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentRepo: 'other/repo',\n })\n assert.equal(r.status, 2)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が呼ばれていないこと')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST が呼ばれていないこと')\n})\n\ntest('ケース16c: 親が別リポジトリ → stderr に reason=cross-repository-parent マーカーが出る(Issue #335 codex-review 指摘の再発防止固定)', () => {\n // exit 2 は gh/jq 不在・未認証・issue 取得失敗(解消可能な前提不備)とも共有するため、\n // このケース(恒久的に対象外)だけを終了コード単独では区別できない。呼び出し側が\n // SKILL.md の記述どおり (a)/(b) を機械的に判定できるよう、安定したマーカー行を固定する\n const r = run(['--issue', '22', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentRepo: 'other/repo',\n })\n assert.equal(r.status, 2)\n assert.ok(\n /^reason=cross-repository-parent$/m.test(r.stderr),\n 'stderr に reason=cross-repository-parent マーカー行があること',\n )\n})\n\ntest('ケース9/ケース11: 解消可能な前提不備(exit 2 (a))では reason=cross-repository-parent マーカーが出ない', () => {\n // (b) 専用マーカーが (a) 側へ誤って漏れると、呼び出し側が恒久的な対象外と誤判定し\n // 棚卸し対象から不要に除外してしまう\n const r = run(['--issue', '22', '--new-parent', '7'], { getFail: true })\n assert.equal(r.status, 2)\n assert.ok(\n !/reason=cross-repository-parent/.test(r.stderr),\n '解消可能な前提不備で cross-repository 専用マーカーが出ていないこと',\n )\n})\n\ntest('ケース16b: 親が別リポジトリで停止する際、stderr が --repo の付け替えを勧めていない(Issue #332 / PR #314 P0 の再発防止固定)', () => {\n // c4c27b9 で「--repo を親リポジトリへ変えて再実行する」という危険な案内を削除した。\n // 将来の善意の編集でこの案内が復活しないよう、stderr に --repo が出現しないことを固定する\n const r = run(['--issue', '22', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentRepo: 'other/repo',\n })\n assert.equal(r.status, 2)\n assert.ok(!/--repo/.test(r.stderr), 'stderr が --repo の付け替えを勧めていないこと')\n})\n\ntest('ケース17: 別リポの親の番号が --new-parent と一致しても already-attached と誤判定しない', () => {\n // 別リポの #7 配下にあるだけで、本リポの #7 には付いていない\n const r = run(['--issue', '23', '--new-parent', '7'], {\n parentBefore: '7',\n parentRepo: 'other/repo',\n })\n assert.equal(r.status, 2)\n assert.ok(!r.stdout.includes('already-attached'), 'already-attached を返していないこと')\n})\n\ntest('ケース18: 事後確認で親が別リポジトリ → exit 5(成功として報告しない)', () => {\n // POST 後の競合で対象が other/repo の同番号 issue へ移された状況。番号だけを見ると\n // VERIFY_PARENT == NEW_PARENT となり誤ったツリー状態を成功報告する(PR #314 codex P1)\n const r = run(['--issue', '24', '--new-parent', '7'], {\n parentBefore: '',\n parentAfter: '7',\n parentRepoAfter: 'other/repo',\n })\n assert.equal(r.status, 5)\n assert.ok(!r.stdout.includes('result=posted-only'), '成功の result= 行を出していないこと')\n})\n\ntest('ケース11: gh auth status が非ゼロ → exit 2、API 呼び出し無し', () => {\n const r = run(['--issue', '1', '--new-parent', '2'], {\n authFail: true,\n })\n assert.equal(r.status, 2)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.startsWith('api')), 'auth 失敗後は api 系呼び出しが無いこと')\n})\n\ntest('ケース12: --old-parent が実測値と食い違う → exit 6 で fail-closed、DELETE も POST も呼ばれない', () => {\n // 実際の現在の親は #9(parentBefore)。呼び出し側は Step 2 で #5 からの付け替えを承認している。\n // #9 は承認されていない親であり、そこから外すと承認外の親子関係を壊すため停止する(PR #314 codex P1)\n const r = run(['--issue', '18', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '9',\n parentAfter: '7',\n })\n assert.equal(r.status, 6)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が 1 件も呼ばれていないこと')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST が 1 件も呼ばれていないこと')\n // 実測した親を呼び出し側へ提示できること(承認の取り直しに必要)\n assert.match(r.stderr, /#9/)\n})\n\ntest('ケース13: 孤児として承認されたが実測では親が居る → exit 6 で fail-closed', () => {\n // --old-parent 省略 = 「この issue は孤児である」ことを承認した意味。実測で #9 配下に\n // あるなら、その親子関係は承認されていない\n const r = run(['--issue', '19', '--new-parent', '7'], {\n parentBefore: '9',\n parentAfter: '7',\n })\n assert.equal(r.status, 6)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が 1 件も呼ばれていないこと')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST が 1 件も呼ばれていないこと')\n assert.match(r.stderr, /#9/)\n})\n\ntest('ケース14: --old-parent 指定だが実測は孤児 → 破壊が起きないため続行し posted-only', () => {\n // 承認された操作(#5 から外して #7 へ付ける)の部分集合。DELETE 対象が存在しないだけで\n // 承認外の親子関係は壊れないため、警告のうえ POST のみ実行する\n const r = run(['--issue', '20', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '',\n parentAfter: '7',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=posted-only issue=20 new_parent=7 old_parent=-$/)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が呼ばれていないこと')\n assert.ok(c.some((l) => l.includes('--method POST')), 'POST が呼ばれていること')\n})\n\n// --- Issue #333: DELETE 前の --new-parent 事前検証 ---\n\ntest('ケース19: --new-parent が --issue と同一(自己参照)→ exit 1、API 呼び出しゼロ', () => {\n const r = run(['--issue', '30', '--new-parent', '30'], {})\n assert.equal(r.status, 1)\n assert.deepEqual(calls(r.logPath), [])\n})\n\ntest('ケース20: 新親 GET が失敗(存在しない番号)→ exit 2、DELETE も POST も呼ばれない(AC4 の核)', () => {\n const r = run(['--issue', '31', '--old-parent', '5', '--new-parent', '999'], {\n parentBefore: '5',\n newParentGetFail: true,\n })\n assert.equal(r.status, 2)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が 1 件も呼ばれていないこと')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST が 1 件も呼ばれていないこと')\n})\n\ntest('ケース21: 新親が別リポジトリ → exit 2、DELETE も POST も呼ばれない', () => {\n const r = run(['--issue', '32', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n newParentRepo: 'other/repo',\n })\n assert.equal(r.status, 2)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が 1 件も呼ばれていないこと')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST が 1 件も呼ばれていないこと')\n})\n\ntest('ケース22: 孤児経路(--old-parent 省略)で新親 GET が失敗 → exit 2、POST も呼ばれない(経路の非対称が無いこと)', () => {\n const r = run(['--issue', '33', '--new-parent', '999'], {\n parentBefore: '',\n newParentGetFail: true,\n })\n assert.equal(r.status, 2)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が 1 件も呼ばれていないこと')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST が 1 件も呼ばれていないこと')\n})\n\ntest('ケース25: 新親が Pull Request → exit 2、DELETE も POST も呼ばれない', () => {\n // issues API は PR も返す(issue と PR は番号空間を共有する)。存在確認・同一リポジトリ確認\n // だけでは PR を弾けず、DELETE 成功後に POST が失敗して孤児化する。判別は取得済み JSON の\n // `.pull_request` で行うため追加の API 呼び出しは発生しない。\n const r = run(['--issue', '35', '--old-parent', '5', '--new-parent', '77'], {\n parentBefore: '5',\n newParentIsPullRequest: true,\n })\n assert.equal(r.status, 2)\n assert.match(r.stderr, /Pull Request/)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が 1 件も呼ばれていないこと')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST が 1 件も呼ばれていないこと')\n})\n\ntest('ケース26: 孤児経路でも新親が Pull Request → exit 2、POST も呼ばれない(経路の非対称が無いこと)', () => {\n const r = run(['--issue', '36', '--new-parent', '77'], {\n parentBefore: '',\n newParentIsPullRequest: true,\n })\n assert.equal(r.status, 2)\n const c = calls(r.logPath)\n assert.ok(!c.some((l) => l.includes('--method DELETE')), 'DELETE が 1 件も呼ばれていないこと')\n assert.ok(!c.some((l) => l.includes('--method POST')), 'POST が 1 件も呼ばれていないこと')\n})\n\ntest('ケース27: 新親が通常の issue(.pull_request 無し)なら従来どおり付け替えが成立する(過検知していないこと)', () => {\n const r = run(['--issue', '37', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '7',\n })\n assert.equal(r.status, 0)\n const c = calls(r.logPath)\n assert.ok(c.some((l) => l.includes('--method DELETE')), 'DELETE が呼ばれていること')\n assert.ok(c.some((l) => l.includes('--method POST')), 'POST が呼ばれていること')\n})\n\ntest('ケース23: already-attached 経路では新親 GET を追加で撃たない(AC3 の追加コスト回避)', () => {\n const r = run(['--issue', '34', '--old-parent', '5', '--new-parent', '5'], {\n parentBefore: '5',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=already-attached issue=34 new_parent=5 old_parent=5$/)\n const c = calls(r.logPath).filter((l) => l.startsWith('api'))\n assert.equal(c.length, 1, 'api 呼び出しが GET 1 件のみであること')\n})\n\ntest('ケース24: 承認不一致(exit 6)経路でも新親 GET を撃たない', () => {\n const r = run(['--issue', '35', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '9',\n })\n assert.equal(r.status, 6)\n const c = calls(r.logPath).filter((l) => l.startsWith('api'))\n assert.equal(c.length, 1, 'api 呼び出しが GET 1 件のみであること')\n})\n\n// --- Issue #482: 新親初観測時の安定確認 ---------------------------------------\n// confirm_stable_parent は「旧親 / 孤児を期待した再確認で本来の新親 #NEW_PARENT を\n// 観測した」場合に戻り値 2 を返し、呼び出し元 4 箇所がこれを reassigned の成功終端へ\n// 変換する。しかし新親の観測自体は sleep 後の GET 1 回きりであり、期待値側に適用して\n// いる「2 回連続で同じ親を観測する」条件が新親側には適用されていなかった(articles#119\n// の codex P1)。孤児 → 新親 → 孤児 と推移する反映途中の過渡状態を成功として確定させて\n// しまうため、新親を初観測した後は新親を期待値としてもう 1 回取得し、一致した場合にのみ\n// reassigned を確定する。以下 3 ケースはその契約の回帰テスト。\n//\n// いずれも「DELETE 成功 → POST 失敗 → 実状態の再取得で旧親配下 → 反映遅延を考慮した\n// 再確認(confirm_stable_parent expected=旧親)」経路を使う。対象 issue への GET は\n// 1 回目=事前確認 / 2 回目=復旧のための実状態再取得 / 3 回目=反映遅延の再確認 /\n// 4 回目=本 Issue で追加した新親側の安定確認、の順に対応する。\n\ntest('ケース43: 反映遅延の再確認で新親 #7 を初観測 → 新親を期待値とした再取得でも #7 → exit 0 reassigned(Issue #482。新親側も 2 回連続一致を経てから確定する)', () => {\n const r = run(['--issue', '61', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '5', // 復旧のための再取得 GET は旧親 #5 配下に見える → 反映遅延の再確認へ進む\n parentAfter2: '7', // 反映遅延の再確認で本来の新親 #7 を初観測(ここで即確定してはいけない)\n parentAfter3: '7', // 新親を期待値とした再取得でも #7 → ここで初めて reassigned を確定\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 0)\n assert.match(r.stdout.trim(), /^result=reassigned issue=61 new_parent=7 old_parent=5$/)\n // 対象 issue への GET が 4 回撃たれていること(新親側の安定確認が実際に走った証拠)。\n // 新親 #7 の事前検証 GET と DELETE / POST は対象パスに含まれないため除外される\n const targetGets = calls(r.logPath).filter(\n (l) => l.startsWith('api') && l.includes('/issues/61') && !l.includes('--method'),\n )\n assert.equal(targetGets.length, 4, '新親初観測後に新親を期待値とした再取得が 1 回追加されること')\n})\n\ntest('ケース44: 反映遅延の再確認で新親 #7 を初観測 → 新親を期待値とした再取得では孤児(過渡状態)→ exit 8 recovery-state-unknown(Issue #482。成功へ倒さない)', () => {\n const r = run(['--issue', '62', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '5',\n parentAfter2: '7', // 新親を初観測\n parentAfter3: '', // 新親側の安定確認では孤児だった(孤児 → 新親 → 孤児 の過渡状態)\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8, '新親側の安定確認が取れない場合は成功終端しないこと')\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n assert.doesNotMatch(r.stdout, /result=reassigned/, '安定確認できていない状態で reassigned を出力しないこと')\n})\n\ntest('ケース44b: 反映遅延の再確認で新親 #7 を初観測 → 新親を期待値とした再取得では旧親 #5 に戻っていた → exit 8 recovery-state-unknown(Issue #482)', () => {\n const r = run(['--issue', '63', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '5',\n parentAfter2: '7',\n parentAfter3: '5', // 新親側の安定確認では旧親 #5 配下に見えた(新親観測は過渡状態だった)\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n assert.doesNotMatch(r.stdout, /result=reassigned/)\n})\n\ntest('ケース45: 反映遅延の再確認で新親 #7 を初観測 → 新親を期待値とした再取得が失敗 → exit 8 recovery-state-unknown(Issue #482。取得失敗も状態不明として fail-closed)', () => {\n const r = run(['--issue', '64', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '5',\n parentAfter2: '7', // 新親を初観測\n fourthGetFail: true, // 新親側の安定確認のための再取得が失敗する\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8, '再取得失敗は成功ではなく状態不明として終端すること')\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n assert.doesNotMatch(r.stdout, /result=reassigned/)\n})\n\n// --- 復旧経路の応答検証(articles#119 の codex P1 指摘)---------------------------\n// gh が終了コード 0 で返しても、応答が空・`{}`・`null`・別 issue のオブジェクトである\n// 場合がある。`.parent_issue_url // empty` はそのいずれでも終了コード 0 で空文字列を\n// 返すため、復旧取得と安定確認がともにこの応答を受け入れると「孤児を 2 回確認済み」と\n// 誤認し、旧親への補償 POST(承認外になり得る書き込み)へ進んでしまう。\n// 応答が対象 issue の JSON オブジェクトであることを検証し、満たさない応答は状態不明\n// (exit 8 reason=recovery-state-unknown)として書き込まずに停止することを実測する。\n\ntest('ケース46: DELETE 後の POST 失敗 → 復旧取得・安定確認がともに `{}` を返す(gh は成功終了)→ 孤児と誤認せず exit 8 reason=recovery-state-unknown、補償 POST は撃たれない(articles#119 codex P1)', () => {\n const r = run(['--issue', '60', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n rawAfter: '{}', // 復旧のための実状態再取得が対象 issue の JSON でない応答を返す\n rawAfter2: '{}', // 安定確認の再取得も同じ応答(指摘の「ともに受け入れる」条件)\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 1, '状態不明のまま補償 POST(書き込み)を撃たないこと(1 回目の新親 POST のみ)')\n assert.ok(\n !posts.some((l) => l.includes('issues/5/sub_issues')),\n '旧親 #5 への補償 POST が 1 件も呼ばれていないこと',\n )\n assert.doesNotMatch(r.stdout, /result=restored/)\n})\n\ntest('ケース46b: DELETE 後の POST 失敗 → 復旧取得の応答が空(gh は成功終了・stdout なし)→ 孤児と誤認せず exit 8 reason=recovery-state-unknown、補償 POST は撃たれない(articles#119 codex P1)', () => {\n const r = run(['--issue', '61', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n // 成功終了だが stdout が空(jq は値を 1 件も出力せず -e が exit 4 を返す)\n rawAfter: '',\n rawAfter2: '', // 安定確認の再取得も同じ応答(両方が受け入れると誤って孤児確定する)\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 1, '状態不明のまま補償 POST を撃たないこと')\n})\n\ntest('ケース47: DELETE 後の POST 失敗 → 復旧取得の応答が別 issue 番号のオブジェクト → 対象 issue の状態として受け入れず exit 8 reason=recovery-state-unknown(articles#119 codex P1)', () => {\n const r = run(['--issue', '62', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n // 番号が対象 issue(#62)と異なる = リダイレクト・取り違えで別 issue の応答が返った状態\n rawAfter: '{\"id\": 999, \"number\": 777, \"repository_url\": \"https://api.github.com/repos/o/r\", \"parent_issue_url\": null}',\n // 安定確認の再取得も同じ別 issue 応答(両方が受け入れると誤って孤児確定する)\n rawAfter2: '{\"id\": 999, \"number\": 777, \"repository_url\": \"https://api.github.com/repos/o/r\", \"parent_issue_url\": null}',\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 8)\n assert.match(r.stderr, /reason=recovery-state-unknown/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 1, '別 issue の応答を孤児の根拠にして補償 POST を撃たないこと')\n})\n\ntest('ケース48: DELETE 後の POST 失敗 → 復旧取得・安定確認がともに対象 issue の有効な「親なし」応答 → 従来どおり孤児と判定し補償 POST を撃って exit 10 restored(ケース46/47 の対照。検証強化で正常系を壊していないこと)', () => {\n const r = run(['--issue', '63', '--old-parent', '5', '--new-parent', '7'], {\n parentBefore: '5',\n parentAfter: '', // 復旧のための再取得・安定確認ともに有効な親なし応答\n parentAfter3: '5', // 補償 POST 後の事後確認は旧親配下\n postExit: 1,\n postBody: '500 Internal Server Error',\n })\n assert.equal(r.status, 10)\n assert.match(r.stdout.trim(), /^result=restored issue=63 new_parent=7 old_parent=5$/)\n const posts = calls(r.logPath).filter((l) => l.includes('--method POST'))\n assert.equal(posts.length, 2, '有効な孤児応答なら補償 POST が撃たれること')\n assert.match(posts[1], /issues\\/5\\/sub_issues/)\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":63975,"content_sha256":"88870f1a95b969c79e868ad050003ab1c08c080caa4b69e70ef9a57aa644e31e"},{"filename":"tests/skill-invocation-block.test.mjs","content":"// skill-invocation-block.test.mjs — Issue #335 / #372 の回帰テスト。\n//\n// #335: SKILL.md の Step 3 / Step 4 は `bash \"${REASSIGN_SCRIPT}\" ...` の直後を\n// `echo \"exit=$?\"` で締めていた。echo がブロック最後のコマンドになるため、\n// コードブロック全体(呼び出し元がコピペ実行する単位)の終了ステータスが\n// 常に 0 になり、非ゼロ終了(DELETE 済み・POST 失敗 = exit 8 等)を実行基盤が\n// 「最終ステータス」で判定した場合に見落とす。\n//\n// #372: その修正後も、ブロックは非ゼロ終了で無条件に `exit` する一方、\n// 本文とコメントは「exit 2 のうち stderr に `reason=cross-repository-parent` が\n// あるものは対象外として次の 1 件へ進む」という契約を定めていた。\n// **ブロックが stderr を捕捉していないため、掲載どおり実行しても契約を実現できない。**\n// cross-repository 親を 1 件検出しただけで棚卸し全体が停止する。\n// 修正は (1) stderr のファイル捕捉と再出力、(2) マーカー判定、(3) 対象外を表す\n// 返り値 9、(4) 呼び出し側ループの掲載、の 4 点。\n//\n// #374 P1 第 3 ラウンド: その修正で導入した共有関数(reassign_one / require_plan_array)は\n// Step 3 のフェンス内で定義し Step 4 が再利用する構成だった。しかしコードフェンスは\n// ブロックごとに独立シェルで実行され得るため、Step 4 単体実行では関数定義が継承されず\n// command not found となり、孤児の再配置が一切実行されない。旧テストは Step 4 へ\n// Step 3 の定義を人工的に前置しており、この欠陥を隠していた。\n// 修正は共有部分の scripts/reassign-lib.sh への切り出しと、両フェンスが自己完結的に\n// source する前置きの掲載。**テストは前置を一切行わず、各フェンスを単体実行する。**\n//\n// SKILL.md はドキュメントであり import 可能なモジュールではないため、\n// フェンス内のシェルスクリプトをテキストとして抽出し、実プロセスとして\n// bash 実行して終了ステータスと入出力を観測する(node:test 標準ライブラリのみ)。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { spawnSync } from 'node:child_process'\nimport { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync, copyFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SKILL_MD = join(dirname(fileURLToPath(import.meta.url)), '..', 'SKILL.md')\n\n// 付け替えを実行する ```bash フェンスのみを抽出する。件数が 2 から変化したら\n// (新ブロック追加・削除)このテストが落ち、伝播修正の当て漏れに気づける設計にする。\n// 判定キーは `reassign_one`(reassign-lib.sh が定義し、両フェンスが呼び出す関数名)。\n// スクリプト直叩き(`bash \"${REASSIGN_SCRIPT}\"`)を判定キーにすると、関数化により\n// どちらのフェンスも該当しなくなり抽出漏れを起こすため使わない。\nfunction extractReassignBlocks() {\n const text = readFileSync(SKILL_MD, 'utf8')\n const fenceRe = /```bash\\n([\\s\\S]*?)```/g\n const blocks = []\n let m\n while ((m = fenceRe.exec(text)) !== null) {\n if (m[1].includes('reassign_one')) {\n blocks.push(m[1])\n }\n }\n return blocks\n}\n\n// 両フェンスの 3 レイアウト解決ループの第一候補と一致させる。\nconst SCRIPTS_RELATIVE = join('skills', 'update-issue-tree', 'scripts')\nconst STUB_RELATIVE = join(SCRIPTS_RELATIVE, 'reassign-sub-issue.sh')\nconst LIB_RELATIVE = join(SCRIPTS_RELATIVE, 'reassign-lib.sh')\nconst LIB_SOURCE = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'reassign-lib.sh')\n\n// 出荷される実体の reassign-lib.sh を tmp レイアウトへ複製する。テスト用の写しを作らないのは、\n// ライブラリ自身の解決ロジック(自分の実体位置から兄弟の reassign-sub-issue.sh を導出する)\n// までを実測対象に含めるため。写しを使うと解決バグがテストを落とせなくなる。\nfunction installLib(tmp) {\n const dest = join(tmp, LIB_RELATIVE)\n mkdirSync(dirname(dest), { recursive: true })\n copyFileSync(LIB_SOURCE, dest)\n}\n\n// 任意の本文でスタブを置く(対になるライブラリも併せて設置する)。\n// 意図的に実行ビットを付けない: vendoring で実行ビットが落ちるケースを\n// 素通りさせず、bash 経由の起動がその状態でも機能することを実測する。\nfunction makeStubScript(tmp, body) {\n installLib(tmp)\n const path = join(tmp, STUB_RELATIVE)\n mkdirSync(dirname(path), { recursive: true })\n writeFileSync(path, body)\n return path\n}\n\n// exitCode と stderr 本文を指定できるスタブ。#372 の判定は stderr の内容に依存するため、\n// 終了コードだけでなく stderr も制御できる必要がある。\nfunction makeStub(tmp, exitCode, stderrText = '') {\n const emit = stderrText ? `printf '%s\\\\n' ${JSON.stringify(stderrText)} >&2\\n` : ''\n makeStubScript(tmp, `#!/usr/bin/env bash\\n${emit}exit ${exitCode}\\n`)\n}\n\nfunction prelude({\n plan = ['123 1 2'],\n orphans = ['456 3'],\n declarePlans = true,\n scalarPlans = false,\n} = {}) {\n const lines = []\n if (scalarPlans) {\n // 配列ではなくスカラーとして宣言する。declare -p はこの場合も成功するため、\n // 存在検査だけのガードは素通りしてしまう(PR #374 codex P1 第 2 ラウンド)。\n lines.push(`REASSIGN_PLAN=${JSON.stringify(plan[0] ?? '123 1 2')}`)\n lines.push(`ORPHAN_PLAN=${JSON.stringify(orphans[0] ?? '456 3')}`)\n } else if (declarePlans) {\n lines.push(`REASSIGN_PLAN=(${plan.map((e) => JSON.stringify(e)).join(' ')})`)\n lines.push(`ORPHAN_PLAN=(${orphans.map((e) => JSON.stringify(e)).join(' ')})`)\n }\n return lines.join('\\n')\n}\n\n// execFileSync ではなく spawnSync を使う。execFileSync は成功時に stdout しか返さず、\n// stderr は親プロセスへ素通りするため、`r.stderr` が常に空になる。#372 の判定は\n// 「成功終了(status 0)しつつ stderr へ対象外を報告する」経路を検証するため、\n// 成功時にも stderr を捕捉できる必要がある。\n// **フェンスの前に SKILL.md 由来のコードを一切前置しない。** 前置すると「Step 4 が単体で\n// 実行できない」という実運用上の欠陥をテストが隠す(PR #374 P1 第 3 ラウンドの指摘そのもの)。\n// 前置するのは計画配列の宣言(Step 2 の成果物に相当)だけに限る。\nfunction runBlock(block, cwd, preludeOpts) {\n const file = join(cwd, 'block.sh')\n writeFileSync(file, `${prelude(preludeOpts)}\\n${block}`)\n // env を最小化して実行環境の偶発的な変数を拾わないようにする(Issue #335 レビュー指摘)。\n const r = spawnSync('bash', [file], {\n cwd,\n encoding: 'utf8',\n env: { PATH: process.env.PATH ?? '' },\n })\n if (r.error) throw r.error\n return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }\n}\n\nconst MARKER = 'reason=cross-repository-parent'\n\ntest('SKILL.md から付け替え実行ブロックがちょうど2件抽出できる', () => {\n const blocks = extractReassignBlocks()\n assert.equal(blocks.length, 2, 'Step 3 / Step 4 以外のブロックが増減していないか確認')\n})\n\nconst blocks = extractReassignBlocks()\nconst step3 = blocks[0]\nconst step4 = blocks[1]\nconst cases = [\n { label: 'Step3(closed 親下の付け替え)', block: step3 },\n // Step 4 も prefix なしで同列に扱う。単体で実行できることが仕様であり、\n // 前置が必要になった時点でそれは退行である(PR #374 P1 第 3 ラウンド)。\n { label: 'Step4(孤児の再配置)', block: step4 },\n]\n\nfor (const { label, block } of cases) {\n test(`${label}: スタブが exit 8 → ブロックの終了ステータスも 8 で返る(#335 の回帰)`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n makeStub(tmp, 8)\n const r = runBlock(block, tmp)\n assert.equal(r.status, 8)\n assert.match(r.stdout, /exit=8/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n\n test(`${label}: スタブが exit 0 → ブロックの終了ステータスも 0、stdout に exit=0`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n makeStub(tmp, 0)\n const r = runBlock(block, tmp)\n assert.equal(r.status, 0)\n assert.match(r.stdout, /exit=0/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n\n // --- #372: マーカーによる (a)/(b) の判定 ---\n\n test(`${label}: exit 2 + cross-repository マーカー → 中断せず 0 で完走する(#372)`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n makeStub(tmp, 2, `エラー: 現在の親が別リポジトリにある ${MARKER}`)\n const r = runBlock(block, tmp)\n assert.equal(r.status, 0, 'cross-repository 親 1 件で棚卸し全体が停止してはならない')\n assert.match(r.stderr, /対象外(cross-repository 親)/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n\n test(`${label}: exit 2 でマーカー無し → 解消可能な前提不備として 2 で中断する(#372)`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n makeStub(tmp, 2, 'エラー: gh の認証が無い')\n const r = runBlock(block, tmp)\n assert.equal(r.status, 2, 'マーカーが無い exit 2 は中断しなければならない')\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n\n test(`${label}: 捕捉した stderr は握り潰さず再出力する(#372)`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n const detail = 'エラー: 診断に必要な詳細メッセージ'\n makeStub(tmp, 3, detail)\n const r = runBlock(block, tmp)\n assert.equal(r.status, 3)\n assert.match(r.stderr, new RegExp(detail))\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n\n // --- Cursor Bugbot 指摘「Exit 10 aborts full reassign loop」の回帰 ---\n // 終了コード表(233〜234 行)は exit 10 / 11 を「要確認事項へ記載(件数へは計上しない)」と\n // 定めており、ループを止める理由がない。旧実装はこれを fatal 扱いにして即 exit していた。\n\n test(`${label}: exit 10(補償復旧成功)→ 中断せず 0 で完走し要確認事項へ記録する`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n makeStub(tmp, 10, 'result=restored issue=123 new_parent=2 old_parent=1')\n const r = runBlock(block, tmp)\n assert.equal(r.status, 0, 'exit 10 は fatal ではなくループを継続しなければならない')\n assert.match(r.stderr, /要確認事項/)\n assert.match(r.stderr, /exit=10 restored/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n\n test(`${label}: exit 11(第三者が別親を設定済み)→ 中断せず 0 で完走し要確認事項へ記録する`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n makeStub(tmp, 11, 'エラー: reason=third-party-parent')\n const r = runBlock(block, tmp)\n assert.equal(r.status, 0, 'exit 11 は fatal ではなくループを継続しなければならない')\n assert.match(r.stderr, /要確認事項/)\n assert.match(r.stderr, /exit=11 third-party-parent/)\n assert.match(r.stderr, /再実行禁止/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n}\n\n// exit 10 で 1 件目が止まらず、残りの計画配列が処理されることを呼び出し回数で実測する\n// (Cursor Bugbot 指摘の核心: 「exit 10 aborts full reassign loop」)。\ntest('Step3: exit 10(補償復旧成功)を挟んでも残りの件が処理される', () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n const counter = join(tmp, 'calls.txt')\n makeStubScript(\n tmp,\n `#!/usr/bin/env bash\\n`\n + `issue=\"\"\\n`\n + `while [[ $# -gt 0 ]]; do\\n`\n + ` case \"$1\" in --issue) issue=\"$2\"; shift 2 ;; *) shift ;; esac\\n`\n + `done\\n`\n + `printf '%s\\\\n' \"\\${issue}\" >> ${JSON.stringify(counter)}\\n`\n + `if [[ \"\\${issue}\" == \"111\" ]]; then\\n`\n + ` printf '%s\\\\n' \"result=restored issue=111 new_parent=2 old_parent=1\"\\n`\n + ` exit 10\\n`\n + `fi\\n`\n + `exit 0\\n`,\n )\n const r = runBlock(step3, tmp, { plan: ['111 1 2', '222 1 2', '333 1 2'] })\n assert.equal(r.status, 0)\n const calls = readFileSync(counter, 'utf8').trim().split('\\n')\n assert.deepEqual(calls, ['111', '222', '333'], 'exit 10 の 1 件目で停止せず 3 件すべて呼ばれること')\n assert.match(r.stderr, /要確認事項/)\n assert.match(r.stderr, /exit=10 restored/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n})\n\n// 複数件のループが実際に「対象外は飛ばして次へ進む」ことを、呼び出し回数で実測する。\n// 単一件のテストでは「1 件目で止まった」と「1 件目を飛ばして完走した」を区別できない。\ntest('Step3: cross-repository 親を飛ばして残りの件も処理する(呼び出し回数で実測。#372)', () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n const counter = join(tmp, 'calls.txt')\n // --issue の値で挙動を変える: 111 は cross-repository(exit 2 + マーカー)、\n // それ以外は成功。呼び出しごとに 1 行追記して回数を数える。\n makeStubScript(\n tmp,\n `#!/usr/bin/env bash\\n`\n + `issue=\"\"\\n`\n + `while [[ $# -gt 0 ]]; do\\n`\n + ` case \"$1\" in --issue) issue=\"$2\"; shift 2 ;; *) shift ;; esac\\n`\n + `done\\n`\n + `printf '%s\\\\n' \"\\${issue}\" >> ${JSON.stringify(counter)}\\n`\n + `if [[ \"\\${issue}\" == \"111\" ]]; then\\n`\n + ` printf '%s\\\\n' \"エラー: ${MARKER}\" >&2\\n`\n + ` exit 2\\n`\n + `fi\\n`\n + `exit 0\\n`,\n )\n const r = runBlock(step3, tmp, { plan: ['111 1 2', '222 1 2', '333 1 2'] })\n assert.equal(r.status, 0)\n const calls = readFileSync(counter, 'utf8').trim().split('\\n')\n assert.deepEqual(calls, ['111', '222', '333'], '対象外の 1 件目で停止せず 3 件すべて呼ばれること')\n assert.match(r.stderr, /対象外(cross-repository 親): 111/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n})\n\n// 未検出パスは reassign-lib.sh への切り出しで 2 通りに分かれた。両方を塞ぐ——\n// (a) ライブラリ自体が 3 レイアウトいずれにも無い、(b) ライブラリはあるが対の\n// reassign-sub-issue.sh が欠落している。(b) を落とすと、source は成功して\n// REASSIGN_SCRIPT が実在しないパスを指したままループへ進む形の退行を検出できない。\nfor (const { label, block } of cases) {\n test(`${label}: reassign-lib.sh が 3 レイアウトいずれにも無ければ exit 1(未検出パス (a))`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n const r = runBlock(block, tmp)\n assert.equal(r.status, 1)\n assert.match(r.stderr, /reassign-lib\\.sh が見つからない/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n\n test(`${label}: ライブラリはあるが対の reassign-sub-issue.sh が無ければ中断する(未検出パス (b))`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n installLib(tmp) // スタブは置かない\n const r = runBlock(block, tmp)\n assert.notEqual(r.status, 0, '対のスクリプトが無いまま完走してはならない')\n assert.match(r.stderr, /対のスクリプトが欠落/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n}\n\n// PR #374 P1 第 3 ラウンドの核心。Step 4 のフェンスを **Step 3 由来のコードを 1 行も\n// 前置せずに** 実行し、孤児の再配置が実際に呼ばれるところまで確認する。\n// 旧テストは Step 3 の関数定義を人工的に前置していたため、フェンスが独立シェルで\n// 実行される実運用では command not found で 1 件も処理されない欠陥を隠していた。\ntest('Step4: Step 3 のコードを前置せず単体で実行でき、スクリプトが実際に呼ばれる(PR #374 P1 第3R)', () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n const argsLog = join(tmp, 'args.txt')\n makeStubScript(tmp, `#!/usr/bin/env bash\\nprintf '%s\\\\n' \"$*\" >> ${JSON.stringify(argsLog)}\\nexit 0\\n`)\n const r = runBlock(step4, tmp, { orphans: ['456 3', '789 3'] })\n assert.equal(r.status, 0, `単体実行で失敗した: ${r.stderr}`)\n assert.doesNotMatch(r.stderr, /command not found/, '関数未定義でフェンスが壊れてはならない')\n const lines = readFileSync(argsLog, 'utf8').trim().split('\\n')\n assert.deepEqual(lines, [\n '--issue 456 --new-parent 3',\n '--issue 789 --new-parent 3',\n ], '孤児 2 件が --old-parent 無しで順に呼ばれること')\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n})\n\n// 共有処理はライブラリ側にあり、フェンスは source するだけ——という構造そのものを固定する。\n// どちらかのフェンスが再び関数を定義し始めたら、もう一方がそれに依存する構成へ戻り得る。\ntest('両フェンスが reassign-lib.sh を自己完結的に source し、共有関数を自前定義していない(PR #374 P1 第3R)', () => {\n for (const { label, block } of cases) {\n assert.match(block, /source \"\\$\\{REASSIGN_LIB\\}\"/, `${label}: ライブラリの source が必要`)\n assert.match(block, /reassign-lib\\.sh\"/, `${label}: 3 レイアウト解決の候補列挙が必要`)\n assert.doesNotMatch(\n block,\n /^(reassign_one|require_plan_array)\\(\\) \\{/m,\n `${label}: 共有関数はフェンス内で定義しない(他方のフェンスが継承できない)`,\n )\n }\n})\n\n// `if reassign_one ...; then ... fi` は fi 直後の $? が 0 になるため使ってはならない\n// (実測: bash では条件が偽で else が無い場合 $? = 0)。この形に戻ると失敗が\n// すべて「成功」として読まれ、(a)/(b) の判定も中断も素通りする。\ntest('両ブロックとも if/fi で返り値を判定していない($? が 0 に化ける形の検出。#372)', () => {\n // reassign_one の本体はライブラリへ移ったため、フェンスだけを検査すると\n // `$?` が 0 に化ける形の退行を素通りさせる。ライブラリ本体も同じ検査に通す。\n const targets = [...cases, { label: 'reassign-lib.sh', block: readFileSync(LIB_SOURCE, 'utf8') }]\n for (const { label, block } of targets) {\n // コメント行を除去してから判定する。この形を禁じる注意書き自体がコメントとして\n // ブロック内に存在するため、生テキストへの照合だと自分の警告文に誤ヒットする。\n const code = block\n .split('\\n')\n .filter((line) => !/^\\s*#/.test(line))\n .join('\\n')\n assert.doesNotMatch(\n code,\n /if\\s+reassign_one[\\s\\S]*?;\\s*then/,\n `${label}: if reassign_one ...; then の形は fi 直後の $? が 0 になるため使えない`,\n )\n // `|| status=$?` の退避は呼び出し側ループの契約。ライブラリ本体は同じ役割を\n // `status=$?` 単独(bash 起動の直後)で果たすため、この要求の対象外とする。\n if (label !== 'reassign-lib.sh') {\n assert.match(code, /\\|\\|\\s*status=\\$\\?/, `${label}: || status=$? による明示的な退避が必要`)\n }\n }\n})\n\n// set -e があると reassign_one 内の `status=$?` 退避に到達せず、Issue #335 の欠陥が再発する。\n// 「非ゼロを握り潰さないため」の設定が、まさにその見落としを作り直す形になっているのを防ぐ。\ntest('reassign-lib.sh と両フェンスが set -e / set -o errexit を使っていない(#335 の再発防止)', () => {\n const targets = [\n ...cases,\n { label: 'reassign-lib.sh', block: readFileSync(LIB_SOURCE, 'utf8') },\n ]\n for (const { label, block } of targets) {\n const code = block\n .split('\\n')\n .filter((line) => !/^\\s*#/.test(line))\n .join('\\n')\n assert.doesNotMatch(code, /^\\s*set\\s+-[a-z]*e/m, `${label}: set -e は使えない`)\n assert.doesNotMatch(code, /set\\s+-o\\s+errexit/, `${label}: set -o errexit は使えない`)\n }\n})\n\n// ---------------------------------------------------------------------------\n// 計画配列の未宣言ガード(PR #374 codex P1)\n// ---------------------------------------------------------------------------\n// bash では未定義配列の \"${REASSIGN_PLAN[@]}\" が空へ展開されるため、Step 2 を実行し\n// 忘れてもループが 0 回で回り、承認済みの対象があってもエラーなく完走して完了報告まで\n// 進んでしまう。「対象なし(空配列を宣言済み)」と「計画未設定(未宣言)」は意味が\n// 異なるため、後者は fail-closed で停止しなければならない。\nfor (const { label, block } of cases) {\n test(`${label}: 計画配列が未宣言なら exit 1 で停止する(0 件完走との区別。PR #374 P1)`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n makeStub(tmp, 0)\n const r = runBlock(block, tmp, { declarePlans: false })\n assert.equal(r.status, 1, '未宣言のまま 0 件完走してはならない')\n assert.match(r.stderr, /未宣言/)\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n\n test(`${label}: 空配列を宣言済みなら正常に 0 件で完走する(対象なしは正常系)`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n const counter = join(tmp, 'calls.txt')\n makeStubScript(tmp, `#!/usr/bin/env bash\\nprintf 'called\\\\n' >> ${JSON.stringify(counter)}\\nexit 0\\n`)\n const r = runBlock(block, tmp, { plan: [], orphans: [] })\n assert.equal(r.status, 0)\n // スクリプトが 1 度も呼ばれていないこと(= ループが 0 回で正常終了)\n assert.equal(existsSync(counter), false, '対象 0 件でスクリプトが呼ばれてはならない')\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n}\n\n// Step 2 が計画配列の構築手順を掲載していること(ガードだけ足して構築手順が無いと、\n// 実行者は「どう作ればよいか」が分からず結局 fail-closed で止まり続ける)。\ntest('Step 2 に REASSIGN_PLAN / ORPHAN_PLAN の構築手順が掲載されている(PR #374 P1)', () => {\n const text = readFileSync(SKILL_MD, 'utf8')\n const step2 = text.slice(text.indexOf('### Step 2:'), text.indexOf('### Step 3:'))\n assert.match(step2, /REASSIGN_PLAN=\\(/, 'Step 2 に REASSIGN_PLAN の宣言例が必要')\n assert.match(step2, /ORPHAN_PLAN=\\(/, 'Step 2 に ORPHAN_PLAN の宣言例が必要')\n assert.match(step2, /空配列として宣言/, '対象 0 件でも宣言する契約の明示が必要')\n})\n\n// `declare -p` は同名のスカラー変数が宣言済みでも成功する。その場合 \"${ARR[@]}\" は\n// スカラー値 1 個へ展開され、計画として構築していない値が 1 件の付け替え指示として\n// 実行される(実測: REASSIGN_PLAN=\"123 1 2\" は存在検査だけのガードを通過し、\n// ループが 1 回まわってスクリプトが呼ばれる)。属性が添字配列であることまで検査する。\nfor (const { label, block } of cases) {\n test(`${label}: 計画変数がスカラーなら exit 1 で停止しスクリプトを呼ばない(PR #374 P1 第2R)`, () => {\n const tmp = mkdtempSync(join(tmpdir(), 'update-issue-tree-block-'))\n try {\n const counter = join(tmp, 'calls.txt')\n makeStubScript(tmp, `#!/usr/bin/env bash\\nprintf 'called\\\\n' >> ${JSON.stringify(counter)}\\nexit 0\\n`)\n const r = runBlock(block, tmp, { scalarPlans: true })\n assert.equal(r.status, 1, 'スカラー宣言をガードが通してはならない')\n assert.match(r.stderr, /添字配列ではない/)\n assert.equal(existsSync(counter), false, 'ガード通過前にスクリプトが呼ばれてはならない')\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n })\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":25537,"content_sha256":"e032580d8f408cce041c00f767e1251fcf64b3fc0a588299d11065df1d00ad23"},{"filename":"tests/skill-prerequisites.test.mjs","content":"// skill-prerequisites.test.mjs — Issue #366 の再発防止テスト。\n//\n// PR #349(Issue #332、cross-repository スコープの明文化)で、SKILL.md の「前提条件」節に\n// 「本スクリプトが対象 issue を処理できるのは parent_issue_url が null になってからである」\n// という、条件節を伴わない断定 bullet が混入した。この記述は同一リポジトリ内の親からの\n// 付け替え(Step 3 が `--old-parent` 付きで DELETE→POST する、本スキルの中核機能)まで\n// 禁止しているように読め、Step 3 の契約と自己矛盾していた(Issue #366)。\n//\n// 実装(reassign-sub-issue.sh)は parent_issue_url のリポジトリ部分が対象 issue の\n// repository_url と異なる場合にのみ fail-closed する。つまり null 要求は cross-repository の\n// 親を持つ issue に限られる。この不変条件を「前提条件」節の bullet 構造から機械的に検証し、\n// 将来また条件節を落とした断定表現が紛れ込んだ場合に検知できるようにする。\n//\n// 文面の逐語比較はしない(言い回しの変更だけで無意味に赤くなるのを避けるため)。\n// bullet を「先頭が `- ` の論理項目(継続行を束ねる)」という構造で切り出し、\n// null 要求を含む bullet が cross-repository の条件と同一 bullet 内にあるかどうかだけを見る。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { readFileSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SKILL_MD = join(dirname(fileURLToPath(import.meta.url)), '..', 'SKILL.md')\n\n// 「## 前提条件」見出しから次の「## 」見出しまでのテキストを切り出す。\nfunction extractPrerequisitesSection() {\n const text = readFileSync(SKILL_MD, 'utf8')\n const startMarker = '## 前提条件'\n const startIdx = text.indexOf(startMarker)\n assert.ok(startIdx !== -1, 'SKILL.md に \"## 前提条件\" 見出しが見つからない')\n const afterStart = startIdx + startMarker.length\n const nextHeadingIdx = text.indexOf('\\n## ', afterStart)\n assert.ok(nextHeadingIdx !== -1, '\"## 前提条件\" の次の \"## \" 見出しが見つからない')\n return text.slice(afterStart, nextHeadingIdx)\n}\n\n// 先頭が `- ` の行を bullet の開始とみなし、次の bullet(または節末)までの\n// 継続行(インデントされた行)を束ねて 1 論理項目にする。\nfunction extractBullets(sectionText) {\n const lines = sectionText.split('\\n')\n const bullets = []\n let current = null\n for (const line of lines) {\n if (/^- /.test(line)) {\n if (current !== null) bullets.push(current)\n current = line\n } else if (current !== null && /^\\s+\\S/.test(line)) {\n current += '\\n' + line\n } else if (current !== null && line.trim() === '') {\n // 空行は bullet 継続とみなさないが、直後にまた継続行が来る書式もあるため\n // bullet を確定させずスキップする(前提条件節に空行区切りの bullet は無い想定)。\n continue\n }\n }\n if (current !== null) bullets.push(current)\n return bullets\n}\n\n// bullet 全体ではなく「。」区切りの文(継続行の改行は連結して 1 文として扱う)単位で判定する。\n// Issue #366 の元の文言は、bullet 全体としては 'cross-repository' という語を含んでいたが、\n// それは null 要求を述べる文とは別の文(親リンク取り外しの実施者を説明する文)に登場して\n// いただけで、null 要求の文自体は無条件だった。bullet 単位の部分一致では両文が同じ bullet に\n// あるだけで「限定されている」と誤判定してしまうため、null 要求を述べる文の内部にスコープ語\n// (cross-repository 限定)または免除語(明示的な対象外)があるかどうかで判定する。\nfunction extractSentences(bullet) {\n return bullet\n .replace(/\\n\\s*/g, '')\n .split('。')\n .map((s) => s.trim())\n .filter((s) => s.length > 0)\n}\n\ntest('前提条件節: parent_issue_url の null 要求は同一文内に cross-repository 限定の条件を伴う', () => {\n const section = extractPrerequisitesSection()\n const bullets = extractBullets(section)\n assert.ok(bullets.length > 0, '前提条件節から bullet を 1 件も抽出できなかった')\n\n // parent_issue_url と null の両方に言及する bullet を網羅的に集める(特定の言い回しへの\n // 逐語一致では絞り込まない)。AC1 の断定 bullet と AC2 の肯定(免除)bullet の両方が\n // ここに入り得るため、各 bullet を「cross-repository 限定」または「明示的な免除」の\n // いずれかの極性で判定する。将来、言い回しは違うが両方を欠く裸の断定 bullet が\n // 紛れ込んだ場合にこの判定がすり抜けを許さない(Issue #366 の再発防止)。\n const nullMentioningBullets = bullets.filter(\n (b) => b.includes('parent_issue_url') && b.includes('null')\n )\n assert.ok(\n nullMentioningBullets.length > 0,\n 'parent_issue_url と null の両方に言及する bullet が見つからない(前提条件節の構成が変わった可能性)'\n )\n\n for (const bullet of nullMentioningBullets) {\n // null 要求そのものを述べている文(parent_issue_url と null の両方を含む文)だけを\n // 対象にする。bullet 内の他の文(例: 実施者の説明)に cross-repository が登場しているだけ\n // では、null 要求自体が限定されているとはみなさない。\n const nullRequirementSentences = extractSentences(bullet).filter(\n (s) => s.includes('parent_issue_url') && s.includes('null')\n )\n assert.ok(\n nullRequirementSentences.length > 0,\n `parent_issue_url と null の両方を含む bullet だが、両方を含む文が抽出できない(文分割ロジックの不備の可能性):\\n${bullet}`\n )\n\n for (const sentence of nullRequirementSentences) {\n const isCrossRepoScoped = sentence.includes('別リポジトリ') || sentence.includes('cross-repository')\n const isExplicitExemption = sentence.includes('必要はない') || sentence.includes('対象外')\n assert.ok(\n isCrossRepoScoped || isExplicitExemption,\n `null 要求を述べる文が cross-repository 限定でも明示的な免除でもない(Issue #366 の再発):\\n${sentence}`\n )\n }\n }\n})\n\ntest('前提条件節: 同一リポジトリ内の付け替えが --old-parent による通常フローであると明記されている', () => {\n const section = extractPrerequisitesSection()\n const bullets = extractBullets(section)\n\n const sameRepoBullets = bullets.filter((b) => b.includes('同一リポジトリ'))\n assert.ok(\n sameRepoBullets.length > 0,\n '同一リポジトリ内の付け替えに言及する bullet が前提条件節に見つからない(AC2 未充足)'\n )\n assert.ok(\n sameRepoBullets.some((b) => b.includes('--old-parent')),\n '同一リポジトリ内の付け替えに言及する bullet が --old-parent への言及を伴っていない'\n )\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":7358,"content_sha256":"ce3f1d8aba7b8935bec99762c429e39263e80bca16d4a5f461b71d9f0d6c22e6"},{"filename":"tests/step8-root-body.test.mjs","content":"// step8-root-body.test.mjs — Issue #545 の回帰テスト。\n//\n// 旧 Step 8 は固定テンプレートの here-document を `gh issue edit --body` へ渡し、ルート issue の\n// 本文を全置換していた。人が書いた計画・判断根拠が棚卸しのたびに消え、件数・日付のプレース\n// ホルダーが本文へ到達し得た。修正後は scripts/update-root-body.sh が既存本文を取得して管理\n// ブロックだけを差し替える。本テストはそのスクリプトを実プロセスとして起動し、gh を PATH 上の\n// スタブへ差し替えて、保持・移行・冪等・fail-closed の契約を固定する(node:test 標準のみ)。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { spawnSync } from 'node:child_process'\nimport { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync, readdirSync, chmodSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst HERE = dirname(fileURLToPath(import.meta.url))\nconst SKILL_MD = join(HERE, '..', 'SKILL.md')\nconst SCRIPT = join(HERE, '..', 'scripts', 'update-root-body.sh')\n\nconst PB = '\u003c!-- update-issue-tree:phase-plan:begin -->'\nconst PE = '\u003c!-- update-issue-tree:phase-plan:end -->'\nconst IB = '\u003c!-- update-issue-tree:inventory:begin -->'\nconst IE = '\u003c!-- update-issue-tree:inventory:end -->'\nconst REPO_URL = 'https://api.github.com/repos/o/r'\n\nconst GH_STUB = `#!/usr/bin/env bash\nD=\"\\${STUB_DIR}\"\necho \"$*\" >> \"\\${D}/calls.log\"\ncase \"$1\" in\n auth)\n [[ -f \"\\${D}/auth.fail\" ]] && exit 1\n exit 0\n ;;\n issue)\n # gh issue edit \u003cn> --body-file \u003cf>\n echo \"edit\" >> \"\\${D}/edit.log\"\n [[ -f \"\\${D}/edit.fail\" ]] && exit 1\n cp \"$5\" \"\\${D}/edited.body\"\n exit 0\n ;;\n api)\n path=\"$2\"\n case \"\\${path}\" in\n */sub_issues\\\\?*)\n rest=\"\\${path#*/issues/}\"\n n=\"\\${rest%%/*}\"\n page=\"\\${path##*page=}\"\n echo \"sub \\${n} \\${page}\" >> \"\\${D}/sub.log\"\n f=\"\\${D}/sub_\\${n}_\\${page}.json\"\n if [[ -f \"\\${f}\" ]]; then cat \"\\${f}\"; exit 0; fi\n exit 1\n ;;\n */issues/*)\n [[ -f \"\\${D}/root.fail\" ]] && exit 1\n k=$(cat \"\\${D}/root.count\" 2>/dev/null || echo 0)\n k=$((k + 1))\n echo \"\\${k}\" > \"\\${D}/root.count\"\n if [[ -f \"\\${D}/edited.body\" && -f \"\\${D}/verify.override.json\" ]]; then\n cat \"\\${D}/verify.override.json\"\n elif [[ -f \"\\${D}/edited.body\" ]]; then\n jq -n --rawfile b \"\\${D}/edited.body\" '{body: $b, repository_url: \"${REPO_URL}\"}'\n elif [[ -f \"\\${D}/root.\\${k}.json\" ]]; then\n cat \"\\${D}/root.\\${k}.json\"\n else\n cat \"\\${D}/root.json\"\n fi\n exit 0\n ;;\n esac\n ;;\nesac\nexit 1\n`\n\nfunction setup() {\n const dir = mkdtempSync(join(tmpdir(), 'step8-'))\n const stub = join(dir, 'stub')\n const bin = join(dir, 'bin')\n const tmp = join(dir, 'tmp')\n for (const d of [stub, bin, tmp]) mkdirSync(d)\n writeFileSync(join(bin, 'gh'), GH_STUB)\n chmodSync(join(bin, 'gh'), 0o755)\n return { dir, stub, bin, tmp }\n}\n\nfunction setRoot(env, body, extra = {}) {\n const obj = { number: 254, repository_url: REPO_URL, body, ...extra }\n writeFileSync(join(env.stub, 'root.json'), JSON.stringify(obj))\n}\n\nfunction issue(number, title, o = {}) {\n return {\n number,\n title,\n state: o.state ?? 'open',\n repository_url: o.repo ? `https://api.github.com/repos/${o.repo}` : REPO_URL,\n labels: (o.labels ?? []).map((name) => ({ name })),\n sub_issues_summary: { total: o.total ?? 0, completed: 0 },\n }\n}\n\nfunction setSubs(env, n, items, page = 1) {\n writeFileSync(join(env.stub, `sub_${n}_${page}.json`), JSON.stringify(items))\n}\n\nconst DEFAULT_ARGS = [\n '--root', '254', '--granularity', '2h',\n '--reassigned', '1', '--orphans', '2', '--labels', '3', '--new-phases', '0', '--split', '0',\n '--date', '2026-10-06',\n]\n\nfunction run(env, args = DEFAULT_ARGS) {\n const r = spawnSync('bash', [SCRIPT, ...args], {\n cwd: env.dir,\n encoding: 'utf8',\n env: { PATH: `${env.bin}:${process.env.PATH ?? ''}`, STUB_DIR: env.stub, TMPDIR: env.tmp, HOME: env.dir },\n })\n if (r.error) throw r.error\n return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }\n}\n\nconst editCount = (env) => {\n const f = join(env.stub, 'edit.log')\n return existsSync(f) ? readFileSync(f, 'utf8').split('\\n').filter(Boolean).length : 0\n}\nconst edited = (env) => readFileSync(join(env.stub, 'edited.body'), 'utf8')\nconst callCount = (env) => {\n const f = join(env.stub, 'calls.log')\n return existsSync(f) ? readFileSync(f, 'utf8').split('\\n').filter(Boolean).length : 0\n}\nconst count = (text, line) => text.split('\\n').filter((l) => l === line).length\nconst cleanup = (env) => rmSync(env.dir, { recursive: true, force: true })\n\nfunction withEnv(fn) {\n const env = setup()\n try {\n return fn(env)\n } finally {\n cleanup(env)\n }\n}\n\n// #254 を模した、手書きの内容が混在するマーカー無し本文。\nconst HANDWRITTEN = [\n '\u003c!-- granularity: 2h -->',\n '## 概要',\n '',\n '手書きの概要。判断根拠はここに書く。',\n '',\n '## 棚卸しで実施した整理(2026-09-01 追記)',\n '',\n '- 手書きの叙述 A',\n '- 手書きの叙述 B',\n '',\n '## Phase 別実装計画',\n '',\n '| Phase | 親 issue | 直下 | 総 open 件数 |',\n '|-------|----------|------|-------------|',\n '| Phase 1 | #10 旧タイトル | 1 | 1 |',\n '| 実装ラン | #275 手書きサマリー | - | - |',\n '',\n '### Phase 1: 旧タイトル',\n '',\n '| Issue | タイトル | 由来 | 分解 |',\n '|-------|---------|------|------|',\n '| #11 | 旧子 | 手書き由来 | - |',\n '',\n '### 実装ラン (#275)',\n '',\n '| 項目 | 内容 |',\n '|------|------|',\n '| 手書き | 別スキーマの表 |',\n '',\n '## 運用',\n '',\n '- 新規 issue は起票時に Phase 親へ紐付ける',\n '- テンプレート外の手書き bullet',\n '',\n].join('\\n')\n\nfunction basicTree(env) {\n setSubs(env, 254, [\n issue(10, 'Phase 親 1', { labels: ['phase:1'], total: 2 }),\n issue(20, 'Phase 親 2', { labels: ['phase:2'], total: 0 }),\n ])\n setSubs(env, 10, [issue(11, '子 A', { total: 1 }), issue(12, '子 B')])\n setSubs(env, 11, [issue(13, '孫 A')])\n}\n\ntest('a: マーカー無しの手書き本文は全行を保持して移行する(旧 Phase 表は details へ退避)', () =>\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n assert.equal(editCount(env), 1)\n const out = edited(env)\n const outLines = new Set(out.split('\\n'))\n for (const line of HANDWRITTEN.split('\\n')) {\n if (line === '' || line.startsWith('\u003c!-- granularity')) continue\n assert.ok(outLines.has(line), `行が失われた: ${line}`)\n }\n assert.equal(out.split('\\n')[0], '\u003c!-- granularity: 2h -->')\n const detailsStart = out.indexOf('\u003cdetails>')\n const detailsEnd = out.indexOf('\u003c/details>')\n assert.ok(detailsStart >= 0 && detailsEnd > detailsStart)\n const details = out.slice(detailsStart, detailsEnd)\n assert.ok(details.includes('### Phase 1: 旧タイトル'))\n assert.ok(details.includes('| #11 | 旧子 | 手書き由来 | - |'))\n const runIdx = out.indexOf('### 実装ラン (#275)')\n assert.ok(runIdx >= 0 && (runIdx \u003c detailsStart || runIdx > detailsEnd), '実装ラン は details の外')\n for (const m of [PB, PE, IB, IE]) assert.equal(count(out, m), 1)\n assert.match(r.stdout, /migration=phase-plan-archived/)\n assert.match(out, /\\| Phase 1 \\| #10 Phase 親 1 \\| 2 \\| 3 \\|/)\n assert.match(out, /\\| 2026-10-06 \\| 1 \\| 2 \\| 3 \\| 0 \\| 0 \\|/)\n }))\n\ntest('b: マーカーあり本文は phase-plan 間だけを置換し、マーカー外は一致する', () =>\n withEnv((env) => {\n const before = ['\u003c!-- granularity: 2h -->', '## 概要', '', '手書き', '', '## Phase 別実装計画', '', PB, '古い表', PE, '', '## 運用', '', '- 手書き bullet', ''].join('\\n')\n setRoot(env, before)\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n const out = edited(env)\n // マーカー外の元の行(granularity 行を除く)が、同じ順序で 1 行も欠けず残る\n const outLines = out.split('\\n')\n let pos = 0\n const outside = before.split('\\n').filter((l) => !l.startsWith('\u003c!-- granularity'))\n let inside = false\n for (const line of outside) {\n if (line === PB) inside = true\n if (!inside) {\n const idx = outLines.indexOf(line, pos)\n assert.ok(idx >= 0, `行が失われた・順序が変わった: ${line}`)\n pos = idx + 1\n }\n if (line === PE) inside = false\n }\n assert.ok(out.split(PE)[1].includes('## 運用\\n\\n- 手書き bullet'))\n assert.ok(!out.includes('古い表'))\n assert.match(r.stdout, /migration=inserted/)\n }))\n\ntest('c: 同一入力の 2 回目は unchanged・edit なし・履歴行が重複しない', () =>\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n basicTree(env)\n assert.equal(run(env).status, 0)\n const first = edited(env)\n rmSync(join(env.stub, 'root.count'), { force: true })\n setRoot(env, first)\n rmSync(join(env.stub, 'edited.body'))\n const r2 = run(env)\n assert.equal(r2.status, 0, r2.stderr)\n assert.match(r2.stdout, /result=unchanged/)\n assert.match(r2.stdout, /migration=none/)\n assert.equal(editCount(env), 1)\n }))\n\ntest('d: 件数が変わった 2 回目は履歴行が 1 行追加され、1 回目の行も残る', () =>\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n basicTree(env)\n assert.equal(run(env).status, 0)\n const first = edited(env)\n rmSync(join(env.stub, 'edited.body'))\n setRoot(env, first)\n const args = DEFAULT_ARGS.map((a, i) => (DEFAULT_ARGS[i - 1] === '--reassigned' ? '5' : a))\n const r2 = run(env, args)\n assert.equal(r2.status, 0, r2.stderr)\n const out = edited(env)\n assert.ok(out.includes('| 2026-10-06 | 1 | 2 | 3 | 0 | 0 |'))\n assert.ok(out.includes('| 2026-10-06 | 5 | 2 | 3 | 0 | 0 |'))\n }))\n\ntest('e: 見出しもマーカーも無い本文は末尾へ追記し、既存行を保持する', () =>\n withEnv((env) => {\n const before = '手書きメモ 1\\n手書きメモ 2\\n'\n setRoot(env, before)\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n const out = edited(env)\n assert.ok(out.includes('手書きメモ 1\\n手書きメモ 2\\n'))\n assert.ok(out.indexOf('## Phase 別実装計画') > out.indexOf('手書きメモ 2'))\n for (const m of [PB, PE, IB, IE]) assert.equal(count(out, m), 1)\n }))\n\ntest('f: 空本文・null 本文は骨格を生成し、プレースホルダーを含まない', () => {\n for (const body of ['', null, ' \\n ']) {\n withEnv((env) => {\n setRoot(env, body)\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n const out = edited(env)\n assert.ok(out.includes('## 概要') && out.includes('## 運用') && out.includes('## Phase 別実装計画'))\n assert.ok(!/YYYY-MM-DD|\u003cphase|\u003cN>|\\bN 件/.test(out), 'プレースホルダー混入')\n assert.ok(out.includes('| 2026-10-06 | 1 | 2 | 3 | 0 | 0 |'))\n })\n }\n})\n\ntest('g: マーカー不整合は exit 4・edit なし', () => {\n const bad = [\n [PB, 'x'].join('\\n'),\n [PB, PB, PE].join('\\n'),\n [PE, 'x', PB].join('\\n'),\n [IB, 'x', PB, IE, PE].join('\\n'),\n ]\n for (const body of bad) {\n withEnv((env) => {\n setRoot(env, body)\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 4, body)\n assert.equal(editCount(env), 0)\n })\n }\n})\n\ntest('h: ルート取得失敗は exit 2・edit なし', () =>\n withEnv((env) => {\n writeFileSync(join(env.stub, 'root.fail'), '')\n const r = run(env)\n assert.equal(r.status, 2)\n assert.equal(editCount(env), 0)\n }))\n\ntest('h2: ルートが PR なら exit 2', () =>\n withEnv((env) => {\n setRoot(env, 'x', { pull_request: { url: 'u' } })\n assert.equal(run(env).status, 2)\n assert.equal(editCount(env), 0)\n }))\n\ntest('i: sub_issues 取得失敗は exit 3・edit なし', () =>\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n const r = run(env)\n assert.equal(r.status, 3)\n assert.equal(editCount(env), 0)\n }))\n\ntest('j: 不正な引数(プレースホルダー残り含む)は exit 1・gh 呼び出しなし', () => {\n const swap = (flag, val) => DEFAULT_ARGS.map((a, i) => (DEFAULT_ARGS[i - 1] === flag ? val : a))\n const cases = [\n swap('--reassigned', 'N'),\n swap('--orphans', '\u003cN>'),\n swap('--labels', '-1'),\n swap('--new-phases', '01'),\n swap('--split', ''),\n swap('--granularity', '2'),\n swap('--granularity', '0h'),\n swap('--root', 'abc'),\n swap('--date', '2026/10/06'),\n DEFAULT_ARGS.slice(0, 4),\n ]\n for (const args of cases) {\n withEnv((env) => {\n setRoot(env, 'x')\n const r = run(env, args)\n assert.equal(r.status, 1, JSON.stringify(args))\n assert.equal(callCount(env), 0)\n })\n }\n})\n\ntest('k: タイトルの | ・改行・終了マーカー文字列でも表が崩れず、再入力が通る', () =>\n withEnv((env) => {\n setRoot(env, '')\n setSubs(env, 254, [issue(10, `a | b\\nc ${PE} d \u003c!-- x -->`, { labels: ['phase:1'], total: 1 })])\n setSubs(env, 10, [issue(11, `x | y ${IE}`)])\n assert.equal(run(env).status, 0)\n const out = edited(env)\n for (const m of [PB, PE, IB, IE]) assert.equal(count(out, m), 1)\n const rows = out.split('\\n').filter((l) => l.startsWith('| #'))\n for (const row of rows) {\n assert.equal(row.split('|').length, 5, row)\n }\n assert.ok(out.includes('a | b c <!-- update-issue-tree:phase-plan:end --> d <!-- x -->'), out)\n assert.ok(out.includes('x | y <!-- update-issue-tree:inventory:end -->'), out)\n rmSync(join(env.stub, 'edited.body'))\n setRoot(env, out)\n const r2 = run(env)\n assert.equal(r2.status, 0, r2.stderr)\n }))\n\ntest('k2: タイトルのバックスラッシュと | は文字参照になり、表の列が割れない', () =>\n withEnv((env) => {\n setRoot(env, '')\n setSubs(env, 254, [issue(10, 'a\\\\|b|c', { labels: ['phase:1'], total: 0 })])\n assert.equal(run(env).status, 0)\n const rows = edited(env).split('\\n').filter((l) => l.startsWith('| Phase '))\n assert.ok(rows.some((l) => l.includes('a\|b|c')), rows.join('\\n'))\n for (const row of rows) {\n assert.ok(!row.includes('\\\\'), row)\n // 列区切りの | だけが残る(4 列 = 5 区切り + 外側 = 6 分割)\n assert.equal(row.split('|').length, 6, row)\n }\n }))\n\n// create-issue-tree の step6 (G4) と同じ入出力。変換は両スキルで揃える(スキルをまたぐ import はしない)。\ntest('k3: タイトルの & \u003c > ` \\\\ | を create-issue-tree の CELL と同じ文字参照へ変換する(表・見出し・詳細表)', () =>\n withEnv((env) => {\n const title = 'a&b \u003cc> `d` e\\\\f g|h'\n const conv = 'a&b <c> `d` e\f g|h'\n setRoot(env, '')\n setSubs(env, 254, [issue(10, title, { labels: ['phase:1'], total: 1 })])\n setSubs(env, 10, [issue(11, title)])\n assert.equal(run(env).status, 0)\n const out = edited(env)\n assert.ok(out.includes(`| #11 | ${conv} | - |`), out)\n assert.ok(out.includes(`### Phase 1: ${conv}`), out)\n assert.ok(out.includes(`| #10 ${conv} |`), out)\n const block = out.slice(out.indexOf(PB), out.indexOf(PE))\n assert.ok(!/[`\\\\]/.test(block), block)\n }))\n\ntest('k4: & は二重変換せず(& は &amp; になる)、CR/LF の連続は空白 1 つになる', () =>\n withEnv((env) => {\n setRoot(env, '')\n setSubs(env, 254, [issue(10, 'x & y\\r\\n\\r\\nz', { labels: ['phase:1'], total: 0 })])\n assert.equal(run(env).status, 0)\n assert.ok(edited(env).includes('x &amp; y z'), edited(env))\n }))\n\ntest('k5: 管理区間内の旧形式(\\\\| ・\\\\\\\\ ・バッククォート)は文字参照へ書き直し、区間外は変えず、冪等', () =>\n withEnv((env) => {\n const head = '# 手書きの計画\\n\\n方針: `code` と a \\\\| b をそのまま残す\\n\\n'\n const tail = '\\n\u003cdetails>\\n\u003csummary>旧 Phase 表\u003c/summary>\\n\\n| Phase 9 | #9 old \\\\| `x` |\\n\u003c/details>\\n'\n const old = [\n PB,\n '| Phase | 親 issue | 直下 open | 総 open |',\n '|-------|----------|-----------|---------|',\n '| Phase 1 | #10 a \\\\| b | 1 | 1 |',\n '',\n '### Phase 1: a \\\\\\\\ b',\n '',\n '| Issue | タイトル | 分解 |',\n '|-------|---------|------|',\n '| #11 | x \\\\| `y` | - |',\n '',\n PE,\n ].join('\\n')\n setRoot(env, head + old + '\\n' + tail)\n setSubs(env, 254, [issue(10, 'a | b', { labels: ['phase:1'], total: 1 })])\n setSubs(env, 10, [issue(11, 'x | `y`')])\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n assert.equal(editCount(env), 1)\n const out = edited(env)\n const block = out.slice(out.indexOf(PB), out.indexOf(PE))\n assert.ok(!/[`\\\\]/.test(block), block)\n assert.ok(block.includes('a | b') && block.includes('x | `y`'), block)\n assert.ok(out.includes(head), out)\n assert.ok(out.includes(tail), out)\n rmSync(join(env.stub, 'edited.body'))\n rmSync(join(env.stub, 'edit.log'))\n setRoot(env, out)\n const r2 = run(env)\n assert.equal(r2.status, 0, r2.stderr)\n assert.match(r2.stdout, /result=unchanged/)\n assert.equal(editCount(env), 0)\n }))\n\ntest('l: CRLF 本文でも見出し・マーカーを認識し内容を保持する', () =>\n withEnv((env) => {\n setRoot(env, HANDWRITTEN.replace(/\\n/g, '\\r\\n'))\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n const out = edited(env)\n assert.ok(out.includes('手書きの概要。判断根拠はここに書く。'))\n assert.ok(out.includes('### 実装ラン (#275)'))\n // 管理範囲外の行は CR を含め逐語で保持する(#545 契約)。\n assert.ok(out.includes('手書きの概要。判断根拠はここに書く。\\r\\n'))\n assert.ok(out.includes('## 運用\\r\\n'))\n assert.match(r.stdout, /migration=phase-plan-archived/)\n }))\n\ntest('l2: マーカーあり CRLF 本文は管理範囲外の CR を失わず、2 回目は unchanged', () =>\n withEnv((env) => {\n const body = ['## 概要', '', '手書き', '', PB, 'old', PE, '', '## 運用', '', '- 手書き bullet', ''].join('\\r\\n')\n setRoot(env, body)\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n const out = edited(env)\n assert.ok(out.includes('手書き\\r\\n'))\n assert.ok(out.includes('- 手書き bullet\\r'))\n assert.equal(count(out, PB), 1)\n }))\n\ntest('x: 子 issue が 0 件のルートでも Phase 表を生成して更新できる', () =>\n withEnv((env) => {\n setRoot(env, '## 概要\\n\\n手書き\\n')\n setSubs(env, 254, [])\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n assert.match(r.stdout, /phases=0/)\n assert.ok(edited(env).includes('| Phase | 親 issue | 直下 open | 総 open |'))\n }))\n\ntest('y: 書き込み長がちょうど 65536 文字なら編集し、65537 文字になる本文は exit 3・edit なし', () => {\n const build = (pad) => {\n const base = ['## 概要', '', 'x'.repeat(pad), ''].join('\\n')\n return base\n }\n // 骨格長を実測して、書き込み長(末尾改行込み)を 65536 / 65537 に合わせる。\n let probeLen\n withEnv((env) => {\n setRoot(env, build(1))\n basicTree(env)\n const r = run(env, [...DEFAULT_ARGS, '--dry-run'])\n assert.equal(r.status, 0, r.stderr)\n // dry-run は printf '%s\\n' で出力する。stdout は result= 行を含むため除去して測る。\n const body = r.stdout.replace(/result=.*\\n$/, '')\n probeLen = [...body].length\n })\n const exact = 65536 - probeLen + 1\n withEnv((env) => {\n setRoot(env, build(exact))\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n assert.equal([...edited(env)].length, 65536)\n })\n withEnv((env) => {\n setRoot(env, build(exact + 1))\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 3, r.stderr)\n assert.equal(editCount(env), 0)\n })\n})\n\ntest('z1: マーカー外の末尾改行を失わない(コマンド置換で落とさない)', () =>\n withEnv((env) => {\n const before = ['## 概要', '', '手書き', '', PB, PE, '', '末尾', ''].join('\\n') + '\\n\\n'\n setRoot(env, before)\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n assert.ok(edited(env).endsWith('末尾\\n\\n\\n'), JSON.stringify(edited(env).slice(-12)))\n }))\n\ntest('z2: 事後確認で取得本文が送信本文と異なれば(欠落・改変)exit 7', () =>\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n basicTree(env)\n // 先頭の granularity 行と管理マーカー数は正しいまま、本文の一部だけが欠けた応答を返す。\n const probe = run(env, [...DEFAULT_ARGS, '--dry-run'])\n assert.equal(probe.status, 0, probe.stderr)\n const full = probe.stdout.replace(/result=.*\\n$/, '')\n const lines = full.split('\\n')\n const idx = lines.findIndex((l) => l === '手書き' || l.startsWith('- '))\n assert.ok(idx > 0)\n lines.splice(idx, 1)\n writeFileSync(join(env.stub, 'verify.override.json'), JSON.stringify({ number: 254, repository_url: REPO_URL, body: lines.join('\\n') }))\n const r = run(env)\n assert.equal(r.status, 7, r.stderr)\n }))\n\ntest('z3: 閉じていないコードフェンスは exit 4・edit なし(管理ブロックを二重追記しない)', () =>\n withEnv((env) => {\n setRoot(env, ['## 概要', '', '```', '## Phase 別実装計画', PB, PE, '', '手書き'].join('\\n'))\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 4, r.stderr)\n assert.equal(editCount(env), 0)\n }))\n\ntest('m: コードフェンス内のマーカー様文字列・見出しは管理範囲として扱わない', () =>\n withEnv((env) => {\n const before = ['## 概要', '', '```', PB, '## Phase 別実装計画', PE, '```', '', '手書き', ''].join('\\n')\n setRoot(env, before)\n basicTree(env)\n const r = run(env)\n assert.equal(r.status, 0, r.stderr)\n const out = edited(env)\n assert.ok(out.includes(['```', PB, '## Phase 別実装計画', PE, '```'].join('\\n')))\n assert.equal(count(out, PB), 2)\n assert.match(r.stdout, /migration=inserted/)\n }))\n\ntest('n: granularity マーカーは重複・途中配置でも先頭 1 行だけになり、値は引数どおり', () =>\n withEnv((env) => {\n const before = ['\u003c!-- granularity: 9h -->', '## 概要', '', '\u003c!-- granularity: 4h -->', '本文', ''].join('\\n')\n setRoot(env, before)\n basicTree(env)\n assert.equal(run(env).status, 0)\n const out = edited(env)\n assert.equal(out.split('\\n')[0], '\u003c!-- granularity: 2h -->')\n assert.equal(out.split('\\n').filter((l) => l.includes('granularity:')).length, 1)\n }))\n\ntest('o: 100 件ちょうどの 1 ページ目と 2 ページ目の全件が表に載る', () =>\n withEnv((env) => {\n setRoot(env, '')\n const p1 = Array.from({ length: 100 }, (_, i) => issue(1000 + i, `t${i}`))\n const p2 = [issue(2000, 'last')]\n setSubs(env, 254, p1, 1)\n setSubs(env, 254, p2, 2)\n assert.equal(run(env).status, 0)\n const out = edited(env)\n assert.ok(out.includes('#1000 t0'))\n assert.ok(out.includes('#1099 t99'))\n assert.ok(out.includes('#2000 last'))\n assert.match(readFileSync(join(env.stub, 'sub.log'), 'utf8'), /sub 254 2/)\n }))\n\ntest('p: 総 open 数は全子孫を数え、summary.total == 0 の節点は API を呼ばない', () =>\n withEnv((env) => {\n setRoot(env, '')\n setSubs(env, 254, [issue(10, 'P', { labels: ['phase:1'], total: 2 })])\n setSubs(env, 10, [issue(11, 'c', { total: 1 }), issue(12, 'leaf', { total: 0 })])\n setSubs(env, 11, [issue(13, 'gc', { total: 1 })])\n setSubs(env, 13, [issue(14, 'ggc')])\n assert.equal(run(env).status, 0)\n const out = edited(env)\n assert.match(out, /\\| Phase 1 \\| #10 P \\| 2 \\| 4 \\|/)\n const log = readFileSync(join(env.stub, 'sub.log'), 'utf8')\n assert.ok(!/sub 12 /.test(log), 'total 0 の #12 を取得してはならない')\n assert.ok(!/sub 14 /.test(log))\n }))\n\ntest('q: closed の親は(closed)表示、phase ラベル無し・複数は Phase 列が - で位置番号を使わない', () =>\n withEnv((env) => {\n setRoot(env, '')\n setSubs(env, 254, [\n issue(10, 'closed 親', { state: 'closed', labels: ['phase:1'] }),\n issue(20, 'ラベル無し'),\n issue(30, '複数', { labels: ['phase:1', 'phase:2'] }),\n issue(40, '不正値', { labels: ['phase:a b'] }),\n ])\n assert.equal(run(env).status, 0)\n const out = edited(env)\n assert.ok(out.includes('| Phase 1 | #10 closed 親(closed) | 0 | 0 |'))\n assert.ok(out.includes('| - | #20 ラベル無し | 0 | 0 |'))\n assert.ok(out.includes('| - | #30 複数 | 0 | 0 |'))\n assert.ok(out.includes('| - | #40 不正値 | 0 | 0 |'))\n }))\n\ntest('r: 別リポジトリの sub-issue は owner/repo#N 表示・再帰しない', () =>\n withEnv((env) => {\n setRoot(env, '')\n setSubs(env, 254, [issue(10, '他リポ', { repo: 'x/y', total: 3 })])\n assert.equal(run(env).status, 0)\n assert.ok(edited(env).includes('x/y#10 他リポ'))\n assert.ok(!existsSync(join(env.stub, 'sub.log')) || !/sub 10 /.test(readFileSync(join(env.stub, 'sub.log'), 'utf8')))\n }))\n\ntest('s: 編集直前の再取得で本文が変わっていれば exit 5・edit なし', () =>\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n basicTree(env)\n const changed = { number: 254, repository_url: REPO_URL, body: `${HANDWRITTEN}\\n並行編集` }\n writeFileSync(join(env.stub, 'root.2.json'), JSON.stringify(changed))\n const r = run(env)\n assert.equal(r.status, 5)\n assert.equal(editCount(env), 0)\n }))\n\ntest('t: --dry-run は本文を stdout へ出し、edit を呼ばない', () =>\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n basicTree(env)\n const r = run(env, [...DEFAULT_ARGS, '--dry-run'])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(editCount(env), 0)\n assert.ok(r.stdout.includes('手書きの概要。判断根拠はここに書く。'))\n assert.match(r.stdout, /result=dry-run/)\n }))\n\ntest('u: 一時ファイルは成功・失敗いずれでも TMPDIR に残らない', () => {\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n basicTree(env)\n assert.equal(run(env).status, 0)\n assert.deepEqual(readdirSync(env.tmp), [])\n })\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n assert.equal(run(env).status, 3)\n assert.deepEqual(readdirSync(env.tmp), [])\n })\n withEnv((env) => {\n setRoot(env, HANDWRITTEN)\n basicTree(env)\n writeFileSync(join(env.stub, 'edit.fail'), '')\n assert.equal(run(env).status, 6)\n assert.deepEqual(readdirSync(env.tmp), [])\n })\n})\n\n// ---- SKILL.md の静的検査・Step 8 フェンスの単体実行 ----\nfunction step8Section() {\n const text = readFileSync(SKILL_MD, 'utf8')\n const start = text.indexOf('### Step 8')\n const end = text.indexOf('### Step 9')\n assert.ok(start >= 0 && end > start)\n return text.slice(start, end)\n}\n\ntest('v: Step 8 はスクリプトを起動し、本文全置換(--body \"$()を持たず、reassign_one を含まない', () => {\n const sec = step8Section()\n assert.ok(sec.includes('update-root-body.sh'))\n assert.ok(!/gh issue edit[^\\n]*--body\\s+\"\\$\\(/.test(sec))\n assert.ok(!/--body\\s+\"\\$\\(/.test(sec))\n assert.ok(!sec.includes('reassign_one'))\n assert.ok(sec.includes(PB) && sec.includes(IB))\n})\n\nfunction step8Fence() {\n const m = /```bash\\n([\\s\\S]*?)```/.exec(step8Section())\n assert.ok(m, 'Step 8 に bash フェンスがある')\n return m[1]\n}\n\ntest('w: Step 8 フェンスは 3 レイアウト解決で起動し、非ゼロ終了が伝播する', () => {\n const dir = mkdtempSync(join(tmpdir(), 'step8-fence-'))\n try {\n const scripts = join(dir, 'skills', 'update-issue-tree', 'scripts')\n mkdirSync(scripts, { recursive: true })\n writeFileSync(join(scripts, 'update-root-body.sh'), '#!/usr/bin/env bash\\necho \"args: $*\"\\nexit \"${STUB_EXIT:-0}\"\\n')\n const fence = step8Fence()\n const base = {\n PATH: process.env.PATH ?? '',\n ROOT_NUMBER: '254',\n GRANULARITY: '2h',\n REASSIGNED_COUNT: '1',\n ORPHAN_COUNT: '2',\n LABEL_SYNC_COUNT: '3',\n NEW_PHASE_COUNT: '0',\n SPLIT_COUNT: '0',\n }\n writeFileSync(join(dir, 'block.sh'), fence)\n const ok = spawnSync('bash', ['block.sh'], { cwd: dir, encoding: 'utf8', env: base })\n assert.equal(ok.status, 0, ok.stderr)\n assert.match(ok.stdout, /--root 254 --granularity 2h --reassigned 1 --orphans 2 --labels 3 --new-phases 0 --split 0/)\n const ng = spawnSync('bash', ['block.sh'], { cwd: dir, encoding: 'utf8', env: { ...base, STUB_EXIT: '5' } })\n assert.equal(ng.status, 5)\n const { ROOT_NUMBER: _omit, ...noRoot } = base\n const miss = spawnSync('bash', ['block.sh'], { cwd: dir, encoding: 'utf8', env: noRoot })\n assert.notEqual(miss.status, 0)\n assert.ok(!miss.stdout.includes('args:'), 'ROOT_NUMBER 未設定なら起動前に停止する')\n } finally {\n rmSync(dir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":29504,"content_sha256":"c8e4ccd440c537a42d6bbbd7489592f87fdea3f9680cdb7687f297e87f894b92"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"update-issue-tree","type":"text"}]},{"type":"paragraph","content":[{"text":"既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"使い方","type":"text"}]},{"type":"paragraph","content":[{"text":"ルートのトラッキング issue 番号を引数として渡す。 ","type":"text"},{"text":"--granularity","type":"text","marks":[{"type":"code_inline"}]},{"text":" オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ","type":"text"},{"text":"2h","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"4h","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"1h","type":"text","marks":[{"type":"code_inline"}]},{"text":" のように正整数+時間単位(","type":"text"},{"text":"h","type":"text","marks":[{"type":"code_inline"}]},{"text":")で指定する。優先順位は ","type":"text"},{"text":"--granularity","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 明示 > ルート issue 本文の ","type":"text","marks":[{"type":"strong"}]},{"text":"\u003c!-- granularity: Nh -->","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" マーカー > 既定 ","type":"text","marks":[{"type":"strong"}]},{"text":"2h","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で ","type":"text"},{"text":"GRANULARITY","type":"text","marks":[{"type":"code_inline"}]},{"text":" として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 ","type":"text"},{"text":"GRANULARITY","type":"text","marks":[{"type":"code_inline"}]},{"text":" の確定値でマーカー行を先頭に 1 行だけ保持する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"update-issue-tree 42\nupdate-issue-tree 42 --granularity 4h","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"前提条件","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"gh","type":"text","marks":[{"type":"code_inline"}]},{"text":" CLI がインストールされ、認証済みであること(","type":"text"},{"text":"gh auth status","type":"text","marks":[{"type":"code_inline"}]},{"text":" で確認)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"対象リポジトリへの Issue 書き込み権限があること","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"対象ツリーは","type":"text"},{"text":"単一リポジトリ内","type":"text","marks":[{"type":"strong"}]},{"text":"で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross-repository sub-issue を","type":"text"},{"text":"対象外","type":"text","marks":[{"type":"strong"}]},{"text":"とする。 親リポジトリへの書き込みは本スキルの前提条件(対象リポジトリへの書き込み権限)の 外側にあり、誤ったリポジトリの同番号 issue を操作する事故を構造的に防ぐため。この契約の実装は ","type":"text"},{"text":"scripts/reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" の ","type":"text"},{"text":"exit 2 による fail-closed","type":"text","marks":[{"type":"strong"}]},{"text":"(下記 Step 3 の終了コード表を参照)。exit 2 は gh/jq 不在・未認証・issue 取得失敗(解消可能な前提不備)とも共有するため、このケースだけ stderr に ","type":"text"},{"text":"reason=cross-repository-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" の安定マーカー行が追加で出る(終了コード表参照)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"対象 issue の親が","type":"text"},{"text":"別リポジトリ","type":"text","marks":[{"type":"strong"}]},{"text":"にある場合、","type":"text"},{"text":"scripts/reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" がその issue を 処理できるのは、親リポジトリ側で親リンクが取り外され ","type":"text"},{"text":"parent_issue_url","type":"text","marks":[{"type":"code_inline"}]},{"text":" が null(どの親にも 紐付いていない)状態になってからである。cross-repository の親リンクの取り外しは ","type":"text"},{"text":"親リポジトリ側の操作","type":"text","marks":[{"type":"strong"}]},{"text":"であり、そのリポジトリへの書き込み権限を持つ担当者が行う。本スキルの範囲外","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"同一リポジトリ内での親の付け替えは本スキルの中核機能であり、上記の null 要求の対象外","type":"text","marks":[{"type":"strong"}]},{"text":"である。 Step 3 は承認済みの旧親を ","type":"text"},{"text":"--old-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" に渡して DELETE→POST を実行するため、事前に ","type":"text"},{"text":"parent_issue_url","type":"text","marks":[{"type":"code_inline"}]},{"text":" を null にしておく必要はない(孤児の再配置は Step 4 が ","type":"text"},{"text":"--old-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" 省略で扱う)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"フロー","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1: ツリー全体を再帰取得する","type":"text"}]},{"type":"paragraph","content":[{"text":"ルート issue から sub_issues API を再帰的に呼び出し、全階層のツリー構造を取得する。","type":"text"},{"type":"br"},{"text":"ページネーションを考慮し、","type":"text"},{"text":"per_page=100","type":"text","marks":[{"type":"code_inline"}]},{"text":" で全件取得する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"ROOT_NUMBER=\"\u003cルート issue 番号>\"\n\n# --granularity の値は代入前に Claude 側でも同じ正規表現(^[1-9][0-9]*h$)で検証し、\n# 不一致なら**このフェンスを実行せず**ユーザーに再指定を求める(未信頼値を生成済みシェルへ\n# 埋め込まない)。優先順位は --granularity > ルートのマーカー > 既定 2h の順。\n# --granularity で渡された時間を実際の値で代入する(実行時に Claude が置き換える)\n# 例: --granularity 4h が指定された場合 → GRANULARITY_ARG=\"4h\" / 未指定の場合は空文字\nGRANULARITY_ARG=\"\u003c--granularity で渡された時間(未指定なら空文字)>\"\n\nif [[ -n \"${GRANULARITY_ARG}\" ]]; then\n GRANULARITY=\"${GRANULARITY_ARG}\"\n GRANULARITY_SOURCE=\"--granularity 引数\"\nelse\n # ルート本文の取得失敗とマーカー不在は分離する。取得失敗は「粒度を既定へ倒さず中止」する\n # (gh issue view 自体の失敗を || true で握り潰すと、権限エラー・issue 不在等を\n # 「マーカーなし」と誤読して既定 2h へサイレントに倒れてしまうため)\n ROOT_BODY=$(gh issue view \"${ROOT_NUMBER}\" --json body --jq '.body') \\\n || { echo \"エラー: ルート issue #${ROOT_NUMBER} の本文を取得できません。粒度を既定へ倒さず中止します。\"; exit 1; }\n # 既存ルート本文の \u003c!-- granularity: Nh --> マーカーから継承する。マーカーが無ければ既定 2h\n # (|| true は grep のマーカー不在にのみ掛かる。gh issue view 自体の失敗は上で既に処理済み)\n ROOT_GRANULARITY=$(printf '%s\\n' \"${ROOT_BODY}\" \\\n | grep -oE '\u003c!-- granularity: [1-9][0-9]*h -->' | head -1 | grep -oE '[1-9][0-9]*h' || true)\n if [[ -n \"${ROOT_GRANULARITY}\" ]]; then\n GRANULARITY=\"${ROOT_GRANULARITY}\"\n GRANULARITY_SOURCE=\"ルート issue #${ROOT_NUMBER} のマーカー\"\n else\n GRANULARITY=\"2h\"\n GRANULARITY_SOURCE=\"既定値\"\n fi\nfi\n\n# 許可する構文は正整数 + h のみ(例: 1h / 2h / 4h)。引用符・空白・コマンド置換・0h・単位なしは\n# ここで拒否する(ROOT_GRANULARITY はマーカー抽出時点で同じ正規表現を通しているが、\n# GRANULARITY_ARG はユーザー入力の生値のため、代入元によらずこの検証を必ず通す)\nif ! printf '%s' \"${GRANULARITY}\" | grep -qE '^[1-9][0-9]*h

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

; then\n echo \"エラー: --granularity の値 '${GRANULARITY}' は不正です(許可: 正整数+h、例 2h)。中止します。\"\n exit 1\nfi\n\n# 確定値と由来を必ず出力する(既定 2h と読み替えないことをここで可視化する)\necho \"粒度基準: ${GRANULARITY}(由来: ${GRANULARITY_SOURCE})\"\n\n# ルート直下の sub-issues を取得(ページネーション対応)\nfetch_sub_issues() {\n local PARENT=\"${1}\"\n local PAGE=1\n while true; do\n RESULT=$(gh api \\\n \"repos/{owner}/{repo}/issues/${PARENT}/sub_issues?per_page=100&page=${PAGE}\")\n echo \"${RESULT}\"\n COUNT=$(echo \"${RESULT}\" | jq 'length')\n if [ \"${COUNT}\" -lt 100 ]; then break; fi\n PAGE=$((PAGE + 1))\n done\n}\n\n# ルートから再帰的にツリーを構築\nfetch_sub_issues \"${ROOT_NUMBER}\"","type":"text"}]},{"type":"paragraph","content":[{"text":"以降の Step は Step 1 で出力された ","type":"text"},{"text":"GRANULARITY","type":"text","marks":[{"type":"code_inline"}]},{"text":" の値を使う(既定 2h と読み替えない)。","type":"text"}]},{"type":"paragraph","content":[{"text":"各 issue の ","type":"text"},{"text":"state","type":"text","marks":[{"type":"code_inline"}]},{"text":"(open / closed)・ラベル・タイトルを記録してツリーマップを作成する。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2: 棚卸し対象を特定する","type":"text"}]},{"type":"paragraph","content":[{"text":"取得したツリーマップを分析し、以下のケースを特定する。","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ケース","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"対応方針","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"closed 親の下に open issue が残置されている","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"適切な open Phase 親へ付け替え","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"どの親にも紐付いていない孤児 issue がある","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"該当 Phase 親へ紐付け(Phase が不明な場合はユーザーに確認)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"phase ラベルが親と一致しない issue がある","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ラベルを同期","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"既存 Phase に収まらない新規タスクがある","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"新 Phase 親の新設を検討","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"実装時間が ","type":"text"},{"text":"${GRANULARITY}","type":"text","marks":[{"type":"code_inline"}]},{"text":"(Step 1 で確定。既定 2h)超の issue が分解されていない","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sub-issue に分解(create-issue-tree と同じ粒度基準)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"対象 issue の親が別リポジトリにある(cross-repository sub-issue)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"本スキルの対象外。棚卸し対象から除外し、Step 9 の要確認事項へ記載する","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"owner:human","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"owner:joint","type":"text","marks":[{"type":"code_inline"}]},{"text":" ラベル付きの open issue がある","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"一覧化して Step 9 の要確認事項へ記載するだけ。ラベルは自動で付け外ししない(人手の判断を機械が書き換えない)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"棚卸し対象の一覧をユーザーに提示し、方針確認を取ってから変更を実行する。","type":"text"}]},{"type":"paragraph","content":[{"text":"owner ラベル付き issue は、Step 1 のツリーマップ(sub_issues API の応答の ","type":"text"},{"text":"labels","type":"text","marks":[{"type":"code_inline"}]},{"text":")に記録したラベルからだけ抽出する。 対象は Step 1 のツリーに含まれる番号に限る(リポジトリ全体の ","type":"text"},{"text":"gh issue list","type":"text","marks":[{"type":"code_inline"}]},{"text":" は 30 件上限で別ツリーも混ざるため使わない)。 ラベルが不明な番号だけ ","type":"text"},{"text":"gh issue view \"${N}\" --json number,labels","type":"text","marks":[{"type":"code_inline"}]},{"text":" で取得し、gh のエラーは stdout に出るため ","type":"text"},{"text":"終了コード","type":"text","marks":[{"type":"strong"}]},{"text":"で成否を判定する(失敗を「該当なし」と読まない)。 これらの issue は ","type":"text"},{"text":"implement-issue-tree","type":"text","marks":[{"type":"code_inline"}]},{"text":" を ","type":"text"},{"text":"skipLabels: [\"owner:human\",\"owner:joint\"]","type":"text","marks":[{"type":"code_inline"}]},{"text":" 付きで呼ぶと自動実装の対象外になる。","type":"text"}]},{"type":"paragraph","content":[{"text":"承認された対象を Step 3 / Step 4 が読む 2 つの計画配列へ落とす。","type":"text","marks":[{"type":"strong"}]},{"text":" 配列は必ず宣言する (対象 0 件でも空配列として宣言する)。未宣言のまま Step 3 / Step 4 へ進むと ","type":"text"},{"text":"\"${REASSIGN_PLAN[@]}\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" が空展開され、承認済みの対象があってもエラーなく 0 件で完走して 完了報告まで進んでしまうため、両ステップの冒頭で宣言の有無を検査して fail-closed で停止する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 承認された「closed 親下の付け替え」対象。要素は \"\u003cissue> \u003cold-parent> \u003cnew-parent>\"。\n# 空白区切りの 1 行 1 件にするのは、Step 3 のループが read で分割して受け取るため。\n# issue 番号は全て 10 進の正整数であり、空白・改行を含まないことを Step 1 の取得時に保証する。\nREASSIGN_PLAN=(\n # \"123 456 789\"\n)\n\n# 承認された「孤児の再配置」対象。要素は \"\u003corphan-issue> \u003cphase-parent>\"。\nORPHAN_PLAN=(\n # \"234 789\"\n)","type":"text"}]},{"type":"paragraph","content":[{"text":"対象が 0 件の場合も上記のとおり","type":"text"},{"text":"空配列として宣言する","type":"text","marks":[{"type":"strong"}]},{"text":"。「対象なし(空配列)」と 「計画未設定(未宣言)」は意味が異なり、後者は Step 2 の実行漏れであって正常系ではない。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 3: closed 親下の残置 open issue を付け替える","type":"text"}]},{"type":"paragraph","content":[{"text":"closed 親の下に残置されている open issue を、対応する open Phase 親へ移動する。 「旧親から DELETE → 新親へ POST」の 2 段操作と、その前後の冪等性判定・事後確認は ","type":"text"},{"text":"scripts/reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" に集約されている(SKILL.md 本文に素の ","type":"text"},{"text":"gh api","type":"text","marks":[{"type":"code_inline"}]},{"text":" を並べると、 DELETE 失敗検知なしに POST へ進む等の欠陥を生むため)。 その呼び出し側で必要な共有処理(計画配列の存在・型検証、1 件呼び出しの終了ステータス解釈)は ","type":"text"},{"text":"scripts/reassign-lib.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" に切り出してあり、Step 3 / Step 4 がそれぞれ source して使う。","type":"text"}]},{"type":"paragraph","content":[{"text":"各コードフェンスは独立したシェルで実行され得る","type":"text","marks":[{"type":"strong"}]},{"text":"(","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" の設計前提と同じ)。 そのため共有処理をどちらかのフェンス内で定義して他方が再利用する構成にはしない。Step 4 の 実行時に関数定義が継承されず ","type":"text"},{"text":"command not found","type":"text","marks":[{"type":"code_inline"}]},{"text":" となり、孤児の再配置が一切実行されないため。両 Step のフェンスは同一の前置きで ","type":"text"},{"text":"reassign-lib.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を自己完結的に解決・source する。","type":"text"}]},{"type":"paragraph","content":[{"text":"このスキルの配置ルートは導入形態(本リポジトリのソース/","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" による vendoring/","type":"text"},{"text":".claude/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" symlink 経由)で異なる。source 前に 3 レイアウトを順に確認し、 実在するものを採用する(implement-issue-tree の ","type":"text"},{"text":"scriptPath","type":"text","marks":[{"type":"code_inline"}]},{"text":" 3 レイアウト・contribute-skill の ","type":"text"},{"text":"LOCAL_SKILL_DIR","type":"text","marks":[{"type":"code_inline"}]},{"text":" 解決と同じ考え方)。","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" のパスはここでは指定しない—— ライブラリが自身の実体位置から兄弟として解決するため、どの導入形態でも対の実体が必ず組み合う。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# reassign-lib.sh を 3 レイアウトから解決して source する。\n# **この前置きは Step 3 / Step 4 の両フェンスが同一の内容で持つ。** 手順書のコードフェンスは\n# ブロックごとに独立シェルで実行され得るため、片方のフェンスで定義した関数・変数がもう一方へ\n# 継承される保証がない(Issue #372 / PR #374 codex-review P1)。\nREASSIGN_LIB=\"\"\nfor CANDIDATE in \\\n \"skills/update-issue-tree/scripts/reassign-lib.sh\" \\\n \".agents/skills/update-issue-tree/scripts/reassign-lib.sh\" \\\n \".claude/skills/update-issue-tree/scripts/reassign-lib.sh\"; do\n # 存在確認は -f のみで行う(-x にすると、npx skills add 等の vendoring で\n # 実行ビットが落ちたファイルを「存在しない」と誤検知し、3 レイアウトいずれにも\n # 見つからないという誤ったエラーメッセージになる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n REASSIGN_LIB=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${REASSIGN_LIB}\" ]]; then\n echo \"エラー: reassign-lib.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n# ライブラリは対の reassign-sub-issue.sh の存在まで確認したうえで、reassign_one /\n# require_plan_array と REASSIGN_SCRIPT を定義する。欠落していれば非ゼロを返すので中断する。\n# このフェンスで set -euo pipefail を使ってはならない(理由は reassign-lib.sh 冒頭のコメント参照。\n# set -e があると reassign_one 内の `status=$?` 退避に到達せず、Issue #335 の欠陥が再発する)\nsource \"${REASSIGN_LIB}\" || exit 1\n\n# 計画配列の存在・型ガード(fail-closed)。詳細は reassign-lib.sh の require_plan_array を参照\nrequire_plan_array REASSIGN_PLAN || exit 1\n\n# 呼び出し側ループ。契約の主体はここにある——「(b) 恒久的な対象外は次の 1 件へ進み、\n# (a) 解消可能な前提不備は原因解消まで中断する」を実際に実現するのはこのループである。\n# ISSUE_NUMBER / OLD_PARENT / NEW_PARENT は Step 2 で構築した REASSIGN_PLAN から供給する。\nSKIPPED=()\nNEEDS_REVIEW=()\nfor ENTRY in \"${REASSIGN_PLAN[@]}\"; do\n # ENTRY は \"\u003cissue> \u003cold-parent> \u003cnew-parent>\" 形式。\n # zsh は未クォートの展開を単語分割しないため、read で明示的に分割する\n IFS=' ' read -r ISSUE_NUMBER OLD_PARENT NEW_PARENT \u003c\u003c\u003c \"${ENTRY}\"\n # `if reassign_one ...; then ... fi` の形にしてはならない。条件が偽で else が無い場合、\n # `fi` の直後の $? は 0 になり(実測済み)、失敗が「成功」として読まれて\n # (a)/(b)/(c) の判定も中断もすべて素通りする。`|| status=$?` で明示的に退避する。\n status=0\n reassign_one --issue \"${ISSUE_NUMBER}\" \\\n --old-parent \"${OLD_PARENT}\" \\\n --new-parent \"${NEW_PARENT}\" || status=$?\n if (( status == 0 )); then\n continue\n fi\n if (( status == 9 )); then\n # (b) cross-repository 親。棚卸し対象から除外して次へ(Step 9 の要確認事項へ記載)\n SKIPPED+=(\"${ISSUE_NUMBER}\")\n continue\n fi\n if (( status == 10 )); then\n # (c) 補償復旧が成功し旧親へ復帰済み(本来の付け替えは未達)。fatal にせず要確認事項へ\n # 記録して次の 1 件へ進む。exit 10 を中断扱いにすると残りの REASSIGN_PLAN が未処理のまま\n # 中断し、Step 4 の孤児処理・Step 9 の報告経路自体がスキップされてしまう\n NEEDS_REVIEW+=(\"#${ISSUE_NUMBER}: exit=10 restored — 旧親 #${OLD_PARENT} へ復帰済み。新親 #${NEW_PARENT} への付け替えは未達\")\n continue\n fi\n if (( status == 11 )); then\n # (c) DELETE 後、第三者が別親を設定済みのため補償せず見送り。同一コマンドでの再実行禁止を\n # 記録に残したうえで、fatal にせず次の 1 件へ進む\n NEEDS_REVIEW+=(\"#${ISSUE_NUMBER}: exit=11 third-party-parent — 旧親 #${OLD_PARENT} から DELETE 済み、第三者が別親を設定済み。同じコマンドで再実行禁止。stderr の実測親を確認しユーザー承認のうえ再実行すること\")\n continue\n fi\n # (a) それ以外(exit 1-8。9・10・11 を除く)は握り潰さず中断する。原因を解消してから再実行する\n echo \"エラー: #${ISSUE_NUMBER} の付け替えが exit ${status} で失敗した。中断する\" >&2\n exit \"${status}\"\ndone\nif (( ${#SKIPPED[@]} > 0 )); then\n echo \"対象外(cross-repository 親): ${SKIPPED[*]}\" >&2\nfi\nif (( ${#NEEDS_REVIEW[@]} > 0 )); then\n echo \"要確認事項(Step 9 のレポートへ転記すること):\" >&2\n printf ' %s\\n' \"${NEEDS_REVIEW[@]}\" >&2\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"reassign_one","type":"text","marks":[{"type":"code_inline"}]},{"text":" は","type":"text"},{"text":"1 件分の呼び出し","type":"text","marks":[{"type":"strong"}]},{"text":"であり、非ゼロ終了は","type":"text"},{"text":"当該 1 件の失敗","type":"text","marks":[{"type":"strong"}]},{"text":"として 関数の返り値へ伝播する。判定に必要な stderr は関数内で捕捉したうえで必ず再出力するため、 診断情報は失われない。呼び出し側ループは Step 9 の要確認事項へ記録したうえで、次の 3 通りに 振り分ける。","type":"text"},{"text":"(a) 解消可能な前提不備","type":"text","marks":[{"type":"strong"}]},{"text":"(exit 2 のうち ","type":"text"},{"text":"gh","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"jq","type":"text","marks":[{"type":"code_inline"}]},{"text":" 不在・未認証・issue 取得失敗等。 9・10・11 を除く exit 1〜8 全般)は原因解消まで中断する。","type":"text"},{"text":"(b) 恒久的な対象外","type":"text","marks":[{"type":"strong"}]},{"text":" (cross-repository 親。exit 9)は要確認事項へ記載して棚卸し対象から除外し、次の 1 件の 呼び出しへ進む。","type":"text"},{"text":"(c) 部分的に変更済みだが処理は継続してよいもの","type":"text","marks":[{"type":"strong"}]},{"text":"(exit 10 = DELETE 後の 補償復旧が成功し旧親へ復帰済み・exit 11 = DELETE 後に第三者が別親を設定済みで補償を見送り)は 要確認事項へ記録したうえで、fatal にせず次の 1 件へ進む。exit 10 / 11 を即 ","type":"text"},{"text":"exit","type":"text","marks":[{"type":"code_inline"}]},{"text":" で中断すると 残りの ","type":"text"},{"text":"REASSIGN_PLAN","type":"text","marks":[{"type":"code_inline"}]},{"text":" が未処理のまま止まり、Step 4 の孤児処理・Step 9 の報告経路自体が スキップされる欠陥になるため、この 2 コードはループを止めない(終了コード表を参照)。 (a)/(b) の判定は stderr に ","type":"text"},{"text":"reason=cross-repository-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" が出ているかで機械的に行う (無ければ (a))。(c) は返り値がそのまま 10 / 11 であるため、判定に stderr のマーカーは不要。","type":"text"}]},{"type":"paragraph","content":[{"text":"(c) だけを継続対象にする理由(exit 3/4/5/7/8 は継続しない)","type":"text","marks":[{"type":"strong"}]},{"text":": 終了コード表を見ると exit 3・4・5・7・8 も「呼び出し側の扱い」欄に「要確認事項へ記載」と書かれており、一見 (c) と同じに見える。しかし止める・止めないの分岐点は「要確認事項へ記載するか」ではなく ","type":"text"},{"text":"当該 1 件の終端状態が実測で確定しているか","type":"text","marks":[{"type":"strong"}]},{"text":"である。exit 10 / 11 は補償復旧ルーチンが 実状態を再取得した末に「旧親配下」「第三者親配下」という","type":"text"},{"text":"確定した終端状態","type":"text","marks":[{"type":"strong"}]},{"text":"へ着地しており、 次の 1 件へ進んでも新たな破壊は生じない。対して exit 3(DELETE 失敗)・4(POST 失敗)・ 5(事後確認不一致)・7(POST 時点のレース)・8(補償も失敗し状態不明/孤児)は、無変更・ 孤児化・状態不明のいずれかであり、原因(","type":"text"},{"text":"gh","type":"text","marks":[{"type":"code_inline"}]},{"text":" 認証切れ・API 障害・レース条件)が ","type":"text"},{"text":"後続の全件に共通して波及する可能性が高い","type":"text","marks":[{"type":"strong"}]},{"text":"ため、1 件で切り上げて原因究明を優先する。 この境界線は exit 10 / 11 に限定したものであり、3/4/5/7/8 へ安易に拡張しない。","type":"text"}]},{"type":"paragraph","content":[{"text":"引数","type":"text","marks":[{"type":"strong"}]}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"引数","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"必須","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"意味","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--issue","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"必須","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"付け替え対象の issue 番号","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--new-parent","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"必須","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"付け替え先の issue 番号。DELETE の前に新親を GET し、存在すること・","type":"text"},{"text":"--issue","type":"text","marks":[{"type":"code_inline"}]},{"text":" 自身でないこと(自己参照)・対象 issue と同一リポジトリにあることを検証する。いずれかを満たさない場合は","type":"text"},{"text":"DELETE を 1 件も撃たずに","type":"text","marks":[{"type":"strong"}]},{"text":" exit 1(自己参照)/ exit 2(存在しない・別リポジトリ)で無変更終端する","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--old-parent","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"任意","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"現在の親。Step 2 でユーザーが承認した旧親を渡す。実測した現在の親と食い違う場合は","type":"text"},{"text":"何も変更せず exit 6 で停止","type":"text","marks":[{"type":"strong"}]},{"text":"する(承認外の親子関係を壊さないため)。省略時は「孤児である」ことを承認した意味になり、実測で親が居れば同じく exit 6 で停止する","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--repo","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"任意","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"owner/name","type":"text","marks":[{"type":"code_inline"}]},{"text":"。","type":"text"},{"text":"対象 issue(","type":"text","marks":[{"type":"strong"}]},{"text":"--issue","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")の所在","type":"text","marks":[{"type":"strong"}]},{"text":"であり、親(","type":"text"},{"text":"--old-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"--new-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":")の所在ではない。省略時は cwd の git remote から解決。","type":"text"},{"text":"--repo","type":"text","marks":[{"type":"code_inline"}]},{"text":" は対象 issue の GET だけでなく DELETE / POST を含む全 API パスを切り替えるため、親が別リポジトリにあるからといって親リポジトリの値を入れてはならない(入れると親リポジトリ側の同番号 issue を誤操作する)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"終了コードと ","type":"text","marks":[{"type":"strong"}]},{"text":"result=","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 行","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"stdout 最終行が ","type":"text"},{"text":"result=\u003cstate> issue=\u003cn> new_parent=\u003cn> old_parent=\u003cn|->","type":"text","marks":[{"type":"code_inline"}]},{"text":" の形式で 機械可読な内訳を返す。","type":"text"},{"text":"非ゼロ終了は 1 件も握り潰さず、Step 9 の完了レポートの 「要確認事項」へ必ず記載する。","type":"text","marks":[{"type":"strong"}]}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"終了コード","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"state","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"意味","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"呼び出し側の扱い","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"変更の有無","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"reassigned","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DELETE→POST を実施","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"「付け替え」件数へ計上","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"変更あり(成功)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"already-attached","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"既に新親配下(no-op)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"件数へ計上しない","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"無変更(no-op)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"posted-only","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"旧親配下になく POST のみ","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"「孤児の再配置」件数へ計上(Step 4 と同一スクリプト)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"変更あり(POST のみ)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"引数・使い方エラー(","type":"text"},{"text":"--new-parent","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" の自己参照を含む","type":"text","marks":[{"type":"strong"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"実行者の誤り。修正して再実行","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"無変更","type":"text","marks":[{"type":"strong"}]},{"text":"(API 未実行)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"前提不備。2 類型が混在し、","type":"text"},{"text":"stderr のマーカー行で機械的に判定する","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"reason=cross-repository-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" が出ていれば (b)、無ければ (a)。(a) 解消して再実行できるもの(","type":"text"},{"text":"gh","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"jq","type":"text","marks":[{"type":"code_inline"}]},{"text":" 不在・未認証・issue 取得失敗・","type":"text"},{"text":"新親が存在しない","type":"text","marks":[{"type":"strong"}]},{"text":"・","type":"text"},{"text":"新親が Pull Request","type":"text","marks":[{"type":"strong"}]},{"text":"・","type":"text"},{"text":"新親が別リポジトリ","type":"text","marks":[{"type":"strong"}]},{"text":")と、(b) ","type":"text"},{"text":"恒久的に対象外","type":"text","marks":[{"type":"strong"}]},{"text":"の cross-repository 親(同一コマンドの再実行では解決しない。親リポジトリ側で親リンクが外れるまで本スキルでは処理できない)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"(a) は原因を解消して再実行。(b) は要確認事項へ記載し、棚卸し対象から除外する(Step 2 参照)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"無変更","type":"text","marks":[{"type":"strong"}]},{"text":"(DELETE / POST 未実行)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DELETE 失敗(gh の非ゼロ終了)。","type":"text"},{"text":"POST は実行していない。","type":"text","marks":[{"type":"strong"}]},{"text":" DELETE の応答だけを信頼せず実状態を再取得・安定確認したうえで、実測でも旧親配下のままであることを確認して初めて確定する(応答取得だけが失敗しサーバー側では成立していた場合は孤児として POST 工程へ進むため、この exit には到達しない)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"要確認事項へ記載。旧親配下のまま","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"無変更","type":"text","marks":[{"type":"strong"}]},{"text":"(実測で確認済み。DELETE 失敗・旧親配下のまま)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"POST 失敗(","type":"text"},{"text":"孤児経路のみ","type":"text","marks":[{"type":"strong"}]},{"text":"。DELETE を伴わないため孤児化リスク自体は無い)。POST の応答だけを信頼せず実状態を再取得・安定確認したうえで、実測でも孤児のままであることを確認して初めて確定する(応答取得だけが失敗しサーバー側では成立していた場合は ","type":"text"},{"text":"posted-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" として exit 0 になるため、この exit には到達しない)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"要確認事項へ記載","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"無変更","type":"text","marks":[{"type":"strong"}]},{"text":"(実測で確認済み。DELETE 未実行)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"事後確認で新親配下に見つからない(別リポジトリの親配下にある場合を含む)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"要確認事項へ記載。手動で実状態を確認","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"変更あり(DELETE / POST は実行済み・実状態の手動確認が必要)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"承認された旧親と実測が食い違う。何も変更していない","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"要確認事項へ記載。","type":"text"},{"text":"同じコマンドで再実行してはならない。","type":"text","marks":[{"type":"strong"}]},{"text":" stderr が示す実測の親をユーザーへ提示して承認を得たうえで、","type":"text"},{"text":"--old-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" にその値を入れて再実行する","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"無変更","type":"text","marks":[{"type":"strong"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"7","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"POST 時点で別の親が付いていたレース。","type":"text"},{"text":"DELETE 未実行のため無変更","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"要確認事項へ記載。実測し直して承認を取り直したうえで再実行する","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"無変更","type":"text","marks":[{"type":"strong"}]},{"text":"(DELETE 未実行)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"8","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"次の 3 系統いずれかの実状態再取得・安定確認が","type":"text"},{"text":"孤児のまま/状態不明/第三者の親配下で終端","type":"text","marks":[{"type":"strong"}]},{"text":"した: (1) DELETE 後の POST 失敗に対する補償復旧(補償 POST 自体の失敗・復旧用の再取得失敗・補償後の検証不一致・反映遅延の再確認不一致のいずれか。","type":"text"},{"text":"reason=compensation-post-failed","type":"text","marks":[{"type":"code_inline"}]},{"text":"(実測でも孤児)/ ","type":"text"},{"text":"reason=compensation-post-failed-third-party-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":"(補償 POST 失敗中に第三者が別の親を設定済みと実測)で内訳を判別)、(2) DELETE 失敗時の実状態再取得(DELETE はエラー応答だったがサーバー側で成立していた可能性があるため、応答だけで exit 3 と断定せず再取得した結果、再取得自体の失敗・不一致・不安定だった)、(3) 孤児経路の POST 失敗時の実状態再取得(POST はエラー応答だったがサーバー側で成立していた可能性があるため、応答だけで exit 4 と断定せず再取得した結果、再取得自体の失敗・不一致・不安定だった)。(2)(3) はいずれも ","type":"text"},{"text":"reason=recovery-state-unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":" を出す。(1) の再取得系失敗も同じ ","type":"text"},{"text":"reason=recovery-state-unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":" を出す","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"要確認事項へ記載。","type":"text"},{"text":"いずれも無変更ではない(実測で確定できなかった状態不明)。","type":"text","marks":[{"type":"strong"}]},{"text":" 実状態を確認し必要なら手で紐付け直す。同一コマンドの再実行では回復しない","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"(1) ","type":"text"},{"text":"部分変更","type":"text","marks":[{"type":"strong"}]},{"text":"(旧親から外れ、新親にも付いていない。第三者配下の場合を除く)。(2)(3) ","type":"text"},{"text":"状態不明","type":"text","marks":[{"type":"strong"}]},{"text":"(DELETE / POST がサーバー側で成立していたか未確定のため、無変更とも部分変更とも断定しない)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"10","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"restored","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DELETE 後の POST 失敗に対する補償復旧が","type":"text"},{"text":"成功","type":"text","marks":[{"type":"strong"}]},{"text":"し、実測で旧親配下へ復帰した(本来の目的だった新親への付け替えは未達)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"要確認事項へ記載(本来の付け替えが未達のため)。件数へは計上しない","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"変更あり","type":"text","marks":[{"type":"strong"}]},{"text":"(旧親へ復帰。新親への付け替えは不成立)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"11","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"実状態を再取得したところ、","type":"text"},{"text":"第三者が別の親を設定済み","type":"text","marks":[{"type":"strong"}]},{"text":"だったため補償せず見送った(","type":"text"},{"text":"reason=third-party-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":")。次のいずれかから到達する: (a) DELETE 後の POST 失敗を受けての再取得、(b) DELETE 失敗そのものを受けての再取得(DELETE の応答だけで exit 3 と断定せず再取得した結果、第三者の親配下と判明した場合)。","type":"text"},{"text":"新親配下の場合はこの判定より先に成功経路(exit 0 / result=reassigned)へ倒す","type":"text","marks":[{"type":"strong"}]},{"text":"ため、この exit 11 に到達するのは実測が旧親でも新親でもない別の親だった場合に限る","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"要確認事項へ記載。","type":"text"},{"text":"同じコマンドで再実行してはならない。","type":"text","marks":[{"type":"strong"}]},{"text":" stderr が示す実測の親をユーザーへ提示して承認を得たうえで再実行する","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"変更あり","type":"text","marks":[{"type":"strong"}]},{"text":"(旧親からは既に DELETE 済み。","type":"text"},{"text":"「補償 POST を撃たず fail-closed で停止」は新親への POST 側の話であり、DELETE は exit 11 に到達する時点で既に成立している(第三者の親配下に見えている以上、サーバー側のデータモデル上、旧親からは既に外れている)。","type":"text","marks":[{"type":"strong"}]},{"text":" 実測では第三者が設定した別親配下にある)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"スクリプト自身は 0〜8・10・11 を返す(9 は使わない)。","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"reassign-lib.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" の ","type":"text"},{"text":"reassign_one","type":"text","marks":[{"type":"code_inline"}]},{"text":" ラッパは、これに加えて ","type":"text"},{"text":"9 = 恒久的に対象外(cross-repository 親)","type":"text","marks":[{"type":"strong"}]},{"text":" を返す。9 はスクリプトの終了コードではなく、ラッパが stderr の ","type":"text"},{"text":"reason=cross-repository-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" マーカーを判定して合成する値であり、呼び出し側ループが 「棚卸し対象から除外して次の 1 件へ進む」のシグナルとして使う。マーカーの判定には スクリプトの stderr が必要なため、ラッパは stderr をファイルへ捕捉したうえで","type":"text"},{"text":"必ず再出力する","type":"text","marks":[{"type":"strong"}]},{"text":" (診断情報は握り潰さない)。10・11 はラッパの「その他はスクリプトの終了コードをそのまま返す」 契約により無加工で透過するため、","type":"text"},{"text":"reassign-lib.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" 側の変更は不要。","type":"text"}]},{"type":"paragraph","content":[{"text":"DELETE 後の POST 失敗時の補償復旧の契約","type":"text","marks":[{"type":"strong"}]},{"text":": 新親の事前検証は「事前に判定できる 拒否条件」を DELETE 前に潰すが、DELETE と POST の間のレース・一時的な 5xx は事前判定できない 残余として残る。POST 失敗で即終端すると対象 issue が孤児のまま滞留するため、POST 失敗を 検知した時点で","type":"text"},{"text":"対象 issue の実状態を再取得","type":"text","marks":[{"type":"strong"}]},{"text":"してから分岐する(実測を先に確定させてから動く):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"実測の親が新親 → POST は偽陰性だった。通常の成功終端(","type":"text"},{"text":"exit 0","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"result=reassigned","type":"text","marks":[{"type":"code_inline"}]},{"text":")と同じ扱い。 ただし旧親・孤児を期待した再確認の途中で新親を初めて観測した場合は、新親を期待値として もう 1 回取得し、2 回連続で新親配下が観測できたときにだけ ","type":"text"},{"text":"reassigned","type":"text","marks":[{"type":"code_inline"}]},{"text":" を確定する。 一致しない・再取得に失敗した場合は成功へ倒さず ","type":"text"},{"text":"exit 8","type":"text","marks":[{"type":"code_inline"}]},{"text":"(状態不明)で終端する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"実測で孤児 → 1 回の読み取りだけでは DELETE/POST の反映遅延による過渡状態を見ている 可能性を排除できないため、補償 POST という書き込みを行う前に短い間隔を空けて再取得し、 2 回連続で親なしが観測できて初めて安定した孤児として確定する。安定確認できれば、承認済み操作の逆操作 (旧親へ戻す)は承認範囲内のため補償 POST を撃つ。成功なら事後確認を取り直し ","type":"text"},{"text":"exit 10","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"result=restored","type":"text","marks":[{"type":"code_inline"}]},{"text":"。補償 POST 自体が失敗する多重障害、または再取得・事後確認・ 安定確認が失敗する状態不明ケースは書き込まずに ","type":"text"},{"text":"exit 8","type":"text","marks":[{"type":"code_inline"}]},{"text":" で終端する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"実測で第三者が別の親(別リポジトリを含む)を設定済み → ","type":"text"},{"text":"補償 POST を撃たず","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"exit 11","type":"text","marks":[{"type":"code_inline"}]},{"text":" で fail-closed 停止する。第三者が確定させた親子関係を上書きしない","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"DELETE 失敗・孤児経路 POST 失敗の実状態再確認","type":"text","marks":[{"type":"strong"}]},{"text":": 上記は DELETE 成功後の POST 失敗(部分変更)に対する補償復旧だが、DELETE 自体の失敗(","type":"text"},{"text":"reason","type":"text","marks":[{"type":"code_inline"}]},{"text":" 系マーカーを持たない gh の非ゼロ終了)と孤児経路(DELETE を伴わない)の POST 失敗も同様に扱う。 ","type":"text"},{"text":"gh api","type":"text","marks":[{"type":"code_inline"}]},{"text":" は終了コード 0 で返しても応答取得だけが失敗することがある一方、非ゼロ終了は 「サーバー側で処理が成立しなかった」ことを保証しない(応答取得側の失敗も非ゼロになり得る)。 このため DELETE / POST の非ゼロ終了だけを根拠に exit 3 / exit 4(無変更)と即断すると、 実際にはサーバー側で成立していた場合に誤報になる。 対称に実状態を再取得してから分岐する:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"DELETE 失敗","type":"text","marks":[{"type":"strong"}]},{"text":" → 実状態を再取得し、旧親配下のまま安定確認できれば ","type":"text"},{"text":"exit 3","type":"text","marks":[{"type":"code_inline"}]},{"text":" (無変更)。孤児であることを安定確認できれば DELETE はサーバー側で成立済みとみなし、 そのまま POST 工程(新親への付け替え)へ進む。","type":"text"},{"text":"新親配下であることを安定確認できれば、 第三者親判定より先に判定し","type":"text","marks":[{"type":"strong"}]},{"text":"、DELETE→POST が既に成立していた(または並行実行が承認済みの 新親へ既に付け替え済みだった)とみなして既存の成功経路と同じ ","type":"text"},{"text":"result=reassigned","type":"text","marks":[{"type":"code_inline"}]},{"text":" で ","type":"text"},{"text":"exit 0","type":"text","marks":[{"type":"code_inline"}]},{"text":" にする(最初の読み取りで直接新親を観測した場合も、安定確認の途中で新親を観測した 場合と同じ成功終端にそろえ、観測タイミングだけで承認外の第三者親(","type":"text"},{"text":"exit 11","type":"text","marks":[{"type":"code_inline"}]},{"text":")へ倒れないようにする)。それ以外で第三者が別の親を設定済みなら ","type":"text"},{"text":"exit 11","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"reason=third-party-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":"(既存の補償復旧経路と同じ扱い)。再取得の失敗・ 不一致・不安定は ","type":"text"},{"text":"exit 8","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"reason=recovery-state-unknown","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"孤児経路(DELETE なし)の POST 失敗","type":"text","marks":[{"type":"strong"}]},{"text":" → 実状態を再取得し、孤児のまま安定確認できれば ","type":"text"},{"text":"exit 4","type":"text","marks":[{"type":"code_inline"}]},{"text":"(無変更)。新親配下であることを安定確認できれば POST は偽陰性だった とみなし ","type":"text"},{"text":"result=posted-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" で ","type":"text"},{"text":"exit 0","type":"text","marks":[{"type":"code_inline"}]},{"text":"。この経路には DELETE が無く孤児化リスク自体が 無いため、補償の書き込み(POST)は発生しない(判定のみで完結する)。エラーメッセージ ベースのレース検知(","type":"text"},{"text":"exit 7","type":"text","marks":[{"type":"code_inline"}]},{"text":"。\"only have one parent\")は再取得より先に判定する。それ以外 (再取得の失敗・不一致・不安定・メッセージ非該当の第三者親)は ","type":"text"},{"text":"exit 8","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"reason=recovery-state-unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":" で終端する","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"事前検証と事後報告の契約","type":"text","marks":[{"type":"strong"}]},{"text":": 事前に判定できる拒否条件(新親の不存在・自己参照・ 別リポジトリ)は DELETE を撃たずに exit 1 / 2 で無変更終端する。一方、","type":"text"},{"text":"事前に判定できない 拒否条件(事前 GET と DELETE / POST の間に第三者が状態を動かすレース、循環参照、新親が sub-issue を受け付けない状態など)は exit 4 / exit 7 / exit 8 として事後に報告する","type":"text","marks":[{"type":"strong"}]},{"text":" 契約であり、事前検証はこれを置き換えない。DELETE 後の POST 失敗(孤児化しうる部分変更) だけは exit での即報告ではなく、上記の補償復旧ルーチンを経由してから exit 8 / 10 / 11 の いずれかへ着地する。","type":"text"}]},{"type":"paragraph","content":[{"text":"GET 回数","type":"text","marks":[{"type":"strong"}]},{"text":": 経路ごとの GET 回数は次のとおり(付け替え・再配置の経路は新親の事前検証の 1 回を含む)。","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"経路","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GET 回数","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"正常な付け替え(reassigned)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"孤児の再配置(posted-only)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"already-attached(no-op)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"承認不一致(exit 6)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"事前検証の GET は最大 1 回で、","type":"text"},{"text":"update-issue-tree","type":"text","marks":[{"type":"code_inline"}]},{"text":" の 1 ラン当たりの呼び出し回数は数十件規模。 認証済み REST の 5,000 req/h に対して無視できる。対して防ぐのは","type":"text"},{"text":"不可逆な孤児化","type":"text","marks":[{"type":"strong"}]},{"text":"であり、 割に合う。孤児経路(DELETE を伴わず孤児化リスク自体は無い経路)にも検証を通しているのは、 「事前に判定できる拒否条件は必ず無変更で終端する」契約を経路によらず統一する意図的な判断 であり、新親が存在しない場合は孤児経路でも exit 2 になる。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 4: 孤児 issue を再配置する","type":"text"}]},{"type":"paragraph","content":[{"text":"どの親にも紐付いていない孤児 issue を適切な Phase 親へ紐付ける。 ","type":"text"},{"text":"--old-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" を省略して同じスクリプトを呼ぶ(DELETE を飛ばして POST のみ実行される)。 Phase が不明な issue はタイトル・本文を読んで判断し、判断できない場合はユーザーに確認する。","type":"text"}]},{"type":"paragraph","content":[{"text":"このフェンスは Step 3 の実行結果に依存せず単体で実行できる。","type":"text","marks":[{"type":"strong"}]},{"text":" フェンスは独立シェルで 実行され得るため、Step 3 が定義した関数・変数を継承しない前提で書く。前置きは Step 3 と同一である。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# reassign-lib.sh を 3 レイアウトから解決して source する。\n# **この前置きは Step 3 / Step 4 の両フェンスが同一の内容で持つ。** 手順書のコードフェンスは\n# ブロックごとに独立シェルで実行され得るため、片方のフェンスで定義した関数・変数がもう一方へ\n# 継承される保証がない(Issue #372 / PR #374 codex-review P1)。\nREASSIGN_LIB=\"\"\nfor CANDIDATE in \\\n \"skills/update-issue-tree/scripts/reassign-lib.sh\" \\\n \".agents/skills/update-issue-tree/scripts/reassign-lib.sh\" \\\n \".claude/skills/update-issue-tree/scripts/reassign-lib.sh\"; do\n # 存在確認は -f のみで行う(-x にすると、npx skills add 等の vendoring で\n # 実行ビットが落ちたファイルを「存在しない」と誤検知し、3 レイアウトいずれにも\n # 見つからないという誤ったエラーメッセージになる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n REASSIGN_LIB=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${REASSIGN_LIB}\" ]]; then\n echo \"エラー: reassign-lib.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n# ライブラリは対の reassign-sub-issue.sh の存在まで確認したうえで、reassign_one /\n# require_plan_array と REASSIGN_SCRIPT を定義する。欠落していれば非ゼロを返すので中断する。\n# このフェンスで set -euo pipefail を使ってはならない(理由は reassign-lib.sh 冒頭のコメント参照。\n# set -e があると reassign_one 内の `status=$?` 退避に到達せず、Issue #335 の欠陥が再発する)\nsource \"${REASSIGN_LIB}\" || exit 1\n\n# Step 3 と同じガードを同じ関数で行う(非対称を作らない)\nrequire_plan_array ORPHAN_PLAN || exit 1\n\nSKIPPED_ORPHANS=()\nNEEDS_REVIEW_ORPHANS=()\nfor ENTRY in \"${ORPHAN_PLAN[@]}\"; do\n # ENTRY は \"\u003corphan-issue> \u003cphase-parent>\" 形式\n IFS=' ' read -r ORPHAN_NUMBER PHASE_NUMBER \u003c\u003c\u003c \"${ENTRY}\"\n # Step 3 と同じ理由で `if ...; then ... fi` は使わない(`fi` 直後の $? は 0 になる)\n status=0\n reassign_one --issue \"${ORPHAN_NUMBER}\" --new-parent \"${PHASE_NUMBER}\" || status=$?\n if (( status == 0 )); then\n continue\n fi\n if (( status == 9 )); then\n SKIPPED_ORPHANS+=(\"${ORPHAN_NUMBER}\")\n continue\n fi\n if (( status == 10 )); then\n # Step 3 と同じ (c) 扱い。孤児経路は --old-parent を渡さないため通常は起こり得ないが、\n # ループ構造を Step 3 と非対称にしないため同じ分岐を持つ\n NEEDS_REVIEW_ORPHANS+=(\"#${ORPHAN_NUMBER}: exit=10 restored — 補償復旧が発生。Phase 親 #${PHASE_NUMBER} への紐付けは未達。実状態を確認すること\")\n continue\n fi\n if (( status == 11 )); then\n NEEDS_REVIEW_ORPHANS+=(\"#${ORPHAN_NUMBER}: exit=11 third-party-parent — 第三者が別親を設定済み。同じコマンドで再実行禁止。stderr の実測親を確認しユーザー承認のうえ再実行すること\")\n continue\n fi\n echo \"エラー: #${ORPHAN_NUMBER} の再配置が exit ${status} で失敗した。中断する\" >&2\n exit \"${status}\"\ndone\nif (( ${#SKIPPED_ORPHANS[@]} > 0 )); then\n echo \"対象外(cross-repository 親): ${SKIPPED_ORPHANS[*]}\" >&2\nfi\nif (( ${#NEEDS_REVIEW_ORPHANS[@]} > 0 )); then\n echo \"要確認事項(Step 9 のレポートへ転記すること):\" >&2\n printf ' %s\\n' \"${NEEDS_REVIEW_ORPHANS[@]}\" >&2\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"Step 3 と同一の関数・同一のループ構造であり、終了コードの扱いも同じ ((a) 解消可能な前提不備のみ原因解消まで中断、(b) 恒久的な対象外(exit 9)は要確認事項へ記載して 次の 1 件へ進む、(c) 部分的に変更済みだが継続してよいもの(exit 10 / 11)も要確認事項へ記録して fatal にせず次の 1 件へ進む。(b) の判定は関数が捕捉した stderr の ","type":"text"},{"text":"reason=cross-repository-parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" マーカーで機械的に行い、返り値 9 として呼び出し側へ伝える。 終了コード表を参照)。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 5: 必要に応じて新 Phase 親を新設する","type":"text"}]},{"type":"paragraph","content":[{"text":"既存 Phase に収まらない新規タスクが多い場合、新 Phase 親 issue を作成してルートへ紐付ける。 (この POST は ","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を使わない。たった今作成した、親を持たないことが 自明な issue への単発 POST であり、DELETE パス・冪等性判定の対象外のため)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# phase ラベルが存在しないリポジトリでは issue 作成が失敗するため、必ず事前作成する\n# (作成済みの場合は失敗を無視して続行する)\ngh label create \"phase:N\" --color \"0075ca\" 2>/dev/null || true\n\n# gh issue create は URL を出力する(--json 非対応)。URL 末尾から番号を抽出する\nNEW_PHASE_URL=$(gh issue create \\\n --title \"feat(phase-N): Phase N タイトル\" \\\n --label \"phase:N\" \\\n --body \"$(cat \u003c\u003c'EOF'\n## 概要\n\nPhase N の実装タスクをまとめる親 issue。\n\n## タスク一覧\n\n| Issue | タイトル | 分解 |\n|-------|---------|------|\nEOF\n)\")\nNEW_PHASE_NUMBER=$(printf '%s' \"${NEW_PHASE_URL}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

)\n\n# ルートへ紐付け。sub_issue_id は issue 番号ではなく database id を渡す(GitHub sub-issues API 仕様)\nNEW_PHASE_ID=$(gh api \"repos/{owner}/{repo}/issues/${NEW_PHASE_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${NEW_PHASE_ID}\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 6: phase ラベルを同期する","type":"text"}]},{"type":"paragraph","content":[{"text":"各 issue の phase ラベルが親 Phase と一致しているか確認し、不一致のラベルを修正する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# ラベルを追加\ngh issue edit \"${ISSUE_NUMBER}\" --add-label \"phase:1\"\n\n# 古いラベルを削除\ngh issue edit \"${ISSUE_NUMBER}\" --remove-label \"phase:0\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 7: 粒度基準超の issue を sub-issue に分解する","type":"text"}]},{"type":"paragraph","content":[{"text":"棚卸し中に実装時間が ","type":"text"},{"text":"${GRANULARITY}","type":"text","marks":[{"type":"code_inline"}]},{"text":"(Step 1 で確定。既定 2h)超と判断した issue は、create-issue-tree と同じ粒度基準で sub-issue に分解する。 (この POST も ","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を使わない。理由は Step 5 と同じ: 新規作成した 親なし issue への単発 POST)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# phase ラベルが存在しない場合に備えて事前作成する(作成済みなら no-op)\ngh label create \"phase:N\" --color \"0075ca\" 2>/dev/null || true\n\n# sub-issue を作成(URL 末尾から番号を抽出)\nSUB_URL=$(gh issue create \\\n --title \"feat: サブタスク名\" \\\n --label \"phase:N\" \\\n --body \"...\")\nSUB_NUMBER=$(printf '%s' \"${SUB_URL}\" | grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

)\n\n# 親 issue へ紐付け(sub_issue_id は database id)\nSUB_ID=$(gh api \"repos/{owner}/{repo}/issues/${SUB_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${ISSUE_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${SUB_ID}\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 8: ルート issue 本文の管理ブロックを更新する(既存本文は保持)","type":"text"}]},{"type":"paragraph","content":[{"text":"ルート issue 本文のうち","type":"text"},{"text":"本スキルが管理する範囲だけ","type":"text","marks":[{"type":"strong"}]},{"text":"を実ツリーから再生成して差し替える。 それ以外の本文(概要・運用・手書きの計画や判断根拠・追記)は 1 行も失わない。本文全体を 固定テンプレートで書き戻してはならない(人が書いた内容が棚卸しのたびに消え、件数・日付の プレースホルダーが本文へ到達し得るため。Issue #545)。処理は ","type":"text"},{"text":"scripts/update-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" に集約されている。","type":"text"}]},{"type":"paragraph","content":[{"text":"管理範囲と不変条件","type":"text","marks":[{"type":"strong"}]}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"領域","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"マーカー(行全体で一致・コードフェンス外のみ有効)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"更新規則","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Phase 別表","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\u003c!-- update-issue-tree:phase-plan:begin -->","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"\u003c!-- update-issue-tree:phase-plan:end -->","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"マーカー間を毎回全置換(ルート直下の sub-issue 全件の表と、直下に open の子を持つ節点の詳細表)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"棚卸し履歴","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\u003c!-- update-issue-tree:inventory:begin -->","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"\u003c!-- update-issue-tree:inventory:end -->","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"追記専用。既存行は保持し、今回の日付と 5 件数の 1 行を末尾へ足す(直前行と同一なら足さない)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"granularity","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\u003c!-- granularity: Nh -->","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"先頭に 1 行だけ維持する。値は ","type":"text"},{"text":"GRANULARITY","type":"text","marks":[{"type":"code_inline"}]},{"text":" の確定値","type":"text"}]}]}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"既存本文から消してよいのは「phase-plan マーカー間の行」と「granularity マーカー行」だけ","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"## 概要","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"## 運用","type":"text","marks":[{"type":"code_inline"}]},{"text":" は管理しない。存在すれば逐語で保持し、存在しなくても追加しない (本文が空のときだけ骨格を生成する)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"表・件数・日付は実ツリーと検証済み引数からだけ生成する。5 件数の引数は既定値なしの必須で、 プレースホルダーが残っていれば API 呼び出し前に exit 1 で止まる","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"issue タイトルは非信頼データ。改行は空白 1 つに、","type":"text"},{"text":"&","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"\u003c","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":">","type":"text","marks":[{"type":"code_inline"}]},{"text":"・バッククォート・バックスラッシュ・","type":"text"},{"text":"|","type":"text","marks":[{"type":"code_inline"}]},{"text":" は HTML 文字参照へ変換して書き込む(create-issue-tree と同じ変換)。タイトルが管理マーカーを偽装できず、create-issue-tree の ","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 再マージも判定範囲の記号で中止しない。旧形式(","type":"text"},{"text":"\\|","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)の行は管理マーカー区間内であれば次回の実行で書き直される","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"マーカーの不整合(片方のみ・重複・逆順・入れ子)と、閉じていないコードフェンスは編集せず exit 4 で止まる (閉じていないフェンスは以降を全部コード扱いにして管理ブロックを二重追記するため)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"マーカーが無い既存本文の移行","type":"text","marks":[{"type":"strong"}]},{"text":"(現存するルートはすべてこの状態): 既存行は捨てない。","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"## Phase 別実装計画","type":"text","marks":[{"type":"code_inline"}]},{"text":" 節があれば、見出し直後へマーカー付きの生成ブロックを挿入する。同節内の 見出し直後〜最初の H3 までの先頭部分と ","type":"text"},{"text":"### Phase ","type":"text","marks":[{"type":"code_inline"}]},{"text":" で始まる H3 は旧生成物の候補として ","type":"text"},{"text":"削除せず","type":"text","marks":[{"type":"strong"}]},{"text":"、","type":"text"},{"text":"\u003cdetails>","type":"text","marks":[{"type":"code_inline"}]},{"text":"(要約に退避日と「確認のうえ不要なら削除」を明記)へ逐語で移す。 それ以外の H3(","type":"text"},{"text":"### 実装ラン (...)","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)は元の位置・内容のまま残す","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"節が無ければ本文末尾へ ","type":"text"},{"text":"## Phase 別実装計画","type":"text","marks":[{"type":"code_inline"}]},{"text":" とマーカー付きブロックを追記する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"棚卸し履歴は ","type":"text"},{"text":"## 棚卸しで実施した整理(update-issue-tree 実行履歴)","type":"text","marks":[{"type":"code_inline"}]},{"text":" 節を Phase 別実装計画の直前へ挿入する。 既存の手書きの棚卸し節には触れない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"退避が起きたら stdout 最終行の ","type":"text"},{"text":"migration=phase-plan-archived","type":"text","marks":[{"type":"code_inline"}]},{"text":" を Step 9 の要確認事項へ転記する","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"書き込み前の確認と書き込み","type":"text","marks":[{"type":"strong"}]},{"text":": 取得した本文と編集直前に再取得した本文が異なれば並行編集として exit 5 で止まる。変更が無ければ ","type":"text"},{"text":"result=unchanged","type":"text","marks":[{"type":"code_inline"}]},{"text":" で編集しない。","type":"text"},{"text":"--dry-run","type":"text","marks":[{"type":"code_inline"}]},{"text":" を付けると マージ後の本文を stdout へ出し、書き込み系の ","type":"text"},{"text":"gh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を一切呼ばない(初回の移行前に推奨)。","type":"text"}]},{"type":"paragraph","content":[{"text":"件数変数の確定手順","type":"text","marks":[{"type":"strong"}]},{"text":"(各コードフェンスは独立したシェルで実行され得るため、Step 3〜7 のフェンス内の 変数は Step 8 へ引き継がれない。実績値は会話上で集計し、下記フェンスの先頭へ","type":"text"},{"text":"数値を直接書いた代入行として 追記","type":"text","marks":[{"type":"strong"}]},{"text":"してから実行する。推測・既定値・プレースホルダーのまま実行しない):","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"変数","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"確定する値","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"集計元","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ROOT_NUMBER","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ルート issue 番号","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"引数","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GRANULARITY","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"粒度(","type":"text"},{"text":"2h","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等。","type":"text"},{"text":"Nh","type":"text","marks":[{"type":"code_inline"}]},{"text":" 形式)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"引数の確定値(既定 ","type":"text"},{"text":"2h","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"REASSIGNED_COUNT","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"closed 親下の残置 issue 付け替えの成功件数","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 3 で ","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" が ","type":"text"},{"text":"result=reassigned","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"posted-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" を返した回数","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ORPHAN_COUNT","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"孤児 issue 再配置の成功件数","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 4 で同様に成功した回数","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"LABEL_SYNC_COUNT","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"phase ラベル同期の成功件数","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 6 で実際にラベルを付与・変更できた issue 数","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NEW_PHASE_COUNT","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"新設した Phase 親の件数","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 5 で作成と紐付けが成功した件数","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SPLIT_COUNT","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sub-issue 分解の成功件数","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 7 で分解(作成と紐付け)まで成功した issue 数","type":"text"}]}]}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"該当 Step を実行しなかった、または対象が無かった場合は ","type":"text"},{"text":"0","type":"text","marks":[{"type":"code_inline"}]},{"text":" を代入する(未設定のまま放置しない)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"失敗・見送り(非ゼロ終了、exit 10 / 11 を含む)は件数へ計上しない(Step 9 の集計規則と同じ)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"代入例(値は実績に置き換える): ","type":"text"},{"text":"ROOT_NUMBER=254 GRANULARITY=2h REASSIGNED_COUNT=1 ORPHAN_COUNT=0 LABEL_SYNC_COUNT=3 NEW_PHASE_COUNT=0 SPLIT_COUNT=0","type":"text","marks":[{"type":"code_inline"}]},{"text":" を ","type":"text"},{"text":"export","type":"text","marks":[{"type":"code_inline"}]},{"text":" 付きでフェンス先頭に置く","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"各コードフェンスは独立したシェルで実行され得るため、このフェンスは Step 3 / Step 4 と同形に スクリプトを 3 レイアウトから自己完結的に解決する。件数の変数が未設定なら起動前に停止する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Step 1〜7 の確定値。未設定ならここで停止する(プレースホルダーを本文へ流さない)\n: \"${ROOT_NUMBER:?}\" \"${GRANULARITY:?}\" \"${REASSIGNED_COUNT:?}\" \"${ORPHAN_COUNT:?}\" \\\n \"${LABEL_SYNC_COUNT:?}\" \"${NEW_PHASE_COUNT:?}\" \"${SPLIT_COUNT:?}\"\n\nUPDATE_ROOT_BODY=\"\"\nfor CANDIDATE in \\\n \"skills/update-issue-tree/scripts/update-root-body.sh\" \\\n \".agents/skills/update-issue-tree/scripts/update-root-body.sh\" \\\n \".claude/skills/update-issue-tree/scripts/update-root-body.sh\"; do\n # 存在確認は -f のみ(vendoring で実行ビットが落ちても bash 経由で起動できる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n UPDATE_ROOT_BODY=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${UPDATE_ROOT_BODY}\" ]]; then\n echo \"エラー: update-root-body.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n\n# echo を最終コマンドにしない。非ゼロ終了はそのままフェンスの終了ステータスにする\nstatus=0\nbash \"${UPDATE_ROOT_BODY}\" \\\n --root \"${ROOT_NUMBER}\" --granularity \"${GRANULARITY}\" \\\n --reassigned \"${REASSIGNED_COUNT}\" --orphans \"${ORPHAN_COUNT}\" \\\n --labels \"${LABEL_SYNC_COUNT}\" --new-phases \"${NEW_PHASE_COUNT}\" \\\n --split \"${SPLIT_COUNT}\" || status=$?\nif (( status != 0 )); then\n echo \"exit=${status}\" >&2\n exit \"${status}\"\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"終了コード","type":"text","marks":[{"type":"strong"}]},{"text":"(stdout 最終行は ","type":"text"},{"text":"result=\u003cupdated|unchanged|dry-run> root=\u003cn> migration=\u003cnone|phase-plan-archived|inserted> phases=\u003ck>","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"exit","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"意味","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"本文の変更","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"成功(updated のみ変更あり)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"updated のみ","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"引数エラー(件数のプレースホルダー残りを含む)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"なし","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"前提不備(gh / jq 不在・未認証・ルート取得失敗・ルートが PR)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"なし","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ツリー取得失敗・深さ超過(8 段)・表の行数不一致・65,536 文字超","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"なし","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理マーカーの不整合、または本文のコードフェンスが閉じていない","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"なし","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"並行編集を検知","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"なし","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gh issue edit","type":"text","marks":[{"type":"code_inline"}]},{"text":" が失敗","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"不明(実状態を確認する)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"7","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"事後確認の不一致(再取得した本文全体が送信した本文と一致しない。末尾改行・CR の差は無視)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"あり(編集済み)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"非ゼロ終了は握り潰さず Step 9 の要確認事項へ転記する。exit 6 / 7 は本文の実状態を確認する。 誤って更新した場合は GitHub の本文編集履歴から復元できる。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 9: 棚卸し結果を報告する","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"## update-issue-tree 完了レポート\n\n### 対象ルート issue\n- #N: タイトル\n\n### 棚卸し実施内容\n| 操作 | 件数 |\n|------|------|\n| closed 親下の残置 issue 付け替え | N 件 |\n| 孤児 issue の再配置 | N 件 |\n| phase ラベル同期 | N 件 |\n| 新 Phase 親の新設 | N 件 |\n| 粒度基準超 issue の sub-issue 分解 | N 件 |\n\n### 現在の Phase 別サマリー\n| Phase | 親 issue | open 件数 |\n|-------|----------|----------|\n| Phase 1 | #N | N 件 |\n\n### ルート本文の更新結果\n- result: updated / unchanged、migration: none / phase-plan-archived / inserted\n\n### 要確認事項(自動配置できなかった issue)\n- #N: タイトル — 確認理由\n- owner ラベル付き open issue(`owner:human` / `owner:joint`): #N: タイトル — 人手対応待ち(ラベルは変更しない)\n- 退避ブロック(`\u003cdetails>`)の確認 / Step 8 の exit 5・6・7(該当時)","type":"text"}]},{"type":"paragraph","content":[{"text":"「closed 親下の残置 issue 付け替え」「孤児 issue の再配置」の件数は、Step 3 / Step 4 で ","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を呼んだ回数分の ","type":"text"},{"text":"result=","type":"text","marks":[{"type":"code_inline"}]},{"text":" 行(","type":"text"},{"text":"reassigned","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"posted-only","type":"text","marks":[{"type":"code_inline"}]},{"text":")から集計する。 ","type":"text"},{"text":"すべての非ゼロ終了","type":"text","marks":[{"type":"strong"}]},{"text":"(exit 1〜8・10・11。今後コードが増えた場合も含む)は 1 件も件数へ含めず、 必ず「要確認事項」へ理由付きで記載する。とくに exit 8 は","type":"text"},{"text":"部分変更が残っている","type":"text","marks":[{"type":"strong"}]},{"text":"ため、 報告を漏らすと壊れたツリーが放置される。exit 10(補償復旧成功)・exit 11(第三者が別親を設定済み) も本来の付け替えは未達のため件数へは計上せず要確認事項へ記載する。","type":"text"},{"text":"exit 9・10・11 は Step 3 / Step 4 のループ自体が fatal 扱いせず継続する","type":"text","marks":[{"type":"strong"}]},{"text":"ため(終了コード表・上記ループを参照)、 これらは計画配列の残り全件を処理し終えたうえで Step 9 に到達する。Step 3 / Step 4 が stderr へ 出力する ","type":"text"},{"text":"SKIPPED","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"NEEDS_REVIEW","type":"text","marks":[{"type":"code_inline"}]},{"text":"(および ","type":"text"},{"text":"SKIPPED_ORPHANS","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"NEEDS_REVIEW_ORPHANS","type":"text","marks":[{"type":"code_inline"}]},{"text":")の内容を そのままこの節へ転記する。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"検証","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ルート issue 本文の Phase 別表が更新されていることを確認する。あわせて","type":"text"},{"text":"マーカー外の節(概要・運用・手書き節)が更新前後で変わっていない","type":"text","marks":[{"type":"strong"}]},{"text":"こと、","type":"text"},{"text":"YYYY-MM-DD","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"N 件","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等のプレースホルダーが本文に無いことを確認する(初回は ","type":"text"},{"text":"update-root-body.sh ... --dry-run","type":"text","marks":[{"type":"code_inline"}]},{"text":" で出力を確認してから書き込む)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"closed Phase 親の下に open issue が残置されていないことを確認する。","type":"text"},{"text":"ただし exit 10 (補償復旧成功)で要確認事項へ記録された issue は、承認範囲内の逆操作として旧親(closed) 配下へ意図的に復帰しているため、この確認の例外として扱う。","type":"text","marks":[{"type":"strong"}]},{"text":" 単独では「残置」に見えるので、 Step 9 の要確認事項の記録と突き合わせて exit 10 由来であることを確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Step 3 / Step 4 で呼んだ ","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" の各回について、","type":"text"},{"text":"echo \"exit=${status}\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" の値と ","type":"text"},{"text":"result=","type":"text","marks":[{"type":"code_inline"}]},{"text":" 行を確認する。非ゼロ終了があれば Step 9 の要確認事項へ反映されているか確認する。 加えて、**exit 1〜8(9・10・11 を除く)**の fatal 系はコードブロック自体の終了ステータスが 非ゼロで返ること(","type":"text"},{"text":"echo","type":"text","marks":[{"type":"code_inline"}]},{"text":" を最後のコマンドにして握り潰していないこと)を確認する。 ","type":"text"},{"text":"exit 9・10・11","type":"text","marks":[{"type":"strong"}]},{"text":"は fatal ではなくループ継続の契約であるため、コードブロックはそのまま 正常終了(終了ステータス 0)で最後の ","type":"text"},{"text":"SKIPPED*","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"NEEDS_REVIEW*","type":"text","marks":[{"type":"code_inline"}]},{"text":" echo まで到達すること、 かつ ","type":"text"},{"text":"REASSIGN_PLAN","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"ORPHAN_PLAN","type":"text","marks":[{"type":"code_inline"}]},{"text":" の残り全件が処理されていることを確認する","type":"text"}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 全 sub-issues の state を確認\ngh api \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues\" \\\n --jq '.[] | {number: .number, state: .state, title: .title}'\n\n# Phase 親の sub-issues も確認\ngh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues\" \\\n --jq '.[] | {number: .number, state: .state}'\n\n# phase ラベルの同期確認(各 Phase 親直下で確認)\ngh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues\" \\\n --jq '.[] | {number: .number, labels: [.labels[].name]}'","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"よくある失敗","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"問題","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"回避策","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"付け替えの DELETE が 404 になり、続く POST が 422 で失敗する","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"削除のパスだけ単数形 ","type":"text"},{"text":"sub_issue","type":"text","marks":[{"type":"code_inline"}]},{"text":"。複数形 ","type":"text"},{"text":"sub_issues","type":"text","marks":[{"type":"code_inline"}]},{"text":" は 404 になり、旧親から外れないまま POST するため ","type":"text"},{"text":"Sub issue may only have one parent","type":"text","marks":[{"type":"code_inline"}]},{"text":" で必ず失敗する(","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" は DELETE 失敗時に POST へ進まないため、この連鎖失敗自体は起きない。手動で ","type":"text"},{"text":"gh api","type":"text","marks":[{"type":"code_inline"}]},{"text":" を直接叩く場合の注意として記載を残す)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理ブロック内を手で編集したが次回の棚卸しで上書きされた","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"マーカー間は毎回再生成される。残したい内容はマーカーの外へ書く","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"管理マーカーを片方だけ消して Step 8 が exit 4 になる","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"両方を戻すか両方とも消す(マーカーを消すと次回は移行扱いになり、旧ブロックは ","type":"text"},{"text":"\u003cdetails>","type":"text","marks":[{"type":"code_inline"}]},{"text":" へ退避される)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--granularity","type":"text","marks":[{"type":"code_inline"}]},{"text":" に ","type":"text"},{"text":"2 h","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"2","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"0h","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等を渡して中断される","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"正整数+h 形式(","type":"text"},{"text":"^[1-9][0-9]*h$","type":"text","marks":[{"type":"code_inline"}]},{"text":"。例: ","type":"text"},{"text":"2h","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"4h","type":"text","marks":[{"type":"code_inline"}]},{"text":")で指定する","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"注意事項","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"棚卸し前に変更内容をユーザーに提示して確認を取る","type":"text","marks":[{"type":"strong"}]},{"text":"(Step 2 参照)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ページネーション: sub-issues が 100 件を超える場合は ","type":"text"},{"text":"per_page=100&page=N","type":"text","marks":[{"type":"code_inline"}]},{"text":" でページングして全件取得する(Step 1 のツリー全体取得に適用。","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" は対象 issue の ","type":"text"},{"text":"parent_issue_url","type":"text","marks":[{"type":"code_inline"}]},{"text":" を直接参照するため、付け替え判定自体にはページネーションが不要)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"シェルコマンドの変数は必ず ","type":"text"},{"text":"\"${var}\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" でクォートする(コマンドインジェクション対策)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"gh issue create","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" は ","type":"text","marks":[{"type":"strong"}]},{"text":"--json","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 非対応","type":"text","marks":[{"type":"strong"}]},{"text":"。issue URL を stdout に出力するため、","type":"text"},{"text":"| grep -oE '[0-9]+

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…

","type":"text","marks":[{"type":"code_inline"}]},{"text":" で末尾の番号を抽出する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"sub_issues API(POST / DELETE)の ","type":"text","marks":[{"type":"strong"}]},{"text":"sub_issue_id","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" は issue 番号ではなく database id","type":"text","marks":[{"type":"strong"}]},{"text":"(GitHub 仕様)。","type":"text"},{"text":"gh api \"repos/{owner}/{repo}/issues/\u003cnumber>\" --jq '.id'","type":"text","marks":[{"type":"code_inline"}]},{"text":" で id を取得してから渡す。番号をそのまま渡すと誤った issue を操作する/404 になる(","type":"text"},{"text":"reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" はこれを内部で解決するため、Step 3/4 で手動取得する必要はない)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"孤児 issue の Phase が判断できない場合は推測せずにユーザーへ確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"sub_issues の DELETE API(付け替え時に旧親から外す操作)はパスが単数形 ","type":"text"},{"text":"sub_issue","type":"text","marks":[{"type":"code_inline"}]},{"text":" である点に注意し、操作対象の issue 番号を必ず確認してから実行する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ツリー更新後は implement-issue-tree が post-order DFS で正しく消化できる構造になっているか確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Step 3 / Step 4 の付け替え処理は ","type":"text"},{"text":"scripts/reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を使う。SKILL.md 本文へ素の ","type":"text"},{"text":"gh api","type":"text","marks":[{"type":"code_inline"}]},{"text":" DELETE/POST を書き戻さない(状態変数の受け渡しがコードフェンス境界で壊れるクラスの欠陥に戻るため。詳細は ","type":"text"},{"text":"scripts/reassign-sub-issue.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" 冒頭コメントを参照)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Step 8 は ","type":"text"},{"text":"scripts/update-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を使う。","type":"text"},{"text":"gh issue edit --body","type":"text","marks":[{"type":"code_inline"}]},{"text":" でルート本文全体を書き戻さない(手書きの本文が消えるため。詳細は ","type":"text"},{"text":"scripts/update-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" 冒頭コメントを参照)。誤更新時は GitHub の本文編集履歴から復元できる","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"update-issue-tree","model":"opus","author":"@skillopedia","source":{"stars":2,"repo_name":"agent-cli-skills","origin_url":"https://github.com/Fandhe-AI/agent-cli-skills/blob/HEAD/skills/update-issue-tree/SKILL.md","repo_owner":"Fandhe-AI","body_sha256":"f6400cf5b409bc21e6c5c07b68250ea96bf5d52837c2dafe60d4fdb1fe6b7b2d","cluster_key":"2a9d282cd4d847aff6d2c17ac3d86845f06e0e88f8f23e94df61118c7a6a6abd","clean_bundle":{"format":"clean-skill-bundle-v1","source":"Fandhe-AI/agent-cli-skills/skills/update-issue-tree/SKILL.md","text_bytes":241029,"source_commit":"b1c76148d77c8f8897e0f7206af34b126739fd83","skills_sh_rank":9661,"attachment_count":9,"text_attachments":9,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/update-issue-tree/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"integrations-apis","category_label":"Integrations"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"integrations-apis","import_tag":"skills-sh-top20k-2026-11","description":"既存の GitHub Issue ツリーを棚卸し・更新するスキル。「ツリーを棚卸しして」「イシューツリーを更新して」「トラッキング issue を整理して」で使用。 ルートのトラッキング issue 番号を受け取り、sub_issues API でツリー全体を再帰取得 → closed 親下の残置 open issue 付け替え・孤児の再配置・新 Phase 親の新設・phase ラベル同期 → ルート issue 本文は既存本文を保持し、Phase 別表・棚卸し履歴の管理ブロックのみ更新する。 ツリー新規作成は create-issue-tree、実装消化は implement-issue-tree を参照。\n","argument-hint":"\u003cルートトラッキング issue 番号> [--granularity \u003c時間>]","user-invocable":true}},"renderedAt":1793666012689}

update-issue-tree 既存の Issue ツリーを棚卸しし、ルート issue 本文を最新状態へ更新する(管理ブロックのみ。手書きの本文は保持する)。 closed 親下に残置された open issue の付け替え・孤児の再配置・phase ラベルの同期を実施し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 使い方 ルートのトラッキング issue 番号を引数として渡す。 オプションで粒度基準(1 issue に収める実装時間の上限)を指定できる。 ・ ・ のように正整数+時間単位( )で指定する。優先順位は 明示 ルート issue 本文の マーカー 既定 の順(create-issue-tree と同じ粒度基準・同じマーカー形式)。値は Step 1 で として確定し、Step 2・Step 7 の判定に使う。棚卸し後に Step 8 が管理ブロックを更新する際、 の確定値でマーカー行を先頭に 1 行だけ保持する。 前提条件 - CLI がインストールされ、認証済みであること( で確認) - 対象リポジトリへの Issue 書き込み権限があること - 対象ツリーは 単一リポジトリ内 で完結していること。GitHub の sub-issues はリポジトリを 跨いで紐付けられるが、本スキルは cross…