Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "parallel-ai-agents",
"source": "./plugins/parallel-ai-agents",
"description": "平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移",
"version": "2.13.2",
"version": "2.14.0",
"author": {
"name": "Che Cheng"
},
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: test

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
shellcheck-bats:
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugins/parallel-ai-agents
steps:
- uses: actions/checkout@v4

- name: Install bats
run: sudo apt-get update && sudo apt-get install -y bats
# shellcheck 已預裝在 ubuntu-latest runner

- name: shellcheck
run: shellcheck bin/pai-build-diff

- name: bats
run: bats test/
2 changes: 1 addition & 1 deletion plugins/parallel-ai-agents/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "parallel-ai-agents",
"description": "平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移",
"version": "2.13.2",
"version": "2.14.0",
"author": {
"name": "Che Cheng"
}
Expand Down
10 changes: 10 additions & 0 deletions plugins/parallel-ai-agents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.14.0] - 2026-06-03

### Added
- **`bin/pai-build-diff` —— diff 模式建構器抽成 version-pinned shipped script**(單一真相源)。原本 inline 在 `ensemble-code-review` SKILL.md 的 bash recipe(經 3 輪 self-dogfood 硬化)抽成獨立可執行檔,`SKILL.md` 改成呼叫它(`bash "${CLAUDE_PLUGIN_ROOT}/bin/pai-build-diff" "$MODE" ...`)。好處:消除「LLM 改寫 inline recipe 時 typo」的風險、可 shellcheck、可 bats 覆蓋。退出碼契約:`0` 有 diff/`3` 無變更(良性)/`1` 錯誤。
- **`test/pai-build-diff.bats` —— 25 個 bats regression 測試**:把 3 輪人工 re-audit 抓到的 bug 全部固化(5 種模式、退出碼、ref/N 驗證防 injection/dashed-ref/0/leading-zero/位數溢位、untracked symlink no-follow、FIFO no-hang、換行檔名 C-quote、empty-tree base)。取代「每次改 diff 邏輯都人工重審」。附 `test/run.sh`(shellcheck + bats 一鍵)+ `test/README.md`。
- **`.github/workflows/test.yml` —— CI**:每次 push / PR 自動跑 shellcheck + bats。

### Fixed
- **`bin/pai-build-diff` 未知 mode 的多位元組 bug**(extraction 過程 dogfood 抓到):`未知 MODE: $MODE(限...`—— `$MODE` 緊貼全形 `(`,bash 把 `(` 的首位元組吞進變數名 → `set -u` 誤報 `unbound variable`。改 `${MODE}` 明確界定。已加 regression 測試。

## [2.13.2] - 2026-06-02

### Fixed
Expand Down
117 changes: 117 additions & 0 deletions plugins/parallel-ai-agents/bin/pai-build-diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# pai-build-diff — ensemble-code-review 的 diff 模式建構器(單一真相源)
#
# 把 git/gh 的變更取成 unified diff 寫到 stdout,給 ensemble-code-review skill
# 重導到自己的 DIFF_FILE(async reviewer agents 讀的那個檔)。
#
# 用法:
# pai-build-diff [-C <repo-dir>] --diff
# pai-build-diff [-C <repo-dir>] --base <ref> # 本分支相對 <ref> 的 merge-base(三點)
# pai-build-diff [-C <repo-dir>] --since <ref> # <ref>..HEAD(兩點)
# pai-build-diff [-C <repo-dir>] --commits <N> # 最近 N 個 commit
# pai-build-diff [-C <repo-dir>] --pr <N> # gh pr diff <N>
#
# 退出碼(給測試與 skill 區分):
# 0 diff 已寫 stdout(有變更)
# 1 錯誤(非 repo / ref 非法 / N 非法 / git|gh 失敗 / 用法錯)
# 3 無變更可審(git 成功但 diff 為空 —— 良性,非錯誤)
#
# 設計重點(3 輪 self-dogfood 硬化的成果,全部住這):
# * ref/N 一律先驗證再用 —— 防 command injection + dashed-ref 被當 option。
# * 所有 git/gh 檢查 exit code —— 不可把 git 報錯的空輸出當「無變更、乾淨通過」(假綠燈)。
# * --diff 含 untracked 新檔,但 symlink/FIFO/dir 只列路徑不讀內容
# (避免 follow-symlink 洩漏 / FIFO read 卡死),一般新檔走 git diff(自動 C-quote 檔名,
# 防含換行檔名偽造假 diff 行)+ head -c 64KB 上限。
# * --commits N≥commit 總數 → 對 empty tree 取 diff(不 clamp 成 HEAD~TOT 越界 fatal)。
# * 內部 buffer 用 trap EXIT 清理是安全的(同步、無 async reader);skill 的 DIFF_FILE 才需手動 Phase 4.5 清。

set -uo pipefail

usage() {
sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'
}

err() { printf '%s\n' "$*" >&2; }

# ── 解析 -C <dir>(可選,給測試指向 fixture repo;skill 不帶則用 cwd) ──
repo_arg="."
if [ "${1:-}" = "-C" ]; then
[ $# -ge 2 ] || { err "-C 需要一個目錄參數"; exit 1; }
repo_arg="$2"; shift 2
fi

MODE="${1:-}"
ARG="${2:-}"

case "$MODE" in
-h|--help) usage; exit 0 ;;
--diff|--base|--since|--commits|--pr) : ;;
"") err "缺 MODE。用法見 --help"; exit 1 ;;
*) err "未知 MODE: ${MODE}(限 --diff/--base/--since/--commits/--pr)"; exit 1 ;;
esac

# ── 解析 repo root(杜絕「不在 repo 內卻靜默產生空 diff」) ──
REPO="$(git -C "$repo_arg" rev-parse --show-toplevel 2>/dev/null)" \
|| { err "不在 git repo 內(-C='$repo_arg')→ 改路徑模式或 cd 進 repo"; exit 1; }

GIT() { git -C "$REPO" --no-pager "$@"; } # --no-pager + 下方 --no-color:杜絕 ANSI 污染輸出

# ── 內部 buffer:synchronous,trap EXIT 清理安全(與 skill 的 async DIFF_FILE 無關) ──
WORK="$(mktemp -t pai-build-diff.XXXXXX)" || { err "mktemp 失敗"; exit 1; }
trap 'rm -f "$WORK"' EXIT

# ── 驗證器(防 injection + dashed-ref):ref 必解析成 commit;N 必正整數(排 0/leading-zero,≤9 位防算術溢位) ──
validate_ref() { GIT rev-parse --verify --quiet "$1^{commit}" >/dev/null || { err "ref 非法: $1"; exit 1; }; }
validate_int() {
# 明確 if(非 A && B || C)—— 排 0/leading-zero(^[1-9][0-9]*$)+ ≤9 位(防 (( ARG >= TOT )) 算術溢位)
if [[ "$1" =~ ^[1-9][0-9]*$ ]] && (( ${#1} <= 9 )); then return 0; fi
err "需正整數(1..9 位): $1"; exit 1
}

# ── untracked/新檔 append:只處理一般檔;symlink/FIFO/dir 只列路徑不讀內容 ──
# (避免 follow-symlink 量錯/洩漏目標、避免 FIFO read 卡死、避免洩 symlink 目標路徑)。
# 內容走 git diff(git 自動 C-quote 檔名,防含換行檔名偽造假 diff 行),head -c 截 64KB 上限。
append_new() {
local f="$1"
if [ -L "$REPO/$f" ] || [ ! -f "$REPO/$f" ]; then
printf 'new file(symlink/特殊檔,僅列路徑): %q\n' "$f" >>"$WORK"
return
fi
GIT diff --no-color --no-index -- /dev/null "$f" 2>/dev/null | head -c 65536 >>"$WORK" || true
}

# ── 取 diff(每條檢查 exit code;只用驗證過的值,不裸內插) ──
case "$MODE" in
--diff)
GIT diff --no-color HEAD >"$WORK" || { err "git diff 失敗"; exit 1; }
GIT ls-files --others --exclude-standard -z \
| while IFS= read -r -d '' f; do append_new "$f"; done ;;
--base)
[ -n "$ARG" ] || { err "--base 需要 <ref>"; exit 1; }
validate_ref "$ARG"
GIT diff --no-color "$ARG"...HEAD >"$WORK" || { err "git diff 失敗"; exit 1; } ;; # 三點=相對 merge-base
--since)
[ -n "$ARG" ] || { err "--since 需要 <ref>"; exit 1; }
validate_ref "$ARG"
GIT diff --no-color "$ARG"..HEAD >"$WORK" || { err "git diff 失敗"; exit 1; } ;;
--commits)
validate_int "$ARG"
TOT="$(GIT rev-list --count HEAD)" || { err "repo 無 commit"; exit 1; }
if (( ARG >= TOT )); then
BASE="$(GIT hash-object -t tree /dev/null)" # N≥全部 → 對 empty tree(含 root 的全部變更;不會 HEAD~TOT 越界 fatal)
else
BASE="HEAD~$ARG"
fi
GIT diff --no-color "$BASE" HEAD >"$WORK" || { err "git diff 失敗"; exit 1; } ;;
--pr)
validate_int "$ARG"
( cd "$REPO" && gh pr diff --color never "$ARG" ) >"$WORK" || { err "gh pr diff 失敗"; exit 1; } ;;
esac

# ── 空判定:上面已用 exit code 擋掉 git 報錯 → 這裡的空 = 真「無變更」(非 git fatal 被誤判) ──
if [ ! -s "$WORK" ]; then
err "無變更可審"
exit 3
fi

cat "$WORK"
51 changes: 16 additions & 35 deletions plugins/parallel-ai-agents/skills/ensemble-code-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,44 +88,25 @@ allowed-tools:
**diff 模式**:依 Phase 0 的來源取 diff,**寫到 temp 檔**(大 diff 不塞 inline args —— Workflow 會把 args JSON-stringify;reviewer 用 file-read tool 讀,避開 escape 地獄 + prompt 膨脹):

```bash
# 0) 把 Phase 0 的判定 lower 成變數(別只留 prose;<ref>/<N> 必帶值,缺值→報錯):
# --diff → MODE=--diff
# --base <ref> / --since <ref> → MODE=--base|--since、REF=<ref>
# --commits <N> / --pr <N> → MODE=--commits|--pr、N=<N>
REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "不在 git repo 內 → 改路徑模式或 cd 進 repo"; exit 1; }
GIT() { git -C "$REPO" --no-pager "$@"; } # --no-pager + --no-color:杜絕 ANSI 污染 DIFF_FILE
DIFF_FILE="$(mktemp -t pai-codereview-diff.XXXXXX)" # ⚠ ensemble 跑完後要 rm(含 changed-line 祕密,見鐵律)

# ref/N 驗證(防 injection + dashed-ref):ref 必解析成 commit;N 必正整數(排 0/leading-zero,且 ≤9 位防算術溢位)
validate_ref() { GIT rev-parse --verify --quiet "$1^{commit}" >/dev/null || { echo "ref 非法: $1"; exit 1; }; }
validate_int() { [[ "$1" =~ ^[1-9][0-9]*$ ]] && (( ${#1} <= 9 )) || { echo "需正整數(1..9 位): $1"; exit 1; }; }
# untracked/新檔 append:只處理一般檔 —— symlink/FIFO/dir 只列路徑不讀內容(避免 wc-c follow-symlink 量錯、避免 FIFO
# 把 read 卡死、避免洩 symlink 目標路徑)。內容走 git diff(git 自動 C-quote 檔名,防含換行檔名偽造假 diff 行),
# 再 head -c 截 64KB 上限避免單檔撐爆 prompt(用 %q 寫 path-only 檔名,中和控制字元)。
append_new() { local f="$1"
if [ -L "$REPO/$f" ] || [ ! -f "$REPO/$f" ]; then printf 'new file(symlink/特殊檔,僅列路徑): %q\n' "$f" >>"$DIFF_FILE"; return; fi
GIT diff --no-color --no-index -- /dev/null "$f" 2>/dev/null | head -c 65536 >>"$DIFF_FILE" || true; }

# 取 diff(每條檢查 exit code;只用驗證過的值,不裸內插)
case "$MODE" in
--diff)
GIT diff --no-color HEAD >"$DIFF_FILE" || { echo "git diff 失敗"; exit 1; }
GIT ls-files --others --exclude-standard -z | while IFS= read -r -d '' f; do append_new "$f"; done ;;
--base) validate_ref "$REF"; GIT diff --no-color "$REF"...HEAD >"$DIFF_FILE" || exit 1 ;; # 三點=相對 merge-base
--since) validate_ref "$REF"; GIT diff --no-color "$REF"..HEAD >"$DIFF_FILE" || exit 1 ;;
--commits)
validate_int "$N"
TOT="$(GIT rev-list --count HEAD)" || { echo "repo 無 commit"; exit 1; }
if (( N >= TOT )); then BASE="$(GIT hash-object -t tree /dev/null)" # N≥全部 → 對 empty tree(含 root 的全部變更;不會 HEAD~TOT 越界 fatal)
else BASE="HEAD~$N"; fi
GIT diff --no-color "$BASE" HEAD >"$DIFF_FILE" || exit 1 ;;
--pr) validate_int "$N"; gh pr diff --color never "$N" >"$DIFF_FILE" || { echo "gh pr diff 失敗"; exit 1; } ;;
# Phase 0 已把判定 lower 成變數(缺值→pai-build-diff 自己報錯):
# --diff → MODE=--diff(無 ARG)
# --base|--since <ref> → MODE=--base|--since、ARG=<ref>
# --commits|--pr <N> → MODE=--commits|--pr、ARG=<N>
ARG="${ARG:-}"
DIFF_FILE="$(mktemp -t pai-codereview-diff.XXXXXX)" # ⚠ skill 自管:async reviewer 讀這個檔;Phase 4.5 才 rm(含 changed-line 祕密)

# diff 建構統一交給 version-pinned 的 bin/pai-build-diff —— 3 輪硬化 + bats 覆蓋的「單一真相源」。
# 絕對路徑不賭 PATH(同 codex-call 理由)。退出碼:0=有 diff/3=無變更(良性)/其它=錯誤。
bash "${CLAUDE_PLUGIN_ROOT}/bin/pai-build-diff" "$MODE" ${ARG:+"$ARG"} >"$DIFF_FILE"; rc=$?
case "$rc" in
0) : ;; # 有變更 → 往下派 ensemble
3) rm -f "$DIFF_FILE"; echo "無變更可審 → 停(不空跑 ensemble)"; exit 0 ;;
*) rm -f "$DIFF_FILE"; echo "diff 建構失敗(見上方 stderr)→ 停"; exit 1 ;;
esac

# 空判定:上面已用 exit code 擋掉 git 報錯 → 這裡的空 = 真「無變更」(非 git fatal 被誤判)
[ -s "$DIFF_FILE" ] || { echo "無變更可審"; exit 1; }
```

> `pai-build-diff` 封裝了全部硬化:ref/N 驗證(防 command injection + dashed-ref)、untracked 新檔的 symlink/FIFO/換行檔名安全處理、`--commits N≥TOT` 用 empty-tree base、所有 git/gh exit-code 檢查、空判定。**要改 diff 邏輯就改 `bin/pai-build-diff` 並更新 `test/pai-build-diff.bats`,不要在這裡重寫 inline**(單一真相源)。契約細節見該 script 與其 25 個 bats 測試。

> **PR mode 的 context 陷阱**:`gh pr diff` 取的是**遠端 PR** 的 diff,但 reviewer 用 Read 讀到的「周邊原始碼」是**本地工作樹**(可能停在別的分支)。所以 `--pr` 模式下,contextBlock 要明示「周邊以 diff 內 hunk context 為準,本地 Read 未必對應 PR 的版本」;要精確就先 `gh pr checkout <N>`(會動工作樹,多數情況不值得)。

自動判斷審閱重點(兩模式共用):
Expand Down
31 changes: 31 additions & 0 deletions plugins/parallel-ai-agents/test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# test/

`ensemble-code-review` diff 模式的自動化測試。把過去人工 re-audit(3 輪 self-dogfood)抓到的 bug 全部固化成 regression,取代「每次改 diff 邏輯都要重新人工審」。

## 測什麼

| 檔案 | 對象 |
|------|------|
| `pai-build-diff.bats` | `../bin/pai-build-diff`(diff 模式建構器,3 輪硬化的單一真相源)|

涵蓋:5 種模式(`--diff`/`--base`/`--since`/`--commits`/`--pr`)、退出碼契約(0 有 diff/3 無變更/1 錯誤)、ref/N 驗證(injection、dashed-ref、0/leading-zero、位數溢位)、untracked 安全(symlink no-follow、FIFO no-hang、換行檔名 C-quote)、empty-tree base、未知 mode 的多位元組 regression。

## 跑法

```bash
# 前置(一次性)
brew install bats-core shellcheck

# 一鍵:shellcheck + bats
./test/run.sh

# 或分開
shellcheck bin/pai-build-diff
bats test/
```

CI(`.github/workflows/test.yml`)在每次 push / PR 自動跑同一組。

## 加測試的原則

改 `bin/pai-build-diff` 的行為 → **先在 `pai-build-diff.bats` 加一個會 fail 的 case(RED),再改 script 讓它 pass(GREEN)**。不要在 `SKILL.md` inline 重寫 diff 邏輯 —— script 是單一真相源,SKILL.md 只呼叫它。
Loading
Loading