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.15.0",
"version": "2.16.0",
"author": {
"name": "Che Cheng"
},
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ jobs:
# shellcheck 已預裝在 ubuntu-latest runner

- name: shellcheck
run: shellcheck bin/pai-build-diff bin/pai-parse-verdict
run: shellcheck bin/pai-build-diff bin/pai-parse-verdict bin/pai-iter-commit

- name: py_compile
run: python3 -m py_compile bin/pai-parse-lens-csv

- name: bats
run: bats test/

- name: harness regression (node)
run: node test/ensemble-workflow.test.mjs
- name: node tests
run: for t in test/*.test.mjs; do echo "$t"; node "$t"; done
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.15.0",
"version": "2.16.0",
"author": {
"name": "Che Cheng"
}
Expand Down
9 changes: 9 additions & 0 deletions plugins/parallel-ai-agents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.16.0] - 2026-06-10

### Added
- **`bin/pai-iterate-decide` —— `--auto-iterate` 主迴圈的轉移函式抽成純狀態機**(單一真相源)。Phase 5b 那個看似「LLM 編排」的迴圈,其決策核心(halt 判定、mode 奇偶交替、`last_3_同focus_CONVERGED → focus 輪替`、pool 繞回、max-rounds clamp [1,30])其實全是確定性邏輯 —— 抽成 JSON in/out 的 node script 後可窮舉測試。`test/pai-iterate-decide.test.mjs`(17 個)涵蓋:converged≠max-rounds 兩種 halt、**最後一輪仍套 fix 才 halt**、自訂 `--converge-on`、clamp 上下界、奇偶 mode、剛好 3 次同 focus CONVERGED 才輪替(2 次/focus 不一致/含 NEEDS_ITER 都不輪替)、pool 繞回、自訂 focus 落 pool[0]、自訂 focusPool、非法 round/JSON → exit 2。
- **`bin/pai-iter-commit` —— per-round checkpoint commit 抽成 script**:標準 `iter-N:` 訊息單一真相源 + **空輪防護**(apply-fix 全 skip 的輪不留空 commit)。`test/pai-iter-commit.bats`(9 個)用 fixture repo 斷言 commit graph:有變更才 commit、untracked 被納入、空輪跳過、round 驗證(0/非數字/11 位)、非 repo。
- academic SKILL.md Phase 5b 主迴圈改寫:確定性決策全部委派給上述兩個 script,**唯一的 LLM 步驟剩 `apply_fixes`**(屬 eval 範疇,非單元測試)。
- `test/README.md` 補「哲學」一節:把「LLM 編排」拆成確定性核心 + 模型 seam(Functional Core, Imperative Shell)—— decider/parser 窮舉測、mock seam 測接線、fixture 測 side-effect、模型判斷品質歸 eval。
- CI/`run.sh`:shellcheck 擴及 `pai-iter-commit`、node 測試改跑全部 `test/*.test.mjs`。bats 50 → 59、node 8 → 25。

## [2.15.0] - 2026-06-04

### Added
Expand Down
34 changes: 34 additions & 0 deletions plugins/parallel-ai-agents/bin/pai-iter-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# pai-iter-commit — ensemble-academic-review --auto-iterate 的 per-round checkpoint commit(單一真相源)。
#
# 標準訊息 + 空 commit 防護:一輪若沒套到任何 fix(apply-fix 全 skip),不留空的 iter-N commit。
#
# 用法:pai-iter-commit [-C <repo>] <N>
# 退出碼:0 已 commit 或無變更跳過(皆良性);1 git 錯誤;2 用法/round 非法。
set -uo pipefail

REPO="."
if [ "${1:-}" = "-C" ]; then
[ $# -ge 2 ] || { echo "-C 需要一個目錄參數" >&2; exit 2; }
REPO="$2"; shift 2
fi

N="${1:-}"
if [[ "$N" =~ ^[1-9][0-9]*$ ]] && (( ${#N} <= 9 )); then :; else
echo "需正整數(1..9 位) round: $N" >&2; exit 2
fi

GIT() { git -C "$REPO" "$@"; }

GIT rev-parse --git-dir >/dev/null 2>&1 || { echo "不在 git repo 內(-C='$REPO')" >&2; exit 1; }
GIT add -A || { echo "git add 失敗" >&2; exit 1; }

# 空 commit 防護:暫存區無變更 → 不留空 iter-N commit(exit 0,良性)
if GIT diff --cached --quiet; then
echo "iter-$N: 無變更,跳過 commit" >&2
exit 0
fi

GIT commit -q -m "iter-$N: apply HIGH fixes from ensemble round $N" \
|| { echo "git commit 失敗" >&2; exit 1; }
echo "iter-$N: committed"
112 changes: 112 additions & 0 deletions plugins/parallel-ai-agents/bin/pai-iterate-decide
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env node
// pai-iterate-decide — ensemble-academic-review --auto-iterate 主迴圈的「轉移函式」(單一真相源)。
//
// Phase 5b 那個看似「LLM 編排」的迴圈,其決策核心其實是純函式:給定一輪結束時的狀態,
// 決定 halt / 是否套用本輪 fix / 下一輪的 mode 與 focus。把它抽成可窮舉測試的 decider,
// 真正未測的就只剩 apply-fix 那一次 Edit 呼叫(屬 eval 範疇)。
//
// I/O:state JSON 從 stdin(或 argv[2] 檔路徑)讀入,action JSON 印到 stdout。
//
// state = {
// round:Int(≥1), maxRounds:Int, verdict:Str(""=無),
// convergeOn:Str(default PERMANENT_CONVERGENCE),
// currentFocus:Str, focusHistory:[{focus,verdict}](本輪之前的歷史),
// focusPool:[Str](optional, default 見下)
// }
// action = { halt:Bool, reason:'converged'|'max-rounds'|null, applyFixes:Bool,
// nextRound:Int, nextMode:'independent'|'hybrid', nextFocus:Str, rotated:Bool }
//
// 語意忠實對應 SKILL.md 主迴圈:
// - verdict==convergeOn → break(halt,本輪「不」套 fix)。
// - 否則本輪套 HIGH fix + commit;focus_history 追加 (currentFocus,verdict);
// 末 3 筆同 focus 且皆 CONVERGED → focus 輪替到 pool 下一個(繞回)。
// - 迴圈 N+=1 後 while N<=max_rounds:故 nextRound>maxRounds → halt(reason max-rounds),
// 但該輪 fix 仍照套(break 只在 converge)。
// - mode:奇數輪 independent、偶數輪 hybrid。
// - maxRounds 防呆 clamp [1,30](對齊 SKILL 的 --max-rounds clamp)。

const DEFAULT_POOL = ['method-section', 'proofs', 'typography', 'cross-references', 'boundary-cases']

function modeFor(n) {
return n % 2 === 1 ? 'independent' : 'hybrid'
}

function nextInPool(pool, focus) {
const i = pool.indexOf(focus)
if (i < 0) return pool[0] // focus 不在 pool(自訂)→ 從頭
return pool[(i + 1) % pool.length] // 繞回
}

// 末 n 筆都「同一個 focus 且 verdict===CONVERGED」
function lastNSameFocusConverged(history, n, focus) {
if (history.length < n) return false
return history.slice(-n).every((h) => h.verdict === 'CONVERGED' && h.focus === focus)
}

function decide(state) {
const round = Math.floor(Number(state.round))
const verdict = state.verdict == null ? '' : String(state.verdict)
const convergeOn = state.convergeOn ? String(state.convergeOn) : 'PERMANENT_CONVERGENCE'
const currentFocus = String(state.currentFocus == null ? '' : state.currentFocus)
const pool = Array.isArray(state.focusPool) && state.focusPool.length ? state.focusPool.map(String) : DEFAULT_POOL
const history = Array.isArray(state.focusHistory) ? state.focusHistory : []
// clamp maxRounds 到 [1,30](防 0→每輪立即 halt、防 runaway)
const maxRounds = Math.min(30, Math.max(1, Math.floor(Number(state.maxRounds) || 1)))

if (!Number.isInteger(round) || round < 1) {
throw new Error(`round 需正整數: ${state.round}`)
}

// 1. 收斂 → break,本輪不套 fix、不前進
if (verdict === convergeOn) {
return { halt: true, reason: 'converged', applyFixes: false, nextRound: round, nextMode: modeFor(round), nextFocus: currentFocus, rotated: false }
}

// 2. 未收斂 → 本輪套 HIGH fix + commit
const newHistory = [...history, { focus: currentFocus, verdict }]
const rotated = lastNSameFocusConverged(newHistory, 3, currentFocus)
const nextFocus = rotated ? nextInPool(pool, currentFocus) : currentFocus
const nextRound = round + 1
const halt = nextRound > maxRounds // N+=1 後越界 → 迴圈結束(但本輪 fix 已套)
return {
halt,
reason: halt ? 'max-rounds' : null,
applyFixes: true,
nextRound,
nextMode: modeFor(nextRound),
nextFocus,
rotated,
}
}

function main() {
const fs = require('node:fs')
let raw
try {
raw = process.argv[2] ? fs.readFileSync(process.argv[2], 'utf8') : fs.readFileSync(0, 'utf8')
} catch (e) {
process.stderr.write(`讀 state 失敗: ${e.message}\n`)
return 2
}
let state
try {
state = JSON.parse(raw)
} catch {
process.stderr.write('state 不是合法 JSON\n')
return 2
}
let action
try {
action = decide(state)
} catch (e) {
process.stderr.write(`${e.message}\n`)
return 2
}
process.stdout.write(JSON.stringify(action) + '\n')
return 0
}

if (require.main === module) {
process.exit(main())
}
module.exports = { decide, modeFor, nextInPool, lastNSameFocusConverged, DEFAULT_POOL }
53 changes: 28 additions & 25 deletions plugins/parallel-ai-agents/skills/ensemble-academic-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,43 +547,46 @@ TaskUpdate: "Final: merge all rounds" → in_progress

#### 主迴圈

迴圈的**確定性決策**(halt / 是否套 fix / 下一輪 mode / focus-rotation)全部交給純狀態機
`bin/pai-iterate-decide`(已測,見 `test/pai-iterate-decide.test.mjs`);commit 交給 `bin/pai-iter-commit`
(空輪自動跳過、訊息單一真相源)。**唯一的 LLM 步驟是 `apply_fixes`。**

```
N=1
verdict=NEEDS_ITER_1
focus_history=[]
N=1; verdict=""; mode=independent; current_focus=method-section; focus_history=[]

while N <= max_rounds:
# 1. Run round (alternating: odd=independent, even=hybrid)
mode = 'independent' if N is odd else 'hybrid'
loop:
# 1. 跑一輪(mode 首輪 independent,之後用上一輪 decider 的 nextMode)
run Phase 1-4 with mode → review-round-{N}.md

# 2. Parse Codex verdict(取最後一個 <verdict>TAG</verdict>;查無 → "" 視為未收斂)
# 2. verdict(取 last-match;查無 → "" 視為未收斂)
verdict = $(bin/pai-parse-verdict review-round-{N}.md) || verdict=""

# 3. Halt check
if verdict == converge_on:
break

# 4. Apply HIGH-severity fixes
high_findings = parse_findings(review-round-{N}.md, severity='HIGH')
apply_fixes(high_findings) → working tree modified
# 3. 決策(halt / applyFixes / nextMode / focus-rotation —— 純狀態機、窮舉測過)
# state JSON 用 jq 組(focus_history 傳本輪「之前」的歷史,decider 內部會 append 當輪做 rotation 判定)
action = ( jq -nc --argjson r "$N" --argjson m "$max_rounds" --arg v "$verdict" \
--arg co "$converge_on" --arg f "$current_focus" --argjson h "$focus_history" \
'{round:$r,maxRounds:$m,verdict:$v,convergeOn:$co,currentFocus:$f,focusHistory:$h}' \
| node "${CLAUDE_PLUGIN_ROOT}/bin/pai-iterate-decide" )

# 5. Auto-commit checkpoint
git add -A
git commit -m "iter-{N}: apply HIGH fixes from ensemble round {N}"
# 4. 套 HIGH fix(若 action.applyFixes)+ checkpoint commit(pai-iter-commit 空輪自動跳過、不留空 commit)
if action.applyFixes:
apply_fixes(parse_findings(review-round-{N}.md, severity='HIGH')) # ← 唯一非確定性步驟(Edit)
"${CLAUDE_PLUGIN_ROOT}/bin/pai-iter-commit" {N}

# 6. Rotate focus heuristic (after K=3 same-focus CONVERGED)
focus_history.append((current_focus, verdict))
if last_3_verdicts_all_CONVERGED_with_same_focus(focus_history):
current_focus = next_focus_in_pool()
# focus pool: method-section, proofs, typography, cross-references, boundary-cases
# 5. 追加歷史(供下一輪 rotation 判定)
focus_history += [ {focus: current_focus, verdict: verdict} ]

N += 1
# 6. halt?(converged 或 max-rounds,由 decider 判)
if action.halt:
log("Halted: " + action.reason) # 'converged' | 'max-rounds'
break

if N > max_rounds:
log("Halted at max_rounds without reaching {converge_on}")
# 7. 前進(mode / focus 全由 decider 給;max_rounds 由 decider clamp 到 [1,30])
N = action.nextRound; mode = action.nextMode; current_focus = action.nextFocus
```

> **為何抽成 script**:mode 奇偶交替、halt 判定、`last_3_同focus_CONVERGED → 輪替`、pool 繞回、max-rounds clamp 全是**確定性**邏輯,過去藏在「LLM 編排」裡無法測。抽成 `pai-iterate-decide` 後窮舉測(converged≠max-rounds、最後一輪仍套 fix、剛好 3 次才輪替、pool 繞回…),未測表面縮到只剩 `apply_fixes` 那一次 Edit(屬 eval 範疇,非單元測試)。

#### Verdict 解析 protocol

Codex prompt 結尾必須含明確 instruction:
Expand Down
15 changes: 14 additions & 1 deletion plugins/parallel-ai-agents/test/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# test/

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

## 哲學:把「LLM 編排」拆成「確定性核心 + 模型 seam」

ensemble-* 的程式表面看似都是「LLM 驅動的編排」,不可測。但把每條流程拆開,**80% 是確定性膠水**(可窮舉測),只有真正呼叫模型那幾步是非確定的。本目錄的策略(Functional Core, Imperative Shell):

1. **抽出 decider / parser 純函式** → 窮舉單元測。例:`pai-iterate-decide`(迴圈狀態機)、`pai-parse-verdict`(收斂判定)、`pai-build-diff`(diff 建構)。
2. **Mock 模型 seam** → 注入確定性 oracle,測「接線」而非模型。例:`ensemble-workflow.test.mjs` 把 `agent()` 換成 mock。
3. **Fixture 測 side-effect** → 拋棄式 git repo 斷言 commit graph。例:`pai-iter-commit`(空輪不留空 commit)。
4. **真正不可單元測的(模型判斷品質)** → 屬 eval / invariant assertion 範疇,不進每次 push 的 CI。例:`apply_fixes` 那一次 Edit 改得對不對、reviewer 有沒有抓到埋好的幻覺文獻。

`--auto-iterate` 主迴圈即範例:halt 判定、mode 奇偶交替、focus-rotation、max-rounds clamp、per-round commit 全部抽出測(`pai-iterate-decide` + `pai-iter-commit`),未測表面縮到只剩 `apply_fixes` 一步。

## 測什麼

Expand All @@ -10,6 +21,8 @@
| `ensemble-workflow.test.mjs` | `../workflows/ensemble-workflow.js`(共用 harness,4 個 skill 的底層)|
| `pai-parse-lens-csv.bats` | `../bin/pai-parse-lens-csv`(ensemble-compose 的 `--lens-file` CSV 解析器)|
| `pai-parse-verdict.bats` | `../bin/pai-parse-verdict`(ensemble-academic-review `--auto-iterate` 的 verdict tag 解析器)|
| `pai-iterate-decide.test.mjs` | `../bin/pai-iterate-decide`(`--auto-iterate` 主迴圈的純狀態機:halt / 套 fix / mode 交替 / focus-rotation)|
| `pai-iter-commit.bats` | `../bin/pai-iter-commit`(`--auto-iterate` 的 per-round checkpoint commit + 空輪防護)|

`pai-parse-lens-csv.bats` 涵蓋:含逗號/引號/換行的 focus(csv 模組、不被切爛)、needsSrt 變體、空欄跳過、**BOM 不丟列(utf-8-sig regression)**、CRLF、缺檔/缺欄。
`pai-parse-verdict.bats` 涵蓋:**last-match(防 echoed instruction 範例造成假收斂的 regression)**、`{N}` placeholder 不匹配、嚴格大寫、查無 tag → 非零、stdin/file。
Expand Down
72 changes: 72 additions & 0 deletions plugins/parallel-ai-agents/test/pai-iter-commit.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env bats
# pai-iter-commit(academic --auto-iterate 的 per-round checkpoint commit)的 bats 測試。
# 對抗案例:標準訊息、空 commit 防護、round 驗證、非 repo。

setup() {
BIN="${BATS_TEST_DIRNAME}/../bin/pai-iter-commit"
REPO="${BATS_TEST_TMPDIR}/repo"
git init -q "$REPO"
git -C "$REPO" config user.email t@example.com
git -C "$REPO" config user.name tester
git -C "$REPO" config commit.gpgsign false
printf 'base\n' > "$REPO/f.txt"; git -C "$REPO" add f.txt; git -C "$REPO" commit -qm base
}

commits() { git -C "$REPO" rev-list --count HEAD; }

@test "有變更 → commit,訊息為 iter-N: ..." {
printf 'fix\n' >> "$REPO/f.txt"
run "$BIN" -C "$REPO" 1
[ "$status" -eq 0 ]
[ "$(commits)" -eq 2 ]
[ "$(git -C "$REPO" log -1 --pretty=%s)" = "iter-1: apply HIGH fixes from ensemble round 1" ]
}

@test "untracked 新檔 → 被 add -A 納入 commit" {
printf 'new\n' > "$REPO/g.txt"
run "$BIN" -C "$REPO" 2
[ "$status" -eq 0 ]
[ "$(commits)" -eq 2 ]
git -C "$REPO" ls-files --error-unmatch g.txt
}

@test "空輪(無變更)→ 跳過、不留空 commit、exit 0" {
run "$BIN" -C "$REPO" 3
[ "$status" -eq 0 ]
[[ "$output" == *"無變更,跳過"* ]]
[ "$(commits)" -eq 1 ]
}

@test "round 0 → exit 2" {
printf 'x\n' >> "$REPO/f.txt"
run "$BIN" -C "$REPO" 0
[ "$status" -eq 2 ]
[[ "$output" == *"需正整數"* ]]
}

@test "round 非數字 → exit 2" {
run "$BIN" -C "$REPO" abc
[ "$status" -eq 2 ]
}

@test "round 11 位數 → exit 2(位數上限)" {
run "$BIN" -C "$REPO" 12345678901
[ "$status" -eq 2 ]
}

@test "缺 round → exit 2" {
run "$BIN" -C "$REPO"
[ "$status" -eq 2 ]
}

@test "非 git repo → exit 1" {
mkdir "$BATS_TEST_TMPDIR/plain"
run "$BIN" -C "$BATS_TEST_TMPDIR/plain" 1
[ "$status" -eq 1 ]
[[ "$output" == *"不在 git repo"* ]]
}

@test "-C 缺目錄參數 → exit 2" {
run "$BIN" -C
[ "$status" -eq 2 ]
}
Loading
Loading