From 03ec8318b8b35173ac357db6d7198b2b19812fcc Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 7 Oct 2026 16:38:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20herdr-issue=20skill?= =?UTF-8?q?=EF=BC=9A=E9=98=BB=E6=93=8B=E9=97=9C=E4=BF=82=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20Gitea=20=E5=8E=9F=E7=94=9F=20issue=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- skills/herdr-issue/SKILL.md | 16 +++++++---- skills/herdr-issue/scripts/lib.sh | 15 ++++++++++ skills/herdr-issue/scripts/ready.py | 43 +++++++++++++++++----------- skills/herdr-issue/scripts/ready.sh | 4 +-- skills/herdr-issue/scripts/start.sh | 44 ++++++++++++++++++++++++----- 5 files changed, 90 insertions(+), 32 deletions(-) diff --git a/skills/herdr-issue/SKILL.md b/skills/herdr-issue/SKILL.md index 44cff98..7c82c89 100644 --- a/skills/herdr-issue/SKILL.md +++ b/skills/herdr-issue/SKILL.md @@ -23,19 +23,23 @@ description: "Run Gitea issues through Herdr: one Claude agent per issue in its ## 流程 A:啟動一個議題 -1. `S/start.sh `。它會建 worktree `../-issue-N`(分支已存在就接續)、開 tab `#N`、啟動 `issue-N` agent、送提示詞(`scripts/prompt.txt`)、掛 `in-progress` 標籤。最後一行是 JSON。失敗訊息在 stderr,照實轉述。 +1. `S/start.sh `。它會建 worktree `../-issue-N`(分支已存在就接續)、開 tab `#N`、啟動 `issue-N` agent、送提示詞(`scripts/prompt.txt`)並等 agent 進入 `working` 或 `blocked`,確認有反應後才掛 `in-progress` 標籤。最後一行是 JSON,`state` 欄位是 agent 當時的狀態(`blocked` 表示一開始就卡在對話框,watch-issue.sh 會通知)。失敗(agent 啟動不了、送出後沒反應)時它已自己回滾:關 tab、移除 worktree、刪掉本次新建的分支,議題等同沒啟動過。把 stderr 的錯誤照實轉述,**不要重跑**,跳過第 2 步直接做「推薦下一步」;這個議題會重新出現在「可執行」清單,要重試由使用者再勾。 2. 在**背景**執行 `S/watch-issue.sh `(Bash 的 `run_in_background`)。它會阻塞到議題關閉,期間 agent blocked 或消失會彈 Herdr 通知;關閉後自己關 tab、清 worktree 與分支、拿掉標籤、往上檢查規格與總覽能不能關。結束時你會被叫醒。 3. 做一次「推薦下一步」。 +## 阻擋規則 + +議題之間的阻擋只認 Gitea 原生的 issue dependencies(議題頁右側的「依賴」),不看內文。有任何一個 open 的依賴(跨 repo 也算)就是被阻擋:`ready.sh` 不會列出它,`start.sh` 會拒絕啟動。依賴的議題一關閉就解鎖,不看 PR 有沒有合併。讀依賴失敗時 `ready.sh` 整個停下並印出錯誤,不猜。 + ## 流程 B:從 repo 挑議題 1. `S/ready.sh `。 -2. 用多選問題列出「可執行」的議題,每項標題帶規格標籤;同一規格的議題在描述裡註明「同規格,PR 可能互相衝突」。「掛 in-progress 但沒有 agent」的議題另列一題,說明重跑會接續先前分支。 -3. 勾選的每一個議題各走流程 A 的第 1、2 步,全部啟動後做一次「推薦下一步」。 +2. 「可執行」是空的就直接說「目前沒有可執行的議題」,不出多選題,但「執行中」和「掛 in-progress 但沒 agent」照常轉述。否則用多選問題列出「可執行」的議題,每項標題帶規格標籤;同一規格的議題在描述裡註明「同規格,PR 可能互相衝突」。「掛 in-progress 但沒有 agent」的議題另列一題,說明重跑會接續先前分支。 +3. 勾選的每一個議題各走流程 A 的第 1、2 步。其中一個 `start.sh` 失敗不影響其餘的,繼續啟動下一個;全部跑完後把失敗的議題與原因一併轉述,再做一次「推薦下一步」。 ## 推薦下一步 -`S/ready.sh ` 再跑一次(剛啟動的議題已經在執行中,會被排除),結果用多選問題呈現,格式同流程 B。沒勾就什麼都不做。 +`S/ready.sh ` 再跑一次(剛啟動的議題已經在執行中,會被排除),結果用多選問題呈現,格式同流程 B(可執行為空就直說,不出題)。沒勾就什麼都不做。 ## 被 watch-issue.sh 叫醒時 @@ -45,8 +49,8 @@ description: "Run Gitea issues through Herdr: one Claude agent per issue in its | 腳本 | 給誰 | 做什麼 | | --- | --- | --- | -| `ready.sh` | 你 | 列出可執行、執行中、被阻擋的議題 | -| `start.sh` | 你 | 建 worktree、開 tab、啟動 agent、送提示詞、掛標籤 | +| `ready.sh` | 你 | 列出可執行、執行中、掛 in-progress 但沒 agent 的議題(被阻擋的不列) | +| `start.sh` | 你 | 檢查沒被阻擋後建 worktree、開 tab、啟動 agent、送提示詞並確認 agent 有反應、掛標籤;失敗自動回滾 | | `watch-issue.sh` | 你(背景) | 等議題關閉,清理,往上關規格與總覽 | | `tick.sh` | 子 agent | 勾議題內文第 k 個核取方塊 | | `pr-wait.sh` | 子 agent(背景) | 等 PR 衝突、關閉或合併 | diff --git a/skills/herdr-issue/scripts/lib.sh b/skills/herdr-issue/scripts/lib.sh index 675a18e..70e0ca1 100755 --- a/skills/herdr-issue/scripts/lib.sh +++ b/skills/herdr-issue/scripts/lib.sh @@ -79,3 +79,18 @@ pr_for_issue() { | jq_py "m=[p for p in d if p.get('head')=='issue-$2'] print(' '.join([m[0]['index'],m[0]['state'],str(m[0].get('mergeable'))]) if m else '')" } + +# 擋住議題 N 的 open 依賴(Gitea 原生 issue dependencies),一行一個:同 repo 印 #M,跨 repo 印 owner/repo#M。 +# tea api 遇到 HTTP 錯誤仍 exit 0,所以用回應是否為 JSON 陣列判斷成敗;失敗就 die。 +open_blockers() { + tea api --repo "$1" "/repos/{owner}/{repo}/issues/$2/dependencies" | python3 -c "import sys,json +raw=sys.stdin.read() +try: d=json.loads(raw) +except Exception: d=None +if not isinstance(d,list): + sys.stderr.write('herdr-issue: 讀取 #$2 的依賴失敗:'+raw.strip()[:300]+'\n'); sys.exit(1) +for i in d: + if i.get('state')=='open': + r=(i.get('repository') or {}).get('full_name','') + print(('#' if r in ('','$1') else r+'#')+str(i['number']))" +} diff --git a/skills/herdr-issue/scripts/ready.py b/skills/herdr-issue/scripts/ready.py index 26fdd0b..1d17307 100755 --- a/skills/herdr-issue/scripts/ready.py +++ b/skills/herdr-issue/scripts/ready.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 -"""列出可執行的議題。輸入:open.json agents.json tabs.json""" -import json, re, sys +"""列出可執行的議題。輸入:open.json agents.json tabs.json owner/repo +阻擋關係只認 Gitea 原生 issue dependencies(tea api),不看議題內文。""" +import json, re, subprocess, sys open_issues = json.load(open(sys.argv[1])) agents = json.load(open(sys.argv[2])) tabs = json.load(open(sys.argv[3])) +repo = sys.argv[4] def find(o, k): if isinstance(o, dict): @@ -19,9 +21,6 @@ def find(o, k): def labels(i): return [l.strip() for l in (i.get("labels") or "").split() if l.strip()] if isinstance(i.get("labels"), str) else [l["name"] for l in i.get("labels") or []] -open_idx = {i["index"] for i in open_issues} -by_idx = {i["index"]: i for i in open_issues} - # Herdr 裡活著的 agent:名稱 issue-N,或掛在標籤 #N 的 tab 上 running = set() for a in find(agents, "agents") or []: @@ -32,14 +31,25 @@ for t in find(tabs, "tabs") or []: m = re.fullmatch(r"#(\d+)", str(t.get("label") or "")) if m and t.get("tab_id") in agent_tabs: running.add(m.group(1)) -def blockers(i): - m = re.search(r"Blocked by:\s*([^\n]*)", i.get("body") or "") - return re.findall(r"#(\d+)", m.group(1)) if m else [] +def open_blockers(n): + """擋住 #n 的 open 依賴。tea api 遇到 HTTP 錯誤仍 exit 0,所以回應不是陣列就當失敗,整個停下。""" + raw = subprocess.run(["tea", "api", "--repo", repo, f"/repos/{{owner}}/{{repo}}/issues/{n}/dependencies"], + capture_output=True, text=True).stdout + try: d = json.loads(raw) + except Exception: d = None + if not isinstance(d, list): + sys.exit(f"herdr-issue: 讀取 #{n} 的依賴失敗:{raw.strip()[:300]}") + out = [] + for i in d: + if i.get("state") != "open": continue + r = (i.get("repository") or {}).get("full_name", "") + out.append(("#" if r in ("", repo) else r + "#") + str(i["number"])) + return out def spec(i): return next((l for l in labels(i) if l.startswith("規格/#") or l.startswith("總覽/#")), "") -runnable, blocked, stale = [], [], [] +runnable, stale = [], [] for i in sorted(open_issues, key=lambda x: int(x["index"])): ls = labels(i) if "ready-for-agent" not in ls: continue @@ -48,17 +58,16 @@ for i in sorted(open_issues, key=lambda x: int(x["index"])): if n in running: continue if "in-progress" in ls: stale.append(n); continue - open_blockers = [b for b in blockers(i) if b in open_idx] - if open_blockers: - blocked.append((n, open_blockers)); continue + if open_blockers(n): continue runnable.append((n, spec(i), i["title"])) -print(f"可執行({len(runnable)}):編號\t規格\t標題") -for n, s, t in runnable: - print(f"#{n}\t{s}\t{t}") +if runnable: + print(f"可執行({len(runnable)}):編號\t規格\t標題") + for n, s, t in runnable: + print(f"#{n}\t{s}\t{t}") +else: + print("目前沒有可執行的議題。") print() print("Herdr 執行中:" + (" ".join(f"#{n}" for n in sorted(running, key=int)) or "無")) if stale: print("掛 in-progress 但沒有 agent(需處理,重跑 start 會接續):" + " ".join(f"#{n}" for n in stale)) -if blocked: - print("被阻擋:" + " ".join(f"#{n}←{','.join('#'+b for b in bs)}" for n, bs in blocked)) diff --git a/skills/herdr-issue/scripts/ready.sh b/skills/herdr-issue/scripts/ready.sh index a16ba2c..579d1d2 100755 --- a/skills/herdr-issue/scripts/ready.sh +++ b/skills/herdr-issue/scripts/ready.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # 用法:ready.sh -# 列出可執行的議題(open、ready-for-agent、blocker 全關、Herdr 沒在跑、沒掛 in-progress)。 +# 列出可執行的議題(open、ready-for-agent、Gitea 原生依賴全關、Herdr 沒在跑、沒掛 in-progress)。被阻擋的不列。 source "$(dirname "$0")/lib.sh" REPO="${1:?用法:ready.sh }" T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT tea issues list --repo "$REPO" --state open --limit 500 --output json --fields index,title,labels,body > "$T/open.json" herdr agent list > "$T/agents.json" herdr tab list --workspace "${HERDR_WORKSPACE_ID:?}" > "$T/tabs.json" -python3 "$SCRIPTS/ready.py" "$T/open.json" "$T/agents.json" "$T/tabs.json" +python3 "$SCRIPTS/ready.py" "$T/open.json" "$T/agents.json" "$T/tabs.json" "$REPO" diff --git a/skills/herdr-issue/scripts/start.sh b/skills/herdr-issue/scripts/start.sh index ff0cc95..df779c5 100755 --- a/skills/herdr-issue/scripts/start.sh +++ b/skills/herdr-issue/scripts/start.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # 用法:start.sh -# 在目前 clone 旁建 worktree、開 Herdr tab、啟動 claude、送提示詞、掛 in-progress 標籤。 -# 最後一行印出 JSON:{"tab":..,"pane":..,"path":..,"resumed":..} +# 在目前 clone 旁建 worktree、開 Herdr tab、啟動 claude、送提示詞並確認 agent 有反應、掛 in-progress 標籤。 +# 送出提示詞後 15 秒內沒觀察到 working 或 blocked 就視為失敗並回滾(關 tab、移除 worktree、刪本次新建的分支)。 +# 最後一行印出 JSON:{"tab":..,"pane":..,"path":..,"resumed":..,"state":"working"|"blocked"} source "$(dirname "$0")/lib.sh" REPO="${1:?}"; N="${2:?用法:start.sh }" LINK="https://gitea.jsc.idv.tw/$REPO/issues/$N" @@ -14,6 +15,8 @@ if [ "$(agent_status "$N")" != missing ]; then die "issue-$N 的 agent 還活著(tab $(tab_for_issue "$N")),不重複啟動" fi [ "$(issue_state "$REPO" "$N")" = open ] || die "#$N 不是 open" +BLOCKERS="$(open_blockers "$REPO" "$N")" || exit 1 +[ -z "$BLOCKERS" ] || die "#$N 被還沒關閉的議題擋住:$(echo "$BLOCKERS" | tr '\n' ' ')(Gitea 依賴設定)。等它們關閉,或到 Gitea 拿掉依賴再啟動" git -C "$CLONE" fetch --quiet origin RESUMED=false @@ -26,24 +29,51 @@ else git -C "$CLONE" worktree add --quiet -b "$BRANCH" "$WT" origin/main fi +TAB="" +# 回滾到跟沒跑過一樣:關 tab、移除 worktree;分支只在本次新建時刪除。 +rollback() { + [ -n "$TAB" ] && herdr tab close "$TAB" >/dev/null 2>&1 || true + if git -C "$CLONE" worktree list --porcelain | grep -qx "worktree $WT"; then + git -C "$CLONE" worktree remove --force "$WT" >/dev/null 2>&1 || true + fi + $RESUMED || git -C "$CLONE" branch -D "$BRANCH" >/dev/null 2>&1 || true +} + TAB_JSON="$(herdr tab create --workspace "${HERDR_WORKSPACE_ID:?}" --cwd "$WT" --label "#$N" --no-focus)" TAB="$(jq_py 'print(find(d,"tab")["tab_id"])' <<<"$TAB_JSON")" PANE="$(jq_py 'print(find(d,"root_pane")["pane_id"])' <<<"$TAB_JSON")" +START_ERR="$(mktemp)" for _ in 1 2 3 4 5 6; do - if herdr agent start "issue-$N" --kind claude --pane "$PANE" --timeout 90000 -- --model "$MODEL" >/dev/null 2>"$WT/.herdr-start.err"; then - rm -f "$WT/.herdr-start.err"; break + if herdr agent start "issue-$N" --kind claude --pane "$PANE" --timeout 90000 -- --model "$MODEL" >/dev/null 2>"$START_ERR"; then + rm -f "$START_ERR"; break fi sleep 2 done -[ -f "$WT/.herdr-start.err" ] && { cat "$WT/.herdr-start.err" >&2; herdr tab close "$TAB" >/dev/null; die "agent 啟動失敗"; } +if [ -f "$START_ERR" ]; then + cat "$START_ERR" >&2; rm -f "$START_ERR" + rollback + die "agent 啟動失敗,已回滾" +fi if $RESUMED; then RESUME_LINE="分支已有先前進度,先看 git log 和議題核取方塊再接續。"; else RESUME_LINE=""; fi PROMPT="$(sed -e "s|{LINK}|$LINK|g" -e "s|{REPO}|$REPO|g" -e "s|{N}|$N|g" -e "s|{S}|$SCRIPTS|g" -e "s|{RESUME}|$RESUME_LINE|" "$SCRIPTS/prompt.txt")" -herdr agent prompt "issue-$N" "$PROMPT" >/dev/null + +# 送出後必須觀察到 agent 進入 working 或 blocked;否則 Herdr 回非 0(agent_prompt_stalled、timeout、agent_blocked 等)。 +PROMPT_ERR="$(mktemp)" +if PROMPT_OUT="$(herdr agent prompt "issue-$N" "$PROMPT" --wait --until working --until blocked --timeout 15000 2>"$PROMPT_ERR")"; then + rm -f "$PROMPT_ERR" +else + echo "herdr-issue: 送出提示詞後 agent 沒有反應,已回滾" >&2 + cat "$PROMPT_ERR" >&2; rm -f "$PROMPT_ERR" + rollback + exit 1 +fi +STATE="$(jq_py 'print(find(d,"agent_status") or "")' <<<"$PROMPT_OUT" 2>/dev/null || true)" +case "$STATE" in working|blocked) ;; *) STATE="$(agent_status "$N")" ;; esac ensure_label "$REPO" tea issues edit "$N" --repo "$REPO" --remove-labels "$LABEL" >/dev/null 2>&1 || true tea issues edit "$N" --repo "$REPO" --add-labels "$LABEL" >/dev/null -printf '{"tab":"%s","pane":"%s","path":"%s","resumed":%s}\n' "$TAB" "$PANE" "$WT" "$RESUMED" +printf '{"tab":"%s","pane":"%s","path":"%s","resumed":%s,"state":"%s"}\n' "$TAB" "$PANE" "$WT" "$RESUMED" "$STATE" -- 2.53.0 From 6edfb12c950f3befa1178f6753137d7741aa22a9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 7 Oct 2026 16:39:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=99=9F=E6=94=B9?= =?UTF-8?q?=E7=82=BA=200.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2b8a00d..fd233fe 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "herdr-issue", - "version": "0.1.0", + "version": "0.0.1", "description": "透過 Herdr 讓 Claude agent 逐一實作 Gitea 議題(每個議題一個 worktree 與 tab)。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 8d9db05..c965b0a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "herdr-issue", - "version": "0.1.0", + "version": "0.0.1", "description": "透過 Herdr 讓 Claude agent 逐一實作 Gitea 議題(每個議題一個 worktree 與 tab)。", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index 3aab1df..c89949c 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "herdr-issue", - "version": "0.1.0", + "version": "0.0.1", "description": "透過 Herdr 讓 Claude agent 逐一實作 Gitea 議題(每個議題一個 worktree 與 tab)。", "skills": "./skills/" } \ No newline at end of file -- 2.53.0