diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3bd1840..fa468fc 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,8 +8,8 @@ { "name": "parallel-ai-agents", "source": "./plugins/parallel-ai-agents", - "description": "v2.20.1: codex-call 補上 SSE error 事件的 message 提取路徑 (#25) — 直接呼叫 codex-call 時,HTTP 200 stream 內帶 message 的後端錯誤(如 server_is_overloaded)會顯示真實原因而非籠統的 \"Codex error\";經 ensemble 使用時仍受 #27 限制(消費端硬編碼失敗訊息)。v2.20.0: first-party codex-pro governance deep-integration (#23). v2.19.0: codexModel/codexEffort contract args (#22, caller-governed cross-model leg). 平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移", - "version": "2.20.1", + "description": "v2.21.0: 三層 lens 疊加 built-in → lens pack → user (#29) — lens 可由外部 pai-lenses plugin 與 ~/.claude/pai-lenses/ 疊加,新增一條 lens 從「改 JS + bump plugin」降為「改 CSV」;撞名需顯式 override,報表附 provenance 行。v2.20.1: codex-call 補上 SSE error 事件的 message 提取路徑 (#25)。v2.20.0: first-party codex-pro governance deep-integration (#23). v2.19.0: codexModel/codexEffort contract args (#22, caller-governed cross-model leg). 平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移", + "version": "2.21.0", "author": { "name": "Che Cheng" }, @@ -22,6 +22,25 @@ "claude", "codex" ] + }, + { + "name": "pai-lenses", + "source": { + "source": "github", + "repo": "PsychQuant/pai-lenses" + }, + "description": "parallel-ai-agents 的 lens pack(層 ②):以 CSV 提供可疊加的 reviewer lens。裝了之後四個 ensemble skill 的 lens 集合會自動疊上;撞名需在 CSV 標 override 才取代。新增一條 lens = 改 CSV + bump 版本,不必動 plugin 程式碼。", + "version": "0.1.0", + "author": { + "name": "Che Cheng" + }, + "category": "development", + "keywords": [ + "parallel-ai-agents", + "ensemble-review", + "lenses", + "code-review" + ] } ] } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46f6e6b..ec5b8c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,21 @@ jobs: 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 + run: python3 -m py_compile bin/pai-parse-lens-csv bin/pai-collect-lens-layers + + # builtin-lenses.csv is generated from the harness PROFILES. It drives nothing at runtime + # (#29 keeps the built-in baseline inside the harness), so a stale catalog is a DOCS defect, + # not a correctness one — but this repo's pitch is "contribute a lens", and a catalog that + # disagrees with the code sends contributors at the wrong thing. Three lines to prevent that. + - name: builtin-lenses.csv drift (regenerate → expect no diff) + shell: bash + run: | + set -o pipefail + bash references/regen-builtin-lenses.sh + git diff --exit-code -- references/builtin-lenses.csv || { + echo "::error::references/builtin-lenses.csv is stale — run 'bash references/regen-builtin-lenses.sh' and commit the result" + exit 1 + } - name: bats run: bats test/ diff --git a/plugins/parallel-ai-agents/.claude-plugin/plugin.json b/plugins/parallel-ai-agents/.claude-plugin/plugin.json index df04eef..3fac0ab 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": "v2.20.1: codex-call 補上 SSE error 事件的 message 提取路徑 (#25) — 直接呼叫 codex-call 時,HTTP 200 stream 內帶 message 的後端錯誤(如 server_is_overloaded)會顯示真實原因而非籠統的 \"Codex error\";經 ensemble 使用時仍受 #27 限制(消費端硬編碼失敗訊息)。v2.20.0: first-party skills deep-integrate codex-pro governance (#23, mirroring issue-driven-dev#264) — new references/codex-governance.md (canonical resolution: MIN_CODEX_PRO 0.7.0 gate, defaults.json base + two profile.yaml layers, fail-fast with install instruction when codexEnabled and codex-pro absent); ensemble-code-review / ensemble-academic-review / ensemble-compose(--codex) resolve and pass codexModel/codexEffort explicitly; engine + bin/codex-call baked defaults become release-time governance SNAPSHOTS (bumped to gpt-5.6-sol) — authoritative source is codex-pro's defaults.json; all first-party prose generation-neutral. v2.19.0: codexModel / codexEffort engine args (#22) — the cross-model codex leg's model and effort become caller-governed contract args (defaults gpt-5.5 / xhigh preserve pre-#22 behavior byte-identically). First consumer: issue-driven-dev passing codex-pro-resolved governance. 平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移", - "version": "2.20.1", + "version": "2.21.0", "author": { "name": "Che Cheng" } diff --git a/plugins/parallel-ai-agents/CHANGELOG.md b/plugins/parallel-ai-agents/CHANGELOG.md index 86b5eea..56baeed 100644 --- a/plugins/parallel-ai-agents/CHANGELOG.md +++ b/plugins/parallel-ai-agents/CHANGELOG.md @@ -11,6 +11,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.21.0] - 2026-08-01 + +### Added + +- **三層 lens 疊加:built-in → lens pack → user (#29)** — lens 集合不再只能來自 harness 的 `PROFILES`。`pai-lenses` plugin 的 `lenses/.csv`(層 ②)與 `~/.claude/pai-lenses/.csv`(層 ③)會自動疊上來。新增一條 lens 的成本從「改 JS + bump plugin + 同步 marketplace」降為「改 CSV + bump lens pack」;外部貢獻的**出口成本**同步下降 —— 收一條 lens 不再等於發一次 plugin release。設計見 `docs/superpowers/specs/2026-07-29-lens-pack-externalization-design.md`(D1–D8),契約見 `references/lens-layers.md`。 +- **`bin/pai-collect-lens-layers`** — 層 ②③ 的蒐集器(跨 marketplace semver glob 定位 lens pack、委派 `pai-parse-lens-csv` 解析、依序串接)。四個 ensemble skill 共用同一個入口。 +- **`stats.lensProvenance`** — harness 回報每個 lens 的處置(`added` / `overridden` / `ignored` + `overrodeFrom`),供報表的 provenance 行使用。 +- **報表新增 provenance 行** — 列出各層來源與版本、哪些 lens 被覆蓋。**沒裝 lens pack 時也會印**:量測儀器換了刻度卻不說,是 eval 偵測率數字前後不可比的根源。 +- **CSV 新增可選欄 `override`** — truthy(`1`/`true`/`yes`)時取代同 key 的既有 lens。語意是「我要取代那一條」,不是「我比較重要」。 +- **CI 新增 `builtin-lenses.csv` drift 檢查** — 跑 regen 後 `git diff --exit-code`。catalog 過期是文件缺陷(它不驅動 runtime),但會把想貢獻 lens 的人指向錯的檔案。 + +### Changed + +- **harness 的 lens 去重從純 first-wins 改為 override-aware** — 撞名時後來者只有標了 `override` 才勝出,且是**原位取代**(devil's-advocate 依 lens 順序讀 reviewer 完稿,移位會讓它看到的東西因與 override 無關的理由改變)。**未標記的行為與 2.20.1 逐位元相同**,向後相容鎖有專屬測試。 +- **`references/builtin-lenses.csv` 檔頭標明唯讀** 並指向 lens pack。註解列刻意放在 header **之後** —— 放前面會被 `csv.DictReader` 當成 header,整份檔案解析成空。 + +### Known limitations + +- **`profile.title` 沒有 `args` 覆寫路徑** —— 這是為何三個 profile skill 的 `profile` 必須維持原值而非改傳 `"custom"`(改了會讓每次審閱對所有 agent 自稱「自訂 ensemble」)。目前無驅動案例要求可覆寫。 +- **專案級 lens(第四層 `.claude/pai-lenses/`)未實作** —— 折疊形式已使增層只需延長來源序列,但無需求驅動(spec §11 明確排除)。 +- **多個 lens pack 並存的優先序未定義** —— 本版假設單一 pack;`pai-collect-lens-layers` 取 semver 最高者。 + ## [2.20.1] - 2026-07-31 ### Fixed diff --git a/plugins/parallel-ai-agents/bin/pai-collect-lens-layers b/plugins/parallel-ai-agents/bin/pai-collect-lens-layers new file mode 100755 index 0000000..84fba19 --- /dev/null +++ b/plugins/parallel-ai-agents/bin/pai-collect-lens-layers @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""pai-collect-lens-layers — 蒐集三層 lens 疊加的層 ②(lens pack)與層 ③(user)。 + +層 ① built-in **不在這裡**:它活在 harness 的 `PROFILES` 物件裡,而 workflow runtime +沒有檔案系統存取,所以 built-in 永遠由 harness 自己供給。這支只負責「skill 讀得到、 +harness 讀不到」的那兩層,結果由 skill 併進 `args.customLenses`。 + +用法:pai-collect-lens-layers + +輸出 JSON 到 stdout: + { + "lenses": [{"key","focus","needsSrt"?,"override"?,"_layer":"pack"|"user"}, ...], + "layers": [{"name","path","version","status"}, ...], + "warnings": [...] + } + +陣列順序即優先序:pack 在前、user 在後。撞名的判定(first-wins,除非標 `override`) +**全部交給 harness** —— 這裡只搬運、不去重。單一去重點才不會兩邊語意漂移(#29)。 + +CSV 解析一律委派 `bin/pai-parse-lens-csv`(BOM-safe、csv 模組、bats 覆蓋的單一真相源)。 + +status 語意(對應 spec D5「缺席與損壞分開處理」): + absent 沒裝 → 靜默,不產生 warning + ok 正常 + empty 檔案在、卻解析出 0 條 lens(多半是 header 打錯)→ 警告。 + 這是最危險的失敗模式:不警告的話它會安靜地什麼都不貢獻 + corrupt 解析器非零退出(讀不到 / 編碼壞)→ 警告 + unversioned 裝了 pai-lenses 卻沒有 semver 目錄(plugin.json 缺 version)→ 警告。 + 「裝了但不可用」不是「沒裝」,不可靜默 + +環境變數(測試注入用;預設值即正式路徑): + PAI_LENS_CACHE_ROOT 預設 ~/.claude/plugins/cache + PAI_USER_LENS_DIR 預設 ~/.claude/pai-lenses + +退出碼:0(含全部缺席);2 用法錯。 +""" +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +PACK_PLUGIN = "pai-lenses" +SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)") +PARSER = Path(__file__).resolve().parent / "pai-parse-lens-csv" + + +def _semver_key(name): + m = SEMVER.match(name) + return tuple(int(g) for g in m.groups()) if m else None + + +def find_pack_dir(cache_root): + """回傳 (dir, version, status)。跨 marketplace glob —— lens pack 註冊在哪個 + marketplace 是使用者端的事,不該寫死在這裡。""" + candidates = [] + homes = sorted(Path(cache_root).glob(f"*/{PACK_PLUGIN}")) if cache_root.exists() else [] + for home in homes: + if not home.is_dir(): + continue + for child in home.iterdir(): + if not child.is_dir(): + continue + key = _semver_key(child.name) + if key is not None: + candidates.append((key, child)) + if candidates: + key, path = max(candidates, key=lambda kv: kv[0]) + return path, ".".join(str(n) for n in key), "ok" + # 有 pai-lenses 目錄卻挑不出 semver 版本 = 裝了但不可用,不是沒裝 + if homes: + return None, None, "unversioned" + return None, None, "absent" + + +def parse_csv(path): + """→ (lenses, status, warning)。解析委派給 pai-parse-lens-csv。""" + if not path or not path.is_file(): + return [], "absent", None + proc = subprocess.run( + [sys.executable, str(PARSER), str(path)], + capture_output=True, text=True, + ) + if proc.returncode != 0: + return [], "corrupt", f"lens CSV 解析失敗({path}):{proc.stderr.strip()}" + try: + lenses = json.loads(proc.stdout) + except json.JSONDecodeError as e: + return [], "corrupt", f"lens CSV 解析輸出不是 JSON({path}):{e}" + if not lenses: + return [], "empty", ( + f"lens CSV 解析出 0 條 lens({path})—— header 是否為 key,focus?" + "檔案存在卻不貢獻任何 lens,多半是打錯而不是刻意留空。" + ) + return lenses, "ok", None + + +def main(): + if len(sys.argv) != 2: + print("用法:pai-collect-lens-layers ", file=sys.stderr) + return 2 + profile = sys.argv[1].strip() + if not profile: + print("用法:pai-collect-lens-layers ", file=sys.stderr) + return 2 + + cache_root = Path(os.environ.get("PAI_LENS_CACHE_ROOT") + or Path.home() / ".claude" / "plugins" / "cache") + user_dir = Path(os.environ.get("PAI_USER_LENS_DIR") + or Path.home() / ".claude" / "pai-lenses") + + lenses, layers, warnings = [], [], [] + + # ── 層 ②:lens pack ── + pack_dir, pack_version, pack_status = find_pack_dir(cache_root) + pack_csv = pack_dir / "lenses" / f"{profile}.csv" if pack_dir else None + if pack_status == "unversioned": + warnings.append( + f"{PACK_PLUGIN} 已安裝但找不到 semver 版本目錄({cache_root})—— " + "lens pack 的 plugin.json 必須有 version 欄位 —— 缺了的話 cache 目錄名會退回 " + "git commit SHA(官方文件的 fallback)或 unknown(實測見過),兩者都不是 semver、" + "定位不到。本層已略過。" + ) + elif pack_status == "ok": + got, csv_status, warn = parse_csv(pack_csv) + pack_status = csv_status + lenses += [dict(l, _layer="pack") for l in got] + if warn: + warnings.append(warn) + layers.append({ + "name": "pack", + "path": str(pack_csv) if pack_csv else None, + "version": pack_version, + "status": pack_status, + }) + + # ── 層 ③:user ── + user_csv = user_dir / f"{profile}.csv" + got, user_status, warn = parse_csv(user_csv) + lenses += [dict(l, _layer="user") for l in got] + if warn: + warnings.append(warn) + layers.append({ + "name": "user", + "path": str(user_csv), + "version": None, + "status": user_status, + }) + + json.dump({"lenses": lenses, "layers": layers, "warnings": warnings}, + sys.stdout, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/parallel-ai-agents/bin/pai-parse-lens-csv b/plugins/parallel-ai-agents/bin/pai-parse-lens-csv index d965623..66d2712 100755 --- a/plugins/parallel-ai-agents/bin/pai-parse-lens-csv +++ b/plugins/parallel-ai-agents/bin/pai-parse-lens-csv @@ -3,13 +3,14 @@ 用 csv 模組(絕不 naive split —— focus 是含逗號/中文標點的長 prose)。讀 UTF-8(含 BOM) CSV,每列 = 一個自訂 lens,輸出 JSON array 到 stdout: - [{"key": "...", "focus": "...", "needsSrt": true?}, ...] + [{"key": "...", "focus": "...", "needsSrt": true?, "override": true?}, ...] 契約: - - header 需含 key, focus(needsSrt 可選欄) + - header 需含 key, focus(needsSrt / override 為可選欄) - 跳過 key 或 focus 為空的列(與 harness 的 customLenses filter 對稱) - - needsSrt ∈ {1,true,yes}(不分大小寫)→ true;否則省略該欄 - - 保留列序、不去重(跨來源 first-wins 去重由 harness 做) + - needsSrt / override ∈ {1,true,yes}(不分大小寫)→ true;否則省略該欄 + - 保留列序、不去重(跨來源去重由 harness 做:預設 first-wins,標了 + override 的列才取代同 key 的既有 lens —— 見 #29) - 檔案不存在/不可讀 → stderr + exit 1 退出碼:0 成功(含空 array);1 錯誤;2 用法錯。 @@ -19,6 +20,12 @@ import json import sys +def _truthy(value): + """CSV 布林欄的共用判準(needsSrt / override)—— 兩欄必須同一套語意, + 否則寫 lens pack 的人得記兩組規則。""" + return str(value or "").strip().lower() in ("1", "true", "yes") + + def main(): if len(sys.argv) != 2: print("用法:pai-parse-lens-csv ", file=sys.stderr) @@ -35,8 +42,10 @@ def main(): if not key or not focus: continue item = {"key": key, "focus": focus} - if str(row.get("needsSrt", "")).strip().lower() in ("1", "true", "yes"): + if _truthy(row.get("needsSrt")): item["needsSrt"] = True + if _truthy(row.get("override")): + item["override"] = True out.append(item) except FileNotFoundError: print(f"CSV 檔不存在: {path}", file=sys.stderr) diff --git a/plugins/parallel-ai-agents/references/builtin-lenses.csv b/plugins/parallel-ai-agents/references/builtin-lenses.csv index bbd0f53..90ae188 100644 --- a/plugins/parallel-ai-agents/references/builtin-lenses.csv +++ b/plugins/parallel-ai-agents/references/builtin-lenses.csv @@ -1,4 +1,5 @@ profile,key,focus,needsSrt +# 唯讀 catalog — 編輯本檔不會改變任何行為。真源是 workflows/ensemble-workflow.js 的 PROFILES。要新增/修改 lens 請走 lens pack 或 ~/.claude/pai-lenses/(見 references/lens-layers.md)。本檔由 references/regen-builtin-lenses.sh 產生。 lecture,content-accuracy,"教學講義的知識正確性。檢查:(1) 統計概念定義是否正確(p-value、power、effect size、confidence interval);(2) 公式/數學符號有無寫錯(KaTeX 語法);(3) 心理學理論描述是否準確(如 Higgins, Regulatory Focus/Fit);(4) 因果推論有無把相關說成因果或過度推論;(5) 術語一致性(同概念是否同名);(6) 範例是否恰當支持概念。",false lecture,student-readability,"從「零程式基礎」學生角度的易懂程度。檢查:(1) 白話程度(專業術語有無解釋);(2) 邏輯銜接(段落跳躍是否太大);(3) 抽象概念有無搭配具體例子;(4) 表格/圖表是否幫助理解(還是增加混淆);(5) 篇幅平衡(重要概念是否足夠篇幅);(6) 結構導航(標題是否清楚、好找);(7)「重點整理」是否真涵蓋最重要內容。自問:「零基礎學生讀到這裡會卡住嗎?」",false lecture,completeness,"講義是否完整覆蓋上課內容。檢查:(1) 逐字稿覆蓋率(逐字稿有教但講義漏寫的重點,最重要);(2) 結構完整性(有無「重點整理」「課後作業」section;h2/h3 層級正確、無孤立 h3;h2 之間有 --- 分隔);(3) KaTeX/Mermaid(有數學符號處有無 KaTeX CDN、有路徑圖處有無 Mermaid 或 ASCII art);(4) 連結有效性(href 指向的檔案是否存在);(5) 缺少的教學元素(該有 blockquote 提醒、該用表格對比卻只用文字處)。",true diff --git a/plugins/parallel-ai-agents/references/lens-layers.md b/plugins/parallel-ai-agents/references/lens-layers.md new file mode 100644 index 0000000..4287f80 --- /dev/null +++ b/plugins/parallel-ai-agents/references/lens-layers.md @@ -0,0 +1,111 @@ +# 三層 lens 疊加(#29) + +所有 ensemble skill 的 lens 集合由**三層**疊出來。這份文件是四個 skill 共用的契約 —— +**要改行為就改這裡**,不要在個別 SKILL.md 裡各寫一份(三份文案會漂移,那正是 #29 +診斷時記下的風險)。同 `codex-governance.md` 的引用模式。 + +| 層 | 來源 | 誰讀它 | +|---|---|---| +| ① built-in | `workflows/ensemble-workflow.js` 的 `PROFILES` | **harness 自己**(workflow runtime 無檔案系統,讀不到任何 CSV) | +| ② lens pack | `pai-lenses` plugin 的 `lenses/.csv` | skill(Phase 2) | +| ③ user | `~/.claude/pai-lenses/.csv` | skill(Phase 2) | + +層 ① 由 harness 供給,層 ②③ 由 skill 蒐集後經 `args.customLenses` 送進去。**陣列順序即優先序**。 + +## Skill 該做的事 + +### 1. 蒐集(Phase 2,呼叫 Workflow 之前) + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/bin/pai-collect-lens-layers" +``` + +`` = 該 skill 的 profile 名(`code` / `academic` / `lecture`;`ensemble-compose` +用 `--base` 的值,預設不帶則跳過本步)。 + +輸出: + +```json +{ + "lenses": [{"key":"…","focus":"…","needsSrt":true?,"override":true?,"_layer":"pack"|"user"}], + "layers": [{"name":"pack"|"user","path":…,"version":…,"status":"…"}], + "warnings": ["…"] +} +``` + +把 `lenses` **原樣** append 進 `args.customLenses`(`override` 欄必須保留 —— 它就是取代語意的載體; +`_layer` 留著無妨,harness 只讀 `key`/`focus`/`needsSrt`/`override`)。若 skill 另有自己的 +custom lens(`--lens` / `--lens-file`),那些排在**後面**(使用者當下打的指令優先序最高)。 + +### 2. `profile` 維持原值 —— 不要改成 `"custom"` + +```json +{ "profile": "code", "customLenses": [ …層 ②③… ] } +``` + +**這一點是刻意的,不是疏漏。** 把 profile 換成 `"custom"` 會讓 built-in lens 不再是 baseline, +連帶換掉 `profile.title` —— 而 title 被寫進 reviewer、devil's-advocate、Codex 三種 prompt 的 +四個位置,且**沒有 `args` 覆寫路徑**。改了之後每次審閱都會對所有 agent 自稱「自訂 ensemble」, +是安靜的 prompt 品質退化。 + +### 3. 撞名判定全在 harness,skill 不去重 + +- 預設 **first-wins**:同 key 時先到的勝(built-in → include → 層 ② → 層 ③) +- CSV 標了 `override`(`1`/`true`/`yes`)的才**原位取代**先前同 key 的 lens +- 因此層 ③ 要蓋層 ② 同樣需要 `override` —— 判定對象是**當前累積集合**,不是 built-in + +單一去重點才不會兩邊語意漂移。skill 只搬運。 + +### 4. 報表印 provenance 行 + +合併/報表階段(各 skill 的 Phase 4,`ensemble-compose` 為 Phase 3)在 findings 表**之前**印一行: + +``` +Lens 來源:built-in 條 · pack +/⊕ · user +/⊕ +(⊕ = 覆蓋,被覆蓋者:security←pack) +``` + +資料來自兩處:`pai-collect-lens-layers` 的 `layers`(哪幾層在、版本多少)與 harness 回傳的 +`stats.lensProvenance`(每個 lens 的處置:`added` / `overridden` / `ignored`,`overrodeFrom` 指出被誰蓋)。 + +**沒裝 lens pack 時這行仍要印**(只會顯示 built-in),這樣「今天的報表跟昨天不同」永遠有據可查 —— +量測儀器換了刻度卻不說,是 eval 數字不可比的根源。 + +### 5. 警告照實印 + +`warnings` 非空 → 在 provenance 行下方逐條列出。**缺席是靜默的,損壞不是**(spec D5): + +| status | 意思 | 印警告? | +|---|---|---| +| `absent` | 沒裝 / 沒這個檔 | ❌ 靜默 | +| `ok` | 正常 | ❌ | +| `empty` | 檔案在、卻解析出 0 條(多半 header 打錯) | ✅ | +| `corrupt` | 解析器非零退出 | ✅ | +| `unversioned` | 裝了 `pai-lenses` 但無 semver 目錄(`plugin.json` 缺 `version`) | ✅ | + +`empty` 與 `unversioned` 是刻意加的防安靜失敗:前者會讓一個存在的檔案什麼都不貢獻, +後者會讓「裝了但定位不到」看起來像「沒裝」。 + +## Lens pack 的 CSV 格式 + +```csv +key,focus,needsSrt,override +perf,"檢查每個 hot path 的時間複雜度與不必要的重算",, +security,"(取代內建的 security lens)……",,true +``` + +- `key` / `focus` 必填,其餘可選 +- `needsSrt` / `override` 用同一套 truthy 判準:`1` / `true` / `yes`(不分大小寫) +- **一個 profile 一個檔**,profile 由檔名決定(`lenses/code.csv`) +- 解析一律走 `bin/pai-parse-lens-csv`(BOM-safe、csv 模組、bats 覆蓋),**不可** naive split —— + focus 是含逗號與中文標點的長 prose + +`override` 的語意是「**我要取代那一條**」,不是「我比較重要」。不標記 = 純新增。標記需要理由, +因為它會讓一條經過調校的 built-in lens消失。 + +## 相關 + +- `references/builtin-lenses.csv` — 層 ① 的唯讀 catalog(**編它不改變任何行為**;真源是 `PROFILES`) +- `bin/pai-collect-lens-layers` — 層 ②③ 的蒐集器 +- `bin/pai-parse-lens-csv` — CSV 解析的單一真相源 +- 設計 spec:`docs/superpowers/specs/2026-07-29-lens-pack-externalization-design.md`(D1–D8) diff --git a/plugins/parallel-ai-agents/references/regen-builtin-lenses.sh b/plugins/parallel-ai-agents/references/regen-builtin-lenses.sh index 277db8d..8799661 100755 --- a/plugins/parallel-ai-agents/references/regen-builtin-lenses.sh +++ b/plugins/parallel-ai-agents/references/regen-builtin-lenses.sh @@ -25,12 +25,23 @@ node --input-type=module < '"' + String(s).replace(/"/g, '""') + '"' -const rows = ['profile,key,focus,needsSrt'] +// The notice must sit AFTER the header, never before it: a leading '#' line would be read +// as the header row and the whole file would parse to []. Placed here it lands in the first +// column with key/focus empty, so pai-parse-lens-csv skips it (empty key) -- the file stays +// safe to feed to --lens-file while the warning is the first thing a human sees. +// NOTE: this heredoc is UNQUOTED (interpolates tmp/out), so no backticks in here -- the shell +// would run them as command substitution. +const rows = [ + 'profile,key,focus,needsSrt', + '# 唯讀 catalog — 編輯本檔不會改變任何行為。真源是 workflows/ensemble-workflow.js 的 PROFILES。' + + '要新增/修改 lens 請走 lens pack 或 ~/.claude/pai-lenses/(見 references/lens-layers.md)。' + + '本檔由 references/regen-builtin-lenses.sh 產生。', +] for (const [pname, p] of Object.entries(PROFILES)) { for (const l of (p.lenses || [])) { rows.push([pname, l.key, esc(l.focus), l.needsSrt ? 'true' : 'false'].join(',')) } } writeFileSync('${out}', rows.join('\n') + '\n') -console.log('wrote', rows.length - 1, 'built-in lenses →', '${out}') +console.log('wrote', rows.length - 2, 'built-in lenses →', '${out}') // −2: header + notice row NODE 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 f39fea7..a77c152 100644 --- a/plugins/parallel-ai-agents/skills/ensemble-academic-review/SKILL.md +++ b/plugins/parallel-ai-agents/skills/ensemble-academic-review/SKILL.md @@ -189,13 +189,15 @@ TaskCreate: "Final: merge all rounds" 每一輪這樣呼叫: 1. 解析 harness 絕對路徑 `${CLAUDE_PLUGIN_ROOT}/workflows/ensemble-workflow.js`;wrapper 絕對路徑 `${CLAUDE_PLUGIN_ROOT}/bin/codex-call`。並依 [`references/codex-governance.md`](../../references/codex-governance.md) 解析 `CODEX_MODEL`/`CODEX_EFFORT`(#23,codex-pro 契約;缺席 fail-fast)。 -2. 解析 dispatch model(#20):`PAI_AGENT_MODEL` 未設 → `opus`;設了但不在 `sonnet|opus|haiku|fable` → **abort with usage error**(fail-loud,不靜默換模型;engine 對顯式非法值亦會於派發前 throw 作第二層)。解析值經 `args.agentModel` 傳入。接著呼叫 `Workflow` tool,傳 `scriptPath` + `args`: +2. **蒐集 lens 層 ②③**(#29):`python3 "${CLAUDE_PLUGIN_ROOT}/bin/pai-collect-lens-layers" academic` → `lenses` **原樣**(含 `override` 欄)進 `args.customLenses`;`layers` / `warnings` 留給 Phase 4 的 provenance 行。完整契約見 [`references/lens-layers.md`](../../references/lens-layers.md)。⚠️ **`profile` 維持 `"academic"`,不可改成 `"custom"`**。多輪模式**每一輪都重新蒐集**(使用者可能在輪次之間改了 `~/.claude/pai-lenses/`)。 +3. 解析 dispatch model(#20):`PAI_AGENT_MODEL` 未設 → `opus`;設了但不在 `sonnet|opus|haiku|fable` → **abort with usage error**(fail-loud,不靜默換模型;engine 對顯式非法值亦會於派發前 throw 作第二層)。解析值經 `args.agentModel` 傳入。接著呼叫 `Workflow` tool,傳 `scriptPath` + `args`: ```json { "profile": "academic", "agentModel": "", "file": "", + "customLenses": "", "contextBlock": "<全文/文獻列表/ground-truth artifact 清單/focus>", "codexEnabled": true, "codexCallPath": "${CLAUDE_PLUGIN_ROOT}/bin/codex-call", @@ -513,6 +515,7 @@ Codex prompt 應包含: 產出本輪比較表: +0. **Provenance 行**(#29):本輪 findings 表之前先印 lens 來源一行(+ `warnings` 逐條)。格式與資料來源見 [`references/lens-layers.md`](../../references/lens-layers.md) §4/§5。多輪模式**每輪都印** —— 輪次之間 lens 集合若變了,那正是必須看得見的事。 1. **去重**:相同問題 → 合併,標註來源 2. **severity 以最高為準** 3. **Devil's Advocate 的反駁如果成立** → 升級 severity diff --git a/plugins/parallel-ai-agents/skills/ensemble-code-review/SKILL.md b/plugins/parallel-ai-agents/skills/ensemble-code-review/SKILL.md index 1d35149..32e63f8 100644 --- a/plugins/parallel-ai-agents/skills/ensemble-code-review/SKILL.md +++ b/plugins/parallel-ai-agents/skills/ensemble-code-review/SKILL.md @@ -134,7 +134,8 @@ esac 1. 解析 harness 絕對路徑:`${CLAUDE_PLUGIN_ROOT}/workflows/ensemble-workflow.js`。 2. **解析 codex 治理**(#23,codexEnabled=true 必經):依 [`references/codex-governance.md`](../../references/codex-governance.md) 從 codex-pro 契約解析 `CODEX_MODEL`/`CODEX_EFFORT`(缺席 fail-fast + 安裝指令)。 3. 解析 wrapper 絕對路徑:`${CLAUDE_PLUGIN_ROOT}/bin/codex-call`(**用絕對路徑**,不賭 workflow agent shell 的 PATH —— install-time PATH 注入是 version-pinned、可能 stale/不存在)。 -3. 解析 dispatch model(#20):`PAI_AGENT_MODEL` 未設 → `opus`;設了但不在 `sonnet|opus|haiku|fable` → **abort with usage error**(fail-loud,不靜默換模型;engine 對顯式非法值亦會於派發前 throw 作第二層)。解析值經 `args.agentModel` 傳入。接著呼叫 `Workflow` tool,傳 `scriptPath`(harness 絕對路徑)+ `args`: +4. **蒐集 lens 層 ②③**(#29):`python3 "${CLAUDE_PLUGIN_ROOT}/bin/pai-collect-lens-layers" code` → `lenses` **原樣**(含 `override` 欄)進 `args.customLenses`;`layers` / `warnings` 留給 Phase 4 的 provenance 行。完整契約見 [`references/lens-layers.md`](../../references/lens-layers.md)。⚠️ **`profile` 維持 `"code"`,不可改成 `"custom"`** —— 理由(`profile.title` 無 args 覆寫路徑)在該文件。 +5. 解析 dispatch model(#20):`PAI_AGENT_MODEL` 未設 → `opus`;設了但不在 `sonnet|opus|haiku|fable` → **abort with usage error**(fail-loud,不靜默換模型;engine 對顯式非法值亦會於派發前 throw 作第二層)。解析值經 `args.agentModel` 傳入。接著呼叫 `Workflow` tool,傳 `scriptPath`(harness 絕對路徑)+ `args`: ```json { @@ -142,6 +143,7 @@ esac "agentModel": "", "file": "<路徑模式:FILE_OR_DIR 絕對路徑>", "diffFile": "", + "customLenses": "", "contextBlock": "", "codexEnabled": true, "codexCallPath": "${CLAUDE_PLUGIN_ROOT}/bin/codex-call", @@ -155,7 +157,7 @@ esac - `codexEnabled: true` → Codex(gpt-5.x)作為 barrier 內第 4 個 agent,shell 出去呼 `codexCallPath`(**絕不** `codex exec`),fail-soft:timeout/error 只回 1 個 INFO finding(不阻擋 Claude-lens verdict)。 - `replicas` 預設 1(3 Claude lens + Codex + DA = 5,與 legacy 等價)。調高即大量 fan-out;harness 封頂 `MAX_AGENTS=16`(建議 Codex replica ≤2,fast = 2.5× credit)。 -4. Workflow 回 `{ findings, verdict, stats }`,`findings` 已 merge+dedup(severity 高者勝、跨 lens 不誤併)。Codex 的 finding `lens="codex"`、DA 的 `lens="devils-advocate"`。直接進 Phase 4 render,**不要**自己再 dedup。 +6. Workflow 回 `{ findings, verdict, stats }`,`findings` 已 merge+dedup(severity 高者勝、跨 lens 不誤併)。Codex 的 finding `lens="codex"`、DA 的 `lens="devils-advocate"`。直接進 Phase 4 render,**不要**自己再 dedup。`stats.lensProvenance` 供 Phase 4 的 provenance 行使用。 > 跨模型獨立性由 harness 保證:codexPrompt **不**提及 Claude reviewers、**不**餵 Codex 他們的 findings;DA 則**會**讀 Claude reviewers 的完稿 findings(兩者 prompt builder 分開)。DA 為 downstream node(讀完稿,非 live SendMessage)。 @@ -333,6 +335,7 @@ Codex prompt 應包含: 產出比較表: +0. **Provenance 行**(#29):findings 表之前先印 lens 來源一行(+ `warnings` 逐條)。格式與資料來源見 [`references/lens-layers.md`](../../references/lens-layers.md) §4/§5。**沒裝 lens pack 時也要印** —— 換了刻度不說,是 eval 數字不可比的根源。 1. **去重**:相同檔案 + 相似描述 → 合併,標註來源 `[team:architecture+codex]` 2. **severity 以最高為準**:如果 correctness 說 MEDIUM 但 codex 說 HIGH → HIGH 3. **Devil's Advocate 的反駁如果成立** → 升級 severity diff --git a/plugins/parallel-ai-agents/skills/ensemble-compose/SKILL.md b/plugins/parallel-ai-agents/skills/ensemble-compose/SKILL.md index 4de3bfa..48d07ed 100644 --- a/plugins/parallel-ai-agents/skills/ensemble-compose/SKILL.md +++ b/plugins/parallel-ai-agents/skills/ensemble-compose/SKILL.md @@ -50,7 +50,7 @@ allowed-tools: > **devil's-advocate 與 Codex 不在上表(也不在 CSV)**:每個 ensemble 都會**自動**加一個 devil's-advocate(讀同儕完稿反駁、fail-closed),`--codex` 則加跨模型 Codex 盲驗。兩者由 harness 管理、**不可 `--include`**(上表只列可挑/可仿照的 reviewer lens)。所以你不用、也不該把 `devils-advocate` 放進 lens 清單或 CSV —— 它本來就在每次 run 裡。 -> 完整 focus 文字見 [`references/builtin-lenses.csv`](../../references/builtin-lenses.csv)(從 harness `PROFILES` 自動產生的 reference)。⚠️ 這是**唯讀 reference** —— 編它**不會**改 harness(內建 lens 的真源是 code,且 workflow runtime 無 FS 讀不到)。要客製請用下方 `--lens-file` 自己的 CSV、或 `--lens`。改了 `PROFILES` 後重生此檔:`bash references/regen-builtin-lenses.sh`。 +> 完整 focus 文字見 [`references/builtin-lenses.csv`](../../references/builtin-lenses.csv)(從 harness `PROFILES` 自動產生的 reference)。⚠️ 這是**唯讀 reference** —— 編它**不會**改 harness(內建 lens 的真源是 code,且 workflow runtime 無 FS 讀不到)。要客製有三條路:**lens pack**(`pai-lenses` plugin,可跨專案重用、可 PR 回上游 —— 見 [`references/lens-layers.md`](../../references/lens-layers.md))、`~/.claude/pai-lenses/.csv`(只給自己)、或當下的 `--lens-file` / `--lens`。改了 `PROFILES` 後重生此檔:`bash references/regen-builtin-lenses.sh`。 ## CSV lens 包(`--lens-file`) @@ -65,7 +65,7 @@ a11y,"檢查 ARIA 標籤、鍵盤導航、色彩對比、focus 順序",false i18n,"檢查硬編字串、日期/數字/貨幣格式、RTL、複數規則",false ``` -- `key`:短名(lens 標識;與其他來源同名時 first-wins)。 +- `key`:短名(lens 標識;與其他來源同名時 first-wins,除非該列標了 `override`)。 - `focus`:給 agent 的**具體**檢查清單(像內建 lens 那樣逐點,太空泛審閱品質差)。**含逗號/中文標點必用 `"..."` 包住。** - `needsSrt`(可選欄):`true` 時該 lens 收到 `srtFile`(lecture 場景);省略/`false` 即一般。 @@ -76,7 +76,28 @@ i18n,"檢查硬編字串、日期/數字/貨幣格式、RTL、複數規則",fals python3 "${CLAUDE_PLUGIN_ROOT}/bin/pai-parse-lens-csv" "$LENS_FILE" ``` -把輸出 array append 進 `customLenses`(與 `--lens` 合併;同 key first-wins 由 harness 處理)。**要改 CSV 解析就改 `bin/pai-parse-lens-csv` + `test/pai-parse-lens-csv.bats`,不要在這裡重寫 inline。** 範本見 `references/example-lenses.csv`。 +把輸出 array append 進 `customLenses`(與 `--lens` 合併)。撞名時**預設 first-wins 由 harness 處理,但標了 `override` 欄的列會取代同 key 的既有 lens**(#29,見下方 § 三層 lens 疊加)。**要改 CSV 解析就改 `bin/pai-parse-lens-csv` + `test/pai-parse-lens-csv.bats`,不要在這裡重寫 inline。** 範本見 `references/example-lenses.csv`。 + +### CSV 欄位 + +| 欄 | 必填 | 說明 | +|---|---|---| +| `key` | ✅ | lens 識別名 | +| `focus` | ✅ | 該 reviewer 的檢查清單(長 prose,可含逗號 → 記得 quote)| +| `needsSrt` | — | truthy 時標記此 lens 需要 SRT | +| `override` | — | truthy 時**取代**同 key 的既有 lens;不標記則撞名時被忽略(#29)| + +`needsSrt` / `override` 的 truthy 判準相同:`1` / `true` / `yes`(不分大小寫)。 + +## 三層 lens 疊加(#29) + +`--base ` 帶入時,該 profile 的 lens pack 與 user 層會**自動疊上來**: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/bin/pai-collect-lens-layers" "$BASE_PROFILE" +``` + +輸出的 `lenses` 排在 `--lens` / `--lens-file` **之前**(使用者當下打的指令優先序最高),`layers` / `warnings` 進 Phase 3 的 provenance 行。不帶 `--base` → 跳過本步(沒有 profile 就沒有對應的 pack 檔)。完整契約見 [`references/lens-layers.md`](../../references/lens-layers.md)。 ## 執行流程 @@ -107,7 +128,8 @@ FILE_OR_DIR — 審閱對象(缺則問使用者) - `--base` → `profile`(預設 `"custom"`) - `--include code.security` → `includeLenses: ["code.security", ...]` - `--lens 'perf: 檢查每個迴圈的時間複雜度...'` → `customLenses: [{key:"perf", focus:"檢查每個迴圈的時間複雜度..."}]` - - `--lens-file pack.csv` → **用 python3 csv 模組解析**(見 § CSV lens 包;focus 含逗號/中文標點,**不可** naive split)→ 每列轉 `{key, focus, needsSrt?}` append 到 `customLenses`(與 `--lens` 合併;同 key first-wins) + - `--lens-file pack.csv` → **用 python3 csv 模組解析**(見 § CSV lens 包;focus 含逗號/中文標點,**不可** naive split)→ 每列轉 `{key, focus, needsSrt?, override?}` append 到 `customLenses`(與 `--lens` 合併;撞名 first-wins,標 `override` 者取代) + - `--base ` 帶入時,先跑 `bin/pai-collect-lens-layers "$BASE_PROFILE"`,其 `lenses` 排在 `--lens` / `--lens-file` 之前(見 § 三層 lens 疊加) - `--replicas` / `--max-agents` / `--codex` → `replicas` / `maxAgents` / `codexEnabled`(`--codex` 生效時另依 [`references/codex-governance.md`](../../references/codex-governance.md) 解析並傳 `codexModel`/`codexEffort`,#23) 3. 解析 dispatch model(#20):`PAI_AGENT_MODEL` 未設 → `opus`;設了但不在 `sonnet|opus|haiku|fable` → **abort with usage error**(fail-loud,不靜默換模型;engine 對顯式非法值亦會於派發前 throw 作第二層)。解析值經 `args.agentModel` 傳入。接著呼叫 `Workflow` tool,傳 `scriptPath` + `args`: @@ -128,12 +150,14 @@ FILE_OR_DIR — 審閱對象(缺則問使用者) } ``` - - harness 組裝順序:base profile lens → include(跨 profile)→ custom,**key 去重 first-wins**,再砍到 `maxAgents − codex − DA`、replicas 依剩餘 budget clamp。**組合自由但成本始終有 ceiling。** -4. Workflow 回 `{ findings, verdict, stats }`,已 merge+dedup。`stats.reviewers` 列出實際跑了哪些 lens。 + - harness 組裝順序:base profile lens → include(跨 profile)→ custom,**key 去重預設 first-wins,標了 `override` 的後來者原位取代**(#29),再砍到 `maxAgents − codex − DA`、replicas 依剩餘 budget clamp。**組合自由但成本始終有 ceiling。** +4. Workflow 回 `{ findings, verdict, stats }`,已 merge+dedup。`stats.reviewers` 列出實際跑了哪些 lens;`stats.lensProvenance` 列出每個 lens 的處置(`added` / `overridden` / `ignored`)供 provenance 行使用。 ### Phase 3: 渲染比較表 -依 `lens` 分組 render(同其他 ensemble skill 的比較表:# / 嚴重性 / title—body / lens·file:line)。**不要**再 dedup。附 `stats`(實際 agent 數、被砍掉的 lens 若有)。 +**先印 provenance 行**(#29):lens 來源一行 + `warnings` 逐條,格式與資料來源見 [`references/lens-layers.md`](../../references/lens-layers.md) §4/§5。 + +接著依 `lens` 分組 render(同其他 ensemble skill 的比較表:# / 嚴重性 / title—body / lens·file:line)。**不要**再 dedup。附 `stats`(實際 agent 數、被砍掉的 lens 若有)。 ## Legacy fallback(無 Workflow tool) @@ -144,6 +168,6 @@ FILE_OR_DIR — 審閱對象(缺則問使用者) - **至少一個 lens**。空組合 → harness 回 HIGH integrity finding。 - **自訂 lens 的 focus 要具體**:給 agent 明確檢查清單(像內建 lens 那樣逐點),focus 太空泛審閱品質會差。 - **maxAgents 硬上限 30**;成本由 `lens 數 × replicas + codex + DA` 決定。 -- **跨 profile 同名 lens**:first-wins(base 先、include 次、custom 後),不會疊兩份。 +- **跨 profile 同名 lens**:first-wins(base 先、include 次、custom 後),不會疊兩份。要蓋掉先到者,該列必須顯式標 `override`(#29)—— 「取代一條調校過的 lens」是需要理由的動作,不該靠順序意外發生。 - **findings/報表契約與其他 ensemble skill 一致** —— 下游消費方式相同。 - **`--lens-file` 一律走 `bin/pai-parse-lens-csv`**(csv 模組、BOM-safe、bats 覆蓋),不可 naive split —— focus 是長 prose,逗號/中文標點會把欄位切爛。CSV 由 skill(主 session,有 Read)讀,**不是** harness(runtime 無 FS)。改解析邏輯改 script + 測試。 diff --git a/plugins/parallel-ai-agents/skills/ensemble-lecture-review/SKILL.md b/plugins/parallel-ai-agents/skills/ensemble-lecture-review/SKILL.md index d581cc9..1da5621 100644 --- a/plugins/parallel-ai-agents/skills/ensemble-lecture-review/SKILL.md +++ b/plugins/parallel-ai-agents/skills/ensemble-lecture-review/SKILL.md @@ -58,7 +58,8 @@ Arguments: #### Backend A — Workflow(預設) 1. 解析 harness 絕對路徑:`${CLAUDE_PLUGIN_ROOT}/workflows/ensemble-workflow.js`。 -2. 解析 dispatch model(#20):`PAI_AGENT_MODEL` 未設 → `opus`;設了但不在 `sonnet|opus|haiku|fable` → **abort with usage error**(fail-loud,不靜默換模型;engine 對顯式非法值亦會於派發前 throw 作第二層)。解析值經 `args.agentModel` 傳入。接著呼叫 `Workflow` tool,傳 `scriptPath`(上述絕對路徑)+ `args`: +2. **蒐集 lens 層 ②③**(#29):`python3 "${CLAUDE_PLUGIN_ROOT}/bin/pai-collect-lens-layers" lecture` → `lenses` **原樣**(含 `override` 與 `needsSrt` 欄)進 `args.customLenses`;`layers` / `warnings` 留給 Phase 4 的 provenance 行。完整契約見 [`references/lens-layers.md`](../../references/lens-layers.md)。⚠️ **`profile` 維持 `"lecture"`,不可改成 `"custom"`**。外部 lens 同樣可標 `needsSrt` —— 沒有 SRT 時 harness 會照既有規則處理。 +3. 解析 dispatch model(#20):`PAI_AGENT_MODEL` 未設 → `opus`;設了但不在 `sonnet|opus|haiku|fable` → **abort with usage error**(fail-loud,不靜默換模型;engine 對顯式非法值亦會於派發前 throw 作第二層)。解析值經 `args.agentModel` 傳入。接著呼叫 `Workflow` tool,傳 `scriptPath`(上述絕對路徑)+ `args`: ```json { @@ -66,6 +67,7 @@ Arguments: "agentModel": "", "file": "<講義 HTML 絕對路徑>", "srtFile": "", + "customLenses": "", "contextBlock": "", "replicas": 1, "codexEnabled": false @@ -201,7 +203,7 @@ SRT instruction(有 SRT 時注入): - **Backend A(workflow)**:`findings` 已由 harness merge+dedup(severity 高者勝、跨 lens 不誤併),直接把 array render 成下表(一 finding 一列:嚴重性 / title—body / lens·file:line)。**不要**再跑一次 dedup。 - **Backend B(legacy)**:主 session Claude 讀取 4 個 teammate 結果,手動合併去重。 -產出比較表: +產出比較表。**表之前先印 provenance 行**(#29):lens 來源一行 + `warnings` 逐條,格式與資料來源見 [`references/lens-layers.md`](../../references/lens-layers.md) §4/§5。 ```markdown ## Ensemble Lecture Review: {FILE} diff --git a/plugins/parallel-ai-agents/test/ensemble-workflow.test.mjs b/plugins/parallel-ai-agents/test/ensemble-workflow.test.mjs index f092307..302e22b 100644 --- a/plugins/parallel-ai-agents/test/ensemble-workflow.test.mjs +++ b/plugins/parallel-ai-agents/test/ensemble-workflow.test.mjs @@ -143,6 +143,84 @@ test('mergeDedup robust to malformed severity (no crash, finding kept, FINDINGS) assert.equal(out.verdict, 'FINDINGS') // 'BOGUS' !== 'INFO' }) +// ── #29 lens-layer override semantics ─────────────────────────────────────── +// 三層 lens 疊加(built-in → lens pack → user)在 harness 內折疊,所以「撞名時誰勝出」 +// 是 harness 的不變式,不是 skill 側的膠水。lens pack 的 CSV 用 override 欄宣告取代意圖。 + +// 捕捉每個 reviewer 的 prompt —— 這是唯一能分辨「哪一份 focus 真的出貨」的觀測點。 +const captureLenses = () => { + const seen = [] + const impl = async (prompt, o) => { + if (o && typeof o.label === 'string' && o.label.startsWith('review:')) { + seen.push({ key: o.label.slice('review:'.length), prompt: String(prompt) }) + } + return { findings: [] } + } + return { seen, impl } +} + +test('#29 向後相容鎖:未標記的撞名仍是 first-wins(built-in 勝)', async () => { + const { seen, impl } = captureLenses() + await runEnsemble( + { profile: 'code', file: '/x', codexEnabled: false, + customLenses: [{ key: 'security', focus: 'CUSTOM_SECURITY_FOCUS' }] }, + impl + ) + const sec = seen.filter((s) => s.key === 'security') + assert.equal(sec.length, 1, 'security 被複製成兩個 reviewer') + assert.ok(!sec[0].prompt.includes('CUSTOM_SECURITY_FOCUS'), + '未標記的 custom lens 蓋掉了 built-in —— first-wins 被改壞了') +}) + +test('#29 override:true 取代同 key 的 built-in lens', async () => { + const { seen, impl } = captureLenses() + await runEnsemble( + { profile: 'code', file: '/x', codexEnabled: false, + customLenses: [{ key: 'security', focus: 'CUSTOM_SECURITY_FOCUS', override: true }] }, + impl + ) + const sec = seen.filter((s) => s.key === 'security') + assert.equal(sec.length, 1, 'override 必須是取代,不是追加') + assert.ok(sec[0].prompt.includes('CUSTOM_SECURITY_FOCUS'), + 'override:true 沒生效 —— 出貨的仍是 built-in 的 focus') +}) + +test('#29 override 是原位取代(lens 順序不變)', async () => { + const base = captureLenses() + await runEnsemble({ profile: 'code', file: '/x', codexEnabled: false }, base.impl) + const baseOrder = base.seen.map((s) => s.key) + assert.ok(baseOrder.length >= 2, 'code profile 應有多個 lens') + + const over = captureLenses() + await runEnsemble( + { profile: 'code', file: '/x', codexEnabled: false, + customLenses: [{ key: baseOrder[0], focus: 'OVERRIDDEN', override: true }] }, + over.impl + ) + assert.deepEqual(over.seen.map((s) => s.key), baseOrder, + 'override 把 lens 移位了 —— devil\'s-advocate 依序讀 reviewer 完稿,位置是契約的一部分') +}) + +test('#29 stats.lensProvenance 記錄 added / overridden / ignored', async () => { + const out = await runEnsemble( + { profile: 'code', file: '/x', codexEnabled: false, + customLenses: [ + { key: 'security', focus: 'REPLACED', override: true }, + { key: 'correctness', focus: 'SHOULD_BE_IGNORED' }, + { key: 'perf', focus: '全新的 lens' }, + ] }, + allPass + ) + const prov = out.stats.lensProvenance + assert.ok(Array.isArray(prov), 'stats.lensProvenance 缺席') + // 每個「進來的」lens 一筆(含 built-in),後來者覆寫同 key 的紀錄 → 取最後一筆為最終處置 + const byKey = Object.fromEntries(prov.map((p) => [p.key, p])) + assert.equal(byKey.security.action, 'overridden', JSON.stringify(prov)) + assert.equal(byKey.security.overrodeFrom, 'builtin', JSON.stringify(prov)) + assert.equal(byKey.correctness.action, 'ignored', JSON.stringify(prov)) + assert.equal(byKey.perf.action, 'added', JSON.stringify(prov)) +}) + let pass = 0 let fail = 0 for (const t of tests) { diff --git a/plugins/parallel-ai-agents/test/pai-collect-lens-layers.bats b/plugins/parallel-ai-agents/test/pai-collect-lens-layers.bats new file mode 100644 index 0000000..a76d691 --- /dev/null +++ b/plugins/parallel-ai-agents/test/pai-collect-lens-layers.bats @@ -0,0 +1,152 @@ +#!/usr/bin/env bats +# pai-collect-lens-layers(#29 三層 lens 疊加的層 ②③ 蒐集器)的 bats 測試。 +# +# 層 ① built-in 不在這裡 —— 它活在 harness 的 PROFILES 裡,這支只負責 +# lens pack(層 ②)與 user(層 ③)。所以本檔不斷言任何 builtin 層。 +# +# 鐵律:全部用 BATS_TEST_TMPDIR 自建的假 cache 與假 user 目錄, +# 絕不讀真實 lens pack 或開發機的 ~/.claude/pai-lenses/。 + +setup() { + BIN="${BATS_TEST_DIRNAME}/../bin/pai-collect-lens-layers" + CACHE="${BATS_TEST_TMPDIR}/cache" + USERDIR="${BATS_TEST_TMPDIR}/userlens" + export PAI_LENS_CACHE_ROOT="$CACHE" + export PAI_USER_LENS_DIR="$USERDIR" +} + +# 造一個假的 lens pack cache 目錄:mkpack +mkpack() { + PACK="${CACHE}/$1/pai-lenses/$2" + mkdir -p "${PACK}/lenses" +} + +# 便利斷言:用 python 讀 stdout JSON +jq_py() { python3 -c "$1" "$2"; } + +@test "兩層都有 → pack 在前、user 在後,各自標 _layer" { + mkpack psychquant 1.2.0 + printf 'key,focus\npack-lens,來自 pack\n' > "${PACK}/lenses/code.csv" + mkdir -p "$USERDIR" + printf 'key,focus\nuser-lens,來自 user\n' > "${USERDIR}/code.csv" + run "$BIN" code + [ "$status" -eq 0 ] + jq_py ' +import json,sys +d=json.loads(sys.argv[1]) +ls=[(x["key"],x["_layer"]) for x in d["lenses"]] +assert ls==[("pack-lens","pack"),("user-lens","user")], ls +' "$output" +} + +@test "兩層皆缺席 → 靜默(exit 0、lenses 空、warnings 空)" { + run "$BIN" code + [ "$status" -eq 0 ] + jq_py ' +import json,sys +d=json.loads(sys.argv[1]) +assert d["lenses"]==[], d["lenses"] +assert d["warnings"]==[], d["warnings"] +assert {l["name"]:l["status"] for l in d["layers"]}=={"pack":"absent","user":"absent"}, d["layers"] +' "$output" +} + +@test "pack 有、user 缺席 → 只回 pack,不警告" { + mkpack psychquant 0.1.0 + printf 'key,focus\na,fa\n' > "${PACK}/lenses/code.csv" + run "$BIN" code + [ "$status" -eq 0 ] + jq_py ' +import json,sys +d=json.loads(sys.argv[1]) +assert [x["key"] for x in d["lenses"]]==["a"], d["lenses"] +assert d["warnings"]==[], d["warnings"] +' "$output" +} + +@test "pack 裝了但無 semver 目錄(plugin.json 缺 version)→ 警告,不當成沒裝" { + mkdir -p "${CACHE}/psychquant/pai-lenses/unknown/lenses" + printf 'key,focus\na,fa\n' > "${CACHE}/psychquant/pai-lenses/unknown/lenses/code.csv" + run "$BIN" code + [ "$status" -eq 0 ] + jq_py ' +import json,sys +d=json.loads(sys.argv[1]) +st={l["name"]:l["status"] for l in d["layers"]} +assert st["pack"]=="unversioned", st +assert any("version" in w for w in d["warnings"]), d["warnings"] +' "$output" +} + +@test "pack CSV 存在但解析出 0 條(header 打錯)→ 警告,不靜默吞掉" { + mkpack psychquant 1.0.0 + printf 'keys,focuses\na,fa\n' > "${PACK}/lenses/code.csv" + run "$BIN" code + [ "$status" -eq 0 ] + jq_py ' +import json,sys +d=json.loads(sys.argv[1]) +st={l["name"]:l["status"] for l in d["layers"]} +assert st["pack"]=="empty", st +assert d["warnings"], "header 打錯卻沒有任何警告" +' "$output" +} + +@test "一層壞掉不影響另一層(user 照常出貨)" { + mkpack psychquant 1.0.0 + printf 'keys,focuses\na,fa\n' > "${PACK}/lenses/code.csv" + mkdir -p "$USERDIR" + printf 'key,focus\nu,fu\n' > "${USERDIR}/code.csv" + run "$BIN" code + [ "$status" -eq 0 ] + jq_py ' +import json,sys +d=json.loads(sys.argv[1]) +assert [x["key"] for x in d["lenses"]]==["u"], d["lenses"] +' "$output" +} + +@test "多版本並存 → 取最高 semver(10 > 9,非字典序)" { + mkpack psychquant 1.9.0 + printf 'key,focus\nold,舊版\n' > "${PACK}/lenses/code.csv" + mkpack psychquant 1.10.0 + printf 'key,focus\nnew,新版\n' > "${PACK}/lenses/code.csv" + run "$BIN" code + [ "$status" -eq 0 ] + jq_py ' +import json,sys +d=json.loads(sys.argv[1]) +assert [x["key"] for x in d["lenses"]]==["new"], d["lenses"] +v=[l["version"] for l in d["layers"] if l["name"]=="pack"][0] +assert v=="1.10.0", v +' "$output" +} + +@test "profile 決定檔名 —— 要 academic 不會拿到 code.csv" { + mkpack psychquant 1.0.0 + printf 'key,focus\nc,程式\n' > "${PACK}/lenses/code.csv" + run "$BIN" academic + [ "$status" -eq 0 ] + jq_py ' +import json,sys +d=json.loads(sys.argv[1]) +assert d["lenses"]==[], d["lenses"] +' "$output" +} + +@test "override 欄穿透到輸出(harness 才是判定者,這裡只搬運)" { + mkpack psychquant 1.0.0 + printf 'key,focus,override\nsecurity,取代內建的,true\n' > "${PACK}/lenses/code.csv" + run "$BIN" code + [ "$status" -eq 0 ] + jq_py ' +import json,sys +d=json.loads(sys.argv[1]) +assert d["lenses"][0].get("override") is True, d["lenses"] +' "$output" +} + +@test "無參數 → exit 2(用法)" { + run "$BIN" + [ "$status" -eq 2 ] +} diff --git a/plugins/parallel-ai-agents/test/pai-parse-lens-csv.bats b/plugins/parallel-ai-agents/test/pai-parse-lens-csv.bats index 8a9af59..8d8a23e 100644 --- a/plugins/parallel-ai-agents/test/pai-parse-lens-csv.bats +++ b/plugins/parallel-ai-agents/test/pai-parse-lens-csv.bats @@ -52,6 +52,35 @@ assert d=={"a":True,"b":True,"c":True,"d":None,"e":None}, d ' "$output" } +@test "override true/yes/1 → true;false/空/缺欄 → 省略(#29)" { + printf 'key,focus,override\na,fa,true\nb,fb,YES\nc,fc,1\nd,fd,false\ne,fe,\n' > "$CSV" + run "$BIN" "$CSV" + [ "$status" -eq 0 ] + python3 -c ' +import json,sys +d={x["key"]:x.get("override") for x in json.loads(sys.argv[1])} +assert d=={"a":True,"b":True,"c":True,"d":None,"e":None}, d +' "$output" +} + +@test "override 與 needsSrt 互不干擾(#29)" { + printf 'key,focus,needsSrt,override\na,fa,true,false\nb,fb,false,true\n' > "$CSV" + run "$BIN" "$CSV" + [ "$status" -eq 0 ] + python3 -c ' +import json,sys +d={x["key"]:(x.get("needsSrt"),x.get("override")) for x in json.loads(sys.argv[1])} +assert d=={"a":(True,None),"b":(None,True)}, d +' "$output" +} + +@test "無 override 欄的舊 CSV → 每列都不帶 override(向後相容,#29)" { + printf 'key,focus\nperf,檢查複雜度\n' > "$CSV" + run "$BIN" "$CSV" + [ "$status" -eq 0 ] + [[ "$output" != *'"override"'* ]] +} + @test "空 key 或空 focus 的列被跳過" { printf 'key,focus\n,有focus沒key\n有key沒focus,\nok,好\n' > "$CSV" run "$BIN" "$CSV" diff --git a/plugins/parallel-ai-agents/workflows/ensemble-workflow.js b/plugins/parallel-ai-agents/workflows/ensemble-workflow.js index a264e62..bc6d770 100644 --- a/plugins/parallel-ai-agents/workflows/ensemble-workflow.js +++ b/plugins/parallel-ai-agents/workflows/ensemble-workflow.js @@ -440,7 +440,10 @@ if (!profile) { // Start from the profile's built-in lenses, optionally pull specific lenses from OTHER profiles // (args.includeLenses = ["code.security", "academic.methodology"]), append caller-defined custom // lenses (args.customLenses = [{key, focus, needsSrt?}]), then drop any in args.disableLenses. -// Dedup by key, FIRST wins (built-in → included → custom) so a name clash is predictable, not doubled. +// Dedup by key, FIRST wins (built-in → included → custom) so a name clash is predictable, not doubled +// — UNLESS the later entry sets `override: true`, which is how a lens pack declares "replace that one" +// (#29). Unmarked clashes behave exactly as they did before #29, so every pre-existing caller is +// byte-for-byte unaffected; only an explicit opt-in changes the outcome. // profile:"custom" ships no built-in lenses, so a fully bespoke ensemble comes entirely from // includeLenses + customLenses. const disabled = Array.isArray(A.disableLenses) ? A.disableLenses : [] @@ -455,17 +458,42 @@ const included = (Array.isArray(A.includeLenses) ? A.includeLenses : []) .filter(Boolean) const customs = (Array.isArray(A.customLenses) ? A.customLenses : []) .filter((l) => l && typeof l.key === 'string' && l.key.trim() && typeof l.focus === 'string' && l.focus.trim()) - .map((l) => ({ key: l.key.trim(), focus: l.focus, needsSrt: !!l.needsSrt })) -const seen = new Set() -let assembled = [...profile.lenses, ...included, ...customs].filter( - (l) => !disabled.includes(l.key) && !seen.has(l.key) && seen.add(l.key) -) + .map((l) => ({ key: l.key.trim(), focus: l.focus, needsSrt: !!l.needsSrt, override: !!l.override })) + +// Fold the layers in order, recording what happened to each incoming lens. Replacement is IN PLACE: +// the devil's-advocate reads the reviewers' write-ups in lens order, so quietly moving an overridden +// lens to the end of the set would change what it sees for reasons unrelated to the override. +const lensProvenance = [] +const slotByKey = new Map() +const originBySlot = [] // origin of whichever lens currently OCCUPIES each slot (an override changes it) +let assembled = [] +for (const [origin, group] of [['builtin', profile.lenses], ['include', included], ['custom', customs]]) { + for (const l of group) { + if (disabled.includes(l.key)) continue + const lens = { key: l.key, focus: l.focus, ...(l.needsSrt ? { needsSrt: true } : {}) } + const slot = slotByKey.get(l.key) + if (slot === undefined) { + slotByKey.set(l.key, assembled.length) + originBySlot[assembled.length] = origin + assembled = [...assembled, lens] + lensProvenance.push({ key: l.key, origin, action: 'added' }) + } else if (l.override) { + lensProvenance.push({ key: l.key, origin, action: 'overridden', overrodeFrom: originBySlot[slot] }) + originBySlot[slot] = origin + assembled = assembled.map((cur, i) => (i === slot ? lens : cur)) + } else { + lensProvenance.push({ key: l.key, origin, action: 'ignored' }) + } + } +} // Empty after composition → bail (covers profile:"custom" with no include/custom, or disabling all). if (assembled.length === 0) { return { findings: [{ lens: 'harness', severity: 'HIGH', title: 'no active lenses after composition', file: null, body: `profile "${A.profile}" + includeLenses + customLenses − disableLenses resolved to zero reviewers. Supply at least one lens, or fall back to the legacy backend.` }], verdict: 'FINDINGS', - stats: { profile: A.profile || null, agents: 0, dispatchModel: AGENT_MODEL }, + // Carry provenance even here — it is exactly what tells the caller WHY the set is empty + // (everything disabled? every layer ignored as an unmarked clash?). + stats: { profile: A.profile || null, agents: 0, dispatchModel: AGENT_MODEL, lensProvenance }, } } @@ -564,6 +592,7 @@ const stats = { daOk: da.ok, integrity: integrity.length, dispatchModel: AGENT_MODEL, + lensProvenance, } log(`pai-ensemble[${A.profile}]: ${stats.agents} agents (model: ${AGENT_MODEL}) → ${merged.length} merged finding(s) → ${verdict}` + (integrity.length ? ` (${integrity.length} integrity/process-gap)` : '')) return { findings: merged, verdict, stats }