diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index be3f392..840100e 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": "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.22.0",
+ "version": "2.23.0",
"author": {
"name": "Che Cheng"
},
@@ -25,12 +25,9 @@
},
{
"name": "pai-lenses",
- "source": {
- "source": "github",
- "repo": "PsychQuant/pai-lenses"
- },
+ "source": "./plugins/pai-lenses",
"description": "parallel-ai-agents 的 lens pack(層 ②):以 CSV 提供可疊加的 reviewer lens。裝了之後四個 ensemble skill 的 lens 集合會自動疊上;撞名需在 CSV 標 override 才取代。新增一條 lens = 改 CSV + bump 版本,不必動 plugin 程式碼。",
- "version": "0.1.0",
+ "version": "0.3.0",
"author": {
"name": "Che Cheng"
},
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ec5b8c4..ba750f4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -23,10 +23,12 @@ jobs:
# shellcheck 已預裝在 ubuntu-latest runner
- name: shellcheck
- run: shellcheck bin/pai-build-diff bin/pai-parse-verdict bin/pai-iter-commit
+ # 寫死清單的形式本身是問題(下一個新增的 script 預設不會被檢查)—— 追蹤於 #30。
+ # 這裡先把本 PR 新增的 pai-list-profiles 補進來,不留一支未檢查的新腳本。
+ run: shellcheck bin/pai-build-diff bin/pai-parse-verdict bin/pai-iter-commit bin/pai-list-profiles
- name: py_compile
- run: python3 -m py_compile bin/pai-parse-lens-csv bin/pai-collect-lens-layers
+ run: python3 -m py_compile bin/pai-parse-lens-csv bin/pai-collect-lens-layers bin/pai-contribute-lenses
# 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,
@@ -98,3 +100,36 @@ jobs:
fi
grep -q '^ok' "$TAP" || { echo "::error::no tests ran (empty glob or bats bail-out)"; exit 1; }
exit "$rc"
+
+ pai-lenses-validate:
+ # 併回本 repo 前,這個檢查住在 PsychQuant/pai-lenses 自己的 validate.yml。
+ # 併入後該檔落在 plugins/pai-lenses/.github/ —— GitHub 只執行 repo root 的
+ # .github/workflows/,所以它形同失效。改掛成本 repo 的獨立 job:兩個 plugin
+ # 的 CI 職責分離,且 scripts/validate.py 本身不動(貢獻者仍可本機跑同一支)。
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: plugins/pai-lenses
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # fetch-depth: 0 是必要的 —— 預設 shallow clone 只有一個 commit,
+ # 下面的 `git diff ...HEAD` 會因為 base SHA 不在本地歷史裡而失敗,
+ # 而 validate.py 對 git 失敗是「印出略過」而非報錯 —— 檢查會安靜地不存在。
+ fetch-depth: 0
+
+ # 檢查的內容不寫進 workflow 而放在 scripts/validate.py:`run: |` 區塊裡的
+ # heredoc 一旦把內容放在第 0 欄就會跳出 YAML block scalar,workflow 靜默停止解析。
+ - name: validate lens pack (version sync + bump-on-change + profile names + CSV shape)
+ # --base 讓 validate 能判斷「改了 lenses/*.csv 卻沒 bump 版本」——
+ # 只驗「兩處一致」守不住這個(#33 verify R2 H5/H10)。PR 事件用 base ref,
+ # push 到 main 時沒有 base,validate 會明確印出略過而非假裝檢查過。
+ # base SHA 走 env 而非直接內插進 run(workflow-injection 的標準防護形狀)。
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ if [ -n "$BASE_SHA" ]; then
+ python3 scripts/validate.py --base "$BASE_SHA"
+ else
+ python3 scripts/validate.py
+ fi
diff --git a/CLAUDE.md b/CLAUDE.md
index db1d39a..fa2cfc0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,13 +5,16 @@
## 結構
- `.claude-plugin/marketplace.json` — marketplace manifest
-- `plugins/parallel-ai-agents/` — 唯一的 plugin(含 skills、`bin/codex-call` wrapper 等所有實作)
+- `plugins/parallel-ai-agents/` — 主 plugin(含 skills、`bin/codex-call` wrapper 等所有實作)
+- `plugins/pai-lenses/` — **第二個 plugin**:官方 lens pack(三層疊加的層 ②,見 `plugins/parallel-ai-agents/references/lens-layers.md`)。只有 CSV 與 validator,無程式碼
- `README.md` — marketplace 對外介紹
- `LICENSE` — MIT
## 給未來 Claude 的 note
-當使用者要求改 ensemble 審閱邏輯、調整 agent 派發、改 Codex wrapper 時:**所有實作都在 `plugins/parallel-ai-agents/` 底下**,root 只保留 marketplace metadata 與整體文件。plugin 內部開發指引見 `plugins/parallel-ai-agents/CLAUDE.md`。
+當使用者要求改 ensemble 審閱邏輯、調整 agent 派發、改 Codex wrapper 時:**實作都在 `plugins/parallel-ai-agents/` 底下**,root 只保留 marketplace metadata 與整體文件。plugin 內部開發指引見 `plugins/parallel-ai-agents/CLAUDE.md`。
+
+要**新增或修改一條 lens** 則看目標層:既有 profile 加 lens → `plugins/pai-lenses/lenses/.csv`;需要新 profile → `plugins/parallel-ai-agents/workflows/ensemble-workflow.js` 的 `PROFILES`。判準與完整流程見 `references/lens-layers.md` 與 `/ensemble-contribute-lenses`。**`references/builtin-lenses.csv` 是 generated 的唯讀投影,編它不改變任何行為。**
## 重要區分
@@ -22,9 +25,13 @@
## 版本同步(CRITICAL)
-bump 版本時兩處必須一致:
+bump 版本時兩處必須一致。**這條對每一個 plugin 各自成立**,現在有兩個:
+
+| plugin | plugin.json | marketplace.json entry |
+|---|---|---|
+| `parallel-ai-agents` | `plugins/parallel-ai-agents/.claude-plugin/plugin.json` | `name: "parallel-ai-agents"` |
+| `pai-lenses` | `plugins/pai-lenses/.claude-plugin/plugin.json` | `name: "pai-lenses"` |
-- `plugins/parallel-ai-agents/.claude-plugin/plugin.json` 的 `version`
-- `.claude-plugin/marketplace.json` 對應 plugin entry 的 `version`
+兩者不同步 → 使用者 `/plugin update` 會看到舊版或裝不到新功能,**而且沒有任何錯誤訊息**。
-兩者不同步 → 使用者 `/plugin update` 會看到舊版或裝不到新功能。
+`pai-lenses` 有機械閘門守這條(`plugins/pai-lenses/scripts/validate.py` 的 `check_marketplace_sync`,CI job `pai-lenses-validate` 會跑)。`parallel-ai-agents` 目前沒有 —— 改它的版本時要自己記得兩處都改。
diff --git a/README.md b/README.md
index 6ff9efd..4692c4f 100644
--- a/README.md
+++ b/README.md
@@ -2,22 +2,49 @@
Claude Code marketplace,散發 **平行多 AI agent 審閱** plugin。
-目前只裝一個 plugin:`parallel-ai-agents`。把任務平行派發給多個 AI agent(Claude teammates + Codex GPT-5.5),各自**獨立執行**後交叉比對,找出共識與盲點。
+散發**兩個** plugin。把任務平行派發給多個 AI agent(Claude teammates + Codex),各自**獨立執行**後交叉比對,找出共識與盲點。
+
+| Plugin | 是什麼 |
+|---|---|
+| **`parallel-ai-agents`** | 主 plugin —— ensemble 審閱的 skill、harness、`bin/` 工具 |
+| **`pai-lenses`** | 官方 **lens pack**(三層疊加的層 ②):以 CSV 提供可疊加的 reviewer lens。純資料,無程式碼 |
## 安裝
```bash
/plugin marketplace add PsychQuant/parallel-ai-agents
/plugin install parallel-ai-agents@parallel-ai-agents
+/plugin install pai-lenses@parallel-ai-agents # 官方 lens pack(建議一併安裝)
```
-安裝後即可使用三個 skill:
+> **`pai-lenses` 是選配但建議裝。** 沒裝時 ensemble 只會用 harness 內建的 lens ——
+> 不會報錯、不會警告(缺席是靜默的,這是刻意設計),所以**「沒裝」與「裝了但沒生效」
+> 從輸出上看不出差別**。報表的 provenance 行會列出實際載入了哪幾層,可據此確認。
+
+安裝後可用的 skill:
| Skill | 用途 |
|-------|------|
-| `/ensemble-code-review` | 程式碼/技術文件審閱:4 個 Claude teammates(architecture、correctness、security、devils-advocate)+ Codex 獨立審一遍,最後合成比較表 |
+| `/ensemble-code-review` | 程式碼/技術文件審閱:architecture、correctness、security + devils-advocate + Codex 獨立審一遍,最後合成比較表 |
| `/ensemble-academic-review` | 學術論文審閱:methodology、writing、reference verification(che-zotero-mcp 抓幻覺文獻)、number-verification(R/Python 重跑 ground-truth 抓幻覺數字)、devils-advocate。支援 independent/hybrid/mix N 三種模式 |
-| `/ensemble-lecture-review` | 教學講義審閱:4 個 Claude teammates 各自獨立審閱講義品質(可帶對應逐字稿 `--srt`) |
+| `/ensemble-lecture-review` | 教學講義審閱:內容正確性/可讀性/逐字稿覆蓋率(可帶 `--srt`) |
+| `/ensemble-compose` | 自由組合:跨 profile 挑 lens + 自訂 reviewer(`--include` / `--lens` / `--lens-file`)|
+| `/ensemble-contribute-lenses` | 把本機 `~/.claude/pai-lenses/` 的 lens 送回公共層並開 PR |
+| `/ensemble-eval` | **dev 工具**:對埋好缺陷的 fixture 跑 K 次真 ensemble,量偵測率 |
+
+## 三層 lens 疊加
+
+reviewer 的 lens 由三層疊出來,順序即優先序:
+
+| 層 | 來源 | 誰能改 |
+|---|---|---|
+| ① built-in | 主 plugin 的 `PROFILES`(harness 內) | 改 code + 發版 |
+| ② lens pack | `pai-lenses` 的 `lenses/.csv` | 改 CSV + bump 版本 |
+| ③ user | `~/.claude/pai-lenses/.csv` | 直接編,立即生效、不必發布 |
+
+撞名時預設 first-wins,CSV 標了 `override` 才取代。寫在層 ③ 的 lens 想回流上游,跑
+`/ensemble-contribute-lenses`。完整契約見
+[`references/lens-layers.md`](plugins/parallel-ai-agents/references/lens-layers.md)。
## 為什麼
@@ -30,14 +57,23 @@ Claude Code marketplace,散發 **平行多 AI agent 審閱** plugin。
├── .claude-plugin/
│ └── marketplace.json # marketplace manifest
├── plugins/
-│ └── parallel-ai-agents/ # 唯一的 plugin
+│ ├── parallel-ai-agents/ # 主 plugin
+│ │ ├── .claude-plugin/
+│ │ │ └── plugin.json
+│ │ ├── bin/
+│ │ │ ├── codex-call # Swift script:直接 HTTP 呼叫 Codex
+│ │ │ ├── pai-list-profiles # 查 PROFILES 真源
+│ │ │ └── pai-contribute-lenses # 層 ③ 的回流流程
+│ │ ├── skills/ # 六個 skill
+│ │ ├── workflows/ # ensemble harness
+│ │ ├── references/ # lens-layers 契約、built-in lens catalog
+│ │ ├── CHANGELOG.md
+│ │ └── CLAUDE.md # plugin internal guide
+│ └── pai-lenses/ # 官方 lens pack(層 ②)
│ ├── .claude-plugin/
│ │ └── plugin.json
-│ ├── bin/
-│ │ └── codex-call # Swift script:直接 HTTP 呼叫 Codex
-│ ├── skills/ # 三個 ensemble-review skill
-│ ├── CHANGELOG.md
-│ └── CLAUDE.md # plugin internal guide
+│ ├── lenses/ # .csv
+│ └── scripts/validate.py # CI 閘門
├── README.md # 本檔案:marketplace 說明
├── LICENSE # MIT
└── .gitignore
diff --git a/plugins/pai-lenses/.claude-plugin/plugin.json b/plugins/pai-lenses/.claude-plugin/plugin.json
new file mode 100644
index 0000000..5c6fb33
--- /dev/null
+++ b/plugins/pai-lenses/.claude-plugin/plugin.json
@@ -0,0 +1,15 @@
+{
+ "name": "pai-lenses",
+ "version": "0.3.0",
+ "description": "parallel-ai-agents 的 lens pack:以 CSV 提供可疊加的 reviewer lens(層 ②)。新增一條 lens = 改 CSV + bump 版本,不必動 plugin 程式碼。",
+ "author": {
+ "name": "Che Cheng"
+ },
+ "category": "development",
+ "keywords": [
+ "parallel-ai-agents",
+ "ensemble-review",
+ "lenses",
+ "code-review"
+ ]
+}
diff --git a/plugins/pai-lenses/.gitignore b/plugins/pai-lenses/.gitignore
new file mode 100644
index 0000000..e43b0f9
--- /dev/null
+++ b/plugins/pai-lenses/.gitignore
@@ -0,0 +1 @@
+.DS_Store
diff --git a/plugins/pai-lenses/LICENSE b/plugins/pai-lenses/LICENSE
new file mode 100644
index 0000000..6452c33
--- /dev/null
+++ b/plugins/pai-lenses/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Che Cheng
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/pai-lenses/README.md b/plugins/pai-lenses/README.md
new file mode 100644
index 0000000..568cfb0
--- /dev/null
+++ b/plugins/pai-lenses/README.md
@@ -0,0 +1,113 @@
+# pai-lenses
+
+[`parallel-ai-agents`](https://github.com/PsychQuant/parallel-ai-agents) 的 **lens pack** —— 用 CSV 提供可疊加的 reviewer lens。
+
+裝了這個 plugin 之後,`ensemble-code-review` / `ensemble-academic-review` / `ensemble-lecture-review` /
+`ensemble-compose` 的 lens 集合會自動疊上這裡的內容。
+
+## 為什麼有這個 plugin
+
+built-in lens 的真源是 `plugins/parallel-ai-agents/workflows/ensemble-workflow.js` 的 `PROFILES` 物件。
+動它 = 改程式碼 → bump plugin 版本 → 同步 marketplace。結果是 lens 從 2026-06 起一條都沒新增過 ——
+**不是沒人想改,是改一條的成本太高**。
+
+把 lens 抽成獨立 plugin 之後:
+
+| | 之前 | 現在 |
+|---|---|---|
+| 新增一條 lens | 改 JS + bump 主 plugin | 改 CSV + bump 本 plugin |
+| 量測條件可追溯 | plugin 版本號會被無關改動污染 | lens pack 版本就是 lens 的版本座標 |
+
+第二點對 `eval/fixtures/` 的偵測率數字尤其重要:lens 是量測儀器,前後用不同 lens 量到的數字不可比。
+報表的 provenance 行會印出本 pack 的版本。
+
+### 為什麼**不**是獨立 repo(#33 的更正)
+
+本 pack 曾短暫是獨立的 `PsychQuant/pai-lenses` repo,理由是「降低外部貢獻的出口成本」。
+那個理由**不成立**,而且反過來是障礙:
+
+- 三層疊加的層 ③(`~/.claude/pai-lenses/`)要回流時,貢獻者得先判斷該進層 ① 還是層 ②,
+ 而那兩層當時分屬**兩個 repo** —— 判定與開 PR 都跨 repo
+- 兩層在同一棵樹上,`/ensemble-contribute-lenses` 才有辦法自動判定目標層並在**一個 PR** 裡完成
+
+舊 repo 已封存(README 指向這裡)。層 ①②③ 的完整契約見
+[`references/lens-layers.md`](../parallel-ai-agents/references/lens-layers.md)。
+
+## 三層疊加
+
+| 層 | 來源 | 給誰 |
+|---|---|---|
+| ① built-in | `parallel-ai-agents` 的 `PROFILES` | 所有人的 baseline |
+| ② **lens pack(本 plugin)** | `lenses/.csv` | 裝了這個 plugin 的人 |
+| ③ user | `~/.claude/pai-lenses/.csv` | 只有你自己 |
+
+順序即優先序。撞名時**預設 first-wins**(先到的勝),只有標了 `override` 的才取代。
+
+## CSV 格式
+
+一個 profile 一個檔,profile 由**檔名**決定(`lenses/code.csv` → `code` profile)。
+
+```csv
+key,focus,needsSrt,override
+perf,"檢查每個 hot path 的時間複雜度與不必要的重算",,
+security,"(取代內建的 security lens)……",,true
+```
+
+| 欄 | 必填 | 說明 |
+|---|---|---|
+| `key` | ✅ | lens 識別名。與其他層同名時觸發撞名判定 |
+| `focus` | ✅ | 該 reviewer 的檢查清單。**含逗號要 quote** —— 這是長 prose,不是短標籤 |
+| `needsSrt` | — | truthy 時標記此 lens 需要逐字稿(`lecture` profile 用) |
+| `override` | — | truthy 時**取代**同 key 的既有 lens |
+
+truthy 判準:`1` / `true` / `yes`(不分大小寫)。空白或省略 = false。
+
+### `override` 的語意
+
+`override` 是「**我要取代那一條**」,不是「我比較重要」。
+
+不標記 = 純新增;撞名時你的那條會被忽略(並在報表警告)。標記則會讓一條經過調校的 built-in lens
+**消失**,所以請在 PR 描述裡寫清楚為什麼原本那條不夠用。
+
+## 怎麼寫一條好 lens
+
+看 `parallel-ai-agents` 的 [`references/builtin-lenses.csv`](https://github.com/PsychQuant/parallel-ai-agents/blob/main/plugins/parallel-ai-agents/references/builtin-lenses.csv)
+—— 那是內建 lens 的唯讀 catalog,可以直接當範本。共同特徵:
+
+- **一個 lens 只審一件事**。範圍越窄,reviewer 越不會滑回泛泛而談
+- **focus 是逐點檢查清單**,不是一句話的期望。`(1)…(2)…(3)…` 的形式最有效
+- **明講要用工具查證**(「用 Read/Grep 實際打開檔案核對」),否則模型傾向只讀眼前的內容
+- **寫下這條 lens 的失敗模式**(「一段寫得很有說服力卻與程式碼不符的說明,比沒有註解更危險」)——
+ reviewer 需要知道它在防什麼
+
+## 貢獻
+
+**本機已經寫好 lens(層 ③)** → 跑 `/parallel-ai-agents:ensemble-contribute-lenses`。
+它會掃 `~/.claude/pai-lenses/*.csv`、判定每條該進層 ① 還是層 ②、產出變更並開 PR。
+
+**手動貢獻**:
+
+1. Fork `PsychQuant/parallel-ai-agents` → 改 `plugins/pai-lenses/lenses/.csv`
+2. **bump 兩處 version**:本 plugin 的 `.claude-plugin/plugin.json` **與** repo root
+ `.claude-plugin/marketplace.json` 的 `pai-lenses` entry。只改一處 → merge 後使用者收不到,
+ **且無錯誤訊息**(CI 的 `check_marketplace_sync` 會擋)
+3. PR 描述說明:這條 lens 抓什麼、為什麼既有的抓不到、若標了 `override` 為何要取代
+4. **檔名必須是既有 profile**(`bin/pai-list-profiles` 查得到的)。需要新 profile 就不是
+ 改這裡 —— CSV 描述不了 profile 級的 `title`/`daFocus`/`codexDefault`,要改 `PROFILES`(層 ①)
+
+CI(`pai-lenses-validate`)會檢查:semver `version`、marketplace 版本一致、檔名是既有 profile、
+CSV 可解析且每檔至少一條 lens、以及 `key` 不是誤複製進來的註解列。
+
+## 硬性前提:`plugin.json` 必須有 `version`
+
+Claude Code 把 plugin 解到 `~/.claude/plugins/cache////`。
+`plugin.json` 缺 `version` 時,目錄名會退回 **git commit SHA**([官方文件](https://code.claude.com/docs/en/plugin-marketplaces)
+的 fallback)或 `unknown`(實測在 `claude-plugins-official` 的幾個 plugin 上看過)。兩者都不是 semver,
+`parallel-ai-agents` 的 semver glob 就定位不到,本 pack 等同沒裝 —— 但報表會出現 `unversioned` 警告,
+不會靜默。CI 有守這一條。
+
+順帶一提,這也是為什麼**每次改 lens 都要 bump 版本**:版本沒變,使用者端不會收到更新。
+
+## License
+
+MIT
diff --git a/plugins/pai-lenses/lenses/code.csv b/plugins/pai-lenses/lenses/code.csv
new file mode 100644
index 0000000..4e98560
--- /dev/null
+++ b/plugins/pai-lenses/lenses/code.csv
@@ -0,0 +1,2 @@
+key,focus,needsSrt,override
+docs-vs-code,"註解與文件相對於它們所描述的程式碼是否**仍然為真**。只審這一件事,不審程式碼本身的對錯。檢查:(1) 每一段註解/docstring/README 段落,逐句對照它描述的實際程式碼,指出**已經不成立**的敘述(參數名改了、預設值改了、行為改了、錯誤處理改了);(2) 註解宣稱的不變式(「這裡一定非空」「呼叫端保證已排序」)在程式碼裡是否真的被維持或檢查;(3) 註解寫「為什麼」還是只複述「做什麼」—— 後者是雜訊,隨程式碼漂移還會變成假訊息;(4) 被註解掉的程式碼、過期的 TODO/FIXME(引用已關閉的 issue、已完成的重構);(5) 文件裡的路徑、指令、環境變數名是否還存在。用 Read/Grep 實際打開被引用的檔案核對,**不要**只憑註解自己讀起來是否合理 —— 一段寫得很有說服力卻與程式碼不符的說明,比沒有註解更危險。",,zz-brand-new,正常的一條新 lens,,
diff --git a/plugins/pai-lenses/scripts/validate.py b/plugins/pai-lenses/scripts/validate.py
new file mode 100644
index 0000000..4d72f4b
--- /dev/null
+++ b/plugins/pai-lenses/scripts/validate.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+"""驗證這個 lens pack 可被 parallel-ai-agents 正確消費。
+
+兩件事,都對應一個**安靜**的失敗模式:
+
+1. `plugin.json` 必須有 semver `version`。缺了的話 Claude Code 的 cache 目錄名會退回
+ git commit SHA(或 `unknown`),兩者都不是 semver,consumer 的 semver glob 定位不到 ——
+ pack 看起來裝好了卻什麼都不貢獻。
+
+2. 每個 `lenses/*.csv` 必須解析出至少一條 lens。header 打錯(`keys` 而非 `key`)時
+ `csv.DictReader` 不會報錯,只會讓每一列都被跳過;檔案看起來好好的,lens 卻全部消失。
+
+用 stdlib `csv` —— 與 consumer 的 `pai-parse-lens-csv` 同一個模組、同一套 quoting 規則。
+
+退出碼:0 全部通過;1 有錯。
+"""
+import csv
+import json
+import pathlib
+import re
+import subprocess
+import sys
+
+SEMVER = re.compile(r"^\d+\.\d+\.\d+")
+TRUTHY = ("1", "true", "yes")
+FALSY = ("", "0", "false", "no")
+
+
+def check_version(root, errs):
+ manifest = root / ".claude-plugin" / "plugin.json"
+ try:
+ version = json.loads(manifest.read_text(encoding="utf-8")).get("version", "")
+ except (OSError, json.JSONDecodeError) as e:
+ errs.append(f"::error file={manifest}::讀不到或不是合法 JSON:{e}")
+ return
+ print(f"version = {version or ''}")
+ if not SEMVER.match(str(version)):
+ errs.append(
+ f"::error file={manifest}::需要 semver version —— 缺了的話 cache 目錄名會退回 "
+ "commit SHA 或 unknown,consumer 的 semver glob 定位不到這個 pack"
+ )
+
+
+def check_csvs(root, errs):
+ files = sorted((root / "lenses").glob("*.csv"))
+ if not files:
+ errs.append("::error::找不到任何 lenses/*.csv —— 空的 pack 不貢獻任何東西")
+ return
+ for path in files:
+ rel = path.relative_to(root)
+ try:
+ with path.open(newline="", encoding="utf-8-sig") as fh:
+ rows = list(csv.DictReader(fh))
+ except (OSError, UnicodeDecodeError, csv.Error) as e:
+ errs.append(f"::error file={rel}::讀取/解析失敗:{e}")
+ continue
+ # #33 verify R2 H14:先前的「key 以 # 開頭」偵測對真正的 catalog 註解列**不可能觸發**
+ # —— 那一列在 catalog 裡的第一欄是 profile,複製過來時整份 header 也一起帶了。
+ # 真正的複製危害是**欄位錯位**:catalog 是 profile,key,focus,needsSrt,pack 是
+ # key,focus,needsSrt,override。整份複製後 key 欄拿到 profile 名、focus 欄拿到 key。
+ # 這個失敗是機械可測的:header 開頭就不一樣。
+ if rows and "profile" in rows[0] and "key" in rows[0] and "focus" in rows[0]:
+ errs.append(
+ f"::error file={rel}::header 含 `profile` 欄 —— 這是 builtin-lenses.csv 的格式"
+ "(profile,key,focus,needsSrt),不是 pack 的格式(key,focus,needsSrt,override)。"
+ "整份複製 catalog 會讓 key 欄拿到 profile 名、focus 欄拿到 key,"
+ "而每一列看起來都還是合法的 lens。請只複製你要的那幾列並改成 pack 的欄位順序"
+ )
+ continue
+ if not rows or "key" not in rows[0] or "focus" not in rows[0]:
+ errs.append(f"::error file={rel}::header 必須含 key 與 focus")
+ continue
+ lenses = [r for r in rows
+ if (r.get("key") or "").strip() and (r.get("focus") or "").strip()]
+ if not lenses:
+ errs.append(
+ f"::error file={rel}::解析出 0 條 lens —— 存在卻不貢獻任何東西的檔案比沒有更糟"
+ "(consumer 會警告,而審閱者會安靜地少一個 lens)"
+ )
+ continue
+ # #33 verify H14:CSV 沒有註解語法,而 builtin-lenses.csv(本 pack README 叫人拿它
+ # 當範本)第二列**就是**一行 `# 唯讀 catalog…` 的說明。那一列在 catalog 裡是安全的
+ # (key/focus 欄為空 → parser 跳過),但複製過來當範本時若把它放進 key 欄、又剛好
+ # 帶了逗號,就會被解析成一條「focus 是說明文字」的真 lens —— 而且舊版 CI 會蓋章通過。
+ for r in lenses:
+ if (r.get("key") or "").lstrip().startswith("#"):
+ errs.append(
+ f"::error file={rel}::key 以 '#' 開頭('{r['key'][:40]}')—— CSV 沒有註解語法。"
+ "這幾乎一定是從 builtin-lenses.csv 複製範本時把說明列一起帶進來了;"
+ "它會變成一條真的 lens 送進 reviewer prompt。請刪掉該列"
+ )
+ print(f"{rel}: {len(lenses)} 條 lens ✓")
+ for r in lenses:
+ for col in ("override", "needsSrt"):
+ raw = (r.get(col) or "").strip().lower()
+ if raw and raw not in TRUTHY + FALSY:
+ print(f"::warning file={rel}::{col}='{r[col]}' 不是可辨識的真假值"
+ f"(1/true/yes vs 空/0/false/no)—— 會被當成 false")
+
+
+def repo_root(root):
+ """併回主 repo 後,root 的祖父目錄就是 monorepo root(plugins/pai-lenses → repo)。
+ 獨立使用(pack 不在 monorepo 內)時回 None,相關檢查自動略過 —— 這支要能單獨跑。"""
+ cand = root.parent.parent
+ return cand if (cand / ".claude-plugin" / "marketplace.json").is_file() else None
+
+
+def check_marketplace_sync(root, errs):
+ """**每一個**相對路徑 plugin 的 plugin.json version 必須與 marketplace.json entry 一致。
+
+ #33 verify R1 H5/H9/H15:只 bump 一處時使用者 `/plugin update` 收不到新版,且無錯誤訊息。
+ #33 verify R2 H6:先前只檢查 `pai-lenses` 一個 entry,但 SKILL.md 的層 ① 路徑也指示要
+ bump `parallel-ai-agents` —— 那條路徑上沒有任何閘門。改為逐一檢查所有 `./plugins/...`
+ 來源的 plugin,新增第三個 plugin 時自動涵蓋。"""
+ repo = repo_root(root)
+ if repo is None:
+ print("note: 不在 monorepo 內 —— 略過 marketplace 版本一致檢查")
+ return
+ mp = repo / ".claude-plugin" / "marketplace.json"
+ try:
+ plugins = json.loads(mp.read_text(encoding="utf-8")).get("plugins", [])
+ except (OSError, json.JSONDecodeError) as e:
+ errs.append(f"::error file={mp}::讀取失敗:{e}")
+ return
+ seen = 0
+ for entry in plugins:
+ src = entry.get("source")
+ if not isinstance(src, str) or not src.startswith("./"):
+ continue # 非相對路徑來源不在本 repo 內,無從比對
+ pj = repo / src[2:] / ".claude-plugin" / "plugin.json"
+ if not pj.is_file():
+ errs.append(f"::error file={mp}::{entry.get('name')} 的 source 指向 {src},但該處沒有 plugin.json")
+ continue
+ try:
+ pj_ver = json.loads(pj.read_text(encoding="utf-8")).get("version")
+ except (OSError, json.JSONDecodeError) as e:
+ errs.append(f"::error file={pj}::讀取失敗:{e}")
+ continue
+ seen += 1
+ if entry.get("version") != pj_ver:
+ errs.append(
+ f"::error file={mp}::{entry.get('name')} version 不同步 —— "
+ f"plugin.json={pj_ver} 但 marketplace.json={entry.get('version')}。"
+ "兩者不一致時使用者 /plugin update 收不到新版,且不會有任何錯誤訊息"
+ )
+ else:
+ print(f"marketplace 版本一致:{entry.get('name')} {pj_ver} ✓")
+ if seen == 0:
+ errs.append(f"::error file={mp}::沒有任何相對路徑 plugin 被檢查 —— 這個檢查形同虛設")
+
+
+def check_bumped(root, errs, base):
+ """改了 `lenses/*.csv` 就**必須** bump 版本(相對 base ref 增加),不只是「兩處一致」。
+
+ #33 verify R2 H5/H10:equality 守得住「同步」,守不住「有 bump」。改了 lens 而兩處
+ 都停在同一版時,其餘檢查全過、CI 全綠、使用者收不到新 lens、無任何錯誤訊息 ——
+ 而 pack README 白紙黑字寫「每次改 lens 都要 bump…CI 會擋」。那句話先前是空頭支票。
+
+ 需要 base ref 才能判斷「有沒有改」,所以 CI 要傳 `--base origin/`;
+ 本機不傳時明確印出略過(不假裝檢查過)。"""
+ if not base:
+ print("note: 未給 --base —— 略過「改了 lens 必須 bump」檢查(CI 會帶 base)")
+ return
+ repo = repo_root(root)
+ if repo is None:
+ print("note: 不在 monorepo 內 —— 略過 bump 檢查")
+ return
+ rel = "plugins/pai-lenses/lenses"
+ changed = subprocess.run(["git", "diff", "--name-only", f"{base}...HEAD", "--", rel],
+ cwd=repo, capture_output=True, text=True)
+ if changed.returncode != 0:
+ print(f"note: git diff 失敗(base={base} 不存在?)—— 略過 bump 檢查:{changed.stderr.strip()}")
+ return
+ if not changed.stdout.strip():
+ print("lenses/ 相對 base 無變更 —— 無需 bump ✓")
+ return
+ pj = root / ".claude-plugin" / "plugin.json"
+ now = json.loads(pj.read_text(encoding="utf-8")).get("version", "")
+ old = subprocess.run(["git", "show", f"{base}:plugins/pai-lenses/.claude-plugin/plugin.json"],
+ cwd=repo, capture_output=True, text=True)
+ prev = json.loads(old.stdout).get("version", "") if old.returncode == 0 else None
+ if prev is None:
+ print(f"note: base 沒有這個 plugin.json(新增的 pack?)—— 略過 bump 檢查")
+ return
+ def tup(v):
+ try:
+ return tuple(int(x) for x in str(v).split(".")[:3])
+ except ValueError:
+ return ()
+ if tup(now) <= tup(prev):
+ errs.append(
+ f"::error file={pj}::lenses/ 改了({', '.join(changed.stdout.split())})"
+ f"但版本沒有增加(base={prev} → 現在={now})。"
+ "版本沒變時使用者 /plugin update 收不到這些 lens,而且不會有任何錯誤訊息"
+ )
+ else:
+ print(f"lenses/ 有變更且已 bump:{prev} → {now} ✓")
+
+
+def check_profiles(root, errs):
+ """每個 lenses/.csv 的檔名必須是 harness PROFILES 裡真的存在的 profile。
+
+ #33 verify H8:這是本設計的核心不變式,先前只寫在散文裡。檔名打錯或想用 pack
+ 偷渡新 profile 時,harness 會回 unknown ensemble profile、0 個 agent 被派出,
+ 而 workflow 仍「成功」結束 —— 正是這個 repo 反覆在防的那種安靜失敗。
+
+ profile 清單查真源(bin/pai-list-profiles),不查 builtin-lenses.csv ——
+ 後者由 lens 產生,lenses: [] 的 profile(如 custom)在裡面一列都沒有(H7)。"""
+ repo = repo_root(root)
+ if repo is None:
+ print("note: 不在 monorepo 內 —— 略過 profile 名稱檢查")
+ return
+ lister = repo / "plugins" / "parallel-ai-agents" / "bin" / "pai-list-profiles"
+ if not lister.is_file():
+ print(f"note: 找不到 {lister} —— 略過 profile 名稱檢查")
+ return
+ proc = subprocess.run(["bash", str(lister)], capture_output=True, text=True)
+ if proc.returncode != 0:
+ errs.append(f"::error::無法取得 PROFILES 清單:{proc.stderr.strip()}")
+ return
+ known = {p.strip() for p in proc.stdout.split() if p.strip()}
+ for path in sorted((root / "lenses").glob("*.csv")):
+ if path.stem not in known:
+ errs.append(
+ f"::error file={path.relative_to(root)}::'{path.stem}' 不是既有 profile"
+ f"(真源 PROFILES 有:{', '.join(sorted(known))})。"
+ "pack 只能為既有 profile 加 lens —— CSV 描述不了 profile 級的 "
+ "title/daFocus/codexDefault,新 profile 必須改 PROFILES(層 ①)"
+ )
+ else:
+ print(f"{path.relative_to(root)}: profile '{path.stem}' 存在於 PROFILES ✓")
+
+
+def main():
+ base = None
+ argv = sys.argv[1:]
+ if "--base" in argv:
+ i = argv.index("--base")
+ if i + 1 >= len(argv):
+ print("用法:validate.py [--base []", file=sys.stderr)
+ return 2
+ base = argv[i + 1]
+ root = pathlib.Path(__file__).resolve().parent.parent
+ errs = []
+ check_version(root, errs)
+ check_marketplace_sync(root, errs)
+ check_bumped(root, errs, base)
+ check_profiles(root, errs)
+ check_csvs(root, errs)
+ for e in errs:
+ print(e)
+ return 1 if errs else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/plugins/parallel-ai-agents/.claude-plugin/plugin.json b/plugins/parallel-ai-agents/.claude-plugin/plugin.json
index 0ee63a0..f14bb42 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.22.0: minutes profile + ensemble-minutes-review skill — 會議記錄的 ensemble 審閱。四個 lens 互為補集:fidelity(記錄寫的逐字稿有嗎)、completeness(逐字稿有的記錄漏了嗎)、attribution(發言與責任歸屬的依據)、cross-document(來函/開會通知/前次記錄與交叉參照)。既有 profile 都不合用:academic 四個 lens 有三個空轉(methodology 不適用、reference-verifier 查 Zotero、number-verifier 需計算 artifact),lecture 的 student-readability 不適用。skill 並記入 args 須傳物件(傳字串會 0 agent 空跑)與 agentModel 須顯式指定兩個實測陷阱。 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.22.0",
+ "description": "v2.23.0: pai-lenses 併回本 repo 為第二個 plugin(marketplace source 改相對路徑)+ ensemble-contribute-lenses skill —— 層 ③(user)原是單向終點,寫在 ~/.claude/pai-lenses/ 的 lens 只有本機吃得到;本 skill 掃 user 層、比對 built-in 與 pack、判定目標層後開 PR。判準核心:CSV 描述得了 lens、描述不了 profile 級的 title/daFocus/codexDefault,故新 profile 必須進層 ①;override 預設不送(會替所有人移除一條調校過的 lens 且無通知)。併回理由:collect 腳本的 PACK_PLUGIN 寫死單一 pack 名,架構只認一個官方 pack,獨立 repo 的生態理由不成立。 v2.22.0: minutes profile + ensemble-minutes-review skill — 會議記錄的 ensemble 審閱。四個 lens 互為補集:fidelity(記錄寫的逐字稿有嗎)、completeness(逐字稿有的記錄漏了嗎)、attribution(發言與責任歸屬的依據)、cross-document(來函/開會通知/前次記錄與交叉參照)。既有 profile 都不合用:academic 四個 lens 有三個空轉(methodology 不適用、reference-verifier 查 Zotero、number-verifier 需計算 artifact),lecture 的 student-readability 不適用。skill 並記入 args 須傳物件(傳字串會 0 agent 空跑)與 agentModel 須顯式指定兩個實測陷阱。 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.23.0",
"author": {
"name": "Che Cheng"
}
diff --git a/plugins/parallel-ai-agents/CHANGELOG.md b/plugins/parallel-ai-agents/CHANGELOG.md
index 8f463c5..ae7348a 100644
--- a/plugins/parallel-ai-agents/CHANGELOG.md
+++ b/plugins/parallel-ai-agents/CHANGELOG.md
@@ -11,6 +11,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.23.0] - 2026-08-04
+
+### Added
+
+- `ensemble-contribute-lenses` skill:層 ③(user)的回流路徑。掃 `~/.claude/pai-lenses/*.csv`、
+ 比對 built-in 與 lens pack、判定目標層後開 PR。判準是「能不能只用一條 lens 表達」——
+ CSV 描述得了 lens,描述不了 profile 級的 `title` / `daFocus` / `codexDefault`,
+ 故新 profile 必須進層 ①,缺的欄位一律向使用者索取不代填。
+ `override` 標記預設不送(會替所有使用者移除一條調校過的 lens,且傷害是靜默的)。
+- `references/lens-layers.md` 開頭新增「我想加 lens,該去哪」決策表(四種情況直接對到動作)。
+
+### Changed
+
+- `pai-lenses` 由獨立 repo 併回本 repo `plugins/pai-lenses/`(`git subtree`,保留其 3 個 commit)。
+ marketplace source 由 `{"source":"github",...}` 改為 `./plugins/pai-lenses`,與主 plugin 一致。
+ 併回理由:`bin/pai-collect-lens-layers` 的 `PACK_PLUGIN` 寫死單一 pack 名、只 glob `*/pai-lenses`,
+ 架構只認一個官方 pack,「讓第三方各自發 pack」的分離理由不成立。
+- 其 `validate.yml` 併入 root `test.yml` 為獨立 job;併入後落在 `plugins/` 下的 workflow
+ 不會被 GitHub 執行,故移除以免誤導。
+
+### Fixed
+
+- `references/builtin-lenses.csv` 檔頭改為 `!!! GENERATED FILE — DO NOT EDIT !!!` ——
+ 實測有人(含本次開發 session)第一次就誤以為該檔可編輯而去改它。
+
+### Fixed(#33 verify R1 — 6-AI ensemble 抓到 15 個 HIGH 後的修正)
+
+第一版的 `ensemble-contribute-lenses` **照著做走不完,走完了東西也送不到**。逐項:
+
+- **skill 現在有可執行的起點與終點**。新增 Phase 0「定位可修改的 repo 工作樹」(已在 repo → 用它;
+ 有 push 權 → `gh repo clone`;外部貢獻者 → `gh repo fork --clone`),全流程路徑改以 `$REPO_ROOT` 為
+ 唯一基準;Phase 6 補上 `git switch -c` / `git add` / `git commit` / `git push` / `gh pr create`。
+ 先前所有路徑都默默假設 cwd 是本 repo 的 clone,但這個 skill 鎖定的使用者手上只有 plugin cache
+ (不是 git checkout,不能 commit)。
+- **bump 一律兩處**。層 ①② 的 bump 指令、決策表、`lens-layers.md`、pack README 全部改成
+ `plugin.json` **與** `marketplace.json` 對應 entry。只改一處時 PR merge 後使用者收不到新版、
+ **且沒有任何錯誤訊息** —— 這正好是這個 skill 想達成的相反面。
+- **比對改用 parser 不用 `grep`**。`focus` 是可含換行與逗號的 quoted 長 prose,`grep` 拿到的是
+ record 的第一個實體行而非欄位值,「focus 相同/不同」的分支根本無法實作;且 `key`/`profile`
+ 來自使用者輸入,直接插進 `grep -E` 是 regex/option 注入。
+- **profile 存在性改查真源**(新增 `bin/pai-list-profiles`)。`builtin-lenses.csv` 由 lens 產生,
+ `lenses: []` 的 profile(`custom`)在投影裡一列都沒有 —— 拿它問存在性對 `custom` 必定答錯,
+ 會把該進層 ② 的貢獻送去層 ①、在 `PROFILES` 產生重複 key 並靜默蓋掉既有 profile。
+- **Phase 6 的 catalog 檢查不再自我阻擋**。層 ① 路徑在 Phase 5 已跑過 regen,此時 catalog 相對
+ HEAD 本來就該有差異,原本的 `git diff --exit-code` 必然把正常流程判成失敗。改為驗冪等
+ (再跑一次 regen 不會再變)。
+
+### Fixed(#33 verify R2 — 18 個 HIGH,核心判定是「R1 的方向對但形式錯了」)
+
+R1 補齊了缺的段落,但沒有讓它**能跑**。R2 的 18 個 HIGH 有 8 個指向同一件事:
+
+> 這個流程不可能以「文件裡的一串 bash 區塊」的形式運作。
+
+AI 逐個 fenced block 呼叫 Bash 時每次都是新 shell —— `REPO_ROOT` / `USER_DIR` / `UPSTREAM`
+到下一個 block 全是空字串;`profile` 從未被賦值也沒有走訪 `*.csv` 的迴圈;沒有 `set -e`,
+所以 Phase 6 的 `validate.py` **不是閘門**:驗證 exit 1 後仍會照常 commit / push / 開 PR。
+
+- **新增 `bin/pai-contribute-lenses`**(python3)—— 整條回流流程改為一支腳本,SKILL.md 退回成
+ 「何時用、判準是什麼」的薄封裝。一個 process 內完成就沒有跨 shell 的狀態問題,而且可以被
+ bats 測(10 條,含 3 處 mutation 驗證)。腳本**不代填任何設計決定**:新 profile 缺
+ `title`/`daFocus`/`codexDefault`、或 `override` 缺取代理由時 **exit 3 並印出缺什麼**,
+ 由 skill 問使用者後帶參數重跑。驗證是**真正的閘門** —— 未過即 exit 1,且此時保證尚未做
+ 任何 git 寫入或遠端操作。
+- **與 built-in 逐字相同的 lens 現在判 `SKIP` 而非 `MODIFY`** —— 先前 `builtin` 的 focus 讀進來
+ 卻從未比較(dead code),導致「上游已經有一模一樣的東西」被要求提供 override 理由。
+- **catalog 同步檢查改為無條件比對**。先前用「catalog 有沒有被改」當作「這是不是層 ① 路徑」的
+ proxy,而「改了 `PROFILES` 卻忘了跑 regen」正好讓 catalog 沒差異 → 檢查整段被跳過 ——
+ 守衛對它自己要抓的案例結構性不可達。
+- **profile 真源查詢區分「失敗」與「查無」**。先前 `pai-list-profiles | grep -qxF` 把
+ node 缺席/harness 求值失敗壓成與「查無此 profile」相同的 exit code,會被讀成「新 profile」
+ 並在 `PROFILES` 產生重複 key 靜默蓋掉既有 profile。
+- **`check_marketplace_sync` 改為檢查所有相對路徑 plugin**(先前只查 `pai-lenses` 一個 entry),
+ 新增第三個 plugin 時自動涵蓋。主 plugin 的層 ① 路徑先前完全沒有機械閘門。
+- **新增 `check_bumped`** —— 改了 `lenses/*.csv` 就必須 bump(相對 base ref 增加),不只是
+ 「兩處一致」。equality 守得住「同步」,守不住「有 bump」;先前改了 lens 而兩處都停在同一版時
+ 四個檢查全過、CI 全綠、使用者收不到新 lens、無任何錯誤訊息 —— 與 pack README 的宣稱直接矛盾。
+ CI 帶 `--base`(並改用 `fetch-depth: 0`,否則 shallow clone 讓這個檢查安靜地不存在)。
+- **CSV 範本複製的偵測改對目標** —— 先前偵測「`key` 以 `#` 開頭」,但那對真正的 catalog 註解列
+ 不可能觸發(該列在 catalog 裡的第一欄是 `profile`)。真正的危害是**欄位錯位**:整份複製
+ `profile,key,focus,needsSrt` 後 `key` 欄拿到 profile 名、`focus` 欄拿到 key,而每一列看起來
+ 都還是合法的 lens。改為偵測 header 含 `profile` 欄。
+- **root `README.md` 補上 `pai-lenses` 的安裝路徑**。R1 只修了被點名的 `CLAUDE.md`,而 README
+ 才是**唯一寫了安裝指令的檔案** —— 舊 repo 已封存後,使用者從此沒有任何管道裝到層 ②,
+ 而沒裝時 collector 回 `absent`(靜默、依設計不警告)→ 整個層 ② 安靜地不存在。
+- **bump `pai-lenses` 0.1.0 → 0.2.0**。本 PR 改了該 plugin 的 README 與 validate.py 卻沒 bump ——
+ 一份反覆論證「漏 bump 是靜默失敗」的 PR,作者自己第一個違反。
+
+### Added(同上一輪)
+
+- `bin/pai-list-profiles` — 印出 `PROFILES` 的 profile key(真源查詢;抽取法同 regen script)。
+- `plugins/pai-lenses/scripts/validate.py` 新增三道機械閘門,先前都只寫在散文裡:
+ `check_marketplace_sync`(兩處 version 必須一致)、`check_profiles`(CSV 檔名必須是既有 profile ——
+ 否則 harness 回 `unknown ensemble profile`、0 agent 派出、workflow 仍「成功」結束)、
+ 以及「`key` 以 `#` 開頭」的偵測(CSV 無註解語法,而 README 叫人拿有註解列的 catalog 當範本)。
+- `test/pai-collect-lens-layers.bats` 新增整合錨點:用**真實的** `plugins/pai-lenses` 內容複製進
+ 模擬 cache,驗證併回(相對路徑 source)後仍被正確定位與解析。先前這一項只有手動驗過。
+- root `CLAUDE.md` 更新:不再宣告「唯一的 plugin」,版本同步 CRITICAL 規則改為逐 plugin 的表格。
+
+
## [2.22.0] - 2026-08-04
### Added
diff --git a/plugins/parallel-ai-agents/bin/pai-contribute-lenses b/plugins/parallel-ai-agents/bin/pai-contribute-lenses
new file mode 100755
index 0000000..46c019a
--- /dev/null
+++ b/plugins/parallel-ai-agents/bin/pai-contribute-lenses
@@ -0,0 +1,375 @@
+#!/usr/bin/env python3
+"""pai-contribute-lenses — 把本機 user 層(層 ③)的 lens 送回公共層。
+
+**為什麼是一支腳本而不是 SKILL.md 裡的一串 bash 區塊**(#33 verify R1/R2):
+前兩版把流程寫成文件裡的 fenced bash blocks,兩輪 6-AI verify 都判 FAIL,
+第二輪明確指出形式本身不可行 ——
+
+ - AI 逐個 block 呼叫 Bash 時**每次都是新 shell**:`REPO_ROOT` / `USER_DIR` /
+ `UPSTREAM` 在下一個 block 全是空字串
+ - `profile` 從未被賦值,也沒有走訪 `*.csv` 的迴圈
+ - 沒有 `set -e`:validate 失敗後仍會照常 commit / push / 開 PR ——
+ 宣稱的「閘門」其實不是閘門
+
+一個 process 內完成就沒有這些問題,而且可以被 bats 測。
+
+**分工**:本腳本做**機械**的部分(盤點 → 比對 → 分類 → 產出變更 → 驗證 → git/PR)。
+需要**判斷**的部分不在這裡:新 profile 的 title/daFocus/codexDefault、override 的取代
+理由,都由呼叫端(skill)向使用者取得後以參數傳入。缺少時本腳本 **exit 3 並印出缺什麼**,
+絕不代填 —— 那是設計決定不是格式轉換。
+
+用法:
+ pai-contribute-lenses [--profile NAME] [--dry-run] [--include-override]
+ [--repo-root PATH] [--upstream OWNER/REPO]
+ [--override-reason KEY=REASON]...
+ [--new-profile-meta PROFILE=title|daFocus|codexDefault]...
+
+退出碼:
+ 0 完成(或 --dry-run 印完計畫)
+ 1 錯誤(含驗證未過 —— 此時保證尚未做任何 git 寫入或遠端操作)
+ 2 用法錯
+ 3 需要使用者輸入(新 profile 欄位/override 理由)—— 呼叫端負責問,然後重跑
+"""
+import argparse
+import csv
+import io
+import json
+import os
+import pathlib
+import subprocess
+import sys
+
+UPSTREAM_DEFAULT = "PsychQuant/parallel-ai-agents"
+
+
+def die(msg, code=1):
+ print(f"✗ {msg}", file=sys.stderr)
+ sys.exit(code)
+
+
+def run(cmd, **kw):
+ """跑外部指令。check=True 時失敗即中止 —— 這是 set -e 的替代品。"""
+ return subprocess.run(cmd, text=True, capture_output=True, **kw)
+
+
+# ── repo 定位 ────────────────────────────────────────────────────────────────
+
+def resolve_repo_root(explicit, upstream, allow_network):
+ """回傳可修改的 repo 工作樹。
+
+ 這個 skill 的目標使用者是「lens 寫在 ~/.claude/pai-lenses/、人在別的專案目錄」的
+ 貢獻者 —— 他手上有的是 plugin cache(不是 git checkout,不能 commit)。所以必須
+ 明確解出一個工作樹,不能假設 cwd。
+ """
+ if explicit:
+ root = pathlib.Path(explicit).resolve()
+ if not (root / ".claude-plugin" / "marketplace.json").is_file():
+ die(f"--repo-root {root} 不像是本 repo(缺 .claude-plugin/marketplace.json)")
+ return root
+
+ r = run(["git", "rev-parse", "--show-toplevel"])
+ if r.returncode == 0:
+ cand = pathlib.Path(r.stdout.strip())
+ if (cand / ".claude-plugin" / "marketplace.json").is_file() and (cand / "plugins" / "pai-lenses").is_dir():
+ return cand
+
+ if not allow_network:
+ die("不在本 repo 內,且 --dry-run 不會 clone/fork。\n"
+ " 請在本 repo 的 checkout 內執行,或用 --repo-root 指定,或拿掉 --dry-run。", 1)
+
+ dest = pathlib.Path(os.environ.get("PAI_CONTRIB_CLONE_DIR")
+ or pathlib.Path.home() / ".cache" / "pai-contrib" / "parallel-ai-agents")
+ if (dest / ".git").is_dir():
+ print(f"→ 重用既有 clone:{dest}")
+ return dest
+ dest.parent.mkdir(parents=True, exist_ok=True)
+
+ perm = run(["gh", "repo", "view", upstream, "--json", "viewerPermission",
+ "-q", ".viewerPermission"])
+ can_push = perm.returncode == 0 and perm.stdout.strip() in ("ADMIN", "MAINTAIN", "WRITE")
+ cmd = (["gh", "repo", "clone", upstream, str(dest)] if can_push
+ else ["gh", "repo", "fork", upstream, "--clone", "--remote", "--", str(dest)])
+ print(f"→ {'clone' if can_push else 'fork + clone'} {upstream} → {dest}")
+ r = run(cmd)
+ if r.returncode != 0:
+ die(f"取得工作樹失敗:{r.stderr.strip()}")
+ return dest
+
+
+# ── lens 讀取與比對 ──────────────────────────────────────────────────────────
+
+def parse_lens_csv(parser, path):
+ """解析一份 lens CSV。一律走 pai-parse-lens-csv(BOM-safe、csv 模組、bats 覆蓋的
+ 單一真相源)—— 絕不 naive split,focus 是含逗號與換行的 quoted 長 prose。"""
+ if not path.is_file():
+ return {}
+ r = run([sys.executable, str(parser), str(path)])
+ if r.returncode != 0:
+ die(f"解析 {path} 失敗:{r.stderr.strip()}")
+ return {l["key"]: l for l in json.loads(r.stdout)}
+
+
+def builtin_lenses(root, profile):
+ """從 generated 投影讀某 profile 的 built-in lens(key → focus)。
+
+ 注意:投影只保證「列出所有 built-in *lens*」。profile 的**存在性**不可查它 ——
+ 見 known_profiles()。"""
+ cat = root / "plugins/parallel-ai-agents/references/builtin-lenses.csv"
+ out = {}
+ with cat.open(newline="", encoding="utf-8-sig") as fh:
+ for r in csv.DictReader(fh):
+ if (r.get("profile") or "") == profile and (r.get("key") or "").strip():
+ out[r["key"]] = (r.get("focus") or "")
+ return out
+
+
+def known_profiles(root):
+ """查真源 PROFILES 的 profile key。
+
+ #33 verify R2 H3:這裡必須把「查詢失敗」與「查無此 profile」分開。壓成同一個
+ exit code 時,node 缺席/harness 求值失敗會被讀成「profile 不存在 → 新 profile」,
+ 於是在 PROFILES 新增一個**其實已存在**的 key、重複 key 靜默蓋掉既有 profile ——
+ 正是本流程宣稱要 fail-loud 防止的事。"""
+ lister = root / "plugins/parallel-ai-agents/bin/pai-list-profiles"
+ if not lister.is_file():
+ die(f"找不到 {lister} —— 無法查 profile 真源")
+ r = run(["bash", str(lister)])
+ if r.returncode != 0:
+ die("查 PROFILES 失敗(node 缺席?harness 求值錯誤?):\n " + r.stderr.strip() +
+ "\n 這與「查無此 profile」是兩回事,不可當成新 profile 處理。")
+ profiles = {p.strip() for p in r.stdout.split() if p.strip()}
+ if not profiles:
+ die("PROFILES 解析出 0 個 profile —— 這不可能,視為查詢失敗")
+ return profiles
+
+
+def classify(mine, pack, builtin):
+ """把本機 lens 分成三類。回傳 [(action, key, note)]。"""
+ rows = []
+ for key, l in mine.items():
+ focus = l["focus"]
+ if key in pack and pack[key]["focus"] == focus:
+ rows.append(("SKIP", key, "已回流(層 ② 內容相同)"))
+ elif key in builtin and builtin[key] == focus:
+ # R2 H13:與 built-in 逐字相同的 lens 先前被判 MODIFY 並導向 override,
+ # 但它其實什麼都不用做 —— 上游已經有一模一樣的東西。
+ rows.append(("SKIP", key, "已在層 ①(built-in 內容相同)"))
+ elif key in pack or key in builtin:
+ where = "層 ②" if key in pack else "層 ①"
+ rows.append(("MODIFY", key, f"{where}已有同 key 但 focus 不同 → 需 override"))
+ else:
+ rows.append(("CANDIDATE", key, "兩層都沒有"))
+ return rows
+
+
+# ── 產出變更 ─────────────────────────────────────────────────────────────────
+
+def append_to_pack(root, profile, lenses, dry):
+ """把 lens 附加進 lenses/.csv。用 csv.writer 寫 —— focus 含逗號與換行,
+ 手拼字串必爛。"""
+ path = root / "plugins/pai-lenses/lenses" / f"{profile}.csv"
+ header = ["key", "focus", "needsSrt", "override"]
+ existing = path.read_text(encoding="utf-8-sig") if path.is_file() else ""
+ buf = io.StringIO()
+ w = csv.writer(buf, lineterminator="\n")
+ if not existing:
+ w.writerow(header)
+ for l in lenses:
+ w.writerow([l["key"], l["focus"],
+ "true" if l.get("needsSrt") else "",
+ "true" if l.get("override") else ""])
+ if dry:
+ print(f" [dry-run] 會附加到 {path.relative_to(root)}:{[l['key'] for l in lenses]}")
+ return
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("a", encoding="utf-8") as fh:
+ fh.write(buf.getvalue())
+ print(f" 已附加 {len(lenses)} 條 → {path.relative_to(root)}")
+
+
+def bump(root, plugin_dir_name, dry, part="minor"):
+ """bump 一個 plugin 的版本 —— **兩處**:plugin.json 與 marketplace.json 對應 entry。
+
+ 只改一處時 PR merge 後使用者 /plugin update 收不到新版,而且沒有任何錯誤訊息。
+ """
+ pj = root / "plugins" / plugin_dir_name / ".claude-plugin" / "plugin.json"
+ mp = root / ".claude-plugin" / "marketplace.json"
+ d = json.loads(pj.read_text(encoding="utf-8"))
+ name = d["name"]
+ major, minor, patch = (int(x) for x in d["version"].split(".")[:3])
+ new = f"{major}.{minor + 1}.0" if part == "minor" else f"{major}.{minor}.{patch + 1}"
+ if dry:
+ print(f" [dry-run] 會把 {name} 從 {d['version']} bump 到 {new}(plugin.json + marketplace.json)")
+ return new
+ d["version"] = new
+ pj.write_text(json.dumps(d, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ m = json.loads(mp.read_text(encoding="utf-8"))
+ hit = False
+ for p in m.get("plugins", []):
+ if p.get("name") == name:
+ p["version"] = new
+ hit = True
+ if not hit:
+ die(f"marketplace.json 找不到 {name} entry —— 無法同步版本")
+ mp.write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ print(f" {name} {new}(plugin.json + marketplace.json 皆已更新)")
+ return new
+
+
+# ── 驗證(真正的閘門:任何 git 寫入之前)────────────────────────────────────
+
+def gate(root):
+ """全部驗證通過才回傳。任一項失敗 → exit 1,且此時保證尚未做任何 git 寫入。
+
+ #33 verify R2 H2:前一版把 validate.py 放在 Phase 6 的 bash block 裡但沒有
+ set -e / || exit,驗證 exit 1 之後仍會照常 commit / push / 開 PR —— 宣稱的閘門
+ 其實不是閘門。"""
+ print("→ 驗證")
+ v = run([sys.executable, "scripts/validate.py"], cwd=root / "plugins/pai-lenses")
+ print("\n".join(" " + l for l in (v.stdout or "").splitlines()))
+ if v.returncode != 0:
+ die("lens pack 驗證未通過(上方為輸出)—— 未做任何 git 寫入")
+
+ # catalog 同步:**無條件**比對,不用「catalog 有沒有被改」當 proxy。
+ # R2 H4/H9/H12:用 proxy 時,「改了 PROFILES 卻忘了跑 regen」正好讓 catalog 沒
+ # 差異 → 檢查被整段跳過 —— 守衛對它自己要抓的案例結構性不可達。
+ cat = root / "plugins/parallel-ai-agents/references/builtin-lenses.csv"
+ before = cat.read_bytes() if cat.is_file() else b""
+ r = run(["bash", "plugins/parallel-ai-agents/references/regen-builtin-lenses.sh"], cwd=root)
+ if r.returncode != 0:
+ die(f"regen 失敗:{r.stderr.strip()}")
+ if cat.read_bytes() != before:
+ die("builtin-lenses.csv 與 PROFILES 不同步 —— regen 產生了差異。\n"
+ " (已幫你重生,請確認內容後一併 commit)")
+ print(" catalog 與 PROFILES 同步 ✓")
+
+
+# ── git / PR ────────────────────────────────────────────────────────────────
+
+def open_pr(root, upstream, slug, body, touched, dry):
+ if dry:
+ print(f" [dry-run] 會開 branch contrib/{slug}、commit {len(touched)} 個檔、push、開 PR")
+ return
+ branch = f"contrib/{slug}"
+ for cmd in (["git", "switch", "-c", branch],
+ ["git", "add", *touched],
+ ["git", "commit", "-m", f"feat(lenses): 貢獻 lens 回公共層 ({slug})"],
+ ["git", "push", "-u", "origin", branch]):
+ r = run(cmd, cwd=root)
+ if r.returncode != 0:
+ die(f"{' '.join(cmd[:2])} 失敗:{r.stderr.strip()}")
+ bodyfile = root / ".git" / "pai-contrib-pr-body.md"
+ bodyfile.write_text(body, encoding="utf-8")
+ r = run(["gh", "pr", "create", "--repo", upstream,
+ "--title", f"lens 貢獻:{slug}", "--body-file", str(bodyfile)], cwd=root)
+ if r.returncode != 0:
+ die(f"gh pr create 失敗:{r.stderr.strip()}")
+ print(f" PR:{r.stdout.strip()}")
+
+
+# ── main ────────────────────────────────────────────────────────────────────
+
+def main():
+ ap = argparse.ArgumentParser(add_help=True)
+ ap.add_argument("--profile")
+ ap.add_argument("--dry-run", action="store_true")
+ ap.add_argument("--include-override", action="store_true")
+ ap.add_argument("--repo-root")
+ ap.add_argument("--upstream", default=UPSTREAM_DEFAULT)
+ ap.add_argument("--override-reason", action="append", default=[], metavar="KEY=REASON")
+ args = ap.parse_args()
+ dry = args.dry_run
+
+ user_dir = pathlib.Path(os.environ.get("PAI_USER_LENS_DIR")
+ or pathlib.Path.home() / ".claude" / "pai-lenses")
+ if not user_dir.is_dir():
+ print(f"本機無 {user_dir} — 沒有可貢獻的 lens。")
+ return 0
+ csvs = sorted(user_dir.glob("*.csv"))
+ if args.profile:
+ csvs = [p for p in csvs if p.stem == args.profile]
+ if not csvs:
+ die(f"{user_dir} 下沒有 {args.profile}.csv", 1)
+ if not csvs:
+ print(f"{user_dir} 下沒有 .csv — 沒有可貢獻的 lens。")
+ return 0
+
+ root = resolve_repo_root(args.repo_root, args.upstream, allow_network=not dry)
+ parser = root / "plugins/parallel-ai-agents/bin/pai-parse-lens-csv"
+ profiles = known_profiles(root)
+ reasons = dict(kv.split("=", 1) for kv in args.override_reason if "=" in kv)
+
+ plan, needs_input, touched = [], [], set()
+ for path in csvs:
+ profile = path.stem
+ mine = parse_lens_csv(parser, path)
+ if not mine:
+ print(f"→ {path.name}: 解析出 0 條 lens(header 是否為 key,focus?)")
+ continue
+ if profile not in profiles:
+ # 新 profile 只能進層 ①,而 CSV 描述不了 profile 級的 title/daFocus/
+ # codexDefault —— 缺就問,不代填(代填等於替使用者做設計決定)。
+ needs_input.append(
+ f"profile '{profile}' 不在 PROFILES(既有:{', '.join(sorted(profiles))})。"
+ f"這是新 profile,只能進層 ①,需要 title / daFocus / codexDefault —— "
+ f"請向使用者取得後改 PROFILES,本腳本不代填。")
+ continue
+ pack = parse_lens_csv(parser, root / "plugins/pai-lenses/lenses" / f"{profile}.csv")
+ rows = classify(mine, pack, builtin_lenses(root, profile))
+ print(f"→ {path.name}(profile {profile})")
+ send = []
+ for action, key, note in rows:
+ print(f" {action:9s} {key} — {note}")
+ if action == "CANDIDATE":
+ send.append(mine[key])
+ elif action == "MODIFY":
+ if not args.include_override:
+ print(f" (預設不送 —— 加 --include-override 才列入)")
+ elif key not in reasons:
+ needs_input.append(
+ f"lens '{key}' 要取代既有的同 key lens,需要取代理由 —— "
+ f"重跑時帶 --override-reason {key}=<理由>")
+ else:
+ send.append({**mine[key], "override": True})
+ if send:
+ plan.append((profile, send))
+
+ if needs_input:
+ print("\n需要使用者輸入才能繼續:", file=sys.stderr)
+ for n in needs_input:
+ print(f" - {n}", file=sys.stderr)
+ return 3
+ if not plan:
+ print("\n沒有需要送出的 lens。")
+ return 0
+
+ print("\n→ 產出變更")
+ for profile, lenses in plan:
+ append_to_pack(root, profile, lenses, dry)
+ touched.add(f"plugins/pai-lenses/lenses/{profile}.csv")
+ bump(root, "pai-lenses", dry)
+ touched.update({"plugins/pai-lenses/.claude-plugin/plugin.json", ".claude-plugin/marketplace.json"})
+
+ if dry:
+ print("\n[dry-run] 未寫入任何檔案、未做任何 git 操作。")
+ return 0
+
+ gate(root) # 真正的閘門:未過就 exit 1,此時尚未有任何 git 寫入
+
+ keys = [l["key"] for _, ls in plan for l in ls]
+ slug = "-".join(keys[:3])[:40] or "lenses"
+ body = ["## 貢獻的 lens", ""]
+ for profile, lenses in plan:
+ for l in lenses:
+ body.append(f"- `{profile}` / **{l['key']}** — 來源:本機 `{user_dir}/{profile}.csv`"
+ + (f";**取代**既有同 key lens,理由:{reasons.get(l['key'], '')}"
+ if l.get("override") else ";純新增"))
+ body += ["", "目標層:② lens pack(`plugins/pai-lenses/lenses/`)", "",
+ "由 `/parallel-ai-agents:ensemble-contribute-lenses` 產生。"]
+ open_pr(root, args.upstream, slug, "\n".join(body), sorted(touched), dry)
+ print("\n完成。本機的原始 CSV 未刪 —— PR merge 前刪掉會兩頭落空。")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/plugins/parallel-ai-agents/bin/pai-list-profiles b/plugins/parallel-ai-agents/bin/pai-list-profiles
new file mode 100755
index 0000000..dd00455
--- /dev/null
+++ b/plugins/parallel-ai-agents/bin/pai-list-profiles
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# pai-list-profiles — 印出 harness PROFILES 的所有 profile key,一行一個。
+#
+# 為什麼需要這支(#33 verify H7):`references/builtin-lenses.csv` 是 PROFILES 的
+# generated 投影,但它**由 lens 產生** —— `regen-builtin-lenses.sh` 只對
+# `for (const l of (p.lenses || []))` 產列。所以 `lenses: []` 的 profile(例如
+# `custom`,它的定位就是「自由組合、無內建 lens」)在投影裡一列都沒有。
+#
+# 拿投影回答「這個 profile 存在嗎」會對 custom 必定答錯,並把該進層 ② 的貢獻
+# 誤送層 ①、在 PROFILES 產生重複 key 靜默蓋掉既有 profile。投影保證的是
+# 「列出所有 built-in lens」,不是「列出所有 profile」—— 兩個不同的集合。
+#
+# 抽取法與 regen-builtin-lenses.sh 相同:取「純定義區」(在 Orchestration 分隔線
+# 之前,runtime globals 尚未出現)、中和 meta export、append 一行 re-export,
+# 交給真的 JS engine 求值。不用 regex 撈 key —— PROFILES 的 focus 字串含大量
+# 標點與跳脫,regex 會撈錯。
+#
+# 退出碼:0 成功;1 harness 找不到或求值失敗。
+set -euo pipefail
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+harness="${PAI_HARNESS:-$here/../workflows/ensemble-workflow.js}"
+
+if [ ! -f "$harness" ]; then
+ echo "找不到 harness:$harness" >&2
+ exit 1
+fi
+
+tmpdir="$(mktemp -d)"
+trap 'rm -rf "$tmpdir"' EXIT
+tmp="$tmpdir/profiles.mjs"
+
+awk '/^\/\/ ── Orchestration ──/{exit} {print}' "$harness" \
+ | sed 's/^export const meta/const meta/' > "$tmp"
+echo 'export { PROFILES }' >> "$tmp"
+
+node --input-type=module <.csv`,立即生效,不必發布 |
+| 想貢獻,且是**既有** profile 的 lens | 層 ② lens pack | 編 `plugins/pai-lenses/lenses/.csv` + bump **兩處** version(`plugin.json` 與 `marketplace.json` 對應 entry)|
+| 想貢獻,且需要**新 profile** | 層 ① built-in | 改 `workflows/ensemble-workflow.js` 的 `PROFILES` → 跑 `references/regen-builtin-lenses.sh` → bump 兩處 version |
+
+> ⚠️ **「profile 是否存在」要查真源,不要查 `builtin-lenses.csv`**:該投影由 lens 產生,
+> `lenses: []` 的 profile(如 `custom`)在裡面一列都沒有。用 `bin/pai-list-profiles`。
+| 本機已經寫好,想一次送上去 | — | `/ensemble-contribute-lenses`(掃 user 層、判定目標層、開 PR) |
+
+> ⚠️ **`references/builtin-lenses.csv` 是 generated 的唯讀投影** —— 編它不改變任何行為。
+> 真源是 `PROFILES`。這個檔存在只為了讓人「看得到目前有哪些 lens」。
+
+> ⚠️ **新 profile 不能只靠 lens pack**:CSV 描述得了 lens,描述不了 profile 級的
+> `title` / `daFocus` / `codexDefault`。harness 的 `PROFILES` 沒有該 key 時,用它呼叫會回
+> `unknown ensemble profile` 且 **0 個 agent 被派出**,workflow 卻仍「成功」結束。
+
## Skill 該做的事
### 1. 蒐集(Phase 2,呼叫 Workflow 之前)
diff --git a/plugins/parallel-ai-agents/references/regen-builtin-lenses.sh b/plugins/parallel-ai-agents/references/regen-builtin-lenses.sh
index 8799661..9de4352 100755
--- a/plugins/parallel-ai-agents/references/regen-builtin-lenses.sh
+++ b/plugins/parallel-ai-agents/references/regen-builtin-lenses.sh
@@ -33,7 +33,7 @@ const esc = (s) => '"' + String(s).replace(/"/g, '""') + '"'
// would run them as command substitution.
const rows = [
'profile,key,focus,needsSrt',
- '# 唯讀 catalog — 編輯本檔不會改變任何行為。真源是 workflows/ensemble-workflow.js 的 PROFILES。' +
+ '# !!! GENERATED FILE — DO NOT EDIT !!! 唯讀 catalog — 編輯本檔不會改變任何行為。真源是 workflows/ensemble-workflow.js 的 PROFILES。' +
'要新增/修改 lens 請走 lens pack 或 ~/.claude/pai-lenses/(見 references/lens-layers.md)。' +
'本檔由 references/regen-builtin-lenses.sh 產生。',
]
diff --git a/plugins/parallel-ai-agents/skills/ensemble-contribute-lenses/SKILL.md b/plugins/parallel-ai-agents/skills/ensemble-contribute-lenses/SKILL.md
new file mode 100644
index 0000000..c0c7e81
--- /dev/null
+++ b/plugins/parallel-ai-agents/skills/ensemble-contribute-lenses/SKILL.md
@@ -0,0 +1,104 @@
+---
+name: ensemble-contribute-lenses
+description: |
+ 把本機 user 層的 lens 送回公共層並開 PR。掃 ~/.claude/pai-lenses/*.csv,
+ 比對 built-in(PROFILES)與 lens pack(plugins/pai-lenses/lenses/),
+ 判定每條 lens 該進哪一層,產出變更後開 PR。
+ Use when: 自己寫的 lens 想貢獻回 repo、或想知道本機有哪些 lens 還沒回流。
+argument-hint: "[--profile ] [--dry-run] [--include-override]"
+allowed-tools:
+ - Read
+ - Write
+ - Edit
+ - Bash
+ - Grep
+ - Glob
+ - AskUserQuestion
+---
+
+# /ensemble-contribute-lenses — 把本機 lens 送回公共層
+
+三層 lens 疊加(`references/lens-layers.md`)裡,層 ③(user)原本是單向終點:寫在
+`~/.claude/pai-lenses/.csv` 的 lens 只有自己機器吃得到。這個 skill 是它的出口。
+
+## 兩個目標層,判準不同
+
+| 目標 | 何時 | 改什麼 |
+|---|---|---|
+| **層 ②** lens pack | 為**既有** profile 加 lens | `plugins/pai-lenses/lenses/.csv` + bump **兩處** version(`plugin.json` **與** `marketplace.json` 對應 entry)|
+| **層 ①** built-in | 需要**新 profile**,或需要 profile 級語意 | `plugins/parallel-ai-agents/workflows/ensemble-workflow.js` 的 `PROFILES` + 跑 `references/regen-builtin-lenses.sh` + bump 兩處 version |
+
+> **「bump」永遠是兩處**:只改 `plugin.json` 而漏 `marketplace.json`,PR merge 後使用者
+> `/plugin update` 收不到新版 —— 而且沒有任何錯誤訊息。見 root `CLAUDE.md` 的「版本同步(CRITICAL)」。
+
+**判準是「能不能只用一條 lens 表達」**:
+
+- lens pack 的 CSV 只能描述 lens 本身(`key` / `focus` / `needsSrt` / `override`)。
+- profile 級的東西——`title`、`daFocus`、`codexDefault`——**只存在於 `PROFILES`**。CSV 表達不了。
+- 更關鍵:harness 的 `PROFILES` 沒有某個 profile key 時,用該 profile 呼叫會回
+ `unknown ensemble profile` 且 **0 個 agent 被派出**,workflow 卻仍「成功」結束。
+ 把新 profile 誤送層 ② 的後果是這個安靜失敗,所以**判錯必須 fail-loud,不可猜**。
+
+## 執行
+
+**整條流程是一支腳本,不是一串 bash 區塊。** 直接呼叫:
+
+```bash
+python3 "${CLAUDE_PLUGIN_ROOT}/bin/pai-contribute-lenses" [--profile NAME] [--dry-run] [--include-override]
+```
+
+它做完所有**機械**的部分:定位可修改的 repo 工作樹(在 repo 內就用它;有 push 權 `gh repo clone`;
+外部貢獻者 `gh repo fork --clone`)→ 走訪 `~/.claude/pai-lenses/*.csv`(檔名即 profile)→ 用
+`bin/pai-parse-lens-csv` 解析並與層 ①② 比對 → 用 `bin/pai-list-profiles` 查真源判定目標層 →
+附加到 pack CSV → bump **兩處** version → **驗證(真正的閘門)** → branch / commit / push /
+`gh pr create`。
+
+> **為什麼是腳本**(#33 verify R1 + R2):前兩版把流程寫成本文件裡的 fenced bash blocks,
+> 兩輪 6-AI verify 都判 FAIL。R2 明確指出形式本身不可行 —— AI 逐個 block 呼叫 Bash 時**每次
+> 都是新 shell**,`REPO_ROOT` / `USER_DIR` 到下一個 block 全是空字串;`profile` 從未被賦值;
+> 沒有 `set -e`,所以「驗證」失敗後仍會照常 commit / push / 開 PR。補指令不會讓它變成可執行,
+> 這是形式問題不是內容問題。一個 process 內完成就沒有這些問題,而且可以被 bats 測。
+
+### 你(skill)要做的:回答腳本問不出來的事
+
+腳本**不代填任何設計決定**。遇到需要判斷的地方它 **exit 3** 並印出缺什麼,你負責問使用者、
+然後帶著答案重跑:
+
+| exit 3 的原因 | 你要做的 |
+|---|---|
+| 本機 CSV 的 profile 不在 `PROFILES` | 這是**新 profile**,只能進層 ①。用 `AskUserQuestion` 取得 `title` / `daFocus` / `codexDefault`,**手動**改 `workflows/ensemble-workflow.js` 的 `PROFILES`、跑 `references/regen-builtin-lenses.sh`、bump 主 plugin 的兩處 version。腳本不碰層 ① —— 改 JS 物件不是機械操作 |
+| 某條 lens 要取代同 key 的既有 lens | 用 `AskUserQuestion` 確認,**問題中列出被取代那條的 `focus` 全文**,取得一句取代理由,再帶 `--include-override --override-reason KEY=理由` 重跑 |
+
+其餘退出碼:`0` 完成(或 `--dry-run` 印完計畫)、`1` 錯誤(含驗證未過 —— 此時**保證尚未做任何
+git 寫入或遠端操作**)、`2` 用法錯。
+
+### `override` 為什麼預設不送
+
+標了 `override` 的 lens 語意是「**取代**某條既有 lens」,不是「新增」。貢獻到公共層等於
+**替所有使用者移除一條調校過的 lens**,而且傷害是靜默的 —— 被取代的 lens 消失後沒有人會收到通知。
+所以預設不列入候選,要送必須顯式 `--include-override` 且逐條給理由(理由會寫進 PR body)。
+
+### 讀腳本印出的分類
+
+```
+SKIP 已回流(層 ② 內容相同)/已在層 ①(built-in 內容相同)→ 提示使用者本機該條可刪
+CANDIDATE 兩層都沒有 → 純新增,直接送
+MODIFY 同 key 但 focus 不同 → 需要 override 決定
+```
+
+
+## 為什麼是單一 repo 的一個 PR
+
+`pai-lenses` 曾是獨立 repo,貢獻要跨兩個 repo 判斷該去哪、開兩個 PR。#33 裁定它是本 repo 的
+官方增補層並併入 `plugins/pai-lenses/` 後,兩層的變更落在同一個 PR,這個 skill 也因此不需要
+處理跨 repo 的分支與版本對齊。
+
+## 反模式
+
+| 想做的 | 為什麼不行 |
+|---|---|
+| 直接編 `references/builtin-lenses.csv` | 那是 generated 的唯讀投影,改它不改變任何行為。真源是 `PROFILES` |
+| 新 profile 送層 ② | harness 的 `PROFILES` 沒有該 key → `unknown ensemble profile`、0 agent 派出,且 workflow 仍「成功」結束 |
+| 代填 `daFocus` / `codexDefault` | 那是設計決定不是格式轉換。缺就問 |
+| 自動送出 `override` lens | 會替所有使用者移除一條調校過的 lens,且無人收到通知 |
+| 送完就刪本機檔 | PR 未 merge 前刪掉會兩頭落空。提示使用者,由他決定何時清 |
diff --git a/plugins/parallel-ai-agents/test/pai-collect-lens-layers.bats b/plugins/parallel-ai-agents/test/pai-collect-lens-layers.bats
index a76d691..f2b9243 100644
--- a/plugins/parallel-ai-agents/test/pai-collect-lens-layers.bats
+++ b/plugins/parallel-ai-agents/test/pai-collect-lens-layers.bats
@@ -146,6 +146,41 @@ assert d["lenses"][0].get("override") is True, d["lenses"]
' "$output"
}
+@test "整合錨點(#33):併回後的真實 pai-lenses 內容,在安裝後的 cache 佈局仍被解析" {
+ # issue #33 要求 4 —— source 從 github 改成相對路徑後,cache 佈局會不會變、
+ # semver glob 還找不找得到 pack。這條用**真實的 plugins/pai-lenses 內容**(不是 fixture)
+ # 複製進模擬 cache,所以 pack 的檔名、版本、CSV 任何一項壞掉都會在這裡紅。
+ #
+ # 佈局取自同 marketplace 的實證:parallel-ai-agents 自己就是相對路徑 source,
+ # 其 cache 是 ~/.claude/plugins/cache/parallel-ai-agents/parallel-ai-agents//。
+ PACK_SRC="${BATS_TEST_DIRNAME}/../../pai-lenses"
+ [ -d "$PACK_SRC" ] || skip "找不到 $PACK_SRC(pai-lenses 未併入本 repo)"
+ VER=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['version'])" \
+ "${PACK_SRC}/.claude-plugin/plugin.json")
+ DEST="${CACHE}/parallel-ai-agents/pai-lenses/${VER}"
+ mkdir -p "$DEST"
+ cp -R "${PACK_SRC}/." "$DEST/"
+
+ run "$BIN" code
+ [ "$status" -eq 0 ]
+ jq_py '
+import json,sys
+d=json.loads(sys.argv[1])
+pack=[l for l in d["layers"] if l["name"]=="pack"][0]
+assert pack["status"]=="ok", pack
+assert d["lenses"], "真實 pack 的 code.csv 一條 lens 都沒收到"
+assert all(x["_layer"]=="pack" for x in d["lenses"]), d["lenses"]
+assert d["warnings"]==[], d["warnings"]
+' "$output"
+ # 版本要如實回報 —— provenance 行靠它,報錯版本等於量測條件記錯
+ jq_py "
+import json,sys
+d=json.loads(sys.argv[1])
+v=[l for l in d['layers'] if l['name']=='pack'][0]['version']
+assert v=='${VER}', (v, '${VER}')
+" "$output"
+}
+
@test "無參數 → exit 2(用法)" {
run "$BIN"
[ "$status" -eq 2 ]
diff --git a/plugins/parallel-ai-agents/test/pai-contribute-lenses.bats b/plugins/parallel-ai-agents/test/pai-contribute-lenses.bats
new file mode 100644
index 0000000..7bcc341
--- /dev/null
+++ b/plugins/parallel-ai-agents/test/pai-contribute-lenses.bats
@@ -0,0 +1,132 @@
+#!/usr/bin/env bats
+# pai-contribute-lenses(層 ③ 回流流程)的 bats 測試。
+#
+# 這支腳本存在的理由就是「可測」:#33 的前兩版把流程寫成 SKILL.md 裡的 bash 區塊,
+# 兩輪 6-AI verify 都判 FAIL——跨 Bash 呼叫的 shell 變數不存活、`profile` 從未被賦值、
+# 沒有 set -e 所以「閘門」不是閘門。文件測不了,腳本測得了。
+#
+# 鐵律:全部用 BATS_TEST_TMPDIR 內自建的 user lens 目錄,絕不讀開發機真實的
+# ~/.claude/pai-lenses/。repo 用真實的 checkout(唯讀操作 + --dry-run)。
+
+setup() {
+ BIN="${BATS_TEST_DIRNAME}/../bin/pai-contribute-lenses"
+ ROOT="$(cd "${BATS_TEST_DIRNAME}/../../.." && pwd)"
+ USERDIR="${BATS_TEST_TMPDIR}/userlens"
+ mkdir -p "$USERDIR"
+ export PAI_USER_LENS_DIR="$USERDIR"
+}
+
+# 取一條真實的 built-in lens(key 與 focus),供「逐字相同」與「同 key 不同 focus」用
+builtin_row() {
+ python3 -c "
+import csv,sys
+rows=[r for r in csv.DictReader(open(sys.argv[1],encoding='utf-8-sig'))
+ if r.get('profile')=='code' and (r.get('key') or '').strip()]
+r=rows[0]; print(r['key']); print(r['focus'])
+" "${ROOT}/plugins/parallel-ai-agents/references/builtin-lenses.csv"
+}
+
+@test "本機無 user lens 目錄 → 靜默 exit 0" {
+ export PAI_USER_LENS_DIR="${BATS_TEST_TMPDIR}/nope"
+ run python3 "$BIN" --dry-run --repo-root "$ROOT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"沒有可貢獻的 lens"* ]]
+}
+
+@test "全新 lens → CANDIDATE,dry-run 印計畫且不寫入任何檔案" {
+ printf 'key,focus\nzz-brand-new,"檢查 hot path 的複雜度, 以及重算"\n' > "${USERDIR}/code.csv"
+ before=$(cd "$ROOT" && git status --porcelain | wc -l)
+ run python3 "$BIN" --dry-run --repo-root "$ROOT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"CANDIDATE"* ]]
+ [[ "$output" == *"zz-brand-new"* ]]
+ [[ "$output" == *"dry-run"* ]]
+ after=$(cd "$ROOT" && git status --porcelain | wc -l)
+ [ "$before" -eq "$after" ]
+}
+
+@test "與 built-in 逐字相同 → SKIP(不是 MODIFY,也不進 override 路徑)" {
+ # #33 verify R2 H13:先前 builtin 的 focus 讀進來卻從未比較(dead code),
+ # 導致「上游已經有一模一樣的東西」被判成 MODIFY 並要求 override 理由。
+ mapfile -t row < <(builtin_row)
+ python3 -c "
+import csv,sys
+w=csv.writer(open(sys.argv[1],'w',newline=''))
+w.writerow(['key','focus']); w.writerow([sys.argv[2], sys.argv[3]])
+" "${USERDIR}/code.csv" "${row[0]}" "${row[1]}"
+ run python3 "$BIN" --dry-run --repo-root "$ROOT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"SKIP"* ]]
+ [[ "$output" != *"MODIFY"* ]]
+}
+
+@test "同 key 但 focus 不同 → MODIFY;未給理由時 exit 3 而非擅自送出" {
+ mapfile -t row < <(builtin_row)
+ python3 -c "
+import csv,sys
+w=csv.writer(open(sys.argv[1],'w',newline=''))
+w.writerow(['key','focus']); w.writerow([sys.argv[2],'完全不同的 focus 內容'])
+" "${USERDIR}/code.csv" "${row[0]}"
+ run python3 "$BIN" --dry-run --repo-root "$ROOT" --include-override
+ [ "$status" -eq 3 ]
+ [[ "$output" == *"MODIFY"* ]] || [[ "$output" == *"取代理由"* ]]
+}
+
+@test "override 給了理由 → 可進行" {
+ mapfile -t row < <(builtin_row)
+ python3 -c "
+import csv,sys
+w=csv.writer(open(sys.argv[1],'w',newline=''))
+w.writerow(['key','focus']); w.writerow([sys.argv[2],'完全不同的 focus 內容'])
+" "${USERDIR}/code.csv" "${row[0]}"
+ run python3 "$BIN" --dry-run --repo-root "$ROOT" --include-override \
+ --override-reason "${row[0]}=內建那條漏了 X"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"dry-run"* ]]
+}
+
+@test "未標 --include-override 時,MODIFY 不會被送出(預設不送)" {
+ mapfile -t row < <(builtin_row)
+ python3 -c "
+import csv,sys
+w=csv.writer(open(sys.argv[1],'w',newline=''))
+w.writerow(['key','focus']); w.writerow([sys.argv[2],'完全不同的 focus 內容'])
+" "${USERDIR}/code.csv" "${row[0]}"
+ run python3 "$BIN" --dry-run --repo-root "$ROOT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"沒有需要送出的 lens"* ]]
+}
+
+@test "新 profile → exit 3 並說明缺哪些 profile 級欄位(不代填)" {
+ # CSV 描述不了 title / daFocus / codexDefault;代填等於替使用者做設計決定。
+ printf 'key,focus\nfoo,某個檢查\n' > "${USERDIR}/zz-not-a-profile.csv"
+ run python3 "$BIN" --dry-run --repo-root "$ROOT"
+ [ "$status" -eq 3 ]
+ [[ "$output" == *"不在 PROFILES"* ]]
+ [[ "$output" == *"daFocus"* ]]
+}
+
+@test "--profile 真的會篩選(只處理指定的那一個檔)" {
+ printf 'key,focus\naaa,檢查 A\n' > "${USERDIR}/code.csv"
+ printf 'key,focus\nbbb,檢查 B\n' > "${USERDIR}/academic.csv"
+ run python3 "$BIN" --dry-run --repo-root "$ROOT" --profile code
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"aaa"* ]]
+ [[ "$output" != *"bbb"* ]]
+}
+
+@test "bump 計畫一定同時涵蓋 plugin.json 與 marketplace.json" {
+ # 只 bump 一處時使用者 /plugin update 收不到新版,且無任何錯誤訊息。
+ printf 'key,focus\nzz-brand-new,某個檢查\n' > "${USERDIR}/code.csv"
+ run python3 "$BIN" --dry-run --repo-root "$ROOT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"plugin.json + marketplace.json"* ]]
+}
+
+@test "focus 含逗號與引號不會被切爛(走 parser 不是 naive split)" {
+ printf 'key,focus\nzz-comma,"檢查 a, b, 以及 ""c"" 的情況"\n' > "${USERDIR}/code.csv"
+ run python3 "$BIN" --dry-run --repo-root "$ROOT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"CANDIDATE"* ]]
+ [[ "$output" == *"zz-comma"* ]]
+}
]