diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 80b57ce..7406489 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" }, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 66a033f..5b40c53 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ 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 @@ -31,5 +31,5 @@ jobs: - 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 diff --git a/plugins/parallel-ai-agents/.claude-plugin/plugin.json b/plugins/parallel-ai-agents/.claude-plugin/plugin.json index df56b60..0532958 100644 --- a/plugins/parallel-ai-agents/.claude-plugin/plugin.json +++ b/plugins/parallel-ai-agents/.claude-plugin/plugin.json @@ -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" } diff --git a/plugins/parallel-ai-agents/CHANGELOG.md b/plugins/parallel-ai-agents/CHANGELOG.md index 57c33fe..9286080 100644 --- a/plugins/parallel-ai-agents/CHANGELOG.md +++ b/plugins/parallel-ai-agents/CHANGELOG.md @@ -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 diff --git a/plugins/parallel-ai-agents/bin/pai-iter-commit b/plugins/parallel-ai-agents/bin/pai-iter-commit new file mode 100755 index 0000000..39792de --- /dev/null +++ b/plugins/parallel-ai-agents/bin/pai-iter-commit @@ -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 ] +# 退出碼: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" diff --git a/plugins/parallel-ai-agents/bin/pai-iterate-decide b/plugins/parallel-ai-agents/bin/pai-iterate-decide new file mode 100755 index 0000000..79669a2 --- /dev/null +++ b/plugins/parallel-ai-agents/bin/pai-iterate-decide @@ -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 } diff --git a/plugins/parallel-ai-agents/skills/ensemble-academic-review/SKILL.md b/plugins/parallel-ai-agents/skills/ensemble-academic-review/SKILL.md index 9739493..5e35e2f 100644 --- a/plugins/parallel-ai-agents/skills/ensemble-academic-review/SKILL.md +++ b/plugins/parallel-ai-agents/skills/ensemble-academic-review/SKILL.md @@ -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(取最後一個 TAG;查無 → "" 視為未收斂) + # 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: diff --git a/plugins/parallel-ai-agents/test/README.md b/plugins/parallel-ai-agents/test/README.md index 2ba53de..6b564a7 100644 --- a/plugins/parallel-ai-agents/test/README.md +++ b/plugins/parallel-ai-agents/test/README.md @@ -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` 一步。 ## 測什麼 @@ -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。 diff --git a/plugins/parallel-ai-agents/test/pai-iter-commit.bats b/plugins/parallel-ai-agents/test/pai-iter-commit.bats new file mode 100644 index 0000000..48fda89 --- /dev/null +++ b/plugins/parallel-ai-agents/test/pai-iter-commit.bats @@ -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 ] +} diff --git a/plugins/parallel-ai-agents/test/pai-iterate-decide.test.mjs b/plugins/parallel-ai-agents/test/pai-iterate-decide.test.mjs new file mode 100644 index 0000000..f22e859 --- /dev/null +++ b/plugins/parallel-ai-agents/test/pai-iterate-decide.test.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// pai-iterate-decide 的 regression 測試(純 node,無框架)。 +// 跑法:node test/pai-iterate-decide.test.mjs +// 透過 subprocess 跑真正的 bin/pai-iterate-decide(JSON stdin → JSON stdout),測 CLI 契約。 + +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import assert from 'node:assert/strict' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const BIN = join(__dirname, '..', 'bin', 'pai-iterate-decide') + +// 跑 decider,回 {action, status};非 0 退出時 action=null +function run(state) { + try { + const out = execFileSync('node', [BIN], { input: JSON.stringify(state), encoding: 'utf8' }) + return { action: JSON.parse(out), status: 0 } + } catch (e) { + return { action: null, status: e.status ?? 1 } + } +} + +const base = { round: 1, maxRounds: 12, verdict: 'NEEDS_ITER_1', currentFocus: 'method-section', focusHistory: [] } + +const tests = [] +const test = (name, fn) => tests.push({ name, fn }) + +test('PERMANENT_CONVERGENCE(預設 convergeOn)→ halt converged、不套 fix', () => { + const { action } = run({ ...base, round: 3, verdict: 'PERMANENT_CONVERGENCE' }) + assert.equal(action.halt, true) + assert.equal(action.reason, 'converged') + assert.equal(action.applyFixes, false) +}) + +test('CONVERGED(預設 convergeOn=PERMANENT)→ 不 halt、繼續迭代', () => { + const { action } = run({ ...base, round: 3, verdict: 'CONVERGED' }) + assert.equal(action.halt, false) + assert.equal(action.applyFixes, true) +}) + +test('自訂 --converge-on CONVERGED → CONVERGED 即 halt', () => { + const { action } = run({ ...base, round: 3, verdict: 'CONVERGED', convergeOn: 'CONVERGED' }) + assert.equal(action.halt, true) + assert.equal(action.reason, 'converged') +}) + +test('NEEDS_ITER 中段 → continue、applyFixes、前進一輪', () => { + const { action } = run({ ...base, round: 1, verdict: 'NEEDS_ITER_1' }) + assert.equal(action.halt, false) + assert.equal(action.applyFixes, true) + assert.equal(action.nextRound, 2) +}) + +test('最後一輪 NEEDS_ITER → 本輪仍套 fix,但 halt max-rounds', () => { + const { action } = run({ ...base, round: 12, maxRounds: 12, verdict: 'NEEDS_ITER_12' }) + assert.equal(action.applyFixes, true) // break 只在 converge,max-rounds 輪 fix 照套 + assert.equal(action.halt, true) + assert.equal(action.reason, 'max-rounds') +}) + +test('maxRounds clamp 下界:0 → 1(round 1 即達上限 → halt)', () => { + const { action } = run({ ...base, round: 1, maxRounds: 0, verdict: 'NEEDS_ITER_1' }) + assert.equal(action.halt, true) + assert.equal(action.reason, 'max-rounds') +}) + +test('maxRounds clamp 上界:1000 → 30(round 30 → nextRound 31 > 30 → halt)', () => { + const { action } = run({ ...base, round: 30, maxRounds: 1000, verdict: 'NEEDS_ITER_30' }) + assert.equal(action.halt, true) + assert.equal(action.reason, 'max-rounds') +}) + +test('mode 奇偶:round 1 → nextMode hybrid(2);round 2 → independent(3)', () => { + assert.equal(run({ ...base, round: 1 }).action.nextMode, 'hybrid') + assert.equal(run({ ...base, round: 2 }).action.nextMode, 'independent') +}) + +test('focus 輪替:末 3 筆同 focus 皆 CONVERGED → rotated、focus 進下一個', () => { + const { action } = run({ + ...base, round: 4, verdict: 'CONVERGED', currentFocus: 'method-section', + focusHistory: [{ focus: 'method-section', verdict: 'CONVERGED' }, { focus: 'method-section', verdict: 'CONVERGED' }], + }) + assert.equal(action.rotated, true) + assert.equal(action.nextFocus, 'proofs') // method-section 的下一個 +}) + +test('focus 不輪替:只有 2 筆(含本輪)→ 不足 3', () => { + const { action } = run({ + ...base, round: 2, verdict: 'CONVERGED', currentFocus: 'method-section', + focusHistory: [{ focus: 'method-section', verdict: 'CONVERGED' }], + }) + assert.equal(action.rotated, false) + assert.equal(action.nextFocus, 'method-section') +}) + +test('focus 不輪替:3 筆 CONVERGED 但 focus 不一致', () => { + const { action } = run({ + ...base, round: 4, verdict: 'CONVERGED', currentFocus: 'proofs', + focusHistory: [{ focus: 'method-section', verdict: 'CONVERGED' }, { focus: 'proofs', verdict: 'CONVERGED' }], + }) + assert.equal(action.rotated, false) +}) + +test('focus 不輪替:末 3 筆 focus 同但 verdict 含 NEEDS_ITER', () => { + const { action } = run({ + ...base, round: 4, verdict: 'CONVERGED', currentFocus: 'proofs', + focusHistory: [{ focus: 'proofs', verdict: 'NEEDS_ITER_2' }, { focus: 'proofs', verdict: 'CONVERGED' }], + }) + assert.equal(action.rotated, false) +}) + +test('pool 繞回:boundary-cases 輪替 → 回到 method-section', () => { + const { action } = run({ + ...base, round: 4, verdict: 'CONVERGED', currentFocus: 'boundary-cases', + focusHistory: [{ focus: 'boundary-cases', verdict: 'CONVERGED' }, { focus: 'boundary-cases', verdict: 'CONVERGED' }], + }) + assert.equal(action.rotated, true) + assert.equal(action.nextFocus, 'method-section') +}) + +test('focus 不在 pool(自訂)+ 輪替 → 落到 pool[0]', () => { + const { action } = run({ + ...base, round: 4, verdict: 'CONVERGED', currentFocus: 'custom-area', + focusHistory: [{ focus: 'custom-area', verdict: 'CONVERGED' }, { focus: 'custom-area', verdict: 'CONVERGED' }], + }) + assert.equal(action.rotated, true) + assert.equal(action.nextFocus, 'method-section') +}) + +test('自訂 focusPool 被採用', () => { + const { action } = run({ + ...base, round: 4, verdict: 'CONVERGED', currentFocus: 'a', focusPool: ['a', 'b'], + focusHistory: [{ focus: 'a', verdict: 'CONVERGED' }, { focus: 'a', verdict: 'CONVERGED' }], + }) + assert.equal(action.nextFocus, 'b') +}) + +test('round 非法(0)→ exit 2', () => { + assert.equal(run({ ...base, round: 0 }).status, 2) +}) + +test('非法 JSON → exit 2', () => { + let status + try { + execFileSync('node', [BIN], { input: '{not json', encoding: 'utf8' }) + status = 0 + } catch (e) { + status = e.status + } + assert.equal(status, 2) +}) + +let pass = 0 +let fail = 0 +for (const t of tests) { + try { + t.fn() + console.log(`ok - ${t.name}`) + pass++ + } catch (e) { + console.log(`not ok - ${t.name}\n ${e.message}`) + fail++ + } +} +console.log(`\n${pass}/${tests.length} passed`) +process.exit(fail === 0 ? 0 : 1) diff --git a/plugins/parallel-ai-agents/test/run.sh b/plugins/parallel-ai-agents/test/run.sh index 6687ede..4778fbe 100755 --- a/plugins/parallel-ai-agents/test/run.sh +++ b/plugins/parallel-ai-agents/test/run.sh @@ -5,7 +5,7 @@ set -euo pipefail cd "$(dirname "$0")/.." echo "── shellcheck (bash scripts) ──" -shellcheck bin/pai-build-diff bin/pai-parse-verdict +shellcheck bin/pai-build-diff bin/pai-parse-verdict bin/pai-iter-commit echo "── py_compile (python scripts) ──" python3 -m py_compile bin/pai-parse-lens-csv @@ -13,7 +13,7 @@ python3 -m py_compile bin/pai-parse-lens-csv echo "── bats test/ ──" bats test/ -echo "── harness regression (node) ──" -node test/ensemble-workflow.test.mjs +echo "── node tests ──" +for t in test/*.test.mjs; do echo " $t"; node "$t"; done echo "✓ 全部通過"