Author SHA1 Message Date
jiantw83 b23c546079 docs(hooks-install): 據實寫明各 CLI 的註解範圍掃描時機
What:`skills/hooks-install/SKILL.md`、`README.md`、`AGENTS.md` 三份文件一併改寫註解範圍的覆蓋範圍說明:`comment-scope.sh` 由兩種模式改為三種,並以表格列出五個 CLI 各自的掃描時機——claude 逐檔即時(PostToolUse)、codex 每輪結束(`notify`)、kiro 每輪提示送出時(`userPromptSubmit`,掃的是上一輪寫的檔)、copilot 與 antigravity 只有工作階段結束時由 `tools/jsc-wrap.sh` 收尾掃一次。README 的 `tools/jsc-wrap.sh` 那列補上收尾 sweep 與「不影響結束碼」的約定,`smoke` 例外說明改成掃描模式通用。

Why:舊文件寫的是「四個 CLI 只剩規則提示」,接上 sweep 之後那句話已經不實。但也不能倒過來寫成五支一樣:時機差一輪或差一整個工作階段,操作者要知道自己現在用的 CLI 什麼時候才會收到警告。文件不同步,操作者會對保護程度有錯誤預期。

How:SKILL.md 全份維持英文,正文改用一張 CLI 對掃描時機的表格,並註明 sweep 讀的是 `git diff HEAD`、涵蓋範圍與 claude 相同、不在 git 工作區內就安靜 exit 0,frontmatter 的 `description` 不動;README.md 維持 STE100 繁中,hook 一覽表那列補上三種模式與各 CLI 時機,原本的降級段落換成同一張表;AGENTS.md 的第 5 條補上三種模式與「不得寫成五支一樣」的要求。

Who:`jsc-hooks` 的文件層與 `hooks-install` 技能,供操作者與後續 sub agent 依循。
2026-08-27 09:16:32 +08:00
jiantw83 0bebdef46f feat(wire-cli): 把 comment-scope sweep 接到其餘四個 CLI
What:`tools/wire-cli.sh` 把 `comment-scope.sh sweep` 接進 codex 的 `config.toml` 根層 `notify` 與 kiro 的 `.kiro/hooks/jsc-hooks.json` 的 `userPromptSubmit`;`tools/jsc-wrap.sh` 在 CLI 結束後、回傳結束碼之前跑一次 sweep,涵蓋 copilot、antigravity 與同樣走包裝器的 codex。`smoke` 多跑一輪 sweep,`status` 多盤點 codex 的 `notify-sweep` 與 kiro 的 `comment-scope-sweep` 兩項。

Why:這四個 CLI 沒有 post-tool hook,接不到逐檔即時掃描,先前只寫得進規則提示,違規註解寫進去沒有人叫。接上 sweep 之後五個 CLI 都掃得到,差別只剩回饋速度。`smoke` 與 `status` 不同步補上,就驗不出來接線少了哪一支。

How:codex 的 notify 串成 `start; mark; sweep`,位置仍由 `replace_block_toml` 保證落在第一個表頭之前,寫入後除了既有的根層鍵檢查,再 grep 一次確認 sweep 真的串進那一行。kiro 的 `run` 尾端接上 sweep,`prompt` 與 `sweep` 各驗一個 grep,只驗腳本名會漏掉少接的那一個。`jsc-wrap.sh` 的 sweep 加 `|| true` 接住 exit 2,包裝器一律原樣回傳 CLI 自己的結束碼——包裝器改掉結束碼,呼叫端的 `cmd && next` 就會誤判。`smoke` 的 exit 2 白名單由「無參數模式」放寬到「所有掃描模式」,因為 sweep 在髒工作區本來就會回 2,那是 hook 正常工作,不是 hook 壞掉。四個 CLI 的 `reason` 與 `[jsc]` 說明改寫成各自真正的掃描時機。

Who:`jsc-hooks` 的接線工具層,供 `jsc-hooks:hooks-install` 與 `jsc-hooks:repair` 呼叫,最終服務 codex、copilot、antigravity、kiro 的使用者。
2026-08-27 09:16:15 +08:00
jiantw83 8ede74e9e2 feat(comment-scope): 新增 sweep 模式,掃整個 git 工作區
What:`hooks/comment-scope.sh` 由兩種模式變三種,新增 `sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,命中就把報告與最多三行證據送到 stderr 並 exit 2,找不到 git 就安靜 exit 0。`prompt` 與無參數單檔掃描兩個模式的判定邏輯一行不動。

Why:只有 claude 有 PostToolUse,拿得到「剛剛寫了哪個檔」。codex、copilot、antigravity、kiro 四個 CLI 都沒有 post-tool 事件,註解範圍檢查在那邊只剩規則提示,違規註解寫進去了不會有人叫。改掃整個工作區的 git diff,時機晚一點,涵蓋範圍一樣。

How:單檔判定抽成 `scan_file()`,兩種掃描模式共用同一份禁止樣式與白名單,不會各自漂移。`sweep` 以 `git rev-parse --show-toplevel` 找庫根,所以在子目錄跑也掃得到整個庫;逐檔報告累積在暫存檔再一次輸出,因為迴圈跑在管線的子行程裡,變數帶不回本 shell。

Who:`jsc-hooks` 的 hook 實作層,供 `tools/wire-cli.sh` 接線給 codex、copilot、antigravity、kiro 四個 CLI 使用。
2026-08-27 09:15:56 +08:00
jiantw83 7690b697c7 feat(manifest): 三份 manifest 版本升到 0.1.9
What:`plugin.json`、`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 三份 manifest 的 `version` 由 0.1.8 升到 0.1.9。

Why:本次新增了第六支 hook,屬於功能增修。版本不升,`version-guard.sh` 的版本前置檢查與 `jsc-cli:deploy` 的更新判斷都看不出本機落後,使用者不會收到更新提示。

How:三份檔案同步改同一個版本號,維持三份 manifest 版本一致的既有慣例。

Who:`jsc-hooks` 的發佈中繼資料,供 `hooks/version-guard.sh` 與 `jsc-cli:deploy` 比對版本。
2026-08-26 19:01:36 +08:00
jiantw83 5d60e0f231 feat(hooks-install): 文件同步六支 hook 與降級說明
What:`skills/hooks-install/SKILL.md`、`README.md`、`AGENTS.md` 三份文件一併改口徑為六支 hook,補上 `comment-scope.sh` 的職責、兩種模式、環境變數 `JSC_COMMENT_SCOPE` 與 `JSC_CHANGED_FILE`,以及各 CLI 的覆蓋範圍差異。

Why:文件停在五支會誤導操作者,讓人以為每個 CLI 都受同等保護。實際上只有 claude 有 post-tool hook,其他四個 CLI 只接得到規則提示,這個落差必須據實寫明。

How:SKILL.md 的 `description` 與正文加上 comment scope scanner,並註明 exit 2 在冒煙測試裡算健康;README.md 的 hook 表格新增一列,接線工具那列改寫 `smoke` 的例外說明,環境變數表補兩列,另加一段講明四個 CLI 的降級實情;AGENTS.md 新增一條規則,寫明規則正文的唯一來源在 `jsc-review`,本存取庫不留副本。

Who:`jsc-hooks` 的文件層與 `hooks-install` 技能,供操作者與後續 sub agent 依循。
2026-08-26 19:01:36 +08:00
jiantw83 e2232d26f1 feat(wire-cli): 接線腳本納入註解範圍 hook,五支改六支
What:`tools/wire-cli.sh` 新增 `comment_scope_text()`、`rules_text()` 與 `has_comment_scope()` 三個函式,把 `comment-scope.sh` 的 `prompt` 模式接進 codex、copilot、antigravity 的規則檔與 kiro 的 hook JSON,`smoke` 與 `status` 兩個子命令也補上這支 hook,全腳本由五支 hook 改口徑為六支。

Why:`hooks/hooks.json` 只服務 claude;其他四個 CLI 要靠這支腳本接線,不改就完全接不到新規則。`smoke` 與 `status` 不同步補上,接線成功與否也驗不出來。

How:`comment_scope_text()` 直接取 `comment-scope.sh prompt` 的實際輸出當規則文字,不在本存取庫留規則清單副本;`rules_text()` 把 STE100 與註解範圍併進同一個 `<!-- jsc-hooks -->` 標記段落,重跑等同先移除再重裝;`has_comment_scope()` 供 `status` 判斷標記段落在不在。`smoke` 把 `comment-scope.sh` 無參數模式的 exit 2 視為設計行為,與 `sdlc-gate.sh check` 同列例外。

Who:`jsc-hooks` 的接線工具層,供 `jsc-hooks:hooks-install` 與 `jsc-hooks:repair` 呼叫。
2026-08-26 19:01:36 +08:00
jiantw83 f81ceaf145 feat(comment-scope): 新增註解範圍檢查 hook 並接進 claude 的 hooks.json
What:新增第六支 hook `hooks/comment-scope.sh`,並在 `hooks/hooks.json` 補上兩個接線點:UserPromptSubmit 走 `prompt` 模式、PostToolUse 的 `Write|Edit|MultiEdit` 走掃描模式。

Why:程式碼註解常被寫進工單編號、專案代號、負責人這類文件相關資訊,讓註解變成過期文件。過去只能靠 `/jsc-review:code-review` 事後抓,回饋太慢;把規則搬到寫檔當下,模型可以立刻修正。

How:`prompt` 模式印出規則摘要注入提示。無參數模式從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只掃 `git diff HEAD` 的新增行,不翻舊帳;markdown、純文字、資料檔與二進位檔一律跳過。命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正,不擋寫入。逃生門為 `JSC_COMMENT_SCOPE=off`。規則正文的唯一來源在 `jsc-review` 的 `references/comment-scope.md`,本存取庫不留副本。

Who:`jsc-hooks` 的 hook 層,服務 `jsc-hooks:hooks-install` 的接線流程與 `jsc-review:code-review` 的註解契約檢查。
2026-08-26 19:01:36 +08:00
admin fcd77013ea Merge pull request 'fix/per-package-work-package-lock-file' (#23) from fix/per-package-work-package-lock-file into develop
Reviewed-on: #23
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-26 10:07:27 +00:00
jiantw83 221742486b fix(sdlc-gate): 工作包鎖檔改依索引區分,避免平行工作包互相覆蓋並修正提示文字 2026-08-26 17:59:21 +08:00
admin 388d967aab Merge pull request 'feat/hooks-wire-cli-status' (#21) from feat/hooks-wire-cli-status into develop
Reviewed-on: #21
2026-08-26 02:51:19 +00:00
jiantw83 f1bcdc3d72 feat(wire-cli): 新增唯讀的接線盤點子命令
What: wire-cli.sh 新增 status 子命令,只讀設定檔判斷 jsc 標記段落在不在,不寫檔也不執行 hook。每個接線點印一行 item,以 status=wired、degraded、unwired、skipped 回報。
Why: 體檢類技能要問「hook 接線在不在」,但既有三個子命令都會動到環境:不帶子命令會重新接線、purge 會刪掉、smoke 會真的執行 hook。體檢不該有副作用。
How: 沿用底下接線區塊的同一組檔案位置與標記字串,共用 has_block、toml_root_key、json_top_key 三個判讀函式。codex 的 notify 另外單獨檢查是否落在根層,標記在、鍵被歸進表裡時 codex 讀不到,等同沒接。
Who: 供 jsc-cli:doctor 呼叫的接線檢查。
2026-08-26 10:44:47 +08:00
admin 88c7d37c2d Merge pull request 'feat/work-package-pr-gate-hook-enforcement' (#20) from feat/work-package-pr-gate-hook-enforcement into develop
Reviewed-on: #20
2026-08-25 11:03:24 +00:00
jiantw83 65d3c13d22 chore(hooks): 三份 manifest 同步升版到 0.1.7 2026-08-25 18:59:29 +08:00
jiantw83 26a5a4690d docs(hooks): 補上工作包 PR 閘門的 hook 說明與逃生門 2026-08-25 18:59:29 +08:00
jiantw83 b9ad321930 feat(sdlc-gate): 新增工作包 PR 閘門,未結清就擋下別的階段技能 2026-08-25 18:59:29 +08:00
admin 291909cfed Merge pull request 'feat/hooks-install-purge-first-and-repair-on-any-hook-error' (#18) from feat/hooks-install-purge-first-and-repair-on-any-hook-error into develop
Reviewed-on: #18
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 10:20:02 +00:00
jiantw83 e62511b57f chore(hooks): 三份 manifest 同步升版到 0.1.6 2026-08-25 18:18:10 +08:00
jiantw83 6ecb7d0387 docs(hooks): 同步 purge、smoke 與錯誤掃描的工具與技能說明 2026-08-25 18:18:10 +08:00
jiantw83 b45a98af87 feat(hooks-install): 安裝改為先清空所有 hook 再接線並驗執行期 2026-08-25 18:18:10 +08:00
admin 881aabee83 Merge pull request 'feat/auto-repair-failed-hook-wiring' (#16) from feat/auto-repair-failed-hook-wiring into develop
Reviewed-on: #16
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 08:58:41 +00:00
jiantw83 ad2f944a66 fix(hooks): 修正版本號為單位數格式 2026-08-25 16:39:49 +08:00
jiantw83 c68292acbc feat(hooks): 自動接手失敗接線與修復流程 2026-08-25 16:33:38 +08:00
admin 8fc1f18c31 Merge pull request 'fix/skillset-audit-compliance-and-guard-fixes' (#15) from fix/skillset-audit-compliance-and-guard-fixes into develop
Reviewed-on: #15
2026-08-25 07:15:00 +00:00
jiantw83andClaude Opus 5 2982ca4e9f chore(hooks): 三份 manifest 同步升版並同步 marketplace 正本
What:三份 plugin manifest 版本同步 bump,兩份 marketplace 檔與 plugins/meta 正本對齊。

Why:準則要求技能異動必須同步升版;marketplace 副本必須與正本完全一致。

How:以 jsc-meta 的 tools/sync-skill-manifest.sh 升版,marketplace 檔由正本複製。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:54 +08:00
jiantw83andClaude Opus 5 83170e1b43 docs(hooks): 同步文件與參考資料
What:更新 README、AGENTS.md、templates 與 references,讓文件敘述與實際行為一致。

Why:稽核發現多處文件與程式行為分歧,違反「每個意義只有單一真實來源」。

How:以實際程式行為為準改寫敘述,重複的規則收成單一來源並以一行指引指過去。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:54 +08:00
jiantw83andClaude Opus 5 fedcfc8b05 fix(hooks): 補齊稽核缺失並修掉護欄失效
What:依 jsc-meta:skill-check 的稽核結果修正技能與工具——補上每個步驟的可檢核完成條件、
把留在內文的標準輸入輸出流程下放 tools/、修正查表與退碼路由造成的誤判。

Why:稽核發現這些缺失會讓技能在實際執行時走錯分支或靜默通過。
完成條件缺漏是最常被違反的一項;退碼誤判與查表錯誤則會讓良性狀況被當成失敗。

How:逐項對照 references/guidelines.md 的審核檢查清單修正,新增的工具都有
documented exit codes,並以真實執行驗證每條路徑。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:54 +08:00
admin cc9c80d760 Merge pull request '發佈 jsc-hooks 0.0.9:version-guard 新增 report' (#14) from develop into master
Reviewed-on: #14
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 05:05:50 +00:00
19 changed files with 1810 additions and 272 deletions
+3 -3
View File
@@ -43,7 +43,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/hooks.git" "url": "https://gitea.jsc.idv.tw/plugins/hooks.git"
}, },
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄" "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查"
}, },
{ {
"name": "jsc-log", "name": "jsc-log",
@@ -75,7 +75,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/review.git" "url": "https://gitea.jsc.idv.tw/plugins/review.git"
}, },
"description": "程式碼審查:Refactoring 壞味道六組 + 註解規範 + 淺模組" "description": "程式碼審查:Refactoring 壞味道六組、註解規範、淺模組"
}, },
{ {
"name": "jsc-sdlc", "name": "jsc-sdlc",
@@ -83,7 +83,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/sdlc.git" "url": "https://gitea.jsc.idv.tw/plugins/sdlc.git"
}, },
"description": "開發生命週期:規劃/分析/實作/維護(wiki 追蹤)" "description": "開發生命週期:規劃、分析、實作、維護(wiki 追蹤)"
} }
] ]
} }
+3 -3
View File
@@ -43,7 +43,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/hooks.git" "url": "https://gitea.jsc.idv.tw/plugins/hooks.git"
}, },
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄" "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查"
}, },
{ {
"name": "jsc-log", "name": "jsc-log",
@@ -75,7 +75,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/review.git" "url": "https://gitea.jsc.idv.tw/plugins/review.git"
}, },
"description": "程式碼審查:Refactoring 壞味道六組 + 註解規範 + 淺模組" "description": "程式碼審查:Refactoring 壞味道六組、註解規範、淺模組"
}, },
{ {
"name": "jsc-sdlc", "name": "jsc-sdlc",
@@ -83,7 +83,7 @@
"source": "url", "source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/sdlc.git" "url": "https://gitea.jsc.idv.tw/plugins/sdlc.git"
}, },
"description": "開發生命週期:規劃/分析/實作/維護(wiki 追蹤)" "description": "開發生命週期:規劃、分析、實作、維護(wiki 追蹤)"
} }
] ]
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.0.9", "version": "0.1.9",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
"name": "JSC" "name": "JSC"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.0.9", "version": "0.1.9",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查",
"skills": "./skills" "skills": "./skills"
} }
+3 -2
View File
@@ -1,6 +1,6 @@
# jsc-hooks — 給 AI 助理的指引 # jsc-hooks — 給 AI 助理的指引
本 repo 是 jsc 技能組的 `hooks` domain(跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖),可同時被 Claude Code / Codex / Copilot / Antigravity / Kiro 使用。 本 repo 是 jsc 技能組的 `hooks` domain(跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍檢查),可同時被 Claude Code / Codex / Copilot / Antigravity / Kiro 使用。
## 規則 ## 規則
@@ -8,7 +8,8 @@
2. 技能位於 `skills/{name}/SKILL.md`;處理任務前先比對需求與各技能的 `description`,相符就載入並依其步驟執行。 2. 技能位於 `skills/{name}/SKILL.md`;處理任務前先比對需求與各技能的 `description`,相符就載入並依其步驟執行。
3. 技能準則的唯一來源:`plugins/meta` 存取庫的 `references/guidelines.md`。 3. 技能準則的唯一來源:`plugins/meta` 存取庫的 `references/guidelines.md`。
4. 所有 hook 只放在 `jsc-hooks`;gitea 操作一律經由 `jsc-gitea` 的 `tools/gitea.sh`;問使用者一律依 `jsc-ask:ask` 的決策樹規則。 4. 所有 hook 只放在 `jsc-hooks`;gitea 操作一律經由 `jsc-gitea` 的 `tools/gitea.sh`;問使用者一律依 `jsc-ask:ask` 的決策樹規則。
5. 主 agent 不需要處理細節的流程,一律建立 sub agent 處理。 5. 註解範圍規則正文的唯一來源:`jsc-review` 的 `references/comment-scope.md`。本存取庫只放 `hooks/comment-scope.sh` 的判定實作,不留規則清單副本,接線腳本要用規則文字時一律取腳本的實際輸出。`comment-scope.sh` 有 `prompt`、無參數逐檔掃描、`sweep` 掃整個 git 工作區三種模式;掃描時機每個 CLI 都不同(claude 逐檔即時、codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束時),談覆蓋範圍時一律據實分開講,不得寫成五支一樣。
6. 主 agent 不需要處理細節的流程,一律建立 sub agent 處理。
## 呼叫慣例 ## 呼叫慣例
+39 -10
View File
@@ -23,29 +23,48 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
| 腳本 | 事件 | 作用 | | 腳本 | 事件 | 作用 |
| --- | --- | --- | | --- | --- | --- |
| `hooks/ste100-guard.sh` | UserPromptSubmit | 注入 STE100 繁體中文輸出規則(hook > prompt 強制層) | | `hooks/ste100-guard.sh` | UserPromptSubmit | 注入 STE100 繁體中文輸出規則(hook > prompt 強制層) |
| `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖;`report` 子指令供 `jsc-log:worklog` 取花費時間 | | `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖。子指令:`start` 記起始時間(已有紀錄就不動,給 claude 這種每階段有自己 session id 的 CLI)、`restart` 一律覆寫起始時間(給接不到 session id 的 kiro,不覆寫會把上一階段算進來)、`mark` 更新最後活動時間、`report` 供 `jsc-log:worklog` 取花費時間 |
| `hooks/version-guard.sh` | PreToolUse(Skill) | 技能使用前的版本前置檢查:本機**實際載入**版本落後遠端發佈版本就以 exit 2 擋下該次呼叫並提示更新指令。只擋落後(超前放行,開發技能組時本機本來就會超前);遠端查不到一律擋(fail-closed),逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-cli:models`、`jsc-meta:*` | | `hooks/version-guard.sh` | PreToolUse(Skill) | 技能使用前的版本前置檢查:本機**實際載入**版本落後遠端發佈版本就以 exit 2 擋下該次呼叫並提示更新指令(更新指令依當前 CLI 給)。只擋落後這一種情況:超前放行(開發技能組時本機本來就會超前),讀不到本機版本、推導不出站台、查不到遠端版本也一律放行。逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-cli:models`、`jsc-meta:*` |
| `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 | | `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 |
| `hooks/sdlc-gate.sh` | UserPromptSubmit | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從 transcript 讀出實際模型 id 比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖;`check` 在模型不符時以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`unlock` 為逃生門 | | `hooks/comment-scope.sh` | UserPromptSubmit、PostToolUse(Write、Edit、MultiEdit)、codex `notify`、kiro `userPromptSubmit`、`tools/jsc-wrap.sh` 收尾 | 程式碼註解不得夾帶文件相關資訊,共三種模式。`prompt`:在每次提示注入規則摘要(禁止項與白名單各一行),五個 CLI 都接得到。無參數:寫檔後的逐檔掃描,從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只有 claude 的 PostToolUse 接得上。`sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,給沒有 post-tool hook 的四個 CLI 用,找不到 git 就安靜 exit 0。掃描時機每個 CLI 不同——claude 逐檔即時(PostToolUse)、codex 每輪結束(`notify`)、kiro 每輪提示送出時(`userPromptSubmit`,掃的是上一輪寫的檔)、copilot 與 antigravity 只有工作階段結束時由 `tools/jsc-wrap.sh` 收尾掃一次。兩種掃描模式都只看 `git diff HEAD` 的新增行、不翻舊帳,命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正(不擋寫入,檔案已經寫好了)。markdown、純文字、資料檔與二進位檔一律跳過。只實作可用樣式判定的項目,專案代號、客戶名稱這類判不出來的交給 `/jsc-review:code-review`。規則正文的唯一來源在 `jsc-review` 的 `references/comment-scope.md`,本存取庫不留副本。逃生門 `JSC_COMMENT_SCOPE=off` |
| `hooks/sdlc-gate.sh` | UserPromptSubmit、PreToolUse(Skill) | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從 transcript 讀出實際模型 id 比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖;`check` 在模型不符時以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`unlock` 為逃生門。另含工作包 PR 閘門:`wp-lock {owner}/{repo} {index}` 記下一筆未結清的工作包 PR、`wp-unlock {owner}/{repo} {index}` 結清那一筆(檔案不存在也算成功)、`wp-report` 印出所有未結清、`wp-check {prompt|skill}` 為 hook 模式。狀態檔一個工作包一支,在 `$JSC_HOME/wp/{owner}-{repo}-{index}.pr`,**刻意不綁 session**——PR 沒合併時換一個工作階段照樣要擋;一個工作包一支鎖檔是為了讓好幾個互不相依的工作包能同時記在案,不會互相覆蓋掉對方的鎖。`wp-check prompt` 只注入提醒、絕不擋提示(擋了連「去修那支 PR」的對話都送不出去);`wp-check skill` 在有未結清 PR 時以 exit 2 擋下 `plan`、`analyze`、`maintain`,但一律放行 `implement`(結清 PR 正是 implement 的步驟,擋它會鎖死流程)——這一層是整個存取庫共用的粗粒度提醒,「某個候選工作包能不能挑」的細粒度判斷在 `jsc-sdlc/tools/wp-gate.sh check-deps`,不是這裡。逃生門 `JSC_WP_GATE=off`。這道閘門只讀檔案、不打網路,PR 的真實合併狀態由 `jsc-sdlc/tools/wp-gate.sh` 查證 |
Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。 Claude 由 `hooks/hooks.json` 自動接線六支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。
> 覆蓋範圍要據實看待:只有 claude 同時有 PreToolUse、PostToolUse 與 UserPromptSubmit,六支 hook 全接得上,回報 `wired`。codex、copilot、antigravity、kiro 都沒有 pre-tool hook,接不上 `version-guard.sh` 的版本前置檢查,SDLC 模型鎖也只剩技能步驟檢查,這四個 CLI 一律回報 `degraded`,靠 `/jsc-cli:deploy` 定期更新。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。
> `comment-scope.sh` 五個 CLI 都掃得到,但時機不同,不能當成五支一樣:
| CLI | 掃描時機 | 接在哪裡 |
| --- | --- | --- |
| claude | 逐檔即時,寫完哪個檔就掃哪個 | PostToolUse |
| codex | 每輪結束,掃整個 git 工作區 | `config.toml` 的根層 `notify` |
| kiro | 每輪提示送出時,掃整個 git 工作區(掃到的是上一輪寫的檔) | `.kiro/hooks/jsc-hooks.json` 的 `userPromptSubmit` |
| copilot、antigravity | 工作階段結束時掃一次 | `tools/jsc-wrap.sh` 收尾 |
> `sweep` 看的是 `git diff HEAD`,涵蓋範圍與 claude 一樣,差的是回饋速度:claude 當下就叫,其他四個要等到該輪或該階段結束。不在 git 工作區內時 `sweep` 安靜 exit 0,等於沒掃。規則提示(`prompt` 模式)在五個 CLI 都照樣寫進規則檔,與 STE100 共用同一個標記段落——晚一輪的警告,價值仍低於一開始就不要寫。判不出來的項目(專案代號、客戶名稱)一律交給 `/jsc-review:code-review` 第 2 組。
> `version-guard.sh report` 是非 hook 的子指令:印出每個已安裝 jsc plugin 的 > `version-guard.sh report` 是非 hook 的子指令:印出每個已安裝 jsc plugin 的
> 「{domain} {本機} {遠端} {落後|最新|超前|查詢失敗}」,最後一行 `behind {落後個數}`。 > 「{domain} {本機} {遠端} {落後|最新|超前|查詢失敗}」,最後一行 `behind {落後個數}`。
> 本機沒有 Claude 的 plugin 註冊檔時改印 `noregistry {路徑}` 再接 `behind 0`,
> 代表這台機器無法做版本檢查,跟「全部最新」是兩件事。查遠端版本走與 hook 同一份快取
> 與同一個 `JSC_VERSION_TTL`,一次部署不會為每個 domain 各打一輪網路。
> `jsc-cli:deploy` 用它決定要不要把「更新」設成推薦選項。 > `jsc-cli:deploy` 用它決定要不要把「更新」設成推薦選項。
## 工具 ## 工具
| 腳本 | 用途 | | 腳本 | 用途 |
| --- | --- | | --- | --- |
| `tools/jsc-wrap.sh` | 無 hook 系統 CLI 的包裝啟動器:匯出 `JSC_CLI`、`JSC_SESSION_ID`,前後接 `session-timer.sh`,結束時自動跑 `scan-logs.sh` 回填 | | `tools/jsc-wrap.sh` | 沒有完整 hook 系統的 CLI 的包裝啟動器:匯出 `JSC_CLI`、`JSC_SESSION_ID`,前後接 `session-timer.sh`,結束時自動跑 `scan-logs.sh` 回填,再跑一次 `comment-scope.sh sweep` 掃整個 git 工作區的註解範圍(copilot 與 antigravity 沒有任何逐輪事件,整個工作階段只有這裡掃得到)。收尾掃描一律不影響結束碼:包裝器原樣回傳 CLI 自己的結束碼,`sweep` 命中只把警告印到 stderr。`JSC_CLI` 存 CLI 代號,實際執行的是對應的執行檔(antigravity 是 agy、kiro 是 kiro-cli) |
| `tools/scan-logs.sh` | 離線回填:解析 copilot、antigravity、codex 的原生日誌,把技能用量與階段界線補進 `$JSC_HOME`,重掃不重複 | | `tools/scan-logs.sh` | 離線回填:解析 copilot、antigravity、codex 的原生日誌,把技能用量與階段界線補進 `$JSC_HOME`,重掃不重複 |
| `tools/wire-cli.sh` | 單一 CLI 的接線流程:`{cli}` 對應的設定編輯、包裝別名安裝、hook 檔建立,皆以 `<!-- jsc-hooks -->`(或 `# jsc-hooks`)標記整段取代,重跑不重複;以 `status=wired\|degraded\|skipped` 回報結果 | | `tools/report-error.sh` | 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 `ERROR_{HASH}`,並在 `ERROR_CONTENTS` 附上一列索引。wiki 位置由 `jsc-gitea` 的 `gitea.sh wiki-repo ERROR` 解析,解析不出來就安靜降級。由操作者手動執行,或由 `hooks-install` 在 `wire-cli.sh` 回報 `status=failed` 時執行;**不接在失敗的 hook 上自動觸發**(hook 一律安靜 exit 0,自我回報會疊出迴圈) |
| `tools/wire-cli.sh` | 單一 CLI 的 hook 生命週期,共三個用法。`{cli}` 是接線:對應的設定編輯、包裝別名安裝、hook 檔建立,皆以 `<!-- jsc-hooks -->`(或 `# jsc-hooks`)標記整段重寫,重跑等同先移除再重裝;寫完每個檔案會重讀驗證位置正確才回報成功(codex 的 `notify` 必須是根層鍵、kiro 的 JSON 必須成對且 `on`、`run` 在最上層),以 `status=wired\|degraded\|skipped\|failed` 回報。`purge {cli}` 是移除:把該 CLI 的**所有** hook 清掉,含非 jsc 的第三方項目,動到的檔案先原樣備份到 `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`,備份失敗就不移除,移除後重讀驗證,驗不過自動還原備份,以 `status=purged\|skipped\|failed` 回報。`smoke {cli}` 是執行期冒煙測試:六支 hook 的每個接線模式各跑一次,非零退出即為錯誤(例外有兩個:`sdlc-gate.sh check` 的 exit 2 是階段鎖的設計行為,`comment-scope.sh` 掃描模式的 exit 2 是掃到違規註解的設計行為——`sweep` 在髒工作區本來就會回 2,不算 hook 壞掉),以 `status=ok\|failed` 回報。`status {cli}` 是唯讀盤點:只讀設定檔判斷標記段落在不在,不寫檔也不執行 hook,每個接線點印一行 `item<TAB>{項目}<TAB>{路徑}<TAB>{present\|missing}`,以 `status=wired\|degraded\|unwired\|skipped` 回報(結束碼 0、1、5、3)。體檢類技能(`/jsc-cli:doctor`)只能用這個子命令,另外三個都會動到環境 |
| `tools/scan-hook-errors.sh` | 掃 CLI 原生紀錄找 hook 的執行期錯誤(接線寫對、跑起來出錯)。只有 claude 有 hook 結果紀錄,掃 `~/.claude/projects/**/*.jsonl` 的 `hook_non_blocking_error` 與非空 `hookErrors`;codex、copilot、antigravity、kiro 沒有等價紀錄,一律回報 `unavailable` 並指向 `wire-cli.sh smoke {cli}`。每筆錯誤附加一行 JSON 到 `$JSC_HOME/errors/hooks.jsonl`,`jsc` 欄位標明是不是 jsc 自己的 hook(第三方 hook 的錯誤只回報,不由 jsc 修正);去重與 `scan-logs.sh` 同法,重掃只讀新增段落,以 `status=clean\|errors\|unavailable` 回報 |
## 失敗回報範本 ## 失敗回報範本
這兩個模板給外層的 hook 失敗回報流程使用,不改動現有 hook 行為。 這兩個模板是失敗回報頁的文案來源,由 `tools/report-error.sh` 填欄位後寫進 wiki。
失敗時若要寫入 wiki,套用這兩個檔案即可。 用法:`tools/report-error.sh --hook {名稱} --exit {碼} --summary {摘要}`,錯誤輸出摘要走標準輸入。
| 範本 | 用途 | | 範本 | 用途 |
| --- | --- | | --- | --- |
@@ -60,7 +79,11 @@ Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技
### `hooks-install` ### `hooks-install`
把四支 hook 接線到所有已安裝的 CLI:偵測 CLI 後,逐一呼叫 `tools/wire-cli.sh {cli}` 完成接線(claude 由 `hooks.json` 自動接線,無需寫入)。copilot、antigravity 由該腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍追加到各自的規則檔(以 `<!-- jsc-hooks -->` 標記整段取代,不重複追加)。codex、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入。腳本以 `status=wired|degraded|skipped` 回報結果,供技能對照 verify 表。 把六支 hook 接線到所有已安裝的 CLI,每個 CLI 走四道關卡:先 `tools/wire-cli.sh purge {cli}` 備份後移除所有 hook(含非 jsc 的第三方項目,乾淨起跑才分得清後續失敗是誰的),再 `tools/wire-cli.sh {cli}` 接線(claude 由 `hooks.json` 自動接線,無需寫入),接著 `tools/wire-cli.sh smoke {cli}` 驗執行期,最後 `tools/scan-hook-errors.sh --cli {cli}` 掃原生紀錄。codex、copilot、antigravity 由接線腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍重寫到各自的規則檔(以 `<!-- jsc-hooks -->` 標記整段取代,等同先移除再重裝,不重複追加)。codex、copilot、antigravity、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入;這四個 CLI 沒有 pre-tool hook,版本前置檢查接不上;也沒有 post-tool hook,`comment-scope.sh` 接不到逐檔即時掃描,改用 `sweep` 掃整個 git 工作區——codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束時掃一次,腳本會在 `reason` 裡講明各自的時機,只有 claude 回報 `wired`,也只有 claude 掃得到執行期錯誤紀錄。任一關卡出錯(purge、接線、冒煙失敗,或掃到 `jsc=true` 的執行期錯誤)就先寫 `ERROR_{HASH}`,再交給 `repair` 技能接手並以 `develop` PR 收尾;此時允許中止剩下的安裝,但修正一定要開始。掃到 `jsc=false` 的第三方 hook 錯誤只回報,不轉修正。
### `repair`
接手 `hooks-install` 或 `report-error.sh` 留下的失敗:讀 `ERROR_{HASH}` 與相關檔案後,把診斷拆給安裝中的其他 AI agent CLI 當 sub agent,彙整建議修正、實作 hooks repo 的修補、同步 manifest,最後以 `develop` 為基底開 PR。只有在真的沒有可用 CLI 時,才退回主 agent 自己判讀。
<!-- JSC-SKILLS:END --> <!-- JSC-SKILLS:END -->
@@ -69,9 +92,15 @@ Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技
| 變數 | 用途 | 未設定時 | | 變數 | 用途 | 未設定時 |
| --- | --- | --- | | --- | --- | --- |
| `JSC_HOME` | Hook 資料目錄 | 預設 `~/.jsc` | | `JSC_HOME` | Hook 資料目錄 | 預設 `~/.jsc` |
| `JSC_WIKI_REPO_ERROR` | `ERROR_CONTENTS`、`ERROR_{HASH}` 所在的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO` |
| `JSC_WIKI_REPO` | 未逐類設定時的共用 wiki `{owner}/{repo}` | `tools/report-error.sh` 安靜降級,不寫 wiki |
| `JSC_CLAUDE_SETTINGS_DIR` | `tools/wire-cli.sh purge claude` 要清 `hooks` 鍵的設定檔目錄。指向一份複製品就能完整測過刪鍵邏輯,不必拿使用者本人的設定檔當測試場 | 預設 `~/.claude` |
| `JSC_VERSION_GUARD` | 設 `off` 完全略過版本前置檢查(離線工作用) | 啟用檢查 | | `JSC_VERSION_GUARD` | 設 `off` 完全略過版本前置檢查(離線工作用) | 啟用檢查 |
| `JSC_VERSION_TTL` | 遠端版本查詢的快取秒數 | 預設 600 | | `JSC_VERSION_TTL` | 遠端版本查詢的快取秒數 | 預設 600 |
| `JSC_CLI` / `JSC_SESSION_ID` / `JSC_SKILL` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供 | 安靜降級 | | `JSC_WP_GATE` | 設 `off` 完全略過工作包 PR 閘門(`wp-check` 一律放行) | 啟用閘門 |
| `JSC_COMMENT_SCOPE` | 設 `off` 完全略過註解範圍檢查(`comment-scope.sh` 三種模式都直接結束) | 啟用檢查 |
| `JSC_CHANGED_FILE` | 非 Claude CLI 要掃描的檔案路徑,代替 stdin JSON 的 `file_path`,供 `comment-scope.sh` 使用 | 安靜降級,不掃描 |
| `JSC_CLI` / `JSC_SESSION_ID` / `JSC_SKILL` / `JSC_TOOL_NAME` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供,代替 stdin JSON 的 `session_id`、`skill`、`tool_name`(`version-guard.sh` 也收沒有前綴的 `SKILL`、`TOOL_NAME`) | 安靜降級 |
| `JSC_MODEL` | 非 Claude CLI 的目前模型,供 `sdlc-gate.sh` 比對;優先序在 transcript 實際值與 stdin `model` 之後 | 改讀 `~/.claude/settings.json`,再不行就安靜降級 | | `JSC_MODEL` | 非 Claude CLI 的目前模型,供 `sdlc-gate.sh` 比對;優先序在 transcript 實際值與 stdin `model` 之後 | 改讀 `~/.claude/settings.json`,再不行就安靜降級 |
## 相關 domain ## 相關 domain
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env sh
# comment-scope.sh — 程式碼註解不得夾帶文件相關資訊(hook > prompt 的強制層)。
# 規則正文的唯一來源:jsc-review 的 references/comment-scope.md。本腳本只實作可用樣式判定的項目;
# 專案代號、客戶名稱這類無法用樣式判定的,交給 jsc-review:code-review 第 2 組人工審查。
#
# 用法:
# comment-scope.sh prompt 注入規則摘要(UserPromptSubmit 或規則檔取文字用)
# comment-scope.sh 掃描剛寫入的單一檔案(PostToolUse)
# comment-scope.sh sweep [dir] 掃描整個工作區這次改過的所有檔案(沒有 post-tool hook 的 CLI 用)
#
# 為什麼要有 sweep:只有 claude 接得到 PostToolUse,逐檔精準掃得到。codex 只有每輪結束的
# notify、kiro 只有 userPromptSubmit、copilot 與 antigravity 只有包裝別名,這四個都拿不到
# 「剛剛寫了哪個檔」,只能改成掃整個工作區的 git diff。時機晚一點,涵蓋範圍一樣。
#
# 輸入相容:
# Claude: PostToolUse 的 stdin JSON,取 tool_input.file_path。
# 其他 CLI: 環境變數 JSC_CHANGED_FILE。
# 兩者都取不到就安靜降級(exit 0)。
#
# 掃描範圍:檔案在 git 工作區內就只掃 `git diff HEAD` 的新增行,不翻舊帳;
# 不在 git 內或檔案尚未追蹤才整檔掃描。sweep 一律只看 git diff。
#
# 結束碼:0=沒命中或資料不足;2=命中,訊息走 stderr 交回模型自行修正(不擋寫入,檔案已經寫好了)。
# 逃生門:JSC_COMMENT_SCOPE=off。
set -u
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib.sh" 2>/dev/null || true
[ "${JSC_COMMENT_SCOPE:-on}" = "off" ] && exit 0
if [ "${1:-}" = "prompt" ]; then
echo "[jsc] 程式碼註解只寫「為什麼這樣寫」,不寫「這件事記在哪份文件」。禁止寫入:議題與 PR 編號、變更單編號、wiki 頁編號與網址、工作包編號、TDD 待辦編號、使用者故事與驗收條件與測試案例編號、規格章節與稽核項編號、commit hash 與分支名、版本號與 Sprint 與里程碑、人名與認領者與 @ 提及、工時估算、專案代號與客戶名稱、產生來源署名、外部文件連結。"
echo "[jsc] 註解可以寫:日期與時間戳、需求變更歷程、RFC 與 ISO 標準編號、CVE 編號、第三方套件 issue 連結、授權標頭與 SPDX 標記、@deprecated 與 @since 等語言原生標記。命中禁止項就把編號指向的內容搬進註解,再刪掉編號。規則正文見 jsc-review 的 references/comment-scope.md。"
exit 0
fi
hit() { # $1=樣式 $2=說明;命中就把說明與最多三行證據印到 stdout
m=$(printf '%s\n' "$cleaned" | grep -nE "$1" | head -n 3)
[ -n "$m" ] || return 0
printf ' %s\n' "$2"
printf '%s\n' "$m" | sed 's/^/ /'
}
scan_file() { # $1=檔案路徑;命中就把報告印到 stdout 並回傳 1,沒命中回傳 0
f=$1
[ -f "$f" ] || return 0
# 非程式碼檔不受本規則限制:markdown、純文字、資料檔沒有「程式碼註解」。
case "$f" in
*.md|*.markdown|*.txt|*.rst|*.json|*.csv|*.tsv|*.svg|*.lock|*.log|*COMMIT_EDITMSG) return 0 ;;
esac
# 二進位檔跳過。只認 NUL 位元組——拿「非可列印字元」當判準會把所有含中文的檔案誤判成二進位。
raw=$(head -c 1024 "$f" 2>/dev/null | wc -c)
txt=$(head -c 1024 "$f" 2>/dev/null | LC_ALL=C tr -d '\000' | wc -c)
[ "$raw" = "$txt" ] || return 0
d=$(dirname -- "$f")
if git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
git -C "$d" ls-files --error-unmatch -- "$f" >/dev/null 2>&1; then
lines=$(git -C "$d" diff HEAD -- "$f" 2>/dev/null | sed -n 's/^+[^+]/&/p' | cut -c2-)
[ -n "$lines" ] || return 0
else
lines=$(cat "$f" 2>/dev/null)
fi
# 只留註解行:行首註解符號,或行中出現 // 與 # 的行尾註解。
comments=$(printf '%s\n' "$lines" | grep -E '^[[:space:]]*(//|#|--|\*|/\*|<!--|;|%)|[[:space:]](//|#)[[:space:]]' || true)
[ -n "$comments" ] || return 0
# 白名單先剪掉,再比對禁止樣式。剪掉而不是整行放行——同一行可能一半合規、一半違規。
cleaned=$(printf '%s\n' "$comments" | sed -E \
-e 's#SPDX-License-Identifier:[^[:space:]]*##g' \
-e 's#CVE-[0-9]{4}-[0-9]+##g' \
-e 's#(RFC|ISO|IEEE|ANSI|ECMA|UTF|SHA|MD|AES|RSA|HMAC|PBKDF|TLS|SSL|HTTP|BIG|EUC|JIS|GB|RS|IPV|X)-?[0-9]+(-[0-9]+)?##g' \
-e 's#@(deprecated|since|param|returns?|throws|type|typedef|example|see|link|inheritdoc|override|nullable|internal)##g' \
-e 's#https?://(github|gitlab|bitbucket)\.com/[^[:space:]]*##g' \
-e 's#https?://[^[:space:]]*[{<][^[:space:]]*##g' \
-e 's#[0-9]{4}[-/][0-9]{1,2}[-/][0-9]{1,2}##g')
out=$(
hit '(^|[^[:alnum:]_/])#[0-9]+' '議題編號(#123)'
hit '(^|[^[:alnum:]_])![0-9]+' 'PR、MR 編號(!45)'
hit '[A-Z]{2,6}-[0-9]{1,6}' '工作包、故事、驗收、測試案例、變更單、議題編號(前綴加流水號)'
hit '(QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR|CHECK|REPORT)_([A-Z0-9]{8}|CONTENTS)' 'jsc wiki 頁面編號'
hit '(todo|TODO|待辦)[[:space:]]*#?[0-9]+' 'TDD 待辦編號'
hit '([Ss]print|里程碑|[Mm]ilestone)[[:space:]]*[0-9]+' 'Sprint、里程碑編號'
hit '(^|[^[:alnum:].])v[0-9]+\.[0-9]+|版本[[:space:]]*v?[0-9]+\.[0-9]+' '版本號'
hit '(commit|提交|hash|SHA)[[:space:]:]*[0-9a-f]{7,40}' 'commit hash'
hit '(branch|分支)[[:space:]:]*[a-z]+/[a-z0-9-]+' '分支名稱'
hit '(^|[[:space:]])@[a-zA-Z][a-zA-Z0-9_.-]{2,}' '人名、認領者、@ 提及(含 @author)'
hit 'https?://[^[:space:]]*(wiki|confluence|atlassian|notion\.so|docs\.google|sharepoint)' 'wiki 或外部文件連結'
hit 'https?://[^[:space:]]*/(issues|pulls)/[0-9]+' '內部議題、PR 連結'
hit '([Gg]enerated (with|by)|Co-Authored-By|本檔(案)?由|AI (產生|生成|撰寫))' '產生來源署名'
hit '預估[[:space:]]*[0-9]+[[:space:]]*(小時|分鐘|人日|人天|天)|[0-9]+[[:space:]]*(人日|人天|工時)' '工時估算'
hit '(規格書|需求書|準則|規範|清單|guidelines)[^。]{0,8}第[[:space:]]*[0-9]+|(規格書|需求書|SRS)[^。]{0,6}[0-9]+(\.[0-9]+)+' '規格文件章節、稽核檢查項編號'
)
[ -n "$out" ] || return 0
printf '%s\n' "$f"
printf '%s\n' "$out"
return 1
}
advice() {
printf ' 修法:把編號指向的內容搬進註解,然後刪掉編號。搬不動就代表那件事不該用註解表達。\n'
printf ' 規則正文與白名單見 jsc-review 的 references/comment-scope.md。誤判時用 JSC_COMMENT_SCOPE=off 關閉。\n'
}
if [ "${1:-}" = "sweep" ]; then
target=${2:-.}
[ -d "$target" ] || exit 0
root=$(git -C "$target" rev-parse --show-toplevel 2>/dev/null) || exit 0
[ -n "$root" ] || exit 0
changed=$(git -C "$root" diff --name-only HEAD 2>/dev/null)
[ -n "$changed" ] || exit 0
# 報告累積在暫存檔:迴圈跑在管線的子行程裡,變數帶不回來。
tmp=${TMPDIR:-/tmp}/jsc-comment-scope.$$
: > "$tmp" 2>/dev/null || exit 0
printf '%s\n' "$changed" | while IFS= read -r rel; do
[ -n "$rel" ] || continue
scan_file "$root/$rel" >> "$tmp" 2>/dev/null
done
if [ -s "$tmp" ]; then
{
printf '[jsc] 工作區有註解夾帶文件相關資訊,請就地修正:\n'
sed 's/^/ /' "$tmp"
advice
} >&2
rm -f "$tmp"
exit 2
fi
rm -f "$tmp"
exit 0
fi
read_stdin 2>/dev/null || STDIN_JSON=""
file=$(json_str file_path 2>/dev/null || true)
[ -n "$file" ] || file="${JSC_CHANGED_FILE:-}"
[ -n "$file" ] || exit 0
report=$(scan_file "$file") && exit 0
{
printf '[jsc] 程式碼註解夾帶了文件相關資訊,請就地修正:\n'
printf '%s\n' "$report"
advice
} >&2
exit 2
+21
View File
@@ -20,6 +20,14 @@
{ {
"type": "command", "type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" check" "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" check"
},
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" wp-check prompt"
},
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/comment-scope.sh\" prompt"
} }
] ]
} }
@@ -51,6 +59,10 @@
{ {
"type": "command", "type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/version-guard.sh\"" "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/version-guard.sh\""
},
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" wp-check skill"
} }
] ]
} }
@@ -64,6 +76,15 @@
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/skill-usage.sh\"" "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/skill-usage.sh\""
} }
] ]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/comment-scope.sh\""
}
]
} }
] ]
} }
+36
View File
@@ -8,6 +8,10 @@
JSC_HOME="${JSC_HOME:-$HOME/.jsc}" JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true
# 呼叫端腳本所在目錄。source 不會改變 $0,所以這裡取到的是 hooks/ 或 tools/。
JSC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
JSC_SCRIPT_DIR="${JSC_SCRIPT_DIR:-.}"
# 讀完 stdin(可能為空;非阻塞宿主) # 讀完 stdin(可能為空;非阻塞宿主)
read_stdin() { read_stdin() {
if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi
@@ -47,5 +51,37 @@ cli_name() {
else printf 'unknown'; fi else printf 'unknown'; fi
} }
# 找出 jsc-gitea 的 tools/gitea.sh 絕對路徑。所有 gitea 操作一律經由它(技能準則),
# 不可自行拼 API 呼叫:token 取用與 tea 金鑰退回都寫在那支腳本裡。
# 找不到就回傳 1,由呼叫端安靜降級(hook 一律 exit 0,不中斷宿主 CLI)。
jsc_gitea_sh() {
if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then
printf '%s\n' "$JSC_GITEA_TOOLS/gitea.sh"; return 0
fi
_root="${CLAUDE_PLUGIN_ROOT:-$JSC_SCRIPT_DIR/..}"
# 開發用的並排存取庫版面:{workspace}/hooks 旁邊就是 {workspace}/gitea
for _c in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
done
# 已安裝版面:每個 plugin 各有版本目錄,取排序最後的一份(通常即最新版)
_c=$(ls -d "$_root"/../../jsc-gitea/*/tools/gitea.sh \
"$_root"/../../gitea/*/tools/gitea.sh \
"$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/gitea.sh 2>/dev/null \
| sort | tail -n1)
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
_c=$(command -v gitea.sh 2>/dev/null || true)
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
}
# 每個 CLI 代號對應的實際執行檔(antigravity 是 agy、kiro 是 kiro-cli,其餘同名)
cli_bin() { # $1=CLI 代號
case "$1" in
antigravity) printf 'agy' ;;
kiro) printf 'kiro-cli' ;;
*) printf '%s' "$1" ;;
esac
}
now_epoch() { date +%s; } now_epoch() { date +%s; }
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
+136 -7
View File
@@ -17,11 +17,34 @@
# sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。 # sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。
# sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {上鎖時的模型};無鎖不印。 # sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {上鎖時的模型};無鎖不印。
# #
# exit code 例外:其他 jsc hook 一律 exit 0 不中斷宿主 CLI;本檔 check 是刻意的例外—— # sdlc-gate.sh wp-lock {owner}/{repo} {index} 記下一筆未結清的工作包 PR。
# 鎖存在且模型不符時 exit 2 擋下該輪提示。只用提示注入的話模型可以無視,閘門形同虛設。 # exit 0 = 已記下;exit 2 = 用法錯誤或寫不進狀態檔(沒記下等於沒鎖)。
# sdlc-gate.sh wp-unlock {owner}/{repo} {index} 結清後移除該工作包的狀態檔;檔案不存在也算成功。
# exit 0 = 已結清;exit 2 = 用法錯誤。
# sdlc-gate.sh wp-report 印出 {owner}/{repo} {index} {上鎖時間},每個未結清工作包各一行;
# 沒有未結清就不印,exit 0。
# sdlc-gate.sh wp-check prompt hook 模式(UserPromptSubmit):注入提醒,一律 exit 0。
# sdlc-gate.sh wp-check skill hook 模式(PreToolUse,matcher Skill):命中別的階段技能時
# exit 2 擋下該次呼叫;其餘 exit 0。
#
# 鎖檔一個工作包一支($JSC_HOME/wp/{owner}-{repo}-{index}.pr),不是整個存取庫共用一支:
# SDLC 實作可能同時有好幾個互不相依的工作包平行進行,各自開各自的 PR。整庫共用一支鎖檔
# 只留得住「最後一個 lock 的那一包」,先前還沒合併的那幾包會被覆蓋掉,鎖跟著憑空消失。
# 這支鎖檔管的是「plan/analyze/maintain 能不能在這個存取庫上動」(見下方 wp-check skill),
# 跟「implement 挑下一個工作包能不能挑到某一包」是兩件事——後者的判斷依據是該包在分析頁
# WBS 表的相依欄,走 jsc-sdlc/tools/wp-gate.sh check-deps,不靠這支鎖檔。
#
# exit code 例外:其他 jsc hook 一律 exit 0 不中斷宿主 CLI;本檔 check 與 wp-check skill 是
# 刻意的例外——鎖存在且不合規時 exit 2 擋下。只用提示注入的話模型可以無視,閘門形同虛設。
# 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。 # 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
read_stdin # 只有需要 stdin JSON 的子命令才讀它:模型判定要 transcript_path,session 判定要 session_id。
# wp-lock、wp-unlock、wp-report 兩者都不需要,而 read_stdin 在標準輸入是管線又沒人關閉時會
# 一直等——工具腳本(jsc-sdlc 的 wp-gate.sh)轉呼叫這三個子命令時就這樣整支卡死。
case "${1:-}" in
wp-lock|wp-unlock|wp-report) STDIN_JSON="" ;;
*) read_stdin ;;
esac
sid=$(session_id) sid=$(session_id)
state="$JSC_HOME/sessions/$sid.stage" state="$JSC_HOME/sessions/$sid.stage"
TAGS_TSV="$JSC_HOME/model-tags.tsv" TAGS_TSV="$JSC_HOME/model-tags.tsv"
@@ -35,15 +58,21 @@ stage_tags() { # $1=階段
awk -F'\t' -v s="$1" '$1 == "stage" && $2 == s { print $3; exit }' "$TAGS_TSV" awk -F'\t' -v s="$1" '$1 == "stage" && $2 == s { print $3; exit }' "$TAGS_TSV"
} }
# 某模型的能力標籤。模型鍵與實際 id 雙向包含即視為同一家族 # 某模型的能力標籤。查法:先找完全相同的鍵,沒有才退回「表列鍵是實際 id 的前綴」
#(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001);多筆命中取最長鍵。 # 之中最長的一筆(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001)。
# 只認前綴這個方向。反向包含(實際 id 是表列鍵的前綴)會讓 gpt-5.x 命中更長的
# gpt-5.x-mini,最長鍵勝出就把 mini 的標籤發給 gpt-5.x,把夠格的模型擋掉。
model_tags() { # $1=模型 id model_tags() { # $1=模型 id
[ -s "$TAGS_TSV" ] || return 0 [ -s "$TAGS_TSV" ] || return 0
awk -F'\t' -v m="$1" ' awk -F'\t' -v m="$1" '
$1 == "model" && (index(m, $2) > 0 || index($2, m) > 0) { $1 == "model" && $2 == m { exact = $3 }
$1 == "model" && $2 != m && substr(m, 1, length($2)) == $2 {
if (length($2) > best_len) { best_len = length($2); best = $3 } if (length($2) > best_len) { best_len = length($2); best = $3 }
} }
END { if (best != "") print best } END {
if (exact != "") print exact
else if (best != "") print best
}
' "$TAGS_TSV" ' "$TAGS_TSV"
} }
@@ -86,6 +115,44 @@ current_model() {
printf '%s' "$m" printf '%s' "$m"
} }
# --- 工作包 PR 閘門(狀態檔:$JSC_HOME/wp/{owner}-{repo}-{index}.pr,一個工作包一支) ---
#
# 刻意不綁 session:PR 沒合併就是沒合併,換一個工作階段照樣要擋。綁 session 等於給閘門
# 留一道「開新對話就自動繞過」的門,規則就不再是強制的。
#
# 一律只讀檔案,絕不打網路:hook 要快、也要能離線用。PR 的真實合併狀態由
# jsc-sdlc/tools/wp-gate.sh 去查並負責結清,本檔只反映已記錄的未結清狀態。
WP_DIR="$JSC_HOME/wp"
wp_state_file() { # $1={owner}/{repo} $2=index
printf '%s/%s-%s.pr' "$WP_DIR" "$(printf '%s' "$1" | tr '/' '-')" "$2"
}
# 未結清清單,每行「{owner}/{repo} {index} {上鎖時間}」;沒有就不輸出。
wp_pending() {
[ -d "$WP_DIR" ] || return 0
for _f in "$WP_DIR"/*.pr; do
[ -f "$_f" ] || continue
_line=$(sed -n '1p' "$_f" 2>/dev/null | tr '\t' ' ')
[ -n "$_line" ] && printf '%s\n' "$_line"
done
}
# 未結清清單濃縮成一句可讀的「{repo} 第 {index} 號」,多筆用頓號串起。
wp_brief() { # 標準輸入 = wp_pending 的輸出
awk '{ out = (out == "" ? $1 " 第 " $2 " 號" : out "、" $1 " 第 " $2 " 號") } END { print out }'
}
# 存取庫參數格式檢查;不合格就回 1,由呼叫端印訊息後 exit 2。
wp_valid_repo() { # $1=參數
case "${1:-}" in
*/*/*|/*|*/) return 1 ;;
*/*) return 0 ;;
*) return 1 ;;
esac
}
case "${1:-check}" in case "${1:-check}" in
lock) lock)
stage="${2:-}" stage="${2:-}"
@@ -163,5 +230,67 @@ case "${1:-check}" in
[ -n "$line" ] && echo "$sid $line" [ -n "$line" ] && echo "$sid $line"
fi fi
exit 0 ;; exit 0 ;;
wp-lock)
repo="${2:-}"; idx="${3:-}"
wp_valid_repo "$repo" || {
echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; }
case "$idx" in
''|*[!0-9]*)
echo "[jsc][工作包閘門][ERR]:PR 編號須為數字,收到「${idx:-空值}」。" >&2; exit 2 ;;
esac
mkdir -p "$WP_DIR" 2>/dev/null || true
wpf=$(wp_state_file "$repo" "$idx")
printf '%s\t%s\t%s\n' "$repo" "$idx" "$(now_iso)" > "$wpf" 2>/dev/null || {
echo "[jsc][工作包閘門][ERR]:寫不進狀態檔 $wpf,工作包鎖未生效。" >&2; exit 2; }
echo "[jsc][工作包閘門][OK]:已記下 $repo 第 $idx 號 PR 未結清。相依於它的工作包在它結清前不得開始;其餘互不相依的工作包不受影響。"
exit 0 ;;
wp-unlock)
repo="${2:-}"; idx="${3:-}"
wp_valid_repo "$repo" || {
echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; }
case "$idx" in
''|*[!0-9]*)
echo "[jsc][工作包閘門][ERR]:PR 編號須為數字,收到「${idx:-空值}」。" >&2; exit 2 ;;
esac
# 冪等:狀態檔不存在也算成功。結清流程可能被重跑,第二次失敗只會讓呼叫端誤判。
rm -f "$(wp_state_file "$repo" "$idx")" 2>/dev/null || true
exit 0 ;;
wp-report)
wp_pending
exit 0 ;;
wp-check)
[ "${JSC_WP_GATE:-}" = "off" ] && exit 0
pending=$(wp_pending)
[ -n "$pending" ] || exit 0
brief=$(printf '%s\n' "$pending" | wp_brief)
case "${2:-prompt}" in
prompt)
# 只注入提醒,一律 exit 0。擋提示會連「去把那支 PR 修好」的對話都送不出去,
# 把使用者鎖在門外,連逃生門都下不了指令。
# 這裡只列得出「哪些工作包還沒結清」,不知道候選包相依於誰——沒有上下文可以判斷。
# 真正「這一包能不能挑」的判斷在 jsc-sdlc/tools/wp-gate.sh check-deps,這則只是提醒。
echo "[jsc] ${brief} PR 尚未合併。相依於它的工作包不能開始,其餘互不相依的工作包不受影響——是否可挑,見 wp-gate.sh check-deps。"
exit 0 ;;
skill)
# 技能名取法比照 version-guard.sh:環境變數優先,非 Claude CLI 只餵得到環境變數。
skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
# 取不到技能名就安靜降級放行,不能拿沒有的資料當擋人的理由。
[ -n "$skill" ] || exit 0
# 帶前綴(jsc-sdlc:plan)與裸名(plan)都要認。
sname=${skill##*:}
case "$sname" in
plan|analyze|maintain)
echo "[jsc][工作包閘門][ERR]:${brief} PR 尚未合併,禁止在此存取庫執行「${sname}」。請先把該 PR 結清(合併或關閉),或執行 jsc-hooks/hooks/sdlc-gate.sh wp-unlock {owner}/{repo} {index} 解除;確定要整體放行請設 JSC_WP_GATE=off。本次技能呼叫已擋下。" >&2
exit 2 ;;
esac
# implement 與其餘技能一律放行:結清 PR 正是 implement 步驟 4 要做的事,
# 擋掉 implement 就沒有任何路徑能解除這道鎖,等於把流程鎖死。
exit 0 ;;
esac
exit 0 ;;
esac esac
exit 0 exit 0
+11 -1
View File
@@ -1,9 +1,16 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# session-timer.sh — 記錄工作階段花費時間(供 jsc-log:worklog 取用)。 # session-timer.sh — 記錄工作階段花費時間(供 jsc-log:worklog 取用)。
# 用法: # 用法:
# session-timer.sh start # SessionStart:記錄起始時間 # session-timer.sh start # SessionStart:記錄起始時間(已有紀錄就不動)
# session-timer.sh restart # SessionStart:一律覆寫起始時間
# session-timer.sh mark # Stop/SessionEnd:更新最後活動時間 # session-timer.sh mark # Stop/SessionEnd:更新最後活動時間
# session-timer.sh report [sid] # 印出 {sid} {seconds};無紀錄印 0 # session-timer.sh report [sid] # 印出 {sid} {seconds};無紀錄印 0
#
# start 與 restart 的差別在「同一個 session id 會不會重複開始」:
# start 給 Claude 這種每個工作階段都有自己 session id 的 CLI。續接同一階段時
# SessionStart 會再觸發一次,覆寫起始時間會讓花費時間歸零。
# restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default,
# 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
read_stdin read_stdin
sid=$(session_id) sid=$(session_id)
@@ -11,6 +18,9 @@ case "${1:-mark}" in
start) start)
f="$JSC_HOME/sessions/$sid.start" f="$JSC_HOME/sessions/$sid.start"
[ -f "$f" ] || now_epoch > "$f" ;; [ -f "$f" ] || now_epoch > "$f" ;;
restart)
now_epoch > "$JSC_HOME/sessions/$sid.start"
rm -f "$JSC_HOME/sessions/$sid.end" ;;
mark) mark)
now_epoch > "$JSC_HOME/sessions/$sid.end" ;; now_epoch > "$JSC_HOME/sessions/$sid.end" ;;
report) report)
Regular → Executable
+180 -158
View File
@@ -3,15 +3,22 @@
# #
# 本機版本落後遠端發佈版本時擋下該次技能呼叫,並提示更新指令。 # 本機版本落後遠端發佈版本時擋下該次技能呼叫,並提示更新指令。
# #
# 輸入:stdin JSON(Claude 格式)或環境變數,兩者都收。
# 工具名 JSC_TOOL_NAME、TOOL_NAME、stdin 的 tool_name
# 技能名 JSC_SKILL、SKILL、stdin 的 skill
# 兩邊都拿不到就安靜降級 exit 0。
#
# 判準與取值: # 判準與取值:
# - 比對對象是「遠端發佈版本」與「本機**實際載入**的版本」。 # - 比對對象是「遠端發佈版本」與「本機**實際載入**的版本」。
# 實際載入版本要從 installed_plugins.json 的 installPath 讀該版目錄下的 # 實際載入版本只認 installed_plugins.json 的 installPath 底下那份 plugin.json,
# plugin.json,不能只看註冊在 installed_plugins.json 的版本欄位——那兩者 # 不看註冊在 installed_plugins.json 的版本欄位——那兩者可能不同,註冊值比較新時
# 可能不同,只看註冊值會放過真正被載入的舊版。 # 會放過真正被載入的舊版。讀不到那份檔案就當查不到,安靜放行。
# - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會 # - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會
# 超前 master,擋下去會讓維護者自己動不了。 # 超前預設分支,擋下去會讓維護者自己動不了。
# - 遠端版本查不到(離線、站台維護、repo 改名)一律**擋**(fail-closed), # - **只有「本機落後遠端」會擋**。查不到資料一律放行(exit 0):本機版本、
# 避免「查不到就當作沒事」而讓落後版本靜靜跑下去。逃生門見下。 # Gitea 站台、遠端版本全部來自 Claude 的 plugin 檔案與 Gitea API,沒裝
# Claude 或離線的機器一筆都讀不到。那種情況擋下去,等於在沒有任何版本
# 證據時停掉每一次技能呼叫,護欄變成故障點。
# #
# 豁免(這些技能永遠放行): # 豁免(這些技能永遠放行):
# jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖 # jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖
@@ -22,84 +29,148 @@
# 逃生門:JSC_VERSION_GUARD=off 完全略過檢查(離線工作時用)。 # 逃生門:JSC_VERSION_GUARD=off 完全略過檢查(離線工作時用)。
# #
# 快取:$JSC_HOME/version-cache/{domain},單行「{版本} {epoch}」, # 快取:$JSC_HOME/version-cache/{domain},單行「{版本} {epoch}」,
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。 # 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。hook 與 report 共用同一份。
# #
# 另有一個非 hook 的子指令: # 另有一個非 hook 的子指令:
# version-guard.sh report 把每個已安裝 jsc-* plugin 的版本比對印成 TSV,每行 # version-guard.sh report 把每個已安裝 jsc-* plugin 的版本比對印成 TSV,每行
# 「{domain}<TAB>{本機}<TAB>{遠端}<TAB>{落後|最新|超前|查詢失敗}」, # 「{domain}<TAB>{本機}<TAB>{遠端}<TAB>{落後|最新|超前|查詢失敗}」,
# 最後一行「behind<TAB>{落後個數}」。供 jsc-cli:deploy 判斷要不要 # 最後一行「behind<TAB>{落後個數}」。供 jsc-cli:deploy 判斷要不要
# 把「更新」設成推薦選項。report 只讀不擋,永遠 exit 0。 # 把「更新」設成推薦選項。report 只讀不擋,永遠 exit 0。
# 本機沒有 Claude 的 plugin 註冊檔時改印「noregistry<TAB>{路徑}」
# 再接 behind 0:那代表這台機器無法做版本檢查,跟「全部最新」是兩件事。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
REG="$HOME/.claude/plugins/installed_plugins.json"
MK="$HOME/.claude/plugins/known_marketplaces.json"
# 從檔案取 JSON 字串欄位。與 lib.sh 的 json_str 同一種 naive 解析,只是來源是檔案:
# 先把換行換成空白、再以逗號斷行,這樣每行最多一個欄位,取值不會被貪婪比對吃掉。
file_json_str() { # $1=檔案 $2=欄位名
[ -f "$1" ] || return 0
tr '\n' ' ' < "$1" | tr ',' '\n' \
| sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
}
# 本機實際載入版本:先取該 plugin 的 installPath,再讀那個目錄下的 plugin.json。
# 只認 installPath 底下那份檔案。註冊在 installed_plugins.json 的 version 欄位不當備援:
# 註冊值可能比實際載入的版本新,拿它來比對會放過真正被載入的舊版,護欄形同虛設。
# 讀不到那份檔案就當「查不到本機載入版本」,由呼叫端安靜放行。
local_version() { # $1=domain
[ -f "$REG" ] && [ -r "$REG" ] || return 0
_seg=$(tr -d '\n' < "$REG" \
| sed -n "s/.*\"jsc-$1@jsc\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p")
[ -n "$_seg" ] || return 0
_path=$(printf '%s' "$_seg" | tr ',' '\n' \
| sed -n 's/.*"installPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$_path" ] || return 0
file_json_str "$_path/plugin.json" version
}
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST。
# 印出「{host} {owner}」;推導不出來時 host 為空字串。
remote_host_owner() {
_url=""
if [ -f "$MK" ]; then
_url=$(tr -d '\n' < "$MK" | sed -n 's/.*"jsc"[[:space:]]*:[[:space:]]*{//p' \
| tr ',' '\n' \
| sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
fi
_h=$(printf '%s' "$_url" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')
_o=$(printf '%s' "$_url" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')
if [ -z "$_h" ] || [ -z "$_o" ]; then
_h="${GITEA_HOST:-}"; _o="${JSC_GITEA_OWNER:-plugins}"
fi
printf '%s %s' "$_h" "$_o"
}
# 遠端發佈版本:一律經由 jsc-gitea 的 gitea.sh(技能準則),它會帶 GITEA_TOKEN,
# 並在缺 token 或 401/403 時退回 tea 的登入金鑰,私有存取庫才讀得到。
# 不指定 ref:Gitea 的 raw 端點預設就取該存取庫的預設分支,比在這裡寫死分支名準。
# 查不到就回傳空字串,由呼叫端放行。
remote_version() { # $1=domain $2=host $3=owner
_gsh=$(jsc_gitea_sh) || return 0
_v=""
for _try in 1 2; do # 暫時性網路失敗不該誤判成版本問題,失敗重試一次
_body=$(GITEA_HOST="$2" sh "$_gsh" api GET "/repos/$3/$1/raw/plugin.json" 2>/dev/null) \
&& _v=$(printf '%s' "$_body" | tr '\n' ' ' | tr ',' '\n' \
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$_v" ] && break
sleep 1
done
printf '%s' "$_v"
}
TTL="${JSC_VERSION_TTL:-600}"
cache_dir="$JSC_HOME/version-cache"
# 帶快取的遠端版本查詢。hook 與 report 共用同一份快取與同一個 TTL:
# report 每個 domain 各打一次網路(還帶重試),/jsc-cli:deploy 一跑就是全部 domain,
# 不共用快取等於每次部署都付一輪網路成本。
cached_remote_version() { # $1=domain $2=host $3=owner
_cache="$cache_dir/$1"
_now=$(now_epoch)
if [ -f "$_cache" ]; then
_cv=$(cut -d' ' -f1 "$_cache" 2>/dev/null)
_ca=$(cut -d' ' -f2 "$_cache" 2>/dev/null)
if [ -n "$_cv" ] && [ -n "$_ca" ] && [ $((_now - _ca)) -lt "$TTL" ]; then
printf '%s' "$_cv"; return 0
fi
fi
_rv=$(remote_version "$1" "$2" "$3")
if [ -n "$_rv" ]; then
mkdir -p "$cache_dir" 2>/dev/null || true
printf '%s %s\n' "$_rv" "$_now" > "$_cache" 2>/dev/null || true
fi
printf '%s' "$_rv"
}
# 語意化比較:印出 -1($1 落後)、0(相等)、1($1 超前)
ver_cmp() { # $1=版本 A $2=版本 B
awk -v a="$1" -v b="$2" '
BEGIN {
n = split(a, x, "."); m = split(b, y, ".")
for (i = 1; i <= 3; i++) {
xi = (i <= n ? x[i] + 0 : 0); yi = (i <= m ? y[i] + 0 : 0)
if (xi < yi) { print -1; exit }
if (xi > yi) { print 1; exit }
}
print 0
}'
}
# ── report:一次比對所有已安裝的 jsc plugin(非 hook 模式,不讀 stdin) # ── report:一次比對所有已安裝的 jsc plugin(非 hook 模式,不讀 stdin)
if [ "${1:-}" = "report" ]; then if [ "${1:-}" = "report" ]; then
python3 - <<'PY' # 註冊檔不存在或讀不到就明講。這裡不能只印 behind 0:呼叫端會把它讀成「都是最新」,
import json, os, re, subprocess, sys # 於是把「這台機器無法做版本檢查」誤報成「不用更新」。
# 舊版用 `tr -d '\n' < "$REG" 2>/dev/null`,那個 2>/dev/null 只蓋住 tr 的 stderr,
home = os.path.expanduser("~") # 蓋不住 shell 開檔失敗的訊息,所以沒裝 Claude 的機器會先漏一行 cannot open。
try: if [ ! -f "$REG" ] || [ ! -r "$REG" ]; then
reg = json.load(open(f"{home}/.claude/plugins/installed_plugins.json")).get("plugins", {}) printf 'noregistry\t%s\n' "$REG"
except Exception: printf 'behind\t0\n'
print("behind\t0"); sys.exit(0) exit 0
fi
host = owner = "" ho=$(remote_host_owner)
try: r_host=$(printf '%s' "$ho" | cut -d' ' -f1)
mk = json.load(open(f"{home}/.claude/plugins/known_marketplaces.json")) r_owner=$(printf '%s' "$ho" | cut -d' ' -f2)
m = re.match(r"(https?://[^/]+)/([^/]+)/[^/]+?(?:\.git)?/?$", behind=0
(mk.get("jsc", {}).get("source", {}) or {}).get("url", "")) domains=$(tr -d '\n' < "$REG" | tr ',' '\n' \
if m: | sed -n 's/.*"jsc-\([a-z0-9][a-z0-9-]*\)@[^"]*"[[:space:]]*:.*/\1/p' | sort -u)
host, owner = m.group(1), m.group(2) for d in $domains; do
except Exception: lv=$(local_version "$d")
pass rv=""
if not host: [ -n "$r_host" ] && rv=$(cached_remote_version "$d" "$r_host" "$r_owner")
host = os.environ.get("GITEA_HOST", "") if [ -z "$rv" ]; then
owner = os.environ.get("JSC_GITEA_OWNER", "plugins") st="查詢失敗"
else
case "$(ver_cmp "$lv" "$rv")" in
def ver(v): -1) st="落後"; behind=$((behind + 1)) ;;
parts = ((v or "").split(".") + ["0", "0", "0"])[:3] 1) st="超前" ;;
return tuple(int(p) if p.isdigit() else 0 for p in parts) *) st="最新" ;;
esac
fi
behind = 0 printf '%s\t%s\t%s\t%s\n' "$d" "${lv:-?}" "${rv:-?}" "$st"
for key in sorted(reg): done
m = re.match(r"^jsc-([^@]+)@", key) printf 'behind\t%s\n' "$behind"
if not m:
continue
domain = m.group(1)
# 本機版本一律以 installPath 底下那份 plugin.json 為準(實際載入版本),
# 讀不到才退回註冊欄位。
local = ""
for e in reg[key]:
try:
local = json.load(open(os.path.join(e.get("installPath", ""), "plugin.json"))).get("version", "")
break
except Exception:
local = e.get("version", "")
remote = ""
if host:
url = f"{host}/{owner}/{domain}/raw/branch/master/plugin.json"
for _ in range(2): # 暫時性網路失敗不該誤判成版本問題,失敗重試一次
try:
out = subprocess.run(["curl", "-sS", "--max-time", "10", url],
capture_output=True, text=True, timeout=20).stdout
mm = re.search(r'"version"\s*:\s*"([^"]+)"', out)
if mm:
remote = mm.group(1)
break
except Exception:
pass
if not remote:
state = "查詢失敗"
elif ver(local) < ver(remote):
state = "落後"; behind += 1
elif ver(local) > ver(remote):
state = "超前"
else:
state = "最新"
print(f"{domain}\t{local or '?'}\t{remote or '?'}\t{state}")
print(f"behind\t{behind}")
PY
exit 0 exit 0
fi fi
@@ -107,11 +178,13 @@ read_stdin
[ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0 [ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0
# 只管 Skill 工具 # 輸入相容:stdin JSON(Claude 格式)與環境變數(其他四支 CLI 接線時設定)都要收。
tool=$(json_str tool_name) # 只讀 stdin 的話,用環境變數餵資料的 CLI 一律拿到空值,檢查會整支靜靜放行。
# 兩者都缺才是真的沒資料,那時照舊安靜降級 exit 0。
tool="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}"
[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0 [ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0
skill=$(json_str skill) skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
[ -n "$skill" ] || exit 0 [ -n "$skill" ] || exit 0
# 只管本技能組(jsc-{domain}:{name}) # 只管本技能組(jsc-{domain}:{name})
@@ -129,95 +202,44 @@ case "$skill" in
jsc-cli:deploy|jsc-hooks:hooks-install|jsc-cli:models|jsc-meta:*) exit 0 ;; jsc-cli:deploy|jsc-hooks:hooks-install|jsc-cli:models|jsc-meta:*) exit 0 ;;
esac esac
TTL="${JSC_VERSION_TTL:-600}" # 更新指令依實際 CLI 給。印別的 CLI 的指令等於沒給指令,使用者照著打只會失敗。
cache_dir="$JSC_HOME/version-cache" update_cmd() { # $1=domain
mkdir -p "$cache_dir" 2>/dev/null || true case "$(cli_name)" in
claude)
printf 'claude plugin marketplace update jsc && claude plugin update jsc-%s@jsc' "$1" ;;
codex)
printf 'codex plugin marketplace upgrade jsc' ;;
copilot)
printf 'copilot plugin marketplace update jsc && copilot plugin update jsc-%s@jsc' "$1" ;;
antigravity)
printf 'git -C ~/plugins/%s pull && agy plugin uninstall jsc-%s && agy plugin install ~/plugins/%s' "$1" "$1" "$1" ;;
kiro)
printf 'kiro-cli plugin marketplace update jsc && kiro-cli plugin update jsc-%s@jsc' "$1" ;;
*)
printf '用你的 CLI 的 plugin 更新指令更新 jsc-%s@jsc' "$1" ;;
esac
}
deny() { # $1=訊息 deny() { # $1=訊息
printf '[jsc][版本檢查][ERR]:%s\n' "$1" >&2 printf '[jsc][版本檢查][ERR]:%s\n' "$1" >&2
printf '更新指令:claude plugin marketplace update jsc && claude plugin update jsc-%s@jsc\n' "$domain" >&2 printf '更新指令:%s\n' "$(update_cmd "$domain")" >&2
printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2 printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2
exit 2 exit 2
} }
# 本機實際載入版本:從 installPath 的 plugin.json 讀,不用註冊欄位 # 讀不到本機實際載入版本就放行:沒有版本證據時擋下等於停掉每一次技能呼叫
local_ver=$(python3 - "$domain" <<'PY' 2>/dev/null local_ver=$(local_version "$domain")
import json, os, sys [ -n "$local_ver" ] || exit 0
domain = sys.argv[1]
try:
reg = json.load(open(os.path.expanduser("~/.claude/plugins/installed_plugins.json")))
except Exception:
sys.exit(0)
entries = reg.get("plugins", {}).get(f"jsc-{domain}@jsc") or []
for e in entries:
p = os.path.join(e.get("installPath", ""), "plugin.json")
try:
print(json.load(open(p)).get("version", "")); sys.exit(0)
except Exception:
continue
# 讀不到實際檔案時退回註冊版本,並在後面標記為次要來源
if entries and entries[0].get("version"):
print(entries[0]["version"])
PY
)
[ -n "$local_ver" ] || deny "讀不到本機 jsc-$domain 的實際載入版本(installed_plugins.json 或該版目錄的 plugin.json 不可用)"
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST ho=$(remote_host_owner)
remote_src=$(python3 - <<'PY' 2>/dev/null host=$(printf '%s' "$ho" | cut -d' ' -f1)
import json, os, re owner=$(printf '%s' "$ho" | cut -d' ' -f2)
try: [ -n "$host" ] || exit 0
d = json.load(open(os.path.expanduser("~/.claude/plugins/known_marketplaces.json")))
except Exception:
raise SystemExit
url = (d.get("jsc", {}).get("source", {}) or {}).get("url", "")
m = re.match(r"(https?://[^/]+)/([^/]+)/[^/]+?(?:\.git)?/?$", url)
if m:
print(m.group(1), m.group(2))
PY
)
host=$(printf '%s' "$remote_src" | cut -d' ' -f1)
owner=$(printf '%s' "$remote_src" | cut -d' ' -f2)
if [ -z "$host" ] || [ -z "$owner" ]; then
host="${GITEA_HOST:-}"; owner="${JSC_GITEA_OWNER:-plugins}"
fi
[ -n "$host" ] || deny "推導不出 Gitea 站台(known_marketplaces.json 無 jsc 來源,GITEA_HOST 也未設定)"
# 快取 # 遠端版本(走 hook 與 report 共用的快取與 TTL)
cache="$cache_dir/$domain" remote_ver=$(cached_remote_version "$domain" "$host" "$owner")
now=$(now_epoch) [ -n "$remote_ver" ] || exit 0
remote_ver=""
if [ -f "$cache" ]; then
c_ver=$(cut -d' ' -f1 "$cache" 2>/dev/null)
c_at=$(cut -d' ' -f2 "$cache" 2>/dev/null)
if [ -n "$c_ver" ] && [ -n "$c_at" ] && [ $((now - c_at)) -lt "$TTL" ]; then
remote_ver="$c_ver"
fi
fi
if [ -z "$remote_ver" ]; then # 只擋「本機 < 遠端」這一種情況
url="$host/$owner/$domain/raw/branch/master/plugin.json" [ "$(ver_cmp "$local_ver" "$remote_ver")" = "-1" ] || exit 0
# 暫時性網路失敗不該誤判成版本問題,所以失敗重試一次再放棄
for _try in 1 2; do
body=$(curl -sS --max-time 10 "$url" 2>/dev/null) && \
remote_ver=$(printf '%s' "$body" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$remote_ver" ] && break
sleep 1
done
[ -n "$remote_ver" ] || deny "查不到 jsc-$domain 的遠端發佈版本($url)。無法確認本機是否為最新,依 fail-closed 規則擋下"
printf '%s %s\n' "$remote_ver" "$now" > "$cache" 2>/dev/null || true
fi
# 語意化比較:只擋「本機 < 遠端」
cmp=$(awk -v a="$local_ver" -v b="$remote_ver" '
BEGIN {
n = split(a, x, "."); m = split(b, y, ".")
for (i = 1; i <= 3; i++) {
xi = (i <= n ? x[i] + 0 : 0); yi = (i <= m ? y[i] + 0 : 0)
if (xi < yi) { print -1; exit }
if (xi > yi) { print 1; exit }
}
print 0
}')
[ "$cmp" = "-1" ] || exit 0
deny "jsc-$domain 本機版本 $local_ver 落後遠端發佈版本 $remote_ver,本次技能呼叫已擋下" deny "jsc-$domain 本機版本 $local_ver 落後遠端發佈版本 $remote_ver,本次技能呼叫已擋下"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.0.9", "version": "0.1.9",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查",
"skills": "./skills/" "skills": "./skills/"
} }
+35 -17
View File
@@ -1,33 +1,51 @@
--- ---
name: hooks-install name: hooks-install
description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SDLC model gate, plugin version guard) into every installed AI CLI. Detect CLIs via jsc-cli detect-clis.sh, then apply native hook config, the jsc-wrap.sh launcher, or an instruction-file fallback per CLI. Use after installing or updating the jsc plugin set; not for writing new hooks. description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SDLC model gate, plugin version guard, comment scope scanner) into every installed AI CLI, purging all pre-existing hooks first — third-party ones included, backed up before removal. Drive it per CLI through tools/wire-cli.sh purge, tools/wire-cli.sh, tools/wire-cli.sh smoke and tools/scan-hook-errors.sh. Hand any hook error, wiring or runtime, to jsc-hooks:repair, which must finish with a PR against develop; aborting the rest of the install to start that repair is allowed. Use after installing or updating the jsc plugin set; not for writing new hooks.
--- ---
# hooks-install — wire jsc hooks into every installed CLI # hooks-install — wire jsc hooks into every installed CLI
Goal: make the five hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.sh`, `version-guard.sh`) effective in every CLI. Goal: make the six hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.sh`, `version-guard.sh`, `comment-scope.sh`) effective in every CLI, with nothing else wired alongside them.
`version-guard.sh` runs on PreToolUse(Skill) and blocks a skill whose locally loaded plugin version is behind the published one. Where a CLI has no pre-tool hook, that guard cannot be wired — say so in the report rather than implying every CLI is covered.
Claude wiring is automatic via `hooks.json`. On codex and kiro the SDLC gate degrades to the skill-step check only; the lock file still works because the SDLC skills call `sdlc-gate.sh lock {stage}` directly — that call is where the capability-tag comparison happens, so the gate keeps its force even where the prompt hook cannot be wired. Install on a clean slate. Every CLI is purged of all hooks first, third-party ones included, so a later failure has exactly one owner. `tools/wire-cli.sh purge` backs up every file it touches before it removes anything, so the removal stays reversible.
Only claude has PreToolUse, PostToolUse and UserPromptSubmit, so only claude reports `wired`. On codex, copilot, antigravity and kiro the version guard cannot be wired at all and the SDLC gate degrades to the skill-step check, so all four report `degraded` — report that gap as the script words it instead of implying every CLI is covered.
`comment-scope.sh` now reaches all five, but on a different event and at a different moment each. Report the timing per CLI; never state it as one uniform behaviour:
| CLI | Scanning moment | Wired through |
| --- | --- | --- |
| claude | Per file, the instant it is written | PostToolUse |
| codex | End of every turn, over the whole git worktree | `notify` in `config.toml` |
| kiro | On every prompt submit, over the whole git worktree — it sees what the previous turn wrote | `userPromptSubmit` in `.kiro/hooks/jsc-hooks.json` |
| copilot, antigravity | Once, when the session ends | `tools/jsc-wrap.sh` teardown |
The `sweep` mode reads `git diff HEAD`, so its coverage matches what claude sees; only the feedback delay differs. Outside a git worktree `sweep` exits 0 in silence and nothing is scanned at all — say so when the user works outside git. The `prompt` rule reminder still goes into every rule file alongside the STE100 block, because a warning that arrives a turn late is worth less than not writing the comment in the first place.
The lock file still works on those four because the SDLC skills call `sdlc-gate.sh lock {stage}` directly — that call is where the capability-tag comparison happens, so the gate keeps its force even where the prompt hook cannot be wired.
The gate needs `$JSC_HOME/model-tags.tsv`; when it is missing, report that `jsc-cli:models` (or `jsc-cli/tools/model-tags.sh sync`) must run once, because `sdlc-gate.sh lock` refuses to lock without it. The gate needs `$JSC_HOME/model-tags.tsv`; when it is missing, report that `jsc-cli:models` (or `jsc-cli/tools/model-tags.sh sync`) must run once, because `sdlc-gate.sh lock` refuses to lock without it.
Treat any hook error as repair work, whether it appeared while wiring or while running. Stopping the remaining installs to start that repair is the right call; leaving a broken hook wired is not.
The detailed flow **MUST run as a sub agent**; the main agent only reports the summary. The detailed flow **MUST run as a sub agent**; the main agent only reports the summary.
## Steps ## Steps
1. Run `jsc-cli/tools/detect-clis.sh`. Done when you hold the list of installed CLIs; when the list is empty, report that and stop. 1. Run `jsc-cli/tools/detect-clis.sh`. Done when you hold the list of installed CLIs; when the list is empty, report that and stop.
2. For each installed CLI, run `tools/wire-cli.sh {cli}`. The script performs the config edit, wrapper alias install, or hook file creation for that CLI, and replaces its `<!-- jsc-hooks -->` (or `# jsc-hooks`) marker block idempotently — reruns never duplicate content. Read its exit code and first output line (`status=wired|degraded|skipped reason=...`), then confirm against the table below: 2. For each installed CLI, run `tools/wire-cli.sh purge {cli}`. The script backs up every file it touches, removes all hooks, re-reads each file to confirm the removal, and restores the backup by itself when a check fails. Done when every CLI has printed exactly one `status=purged|skipped|failed reason=...` line and you have noted the backup directory path from its `[jsc]` output.
3. For each installed CLI, run `tools/wire-cli.sh {cli}`. The script owns both the wiring and its verification: it writes the config, alias or hook file inside a `<!-- jsc-hooks -->` (or `# jsc-hooks`) marker block, re-reads every file it wrote, and confirms the block is present and correctly placed before it prints a success status. Trust its first line, `status=wired|degraded|skipped|failed reason=...`. Exit 2 means a bad CLI name, not a wiring outcome — fix the name and rerun. Done when every installed CLI has printed exactly one `status=` line and none exited 2.
| CLI | Exit / status | Verify | 4. For each installed CLI, run `tools/wire-cli.sh smoke {cli}`. This runs all six hooks once each, every wired mode included, and catches what the wiring check cannot see: a hook that is wired correctly and still fails when it executes. Done when every CLI has printed one `status=ok|failed reason=...` line plus one result line per hook.
| --- | --- | --- | 5. For each installed CLI, run `tools/scan-hook-errors.sh --cli {cli}`. Only claude keeps hook results in its native records and can answer `clean` or `errors`; codex, copilot, antigravity and kiro answer `unavailable`, and their runtime evidence comes from step 4 alone. Done when every CLI has printed one `status=clean|errors|unavailable reason=...` line and the four `unavailable` CLIs are reported as exactly that, not as clean.
| claude | `status=wired` (exit 0) — hooks.json auto-wires everything, nothing to write | `claude plugin list` shows `jsc-hooks` and `/hooks` shows the registrations | 6. For each error — `purge` failed, wiring failed, smoke failed, or a scanned error with `jsc=true` — run `tools/report-error.sh --hook {script name} --exit {code} --summary "{reason}" --cli {cli}` with the script's `[jsc]` output on stdin, then hand the failure to `jsc-hooks:repair`, which **MUST run as a sub agent** and must finish by opening a PR against `develop`. Aborting the remaining installs here is allowed as long as the repair starts. A scanned error with `jsc=false` belongs to a third-party hook: report it and leave it alone. Done when each error has either an `ERROR_{HASH}` page name on stdout, or an empty exit 0 meaning `JSC_WIKI_REPO_ERROR` and `JSC_WIKI_REPO` are both unset — in that second case carry the reason into step 7 instead. Skip this step when every CLI passed all four checks.
| codex | `status=degraded` (exit 1) — notify + AGENTS.md prompt fallback | `~/.codex/config.toml` contains the `notify` entry and `AGENTS.md` contains the block | 7. Report four results per CLI — purge, wiring, smoke, scan — each with the reason its script printed, plus any `ERROR_{HASH}` page name and repair PR URL. Done when every detected CLI has exactly one status per check and every repair has a PR against `develop`.
| copilot | `status=wired` (exit 0) when the CLI is detected, `status=skipped` (exit 3) otherwise | the alias resolves to `jsc-wrap.sh copilot` and `copilot-instructions.md` contains the block |
| antigravity | `status=wired` (exit 0) when the CLI is detected, `status=skipped` (exit 3) otherwise | the alias resolves to `jsc-wrap.sh antigravity` and the rules file contains the block |
| kiro | `status=degraded` (exit 1) | the hook file exists under `.kiro/hooks/` and names the script |
A row counts as done only when its verify check passes. Exit 2 means bad usage (wrong CLI name), not a wiring outcome.
3. Report the exact `status=` line `tools/wire-cli.sh` printed for each CLI; do not reinterpret or recompute the outcome by hand. Done when every detected CLI has exactly one reported status: wired, degraded, or skipped with a reason.
## Notes ## Notes
- The hook scripts accept both stdin JSON and environment variables (`JSC_CLI`, `JSC_SESSION_ID`, `JSC_SKILL`, `JSC_MODEL`); `jsc-wrap.sh` sets the first two itself. - Every hook script accepts both stdin JSON and environment variables (`JSC_CLI`, `JSC_SESSION_ID`, `JSC_SKILL`, `JSC_TOOL_NAME`, `JSC_MODEL`); `jsc-wrap.sh` sets the first two itself.
- `session-timer.sh` takes `start` (keep an existing start time), `restart` (always overwrite it, for a CLI with no session id — kiro), `mark` and `report`. `wire-cli.sh` picks the right one per CLI; do not hand-edit the generated hook files.
- `purge` reaches the user-level config only. Hooks that another plugin ships in its own `hooks.json` stay active, and uninstalling that plugin is the only way to clear them — say so when reporting, and treat their errors as third-party.
- Backups land in `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`, one directory per purge run, under the original file names. Hand that path to the user whenever a purge removed something.
- `smoke` treats `sdlc-gate.sh check` exit 2 as healthy: that exit is the stage lock blocking a turn on purpose, not a runtime error. `comment-scope.sh` exit 2 counts as healthy for the same reason — it means the scan found a comment and warned about it. The no-argument mode has no file name during smoke and exits 0 in silence; `sweep` depends on the worktree it runs in, so it answers 2 whenever that worktree happens to carry an offending comment. Neither is a broken hook.
- `comment-scope.sh` takes three modes: `prompt` (inject the rule summary at UserPromptSubmit), no argument at all (scan the file just written at PostToolUse, reading `file_path` from stdin JSON or `JSC_CHANGED_FILE`), and `sweep [dir]` (scan every file the git worktree changed, for the four CLIs with no post-tool hook). All scanning modes read only the lines a diff added, skip markdown and binary files, and turn off entirely with `JSC_COMMENT_SCOPE=off`. The rule text itself lives in one place only, `jsc-review`'s `references/comment-scope.md`; never restate the list anywhere in this repo.
- `jsc-wrap.sh` runs `sweep` after the CLI exits and always returns the CLI's own exit code. A `sweep` hit warns on stderr and changes nothing else — never let a comment warning turn a successful CLI run into a failed one.
- `tools/report-error.sh` is operator- or skill-invoked only. Never wire it to fire from a failing hook: hooks stay silent and exit 0, and a failing hook that reports itself can loop.
- Data lands in `$JSC_HOME` (default `~/.jsc`), consumed by `jsc-log:worklog` and `jsc-log:stats`. - Data lands in `$JSC_HOME` (default `~/.jsc`), consumed by `jsc-log:worklog` and `jsc-log:stats`.
+16
View File
@@ -0,0 +1,16 @@
---
name: repair
description: Repair failed hook wiring by delegating diagnosis to installed AI agent CLIs as subagents, patching the hooks repo, syncing manifests, and opening a PR against develop. Use when hooks-install or report-error.sh reports a failed hook, or when a hook keeps failing after rewiring; not for routine wiring or unrelated work.
---
# repair — repair a failed hook
Single source of guidelines: [`../../references/guidelines.md`](../../references/guidelines.md).
## Flow
1. Read the failure context from `ERROR_{HASH}` through `jsc-gitea:wiki` or from the failed `status=` line, then confirm the target repo is `hooks` and the PR base branch is `develop`. Completion condition: the failure context and target branch are explicit.
2. Detect installed AI CLIs with `../cli/tools/detect-clis.sh`, then delegate diagnosis to one subagent per available CLI. Each subagent must receive the failure context and the `/jsc-shared:spec-output` rules, must stay read-only, and must return one structured repair proposal: root cause, changed files, and verification command. Completion condition: every available CLI has one returned proposal, or there are no CLIs and the main agent has noted that it must diagnose alone.
3. Pick the smallest repair that makes the wiring pass, then apply it in the `hooks` repo. If the fix touches wiring behavior, update `hooks/tools/wire-cli.sh`, `hooks/skills/hooks-install/SKILL.md`, and `hooks/README.md` together. Run the relevant verification command before moving on. Completion condition: the fix is on disk and the verification command passes.
4. Run `../meta/tools/sync-skill-manifest.sh .`. Completion condition: the README skill list and all three manifests show the same new version.
5. Commit, push, and open a PR with `jsc-git:pr develop`. Completion condition: a PR URL comes back and the repair is ready for review.
+13 -2
View File
@@ -2,23 +2,34 @@
# jsc-wrap.sh — 無 hook 系統 CLI 的包裝啟動器(例:copilot、antigravity)。 # jsc-wrap.sh — 無 hook 系統 CLI 的包裝啟動器(例:copilot、antigravity)。
# 用法: jsc-wrap.sh {cli} [args...] # 用法: jsc-wrap.sh {cli} [args...]
# 行為: 匯出 JSC_CLI 與 JSC_SESSION_ID → session-timer start → 執行 CLI → # 行為: 匯出 JSC_CLI 與 JSC_SESSION_ID → session-timer start → 執行 CLI →
# 結束後 session-timer mark 並以 scan-logs.sh 回填用量,最後回傳 CLI 的結束碼。 # 結束後 session-timer mark、以 scan-logs.sh 回填用量、以 comment-scope.sh sweep
# 掃一次整個工作區的註解範圍,最後回傳 CLI 的結束碼。
# 注意: JSC_CLI 存的是 CLI 代號(antigravity、kiro),實際執行的是 cli_bin 對應的
# 執行檔(agy、kiro-cli)。直接拿代號當指令跑會 127,因為沒有這兩個執行檔。
HERE=$(cd "$(dirname "$0")" && pwd) HERE=$(cd "$(dirname "$0")" && pwd)
HOOKS="$HERE/../hooks" HOOKS="$HERE/../hooks"
. "$HOOKS/lib.sh"
cli="${1:-}" cli="${1:-}"
if [ -z "$cli" ]; then if [ -z "$cli" ]; then
echo "用法:jsc-wrap.sh {cli} [args...]" >&2 echo "用法:jsc-wrap.sh {cli} [args...]" >&2
exit 2 exit 2
fi fi
shift shift
bin=$(cli_bin "$cli")
JSC_CLI="$cli" JSC_CLI="$cli"
# 未提供 session id 就自動產生({cli}-時間戳-PID),讓計時與用量共用同一個 session # 未提供 session id 就自動產生({cli}-時間戳-PID),讓計時與用量共用同一個 session
[ -n "${JSC_SESSION_ID:-}" ] || JSC_SESSION_ID="$cli-$(date +%Y%m%d%H%M%S)-$$" [ -n "${JSC_SESSION_ID:-}" ] || JSC_SESSION_ID="$cli-$(date +%Y%m%d%H%M%S)-$$"
export JSC_CLI JSC_SESSION_ID export JSC_CLI JSC_SESSION_ID
sh "$HOOKS/session-timer.sh" start </dev/null sh "$HOOKS/session-timer.sh" start </dev/null
"$cli" "$@" "$bin" "$@"
rc=$? rc=$?
# 收尾:補記結束時間,並從原生日誌回填技能用量 # 收尾:補記結束時間,並從原生日誌回填技能用量
sh "$HOOKS/session-timer.sh" mark </dev/null sh "$HOOKS/session-timer.sh" mark </dev/null
sh "$HERE/scan-logs.sh" --cli "$cli" --session "$JSC_SESSION_ID" </dev/null || true sh "$HERE/scan-logs.sh" --cli "$cli" --session "$JSC_SESSION_ID" </dev/null || true
# 註解範圍收尾掃描:copilot 與 antigravity 連逐輪事件都沒有,整個工作階段只有這裡掃得到。
# 掃的是啟動當下的工作目錄,也就是使用者跑 CLI 的那個 git 工作區。
# `|| true` 不可省:sweep 掃到違規會 exit 2,接住它才不會蓋掉底下要回傳的 CLI 結束碼。
# 警告只走 sweep 自己的 stderr,包裝器一律原樣回傳 $rc——包裝器改掉結束碼,呼叫端的
# `cmd && next` 就會誤判,註解檢查不該有這種副作用。
sh "$HOOKS/comment-scope.sh" sweep </dev/null || true
exit "$rc" exit "$rc"
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env sh
# report-error.sh — 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 ERROR_{HASH},
# 並在 ERROR_CONTENTS 附上一列索引。頁面內容套用 templates/ 的兩份範本,
# 範本是文案的唯一來源,本腳本只填欄位。
#
# 用法:
# report-error.sh --hook {名稱} --exit {碼} --summary {摘要}
# [--repo {owner}/{repo}] [--cli {名稱}] [--session {id}]
# [--source {stdin|env|command}] [--symptom {現象}]
# [--cause {可能原因}] [--action {處理結果}]
# 相關輸出(stdout/stderr 摘要)由標準輸入讀入,可省略。
#
# 輸出:
# 成功印出「{頁名} {網址}」一行。
# wiki 位置解析不出來(JSC_WIKI_REPO_ERROR 與 JSC_WIKI_REPO 都沒設,或找不到
# gitea.sh)時安靜降級:不輸出、exit 0。回報失敗不該再變成一次失敗。
# 寫入 wiki 失敗才以 exit 4 回報,訊息走 stderr。
#
# 頁名:
# ERROR_{HASH},HASH 取「{owner}/{repo} {hook} {時間}」的 SHA-1 前 8 碼(共用 hash 規則)。
# 時間放進 hash:同一種失敗再發生時要另開新頁,不覆寫舊紀錄。
#
# 誰來呼叫:
# 由操作者手動執行,或由技能步驟執行(`jsc-hooks:hooks-install` 在 wire-cli.sh 回報
# status=failed 時呼叫)。**不接在失敗的 hook 上自動觸發**:hook 一律安靜 exit 0,
# 而且自我回報要走網路寫 wiki,失敗的 hook 再去回報自己會疊出迴圈。
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/.." && pwd)
. "$ROOT/hooks/lib.sh"
STDIN_JSON="" # 本腳本的標準輸入是錯誤輸出摘要,不是 JSON
hook=""; code=""; summary=""; repo=""; cli=""; session=""
source_kind=""; symptom=""; cause=""; action=""
while [ $# -gt 0 ]; do
case "$1" in
--hook) hook="${2:-}"; shift 2 ;;
--exit) code="${2:-}"; shift 2 ;;
--summary) summary="${2:-}"; shift 2 ;;
--repo) repo="${2:-}"; shift 2 ;;
--cli) cli="${2:-}"; shift 2 ;;
--session) session="${2:-}"; shift 2 ;;
--source) source_kind="${2:-}"; shift 2 ;;
--symptom) symptom="${2:-}"; shift 2 ;;
--cause) cause="${2:-}"; shift 2 ;;
--action) action="${2:-}"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$hook" ] || [ -z "$summary" ]; then
echo "用法:report-error.sh --hook {名稱} --exit {碼} --summary {摘要} [...]" >&2
exit 2
fi
gsh=$(jsc_gitea_sh) || exit 0
wrepo=$(sh "$gsh" wiki-repo ERROR 2>/dev/null) || exit 0
[ -n "$wrepo" ] || exit 0
# 存取庫名稱未指定就取工作目錄的 origin(只用來標記異常屬於哪個存取庫)
if [ -z "$repo" ]; then
origin=$(git config --get remote.origin.url 2>/dev/null || true)
repo=$(printf '%s' "$origin" \
| sed -n 's#.*[/:]\([^/]*\)/\([^/]*\)$#\1/\2#p' | sed 's/\.git$//')
fi
[ -n "$repo" ] || repo="-"
[ -n "$cli" ] || cli=$(cli_name)
[ -n "$session" ] || session=$(session_id)
[ -n "$code" ] || code="-"
[ -n "$source_kind" ] || source_kind="command"
[ -n "$symptom" ] || symptom="$summary"
[ -n "$cause" ] || cause="待查"
[ -n "$action" ] || action="待處理"
ts=$(date +'%Y-%m-%d %H:%M:%S')
ticket_ts=$(date +'%Y%m%d_%H%M%S')
if [ -t 0 ]; then detail=""; else detail=$(cat 2>/dev/null | tr '\n' ' ' | cut -c1-500); fi
[ -n "$detail" ] || detail="(無)"
# 摘要與相關輸出都落在 markdown 表格欄位裡,半形 | 會把欄位切斷,改成全形
detail=$(printf '%s' "$detail" | sed 's/|/|/g')
summary=$(printf '%s' "$summary" | sed 's/|/|/g')
hash=$(sh "$gsh" hash-id "$repo $hook $ts" 2>/dev/null) || exit 0
[ -n "$hash" ] || exit 0
page="ERROR_$hash"
# sed 取代值要先轉義:& 與分隔字元 | 會被 sed 當語法,換行會整行斷掉
esc() { printf '%s' "$1" | tr '\n' ' ' | sed 's/[\\&|]/\\&/g'; }
fill() { sed "s|$1|$(esc "$2")|g"; }
tmp_page=$(mktemp) || exit 0
tmp_list=$(mktemp) || { rm -f "$tmp_page"; exit 0; }
trap 'rm -f "$tmp_page" "$tmp_list"' EXIT
fill '{HASH}' "$hash" < "$ROOT/templates/error-page.md" \
| fill '{yyyy-MM-dd HH:mm:ss}' "$ts" \
| fill '{owner}/{repo}' "$repo" \
| fill '{cli}' "$cli" \
| fill '{session_id}' "$session" \
| fill '{hook_name}' "$hook" \
| fill '{exit_code}' "$code" \
| fill '{error_summary}' "$summary" \
| fill '{現象描述}' "$symptom" \
| fill '{可能原因}' "$cause" \
| fill '{處理方式}' "$action" \
| fill '{stdin / env / command}' "$source_kind" \
| fill '{stdout / stderr 摘要}' "$detail" \
| fill '{yyyyMMdd}_{HHmmss}' "$ticket_ts" > "$tmp_page"
row=$(printf '| %s | [[%s|%s]] | %s | %s | %s | %s |' \
"$ts" "$hook 異常 $ts" "$page" "$repo" "$hook" "$code" "$summary")
# 目錄頁:已存在就把新列附在文末(最新一筆在最後);不存在就用範本建立
if sh "$gsh" wiki-get "$wrepo" ERROR_CONTENTS > "$tmp_list" 2>/dev/null \
&& [ -s "$tmp_list" ]; then
printf '%s\n' "$row" >> "$tmp_list"
else
fill '{yyyy-MM-dd HH:mm:ss}' "$ts" < "$ROOT/templates/error-contents.md" \
| fill '{HASH}' "$hash" \
| fill '{error title}' "$hook 異常 $ts" \
| fill '{owner}/{repo}' "$repo" \
| fill '{hook_name}' "$hook" \
| fill '{exit_code}' "$code" \
| fill '{error_summary}' "$summary" > "$tmp_list"
fi
if ! sh "$gsh" wiki-put "$wrepo" "$page" "$tmp_page" >/dev/null 2>&1; then
echo "[jsc] 寫入 $page 失敗($wrepo)。" >&2
exit 4
fi
if ! sh "$gsh" wiki-put "$wrepo" ERROR_CONTENTS "$tmp_list" >/dev/null 2>&1; then
echo "[jsc] 寫入 ERROR_CONTENTS 失敗($wrepo),$page 已建立。" >&2
exit 4
fi
url=$(sh "$gsh" wiki-url "$wrepo" "$page" 2>/dev/null || true)
printf '%s %s\n' "$page" "$url"
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env sh
# scan-hook-errors.sh — 掃 CLI 的原生紀錄,找出 hook 的「執行期」錯誤:接線寫對了、
# hook 也真的被觸發了,但跑起來出錯(缺執行檔、路徑錯、權限不足)。
# 用法: scan-hook-errors.sh --cli {claude|codex|copilot|antigravity|kiro}
#
# 覆蓋範圍要據實回報,不得暗示每個 CLI 都掃得到:
# claude 有 hook 結果紀錄,掃 ~/.claude/projects/**/*.jsonl 兩種紀錄——
# `"type":"hook_non_blocking_error"` 的 attachment
# (hookName、hookEvent、exitCode、stderr、command、timestamp)
# 與非空的 `"hookErrors":[...]`
# codex、copilot、antigravity、kiro 原生紀錄只留工作階段與提示內容,沒有記下 hook 的退出碼與
# stderr,一律回報 unavailable,改用 wire-cli.sh smoke {cli}
# 主動跑一輪驗執行期
#
# 去重: 以 $JSC_HOME/errors/scan-state/ 記住每個日誌檔已掃描的位元組數(同 tools/scan-logs.sh),
# 重掃只讀新增段落。兩種紀錄各自成筆,不互相配對:同一次失敗在不同 transcript 條目
# 裡各留一筆,靠位置猜配對只會把真錯誤併掉。
#
# 產出: 每筆錯誤附加一行 JSON 到 $JSC_HOME/errors/hooks.jsonl(格式比照 hooks/skill-usage.sh):
# {ts,cli,hook,event,exit,detail,jsc}
# jsc 欄位:command 或 stderr 命中 ste100-guard.sh、session-timer.sh、skill-usage.sh、
# sdlc-gate.sh、version-guard.sh 任一支就是 true,否則 false。分得出來才用得上——
# 非 jsc 的 hook 錯誤不是 jsc 該修的,hooks-install 只回報、不轉 jsc-hooks:repair。
#
# 輸出: 第一行 `status={clean|errors|unavailable} reason=...`(可供程式判讀),
# errors 時其後每筆一行人類可讀的繁中摘要(hook 名、退出碼、是否屬 jsc)。
# 結束碼: 0=clean 或 unavailable、1=errors、2=用法錯誤
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
. "$HERE/../hooks/lib.sh"
cli=""
while [ $# -gt 0 ]; do
case "$1" in
--cli) cli="${2:-}"; shift 2 ;;
*) shift ;;
esac
done
case "$cli" in
claude|codex|copilot|antigravity|kiro) ;;
*)
echo "用法:scan-hook-errors.sh --cli {claude|codex|copilot|antigravity|kiro}" >&2
exit 2 ;;
esac
ERRDIR="$JSC_HOME/errors"
STATE="$ERRDIR/scan-state"
OUT="$ERRDIR/hooks.jsonl"
mkdir -p "$STATE" 2>/dev/null || true
case "$cli" in
codex|copilot|antigravity|kiro)
printf 'status=unavailable reason=%s\n' "$cli 沒有 hook 結果紀錄,執行期錯誤掃不到"
echo "[jsc] $cli:原生紀錄只留工作階段與提示內容,沒有記下 hook 的退出碼與 stderr。"
echo "[jsc] $cli:改跑 tools/wire-cli.sh smoke $cli,主動執行五支 hook 驗執行期。"
exit 0 ;;
esac
# 印出日誌檔自上次掃描後的新增內容,並更新位移(位移即去重機制)
new_content() { # $1=file
key=$(printf '%s' "$1" | cksum | tr ' \t' '--')
off_f="$STATE/$cli-$key.offset"
off=$(cat "$off_f" 2>/dev/null || echo 0)
size=$(wc -c < "$1" 2>/dev/null || echo 0)
[ "$size" -gt "$off" ] 2>/dev/null || return 0
tail -c +"$((off + 1))" "$1" 2>/dev/null
echo "$size" > "$off_f"
}
# 從新增內容萃取錯誤,每筆一行 TSV:hook、event、exit、jsc、ts、detail。
# 欄位用手寫掃描取,不靠正規式一次抓完:stderr 裡有轉義引號,正規式會抓過頭。
extract_errors() {
awk '
# 取 JSON 字串或純量欄位;字串保留原本的轉義序列,寫回 jsonl 時才不必重新轉義。
function jstr(s, name, p, i, c, out) {
p = index(s, "\"" name "\":")
if (p == 0) return ""
i = p + length(name) + 3
while (substr(s, i, 1) == " ") i++
if (substr(s, i, 1) != "\"") {
out = ""
while (i <= length(s) && substr(s, i, 1) !~ /[,}\]]/) { out = out substr(s, i, 1); i++ }
return out
}
i++
out = ""
while (i <= length(s)) {
c = substr(s, i, 1)
if (c == "\\") { out = out substr(s, i, 2); i += 2; continue }
if (c == "\"") break
out = out c; i++
}
return out
}
function is_jsc(t) {
if (index(t, "ste100-guard.sh") || index(t, "session-timer.sh") \
|| index(t, "skill-usage.sh") || index(t, "sdlc-gate.sh") \
|| index(t, "version-guard.sh")) return "true"
return "false"
}
# 摘要收斂成單行短字串:TSV 欄位不能有 tab,jsonl 欄位不能有裸換行;
# 截斷可能切到半個轉義序列,尾端的反斜線要清掉才是合法 JSON 字串。
function clean(t, x) {
x = t
gsub(/\t/, " ", x)
gsub(/\\n/, " ", x)
x = substr(x, 1, 300)
sub(/\\+$/, "", x)
if (x == "") x = "(無錯誤輸出)"
return x
}
# hookErrors 的內容是 JSON 陣列原文,元素外面那對引號是結構、不是文字。
# 直接寫進 jsonl 會多出一對裸引號把字串切斷,所以先拆成純文字,多筆用分號串起來。
function unarray(a) {
gsub(/","/, "; ", a)
sub(/^"/, "", a)
sub(/"$/, "", a)
return a
}
function emit(hook, ev, ec, own, ts, detail) {
if (hook == "") hook = "unknown"
if (ev == "") ev = "-"
if (ec == "") ec = "-"
printf "%s\t%s\t%s\t%s\t%s\t%s\n", hook, ev, ec, own, ts, clean(detail)
}
/"type":"hook_non_blocking_error"/ {
err = jstr($0, "stderr"); cmd = jstr($0, "command")
detail = (err != "" ? err : cmd)
emit(jstr($0, "hookName"), jstr($0, "hookEvent"), jstr($0, "exitCode"), \
is_jsc(cmd " " err), jstr($0, "timestamp"), detail)
next
}
# 非空的 hookErrors:只有訊息陣列,沒有 hook 名與退出碼,欄位據實留空。
/"hookErrors":\[[^]]/ {
p = index($0, "\"hookErrors\":[")
rest = substr($0, p + length("\"hookErrors\":["))
q = index(rest, "]")
arr = (q > 1 ? substr(rest, 1, q - 1) : rest)
emit("", "", "", is_jsc(arr), jstr($0, "timestamp"), unarray(arr))
}
'
}
d="$HOME/.claude/projects"
if [ ! -d "$d" ]; then
printf 'status=clean reason=%s\n' "找不到 $d,沒有 claude 紀錄可掃"
echo "[jsc] claude:這台機器沒有 transcript 目錄,掃不到紀錄跟沒有錯誤是兩件事,請改跑 tools/wire-cli.sh smoke claude。"
exit 0
fi
recs=$(mktemp) || { printf 'status=clean reason=%s\n' "無法建立暫存檔"; exit 0; }
files=$(mktemp) || { rm -f "$recs"; printf 'status=clean reason=%s\n' "無法建立暫存檔"; exit 0; }
trap 'rm -f "$recs" "$files"' EXIT
find "$d" -type f -name '*.jsonl' 2>/dev/null | sort > "$files"
# 迴圈從檔案讀,不放在管線右邊:管線會開子 shell,計數與旗標傳不回本 shell。
while IFS= read -r f; do
[ -n "$f" ] || continue
new_content "$f" | extract_errors >> "$recs"
done < "$files"
total=0; jsc_n=0
human=$(mktemp) || { printf 'status=clean reason=%s\n' "無法建立暫存檔"; exit 0; }
TAB=$(printf '\t')
while IFS="$TAB" read -r hook ev ec isjsc ts detail; do
[ -n "$hook" ] || continue
[ -n "$ts" ] || ts=$(now_iso)
total=$((total + 1))
printf '{"ts":"%s","cli":"%s","hook":"%s","event":"%s","exit":"%s","detail":"%s","jsc":%s}\n' \
"$ts" "$cli" "$hook" "$ev" "$ec" "$detail" "$isjsc" >> "$OUT"
if [ "$isjsc" = true ]; then
jsc_n=$((jsc_n + 1)); own="屬 jsc"
else
own="非 jsc 的第三方 hook"
fi
printf '[jsc] %s(%s 事件)exit %s,%s:%s\n' "$hook" "$ev" "$ec" "$own" "$detail" >> "$human"
done < "$recs"
if [ "$total" -eq 0 ]; then
rm -f "$human"
printf 'status=clean reason=%s\n' "claude 紀錄裡沒有新的 hook 執行期錯誤"
exit 0
fi
printf 'status=errors reason=%s\n' "掃到 $total 筆 hook 執行期錯誤,其中 $jsc_n 筆屬 jsc"
cat "$human"
rm -f "$human"
echo "[jsc] 屬 jsc 的錯誤請先以 tools/report-error.sh 回報,再交給 /jsc-hooks:repair 自動修正並開 develop PR。"
echo "[jsc] 非 jsc 的第三方 hook 錯誤只回報,不由 jsc 修正。"
echo "[jsc] 全部紀錄已附加到 $OUT。"
exit 1
+834 -63
View File
@@ -1,52 +1,120 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# wire-cli.sh — 把 jsc 四支 hook 接線到單一 CLI(供 hooks-install 技能呼叫)。 # wire-cli.sh — 單一 CLI 的 hook 生命週期:先清、再接、再冒煙(供 hooks-install 技能呼叫)。
# 用法: wire-cli.sh {claude|codex|copilot|antigravity|kiro} # 用法:
# 行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加): # wire-cli.sh {claude|codex|copilot|antigravity|kiro} 接線
# claude — 什麼都不用寫,hooks.json 已自動接線四支 hook # wire-cli.sh purge {claude|codex|copilot|antigravity|kiro} 備份後移除該 CLI 的所有 hook
# codex — 在 config.toml 設 notify(呼叫 session-timer.sh mark,JSC_CLI=codex); # wire-cli.sh smoke {claude|codex|copilot|antigravity|kiro} 跑一輪六支 hook,驗執行期
# 在 AGENTS.md 附加 STE100 規則段落(prompt 降級) # wire-cli.sh status {claude|codex|copilot|antigravity|kiro} 唯讀盤點接線現況,不寫檔也不執行 hook
# copilot — 在 shell rc 檔加上 copilot 別名,轉呼叫 tools/jsc-wrap.sh copilot;
# 在 copilot-instructions.md 附加 STE100 規則段落
# antigravity — 在 shell rc 檔加上 agy 別名,轉呼叫 tools/jsc-wrap.sh antigravity;
# 在全域規則檔附加 STE100 規則段落
# kiro — 在工作區 .kiro/hooks/ 下建立 jsc-hooks.json(JSC_CLI=kiro)
# #
# 輸出: 第一行固定為 `status={wired|degraded|skipped} reason=...`(可供程式判讀), # purge 移除的是「所有 hook」,含非 jsc 的第三方項目。安裝一律先 purge 再接線:混著別人的
# 其後為人類可讀的繁中說明。 # hook 接線,出錯時分不清是誰的 hook 壞掉,也修不了。移除前每個要動的檔案先原樣複製到
# 結束碼: 0=wired(已完整接線) 1=degraded(降級為 prompt/技能步驟檢查) # $JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/,備份失敗就不移除。
#
# smoke 在接線之後跑,補上接線驗證看不到的那一半:接線只證明設定寫對位置,證不了 hook
# 跑起來不出錯(缺 node、路徑錯、權限不足都只在真的執行時才現形)。
#
# 接線行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加):
# claude — 什麼都不用寫,hooks.json 已自動接線六支 hook
# codex — 在 shell rc 檔加上 codex 別名,轉呼叫 tools/jsc-wrap.sh codex(開始計時,
# 結束時收尾掃一次註解範圍);在 config.toml 設 notify(每輪補
# session-timer.sh start 再 mark,最後 comment-scope.sh sweep 掃整個工作區,
# JSC_CLI=codex);在 AGENTS.md 附加 STE100 與註解範圍規則段落(prompt 降級)
# copilot — 在 shell rc 檔加上 copilot 別名,轉呼叫 tools/jsc-wrap.sh copilot(結束時
# 收尾掃一次註解範圍);在 copilot-instructions.md 附加 STE100 與註解範圍規則段落
# antigravity — 在 shell rc 檔加上 agy 別名,轉呼叫 tools/jsc-wrap.sh antigravity(同樣收尾
# 掃一次);在全域規則檔附加 STE100 與註解範圍規則段落
# kiro — 在工作區 .kiro/hooks/ 下建立 jsc-hooks.json(每輪 mark 加 STE100
# 與註解範圍規則,再 comment-scope.sh sweep 掃整個工作區)與
# jsc-hooks-session-start.json(sessionStart 開始計時),皆帶 JSC_CLI=kiro
#
# 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護:
# claude 六支 hook 全接,回報 wired
# codex、copilot、antigravity、kiro 只有別名、notify 或規則檔,接不上 PreToolUse、PostToolUse
# 與 UserPromptSubmit,版本前置檢查與 SDLC 模型鎖都沒接上,
# 一律回報 degraded 並在 reason 講明
#
# 註解範圍掃描每個 CLI 的時機都不同,回報時不得寫成五支一樣:
# claude 掛在 PostToolUse,寫完哪個檔就掃哪個,逐檔即時
# codex 掛在每輪結束的 notify,掃整個 git 工作區這輪改過的檔(sweep)
# kiro 掛在 userPromptSubmit,掃整個 git 工作區,掃到的是上一輪寫的檔
# copilot、antigravity 沒有任何逐輪事件,只有工作階段結束時由 jsc-wrap.sh 收尾掃一次
# 時機晚一點、涵蓋範圍一樣:sweep 看的是 git diff,那一輪寫過的檔一個都不會漏。真正的差別
# 在回饋速度——claude 當下就叫,其他四個要等到該輪或該階段結束。
# 所有 jsc 標記段落都採整段重寫,重跑等同先移除舊內容再重裝
#
# 寫入後自我驗證,通過才回報成功:每個寫過的檔案重新讀一次,確認標記段落存在且落在
# 正確位置(codex 的 notify 必須是根層鍵,不能被歸進前一張表;kiro 的 JSON 必須成對
# 且 on、run 在最上層),內容也要涵蓋這次該接上的每一支腳本(含 comment-scope.sh sweep)。
# 腳本說寫好了卻寫錯位置或少接一支,是最難查的失敗,所以驗證放在腳本裡。
#
# 輸出: 第一行固定為 `status=... reason=...`(可供程式判讀),其後為人類可讀的繁中說明。
# 結束碼(接線): 0=wired(已完整接線) 1=degraded(降級為 prompt/技能步驟檢查)
# 2=用法錯誤 3=skipped(該 CLI 未偵測到執行檔,略過) # 2=用法錯誤 3=skipped(該 CLI 未偵測到執行檔,略過)
# 4=failed(寫入或驗證沒過,接線沒生效;由 hooks-install 呼叫 report-error.sh 回報)
# 結束碼(purge): 0=purged 2=用法錯誤 3=skipped 4=failed
# 結束碼(smoke): 0=ok 2=用法錯誤 4=failed
# 結束碼(status): 0=wired 1=degraded 2=用法錯誤 3=skipped 5=unwired(該接的段落缺了至少一項)
# status 之外的動作都會寫檔,體檢類技能(/jsc-cli:doctor)只能呼叫 status。判讀邏輯跟接線
# 共用同一組檔案位置與標記字串,分兩份實作就會各自漂移,體檢說沒接、實際上接著。
#
# JSC_CLAUDE_SETTINGS_DIR 可覆寫 claude 使用者層設定檔目錄(預設 ~/.claude)。
# 有這個逃生門才測得動 purge 的 JSON 刪鍵:預設路徑是使用者自己的設定檔,拿真檔案試刪
# 等於拿使用者的環境當測試場。指向一份複製品就能完整跑過 purge claude 而不動到本人設定。
set -u set -u
HERE=$(cd "$(dirname "$0")" && pwd) HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/.." && pwd) ROOT=$(cd "$HERE/.." && pwd)
HOOKS="$ROOT/hooks" HOOKS="$ROOT/hooks"
# cli_bin(CLI 代號 → 實際執行檔)的唯一來源在 lib.sh,包裝啟動器也用同一份
. "$HOOKS/lib.sh"
usage() {
echo "用法:wire-cli.sh [purge|smoke|status] {claude|codex|copilot|antigravity|kiro}" >&2
exit 2
}
# 第一個參數是子命令時走新流程,否則沿用原本的「wire-cli.sh {cli}」接線。
action=wire
case "${1:-}" in
purge|smoke|status) action="$1"; shift ;;
esac
cli="${1:-}" cli="${1:-}"
case "$cli" in case "$cli" in
claude|codex|copilot|antigravity|kiro) ;; claude|codex|copilot|antigravity|kiro) ;;
*) *) usage ;;
echo "用法:wire-cli.sh {claude|codex|copilot|antigravity|kiro}" >&2
exit 2 ;;
esac esac
# 每個 CLI 對應的實際執行檔名稱(antigravity 的執行檔是 agy,其餘與 CLI 代號同名)
cli_bin() {
case "$1" in
antigravity) printf 'agy' ;;
kiro) printf 'kiro-cli' ;;
*) printf '%s' "$1" ;;
esac
}
# STE100 規則段落的唯一來源:ste100-guard.sh 的實際輸出 # STE100 規則段落的唯一來源:ste100-guard.sh 的實際輸出
ste100_text() { sh "$HOOKS/ste100-guard.sh" 2>/dev/null | sed '/^exit /d'; } ste100_text() { sh "$HOOKS/ste100-guard.sh" 2>/dev/null | sed '/^exit /d'; }
# 註解範圍規則段落的唯一來源:comment-scope.sh prompt 的實際輸出。
# 規則正文不在這裡抄一份:抄了就會跟腳本各自漂移,兩邊講的規則對不起來。
comment_scope_text() { sh "$HOOKS/comment-scope.sh" prompt 2>/dev/null | sed '/^exit /d'; }
# 寫進規則檔的完整段落:語言規則加註解範圍規則,共用同一組 jsc-hooks 標記。
# 兩段合在一個標記段落裡,purge 與重跑接線都是整段處理,不必各自再記一組標記。
rules_text() { ste100_text; comment_scope_text; }
# 驗證:規則檔真的收到註解範圍那一段了嗎。比對字串取自腳本的第一行實際輸出,
# 不是另外抄一句關鍵字——抄的關鍵字改腳本時不會跟著改,驗證就會永遠通過。
# $1=檔案
has_comment_scope() {
_first=$(comment_scope_text | head -n1)
[ -n "$_first" ] || return 1
grep -qF "$_first" "$1" 2>/dev/null
}
# 以標記整段取代(冪等);標記不存在就在檔尾新增;檔案不存在就建立。 # 以標記整段取代(冪等);標記不存在就在檔尾新增;檔案不存在就建立。
# 適用 markdown 規則檔與 shell rc 檔:這兩種檔案沒有「區段」概念,附在檔尾就對了。
# $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容 # $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容
replace_block() { replace_block() {
file="$1"; bopen="$2"; bshut="$3"; content="$4" file="$1"; bopen="$2"; bshut="$3"; content="$4"
dir=$(dirname "$file") dir=$(dirname "$file")
mkdir -p "$dir" 2>/dev/null || return 1 mkdir -p "$dir" 2>/dev/null || return 1
touch "$file" 2>/dev/null || return 1 touch "$file" 2>/dev/null || return 1
# touch 對目錄也會成功,所以要另外確認它真的是一般檔案;不然接著的寫入才失敗,
# 而 shell 開檔失敗的訊息蓋不掉,會漏一行 cannot create 給使用者看。
[ -f "$file" ] || return 1
block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut") block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut")
if grep -qF "$bopen" "$file" 2>/dev/null; then if grep -qF "$bopen" "$file" 2>/dev/null; then
awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" ' awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" '
@@ -54,14 +122,100 @@ replace_block() {
$0==bshut { skip=0; next } $0==bshut { skip=0; next }
skip { next } skip { next }
{ print } { print }
' "$file" > "$file.jsc-tmp" 2>/dev/null && mv "$file.jsc-tmp" "$file" ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
else else
printf '\n%s\n' "$block" >> "$file" # 包一層子 shell 才蓋得住 shell 自己的開檔失敗訊息(>> 失敗時那行不走命令的 stderr)
( printf '\n%s\n' "$block" >> "$file" ) 2>/dev/null || return 1
fi fi
} }
# TOML 版的整段取代:標記段落一律放在第一個表頭(`[table]`、`[[array]]`)之前。
# TOML 的根層鍵只在第一個表頭之前有效,附在檔尾會被歸進最後那張表——檔案照樣解析
# 得過,codex 卻永遠讀不到 notify,hook 靜靜失效。所以位置本身就是正確性的一部分。
# 舊版寫錯位置的段落也會被這支函式移到正確位置(先整段刪除,再插到表頭之前)。
# $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容
replace_block_toml() {
file="$1"; bopen="$2"; bshut="$3"; content="$4"
dir=$(dirname "$file")
mkdir -p "$dir" 2>/dev/null || return 1
touch "$file" 2>/dev/null || return 1
# touch 對目錄也會成功,所以要另外確認它真的是一般檔案;不然接著的寫入才失敗,
# 而 shell 開檔失敗的訊息蓋不掉,會漏一行 cannot create 給使用者看。
[ -f "$file" ] || return 1
block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut")
awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" '
$0==bopen { skip=1; next }
$0==bshut { skip=0; next }
skip { next }
# 表頭樣式:整行只有 [name] 或 [[name]]。多行陣列裡的 [1, 2], 不會命中。
!done && /^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { print block; print ""; done=1 }
{ print }
END { if (!done) print block }
' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
}
# 驗證:檔案裡有這段標記嗎($1=檔案 $2=開頭標記行)
has_block() { grep -qF "$2" "$1" 2>/dev/null; }
# 驗證:TOML 的某個鍵是不是落在根層(第一個表頭之前)。$1=檔案 $2=鍵名
toml_root_key() {
awk -v k="$2" '
/^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { intable=1; next }
!intable && $0 ~ "^[ \t]*" k "[ \t]*=" { found=1 }
END { exit(found ? 0 : 1) }
' "$1" 2>/dev/null
}
# 驗證:JSON 括號成對,且某個鍵出現在最上層($1=檔案 $2=鍵名 $3=要求)。
# 解析失敗(括號不成對、字串沒收尾)也回傳非 0,所以這支同時當語法檢查用。
# $3=key(預設)要求該鍵存在;$3=pairs 只檢查語法,不管鍵在不在——purge 之後要驗的是
# 「鍵不見了而且檔案還是合法 JSON」,這兩件事得分開問,不然刪壞檔也會被當成刪成功。
json_top_key() {
awk -v k="$2" -v want="${3:-key}" '
{ s = s $0 "\n" }
END {
n = length(s); depth = 0; i = 1; found = 0; bad = 0
while (i <= n) {
c = substr(s, i, 1)
if (c == "\"") {
buf = ""; i++; closed = 0
while (i <= n) {
c = substr(s, i, 1)
if (c == "\\") { i += 2; continue }
if (c == "\"") { i++; closed = 1; break }
buf = buf c; i++
}
if (!closed) { bad = 1; break }
j = i
while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++
if (substr(s, j, 1) == ":" && depth == 1 && buf == k) found = 1
continue
}
if (c == "{" || c == "[") depth++
else if (c == "}" || c == "]") { depth--; if (depth < 0) { bad = 1; break } }
i++
}
if (bad || depth != 0) exit(1)
exit((want == "pairs" || found) ? 0 : 1)
}' "$1" 2>/dev/null
}
# 驗證:JSON 語法成對(括號收齊、字串收尾)。鍵不管。
json_pairs_ok() { json_top_key "$1" __no_such_key__ pairs; }
# 找出已存在的 shell rc 檔(purge 用)。rc_files 找不到會建立 ~/.bashrc,移除流程不建檔:
# 為了清 hook 而生出一個新檔案,是把環境弄得更亂,不是更乾淨。
rc_files_existing() {
for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do
[ -f "$f" ] && printf '%s\n' "$f"
done
return 0
}
# 找出可寫入別名的 shell rc 檔;都不存在就以 ~/.bashrc 為預設(自動建立)。 # 找出可寫入別名的 shell rc 檔;都不存在就以 ~/.bashrc 為預設(自動建立)。
# 印出找到/建立的 rc 檔路徑,一行一個。 # 印出找到或建立的 rc 檔路徑,一行一個。
rc_files() { rc_files() {
found="" found=""
for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do
@@ -70,32 +224,609 @@ rc_files() {
[ -n "$found" ] || printf '%s\n' "$HOME/.bashrc" [ -n "$found" ] || printf '%s\n' "$HOME/.bashrc"
} }
# 把別名寫進每個 rc 檔並逐檔驗證。$1=標記名(不含 # 與 /)$2=別名內容
# 迴圈不可以放在管線右邊:那會變成子 shell,寫入失敗的旗標傳不回來,
# 明明沒寫成功也照樣回報 wired。改成從暫存檔讀,迴圈就留在本 shell。
write_alias_rc() {
_mark="$1"; _line="$2"; _ok=1
_list=$(mktemp) || return 1
rc_files > "$_list" || { rm -f "$_list"; return 1; }
while IFS= read -r rc; do
[ -n "$rc" ] || continue
replace_block "$rc" "# $_mark" "# /$_mark" "$_line" || { _ok=0; continue; }
grep -qF "$_line" "$rc" 2>/dev/null || _ok=0
done < "$_list"
rm -f "$_list"
[ "$_ok" = 1 ]
}
# --- 移除(purge)用的函式 ---
# 以標記整段移除(冪等)。與 replace_block 對稱:同一組標記,一支寫入、一支移除。
# 標記不存在就當成已移除、回傳成功——purge 重跑不該因為「上次已經清掉了」而失敗。
# $1=檔案 $2=開頭標記行 $3=結尾標記行
remove_block() {
file="$1"; bopen="$2"; bshut="$3"
[ -f "$file" ] || return 0
grep -qF "$bopen" "$file" 2>/dev/null || return 0
awk -v bopen="$bopen" -v bshut="$bshut" '
$0==bopen { skip=1; next }
$0==bshut { skip=0; next }
skip { next }
{ print }
' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
}
# 移除 rc 檔裡所有 `# jsc-hooks*` 標記段落,不管後面接哪個 CLI 名。
# 舊版接線可能留下已改名的段落,逐一指名會漏掉,所以用前綴一次掃乾淨。
# $1=檔案
remove_rc_blocks() {
file="$1"
[ -f "$file" ] || return 0
grep -q '^# jsc-hooks' "$file" 2>/dev/null || return 0
awk '
/^# jsc-hooks/ { skip=1; next }
/^# \/jsc-hooks/ { skip=0; next }
skip { next }
{ print }
' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
}
# 移除 TOML 的根層鍵(第一個表頭之前的那個鍵),含非 jsc 設的值。
# 值可能是多行陣列或多行行內表,所以要追括號深度,收齊才停;只刪一行會留下孤兒括號。
# $1=檔案 $2=鍵名
remove_toml_root_key() {
file="$1"; key="$2"
[ -f "$file" ] || return 0
awk -v k="$key" '
BEGIN { intable=0; drop=0; depth=0 }
/^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { intable=1 }
{
if (drop) {
depth += gsub(/[[{]/, "&") - gsub(/[]}]/, "&")
if (depth <= 0) drop=0
next
}
if (!intable && $0 ~ "^[ \t]*" k "[ \t]*=") {
depth = gsub(/[[{]/, "&") - gsub(/[]}]/, "&")
if (depth > 0) drop=1
next
}
print
}
' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
}
# 刪掉 JSON 最上層的 hooks 鍵(含後面多餘的逗號),逐字元追蹤引號與括號深度。
# jq 在目標機器上不保證存在,所以要有這條純 awk 的路;只用 sed 刪不了嵌套的 {...}。
# 追蹤引號是必要的:字串裡的 { 與 } 不算深度,漏算就會把整段設定切壞。
# $1=檔案,結果印到標準輸出;解析不出來(括號不成對、字串沒收尾)就 exit 1,不輸出半份檔案。
awk_del_hooks() {
awk '
function skip_string(s, i, n, c) {
i++
while (i <= n) {
c = substr(s, i, 1)
if (c == "\\") { i += 2; continue }
if (c == "\"") return i + 1
i++
}
return 0
}
function skip_value(s, i, n, c, d) {
while (i <= n && substr(s, i, 1) ~ /[ \t\r\n]/) i++
c = substr(s, i, 1)
if (c == "\"") return skip_string(s, i, n)
if (c == "{" || c == "[") {
d = 0
while (i <= n) {
c = substr(s, i, 1)
if (c == "\"") { i = skip_string(s, i, n); if (i == 0) return 0; continue }
if (c == "{" || c == "[") { d++; i++; continue }
if (c == "}" || c == "]") { d--; i++; if (d == 0) return i; continue }
i++
}
return 0
}
while (i <= n && substr(s, i, 1) !~ /[,}\]\t\r\n ]/) i++
return i
}
{ s = s $0 "\n" }
END {
n = length(s); i = 1; depth = 0; out = ""
while (i <= n) {
c = substr(s, i, 1)
if (c == "\"") {
start = i; buf = ""; j = i + 1
while (j <= n) {
c = substr(s, j, 1)
if (c == "\\") { j += 2; continue }
if (c == "\"") break
buf = buf c; j++
}
if (j > n) exit 1
i = j + 1
j = i
while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++
if (depth == 1 && buf == "hooks" && substr(s, j, 1) == ":") {
i = skip_value(s, j + 1, n)
if (i == 0) exit 1
j = i
while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++
if (substr(s, j, 1) == ",") {
# 後面還有成員:連逗號一起吃掉,並把 hooks 那行留下的縮排收乾淨
i = j + 1
sub(/[ \t]+$/, "", out)
while (i <= n && substr(s, i, 1) ~ /[ \t\r]/) i++
if (substr(s, i, 1) == "\n") i++
} else {
# hooks 是最後一個成員:改刪前一個逗號,不刪會留下「, }」這種壞掉的 JSON
sub(/,[ \t\r\n]*$/, "", out)
}
continue
}
out = out substr(s, start, i - start)
continue
}
if (c == "{" || c == "[") depth++
else if (c == "}" || c == "]") { depth--; if (depth < 0) exit 1 }
out = out c; i++
}
if (depth != 0) exit 1
printf "%s", out
}' "$1"
}
# 刪掉設定檔最上層的 hooks 鍵:有 jq 就用 jq,沒有就走 awk_del_hooks。$1=檔案
json_del_hooks() {
_f="$1"
[ -f "$_f" ] || return 0
if command -v jq >/dev/null 2>&1; then
jq 'del(.hooks)' "$_f" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; }
else
awk_del_hooks "$_f" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; }
fi
[ -s "$_f.jsc-tmp" ] || { rm -f "$_f.jsc-tmp"; return 1; }
mv "$_f.jsc-tmp" "$_f" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; }
}
# --- 備份:先備份才准移除 ---
BACKUP_DIR=""
BACKUP_STAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_LIST="" # 每行「{備份檔}<TAB>{原檔}」,還原時反向複製回去
# 備份目錄延後建立:沒有檔案要動時不留空目錄。
# 只設全域變數、不印路徑:呼叫端若寫成 $(backup_dir) 就變成子 shell,設好的 BACKUP_DIR
# 與 BACKUP_LIST 傳不回本 shell,接著的備份與還原全部失準。
ensure_backup_dir() {
[ -z "$BACKUP_DIR" ] || return 0
_d="$JSC_HOME/backup/hooks/$cli/$BACKUP_STAMP"
mkdir -p "$_d" 2>/dev/null || return 1
BACKUP_LIST=$(mktemp) || return 1
BACKUP_DIR="$_d"
return 0
}
# 原樣複製一份到備份目錄,保留原檔名;同名就加 -1、-2 後綴(不同目錄可能有同名檔)。
# 複製失敗回傳 1,呼叫端必須就此停手:沒有備份就移除,等於把使用者的設定弄不見。
backup_file() { # $1=檔案
_src="$1"
[ -f "$_src" ] || return 0
ensure_backup_dir || return 1
_base=$(basename "$_src")
_dst="$BACKUP_DIR/$_base"; _n=0
while [ -e "$_dst" ]; do
_n=$((_n + 1)); _dst="$BACKUP_DIR/$_base-$_n"
done
cp "$_src" "$_dst" 2>/dev/null || return 1
[ -f "$_dst" ] || return 1
printf '%s\t%s\n' "$_dst" "$_src" >> "$BACKUP_LIST" || return 1
return 0
}
# 還原這次所有備份(驗證沒過時用)。已刪除的檔案會被複製回來。
restore_backups() {
[ -n "$BACKUP_LIST" ] && [ -f "$BACKUP_LIST" ] || return 0
while IFS="$(printf '\t')" read -r _b _o; do
[ -n "$_b" ] && [ -n "$_o" ] || continue
mkdir -p "$(dirname "$_o")" 2>/dev/null || true
cp "$_b" "$_o" 2>/dev/null || true
done < "$BACKUP_LIST"
return 0
}
skip() { # $1=reason skip() { # $1=reason
printf 'status=skipped reason=%s\n' "$1" printf 'status=skipped reason=%s\n' "$1"
echo "[jsc] 略過:$1" echo "[jsc] 略過:$1"
exit 3 exit 3
} }
fail() { # $1=reason
printf 'status=failed reason=%s\n' "$1"
echo "[jsc] 接線沒生效:$1" >&2
echo "[jsc] 請先以 tools/report-error.sh 回報這次失敗,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2
exit 4
}
# purge 專用的失敗出口:先把備份還原回去,再回報。移除做一半的環境比沒動過更難修。
pfail() { # $1=reason
restore_backups
printf 'status=failed reason=%s\n' "$1"
echo "[jsc] 移除沒完成,已從備份還原:$1" >&2
[ -n "$BACKUP_DIR" ] && echo "[jsc] 備份目錄:$BACKUP_DIR" >&2
echo "[jsc] 請先以 tools/report-error.sh 回報這次失敗,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2
exit 4
}
purged() { # $1=reason
printf 'status=purged reason=%s\n' "$1"
if [ -n "$BACKUP_DIR" ]; then
echo "[jsc] $cli:已移除全部 hook,移除前的原檔備份在 $BACKUP_DIR。"
else
echo "[jsc] $cli:沒有找到任何 hook 設定,已是乾淨狀態,未建立備份目錄。"
fi
}
if [ "$action" = purge ]; then
case "$cli" in
claude)
bin=$(cli_bin claude)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 claude 執行檔"
# claude 的 hook 全部宣告在使用者層設定檔的 hooks 鍵裡,清掉那個鍵就等於清掉所有 hook。
cdir="${JSC_CLAUDE_SETTINGS_DIR:-$HOME/.claude}"
done_files=""
for f in "$cdir/settings.json" "$cdir/settings.local.json"; do
[ -f "$f" ] || continue
# 先問語法:讀不懂的設定檔不能刪鍵,也不能當成「沒有 hooks 鍵」帶過——
# 那會回報 purged 卻留著整套 hook,比直接說失敗更難查。
json_pairs_ok "$f" || pfail "$f 不是成對的 JSON,讀不懂就不動它,請先修好這個檔案"
json_top_key "$f" hooks || continue
backup_file "$f" || pfail "無法備份 $f,沒有備份就不移除"
json_del_hooks "$f" || pfail "無法從 $f 刪除 hooks 鍵"
json_pairs_ok "$f" || pfail "$f 刪除 hooks 鍵後 JSON 括號不成對"
! json_top_key "$f" hooks || pfail "$f 刪除後最上層仍有 hooks 鍵"
done_files="$done_files $f"
done
if [ -n "$done_files" ]; then
purged "已從 claude 使用者層設定檔刪除 hooks 鍵,含非 jsc 的第三方項目"
echo "[jsc] claude:已處理的設定檔:$done_files"
else
purged "claude 使用者層設定檔沒有 hooks 鍵,沒有 hook 要移除"
fi
echo "[jsc] claude:其他 plugin 自帶的 hooks.json 不在使用者設定檔裡,purge 動不到;要靠移除該 plugin 才能清掉。"
echo "[jsc] claude:jsc 自己的 hooks/hooks.json 同樣隨 plugin 提供,移除 jsc-hooks plugin 才會消失。"
exit 0 ;;
codex)
bin=$(cli_bin codex)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔"
CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
config="$CODEX_HOME/config.toml"
agents="$CODEX_HOME/AGENTS.md"
if [ -f "$config" ]; then
if has_block "$config" "# jsc-hooks" || toml_root_key "$config" notify; then
backup_file "$config" || pfail "無法備份 $config,沒有備份就不移除"
remove_block "$config" "# jsc-hooks" "# /jsc-hooks" \
|| pfail "無法從 $config 移除 jsc-hooks 標記段落"
remove_toml_root_key "$config" notify || pfail "無法從 $config 移除根層 notify"
has_block "$config" "# jsc-hooks" && pfail "$config 移除後仍讀得到 jsc-hooks 標記段落"
toml_root_key "$config" notify && pfail "$config 移除後仍有根層 notify"
fi
fi
# rc 檔清所有 `# jsc-hooks*` 段落:別名段落沒有分 CLI 的必要,一次清乾淨最可靠。
rclist=$(mktemp) || pfail "無法建立暫存檔"
rc_files_existing > "$rclist"
while IFS= read -r rc; do
[ -n "$rc" ] || continue
grep -q '^# jsc-hooks' "$rc" 2>/dev/null || continue
backup_file "$rc" || pfail "無法備份 $rc,沒有備份就不移除"
remove_rc_blocks "$rc" || pfail "無法從 $rc 移除 jsc-hooks 標記段落"
grep -q '^# jsc-hooks' "$rc" 2>/dev/null && pfail "$rc 移除後仍有 jsc-hooks 標記段落"
done < "$rclist"
rm -f "$rclist"
if [ -f "$agents" ] && has_block "$agents" "<!-- jsc-hooks -->"; then
backup_file "$agents" || pfail "無法備份 $agents,沒有備份就不移除"
remove_block "$agents" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" \
|| pfail "無法從 $agents 移除 jsc-hooks 標記段落"
has_block "$agents" "<!-- jsc-hooks -->" && pfail "$agents 移除後仍讀得到 jsc-hooks 標記段落"
fi
purged "已移除 config.toml 的標記段落與根層 notify、rc 檔的 jsc-hooks 段落、AGENTS.md 的規則段落"
echo "[jsc] codex:別名要開新的 shell 或重新 source rc 檔才真的失效。"
exit 0 ;;
copilot)
bin=$(cli_bin copilot)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔"
instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
rclist=$(mktemp) || pfail "無法建立暫存檔"
rc_files_existing > "$rclist"
while IFS= read -r rc; do
[ -n "$rc" ] || continue
has_block "$rc" "# jsc-hooks:copilot" || continue
backup_file "$rc" || pfail "無法備份 $rc,沒有備份就不移除"
remove_block "$rc" "# jsc-hooks:copilot" "# /jsc-hooks:copilot" \
|| pfail "無法從 $rc 移除 jsc-hooks:copilot 段落"
has_block "$rc" "# jsc-hooks:copilot" && pfail "$rc 移除後仍有 jsc-hooks:copilot 段落"
done < "$rclist"
rm -f "$rclist"
if [ -f "$instr" ] && has_block "$instr" "<!-- jsc-hooks -->"; then
backup_file "$instr" || pfail "無法備份 $instr,沒有備份就不移除"
remove_block "$instr" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" \
|| pfail "無法從 $instr 移除 jsc-hooks 標記段落"
has_block "$instr" "<!-- jsc-hooks -->" && pfail "$instr 移除後仍讀得到 jsc-hooks 標記段落"
fi
purged "已移除 rc 檔的 jsc-hooks:copilot 段落與指引檔的規則段落"
echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才真的失效。"
exit 0 ;;
antigravity)
bin=$(cli_bin antigravity)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔"
rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}"
rclist=$(mktemp) || pfail "無法建立暫存檔"
rc_files_existing > "$rclist"
while IFS= read -r rc; do
[ -n "$rc" ] || continue
has_block "$rc" "# jsc-hooks:antigravity" || continue
backup_file "$rc" || pfail "無法備份 $rc,沒有備份就不移除"
remove_block "$rc" "# jsc-hooks:antigravity" "# /jsc-hooks:antigravity" \
|| pfail "無法從 $rc 移除 jsc-hooks:antigravity 段落"
has_block "$rc" "# jsc-hooks:antigravity" && pfail "$rc 移除後仍有 jsc-hooks:antigravity 段落"
done < "$rclist"
rm -f "$rclist"
if [ -f "$rules" ] && has_block "$rules" "<!-- jsc-hooks -->"; then
backup_file "$rules" || pfail "無法備份 $rules,沒有備份就不移除"
remove_block "$rules" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" \
|| pfail "無法從 $rules 移除 jsc-hooks 標記段落"
has_block "$rules" "<!-- jsc-hooks -->" && pfail "$rules 移除後仍讀得到 jsc-hooks 標記段落"
fi
purged "已移除 rc 檔的 jsc-hooks:antigravity 段落與全域規則檔的規則段落"
echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才真的失效。"
exit 0 ;;
kiro)
command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔"
hookdir="./.kiro/hooks"
if [ -d "$hookdir" ]; then
for f in "$hookdir"/*; do
[ -f "$f" ] || continue
backup_file "$f" || pfail "無法備份 $f,沒有備份就不移除"
rm -f "$f" 2>/dev/null || pfail "無法刪除 $f"
done
left=$(find "$hookdir" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')
[ "$left" = 0 ] || pfail "$hookdir 底下還有 $left 個 hook 檔沒刪掉"
fi
purged "已刪除工作區 .kiro/hooks/ 底下所有 hook 檔,含非 jsc 的第三方項目"
echo "[jsc] kiro:hook 檔綁在工作區,這次只清得到目前目錄的 ./.kiro/hooks/,其他工作區要各自跑一次。"
exit 0 ;;
esac
fi
if [ "$action" = smoke ]; then
smoke_out=$(mktemp) || { printf 'status=failed reason=%s\n' "無法建立暫存檔"; exit 4; }
smoke_fails=0
# 跑一支 hook 並判定結果。$1=腳本檔名 $2=子命令(可省略)
# $2 不加引號展開:子命令是固定字面字,空字串時要展成「沒有參數」而不是空參數。
smoke_one() {
_h="$1"; _s="${2:-}"
# 技能名一律清空:冒煙要驗的是「沒有技能情境時腳本跑得完」。留著繼承來的 JSC_SKILL,
# sdlc-gate.sh wp-check skill 會拿它當真實呼叫判定,有未結清 PR 時就誤報成執行期錯誤。
# JSC_CHANGED_FILE 同理清空:留著繼承來的檔名,comment-scope.sh 會真的去掃那個檔,
# 掃到違規註解就 exit 2,冒煙測試變成看環境臉色,測不出腳本本身跑不跑得完。
_out=$(printf '{}' | JSC_CLI="$cli" JSC_SKILL="" SKILL="" JSC_CHANGED_FILE="" \
sh "$HOOKS/$_h" $_s 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
printf '[jsc] %s%s:exit 0,正常。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out"
elif [ "$_h" = sdlc-gate.sh ] && [ "$_s" = check ] && [ "$_rc" -eq 2 ]; then
# 唯一放行的非零退出:sdlc-gate.sh check 的 exit 2 是刻意設計的階段鎖阻擋
# (見 hooks/lib.sh 開頭)——鎖存在且模型不符時就該擋下該輪提示。那是 hook 正常
# 工作,不是執行期錯誤;把它算成錯誤會讓每個正在上鎖的工作階段都誤報一次失敗。
printf '[jsc] %s check:exit 2,SDLC 階段鎖擋下該輪提示,屬設計行為,不算錯誤。\n' "$_h" >> "$smoke_out"
elif [ "$_h" = comment-scope.sh ] && [ "$_rc" -eq 2 ]; then
# 同一類放行:comment-scope.sh 掃描模式的 exit 2 是「掃到違規註解」的設計行為。
# 無參數模式冒煙時取不到檔名,正常會走 exit 0;sweep 則看工作區乾不乾淨——工作區剛好
# 有違規註解就回 2。那是 hook 正常工作,不是 hook 壞掉,不能因此判定接線失敗。
printf '[jsc] %s%s:exit 2,掃到違規註解並發出警告,屬設計行為,不算錯誤。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out"
else
smoke_fails=$((smoke_fails + 1))
printf '[jsc] %s%s:exit %s,執行期出錯:%s\n' "$_h" "${_s:+ $_s}" "$_rc" \
"$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out"
fi
}
smoke_one session-timer.sh mark
smoke_one sdlc-gate.sh check
smoke_one version-guard.sh
smoke_one skill-usage.sh
smoke_one ste100-guard.sh
# sdlc-gate.sh 有兩個 hook 模式,接在不同事件上,兩個都要驗:wp-check prompt 一律 exit 0,
# wp-check skill 在取不到技能名時放行(上面已清空技能名),所以兩者都不需要白名單例外。
smoke_one sdlc-gate.sh "wp-check prompt"
smoke_one sdlc-gate.sh "wp-check skill"
# comment-scope.sh 有三個接在不同事件的模式,三個都要驗:prompt 一律 exit 0,
# 無參數模式在取不到檔名時安靜 exit 0(上面已清空 JSC_CHANGED_FILE,stdin 也只有 {}),
# sweep 掃目前工作目錄所在的 git 工作區——乾淨或非 git 目錄回 0,有違規註解回 2,
# 後者由上面的白名單放行(見 smoke_one)。
smoke_one comment-scope.sh prompt
smoke_one comment-scope.sh
smoke_one comment-scope.sh sweep
if [ "$smoke_fails" -eq 0 ]; then
printf 'status=ok reason=%s\n' "六支 hook 的每個接線模式都跑得完,沒有執行期錯誤"
cat "$smoke_out"; rm -f "$smoke_out"; exit 0
fi
printf 'status=failed reason=%s\n' "$smoke_fails 支 hook 有執行期錯誤"
cat "$smoke_out"
rm -f "$smoke_out"
echo "[jsc] 請先以 tools/report-error.sh 回報,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2
exit 4
fi
if [ "$action" = status ]; then
# 唯讀盤點:只讀設定檔判斷標記段落在不在,不寫檔,也不執行任何 hook。
# 檔案位置與標記字串一律沿用底下接線區塊的同一份值,兩邊必須一起改。
command -v "$(cli_bin "$cli")" >/dev/null 2>&1 || skip "未偵測到 $(cli_bin "$cli") 執行檔"
st_items=$(mktemp) || { printf 'status=failed reason=%s\n' "無法建立暫存檔"; exit 4; }
st_missing=0
st_degrade=""
# 記一個檢查點。$1=項目名 $2=路徑 $3=present|missing
st_item() {
printf 'item\t%s\t%s\t%s\n' "$1" "$2" "$3" >> "$st_items"
[ "$3" = present ] || st_missing=$((st_missing + 1))
}
# 檔案存在且帶有該標記才算接上。$1=項目名 $2=檔案 $3=開頭標記行
st_block() {
if [ -f "$2" ] && has_block "$2" "$3"; then st_item "$1" "$2" present
else st_item "$1" "$2" missing; fi
}
# 註解範圍規則寫進規則檔了嗎。與 STE100 共用同一個標記段落,所以要單獨比對內容:
# 標記在、內容卻是舊版只有 STE100 的那一份時,這一項才看得出來缺了。$1=項目名 $2=檔案
st_comment_scope() {
if [ -f "$2" ] && has_comment_scope "$2"; then st_item "$1" "$2" present
else st_item "$1" "$2" missing; fi
}
# 別名段落只要任何一個既有 rc 檔帶有標記就算接上。$1=項目名 $2=標記名
st_rc_alias() {
for _rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do
[ -f "$_rc" ] || continue
if has_block "$_rc" "# $2"; then st_item "$1" "$_rc" present; return 0; fi
done
st_item "$1" "$HOME/.bashrc" missing
}
case "$cli" in
claude)
if [ -f "$HOOKS/hooks.json" ]; then st_item hooks.json "$HOOKS/hooks.json" present
else st_item hooks.json "$HOOKS/hooks.json" missing; fi
# 六支 hook 全靠這一個檔宣告,只看檔案在不在會漏掉「檔在、某支沒接進去」。
# 最後加進來的 comment-scope.sh 是最可能漏的一支,所以單獨列一項。
if [ -f "$HOOKS/hooks.json" ] && grep -qF 'comment-scope.sh' "$HOOKS/hooks.json" 2>/dev/null
then st_item comment-scope "$HOOKS/hooks.json" present
else st_item comment-scope "$HOOKS/hooks.json" missing; fi ;;
codex)
config="${CODEX_HOME:-$HOME/.codex}/config.toml"
st_block notify "$config" "# jsc-hooks"
# 標記在、鍵卻被歸進某張表時 codex 讀不到 notify,等同沒接,所以位置要單獨算一項
if [ -f "$config" ] && toml_root_key "$config" notify; then
st_item notify-root "$config" present
else
st_item notify-root "$config" missing
fi
# notify 接上了,不代表 sweep 也串進那一行:舊版接線只有計時,掃描是後來才加的
if [ -f "$config" ] && grep -qF 'comment-scope.sh' "$config" 2>/dev/null &&
grep -qF 'sweep' "$config" 2>/dev/null; then
st_item notify-sweep "$config" present
else
st_item notify-sweep "$config" missing
fi
st_rc_alias alias jsc-hooks:codex
st_block ste100 "${CODEX_HOME:-$HOME/.codex}/AGENTS.md" "<!-- jsc-hooks -->"
st_comment_scope comment-scope "${CODEX_HOME:-$HOME/.codex}/AGENTS.md"
st_degrade="STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查,註解範圍改為每輪結束掃整個工作區,不是逐檔即時" ;;
copilot)
st_rc_alias alias jsc-hooks:copilot
st_block ste100 "${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" "<!-- jsc-hooks -->"
st_comment_scope comment-scope "${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
st_degrade="STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查,註解範圍只在工作階段結束時掃一次整個工作區" ;;
antigravity)
st_rc_alias alias jsc-hooks:antigravity
st_block ste100 "${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" "<!-- jsc-hooks -->"
st_comment_scope comment-scope "${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}"
st_degrade="STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查,註解範圍只在工作階段結束時掃一次整個工作區" ;;
kiro)
# kiro 的 hook 檔綁在工作區,這裡看的一律是目前工作目錄底下那一份
for _f in ./.kiro/hooks/jsc-hooks-session-start.json ./.kiro/hooks/jsc-hooks.json; do
if [ -f "$_f" ] && json_top_key "$_f" run; then st_item "$(basename "$_f" .json)" "$_f" present
else st_item "$(basename "$_f" .json)" "$_f" missing; fi
done
# userPromptSubmit 那一筆的 run 串了好幾支腳本,只驗 JSON 讀得懂會漏掉少接的那一支。
# comment-scope.sh 的 prompt 與 sweep 是兩件事,各算一項,才看得出舊版接線少了哪一個。
if [ -f ./.kiro/hooks/jsc-hooks.json ] &&
grep -qF 'comment-scope.sh\" prompt' ./.kiro/hooks/jsc-hooks.json 2>/dev/null
then st_item comment-scope ./.kiro/hooks/jsc-hooks.json present
else st_item comment-scope ./.kiro/hooks/jsc-hooks.json missing; fi
if [ -f ./.kiro/hooks/jsc-hooks.json ] &&
grep -qF 'comment-scope.sh\" sweep' ./.kiro/hooks/jsc-hooks.json 2>/dev/null
then st_item comment-scope-sweep ./.kiro/hooks/jsc-hooks.json present
else st_item comment-scope-sweep ./.kiro/hooks/jsc-hooks.json missing; fi
st_degrade="SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查,註解範圍改為每輪提示送出時掃整個工作區,不是逐檔即時" ;;
esac
if [ "$st_missing" -gt 0 ]; then
printf 'status=unwired reason=%s\n' "$st_missing 個接線項目缺漏,執行 /jsc-hooks:hooks-install 重新接線"
cat "$st_items"; rm -f "$st_items"; exit 5
fi
if [ -n "$st_degrade" ]; then
printf 'status=degraded reason=%s\n' "$st_degrade"
cat "$st_items"; rm -f "$st_items"; exit 1
fi
printf 'status=wired reason=%s\n' "hooks.json 自動接線全部六支 hook"
cat "$st_items"; rm -f "$st_items"; exit 0
fi
case "$cli" in case "$cli" in
claude) claude)
bin=$(cli_bin claude)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 claude 執行檔"
[ -f "$HOOKS/hooks.json" ] || fail "找不到 $HOOKS/hooks.json,claude 接不到任何 hook"
grep -qF 'comment-scope.sh' "$HOOKS/hooks.json" 2>/dev/null \
|| fail "$HOOKS/hooks.json 沒有接上 comment-scope.sh,註解範圍檢查不會生效"
printf 'status=wired reason=%s\n' "hooks.json 自動接線" printf 'status=wired reason=%s\n' "hooks.json 自動接線"
echo "[jsc] claude:由 hooks/hooks.json 自動接線全部四支 hook,無需寫入設定。" echo "[jsc] claude:由 hooks/hooks.json 自動接線全部六支 hook,無需寫入設定。"
echo "[jsc] claude:只有 claude 有 post-tool hook,comment-scope.sh 的逐檔即時掃描只在這裡接得上;其他四個 CLI 改用 sweep 掃整個工作區,時機晚一輪或晚到工作階段結束。"
exit 0 ;; exit 0 ;;
codex) codex)
command -v "$(cli_bin codex)" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔" bin=$(cli_bin codex)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔"
CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
config="$CODEX_HOME/config.toml" config="$CODEX_HOME/config.toml"
agents="$CODEX_HOME/AGENTS.md" agents="$CODEX_HOME/AGENTS.md"
notify_line="notify = [\"env\", \"JSC_CLI=codex\", \"sh\", \"$HOOKS/session-timer.sh\", \"mark\"]" # codex 的 notify 只在每一輪結束時觸發,沒有工作階段開始事件,所以計時分兩段接:
replace_block "$config" "# jsc-hooks" "# /jsc-hooks" "$notify_line" \ # 1. shell 別名走 jsc-wrap.sh:啟動當下就 session-timer start,並給這次工作階段
|| skip "無法寫入 $config" # 一個 JSC_SESSION_ID,codex 內觸發的 notify 會沿用同一個 id。
replace_block "$agents" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \ # 2. notify 每輪先補 start 再 mark。start 已有紀錄就不動,所以沒走別名啟動時
|| skip "無法寫入 $agents" # 仍拿得到起始時間(從第一輪算起)。少了這一段,worklog 只會拿到 0 秒。
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查" # notify 最後再掛 comment-scope.sh sweep:codex 沒有 post-tool hook,拿不到「剛剛寫了
echo "[jsc] codex:已設定 $config 的 notify 呼叫 session-timer.sh mark。" # 哪個檔」,只能改掃整個 git 工作區的 diff。每輪結束掃一次,時機比 claude 晚,那一輪
echo "[jsc] codex:已在 $agents 寫入 STE100 規則段落(prompt 降級)。" # 寫過的檔一個都不會漏。
timer="sh '$HOOKS/session-timer.sh'"
scope="sh '$HOOKS/comment-scope.sh'"
notify_line="notify = [\"env\", \"JSC_CLI=codex\", \"sh\", \"-c\", \"$timer start </dev/null; $timer mark </dev/null; $scope sweep </dev/null\"]"
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" codex'"
replace_block_toml "$config" "# jsc-hooks" "# /jsc-hooks" "$notify_line" \
|| fail "無法寫入 $config"
has_block "$config" "# jsc-hooks" || fail "$config 寫入後讀不到 jsc-hooks 標記段落"
toml_root_key "$config" notify \
|| fail "$config 的 notify 沒有落在根層(被歸進某張表,codex 讀不到,hook 會靜靜失效)"
grep -qF "$scope sweep" "$config" 2>/dev/null \
|| fail "$config 的 notify 沒有接到 comment-scope.sh sweep,codex 每輪結束不會掃註解範圍"
write_alias_rc "jsc-hooks:codex" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
replace_block "$agents" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(rules_text)" \
|| fail "無法寫入 $agents"
has_block "$agents" "<!-- jsc-hooks -->" || fail "$agents 寫入後讀不到 jsc-hooks 標記段落"
has_comment_scope "$agents" || fail "$agents 寫入後讀不到註解範圍規則"
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查,註解範圍改為每輪結束掃整個工作區,不是逐檔即時"
echo "[jsc] codex:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh codex,啟動當下開始計時。"
echo "[jsc] codex:別名要開新的 shell 或重新 source rc 檔才生效。"
echo "[jsc] codex:已設定 $config 的 notify(根層鍵,已驗證),每輪補 session-timer.sh start 再 mark,最後跑 comment-scope.sh sweep。"
echo "[jsc] codex:已在 $agents 寫入 STE100 與註解範圍規則段落(prompt 降級)。"
echo "[jsc] codex:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" echo "[jsc] codex:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] codex:版本前置檢查接不上(codex 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
echo "[jsc] codex:註解範圍除了規則提示,每輪結束會由 notify 掃一次整個 git 工作區(codex 沒有 post-tool hook,接不到逐檔即時掃描),回饋比 claude 晚一輪。"
exit 1 ;; exit 1 ;;
copilot) copilot)
@@ -103,47 +834,87 @@ case "$cli" in
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔" command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔"
instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" copilot'" alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" copilot'"
rc_files | while IFS= read -r rc; do write_alias_rc "jsc-hooks:copilot" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
replace_block "$rc" "# jsc-hooks:copilot" "# /jsc-hooks:copilot" "$alias_line" replace_block "$instr" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(rules_text)" \
done || fail "無法寫入 $instr"
replace_block "$instr" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \ has_block "$instr" "<!-- jsc-hooks -->" || fail "$instr 寫入後讀不到 jsc-hooks 標記段落"
|| skip "無法寫入 $instr" has_comment_scope "$instr" || fail "$instr 寫入後讀不到註解範圍規則"
printf 'status=wired reason=%s\n' "wrapper 別名 + 規則檔已接線" printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查,註解範圍只在工作階段結束時掃一次整個工作區"
echo "[jsc] copilot:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh copilot。" echo "[jsc] copilot:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh copilot。"
echo "[jsc] copilot:已在 $instr 寫入 STE100 規則段落。" echo "[jsc] copilot:已在 $instr 寫入 STE100 與註解範圍規則段落(prompt 降級)。"
exit 0 ;; echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才生效。"
echo "[jsc] copilot:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] copilot:版本前置檢查接不上(copilot 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
echo "[jsc] copilot:註解範圍除了規則提示,工作階段結束時由 jsc-wrap.sh 收尾掃一次整個 git 工作區(copilot 連逐輪事件都沒有),回饋要等到離開 CLI 才看得到。"
exit 1 ;;
antigravity) antigravity)
bin=$(cli_bin antigravity) bin=$(cli_bin antigravity)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔" command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔"
rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}"
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" antigravity'" alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" antigravity'"
rc_files | while IFS= read -r rc; do write_alias_rc "jsc-hooks:antigravity" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
replace_block "$rc" "# jsc-hooks:antigravity" "# /jsc-hooks:antigravity" "$alias_line" replace_block "$rules" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(rules_text)" \
done || fail "無法寫入 $rules"
replace_block "$rules" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \ has_block "$rules" "<!-- jsc-hooks -->" || fail "$rules 寫入後讀不到 jsc-hooks 標記段落"
|| skip "無法寫入 $rules" has_comment_scope "$rules" || fail "$rules 寫入後讀不到註解範圍規則"
printf 'status=wired reason=%s\n' "wrapper 別名 + 規則檔已接線" printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查,註解範圍只在工作階段結束時掃一次整個工作區"
echo "[jsc] antigravity:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh antigravity。" echo "[jsc] antigravity:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh antigravity。"
echo "[jsc] antigravity:已在 $rules 寫入 STE100 規則段落。" echo "[jsc] antigravity:已在 $rules 寫入 STE100 與註解範圍規則段落(prompt 降級)。"
exit 0 ;; echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才生效。"
echo "[jsc] antigravity:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] antigravity:版本前置檢查接不上(antigravity 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
echo "[jsc] antigravity:註解範圍除了規則提示,工作階段結束時由 jsc-wrap.sh 收尾掃一次整個 git 工作區(antigravity 連逐輪事件都沒有),回饋要等到離開 CLI 才看得到。"
exit 1 ;;
kiro) kiro)
command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔" command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔"
hookdir="./.kiro/hooks" hookdir="./.kiro/hooks"
hookfile="$hookdir/jsc-hooks.json" hookfile="$hookdir/jsc-hooks.json"
mkdir -p "$hookdir" 2>/dev/null || skip "無法建立 $hookdir" startfile="$hookdir/jsc-hooks-session-start.json"
cat > "$hookfile" 2>/dev/null <<EOF || skip "無法寫入 $hookfile" mkdir -p "$hookdir" 2>/dev/null || fail "無法建立 $hookdir"
# 計時要分兩個檔:kiro 的一個 hook 檔只有一組 run,所有事件共用。
# sessionStart 單獨一檔跑 restart,才算得出這一次工作階段的花費時間;
# kiro 給不到 session id,紀錄共用 default,不覆寫起始時間就會把上一階段算進來。
cat > "$startfile" 2>/dev/null <<EOF || fail "無法寫入 $startfile"
{ {
"name": "jsc-hooks", "name": "jsc-hooks-session-start",
"description": "jsc session timer + STE100 guard bridge (auto-generated by jsc-hooks:hooks-install, do not edit by hand)", "description": "jsc session timer start (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
"on": ["sessionStart", "sessionEnd", "userPromptSubmit"], "on": ["sessionStart"],
"env": { "JSC_CLI": "kiro" }, "env": { "JSC_CLI": "kiro" },
"run": "sh \\"$HOOKS/session-timer.sh\\" mark; sh \\"$HOOKS/ste100-guard.sh\\"" "run": "sh \\"$HOOKS/session-timer.sh\\" restart </dev/null"
} }
EOF EOF
printf 'status=degraded reason=%s\n' "SDLC 模型鎖降級為技能步驟檢查" # userPromptSubmit 那一輪的 run 最後再掛 comment-scope.sh sweep:kiro 沒有 post-tool hook,
echo "[jsc] kiro:已建立 $hookfile(JSC_CLI=kiro)。" # 拿不到「剛剛寫了哪個檔」,只能改掃整個 git 工作區的 diff。掃到的是上一輪寫的檔——
# 提示送出時,上一輪的寫入早就落地了,時機合理。
cat > "$hookfile" 2>/dev/null <<EOF || fail "無法寫入 $hookfile"
{
"name": "jsc-hooks",
"description": "jsc session timer + STE100 guard + comment scope bridge (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
"on": ["sessionEnd", "userPromptSubmit"],
"env": { "JSC_CLI": "kiro" },
"run": "sh \\"$HOOKS/session-timer.sh\\" mark </dev/null; sh \\"$HOOKS/ste100-guard.sh\\" </dev/null; sh \\"$HOOKS/comment-scope.sh\\" prompt </dev/null; sh \\"$HOOKS/comment-scope.sh\\" sweep </dev/null"
}
EOF
for f in "$startfile" "$hookfile"; do
json_top_key "$f" on || fail "$f 不是成對的 JSON,或 on 不在最上層"
json_top_key "$f" run || fail "$f 不是成對的 JSON,或 run 不在最上層"
grep -qF '"JSC_CLI": "kiro"' "$f" 2>/dev/null || fail "$f 缺少 JSC_CLI=kiro"
grep -qF 'session-timer.sh' "$f" 2>/dev/null || fail "$f 的 run 沒有接到 session-timer.sh"
done
grep -qF '"sessionStart"' "$startfile" 2>/dev/null || fail "$startfile 沒有接在 sessionStart"
grep -qF '"userPromptSubmit"' "$hookfile" 2>/dev/null || fail "$hookfile 沒有接在 userPromptSubmit"
grep -qF 'comment-scope.sh' "$hookfile" 2>/dev/null || fail "$hookfile 的 run 沒有接到 comment-scope.sh"
# 兩個模式接的是兩件事,只驗腳本名會漏掉少接的那一個,所以各驗一次
grep -qF 'comment-scope.sh\" prompt' "$hookfile" 2>/dev/null \
|| fail "$hookfile 的 run 沒有接到 comment-scope.sh prompt,每輪不會注入註解範圍規則"
grep -qF 'comment-scope.sh\" sweep' "$hookfile" 2>/dev/null \
|| fail "$hookfile 的 run 沒有接到 comment-scope.sh sweep,kiro 每輪不會掃註解範圍"
printf 'status=degraded reason=%s\n' "SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查,註解範圍改為每輪提示送出時掃整個工作區,不是逐檔即時"
echo "[jsc] kiro:已建立 $startfile(sessionStart 開始計時)與 $hookfile(JSC_CLI=kiro),兩份都已驗證。"
echo "[jsc] kiro:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" echo "[jsc] kiro:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] kiro:版本前置檢查接不上(kiro 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
echo "[jsc] kiro:註解範圍除了規則提示,每輪提示送出時會掃一次整個 git 工作區(kiro 沒有 post-tool hook),掃到的是上一輪寫的檔。"
exit 1 ;; exit 1 ;;
esac esac