👋 はじめに
私は昔からIssue駆動開発推しです
以前はIssueをひとつずつ手でエージェントに割り当てていたのですが、ループエンジニアリングを導入してからはエージェントが自分でIssueを探しにいって対応するようになりました。
私のissue駆動推しの話(参考記事)
ループエンジニアリングの記事はよく見るのですが、次のタスクをどのように取得しにいくか?という部分について触れている記事があまりなかったので今回はループの中で次のissueをどのように取得しているか、という部分の私の実装例を紹介したいと思います。
📝 TL;DR
- Issue同士の依存関係(
blocked-by)とラベルをghコマンドで取得し、機械的なフィルタで「今すぐ着手できるIssue」だけをエージェントに渡す - 複数候補が残ったときの優先順位づけと、ブランチ作成による排他ロックまでを機械的に決めておく
「次着手すべきissueを対応して」は悪手
最近のAIエージェントは賢いので、ついつい「いい感じによろしく!」と言いたくなりますよね?少ないissueならいい感じに読み解いてくれますが、規模が大きくなるにつれ「いい感じ」は通用しなくなってきます
🔗 issueには依存関係がある
当然の事ですがissueには依存関係があります。issueAが終わってないとissueBが開始できない。このような依存関係が絡み合っています。そのため番号の若いものから順番に食べていくと、依存先がまだ終わっていないIssueに突撃して立ち往生するエージェントが量産されます。
githubでは2025/1にsub-issues(親子関係)が、2025/8に issue dependencies(依存関係)を管理できるようになりました。またghコマンドでこれらの情報を取得できるためエージェントがissueの相関関係を把握することができるようになりました。これを使って次に対応すべきissueを挙げているというわけです。
🗺️ 全体像(山ちゃんLOOP)
実際に運用しているLOOPの全体像は次のとおり。内側のループでissueの完了基準を満たすまで対応し、シェル側(外側ループ)で次に着手するissueを選定しています。今回掘り下げるのは、外側ループが呼び出す pick-issue の中身、つまり図の「次の一件を選ぶ」部分になります。
[前提]
この仕組みが成立するための前提としてIssue 同士に依存関係(blocked-by)が張られていることが必須になります。GitHubのIssue Dependencies機能を使い「このIssueは#5と#6の完了を待つ」という関係を機械的に読み取れる形にしておく必要があります。
🛠️ loop.sh と skill のサンプル
loop.sh
#!/usr/bin/env bash
# Issue 駆動ループ。INTERVAL 秒ごとに GitHub を見て、状態が変わっていたらエージェント(claude -p)を1体起動する。
# 判断も後片づけもしない。それはエージェント側(CLAUDE.md と .claude/skills/)の仕事。
#
# 使い方: scripts/loop.sh 2>&1 | tee -a logs/loop.log
# DRY_RUN=1 scripts/loop.sh (claude -p を呼ばない)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# REPO / WORKTREES を変えるなら CLAUDE.md の実値も合わせる
REPO="${REPO:-xxxxxxxxxxx}"
INTERVAL="${INTERVAL:-300}"
MAX_RUNTIME="${MAX_RUNTIME:-7200}"
WORKTREES="${WORKTREES:-$ROOT/.worktrees}"
DRY_RUN="${DRY_RUN:-0}"
LAST_STATE=""
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
warn() { printf '[%s] WARN %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
# open Issue のラベル・依存と open PR のラベルをハッシュ1つにする(前の周との比較用)
snapshot() {
{
gh issue list --repo "$REPO" --state open --limit 100 --json number,labels,blockedBy \
--jq '.[] | "\(.number) \([.labels[].name] | sort | join(",")) \([.blockedBy.nodes[]?.number] | sort | join(","))"' 2>/dev/null || true
gh pr list --repo "$REPO" --state open --limit 100 --json number,labels \
--jq '.[] | "\(.number) \([.labels[].name] | sort | join(","))"' 2>/dev/null || true
} | cksum | cut -d' ' -f1
}
# 状態が変わった周だけ1体起動する。
spawn_agents() {
local state
state="$(snapshot)"
if [ "$state" = "$LAST_STATE" ]; then
log "状態に変化なし。起動しない"
return 0
fi
LAST_STATE="$state"
if [ "$DRY_RUN" = "1" ]; then
log "[DRY_RUN] 状態が変わったので1体起動するはずだった"
return 0
fi
log "エージェントを1体起動する"
# プロンプトは /pick-issue だけ。stdin 渡しなのは --add-dir が後ろの引数を飲むため
(cd "$ROOT" && printf '%s' '/pick-issue' | claude -p --add-dir "$WORKTREES") &
}
mkdir -p "$WORKTREES"
log "REPO=$REPO DRY_RUN=$DRY_RUN"
START_TS=$(date +%s)
while :; do
elapsed=$(($(date +%s) - START_TS))
if [ "$elapsed" -ge "$MAX_RUNTIME" ]; then
log "サーキットブレーカー作動: 起動から $((elapsed / 60)) 分経過。新規の起動を止めて終了する。"
exit 0
fi
git -C "$ROOT" fetch origin --quiet 2>/dev/null || true # pick-issue が origin/main から worktree を切る
spawn_agents
sleep "$INTERVAL"
done
pick-issue/SKILL.md
---
name: pick-issue
description: 着手する Issue を1件選び、worktree を作って in-progress にするまでの固定手順。ループから起動された直後、まだ Issue を持っていない状態で必ず最初に使う。修正依頼(rework 付きの PR)が無いかの確認、候補の取得コマンド、他エージェントとのスコープ重なり確認、ブランチによる排他ロック、ラベル付け替えの順序を定める。
---
# pick-issue
まだ Issue を持っていない状態から、1件だけ選んで着手するまで。
この段階ではまだ自分の Issue が無いので `stop-and-ask` は使えない。当てはまらない状況に出会ったら、**何も変更せずに終了する。**
## 手順0: 修正依頼の PR が無いか見る
人間からの修正依頼は新規着手より優先する(指摘を書いた人間が返事を待っている)。
```
gh pr list --repo <owner>/<repo> --state open --label rework --limit 100 --json number --jq '.[].number'
```
| 状況 | どうするか |
| ----------- | ---------------------------------------------------------------------- |
| 1件でも出た | Issue は選ばない。`rework-pr` スキルへ移る(そこで0件なら手順1へ戻る) |
| 何も出ない | 手順1へ |
## 手順1: 候補を取る
```
gh issue list --repo <owner>/<repo> --state open --label ready --limit 100 \
--json number,title,labels,blockedBy,body \
--jq '.[]
| select([.labels[].name] | index("needs-human") | not)
| select([.labels[].name] | index("in-progress") | not)
| select([.blockedBy.nodes[]? | select(.state == "OPEN")] | length == 0)
| {number, title, labels: [.labels[].name], body}'
```
`needs-human`(人間の回答待ち)、`in-progress`(他のエージェントが走行中)、依存先が OPEN(`.blockedBy.nodes[]` の下。`.blockedBy[]` ではない)の3つを除外している。
出力が空なら手順5へ。
## 手順2: いま走っているものを見る
```
gh issue list --repo <owner>/<repo> --state open --label in-progress --limit 100 \
--json number,title,body
```
各 Issue 本文の `## 触ってよい範囲` だけを読む。ここに書かれたパスが、いま他のエージェントが握っている範囲。
## 手順3: 1件選ぶ
| 状況 | どうするか |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| 候補の「触ってよい範囲」が、手順2のどれかと1パスでも重なる | その候補を捨てる |
| 候補の本文に `## 触ってよい範囲` が無い、またはパスが具体的でない | その候補を捨てる。重なり判定ができない Issue は取らない |
| 残りが1件 | それを選ぶ |
| 残りが複数 | ① 他の Issue から `blocked-by` で待たれている数が多いもの → ② `risk:low` → ③ 番号が小さいもの、の順で1件に絞る |
| 残りが0件 | 手順5へ |
**候補を増やす操作は一切しない。** `ready` を付け足さない。`needs-human` を外さない。`--remove-blocked-by` しない。他のエージェントの `in-progress` を剥がさない。
## 手順4: worktree で場所を取り、それからラベルを付け替える
選んだ番号を `<n>` とする。**この順序を逆にしない。**
```
git worktree add -b feat/<n> <ワークツリー置き場>/issue-<n> origin/main
```
ブランチ `feat/<n>` の作成は同じ番号に対して1体しか成功しない。これが排他ロック。
ディレクトリ名は必ず `issue-<n>`(他のスキルの片づけと README の復旧手順がこの名前を前提にしている)。
- **失敗したら**、他が先に取っている。候補から外して手順3に戻る。**ラベルは触らない**(先に付け替えると、ロックを取れていないのに `ready` が剥がれた Issue が宙に浮く)
- 選び直しは最大3回まで。3回失敗したら手順5へ
成功したら、**そのあとで**ラベルを付け替え、作業ツリーへ移動して依存を入れ、Issue を読む。
```
gh issue edit <n> --repo <owner>/<repo> --remove-label ready --add-label in-progress
cd <ワークツリー置き場>/issue-<n>
npm ci
gh issue view <n> --repo <owner>/<repo> --json title,body,comments --jq '{title, body, comments: [.comments[].body]}'
```
- 作業ツリーには `node_modules` が無い。`npm ci` を飛ばすと `npm run check` が落ちる(`npm install` は lock を書き換えるので使わない)
- `--comments` フラグは端末が無いと空出力になるので使わない。必ず `--json` で取る
ラベルを付け替えた時点で、この1行を出す。理由は「何を見て、なぜそれを選び、なぜ他を選ばなかったか」を1行で。
```
[PICK] #7 タスク追加フォームの実装 / 理由: #5 #6 に待たれているため最優先
```
続けて次を1回だけ打つ(`[PICK] none` のときは打たない)。人間向けの読み上げで、`echo` 1コマンドの形でないと拾われないので、`&&` や `;` で連結せず、パイプにも通さない。
```
echo "<voice>#<n> <Issueタイトルの要約> に着手するよ</voice>"
```
**`[PICK]` は「選んだ」という記録であって、作業の終わりではない。** 返事をする相手はいないので、途中報告をして終了しない。
本文とコメントを読み、設計書を読み、実装し、`open-pr` で PR 作成とマージ判断まで、この同じセッションで一気に進める。
## 手順5: 候補が無いとき
**何もせず終了する。** 正常な状態で、「人間が次の一手を決める番だ」という意味。`ready` を付けたり `needs-human` や `blocked-by` を外したり、催促コメントをしたりして候補を作らない。
```
[PICK] none / 着手できる Issue は無い
```
💡 ポイントの解説
1.依存関係の解決は決定的なフィルタに寄せる
issueの依存関係はAIに判断を全て委ねずskillsのコマンドで gh issue list は --jq フィルタで機械的に除外してからAIに渡します。ここをAIの判断に任せると事故が起きる可能性がある為なるべく判断をコードに寄せてます。
2.優先条件判定
対応できるissueの中からは人間PRコメント対応を最優先としてその次に待たれている数が多いものを優先にしています。後続を一番多くブロックしているissueを先に片づける方がループ全体のスループットが上がるからです。とはいえ順次並行可動でエージェントが捌いていくのでそこまで差はないかもしれません。
3 .その他プチ工夫
・毎回エージェントが起動しないようにissueの、ハッシュを比べて動きがあったら場合のみエージェントを起動する仕組みにしています。
・エージェントが並列で作業できるようにエージェント単位のワークスペースを作成しています(マージのタイミングで合わせて消す)
🎬 締め
Issueの書き方を工夫するのと同じくらい「次にどれをやるか」は開発の速度と安全性を左右すると思います。今回のポイントはAIに何を判断させて、何を判断させないか、その線引きがこの仕組みの一番のポイントだと思っています。
この山ちゃんLoopは依存関係しっかり把握して適切なissueを選択できるのですが仕様関連の修正が入ると直ぐ人間Reviewを回してきて度々止まりますwでもこれがあるべき姿だと思います。Loopは長く動けば良いものではなく、適切に泊まれるかが重要なので。
今回はissueの捕まえ方にフォーカスして紹介しましたがissue駆動開発推しネタ
はまだまだあるのでまた紹介できればと思います![]()
