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"* ]] +}