Maigo 的 Claude Code plugin 註冊六個 hook,定義在 hooks/hooks.json。
只要 Claude Code plugin 載入就自動生效,使用者不用設定。
Codex manifest 會用空的 inline hooks 覆蓋這組 Claude Code lifecycle hooks; Codex command 仍會顯式執行 review / verification,不共用兩端不同的 hook I/O schema。
session 開啟時觸發。偵測目前 repo 是否命中已知 project,若命中就 emit systemMessage 要求 agent 載入對應的 project-aware skill;未命中則 silent approve。讓 contributor 進到熟悉的 codebase 時,自動拿到該 repo 的慣例知識 (命名、測試模式、PR 規範等),不用使用者手動引用。
- 讀 stdin JSON 取
cwd(缺欄位 → fallbackos.getcwd()) - 把
.maigo接到集中儲存(ensure_maigo_linked,見下) - 確保
.maigo被 git 忽略(ensure_maigo_ignored,見下) - 記下目前 HEAD(
_session_head.record,見下) - 對
REPO_RULES內每個 rule,依序跑其detectors - 任一 detector 命中即視為 rule 命中(OR 邏輯)
- 命中 → emit
approve+ systemMessage(要求載入skills/<skill>/SKILL.md) - 全部未命中 → emit
approve+ 空 systemMessage(silent)
| Project | Skill | Detector 條件 |
|---|---|---|
apache-airflow |
airflow-aware |
git remote 含 apache/airflow,或 airflow/__init__.py 存在且 airflow/models/dag.py / airflow/dag.py 至少有一個 |
commitizen-tools-commitizen |
commitizen-aware |
git remote 含 commitizen-tools/commitizen,或 commitizen/__init__.py 存在且 commitizen/cli.py / commitizen/commands/__init__.py / commitizen/bump.py 至少有一個 |
不分 project,每次 SessionStart 都跑(在 ensure_maigo_ignored 之前)。呼叫
scripts/maigo_state.py 的 ensure_link:<repo root>/.maigo 不存在時,建成指向
本機集中 root($MAIGO_STATE_HOME,預設 ~/.local/share/maigo/state/<owner>/<repo>/)
的 symlink,同一個 repo 的所有 worktree 因此共用一份 .maigo/。細節(identity 規則、
遷移既有目錄)見 .maigo/ 集中儲存。
特性:
- 不碰既有資料:
.maigo已是真目錄、指向別處的 symlink 或一般檔案 → 原樣保留, 要把真目錄併進去用scripts/maigo_state.py migrate(預設 dry-run)。 - 冪等:已是正確 symlink → 不動。
- fail-open:非 git repo、無法 import
maigo_state、或任何例外 → 靜默略過。
不分 project,每次 SessionStart 都跑(在 rule 偵測之前)。maigo 的所有 command
(go / quick / team / review / address-comments)都把 plan(pr/<id>/plan.md /
issue/<id>/plan.md / topic/<slug>/plan.md)、review rubric、review 報告(review/<id>/review.md 或 pr/<id>/review.md)、
pr-comments、retry log 等 artefact 寫進 repo root 的 .maigo/,這些絕不該被
commit。
為了不動到 host repo 被追蹤的 .gitignore(例如 apache/airflow 的 .gitignore
是上游檔案),hook 改寫該 repo 的 info/exclude——路徑用
git rev-parse --git-path info/exclude 解析,所以在 linked worktree 下也會落到
共用 git dir、一次涵蓋所有 worktree。
特性:
- 規則是不帶斜線的
.maigo:.maigo是 symlink,git 把 symlink 當檔案,帶斜線的.maigo/只匹配目錄、匹配不到它;不帶斜線的規則同時涵蓋真目錄與 symlink。 - 冪等:
.maigo已被任何機制忽略(global excludesfile、被追蹤的.gitignore、或前次寫入的 exclude)→ 不重複寫;entry 已在 exclude 裡 → 跳過。 - fail-open:非 git repo、git 不存在、timeout 或任何 OS error → 靜默略過, 不影響 session 啟動。
把「session 開始時的 HEAD SHA」寫進 <repo root>/.maigo/_internal/log/session-head.json({session_id: sha}),
供 Stop hook 判斷這個 session 有沒有 commit 過東西。必須在 rule loop 之前執行——
命中 rule 會 emit 並結束 process。
- 為什麼需要:Stop hook 原本只看 working tree 髒不髒。session 把工作 commit 掉之後 tree 是乾淨的,於是驗證被跳過——而那正是最該驗的時刻(東西進了歷史卻沒跑過測試)
- 不會弄髒 tree:檔案落在
.maigo/,已被上面的ensure_maigo_ignored排除 - 上限:保留最近 200 個 session,超過丟最舊的(dict 插入序)
- Fail-quiet:非 git repo、repo 尚無任何 commit、缺
session_id、寫檔失敗 → 不記錄。 Stop hook 查不到記錄時視為「不知道」,不會因此強制跑 test(見下)
Rule dict 的可選欄位。偵測命中時,hook 在 user project 的 .claude/ 目錄 write-once 寫入指定檔案——若檔案已存在(使用者手動編輯過、或前次 session 已寫入)則跳過,永遠不覆寫。
"claude_config_seeds": {
"skip-test-verification": "...", # key = 檔案名;value = 初始內容
}目前只有 apache-airflow rule 使用此機制:偵測命中時自動寫 .claude/skip-test-verification,讓 Stop hook 跳過 uv run pytest。原因是 Airflow 的 test suite 必須跑在 Breeze container 或透過 uv run --project <PROJECT> pytest <PATH>;在 host 端直接跑會因 jpype1 / cmake FindJava 失敗,且 hook 的 90s timeout 也不夠任何 Airflow subproject 的 test suite 跑完。詳見 skills/airflow-aware/SKILL.md。
如需停用自動跳過,刪除 .claude/skip-test-verification,再視需要加 .claude/test-command 指向一個可在 host 跑的子集。注意優先序:Stop hook 與 verify_task.py 先讀 skip-test-verification,有非空原因就整個跳過——只加 test-command 而不刪 skip 檔,驗證仍然是 skipped(2026-10-29 apache/airflow 某 worktree 的實例:review-only 時期留下的 skip 檔讓後來的 address-comments 落地差點在未驗證狀態下 commit)。這個標記是 per-worktree、無期限的,worktree 從「只做 review」轉成「要落地 code」時要主動清掉。
type |
參數 | 命中條件 |
|---|---|---|
git_remote |
pattern: <substring> |
git config --get remote.origin.url 輸出含 pattern |
file_structure |
all_of: [paths] / any_of: [paths] |
all_of 全部存在 且(若有 any_of)至少一個存在 |
- stdin JSON 解析失敗 → 視為空 dict,仍 fallback
os.getcwd()繼續跑 - 個別 detector 拋例外(
subprocess.TimeoutExpired/FileNotFoundError/OSError)→ skip 該 detector,繼續下一個 - 個別 rule
match_rule拋例外 → skip 該 rule,繼續下一個 - 頂層 unhandled exception → stderr 印一行,emit 空 approve
5 秒上限(hooks/hooks.json 設定),單個 git subprocess 內部另設 3 秒上限
(GIT_TIMEOUT_SEC)。偵測都是本機檔案或 git config 讀取,毫秒級完成;5 秒
為保護性上限。
擴充偵測範圍走兩步:
- 加 registry entry — 編輯
hooks/repo_detect.py的REPO_RULESlist, append 一個 dict:{ "name": "<project-name>", "skill": "<skill-name>", # 對應 skills/<skill-name>/SKILL.md "detectors": [ {"type": "git_remote", "pattern": "<org>/<repo>"}, # 可選:加 file_structure detector 當備援 {"type": "file_structure", "all_of": ["<sentinel-file>"], "any_of": ["<alt-file-1>", "<alt-file-2>"]}, ], } - 建對應 skill — 依 skills.md 加新 skill 的 checklist 建
skills/<skill-name>/SKILL.md。 skill 內容定位為「knowledge layer」(contributor 慣例),參考skills/airflow-aware/SKILL.md結構: 開頭寫**Loaded by**: repo-detect hook (SessionStart) when <project> is detected, 後接 When to apply / 命名 / 環境 / 測試 / PR 等子段。 若該 repo 的語言已有語言層 skill(目前:python-aware),開頭要加組合宣告 (先讀語言層;優先序 repoAGENTS.md> repo-aware > 語言層),內文只列 repo 專屬規則。 分層慣例見 skills.md 的 Language layer 段。
完成後跑 uv run python scripts/validate_plugin.py 確認 skill cross-ref 通過
(validator 會抓 repo_detect.py 引用的 skills/<name>/ 是否存在)。
只匹配 Agent 工具。交辦 subagent 之前掃 tool_input.prompt,
擋下把「字面 grep 的命中數」當成抽象性質證明的驗收條件。
擋的判準(同一行同時成立才算):
| 條件 | 說明 |
|---|---|
| 是條列項或含「驗收」 | - / * / 1. / - [ ] 開頭,或該行出現「驗收」 |
出現 grep / rg / ripgrep |
掃描工具 |
| 出現歸零/計數斷言 | 為零 / 為 0 / 回 0 / = 0 / 應為 0 / 零命中 / 只准剩 / no matches |
| 沒有限縮到本次改動 | 含 git diff / 新增行 / 本次新增 / 本次修改 的行不擋——那是教訓給的正解 |
| 不是在引用規則本身 | 含 不要 / 不得用 / 別用 / 禁止 / 避免 / 反例 的行不擋 |
出處是登記在案的教訓家族「字面 grep 當判準」(2026-07-25/07-31/08-11,累計 3 次): pattern 會命中正當內容(註解、對照表、fixture、詞彙表)或無關的共現字串, 判準回非預期值時錯的往往是判準而不是產物,而照字面「修好它」比原狀更糟。
同一組偵測邏輯(hooks/_grep_criteria.py)也被 SubagentStop 的
燈 (Tomori) 檢查重用,掃她剛寫進 .maigo/pr/<id>/plan.md(或 issue/ / topic/ 底下同名檔)的驗收條件。
PreToolUse 的 approve 等於跳過權限系統。這個 hook 沒有資格代替使用者做那個決定,
所以只在命中時 block,放行一律靜默 exit 0,不碰權限流程。
- input 不是有效 JSON、
tool_input不是 object、prompt不是非空字串 → 靜默放行
5 秒上限。純 regex,毫秒級完成。
匹配 Write / Edit 兩種工具。擋下寫入 .maigo/ 底下兩種舊形狀的產物:
- 舊固定檔名(
plan.md/review-rubric.md/review.md/review-draft.md/triage-rubric.md/pr-comments.md/reply-draft.md) - 巢狀 kind 的分目錄前扁平檔(
review-rubric-42.md這類,kind是nested_locations(kind)非空的 kind,頂層plan-<id>[-<n>].md也在內—— plan 已改收進pr//issue//topic/;review-batch-state.md/review-board.md這兩個字面上像但不是的檔名先排除,不會被誤擋)
——跨 13 個實際安裝 maigo 的 repo 實測,散文說「一律呼叫
scripts/artifact_path.py」的採用率是 0%,這支 hook 改用程式碼擋。
反向判準:kind 清單動態組自 from artifact_path import _KNOWN_KINDS
(不重複列一份 kind 清單字面值),regex 錨定 .maigo/ 目錄下——第一種
regex 要求 <kind>.md(stem 整個等於某個 kind);第二種要求 <kind>-<id>.md
且 kind 屬於巢狀 kind(含 plan)——.maigo/review/42/rubric.md、
.maigo/pr/42/reply-draft.md、.maigo/topic/x/plan.md 這類巢狀路徑、board.md、
local-model-dispatch-plan.md、.maigo/i/9201.md 都天然不命中,不必特別寫排除規則。
擋下頂層 plan-<id>.md 時,訊息改列 pr/ / issue/ / topic/ 三種新形狀,並提示
migrate_legacy_artifacts.py 可把既有的頂層 plan 搬進 topic/<id>/。
命中時 block,訊息附上正確的 artifact_path.py 呼叫指令範例,分目錄前扁平
檔另提 scripts/migrate_legacy_artifacts.py 可搬既有檔,以及
artifact-ownership
規則 4 的提醒。
同 delegation_criteria_check.py:PreToolUse 的 approve 等於跳過權限系統,
這個 hook 沒有資格代替使用者做那個決定,所以只在命中時 block,放行一律靜默
exit 0。
- input 不是有效 JSON、
tool_name不是Write/Edit、tool_input不是 object、file_path不是非空字串 → 靜默放行
5 秒上限。純 regex,毫秒級完成。
兩條路徑都寫入目前 repo 根目錄的 .maigo/_internal/log/token-usage.jsonl,不重新送內容給模型、不跑 tokenizer,也不自行估算:
- PostToolUse(matcher
Agent):foreground subagent 完成時,直接讀 Claude Codetool_response.usage提供的 metadata。 - SubagentStop(另一個不帶 matcher 的 group,記錄所有 agent 類型,不限 Maigo 角色):
讀 payload 的
agent_transcript_path,把 assistant 行的message.usage四個 token 欄加總, 同一個message.id只算一次(transcript 會把同一則訊息拆成多行重複記錄)。 這條路徑讓 background agent 也有紀錄,因為它們的 PostToolUse 拿不到 usage。model取 transcript 的requestedModel,可能是別名(例如sonnet),與 PostToolUse 的resolvedModel完整 ID 並存時,breakdown的 model 分組會出現兩種命名。 payload 缺agent_transcript_path時直接略過,不推導路徑。 transcript 格式 Claude Code 並未文件化,讀取器全程 fail-open:壞行、缺欄位、檔案不存在都靜默略過; 讀不到可用資料時最多再試 2 次、間隔 150ms。
每行一筆 JSON:
{"ts":"2026-07-16T12:00:00Z","session_id":"...","agent_id":"...","agent_type":"maigo:Soyo","model":"claude-sonnet","input_tokens":8320,"output_tokens":1230,"cache_creation_input_tokens":400,"cache_read_input_tokens":2500}兩條路徑共用同一份 log,以 session_id + agent_id 去重,所以 foreground agent 只會記一筆。下列情況靜默略過:
- PostToolUse:
status不是completed(包含 background agent 的async_launched) - PostToolUse:harness 沒提供
usage;SubagentStop:transcript 不存在、不可讀或四欄全為 0 - malformed input、無法建立
.maigo/或寫檔失敗 - Codex plugin(Codex manifest 不安裝 Claude Code lifecycle hook)
Stop hook 成功放行時會依 session_id 彙整並附上一行摘要;資料不完整時只報
「已追蹤 N agents」,不宣稱是完整總量。完整本機彙整可執行:
python3 scripts/token_usage_summary.py # 最近 7 天,含角色/model 分組
python3 scripts/token_usage_summary.py --days 30 # 自訂觀察期間由 Agent 工具啟動的 Maigo subagent 回覆完成時觸發。Matcher 限定 Maigo 的五位角色,
以 agent_type(例如 maigo:Soyo)辨識角色,優先從 last_assistant_message 取得輸出;
缺少或為空時(以 SubagentHandback 結尾的 agent 不會帶這個欄位),改讀 agent_transcript_path
指向的 transcript,取最後一次 SubagentHandback 的 input.message,再沒有就取最後一則 text。
兩者都拿不到才回報「缺少 last_assistant_message」。並以事件提供的 cwd 檢查 artifact、寫入 retry log。
不符合最低規格就輸出 {"decision":"block","reason":"..."},exit 0;通過時省略
decision。這是 SubagentStop 的官方契約。
原先使用的 TeammateIdle 屬於 agent team 的閒置事件,沒有直接提供角色輸出,已移除該註冊。
限制:agent 以 SubagentHandback 結尾時,block 不一定能讓它重新跑起來(agent 已經交棒結束),
實際效果可能只剩 systemMessage 的告知。這點目前只是從 transcript 尾端推論,尚無 runtime 驗證;
block 語意不因輸入來源(payload 或 transcript)而分流。transcript 格式未文件化,讀取採 fail-open。
| Agent | 必須包含 | 違反時的 block message |
|---|---|---|
| Raana | ## Loaded memory entries 段(即使無相關 entry 也要明寫「(無相關 entry)」) |
「缺 memory 載入回報」 |
| Tomori | ## Loaded memory entries 段 |
「缺 memory 載入回報」 |
| Tomori | 提到 .maigo/pr/<id>/plan.md、.maigo/issue/<id>/plan.md、.maigo/topic/<slug>/plan.md、.maigo/review/<id>/rubric.md、.maigo/pr/<id>/rubric.md 或 .maigo/issue/<id>/rubric.md 路徑 |
「沒提到計畫檔路徑」 |
| Tomori | 結構段落:## Goal / ## Steps / ## Rubric / ## Acceptance / ## 目標 / ## 步驟 之一 |
「缺計畫結構」 |
| Tomori | plan(pr/<id>/plan.md / issue/<id>/plan.md / topic/<slug>/plan.md)的驗收條件不得把字面 grep 命中數當性質證明(判準同 PreToolUse;讀不到檔案就 fail-open) |
「把字面 grep 的命中數當成抽象性質的證明」 |
| Soyo | ## Loaded memory entries 段 |
「缺 memory 載入回報」 |
| 🟡 Soyo | ## Verdict 內的 review verdict:APPROVED / NEEDS_CHANGES / BLOCKED;triage:READY / NEEDS_INFO / DUP / CLOSE |
「沒下 verdict」 |
| 🟡 Soyo | ## Checklist (mode=<name>) 段依序保留至少 9 項;依 共用模式資料 核對 full/quick/design-preview/compliance-only/test-only;code-review 的每個略過項附一致的 skipped by mode=<name>,triage 非 bug 情境可略過 2–4 |
「checklist 不完整、mode 矛盾或略過必要項目」 |
| 🟡 Soyo | APPROVED / READY 時不可有 [ ] |
「尚有未通過項目」 |
| 🟡 Soyo | review 非 APPROVED 時:must-fix / 改法 / evidence / 待補 之一 |
「擋下卻沒列 must-fix」 |
| Soyo | 同 must-fix key 連續 ≥ 2 次 | block reason 前綴 ⚠️ RETRY LIMIT REACHED (Soyo): |
| Taki | exit <number> 模式 |
「沒貼 exit code」 |
| 🟣 Taki | ## Verdict 內只有一個 PASS 或 FAIL |
「沒給最終 verdict」 |
| 🟣 Taki | ## Commands 每列為 - `command` — exit <code>;PASS 時每個不同 command 的最新 exit 都必須是 0;歷史結果另列 ## Previous attempts |
「PASS 與 exit code 矛盾或缺少檢查結果」 |
| Taki | 不能包含 should work / looks good / 應該可以 / 看起來沒問題 等 hedge 語 |
「verifier 只能拿 exit code 講話」 |
| Anon | 至少一個 file path reference(regex 抓 *.py / *.md / *.yml / *.yaml / *.json / *.toml / *.txt / *.sh / *.cfg) |
「沒看到檔案路徑 reference」 |
舊報告沒有 ## Verdict 時仍接受唯一一行獨立 verdict,敘述中提到的詞不算。
沒有 ## Commands 的舊驗證報告只接受單一 exit code,避免匿名結果互相抵銷。
🟡 Soyo verdict 非 APPROVED 時,hook 從輸出抽 must-fix 條目,以
backtick 內的 file path(去掉 :line)當 key;沒有檔案引用時用 normalized 文字。
這是格式層的近似 key;是否為同一個語意問題仍由 orchestrator 依 failure-handling 判斷。
.maigo/_internal/log/soyo-must-fix.jsonl 每行記錄 ts、scope 與 must_fix_keys。
SubagentStop 用真實事件的 session_id / agent_id 作為 run / task 範圍:
同一 agent 的相鄰結果都含同一 key、達 SOYO_RETRY_LIMIT(預設 2)時,
block reason 加上 ⚠️ RETRY LIMIT REACHED (Soyo):。APPROVED 寫入空 keys,清除該範圍的連續計數。
換 session 或 agent 就重新計數;換新 agent 做 re-review 時,orchestrator 仍須自行延續同一邏輯任務的預算。
缺少任一識別碼或舊紀錄沒有 scope 時,只留歷史證據,不累計跨呼叫的 retry limit。 各範圍可以交錯寫入;同一範圍的重試須依序執行。
- 不在已知名單的角色 → 預設通過
- 沒有有效
agent_type、非 Maigo 角色,或 input 不是有效 JSON object → 略過,不輸出 decision - 已辨識的 Maigo 角色缺少
last_assistant_message、或cwd無效 → block
這是輸出格式檢查,不能證明 checklist 判斷正確或 command 真的執行過;實際測試證據由 下節的顯式驗證 CLI 取得。Review 的 NEEDS_CHANGES/BLOCKED 格式完整時可交回 orchestrator, 不代表變更已獲 APPROVED。
30 秒上限。hook 只做 regex match,理論上毫秒級完成;30 秒為保護性上限,不應在正常情況被觸發。
編輯 hooks/teammate_quality_check.py,在 ROLE_HANDLERS 字典加一個新 mapping,
並寫對應的 check_<role> 函式。每個 handler 都要呼叫 emit("block", ...) 或 emit("approve", ...)。
quick 在所有宿主都明確執行以下 CLI;不需要 lifecycle hooks、subagents 或特定模型。
--cwd 必須指向本次工作目錄,即使該 repo 乾淨、工作已 commit,也會執行驗證。
python3 /path/to/maigo/scripts/verify_task.py --cwd /path/to/project
python3 /path/to/maigo/scripts/verify_task.py --cwd /path/to/project --command 'uv run pytest tests/test_example.py'CLI 與 Stop hook 共用 run_verification()。stdout 為單一 JSON object,包含
schema_version、cwd、開始/結束時間、status、reason、實際執行的 command、
子程序 exit_code 和 output。未執行或無法取得完成狀態時,exit_code 為 null。
可用 shell redirect 保存本次結果;改動後必須重跑。
首次呼叫會回傳新 run_id 與 task_id。同一任務重試時,沿用該次 JSON 的兩個 ID:
python3 /path/to/maigo/scripts/verify_task.py --cwd /path/to/project \
--run-id <previous-run-id> --task-id <previous-task-id>兩個旗標須一起給;新任務省略兩者以取得新範圍,或明確指定新的 task ID。 重試計數還會按實際 argv 分開,避免 lint 通過清掉另一個 test command 的失敗。 同範圍同 command 的相鄰可判定結果才累計:本次未出現的失敗 key 不延續;通過或只剩已知失敗時寫入空 keys 重設。 無法執行、逾時、skip 等沒有有效測試結果的情況,不作為成功重設。 舊的無 scope 紀錄保留供 doctor 歷史統計,不用來觸發當前任務的 retry limit。
| status | CLI exit | 意義 |
|---|---|---|
passed |
0 | 該次驗證 command exit 0 |
failed |
1 | 測試或 collection/設定檢查失敗 |
unavailable |
2 | 無 runner/測試設定、無法啟動、逾時、或 host build env 失敗 |
skipped |
3 | .claude/skip-test-verification 已設定原因,沒有執行測試 |
known_failures |
4 | command 非零,能解析的失敗都在已知失敗名單中 |
--command 優先於 .claude/test-command,以 argv 解析,沒有 shell expansion。
既有 skip 設定仍生效。未指定 command 時沿用以下 Stop hook 的 runner 偵測與設定。
CLI 的 status 不代表 review 已通過,也不涵蓋未執行的其他檢查。
沒有 hook 的宿主仍靠 command 顯式呼叫,不能宣稱有機器強制的 completion gate。 Stop hook 保留既有 skip/known-failure 放行政策;CLI 用獨立狀態和非零 exit code 表示這些例外, 讓呼叫端依已授權的政策決定後續動作,不能把它們當成測試全綠。
任務宣告完成前觸發。即使 orchestrator 想跳過 Taki 也擋下。成功 approve 時,若 input
帶有 session_id,會附上該 session 的 token usage 一行摘要;不會把完整 JSONL 放進訊息。
Stop 事件沒有可靠的 task ID,因此這條入口不累計跨呼叫的重試次數;失敗仍照常 block。 需要任務級計次時用上述顯式 CLI,orchestrator 仍遵守 failure-handling 的重試預算。
| 偵測到 | 跑什麼指令 |
|---|---|
uv.lock |
uv run pytest -x |
pyproject.toml 或 setup.py + tests/ 或 test/ 目錄 |
pytest -x |
package.json 內 scripts.test 存在 |
npm test --silent |
Cargo.toml |
cargo test --quiet |
go.mod |
go test -failfast ./... |
| 都沒有 | 跳過(no-op approve) |
-x / -failfast 是讓單一失敗就停的旗標,省下 90s timeout 餘額給 parsing & emit。
cargo test 本身對編譯錯誤即 abort,npm test 的 script 是 user-defined,這兩個不強加旗標。
| 檔案 | 行為 |
|---|---|
skip-test-verification |
第一行非空非註解視為原因,整個檢查跳過 |
test-command |
完全覆寫 test 指令(用 shlex.split 解析,支援引號;以 argv 執行,無 shell expansion) |
known-test-failures |
已知失敗名單(一行一個),不擋這些;只擋「新的」失敗。要寫 pytest 印出的完整 ID(例如 tests/x.py::test_y[a b],含參數化方括號),比對方式是整行字串相等 |
test-command 是 argv,不是 shell command line——shlex.split 之後直接 subprocess.run,所以 &&、||、|、;、重導向、glob、變數展開全部不生效,它們會變成前一個指令的參數。要跑多個 target 就用單一指令帶多路徑(pytest <path1> <path2>);真的需要串接,包成 script 再讓 test-command 指向它。
實例(2026-09-17,apache/airflow worktree):test-command 寫成 uv run --project A pytest <path1> && uv run --project B pytest <path2>,hook 回報 unrecognized arguments、suite 根本沒跑——&& 之後整串都成了第一個 pytest 的參數。改用 uv run --project A pytest <path1> <path2>(該 monorepo 的兩個 target 共用同一個 workspace venv)後通過。注意 bash -c "$(< .claude/test-command)" 這類自我驗證不會重現這個失敗,因為那條路徑有 shell;要複現 hook 的行為,得照它的方式 shlex.split 再 subprocess.run。
這三個檔的查找位置是 cwd/.claude/,其中 cwd 來自 stdin JSON——而該值會跟著 session 的 shell 工作目錄移動,不是固定在 repo 根。在 monorepo 的子目錄工作時(例:cd airflow-core/src/airflow/ui),claude_dir 變成 <子目錄>/.claude;repo 根那三個檔全部靜默失效,detect_test_command 也改用子目錄的標記重新偵測。
實例(2026-08-19,apache/airflow):session 在 airflow-core/src/airflow/ui 下操作後,hook 跑的是 npm test --silent(該目錄有 package.json),而非 repo 根該偵測到的 uv run pytest -x(根有 uv.lock 且 uv 在 PATH);同時根目錄既有的 known-test-failures(已列 4 個已知失敗檔)與 skip-test-verification 都沒生效,那 4 檔被重新報成「新失敗」。
判斷方法:hook 訊息裡的指令名就是線索——跑的指令若不是 repo 根該偵測到的那個,claude_dir 就不在根目錄。要讓設定生效,把 .claude/ 放在 hook 實際解析到的那層目錄。
偵測到 \bImportError: / \bModuleNotFoundError: / \bSyntaxError:(必須有冒號,
避免誤判 test 名稱裡的字串)→ 視為 collection 錯,比 test 失敗更優先擋下,
message 強調「這不是 test fail,是 import 錯」。
| 框架 | 模式 |
|---|---|
| pytest | FAILED <name> 或 <file>::<test> FAILED;<name> 是完整 node ID,含參數化方括號內的空白與巢狀 [] |
| jest | FAIL <file>.test.[jt]sx? |
| cargo test | test <name> ... FAILED |
| go test | --- FAIL: <name> |
抓不出名稱但 exit 非 0 → 仍 block,附最後 500 chars 原始 output。
預設 90 秒(hook 自身 timeout 120 秒,留 30 秒 buffer)。
編輯 TEST_TIMEOUT_SEC 常數可調。
專案有 uv.lock / pyproject.toml(或其他類型標記)但實際沒有測試套件、或測試框架沒裝時,hook 仍會跑偵測到的指令並擋下。例:Python repo 沒有 pytest,uv run pytest 回 error: Failed to spawn: pytest / No such file or directory (os error 2)(exit 2);這不在 BUILD_ENV_ERROR_RE 也不是 fatal marker,所以會 block,並在每次收尾重複觸發成 loop。
緩解(任一):
- 在專案放
.claude/skip-test-verification(第一行寫原因)讓整個檢查跳過 - 或補上最小測試套件 + 對應依賴,讓指令真的 exit 0
- 偵測不到任何專案類型(沒有
uv.lock/pyproject.toml/package.json/Cargo.toml/go.mod)→ approve(no-op) .claude/skip-test-verification存在 → approve 並記錄原因- 本次 session 無檔案修改(read-only session)→ approve,跳過 test 驗證。兩個條件都成立才跳過:
- working tree 乾淨——
git status --porcelain回傳 exit 0 且 stdout 為空;returncode != 0(非 git repo 等)→ fail-open,照常跑 test - HEAD 未離開 SessionStart 記下的 SHA(
_session_head.head_moved)——所以 commit 過的 session 照樣會被驗 查不到本 session 的 HEAD 記錄時視為「不知道」→ 只看條件 1,維持舊行為。這是刻意的: 部分 harness 不跑 SessionStart hook,若在那裡把「不知道」當成「有改動」,每個唯讀 session 都會被拖去跑整套測試
- working tree 乾淨——
- stdin JSON 解析失敗或無
cwd欄位 → fallback 到os.getcwd(),照常嘗試偵測;如果偵測不到還是會 no-op approve
要看 hook 真的有跑、回傳什麼:
# 模擬 SubagentStop 觸發
python3 -c "import json; print(json.dumps({'hook_event_name':'SubagentStop','agent_type':'maigo:Soyo','last_assistant_message':'## Verdict\nBLOCKED\n## Checklist\n- [ ] foo'}))" \
| python3 hooks/teammate_quality_check.py
# 模擬 SubagentStop 的 transcript fallback / token usage(先準備 fixture JSONL,
# 每行一筆 {"type":"assistant","message":{"id":"m1","usage":{...},"content":[...]}})
python3 -c "import json; print(json.dumps({'hook_event_name':'SubagentStop','session_id':'demo','agent_id':'agent-1','agent_type':'general-purpose','cwd':'/path/to/project','agent_transcript_path':'/path/to/agent.jsonl'}))" \
| python3 hooks/token_usage.py
# 模擬 Stop 觸發
python3 -c "import json,sys; print(json.dumps({'cwd':'/path/to/project'}))" \
| python3 hooks/verify_completion.py
# 模擬 Write 到舊固定檔名(應該 block)
python3 -c "import json; print(json.dumps({'tool_name':'Write','tool_input':{'file_path':'.maigo/plan.md'}}))" \
| python3 hooks/legacy_artifact_path_check.py
# 模擬 foreground Agent usage metadata
python3 -c "import json; print(json.dumps({'hook_event_name':'PostToolUse','tool_name':'Agent','session_id':'demo','cwd':'/path/to/project','tool_input':{'subagent_type':'maigo:Soyo'},'tool_response':{'status':'completed','agentId':'agent-1','resolvedModel':'claude-sonnet','usage':{'input_tokens':100,'output_tokens':20}}}))" \
| python3 hooks/token_usage.py