diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d36a764..d264c11 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -14,6 +14,11 @@ "name": "companion", "source": "./plugins/companion", "description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色" + }, + { + "name": "herdr-issue", + "source": "./plugins/herdr-issue", + "description": "透過 Herdr 讓 Claude agent 逐一實作 Gitea 議題(每個議題一個 worktree 與 tab)" } ] } diff --git a/README.md b/README.md index dd428de..d10360d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ JSC 自用 Claude Code mods。每個 mod 皆可單獨安裝、更新與移除。 | --- | --- | | [statusbar](plugins/statusbar) | 輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟 | | [companion](plugins/companion) | 小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色 | +| [herdr-issue](plugins/herdr-issue) | 透過 Herdr 讓 Claude agent 逐一實作 Gitea 議題,每個議題一個 worktree 與 tab(skill,需 Herdr 與 tea) | ## 安裝 @@ -15,12 +16,14 @@ JSC 自用 Claude Code mods。每個 mod 皆可單獨安裝、更新與移除。 claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace.git claude plugin install statusbar@jsc-marketplace claude plugin install companion@jsc-marketplace +claude plugin install herdr-issue@jsc-marketplace ``` ## 指令 - `/statusbar on | off | compact`:切換狀態列 - `/companion status | switch <角色> | off | setup | todo | quiet`:角色陪伴控制(`todo add <內容> [due=YYYY-MM-DDTHH:MM]`、`todo done <編號>`、`quiet [分鐘]`) +- `/herdr-issue:herdr-issue <議題連結>`:交給 Herdr 裡的 Claude agent 實作 Gitea 議題 ## 更新 @@ -28,6 +31,7 @@ claude plugin install companion@jsc-marketplace claude plugin marketplace update jsc-marketplace claude plugin update statusbar@jsc-marketplace claude plugin update companion@jsc-marketplace +claude plugin update herdr-issue@jsc-marketplace ``` 更新後需重新啟動 Claude Code 才會生效。 @@ -37,6 +41,7 @@ claude plugin update companion@jsc-marketplace ```bash claude plugin uninstall statusbar@jsc-marketplace claude plugin uninstall companion@jsc-marketplace +claude plugin uninstall herdr-issue@jsc-marketplace claude plugin marketplace remove jsc-marketplace ``` diff --git a/plugins/herdr-issue/.claude-plugin/plugin.json b/plugins/herdr-issue/.claude-plugin/plugin.json new file mode 100644 index 0000000..d464cb8 --- /dev/null +++ b/plugins/herdr-issue/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "herdr-issue", + "version": "0.0.1", + "description": "透過 Herdr 讓 Claude agent 逐一實作 Gitea 議題(每個議題一個 worktree 與 tab)", + "license": "MIT", + "skills": "./skills", + "author": { + "name": "JSC" + } +} diff --git a/plugins/herdr-issue/skills/herdr-issue/SKILL.md b/plugins/herdr-issue/skills/herdr-issue/SKILL.md new file mode 100644 index 0000000..b7308e3 --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/SKILL.md @@ -0,0 +1,64 @@ +--- +name: herdr-issue +description: "Run Gitea issues through Herdr: one Claude agent per issue in its own worktree and tab, watch the issue to closure, then offer the next unblocked issues. Use only when the user runs /herdr-issue or explicitly asks to drive a Gitea issue or repo link with Herdr." +--- + +# herdr-issue + +把 Gitea 議題交給 Herdr 裡的 Claude agent 實作。所有確定性的工作都在 `scripts/` 裡,你只在「要做決定」的時刻出手。腳本都要在該 repo 的 clone 裡執行;`S` 代表 `~/.claude/skills/herdr-issue/scripts`。 + +## 使用者身分 + +Gitea 一律以 tea 的預設 login(`tea login list` 中 default 為 true 的那個)操作:`lib.sh` 會取出它並讓所有 tea 呼叫加上 `--login`,避免在 clone 裡 tea 依 remote URL 挑到別的帳號。`start.sh` 也會把該使用者的名稱與 email 設成 worktree 的 git commit 身分。git 走 HTTPS 的帳密由 `scripts/git-credential.sh`(git credential helper)提供,用同一個 login 的 token:腳本內的 git 自動套用,`start.sh` 也會把它寫進 clone 的本地設定(只對該 Gitea 主機),所以 agent 在 worktree 裡 push/fetch 不會再出現 `could not read Username`。要換人先 `tea login default `,或設 `HERDR_ISSUE_LOGIN`。 + +## 語言規則 + +本技能的所有回覆、提問、通知與輸出訊息一律使用繁體中文(程式碼、指令、識別字除外)。 + +## 前置檢查 + +1. `test "${HERDR_ENV:-}" = 1`,不成立就說明不在 Herdr 裡並停止。 +2. 解析輸入: + - 議題連結 `https://gitea.jsc.idv.tw///issues/` → 流程 A。 + - `#N` → 用目前 clone 的 origin 推出 owner/repo,流程 A。 + - repo 連結 `https://gitea.jsc.idv.tw//` → 流程 B。 +3. 目前目錄必須是該 repo 的 clone(腳本會比對 origin)。不是就告訴使用者要在哪個目錄執行並停止。 +4. 提醒一句:這個 session 要保持開著,監看與下一步推薦都靠它。 + +## 流程 A:啟動一個議題 + +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. 「可執行」是空的就直接說「目前沒有可執行的議題」,不出多選題,但「執行中」和「掛 in-progress 但沒 agent」照常轉述。否則用多選問題列出「可執行」的議題,每項標題帶規格標籤;同一規格的議題在描述裡註明「同規格,PR 可能互相衝突」。「掛 in-progress 但沒有 agent」的議題另列一題,說明重跑會接續先前分支。 +3. 勾選的每一個議題各走流程 A 的第 1、2 步。其中一個 `start.sh` 失敗不影響其餘的,繼續啟動下一個;全部跑完後把失敗的議題與原因一併轉述,再做一次「推薦下一步」。 + +## 推薦下一步 + +`S/ready.sh ` 再跑一次(剛啟動的議題已經在執行中,會被排除),結果用多選問題呈現,格式同流程 B(可執行為空就直說,不出題)。沒勾就什麼都不做。 + +## 被 watch-issue.sh 叫醒時 + +把它印出的清理摘要(PR 狀態、分支去留、規格與總覽是否關閉)用兩三句轉述,然後「推薦下一步」。 + +## 腳本一覽 + +| 腳本 | 給誰 | 做什麼 | +| --- | --- | --- | +| `ready.sh` | 你 | 列出可執行、執行中、掛 in-progress 但沒 agent 的議題(被阻擋的不列) | +| `start.sh` | 你 | 檢查沒被阻擋後建 worktree、開 tab、啟動 agent、送提示詞並確認 agent 有反應、掛標籤;失敗自動回滾 | +| `watch-issue.sh` | 你(背景) | 等議題關閉,清理,往上關規格與總覽 | +| `tick.sh` | 子 agent | 勾議題內文第 k 個核取方塊 | +| `pr-wait.sh` | 子 agent(背景) | 等 PR 衝突、關閉或合併 | + +TLS/憑證錯誤:`lib.sh` 的 tea 包裝會自動重試(預設 5 次、間隔 3 秒),輪詢中的 `watch-issue.sh`、`pr-wait.sh` 因此不會被一次性的連線問題打斷;另外**一律忽略 TLS 憑證檢查**:`lib.sh` 會把 tea 設定檔(`~/.config/tea/config.yml`)所有 login 的 `insecure` 設為 `true`,git 則用 `GIT_SSL_NO_VERIFY` 與 worktree 的 `http.sslVerify=false`。 + +環境變數:`HERDR_ISSUE_TLS_RETRIES`(TLS 重試次數,預設 5)、`HERDR_ISSUE_INTERVAL`(pr-wait 輪詢秒數,預設 300)、`HERDR_ISSUE_WATCH_INTERVAL`(watch-issue 輪詢秒數,預設 5)、`HERDR_ISSUE_BLOCKED_AFTER`(blocked 多久才通知,預設 60 秒)、`HERDR_ISSUE_MODEL`(預設 `claude-sonnet-5-5`)。 diff --git a/plugins/herdr-issue/skills/herdr-issue/scripts/git-credential.sh b/plugins/herdr-issue/skills/herdr-issue/scripts/git-credential.sh new file mode 100755 index 0000000..8f563f3 --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/scripts/git-credential.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# git credential helper:用 tea 預設 login 的帳號與 token 回應 HTTPS 認證,避免 git 要互動輸入帳密 +# ("could not read Username ... No such device or address")。只處理 get,且只回應該 login 的主機。 +[ "${1:-}" = get ] || exit 0 +exec python3 -c ' +import os, sys +req = dict(l.split("=", 1) for l in sys.stdin.read().splitlines() if "=" in l) +cfg = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "tea", "config.yml") +logins, cur = [], None +for line in open(cfg): + s = line.strip() + if s.startswith("- name:"): + cur = {"name": s.split(":", 1)[1].strip()}; logins.append(cur) + elif cur is not None and ":" in s: + k, v = s.split(":", 1) + if k in ("url", "token", "user", "default"): cur[k] = v.strip().strip("\"\x27") +want = os.environ.get("HERDR_ISSUE_LOGIN") +pick = next((l for l in logins if l["name"] == want), None) if want else None +pick = pick or next((l for l in logins if l.get("default") == "true"), None) +if not pick or not pick.get("token"): sys.exit(0) +host = pick["url"].split("://", 1)[-1].rstrip("/") +if req.get("host") != host: sys.exit(0) +print("username=" + pick["user"]); print("password=" + pick["token"]) +' diff --git a/plugins/herdr-issue/skills/herdr-issue/scripts/lib.sh b/plugins/herdr-issue/skills/herdr-issue/scripts/lib.sh new file mode 100755 index 0000000..06e103d --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/scripts/lib.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# herdr-issue 共用函式。每個腳本都 source 這個檔。 +set -euo pipefail + +LABEL="in-progress" +LABEL_COLOR="0e8a16" +LABEL_DESC="Herdr 的 agent 正在實作" +INTERVAL="${HERDR_ISSUE_INTERVAL:-300}" +MODEL="${HERDR_ISSUE_MODEL:-claude-sonnet-5-5}" +SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +die() { echo "herdr-issue: $*" >&2; exit 1; } + +# tea 的預設使用者。在 clone 裡 tea 會依 remote URL 挑第一個符合的 login(可能不是預設的), +# 所以明確取 default 那個 login,所有 tea 呼叫都加 --login。可用 HERDR_ISSUE_LOGIN 覆寫。 +if [ -z "${HERDR_ISSUE_LOGIN:-}" ]; then + HERDR_ISSUE_LOGIN="$(tea login list --output json | python3 -c "import sys,json +print(next((l['name'] for l in json.load(sys.stdin) if str(l.get('default')).lower()=='true'),''))")" +fi +[ -n "$HERDR_ISSUE_LOGIN" ] || { echo "herdr-issue: tea 沒有預設 login,請先 tea login default " >&2; exit 1; } +export HERDR_ISSUE_LOGIN + +# 忽略所有 TLS 檢查:tea 的 insecure 只能寫在設定檔(沒有環境變數),所以把所有 login 設成 insecure: true(冪等); +# git 用 GIT_SSL_NO_VERIFY。agent 的 tea/git 也會吃到同一份設定。 +TEA_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml" +if [ -f "$TEA_CONFIG" ] && grep -qE '^\s*insecure:\s*false' "$TEA_CONFIG"; then + sed -i -E 's/^(\s*insecure:\s*)false/\1true/' "$TEA_CONFIG" +fi +export GIT_SSL_NO_VERIFY=true + +# git 走 HTTPS 時沒有帳密又不能互動輸入會失敗,所以腳本內的 git 一律用 tea 預設 login 的 token(git-credential.sh)。 +export GIT_TERMINAL_PROMPT=0 +git() { command git -c http.sslVerify=false -c credential.helper= -c "credential.helper=$SCRIPTS/git-credential.sh" "$@"; } +# TLS/憑證錯誤多半是暫時的(連線被重置、代理、時鐘),遇到就重試幾次,不要讓輪詢中的腳本因此中斷; +# 其他錯誤原樣回傳。(憑證驗證已關閉,仍保留重試以處理連線被重置等暫時性錯誤。)重試次數 HERDR_ISSUE_TLS_RETRIES(預設 5),間隔 3 秒。 +tea() { + local out err rc=0 try max="${HERDR_ISSUE_TLS_RETRIES:-5}" ef; ef="$(mktemp)" + for ((try = 1; try <= max; try++)); do + out="$(command tea "$1" --login "$HERDR_ISSUE_LOGIN" "${@:2}" 2>"$ef")" && rc=0 || rc=$? + err="$(cat "$ef")" + if [ "$rc" -eq 0 ] || ! grep -qiE 'tls|x509|certificate|handshake' <<<"$out$err"; then break; fi + echo "herdr-issue: tea 遇到 TLS/憑證錯誤(第 $try/$max 次),3 秒後重試" >&2 + rc=${rc/#0/1}; sleep 3 + done + rm -f "$ef" + [ -z "$err" ] || printf '%s\n' "$err" >&2 + printf '%s\n' "$out" + return "$rc" +} + +# 從 stdin 讀 JSON,執行 python 片段,d 是解析後的物件。 +jq_py() { python3 -c "import sys,json +def find(o,k): + if isinstance(o,dict): + if k in o: return o[k] + for v in o.values(): + r=find(v,k) + if r is not None: return r + elif isinstance(o,list): + for v in o: + r=find(v,k) + if r is not None: return r + return None +d=json.load(sys.stdin) +$1"; } + +# 議題的完整 JSON(state、labels、body、title、closedAt)。 +issue_json() { tea issues "$2" --repo "$1" --output json; } + +issue_state() { issue_json "$1" "$2" | jq_py 'print(d["state"])'; } + +issue_labels() { issue_json "$1" "$2" | jq_py 'print("\n".join(l["name"] for l in d.get("labels") or []))'; } + +# 某個標籤底下還有幾個 open 議題。 +open_count_with_label() { + tea issues list --repo "$1" --state open --labels "$2" --limit 500 --output json --fields index \ + | jq_py 'print(len(d))' +} + +ensure_label() { + if ! tea labels list --repo "$1" --output json | jq_py "sys.exit(0 if any(l['name']=='$LABEL' for l in d) else 1)"; then + tea labels create --repo "$1" --name "$LABEL" --color "$LABEL_COLOR" --description "$LABEL_DESC" >/dev/null + fi +} + +# agent 狀態:idle|working|blocked|done|unknown|missing +agent_status() { + herdr agent get "issue-$1" 2>/dev/null | jq_py 'print(find(d,"agent_status") or "unknown")' 2>/dev/null || echo missing +} + +# 標籤為 #N 的 tab ID,沒有就空字串。 +tab_for_issue() { + herdr tab list --workspace "${HERDR_WORKSPACE_ID:?}" \ + | jq_py "print(next((t['tab_id'] for t in find(d,'tabs') or [] if t.get('label')=='#$1'),''))" +} + +# worktree 路徑:clone 同層的 -issue-N +worktree_path() { echo "$(dirname "$2")/$(basename "$2")-issue-$1"; } + +notify() { herdr notification show "$1" --body "$2" --sound "${3:-request}" >/dev/null 2>&1 || true; } + +# 目前目錄必須是 owner/repo 的 clone;印出 clone 根目錄。 +clone_root_for() { + local root url + root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "目前目錄不是 git repo,請在 $1 的 clone 裡執行" + url="$(git -C "$root" remote get-url origin 2>/dev/null || true)" + case "$url" in + *"$1"|*"$1.git"|*"$1/") echo "$root" ;; + *) die "目前 clone 的 origin 是 $url,不是 $1" ;; + esac +} + +# head 分支為 issue-N 的最新 PR:印出「index state」或空字串。 +pr_for_issue() { + tea pulls list --repo "$1" --state all --limit 500 --output json --fields index,state,mergeable,head \ + | 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/plugins/herdr-issue/skills/herdr-issue/scripts/pr-wait.sh b/plugins/herdr-issue/skills/herdr-issue/scripts/pr-wait.sh new file mode 100755 index 0000000..354bebe --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/scripts/pr-wait.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# 用法:pr-wait.sh +# 阻塞到 head 為 issue-N 的 PR 有衝突、被關閉或合併,或議題被關閉。 +# 最後一行印出原因:conflict | closed | merged | issue-closed +source "$(dirname "$0")/lib.sh" +REPO="${1:?}"; N="${2:?用法:pr-wait.sh }" +while :; do + read -r PR STATE MERGEABLE <<<"$(pr_for_issue "$REPO" "$N")" || true + if [ -z "${PR:-}" ]; then + echo "還沒有 head 為 issue-$N 的 PR,$INTERVAL 秒後再查" + else + case "$STATE" in + merged) echo "PR #$PR 已合併"; echo merged; exit 0 ;; + closed) echo "PR #$PR 被關閉(未合併)"; echo closed; exit 0 ;; + open) + if [ "$MERGEABLE" = "false" ]; then + sleep 60 + read -r _ _ AGAIN <<<"$(pr_for_issue "$REPO" "$N")" + if [ "$AGAIN" = "false" ]; then echo "PR #$PR 與 main 有衝突"; echo conflict; exit 0; fi + fi ;; + esac + fi + if [ "$(issue_state "$REPO" "$N")" = "closed" ]; then echo "議題 #$N 已關閉"; echo issue-closed; exit 0; fi + sleep "$INTERVAL" +done diff --git a/plugins/herdr-issue/skills/herdr-issue/scripts/prompt.txt b/plugins/herdr-issue/skills/herdr-issue/scripts/prompt.txt new file mode 100644 index 0000000..49c2fd0 --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/scripts/prompt.txt @@ -0,0 +1,18 @@ +請實作這個 Gitea 議題:{LINK}(先用 tea issues 讀完整內文與留言,依 Acceptance criteria 逐項完成)。 + +你在分支 issue-{N} 的 worktree 裡,從 origin/main 建立,請在這裡實作,不要另外建 worktree。 +{RESUME} +規則: +0. 一律以 tea 的預設使用者 {LOGIN} 操作 Gitea(tea 指令加 --login {LOGIN}),git commit 身分已設為同一人。 + 所有回覆、commit 訊息、PR 標題與描述、議題留言一律使用繁體中文(程式碼與指令除外)。 +1. 議題的代辦項目是內文 Acceptance criteria 的核取方塊。每完成一項就執行 + {S}/tick.sh {REPO} {N} <第幾項>(從第一個核取方塊數起,已勾的也算) + 它會重新讀取最新內文再改,不要自己用 tea issues edit 重寫整段。 +2. 全部完成後推送分支並建立 PR 回 main: + tea pulls create --login {LOGIN} --repo {REPO} --base main --head issue-{N} --title "<標題>" --description "<摘要> + +Closes #{N}" +3. 然後在背景執行 {S}/pr-wait.sh {REPO} {N},它會在 PR 有衝突、被關閉或合併時結束並印出原因: + - conflict:git fetch origin && git merge origin/main,用 /mattpocock-skills:resolving-merge-conflicts 解決,跑測試,推送,再執行一次 pr-wait.sh。 + - closed:tea issues close --login {LOGIN} --repo {REPO} {N},結束。 + - merged 或 issue-closed:結束。 diff --git a/plugins/herdr-issue/skills/herdr-issue/scripts/ready.py b/plugins/herdr-issue/skills/herdr-issue/scripts/ready.py new file mode 100755 index 0000000..f7b515a --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/scripts/ready.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""列出可執行的議題。輸入:open.json agents.json tabs.json owner/repo +阻擋關係只認 Gitea 原生 issue dependencies(tea api),不看議題內文。""" +import os, 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): + if k in o: return o[k] + for v in o.values(): + r = find(v, k) + if r is not None: return r + elif isinstance(o, list): + for v in o: + r = find(v, k) + if r is not None: return r + +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 []] + +# Herdr 裡活著的 agent:名稱 issue-N,或掛在標籤 #N 的 tab 上 +running = set() +for a in find(agents, "agents") or []: + m = re.fullmatch(r"issue-(\d+)", str(a.get("name") or "")) + if m: running.add(m.group(1)) +agent_tabs = {a.get("tab_id") for a in find(agents, "agents") or []} +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 open_blockers(n): + """擋住 #n 的 open 依賴。tea api 遇到 HTTP 錯誤仍 exit 0,所以回應不是陣列就當失敗,整個停下。""" + raw = subprocess.run(["tea", "api", "--login", os.environ["HERDR_ISSUE_LOGIN"], "--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, stale = [], [] +for i in sorted(open_issues, key=lambda x: int(x["index"])): + ls = labels(i) + if "ready-for-agent" not in ls: continue + if "類型/規格" in ls or "類型/總覽" in ls: continue + n = i["index"] + if n in running: continue + if "in-progress" in ls: + stale.append(n); continue + if open_blockers(n): continue + runnable.append((n, spec(i), i["title"])) + +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)) diff --git a/plugins/herdr-issue/skills/herdr-issue/scripts/ready.sh b/plugins/herdr-issue/skills/herdr-issue/scripts/ready.sh new file mode 100755 index 0000000..579d1d2 --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/scripts/ready.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# 用法:ready.sh +# 列出可執行的議題(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" "$REPO" diff --git a/plugins/herdr-issue/skills/herdr-issue/scripts/start.sh b/plugins/herdr-issue/skills/herdr-issue/scripts/start.sh new file mode 100755 index 0000000..dd4feb6 --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/scripts/start.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# 用法:start.sh +# 在目前 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" +test "${HERDR_ENV:-}" = 1 || die "不在 Herdr 裡(HERDR_ENV≠1)" +CLONE="$(clone_root_for "$REPO")" +BRANCH="issue-$N" +WT="$(worktree_path "$N" "$CLONE")" + +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 +if git -C "$CLONE" show-ref --verify --quiet "refs/heads/$BRANCH"; then + RESUMED=true + if ! git -C "$CLONE" worktree list --porcelain | grep -qx "worktree $WT"; then + git -C "$CLONE" worktree add --quiet "$WT" "$BRANCH" + fi +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>"$START_ERR"; then + rm -f "$START_ERR"; break + fi + sleep 2 +done +if [ -f "$START_ERR" ]; then + cat "$START_ERR" >&2; rm -f "$START_ERR" + rollback + die "agent 啟動失敗,已回滾" +fi + +# git 身分:與 tea 預設使用者一致(只設在這個 worktree,不動 clone 的設定) +GIT_USER="$(command tea login list --output json | jq_py "print(next((l['user'] for l in d if l['name']=='$HERDR_ISSUE_LOGIN'),''))")" +GIT_EMAIL="$(tea api "/user" | jq_py 'print(d.get("email") or "")' 2>/dev/null || true)" +git -C "$CLONE" config extensions.worktreeConfig true +# agent 在 worktree 裡自己跑的 git(push、fetch)也要有帳密:把 helper 設到 clone 的本地設定(worktree 共用),只對這個主機生效 +GIT_HOST_URL="$(command tea login list --output json | jq_py "print(next((l['url'] for l in d if l['name']=='$HERDR_ISSUE_LOGIN'),''))")" +if [ -n "$GIT_HOST_URL" ]; then + git -C "$CLONE" config --local --replace-all "credential.$GIT_HOST_URL.helper" "$SCRIPTS/git-credential.sh" +fi +git -C "$WT" config --worktree http.sslVerify false +git -C "$WT" config --worktree user.name "$GIT_USER" +[ -z "$GIT_EMAIL" ] || git -C "$WT" config --worktree user.email "$GIT_EMAIL" + +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|{LOGIN}|$HERDR_ISSUE_LOGIN|g" -e "s|{RESUME}|$RESUME_LINE|" "$SCRIPTS/prompt.txt")" + +# 送出後必須觀察到 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,"state":"%s"}\n' "$TAB" "$PANE" "$WT" "$RESUMED" "$STATE" diff --git a/plugins/herdr-issue/skills/herdr-issue/scripts/tick.sh b/plugins/herdr-issue/skills/herdr-issue/scripts/tick.sh new file mode 100755 index 0000000..7be08cb --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/scripts/tick.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# 用法:tick.sh <第幾項> +# 把議題內文第 k 個核取方塊(已勾的也算在內)改成「[x]」。每次都重新讀取最新內文再改。 +source "$(dirname "$0")/lib.sh" +REPO="${1:?}"; N="${2:?}"; K="${3:?用法:tick.sh <第幾項>}" +NEW="$(issue_json "$REPO" "$N" | python3 -c " +import sys,json,re +body=json.load(sys.stdin)['body'] +k=int(sys.argv[1]); i=[0] +def rep(m): + i[0]+=1 + return '[x]' if i[0]==k else m.group(0) +out=re.sub(r'\[[ xX]\]', rep, body) +if i[0]/dev/null +echo "#$N 第 $K 項已勾選,剩 $(grep -o '\[ \]' <<<"$NEW" | wc -l) 項" diff --git a/plugins/herdr-issue/skills/herdr-issue/scripts/watch-issue.sh b/plugins/herdr-issue/skills/herdr-issue/scripts/watch-issue.sh new file mode 100755 index 0000000..43e2cf6 --- /dev/null +++ b/plugins/herdr-issue/skills/herdr-issue/scripts/watch-issue.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# 用法:watch-issue.sh (在 clone 裡以背景執行) +# 每 HERDR_ISSUE_WATCH_INTERVAL 秒(預設 5)查議題;agent blocked 超過 HERDR_ISSUE_BLOCKED_AFTER 秒(預設 60,只通知一次)或消失就彈 Herdr 通知;議題關閉時清理並往上檢查規格、總覽。 +source "$(dirname "$0")/lib.sh" +REPO="${1:?}"; N="${2:?用法:watch-issue.sh }" +CLONE="$(clone_root_for "$REPO")" +BRANCH="issue-$N" +WT="$(worktree_path "$N" "$CLONE")" +LAST="" +WATCH_INTERVAL="${HERDR_ISSUE_WATCH_INTERVAL:-5}" # 輪詢秒數(不耗額度,只查 Gitea 與 Herdr) +BLOCKED_AFTER="${HERDR_ISSUE_BLOCKED_AFTER:-60}" # blocked 連續超過幾秒才通知 +BLOCKED_SINCE=0 +NOTIFIED=false + +# 若