create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

; then\n echo \"エラー: --granularity の値 '${GRANULARITY}' は不正です(許可: 正整数+h、例 2h)。中止します。\"\n exit 1\nfi\n\n# 確定値と由来を必ず出力する(既定 2h と読み替えないことをここで可視化する)\necho \"粒度基準: ${GRANULARITY}(由来: ${GRANULARITY_SOURCE})\"\n```\n\n以降の Step は Step 1 で出力された `GRANULARITY` の値を使う(既定 2h と読み替えない)。\n\n- 各タスクの依存関係・実行順を把握する。人手タスクに依存する実装タスクは、本文に `## 前提` 節を置いて行頭に `- #N` を書く(Step 5 参照)。そのため人手 issue を先に起票する\n- タスク数を集計し、Phase 分割の要否を判断する(目安: 10 件超で Phase 分割を検討)\n\n分解結果をユーザーに提示し、Phase 構成・issue 数について確認を取る。 \n`--phase` オプションが指定されている場合は該当 Phase のタスクのみ対象とする。\n\n### Step 2: Phase 構成を設計する\n\nタスク数・依存関係をもとに Phase を設計する。\n\n- Phase は独立して開発可能な単位で分割する(例: Phase 1 基盤・Phase 2 機能・Phase 3 改善)\n- 各 Phase に親 issue タイトル(Conventional Commits 形式推奨)を割り当てる\n- 全体をまとめるルート(トラッキング)issue のタイトルを決定する\n - 例: `chore(global): 全 open issue の Phase 別トラッキング (YYYY-MM-DD)`\n\nタスクが少数(Phase 分割不要)の場合はルート issue + 子 issue の 2 階層構成にする。\n\n### Step 2.5: milestone を決定する\n\nこのツリーに割り当てる GitHub Milestone を決定する。ルート・Phase 親・子・sub-issue の\n全件に同一 milestone を適用する(ツリー単位で 1 milestone)。\n\n`--root` が指定されている場合は、ここで先に `ROOT_NUMBER` を設定し、milestone の\n継承・書き込みを行う前に OPEN 状態を検証する(closed なルートの milestone を\n書き換えてから中断する事故を防ぐ。Step 3 側では再代入・再検証しない)。\n\n```bash\n# --root で渡された Issue 番号を実際の値で代入する(実行時に Claude が置き換える)\n# 例: --root 123 が指定された場合 → ROOT_NUMBER=\"123\" / --root 未指定の場合 → ROOT_NUMBER=\"\"\nROOT_NUMBER=\"\u003c--root で渡された Issue 番号(未指定なら空文字)>\"\n\n# --root 指定時のみ: OPEN でなければ milestone 操作より前に中止する\n# (新規ツリー作成で ROOT_NUMBER が空の場合はこの検証をスキップする)\nif [[ -n \"${ROOT_NUMBER}\" ]]; then\n ROOT_STATE=$(gh issue view \"${ROOT_NUMBER}\" --json state --jq '.state')\n if [[ \"${ROOT_STATE}\" != \"OPEN\" ]]; then\n echo \"エラー: ルート issue #${ROOT_NUMBER} は OPEN ではありません (state: ${ROOT_STATE})。中止します。\"\n exit 1\n fi\nfi\n```\n\n**milestone 非運用リポジトリのガード**: `--milestone` が明示されていない場合、リポジトリに\nmilestone が 1 件も存在しなければ(closed 含む)milestone 非運用リポジトリとみなし、\n以降の確認をすべてスキップして `MILESTONE` は空のまま Step 3 へ進む\n(milestone を使わないリポジトリの起票フローに確認を増やさない)。\n\n```bash\nMILESTONE_COUNT=$(gh api \"repos/{owner}/{repo}/milestones?state=all\" --jq 'length')\n# MILESTONE_COUNT が 0 かつ --milestone 未指定なら、このステップの残りをスキップする\n```\n\n優先順位は **`--milestone` > `--root` からの継承 > ユーザーへの確認** の順。\n\n- `--milestone` が指定されている場合: その値をそのまま `MILESTONE` として使用する\n (`--root` も同時指定されている場合、ルート側の milestone より `--milestone` を優先する。\n ルート側と異なる値の場合は、ルートの milestone も合わせて更新してよいかユーザーに確認する。\n 更新しないと回答された場合はツリー内で milestone が混在する点を伝えたうえで続行する)\n- `--milestone` 未指定かつ `--root` 指定時: 既存ルートの milestone を自動継承する\n (milestone が取得できた場合のみユーザーへの確認は不要)\n\n ```bash\n MILESTONE=$(gh issue view \"${ROOT_NUMBER}\" --json milestone --jq '.milestone.title // empty')\n ```\n\n `MILESTONE` が空(既存ルートに milestone が未設定)の場合は自動継承とみなさず、\n 「どちらも未指定の場合」と同じユーザー確認フローへ進む(リポジトリに milestone が\n 存在するのにサイレントに milestone なしで進行しない。milestone が 1 件もない場合は\n 冒頭の非運用ガードが先に働くため、この確認には到達しない)。\n\n- どちらも未指定の場合、または `--root` 指定時に継承すべき milestone が空だった場合:\n milestone を割り当てるかユーザーに確認する。\n 割り当てる場合はオープン中の milestone 一覧を提示して選ばせるか、新規 milestone 名の\n 入力を受け付けて `MILESTONE` に設定する。割り当てないと回答されたら `MILESTONE` は\n 空のまま Step 3 以降へ進む(issue は milestone なしで作成される)。\n\n ```bash\n gh api \"repos/{owner}/{repo}/milestones\" --jq '.[] | select(.state==\"open\") | .title'\n ```\n\n ユーザーが一覧にない新規 milestone 名を入力した場合、`gh issue create --milestone` は\n 既存の milestone 名しか受け付けないため、使用前に milestone 自体を作成する。\n 同名の closed milestone が既に存在すると作成が 422(already_exists)で失敗するため、\n その場合は reopen するか別名にするかをユーザーに確認する。\n\n ```bash\n gh api --method POST \"repos/{owner}/{repo}/milestones\" -f \"title=${MILESTONE}\"\n ```\n\n `--root` 指定時に継承すべき milestone が空でこのフローに合流した場合、決定した\n `MILESTONE` を既存ルート issue にも反映する(子だけ milestone が付き、ルートが\n 未設定のまま残る不整合を防ぐ)。\n\n ```bash\n if [[ -n \"${ROOT_NUMBER}\" && -n \"${MILESTONE}\" ]]; then\n gh issue edit \"${ROOT_NUMBER}\" --milestone \"${MILESTONE}\"\n fi\n ```\n\n### Step 3: ルート(トラッキング)issue を作成する\n\nルート issue はツリー全体の進捗を管理するトラッキング issue として作成する。**ルート issue 自体には phase ラベルは付与しない**(Phase 親以下の issue にのみ付与する)。\n\n**`--root` 指定時は新規作成をスキップする。** `--phase` での 2 回目以降の部分起票で\nルート issue を重複作成しないため、Step 2.5 で設定・OPEN 検証済みの `ROOT_NUMBER` を\nそのまま再利用する(ここで再代入・再検証しない)。\n\n`--root` 未指定の場合のみ、以下でルート issue を新規作成する。\n表のプレースホルダー行は `scripts/merge-root-body.sh`(Step 6 の `--root` 経路)が追記位置として読む。\n文言を変えるときは同スクリプトの awk とテスト (n) を合わせて直す。\n\n```bash\n# MILESTONE が空でなければ --milestone を付与する(Step 2.5 で決定済み)\nROOT_ARGS=(--title \"chore: 全 open issue の Phase 別トラッキング ($(date +%Y-%m-%d))\")\nif [[ -n \"${MILESTONE}\" ]]; then\n ROOT_ARGS+=(--milestone \"${MILESTONE}\")\nfi\n\n# gh issue create は issue URL を stdout に出力する(--json 非対応)。URL 末尾から番号を抽出する\nROOT_URL=$(gh issue create \"${ROOT_ARGS[@]}\" \\\n --body \"$(printf '\u003c!-- granularity: %s -->\\n' \"${GRANULARITY}\"; cat \u003c\u003c'EOF'\n## 概要\n\n全 open issue を Phase 別に 1 ツリーへ整理する。各 Phase 親 issue を sub-issues として紐付け。\n\n## Phase 別実装計画\n\n| Phase | 親 issue | 直下 | 総 open 件数 |\n|-------|----------|------|-------------|\n| (作成後に更新) | | | |\n\n## 運用\n\n- 新規 issue は起票時に Phase 親へ紐付ける\n- 実行順は sub-issues リスト順が正\n- closed 親の下に open issue を残置しない\n- implement-issue-tree が post-order DFS で消化可能な構造を維持する\nEOF\n)\")\nROOT_NUMBER=$(printf '%s' \"${ROOT_URL}\" | grep -oE '[0-9]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

)\necho \"ルート issue: ${ROOT_NUMBER}\"\n```\n\n### Step 4: Phase 親 issue を作成して紐付ける\n\nPhase ごとに親 issue を作成し、sub_issues API でルートへ紐付ける。\n以下のスニペットは `PHASE` 変数で Phase 番号を切り替える。`--phase` 指定時はその番号を、\n全 Phase 起票時は処理中の Phase 番号を設定する(タイトル・ラベルとも `PHASE` に追従させる)。\n\n```bash\n# 処理対象の Phase 番号(--phase 指定時はその番号、全 Phase 起票時はループ中の番号)\nPHASE=1\n\n# phase ラベルが存在しないリポジトリでは issue 作成が失敗するため、必ず事前作成する\n# (作成済みの場合は失敗を無視して続行する)\ngh label create \"phase:${PHASE}\" --color \"0075ca\" 2>/dev/null || true\n\n# 人手タスク用の owner ラベル。このツリーで実際に使うラベルだけを OWNER_LABELS に並べる\n# (該当タスクが無ければ空配列のままにし、何も作成しない)\nOWNER_LABELS=() # 例: (\"owner:human\" \"owner:joint\")\nfor OWNER in ${OWNER_LABELS[@]+\"${OWNER_LABELS[@]}\"}; do\n case \"${OWNER}\" in\n owner:human) COLOR=\"d93f0b\" ;;\n owner:joint) COLOR=\"fbca04\" ;;\n *) continue ;;\n esac\n gh label create \"${OWNER}\" --color \"${COLOR}\" 2>/dev/null || true\ndone\n\n# --root 再実行時の重複防止: ルート直下に同じ Phase の open な親が既にあれば再利用する\n# closed な親は再利用しない(closed 親の下に open issue を残置しない運用ルールと整合させる)。\n# phase ラベルだけでは同ラベルの一般 issue がルート直下に混在した場合に誤マッチするため、\n# Phase 親のタイトル規約(feat(phase-N): 接頭辞)でも絞り込む。\n# 直下が 100 件を超える場合に備えてページングで全件走査する\nPHASE_NUMBER=\"\"\nPAGE=1\nwhile true; do\n RESULT=$(gh api \\\n \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues?per_page=100&page=${PAGE}\")\n PHASE_NUMBER=$(echo \"${RESULT}\" | jq -r \\\n \"[.[] | select(.state == \\\"open\\\"\n and (.title | startswith(\\\"feat(phase-${PHASE}):\\\"))\n and any(.labels[]?; .name == \\\"phase:${PHASE}\\\"))][0].number // empty\")\n COUNT=$(echo \"${RESULT}\" | jq 'length')\n if [[ -n \"${PHASE_NUMBER}\" || \"${COUNT}\" -lt 100 ]]; then break; fi\n PAGE=$((PAGE + 1))\ndone\n[[ -n \"${PHASE_NUMBER}\" ]] && echo \"既存の Phase 親 issue を再利用: #${PHASE_NUMBER}\"\n\n# 再利用する Phase 親が milestone 未設定の場合はここで揃える(milestone 導入前に\n# 作られたツリーへの追記で、新規の子だけに milestone が付く不整合を防ぐ。冪等)\nif [[ -n \"${PHASE_NUMBER}\" && -n \"${MILESTONE}\" ]]; then\n gh issue edit \"${PHASE_NUMBER}\" --milestone \"${MILESTONE}\"\nfi\n```\n\nタイトル規約が `feat(phase-N):` と異なるツリーでは上記の自動判定に頼らず、候補をユーザーに\n提示して再利用すべき Phase 親を確認する。\n\n`PHASE_NUMBER` が空(既存の Phase 親がない)場合のみ、以下で新規作成してルートへ紐付ける。\n\n```bash\n# MILESTONE が空でなければ --milestone を付与する(Step 2.5 で決定済み)\nPHASE_ARGS=(--title \"feat(phase-${PHASE}): Phase ${PHASE} 基盤整備\" --label \"phase:${PHASE}\")\nif [[ -n \"${MILESTONE}\" ]]; then\n PHASE_ARGS+=(--milestone \"${MILESTONE}\")\nfi\n\n# Phase 親 issue を作成(URL 末尾から番号を抽出)\nPHASE_URL=$(gh issue create \"${PHASE_ARGS[@]}\" \\\n --body \"$(cat \u003c\u003c'EOF'\n## 概要\n\nこの Phase の実装タスクをまとめる親 issue。\n\n## タスク一覧\n\n| Issue | タイトル | 分解 |\n|-------|---------|------|\n| (子 issue 作成後に更新) | | |\nEOF\n)\")\nPHASE_NUMBER=$(printf '%s' \"${PHASE_URL}\" | grep -oE '[0-9]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

)\n\n# ルートへ紐付け。sub_issue_id は issue 番号ではなく database id を渡す(GitHub sub-issues API 仕様)\nPHASE_ID=$(gh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${PHASE_ID}\"\n```\n\n### Step 5: 子 issue・sub-issue を作成して紐付ける\n\n各タスクを issue として作成し、Phase 親へ紐付ける。粒度基準(既定 2h・`--granularity`)超のタスクはさらに sub-issue に分解する。\n\n```bash\n# MILESTONE が空でなければ --milestone を付与する(Step 2.5 で決定済み)\nCHILD_ARGS=(--title \"feat: タスク名\" --label \"phase:${PHASE}\")\nOWNER_LABEL=\"\" # 人手タスクなら \"owner:human\" または \"owner:joint\" に置き換える\nif [[ -n \"${MILESTONE}\" ]]; then\n CHILD_ARGS+=(--milestone \"${MILESTONE}\")\nfi\n# 人手タスクだけ owner ラベルを追加する(OWNER_LABEL は owner:human / owner:joint の固定値のみ。それ以外は空)\nif [[ -n \"${OWNER_LABEL}\" ]]; then\n CHILD_ARGS+=(--label \"${OWNER_LABEL}\")\nfi\n\n# 子 issue を作成(URL 末尾から番号を抽出)。PHASE は Step 4 で設定した番号を引き継ぐ\nCHILD_URL=$(gh issue create \"${CHILD_ARGS[@]}\" \\\n --body \"$(cat \u003c\u003c'EOF'\n## 概要\n\n...\n\n## 受け入れ条件\n\n- [ ] 条件1\n- [ ] 条件2\nEOF\n)\")\nCHILD_NUMBER=$(printf '%s' \"${CHILD_URL}\" | grep -oE '[0-9]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

)\n\n# Phase 親へ紐付け(sub_issue_id は database id)\nCHILD_ID=$(gh api \"repos/{owner}/{repo}/issues/${CHILD_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${CHILD_ID}\"\n\n# 粒度基準(既定 2h・--granularity)超の場合は sub-issue を作成して子 issue へ紐付け(同じく MILESTONE を付与)\nSUB_ARGS=(--title \"feat: サブタスク名\" --label \"phase:${PHASE}\")\nif [[ -n \"${MILESTONE}\" ]]; then\n SUB_ARGS+=(--milestone \"${MILESTONE}\")\nfi\nSUB_URL=$(gh issue create \"${SUB_ARGS[@]}\" --body \"...\")\nSUB_NUMBER=$(printf '%s' \"${SUB_URL}\" | grep -oE '[0-9]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

)\n\nSUB_ID=$(gh api \"repos/{owner}/{repo}/issues/${SUB_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${CHILD_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${SUB_ID}\"\n```\n\n**人手タスクに依存する実装タスク**は、本文に `## 前提` 節を置き、行頭に `- #N`(人手 issue の番号)を書く。\n書式は `implement-issue-tree` の依存宣言(見出し節の行頭の `#N`)に合わせる。「関連」「参考」を含む行は依存から除外される。\n番号が要るため、人手 issue を実装 issue より先に起票する。\n\n```markdown\n## 前提\n\n- #123\n```\n\n人手 issue を同じツリーに置くのは、`implement-issue-tree` を `skipLabels` 付きで回す前提である。付けずに回すと人手 issue も自動実装の対象になる。\n\nissue 数が多い場合(50 件超が目安)は `per_page=100` パラメータを使用し、ページネーションで全件確認する。\n\n```bash\n# ページネーション例(全 sub-issues を取得)\nPAGE=1\nwhile true; do\n RESULT=$(gh api \\\n \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues?per_page=100&page=${PAGE}\")\n COUNT=$(echo \"${RESULT}\" | jq 'length')\n echo \"${RESULT}\"\n if [ \"${COUNT}\" -lt 100 ]; then break; fi\n PAGE=$((PAGE + 1))\ndone\n```\n\n### Step 6: ルート issue 本文を Phase 別表で更新する\n\n全 issue の作成が完了したら、ルート issue 本文の Phase 別表を実際の issue 番号・件数で更新する。\n\n**`--root` での部分起票では本文を全置換しない。** 既存ルートの本文には先行 Phase の表が\n含まれるため、現在の本文を取得し、今回起票した Phase の行・セクションのみを追記・更新した\n本文で `gh issue edit` する。全置換は新規作成(`--root` 未指定)時のみ行い、その生成は `scripts/create-root-body.sh` に、\n`--root` 時のマージは `scripts/merge-root-body.sh` に任せる(本文生成ロジックを SKILL.md へ書かない)。\nStep 3 の雛形のままのルート(Step 6 到達前に中断した起票の再実行)でも、表のプレースホルダー行\n`| (作成後に更新) |` を今回の Phase 行へ置き換えて完走する。\n\n`--root` 指定時は `scripts/merge-root-body.sh` が既存本文を取得し、今回の Phase 行・セクションを\n実ツリーから生成してマージする(Issue #556)。各コードフェンスは独立したシェルで実行され得るため、\nこのフェンスは Step 3 / Step 4 と同形にスクリプトを 3 レイアウトから自己完結的に解決する。\nROOT_NUMBER(ルート issue 番号)・GRANULARITY(Step 1 で確定した粒度)・PHASE(Step 4 で確定した\nPhase 番号 N)・PHASE_NUMBER(その Phase 親 issue 番号)が未設定なら起動前に停止する。\n\n```bash\n: \"${ROOT_NUMBER:?ROOT_NUMBER 未設定}\" \"${GRANULARITY:?GRANULARITY 未設定}\" \\\n \"${PHASE:?PHASE 未設定}\" \"${PHASE_NUMBER:?PHASE_NUMBER 未設定}\"\n\nMERGE_ROOT_BODY=\"\"\nfor CANDIDATE in \\\n \"skills/create-issue-tree/scripts/merge-root-body.sh\" \\\n \".agents/skills/create-issue-tree/scripts/merge-root-body.sh\" \\\n \".claude/skills/create-issue-tree/scripts/merge-root-body.sh\"; do\n # 存在確認は -f のみ(vendoring で実行ビットが落ちても bash 経由で起動できる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n MERGE_ROOT_BODY=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${MERGE_ROOT_BODY}\" ]]; then\n echo \"エラー: merge-root-body.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n\n# echo を最終コマンドにしない。非ゼロ終了はそのままフェンスの終了ステータスにする\nstatus=0\nbash \"${MERGE_ROOT_BODY}\" --root \"${ROOT_NUMBER}\" --granularity \"${GRANULARITY}\" \\\n --phase \"${PHASE}\" --phase-number \"${PHASE_NUMBER}\" || status=$?\nif (( status != 0 )); then\n echo \"exit=${status}\" >&2\n exit \"${status}\"\nfi\n```\n\n**終了コード**(成功時の stdout 最終行は `result=merged root=\u003cn> phase=\u003cN>`。エラーメッセージは stderr)\n\n| exit | 意味 | 本文の更新 |\n|------|------|-----------|\n| 0 | 既存本文へ今回の Phase 分をマージし `gh issue edit` が成功 | あり |\n| 1 | 引数不正(`--root`・`--phase-number` は正整数、`--phase` は数字、`--granularity` は `^[1-9][0-9]*h create-issue-tree — Skillopedia )、既存本文・Phase 親・sub_issues・子孫の取得失敗、追記位置なし・Phase 行を出力不可、プレースホルダー残り・検査失敗、一時ファイル作成失敗 | なし |\n| 2 | 前提不備(`tree-lib.sh` を読み込めない、`gh`・`jq` が無い) | なし |\n| その他 | `gh issue edit` 自身の終了コード | 不明(実状態を確認する) |\n\nマージの不変条件: Phase 行・セクションは実ツリー(sub_issues API)からのみ作り、先行 Phase の表・自由記述は保持する /\nissue タイトルは非信頼データとして `&` `\u003c` `>` バッククォート・バックスラッシュ・`|` を HTML 文字参照へ変換し改行を空白にする / プレースホルダーが残る本文では\n`gh issue edit` しない(fail-closed)/ 一時ファイルは成否によらず削除する / granularity マーカーは常に\nGRANULARITY で書き直す / update-issue-tree の管理マーカー区間があれば内側だけを置換し、`\u003cdetails>` 内(退避節)は対象外 /\nマーカーが 1 組でない・除外後も同名見出しが複数ある・`\u003cdetails>` が閉じていない本文は中止する(行内・行末の `\u003c/details>` も閉じとして数えるが、除外〔素通し〕にするのは `\u003cdetails>` の内側で始まる行・開いたまま終わる行・行頭が素の `\u003cdetails>` / `\u003c/details>` の行だけで、表行・見出しの行頭以外のタグ文字列は置換対象のまま。表行・見出しが開いたまま終わる場合も中止する。管理マーカー区間の内側〔マーカーが無ければ本文全体。コードフェンス内を除く〕にバッククォート・`\u003c!--`・`-->`・バックスラッシュを含む行、または許可していない HTML タグ〔`\u003cdetails>` `\u003c/details>` `\u003csummary>` `\u003c/summary>` の属性の無い素の形を取り除いたあとに `\u003c` が残る行。属性付きの `\u003cdetails>`・`\u003cspan>`・`\u003cbr>` 等〕を含む行があれば、タグが文字か構造か判定できないため exit 1 で中止する。`>` 単独は中止しない)。詳細は `scripts/merge-root-body.sh` 冒頭コメントを参照。\n\n`--root` 未指定(新規作成)の場合は `scripts/create-root-body.sh` が実ツリーから本文を生成して更新する\n(Issue #555)。ヘルパー(`list_subs` 等)は両経路とも `scripts/tree-lib.sh` を共用する。\n\n同じ形式で、ROOT_NUMBER と GRANULARITY が未設定なら起動前に停止する。\n\n```bash\n: \"${ROOT_NUMBER:?ROOT_NUMBER 未設定}\" \"${GRANULARITY:?GRANULARITY 未設定}\"\n\nCREATE_ROOT_BODY=\"\"\nfor CANDIDATE in \\\n \"skills/create-issue-tree/scripts/create-root-body.sh\" \\\n \".agents/skills/create-issue-tree/scripts/create-root-body.sh\" \\\n \".claude/skills/create-issue-tree/scripts/create-root-body.sh\"; do\n # 存在確認は -f のみ(vendoring で実行ビットが落ちても bash 経由で起動できる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n CREATE_ROOT_BODY=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${CREATE_ROOT_BODY}\" ]]; then\n echo \"エラー: create-root-body.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n\n# echo を最終コマンドにしない。非ゼロ終了はそのままフェンスの終了ステータスにする\nstatus=0\nbash \"${CREATE_ROOT_BODY}\" --root \"${ROOT_NUMBER}\" --granularity \"${GRANULARITY}\" || status=$?\nif (( status != 0 )); then\n echo \"exit=${status}\" >&2\n exit \"${status}\"\nfi\n```\n\n**終了コード**(成功時の stdout 最終行は `result=updated root=\u003cn> phases=\u003ck>`。エラーメッセージは stderr)\n\n| exit | 意味 | 本文の更新 |\n|------|------|-----------|\n| 0 | 本文を生成し `gh issue edit` が成功 | あり |\n| 1 | 引数不正(`--root` は正整数・`--granularity` は `^[1-9][0-9]*h create-issue-tree — Skillopedia )、sub_issues 取得失敗、Phase 親 0 件、Phase 番号決定不可、子孫取得失敗、プレースホルダー残り・検査失敗、Phase 行数不一致、一時ファイル作成失敗 | なし |\n| 2 | 前提不備(`tree-lib.sh` を読み込めない、`gh`・`jq` が無い) | なし |\n| その他 | `gh issue edit` 自身の終了コード | 不明(実状態を確認する) |\n\n生成の不変条件: 表は実ツリー(sub_issues API)からのみ作る / issue タイトルは非信頼データとして\n`&` `\u003c` `>` バッククォート・バックスラッシュ・`|` を HTML 文字参照へ変換し改行を空白にする /\nプレースホルダーが残る本文では `gh issue edit` しない\n(fail-closed)/ 一時ファイルは成否によらず削除する。詳細は `scripts/create-root-body.sh` 冒頭コメントを参照。\n\n### Step 7: 作成結果を報告する\n\n```\n## create-issue-tree 完了レポート\n\n### ルート issue\n- #N: タイトル\n\n### Phase 別作成サマリー\n| Phase | 親 issue | 起票数 |\n|-------|----------|-------|\n| Phase 1 | #N | N 件 |\n\n### 作成した issue 一覧\n- #N: タイトル(Phase 1 / 親: #M)\n...\n\n### 次のアクション\n- 実装消化: implement-issue-tree スキルに ルート issue 番号を渡す。`owner:human` / `owner:joint` を使った場合は `skipLabels: [\"owner:human\",\"owner:joint\"]`(待ち一覧をルートへ通知するなら `notifyBlocked: true` も)を付ける\n- ツリー更新: update-issue-tree スキルでトラッキング issue を棚卸しする\n```\n\n## 検証\n\n- ルート issue の sub-issues に各 Phase 親 issue が列挙されていることを確認する\n- 各 Phase 親 issue の sub-issues に子 issue が列挙されていることを確認する\n- `gh issue view \"${ROOT_NUMBER}\"` でルート issue 本文の Phase 別表が正しく生成されていることを確認する\n- `gh issue view \"${ROOT_NUMBER}\" --json body --jq .body` に `\u003cphaseN_number>`・`#N`・`(作成後に更新)`・素の `N` セルが残っていないことを確認する(残っていれば Step 6 を再実行する)\n- `MILESTONE` を割り当てた場合、`gh issue view \u003cN> --json milestone --jq '.milestone.title'`\n でルート・Phase 親・子いずれも `${MILESTONE}` と一致することを確認する\n\n```bash\n# sub-issues は 1 ページ最大 100 件。100 件超のツリーは Step 5 のページネーション\n# ループで全件確認する(以下は per_page=100 で先頭ページのみ。件数が 100 未満なら全件)\n\n# ルート直下の sub-issues を確認\ngh api \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues?per_page=100\" --jq '.[].number'\n\n# Phase 親直下の sub-issues を確認\ngh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues?per_page=100\" --jq '.[].number'\n\n# phase ラベルの同期確認(Phase 親・子 issue にラベルが付いているか確認)\ngh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues?per_page=100\" \\\n --jq '.[] | {number: .number, labels: [.labels[].name]}'\n```\n\n## よくある失敗\n\n| 問題 | 回避策 |\n|------|--------|\n| `--root` を指定せず 2 回目の部分起票でルート issue が重複作成される | 2 回目以降は必ず `--root \u003c既存ルートissue番号>` を渡す |\n| `sub_issue_id` に issue 番号をそのまま渡す | `gh api .../issues/\u003cnumber> --jq '.id'` で database id を取得してから POST する |\n| phase ラベルが存在しないリポジトリで issue 作成が失敗する | Step 4 冒頭の `gh label create \"phase:${PHASE}\"` を必ず先に実行する |\n| `--root` 追記時に既存ルートの milestone が未設定なのに気づかず milestone なしで起票してしまう | リポジトリに milestone が存在する場合、Step 2.5 は継承結果が空ならユーザー確認フローへ自動的に合流する(確認で milestone を選ぶとルート issue にも反映される)。milestone が 1 件もない非運用リポジトリでは非運用ガードによる milestone なし起票が正常動作 |\n| closed 親の下に open issue が残置される | Phase 親を close する前に全子 issue の close を確認する |\n| Step 6 の本文を手書きのプレースホルダーのまま `gh issue edit` してルート本文が雛形で上書きされる | 実ツリーから表を生成する `scripts/create-root-body.sh`(新規作成)・`scripts/merge-root-body.sh`(`--root`)を使う。検査ガードに引っかかったら本文を送らず中止される |\n| Step 6 到達前に中断し、雛形のままのルートが残った | `--root \u003cルート番号>` を付けて再実行する。プレースホルダー行は今回の Phase 行へ置き換わる |\n| 人手 issue(`owner:*`)を置いたツリーを `skipLabels` なしで `implement-issue-tree` に渡し、人手 issue まで自動実装の対象になる | `skipLabels: [\"owner:human\",\"owner:joint\"]` を付けて実行する。ラベルは本人だけに効き子孫へ継承されない |\n| `--granularity` に `2 h`・`2`・`0h` 等を渡して中断される | 正整数+h 形式(`^[1-9][0-9]*h create-issue-tree — Skillopedia 。例: `2h`・`4h`)で指定する |\n\n## 注意事項\n\n- **1 issue は粒度基準(既定 2h・`--granularity` で変更可)程度に収める。** 超えると判断した場合は sub-issue に分解する\n- issue タイトルは Conventional Commits 形式を推奨(`feat:`・`fix:`・`chore:` 等)\n- `--phase` 指定で部分起票した場合、別 Phase の追加起票では **必ず `--root \u003c既存ルートissue番号>` を渡す**(Step 3 の新規作成をスキップして既存ツリーへ継ぎ足し、Step 6 も全置換せず既存本文へ差分追記する)。起票後は update-issue-tree に同じルート issue 番号を渡して棚卸しする\n- ページネーション: sub-issues が 100 件を超える場合は `per_page=100&page=N` でページングして全件取得する\n- Step 6 の本文生成は新規作成が `scripts/create-root-body.sh`、`--root` マージが `scripts/merge-root-body.sh`。本文生成ロジックを SKILL.md へ書き戻さない(肥大とヘルパー重複の再発防止)\n- シェルコマンドの変数は必ず `\"${var}\"` でクォートする(コマンドインジェクション対策)\n- **`gh issue create` は `--json` 非対応**。issue URL を stdout に出力するため、`| grep -oE '[0-9]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

` で末尾の番号を抽出して変数に保持する\n- **sub_issues API の `sub_issue_id` は issue 番号ではなく database id**(GitHub 仕様)。`gh api \"repos/{owner}/{repo}/issues/\u003cnumber>\" --jq '.id'` で id を取得してから POST する。番号をそのまま渡すと誤った issue を紐付ける/404 になる\n- 人手タスクは `owner:human` / `owner:joint` ラベルで区別し、依存する実装 issue には `## 前提` 節で `- #N` を書く。`implement-issue-tree` は `skipLabels` 付きで呼ぶ(Step 1・Step 5 参照)\n- phase ラベルは Step 4 冒頭の `gh label create \"phase:${PHASE}\" --color \"0075ca\"` で issue 作成より前に必ず作成する(作成済みリポジトリでは no-op)","attachment_filenames":["scripts/create-root-body.sh","scripts/merge-root-body.sh","scripts/tree-lib.sh","tests/step6-root-body.test.mjs"],"attachments":[{"filename":"scripts/create-root-body.sh","content":"#!/usr/bin/env bash\n# create-root-body.sh — 新規作成したツリーのルート(トラッキング)issue 本文を実ツリーから生成して更新する\n#\n# 呼び出し元: skills/create-issue-tree/SKILL.md Step 6(--root 未指定の新規作成経路)。\n# 役割: 旧 Step 6 は約 125 行の bash を SKILL.md に埋め込み、実行時の Claude が転記して実行していた。\n# レビュー修正のたびに SKILL.md が肥大するため、本文生成を本スクリプトへ切り出した(Issue #555)。\n# Phase 別表は sub_issues API の実ツリーだけから生成し、手書きのプレースホルダーを本文に載せない。\n#\n# 使い方: create-root-body.sh --root \u003cn> --granularity \u003cNh>\n# 引数が無ければ環境変数 ROOT_NUMBER / GRANULARITY を使う(SKILL.md の既存フロー互換)。\n#\n# 不変条件:\n# - 表は実ツリーからのみ生成し、タイトルは非信頼データとして無害化する(tree-lib.sh の CELL)。\n# - プレースホルダー残り・Phase 行数不一致の本文では gh issue edit を呼ばない(fail-closed)。\n# - 一時ファイルは成否によらず削除する(trap は mktemp より先に登録)。\n#\n# 終了コード: 0 本文を更新した / 1 入力不正・取得失敗・検査失敗(本文は未更新)/\n# 2 前提不備(tree-lib.sh・gh・jq が使えない。本文は未更新)/\n# それ以外は gh issue edit 自身の終了コード。\n# 成功時の stdout 最終行: result=updated root=\u003cn> phases=\u003ck>。エラーは stderr へ出す。\n#\n# set -e は使わない。本スクリプトは各所の `|| die ...` で失敗を明示処理する設計で、\n# `[ ... ] && break` 等の -e 非互換パターンを含むため。単体実行用であり source しない。\nset -uo pipefail\n\ndie() {\n local code=\"$1\"\n shift\n echo \"エラー: $*\" >&2\n exit \"${code}\"\n}\n\nROOT_NUMBER=\"${ROOT_NUMBER:-}\"\nGRANULARITY=\"${GRANULARITY:-}\"\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --root | --granularity)\n [[ $# -ge 2 ]] || die 1 \"$1 に値がありません。使い方: create-root-body.sh --root \u003cn> --granularity \u003cNh>\"\n if [[ \"$1\" == \"--root\" ]]; then ROOT_NUMBER=\"$2\"; else GRANULARITY=\"$2\"; fi\n shift 2\n ;;\n *) die 1 \"未知の引数: $1。使い方: create-root-body.sh --root \u003cn> --granularity \u003cNh>\" ;;\n esac\ndone\n[[ \"${ROOT_NUMBER}\" =~ ^[1-9][0-9]*$ ]] || die 1 \"--root(ROOT_NUMBER)は正整数で指定してください。\"\n[[ \"${GRANULARITY}\" =~ ^[1-9][0-9]*h$ ]] || die 1 \"--granularity(GRANULARITY)は正整数+h 形式(例: 2h)で指定してください。\"\n\n# gh・jq の欠落はライブラリ読み込み失敗と区別して前提不備(exit 2)にする\ncommand -v gh >/dev/null 2>&1 || die 2 \"gh が見つかりません。\"\ncommand -v jq >/dev/null 2>&1 || die 2 \"jq が見つかりません。\"\n# shellcheck source=tree-lib.sh\nsource \"$(dirname \"${BASH_SOURCE[0]}\")/tree-lib.sh\" 2>/dev/null \\\n || die 2 \"tree-lib.sh を読み込めません。\"\n\n# trap を mktemp より先に登録する(2 回目以降の mktemp 失敗でも作成済みファイルを削除するため)\nBODY_FILE=''; SUMMARY_FILE=''; DETAIL_FILE=''\ntrap 'rm -f \"${BODY_FILE}\" \"${SUMMARY_FILE}\" \"${DETAIL_FILE}\"' EXIT\nBODY_FILE=$(mktemp) && SUMMARY_FILE=$(mktemp) && DETAIL_FILE=$(mktemp) \\\n || die 1 \"一時ファイルを作成できません。中止します。\"\n\nPHASES=$(list_subs \"${ROOT_NUMBER}\") \\\n || die 1 \"ルート #${ROOT_NUMBER} の sub-issues を取得できません。中止します。\"\nPHASE_COUNT=$(printf '%s' \"${PHASES}\" | jq 'length')\n[ \"${PHASE_COUNT}\" -ge 1 ] \\\n || die 1 \"ルート #${ROOT_NUMBER} 直下に Phase 親がありません。中止します。\"\n\nfor i in $(seq 0 $((PHASE_COUNT - 1))); do\n PNUM=$(printf '%s' \"${PHASES}\" | jq -r --argjson i \"${i}\" '.[$i].number')\n PTITLE=$(printf '%s' \"${PHASES}\" | jq -r --argjson i \"${i}\" \".[\\$i].title | ${CELL}\")\n # Phase 番号は位置(i + 1)で採番しない。--phase 2 等の部分起票では最初の親が Phase 2 になるため、\n # Step 4 で付与した phase:N ラベルから取得する(無ければ title の feat(phase-N): から取得)。\n # どちらからも決められなければ誤記録せず中止する(fail-closed)\n PNO=$(printf '%s' \"${PHASES}\" | jq -r --argjson i \"${i}\" '.[$i] as $p\n | ([$p.labels[]?.name | select(test(\"^phase:[0-9]+$\")) | sub(\"^phase:\"; \"\")][0]\n // ($p.title | capture(\"^feat\\\\(phase-(?\u003cn>[0-9]+)\\\\):\").n) // empty)') \\\n || PNO=''\n [[ \"${PNO}\" =~ ^[0-9]+$ ]] \\\n || die 1 \"Phase 親 #${PNUM} の Phase 番号(phase:N ラベル / タイトル)を決定できません。中止します。\"\n CHILDREN=$(list_subs \"${PNUM}\") \\\n || die 1 \"Phase 親 #${PNUM} の sub-issues を取得できません。中止します。\"\n CHILDREN=$(printf '%s' \"${CHILDREN}\" | jq '[.[] | select(.state == \"open\")]')\n DIRECT=$(printf '%s' \"${CHILDREN}\" | jq 'length')\n TOTAL=${DIRECT}\n\n {\n printf '\\n### Phase %s: %s\\n\\n' \"${PNO}\" \"${PTITLE}\"\n printf '| Issue | タイトル | 分解 |\\n|-------|---------|------|\\n'\n } >> \"${DETAIL_FILE}\"\n\n for j in $(seq 0 $((DIRECT - 1))); do\n [ \"${DIRECT}\" -ge 1 ] || break\n CNUM=$(printf '%s' \"${CHILDREN}\" | jq -r --argjson j \"${j}\" '.[$j].number')\n CTITLE=$(printf '%s' \"${CHILDREN}\" | jq -r --argjson j \"${j}\" \".[\\$j].title | ${CELL}\")\n GRAND_OPEN=$(count_open_desc \"${CNUM}\") \\\n || die 1 \"#${CNUM} の子孫を取得できません。中止します。\"\n TOTAL=$((TOTAL + GRAND_OPEN))\n if [ \"${GRAND_OPEN}\" -ge 1 ]; then DECOMP='sub-issue あり'; else DECOMP='-'; fi\n printf '| #%s | %s | %s |\\n' \"${CNUM}\" \"${CTITLE}\" \"${DECOMP}\" >> \"${DETAIL_FILE}\"\n done\n\n printf '| Phase %s | #%s %s | %s | %s |\\n' \"${PNO}\" \"${PNUM}\" \"${PTITLE}\" \"${DIRECT}\" \"${TOTAL}\" >> \"${SUMMARY_FILE}\"\ndone\n\n{\n printf '\u003c!-- granularity: %s -->\\n' \"${GRANULARITY}\"\n cat \u003c\u003c'BODY_EOF'\n## 概要\n\n全 open issue を Phase 別に 1 ツリーへ整理する。各 Phase 親 issue を sub-issues として紐付け。\n\n## Phase 別実装計画\n\n| Phase | 親 issue | 直下 | 総 open 件数 |\n|-------|----------|------|-------------|\nBODY_EOF\n cat \"${SUMMARY_FILE}\"\n cat \"${DETAIL_FILE}\"\n cat \u003c\u003c'BODY_EOF'\n\n## 運用\n\n- 新規 issue は起票時に Phase 親へ紐付ける\n- 実行順は sub-issues リスト順が正\n- closed 親の下に open issue を残置しない\n- implement-issue-tree が post-order DFS で消化可能な構造を維持する\nBODY_EOF\n} > \"${BODY_FILE}\"\n\n# プレースホルダー検査(fail-closed)。grep の終了コードは 0=ヒット / 1=なし / 2 以上=失敗。\n# 2 以上は「残りなし」へ倒さず検査失敗として中止する\nrc=0; grep -qE \"${TREE_PLACEHOLDER_RE}\" \"${BODY_FILE}\" || rc=$?\n[ \"${rc}\" -eq 1 ] \\\n || die 1 \"本文にプレースホルダーが残っている、または検査に失敗した(grep exit ${rc})。ルート本文は更新しません。\"\nrc=0; ROWS=$(grep -cE '^\\| Phase [0-9]+ \\| #[0-9]+ ' \"${BODY_FILE}\") || rc=$?\n{ [ \"${rc}\" -le 1 ] && [ \"${ROWS}\" -eq \"${PHASE_COUNT}\" ]; } \\\n || die 1 \"Phase 行数(${ROWS:-?})が Phase 親数(${PHASE_COUNT})と一致しません。ルート本文は更新しません。\"\n\ngh issue edit \"${ROOT_NUMBER}\" --body-file \"${BODY_FILE}\" || exit $?\necho \"result=updated root=${ROOT_NUMBER} phases=${PHASE_COUNT}\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":7491,"content_sha256":"f472c52e32944eadac1ebbe237840325fc533fd7b0eecd71a8168d059c64ec1d"},{"filename":"scripts/merge-root-body.sh","content":"#!/usr/bin/env bash\n# merge-root-body.sh — 既存ルート issue の本文へ、今回起票した Phase の行・セクションだけをマージして更新する\n#\n# 呼び出し元: skills/create-issue-tree/SKILL.md Step 6(--root 指定の部分起票経路)。\n# 役割: 旧 Step 6 は --root 用の約 125 行の bash を SKILL.md に埋め込み、実行時の Claude が転記して\n# 実行していた。レビュー修正のたびに SKILL.md が肥大し、ヘルパーも tree-lib.sh と二重化して\n# いたため、マージ処理を本スクリプトへ切り出した(Issue #556。新規作成経路は create-root-body.sh)。\n# 先行 Phase の表・自由記述は保持し、今回の Phase 行とセクションだけを実ツリーから生成して差し替える。\n#\n# 使い方: merge-root-body.sh --root \u003cn> --granularity \u003cNh> --phase \u003cN> --phase-number \u003cn>\n# 引数が無ければ環境変数 ROOT_NUMBER / GRANULARITY / PHASE / PHASE_NUMBER を使う。\n#\n# Step 3 雛形との対応(追記位置の契約):\n# - 表のプレースホルダー行 `| (作成後に更新) |` は、実在の Phase 行が 1 つも無い雛形のままのルートで\n# 今回の Phase 行へ置き換える位置として awk が読む。`## 運用` 見出しは Phase セクションの挿入位置。\n# 文言を変えるときは Step 3 の雛形とテスト (n) を合わせて直す。\n#\n# 不変条件:\n# - Phase 行・セクションは実ツリーからのみ生成し、タイトルは非信頼データとして無害化する(CELL)。\n# - 最終本文にプレースホルダーが残る・検査に失敗する場合は gh issue edit を呼ばない(fail-closed)。\n# - セクションは一時ファイル、行は ENVIRON 経由で awk へ渡す(環境変数長上限の回帰防止。SEC= で渡さない)。\n# - コードフェンス内の行は見出し・表行・`## 運用` として扱わない(フェンス内の `# コメント` が置換対象セクションの\n# 終端に見えて旧内容が残るのを防ぐ)。閉じ忘れのフェンスは追記位置を判定できないため中止する(Issue #557)。\n# - update-issue-tree の管理マーカー(`\u003c!-- update-issue-tree:phase-plan:begin/end -->`)がある本文では区間の内側だけを\n# 置換対象にし、外側は素通しする。マーカーが無くても `\u003cdetails>` 内(update-issue-tree が退避した旧表など)は対象外。\n# マーカーが 1 組でない・除外後も同名の見出しが複数ある・`\u003cdetails>` が閉じていない本文は、置換先を決められないため中止する。\n# 開閉として数えるのは属性の無い `\u003cdetails>` と `\u003c/details>` だけ。判定範囲の行に `\u003c` があってよいのは `\u003cdetails>` `\u003c/details>`\n# `\u003csummary>` `\u003c/summary>`(いずれも属性の無い素の形)の一部である場合だけで、これらを取り除いた後に `\u003c` が残る行\n# (属性付きの details・span 等の別タグ・属性値の中の `\u003c/details>`・行をまたぐタグ・`>` を欠く閉じタグ)は exit 9 で中止する。`>` 単独は中止しない。\n# 行内・行末の `\u003c/details>` も閉じとして数える(1 行に複数あれば全て数える)。ただし除外(素通し)にするのは `\u003cdetails>` の\n# 内側で始まる行・開いたまま終わる行・行頭(空白のみ前置)が素の `\u003cdetails>` / `\u003c/details>` の行だけ。表行・見出しの行頭以外に\n# あるタグ文字列は旧版が書いたタイトルの可能性が高く構造ではないので除外せず、置換と読み飛ばしの対象にする(Issue #689)。\n# 表行・見出しが外側で始まり開いたまま終わる場合は構造かタイトルか決められないため中止する(awk 内部では exit 10)。\n# コードスパン・HTML コメント・エスケープの状態は追わない。判定範囲(管理マーカーがあれば区間の内側、無ければ本文全体。\n# コードフェンス内と先頭の granularity マーカー行を除く)の行にバッククォート・`\u003c!--`・`-->`・バックスラッシュのいずれかが\n# あれば、タグが文字か構造か決められないため何も書かず中止する(exit 1・stderr に理由。awk 内部では exit 9)。\n# - 一時ファイルは成否によらず削除する(trap は mktemp より先に登録)。\n# - granularity マーカーは常に GRANULARITY で先頭へ 1 行だけ書き直す。\n#\n# 終了コード: 0 本文をマージして更新した / 1 入力不正・取得失敗・追記位置なし・検査失敗(本文は未更新)/\n# 2 前提不備(gh・jq・tree-lib.sh が使えない。本文は未更新)/\n# それ以外は gh issue edit 自身の終了コード(実状態の確認が必要)。\n# 成功時の stdout 最終行: result=merged root=\u003cn> phase=\u003cN>。エラーは stderr へ出す。\n#\n# set -e は使わない。本文がマーカー 1 行だけのとき grep -v が exit 1 を返すのは正常系であり、\n# `[ ... ] && break` 等の -e 非互換パターンも含むため、失敗は `|| die` で明示処理する。単体実行用で source しない。\nset -uo pipefail\n\nUSAGE='使い方: merge-root-body.sh --root \u003cn> --granularity \u003cNh> --phase \u003cN> --phase-number \u003cn>'\n\ndie() {\n local code=\"$1\"\n shift\n echo \"エラー: $*\" >&2\n exit \"${code}\"\n}\n\nROOT_NUMBER=\"${ROOT_NUMBER:-}\"\nGRANULARITY=\"${GRANULARITY:-}\"\nPHASE=\"${PHASE:-}\"\nPHASE_NUMBER=\"${PHASE_NUMBER:-}\"\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --root | --granularity | --phase | --phase-number)\n [[ $# -ge 2 ]] || die 1 \"$1 に値がありません。${USAGE}\"\n case \"$1\" in\n --root) ROOT_NUMBER=\"$2\" ;;\n --granularity) GRANULARITY=\"$2\" ;;\n --phase) PHASE=\"$2\" ;;\n --phase-number) PHASE_NUMBER=\"$2\" ;;\n esac\n shift 2\n ;;\n *) die 1 \"未知の引数: $1。${USAGE}\" ;;\n esac\ndone\n[[ \"${ROOT_NUMBER}\" =~ ^[1-9][0-9]*$ ]] || die 1 \"--root(ROOT_NUMBER)は正整数で指定してください。\"\n[[ \"${GRANULARITY}\" =~ ^[1-9][0-9]*h$ ]] || die 1 \"--granularity(GRANULARITY)は正整数+h 形式(例: 2h)で指定してください。\"\n# PHASE は awk の正規表現へ入るため、数字以外は API 呼び出し前に拒否する\n[[ \"${PHASE}\" =~ ^[0-9]+$ ]] || die 1 \"--phase(PHASE)は数字で指定してください。\"\n[[ \"${PHASE_NUMBER}\" =~ ^[1-9][0-9]*$ ]] || die 1 \"--phase-number(PHASE_NUMBER)は正整数で指定してください。\"\n\n# gh・jq の欠落はライブラリ読み込み失敗と区別して前提不備(exit 2)にする\ncommand -v gh >/dev/null 2>&1 || die 2 \"gh が見つかりません。\"\ncommand -v jq >/dev/null 2>&1 || die 2 \"jq が見つかりません。\"\n# shellcheck source=tree-lib.sh\nsource \"$(dirname \"${BASH_SOURCE[0]}\")/tree-lib.sh\" 2>/dev/null \\\n || die 2 \"tree-lib.sh を読み込めません。\"\n\n# trap を mktemp より先に登録する(mktemp 失敗時も作成済みファイルを削除するため)\nSEC_FILE=''\nCHK_FILE=''\ntrap 'rm -f \"${SEC_FILE}\" \"${CHK_FILE}\"' EXIT\n\nCURRENT_BODY=$(gh issue view \"${ROOT_NUMBER}\" --json body --jq '.body') \\\n || die 1 \"ルート issue #${ROOT_NUMBER} の本文を取得できません。中止します。\"\n\n# \u003c!-- granularity: Nh --> マーカーは常に GRANULARITY で先頭へ 1 行だけ書き直す。\n# 既存本文に旧マーカー行があれば重複させないため、先に取り除いてから先頭へ再出力する。\n# 本文がマーカー 1 行だけだと grep -v は exit 1 を返すが正常系なので失敗扱いにしない(2 以上のみ失敗)\nrc=0; CURRENT_BODY=$(printf '%s\\n' \"${CURRENT_BODY}\" | grep -vE '^\u003c!-- granularity: [1-9][0-9]*h -->

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

) || rc=$?\n[ \"${rc}\" -le 1 ] || die 1 \"既存本文の granularity マーカー除去に失敗しました(grep exit ${rc})。中止します。\"\nNEW_BODY=\"$(printf '\u003c!-- granularity: %s -->\\n' \"${GRANULARITY}\"; printf '%s\\n' \"${CURRENT_BODY}\")\"\n\n# 今回の Phase 行(PHASE_ROW)と「### Phase N: ...」セクション(PHASE_SECTION)を実ツリーから生成する\nPTITLE=$(gh issue view \"${PHASE_NUMBER}\" --json title --jq \".title | ${CELL}\") \\\n || die 1 \"Phase 親 #${PHASE_NUMBER} を取得できません。中止します。\"\nCHILDREN=$(list_subs \"${PHASE_NUMBER}\") \\\n || die 1 \"Phase 親 #${PHASE_NUMBER} の sub-issues を取得できません。中止します。\"\n# jq が失敗(API 応答の形が想定外)したまま空値で Phase 行・セクションを作ると、既存本文を誤内容で\n# 上書きし得るため、変換ごとに終了コードと値の形を確認して失敗時は本文未更新で中止する(fail-closed)\nCHILDREN=$(printf '%s' \"${CHILDREN}\" | jq '[.[] | select(.state == \"open\")]') \\\n || die 1 \"Phase 親 #${PHASE_NUMBER} の sub-issues の絞り込みに失敗しました。ルート本文は更新しません。\"\nDIRECT=$(printf '%s' \"${CHILDREN}\" | jq 'length') \\\n || die 1 \"Phase 親 #${PHASE_NUMBER} の子 issue 数の集計に失敗しました。ルート本文は更新しません。\"\n[[ \"${DIRECT}\" =~ ^[0-9]+$ ]] \\\n || die 1 \"Phase 親 #${PHASE_NUMBER} の子 issue 数が数値ではありません。ルート本文は更新しません。\"\nTOTAL=${DIRECT}\n\nPHASE_SECTION=\"### Phase ${PHASE}: ${PTITLE}\"

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

\\n\\n'\"| Issue | タイトル | 分解 |\"

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

\\n'\"|-------|---------|------|\"\nfor j in $(seq 0 $((DIRECT - 1))); do\n [ \"${DIRECT}\" -ge 1 ] || break\n CNUM=$(printf '%s' \"${CHILDREN}\" | jq -r --argjson j \"${j}\" '.[$j].number') \\\n || die 1 \"子 issue の番号を取得できません。ルート本文は更新しません。\"\n [[ \"${CNUM}\" =~ ^[1-9][0-9]*$ ]] \\\n || die 1 \"子 issue の番号が正整数ではありません。ルート本文は更新しません。\"\n CTITLE=$(printf '%s' \"${CHILDREN}\" | jq -er --argjson j \"${j}\" \".[\\$j].title | ${CELL}\") \\\n || die 1 \"#${CNUM} のタイトルを取得できません。ルート本文は更新しません。\"\n GRAND_OPEN=$(count_open_desc \"${CNUM}\") \\\n || die 1 \"#${CNUM} の子孫を取得できません。中止します。\"\n TOTAL=$((TOTAL + GRAND_OPEN))\n if [ \"${GRAND_OPEN}\" -ge 1 ]; then DECOMP='sub-issue あり'; else DECOMP='-'; fi\n PHASE_SECTION+=

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

\\n'\"| #${CNUM} | ${CTITLE} | ${DECOMP} |\"\ndone\nPHASE_ROW=\"| Phase ${PHASE} | #${PHASE_NUMBER} ${PTITLE} | ${DIRECT} | ${TOTAL} |\"\n\n# Phase N が既存本文にあれば、その行とセクションを置き換える(再利用した Phase 親の件数更新)。\n# 無ければ表の最終行('| Phase N |' 行)の直後へ行を挿入し、セクションは既存 Phase セクションと同じ位置\n# ('## 運用' の直前)へ挿入する('## 運用' が無い本文のみ末尾へ追加する)。\n# 実在の Phase 行が 1 つも無い Step 3 雛形のままの本文では、'| (作成後に更新) |' 行の位置へ今回の Phase 行を\n# 置き換えて挿入する。このプレースホルダー行は実在行の有無によらず出力しない。\n# 追記位置がどちらも無い、または行を出力できなかった場合は awk が exit 3 で中止する(fail-closed)。\n# コードフェンス(行頭 0〜3 空白の ``` / ~~~、閉じは同記号で開き以上の長さ)の内側の行は F[] で印を付け、\n# 見出し・表行・プレースホルダー・運用見出しの判定から外す。ユーザー編集可能な本文で閉じ忘れフェンスがあると\n# 以降が全てフェンス内扱いになり実セクションを消し得るため、閉じていなければ exit 5 で本文未更新のまま中止する。\n# 管理マーカー区間と \u003cdetails> の内側は X[] で対象外にし、マーカーの不整合は exit 6、除外後も置換対象の見出しが複数なら exit 7、\u003cdetails> の閉じ忘れは exit 8、表行・見出しが開いたまま終わる \u003cdetails> は exit 10、判定範囲内の曖昧な記号(バッククォート・\u003c!--・-->・バックスラッシュ)と許可していない HTML タグ(\u003cdetails> / \u003csummary> の素の形以外)は exit 9 で中止する。\n# 区間指定 {n,m} や gawk 拡張は mawk・BSD awk 非対応のため使わない。\n# セクションは全子 issue の行を含み環境変数・引数の長さ上限を超え得るため、一時ファイル経由で渡す。\n# 行(1 行)は ENVIRON で渡し、いずれもシェル構文・awk 構文として再評価させない\nSEC_FILE=$(mktemp) && printf '%s\\n' \"${PHASE_SECTION}\" > \"${SEC_FILE}\" \\\n || die 1 \"Phase セクションの一時ファイルを作成できません。中止します。\"\nCHK_FILE=$(mktemp) || die 1 \"検査用の一時ファイルを作成できません。中止します。\"\nrc=0\nNEW_BODY=$(printf '%s\\n' \"${NEW_BODY}\" | PH=\"${PHASE}\" ROW=\"${PHASE_ROW}\" SECF=\"${SEC_FILE}\" CHKF=\"${CHK_FILE}\" awk '\n # 開きフェンス行なら 1 を返し、記号 fch と長さ flen を保存する。info 文字列に ` を含む ``` 行は開きではない\n function fence_open(line, ind, rest, len, info) {\n match(line, /^ */); ind = RLENGTH\n if (ind > 3) return 0\n rest = substr(line, ind + 1)\n if (match(rest, /^`+/)) {\n len = RLENGTH; info = substr(rest, len + 1)\n if (len \u003c 3 || index(info, \"`\") > 0) return 0\n fch = \"`\"; flen = len; return 1\n }\n if (match(rest, /^~+/)) {\n if (RLENGTH \u003c 3) return 0\n fch = \"~\"; flen = RLENGTH; return 1\n }\n return 0\n }\n # 閉じフェンス行なら 1 を返す。同じ記号が開き以上の長さで続き、後ろは空白・タブ・CR のみ\n function fence_close(line, ind, rest, len) {\n match(line, /^ */); ind = RLENGTH\n if (ind > 3) return 0\n rest = substr(line, ind + 1)\n # 三項演算子に正規表現リテラルを置くと $0 との照合結果(0/1)になるため、文字列で渡す\n if (!match(rest, (fch == \"`\") ? \"^`+\" : \"^~+\")) return 0\n len = RLENGTH\n return (len >= flen && substr(rest, len + 1) ~ /^[ \\t\\r]*$/)\n }\n # 本文へ 1 行出力する。passthru = 1(素通しの退避部分)でなければ検査用ファイルにも書く\n function emit(text, passthru) {\n print text\n if (!passthru) print text > ENVIRON[\"CHKF\"]\n }\n BEGIN {\n sec = \"\"; n = 0\n while ((rc = (getline sl \u003c ENVIRON[\"SECF\"])) > 0) { sec = (n++ ? sec \"\\n\" : \"\") sl }\n if (rc \u003c 0) exit 4\n }\n {\n L[NR] = $0\n if (infence) { F[NR] = 1; if (fence_close($0)) infence = 0 }\n else if (fence_open($0)) { F[NR] = 1; infence = 1 }\n }\n END {\n if (infence) exit 5\n ph = ENVIRON[\"PH\"]; row_re = \"^[|] Phase \" ph \" [|]\"; sec_re = \"^### Phase \" ph \":\"\n row_done = 0; sec_done = 0; skip = 0; has_sec = 0\n # 管理マーカー区間(update-issue-tree の phase-plan)と \u003cdetails> は置換対象から外す(X[] = 1)。\n # マーカーがあれば区間の内側だけが対象で外側は素通し。無ければ \u003cdetails> の外側が対象。\n # マーカーの個数・順序が不整合なら対象範囲を決められないため exit 6 で中止する\n nb = 0; ne = 0; depth = 0\n for (k = 1; k \u003c= NR; k++) {\n if (F[k]) continue\n if (L[k] ~ /^[ \\t]*\u003c!-- update-issue-tree:phase-plan:begin -->[ \\t\\r]*$/) { nb++; bpos = k }\n else if (L[k] ~ /^[ \\t]*\u003c!-- update-issue-tree:phase-plan:end -->[ \\t\\r]*$/) { ne++; epos = k }\n }\n if (nb > 1 || ne > 1 || nb != ne || (nb == 1 && bpos > epos)) exit 6\n # 判定範囲: 管理マーカーがあれば区間の内側だけ(外側は常に素通しで \u003cdetails> の判定に関わらない)、無ければ本文全体。\n # 範囲内の行にコードスパン・HTML コメント・エスケープの記号(バッククォート・\u003c!--・-->・バックスラッシュ)があると、\n # \u003cdetails> の開閉が文字か構造かを状態追跡なしには決められないため、何も書かず exit 9 で中止する(fail-closed)。\n # コードフェンス内の行(F[])は走査しない。先頭の granularity マーカー行(本スクリプトが付ける 1 行)は記号を含むが対象外。\n # 記号が無ければ状態を追わず、各行の開き・閉じタグを左から全て数える(除外する行の条件は下のループ内に記す)。\n lo = 1; hi = NR\n if (nb == 1) {\n lo = bpos + 1; hi = epos - 1\n for (k = 1; k \u003c= NR; k++) if (k \u003c= bpos || k >= epos) { X[k] = 1; XO[k] = 1 }\n }\n for (k = lo; k \u003c= hi; k++) {\n if (F[k]) { if (depth > 0) X[k] = 1; continue }\n s = L[k]\n if (s ~ /^\u003c!-- granularity: [1-9][0-9]*h -->[ \\t\\r]*$/) continue\n if (index(s, \"`\") || index(s, \"\u003c!--\") || index(s, \"-->\") || index(s, \"\\\\\")) exit 9\n # 許可するタグは \u003cdetails> \u003c/details> \u003csummary> \u003c/summary> の属性の無い素の形だけ。これらを取り除いたあとに \u003c が残る行\n # (属性付きの details・span などの別タグ・属性値の中の \u003c/details>・行をまたぐタグ・> を欠く閉じタグ)は、\n # 属性値や別タグの中の \u003c/details> と構造を区別できないため、何も書かず exit 9 で中止する。> 単独は見ない\n t = s; gsub(/\u003c\\/?details>|\u003c\\/?summary>/, \"\", t)\n if (index(t, \"\u003c\")) exit 9\n before = depth\n while (match(s, /\u003c\\/details>|\u003cdetails>/)) {\n if (substr(s, RSTART, 2) == \"\u003c/\") { if (depth > 0) depth-- }\n else depth++\n s = substr(s, RSTART + RLENGTH)\n }\n # 除外するのは details の構造に当たる行(内側で始まる・開いたまま終わる・行頭が素の開き/閉じタグ)だけ。\n # 表行・見出しの行頭以外にあるタグ文字列は旧版が書いたタイトルの可能性が高く、構造ではないので除外しない(Issue #689)。\n # ただし表行・見出しが外側で始まり開いたまま終わる場合は、構造かタイトルか決められないため exit 10 で中止する\n if (before == 0 && depth > 0 && (L[k] ~ /^[|]/ || L[k] ~ /^#+[ \\t]/)) exit 10\n if (before > 0 || depth > 0 || L[k] ~ /^[ \\t]*\u003c\\/?details>/) X[k] = 1\n }\n # 閉じ忘れの \u003cdetails> は、以降の行が全て除外されて後ろの見出しが置換対象から外れるため、範囲を判定できず中止する\n if (depth > 0) exit 8\n # 追記位置(最終 Phase 行・プレースホルダー行)と置換対象の見出し数は、対象範囲の行だけから数える\n nsec = 0\n for (k = 1; k \u003c= NR; k++) {\n if (F[k] || X[k]) continue\n if (L[k] ~ /^[|] Phase [0-9]+ [|]/) last = k\n else if (L[k] ~ /^[|] [(]作成後に更新[)] [|][ |]*$/) { T[k] = 1; if (!tmpl) tmpl = k }\n if (L[k] ~ sec_re) { has_sec = 1; nsec++ }\n }\n if (nsec > 1) exit 7\n if (last == 0 && tmpl == 0) exit 3\n for (i = 1; i \u003c= NR; i++) {\n line = L[i]\n # 旧節の除去は次の見出しか区間の外へ出る境界(end マーカー)まで続ける。途中の通常コメント行・表は旧節の一部として落とし、\n # 保護対象の \u003cdetails>(退避節)だけは落とさず出力する\n if (skip && XO[i]) { skip = 0; emit(\"\", 0) }\n if (skip && X[i]) { emit(line, 1); continue }\n if (F[i] && !X[i]) { if (skip) continue; emit(line, 0); continue }\n if (X[i]) {\n # マーカー区間の末尾で、置換対象の節がまだ無ければ区間の内側(end マーカーの直前)へ新節を入れる\n if (nb == 1 && i == epos && !sec_done && !has_sec) { emit(sec, 0); emit(\"\", 0); sec_done = 1 }\n emit(line, 1); continue\n }\n if (skip) { if (line ~ /^(#|##|###) /) { skip = 0; emit(\"\", 0) } else continue }\n if (i in T) { if (last == 0 && i == tmpl && !row_done) { emit(ENVIRON[\"ROW\"], 0); row_done = 1 }; continue }\n if (line ~ row_re) { if (!row_done) { emit(ENVIRON[\"ROW\"], 0); row_done = 1 }; continue }\n if (line ~ sec_re) { if (!sec_done) { emit(sec, 0); sec_done = 1 }; skip = 1; continue }\n if (line ~ /^## 運用/ && !sec_done && !has_sec) { emit(sec, 0); emit(\"\", 0); sec_done = 1 }\n emit(line, 0)\n if (i == last && !row_done) { emit(ENVIRON[\"ROW\"], 0); row_done = 1 }\n }\n if (!sec_done) { emit(\"\", 0); emit(sec, 0) }\n # 唯一のプレースホルダー行が置換対象セクション内にあると skip 分岐で読み飛ばされ、行を出力しないまま終わる\n if (!row_done) exit 3\n }') || rc=$?\nif [ \"${rc}\" -eq 5 ]; then\n die 1 \"ルート本文のコードフェンスが閉じていないため追記位置を判定できません。ルート本文は更新しません。\"\nelif [ \"${rc}\" -eq 6 ]; then\n die 1 \"update-issue-tree の管理マーカー(phase-plan:begin / end)が 1 組になっていないため、置換対象の範囲を判定できません。ルート本文は更新しません。\"\nelif [ \"${rc}\" -eq 7 ]; then\n die 1 \"置換対象の '### Phase ${PHASE}:' 見出しが管理マーカー・\u003cdetails> を除いても複数あり、どれを置換するか判定できません。ルート本文は更新しません。\"\nelif [ \"${rc}\" -eq 8 ]; then\n die 1 \"ルート本文の \u003cdetails> が閉じていないため置換対象の範囲を判定できません。ルート本文は更新しません。\"\nelif [ \"${rc}\" -eq 9 ]; then\n die 1 \"ルート本文の \u003cdetails> を判定する範囲に、バッククォート・\u003c!--・-->・バックスラッシュのいずれかを含む行、または許可していない HTML タグ(\u003cdetails> / \u003csummary> の素の形以外。属性付きの \u003cdetails>・\u003cspan> 等・> を欠く \u003c/details など)を含む行があり、\u003cdetails> の開閉を判定できません。該当記号を取り除くか、update-issue-tree の管理マーカー区間の外へ移してください。旧形式のエスケープ(タイトル中の | や \\\\ を \\\\| ・\\\\\\\\ と書いた旧版の生成行)の可能性もあります。その場合は該当行を手で文字参照(| ・\ ・`)へ置き換えてください。update-issue-tree が書いた管理マーカー区間内の行も対象です。管理マーカー区間内の旧形式の行は、現行版の update-issue-tree を再実行すると文字参照へ書き直されます。区間の外・手書きの行は手で置き換えてください。ルート本文は更新しません。\"\nelif [ \"${rc}\" -eq 10 ]; then\n die 1 \"ルート本文の表行または見出し行に、閉じていない素の \u003cdetails> があり、構造かタイトルの文字列か判定できません。文字参照へ変換する前の旧版が書いたタイトルの可能性があります。該当する \u003cdetails> を手で <details> に置き換えてください。管理マーカー区間内の行なら、現行版の update-issue-tree を再実行すると書き直されます。ルート本文は更新しません。\"\nelif [ \"${rc}\" -ne 0 ]; then\n die 1 \"本文の Phase 別表に追記位置('| Phase N |' 行または '| (作成後に更新) |' 行)がないか、Phase 行を出力できませんでした。中止します。\"\nfi\n\n# 検査は追記を全て終えた最終本文のうち、今回生成・置換した範囲(awk が CHK_FILE へ書いた、素通しの退避部分\n# = 管理マーカー区間の外と \u003cdetails> 内を除く行)に対し、gh issue edit の直前で行う(fail-closed)。\n# 退避された Step 3 表のプレースホルダーは update-issue-tree が保持するため、検査対象にすると移行済みルートが更新できない。\n# 追記前に検査すると、追記部分に残ったプレースホルダーが検査を素通りする。\n# grep の終了コードは 0=ヒット / 1=なし / 2 以上=失敗。2 以上は「残りなし」へ倒さず中止する\nrc=0; grep -qE \"${TREE_PLACEHOLDER_RE}\" \"${CHK_FILE}\" || rc=$?\n[ \"${rc}\" -eq 1 ] \\\n || die 1 \"本文にプレースホルダーが残っている、または検査に失敗した(grep exit ${rc})。ルート本文は更新しません。\"\n\n# 検査を通った NEW_BODY を、検査直後に(本文を変更せず)そのまま gh issue edit へ渡す。\n# 本文は stdin 経由で渡し、一時ファイルを作らない。失敗時は gh issue edit 自身の終了コードを伝播する\nprintf '%s\\n' \"${NEW_BODY}\" | gh issue edit \"${ROOT_NUMBER}\" --body-file - || exit $?\necho \"result=merged root=${ROOT_NUMBER} phase=${PHASE}\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":24792,"content_sha256":"7a4bbac32896a236b3ce6d0e4a8d6cf99fdcba07fa7174434dfbd6572250b2fc"},{"filename":"scripts/tree-lib.sh","content":"#!/usr/bin/env bash\n# tree-lib.sh — イシューツリーの実ツリー取得と表セル無害化の共通ヘルパー(source 専用)\n#\n# 呼び出し元: create-root-body.sh(Step 6 新規作成経路)と merge-root-body.sh(Step 6 --root 経路)。\n# 役割: sub_issues API のページング取得・open 子孫の再帰集計・issue タイトルの表セル無害化・\n# プレースホルダー検査の正規表現を 1 箇所に定義し、2 つのスクリプトで本文生成ロジックを\n# 重複させない(Issue #555・#556)。\n# 前提: gh(認証済み)と jq が PATH にあること。リポジトリは gh の {owner}/{repo} 解決に従う。\n# 契約: 失敗は非ゼロ return。source 元のシェル状態を壊さないため set -e / set -u / trap / exit\n# は置かない(エラー処理は呼び出し側が行う)。二重 source は無害(ガードで即 return)。\n#\n# このファイルは単体実行しない。\n\n# CELL / TREE_PLACEHOLDER_RE は source 元が参照する(このファイル内では未使用)\n# shellcheck disable=SC2034\n[[ -n \"${TREE_LIB_LOADED:-}\" ]] && return 0\nTREE_LIB_LOADED=1\n\n# 指定 issue の sub-issues 全件(JSON 配列)をページングで取得する。失敗時は非ゼロ。\nlist_subs() {\n local n=\"$1\" page=1 res all='[]'\n while true; do\n res=$(gh api \"repos/{owner}/{repo}/issues/${n}/sub_issues?per_page=100&page=${page}\") || return 1\n # 本文を含む全 JSON を --argjson の引数に載せると ARG_MAX / MAX_ARG_STRLEN を超え得るため、\n # printf(組み込み)経由の stdin で jq へ渡す\n all=$({ printf '%s' \"${all}\"; printf '%s' \"${res}\"; } | jq -s '.[0] + .[1]') || return 1\n [ \"$(printf '%s' \"${res}\" | jq 'length')\" -lt 100 ] && break\n page=$((page + 1))\n done\n printf '%s' \"${all}\"\n}\n\n# 指定 issue の全子孫のうち open な issue 件数を再帰で数えて stdout へ出す。Step 5 は sub-issue への\n# 追加分解を許すため、直下だけでなく 3 階層目より深い open issue も総件数に含める。\n# 失敗時は非ゼロ。depth は循環・異常な深さへの安全弁(超過は失敗扱い)\ncount_open_desc() {\n local n=\"$1\" depth=\"${2:-0}\" subs open c cnum\n [ \"${depth}\" -le 20 ] || return 1\n subs=$(list_subs \"${n}\") || return 1\n open=$(printf '%s' \"${subs}\" | jq '[.[] | select(.state == \"open\")] | length') || return 1\n for cnum in $(printf '%s' \"${subs}\" | jq -r '.[].number'); do\n c=$(count_open_desc \"${cnum}\" $((depth + 1))) || return 1\n open=$((open + c))\n done\n printf '%s' \"${open}\"\n}\n\n# issue タイトルは非信頼データ。シェル展開には載せず、本文へ書く前に HTML 文字参照へ変換する。\n# 改行(CR/LF)は空白 1 つへ、& \u003c > ` \\ | は文字参照へ置換する。& を最初に置換するのは、後続の置換が出す\n# 文字参照の & を二重変換しないため。生成行にバッククォート・\u003c!--・-->・バックスラッシュ・HTML タグ・列区切りの | が\n# 入らないので、merge-root-body.sh の判定(曖昧な記号での中止・\u003cdetails> の数え上げ)を乱さず、\n# GitHub 上の表示は元のタイトルのまま(文字参照として描画される)\nCELL='gsub(\"[\\r\\n]+\"; \" \") | gsub(\"&\"; \"&\") | gsub(\"\u003c\"; \"<\") | gsub(\">\"; \">\") | gsub(\"`\"; \"`\") | gsub(\"\\\\\\\\\"; \"\\") | gsub(\"\\\\|\"; \"|\")'\n\n# ルート本文に残ってはならないプレースホルダー(grep -E 用)。Step 3 の雛形・手書き例の取り残しを検出する\nTREE_PLACEHOLDER_RE='\u003cphase[0-9]*_number>|\\(作成後に更新\\)|#N([^0-9A-Za-z]|$)|^\\|.*\\|[[:space:]]*N[[:space:]]*(\\||$)'\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":3733,"content_sha256":"dc1e49302fd91fc120cd6664f9ebb8643969c9d9188ee48bfb424a1549adc32d"},{"filename":"tests/step6-root-body.test.mjs","content":"// step6-root-body.test.mjs — Issue #544 の回帰テスト。\n//\n// SKILL.md Step 6 の新規作成用ブロックは、`#\u003cphase1_number>` や `N` を含む固定本文を\n// そのまま `gh issue edit --body` へ渡していた。例をそのまま実行するとルート issue の\n// 本文が雛形で上書きされ、実際の Phase 構成が失われる(後続の update-issue-tree /\n// implement-issue-tree が読むトラッキング本文を破壊する)。\n// 修正は (1) 実ツリー(sub_issues API)から表を生成、(2) プレースホルダー残りの\n// 検査ガード(fail-closed)の 2 点。\n//\n// Issue #555 は新規作成経路(旧フェンス)を scripts/create-root-body.sh + tree-lib.sh へ\n// 切り出した。(a)〜(j)・(f) は期待値を変えずスクリプト直接実行へ移行し、引数検証・\n// 環境変数フォールバック・ライブラリ契約・SKILL.md の静的検査・探索フェンス単体実行を追加した。\n//\n// Issue #551 は Step 3 の雛形(プレースホルダー行だけの表)を持つルートへ --root で\n// 再実行しても Step 6 が完走することの回帰(テスト (n)〜(r))。\n//\n// Issue #556 は --root 経路(既存本文への Phase 行・セクションのマージ)も\n// scripts/merge-root-body.sh へ切り出した。(k)〜(r) は期待値を変えずスクリプト直接実行へ移行し、\n// 引数検証・環境変数フォールバック・取得失敗・edit 失敗の伝播・前提不備を追加した。\n//\n// いずれの経路も PATH 先頭に gh スタブを差し込んで実プロセスとして実行し、\n// gh 呼び出しと本文を観測する(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, readdirSync, existsSync, chmodSync, symlinkSync } 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// 新規作成経路は scripts/create-root-body.sh へ切り出した(Issue #555)。SKILL.md からの\n// ブロック抽出ではなく、スクリプトを直接実行して検証する。\nconst SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'create-root-body.sh')\nconst MERGE_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'merge-root-body.sh')\nconst LIB = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'tree-lib.sh')\n\n// gh スタブ。api .../issues/\u003cn>/sub_issues?...page=\u003cp> には fixture/sub_\u003cn>_\u003cp>.json を返し、\n// 無ければ exit 1(API 失敗)。issue edit は引数と --body-file の中身を記録する。\nconst GH_STUB = `#!/usr/bin/env bash\nif [ \"$1\" = \"api\" ]; then\n path=\"$2\"\n n=$(printf '%s' \"$path\" | sed -E 's#.*issues/([0-9]+)/sub_issues.*#\\\\1#')\n p=$(printf '%s' \"$path\" | sed -E 's#.*page=([0-9]+).*#\\\\1#')\n f=\"$FIXTURE_DIR/sub_\\${n}_\\${p}.json\"\n if [ -f \"$f\" ]; then cat \"$f\"; exit 0; fi\n echo \"stub: no fixture for $path\" >&2\n exit 1\nfi\nif [ \"$1\" = \"issue\" ] && [ \"$2\" = \"edit\" ]; then\n echo \"EDIT $*\" >> \"$GH_CALL_LOG\"\n while [ $# -gt 0 ]; do\n if [ \"$1\" = \"--body-file\" ]; then cat \"$2\" > \"$GH_BODY_OUT\"; fi\n shift\n done\n exit 0\nfi\necho \"stub: unexpected $*\" >&2\nexit 1\n`\n\nfunction setup(fixtures) {\n const dir = mkdtempSync(join(tmpdir(), 'step6-'))\n const bin = join(dir, 'bin')\n const fx = join(dir, 'fx')\n const tmp = join(dir, 'tmp')\n for (const d of [bin, fx, tmp]) mkdirSync(d)\n writeFileSync(join(bin, 'gh'), GH_STUB)\n chmodSync(join(bin, 'gh'), 0o755)\n for (const [name, data] of Object.entries(fixtures)) {\n writeFileSync(join(fx, name), JSON.stringify(data))\n }\n return { dir, bin, fx, tmp }\n}\n\nfunction run(ctx, { args = ['--root', '100', '--granularity', '2h'], env = {} } = {}) {\n const callLog = join(ctx.dir, 'calls.log')\n const bodyOut = join(ctx.dir, 'body.md')\n const r = spawnSync('bash', [SCRIPT, ...args], {\n cwd: ctx.dir,\n encoding: 'utf8',\n env: {\n PATH: `${ctx.bin}:${process.env.PATH}`,\n HOME: process.env.HOME,\n TMPDIR: ctx.tmp,\n FIXTURE_DIR: ctx.fx,\n GH_CALL_LOG: callLog,\n GH_BODY_OUT: bodyOut,\n ...env,\n },\n })\n const calls = existsSync(callLog) ? readFileSync(callLog, 'utf8').split('\\n').filter(Boolean) : []\n const body = existsSync(bodyOut) ? readFileSync(bodyOut, 'utf8') : null\n return { r, calls, body, leftovers: readdirSync(ctx.tmp) }\n}\n\nconst issue = (number, title, state = 'open', labels = []) => ({\n number,\n title,\n state,\n labels: labels.map((name) => ({ name })),\n})\n\nconst BASE = () => ({\n 'sub_100_1.json': [issue(101, 'feat: 基盤整備', 'open', ['phase:1']), issue(102, 'feat: 機能追加', 'open', ['phase:2'])],\n 'sub_101_1.json': [issue(111, 'feat: DB 設計'), issue(112, 'feat: API 雛形')],\n 'sub_102_1.json': [issue(121, 'feat: 画面')],\n 'sub_111_1.json': [issue(1111, 'feat: テーブル定義')],\n 'sub_1111_1.json': [],\n 'sub_112_1.json': [],\n 'sub_121_1.json': [],\n})\n\ntest('(a) 実ツリーから表を生成し、プレースホルダーなしで 1 回だけ edit する', () => {\n const ctx = setup(BASE())\n try {\n const { r, calls, body } = run(ctx)\n assert.equal(r.status, 0, r.stderr)\n assert.equal(calls.length, 1)\n assert.match(calls[0], /issue edit 100 --body-file/)\n assert.ok(body.startsWith('\u003c!-- granularity: 2h -->\\n'))\n assert.match(body, /\\| Phase 1 \\| #101 feat: 基盤整備 \\| 2 \\| 3 \\|/)\n assert.match(body, /\\| Phase 2 \\| #102 feat: 機能追加 \\| 1 \\| 1 \\|/)\n assert.match(body, /\\| #111 \\| feat: DB 設計 \\| sub-issue あり \\|/)\n assert.match(body, /\\| #112 \\| feat: API 雛形 \\| - \\|/)\n assert.ok(!/\u003cphase|#N\\b|\\(作成後に更新\\)/.test(body), 'プレースホルダー残り')\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(b) タイトルの | と改行で表の列数が崩れない', () => {\n const fx = BASE()\n fx['sub_101_1.json'] = [issue(111, 'feat: a | b\\nc'), issue(112, 'x')]\n const ctx = setup(fx)\n try {\n const { r, body } = run(ctx)\n assert.equal(r.status, 0, r.stderr)\n const row = body.split('\\n').find((l) => l.startsWith('| #111 '))\n assert.ok(row, '#111 行がある')\n assert.equal(row, '| #111 | feat: a | b c | sub-issue あり |')\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(c) Phase 親が 0 件なら edit せず非ゼロ終了する', () => {\n const ctx = setup({ 'sub_100_1.json': [] })\n try {\n const { r, calls } = run(ctx)\n assert.notEqual(r.status, 0)\n assert.equal(calls.length, 0)\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(d) sub_issues 取得失敗なら edit せず非ゼロ終了する', () => {\n const fx = BASE()\n delete fx['sub_102_1.json']\n const ctx = setup(fx)\n try {\n const { r, calls } = run(ctx)\n assert.notEqual(r.status, 0)\n assert.equal(calls.length, 0)\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(e) 100 件ちょうどの 1 ページ目に続く 2 ページ目も取得する', () => {\n const fx = BASE()\n const page1 = Array.from({ length: 100 }, (_, i) => issue(2000 + i, `feat: c${i}`))\n fx['sub_102_1.json'] = page1\n fx['sub_102_2.json'] = [issue(3000, 'feat: 最終')]\n for (const i of page1) fx[`sub_${i.number}_1.json`] = []\n fx['sub_3000_1.json'] = []\n const ctx = setup(fx)\n try {\n const { r, calls, body } = run(ctx)\n assert.equal(r.status, 0, r.stderr)\n assert.equal(calls.length, 1)\n assert.match(body, /\\| Phase 2 \\| #102 feat: 機能追加 \\| 101 \\| 101 \\|/)\n assert.match(body, /\\| #3000 \\| feat: 最終 \\| - \\|/)\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(g) タイトルが #N 等のプレースホルダーに見える場合も edit せず中止する', () => {\n const fx = BASE()\n fx['sub_101_1.json'] = [issue(111, '#N'), issue(112, 'x')]\n const ctx = setup(fx)\n try {\n const { r, calls } = run(ctx)\n assert.notEqual(r.status, 0)\n assert.equal(calls.length, 0)\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(h) 本文を含む大きな sub_issues 応答(引数長上限超え)でも edit まで到達する', () => {\n const fx = BASE()\n const bigBody = 'x'.repeat(4000)\n const page1 = Array.from({ length: 100 }, (_, i) => ({ ...issue(2000 + i, `feat: c${i}`), body: bigBody }))\n fx['sub_102_1.json'] = page1\n fx['sub_102_2.json'] = [{ ...issue(3000, 'feat: 最終'), body: bigBody }]\n for (const i of page1) fx[`sub_${i.number}_1.json`] = []\n fx['sub_3000_1.json'] = []\n const ctx = setup(fx)\n try {\n const { r, calls, body } = run(ctx)\n assert.equal(r.status, 0, r.stderr)\n assert.equal(calls.length, 1)\n assert.match(body, /\\| #3000 \\| feat: 最終 \\| - \\|/)\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(i) 部分起票(Phase 2・3 のみ)でも実 Phase 番号で表と見出しを生成する', () => {\n const fx = BASE()\n fx['sub_100_1.json'] = [issue(101, 'feat: 基盤整備', 'open', ['phase:2']), issue(102, 'feat(phase-3): 機能追加')]\n const ctx = setup(fx)\n try {\n const { r, body } = run(ctx)\n assert.equal(r.status, 0, r.stderr)\n assert.match(body, /\\| Phase 2 \\| #101 /)\n assert.match(body, /\\| Phase 3 \\| #102 /)\n assert.match(body, /### Phase 2: feat: 基盤整備/)\n assert.ok(!/Phase 1/.test(body), 'Phase 1 を誤記録しない')\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(j) Phase 番号を決定できない親があれば edit せず中止する', () => {\n const fx = BASE()\n fx['sub_100_1.json'] = [issue(101, 'feat: 基盤整備'), issue(102, 'feat: 機能追加', 'open', ['phase:2'])]\n const ctx = setup(fx)\n try {\n const { r, calls } = run(ctx)\n assert.notEqual(r.status, 0)\n assert.equal(calls.length, 0)\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(f) 一時ファイルは成功時も失敗時も残らない', () => {\n const ok = setup(BASE())\n const fx = BASE()\n delete fx['sub_102_1.json']\n const ng = setup(fx)\n try {\n assert.deepEqual(run(ok).leftovers, [])\n assert.deepEqual(run(ng).leftovers, [])\n } finally {\n rmSync(ok.dir, { recursive: true, force: true })\n rmSync(ng.dir, { recursive: true, force: true })\n }\n})\n\n// ---- --root 用フェンス(既存本文へ Phase 行・セクションをマージするブロック)----\n// Issue #544 PR #548 のレビュー指摘(P1: セクションを環境変数で awk へ渡して長さ上限超過、\n// P2: 読み飛ばしが任意の見出しで終了し小見出し以降の旧内容が残る)の回帰テスト。\n\n// issue view \u003cn> --json body は fixture/root_body.md(FAIL_VIEW_BODY=1 なら失敗)、--json title は jq 経由で\n// title を返す。issue edit は --body-file - の stdin を GH_BODY_OUT へ記録し、EDIT_EXIT で終了コードを変えられる。\n// 全 gh 呼び出しは GH_ALL_LOG へ記録する(引数検証で gh が 1 回も呼ばれないことの確認用)。\nconst ROOT_GH_STUB = `#!/usr/bin/env bash\necho \"CALL $*\" >> \"$GH_ALL_LOG\"\nif [ \"$1\" = \"api\" ]; then\n path=\"$2\"\n n=$(printf '%s' \"$path\" | sed -E 's#.*issues/([0-9]+)/sub_issues.*#\\\\1#')\n p=$(printf '%s' \"$path\" | sed -E 's#.*page=([0-9]+).*#\\\\1#')\n f=\"$FIXTURE_DIR/sub_\\${n}_\\${p}.json\"\n if [ -f \"$f\" ]; then cat \"$f\"; exit 0; fi\n exit 1\nfi\nif [ \"$1\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n if [ \"$5\" = \"body\" ]; then\n [ -z \"\\${FAIL_VIEW_BODY:-}\" ] || exit 1\n jq -n --rawfile b \"$FIXTURE_DIR/root_body.md\" '{body:$b}' | jq -r '.body'; exit 0\n fi\n jq -n --arg t \"$PHASE_TITLE\" '{title:$t}' | jq -r \".title | $(printf '%s' \"$7\" | sed 's/^\\\\.title | //')\"\n exit 0\nfi\nif [ \"$1\" = \"issue\" ] && [ \"$2\" = \"edit\" ]; then\n echo \"EDIT $*\" >> \"$GH_CALL_LOG\"\n cat > \"$GH_BODY_OUT\"\n exit \"\\${EDIT_EXIT:-0}\"\nfi\nexit 1\n`\n\nconst ROOT_ARGS = ['--root', '100', '--granularity', '2h', '--phase', '1', '--phase-number', '101']\n\nfunction runRootFull(rootBody, children, { args = ROOT_ARGS, env = {} } = {}) {\n const dir = mkdtempSync(join(tmpdir(), 'step6root-'))\n const bin = join(dir, 'bin')\n const fx = join(dir, 'fx')\n const tmp = join(dir, 'tmp')\n for (const d of [bin, fx, tmp]) mkdirSync(d)\n writeFileSync(join(bin, 'gh'), ROOT_GH_STUB)\n chmodSync(join(bin, 'gh'), 0o755)\n writeFileSync(join(fx, 'root_body.md'), rootBody)\n const pnIdx = args.indexOf('--phase-number')\n const pn = pnIdx >= 0 ? args[pnIdx + 1] : '101'\n writeFileSync(join(fx, `sub_${pn}_1.json`), JSON.stringify(children))\n for (const c of children) writeFileSync(join(fx, `sub_${c.number}_1.json`), '[]')\n const callLog = join(dir, 'calls.log')\n const allLog = join(dir, 'all.log')\n const bodyOut = join(dir, 'body.md')\n try {\n const r = spawnSync('bash', [MERGE_SCRIPT, ...args], {\n cwd: dir,\n encoding: 'utf8',\n env: {\n PATH: `${bin}:${process.env.PATH}`,\n HOME: process.env.HOME,\n TMPDIR: tmp,\n FIXTURE_DIR: fx,\n GH_CALL_LOG: callLog,\n GH_ALL_LOG: allLog,\n GH_BODY_OUT: bodyOut,\n PHASE_TITLE: 'feat: 基盤整備',\n ...env,\n },\n })\n const lines = (f) => (existsSync(f) ? readFileSync(f, 'utf8').split('\\n').filter(Boolean) : [])\n const body = existsSync(bodyOut) ? readFileSync(bodyOut, 'utf8') : null\n return { r, body, edits: lines(callLog), allCalls: lines(allLog), leftovers: readdirSync(tmp) }\n } finally {\n rmSync(dir, { recursive: true, force: true })\n }\n}\n\nfunction runRoot(rootBody, children) {\n const { r, body, leftovers } = runRootFull(rootBody, children)\n return { r, body, leftovers }\n}\n\nconst ROOT_BODY = `\u003c!-- granularity: 2h -->\n## Phase 別表\n\n| Phase | 親 | 直下 | 総数 |\n|-------|----|------|------|\n| Phase 1 | #101 古い | 9 | 9 |\n| Phase 2 | #102 後続 | 1 | 1 |\n\n### Phase 1: 古い\n\n| Issue | タイトル | 分解 |\n|-------|---------|------|\n| #901 | 古い子 | - |\n\n#### 補足\n\nOLD-DETAIL-MARKER\n\n##### さらに深い\n\nOLD-DEEP-MARKER\n\n### Phase 2: 後続\n\nPHASE2-KEEP-MARKER\n\n## 運用\n\nOPS-KEEP-MARKER\n`\n\ntest('(k) 既存 Phase セクション内の #### 以深の小見出しと旧内容も置換で残らない', () => {\n const { r, body } = runRoot(ROOT_BODY, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(!body.includes('OLD-DETAIL-MARKER'), '#### 以降の旧内容が残っている')\n assert.ok(!body.includes('OLD-DEEP-MARKER'), '##### 以降の旧内容が残っている')\n assert.ok(!body.includes('#### 補足'))\n assert.match(body, /\\| #111 \\| feat: 新しい子 \\| - \\|/)\n assert.ok(!body.includes('古い子'))\n assert.match(body, /### Phase 2: 後続\\n\\nPHASE2-KEEP-MARKER/)\n assert.match(body, /## 運用\\n\\nOPS-KEEP-MARKER/)\n assert.match(body, /\\| Phase 1 \\| #101 feat: 基盤整備 \\| 1 \\| 1 \\|/)\n})\n\ntest('(l) Phase セクションが巨大でも環境変数に載せず置換でき、一時ファイルが残らない', () => {\n const children = Array.from({ length: 90 }, (_, i) => issue(5000 + i, `feat: ${'長'.repeat(1500)} ${i}`))\n const { r, body, leftovers } = runRoot(ROOT_BODY, children)\n assert.equal(r.status, 0, r.stderr)\n // 1 ページ(100 件)以内で、セクション全体が Linux の単一環境変数上限(128KiB)を超える規模\n assert.ok(Buffer.byteLength(body) > 128 * 1024)\n assert.match(body, /\\| #5089 \\| feat: /)\n assert.ok(!body.includes('OLD-DETAIL-MARKER'))\n assert.match(body, /## 運用\\n\\nOPS-KEEP-MARKER/)\n assert.deepEqual(leftovers, [])\n})\n\ntest('(m) merge-root-body.sh は PHASE_SECTION を SEC 環境変数として awk へ渡さない', () => {\n // コメント行(不変条件の説明文に SEC= の語が出る)は除いてコードだけを検査する\n const src = readFileSync(MERGE_SCRIPT, 'utf8').split('\\n').filter((l) => !/^\\s*#/.test(l)).join('\\n')\n assert.ok(!/\\bSEC=/.test(src), 'SEC= による環境変数渡しが残っている')\n assert.ok(!/ENVIRON\\[\"SEC\"\\]/.test(src))\n})\n\n// ---- Issue #551: Step 3 の雛形だけのルートへ --root で再実行するケース ----\n// 雛形は手で写さず SKILL.md の Step 3 フェンスから抽出する。Step 3 の文言と Step 6 の\n// 追記位置判定が食い違うと、この入力のテストが落ちて気づける。\nfunction extractStep3Template() {\n const text = readFileSync(SKILL_MD, 'utf8')\n const start = text.indexOf('### Step 3')\n const end = text.indexOf('### Step 4')\n assert.ok(start >= 0 && end > start, 'Step 3 / Step 4 見出しが見つからない')\n const m = /cat \u003c\u003c'EOF'\\n([\\s\\S]*?)\\nEOF\\n/.exec(text.slice(start, end))\n assert.ok(m, 'Step 3 の heredoc が見つからない')\n assert.match(m[1], /^\\| \\(作成後に更新\\) \\|/m, 'Step 3 雛形にプレースホルダー行がある')\n return `\u003c!-- granularity: 2h -->\\n${m[1]}\\n`\n}\n\ntest('(n) Step 3 の雛形本文へ --root マージするとプレースホルダー行が Phase 行に置き換わる', () => {\n const { r, body } = runRoot(extractStep3Template(), [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stdout + r.stderr)\n assert.ok(!body.includes('(作成後に更新)'))\n assert.match(body, /\\|\\n\\| Phase 1 \\| #101 feat: 基盤整備 \\| 1 \\| 1 \\|\\n\\n### Phase 1: feat: 基盤整備\\n/)\n assert.match(body, /\\| #111 \\| feat: 新しい子 \\| - \\|\\n\\n## 運用\\n/)\n assert.match(body, /^\u003c!-- granularity: 2h -->\\n## 概要\\n/)\n assert.equal(body.split('\\n').filter((l) => /^\\| Phase \\d+ \\|/.test(l)).length, 1)\n})\n\ntest('(o) Phase 行もプレースホルダー行も無い本文は edit せず中止する', () => {\n const { r, body } = runRoot('## 概要\\n\\n自由記述のみ\\n\\n## 運用\\n\\nx\\n', [issue(111, 'c')])\n assert.notEqual(r.status, 0)\n assert.equal(body, null)\n})\n\ntest('(p) 実在の Phase 行とプレースホルダー行が併存する本文ではプレースホルダー行だけ落とす', () => {\n const tpl = extractStep3Template()\n const b = tpl.replace('| (作成後に更新) | | | |', '| Phase 2 | #102 後続 | 1 | 1 |\\n| (作成後に更新) | | | |')\n assert.notEqual(b, tpl, 'Step 3 の文言が変わり置換が空振りしている')\n const { r, body } = runRoot(b, [issue(111, 'c')])\n assert.equal(r.status, 0, r.stdout + r.stderr)\n assert.ok(!body.includes('(作成後に更新)'))\n assert.match(body, /\\| Phase 2 \\| #102 後続 \\| 1 \\| 1 \\|\\n\\| Phase 1 \\| #101 feat: 基盤整備 \\| 1 \\| 1 \\|\\n\\n/)\n})\n\ntest('(q) 表の行以外にある (作成後に更新) は追記位置にせず、残っていれば中止する', () => {\n const b = '## 概要\\n\\n(作成後に更新) という語を含む自由記述\\n\\n| x | (作成後に更新) |\\n\\n## 運用\\n'\n const { r, body } = runRoot(b, [issue(111, 'c')])\n assert.notEqual(r.status, 0)\n assert.equal(body, null)\n})\n\ntest('(r) 雛形へのマージ結果へ同じ Phase を再マージしても行・セクションが重複しない', () => {\n const first = runRoot(extractStep3Template(), [issue(111, 'c')])\n assert.equal(first.r.status, 0, first.r.stderr)\n const second = runRoot(first.body, [issue(111, 'c'), issue(112, 'd')])\n assert.equal(second.r.status, 0, second.r.stderr)\n assert.equal(second.body.split('\\n').filter((l) => /^\\| Phase 1 \\|/.test(l)).length, 1)\n assert.equal(second.body.split('\\n').filter((l) => /^### Phase 1:/.test(l)).length, 1)\n assert.match(second.body, /\\| Phase 1 \\| #101 feat: 基盤整備 \\| 2 \\| 2 \\|/)\n})\n\n// ---- Issue #555: スクリプト切り出しの追加検証 ----\n\ntest('(s) 不正な引数・欠落は gh を 1 回も呼ばず exit 1 で止まる', () => {\n const cases = [\n { args: ['--root', 'abc', '--granularity', '2h'] },\n { args: ['--root', '0', '--granularity', '2h'] },\n { args: ['--root', '100', '--granularity', '2'] },\n { args: ['--root', '100', '--granularity', '2 h'] },\n { args: ['--root', '100', '--bogus', 'x'] },\n { args: ['--root'] },\n { args: [] },\n ]\n for (const c of cases) {\n const ctx = setup(BASE())\n try {\n const { r, calls } = run(ctx, c)\n assert.equal(r.status, 1, `${JSON.stringify(c.args)}: ${r.stderr}`)\n assert.equal(calls.length, 0)\n assert.equal(r.stdout.includes('result='), false)\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n }\n})\n\ntest('(t) 引数が無ければ環境変数 ROOT_NUMBER / GRANULARITY を使う', () => {\n const ctx = setup(BASE())\n try {\n const { r, calls, body } = run(ctx, { args: [], env: { ROOT_NUMBER: '100', GRANULARITY: '4h' } })\n assert.equal(r.status, 0, r.stderr)\n assert.equal(calls.length, 1)\n assert.ok(body.startsWith('\u003c!-- granularity: 4h -->\\n'))\n assert.match(r.stdout.trim().split('\\n').pop(), /^result=updated root=100 phases=2$/)\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\ntest('(u) tree-lib.sh は source してもシェルオプションを変えず、ヘルパーを定義する', () => {\n const script = [\n 'before=$(set +o)',\n `source \"${LIB}\"`,\n 'after=$(set +o)',\n '[ \"$before\" = \"$after\" ] || exit 3',\n `source \"${LIB}\" || exit 4`,\n 'declare -F list_subs count_open_desc >/dev/null || exit 5',\n '[ -n \"$CELL\" ] && [ -n \"$TREE_PLACEHOLDER_RE\" ] || exit 6',\n ].join('\\n')\n const r = spawnSync('bash', ['-c', script], { encoding: 'utf8' })\n assert.equal(r.status, 0, `exit=${r.status} ${r.stderr}`)\n})\n\ntest('(v) gh が見つからなければ前提不備として exit 2 で止まる', () => {\n const ctx = setup(BASE())\n try {\n // gh を含まない PATH(jq・bash・coreutils の symlink だけ)で実行する\n const bare = join(ctx.dir, 'nogh')\n mkdirSync(bare)\n for (const cmd of ['jq', 'bash', 'dirname', 'mktemp', 'rm']) {\n const w = spawnSync('bash', ['-c', `command -v ${cmd}`], { encoding: 'utf8' }).stdout.trim()\n if (w) symlinkSync(w, join(bare, cmd))\n }\n const r = spawnSync(join(bare, 'bash'), [SCRIPT, '--root', '100', '--granularity', '2h'], {\n cwd: ctx.dir,\n encoding: 'utf8',\n env: { PATH: bare, HOME: process.env.HOME, TMPDIR: ctx.tmp },\n })\n assert.equal(r.status, 2, r.stderr)\n assert.deepEqual(readdirSync(ctx.tmp), [])\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n})\n\n// ---- SKILL.md の静的検査(update-issue-tree の Step 8 テストと同型)----\n\nfunction step6Section() {\n const text = readFileSync(SKILL_MD, 'utf8')\n const start = text.indexOf('### Step 6')\n const end = text.indexOf('### Step 7')\n assert.ok(start >= 0 && end > start, 'Step 6 / Step 7 見出しが見つからない')\n return text.slice(start, end)\n}\n\nfunction step6Fences() {\n const blocks = []\n const re = /```bash\\n([\\s\\S]*?)```/g\n const section = step6Section()\n let m\n while ((m = re.exec(section)) !== null) blocks.push(m[1])\n return blocks\n}\n\n// 呼び出しフェンス(指定スクリプトを参照するもの)\nfunction invokeFence(script) {\n const fences = step6Fences().filter((b) => b.includes(script))\n assert.equal(fences.length, 1, `${script} の呼び出しフェンスは 1 つであること(実際: ${fences.length})`)\n return fences[0]\n}\n\nconst SCRIPTS = ['create-root-body.sh', 'merge-root-body.sh']\n\ntest('(w) Step 6 の bash フェンスは 2 つの呼び出し用だけで、本文生成ロジックが残っていない', () => {\n const fences = step6Fences()\n assert.equal(fences.length, 2, `実際: ${fences.length}`)\n for (const script of SCRIPTS) {\n const inv = invokeFence(script)\n assert.ok(!/list_subs\\(\\)|count_open_desc\\(\\)|CELL=|\\bawk\\b|CURRENT_BODY/.test(inv), `${script}: ロジックが無い`)\n assert.ok(!inv.includes('gh issue edit'), `${script}: gh issue edit を直接呼ばない`)\n }\n const defs = step6Section().match(/list_subs\\(\\) \\{/g) ?? []\n assert.equal(defs.length, 0, `Step 6 に list_subs 定義が残っている(実際: ${defs.length})`)\n})\n\n// ---- 探索フェンスの単体実行(スタブスクリプトで引数と終了コードの伝播を確認)----\n\nfunction runInvoke(script, layout, { env = {}, stubExit = '0' } = {}) {\n const dir = mkdtempSync(join(tmpdir(), 'step6inv-'))\n try {\n if (layout) {\n const sd = join(dir, layout, 'create-issue-tree', 'scripts')\n mkdirSync(sd, { recursive: true })\n writeFileSync(join(sd, script), '#!/usr/bin/env bash\\necho \"ARGS $*\"\\nexit \"${STUB_EXIT:-0}\"\\n')\n }\n return spawnSync('bash', ['-c', invokeFence(script)], {\n cwd: dir,\n encoding: 'utf8',\n env: { PATH: process.env.PATH, HOME: process.env.HOME, STUB_EXIT: stubExit, ...env },\n })\n } finally {\n rmSync(dir, { recursive: true, force: true })\n }\n}\n\nconst INV_ENV = { ROOT_NUMBER: '100', GRANULARITY: '2h', PHASE: '3', PHASE_NUMBER: '103' }\nconst INV_ARGS = {\n 'create-root-body.sh': /ARGS --root 100 --granularity 2h$/m,\n 'merge-root-body.sh': /ARGS --root 100 --granularity 2h --phase 3 --phase-number 103$/m,\n}\n\ntest('(x) 探索フェンスは 3 レイアウトを解決し、引数を渡す', () => {\n for (const script of SCRIPTS) {\n for (const layout of ['skills', '.agents/skills', '.claude/skills']) {\n const r = runInvoke(script, layout, { env: INV_ENV })\n assert.equal(r.status, 0, `${script} ${layout}: ${r.stderr}`)\n assert.match(r.stdout, INV_ARGS[script])\n }\n }\n})\n\ntest('(y) 探索フェンスはスクリプトの非ゼロ終了を伝播し、未設定・不在では起動前に止まる', () => {\n for (const script of SCRIPTS) {\n const ng = runInvoke(script, 'skills', { env: INV_ENV, stubExit: '7' })\n assert.equal(ng.status, 7, `${script}: ${ng.stderr}`)\n for (const missing of Object.keys(INV_ENV)) {\n const { [missing]: _drop, ...env } = INV_ENV\n const unset = runInvoke(script, 'skills', { env })\n // 呼び出しフェンスが要求しない変数(create 側の PHASE 等)は未設定でも起動してよい\n const required = script === 'merge-root-body.sh' || ['ROOT_NUMBER', 'GRANULARITY'].includes(missing)\n if (required) {\n assert.notEqual(unset.status, 0, `${script}: ${missing} 未設定で止まる`)\n assert.ok(!unset.stdout.includes('ARGS'), `${script}: ${missing} 未設定ならスクリプトを起動しない`)\n }\n }\n const none = runInvoke(script, null, { env: INV_ENV })\n assert.equal(none.status, 1)\n }\n})\n\n// ---- Issue #556: merge-root-body.sh の追加検証 ----\n\nconst KIDS = () => [issue(111, 'feat: 新しい子')]\n\ntest('(z1) 不正な引数・欠落は gh を 1 回も呼ばず exit 1 で止まる', () => {\n const cases = [\n ['--root', 'abc', '--granularity', '2h', '--phase', '1', '--phase-number', '101'],\n ['--root', '100', '--granularity', '2', '--phase', '1', '--phase-number', '101'],\n ['--root', '100', '--granularity', '2h', '--phase', 'abc', '--phase-number', '101'],\n ['--root', '100', '--granularity', '2h', '--phase', '1;x', '--phase-number', '101'],\n ['--root', '100', '--granularity', '2h', '--phase', '1', '--phase-number', '0'],\n ['--root', '100', '--granularity', '2h', '--phase', '1'],\n ['--root', '100', '--bogus', 'x'],\n ['--phase-number'],\n [],\n ]\n for (const args of cases) {\n const { r, allCalls } = runRootFull(ROOT_BODY, KIDS(), { args })\n assert.equal(r.status, 1, `${JSON.stringify(args)}: ${r.stderr}`)\n assert.equal(allCalls.length, 0, `${JSON.stringify(args)}: gh が呼ばれた`)\n assert.ok(!r.stdout.includes('result='))\n }\n})\n\ntest('(z2) 引数が無ければ環境変数を使い、成功時の最終行は result=merged になる', () => {\n const { r, edits, body } = runRootFull(ROOT_BODY, KIDS(), {\n args: [],\n env: { ROOT_NUMBER: '100', GRANULARITY: '4h', PHASE: '1', PHASE_NUMBER: '101' },\n })\n assert.equal(r.status, 0, r.stderr)\n assert.equal(edits.length, 1)\n assert.ok(body.startsWith('\u003c!-- granularity: 4h -->\\n'))\n assert.match(r.stdout.trim().split('\\n').pop(), /^result=merged root=100 phase=1$/)\n})\n\ntest('(z3) 既存本文の取得に失敗したら edit せず exit 1 で止まる', () => {\n const { r, edits } = runRootFull(ROOT_BODY, KIDS(), { env: { FAIL_VIEW_BODY: '1' } })\n assert.equal(r.status, 1, r.stderr)\n assert.equal(edits.length, 0)\n})\n\ntest('(z4) gh issue edit の失敗は同じ終了コードで伝播し、成功メッセージを出さない', () => {\n const { r, edits } = runRootFull(ROOT_BODY, KIDS(), { env: { EDIT_EXIT: '9' } })\n assert.equal(r.status, 9, r.stderr)\n assert.equal(edits.length, 1)\n assert.ok(!r.stdout.includes('result='))\n})\n\ntest('(z5) gh が見つからなければ前提不備として exit 2 で止まり、一時ファイルが残らない', () => {\n const dir = mkdtempSync(join(tmpdir(), 'step6nogh-'))\n try {\n const bare = join(dir, 'bin')\n const tmp = join(dir, 'tmp')\n mkdirSync(bare)\n mkdirSync(tmp)\n for (const cmd of ['jq', 'bash', 'dirname', 'mktemp', 'rm']) {\n const w = spawnSync('bash', ['-c', `command -v ${cmd}`], { encoding: 'utf8' }).stdout.trim()\n if (w) symlinkSync(w, join(bare, cmd))\n }\n const r = spawnSync(join(bare, 'bash'), [MERGE_SCRIPT, ...ROOT_ARGS], {\n cwd: dir,\n encoding: 'utf8',\n env: { PATH: bare, HOME: process.env.HOME, TMPDIR: tmp },\n })\n assert.equal(r.status, 2, r.stderr)\n assert.deepEqual(readdirSync(tmp), [])\n } finally {\n rmSync(dir, { recursive: true, force: true })\n }\n})\n\ntest('(z6) 本文がマーカー 1 行だけ・空本文でも grep -v の exit 1 を失敗扱いにせず、追記位置が無ければ安全に中止する', () => {\n for (const b of ['\u003c!-- granularity: 2h -->\\n', '']) {\n const { r, edits } = runRootFull(b, KIDS())\n // 追記位置(Phase 行・プレースホルダー行)が無いので exit 1 だが、理由は追記位置であってマーカー除去ではない\n assert.equal(r.status, 1, r.stderr)\n assert.match(r.stderr, /追記位置/)\n assert.ok(!/マーカー除去/.test(r.stderr))\n assert.equal(edits.length, 0)\n }\n})\n\ntest('(z7) 先行 Phase のマーカー重複行は 1 行へ正規化される', () => {\n const { r, body } = runRootFull('\u003c!-- granularity: 8h -->\\n' + ROOT_BODY, KIDS())\n assert.equal(r.status, 0, r.stderr)\n assert.equal(body.split('\\n').filter((l) => l.startsWith('\u003c!-- granularity:')).length, 1)\n assert.ok(body.startsWith('\u003c!-- granularity: 2h -->\\n'))\n})\n\ntest('(s) sub_issues 応答の形が想定外で jq 変換が失敗したら edit せず非ゼロ終了する', () => {\n // 配列要素が文字列だと `.state` の参照で jq が失敗する。空値から Phase 行を作って本文を上書きしてはならない\n const { r, body, edits } = runRootFull(ROOT_BODY, ['unexpected'])\n assert.notEqual(r.status, 0)\n assert.equal(edits.length, 0, 'gh issue edit が呼ばれた')\n assert.equal(body, null)\n assert.match(r.stderr, /絞り込みに失敗/)\n})\n\n// ---- コードフェンス内の行を見出し・表行・運用見出しと誤認しない(Issue #557) ----\nconst FENCE = '```'\nconst fenceCount = (b) => b.split('\\n').filter((l) => /^ {0,3}(`{3,}|~{3,})/.test(l)).length\n\nconst FENCED_ROOT = `\u003c!-- granularity: 2h -->\n## Phase 別表\n\n| Phase | 親 | 直下 | 総数 |\n|-------|----|------|------|\n| Phase 1 | #101 古い | 9 | 9 |\n\n### Phase 1: 古い\n\n旧メモ A\n\n${FENCE}bash\n# コメント行\n### Phase 2: ニセ\n## 運用\n| Phase 9 | ニセ | 0 | 0 |\necho old\n${FENCE}\n\n旧メモ B\n\n### Phase 2: 後続\n\nPHASE2-KEEP-MARKER\n\n## 運用\n\nOPS-KEEP-MARKER\n`\n\ntest('(A1) セクション内のフェンス中の # 行で読み飛ばしが止まらず、旧内容が全て置換される', () => {\n const { r, body } = runRootFull(FENCED_ROOT, KIDS())\n assert.equal(r.status, 0, r.stderr)\n for (const m of ['旧メモ A', '旧メモ B', '# コメント行', 'echo old', 'ニセ']) {\n assert.ok(!body.includes(m), `${m} が残っている`)\n }\n assert.match(body, /### Phase 2: 後続\\n\\nPHASE2-KEEP-MARKER/)\n assert.match(body, /## 運用\\n\\nOPS-KEEP-MARKER/)\n assert.equal(fenceCount(body), 0, 'フェンス行が孤立して残っている')\n assert.equal(body.split('\\n').filter((l) => l.startsWith('### Phase 1:')).length, 1)\n})\n\ntest('(A2) フェンス内の見出し形の行は終端扱いされず、セクション全体が置換される(チルダ・長いフェンス)', () => {\n const F4 = FENCE + '`'\n const body0 = FENCED_ROOT.replace(`${FENCE}bash`, `${F4}bash`).replace(`echo old\\n${FENCE}`, `${FENCE}\\necho old\\n${F4}`)\n const { r, body } = runRootFull(body0, KIDS())\n assert.equal(r.status, 0, r.stderr)\n assert.ok(!body.includes('echo old'))\n assert.ok(!body.includes('旧メモ B'))\n assert.equal(fenceCount(body), 0)\n const tilde = FENCED_ROOT.replace(`${FENCE}bash`, '~~~bash').replace(`echo old\\n${FENCE}`, 'echo old\\n~~~')\n const t = runRootFull(tilde, KIDS())\n assert.equal(t.r.status, 0, t.r.stderr)\n assert.ok(!t.body.includes('echo old'))\n assert.ok(!t.body.includes('旧メモ B'))\n assert.equal(fenceCount(t.body), 0)\n})\n\ntest('(A3) フェンス内の ## 運用 形の行を新セクションの挿入位置にしない', () => {\n const root = `\u003c!-- granularity: 2h -->\n## Phase 別表\n\n| Phase | 親 | 直下 | 総数 |\n|-------|----|------|------|\n| Phase 2 | #102 後続 | 1 | 1 |\n\n### Phase 2: 後続\n\n${FENCE}md\n## 運用\n${FENCE}\n\n## 運用\n\nOPS-KEEP-MARKER\n`\n const { r, body } = runRootFull(root, KIDS())\n assert.equal(r.status, 0, r.stderr)\n const lines = body.split('\\n')\n const newSec = lines.findIndex((l) => l.startsWith('### Phase 1:'))\n const realOps = lines.findIndex((l, i) => l === '## 運用' && lines[i - 1] === '' && lines[i - 2] !== '```md')\n const fakeOps = lines.findIndex((l) => l === '## 運用')\n assert.ok(newSec > fakeOps, '新セクションがフェンス内の疑似見出しの前へ入った')\n assert.ok(newSec \u003c realOps)\n assert.match(body, /OPS-KEEP-MARKER/)\n})\n\ntest('(A4) フェンス内の | Phase N | 形の行を表の追記位置にしない', () => {\n const root = `\u003c!-- granularity: 2h -->\n## Phase 別表\n\n| Phase | 親 | 直下 | 総数 |\n|-------|----|------|------|\n| Phase 2 | #102 後続 | 1 | 1 |\n\n例:\n\n${FENCE}\n| Phase 9 | ニセ | 0 | 0 |\n${FENCE}\n\n## 運用\n`\n const { r, body } = runRootFull(root, KIDS())\n assert.equal(r.status, 0, r.stderr)\n assert.match(body, /\\| Phase 2 \\| #102 後続 \\| 1 \\| 1 \\|\\n\\| Phase 1 \\| #101 feat: 基盤整備 \\| 1 \\| 1 \\|/)\n assert.match(body, /\\| Phase 9 \\| ニセ \\| 0 \\| 0 \\|\\n```/)\n})\n\ntest('(A5) 4 個のフェンスは 3 個の行や別記号の行では閉じない', () => {\n const root = `\u003c!-- granularity: 2h -->\n## Phase 別表\n\n| Phase | 親 | 直下 | 総数 |\n|-------|----|------|------|\n| Phase 1 | #101 古い | 9 | 9 |\n\n### Phase 1: 古い\n\n\\`\\`\\`\\`md\n${FENCE}\n~~~\n# 内側のコメント\n\\`\\`\\`\\`\n\n### Phase 2: 後続\n\nKEEP\n\n## 運用\n`\n const { r, body } = runRootFull(root, KIDS())\n assert.equal(r.status, 0, r.stderr)\n assert.ok(!body.includes('内側のコメント'))\n assert.match(body, /### Phase 2: 後続\\n\\nKEEP/)\n})\n\ntest('(A6) CRLF の閉じフェンスも閉じとして扱う', () => {\n const root = FENCED_ROOT.replace(/\\n/g, '\\r\\n')\n const { r, body } = runRootFull(root, KIDS())\n assert.equal(r.status, 0, r.stderr)\n assert.ok(!body.includes('echo old'))\n assert.ok(!body.includes('旧メモ B'))\n assert.match(body, /PHASE2-KEEP-MARKER/)\n})\n\ntest('(A7) 閉じ忘れフェンスを含む本文は edit せず中止し、一時ファイルが残らない', () => {\n const root = FENCED_ROOT.replace(`echo old\\n${FENCE}`, 'echo old')\n const { r, body, edits, leftovers } = runRootFull(root, KIDS())\n assert.notEqual(r.status, 0)\n assert.equal(edits.length, 0)\n assert.equal(body, null)\n assert.match(r.stderr, /閉じていない/)\n assert.deepEqual(leftovers, [])\n})\n\n// Issue #656: update-issue-tree が管理するルート本文(管理マーカー区間 + 退避 \u003cdetails>)に\n// --root マージしても、区間外・退避節・end マーカー・\u003c/details> を壊さない。\nconst BEGIN_M = '\u003c!-- update-issue-tree:phase-plan:begin -->'\nconst END_M = '\u003c!-- update-issue-tree:phase-plan:end -->'\n\nconst MANAGED_ROOT = () => `\u003c!-- granularity: 2h -->\n## Phase 別表\n\n${BEGIN_M}\n| Phase | 親 | 直下 | 合計 |\n|-------|----|------|------|\n| Phase 1 | #10 A | 1 | 1 |\n\n### Phase 1: A\n\n| Issue | タイトル | 分解 |\n|-------|---------|------|\n| #11 | old | - |\n\n### Phase 2: B\n\n| Issue | タイトル | 分解 |\n|-------|---------|------|\n| #21 | b | - |\n${END_M}\n\n\u003cdetails>\u003csummary>移行前の Phase 別表(update-issue-tree が退避)\u003c/summary>\n\n### Phase 1: A\n\n| Issue | タイトル | 分解 |\n|-------|---------|------|\n| #12 | legacy | - |\n\n\u003c/details>\n\n## 運用\n\nOPS-KEEP-MARKER\n`\n\nconst phase1Headings = (body) => body.split('\\n').filter((l) => l.startsWith('### Phase 1:')).length\n\ntest('(B1) 管理マーカー区間の節だけを置換し、end マーカー・退避節・\u003c/details> を残す', () => {\n const { r, body } = runRoot(MANAGED_ROOT(), [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(!body.includes('| #11 | old |'), '区間内の旧節が残っている')\n assert.match(body, /\\| #111 \\| feat: 新しい子 \\| - \\|/)\n assert.match(body, /### Phase 2: B\\n[\\s\\S]*\\| #21 \\| b \\| - \\|/)\n assert.ok(body.includes(END_M), 'end マーカーが消えた')\n assert.ok(body.includes('\u003c/details>'), '\u003c/details> が消えた')\n assert.ok(body.includes('| #12 | legacy | - |'), '退避節の表が消えた')\n assert.match(body, /\u003cdetails>\u003csummary>移行前の Phase 別表/)\n assert.equal(phase1Headings(body), 2)\n assert.match(body, /## 運用\\n\\nOPS-KEEP-MARKER/)\n assert.ok(body.indexOf(BEGIN_M) \u003c body.indexOf('#111') && body.indexOf('#111') \u003c body.indexOf(END_M))\n})\n\ntest('(B2) 新節 → end → details → 旧節の並び(Phase 9 型)でも end・details・旧節が残る', () => {\n const root = MANAGED_ROOT().replace(/### Phase 2: B[\\s\\S]*?(?=\u003c!-- update-issue-tree:phase-plan:end)/, '')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.indexOf(BEGIN_M) \u003c body.indexOf('#111') && body.indexOf('#111') \u003c body.indexOf(END_M))\n assert.ok(body.indexOf(END_M) \u003c body.indexOf('\u003cdetails>') && body.indexOf('\u003cdetails>') \u003c body.indexOf('legacy'))\n assert.ok(body.includes('| #12 | legacy | - |'))\n assert.ok(body.includes('\u003c/details>'))\n})\n\ntest('(B3) 区間内に該当節が無ければ end マーカーの直前へ新節を入れ、退避節は触らない', () => {\n const root = MANAGED_ROOT().replace('### Phase 1: A\\n\\n| Issue | タイトル | 分解 |\\n|-------|---------|------|\\n| #11 | old | - |\\n\\n', '')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.indexOf('#111') \u003c body.indexOf(END_M))\n assert.ok(body.includes('| #12 | legacy | - |'))\n assert.equal(phase1Headings(body), 2)\n})\n\ntest('(B4) マーカーが無くても \u003cdetails> 内の同名見出しは置換せず素通しする', () => {\n const root = MANAGED_ROOT().replace(`${BEGIN_M}\\n`, '').replace(`${END_M}\\n`, '')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(!body.includes('| #11 | old |'))\n assert.ok(body.includes('| #12 | legacy | - |'))\n assert.ok(body.includes('\u003c/details>'))\n assert.match(body, /\u003cdetails>\u003csummary>/)\n})\n\ntest('(B5) 除外後も置換対象の同名見出しが複数あれば edit せず exit 1 で中止する', () => {\n const dup = MANAGED_ROOT().replace('### Phase 2: B', '### Phase 1: 重複\\n\\n### Phase 2: B')\n const { r, body, edits } = runRootFull(dup, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 1)\n assert.equal(edits.length, 0)\n assert.equal(body, null)\n assert.match(r.stderr, /複数/)\n})\n\ntest('(B6) 管理マーカーが 1 組でない本文は edit せず exit 1 で中止する', () => {\n const noEnd = MANAGED_ROOT().replace(`${END_M}\\n`, '')\n const { r, edits } = runRootFull(noEnd, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 1)\n assert.equal(edits.length, 0)\n assert.match(r.stderr, /管理マーカー/)\n})\n\ntest('(B7) 退避された Step 3 表のプレースホルダー行(details 内・マーカー区間外)が残っていても更新できる', () => {\n const root = MANAGED_ROOT()\n .replace('| #12 | legacy | - |', '| #12 | legacy | - |\\n| (作成後に更新) | | |')\n .replace('OPS-KEEP-MARKER', 'OPS-KEEP-MARKER\\n\\n| (作成後に更新) |')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(body.split('(作成後に更新)').length - 1, 2, '退避部分のプレースホルダーが保持されていない')\n assert.ok(body.includes('\u003c/details>'))\n assert.match(body, /\\| #111 \\| feat: 新しい子 \\| - \\|/)\n})\n\ntest('(B8) 区間内に生成物以外のプレースホルダーが残れば従来どおり中止する', () => {\n const root = MANAGED_ROOT().replace('| #21 | b | - |', '| #21 | b | - |\\n\\n残り: \u003cphase1_number>')\n const { r, edits } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.notEqual(r.status, 0)\n assert.equal(edits.length, 0)\n})\n\ntest('(B9) 置換対象の節と旧表の間に通常の行・details があっても旧表は次の見出しまで除去され、details は保持される', () => {\n const root = MANAGED_ROOT().replace(\n '### Phase 1: A\\n\\n| Issue',\n '### Phase 1: A\\n\\nNOTE-LINE\\n\\n\u003cdetails>\u003csummary>メモ\u003c/summary>\\n\\nKEEP-DETAILS\\n\\n\u003c/details>\\n\\n| Issue',\n )\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(!body.includes('| #11 | old |'), '旧表が新節の後に残っている')\n assert.ok(!body.includes('NOTE-LINE'))\n assert.ok(body.includes('KEEP-DETAILS'), '保護対象の details が消えた')\n assert.ok(body.includes(END_M))\n assert.ok(body.includes('| #12 | legacy | - |'))\n assert.match(body, /### Phase 2: B/)\n})\n\ntest('(B10) マーカー無しでも節と旧表の間の通常の行で除去が止まらない', () => {\n const root = ROOT_BODY.replace('### Phase 1: 古い\\n', '### Phase 1: 古い\\n\\nNOTE-LINE\\n')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(!body.includes('古い子'))\n assert.ok(!body.includes('OLD-DETAIL-MARKER'))\n assert.match(body, /### Phase 2: 後続\\n\\nPHASE2-KEEP-MARKER/)\n})\n\n// \u003cdetails> の閉じ忘れ・タグ名の前方一致: depth が閉じないまま本文末に達すると以降の行が全て除外され、\n// 後ろの '### Phase N:' 見出しが置換対象から外れて節が重複したままマージが成功していた。\ntest('(C1) 閉じ忘れの \u003cdetails> の後ろに置換対象の節がある本文は edit せず exit 1 で中止する', () => {\n const root = ROOT_BODY.replace('### Phase 1: 古い', '\u003cdetails>\u003csummary>閉じ忘れ\u003c/summary>\\n\\n### Phase 1: 古い')\n const { r, body, edits, leftovers } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 1, r.stderr)\n assert.equal(edits.length, 0)\n assert.equal(body, null)\n assert.match(r.stderr, /\u003cdetails> が閉じていない/)\n assert.deepEqual(leftovers, [])\n})\n\ntest('(C2) `>` を伴わず行末で終わる \u003cdetails は複数行にまたがるタグとして判定できず、edit せず中止する', () => {\n abortsAmbiguous(ROOT_BODY.replace('### Phase 1: 古い', '\u003cdetails\\n\\n### Phase 1: 古い'), 'C2')\n})\n\ntest('(C2b) CRLF 本文で行末が CR の \u003cdetails 単独行も判定できず中止する', () => {\n abortsAmbiguous(ROOT_BODY.replace('### Phase 1: 古い', '\u003cdetails\\n\\n### Phase 1: 古い').replace(/\\n/g, '\\r\\n'), 'C2b')\n})\n\ntest('(C2c) 属性付き \u003cdetails open> を含む本文は中止する', () => {\n abortsAmbiguous(ROOT_BODY.replace('### Phase 1: 古い', '\u003cdetails open>\\n\\n### Phase 1: 退避済み\\n\\nKEEP\\n\\n\u003c/details>\\n\\n### Phase 1: 古い'), 'C2c')\n})\n\ntest('(C2d) 属性値に \u003c/details> を含む開きタグ(codex P1)は同じ行で depth が戻らず、退避内容を消さずに中止する', () => {\n abortsAmbiguous(ROOT_BODY.replace('### Phase 1: 古い', '\u003cdetails title=\"\u003c/details>\">\\n\\n### Phase 1: 退避済み\\n\\nKEEP-ATTR\\n\\n\u003c/details>\\n\\n### Phase 1: 古い'), 'C2d')\n})\n\ntest('(C3) \u003cdetailsfoo> のような許可リスト外のタグは別名でも中止する(旧: 通常行として素通し)', () => {\n abortsAmbiguous(ROOT_BODY.replace('### Phase 1: 古い', '\u003cdetailsfoo>\\n\\n### Phase 1: 古い'), 'C3')\n})\n\ntest('(C4) 属性の無い複数行 \u003cdetails> と 1 行で開閉する \u003cdetails>…\u003c/details> は除外され、閉じていれば中止しない', () => {\n const root = ROOT_BODY.replace(\n '### Phase 1: 古い',\n '\u003cdetails>\\n\\n### Phase 1: 退避済み\\n\\nKEEP-OPEN-ATTR\\n\\n\u003c/details>\\n\\n\u003cdetails>\u003csummary>x\u003c/summary>y\u003c/details>\\n\\n### Phase 1: 古い',\n )\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes('KEEP-OPEN-ATTR'), 'details 内が消えた')\n assert.ok(body.includes('\u003cdetails>\u003csummary>x\u003c/summary>y\u003c/details>'))\n assert.ok(!body.includes('古い子'))\n assert.equal(phase1Headings(body), 2)\n})\n\nconst abortsAmbiguous = (root, label) => {\n const { r, edits, leftovers } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 1, `${label}: ${r.stderr}`)\n assert.equal(edits.length, 0, `${label}: gh issue edit が呼ばれた`)\n assert.match(r.stderr, /判定できません/, label)\n assert.match(r.stderr, /バッククォート・\u003c!--・-->・バックスラッシュ/, label)\n assert.match(r.stderr, /許可していない HTML タグ/, label)\n assert.deepEqual(leftovers, [], label)\n}\n\ntest('(C4b) HTML コメントを含む行が判定範囲にあれば、コメント内の \u003c/details> を数えず推測もせず中止する', () => {\n abortsAmbiguous(ROOT_BODY.replace('### Phase 1: 古い', '\u003cdetails>\\n\\n\u003c!-- \u003c/details> -->\\n\\n### Phase 1: 退避済み\\n\\nKEEP-COMMENT\\n\\n\u003c/details>\\n\\n### Phase 1: 古い'), 'C4b')\n})\n\ntest('(C4c) 複数行 HTML コメントを含む本文も、本文を書き換えず中止する', () => {\n abortsAmbiguous(ROOT_BODY.replace('### Phase 1: 古い', '\u003cdetails>\\n\\n\u003c!--\\n\u003c/details>\\n\u003cdetails>\\n-->\\n\\n### Phase 1: 退避済み\\n\\nKEEP-MULTI\\n\\n\u003c/details>\\n\\n### Phase 1: 古い'), 'C4c')\n})\n\ntest('(C5) 閉じタグが `>` を欠く \u003c/details は閉じと判定できず、edit せず中止する', () => {\n abortsAmbiguous(ROOT_BODY.replace('### Phase 1: 古い', '\u003cdetails>\u003csummary>x\u003c/summary>\\n\\n\u003c/details\\n\\n### Phase 1: 古い'), 'C5')\n})\n\ntest('(C6) update-issue-tree が退避する \u003cdetails>\u003csummary>…\u003c/summary>…\u003c/details> は引き続き除外され、閉じていれば中止しない', () => {\n const { r, body } = runRoot(MANAGED_ROOT(), [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes('| #12 | legacy | - |'))\n assert.ok(body.includes('\u003c/details>'))\n})\n\n// 許可リスト(Issue #678・codex P1): \u003c は \u003cdetails> \u003c/details> \u003csummary> \u003c/summary> の素の形の一部のときだけ許す。\nconst ESCAPED_ATTR = '\u003cdetails>\\n\u003cspan title=\"\u003c/details>\">説明\u003c/span>\\n\\n### Phase 1: 退避済み\\n\\nKEEP\\n\\n\u003c/details>'\n\ntest('(H1) 属性値に \u003c/details> を持つ別タグ(codex P1)は depth が 0 に戻って見えても、edit せず中止する', () => {\n abortsAmbiguous(withOld(ESCAPED_ATTR), 'H1')\n})\n\ntest('(H2) \u003cspan>・\u003cbr>・\u003ca href> を含む判定範囲は中止する', () => {\n for (const tag of ['\u003cspan>x\u003c/span>', 'a\u003cbr>b', '\u003ca href=\"x\">link\u003c/a>']) abortsAmbiguous(withOld(tag), `H2 ${tag}`)\n})\n\ntest('(H3) update-issue-tree 形式の退避節(summary 付き details・旧表・\u003c/details>)は中止せず更新でき、退避節が保持される', () => {\n const esc = '\u003cdetails>\u003csummary>移行前の Phase 別表(update-issue-tree が退避)\u003c/summary>\\n\\n| Issue | タイトル | 分解 |\\n|---|---|---|\\n| #9 | KEEP-OLD | - |\\n\\n\u003c/details>'\n const { r, body, edits } = runRootFull(withOld(esc), [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(edits.length, 1)\n assert.ok(body.includes(esc), '退避節が保持されていない')\n assert.ok(!body.includes('古い子'))\n})\n\ntest('(H4) 管理マーカーの外にある HTML タグは中止の対象にならない', () => {\n const root = MANAGED_ROOT().replace('OPS-KEEP-MARKER', '\u003cspan title=\"\u003c/details>\">KEEP\u003c/span>\u003cbr>')\n const { r, body, edits } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(edits.length, 1)\n assert.ok(body.includes('\u003cspan title=\"\u003c/details>\">KEEP\u003c/span>\u003cbr>'))\n})\n\ntest('(H5) 管理マーカーの内側の HTML タグは中止する', () => {\n abortsAmbiguous(MANAGED_ROOT().replace('| #11 | old | - |', '| #11 | \u003cb>old\u003c/b> | - |'), 'H5')\n})\n\ntest('(H6) > 単独の行(a > b)は中止しない', () => {\n const { r, body, edits } = runRootFull(withOld('a > b\\n\\n> 引用'), [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(edits.length, 1)\n assert.ok(body.includes('a > b'))\n})\n\n// 行内・行末の \u003c/details>: 閉じている本文でも行頭以外の閉じタグを数えず exit 8 になっていた(Issue #678)。\n// 数えるのは行内・行末の閉じタグまで。除外(素通し)にする行の条件は Issue #689 の (I*) 系で固定している。\nconst withOld = (head) => ROOT_BODY.replace('### Phase 1: 古い', `${head}\\n\\n### Phase 1: 古い`)\n\ntest('(D1) 単独行 \u003c/details> で閉じる退避節でも中止せず、退避節の外だけが置換される', () => {\n const root = withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n| Issue | タイトル |\\n|---|---|\\n| #9 | old |\\n\\n\u003c/details>')\n const { r, body, edits } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(edits.length, 1)\n assert.ok(body.includes('| #9 | old |'))\n assert.ok(!body.includes('古い子'), '旧節が残っている')\n assert.equal(phase1Headings(body), 1)\n})\n\ntest('(D1b) 退避節内に同名見出しがあっても素通しされ、外側の旧節だけが置換される', () => {\n const root = withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n### Phase 1: 退避済み\\n\\n| #9 | old |\\n\\n\u003c/details>')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes('### Phase 1: 退避済み'))\n assert.ok(!body.includes('古い子'))\n assert.equal(phase1Headings(body), 2)\n})\n\ntest('(D1c) 見出し行の \u003c/details> も他の行と同じく閉じと数える(表・見出し行を特別扱いしない)', () => {\n const root = withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n| #9 | old |\\n\\n### Phase 2: fix \u003c/details>')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes('### Phase 2: fix \u003c/details>'))\n assert.ok(body.includes('| #9 | old |'))\n assert.ok(!body.includes('古い子'))\n assert.equal(phase1Headings(body), 1)\n})\n\ntest('(D1d) 退避節が表行末の \u003c/details> で閉じていても、閉じと数えて更新できる', () => {\n const root = withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n| #9 | old |\u003c/details>')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes('| #9 | old |\u003c/details>'))\n assert.ok(!body.includes('古い子'))\n})\n\ntest('(D2) 1 行で開閉したあとに文字が続く行でも中止せず、素通しされる', () => {\n const line = '\u003cdetails>\u003csummary>x\u003c/summary>y\u003c/details> 補足'\n const { r, body } = runRoot(withOld(line), [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes(line))\n assert.ok(!body.includes('古い子'))\n assert.equal(phase1Headings(body), 1)\n})\n\ntest('(D3) 本当に閉じていない本文は従来どおり edit せず中止し、一時ファイルを残さない', () => {\n for (const head of ['\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n| #9 | old |', '\u003cdetails>\u003csummary>x\u003c/summary>y 補足']) {\n const { r, edits, leftovers } = runRootFull(withOld(head), [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 1, r.stderr)\n assert.equal(edits.length, 0)\n assert.match(r.stderr, /\u003cdetails> が閉じていない/)\n assert.deepEqual(leftovers, [])\n }\n})\n\ntest('(D4) 地の文の中ほどにある \u003c/details> で退避節が閉じ、後ろの節は置換対象に戻る', () => {\n const root = withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\nfoo \u003c/details> bar')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes('foo \u003c/details> bar'))\n assert.ok(!body.includes('古い子'))\n assert.equal(phase1Headings(body), 1)\n})\n\ntest('(D5) 1 行内の入れ子は閉じが 2 つなら通り、1 つなら中止する', () => {\n const ok = runRoot(withOld('\u003cdetails>\u003cdetails>a\u003c/details>\u003c/details>'), [issue(111, 'feat: 新しい子')])\n assert.equal(ok.r.status, 0, ok.r.stderr)\n const ng = runRootFull(withOld('\u003cdetails>\u003cdetails>a\u003c/details>'), [issue(111, 'feat: 新しい子')])\n assert.equal(ng.r.status, 1, ng.r.stderr)\n assert.equal(ng.edits.length, 0)\n})\n\ntest('(D6) バッククォートを含む行が判定範囲にあれば、\u003cdetails> の言及でも中止する', () => {\n abortsAmbiguous(withOld('`\u003cdetails>` を使う説明'), 'D6 open')\n abortsAmbiguous(withOld('\u003cdetails>\u003csummary>x\u003c/summary>\\n\\n`\u003c/details>` の説明'), 'D6 close')\n})\n\ntest('(D6b) 複数バッククォートのコードスパンを含む本文も中止する', () => {\n abortsAmbiguous(withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n`` \u003c/details> ``\\n\\n### Phase 1: 退避済み\\n\\n\u003c/details>'), 'D6b')\n})\n\ntest('(D7) タイトルに \u003cdetails> を含む子 issue は文字参照で書かれ、再マージしても未閉じ扱いで中止しない', () => {\n const kids = [issue(111, 'fix: \u003cdetails> の閉じ忘れ')]\n const first = runRoot(ROOT_BODY, kids)\n assert.equal(first.r.status, 0, first.r.stderr)\n assert.ok(first.body.includes('fix: <details> の閉じ忘れ'))\n const second = runRootFull(first.body, kids)\n assert.equal(second.r.status, 0, second.r.stderr)\n assert.equal(second.edits.length, 1)\n assert.equal(phase1Headings(second.body), 1)\n})\n\ntest('(D8) 表行の行中の \u003c/details> も閉じと数える', () => {\n const root = withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n| #5 | fix: \u003c/details> を数えず | - |')\n const { r, body } = runRoot(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes('| #5 | fix: \u003c/details> を数えず | - |'))\n assert.ok(!body.includes('古い子'))\n})\n\ntest('(D9) 表行のタイトルに \u003c!-- を含む本文は、状態を追わず中止する', () => {\n abortsAmbiguous(withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n### Phase 1: 退避済み\\n\\n| #9 | fix: \u003c!-- の説明 |\\n\\n\u003c/details>'), 'D9')\n})\n\n// バックスラッシュでエスケープした details タグは文字として表示されるだけで、深さを変えない(PR #679 レビュー)。\ntest('(D11) 説明文中のバックスラッシュ(\\\u003cdetails>)を含む行は、エスケープを解釈せず中止する', () => {\n abortsAmbiguous(withOld('タグは \\\\\u003cdetails> と書く'), 'D11')\n})\n\ntest('(D12) 退避節内のバックスラッシュを含む行(\\\u003c/details>)も中止する', () => {\n abortsAmbiguous(withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n説明: \\\\\u003c/details> は文字\\n\\n### Phase 1: 退避済み\\n\\n\u003c/details>'), 'D12')\n})\n\ntest('(D13) 二重バックスラッシュ直後の \u003cdetails> も中止する', () => {\n abortsAmbiguous(withOld('\\\\\\\\\u003cdetails>'), 'D13')\n})\n\ntest('(D10) 見出し行に \u003c!-- を含む本文は中止する', () => {\n abortsAmbiguous(withOld('### メモ \u003c!-- 注\\n\\n\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n### Phase 1: 退避済み\\n\\n\u003c/details>'), 'D10')\n})\n\n// 方針(Issue #678): コードスパン・HTML コメント・エスケープの状態は追わない。判定範囲に曖昧な記号があれば中止する。\ntest('(E1) 状態追跡のコード(strip_code・pend・incomment・エスケープ除去)を持たず、未閉じ \u003cdetails> は edit せず中止する', () => {\n const src = readFileSync(new URL('../scripts/merge-root-body.sh', import.meta.url), 'utf8')\n for (const dead of ['strip_code', 'pend_hit', 'incomment', 'gsub(/[\\\\\\\\]\u003c']) assert.ok(!src.includes(dead), `${dead} が残っている`)\n const ng = runRootFull(withOld('\u003cdetails>\u003csummary>x\u003c/summary>'), [issue(111, 'feat: 新しい子')])\n assert.equal(ng.r.status, 1, ng.r.stderr)\n assert.equal(ng.edits.length, 0)\n assert.match(ng.r.stderr, /\u003cdetails> が閉じていない/)\n})\n\ntest('(E2) 複数行のコードスパンを含む本文は、スパン内の \u003c/details> を数えず推測もせず中止する', () => {\n abortsAmbiguous(withOld('\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n### Phase 1: 退避済み\\n\\n説明 `例:\\nfoo \u003c/details>\\n終わり`\\n\\n\u003c/details>'), 'E2')\n})\n\ntest('(E3) 閉じないバッククォートを含む本文は中止する', () => {\n for (const head of ['\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n説明 `例:\\nfoo \u003c/details>\\n\\n続き', '説明 `例:\\n\u003cdetails>x']) abortsAmbiguous(withOld(head), 'E3')\n})\n\nconst KEEP = '### Phase 1: 退避済み'\nconst ESC = `\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n${KEEP}\\n\\nKEEP-BODY\\n\\n\u003c/details>`\n\n// レビュー指摘 1(Bugbot High): 閉じないバッククォートで行の残りを読み飛ばすと、その中の --> も読み飛ばして後続の \u003cdetails> を見落とした。\ntest('(E4) 閉じないバッククォートと --> を持つ本文は中止し、退避済みの節を消さない', () => {\n abortsAmbiguous(withOld(`説明 \\`未閉じ -->\\n${ESC}`), 'E4 backtick+-->')\n abortsAmbiguous(withOld(`\u003c!-- 注 \\`\\n-->\\n${ESC}`), 'E4 comment')\n})\n\n// レビュー指摘 2(codex P1): 見出し・表行の中で閉じるコメントを検出できなかった。\ntest('(E5) 見出し行・表行の中で閉じる --> を含む本文は中止する', () => {\n abortsAmbiguous(withOld(`\u003c!-- 開始\\n# 注記 -->\\n${ESC}`), 'E5 heading')\n abortsAmbiguous(withOld(`\u003c!-- 開始\\n| 注記 -->\\n${ESC}`), 'E5 table')\n})\n\n// レビュー指摘 3(codex P1): エスケープされたバッククォート・\u003c!-- をコードスパン・コメントの開始として扱った。\ntest('(E6) エスケープされたバッククォート・\u003c!-- を含む本文は中止する', () => {\n abortsAmbiguous(withOld(`文字の \\\\\\` です\\n${ESC}`), 'E6 backtick')\n abortsAmbiguous(withOld(`文字の \\\\\u003c!-- です\\n${ESC}`), 'E6 comment')\n})\n\ntest('(F1) 判定範囲に記号が無ければ、行内に複数の \u003cdetails> / \u003c/details> があっても数えて更新できる', () => {\n const head = `\u003cdetails>a\u003c/details> 補足 \u003cdetails>\u003cdetails>b\u003c/details>\u003c/details>\\n\\n${ESC}\\n\\n行頭 \u003c/details> を余分に閉じても深さは負にならない`\n const { r, body, edits } = runRootFull(withOld(head), [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(edits.length, 1)\n assert.ok(body.includes('KEEP-BODY'))\n assert.ok(body.includes(KEEP))\n assert.ok(!body.includes('古い子'))\n assert.equal(phase1Headings(body), 2)\n})\n\ntest('(F2) 管理マーカーの外にあるバッククォート・コメント・バックスラッシュでは止まらず、内側だけを判定する', () => {\n const root = MANAGED_ROOT()\n .replace('OPS-KEEP-MARKER', 'OPS `code` \u003c!-- c --> \\\\ KEEP')\n .replace('## Phase 別表\\n', '## Phase 別表\\n\\n説明 `x` と \u003c!-- メモ --> と \\\\\\n')\n const { r, body, edits } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(edits.length, 1)\n assert.ok(body.includes('OPS `code` \u003c!-- c --> \\\\ KEEP'))\n assert.ok(body.includes('| #12 | legacy | - |'))\n})\n\ntest('(F3) 管理マーカーの内側にバッククォートがあれば中止する', () => {\n abortsAmbiguous(MANAGED_ROOT().replace('| #11 | old | - |', '| #11 | `old` | - |'), 'F3')\n})\n\ntest('(F4) コードフェンス内の記号は走査せず、フェンス外に記号が無ければ更新できる', () => {\n const root = withOld('```\\n`x` \u003c!-- y --> \\\\\\n```')\n const { r, edits } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(edits.length, 1)\n})\n\n// ---- CELL を HTML 文字参照へ変更(Issue #678 追加指摘)----\n// タイトル由来の | \\ ` \u003c > & が生成行に残ると、次回以降の --root 再マージが判定範囲の記号で中止したり、\n// タグに触れた行が置換対象から外れて Phase が二重化した。生成時に文字参照へ変換して無害化する。\nconst countLines = (body, re) => body.split('\\n').filter((l) => re.test(l)).length\n\ntest('(G1) | と \\\\ を含むタイトルで create-root-body.sh → --root 再マージを 2 回続けても中止せず、Phase 行は重複しない', () => {\n const ptitle = 'feat(phase-1): a | b \\\\ c'\n const ctitle = 'feat: x | y \\\\ z'\n const ctx = setup({\n 'sub_100_1.json': [issue(101, ptitle, 'open', ['phase:1'])],\n 'sub_101_1.json': [issue(111, ctitle)],\n 'sub_111_1.json': [],\n })\n let created\n try {\n created = run(ctx)\n } finally {\n rmSync(ctx.dir, { recursive: true, force: true })\n }\n assert.equal(created.r.status, 0, created.r.stderr)\n assert.ok(created.body.includes('|') && created.body.includes('\'), '文字参照が出ていない')\n assert.ok(!created.body.includes('\\\\'), 'バックスラッシュが残っている')\n const kids = [issue(111, ctitle)]\n const env = { PHASE_TITLE: ptitle }\n const m1 = runRootFull(created.body, kids, { env })\n assert.equal(m1.r.status, 0, m1.r.stderr)\n const m2 = runRootFull(m1.body, kids, { env })\n assert.equal(m2.r.status, 0, m2.r.stderr)\n assert.equal(m2.edits.length, 1)\n assert.equal(countLines(m2.body, /^\\| Phase 1 \\|/), 1)\n assert.equal(phase1Headings(m2.body), 1)\n assert.equal(countLines(m2.body, /^\\| #111 \\|/), 1)\n})\n\ntest('(G2) Phase 親タイトルが \u003c/details> / \u003cdetails>x\u003c/details> でも表行・節が置換され、1 つずつしか無い', () => {\n const t1 = 'fix: \u003c/details>'\n const t2 = 'fix: \u003cdetails>x\u003c/details>'\n const a1 = ['--root', '100', '--granularity', '2h', '--phase', '1', '--phase-number', '101']\n const a2 = ['--root', '100', '--granularity', '2h', '--phase', '2', '--phase-number', '102']\n const kids = [issue(111, 'feat: 新しい子')]\n let body = ROOT_BODY\n for (let round = 0; round \u003c 2; round++) {\n const r1 = runRootFull(body, kids, { args: a1, env: { PHASE_TITLE: t1 } })\n assert.equal(r1.r.status, 0, `round ${round} phase1: ${r1.r.stderr}`)\n const r2 = runRootFull(r1.body, kids, { args: a2, env: { PHASE_TITLE: t2 } })\n assert.equal(r2.r.status, 0, `round ${round} phase2: ${r2.r.stderr}`)\n body = r2.body\n }\n assert.equal(countLines(body, /^\\| Phase 1 \\|/), 1)\n assert.equal(countLines(body, /^\\| Phase 2 \\|/), 1)\n assert.equal(phase1Headings(body), 1)\n assert.equal(countLines(body, /^### Phase 2:/), 1)\n assert.ok(body.includes('fix: </details>'))\n assert.ok(body.includes('fix: <details>x</details>'))\n assert.ok(!body.includes('古い子') && !body.includes('PHASE2-KEEP-MARKER'), '旧節が残っている')\n assert.ok(body.includes('OPS-KEEP-MARKER'))\n})\n\ntest('(G3) バッククォート・\u003c!--・--> を含むタイトルでも 2 回の再マージが通り、生成行に記号が残らない', () => {\n const t = 'feat: `x` \u003c!-- y --> z'\n const kids = [issue(111, t)]\n const env = { PHASE_TITLE: t }\n const m1 = runRootFull(ROOT_BODY, kids, { env })\n assert.equal(m1.r.status, 0, m1.r.stderr)\n const m2 = runRootFull(m1.body, kids, { env })\n assert.equal(m2.r.status, 0, m2.r.stderr)\n const gen = m2.body.split('\\n').filter((l) => /^(\\| Phase 1 \\||### Phase 1:|\\| #111 \\|)/.test(l))\n assert.equal(gen.length, 3)\n for (const l of gen) assert.ok(!/[`\\\\]|\u003c!--|-->/.test(l), `生成行に記号が残る: ${l}`)\n assert.ok(m2.body.includes('`x` <!-- y --> z'))\n assert.equal(phase1Headings(m2.body), 1)\n})\n\ntest('(G4) & は最初に変換され、文字参照が二重変換されない', () => {\n const kids = [issue(111, 'a&b \u003cc> `d` e\\\\f g|h')]\n const { r, body } = runRootFull(ROOT_BODY, kids)\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes('| #111 | a&b <c> `d` e\f g|h | - |'))\n})\n\ntest('(G5) 旧形式のエスケープ(\\\\|)が残る本文は中止し、文字参照で手直しする案内を出す', () => {\n const root = ROOT_BODY.replace('| #901 | 古い子 | - |', '| #901 | a \\\\| b | - |')\n const { r, edits } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 1, r.stderr)\n assert.equal(edits.length, 0)\n assert.match(r.stderr, /旧形式のエスケープ/)\n assert.match(r.stderr, /文字参照/)\n assert.match(r.stderr, /|/)\n assert.match(r.stderr, /管理マーカー区間内の行も対象/)\n assert.match(r.stderr, /update-issue-tree を再実行/)\n assert.doesNotMatch(r.stderr, /update-issue-tree で本文を更新してください/)\n assert.doesNotMatch(r.stderr, /回避策になりません/)\n})\n\n// Issue #683: update-issue-tree の管理区間の出力を create-issue-tree の --root 再マージへ通す往復。\n// update-issue-tree が無い配布形態(スキル単体の配布)では skip する。スキルをまたぐ import はしない。\nconst UPDATE_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'update-issue-tree', 'scripts', 'update-root-body.sh')\nconst UPDATE_GH_STUB = `#!/usr/bin/env bash\nD=\"\\${STUB_DIR}\"\ncase \"$1\" in\n auth) exit 0 ;;\n issue) cp \"$5\" \"\\${D}/edited.body\"; exit 0 ;;\n api)\n path=\"$2\"\n case \"\\${path}\" in\n */sub_issues\\\\?*)\n rest=\"\\${path#*/issues/}\"; n=\"\\${rest%%/*}\"; page=\"\\${path##*page=}\"\n f=\"\\${D}/sub_\\${n}_\\${page}.json\"\n if [[ -f \"\\${f}\" ]]; then cat \"\\${f}\"; exit 0; fi\n exit 1 ;;\n */issues/*)\n if [[ -f \"\\${D}/edited.body\" ]]; then\n jq -n --rawfile b \"\\${D}/edited.body\" '{body: $b, repository_url: \"https://api.github.com/repos/o/r\"}'\n else\n cat \"\\${D}/root.json\"\n fi\n exit 0 ;;\n esac ;;\nesac\nexit 1\n`\n\n// update-root-body.sh を gh スタブで実行し、生成した本文を返す。ルート 254 の直下に phase:1 の親 10・その子 11。\nfunction runUpdate(rootBody, parentTitle, childTitle) {\n const dir = mkdtempSync(join(tmpdir(), 'step6upd-'))\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'), UPDATE_GH_STUB)\n chmodSync(join(bin, 'gh'), 0o755)\n const url = 'https://api.github.com/repos/o/r'\n const mk = (number, title, labels, total) => ({\n number, title, state: 'open', repository_url: url,\n labels: labels.map((name) => ({ name })), sub_issues_summary: { total, completed: 0 },\n })\n writeFileSync(join(stub, 'root.json'), JSON.stringify({ number: 254, repository_url: url, body: rootBody }))\n writeFileSync(join(stub, 'sub_254_1.json'), JSON.stringify([mk(10, parentTitle, ['phase:1'], 1)]))\n writeFileSync(join(stub, 'sub_10_1.json'), JSON.stringify([mk(11, childTitle, [], 0)]))\n try {\n const r = spawnSync('bash', [UPDATE_SCRIPT, '--root', '254', '--granularity', '2h', '--reassigned', '0', '--orphans', '0', '--labels', '0', '--new-phases', '0', '--split', '0', '--date', '2026-10-06'], {\n cwd: dir,\n encoding: 'utf8',\n env: { PATH: `${bin}:${process.env.PATH}`, STUB_DIR: stub, TMPDIR: tmp, HOME: dir },\n })\n const f = join(stub, 'edited.body')\n return { r, body: existsSync(f) ? readFileSync(f, 'utf8') : null }\n } finally {\n rmSync(dir, { recursive: true, force: true })\n }\n}\n\nconst HOSTILE_TITLE = 'fix: a | b \\\\ c `d` \u003cdetails>x\u003c/details> \u003c!-- y --> \u003c/details>'\n\ntest('(G6) update-issue-tree が書いた管理区間は create-issue-tree の --root 再マージで中止しない', { skip: !existsSync(UPDATE_SCRIPT) }, () => {\n const upd = runUpdate('', HOSTILE_TITLE, HOSTILE_TITLE)\n assert.equal(upd.r.status, 0, upd.r.stderr)\n const kids = [issue(111, HOSTILE_TITLE)]\n const m = runRootFull(upd.body, kids, { env: { PHASE_TITLE: HOSTILE_TITLE } })\n assert.equal(m.r.status, 0, m.r.stderr)\n assert.equal(m.edits.length, 1)\n assert.doesNotMatch(m.r.stderr, /バッククォート/)\n for (const mk of ['phase-plan:begin', 'phase-plan:end', 'inventory:begin', 'inventory:end']) {\n assert.equal(m.body.split(`\u003c!-- update-issue-tree:${mk} -->`).length - 1, 1, mk)\n }\n assert.equal(countLines(m.body, /^### Phase 1:/), 1)\n assert.equal(countLines(m.body, /^\\| Phase 1 \\|/), 1)\n assert.equal(countLines(m.body, /^\\| #111 \\|/), 1)\n // 往復: マージ後の本文を update-issue-tree へもう一度通しても通る\n const upd2 = runUpdate(m.body, HOSTILE_TITLE, HOSTILE_TITLE)\n assert.equal(upd2.r.status, 0, upd2.r.stderr)\n})\n\ntest('(G7) update-issue-tree の表セル・見出しは create-issue-tree の CELL と同じ出力になる', { skip: !existsSync(UPDATE_SCRIPT) }, () => {\n const inputs = [\n 'a&b \u003cc> `d` e\\\\f g|h',\n '&',\n 'x\\r\\ny\\n\\nz',\n '\u003c!-- update-issue-tree:phase-plan:end -->',\n '\u003c/details>',\n 'feat: 日本語のタイトル',\n ]\n for (const t of inputs) {\n const cell = spawnSync('bash', ['-c', 'source \"$1\"; jq -rn --arg t \"$2\" \"\\\\$t | ${CELL}\"', 'bash', LIB, t], { encoding: 'utf8' })\n assert.equal(cell.status, 0, cell.stderr)\n const expected = cell.stdout.replace(/\\n$/, '')\n const upd = runUpdate('', t, t)\n assert.equal(upd.r.status, 0, upd.r.stderr)\n assert.ok(upd.body.includes(`### Phase 1: ${expected}\\n`), JSON.stringify([t, expected]))\n assert.ok(upd.body.includes(`| #11 | ${expected} | - |`), JSON.stringify([t, expected]))\n }\n})\n\n// Issue #689: 文字参照化(#679・#684)より前の版が書いた表行・見出しに素のタグ文字列が残っていても、\n// 行頭が \u003cdetails> / \u003c/details> でなければ除外せず置換対象に戻す。開いたまま終わる表行・見出しは中止する。\ntest('(I1) マーカー無し: 表行・見出し・子行のタグ文字列は除外されず置換される', () => {\n const root = ROOT_BODY\n .replace('| Phase 1 | #101 古い | 9 | 9 |', '| Phase 1 | #101 fix: \u003cdetails>x\u003c/details> | 9 | 9 |')\n .replace('### Phase 1: 古い', '### Phase 1: fix: \u003c/details>')\n .replace('| #901 | 古い子 | - |', '| #901 | \u003cdetails>a\u003c/details> 古い子 | - |')\n const { r, body, edits } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(edits.length, 1)\n assert.equal(countLines(body, /^\\| Phase 1 \\|/), 1)\n assert.equal(phase1Headings(body), 1)\n assert.ok(!body.includes('古い子') && !body.includes('fix: \u003c/details>'), '旧行が残っている')\n assert.ok(body.includes('PHASE2-KEEP-MARKER') && body.includes('OPS-KEEP-MARKER'))\n})\n\ntest('(I2) 管理マーカー区間: 表行・見出しのタグ文字列は置換され、退避節は保持される', () => {\n const root = MANAGED_ROOT()\n .replace('| Phase 1 | #10 A | 1 | 1 |', '| Phase 1 | #10 A \u003c/details> | 1 | 1 |')\n .replace('### Phase 1: A\\n\\n| Issue | タイトル | 分解 |\\n|-------|---------|------|\\n| #11', '### Phase 1: A \u003cdetails>q\u003c/details>\\n\\n| Issue | タイトル | 分解 |\\n|-------|---------|------|\\n| #11')\n const { r, body } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.equal(countLines(body, /^\\| Phase 1 \\|/), 1)\n assert.ok(!body.includes('| #11 | old |'), '旧子行が残っている')\n assert.ok(body.includes('| #12 | legacy | - |') && body.includes('\u003cdetails>\u003csummary>移行前'))\n assert.equal(phase1Headings(body), 2)\n})\n\ntest('(I3) 開いたまま終わる表行・見出しは構造かタイトルか決められず中止する', () => {\n for (const [from, to] of [\n ['### Phase 1: 古い', '### Phase 1: fix: \u003cdetails>'],\n ['| Phase 1 | #101 古い | 9 | 9 |', '| Phase 1 | #101 a \u003cdetails> | 9 | 9 |'],\n ]) {\n const { r, edits, leftovers } = runRootFull(ROOT_BODY.replace(from, to), [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 1, r.stderr)\n assert.equal(edits.length, 0)\n assert.match(r.stderr, /閉じていない素の \u003cdetails>/)\n assert.deepEqual(leftovers, [])\n }\n})\n\ntest('(I4) 深さ 0 の段落の行内タグは除外されない: 旧節の中なら落ち、外なら保持される', () => {\n const inside = withOld('').replace('OLD-DETAIL-MARKER', '説明 \u003cdetails>a\u003c/details> の話')\n const a = runRootFull(inside, [issue(111, 'feat: 新しい子')])\n assert.equal(a.r.status, 0, a.r.stderr)\n assert.ok(!a.body.includes('説明 \u003cdetails>a\u003c/details> の話'))\n const outside = ROOT_BODY.replace('PHASE2-KEEP-MARKER', '説明 \u003cdetails>a\u003c/details> の話')\n const b = runRootFull(outside, [issue(111, 'feat: 新しい子')])\n assert.equal(b.r.status, 0, b.r.stderr)\n assert.ok(b.body.includes('説明 \u003cdetails>a\u003c/details> の話'))\n})\n\ntest('(I5) 深さ 0 の表行で余った \u003c/details> は深さを負にせず、後ろの退避節は保持される', () => {\n const root = withOld('| x | \u003c/details> |\\n\\n\u003cdetails>\u003csummary>退避\u003c/summary>\\n\\n| #9 | keep |\\n\\n\u003c/details>')\n const { r, body } = runRootFull(root, [issue(111, 'feat: 新しい子')])\n assert.equal(r.status, 0, r.stderr)\n assert.ok(body.includes('| #9 | keep |') && body.includes('\u003cdetails>\u003csummary>退避\u003c/summary>'))\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":74743,"content_sha256":"bff1aea7cb0c5f5e4ba5572502fa4637fe2f23f6dbde8eb8ca559b8068102c14"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"create-issue-tree","type":"text"}]},{"type":"paragraph","content":[{"text":"要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。","type":"text"}]},{"type":"paragraph","content":[{"text":"ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の","type":"text"},{"text":"優先度","type":"text","marks":[{"type":"strong"}]},{"text":"に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に ","type":"text"},{"text":"phaseGate: true","type":"text","marks":[{"type":"code_inline"}]},{"text":" を指定する(implement-issue-tree の SKILL.md 参照)。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"使い方","type":"text"}]},{"type":"paragraph","content":[{"text":"引数としてタスク要件テキストまたはファイルパスを渡す。","type":"text"},{"type":"br"},{"text":"--phase","type":"text","marks":[{"type":"code_inline"}]},{"text":" オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。","type":"text"},{"type":"br"},{"text":"2 回目以降の部分起票では ","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。","type":"text"},{"type":"br"},{"text":"--milestone","type":"text","marks":[{"type":"code_inline"}]},{"text":" オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる (","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指定時は省略可。既存ルートの milestone を自動継承する)。 ","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":" 明示 > ","type":"text","marks":[{"type":"strong"}]},{"text":"--root","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 指定時の既存ルート issue 本文マーカー > 既定 ","type":"text","marks":[{"type":"strong"}]},{"text":"2h","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" の順 (Step 1 で確定)。決定した値はルート issue 本文へ ","type":"text"},{"text":"\u003c!-- granularity: Nh -->","type":"text","marks":[{"type":"code_inline"}]},{"text":" として 永続化され、同じルートに対する ","type":"text"},{"text":"update-issue-tree","type":"text","marks":[{"type":"code_inline"}]},{"text":" 実行時に継承される。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"create-issue-tree \"ユーザー認証機能を実装する\"\ncreate-issue-tree requirements.md\ncreate-issue-tree requirements.md --phase 2 --root 123\ncreate-issue-tree requirements.md --milestone \"v2.0\"\ncreate-issue-tree requirements.md --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":"heading","attrs":{"level":2},"content":[{"text":"フロー","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1: 要件を分析してタスクを分解する","type":"text"}]},{"type":"paragraph","content":[{"text":"入力テキストまたはファイルから要件を読み込み、以下の観点でタスクを分解する。","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"粒度基準: 1 issue は実装 ","type":"text","marks":[{"type":"strong"}]},{"text":"${GRANULARITY}","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"(既定 2h・","type":"text","marks":[{"type":"strong"}]},{"text":"--granularity","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" で変更可)程度に収める。","type":"text","marks":[{"type":"strong"}]},{"text":" 超えると判断した場合は sub-issue に再分解する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"人手タスクの判定:","type":"text","marks":[{"type":"strong"}]},{"text":" 依頼者の回答・レビュー、対象者の聞き取り、実機・スクリーンリーダー操作、承認など自動実装できないものは ","type":"text"},{"text":"owner:human","type":"text","marks":[{"type":"code_inline"}]},{"text":"(人手のみ)、人と Claude の協働は ","type":"text"},{"text":"owner:joint","type":"text","marks":[{"type":"code_inline"}]},{"text":" とする。判定が曖昧なものは下の確認提示でユーザーに聞く。ラベルは付けた issue 本人にだけ効き、子孫へは継承されない。","type":"text"},{"text":"implement-issue-tree","type":"text","marks":[{"type":"code_inline"}]},{"text":" の ","type":"text"},{"text":"skipLabels","type":"text","marks":[{"type":"code_inline"}]},{"text":" で自動実装の対象外にできる(Step 5 の案内と次のアクション参照)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"--granularity","type":"text","marks":[{"type":"code_inline"}]},{"text":" の値は代入前に Claude 側でも同じ正規表現(","type":"text"},{"text":"^[1-9][0-9]*h$","type":"text","marks":[{"type":"code_inline"}]},{"text":")で検証し、 不一致なら","type":"text"},{"text":"このフェンスを実行せず","type":"text","marks":[{"type":"strong"}]},{"text":"ユーザーに再指定を求める(未信頼値を生成済みシェルへ 埋め込まない)。優先順位は使い方節のとおり、","type":"text"},{"text":"--granularity","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" > ルートのマーカー > 既定 2h","type":"text","marks":[{"type":"strong"}]},{"text":" の順。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# --granularity で渡された時間を実際の値で代入する(実行時に Claude が置き換える)\n# 例: --granularity 4h が指定された場合 → GRANULARITY_ARG=\"4h\" / 未指定の場合は空文字\nGRANULARITY_ARG=\"\u003c--granularity で渡された時間(未指定なら空文字)>\"\n# --root で渡された Issue 番号(未指定なら空文字)。Step 2.5 で ROOT_NUMBER として\n# OPEN 検証込みで正式に確定する前に、粒度継承の参照のためここでも読む\nROOT_ARG=\"\u003c--root で渡された Issue 番号(未指定なら空文字)>\"\n\nif [[ -n \"${GRANULARITY_ARG}\" ]]; then\n GRANULARITY=\"${GRANULARITY_ARG}\"\n GRANULARITY_SOURCE=\"--granularity 引数\"\nelif [[ -n \"${ROOT_ARG}\" ]]; then\n # ルート本文の取得失敗とマーカー不在は分離する。取得失敗は「粒度を既定へ倒さず中止」する\n # (gh issue view 自体の失敗を || true で握り潰すと、権限エラー・issue 不在等を\n # 「マーカーなし」と誤読して既定 2h へサイレントに倒れてしまうため)\n ROOT_BODY=$(gh issue view \"${ROOT_ARG}\" --json body --jq '.body') \\\n || { echo \"エラー: ルート issue #${ROOT_ARG} の本文を取得できません。粒度を既定へ倒さず中止します。\"; 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_ARG} のマーカー\"\n else\n GRANULARITY=\"2h\"\n GRANULARITY_SOURCE=\"既定値\"\n fi\nelse\n GRANULARITY=\"2h\"\n GRANULARITY_SOURCE=\"既定値\"\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

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

; then\n echo \"エラー: --granularity の値 '${GRANULARITY}' は不正です(許可: 正整数+h、例 2h)。中止します。\"\n exit 1\nfi\n\n# 確定値と由来を必ず出力する(既定 2h と読み替えないことをここで可視化する)\necho \"粒度基準: ${GRANULARITY}(由来: ${GRANULARITY_SOURCE})\"","type":"text"}]},{"type":"paragraph","content":[{"text":"以降の Step は Step 1 で出力された ","type":"text"},{"text":"GRANULARITY","type":"text","marks":[{"type":"code_inline"}]},{"text":" の値を使う(既定 2h と読み替えない)。","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"各タスクの依存関係・実行順を把握する。人手タスクに依存する実装タスクは、本文に ","type":"text"},{"text":"## 前提","type":"text","marks":[{"type":"code_inline"}]},{"text":" 節を置いて行頭に ","type":"text"},{"text":"- #N","type":"text","marks":[{"type":"code_inline"}]},{"text":" を書く(Step 5 参照)。そのため人手 issue を先に起票する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"タスク数を集計し、Phase 分割の要否を判断する(目安: 10 件超で Phase 分割を検討)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"分解結果をユーザーに提示し、Phase 構成・issue 数について確認を取る。","type":"text"},{"type":"br"},{"text":"--phase","type":"text","marks":[{"type":"code_inline"}]},{"text":" オプションが指定されている場合は該当 Phase のタスクのみ対象とする。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2: Phase 構成を設計する","type":"text"}]},{"type":"paragraph","content":[{"text":"タスク数・依存関係をもとに Phase を設計する。","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Phase は独立して開発可能な単位で分割する(例: Phase 1 基盤・Phase 2 機能・Phase 3 改善)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"各 Phase に親 issue タイトル(Conventional Commits 形式推奨)を割り当てる","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"全体をまとめるルート(トラッキング)issue のタイトルを決定する","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"例: ","type":"text"},{"text":"chore(global): 全 open issue の Phase 別トラッキング (YYYY-MM-DD)","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]}]},{"type":"paragraph","content":[{"text":"タスクが少数(Phase 分割不要)の場合はルート issue + 子 issue の 2 階層構成にする。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2.5: milestone を決定する","type":"text"}]},{"type":"paragraph","content":[{"text":"このツリーに割り当てる GitHub Milestone を決定する。ルート・Phase 親・子・sub-issue の 全件に同一 milestone を適用する(ツリー単位で 1 milestone)。","type":"text"}]},{"type":"paragraph","content":[{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" が指定されている場合は、ここで先に ","type":"text"},{"text":"ROOT_NUMBER","type":"text","marks":[{"type":"code_inline"}]},{"text":" を設定し、milestone の 継承・書き込みを行う前に OPEN 状態を検証する(closed なルートの milestone を 書き換えてから中断する事故を防ぐ。Step 3 側では再代入・再検証しない)。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# --root で渡された Issue 番号を実際の値で代入する(実行時に Claude が置き換える)\n# 例: --root 123 が指定された場合 → ROOT_NUMBER=\"123\" / --root 未指定の場合 → ROOT_NUMBER=\"\"\nROOT_NUMBER=\"\u003c--root で渡された Issue 番号(未指定なら空文字)>\"\n\n# --root 指定時のみ: OPEN でなければ milestone 操作より前に中止する\n# (新規ツリー作成で ROOT_NUMBER が空の場合はこの検証をスキップする)\nif [[ -n \"${ROOT_NUMBER}\" ]]; then\n ROOT_STATE=$(gh issue view \"${ROOT_NUMBER}\" --json state --jq '.state')\n if [[ \"${ROOT_STATE}\" != \"OPEN\" ]]; then\n echo \"エラー: ルート issue #${ROOT_NUMBER} は OPEN ではありません (state: ${ROOT_STATE})。中止します。\"\n exit 1\n fi\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"milestone 非運用リポジトリのガード","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"--milestone","type":"text","marks":[{"type":"code_inline"}]},{"text":" が明示されていない場合、リポジトリに milestone が 1 件も存在しなければ(closed 含む)milestone 非運用リポジトリとみなし、 以降の確認をすべてスキップして ","type":"text"},{"text":"MILESTONE","type":"text","marks":[{"type":"code_inline"}]},{"text":" は空のまま Step 3 へ進む (milestone を使わないリポジトリの起票フローに確認を増やさない)。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"MILESTONE_COUNT=$(gh api \"repos/{owner}/{repo}/milestones?state=all\" --jq 'length')\n# MILESTONE_COUNT が 0 かつ --milestone 未指定なら、このステップの残りをスキップする","type":"text"}]},{"type":"paragraph","content":[{"text":"優先順位は ","type":"text"},{"text":"--milestone","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" > ","type":"text","marks":[{"type":"strong"}]},{"text":"--root","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" からの継承 > ユーザーへの確認","type":"text","marks":[{"type":"strong"}]},{"text":" の順。","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"--milestone","type":"text","marks":[{"type":"code_inline"}]},{"text":" が指定されている場合: その値をそのまま ","type":"text"},{"text":"MILESTONE","type":"text","marks":[{"type":"code_inline"}]},{"text":" として使用する (","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" も同時指定されている場合、ルート側の milestone より ","type":"text"},{"text":"--milestone","type":"text","marks":[{"type":"code_inline"}]},{"text":" を優先する。 ルート側と異なる値の場合は、ルートの milestone も合わせて更新してよいかユーザーに確認する。 更新しないと回答された場合はツリー内で milestone が混在する点を伝えたうえで続行する)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"--milestone","type":"text","marks":[{"type":"code_inline"}]},{"text":" 未指定かつ ","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指定時: 既存ルートの milestone を自動継承する (milestone が取得できた場合のみユーザーへの確認は不要)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"MILESTONE=$(gh issue view \"${ROOT_NUMBER}\" --json milestone --jq '.milestone.title // empty')","type":"text"}]},{"type":"paragraph","content":[{"text":"MILESTONE","type":"text","marks":[{"type":"code_inline"}]},{"text":" が空(既存ルートに milestone が未設定)の場合は自動継承とみなさず、 「どちらも未指定の場合」と同じユーザー確認フローへ進む(リポジトリに milestone が 存在するのにサイレントに milestone なしで進行しない。milestone が 1 件もない場合は 冒頭の非運用ガードが先に働くため、この確認には到達しない)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"どちらも未指定の場合、または ","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指定時に継承すべき milestone が空だった場合: milestone を割り当てるかユーザーに確認する。 割り当てる場合はオープン中の milestone 一覧を提示して選ばせるか、新規 milestone 名の 入力を受け付けて ","type":"text"},{"text":"MILESTONE","type":"text","marks":[{"type":"code_inline"}]},{"text":" に設定する。割り当てないと回答されたら ","type":"text"},{"text":"MILESTONE","type":"text","marks":[{"type":"code_inline"}]},{"text":" は 空のまま Step 3 以降へ進む(issue は milestone なしで作成される)。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gh api \"repos/{owner}/{repo}/milestones\" --jq '.[] | select(.state==\"open\") | .title'","type":"text"}]},{"type":"paragraph","content":[{"text":"ユーザーが一覧にない新規 milestone 名を入力した場合、","type":"text"},{"text":"gh issue create --milestone","type":"text","marks":[{"type":"code_inline"}]},{"text":" は 既存の milestone 名しか受け付けないため、使用前に milestone 自体を作成する。 同名の closed milestone が既に存在すると作成が 422(already_exists)で失敗するため、 その場合は reopen するか別名にするかをユーザーに確認する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"gh api --method POST \"repos/{owner}/{repo}/milestones\" -f \"title=${MILESTONE}\"","type":"text"}]},{"type":"paragraph","content":[{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指定時に継承すべき milestone が空でこのフローに合流した場合、決定した ","type":"text"},{"text":"MILESTONE","type":"text","marks":[{"type":"code_inline"}]},{"text":" を既存ルート issue にも反映する(子だけ milestone が付き、ルートが 未設定のまま残る不整合を防ぐ)。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"if [[ -n \"${ROOT_NUMBER}\" && -n \"${MILESTONE}\" ]]; then\n gh issue edit \"${ROOT_NUMBER}\" --milestone \"${MILESTONE}\"\nfi","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 3: ルート(トラッキング)issue を作成する","type":"text"}]},{"type":"paragraph","content":[{"text":"ルート issue はツリー全体の進捗を管理するトラッキング issue として作成する。","type":"text"},{"text":"ルート issue 自体には phase ラベルは付与しない","type":"text","marks":[{"type":"strong"}]},{"text":"(Phase 親以下の issue にのみ付与する)。","type":"text"}]},{"type":"paragraph","content":[{"text":"--root","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 指定時は新規作成をスキップする。","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"--phase","type":"text","marks":[{"type":"code_inline"}]},{"text":" での 2 回目以降の部分起票で ルート issue を重複作成しないため、Step 2.5 で設定・OPEN 検証済みの ","type":"text"},{"text":"ROOT_NUMBER","type":"text","marks":[{"type":"code_inline"}]},{"text":" を そのまま再利用する(ここで再代入・再検証しない)。","type":"text"}]},{"type":"paragraph","content":[{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 未指定の場合のみ、以下でルート issue を新規作成する。 表のプレースホルダー行は ","type":"text"},{"text":"scripts/merge-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":"(Step 6 の ","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 経路)が追記位置として読む。 文言を変えるときは同スクリプトの awk とテスト (n) を合わせて直す。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# MILESTONE が空でなければ --milestone を付与する(Step 2.5 で決定済み)\nROOT_ARGS=(--title \"chore: 全 open issue の Phase 別トラッキング ($(date +%Y-%m-%d))\")\nif [[ -n \"${MILESTONE}\" ]]; then\n ROOT_ARGS+=(--milestone \"${MILESTONE}\")\nfi\n\n# gh issue create は issue URL を stdout に出力する(--json 非対応)。URL 末尾から番号を抽出する\nROOT_URL=$(gh issue create \"${ROOT_ARGS[@]}\" \\\n --body \"$(printf '\u003c!-- granularity: %s -->\\n' \"${GRANULARITY}\"; cat \u003c\u003c'EOF'\n## 概要\n\n全 open issue を Phase 別に 1 ツリーへ整理する。各 Phase 親 issue を sub-issues として紐付け。\n\n## Phase 別実装計画\n\n| Phase | 親 issue | 直下 | 総 open 件数 |\n|-------|----------|------|-------------|\n| (作成後に更新) | | | |\n\n## 運用\n\n- 新規 issue は起票時に Phase 親へ紐付ける\n- 実行順は sub-issues リスト順が正\n- closed 親の下に open issue を残置しない\n- implement-issue-tree が post-order DFS で消化可能な構造を維持する\nEOF\n)\")\nROOT_NUMBER=$(printf '%s' \"${ROOT_URL}\" | grep -oE '[0-9]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

)\necho \"ルート issue: ${ROOT_NUMBER}\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 4: Phase 親 issue を作成して紐付ける","type":"text"}]},{"type":"paragraph","content":[{"text":"Phase ごとに親 issue を作成し、sub_issues API でルートへ紐付ける。 以下のスニペットは ","type":"text"},{"text":"PHASE","type":"text","marks":[{"type":"code_inline"}]},{"text":" 変数で Phase 番号を切り替える。","type":"text"},{"text":"--phase","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指定時はその番号を、 全 Phase 起票時は処理中の Phase 番号を設定する(タイトル・ラベルとも ","type":"text"},{"text":"PHASE","type":"text","marks":[{"type":"code_inline"}]},{"text":" に追従させる)。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 処理対象の Phase 番号(--phase 指定時はその番号、全 Phase 起票時はループ中の番号)\nPHASE=1\n\n# phase ラベルが存在しないリポジトリでは issue 作成が失敗するため、必ず事前作成する\n# (作成済みの場合は失敗を無視して続行する)\ngh label create \"phase:${PHASE}\" --color \"0075ca\" 2>/dev/null || true\n\n# 人手タスク用の owner ラベル。このツリーで実際に使うラベルだけを OWNER_LABELS に並べる\n# (該当タスクが無ければ空配列のままにし、何も作成しない)\nOWNER_LABELS=() # 例: (\"owner:human\" \"owner:joint\")\nfor OWNER in ${OWNER_LABELS[@]+\"${OWNER_LABELS[@]}\"}; do\n case \"${OWNER}\" in\n owner:human) COLOR=\"d93f0b\" ;;\n owner:joint) COLOR=\"fbca04\" ;;\n *) continue ;;\n esac\n gh label create \"${OWNER}\" --color \"${COLOR}\" 2>/dev/null || true\ndone\n\n# --root 再実行時の重複防止: ルート直下に同じ Phase の open な親が既にあれば再利用する\n# closed な親は再利用しない(closed 親の下に open issue を残置しない運用ルールと整合させる)。\n# phase ラベルだけでは同ラベルの一般 issue がルート直下に混在した場合に誤マッチするため、\n# Phase 親のタイトル規約(feat(phase-N): 接頭辞)でも絞り込む。\n# 直下が 100 件を超える場合に備えてページングで全件走査する\nPHASE_NUMBER=\"\"\nPAGE=1\nwhile true; do\n RESULT=$(gh api \\\n \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues?per_page=100&page=${PAGE}\")\n PHASE_NUMBER=$(echo \"${RESULT}\" | jq -r \\\n \"[.[] | select(.state == \\\"open\\\"\n and (.title | startswith(\\\"feat(phase-${PHASE}):\\\"))\n and any(.labels[]?; .name == \\\"phase:${PHASE}\\\"))][0].number // empty\")\n COUNT=$(echo \"${RESULT}\" | jq 'length')\n if [[ -n \"${PHASE_NUMBER}\" || \"${COUNT}\" -lt 100 ]]; then break; fi\n PAGE=$((PAGE + 1))\ndone\n[[ -n \"${PHASE_NUMBER}\" ]] && echo \"既存の Phase 親 issue を再利用: #${PHASE_NUMBER}\"\n\n# 再利用する Phase 親が milestone 未設定の場合はここで揃える(milestone 導入前に\n# 作られたツリーへの追記で、新規の子だけに milestone が付く不整合を防ぐ。冪等)\nif [[ -n \"${PHASE_NUMBER}\" && -n \"${MILESTONE}\" ]]; then\n gh issue edit \"${PHASE_NUMBER}\" --milestone \"${MILESTONE}\"\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"タイトル規約が ","type":"text"},{"text":"feat(phase-N):","type":"text","marks":[{"type":"code_inline"}]},{"text":" と異なるツリーでは上記の自動判定に頼らず、候補をユーザーに 提示して再利用すべき Phase 親を確認する。","type":"text"}]},{"type":"paragraph","content":[{"text":"PHASE_NUMBER","type":"text","marks":[{"type":"code_inline"}]},{"text":" が空(既存の Phase 親がない)場合のみ、以下で新規作成してルートへ紐付ける。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# MILESTONE が空でなければ --milestone を付与する(Step 2.5 で決定済み)\nPHASE_ARGS=(--title \"feat(phase-${PHASE}): Phase ${PHASE} 基盤整備\" --label \"phase:${PHASE}\")\nif [[ -n \"${MILESTONE}\" ]]; then\n PHASE_ARGS+=(--milestone \"${MILESTONE}\")\nfi\n\n# Phase 親 issue を作成(URL 末尾から番号を抽出)\nPHASE_URL=$(gh issue create \"${PHASE_ARGS[@]}\" \\\n --body \"$(cat \u003c\u003c'EOF'\n## 概要\n\nこの Phase の実装タスクをまとめる親 issue。\n\n## タスク一覧\n\n| Issue | タイトル | 分解 |\n|-------|---------|------|\n| (子 issue 作成後に更新) | | |\nEOF\n)\")\nPHASE_NUMBER=$(printf '%s' \"${PHASE_URL}\" | grep -oE '[0-9]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

)\n\n# ルートへ紐付け。sub_issue_id は issue 番号ではなく database id を渡す(GitHub sub-issues API 仕様)\nPHASE_ID=$(gh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${PHASE_ID}\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 5: 子 issue・sub-issue を作成して紐付ける","type":"text"}]},{"type":"paragraph","content":[{"text":"各タスクを issue として作成し、Phase 親へ紐付ける。粒度基準(既定 2h・","type":"text"},{"text":"--granularity","type":"text","marks":[{"type":"code_inline"}]},{"text":")超のタスクはさらに sub-issue に分解する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# MILESTONE が空でなければ --milestone を付与する(Step 2.5 で決定済み)\nCHILD_ARGS=(--title \"feat: タスク名\" --label \"phase:${PHASE}\")\nOWNER_LABEL=\"\" # 人手タスクなら \"owner:human\" または \"owner:joint\" に置き換える\nif [[ -n \"${MILESTONE}\" ]]; then\n CHILD_ARGS+=(--milestone \"${MILESTONE}\")\nfi\n# 人手タスクだけ owner ラベルを追加する(OWNER_LABEL は owner:human / owner:joint の固定値のみ。それ以外は空)\nif [[ -n \"${OWNER_LABEL}\" ]]; then\n CHILD_ARGS+=(--label \"${OWNER_LABEL}\")\nfi\n\n# 子 issue を作成(URL 末尾から番号を抽出)。PHASE は Step 4 で設定した番号を引き継ぐ\nCHILD_URL=$(gh issue create \"${CHILD_ARGS[@]}\" \\\n --body \"$(cat \u003c\u003c'EOF'\n## 概要\n\n...\n\n## 受け入れ条件\n\n- [ ] 条件1\n- [ ] 条件2\nEOF\n)\")\nCHILD_NUMBER=$(printf '%s' \"${CHILD_URL}\" | grep -oE '[0-9]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

)\n\n# Phase 親へ紐付け(sub_issue_id は database id)\nCHILD_ID=$(gh api \"repos/{owner}/{repo}/issues/${CHILD_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${CHILD_ID}\"\n\n# 粒度基準(既定 2h・--granularity)超の場合は sub-issue を作成して子 issue へ紐付け(同じく MILESTONE を付与)\nSUB_ARGS=(--title \"feat: サブタスク名\" --label \"phase:${PHASE}\")\nif [[ -n \"${MILESTONE}\" ]]; then\n SUB_ARGS+=(--milestone \"${MILESTONE}\")\nfi\nSUB_URL=$(gh issue create \"${SUB_ARGS[@]}\" --body \"...\")\nSUB_NUMBER=$(printf '%s' \"${SUB_URL}\" | grep -oE '[0-9]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

)\n\nSUB_ID=$(gh api \"repos/{owner}/{repo}/issues/${SUB_NUMBER}\" --jq '.id')\ngh api \\\n --method POST \\\n \"repos/{owner}/{repo}/issues/${CHILD_NUMBER}/sub_issues\" \\\n -F \"sub_issue_id=${SUB_ID}\"","type":"text"}]},{"type":"paragraph","content":[{"text":"人手タスクに依存する実装タスク","type":"text","marks":[{"type":"strong"}]},{"text":"は、本文に ","type":"text"},{"text":"## 前提","type":"text","marks":[{"type":"code_inline"}]},{"text":" 節を置き、行頭に ","type":"text"},{"text":"- #N","type":"text","marks":[{"type":"code_inline"}]},{"text":"(人手 issue の番号)を書く。 書式は ","type":"text"},{"text":"implement-issue-tree","type":"text","marks":[{"type":"code_inline"}]},{"text":" の依存宣言(見出し節の行頭の ","type":"text"},{"text":"#N","type":"text","marks":[{"type":"code_inline"}]},{"text":")に合わせる。「関連」「参考」を含む行は依存から除外される。 番号が要るため、人手 issue を実装 issue より先に起票する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"markdown"},"content":[{"text":"## 前提\n\n- #123","type":"text"}]},{"type":"paragraph","content":[{"text":"人手 issue を同じツリーに置くのは、","type":"text"},{"text":"implement-issue-tree","type":"text","marks":[{"type":"code_inline"}]},{"text":" を ","type":"text"},{"text":"skipLabels","type":"text","marks":[{"type":"code_inline"}]},{"text":" 付きで回す前提である。付けずに回すと人手 issue も自動実装の対象になる。","type":"text"}]},{"type":"paragraph","content":[{"text":"issue 数が多い場合(50 件超が目安)は ","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":"# ページネーション例(全 sub-issues を取得)\nPAGE=1\nwhile true; do\n RESULT=$(gh api \\\n \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues?per_page=100&page=${PAGE}\")\n COUNT=$(echo \"${RESULT}\" | jq 'length')\n echo \"${RESULT}\"\n if [ \"${COUNT}\" -lt 100 ]; then break; fi\n PAGE=$((PAGE + 1))\ndone","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 6: ルート issue 本文を Phase 別表で更新する","type":"text"}]},{"type":"paragraph","content":[{"text":"全 issue の作成が完了したら、ルート issue 本文の Phase 別表を実際の issue 番号・件数で更新する。","type":"text"}]},{"type":"paragraph","content":[{"text":"--root","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" での部分起票では本文を全置換しない。","type":"text","marks":[{"type":"strong"}]},{"text":" 既存ルートの本文には先行 Phase の表が 含まれるため、現在の本文を取得し、今回起票した Phase の行・セクションのみを追記・更新した 本文で ","type":"text"},{"text":"gh issue edit","type":"text","marks":[{"type":"code_inline"}]},{"text":" する。全置換は新規作成(","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 未指定)時のみ行い、その生成は ","type":"text"},{"text":"scripts/create-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" に、 ","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 時のマージは ","type":"text"},{"text":"scripts/merge-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" に任せる(本文生成ロジックを SKILL.md へ書かない)。 Step 3 の雛形のままのルート(Step 6 到達前に中断した起票の再実行)でも、表のプレースホルダー行 ","type":"text"},{"text":"| (作成後に更新) |","type":"text","marks":[{"type":"code_inline"}]},{"text":" を今回の Phase 行へ置き換えて完走する。","type":"text"}]},{"type":"paragraph","content":[{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指定時は ","type":"text"},{"text":"scripts/merge-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" が既存本文を取得し、今回の Phase 行・セクションを 実ツリーから生成してマージする(Issue #556)。各コードフェンスは独立したシェルで実行され得るため、 このフェンスは Step 3 / Step 4 と同形にスクリプトを 3 レイアウトから自己完結的に解決する。 ROOT_NUMBER(ルート issue 番号)・GRANULARITY(Step 1 で確定した粒度)・PHASE(Step 4 で確定した Phase 番号 N)・PHASE_NUMBER(その Phase 親 issue 番号)が未設定なら起動前に停止する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":": \"${ROOT_NUMBER:?ROOT_NUMBER 未設定}\" \"${GRANULARITY:?GRANULARITY 未設定}\" \\\n \"${PHASE:?PHASE 未設定}\" \"${PHASE_NUMBER:?PHASE_NUMBER 未設定}\"\n\nMERGE_ROOT_BODY=\"\"\nfor CANDIDATE in \\\n \"skills/create-issue-tree/scripts/merge-root-body.sh\" \\\n \".agents/skills/create-issue-tree/scripts/merge-root-body.sh\" \\\n \".claude/skills/create-issue-tree/scripts/merge-root-body.sh\"; do\n # 存在確認は -f のみ(vendoring で実行ビットが落ちても bash 経由で起動できる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n MERGE_ROOT_BODY=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${MERGE_ROOT_BODY}\" ]]; then\n echo \"エラー: merge-root-body.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n\n# echo を最終コマンドにしない。非ゼロ終了はそのままフェンスの終了ステータスにする\nstatus=0\nbash \"${MERGE_ROOT_BODY}\" --root \"${ROOT_NUMBER}\" --granularity \"${GRANULARITY}\" \\\n --phase \"${PHASE}\" --phase-number \"${PHASE_NUMBER}\" || 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=merged root=\u003cn> phase=\u003cN>","type":"text","marks":[{"type":"code_inline"}]},{"text":"。エラーメッセージは stderr)","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":"既存本文へ今回の Phase 分をマージし ","type":"text"},{"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":"1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"引数不正(","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"--phase-number","type":"text","marks":[{"type":"code_inline"}]},{"text":" は正整数、","type":"text"},{"text":"--phase","type":"text","marks":[{"type":"code_inline"}]},{"text":" は数字、","type":"text"},{"text":"--granularity","type":"text","marks":[{"type":"code_inline"}]},{"text":" は ","type":"text"},{"text":"^[1-9][0-9]*h$","type":"text","marks":[{"type":"code_inline"}]},{"text":")、既存本文・Phase 親・sub_issues・子孫の取得失敗、追記位置なし・Phase 行を出力不可、プレースホルダー残り・検査失敗、一時ファイル作成失敗","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":"前提不備(","type":"text"},{"text":"tree-lib.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を読み込めない、","type":"text"},{"text":"gh","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"jq","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":"その他","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":"paragraph","content":[{"text":"マージの不変条件: Phase 行・セクションは実ツリー(sub_issues API)からのみ作り、先行 Phase の表・自由記述は保持する / issue タイトルは非信頼データとして ","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 文字参照へ変換し改行を空白にする / プレースホルダーが残る本文では ","type":"text"},{"text":"gh issue edit","type":"text","marks":[{"type":"code_inline"}]},{"text":" しない(fail-closed)/ 一時ファイルは成否によらず削除する / granularity マーカーは常に GRANULARITY で書き直す / update-issue-tree の管理マーカー区間があれば内側だけを置換し、","type":"text"},{"text":"\u003cdetails>","type":"text","marks":[{"type":"code_inline"}]},{"text":" 内(退避節)は対象外 / マーカーが 1 組でない・除外後も同名見出しが複数ある・","type":"text"},{"text":"\u003cdetails>","type":"text","marks":[{"type":"code_inline"}]},{"text":" が閉じていない本文は中止する(行内・行末の ","type":"text"},{"text":"\u003c/details>","type":"text","marks":[{"type":"code_inline"}]},{"text":" も閉じとして数えるが、除外〔素通し〕にするのは ","type":"text"},{"text":"\u003cdetails>","type":"text","marks":[{"type":"code_inline"}]},{"text":" の内側で始まる行・開いたまま終わる行・行頭が素の ","type":"text"},{"text":"\u003cdetails>","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"\u003c/details>","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":"・バックスラッシュを含む行、または許可していない HTML タグ〔","type":"text"},{"text":"\u003cdetails>","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"\u003c/details>","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"\u003csummary>","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"\u003c/summary>","type":"text","marks":[{"type":"code_inline"}]},{"text":" の属性の無い素の形を取り除いたあとに ","type":"text"},{"text":"\u003c","type":"text","marks":[{"type":"code_inline"}]},{"text":" が残る行。属性付きの ","type":"text"},{"text":"\u003cdetails>","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"\u003cspan>","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"\u003cbr>","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等〕を含む行があれば、タグが文字か構造か判定できないため exit 1 で中止する。","type":"text"},{"text":">","type":"text","marks":[{"type":"code_inline"}]},{"text":" 単独は中止しない)。詳細は ","type":"text"},{"text":"scripts/merge-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" 冒頭コメントを参照。","type":"text"}]},{"type":"paragraph","content":[{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 未指定(新規作成)の場合は ","type":"text"},{"text":"scripts/create-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" が実ツリーから本文を生成して更新する (Issue #555)。ヘルパー(","type":"text"},{"text":"list_subs","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)は両経路とも ","type":"text"},{"text":"scripts/tree-lib.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を共用する。","type":"text"}]},{"type":"paragraph","content":[{"text":"同じ形式で、ROOT_NUMBER と GRANULARITY が未設定なら起動前に停止する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":": \"${ROOT_NUMBER:?ROOT_NUMBER 未設定}\" \"${GRANULARITY:?GRANULARITY 未設定}\"\n\nCREATE_ROOT_BODY=\"\"\nfor CANDIDATE in \\\n \"skills/create-issue-tree/scripts/create-root-body.sh\" \\\n \".agents/skills/create-issue-tree/scripts/create-root-body.sh\" \\\n \".claude/skills/create-issue-tree/scripts/create-root-body.sh\"; do\n # 存在確認は -f のみ(vendoring で実行ビットが落ちても bash 経由で起動できる)\n if [[ -f \"${CANDIDATE}\" ]]; then\n CREATE_ROOT_BODY=\"${CANDIDATE}\"\n break\n fi\ndone\nif [[ -z \"${CREATE_ROOT_BODY}\" ]]; then\n echo \"エラー: create-root-body.sh が見つからない(3 レイアウトいずれにも存在しない)\" >&2\n exit 1\nfi\n\n# echo を最終コマンドにしない。非ゼロ終了はそのままフェンスの終了ステータスにする\nstatus=0\nbash \"${CREATE_ROOT_BODY}\" --root \"${ROOT_NUMBER}\" --granularity \"${GRANULARITY}\" || 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=updated root=\u003cn> phases=\u003ck>","type":"text","marks":[{"type":"code_inline"}]},{"text":"。エラーメッセージは stderr)","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":"本文を生成し ","type":"text"},{"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":"1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"引数不正(","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" は正整数・","type":"text"},{"text":"--granularity","type":"text","marks":[{"type":"code_inline"}]},{"text":" は ","type":"text"},{"text":"^[1-9][0-9]*h$","type":"text","marks":[{"type":"code_inline"}]},{"text":")、sub_issues 取得失敗、Phase 親 0 件、Phase 番号決定不可、子孫取得失敗、プレースホルダー残り・検査失敗、Phase 行数不一致、一時ファイル作成失敗","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":"前提不備(","type":"text"},{"text":"tree-lib.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を読み込めない、","type":"text"},{"text":"gh","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"jq","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":"その他","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":"paragraph","content":[{"text":"生成の不変条件: 表は実ツリー(sub_issues API)からのみ作る / issue タイトルは非信頼データとして ","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 文字参照へ変換し改行を空白にする / プレースホルダーが残る本文では ","type":"text"},{"text":"gh issue edit","type":"text","marks":[{"type":"code_inline"}]},{"text":" しない (fail-closed)/ 一時ファイルは成否によらず削除する。詳細は ","type":"text"},{"text":"scripts/create-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" 冒頭コメントを参照。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 7: 作成結果を報告する","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"## create-issue-tree 完了レポート\n\n### ルート issue\n- #N: タイトル\n\n### Phase 別作成サマリー\n| Phase | 親 issue | 起票数 |\n|-------|----------|-------|\n| Phase 1 | #N | N 件 |\n\n### 作成した issue 一覧\n- #N: タイトル(Phase 1 / 親: #M)\n...\n\n### 次のアクション\n- 実装消化: implement-issue-tree スキルに ルート issue 番号を渡す。`owner:human` / `owner:joint` を使った場合は `skipLabels: [\"owner:human\",\"owner:joint\"]`(待ち一覧をルートへ通知するなら `notifyBlocked: true` も)を付ける\n- ツリー更新: update-issue-tree スキルでトラッキング issue を棚卸しする","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"検証","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ルート issue の sub-issues に各 Phase 親 issue が列挙されていることを確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"各 Phase 親 issue の sub-issues に子 issue が列挙されていることを確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"gh issue view \"${ROOT_NUMBER}\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" でルート issue 本文の Phase 別表が正しく生成されていることを確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"gh issue view \"${ROOT_NUMBER}\" --json body --jq .body","type":"text","marks":[{"type":"code_inline"}]},{"text":" に ","type":"text"},{"text":"\u003cphaseN_number>","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"#N","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"(作成後に更新)","type":"text","marks":[{"type":"code_inline"}]},{"text":"・素の ","type":"text"},{"text":"N","type":"text","marks":[{"type":"code_inline"}]},{"text":" セルが残っていないことを確認する(残っていれば Step 6 を再実行する)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"MILESTONE","type":"text","marks":[{"type":"code_inline"}]},{"text":" を割り当てた場合、","type":"text"},{"text":"gh issue view \u003cN> --json milestone --jq '.milestone.title'","type":"text","marks":[{"type":"code_inline"}]},{"text":" でルート・Phase 親・子いずれも ","type":"text"},{"text":"${MILESTONE}","type":"text","marks":[{"type":"code_inline"}]},{"text":" と一致することを確認する","type":"text"}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# sub-issues は 1 ページ最大 100 件。100 件超のツリーは Step 5 のページネーション\n# ループで全件確認する(以下は per_page=100 で先頭ページのみ。件数が 100 未満なら全件)\n\n# ルート直下の sub-issues を確認\ngh api \"repos/{owner}/{repo}/issues/${ROOT_NUMBER}/sub_issues?per_page=100\" --jq '.[].number'\n\n# Phase 親直下の sub-issues を確認\ngh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues?per_page=100\" --jq '.[].number'\n\n# phase ラベルの同期確認(Phase 親・子 issue にラベルが付いているか確認)\ngh api \"repos/{owner}/{repo}/issues/${PHASE_NUMBER}/sub_issues?per_page=100\" \\\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":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" を指定せず 2 回目の部分起票でルート issue が重複作成される","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2 回目以降は必ず ","type":"text"},{"text":"--root \u003c既存ルートissue番号>","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":"sub_issue_id","type":"text","marks":[{"type":"code_inline"}]},{"text":" に issue 番号をそのまま渡す","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"gh api .../issues/\u003cnumber> --jq '.id'","type":"text","marks":[{"type":"code_inline"}]},{"text":" で database id を取得してから POST する","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":"Step 4 冒頭の ","type":"text"},{"text":"gh label create \"phase:${PHASE}\"","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":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" 追記時に既存ルートの milestone が未設定なのに気づかず milestone なしで起票してしまう","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"リポジトリに milestone が存在する場合、Step 2.5 は継承結果が空ならユーザー確認フローへ自動的に合流する(確認で milestone を選ぶとルート issue にも反映される)。milestone が 1 件もない非運用リポジトリでは非運用ガードによる milestone なし起票が正常動作","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":"Phase 親を close する前に全子 issue の close を確認する","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Step 6 の本文を手書きのプレースホルダーのまま ","type":"text"},{"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"},{"text":"scripts/create-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":"(新規作成)・","type":"text"},{"text":"scripts/merge-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":"(","type":"text"},{"text":"--root","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":"Step 6 到達前に中断し、雛形のままのルートが残った","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--root \u003cルート番号>","type":"text","marks":[{"type":"code_inline"}]},{"text":" を付けて再実行する。プレースホルダー行は今回の Phase 行へ置き換わる","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"人手 issue(","type":"text"},{"text":"owner:*","type":"text","marks":[{"type":"code_inline"}]},{"text":")を置いたツリーを ","type":"text"},{"text":"skipLabels","type":"text","marks":[{"type":"code_inline"}]},{"text":" なしで ","type":"text"},{"text":"implement-issue-tree","type":"text","marks":[{"type":"code_inline"}]},{"text":" に渡し、人手 issue まで自動実装の対象になる","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skipLabels: [\"owner:human\",\"owner:joint\"]","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":"1 issue は粒度基準(既定 2h・","type":"text","marks":[{"type":"strong"}]},{"text":"--granularity","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" で変更可)程度に収める。","type":"text","marks":[{"type":"strong"}]},{"text":" 超えると判断した場合は sub-issue に分解する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"issue タイトルは Conventional Commits 形式を推奨(","type":"text"},{"text":"feat:","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"fix:","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"chore:","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"--phase","type":"text","marks":[{"type":"code_inline"}]},{"text":" 指定で部分起票した場合、別 Phase の追加起票では ","type":"text"},{"text":"必ず ","type":"text","marks":[{"type":"strong"}]},{"text":"--root \u003c既存ルートissue番号>","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" を渡す","type":"text","marks":[{"type":"strong"}]},{"text":"(Step 3 の新規作成をスキップして既存ツリーへ継ぎ足し、Step 6 も全置換せず既存本文へ差分追記する)。起票後は update-issue-tree に同じルート issue 番号を渡して棚卸しする","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":" でページングして全件取得する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Step 6 の本文生成は新規作成が ","type":"text"},{"text":"scripts/create-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" マージが ","type":"text"},{"text":"scripts/merge-root-body.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":"。本文生成ロジックを SKILL.md へ書き戻さない(肥大とヘルパー重複の再発防止)","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]+

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…

","type":"text","marks":[{"type":"code_inline"}]},{"text":" で末尾の番号を抽出して変数に保持する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"sub_issues API の ","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 を取得してから POST する。番号をそのまま渡すと誤った issue を紐付ける/404 になる","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"人手タスクは ","type":"text"},{"text":"owner:human","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"owner:joint","type":"text","marks":[{"type":"code_inline"}]},{"text":" ラベルで区別し、依存する実装 issue には ","type":"text"},{"text":"## 前提","type":"text","marks":[{"type":"code_inline"}]},{"text":" 節で ","type":"text"},{"text":"- #N","type":"text","marks":[{"type":"code_inline"}]},{"text":" を書く。","type":"text"},{"text":"implement-issue-tree","type":"text","marks":[{"type":"code_inline"}]},{"text":" は ","type":"text"},{"text":"skipLabels","type":"text","marks":[{"type":"code_inline"}]},{"text":" 付きで呼ぶ(Step 1・Step 5 参照)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"phase ラベルは Step 4 冒頭の ","type":"text"},{"text":"gh label create \"phase:${PHASE}\" --color \"0075ca\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" で issue 作成より前に必ず作成する(作成済みリポジトリでは no-op)","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"create-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/create-issue-tree/SKILL.md","repo_owner":"Fandhe-AI","body_sha256":"789413f2836a93fab63e1366a476471d60d228a182ada3cc43b8006a2763e01a","cluster_key":"13a6036977e896389abd24812acef96b0478356899f2802749cd56e696cde5ee","clean_bundle":{"format":"clean-skill-bundle-v1","source":"Fandhe-AI/agent-cli-skills/skills/create-issue-tree/SKILL.md","text_bytes":110759,"source_commit":"b1c76148d77c8f8897e0f7206af34b126739fd83","skills_sh_rank":9379,"attachment_count":4,"text_attachments":4,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/create-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":"Phase 分割された GitHub Issue ツリーを新規作成するスキル。「イシューツリー作って」「タスクを Phase 別に Issue 化」「Issue ツリーを起票して」で使用。 要件・タスク一覧を受け取り、タスク分解(既定 2h 粒度・--granularity で変更可)→ Phase 分割 → ルート(トラッキング)issue → Phase 親 issue → 子 issue の階層を sub_issues API で紐付け。 phase ラベル付与・ルート issue 本文の Phase 別表生成まで自動化。任意の phase 指定で部分起票にも対応。 ツリーの棚卸し・更新は update-issue-tree、実装消化は implement-issue-tree を参照。\n","argument-hint":"\u003c要件テキストまたはファイルパス> [--phase \u003cphase番号>] [--root \u003c既存ルートissue番号>] [--milestone \u003cmilestone名>] [--granularity \u003c時間>]","user-invocable":true}},"renderedAt":1793665984567}

create-issue-tree 要件・タスク一覧から Phase 分割された GitHub Issue ツリーを新規作成する。 ルート(トラッキング issue)→ Phase 親 issue → issue → sub-issue の 4 階層を構築し、implement-issue-tree が post-order DFS で消化できる構造を維持する。 ルート issue 本文の運用ルール「実行順は sub-issues リスト順が正」は既定では post-order の 優先度 に留まり、並列実行時に後続 Phase が前 Phase を追い越す余地がある。Phase 完了を厳密に順序保証したい場合は implement-issue-tree 実行時に を指定する(implement-issue-tree の SKILL.md 参照)。 使い方 引数としてタスク要件テキストまたはファイルパスを渡す。 オプションで特定 Phase のみ起票することもできる(大規模ツリーを段階的に起票する場合)。 2 回目以降の部分起票では で既存ルート issue 番号を渡し、同じツリーに継ぎ足す (指定しないと新しいルート issue が重複作成される)。 オプションで起票する issue 全件に割り当てる GitHub Milestone を指定できる ( 指定時は省略可。既存ルー…