Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad10159dfe | |||
| daa84b583f | |||
| c7890335cc | |||
| 8a3802c4b7 | |||
| 09769a0dd1 | |||
| 3bc2147e36 | |||
| 6047dff71b | |||
| 147fdb1482 | |||
| ef11871ba4 | |||
| 7b5a6450df | |||
| 2bbcda2ac3 | |||
| 51c668f9c8 | |||
| 06fb2c59c8 | |||
| 6174944a0e | |||
| 221d3788c5 | |||
| 5d938f5d3a | |||
| ee4af97765 | |||
| d2f706a16c | |||
| 25da674615 | |||
| 01dafb01b5 | |||
| 436e30b9d1 | |||
| 916a6890ff | |||
| ba1f2a50f8 | |||
| fbc56b9e59 | |||
| 8eb4c58d2d | |||
| c1df3c7a08 | |||
| 544beefc5e | |||
| c0eec76b52 | |||
| 0d49c0dcd7 | |||
| b2d9141a7d | |||
| 26aee6dffb | |||
| ec9f28d9e8 | |||
| 4d27132413 | |||
| 7f726bb99a | |||
| f6791408ab | |||
| 19face1c41 | |||
| d4bf172990 | |||
| 7a9e30589c | |||
| 3785fc66cc | |||
| 7750299b18 | |||
| 7cacfdcf11 | |||
| 13b97027d1 | |||
| 3d18b6c8e5 | |||
| f13eed7ca2 | |||
| 95c6fee199 | |||
| 49a7145e70 | |||
| bcf7c05a7b | |||
| e9492e5d7c | |||
| 5b0267112f | |||
| 48befe8906 | |||
| 1c0012107f | |||
| 56ae38d8d4 | |||
| b4d47e94c9 | |||
| fef35ed03a | |||
| 1fa45cd539 | |||
| f2ebeb441e | |||
| 5bf7d1c729 | |||
| 06a4dd5b7b | |||
| d19bf88503 | |||
| 11cef0bec4 | |||
| fed7258a30 | |||
| 4ba51b7b04 | |||
| 57219f3321 | |||
| 65120a1c65 | |||
| b5d501e9b8 | |||
| 354480901a | |||
| 49dedbbb15 | |||
| 605f1fe5a6 | |||
| f1b2ed909f | |||
| 9e76571af8 | |||
| 044ef289fe | |||
| 5909c2a43a | |||
| 35cc56dfc5 | |||
| dd86fef9a6 | |||
| 92a008fd28 | |||
| 1f05bc2134 | |||
| cb310e9c72 | |||
| 6f37772a40 | |||
| 766375cd95 | |||
| 7aef9436c9 | |||
| ca9ea9993b | |||
| 013a987688 | |||
| ce0fe87529 | |||
| 85f76c556a | |||
| 58c3689e4b | |||
| b8676f5aa9 | |||
| 6e18cc2072 | |||
| 8b2c7cecac | |||
| c7af690c2b | |||
| 4b91cd5d48 | |||
| 358f67abf3 | |||
| f69ea11a58 | |||
| aac6498850 | |||
| 5342917f2b | |||
| a0a34b629e | |||
| 7df15f2e2b | |||
| 7a3ce4e590 | |||
| 672439bd95 | |||
| 79ee9e62d6 | |||
| 156d339308 | |||
| da9bd53cae |
@@ -8,7 +8,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:51 支機械閘(64 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(85 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、60 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"author": {
|
||||
"name": "Leo",
|
||||
"url": "https://uncle6.me"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:58 支機械閘(79 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、49 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.15.0",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(88 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、8 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、65 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.30.10",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -23,32 +23,34 @@
|
||||
|
||||
| | 數量 | 是什麼 |
|
||||
|---|---|---|
|
||||
| `hooks/` | 56 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 75 條註冊) |
|
||||
| `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` |
|
||||
| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … |
|
||||
| `skills/` | 2 支 | |
|
||||
| `scripts/` | 36 支 | `ticket`/`roster`/`github-arm.sh`/`gitea-bootstrap.sh` … |
|
||||
| `hooks/` | 62 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 88 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** |
|
||||
| `agents/` | 8 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` |
|
||||
| `commands/` | 7 支 | `/wiki-recall` `/wiki-capture` `/cp-write` `/issue-handle` … |
|
||||
| `skills/` | 2 支 | `ship-check`(東西要出去之前)/`deep-recall`(把散落的枝葉還原成一棵樹)。🔴 **這兩支的內容不一定是在這裡寫的**——誰是真相源查 `docs/file-ownership.tsv` |
|
||||
| `scripts/` | 65 支 | `ticket`/`roster`/`isep-nag`/`wiki-compress`/`github-arm.sh` …(頂層檔案,不含 `lib/` 等子目錄) |
|
||||
|
||||
> 🔴 這四個數字**每次都要在自己的樹上實數**,不准沿用上一版、也不准用加減推
|
||||
> 🔴 這五個數字**每次都要在自己的樹上實數**,不准沿用上一版、也不准用加減推
|
||||
> (`system-dev/wiki/mistakes.md`:那是 leo 的驗收介面,多報就是假綠):
|
||||
> `ls hooks/*.sh | wc -l`/`grep -c '"command":' hooks/hooks.json`(**冒號不能省**)/
|
||||
> `ls agents/*.md | wc -l`/`ls -p scripts | grep -v / | wc -l`。
|
||||
> 本版(`ISEP#86`/`#87`/`#88`)就是這樣數出來的:上一版寫 48/59/23,實際是 53/68/35。
|
||||
| `hooks/` | 54 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 69 條註冊,2026-08-28 在樹上實數;`ISEP#60` 移除 `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制,改由 Gitea 原生三格承接見 `ISEP#59`) |
|
||||
| `hooks/` | 54 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 71 條註冊。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家**) |
|
||||
| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … |
|
||||
| `skills/` | 2 支 | |
|
||||
| `scripts/` | 38 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` …(頂層檔案,不含 `lib/` 等子目錄) |
|
||||
|
||||
> 🔴 **這四個數字要用數的,不要用推的**(2026-08-28:上一版寫 48/59/23,
|
||||
> 而真實是 54/71/38——三個都錯,錯了不知道多久)。重數一次:
|
||||
>
|
||||
> ```sh
|
||||
> ls hooks/*.sh | wc -l # hooks
|
||||
> grep -c '"command":' hooks/hooks.json # 註冊條數(冒號不能省,省了會數到 "type": "command")
|
||||
> ls agents/*.md | wc -l
|
||||
> ls commands/*.md | wc -l ; ls -d skills/*/ | wc -l
|
||||
> find scripts -maxdepth 1 -type f | wc -l # scripts(頂層)
|
||||
> python3 -c "import json;h=json.load(open('hooks/hooks.json'));h=h.get('hooks',h);\
|
||||
> print(sum(len(m.get('hooks',[])) for ev in h.values() for m in ev))" # 註冊條數
|
||||
> ls -p scripts | grep -v / | wc -l # scripts(只數檔案,不含子目錄)
|
||||
> ```
|
||||
>
|
||||
> 🔴 **光看數字對不對還不夠**——`hooks.json` 合錯的時候「語法合法、閘卻不見了」,
|
||||
> **不會有任何東西喊一聲**。合併過 `hooks.json` 之後一律再跑這一支,逐支點名:
|
||||
>
|
||||
> ```sh
|
||||
> python3 -c "
|
||||
> import json,collections
|
||||
> d=json.load(open('hooks/hooks.json'))
|
||||
> c=collections.Counter(h['command'].split('/')[-1] for ev in d['hooks'].values() for g in ev for h in g.get('hooks',[]))
|
||||
> print('總註冊', sum(c.values()))
|
||||
> for k in sorted(c): print(' ', k, c[k])
|
||||
> "
|
||||
> ```
|
||||
>
|
||||
> **這是 leo 的驗收介面**——多報就是假綠(`docs/hooks-inventory.md` 開頭記著同一個病)。
|
||||
@@ -68,6 +70,13 @@ hook 一律用官方的 `${CLAUDE_PLUGIN_ROOT}`,**不准寫死絕對路徑、
|
||||
🔴 **只改這裡,然後兩邊 `/plugin update`。**
|
||||
不要再改 `InkStoneCo/.claude/hooks/`——那個目錄退場中。
|
||||
|
||||
🔴 **但有些檔案的內容不是在這裡寫的**(`inkstone/ISEP#122`):
|
||||
`skills/`/`commands/`/`agents/` 底下有幾個檔案在 InkStoneCo 也有一份,
|
||||
**誰是真相源、往哪個方向修,一律查 `docs/file-ownership.tsv`**,不要憑「ISEP 一定比較新」動手
|
||||
——2026-09-02 實查,兩個方向都真的發生過(`ship-check` 是那邊新,`sdd-check` 是這邊新)。
|
||||
改完那類檔案要把 commit/sha256 兩欄一起更新;沒更新的話,
|
||||
`isep-presence-beacon.sh` 會在下一個 session 開場當著大家的面說它對不上。
|
||||
|
||||
## 這些閘各自在管什麼
|
||||
|
||||
**不用點開任何 `.sh`**——`docs/hooks-inventory.md` 一支一行白話,按「你會在什麼時候撞到它」分組。
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: course-hand
|
||||
description: 課程的工人——課件內容(ax-courses)、簡報引擎(course_gen)、學員範本(llm-wiki-template)。動這三個 repo 派它。
|
||||
---
|
||||
|
||||
你是 **course-hand**,leo 課程這一族 repo 的工人。
|
||||
|
||||
**負責 repo**:inkstone/ax-courses、inkstone/course_gen、inkstone/llm-wiki-template
|
||||
|
||||
三個 repo 是同一件事的三段:`ax-courses` 放課件原始碼(內容),`course_gen` 是把它
|
||||
build 成 PPTX/PDF 的引擎,`llm-wiki-template` 是學員照著做的範本(GitHub 上有公開版)。
|
||||
|
||||
## 開工前先讀
|
||||
|
||||
- 票所在那個 repo 的 `CLAUDE.md`
|
||||
- `ax-courses`:`system-dev/wiki/status.md`,以及同目錄**最新一份** `HANDOVER-*.md`(先看它的 R 段)
|
||||
- `course_gen`:`docs/CONTENT-GUIDE.md`(投影片怎麼寫才對)、`docs/SOP.md`(出一場課的流程與驗收三層)、
|
||||
`docs/PLACEMENT.md`(東西放哪)
|
||||
- `llm-wiki-template`:`README.md` 與 `README.en.md`(n8n 審核者讀的是英文版)
|
||||
|
||||
## 紅線
|
||||
|
||||
- **課件交付前要把 PDF 轉成圖逐頁看過**(`pdftoppm`)。`make build` 字量全過、`qa.py` 全綠
|
||||
**都不算驗過**——2026-09-13 D3 就是全綠交出去,28 頁裡 8 頁是空的灰框。
|
||||
- **`.pptx` 從來不是原始碼。** 內容改 `slides.md`,版型改 `templates/`;課件原始碼住 `ax-courses`,
|
||||
引擎住 `course_gen`,不要把內容寫進引擎 repo。
|
||||
- **不要在共用工作目錄切分支**——一條線用 `scripts/worktree open` 開自己的分身。
|
||||
- 推 main 會被判成 subagent 擋下:**交回總管**,不要蓋戳記硬推、也不要找別的路推上去。
|
||||
- `course_gen` 的 SOP §1 第 9 步寫的 `make capture` **不存在**,不要照著跑然後回報失敗。
|
||||
- `llm-wiki-template` 的公開版在 GitHub:**任何寫到 GitHub 的動作都要 leo 解保險**(D20),自己不推。
|
||||
- 版型取捨、課程骨架、要不要補圖這類**品味題**:把選項和各自的後果寫上票,交 leo 裁,不自己定。
|
||||
@@ -16,6 +16,8 @@ description: 頂層知識庫的工人——wiki、SDD、Critical Path、決策
|
||||
## 紅線
|
||||
|
||||
- **頂層不寫業務程式碼。** 這一層只放跨專案的整理與判準。
|
||||
- **任何時刻只允許一份 `status: active` 的 SDD**;不准自行建 SDD。
|
||||
- **SDD 不是任務清單**(leo 2026-08-16 取消 Active SDD,`inkstone/InkStoneCo#40`):
|
||||
它只記「起初的樣子」,**不帶 `status:`、不帶任務 checkbox**。任務本體在 Gitea 票。
|
||||
⇒ 不准自行建 SDD,也不准把進度寫回 SDD。
|
||||
- 規格層的變更 ⇒ 開一張 Gitea 票(`Human` + 指派 `Leo` + `s/triage`),不要自己改規格。
|
||||
- 寫給 leo 看的 md 一律**巢狀 bullet**(Logseq outliner),禁表格平攤。
|
||||
|
||||
+57
-41
@@ -1,65 +1,81 @@
|
||||
---
|
||||
name: sdd-check
|
||||
description: |
|
||||
開始任何開發任務前確認有沒有對應的 SDD——要寫 code、開新功能、
|
||||
或不確定「這件事屬於哪份規格」時自動載入。
|
||||
依 D35 SDD 生命週期鐵律:任何時刻只允許一份 status: active 的 SDD,
|
||||
所有開發任務對應它的 tasks,找不到就停下來問(不得自行建 SDD);
|
||||
動手前把這件事的來歷讀齊——要寫 code、開新功能、
|
||||
或不確定「這件事當初為什麼長這樣」時自動載入。
|
||||
🔴 Active SDD 已於 2026-08-16 取消(inkstone/InkStoneCo#40):
|
||||
**任務本體在 Gitea 票,不在 SDD**;SDD 只記「起初的樣子」,
|
||||
沒有 status、沒有唯一活性、也不再養任務 checkbox。
|
||||
⇒ 找不到 SDD 不是停下來的理由,找不到票才是;也不要為了動手而現編一份 SDD。
|
||||
規格層變更改開 Gitea 票(Human+指派 Leo)後停止等 confirm——pending-changes.md 已於 2026-08-19 廢除,禁止寫入。
|
||||
---
|
||||
|
||||
# /sdd-check — 確認當前任務有沒有對應 SDD
|
||||
# /sdd-check — 動手前把這件事的來歷讀齊
|
||||
|
||||
動手前執行。確保 CC 有全局觀,不會在沒有設計文件的情況下猛衝。
|
||||
## 先講清楚它現在是什麼
|
||||
|
||||
leo 2026-08-16(`inkstone/InkStoneCo#40` → comment 2942):
|
||||
|
||||
> 「**取消 Active SDD**⋯⋯所有文件中都不帶有任務 checkbox,因為移動到 Gitea 來管理,
|
||||
> SDD 會脫節,它所做的是記錄起初的樣子⋯⋯實際情況靠 issues, PR, release 等機制來管理。」
|
||||
|
||||
所以三件事分家(`docs/governance/sdd-gitea-governance.md` §0 公理 1):
|
||||
|
||||
```
|
||||
意圖真相 → SDD(docs/3-specs/) 未來式:當初打算怎麼做
|
||||
狀態真相 → Gitea 票/PR/release 現在式:現在做到哪、誰在做
|
||||
知識真相 → wiki 過去式:撞過什麼、學到什麼
|
||||
```
|
||||
|
||||
🔴 **這支命令已經不是一道閘了。** 舊版靠 `sdd-guard.sh` 在動 code 前強制要有
|
||||
「恰好一份 status: active 的 SDD」,那支閘於 `inkstone/ISEP#91` 退役——
|
||||
它是 fail-closed 的,裁決一執行(active SDD 歸零)就會鎖死所有 code 寫入。
|
||||
|
||||
---
|
||||
|
||||
## 執行流程
|
||||
|
||||
### 第一步:理解任務
|
||||
### 第一步:這件事的**票**是哪一張
|
||||
|
||||
確認使用者要做什麼:
|
||||
- 涉及哪個子系統?
|
||||
- 是新功能還是修改現有功能?
|
||||
- 影響範圍?
|
||||
**這一步不能跳,也是唯一會讓你停下來的一步。**
|
||||
|
||||
### 第二步:尋找對應 SDD
|
||||
- 有票號 → 讀它(含整串留言:裁決、假設、前手交回的下一步都在那裡)
|
||||
- 沒票號 → **先問清楚是哪一張,或先把票開出來**,不要憑一句話開工
|
||||
(`commands/issue-handle.md`:Issue 是唯一任務介面)
|
||||
|
||||
在 `docs/3-specs/` 下尋找對應的子系統目錄,確認有沒有:
|
||||
- `design.md`(設計文件)
|
||||
- `tasks.md`(任務清單)
|
||||
### 第二步:這件事的**來歷**在哪份 SDD
|
||||
|
||||
### 第三步:根據結果回應
|
||||
在 `system-dev/docs/3-specs/` 下找對應子系統的 `design.md`。
|
||||
|
||||
- 找到 → 讀它,弄清楚「當初為什麼這樣設計、有哪些邊界不能動」
|
||||
- **找不到 → 照樣可以動手**。票才是任務本體;
|
||||
沒有 SDD 只代表這塊當初沒有寫下設計,不代表這件事沒被批准
|
||||
|
||||
🔴 **不要為了動手而現編一份 SDD**,也不要去改任何 `status:` frontmatter——
|
||||
那是已經取消的制度的殘留物。
|
||||
|
||||
🔴 **不要把任務 checkbox 寫進 SDD**。要追蹤進度就開票/改票的狀態標籤;
|
||||
寫進文件裡的 checkbox 沒有人會回來關掉它(`inkstone/InkStoneCo#49`
|
||||
清的就是這一批:378 條沒人維護的 checkbox)。
|
||||
|
||||
### 第三步:把讀到的東西講出來再動手
|
||||
|
||||
回覆開頭一行交代即可:
|
||||
|
||||
**情況 A:找到對應 SDD**
|
||||
```
|
||||
✅ 找到 SDD:docs/3-specs/[子系統]/
|
||||
📋 design.md:[確認]
|
||||
📋 tasks.md:[確認,列出相關 task]
|
||||
🎯 對應 task:[編號和描述]
|
||||
繼續嗎?
|
||||
📌 票:<owner/repo#N> —— <一句話它要解什麼>
|
||||
📋 來歷:<design.md 路徑,或「這塊沒有 SDD」>
|
||||
🚧 邊界:<這次不准動的東西>
|
||||
```
|
||||
|
||||
**情況 B:找不到 SDD,任務明確**
|
||||
```
|
||||
⚠️ 找不到對應 SDD
|
||||
任務:[描述]
|
||||
建議在 docs/3-specs/[建議子系統名]/ 建立 SDD
|
||||
---
|
||||
|
||||
要我幫你起草 design.md 嗎?(需要你確認後才動手)
|
||||
```
|
||||
## 什麼時候該停下來問
|
||||
|
||||
**情況 C:找不到 SDD,任務模糊**
|
||||
```
|
||||
⚠️ 找不到對應 SDD,而且任務範圍不夠清楚
|
||||
請先回答:
|
||||
1. 這個功能屬於哪個子系統?
|
||||
2. 完成的標準是什麼?
|
||||
3. 有沒有不能動的邊界?
|
||||
```
|
||||
只有這幾種,**都跟 SDD 在不在無關**:
|
||||
|
||||
### 注意
|
||||
- 找不到對應的票,也問不出是哪一張
|
||||
- 票上的要求跟現行規範互相衝突(貼出兩邊出處,給出你的推測,再問)
|
||||
- 命中四題公式:花錢/不可逆/跨專案結構/品味方向
|
||||
|
||||
- 找不到 SDD **不等於可以直接動手**
|
||||
- 小修改(修 bug、改文字)可以豁免,但要明確說「這是小修改,範圍是 X」
|
||||
- 新功能、架構變動、跨模組的修改 → 一定要有 SDD
|
||||
其餘情況做出最合理的假設、把假設寫進 commit message 或票的留言,繼續走。
|
||||
|
||||
+872
-12
@@ -71,22 +71,48 @@ bash scripts/test-ticket-where-seen-guard.sh
|
||||
人就學會忽略它
|
||||
- 最後那組 `fd_is_devnull` 紅 ⇒ 判準從「fstat 問得出來的事實」滑回猜文字
|
||||
|
||||
### A11 — 討論串裡的任務要長成子票:22 條
|
||||
### A11 — 討論串裡的任務要長成子票:29 條
|
||||
```
|
||||
bash scripts/test-comment-carries-task-guard.sh
|
||||
```
|
||||
**該看到**:`22/22 通過`。
|
||||
**該看到**:`29/29 通過`。全程離線(`TICKET_HOST` 指到連不上的位址),不開任何測試票。
|
||||
**失敗**:
|
||||
- 「該擋」6 條任一紅 ⇒ 08-26 那則真的掉了的留言形狀(「等雲端那半出貨才驗得了」)會漏抓
|
||||
- 「不該擋」10 條任一紅 ⇒ **誤攔,這比漏擋嚴重**——每次留言都被擋,人就學會忽略它
|
||||
- 最後兩條(`-F <檔>`)紅 ⇒ 內文放在檔案裡時閘看不到,等於走 `ticket say` 就自動繞過
|
||||
- `-F <檔>` 那兩條紅 ⇒ 內文放在檔案裡時閘看不到,等於走 `ticket say` 就自動繞過
|
||||
- **⑮~⑳(`inkstone/ISEP#112`)**:這一組驗的不是「有沒有擋」,是**擋下來之後教的那條路走不走得通**。
|
||||
- ⑮⑯ 紅 ⇒ 訊息又印出相對路徑的 `scripts/ticket`。相對路徑是對**貼上去那個人的 cwd**
|
||||
解析的,而 `InkStoneCo/scripts/ticket` 是舊複本(`grep -c subtask` → 0)
|
||||
⇒ 照著貼會印出它「四個動詞」的說明然後什麼都沒發生。
|
||||
- ⑰ 紅 ⇒ 閘又自己抄了一份用法(它應該去 `ticket usage subtask` 現要)。
|
||||
- **⑱ 紅 ⇒ 最嚴重**:把訊息裡那塊指令原樣抽出來、只填標題/目標/驗收條件三格,
|
||||
**真的執行**,離開碼必須是 1(走到網路才停)。是 2 就表示又被某道閘擋下,
|
||||
這張票的病復發了。
|
||||
- ⑲ 是 ⑱ 的鑑別力對照(把閘原本那組 `--assign` 沒配 `--next` 接回去,必須紅);
|
||||
⑲ 綠不了就代表 ⑱ 那條綠燈沒有意義。
|
||||
- ⑳ 紅 ⇒ 內文草稿模板跟 `REQUIRED_SECTIONS` 分家了,會生出「照著貼卻過不了自己那道閘」的票。
|
||||
- ㉑ 紅 ⇒ 正本叫不動時(雲端裝壞、路徑不對)閘不是不擋了,就是**又在訊息裡補了一份手抄用法**。
|
||||
fail-open 的只准是「訊息內容」,不准是「擋不擋」;而補一份副本就是把這張票拔掉的東西裝回去。
|
||||
|
||||
### A12 — 收工要把棒子交回來:10 條
|
||||
> 🔴 **這一組刻意驗訊息內容,不是只驗離開碼。** `system-dev/wiki/mistakes.md`
|
||||
> 記過:訊息壞掉時閘照樣 `exit 2`,只看離開碼完全看不出來——
|
||||
> 而這張票(`inkstone/ISEP#112`)就是那個形狀的極端版:**閘擋對了,但它教的解法根本不存在。**
|
||||
|
||||
### A12 — 收工要把棒子交回來:24 條
|
||||
```
|
||||
bash scripts/test-baton-handback-guard.sh
|
||||
```
|
||||
**該看到**:`10/10 通過`。**用 fixture 跑,不打網路、不在票池留下測試票**。
|
||||
**該看到**:`24/24 通過`。**用 fixture 跑,不打網路、不在票池留下測試票**
|
||||
(Stop 那半用 `mktemp` 造的假 transcript;出路那兩條把 `TICKET_HOST` 指到連不上的位址)。
|
||||
**失敗**:
|
||||
- **`inkstone/ISEP#153` 驗收 1**(背景派工送出那一刻)兩條紅 ⇒ 工人剛出發就念「收工了」,
|
||||
會教總管在工人動手前去改票(假交棒)——**誤攔,比漏擋嚴重**
|
||||
- **驗收 2**「完工通知 completed ⇒ 報」紅 ⇒ 背景工人真的交件、票上沒交棒,卻靜靜通過;
|
||||
「只點名那一則通知對到的票」紅 ⇒ 判準從 `tool-use-id` 比對滑成「transcript 裡有通知就全報」
|
||||
- 「同一則通知第二次 Stop」紅 ⇒ 鬼打牆,每個回合都被擋
|
||||
- 「出路印的是絕對路徑」紅 ⇒ 又印回相對的 `scripts/ticket`,總管在 InkStoneCo 貼上去會叫到舊複本(沒有 `handback`)
|
||||
- ★「原樣貼上只填三格」紅 ⇒ 訊息教的那塊指令跑不完;它的對照組(拿掉 `--next`)紅就表示 ★ 沒有鑑別力
|
||||
- 📌 這 14 條拿 `main` 上舊版的 hook 跑是 **7 條紅**(2026-09-13 實跑),不是換了寫法就會綠的測試
|
||||
- 「該報」5 條任一紅 ⇒ 指派/tag/下一步缺哪一格抓不到,08-26 那次掉棒的狀態會靜靜通過
|
||||
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
|
||||
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
|
||||
@@ -313,11 +339,20 @@ bash hooks/tests/unpushed-police.test.sh
|
||||
- B 群(⑦—⑩,該報)任一紅 ⇒ 為了不吵而改成放行了,那是把閘關掉不是修好
|
||||
- 特別看 ④:問不到遠端(離線)**不准當成「你沒推」**
|
||||
|
||||
### A18 — 信標會講出雲端會壞掉的那三件事:14 條
|
||||
### A18 — 信標會講出雲端會壞掉的那幾件事:33 條
|
||||
```
|
||||
bash hooks/tests/isep-presence-beacon.test.sh
|
||||
```
|
||||
**該看到**:`通過 14 條,失敗 0 條`。全離線。
|
||||
**該看到**:`通過 33 條,失敗 0 條`。全離線。
|
||||
|
||||
> 📌 `inkstone/ISEP#67`(2026-09-07)從 26 條加到 33 條:信標 ④ 開場報「打了 tag 卻沒 release」,見 A38。
|
||||
> 📌 `inkstone/ISEP#122` 從 14 條加到 26 條:信標原本只掃 `scripts/`,
|
||||
> 而**真正會自動載入的東西**(skill/command/agent)同樣兩邊各有一份。
|
||||
> 實查:那 9 個檔案在 `0.1.0`(c263866)複製過來之後**一次都沒再同步**,
|
||||
> 到 2026-09-02 已經分家兩個、而且**方向相反**——
|
||||
> `skills/ship-check/SKILL.md` 是 InkStoneCo 那邊新,`commands/sdd-check.md` 是 ISEP 這邊新。
|
||||
> ⇒ 所以新增的不只是「有沒有掃到」,還有「**說不說得出真相源是哪一份**」
|
||||
> (`docs/file-ownership.tsv`),以及**雲端沒有專案那一份可比時**改用 sha256 單邊驗。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#90 ②③):信標原本只證明「有一份 plugin 載入了」,
|
||||
不證明「載入的是哪一份」——08-27 雲端載 0.3.9、main 0.9.0,差 7 個 release,
|
||||
@@ -329,6 +364,176 @@ bash hooks/tests/isep-presence-beacon.test.sh
|
||||
- ⑤⑦ 紅 ⇒ 舊複本遮蔽正門抓不到(`InkStoneCo/scripts/ticket` 那件)
|
||||
- ⑥ 紅 ⇒ **誤攔**:同步過的複本被念,人就學會忽略它
|
||||
- ⑪ 紅 ⇒ 內層迴圈變數撞名的回歸(同一個 `.claude` 下第二個殘骸會被靜靜跳過)
|
||||
- ⑮⑱⑲⑳ 任一紅 ⇒ 自動載入的分身抓不到(`ship-check` 那件會再發生一次)
|
||||
- ⑰ 紅 ⇒ **誤攔**:同步過的複本被念,人就學會忽略它(跟 ⑥ 同一條線)
|
||||
- ㉔ 紅 ⇒ 雲端唯一還作數的那個檢查失效(薄殼沒有 InkStoneCo 可以比)
|
||||
- ㉕ 紅 ⇒ **誤攔**:還沒登記 sha256 的檔案被當成「被改過」
|
||||
|
||||
### A29 — 閘印出來的逃生門,照著打真的過得去:10 條
|
||||
> 📌 編號跳過 A27/A28:那兩格被同一張票的 PR `inkstone/ISEP#124` 佔著(還沒併)。
|
||||
```
|
||||
bash hooks/tests/history-first-guard.test.sh
|
||||
```
|
||||
**該看到**:`通過 10 條,失敗 0 條`。全離線,全程用 `KBDB_STAMP_DIR` 指到 TMP,
|
||||
**不碰這台機器真正的 `/tmp/.kbdb-*` 戳記**(清掉別人的戳記=把閘弄成隨機的)。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#122`,2026-09-02 實撞):`history-first-guard.sh` 印的
|
||||
`🚪 KBDB 真的連不上:touch /tmp/.kbdb-down 後重送` 是**假的**——
|
||||
`touch` 造出來的是空檔,而那支閘讀的是**檔案內容**當時戳,空字串被當成 0
|
||||
⇒ 「距今 17 億秒」⇒ 永遠不新鮮 ⇒ **照著訊息打完,被一模一樣地擋第二次**。
|
||||
|
||||
🔴 這跟本票 comment 6071、`inkstone/ISEP#125` 是同一句話:
|
||||
**閘印出來的下一步,沒有人實際照著打過一次。**
|
||||
|
||||
**失敗**:
|
||||
- ④ 紅 ⇒ 逃生門又是假的(這是本支唯一分辨得出新舊版的那一格,見檔頭)
|
||||
- ⑦ 紅 ⇒ 逃生門變成永久後門(一次 `touch` 就免疫,過期不算數這件事沒了)
|
||||
- ⑧⑨⑩ 任一紅 ⇒ **誤攔**:文件/新檔/測試檔本來就不該被這支碰
|
||||
|
||||
### A30 — 測試沙盒傳錯參數時要在**複製之前**收手:10 條
|
||||
```
|
||||
bash hooks/tests/hook-sandbox.test.sh
|
||||
```
|
||||
**該看到**:`通過 10 條,失敗 0 條`。全離線。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#122`,2026-09-02 實撞,**兩次**):
|
||||
`hooks/tests/lib/hook-sandbox.sh` 的 `hook_sandbox <hook 的檔案路徑>` 舊版**不驗參數**,
|
||||
直接 `cp -R "$(dirname "$1")"`。順手把 repo 根目錄傳進去時:
|
||||
|
||||
```
|
||||
$ bash hooks/tests/main-and-prod-push-guard.test.sh "$PWD"
|
||||
dirname → ~/Documents/tech_projects ← 上一層,不是 hooks/
|
||||
cp -R → 整個 tech_projects(所有 repo、所有 worktree)搬進 mktemp
|
||||
實測 一次 13 GB+一次 10 GB,磁碟可用從 25 GB 掉到 462 MB
|
||||
印出來的 ❌ 沙盒建不起來 ← 只有這一句
|
||||
```
|
||||
|
||||
🔴 **它沒說參數傳錯,也沒說它已經把磁碟寫滿了。**
|
||||
判準用「要求某個東西在場」(`$1` 要是真的檔案;上一層要叫 `hooks`),
|
||||
不是關鍵字比對——兩條任一不成立就在 `mktemp` 之前 `return 1`。
|
||||
|
||||
📌 **正確用法**(兩支需要傳參數的測試,`docs/TESTING.md` 之前沒寫過):
|
||||
```
|
||||
bash hooks/tests/main-and-prod-push-guard.test.sh hooks/main-and-prod-push-guard.sh
|
||||
bash hooks/tests/main-and-prod-push-guard-cross-repo.test.sh hooks/main-and-prod-push-guard.sh
|
||||
bash hooks/tests/prod-write-guard.test.sh hooks/prod-write-guard.sh
|
||||
bash hooks/tests/stage-before-prod-guard.test.sh hooks/stage-before-prod-guard.sh
|
||||
bash hooks/tests/gitea-arm-check.test.sh .
|
||||
```
|
||||
**不傳的後果不是報錯,是假綠**:`prod-write-guard.test.sh` 的 `HOOK="$1"` 空掉時
|
||||
每一條都執行空指令回 0 ⇒ 「該擋」全部變成「實得 pass」,**19 條假紅**
|
||||
(同一支傳對參數是 `通過 37 / 失敗 0`)。不必自己傳參數的包裝在
|
||||
`scripts/test-main-and-prod-push-guard.sh`。
|
||||
|
||||
**失敗**:
|
||||
- ①③⑤⑥ 任一紅 ⇒ 收手收得太晚,暫存區已經開始長東西(磁碟風險回來了)
|
||||
- ② 紅 ⇒ 訊息沒給出走得通的那一行(本票整張票在講的就是這件事)
|
||||
- ⑧⑨⑩ 任一紅 ⇒ **誤攔/複製錯**:正常用法被弄壞,或複本裡混進了 `hooks/` 以外的東西
|
||||
### A31 — 雲端工人抓票的規則是機械可判的:84 條
|
||||
```
|
||||
bash scripts/test-ticket-pick.sh
|
||||
```
|
||||
**該看到**:`通過 84 條,失敗 0 條`。**全程離線**:假池子(5 個 repo、3 個 open milestone、
|
||||
52 張票、1 條相依邊;`big` 那個 repo 的假伺服器每頁只給 30 張,驗分頁)餵給換掉的 `api()`,時鐘走 `ISEP_COUNTDOWN_NOW`,`TICKET_HOST` 指到連不上的位址
|
||||
——任何一條真的走到網路會當場炸,不會靜靜變成假綠。**不開票、不改票、不留任何東西在 Gitea 上。**
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#131`):Routine 08-06 起讀 `journeys.md` 本 sprint 段,
|
||||
08-19 到期沒續,**連續兩週讀到殘骸只能空轉**。09-07 把 `cloud-worker.md` 步驟 1 改成從
|
||||
Gitea 主線里程碑抓票(`inkstone/InkStoneCo#118`),但那是一段給人讀的字。
|
||||
這一支把規則做成 `scripts/ticket pick`/`claim` 兩個動詞,規則本體在 `pickable()` 一支純函式:
|
||||
|
||||
```
|
||||
可抓 s/* 恰好是 {s/todo} + 沒有 assignee + 沒有 Human/human/* + 不是 hub + 在主線上
|
||||
「在主線上」=hub 里程碑裡、或被主線成員當相依(跨 repo 邊,inkstone/ISEP#133);
|
||||
別 repo 裡同名的里程碑**不算**主線。主線抓盡才抓「逾期的 open 里程碑」(一個里程碑物件一組);
|
||||
backlog/沒里程碑且沒有邊的一律不抓
|
||||
⇒ 帶 --pool 或環境 ISEP_DRAIN=1(接票的環境):兩層都抓盡後再看整個 open 票池,
|
||||
s/* 恰好是 s/todo 或 s/backlog 就可抓(不限里程碑),其他格一格不放寬(inkstone/ISEP#130)
|
||||
可抓 0 張時列出池子大小與每種「不能抓的理由」各幾張,不再印「正常、不是故障」
|
||||
認領 claim = 指派 + s/doing + 第一行【身份】的留言,一個動作
|
||||
完成 handback --label s/review(本來就有的那半,不重做)
|
||||
```
|
||||
|
||||
**失敗**:
|
||||
- ①「★」那四條任一紅 ⇒ 票上驗收第 1 條破了:Human/有 assignee/backlog 有一種會被抓走
|
||||
- ①「s/todo+s/doing 同時掛著」紅 ⇒ 會把別人正在做的票抓走(09-07 實查 `inkstone/mira#6`
|
||||
就是這個形狀——exclusive 只擋 UI 不擋 API)
|
||||
- ②「主線抓盡 → 才輪到逾期」那兩條紅 ⇒ 順序壞了:要嘛主線還有票就去抓逾期的,
|
||||
要嘛抓盡了還回 `Arcrun#8`(既不是主線也沒逾期)
|
||||
- ②「離開碼 1」紅 ⇒ 「真的沒有票」與「讀不到」被講成同一句——後者要離開碼 2
|
||||
- ②「沒有主線 → 離開碼 2 且一通 API 都沒打」紅 ⇒ 沒主線時去抓了別的
|
||||
- ②「ISEP#1 靠相依進主線」/「ISEP#13 同名沒邊 → 不在」任一紅 ⇒ 成員又變回靠名字算
|
||||
(leo 2026-09-07 推翻的那條判準回來了,A41 也會一起紅)
|
||||
- ③「該擋」六條任一紅 ⇒ 規則只長在 `pick` 這端,手動 `claim` 一張 Human 的票照樣過
|
||||
- ③「身份名不在名單上 → 一通都沒打」紅 ⇒ 名單以外的名字碰到了 Gitea
|
||||
- ④ 第一條紅 ⇒ 連不上 Gitea 時回了離開碼 1,雲端會把「斷線」當「沒票」收工
|
||||
- ⑤「主線與逾期都抓盡、帶 --pool → 回 mira#25」紅 ⇒ 又回到 09-18 的形狀:幾百張 open 票,工人說沒事做
|
||||
- ⑤「--all 票池恰好是這幾張」紅 ⇒ 要嘛放寬了不該碰的(Human/指派 Leo/triage/沒 s/*/hub),要嘛漏了該抓的
|
||||
- ⑤「ISEP_DRAIN=1 = --pool」紅 ⇒ 雲端工人照 `cloud-worker.md` 打 `ticket pick`、佇列閘自己跑的 pick,都看不到票池
|
||||
- ②「沒帶 --pool → 票池一通都沒讀」紅 ⇒ **誤攔**:本機總管跑過 `handback` 後每次收工都會被佇列閘點名去抓票池
|
||||
- ⑤「可抓 0 張時列理由表、不說正常」紅 ⇒ 「0 張」又被說成正常收工(c7797)
|
||||
- ⑤「分頁」紅 ⇒ 伺服器每頁上限比 50 小時,第二頁之後的票看不到
|
||||
- ⑤「票池抓盡、還有沒 s/* 的票 → 回待分診那張,下一步是 triage」紅 ⇒ 沒驗傷的票又沉默地消失在「可抓 0 張」後面(`inkstone/ISEP#159`;A43 測分診本身)
|
||||
- ⑤「pick --claim 遇到待分診 → 不認領」紅 ⇒ 會對一張還沒決定要不要做的票寫 s/doing
|
||||
|
||||
📌 **真實 Gitea 唯讀實跑**(2026-09-07,isep-hand,不改任何票):
|
||||
`ticket pick --mainline InkStoneCo/system-dev/mainline.json --all` 在主線
|
||||
「AI 問一次就看得到全部」回 `ISEP#130`、`ISEP#131` 兩張(`Arcrun#175/176` 已有 assignee、
|
||||
`Arcrun#86` 是 s/doing、`mira#6` s/todo+s/doing 都沒被抓);逾期里程碑組把 `ISEP#31–35`
|
||||
五張 `hub` 排除後剩 `Arcrun#83`、`mira#5`、`arcrun-rag#14/27/37`。
|
||||
**寫入那半**用 `claim inkstone/ISEP#131 --name isep-hand` 真的認領過一次:
|
||||
assignee=`claude-code`、標籤 `s/todo → s/doing`、留言 6325 第一行【身份】——
|
||||
之後再 `pick` 就不再回 #131(票上驗收第 2 條的 log 在票的留言裡)。
|
||||
|
||||
### A43 — 雲端工人會分診、會組里程碑,而且碰不到 leo 的票:80 條
|
||||
```
|
||||
bash scripts/test-ticket-triage.sh
|
||||
bash hooks/tests/queue-drain-guard.test.sh # 28 條:待分診那一列,閘印 triage 不印 claim
|
||||
```
|
||||
**該看到**:`通過 80 條,失敗 0 條`、`通過 28 條,失敗 0 條`。**全程離線**(形狀同 A31:假池子 3 個 repo、
|
||||
3 個 open 里程碑、1 張載體帶 3 條相依;時鐘定在 2026-09-18;`TICKET_HOST` 連不上)。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#159`):leo 09-18「我有快 500 個票⋯⋯它看到票時要能夠組成 milestone 提出版本,
|
||||
所以不只是看票,還要組票」。當天實量 open 302 張、沒 s/* 的 74 張、沒掛里程碑的 257 張;雲端只會撿(A31),不會組。
|
||||
哪張跟哪張重複、哪幾張該一起出版——是**讀票的人的判斷**;這一支守的是判斷寫回 Gitea 的那一步:
|
||||
|
||||
```
|
||||
triage 沒有 s/* 的票 → s/backlog|s/triage|s/pending + 票上一則說明(--why 必填),一個動作
|
||||
--dup-of <票> → 說明+close/duplicate+關+在留下的那張留一則指回來
|
||||
compose --title --goal --due --carrier inkstone/InkStoneCo#N <前置…>
|
||||
建在 InkStoneCo、掛在既有載體、前置掛成相依、s/backlog → s/todo,不開任何新票
|
||||
先全部讀過才寫第一筆;驗不過一次列出全部理由
|
||||
pick --pool 時:還沒到期的 InkStoneCo 里程碑的前置 → 排在散票之前(組了就照里程碑做)
|
||||
可做的都抓盡 → 回一張待分診的(row 帶 next=triage 那一行,佇列閘照它印)
|
||||
不碰 掛 Human/human/*、指派給別人(含 Leo)——判準是標籤與 assignee 登入名,不看文字
|
||||
```
|
||||
|
||||
**失敗**:
|
||||
- ①「掛 Human/指派 Leo → 不碰」任一紅,或 ②③ 裡「驗收 3」那幾條紅 ⇒ **會寫 leo 的票**(驗收 3 破了)
|
||||
- ②「重複的對象是 Leo 的票 → 不在它上面寫字」紅 ⇒ 關重複票時順手在 leo 的票上留言
|
||||
- ②「寫入恰好是 [留言, 改標籤]」紅 ⇒ 改了欄位卻沒留說明(驗收 1:每張被動過的票都說得出分到哪、為什麼)
|
||||
- ②「--to s/todo 擋」紅 ⇒ 沒排進里程碑的票被標成「已排進里程碑」
|
||||
- ③「好幾格不對時一次列齊」紅 ⇒ 改一格撞一次——閘在懲罰人
|
||||
- ③「名字是版本號/沒期限/9999/過去」任一紅 ⇒ 開工規約第 5、6 條破了
|
||||
- ③「載體不在 InkStoneCo」紅 ⇒ 開工規約第 7 條破了(在子 repo 建里程碑)
|
||||
- ③「前置已是別的里程碑載體的前置」/「載體是別一輪的前置」紅 ⇒ 一張票同時排進兩輪
|
||||
- ③「不開任何新票」紅 ⇒ 開工規約第 2 條破了
|
||||
- ④「里程碑那一組排在票池之前」紅 ⇒ 組了里程碑,工人還是照開票時間撿散票
|
||||
- ④「沒帶 --pool → 不看、不回待分診」紅 ⇒ **誤攔**:本機總管收工時被佇列閘叫去分診
|
||||
- ④「載體票本身不當成可做的票」紅 ⇒ 工人會去「做」一張容器票
|
||||
|
||||
📌 **真實 Gitea 實跑**(2026-09-18,isep-hand 用本分支代跑一趟,署名 `[isep-hand]`):
|
||||
- 分診前 `triage --list`:待分診 72 張 → 分診 4 張後 67 張(總數同時受別條線影響;我動的 4 張讀回皆對)
|
||||
- `Arcrun#188` → 與 `Arcrun#143` 重複:`close/duplicate`、已關,#143 上有指回來的留言
|
||||
- `Arcrun#131` → `s/triage`(與 #143 同一件事,兩張留哪張交總管)
|
||||
- `ISEP#47`、`arcrun-rag#199` → `s/backlog`,各有一則說明
|
||||
- 組里程碑:`ticket where` 撈不到能載這一輪的 InkStoneCo User Story ⇒ 補一張載體 `InkStoneCo#136`,
|
||||
`compose` 建出 InkStoneCo milestone 68「金鑰不再出現在任何畫面與 log 裡」(期限 09-25):
|
||||
載體掛上、`ISEP#47`/`arcrun-rag#199` 成為相依並 `s/backlog → s/todo`、三張各一則說明
|
||||
- `pick --pool --all`(主線 #65):第一組就是這個里程碑的兩張前置,之後票池 101 張、待分診 67 張
|
||||
- 真的擋:`compose --carrier InkStoneCo#134 ISEP#47 Arcrun#143` → 離開碼 2、一筆都沒寫,三個理由一次列出
|
||||
(#134 已是主線載體 #55 的前置/#47 已排進 milestone 68/#143 還沒分診)
|
||||
|
||||
### A19 — 下游做完時頂層跟著關:49 條
|
||||
```
|
||||
bash scripts/test-ticket-handoff-writeback.sh
|
||||
@@ -408,6 +613,532 @@ python3 scripts/debt-worklist list --top 5
|
||||
**失敗**:前幾名一眼看不出為什麼在那裡 ⇒ 那些權重就是錯的,去改 `score()`,
|
||||
**不要改成「總管覺得」**——這份清單的價值就在於它不靠記憶。
|
||||
|
||||
### A21 — 沒人會叫的事會自己叫:43 條
|
||||
```
|
||||
bash hooks/tests/overdue-nag.test.sh
|
||||
```
|
||||
**該看到**:`通過 43 條,失敗 0 條`。**全程離線**——資料走 `--fixture`、時鐘走 `--now`、
|
||||
網路用 `ISEP_NOTIFY_OFFLINE=1` 關掉。**不打 Gitea、不打實例、不留任何測試票。**
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#93`,2026-08-27 實查):一張票掛著等 leo 三天了、
|
||||
一個 milestone 逾期了、一根棒子躺著沒人接——**沒有任何東西為此叫過一聲**。
|
||||
當天有五個 milestone 逾期(08-24 三個、08-26 兩個),一聲都沒有。
|
||||
|
||||
**失敗**:
|
||||
- A 群任一紅 ⇒ 撈不出票上點名的那五個逾期 milestone,或訊息不是白話的(票上驗收 1)
|
||||
- ⑨⑩ 紅 ⇒ 沒東西可報時**安靜結束**(票上驗收 2)。**安靜跟壞掉長得一模一樣**
|
||||
- ⑪ 紅 ⇒ 撈不到資料時把「我沒查到」講成「沒有事情逾期」——那是最貴的一種假情報
|
||||
- **C 群任何一條紅 ⇒ 誤報**,這比漏報嚴重:每次開場都被念一串不相干的東西,
|
||||
人就學會跳過它,那時真的有事也叫不動他
|
||||
- D 群紅 ⇒ **發不出去卻靜默**。「送出成功」跟「送到了」是兩件事。
|
||||
㉒b 特別看:退路留言若只帶閘的判定、不帶**為什麼送不出去**,
|
||||
下一個人會去修閘——而斷點可能根本不在那裡(2026-08-28 第一版真的漏了這一格)
|
||||
- ㉖㉗㉘㉙ 紅 ⇒ 這個 session 的閘是哪一版變回「假設」。㉘ 特別重要:
|
||||
**閘說會擋就不准從 python 這條路繞過去**——繞得過的閘等於不存在
|
||||
- ㉛ 紅 ⇒ 探針把 `/tmp/.prod-write-ok` 燒掉了(那是總管單次用完即丟的授權)
|
||||
- ㉟ 紅 ⇒ 每條 subagent 開工都吵 leo 一次
|
||||
- **G 群(㊱–㊷,`inkstone/ISEP#118`)**:新開的 repo 有票卻沒有工人,開場要點名
|
||||
- ㊱㊵ 紅 ⇒ 缺工人的 repo 沒被點名 ⇒ 又回到「第一次派工撞閘才發現、開例外閘繞過」
|
||||
- ㊳ 紅 ⇒ **誤報**:有工人的、沒票的、封存的被點名,每次開場念一串不相干的 repo
|
||||
- ㊴ 紅 ⇒ 補工人這件總管的事跑進 leo 的 Telegram
|
||||
- ㊶㊷ 紅 ⇒ 名單或 org 清單讀不到時,把「沒查到」講成「每個都缺人」或「都有人」
|
||||
|
||||
> 🔴 **跑測試前先確認 `CLAUDE_CODE_CHILD_SESSION` 沒有殘留**:這支閘刻意放行子 session,
|
||||
> 在一條 subagent 裡跑,F 群會全綠而且是**假綠**。測試檔自己會把它清掉。
|
||||
|
||||
### A22 — 壓 wiki 不准弄丟東西:25 條
|
||||
```
|
||||
bash hooks/tests/wiki-compress.test.sh
|
||||
```
|
||||
**該看到**:`通過 25 條,失敗 0 條`。**全程離線**,只在 `mktemp` 目錄裡動檔案,
|
||||
**不碰任何真的 wiki**。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#89`):`mistakes.md` 是「做新功能前讀一遍」等級的必讀檔,
|
||||
實測 **7,681 行 / 260 條**——**沒有人真的每次都從頭讀。讀不完的必讀檔,等於沒有。**
|
||||
|
||||
**失敗**:
|
||||
- ①② 紅 ⇒ 切條切錯了,後面三件全部失準。② 特別重要:`mistakes.md` 裡真的有
|
||||
**寫在 code fence 裡的 `##`**(第 534 行那種),把它當成一條就會拿不存在的東西去對帳
|
||||
- ⑤⑥ 紅 ⇒ 弄丟了東西卻沒被發現(票上驗收 2)
|
||||
- **⑦ 紅最嚴重**:那是**反向測**——真的弄丟一條時 `verify` 抓不到。
|
||||
**一個抓不到問題的對帳表,比沒有對帳表更糟**,因為它會被當成證據
|
||||
- ⑧⑨⑩ 紅 ⇒ 壓完查不到、或查得更慢(票上驗收 1)
|
||||
- ⑪–⑮ 紅 ⇒ 壓縮沒有票號、沒有交付紀錄(票上驗收 3:不准順手做完沒人知道)
|
||||
- **⑱⑲㉑ 任一紅 ⇒ 誤攔**,這比漏擋嚴重:一般編輯被擋、非 wiki 的檔被擋、
|
||||
新建檔案被擋,人就學會繞過它
|
||||
- ㉒ 紅 ⇒ 擋不只一次 ⇒ 會卡死
|
||||
- ㉕ 紅 ⇒ 都在門檻內還要講一次話,那行提示下次就沒人看了
|
||||
|
||||
📌 **真跡跑過**:拿**現在的 `mistakes.md` 複本**實壓一次(不動真的 wiki)——
|
||||
`7,681 行 → 1,199 行`、`260 條一條都沒少`(`verify` 逐條對帳)、
|
||||
`80 個查詢命中率 80/80,定位成本 2,956 行 → 131 行,快 22.5 倍`(`bench`)。
|
||||
指令:
|
||||
```
|
||||
cp <某個 wiki>/mistakes.md /tmp/w/ && python3 scripts/wiki-compress apply /tmp/w/mistakes.md --ticket inkstone/ISEP#89
|
||||
python3 scripts/wiki-compress verify /tmp/w/mistakes.md.before-compress /tmp/w/mistakes.md /tmp/w/mistakes-archive-*.md
|
||||
python3 scripts/wiki-compress bench /tmp/w/mistakes.md.before-compress /tmp/w/mistakes.md /tmp/w/mistakes-archive-*.md
|
||||
```
|
||||
|
||||
|
||||
### A23 — 同時跑的線有上限:33 條
|
||||
```
|
||||
bash hooks/tests/parallel-lines-cap-guard.test.sh
|
||||
```
|
||||
**該看到**:`33/33 通過`。**離線、不打網路、不花錢**——每個案例自己搭一份假的 harness 佈局
|
||||
(`<sid>.jsonl` + `<sid>/subagents/agent-*.meta.json`),判準只有檔案的存在與 mtime,沒有語意判官。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#109,2026-08-29 實撞):總管同時開七條線,8 GB 的 Mac 重開機;
|
||||
同一天還派錯線 1 次、量錯東西 6 次、停掉正確的工作 1 次。leo:「**跑 2-3 條應該是極限了**」。
|
||||
|
||||
**失敗**:
|
||||
- A 群 14 條任一紅 ⇒ **誤攔**。這比漏擋嚴重——④⑤紅代表「收工了還在擋」,
|
||||
那會變成**整台機器永遠派不了工**
|
||||
- ⑬⑭ 紅 ⇒ fail-open 壞了。這支刻意選「算不出來就放行、但出聲」:
|
||||
算不出來就擋會卡死所有派工,那是比漏擋貴得多的失敗
|
||||
- ㉒ 紅 ⇒ 安靜窗的邊界算錯了,那是唯一擋著「永久卡死」的保險
|
||||
|
||||
📌 **這支閘的三個機械前提是實測出來的,不是推測的**(改它之前先重測這三件):
|
||||
`SubagentStop` 在實測的 session 裡**一次都沒觸發**(所以不能拿它當減法);
|
||||
`PostToolUse:Agent` 在**送出後 5 秒**就觸發(不是收工);
|
||||
subagent **不是獨立行程**(`ps` 只有一個 claude-code 行程,所以數行程數不到線)。
|
||||
|
||||
### A24 — 一條線要有自己的工作目錄:110 條
|
||||
```
|
||||
bash hooks/tests/line-needs-own-worktree.test.sh
|
||||
```
|
||||
**該看到**:`110/110 通過`。離線,每個案例自己開**兩顆**真的 git repo +一份真的 linked worktree +一顆本機 bare 遠端。
|
||||
|
||||
**G/H 群在守什麼**(inkstone/ISEP#147,2026-09-07 補):**分身住 repo 裡面,收工要被收。**
|
||||
leo 在 Finder 看到 `ISEP-wt-122ship`/`ISEP-wt-117`/`ISEP-wt115`/`InkStoneCo-wt-112`…一排——舊版教「開在 repo 旁邊、
|
||||
收工 `worktree remove`」,而 remove 只寫在閘訊息裡,沒機制驗。
|
||||
G 群的測資是**閘自己印出來的 `scripts/worktree open` 那一行**((78) 擋一次、(79) 抽出、(80b) 只填票號與分支真的跑),
|
||||
然後看三件事實:分身在 `<repo>/.worktrees/` 底下、**repo 旁邊的目錄清單一個都沒多**((81))、共用目錄 `git status` 乾淨((82))。
|
||||
H 群餵的是 PostToolUse `Agent` 的 payload(派工單 `【工單】owner/repo#N`,總管 session):沒推 ⇒ exit 2 點名分支+路徑+推的指令、分身一根寒毛沒動((86)–(91));
|
||||
推了但有未追蹤檔 ⇒ exit 2((92)–(94));推了且乾淨 ⇒ exit 0、目錄與登記都沒了、**分支還在**((95)–(98));
|
||||
沒工單/沒分身/工具不是 Agent ⇒ 安靜((99)–(103),誤攔比漏擋嚴重);一單兩票各自處理((104)–(106));
|
||||
環境裡的 `WORKTREE_OK=1` 是切分支的逃生門,不影響收工((107))。
|
||||
修之前拿新測試跑舊閘(main 那份):**90/110,20 條紅**——㉖㉙㉚b㉚c(訊息還在教開在旁邊)+ G/H 全部;B/D/E/F 全綠。紅的正好是這次改到的行為。
|
||||
|
||||
**E 群在守什麼**(comment 6587,2026-09-07 補):**閘印出來的那條出路要真的走得通。**
|
||||
閘訊息教的是 `WORKTREE_OK=1 git -C … checkout …`,舊版讀的卻是 hook 自己的環境變數——PreToolUse hook
|
||||
跟指令不是同一個行程,指令前綴的賦值到不了它 ⇒ 總管三次照貼三次被擋。E 群的測資是**閘自己印出來的那一行**
|
||||
((61) 先擋一次、(62) 抽出那行、(63) 原樣餵回去要放行);(64)–(67) 釘住判準是**位置不是字**:
|
||||
`echo WORKTREE_OK=1; git checkout`、前綴掛在別的指令上、值不是 1、寫在 commit 訊息裡,四種都照擋。
|
||||
修之前拿新測試跑舊閘:(60)(63)(68) 三條紅,其餘全綠——紅的那三條就是真的改到的。
|
||||
|
||||
**F 群**是 `inkstone/ISEP#125` 票上原文的驗收表 A–F((70)–(75),期望值照票:A–E 要 0、**F 要 2**)。
|
||||
D/E 是「用 heredoc 寫一份內含 `cd <目錄> && git checkout X` 示範的文件」被當成在切分支(09-02 兩次實地命中),
|
||||
現在先用 `lib/strip_heredoc.py` 剝掉 body 再解析(跟 D20 閘同一支 helper)。(76) 是寫成測試的已知邊界:
|
||||
tokenize 層把換行當空白吞掉,`…EOF⏎git checkout main` 從第一版起就看不到(漏擋不是誤攔,與 (53) 同層,另報);
|
||||
(77) 是它的對照組(換成分號就擋)。
|
||||
|
||||
**D 群在守什麼**(comment 5398,2026-08-29 補):不只要擋對,還要**說得出是哪一個 repo**。
|
||||
舊版一律拿 payload 的 `cwd` 當答案,於是 `cd <別的 repo> && git checkout` 擋是擋對了,
|
||||
訊息卻指著 `cwd` 那個 repo ⇒ 照著做的人會在**錯的 repo** 開一份用不到的 worktree,
|
||||
真正要隔離的那個沒開到,而他以為隔離好了。🔴 **一道閘給錯下一步,比不擋更糟。**
|
||||
D 群同時釘住反面:認不出來(`cd $VAR`、`cd -`、cd 到不是 repo 的地方)就**放行**,不猜。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#109 → comment 5391,2026-08-29 實撞):
|
||||
每個 repo 只有**一份**工作目錄,所有線共用。`arcrun-rag#163` 切到自己的分支、交回時沒切回來,
|
||||
總管後來在同一個目錄跑 `ship.mjs`,**讀到的是別人留下的 HEAD**。
|
||||
🔴 票號說得出「這是哪個任務」,**沒有任何東西說得出「這個目錄現在是誰留下的狀態」**。
|
||||
|
||||
**失敗**:
|
||||
- A 群 16 條任一紅 ⇒ **誤攔**。特別是⑤⑥⑦(還原檔案)與⑨(已經在自己的 worktree 裡)——
|
||||
擋掉它們等於線動不了工
|
||||
- ① 紅 ⇒ 連總管在自己的目錄裡切分支都被擋。那不是嚴格,那是把共用目錄的主人趕出去
|
||||
- ㉞㉟ 紅 ⇒ `prune` 做錯了。㉞是沒清掉說謊的登記;**㉟是把還在的 worktree 也弄掉了,那是災難**
|
||||
- (63) 紅 ⇒ 閘印的出路又走不通了——被擋的人不會停下來修閘,他會去找繞過去的方法(6587 當天就是)
|
||||
- (64)–(67) 任一紅 ⇒ 出路退化成關鍵字:印個字就能過,閘等於沒有
|
||||
- (75) 紅 ⇒ **閘廢了**(#125 原話):剝 heredoc 剝過頭,真的切也放行
|
||||
- (76) 紅 ⇒ tokenize 那層被改了,回頭跑 (53) 與推 main 的 10+19 條
|
||||
|
||||
### A25 — `sdd-guard` 退役了,而且退乾淨了:8 條
|
||||
```
|
||||
bash hooks/tests/sdd-guard-retired.test.sh
|
||||
```
|
||||
**該看到**:`通過 8 / 失敗 0`。離線,自己開兩顆假 git repo,跑完自己清。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#91`):leo 2026-08-16 在 `inkstone/InkStoneCo#40` 裁定
|
||||
**取消 Active SDD**——任務狀態搬到 Gitea 管,SDD 只記「起初的樣子」。
|
||||
但 `sdd-guard.sh` 是 **fail-closed** 的:`status: active` 不是恰好 1 份就擋(**0 份也擋**),
|
||||
路徑所在的 repo 沒有 `3-specs` 也擋。⇒ **照裁決把 active SDD 拿掉,會當場鎖死所有 code 寫入**,
|
||||
這就是那個裁決躺了 11 天沒人敢執行的真正原因。
|
||||
|
||||
🔴 **這支測的不是「檔案刪掉了沒有」,是「那個擋還會不會發生」**:
|
||||
它把「裁決執行完之後的世界」(沒有 `3-specs` 的 repo、有兩份 `status: active` 的 repo)
|
||||
丟給 `hooks.json` 上**整組** `Write|Edit|MultiEdit` 的閘——清單是當場從 `hooks.json` 讀的,
|
||||
不寫死——**不准有任何一支用 SDD/3-specs 當理由擋下來**。
|
||||
⇒ 日後有人換個檔名把同一個形狀種回來,這支照樣紅。
|
||||
|
||||
**失敗**:
|
||||
- ②那四條任一紅 ⇒ 裁決又被種回去了。**最要看的是最後一條**(ISEP 自己的 `hooks/lib/*.py`):
|
||||
ISEP 這個 repo 本身就沒有 `3-specs`,退役前那一格是紅的——它是這件事的活體證據
|
||||
- ③紅 ⇒ 條文回來了。有人在 `commands/`/`agents/`/`skills/`/`hooks/` 裡
|
||||
又寫了一次「只准一份 active SDD」,而**條文會被載入,載入就會被照做**
|
||||
- ①的第三條(`hooks.json` 仍是合法 JSON)紅 ⇒ 合併把檔案弄壞了;
|
||||
但**它只證明語法沒壞、不證明閘還在**——閘在不在要另外跑 README 那道逐支點名
|
||||
|
||||
📌 **這支只管 ISEP 這一半。** 裁決的另外兩件在 `inkstone/InkStoneCo`:
|
||||
拿掉那份 active SDD + 刪 `CLAUDE.md` 的「單一活性鐵律」段,以及
|
||||
`system-dev/docs/` 底下的任務 checkbox 分診(掛 `inkstone/InkStoneCo#49`)。
|
||||
🔴 **順序不可顛倒**:要等這一版 ISEP 出去、兩邊 `/plugin update` 之後,
|
||||
那半才動得——先拿掉 active SDD 就會鎖死。
|
||||
|
||||
### A26 — 雲端該有的憑證清單,這台機器真的拿得到:11 條
|
||||
```
|
||||
bash scripts/test-make-cloud-env.sh
|
||||
```
|
||||
**該看到**:`11/11 通過,跳過 0 條`(清單現在是 10 把——兩台全給 + 機器帳號 + uncle6 出貨 + Arcrun 出貨線的實例 namespace)。**全程只印變數名字與值的長度,一個字元的值都不印。**
|
||||
A 段用假的 `.env` 隔離跑(不碰真金鑰、不碰 `~/.claude/cloud-env`);
|
||||
B 段拿這台機器真正的 `.env` 對帳——**`.env` 不在的機器(雲端就是)會印 `⏭️ SKIP`**,
|
||||
不算失敗,但也不算驗過。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#115,2026-09-01 實撞):
|
||||
`make-cloud-env.sh` 的 `NEEDED` 是「雲端總管手上有哪些憑證」的**唯一**清單。
|
||||
那天雲端要清空 youlin,`env | grep -ciE 'cloudflare|^CF_|wrangler'` → **0**——
|
||||
不是雲端漏設,是**這份清單裡從來沒有任何 CF 憑證**。
|
||||
🔴 更貴的是它**發生在流程末端**:工人已經把清空腳本與測試都寫完了,
|
||||
到要真的跑的那一刻才知道跑不了。
|
||||
|
||||
**失敗**:
|
||||
- **B7 紅** ⇒ 清單上有一把這台機器根本拿不到值 ⇒ leo 貼出去的會是
|
||||
`<🔴 找不到>`,而雲端要到**用到那一刻**才知道。這就是 ISEP#115 那一天的形狀
|
||||
- **B8 紅** ⇒ 清單裡混進了值出自 `polaris/mira/.env` 的變數,那是 **leo21c 現役正式環境**。
|
||||
leo 2026-08-20 已把那 7 把分進 B 段並選擇不放(`inkstone/InkStoneCo#14` → comment `3890`)
|
||||
- **B9 紅** ⇒ 清單裡有一把的**值**就是 `CLOUDFLARE_API_TOKEN_leo21c`。
|
||||
leo 2026-09-01 的紅線是「那把不准加」,而 B9 比的是值不是名字 ⇒ **改個名字照樣紅**
|
||||
- **B8x 紅或長期 SKIP** ⇒ B8 的偵測**不會亮**。全綠有兩種可能(真的沒有正式憑證/
|
||||
偵測根本壞了),分不開就等於沒驗
|
||||
- **A5a/A5b 紅** ⇒ 缺值時不會被標成 `<🔴 …>`、收尾也不點名 ⇒ **假綠**,
|
||||
而假綠正是這張票要解的病
|
||||
- **A6 紅** ⇒ 有東西被寫進 repo。那支腳本的產物含金鑰真身,**刻意不在任何 repo 裡**
|
||||
|
||||
> 🔴 **B8 與 B9 的判準都不是名字,是機器算得出來的事實。**
|
||||
> B8 看「值從哪個檔案拿到的」,B9 看「這個值是不是就是 leo21c 那把」。
|
||||
> **兩條是分工不是重複**:`CLOUDFLARE_API_TOKEN_leo21c` 住在**頂層** `.env`,
|
||||
> 不在 `polaris/mira/.env` ⇒ B8 抓不到它,那就是 B9 存在的理由。
|
||||
>
|
||||
> **兩條的「該紅」方向都實跑過**(2026-09-01,拋棄式副本,分支一個字沒動):
|
||||
> ```
|
||||
> 混進 mira 的 NAMESPACE → ❌ B8 …want=0 got=1,並指出「來自 polaris/mira/.env」
|
||||
> 把 leo21c 那把改名 CF_TOKEN_BACKUP → ❌ B9 …want=0 got=1(B8 此時仍綠,因為值不出自 mira)
|
||||
> ```
|
||||
> ⇒ 第二條正是黑名單會放過去的形狀。
|
||||
|
||||
### A31 — leo21c 讀可以、寫不行,而且改法指到活著的 youlin:26 條
|
||||
```
|
||||
bash hooks/tests/leo21c-write-guard.test.sh
|
||||
```
|
||||
**該看到**:`通過 26 條,失敗 0 條`。全程離線,這支閘不寫檔,直接跑真跡。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#130,2026-09-04 雲端 run log):`leo21c-write-guard.sh`
|
||||
**連讀都擋**——Routine 讀 `notify_leo` 定義那一條被攔下,雲端於是什麼都拿不到。
|
||||
實查:舊判準 `-d[[:space:]]` 把 `| tr -d '\r'`、`cut -d=` 這種唯讀的 `-d` 當成 curl 的 body;
|
||||
`prod-write-guard.sh` 在 08-12 就修過同一個洞,這支漏了。另外它把 `notify_leo` 的 trigger
|
||||
一律當寫入擋,而 `prod-write-guard.sh` 從 ISEP#63 起就放行它——**兩支閘對同一條指令說不同的話**。
|
||||
這支之前**沒有任何測試**,「讀會不會被誤攔」從來沒被驗過。
|
||||
|
||||
測資是 Routine 文件(`cloud-worker.md`/`progress-guard.md`)裡**真的會下的那幾條**。
|
||||
|
||||
**失敗**:
|
||||
- A 群任一紅 ⇒ **誤攔**,雲端又回到「連讀都讀不到」;③④⑤ 紅就是 09-04 那個形狀復發
|
||||
- ⑫ 紅 ⇒ 撞人閘時發 Telegram 叫 leo 又會被擋(inkstone/InkStoneCo#110 的病)
|
||||
- B 群任一紅 ⇒ 寫得進 leo 的真庫(2026-08-20 那 8 張測試卡的病)。⑬ 特別看:
|
||||
Routine 的心跳 POST **就是寫入**,它該被擋——那條指令要改,不是閘要放
|
||||
- ㉔㉕ 紅 ⇒ 閘印的「改法」教人打 09-02 起 DNS 已不存在的舊子網域,照著做只會拿到 000
|
||||
|
||||
**修之前用同一份測試跑舊閘(`git show main:hooks/leo21c-write-guard.sh`)**:③⑤⑫㉔㉕ 五條紅
|
||||
——證明這五格是真的改到了,不是測試順著新閘寫的。
|
||||
|
||||
### A32 — 主線那個檔隨 repo 走:17 條
|
||||
```
|
||||
bash hooks/tests/mainline-repo-mirror.test.sh
|
||||
```
|
||||
**該看到**:`通過 17 條,失敗 0 條`。全程離線:家目錄那份走 `ISEP_COUNTDOWN_STATE_DIR`、
|
||||
repo 那份走 TMP 底下的假 `InkStoneCo/system-dev/`。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#130;leo 09-05「routine 每天早上開啟後抓不到任務」):
|
||||
主線只住在家目錄,雲端是另一台機器 ⇒ 不知道主線是哪條。PR inkstone/InkStoneCo#118
|
||||
把它放進 `system-dev/mainline.json`,這裡驗 ISEP 這一半:**家目錄沒有就讀 repo 那份;
|
||||
兩份都在 `set_at` 較新的算數;`set`/`adopt`/`clear` 兩份一起動;`refresh` 只寫家目錄。**
|
||||
|
||||
**失敗**:
|
||||
- ①②③ 紅 ⇒ 雲端(`$CLAUDE_PROJECT_DIR` 是薄殼根、真身在 `InkStoneCo/`)仍然讀不到主線
|
||||
- ⑤⑥ 紅 ⇒ 誰後標的誰算數這條壞了:本機會把雲端剛標的蓋回去,或反過來
|
||||
- ⑧ 紅 ⇒ **每開一個 session InkStoneCo 的工作樹就髒一次**,而髒的 diff 沒有人會去 commit
|
||||
- ⑫ 紅 ⇒ `clear` 之後下一次讀又從 repo 那份長回來
|
||||
- ⑮ 紅 ⇒ ISEP#82 第 4 條(沒有主線不能整組壞掉)被本票弄壞
|
||||
|
||||
雲端實跑(2026-09-07,本 session):`CLAUDE_PROJECT_DIR=/home/user/inkstoneco
|
||||
ISEP_COUNTDOWN_STATE_DIR=$(mktemp -d) python3 scripts/mainline` → 印出
|
||||
`🎯 現在的主線:inkstone/Arcrun#48「AI 問一次就看得到全部…」`——家目錄空的,只靠 repo 那份。
|
||||
|
||||
### A33 — 權限白名單住 ISEP 一份、兩台各自寫進家目錄:19 條
|
||||
```
|
||||
bash scripts/test-settings-allow-sync.sh
|
||||
```
|
||||
**該看到**:`19/19 通過`。全程在 TMP 底下的假 `settings.json` 上跑,不碰真的 `~/.claude/settings.json`。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#130 → comment 6120/6121):leo 09-07 親手加進本機
|
||||
`InkStoneCo/.claude/settings.json` 的四條(`ticket`/`mainline`/`gate-ok`/`gitea-pr-merge`)
|
||||
雲端沒有 ⇒ 同樣動作被分類器擋(09-04 `permission_denials=6`)。雲端真正讀的薄殼 repo
|
||||
要 D20 開閘才推得動 ⇒ 清單住 ISEP `docs/permissions-allow.json`,`scripts/settings-allow-sync`
|
||||
在那台機器上寫進 `~/.claude/settings.json`:雲端由 `docs/cloud-setup-script.sh` 裝完 plugin 跑一次、
|
||||
之後每個 SessionStart 再對一次;本機同一支。
|
||||
|
||||
**失敗**:
|
||||
- A1 紅 ⇒ 清單裡混進了整類放行(`Bash(python3 *)`)或少了正門工具
|
||||
- B3/B4 紅 ⇒ `~` 沒換成那台的家目錄——雲端是 `/root` 不是 `/Users/youlinhsieh`,規則會對不上
|
||||
- B7 紅 ⇒ 不冪等,每個 session 都改一次 settings.json
|
||||
- **C1 紅 ⇒ 動到了別人手加的規則**。這支只准加不准減
|
||||
- E1 紅 ⇒ 目標壞掉時炸了——它掛在 SessionStart,炸了 session 就沒閘
|
||||
- F1/F2 紅 ⇒ 沒接線:清單對了但沒人會去跑它
|
||||
|
||||
⚠️ **這支驗不了「分類器真的不擋」**——那要在雲端一趟 run 裡看 `permission_denials`。
|
||||
規則的形狀(`Bash(python3 …/isep/*/scripts/ticket *)`)沿用 leo 09-07 親手加、實測有效的那四條。
|
||||
|
||||
### A34 — 收件 repo 寫 `owner/repo` 不該是 404:7 條
|
||||
```
|
||||
bash scripts/test-ticket-repo-arg.sh
|
||||
```
|
||||
**該看到**:`通過 7 條,失敗 0 條`。把 `scripts/ticket` 當模組載進來、`api()` 換成只記路徑的假貨——
|
||||
不打 Gitea、不開票、不留戳記。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#130 → comment 6120 第 2 件):09-07 `ticket new inkstone/ISEP`
|
||||
回 404。不是 ISEP 不存在,是 `inkstone/ISEP` 被原樣塞進 `/repos/inkstone/{repo}/issues`
|
||||
⇒ 打到 `/repos/inkstone/inkstone/ISEP/issues`。**正門壞了人就走側門**——那天四張票是直接打 API 開的。
|
||||
|
||||
**失敗**:② 紅 ⇒ 那個 404 復發;③ 紅 ⇒ 寫錯 org 又變成一個要人猜的 404;⑦ 紅 ⇒ 路徑裡出現 `inkstone/inkstone`。
|
||||
|
||||
### A35 — 雲端唯一通的通知路(Bot API 直送):10 條
|
||||
```
|
||||
bash scripts/test-isep-notify-botapi.sh
|
||||
```
|
||||
**該看到**:`10/10 通過`。Bot API 指到本機一個假伺服器、實例那條也指到它——不打真 Telegram、不打實例。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#130,2026-09-07 從雲端實測):
|
||||
```
|
||||
notify_leo @ leo21c → HTTP 404「找不到 workflow」(08-28 起就斷;修它是 leo 的人閘)
|
||||
notify_leo @ youlin → 雲端 egress proxy 回 403(policy denial;要 leo 在 Cloud environment 放行主機)
|
||||
api.telegram.org → 302,通
|
||||
```
|
||||
⇒ 雲端唯一走得通的是 Bot API 直送,只缺 `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` 兩個環境變數。
|
||||
有就先走它(不碰實例,閘管不到也不必管);沒有要**點名缺哪兩個**,不能靜默。
|
||||
|
||||
**失敗**:A1 紅 ⇒ 沒憑證時不講缺什麼,leo 不知道要在 Cloud environment 加哪兩個;
|
||||
B3 紅 ⇒ 送到了還去打實例;C1 紅 ⇒ 401 被講成送到;D1 紅 ⇒ 舊版閘 block 時直送也被連坐。
|
||||
|
||||
### A36 — 雲端對 stage 的寫入有正門,而且只寫得進 stage:45 條
|
||||
```
|
||||
bash scripts/test-stage.sh
|
||||
```
|
||||
**該看到**:`45/45 通過`。CF API 與 stage 實例都指到本機一個假伺服器——不打真 Cloudflare、不碰任何 worker。
|
||||
假伺服器對 secret PUT 回 **201**(真 CF 就是 201)。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#137,2026-09-07 三條主線工人在雲端撞的同一面牆):
|
||||
```
|
||||
printf … | npx wrangler secret put … ← 分類器擋(複合指令對不上白名單任何一條前綴)
|
||||
python3 scripts/stage-deploy-artifacts.py all --confirm ← 分類器擋
|
||||
curl -X POST …arcrun-yuga3bse.workers.dev/records ← 分類器擋;0.21.0 的 prod-write-guard 又當它是 prod
|
||||
```
|
||||
⇒ `scripts/stage`:`secret put/list/delete`(直接打 CF API,不經 npx)與 `api <METHOD> <worker>/<path>`
|
||||
(curl 打實例,標頭與 body 走 stdin,不走 argv)。**只寫 stage**:secret 先 `GET /accounts` 看這把 token
|
||||
打得到誰、URL 永遠只帶 `1129efd7…`;api 的主機只會長在 `*.arcrun-yuga3bse.workers.dev`。
|
||||
值只從環境變數名讀(`--from-env`/`--bearer-env`/`--header-env`/`--data-env`),`--value`/`--bearer`/`--header` 一律拒收。
|
||||
Arcrun 的 `stage-deploy-artifacts.py` 自己已寫死只認 youlin 帳號 ID、不收 `--account`/`--token`,
|
||||
白名單直接放它從 Arcrun 根目錄跑的相對形狀,不重造一支。
|
||||
|
||||
**失敗**:
|
||||
- A1/B1/B2/D1/D2 紅 ⇒ **非 stage 也寫得進去**——這是本工具存在的唯一理由
|
||||
- A4/D3 紅 ⇒ 值可以從指令列進來(會進 shell 歷史與 session log)
|
||||
- C1 紅 ⇒ 又把 201 講成失敗(第一次實跑就是這樣:CF 種好了、工具說失敗)
|
||||
- C7/C9 紅 ⇒ 只信 PUT 的回應不去列
|
||||
- **E2 紅 ⇒ ISEP 自己的閘誤攔正門**——誤攔比漏擋嚴重,被擋的人會去走側門
|
||||
- E4 紅 ⇒ 又長出第二支平行的工具
|
||||
|
||||
**真實跑(2026-09-07,雲端 session,isep-hand)**:
|
||||
`stage secret put` 把隨機值的 `ISEP_137_PROBE` 種進 youlin `arcrun-cypher-executor` → 不經工具用 CF API 列到
|
||||
`["ISEP_137_PROBE","KBDB_INTERNAL_TOKEN"]` → `stage secret delete` → 再列剩 `["KBDB_INTERNAL_TOKEN"]`(stage 恢復原狀)。
|
||||
`stage api GET arcrun-cypher-executor/health` → HTTP 200;`stage api POST arcrun-kbdb/map/recompute…` 不帶 Bearer → HTTP 401、離開碼 1(打到了、沒寫);
|
||||
完整網址指到 leo21c → 拒絕、離開碼 2、零請求。
|
||||
⚠️ **帶 Bearer 真的寫進 stage KBDB** 這格沒跑——雲端 Cloud environment 沒有 `KBDB_INTERNAL_TOKEN`(它只住在安裝器中心側 KV,Arcrun#176 comment 6487 步驟 0);
|
||||
`CF_SECRETS_API_TOKEN` 的真值是主線 B-3(Arcrun#86)的東西,由總管用 `stage secret put` 種,本票不編一個假的塞進去。
|
||||
⚠️ 「分類器真的不擋」與 A33 同款:要下一版裝進雲端之後,看一趟 run 的 `permission_denials`。
|
||||
|
||||
### A37 — 打了 tag 之後那一站:把 tag 建成 leo 看得到的 release:14 條
|
||||
```
|
||||
bash scripts/test-release-ship.sh
|
||||
```
|
||||
**該看到**:`共 14 項:✅ 14 / ❌ 0`。全程離線(`RELEASE_SHIP_FIXTURE`+`RELEASE_SHIP_SINK`),
|
||||
**不打 Gitea、不建任何真 release**。fixture 的形狀見 `hooks/lib/release_chain.py`。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#67`):ISEP 全樹沒有任何東西會建 release,於是 tag 推了、
|
||||
Releases 頁停在舊版——08-27(v0.4.0/v0.5.0)、09-02(v0.22.0)、09-07(v0.23.0/v0.24.0)
|
||||
同一站斷了三輪;09-07 那次交棒給 leo 的「測試位置」打開是一頁空 tag。
|
||||
三個前置**必須在場**才放行:① **那個 tag 的樹**裡 `plugin.json` = tag 號碼(問 Gitea `contents?ref=<tag>`,
|
||||
**不是工作樹**)② tag 真的在 Gitea ③ note 有內容(沒給就從 Gitea compare 生事實清單)。
|
||||
建完回頭跑 `release-check <那個 tag>` 拿收貨端覆核——送的人說成功不算數。
|
||||
|
||||
**失敗**:
|
||||
- A1 紅 ⇒ tag 的樹自己講錯版本也建得了,leo 裝到的會各說各話
|
||||
- B4 那三條任一紅 ⇒ **前置①又回去比「最新的 tag」或工作樹了**——總管 09-07 在 v0.23.0 的 checkout
|
||||
跑 `release-ship v0.23.0` 就是這樣被擋的(訊息說最新是 v0.24.0),這一組工作樹故意擺 `9.9.9`
|
||||
- B2 第二條紅 ⇒ 已存在的 release 被說成「已建立」(09-05 comment 6207 那句假綠,scout 6213 抓到的)
|
||||
- B3 第二條紅 ⇒ `--dry-run` 真的寫了東西
|
||||
|
||||
**真實跑(2026-09-07,isep-hand,全部唯讀)**:工作樹在本分支(plugin.json 0.24.0)跑
|
||||
`release-ship v0.23.0 --dry-run` → 前置②✅、前置①「tag v0.23.0 的樹裡 plugin.json = 0.23.0」✅、
|
||||
「本來就在了——不重複建、不動它」、覆核 `v0.23.0 ✅✅✅ ←`、exit 0(09-05 那版在同一情境是 exit 2);
|
||||
`release-ship v0.3.4 --dry-run`(現存唯一沒 release 的 tag)走到「要建」那條路:note 從 Gitea compare 生 8 行、不 POST、exit 0。
|
||||
|
||||
### A38 — 回頭問收貨端「到了嗎」,而且開場就會問:14 條+信標 7 條
|
||||
```
|
||||
bash scripts/test-release-check.sh
|
||||
bash hooks/tests/isep-presence-beacon.test.sh # ㉗–㉝ 那七條
|
||||
```
|
||||
**該看到**:`共 14 項:✅ 14 / ❌ 0`;信標 `通過 33 條,失敗 0 條`。全離線。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#67` → comment 6574,總管 09-07 裁定②):「打了 tag 卻沒 release」
|
||||
要在**下一個 session 開場**就被報出來(一行:缺哪一站+怎麼補),不靠人記得;兩者都在時安靜。
|
||||
`release-check` 逐版問 Gitea(匿名,D20),現行版本缺站 ⇒ exit 2 +指名缺哪一站+補法(正本絕對路徑);
|
||||
更舊版本缺件只黃字;**v0.19/0.20/0.21 已裁定不補**(同一則 comment 裁定①),表上標出處、不再黃字。
|
||||
信標 ④ 用同一份判準(`hooks/lib/release_chain.py`):最新 tag 沒 release ⇒ 報;main 定版比最新 tag 新 ⇒ 報缺 tag;
|
||||
只快取「都在」6 小時,缺件不快取——補完的下一個 session 就該安靜。
|
||||
|
||||
**失敗**:
|
||||
- A1/A2 紅 ⇒ 驗收 4 沒過:故意漏一站,管線說不出缺哪一站
|
||||
- A3 紅 ⇒ `release-check <版本>` 沒看你指定的那一版(release-ship 送完覆核會覆核到錯的版本)
|
||||
- B2 紅 ⇒ **誤攔**:現行版本好好的,舊版缺件卻擋下來(懲罰謹慎)
|
||||
- B4 紅 ⇒ 裁定過不補的三版又天天黃字(會被學會忽略);或裁決把事實也改掉了(指定它來問應該照擋)
|
||||
- 信標 ㉗ 紅 ⇒ 開場報不出來,斷點又回到靠人記得;㉙ 紅 ⇒ **誤攔**:都在還念;
|
||||
㉚ 紅 ⇒ 問不到收貨端時亂猜;㉜ 紅 ⇒ 印了一條在這一份裡不存在的路徑當出路;㉝ 紅 ⇒ 補完還要念 6 小時
|
||||
|
||||
**真實跑(2026-09-07)**:信標不帶 fixture 打真 Gitea(匿名)→ 安靜(v0.24.0 tag+release 都在),
|
||||
留下 `.isep-chain-ok-v0.24.0` 快取;`release-check` 在本分支 → `v0.24.0 ✅✅✅ ←`、三個裁定不補的版本標 ⏭、exit 0。
|
||||
|
||||
### A39 — D20 閘判「push 到哪」看的是指令實際會推的那個 repo:26 條
|
||||
```
|
||||
bash scripts/test-github-contact-guard.sh
|
||||
```
|
||||
**該看到**:`26/26 通過`。離線;後 12 條自己開兩顆 git repo(`shell` 的 origin=github.com、
|
||||
`body` 的 origin 是 Gitea 的形狀),payload 帶 `cwd`,跟 hook 真的會收到的一樣。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#109` → comment 6629,2026-09-07 實撞):雲端薄殼的 cwd 是 GitHub 那份,
|
||||
總管站在那裡 `git -C <InkStoneCo> push origin x`(InkStoneCo 的 origin=Gitea),舊版拿 **hook 收到的 cwd**
|
||||
解 `origin` ⇒ 判成「指向 GitHub」擋下,`-C` 看都沒看;arcrun-hand 同日在 `Arcrun#176` comment 6614 撞到同一支。
|
||||
現在 remote 名在**這條指令實際會推的那個 repo** 裡解(沿用 `hooks/lib/push_target_dir.py`:cd 鏈、`-C`、
|
||||
子殼不外洩,推 main 那道閘 08-23 就為同一個病接的它);判準仍是 remote 解出來的 URL 主機,**不是「有 -C 就放行」**。
|
||||
修之前拿新測試跑舊閘:**7 條紅**——4 條是誤攔(推 Gitea 被擋),**3 條是漏擋**(站在 Gitea 那份 `-C` 到薄殼推 GitHub,舊版放行)。
|
||||
|
||||
**失敗**:
|
||||
- 「不該擋」6 條任一紅 ⇒ **誤攔**:雲端推 Gitea 分支又得改用完整網址繞路,久了整道閘沒人當真
|
||||
- 「該擋」5 條任一紅 ⇒ D20 有洞:`-C`/`cd` 指到 GitHub 那份的 push 沒被攔
|
||||
- `(cd body && true); git push` 那條紅 ⇒ 子殼的 cd 外洩了(08-11 穿透的形狀)
|
||||
- 前 14 條紅 ⇒ 舊有判準(heredoc/引號裡的散文/讀取放行)被改壞
|
||||
|
||||
### A37 — 雲端第一則 prompt 的主線就是現在這條,不是兩週前的舊線:31 條
|
||||
```
|
||||
bash hooks/tests/mainline-gitea-fallback.test.sh
|
||||
```
|
||||
**該看到**:`通過 31 條,失敗 0 條`。全程離線:Gitea 是本機一個假伺服器(`TICKET_HOST` 指過去),
|
||||
它照真 Gitea 回——public 時 raw 端點匿名 200;**private 時匿名 404、帶 token 200**(真 `inkstone/InkStoneCo`
|
||||
就是 private,2026-09-07 實測匿名打 raw/contents/repo API 都 404)。狀態走 `ISEP_COUNTDOWN_STATE_DIR`、
|
||||
repo 那份走 TMP 底下的假薄殼。
|
||||
|
||||
**它在守什麼**(inkstone/ISEP#140;母票 #130):薄殼 `youlinhsieh/inkstoneco` 的 SessionStart 跑在 `bootstrap.sh`
|
||||
之前——`InkStoneCo/` 還沒 clone、家目錄又是空的 ⇒ 兩份主線檔都不在 ⇒ `countdown.py` 退到「期限最近的 milestone」
|
||||
⇒ 第一則 prompt 印 `🔴 主線 把管理這條線做對 已逾期 341 小時`+`🎯 現在沒有主線`。機制沒壞,是順序。
|
||||
⇒ `scripts/mainline refresh`(SessionStart)在兩份都不在時去 Gitea 讀 `inkstone/InkStoneCo` main 的
|
||||
`system-dev/mainline.json` **一次**、寫進家目錄;之後每一則 UserPromptSubmit 讀家目錄,路徑上照舊零網路。
|
||||
優先序寫死在 `hooks/lib/mainline.py` 檔頭:**家目錄 > repo 那份 > Gitea 那份**,不是第二套來源。
|
||||
|
||||
**失敗**:
|
||||
- ①② 紅 ⇒ 前置狀態測不到(票上撞到的那個畫面重現不了,後面全是假綠)
|
||||
- ⑥⑦⑧ 紅 ⇒ 第一則 prompt 仍是「沒有主線」或那條逾期的舊線——本票要解的就是這一格
|
||||
- ⑨⑲ 紅 ⇒ 一上來就實名(讀的形狀是匿名優先,被拒才帶 token)
|
||||
- ⑬ 紅 ⇒ **每一則訊息的路徑上打了網路**(`load()`/`line()` 從不打網路這條被弄壞)
|
||||
- ⑭⑳㉒ 紅 ⇒ 家目錄或 repo 那份在場還去問 Gitea,或讀 repo 那份時順手寫了家目錄(A32 ④)
|
||||
- ⑮⑯⑰㉓㉔㉕㉖㉗ 紅 ⇒ 讀不到時不是安靜退回:炸、掛住、或往 stderr 抱怨(SessionStart 每次都會看到)
|
||||
- ㉘㉙ 紅 ⇒ bootstrap 之後 repo 那份出現時,`set_at` 規則(A32 ⑤⑦)被本票弄壞
|
||||
|
||||
**真實跑(2026-09-07,雲端 session,isep-hand)**:薄殼形狀(空家目錄、`$CLAUDE_PROJECT_DIR` 是沒有 `InkStoneCo/`
|
||||
的空目錄)——環境有 `GITEA_TOKEN_CLAUDE_CODE`:`mainline refresh` 2.06 秒、rc 0,`mainline` 印
|
||||
`inkstone/Arcrun#48「AI 問一次就看得到全部…」`,UserPromptSubmit 餵 `countdown-guard.sh` 印
|
||||
`⏱ …|主線 AI 問一次就看得到全部… 剩 137 小時 41 分` 與 `🎯 主線:inkstone/Arcrun#48…`;
|
||||
拔掉 token:rc 0、家目錄零檔案、印「現在沒有主線」、薄殼目錄仍是空的。
|
||||
⚠️ **「新開一個雲端 session、不跑任何指令、第一眼就是它」**這格要下一版裝進雲端才驗得到——
|
||||
前提是 Cloud environment 有 `GITEA_TOKEN_CLAUDE_CODE`(本 session 有),沒有就跟 0.25.0 一樣退到舊線。
|
||||
|
||||
### A40 — 一個 repo 只有一個資料夾:`scripts/worktree`(sweep 現場整理):42 條
|
||||
```
|
||||
bash scripts/test-worktree.sh
|
||||
```
|
||||
**該看到**:`通過 42 條,失敗 0 條`。全程離線:遠端是本機 bare repo,不碰任何真的 repo。
|
||||
|
||||
**它守什麼**(inkstone/ISEP#147):leo 在 Mac 上跑一次的整理工具。fixture 照他 09-07 在 Finder 看到的那排分身
|
||||
**原樣造出來**(`course_gen-ticket-3-notes`/`course_gen-ticket-4-handouts`/`ax-courses-ticket1`/`ISEP-wt-122ship`/
|
||||
`ISEP-wt-117`/`ISEP-wt115`/`InkStoneCo-wt-112`,各自處境不同:推了乾淨、沒推、髒、登記指向空氣),
|
||||
外加一份新家 `.worktrees/` 裡的與一個沒人登記的孤兒目錄。
|
||||
|
||||
- **A dry-run**((1)–(13)):每份落在對的組、沒推的印分支與推的指令、`--skip course_gen` 那兩份在「不碰」組、**目錄樹快照前後相同**。
|
||||
- **B --apply**((14)–(27)):只收「推了且乾淨」的 3 份+prune 空氣登記;沒推的、髒的、`--skip` 的、孤兒的一根寒毛沒動;收掉的分身**分支還在**;第二次冪等。
|
||||
- **C 判準是事實不是名字**((28)–(32)):不 `--skip` 時 `course_gen-*` 照 git 的事實分組;拿掉 remote ⇒ 落在「問不到遠端」、**不當成沒推、也不收**。
|
||||
- **D open/close/list**((33)–(42)):open 的路徑在 `<repo>/.worktrees/`、主 repo `git status` 乾淨、`tech_projects/` 沒多任何資料夾;close 沒推 ⇒ 2 點名,推了 ⇒ 0 目錄沒了,再 close ⇒ 0 不吵。
|
||||
|
||||
**失敗**:
|
||||
- (2)(30) 紅 ⇒ **dry-run 動了東西**——leo 跑第一次「只是看看」就已經被改了現場,那是災難。
|
||||
- (19)–(23) 任一紅 ⇒ 收錯東西:刪了沒推的 commit/動了 `--skip` 的 repo/碰了不明目錄。**不刪任何有未推 commit 的分身**是票上紅線。
|
||||
- (28)(29) 紅 ⇒ 判準滑回看名字(`course_gen` 被當關鍵字),違反本 repo 紅線。
|
||||
- (31)(32) 紅 ⇒ 把「離線」當成「沒推」——拿雜訊懲罰謹慎。
|
||||
|
||||
|
||||
### A41 — 主線成員由相依邊決定,不靠同名里程碑:57 條
|
||||
```
|
||||
bash hooks/tests/mainline-members-by-dependency.test.sh
|
||||
```
|
||||
**該看到**:`通過 57 條,失敗 0 條`。**全程離線**:Gitea 是本機一個假伺服器(`TICKET_HOST` 指過去,
|
||||
照真 Gitea 回:相依 POST 201、**已存在回 500**(2026-09-07 實測,不是想像中的 409)、issue 列表用 `milestones=` 名字查),狀態走
|
||||
`ISEP_COUNTDOWN_STATE_DIR`、repo 那份走 TMP 底下的假薄殼、`HOME` 也指到 TMP。
|
||||
**不碰真家目錄、不碰真 Gitea、不碰真 InkStoneCo。**
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#133` → comment 6743):`scripts/mainline` 第一版檔頭寫
|
||||
「跨 repo 同名是既有做法,不動它」,成員也照標題跨 repo 收;總管 09-07 照它做、把那句抄進
|
||||
InkStoneCo 的 ops-facts、還在 arcrun-rag 補建了一個同名的。leo 當天:
|
||||
「**拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone,不同 repo 用指針**」
|
||||
(規則本身:`inkstone/ISEP#30`、`inkstone/InkStoneCo#44`)。
|
||||
|
||||
現在的判準全部是 Gitea 的欄位,沒有標題比對:
|
||||
|
||||
```
|
||||
錨 owner/repo#<milestone id>:整個 Gitea 只有這一個 milestone 物件
|
||||
hub 票 mainline set … --hub <owner/repo#N>:這條線的載體票,別 repo 的票用相依指到它
|
||||
成員 hub 里程碑裡的票 + hub 票,沿 /dependencies 邊(跨 repo)收下去(hooks/lib/mainline.py::collect_members)
|
||||
adopt 同 repo ⇒ 掛進 hub 里程碑;別 repo ⇒ POST 相依到 hub 票(或 --via 指定的成員)。不建同名里程碑
|
||||
```
|
||||
|
||||
**失敗**:
|
||||
- A 群 ③④⑤ 任一紅 ⇒ 相依邊沒走到(跨 repo 成員收不到)
|
||||
- A 群 ⑥⑦ 紅 ⇒ **同名里程碑裡的票又被當成成員**——被推翻的判準回來了
|
||||
- ⑪ 紅 ⇒ `set` 又去別的 repo 找同名的了
|
||||
- ⑮⑯⑰ 任一紅 ⇒ `adopt` 別 repo 的票不是加相依:要嘛改了那張票的 milestone、要嘛建了里程碑
|
||||
- ⑳⑳′ 紅 ⇒ 重複 adopt 炸了或重複 POST 了(冪等要靠「先讀 /dependencies」,真 Gitea 對重複 POST 回 500)
|
||||
- ㉔–㉙ 任一紅 ⇒ 沒 hub 票時沒擋、沒給路、或給的路**貼上去跑不動**(㉙ 把訊息裡那行原樣執行)
|
||||
- ㉛–㉞ 任一紅 ⇒ **票上驗法第 1 條破了**:同名里程碑關掉後成員掉出主線,或 `pick` 抓不到靠邊進來的票
|
||||
- ㊱㊴㊵ 任一紅 ⇒ `has`/focus guard 沒去問 `/blocks`,只看 milestone 就下結論(那是「只知一半」)
|
||||
- ㊹ 紅 ⇒ 派一張掛在別 repo 同名里程碑、沒邊的票被放行 ⇒ 閘還在拿名字判
|
||||
- ㊻ 紅 ⇒ Gitea 讀不到時成員被清掉(讀不到 ≠ 沒有成員)
|
||||
- ㊼–㊿ 任一紅 ⇒ **票上驗法第 3 條破了**:帳本落在 plugin 快取(`claude plugin update` 一換就沒)
|
||||
|
||||
📌 **真實 Gitea 唯讀實跑**(2026-09-07,isep-hand,state 與鏡像都指到暫存目錄,不寫 Gitea):
|
||||
`mainline set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44` 收到 **25 張成員、5 個 repo**
|
||||
(里程碑裡 4 + 相依收到 20),其中 `mira#6`、`ISEP#130`、`ISEP#140`、`arcrun-rag#104` 都在
|
||||
——**不必新增任何邊**,現有的相依圖已經連得到;`has` 四張都回「在」;
|
||||
`ticket pick --all` 主線組回 `Arcrun#197`、`mira#5`(後者靠 `Arcrun#86` 的邊,沒掛任何 hub 里程碑)。
|
||||
**寫入那半也真跑過一次**:`adopt inkstone/ISEP#133`(hub=InkStoneCo#44)POST 成功、`/blocks` 回 `InkStoneCo#44`、清空快取後 `has` 靠 `/blocks` 判「在」;重複 POST 真 Gitea 回 **500**(假伺服器原本猜 409——這就是「離線全綠之後一定要真跑一次」),修成先讀 `/dependencies`;測完 DELETE 復原(回 201,`/blocks` 空)。
|
||||
這一走 19 秒(約 30 次序列 GET)⇒ SessionStart 的 `refresh` **不帶** `--members`(成本不變),
|
||||
`set`/`adopt`/`refresh --members` 才重收;`pick`/`has`/閘每次現問 Gitea,快取只餵 `show`。
|
||||
|
||||
### A5 — 開票前的搜尋是跨 repo 的
|
||||
```
|
||||
python3 scripts/ticket where 標籤 模組化
|
||||
@@ -464,11 +1195,11 @@ bash hooks/tests/dispatch-format-guard.test.sh
|
||||
- **F 群㉚–㉟ 任一紅 ⇒ 誤攔**。㉚特別重要:subagent 往上回報(`to: "main"`)是**交件**,
|
||||
擋它等於擋掉交件本身;㉟反過來,子 session 自己往下派工時規矩要照樣管它,不然是逃逸艙口
|
||||
|
||||
### A16 — 工人有名字:指名派工、沒有這個人、注入它的檔案:24 條
|
||||
### A16 — 工人有名字:指名派工、沒有這個人、注入它的檔案:31 條
|
||||
```
|
||||
bash hooks/tests/roster-guard.test.sh
|
||||
```
|
||||
**該看到**:`24 通過 / 0 失敗`。**離線、不打網路、不花錢**——純結構判斷(名字在不在名單裡),
|
||||
**該看到**:`31 通過 / 0 失敗`(本頁先前寫 24,實跑是 25,⑲ 加進來時沒回頭改)。**離線、不打網路、不花錢**——純結構判斷(名字在不在名單裡),
|
||||
沒有語意判官,每次結果一樣。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#86`):「身為派工的人,我要工人有名字,
|
||||
@@ -484,6 +1215,10 @@ bash hooks/tests/roster-guard.test.sh
|
||||
- ⑯⑰ 紅 ⇒ 注入壞了。那是「你是誰/先讀什麼/你的紅線」唯一的送達方式——
|
||||
它壞了不會有人立刻發現,派工照樣送出去,只是工人不知道自己是誰
|
||||
- ⑱ 紅 ⇒ 驗收 3 沒過:票上的【身份】欄吃不下工人名字(或把原本三個角色弄壞了)
|
||||
- ⑳㉑㉒ 紅 ⇒ `inkstone/ISEP#118` 沒過:課程三個 repo 找不到 `course-hand`,
|
||||
或用真實形狀 `isep:course-hand` 派工被擋 ⇒ 派 ax-courses 又要開例外閘
|
||||
- ㉓ 紅 ⇒ 一個工人管多個 repo 的解析壞了(只認第一個),或「有票卻沒工人」的判準
|
||||
開始點名沒票/封存的 repo(誤報)。2026-09-14 突變驗過:拿掉票數條件、只認第一個 repo,這條都會紅
|
||||
|
||||
### A17 — 未經調查不寫診斷:26 條
|
||||
```
|
||||
@@ -553,6 +1288,57 @@ claude -p '請執行 git tag -a v9.9.9 -m test'
|
||||
> 為什麼挑 `release-tag-guard` 當考題:它**只存在於 ISEP**,舊的 `.claude/` 那份沒有。
|
||||
> 用它才分得出「載到的是 plugin」還是「載到的是舊的」。
|
||||
|
||||
### A8b — 這台**每一個** Claude Code 都真的載入了 ISEP:離線 11 條+實機 3 條
|
||||
```
|
||||
python3 scripts/isep-doctor # 看這台現況
|
||||
bash scripts/test-isep-doctor.sh # 離線:假的 claude 可執行檔
|
||||
bash scripts/test-isep-doctor-live.sh # 實機:真的 CLI 與 Desktop 內建那一份,隔離 HOME,不動 ~/.claude
|
||||
```
|
||||
**該看到**:`isep-doctor` 逐一列出 CLI 與 Desktop,各一行 ✅;離線 `pass=11 fail=0`;實機 `pass=3 fail=0 skip=0`。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#150`):plugin 沒載入時,**會講話的信標本身也沒載入**,
|
||||
所以只能從 plugin 外面問。而同一台 Mac 有**不只一個** Claude Code,它們讀同一份 `~/.claude` 卻不一定同一個結論:
|
||||
`extraKnownMarketplaces.inkstone.source` 多了 `path` 時,**Desktop 內建的 2.1.266 整個忽略 marketplace、CLI 2.1.220 照常載入**
|
||||
(c6991 兩份 debug log;本支實機測試在隔離 HOME 重現)。第一版 doctor 看設定長相就對整台判 ❌——在 CLI 上是假陽性。
|
||||
⇒ 判準是**每一個 Claude Code 自己 `plugin list --json` 回報的 errors**;設定比對只當解釋,永遠不單獨判 ❌。
|
||||
|
||||
**失敗**:
|
||||
- ① 紅 ⇒ **誤攔**,又回到看設定長相判 ❌。鑑別力實跑過:拿 8eb4c58 那版跑這份測試是 `pass=1 fail=10`,
|
||||
大多是輸出格式不同;**真正分得出判準對錯的是 ①**——舊版在「CLI 實際載入」時照樣 exit 1 並點名 path
|
||||
- ② 紅 ⇒ 分不出哪一個 surface 壞了,或又把整台機器講成同一個結論
|
||||
- ④ 紅 ⇒ 別的原因失敗時編了一個 path 原因出來
|
||||
- ⑥⑦ 紅 ⇒ 「查不了」被講成 ✅ 或 ❌(讀不到不等於沒載入)
|
||||
- ⑧⑧b 紅 ⇒ 印出來的修法照著貼走不通
|
||||
- ⑨ 紅 ⇒ Desktop 版本用字串排序,`2.1.99` 會被當成比 `2.1.266` 新
|
||||
- 實機測試印 SKIP ⇒ 這台的版本不是 2.1.220/2.1.266,行為可能不同,那一格**沒驗過**
|
||||
|
||||
### A42 — 票上回碼解的保險,寫在出貨線讀得到的地方:18 條
|
||||
```
|
||||
bash hooks/tests/gitea-arm-to-github-armed.test.sh .
|
||||
```
|
||||
**該看到**:`通過 18 條,失敗 0 條`。**全程離線**:在 `mktemp` 裡造一個長得像
|
||||
`~/.claude/plugins/cache/inkstone/isep/<ver>/` 的安裝位置與一個假的專案根,
|
||||
「leo 已核准」由 stub 扮演;fail-closed 那組用**真的** `gitea-arm-check.sh`(沒有待核請求 → 本地就擋)。
|
||||
**不碰真 InkStoneCo、不寫真的 `.github-armed`、不打 Gitea。**
|
||||
(②a 會去讀 `InkStoneCo/products/arcrun-rag/installer/scripts/d20-guard.mjs` 的 `checkArmed`;
|
||||
找不到那個檔或沒有 node 時印 `⏭️ SKIP`,不算失敗,也不算驗過。)
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#156`,09-17 實撞於 `inkstone/arcrun-rag#202` c7598):
|
||||
舊版用 `ROOT=$(dirname $0)/..` 決定保險寫在哪。plugin 發佈後腳本住在 plugin 快取裡
|
||||
⇒ 保險寫進快取、腳本照印「✅ 保險已解」、出貨線讀 `InkStoneCo/.github-armed` 讀不到
|
||||
⇒ leo 回了碼,出貨照樣卡住,總管手動搬檔才通。
|
||||
現在寫在 `${CLAUDE_PROJECT_DIR:-$(pwd)}`——**跟每一個讀它的人同一個位置**。
|
||||
|
||||
📌 拿修之前的腳本跑是 **11 條紅**(2026-09-17 實跑),不是換了寫法就會綠的測試。
|
||||
|
||||
**失敗**:
|
||||
- ① 的兩條 ★ 紅 ⇒ 保險又寫回腳本自己的位置(09-17 那個病)
|
||||
- ② 任一 ★ 紅 ⇒ 檔案寫對地方了,但**讀的人不認得**(格式或位置跟出貨線/閘又分家了);
|
||||
它們的「對照」紅 ⇒ ★ 那條沒有鑑別力
|
||||
- ③ 紅 ⇒ 終端機(沒有 `CLAUDE_PROJECT_DIR`)時跟閘的退回規則不一致
|
||||
- ④ 前兩條紅 ⇒ **沒拿到核准也寫了保險**——這是 D20 等級的洞,最嚴重
|
||||
- ④ ★ 紅 ⇒ 擋下之後教的指令又印回相對路徑(站在 InkStoneCo 會叫到舊複本,`inkstone/ISEP#112` 同病)
|
||||
|
||||
---
|
||||
|
||||
## B. 只有 leo 能跑的(雲端)
|
||||
@@ -576,9 +1362,11 @@ bash scripts/make-cloud-env.sh
|
||||
|
||||
打開上一步產生的檔,裡面兩塊分別貼進 claude.ai → **Cloud environments** → 你的環境:
|
||||
|
||||
1. **Environment variables** 加一個
|
||||
- 名稱:`GITEA_TOKEN_CLAUDE_CODE`
|
||||
- 值:**既有的** claude-code 機器帳號 Gitea token(不要新造一把)
|
||||
1. **Environment variables**:把 ① 區塊**每一行**都貼進去(一行一個,名字與值分開填)。
|
||||
現在是 2 個:`GITEA_TOKEN_CLAUDE_CODE`(機器帳號 Gitea token)與
|
||||
`CLOUDFLARE_API_TOKEN_YOULIN_CC_USE`(youlin = AI 的 stage 實例)。
|
||||
🔴 **不要照這裡的名字數**——以腳本產出的 ① 區塊為準,這份文件會落後。
|
||||
值全部是**既有的**,一把都沒有新造。
|
||||
2. **Setup script** 欄位:貼進 `docs/cloud-setup-script.sh` 的全文,一字不改。
|
||||
|
||||
**該看到**:儲存後沒有紅字。
|
||||
@@ -661,6 +1449,9 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
|
||||
| **A16 跨 repo 版本清單** | 總管 | ✅ 21/21(2026-08-28,inkstone/ISEP#84) |
|
||||
| **A17 估多久/花多久/差多少** | 總管 | ✅ 23/23(2026-08-28,inkstone/ISEP#85) |
|
||||
| **A18 結案要記帳的閘** | 總管 | ✅ 22/22(2026-08-28,inkstone/ISEP#85) |
|
||||
| **A21 沒人會叫的事會自己叫** | 總管 | ✅ 36/36(2026-08-28,inkstone/ISEP#93) |
|
||||
| **A21b Telegram 真的送達** | **leo** | ⛔ **驗不了——通道本身斷了,而且修它是真人閘**(見下) |
|
||||
| **A22 壓 wiki 不准弄丟東西** | 總管 | ✅ 25/25+真跡實壓(2026-08-28,inkstone/ISEP#89) |
|
||||
| A5 搜尋跨 repo | 總管 | ✅ |
|
||||
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
|
||||
| **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) |
|
||||
@@ -671,13 +1462,82 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
|
||||
| **A17 未經調查不寫診斷(ISEP#87)** | 總管 | ✅ 26/26(2026-08-28) |
|
||||
| **A11 派工單只剩票號** | 總管 | ✅ 19/19(2026-08-27) |
|
||||
| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/11(2026-08-27) |
|
||||
| **A23 同時跑的線有上限** | isep-guard | ✅ 33/33(2026-08-29,inkstone/ISEP#109) |
|
||||
| **A24 一條線要有自己的工作目錄** | isep-guard | ✅ 35/35(2026-08-29,inkstone/ISEP#109→5391) |
|
||||
| **A24 出路那一行原樣餵回去走得通+#125 A–F** | isep-hand | ✅ 77/77(2026-09-07,inkstone/ISEP#109→6587、#125)+真路徑實跑(薄殼 cwd、`-C ISEP`:擋→印的那行→放行)+舊閘實跑 3 條紅 |
|
||||
| **A39 D20 閘看 push 實際會推的 repo** | isep-hand | ✅ 26/26(2026-09-07,inkstone/ISEP#109→6629)+真路徑實跑(薄殼 cwd:`-C InkStoneCo` 放行、裸 `git push origin` 擋)+舊閘實跑 7 條紅(4 誤攔+3 漏擋) |
|
||||
| **A24 分身住 repo 裡面+收工被收(G/H 群)** | isep-hand | ✅ 110/110(2026-09-07,inkstone/ISEP#147)+舊閘實跑 20 條紅(訊息+G/H,B/D/E/F 全綠)+本線自己就是 dogfood(分身開在 `ISEP/.worktrees/ISEP-147`,主目錄 `git status` 乾淨) |
|
||||
| **A40 sweep 現場整理(七個分身的 fixture)** | isep-hand | ✅ 42/42(2026-09-07,inkstone/ISEP#147);**leo 的 Mac 現場**要他自己跑 `sweep ~/tech_projects`(先 dry-run)——這台機器碰不到 |
|
||||
| **A42 票上回碼的保險寫在出貨線讀得到的地方** | isep-hand | ✅ 18/18(2026-09-17,inkstone/ISEP#156);修之前的腳本 11 條紅 |
|
||||
| **A26 雲端憑證清單這台拿得到** | isep-hand | ✅ 11/11(2026-09-01,inkstone/ISEP#115→5526/5541/5542/5577)+兩條「該紅」方向實跑 |
|
||||
| **A16 放行的門真的打得開** | 總管 | ✅ 17/17(2026-08-28,inkstone/ISEP#90) |
|
||||
| **A17 未推警察不誤攔雲端分支** | 總管 | ✅ 10/10(2026-08-28,inkstone/ISEP#90) |
|
||||
| **A18 信標會報雲端接線缺陷** | 總管 | ✅ 14/14(2026-08-28,inkstone/ISEP#90) |
|
||||
| **A19 下游做完頂層跟著關** | 總管 | ✅ 49/49(2026-08-28,inkstone/ISEP#92) |
|
||||
| **A31 雲端工人抓票規則機械可判** | isep-hand | ✅ 48/48(2026-09-07,inkstone/ISEP#131)+真實 Gitea 唯讀實跑+一次真認領 |
|
||||
| **A20 舊票有固定管道被撈** | 總管 | ✅ 47/47(2026-08-28,inkstone/ISEP#83)+真實 229 張唯讀實跑 |
|
||||
| **A31 leo21c 讀放寫擋+改法指活的 youlin** | isep-hand | ✅ 26/26(2026-09-07,inkstone/ISEP#130)+舊閘實跑 5 條紅 |
|
||||
| **A32 主線檔隨 repo 走** | isep-hand | ✅ 17/17(2026-09-07,inkstone/ISEP#130)+雲端 session 實跑讀到主線 |
|
||||
| **A33 權限白名單住 ISEP 一份** | isep-hand | ✅ 19/19(2026-09-07,inkstone/ISEP#130);「分類器真的不擋」要雲端 run 的 `permission_denials` 才驗得到 |
|
||||
| **A34 收件 repo 寫 owner/repo 不 404** | isep-hand | ✅ 7/7(2026-09-07,inkstone/ISEP#130) |
|
||||
| **A35 Bot API 直送** | isep-hand | ✅ 10/10(2026-09-07);**真的到 leo 手機**要 Cloud environment 先有 `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID`(leo) |
|
||||
| **A36 雲端對 stage 的寫入有正門、只寫得進 stage** | isep-hand | ✅ 45/45(2026-09-07,inkstone/ISEP#137)+真實種進 youlin 一把探針再刪掉、真打 stage GET 200/無 Bearer POST 401;「帶 Bearer 真寫 KBDB」缺 `KBDB_INTERNAL_TOKEN` 沒跑 |
|
||||
| **A37 雲端第一則 prompt 的主線是現在這條** | isep-hand | ✅ 31/31(2026-09-07,inkstone/ISEP#140)+真 Gitea 薄殼形狀實跑(帶 token 讀到 Arcrun#48;匿名退回);「新 session 不跑指令第一眼就是它」要下一版裝進雲端 |
|
||||
| **A37 tag → release 那一站** | isep-hand | ✅ 14/14(2026-09-07,inkstone/ISEP#67→6574)+真 Gitea 唯讀 dry-run 兩次(v0.23.0 總管被擋的情境、v0.3.4 走到建那條路) |
|
||||
| **A38 回頭問收貨端+開場會問** | isep-hand | ✅ 14/14+信標 33/33(2026-09-07,inkstone/ISEP#67→6574)+信標真 Gitea 匿名實跑一次安靜 |
|
||||
| A7 plugin 裝得起來 | 總管 | ✅ |
|
||||
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
|
||||
| **B1–B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment) |
|
||||
|
||||
**A8 與 B 全綠之前,這個 sprint 的里程碑不准關。**
|
||||
|
||||
---
|
||||
|
||||
## ⛔ 已知斷點:`notify_leo` 這條 Telegram 通道現在是斷的(2026-08-28 實測)
|
||||
|
||||
`inkstone/ISEP#93` 的驗收第 4 條是「**Telegram 真的收得到**(不是『送出成功』,
|
||||
是 leo 手機上真的出現)」。**這一格現在過不了,而且原因不在本次的程式碼。**
|
||||
|
||||
實測(`scripts/isep-notify`,閘判定 `pass`、UA 帶對之後):
|
||||
|
||||
```
|
||||
POST …/webhooks/named/leo/notify_leo/trigger
|
||||
→ HTTP 404 {"error":"找不到 workflow \"notify_leo\",請先執行 acr push"}
|
||||
```
|
||||
|
||||
⇒ **那台線上實例上沒有 `notify_leo` 這支工作流。** 不是閘擋的、不是網路、不是金鑰
|
||||
(這條路本來就不需要金鑰)。頂層 wiki `agent-memory.md` 記過同一件事發生過一次
|
||||
(2026-08-10,KV 整批換新時定義消失,後來補推回去)——**看起來又斷了一次**。
|
||||
|
||||
🔴 **總管 2026-08-28 用 MCP 直接問那台實例,範圍比這裡查到的更廣**:
|
||||
|
||||
```
|
||||
arcrun_get_workflow(notify_leo) → not_found
|
||||
arcrun_search_workflows(通知 leo Telegram) → 只回 ship_refresh_cdn 一支
|
||||
而且它的執行紀錄寫著「圖定義已失效」
|
||||
```
|
||||
|
||||
⇒ **不只 `notify_leo` 不見了,那台實例上的工作流定義是整批失效的。**
|
||||
|
||||
修法要有人把 `mira` repo 的 `workflows/notify-leo.yaml` 重新 push 上那台實例。
|
||||
**這不是「總管可以解的閘」——它比那更嚴格**:那台是 leo 的個人帳號,
|
||||
而 `leo21c-write-guard.sh`(leo 2026-08-20 立)明文禁止寫它
|
||||
⇒ **這是真人閘,總管也不能解,只有 leo 自己動得了。**
|
||||
|
||||
📌 **這條斷了不代表 `#93` 沒交付**:本次的設計前提就是「**不假設發得出去**」——
|
||||
實測那兩次的退路都走通了,訊息貼回了 `inkstone/ISEP#93`
|
||||
(`#issuecomment-5182`、`#issuecomment-5184`),並把原文印在眼前。
|
||||
**發不出去而靜默,等於沒做;發不出去但講出來並留下退路,就是這支的規格。**
|
||||
通道修好之後不必改任何程式碼,重跑 `python3 scripts/isep-nag --notify` 即可補驗這一格。
|
||||
|
||||
### 2026-09-07 從雲端 session 再量一次(inkstone/ISEP#130)
|
||||
|
||||
| 路 | 結果 | 誰能修 |
|
||||
|---|---|---|
|
||||
| `notify_leo` @ leo21c(`…/webhooks/named/leo/notify_leo`) | **HTTP 404**,跟 08-28 一樣 | leo(leo21c 寫入是人閘) |
|
||||
| `notify_leo` @ youlin(`*.arcrun-yuga3bse.workers.dev`) | **連不到:egress proxy 對 CONNECT 回 403**(`/__agentproxy/status` 記為 policy denial)。不是 DNS、不是 000 | leo(Cloud environment 的 allowed network hosts) |
|
||||
| `api.telegram.org` | **302,通** | — |
|
||||
|
||||
⇒ 雲端能走的只有 Bot API 直送(`scripts/isep-notify` 現在會先試它),缺的只是兩個環境變數
|
||||
`TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID`——Routine 文件 `cloud-worker.md` 檔頭本來就列著它們,
|
||||
只是這個 environment 沒有。**這一格仍然是 leo 的:加變數,然後回一個詞。**
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 雲端環境(claude.ai Cloud environment)變數清單的版本紀錄——**只記名字,不記值**。
|
||||
# leo 2026-09-18:「你這裡記錄每版的 credential 的 key,但 value 自動產生,我就貼上去,你要有版本,因為這件事做了很多次,但你都沒記錄版本」
|
||||
# 產生器 scripts/make-cloud-env.sh 讀本檔最後一版的號碼印在產物檔頭,並列出與前一版的差異。
|
||||
# 欄位:版本<TAB>日期<TAB>key(逗號分隔,順序=NEEDED)<TAB>為什麼變
|
||||
v1 2026-08-20 GITEA_TOKEN_CLAUDE_CODE 首版:雲端 bootstrap 與 plugin 安裝(f855d82)
|
||||
v2 2026-09-01 GITEA_TOKEN_CLAUDE_CODE,CLOUDFLARE_API_TOKEN_YOULIN_CC_USE 雲端拿得到 youlin 的 CF token(ISEP#115 c5526,ca9ea99)
|
||||
v3 2026-09-01 GITEA_TOKEN_CLAUDE_CODE,CLOUDFLARE_API_TOKEN_YOULIN_CC_USE,CLOUDFLARE_ACCOUNT_ID_YOULIN,YOULIN_MCP_OWNER_SECRETS,STAGE_USERNAME_YOULIN,STAGE_PASSWORD_YOULIN,CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE,CLOUDFLARE_ACCOUNT_ID_GEEK6688 leo「youlin 和 geek6688 都全給」(ISEP#115 c5541/5542,7aef943)
|
||||
v4 2026-09-01 GITEA_TOKEN_CLAUDE_CODE,CLOUDFLARE_API_TOKEN_YOULIN_CC_USE,CLOUDFLARE_ACCOUNT_ID_YOULIN,YOULIN_MCP_OWNER_SECRETS,STAGE_USERNAME_YOULIN,STAGE_PASSWORD_YOULIN,CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE,CLOUDFLARE_ACCOUNT_ID_GEEK6688,UNCLE6_CF_API_KEY 出貨三站實際部署到 uncle6(ISEP#115 c5577,cb310e9)
|
||||
v5 2026-09-01 GITEA_TOKEN_CLAUDE_CODE,CLOUDFLARE_API_TOKEN_YOULIN_CC_USE,CLOUDFLARE_ACCOUNT_ID_YOULIN,YOULIN_MCP_OWNER_SECRETS,STAGE_USERNAME_YOULIN,STAGE_PASSWORD_YOULIN,CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE,CLOUDFLARE_ACCOUNT_ID_GEEK6688,UNCLE6_CF_API_KEY,ARCRUN_NS_YOULIN 出貨線實例座標(Arcrun#195,dd86fef/v0.21.0)
|
||||
v6 2026-09-18 GITEA_TOKEN_CLAUDE_CODE,CLOUDFLARE_API_TOKEN_YOULIN_CC_USE,CLOUDFLARE_ACCOUNT_ID_YOULIN,YOULIN_MCP_OWNER_SECRETS,STAGE_USERNAME_YOULIN,STAGE_PASSWORD_YOULIN,CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE,CLOUDFLARE_ACCOUNT_ID_GEEK6688,UNCLE6_CF_API_KEY,ARCRUN_NS_YOULIN,ARCRUN_NS_UNCLE6,GITHUB_MIRROR_TOKEN,GITHUB_ACCOUNT_NAME 雲端要能出 stage/prod:uncle6 namespace+GitHub 鏡像兩把(InkStoneCo#98 c7739;ISEP#158)
|
||||
v7 2026-09-18 GITEA_TOKEN_CLAUDE_CODE,CLOUDFLARE_API_TOKEN_YOULIN_CC_USE,CLOUDFLARE_ACCOUNT_ID_YOULIN,YOULIN_MCP_OWNER_SECRETS,STAGE_USERNAME_YOULIN,STAGE_PASSWORD_YOULIN,CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE,CLOUDFLARE_ACCOUNT_ID_GEEK6688,UNCLE6_CF_API_KEY,ARCRUN_NS_YOULIN,ARCRUN_NS_UNCLE6,GITHUB_MIRROR_TOKEN,GITHUB_ACCOUNT_NAME,TELEGRAM_CHAT_ID,TELEGRAM_BOT_TOKEN,ISEP_DRAIN 收回雲端現行卻不在 v1–v6 的三個:TELEGRAM_CHAT_ID/TELEGRAM_BOT_TOKEN(通知出口,值在 polaris/mira/.env,點名放行)+ISEP_DRAIN=1 固定值(leo 09-18 貼出現行雲端環境才發現漏收)
|
||||
|
@@ -160,6 +160,75 @@ GitHub、不是 Gitea):
|
||||
驗 `claude plugin list` 顯示 `isep@inkstone enabled`,且照 §逐條對照 那張表
|
||||
再核一次數字。
|
||||
|
||||
## 雲端要全權處理的三樣:票、主線、通知(inkstone/ISEP#130,2026-09-07)
|
||||
|
||||
leo 09-07:「雲端回覆的問題你首要解決⋯⋯僅有需要依賴 Mac 環境的東西要求地端處理,其他全部需要可以從雲端全權處理。」
|
||||
09-04 兩支 Routine 的 run log 實查出四件雲端拿不到的東西,ISEP 這一半的解法:
|
||||
|
||||
| 拿不到什麼 | 為什麼 | ISEP 這一半 | 還缺誰 |
|
||||
|---|---|---|---|
|
||||
| **主線是哪條** | 主線檔只在本機 `~/.claude` | `hooks/lib/mainline.py`:家目錄沒有就讀 repo 的 `InkStoneCo/system-dev/mainline.json`(雲端形狀:`$CLAUDE_PROJECT_DIR` 是薄殼根、真身在 `InkStoneCo/`);`mainline set`/`adopt`/`clear` 兩份一起寫。**兩份都不在**(SessionStart 跑在 `bootstrap.sh` 之前,`InkStoneCo/` 還沒 clone)⇒ `mainline refresh` 去 Gitea 讀 InkStoneCo main 那份一次寫進家目錄(`inkstone/ISEP#140`)——第一則 prompt 的 ⏱/🎯 就是現在這條,不再退到逾期的舊線 | 總管 `set` 之後要把 repo 那份 commit/併 main;Cloud environment 要有 `GITEA_TOKEN_CLAUDE_CODE`(InkStoneCo 是 private,匿名讀不到) |
|
||||
| **正門工具被分類器擋**(`permission_denials=6`) | leo 09-07 親手加的四條只在本機 settings | `docs/permissions-allow.json` 一份清單;`scripts/settings-allow-sync` 寫進那台的 `~/.claude/settings.json`(setup script 裝完 plugin 跑一次,SessionStart 每次再對一次) | 雲端一趟 run 看 `permission_denials`=0 才算驗到 |
|
||||
| **讀 leo21c 也被擋** | `leo21c-write-guard.sh` 把 `tr -d` 的 `-d` 當 body | 唯讀 `-d` 先剪掉再判;`notify_leo` trigger 放行(同 `prod-write-guard` 白名單);改法段改印現役 youlin 子網域 | — |
|
||||
| **收工摘要到不了手機** | 雲端沒 `TELEGRAM_*`;leo21c 的 `notify_leo` 404;youlin 被 egress proxy 擋 | `scripts/isep-notify` 有 `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` 就先走 Bot API 直送(`api.telegram.org` 從雲端是通的) | **leo**:在 Cloud environment 加那兩個變數 |
|
||||
|
||||
### 雲端連不到 youlin 不是 000,是 proxy 403(2026-09-07 實查)
|
||||
|
||||
```
|
||||
$ curl -sS -o /dev/null -w '%{http_code}' https://arcrun-cypher-executor.arcrun-yuga3bse.workers.dev/health
|
||||
curl: (56) CONNECT tunnel failed, response 403 ← egress policy denial
|
||||
$ curl -sS "$HTTPS_PROXY/__agentproxy/status" | jq .recentRelayFailures
|
||||
{ "kind": "connect_rejected", "host": "arcrun-cypher-executor.arcrun-yuga3bse.workers.dev:443",
|
||||
"detail": "gateway answered 403 to CONNECT (policy denial or upstream failure)" }
|
||||
$ curl … https://arcrun-cypher-executor.leo21c.workers.dev/health → 200
|
||||
$ curl … https://api.telegram.org/ → 302
|
||||
$ curl … https://git.uncle6.me/api/v1/version → 200
|
||||
```
|
||||
|
||||
⇒ 09-04 log 寫的「youlin 從雲端 HTTP 000」有兩層:舊子網域 `youlin-hsieh-dev` DNS 已死(總管 09-07 查到),
|
||||
**而新子網域從雲端是被 policy 擋**——`/root/.ccr/README.md` 明寫「403/407 = 你組織的 egress policy
|
||||
不允許這個主機,不要重試、不要繞路,回報主機名」。leo21c 通、youlin 不通,差別只在主機有沒有被放行。
|
||||
**這格是 leo 的**:Cloud environment 的 allowed network hosts 加 `*.arcrun-yuga3bse.workers.dev`;
|
||||
在那之前雲端做不了 stage 驗證,所有要打 youlin 的驗證一律交回地端或標 `Human`。
|
||||
|
||||
### 只有「要 Mac」的事才交回地端(給 `cloud-worker.md` 的清單草稿)
|
||||
|
||||
判準一句話:**這件事依賴的東西隨 repo clone 走得到嗎?走得到就雲端做。**
|
||||
|
||||
| 交回地端 | 為什麼雲端做不了 |
|
||||
|---|---|
|
||||
| 打包 DMG/簽章/MS Store 送件 | 要 macOS 工具鏈與簽章身分(Keychain),不隨 clone 走 |
|
||||
| 本機 daemon(`~/.arcrun-rag`、`~/.arcrun/config.yaml`)的改與驗 | 住在 leo 的家目錄 |
|
||||
| 用 `~/.wrangler` OAuth 登入態出貨到 uncle6 | 登入態不隨 clone 走(`make-cloud-env.sh` 檔頭記過) |
|
||||
| 要 leo 親手輸入的互動閘(`acr recipe push` 的暴露同意、密碼打進登入欄) | surface 層硬規則,誰都不能代 |
|
||||
| ~~打 youlin stage 的驗證~~ | 09-07 已回到雲端:`*.arcrun-yuga3bse.workers.dev` 從雲端 curl 200。**寫入走 `scripts/stage`**(secret/api,inkstone/ISEP#137),不再交回地端 |
|
||||
|
||||
其餘(讀票、認領、留言、handback、寫 code、跑測試、開 PR、對 leo21c 唯讀查詢、發 Telegram、**種 secret/直推成品/POST 到 stage 實例**——走 `scripts/stage` 與 Arcrun 的 `stage-deploy-artifacts.py`)雲端全做。
|
||||
📌 這份清單的家是 `inkstone/InkStoneCo` 的 `cloud-worker.md`(票上驗收第 4 條),ISEP 只放草稿;併進去由總管做。
|
||||
|
||||
## 雲端工人不只撿票,還要分診、組里程碑(inkstone/ISEP#159,給 `cloud-worker.md` 的草稿)
|
||||
|
||||
leo 09-18:「它看到票時要能夠組成 milestone 提出版本,所以不只是看票,還要組票。」
|
||||
ISEP 這一半是 `scripts/ticket` 的 `triage`/`compose` 兩個動詞,與 `pick --pool` 多的兩層(見 `docs/TESTING.md` A43)。
|
||||
`cloud-worker.md` 現在寫著「🚫 不開里程碑、不改里程碑⋯⋯」——那一條要換成下面這段(**併進 InkStoneCo 由總管做**):
|
||||
|
||||
```
|
||||
1. 分診:`ticket triage --list`(找重複用 `--all`)。每張沒 s/* 的票讀過之後擇一:
|
||||
ticket triage <票> --to s/backlog --why "…" 決定要做
|
||||
ticket triage <票> --to s/triage --why "…" 要總管/leo 判斷(方向、品味、要不要做)
|
||||
ticket triage <票> --to s/pending --why "…" 在等外部
|
||||
ticket triage <票> --dup-of <留下的那張> --why "…" 重複(關新的、留舊的)
|
||||
都帶 `--name <ISEP 工人名> --sign cloud-worker`。掛 Human/指派 Leo 的票工具會擋,不必自己判。
|
||||
2. 組里程碑:已分診、彼此相關、做完能出一個可測版本的幾張 ⇒
|
||||
ticket compose --title "<這一輪要讓什麼變成真的>" --goal "…" --due <1–60 天內>
|
||||
--carrier inkstone/InkStoneCo#N <前置票…> --name … --sign cloud-worker
|
||||
載體=既有的 InkStoneCo User Story;`ticket where` 撈不到能載這一輪的 ⇒ 先 `ticket new InkStoneCo` 補一張 US 當載體
|
||||
(開工規約第 2 條:撈不到才是真缺口)。一張票同時只排一輪,工具會擋。
|
||||
3. 取票:`ticket pick`(ISEP_DRAIN=1 ⇒ 自帶 --pool)。組好的里程碑的前置排在散票之前;
|
||||
可做的都抓盡時它回一張待分診的,下一步就是它印的那行 triage。
|
||||
收工條件不變:`ticket pick` 回離開碼 1。
|
||||
```
|
||||
|
||||
## 這張票沒動、也不會動的東西
|
||||
|
||||
- 沒有動 `youlinhsieh/inkstoneco`(GitHub 薄殼 repo)——這個新機制**完全不需要
|
||||
|
||||
@@ -28,6 +28,17 @@ set -uo pipefail
|
||||
claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git --scope user 2>&1 || true
|
||||
claude plugin install isep@inkstone --scope user 2>&1 || true
|
||||
|
||||
# 🔴 權限白名單(inkstone/ISEP#130):四個 Gitea 正門工具(ticket/mainline/gate-ok/gitea-pr-merge)
|
||||
# 要進這台的 ~/.claude/settings.json,auto mode 的分類器才不擋(09-04 run log:permission_denials=6)。
|
||||
# 清單住 ISEP `docs/permissions-allow.json` 一份,本機與雲端都由同一支腳本寫——薄殼 repo 不再各養一份。
|
||||
# plugin 快取的路徑帶版本號,挑最新的那份跑;沒裝成也不擋門,SessionStart 會再對一次。
|
||||
ISEP_DIR=$(ls -d "$HOME"/.claude/plugins/cache/inkstone/isep/*/ 2>/dev/null | sort -V | tail -1)
|
||||
if [ -n "$ISEP_DIR" ] && [ -f "$ISEP_DIR/scripts/settings-allow-sync" ]; then
|
||||
python3 "$ISEP_DIR/scripts/settings-allow-sync" 2>&1 || true
|
||||
else
|
||||
echo "⚠️ 找不到裝好的 ISEP,白名單沒寫進 ~/.claude/settings.json——session 開頭 SessionStart 會再試一次。"
|
||||
fi
|
||||
|
||||
if claude plugin marketplace list 2>/dev/null | grep -q "inkstone"; then
|
||||
echo "✅ marketplace inkstone 已就位"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# file-ownership.tsv — 同一個檔案兩邊都有時,**哪一份是真相源**(inkstone/ISEP#122)
|
||||
#
|
||||
# 為什麼要有這張表(2026-09-02 實查,兩個方向都真的發生過):
|
||||
# skills/ship-check/SKILL.md InkStoneCo 那份前進到 651 行,ISEP 停在 595 行
|
||||
# commands/sdd-check.md ISEP 那份前進到 81 行,InkStoneCo 停在 65 行
|
||||
# (InkStoneCo 那份還在教已經退役的「唯一 active SDD」)
|
||||
# ⇒ 「ISEP 一定比較新」是錯的,「InkStoneCo 一定比較新」也是錯的。
|
||||
# 兩邊都會被就地編輯,所以歸屬**不能靠人記得**,要寫下來、而且要有東西去比對。
|
||||
#
|
||||
# 誰在用這張表:`hooks/lib/beacon_report.py`(SessionStart 信標,只報告、不擋人)
|
||||
# ① 兩邊內容不同時 → 照這張表講出「真相源是哪一份、該往哪個方向修」
|
||||
# ② 表上有 sha256 時 → 順便驗 ISEP 這份有沒有被就地改過(雲端沒有 InkStoneCo
|
||||
# 可以比,這一格是那時唯一還能作數的檢查)
|
||||
#
|
||||
# 規約三條:
|
||||
# 1. **只有內容會分家的檔案才需要一列。** 兩邊一模一樣時不必登記,信標也不會出聲。
|
||||
# 2. 真相源是 `inkstone/InkStoneCo` ⇒ **內容要改就改在那裡**,改完把檔案原樣搬過來、
|
||||
# 更新 ④⑤ 兩欄、升版。**不要在 ISEP 這邊改內容**(改了兩邊就各自演化,就是本表要解的病)。
|
||||
# 3. 真相源是 `inkstone/ISEP` ⇒ 專案那份是退場中的舊複本,該被同步或刪掉;
|
||||
# ③④⑤ 三欄填 `-`。
|
||||
#
|
||||
# 欄位(TAB 分隔,5 欄):
|
||||
# ① plugin 內路徑
|
||||
# ② 真相源 repo
|
||||
# ③ 真相源在那個 repo 裡的路徑(真相源就是 ISEP 時填 `-`)
|
||||
# ④ 這一份是從哪顆 commit 搬過來的(同上,填 `-`)
|
||||
# ⑤ 搬過來的當下 sha256(同上,填 `-`)
|
||||
#
|
||||
skills/ship-check/SKILL.md inkstone/InkStoneCo .claude/skills/ship-check/SKILL.md bd52fa3a508cbfb29755ddc24923c6fea8c3fbf7 847967830007e804a16dec9adfc5d4120c233445649d4d5cdd34b04550fb3270
|
||||
commands/sdd-check.md inkstone/ISEP - - -
|
||||
|
@@ -471,6 +471,8 @@ Telegram → 純投影。**Wiki 壞不影響 Gitea,Gitea 壞不影響 SDD,
|
||||
> 🔴 **排序不是按票號,是按「什麼擋住什麼」**:上面的群沒解,下面的群做了也看不到效果。
|
||||
> 本節是唯一把六群串起來的地方——Gitea 沒有跨 repo milestone(已查證),
|
||||
> 各 repo 的同名里程碑都指回這裡。
|
||||
> 🔴 **「各 repo 同名」這半句 2026-09-07 已被 leo 推翻**(`inkstone/ISEP#133`):一條線只有一個
|
||||
> milestone,別 repo 的票用相依指到 hub 票;`scripts/mainline` 的成員判定改看相依邊,不看名字。
|
||||
|
||||
### 13.0 六群一頁看完
|
||||
|
||||
@@ -639,7 +641,7 @@ leo 08-17 診斷見公理 3;08-17 實測:文字層的閘 **8 次誤攔、0
|
||||
3. **一切要留痕、可量測。** 閘的動作、開場全局、里程碑百分比——leo 看到的畫面就是實況。
|
||||
4. **進度=舊票關掉幾張。** 版本只是讓閘生效的載具,release note 寫明關了哪張票。
|
||||
|
||||
### 15.2 工作順序(六個里程碑,各 repo 同名,內容一經確定不增不減)
|
||||
### 15.2 工作順序(六個里程碑,內容一經確定不增不減;「各 repo 同名」已於 2026-09-07 推翻——一條線一個 milestone,別 repo 用相依,`inkstone/ISEP#133`)
|
||||
|
||||
| 順 | 里程碑 | 來源票(由目標遍歷票池而來,M4.0) |
|
||||
|---|---|---|
|
||||
@@ -853,7 +855,7 @@ scripts/ticket mine [--user <誰>]
|
||||
|
||||
| 閘 | 掛在哪 | 抓什麼 | 測試 |
|
||||
|---|---|---|---|
|
||||
| `hooks/comment-carries-task-guard.sh` | PreToolUse `Bash` | 留言裡帶「等 X 才…/驗不了/還沒…」這種**未完成的未來式**,卻沒開子票 → 擋一次 | `scripts/test-comment-carries-task-guard.sh`(22 條) |
|
||||
| `hooks/comment-carries-task-guard.sh` | PreToolUse `Bash` | 留言裡帶「等 X 才…/驗不了/還沒…」這種**未完成的未來式**,卻沒開子票 → 擋一次 | `scripts/test-comment-carries-task-guard.sh`(29 條) |
|
||||
| `hooks/baton-handback-guard.sh` | PostToolUse `Agent\|Task` | 一條線收工,票上**指派/tag/下一步**缺哪一格 → 當場說出來(提醒,不擋) | `scripts/test-baton-handback-guard.sh`(10 條) |
|
||||
|
||||
🔴 **兩支閘的誤攔設計**:`comment-carries-task-guard` 同一 session 只擋一次,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
| 段 | 問題 | 為什麼一定要有 |
|
||||
|---|---|---|
|
||||
| `**負責 repo**` | 它管哪一個 repo | 派工時挑人的唯一判準;也是【身份】欄第二格 |
|
||||
| `**負責 repo**` | 它管哪個(或哪幾個)repo | 派工時挑人的唯一判準;也是【身份】欄第二格。同一件事的幾個 repo 用「、」隔開(例:`course-hand`) |
|
||||
| `## 開工前先讀` | 它要先讀什麼 | 這是**每個工人各自不同**的部分——共通規定補不了 |
|
||||
| `## 紅線` | 它不准做什麼 | 全機共通的紅線在共通規定裡;這裡只寫這個 repo 特有的 |
|
||||
| frontmatter `description` | 一句話它是誰 | `scripts/roster list` 印的就是這一行 |
|
||||
@@ -39,3 +39,24 @@ bash hooks/tests/roster-guard.test.sh # 全綠
|
||||
```
|
||||
|
||||
**不必改任何 hook**——名單是資料,閘讀的是這個目錄。
|
||||
|
||||
## 新開一個 repo 之後,工人是誰在什麼時候加的(inkstone/ISEP#118)
|
||||
|
||||
**以前的答案是「靠人記得」,所以一定會忘。** 實查 2026-09-14:org 裡 20 個 repo,
|
||||
名單只蓋到 6 個;`llm-wiki-template`(09-01 建)與 `ax-courses`(09-07 建)
|
||||
都是**第一次派工撞上 `roster-guard.sh`** 才發現沒人,而那一刻最省事的出路是
|
||||
開 `/tmp/.roster-ok-*` 例外閘繞過去——繞過去之後,下一次還是會撞。
|
||||
|
||||
**現在的答案**:
|
||||
|
||||
- **什麼時候**:建 repo 之後,**開第一張票後的下一個 session 開場**。
|
||||
- **誰叫**:`scripts/isep-nag`(掛在 `overdue-nag-guard.sh` 上,開場跑一次)的第 ④ 類
|
||||
「**有票卻沒有工人的 repo**」——拿 org 的 repo 清單比對本目錄的 `負責 repo`。
|
||||
- **誰加**:總管。在這個目錄加一個工人檔,或把 repo 寫進既有工人的 `負責 repo`。
|
||||
|
||||
判準只有兩個事實:名單上沒有人負責它、它有開著的票。
|
||||
**沒票的 repo 不叫**(封存、投影用、還沒開張的——沒有工可派,就不是缺人),
|
||||
所以這一段不會變成每天念一串不相干東西的噪音。
|
||||
**只進開場給總管看的長版,不進 Telegram**——補工人是總管的事,不吵 leo。
|
||||
|
||||
手動看一次:`python3 scripts/isep-nag`(`--json` 裡的 `repos_without_worker`)。
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# 一個 repo 只有一個資料夾:分身住 `.worktrees/`,收工就被收(inkstone/ISEP#147)
|
||||
|
||||
> leo 2026-09-07(兩則,一字不改):
|
||||
> 「我看到的是一個 repo,你會產生大量的同一個頭不同尾巴的 repo,全部拆開就更亂了,
|
||||
> 為什麼要產出新的 repo?這個 repo 的目的就是收納所有相關的內容,這樣散開造成凌亂,
|
||||
> ISEP, Inkstoneco, Course Gen 這些都產出好多個資料夾,這要整理回去」
|
||||
> 「另外要在 ISEP 裡寫明,禁止這樣產出一大堆資料夾」
|
||||
|
||||
## 規約(三句)
|
||||
|
||||
1. 🔴 **禁止在專案目錄旁邊產出分身資料夾。** `tech_projects/` 底下一個 repo 只能有一個資料夾;
|
||||
`ISEP-wt-117`、`InkStoneCo-wt-112`、`course_gen-ticket-3-notes` 這種同頭不同尾的東西不准再長出來。
|
||||
2. **一條線要隔離工作目錄,分身住 repo 裡面**:`<repo>/.worktrees/<票的 repo 名>-<票號>`。
|
||||
Finder 看不到(點開頭)、`git status` 看不到(寫進 `.git/info/exclude`)、`git worktree list` 找得到。
|
||||
3. **分身是借的,收工要還**:分支推上遠端且工作樹乾淨 ⇒ 收掉;沒推 ⇒ 點名分支與路徑,等人決定。
|
||||
**絕不 force、絕不刪沒推的 commit。**
|
||||
|
||||
## 為什麼會長成那樣(根因,不是誰疏忽)
|
||||
|
||||
`hooks/line-needs-own-worktree.sh`(inkstone/ISEP#109,08-29 立)擋下線在共用目錄切分支,教它
|
||||
「開一份在 repo 旁邊、不要在 /tmp、收工時 `worktree remove`」。
|
||||
|
||||
那時比較的只有「旁邊 vs /tmp」——/tmp 會被系統清掉、登記簿說謊;旁邊的一份都沒掉。
|
||||
**一份都沒掉,也一份都沒收**:`remove` 那一步只寫在閘訊息裡,沒有任何機制驗。
|
||||
每個工人留一份,`tech_projects/` 就長成那排。**規則在文字裡,不在機器上**
|
||||
(同日 ISEP#109 comment 6629 是同一種病:出路寫在訊息裡,機器讀不到)。
|
||||
|
||||
## 機器上的兩端
|
||||
|
||||
| 端 | 在哪 | 做什麼 |
|
||||
|---|---|---|
|
||||
| 開 | `hooks/line-needs-own-worktree.sh`(PreToolUse Bash) | 擋下共用目錄裡的 checkout,印出 `scripts/worktree open …`(絕對路徑,貼了就能跑) |
|
||||
| 收 | **同一支閘**多掛 PostToolUse `Agent\|Task` | 從派工單 `【工單】owner/repo#N` 認出分身名字,跑 `scripts/worktree close`:推了收、沒推點名(exit 2) |
|
||||
| 工具 | `scripts/worktree` | `open`/`close`/`list`/`sweep` 四個動詞;「推了沒」只有這一份判準(三態:有/沒有/問不到,問不到不當成沒推) |
|
||||
|
||||
為什麼收的那一半不另開一支閘:教你開的和替你收的是同一件事的兩半,拆成兩支就又是「規則在兩個地方」
|
||||
(`countdown-guard.sh` 同一種形狀:注入與查核是一支閘掛兩個事件)。
|
||||
|
||||
為什麼掛 PostToolUse 不掛 SubagentStop:票號在派工單(Agent 的 `tool_input.prompt`)裡,SubagentStop 拿不到
|
||||
(`baton-handback-guard.sh` 檔頭那段實撞)。
|
||||
|
||||
## 工人怎麼用
|
||||
|
||||
```
|
||||
python3 <plugin>/scripts/worktree open inkstone/ISEP#147 --repo <repo> -b feat/147-xxx # 開
|
||||
cd <repo>/.worktrees/ISEP-147 # 在裡面做事
|
||||
python3 <plugin>/scripts/worktree close inkstone/ISEP#147 --repo <repo> # 收(推了才收得掉)
|
||||
```
|
||||
|
||||
忘了 `close` 也沒關係——線收工那一刻閘會替你跑;但**沒推的它不會替你收**,只會點名。
|
||||
不要用 `git worktree add <repo>/../<repo>-wt-…` 這條老路;閘訊息已經不印它了。
|
||||
|
||||
## leo 在 Mac 上整理現場(一次)
|
||||
|
||||
```
|
||||
python3 ~/.claude/plugins/cache/inkstone/isep/<版本>/scripts/worktree sweep ~/tech_projects # 只列,不動
|
||||
python3 ~/.claude/plugins/cache/inkstone/isep/<版本>/scripts/worktree sweep ~/tech_projects --apply --skip course_gen
|
||||
```
|
||||
|
||||
- 預設**只列不動**;`--apply` 才收,而且只收「推了且乾淨」的。
|
||||
- 沒推的、有未 commit 的、問不到遠端的、沒有任何 repo 登記它的:**全部列出來、一個都不動**,等他決定。
|
||||
- `--skip <repo 名>` 是操作者點名不碰的 repo(09-07:`course_gen` 地端正在處理,列可以、不收不動)。
|
||||
它是操作者的明示指令,不是判準——判準永遠是「這個目錄是不是某個 repo 的 worktree、分支推了沒、乾不乾淨」。
|
||||
|
||||
## 測試
|
||||
|
||||
- `hooks/tests/line-needs-own-worktree.test.sh` G 群(開在裡面、旁邊不多任何東西、閘印的那行原樣跑得動)
|
||||
與 H 群(PostToolUse:推了收掉、沒推點名、沒工單/沒分身安靜)。
|
||||
- `scripts/test-worktree.sh`:七個分身的 fixture(照 leo 09-07 看到的那排)跑 `sweep`——
|
||||
dry-run 不動任何東西;`--apply` 只收已推的、`--skip` 的一根寒毛不動、沒推的印分支與路徑。
|
||||
+132
-18
@@ -1,5 +1,4 @@
|
||||
# 56 支閘,白話盤點表
|
||||
# 54 支閘,白話盤點表
|
||||
# 62 支閘,白話盤點表
|
||||
|
||||
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
|
||||
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
|
||||
@@ -8,16 +7,28 @@
|
||||
|
||||
## 一句話結論
|
||||
|
||||
`hooks/` 底下有 **58 個 `.sh` 檔**,`hooks.json` 實際掛上 **79 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **58 個 `.sh` 檔**,`hooks.json` 實際掛上 **79 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **58 個 `.sh` 檔**,`hooks.json` 實際掛上 **79 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **62 個 `.sh` 檔**,`hooks.json` 實際掛上 **88 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
> 📌 **`inkstone/ISEP#159`(2026-09-18,待總管定版)+0 支、+0 條、腳本 65→66**:沒加閘。`scripts/ticket` 多兩個動詞
|
||||
> `triage`(沒 s/* 的票給狀態+留說明,或當重複關掉並指回去)與 `compose`(把已分診的票組成 InkStoneCo 里程碑:載體+前置,
|
||||
> 先全部驗過才寫);`pick --pool` 多兩層(還沒到期的 InkStoneCo 里程碑的前置、最後是待分診的票),每一列帶 `next`。
|
||||
> `queue-drain-guard.sh` 改判準不加閘:照 pick 那一列的 `next` 印下一步(待分診的票下一步是 triage,不是 claim)。
|
||||
> `scripts/` +1:`test-ticket-triage.sh`。
|
||||
> **62 支、88 條、66 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=88、`ls -p scripts | grep -v / | wc -l`=66)。
|
||||
|
||||
> 📌 **`inkstone/ISEP#153`(2026-09-13,待總管定版)+0 支、+1 條**:`baton-handback-guard.sh` 多掛一條 `Stop`。
|
||||
> 背景派工時 `PostToolUse:Agent` 在工人**剛出發**就觸發,舊版當場念「收工了但棒子沒交回來」(09-13 對 ISEP#150、arcrun-rag#14 各一次,工人其實還要跑 17 分鐘)。
|
||||
> 現在送出那一刻看到 launch ack 就不查;工人交件(主對話收到 `<task-notification>` completed)時由 Stop 那半用 `tool-use-id` 對回原派工單再查,同一條線只點名一次。
|
||||
> **62 支、88 條、65 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=88、`ls -p scripts | grep -v / | wc -l`=65)。
|
||||
|
||||
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||
> 現在的寫法是實際數出來的:
|
||||
> `ls hooks/*.sh | wc -l` = 56;`grep -c '"command":' hooks/hooks.json` = 75。
|
||||
> `ls hooks/*.sh | wc -l` = 61;`grep -c '"command":' hooks/hooks.json` = 85。
|
||||
> ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。
|
||||
> 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
|
||||
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
|
||||
@@ -35,6 +46,57 @@
|
||||
> **兩個事件是同一件事的兩半**:只注入 = 又一個會被忽略的提醒;只查核 = 罰它做一件拿不到資料的事。
|
||||
> 這兩個數字是併之前當場數出來的,不是從上一版加減推出來的。
|
||||
|
||||
> 📌 **`inkstone/ISEP#67`(2026-09-07)+0 支、+0 條、腳本 56→60**:沒加閘。`isep-presence-beacon.sh`
|
||||
> 多報一格 ④「打了 tag 卻沒 release」(改判準不加閘);`scripts/` 多了運送鏈的兩站 `release-ship`/`release-check`
|
||||
> 與各自的測試,判準收在 `hooks/lib/release_chain.py` 一份三處用。
|
||||
> **61 支、85 條、60 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=61、
|
||||
> `grep -c '"command":' hooks/hooks.json`=85、`ls -p scripts | grep -v / | wc -l`=60)。
|
||||
|
||||
> 📌 **`0.30.0`(`inkstone/ISEP#33`,2026-09-13)+1 支、+1 條**:`queue-drain-guard.sh`(F 組,Stop)——
|
||||
> 唯一一支看「佇列」的收工閘:在接票的 session,`ticket pick` 還有票就不准收工。
|
||||
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=62)。
|
||||
|
||||
> 📌 **`0.30.2`(`inkstone/ISEP#150`,2026-09-13)+0 支、+0 條、+3 支腳本**:新增 `scripts/isep-doctor`——
|
||||
> 講出這台每一個 Claude Code(本機 CLI/Desktop 內建)**實際有沒有載入** ISEP,判準讀 `claude plugin list --json`,不再看 settings 長相
|
||||
> (09-13 實撞:settings 的 marketplace 多了 `path` ⇒ Desktop 2.1.266 整個忽略 inkstone、CLI 2.1.220 照載入)。
|
||||
> **62 支、87 條、65 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=65)。
|
||||
|
||||
> 📌 **`0.30.1`(`inkstone/ISEP#130`,2026-09-13)+0 支、+0 條**:修補 0.30.0 的抓票入口——
|
||||
> `mainline set/refresh/adopt` 讀不到任何一條 `/dependencies` 時,名單標成不完整、`set` 離開碼 3 不印 ✅、
|
||||
> 保留舊快取;否則雲端工人會拿殘缺名單 `pick` 到「抓盡」而收工(09-13 實撞:hub 票底下三張整批漏收)。
|
||||
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=62)。
|
||||
|
||||
> 📌 **`inkstone/ISEP#150`(2026-09-13)+0 支、+0 條、腳本 62→65**:沒加閘。`scripts/isep-doctor` 從 plugin **外面**
|
||||
> 問這台每一個 Claude Code(CLI、Desktop 內建那一份)有沒有真的載入 ISEP——plugin 沒載入時信標自己也講不出話。
|
||||
> 判準是各自 `plugin list --json` 回報的 errors,不是設定長相(`path` 在 Desktop 2.1.266 會讓 marketplace 被忽略、CLI 2.1.220 不會)。
|
||||
> 另加兩支測試:`test-isep-doctor.sh`(離線)、`test-isep-doctor-live.sh`(真的 Claude Code、隔離 HOME)。
|
||||
> **62 支、87 條、65 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=65)。
|
||||
|
||||
> 📌 **`inkstone/ISEP#147`(2026-09-07)+0 支、+1 條**:`line-needs-own-worktree.sh` 多掛 PostToolUse `Agent|Task`
|
||||
> ——教你開分身的那支閘,在線收工那一刻替你收(分支推了就 remove;沒推就點名分支與路徑,不靜默、不 force)。
|
||||
> 分身的家從「repo 旁邊」改成「repo 裡面的 `.worktrees/`」:leo 在 Finder 看到一排 `ISEP-wt-117`/`InkStoneCo-wt-112`
|
||||
> 就是第一版「收工 remove 只寫在閘訊息裡、沒機制驗」長出來的。同一支閘掛兩個事件(跟 `countdown-guard.sh` 同形狀),
|
||||
> **不另開一支**。`scripts/` +2:`worktree`(open/close/list/sweep,「推了沒」只有這一份判準)與它的測試。
|
||||
> **61 支、86 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=61、
|
||||
> `grep -c '"command":' hooks/hooks.json`=86、`ls -p scripts | grep -v / | wc -l`=62)。規約:`docs/governance/worktree-placement.md`。
|
||||
|
||||
> 📌 **`inkstone/ISEP#109` → comment 6629(2026-09-07)+0 支、+0 條**:兩支閘改判準不加閘——
|
||||
> `line-needs-own-worktree.sh` 的出路改認指令字串裡的 `WORKTREE_OK=1` 前綴(舊版讀 hook 自己的環境,
|
||||
> 出路永遠走不通);`github-contact-guard.sh` 判 push 目標改在「這條指令實際會推的那個 repo」裡解 remote
|
||||
> (舊版在 hook 的 cwd 解,薄殼站 GitHub、推 Gitea 會誤擋,反向會漏)。
|
||||
> 數字是改完當場數的:`ls hooks/*.sh | wc -l`=61、`grep -c '"command":' hooks/hooks.json`=85。
|
||||
|
||||
> 📌 **`inkstone/ISEP#130`(2026-09-07)+0 支、+1 條**:SessionStart 多掛一條
|
||||
> `scripts/settings-allow-sync --quiet`(不是閘,是腳本:把 ISEP 的權限白名單寫進這台的
|
||||
> `~/.claude/settings.json`,雲端才不會每個正門工具都被分類器擋)。
|
||||
> **61 支不變、84→85 條,兩個數字是掛完之後當場數出來的**(`ls hooks/*.sh | wc -l`=61、
|
||||
> `grep -c '"command":' hooks/hooks.json`=85)。同票順手:`leo21c-write-guard.sh` 補了
|
||||
> 唯讀 `-d` 與 notify_leo 的放行、三支閘認得 youlin 09-02 起的新子網域——**都是改判準不是加閘**。
|
||||
|
||||
> 📌 **`inkstone/ISEP#82`(2026-08-28)+1 支、+3 條**:`mainline-focus-guard.sh`
|
||||
> 同時掛在 `Agent` 與 `Task`(派工的兩個入口都要守,只守一個等於沒守),
|
||||
> 加上 SessionStart 多掛一條 `scripts/mainline refresh`(更新主線的進度/期限,不是閘)。
|
||||
@@ -60,6 +122,32 @@
|
||||
> 不是拿上一版加減推的。順手改掉兩個過期的數字:`README.md` 的「48 支/59 條註冊/23 支腳本」
|
||||
> ——實數是 53/68/35(那是**併之前** main 上的實況),本版之後是 56/75/36。
|
||||
> **同一個病,只是換一個檔案。**
|
||||
>
|
||||
> 📌 **`inkstone/ISEP#93` + `#89`(2026-08-28)進來兩支、+3 條**:
|
||||
> `overdue-nag-guard.sh`(E 組,SessionStart)與 `wiki-size-guard.sh`(E 組 + B 組,兩個事件)。
|
||||
> 58→**60** 支、79→**82** 條——**兩個數字都是在「把 main 併進來之後」的樹上當場數出來的**,
|
||||
> 不是拿上一版加減推的(本頁自己上面就記著「不能靠加減推算、只能實數」)。
|
||||
>
|
||||
> 🔴 **這次併 `hooks.json` 學到一件比數字更重要的事**:它是 JSON,
|
||||
> **合錯了語法照樣合法、閘卻不見了,不會有任何東西喊一聲。**
|
||||
> 「同一個陣列位置兩邊各有一支」時(本次是 `roster-guard` vs `mainline-focus-guard`),
|
||||
> 正解是**展開成兩個項目**,不是二選一——選一邊就會讓一支真的閘靜靜消失。
|
||||
> ⇒ 合完一律再跑一次**逐支點名**(指令寫在 `README.md`「裝什麼」那段),
|
||||
> 並跟兩個父節點做集合對照:**從 main 掉了幾條、從自己這邊掉了幾條、冒出幾條**。
|
||||
> 本次三個答案都是 0。
|
||||
>
|
||||
> 📌 **順手抓到的漂移(每次併都抓到一個,這次是三個)**:
|
||||
> ① 本頁的「一句話結論」被貼成**三份**、標頭被貼成**兩份**(數字還互相打架);
|
||||
> ② `README.md` 的 `hooks/` 那列累積成**四列**,其中兩列是舊數字;
|
||||
> ③ `plugin.json` 描述欄寫「49 支腳本」,而用本頁寫死的那道指令
|
||||
> (`ls -p scripts | grep -v / | wc -l`,只數檔案)在合併後的樹上實數是 **48**。
|
||||
> **同一個病,第 N 次,只是換一欄。**
|
||||
|
||||
> 📌 **`inkstone/ISEP#91`(2026-08-31)退一支、−1 條**:`sdd-guard.sh`(B 組)退役,
|
||||
> 執行 leo 2026-08-16 在 `inkstone/InkStoneCo#40` 那個「取消 Active SDD」的裁決。
|
||||
> 62→**61** 支、85→**84** 條,兩個數字都是**在自己這棵樹上當場數出來的**
|
||||
> (`ls hooks/*.sh | wc -l`/`grep -c '"command":' hooks/hooks.json`),不是拿上一版減一。
|
||||
> 退役的理由與順序寫在 B 組表格底下那段。
|
||||
|
||||
> 📌 **`0.10.0`(`inkstone/ISEP#81`,2026-08-28)進來一支**:`pr-verdict-guard.sh`(F 組,Stop)。
|
||||
> 51→**52** 支、64→**65** 條,兩個數字都是加完之後當場數出來的(指令同上)。
|
||||
@@ -93,20 +181,21 @@
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `github-contact-guard.sh` | AI 想碰 GitHub(`git push`、`gh api` 等)就先擋下——兩個帳號曾經因為機器人式高頻寫入被永久 flag 過。**只有讀取(clone/fetch)自由,寫入一律要你在終端機跑 `github-arm.sh` 解鎖**。 | 🛑 擋 |
|
||||
| `github-contact-guard.sh` | AI 想碰 GitHub(`git push`、`gh api` 等)就先擋下——兩個帳號曾經因為機器人式高頻寫入被永久 flag 過。**只有讀取(clone/fetch)自由,寫入一律要你在終端機跑 `github-arm.sh` 解鎖**。判「這次 push 到哪」看的是**這條指令實際會推的那個 repo**(含 `cd …&&`、`git -C …`、cwd 在子目錄),不是 hook 站的 cwd——雲端薄殼站在 GitHub 那份、推的是 Gitea 那份時不會誤擋,反過來也不會漏(`inkstone/ISEP#109`→6629)。 | 🛑 擋 |
|
||||
| `kbdb-api-wall-guard.sh` | AI 想繞過 KBDB 的 API、直接對資料庫下 SQL 或建新表就擋下——KBDB 的鐵律是「只能用 API、永遠只有三張表」。 | 🛑 擋 |
|
||||
| `stage-before-prod-guard.sh` | AI 想動「出貨鏈」相關指令(含 `arcrun-rag-bundles`/`github-arm`/`publish-github` 字樣)卻沒先在 stage 環境驗過就擋下。 | 🛑 擋 |
|
||||
| `main-and-prod-push-guard.sh` | 兩層確認:① subagent 想推 Gitea `main` 分支 → 一定要交回總管,總管確認過才放行;② 任何 `wrangler deploy` 打正式環境 → 一定要你親手解鎖。 | 🛑 擋 |
|
||||
| `prod-write-guard.sh` | AI 想做任何「會真的改到線上正式帳號(leo21c)」的操作(不只是 push,也包含直接呼叫會寫入的工具)就擋下,交回總管處理。 | 🛑 擋 |
|
||||
| `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 |
|
||||
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 |
|
||||
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。**讀一律放行**(含 `tr -d`/`cut -d=` 這種唯讀的 `-d`,09-04 雲端就是被這個誤攔到「連讀都讀不到」),發一則 `notify_leo`(只送訊息、什麼都沒改)也放行——跟 `prod-write-guard.sh` 同一份白名單(inkstone/ISEP#130)。 | 🛑 擋 |
|
||||
| `release-tag-guard.sh` | AI 想打版本 tag,但 `plugin.json` 裡寫的版本號跟 tag 對不上就擋下——避免「這裡說 0.2.0、那裡說 0.1.0」各說各話。 | 🛑 擋 |
|
||||
| `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 新增 Gitea 東西(開新票/milestone/label/PR,會漏掉「新增前先搜過」這道檢查)就擋下——不管是隱式 POST(urllib 傳 data= 沒寫 method)還是小寫 `requests.post(...)`。 | 🛑 擋 |
|
||||
| `milestone-due-guard.sh` | AI 想開一個新的里程碑(milestone)卻沒填期限就擋下,**填 `9999-01-01` 一樣擋**——那是「沒有期限」穿了一件期限的衣服,盤點時每一格看起來都有值,其實一格都沒有。只管「建里程碑」這個動作,查詢/改別的欄位都放行。 | 🛑 擋 |
|
||||
| `milestone-account-guard.sh` | AI 想直接打 API 把某個里程碑改成「已關閉」,卻**還沒留下「估多久/花多久/差多少/為什麼差」**就擋下。跟上一列是同一條線的兩端:那支管**開**里程碑要有真的期限,這支管**關**的時候要留下帳。走正門 `scripts/milestone-account close` 就會自己算(數字取自 Gitea 的 `created_at`/`due_on`/`closed_at`,不用手填),差超過 ±25% 才要你從**七個固定代號**裡挑一個,而且那個代號要有 Gitea 時間軸撐得住——**不採信自述**。純讀取/建里程碑/改期限/只是談論它/走正門,全部放行;真要手動關就在指令裡加 `milestone-account-ok`(留痕)。 | 🛑 擋 |
|
||||
| `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 |
|
||||
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) |
|
||||
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。**擋下來時印的是一整塊可以直接貼著跑的指令**(腳本用絕對路徑、母票已經填好、內文草稿附在同一塊裡),那塊字是跟 `scripts/ticket usage subtask` 現要的,不是閘自己抄的一份(`inkstone/ISEP#112`)。 | 🛑 擋(同一輪只擋一次) |
|
||||
| `search-is-not-proof-guard.sh` | AI 用知識庫的**搜尋**找到一筆東西,然後把那筆東西的原始編號直接貼進票裡或派工單裡,當成「某個功能壞了」的證據——而它從沒用正規的檢索路徑去查過那筆——擋一次。你關心的是:不會再有人拿錯的方法量出一個錯的結論,然後退回一張其實已經做完的票。 | 🛑 擋(同一輪只擋一次) |
|
||||
| `line-needs-own-worktree.sh` | 一條派工線想在**大家共用的那份工作目錄**裡切分支(`git checkout`/`git switch`)就擋下,叫它先開一份自己的 worktree——**開在 repo 裡面的 `.worktrees/`,不是旁邊**(`scripts/worktree open`;`inkstone/ISEP#147`:leo 在 Finder 看到一排 `ISEP-wt-117`/`InkStoneCo-wt-112` 就是舊版教出來的)。同一支閘也掛在線收工那一刻(PostToolUse `Agent|Task`):從派工單的【工單】認出分身,分支推了且乾淨就收掉,沒推/有東西沒交代就點名分支與路徑——不靜默、不 force、不刪沒推的 commit。你關心的是:一個 repo 在 Finder 只有一個資料夾,而且不會再有人交回工作時把某個 repo 留在自己的分支上。**總管在自己的目錄裡切分支不管**(那是他的目錄);已經在自己 worktree 裡的切換全部放行;還原檔案(`git checkout -- 檔`)不管。擋下來的同時會順手把「指向空氣」的 worktree 登記清掉。切分支的出路是閘訊息印的那一行 `WORKTREE_OK=1 git -C … checkout …`——認的是**指令字串裡**的這個前綴,原樣貼回去就過(`inkstone/ISEP#109`→6587)。 | 🛑 擋/收工 🚨 點名 |
|
||||
| `diagnosis-evidence-guard.sh` | 你想把一段有份量的東西寫上票,而這個 session **沒派人去查過那張票**、那段話裡也**沒有任何走得過去的出處**(檔案:行號/commit/comment 號/票號/網址/貼出來的實測輸出)就擋一次——未經調查的診斷寫在票上會長得像事實,工人會照著它去驗證而不是去查。派過人查、或把出處帶上,都放行;一句短回覆不管。 | 🛑 擋 |
|
||||
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit)
|
||||
|
||||
@@ -115,13 +204,31 @@
|
||||
| `guard-cross-project.sh` | 總管(頂層)想直接改某個子 repo 的程式碼(非 `.md`)就擋下——頂層只做安排交辦,實作要進那個子 repo 自己做。 | 🛑 擋 |
|
||||
| `wiki-secret-scan.sh` | 要寫進 `system-dev/wiki/` 的內容裡出現密碼/金鑰/身分證/信用卡等特徵就擋下,防止機敏資料意外留在會被反覆讀取的記憶空間裡。 | 🛑 擋 |
|
||||
| `component-guard.sh` | AI 想自己新建一個零件(component)或亂接 service binding 就擋下——逼它先想「現成零件夠不夠用」,真要建要你解鎖。 | 🛑 擋 |
|
||||
| `sdd-guard.sh` | AI 想直接動程式碼檔案,但找不到「唯一一份 active 規格文件(SDD)」對應這件事,或同時有一份以上 active 規格就擋下。 | 🛑 擋 |
|
||||
| `credential-only-guard.sh` | AI 想把金鑰真身或自製佔位符(例如 `__XXX_TOKEN__`)寫進設定檔就擋下——金鑰只准放在統一的 credential 中心。 | 🛑 擋 |
|
||||
| `arcrun-intent-guard.sh` | AI 寫的 Arcrun workflow 語法不對就擋下,而且**直接把正確寫法回貼給它**(不是只罵它錯,是教它怎麼改)。 | 🛑 擋(教學型) |
|
||||
| `subagent-first-guard.sh` | 這個對話**從頭到尾都沒有派過任何 subagent**,AI 卻要自己動手改程式碼,就先擋一次,逼它想一想「這件事能不能交給別人做」。 | 🛑 擋 |
|
||||
| `mistake-needs-ticket-guard.sh` | AI 想往 `mistakes.md`(教訓紀錄)新增一條「機制可以防止」的教訓,卻沒附對應票號就擋下——沒有票號的教訓沒有人會回頭處理。 | 🛑 擋 |
|
||||
| `wiki-size-guard.sh` | AI 想**一口氣砍掉 wiki 檔一大半內容**(淨縮水超過 800 字且超過原本 45%)就擋一次——那不是一次編輯,那是一次壓縮,而**壓縮會弄丟東西,弄丟的當下沒有人會發現**。出路是走 `scripts/wiki-compress`:它逼你附票號、把壓掉了什麼寫進 `.compress-log.md`,並用內文雜湊**逐條對帳**證明沒弄丟。只是改字、加字、小修一律不碰;真要手改就在內容裡放 `wiki-compress-ok` 留痕。 | 🛑 擋(至多攔一次) |
|
||||
| `pending-changes-retired.sh` | AI 想寫東西進已經廢除的 `pending-changes.md` 檔案就擋下——這個檔案已停用,規格變更一律改開 Gitea 票。 | 🛑 擋 |
|
||||
|
||||
> 🔴 **`sdd-guard.sh` 已於 `inkstone/ISEP#91` 退役**(原本掛在這一組)。
|
||||
> 它擋的是「動 code 檔時,`status: active` 的 SDD 不是恰好一份」。
|
||||
> leo 2026-08-16(`inkstone/InkStoneCo#40` → comment 2942)已裁定**取消 Active SDD**:
|
||||
> 任務狀態搬到 Gitea 管,SDD 只記「起初的樣子」,不帶任務 checkbox。
|
||||
> ⇒ 那支閘要的東西,制度上已經不會再有人生產。
|
||||
>
|
||||
> **它為什麼非退不可,而不是放著沒關係**:它是 fail-closed 的——
|
||||
> `active` 不是恰好 1 份就擋,**0 份也擋**;路徑所在的 repo 沒有 `3-specs` 也擋。
|
||||
> 所以「照裁決把 active SDD 拿掉」這個動作本身,會把所有人動任何
|
||||
> `.ts`/`.py`/`.go` 的路一起鎖死。這就是那個裁決躺了 11 天沒人敢執行的真正原因。
|
||||
> 而 **ISEP 這個 repo 自己就沒有 `3-specs`**:退役之前,改自己的 `hooks/lib/*.py`
|
||||
> 當場被這支閘擋下——那不是假設,是這張票開工第一件事實測到的。
|
||||
> ⇒ 退役的順序是「**先讓閘退役,再拿掉 active SDD**」,顛倒過來就鎖死。
|
||||
>
|
||||
> 迴歸測試在 `hooks/tests/sdd-guard-retired.test.sh`:它測的不是「檔案刪了沒有」,
|
||||
> 而是把「裁決執行完之後的世界」丟給整組寫檔閘,**不准有任何閘用 SDD 當理由擋下來**
|
||||
> ——有人日後換個檔名把同一個形狀種回來,那支測試照樣會紅。
|
||||
|
||||
> `kbdb-api-wall-guard.sh` 在這裡也重複掛了一次(見 A 組)——它同時守著「下指令」跟「寫檔案」兩種情境,詳見下方「重複掛載」一節。
|
||||
|
||||
## C. AI 要去翻程式碼/查資料之前(PreToolUse / Grep·Glob·Read·Bash)
|
||||
@@ -141,8 +248,9 @@
|
||||
| `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 |
|
||||
| `dispatch-format-guard.sh` | **派工單 = 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。純禮貌收尾(「謝謝」)不算違規;`(→ comment M)` 不論全形或半形括號都認得(ISEP#65 修過一次:CLAUDE.md 自己規定的合格寫法曾經被這支閘自己擋下)。**`ISEP#88` 起同一支閘也掛在「回覆 subagent」那條路上**(`SendMessage`、雲端 session/trigger、以及 Bash 的 `claude -p`)——回覆也是派工,同一套規矩;擋下來時會把那段內容**原文印出來**方便直接貼上票。subagent 往上回報(`to: "main"`)不管,那是交件不是派工。 | 🛑 擋(+注入) |
|
||||
| `roster-guard.sh` | 派工要**指名派給誰**:`Task` 的 `subagent_type` 得是 `agents/` 名單上的名字(`isep-hand`/`arcrun-hand`/`scout`…),沒指名或指了不存在的名字就擋下,並把整份名單印出來。指對了它會**自動把那位工人的檔案原文注入**給它——你是誰、負責哪個 repo、開工前先讀什麼、你的紅線是什麼(這些每個工人都不一樣,共通規定補不了)。名單是資料:加一個工人=在 `agents/` 加一個 `.md`,不必改任何 hook。 | 🛑 擋(+注入) |
|
||||
| `mainline-focus-guard.sh` | 派出去的票**不在「現在的主線」上**,就擋一次,並問你是「補收」(它本來就該掛在主線上)還是「跳線」(真的插件事)。背景:2026-08-27 實查有 **14 個 milestone 同時開著**,其中「Mira 現代化」同名活在 5 個 repo ⇒ SOP 說的「只做該 milestone 的事」在現場**沒有指涉對象**,等於不存在。現在主線是一個查得到的事實(`scripts/mainline`,答案唯一),這支就是那個事實的用處。🔴 **沒有標主線 ⇒ 一律放行**;多張票只要有一張在主線上就放行;查不到那張票掛在哪也放行(**讀不到 ≠ 不屬於**);**同一張票只擋一次**,重送就過。跳線會留一筆紀錄——不留痕的話,跳線與專注在機器上長得一模一樣。 | 🛑 擋(同一張票至多一次) |
|
||||
| `mainline-focus-guard.sh` | 派出去的票**不在「現在的主線」上**,就擋一次,並問你是「補收」(它本來就該掛在主線上)還是「跳線」(真的插件事)。背景:2026-08-27 實查有 **14 個 milestone 同時開著**,其中「Mira 現代化」同名活在 5 個 repo ⇒ SOP 說的「只做該 milestone 的事」在現場**沒有指涉對象**,等於不存在。現在主線是一個查得到的事實(`scripts/mainline`,答案唯一),這支就是那個事實的用處。「在不在主線上」看兩個 Gitea 欄位:掛在錨那一個 milestone、或被主線成員當相依(`/blocks`,跨 repo)——**別 repo 裡同名的里程碑不算**(leo 2026-09-07,`inkstone/ISEP#133`)。🔴 **沒有標主線 ⇒ 一律放行**;多張票只要有一張在主線上就放行;查不到那張票掛在哪、或查不到誰指著它,也放行(**讀不到 ≠ 不屬於**);**同一張票只擋一次**,重送就過。跳線會留一筆紀錄——不留痕的話,跳線與專注在機器上長得一模一樣。 | 🛑 擋(同一張票至多一次) |
|
||||
| `dispatch-format-guard.sh` | **派工單 = 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。純禮貌收尾(「謝謝」)不算違規;`(→ comment M)` 不論全形或半形括號都認得(ISEP#65 修過一次:CLAUDE.md 自己規定的合格寫法曾經被這支閘自己擋下)。 | 🛑 擋(+注入) |
|
||||
| `parallel-lines-cap-guard.sh` | **同時在跑的線超過上限就派不出下一條**(預設 3,`ISEP_MAX_PARALLEL_LINES` 可調),而且訊息會列出現在有哪幾條在跑、各跑了多久。你關心的是:不會再因為一次派七條而讓機器爆掉,也不會再有「同時盯七條線所以派錯、量錯」那種錯。**條數是從 harness 自己寫的檔案數出來的,不是靠誰記得**——所以某一條收工,下一條立刻派得出去,不會卡死。真的非同時不可:`touch /tmp/.parallel-ok-<session_id>`(用一次就消失)。 | 🛑 擋 |
|
||||
|
||||
## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion)
|
||||
|
||||
@@ -160,9 +268,12 @@
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。 | 📝 記錄(context 注入) |
|
||||
| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。它另外會報四件「這一份是不是還有效」:①這一份落後 ISEP main 幾版 ②專案裡有沒有內容不同的同名**腳本**②b 專案裡有沒有內容不同的同名**skill/command/agent**——這些是**自動載入**的,載到舊的那份不會有任何症狀,只會安靜地教錯的東西(`docs/file-ownership.tsv` 說得出每一組的真相源是哪一份、往哪個方向修)②c 本身的內容跟那張歸屬表對不對得上(雲端沒有專案那一份可比時,這是唯一還作數的檢查)③工作區有沒有已退役機制留下的產物 ④**打了 tag 卻沒 release**(Gitea 最新 tag 沒有 release、或 main 定版了還沒打 tag)——一行說缺哪一站+可以直接貼著跑的補法(`scripts/release-ship`),都在時一個字都不說;判準跟 `release-ship`/`release-check` 同一份(`hooks/lib/release_chain.py`),匿名讀、問不到就閉嘴(`inkstone/ISEP#67`)。**全部只講不擋。** | 📝 記錄(context 注入) |
|
||||
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
|
||||
| `scripts/mainline refresh` | (不是閘,是腳本)對話一開始把**現在的主線**那條 milestone 的進度與期限更新一次,好讓每回合眼前那一行講的是今天的數字。拿不到 Gitea 就原封不動——**寧可資料舊,不要把主線弄丟**。跑一次就結束,不輪詢。 | 📝 記錄 |
|
||||
| `scripts/settings-allow-sync --quiet` | (不是閘,是腳本)對話一開始把 ISEP 的權限白名單(`docs/permissions-allow.json`:`ticket`/`mainline`/`gate-ok`/`gitea-pr-merge`/`stage`(inkstone/ISEP#137 起,雲端對 stage 寫入的正門)五個正門工具,外加 Arcrun 的 `stage-deploy-artifacts.py`)寫進**這台機器**的 `~/.claude/settings.json`。只加不減、冪等;目標檔壞掉就不碰它、講一聲。**為什麼**:leo 09-07 親手加的四條只在本機,雲端沒有 ⇒ 同樣的動作被分類器擋(09-04 run log `permission_denials=6`);薄殼 repo 要 D20 開閘才推得動,所以清單住 ISEP 一份、兩台各自寫進自己的家目錄(inkstone/ISEP#130)。 | 📝 記錄(寫 settings) |
|
||||
| `scripts/mainline refresh` | (不是閘,是腳本)對話一開始把**現在的主線**那條 milestone 的進度與期限更新一次,好讓每回合眼前那一行講的是今天的數字。拿不到 Gitea 就原封不動——**寧可資料舊,不要把主線弄丟**。跑一次就結束,不輪詢。成員清單(hub 里程碑裡的票+沿相依邊跨 repo 收到的,`inkstone/ISEP#133`)**不在這裡重收**——真 Gitea 一走 19 秒;`set`/`adopt`/`refresh --members` 才重收,`pick`/`has`/focus guard 每次現問。**家目錄與 repo 那份都不在時**(雲端薄殼跑到這裡 `InkStoneCo/` 還沒 clone)先去 Gitea 讀 `inkstone/InkStoneCo` main 的 `system-dev/mainline.json` 一次寫進家目錄(`inkstone/ISEP#140`):匿名優先、被拒才帶 token;讀不到就退回,不報錯、逾時有上限。優先序寫在 `hooks/lib/mainline.py` 檔頭。 | 📝 記錄 |
|
||||
| `overdue-nag-guard.sh` | 對話一開始就去 Gitea 撈**沒有人會叫的事**:逾期的 milestone、掛著等你的票(標「等了幾天」)、標著「有人在做」卻好幾天沒動的票;撈完用白話講出來,**有事就發 Telegram 給你**。另外點名**有開著的票、工人名單上卻沒人負責的 repo**(新開 repo 後第一個開場就會叫,`inkstone/ISEP#118`)——這一類只給總管看,不發 Telegram。沒東西可報時它會說「查過了,沒有」——**安靜跟壞掉長得一模一樣**。發不出去時不會靜默:它會先問這個 session 的 `prod-write-guard` 會不會擋(舊版把「發通知」誤認成「部署」),擋就改貼回票上並把原文印在眼前。只在開 session 時跑一次,**不輪詢、不掛排程**。 | 📝 記錄(context 注入 + Telegram) |
|
||||
| `wiki-size-guard.sh` | 對話一開始講出「哪幾個 wiki 檔已經沒有人讀得完了」(預設超過 1200 行就點名)。**讀不完的必讀檔,跟沒有那個檔的差別只在於它讓人以為有。** 另一半掛在寫檔上,見 B 組。 | 📝 記錄(context 注入) |
|
||||
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
|
||||
|
||||
## F. AI 想結束這一輪、要收工的時候(Stop)
|
||||
@@ -181,6 +292,8 @@
|
||||
| `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 |
|
||||
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
|
||||
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
|
||||
| `queue-drain-guard.sh` | **在接票的 session(跑過 `ticket pick/claim/handback`,或環境設了 `ISEP_DRAIN=1`),池子裡還有現在就能抓的票,就不准收工**——它自己跑一次 `ticket pick`,有票就把票號和認領指令交回去。「池子」多大看環境:設了 `ISEP_DRAIN=1`(雲端工人)時 pick 會看到整個 open 票池的 `s/todo`/`s/backlog`;本機沒設時只看主線與逾期里程碑,所以總管跑過 `handback` 也不會被叫去抓票池(`inkstone/ISEP#130`)。雲端環境下可做的都抓盡、還有沒 s/* 的票時,pick 回一張待分診的,本閘照那一列印 `ticket triage …` 而不是 claim(`inkstone/ISEP#159`)。這是 ISEP 唯一一支看「佇列」而不是看「這回合寫了什麼」的收工閘(`inkstone/ISEP#33`:雲端三趟 run 都是做完一件、還有票就停)。做不了的票要把理由寫回票、改欄位,pick 就不再回它。跟 leo 聊天的 session 不會被咬;同一張連續指名 3 次、讀不到佇列 3 次、或一個 session 擋滿 40 次就放行並留痕,不會鎖死。 | 🛑 擋(有上限) |
|
||||
| `baton-handback-guard.sh` | **背景派出去的工人交件時**(主對話收到完工通知),檢查那張工單票有沒有把棒子交回來(指派/標籤/下一步三格),缺就擋一次要 AI 補。工人還在跑的時候一聲都不出(`inkstone/ISEP#153`)。同一條線只點名一次;補完三格就不再出現。前景派工的那一半在下面 H 組。 | 🛑 擋(每條線至多一次) |
|
||||
|
||||
> 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。
|
||||
> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58:
|
||||
@@ -200,14 +313,14 @@
|
||||
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
|
||||
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
|
||||
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
|
||||
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) |
|
||||
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。**只管前景派工**:背景派工送出的那一刻工人才剛出發,這裡不出聲,等它交件時由 F 組的 Stop 那半查(`inkstone/ISEP#153`)。 | 📝 記錄(提醒,不擋) |
|
||||
| `kbdb-evidence-stamp.sh` | AI 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 |
|
||||
| `investigate-first-stamp.sh` | 你真的**派人去查某張票**之後,按「session + 票號」留一個時間戳——給 `diagnosis-evidence-guard.sh` 判斷「這件事有沒有人查過」用。按票分而不是按 session 分:早上派人查了 A 票,不該等於解鎖了 B 票的診斷。 | 📝 記錄 |
|
||||
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `history-first-guard.sh` | AI 要改一個舊檔案之前,先把這個檔案過去被改過幾次、被誰在什麼情況下改過的紀錄攤在它眼前,逼它回答「這是不是已經修過的老問題」再動手。 | 🛑 擋 |
|
||||
| `history-first-guard.sh` | AI 要改一個舊檔案之前,先把這個檔案過去被改過幾次、被誰在什麼情況下改過的紀錄攤在它眼前,逼它回答「這是不是已經修過的老問題」再動手。前面還有第 0 道:這一小時內有沒有問過 KBDB。**KBDB 連不上時 `touch /tmp/.kbdb-down` 就放行**——這條逃生門 2026-09-02 之前是壞的(`touch` 造的是空檔,而它讀的是檔案內容)**照著訊息打完還是被擋**,`inkstone/ISEP#122` 修好並補了迴歸測試。 | 🛑 擋 |
|
||||
|
||||
---
|
||||
|
||||
@@ -243,8 +356,8 @@ countdown-milestone-refresh.sh ← 假警報:它在 scripts/ 不在 hooks/
|
||||
🔴 **上面那一筆是假警報,不是「指向空氣」的閘**:`inkstone/ISEP#63` 之後,
|
||||
SessionStart 上掛的東西不全是 `hooks/*.sh`——`scripts/countdown-milestone-refresh.sh`
|
||||
是腳本不是閘。這組指令只比檔名、不看目錄,所以它會出現在這裡。
|
||||
(`inkstone/ISEP#82` 又多掛一條 `scripts/mainline refresh`,那一條沒有 `.sh`
|
||||
副檔名,所以不會落進這個名單。)**照著跑會看到它,這裡先講清楚,
|
||||
(`inkstone/ISEP#82` 又多掛一條 `scripts/mainline refresh`、`inkstone/ISEP#130` 多掛一條
|
||||
`scripts/settings-allow-sync --quiet`,這兩條沒有 `.sh` 副檔名,所以不會落進這個名單。)**照著跑會看到它,這裡先講清楚,
|
||||
免得下一個人以為是自己弄壞的。**
|
||||
|
||||
🔴 **上面那兩行驗不出「表格漏列」**——它們比的是 `hooks.json`,不是這張表。
|
||||
@@ -322,6 +435,7 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
|
||||
| `micromanage-guard.sh` / `irreversible-dispatch-guard.sh` / `no-ticket-no-dispatch.sh` | 各 2 次 | 各自同時掛在 `Task` 與 `Agent` 兩個矩比對名稱上——這兩個名稱應該是同一種派工動作的新舊叫法,兩個都掛保證不漏接 |
|
||||
| `worklist-guard.sh` / `self-drive-police.sh` / `self-drive-judge.sh` / `delivery-police.sh` / `wiki-first-police.sh` / `unpushed-police.sh` | 各 2 次 | 一次守「總管自己想收工」(Stop),一次守「subagent 交回工作」(SubagentStop)——同一套判準用在兩種角色身上 |
|
||||
| `search-is-not-proof-guard.sh` | 3 次 | 拿搜尋結果當證據的那個動作,可能是「貼進票裡」(Bash),也可能是「寫進派工單發包出去」(`Task`/`Agent` 兩個名稱各掛一次),三種都要接住 |
|
||||
| `baton-handback-guard.sh` | 2 次 | 同一件事的兩種收工時機:前景派工 tool 回來就是交件(PostToolUse),背景派工要等完工通知進主對話(Stop) |
|
||||
|
||||
❓ **這裡有一件我看不出來是刻意還是遺留、需要人判斷**:`micromanage-guard.sh`/`irreversible-dispatch-guard.sh`/`no-ticket-no-dispatch.sh`
|
||||
三支都**同時**掛在 `Task` 跟 `Agent` 這兩個矩比對名稱上。如果這兩個名稱在目前版本的 Claude Code 裡指的是「同一種派工工具呼叫」,
|
||||
@@ -370,4 +484,4 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
|
||||
|
||||
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落**第二組**(`inv_fs` vs `inv_table` 的兩行 `comm`)**加第三格 C**,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單。**不是第一組**——第一組比的是 `hooks.json`,只驗證有沒有註冊,驗不了有沒有寫進這張人話表。2026-08-27 漏掉的那兩列,第一組是綠的、第二組才紅。**抓表格列的那組 `grep` 一定要帶 `^\| \`` 錨點**,理由與拿掉之後會冒出的 3 個假警報,就寫在那一段裡。
|
||||
2. **有沒有閘的行為跟這裡寫的不一樣了**:抽幾支重新讀一次源碼,跟這裡的「一句話」對一遍——就是本文「抽驗 5 支」做的事,可以照同樣方法定期重做。
|
||||
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
|
||||
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"_what": "Claude Code 權限白名單(permissions.allow)的唯一清單——本機與雲端都從這裡讀,不各養一份(inkstone/ISEP#130)",
|
||||
"_why": [
|
||||
"leo 09-07 親手把四個 Gitea 正門工具(ticket/mainline/gate-ok/gitea-pr-merge)加進本機 InkStoneCo/.claude/settings.json,auto mode 的分類器才不擋。",
|
||||
"雲端 session 沒有那四條,同樣的動作照樣被擋(09-04 progress-guard run log:permission_denials=6)。",
|
||||
"薄殼 repo(GitHub)要 D20 開閘才推得動 ⇒ 白名單住 ISEP,`scripts/settings-allow-sync` 開工時寫進那台機器的 ~/.claude/settings.json。",
|
||||
"以後加規則只改這個檔、出一版,雲端下一趟就有。",
|
||||
"inkstone/ISEP#137:09-07 雲端對 stage 的三種寫入(`npx wrangler secret put`/`stage-deploy-artifacts.py`/curl POST 到 arcrun-yuga3bse)全被分類器擋——複合指令與任意 curl 對不上任何一條前綴。收成 `scripts/stage`(secret/api 兩個子命令,只寫 stage、值只從環境變數名讀)四種形狀,外加 Arcrun 自己那支 `scripts/stage-deploy-artifacts.py`(它已寫死只認 youlin 帳號 ID、不收 --account/--token)從 Arcrun repo 根目錄的相對形狀。不放 `Bash(npx *)`/`Bash(wrangler *)`/`Bash(curl *)`。",
|
||||
"inkstone/ISEP#67:SessionStart 信標印的補法是 `python3 <plugin root>/scripts/release-ship vX.Y.Z`——那一行在雲端要是被分類器擋,出路就走不通(閘的價值是「擋下來+給一條走得通的路」)。release-ship 只 POST 一件事(建 release,tag 與 release 名寫死從 Gitea 讀);release-check 純讀。",
|
||||
"inkstone/ISEP#147:worktree 閘印出來的出路是 `python3 <plugin>/scripts/worktree open …`/`close …`,收工那一半也是同一支工具;出路在雲端被分類器擋就是走不通的路(閘的價值是「擋下來+給一條走得通的路」)。它只碰 git worktree,不打網路寫入。"
|
||||
],
|
||||
"_rules": [
|
||||
"開頭的 `~` 由 settings-allow-sync 換成那台機器的家目錄(本機 /Users/…、雲端 /root),其餘一字不改。",
|
||||
"只放「正門工具」:每一條都要說得出它是哪支腳本的哪種呼叫形狀。不放 Bash(python3 *) 這種整類放行。",
|
||||
"同一支工具幾種呼叫形狀就幾條(快取路徑/$CLAUDE_PLUGIN_ROOT/相對路徑)——權限規則是逐條完全比對,形狀差一個字就落在規則之外(scripts/gate-ok 檔頭記過這一課)。",
|
||||
"別的 repo 的正門工具也可以進來(例 Arcrun 的 `scripts/stage-deploy-artifacts.py`),條件一樣:那支腳本自己把「只打哪台」寫死在程式裡,本清單放的是它從那個 repo 根目錄跑的相對形狀。"
|
||||
],
|
||||
"allow": [
|
||||
"Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/ticket *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT/scripts/ticket\" *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/ticket *)",
|
||||
"Bash(python3 scripts/ticket *)",
|
||||
"Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/mainline *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT/scripts/mainline\" *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/mainline *)",
|
||||
"Bash(python3 scripts/mainline *)",
|
||||
"Bash(bash ~/.claude/plugins/cache/inkstone/isep/*/scripts/gate-ok *)",
|
||||
"Bash(bash \"$CLAUDE_PLUGIN_ROOT/scripts/gate-ok\" *)",
|
||||
"Bash(bash \"$CLAUDE_PLUGIN_ROOT\"/scripts/gate-ok *)",
|
||||
"Bash(bash scripts/gate-ok *)",
|
||||
"Bash(bash scripts/gitea-pr-merge.sh *)",
|
||||
"Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/stage *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT/scripts/stage\" *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/stage *)",
|
||||
"Bash(python3 scripts/stage *)",
|
||||
"Bash(python3 scripts/stage-deploy-artifacts.py *)",
|
||||
"Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/release-ship *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT/scripts/release-ship\" *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/release-ship *)",
|
||||
"Bash(python3 scripts/release-ship *)",
|
||||
"Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/release-check *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT/scripts/release-check\" *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/release-check *)",
|
||||
"Bash(python3 scripts/release-check *)",
|
||||
"Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/worktree *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT/scripts/worktree\" *)",
|
||||
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/worktree *)",
|
||||
"Bash(python3 scripts/worktree *)"
|
||||
]
|
||||
}
|
||||
+170
-65
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
|
||||
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
|
||||
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);真要放行在指令裡留 `baton-ok: <理由>`(本閘只提醒不阻擋,不會擋住任何動作)。
|
||||
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);背景工人交件後同一條線只點名一次,補完三格就不再出現。
|
||||
#
|
||||
# baton-handback-guard.sh — 收工=把棒子交回去(PostToolUse: Agent|Task)
|
||||
# baton-handback-guard.sh — 收工=把棒子交回去
|
||||
# 同一件事的兩種收工時機,掛兩個事件:
|
||||
# PostToolUse(Agent|Task) 前景派工:tool 回來=工人交件了 ⇒ 當場查
|
||||
# Stop 背景派工:主對話收到 <task-notification> completed ⇒ 回合結束時查
|
||||
#
|
||||
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
|
||||
@@ -15,73 +18,161 @@
|
||||
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
|
||||
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
|
||||
#
|
||||
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ──────────────
|
||||
# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。
|
||||
# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來
|
||||
# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。
|
||||
# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。
|
||||
# ── 背景派工為什麼改到 Stop 查(inkstone/ISEP#153,09-13 兩次實撞)──────────
|
||||
# PostToolUse:Agent 對背景派工是**送出後幾秒**就觸發(parallel-lines-cap-guard.sh 檔頭有實測時間線),
|
||||
# 那時 tool_response 是 launch ack:{"isAsync": true, "status": "async_launched", ...}
|
||||
# (同一份物件寫在 transcript 的 toolUseResult,2026-08-24 實跡)。
|
||||
# 舊版在這一刻就查票 ⇒ 對 ISEP#150、arcrun-rag#14 各念一次「收工了但棒子沒交回來」,
|
||||
# 而兩個工人之後各跑了 17 分鐘才交件。這會教總管在工人動手前去改票(假交棒),或學會忽略本閘。
|
||||
#
|
||||
# ── 只提醒,不阻擋 ────────────────────────────────────────────────
|
||||
# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。
|
||||
# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。
|
||||
# 真正的交件是主 transcript 裡的 <task-notification>(<status>completed</status>),
|
||||
# 它帶著 <tool-use-id> ——**拿這個唯一識別碼回頭對上當初那一則 Agent tool_use**,
|
||||
# 從它的 prompt 取【工單】。判準是結構比對(哪一則派工),不是讀文字猜。
|
||||
# 不掛 SubagentStop:parallel-lines-cap-guard.sh 檔頭實測過它在主 session 一次都沒觸發。
|
||||
#
|
||||
# ── 只點名一次 ────────────────────────────────────────────────────
|
||||
# Stop 回 exit 2 會讓總管多走一個回合(去補三格)。查過的 tool-use-id 記在狀態檔,之後不再查
|
||||
# ⇒ 補完了自然安靜,沒補也不會鬼打牆。狀態檔在 ${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}。
|
||||
# 前景 PostToolUse 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不擋任何動作。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
# 訊息裡的出路一律印絕對路徑:總管的 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是舊複本,
|
||||
# `grep -c handback` → 0(2026-09-13 實查)⇒ 印相對路徑=教一條走不通的路(同 comment-carries-task-guard ⑮)。
|
||||
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
TICKET="$PLUGIN_ROOT/scripts/ticket"
|
||||
STATE_DIR="${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}"
|
||||
export ISEP_BATON_STATE_DIR="${STATE_DIR%/}"
|
||||
|
||||
# ── 找出這一次要查的票(每行一張:owner repo num)──────────────────────
|
||||
SPECS=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json, re, os
|
||||
|
||||
TICKET = re.compile(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)")
|
||||
|
||||
def spec_of(prompt):
|
||||
for line in (prompt or "").splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
m = TICKET.search(line)
|
||||
if m:
|
||||
return "%s %s %s" % m.groups()
|
||||
return None
|
||||
|
||||
def is_async_ack(resp):
|
||||
# launch ack 兩種長相都認:結構化物件(isAsync/status),或只剩文字的 content 陣列
|
||||
if isinstance(resp, dict) and (resp.get("isAsync") is True or resp.get("status") == "async_launched"):
|
||||
return True
|
||||
try:
|
||||
return "Async agent launched" in json.dumps(resp, ensure_ascii=False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
SPEC=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json, re
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
p = (d.get("tool_input") or {}).get("prompt") or ""
|
||||
for line in p.splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
|
||||
if m:
|
||||
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
|
||||
break
|
||||
|
||||
if d.get("hook_event_name") != "Stop":
|
||||
if is_async_ack(d.get("tool_response")):
|
||||
raise SystemExit # 工人才剛出發,還沒有「收工」可言;交件時由 Stop 那半查
|
||||
s = spec_of((d.get("tool_input") or {}).get("prompt"))
|
||||
if s:
|
||||
print(s)
|
||||
raise SystemExit
|
||||
|
||||
# ── Stop:有沒有背景工人交件 ─────────────────────────────────────────
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.isfile(tp):
|
||||
raise SystemExit
|
||||
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", d.get("session_id") or "nosid")[:64]
|
||||
state = os.path.join(os.environ["ISEP_BATON_STATE_DIR"], "baton-handback-seen-%s" % sid)
|
||||
try:
|
||||
seen = set(open(state, encoding="utf-8").read().split())
|
||||
except OSError:
|
||||
seen = set()
|
||||
|
||||
NOTE = re.compile(r"<tool-use-id>(toolu_[A-Za-z0-9_-]+)</tool-use-id>.*?<status>([a-z_]+)</status>", re.S)
|
||||
completed = [] # 保留出現順序
|
||||
dispatch = {} # tool_use id → 帶【工單】的派工單
|
||||
try:
|
||||
with open(tp, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if "<task-notification>" in line:
|
||||
# 同一則通知會以 enqueue/attachment/remove 出現多次,用 id 去重
|
||||
for tuid, st in NOTE.findall(line.replace("\\n", "\n")):
|
||||
if st == "completed" and tuid not in completed:
|
||||
completed.append(tuid)
|
||||
if "【工單】" in line and "tool_use" in line:
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
msg = e.get("message")
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
continue
|
||||
for b in msg.get("content") or []:
|
||||
if (isinstance(b, dict) and b.get("type") == "tool_use"
|
||||
and b.get("name") in ("Agent", "Task")):
|
||||
p = (b.get("input") or {}).get("prompt")
|
||||
if isinstance(p, str) and "【工單】" in p:
|
||||
dispatch[b.get("id")] = p
|
||||
except OSError:
|
||||
raise SystemExit
|
||||
|
||||
todo = [t for t in completed if t in dispatch and t not in seen]
|
||||
if not todo:
|
||||
raise SystemExit
|
||||
try:
|
||||
os.makedirs(os.path.dirname(state), exist_ok=True)
|
||||
with open(state, "a", encoding="utf-8") as f:
|
||||
f.write("\n".join(todo) + "\n")
|
||||
except OSError:
|
||||
pass # 記不下來頂多再念一次,不擋
|
||||
out = []
|
||||
for t in todo:
|
||||
s = spec_of(dispatch[t])
|
||||
if s and s not in out:
|
||||
out.append(s)
|
||||
print("\n".join(out))
|
||||
' 2>/dev/null || true)
|
||||
|
||||
[ -n "${SPEC:-}" ] || exit 0
|
||||
# shellcheck disable=SC2086
|
||||
set -- $SPEC
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
[ -n "${SPECS:-}" ] || exit 0
|
||||
|
||||
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
|
||||
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
|
||||
# 正式執行時這個變數不存在,一行都不會走到。
|
||||
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
|
||||
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
|
||||
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
|
||||
else
|
||||
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
URL=""
|
||||
for d in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
|
||||
[ -e "$d/.git" ] || continue
|
||||
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||
[ -n "${U:-}" ] && { URL="$U"; break; }
|
||||
done
|
||||
[ -n "$URL" ] || exit 0
|
||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
[ "$TOKEN" != "$URL" ] || exit 0
|
||||
# ── 查一張票:缺哪一格就寫到 stderr 並回 2,齊了回 0 ─────────────────────
|
||||
check_one(){ # $1 owner $2 repo $3 num
|
||||
local OWNER="$1" REPO="$2" NUM="$3" J C REPORT BODY NOW_A NOW_L
|
||||
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
|
||||
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
|
||||
# 正式執行時這個變數不存在,一行都不會走到。
|
||||
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
|
||||
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
|
||||
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
|
||||
else
|
||||
# token:來源=env/.env(inkstone/ISEP#47),不再從 remote URL 抽(那會逼 token 明文嵌在 URL 裡)
|
||||
local TOKEN _GT_LIB
|
||||
_GT_LIB="$(dirname -- "$0")/../scripts/lib/gitea-token.sh"; [ -f "$_GT_LIB" ] && . "$_GT_LIB"
|
||||
command -v gitea_token >/dev/null 2>&1 || return 0
|
||||
TOKEN=$(gitea_token) || return 0
|
||||
[ -n "$TOKEN" ] || return 0
|
||||
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || return 0
|
||||
C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
|
||||
fi
|
||||
|
||||
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || exit 0
|
||||
C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
|
||||
fi
|
||||
|
||||
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
|
||||
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
|
||||
import json, sys
|
||||
try:
|
||||
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
if not isinstance(it, dict) or "state" not in it:
|
||||
raise SystemExit # 讀不到票(404/錯誤回應)⇒ 不瞎報
|
||||
if it.get("state") == "closed":
|
||||
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
|
||||
if not isinstance(cs, list):
|
||||
cs = []
|
||||
miss = []
|
||||
if not (it.get("assignees") or []):
|
||||
miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上")
|
||||
@@ -100,37 +191,51 @@ print("\n".join(" ❌ " + m for m in miss))
|
||||
print("__ASSIGNEES__" + ",".join(a["login"] for a in (it.get("assignees") or [])))
|
||||
print("__LABELS__" + ",".join(labs))
|
||||
PY
|
||||
) || exit 0
|
||||
) || return 0
|
||||
[ -n "${REPORT:-}" ] || return 0
|
||||
|
||||
[ -n "${REPORT:-}" ] || exit 0
|
||||
BODY=$(printf '%s' "$REPORT" | grep -v '^__')
|
||||
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
|
||||
NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p')
|
||||
|
||||
BODY=$(printf '%s' "$REPORT" | grep -v '^__')
|
||||
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
|
||||
NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p')
|
||||
|
||||
cat >&2 <<EOF
|
||||
cat >&2 <<EOF
|
||||
🏃 交棒警察:$OWNER/$REPO#$NUM 這條線收工了,但**棒子沒有交回來**。
|
||||
|
||||
現在票上:指派=[${NOW_A:-(空)}] 標籤=[${NOW_L:-(空)}]
|
||||
|
||||
$BODY
|
||||
|
||||
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
|
||||
直到最後交出正確 deliverable 並且驗證」
|
||||
|
||||
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
|
||||
leo 自己就是用 Gitea 原生的「指派給您的」在看。
|
||||
|
||||
【一個動作把三格補齊】
|
||||
scripts/ticket handback $OWNER/$REPO#$NUM \\
|
||||
"$TICKET" handback $OWNER/$REPO#$NUM \\
|
||||
--to claude-code \\
|
||||
--next "<收下的人第一件事要做什麼,一句話>" \\
|
||||
--label <s/review|s/stage|s/pending|s/todo> \\
|
||||
--evidence "<實測輸出或 PR 連結>"
|
||||
|
||||
要 leo 親手做的 → --to Leo(會自動加 Human 標籤)
|
||||
已經真的做完且驗過了 → scripts/ticket close $OWNER/$REPO#$NUM --deliverable <URL>
|
||||
已經真的做完且驗過了 → "$TICKET" close $OWNER/$REPO#$NUM --deliverable <URL>
|
||||
|
||||
【撈一次看棒子在誰手上】 scripts/ticket mine
|
||||
EOF
|
||||
return 2
|
||||
}
|
||||
|
||||
HIT=0
|
||||
while read -r OWNER REPO NUM; do
|
||||
[ -n "${NUM:-}" ] || continue
|
||||
check_one "$OWNER" "$REPO" "$NUM" </dev/null
|
||||
[ $? -eq 2 ] && HIT=1
|
||||
done <<< "$SPECS"
|
||||
|
||||
[ "$HIT" = 1 ] || exit 0
|
||||
|
||||
cat >&2 <<EOF
|
||||
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
|
||||
直到最後交出正確 deliverable 並且驗證」
|
||||
|
||||
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
|
||||
leo 自己就是用 Gitea 原生的「指派給您的」在看。
|
||||
|
||||
【撈一次看棒子在誰手上】 "$TICKET" mine
|
||||
(背景工人交件後,同一條線只點名這一次;補完三格就不會再出現。)
|
||||
EOF
|
||||
exit 2
|
||||
|
||||
@@ -103,10 +103,64 @@ WARNED="/tmp/.comment-task-guard-warned-$SID"
|
||||
[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆
|
||||
date +%s > "$WARNED"
|
||||
|
||||
cat >&2 <<EOF
|
||||
# ── 出路要真的走得通(inkstone/ISEP#112)────────────────────────────────
|
||||
#
|
||||
# 這支閘 08-27 上線時印的是 `scripts/ticket subtask …`,而那一行**跑不動**,
|
||||
# 兩個獨立的原因(2026-08-31 實測,各自都足以害死它):
|
||||
#
|
||||
# ① 相對路徑對「貼上去那個人的 cwd」解析。總管的 cwd 是 `InkStoneCo/`,
|
||||
# 那裡的 `scripts/ticket` 是舊複本(`grep -c subtask` → 0),
|
||||
# 照著貼只會印出它「四個動詞」的說明然後 exit 0。
|
||||
# ② 用法是**手抄的第二份**,而它已經漂了:寫了 `--assign` 卻沒有 `--next`,
|
||||
# 而正本會因為「指派了人卻沒寫 --next」直接擋下。
|
||||
#
|
||||
# 票上的判決:「**當它教的做法不存在,它就從『幫你』變成『擋你』**——
|
||||
# 而且被擋的人當下正在做別的事,最可能的反應是找個理由繞過去。」
|
||||
#
|
||||
# 🔴 所以這裡**不准再抄一份用法**。整塊指令去正本要:
|
||||
# "$TICKET" usage subtask --example --parent <母票> --draft <草稿路徑>
|
||||
# 正本印的路徑是它自己的 `__file__`(絕對路徑)⇒ 印指令的人與跑指令的人
|
||||
# 是同一個檔案,沒有第二種可能。
|
||||
ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
TICKET="$ROOT/scripts/ticket"
|
||||
|
||||
# 母票是誰:兩條路都認(走正門 `ticket say`,或直接打 API)。認不出來就留 <母票 …>,
|
||||
# 正本那邊會照樣印出可讀的樣板——**寧可少填一格,不要猜錯一張票**。
|
||||
PARENT=$(printf '%s' "$CMD" | python3 -c '
|
||||
import re, sys
|
||||
c = sys.stdin.read()
|
||||
m = re.search(r"/repos/([\w.-]+)/([\w.-]+)/issues/(\d+)/comments", c)
|
||||
if m:
|
||||
print("%s/%s#%s" % m.groups()); raise SystemExit
|
||||
m = re.search(r"\bticket\s+say\s+([\w.-]+)/([\w.-]+)#(\d+)", c)
|
||||
if m:
|
||||
print("%s/%s#%s" % m.groups())
|
||||
' 2>/dev/null)
|
||||
|
||||
DRAFT="/tmp/isep-subtask-$SID.md"
|
||||
EXAMPLE=$(python3 "$TICKET" usage subtask --example \
|
||||
${PARENT:+--parent "$PARENT"} --draft "$DRAFT" 2>/dev/null) || EXAMPLE=""
|
||||
MORE=$(python3 "$TICKET" usage subtask 2>/dev/null) || MORE=""
|
||||
|
||||
# fail-open 的**訊息**(不是閘):正本叫不動時仍然要擋,但不准在這裡補一份手抄用法
|
||||
# ——那樣就把剛拔掉的那個副本裝回來了。只指路,指到唯一那一份。
|
||||
LEAD="整段複製貼上就跑得動。腳本是絕對路徑、母票已經填好、必填段落已經在草稿裡,
|
||||
只剩三格是機器不可能知道的:標題、目標、驗收條件——改完那三格再送。
|
||||
(下面這塊**不要縮排**,\`<<'MD'\` 的結束標記要頂在行首。)"
|
||||
if [ -z "$EXAMPLE" ]; then
|
||||
LEAD="把它長成子票。**這台機器叫不動正本,所以下面只給路、不給整塊指令**
|
||||
——這裡刻意不補一份手抄的用法,那份副本正是 inkstone/ISEP#112 拔掉的東西。"
|
||||
EXAMPLE="python3 $TICKET usage subtask --example"
|
||||
MORE=""
|
||||
fi
|
||||
|
||||
# 🔴 訊息一律用 `<<'EOF'`(標記加引號)+ 事後用 python 換佔位符。
|
||||
# ISEP wiki mistakes.md 記過:不加引號的 heredoc 會把訊息裡的反引號當命令執行,
|
||||
# 而**閘照樣 exit 2,所以只看離開碼的測試完全看不出來**。
|
||||
MSG=$(cat <<'EOF'
|
||||
🎫 這則留言裡藏著一件**還沒做完的事**,而它現在只是文字。
|
||||
|
||||
命中的詞:$HIT
|
||||
命中的詞:__HIT__
|
||||
|
||||
【leo 2026-08-27】「**每個任務長出子票做為這張票的相依⋯⋯票沒完工可以察覺嗎?**」
|
||||
|
||||
@@ -115,18 +169,32 @@ cat >&2 <<EOF
|
||||
08-26 18-23 總管真的出貨了 ← 條件滿足,沒人知道
|
||||
08-27 11:xx leo 問「這 14 小時前的,你派了還是沒派?」 ← 才被撿起來
|
||||
|
||||
【怎麼做】把它長成子票,Gitea 原生相依會**硬擋**母票關閉(實測 412):
|
||||
【怎麼做】__LEAD__
|
||||
|
||||
scripts/ticket subtask <母票 owner/repo#N> \\
|
||||
--title "身為<誰>,我要<什麼>,我才<為什麼>" \\
|
||||
-F <內文檔> --label s/todo --assign <誰做>
|
||||
|
||||
⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證。**
|
||||
__EXAMPLE__
|
||||
__MORE__
|
||||
⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證**(Gitea 原生相依,實測 412)。
|
||||
|
||||
【粒度】leo 2026-08-27:「**小任務做完就關閉,不會讓池子爆掉,
|
||||
每個事情沒有歷史記錄才是大問題**」⇒ 判準是「這件事需要被記得嗎」,
|
||||
傾向**多開票**。不要用「會不會太多」當理由不開。
|
||||
|
||||
【真的不是待辦】在指令裡加 \`no-subtask: <一句理由>\` 重送(留痕)。
|
||||
【真的不是待辦】在指令裡加 `no-subtask: <一句理由>` 重送(留痕)。
|
||||
EOF
|
||||
)
|
||||
|
||||
MORE_BLOCK=""
|
||||
[ -n "$MORE" ] && MORE_BLOCK="
|
||||
還有哪些參數(指派給誰+下一步、旅程標籤、換 repo):
|
||||
$(printf '%s' "$MORE" | sed 's/^/ /; s/[[:space:]]*$//')
|
||||
"
|
||||
|
||||
HIT="$HIT" LEAD="$LEAD" EXAMPLE="$EXAMPLE" MORE_BLOCK="$MORE_BLOCK" MSG="$MSG" python3 -c '
|
||||
import os, sys
|
||||
m = os.environ["MSG"]
|
||||
for k in ("HIT", "LEAD", "EXAMPLE"):
|
||||
m = m.replace("__%s__" % k, os.environ.get(k, ""))
|
||||
m = m.replace("__MORE__", os.environ.get("MORE_BLOCK", ""))
|
||||
sys.stderr.write(m + "\n")
|
||||
'
|
||||
exit 2
|
||||
|
||||
@@ -294,7 +294,10 @@ for d in /private/tmp/claude-501/*/*/tasks; do
|
||||
done
|
||||
[ "$RUNNING" -gt 0 ] && exit 0
|
||||
|
||||
TOKEN=$(git -C "$PROJ" remote get-url gitea 2>/dev/null | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') || TOKEN=""
|
||||
# token 來源=env/.env(inkstone/ISEP#47),不再從 remote URL 抽(那會逼 token 明文嵌在 URL 裡)
|
||||
_GT_LIB="$(dirname -- "$0")/../scripts/lib/gitea-token.sh"; [ -f "$_GT_LIB" ] && . "$_GT_LIB"
|
||||
command -v gitea_token >/dev/null 2>&1 || exit 0
|
||||
TOKEN=$(gitea_token) || TOKEN=""
|
||||
[ -z "$TOKEN" ] && exit 0
|
||||
|
||||
# 🔴 org 是 inkstone,不是 Leo(2026-08-13 搬遷;舊值讓本閘靜默失效到 08-16)
|
||||
|
||||
@@ -87,12 +87,29 @@ try: print(json.load(sys.stdin).get('cwd','') or '')
|
||||
except Exception: print('')
|
||||
" 2>/dev/null)
|
||||
[ -n "$CWD" ] || CWD=$(pwd)
|
||||
# 🔴 2026-09-07(inkstone/ISEP#109 → comment 6629):remote 名要在**這條指令實際會推的
|
||||
# 那個 repo** 裡解,不是在 hook 收到的 cwd 裡解。雲端薄殼的 cwd 是 GitHub 那份
|
||||
# (origin=github.com),總管站在那裡 `git -C <InkStoneCo> push origin x`
|
||||
# (InkStoneCo 的 origin=Gitea)——舊版拿 cwd 解 origin ⇒ 判成「指向 GitHub」擋下,
|
||||
# `-C` 看都沒看;反過來,站在 Gitea 那份 `git -C <薄殼> push origin x` 則**漏擋**。
|
||||
# ⇒ 沿用 lib/push_target_dir.py(推 main 那道閘 08-23 就是為同一個病接的它):
|
||||
# 純 tokenize 解出 cd 鏈/-C,解得出來就 cd 進去(唯讀)問 git;解不出來
|
||||
# (`cd $VAR`、目錄不存在)才退回 cwd——退回的方向是「照舊」,不是放寬。
|
||||
# 判準沒變:仍是 remote 解出來的 URL 主機(唯一識別碼),不是「有沒有 -C」。
|
||||
PUSHDIR="$CWD"
|
||||
_ptd="$(dirname "$0")/lib/push_target_dir.py"
|
||||
if [ -f "$_ptd" ]; then
|
||||
_expr=$(printf '%s' "$CMD" | python3 "$_ptd" 2>/dev/null || printf '')
|
||||
if [ -n "$_expr" ]; then
|
||||
PUSHDIR=$(cd "$CWD" 2>/dev/null && cd "$_expr" 2>/dev/null && pwd) || PUSHDIR="$CWD"
|
||||
fi
|
||||
fi
|
||||
# 取 push 後面第一個非 flag 的字當 remote 名;沒有就是 origin
|
||||
RNAME=$(printf '%s' "$CMD" | sed -nE 's/.*git[[:space:]]+([^|;&]*[[:space:]])?push[[:space:]]+(-[^[:space:]]+[[:space:]]+)*([A-Za-z0-9._//-]+).*/\3/p' | head -1)
|
||||
[ -n "$RNAME" ] || RNAME="origin"
|
||||
RURL=$(git -C "$CWD" remote get-url "$RNAME" 2>/dev/null)
|
||||
RURL=$(git -C "$PUSHDIR" remote get-url "$RNAME" 2>/dev/null)
|
||||
if printf '%s' "$RURL" | grep -qi 'github\.com'; then
|
||||
HIT="git push → github(remote「$RNAME」指向 GitHub)"
|
||||
HIT="git push → github($PUSHDIR 的 remote「$RNAME」指向 GitHub:$(printf '%s' "$RURL" | sed 's|//[^@]*@|//<憑證省略>@|g'))"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -56,15 +56,32 @@ STAMP="/tmp/.history-guard-$(printf '%s' "$FILE_PATH" | shasum | cut -c1-12)"
|
||||
#
|
||||
# 時戳由 kbdb-asked-stamp.sh(PostToolUse,matcher 對 kbdb_* 工具)寫下。
|
||||
# 逃生口:KBDB 真的連不上時 `touch /tmp/.kbdb-down`——放行但留痕,且回覆裡要說明。
|
||||
KBDB_STAMP=/tmp/.kbdb-asked
|
||||
KBDB_DOWN=/tmp/.kbdb-down
|
||||
#
|
||||
# 🔴 **逃生門要真的走得通**(inkstone/ISEP#122,2026-09-02 實撞,本檔第二次修):
|
||||
# 上面那句訊息叫人 `touch /tmp/.kbdb-down`,而 `touch` 造出來的是**空檔**
|
||||
# ⇒ 舊版 `t=$(cat "$f")` 讀到空字串 ⇒ 算式當成 0 ⇒ 「距今 17 億秒」
|
||||
# ⇒ 永遠不新鮮 ⇒ **照著訊息打完,還是被擋**。
|
||||
# 實測(2026-09-02,KBDB 兩支工具都回 Connection closed 時):
|
||||
# $ touch /tmp/.kbdb-down → 再送同一個編輯 → 一模一樣的擋,訊息一字不差
|
||||
# ⇒ 那行逃生門是印出來好看的,**沒有人照著打過一次**
|
||||
# (同族:ISEP#125 worktree 閘印的逃生門原文照打 rc=2;本票 comment 6071)。
|
||||
#
|
||||
# 改法:**內容不是數字就用檔案的 mtime**——`touch` 做的正是更新 mtime,
|
||||
# 所以訊息裡那一行從此真的走得通,而 `kbdb-asked-stamp.sh` 寫數字的舊格式照樣相容。
|
||||
# 路徑用 `KBDB_STAMP_DIR` 可覆寫,**只為了讓迴歸測試不去動這台機器真正的戳記**
|
||||
# (測試把別的 session 的戳記清掉=把閘弄成隨機的)。
|
||||
KBDB_STAMP="${KBDB_STAMP_DIR:-/tmp}/.kbdb-asked"
|
||||
KBDB_DOWN="${KBDB_STAMP_DIR:-/tmp}/.kbdb-down"
|
||||
kbdb_fresh=0
|
||||
now=$(date +%s)
|
||||
for f in "$KBDB_STAMP" "$KBDB_DOWN"; do
|
||||
if [ -f "$f" ]; then
|
||||
t=$(cat "$f" 2>/dev/null || echo 0)
|
||||
[ $((now - t)) -lt 3600 ] && kbdb_fresh=1
|
||||
fi
|
||||
[ -f "$f" ] || continue
|
||||
t=$(cat "$f" 2>/dev/null || true)
|
||||
case "$t" in
|
||||
''|*[!0-9]*) t=$(date -r "$f" +%s 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0) ;;
|
||||
esac
|
||||
[ "${t:-0}" -gt 0 ] || continue
|
||||
[ $((now - t)) -lt 3600 ] && kbdb_fresh=1
|
||||
done
|
||||
if [ "$kbdb_fresh" -eq 0 ]; then
|
||||
cat >&2 <<'KEOF'
|
||||
|
||||
+50
-4
@@ -68,6 +68,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/line-needs-own-worktree.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -95,10 +99,6 @@
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/component-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/sdd-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/credential-only-guard.sh"
|
||||
@@ -122,6 +122,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pending-changes-retired.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-size-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -172,6 +176,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/parallel-lines-cap-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -223,6 +231,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/parallel-lines-cap-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -268,6 +280,18 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/mainline refresh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/settings-allow-sync --quiet"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/overdue-nag-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-size-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -344,6 +368,24 @@
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/queue-drain-guard.sh",
|
||||
"timeout": 180
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
@@ -421,6 +463,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/line-needs-own-worktree.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -43,17 +43,12 @@ for line in p.splitlines():
|
||||
set -- $SPEC
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
|
||||
# token 從該 repo 的 gitea remote 取;取不到就安靜結束(不影響派工)
|
||||
for d in "$CLAUDE_PROJECT_DIR/products/$REPO" "$CLAUDE_PROJECT_DIR/matrix/$REPO" "$CLAUDE_PROJECT_DIR"; do
|
||||
[ -d "$d/.git" ] || continue
|
||||
URL=$(git -C "$d" remote get-url gitea 2>/dev/null) || continue
|
||||
case "$URL" in *@*) break ;; esac
|
||||
done
|
||||
[ -n "${URL:-}" ] || exit 0
|
||||
|
||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
HOST=$(printf '%s' "$URL" | sed -E 's|.*@([^/]+)/.*|\1|')
|
||||
[ "$TOKEN" != "$URL" ] || exit 0
|
||||
# token 來源=env/.env(inkstone/ISEP#47),不再從 remote URL 抽;取不到就安靜結束(不影響派工)
|
||||
_GT_LIB="$(dirname -- "$0")/../scripts/lib/gitea-token.sh"; [ -f "$_GT_LIB" ] && . "$_GT_LIB"
|
||||
command -v gitea_token >/dev/null 2>&1 || exit 0
|
||||
TOKEN=$(gitea_token) || exit 0
|
||||
[ -n "$TOKEN" ] || exit 0
|
||||
HOST="git.uncle6.me"
|
||||
|
||||
curl -s -o /dev/null --max-time 8 \
|
||||
-X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
# KBDB(一次看所有庫,最便宜)→ 該 repo 的 wiki/tasks → git log -S → 讀源碼(最貴,
|
||||
# 只看得到「現在長怎樣」,看不到「為什麼變成這樣」)
|
||||
set -eu
|
||||
date +%s > /tmp/.kbdb-asked
|
||||
# 路徑跟 history-first-guard.sh 用同一個覆寫變數,否則測試會兩邊指到不同地方
|
||||
date +%s > "${KBDB_STAMP_DIR:-/tmp}/.kbdb-asked"
|
||||
exit 0
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# 判準(封動作,不封措辭 —— 同 empty-handed-stop-guard 的哲學):
|
||||
# 命中 leo21c 的座標 + 這是一個寫入動作 ⇒ 擋
|
||||
# 只是讀(GET/查詢/grep 到那個字串) ⇒ 放行
|
||||
# 發一則 notify_leo(通知型 trigger,什麼都沒改)⇒ 放行(同 prod-write-guard 的白名單)
|
||||
set -uo pipefail
|
||||
payload=$(cat)
|
||||
cmd=$(printf '%s' "$payload" | python3 -c "
|
||||
@@ -60,8 +61,34 @@ hit=0
|
||||
|
||||
# ① 寫入請求打到那台 worker
|
||||
if printf '%s' "$cmd" | grep -qiE 'https?://[^[:space:]"'"'"']*leo21c\.workers\.dev'; then
|
||||
if printf '%s' "$cmd" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d[[:space:]]|/trigger'; then
|
||||
hit=1
|
||||
# 🔴 (a) 通知型 trigger 放行(inkstone/ISEP#130;判準與 prod-write-guard.sh 同一份):
|
||||
# `…/webhooks/named/<ns>/notify_leo/trigger` = 發一則 Telegram 給 leo,**什麼都沒改**。
|
||||
# wiki `agent-memory.md` 寫明它是找 leo 的正式通道;prod-write-guard 在 ISEP#63 就放行了它,
|
||||
# 本閘卻因為路徑尾巴是 `/trigger` 照擋 ⇒ 兩支閘對同一條指令說不同的話,
|
||||
# 而「發通知」被擋的結果是**雲端撞到人閘也叫不動 leo**(inkstone/InkStoneCo#110)。
|
||||
# 放行的範圍刻意只有一個名字:指令裡打到 leo21c 的**每一個**網址都得是它,
|
||||
# 混進部署端點(`…/webhooks/named` 無名字)或別的工作流 ⇒ 照擋。
|
||||
NOTIFY_ONLY=$(printf '%s' "$cmd" | python3 -c '
|
||||
import re, sys
|
||||
cmd = sys.stdin.read()
|
||||
ALLOW = {"notify_leo"}
|
||||
urls = [u for u in re.findall(r"https?://[^\s\"\x27<>]+", cmd) if "leo21c.workers.dev" in u.lower()]
|
||||
PAT = re.compile(r"https?://[^/]+/webhooks/named/[^/]+/([A-Za-z0-9_-]+)/trigger/?$")
|
||||
def notify(u):
|
||||
m = PAT.match(u.rstrip(",;)"))
|
||||
return bool(m) and m.group(1) in ALLOW
|
||||
print("notify-only" if urls and all(notify(u) for u in urls) else "")
|
||||
' 2>/dev/null || printf '')
|
||||
if [ "$NOTIFY_ONLY" != "notify-only" ]; then
|
||||
# 🔴 (b) 先剪掉唯讀工具的 `-d`(inkstone/ISEP#130,09-04 雲端實撞):
|
||||
# `curl -s https://…leo21c…/x | tr -d '\r'`、`cut -d= -f2`、`sort -d`、`date -d` ……
|
||||
# 這些 `-d` 沒有一個會寫到那台,舊判準 `-d[[:space:]]` 卻把整條純 GET 擋下
|
||||
# ⇒ 「連讀都被擋」。prod-write-guard.sh 在 2026-08-12 就修過同一個洞,本閘漏了。
|
||||
# 剪法與那支同一句 sed,剪完剩下的 `-d` 才是 curl 的 body。
|
||||
cmd_w=$(printf '%s' "$cmd" | sed -E 's/(^|[|;&( ])(tr|cut|sort|uniq|date|xargs|paste|join|du|logger|split|comm)[[:space:]]+-d/\1\2 __READONLY_D__/g')
|
||||
if printf '%s' "$cmd_w" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--request *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d[[:space:]]|/trigger'; then
|
||||
hit=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -87,7 +114,8 @@ leo 原話:「**leo21c 就是我這個普通用戶,不應該讓你去操控
|
||||
改法:把目標明寫成 youlin,不要吃 `~/.arcrun/config.yaml` 的預設——
|
||||
那個檔至今仍指著 leo21c(KV id 與 encryption_key 是該實例專屬,換不過去)。
|
||||
|
||||
cypher : https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev
|
||||
cypher : https://arcrun-cypher-executor.arcrun-yuga3bse.workers.dev
|
||||
(09-02 重裝後的子網域;舊名 youlin-hsieh-dev 的 DNS 已不存在,打它一律 000)
|
||||
ns : yuga3bse
|
||||
CF : 1129efd7df2e8899d537e9c8fbabb6cb
|
||||
token : 頂層 .env 的 CLOUDFLARE_API_TOKEN_YOULIN_CC_USE
|
||||
|
||||
+211
-2
@@ -1,6 +1,7 @@
|
||||
# ── 以下三格是 inkstone/ISEP#90 加的「這一份是不是還有效」自檢 ──────────
|
||||
# ── 以下五格是「這一份是不是還有效」自檢 ─────────────────────────────
|
||||
# ①②③ inkstone/ISEP#90 立;②b②c inkstone/ISEP#122 補(會自動載入的東西也有分身)
|
||||
# 都**只是報告,不擋任何事**(SessionStart 本來就不該擋),而且每一格拿不到答案就閉嘴。
|
||||
import json, os, re, subprocess, sys, time, urllib.request
|
||||
import hashlib, json, os, re, subprocess, sys, time, urllib.request
|
||||
|
||||
ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
||||
PROJ = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
||||
@@ -58,6 +59,76 @@ if MAIN and VER != "未知" and MAIN != VER:
|
||||
else:
|
||||
notes.append("ℹ️ 這一份比 ISEP main 新(載入 %s / main %s)——沒發版的改動只在這台機器上。" % (VER, MAIN))
|
||||
|
||||
# ══ ④ 打了 tag 卻沒 release(運送鏈斷在 leo 看得到的那一站之前)═══════════
|
||||
#
|
||||
# 🔴 為什麼要在開場報(inkstone/ISEP#67 → comment 6574,總管 2026-09-07 裁定):
|
||||
# v0.23.0/v0.24.0 的 tag 都推了,Gitea Releases 頁卻停在 v0.22.0——
|
||||
# 交棒給 leo 的「測試位置 releases/tag/v0.24.0」打開是一頁空 tag。
|
||||
# 建 release 的工具(scripts/release-ship)在 09-05 就寫好了,但**沒有任何東西在
|
||||
# 下一個 session 開場說「你上一版少了一站」**——於是斷點靠人記得,而人沒記得。
|
||||
# 判準(與 release-ship/release-check 同一份:hooks/lib/release_chain.py):
|
||||
# · Gitea 上最新的 tag,它的 release 在不在 → 不在=「打了 tag 沒 release」
|
||||
# · main 宣稱的版本(①已經拿到的 MAIN)比最新 tag 新 → 「定版了沒打 tag」
|
||||
# 兩者都在 ⇒ 一個字都不說(天天念的東西會被學會忽略)。
|
||||
# 匿名讀(D20)、逾時 6 秒、問不到就閉嘴;只快取「都在」的結果 6 小時,
|
||||
# 缺件不快取——補完的下一個 session 就該安靜,不該再念 6 小時。
|
||||
def chain_gap():
|
||||
"""回 (版本, [(站, 補法)]) 或 None(問不到/沒有 tag/都在)。"""
|
||||
fx = None
|
||||
if os.environ.get("ISEP_BEACON_SKIP_NET") == "1":
|
||||
fxp = os.environ.get("ISEP_BEACON_CHAIN_FIXTURE")
|
||||
if not fxp:
|
||||
return None
|
||||
try:
|
||||
fx = json.load(open(fxp, encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import release_chain as rc
|
||||
except Exception:
|
||||
return None
|
||||
ship = os.path.join(ROOT, "scripts", "release-ship")
|
||||
ship_cmd = ("python3 %s" % ship) if os.path.isfile(ship) else \
|
||||
"先 `claude plugin update isep@inkstone`(這一份 v%s 還沒有 scripts/release-ship)再跑 `python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/release-ship`" % VER
|
||||
chain = rc.Chain(fixture=fx, timeout=6)
|
||||
try:
|
||||
tags = chain.tags()
|
||||
if not tags:
|
||||
return None
|
||||
latest = max(tags, key=rc.vtuple)
|
||||
cache = os.path.join(os.environ.get("ISEP_BEACON_CACHE_DIR", "/tmp"), ".isep-chain-ok-" + latest)
|
||||
# main 定版了、tag 還沒打 ⇒ 缺的是 tag 那一站(MAIN 來自 ①,可能是 6 小時內的快取)
|
||||
if MAIN and rc.vtuple(MAIN) > rc.vtuple(latest):
|
||||
g = [(label, fix.format(v=MAIN, ship=ship_cmd)) for k, label, fix in rc.STATIONS if k == "tag"]
|
||||
return MAIN, g
|
||||
try:
|
||||
if fx is None and time.time() - os.path.getmtime(cache) < 6 * 3600:
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
rels = chain.releases()
|
||||
g = [(label, fix.format(v=latest.lstrip("v"), ship=ship_cmd))
|
||||
for k, label, fix in rc.gaps(latest, tags, rels) if k == "release"]
|
||||
if not g:
|
||||
try:
|
||||
open(cache, "w").write(latest)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
return latest.lstrip("v"), g
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
cg = chain_gap()
|
||||
if cg:
|
||||
ver_gap, stations = cg
|
||||
notes.append(
|
||||
"🔴 **v%s 送到一半:%s**——leo 那頁(Gitea Releases)看不到這一版,對他等於不存在。"
|
||||
"補:%s(inkstone/ISEP#67)"
|
||||
% (ver_gap, "、".join("缺 " + label for label, _f in stations),
|
||||
";".join(fix for _l, fix in stations)))
|
||||
|
||||
# ══ ② 專案裡有沒有 ISEP 腳本的舊複本在遮蔽正門 ══════════════════════════
|
||||
#
|
||||
# 🔴 實例(inkstone/ISEP#90 ②):`InkStoneCo/scripts/ticket` 是 ISEP `scripts/ticket`
|
||||
@@ -103,6 +174,144 @@ if sh:
|
||||
"在雲端一律死在「拿不到 gitea token」。要嘛刪掉複本改叫 "
|
||||
"`\"$CLAUDE_PLUGIN_ROOT\"/scripts/<名字>`,要嘛把複本同步回 ISEP。" % "、".join(sh))
|
||||
|
||||
# ══ ②b 會**自動載入**的東西(skill/command/agent)兩邊各有一份而內容不同 ══
|
||||
#
|
||||
# 🔴 為什麼上面那一格抓不到(inkstone/ISEP#122,2026-09-02 實查):
|
||||
# ② 只掃 `scripts/`。而真正會**自動載入**的東西住在別的目錄,同樣兩邊各有一份:
|
||||
# plugin `skills/<名>/SKILL.md` ↔ 專案 `.claude/skills/<名>/SKILL.md`
|
||||
# plugin `commands/<名>.md` ↔ 專案 `.claude/commands/<名>.md`
|
||||
# plugin `agents/<名>.md` ↔ 專案 `.claude/agents/<名>.md`
|
||||
#
|
||||
# 實況:這 9 個檔案在 ISEP `0.1.0`(c263866)從 InkStoneCo 複製過來**一次**,
|
||||
# 之後再也沒有同步過。到 2026-09-02 已經分家兩個,而且**方向相反**:
|
||||
# `skills/ship-check/SKILL.md` InkStoneCo 651 行 / ISEP 595 行
|
||||
# `commands/sdd-check.md` ISEP 81 行 / InkStoneCo 65 行
|
||||
# (InkStoneCo 那份還在教 ISEP#91 已退役的「唯一 active SDD」)
|
||||
# ⇒ 「ISEP 一定比較新」與「InkStoneCo 一定比較新」**兩句都是錯的**。
|
||||
# 兩份都會被就地編輯 ⇒ 歸屬只能寫下來(`docs/file-ownership.tsv`)並且要有東西去比。
|
||||
#
|
||||
# 🔴 這一格比 ② 嚴重:腳本要有人叫它才會跑,**skill/command 是自動載入的**——
|
||||
# 載到舊的那份不會報錯、不會變慢、不會有任何症狀,只會安靜地教錯的東西。
|
||||
# (ship-check 就是這樣:舊描述在「我要發一篇部落格文章」時根本不會被觸發。)
|
||||
#
|
||||
# 判準與 ② 同一條:**檔名一樣而內容不同**才出聲,同步過的不吵。
|
||||
LOADABLE_PAIRS = [("skills", ".claude/skills", True),
|
||||
("commands", ".claude/commands", False),
|
||||
("agents", ".claude/agents", False)]
|
||||
|
||||
|
||||
def ownership():
|
||||
"""讀 docs/file-ownership.tsv → {plugin 內路徑: (真相源, 真相源路徑, commit, sha256)}"""
|
||||
out = {}
|
||||
try:
|
||||
with open(os.path.join(ROOT, "docs", "file-ownership.tsv"), encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
c = line.rstrip("\n").split("\t")
|
||||
if len(c) >= 2 and c[0].strip():
|
||||
out[c[0].strip()] = tuple((c[1:5] + ["-", "-", "-", "-"])[:4])
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
OWN = ownership()
|
||||
|
||||
|
||||
def loadable_pairs():
|
||||
"""產生 (plugin 內相對路徑, 專案內相對路徑)——只列 plugin 真的有的那些"""
|
||||
for pdir, jdir, nested in LOADABLE_PAIRS:
|
||||
src = os.path.join(ROOT, pdir)
|
||||
if not os.path.isdir(src):
|
||||
continue
|
||||
try:
|
||||
names = sorted(os.listdir(src))
|
||||
except Exception:
|
||||
continue
|
||||
for name in names:
|
||||
if nested:
|
||||
if os.path.isfile(os.path.join(src, name, "SKILL.md")):
|
||||
yield "%s/%s/SKILL.md" % (pdir, name), "%s/%s/SKILL.md" % (jdir, name)
|
||||
elif name.endswith(".md") and os.path.isfile(os.path.join(src, name)):
|
||||
yield "%s/%s" % (pdir, name), "%s/%s" % (jdir, name)
|
||||
|
||||
|
||||
def whose(rel):
|
||||
"""真相源是誰 → 一句照著做就會走到的話。表上沒有就誠實說未定,不要猜。"""
|
||||
row = OWN.get(rel)
|
||||
if not row:
|
||||
return "歸屬未定 ⇒ 兩份都看一眼,決定之後補一列進 docs/file-ownership.tsv"
|
||||
src, spath, _commit, _sha = row
|
||||
if src == "inkstone/ISEP":
|
||||
return "真相源=ISEP 這一份 ⇒ 專案那份是舊複本,同步過去或刪掉它"
|
||||
return ("真相源=%s:%s ⇒ 內容改在那裡,改完原樣搬進 ISEP、"
|
||||
"更新 docs/file-ownership.tsv 的 commit/sha256、升版" % (src, spath or "?"))
|
||||
|
||||
|
||||
def shadow_loadables():
|
||||
out = []
|
||||
roots = [PROJ, os.path.join(PROJ, "InkStoneCo")]
|
||||
for prel, jrel in loadable_pairs():
|
||||
a = os.path.join(ROOT, prel)
|
||||
try:
|
||||
ab = open(a, "rb").read()
|
||||
except Exception:
|
||||
continue
|
||||
for base in roots:
|
||||
b = os.path.join(base, jrel)
|
||||
try:
|
||||
if os.path.realpath(b) == os.path.realpath(a) or not os.path.isfile(b):
|
||||
continue
|
||||
if open(b, "rb").read() != ab:
|
||||
out.append("%s ↔ %s(%s)"
|
||||
% (prel, os.path.relpath(b, PROJ), whose(prel)))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
ld = shadow_loadables()
|
||||
if ld:
|
||||
notes.append(
|
||||
"🟡 **會自動載入的東西兩邊各有一份,而且內容不同**:\n - %s\n"
|
||||
" 自動載入的東西載到舊的那份**不會有任何症狀**——不報錯、不變慢,"
|
||||
"只會安靜地教錯的東西(`ship-check` 的舊描述在「我要發一篇部落格文章」時"
|
||||
"根本不會被觸發)。歸屬表:docs/file-ownership.tsv(inkstone/ISEP#122)。"
|
||||
% "\n - ".join(ld))
|
||||
|
||||
# ══ ②c ISEP 自己這一份,跟歸屬表記的那顆對不對得上 ══════════════════════
|
||||
#
|
||||
# 🔴 為什麼要有這一格:②b 要「專案那一份」在磁碟上才比得出來,
|
||||
# 而**雲端的 project dir 是薄殼,根本沒有那一份**(ISEP#90 記過同一件事)。
|
||||
# 這一格只比「檔案 vs 表上寫的 sha256」——離線、單邊、不依賴任何別的 repo,
|
||||
# 是雲端唯一還作數的那個檢查。
|
||||
def off_manifest():
|
||||
out = []
|
||||
for rel, (src, spath, commit, sha) in OWN.items():
|
||||
if not sha or sha == "-":
|
||||
continue
|
||||
p = os.path.join(ROOT, rel)
|
||||
if not os.path.isfile(p):
|
||||
continue
|
||||
try:
|
||||
h = hashlib.sha256(open(p, "rb").read()).hexdigest()
|
||||
except Exception:
|
||||
continue
|
||||
if h != sha:
|
||||
out.append("%s(表記 %s 的 %s…,實際 %s…;真相源 %s:%s)"
|
||||
% (rel, (commit or "?")[:7], sha[:12], h[:12], src, spath or "?"))
|
||||
return out
|
||||
|
||||
|
||||
om = off_manifest()
|
||||
if om:
|
||||
notes.append(
|
||||
"🟡 **ISEP 這一份跟歸屬表對不上**:%s。兩種可能,兩種都要動手:"
|
||||
"① 它被就地改過 ⇒ 內容要改在真相源那邊,這裡只放原樣搬過來的複本;"
|
||||
"② 它是同步過的新內容、只是沒更新 docs/file-ownership.tsv 的 commit/sha256 ⇒ 補上那兩欄。"
|
||||
% "、".join(om))
|
||||
|
||||
# ══ ③ 工作區有沒有「已退役機制」留下的產物 ══════════════════════════════
|
||||
#
|
||||
# 判準是機械的、而且會自己長大:**plugin 自己的原始碼裡有沒有任何一個字提到這個目錄**。
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""解出一條指令裡的 `git checkout` / `git switch` **真正會落在哪個目錄**。
|
||||
|
||||
管什麼
|
||||
line-needs-own-worktree.sh 要回答「這次切分支動的是哪個 repo」。舊版拿
|
||||
payload 的 `cwd` 當答案,於是 `cd <別的 repo> && git checkout <分支>` 這個
|
||||
形狀會**認錯 repo**(inkstone/ISEP#109 → comment 5398 實測):
|
||||
|
||||
指令:cd .../matrix/arcrun && git checkout fix/library-lifecycle-187
|
||||
舊版訊息:目錄:.../InkStoneCo 它現在在:feat/ticket-bell-webhook
|
||||
↑ 動的是 matrix/arcrun,講的卻是 InkStoneCo
|
||||
|
||||
🔴 擋是擋對了,但**印給人的補救指令是另一個 repo 的**——照著做會在
|
||||
InkStoneCo 開一份用不到的 worktree,真正要隔離的 matrix/arcrun 沒開到,
|
||||
而人以為自己隔離好了。**一道閘給錯下一步,比不擋更糟。**
|
||||
而 `cd X && git checkout` 正是這條線最常出現的寫法(本票的來由那次就是它)。
|
||||
|
||||
為什麼接既有的解析器,不另寫一支
|
||||
`push_target_dir.py` 2026-08-23 已經替 `git push` 解過同一個問題
|
||||
(inkstone/ISEP#30 comment 3949),連多層 cd 鏈與**子殼的 cd 不外洩**
|
||||
(`(cd A && …); git checkout` 落在殼外而不是 A)都算過了。
|
||||
⇒ 本檔只加「哪個動詞」與「哪些形狀不動 HEAD」,tokenize 與路徑接合一律沿用
|
||||
那支,不養第二套會漂的 parser。那支的 42 條既有測試在本次改動後全數重跑通過。
|
||||
|
||||
回傳三種,呼叫端要分開處理
|
||||
"" 這條指令裡沒有會**移動 HEAD** 的 checkout/switch ⇒ 不關這道閘的事
|
||||
"?" 有,但目錄**解不出來**(`cd $VAR`、`cd -`、引號壞掉)
|
||||
⇒ 🔴 呼叫端必須放行。寧可漏擋,也不要指著錯的 repo 叫人去開 worktree
|
||||
——那正是本檔要修的病,猜一個回去等於原地打轉。
|
||||
其他 目錄運算式,可能是相對的;呼叫端自己 `cd` 進去問 git(唯讀)。
|
||||
"." =「就是呼叫端自己的 cwd」(指令裡沒有 cd 也沒有 -C)。
|
||||
|
||||
出路的判準也住在這裡(inkstone/ISEP#109 → comment 6587,2026-09-07)
|
||||
閘訊息教的出路是 `WORKTREE_OK=1 git -C … checkout …`。舊版讀的是 **hook 自己的
|
||||
環境變數**,而 PreToolUse hook 跑在指令之前、跟指令不是同一個行程——指令字串裡的
|
||||
`WORKTREE_OK=1` 是給 git 的前綴賦值,hook 的行程裡根本沒有它 ⇒ 總管三次照貼三次被擋。
|
||||
⇒ 改成認**指令字串裡的字面前綴**,而且判準是位置不是字:那個賦值必須是
|
||||
「會移動 HEAD 的那條 git 指令」自己的前綴(`echo WORKTREE_OK=1; git checkout` 不算、
|
||||
`WORKTREE_OK=1 echo hi && git checkout` 不算、寫在 commit 訊息裡不算)。
|
||||
留痕就是那個前綴本身——它在指令歷史上。
|
||||
|
||||
用法:printf '%s' "$CMD" | python3 checkout_target_dir.py → 目錄運算式(見上)
|
||||
printf '%s' "$CMD" | python3 checkout_target_dir.py --escape WORKTREE_OK=1
|
||||
→ 那條 checkout 帶著這個前綴就印 1,否則空
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from push_target_dir import _ENV_ASSIGN, _events, _join # noqa: E402
|
||||
|
||||
VERBS = ("checkout", "switch")
|
||||
UNKNOWN = "?"
|
||||
|
||||
|
||||
def _step(base, path):
|
||||
"""走一步 cd/-C。不確定就一路標 unknown,不要往回猜成呼叫端的 cwd。"""
|
||||
if base == UNKNOWN:
|
||||
return UNKNOWN
|
||||
if not path or path == "-" or path.startswith("$") or path.startswith("`"):
|
||||
return UNKNOWN
|
||||
return _join(base, path)
|
||||
|
||||
|
||||
def _moves_head(tokens):
|
||||
"""tokens 是整串 `[NAME=value …] git …`。
|
||||
回傳 (會不會動 HEAD, -C 指的路徑 or None, 前綴賦值清單)。
|
||||
|
||||
判準只有「這個動作動不動 HEAD」,不看任何措辭:
|
||||
git checkout -- <檔>/git checkout HEAD -- <檔> ⇒ 還原檔案,不動
|
||||
git checkout -p ⇒ 挑 hunk,不動
|
||||
git checkout(沒有任何目標) ⇒ git 自己會報錯
|
||||
"""
|
||||
tok = list(tokens)
|
||||
env = []
|
||||
while tok and _ENV_ASSIGN.match(tok[0]):
|
||||
env.append(tok.pop(0)) # `WORKTREE_OK=1 git …` 的前綴——出路就認這個
|
||||
tok = tok[1:] # 去掉 "git"
|
||||
c_path = None
|
||||
while tok and tok[0].startswith("-"):
|
||||
if tok[0] == "-C" and len(tok) > 1:
|
||||
c_path = tok[1] if c_path is None else c_path + "/" + tok[1]
|
||||
tok = tok[2:]
|
||||
continue
|
||||
if tok[0].startswith("-C") and len(tok[0]) > 2:
|
||||
seg = tok[0][2:]
|
||||
c_path = seg if c_path is None else c_path + "/" + seg
|
||||
tok.pop(0)
|
||||
continue
|
||||
if tok[0].startswith("--git-dir"):
|
||||
return (False, None, env) # 自己指 git-dir 的花式用法,不猜
|
||||
tok.pop(0)
|
||||
if not tok or tok[0] not in VERBS:
|
||||
return (False, None, env)
|
||||
|
||||
rest = tok[1:]
|
||||
if "--" in rest or "-p" in rest or "--patch" in rest:
|
||||
return (False, None, env)
|
||||
if not [a for a in rest if not a.startswith("-")] and \
|
||||
not any(a in ("-b", "-B", "-c", "-C") for a in rest):
|
||||
return (False, None, env)
|
||||
return (True, c_path, env)
|
||||
|
||||
|
||||
def find_checkout(cmd):
|
||||
"""回 (目錄運算式, 那條 checkout 的前綴賦值清單)。目錄運算式的三種值見檔頭。"""
|
||||
events = _events(cmd, VERBS, strip_env=True)
|
||||
if not events:
|
||||
return ("", []) # 引號壞掉之類——沿用「不擋在不確定上」
|
||||
|
||||
stack = [None] # 每層括號各自的 cwd;None = 同呼叫端的 cwd
|
||||
for kind, val in events:
|
||||
if kind == "enter":
|
||||
stack.append(stack[-1]) # 子殼繼承「進去那一刻」的位置
|
||||
elif kind == "exit":
|
||||
if len(stack) > 1:
|
||||
stack.pop() # 子殼自己 cd 到哪,出來不算數
|
||||
elif kind == "cd":
|
||||
stack[-1] = _step(stack[-1], val)
|
||||
elif kind == "verb":
|
||||
moves, c_path, env = _moves_head(val)
|
||||
if not moves:
|
||||
continue
|
||||
where = stack[-1]
|
||||
if c_path is not None:
|
||||
# -C 贏過 cd:git 自己就是這個優先序
|
||||
# (而且 -C 若是相對路徑,是接在 cd 之後算,不是接在 cwd)
|
||||
where = _step(where, c_path)
|
||||
if where == UNKNOWN:
|
||||
return (UNKNOWN, env)
|
||||
return (where or ".", env)
|
||||
return ("", [])
|
||||
|
||||
|
||||
def find_checkout_target(cmd):
|
||||
return find_checkout(cmd)[0]
|
||||
|
||||
|
||||
def has_escape(cmd, assignment):
|
||||
"""會移動 HEAD 的那條 git 指令,自己的前綴裡有沒有這個字面賦值(例 `WORKTREE_OK=1`)。
|
||||
只看**那條指令的前綴**:印在別處、掛在別的指令上、寫在引號裡都不算。"""
|
||||
_expr, env = find_checkout(cmd)
|
||||
return assignment in env
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
cmd = sys.stdin.read()
|
||||
if len(argv) == 2 and argv[0] == "--escape":
|
||||
sys.stdout.write("1" if has_escape(cmd, argv[1]) else "")
|
||||
return
|
||||
sys.stdout.write(find_checkout_target(cmd))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+384
-33
@@ -2,8 +2,9 @@
|
||||
"""hooks/lib/mainline.py —— 「現在的主線是哪一個」這件事的**唯一存放處**。
|
||||
|
||||
這是 helper,不是閘(inkstone/ISEP#40 S7:`lib/` 底下的東西不算一支手寫的閘)。
|
||||
三個呼叫者共用它,所以「主線是誰」的答案只有一份:
|
||||
四個呼叫者共用它,所以「主線是誰」「誰在主線上」的答案只有一份:
|
||||
· `scripts/mainline` —— 人問「現在的主線是哪一個」
|
||||
· `scripts/ticket pick/claim` —— 雲端工人抓票:抓的是主線的成員
|
||||
· `hooks/lib/countdown.py` —— 每一則回覆眼前那一行的第二段
|
||||
· `hooks/mainline-focus-guard.sh` —— 派了一張不屬於主線的票就攔一次
|
||||
|
||||
@@ -13,24 +14,43 @@
|
||||
SOP 說「有 active milestone 時只做該 milestone 的事」——但**「那個」在現場沒有指涉對象**。
|
||||
|
||||
leo 的原話是「**14 個里程碑同時亮著卻不知道該看哪個**」。
|
||||
⇒ 要解的不是「milestone 太多」(跨 repo 同名是既有做法,v0.6.0 §4/SOP S7-4),
|
||||
要解的是「**這一刻我在做哪一條**」沒有答案。
|
||||
|
||||
⇒ 所以這個檔的形狀就是答案的形狀:**一個檔,放得下一條**。
|
||||
沒有清單、沒有優先序、沒有「前三名」——被覆蓋掉的那條就不再是主線。
|
||||
問「現在的主線是哪一個」永遠只會拿到 0 或 1 個答案,不會拿到 14 個。
|
||||
|
||||
── 主線是一個「名字」,錨在一個具體的 milestone 上 ──────────────────
|
||||
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo(票上實查)。
|
||||
所以:
|
||||
· **錨**(anchor)= `owner/repo#<milestone id>`:期限、進度、目標宣告都讀它
|
||||
· **成員判定**= 票所屬 milestone 的**標題**與主線標題相同(不分 repo)
|
||||
⇒ 「同名跨 repo」照舊能用,而「現在在做哪一條」仍然只有一個答案。
|
||||
── 一條線在整個 Gitea 只有一個里程碑;別的 repo 用相依指過來(inkstone/ISEP#133)──
|
||||
🔴 本檔第一版寫的是「同名跨 repo 是既有做法,成員=票所屬 milestone 的標題與主線相同」。
|
||||
那不是規則,是當時現場的樣子——而 leo 早在 2026-08-20(inkstone/ISEP#30)與 08-27
|
||||
(inkstone/InkStoneCo#44)就定了相反的規則:**一條線一個里程碑,別 repo 的票用相依指到 hub 票**。
|
||||
工具把現場當規則寫進檔頭,總管 09-07 照它做,還在 arcrun-rag 補建了一個同名的;
|
||||
leo 當天:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone,
|
||||
不同 repo 用指針」。⇒ 檔頭寫的「既有做法」會被當成規則抄走,所以這裡改成規則本身:
|
||||
|
||||
── 這支從不打網路 ────────────────────────────────────────────────
|
||||
它被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話
|
||||
· **錨**(anchor)= `owner/repo#<milestone id>`:整個 Gitea 只有這一個 milestone 物件。
|
||||
期限、進度、目標宣告都讀它。
|
||||
· **hub 票**(`hub_ticket`,可選)= 這條線的載體票(例:inkstone/InkStoneCo#44「這張票是那條線本身」)。
|
||||
別 repo 的票要進主線,就成為它(或 hub 里程碑裡任一張票)的 **dependency**。
|
||||
· **成員判定**= `collect_members()`:hub 里程碑裡的票 + hub 票,再沿著 Gitea 的
|
||||
`/dependencies` 邊(跨 repo)一路收下去。**判準是 milestone id 與相依邊,不是標題。**
|
||||
同名 milestone 在別的 repo 裡開著也**不算**成員——那正是被推翻的那條。
|
||||
· `belongs()` 因此需要「這張票掛在哪個 milestone」+「誰把它當相依」兩件事實才敢說「不屬於」;
|
||||
只知道一半 ⇒ 回 None(不知道),閘拿到 None 一律放行。
|
||||
|
||||
── `load()`/`line()` 從不打網路 ─────────────────────────────────
|
||||
它們被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話
|
||||
都先等一次網路。網路那一半全部在 `scripts/mainline`(set/refresh/adopt),
|
||||
本支只讀寫 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
|
||||
本支的讀寫只碰 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
|
||||
兩個例外都不在關鍵路徑上:`fetch_gitea()`(inkstone/ISEP#140,只有 SessionStart 的
|
||||
`scripts/mainline refresh` 叫)與 `collect_members()`(要呼叫端把 api 函式傳進來,
|
||||
本檔自己不帶憑證、不認主機;`load()`/`line()` 永遠不叫它們)。
|
||||
|
||||
── 三個來源,優先序寫死:家目錄 > repo 那份 > Gitea main 那份 ───────
|
||||
① 家目錄 `~/.claude/isep-countdown/mainline.json`(這台機器上 refresh 過、有進度)
|
||||
② repo 那份 `InkStoneCo/system-dev/mainline.json`(隨 repo 走,inkstone/ISEP#130)
|
||||
③ Gitea 上 `inkstone/InkStoneCo` main 的同一個檔(inkstone/ISEP#140)
|
||||
①② 之間誰算數看 `set_at`(見 `load()`);③ **只在 ①② 都不存在時**被問一次,
|
||||
抓到就寫進 ①,之後跟 ① 一樣對待。③ 讀的就是 ② 那個檔,只是從 Gitea 拿而不是從
|
||||
工作樹拿——它不是第二套主線來源。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -58,39 +78,214 @@ def path() -> str:
|
||||
return os.path.join(state_dir(), STATE_NAME)
|
||||
|
||||
|
||||
# ── repo 裡的那一份(inkstone/ISEP#130)──────────────────────────────
|
||||
#
|
||||
# 主線原本只住在 `~/.claude/isep-countdown/mainline.json`——**這台機器的家目錄**。
|
||||
# 雲端 session 是另一台機器:家目錄是空的,clone 下來的 repo 才是它唯一拿得到的東西。
|
||||
# 2026-09-05 leo:「routine 每天早上開啟後抓不到任務」——不是 Gitea 沒票,
|
||||
# 是雲端根本不知道主線是哪一條。
|
||||
# ⇒ `inkstone/InkStoneCo` 的 `system-dev/mainline.json` 是**隨 repo 走的那一份**
|
||||
# (PR inkstone/InkStoneCo#118 放進去的)。這裡的規矩:
|
||||
# · 讀:家目錄那份沒有 ⇒ 讀 repo 那份(雲端就是這個形狀)。
|
||||
# 兩份都在 ⇒ 用 `set_at` 較新的(誰後標的誰算數,不分本機雲端)。
|
||||
# · 寫:`set`/`adopt`/`clear` 這種「主線是誰」的改動 ⇒ 兩份一起寫(`mirror=True`)。
|
||||
# `refresh`/閘順手學到成員 ⇒ 只寫家目錄那份——否則每開一個 session
|
||||
# InkStoneCo 的工作樹就髒一次,而髒的 diff 沒有人會去 commit。
|
||||
# · repo 那份要 commit/開 PR 才到得了雲端;這支不碰 git。
|
||||
REPO_REL = os.path.join("system-dev", STATE_NAME)
|
||||
|
||||
|
||||
def project_roots():
|
||||
"""「這個 session 的專案在哪」的候選根,依序:`$CLAUDE_PROJECT_DIR`、它底下的 `InkStoneCo/`
|
||||
(雲端薄殼的形狀:專案根是薄殼、真身在 InkStoneCo/)、cwd、cwd 底下的 `InkStoneCo/`。
|
||||
|
||||
主線檔(本檔)與 `scripts/milestone-account` 的帳本(inkstone/ISEP#133)都靠它找
|
||||
`system-dev/`——兩份「隨 repo 走的狀態」用同一把尺找家,不各寫一套。"""
|
||||
roots = []
|
||||
for r in (os.environ.get("CLAUDE_PROJECT_DIR", "").strip(), os.getcwd()):
|
||||
if r:
|
||||
for c in (r, os.path.join(r, "InkStoneCo")):
|
||||
if c not in roots:
|
||||
roots.append(c)
|
||||
return roots
|
||||
|
||||
|
||||
def repo_candidates():
|
||||
"""repo 那一份可能在哪:明指 > 專案根 > 專案根底下的 InkStoneCo/(雲端薄殼的形狀)> cwd 同兩層。"""
|
||||
out = []
|
||||
forced = os.environ.get("ISEP_MAINLINE_REPO_FILE", "").strip()
|
||||
if forced == "none":
|
||||
return [] # 測試用:把「repo 那份」整個拿掉,外界定住
|
||||
if forced:
|
||||
out.append(forced)
|
||||
for r in project_roots():
|
||||
p = os.path.join(r, REPO_REL)
|
||||
if p not in out:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def repo_path(for_write=False):
|
||||
"""讀:第一個**存在**的檔。寫:第一個 `system-dev/` 目錄存在的位置(檔可以還沒有)。
|
||||
找不到 ⇒ None(雲端沒 clone InkStoneCo、或 cwd 在別的 repo 時都是正常狀態)。"""
|
||||
for p in repo_candidates():
|
||||
if for_write:
|
||||
if os.path.isdir(os.path.dirname(p)):
|
||||
return p
|
||||
elif os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _valid(d):
|
||||
"""一份主線檔長什麼樣才算數:dict、而且有 title。三個來源用同一把尺。"""
|
||||
return d if (isinstance(d, dict) and d.get("title")) else None
|
||||
|
||||
|
||||
def _read(p):
|
||||
try:
|
||||
with open(p) as f:
|
||||
d = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
return _valid(d)
|
||||
|
||||
|
||||
# ── Gitea main 上的那一份(inkstone/ISEP#140)────────────────────────
|
||||
#
|
||||
# 雲端薄殼 `youlinhsieh/inkstoneco` 的 SessionStart 跑在 `bootstrap.sh` 之前:那時
|
||||
# `InkStoneCo/` 還沒 clone,家目錄又是空的 ⇒ 上面兩份都不存在 ⇒ `countdown.py` 退到
|
||||
# 第二基準「期限最近的 milestone」⇒ 第一則 prompt 印出一條兩週前的舊線
|
||||
# (票上實測:`🔴 主線 把管理這條線做對 已逾期 341 小時`+`🎯 現在沒有主線`)。
|
||||
# 機制沒壞,是順序。而「哪一條是主線」這個指標本來就在 Gitea 上讀得到。
|
||||
#
|
||||
# 規矩(跟上面「repo 裡的那一份」那段接起來,不另立一套):
|
||||
# · 只在家目錄與 repo 那份**都不存在**時問 Gitea,問一次;任一份在場就不問。
|
||||
# · 只有 SessionStart 的 `scripts/mainline refresh` 會叫 `fetch_gitea()`;抓到就
|
||||
# `save()` 進家目錄(不 mirror:雲端沒有 InkStoneCo/ 可寫,本機也不弄髒工作樹)。
|
||||
# bootstrap 之後 repo 那份出現時,兩份 `set_at` 相同 ⇒ 家目錄那份算數(它有 refresh 過的進度)。
|
||||
# · 匿名優先。匿名被拒(401/403/404)且環境拿得到 Gitea token 才帶 token 再問一次
|
||||
# ——`inkstone/InkStoneCo` 在 Gitea 是 **private**(2026-09-07 實測:匿名打 raw/
|
||||
# contents/repo API 都 404),沒 token 就真的讀不到。
|
||||
# · 讀不到(斷網/5xx/不是 JSON/沒 title/逾時)⇒ 回 None,退回現行行為,不報錯。
|
||||
# 逾時有上限(`ISEP_MAINLINE_GITEA_TIMEOUT`,預設 4 秒,最多問兩次)。
|
||||
# · 測試用:`ISEP_MAINLINE_GITEA_URL=none` 把這個來源整個拿掉;`TICKET_HOST` 換主機
|
||||
# (既有測試都把它指到連不上的位址,所以它們照舊離線)。
|
||||
GITEA_HOST_DEFAULT = "https://git.uncle6.me"
|
||||
GITEA_REPO = "inkstone/InkStoneCo"
|
||||
GITEA_REF = "main"
|
||||
|
||||
|
||||
def gitea_url() -> str:
|
||||
forced = os.environ.get("ISEP_MAINLINE_GITEA_URL", "").strip()
|
||||
if forced == "none":
|
||||
return ""
|
||||
if forced:
|
||||
return forced
|
||||
host = (os.environ.get("TICKET_HOST", "").strip() or GITEA_HOST_DEFAULT).rstrip("/")
|
||||
return "%s/api/v1/repos/%s/raw/%s?ref=%s" % (
|
||||
host, GITEA_REPO, REPO_REL.replace(os.sep, "/"), GITEA_REF)
|
||||
|
||||
|
||||
def fetch_gitea(token="", timeout=None):
|
||||
"""去 Gitea 讀 `inkstone/InkStoneCo` main 上的 `system-dev/mainline.json`。
|
||||
|
||||
回一份跟 `_read()` 同樣通過 `_valid()` 的 dict,或 None。**永遠不 raise。**
|
||||
`token`:字串,或一個回字串的函式(只在匿名被拒時才會被叫)。
|
||||
🔴 這是本檔唯一會打網路的函式,只准 SessionStart 的 `scripts/mainline refresh` 叫;
|
||||
`load()`/`line()` 走的是每一則訊息的關鍵路徑,不准碰它。
|
||||
"""
|
||||
url = gitea_url()
|
||||
if not url:
|
||||
return None
|
||||
if timeout is None:
|
||||
try:
|
||||
timeout = float(os.environ.get("ISEP_MAINLINE_GITEA_TIMEOUT", "") or 4)
|
||||
except Exception:
|
||||
timeout = 4.0
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
def get(headers):
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.read().decode("utf-8", "replace")
|
||||
|
||||
try:
|
||||
text = get({}) # 匿名優先(D20 的形狀:讀不帶憑證)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in (401, 403, 404):
|
||||
return None
|
||||
# `token` 可以是字串,也可以是「被拒了才去拿」的函式——拿 token 可能要掃 git remote、
|
||||
# 可能拿不到而想開口抱怨,匿名讀得到的時候根本不該走到那裡。
|
||||
tok = token() if callable(token) else token
|
||||
if not tok:
|
||||
return None
|
||||
try:
|
||||
text = get({"Authorization": "token " + tok}) # private repo:帶 token 再問一次
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return _valid(json.loads(text))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load():
|
||||
"""讀主線。沒有/壞掉 ⇒ None(=現在沒有主線),**不是例外**。
|
||||
|
||||
「沒有主線」是一個正常狀態,不是故障:ISEP#82 驗收第 4 條寫死了
|
||||
「沒有任何主線時不能整組壞掉」。所以這支從不 raise。
|
||||
|
||||
家目錄那份與 repo 那份都在時,`set_at` 較新的算數(格式固定 `YYYY-MM-DD HH:MM`,
|
||||
字串比大小就是時間比大小);一樣新 ⇒ 家目錄那份(它才有 refresh 過的進度)。
|
||||
"""
|
||||
try:
|
||||
with open(path()) as f:
|
||||
d = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(d, dict) or not d.get("title"):
|
||||
return None
|
||||
return d
|
||||
local = _read(path())
|
||||
rp = repo_path()
|
||||
remote = _read(rp) if rp else None
|
||||
if local and remote:
|
||||
return remote if (remote.get("set_at") or "") > (local.get("set_at") or "") else local
|
||||
return local or remote
|
||||
|
||||
|
||||
def save(d) -> bool:
|
||||
def save(d, mirror=False) -> bool:
|
||||
"""寫家目錄那份;`mirror=True` 時同時寫 repo 那份(只有「主線是誰」變了才這樣叫)。
|
||||
回 True/False 只看家目錄那份——repo 那份寫不寫得進去用 `repo_path(for_write=True)` 另外問。"""
|
||||
try:
|
||||
with open(path(), "w") as f:
|
||||
json.dump(d, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
if mirror:
|
||||
rp = repo_path(for_write=True)
|
||||
if rp:
|
||||
try:
|
||||
with open(rp, "w") as f:
|
||||
json.dump(d, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def clear() -> bool:
|
||||
def clear(mirror=False) -> bool:
|
||||
ok = True
|
||||
try:
|
||||
os.remove(path())
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
ok = False
|
||||
if mirror:
|
||||
rp = repo_path()
|
||||
if rp:
|
||||
try:
|
||||
os.remove(rp)
|
||||
except Exception:
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def parse_ts(raw):
|
||||
@@ -116,6 +311,8 @@ def norm(s) -> str:
|
||||
|
||||
🔴 刻意不做同義詞、不做模糊比對:主線是**被指定的**,不是被猜出來的。
|
||||
猜錯的方向是「把不屬於主線的票放行」,那正是這張票要擋的事。
|
||||
inkstone/ISEP#133 起標題只用來**查**(Gitea 的 `milestones=` 只吃名字),
|
||||
成員判定看的是 milestone id 與相依邊,不再拿標題當判準。
|
||||
"""
|
||||
return "".join((s or "").split()).lower()
|
||||
|
||||
@@ -126,27 +323,181 @@ def ref_of(ms) -> str:
|
||||
return "%s/%s#%s" % (ms.get("owner") or "?", ms.get("repo") or "?", ms.get("id") or "?")
|
||||
|
||||
|
||||
def split_ref(ref):
|
||||
"""`owner/repo#N` → (owner, repo, N);不是這個形狀 ⇒ None。"""
|
||||
import re
|
||||
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (ref or "").strip())
|
||||
return (m.group(1), m.group(2), int(m.group(3))) if m else None
|
||||
|
||||
|
||||
def issue_ref(issue, owner=None, repo=None):
|
||||
"""Gitea 的 issue dict → `owner/repo#N`。相依端點回的 issue 帶 `repository.full_name`,
|
||||
列 milestone 的端點有時不帶 ⇒ 呼叫端補 owner/repo。"""
|
||||
if not isinstance(issue, dict) or not issue.get("number"):
|
||||
return None
|
||||
full = ((issue.get("repository") or {}).get("full_name")) or (
|
||||
"%s/%s" % (owner, repo) if owner and repo else None)
|
||||
return "%s#%s" % (full, issue["number"]) if full else None
|
||||
|
||||
|
||||
def hub_ticket(ms):
|
||||
return ((ms or {}).get("hub_ticket") or "").strip() or None
|
||||
|
||||
|
||||
def members(ms):
|
||||
return set(ms.get("members") or []) if ms else set()
|
||||
out = set(ms.get("members") or []) if ms else set()
|
||||
h = hub_ticket(ms)
|
||||
if h:
|
||||
out.add(h)
|
||||
return out
|
||||
|
||||
|
||||
def belongs(ticket_ref, ms, ticket_milestone_title=None):
|
||||
MEMBER_WALK_CAP = 300
|
||||
|
||||
|
||||
def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
"""主線的成員(inkstone/ISEP#133):**hub 里程碑裡的票 + hub 票,沿 `/dependencies` 收下去**。
|
||||
|
||||
`api(path)` 由呼叫端提供(`scripts/ticket` 的 `api`/`api_soft`):回解析好的 JSON 或 None。
|
||||
本檔不帶憑證、不認主機——這樣 `scripts/ticket` 的離線測試用假池子就測得到整條路。
|
||||
|
||||
回 (members, info):
|
||||
members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們)
|
||||
info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None,
|
||||
"reached": bool, "capped": bool, "unread": [...], "complete": bool}
|
||||
🔴 `reached=False` = 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取**
|
||||
(讀不到 ≠ 沒有成員)。
|
||||
🔴 `unread` = 讀不到 `/dependencies` 的票(重讀過仍失敗)。**只要有一張,名單就不完整**:
|
||||
那張底下的前置全部沒收到(inkstone/ISEP#130 c6973:Gitea 握手逾時,hub 票底下三張全漏,
|
||||
`set` 卻印 ✅)。`complete`=reached 且沒有 unread 且沒被上限截斷——呼叫端只准在它為真時
|
||||
把名單當成完整的寫出去。
|
||||
判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。
|
||||
"""
|
||||
import time
|
||||
import urllib.parse
|
||||
try:
|
||||
retries = max(0, int(os.environ.get("ISEP_MEMBER_RETRIES", "2")))
|
||||
except ValueError:
|
||||
retries = 2
|
||||
try:
|
||||
backoff = max(0.0, float(os.environ.get("ISEP_MEMBER_BACKOFF", "2")))
|
||||
except ValueError:
|
||||
backoff = 2.0
|
||||
|
||||
def read_list(path):
|
||||
"""讀一頁清單;拿不到 list 就重讀(握手逾時常常下一次就好)。仍拿不到回 None。"""
|
||||
for n in range(retries + 1):
|
||||
got = api(path)
|
||||
if isinstance(got, list):
|
||||
return got
|
||||
if n < retries and backoff:
|
||||
time.sleep(backoff * (n + 1))
|
||||
return None
|
||||
|
||||
owner, repo, mid = ms.get("owner"), ms.get("repo"), ms.get("id")
|
||||
title = ms.get("title") or ""
|
||||
info = {"milestone": [], "via_deps": [], "hub_ticket": hub_ticket(ms),
|
||||
"reached": False, "capped": False, "unread": [], "complete": False}
|
||||
q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100})
|
||||
rows = read_list("/repos/%s/%s/issues?%s" % (owner, repo, q))
|
||||
if not isinstance(rows, list):
|
||||
return [], info
|
||||
info["reached"] = True
|
||||
roots = []
|
||||
for it in rows:
|
||||
if not isinstance(it, dict) or it.get("pull_request"):
|
||||
continue
|
||||
im = it.get("milestone") or {}
|
||||
# 🔴 `milestones=` 在不同 Gitea 版本對「名稱 vs id」的解讀不一致,拿回來要自己用 id 確認
|
||||
if str(im.get("id")) != str(mid) and norm(im.get("title")) != norm(title):
|
||||
continue
|
||||
r = issue_ref(it, owner, repo)
|
||||
if r and r not in roots:
|
||||
roots.append(r)
|
||||
info["milestone"] = list(roots)
|
||||
h = hub_ticket(ms)
|
||||
if h and h not in roots:
|
||||
roots.append(h)
|
||||
seen, queue, out = set(roots), list(roots), list(roots)
|
||||
while queue:
|
||||
cur = queue.pop(0)
|
||||
parts = split_ref(cur)
|
||||
if not parts:
|
||||
continue
|
||||
deps = read_list("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts)
|
||||
if not isinstance(deps, list):
|
||||
info["unread"].append(cur) # 讀不到 ≠ 沒有相依:記下來,名單就不算完整
|
||||
continue
|
||||
for d in deps:
|
||||
r = issue_ref(d)
|
||||
if not r or r in seen:
|
||||
continue
|
||||
if len(out) >= cap:
|
||||
info["capped"] = True
|
||||
break
|
||||
seen.add(r); out.append(r); queue.append(r)
|
||||
info["via_deps"].append(r)
|
||||
info["complete"] = not info["unread"] and not info["capped"]
|
||||
return sorted(set(out)), info
|
||||
|
||||
|
||||
def _ms_matches(ticket_milestone, ms):
|
||||
"""票所掛的 milestone 是不是錨那一個。dict 看 id(沒 id 才退回標題);str 只有標題。"""
|
||||
if isinstance(ticket_milestone, dict):
|
||||
if ticket_milestone.get("id") is not None and ms.get("id") is not None:
|
||||
return str(ticket_milestone.get("id")) == str(ms.get("id"))
|
||||
return norm(ticket_milestone.get("title")) == norm(ms.get("title"))
|
||||
return norm(ticket_milestone) == norm(ms.get("title"))
|
||||
|
||||
|
||||
def belongs(ticket_ref, ms, ticket_milestone=None, blocks=None):
|
||||
"""這張票屬不屬於主線?回 True/False/None。
|
||||
|
||||
None = **不知道**(快取裡沒有這張票,而呼叫端也沒查到它的 milestone)。
|
||||
🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。
|
||||
None = **不知道**。🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。
|
||||
呼叫的閘拿到 None 時該放行(本 repo 心法第 2 條:誤攔比漏擋更該修)。
|
||||
|
||||
inkstone/ISEP#133 起要說「不屬於」需要兩件事實都在手上:
|
||||
ticket_milestone 票掛在哪個 milestone(dict 或標題字串;None=沒查)
|
||||
blocks 誰把這張票當相依(`/issues/N/blocks` 回的 ref 清單;None=沒查)
|
||||
· 在快取成員裡/就是 hub 票 ⇒ True
|
||||
· 掛在錨那一個 milestone(同 repo、同 id) ⇒ True
|
||||
· blocks 裡有任何一張是成員或 hub 票 ⇒ True
|
||||
· milestone 與 blocks 都查過、都不中 ⇒ False
|
||||
· 只查了其中一半而那一半不中 ⇒ None(別 repo 的票只看 milestone 判不出來)
|
||||
別 repo 裡同名的 milestone **不算**——那是被 leo 推翻的判準(本檔檔頭)。
|
||||
"""
|
||||
if not ms:
|
||||
return True # 沒有主線 ⇒ 沒有「不屬於主線」這回事
|
||||
ref = (ticket_ref or "").strip()
|
||||
if ref and ref in members(ms):
|
||||
return True
|
||||
if ticket_milestone_title is not None:
|
||||
return norm(ticket_milestone_title) == norm(ms.get("title"))
|
||||
parts = split_ref(ref)
|
||||
same_repo = bool(parts) and parts[0] == ms.get("owner") and parts[1] == ms.get("repo")
|
||||
if ticket_milestone is not None and ticket_milestone != "" and same_repo and _ms_matches(ticket_milestone, ms):
|
||||
return True
|
||||
if blocks is not None:
|
||||
mem = members(ms)
|
||||
if any((b or "").strip() in mem for b in blocks):
|
||||
return True
|
||||
if ticket_milestone is not None and blocks is not None:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def fixture_lookup(fx, ref):
|
||||
"""把 `ISEP_MAINLINE_FIXTURE` 裡一筆整理成 (ticket_milestone, blocks)。
|
||||
值是字串 ⇒ 那是它掛的 milestone 標題、而且**沒有人把它當相依**(blocks=[])——
|
||||
舊測資的形狀,兩件事實都給齊了,判得出 True/False。
|
||||
值是 dict ⇒ `{"milestone": {...}|"標題", "blocks": ["owner/repo#N", …]}`。
|
||||
沒收錄 ⇒ None(等同問不到)。"""
|
||||
if not isinstance(fx, dict) or ref not in fx:
|
||||
return None
|
||||
v = fx[ref]
|
||||
if isinstance(v, dict):
|
||||
return v.get("milestone"), list(v.get("blocks") or [])
|
||||
return v, []
|
||||
|
||||
|
||||
def fixture_titles():
|
||||
"""測試用:把「去 Gitea 問這張票掛在哪」換成一份現成的答案。
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
# 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地,
|
||||
# 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。
|
||||
#
|
||||
# ⚠️ **`sdd-guard.sh` 已於 `inkstone/ISEP#91` 退役**(執行 leo「取消 Active SDD」的裁決),
|
||||
# 所以本檔目前**沒有任何 caller**——但它不是死 code:上面那份清單裡的十幾支閘全都還在用
|
||||
# 「猜專案根」那個寫法,本檔就是要給它們用的解法。**不要因為沒人 source 就順手刪掉**,
|
||||
# 刪掉等於把 #22 學到的東西一起丟了。
|
||||
# (本段只加註解,不動下面任何一行邏輯——`8718658` 那輪修的判定行為原封不動。)
|
||||
#
|
||||
# 用法:
|
||||
# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh"
|
||||
# if ! path_in_git_worktree "$FILE_PATH"; then
|
||||
|
||||
@@ -57,10 +57,12 @@ have opened before.
|
||||
Usage:
|
||||
printf '%s' "$CMD" | python3 push_target_dir.py
|
||||
"""
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
_SEPARATORS = {";", "&&", "||", "|", "&", "\n"}
|
||||
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
|
||||
|
||||
def _join(base, path):
|
||||
@@ -82,17 +84,43 @@ def _join(base, path):
|
||||
return base.rstrip("/") + "/" + path
|
||||
|
||||
|
||||
def _classify(tokens):
|
||||
if not tokens:
|
||||
def _classify(tokens, verbs=("push",), strip_env=False):
|
||||
"""`verbs` is which git subcommand(s) count as the action being located.
|
||||
It defaults to push so find_push_target's behaviour is byte-identical to
|
||||
before; checkout_target_dir.py passes checkout/switch instead. Matching
|
||||
stays deliberately loose here (the verb may sit anywhere after `git`) --
|
||||
callers re-walk the returned token list to confirm command position, so
|
||||
a false positive at this layer costs nothing."""
|
||||
body = tokens
|
||||
if strip_env:
|
||||
# `FOO=1 git checkout …` -- an env prefix doesn't change which command
|
||||
# runs. Opt-in so find_push_target's behaviour stays byte-identical;
|
||||
# checkout_target_dir.py turns it on (its predecessor stripped these,
|
||||
# and dropping that would have been a silent regression -- caught by
|
||||
# the worktree gate's own case ㉒).
|
||||
#
|
||||
# 2026-09-07 (inkstone/ISEP#109 -> comment 6587): the prefix is skipped
|
||||
# for *classifying* the statement, but the "verb" event hands back the
|
||||
# full token list, prefix included. The worktree gate's escape hatch is
|
||||
# exactly that prefix (`WORKTREE_OK=1 git checkout …`), and a PreToolUse
|
||||
# hook can only see it in the command string -- it never reaches the
|
||||
# hook's own environment. Dropping it here made the printed way out
|
||||
# unwalkable. Callers that don't care simply skip leading NAME=value
|
||||
# tokens themselves (checkout_target_dir._moves_head does).
|
||||
i = 0
|
||||
while i < len(tokens) and _ENV_ASSIGN.match(tokens[i]):
|
||||
i += 1
|
||||
body = tokens[i:]
|
||||
if not body:
|
||||
return ("other", None)
|
||||
if tokens[0] == "cd" and len(tokens) > 1:
|
||||
return ("cd", tokens[1])
|
||||
if tokens[0] == "git" and "push" in tokens[1:]:
|
||||
return ("push", tokens)
|
||||
if body[0] == "cd" and len(body) > 1:
|
||||
return ("cd", body[1])
|
||||
if body[0] == "git" and any(v in body[1:] for v in verbs):
|
||||
return ("verb", tokens)
|
||||
return ("other", None)
|
||||
|
||||
|
||||
def _events(cmd):
|
||||
def _events(cmd, verbs=("push",), strip_env=False):
|
||||
"""Tokenize cmd into (kind, value) events in source order: 'enter'/
|
||||
'exit' for parens (subshell boundaries), 'cd'/'push'/'other' for
|
||||
statements split on the usual shell separators. Returns [] on any
|
||||
@@ -109,10 +137,10 @@ def _events(cmd):
|
||||
|
||||
def flush():
|
||||
if seg:
|
||||
# _classify's "push" branch returns `tokens` by reference; copy
|
||||
# _classify's "verb" branch returns `tokens` by reference; copy
|
||||
# before clear() below, or the event's tuple would observe the
|
||||
# list emptied out from under it (aliasing, not a value copy).
|
||||
events.append(_classify(seg[:]))
|
||||
events.append(_classify(seg[:], verbs, strip_env))
|
||||
seg.clear()
|
||||
|
||||
for tok in toks:
|
||||
@@ -147,7 +175,7 @@ def find_push_target(cmd):
|
||||
stack.pop() # subshell's own cd's don't leak out
|
||||
elif kind == "cd":
|
||||
stack[-1] = _join(stack[-1], val)
|
||||
elif kind == "push":
|
||||
elif kind == "verb":
|
||||
saw_push = True
|
||||
c_path = None
|
||||
toks = val
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""release_chain — ISEP 運送鏈「tag → release」那兩站的**收貨端事實**,一份定義三處用
|
||||
(inkstone/ISEP#67)
|
||||
|
||||
誰在用:
|
||||
scripts/release-ship 送貨:把已推的 tag 建成 release(前置條件從這裡問)
|
||||
scripts/release-check 覆核:逐版問 Gitea「到了嗎」
|
||||
hooks/lib/beacon_report.py 開場:SessionStart 報「打了 tag 卻沒 release」
|
||||
|
||||
🔴 為什麼收成一份:09-05 那版 release-ship/release-check 各自抄了一套「問 Gitea」
|
||||
的程式碼;要再讓 SessionStart 也問一次,就是第三份。同一個判準散在三處,
|
||||
修一份不會帶動另外兩份——`leo21c-write-guard.sh` 那一族就是這樣漂掉的
|
||||
(system-dev/wiki/mistakes.md 2026-09-07)。
|
||||
|
||||
🔴 讀一律匿名(D20:讀不帶憑證=自由、不計次)。inkstone/ISEP 是公開 repo,
|
||||
tags/releases/某個 tag 底下的檔案/兩個 tag 之間的 commit 都不需要 token。
|
||||
只有 401/403 才退回帶 token 再試一次;token 只有 POST(建 release)才是必要的。
|
||||
|
||||
判準是結構不是關鍵字:某一版「送到了沒」=它的 tag 與 release **在不在** Gitea 上。
|
||||
|
||||
離線:每個入口各自把 fixture dict 傳進來(鍵:tags/releases/manifest/commits),
|
||||
不打網路。fixture 的形狀在 `Chain.__init__` 的註解。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_HOST = "https://git.uncle6.me"
|
||||
DEFAULT_REPO = "inkstone/ISEP"
|
||||
MANIFEST_PATH = ".claude-plugin/plugin.json"
|
||||
|
||||
# 運送鏈的三站:鍵、人話、怎麼補(補法的指令由呼叫端決定要不要換成絕對路徑)
|
||||
STATIONS = [
|
||||
("dingban", "定版 commit",
|
||||
"在 main 上 commit 一顆 `定版 v{v}`(= plugin.json 也改成那一版)"),
|
||||
("tag", "tag(Gitea)",
|
||||
"打並推 tag:`git tag v{v} && git push origin v{v}`"),
|
||||
("release", "release(Gitea)",
|
||||
"建 release:`{ship} v{v}`(這一站就是最常斷的那站)"),
|
||||
]
|
||||
|
||||
|
||||
class ChainError(Exception):
|
||||
"""問不到收貨端(網路/HTTP 非 404)。呼叫端自己決定要死還是要閉嘴。"""
|
||||
|
||||
|
||||
def vtuple(v):
|
||||
nums = re.findall(r"\d+", v or "")
|
||||
return tuple(int(x) for x in nums[:3]) if nums else (0,)
|
||||
|
||||
|
||||
def tagname(v):
|
||||
v = (v or "").strip()
|
||||
return v if v.startswith("v") else "v" + v
|
||||
|
||||
|
||||
def token_from_env_or_remote(host, root=None):
|
||||
"""建 release 才用得到。環境變數優先;沒有就從 remote URL 裡撈(bootstrap 會帶)。"""
|
||||
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
|
||||
if os.environ.get(env):
|
||||
return os.environ[env]
|
||||
if not root:
|
||||
return ""
|
||||
h = host.split("//")[-1].rstrip("/")
|
||||
try:
|
||||
out = subprocess.run(["git", "-C", root, "remote", "-v"],
|
||||
capture_output=True, text=True, timeout=20).stdout
|
||||
except Exception:
|
||||
out = ""
|
||||
for line in out.splitlines():
|
||||
if h in line:
|
||||
m = re.search(r"//[^:/]+:([^@]+)@", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
class Chain:
|
||||
"""一個 repo 的運送鏈收貨端。
|
||||
|
||||
fixture(離線用)的形狀:
|
||||
{"tags": ["v0.24.0", ...],
|
||||
"releases": ["v0.23.0", ...],
|
||||
"manifest": {"v0.24.0": "0.24.0", ...}, # 那個 tag 底下 plugin.json 的 version
|
||||
"commits": {"v0.23.0...v0.24.0": ["定版 v0.24.0(…)", "..."], # 兩 tag 之間的 commit 標題
|
||||
"v0.24.0": ["…"]}} # 沒有上一個 release 時
|
||||
"""
|
||||
|
||||
def __init__(self, host=None, repo=None, fixture=None, timeout=25, token=None):
|
||||
self.host = (host or DEFAULT_HOST).rstrip("/")
|
||||
self.repo = repo or DEFAULT_REPO
|
||||
self.fx = fixture
|
||||
self.timeout = timeout
|
||||
self.token = token
|
||||
|
||||
# ── HTTP:匿名優先 ───────────────────────────────────────────────────
|
||||
def _get(self, path, params=""):
|
||||
url = "%s/api/v1/repos/%s/%s%s" % (self.host, self.repo, path, params)
|
||||
headers = {"Cache-Control": "no-cache"} # 09-05 實撞:邊緣快取的舊值被當 live
|
||||
for attempt in (0, 1):
|
||||
if attempt == 1:
|
||||
if not self.token:
|
||||
raise ChainError("Gitea 對匿名讀回 401/403,而這裡沒有 token:%s" % url)
|
||||
headers["Authorization"] = "token " + self.token
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
||||
return json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
if e.code in (401, 403) and attempt == 0:
|
||||
continue
|
||||
raise ChainError("Gitea %s:%s %s" % (e.code, url, e.read().decode()[:200]))
|
||||
except Exception as e:
|
||||
raise ChainError("連不上 %s:%s" % (url, e))
|
||||
return None
|
||||
|
||||
def _names(self, path, key):
|
||||
names, page = set(), 1
|
||||
while True:
|
||||
rows = self._get(path, "?limit=50&page=%d" % page) or []
|
||||
for r in rows:
|
||||
n = r.get(key)
|
||||
if n:
|
||||
names.add(n)
|
||||
if len(rows) < 50:
|
||||
return names
|
||||
page += 1
|
||||
|
||||
# ── 收貨端事實 ───────────────────────────────────────────────────────
|
||||
def tags(self):
|
||||
if self.fx is not None:
|
||||
return set(self.fx.get("tags") or [])
|
||||
return self._names("tags", "name")
|
||||
|
||||
def releases(self):
|
||||
if self.fx is not None:
|
||||
return set(self.fx.get("releases") or [])
|
||||
return self._names("releases", "tag_name")
|
||||
|
||||
def release(self, tag):
|
||||
"""這個 tag 的 release(dict),沒有就 None。"""
|
||||
if self.fx is not None:
|
||||
return {"tag_name": tag, "html_url": "(fixture)"} if tag in self.releases() else None
|
||||
return self._get("releases/tags/%s" % tag)
|
||||
|
||||
def manifest_version_at(self, tag):
|
||||
"""那個 tag 的樹裡 plugin.json 宣稱的版本(None = tag 不在或檔案不在)。
|
||||
|
||||
🔴 這是 release-ship 前置①的對象:**要建 release 的那個 tag 的樹**,
|
||||
不是「現在 checkout 出來的工作樹」——工作樹可能是任何分支,跟那個 tag 無關。
|
||||
"""
|
||||
if self.fx is not None:
|
||||
return (self.fx.get("manifest") or {}).get(tag)
|
||||
d = self._get("contents/%s" % MANIFEST_PATH, "?ref=%s" % tag)
|
||||
if not d:
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(d.get("content") or "").decode("utf-8")
|
||||
return (json.loads(raw) or {}).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def commits_between(self, base, head):
|
||||
"""base(可 None)到 head 之間的 commit 標題,**不含 merge commit**,舊在前。"""
|
||||
key = ("%s...%s" % (base, head)) if base else head
|
||||
if self.fx is not None:
|
||||
return list((self.fx.get("commits") or {}).get(key) or [])
|
||||
if base:
|
||||
d = self._get("compare/%s...%s" % (base, head)) or {}
|
||||
rows = d.get("commits") or []
|
||||
else:
|
||||
rows = self._get("commits", "?sha=%s&limit=50&stat=false&verification=false&files=false" % head) or []
|
||||
rows = list(reversed(rows))
|
||||
out = []
|
||||
for c in rows:
|
||||
if len(c.get("parents") or []) > 1:
|
||||
continue
|
||||
msg = ((c.get("commit") or {}).get("message") or "").strip()
|
||||
if msg:
|
||||
out.append(msg.splitlines()[0])
|
||||
return out
|
||||
|
||||
def tag_commit_message(self, tag):
|
||||
"""tag 指到的那顆 commit 的完整訊息(取 `定版 vX(人話)` 用)。"""
|
||||
if self.fx is not None:
|
||||
for lines in (self.fx.get("commits") or {}).values():
|
||||
for line in lines:
|
||||
if "定版" in line and tag.lstrip("v") in line:
|
||||
return line
|
||||
return ""
|
||||
t = self._get("tags/%s" % tag)
|
||||
sha = ((t or {}).get("commit") or {}).get("sha")
|
||||
if not sha:
|
||||
return ""
|
||||
c = self._get("git/commits/%s" % sha) or {}
|
||||
return ((c.get("commit") or {}).get("message") or "")
|
||||
|
||||
# ── 判準 ─────────────────────────────────────────────────────────────
|
||||
def latest_release_before(self, ver, releases=None):
|
||||
rels = releases if releases is not None else self.releases()
|
||||
older = [t for t in rels if vtuple(t) < vtuple(ver)]
|
||||
return max(older, key=vtuple) if older else None
|
||||
|
||||
|
||||
def gaps(ver, tags, releases, dingban=True):
|
||||
"""某一版缺哪幾站 → [(key, label, fix_template)],按運送順序。空 list = 都在。"""
|
||||
t = tagname(ver)
|
||||
present = {"dingban": dingban, "tag": t in tags, "release": t in releases}
|
||||
return [(k, label, fix) for k, label, fix in STATIONS if not present[k]]
|
||||
|
||||
|
||||
def dingban_title(message, ver):
|
||||
"""從 `定版 vX.Y.Z(一句人話)` 抓括號那句——leo 既有 release 的命名慣例。"""
|
||||
m = re.search(r"定版\s+v?%s[((]([^))]+)[))]" % re.escape(ver.lstrip("v")), message or "")
|
||||
return m.group(1).strip() if m else None
|
||||
+61
-4
@@ -46,7 +46,18 @@ _NAME_RE = re.compile(r"^name:\s*(.+?)\s*$", re.M)
|
||||
_DESC_RE = re.compile(r"^description:\s*(.+?)\s*$", re.M)
|
||||
# 「**負責 repo**:inkstone/ISEP」——粗體記號與全形冒號都當可選,
|
||||
# 免得改一次排版就讓名單讀不到 repo(那會靜默失效,比擋錯更難發現)。
|
||||
_REPO_RE = re.compile(r"\*{0,2}負責\s*repo\*{0,2}\s*[::]\s*([^\s((]+)")
|
||||
# 一個工人可以管**幾個同一件事的 repo**(inkstone/ISEP#118:課程工人同時管課件內容
|
||||
# `ax-courses`、引擎 `course_gen`、學員範本 `llm-wiki-template`)——用「、」或逗號隔開。
|
||||
# 抓到行尾或第一個括號為止,再切開;只收長得像 `owner/repo` 的片段。
|
||||
_REPO_RE = re.compile(r"\*{0,2}負責\s*repo\*{0,2}\s*[::]\s*([^\n((]+)")
|
||||
_REPO_SPLIT = re.compile(r"[、,,\s]+")
|
||||
|
||||
|
||||
def _parse_repos(body):
|
||||
m = _REPO_RE.search(body)
|
||||
if not m:
|
||||
return []
|
||||
return [p for p in _REPO_SPLIT.split(m.group(1).strip()) if "/" in p]
|
||||
|
||||
|
||||
def roster_dir():
|
||||
@@ -81,11 +92,13 @@ def load(directory=None):
|
||||
if not m:
|
||||
continue
|
||||
dm = _DESC_RE.search(head)
|
||||
rm = _REPO_RE.search(body)
|
||||
repos = _parse_repos(body)
|
||||
out.append({
|
||||
"name": m.group(1).strip(),
|
||||
"description": (dm.group(1).strip() if dm else ""),
|
||||
"repo": (rm.group(1).strip() if rm else ""),
|
||||
# `repo` 維持字串(閘的注入、【身份】欄、one_liner 都吃它);比對一律用 `repos`
|
||||
"repo": "、".join(repos),
|
||||
"repos": repos,
|
||||
"path": path,
|
||||
"body": body.strip(),
|
||||
})
|
||||
@@ -97,15 +110,59 @@ def names(directory=None):
|
||||
|
||||
|
||||
def find(name, directory=None):
|
||||
# 🔴 subagent_type 在 plugin 裡帶前綴:Claude Code 給的是 "isep:isep-hand",
|
||||
# 而 agents/*.md 的 frontmatter name 是 "isep-hand"。原本用完全相等比對
|
||||
# ⇒ 真實的派工一律認不出來,而名單裡的名字工具又不收 ⇒ 兩種寫法都派不出去。
|
||||
# 2026-08-28 v0.16.0 上線後實撞:整個環境派不出任何工,只能蓋 solo 戳記自己修。
|
||||
# 測試沒抓到是因為 24 條全部餵手寫 payload,沒有一條用真實的 subagent_type 形狀。
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
return None
|
||||
# 允許 "<plugin>:<name>";冒號後面那半才是名單上的名字
|
||||
bare = n.rsplit(":", 1)[-1].strip() if ":" in n else n
|
||||
for w in load(directory):
|
||||
if w["name"] == n:
|
||||
if w["name"] == n or w["name"] == bare:
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
def which(repo, directory=None, workers=None):
|
||||
"""這個 `owner/repo` 該派給誰 ⇒ 回工人清單(可能是空的)。大小寫不分。"""
|
||||
want = (repo or "").strip().lower()
|
||||
if not want:
|
||||
return []
|
||||
ws = workers if workers is not None else load(directory)
|
||||
return [w for w in ws if want in (r.lower() for r in w.get("repos") or [])]
|
||||
|
||||
|
||||
def uncovered(repo_rows, directory=None):
|
||||
"""給 Gitea `/orgs/{org}/repos` 回來的列 ⇒ 回「**有開著的票、卻沒有工人**」的 repo。
|
||||
|
||||
inkstone/ISEP#118:新 repo 的工人從來沒有人負責加——建 repo 的那一刻沒有任何東西提醒,
|
||||
要等到第一次派工撞上 roster-guard 才發現,而那時最省事的出路是開例外閘繞過去。
|
||||
|
||||
判準只看兩個事實,不看名字長相:
|
||||
· 名單上**沒有**任何工人的 `負責 repo` 包含它(唯一識別碼比對,方向是放行)
|
||||
· 它**有開著的票**(`open_issues_count > 0`,Gitea 這個數字不含 PR)
|
||||
⇒ 沒票的 repo(封存、投影用、還沒開張的)不叫:沒有工可派,就不是缺人
|
||||
封存的 repo 不叫。名單讀不到 ⇒ 回 None(**不是**「全部都缺人」——那是最貴的假情報)。
|
||||
"""
|
||||
ws = load(directory)
|
||||
if not ws:
|
||||
return None
|
||||
out = []
|
||||
for r in repo_rows or []:
|
||||
full = (r or {}).get("full_name") or ""
|
||||
if not full or r.get("archived"):
|
||||
continue
|
||||
n = int(r.get("open_issues_count") or 0)
|
||||
if n <= 0 or which(full, workers=ws):
|
||||
continue
|
||||
out.append({"repo": full, "open": n})
|
||||
out.sort(key=lambda x: (-x["open"], x["repo"]))
|
||||
return out
|
||||
|
||||
|
||||
def one_liner(w):
|
||||
repo = w.get("repo") or "(跨 repo)"
|
||||
return "%-18s %-26s %s" % (w["name"], repo, w.get("description", ""))
|
||||
|
||||
Executable
+242
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 一條派工線要在「共用的工作目錄」裡切分支時擋下來,叫它去開一份自己的 worktree——開在 repo **裡面**(`.worktrees/`),不開在旁邊;同一支閘在那條線收工那一刻(PostToolUse Agent|Task)把分身收掉:分支推了就 remove,沒推就點名分支與路徑,不靜默留著。
|
||||
# 為什麼: 2026-08-29 inkstone/ISEP#109 comment 5391——每個 repo 只有一份工作目錄,所有線共用。#163 切到自己的分支、交回時沒切回來,總管後來在同一個目錄跑 ship.mjs,讀到的是別人留下的 HEAD。票號說得出「這是哪個任務」,沒有任何東西說得出「這個目錄現在是誰留下的狀態」。
|
||||
# 🔴 2026-09-07 inkstone/ISEP#147:第一版教工人「開在 repo 旁邊、收工 worktree remove」,而 remove 那一步只寫在閘訊息裡、沒有任何機制驗 ⇒ 每個工人留一份,leo 在 Finder 看到 `ISEP-wt-117`/`ISEP-wt115`/`InkStoneCo-wt-112`/`course_gen-ticket-3-notes`…一排同頭不同尾的分身。leo:「一個 repo 只看得到一個資料夾」「禁止這樣產出一大堆資料夾」。規則在文字裡不在機器上——所以現在兩端都在機器上:開在裡面(Finder 看不到),收在 PostToolUse(忘了也會被收)。工具=scripts/worktree(open/close/sweep 同一份「推了沒」判準);規約=docs/governance/worktree-placement.md。
|
||||
# 誤觸時怎麼關: `WORKTREE_OK=1 git checkout …`——認的是**指令字串裡**這個字面前綴(留痕就在指令歷史上)。
|
||||
# 🔴 2026-09-07(inkstone/ISEP#109 → comment 6587):舊版讀的是 hook 自己的環境變數 `$WORKTREE_OK`,
|
||||
# 而 PreToolUse hook 跟指令不是同一個行程——指令前綴的賦值到不了它 ⇒ 閘印出來的出路在 Claude Code
|
||||
# 底下永遠走不通(總管三次照貼三次被擋)。現在兩種都認:環境變數(本機 shell export 過)與字面前綴
|
||||
# (閘訊息教的那一行)。判準是位置不是字:前綴必須掛在會移動 HEAD 的那條 git 指令上,見
|
||||
# lib/checkout_target_dir.py 檔頭;測試 E 群把閘印出來的那一行原樣餵回去驗它走得通。
|
||||
#
|
||||
# line-needs-own-worktree.sh — 共用工作目錄裡不准切分支(PreToolUse: Bash)
|
||||
#
|
||||
# ── 判準:git 自己回答的兩個問題,不看任何措辭 ────────────────────────
|
||||
# ① 這條命令會不會**移動 HEAD**?(checkout / switch,含 -b / -c)
|
||||
# —— 動作有限且可枚舉。`git checkout -- <檔>`/`git restore` 是還原檔案,不動 HEAD,放行。
|
||||
# ② 它要動的那個目錄,是不是那個 repo 的**主工作目錄**(=大家共用的那一份)?
|
||||
# git rev-parse --git-dir == git rev-parse --git-common-dir ⇒ 主工作目錄(共用)
|
||||
# 兩者不同 ⇒ 這已經是一份 worktree(自己的)
|
||||
# —— 這是 git 自己算出來的事實,**不需要任何人去登記**。
|
||||
#
|
||||
# ①②都成立、而且送出命令的是一條線(`CLAUDE_CODE_CHILD_SESSION=1`)⇒ 擋。
|
||||
#
|
||||
# ── 為什麼是「線」而不是所有人(這不是總管例外)──────────────────────
|
||||
# 共用工作目錄**就是總管那一份**——他在自己的目錄裡切分支不會偷走別人的狀態。
|
||||
# 會出事的是**線把總管的目錄留在自己的分支上**(08-29 實錄就是這個形狀)。
|
||||
# ⇒ 規則不是「誰比較大」,是「**誰的目錄**」。線要動工,就去開一份自己的。
|
||||
#
|
||||
# ── 為什麼不靠 `.claude/branch-holds.md`(驗收條件 6 的原話:不靠人記得)─────
|
||||
# `not-my-branch-guard.sh` 守的是「**別 commit 到別人登記過的分支**」——
|
||||
# ① 只認 `git commit`、② 只看頂層 repo、③ **要有人先手動登記**。
|
||||
# 而 08-29 出事的是子 repo(`products/arcrun-rag`、`matrix/arcrun`)、動作是 `checkout`、
|
||||
# 而且沒有人登記過。**兩支守的不是同一件事,這支不動那支。**
|
||||
#
|
||||
# ── worktree 開在哪:repo **裡面**的 `.worktrees/`,不在旁邊、不在 /tmp ─────────
|
||||
# /tmp(2026-08-29 實測):
|
||||
# $ git -C products/arcrun-rag worktree list
|
||||
# /private/tmp/.../scratchpad/wt-180 … prunable ← 目錄被系統清掉了
|
||||
# /private/tmp/wt167src … prunable
|
||||
# ISEP 同一天有 **12 筆 prunable**,全部在 /private/tmp 底下。
|
||||
# ⇒ 開在 `/tmp` 的會被系統清掉,而**登記簿還留著** ⇒ 登記簿說謊。
|
||||
# 旁邊(2026-09-07 inkstone/ISEP#147 實錄):一份都沒掉——**也一份都沒收**。
|
||||
# tech_projects/ 底下躺著 ISEP-wt-122ship、ISEP-wt-117、ISEP-wt115、InkStoneCo-wt-112…
|
||||
# leo 在 Finder 看到的是「一個 repo 長出一排同頭不同尾的分身」。
|
||||
# ⇒ 住 `<repo>/.worktrees/<票 repo>-<票號>`:Finder 看不到(點開頭)、`git status` 看不到
|
||||
# (`scripts/worktree open` 寫進 `.git/info/exclude`,不動被追蹤的檔)、
|
||||
# `git worktree list` 照樣列得到(收工那一半靠它找)。
|
||||
#
|
||||
# ── 收工那一半:同一支閘掛在 PostToolUse(Agent|Task)(inkstone/ISEP#147)───────
|
||||
# 派工單裡的 `【工單】owner/repo#N` 就是分身的名字(`.worktrees/<repo>-<N>`),
|
||||
# 跟 baton-handback-guard.sh/issue-status-autoflip.sh 同一個位置、同一種抓法。
|
||||
# 線收工那一刻:`scripts/worktree close <票>` ——
|
||||
# 推了且乾淨 ⇒ remove+prune,安靜。
|
||||
# 沒推/有未 commit/問不到遠端 ⇒ exit 2 點名分支與路徑,並印推的指令。**不 force、不刪。**
|
||||
# 為什麼不掛 SubagentStop:那裡拿不到票號(見 baton-handback-guard.sh 檔頭)。
|
||||
# 為什麼不另開一支閘:教你開的和替你收的是同一件事的兩半,拆成兩支就又是「規則在兩個地方」。
|
||||
#
|
||||
# ── 順手把說謊的登記清掉(驗收條件 7:prune 是收工動作,不是偶爾整理)───────
|
||||
# 擋下來的同時對那個 repo 跑一次 `git worktree prune`,並把清掉幾筆講出來。
|
||||
# prune **只刪「目錄已經不存在」的登記**,不碰任何還在的 worktree、不碰任何檔案。
|
||||
#
|
||||
# ── 誠實邊界 ────────────────────────────────────────────────────
|
||||
# 1. 它只擋 `checkout`/`switch`。**已經切過去之後**的 commit/build 這支看不到
|
||||
# (那是別的閘的地盤)。它守的是「別把共用目錄帶離原位」這一個動作。
|
||||
# 2. 它不知道那個 repo 現在有沒有別條線在動——**不知道就一律隔離**,
|
||||
# 因為「現在剛好沒人」是會變的,而分支留在那裡是不會自己回去的。
|
||||
# 3. `git worktree add` 之後那份 worktree 裡的 checkout 全部放行(判準 ②)。
|
||||
# 4. **目錄解不出來就放行**(`cd $VAR && git checkout`、`cd -`、引號壞掉)。
|
||||
# 不是漏了,是選的:猜錯會指著別的 repo 叫人去開 worktree,而他會照做、
|
||||
# 然後以為自己隔離好了。**寧可漏擋,也不要給錯的下一步**(comment 5398)。
|
||||
set -eu
|
||||
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
|
||||
# ── 收工那一半(PostToolUse Agent|Task):先於任何環境變數判斷——這裡跑的是總管的 session ──
|
||||
EVENT=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
sys.stdout.write(str(d.get("hook_event_name") or ""))
|
||||
' 2>/dev/null) || EVENT=""
|
||||
if [ "${EVENT:-}" = "PostToolUse" ]; then
|
||||
SPEC=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import json, re, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
if d.get("tool_name") not in ("Agent", "Task"):
|
||||
raise SystemExit
|
||||
p = (d.get("tool_input") or {}).get("prompt") or ""
|
||||
seen = []
|
||||
for line in p.splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
for m in re.finditer(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line):
|
||||
t = "%s/%s#%s" % m.groups()
|
||||
if t not in seen:
|
||||
seen.append(t)
|
||||
sys.stdout.write(" ".join(seen))
|
||||
' 2>/dev/null) || SPEC=""
|
||||
[ -n "${SPEC:-}" ] || exit 0
|
||||
WT_TOOL="$(cd "$(dirname "$0")/.." && pwd)/scripts/worktree"
|
||||
if [ ! -f "$WT_TOOL" ]; then
|
||||
echo "⚠️ line-needs-own-worktree:找不到 scripts/worktree,這條線的分身這一次沒有收。" >&2
|
||||
exit 0
|
||||
fi
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
# 掃哪裡:專案根、它的上一層(Mac:tech_projects/ 底下 ISEP 與 InkStoneCo 是兄弟)、
|
||||
# 雲端薄殼底下的真身(子 repo 在 InkStoneCo/matrix|products|polaris 底下,工具自己會往下找一層)。
|
||||
SCAN="--scan $ROOT --scan $(dirname "$ROOT")"
|
||||
[ -d "$ROOT/InkStoneCo" ] && SCAN="$SCAN --scan $ROOT/InkStoneCo"
|
||||
RC=0
|
||||
for T in $SPEC; do
|
||||
rc=0
|
||||
# shellcheck disable=SC2086
|
||||
OUT=$(python3 "$WT_TOOL" close "$T" $SCAN --quiet 2>&1 >/dev/null) || rc=$?
|
||||
[ -n "${OUT:-}" ] && printf '%s\n' "$OUT" | sed 's/^/ /' >&2
|
||||
[ "$rc" -eq 2 ] && RC=2
|
||||
done
|
||||
[ "$RC" -eq 2 ] || exit 0
|
||||
cat >&2 <<'EOF'
|
||||
|
||||
🌳 這條線收工了,但它的分身還留在 repo 裡(上面點名的那幾份)。
|
||||
不會靜默留著、也不會替它刪沒推的東西——推了再收,或確認作廢後自己刪。
|
||||
規約:docs/governance/worktree-placement.md(inkstone/ISEP#147)
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
[ "${WORKTREE_OK:-}" = "1" ] && exit 0
|
||||
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] || exit 0 # 總管在自己的目錄裡,不管
|
||||
|
||||
# 這條指令會動到哪個目錄的 HEAD——**不是問「hook 站在哪」**。
|
||||
# 2026-08-29 實測(inkstone/ISEP#109 → comment 5398):`cd <別的 repo> && git checkout`
|
||||
# 舊版拿 payload 的 cwd 當答案,於是擋對了、卻**指著另一個 repo 叫人去開 worktree**。
|
||||
# 照著做的人會在錯的 repo 開一份用不到的,真正要隔離的那個沒開到,而他以為隔離好了。
|
||||
# 🔴 一道閘給錯下一步,比不擋更糟。解析住在 lib/checkout_target_dir.py(純 tokenize,
|
||||
# 不執行任何指令;cd 鏈、子殼不外洩、-C 贏過 cd 的優先序都在那裡,理由見該檔檔頭)。
|
||||
CMD=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
ti = d.get("tool_input") or {}
|
||||
sys.stdout.write((ti.get("command") or "") if isinstance(ti, dict) else "")
|
||||
' 2>/dev/null) || exit 0
|
||||
[ -n "${CMD:-}" ] || exit 0
|
||||
|
||||
# heredoc 的 body 是資料不是指令(inkstone/ISEP#125 缺陷 D/E,2026-09-02 兩次實地命中):
|
||||
# 用 heredoc 寫一份「內含 `cd <目錄> && git checkout X` 示範」的說明文件,整條指令沒有任何
|
||||
# git 操作,卻被當成在切分支擋下——被擋的是「寫文件」這個動作。跟 github-contact-guard 一樣
|
||||
# 先把 body 剝掉再解析;剝不動就用原字串(fail-open 的是「這支 helper 有沒有跑」,不是判準)。
|
||||
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
|
||||
[ -n "${CMD:-}" ] || exit 0
|
||||
|
||||
CWD=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
sys.stdout.write(d.get("cwd") or "")
|
||||
' 2>/dev/null) || exit 0
|
||||
[ -n "${CWD:-}" ] && [ -d "$CWD" ] || CWD=$PWD
|
||||
|
||||
RESOLVER="$(dirname "$0")/lib/checkout_target_dir.py"
|
||||
if [ ! -f "$RESOLVER" ]; then
|
||||
# 解析器不見了=安裝壞了。**不靜默**:講出來再放行,因為這時候唯一能給的
|
||||
# 「下一步」就是錯的那一個(拿 cwd 當答案),而那正是本輪修掉的病。
|
||||
echo "⚠️ line-needs-own-worktree:找不到 lib/checkout_target_dir.py,這一次沒有檢查。" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EXPR=$(printf '%s' "$CMD" | python3 "$RESOLVER" 2>/dev/null) || exit 0
|
||||
[ -n "${EXPR:-}" ] || exit 0 # 沒有會移動 HEAD 的 checkout/switch
|
||||
[ "$EXPR" = "?" ] && exit 0 # 目錄解不出來(cd $VAR / cd -)⇒ 不猜,見該檔檔頭
|
||||
|
||||
# 出路:那條 checkout 自己帶著 `WORKTREE_OK=1` 前綴 ⇒ 放行(comment 6587)。
|
||||
# 看的是指令字串裡的位置,不是 hook 的環境——hook 的環境裡永遠不會有它。
|
||||
ESC=$(printf '%s' "$CMD" | python3 "$RESOLVER" --escape WORKTREE_OK=1 2>/dev/null) || ESC=""
|
||||
[ "${ESC:-}" = "1" ] && exit 0
|
||||
|
||||
TARGET=$(cd "$CWD" 2>/dev/null && cd "$EXPR" 2>/dev/null && pwd) || exit 0
|
||||
[ -n "${TARGET:-}" ] || exit 0
|
||||
|
||||
# 那個目錄是不是 repo 的**主工作目錄**(=共用的那一份)——git 自己回答
|
||||
GD=$(git -C "$TARGET" rev-parse --git-dir 2>/dev/null) || exit 0
|
||||
CD=$(git -C "$TARGET" rev-parse --git-common-dir 2>/dev/null) || exit 0
|
||||
ABS() { (cd "$TARGET" 2>/dev/null && cd "$1" 2>/dev/null && pwd) || printf '%s' "$1"; }
|
||||
[ "$(ABS "$GD")" = "$(ABS "$CD")" ] || exit 0 # 已經在自己的 worktree 裡 ⇒ 放行
|
||||
|
||||
TOP=$(git -C "$TARGET" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
NAME=$(basename "$TOP")
|
||||
PARENT=$(dirname "$TOP")
|
||||
# 印給人複製的指令用工具的絕對路徑——相對路徑會對貼的人的 cwd 解析(mistakes.md「出路跑不動」)
|
||||
WT_TOOL="$(cd "$(dirname "$0")/.." && pwd)/scripts/worktree"
|
||||
NOW=$(git -C "$TOP" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')
|
||||
|
||||
# 驗收條件 7:說謊的登記順手清掉(只刪目錄已經不存在的那些)
|
||||
BEFORE=$(git -C "$TOP" worktree list 2>/dev/null | grep -c 'prunable' || true)
|
||||
git -C "$TOP" worktree prune 2>/dev/null || true
|
||||
PRUNED=""
|
||||
[ "${BEFORE:-0}" -gt 0 ] && PRUNED="
|
||||
🧹 順手清掉 $BEFORE 筆**指向空氣**的 worktree 登記(目錄早就被清掉、登記還留著)。
|
||||
只刪登記,沒有動任何還在的 worktree、沒有動任何檔案。"
|
||||
|
||||
cat >&2 <<EOF
|
||||
🌳 這是**共用的工作目錄**,不要在這裡切分支。
|
||||
|
||||
目錄:$TOP
|
||||
它現在在:$NOW
|
||||
$PRUNED
|
||||
【為什麼(inkstone/ISEP#109,2026-08-29 實錄)】
|
||||
每個 repo 只有**一份**工作目錄,所有線共用。那天 arcrun-rag#163 切到自己的分支、
|
||||
交回時沒切回來;總管後來在同一個目錄跑 ship.mjs,**讀到的是別人留下的 HEAD**。
|
||||
🔴 票號說得出「這是哪個任務」,**沒有任何東西說得出「這個目錄現在是誰留下的狀態」**。
|
||||
(那次沒出錯貨——是文案契約閘擋下來的。閘有效,但那是最後一道。)
|
||||
|
||||
【現在怎麼做】開一份自己的——住在 repo **裡面**的 .worktrees/,然後在裡面做事:
|
||||
|
||||
python3 $WT_TOOL open <owner/repo#票號> --repo $TOP -b <你的分支>
|
||||
cd $TOP/.worktrees/<票的 repo 名>-<票號>
|
||||
|
||||
🔴 **不要開在 /tmp 底下**(08-29 實測:$NAME 指向 /private/tmp 的登記全部 prunable),
|
||||
**也不要開在 repo 旁邊**(09-07 實錄:$PARENT/ 底下躺了一排 $NAME-wt-…,
|
||||
leo 在 Finder 看到「一個 repo 一排分身」——inkstone/ISEP#147,禁止)。
|
||||
裡面的那份 Finder 看不到、git status 看不到,git worktree list 找得到。
|
||||
|
||||
收工時(推了會收掉;沒推會點名,不會靜默留著;你忘了,線收工那一刻這支閘也會替你收):
|
||||
python3 $WT_TOOL close <owner/repo#票號> --repo $TOP
|
||||
|
||||
規約:docs/governance/worktree-placement.md
|
||||
|
||||
【真的要在共用目錄裡切(接手、緊急止血)】
|
||||
WORKTREE_OK=1 git -C $TOP checkout <分支>
|
||||
留痕在指令歷史上,而且**切完要切回去**——$NOW 是你進來時的樣子。
|
||||
EOF
|
||||
exit 2
|
||||
@@ -315,12 +315,13 @@ fi
|
||||
#
|
||||
# 整套測試環境=下列任一出現在命令裡:
|
||||
# · `youlin-hsieh-dev` ── 帳號名(D37 定的 stage 帳號)
|
||||
# · `arcrun-yuga3bse` ── 該帳號 09-02 重裝後的 workers.dev 子網域(inkstone/ISEP#130;舊名 DNS 已死)
|
||||
# · `1129efd7df2e8899d537e9c8fbabb6cb` ── 該帳號 ID(用 env 指過去時只有它看得見)
|
||||
# · `_YOULIN_` ── 憑證變數名(如 `CLOUDFLARE_API_TOKEN_YOULIN_CC_USE`)
|
||||
# · `X-Arcrun-API-Key: youlin` ── 打該實例 API 時的身分標頭
|
||||
case "$CMD" in
|
||||
*staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*) exit 0 ;;
|
||||
*youlin-hsieh-dev*|*1129efd7df2e8899d537e9c8fbabb6cb*|*_YOULIN_*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;;
|
||||
*youlin-hsieh-dev*|*arcrun-yuga3bse*|*1129efd7df2e8899d537e9c8fbabb6cb*|*_YOULIN_*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 🔴 2026-08-12 leo 拍板「加閘」:**geek6688(出貨機)是明文授權「總管可以直接動」的那一台。**
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
#
|
||||
# 本閘從頭到尾**不讀派工單的任何一個字義**。它只做兩個查表:
|
||||
# ① 派工單的【工單】欄位是哪一張票(`hooks/lib/dispatch_parse.py` 解結構,不判語意)
|
||||
# ② 那張票所屬的 milestone 標題,等不等於被標定的主線標題(字串相等,不是模糊比對)
|
||||
# 措辭怎麼寫都不影響判決;改的只有「這張票掛在哪」這個 Gitea 上的事實。
|
||||
# ② 那張票在不在主線上:掛在錨那一個 milestone(同 repo、同 id),或有任何一張主線成員
|
||||
# 把它當相依(`/blocks`)。判準是 milestone id 與相依邊——**不是標題**:
|
||||
# 別 repo 裡同名的里程碑不算(leo 2026-09-07,inkstone/ISEP#133)。
|
||||
# 措辭怎麼寫都不影響判決;改的只有「這張票掛在哪、誰指著它」這兩個 Gitea 上的事實。
|
||||
#
|
||||
# ── 誤攔的出口全部先關掉(誤攔比漏擋更該修)──────────────────────────
|
||||
# · **沒有主線 ⇒ 一律放行**(ISEP#82 驗收第 4 條:沒有主線不能整組壞掉)
|
||||
@@ -92,16 +94,20 @@ for ref in wanted:
|
||||
if v is None:
|
||||
unknown.append(ref)
|
||||
|
||||
# ② 快取查不到的,去問那張票掛在哪。問不到 ⇒ 放行。
|
||||
# ② 快取查不到的,去問兩件事實:那張票掛在哪個 milestone、誰把它當相依(inkstone/ISEP#133)。
|
||||
# 兩件都到手才敢說「不在」;問不到任何一件 ⇒ 放行。
|
||||
if unknown:
|
||||
fx = ML.fixture_titles()
|
||||
if fx is not None: # 測試把外界定住(見 mainline.fixture_titles)
|
||||
learned = [r for r in unknown
|
||||
if ML.norm(fx.get(r, "")) == ML.norm(ms.get("title"))]
|
||||
if learned:
|
||||
if fx is not None: # 測試把外界定住(見 mainline.fixture_lookup)
|
||||
verdicts = {}
|
||||
for r in unknown:
|
||||
got = ML.fixture_lookup(fx, r)
|
||||
if got is not None:
|
||||
verdicts[r] = ML.belongs(r, ms, ticket_milestone=got[0], blocks=got[1])
|
||||
if any(v is True for v in verdicts.values()):
|
||||
print("SKIP:on-mainline-fixture"); raise SystemExit
|
||||
if not any(r in fx for r in unknown):
|
||||
bail("unreachable") # fixture 沒收錄 ⇒ 等同問不到
|
||||
if not verdicts or any(v is None for v in verdicts.values()):
|
||||
bail("unreachable") # fixture 沒收錄/只知一半 ⇒ 等同問不到
|
||||
else:
|
||||
try:
|
||||
T = load("isep_ticket", os.path.join(HERE, "..", "scripts", "ticket"))
|
||||
@@ -111,28 +117,37 @@ if unknown:
|
||||
# 那不是 Exception 的子類。只接 Exception 的話,這支會整個死掉而不是放行。
|
||||
except BaseException:
|
||||
bail("no-token") # 讀不到 ≠ 不屬於
|
||||
def get(path):
|
||||
req = urllib.request.Request("%s/api/v1%s" % (host, path),
|
||||
headers={"Authorization": "token %s" % tok})
|
||||
return json.load(urllib.request.urlopen(req, timeout=6))
|
||||
reached = False
|
||||
learned = []
|
||||
undecided = False
|
||||
for ref in unknown:
|
||||
parts = ML.split_ref(ref)
|
||||
if not parts:
|
||||
continue
|
||||
try:
|
||||
owner, rest = ref.split("/", 1)
|
||||
repo, num = rest.split("#", 1)
|
||||
req = urllib.request.Request(
|
||||
"%s/api/v1/repos/%s/%s/issues/%s" % (host, owner, repo, num),
|
||||
headers={"Authorization": "token %s" % tok})
|
||||
it = json.load(urllib.request.urlopen(req, timeout=6))
|
||||
it = get("/repos/%s/%s/issues/%d" % parts)
|
||||
reached = True
|
||||
except Exception:
|
||||
continue
|
||||
title = ((it or {}).get("milestone") or {}).get("title") or ""
|
||||
if ML.norm(title) == ML.norm(ms.get("title")):
|
||||
try:
|
||||
blocks = [x for x in (ML.issue_ref(b) for b in get("/repos/%s/%s/issues/%d/blocks?limit=100" % parts)) if x]
|
||||
except Exception:
|
||||
blocks = None # 讀不到「誰把它當相依」⇒ 這一張判不出來
|
||||
v = ML.belongs(ref, ms, ticket_milestone=(it or {}).get("milestone") or {}, blocks=blocks)
|
||||
if v is True:
|
||||
learned.append(ref)
|
||||
elif v is None:
|
||||
undecided = True
|
||||
if learned:
|
||||
ms["members"] = sorted(set(ms.get("members") or []) | set(learned))
|
||||
ML.save(ms)
|
||||
print("SKIP:on-mainline-fresh"); raise SystemExit
|
||||
if not reached:
|
||||
bail("unreachable") # 一張都問不到 ⇒ 放行
|
||||
if not reached or undecided:
|
||||
bail("unreachable") # 問不到/只知一半 ⇒ 放行
|
||||
|
||||
# ③ 確定都不在主線上 —— 同一組票只擋一次
|
||||
import hashlib
|
||||
@@ -161,7 +176,8 @@ case "$VERDICT" in
|
||||
printf ' 這次要派的:%s\n\n' "$TICKETS"
|
||||
printf '**先說是哪一種,再繼續**——兩種都可能是對的,錯的是默默做完:\n\n'
|
||||
printf ' ① 補收 —— 它本來就該在主線上,只是沒掛上去\n'
|
||||
printf ' `scripts/mainline adopt %s` 然後重送\n\n' "$FIRST"
|
||||
printf ' `scripts/mainline adopt %s` 然後重送\n' "$FIRST"
|
||||
printf ' (別 repo 的票會成為 hub 票的相依;**不要**去那個 repo 開同名里程碑)\n\n'
|
||||
printf ' ② 跳線 —— 真的是插件事(leo 臨時交辦、擋路的地雷…)\n'
|
||||
printf ' 直接重送即可(**同一張票只擋一次**),並在回覆講一句為什麼現在要岔開主線\n\n'
|
||||
printf '為什麼要問:leo 2026-08-27「今天……分心」。分心的前提是有一條主線,\n'
|
||||
|
||||
@@ -79,10 +79,12 @@ set -- $REF
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
|
||||
# 拿 token(拿不到就放行——這是本地環境問題,不是派工者的錯)
|
||||
URL=$(git -C "${CLAUDE_PROJECT_DIR:-.}" remote get-url gitea 2>/dev/null || true)
|
||||
case "${URL:-}" in *@*) ;; *) exit 0 ;; esac
|
||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
HOST=$(printf '%s' "$URL" | sed -E 's|.*@([^/]+)/.*|\1|')
|
||||
# token 來源=env/.env(inkstone/ISEP#47),不再從 remote URL 抽(那會逼 token 明文嵌在 URL 裡)
|
||||
_GT_LIB="$(dirname -- "$0")/../scripts/lib/gitea-token.sh"; [ -f "$_GT_LIB" ] && . "$_GT_LIB"
|
||||
command -v gitea_token >/dev/null 2>&1 || exit 0
|
||||
TOKEN=$(gitea_token) || exit 0
|
||||
[ -n "$TOKEN" ] || exit 0
|
||||
HOST="git.uncle6.me"
|
||||
|
||||
RESP=$(curl -s --max-time 15 -H "Authorization: token $TOKEN" \
|
||||
"https://$HOST/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null || true)
|
||||
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# overdue-nag-guard.sh — SessionStart:**開工的時候,先把沒人會叫的事叫出來**
|
||||
# (inkstone/ISEP#93)
|
||||
#
|
||||
# ── 為什麼是 SessionStart,不是排程 ────────────────────────────────────
|
||||
# 票上寫死:「第一版做成手動可跑的腳本 + 開 session 時順手跑一次,
|
||||
# **不依賴 Gitea 的自動通知機制**」。理由兩個,都不是偷懶:
|
||||
# ① 機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/inkstone/hooks` 回 403 ——**裝不了**
|
||||
# ② ISEP v0.6.0 §8.2:「壞掉的形式是『以為有人在跑』,比『沒人跑』貴得多」
|
||||
# 而頂層 `CLAUDE.md` 的 flag 紅線直接禁掉排程輪詢與事件 fan-out。
|
||||
# ⇒ 這支的觸發點是**人開工的那一刻**,不是時鐘。
|
||||
#
|
||||
# ── 這支不是閘,是**鬧鐘** ────────────────────────────────────────────
|
||||
# 它不擋任何東西(永遠 exit 0)。它做三件事:
|
||||
# ① 撈:`scripts/isep-nag` 去問 Gitea(逾期 milestone/等 leo 的票/掉在地上的棒子)
|
||||
# ② 講:把白話報告注入眼前——連「查過了,沒有」也要講出來
|
||||
# ③ 叫:發 Telegram 給 leo。**發不出去的時候不會安靜**(見下)
|
||||
#
|
||||
# ── 🔴 「發不出去」是每個 session 各自不同的事實 ──────────────────────
|
||||
# 2026-08-28 實測:能不能發 Telegram 取決於**這個 session 載到的 ISEP 是哪一版**——
|
||||
# `prod-write-guard` v0.10.0 才認得出「發通知」不是「部署工作流」,
|
||||
# 而 **hook 的註冊路徑是 session 啟動當下寫死的** ⇒ 更新 plugin 之後要開新 session。
|
||||
#
|
||||
# ⇒ 所以本支**不假設發得出去**:`isep-notify` 會先拿真的要送的那一則去問
|
||||
# `$CLAUDE_PLUGIN_ROOT/hooks/prod-write-guard.sh`(=這個 session 註冊的那一份,
|
||||
# 沒有第二種可能),把判定寫成檔,然後
|
||||
# · 放行 ⇒ 送,並驗**內層** `data.data.ok`(外層 200 不算送到)
|
||||
# · 會擋 ⇒ **不繞路**,改貼回 `inkstone/ISEP#93`,並把整段印在眼前
|
||||
# **發不出去而靜默,等於沒做**——所以判定與退路都會出現在注入的文字裡。
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
NAG="$ROOT/scripts/isep-nag"
|
||||
NOTIFY="$ROOT/scripts/isep-notify"
|
||||
[ -x "$NAG" ] || [ -f "$NAG" ] || exit 0
|
||||
|
||||
STATE="${ISEP_NAG_STATE_DIR:-$HOME/.claude/isep-nag}"
|
||||
export ISEP_NOTIFY_STATE_DIR="${ISEP_NOTIFY_STATE_DIR:-$STATE}"
|
||||
COOLDOWN="${ISEP_NAG_NOTIFY_COOLDOWN:-21600}" # 預設 6 小時叫一次,不洗版
|
||||
TICKET="${ISEP_NAG_TICKET:-inkstone/ISEP#93}"
|
||||
BUDGET="${ISEP_NAG_TIMEOUT:-25}"
|
||||
mkdir -p "$STATE" 2>/dev/null || true
|
||||
|
||||
# 子 session 不叫:subagent 開工不是 leo 開工,鬧鐘響給沒有手的人聽是噪音。
|
||||
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
|
||||
|
||||
# ── ① 撈 ──────────────────────────────────────────────────────────────
|
||||
# 逾時/沒網路/沒 token 都不准讓開場卡住——這支是鬧鐘,不是安全閘。
|
||||
# ISEP_NAG_FIXTURE 只給測試用:讓整條路徑(撈→講→叫)離線跑得完,不打 Gitea。
|
||||
ARGS=()
|
||||
[ -n "${ISEP_NAG_FIXTURE:-}" ] && ARGS=(--fixture "$ISEP_NAG_FIXTURE")
|
||||
# 🔴 一次算完長版+短版(--both)。分兩次呼叫=打兩輪 Gitea=開場多等一輪,
|
||||
# 而這裡是 leo 每次開工都會經過的關鍵路徑。
|
||||
BOTH=$(timeout "$BUDGET" python3 "$NAG" "${ARGS[@]+"${ARGS[@]}"}" --both 2>/dev/null) || BOTH=""
|
||||
SEP='␟--isep-nag-short--␟'
|
||||
REPORT=$(printf '%s' "$BOTH" | sed -n "1,/$SEP/p" | sed '$d')
|
||||
SHORT=$(printf '%s' "$BOTH" | sed -n "/$SEP/,\$p" | sed '1d')
|
||||
[ -n "$REPORT" ] || REPORT="🔔 催辦員這次沒跑完(逾時 ${BUDGET}s 或拿不到 Gitea)。
|
||||
⇒ **這不等於「沒有事情逾期」**,這是「我沒查到」。手動補一次:python3 scripts/isep-nag"
|
||||
|
||||
# ── ② 叫(有東西才叫,而且有冷卻)────────────────────────────────────
|
||||
NOTE=""
|
||||
STAMP="$STATE/last-notify"
|
||||
now=$(date +%s 2>/dev/null || echo 0)
|
||||
last=0
|
||||
[ -f "$STAMP" ] && last=$(stat -c %Y "$STAMP" 2>/dev/null || stat -f %m "$STAMP" 2>/dev/null || echo 0)
|
||||
case "$now$last" in *[!0-9]*) now=0; last=0 ;; esac
|
||||
|
||||
if [ "${ISEP_NAG_NO_NOTIFY:-}" = "1" ]; then
|
||||
NOTE="(ISEP_NAG_NO_NOTIFY=1,這次不發 Telegram)"
|
||||
elif [ -z "$SHORT" ]; then
|
||||
NOTE="(撈不到東西,沒有可發的內容——這不是「沒事」,是「沒查到」)"
|
||||
elif printf '%s' "$SHORT" | grep -q "查過了,沒有"; then
|
||||
# 🔴 「沒事」不發 Telegram,但**眼前照樣講**。
|
||||
# 每隔幾小時發一則「一切正常」,leo 會學會不看它——那時真的有事也叫不動他。
|
||||
NOTE="(三類都乾淨,不打擾 leo;但上面那段一定講出來)"
|
||||
elif [ "$now" -gt 0 ] && [ $((now - last)) -lt "$COOLDOWN" ]; then
|
||||
NOTE="($(( (COOLDOWN - (now - last)) / 60 )) 分鐘前才叫過,這次不重複發)"
|
||||
else
|
||||
NOTE=$(timeout 60 python3 "$NOTIFY" --text "$SHORT" --fallback-ticket "$TICKET" 2>&1) \
|
||||
&& touch "$STAMP" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── ③ 講(不管上面走哪條路,都要有話)────────────────────────────────
|
||||
python3 - "$REPORT" "$NOTE" <<'PY'
|
||||
import json, sys
|
||||
report, note = sys.argv[1], sys.argv[2]
|
||||
head = report.splitlines()[0] if report else "🔔 催辦員"
|
||||
n = sum(report.count(k) for k in ("⏰ **逾期", "🙋 **在等你", "🧤 **棒子"))
|
||||
sysmsg = "🔔 催辦員查過了(%s 類有事)" % n if n else "🔔 催辦員查過了:三類都乾淨"
|
||||
|
||||
ctx = "\n".join([
|
||||
report,
|
||||
"",
|
||||
"── 通知這一段 ──",
|
||||
note or "(沒有通知動作)",
|
||||
"",
|
||||
"🔴 這段是 ISEP 的催辦員(inkstone/ISEP#93)自己發的,不是你想起來要查的。",
|
||||
"它只在**你開 session 的那一刻**跑一次——**CC 沒在跑的那段時間補不到**,",
|
||||
"那一段要 leo 決定要不要開 Gitea 的通知權限(機器帳號現在是 403)。",
|
||||
"",
|
||||
"要重跑:`python3 scripts/isep-nag`(`--json` 看全部、`--notify` 再發一次)。",
|
||||
])
|
||||
print(json.dumps({
|
||||
"systemMessage": sysmsg,
|
||||
"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": ctx},
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
exit 0
|
||||
Executable
+283
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 同時在跑的派工線已經到上限時,再派下一條就擋下來,並列出現在有哪幾條在跑。
|
||||
# 為什麼: 2026-08-29 總管同時開七條線,8 GB 的 Mac 被吃爆重開機;同一天還派錯線一次、量錯東西六次、停掉正確的工作一次。leo:「跑 2-3 條應該是極限了」。在這之前這條規則只活在總管的回覆文字裡。
|
||||
# 誤觸時怎麼關: `touch /tmp/.parallel-ok-<session_id>` 後重送(用掉就消失,會留在指令歷史上)。上限本身用 `ISEP_MAX_PARALLEL_LINES` 調。
|
||||
#
|
||||
# parallel-lines-cap-guard.sh — 同時跑的線有上限(PreToolUse: Agent / Task)
|
||||
#
|
||||
# ── 判準:數 harness 自己寫下來的檔案,不數我自己記的數字 ──────────────
|
||||
# 這支一個字都不看派工單的內容。它只回答一個機械問題:
|
||||
#
|
||||
# **現在有幾條線還在跑?**
|
||||
#
|
||||
# 一條線算「還在跑」,要三個條件同時成立:
|
||||
# ① `<projects>/<sid>/subagents/agent-<id>.meta.json` 存在
|
||||
# —— harness 在啟動那條線的當下寫的,不是我寫的
|
||||
# ② 主 transcript 裡**還沒有**它的完工通知 `<task-id><id></task-id>`
|
||||
# ③ 它自己的 `agent-<id>.jsonl` 在 IDLE 秒內還在被寫(預設 900 秒)
|
||||
#
|
||||
# ①②③ 都是別人寫的事實,這支只是去讀。**沒有任何一個計數器要我去減。**
|
||||
#
|
||||
# ── 為什麼不用計數器(PreToolUse +1 / SubagentStop −1)────────────────
|
||||
# 減不掉就永遠卡死。而這張票的驗收條件第 2 條寫死:
|
||||
# 「前一條收工後,下一條派得出去——不能變成永久卡死」。
|
||||
# 🔴 更關鍵的是**那個減法根本不會發生**(2026-08-29 實測,非推測):
|
||||
#
|
||||
# $ grep -oh '"hookEvent":[ ]*"[^"]*"' <本 session 的 transcript> | sort | uniq -c
|
||||
# 4545 "hookEvent":"PreToolUse"
|
||||
# 530 "hookEvent":"Stop"
|
||||
# 22 "hookEvent":"PostToolUse"
|
||||
# 20 "hookEvent":"SessionStart"
|
||||
# ⇒ **`SubagentStop` 在這個 session 一次都沒出現過。**
|
||||
# (hooks.json 目前有 7 條掛在 SubagentStop 上——那是另一張票的事,本票不動它們。)
|
||||
#
|
||||
# ── 為什麼不數行程 ────────────────────────────────────────────────
|
||||
# 票上引的是「claude 12 支 923 MB」。2026-08-29 20:31 在同一台機器實測:
|
||||
#
|
||||
# $ ps -Ao pid,ppid,rss,comm | grep -i claude
|
||||
# ...一堆 /Applications/Claude.app/... 的 Electron helper...
|
||||
# 2196 2195 389584 .../claude-code/2.1.246/claude.app/Contents/MacOS/claude
|
||||
#
|
||||
# ⇒ **Claude Code 只有一個行程(pid 2196)**,那 12 支是 Claude Desktop 的 helper。
|
||||
# subagent 不是獨立行程,**數行程數不到線**。(在別的 surface 可能不同——但這台是這樣。)
|
||||
#
|
||||
# ── 為什麼不掛在 PostToolUse(Agent) 上做「收工」的判斷 ─────────────────
|
||||
# 實測:PostToolUse:Agent 是在**送出後約 5 秒**觸發,那條線還在跑。
|
||||
# 12:26:46 PreToolUse:Agent toolu_01SPDbSPvzXCXHgMehcy3UKq
|
||||
# 12:26:51 tool_result "Async agent launched successfully... agentId: a6019a…"
|
||||
# 12:26:51 PostToolUse:Agent (同一秒)
|
||||
# ⇒ agent 這時候才剛開始跑。真正的完工通知是 66 分鐘後才進 transcript 的
|
||||
# `<task-notification>`(另一條線實測:10:35:59 送出 → 11:40:13 通知)。
|
||||
#
|
||||
# ── 誤攔的方向被刻意壓在「漏擋」那一邊 ────────────────────────────────
|
||||
# `.claude/branch-holds.md` 檔頭:**永遠在響的警報,等於訓練人忽略這個警報。**
|
||||
# 所以:
|
||||
# • 條件 ③ 的 IDLE 窗是**上限保險**——就算完工通知的格式哪天變了、②整個失效,
|
||||
# 一條線最多也只會被多算 15 分鐘,**不可能永久卡死**。
|
||||
# • 讀不到 payload/算不出來 ⇒ **放行**,但**出聲**(把「這次沒把關」講給模型聽),
|
||||
# 不是靜默放行。房規要的是「內部錯誤不准變成**靜默**放行」,
|
||||
# 而在這支身上「算不出來就擋」會變成整台機器派不了工——那是比漏擋貴得多的失敗。
|
||||
#
|
||||
# ── 誠實邊界(不補,補了就變成猜)────────────────────────────────────
|
||||
# 1. **只認得這個 surface 的檔案佈局**(`~/.claude/projects/<slug>/<sid>/subagents/`)。
|
||||
# 雲端 CC 的佈局沒實測過 ⇒ 那裡找不到目錄時這支等於不存在(會出聲說它數不出來)。
|
||||
# 2. **同步 agent**(不背景跑的)靠「tool_result 不是 launch ack」判收工;
|
||||
# 這條路在本機沒有實跡可對(這個 session 的 81 次派工全是背景),只有測試涵蓋。
|
||||
# 3. 它管的是**條數**,不是記憶體。真正吃掉 8 GB 的是什麼,這支不知道也不猜。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
CAP="${ISEP_MAX_PARALLEL_LINES:-3}"
|
||||
IDLE="${ISEP_PARALLEL_IDLE_SEC:-900}"
|
||||
|
||||
export ISEP_CAP="$CAP" ISEP_IDLE="$IDLE"
|
||||
|
||||
OUT=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import json, os, re, sys, time
|
||||
|
||||
def bail(msg=""):
|
||||
# 算不出來 ⇒ 放行,但把原因印出來(下面的 shell 會轉成 additionalContext)
|
||||
if msg:
|
||||
print("NOTE\t" + msg)
|
||||
raise SystemExit
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
bail("並行閘讀不到 hook payload,這一次沒有把關。")
|
||||
|
||||
tool = d.get("tool_name") or ""
|
||||
if tool not in ("Agent", "Task"):
|
||||
raise SystemExit # 不是派工,這支沒有立場說話
|
||||
|
||||
sid = d.get("session_id") or ""
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not sid or not tp:
|
||||
bail("並行閘拿不到 session_id / transcript_path,這一次沒有把關。")
|
||||
|
||||
# ── 找 subagents 目錄 ────────────────────────────────────────────
|
||||
# 主 session 的 transcript: <projects>/<sid>.jsonl
|
||||
# 子 agent 的 transcript: <projects>/<sid>/subagents/agent-<id>.jsonl
|
||||
# 兩種都要能回推到 <projects>。
|
||||
base = os.path.dirname(tp)
|
||||
if os.path.basename(base) == "subagents":
|
||||
base = os.path.dirname(os.path.dirname(base))
|
||||
|
||||
subdir = os.path.join(base, sid, "subagents")
|
||||
main = os.path.join(base, sid + ".jsonl")
|
||||
|
||||
if not os.path.isdir(subdir):
|
||||
raise SystemExit # 一條線都還沒派過 ⇒ 沒得數,也沒什麼好說的
|
||||
|
||||
cap = int(os.environ.get("ISEP_CAP", "3"))
|
||||
idle = int(os.environ.get("ISEP_IDLE", "900"))
|
||||
now = time.time()
|
||||
|
||||
# ── ① harness 寫下來的那些線 + ③ 還在動的那些 ─────────────────────
|
||||
cand = {}
|
||||
try:
|
||||
names = os.listdir(subdir)
|
||||
except OSError:
|
||||
bail("並行閘讀不到 subagents 目錄,這一次沒有把關。")
|
||||
|
||||
for fn in names:
|
||||
if not (fn.startswith("agent-") and fn.endswith(".meta.json")):
|
||||
continue
|
||||
aid = fn[len("agent-"):-len(".meta.json")]
|
||||
jl = os.path.join(subdir, "agent-%s.jsonl" % aid)
|
||||
try:
|
||||
quiet = now - os.path.getmtime(jl)
|
||||
except OSError:
|
||||
continue # 連 transcript 都沒有 ⇒ 不是活的線
|
||||
if quiet > idle:
|
||||
continue # ③ 安靜太久 ⇒ 收工了或死了,一律不算
|
||||
try:
|
||||
meta = json.load(open(os.path.join(subdir, fn), encoding="utf-8"))
|
||||
except Exception:
|
||||
meta = {}
|
||||
try:
|
||||
started = now - os.path.getmtime(os.path.join(subdir, fn))
|
||||
except OSError:
|
||||
started = 0
|
||||
cand[aid] = {
|
||||
"name": meta.get("name") or meta.get("description") or "(沒取名字)",
|
||||
"type": meta.get("agentType") or "?",
|
||||
"desc": meta.get("description") or "",
|
||||
"tuid": meta.get("toolUseId") or "",
|
||||
"quiet": int(quiet),
|
||||
"age": int(started),
|
||||
}
|
||||
|
||||
if not cand:
|
||||
raise SystemExit
|
||||
|
||||
# ── ② 主 transcript 裡的完工通知 ─────────────────────────────────
|
||||
# 實跡:{"type":"queue-operation","operation":"enqueue", ... "<task-notification>
|
||||
# <task-id>a76283ec07f38ed24</task-id><tool-use-id>toolu_…</tool-use-id>"}
|
||||
done = set()
|
||||
byuid = {v["tuid"]: a for a, v in cand.items() if v["tuid"]}
|
||||
acked = {}
|
||||
|
||||
if os.path.isfile(main):
|
||||
marks = {a: "<task-id>%s</task-id>" % a for a in cand}
|
||||
try:
|
||||
with open(main, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
for a, mk in marks.items():
|
||||
if a in done:
|
||||
continue
|
||||
if mk in line:
|
||||
done.add(a)
|
||||
if not byuid or "tool_result" not in line:
|
||||
continue
|
||||
if not any(u in line for u in byuid):
|
||||
continue
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
msg = e.get("message")
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
for b in (msg.get("content") or []):
|
||||
if not isinstance(b, dict) or b.get("type") != "tool_result":
|
||||
continue
|
||||
a = byuid.get(b.get("tool_use_id"))
|
||||
if not a:
|
||||
continue
|
||||
txt = json.dumps(b.get("content"), ensure_ascii=False)
|
||||
# launch ack = 這條線才剛開始;別的內容 = 同步 agent 已經交件
|
||||
acked[a] = ("Async agent launched successfully" in txt)
|
||||
except OSError:
|
||||
pass
|
||||
# 主 transcript 讀不到 ⇒ 只靠 ③ 的 IDLE 窗,仍然不會卡死(誠實邊界 1)
|
||||
|
||||
running = [a for a in cand if a not in done and acked.get(a, True)]
|
||||
if len(running) < cap:
|
||||
raise SystemExit
|
||||
|
||||
running.sort(key=lambda a: -cand[a]["age"])
|
||||
|
||||
def human(s):
|
||||
if s < 90:
|
||||
return "%d 秒" % s
|
||||
if s < 5400:
|
||||
return "%d 分" % (s // 60)
|
||||
return "%.1f 小時" % (s / 3600.0)
|
||||
|
||||
rows = []
|
||||
for i, a in enumerate(running, 1):
|
||||
c = cand[a]
|
||||
# 🔴 只印名字/類型/時間,**不印 agentId**——那是 harness 明文說不要往外貼的內部值
|
||||
rows.append(" %d. %s(%s)|已跑 %s|最後一個動作 %s前"
|
||||
% (i, c["name"], c["type"], human(c["age"]), human(c["quiet"])))
|
||||
|
||||
print("BLOCK\t%d\t%d" % (len(running), cap))
|
||||
print("\n".join(rows))
|
||||
' 2>/dev/null) || {
|
||||
cat >&2 <<'EOM'
|
||||
⚠️ 並行閘(parallel-lines-cap-guard)自己出錯了,這一次**沒有把關**。
|
||||
放行是刻意的(算不出來就擋 = 整台機器派不了工),但它不該安靜地過去。
|
||||
請把這一行連同當時的動作記到 inkstone/ISEP#109。
|
||||
EOM
|
||||
exit 0
|
||||
}
|
||||
|
||||
[ -n "${OUT:-}" ] || exit 0
|
||||
|
||||
KIND=$(printf '%s' "$OUT" | head -1 | cut -f1)
|
||||
|
||||
if [ "$KIND" = "NOTE" ]; then
|
||||
MSG=$(printf '%s' "$OUT" | head -1 | cut -f2-)
|
||||
printf '⚠️ %s\n 放行是刻意的,但它不該安靜地過去(inkstone/ISEP#109)。\n' "$MSG" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
N=$(printf '%s' "$OUT" | head -1 | cut -f2)
|
||||
CAPN=$(printf '%s' "$OUT" | head -1 | cut -f3)
|
||||
ROWS=$(printf '%s' "$OUT" | tail -n +2)
|
||||
|
||||
# ── 逃生門:用掉就消失,留在指令歷史上 ────────────────────────────────
|
||||
ESC="/tmp/.parallel-ok-${CLAUDE_SESSION_ID:-}"
|
||||
if [ -z "${CLAUDE_SESSION_ID:-}" ]; then
|
||||
ESC=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print("/tmp/.parallel-ok-" + (json.load(sys.stdin).get("session_id") or "nosid"))
|
||||
except Exception: print("/tmp/.parallel-ok-nosid")
|
||||
' 2>/dev/null || echo /tmp/.parallel-ok-nosid)
|
||||
fi
|
||||
if [ -f "$ESC" ]; then
|
||||
rm -f "$ESC"
|
||||
printf '🔓 並行閘:現在有 %s 條線在跑(上限 %s),逃生門已用掉,這一次放行。\n' "$N" "$CAPN" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat >&2 <<EOF
|
||||
🚦 同時跑的線已經到上限:**現在有 $N 條在跑,上限 $CAPN**。
|
||||
|
||||
現在在跑的是這幾條:
|
||||
$ROWS
|
||||
|
||||
【上限從哪來】leo 2026-08-29 原話:「因為剛剛造成 Mac 重啓,它很少這樣⋯⋯
|
||||
**跑 2-3 條應該是極限了**」——取他自己講的上限 3,所以第 4 條才擋。
|
||||
這台機器:Mac14,2 / M2 / 記憶體 8 GB / Swap 已用 914 MB。
|
||||
|
||||
【它花掉過什麼(2026-08-29,這張閘的來由)】
|
||||
同時開七條線(arcrun-rag#163/#164/Arcrun#176/#180/#185/#187/InkStoneCo#104)
|
||||
⇒ leo 的 Mac **重新開機**。而且同一天:派錯線 1 次、量錯東西 6 次、
|
||||
停掉正確的工作 1 次(MISTAKE 134)。
|
||||
⇒ **平行度超過盯得住的範圍時,錯誤率跟著上去。這條閘同時在省機器與省判斷力。**
|
||||
|
||||
【出路,挑一條】
|
||||
① **等**——上面某一條收工,這條就派得出去(收工通知一進 transcript 就解鎖,不用等 15 分鐘)。
|
||||
同一個回合可以先做別的不需要開新線的事。
|
||||
② **這條更急** ⇒ 用 SendMessage 把某一條停下來/收掉,再派。
|
||||
③ **真的非同時不可** ⇒ 在終端機跑
|
||||
touch $ESC
|
||||
然後重送。用掉就消失,會留在指令歷史上。
|
||||
④ 上限本身要調 ⇒ ISEP_MAX_PARALLEL_LINES=<n>(安靜窗 ISEP_PARALLEL_IDLE_SEC,預設 900 秒)。
|
||||
|
||||
🔴 這道閘擋的是總管自己,**沒有總管例外**。
|
||||
EOF
|
||||
exit 2
|
||||
@@ -62,17 +62,12 @@ set -u
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
|
||||
# ── token:同 scripts/ticket 的做法(掃 remote 找帶憑證的,再退到環境變數)──
|
||||
# ── token:來源=env/.env(inkstone/ISEP#47),不再從 remote URL 抽 ──
|
||||
# (舊法掃各 repo 的 remote 找帶憑證的網址,代表 token 得明文嵌在 URL 裡 → 會洩進 log)
|
||||
TOKEN=""
|
||||
if [ -z "${PR_VERDICT_FIXTURE:-}" ]; then
|
||||
for d in "$PROJ" "$PROJ/products"/* "$PROJ/matrix"/* "$PROJ/polaris"/*; do
|
||||
[ -e "$d/.git" ] || continue
|
||||
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||
if [ -n "${U:-}" ]; then
|
||||
T=$(printf '%s' "$U" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
[ "$T" != "$U" ] && { TOKEN="$T"; break; }
|
||||
fi
|
||||
done
|
||||
_GT_LIB="$(dirname -- "$0")/../scripts/lib/gitea-token.sh"; [ -f "$_GT_LIB" ] && . "$_GT_LIB"
|
||||
command -v gitea_token >/dev/null 2>&1 && TOKEN=$(gitea_token) || TOKEN=""
|
||||
[ -n "$TOKEN" ] || TOKEN="${GITEA_TOKEN_CLAUDE_CODE:-${GITEA_TOKEN:-}}"
|
||||
fi
|
||||
|
||||
|
||||
@@ -227,6 +227,9 @@ fi
|
||||
case "$CMD" in
|
||||
*staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*|*"-staging"*) exit 0 ;;
|
||||
*youlin-hsieh-dev*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;;
|
||||
# 🔴 2026-09-02 youlin 重裝後 workers.dev 子網域換成 `arcrun-yuga3bse`(inkstone/ISEP#130):
|
||||
# 舊名 DNS 已不存在。認不得新名 ⇒ 打 stage 做實驗又會被當成 prod 擋——同一個誤攔第二次。
|
||||
*arcrun-yuga3bse*) exit 0 ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
# queue-drain-guard.sh — 佇列警察:**在接票的 session,池子還有票就不准收工**(inkstone/ISEP#33)
|
||||
#
|
||||
# ── 補的是哪一格 ────────────────────────────────────────────────
|
||||
# leo 2026-09-13(判定 ISEP 失敗):
|
||||
# 「不會在一條任務完成後啓動另一個 loop」「任務完成自己去拿下一個任務⋯⋯它完全做不到,一直停工」
|
||||
#
|
||||
# 官方文件(code.claude.com/docs/en/routines):Routine 每次觸發=**一個** cloud session,
|
||||
# 跑完就結束;session 裡沒有 approval prompt。⇒ 「做完一張接下一張」只可能有兩種來源:
|
||||
# ① 模型自己決定不停 ② Stop hook 以 exit 2 擋下收工(hooks 文件:「Prevents Claude from stopping」)
|
||||
# ISEP 的 12 支 Stop 閘**沒有一支看佇列**——它們量的是這回合有沒有 tool call、有沒有宣告下一步、
|
||||
# 連續幾個乾回合、清單檔還剩幾行(那個清單檔要模型自己寫)。所以「做完一張、pick 還有票、
|
||||
# 模型寫完回報就停」這個形狀,12 支全部放行。09-10/09-11/09-12 三趟 cloud-worker run
|
||||
# 都是這樣結束的(arcrun-rag#138 c6913、Arcrun#86 c6917、Arcrun#210 c6920)。
|
||||
#
|
||||
# ── 判準:狀態,不是措辭 ────────────────────────────────────────
|
||||
# 啟動條件(任一):
|
||||
# · 這個 session 真的**執行過** `ticket pick|claim|handback`(Bash tool_use,文字裡提到不算)
|
||||
# ⇒ 它在當工人。總管跟 leo 聊天的 session 不會被這支咬。
|
||||
# · 環境變數 ISEP_DRAIN=1(雲端環境可設,讓第一回合就生效)
|
||||
# 量什麼:跑一次 `ticket pick --json`(跟工人用的是同一支、同一套規則,不另立判準)
|
||||
# 離開碼 0 = 有票 ⇒ 擋,把那張票號與認領指令交給它
|
||||
# 離開碼 1 = 真的抓盡 ⇒ 放行(這是正常收工)
|
||||
# 其他 = 讀不到 ⇒ 擋兩次叫它重跑 pick(讀不到 ≠ 沒票),第三次放行並留痕
|
||||
#
|
||||
# ── 為什麼不理 stop_hook_active ─────────────────────────────────
|
||||
# 別的 Stop 閘擋過一次就讓路,正是「擋一下、寫一段、照樣停」的來源。本閘改用兩道**有限**的保險:
|
||||
# · 同一張票連續被指名 3 次仍沒被認領 ⇒ 放行(它可能真的做不了,該把理由寫回票、改欄位)
|
||||
# · 一個 session 最多擋 40 次
|
||||
# ⇒ 不可能鎖死,而且鎖不住的那一格會留在 log 裡。
|
||||
#
|
||||
# ── log 寫在哪 ──────────────────────────────────────────────────
|
||||
# 不用 $CLAUDE_PROJECT_DIR:雲端沒有它,21 支閘的 log 因此靜默寫進 /.claude/hooks/(ISEP#130 c6873)。
|
||||
# 狀態與 log 一律寫 ${ISEP_DRAIN_STATE_DIR:-${TMPDIR:-/tmp}},那裡在哪台都寫得進去。
|
||||
#
|
||||
# 「池子」多大由 pick 決定:環境 ISEP_DRAIN=1 時 pick 會往下看到整個 open 票池(inkstone/ISEP#130);
|
||||
# 本機沒設時只看主線與逾期——總管跑過 handback 不會因此被叫去抓票池。
|
||||
#
|
||||
# 迴歸測試:hooks/tests/queue-drain-guard.test.sh
|
||||
set -u
|
||||
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
STATE_DIR="${ISEP_DRAIN_STATE_DIR:-${TMPDIR:-/tmp}}"
|
||||
STATE_DIR="${STATE_DIR%/}"
|
||||
LOG="$STATE_DIR/queue-drain-guard.log"
|
||||
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
|
||||
note() { printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOG" 2>/dev/null || true; }
|
||||
|
||||
# ① 這個 session 在不在接票?(只看真的執行過的 Bash 指令)
|
||||
INFO=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import json, os, re, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP:bad-payload"); raise SystemExit
|
||||
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", d.get("session_id") or "nosid")[:64]
|
||||
if os.environ.get("ISEP_DRAIN") == "1":
|
||||
print("ON:" + sid); raise SystemExit
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.exists(tp):
|
||||
print("SKIP:no-transcript:" + sid); raise SystemExit
|
||||
pat = re.compile(r"(^|[\s/\x22\x27])ticket\x22?\s+(pick|claim|handback)\b")
|
||||
try:
|
||||
with open(tp) as f:
|
||||
for line in f:
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if r.get("type") != "assistant":
|
||||
continue
|
||||
for b in (r.get("message") or {}).get("content") or []:
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("name") == "Bash":
|
||||
cmd = (b.get("input") or {}).get("command") or ""
|
||||
if isinstance(cmd, str) and pat.search(cmd):
|
||||
print("ON:" + sid); raise SystemExit
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
print("SKIP:unreadable:" + sid); raise SystemExit
|
||||
print("OFF:" + sid)
|
||||
' 2>/dev/null) || INFO="SKIP:crash"
|
||||
|
||||
case "$INFO" in
|
||||
ON:*) SID="${INFO#ON:}" ;;
|
||||
OFF:*) exit 0 ;;
|
||||
*) note "⚪ 略過" "$INFO"; exit 0 ;;
|
||||
esac
|
||||
|
||||
# ② 問佇列(同一支 pick、同一套規則),**有時限**
|
||||
# 2026-09-13 本機實量:Gitea 同一分鐘 curl 60s 逾時、urllib 17s/0.3s/42s 握手逾時,
|
||||
# 整趟 `pick --all` 跑了約 15 分鐘。hooks.json 給本閘 180s;閘被 Claude Code 砍掉=非阻擋錯誤=放行收工,
|
||||
# 正好又回到「讀不到就停」。⇒ 自己在 150s 收手,逾時當成「讀不到」(擋,叫它重跑 pick)。
|
||||
if [ -n "${ISEP_DRAIN_PICK_CMD:-}" ]; then
|
||||
PICK_ARGV="sh -c \"\$ISEP_DRAIN_PICK_CMD\""
|
||||
else
|
||||
PICK_ARGV="python3 \"$PLUGIN_ROOT/scripts/ticket\" pick --json"
|
||||
fi
|
||||
OUT=$(PICK_ARGV="$PICK_ARGV" python3 -c '
|
||||
import os, subprocess, sys
|
||||
try:
|
||||
lim = float(os.environ.get("ISEP_DRAIN_PICK_TIMEOUT", "150"))
|
||||
except ValueError:
|
||||
lim = 150.0
|
||||
try:
|
||||
p = subprocess.run(["sh", "-c", os.environ["PICK_ARGV"]], capture_output=True, text=True, timeout=lim)
|
||||
sys.stdout.write(p.stdout); sys.exit(p.returncode)
|
||||
except subprocess.TimeoutExpired:
|
||||
sys.exit(124)
|
||||
' 2>/dev/null); RC=$?
|
||||
|
||||
STATE="$STATE_DIR/.queue-drain-$SID.json"
|
||||
VERDICT=$(OUT="$OUT" RC="$RC" STATE="$STATE" python3 -c '
|
||||
import json, os
|
||||
rc = int(os.environ["RC"]); out = os.environ.get("OUT", ""); path = os.environ["STATE"]
|
||||
try:
|
||||
st = json.load(open(path))
|
||||
except Exception:
|
||||
st = {}
|
||||
blocks = int(st.get("blocks", 0)); same = int(st.get("same", 0))
|
||||
last = st.get("last_ref"); miss = int(st.get("unreachable", 0))
|
||||
MAX_BLOCKS, MAX_SAME, MAX_MISS = 40, 3, 2
|
||||
def save(v):
|
||||
st.update(blocks=blocks, same=same, last_ref=last, unreachable=miss)
|
||||
try:
|
||||
json.dump(st, open(path, "w"))
|
||||
except Exception:
|
||||
pass
|
||||
print(v); raise SystemExit
|
||||
if rc == 1:
|
||||
same, last, miss = 0, None, 0
|
||||
save("ALLOW:queue-empty")
|
||||
if rc != 0:
|
||||
miss += 1
|
||||
if miss > MAX_MISS:
|
||||
save("ALLOW:unreachable-%d" % miss)
|
||||
blocks += 1
|
||||
save("BLOCK_UNREACHABLE:%d" % miss)
|
||||
miss = 0
|
||||
try:
|
||||
row = json.loads(out)
|
||||
ref, title = row.get("ref"), (row.get("title") or "")[:80]
|
||||
nxt = (row.get("next") or "").replace("\t", " ").replace("\n", " ")
|
||||
except Exception:
|
||||
save("ALLOW:unparsable")
|
||||
if not ref:
|
||||
save("ALLOW:no-ref")
|
||||
same = same + 1 if ref == last else 1
|
||||
last = ref
|
||||
if same > MAX_SAME:
|
||||
save("ALLOW:stuck-on:%s" % ref)
|
||||
if blocks >= MAX_BLOCKS:
|
||||
save("ALLOW:max-blocks")
|
||||
blocks += 1
|
||||
save("BLOCK:%s\t%s\t%d\t%s" % (ref, title, same, nxt))
|
||||
' 2>/dev/null) || VERDICT="ALLOW:crash"
|
||||
|
||||
case "$VERDICT" in
|
||||
BLOCK:*)
|
||||
body="${VERDICT#BLOCK:}"
|
||||
REF=$(printf '%s' "$body" | cut -f1); TITLE=$(printf '%s' "$body" | cut -f2); N=$(printf '%s' "$body" | cut -f3); NEXT=$(printf '%s' "$body" | cut -f4)
|
||||
# pick 的每一列自帶下一步(inkstone/ISEP#159):待分診的票下一步是 triage 不是 claim——照它印,不自己猜
|
||||
[ -z "$NEXT" ] && NEXT="python3 \"$PLUGIN_ROOT/scripts/ticket\" claim $REF --name <你的名字>"
|
||||
note "⛔ 擋下" "池子還有 $REF(第 $N 次指名)"
|
||||
cat >&2 <<MSG
|
||||
🔁 佇列警察:**這個 session 在接票,而池子裡還有一張現在就能抓的票——還不是收工的時候。**
|
||||
|
||||
下一張:$REF $TITLE
|
||||
下一步:$NEXT
|
||||
|
||||
leo 2026-09-13:「任務完成自己去拿下一個任務,幫我消化任務」。
|
||||
回報寫在票上就好,**不必在這裡寫收工摘要**——摘要等 pick 抓盡再寫一次。
|
||||
|
||||
這張其實做不了(要 leo 的手/要 Mac/前提不在)?
|
||||
不要停在這裡:把「為什麼」寫回那張票,並改掉讓它可抓的欄位(s/* 或 Human),
|
||||
pick 就不會再回它,接著抓下一張。四題人閘命中的票,也是這樣留在票上,**不是停 session**。
|
||||
(同一張連續指名 3 次仍沒動,本閘會放行並記進 log——那張需要總管看。)
|
||||
MSG
|
||||
exit 2 ;;
|
||||
BLOCK_UNREACHABLE:*)
|
||||
note "⛔ 擋下" "pick 讀不到佇列(第 ${VERDICT#BLOCK_UNREACHABLE:} 次)"
|
||||
cat >&2 <<MSG
|
||||
🔁 佇列警察:**讀不到佇列 ≠ 佇列是空的。**
|
||||
|
||||
\`ticket pick\` 這次沒拿到 Gitea(連線或主線檔)。重跑一次:
|
||||
python3 "$PLUGIN_ROOT/scripts/ticket" pick
|
||||
抓得到就認領;真的抓盡(離開碼 1)就可以收工。連續讀不到 3 次,本閘放行並留痕。
|
||||
MSG
|
||||
exit 2 ;;
|
||||
*)
|
||||
note "✅ 放行" "$VERDICT"
|
||||
exit 0 ;;
|
||||
esac
|
||||
@@ -1,234 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 管什麼: Write/Edit 動 code 檔(.ts/.py/.go…)前,要不要有對應的一份 status: active SDD(design.md)。
|
||||
# 為什麼: SDD 生命週期鐵律——動 code 前必須有規格可對,且整個 repo 同一時刻只准一份 active。
|
||||
# 把「動手前先讀 SDD」從只能靠人記,升級成機器擋(system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
|
||||
# 誤觸時怎麼關: 改文件/測試檔/3-specs 自己一律放行(下方 case 已排除);不在任何 git repo
|
||||
# 裡的路徑(scratchpad、/tmp 暫存檔)一律放行,SDD 管不到它們。真的要臨時豁免
|
||||
# 一次小改動,說明範圍後由人手動放行——這支閘不設「一行關掉」的旗標。
|
||||
#
|
||||
# PreToolUse hook — 動 code 前檢查 SDD + 單一活性 SDD 鐵律(issue #6)
|
||||
# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。
|
||||
# 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md
|
||||
#
|
||||
# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。
|
||||
# stdin 收到 JSON:{ tool_name, tool_input: { file_path, ... } }
|
||||
# 行為:
|
||||
# 1. status: active 的 SDD > 1 份 → 單一活性鐵律已被違反,**不論寫什麼檔**一律擋(exit 2),
|
||||
# 先收斂到一份再說。
|
||||
# 2. 動 code 檔(.ts/.go/...)→ 需要「恰好 1 份」active SDD;0 份 → 擋。
|
||||
# 3. 向下相容:3-specs 下完全沒有任何 design.md 帶 frontmatter(老 repo 尚未遷移生命週期制度)
|
||||
# → 退回舊行為:有 design.md 就放行+提醒,沒有才擋。避免 template update 後老 repo 立刻全紅。
|
||||
#
|
||||
# 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。
|
||||
# 藏在 helper 裡、用 bash 繞道的改動擋不到。
|
||||
# 價值是「想跳過會被抓到 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh"
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# 解析 file_path。優先用 jq,沒有 jq 退回 grep(容錯)。
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
||||
fi
|
||||
|
||||
# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺)
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
# 🔴 2026-08-02 修:原本寫死相對路徑 `system-dev/docs/3-specs`,
|
||||
# 但 hook 的工作目錄是**頂層 InkStoneCo**,改子 repo 的 code 時就去頂層找 SDD
|
||||
# ⇒ 看不到子 repo 自己那份 ⇒ **一律誤報「找不到任何 SDD」**。
|
||||
# 實撞:改 products/arcrun-rag/collector/... 被擋,但該 repo 明明有
|
||||
# system-dev/docs/3-specs/daemon-beta/design.md(status: active)。
|
||||
# ⇒ 改成從被改檔案往上找最近的 system-dev/docs/3-specs(子 repo 優先,找不到才用頂層)。
|
||||
# ⚠️ 只往上找到「頂層 InkStoneCo」為止——不可讓任意路徑(如 /private/tmp/…)
|
||||
# 退回頂層 SDD 而被放行,那會把原本擋得住的情況變成擋不住。
|
||||
#
|
||||
# 🔴 2026-08-20 修(inkstone/InkStoneCo#22):上面這套邏輯有兩個洞,都是總管 08-12 實撞的:
|
||||
#
|
||||
# 洞 A — scratchpad 暫存檔被當成「code 變動」:
|
||||
# `/private/tmp/.../scratchpad/foo.py` 不在 `$_root` 底下、往上找不到 3-specs,
|
||||
# 於是走到「找不到 SDD」擋下路徑——但 scratchpad 是 session 專用暫存區,從不進版控,
|
||||
# SDD 管的是 repo 裡的產品程式碼,管不到它。**先問「這條路徑到底在不在某個 git repo
|
||||
# 裡」(`path_in_git_worktree`,見 lib/path-resolve.sh),不在 ⇒ 這道閘天生管不到
|
||||
# ⇒ 直接放行**,不必先繞去猜專案根、再證明找不到才擋。
|
||||
# 用「有沒有 .git 可尋」判斷,比列舉路徑關鍵字(/tmp、scratchpad…)更穩:
|
||||
# 不必窮舉每一種暫存區的命名法,任何真的不在 repo 裡的路徑都一視同仁。
|
||||
#
|
||||
# 洞 B — 訊息裡印出字面的 `/nonexistent`:
|
||||
# 舊版用 `/nonexistent/3-specs` 當內部 sentinel,讓「找不到 SDD」的既有擋下路徑可以
|
||||
# 重用;但這個 sentinel 值被直接印進使用者看到的訊息,讀起來像是「這支腳本認真去
|
||||
# /nonexistent 這個地方找過」——具體、卻是假的。改成用 RESOLVED 旗標記「解析成不成功」,
|
||||
# 擋下訊息另外用人話描述「為什麼找不到」,不洩漏內部實作用的假路徑。
|
||||
#
|
||||
# ⚠️ 洞 A/B 都不改變「真的解析失敗時」的判定方向:路徑確實落在某個 git repo 裡,
|
||||
# 但那個 repo 沒有 3-specs(或裡面沒有 active SDD)→ 仍然 **fail-closed**(擋,不放行)。
|
||||
# 為什麼是 fail-closed、不是 fail-open:這道閘存在的目的就是防止「沒有 SDD 卻能動
|
||||
# code」,若把「判斷不出來」直接放行,等於把一次環境跑歪(cwd 被切走、
|
||||
# `$CLAUDE_PROJECT_DIR` 沒設、worktree 缺 3-specs…)悄悄變成「這道閘關掉了,而且沒有
|
||||
# 任何人被告知」——silent bypass 的代價遠高於「多打一次確認」。#22 的紅線也明寫
|
||||
# 「不要把閘改成『解析失敗就放行』——那是把誤判換成漏判」。
|
||||
# 洞 A 的修法,套用在 case 分岔**之前**:不管 `$_root` 猜不猜得對,
|
||||
# 先問「這條路徑到底在不在某個 git repo 裡」。不在 ⇒ SDD 這道閘天生管不到,直接放行。
|
||||
# 🔴 這個檢查故意放在 `$FILE_PATH` 是否落在 `$_root` 底下的判斷之前、且對兩邊都適用
|
||||
# (不是只套用在「專案外」那個分支):第一版只把它放進「專案外」分支,結果測試
|
||||
# (hooks/tests/sdd-guard.test.sh)就抓到一個不對稱漏洞——當 `$_root` 剛好等於
|
||||
# scratchpad 的某層祖先目錄(例如 hook 被叫用時 cwd 已經跑到 /private/tmp 底下、
|
||||
# `$CLAUDE_PROJECT_DIR` 也沒設),scratchpad 路徑會被判成「在 `$_root` 底下」而
|
||||
# 走進另一條完全沒做 git-repo 檢查的路徑,同一個誤判换個路徑重新出現。
|
||||
# 改成「先問是不是在 git repo 裡,不管路徑跟 `$_root` 的關係」就沒有這個不對稱。
|
||||
if ! path_in_git_worktree "$FILE_PATH"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
_root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
# 預設值一律絕對路徑(不留相對路徑「system-dev/docs/3-specs」退回目前 cwd 的洞——
|
||||
# 舊版這裡曾經是相對路徑,若專案內迴圈找不到就會被拿去跟 hook 執行當下的 cwd 兜,
|
||||
# cwd 湊巧有同名目錄就會判斷到不相干的資料)。
|
||||
SPECS_DIR="$_root/system-dev/docs/3-specs"
|
||||
RESOLVED=1 # 1=SPECS_DIR 是有意義的答案;0=真的解析失敗,SPECS_DIR 留空,訊息另外講原因
|
||||
|
||||
case "$FILE_PATH" in
|
||||
"$_root"/*)
|
||||
_d=$(dirname "$FILE_PATH")
|
||||
while [ "$_d" != "/" ] && [ -n "$_d" ]; do
|
||||
if [ -d "$_d/system-dev/docs/3-specs" ]; then
|
||||
SPECS_DIR="$_d/system-dev/docs/3-specs"
|
||||
break
|
||||
fi
|
||||
[ "$_d" = "$_root" ] && break
|
||||
_d=$(dirname "$_d")
|
||||
done
|
||||
;;
|
||||
*)
|
||||
# 專案外的路徑:`$_root` 猜錯,或這條路徑本來就不屬於目前的 `$_root`。
|
||||
# 已知落在某個 git repo 裡(上面剛確認過):往上找它自己的 3-specs。
|
||||
# **不可退回 `$_root` 的 3-specs 就放行**——那會把「這個 repo 沒有 SDD」
|
||||
# 誤判成「用別的 repo 的 SDD 蒙混過關」,原本擋得住的會變成擋不住。
|
||||
SPECS_DIR=""
|
||||
RESOLVED=0
|
||||
_d=$(dirname "$FILE_PATH")
|
||||
while [ "$_d" != "/" ] && [ -n "$_d" ]; do
|
||||
if [ -d "$_d/system-dev/docs/3-specs" ]; then
|
||||
SPECS_DIR="$_d/system-dev/docs/3-specs"
|
||||
RESOLVED=1
|
||||
break
|
||||
fi
|
||||
_d=$(dirname "$_d")
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
# 給訊息用的人話描述:解析成功就印真路徑,失敗就誠實講「為什麼」,不印假路徑
|
||||
# (洞 B 的修法——舊版這裡印的是內部 sentinel `/nonexistent/3-specs`)。
|
||||
if [ "$RESOLVED" -eq 1 ]; then
|
||||
SPECS_DIR_DESC="${SPECS_DIR}/"
|
||||
SPECS_NOT_FOUND_MSG="${SPECS_DIR}/ 下找不到任何 SDD"
|
||||
SPECS_NOT_ACTIVE_MSG="${SPECS_DIR}/ 下沒有任何 status: active 的 SDD"
|
||||
else
|
||||
SPECS_DIR_DESC=""
|
||||
SPECS_NOT_FOUND_MSG="這條路徑所在的 git repo 裡找不到 system-dev/docs/3-specs,也就沒有任何 SDD 可對(或這支閘沒能定位到正確的專案根——這是 fail-closed:寧可誤擋也不悄悄放行,見檔頭註解)"
|
||||
SPECS_NOT_ACTIVE_MSG="$SPECS_NOT_FOUND_MSG"
|
||||
fi
|
||||
|
||||
# ── 統計 active / frontmatter ──────────────────────
|
||||
# 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數——
|
||||
# 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。
|
||||
# frontmatter 判定=design.md 前 10 行有 ^status: 行(機器可查,見 SDD-LIFECYCLE.md)。
|
||||
ACTIVE_COUNT=0
|
||||
FM_COUNT=0
|
||||
ACTIVE_LIST=""
|
||||
if [ -n "$SPECS_DIR" ] && [ -d "$SPECS_DIR" ]; then
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
HEAD10=$(head -10 "$f" 2>/dev/null || true)
|
||||
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*'; then
|
||||
FM_COUNT=$((FM_COUNT + 1))
|
||||
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*active'; then
|
||||
ACTIVE_COUNT=$((ACTIVE_COUNT + 1))
|
||||
ACTIVE_LIST="${ACTIVE_LIST} • ${f}
|
||||
"
|
||||
fi
|
||||
fi
|
||||
done < <(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null)
|
||||
fi
|
||||
|
||||
# ── 鐵律 1:單一活性被違反(active > 1)→ 不論寫什麼檔一律擋 ──
|
||||
if [ "$ACTIVE_COUNT" -gt 1 ]; then
|
||||
cat >&2 <<EOF
|
||||
🚫 SDD 單一活性鐵律違反:偵測到 ${ACTIVE_COUNT} 份 status: active 的 SDD(任何時刻整個 repo 最多一份):
|
||||
${ACTIVE_LIST}
|
||||
請先收斂到一份:其餘改 status: paused / closed(closed 且被取代者填 superseded_by 並移入 3-specs/archive/)。
|
||||
規則全文見 system-dev/docs/3-specs/SDD-LIFECYCLE.md。收斂前擋下所有寫檔。
|
||||
(本 hook 攔 Write/Edit;修 frontmatter 可用 bash 直改,或由人裁決哪份是現行。)
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 只管 code 檔。docs/markdown/設定檔等放行。
|
||||
case "$FILE_PATH" in
|
||||
*.ts|*.tsx|*.js|*.jsx|*.go|*.py|*.rs|*.java|*.rb|*.php|*.c|*.cpp|*.h|*.hpp|*.swift|*.kt) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 改 SDD 自己 / 測試檔 → 放行
|
||||
case "$FILE_PATH" in
|
||||
*system-dev/docs/3-specs/*) exit 0 ;;
|
||||
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# ── 向下相容:整個 3-specs 沒有任何帶 frontmatter 的 design.md ──
|
||||
# =老 repo 還沒遷移生命週期制度 → 退回舊行為(有 design.md 就放行+提醒),
|
||||
# 避免 template update 一裝新 hook,老 repo 所有 code 寫入立刻全紅。
|
||||
if [ "$FM_COUNT" -eq 0 ]; then
|
||||
SDD_COUNT=0
|
||||
if [ -n "$SPECS_DIR" ] && [ -d "$SPECS_DIR" ]; then
|
||||
SDD_COUNT=$(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
if [ "$SDD_COUNT" -eq 0 ]; then
|
||||
cat >&2 <<EOF
|
||||
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_NOT_FOUND_MSG}。
|
||||
|
||||
絕對鐵律:任何 code 變動前必須有對應 SDD(design.md),且遵守單一活性生命週期
|
||||
(system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
|
||||
|
||||
請先:
|
||||
1. 確認這個改動屬於哪個子系統
|
||||
2. 在 [子系統的] system-dev/docs/3-specs/[子系統]/ 建立 design.md(可用 /sdd-check 協助),frontmatter 標 status: active
|
||||
3. 在回覆開頭宣告已讀 SDD + 對應 task
|
||||
|
||||
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 舊行為放行 + 提醒遷移(stderr 警告,不擋)
|
||||
echo "📋 提醒:${SPECS_DIR_DESC} 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 新行為:寫 code 檔需「恰好 1 份」active SDD ──
|
||||
if [ "$ACTIVE_COUNT" -eq 0 ]; then
|
||||
cat >&2 <<EOF
|
||||
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_NOT_ACTIVE_MSG}。
|
||||
|
||||
單一活性鐵律:所有開發任務唯一對應源=那份 active SDD(規則見 system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
|
||||
|
||||
請先(擇一,都是人的決定,CC 不得自行建 SDD):
|
||||
1. 把現行規格的 design.md frontmatter 標成 status: active(一份、只能一份)
|
||||
2. 或依 SDD-LIFECYCLE.md 第 3、4 條:proposal 進 pending-changes.md → 使用者 confirm → 開新 SDD 標 active
|
||||
然後在回覆開頭宣告已讀 active SDD + 對應 task。
|
||||
|
||||
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 恰好 1 份 active:放行,留痕提醒要宣告(stderr 警告,不擋)
|
||||
printf '📋 提醒:現行 active SDD=\n%s動手前請確認已讀它的 design.md、對應到 tasks,並在回覆宣告。\n' "$ACTIVE_LIST" >&2
|
||||
exit 0
|
||||
@@ -15,6 +15,7 @@ HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/countdown-guard.sh}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
|
||||
export ISEP_MAINLINE_REPO_FILE=none # 把 repo 那份鏡像拿掉(inkstone/ISEP#130):這支測的是家目錄那份
|
||||
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# gitea-arm-to-github-armed.sh 的迴歸測試——**保險寫在哪,讀的人就要在哪讀得到**。
|
||||
#
|
||||
# 🔴 它在守什麼(inkstone/ISEP#156,2026-09-17 實撞,inkstone/arcrun-rag#202):
|
||||
# ISEP 以 plugin 形式裝在 `~/.claude/plugins/cache/inkstone/isep/<ver>/`,
|
||||
# 舊版用 `ROOT=$(dirname $0)/..` 決定 `.github-armed` 寫在哪
|
||||
# ⇒ 保險被寫進 **plugin 快取資料夾**;而出貨線(ship.mjs → d20-guard.mjs)
|
||||
# 與每一支讀它的閘(main-and-prod-push-guard/github-contact-guard/stage-before-prod-guard)
|
||||
# 讀的都是**專案根**(`${CLAUDE_PROJECT_DIR:-$(pwd)}`)
|
||||
# ⇒ leo 在票上回了碼、腳本印「✅ 保險已解」,出貨照樣卡住。
|
||||
# **比單純失敗更糟:它對 leo 說謊。**
|
||||
#
|
||||
# 判準是「要求某個東西在場」:保險檔必須出現在讀的人會讀的那個位置,
|
||||
# 並且**真的被那些讀的人認得**(不是只驗檔案存在)。
|
||||
#
|
||||
# 全程離線:`gitea-arm-check.sh` 的核准本身(Leo 真的回覆)必須打真的 Gitea,
|
||||
# 那部分由 gitea-arm-check.test.sh 與 inkstone/InkStoneCo#34 的人工實測負責。
|
||||
# 這裡把「核准成功」換成 stub,**只驗核准之後保險落在哪、被不被認得**;
|
||||
# fail-closed 那條則用**真的** check(無待核請求 → 本地就擋,不碰網路)。
|
||||
#
|
||||
# 用法:bash hooks/tests/gitea-arm-to-github-armed.test.sh .
|
||||
set -u
|
||||
REPO="$(cd "${1:?用法: $0 <ISEP repo 根目錄>}" && pwd)"
|
||||
SRC="$REPO/scripts/gitea-arm-to-github-armed.sh"
|
||||
[ -f "$SRC" ] || { echo "❌ 找不到 $SRC(參數要傳 ISEP repo 根目錄)"; exit 1; }
|
||||
|
||||
T=$(mktemp -d)
|
||||
trap '[ -n "${KEEP:-}" ] && echo "保留 $T" || rm -rf "$T"' EXIT
|
||||
|
||||
# ── 造一個長得像 plugin 快取的安裝位置,與一個長得像 InkStoneCo 的專案根 ──
|
||||
PLUG="$T/.claude/plugins/cache/inkstone/isep/9.9.9"
|
||||
PROJ="$T/InkStoneCo"
|
||||
mkdir -p "$PLUG/scripts/lib" "$PROJ" "$T/elsewhere"
|
||||
cp "$SRC" "$PLUG/scripts/"
|
||||
cp "$REPO/scripts/lib/gitea-arm-common.sh" "$PLUG/scripts/lib/"
|
||||
cp "$REPO/scripts/gitea-arm-request.sh" "$PLUG/scripts/" 2>/dev/null
|
||||
# stub:扮演「leo 已在票上核准」——輸出格式照 gitea-arm-check.sh 成功時的 `ARMED: <mission>`
|
||||
cat > "$PLUG/scripts/gitea-arm-check.sh" <<'STUB'
|
||||
#!/bin/sh
|
||||
[ "${1:-}" = "--consume" ] || { echo "stub: 應該帶 --consume" >&2; exit 1; }
|
||||
echo "ARMED: 測試出貨 ISEP#156"
|
||||
STUB
|
||||
chmod +x "$PLUG/scripts/"*.sh
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok(){ echo " ✅ $1"; PASS=$((PASS+1)); }
|
||||
ng(){ echo " ❌ $1"; FAIL=$((FAIL+1)); }
|
||||
chk(){ if eval "$2"; then ok "$1"; else ng "$1"; fi; }
|
||||
|
||||
echo "── ① 從 plugin 路徑執行,CLAUDE_PROJECT_DIR 指向專案根(總管 session 的實際形狀)──"
|
||||
( cd "$T/elsewhere" && CLAUDE_PROJECT_DIR="$PROJ" sh "$PLUG/scripts/gitea-arm-to-github-armed.sh" ) >"$T/out1" 2>&1
|
||||
rc=$?
|
||||
chk "離開碼 0" '[ $rc -eq 0 ]'
|
||||
chk "★ 專案根出現 .github-armed" '[ -f "$PROJ/.github-armed" ]'
|
||||
chk "★ plugin 快取資料夾裡沒有 .github-armed(09-17 實撞的位置)" '[ ! -e "$PLUG/.github-armed" ]'
|
||||
chk "cwd 也沒有被寫(不是靠碰巧站對位置)" '[ ! -e "$T/elsewhere/.github-armed" ]'
|
||||
chk "印出的「提前上保險」路徑就是真正寫下的那份" 'grep -qF "rm $PROJ/.github-armed" "$T/out1"'
|
||||
chk "第 1 行是未來的到期 epoch" '[ "$(sed -n 1p "$PROJ/.github-armed")" -gt "$(date +%s)" ]'
|
||||
chk "第 2 行帶任務說明" '[ "$(sed -n 2p "$PROJ/.github-armed")" = "測試出貨 ISEP#156" ]'
|
||||
|
||||
echo "── ② 讀的人真的認得這份保險(不是只驗檔案存在)──"
|
||||
# ②a 出貨線:ship.mjs 的 prod --confirm preflight 呼叫的就是 d20-guard.mjs 的 checkArmed(INKSTONE_ROOT)
|
||||
D20=""
|
||||
for c in "${INKSTONE_ROOT:-}" "$REPO/../InkStoneCo" "$REPO/../../InkStoneCo" "$HOME/Documents/tech_projects/InkStoneCo"; do
|
||||
[ -n "$c" ] && [ -f "$c/products/arcrun-rag/installer/scripts/d20-guard.mjs" ] \
|
||||
&& { D20="$(cd "$c" && pwd)/products/arcrun-rag/installer/scripts/d20-guard.mjs"; break; }
|
||||
done
|
||||
if [ -n "$D20" ] && command -v node >/dev/null 2>&1; then
|
||||
node --input-type=module -e "
|
||||
const { checkArmed } = await import(process.argv[1]);
|
||||
const r = checkArmed(process.argv[2]);
|
||||
console.log('checkArmed OK mission=' + r.mission);
|
||||
" "$D20" "$PROJ" >"$T/out2" 2>&1
|
||||
rc=$?
|
||||
chk "★ ship.mjs 的 checkArmed(專案根) 認得($(head -1 "$T/out2"))" '[ $rc -eq 0 ]'
|
||||
node --input-type=module -e "
|
||||
const { checkArmed } = await import(process.argv[1]);
|
||||
checkArmed(process.argv[2]);
|
||||
" "$D20" "$PLUG" >/dev/null 2>&1
|
||||
rc=$?
|
||||
chk "對照:checkArmed(plugin 快取) 讀不到 ⇒ 上一條有鑑別力" '[ $rc -ne 0 ]'
|
||||
else
|
||||
echo " ⏭️ SKIP 找不到 arcrun-rag 的 d20-guard.mjs 或 node(不算失敗,也不算驗過)"
|
||||
fi
|
||||
|
||||
# ②b 閘:同一個位置(`${CLAUDE_PROJECT_DIR:-$(pwd)}/.github-armed`)
|
||||
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]},'cwd':sys.argv[2]}))" "$1" "$T/elsewhere"; }
|
||||
gate(){ # gate <hook> <cmd> <CLAUDE_PROJECT_DIR> → 回 hook 離開碼
|
||||
mk "$2" | ( cd "$T/elsewhere" && env -u CLAUDE_CODE_CHILD_SESSION CLAUDE_PROJECT_DIR="$3" bash "$1" ) >/dev/null 2>&1
|
||||
}
|
||||
# 閘會寫 github-contact-log/狀態目錄——一律寫進暫存的專案根,不碰真的 InkStoneCo
|
||||
mkdir -p "$PROJ/system-dev/docs/3-specs/autonomy-dispatch" "$T/empty/system-dev/docs/3-specs/autonomy-dispatch"
|
||||
WR="npx wrangler deploy"
|
||||
GH="git push https://github.com/example/example.git main"
|
||||
gate "$REPO/hooks/main-and-prod-push-guard.sh" "$WR" "$PROJ"; rc=$?
|
||||
chk "★ main-and-prod-push-guard:保險在專案根 ⇒ wrangler deploy 放行" '[ $rc -ne 2 ]'
|
||||
gate "$REPO/hooks/main-and-prod-push-guard.sh" "$WR" "$T/empty"; rc=$?
|
||||
chk "對照:沒有保險 ⇒ 同一條被擋" '[ $rc -eq 2 ]'
|
||||
gate "$REPO/hooks/github-contact-guard.sh" "$GH" "$PROJ"; rc=$?
|
||||
chk "★ github-contact-guard:保險在專案根 ⇒ push github 放行" '[ $rc -ne 2 ]'
|
||||
gate "$REPO/hooks/github-contact-guard.sh" "$GH" "$T/empty"; rc=$?
|
||||
chk "對照:沒有保險 ⇒ 同一條被擋" '[ $rc -eq 2 ]'
|
||||
|
||||
echo "── ③ 沒設 CLAUDE_PROJECT_DIR(leo 在終端機)⇒ 跟閘一樣退回 cwd ──"
|
||||
rm -f "$PROJ/.github-armed"
|
||||
( cd "$PROJ" && env -u CLAUDE_PROJECT_DIR sh "$PLUG/scripts/gitea-arm-to-github-armed.sh" ) >"$T/out3" 2>&1
|
||||
rc=$?
|
||||
chk "離開碼 0" '[ $rc -eq 0 ]'
|
||||
chk "寫在 cwd(專案根),不在 plugin 快取" '[ -f "$PROJ/.github-armed" ] && [ ! -e "$PLUG/.github-armed" ]'
|
||||
|
||||
echo "── ④ fail-closed:沒拿到核准就一個檔都不寫(用真的 check,不碰網路)──"
|
||||
rm -f "$PROJ/.github-armed" "$PLUG/.github-armed"
|
||||
cp "$REPO/scripts/gitea-arm-check.sh" "$PLUG/scripts/gitea-arm-check.sh"
|
||||
printf 'GITEA_TOKEN_CLAUDE_CODE=test-token-not-real\n' > "$PROJ/.env"
|
||||
( cd "$T/elsewhere" && CLAUDE_PROJECT_DIR="$PROJ" sh "$PLUG/scripts/gitea-arm-to-github-armed.sh" ) >"$T/out4" 2>&1
|
||||
rc=$?
|
||||
chk "沒有待核請求 ⇒ 離開碼非 0" '[ $rc -ne 0 ]'
|
||||
chk "專案根與 plugin 快取都沒有 .github-armed" '[ ! -e "$PROJ/.github-armed" ] && [ ! -e "$PLUG/.github-armed" ]'
|
||||
# 出路要走得通:訊息印的那支 request 腳本必須是真的存在的絕對路徑
|
||||
# (相對的 `scripts/…` 會對貼上那個人的 cwd 解析,站在 InkStoneCo 會叫到舊複本——ISEP#112 同一個病)
|
||||
# 取「本檔自己印的」那一行(⛔ 之後);中文冒號不是空白,所以用「⛔ 之後第一個以 / 開頭的 token」
|
||||
REQ=$(sed -n '/⛔/,$p' "$T/out4" | grep -oE '[^[:space:]:]*gitea-arm-request\.sh' | grep '^/' | head -1)
|
||||
chk "★ 出路印的是存在的絕對路徑(印出:${REQ:-無})" '[ -n "$REQ" ] && [ -f "$REQ" ]'
|
||||
|
||||
echo
|
||||
echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# history-first-guard.sh 的迴歸測試 —— 重點在**逃生門真的走得通**(inkstone/ISEP#122)
|
||||
#
|
||||
# ── 這支在守什麼 ────────────────────────────────────────────────────
|
||||
# 2026-09-02 實撞:KBDB 兩支工具都回 `Connection closed`,照這支閘印出來的那行
|
||||
# `touch /tmp/.kbdb-down`
|
||||
# 打完再送同一個編輯,**被一模一樣地擋第二次**——因為 `touch` 造的是空檔,
|
||||
# 而舊版是 `cat` 檔案內容當時戳,空字串被當成 0 ⇒ 「距今 17 億秒」⇒ 永遠不新鮮。
|
||||
#
|
||||
# 🔴 一道閘印出來的出路走不通,比沒有出路更糟:人照做了、還是被擋,
|
||||
# 下一步就是去繞過這道閘(本 repo 的 ticket-api-bypass-guard.sh 檔頭記過同一課)。
|
||||
# 所以這支測試的第一等公民是**逃生門那一條**,不是「擋不擋得住」。
|
||||
#
|
||||
# 🔴 **這支測試自己怎麼被證明有鑑別力**(不要只看它全綠):
|
||||
# 把修好之前那一版拿來跑,第 ④ 條要紅。因為舊版沒有 KBDB_STAMP_DIR,
|
||||
# 直接餵它會去讀這台機器真正的 /tmp 戳記而「剛好過關」,所以要先把路徑隔離:
|
||||
# git show <修好前的 commit>:hooks/history-first-guard.sh > /tmp/old.sh
|
||||
# sed 's#/tmp/\.kbdb-asked#${KBDB_STAMP_DIR:-/tmp}/.kbdb-asked#;
|
||||
# s#/tmp/\.kbdb-down#${KBDB_STAMP_DIR:-/tmp}/.kbdb-down#' /tmp/old.sh > /tmp/old-iso.sh
|
||||
# bash hooks/tests/history-first-guard.test.sh /tmp/old-iso.sh
|
||||
# 2026-09-02 實跑:舊版 9/10(④ 紅),修好後 10/10。
|
||||
# ⚠️ 第 ③ 條**兩版都是綠的**——它只驗離開碼 2,而過了第 0 道之後歷史警察照樣會擋。
|
||||
# 真正分辨得出來的是 ④「擋人的是誰」。這一格是本支最容易寫成假綠的地方。
|
||||
#
|
||||
# 🔴 全程用 KBDB_STAMP_DIR 指到 TMP,**不碰這台機器真正的 /tmp/.kbdb-* 戳記**
|
||||
# (把別的 session 的戳記清掉=把閘弄成隨機的)。不打網路。
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/history-first-guard.sh}"
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
export KBDB_STAMP_DIR="$TMP/stamps"; mkdir -p "$KBDB_STAMP_DIR"
|
||||
|
||||
PASS=0; FAIL=0; N=0
|
||||
|
||||
# 造一個「有前科」的 code 檔:放在一個有 git 歷史的假 repo 裡
|
||||
REPO="$TMP/repo"; mkdir -p "$REPO"
|
||||
( cd "$REPO" && git init -q && git config user.email t@t && git config user.name t \
|
||||
&& printf 'echo old\n' > victim.sh && git add . && git commit -qm "第一版" ) >/dev/null 2>&1
|
||||
VICTIM="$REPO/victim.sh"
|
||||
|
||||
# run <檔案> → 印出離開碼(送一份 Edit 的 payload 進去)
|
||||
run() {
|
||||
printf '{"tool_input":{"file_path":"%s"}}' "$1" \
|
||||
| CLAUDE_PROJECT_DIR="$REPO" bash "$HOOK" >/dev/null 2>"$TMP/err"
|
||||
printf '%s' "$?"
|
||||
}
|
||||
|
||||
check() { # check <說明> <實際> <期望>
|
||||
N=$((N+1))
|
||||
if [ "$2" = "$3" ]; then printf ' ✅ %s\n' "$1"; PASS=$((PASS+1))
|
||||
else printf ' ❌ %s —— 期望離開碼 %s,實際 %s\n' "$1" "$3" "$2"; sed 's/^/ /' "$TMP/err" | head -6; FAIL=$((FAIL+1)); fi
|
||||
}
|
||||
|
||||
clean_stamps() { rm -f "$KBDB_STAMP_DIR/.kbdb-asked" "$KBDB_STAMP_DIR/.kbdb-down"; rm -f /tmp/.history-guard-*; }
|
||||
|
||||
echo "── 第 0 道:問過 KBDB 了沒 ──────────────────────────────────"
|
||||
clean_stamps
|
||||
check "① 沒有任何戳記 → 擋" "$(run "$VICTIM")" 2
|
||||
grep -q "KBDB" "$TMP/err" && printf ' ✅ ② 擋下時訊息講得出是哪一道\n' && PASS=$((PASS+1)) || { printf ' ❌ ② 訊息沒提到 KBDB\n'; FAIL=$((FAIL+1)); }; N=$((N+1))
|
||||
|
||||
echo "── 🔴 逃生門:照訊息打那一行,就要真的過得去 ──────────────────"
|
||||
clean_stamps
|
||||
touch "$KBDB_STAMP_DIR/.kbdb-down" # ← 訊息裡原文那一行做的事
|
||||
check "③ touch 出來的空檔(本票的實撞)→ 要放行過第 0 道" "$(run "$VICTIM")" 2
|
||||
# ↑ 期望仍是 2:過了第 0 道之後會被「歷史警察」擋(這個檔真的有前科),
|
||||
# 所以要看訊息換人了沒——這是這支測試最容易寫錯的一格。
|
||||
grep -q "歷史警察" "$TMP/err" && printf ' ✅ ④ 擋人的換成歷史警察 ⇒ 第 0 道確實被逃生門放過了\n' && PASS=$((PASS+1)) || { printf ' ❌ ④ 還是卡在 KBDB 那一道 ⇒ 逃生門是假的\n'; sed 's/^/ /' "$TMP/err" | head -4; FAIL=$((FAIL+1)); }; N=$((N+1))
|
||||
|
||||
clean_stamps
|
||||
date +%s > "$KBDB_STAMP_DIR/.kbdb-down" # 舊格式(檔案內容是時戳)
|
||||
run "$VICTIM" >/dev/null
|
||||
grep -q "歷史警察" "$TMP/err" && printf ' ✅ ⑤ 舊格式(內容寫時戳)照樣認得 ⇒ 沒有把既有的弄壞\n' && PASS=$((PASS+1)) || { printf ' ❌ ⑤ 舊格式壞了\n'; FAIL=$((FAIL+1)); }; N=$((N+1))
|
||||
|
||||
clean_stamps
|
||||
date +%s > "$KBDB_STAMP_DIR/.kbdb-asked"
|
||||
run "$VICTIM" >/dev/null
|
||||
grep -q "歷史警察" "$TMP/err" && printf ' ✅ ⑥ 真的問過 KBDB(kbdb-asked-stamp.sh 寫的戳記)→ 過第 0 道\n' && PASS=$((PASS+1)) || { printf ' ❌ ⑥ 問過了還被擋\n'; FAIL=$((FAIL+1)); }; N=$((N+1))
|
||||
|
||||
echo "── 過期的戳記不算數(逃生門不能變成永久後門)──────────────────"
|
||||
clean_stamps
|
||||
touch -t "$(date -v-2H '+%Y%m%d%H%M' 2>/dev/null || date -d '2 hours ago' '+%Y%m%d%H%M')" "$KBDB_STAMP_DIR/.kbdb-down"
|
||||
run "$VICTIM" >/dev/null
|
||||
grep -q "KBDB" "$TMP/err" && printf ' ✅ ⑦ 兩小時前的空戳記 → 過期,回到擋\n' && PASS=$((PASS+1)) || { printf ' ❌ ⑦ 過期的戳記還在放行 ⇒ 一次 touch 就永久免疫\n'; FAIL=$((FAIL+1)); }; N=$((N+1))
|
||||
|
||||
echo "── 不該擋的(誤攔比漏擋嚴重)────────────────────────────────"
|
||||
clean_stamps
|
||||
printf '# doc\n' > "$REPO/readme.md"
|
||||
check "⑧ 文件檔(.md)→ 一路放行,連第 0 道都不該碰" "$(run "$REPO/readme.md")" 0
|
||||
clean_stamps
|
||||
check "⑨ 不存在的新檔 → 放行" "$(run "$REPO/brand-new.sh")" 0
|
||||
clean_stamps
|
||||
mkdir -p "$REPO/tests"; printf 'echo t\n' > "$REPO/tests/a.sh"
|
||||
check "⑩ 測試檔 → 放行" "$(run "$REPO/tests/a.sh")" 0
|
||||
|
||||
echo
|
||||
printf '通過 %s 條,失敗 %s 條(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
||||
[ "$FAIL" = 0 ] || exit 1
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# hooks/tests/lib/hook-sandbox.sh 的迴歸測試(inkstone/ISEP#122)
|
||||
#
|
||||
# 🔴 為什麼有這支(2026-09-02 實撞,證據在票上):
|
||||
# `hook_sandbox` 的第一個參數是「那支 hook 的檔案路徑」,傳錯時舊版不檢查就
|
||||
# `cp -R "$(dirname "$1")"` ⇒ 複製的是上一層。傳 repo 根目錄進去,
|
||||
# 複製的就是**整個 tech_projects**:兩次吃掉 23 GB,磁碟剩 462 MB,
|
||||
# 而它只印一句「沙盒建不起來」——**沒說參數錯,也沒說它已經把磁碟寫滿**。
|
||||
#
|
||||
# 所以這支測的不是「錯誤訊息好不好看」,是**收手的時機**:
|
||||
# ④⑤ 兩條驗「複製之前就 return 1」——暫存區裡不准留下任何東西。
|
||||
#
|
||||
# 用法:bash hooks/tests/hook-sandbox.test.sh
|
||||
set -u
|
||||
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
ROOT=$(CDPATH= cd -- "$HERE/.." && pwd) # …/hooks
|
||||
REPO=$(CDPATH= cd -- "$ROOT/.." && pwd)
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# 每一條都在自己的子殼裡跑:hook_sandbox 會設全域變數,不隔離會互相污染。
|
||||
run(){ # run <參數> → 印 "rc|沙盒路徑|訊息"
|
||||
( . "$HERE/lib/hook-sandbox.sh"
|
||||
msg=$(hook_sandbox "$1" 2>&1); rc=$?
|
||||
printf '%s|%s|%s' "$rc" "${HOOK_SANDBOX:-}" "$(printf '%s' "$msg" | tr '\n' ' ')"
|
||||
)
|
||||
}
|
||||
|
||||
echo "── 該收手(傳錯參數)──────────────────────────────────────"
|
||||
|
||||
r=$(run "$REPO"); rc=${r%%|*}; rest=${r#*|}; sb=${rest%%|*}; msg=${rest#*|}
|
||||
[ "$rc" = "1" ] && ok "① 傳 repo 根目錄(本票的實撞)→ return 1" \
|
||||
|| no "① 傳 repo 根目錄(本票的實撞)→ return 1" "實得 rc=$rc"
|
||||
case "$msg" in *"hooks/main-and-prod-push-guard.sh"*)
|
||||
ok "② 訊息給得出一行**真的跑得起來**的用法" ;;
|
||||
*) no "② 訊息給得出一行**真的跑得起來**的用法" "訊息:$msg" ;;
|
||||
esac
|
||||
if [ -z "$sb" ] || [ ! -d "$sb" ]; then ok "③ 收手在 mktemp 之前,暫存區沒有殘骸"
|
||||
else no "③ 收手在 mktemp 之前,暫存區沒有殘骸" "留下了 $sb"; fi
|
||||
|
||||
r=$(run ""); [ "${r%%|*}" = "1" ] && ok "④ 空參數 → return 1(不是拿空字串去 dirname)" \
|
||||
|| no "④ 空參數 → return 1" "實得 rc=${r%%|*}"
|
||||
|
||||
r=$(run "$REPO/scripts/ticket"); rc=${r%%|*}; rest=${r#*|}; sb=${rest%%|*}
|
||||
if [ "$rc" = "1" ]; then ok "⑤ 檔案存在但上一層不是 hooks/ → 一樣收手"
|
||||
else no "⑤ 檔案存在但上一層不是 hooks/ → 一樣收手" "實得 rc=$rc"; fi
|
||||
if [ -z "$sb" ] || [ ! -d "$sb" ]; then ok "⑥ ⑤ 這條也沒把 scripts/ 複製出去"
|
||||
else no "⑥ ⑤ 這條也沒把 scripts/ 複製出去" "留下了 $sb"; fi
|
||||
|
||||
r=$(run "$ROOT/沒有這支.sh"); [ "${r%%|*}" = "1" ] && ok "⑦ 路徑不存在 → return 1" \
|
||||
|| no "⑦ 路徑不存在 → return 1" "實得 rc=${r%%|*}"
|
||||
|
||||
echo "── 不准把本來會過的弄壞(誤攔比漏擋嚴重)────────────────────"
|
||||
( . "$HERE/lib/hook-sandbox.sh"
|
||||
hook_sandbox "$ROOT/main-and-prod-push-guard.sh"; rc=$?
|
||||
if [ "$rc" != "0" ]; then echo "RC=$rc"; exit 0; fi
|
||||
[ -f "$HOOK_SANDBOX_HOOK" ] && echo "HOOKOK"
|
||||
[ -d "$HOOK_SANDBOX/hooks/lib" ] && echo "LIBOK"
|
||||
# 只複製 hooks/,不該把 repo 的其他目錄帶進來
|
||||
[ -d "$HOOK_SANDBOX/hooks/scripts" ] && echo "LEAK"
|
||||
hook_sandbox_cleanup
|
||||
) > /tmp/.hs-ok.$$ 2>&1
|
||||
grep -q HOOKOK /tmp/.hs-ok.$$ && ok "⑧ 正常用法照樣建得起來,複本裡有那支 hook" \
|
||||
|| no "⑧ 正常用法照樣建得起來" "$(cat /tmp/.hs-ok.$$)"
|
||||
grep -q LIBOK /tmp/.hs-ok.$$ && ok "⑨ 整個 hooks/(含 lib/)都在複本裡" \
|
||||
|| no "⑨ 整個 hooks/(含 lib/)都在複本裡" "$(cat /tmp/.hs-ok.$$)"
|
||||
grep -q LEAK /tmp/.hs-ok.$$ && no "⑩ 只複製 hooks/,沒有把 repo 其他目錄帶進去" "複本裡出現 scripts/" \
|
||||
|| ok "⑩ 只複製 hooks/,沒有把 repo 其他目錄帶進去"
|
||||
rm -f /tmp/.hs-ok.$$
|
||||
|
||||
echo
|
||||
echo "通過 $PASS 條,失敗 $FAIL 條(共 $((PASS+FAIL)) 條)"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -24,10 +24,23 @@ mkplugin() {
|
||||
> "$d/hooks/hooks.json"
|
||||
cp "$HOOK" "$d/hooks/$(basename "$HOOK")"
|
||||
cp "$REAL_HOOKS/lib/beacon_report.py" "$d/hooks/lib/beacon_report.py"
|
||||
cp "$REAL_HOOKS/lib/release_chain.py" "$d/hooks/lib/release_chain.py"
|
||||
printf '#!/bin/sh\necho real\n' > "$d/scripts/ticket"
|
||||
# inkstone/ISEP#122:會自動載入的東西(skill/command/agent)也要造得出來
|
||||
mkdir -p "$d/skills/demo" "$d/commands" "$d/agents" "$d/docs"
|
||||
printf 'PLUGIN 版的 skill\n' > "$d/skills/demo/SKILL.md"
|
||||
printf 'PLUGIN 版的 command\n' > "$d/commands/demo.md"
|
||||
printf 'PLUGIN 版的 agent\n' > "$d/agents/demo.md"
|
||||
printf '%s\n' "$d"
|
||||
}
|
||||
|
||||
# mkown <plugin root> <列...> → 寫一份 docs/file-ownership.tsv(欄位用真正的 TAB)
|
||||
mkown() {
|
||||
d="$1"; shift; mkdir -p "$d/docs"
|
||||
{ printf '# 測試用\n'; for row in "$@"; do printf '%s\n' "$row"; done; } \
|
||||
| sed 's/|/\t/g' > "$d/docs/file-ownership.tsv"
|
||||
}
|
||||
|
||||
# run <plugin root> <project dir> → 印出 systemMessage(JSON 壞掉就印 __BADJSON__)
|
||||
run() {
|
||||
CLAUDE_PLUGIN_ROOT="$1" CLAUDE_PROJECT_DIR="$2" bash "$1/hooks/$(basename "$HOOK")" 2>/dev/null \
|
||||
@@ -112,6 +125,109 @@ printf '#!/bin/sh\necho "OLD"\n' > "$J6/scripts/ticket"
|
||||
out=$(run "$P" "$J6")
|
||||
check "⑭ 報告內容含引號 → JSON 仍然合法" "$out" "🟢 ISEP" "!__BADJSON__"
|
||||
|
||||
P2=$(mkplugin 1.0.0) # ⑫⑬ 會刪掉 $P 的 report,這一組要自己一份
|
||||
echo "── ②b 會自動載入的東西也有分身(inkstone/ISEP#122)──────────────"
|
||||
# 🔴 這一組守的是 ship-check 那件:plugin 帶著舊描述,而舊描述在「我要發部落格文章」
|
||||
# 的情境根本不會被觸發 ⇒ 那個 session 永遠載不到出貨流程,而且不會有任何症狀。
|
||||
JS="$TMP/proj-skill"; mkdir -p "$JS/.claude/skills/demo"
|
||||
printf '專案版的 skill(比較舊)\n' > "$JS/.claude/skills/demo/SKILL.md"
|
||||
out=$(run "$P2" "$JS")
|
||||
check "⑮ 專案有同名 skill 而內容不同 → 要點名" "$out" \
|
||||
"會自動載入的東西兩邊各有一份" "skills/demo/SKILL.md" ".claude/skills/demo/SKILL.md"
|
||||
check "⑯ 歸屬表沒登記 → 誠實說未定,不要替人猜方向" "$out" "歸屬未定"
|
||||
|
||||
JS2="$TMP/proj-skill-same"; mkdir -p "$JS2/.claude/skills/demo"
|
||||
cp "$P2/skills/demo/SKILL.md" "$JS2/.claude/skills/demo/SKILL.md"
|
||||
out=$(run "$P2" "$JS2")
|
||||
check "⑰ 內容一模一樣 → 不吵(誤攔比漏擋嚴重)" "$out" "!會自動載入的東西兩邊各有一份"
|
||||
|
||||
JC="$TMP/proj-cmd"; mkdir -p "$JC/.claude/commands"
|
||||
printf '專案版的 command(比較舊)\n' > "$JC/.claude/commands/demo.md"
|
||||
out=$(run "$P2" "$JC")
|
||||
check "⑱ command 也要掃(sdd-check 那件)" "$out" "commands/demo.md" ".claude/commands/demo.md"
|
||||
|
||||
JA="$TMP/proj-agent"; mkdir -p "$JA/.claude/agents"
|
||||
printf '專案版的 agent(比較舊)\n' > "$JA/.claude/agents/demo.md"
|
||||
out=$(run "$P2" "$JA")
|
||||
check "⑲ agent 也要掃" "$out" "agents/demo.md" ".claude/agents/demo.md"
|
||||
|
||||
JCL="$TMP/proj-cloud"; mkdir -p "$JCL/InkStoneCo/.claude/skills/demo"
|
||||
printf '專案版的 skill(比較舊)\n' > "$JCL/InkStoneCo/.claude/skills/demo/SKILL.md"
|
||||
out=$(run "$P2" "$JCL")
|
||||
check "⑳ 雲端排法:真身在 \$TOP/InkStoneCo/ 底下也要抓到" "$out" \
|
||||
"InkStoneCo/.claude/skills/demo/SKILL.md"
|
||||
|
||||
echo "── 歸屬表要講得出「往哪個方向修」──────────────────────────────"
|
||||
PO=$(mkplugin 1.0.1)
|
||||
mkown "$PO" "skills/demo/SKILL.md|inkstone/InkStoneCo|.claude/skills/demo/SKILL.md|abc1234|-"
|
||||
out=$(run "$PO" "$JS")
|
||||
check "㉑ 真相源=InkStoneCo → 說「內容改在那裡」並給得出路徑" "$out" \
|
||||
"真相源=inkstone/InkStoneCo:.claude/skills/demo/SKILL.md" "內容改在那裡" "!歸屬未定"
|
||||
|
||||
mkown "$PO" "skills/demo/SKILL.md|inkstone/ISEP|-|-|-"
|
||||
out=$(run "$PO" "$JS")
|
||||
check "㉒ 真相源=ISEP → 說「專案那份是舊複本」" "$out" \
|
||||
"真相源=ISEP 這一份" "舊複本" "!歸屬未定"
|
||||
|
||||
echo "── ②c 沒有專案那一份也要驗得出來(雲端唯一作數的檢查)────────────"
|
||||
# 雲端的 project dir 是薄殼,沒有 InkStoneCo 可以比 ⇒ 只剩「檔案 vs 表上的 sha256」
|
||||
SHA=$(shasum -a 256 "$PO/skills/demo/SKILL.md" | cut -d" " -f1)
|
||||
mkown "$PO" "skills/demo/SKILL.md|inkstone/InkStoneCo|.claude/skills/demo/SKILL.md|abc1234|$SHA"
|
||||
out=$(run "$PO" "$J")
|
||||
check "㉓ sha256 對得上(而且專案那份不存在)→ 一個字都不說" "$out" \
|
||||
"🟢 ISEP v1.0.1 已載入" "!跟歸屬表對不上" "!會自動載入的東西兩邊各有一份"
|
||||
|
||||
printf '被就地改過\n' >> "$PO/skills/demo/SKILL.md"
|
||||
out=$(run "$PO" "$J")
|
||||
check "㉔ 檔案被就地改過 → 就算沒有專案那份,也要抓得到" "$out" \
|
||||
"跟歸屬表對不上" "skills/demo/SKILL.md" "abc1234"
|
||||
|
||||
mkown "$PO" "skills/demo/SKILL.md|inkstone/InkStoneCo|.claude/skills/demo/SKILL.md|abc1234|-"
|
||||
out=$(run "$PO" "$J")
|
||||
check "㉕ 表上 sha 欄是 \`-\` → 這一格閉嘴(還沒登記不是錯)" "$out" "!跟歸屬表對不上"
|
||||
|
||||
echo "── 加了這兩格也不准讓信標消失 ────────────────────────────────"
|
||||
out=$(run "$P2" "$JS")
|
||||
check "㉖ ②b 有報告時,那行綠色信標仍在且 JSON 合法" "$out" \
|
||||
"🟢 ISEP v1.0.0 已載入" "!__BADJSON__"
|
||||
|
||||
echo "── ④打了 tag 卻沒 release(inkstone/ISEP#67 → 6574)──────────────────"
|
||||
# 🔴 這一組守的是 09-07 那件:v0.23.0/v0.24.0 tag 都推了、Releases 頁停在 v0.22.0,
|
||||
# 交棒給 leo 的測試位置打開是一頁空 tag。開場要說「缺哪一站+怎麼補」,都在時安靜。
|
||||
P4=$(mkplugin 0.24.0); printf '#!/usr/bin/env python3\n' > "$P4/scripts/release-ship"
|
||||
FX4="$TMP/chain-gap.json"
|
||||
printf '{"tags":["v0.24.0","v0.23.0"],"releases":["v0.23.0"]}\n' > "$FX4"
|
||||
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4" run "$P4" "$J")
|
||||
check "㉗ 最新 tag v0.24.0 沒有 release → 一行說缺哪一站+怎麼補(正本絕對路徑)" "$out" \
|
||||
"v0.24.0 送到一半" "缺 release(Gitea)" "python3 $P4/scripts/release-ship v0.24.0" "inkstone/ISEP#67"
|
||||
check "㉘ 信標那一行仍在、JSON 合法" "$out" "🟢 ISEP v0.24.0 已載入" "!__BADJSON__"
|
||||
|
||||
FX4b="$TMP/chain-ok.json"
|
||||
printf '{"tags":["v0.24.0","v0.23.0"],"releases":["v0.24.0","v0.23.0"]}\n' > "$FX4b"
|
||||
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4b" run "$P4" "$J")
|
||||
check "㉙ tag 與 release 都在 → 一個字都不說(天天念會被學會忽略)" "$out" "!送到一半" "!缺 release"
|
||||
|
||||
out=$(run "$P4" "$J")
|
||||
check "㉚ 問不到收貨端(沒網路、沒 fixture)→ 閉嘴,不猜" "$out" "!送到一半" "🟢 ISEP v0.24.0 已載入"
|
||||
|
||||
out=$(ISEP_FAKE_MAIN_VERSION=0.25.0 ISEP_BEACON_CHAIN_FIXTURE="$FX4b" run "$P4" "$J")
|
||||
check "㉛ main 定版到 0.25.0、Gitea 最新 tag 還是 v0.24.0 → 說缺 tag 站+push tag 的補法" "$out" \
|
||||
"v0.25.0 送到一半" "缺 tag(Gitea)" "git push origin v0.25.0"
|
||||
|
||||
P4o=$(mkplugin 0.21.0) # 載入的這一份還沒有 scripts/release-ship(舊版)
|
||||
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4" run "$P4o" "$J")
|
||||
check "㉜ 載入的這一份還沒有 release-ship → 補法先教 plugin update,不印一條不存在的路徑" "$out" \
|
||||
"v0.24.0 送到一半" "claude plugin update isep@inkstone" "!python3 $P4o/scripts/release-ship"
|
||||
|
||||
# 只快取「都在」:缺件那次不留快取,補完的下一個 session 立刻安靜;都在那次留快取
|
||||
rm -f "$TMP"/.isep-chain-ok-*
|
||||
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4" run "$P4" "$J")
|
||||
[ ! -e "$TMP/.isep-chain-ok-v0.24.0" ] && r1=1 || r1=0
|
||||
out=$(ISEP_BEACON_CHAIN_FIXTURE="$FX4b" run "$P4" "$J")
|
||||
[ -e "$TMP/.isep-chain-ok-v0.24.0" ] && r2=1 || r2=0
|
||||
N=$((N+1)); if [ "$r1$r2" = "11" ]; then printf ' ✅ ㉝ 缺件不快取、都在才快取(補完下一個 session 就安靜)\n'; PASS=$((PASS+1))
|
||||
else printf ' ❌ ㉝ 快取方向錯了(缺件留了快取=%s/都在沒留=%s)\n' "$((1-r1))" "$((1-r2))"; FAIL=$((FAIL+1)); fi
|
||||
|
||||
echo
|
||||
printf '通過 %s 條,失敗 %s 條(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
||||
[ "$FAIL" = 0 ] || exit 1
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# leo21c-write-guard.sh 的迴歸測試(inkstone/ISEP#130)
|
||||
#
|
||||
# ── 這支在守什麼 ──────────────────────────────────────────────────────
|
||||
# 閘的判準是「**讀可以,寫不行**」(leo 2026-08-20:leo21c 是他本人在用的知識庫)。
|
||||
# 2026-09-04 雲端 Routine 的 run log:**讀 notify_leo 的定義被擋** ——
|
||||
# 讀都讀不到,雲端就什麼都拿不到(inkstone/ISEP#130 開票的第一件)。
|
||||
#
|
||||
# 這支之前**沒有任何測試**,所以「讀會不會被誤攔」從來沒被驗過。
|
||||
# 下面的測資是 Routine 文件(cloud-worker.md/progress-guard.md)裡**真的會下的那幾條指令**,
|
||||
# 不是想像出來的例子。
|
||||
#
|
||||
# 🔴 三個方向都要驗,缺一個就是假綠:
|
||||
# A 讀 ⇒ 放行(含 `tr -d`/`cut -d=` 這種唯讀的 -d,那正是 09-04 被擋的形狀)
|
||||
# B 寫 ⇒ 擋(心跳 POST/PATCH 收件匣/部署端點/別的 trigger)
|
||||
# C 閘印出來的「改法」指的是**現役**的 youlin 網址,不是 09-02 已死的舊名
|
||||
#
|
||||
# 這支閘不寫任何檔案,直接跑真跡即可;全程離線。
|
||||
# 用法:bash hooks/tests/leo21c-write-guard.test.sh
|
||||
set -u
|
||||
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
HOOK="$HERE/../leo21c-write-guard.sh"
|
||||
PASS=0; FAIL=0
|
||||
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; }
|
||||
t(){ # t <期望 pass|block> <說明> <指令>
|
||||
LAST_ERR=$(mk "$3" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
got=$([ "$rc" -eq 2 ] && echo block || echo pass)
|
||||
if [ "$got" = "$1" ]; then echo " ✅ $2"; PASS=$((PASS+1))
|
||||
else echo " ❌ $2 —— 期望 $1,實得 $got(rc=$rc)"; FAIL=$((FAIL+1)); fi
|
||||
}
|
||||
|
||||
L='https://arcrun-cypher-executor.leo21c.workers.dev'
|
||||
K='-H "X-Arcrun-API-Key: leo"'
|
||||
D='-d'
|
||||
|
||||
echo "── A. 讀 ⇒ 放行(雲端 Routine 真的會下的形狀)──"
|
||||
t pass "① 讀收件匣(progress-guard.md 步驟 0 原句)" "curl -s \"$L/kbdb/entries?entry_type=inbox&limit=20\" $K"
|
||||
t pass "② 讀 notify_leo 定義(09-04 被擋的那一條)" "curl -s $L/webhooks/named/leo/notify_leo $K"
|
||||
t pass "③ 讀 + tr -d(唯讀的 -d,不是 body)" "curl -s $L/workflows/notify_leo $K | tr $D '\\r'"
|
||||
t pass "④ 讀 + cut -d=" "NS=\$(grep ns cfg | cut ${D}= -f2); curl -s $L/kbdb/entries?limit=1 $K"
|
||||
t pass "⑤ 讀 + sort -d / date -d" "curl -s $L/health | sort $D; date $D yesterday"
|
||||
t pass "⑥ 明寫 -X GET" "curl -s -X GET $L/kbdb/entries?entry_type=dash_beat $K"
|
||||
t pass "⑦ 讀心跳(progress-guard 2.6 原句)" "curl -s \"$L/kbdb/entries?entry_type=dash_beat&limit=30\" $K"
|
||||
t pass "⑧ git commit 訊息提到 leo21c 與 POST(git 寫不到那台)" "git commit -m 'fix: curl -X POST $L/kbdb/entries 會被擋'"
|
||||
t pass "⑨ 修這道閘自己" "sed -i 's/a/b/' hooks/leo21c-write-guard.sh"
|
||||
t pass "⑩ 打 youlin(09-02 起的新子網域)POST,不是 leo21c" "curl -X POST https://arcrun-cypher-executor.arcrun-yuga3bse.workers.dev/webhooks/named $D '{}'"
|
||||
t pass "⑪ 用 youlin 的 CF 帳號部署" "CLOUDFLARE_ACCOUNT_ID=1129efd7df2e8899d537e9c8fbabb6cb npx wrangler deploy"
|
||||
t pass "⑫ 發一則 notify_leo(通知型 trigger,什麼都沒改;同 prod-write-guard 白名單)" "curl -s -X POST $L/webhooks/named/leo/notify_leo/trigger -H 'Content-Type: application/json' $D '{\"text\":\"[總管] hi\"}'"
|
||||
|
||||
echo "── B. 寫 ⇒ 擋 ──"
|
||||
t block "⑬ 打心跳 POST(cloud-worker.md 步驟 0 原句——它寫進 leo 的庫)" "curl -s -X POST \"$L/kbdb/entries\" $K -H 'Content-Type: application/json' $D '{\"entry_type\":\"dash_beat\"}'"
|
||||
t block "⑭ PATCH 收件匣" "curl -s -X PATCH \"$L/kbdb/entries/123\" $K $D '{\"content\":\"x\"}'"
|
||||
t block "⑮ DELETE" "curl -X DELETE $L/kbdb/entries/1 $K"
|
||||
t block "⑯ 只帶 -d 的隱式 POST" "curl $L/kbdb/entries $D '{}'"
|
||||
t block "⑰ --data-urlencode" "curl $L/kbdb/entries --data-urlencode text=hi"
|
||||
t block "⑱ --request PUT" "curl --request PUT $L/kbdb/entries/1 $D '{}'"
|
||||
t block "⑲ 別的工作流 trigger(ship_refresh_cdn 會動線上狀態)" "curl -X POST $L/webhooks/named/leo/ship_refresh_cdn/trigger $D '{}'"
|
||||
t block "⑳ 部署端點(無名字的 named webhook)" "curl -X POST $L/webhooks/named $D @def.yaml"
|
||||
t block "㉑ notify_leo 旁邊夾一個部署端點" "curl -X POST $L/webhooks/named/leo/notify_leo/trigger $D '{}' && curl -X POST $L/webhooks/named $D @x"
|
||||
t block "㉒ 寫 + tr -d(剪掉唯讀 -d 之後仍該擋)" "cat f | tr $D '\\r' | curl -X POST $L/kbdb/entries $D @-"
|
||||
t block "㉓ 用 leo21c 的 CF 帳號部署" "CLOUDFLARE_ACCOUNT_ID=51a01bfa2665bd7bc3fd080dc40cf3e1 npx wrangler deploy"
|
||||
|
||||
echo "── C. 擋下時印的改法要指到活著的 youlin ──"
|
||||
mk "curl -X POST $L/kbdb/entries $D '{}'" | bash "$HOOK" 2>"$HERE/.leo21c-msg.$$" >/dev/null
|
||||
MSG=$(cat "$HERE/.leo21c-msg.$$"); rm -f "$HERE/.leo21c-msg.$$"
|
||||
if printf '%s' "$MSG" | grep -q 'arcrun-cypher-executor.arcrun-yuga3bse.workers.dev'; then
|
||||
echo " ✅ ㉔ 改法段印的是現役子網域 arcrun-yuga3bse"; PASS=$((PASS+1))
|
||||
else echo " ❌ ㉔ 改法段沒有現役子網域 arcrun-yuga3bse"; FAIL=$((FAIL+1)); fi
|
||||
if printf '%s' "$MSG" | grep -q 'cypher : https://arcrun-cypher-executor.youlin-hsieh-dev'; then
|
||||
echo " ❌ ㉕ 改法段還教人打 09-02 已死的 youlin-hsieh-dev"; FAIL=$((FAIL+1))
|
||||
else echo " ✅ ㉕ 改法段不再把死的舊名當網址教人"; PASS=$((PASS+1)); fi
|
||||
if printf '%s' "$MSG" | grep -q '讀可以,寫不行'; then
|
||||
echo " ✅ ㉖ 訊息講得出判準(讀可以,寫不行)"; PASS=$((PASS+1))
|
||||
else echo " ❌ ㉖ 訊息沒講判準"; FAIL=$((FAIL+1)); fi
|
||||
|
||||
echo
|
||||
echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -39,9 +39,38 @@
|
||||
# ⇒ hostsum 兩次都回 `NODIR`(相等)、沙盒的請求數當然是 0
|
||||
# ⇒ **第一條斷言變成「拿空的比空的」的假綠**。
|
||||
# 所以改成「設變數、不印」,而且 hook_sandbox_assert 開頭會擋空值(見下)。
|
||||
#
|
||||
# 🔴 **先驗參數再複製**(inkstone/ISEP#122,2026-09-02 實撞,本檔第二次修):
|
||||
# `$1` 是「那支 hook 的路徑」。傳錯(例如順手傳了 repo 根目錄)時,舊版不檢查就
|
||||
# `cp -R "$(dirname "$1")" …` ⇒ **複製的是那個目錄的上一層**。
|
||||
# 當天實測:`bash hooks/tests/main-and-prod-push-guard.test.sh "$PWD"`
|
||||
# ⇒ dirname 變成 ~/Documents/tech_projects
|
||||
# ⇒ 把 **整個 tech_projects**(所有 repo、所有 worktree)複製進 mktemp
|
||||
# ⇒ 兩次就吃掉 23 GB,磁碟從 25 GB 剩到 462 MB,`cp` 一路吐
|
||||
# `No space left on device`,最後才印一句「❌ 沙盒建不起來」。
|
||||
# ⇒ 訊息只說「建不起來」,**沒說是參數傳錯,也沒說它已經把磁碟寫滿了**。
|
||||
# 判準用「要求某個東西在場」,不是關鍵字比對:
|
||||
# ① `$1` 要指到一個**真的檔案**(傳目錄、傳空字串都不算)
|
||||
# ② 它的上一層目錄名要叫 `hooks`(沙盒的前提就是「複製一整個 hooks/」)
|
||||
# 兩條任一不成立就**在複製之前**收手,並印出走得通的那一行。
|
||||
hook_sandbox() {
|
||||
_hs_real=$1
|
||||
if [ -z "${_hs_real:-}" ] || [ ! -f "$_hs_real" ]; then
|
||||
printf '❌ hook_sandbox:第一個參數要是「那支 hook 的檔案路徑」,實得 %s\n' \
|
||||
"${_hs_real:-(空的)}" >&2
|
||||
printf ' 例:bash hooks/tests/main-and-prod-push-guard.test.sh hooks/main-and-prod-push-guard.sh\n' >&2
|
||||
printf ' (不必自己傳的版本:bash scripts/test-main-and-prod-push-guard.sh)\n' >&2
|
||||
return 1
|
||||
fi
|
||||
_hs_hooks=$(CDPATH= cd -- "$(dirname -- "$_hs_real")" && pwd) || return 1
|
||||
if [ "$(basename "$_hs_hooks")" != "hooks" ]; then
|
||||
printf '❌ hook_sandbox:%s 的上一層不是 hooks/,而沙盒要複製的就是那個目錄。\n' \
|
||||
"$_hs_real" >&2
|
||||
printf ' 算出來的來源是 %s——照複製下去會把它整個搬進暫存區(2026-09-02 這樣寫滿過磁碟)。\n' \
|
||||
"$_hs_hooks" >&2
|
||||
printf ' 例:bash hooks/tests/main-and-prod-push-guard.test.sh hooks/main-and-prod-push-guard.sh\n' >&2
|
||||
return 1
|
||||
fi
|
||||
HOOK_SANDBOX=$(mktemp -d) || return 1
|
||||
cp -R "$_hs_hooks" "$HOOK_SANDBOX/hooks" || return 1
|
||||
HOOK_SANDBOX_HOST="${_hs_hooks%/hooks}/pending-main-push"
|
||||
|
||||
Executable
+386
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env bash
|
||||
# line-needs-own-worktree.sh 的迴歸測試(inkstone/ISEP#109 → comment 5391)。
|
||||
#
|
||||
# **離線、不打網路、不花錢、每次結果一樣**——判準只有兩個,兩個都是 git 自己回答的:
|
||||
# ①這條命令會不會移動 HEAD ②那個目錄是不是主工作目錄。所以不需要語意判官。
|
||||
#
|
||||
# 每個案例都自己開一顆真的 git repo(外加一份真的 linked worktree)。
|
||||
#
|
||||
# 三群,兩個方向都要有證據:
|
||||
# A 群「不該擋」——總管、唯讀動作、還原檔案、已經在自己的 worktree 裡、逃生門、
|
||||
# worktree add 本身(**這群紅了比漏擋嚴重**:擋掉它等於線動不了工)
|
||||
# B 群「該擋」 ——線在共用目錄裡 checkout/switch/-b,含 -C 與複合命令
|
||||
# C 群「訊息與副作用」——講得出目錄與指令、不要開在 /tmp、逃生門、
|
||||
# 以及**真的把說謊的登記清掉**(驗收條件 7)
|
||||
#
|
||||
# 用法:hooks/tests/line-needs-own-worktree.test.sh [hook 路徑]
|
||||
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HOOK="${1:-$(cd "$HERE/.." && pwd)/line-needs-own-worktree.sh}"
|
||||
|
||||
PASS=0; FAIL=0; N=0
|
||||
ROOT=$(mktemp -d "${TMPDIR:-/tmp}/isep-wt.XXXXXX")
|
||||
clean() { rm -rf "$ROOT"; }
|
||||
trap clean EXIT
|
||||
|
||||
# 🔴 macOS 的 $TMPDIR 是 /var/folders/… 而 git 回的是解析過的 /private/var/…
|
||||
# 測試比對前先把路徑解析掉,不然會拿「同一個目錄的兩種寫法」互比而假紅。
|
||||
ROOT=$(cd "$ROOT" && pwd -P)
|
||||
REPO="$ROOT/shared-repo"
|
||||
mkdir -p "$REPO"
|
||||
git -C "$REPO" init -q -b main
|
||||
git -C "$REPO" config user.email t@t; git -C "$REPO" config user.name t
|
||||
echo hi > "$REPO/a.txt"; git -C "$REPO" add -A; git -C "$REPO" commit -qm init
|
||||
# G/H 群要驗「推了沒」——給共用 repo 一顆真的(本機 bare)遠端。離線,不打網路。
|
||||
git init -q --bare "$ROOT/remote.git"
|
||||
git -C "$REPO" remote add origin "$ROOT/remote.git"
|
||||
git -C "$REPO" push -q origin main 2>/dev/null
|
||||
git -C "$REPO" worktree add -q "$ROOT/own-wt" -b mine
|
||||
OWN="$ROOT/own-wt"
|
||||
PLAIN="$ROOT/not-a-repo"; mkdir -p "$PLAIN"
|
||||
|
||||
# 第二顆 repo:D 群要驗「認出的是哪一個」,一顆 repo 分不出對錯
|
||||
REPO2="$ROOT/other-repo"
|
||||
mkdir -p "$REPO2"
|
||||
git -C "$REPO2" init -q -b main
|
||||
git -C "$REPO2" config user.email t@t; git -C "$REPO2" config user.name t
|
||||
echo hi > "$REPO2/b.txt"; git -C "$REPO2" add -A; git -C "$REPO2" commit -qm init
|
||||
|
||||
# payload <command> [cwd]
|
||||
payload() {
|
||||
python3 - "$1" "${2:-$REPO}" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({"session_id": "S-WT", "hook_event_name": "PreToolUse", "cwd": sys.argv[2],
|
||||
"tool_name": "Bash", "tool_input": {"command": sys.argv[1]}},
|
||||
ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
# t <期望 exit> <說明> <command> [cwd] [env 前綴]
|
||||
t() {
|
||||
local want="$1" desc="$2" cmd="$3" cwd="${4:-$REPO}" envs="${5:-CLAUDE_CODE_CHILD_SESSION=1}"
|
||||
N=$((N+1))
|
||||
local err rc
|
||||
err=$(printf '%s' "$(payload "$cmd" "$cwd")" | env $envs bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
LAST_ERR="$err"
|
||||
if [ "$rc" -eq "$want" ]; then
|
||||
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
|
||||
printf '%s\n' "$err" | sed -n '1,8p' | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
has() { N=$((N+1)); if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then printf ' ✅ %s\n' "$2"; PASS=$((PASS+1)); else printf ' ❌ %s —— 找不到「%s」\n' "$2" "$1"; FAIL=$((FAIL+1)); fi; }
|
||||
hasnt() { N=$((N+1)); if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then printf ' ❌ %s —— 出現了「%s」\n' "$2" "$1"; FAIL=$((FAIL+1)); else printf ' ✅ %s\n' "$2"; PASS=$((PASS+1)); fi; }
|
||||
|
||||
echo
|
||||
echo "── A 群:不該擋(誤攔比漏擋嚴重——擋錯了線就動不了工)──"
|
||||
|
||||
t 0 "① 總管(不是線)在共用目錄切分支 ⇒ 不管(那是他自己的目錄)" \
|
||||
"git checkout -b feat/x" "$REPO" "CLAUDE_CODE_CHILD_SESSION="
|
||||
t 0 "② git status ⇒ 唯讀,不管" "git status"
|
||||
t 0 "③ git log --oneline ⇒ 唯讀,不管" "git log --oneline -5"
|
||||
t 0 "④ git branch -a ⇒ 唯讀,不管" "git branch -a"
|
||||
t 0 "⑤ git checkout -- a.txt(還原檔案,不動 HEAD)⇒ 放行" "git checkout -- a.txt"
|
||||
t 0 "⑥ git checkout HEAD -- a.txt ⇒ 放行" "git checkout HEAD -- a.txt"
|
||||
t 0 "⑦ git restore a.txt ⇒ 放行(根本不是 checkout)" "git restore a.txt"
|
||||
t 0 "⑧ git checkout -p ⇒ 互動式挑 hunk,不動 HEAD,放行" "git checkout -p"
|
||||
t 0 "⑨ **已經在自己的 worktree 裡**切分支 ⇒ 放行(這是我們要它做的事)" \
|
||||
"git checkout -b feat/inside" "$OWN"
|
||||
t 0 "⑩ git worktree add ⇒ 放行(那是解法本身,不是病)" \
|
||||
"git worktree add $ROOT/wt2 -b feat/y"
|
||||
t 0 "⑪ 逃生門 WORKTREE_OK=1 ⇒ 放行" \
|
||||
"git checkout -b feat/z" "$REPO" "CLAUDE_CODE_CHILD_SESSION=1 WORKTREE_OK=1"
|
||||
t 0 "⑫ 不是 git 命令 ⇒ 不管" "npm test"
|
||||
t 0 "⑬ 目錄根本不是 git repo ⇒ 不管" "git checkout -b feat/x" "$PLAIN"
|
||||
t 0 "⑭ 命令裡有 checkout 這個字但不是 git 的(不看措辭,看動作)" \
|
||||
"echo '別忘了 git checkout 回 main'"
|
||||
t 0 "⑮ payload 壞掉 ⇒ 放行(不擋在不確定上)" ""
|
||||
|
||||
N=$((N+1))
|
||||
brc=$(printf 'not json' | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" >/dev/null 2>&1; echo $?)
|
||||
if [ "$brc" -eq 0 ]; then printf ' ✅ ⑯ 完全不是 JSON 的 payload ⇒ 放行\n'; PASS=$((PASS+1));
|
||||
else printf ' ❌ ⑯ payload 壞掉卻擋下來了(exit=%s)\n' "$brc"; FAIL=$((FAIL+1)); fi
|
||||
|
||||
echo
|
||||
echo "── B 群:該擋 ──"
|
||||
|
||||
t 2 "⑰ 線在共用目錄 git checkout -b ⇒ 擋" "git checkout -b feat/new"
|
||||
t 2 "⑱ 線在共用目錄 git switch -c ⇒ 擋" "git switch -c feat/new2"
|
||||
t 2 "⑲ 線在共用目錄 git checkout main ⇒ 擋(切回去也是切)" "git checkout main"
|
||||
t 2 "⑳ git -C <共用目錄> checkout(人在別的地方)⇒ 擋" "git -C $REPO checkout -b feat/c" "$PLAIN"
|
||||
t 2 "㉑ 複合命令 cd … && git checkout ⇒ 擋(一段一段拆)" "cd $REPO && git checkout -b feat/d"
|
||||
t 2 "㉒ 前面掛 env 的 git checkout ⇒ 擋" "FOO=1 git checkout -b feat/e"
|
||||
t 2 "㉓ git switch main ⇒ 擋" "git switch main"
|
||||
|
||||
echo
|
||||
echo "── C 群:訊息與副作用 ──"
|
||||
|
||||
t 2 "㉔ 擋下來(下面幾條檢查這一次的訊息)" "git checkout -b feat/msg"
|
||||
has "$REPO" "㉕ 說得出是哪個目錄"
|
||||
has "scripts/worktree open" "㉖ 給得出開自己 worktree 的指令(scripts/worktree open,inkstone/ISEP#147)"
|
||||
has "不要開在 /tmp" "㉗ 講得出不要開在 /tmp(08-29 實測:那些全 prunable)"
|
||||
has "WORKTREE_OK=1" "㉘ 講得出逃生門"
|
||||
has "scripts/worktree close" "㉙ 講得出收工要怎麼收(scripts/worktree close)"
|
||||
hasnt '$(' "㉚ 訊息沒有被 shell 展開"
|
||||
hasnt "worktree add $ROOT/shared-repo-wt-" "㉚b 不再教人開在 repo 旁邊(09-07 那排分身就是這行教出來的)"
|
||||
has "$REPO/.worktrees/" "㉚c 教的是 repo 裡面的 .worktrees/"
|
||||
|
||||
# 驗收條件 7:說謊的登記真的被清掉
|
||||
git -C "$REPO" worktree add -q "$ROOT/ghost" -b ghosted
|
||||
rm -rf "$ROOT/ghost"
|
||||
N=$((N+1))
|
||||
if git -C "$REPO" worktree list 2>/dev/null | grep -q prunable; then
|
||||
printf ' ✅ ㉛ 造出一筆「指向空氣」的登記(目錄砍掉、登記還在)\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ㉛ 造不出 prunable 的登記,下一條測不到東西\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
t 2 "㉜ 這時候擋下來" "git checkout -b feat/prune"
|
||||
has "指向空氣" "㉝ 訊息說得出清掉了幾筆說謊的登記"
|
||||
N=$((N+1))
|
||||
if git -C "$REPO" worktree list 2>/dev/null | grep -q prunable; then
|
||||
printf ' ❌ ㉞ prune 沒有真的執行——登記簿還在說謊\n'; FAIL=$((FAIL+1))
|
||||
else
|
||||
printf ' ✅ ㉞ 說謊的登記真的被清掉了(prune 是收工動作,不是偶爾整理)\n'; PASS=$((PASS+1))
|
||||
fi
|
||||
N=$((N+1))
|
||||
if [ -d "$OWN" ] && git -C "$REPO" worktree list | grep -q "own-wt"; then
|
||||
printf ' ✅ ㉟ 而且還在的那份 worktree 一根寒毛都沒動\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ㉟ prune 把還在的 worktree 也弄掉了——那是災難\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── D 群:認得出是**哪一個** repo(inkstone/ISEP#109 → comment 5398)──"
|
||||
# 為什麼這群非有不可:舊版一律拿 payload 的 cwd 當答案,於是
|
||||
# `cd <別的 repo> && git checkout` 擋是擋對了,訊息卻指著 cwd 那個 repo。
|
||||
# 🔴 照著那個訊息做的人,會在錯的 repo 開一份用不到的 worktree,
|
||||
# 真正要隔離的那個沒開到——而他以為自己隔離好了。給錯下一步比不擋更糟。
|
||||
|
||||
t 2 "㊱ cd <另一個 repo> && git checkout ⇒ 擋(人站在 REPO)" \
|
||||
"cd $REPO2 && git checkout -b feat/x" "$REPO"
|
||||
has "$REPO2" "㊲ 認出的是 cd 進去的那個 repo"
|
||||
hasnt "$REPO/" "㊳ **不是** cwd 那個 repo(舊版就是在這裡指錯)"
|
||||
|
||||
t 2 "㊴ git -C <另一個 repo> checkout ⇒ 擋" \
|
||||
"git -C $REPO2 checkout -b feat/y" "$REPO"
|
||||
has "$REPO2" "㊵ 認出的是 -C 指的那個 repo"
|
||||
hasnt "$REPO/" "㊶ 不是 cwd 那個"
|
||||
|
||||
t 2 "㊷ cd A && git -C B checkout ⇒ 擋(兩個都在,誰贏?)" \
|
||||
"cd $REPO2 && git -C $REPO checkout -b feat/z" "$PLAIN"
|
||||
has "$REPO" "㊸ **-C 贏**——git 自己就是這個優先序(-C 蓋掉當下的 cwd)"
|
||||
hasnt "$REPO2" "㊹ cd 的目標沒有贏"
|
||||
|
||||
t 2 "㊺ 相對路徑的 cd ⇒ 擋,而且接得對" \
|
||||
"cd shared-repo && git checkout main" "$ROOT"
|
||||
has "$REPO" "㊻ 相對 cd 接在 cwd 後面算出正確的 repo"
|
||||
|
||||
t 2 "㊼ cd A && git -C <相對> checkout ⇒ 相對的 -C 接在 cd **之後**算" \
|
||||
"cd $ROOT && git -C shared-repo checkout main" "$PLAIN"
|
||||
has "$REPO" "㊽ 不是接在 cwd 後面(接錯會指到 not-a-repo/shared-repo)"
|
||||
|
||||
t 2 "㊾ 從自己的 worktree cd 回共用目錄再切 ⇒ 擋" \
|
||||
"cd ../shared-repo && git checkout main" "$OWN"
|
||||
has "$REPO" "㊿ 認出的是走回去的那個共用目錄"
|
||||
|
||||
t 2 "(51) 子殼的 cd 不外洩:(cd 別處 && true) ; git checkout ⇒ 擋在 cwd 這個 repo" \
|
||||
"(cd $REPO2 && true) ; git checkout -b feat/sub" "$REPO"
|
||||
has "$REPO" "(52) 子殼裡的 cd 出來就不算數了(沿用 push_target_dir 已驗過的範圍化)"
|
||||
|
||||
# 🔴 已知邊界,寫成測試而不是寫成註解——註解會過期,測試紅了才知道它變了。
|
||||
# `);` 中間沒空白時,shlex 的 punctuation_chars 會把它併成一顆 token,
|
||||
# 整段就斷在那裡 ⇒ **漏擋**(不是指錯 repo,是完全沒看到)。
|
||||
# 這個邊界是 push_target_dir 的 tokenize 層帶來的,兩道閘一起有;
|
||||
# 要修得動那層,會連帶改到推 main 那道閘的偵測範圍 ⇒ 不在本票裡動(另報)。
|
||||
t 0 "(53) 已知邊界:右括號與分號黏在一起 ⇒ 這道閘看不到(漏擋,不是誤攔)" \
|
||||
"(cd $REPO2 && true); git checkout -b feat/glued" "$REPO"
|
||||
|
||||
echo
|
||||
echo "── D 群反面:認不出來的時候**不准猜**(誤攔+指錯路,兩個一起犯)──"
|
||||
|
||||
t 0 "(54) cd 到不是 repo 的地方 ⇒ 放行(舊版會擋,還會指著 cwd 那個 repo)" \
|
||||
"cd $PLAIN && git checkout -b feat/x" "$REPO"
|
||||
t 0 "(55) cd 進**自己的 worktree** 再切 ⇒ 放行(那正是我們要它做的事)" \
|
||||
"cd $OWN && git checkout -b feat/inside2" "$REPO"
|
||||
t 0 "(56) cd 的目標是沒展開的變數 ⇒ 放行(猜錯會叫人去錯的 repo 開 worktree)" \
|
||||
'cd $SOMEREPO && git checkout -b feat/x' "$REPO"
|
||||
t 0 "(57) cd - ⇒ 放行(上一個目錄是什麼,tokenize 看不出來)" \
|
||||
"cd - && git checkout -b feat/x" "$REPO"
|
||||
t 0 "(58) cd 到別的 repo 但只是還原檔案 ⇒ 放行(不動 HEAD 的形狀照樣認得)" \
|
||||
"cd $REPO2 && git checkout -- b.txt" "$REPO"
|
||||
|
||||
N=$((N+1))
|
||||
if [ -f "$(cd "$HERE/.." && pwd)/lib/checkout_target_dir.py" ]; then
|
||||
printf ' ✅ (59) 解析器跟閘住在一起(少了它,閘只會印出錯的下一步)\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ (59) lib/checkout_target_dir.py 不見了\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── E 群:出路要真的走得通(inkstone/ISEP#109 → comment 6587,2026-09-07 實撞)──"
|
||||
# 為什麼這群非有不可:閘訊息教的出路是 `WORKTREE_OK=1 git -C … checkout …`,
|
||||
# 而舊版讀的是 **hook 自己的環境變數**。PreToolUse hook 在指令執行之前跑,
|
||||
# 指令字串裡的 `WORKTREE_OK=1` 是給 git 的前綴賦值,hook 的行程裡根本沒有它
|
||||
# ⇒ 那條出路在 Claude Code 這個 harness 下永遠走不通(總管當天三次照貼三次被擋)。
|
||||
# 🔴 這群的測資是「閘自己印出來的那一行」,原樣餵回去——驗的是出路跑不跑得動,
|
||||
# 不是閘擋不擋(mistakes.md「閘擋對了,但它印出來的那條出路跑不動」)。
|
||||
# 判準是**位置**不是字:那個賦值必須是「會移動 HEAD 的那條 git 指令」自己的前綴。
|
||||
|
||||
t 0 "(60) 指令字串裡帶 WORKTREE_OK=1 前綴、hook 的環境裡**沒有**它 ⇒ 放行" \
|
||||
"WORKTREE_OK=1 git checkout -b feat/esc" "$REPO"
|
||||
t 2 "(61) 先擋一次,拿它印出來的出路當下一條的測資" "git checkout main" "$REPO"
|
||||
ESC_LINE=$(printf '%s' "$LAST_ERR" | grep -F 'WORKTREE_OK=1 git -C' | head -1 | sed 's/^[[:space:]]*//; s/<分支>/main/')
|
||||
N=$((N+1))
|
||||
if [ -n "$ESC_LINE" ]; then printf ' ✅ (62) 抽得出那一行:%s\n' "$ESC_LINE"; PASS=$((PASS+1))
|
||||
else printf ' ❌ (62) 訊息裡抽不出 WORKTREE_OK=1 git -C … 那一行\n'; FAIL=$((FAIL+1)); ESC_LINE="WORKTREE_OK=1 git -C $REPO checkout main"; fi
|
||||
t 0 "(63) 閘印出來的那一行**原樣餵回去**(只填分支、人站在別處)⇒ 放行——6587 那天走不通的就是這條" \
|
||||
"$ESC_LINE" "$PLAIN"
|
||||
t 2 "(64) 只是印了那個字:echo WORKTREE_OK=1; git checkout main ⇒ 照擋(看位置不看字)" \
|
||||
"echo WORKTREE_OK=1; git checkout main"
|
||||
t 2 "(65) 前綴掛在別的指令上:WORKTREE_OK=1 echo hi && git checkout main ⇒ 照擋" \
|
||||
"WORKTREE_OK=1 echo hi && git checkout main"
|
||||
t 2 "(66) 值不是 1:WORKTREE_OK=0 git checkout main ⇒ 照擋" \
|
||||
"WORKTREE_OK=0 git checkout main"
|
||||
t 2 "(67) 寫在 commit 訊息裡再切 ⇒ 照擋(引號裡的字不是前綴)" \
|
||||
"git commit -qm 'WORKTREE_OK=1 git checkout main' ; git checkout main"
|
||||
t 0 "(68) cd 進去再掛前綴:cd <repo> && WORKTREE_OK=1 git checkout main ⇒ 放行" \
|
||||
"cd $REPO && WORKTREE_OK=1 git checkout main" "$PLAIN"
|
||||
t 0 "(69) 在自己的 worktree 裡也掛了前綴 ⇒ 本來就放行,前綴不礙事" \
|
||||
"WORKTREE_OK=1 git checkout -b feat/x2" "$OWN"
|
||||
|
||||
echo
|
||||
echo "── F 群:inkstone/ISEP#125 驗收表 A–F(票上原文的六條,期望值照票)──"
|
||||
# A 逃生門原文照打→0 B 環境變數裡真的有→0 C 文件裡只寫 'git checkout X'→0
|
||||
# D 文件裡寫 'cd <目錄> && git checkout X'→0 E 同 D 角括號拿掉→0 F 真的要切→2(本體,退化成 0=閘廢了)
|
||||
t 0 "(70) #125-A 逃生門原文照打:WORKTREE_OK=1 git -C <TOP> checkout main ⇒ 0" \
|
||||
"WORKTREE_OK=1 git -C $REPO checkout main" "$PLAIN"
|
||||
t 0 "(71) #125-B 同一句、WORKTREE_OK 真的在 hook 的環境裡 ⇒ 0(本來就是,不要弄壞)" \
|
||||
"git -C $REPO checkout main" "$PLAIN" "CLAUDE_CODE_CHILD_SESSION=1 WORKTREE_OK=1"
|
||||
DOC_C="cat > $ROOT/doc-c.md <<'EOF'
|
||||
把 repo 複製一份,然後 git checkout X
|
||||
EOF
|
||||
echo written"
|
||||
t 0 "(72) #125-C 文件裡只寫 'git checkout X' ⇒ 0" "$DOC_C"
|
||||
DOC_D="cat > $ROOT/doc-d.md <<'EOF'
|
||||
示範:cd <目錄> && git checkout X
|
||||
EOF
|
||||
echo written"
|
||||
t 0 "(73) #125-D 文件裡寫 'cd <目錄> && git checkout X' ⇒ 0(寫文件不是執行;09-02 兩次實地命中)" "$DOC_D"
|
||||
DOC_E="cat > $ROOT/doc-e.md <<'EOF'
|
||||
示範:cd $REPO && git checkout feat/x
|
||||
EOF
|
||||
echo written"
|
||||
t 0 "(74) #125-E 同 D、角括號拿掉、目錄是真的共用目錄 ⇒ 0" "$DOC_E"
|
||||
t 2 "(75) #125-F 真的要切(對照組)⇒ 2 🔴 本體,退化成 0=閘廢了" "cd $REPO && git checkout feat/x" "$PLAIN"
|
||||
# 🔴 已知邊界,寫成測試而不是註解(跟 (53) 同一層、同一個病):tokenize 層把**換行**當空白吞掉,
|
||||
# 多行指令被併成一句 ⇒ `echo hi⏎git checkout main` 從第一版起就看不到(舊閘實測 exit=0,
|
||||
# 同一句改成 `echo hi; git checkout main` 就擋)。剝 heredoc 沒有讓它變好也沒有變壞——
|
||||
# body 剝掉之後留下的仍是「換行分隔的兩句」。要修得動那層,會連帶改到推 main 那道閘的偵測範圍
|
||||
# ⇒ 不在本票裡動(另報)。這條紅了=那層被改了,回頭看 (53) 與推 main 的 10+19 條。
|
||||
t 0 "(76) 已知邊界:heredoc 之後**換行**再真的切 ⇒ 這道閘看不到(漏擋,不是誤攔;(53) 同層)" \
|
||||
"cat > $ROOT/doc-f.md <<'EOF'
|
||||
只是文件
|
||||
EOF
|
||||
git checkout main"
|
||||
t 2 "(77) 同一句換成分號:cat <<'EOF' … EOF; git checkout main ⇒ 照擋(剝掉的只有 body)" \
|
||||
"cat > $ROOT/doc-g.md <<'EOF'
|
||||
只是文件
|
||||
EOF
|
||||
; git checkout main"
|
||||
|
||||
echo
|
||||
echo "── G 群:分身開在 repo 裡面,旁邊不多任何東西(inkstone/ISEP#147)──"
|
||||
# 為什麼這群非有不可:閘訊息教的「開在 repo 旁邊」一份都沒掉、也一份都沒收,
|
||||
# leo 在 Finder 看到 ISEP-wt-117/ISEP-wt115/InkStoneCo-wt-112 一排。
|
||||
# 這群的測資是**閘自己印出來的那一行**(跟 E 群同一個原則):抽出來、只填票號與分支、真的跑,
|
||||
# 然後看三件事實:分身在 .worktrees/ 底下、repo 旁邊的目錄清單一個都沒多、共用目錄 git status 乾淨。
|
||||
BESIDE_BEFORE=$(ls -1 "$ROOT" | sort)
|
||||
t 2 "(78) 先擋一次,拿它印出來的 open 那一行當測資" "git checkout -b feat/g" "$REPO"
|
||||
OPEN_LINE=$(printf '%s' "$LAST_ERR" | grep -F 'scripts/worktree open' | head -1 | sed 's/^[[:space:]]*//; s|<owner/repo#票號>|inkstone/shared-repo#901|; s|<你的分支>|feat/901|')
|
||||
N=$((N+1))
|
||||
if [ -n "$OPEN_LINE" ]; then printf ' ✅ (79) 抽得出那一行:%s\n' "$OPEN_LINE"; PASS=$((PASS+1))
|
||||
else printf ' ❌ (79) 訊息裡抽不出 scripts/worktree open 那一行\n'; FAIL=$((FAIL+1)); fi
|
||||
t 0 "(80) 那一行原樣跑(線、人站在別處)⇒ 這道閘不擋它(open 不是 checkout)" "$OPEN_LINE" "$PLAIN"
|
||||
N=$((N+1))
|
||||
if (cd "$PLAIN" && eval "$OPEN_LINE" >/dev/null 2>&1) && [ -d "$REPO/.worktrees/shared-repo-901" ]; then
|
||||
printf ' ✅ (80b) 真的跑:分身長在 %s/.worktrees/shared-repo-901\n' "$REPO"; PASS=$((PASS+1))
|
||||
else printf ' ❌ (80b) 閘印的 open 那一行跑不動、或分身不在 .worktrees/ 底下\n'; FAIL=$((FAIL+1)); fi
|
||||
N=$((N+1))
|
||||
if [ "$(ls -1 "$ROOT" | sort)" = "$BESIDE_BEFORE" ]; then
|
||||
printf ' ✅ (81) repo 旁邊的目錄清單一個都沒多(leo 在 Finder 只看得到一個資料夾)\n'; PASS=$((PASS+1))
|
||||
else printf ' ❌ (81) repo 旁邊多了東西:%s\n' "$(comm -13 <(printf '%s\n' "$BESIDE_BEFORE") <(ls -1 "$ROOT" | sort) | tr '\n' ' ')"; FAIL=$((FAIL+1)); fi
|
||||
N=$((N+1))
|
||||
if [ -z "$(git -C "$REPO" status --porcelain 2>/dev/null)" ]; then
|
||||
printf ' ✅ (82) 共用目錄 git status 乾淨(.worktrees/ 進了 .git/info/exclude,沒動被追蹤的檔)\n'; PASS=$((PASS+1))
|
||||
else printf ' ❌ (82) 共用目錄髒了:%s\n' "$(git -C "$REPO" status --porcelain | head -3 | tr '\n' ' ')"; FAIL=$((FAIL+1)); fi
|
||||
t 0 "(83) 在那份分身裡切分支 ⇒ 放行(它是自己的 worktree)" "git checkout -b feat/g2" "$REPO/.worktrees/shared-repo-901"
|
||||
t 0 "(84) cd 進分身再切 ⇒ 放行" "cd $REPO/.worktrees/shared-repo-901 && git checkout feat/901" "$PLAIN"
|
||||
# 同一行再跑一次要冪等(重派同一張票的線不該撞牆)
|
||||
N=$((N+1))
|
||||
if (cd "$PLAIN" && eval "$OPEN_LINE" >/dev/null 2>&1); then printf ' ✅ (85) open 冪等:同一張票再開一次不炸\n'; PASS=$((PASS+1))
|
||||
else printf ' ❌ (85) 同一張票第二次 open 失敗\n'; FAIL=$((FAIL+1)); fi
|
||||
|
||||
echo
|
||||
echo "── H 群:收工那一半(PostToolUse Agent|Task)——推了收掉、沒推點名、不靜默(inkstone/ISEP#147)──"
|
||||
# 這裡跑的是**總管的 session**(CLAUDE_CODE_CHILD_SESSION 沒設),掃的範圍由 CLAUDE_PROJECT_DIR 給。
|
||||
post() { # post <prompt> [tool_name]
|
||||
python3 - "$1" "${2:-Agent}" "$ROOT" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({"session_id": "S-WT", "hook_event_name": "PostToolUse", "cwd": sys.argv[3],
|
||||
"tool_name": sys.argv[2], "tool_input": {"prompt": sys.argv[1]}, "tool_response": {}},
|
||||
ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
tp() { # tp <期望 exit> <說明> <prompt> [tool_name] [env]
|
||||
local want="$1" desc="$2" prompt="$3" tool="${4:-Agent}" envs="${5:-CLAUDE_CODE_CHILD_SESSION= CLAUDE_PROJECT_DIR=$ROOT}"
|
||||
N=$((N+1)); local err rc
|
||||
err=$(printf '%s' "$(post "$prompt" "$tool")" | env $envs bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
LAST_ERR="$err"
|
||||
if [ "$rc" -eq "$want" ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"; printf '%s\n' "$err" | sed -n '1,8p' | sed 's/^/ /'; FAIL=$((FAIL+1)); fi
|
||||
}
|
||||
WT901="$REPO/.worktrees/shared-repo-901"
|
||||
# (a) 沒推:分身裡多一筆 commit、沒 push
|
||||
echo g > "$WT901/g.txt"; git -C "$WT901" add -A; git -C "$WT901" -c user.email=t@t -c user.name=t commit -qm g
|
||||
tp 2 "(86) 線收工、分支沒推 ⇒ exit 2(點名,不靜默)" "【工單】inkstone/shared-repo#901"
|
||||
has "feat/901" "(87) 點得出分支名"
|
||||
has "$WT901" "(88) 點得出路徑"
|
||||
has "push origin feat/901" "(89) 印得出推的指令"
|
||||
has "worktree-placement.md" "(90) 指到規約"
|
||||
N=$((N+1)); if [ -d "$WT901" ]; then printf ' ✅ (91) 沒推的分身一根寒毛都沒動(不刪沒推的 commit)\n'; PASS=$((PASS+1)); else printf ' ❌ (91) 沒推的分身被刪了——那是災難\n'; FAIL=$((FAIL+1)); fi
|
||||
# (b) 推了但工作樹髒:有未追蹤檔
|
||||
git -C "$WT901" push -q origin feat/901 2>/dev/null
|
||||
echo tmp > "$WT901/untracked.txt"
|
||||
tp 2 "(92) 推了、但分身裡有未追蹤檔 ⇒ exit 2 點名(git 自己也拒絕 remove)" "【工單】inkstone/shared-repo#901"
|
||||
has "未 commit" "(93) 講得出是有東西沒交代"
|
||||
N=$((N+1)); if [ -d "$WT901" ] && [ -f "$WT901/untracked.txt" ]; then printf ' ✅ (94) 髒的分身沒被動\n'; PASS=$((PASS+1)); else printf ' ❌ (94) 髒的分身被動了\n'; FAIL=$((FAIL+1)); fi
|
||||
# (c) 推了且乾淨 ⇒ 收掉、安靜
|
||||
rm -f "$WT901/untracked.txt"
|
||||
tp 0 "(95) 推了且乾淨 ⇒ exit 0(收掉)" "【工單】inkstone/shared-repo#901"
|
||||
N=$((N+1)); if [ ! -e "$WT901" ] && ! git -C "$REPO" worktree list | grep -q shared-repo-901; then printf ' ✅ (96) 分身真的收掉了(目錄與登記都沒了)\n'; PASS=$((PASS+1)); else printf ' ❌ (96) 分身還在\n'; FAIL=$((FAIL+1)); fi
|
||||
N=$((N+1)); if git -C "$REPO" rev-parse --verify -q refs/heads/feat/901 >/dev/null; then printf ' ✅ (97) 分支本身還在(收的是分身,不是分支)\n'; PASS=$((PASS+1)); else printf ' ❌ (97) 分支被刪了\n'; FAIL=$((FAIL+1)); fi
|
||||
N=$((N+1)); if [ -d "$OWN" ] && [ -d "$REPO2" ]; then printf ' ✅ (98) 別人的 worktree 與別的 repo 一根寒毛都沒動\n'; PASS=$((PASS+1)); else printf ' ❌ (98) 收錯東西了\n'; FAIL=$((FAIL+1)); fi
|
||||
# (d) 安靜的方向(誤攔比漏擋嚴重)
|
||||
tp 0 "(99) 同一張票再收一次(分身已不在)⇒ exit 0 安靜" "【工單】inkstone/shared-repo#901"
|
||||
hasnt "沒有找到" "(100) 沒分身時不吵(--quiet)"
|
||||
tp 0 "(101) 派工單沒有【工單】⇒ 不管" "幫我看一下 shared-repo#901 的狀況"
|
||||
tp 0 "(102) 票的 repo 沒有任何分身 ⇒ 安靜" "【工單】inkstone/other-repo#5"
|
||||
tp 0 "(103) PostToolUse 但工具不是 Agent/Task ⇒ 不管" "【工單】inkstone/shared-repo#901" "Bash"
|
||||
# (e) 一張派工單兩張票:兩份分身各自收
|
||||
(cd "$PLAIN" && python3 "$(cd "$HERE/../.." && pwd)/scripts/worktree" open inkstone/shared-repo#903 --repo "$REPO" >/dev/null 2>&1)
|
||||
(cd "$PLAIN" && python3 "$(cd "$HERE/../.." && pwd)/scripts/worktree" open inkstone/shared-repo#904 --repo "$REPO" >/dev/null 2>&1)
|
||||
echo k > "$REPO/.worktrees/shared-repo-904/k"; git -C "$REPO/.worktrees/shared-repo-904" add -A; git -C "$REPO/.worktrees/shared-repo-904" -c user.email=t@t -c user.name=t commit -qm k
|
||||
tp 2 "(104) 兩張工單:903 推了(HEAD=main 已在遠端)收掉、904 沒推點名 ⇒ exit 2" "【工單】inkstone/shared-repo#903
|
||||
【工單】inkstone/shared-repo#904"
|
||||
N=$((N+1)); if [ ! -e "$REPO/.worktrees/shared-repo-903" ] && [ -d "$REPO/.worktrees/shared-repo-904" ]; then printf ' ✅ (105) 903 收了、904 留著\n'; PASS=$((PASS+1)); else printf ' ❌ (105) 903/904 處理錯:903=%s 904=%s\n' "$([ -e "$REPO/.worktrees/shared-repo-903" ] && echo 在 || echo 沒了)" "$([ -e "$REPO/.worktrees/shared-repo-904" ] && echo 在 || echo 沒了)"; FAIL=$((FAIL+1)); fi
|
||||
has "shared-repo-904" "(106) 點名的是 904"
|
||||
# (f) 總管 session 的環境變數不影響收工那一半
|
||||
tp 2 "(107) 環境裡有 WORKTREE_OK=1 也照樣收工檢查(那是切分支的逃生門,不是收工的)" "【工單】inkstone/shared-repo#904" "Agent" "CLAUDE_CODE_CHILD_SESSION= CLAUDE_PROJECT_DIR=$ROOT WORKTREE_OK=1"
|
||||
|
||||
clean
|
||||
echo
|
||||
echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗} ══"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -21,6 +21,7 @@ MAINLINE="$ROOT/../scripts/mainline"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
|
||||
export ISEP_MAINLINE_REPO_FILE=none # 把 repo 那份鏡像拿掉(inkstone/ISEP#130):這支測的是家目錄那份
|
||||
export TICKET_HOST="http://127.0.0.1:9" # 連不上:走到網路就會馬上失敗
|
||||
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
unset ISEP_MAINLINE_FIXTURE
|
||||
|
||||
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env bash
|
||||
# 主線第三個來源「Gitea main 上那份」的迴歸測試(inkstone/ISEP#140)
|
||||
#
|
||||
# ── 這支在守什麼 ──────────────────────────────────────────────────────
|
||||
# 雲端薄殼 `youlinhsieh/inkstoneco` 的 SessionStart 跑在 `bootstrap.sh` 之前:
|
||||
# `InkStoneCo/` 還沒 clone、家目錄又是空的 ⇒ 兩份主線檔都不存在 ⇒ `countdown.py` 退到
|
||||
# 「期限最近的 milestone」⇒ 第一則 prompt 印一條兩週前的舊線(票上實測:逾期 341 小時)。
|
||||
# 解法:`scripts/mainline refresh`(SessionStart)在兩份都不在時去 Gitea 讀
|
||||
# `inkstone/InkStoneCo` main 的 `system-dev/mainline.json` 一次、寫進家目錄。這裡驗:
|
||||
# A 薄殼形狀(兩份都沒有)+ Gitea 讀得到 ⇒ refresh 之後第一則 prompt 的 ⏱/🎯 就是那條線
|
||||
# B 匿名優先;InkStoneCo 是 private ⇒ 匿名 404 後有 token 才帶 token 再問一次,沒 token 就退回
|
||||
# C 家目錄或 repo 那份任一在場 ⇒ 不問 Gitea(優先序:家目錄 > repo > Gitea)
|
||||
# D 讀不到(5xx/不是 JSON/沒 title/掛住/連不上/被關掉)⇒ 退回現行行為,不掛、逾時有上限
|
||||
# E 抓到之後 bootstrap 才出現的 repo 那份,跟 ISEP#130 的 set_at 規則接得起來
|
||||
# F `load()`/UserPromptSubmit 那條路照舊零網路
|
||||
#
|
||||
# 用法:bash hooks/tests/mainline-gitea-fallback.test.sh
|
||||
# 🔴 全程離線:Gitea 是本機一個假伺服器(TICKET_HOST 指過去),它照真 Gitea 回:
|
||||
# public 時 raw 端點匿名 200;private 時匿名 404、帶 token 200(真 InkStoneCo 就是 private)。
|
||||
# 狀態走 ISEP_COUNTDOWN_STATE_DIR、repo 那份走 TMP 底下的假薄殼。不碰真家目錄、不碰真 Gitea。
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MAINLINE="$ROOT/../scripts/mainline"
|
||||
HOOK="$ROOT/countdown-guard.sh"
|
||||
TMP=$(mktemp -d)
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/home-state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
export ISEP_COUNTDOWN_NOW=1757203200 # 2026-09-07 00:00 UTC,讓主線那段「剩 X」穩定
|
||||
export ISEP_MAINLINE_GITEA_TIMEOUT=1
|
||||
unset ISEP_MAINLINE_REPO_FILE ISEP_MAINLINE_FIXTURE ISEP_MAINLINE_GITEA_URL ISEP_MILESTONE_DUE ISEP_MILESTONE_NAME
|
||||
unset GITEA_TOKEN_CLAUDE_CODE GITEA_TOKEN
|
||||
SHELL_ROOT="$TMP/shell"; mkdir -p "$SHELL_ROOT" # 薄殼根:沒有 InkStoneCo/,也不是 git repo
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# ── 假 Gitea:模式從 $TMP/mode 讀,每個請求記一行 `path|auth` 到 $TMP/log ──
|
||||
cat > "$TMP/stub.py" <<'PY'
|
||||
import json, os, sys, time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
TMP = sys.argv[1]; PORT = int(sys.argv[2])
|
||||
RAW = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json"
|
||||
DOC = {"ref": "inkstone/Arcrun#48", "owner": "inkstone", "repo": "Arcrun", "id": 48,
|
||||
"title": "Gitea 上那條現在的主線", "description": "測試用。", "due_on": "2026-09-12T23:59:59Z",
|
||||
"open_issues": 3, "closed_issues": 0, "members": ["inkstone/ISEP#140"],
|
||||
"set_at": "2026-09-07 07:30", "refreshed_at": "2026-09-07 08:18"}
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
def _send(self, code, body=b"", ctype="application/json"):
|
||||
self.send_response(code); self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
|
||||
def do_GET(self):
|
||||
mode = open(os.path.join(TMP, "mode")).read().strip()
|
||||
auth = "auth" if self.headers.get("Authorization") else "anon"
|
||||
with open(os.path.join(TMP, "log"), "a") as f: f.write("%s|%s\n" % (self.path.split("?")[0], auth))
|
||||
if not self.path.startswith(RAW):
|
||||
return self._send(404, b'{"message":"Not found."}')
|
||||
if mode == "public": return self._send(200, json.dumps(DOC, ensure_ascii=False).encode())
|
||||
if mode == "private":
|
||||
if auth == "auth": return self._send(200, json.dumps(DOC, ensure_ascii=False).encode())
|
||||
return self._send(404, b"Not found.", "text/plain") # 真 Gitea 對 private repo 就回 404
|
||||
if mode == "500": return self._send(500, b"boom")
|
||||
if mode == "garbage": return self._send(200, b"<html>not json</html>", "text/html")
|
||||
if mode == "notitle": return self._send(200, b'{"ref":"x","set_at":"2026-09-07 07:30"}')
|
||||
if mode == "hang":
|
||||
time.sleep(8); return self._send(200, json.dumps(DOC).encode())
|
||||
return self._send(500, b"unknown mode")
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", PORT), H); srv.daemon_threads = True; srv.serve_forever() # 掛住的那筆不准堵住後面的請求
|
||||
PY
|
||||
PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1])")
|
||||
echo public > "$TMP/mode"; : > "$TMP/log"
|
||||
python3 "$TMP/stub.py" "$TMP" "$PORT" & SRV=$!
|
||||
trap 'kill $SRV 2>/dev/null; rm -rf "$TMP"' EXIT
|
||||
for _ in $(seq 1 50); do python3 -c "import socket;socket.create_connection(('127.0.0.1',$PORT),1)" 2>/dev/null && break; sleep 0.1; done
|
||||
export TICKET_HOST="http://127.0.0.1:$PORT"
|
||||
|
||||
refresh(){ CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$MAINLINE" refresh >"$TMP/refresh.out" 2>"$TMP/refresh.err"; echo $?; }
|
||||
show(){ CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$MAINLINE" 2>&1 | head -1; }
|
||||
inject(){ # 第一則 prompt:UserPromptSubmit 給 countdown-guard.sh,回 additionalContext
|
||||
printf '{"hook_event_name":"UserPromptSubmit","session_id":"s140","transcript_path":""}' \
|
||||
| CLAUDE_PROJECT_DIR="$SHELL_ROOT" bash "$HOOK" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])
|
||||
except Exception: print("")'
|
||||
}
|
||||
raw_hits(){ grep -c '/raw/system-dev/mainline.json|' "$TMP/log" 2>/dev/null || true; }
|
||||
reset(){ rm -f "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"; rm -rf "$SHELL_ROOT/InkStoneCo"; : > "$TMP/log"; }
|
||||
|
||||
echo "── A. 薄殼形狀(兩份都沒有)+ Gitea 讀得到 ⇒ 第一則 prompt 就是現在的主線 ──"
|
||||
reset; echo public > "$TMP/mode"
|
||||
printf '2026-08-25|把管理這條線做對\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due" # 第二基準:那條逾期的舊線
|
||||
OUT=$(inject)
|
||||
case "$OUT" in *"現在沒有主線"*) ok "① refresh 之前:兩份都沒有 ⇒ 🎯「現在沒有主線」(票上撞到的那個畫面,先確認測得到)" ;;
|
||||
*) no "① 前置狀態不對" "$OUT" ;; esac
|
||||
case "$OUT" in *"把管理這條線做對"*) ok "② refresh 之前:⏱ 退到第二基準,印的是逾期的舊線(同上)" ;;
|
||||
*) no "② 前置狀態不對(第二基準沒生效)" "$OUT" ;; esac
|
||||
RC=$(refresh)
|
||||
[ "$RC" = "0" ] && ok "③ mainline refresh 離開碼 0" || no "③ refresh 離開碼 $RC" "$(cat "$TMP/refresh.err")"
|
||||
[ -f "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && ok "④ 家目錄那份被建出來(之後每一則訊息讀它,不再打網路)" \
|
||||
|| no "④ 家目錄沒有檔" "$(ls "$ISEP_COUNTDOWN_STATE_DIR")"
|
||||
OUT=$(show)
|
||||
case "$OUT" in *"Gitea 上那條現在的主線"*) ok "⑤ \`mainline\` 答得出 Gitea 上那條" ;; *) no "⑤ show 沒讀到" "$OUT" ;; esac
|
||||
OUT=$(inject)
|
||||
case "$OUT" in *"🎯 主線:inkstone/Arcrun#48「Gitea 上那條現在的主線」"*) ok "⑥ 第一則 prompt 的 🎯 那行是 Gitea 上那條(不是「沒有主線」)" ;;
|
||||
*) no "⑥ 🎯 沒講對" "$OUT" ;; esac
|
||||
case "$OUT" in *"主線 Gitea 上那條現在的主線 剩"*) ok "⑦ ⏱ 那行掛的是它的名字與期限(被標定的主線蓋過第二基準)" ;;
|
||||
*) no "⑦ ⏱ 沒掛主線" "$OUT" ;; esac
|
||||
case "$OUT" in *"把管理這條線做對"*) no "⑧ 舊線還在畫面上" "$OUT" ;; *) ok "⑧ 那條逾期 341 小時的舊線不再出現" ;; esac
|
||||
FIRST=$(head -1 "$TMP/log")
|
||||
[ "$FIRST" = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json|anon" ] && ok "⑨ 第一個請求匿名、打的是 InkStoneCo main 的 raw 檔(讀不帶憑證)" \
|
||||
|| no "⑨ 第一個請求不對" "$FIRST"
|
||||
[ "$(raw_hits)" = "1" ] && ok "⑩ 只問了一次" || no "⑩ 問了 $(raw_hits) 次" "$(cat "$TMP/log")"
|
||||
[ ! -e "$SHELL_ROOT/InkStoneCo" ] && ok "⑪ 沒有建出 InkStoneCo/(不 clone、不弄髒任何工作樹)" || no "⑪ 動了薄殼底下的樹" "$(ls "$SHELL_ROOT")"
|
||||
grep -q '"refreshed_at"' "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" && ok "⑫ 寫進家目錄的是完整那份(有 set_at/refreshed_at,之後 set_at 規則照用)" \
|
||||
|| no "⑫ 家目錄那份殘缺" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||
|
||||
echo "── F. load()/每一則訊息那條路:零網路 ──"
|
||||
: > "$TMP/log"; show >/dev/null; show >/dev/null; inject >/dev/null
|
||||
[ "$(wc -l < "$TMP/log" | tr -d ' ')" = "0" ] && ok "⑬ show 兩次+UserPromptSubmit 一次,假 Gitea 零請求(關鍵路徑上沒有網路)" \
|
||||
|| no "⑬ 關鍵路徑打了網路" "$(cat "$TMP/log")"
|
||||
RC=$(refresh)
|
||||
[ "$(raw_hits)" = "0" ] && ok "⑭ 家目錄那份已在 ⇒ 再跑 refresh 也不問 Gitea 的檔(優先序:家目錄 > Gitea)" \
|
||||
|| no "⑭ 家目錄在場還去問" "$(cat "$TMP/log")"
|
||||
|
||||
echo "── B. private:匿名優先,被拒才帶 token;沒 token 就退回 ──"
|
||||
reset; echo private > "$TMP/mode"
|
||||
RC=$(refresh); OUT=$(show)
|
||||
case "$OUT" in *"現在沒有主線"*) ok "⑮ 匿名 404、沒 token ⇒ 退回「現在沒有主線」(不編、不炸;離開碼 $RC)" ;;
|
||||
*) no "⑮ 沒 token 卻讀到了?" "$OUT" ;; esac
|
||||
[ "$RC" = "0" ] && ok "⑯ 沒 token 時離開碼仍是 0(SessionStart 不准炸)" || no "⑯ 離開碼 $RC" "$(cat "$TMP/refresh.err")"
|
||||
[ "$(raw_hits)" = "1" ] && ok "⑰ 沒 token 只問一次就收手" || no "⑰ 問了 $(raw_hits) 次" "$(cat "$TMP/log")"
|
||||
reset
|
||||
RC=$(GITEA_TOKEN_CLAUDE_CODE=fake-token-for-test refresh); OUT=$(show)
|
||||
case "$OUT" in *"Gitea 上那條現在的主線"*) ok "⑱ 匿名 404、環境有 token ⇒ 帶 token 再問一次 ⇒ 讀到(真 InkStoneCo 就是 private)" ;;
|
||||
*) no "⑱ 有 token 還是讀不到" "$OUT/$(cat "$TMP/log")" ;; esac
|
||||
[ "$(sed -n 1p "$TMP/log")" = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json|anon" ] \
|
||||
&& [ "$(sed -n 2p "$TMP/log")" = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json|auth" ] \
|
||||
&& ok "⑲ 順序:第一次匿名、第二次才帶 token(不是一上來就實名)" || no "⑲ 順序不對" "$(cat "$TMP/log")"
|
||||
|
||||
echo "── C. 家目錄或 repo 那份任一在場 ⇒ 不問 Gitea ──"
|
||||
reset; echo public > "$TMP/mode"
|
||||
mkdir -p "$SHELL_ROOT/InkStoneCo/system-dev"
|
||||
python3 - "$SHELL_ROOT/InkStoneCo/system-dev/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
json.dump({"ref": "inkstone/ISEP#1", "owner": "inkstone", "repo": "ISEP", "id": 1, "title": "bootstrap 之後 repo 裡那條",
|
||||
"due_on": "2026-09-20T23:59:59Z", "open_issues": 1, "closed_issues": 0, "members": [],
|
||||
"set_at": "2026-09-06 10:00", "refreshed_at": "2026-09-06 10:00"}, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
RC=$(refresh); OUT=$(show)
|
||||
[ "$(raw_hits)" = "0" ] && ok "⑳ repo 那份在 ⇒ 不問 Gitea(優先序:repo > Gitea)" || no "⑳ repo 在場還去問" "$(cat "$TMP/log")"
|
||||
case "$OUT" in *"bootstrap 之後 repo 裡那條"*) ok "㉑ 答的是 repo 那份(0.24.0 的行為一字不改)" ;; *) no "㉑ 沒答 repo 那份" "$OUT" ;; esac
|
||||
[ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && ok "㉒ 只是讀 repo 那份,家目錄沒有被順手建出來(A32 ④ 沒被弄壞)" \
|
||||
|| no "㉒ 家目錄被寫了" "$(ls "$ISEP_COUNTDOWN_STATE_DIR")"
|
||||
|
||||
echo "── D. 讀不到 ⇒ 退回現行行為,不掛、逾時有上限 ──"
|
||||
for mode in 500 garbage notitle; do
|
||||
reset; echo "$mode" > "$TMP/mode"
|
||||
RC=$(refresh); OUT=$(show)
|
||||
if [ "$RC" = "0" ] && [ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ]; then
|
||||
case "$OUT" in *"現在沒有主線"*) ok "㉓ Gitea 回 $mode ⇒ 什麼都不寫、「現在沒有主線」、離開碼 0" ;; *) no "㉓ $mode 之後答錯" "$OUT" ;; esac
|
||||
else no "㉓ $mode 之後狀態不對" "rc=$RC $(ls "$ISEP_COUNTDOWN_STATE_DIR")"; fi
|
||||
done
|
||||
reset; echo hang > "$TMP/mode"
|
||||
T0=$(date +%s); RC=$(GITEA_TOKEN_CLAUDE_CODE=fake refresh); T1=$(date +%s); OUT=$(show)
|
||||
if [ $((T1 - T0)) -le 5 ] && [ "$RC" = "0" ]; then
|
||||
case "$OUT" in *"現在沒有主線"*) ok "㉔ Gitea 掛住(8 秒不回)⇒ $((T1 - T0)) 秒內放棄、退回、離開碼 0(逾時 1 秒 × 最多兩次)" ;; *) no "㉔ 掛住之後答錯" "$OUT" ;; esac
|
||||
else no "㉔ 掛住時沒有在時限內放棄" "用了 $((T1 - T0)) 秒,rc=$RC"; fi
|
||||
reset; echo public > "$TMP/mode"
|
||||
RC=$(TICKET_HOST="http://127.0.0.1:9" refresh); OUT=$(show)
|
||||
case "$OUT" in *"現在沒有主線"*) [ "$RC" = "0" ] && ok "㉕ 連不上 Gitea(TICKET_HOST 指到死位址)⇒ 退回、離開碼 0" || no "㉕ 離開碼 $RC" "$(cat "$TMP/refresh.err")" ;;
|
||||
*) no "㉕ 連不上還答出主線" "$OUT" ;; esac
|
||||
reset
|
||||
RC=$(ISEP_MAINLINE_GITEA_URL=none refresh); OUT=$(show)
|
||||
[ "$(raw_hits)" = "0" ] && case "$OUT" in *"現在沒有主線"*) ok "㉖ ISEP_MAINLINE_GITEA_URL=none ⇒ 零請求(既有測試靠它把外界定住)" ;; *) no "㉖ none 之後答錯" "$OUT" ;; esac \
|
||||
|| no "㉖ none 沒生效" "$(cat "$TMP/log")"
|
||||
[ -z "$(cat "$TMP/refresh.err")" ] && ok "㉗ 以上每一種讀不到,stderr 都是空的(不報錯嚇人)" || no "㉗ stderr 有東西" "$(cat "$TMP/refresh.err")"
|
||||
|
||||
echo "── E. 抓到之後 bootstrap 才出現的 repo 那份:跟 ISEP#130 的 set_at 規則接得起來 ──"
|
||||
reset; echo public > "$TMP/mode"; RC=$(refresh)
|
||||
mkdir -p "$SHELL_ROOT/InkStoneCo/system-dev"
|
||||
cp "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$SHELL_ROOT/InkStoneCo/system-dev/mainline.json" # bootstrap clone 下來的同一份
|
||||
OUT=$(show)
|
||||
case "$OUT" in *"Gitea 上那條現在的主線"*) ok "㉘ bootstrap 之後 repo 那份出現、set_at 相同 ⇒ 仍是同一條(家目錄那份算數)" ;; *) no "㉘ 答錯" "$OUT" ;; esac
|
||||
python3 - "$SHELL_ROOT/InkStoneCo/system-dev/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1])); d["title"] = "後來在別台機器新標的"; d["set_at"] = "2026-09-08 09:00"
|
||||
json.dump(d, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
OUT=$(show)
|
||||
case "$OUT" in *"後來在別台機器新標的"*) ok "㉙ repo 那份 set_at 較新 ⇒ 用 repo 那份(A32 ⑤ 的規則沒被本票弄壞)" ;; *) no "㉙ 沒用較新的" "$OUT" ;; esac
|
||||
|
||||
echo; echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env bash
|
||||
# 主線成員「由相依邊決定,不靠同名里程碑」的迴歸測試(inkstone/ISEP#133 → comment 6743)
|
||||
#
|
||||
# ── 這支在守什麼 ──────────────────────────────────────────────────────
|
||||
# leo 2026-09-07:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone,
|
||||
# 不同 repo 用指針」。上一版 `scripts/mainline` 的成員=「票所屬 milestone 的標題與主線相同(不分 repo)」,
|
||||
# 總管照它做、還在 arcrun-rag 補建了一個同名的。這裡驗票上的三條驗法:
|
||||
# A 成員=hub 里程碑裡的票 + hub 票 + 它們的相依(跨 repo);別 repo 同名里程碑裡的票**不算**
|
||||
# B `adopt` 別 repo 的票 ⇒ 在 hub 票上加相依;**不會**在那個 repo 建同名里程碑;沒有 hub 票時
|
||||
# 擋下並給兩條走得通的路(那條路真的跑一次)
|
||||
# C 三個同名里程碑關掉之後,`mainline show`/`ticket pick`/`has`/focus guard 仍然列得出跨 repo 成員
|
||||
# D `refresh` 讀不到 Gitea ⇒ 成員保留(讀不到 ≠ 沒有成員)
|
||||
# E `milestone-account` 的帳本落在 InkStoneCo/system-dev/,不落在 plugin 快取
|
||||
#
|
||||
# 用法:bash hooks/tests/mainline-members-by-dependency.test.sh
|
||||
# 🔴 全程離線:Gitea 是本機一個假伺服器(TICKET_HOST 指過去),它照真 Gitea 回:相依 POST 201、
|
||||
# 已存在 **500**(2026-09-07 實測,不是 409)、issue 列表用 `milestones=` 名字查。狀態走 ISEP_COUNTDOWN_STATE_DIR、repo 那份走
|
||||
# TMP 底下的假薄殼、HOME 也指到 TMP。不碰真家目錄、不碰真 Gitea、不碰真 InkStoneCo。
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MAINLINE="$ROOT/../scripts/mainline"
|
||||
TICKET="$ROOT/../scripts/ticket"
|
||||
ACCOUNT="$ROOT/../scripts/milestone-account"
|
||||
GUARD="$ROOT/mainline-focus-guard.sh"
|
||||
TMP=$(mktemp -d)
|
||||
export HOME="$TMP/home"; mkdir -p "$HOME"
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/home-state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
export ISEP_COUNTDOWN_NOW=1757203200 # 2026-09-07 00:00 UTC
|
||||
export ISEP_MAINLINE_GITEA_URL=none # 第三來源(ISEP#140)不在這支的範圍
|
||||
export GITEA_TOKEN_CLAUDE_CODE=fake-token-for-test
|
||||
unset ISEP_MAINLINE_REPO_FILE ISEP_MAINLINE_FIXTURE ISEP_MAINLINE_FILE CLAUDE_CODE_CHILD_SESSION
|
||||
SHELL_ROOT="$TMP/shell"; HUB="$SHELL_ROOT/InkStoneCo"; mkdir -p "$HUB/system-dev" # 雲端薄殼的形狀
|
||||
export CLAUDE_PROJECT_DIR="$SHELL_ROOT"
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# ── 假 Gitea:資料在 $TMP/world.json(測試中途可以改),每個請求記一行 `VERB path` 到 $TMP/log ──
|
||||
cat > "$TMP/stub.py" <<'PY'
|
||||
import json, os, re, sys, urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
TMP = sys.argv[1]; PORT = int(sys.argv[2])
|
||||
W = os.path.join(TMP, "world.json")
|
||||
def world(): return json.load(open(W))
|
||||
def save(w): json.dump(w, open(W, "w"), ensure_ascii=False)
|
||||
def ref(r): return r
|
||||
def issue_obj(w, r):
|
||||
o, rest = r.split("/", 1); repo, n = rest.split("#")
|
||||
d = w["issues"][r]
|
||||
ms = d.get("milestone")
|
||||
return {"number": int(n), "title": d.get("title", r), "state": d.get("state", "open"),
|
||||
"labels": [{"name": l} for l in d.get("labels", [])],
|
||||
"assignees": [{"login": a} for a in d.get("assignees", [])],
|
||||
"milestone": (w["milestones"][ms] | {"id": int(ms.split("#")[1])}) if ms else None,
|
||||
"created_at": "2026-09-01T00:00:00Z",
|
||||
"repository": {"full_name": "%s/%s" % (o, repo)}}
|
||||
def ms_obj(w, key):
|
||||
o, rest = key.split("/", 1); repo, mid = rest.split("#")
|
||||
return w["milestones"][key] | {"id": int(mid)}
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
def _send(self, code, body):
|
||||
raw = json.dumps(body, ensure_ascii=False).encode()
|
||||
self.send_response(code); self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw)
|
||||
def _log(self):
|
||||
with open(os.path.join(TMP, "log"), "a") as f: f.write("%s %s\n" % (self.command, self.path))
|
||||
def do_GET(self):
|
||||
self._log(); w = world()
|
||||
if os.path.exists(os.path.join(TMP, "down")): return self._send(500, {"message": "boom"})
|
||||
p, _, qs = self.path.partition("?"); q = urllib.parse.parse_qs(qs)
|
||||
if p == "/api/v1/orgs/inkstone/repos": return self._send(200, [{"name": r} for r in w["repos"]])
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/milestones/(\d+)$", p)
|
||||
if m:
|
||||
key = "%s/%s#%s" % m.groups()
|
||||
return self._send(200, ms_obj(w, key)) if key in w["milestones"] else self._send(404, {"message": "not found"})
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/milestones$", p)
|
||||
if m:
|
||||
pre = "%s/%s#" % m.groups(); st = q.get("state", ["open"])[0]
|
||||
return self._send(200, [ms_obj(w, k) for k in w["milestones"] if k.startswith(pre)
|
||||
and (st == "all" or w["milestones"][k]["state"] == st)])
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues$", p)
|
||||
if m:
|
||||
pre = "%s/%s#" % m.groups(); want = q.get("milestones", [""])[0]; st = q.get("state", ["open"])[0]
|
||||
out = []
|
||||
for r, d in w["issues"].items():
|
||||
if not r.startswith(pre): continue
|
||||
if st != "all" and d.get("state", "open") != st: continue
|
||||
msk = d.get("milestone")
|
||||
if want and (not msk or w["milestones"][msk]["title"] != want): continue
|
||||
out.append(issue_obj(w, r))
|
||||
return self._send(200, out)
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)(/dependencies|/blocks)?$", p)
|
||||
if m:
|
||||
r = "%s/%s#%s" % m.groups()[:3]
|
||||
if r not in w["issues"]: return self._send(404, {"message": "not found"})
|
||||
if not m.group(4): return self._send(200, issue_obj(w, r))
|
||||
if m.group(4) == "/dependencies":
|
||||
# inkstone/ISEP#130 c6973:Gitea 握手逾時時,票本身讀得到、它的 /dependencies 讀不到
|
||||
fo = os.path.join(TMP, "depsfail-once"); ff = os.path.join(TMP, "depsfail")
|
||||
if os.path.exists(fo) and r in open(fo).read().split():
|
||||
os.remove(fo); return self._send(500, {"message": "handshake timed out (test, once)"})
|
||||
if os.path.exists(ff) and r in open(ff).read().split():
|
||||
return self._send(500, {"message": "handshake timed out (test)"})
|
||||
return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])])
|
||||
return self._send(200, [issue_obj(w, pr) for pr, cs in w["deps"].items() if r in cs])
|
||||
return self._send(404, {"message": "not found", "path": p})
|
||||
def do_POST(self):
|
||||
self._log(); w = world()
|
||||
n = int(self.headers.get("Content-Length") or 0); body = json.loads(self.rfile.read(n) or b"{}")
|
||||
p = self.path.partition("?")[0]
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)/dependencies$", p)
|
||||
if m:
|
||||
parent = "%s/%s#%s" % m.groups(); child = "%s/%s#%s" % (body["owner"], body["repo"], body["index"])
|
||||
if child in w["deps"].get(parent, []): return self._send(500, {"message": "", "url": "…/api/swagger"}) # 真 Gitea 2026-09-07 實測:重複相依回 500,不是 409
|
||||
w["deps"].setdefault(parent, []).append(child); save(w)
|
||||
return self._send(201, issue_obj(w, parent))
|
||||
if re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/milestones$", p):
|
||||
w.setdefault("created_milestones", []).append(p); save(w) # 🔴 這條不該被打到
|
||||
return self._send(201, {"id": 999, "title": body.get("title")})
|
||||
return self._send(404, {"message": "not found"})
|
||||
def do_PATCH(self):
|
||||
self._log(); w = world()
|
||||
n = int(self.headers.get("Content-Length") or 0); body = json.loads(self.rfile.read(n) or b"{}")
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)$", self.path.partition("?")[0])
|
||||
if m and "milestone" in body:
|
||||
r = "%s/%s#%s" % m.groups(); key = "%s/%s#%s" % (m.group(1), m.group(2), body["milestone"])
|
||||
w["issues"][r]["milestone"] = key; save(w)
|
||||
return self._send(201, issue_obj(w, r))
|
||||
return self._send(404, {"message": "not found"})
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", PORT), H); srv.daemon_threads = True; srv.serve_forever()
|
||||
PY
|
||||
|
||||
# 世界:一條線「AI 問一次就看得到全部」,錨在 Arcrun#48;三個 repo 各有一個同名的(09-07 現場的形狀)
|
||||
python3 - "$TMP/world.json" <<'PY'
|
||||
import json, sys
|
||||
T = "AI 問一次就看得到全部"
|
||||
w = {
|
||||
"repos": ["Arcrun", "mira", "ISEP", "arcrun-rag", "InkStoneCo"],
|
||||
"milestones": {
|
||||
"inkstone/Arcrun#48": {"title": T, "state": "open", "due_on": "2026-09-12T23:59:59Z", "open_issues": 3, "closed_issues": 0, "description": "兩個目錄讓 AI 查詢"},
|
||||
"inkstone/Arcrun#50": {"title": "AI 問得到內文", "state": "open", "due_on": "2026-08-26T23:59:59Z", "open_issues": 1, "closed_issues": 1},
|
||||
"inkstone/mira#49": {"title": T, "state": "open", "due_on": "2026-09-12T23:59:59Z", "open_issues": 1, "closed_issues": 0},
|
||||
"inkstone/ISEP#60": {"title": T, "state": "open", "due_on": "2026-09-12T23:59:59Z", "open_issues": 1, "closed_issues": 1},
|
||||
"inkstone/arcrun-rag#61": {"title": T, "state": "open", "due_on": "2026-09-12T23:59:59Z", "open_issues": 2, "closed_issues": 0},
|
||||
},
|
||||
"issues": {
|
||||
"inkstone/Arcrun#86": {"milestone": "inkstone/Arcrun#48", "labels": ["p/high", "s/doing"]},
|
||||
"inkstone/Arcrun#176": {"milestone": "inkstone/Arcrun#48", "labels": ["s/todo"]},
|
||||
"inkstone/Arcrun#197": {"milestone": None, "labels": ["s/todo"]},
|
||||
"inkstone/Arcrun#50": {"milestone": "inkstone/Arcrun#50", "labels": ["s/todo"]},
|
||||
"inkstone/mira#6": {"milestone": "inkstone/mira#49", "labels": ["s/todo", "s/doing"]},
|
||||
"inkstone/ISEP#130": {"milestone": "inkstone/ISEP#60", "labels": ["Human", "p/high", "s/todo"]},
|
||||
"inkstone/ISEP#140": {"milestone": None, "labels": ["s/todo"]},
|
||||
"inkstone/arcrun-rag#104": {"milestone": "inkstone/arcrun-rag#61", "labels": ["s/review"], "assignees": ["claude-code"]},
|
||||
"inkstone/arcrun-rag#37": {"milestone": "inkstone/arcrun-rag#61", "labels": ["s/todo"]},
|
||||
"inkstone/InkStoneCo#44": {"milestone": None, "labels": ["s/doing"]},
|
||||
},
|
||||
"deps": {
|
||||
"inkstone/Arcrun#86": ["inkstone/mira#6"], # 09-07 現場:Arcrun#86 已經有跨 repo 相依
|
||||
"inkstone/ISEP#130": ["inkstone/ISEP#140"], # 09-07 現場:#130 等 #140
|
||||
"inkstone/InkStoneCo#44": ["inkstone/arcrun-rag#104"],# 09-07 現場:#44「這張票是那條線本身」
|
||||
},
|
||||
}
|
||||
json.dump(w, open(sys.argv[1], "w"), ensure_ascii=False, indent=1)
|
||||
PY
|
||||
PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1])")
|
||||
: > "$TMP/log"
|
||||
python3 "$TMP/stub.py" "$TMP" "$PORT" & SRV=$!
|
||||
trap 'kill $SRV 2>/dev/null; rm -rf "$TMP"' EXIT
|
||||
for _ in $(seq 1 50); do python3 -c "import socket;socket.create_connection(('127.0.0.1',$PORT),1)" 2>/dev/null && break; sleep 0.1; done
|
||||
export TICKET_HOST="http://127.0.0.1:$PORT"
|
||||
|
||||
ml(){ python3 "$MAINLINE" "$@" 2>&1; }
|
||||
members(){ python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(' '.join(sorted(d.get('members') or [])))" "$ISEP_COUNTDOWN_STATE_DIR/mainline.json"; }
|
||||
world_set(){ python3 - "$TMP/world.json" "$@" <<'PY'
|
||||
import json, sys
|
||||
w = json.load(open(sys.argv[1])); code = sys.argv[2]
|
||||
exec(code)
|
||||
json.dump(w, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
}
|
||||
created_ms(){ python3 -c "import json,sys; print(len(json.load(open(sys.argv[1])).get('created_milestones') or []))" "$TMP/world.json"; }
|
||||
dispatch(){ # 派工單 → "exit|stderr"
|
||||
out=$(python3 -c 'import json,sys;print(json.dumps({"tool_input":{"prompt":sys.argv[1]},"session_id":"t"}))' "$1" \
|
||||
| bash "$GUARD" 2>&1 >/dev/null); rc=$?
|
||||
printf '%s|%s' "$rc" "$out"
|
||||
}
|
||||
|
||||
echo "── A. set:成員=hub 里程碑裡的票 + hub 票 + 它們的相依;同名不算 ──"
|
||||
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||
[ "$RC" = 0 ] && ok "① set --hub 離開碼 0" || no "① set 失敗" "$OUT"
|
||||
M=$(members)
|
||||
if [[ " $M " == *" inkstone/Arcrun#86 "* && " $M " == *" inkstone/Arcrun#176 "* ]]; then ok "② hub 里程碑裡的兩張是成員(Arcrun#86、#176)"; else no "② 里程碑裡的票沒收到" "$M"; fi
|
||||
case "$M" in *"inkstone/mira#6"*) ok "③ mira#6 是成員——因為 Arcrun#86 把它當相依(跨 repo 邊)" ;; *) no "③ 相依沒走到 mira#6" "$M" ;; esac
|
||||
case "$M" in *"inkstone/arcrun-rag#104"*) ok "④ arcrun-rag#104 是成員——因為 hub 票 InkStoneCo#44 把它當相依" ;; *) no "④ hub 票的相依沒收到" "$M" ;; esac
|
||||
case "$M" in *"inkstone/InkStoneCo#44"*) ok "⑤ hub 票自己也是成員" ;; *) no "⑤ hub 票不在成員裡" "$M" ;; esac
|
||||
case "$M" in *"inkstone/ISEP#130"*) no "⑥ ISEP#130 掛在 ISEP 的同名里程碑、沒有任何邊 ⇒ 不該是成員" "$M" ;; *) ok "⑥ ISEP#130 掛在 ISEP 裡**同名**的里程碑但沒有邊 ⇒ 不是成員(同名不算,leo 09-07)" ;; esac
|
||||
case "$M" in *"inkstone/arcrun-rag#37"*) no "⑦ arcrun-rag#37 同名沒邊 ⇒ 不該是成員" "$M" ;; *) ok "⑦ arcrun-rag#37(arcrun-rag 同名里程碑裡、沒邊)也不是" ;; esac
|
||||
case "$M" in *"inkstone/Arcrun#50"*) no "⑧ 別條線的票混進來了" "$M" ;; *) ok "⑧ 別條線(Arcrun#50)不在成員裡" ;; esac
|
||||
grep -q '"hub_ticket": "inkstone/InkStoneCo#44"' "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" && ok "⑨ hub 票寫進 state(之後 adopt 不必再指定)" || no "⑨ hub_ticket 沒寫" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||
grep -q '"hub_ticket": "inkstone/InkStoneCo#44"' "$HUB/system-dev/mainline.json" && ok "⑩ repo 那份(InkStoneCo/system-dev)也一起寫了(ISEP#130 的鏡像沒被弄壞)" || no "⑩ 鏡像沒寫" "$(ls "$HUB/system-dev")"
|
||||
grep -qE 'GET /api/v1/repos/inkstone/(mira|ISEP|arcrun-rag)/issues\?' "$TMP/log" && no "⑪ set 去別的 repo 列同名里程碑的票了" "$(grep issues? "$TMP/log")" || ok "⑪ set 只列 hub 里程碑那一個 repo 的票,不去別的 repo 找同名"
|
||||
OUT=$(ml)
|
||||
case "$OUT" in *"hub 票:inkstone/InkStoneCo#44"*) ok "⑫ show 講得出 hub 票是誰" ;; *) no "⑫ show 沒印 hub 票" "$OUT" ;; esac
|
||||
case "$OUT" in *"Arcrun 2"*"InkStoneCo 1"*"arcrun-rag 1"*"mira 1"*) ok "⑬ show 的成員分佈跨四個 repo(沒有同名里程碑也列得出)" ;; *) no "⑬ show 分佈不對" "$OUT" ;; esac
|
||||
|
||||
echo "── B. adopt:別 repo ⇒ 加相依到 hub 票;不建同名里程碑;沒 hub 票時給走得通的路 ──"
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml adopt inkstone/ISEP#130 --via inkstone/Arcrun#86); RC=$?
|
||||
[ "$RC" = 0 ] && ok "⑭ adopt 別 repo 的票(--via 指到 hub 里程碑裡的一張)離開碼 0" || no "⑭ adopt 失敗" "$OUT"
|
||||
grep -q '^POST /api/v1/repos/inkstone/Arcrun/issues/86/dependencies' "$TMP/log" && ok "⑮ 動作是 POST 相依到 Arcrun#86(Gitea 原生邊)" || no "⑮ 沒有 POST 相依" "$(cat "$TMP/log")"
|
||||
grep -q '^PATCH' "$TMP/log" && no "⑯ 別 repo 的票不該被改 milestone" "$(grep PATCH "$TMP/log")" || ok "⑯ 沒有去改那張票的 milestone(別 repo 的票不掛 hub 里程碑)"
|
||||
[ "$(created_ms)" = 0 ] && ok "⑰ 沒有在 ISEP 建任何里程碑(同名里程碑不是出路)" || no "⑰ 建了里程碑" "$(created_ms)"
|
||||
M=$(members)
|
||||
case "$M" in *"inkstone/ISEP#130"*"inkstone/ISEP#140"*) ok "⑱ 成員多了 ISEP#130,**還有它的相依 ISEP#140**(邊會一路走下去)" ;; *) no "⑱ 成員沒更新到 #130/#140" "$M" ;; esac
|
||||
case "$OUT" in *"沒有多出任何里程碑"*) ok "⑲ 訊息明講那個 repo 沒有多出里程碑" ;; *) no "⑲ 訊息沒講" "$OUT" ;; esac
|
||||
OUT=$(ml adopt inkstone/ISEP#130 --via inkstone/Arcrun#86); RC=$?
|
||||
[ "$RC" = 0 ] && ok "⑳ 同一張再 adopt 一次 ⇒ 仍是離開碼 0(冪等:先讀 /dependencies,邊在就不 POST;真 Gitea 對重複 POST 回 500)" || no "⑳ 重複 adopt 炸了" "$OUT"
|
||||
grep -c '^POST /api/v1/repos/inkstone/Arcrun/issues/86/dependencies' "$TMP/log" | grep -qx 1 && ok "⑳′ 第二次沒有再 POST(判準是 Gitea 有沒有這條邊,不是回應碼)" || no "⑳′ 重複 POST 了" "$(grep POST "$TMP/log")"
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml adopt inkstone/arcrun-rag#37); RC=$?
|
||||
[ "$RC" = 0 ] && grep -q '^POST /api/v1/repos/inkstone/InkStoneCo/issues/44/dependencies' "$TMP/log" \
|
||||
&& ok "㉑ 沒給 --via ⇒ 用 state 裡的 hub 票(InkStoneCo#44)" || no "㉑ 沒用 hub 票" "$RC $OUT $(cat "$TMP/log")"
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml adopt inkstone/Arcrun#197); RC=$?
|
||||
[ "$RC" = 0 ] && grep -q '^PATCH /api/v1/repos/inkstone/Arcrun/issues/197' "$TMP/log" \
|
||||
&& ok "㉒ 同 repo 的票 ⇒ 掛進 hub 里程碑(原生欄位),不加邊" || no "㉒ 同 repo 沒走 PATCH" "$RC $OUT $(cat "$TMP/log")"
|
||||
grep -q '^POST' "$TMP/log" && no "㉓ 同 repo 不該 POST 相依" "$(grep POST "$TMP/log")" || ok "㉓ 同 repo 沒有 POST 相依"
|
||||
# 沒有 hub 票的狀態:直接把 state 裡的 hub_ticket 拿掉
|
||||
python3 - "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$HUB/system-dev/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
for p in sys.argv[1:]:
|
||||
d = json.load(open(p)); d.pop("hub_ticket", None); json.dump(d, open(p, "w"), ensure_ascii=False)
|
||||
PY
|
||||
world_set 'w["deps"]["inkstone/Arcrun#86"].remove("inkstone/ISEP#130")'
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml adopt inkstone/ISEP#130); RC=$?
|
||||
[ "$RC" = 2 ] && ok "㉔ 別 repo 的票、沒 hub 票、沒 --via ⇒ 擋(離開碼 2)" || no "㉔ 沒擋" "$RC $OUT"
|
||||
case "$OUT" in *"--hub"*"--via"*) ok "㉕ 訊息給兩條路:set --hub/adopt --via" ;; *) no "㉕ 沒給路" "$OUT" ;; esac
|
||||
case "$OUT" in *"不是**出路"*|*"不是出路"*) ok "㉖ 訊息明講「開同名里程碑不是出路」" ;; *) no "㉖ 沒講" "$OUT" ;; esac
|
||||
case "$OUT" in *"inkstone/Arcrun#86"*) ok "㉗ 訊息列出 hub 里程碑裡有哪些票可以 --via(不用人自己去翻)" ;; *) no "㉗ 沒列候選" "$OUT" ;; esac
|
||||
[ "$(created_ms)" = 0 ] && ok "㉘ 擋下時也沒建里程碑" || no "㉘ 建了" "$(created_ms)"
|
||||
# 出路真的跑一次:把訊息裡 `adopt … --via <owner/repo#N>` 那行抽出來、填一格、執行
|
||||
LINE=$(printf '%s\n' "$OUT" | grep -E '^\s+\S+ adopt inkstone/ISEP#130 --via ' | head -1 | sed 's/<owner\/repo#N>/inkstone\/Arcrun#86/')
|
||||
OUT2=$(eval "$LINE" 2>&1); RC2=$?
|
||||
[ "$RC2" = 0 ] && ok "㉙ 訊息裡那條出路原樣貼、只填一格 ⇒ 真的跑得通(離開碼 0)" || no "㉙ 出路跑不動" "LINE=$LINE → $RC2 $OUT2"
|
||||
|
||||
echo "── C. 三個同名里程碑關掉之後(票上驗法第 1 條)──"
|
||||
world_set '
|
||||
for k in ("inkstone/mira#49","inkstone/ISEP#60","inkstone/arcrun-rag#61"): w["milestones"][k]["state"]="closed"
|
||||
for r in ("inkstone/mira#6","inkstone/ISEP#130","inkstone/arcrun-rag#104","inkstone/arcrun-rag#37"): w["issues"][r]["milestone"]=None
|
||||
'
|
||||
python3 - "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1])); d["members"] = []; d["hub_ticket"] = "inkstone/InkStoneCo#44"; json.dump(d, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
rm -f "$HUB/system-dev/mainline.json"
|
||||
: > "$TMP/log"
|
||||
OUT=$(ml refresh); RC=$?
|
||||
[ "$RC" = 0 ] && [ "$(members)" = "" ] && ! grep -q dependencies "$TMP/log" \
|
||||
&& ok "㉚ 光 refresh(SessionStart 那條)不走相依(成本不變:真 Gitea 25 張要 19 秒)" || no "㉚ refresh 走了相依或炸了" "$RC $OUT $(cat "$TMP/log")"
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
[ "$RC" = 0 ] && ok "㉚′ refresh --members 離開碼 0" || no "㉚′ refresh --members 失敗" "$OUT"
|
||||
M=$(members)
|
||||
for r in inkstone/mira#6 inkstone/ISEP#130 inkstone/ISEP#140 inkstone/arcrun-rag#104 inkstone/arcrun-rag#37; do
|
||||
case " $M " in *" $r "*) ok "㉛ 同名里程碑關掉、票也不掛任何里程碑之後,$r 仍是成員(靠邊,不靠名字)" ;; *) no "㉛ $r 掉出主線了" "$M" ;; esac
|
||||
done
|
||||
OUT=$(ml)
|
||||
case "$OUT" in *"mira 1"*"ISEP 2"*|*"ISEP 2"*"mira 1"*) ok "㉜ mainline show 列得出跨 repo 成員" ;; *) no "㉜ show 沒列出" "$OUT" ;; esac
|
||||
OUT=$(python3 "$TICKET" pick --mainline "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" --all --json 2>&1); RC=$?
|
||||
REFS=$(printf '%s' "$OUT" | python3 -c "import json,sys; print(' '.join(r['ref'] for r in json.load(sys.stdin) if r['group']=='主線'))" 2>/dev/null)
|
||||
case " $REFS " in *" inkstone/ISEP#140 "*) ok "㉝ ticket pick 主線組抓得到 ISEP#140(沒里程碑、靠相依在線上、s/todo 沒人)" ;; *) no "㉝ pick 抓不到 ISEP#140" "$OUT" ;; esac
|
||||
case " $REFS " in *" inkstone/arcrun-rag#37 "*) ok "㉞ ticket pick 也抓得到 arcrun-rag#37(同名里程碑關掉後靠 hub 票的邊)" ;; *) no "㉞ pick 抓不到 arcrun-rag#37" "$OUT" ;; esac
|
||||
case " $REFS " in *"ISEP#130"*|*"mira#6"*|*"arcrun-rag#104"*) no "㉟ Human/s/doing/有人的票被抓走了" "$REFS" ;; *) ok "㉟ ISEP#130(Human)、mira#6(s/doing)、arcrun-rag#104(有人)照舊不被抓" ;; esac
|
||||
OUT=$(ml has inkstone/ISEP#140); RC=$?
|
||||
[ "$RC" = 0 ] && ok "㊰ has ISEP#140 ⇒ 在(快取)" || no "has ISEP#140 說不在" "$OUT"
|
||||
python3 - "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1])); d["members"] = ["inkstone/Arcrun#86", "inkstone/Arcrun#176"]; json.dump(d, open(sys.argv[1], "w"), ensure_ascii=False)
|
||||
PY
|
||||
OUT=$(ml has inkstone/mira#6); RC=$?
|
||||
[ "$RC" = 0 ] && ok "㊱ 快取裡沒有 mira#6 ⇒ has 去問「誰把它當相依」(/blocks)⇒ 在" || no "㊱ has 靠 blocks 判不出來" "$RC $OUT"
|
||||
OUT=$(ml has inkstone/Arcrun#50); RC=$?
|
||||
[ "$RC" = 1 ] && ok "㊲ has Arcrun#50(別條線、沒人指著它)⇒ 不在(離開碼 1)" || no "㊲ has Arcrun#50 判錯" "$RC $OUT"
|
||||
case "$OUT" in *"adopt inkstone/Arcrun#50"*) ok "㊳ 不在時給的出路是 adopt(不是去開同名里程碑)" ;; *) no "㊳ 出路不對" "$OUT" ;; esac
|
||||
|
||||
echo "── C′. focus guard 的新鮮查詢:問 milestone + /blocks,不問標題 ──"
|
||||
: > "$TMP/log"
|
||||
R=$(dispatch '【工單】inkstone/mira#6')
|
||||
[ "${R%%|*}" = 0 ] && ok "㊴ 派快取裡沒有、但被 Arcrun#86 當相依的 mira#6 ⇒ 放行(靠 /blocks 學到)" || no "㊴ 誤攔 mira#6" "$R"
|
||||
grep -q 'issues/6/blocks' "$TMP/log" && ok "㊵ 閘真的問了 /blocks" || no "㊵ 沒問 /blocks" "$(cat "$TMP/log")"
|
||||
grep -q 'inkstone/mira#6' "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" && ok "㊶ 學到的成員寫回快取(下次不必再問)" || no "㊶ 沒寫回" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||
R=$(dispatch '【工單】inkstone/Arcrun#50')
|
||||
[ "${R%%|*}" = 2 ] && ok "㊷ 派別條線的 Arcrun#50 ⇒ 擋一次" || no "㊷ 沒擋" "$R"
|
||||
case "${R#*|}" in *"不要**去那個 repo 開同名里程碑"*|*"不要"*"同名里程碑"*) ok "㊸ 擋下時的補收出路明講「不要去開同名里程碑」" ;; *) no "㊸ 出路沒講" "${R#*|}" ;; esac
|
||||
world_set 'w["milestones"]["inkstone/mira#49"]["state"]="open"; w["issues"]["inkstone/mira#5"]={"milestone":"inkstone/mira#49","labels":["s/todo"]}'
|
||||
R=$(dispatch '【工單】inkstone/mira#5')
|
||||
[ "${R%%|*}" = 2 ] && ok "㊹ 派一張掛在 mira **同名**里程碑、沒有任何邊的票 ⇒ 擋一次(同名不是主線)" || no "㊹ 同名被當成主線放行了" "$R"
|
||||
R=$(dispatch '【工單】inkstone/mira#5')
|
||||
[ "${R%%|*}" = 0 ] && ok "㊺ 重送 ⇒ 放行(至多擋一次)" || no "㊺ 鬼打牆" "$R"
|
||||
|
||||
echo "── D. refresh 讀不到 Gitea ⇒ 成員保留 ──"
|
||||
BEFORE=$(members); touch "$TMP/down"
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
[ "$RC" = 0 ] && [ "$(members)" = "$BEFORE" ] && ok "㊻ Gitea 回 500 ⇒ refresh --members 不炸、成員一張不少(讀不到 ≠ 沒有成員)" || no "㊻ 成員被清掉或炸了" "$RC $OUT / $(members)"
|
||||
rm -f "$TMP/down"
|
||||
|
||||
echo "── F. 讀不到任何一條 /dependencies ⇒ 不准寫出一份看起來完整的名單(inkstone/ISEP#130 → c6973)──"
|
||||
jget(){ python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get(sys.argv[2]); print(v if not isinstance(v,list) else ' '.join(v))" "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$1"; }
|
||||
export ISEP_MEMBER_BACKOFF=0
|
||||
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
|
||||
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||
[ "$RC" != 0 ] && ok "F1 hub 票的 /dependencies 讀不到 ⇒ set 離開碼非 0(實際 $RC)" || no "F1 set 當成成功" "$RC $OUT"
|
||||
printf '%s' "$OUT" | grep -q '✅' && no "F2 名單不完整卻印 ✅(c6973 重現的就是這個)" "$OUT" || ok "F2 名單不完整時不印 ✅"
|
||||
case "$OUT" in *"成員不完整"*"inkstone/InkStoneCo#44"*) ok "F3 訊息講出「成員不完整」並點名讀不到的是哪一張" ;; *) no "F3 沒講不完整/沒點名" "$OUT" ;; esac
|
||||
[ "$(jget members_complete)" = "False" ] && ok "F4 寫出去的主線檔標 members_complete=false(讀檔的人也看得出)" || no "F4 檔案沒標不完整" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||
case " $(jget members_unread) " in *" inkstone/InkStoneCo#44 "*) ok "F5 members_unread 列出讀不到的票" ;; *) no "F5 沒列" "$(jget members_unread)" ;; esac
|
||||
[ "$(jget ref)" = "inkstone/Arcrun#48" ] && ok "F6 主線本身照樣標定(壞的只有成員那一格)" || no "F6 主線沒標上" "$(jget ref)"
|
||||
OUT=$(python3 "$TICKET" pick --mainline "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" 2>&1); RC=$?
|
||||
[ "$RC" != 1 ] && ok "F7 同樣讀不到時 ticket pick 不回離開碼 1(不會讓 queue-drain-guard 當成抓盡;實際 $RC)" || no "F7 pick 回了「抓盡」" "$OUT"
|
||||
rm -f "$TMP/depsfail"
|
||||
|
||||
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail-once"
|
||||
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '✅' && ok "F8 只逾時一次 ⇒ 重讀一次就完整,set 照常 ✅" || no "F8 沒重試" "$RC $OUT"
|
||||
case " $(members) " in *" inkstone/arcrun-rag#104 "*) ok "F9 重讀後 hub 票底下的 arcrun-rag#104 收到了" ;; *) no "F9 還是漏收" "$(members)" ;; esac
|
||||
[ "$(jget members_complete)" = "True" ] && ok "F10 完整時 members_complete=true" || no "F10 完整卻沒標" "$(jget members_complete)"
|
||||
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
case "$OUT" in *"成員"*"張"*) ok "F11 refresh --members 讀得到時會印出成員張數(c6973:原本零輸出)" ;; *) no "F11 refresh --members 還是零輸出" "$OUT" ;; esac
|
||||
BEFORE=$(members)
|
||||
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
|
||||
OUT=$(ml refresh --members); RC=$?
|
||||
[ "$RC" = 0 ] && ok "F12 refresh --members 讀不到也不炸(SessionStart 那條不准炸)" || no "F12 炸了" "$RC $OUT"
|
||||
[ "$(members)" = "$BEFORE" ] && ok "F13 不完整時保留舊快取,不拿殘缺名單蓋掉" || no "F13 快取被蓋掉" "$BEFORE → $(members)"
|
||||
case "$OUT" in *"成員不完整"*) ok "F14 refresh --members 講出成員不完整" ;; *) no "F14 靜默" "$OUT" ;; esac
|
||||
rm -f "$TMP/depsfail"
|
||||
unset ISEP_MEMBER_BACKOFF
|
||||
|
||||
echo "── E. milestone-account 的帳本落在會被 clone 走的地方(票上驗法第 3 條)──"
|
||||
OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$ACCOUNT" ledger-path 2>&1)
|
||||
[ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊼ 雲端薄殼形狀(\$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/)⇒ 帳本在 InkStoneCo/system-dev/estimates/" || no "㊼ 帳本路徑不對" "$OUT"
|
||||
OUT=$(cd "$HUB" && env -u CLAUDE_PROJECT_DIR python3 "$ACCOUNT" ledger-path 2>&1)
|
||||
[ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊽ cwd 就是 InkStoneCo ⇒ 同一個地方" || no "㊽ cwd 形狀不對" "$OUT"
|
||||
# plugin 快取的形狀:原始碼被複製到一個沒有 .git 的目錄(claude plugin update 會整個換掉)
|
||||
CACHE="$TMP/plugins/cache/inkstone/isep/9.9.9"; mkdir -p "$CACHE/scripts" "$CACHE/hooks/lib" "$CACHE/system-dev"
|
||||
cp "$ACCOUNT" "$CACHE/scripts/"; cp "$ROOT/lib/mainline.py" "$CACHE/hooks/lib/"
|
||||
EMPTY="$TMP/shell-empty"; mkdir -p "$EMPTY"
|
||||
OUT=$(cd "$EMPTY" && CLAUDE_PROJECT_DIR="$EMPTY" python3 "$CACHE/scripts/milestone-account" ledger-path 2>&1)
|
||||
case "$OUT" in "$CACHE"*) no "㊾ 帳本落進 plugin 快取(下一版就不見)" "$OUT" ;; "$HOME/.claude/isep-estimates/ledger.jsonl") ok "㊾ 專案裡沒有 system-dev、原始碼又在沒 .git 的快取 ⇒ 帳本退到家目錄,**不落快取**" ;; *) no "㊾ 路徑不明" "$OUT" ;; esac
|
||||
mkdir -p "$CACHE/.git"
|
||||
OUT=$(cd "$EMPTY" && CLAUDE_PROJECT_DIR="$EMPTY" python3 "$CACHE/scripts/milestone-account" ledger-path 2>&1)
|
||||
[ "$OUT" = "$CACHE/system-dev/estimates/ledger.jsonl" ] && ok "㊿ 同一棵樹有 .git(真的 checkout,dogfood 的形狀)⇒ 才准用它的 system-dev/" || no "㊿ checkout 形狀不對" "$OUT"
|
||||
|
||||
echo; echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# 主線那個檔「隨 repo 走」的迴歸測試(inkstone/ISEP#130)
|
||||
#
|
||||
# ── 這支在守什麼 ──────────────────────────────────────────────────────
|
||||
# 主線原本只住在家目錄(`~/.claude/isep-countdown/mainline.json`)。雲端是另一台機器,
|
||||
# 家目錄空的、clone 下來的 repo 才是它拿得到的東西 ⇒ 2026-09-05 leo:
|
||||
# 「routine 每天早上開啟後抓不到任務」。PR inkstone/InkStoneCo#118 把主線檔放進
|
||||
# `InkStoneCo/system-dev/mainline.json`,這裡驗 ISEP 這一半:
|
||||
# A 讀:家目錄沒有 ⇒ 讀 repo 那份(雲端的形狀:$CLAUDE_PROJECT_DIR 是薄殼根,真身在 InkStoneCo/)
|
||||
# B 兩份都在 ⇒ `set_at` 較新的算數,不分本機雲端
|
||||
# C 寫:`set`/`adopt`/`clear` 兩份一起動;`refresh` 那種只寫家目錄(不弄髒 InkStoneCo 工作樹)
|
||||
# D 哪裡都沒有 ⇒ 仍然答得出「現在沒有主線」(ISEP#82 驗收第 4 條沒有被本票弄壞)
|
||||
#
|
||||
# 用法:bash hooks/tests/mainline-repo-mirror.test.sh
|
||||
# 🔴 全程離線:狀態走 ISEP_COUNTDOWN_STATE_DIR、repo 那份走 TMP 底下的假 InkStoneCo、
|
||||
# TICKET_HOST 指到連不上的位址。不碰真家目錄、不碰真 InkStoneCo。
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MAINLINE="$ROOT/../scripts/mainline"
|
||||
LIB="$ROOT/lib/mainline.py"
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/home-state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
export TICKET_HOST="http://127.0.0.1:9"
|
||||
unset ISEP_MAINLINE_REPO_FILE ISEP_MAINLINE_FIXTURE
|
||||
SHELL_ROOT="$TMP/shell"; HUB="$SHELL_ROOT/InkStoneCo"; mkdir -p "$HUB/system-dev"
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
write_ms(){ # write_ms <檔> <標題> <set_at>
|
||||
python3 - "$1" "$2" "$3" <<'PY'
|
||||
import json, sys
|
||||
json.dump({"ref": "inkstone/Arcrun#48", "owner": "inkstone", "repo": "Arcrun", "id": 48,
|
||||
"title": sys.argv[2], "description": "測試用。", "due_on": "2026-09-13T23:59:59Z",
|
||||
"open_issues": 3, "closed_issues": 0, "members": ["inkstone/ISEP#130"],
|
||||
"set_at": sys.argv[3], "refreshed_at": sys.argv[3]},
|
||||
open(sys.argv[1], "w"), ensure_ascii=False, indent=2)
|
||||
PY
|
||||
}
|
||||
show(){ CLAUDE_PROJECT_DIR="$1" python3 "$MAINLINE" 2>&1 | head -1; }
|
||||
|
||||
echo "── A. 家目錄沒有 ⇒ 讀 repo 那份 ──"
|
||||
write_ms "$HUB/system-dev/mainline.json" "雲端才讀得到的那一條" "2026-09-07 07:30"
|
||||
OUT=$(show "$SHELL_ROOT")
|
||||
case "$OUT" in *"雲端才讀得到的那一條"*) ok "① \$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/(雲端的形狀)⇒ 答得出主線" ;;
|
||||
*) no "① 薄殼根形狀讀不到 repo 那份" "$OUT" ;; esac
|
||||
OUT=$(show "$HUB")
|
||||
case "$OUT" in *"雲端才讀得到的那一條"*) ok "② \$CLAUDE_PROJECT_DIR 直接是 InkStoneCo ⇒ 也答得出" ;;
|
||||
*) no "② InkStoneCo 根讀不到" "$OUT" ;; esac
|
||||
OUT=$(cd "$HUB" && env -u CLAUDE_PROJECT_DIR python3 "$MAINLINE" 2>&1 | head -1)
|
||||
case "$OUT" in *"雲端才讀得到的那一條"*) ok "③ 沒設 \$CLAUDE_PROJECT_DIR、只是 cwd 在 InkStoneCo ⇒ 也答得出" ;;
|
||||
*) no "③ cwd 形狀讀不到" "$OUT" ;; esac
|
||||
[ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && ok "④ 只是讀,家目錄那份沒有被順手建出來" \
|
||||
|| no "④ 讀的時候寫了家目錄" "$(ls "$ISEP_COUNTDOWN_STATE_DIR")"
|
||||
|
||||
echo "── B. 兩份都在 ⇒ set_at 較新的算數 ──"
|
||||
write_ms "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "本機舊的" "2026-09-01 10:00"
|
||||
OUT=$(show "$SHELL_ROOT")
|
||||
case "$OUT" in *"雲端才讀得到的那一條"*) ok "⑤ repo 那份較新(別台機器後標的)⇒ 用 repo 那份" ;;
|
||||
*) no "⑤ 沒用較新的 repo 那份" "$OUT" ;; esac
|
||||
write_ms "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "本機新的" "2026-09-08 09:00"
|
||||
OUT=$(show "$SHELL_ROOT")
|
||||
case "$OUT" in *"本機新的"*) ok "⑥ 家目錄那份較新 ⇒ 用家目錄那份" ;;
|
||||
*) no "⑥ 沒用較新的家目錄那份" "$OUT" ;; esac
|
||||
write_ms "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "同時標的(家目錄)" "2026-09-07 07:30"
|
||||
OUT=$(show "$SHELL_ROOT")
|
||||
case "$OUT" in *"同時標的(家目錄)"*) ok "⑦ 一樣新 ⇒ 家目錄那份(它才有 refresh 過的進度)" ;;
|
||||
*) no "⑦ 平手沒選家目錄" "$OUT" ;; esac
|
||||
|
||||
echo "── C. 寫:誰該動 repo 那份 ──"
|
||||
R=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 - "$LIB" "$HUB/system-dev/mainline.json" <<'PY'
|
||||
import importlib.util, json, os, sys
|
||||
spec = importlib.util.spec_from_file_location("ml", sys.argv[1]); ML = importlib.util.module_from_spec(spec); spec.loader.exec_module(ML)
|
||||
rp = sys.argv[2]
|
||||
before = open(rp).read()
|
||||
d = ML.load(); d["refreshed_at"] = "2026-09-09 00:00"; d["open_issues"] = 1
|
||||
ML.save(d) # refresh 的形狀:只寫家目錄
|
||||
r1 = open(rp).read() == before
|
||||
d["title"] = "set 之後的新主線"; d["set_at"] = "2026-09-09 00:01"
|
||||
ML.save(d, mirror=True) # set/adopt 的形狀:兩份一起
|
||||
r2 = json.load(open(rp)).get("title") == "set 之後的新主線"
|
||||
r3 = json.load(open(ML.path())).get("title") == "set 之後的新主線"
|
||||
r4 = open(rp).read().endswith("\n")
|
||||
print("|".join(str(x) for x in (r1, r2, r3, r4)))
|
||||
PY
|
||||
)
|
||||
[ "${R%%|*}" = "True" ] && ok "⑧ save()(refresh 的形狀)不碰 repo 那份——不弄髒 InkStoneCo 工作樹" || no "⑧ refresh 動了 repo 那份" "$R"
|
||||
R2=${R#*|}; [ "${R2%%|*}" = "True" ] && ok "⑨ save(mirror=True)(set/adopt 的形狀)寫進 repo 那份" || no "⑨ mirror 沒寫 repo" "$R"
|
||||
R3=${R2#*|}; [ "${R3%%|*}" = "True" ] && ok "⑩ 同一次也寫了家目錄那份" || no "⑩ 家目錄沒寫" "$R"
|
||||
[ "${R##*|}" = "True" ] && ok "⑪ repo 那份收尾有換行(diff 才不會每次多一行 no newline)" || no "⑪ 沒換行" "$R"
|
||||
|
||||
OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$MAINLINE" clear 2>&1)
|
||||
if [ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && [ ! -e "$HUB/system-dev/mainline.json" ]; then
|
||||
ok "⑫ clear ⇒ 兩份都拿掉(否則下次讀又從 repo 那份長回來)"
|
||||
else no "⑫ clear 沒清乾淨" "$(ls "$ISEP_COUNTDOWN_STATE_DIR" "$HUB/system-dev" 2>&1 | tr '\n' ' ')"; fi
|
||||
case "$OUT" in *"repo 那份也刪了"*) ok "⑬ clear 講得出 repo 那份也動了(要 commit 才到雲端)" ;; *) no "⑬ clear 沒講 repo 那份" "$OUT" ;; esac
|
||||
|
||||
# set 要打網路,這裡只驗它印出來的那段「repo 那份寫了沒」是接在 repo_path 上的
|
||||
R=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 - "$LIB" "$HUB" <<'PY'
|
||||
import importlib.util, os, sys
|
||||
spec = importlib.util.spec_from_file_location("ml", sys.argv[1]); ML = importlib.util.module_from_spec(spec); spec.loader.exec_module(ML)
|
||||
print(ML.repo_path(for_write=True) == os.path.join(sys.argv[2], "system-dev", "mainline.json"))
|
||||
PY
|
||||
)
|
||||
[ "$R" = "True" ] && ok "⑭ 檔還不存在時,寫的目標仍指到 InkStoneCo/system-dev/(set 建得出它)" || no "⑭ 寫的目標不對" "$R"
|
||||
|
||||
echo "── D. 哪裡都沒有 ⇒ 仍然答得出「現在沒有主線」 ──"
|
||||
OUT=$(show "$SHELL_ROOT")
|
||||
case "$OUT" in *"現在沒有主線"*) ok "⑮ 兩份都沒有 ⇒ 「現在沒有主線」(ISEP#82 第 4 條沒被弄壞)" ;; *) no "⑮ 沒主線時答不出來" "$OUT" ;; esac
|
||||
OUT=$(ISEP_MAINLINE_REPO_FILE=none show "$SHELL_ROOT")
|
||||
case "$OUT" in *"現在沒有主線"*) ok "⑯ ISEP_MAINLINE_REPO_FILE=none ⇒ 不看 repo(既有測試靠它把外界定住)" ;; *) no "⑯ none 沒生效" "$OUT" ;; esac
|
||||
printf 'not json' > "$HUB/system-dev/mainline.json"
|
||||
OUT=$(show "$SHELL_ROOT")
|
||||
case "$OUT" in *"現在沒有主線"*) ok "⑰ repo 那份壞掉 ⇒ 當成沒有,不炸" ;; *) no "⑰ 壞檔炸了" "$OUT" ;; esac
|
||||
|
||||
echo; echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+260
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env bash
|
||||
# 催辦員的迴歸測試(inkstone/ISEP#93)
|
||||
#
|
||||
# 對應票上那四條驗收:
|
||||
# 1 撈得出逾期的 milestone,而且訊息是白話的 → A 群
|
||||
# 2 沒東西可報時要說「查過了,沒有」 → B 群
|
||||
# 3 開一個新 session 要自己跑過一次 → E 群(hook 的注入)
|
||||
# 4 Telegram 真的收得到 → D 群只驗得了「發不出去不會安靜」
|
||||
# (「leo 手機上真的出現」機器驗不了,那一格由人驗,見 docs/TESTING.md)
|
||||
#
|
||||
# 另外兩群是這支的**主要失效模式**:
|
||||
# C 群 不該叫的不要叫 —— 誤報比漏報更該修:每次開場都被念一串不相干的東西,
|
||||
# 人就學會跳過它,那時真的有事也叫不動(本 repo 心法第 2 條)
|
||||
# D 群 發不出去不會安靜 —— 「送出成功」跟「送到了」是兩件事
|
||||
#
|
||||
# 🔴 全程離線:資料走 --fixture、時鐘走 --now、網路用 ISEP_NOTIFY_OFFLINE=1 關掉。
|
||||
# 不打 Gitea、不打實例、不留任何測試票。
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
NAG="$ROOT/scripts/isep-nag"
|
||||
export NOTIFY_PATH="$ROOT/scripts/isep-notify"
|
||||
NOTIFY="$ROOT/scripts/isep-notify"
|
||||
HOOK="$ROOT/hooks/overdue-nag-guard.sh"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export ISEP_NOTIFY_STATE_DIR="$TMP/state"
|
||||
export ISEP_NAG_STATE_DIR="$TMP/state"
|
||||
export ISEP_NOTIFY_OFFLINE=1
|
||||
mkdir -p "$ISEP_NOTIFY_STATE_DIR"
|
||||
|
||||
# 🔴 明著清掉:測試常常跑在一條 subagent 裡,而 hook 刻意放行子 session
|
||||
# ⇒ 不清掉的話 E 群會全綠,而且是**假綠**(countdown-guard.test.sh 撞過同一個坑)。
|
||||
export CLAUDE_CODE_CHILD_SESSION=
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
has(){ printf '%s' "$1" | grep -q -- "$2"; }
|
||||
|
||||
# 定住的時鐘:2026-08-28 00:00 UTC(=台北 08:00),跟票上那天同一天
|
||||
NOW=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))')
|
||||
|
||||
# ── 測資:票上點名的那五個逾期 milestone,加上一整組「不該叫」的對照 ──────
|
||||
cat > "$TMP/full.json" <<'JSON'
|
||||
{"milestones": [
|
||||
{"repo":"inkstone/arcrun-rag","title":"不必開瀏覽器,也不怕反悔","due_on":"2026-08-24T23:59:59Z","open_issues":2,"closed_issues":0},
|
||||
{"repo":"inkstone/Arcrun","title":"App 打開來就是 Arcrun,而且回得去","due_on":"2026-08-24T23:59:59Z","open_issues":3,"closed_issues":1},
|
||||
{"repo":"inkstone/ISEP","title":"把管理這條線做對","due_on":"2026-08-24T23:59:59Z","open_issues":14,"closed_issues":1},
|
||||
{"repo":"inkstone/Arcrun","title":"AI 問得到內文,不是只查到檔名","due_on":"2026-08-26T23:59:59Z","open_issues":1,"closed_issues":1},
|
||||
{"repo":"inkstone/Arcrun","title":"寫進去,就查得到","due_on":"2026-08-26T23:59:59Z","open_issues":1,"closed_issues":6},
|
||||
{"repo":"inkstone/ISEP","title":"還沒到期的","due_on":"2026-09-30T23:59:59Z","open_issues":9,"closed_issues":0},
|
||||
{"repo":"inkstone/ISEP","title":"逾期但票都關完了","due_on":"2026-08-01T23:59:59Z","open_issues":0,"closed_issues":7},
|
||||
{"repo":"inkstone/ISEP","title":"沒設期限的","due_on":null,"open_issues":5,"closed_issues":0}
|
||||
],
|
||||
"issues": [
|
||||
{"number":138,"title":"App 沒辦法說「我要接哪些外部事件」","updated_at":"2026-08-17T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/Arcrun"},"labels":[{"name":"Human"}],"assignees":[]},
|
||||
{"number":51,"title":"leo21c 上有一批舊資源還在","updated_at":"2026-08-20T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/InkStoneCo"},"labels":[],"assignees":[{"login":"leo"}]},
|
||||
{"number":38,"title":"辨識碼信在 stage 寄不出去","updated_at":"2026-08-12T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/arcrun-rag"},"labels":[{"name":"s/doing"},{"name":"p/high"}],"assignees":[]},
|
||||
{"number":999,"title":"昨天才動過的 s/doing——還在做,不要叫","updated_at":"2026-08-27T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/ISEP"},"labels":[{"name":"s/doing"}],"assignees":[]},
|
||||
{"number":998,"title":"這是一個 PR,pr-verdict-guard 在管","updated_at":"2026-08-01T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/ISEP"},"labels":[{"name":"s/review"}],"assignees":[],
|
||||
"pull_request":{"merged":false}},
|
||||
{"number":997,"title":"舊 org 的同一張票(重複,不是兩件事)","updated_at":"2026-08-01T00:00:00Z",
|
||||
"repository":{"full_name":"Leo/arcrun-rag"},"labels":[{"name":"s/doing"}],"assignees":[]},
|
||||
{"number":996,"title":"沒有狀態標籤,也沒人等——不是誰的事","updated_at":"2026-07-01T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/ISEP"},"labels":[],"assignees":[]}
|
||||
]}
|
||||
JSON
|
||||
echo '{"milestones": [], "issues": []}' > "$TMP/empty.json"
|
||||
|
||||
R=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" 2>&1)
|
||||
S=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" --short 2>&1)
|
||||
J=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" --json 2>&1)
|
||||
|
||||
echo "── A 群:撈得出票上那五個逾期 milestone,而且是白話 ──────────────"
|
||||
has "$R" "逾期的 milestone(5 個)" && ok "① 五個都撈到了(票上驗收 1)" \
|
||||
|| no "① 五個都撈到了" "實得:$(printf '%s' "$R" | head -3 | tr '\n' ' ')"
|
||||
n=0
|
||||
for t in "不必開瀏覽器" "App 打開來就是 Arcrun" "把管理這條線做對" "AI 問得到內文" "寫進去,就查得到"; do
|
||||
has "$R" "$t" && n=$((n+1))
|
||||
done
|
||||
[ "$n" = 5 ] && ok "② 五個名字一個不漏" || no "② 五個名字一個不漏" "只對上 $n 個"
|
||||
has "$R" "逾期 3 天" && has "$R" "逾期 1 天" \
|
||||
&& ok "③ 逾期幾天算得對(08-24→3 天、08-26→1 天)" \
|
||||
|| no "③ 逾期幾天算得對" "實得:$(printf '%s' "$R" | grep -o '逾期 [0-9]* 天' | tr '\n' ' ')"
|
||||
has "$R" "還有 14 張票沒關" && ok "④ 講得出「還有幾張票沒關」(這才是他要判斷的)" \
|
||||
|| no "④ 還有幾張票沒關" "沒出現"
|
||||
has "$R" "對你意味著" && ok "⑤ 每一段都講「這對你意味什麼」(白話鐵律)" \
|
||||
|| no "⑤ 對你意味著" "整份報告沒有一句在講後果"
|
||||
has "$R" "等了 11 天" && ok "⑥ 等 leo 的票標出等了幾天(規則五:不標天數=不會被催)" \
|
||||
|| no "⑥ 等了幾天" "實得:$(printf '%s' "$R" | grep -o '等了 [0-9]* 天' | tr '\n' ' ')"
|
||||
has "$S" "回「看」" && ok "⑦ 短版給得出一個可以回的詞(leo 回一個詞就能處理)" \
|
||||
|| no "⑦ 可回的詞" "短版:$S"
|
||||
[ "$(printf '%s' "$S" | wc -l)" -le 6 ] && ok "⑧ 短版夠短(手機一眼掃完)" \
|
||||
|| no "⑧ 短版夠短" "$(printf '%s' "$S" | wc -l) 行"
|
||||
|
||||
echo "── B 群:沒東西可報時,要說「查過了,沒有」────────────────────────"
|
||||
E=$(python3 "$NAG" --fixture "$TMP/empty.json" --now "$NOW" 2>&1)
|
||||
ES=$(python3 "$NAG" --fixture "$TMP/empty.json" --now "$NOW" --short 2>&1)
|
||||
has "$E" "查過了,沒有" && ok "⑨ 長版說「查過了,沒有」(票上驗收 2)" || no "⑨ 長版" "實得:$E"
|
||||
has "$ES" "查過了,沒有" && ok "⑩ 短版也說(安靜跟壞掉長得一模一樣)" || no "⑩ 短版" "實得:$ES"
|
||||
BAD=$(TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN_CLAUDE_CODE= GITEA_TOKEN= \
|
||||
CLAUDE_PROJECT_DIR="$TMP" python3 "$NAG" --now "$NOW" 2>&1)
|
||||
has "$BAD" "這不等於" && ok "⑪ 撈不到時講「我沒查到」,不講成「沒有事情逾期」" \
|
||||
|| no "⑪ 撈不到≠沒事" "實得:$(printf '%s' "$BAD" | tr '\n' ' ')"
|
||||
|
||||
echo "── C 群:不該叫的不要叫(誤報比漏報更該修)──────────────────────"
|
||||
has "$R" "還沒到期的" && no "⑫ 還沒到期的不叫" "叫了" || ok "⑫ 還沒到期的不叫"
|
||||
has "$R" "逾期但票都關完了" && no "⑬ 票關完的不叫" "叫了" || ok "⑬ 逾期但票都關完了不叫"
|
||||
has "$R" "沒設期限的" && no "⑭ 沒期限的不叫" "叫了" || ok "⑭ 沒設期限的不是節拍器"
|
||||
has "$R" "昨天才動過" && no "⑮ 才動過的不叫" "叫了" || ok "⑮ 3 天內動過的 s/doing 不算掉棒"
|
||||
has "$R" "pr-verdict-guard 在管" && no "⑯ PR 不重複點名" "叫了" || ok "⑯ PR 交給 pr-verdict-guard,不重複"
|
||||
has "$R" "舊 org 的同一張票" && no "⑰ 舊 org 的重複票不叫" "叫了" || ok "⑰ 只看 inkstone,不重複計舊 org"
|
||||
has "$R" "不是誰的事" && no "⑱ 沒標籤沒人等的不叫" "叫了" || ok "⑱ 沒標籤又沒人等的不叫"
|
||||
printf '%s' "$J" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \
|
||||
&& ok "⑲ --json 是合法 JSON(全清單在這裡,長版才封頂)" || no "⑲ --json" "不是合法 JSON"
|
||||
|
||||
echo "── D 群:發不出去不會安靜(票上驗收 4 的機器可驗那一半)──────────"
|
||||
OUT=$(python3 "$NOTIFY" --text "測試訊息 ABC" 2>&1); RC=$?
|
||||
[ "$RC" = 1 ] && ok "⑳ 兩條路都沒走通 ⇒ 離開碼 1(不是靜靜地成功)" \
|
||||
|| no "⑳ 離開碼" "實得 $RC"
|
||||
has "$OUT" "測試訊息 ABC" && ok "㉑ 原文照抄在輸出裡(看到的人才有辦法代發)" \
|
||||
|| no "㉑ 原文照抄" "實得:$OUT"
|
||||
has "$OUT" "leo 的手機上沒有出現這則" && ok "㉒ 明著說 leo 沒收到(不是「送出成功」)" \
|
||||
|| no "㉒ 明著說沒收到" "實得:$OUT"
|
||||
# 🔴 「為什麼發不出去」要跟著訊息一起留下來。2026-08-28 第一版漏了這一格:
|
||||
# 閘明明 pass,退路留言裡卻只看得到 pass,真正的斷點(實例上沒有那支工作流)
|
||||
# 一個字都沒進票 ⇒ 下一個人會去修閘,而斷點根本不在那裡。
|
||||
FB=$(python3 - <<'PYX'
|
||||
import importlib.machinery, importlib.util, os
|
||||
ld = importlib.machinery.SourceFileLoader("n", os.environ["NOTIFY_PATH"])
|
||||
sp = importlib.util.spec_from_loader("n", ld); m = importlib.util.module_from_spec(sp)
|
||||
ld.exec_module(m)
|
||||
fact = {"verdict": "pass", "version": "0.11.0", "guard": "/x/prod-write-guard.sh",
|
||||
"confidence": "high", "source": "測試", "reason": "閘放行"}
|
||||
body = m.fallback_body("要發的原文 XYZ", fact,
|
||||
"通道本身斷了:實例上找不到 notify_leo 工作流")
|
||||
ok = ("找不到 notify_leo" in body # 真正的斷點有進票
|
||||
and "要發的原文 XYZ" in body # 原文有進票
|
||||
and body.splitlines()[0].startswith("【身份】")) # 身份欄在第一行
|
||||
print("HAS_WHY" if ok else "NO_WHY:" + body[:120])
|
||||
PYX
|
||||
)
|
||||
[ "$FB" = "HAS_WHY" ] && ok "㉒b 退路留言帶著「為什麼發不出去」,不是只帶閘的判定" \
|
||||
|| no "㉒b 退路帶原因" "實得:$FB"
|
||||
|
||||
echo "── E 群:閘的版本是查得到的事實,不是假設 ────────────────────────"
|
||||
G=$(python3 "$NOTIFY" --gate 2>&1)
|
||||
has "$G" "這個 session 的通知閘" && ok "㉓ 問得出這個 session 的閘怎麼判" || no "㉓ --gate" "$G"
|
||||
has "$G" "ISEP 版本:v" && ok "㉔ 講得出版本(「哪一版」就是這件事的全部)" || no "㉔ 版本" "$G"
|
||||
has "$G" "問的是哪一份" && ok "㉕ 講得出問的是哪一個檔(同機可能有兩份)" || no "㉕ 路徑" "$G"
|
||||
|
||||
# 舊版的閘(v0.10.0 之前):拿真跡來測——git 歷史裡第一版 prod-write-guard
|
||||
OLD="$TMP/oldplug"
|
||||
mkdir -p "$OLD/hooks/lib" "$OLD/.claude-plugin"
|
||||
if git -C "$ROOT" show c263866:hooks/prod-write-guard.sh > "$OLD/hooks/prod-write-guard.sh" 2>/dev/null; then
|
||||
git -C "$ROOT" show c263866:.claude-plugin/plugin.json > "$OLD/.claude-plugin/plugin.json" 2>/dev/null
|
||||
OG=$(ISEP_NOTIFY_GUARD="$OLD/hooks/prod-write-guard.sh" python3 "$NOTIFY" --gate 2>&1)
|
||||
has "$OG" '\*\*block\*\*' && ok "㉖ 舊版的閘會擋——**用真跡測的**(c263866 那一版)" \
|
||||
|| no "㉖ 舊版會擋" "實得:$(printf '%s' "$OG" | tr '\n' ' ')"
|
||||
has "$OG" "開一個新的 session" && ok "㉗ 擋的時候說得出解法(更新 plugin + 開新 session)" \
|
||||
|| no "㉗ 說得出解法" "$OG"
|
||||
OB=$(ISEP_NOTIFY_GUARD="$OLD/hooks/prod-write-guard.sh" python3 "$NOTIFY" --text "被擋的訊息" 2>&1)
|
||||
has "$OB" "不繞路" && ok "㉘ 閘說會擋就不送——**不從 python 這條路繞過去**" \
|
||||
|| no "㉘ 不繞路" "$OB"
|
||||
has "$OB" "被擋的訊息" && ok "㉙ 被擋也照樣把原文吐出來(擋≠靜默)" || no "㉙ 原文" "$OB"
|
||||
else
|
||||
printf ' ⏭️ SKIP ㉖㉗㉘㉙:拿不到 c263866 的舊版閘(淺 clone?)\n'
|
||||
fi
|
||||
|
||||
NG=$(ISEP_NOTIFY_GUARD="$TMP/沒有這支.sh" python3 "$NOTIFY" --gate 2>&1)
|
||||
has "$NG" '\*\*unknown\*\*' && ok "㉚ 找不到閘 ⇒ 誠實回 unknown(不謊稱 pass)" \
|
||||
|| no "㉚ unknown" "$NG"
|
||||
|
||||
touch /dev/null
|
||||
STAMP_BAK=""
|
||||
if [ -f /tmp/.prod-write-ok ]; then STAMP_BAK=1; fi
|
||||
if [ -z "$STAMP_BAK" ]; then
|
||||
touch /tmp/.prod-write-ok
|
||||
SG=$(python3 "$NOTIFY" --gate 2>&1)
|
||||
[ -f /tmp/.prod-write-ok ] \
|
||||
&& ok "㉛ 戳記在場時不探針——**探一次就把總管那次授權燒掉了**" \
|
||||
|| no "㉛ 不燒戳記" "探針把 /tmp/.prod-write-ok 吃掉了"
|
||||
rm -f /tmp/.prod-write-ok
|
||||
else
|
||||
printf ' ⏭️ SKIP ㉛:這台機器上本來就有 /tmp/.prod-write-ok,不動它\n'
|
||||
fi
|
||||
|
||||
echo "── F 群:開 session 自己跑過一次(票上驗收 3)────────────────────"
|
||||
IN='{"hook_event_name":"SessionStart","session_id":"t"}'
|
||||
H=$(printf '%s' "$IN" | ISEP_NAG_NO_NOTIFY=1 ISEP_NAG_FIXTURE="$TMP/full.json" \
|
||||
ISEP_NAG_TIMEOUT=40 bash "$HOOK" 2>/dev/null)
|
||||
printf '%s' "$H" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \
|
||||
&& ok "㉜ SessionStart 吐得出合法 JSON(吐壞的會被 harness 丟掉)" \
|
||||
|| no "㉜ 合法 JSON" "實得:$(printf '%s' "$H" | head -c 200)"
|
||||
has "$H" "additionalContext" && ok "㉝ 真的把報告注入眼前(不是印在誰都看不到的地方)" \
|
||||
|| no "㉝ 注入" "$H"
|
||||
has "$H" "catch" && no "㉞ 不該有例外堆疊" "有" || ok "㉞ 沒有噴例外"
|
||||
CH=$(printf '%s' "$IN" | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>/dev/null)
|
||||
[ -z "$CH" ] && ok "㉟ 子 session 不叫(subagent 開工不是 leo 開工)" \
|
||||
|| no "㉟ 子 session 不叫" "實得:$CH"
|
||||
|
||||
echo "── G 群:有票卻沒有工人的 repo(inkstone/ISEP#118)──────────────────"
|
||||
# 假名單:只有一位管 acme/staffed 的工人,不碰真的 agents/
|
||||
mkdir -p "$TMP/roster"
|
||||
cat > "$TMP/roster/staffed.md" <<'MD'
|
||||
---
|
||||
name: staffed-hand
|
||||
description: 測試用
|
||||
---
|
||||
**負責 repo**:acme/staffed、acme/also-staffed
|
||||
## 開工前先讀
|
||||
## 紅線
|
||||
MD
|
||||
cat > "$TMP/gap.json" <<'JSON'
|
||||
{"milestones": [], "issues": [],
|
||||
"repos": [
|
||||
{"full_name":"acme/staffed","open_issues_count":4},
|
||||
{"full_name":"acme/also-staffed","open_issues_count":1},
|
||||
{"full_name":"acme/new-course","open_issues_count":5},
|
||||
{"full_name":"acme/quiet","open_issues_count":0},
|
||||
{"full_name":"acme/frozen","open_issues_count":3,"archived":true}
|
||||
]}
|
||||
JSON
|
||||
GR=$(ISEP_ROSTER_DIR="$TMP/roster" python3 "$NAG" --fixture "$TMP/gap.json" --now "$NOW" 2>&1)
|
||||
GS=$(ISEP_ROSTER_DIR="$TMP/roster" python3 "$NAG" --fixture "$TMP/gap.json" --now "$NOW" --short 2>&1)
|
||||
GJ=$(ISEP_ROSTER_DIR="$TMP/roster" python3 "$NAG" --fixture "$TMP/gap.json" --now "$NOW" --json 2>&1)
|
||||
has "$GR" "有票卻沒有工人的 repo(1 個)" && has "$GR" "acme/new-course(5 張票開著)" \
|
||||
&& ok "㊱ 新開、有票、名單上沒人的 repo 被點名(連同幾張票)" \
|
||||
|| no "㊱ 點名缺工人的 repo" "實得:$(printf '%s' "$GR" | tr '\n' ' ' | head -c 300)"
|
||||
has "$GR" "agents/" && ok "㊲ 講得出出路:去 agents/ 補工人(不是開例外閘)" || no "㊲ 出路" "沒講"
|
||||
if has "$GR" "acme/staffed(" || has "$GR" "acme/also-staffed" || has "$GR" "acme/quiet" || has "$GR" "acme/frozen"; then
|
||||
no "㊳ 不該叫的不要叫" "有人的/沒票的/封存的被點名了"
|
||||
else
|
||||
ok "㊳ 有工人的(含多 repo 的第二個)、沒票的、封存的都不叫"
|
||||
fi
|
||||
has "$GS" "查過了,沒有" && ! has "$GS" "工人" \
|
||||
&& ok "㊴ Telegram 短版不提工人(派工是總管的事,不吵 leo)" \
|
||||
|| no "㊴ 短版不提工人" "實得:$GS"
|
||||
printf '%s' "$GJ" | python3 -c '
|
||||
import json,sys
|
||||
assert json.load(sys.stdin)["findings"]["repos_without_worker"] == [{"repo":"acme/new-course","open":5}]
|
||||
' 2>/dev/null && ok "㊵ --json 帶得出同一份清單" || no "㊵ --json" "$(printf '%s' "$GJ" | head -c 200)"
|
||||
GN=$(ISEP_ROSTER_DIR="$TMP/沒有這個目錄" python3 "$NAG" --fixture "$TMP/gap.json" --now "$NOW" 2>&1)
|
||||
has "$GN" "沒查到" && ! has "$GN" "acme/staffed(" \
|
||||
&& ok "㊶ 名單讀不到 ⇒ 講「沒查到」,不把每個 repo 都說成缺人" \
|
||||
|| no "㊶ 名單讀不到" "實得:$(printf '%s' "$GN" | tr '\n' ' ' | head -c 300)"
|
||||
GO=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" 2>&1)
|
||||
has "$GO" "沒查到" && ok "㊷ 舊格式 fixture(沒有 repos)⇒ 講「沒查到」,前三類照舊" \
|
||||
|| no "㊷ 舊格式相容" "實得:$(printf '%s' "$GO" | tail -3 | tr '\n' ' ')"
|
||||
|
||||
echo
|
||||
printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" = 0 ]
|
||||
Executable
+255
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env bash
|
||||
# parallel-lines-cap-guard.sh 的迴歸測試(inkstone/ISEP#109)。
|
||||
#
|
||||
# **離線、不打網路、不花錢、每次結果一樣**——這支閘只讀檔案的存在與 mtime,
|
||||
# 沒有語意判官,所以不需要像 ask-user-question-guard 那樣另開一支 live 測試量準度。
|
||||
#
|
||||
# 每個案例都自己搭一份假的 harness 佈局(跟真的同形狀):
|
||||
# <root>/<sid>.jsonl 主 transcript
|
||||
# <root>/<sid>/subagents/agent-<id>.meta.json harness 啟動那條線時寫的
|
||||
# <root>/<sid>/subagents/agent-<id>.jsonl 那條線自己的 transcript
|
||||
#
|
||||
# 三群,兩個方向都要有證據:
|
||||
# A 群「不該擋」——沒到上限、已收工、當掉的線、逃生門、不是派工的工具、
|
||||
# 主 transcript 讀不到(**這群紅了比漏擋嚴重**:誤攔會讓整台機器派不了工)
|
||||
# B 群「該擋」 ——到上限、超過上限、上限可調、巢狀也算
|
||||
# C 群「訊息本身」——列得出現在有哪幾條在跑、不外洩 agentId、沒有被 shell 展開
|
||||
#
|
||||
# 用法:hooks/tests/parallel-lines-cap-guard.test.sh [hook 路徑]
|
||||
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HOOK="${1:-$(cd "$HERE/.." && pwd)/parallel-lines-cap-guard.sh}"
|
||||
|
||||
PASS=0; FAIL=0; N=0
|
||||
ROOT=$(mktemp -d "${TMPDIR:-/tmp}/isep-parcap.XXXXXX")
|
||||
SID="S-PARCAP-TEST"
|
||||
ESC="/tmp/.parallel-ok-$SID"
|
||||
clean() { rm -rf "$ROOT"; rm -f "$ESC"; }
|
||||
trap clean EXIT
|
||||
|
||||
# reset —— 每個案例從空的佈局開始
|
||||
reset() {
|
||||
rm -rf "$ROOT/$SID" "$ROOT/$SID.jsonl"
|
||||
mkdir -p "$ROOT/$SID/subagents"
|
||||
: > "$ROOT/$SID.jsonl"
|
||||
}
|
||||
|
||||
# line <id> <名字> <型別> <安靜幾秒> [tool_use_id]
|
||||
# 造一條「在跑的線」:meta 在、jsonl 在、jsonl 的 mtime 是 <安靜幾秒> 前
|
||||
line() {
|
||||
local id="$1" name="$2" typ="$3" quiet="$4" tuid="${5:-toolu_$1}"
|
||||
local d="$ROOT/$SID/subagents"
|
||||
printf '{"agentType":"%s","description":"%s 的說明","name":"%s","toolUseId":"%s","spawnDepth":1}\n' \
|
||||
"$typ" "$name" "$name" "$tuid" > "$d/agent-$id.meta.json"
|
||||
printf '{"agentId":"%s"}\n' "$id" > "$d/agent-$id.jsonl"
|
||||
python3 - "$d/agent-$id.jsonl" "$quiet" <<'PY'
|
||||
import os, sys, time
|
||||
p, q = sys.argv[1], int(sys.argv[2])
|
||||
t = time.time() - q
|
||||
os.utime(p, (t, t))
|
||||
PY
|
||||
}
|
||||
|
||||
# finished <id> —— 在主 transcript 補一則完工通知(實跡格式)
|
||||
finished() {
|
||||
printf '{"type":"queue-operation","operation":"enqueue","sessionId":"%s","content":"<task-notification>\\n<task-id>%s</task-id>\\n<tool-use-id>toolu_%s</tool-use-id>\\n"}\n' \
|
||||
"$SID" "$1" "$1" >> "$ROOT/$SID.jsonl"
|
||||
}
|
||||
|
||||
# launch_ack <tool_use_id> —— 背景 agent 剛啟動時的 tool_result(實跡文字)
|
||||
launch_ack() {
|
||||
python3 - "$1" <<'PY' >> "$ROOT_ENV/$SID_ENV.jsonl"
|
||||
import json, sys
|
||||
tuid = sys.argv[1]
|
||||
print(json.dumps({"type": "user", "message": {"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": tuid,
|
||||
"content": [{"type": "text", "text": "Async agent launched successfully. agentId: xyz"}]}]}},
|
||||
ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
# sync_result <tool_use_id> —— 同步 agent 交件了:tool_result 是真的結果,不是 launch ack
|
||||
sync_result() {
|
||||
python3 - "$1" <<'PY' >> "$ROOT_ENV/$SID_ENV.jsonl"
|
||||
import json, sys
|
||||
tuid = sys.argv[1]
|
||||
print(json.dumps({"type": "user", "message": {"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": tuid,
|
||||
"content": [{"type": "text", "text": "做完了,結論如下……"}]}]}},
|
||||
ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
export ROOT_ENV="$ROOT" SID_ENV="$SID"
|
||||
|
||||
# payload [tool_name] [transcript_path]
|
||||
payload() {
|
||||
python3 - "${1:-Agent}" "${2:-$ROOT/$SID.jsonl}" "$SID" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({"session_id": sys.argv[3], "hook_event_name": "PreToolUse",
|
||||
"transcript_path": sys.argv[2], "tool_name": sys.argv[1],
|
||||
"tool_input": {"prompt": "【工單】inkstone/ISEP#109"}},
|
||||
ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
# t <期望 exit> <說明> [tool_name] [transcript_path] [env 前綴]
|
||||
t() {
|
||||
local want="$1" desc="$2" tool="${3:-Agent}" tpath="${4:-$ROOT/$SID.jsonl}" envs="${5:-}"
|
||||
N=$((N+1))
|
||||
local err rc
|
||||
err=$(printf '%s' "$(payload "$tool" "$tpath")" | env $envs bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
LAST_ERR="$err"
|
||||
if [ "$rc" -eq "$want" ]; then
|
||||
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
|
||||
printf '%s\n' "$err" | sed -n '1,8p' | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# has <該出現的字串> <說明>
|
||||
has() {
|
||||
N=$((N+1))
|
||||
if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then
|
||||
printf ' ✅ %s\n' "$2"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ %s —— 訊息裡找不到「%s」\n' "$2" "$1"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# hasnt <不該出現的字串> <說明>
|
||||
hasnt() {
|
||||
N=$((N+1))
|
||||
if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then
|
||||
printf ' ❌ %s —— 訊息裡出現了「%s」\n' "$2" "$1"; FAIL=$((FAIL+1))
|
||||
else
|
||||
printf ' ✅ %s\n' "$2"; PASS=$((PASS+1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo
|
||||
echo "── A 群:不該擋(誤攔比漏擋嚴重——擋錯了整台機器派不了工)──"
|
||||
|
||||
reset
|
||||
t 0 "① 一條線都沒派過(連 subagents 目錄都空)⇒ 放行"
|
||||
|
||||
reset; line a1 收檔那條 rag-collector 5
|
||||
t 0 "② 只有 1 條在跑(上限 3)⇒ 放行"
|
||||
|
||||
reset; line a1 一號 rag-collector 5; line a2 二號 arcrun-engineer 5
|
||||
t 0 "③ 2 條在跑(上限 3)⇒ 放行"
|
||||
|
||||
reset
|
||||
for i in 1 2 3 4 5; do line "b$i" "第$i號" rag-collector 5; finished "b$i"; done
|
||||
t 0 "④ 5 條都收到完工通知 ⇒ 放行(前一條收工後,下一條派得出去)"
|
||||
|
||||
reset
|
||||
for i in 1 2 3 4 5; do line "c$i" "第$i號" rag-collector 3600; done
|
||||
t 0 "⑤ 5 條的 transcript 都安靜超過 15 分(收工了或當掉)⇒ 放行,不會永久卡死"
|
||||
|
||||
reset; line d1 一號 rag-collector 5; line d2 二號 rag-collector 5; line d3 三號 rag-collector 5
|
||||
t 0 "⑥ 到上限,但動作不是派工(Bash)⇒ 這支不管" Bash
|
||||
|
||||
reset; line e1 一號 rag-collector 5; line e2 二號 rag-collector 5; line e3 三號 rag-collector 5
|
||||
touch "$ESC"
|
||||
t 0 "⑦ 到上限但逃生門在 ⇒ 放行"
|
||||
N=$((N+1))
|
||||
if [ -f "$ESC" ]; then
|
||||
printf ' ❌ ⑧ 逃生門用完沒被消耗——那會變成永久豁免\n'; FAIL=$((FAIL+1))
|
||||
else
|
||||
printf ' ✅ ⑧ 逃生門用掉就消失(不是永久豁免)\n'; PASS=$((PASS+1))
|
||||
fi
|
||||
|
||||
reset; line f1 一號 rag-collector 5; line f2 二號 rag-collector 5; line f3 三號 rag-collector 5
|
||||
finished f2
|
||||
t 0 "⑨ 3 條裡有 1 條收工了 ⇒ 剩 2 條,放行"
|
||||
|
||||
reset; line g1 一號 rag-collector 5 toolu_G1; line g2 二號 rag-collector 5 toolu_G2; line g3 三號 rag-collector 5 toolu_G3
|
||||
sync_result toolu_G2
|
||||
t 0 "⑩ 同步 agent 的 tool_result 不是 launch ack ⇒ 算它交件了,放行"
|
||||
|
||||
reset; line h1 一號 rag-collector 5; line h2 二號 rag-collector 5
|
||||
rm -f "$ROOT/$SID.jsonl"
|
||||
t 0 "⑪ 主 transcript 讀不到,只靠安靜窗 ⇒ 2 條不到上限,放行"
|
||||
|
||||
reset; line i1 一號 rag-collector 5; line i2 二號 rag-collector 5; line i3 三號 rag-collector 5
|
||||
t 0 "⑫ transcript_path 指到不存在的佈局 ⇒ 數不出來就放行(不擋在不確定上)" \
|
||||
Agent "$ROOT/nowhere/NOPE.jsonl"
|
||||
|
||||
N=$((N+1))
|
||||
brc=$(printf 'this is not json' | bash "$HOOK" >/dev/null 2>&1; echo $?)
|
||||
if [ "$brc" -eq 0 ]; then
|
||||
printf ' ✅ ⑬ payload 壞掉 ⇒ 放行(fail-open 是刻意的)\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ⑬ payload 壞掉卻擋下來了(exit=%s)——那會卡死整台機器\n' "$brc"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
N=$((N+1))
|
||||
bmsg=$(printf 'this is not json' | bash "$HOOK" 2>&1 >/dev/null)
|
||||
if printf '%s' "$bmsg" | grep -q "沒有把關"; then
|
||||
printf ' ✅ ⑭ 而且它會出聲說「這一次沒有把關」(不是靜默放行)\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ⑭ 出錯時靜默放行了——房規禁止靜默\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── B 群:該擋 ──"
|
||||
|
||||
reset; line j1 一號 rag-collector 5; line j2 二號 arcrun-engineer 5; line j3 三號 isep-guard 5
|
||||
t 2 "⑮ 剛好 3 條在跑(上限 3)⇒ 擋第 4 條"
|
||||
|
||||
reset
|
||||
for i in 1 2 3 4 5 6 7; do line "k$i" "第$i號" rag-collector 5; done
|
||||
t 2 "⑯ 7 條在跑(就是 08-29 那天的形狀)⇒ 擋"
|
||||
|
||||
reset; line l1 一號 rag-collector 5
|
||||
t 2 "⑰ 上限調成 1,1 條在跑 ⇒ 擋(上限可調)" Agent "$ROOT/$SID.jsonl" "ISEP_MAX_PARALLEL_LINES=1"
|
||||
|
||||
reset; line m1 一號 rag-collector 5; line m2 二號 rag-collector 5; line m3 三號 rag-collector 5
|
||||
t 2 "⑱ Task 這條路也管得到(不是只認 Agent)" Task
|
||||
|
||||
reset; line n1 一號 rag-collector 5 toolu_N1; line n2 二號 rag-collector 5 toolu_N2; line n3 三號 rag-collector 5 toolu_N3
|
||||
launch_ack toolu_N1; launch_ack toolu_N2; launch_ack toolu_N3
|
||||
t 2 "⑲ 三條都只有 launch ack(還在跑)⇒ 擋"
|
||||
|
||||
reset
|
||||
for i in 1 2 3; do line "o$i" "第$i號" rag-collector 5; done
|
||||
python3 - "$ROOT/$SID/subagents/agent-o2.meta.json" <<'PY'
|
||||
import json, sys
|
||||
p = sys.argv[1]; d = json.load(open(p)); d["spawnDepth"] = 2
|
||||
json.dump(d, open(p, "w"), ensure_ascii=False)
|
||||
PY
|
||||
t 2 "⑳ 巢狀派出去的線(spawnDepth 2)一樣算進去——它一樣吃記憶體"
|
||||
|
||||
reset; line p1 一號 rag-collector 5; line p2 二號 rag-collector 5; line p3 三號 rag-collector 5
|
||||
touch "$ESC"
|
||||
printf '%s' "$(payload)" | bash "$HOOK" >/dev/null 2>&1
|
||||
t 2 "㉑ 逃生門用過一次之後,第二次照樣擋"
|
||||
|
||||
reset; line q1 一號 rag-collector 890; line q2 二號 rag-collector 890; line q3 三號 rag-collector 890
|
||||
t 2 "㉒ 安靜 890 秒(還在 900 秒窗內)⇒ 仍算在跑,擋"
|
||||
|
||||
echo
|
||||
echo "── C 群:訊息本身 ──"
|
||||
|
||||
reset
|
||||
line r1 install-twice-163 rag-collector 5
|
||||
line r2 dup-machine-icon-185 arcrun-engineer 5
|
||||
line r3 parallel-cap-109 isep-guard 5
|
||||
t 2 "㉓ 到上限 ⇒ 擋(下面幾條檢查這一次的訊息)"
|
||||
has "現在有 3 條在跑" "㉔ 說得出現在有幾條在跑"
|
||||
has "install-twice-163" "㉕ 列得出第一條的名字"
|
||||
has "dup-machine-icon-185" "㉖ 列得出第二條的名字"
|
||||
has "parallel-cap-109" "㉗ 列得出第三條的名字"
|
||||
has "跑 2-3 條應該是極限了" "㉘ 說得出上限從哪來(leo 原話)"
|
||||
has "touch /tmp/.parallel-ok-" "㉙ 講得出逃生門怎麼開"
|
||||
has "ISEP_MAX_PARALLEL_LINES" "㉚ 講得出上限怎麼調"
|
||||
has "沒有總管例外" "㉛ 講明它擋的是總管自己"
|
||||
hasnt "agentId" "㉜ 不外洩 harness 說不要往外貼的內部 id"
|
||||
hasnt '$(' '㉝ 訊息沒有被 shell 展開(錢字號括號不該留在輸出裡)'
|
||||
|
||||
clean
|
||||
echo
|
||||
echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗} ══"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -23,6 +23,7 @@ t pass "GET + sort -d / xargs -d" "ls | sort $DASH_D | xargs $DASH_D '\\n' echo;
|
||||
t pass "GET 帶 Bearer 標頭" "curl -s -H 'Authorization: Bearer xxx' $H/templates?limit=1"
|
||||
t pass "打 staging" "curl -X POST https://arcrun-rag-installer-staging.workers.dev/x $DASH_D '{}'"
|
||||
t pass "打 youlin 測試場" "curl -X POST https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev/x $DASH_D '{}'"
|
||||
t pass "打 youlin 測試場(09-02 重裝後的子網域 arcrun-yuga3bse,inkstone/ISEP#130)" "curl -X POST https://arcrun-cypher-executor.arcrun-yuga3bse.workers.dev/webhooks/named $DASH_D @def.yaml"
|
||||
t pass "打 Gitea(不是實例)" "curl -X POST https://git.uncle6.me/api/v1/repos/Leo/x/issues $DASH_D '{}'"
|
||||
|
||||
echo "── 該擋(寫入)──"
|
||||
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/bin/bash
|
||||
# queue-drain-guard.sh 的迴歸測試(inkstone/ISEP#33)
|
||||
#
|
||||
# 全程離線:`ticket pick` 換成一支假的(ISEP_DRAIN_PICK_CMD),離開碼與輸出由測試指定。
|
||||
# 兩個方向都測:該擋的(在接票、池子有票)與不該擋的(聊天 session、抓盡、鎖死保險)。
|
||||
cd "$(dirname "$0")/../.." || exit 1
|
||||
H=hooks/queue-drain-guard.sh
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
PASS=0; FAIL=0
|
||||
unset ISEP_DRAIN CLAUDE_PLUGIN_ROOT
|
||||
|
||||
cat > "$TMP/pick.sh" <<'SH'
|
||||
#!/bin/sh
|
||||
cat "$STUB_DIR/out" 2>/dev/null
|
||||
exit "$(cat "$STUB_DIR/rc")"
|
||||
SH
|
||||
export STUB_DIR="$TMP" ISEP_DRAIN_PICK_CMD="sh $TMP/pick.sh"
|
||||
|
||||
stub() { printf '%s' "$1" > "$TMP/rc"; printf '%s' "${2:-}" > "$TMP/out"; }
|
||||
TICKET='{"ref":"inkstone/Arcrun#176","title":"查一份筆記關係的人","group":"主線"}'
|
||||
|
||||
# transcript <檔名> <Bash 指令或空> [純文字]
|
||||
transcript() {
|
||||
python3 - "$TMP/$1" "$2" "${3:-}" <<'PY'
|
||||
import json, sys
|
||||
path, cmd, text = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
rows = [{"type": "user", "message": {"content": "開工"}}]
|
||||
content = []
|
||||
if text: content.append({"type": "text", "text": text})
|
||||
if cmd: content.append({"type": "tool_use", "name": "Bash", "input": {"command": cmd}})
|
||||
rows.append({"type": "assistant", "message": {"content": content}})
|
||||
open(path, "w").write("\n".join(json.dumps(r, ensure_ascii=False) for r in rows))
|
||||
PY
|
||||
}
|
||||
|
||||
run() { # run <說明> <want exit> <session> <transcript 檔> [stop_hook_active]
|
||||
export ISEP_DRAIN_STATE_DIR="$TMP/state-$3"; mkdir -p "$ISEP_DRAIN_STATE_DIR"
|
||||
payload=$(printf '{"session_id":"%s","transcript_path":"%s","stop_hook_active":%s}' "$3" "$TMP/$4" "${5:-false}")
|
||||
printf '%s' "$payload" | bash "$H" > "$TMP/stdout" 2> "$TMP/stderr"; got=$?
|
||||
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
|
||||
}
|
||||
check() { # check <說明> <shell 條件>
|
||||
if eval "$2"; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf '%s\n' "$1"
|
||||
}
|
||||
|
||||
transcript worker.jsonl 'python3 /x/scripts/ticket claim inkstone/Arcrun#175 --name cloud-worker'
|
||||
transcript handback.jsonl 'python3 "$CLAUDE_PLUGIN_ROOT/scripts/ticket" handback inkstone/Arcrun#175 --to claude-code'
|
||||
transcript chat.jsonl 'git status'
|
||||
transcript textonly.jsonl '' '我等一下會跑 ticket pick 看看'
|
||||
|
||||
echo "── ① 該擋:在接票、池子還有票 ──"
|
||||
stub 0 "$TICKET"
|
||||
run "★ 跑過 ticket claim + pick 有票 → 擋" 2 s1 worker.jsonl
|
||||
check "訊息點名下一張票號" "grep -q 'inkstone/Arcrun#176' '$TMP/stderr'"
|
||||
check "訊息給出認領指令(claim)" "grep -q 'claim inkstone/Arcrun#176' '$TMP/stderr'"
|
||||
check "訊息給出「做不了」的出路(寫回票+改欄位),不是叫它停" "grep -q '寫回那張票' '$TMP/stderr'"
|
||||
run "引號包住路徑的 handback 也算在接票" 2 s2 handback.jsonl
|
||||
run "★ stop_hook_active=true 仍擋(別的閘擋過一次就讓路,正是停工的來源)" 2 s3 worker.jsonl true
|
||||
ISEP_DRAIN=1 run "ISEP_DRAIN=1:還沒跑過任何 ticket 指令也擋" 2 s4 chat.jsonl
|
||||
check "log 寫在狀態目錄(不靠 CLAUDE_PROJECT_DIR)" "grep -q '擋下' '$TMP/state-s4/queue-drain-guard.log'"
|
||||
|
||||
stub 0 '{"ref":"inkstone/mira#21","title":"沒人看過的票","group":"待分診","next":"python3 /x/scripts/ticket triage inkstone/mira#21 --to <s/backlog|s/triage|s/pending> --why \"<一句理由>\""}'
|
||||
run "★ 可做的都抓盡、還有待分診的票 → 擋(ISEP#159:沒驗傷的票不會自己消失)" 2 s9 worker.jsonl
|
||||
check "訊息照 pick 那一列的 next 印 triage,不印 claim(待分診的票認領不了)" \
|
||||
"grep -q 'triage inkstone/mira#21' '$TMP/stderr' && ! grep -q 'claim inkstone/mira#21' '$TMP/stderr'"
|
||||
|
||||
echo
|
||||
echo "── ② 不該擋 ──"
|
||||
stub 0 "$TICKET"
|
||||
run "★ 沒跑過 ticket 指令的 session(跟 leo 聊天)→ 放行" 0 s5 chat.jsonl
|
||||
run "文字裡提到 ticket pick、沒真的執行 → 放行(看動作不看措辭)" 0 s6 textonly.jsonl
|
||||
stub 1 ""
|
||||
run "★ pick 離開碼 1(真的抓盡)→ 放行" 0 s7 worker.jsonl
|
||||
printf 'not json' | bash "$H" >/dev/null 2>&1; got=$?
|
||||
if [ "$got" = 0 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=0 got=%s 壞掉的 payload → 放行\n' "$got"
|
||||
|
||||
echo
|
||||
echo "── ③ 鎖死保險 ──"
|
||||
stub 0 "$TICKET"
|
||||
run "同一張第 1 次 → 擋" 2 s8 worker.jsonl
|
||||
run "同一張第 2 次 → 擋" 2 s8 worker.jsonl
|
||||
run "同一張第 3 次 → 擋" 2 s8 worker.jsonl
|
||||
run "★ 同一張第 4 次仍沒被認領 → 放行(它可能真的做不了)" 0 s8 worker.jsonl
|
||||
check "放行理由記進 log(那張要總管看)" "grep -q 'stuck-on:inkstone/Arcrun#176' '$TMP/state-s8/queue-drain-guard.log'"
|
||||
stub 0 '{"ref":"inkstone/Arcrun#197","title":"另一張"}'
|
||||
run "換了一張 → 計數重來,擋" 2 s8 worker.jsonl
|
||||
|
||||
stub 2 ""
|
||||
run "讀不到佇列第 1 次 → 擋(讀不到 ≠ 沒票)" 2 s9 worker.jsonl
|
||||
check "訊息叫它重跑 pick" "grep -q 'pick' '$TMP/stderr' && grep -q '讀不到' '$TMP/stderr'"
|
||||
run "讀不到第 2 次 → 擋" 2 s9 worker.jsonl
|
||||
run "★ 讀不到第 3 次 → 放行並留痕" 0 s9 worker.jsonl
|
||||
check "log 有 unreachable" "grep -q 'unreachable' '$TMP/state-s9/queue-drain-guard.log'"
|
||||
|
||||
printf '0' > "$TMP/rc"; printf '%s' "$TICKET" > "$TMP/out"
|
||||
ISEP_DRAIN_PICK_CMD="sleep 5; sh $TMP/pick.sh" ISEP_DRAIN_PICK_TIMEOUT=1 run "★ pick 超過時限(Gitea 慢)→ 當成讀不到而擋,不是被砍掉後放行" 2 s10 worker.jsonl
|
||||
check "逾時那次訊息是「讀不到 ≠ 沒票」" "grep -q '讀不到' '$TMP/stderr'"
|
||||
|
||||
echo
|
||||
echo "── ④ 真的 pick(不帶假指令)接得上 ──"
|
||||
unset ISEP_DRAIN_PICK_CMD
|
||||
export ISEP_DRAIN_STATE_DIR="$TMP/state-real"; mkdir -p "$ISEP_DRAIN_STATE_DIR"
|
||||
printf '{"session_id":"r1","transcript_path":"%s"}' "$TMP/worker.jsonl" \
|
||||
| TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x ISEP_API_RETRIES=0 ISEP_MAINLINE_FILE="$TMP/none.json" \
|
||||
CLAUDE_PLUGIN_ROOT="$PWD" bash "$H" >/dev/null 2>"$TMP/stderr"; got=$?
|
||||
if [ "$got" = 2 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=2 got=%s 真的 scripts/ticket pick 讀不到主線 → 當成讀不到(擋),不是當成抓盡\n' "$got"
|
||||
|
||||
echo
|
||||
echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" = 0 ] || exit 1
|
||||
@@ -176,6 +176,108 @@ else
|
||||
printf ' ❌ ⑱ 身份欄沒有吃下工人名字(或把原本的三個角色弄壞了)\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo "── 驗收 4:真實的 subagent_type 形狀(2026-08-28 實撞的死鎖)──────"
|
||||
|
||||
N=$((N+1))
|
||||
if python3 - "$ROOT" <<'PY' 2>/dev/null
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(sys.argv[1], "hooks", "lib"))
|
||||
import roster
|
||||
d = os.path.join(sys.argv[1], "agents")
|
||||
# Claude Code 給 plugin agent 的真實值帶前綴,agents/*.md 的 name 沒有
|
||||
assert roster.find("isep:isep-hand", d), "帶 plugin 前綴的真實 subagent_type 認不出來"
|
||||
assert roster.find("isep:scout", d), "帶前綴的 scout 認不出來"
|
||||
# 名單上的裸名字仍然要收(人會照 roster list 直接貼)
|
||||
assert roster.find("isep-hand", d), "裸名字被弄壞了"
|
||||
# 剝前綴不能變成放行任何東西
|
||||
assert roster.find("isep:nobody", d) is None, "不存在的名字被放行了"
|
||||
assert roster.find("nobody", d) is None, "不存在的裸名字被放行了"
|
||||
assert roster.find("", d) is None, "空字串被放行了"
|
||||
PY
|
||||
then
|
||||
printf ' ✅ ⑲ 帶 plugin 前綴的真實 subagent_type 認得出來,不存在的仍然擋\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ⑲ 真實的 subagent_type(isep:isep-hand)認不出來——就是這個造成 8/28 的死鎖\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo "── 驗收 5:課程工人(inkstone/ISEP#118):一個工人管三個 repo ──────────"
|
||||
|
||||
for r in inkstone/ax-courses inkstone/course_gen inkstone/llm-wiki-template; do
|
||||
N=$((N+1))
|
||||
if [ "$(python3 "$ROOT/scripts/roster" which "$r" 2>/dev/null)" = "course-hand" ]; then
|
||||
printf ' ✅ ⑳ `roster which %s` ⇒ course-hand\n' "$r"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ⑳ `roster which %s` 沒回 course-hand(下一次派工又要開例外閘)\n' "$r"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
done
|
||||
|
||||
# 真實派工的形狀:plugin 前綴 + 只有一行【工單】
|
||||
t 0 "㉑ 派 isep:course-hand 去 inkstone/ax-courses#6 ⇒ 放行(09-14 實撞開例外閘的那一張)" \
|
||||
"$(payload '【工單】inkstone/ax-courses#6' 'isep:course-hand' 'Agent')"
|
||||
|
||||
N=$((N+1))
|
||||
inj3=$(printf '%s' "$(payload '【工單】inkstone/ax-courses#6' 'isep:course-hand' 'Agent')" | bash "$HOOK" 2>/dev/null)
|
||||
if printf '%s' "$inj3" | python3 -c '
|
||||
import sys, json
|
||||
c = json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"]
|
||||
for want in ("course-hand", "inkstone/ax-courses", "inkstone/course_gen", "pdftoppm", "## 紅線"):
|
||||
assert want in c, want
|
||||
' 2>/dev/null; then
|
||||
printf ' ✅ ㉒ 注入的卡片帶著它管的三個 repo 與課件驗收紅線(pdftoppm)\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ㉒ 課程工人的注入缺東西\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo "── 驗收 6:多 repo 解析與「有票卻沒工人」的判準(離線假名單)────────"
|
||||
|
||||
mkdir -p "$TMP/r6"
|
||||
cat > "$TMP/r6/multi.md" <<'MD'
|
||||
---
|
||||
name: multi-hand
|
||||
description: 測試用
|
||||
---
|
||||
**負責 repo**:acme/a、acme/b, acme/c(括號後面不算 acme/zzz)
|
||||
## 開工前先讀
|
||||
## 紅線
|
||||
MD
|
||||
cat > "$TMP/r6/single.md" <<'MD'
|
||||
---
|
||||
name: single-hand
|
||||
description: 測試用
|
||||
---
|
||||
**負責 repo**:acme/solo
|
||||
## 開工前先讀
|
||||
## 紅線
|
||||
MD
|
||||
|
||||
N=$((N+1))
|
||||
if python3 - "$ROOT" "$TMP/r6" <<'PY' 2>/dev/null
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(sys.argv[1], "hooks", "lib"))
|
||||
import roster
|
||||
d = sys.argv[2]
|
||||
assert [w["name"] for w in roster.which("acme/b", d)] == ["multi-hand"], "頓號後面的 repo 認不出來"
|
||||
assert [w["name"] for w in roster.which("acme/c", d)] == ["multi-hand"], "逗號後面的 repo 認不出來"
|
||||
assert [w["name"] for w in roster.which("ACME/SOLO", d)] == ["single-hand"], "大小寫不分壞了"
|
||||
assert roster.which("acme/zzz", d) == [], "括號裡的字被當成 repo"
|
||||
assert roster.which("acme/nobody", d) == [], "不存在的 repo 被認領了"
|
||||
rows = [
|
||||
{"full_name": "acme/a", "open_issues_count": 3}, # 有人 ⇒ 不叫
|
||||
{"full_name": "acme/lonely", "open_issues_count": 2}, # 沒人、有票 ⇒ 叫
|
||||
{"full_name": "acme/idle", "open_issues_count": 0}, # 沒人、沒票 ⇒ 不叫
|
||||
{"full_name": "acme/old", "open_issues_count": 9, "archived": True}, # 封存 ⇒ 不叫
|
||||
{"full_name": "acme/busy", "open_issues_count": 5}, # 沒人、有票 ⇒ 叫,排前面
|
||||
]
|
||||
got = roster.uncovered(rows, d)
|
||||
assert got == [{"repo": "acme/busy", "open": 5}, {"repo": "acme/lonely", "open": 2}], got
|
||||
assert roster.uncovered(rows, os.path.join(d, "不存在")) is None, "名單讀不到時沒有回 None"
|
||||
PY
|
||||
then
|
||||
printf ' ✅ ㉓ 頓號/逗號都認得、括號後不算;只叫「沒人又有票」、不叫封存與沒票;名單讀不到回 None\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ㉓ 多 repo 解析或 uncovered 判準壞了\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo
|
||||
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
# sdd-guard 退役的迴歸測試(inkstone/ISEP#91)
|
||||
#
|
||||
# ── 這支在守什麼 ────────────────────────────────────────────────────
|
||||
# leo 2026-08-16 在 inkstone/InkStoneCo#40 裁定「**取消 Active SDD**」:
|
||||
# 任務狀態搬到 Gitea 管,SDD 只記「起初的樣子」。
|
||||
# 但 `sdd-guard.sh` 是 **fail-closed** 的——
|
||||
# 動 code 檔時,active SDD 不是「恰好 1 份」→ 擋(0 份也擋)
|
||||
# 路徑所在的 repo 沒有 3-specs → 也擋
|
||||
# ⇒ 照裁決把 active SDD 拿掉,會把「動任何 .ts/.py/.go」整個鎖死。
|
||||
# 這就是那個裁決 11 天沒人敢執行的真正原因。
|
||||
#
|
||||
# 所以退役的順序是「**先讓閘退役,再拿掉 active SDD**」,而這支測的是第一步做完了沒有。
|
||||
#
|
||||
# 🔴 它測的不是「檔案刪掉了沒有」,而是**那個擋還會不會發生**:
|
||||
# 把整組 `Write|Edit|MultiEdit` 的 PreToolUse 閘,拿去撞「裁決執行完之後的世界」
|
||||
# (一個沒有 3-specs 的 repo、以及一個有兩份 active SDD 的 repo),一支都不准擋。
|
||||
# ⇒ 有人日後用別的檔名把同一個形狀種回來,這支照樣會紅。
|
||||
#
|
||||
# 用法:hooks/tests/sdd-guard-retired.test.sh
|
||||
# 🔴 全程在 TMP 底下建假 repo,跑完自己清;不動任何真 repo、不打網路。
|
||||
|
||||
set -u
|
||||
HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$HOOKS_DIR/.." && pwd)"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { echo " ✅ $1"; PASS=$((PASS+1)); }
|
||||
bad() { echo " ❌ $1"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# ── ① 閘本體與它的註冊都不在了 ────────────────────────────────────
|
||||
echo "── ① 退役:檔案與註冊 ──"
|
||||
|
||||
if [ -e "$HOOKS_DIR/sdd-guard.sh" ]; then
|
||||
bad "hooks/sdd-guard.sh 還在(應已刪除)"
|
||||
else
|
||||
ok "hooks/sdd-guard.sh 已刪除"
|
||||
fi
|
||||
|
||||
if grep -q 'sdd-guard' "$HOOKS_DIR/hooks.json"; then
|
||||
bad "hooks.json 還註冊著 sdd-guard(照裁決做會鎖死所有 code 寫入)"
|
||||
else
|
||||
ok "hooks.json 已無 sdd-guard 註冊"
|
||||
fi
|
||||
|
||||
# hooks.json 要仍然是合法 JSON,而且別的閘一支都沒被順手弄掉
|
||||
# (README:合錯的時候「語法合法、閘卻不見了」,不會有任何東西喊一聲)
|
||||
REGISTERED=$(python3 - "$HOOKS_DIR/hooks.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
print(sum(len(g.get("hooks", [])) for ev in d["hooks"].values() for g in ev))
|
||||
PY
|
||||
) || REGISTERED=""
|
||||
if [ -n "$REGISTERED" ]; then
|
||||
ok "hooks.json 仍是合法 JSON(註冊 $REGISTERED 條)"
|
||||
else
|
||||
bad "hooks.json 解析失敗"
|
||||
fi
|
||||
|
||||
# ── ② 真正的驗收:裁決執行完之後,動 code 檔不能被擋 ──────────────
|
||||
echo "── ② 行為:把「拿掉 active SDD 之後的世界」丟給整組寫檔閘,一支都不准擋 ──"
|
||||
|
||||
# 這兩個假 repo 就是舊 sdd-guard 一定會擋下的兩種狀態:
|
||||
# A. 完全沒有 3-specs(=裁決執行完的樣子,也是 ISEP 自己現在的樣子)
|
||||
# B. 兩份 status: active(=舊的「單一活性鐵律」違反)
|
||||
REPO_NO_SDD="$TMP/repo-no-sdd"
|
||||
mkdir -p "$REPO_NO_SDD/src"
|
||||
git init -q "$REPO_NO_SDD"
|
||||
|
||||
REPO_MULTI="$TMP/repo-multi-active"
|
||||
mkdir -p "$REPO_MULTI/system-dev/docs/3-specs/a" "$REPO_MULTI/system-dev/docs/3-specs/b" "$REPO_MULTI/src"
|
||||
git init -q "$REPO_MULTI"
|
||||
printf -- '---\nstatus: active\n---\n# A\n' > "$REPO_MULTI/system-dev/docs/3-specs/a/design.md"
|
||||
printf -- '---\nstatus: active\n---\n# B\n' > "$REPO_MULTI/system-dev/docs/3-specs/b/design.md"
|
||||
|
||||
# 撈出所有掛在 Write|Edit|MultiEdit 上的 PreToolUse 閘(照 hooks.json 的實況,不寫死清單)
|
||||
# macOS 內建 bash 是 3.2,沒有 mapfile/readarray——用相容寫法收陣列
|
||||
# (同 scripts/gitea-labels-sync.sh:56 的前例;2026-09-01 inkstone/ISEP#115 實撞:
|
||||
# 這支測試在 leo 的 mac 上一次都沒跑成功過,離開碼 1,而 TESTING.md 標著「通過 8」)
|
||||
WRITE_HOOKS=()
|
||||
while IFS= read -r _l; do WRITE_HOOKS+=("$_l"); done < <(python3 - "$HOOKS_DIR/hooks.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
for g in d["hooks"].get("PreToolUse", []):
|
||||
m = g.get("matcher", "") or ""
|
||||
if "Write" in m or "Edit" in m:
|
||||
for h in g.get("hooks", []):
|
||||
print(h["command"].split("/")[-1])
|
||||
PY
|
||||
)
|
||||
|
||||
if [ "${#WRITE_HOOKS[@]:-0}" -eq 0 ]; then
|
||||
bad "撈不到任何 Write|Edit 的 PreToolUse 閘——測試本身失效了,先修這裡"
|
||||
fi
|
||||
|
||||
# 判準:擋下來的理由**是不是 SDD**。
|
||||
#
|
||||
# 🔴 這裡刻意不寫成「一支閘都不准擋」——同一組 matcher 上還住著好幾支跟 SDD 無關、
|
||||
# 而且看 session 狀態決定擋不擋的閘(`subagent-first-guard`/`history-first-guard`…)。
|
||||
# 把它們算成失敗,這支測試就會在別人改別的東西時無故變紅,
|
||||
# 紅久了就沒人看——`docs/TESTING.md` 開頭那句「誤攔比漏擋更該修」對測試一樣成立。
|
||||
# 所以失敗的定義是:**有閘擋下來,而且它擋的理由指向 SDD/3-specs**。
|
||||
# 其餘的擋只印出來給人看,不判失敗。
|
||||
probe() { # probe <說明> <file_path>
|
||||
local desc="$1" path="$2" sdd_blocked="" other_blocked=""
|
||||
local payload out rc
|
||||
payload=$(python3 -c "import json,sys;print(json.dumps({'tool_name':'Write','tool_input':{'file_path':sys.argv[1],'content':'export const x = 1;\n'}}))" "$path")
|
||||
for name in "${WRITE_HOOKS[@]}"; do
|
||||
[ -x "$HOOKS_DIR/$name" ] || continue
|
||||
out=$(printf '%s' "$payload" | "$HOOKS_DIR/$name" 2>&1)
|
||||
rc=$?
|
||||
[ "$rc" -eq 2 ] || continue
|
||||
if printf '%s' "$out" | grep -qE 'SDD|3-specs'; then
|
||||
sdd_blocked="$sdd_blocked $name"
|
||||
else
|
||||
other_blocked="$other_blocked $name"
|
||||
fi
|
||||
done
|
||||
if [ -n "$sdd_blocked" ]; then
|
||||
bad "$desc —— 仍被 SDD 理由擋下:$sdd_blocked"
|
||||
else
|
||||
ok "$desc"
|
||||
[ -n "$other_blocked" ] && echo " (另有與 SDD 無關的閘擋下,不算失敗:$other_blocked)"
|
||||
fi
|
||||
}
|
||||
|
||||
# 刻意讓 $_root(CLAUDE_PROJECT_DIR/pwd)跟這些假 repo 對不上——
|
||||
# 舊 sdd-guard 在這個情境下走的正是 fail-closed 那條分支。
|
||||
unset CLAUDE_PROJECT_DIR
|
||||
cd "$TMP" || exit 1
|
||||
|
||||
probe "repo 沒有 3-specs(裁決執行完的樣子)→ 寫 .ts 不被擋" "$REPO_NO_SDD/src/x.ts"
|
||||
probe "repo 沒有 3-specs → 寫 .py 不被擋" "$REPO_NO_SDD/src/x.py"
|
||||
probe "兩份 status: active(舊「單一活性」違反)→ 寫 .ts 不被擋" "$REPO_MULTI/src/x.ts"
|
||||
|
||||
# ISEP 自己就是「沒有 3-specs 的 repo」——退役前,改自己的 .py 當場被擋。
|
||||
probe "ISEP 自己的 hooks/lib/*.py → 不被擋(退役前這一格是紅的)" "$REPO_ROOT/hooks/lib/dispatch_parse.py"
|
||||
|
||||
# ── ③ 會被載入的那幾份,不能再有人讀到「只准一份 active SDD」 ──────
|
||||
echo "── ③ 條文:載入面不再宣告單一活性鐵律 ──"
|
||||
# 只查**會被載入**的那幾份(commands / agents / skills / hooks 腳本)。
|
||||
# docs/ 與 wiki/ 不查——它們得說得出「退役了什麼」,不然這件事沒有歷史。
|
||||
STALE=""
|
||||
while IFS= read -r f; do
|
||||
grep -qE '只(准|允許)一份|單一活性' "$f" && STALE="$STALE $f"
|
||||
done < <(find "$REPO_ROOT/commands" "$REPO_ROOT/agents" "$REPO_ROOT/skills" -name '*.md' 2>/dev/null
|
||||
find "$REPO_ROOT/hooks" -maxdepth 1 -name '*.sh' 2>/dev/null)
|
||||
if [ -n "$STALE" ]; then
|
||||
bad "這些載入面還在宣告「只准一份 active SDD」:$STALE"
|
||||
else
|
||||
ok "commands/agents/skills/hooks 都不再宣告單一活性鐵律"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "結果:通過 $PASS / 失敗 $FAIL"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# sdd-guard.sh 的迴歸測試(inkstone/InkStoneCo#22)。
|
||||
#
|
||||
# 涵蓋兩個洞:
|
||||
# 洞 A — scratchpad/任何不在 git repo 裡的暫存檔被誤判成「code 變動」而擋下。
|
||||
# 洞 B — 真的解析失敗(fail-closed)時,訊息裡印出內部 sentinel `/nonexistent`。
|
||||
# 以及既有行為不能退步:單一活性違反仍擋、恰好 1 份 active 仍放行、
|
||||
# 「dirname 還沒建立」不可被誤判成「不在 repo 裡」(新邏輯自己可能引入的 fail-open 陷阱)。
|
||||
#
|
||||
# 用法:hooks/tests/sdd-guard.test.sh [hooks/sdd-guard.sh 的路徑]
|
||||
# 🔴 全程在一個乾淨的 TMP 底下建假 repo,跑完自己清;不動任何真 repo。
|
||||
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/sdd-guard.sh}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
|
||||
mk() { # mk <file_path> -> JSON on stdout
|
||||
python3 -c "import json,sys;print(json.dumps({'tool_name':'Write','tool_input':{'file_path':sys.argv[1],'content':'x'}}))" "$1"
|
||||
}
|
||||
|
||||
t() { # t <期望 exit code> <說明> <file_path> [額外檢查關鍵字]
|
||||
local want="$1" desc="$2" path="$3" must_not_contain="${4:-}"
|
||||
local out rc
|
||||
out=$(mk "$path" | "$HOOK" 2>&1)
|
||||
rc=$?
|
||||
local ok=1
|
||||
[ "$rc" -eq "$want" ] || ok=0
|
||||
if [ -n "$must_not_contain" ] && printf '%s' "$out" | grep -qF "$must_not_contain"; then
|
||||
ok=0
|
||||
fi
|
||||
if [ "$ok" -eq 1 ]; then
|
||||
echo " ✅ $desc"; PASS=$((PASS+1))
|
||||
else
|
||||
echo " ❌ $desc —— 期望 exit=$want,實得 exit=$rc"
|
||||
[ -n "$must_not_contain" ] && echo " (且訊息不該含「$must_not_contain」)"
|
||||
echo " 輸出:$out" | head -3
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 準備:一個真的沒有 3-specs 的 git repo(模擬「真的解析失敗」)──
|
||||
REPO_NO_SDD="$TMP/repo-no-sdd"
|
||||
mkdir -p "$REPO_NO_SDD/src"
|
||||
git init -q "$REPO_NO_SDD"
|
||||
|
||||
# ── 準備:一個有 1 份 active SDD 的 git repo ──
|
||||
REPO_ONE_ACTIVE="$TMP/repo-one-active"
|
||||
mkdir -p "$REPO_ONE_ACTIVE/system-dev/docs/3-specs/x" "$REPO_ONE_ACTIVE/src"
|
||||
git init -q "$REPO_ONE_ACTIVE"
|
||||
printf -- '---\nstatus: active\n---\n# X\n' > "$REPO_ONE_ACTIVE/system-dev/docs/3-specs/x/design.md"
|
||||
|
||||
# ── 準備:一個有 2 份 active SDD 的 git repo(單一活性違反)──
|
||||
REPO_MULTI="$TMP/repo-multi-active"
|
||||
mkdir -p "$REPO_MULTI/system-dev/docs/3-specs/a" "$REPO_MULTI/system-dev/docs/3-specs/b" "$REPO_MULTI/src"
|
||||
git init -q "$REPO_MULTI"
|
||||
printf -- '---\nstatus: active\n---\n# A\n' > "$REPO_MULTI/system-dev/docs/3-specs/a/design.md"
|
||||
printf -- '---\nstatus: active\n---\n# B\n' > "$REPO_MULTI/system-dev/docs/3-specs/b/design.md"
|
||||
|
||||
# ── 準備:scratchpad 風格的暫存區(不在任何 git repo 裡)──
|
||||
SCRATCH="$TMP/private/tmp/claude-fake-session/scratchpad"
|
||||
mkdir -p "$SCRATCH"
|
||||
|
||||
# 讓 $_root(CLAUDE_PROJECT_DIR 或 pwd)刻意跟這些假 repo 對不上,
|
||||
# 逼所有案例都走「專案外的路徑」那個分支——這正是 #22 實撞的情境(cwd 跑歪/
|
||||
# CLAUDE_PROJECT_DIR 沒設,路徑不落在 $_root 底下)。
|
||||
unset CLAUDE_PROJECT_DIR
|
||||
cd "$TMP"
|
||||
|
||||
echo "── 洞 A:不在任何 git repo 裡的路徑,SDD 管不到,該放行 ──"
|
||||
t 0 "scratchpad 暫存 .py(本票原始事故)" "$SCRATCH/fix-project-settings.py"
|
||||
t 0 "scratchpad 巢狀更深" "$SCRATCH/nested/deep/tmp.js"
|
||||
|
||||
echo "── 洞 B:真的解析失敗(repo 存在但沒有 3-specs)仍要 fail-closed,但訊息不准洩漏內部假路徑 ──"
|
||||
t 2 "真 repo 沒有 3-specs → 仍擋" "$REPO_NO_SDD/src/foo.py"
|
||||
t 2 "上面那筆的訊息不准出現 /nonexistent" "$REPO_NO_SDD/src/foo.py" "/nonexistent"
|
||||
|
||||
echo "── fail-open 陷阱:新檔案要建在還沒建立的子目錄下,不可被誤判成「不在 repo 裡」──"
|
||||
t 2 "真 repo、目標子目錄還沒建立 → 仍擋(不能因為 dirname 不存在就放行)" "$REPO_NO_SDD/brand-new/not-yet/bar.py"
|
||||
|
||||
echo "── 既有行為不能退步 ──"
|
||||
t 0 "只有 1 份 active SDD,改 code 檔 → 放行" "$REPO_ONE_ACTIVE/src/x.py"
|
||||
t 2 "2 份 active SDD(單一活性違反)→ 擋" "$REPO_MULTI/src/x.py"
|
||||
t 0 "改 .md 文件(非 code 檔)→ 放行,即使找不到 3-specs" "$REPO_NO_SDD/README.md"
|
||||
|
||||
echo
|
||||
echo "結果:通過 $PASS / 失敗 $FAIL"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env bash
|
||||
# wiki 壓縮的迴歸測試(inkstone/ISEP#89)
|
||||
#
|
||||
# 對應票上那三條驗收:
|
||||
# 1 壓完之後同一件事查得到、而且查得比壓之前快 → C 群(bench)
|
||||
# 2 壓縮前後不能弄丟任何一條教訓(逐條對帳) → B 群(verify)
|
||||
# 3 壓縮這件事本身要有票、有交付紀錄 → D 群(--ticket + .compress-log.md)
|
||||
#
|
||||
# 另外兩群是這套的主要失效模式:
|
||||
# A 群 切條要切對 —— 切錯的話後面三件全部失準(尤其 code fence 裡的 `#`)
|
||||
# E 群 閘:大幅刪減要擋、正常編輯不准擋 —— **誤攔比漏擋更該修**
|
||||
#
|
||||
# 🔴 全程離線、只在 mktemp 目錄裡動檔案,不碰任何真的 wiki。
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
WC="$ROOT/scripts/wiki-compress"
|
||||
HOOK="$ROOT/hooks/wiki-size-guard.sh"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP" /tmp/.wiki-size-guard-wctest*' EXIT
|
||||
# 🔴 目錄結構要跟真的一樣(`<專案>/system-dev/wiki`),不然 F 群測到的是
|
||||
# 「hook 找不到 wiki 目錄所以閉嘴」,那是假綠——它跟「都在門檻內」長得一模一樣。
|
||||
W="$TMP/system-dev/wiki"; mkdir -p "$W"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
has(){ printf '%s' "$1" | grep -q -- "$2"; }
|
||||
|
||||
# ── 測資:一份小而完整的假 wiki(含所有會出事的形狀)──────────────────
|
||||
{
|
||||
echo "# 假的 mistakes"
|
||||
echo
|
||||
echo "> 前言,壓縮後要留在最上面。"
|
||||
echo
|
||||
for i in $(seq 1 40); do
|
||||
d=$(printf '2026-0%d-%02d' $(( (i % 3) + 6 )) $(( (i % 27) + 1 )))
|
||||
echo
|
||||
echo "## 第 $i 條:獨特關鍵詞 KEY$i 的教訓($d)"
|
||||
echo
|
||||
echo "**症狀**:某某東西壞了 $i。票:inkstone/ISEP#$((100 + i))"
|
||||
echo
|
||||
echo '```sh'
|
||||
echo "# 這一行是程式碼註解,不是標題 $i"
|
||||
echo "## 這一行也是,切條的時候不准把它當成一條"
|
||||
echo '```'
|
||||
echo
|
||||
for _ in $(seq 1 12); do echo "正文填充 $i:這一段是為了讓每條有足夠的行數。"; done
|
||||
done
|
||||
echo
|
||||
echo "## 自承重複的那一條:這是同款第三次(2026-08-20)"
|
||||
echo
|
||||
echo "同一個病又犯一次。票:inkstone/ISEP#999"
|
||||
} > "$W/mistakes.md"
|
||||
cp "$W/mistakes.md" "$TMP/orig.md"
|
||||
|
||||
echo "── A 群:切條要切對(切錯的話後面三件全部失準)──────────────────"
|
||||
A=$(python3 "$WC" audit "$W" 2>&1)
|
||||
has "$A" "41 條" && ok "① 切出 41 條(40 + 自承重複那條)" \
|
||||
|| no "① 切出 41 條" "實得:$(printf '%s' "$A" | grep mistakes)"
|
||||
has "$A" "這一行也是" && no "② code fence 裡的 ## 不算一條" "算進去了" \
|
||||
|| ok "② code fence 裡的 ## 不算一條(mistakes.md 真的有這種行)"
|
||||
has "$A" "自承是同款" && ok "③ 認得出「自承是同款第 N 次」(人下的判斷比機器猜的準)" \
|
||||
|| no "③ 自承重複" "沒偵測到"
|
||||
|
||||
echo "── B 群:不能弄丟任何一條(票上驗收 2)──────────────────────────"
|
||||
AP=$(WIKI_COMPRESS_THRESHOLD=400 python3 "$WC" apply "$W/mistakes.md" \
|
||||
--ticket inkstone/ISEP#89 2>&1)
|
||||
has "$AP" "✅ 壓完了" && ok "④ 壓得動" || no "④ 壓得動" "$AP"
|
||||
V=$(python3 "$WC" verify "$W/mistakes.md.before-compress" "$W/mistakes.md" \
|
||||
"$W"/mistakes-archive-*.md 2>&1)
|
||||
has "$V" "一條都沒弄丟" && ok "⑤ 逐條對帳:一條都沒弄丟" || no "⑤ 逐條對帳" "$V"
|
||||
diff -q "$TMP/orig.md" "$W/mistakes.md.before-compress" >/dev/null \
|
||||
&& ok "⑥ 壓縮前的原檔完整留著(.before-compress,錯了還原得回去)" \
|
||||
|| no "⑥ 原檔留底" "跟壓縮前不一樣"
|
||||
|
||||
# 真的弄丟一條 ⇒ verify 一定要抓到。**抓不到的對帳表比沒有更糟。**
|
||||
cp "$W/mistakes.md" "$TMP/tampered.md"
|
||||
(python3 - "$W" "$TMP/tampered.md" <<'PY'
|
||||
import glob, os, re, sys
|
||||
w, tam = sys.argv[1], sys.argv[2]
|
||||
# 從某個 archive 裡整條刪掉一條,模擬「壓縮的時候弄丟了」
|
||||
for f in sorted(glob.glob(os.path.join(w, "mistakes-archive-*.md"))):
|
||||
t = open(f, encoding="utf-8").read()
|
||||
parts = re.split(r"(?m)^## ", t)
|
||||
if len(parts) > 2:
|
||||
open(f + ".broken", "w", encoding="utf-8").write(
|
||||
parts[0] + "## " + "## ".join(parts[2:]))
|
||||
print(f + ".broken")
|
||||
break
|
||||
PY
|
||||
) >/dev/null
|
||||
BROKEN=$(ls "$W"/mistakes-archive-*.md.broken 2>/dev/null | head -1)
|
||||
if [ -n "$BROKEN" ]; then
|
||||
KEEP=$(ls "$W"/mistakes-archive-*.md 2>/dev/null | grep -v '\.broken$' \
|
||||
| grep -v "$(basename "${BROKEN%.broken}")" || true)
|
||||
# shellcheck disable=SC2086
|
||||
V2=$(python3 "$WC" verify "$W/mistakes.md.before-compress" "$W/mistakes.md" \
|
||||
"$BROKEN" $KEEP 2>&1); RC2=$?
|
||||
has "$V2" "弄丟了" && [ "$RC2" = 1 ] \
|
||||
&& ok "⑦ **真的弄丟一條時 verify 抓得到**(反向測過,離開碼 1)" \
|
||||
|| no "⑦ 反向測" "沒抓到(rc=$RC2):$(printf '%s' "$V2" | tr '\n' ' ')"
|
||||
else
|
||||
printf ' ⏭️ SKIP ⑦:這次沒有 archive 可以拿來破壞\n'
|
||||
fi
|
||||
|
||||
echo "── C 群:查得到,而且查得更快(票上驗收 1)──────────────────────"
|
||||
B=$(python3 "$WC" bench "$W/mistakes.md.before-compress" "$W/mistakes.md" \
|
||||
"$W"/mistakes-archive-*.md 2>&1)
|
||||
has "$B" "查得更快" && ok "⑧ 命中率沒掉,而且更快" || no "⑧ 更快" "$B"
|
||||
BEF=$(printf '%s' "$B" | grep -o '壓縮前.*平均 [0-9]* 行' | grep -o '[0-9]*' | tail -1)
|
||||
AFT=$(printf '%s' "$B" | grep -o '壓縮後.*平均 [0-9]* 行' | grep -o '[0-9]*' | tail -1)
|
||||
[ -n "$BEF" ] && [ -n "$AFT" ] && [ "$AFT" -lt "$BEF" ] \
|
||||
&& ok "⑨ 定位成本真的變小($BEF → $AFT 行)" \
|
||||
|| no "⑨ 定位成本" "前=$BEF 後=$AFT"
|
||||
has "$(cat "$W/mistakes.md")" "KEY7" && ok "⑩ 壓完的原檔裡查得到舊條目(目錄留了指標)" \
|
||||
|| no "⑩ 目錄留指標" "KEY7 在壓縮後的原檔裡不見了"
|
||||
|
||||
echo "── D 群:壓縮這件事本身要有票、有交付紀錄(票上驗收 3)────────────"
|
||||
NT=$(python3 "$WC" apply "$W/mistakes.md" 2>&1); RCN=$?
|
||||
[ "$RCN" != 0 ] && ok "⑪ 沒給 --ticket 就不准壓" || no "⑪ 沒票不准壓" "居然壓了"
|
||||
BAD=$(python3 "$WC" apply "$W/mistakes.md" --ticket 89 2>&1); RCB=$?
|
||||
[ "$RCB" != 0 ] && ok "⑫ 裸號不算票號(跨 repo 會撞號)" || no "⑫ 裸號" "收了"
|
||||
[ -f "$W/.compress-log.md" ] && ok "⑬ 留下交付紀錄 .compress-log.md" \
|
||||
|| no "⑬ 交付紀錄" "沒有這個檔"
|
||||
L=$(cat "$W/.compress-log.md" 2>/dev/null)
|
||||
has "$L" "inkstone/ISEP#89" && ok "⑭ 紀錄裡寫著票號(誰批准壓的查得到)" || no "⑭ 票號" "$L"
|
||||
has "$L" "對帳指令" && ok "⑮ 紀錄裡寫著對帳指令(下一個人跑得起來)" || no "⑮ 對帳指令" "$L"
|
||||
|
||||
echo "── E 群:閘——大幅刪減要擋,正常編輯不准擋 ──────────────────────"
|
||||
mk(){ printf '{"hook_event_name":"PreToolUse","session_id":"wctest%s","tool_name":"%s","tool_input":%s}' "$1" "$2" "$3"; }
|
||||
run(){ rm -f /tmp/.wiki-size-guard-wctest*; out=$(printf '%s' "$1" | bash "$HOOK" 2>&1); printf '%s|%s' "$?" "$(printf '%s' "$out" | tr '\n' ' ')"; }
|
||||
|
||||
BIG=$(python3 -c 'print("這是一段很長的舊內容。" * 200)')
|
||||
SMALL="剩這一句。"
|
||||
R=$(run "$(mk 1 Edit "$(python3 -c "
|
||||
import json,sys
|
||||
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'$SMALL'}, ensure_ascii=False))")")")
|
||||
case "$R" in 2\|*) ok "⑯ 砍掉一大段 wiki ⇒ 擋一次" ;; *) no "⑯ 該擋" "實得 $R" ;; esac
|
||||
has "$R" "只搬不改" && ok "⑰ 擋的訊息說得出出路(三步指令)" || no "⑰ 出路" "$R"
|
||||
|
||||
R=$(run "$(mk 2 Edit "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'這是一句舊的話,長度普通。','new_string':'這是一句新的話,長度也普通,改了幾個字。'}, ensure_ascii=False))")")")
|
||||
case "$R" in 0\|*) ok "⑱ 一般編輯不擋(誤攔比漏擋更該修)" ;; *) no "⑱ 不該擋" "實得 $R" ;; esac
|
||||
|
||||
R=$(run "$(mk 3 Edit "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'file_path':'/somewhere/src/app.ts','old_string':'x'*3000,'new_string':'y'}, ensure_ascii=False))")")")
|
||||
case "$R" in 0\|*) ok "⑲ 不是 wiki 的檔不管(別的檔縮水是它們自己的事)" ;; *) no "⑲ 非 wiki" "實得 $R" ;; esac
|
||||
|
||||
R=$(run "$(mk 4 Edit "$(python3 -c "
|
||||
import json,sys
|
||||
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'剩這一句。 wiki-compress-ok'}, ensure_ascii=False))")")")
|
||||
case "$R" in 0\|*) ok "⑳ wiki-compress-ok 留痕式豁免走得通" ;; *) no "⑳ 豁免" "實得 $R" ;; esac
|
||||
|
||||
R=$(run "$(mk 5 Write "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'file_path':'$W/從來沒有過的新檔.md','content':'新檔案的一點內容'}, ensure_ascii=False))")")")
|
||||
case "$R" in 0\|*) ok "㉑ 新建檔案不算壓縮" ;; *) no "㉑ 新檔" "實得 $R" ;; esac
|
||||
|
||||
# 至多擋一次:同一個 session 連兩次,第二次要放行(不然會卡死)
|
||||
PAY=$(mk 6 Edit "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'剩這一句。'}, ensure_ascii=False))")")
|
||||
rm -f /tmp/.wiki-size-guard-wctest6
|
||||
printf '%s' "$PAY" | bash "$HOOK" >/dev/null 2>&1; first=$?
|
||||
printf '%s' "$PAY" | bash "$HOOK" >/dev/null 2>&1; second=$?
|
||||
[ "$first" = 2 ] && [ "$second" = 0 ] \
|
||||
&& ok "㉒ 至多擋一次(被擋兩次的閘會被想辦法繞過)" \
|
||||
|| no "㉒ 至多擋一次" "第一次 $first、第二次 $second"
|
||||
|
||||
echo "── F 群:SessionStart 講出來哪幾個檔沒人讀得完 ──────────────────"
|
||||
S=$(printf '{"hook_event_name":"SessionStart","source":"startup"}' \
|
||||
| CLAUDE_PROJECT_DIR="$TMP" WIKI_COMPRESS_THRESHOLD=50 bash "$HOOK" 2>/dev/null)
|
||||
printf '%s' "$S" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \
|
||||
&& ok "㉓ 吐得出合法 JSON" || no "㉓ 合法 JSON" "實得:$(printf '%s' "$S" | head -c 200)"
|
||||
has "$S" "沒人讀得完" && ok "㉔ 真的點名超過門檻的檔" || no "㉔ 點名" "$S"
|
||||
S2=$(printf '{"hook_event_name":"SessionStart","source":"startup"}' \
|
||||
| CLAUDE_PROJECT_DIR="$TMP" WIKI_COMPRESS_THRESHOLD=99999 bash "$HOOK" 2>/dev/null)
|
||||
[ -z "$S2" ] && ok "㉕ 都在門檻內就閉嘴(沒事還講話=下次沒人看)" || no "㉕ 沒事閉嘴" "$S2"
|
||||
|
||||
echo
|
||||
printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" = 0 ]
|
||||
@@ -116,10 +116,10 @@ fi
|
||||
# 不影響「擋下來」這個判斷本身。
|
||||
HITS=""
|
||||
if [ "$RESOURCE" = "issues" ]; then
|
||||
REMOTES=$(git -C "${CLAUDE_PROJECT_DIR:-.}" remote -v 2>/dev/null || true)
|
||||
CRED_LINE=$(printf '%s' "$REMOTES" | grep -m1 'git\.uncle6\.me' | grep -m1 '@' || true)
|
||||
if [ -n "$CRED_LINE" ]; then
|
||||
TOKEN=$(printf '%s' "$CRED_LINE" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
# token 來源=env/.env(inkstone/ISEP#47),不再從 remote URL 抽(那會逼 token 明文嵌在 URL 裡)
|
||||
_GT_LIB="$(dirname -- "$0")/../scripts/lib/gitea-token.sh"; [ -f "$_GT_LIB" ] && . "$_GT_LIB"
|
||||
TOKEN=""; command -v gitea_token >/dev/null 2>&1 && TOKEN=$(gitea_token) || TOKEN=""
|
||||
if [ -n "$TOKEN" ]; then
|
||||
# 只認雙引號的 "title": "..."(curl -d/json.dumps 輸出的實際格式,也是
|
||||
# 2026-08-27 那 13 張票真實用的寫法,見 scripts/test-ticket-api-bypass-guard.sh
|
||||
# 既有的模擬指令)。刻意不支援單引號版本——這段 python 要塞進 bash 單引號
|
||||
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bash
|
||||
# wiki-size-guard.sh — 讀不完的必讀檔要被講出來;壓它的時候不准沒人知道
|
||||
# (inkstone/ISEP#89)
|
||||
#
|
||||
# ── 為什麼要有這一支 ──────────────────────────────────────────────────
|
||||
# leo 2026-08-27 說那天「**失去記憶**」。而記憶失效的其中一個原因是:
|
||||
# **東西還在,只是沒人讀得完。** `mistakes.md` 實測 7,681 行、260 條,
|
||||
# 而它是「做新功能前讀一遍」等級的必讀檔。**讀不完的必讀檔,等於沒有。**
|
||||
#
|
||||
# 既有三支管的是別的事,一支都接不到這裡:
|
||||
# · `wiki-secret-scan.sh` 寫進去的內容安不安全
|
||||
# · `wiki-first-search.sh` 有沒有先查 wiki
|
||||
# · `wiki-first-police.sh` 收工前有沒有查過
|
||||
# **沒有任何一支管「太大了」。**
|
||||
#
|
||||
# ── 兩個事件,是同一件事的兩半 ────────────────────────────────────────
|
||||
# ① SessionStart ── 講:哪幾個檔已經沒有人讀得完了(📝 記錄,不擋)
|
||||
# ② Write|Edit|MultiEdit ── 擋:**大幅刪減 wiki 檔**卻沒走正式流程 ⇒ 擋一次
|
||||
#
|
||||
# 只有 ① = 又一個會被忽略的提醒。
|
||||
# 只有 ② = 罰它做一件沒人告訴它該做的事。
|
||||
#
|
||||
# ── 🔴 ② 擋的判準是「動作」不是「措辭」(leo 2026-08-17)────────────────
|
||||
# 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效,
|
||||
# 是因為它封的是**動作**——動作有限且可枚舉。」
|
||||
# 本支比的是**字元數**:wiki 檔的內容淨縮水超過門檻 ⇒ 那就是一次壓縮,
|
||||
# 不管它在 commit 訊息裡怎麼講。加字、改字、小修一律不碰。
|
||||
#
|
||||
# 為什麼壓縮要擋:**壓縮會弄丟東西,而弄丟的當下沒有人會發現**(票上原話)。
|
||||
# 走 `scripts/wiki-compress` 有兩件手寫做不到的事——
|
||||
# · `--ticket` 逼票號,並把「這一版壓掉了什麼」寫進 `.compress-log.md`(留痕)
|
||||
# · `verify` 用內文雜湊**逐條對帳**,「沒弄丟」是算出來的,不是宣稱的
|
||||
#
|
||||
# 出口(留痕式,同 `wiki-secret-ok` 的慣例):真的要手改,
|
||||
# 在寫入的內容裡放一個 `wiki-compress-ok`,並在 commit 說明理由。
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
TOOL_PY="$ROOT/scripts/wiki-compress"
|
||||
INPUT="$(cat)"
|
||||
|
||||
EVENT=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("hook_event_name", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || printf '')
|
||||
|
||||
# ══ ① SessionStart:講出來哪幾個檔已經沒人讀得完 ═══════════════════════
|
||||
if [ "$EVENT" = "SessionStart" ]; then
|
||||
[ -f "$TOOL_PY" ] || exit 0
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
DIRS=""
|
||||
for d in "$PROJ/system-dev/wiki" "$PROJ/.claude/wiki" \
|
||||
"$PROJ/InkStoneCo/system-dev/wiki"; do
|
||||
[ -d "$d" ] && DIRS="$DIRS $d"
|
||||
done
|
||||
[ -n "$DIRS" ] || exit 0
|
||||
# shellcheck disable=SC2086
|
||||
# 🔴 不要寫成 `OUT=$(…) || OUT=""`:`audit` 有東西可報時**刻意回離開碼 1**,
|
||||
# 加上 pipefail,那個 `||` 會把剛拿到的結果整個清成空的
|
||||
# ⇒ **有 7 個檔爆表,而這一段一聲不吭**。(2026-08-28 寫的時候當場踩到。)
|
||||
OUT=$(python3 "$TOOL_PY" audit $DIRS 2>/dev/null | grep '🔴 太長' | head -6 || true)
|
||||
[ -n "$OUT" ] || exit 0
|
||||
python3 - "$OUT" <<'PY'
|
||||
import json, sys
|
||||
rows = [r for r in sys.argv[1].split("\n") if r.strip()]
|
||||
msg = "📚 wiki 有 %d 個檔沒人讀得完了" % len(rows)
|
||||
ctx = "\n".join([
|
||||
"📚 **這幾個 wiki 檔已經超過『讀得完』的門檻**(inkstone/ISEP#89):",
|
||||
"",
|
||||
] + rows + [
|
||||
"",
|
||||
"🔴 讀不完的必讀檔,跟沒有那個檔的差別只在於**它讓人以為有**。",
|
||||
"leo 2026-08-27 說那天「失去記憶」——東西都還在,只是沒人讀得完。",
|
||||
"",
|
||||
"要整理:`python3 scripts/wiki-compress plan <檔>` 看怎麼壓,",
|
||||
"然後 `apply <檔> --ticket owner/repo#N`(**只搬不改**,正文一個字都不動),",
|
||||
"壓完一定要 `verify` 逐條對帳。**壓縮這件事本身要有票**,不准順手做完沒人知道。",
|
||||
])
|
||||
print(json.dumps({"systemMessage": msg,
|
||||
"hookSpecificOutput": {"hookEventName": "SessionStart",
|
||||
"additionalContext": ctx}},
|
||||
ensure_ascii=False))
|
||||
PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ══ ② Write|Edit|MultiEdit:大幅刪減 wiki 檔 ⇒ 擋一次 ═══════════════════
|
||||
VERDICT=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import json, os, re, sys
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP:bad-json"); raise SystemExit
|
||||
|
||||
ti = d.get("tool_input") or {}
|
||||
path = ti.get("file_path") or ""
|
||||
# 只管 wiki 底下的 .md。別的檔案縮水是它們自己的事。
|
||||
if not path.endswith(".md") or not re.search(r"(^|/)(wiki)/", path):
|
||||
print("SKIP:not-wiki"); raise SystemExit
|
||||
|
||||
def size(s):
|
||||
return len(re.sub(r"\s+", "", s or ""))
|
||||
|
||||
# 留痕式出口:內容裡放了 wiki-compress-ok ⇒ 放行(同 wiki-secret-ok 的慣例)
|
||||
blob = json.dumps(ti, ensure_ascii=False)
|
||||
if "wiki-compress-ok" in blob:
|
||||
print("SKIP:opt-out"); raise SystemExit
|
||||
|
||||
before = after = 0
|
||||
if "content" in ti: # Write:跟磁碟上現有的比
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
before = size(f.read())
|
||||
except Exception:
|
||||
print("SKIP:new-file"); raise SystemExit # 新檔不是壓縮
|
||||
after = size(ti.get("content"))
|
||||
else: # Edit / MultiEdit
|
||||
edits = ti.get("edits") or [ti]
|
||||
for e in edits:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
before += size(e.get("old_string"))
|
||||
after += size(e.get("new_string"))
|
||||
|
||||
# 判準:**淨縮水的量**要夠大,比例也要夠大。兩個都要,才不會抓到小修。
|
||||
# · 至少砍掉 800 個非空白字元 —— 改錯字、刪一段話都到不了
|
||||
# · 而且砍掉的超過原本的 45% —— 「重寫一段」通常增減相抵
|
||||
if before < 800:
|
||||
print("SKIP:too-small"); raise SystemExit
|
||||
cut = before - after
|
||||
if cut < 800 or cut < before * 0.45:
|
||||
print("SKIP:not-a-compression"); raise SystemExit
|
||||
print("BLOCK:%s:%d:%d" % (os.path.basename(path), before, cut))
|
||||
' 2>/dev/null || echo "SKIP:crash")
|
||||
|
||||
case "$VERDICT" in
|
||||
BLOCK:*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 至多擋一次:被擋兩次的閘會被想辦法繞過,而繞過的閘等於不存在。
|
||||
SID=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("session_id", "") or "x")
|
||||
except Exception: print("x")
|
||||
' 2>/dev/null || printf 'x')
|
||||
STAMP="/tmp/.wiki-size-guard-$SID"
|
||||
[ -f "$STAMP" ] && exit 0
|
||||
touch "$STAMP" 2>/dev/null || true
|
||||
|
||||
FILE=$(printf '%s' "$VERDICT" | cut -d: -f2)
|
||||
BEFORE=$(printf '%s' "$VERDICT" | cut -d: -f3)
|
||||
CUT=$(printf '%s' "$VERDICT" | cut -d: -f4)
|
||||
|
||||
cat >&2 <<MSG
|
||||
📚 這是一次 wiki 壓縮,不是一次編輯(inkstone/ISEP#89)
|
||||
|
||||
檔案:$FILE
|
||||
這一筆會砍掉 $CUT 個字(原本 $BEFORE 個)——超過一半。
|
||||
|
||||
🔴 **壓縮會弄丟東西,而弄丟的當下沒有人會發現。**(票上原話)
|
||||
所以壓縮不能順手做,它要留得下痕跡、而且要能逐條對帳。
|
||||
|
||||
改走這條(三步,機器會幫你證明沒弄丟):
|
||||
|
||||
python3 scripts/wiki-compress plan <檔>
|
||||
python3 scripts/wiki-compress apply <檔> --ticket owner/repo#N
|
||||
python3 scripts/wiki-compress verify <檔>.before-compress <檔> <搬去的檔...>
|
||||
|
||||
它跟手改的差別只有兩件,但這兩件正是手改做不到的:
|
||||
· **--ticket 逼票號**,並把「這一版壓掉了什麼」寫進 \`.compress-log.md\`
|
||||
· **verify 用內文雜湊逐條對帳**——「沒弄丟」是算出來的,不是你說的
|
||||
|
||||
它的做法是**只搬不改**:舊的整條原封不動搬去 archive,原檔留一份目錄。
|
||||
實測 \`mistakes.md\` 7,681 行 → 1,199 行,**260 條一條都沒少,查詢快 22.5 倍**。
|
||||
|
||||
真的要手改(例如只是刪掉一段過期的說明):
|
||||
在寫入的內容裡放一個 \`wiki-compress-ok\`,並在 commit 說明理由。
|
||||
本閘**至多擋一次**,不會卡死。
|
||||
MSG
|
||||
exit 2
|
||||
+3
-1
@@ -41,6 +41,7 @@ usage() {
|
||||
solo <session_id> 這件事真的該自己做(subagent-first-guard)——回覆裡要寫理由
|
||||
dispatch <session_id> 派工單真的有例外(dispatch-format-guard)
|
||||
ask <session_id> 這題真的是人閘(ask-user-question-guard)
|
||||
roster <session_id> 派工真的是名單的例外(roster-guard)
|
||||
kbdb-down KBDB 真的連不上(history-first-guard)——回覆裡要說明
|
||||
|
||||
--list 列出目前存在的戳記
|
||||
@@ -93,7 +94,7 @@ case "$GATE" in
|
||||
kbdb-down)
|
||||
stamp /tmp/.kbdb-down "KBDB 連不上而放行——**回覆裡要說明**,這是留痕不是豁免" ;;
|
||||
|
||||
solo|dispatch|ask)
|
||||
solo|dispatch|ask|roster)
|
||||
SID="${1:-${CLAUDE_SESSION_ID:-}}"
|
||||
if [ -z "$SID" ]; then
|
||||
printf '❌ %s 要綁 session id(閘用它認「是不是同一個 session」)。\n' "$GATE" >&2
|
||||
@@ -104,6 +105,7 @@ case "$GATE" in
|
||||
solo) stamp "/tmp/.solo-ok-$SID" "這件事自己做——**回覆裡要寫理由**" ;;
|
||||
dispatch) stamp "/tmp/.dispatch-ok-$SID" "這張派工單的例外,放行一次" ;;
|
||||
ask) stamp "/tmp/.ask-ok-$SID" "這題確認是人閘,放行一次" ;;
|
||||
roster) stamp "/tmp/.roster-ok-$SID" "這次派工確認是名單的例外,放行一次" ;;
|
||||
esac ;;
|
||||
|
||||
*)
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# git-credential-gitea.sh — git credential helper:把 Gitea 憑證從 env/.env 餵給 git,
|
||||
# 讓 remote URL 不必嵌 token(inkstone/ISEP#47)。URL 乾淨 ⇒ git 把它印出來也不洩 token。
|
||||
#
|
||||
# 安裝(gitea-bootstrap.sh 會自動設好,這裡是手動法):
|
||||
# git config credential.helper "$(git rev-parse --show-toplevel)/scripts/git-credential-gitea.sh"
|
||||
# 只對 git.uncle6.me 的 get 供憑證;store/erase 不做事(token 不由 git 保管)。
|
||||
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
. "$DIR/lib/gitea-token.sh"
|
||||
|
||||
[ "${1:-}" = "get" ] || exit 0
|
||||
|
||||
HOST=""
|
||||
while IFS='=' read -r k v; do
|
||||
[ -z "$k" ] && break
|
||||
[ "$k" = "host" ] && HOST="$v"
|
||||
done
|
||||
case "$HOST" in
|
||||
git.uncle6.me|"") : ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
TOK=$(gitea_token) || exit 0
|
||||
[ -n "$TOK" ] || exit 0
|
||||
printf 'username=claude-code\n'
|
||||
printf 'password=%s\n' "$TOK"
|
||||
@@ -21,17 +21,36 @@
|
||||
# ⇒ 拿掉那個條件,這支就變成「AI 自己批准自己發佈」——**那正是 D20 要擋的事**。
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
ARMED="$ROOT/.github-armed"
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
# shellcheck source=lib/gitea-arm-common.sh
|
||||
. "$SCRIPT_DIR/lib/gitea-arm-common.sh"
|
||||
|
||||
# ── 保險寫在哪:**讀的人在哪讀,就寫在哪**(inkstone/ISEP#156,2026-09-17 實撞)──────
|
||||
#
|
||||
# 🔴 舊版寫 `ROOT=$(dirname $0)/..`(腳本自己的上一層)。ISEP 還住在 InkStoneCo 裡時兩者剛好
|
||||
# 同一個位置;**改成 plugin 發佈後,腳本住在 `~/.claude/plugins/cache/inkstone/isep/<ver>/`**
|
||||
# ⇒ 保險被寫進 plugin 快取資料夾,印「✅ 保險已解」,而出貨線與每一道閘都讀不到
|
||||
# ⇒ leo 回了碼、出貨照樣卡住,總管手動把檔案搬過去才通(inkstone/arcrun-rag#202 c7598)。
|
||||
# **一句「已解」卻沒解,比單純失敗更糟——它讓 leo 以為自己的動作生效了。**
|
||||
#
|
||||
# ⇒ 改用 `gitea_arm_proj_dir`(=`${CLAUDE_PROJECT_DIR:-$(pwd)}`),理由是**跟讀的人對齊**:
|
||||
# · main-and-prod-push-guard.sh/github-contact-guard.sh/stage-before-prod-guard.sh
|
||||
# 全部讀 `${CLAUDE_PROJECT_DIR:-$(pwd)}/.github-armed`
|
||||
# · ship.mjs 讀 `InkStoneCo/.github-armed`(總管 session 的 CLAUDE_PROJECT_DIR 就是它)
|
||||
# · 本檔自己的核准來源(`gitea-arm-check.sh` 讀的 token 與待核請求)本來就住在這裡
|
||||
# ⇒ 專案根猜錯時 check 會先因為讀不到 `.env`/待核請求而 fail-closed,**不會寫到錯的地方**
|
||||
# 不要再改回「腳本的位置」:腳本住哪是安裝方式決定的,保險住哪是讀的人決定的,兩者無關。
|
||||
ARMED="$(gitea_arm_proj_dir)/.github-armed"
|
||||
|
||||
# ── 唯一的授權來源:leo 在 Gitea 票上的回覆 ────────────────────────────
|
||||
# 不捕捉、不吞錯——check 失敗就讓它把原因印到 stderr 並中止本支。
|
||||
# 🔴 `--consume` 是必要的(2026-08-16 起 check 預設唯讀):本檔會**寫出保險**,
|
||||
# 那就是「放行」本身 ⇒ 這一次核准必須當場作廢,否則同一組碼能重複解保險。
|
||||
OUT=$("$ROOT/scripts/gitea-arm-check.sh" --consume) || {
|
||||
OUT=$("$SCRIPT_DIR/gitea-arm-check.sh" --consume) || {
|
||||
echo "" >&2
|
||||
echo "⛔ 沒有取得 leo 的核准 ⇒ 不寫保險(fail-closed)" >&2
|
||||
echo " 先跑:scripts/gitea-arm-request.sh <票號> \"<要做什麼>\",把代碼給 leo 回在那張票上。" >&2
|
||||
# 印絕對路徑:相對的 `scripts/…` 會對貼上那個人的 cwd 解析,站在 InkStoneCo 會叫到舊複本(ISEP#112 同病)
|
||||
echo " 先跑:$SCRIPT_DIR/gitea-arm-request.sh <票號> \"<要做什麼>\",把代碼給 leo 回在那張票上。" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -50,6 +69,7 @@ printf '%s\n%s\n%s\n' \
|
||||
> "$ARMED"
|
||||
|
||||
echo "✅ 保險已解(來源:leo 在 Gitea 票上的核准)"
|
||||
echo " 寫在:$ARMED"
|
||||
echo " 任務:$MISSION"
|
||||
echo " 有效至:$(date -r "$EXPIRY" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$EXPIRY")"
|
||||
echo " 提前上保險:rm $ARMED"
|
||||
|
||||
@@ -16,14 +16,29 @@ set -eu
|
||||
REPO=""
|
||||
[ "${1:-}" = "-r" ] && { REPO="${2:?-r 後面要接 owner/repo}"; }
|
||||
|
||||
REMOTE=$(git remote get-url gitea 2>/dev/null) || {
|
||||
echo "✗ 當前目錄沒有名為 gitea 的 remote。請 cd 到目標 repo,或先加 remote。" >&2; exit 1; }
|
||||
# 取當前 repo 的 remote(雲端叫 origin、本機慣例叫 gitea)
|
||||
REMOTE_NAME=$(git remote get-url gitea >/dev/null 2>&1 && echo gitea || (git remote get-url origin >/dev/null 2>&1 && echo origin))
|
||||
[ -n "$REMOTE_NAME" ] || { echo "✗ 當前目錄沒有 gitea/origin remote。請 cd 到目標 repo,或先加 remote。" >&2; exit 1; }
|
||||
REMOTE=$(git remote get-url "$REMOTE_NAME" 2>/dev/null)
|
||||
# 去掉網址裡可能嵌著的憑證 → 乾淨 URL(inkstone/ISEP#47:token 不進 remote URL)
|
||||
REMOTE=$(printf '%s' "$REMOTE" | sed -E 's#//[^/@]*@#//#')
|
||||
|
||||
TOKEN=$(printf '%s' "$REMOTE" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
[ "$TOKEN" = "$REMOTE" ] && { echo "✗ gitea remote URL 裡沒有 token,無法取得認證。" >&2; exit 1; }
|
||||
HOST=$(printf '%s' "$REMOTE" | sed -E 's|.*@([^/]+)/.*|\1|')
|
||||
# token 來源=env/.env(不從 remote URL 抽——那會逼 token 明文嵌在 URL 裡 → 洩進 log)
|
||||
_GT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
[ -f "$_GT_DIR/lib/gitea-token.sh" ] && . "$_GT_DIR/lib/gitea-token.sh"
|
||||
TOKEN=""; command -v gitea_token >/dev/null 2>&1 && TOKEN=$(gitea_token || true)
|
||||
[ -n "$TOKEN" ] || { echo "✗ 取不到 Gitea token(設 GITEA_TOKEN_CLAUDE_CODE,或在 .env 放 GITEA_TOKEN_CLAUDE_CODE=)。" >&2; exit 1; }
|
||||
HOST=$(printf '%s' "$REMOTE" | sed -E 's|.*://([^/]+)/.*|\1|')
|
||||
[ -n "$REPO" ] || REPO=$(printf '%s' "$REMOTE" | sed -E 's|.*://[^/]+/(.+)\.git$|\1|')
|
||||
|
||||
# 設定乾淨 remote URL + credential helper(冪等):URL 不含 token,認證改由 helper 從 env/.env 供
|
||||
# ⇒ git 把 remote URL 印出來(git-lfs 的 locksverify 那行)也不會洩 token(inkstone/ISEP#47)。
|
||||
if [ -f "$_GT_DIR/git-credential-gitea.sh" ]; then
|
||||
CUR=$(git remote get-url "$REMOTE_NAME" 2>/dev/null || true)
|
||||
case "$CUR" in *@*) git remote set-url "$REMOTE_NAME" "$REMOTE" && echo " ✓ remote $REMOTE_NAME 已清乾淨(移除嵌入的 token)" ;; esac
|
||||
git config credential.helper "$_GT_DIR/git-credential-gitea.sh"
|
||||
fi
|
||||
|
||||
echo "→ repo: $REPO host: $HOST"
|
||||
|
||||
# 狀態機(互斥 scope label)。順序=出貨流程:
|
||||
|
||||
@@ -30,16 +30,15 @@ if [[ ! -f "$LABELS_YAML" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- token:跟 InkStoneCo 頂層 gitea remote 拿同一把(不落地檔案、不印出來) ---
|
||||
# --- token:來源=env/.env(inkstone/ISEP#47),不再從 remote URL 抽(那會逼 token 明文嵌在 URL 裡) ---
|
||||
TOKEN="${GITEA_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
INKSTONE_TOP="${INKSTONE_TOP:-$HOME/Documents/tech_projects/InkStoneCo}"
|
||||
if [[ -d "$INKSTONE_TOP/.git" ]]; then
|
||||
TOKEN="$(git -C "$INKSTONE_TOP" remote get-url gitea 2>/dev/null | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')"
|
||||
fi
|
||||
_GT_LIB="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/lib/gitea-token.sh"
|
||||
[[ -f "$_GT_LIB" ]] && . "$_GT_LIB"
|
||||
command -v gitea_token >/dev/null 2>&1 && TOKEN="$(gitea_token || true)"
|
||||
fi
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
echo "拿不到 Gitea token——設 GITEA_TOKEN 環境變數,或確認 $INKSTONE_TOP 的 gitea remote 存在" >&2
|
||||
echo "拿不到 Gitea token——設 GITEA_TOKEN_CLAUDE_CODE 環境變數,或在 .env/頂層 .env 放 GITEA_TOKEN_CLAUDE_CODE=" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""isep-doctor — 這台機器上每一個 Claude Code,到底有沒有把 ISEP 載進來(inkstone/ISEP#150)
|
||||
|
||||
為什麼要有這支:
|
||||
plugin 載入失敗時,ISEP 的閘**一支都不會跑**,而且**沒有任何閘能講出來**——
|
||||
講話的 isep-presence-beacon 本身就在那個沒載入的 plugin 裡。
|
||||
|
||||
為什麼一台機器要分開問(09-13 總管複驗 c6991 推翻了第一版):
|
||||
同一台 Mac 上有**不只一個** Claude Code:CLI(例:/opt/homebrew/bin/claude 2.1.220)
|
||||
與 Claude Desktop 內建的那一份(~/Library/Application Support/Claude/claude-code/<版本>/…,例:2.1.266)。
|
||||
它們讀同一份 ~/.claude,**卻不一定得出同一個結論**:
|
||||
settings.json 的 extraKnownMarketplaces.inkstone.source 多了 path 時——
|
||||
2.1.266:整個 marketplace 被忽略 ⇒ isep@inkstone 報 "Marketplace inkstone not found"
|
||||
2.1.220:照常載入
|
||||
第一版只看「設定長得像會出事」就對整台機器判 ❌,在 2.1.220 上是假陽性。
|
||||
|
||||
判準(實際狀態,不是設定長相):
|
||||
對每一個找得到的 Claude Code 各跑一次 `plugin list --json`,看它**自己**回報的 isep@inkstone:
|
||||
沒有這一筆 ⇒ ❌ 沒安裝
|
||||
帶 errors ⇒ ❌ 沒載入(照實轉述;若 settings 比已安裝的 marketplace 多了抓取欄位,附上原因與修法)
|
||||
沒有 errors ⇒ ✅(即使設定裡有 path——那台實際載得進來就是載得進來)
|
||||
跑不動/看不懂 ⇒ ◐ 查不了(讀不到不等於沒載入,也不等於有)
|
||||
設定比對**只當解釋**,永遠不單獨判 ❌。
|
||||
|
||||
離開碼:0 全部 ✅|1 至少一個 ❌|2 沒有 ❌、但至少一個查不了(或一個 Claude Code 都找不到)
|
||||
|
||||
測試用環境變數:
|
||||
ISEP_DOCTOR_CLAUDE_BINS 用這份清單取代自動尋找;以 os.pathsep 分隔,每項 `名稱=可執行檔路徑`
|
||||
ISEP_DOCTOR_CLAUDE_HOME 代替 ~/.claude
|
||||
迴歸測試:scripts/test-isep-doctor.sh(離線)/scripts/test-isep-doctor-live.sh(真的 Claude Code、隔離 HOME)
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PLUGIN_ID = "isep@inkstone"
|
||||
MARKET = "inkstone"
|
||||
FETCH_FIELDS = ("path", "ref", "headers", "sparsePaths")
|
||||
DESKTOP_GLOB = "~/Library/Application Support/Claude/claude-code/*/claude.app/Contents/MacOS/claude"
|
||||
|
||||
home = os.environ.get("ISEP_DOCTOR_CLAUDE_HOME") or os.path.expanduser("~/.claude")
|
||||
|
||||
|
||||
def _ver_key(path):
|
||||
m = re.search(r"/claude-code/([0-9][0-9.]*)/", path)
|
||||
return tuple(int(x) for x in m.group(1).split(".") if x) if m else ()
|
||||
|
||||
|
||||
def find_claudes():
|
||||
"""回傳 [(名稱, 路徑)]。Desktop 只取最新版本那一份(它啟動的就是那一份;舊資料夾可能殘留)。"""
|
||||
env = os.environ.get("ISEP_DOCTOR_CLAUDE_BINS")
|
||||
if env is not None:
|
||||
out = []
|
||||
for item in env.split(os.pathsep):
|
||||
if "=" in item:
|
||||
name, path = item.split("=", 1)
|
||||
out.append((name, path))
|
||||
return out
|
||||
out = []
|
||||
cli = shutil.which("claude")
|
||||
if cli:
|
||||
out.append(("CLI", cli))
|
||||
desk = sorted(glob.glob(os.path.expanduser(DESKTOP_GLOB)), key=_ver_key) # 數字排序:2.1.99 < 2.1.266
|
||||
if desk:
|
||||
out.append(("Desktop", desk[-1]))
|
||||
return out
|
||||
|
||||
|
||||
def version_of(exe):
|
||||
try:
|
||||
r = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=30)
|
||||
return (r.stdout.strip().split() or ["?"])[0]
|
||||
except Exception: # noqa: BLE001
|
||||
return "?"
|
||||
|
||||
|
||||
def ask(exe):
|
||||
"""回傳 (狀態, 內容)。狀態:'list' 內容=plugin list 陣列;'err' 內容=查不了的原因。"""
|
||||
try:
|
||||
r = subprocess.run([exe, "plugin", "list", "--json"], capture_output=True, text=True, timeout=90)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return "err", f"執行失敗({e})"
|
||||
try:
|
||||
data = json.loads(r.stdout)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("不是陣列")
|
||||
return "list", data
|
||||
except Exception: # noqa: BLE001
|
||||
tail = (r.stdout or r.stderr or "").strip().splitlines()[-1:] or ["(沒有輸出)"]
|
||||
return "err", f"plugin list --json 看不懂(exit={r.returncode}:{tail[0][:200]})"
|
||||
|
||||
|
||||
def load(p):
|
||||
try:
|
||||
return json.load(open(p))
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
declared = (load(os.path.join(home, "settings.json")).get("extraKnownMarketplaces") or {}).get(MARKET, {}).get("source")
|
||||
known = (load(os.path.join(home, "plugins", "known_marketplaces.json")).get(MARKET) or {}).get("source")
|
||||
extra = []
|
||||
if isinstance(declared, dict):
|
||||
extra = [k for k in FETCH_FIELDS if k in declared and (not isinstance(known, dict) or declared.get(k) != known.get(k))]
|
||||
|
||||
claudes = find_claudes()
|
||||
if not claudes:
|
||||
print("◐ 查不了:這台找不到任何 Claude Code 可執行檔(CLI 與 Desktop 都沒有)")
|
||||
sys.exit(2)
|
||||
|
||||
bad, unknown, extra_hit = [], [], False
|
||||
for name, exe in claudes:
|
||||
label = f"{name} {version_of(exe)}"
|
||||
state, data = ask(exe)
|
||||
if state == "err":
|
||||
unknown.append(name)
|
||||
print(f"◐ {label}:查不了——{data}(讀不到不等於沒載入)")
|
||||
print(f" {exe}")
|
||||
continue
|
||||
entry = next((p for p in data if p.get("id") == PLUGIN_ID), None)
|
||||
if entry is None:
|
||||
bad.append(name)
|
||||
print(f"❌ {label}:{PLUGIN_ID} 沒有安裝——用這個 Claude Code 開的 session 一支 ISEP 閘都沒有")
|
||||
print(" 修法:claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git && claude plugin install isep@inkstone")
|
||||
continue
|
||||
errors = entry.get("errors") or []
|
||||
if not errors:
|
||||
print(f"✅ {label}:{PLUGIN_ID} {entry.get('version')} 已載入(enabled={entry.get('enabled')})")
|
||||
continue
|
||||
bad.append(name)
|
||||
print(f"❌ {label}:{PLUGIN_ID} {entry.get('version')} 沒有載入——用這個 Claude Code 開的 session 一支 ISEP 閘都沒有")
|
||||
for e in errors:
|
||||
print(f" Claude Code 回報:{e}")
|
||||
if extra:
|
||||
extra_hit = True
|
||||
print(f" 可能原因:~/.claude/settings.json 的 extraKnownMarketplaces.{MARKET}.source 多了 {', '.join(extra)},"
|
||||
"這一版把它當 git 抓取欄位,跟已安裝的 marketplace 對不上,整個 marketplace 被忽略")
|
||||
|
||||
if extra_hit:
|
||||
fields = ", ".join(repr(k) for k in extra)
|
||||
print("修法(一行,只拿掉上面點名的欄位;改完完全關掉 Claude Desktop/重開 CLI session 再跑一次本指令):")
|
||||
print(f" python3 -c \"import json,os;p=os.path.expanduser('~/.claude/settings.json');d=json.load(open(p));"
|
||||
f"s=d['extraKnownMarketplaces']['{MARKET}']['source'];[s.pop(k,None) for k in ({fields},)];"
|
||||
"json.dump(d,open(p,'w'),ensure_ascii=False,indent=2)\"")
|
||||
|
||||
if bad:
|
||||
sys.exit(1)
|
||||
sys.exit(2 if unknown else 0)
|
||||
Executable
+440
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env python3
|
||||
"""isep-nag — 把「沒有人會叫的事」叫出來(inkstone/ISEP#93)
|
||||
|
||||
━━ 這支解的問題 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
有些事情**沒有人會叫**:一張票掛著等 leo 三天了、一個 milestone 逾期了、
|
||||
一根棒子交回來躺著沒人接。它們不會壞掉、不會噴錯,**只是靜靜地過期**。
|
||||
|
||||
leo 整天上課,一天看兩眼。**不叫,就等於這件事不會發生。**
|
||||
|
||||
━━ 🔴 刻意不做什麼(這不是簡化,是判斷)━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
**不做排程輪詢、不做 webhook fan-out、不掛 Gitea Actions。**
|
||||
· 頂層 `CLAUDE.md`「避免再被 GitHub flag 的硬規則」:禁一事件 fan-out 到多 repo
|
||||
· `issue-handle` skill:「**有事才讀**」,換成 Gitea 也不放寬
|
||||
· ISEP v0.6.0 §8.2:第一版不依賴 runner——「壞掉的形式是『以為有人在跑』,
|
||||
比『沒人跑』貴得多」
|
||||
⇒ 本支只在**兩個時機**跑:開 session 時順手一次、人要看的時候手動一次。
|
||||
|
||||
**已知限制,誠實寫在這裡**:它只在 CC 醒著的時候會叫。
|
||||
「CC 整個沒在跑」的那一段補不到——那要 leo 決定要不要開 Gitea 的通知權限
|
||||
(機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/…/hooks` 回 403,裝不了)。
|
||||
**但腳本版本身是完整可用的,不是半成品。**
|
||||
|
||||
━━ 撈四種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
① 逾期的 milestone 期限過了、票還沒關 ⇒ 那個版本交不出來了
|
||||
② 掛著等 leo 的票 `Human` 或指派給 leo,**標等了幾天**
|
||||
③ 棒子掉在地上的票 `s/doing`/`s/review` 卻很多天沒動 ⇒ 沒有人在做
|
||||
④ 有票卻沒有工人的 repo 名單(`agents/`)上沒人負責、卻有開著的票
|
||||
⇒ 派工一定撞 roster-guard(inkstone/ISEP#118)
|
||||
**只進長版**(開場給總管看),不進 Telegram 短版——補工人是總管的事,不吵 leo。
|
||||
它回答的是「新 repo 的工人誰在什麼時候加」:建 repo 之後的**第一個 session 開場**,
|
||||
由這支點名;在那之前沒有任何東西會提醒。
|
||||
|
||||
每一條都用白話寫(`CLAUDE.md` 規則五):一眼看懂、代號附一句人話、
|
||||
講「這對你意味什麼」而不是系統內部狀態。
|
||||
|
||||
🔴 **沒東西可報時會說「查過了,沒有」**,不會安靜結束——
|
||||
安靜跟壞掉長得一模一樣,而分不出來的東西沒有人敢信。
|
||||
|
||||
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
isep-nag 撈一次,白話印出來
|
||||
isep-nag --json 同一份資料的機器格式
|
||||
isep-nag --notify 撈完順便發 Telegram(走 isep-notify,發不出去有退路)
|
||||
isep-nag --short 只印 Telegram 版(很短,leo 手機上看的那則)
|
||||
isep-nag --both 長版+分隔線+短版,一次算完(開場的 hook 用這個)
|
||||
isep-nag --fixture <檔> 用假資料跑(離線測試用,完全不碰網路)
|
||||
isep-nag --now <epoch> 把時鐘定住(測試用)
|
||||
|
||||
環境變數:
|
||||
ISEP_NAG_REPOS 只看這幾個 repo(空白分隔)。預設:整個 org 撈一次
|
||||
ISEP_NAG_ORG 預設 inkstone
|
||||
ISEP_NAG_STALE_DAYS 「幾天沒動算掉棒」,預設 3
|
||||
TICKET_HOST Gitea 位址,預設 https://git.uncle6.me
|
||||
"""
|
||||
import concurrent.futures as cf
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# leo 看的是台北時間。容器多半跑在 UTC ⇒ 不指定就會印出一個他要自己換算的數字。
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
TAIPEI = ZoneInfo("Asia/Taipei")
|
||||
except Exception:
|
||||
TAIPEI = timezone(timedelta(hours=8))
|
||||
|
||||
HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
|
||||
ORG = os.environ.get("ISEP_NAG_ORG") or "inkstone"
|
||||
STALE_DAYS = int(os.environ.get("ISEP_NAG_STALE_DAYS") or 3)
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 「等 leo」的兩個訊號:掛 Human 標籤,或指派給他本人。
|
||||
HUMAN_LABEL = "Human"
|
||||
LEO_LOGINS = {"leo", "leo21c", "Leo"}
|
||||
# 「有人領了」的狀態標籤——掛著這個卻很久沒動,就是棒子掉在地上。
|
||||
BATON_LABELS = {"s/doing", "s/review"}
|
||||
|
||||
|
||||
def token():
|
||||
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
|
||||
v = os.environ.get(env)
|
||||
if v:
|
||||
return v
|
||||
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
||||
host = HOST.split("//")[-1].rstrip("/")
|
||||
try:
|
||||
out = subprocess.run(["git", "-C", root, "remote", "-v"],
|
||||
capture_output=True, text=True, timeout=20).stdout
|
||||
except Exception:
|
||||
out = ""
|
||||
for line in out.splitlines():
|
||||
if host in line:
|
||||
m = re.search(r"//[^:/]+:([^@]+)@", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def api(path, tok):
|
||||
url = "%s/api/v1%s" % (HOST, path)
|
||||
req = urllib.request.Request(url, headers={"Authorization": "token %s" % tok})
|
||||
try:
|
||||
return json.load(urllib.request.urlopen(req, timeout=25))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def ts(s):
|
||||
"""Gitea 的時間字串 → epoch。看不懂就回 None(不猜)。"""
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
.astimezone(timezone.utc).timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def days(a, b):
|
||||
return max(0, int((a - b) // 86400))
|
||||
|
||||
|
||||
# ══ 一、把資料撈回來(或從 fixture 讀)═══════════════════════════════════
|
||||
def collect(fixture=None):
|
||||
"""回 {"milestones": [...], "issues": [...]}。撈不到就回空的,不編。"""
|
||||
if fixture:
|
||||
with open(fixture) as f:
|
||||
return json.load(f)
|
||||
|
||||
tok = token()
|
||||
if not tok:
|
||||
return {"milestones": [], "issues": [], "error":
|
||||
"拿不到 Gitea token(GITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"}
|
||||
|
||||
# org 的 repo 清單**一律撈一次**:④「有票卻沒工人」要看整個 org,
|
||||
# 而且這一通本來就要打(沒設 ISEP_NAG_REPOS 時),不多花 API。
|
||||
org_rows = api("/orgs/%s/repos?limit=50" % ORG, tok) or []
|
||||
repos = os.environ.get("ISEP_NAG_REPOS", "").split()
|
||||
if not repos:
|
||||
repos = [r.get("full_name") for r in org_rows if r.get("full_name")]
|
||||
|
||||
# 🔴 平行撈,因為這支掛在 SessionStart 上——那是 leo 每次開工的關鍵路徑。
|
||||
# 循序版實測 16 秒(16 個 repo + 5 頁票),**開場等 16 秒沒有人會忍受它,
|
||||
# 而被忍不了的東西下一步就是被關掉**。平行之後落在 3 秒上下。
|
||||
milestones = []
|
||||
with cf.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
jobs = {pool.submit(api, "/repos/%s/milestones?state=open" % f, tok): f
|
||||
for f in repos}
|
||||
for job in cf.as_completed(jobs):
|
||||
full = jobs[job]
|
||||
try:
|
||||
rows = job.result() or []
|
||||
except Exception:
|
||||
continue
|
||||
for m in rows:
|
||||
m = dict(m or {})
|
||||
m["repo"] = full
|
||||
milestones.append(m)
|
||||
|
||||
# 票用**一支跨 repo 的搜尋**撈,不是每個 repo 打一次:
|
||||
# 16 個 repo × 一次 = 16 通 API,那個形狀就開始像輪詢了。
|
||||
# 而且只撈**這三種標籤**(Gitea 的 labels= 是 OR)——不是撈回全部再自己篩:
|
||||
# 全撈是 5 頁,篩過是 1–2 頁。
|
||||
# ⚠️ 已知取捨:**沒掛 Human 標籤、只是被指派給 leo 的票,這條路撈不到。**
|
||||
# 真相源是標籤(`labels.yaml`),指派是輔助訊號 ⇒ 寧可漏掉那個邊角,
|
||||
# 也不要為它把開場多花 13 秒。要撈全部就設 ISEP_NAG_ALL_ISSUES=1。
|
||||
want = ",".join(sorted({HUMAN_LABEL} | BATON_LABELS))
|
||||
qs = "" if os.environ.get("ISEP_NAG_ALL_ISSUES") == "1" else \
|
||||
"&labels=%s" % urllib.parse.quote(want)
|
||||
issues = []
|
||||
for page in range(1, 6):
|
||||
rows = api("/repos/issues/search?state=open&type=issues&limit=50&page=%d%s"
|
||||
% (page, qs), tok) or []
|
||||
if not rows:
|
||||
break
|
||||
issues.extend(rows)
|
||||
if len(rows) < 50:
|
||||
break
|
||||
return {"milestones": milestones, "issues": issues, "repos": org_rows}
|
||||
|
||||
|
||||
# ══ 二、找出「該叫的事」═══════════════════════════════════════════════════
|
||||
def findings(data, now):
|
||||
over, waiting, dropped = [], [], []
|
||||
|
||||
for m in data.get("milestones") or []:
|
||||
due = ts(m.get("due_on"))
|
||||
if not due or due >= now:
|
||||
continue # 沒設期限的不是節拍器;還沒到期的不叫
|
||||
open_n = int(m.get("open_issues") or 0)
|
||||
if open_n == 0:
|
||||
continue # 票都關完了,只是沒人按關閉鈕——不吵
|
||||
over.append({
|
||||
"repo": m.get("repo") or "?",
|
||||
"title": m.get("title") or "(沒有名字)",
|
||||
"due": (m.get("due_on") or "")[:10],
|
||||
"late_days": days(now, due),
|
||||
"open": open_n,
|
||||
"closed": int(m.get("closed_issues") or 0),
|
||||
})
|
||||
over.sort(key=lambda x: -x["late_days"])
|
||||
|
||||
for i in data.get("issues") or []:
|
||||
if i.get("pull_request"):
|
||||
continue # PR 有 pr-verdict-guard 在管,不重複點名
|
||||
full = (i.get("repository") or {}).get("full_name") or ""
|
||||
# 🔴 只看設定的那個 org。跨 repo 搜尋端點會把**舊 org 的同名票**一起回來
|
||||
# (實測:`Leo/Arcrun#85` 與 `inkstone/Arcrun#85` 是同一件事的兩份),
|
||||
# 照單全收會讓「26 張掉棒」裡有一半是重複——**灌水的清單等於沒有清單**。
|
||||
if ORG and not full.startswith(ORG + "/"):
|
||||
continue
|
||||
labels = {(l or {}).get("name") for l in (i.get("labels") or [])}
|
||||
who = {(a or {}).get("login") for a in (i.get("assignees") or [])}
|
||||
upd = ts(i.get("updated_at")) or now
|
||||
row = {
|
||||
"ref": "%s#%s" % ((i.get("repository") or {}).get("full_name") or "?",
|
||||
i.get("number")),
|
||||
"title": (i.get("title") or "").strip(),
|
||||
"quiet_days": days(now, upd),
|
||||
"labels": sorted(labels),
|
||||
"url": i.get("html_url") or "",
|
||||
}
|
||||
if HUMAN_LABEL in labels or (who & LEO_LOGINS):
|
||||
waiting.append(row)
|
||||
elif (labels & BATON_LABELS) and row["quiet_days"] >= STALE_DAYS:
|
||||
dropped.append(row)
|
||||
|
||||
waiting.sort(key=lambda x: -x["quiet_days"])
|
||||
dropped.sort(key=lambda x: -x["quiet_days"])
|
||||
return {"overdue_milestones": over, "waiting_on_leo": waiting,
|
||||
"dropped_batons": dropped,
|
||||
"repos_without_worker": no_worker(data)}
|
||||
|
||||
|
||||
def no_worker(data):
|
||||
"""④ 有票卻沒有工人的 repo(inkstone/ISEP#118)。
|
||||
|
||||
回 list(可能是空的);**名單讀不到或 org 清單沒撈到 ⇒ 回 None**,
|
||||
報告裡講「沒查到」,不講成「每個 repo 都缺人」或「都有人」。
|
||||
"""
|
||||
rows = data.get("repos")
|
||||
if not rows:
|
||||
return None
|
||||
try:
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "hooks", "lib"))
|
||||
import roster
|
||||
except Exception:
|
||||
return None
|
||||
return roster.uncovered(rows)
|
||||
|
||||
|
||||
# ══ 三、講出來(白話)═════════════════════════════════════════════════════
|
||||
MAX_ROWS = int(os.environ.get("ISEP_NAG_MAX_ROWS") or 8)
|
||||
BOTH_SEP = "␟--isep-nag-short--␟" # --both 的分隔線(不會出現在正文裡)
|
||||
|
||||
|
||||
def _tail(rows, shown):
|
||||
"""超過封頂的部分收成一行。**不是藏起來,是講出來還有幾張。**"""
|
||||
rest = len(rows) - shown
|
||||
return [" · …另外還有 %d 張(`isep-nag --json` 看全部)" % rest] if rest > 0 else []
|
||||
|
||||
|
||||
def _waited(d):
|
||||
"""0 天講「今天剛掛上」——「等了 0 天」讀起來像壞掉的計數器。"""
|
||||
return "今天剛掛上" if d == 0 else "等了 %d 天" % d
|
||||
|
||||
|
||||
def _clip(s, n):
|
||||
s = (s or "").replace("\n", " ").strip()
|
||||
return s if len(s) <= n else s[:n - 1] + "…"
|
||||
|
||||
|
||||
def full_report(f, now, err=None):
|
||||
"""給眼前看的長版。**沒東西也一定有話講。**"""
|
||||
when = datetime.fromtimestamp(now, timezone.utc).astimezone(TAIPEI).strftime("%Y-%m-%d %H:%M")
|
||||
out = ["🔔 催辦員查了一次(%s)——這些事沒有人會叫,所以它叫(inkstone/ISEP#93)" % when]
|
||||
if err:
|
||||
out += ["", "🔴 撈不到資料:%s" % err,
|
||||
" ⇒ **這不等於「沒有事情逾期」**,這是「我沒查到」。兩件事不要講成同一句。"]
|
||||
return "\n".join(out)
|
||||
|
||||
n = sum(len(f[k]) for k in ("overdue_milestones", "waiting_on_leo", "dropped_batons"))
|
||||
if n == 0:
|
||||
out += ["", "✅ **查過了,沒有。** 三類都乾淨:",
|
||||
" · 沒有逾期還開著票的 milestone",
|
||||
" · 沒有掛著等你的票",
|
||||
" · 沒有超過 %d 天沒動的 s/doing/s/review" % STALE_DAYS]
|
||||
out += worker_section(f)
|
||||
return "\n".join(out)
|
||||
|
||||
if f["overdue_milestones"]:
|
||||
out += ["", "⏰ **逾期的 milestone(%d 個)**——期限過了,票還開著,"
|
||||
"代表那個版本交不出來了:" % len(f["overdue_milestones"])]
|
||||
for m in f["overdue_milestones"][:MAX_ROWS]:
|
||||
out.append(" · %s 「%s」——%s 到期,**逾期 %d 天**,還有 %d 張票沒關"
|
||||
% (m["repo"], m["title"], m["due"], m["late_days"], m["open"]))
|
||||
out += _tail(f["overdue_milestones"], MAX_ROWS)
|
||||
out.append(" ⇒ 對你意味著:不是改期限,就是把票搬走。**放著它只會繼續爛。**")
|
||||
|
||||
if f["waiting_on_leo"]:
|
||||
out += ["", "🙋 **在等你的票(%d 張)**——只有你按得下去,"
|
||||
"沒人替得了:" % len(f["waiting_on_leo"])]
|
||||
for i in f["waiting_on_leo"][:MAX_ROWS]:
|
||||
out.append(" · %s **%s**:%s"
|
||||
% (i["ref"], _waited(i["quiet_days"]), _clip(i["title"], 42)))
|
||||
out += _tail(f["waiting_on_leo"], MAX_ROWS)
|
||||
out.append(" ⇒ 對你意味著:回一個詞就能推動。不回,這幾件永遠不會發生。")
|
||||
|
||||
if f["dropped_batons"]:
|
||||
out += ["", "🧤 **棒子掉在地上(%d 張)**——標著「有人在做」,"
|
||||
"但超過 %d 天沒動:" % (len(f["dropped_batons"]), STALE_DAYS)]
|
||||
for i in f["dropped_batons"][:MAX_ROWS]:
|
||||
out.append(" · %s **靜了 %d 天**(%s):%s"
|
||||
% (i["ref"], i["quiet_days"], "/".join(i["labels"]),
|
||||
_clip(i["title"], 38)))
|
||||
out += _tail(f["dropped_batons"], MAX_ROWS)
|
||||
out.append(" ⇒ 對你意味著:這些不是在做,是沒人接。要嘛派人,要嘛改回 s/todo。")
|
||||
out += worker_section(f)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def worker_section(f):
|
||||
"""④ 只進長版(開場注入給總管看的那份),**不進 Telegram 短版**:
|
||||
派工是總管的事,不是 leo 的事——拿它去吵 leo 就是把總管的活推給他。"""
|
||||
rows = f.get("repos_without_worker")
|
||||
if rows is None:
|
||||
return ["", "👷 有票卻沒有工人的 repo:**沒查到**(org 清單或工人名單讀不到)"
|
||||
"——這不等於「每個 repo 都有人」。"]
|
||||
if not rows:
|
||||
return ["", "👷 每個有開著票的 repo,名單上都有工人。"]
|
||||
out = ["", "👷 **有票卻沒有工人的 repo(%d 個)**——派工到這裡會被 roster-guard 擋,"
|
||||
"只剩開例外閘繞過去:" % len(rows)]
|
||||
for r in rows[:MAX_ROWS]:
|
||||
out.append(" · %s(%d 張票開著)" % (r["repo"], r["open"]))
|
||||
out += _tail(rows, MAX_ROWS)
|
||||
out.append(" ⇒ 對總管意味著:**先補工人再派工**——在 ISEP `agents/` 加一個 `.md`"
|
||||
"(規約 docs/governance/worker-roster.md),或把它寫進既有工人的 `負責 repo`。"
|
||||
"不要開 `/tmp/.roster-ok-*` 了事,那樣下一次還是會撞。")
|
||||
return out
|
||||
|
||||
|
||||
def short_report(f, now, err=None):
|
||||
"""Telegram 版:leo 在手機上一眼掃完、回一個詞。**寧短勿長。**"""
|
||||
if err:
|
||||
return "[總管] 🔴 催辦員查不到資料(%s)。這不是「沒事」,是「沒查到」。" % _clip(err, 60)
|
||||
a, b, c = (len(f["overdue_milestones"]), len(f["waiting_on_leo"]),
|
||||
len(f["dropped_batons"]))
|
||||
if a + b + c == 0:
|
||||
return "[總管] ✅ 查過了,沒有:沒有逾期的 milestone、沒有在等你的票、沒有掉在地上的棒子。"
|
||||
|
||||
lines = ["[總管] 🔔 有 %d 件沒人叫的事:" % (a + b + c)]
|
||||
if a:
|
||||
top = f["overdue_milestones"][0]
|
||||
lines.append("⏰ %d 個 milestone 逾期,最久的是「%s」(%s,逾期 %d 天,%d 張票沒關)"
|
||||
% (a, _clip(top["title"], 20), top["repo"].split("/")[-1],
|
||||
top["late_days"], top["open"]))
|
||||
if b:
|
||||
top = f["waiting_on_leo"][0]
|
||||
lines.append("🙋 %d 張票在等你,最久的 %s %s:%s"
|
||||
% (b, top["ref"], _waited(top["quiet_days"]),
|
||||
_clip(top["title"], 26)))
|
||||
if c:
|
||||
top = f["dropped_batons"][0]
|
||||
lines.append("🧤 %d 張標著「有人在做」卻沒人動,最久 %d 天:%s"
|
||||
% (c, top["quiet_days"], top["ref"]))
|
||||
lines.append("回「看」我就把完整清單貼出來。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ══ 四、CLI ══════════════════════════════════════════════════════════════
|
||||
def main(argv):
|
||||
fixture = now = None
|
||||
want_json = short = do_notify = both = False
|
||||
ticket = "inkstone/ISEP#93"
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--json":
|
||||
want_json = True
|
||||
elif a == "--short":
|
||||
short = True
|
||||
elif a == "--both":
|
||||
# 開場的 hook 用這個:長版+短版**一次算完**。
|
||||
# 分兩次呼叫=打兩輪 Gitea=開場多等 5 秒,而那是 leo 每次開工的關鍵路徑。
|
||||
both = True
|
||||
elif a == "--notify":
|
||||
do_notify = True
|
||||
elif a == "--fixture":
|
||||
i += 1; fixture = argv[i]
|
||||
elif a == "--now":
|
||||
i += 1; now = int(argv[i])
|
||||
elif a == "--fallback-ticket":
|
||||
i += 1; ticket = argv[i]
|
||||
elif a in ("-h", "--help"):
|
||||
print(__doc__); return 0
|
||||
else:
|
||||
print("不認得的參數:%s(--help 看用法)" % a, file=sys.stderr)
|
||||
return 2
|
||||
i += 1
|
||||
|
||||
now = now if now is not None else int(time.time())
|
||||
try:
|
||||
data = collect(fixture)
|
||||
except Exception as e:
|
||||
data = {"milestones": [], "issues": [], "error": "撈的時候炸了:%s" % e}
|
||||
err = data.get("error")
|
||||
f = findings(data, now)
|
||||
|
||||
if both:
|
||||
print(full_report(f, now, err))
|
||||
print(BOTH_SEP)
|
||||
print(short_report(f, now, err))
|
||||
elif want_json:
|
||||
print(json.dumps({"now": now, "error": err, "findings": f},
|
||||
ensure_ascii=False, indent=2))
|
||||
elif short:
|
||||
print(short_report(f, now, err))
|
||||
else:
|
||||
print(full_report(f, now, err))
|
||||
|
||||
if do_notify:
|
||||
# 🔴 走 isep-notify,不自己打 HTTP:那支才會先問閘、發不出去才有退路。
|
||||
# 在這裡自己 urlopen 一次,就等於多開一條沒有退路的路。
|
||||
p = subprocess.run(
|
||||
[sys.executable, os.path.join(HERE, "isep-notify"),
|
||||
"--text", short_report(f, now, err), "--fallback-ticket", ticket],
|
||||
capture_output=True, text=True)
|
||||
sys.stderr.write(p.stdout + p.stderr)
|
||||
return p.returncode
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+485
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env python3
|
||||
"""isep-notify — 發一則 Telegram 給 leo,而且**發不出去的時候不會安靜**
|
||||
(inkstone/ISEP#93)
|
||||
|
||||
━━ 這支存在的理由(2026-08-28 實測出來的限制)━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
通道本身早就有(`notify_leo`,一條 curl、不需金鑰,見頂層 wiki `agent-memory.md`)。
|
||||
問題不在通道,在**閘**:
|
||||
|
||||
· `prod-write-guard.sh` 擋「打到線上實例的寫入型請求」,而發一則 Telegram
|
||||
在它眼裡跟「部署一個工作流上線」長得一模一樣(同一個 named webhook 家族路徑)
|
||||
· v0.10.0 已經修好——只放行 `notify_leo` 這**一個名字**
|
||||
· **但 hook 的註冊路徑是 session 啟動當下寫死的** ⇒ 更新 plugin 之後,
|
||||
要開一個**新** session 才會載到新那份
|
||||
|
||||
⇒ 所以「能不能發得出去」是**每個 session 各自不同的事實**,不是全域設定。
|
||||
這支的工作就是:**先去問這個 session 的閘,再決定怎麼辦**,而不是先射再說。
|
||||
|
||||
🔴 **發不出去而靜默,等於沒做。** 三條出路,一條都不能省:
|
||||
① 先問閘(`--gate`):把「這個 session 載到的是哪一版、它會不會擋」變成查得到的事實
|
||||
② 閘說會擋 ⇒ **不送**(不繞路,見下),改走退路:貼回票上 + 大聲印在眼前
|
||||
③ 送出去了也要驗**內層** `data.data.ok`——外層 200 不算送到(wiki 記過這個坑)
|
||||
|
||||
━━ 為什麼閘說會擋就不送,而不是「反正 python 打得出去」━━━━━━━━━━━━━━━
|
||||
這支用 urllib 打 HTTP,`prod-write-guard` 掛在 `Bash` 上——**它看不到這支**。
|
||||
也就是說:不問閘就送,一定送得出去。**而那正是不能做的事。**
|
||||
|
||||
leo 2026-08-11:「hook 要不讓它對正式環境做任何推送,不是某種推送」
|
||||
leo(同期):「人就會學會繞過它,那它就等於不存在」
|
||||
|
||||
一支「繞過閘也照送」的工具,會讓那道閘對整條 python 路徑失效。
|
||||
⇒ 本支**自己去問那道閘**,閘說不行就不行。這比 shell 那條路更嚴,不是更鬆。
|
||||
|
||||
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
isep-notify --gate 只印閘的判定(不送、不碰網路)
|
||||
isep-notify --text "…" 送一則
|
||||
isep-notify --text-file <檔> 內文放檔案(長訊息用)
|
||||
isep-notify --text "…" --fallback-ticket inkstone/ISEP#93
|
||||
送不出去時貼回那張票
|
||||
isep-notify --text "…" --dry-run 全流程走一遍,只是不真的送
|
||||
|
||||
離開碼:0 = leo 拿得到了(Telegram 送達,或退路留在票上)
|
||||
1 = 兩條路都沒走通(這時 stderr 一定有一段紅字,不會安靜)
|
||||
|
||||
環境變數(都有預設,測試才需要動):
|
||||
TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID 有就先走 Bot API 直送(雲端唯一通的路,ISEP#130)
|
||||
ISEP_NOTIFY_BOT_API Bot API 的主機(預設 https://api.telegram.org;測試指到假的)
|
||||
ISEP_NOTIFY_URL notify_leo 的 trigger 網址
|
||||
ISEP_NOTIFY_IDENTITY 貼票時的【身份】欄
|
||||
ISEP_NOTIFY_STATE_DIR 閘判定快取的位置(預設 ~/.claude/isep-nag)
|
||||
ISEP_NOTIFY_GUARD 指定要問哪一支 prod-write-guard(測試用)
|
||||
ISEP_NOTIFY_OFFLINE=1 不打任何網路(測試用)
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
NOTIFY_URL = os.environ.get(
|
||||
"ISEP_NOTIFY_URL",
|
||||
"https://arcrun-cypher-executor.leo21c.workers.dev"
|
||||
"/webhooks/named/leo/notify_leo/trigger",
|
||||
)
|
||||
IDENTITY = os.environ.get("ISEP_NOTIFY_IDENTITY", "【身份】總管/inkstone/ISEP/-")
|
||||
STATE_DIR = os.environ.get("ISEP_NOTIFY_STATE_DIR") or os.path.join(
|
||||
os.path.expanduser("~"), ".claude", "isep-nag")
|
||||
GITEA_HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
|
||||
PROD_STAMP = "/tmp/.prod-write-ok"
|
||||
GATE_FACT = "gate-verdict.json"
|
||||
|
||||
|
||||
# ══ 一、這個 session 的閘是哪一份、哪一版 ═══════════════════════════════
|
||||
#
|
||||
# 🔴 這一段就是工單說的「**把『這個 session 的閘是哪一版』變成它自己查得到的事實**」。
|
||||
# 三個來源,可信度由高到低——**而且一定把來源講出來**,
|
||||
# 因為「我問到的那份」跟「這個 session 真的註冊的那份」可能不是同一個檔。
|
||||
def find_guard():
|
||||
"""回 (guard 路徑, plugin 根目錄, 來源說明, 可信度)。找不到回 (None, ...)。"""
|
||||
cands = []
|
||||
forced = os.environ.get("ISEP_NOTIFY_GUARD")
|
||||
if forced:
|
||||
# 測試用:直接指定要問哪一支(測「舊版會擋」時把歷史版本擺進來)
|
||||
return forced, os.path.dirname(os.path.dirname(forced)), "ISEP_NOTIFY_GUARD 指定", "high"
|
||||
root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
||||
if root:
|
||||
# hook 裡跑:這就是**這個 session 真的註冊的那一份**,沒有第二種可能
|
||||
cands.append((root, "CLAUDE_PLUGIN_ROOT(這個 session 註冊的那一份)", "high"))
|
||||
remembered = _read_fact()
|
||||
if remembered and remembered.get("plugin_root"):
|
||||
cands.append((remembered["plugin_root"],
|
||||
"SessionStart 當時記下來的那一份", "high"))
|
||||
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
cands.append((here, "本腳本自己所在的那一份(不一定是 session 註冊的)", "low"))
|
||||
|
||||
for r, why, conf in cands:
|
||||
g = os.path.join(r, "hooks", "prod-write-guard.sh")
|
||||
if os.path.isfile(g):
|
||||
return g, r, why, conf
|
||||
return None, None, "找不到任何 prod-write-guard.sh", "none"
|
||||
|
||||
|
||||
def plugin_version(root):
|
||||
try:
|
||||
with open(os.path.join(root, ".claude-plugin", "plugin.json")) as f:
|
||||
return json.load(f).get("version") or "未知"
|
||||
except Exception:
|
||||
return "未知"
|
||||
|
||||
|
||||
def probe_gate(url=None):
|
||||
"""拿**這一支真的要送的請求**去問閘:你會不會擋?
|
||||
|
||||
回一個 dict,永遠有 verdict:
|
||||
pass 閘看過了,放行
|
||||
block 閘會擋(=這個 session 載到的是舊版,或白名單被改窄了)
|
||||
unknown 問不出來(沒有閘、戳記在場、腳本壞了)——**不會謊稱 pass**
|
||||
"""
|
||||
url = url or NOTIFY_URL
|
||||
guard, root, why, conf = find_guard()
|
||||
fact = {
|
||||
"at": int(time.time()),
|
||||
"url": url,
|
||||
"guard": guard,
|
||||
"plugin_root": root,
|
||||
"source": why,
|
||||
"confidence": conf,
|
||||
"version": plugin_version(root) if root else "未知",
|
||||
}
|
||||
if not guard:
|
||||
fact.update(verdict="unknown", reason="這個環境找不到 ISEP 的 prod-write-guard"
|
||||
"(沒有閘 ⇒ 沒有東西被繞過)")
|
||||
return fact
|
||||
|
||||
# 🔴 戳記在場就**不探針**:`prod-write-guard` 的戳記是**單次、用完即丟**的,
|
||||
# 探一次就把總管剛按下去的那一次授權燒掉了。
|
||||
# (而戳記在場本來就代表「現在允許寫」⇒ 送得出去,不必問。)
|
||||
if os.path.exists(PROD_STAMP):
|
||||
fact.update(verdict="unknown",
|
||||
reason="/tmp/.prod-write-ok 在場:探針會把它燒掉(單次用完即丟),"
|
||||
"所以不問。戳記在場=現在允許寫入")
|
||||
return fact
|
||||
|
||||
payload = json.dumps({
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {
|
||||
"command": ("curl -s -X POST %s -H 'Content-Type: application/json' "
|
||||
"-d '{\"text\":\"[總管] gate probe\"}'" % url),
|
||||
},
|
||||
})
|
||||
try:
|
||||
p = subprocess.run(["bash", guard], input=payload, capture_output=True,
|
||||
text=True, timeout=30)
|
||||
except Exception as e:
|
||||
fact.update(verdict="unknown", reason="問不動那支閘:%s" % e)
|
||||
return fact
|
||||
|
||||
if p.returncode == 0:
|
||||
fact.update(verdict="pass", reason="閘看過這一則通知,放行")
|
||||
elif p.returncode == 2:
|
||||
fact.update(verdict="block",
|
||||
reason="閘會擋。這個 session 載到的 ISEP 是 v%s——"
|
||||
"它的 prod-write-guard 還認不出 notify_leo 不是部署"
|
||||
% fact["version"],
|
||||
gate_says=(p.stderr or "").strip()[:400])
|
||||
else:
|
||||
fact.update(verdict="unknown",
|
||||
reason="那支閘回了離開碼 %d(既不是放行也不是擋)" % p.returncode)
|
||||
return fact
|
||||
|
||||
|
||||
def _fact_path():
|
||||
return os.path.join(STATE_DIR, GATE_FACT)
|
||||
|
||||
|
||||
def _read_fact():
|
||||
try:
|
||||
with open(_fact_path()) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def remember_fact(fact):
|
||||
"""把判定寫成檔:之後任何一支腳本(含手動跑的)都查得到,不必自己再探一次。"""
|
||||
try:
|
||||
os.makedirs(STATE_DIR, exist_ok=True)
|
||||
with open(_fact_path(), "w") as f:
|
||||
json.dump(fact, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def gate_report(fact):
|
||||
icon = {"pass": "🟢", "block": "🔴", "unknown": "🟡"}.get(fact["verdict"], "🟡")
|
||||
lines = [
|
||||
"%s 這個 session 的通知閘:**%s**" % (icon, fact["verdict"]),
|
||||
" ISEP 版本:v%s" % fact.get("version", "未知"),
|
||||
" 問的是哪一份:%s" % (fact.get("guard") or "(找不到)"),
|
||||
" 來源可信度:%s(%s)" % (fact.get("confidence"), fact.get("source")),
|
||||
" 理由:%s" % fact.get("reason", ""),
|
||||
]
|
||||
if fact["verdict"] == "block":
|
||||
lines += [
|
||||
"",
|
||||
" ⇒ **這個 session 發不出 Telegram**,不是網路問題,是閘的版本問題。",
|
||||
" ⇒ 解法(兩步,缺一不可):",
|
||||
" claude plugin update isep@inkstone # 拿到 v0.10.0 以上",
|
||||
" **開一個新的 session** # 註冊路徑是啟動當下寫死的",
|
||||
" ⇒ 在那之前,本支一律走退路(貼回票上+印在眼前),不會安靜。",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ══ 二、真的送 ═══════════════════════════════════════════════════════════
|
||||
def send_telegram(text):
|
||||
"""回 (ok, 說明)。**內層 data.data.ok 才算送到**——外層 200 不算。"""
|
||||
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
|
||||
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
|
||||
body = json.dumps({"text": text}).encode()
|
||||
# 🔴 一定要帶 User-Agent(2026-08-28 實撞):不帶的話 urllib 送出的是
|
||||
# `Python-urllib/3.x`,**Cloudflare 直接回 403 error code 1010**(bad user agent)。
|
||||
# 症狀非常會騙人——wiki 說「一條 curl、不需任何金鑰」是對的,
|
||||
# curl 自己帶 UA,所以同一個網址用 curl 通、用 python 不通
|
||||
# ⇒ 會被誤讀成「通道壞了」或「這個 session 沒權限」,而兩個都不是。
|
||||
req = urllib.request.Request(
|
||||
NOTIFY_URL, data=body, method="POST",
|
||||
headers={"Content-Type": "application/json",
|
||||
"User-Agent": "isep-notify/1.0 (+inkstone/ISEP#93)"})
|
||||
try:
|
||||
raw = urllib.request.urlopen(req, timeout=30).read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, "HTTP %s:%s" % (e.code, e.read().decode()[:200])
|
||||
except Exception as e:
|
||||
return False, "連不上:%s" % e
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
return False, "回應不是 JSON:%s" % raw[:200]
|
||||
# 🔴 wiki 記過這個坑:外層 success=true、內層 404 也發生過
|
||||
inner = ((d.get("data") or {}).get("data") or {})
|
||||
if inner.get("ok") is True:
|
||||
return True, "送到了(message_id=%s)" % inner.get("message_id")
|
||||
if "找不到 workflow" in raw or "not found" in raw.lower():
|
||||
# 🔴 這一種要分開講:**閘沒擋、網路也通,是那台實例上根本沒有這支工作流**
|
||||
# (wiki `agent-memory.md` 記過一次:2026-08-10 KV 整批換新時定義消失)。
|
||||
# 講成「發不出去」會讓人去修錯的地方——修閘、修 token,而斷點不在那裡。
|
||||
return False, ("通道本身斷了:實例上找不到 `notify_leo` 工作流。"
|
||||
"**這不是閘擋的、也不是網路問題**——要有人把它重新 push 上去"
|
||||
"(`mira` repo `workflows/notify-leo.yaml`)。原文:%s" % raw[:200])
|
||||
return False, "外層通了但**內層沒 ok**:%s" % raw[:300]
|
||||
|
||||
|
||||
# ══ 二之一、直送 Bot API(雲端的路,inkstone/ISEP#130)═══════════════════════
|
||||
#
|
||||
# 2026-09-07 從雲端 session 實測三條路:
|
||||
# notify_leo @ leo21c → HTTP 404「找不到 workflow」(08-28 起就斷,修它是 leo 的人閘)
|
||||
# notify_leo @ youlin → 連不到:雲端 egress proxy 對 *.arcrun-yuga3bse.workers.dev 回 403
|
||||
# (policy denial,不是 DNS、不是 000;要 leo 在 Cloud environment 放行主機)
|
||||
# api.telegram.org → 302,**通的**
|
||||
# ⇒ 雲端唯一走得通的是 Bot API 直送,而它只缺兩個環境變數:
|
||||
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID(Routine 文件 cloud-worker.md 檔頭本來就列著它們)。
|
||||
# 有 ⇒ 先走這條(它不碰任何實例,閘管不到也不必管);沒有 ⇒ 講清楚缺哪兩個,走原本那條。
|
||||
BOT_API = os.environ.get("ISEP_NOTIFY_BOT_API", "https://api.telegram.org")
|
||||
|
||||
|
||||
def bot_creds():
|
||||
tok = (os.environ.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||
chat = (os.environ.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||
return (tok, chat) if tok and chat else (None, None)
|
||||
|
||||
|
||||
def send_bot_api(text):
|
||||
"""回 (ok, 說明)。Telegram 的回應要 `ok: true` 才算送到。"""
|
||||
tok, chat = bot_creds()
|
||||
if not tok:
|
||||
return False, "沒有 TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID(雲端要 leo 在 Cloud environment 加這兩個變數)"
|
||||
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
|
||||
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
|
||||
req = urllib.request.Request(
|
||||
"%s/bot%s/sendMessage" % (BOT_API, tok),
|
||||
data=json.dumps({"chat_id": chat, "text": text}).encode(), method="POST",
|
||||
headers={"Content-Type": "application/json",
|
||||
"User-Agent": "isep-notify/1.0 (+inkstone/ISEP#130)"})
|
||||
try:
|
||||
raw = urllib.request.urlopen(req, timeout=30).read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, "Bot API HTTP %s:%s" % (e.code, e.read().decode()[:200])
|
||||
except Exception as e:
|
||||
return False, "Bot API 連不上:%s" % e
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
return False, "Bot API 回應不是 JSON:%s" % raw[:200]
|
||||
if d.get("ok") is True:
|
||||
return True, "送到了(Bot API 直送,message_id=%s)" % ((d.get("result") or {}).get("message_id"))
|
||||
return False, "Bot API 回 ok=false:%s" % raw[:200]
|
||||
|
||||
|
||||
# ══ 三、退路:貼回票上 ═══════════════════════════════════════════════════
|
||||
def gitea_token():
|
||||
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
|
||||
v = os.environ.get(env)
|
||||
if v:
|
||||
return v
|
||||
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
||||
host = GITEA_HOST.split("//")[-1].rstrip("/")
|
||||
try:
|
||||
out = subprocess.run(["git", "-C", root, "remote", "-v"],
|
||||
capture_output=True, text=True, timeout=20).stdout
|
||||
except Exception:
|
||||
out = ""
|
||||
for line in out.splitlines():
|
||||
if host in line:
|
||||
m = re.search(r"//[^:/]+:([^@]+)@", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def fallback_body(text, gate_fact, why=None):
|
||||
"""退路留言的內文。**獨立成一支就是為了測得到**——這段的內容才是這條退路的價值。
|
||||
|
||||
🔴 **為什麼發不出去**一定要在裡面,不是只寫閘的判定。
|
||||
2026-08-28 第一版漏了這一格:閘明明 pass,票上卻只看得到 pass,
|
||||
而真正的斷點(實例上找不到 `notify_leo` 工作流)**一個字都沒進票**
|
||||
⇒ 下一個人會去修閘,而斷點根本不在那裡。
|
||||
"""
|
||||
return "\n".join([
|
||||
IDENTITY,
|
||||
"",
|
||||
"🔴 **這則本來要用 Telegram 發給 leo,但這個 session 發不出去**——所以貼在這裡。",
|
||||
"",
|
||||
"**送不出去的原因**:%s" % (why or "(沒有記錄,這本身就是個 bug)"),
|
||||
"",
|
||||
gate_report(gate_fact),
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
text,
|
||||
])
|
||||
|
||||
|
||||
def comment_on_ticket(ref, text, gate_fact, why=None):
|
||||
"""把那則發不出去的訊息貼回票上。回 (ok, 說明)。
|
||||
|
||||
🔴 **同一則不重複貼**:內容雜湊存在狀態目錄,24 小時內一樣就跳過。
|
||||
票被洗版的下場跟發不出去一樣——leo 會學會不看它。
|
||||
"""
|
||||
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (ref or "").strip())
|
||||
if not m:
|
||||
return False, "票的寫法是 owner/repo#N,你給的是:%s" % ref
|
||||
owner, repo, num = m.group(1), m.group(2), m.group(3)
|
||||
|
||||
key = hashlib.sha256(("%s|%s|%s" % (ref, text, why or "")).encode()).hexdigest()[:16]
|
||||
seen = os.path.join(STATE_DIR, "commented-%s" % key)
|
||||
if os.path.exists(seen) and time.time() - os.path.getmtime(seen) < 24 * 3600:
|
||||
return True, "24 小時內已經貼過一模一樣的內容,跳過(不洗版)"
|
||||
|
||||
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
|
||||
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
|
||||
tok = gitea_token()
|
||||
if not tok:
|
||||
return False, "拿不到 Gitea token(GITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"
|
||||
|
||||
body = fallback_body(text, gate_fact, why)
|
||||
url = "%s/api/v1/repos/%s/%s/issues/%s/comments" % (GITEA_HOST, owner, repo, num)
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps({"body": body}).encode(), method="POST",
|
||||
headers={"Authorization": "token %s" % tok,
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
r = json.load(urllib.request.urlopen(req, timeout=40))
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, "Gitea %s:%s" % (e.code, e.read().decode()[:200])
|
||||
except Exception as e:
|
||||
return False, "連不上 Gitea:%s" % e
|
||||
try:
|
||||
os.makedirs(STATE_DIR, exist_ok=True)
|
||||
open(seen, "w").close()
|
||||
except Exception:
|
||||
pass
|
||||
return True, "已貼到 %s(%s)" % (ref, r.get("html_url") or "")
|
||||
|
||||
|
||||
# ══ 四、CLI ══════════════════════════════════════════════════════════════
|
||||
def notify(text, fallback_ticket=None, dry_run=False):
|
||||
"""回 (離開碼, 給人看的報告)。**任何一條路都會留下一段話,不會安靜。**"""
|
||||
fact = probe_gate()
|
||||
remember_fact(fact)
|
||||
out = [gate_report(fact), ""]
|
||||
|
||||
if dry_run:
|
||||
out.append("🧪 --dry-run:到此為止,什麼都沒送。要送的內容是:")
|
||||
out.append("")
|
||||
out.append(text)
|
||||
return 0, "\n".join(out)
|
||||
|
||||
# ① Bot API 直送:不碰任何實例,閘的判定管不到它(inkstone/ISEP#130)
|
||||
sent, why_bot = send_bot_api(text)
|
||||
out.append(("✅ Telegram(Bot API 直送):%s" if sent else "⏭️ Bot API 直送:%s") % why_bot)
|
||||
if sent:
|
||||
return 0, "\n".join(out)
|
||||
|
||||
# ② 走實例上的 notify_leo 工作流(原本那條;閘說會擋就不送)
|
||||
if fact["verdict"] == "block":
|
||||
out.append("⛔ 閘說會擋 ⇒ **本支不繞路**,直接走退路。")
|
||||
sent, why = False, "閘會擋,而繞過去等於拆了那道閘 ⇒ 沒有嘗試送出"
|
||||
else:
|
||||
sent, why = send_telegram(text)
|
||||
out.append(("✅ Telegram(notify_leo):%s" if sent else "❌ Telegram(notify_leo):%s") % why)
|
||||
why = "Bot API 直送:%s;notify_leo:%s" % (why_bot, why)
|
||||
|
||||
if sent:
|
||||
return 0, "\n".join(out)
|
||||
|
||||
# ── 退路 ──────────────────────────────────────────────────────────
|
||||
if fallback_ticket:
|
||||
ok, detail = comment_on_ticket(fallback_ticket, text, fact, why)
|
||||
out.append(("✅ 退路(貼回票上):%s" if ok else "❌ 退路(貼回票上):%s") % detail)
|
||||
else:
|
||||
ok, detail = False, "沒有給 --fallback-ticket,沒有票可以貼"
|
||||
out.append("⚠️ 退路(貼回票上):%s" % detail)
|
||||
|
||||
out += [
|
||||
"",
|
||||
"🔴 **leo 的手機上沒有出現這則。** 原文照抄在下面,"
|
||||
"看到這段的人有義務把它講出去:",
|
||||
"",
|
||||
text,
|
||||
]
|
||||
return (0 if ok else 1), "\n".join(out)
|
||||
|
||||
|
||||
def main(argv):
|
||||
text, ticket, dry, want_json = None, None, False, False
|
||||
mode = "notify"
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--gate":
|
||||
mode = "gate"
|
||||
elif a == "--text":
|
||||
i += 1; text = argv[i]
|
||||
elif a == "--text-file":
|
||||
i += 1
|
||||
with open(argv[i]) as f:
|
||||
text = f.read()
|
||||
elif a == "--fallback-ticket":
|
||||
i += 1; ticket = argv[i]
|
||||
elif a == "--dry-run":
|
||||
dry = True
|
||||
elif a == "--json":
|
||||
want_json = True
|
||||
elif a in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
else:
|
||||
print("不認得的參數:%s(--help 看用法)" % a, file=sys.stderr)
|
||||
return 2
|
||||
i += 1
|
||||
|
||||
if mode == "gate":
|
||||
fact = probe_gate()
|
||||
remember_fact(fact)
|
||||
print(json.dumps(fact, ensure_ascii=False, indent=2) if want_json
|
||||
else gate_report(fact))
|
||||
return 0
|
||||
|
||||
if not text or not text.strip():
|
||||
print("🔴 沒有內容可以發(--text 或 --text-file)", file=sys.stderr)
|
||||
return 2
|
||||
if not text.lstrip().startswith("["):
|
||||
text = "[總管] " + text.lstrip() # 署名鐵律:text 開頭必署名
|
||||
|
||||
code, report = notify(text, ticket, dry)
|
||||
print(report, file=(sys.stdout if code == 0 else sys.stderr))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -254,7 +254,7 @@ YOULIN_ACCOUNT = "1129efd7df2e8899d537e9c8fbabb6cb"
|
||||
# ⇒ 判準:防呆要比對「**決定後果的那一項**」,不是「剛好看得到的那一項」。
|
||||
# namespace 才是資料的歸屬鍵 ⇒ 三項一起驗,缺一不可。
|
||||
YOULIN_NAMESPACE = "yuga3bse"
|
||||
YOULIN_CYPHER = "arcrun-cypher-executor.youlin-hsieh-dev.workers.dev"
|
||||
YOULIN_CYPHER = "arcrun-cypher-executor.arcrun-yuga3bse.workers.dev" # 09-02 重裝後的子網域(inkstone/ISEP#130);舊名 youlin-hsieh-dev 的 DNS 已不存在
|
||||
|
||||
|
||||
class Acr:
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
# gitea-token.sh — 解出 Gitea 憑證(GITEA_TOKEN_CLAUDE_CODE)。source 它、呼叫 gitea_token()。
|
||||
#
|
||||
# 為什麼存在(inkstone/ISEP#47):舊法用 `git remote get-url gitea | sed` 從 remote URL
|
||||
# 抽 token ⇒ token 必須明文嵌在 URL 裡 ⇒ 任何印出 remote URL 的訊息(git-lfs 的
|
||||
# locksverify 那行)都會把它洩進 transcript/log。來源統一到這裡後,remote URL 才能改乾淨。
|
||||
#
|
||||
# 解析順序(先環境、後檔案——雲端只有 env、本機有 .env,兩邊都涵蓋):
|
||||
# 1) 環境變數 $GITEA_TOKEN_CLAUDE_CODE(雲端 make-cloud-env 注入;本機 source .env 後也在)
|
||||
# 2) .env 的 GITEA_TOKEN_CLAUDE_CODE=(先找 $CLAUDE_PROJECT_DIR/.env,再找 git 根的 .env)
|
||||
# —— 與既有 gitea_arm_token()(scripts/lib/gitea-arm-common.sh)同一條路,照既有寫法走。
|
||||
# 讀不到 → 印空字串、回非 0(呼叫者必須自己判空,不能把空字串當成功)。
|
||||
#
|
||||
# 🔴 只回值、不印別的——絕不 echo 出 token 以外的東西(避免洩進 log)。
|
||||
|
||||
# 內部:從一個 .env 檔抓 GITEA_TOKEN_CLAUDE_CODE=,去掉外圍引號。
|
||||
_gitea_token_from_file() {
|
||||
[ -f "$1" ] || return 1
|
||||
_gt_val=$(grep '^GITEA_TOKEN_CLAUDE_CODE=' "$1" 2>/dev/null | head -1 | cut -d= -f2-)
|
||||
_gt_val=${_gt_val%\"}; _gt_val=${_gt_val#\"}
|
||||
_gt_val=${_gt_val%\'}; _gt_val=${_gt_val#\'}
|
||||
[ -n "$_gt_val" ] || return 1
|
||||
printf '%s' "$_gt_val"
|
||||
return 0
|
||||
}
|
||||
|
||||
gitea_token() {
|
||||
# 1) 環境變數優先
|
||||
if [ -n "${GITEA_TOKEN_CLAUDE_CODE:-}" ]; then
|
||||
printf '%s' "$GITEA_TOKEN_CLAUDE_CODE"
|
||||
return 0
|
||||
fi
|
||||
# 2) $CLAUDE_PROJECT_DIR/.env
|
||||
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && _gitea_token_from_file "$CLAUDE_PROJECT_DIR/.env"; then
|
||||
return 0
|
||||
fi
|
||||
# 3) git 根的 .env
|
||||
_gt_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
if [ -n "${_gt_root:-}" ] && _gitea_token_from_file "$_gt_root/.env"; then
|
||||
return 0
|
||||
fi
|
||||
printf ''
|
||||
return 1
|
||||
}
|
||||
+251
-84
@@ -7,15 +7,16 @@ leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」
|
||||
⇒ SOP 說的「**那個** active milestone」在現場根本沒有指涉對象——
|
||||
要注入不知道注哪一個,要擋跳線也不知道拿哪一個當基準。
|
||||
|
||||
這支不解「milestone 太多」(跨 repo 同名是既有做法,不動它),
|
||||
它解的是「**這一刻我在做哪一條**」沒有答案:
|
||||
|
||||
mainline 現在的主線是哪一個(= show,唯一答案,離線)
|
||||
mainline list 有哪些 open milestone 可以選(打網路)
|
||||
mainline set <owner/repo#id> 把某一個 milestone 標成主線(打網路,寫進 state)
|
||||
mainline set <owner/repo#id> [--hub <owner/repo#N>]
|
||||
把某一個 milestone 標成主線(打網路,寫進 state);
|
||||
--hub 指定這條線的載體票(別 repo 的票用相依指到它)
|
||||
mainline clear 現在沒有主線(例:一條線收掉了,下一條還沒開始)
|
||||
mainline refresh [--members] 更新進度/期限(SessionStart 跑一次,不輪詢)
|
||||
mainline adopt <owner/repo#N> 「補收」:把一張票掛進主線(打網路)
|
||||
mainline refresh [--members] 更新進度/期限(SessionStart 跑一次,不輪詢);--members 順便重收成員
|
||||
mainline adopt <owner/repo#N> [--via <hub 票>]
|
||||
「補收」:把一張票掛進主線(打網路)。同 repo ⇒ 掛進 hub 里程碑;
|
||||
別 repo ⇒ 成為 hub 票的相依。**不建同名里程碑。**
|
||||
mainline has <owner/repo#N> 這張票在不在主線上(exit 0 =在,1 =不在)
|
||||
|
||||
🔴 **`<owner/repo#id>` 的 id 是 milestone 的 id,不是 issue 的號碼。**
|
||||
@@ -23,20 +24,31 @@ leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」
|
||||
|
||||
── 答案為什麼唯一 ─────────────────────────────────────────────────
|
||||
主線住在一個檔裡(`~/.claude/isep-countdown/mainline.json`),那個檔**放得下一條**。
|
||||
(inkstone/ISEP#130 起它有一份隨 repo 走的鏡像:`InkStoneCo/system-dev/mainline.json`——
|
||||
雲端沒有家目錄那份,讀的就是它;`set`/`adopt`/`clear` 兩份一起寫,`refresh` 只寫家目錄。
|
||||
inkstone/ISEP#140 起:兩份都不在時(雲端薄殼 bootstrap 之前的形狀),`refresh` 會去 Gitea
|
||||
讀 `inkstone/InkStoneCo` main 上那份一次、寫進家目錄——優先序寫在 `hooks/lib/mainline.py` 檔頭。)
|
||||
沒有清單、沒有優先序、沒有「前三名」——`set` 覆蓋掉的那條就不再是主線。
|
||||
⇒ 問一次只會拿到 0 或 1 個答案,不會拿到 14 個。
|
||||
|
||||
── 主線是一個名字,錨在一個具體的 milestone 上 ────────────────────
|
||||
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo。所以
|
||||
**錨**(期限/進度/目標宣告都讀它)是 `owner/repo#id`,
|
||||
**成員判定**看的是「票所屬 milestone 的標題與主線標題相同」(不分 repo)。
|
||||
兩件事都成立:同名跨 repo 照舊能用,而「現在在做哪一條」仍然只有一個答案。
|
||||
── 一條線只有一個里程碑;別的 repo 用相依指過來(inkstone/ISEP#133)──────
|
||||
🔴 這支的第一版檔頭寫「跨 repo 同名是既有做法,不動它」,成員也是照標題跨 repo 收。
|
||||
那句話被總管 09-07 當成規則抄進 InkStoneCo 的 ops-facts,還在 arcrun-rag 多建了一個同名的。
|
||||
leo 當天:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone,
|
||||
不同 repo 用指針」(規則本身在 inkstone/ISEP#30、inkstone/InkStoneCo#44)。
|
||||
現在的形狀(判準全部是 Gitea 的欄位,沒有標題比對):
|
||||
· **錨** = `owner/repo#<milestone id>`:整個 Gitea 只有這一個 milestone 物件,
|
||||
期限/進度/目標宣告都讀它。
|
||||
· **hub 票**(`set --hub`)= 這條線的載體票。別 repo 的票要進主線 ⇒ 成為它的 dependency。
|
||||
· **成員** = hub 里程碑裡的票 + hub 票,沿 `/dependencies` 邊(跨 repo)收下去
|
||||
(`hooks/lib/mainline.py::collect_members`)。同名里程碑關掉之後成員一張都不會掉——
|
||||
因為成員從來不是靠名字算的。
|
||||
· `adopt` 別 repo 的票 ⇒ 在 hub 票上加一條 dependency,**不會在那個 repo 建同名里程碑**。
|
||||
"""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -91,35 +103,35 @@ def org_repos(owner):
|
||||
return [r["name"] for r in rows if isinstance(r, dict) and r.get("name")]
|
||||
|
||||
|
||||
def issues_in_milestone(owner, repo, title):
|
||||
q = urllib.parse.urlencode({"state": "all", "type": "issues",
|
||||
"milestones": title, "limit": 100})
|
||||
rows = T.api_soft("/repos/%s/%s/issues?%s" % (owner, repo, q)) or []
|
||||
out = []
|
||||
for it in rows:
|
||||
if not isinstance(it, dict) or it.get("pull_request"):
|
||||
continue
|
||||
ms = it.get("milestone") or {}
|
||||
# 🔴 再比對一次標題:`milestones=` 這個參數在不同 Gitea 版本上對「名稱 vs id」
|
||||
# 的解讀不完全一致,拿回來的東西要自己確認過才算數(不要相信查詢字串)。
|
||||
if ML.norm(ms.get("title")) != ML.norm(title):
|
||||
continue
|
||||
out.append("%s/%s#%d" % (owner, repo, it["number"]))
|
||||
return out
|
||||
def collect_members(ms):
|
||||
"""成員=hub 里程碑裡的票 + hub 票 + 它們的相依(跨 repo)。判準在 `hooks/lib/mainline.py`。"""
|
||||
return ML.collect_members(T.api_soft, ms)
|
||||
|
||||
|
||||
def collect_members(owner, title, anchor_repo):
|
||||
"""跨 repo 收齊「掛在同名 milestone 底下」的票。"""
|
||||
refs, scanned = [], []
|
||||
repos = org_repos(owner)
|
||||
if anchor_repo not in repos:
|
||||
repos.append(anchor_repo)
|
||||
for r in repos:
|
||||
got = issues_in_milestone(owner, r, title)
|
||||
if got:
|
||||
scanned.append((r, len(got)))
|
||||
refs.extend(got)
|
||||
return sorted(set(refs)), scanned
|
||||
def members_line(refs, info):
|
||||
byrepo = {}
|
||||
for r in refs:
|
||||
byrepo[r.split("#")[0]] = byrepo.get(r.split("#")[0], 0) + 1
|
||||
dist = "、".join("%s %d" % (k, v) for k, v in sorted(byrepo.items()))
|
||||
return (" 成員 %d 張(里程碑裡 %d + 相依收到 %d)%s" % (
|
||||
len(refs), len(info.get("milestone") or []), len(info.get("via_deps") or []),
|
||||
(":" + dist) if dist else ""))
|
||||
|
||||
|
||||
def fetch_issue(ref):
|
||||
parts = ML.split_ref(ref)
|
||||
if not parts:
|
||||
die("🔴 票的寫法是 owner/repo#N,你給的是:%s" % ref)
|
||||
it = T.api_soft("/repos/%s/%s/issues/%d" % parts)
|
||||
return parts, (it if isinstance(it, dict) and it.get("number") else None)
|
||||
|
||||
|
||||
def fetch_blocks(parts):
|
||||
"""誰把這張票當相依(`/blocks`)。讀不到 ⇒ None(讀不到 ≠ 沒有人)。"""
|
||||
rows = T.api_soft("/repos/%s/%s/issues/%d/blocks?limit=100" % parts)
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
return [r for r in (ML.issue_ref(x) for x in rows) if r]
|
||||
|
||||
|
||||
def fetch_milestone(owner, repo, mid):
|
||||
@@ -142,6 +154,8 @@ def cmd_show(argv):
|
||||
print(" 要標一條:`scripts/mainline list` 看有哪些 → `scripts/mainline set <owner/repo#id>`")
|
||||
print()
|
||||
print("📌 在標之前,每回合的 🎯 那一行也會照樣講「現在沒有主線」,不會編一條出來。")
|
||||
print(" 雲端:SessionStart 的 `mainline refresh` 會先去 Gitea 讀 inkstone/InkStoneCo main 的")
|
||||
print(" system-dev/mainline.json;還是沒有 ⇒ 那份不存在,或這台拿不到 Gitea(inkstone/ISEP#140)。")
|
||||
return
|
||||
c, total, pct = ML.progress(ms)
|
||||
print("🎯 現在的主線:%s「%s」" % (ML.ref_of(ms), ms.get("title")))
|
||||
@@ -153,12 +167,16 @@ def cmd_show(argv):
|
||||
print(" 目標:" + (first[:150] + "…" if len(first) > 150 else first))
|
||||
print(" 期限:%s" % ML.due_phrase(ms))
|
||||
print(" 進度:%d/%d 張已關(%d%%)" % (c, total, pct))
|
||||
if ML.hub_ticket(ms):
|
||||
print(" hub 票:%s(別 repo 的票用相依指到它)" % ML.hub_ticket(ms))
|
||||
mem = sorted(ML.members(ms))
|
||||
byrepo = {}
|
||||
for r in mem:
|
||||
byrepo[r.split("#")[0]] = byrepo.get(r.split("#")[0], 0) + 1
|
||||
print(" 屬於主線的票:%d 張%s" % (
|
||||
len(mem), ("(" + "、".join("%s %d" % (k, v) for k, v in sorted(byrepo.items())) + ")") if byrepo else ""))
|
||||
if mem:
|
||||
print(" " + "、".join(mem))
|
||||
if ms.get("set_at"):
|
||||
print(" 標定於:%s" % ms["set_at"])
|
||||
if ms.get("refreshed_at"):
|
||||
@@ -193,16 +211,36 @@ def cmd_list(argv):
|
||||
|
||||
|
||||
# ── set ──────────────────────────────────────────────────────────────────
|
||||
def opt_of(argv, name):
|
||||
if name in argv and argv.index(name) + 1 < len(argv):
|
||||
return argv[argv.index(name) + 1]
|
||||
return None
|
||||
|
||||
|
||||
def positional(argv, *flags_with_value):
|
||||
return [a for i, a in enumerate(argv)
|
||||
if not a.startswith("--") and (i == 0 or argv[i - 1] not in flags_with_value)]
|
||||
|
||||
|
||||
def cmd_set(argv):
|
||||
need_net()
|
||||
if not argv:
|
||||
die("用法:mainline set <owner/repo#milestone_id>")
|
||||
owner, repo, mid = parse_ms_ref(argv[0])
|
||||
pos = positional(argv, "--hub")
|
||||
if not pos:
|
||||
die("用法:mainline set <owner/repo#milestone_id> [--hub <owner/repo#N>]")
|
||||
owner, repo, mid = parse_ms_ref(pos[0])
|
||||
m = fetch_milestone(owner, repo, mid)
|
||||
title = m.get("title")
|
||||
refs, scanned = collect_members(owner, title, repo)
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
ok = ML.save({
|
||||
hub = opt_of(argv, "--hub")
|
||||
if hub:
|
||||
parts, it = fetch_issue(hub)
|
||||
if not it:
|
||||
die("🔴 讀不到 hub 票 %s(票號寫法 owner/repo#N;讀不到就先別標,不要標一張猜的)" % hub)
|
||||
hub = "%s/%s#%d" % parts
|
||||
else:
|
||||
prev = ML.load() or {}
|
||||
# 重標同一條線而沒帶 --hub ⇒ 沿用原本的 hub 票;換一條線 ⇒ 不沿用(那是別條線的載體)
|
||||
hub = ML.hub_ticket(prev) if prev.get("ref") == "%s/%s#%d" % (owner, repo, mid) else None
|
||||
ms = {
|
||||
"ref": "%s/%s#%d" % (owner, repo, mid),
|
||||
"owner": owner, "repo": repo, "id": mid,
|
||||
"title": title,
|
||||
@@ -210,34 +248,118 @@ def cmd_set(argv):
|
||||
"due_on": m.get("due_on") or "",
|
||||
"open_issues": m.get("open_issues") or 0,
|
||||
"closed_issues": m.get("closed_issues") or 0,
|
||||
"members": refs,
|
||||
"set_at": now, "refreshed_at": now,
|
||||
})
|
||||
if not ok:
|
||||
"hub_ticket": hub,
|
||||
"members": [],
|
||||
}
|
||||
refs, info = collect_members(ms)
|
||||
complete = bool(info.get("reached") and info.get("complete"))
|
||||
if not complete:
|
||||
# 名單不完整:同一條線重標時,舊快取裡有的成員先留著(讀不到 ≠ 沒有)
|
||||
prev = ML.load() or {}
|
||||
if prev.get("ref") == ms["ref"]:
|
||||
refs = sorted(set(refs) | set(prev.get("members") or []))
|
||||
ms["members"] = refs
|
||||
ms["members_complete"] = complete
|
||||
ms["members_unread"] = incomplete_what(info)
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
ms["set_at"] = ms["refreshed_at"] = now
|
||||
if not ML.save(ms, mirror=True):
|
||||
die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
|
||||
print("✅ 主線已標定:%s/%s#%d「%s」" % (owner, repo, mid, title))
|
||||
if scanned:
|
||||
print(" 跨 repo 收到同名 milestone 的票 %d 張:%s"
|
||||
% (len(refs), "、".join("%s %d" % (r, n) for r, n in scanned)))
|
||||
# 🔴 inkstone/ISEP#130 c6973:名單不完整時不准印 ✅——雲端工人靠這份名單抓票,
|
||||
# 漏收=以為抓盡=收工。主線本身照樣標定,壞的只有成員那一格,所以講清楚是哪一格。
|
||||
print("%s 主線已標定:%s/%s#%d「%s」%s" % (
|
||||
"✅" if complete else "⚠️", owner, repo, mid, title, "" if complete else "(🔴 成員不完整)"))
|
||||
if hub:
|
||||
print(" hub 票:%s" % hub)
|
||||
print(members_line(refs, info) if info.get("reached")
|
||||
else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)")
|
||||
if not complete:
|
||||
print(incomplete_line(info))
|
||||
print(mirror_line())
|
||||
print()
|
||||
cmd_show([])
|
||||
if not complete:
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
def incomplete_what(info):
|
||||
"""名單不完整的原因,寫進主線檔(讀檔的人不必重跑也看得出)。完整時回 []。"""
|
||||
what = list(info.get("unread") or [])
|
||||
if not info.get("reached"):
|
||||
what.insert(0, "(hub 里程碑那一頁)")
|
||||
if info.get("capped"):
|
||||
what.append("(相依走到 %d 張上限)" % ML.MEMBER_WALK_CAP)
|
||||
return what
|
||||
|
||||
|
||||
def incomplete_line(info):
|
||||
return (" 🔴 成員不完整:讀不到 %s——那底下的前置沒收到。\n"
|
||||
" 雲端工人靠這份名單抓票;Gitea 恢復後重跑 `mainline refresh --members`(或同一行 set)補齊。"
|
||||
% "、".join(incomplete_what(info)))
|
||||
|
||||
|
||||
def mirror_line():
|
||||
"""repo 那份有沒有跟著寫(inkstone/ISEP#130):寫了要人去 commit,沒寫要講為什麼。"""
|
||||
rp = ML.repo_path(for_write=True)
|
||||
if rp and os.path.isfile(rp):
|
||||
return (" 已同步寫進 repo 那份:%s\n"
|
||||
" 📌 這份要 commit/開 PR 併進 inkstone/InkStoneCo,雲端 clone 才拿得到" % rp)
|
||||
return (" ⚠️ 這台找不到 InkStoneCo 的 system-dev/(CLAUDE_PROJECT_DIR 或 cwd 底下都沒有),"
|
||||
"repo 那份沒更新——雲端仍會讀到舊主線")
|
||||
|
||||
|
||||
def cmd_clear(argv):
|
||||
had = ML.load()
|
||||
ML.clear()
|
||||
rp = ML.repo_path()
|
||||
ML.clear(mirror=True)
|
||||
print("✅ 已清掉主線%s。現在沒有主線——每回合的 🎯 那一行會照實說。"
|
||||
% ("(原本是 %s「%s」)" % (ML.ref_of(had), had.get("title")) if had else ""))
|
||||
if rp:
|
||||
print(" repo 那份也刪了:%s(要 commit 才會到雲端)" % rp)
|
||||
|
||||
|
||||
# ── refresh ──────────────────────────────────────────────────────────────
|
||||
def soft_token():
|
||||
"""拿得到就給,拿不到回空字串。`refresh` 跑在 SessionStart,**不准因為沒 token 就整支炸**
|
||||
——雲端薄殼的 remote 是 GitHub、家目錄是空的,那正是它最需要活著的時候。"""
|
||||
if T is None:
|
||||
return ""
|
||||
import contextlib
|
||||
import io
|
||||
try:
|
||||
with contextlib.redirect_stderr(io.StringIO()): # token() 拿不到會先抱怨再 exit:兩個都吞掉
|
||||
return T.token() or ""
|
||||
except (SystemExit, Exception):
|
||||
return ""
|
||||
|
||||
|
||||
def cmd_refresh(argv):
|
||||
"""SessionStart 跑一次。**拿不到就原封不動**——寧可資料舊,不要把主線弄丟。"""
|
||||
"""SessionStart 跑一次。**拿不到就原封不動**——寧可資料舊,不要把主線弄丟。
|
||||
|
||||
inkstone/ISEP#140:家目錄與 repo 那份都不在(雲端薄殼跑 SessionStart 時 `InkStoneCo/`
|
||||
還沒 clone)⇒ 去 Gitea 讀 `inkstone/InkStoneCo` main 上那份**一次**,寫進家目錄。
|
||||
之後每一則 UserPromptSubmit 讀的是家目錄,路徑上照舊沒有網路。
|
||||
讀不到 ⇒ 什麼都不寫,退回現行行為(`countdown.py` 的第二基準)。
|
||||
"""
|
||||
ms = ML.load()
|
||||
if not ms:
|
||||
return # 沒有主線就沒有東西要更新,安靜結束
|
||||
d = T.api_soft("/repos/%s/%s/milestones/%s" % (ms["owner"], ms["repo"], ms["id"]))
|
||||
got = ML.fetch_gitea(token=soft_token) # 傳函式:匿名讀得到就不去碰 token
|
||||
if not got:
|
||||
return # 兩份都沒有、Gitea 也問不到 ⇒ 真的沒有主線,安靜結束
|
||||
ML.save(got) # 只寫家目錄:雲端沒有 InkStoneCo/ 可寫,本機不弄髒工作樹
|
||||
ms = ML.load()
|
||||
if not ms:
|
||||
return
|
||||
if T is None:
|
||||
return
|
||||
try:
|
||||
d = T.api_soft("/repos/%s/%s/milestones/%s" % (ms["owner"], ms["repo"], ms["id"]))
|
||||
except SystemExit:
|
||||
return # 拿不到 token:主線已經在了,進度晚點再更新,不炸
|
||||
if not isinstance(d, dict) or not d.get("title"):
|
||||
if "--members" in argv: # 明講要成員的人要看得到結果(c6973:原本零輸出)
|
||||
print(" ⚠️ 讀不到主線里程碑 %s——成員沒更新,沿用舊快取 %d 張"
|
||||
% (ML.ref_of(ms), len(ms.get("members") or [])))
|
||||
return # 讀不到 ≠ 主線不存在
|
||||
ms.update({
|
||||
"title": d.get("title") or ms["title"],
|
||||
@@ -248,38 +370,79 @@ def cmd_refresh(argv):
|
||||
"refreshed_at": datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M"),
|
||||
})
|
||||
if "--members" in argv:
|
||||
refs, _ = collect_members(ms["owner"], ms["title"], ms["repo"])
|
||||
if refs:
|
||||
# 成員靠相依邊算(inkstone/ISEP#133):hub 里程碑一頁 + 每張成員一次 /dependencies。
|
||||
# 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變);
|
||||
# `set`/`adopt` 會重收,`pick`/`has`/focus guard 本來就每次現問 Gitea,快取只餵 `show`。
|
||||
# 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。
|
||||
# 🔴 inkstone/ISEP#130 c6973:只有**完整**的名單才准蓋掉快取;不完整就留舊的並講出來。
|
||||
try:
|
||||
refs, info = collect_members(ms)
|
||||
except SystemExit:
|
||||
refs, info = [], {"reached": False}
|
||||
if info.get("reached") and info.get("complete"):
|
||||
ms["members"] = refs
|
||||
ms["members_complete"], ms["members_unread"] = True, []
|
||||
print(members_line(refs, info))
|
||||
else:
|
||||
ms["members_complete"], ms["members_unread"] = False, incomplete_what(info)
|
||||
print(" ⚠️ 成員不完整,沿用舊快取 %d 張(沒有拿殘缺名單蓋掉)" % len(ms.get("members") or []))
|
||||
print(incomplete_line(info))
|
||||
ML.save(ms)
|
||||
|
||||
|
||||
# ── adopt(補收)──────────────────────────────────────────────────────────
|
||||
def cmd_adopt(argv):
|
||||
"""補收。同 repo ⇒ 掛進 hub 里程碑(原生欄位);別 repo ⇒ 成為 hub 票的相依。
|
||||
🔴 不會在別的 repo 建同名里程碑——那是被 leo 推翻的做法(本檔檔頭)。"""
|
||||
need_net()
|
||||
if not argv:
|
||||
die("用法:mainline adopt <owner/repo#N>(把一張票掛進主線=「補收」)")
|
||||
pos = positional(argv, "--via")
|
||||
if not pos:
|
||||
die("用法:mainline adopt <owner/repo#N> [--via <hub 票 owner/repo#N>](把一張票掛進主線=「補收」)")
|
||||
ms = ML.load()
|
||||
if not ms:
|
||||
die("🔴 現在沒有主線,沒有東西可以補收進去。先 `scripts/mainline set …`。")
|
||||
owner, repo, num = T.parse_ref(argv[0])
|
||||
cands = [m for m in (T.api_soft("/repos/%s/%s/milestones?state=all" % (owner, repo)) or [])
|
||||
if isinstance(m, dict) and ML.norm(m.get("title")) == ML.norm(ms["title"])]
|
||||
if not cands:
|
||||
die("""🔴 `%s/%s` 底下沒有叫「%s」的 milestone,所以這張票掛不進去。
|
||||
|
||||
兩條路,**都要人決定,這支不會替你選**:
|
||||
· 這件事真的屬於主線 ⇒ 在 `%s/%s` 開一個同名 milestone(記得設真的期限,
|
||||
`milestone-due-guard.sh` 會擋沒期限的),再跑一次 adopt
|
||||
· 這件事其實是另一條線 ⇒ 那就是**跳線**,不是補收。直接重送派工
|
||||
(本閘同一張票只擋一次),並在回覆說清楚為什麼現在要岔開主線。""" % (
|
||||
owner, repo, ms["title"], owner, repo))
|
||||
mid = cands[0]["id"]
|
||||
T.api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"milestone": mid}, method="PATCH")
|
||||
owner, repo, num = T.parse_ref(pos[0])
|
||||
ref = "%s/%s#%d" % (owner, repo, num)
|
||||
ms["members"] = sorted(set(ms.get("members") or []) | {ref})
|
||||
ML.save(ms)
|
||||
print("✅ 補收:%s 已掛進「%s」(%s/%s#%d)" % (ref, ms["title"], owner, repo, mid))
|
||||
if owner == ms["owner"] and repo == ms["repo"]:
|
||||
T.api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"milestone": ms["id"]}, method="PATCH")
|
||||
how = "掛進 hub 里程碑 %s" % ML.ref_of(ms)
|
||||
else:
|
||||
via = opt_of(argv, "--via") or ML.hub_ticket(ms)
|
||||
if not via:
|
||||
_, info = collect_members(ms)
|
||||
cands = info.get("milestone") or []
|
||||
die("""🔴 %s 在別的 repo,要進主線得成為 hub 票的相依——而這條線還沒有 hub 票。
|
||||
|
||||
兩條路(擇一,都不會建同名里程碑):
|
||||
· 指定這條線的載體票,之後每次 adopt 都用它:
|
||||
%s set %s --hub <owner/repo#N>
|
||||
· 或這一次點名要掛在哪一張底下:
|
||||
%s adopt %s --via <owner/repo#N>
|
||||
hub 里程碑裡現在有:%s
|
||||
|
||||
(在別的 repo 開一個同名里程碑**不是**出路——leo 2026-09-07:「早就定了只有一個 milestone,不同 repo 用指針」)""" % (
|
||||
ref, os.path.abspath(sys.argv[0]), ML.ref_of(ms), os.path.abspath(sys.argv[0]), ref,
|
||||
"、".join(cands) if cands else "(讀不到/沒有)"))
|
||||
vparts = ML.split_ref(via)
|
||||
if not vparts:
|
||||
die("🔴 --via 的寫法是 owner/repo#N,你給的是:%s" % via)
|
||||
via = "%s/%s#%d" % vparts
|
||||
T.add_dependency(vparts, (owner, repo, num))
|
||||
how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo)
|
||||
refs, info = collect_members(ms)
|
||||
complete = bool(info.get("reached") and info.get("complete"))
|
||||
if complete:
|
||||
ms["members"] = sorted(set(refs) | {ref})
|
||||
else:
|
||||
# 不完整(c6973):新收到的與舊快取取聯集,不拿殘缺名單蓋掉
|
||||
ms["members"] = sorted(set(ms.get("members") or []) | set(refs) | {ref})
|
||||
ms["members_complete"], ms["members_unread"] = complete, incomplete_what(info)
|
||||
ML.save(ms, mirror=True)
|
||||
print("✅ 補收:%s → %s" % (ref, how))
|
||||
print(members_line(ms["members"], info) if complete else " (成員清單=舊快取+這次讀得到的+這一張)")
|
||||
if not complete:
|
||||
print(incomplete_line(info))
|
||||
print(mirror_line())
|
||||
|
||||
|
||||
# ── has ──────────────────────────────────────────────────────────────────
|
||||
@@ -293,18 +456,22 @@ def cmd_has(argv):
|
||||
return
|
||||
v = ML.belongs(ref, ms)
|
||||
if v is None:
|
||||
owner, repo, num = T.parse_ref(ref)
|
||||
it = T.api_soft("/repos/%s/%s/issues/%d" % (owner, repo, num))
|
||||
title = ((it or {}).get("milestone") or {}).get("title") if isinstance(it, dict) else None
|
||||
v = ML.belongs(ref, ms, ticket_milestone_title=title or "")
|
||||
if not isinstance(it, dict):
|
||||
need_net()
|
||||
parts, it = fetch_issue(ref)
|
||||
if not it:
|
||||
print("⚪ 讀不到 %s,判不出來(讀不到 ≠ 不屬於)。" % ref)
|
||||
sys.exit(0)
|
||||
blocks = fetch_blocks(parts)
|
||||
v = ML.belongs(ref, ms, ticket_milestone=it.get("milestone") or {}, blocks=blocks)
|
||||
if v is None:
|
||||
print("⚪ %s:讀得到票,但讀不到「誰把它當相依」,判不出來(讀不到 ≠ 不屬於)。" % ref)
|
||||
sys.exit(0)
|
||||
if v:
|
||||
print("✅ %s 在主線「%s」上。" % (ref, ms["title"]))
|
||||
sys.exit(0)
|
||||
print("🚧 %s **不在**主線「%s」上。\n"
|
||||
" 補收 ⇒ `scripts/mainline adopt %s`;真的是插件事 ⇒ 那是跳線,說清楚再做。"
|
||||
" 補收 ⇒ `scripts/mainline adopt %s`(別 repo 的票會成為 hub 票的相依);"
|
||||
"真的是插件事 ⇒ 那是跳線,說清楚再做。"
|
||||
% (ref, ms["title"], ref))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
+120
-17
@@ -5,15 +5,91 @@
|
||||
# 誤觸時怎麼關: 這支不擋任何東西。不想產生就別跑;產物在版控外,刪掉即可。
|
||||
#
|
||||
# 用法:bash scripts/make-cloud-env.sh
|
||||
# 產物:~/.claude/cloud-env/<日期>.txt(權限 600,**不在任何 repo 裡**)
|
||||
# 產物:~/Desktop/cloud-env-<版本>-<日期>.txt(權限 600,**不在任何 repo 裡**);版本紀錄 docs/cloud-env-versions.tsv
|
||||
# 本腳本只寫「變數名字」,值在執行當下才從既有 .env 讀出來寫進產物 —— 值不進版控、不進對話。
|
||||
set -euo pipefail
|
||||
|
||||
# ── 雲端需要哪些變數(只有名字。要加就加在這裡)──────────────────
|
||||
#
|
||||
# 範圍=**youlin(stage)與 geek6688(測試 prod & 出貨機)兩台全給**,
|
||||
# **外加 uncle6**——出貨的三個站實際部署到的是那個帳號。
|
||||
# leo 2026-09-01:「youlin 和 geek6688 都全給,因為這兩個都是讓 CC 操作
|
||||
# 一個 stage 一個 test prod,**不給所有的就是受阻**。」
|
||||
# uncle6 是同一句話的延伸(inkstone/ISEP#115 → comment `5577`):
|
||||
# `products/arcrun-rag/installer/ship.targets.json` 六處 accountId 去重後只有一個值
|
||||
# `58309bb90fd93ad6d0fe0aae99170e9d`,而本機出貨靠的是家目錄裡的 OAuth 登入態
|
||||
# (`~/.wrangler/config/default.toml`)——**那個檔不隨 repo clone 走,雲端永遠拿不到**。
|
||||
# ⚠️ 這把「是 uncle6 的、而且 active」是驗過的;「它有沒有 Workers Scripts:Edit
|
||||
# =真的部署得動」**沒驗**(驗它必須寫入,那是紅線)。缺這格會在部署當下才知道。
|
||||
NEEDED=(
|
||||
GITEA_TOKEN_CLAUDE_CODE # 機器帳號 claude-code 的 Gitea token;bootstrap 與 plugin 安裝都靠它
|
||||
# ── 機器帳號 ──
|
||||
GITEA_TOKEN_CLAUDE_CODE # claude-code 的 Gitea token;bootstrap 與 plugin 安裝都靠它
|
||||
|
||||
# ── youlin = AI 的 stage(沒指定帳號時的預設,agent-memory.md §2)──
|
||||
CLOUDFLARE_API_TOKEN_YOULIN_CC_USE # D37 起這把就是設計給 CC 用的
|
||||
CLOUDFLARE_ACCOUNT_ID_YOULIN # 上面那把的配對 account id(帳號 id 不是機密)
|
||||
YOULIN_MCP_OWNER_SECRETS # youlin 那台 MCP 的 owner secret
|
||||
STAGE_USERNAME_YOULIN # ⚠️ 密碼類,見下面那條限制
|
||||
STAGE_PASSWORD_YOULIN # ⚠️ 密碼類,見下面那條限制
|
||||
|
||||
# ── geek6688 = 測試 prod & 出貨機 ──
|
||||
CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE # 出貨線寫入用
|
||||
CLOUDFLARE_ACCOUNT_ID_GEEK6688 # 上面那把的配對 account id
|
||||
|
||||
# ── uncle6 = 出貨三站真正部署到的帳號(installer/docsSite/mailRelay,stage 與 prod 都是)──
|
||||
UNCLE6_CF_API_KEY # 住在 products/arcrun-rag/.env;本機是靠 ~/.wrangler 的 OAuth 登入態出貨,那個檔不隨 clone 走
|
||||
|
||||
# ── Arcrun 出貨線的實例座標(inkstone/Arcrun#195 起)──
|
||||
# 網址隨 repo 走(Arcrun 的 shared/instance-coordinates/instances.json),
|
||||
# 只有 namespace 是機敏 ⇒ 走環境變數。原本只住在 ~/.arcrun/config.yaml 的 api_key,
|
||||
# 那個檔在家目錄、不隨 clone 走 ⇒ 雲端永遠拿不到。
|
||||
ARCRUN_NS_YOULIN # youlin 實例的 namespace(X-Arcrun-API-Key)
|
||||
ARCRUN_NS_UNCLE6 # uncle6 實例的 namespace:出貨第 1、19、20 站派給 uncle6 工作流(inkstone/InkStoneCo#98 → comment 7739)
|
||||
|
||||
# ── prod 出貨把產物推上 GitHub 鏡像(第 14、21、22 站)──
|
||||
# 🔴 名字進清單 ≠ 雲端可以自由寫 GitHub:`github-contact-guard.sh` 照樣擋,
|
||||
# 寫入仍要 D20 開閘(保險檔落在哪由 inkstone/ISEP#157 處理)。
|
||||
# 進清單是因為「拿不到這兩個名字」會讓 prod 那半在雲端**連開閘之後都走不下去**。
|
||||
GITHUB_MIRROR_TOKEN # GitHub 鏡像 repo 的寫入 token(走標頭,不進網址)
|
||||
GITHUB_ACCOUNT_NAME # 上面那把的帳號名(組鏡像 repo 路徑用,不是機密)
|
||||
|
||||
# ── 雲端工人的通知出口(v7,2026-09-18:leo 貼出現行雲端環境才發現 v1–v6 漏收)──
|
||||
# 雲端走 Bot API 直送(`scripts/isep-notify`;leo21c 的 notify_leo 已於 08-14 消失,InkStoneCo#110)
|
||||
TELEGRAM_CHAT_ID # 收訊的 chat id(不是機密)
|
||||
TELEGRAM_BOT_TOKEN # 通知 bot 的 token
|
||||
)
|
||||
|
||||
# 🔓 例外:值只住在 polaris/mira/.env、但**不是**打得到 leo21c 的憑證——點名放行,其他照擋(B8)
|
||||
# Telegram 兩把是通知 bot,不碰任何 CF 帳號;雲端 09-18 前就已手動設了同樣的值。
|
||||
MIRA_ALLOWED=(TELEGRAM_CHAT_ID TELEGRAM_BOT_TOKEN)
|
||||
|
||||
# ── 固定值(不是金鑰,產生器直接寫)──
|
||||
FIXED=(
|
||||
"ISEP_DRAIN=1" # 雲端工人=接票環境:ticket pick 自帶 --pool、佇列閘不准有票就收工(ISEP#130)
|
||||
)
|
||||
#
|
||||
# 🔴 這份清單的邊界——加一把之前先問「這把打得到哪個實例」,不是「這把方便嗎」
|
||||
#
|
||||
# 可以進來的:**youlin 與 geek6688** 這兩台的全部,加上機器帳號自己的 token。
|
||||
# 🔴 不准進來的:
|
||||
# ① `CLOUDFLARE_API_TOKEN_leo21c`——leo 2026-09-01 只點名那兩台;
|
||||
# leo21c 是現役正式環境,`inkstone/InkStoneCo#14` → comment `3890` 已分進 B 段不放。
|
||||
# ② `polaris/mira/.env` 裡的任何東西——那整份都是 leo21c 現役正式環境。
|
||||
# 其中 `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` 這兩個**沒有名字**的最危險:
|
||||
# 所有 CF 工具都把它們當預設 ⇒ 雲端一個沒指定帳號的指令就會打到正式環境。
|
||||
# 2026-08-14 那輪審查把它列為「本輪最危險的發現」。
|
||||
# 🔎 這一條不必靠人記得:`scripts/test-make-cloud-env.sh` 的 B8 會去看
|
||||
# **每一把的值是從哪個檔案拿到的**,出自 `polaris/mira/.env` 就紅。
|
||||
#
|
||||
# 🔴 密碼類(`STAGE_PASSWORD_YOULIN` 等)進清單是為了讓**人**拿得到。
|
||||
# `credentials-map.md`「密碼類憑證的用法限制」(2026-08-17 立)照舊有效:
|
||||
# **AI 不得把密碼打進登入欄位**,這是 surface 層硬規則,leo 交代了也不解除。
|
||||
# 機器要驗登入後的畫面走那節寫的兩條:帶 token/cookie 打 API,或接 leo 已登入的分頁。
|
||||
#
|
||||
# 📌 兩台的 account id 都有具名變數了(`…_YOULIN` 是 2026-09-01 才補進頂層 `.env` 的,
|
||||
# inkstone/ISEP#115 → comment `5542`;帳號 id 不是機密,wiki 的帳號表本來就公開記著)。
|
||||
# **所以永遠不需要去用那個沒有名字的 `CLOUDFLARE_ACCOUNT_ID`**——那是 leo21c。
|
||||
|
||||
# ── 去哪裡找值(credentials-map.md 記的六個 .env)────────────────
|
||||
BASE="${INKSTONE_ROOT:-$HOME/Documents/tech_projects/InkStoneCo}"
|
||||
ENV_FILES=(
|
||||
@@ -37,23 +113,42 @@ lookup() { # $1=變數名 → 印出值(找不到就空)
|
||||
return 1
|
||||
}
|
||||
|
||||
# 預設丟 ~/.claude/cloud-env(權限 700)。要放別的地方= OUT_DIR=~/Desktop bash scripts/make-cloud-env.sh
|
||||
OUT_DIR="${OUT_DIR:-$HOME/.claude/cloud-env}"
|
||||
# ── 版本(leo 2026-09-18:「你要有版本,因為這件事做了很多次,但你都沒記錄版本」)──
|
||||
# 真相源=docs/cloud-env-versions.tsv(只記 key,不記值)。最後一列=現行版。
|
||||
# 🔴 NEEDED 跟最後一列的 key 不一致 ⇒ 拒絕產生:改了清單就必須在 tsv 加一版。
|
||||
VERSIONS="$(cd "$(dirname "$0")/.." && pwd)/docs/cloud-env-versions.tsv"
|
||||
[ -f "$VERSIONS" ] || { echo "🔴 找不到 $VERSIONS" >&2; exit 1; }
|
||||
CUR_LINE=$(grep -v '^#' "$VERSIONS" | grep -v '^[[:space:]]*$' | tail -1)
|
||||
PREV_LINE=$(grep -v '^#' "$VERSIONS" | grep -v '^[[:space:]]*$' | tail -2 | head -1)
|
||||
VER=$(printf '%s' "$CUR_LINE" | cut -f1); VER_DATE=$(printf '%s' "$CUR_LINE" | cut -f2)
|
||||
VER_KEYS=$(printf '%s' "$CUR_LINE" | cut -f3); VER_WHY=$(printf '%s' "$CUR_LINE" | cut -f4)
|
||||
PREV_VER=$(printf '%s' "$PREV_LINE" | cut -f1); PREV_KEYS=$(printf '%s' "$PREV_LINE" | cut -f3)
|
||||
FIXED_NAMES=(); for kv in "${FIXED[@]}"; do FIXED_NAMES+=("${kv%%=*}"); done
|
||||
NEEDED_JOINED=$(IFS=,; printf '%s' "${NEEDED[*]} ${FIXED_NAMES[*]}" | tr ' ' ',')
|
||||
if [ "$NEEDED_JOINED" != "$VER_KEYS" ]; then
|
||||
echo "🔴 NEEDED 與 $VERSIONS 最後一版($VER)的 key 不一致——改了清單就要在 tsv 加一列新版本再跑。" >&2
|
||||
echo " NEEDED:$NEEDED_JOINED" >&2
|
||||
echo " $VER :$VER_KEYS" >&2
|
||||
exit 2
|
||||
fi
|
||||
ADDED=$(comm -13 <(tr , '\n' <<<"$PREV_KEYS" | sort) <(tr , '\n' <<<"$VER_KEYS" | sort) | paste -sd, -)
|
||||
REMOVED=$(comm -23 <(tr , '\n' <<<"$PREV_KEYS" | sort) <(tr , '\n' <<<"$VER_KEYS" | sort) | paste -sd, -)
|
||||
|
||||
# 預設丟桌面(leo 2026-09-18 指定)。要放別的地方= OUT_DIR=<目錄> bash scripts/make-cloud-env.sh
|
||||
OUT_DIR="${OUT_DIR:-$HOME/Desktop}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
# 只在「這個目錄是我們自己造的預設位置」時才收緊權限——
|
||||
# OUT_DIR 可被覆寫,不該對使用者指定的既有目錄(例如 ~/Desktop)動權限。
|
||||
[ "$OUT_DIR" = "$HOME/.claude/cloud-env" ] && chmod 700 "$OUT_DIR"
|
||||
OUT="$OUT_DIR/$(date +%Y%m%d-%H%M%S).txt"
|
||||
OUT="$OUT_DIR/cloud-env-${VER}-$(date +%Y%m%d-%H%M).txt"
|
||||
|
||||
SETUP="$(cd "$(dirname "$0")/.." && pwd)/docs/cloud-setup-script.sh"
|
||||
[ -f "$SETUP" ] || { echo "🔴 找不到 $SETUP" >&2; exit 1; }
|
||||
|
||||
MISSING=()
|
||||
# 🔴 兩個檔分開產(2026-09-18 實撞):原本兩段放同一檔、中間用「════」分隔,
|
||||
# leo 把分隔線一起貼進 Setup script 欄 ⇒ 雲端開機 exit 127「'════…': command not found」,
|
||||
# 每一趟 cloud-worker 在開機就死。⇒ 變數一個檔(只有 KEY=VALUE)、開機腳本一個檔(逐位元組=cloud-setup-script.sh),
|
||||
# **兩個檔都整份貼**,沒有任何要人自己裁切的地方。
|
||||
SETUP_OUT="${OUT%.txt}-setup-script.sh"
|
||||
{
|
||||
echo "claude.ai → Cloud environments → 你的環境。下面兩塊各自貼進對應欄位。"
|
||||
echo "產生時間:$(date '+%Y-%m-%d %H:%M')"
|
||||
echo
|
||||
echo "════════ ① Environment variables(一行一個,名字與值分開填)════════"
|
||||
for n in "${NEEDED[@]}"; do
|
||||
if v=$(lookup "$n"); then
|
||||
echo "$n=$v"
|
||||
@@ -62,12 +157,20 @@ MISSING=()
|
||||
MISSING+=("$n")
|
||||
fi
|
||||
done
|
||||
echo
|
||||
echo "════════ ② Setup script(整段貼,一字不改)════════"
|
||||
cat "$SETUP"
|
||||
for kv in "${FIXED[@]}"; do echo "$kv"; done
|
||||
} > "$OUT"
|
||||
chmod 600 "$OUT"
|
||||
cp "$SETUP" "$SETUP_OUT"
|
||||
chmod 600 "$OUT" "$SETUP_OUT"
|
||||
{
|
||||
echo "claude.ai → Cloud environments → 你的環境(${VER},${VER_DATE}:${VER_WHY})"
|
||||
echo "比 ${PREV_VER} 多:${ADDED:-(無)}|少:${REMOVED:-(無)}"
|
||||
echo "① Environment variables 欄:整份貼 $(basename "$OUT")"
|
||||
echo "② Setup script 欄:整份貼 $(basename "$SETUP_OUT")(一字不改,不要加任何東西)"
|
||||
echo "貼完刪掉這三個檔。"
|
||||
} > "${OUT%.txt}-README.txt"
|
||||
|
||||
echo "✅ 產生完成:$OUT"
|
||||
echo " 版本:${VER}(比 ${PREV_VER} 多:${ADDED:-無}|少:${REMOVED:-無})"
|
||||
echo " 變數 ${#NEEDED[@]} 個|找不到值的 ${#MISSING[@]} 個 ${MISSING[*]:-}"
|
||||
echo " 🔴 這個檔含金鑰真身:貼完就刪(rm '$OUT'),它刻意不在任何 repo 裡。"
|
||||
echo " 開機腳本:$SETUP_OUT"
|
||||
echo " 🔴 含金鑰真身:貼完就刪(rm '${OUT%.txt}'*),它刻意不在任何 repo 裡。"
|
||||
|
||||
@@ -41,21 +41,48 @@ import urllib.request
|
||||
HOST = os.environ.get("MILESTONE_ACCOUNT_HOST", "https://git.uncle6.me")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
def _project_roots():
|
||||
"""跟 `hooks/lib/mainline.py::project_roots()` 同一把尺(那裡是唯一定義;載不到才退回同形狀的內建)。"""
|
||||
try:
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
path = os.path.join(ROOT, "hooks", "lib", "mainline.py")
|
||||
loader = importlib.machinery.SourceFileLoader("isep_mainline_for_account", path)
|
||||
spec = importlib.util.spec_from_file_location("isep_mainline_for_account", path, loader=loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(mod)
|
||||
return mod.project_roots()
|
||||
except Exception:
|
||||
roots = []
|
||||
for r in (os.environ.get("CLAUDE_PROJECT_DIR", "").strip(), os.getcwd()):
|
||||
if r:
|
||||
for c in (r, os.path.join(r, "InkStoneCo")):
|
||||
if c not in roots:
|
||||
roots.append(c)
|
||||
return roots
|
||||
|
||||
|
||||
def _resolve_dir():
|
||||
"""帳本住哪裡。
|
||||
|
||||
🔴 **不能住在 plugin 目錄裡**:ISEP 是用 marketplace 裝的,
|
||||
`claude plugin update` 會把那個目錄整個換掉 ⇒ 累積幾個月的帳一次歸零,
|
||||
而這張票的全部價值就在「累積」。
|
||||
順序:環境變數 → 這個 session 的專案根底下的 `system-dev/`(總管的跨專案知識庫,
|
||||
帳本本來就屬於那一層)→ 這份原始碼所在 repo 的 `system-dev/` → `~/.claude/`。
|
||||
inkstone/ISEP#133 實撞:雲端的 `$CLAUDE_PROJECT_DIR` 是薄殼根(沒有 `system-dev/`),
|
||||
上一版於是退到「原始碼所在 repo 的 system-dev/」——而雲端的原始碼就在
|
||||
`~/.claude/plugins/cache/inkstone/isep/<版本>/`,那天記的 5 筆帳下一個 session 就不見。
|
||||
順序:環境變數 → 專案根/專案根底下的 `InkStoneCo/`/cwd 同兩層(跟主線檔同一把尺,
|
||||
帳本本來就屬於 InkStoneCo 那一層)→ 這份原始碼所在 repo 的 `system-dev/`,**但只在
|
||||
它是一個 git 工作樹時**(有 `.git`=真的 checkout;plugin 快取沒有 `.git`)→ `~/.claude/`。
|
||||
"""
|
||||
v = os.environ.get("MILESTONE_ACCOUNT_DIR")
|
||||
if v:
|
||||
return v
|
||||
for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", ROOT):
|
||||
if base and os.path.isdir(os.path.join(base, "system-dev")):
|
||||
for base in _project_roots():
|
||||
if os.path.isdir(os.path.join(base, "system-dev")):
|
||||
return os.path.join(base, "system-dev", "estimates")
|
||||
if os.path.exists(os.path.join(ROOT, ".git")) and os.path.isdir(os.path.join(ROOT, "system-dev")):
|
||||
return os.path.join(ROOT, "system-dev", "estimates")
|
||||
return os.path.join(os.path.expanduser("~"), ".claude", "isep-estimates")
|
||||
|
||||
|
||||
|
||||
Executable
+255
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""release-check — 回頭問「這一版真的送到了嗎」,而且**問的人不是送的人**
|
||||
(inkstone/ISEP#67)
|
||||
|
||||
leo 2026-08-27 的病(票上原文):
|
||||
「版本只到 ISEP v0.3.9,你有新版為什麼不推?」
|
||||
v0.4.0/v0.5.0 的 tag 早就推了,但 Gitea Releases 頁停在 v0.3.9
|
||||
⇒ 對 leo 等於這兩版不存在;claude plugin list 也還顯示舊版。
|
||||
2026-09-07 又一次:v0.23.0/v0.24.0 打了 tag,Releases 頁停在 v0.22.0——
|
||||
交棒給 leo 的「測試位置 releases/tag/v0.24.0」打開是一頁空 tag。
|
||||
|
||||
ISEP 的運送鏈有這幾段,**每一段都要有人回頭問「到了嗎」**:
|
||||
|
||||
定版 commit → tag(Gitea) → release(Gitea) → main 的 plugin.json
|
||||
→ 這個 session 真的載入到的版本
|
||||
|
||||
🔴 這支的全部價值在一句話:**它不聽上一站說「我送了」,它自己去問收貨的那一端。**
|
||||
tag 在不在 → 問 Gitea 的 tags;release 在不在 → 問 Gitea 的 releases(leo 會看的那頁);
|
||||
載入到了沒 → 看這個 session 真的掛著的那一份 plugin.json。
|
||||
問的方式全部匿名(D20:讀不帶憑證),判準與 release-ship/SessionStart 信標同一份
|
||||
(hooks/lib/release_chain.py)。
|
||||
|
||||
用法:
|
||||
release-check 現行版本 = 這棵樹 plugin.json 宣稱的那一版
|
||||
release-check v0.23.0 現行版本 = 你指定的那一版(release-ship 送完拿來覆核**那一版**,
|
||||
不管工作樹現在 checkout 在哪)
|
||||
|
||||
判準是**結構**,不是關鍵字(leo 2026-08-17 已證偽關鍵字黑名單:8 次誤攔、0 次正確):
|
||||
· 哪些版本「應該」被送到 = main 上有 `定版 vX.Y.Z` commit 的那些(git 說了算,不是猜)
|
||||
· 那個版本「送到了沒」= 去 Gitea 問它的 tag 與 release 在不在(在=到了)
|
||||
|
||||
離開碼:
|
||||
0 現行版本 **tag 與 release 都在** ⇒ 送貨端完成
|
||||
2 現行版本缺 tag 或缺 release ⇒ **送貨端斷了**,stderr 講出缺哪一站+怎麼補
|
||||
(這正是工單驗收 4:「故意漏一步 → 管線要擋下來並說出缺哪一站」)
|
||||
說明:比現行版本更舊的 `定版` 版本若缺 tag/release,只**黃字提醒**、不改離開碼——
|
||||
它們多半已被新版取代,硬擋等於「懲罰謹慎」(over-block 比 under-block 嚴重)。
|
||||
v0.19/0.20/0.21 就是總管 09-07 裁定「不補」的那三個。
|
||||
載入端落後(這個 session 跑的比 main 舊)也只提醒:那要 update+重啟,
|
||||
是收貨端的動作,不是送貨端能修的,且 SessionStart 信標本來就一直在報。
|
||||
|
||||
離線測試:`scripts/test-release-check.sh`(走 `RELEASE_CHECK_FIXTURE`,不打 Gitea)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "hooks", "lib"))
|
||||
try:
|
||||
import release_chain as rc
|
||||
except Exception as e: # pragma: no cover
|
||||
print("🔴 讀不到 hooks/lib/release_chain.py:%s" % e, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
HOST = os.environ.get("RELEASE_CHECK_HOST", rc.DEFAULT_HOST)
|
||||
REPO = os.environ.get("RELEASE_CHECK_REPO", rc.DEFAULT_REPO)
|
||||
FIXTURE = os.environ.get("RELEASE_CHECK_FIXTURE", "")
|
||||
|
||||
|
||||
def die(msg, code=2):
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def proj_root():
|
||||
"""這支腳本操作的那個 ISEP repo 的根。"""
|
||||
v = os.environ.get("CLAUDE_PROJECT_DIR")
|
||||
if v and os.path.isfile(os.path.join(v, ".claude-plugin", "plugin.json")):
|
||||
return v
|
||||
return os.path.dirname(HERE)
|
||||
|
||||
|
||||
def load_fixture():
|
||||
if not FIXTURE:
|
||||
return None
|
||||
try:
|
||||
with open(FIXTURE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
die("🔴 讀不到 RELEASE_CHECK_FIXTURE:%s" % FIXTURE)
|
||||
|
||||
|
||||
FX = load_fixture()
|
||||
CHAIN = rc.Chain(HOST, REPO, fixture=FX)
|
||||
|
||||
# 🔴 裁定不補的歷史版本(唯一識別碼往放行方向比對,不是關鍵字):
|
||||
# 總管 2026-09-07(inkstone/ISEP#67 → comment 6574):「v0.19/0.20/0.21 不補——
|
||||
# 沒 tag,v0.22.0 的 note 已列到那三個定版。」這三個定版 commit 永遠在 main 上,
|
||||
# 不列在這裡它們就會每次都黃字——天天念的東西會被學會忽略,等於不存在。
|
||||
WAIVED = {"0.19.0": "inkstone/ISEP#67 → 6574", "0.20.0": "inkstone/ISEP#67 → 6574",
|
||||
"0.21.0": "inkstone/ISEP#67 → 6574"}
|
||||
|
||||
|
||||
# ══ 送貨端事實:main 上「定版」了哪些、現在宣稱哪一版、這個 session 載到哪一版 ══
|
||||
def dingban_versions():
|
||||
"""main 上有 `定版 vX.Y.Z` commit 的版本 —— 「應該被送到」的權威清單。"""
|
||||
if FX is not None:
|
||||
return list(FX.get("dingban") or [])
|
||||
root = proj_root()
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", root, "log", "--format=%s", "--grep", "定版 v"],
|
||||
capture_output=True, text=True, timeout=30).stdout
|
||||
except Exception:
|
||||
out = ""
|
||||
seen, vers = set(), []
|
||||
for line in out.splitlines():
|
||||
m = re.search(r"定版\s+v?(\d+\.\d+\.\d+)", line)
|
||||
if m and m.group(1) not in seen:
|
||||
seen.add(m.group(1))
|
||||
vers.append(m.group(1))
|
||||
return vers
|
||||
|
||||
|
||||
def current_version():
|
||||
"""plugin.json 現在宣稱的那一版(= main 要送出去的那一版)。"""
|
||||
if FX is not None:
|
||||
return FX.get("current") or ""
|
||||
try:
|
||||
with open(os.path.join(proj_root(), ".claude-plugin", "plugin.json")) as f:
|
||||
return json.load(f).get("version") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def loaded_version():
|
||||
"""這個 session 真的掛著的那一份 plugin 是哪一版(收貨端最後一段)。
|
||||
|
||||
拿不到就回 None —— 不猜、不謊稱到了(house style:不知道就說不知道)。
|
||||
"""
|
||||
if FX is not None:
|
||||
return FX.get("loaded")
|
||||
root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
||||
if not root:
|
||||
return None
|
||||
try:
|
||||
with open(os.path.join(root, ".claude-plugin", "plugin.json")) as f:
|
||||
return json.load(f).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ══ 覆核:逐版問「到了嗎」 ════════════════════════════════════════════════
|
||||
def audit(focus=None):
|
||||
cur = focus or current_version()
|
||||
if not cur:
|
||||
die("🔴 讀不到 .claude-plugin/plugin.json 的 version —— 這不是 ISEP repo,或檔案壞了。")
|
||||
dingban = dingban_versions()
|
||||
if cur not in dingban:
|
||||
# 現行版本連 `定版` commit 都沒有 ⇒ plugin.json 宣稱了一個沒被定版的號碼。
|
||||
# 這是 check-version-consistency 那條線的病,不是這支的守備範圍——但要講出來。
|
||||
dingban = [cur] + dingban
|
||||
try:
|
||||
tags = CHAIN.tags()
|
||||
rels = CHAIN.releases()
|
||||
except rc.ChainError as e:
|
||||
die("🔴 問不到收貨端,這一支不猜:%s" % e)
|
||||
|
||||
rows = []
|
||||
for v in sorted(set(dingban), key=rc.vtuple, reverse=True):
|
||||
rows.append({
|
||||
"ver": v,
|
||||
"dingban": True, # 這份清單本來就是從 定版 commit 來的
|
||||
"tag": rc.tagname(v) in tags,
|
||||
"release": rc.tagname(v) in rels,
|
||||
"current": v == cur,
|
||||
"gaps": rc.gaps(v, tags, rels),
|
||||
"waived": WAIVED.get(v) if v != cur else None,
|
||||
})
|
||||
return cur, rows
|
||||
|
||||
|
||||
def ship_cmd():
|
||||
"""補 release 的那一行要能直接貼著跑:印正本自己的絕對路徑(mistakes.md 2026-08-31)。"""
|
||||
return "python3 %s" % os.path.join(HERE, "release-ship")
|
||||
|
||||
|
||||
def report(focus=None):
|
||||
cur, rows = audit(focus)
|
||||
loaded = loaded_version()
|
||||
icon = {True: "✅", False: "❌"}
|
||||
lines = ["ISEP 運送鏈覆核(收貨端事實,不聽送的人說) repo=%s" % REPO, ""]
|
||||
lines.append(" 版本 定版 tag release (← =%s)"
|
||||
% ("你指定的版本" if focus else "現行 plugin.json 宣稱的版本"))
|
||||
for r in rows:
|
||||
mark = " ←" if r["current"] else ""
|
||||
if r["waived"] and r["gaps"]:
|
||||
mark = " ⏭ 裁定不補(%s)" % r["waived"]
|
||||
lines.append(" %-10s %s %s %s%s" % (
|
||||
"v" + r["ver"], icon[r["dingban"]], icon[r["tag"]], icon[r["release"]], mark))
|
||||
|
||||
# ── 載入端(這個 session)──────────────────────────────────────────
|
||||
lines.append("")
|
||||
if loaded is None:
|
||||
lines.append(" 載入端:這個 context 讀不到 CLAUDE_PLUGIN_ROOT ⇒ 說不出「這個 session 載到哪一版」"
|
||||
"(不是 session 裡跑的,或沒裝 plugin)。")
|
||||
elif rc.vtuple(loaded) < rc.vtuple(cur):
|
||||
lines.append(" 🟡 載入端落後:這個 session 掛的是 v%s,main 已到 v%s。" % (loaded, cur))
|
||||
lines.append(" 修:本機 `claude plugin update isep@inkstone` **並重啟 session**"
|
||||
"(升版與生效是兩件事);雲端去動一下 Environment 的 setup script 逼它重拍快照。")
|
||||
lines.append(" 這一段 SessionStart 信標本來就一直在報(beacon_report.py ①)。")
|
||||
else:
|
||||
lines.append(" ✅ 載入端:這個 session 掛的是 v%s,跟 main 宣稱的一致。" % loaded)
|
||||
|
||||
# ── 判決 ────────────────────────────────────────────────────────────
|
||||
currow = next((r for r in rows if r["current"]), None)
|
||||
hard = currow["gaps"] if currow else []
|
||||
older_gaps = [r for r in rows if not r["current"] and r["gaps"] and not r["waived"]]
|
||||
|
||||
lines.append("")
|
||||
if hard:
|
||||
lines.append("🔴 **現行版本 v%s 的送貨端斷了** —— 對 leo 等於這一版不存在:" % cur)
|
||||
for _key, label, fix in hard:
|
||||
lines.append(" 缺這一站:%s" % label)
|
||||
lines.append(" 怎麼補:%s" % fix.format(v=cur, ship=ship_cmd()))
|
||||
if older_gaps:
|
||||
lines.append(" (另外還有更舊的版本也缺,見下方黃字,但那些不擋。)")
|
||||
else:
|
||||
lines.append("✅ 現行版本 v%s:定版、tag、release 都在 —— 送貨端完成,leo 那頁看得到。" % cur)
|
||||
|
||||
if older_gaps:
|
||||
lines.append("")
|
||||
lines.append("🟡 更舊的定版版本也缺件(多半已被新版取代,只提醒、不擋):")
|
||||
for r in older_gaps:
|
||||
lines.append(" v%s 缺:%s" % (r["ver"], "+".join(label for _k, label, _f in r["gaps"])))
|
||||
lines.append(" 要補歷史版本 ⇒ 逐版 `%s v<版本>`;"
|
||||
"確定不補 ⇒ 這是 leo/總管的決定,記一句在 inkstone/ISEP#67"
|
||||
"(v0.19/0.20/0.21 已於 2026-09-07 裁定不補)。" % ship_cmd())
|
||||
|
||||
out = "\n".join(lines)
|
||||
if hard:
|
||||
print(out, file=sys.stderr)
|
||||
return 2
|
||||
print(out)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv):
|
||||
focus = None
|
||||
for a in argv:
|
||||
if a in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
m = re.match(r"^v?(\d+\.\d+\.\d+)$", a.strip())
|
||||
if not m:
|
||||
die("用法:release-check [vX.Y.Z](--help 看說明);不認得:%s" % a)
|
||||
focus = m.group(1)
|
||||
return report(focus)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+290
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""release-ship — 把「已經推上去的 tag」變成「leo 那頁看得到的 release」
|
||||
(inkstone/ISEP#67,對應 arcrun-rag 的 release-record 站)
|
||||
|
||||
工單抓到的斷點,一句話:**沒有任何東西會建 release。**
|
||||
ISEP repo 全樹 grep「建 release」的 API 呼叫 → 0 命中。
|
||||
後果:tag 早就推了,Gitea Releases 頁卻停在舊版
|
||||
⇒ 對 leo 等於這一版不存在(「ISEP 現在是哪一版」只有 Releases 答得出來)。
|
||||
08-27 v0.4.0/v0.5.0、09-02 v0.22.0、09-07 v0.23.0/v0.24.0——同一站斷了三輪。
|
||||
|
||||
這支就是補上那一站。它**只做 tag → release** 這一段,不打 tag、不改 plugin.json、
|
||||
不碰 main —— 打 tag 是 `release-tag-guard.sh` 在管的、總管驗過整個 milestone 之後的動作。
|
||||
|
||||
🔴 送之前,三件事「必須在場」才放行(require-something-present,不是關鍵字黑名單):
|
||||
① **那個 tag 的樹**裡 `plugin.json` 的 version = 這個 tag 的號碼。
|
||||
比對的對象是 tag 自己的樹(問 Gitea `contents/…?ref=<tag>`),**不是工作樹**。
|
||||
🔴 09-05 那版拿 `check-version-consistency.sh`(工作樹 plugin.json vs 本機最新 tag)
|
||||
當前置①,結果對「不是最新的那個 tag」永遠建不了:總管在 v0.23.0 的 checkout
|
||||
跑 `release-ship v0.23.0` 被擋,訊息說最新 tag 是 v0.24.0——而 v0.23.0 的樹
|
||||
明明就一致。**要送的東西是 tag,判它的就該看它的樹**;工作樹可能是任何分支
|
||||
(這支本身就是在一條 feature 分支上寫的),跟那個 tag 沒有關係。
|
||||
`check-version-consistency.sh` 守的是「main 上 plugin.json 跟最新 tag 一致」,
|
||||
那是打 tag 那一刻的事(release-tag-guard.sh),不是建 release 這一刻的事。
|
||||
② tag 真的在 Gitea 上:去問收貨端,不在就不給建 release(先去推 tag)。
|
||||
③ release note 有內容:沒給 `--notes-file` 就從 Gitea「上一個 release ⋯ 這個 tag」
|
||||
的 compare 自動生一份**事實**清單(哪幾顆 commit,不含 merge)——leo 可以再改,但不留白。
|
||||
問 Gitea 而不是本機 git log:這支從 plugin 快取目錄跑時**旁邊沒有 git repo**。
|
||||
|
||||
🔴 送完**自己不算數**:最後回頭跑一次 `release-check <這個 tag>`,拿收貨端(Gitea releases 頁)
|
||||
的事實覆核「真的建起來了」。送的人說成功不算,問收貨的那一端才算
|
||||
(工單:「問的人不能是送的人」)。
|
||||
|
||||
讀全部匿名(D20),只有 POST 建 release 才要 token
|
||||
(`GITEA_TOKEN_CLAUDE_CODE`/`GITEA_TOKEN`,或這個 repo remote URL 裡帶的那把)。
|
||||
|
||||
冪等:release 已經在了 ⇒ 報「本來就在」、離開碼 0,不重複建、不報錯、**不動它的內容**。
|
||||
|
||||
離開碼:0 = release 在 Gitea 上了(新建的或本來就在)
|
||||
2 = 某個前置條件沒滿足(stderr 講出是哪一個+怎麼補)
|
||||
|
||||
離線測試:`scripts/test-release-ship.sh`(走 `RELEASE_SHIP_FIXTURE`,不打 Gitea、不建真 release)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "hooks", "lib"))
|
||||
try:
|
||||
import release_chain as rc
|
||||
except Exception as e: # pragma: no cover
|
||||
print("🔴 讀不到 hooks/lib/release_chain.py:%s" % e, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
HOST = os.environ.get("RELEASE_SHIP_HOST", rc.DEFAULT_HOST)
|
||||
REPO = os.environ.get("RELEASE_SHIP_REPO", rc.DEFAULT_REPO)
|
||||
FIXTURE = os.environ.get("RELEASE_SHIP_FIXTURE", "")
|
||||
VER_RE = re.compile(r"^v?(\d+\.\d+\.\d+)$")
|
||||
|
||||
|
||||
def die(msg, code=2):
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def proj_root():
|
||||
v = os.environ.get("CLAUDE_PROJECT_DIR")
|
||||
if v and os.path.isfile(os.path.join(v, ".claude-plugin", "plugin.json")):
|
||||
return v
|
||||
return os.path.dirname(HERE)
|
||||
|
||||
|
||||
def load_fixture():
|
||||
if not FIXTURE:
|
||||
return None
|
||||
try:
|
||||
with open(FIXTURE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
die("🔴 讀不到 RELEASE_SHIP_FIXTURE:%s" % FIXTURE)
|
||||
|
||||
|
||||
FX = load_fixture()
|
||||
CHAIN = rc.Chain(HOST, REPO, fixture=FX)
|
||||
|
||||
|
||||
def ask(fn, *a):
|
||||
"""問收貨端;問不到就死(不猜)。"""
|
||||
try:
|
||||
return fn(*a)
|
||||
except rc.ChainError as e:
|
||||
die("🔴 問不到收貨端 %s:%s" % (HOST, e))
|
||||
|
||||
|
||||
# ══ 前置條件 ③ release note ════════════════════════════════════════════
|
||||
def auto_notes(tagname, releases):
|
||||
"""從 Gitea compare 生一份事實 note:上一個 release 之後到這個 tag 之間的 commit。"""
|
||||
since = CHAIN.latest_release_before(tagname, releases)
|
||||
subjects = ask(CHAIN.commits_between, since, tagname)
|
||||
head = ("這一版收了以下改動(%s 之後):" % since) if since else "這一版收了以下改動:"
|
||||
body = "\n".join("- %s" % s for s in subjects)
|
||||
if not body:
|
||||
body = "-(Gitea 取不到 commit 清單,請手動補;release 已建起來,leo 可直接編輯。)"
|
||||
return ("%s\n\n%s\n\n---\n(本 note 由 scripts/release-ship 從 git log 自動生成,"
|
||||
"leo/總管可直接在 Gitea 上編輯。inkstone/ISEP#67)" % (head, body))
|
||||
|
||||
|
||||
def release_name(tagname):
|
||||
"""`vX.Y.Z — 定版 commit 括號那句人話`,對齊 leo 既有 release 的命名慣例;找不到就純 tag 名。"""
|
||||
ver = tagname.lstrip("v")
|
||||
msg = ask(CHAIN.tag_commit_message, tagname)
|
||||
title = rc.dingban_title(msg, ver)
|
||||
if not title:
|
||||
for s in ask(CHAIN.commits_between, None, tagname)[-20:]:
|
||||
title = rc.dingban_title(s, ver)
|
||||
if title:
|
||||
break
|
||||
return ("%s — %s" % (tagname, title)) if title else tagname
|
||||
|
||||
|
||||
# ══ 真的建 release(POST 收貨端)═══════════════════════════════════════════
|
||||
def create_release(tagname, notes, dry, name):
|
||||
payload = {"tag_name": tagname, "name": name, "body": notes,
|
||||
"draft": False, "prerelease": False}
|
||||
if dry:
|
||||
return None, "🧪 --dry-run:不 POST。要建的 release payload:\n%s" % \
|
||||
json.dumps({"tag_name": tagname, "name": name,
|
||||
"body_lines": notes.count("\n") + 1}, ensure_ascii=False)
|
||||
if FX is not None:
|
||||
sink = os.environ.get("RELEASE_SHIP_SINK")
|
||||
if sink:
|
||||
try:
|
||||
cur = json.load(open(sink)) if os.path.exists(sink) else []
|
||||
except Exception:
|
||||
cur = []
|
||||
cur.append(tagname)
|
||||
json.dump(cur, open(sink, "w"))
|
||||
return {"tag_name": tagname, "html_url": "(fixture-created)"}, "(fixture 模式,未打網路)"
|
||||
tk = rc.token_from_env_or_remote(HOST, proj_root())
|
||||
if not tk:
|
||||
die("🔴 拿不到 %s 的 token —— 建 release 是寫入,一定要憑證"
|
||||
"(GITEA_TOKEN_CLAUDE_CODE,或這個 repo 的 remote URL 帶憑證)。" % HOST)
|
||||
url = "%s/api/v1/repos/%s/releases" % (HOST, REPO)
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(payload).encode(), method="POST",
|
||||
headers={"Authorization": "token " + tk, "Content-Type": "application/json"})
|
||||
try:
|
||||
r = json.load(urllib.request.urlopen(req, timeout=40))
|
||||
return r, "已建立"
|
||||
except urllib.error.HTTPError as e:
|
||||
die("🔴 Gitea 建 release 失敗 %s:%s" % (e.code, e.read().decode()[:300]))
|
||||
except Exception as e:
|
||||
die("🔴 連不上 %s:%s" % (HOST, e))
|
||||
|
||||
|
||||
# ══ 送完覆核:回頭問收貨端(不是問自己)══════════════════════════════════
|
||||
def verify_via_check(tagname):
|
||||
"""跑 `release-check <tag>`,拿收貨端事實覆核**這一版**。回 (ok, 輸出)。"""
|
||||
check = os.path.join(HERE, "release-check")
|
||||
env = dict(os.environ)
|
||||
if FX is not None:
|
||||
# fixture 模式下,把剛「建」進 sink 的 release 併進 release-check 的 fixture 視圖
|
||||
sink = os.environ.get("RELEASE_SHIP_SINK")
|
||||
extra = []
|
||||
if sink and os.path.exists(sink):
|
||||
try:
|
||||
extra = json.load(open(sink))
|
||||
except Exception:
|
||||
extra = []
|
||||
merged = dict(FX)
|
||||
merged["releases"] = sorted(set((FX.get("releases") or []) + extra))
|
||||
cf = (sink or "/tmp/rs") + ".checkfx.json"
|
||||
json.dump(merged, open(cf, "w"))
|
||||
env["RELEASE_CHECK_FIXTURE"] = cf
|
||||
env.setdefault("RELEASE_CHECK_HOST", HOST)
|
||||
env.setdefault("RELEASE_CHECK_REPO", REPO)
|
||||
try:
|
||||
p = subprocess.run(["python3", check, tagname], capture_output=True, text=True,
|
||||
timeout=60, env=env)
|
||||
return p.returncode == 0, (p.stdout + p.stderr).strip()
|
||||
except Exception as e:
|
||||
return False, "跑不動 release-check:%s" % e
|
||||
|
||||
|
||||
# ══ 主流程 ════════════════════════════════════════════════════════════════
|
||||
def ship(arg, notes_file=None, dry=False, do_verify=True):
|
||||
m = VER_RE.match(arg.strip())
|
||||
if not m:
|
||||
die("🔴 版本寫法是 vX.Y.Z 或 X.Y.Z,你給的是:%s" % arg)
|
||||
ver = m.group(1)
|
||||
tagname = "v" + ver
|
||||
out = ["📦 release-ship %s(把 tag 變成 leo 看得到的 release)" % tagname, ""]
|
||||
|
||||
tags = ask(CHAIN.tags)
|
||||
# 前置 ②(先問 tag 在不在——①要讀它的樹,tag 不在就沒有樹可讀)
|
||||
if tagname not in tags:
|
||||
die("🚫 前置② Gitea 上沒有 tag %s —— 不給建 release。\n"
|
||||
" release 是「把已存在的 tag 公開成一頁」,tag 都還沒推,先推:\n"
|
||||
" git tag %s && git push origin %s\n"
|
||||
" (推 tag 那一刻 release-tag-guard.sh 會擋下版本對不上的 tag。)"
|
||||
% (tagname, tagname, tagname))
|
||||
# 前置 ①(看 tag 自己的樹,不看工作樹)
|
||||
at_tag = ask(CHAIN.manifest_version_at, tagname)
|
||||
if at_tag != ver:
|
||||
die("🚫 前置① tag %s 的樹裡 plugin.json 寫的是「%s」—— 不給建 release。\n"
|
||||
" 建 release 是把這個 tag 公開給 leo 裝;tag 的樹自己就講錯版本,裝了會各說各話。\n"
|
||||
" 這種 tag 不該存在(release-tag-guard.sh 在推 tag 那一刻就會擋),\n"
|
||||
" 修法:刪掉這個 tag、在 main 上把 plugin.json 改成 %s 重新定版、再打一次 tag。"
|
||||
% (tagname, at_tag or "(讀不到)", ver))
|
||||
out.append("✅ 前置② Gitea 上有 tag %s(問過收貨端)" % tagname)
|
||||
out.append("✅ 前置① tag %s 的樹裡 plugin.json = %s(看的是 tag 的樹,不是工作樹)" % (tagname, ver))
|
||||
|
||||
# 冪等
|
||||
existing = ask(CHAIN.release, tagname)
|
||||
if existing:
|
||||
out.append("ℹ️ release %s 本來就在了(%s)—— 不重複建、不動它。"
|
||||
% (tagname, existing.get("html_url") or ""))
|
||||
code = 0
|
||||
if do_verify:
|
||||
vok, vout = verify_via_check(tagname)
|
||||
out.append("")
|
||||
out.append("── 覆核(release-check %s,問收貨端)──" % tagname)
|
||||
out.append(vout)
|
||||
code = 0 if vok else 2
|
||||
return code, "\n".join(out)
|
||||
|
||||
# 前置 ③
|
||||
releases = ask(CHAIN.releases)
|
||||
if notes_file:
|
||||
with open(notes_file) as f:
|
||||
notes = f.read()
|
||||
out.append("✅ 前置③ release note 來自 %s" % notes_file)
|
||||
else:
|
||||
notes = auto_notes(tagname, releases)
|
||||
out.append("✅ 前置③ release note 由 Gitea 的 commit 清單自動生成(leo 可再改)")
|
||||
|
||||
# 建(release 名對齊 leo 慣例:vX.Y.Z — 定版 commit 那句人話)
|
||||
relname = release_name(tagname)
|
||||
rel, how = create_release(tagname, notes, dry, relname)
|
||||
if dry:
|
||||
out.append(" release 名:%s" % relname)
|
||||
out.append("")
|
||||
out.append(how)
|
||||
return 0, "\n".join(out)
|
||||
out.append("✅ 已建立 release「%s」(%s)" % (relname, (rel or {}).get("html_url") or how))
|
||||
|
||||
# 覆核(送完問收貨端)
|
||||
code = 0
|
||||
if do_verify:
|
||||
vok, vout = verify_via_check(tagname)
|
||||
out.append("")
|
||||
out.append("── 覆核(release-check %s,問收貨端,不是問自己)──" % tagname)
|
||||
out.append(vout)
|
||||
code = 0 if vok else 2
|
||||
return code, "\n".join(out)
|
||||
|
||||
|
||||
def main(argv):
|
||||
arg, notes_file, dry, do_verify = None, None, False, True
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
elif a == "--notes-file":
|
||||
i += 1
|
||||
notes_file = argv[i]
|
||||
elif a == "--dry-run":
|
||||
dry = True
|
||||
elif a == "--no-verify":
|
||||
do_verify = False
|
||||
elif a.startswith("-"):
|
||||
die("不認得的參數:%s(--help 看用法)" % a)
|
||||
else:
|
||||
arg = a
|
||||
i += 1
|
||||
if not arg:
|
||||
die("用法:release-ship <版本 vX.Y.Z> [--notes-file <檔>] [--dry-run] [--no-verify]")
|
||||
code, report = ship(arg, notes_file, dry, do_verify)
|
||||
print(report, file=(sys.stdout if code == 0 else sys.stderr))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+1
-1
@@ -53,7 +53,7 @@ def cmd_show(name):
|
||||
|
||||
|
||||
def cmd_which(repo):
|
||||
hits = [w for w in R.load() if w["repo"].lower() == (repo or "").strip().lower()]
|
||||
hits = R.which(repo) # 一個工人可以管好幾個 repo(inkstone/ISEP#118)
|
||||
if not hits:
|
||||
print("🚫 名單上沒有負責「%s」的人。現有的是:" % repo, file=sys.stderr)
|
||||
for w in R.load():
|
||||
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""settings-allow-sync — 把 ISEP 的權限白名單寫進**這台機器**的 ~/.claude/settings.json
|
||||
(inkstone/ISEP#130)
|
||||
|
||||
━━ 為什麼有這支 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
leo 09-07 親手把四個 Gitea 正門工具(`ticket`/`mainline`/`gate-ok`/`gitea-pr-merge`)
|
||||
加進本機 `InkStoneCo/.claude/settings.json` 的 `permissions.allow`,auto mode 的分類器
|
||||
才不擋。**雲端 session 沒有那四條** ⇒ 同樣的動作在雲端照樣被擋
|
||||
(09-04 progress-guard run log:permission_denials=6)。
|
||||
|
||||
雲端真正讀的是薄殼 repo(GitHub)的 settings——推它要 D20 開閘(leo 親跑 github-arm)。
|
||||
⇒ 白名單不住薄殼、也不住 InkStoneCo,**住 ISEP 一份**(`docs/permissions-allow.json`),
|
||||
由本支在那台機器上寫進 `~/.claude/settings.json`(user scope,跟 cwd 無關)。
|
||||
雲端:`docs/cloud-setup-script.sh` 裝完 plugin 就跑一次;之後每個 SessionStart 再對一次,
|
||||
plugin 更新帶進新規則時不必等快照重拍。本機:同一支、同一份清單。
|
||||
|
||||
━━ 規矩 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
· 只加不減:settings.json 裡本來就有的東西一個字不動(別人手加的規則不是本支的地盤)。
|
||||
· 冪等:跑第二次 +0 條,檔案一個位元組都不變。
|
||||
· 清單裡開頭的 `~` 換成這台的家目錄(本機 /Users/…、雲端 /root),其餘照抄。
|
||||
· 原子寫:先寫暫存檔再 rename,中途斷掉不會留下半個 JSON。
|
||||
· 讀不懂目標(不是 JSON)⇒ **不碰它**、印出來、離開碼 0——這支掛在 SessionStart,
|
||||
不准因為它把 session 弄壞;`--check` 才會用離開碼講話。
|
||||
|
||||
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
settings-allow-sync 寫進 ~/.claude/settings.json
|
||||
settings-allow-sync --dry-run 只印會加哪幾條,不寫
|
||||
settings-allow-sync --check 對帳:缺任何一條 ⇒ 離開碼 1(測試/驗收用)
|
||||
settings-allow-sync --target <檔> --home <家目錄> 測試用:指到別的檔、別的家目錄
|
||||
settings-allow-sync --quiet 沒事就閉嘴(SessionStart 用)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SOURCE = os.path.join(HERE, "..", "docs", "permissions-allow.json")
|
||||
|
||||
|
||||
def wanted(home):
|
||||
with open(SOURCE, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
out = []
|
||||
for rule in d.get("allow") or []:
|
||||
if not isinstance(rule, str) or not rule.startswith("Bash(") or not rule.endswith(")"):
|
||||
raise ValueError("清單裡有一條不是 Bash(…) 的形狀:%r" % (rule,))
|
||||
inner = rule[len("Bash("):-1]
|
||||
# 只換「路徑開頭」的 ~:`python3 ~/.claude/…` 這種。`~` 出現在別處不動。
|
||||
parts = inner.split(" ")
|
||||
parts = [home + p[1:] if p.startswith("~/") else p for p in parts]
|
||||
out.append("Bash(" + " ".join(parts) + ")")
|
||||
return out
|
||||
|
||||
|
||||
def main(argv):
|
||||
target = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
|
||||
home = os.path.expanduser("~")
|
||||
dry = check = quiet = False
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--target":
|
||||
i += 1; target = argv[i]
|
||||
elif a == "--home":
|
||||
i += 1; home = argv[i].rstrip("/")
|
||||
elif a == "--dry-run":
|
||||
dry = True
|
||||
elif a == "--check":
|
||||
check = True
|
||||
elif a == "--quiet":
|
||||
quiet = True
|
||||
elif a in ("-h", "--help"):
|
||||
print(__doc__); return 0
|
||||
else:
|
||||
print("不認得的參數:%s(--help 看用法)" % a, file=sys.stderr); return 2
|
||||
i += 1
|
||||
|
||||
try:
|
||||
rules = wanted(home)
|
||||
except Exception as e:
|
||||
print("🔴 讀不了白名單清單 %s:%s" % (SOURCE, e), file=sys.stderr)
|
||||
return 1 if check else 0
|
||||
|
||||
cur = {}
|
||||
if os.path.exists(target):
|
||||
try:
|
||||
with open(target, encoding="utf-8") as f:
|
||||
cur = json.load(f)
|
||||
if not isinstance(cur, dict):
|
||||
raise ValueError("最外層不是物件")
|
||||
except Exception as e:
|
||||
print("🔴 %s 讀不懂(%s)——**沒有動它**。修好它再跑一次。" % (target, e), file=sys.stderr)
|
||||
return 1 if check else 0
|
||||
|
||||
perms = cur.get("permissions")
|
||||
if not isinstance(perms, dict):
|
||||
perms = {}
|
||||
allow = perms.get("allow")
|
||||
if not isinstance(allow, list):
|
||||
allow = []
|
||||
have = set(a for a in allow if isinstance(a, str))
|
||||
missing = [r for r in rules if r not in have]
|
||||
|
||||
if check:
|
||||
if missing:
|
||||
print("🔴 %s 缺 %d 條:" % (target, len(missing)))
|
||||
for r in missing:
|
||||
print(" " + r)
|
||||
return 1
|
||||
if not quiet:
|
||||
print("✅ %s 已有全部 %d 條" % (target, len(rules)))
|
||||
return 0
|
||||
|
||||
if not missing:
|
||||
if not quiet:
|
||||
print("✅ 白名單已齊(%d 條):%s" % (len(rules), target))
|
||||
return 0
|
||||
|
||||
if dry:
|
||||
print("🧪 --dry-run:會加 %d 條進 %s:" % (len(missing), target))
|
||||
for r in missing:
|
||||
print(" " + r)
|
||||
return 0
|
||||
|
||||
perms["allow"] = allow + missing
|
||||
cur["permissions"] = perms
|
||||
try:
|
||||
os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".settings-", suffix=".json", dir=os.path.dirname(target) or ".")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(cur, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
os.replace(tmp, target)
|
||||
except Exception as e:
|
||||
print("🔴 寫不進 %s:%s" % (target, e), file=sys.stderr)
|
||||
return 0
|
||||
print("✅ 白名單 +%d 條 → %s(共 %d 條來自 ISEP)" % (len(missing), target, len(rules)))
|
||||
if not quiet:
|
||||
for r in missing:
|
||||
print(" " + r)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+410
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
"""stage — 雲端對 **stage(youlin)** 寫入的正門(inkstone/ISEP#137)
|
||||
|
||||
━━ 為什麼有這支 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
09-07 三條主線工人在雲端對 stage 的寫入全被 Claude Code auto mode 的分類器擋(不是 ISEP 的閘):
|
||||
① `printf … | npx wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor`
|
||||
② `python3 scripts/stage-deploy-artifacts.py all --confirm`(Arcrun 的成品直推,CF API 上傳)
|
||||
③ `curl -X POST …arcrun-yuga3bse.workers.dev/records`/`/graph/canonicalize-entities`/`/map/recompute`
|
||||
權限白名單(docs/permissions-allow.json)是**逐條完全比對前綴**:複合指令(`cd … && printf … | VAR=… npx …`)
|
||||
與任意寫法的 curl 永遠對不上任何一條;放整類 `Bash(npx *)`/`Bash(curl *)` 又是紅線。
|
||||
leo 09-07:「為什麼會擋你,這就是要修復的問題」「本機環境跟你不同應該不行」。
|
||||
|
||||
⇒ 每一種寫入收成**一支固定形狀**,白名單放它的四種呼叫形狀:
|
||||
`stage secret …` = ①(打 api.cloudflare.com,不經 npx——雲端每次要重抓 wrangler,
|
||||
而且 wrangler 會讀 ~/.wrangler 的登入態:本機是 uncle6、雲端沒有,兩台行為不同)
|
||||
`stage api …` = ③(用 curl 打 stage 實例;youlin 邊緣對 python-urllib UA 回 403 code 1010,
|
||||
Arcrun#176 comment 6487——所以這一段**不用 urllib**)
|
||||
② = Arcrun 自己那支已經寫死只認 youlin 帳號 ID、憑證只從
|
||||
CLOUDFLARE_API_TOKEN_YOULIN_CC_USE 讀、不收 --account/--token;
|
||||
白名單直接放它的形狀(`python3 scripts/stage-deploy-artifacts.py *`),不重造一支。
|
||||
|
||||
━━ 只寫 stage,判準是唯一識別碼往放行方向比對,不是名字 ━━━━━━━━━━━━━━━━━━━━━━
|
||||
stage 隨便炸;出貨機(geek6688/uncle6)與 leo 的實例(leo21c)不是。
|
||||
secret:這把 token 打得到的帳號(GET /accounts 現查)裡要有 1129efd7df2e8899d537e9c8fbabb6cb,
|
||||
而且寫入的 URL 永遠只帶這個 ID。打不到 ⇒ 拒絕、印出它打得到誰、離開碼 2。
|
||||
api :URL 只會長在 `<worker>.arcrun-yuga3bse.workers.dev`(09-02 重裝後的 stage 子網域)。
|
||||
給完整網址時主機必須等於它,否則拒絕、離開碼 2——不管那個主機叫 leo21c 還是別的什麼。
|
||||
不看變數名字裡有沒有 `leo21c`/`geek6688`——名字改了黑名單就漏,ID 與主機改不了。
|
||||
|
||||
━━ 值不進指令列 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
secret 的值、Bearer、任何標頭值都只從**環境變數名**讀(--from-env/--bearer-env/--header-env);
|
||||
不落磁碟、不 echo、不進 git、不進 shell 歷史。curl 的標頭與 body 走 stdin 設定檔+管線,不走 argv。
|
||||
|
||||
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
stage secret put --worker <script> --secret <NAME> --from-env <VAR> [--token-env VAR] [--account ID]
|
||||
stage secret list --worker <script>
|
||||
stage secret delete --worker <script> --secret <NAME>
|
||||
--token-env <VAR> CF token 的環境變數名(預設 CLOUDFLARE_API_TOKEN_YOULIN_CC_USE,雲端就有)
|
||||
--account <ID> 目標帳號(預設 stage;給別的 ID 一律拒絕、不打網路)
|
||||
|
||||
stage api <METHOD> <worker>/<path>[?query] [--json '<body>' | --data-env <VAR> | --data-file <檔>]
|
||||
[--bearer-env <VAR>] [--header-env <Name>=<VAR>]…
|
||||
<worker>/<path> 例 arcrun-kbdb/records、arcrun-cypher-executor/webhooks/named/yuga3bse/x/trigger
|
||||
也收完整網址,但主機必須是 *.arcrun-yuga3bse.workers.dev
|
||||
--bearer-env VAR Authorization: Bearer <VAR 的值>(stage KBDB 的 POST 要 KBDB_INTERNAL_TOKEN)
|
||||
--header-env N=VAR 任一標頭,值從環境變數讀(例 X-Arcrun-API-Key=ARCRUN_NS_YOULIN)
|
||||
body 有給就自動帶 Content-Type: application/json
|
||||
印 `HTTP <code>` + 回應;2xx 離開碼 0,其餘 1;curl 連不上印它的錯誤字串(不記成 000)
|
||||
|
||||
例(雲端):
|
||||
python3 "$CLAUDE_PLUGIN_ROOT/scripts/stage" secret put --worker arcrun-cypher-executor \\
|
||||
--secret CF_SECRETS_API_TOKEN --from-env CF_SECRETS_API_TOKEN_VALUE
|
||||
python3 "$CLAUDE_PLUGIN_ROOT/scripts/stage" api POST 'arcrun-kbdb/map/recompute?library=rt-lib&owner_id=yuga3bse' \\
|
||||
--bearer-env KBDB_INTERNAL_TOKEN
|
||||
|
||||
離開碼:0 成功 / 2 被本工具拒絕(非 stage、缺變數、值在指令列)/ 1 API・網路失敗
|
||||
測試用:STAGE_SECRET_PUT_API_BASE(CF API)、STAGE_API_BASE_OVERRIDE(實例)指到假伺服器——scripts/test-stage.sh
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
STAGE_ACCOUNT_ID = "1129efd7df2e8899d537e9c8fbabb6cb" # youlin(D37 定的 stage 帳號)
|
||||
STAGE_LABEL = "youlin(stage)"
|
||||
STAGE_HOST_SUFFIX = ".arcrun-yuga3bse.workers.dev" # 09-02 重裝後的 stage 子網域(ISEP#130)
|
||||
DEFAULT_TOKEN_ENV = "CLOUDFLARE_API_TOKEN_YOULIN_CC_USE"
|
||||
CF_API_BASE = os.environ.get("STAGE_SECRET_PUT_API_BASE", "https://api.cloudflare.com/client/v4").rstrip("/")
|
||||
API_BASE_OVERRIDE = os.environ.get("STAGE_API_BASE_OVERRIDE", "").rstrip("/") # 測試用:整個實例指到假伺服器
|
||||
|
||||
EXIT_OK, EXIT_API, EXIT_REFUSED = 0, 1, 2
|
||||
FORBIDDEN_VALUE_FLAGS = ("--value", "--text", "--bearer", "--token", "--header")
|
||||
|
||||
|
||||
def die(code, msg):
|
||||
print(msg, file=sys.stderr)
|
||||
return code
|
||||
|
||||
|
||||
class Refuse(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ───────────────────────── secret(CF API,api.cloudflare.com)─────────────────────────
|
||||
|
||||
def parse_secret(argv):
|
||||
if not argv or argv[0] not in ("put", "list", "delete"):
|
||||
raise Refuse("secret 後面要接 put/list/delete,不是 %r(--help 看用法)" % (argv[:1] or [""])[0])
|
||||
cmd = argv[0]
|
||||
o = {"worker": None, "secret": None, "from_env": None, "token_env": DEFAULT_TOKEN_ENV, "account": STAGE_ACCOUNT_ID}
|
||||
keys = {"--worker": "worker", "--secret": "secret", "--from-env": "from_env", "--token-env": "token_env", "--account": "account"}
|
||||
i = 1
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a in FORBIDDEN_VALUE_FLAGS:
|
||||
raise Refuse("本工具不收 %s:值只從環境變數名讀(--from-env/--token-env),不准出現在指令列" % a)
|
||||
if a not in keys:
|
||||
raise Refuse("不認得的參數:%s(--help 看用法)" % a)
|
||||
if i + 1 >= len(argv):
|
||||
raise Refuse("%s 後面要接一個值" % a)
|
||||
o[keys[a]] = argv[i + 1]
|
||||
i += 2
|
||||
if not o["worker"]:
|
||||
raise Refuse("缺 --worker <script 名>")
|
||||
if cmd in ("put", "delete") and not o["secret"]:
|
||||
raise Refuse("缺 --secret <SECRET 名>")
|
||||
if cmd == "put" and not o["from_env"]:
|
||||
raise Refuse("缺 --from-env <環境變數名>(值只從這裡讀)")
|
||||
return cmd, o
|
||||
|
||||
|
||||
def cf(method, path, token, body=None):
|
||||
"""回 (http_status, parsed_json);網路層失敗回 (None, 錯誤字串)——不把三種病都記成 000。"""
|
||||
data = None
|
||||
headers = {"Authorization": "Bearer " + token, "User-Agent": "isep-stage/1 (+inkstone/ISEP#137)"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(CF_API_BASE + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return resp.status, json.loads(resp.read().decode("utf-8") or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read().decode("utf-8") or "{}")
|
||||
except Exception:
|
||||
return e.code, {"success": False, "errors": [{"message": "HTTP %d(非 JSON 回應)" % e.code}]}
|
||||
except Exception as e:
|
||||
return None, "%s: %s" % (type(e).__name__, e)
|
||||
|
||||
|
||||
def cf_ok(status, d):
|
||||
# CF 對「建立」回 201(secret PUT 實測就是 201,inkstone/ISEP#137 第一次實跑抓到的)——2xx + success 才算好
|
||||
return status is not None and 200 <= status < 300 and isinstance(d, dict) and bool(d.get("success"))
|
||||
|
||||
|
||||
def cf_errors(d):
|
||||
if isinstance(d, dict):
|
||||
errs = d.get("errors") or []
|
||||
return ";".join("%s(code %s)" % (e.get("message"), e.get("code")) for e in errs) or "(CF 沒給錯誤訊息)"
|
||||
return str(d)
|
||||
|
||||
|
||||
def refuse_account(given):
|
||||
return die(EXIT_REFUSED, "\n".join([
|
||||
"🛑 本工具只種 stage,不種 %s。" % given,
|
||||
"",
|
||||
" stage = %s,帳號 ID %s" % (STAGE_LABEL, STAGE_ACCOUNT_ID),
|
||||
" 你給的 = %s" % given,
|
||||
"",
|
||||
"【為什麼】stage 隨便炸;出貨機(geek6688/uncle6)與 leo 的實例(leo21c)不是(inkstone/ISEP#137)。",
|
||||
"【出路】要種進那兩類帳號 ⇒ 交回地端,由 leo 親手在 Mac 上 `wrangler secret put`;",
|
||||
" 雲端這支不會、也不該會。要種 stage ⇒ 把 --account 拿掉(預設就是 stage)。",
|
||||
]))
|
||||
|
||||
|
||||
def check_token_reaches_stage(token, token_env):
|
||||
status, d = cf("GET", "/accounts", token)
|
||||
if status is None:
|
||||
return die(EXIT_API, "🔴 連不到 CF API(%s):%s\n 雲端看到這個先查 `curl -sS \"$HTTPS_PROXY/__agentproxy/status\"`(policy 拒絕 vs DNS vs 對方沒回,三種修法不同)。" % (CF_API_BASE, d))
|
||||
if not cf_ok(status, d):
|
||||
return die(EXIT_API, "🔴 CF 不認這把 token(%s,HTTP %s):%s" % (token_env, status, cf_errors(d)))
|
||||
accounts = [(a.get("id"), a.get("name")) for a in (d.get("result") or []) if isinstance(a, dict)]
|
||||
if STAGE_ACCOUNT_ID not in [i for i, _ in accounts]:
|
||||
reach = "\n".join(" · %s %s" % (i, n) for i, n in accounts) or " ·(一個都沒有)"
|
||||
return die(EXIT_REFUSED, "\n".join([
|
||||
"🛑 這把 token(%s)打不到 stage,拒絕。" % token_env,
|
||||
"",
|
||||
" stage = %s,帳號 ID %s" % (STAGE_LABEL, STAGE_ACCOUNT_ID),
|
||||
" 這把 token 打得到的帳號:",
|
||||
reach,
|
||||
"",
|
||||
"【為什麼】本工具只種 stage;token 是誰的就會種進誰的帳號,所以先問 CF 它打得到誰(inkstone/ISEP#137)。",
|
||||
"【出路】用 stage 的那把:--token-env %s(雲端 Cloud environment 就有,本機在頂層 .env)。" % DEFAULT_TOKEN_ENV,
|
||||
" 真要種出貨機/leo21c ⇒ 交回地端由 leo 親手做。",
|
||||
]))
|
||||
return None
|
||||
|
||||
|
||||
def list_names(token, account, worker):
|
||||
status, d = cf("GET", "/accounts/%s/workers/scripts/%s/secrets" % (account, worker), token)
|
||||
if status is None:
|
||||
return None, "連不到 CF API(%s):%s" % (CF_API_BASE, d)
|
||||
if not cf_ok(status, d):
|
||||
return None, "列 secrets 失敗(HTTP %s):%s" % (status, cf_errors(d))
|
||||
return sorted(s.get("name") for s in (d.get("result") or []) if isinstance(s, dict) and s.get("name")), None
|
||||
|
||||
|
||||
def run_secret(argv):
|
||||
try:
|
||||
cmd, o = parse_secret(argv)
|
||||
except Refuse as e:
|
||||
return die(EXIT_REFUSED, "🛑 %s" % e)
|
||||
|
||||
if o["account"] != STAGE_ACCOUNT_ID:
|
||||
return refuse_account(o["account"])
|
||||
token = os.environ.get(o["token_env"], "")
|
||||
if not token:
|
||||
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(CF token)。\n【出路】雲端:Cloud environment 要有它(scripts/make-cloud-env.sh 的清單裡就有);本機:從頂層 .env 匯出。" % o["token_env"])
|
||||
value = None
|
||||
if cmd == "put":
|
||||
value = os.environ.get(o["from_env"], "")
|
||||
if not value:
|
||||
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(要種進去的 secret 本身)。\n【出路】把值放進這個環境變數再跑;不要改成把值寫在指令列——那是紅線(值不落磁碟、不進指令歷史)。" % o["from_env"])
|
||||
|
||||
rc = check_token_reaches_stage(token, o["token_env"])
|
||||
if rc is not None:
|
||||
return rc
|
||||
acct, worker = o["account"], o["worker"]
|
||||
|
||||
if cmd == "list":
|
||||
names, err = list_names(token, acct, worker)
|
||||
if err:
|
||||
return die(EXIT_API, "🔴 %s" % err)
|
||||
print("✅ %s@%s 現有 %d 把 secret:%s" % (worker, STAGE_LABEL, len(names), "、".join(names) or "(沒有)"))
|
||||
return EXIT_OK
|
||||
|
||||
if cmd == "delete":
|
||||
status, d = cf("DELETE", "/accounts/%s/workers/scripts/%s/secrets/%s" % (acct, worker, o["secret"]), token)
|
||||
if status is None:
|
||||
return die(EXIT_API, "🔴 連不到 CF API:%s" % d)
|
||||
if not cf_ok(status, d):
|
||||
return die(EXIT_API, "🔴 刪 %s 失敗(HTTP %s):%s" % (o["secret"], status, cf_errors(d)))
|
||||
names, err = list_names(token, acct, worker)
|
||||
if err:
|
||||
return die(EXIT_API, "🔴 刪了但列不出來確認:%s" % err)
|
||||
if o["secret"] in names:
|
||||
return die(EXIT_API, "🔴 CF 說刪了,列表卻還有 %s" % o["secret"])
|
||||
print("✅ 已刪 %s ← %s@%s;剩 %d 把:%s" % (o["secret"], worker, STAGE_LABEL, len(names), "、".join(names) or "(沒有)"))
|
||||
return EXIT_OK
|
||||
|
||||
body = {"name": o["secret"], "text": value, "type": "secret_text"}
|
||||
status, d = cf("PUT", "/accounts/%s/workers/scripts/%s/secrets" % (acct, worker), token, body)
|
||||
del value, body
|
||||
if status is None:
|
||||
return die(EXIT_API, "🔴 連不到 CF API:%s" % d)
|
||||
if not cf_ok(status, d):
|
||||
return die(EXIT_API, "🔴 種 %s → %s 失敗(HTTP %s):%s" % (o["secret"], worker, status, cf_errors(d)))
|
||||
names, err = list_names(token, acct, worker)
|
||||
if err:
|
||||
return die(EXIT_API, "🔴 CF 說種好了,但列不出來確認:%s" % err)
|
||||
if o["secret"] not in names:
|
||||
return die(EXIT_API, "🔴 CF 說種好了,列表卻沒有 %s(有的是:%s)" % (o["secret"], "、".join(names)))
|
||||
print("✅ 已種 %s → %s@%s(值來自環境變數 %s,沒印、沒落地)" % (o["secret"], worker, STAGE_LABEL, o["from_env"]))
|
||||
print(" 現有 %d 把:%s" % (len(names), "、".join(names)))
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
# ───────────────────────── api(curl 打 stage 實例)─────────────────────────
|
||||
|
||||
def parse_api(argv):
|
||||
if len(argv) < 2:
|
||||
raise Refuse("api 後面要接 <METHOD> <worker>/<path>(--help 看用法)")
|
||||
method, target = argv[0].upper(), argv[1]
|
||||
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||
raise Refuse("METHOD 要是 GET/POST/PUT/PATCH/DELETE,不是 %r" % argv[0])
|
||||
o = {"json": None, "data_env": None, "data_file": None, "bearer_env": None, "header_envs": []}
|
||||
i = 2
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a in FORBIDDEN_VALUE_FLAGS:
|
||||
raise Refuse("本工具不收 %s:標頭值只從環境變數名讀(--bearer-env/--header-env Name=VAR),不准出現在指令列" % a)
|
||||
if i + 1 >= len(argv):
|
||||
raise Refuse("%s 後面要接一個值" % a)
|
||||
v = argv[i + 1]
|
||||
if a == "--json":
|
||||
o["json"] = v
|
||||
elif a == "--data-env":
|
||||
o["data_env"] = v
|
||||
elif a == "--data-file":
|
||||
o["data_file"] = v
|
||||
elif a == "--bearer-env":
|
||||
o["bearer_env"] = v
|
||||
elif a == "--header-env":
|
||||
if "=" not in v:
|
||||
raise Refuse("--header-env 的形狀是 Name=VAR(例 X-Arcrun-API-Key=ARCRUN_NS_YOULIN),不是 %r" % v)
|
||||
o["header_envs"].append(tuple(v.split("=", 1)))
|
||||
else:
|
||||
raise Refuse("不認得的參數:%s(--help 看用法)" % a)
|
||||
i += 2
|
||||
if sum(1 for k in ("json", "data_env", "data_file") if o[k]) > 1:
|
||||
raise Refuse("--json/--data-env/--data-file 只能給一個")
|
||||
return method, target, o
|
||||
|
||||
|
||||
def stage_url(target):
|
||||
"""把 <worker>/<path> 或完整網址變成 stage 的網址;主機不是 stage 就拒絕。"""
|
||||
if "://" in target:
|
||||
u = urllib.parse.urlsplit(target)
|
||||
host = (u.hostname or "").lower()
|
||||
if not host.endswith(STAGE_HOST_SUFFIX) or host == STAGE_HOST_SUFFIX.lstrip("."):
|
||||
raise Refuse("\n".join([
|
||||
"本工具只打 stage,不打 %s。" % host,
|
||||
"",
|
||||
" stage = %s,主機一律是 <worker>%s" % (STAGE_LABEL, STAGE_HOST_SUFFIX),
|
||||
" 你給的 = %s" % target,
|
||||
"",
|
||||
"【為什麼】stage 隨便炸;leo21c 是 leo 本人在用的實例、geek6688/uncle6 是出貨機(inkstone/ISEP#137)。",
|
||||
"【出路】打 stage ⇒ 寫成 <worker>/<path>(例 arcrun-kbdb/records),主機由本工具補。",
|
||||
" 對 leo21c 唯讀查詢 ⇒ 直接 curl GET(leo21c-write-guard 放行讀);要寫 ⇒ 交回地端由 leo 親手做。",
|
||||
]))
|
||||
if API_BASE_OVERRIDE:
|
||||
return API_BASE_OVERRIDE + "/" + host.split(".")[0] + (u.path or "/") + ("?" + u.query if u.query else "")
|
||||
return urllib.parse.urlunsplit(("https", host, u.path or "/", u.query, ""))
|
||||
if "/" not in target:
|
||||
raise Refuse("目標要寫成 <worker>/<path>(例 arcrun-kbdb/records),不是 %r" % target)
|
||||
worker, rest = target.split("/", 1)
|
||||
if not worker or any(c in worker for c in ".:@ "):
|
||||
raise Refuse("<worker> 只能是 worker 名(例 arcrun-kbdb),不是 %r" % worker)
|
||||
if API_BASE_OVERRIDE:
|
||||
return API_BASE_OVERRIDE + "/" + worker + "/" + rest
|
||||
return "https://" + worker + STAGE_HOST_SUFFIX + "/" + rest
|
||||
|
||||
|
||||
def curl_quote(s):
|
||||
return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t") + '"'
|
||||
|
||||
|
||||
def run_api(argv):
|
||||
try:
|
||||
method, target, o = parse_api(argv)
|
||||
url = stage_url(target)
|
||||
except Refuse as e:
|
||||
return die(EXIT_REFUSED, "🛑 %s" % e)
|
||||
|
||||
headers = []
|
||||
if o["bearer_env"]:
|
||||
v = os.environ.get(o["bearer_env"], "")
|
||||
if not v:
|
||||
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(Bearer)。\n【出路】把值放進這個環境變數再跑;不要改成寫在指令列。" % o["bearer_env"])
|
||||
headers.append("Authorization: Bearer " + v)
|
||||
for name, var in o["header_envs"]:
|
||||
v = os.environ.get(var, "")
|
||||
if not v:
|
||||
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(標頭 %s)。" % (var, name))
|
||||
headers.append(name + ": " + v)
|
||||
|
||||
body = None
|
||||
if o["json"] is not None:
|
||||
body = o["json"]
|
||||
elif o["data_env"]:
|
||||
body = os.environ.get(o["data_env"], "")
|
||||
if not body:
|
||||
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(body)。" % o["data_env"])
|
||||
elif o["data_file"]:
|
||||
try:
|
||||
with open(o["data_file"], encoding="utf-8") as f:
|
||||
body = f.read()
|
||||
except OSError as e:
|
||||
return die(EXIT_REFUSED, "🛑 讀不了 --data-file %s:%s" % (o["data_file"], e))
|
||||
if body is not None:
|
||||
headers.append("Content-Type: application/json")
|
||||
|
||||
# 標頭與 body 都不走 argv:設定檔從 stdin 進,body 從一條管線(/dev/fd/N)進
|
||||
rfd, wfd = os.pipe()
|
||||
cfg = ["url = " + curl_quote(url), "request = " + curl_quote(method), "silent", "show-error",
|
||||
"write-out = " + curl_quote("\\n__STAGE_HTTP__%{http_code}"), "max-time = 120",
|
||||
"user-agent = " + curl_quote("isep-stage/1 (+inkstone/ISEP#137)")]
|
||||
for h in headers:
|
||||
cfg.append("header = " + curl_quote(h))
|
||||
if body is not None:
|
||||
cfg.append("data-binary = " + curl_quote("@/dev/fd/%d" % rfd))
|
||||
try:
|
||||
p = subprocess.Popen(["curl", "--config", "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, pass_fds=(rfd,) if body is not None else ())
|
||||
except FileNotFoundError:
|
||||
os.close(rfd); os.close(wfd)
|
||||
return die(EXIT_API, "🔴 這台沒有 curl。")
|
||||
os.close(rfd)
|
||||
if body is not None:
|
||||
with os.fdopen(wfd, "w", encoding="utf-8") as w:
|
||||
w.write(body)
|
||||
else:
|
||||
os.close(wfd)
|
||||
out, err = p.communicate(("\n".join(cfg) + "\n").encode("utf-8"))
|
||||
del headers, cfg
|
||||
text = out.decode("utf-8", "replace")
|
||||
if p.returncode != 0:
|
||||
return die(EXIT_API, "🔴 curl 失敗(離開碼 %d):%s\n 雲端 403 CONNECT ⇒ `curl -sS \"$HTTPS_PROXY/__agentproxy/status\"`(policy);`error code: 1010` ⇒ youlin 邊緣擋 UA。"
|
||||
% (p.returncode, err.decode("utf-8", "replace").strip()))
|
||||
resp, _, code = text.rpartition("\n__STAGE_HTTP__")
|
||||
code = code.strip() or "000"
|
||||
try:
|
||||
pretty = json.dumps(json.loads(resp), ensure_ascii=False, indent=2) if resp.strip() else ""
|
||||
except ValueError:
|
||||
pretty = resp
|
||||
print("HTTP %s %s %s" % (code, method, url))
|
||||
if pretty:
|
||||
print(pretty)
|
||||
return EXIT_OK if code.isdigit() and 200 <= int(code) < 300 else EXIT_API
|
||||
|
||||
|
||||
# ───────────────────────── 入口 ─────────────────────────
|
||||
|
||||
def main(argv):
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
return EXIT_OK
|
||||
if argv[0] == "secret":
|
||||
return run_secret(argv[1:])
|
||||
if argv[0] == "api":
|
||||
return run_api(argv[1:])
|
||||
return die(EXIT_REFUSED, "🛑 第一個參數要是 secret/api,不是 %r(--help 看用法)" % argv[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,11 +1,14 @@
|
||||
#!/bin/bash
|
||||
# 交棒警察的測試——三格(指派/tag/下一步)各自缺漏時要被抓到
|
||||
# 判準:5 種該報、5 種不該報
|
||||
# 判準:前景 5 種該報、5 種不該報;背景派工(inkstone/ISEP#153)送出時不報、交件時才報、只報一次;
|
||||
# 訊息印的出路照著貼真的跑得完。
|
||||
# 用 fixture 跑,不打網路、不在票池留下任何一張測試票。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
ROOT=$(pwd)
|
||||
H=hooks/baton-handback-guard.sh
|
||||
PASS=0; FAIL=0
|
||||
FX=$(mktemp -d); trap 'rm -rf "$FX"' EXIT
|
||||
FX=$(mktemp -d); ST=$(mktemp -d); trap 'rm -rf "$FX" "$ST"' EXIT
|
||||
export CLAUDE_PLUGIN_ROOT="$ROOT" ISEP_BATON_STATE_DIR="$ST"
|
||||
|
||||
mkfx(){ # $1=state $2=assignees(csv) $3=labels(csv) $4=有沒有交棒留言(y/n)
|
||||
python3 - "$FX" "$1" "$2" "$3" "$4" <<'PY'
|
||||
@@ -22,12 +25,15 @@ json.dump(comments, open(os.path.join(d, "comments.json"), "w"))
|
||||
PY
|
||||
}
|
||||
|
||||
judge(){ # $1=want $2=got $3=說明
|
||||
if [ "$2" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$1" "$2" "$3"
|
||||
}
|
||||
|
||||
run(){ # $1=want $2=說明 (fixture 已備好)
|
||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"【工單】inkstone/arcrun-rag#136\n做點事"}}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$1" "$got" "$2"
|
||||
judge "$1" "$?" "$2"
|
||||
}
|
||||
|
||||
echo "── 該報:三格有缺 ──"
|
||||
@@ -46,9 +52,98 @@ mkfx closed "" "" n ; run 0 "票已關=棒子到
|
||||
echo "── 派工單裡沒有【工單】那行 ⇒ 不歸這支管 ──"
|
||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"隨便做點事"}}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = 0 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=0 got=%s 沒有票號的派工單\n' "$got"
|
||||
judge 0 "$?" "沒有票號的派工單"
|
||||
|
||||
# ═══ inkstone/ISEP#153:背景派工 ═══════════════════════════════════════
|
||||
post(){ # $1=tool_response JSON → 離開碼
|
||||
printf '{"hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"【工單】inkstone/arcrun-rag#136\\n做點事","run_in_background":true},"tool_response":%s}' "$1" \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "── 驗收 1:背景派工送出的那一刻不報(票上三格全缺也一樣)──"
|
||||
mkfx open "" "s/doing" n
|
||||
post '{"isAsync":true,"status":"async_launched","agentId":"a93f7dc98c50fc671","description":"x"}'
|
||||
judge 0 "$?" "launch ack(toolUseResult 真跡的形狀:isAsync/async_launched)"
|
||||
post '[{"type":"text","text":"Async agent launched successfully. (This tool result is internal metadata)\nagentId: a1"}]'
|
||||
judge 0 "$?" "launch ack 只剩文字 content 陣列時也認得"
|
||||
post '{"status":"completed","content":[{"type":"text","text":"工作做完了"}]}'
|
||||
judge 2 "$?" "驗收 3 對照:前景派工回來(不是 launch ack)照舊要報"
|
||||
|
||||
# 假 transcript:派工 tool_use + launch ack +(可選)完工通知
|
||||
TP="$ST/main.jsonl"
|
||||
mktx(){ # $1=通知對到哪個 tool-use-id(空=還沒交件) $2=通知 status $3=派工單第一行
|
||||
python3 - "$TP" "$1" "$2" "$3" <<'PY'
|
||||
import json, sys
|
||||
tp, note_id, status, first = sys.argv[1:5]
|
||||
L = []
|
||||
def add(o): L.append(json.dumps(o, ensure_ascii=False))
|
||||
for tid, line in (("toolu_AAA", first), ("toolu_BBB", "【工單】inkstone/ISEP#999")):
|
||||
add({"type": "assistant", "message": {"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": tid, "name": "Agent",
|
||||
"input": {"prompt": line + "\n做點事", "run_in_background": True}}]}})
|
||||
add({"type": "user", "message": {"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": tid,
|
||||
"content": [{"type": "text", "text": "Async agent launched successfully."}]}]},
|
||||
"toolUseResult": {"isAsync": True, "status": "async_launched"}})
|
||||
if note_id:
|
||||
n = ("<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>%s</tool-use-id>\n"
|
||||
"<status>%s</status>\n<summary>Agent finished</summary>\n<result>好了</result>\n"
|
||||
"</task-notification>") % (note_id, status)
|
||||
# 真跡裡同一則通知出現三次:enqueue/attachment/remove
|
||||
add({"type": "queue-operation", "operation": "enqueue", "content": n})
|
||||
add({"type": "attachment", "attachment": {"type": "queued_command", "prompt": n}})
|
||||
add({"type": "queue-operation", "operation": "remove", "content": n})
|
||||
open(tp, "w").write("\n".join(L) + "\n")
|
||||
PY
|
||||
}
|
||||
stop(){ # $1=session id → 離開碼;stderr 存到 $ST/err
|
||||
printf '{"hook_event_name":"Stop","session_id":"%s","transcript_path":"%s","stop_hook_active":false}' "$1" "$TP" \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>"$ST/err"
|
||||
}
|
||||
|
||||
echo "── 驗收 2:背景工人真的交件時才報 ──"
|
||||
mkfx open "" "s/doing" n
|
||||
mktx "" "" "【工單】inkstone/arcrun-rag#136"
|
||||
stop s1; judge 0 "$?" "工人還在跑(沒有完工通知)⇒ 不報"
|
||||
mktx toolu_AAA completed "【工單】inkstone/arcrun-rag#136"
|
||||
stop s1; got=$?; judge 2 "$got" "完工通知 completed + 票上三格全缺 ⇒ 報"
|
||||
if grep -q 'inkstone/arcrun-rag#136' "$ST/err" && ! grep -q 'ISEP#999' "$ST/err"; then got=ok; else got=bad; fi
|
||||
judge ok "$got" "只點名那一則通知對到的票(tool-use-id 比對;另一條還在跑的 #999 不點)"
|
||||
cp "$ST/err" "$ST/msg"
|
||||
stop s1; judge 0 "$?" "同一則通知第二次 Stop ⇒ 不鬼打牆(只點名一次)"
|
||||
mktx toolu_AAA killed "【工單】inkstone/arcrun-rag#136"
|
||||
stop s2; judge 0 "$?" "通知不是 completed(killed)⇒ 不當成交件"
|
||||
mkfx open "claude-code" "s/review" y
|
||||
mktx toolu_AAA completed "【工單】inkstone/arcrun-rag#136"
|
||||
stop s3; judge 0 "$?" "交件時三格已經補齊 ⇒ 安靜(出路:補完就不再出現)"
|
||||
mkfx open "" "s/doing" n
|
||||
mktx toolu_AAA completed "隨便做點事,沒有工單"
|
||||
stop s4; judge 0 "$?" "交件的那條派工單沒有【工單】⇒ 不歸這支管"
|
||||
printf '{"hook_event_name":"Stop","session_id":"s5","transcript_path":"/nonexistent"}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
judge 0 "$?" "讀不到 transcript ⇒ 放行"
|
||||
|
||||
echo "── 出路:訊息印的那塊指令照著貼真的跑得完 ──"
|
||||
BLK=$(python3 - "$ST/msg" <<'PY'
|
||||
import sys, re
|
||||
t = open(sys.argv[1], encoding="utf-8").read()
|
||||
m = re.search(r'^\s*("[^"\n]*/scripts/ticket" handback .*?--evidence "[^"\n]*")', t, re.S | re.M)
|
||||
if not m:
|
||||
raise SystemExit
|
||||
b = m.group(1)
|
||||
b = b.replace("<收下的人第一件事要做什麼,一句話>", "去複驗")
|
||||
b = re.sub(r"<s/review|[^>]*>", "s/review", b)
|
||||
b = b.replace("<實測輸出或 PR 連結>", "測試")
|
||||
print(b)
|
||||
PY
|
||||
)
|
||||
case "$BLK" in /*|\"/*) got=abs ;; *) got="${BLK:0:30}" ;; esac
|
||||
judge abs "$got" "出路印的是絕對路徑(InkStoneCo/scripts/ticket 是舊複本,沒有 handback)"
|
||||
OFF="TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN_CLAUDE_CODE=fake ISEP_API_RETRIES=0"
|
||||
env $OFF bash -c "$BLK" >/dev/null 2>&1
|
||||
judge 1 "$?" "★ 原樣貼上只填三格 ⇒ 參數全過、走到網路才停(離開碼 1;2=被自己的用法擋下)"
|
||||
env $OFF bash -c "${BLK/--next/--nope}" >/dev/null 2>&1
|
||||
judge 2 "$?" "對照組:拿掉 --next ⇒ 確實跑不完(上一條才有鑑別力)"
|
||||
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
|
||||
@@ -64,6 +64,158 @@ printf '已驗完,全部綠燈\n' > "$TMPF"
|
||||
run 0 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF"
|
||||
rm -f "$TMPF" "/tmp/.comment-task-guard-warned-$SID"
|
||||
|
||||
echo "── 閘教的那行指令要真的跑得動(inkstone/ISEP#112)──"
|
||||
# 🔴 這一組**不看離開碼**,看訊息內容、而且**真的把訊息裡那塊指令執行一次**。
|
||||
# ISEP wiki mistakes.md 記過:訊息壞掉時閘照樣 exit 2,只驗離開碼完全看不出來。
|
||||
# 這張票就是那個形狀的極端版——閘擋對了,但它教的解法**根本不存在**。
|
||||
SID2=test-cctg-msg-$$
|
||||
rm -f "/tmp/.comment-task-guard-warned-$SID2"
|
||||
PAYLOAD=$(python3 -c '
|
||||
import json, sys
|
||||
cmd = ("curl -X POST \"$API/repos/inkstone/arcrun-rag/issues/136/comments\" "
|
||||
"-d \x27{\"body\":\"Phase 1 的驗收在雲端那半出貨之前驗不了\"}\x27")
|
||||
print(json.dumps({"session_id": sys.argv[1], "tool_name": "Bash",
|
||||
"tool_input": {"command": cmd}}, ensure_ascii=False))
|
||||
' "$SID2")
|
||||
MSGFILE=$(mktemp)
|
||||
printf '%s' "$PAYLOAD" | bash "$H" 2>"$MSGFILE" >/dev/null
|
||||
rm -f "/tmp/.comment-task-guard-warned-$SID2"
|
||||
|
||||
RESFILE=$(mktemp)
|
||||
PAYFILE=$(mktemp); printf '%s' "$PAYLOAD" > "$PAYFILE"
|
||||
rm -f "/tmp/.comment-task-guard-warned-$SID2"
|
||||
MSGFILE="$MSGFILE" RESFILE="$RESFILE" GUARD="$H" PAYLOAD="$PAYFILE" python3 - <<'PY'
|
||||
import io, os, re, subprocess, sys
|
||||
|
||||
msg = io.open(os.environ["MSGFILE"], encoding="utf-8").read()
|
||||
res = []
|
||||
|
||||
|
||||
def t(ok, name, detail=""):
|
||||
res.append((bool(ok), name, detail))
|
||||
|
||||
|
||||
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) \
|
||||
if "__file__" in dir() else os.getcwd()
|
||||
ticket = os.path.realpath(os.path.join(os.getcwd(), "scripts", "ticket"))
|
||||
|
||||
# ⑮ 那塊指令**真正要執行的那一行**,開頭是絕對路徑,而且指到這一份正本。
|
||||
# 🔴 判準刻意放在「要執行的那一行」上,不是「訊息裡出現過的所有路徑」——
|
||||
# 後者在相對路徑混進來時是**空過**的(別的行還有絕對路徑,all() 照樣真)。
|
||||
# 2026-08-31 變異測試當場抓到:把 example 改回相對路徑,舊寫法的 ⑮ 沒紅。
|
||||
run_line = ""
|
||||
for l in msg.splitlines():
|
||||
if " subtask " in l and l.strip().endswith("\\"):
|
||||
run_line = l.strip()
|
||||
break
|
||||
first = run_line.split()[0] if run_line else ""
|
||||
t(first.startswith("/") and os.path.realpath(first) == ticket,
|
||||
"⑮ 要執行的那一行開頭是絕對路徑,且就是這一份正本",
|
||||
"那一行:%s" % (run_line or "(找不到)"))
|
||||
|
||||
# ⑯ 訊息裡**不准**出現相對的 `scripts/ticket`
|
||||
# 相對路徑是對「貼上去那個人的 cwd」解析的,而 InkStoneCo/scripts/ticket 是舊複本。
|
||||
rel = [l for l in msg.splitlines()
|
||||
if re.search(r"(?<![\w/])scripts/ticket\b", l)]
|
||||
t(not rel, "⑯ 訊息裡沒有任何相對路徑的 scripts/ticket",
|
||||
"違規行:%s" % (rel[:2] or ""))
|
||||
|
||||
# ⑰ 訊息裡的用法區塊 = `ticket usage subtask` 的輸出(**一字不差**)
|
||||
# 這就是「不再有第二份可以漂」的機械證明:漂了這一條當場紅。
|
||||
usage = subprocess.run([sys.executable, ticket, "usage", "subtask"],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
flat_msg = "\n".join(l.strip() for l in msg.splitlines())
|
||||
flat_use = "\n".join(l.strip() for l in usage.splitlines())
|
||||
t(flat_use and flat_use in flat_msg,
|
||||
"⑰ 訊息裡的用法就是 `ticket usage subtask` 的輸出,沒有第二份",
|
||||
"" if flat_use in flat_msg else "訊息裡找不到正本印的用法")
|
||||
|
||||
|
||||
def extract_block(m):
|
||||
"""把訊息裡那塊指令**原樣**抽出來——不重新產生,否則就驗不到漂移。"""
|
||||
lines = m.splitlines()
|
||||
i = next(k for k, l in enumerate(lines) if l.startswith("cat > "))
|
||||
md = next(k for k in range(i, len(lines)) if lines[k] == "MD")
|
||||
j = next(k for k in range(md + 1, len(lines)) if not lines[k].endswith("\\"))
|
||||
return "\n".join(lines[i:j + 1])
|
||||
|
||||
|
||||
def fill(block):
|
||||
"""只填三格機器不可能知道的:標題、目標、驗收條件。其餘一個字都不改。"""
|
||||
block = (block.replace("<誰>", "跑這行指令的人")
|
||||
.replace("<什麼>", "它真的跑得動")
|
||||
.replace("<為什麼>", "不會被迫去繞過它"))
|
||||
return re.sub(r"<[^<>\n]+>", "測試填的內容", block)
|
||||
|
||||
|
||||
ENV = dict(os.environ, TICKET_HOST="http://127.0.0.1:9",
|
||||
GITEA_TOKEN_CLAUDE_CODE="dummy", GITEA_TOKEN="dummy")
|
||||
|
||||
# ⑱ ★ 把它原樣貼上、只填那三格,**真的跑一次**。
|
||||
# 離開碼 2 = 被某道閘擋下(動詞不存在/旗標不成組/標題不合格…)= 這張票的病復發
|
||||
# 離開碼 1 = 一路走到網路才炸(TICKET_HOST 指到連不上的位址)= 參數全部過關
|
||||
try:
|
||||
blk = fill(extract_block(msg))
|
||||
r = subprocess.run(["bash", "-c", blk], capture_output=True, text=True, env=ENV)
|
||||
code = r.returncode
|
||||
except StopIteration:
|
||||
blk, code, r = "", -1, None
|
||||
t(code == 1, "⑱ ★ 訊息裡那塊指令原樣貼上就跑得完(走到網路才停,不是被閘擋)",
|
||||
"離開碼 %s(2 = 又被某道閘擋下)%s"
|
||||
% (code, ("|" + (r.stderr or "").strip().splitlines()[0][:80]) if r and code == 2 else ""))
|
||||
|
||||
# ⑲ 鑑別力對照:⑱ 不是「只要跑就會過」。
|
||||
# 把閘**原本**印的那組旗標(`--assign <誰做>` 沒配 `--next`)接回去 ⇒ 必須紅。
|
||||
if blk:
|
||||
bad = blk.rstrip() + " --assign claude-code"
|
||||
rb = subprocess.run(["bash", "-c", bad], capture_output=True, text=True, env=ENV)
|
||||
t(rb.returncode == 2,
|
||||
"⑲ 對照組:閘原本那組旗標(--assign 沒配 --next)確實跑不完",
|
||||
"離開碼 %s(要是 2;不是 2 就表示 ⑱ 沒有鑑別力)" % rb.returncode)
|
||||
else:
|
||||
t(False, "⑲ 對照組:抽不出指令區塊", "")
|
||||
|
||||
# ⑳ 草稿模板與必填檢查**同源**:模板裡一定有 REQUIRED_SECTIONS 的每一段。
|
||||
# 寫死一份模板的話,有人往 REQUIRED_SECTIONS 加一段就會生出
|
||||
# 「照著貼卻過不了自己那道閘」的票——正是這張票要修的形狀。
|
||||
req = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
"import runpy,sys;m=runpy.run_path(sys.argv[1]);"
|
||||
"print('\\x00'.join(m['REQUIRED_SECTIONS']));"
|
||||
"print(m['body_template']())", ticket],
|
||||
capture_output=True, text=True)
|
||||
head, _, tmpl = req.stdout.partition("\n")
|
||||
secs = [x for x in head.split("\x00") if x]
|
||||
t(secs and all(x in tmpl for x in secs) and all(x in msg for x in secs),
|
||||
"⑳ 草稿模板由 REQUIRED_SECTIONS 現生,訊息裡也齊",
|
||||
"必填段:%s" % secs)
|
||||
|
||||
# ㉑ 正本叫不動時(雲端裝壞、路徑不對):**閘照樣擋**,而且**不准就地補一份手抄用法**
|
||||
# ——那份副本正是這張票拔掉的東西。fail-open 的是「訊息內容」,不是「擋不擋」。
|
||||
fb = subprocess.run(["bash", os.environ["GUARD"]],
|
||||
input=io.open(os.environ["PAYLOAD"], encoding="utf-8").read(),
|
||||
capture_output=True, text=True,
|
||||
env=dict(os.environ, CLAUDE_PLUGIN_ROOT="/tmp/isep-no-such-root"))
|
||||
fbmsg = fb.stderr
|
||||
t(fb.returncode == 2 and "usage subtask" in fbmsg
|
||||
and "--title" not in fbmsg and "[--journey" not in fbmsg,
|
||||
"㉑ 正本叫不動時仍然擋,且只指路、不補一份手抄用法",
|
||||
"離開碼 %s|訊息裡有沒有手抄用法:%s"
|
||||
% (fb.returncode, "有(違規)" if "[--journey" in fbmsg else "沒有"))
|
||||
|
||||
npass = 0
|
||||
for ok, name, detail in res:
|
||||
print(" %s %s%s" % ("✅" if ok else "❌", name,
|
||||
(" ※ " + detail) if (detail and not ok) else ""))
|
||||
npass += 1 if ok else 0
|
||||
io.open(os.environ["RESFILE"], "w", encoding="utf-8").write(
|
||||
"%d %d" % (npass, len(res) - npass))
|
||||
PY
|
||||
read -r P2 F2 < "$RESFILE"
|
||||
PASS=$((PASS + P2)); FAIL=$((FAIL + F2))
|
||||
rm -f "$MSGFILE" "$RESFILE" "$PAYFILE" "/tmp/isep-subtask-$SID2.md" \
|
||||
"/tmp/.comment-task-guard-warned-$SID2"
|
||||
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
[ "$FAIL" = 0 ]
|
||||
|
||||
@@ -22,7 +22,7 @@ run(){ # 跑 list(或任何子命令),輸出留在 $OUT,離開碼留在
|
||||
# 🔴 ISEP_COUNTDOWN_STATE_DIR 也要指到暫存目錄:主線(hooks/lib/mainline.py)住在那裡,
|
||||
# 不隔離的話這支測試的結果會取決於「這台機器現在標了哪條主線」——那就不是測試了。
|
||||
OUT=$(ISEP_DEBT_FIXTURE="$FX" ISEP_DEBT_STATE_DIR="$T/state" ISEP_DEBT_NOW="$NOW" \
|
||||
ISEP_COUNTDOWN_STATE_DIR="$ML_DIR" python3 "$W" "$@" 2>&1); RC=$?
|
||||
ISEP_COUNTDOWN_STATE_DIR="$ML_DIR" ISEP_MAINLINE_REPO_FILE=none python3 "$W" "$@" 2>&1); RC=$?
|
||||
}
|
||||
|
||||
ML_DIR="$T/mainline"; mkdir -p "$ML_DIR"
|
||||
|
||||
@@ -34,6 +34,43 @@ run 0 'python3 -c "requests.post(url, json={\"body\": \"quoting: git push origin
|
||||
run 0 'grep -n "gh api" hooks/github-contact-guard.sh'
|
||||
run 0 'grep -rn "git push" installer/scripts/line-source-repo.mjs'
|
||||
|
||||
echo "── 判「push 到哪」要看這條指令實際會推的那個 repo,不是 hook 站的 cwd(inkstone/ISEP#109 → comment 6629,2026-09-07 實撞)──"
|
||||
# 為什麼這群非有不可:雲端薄殼的 cwd 是 GitHub 那份(origin=github.com),而總管在
|
||||
# `InkStoneCo/`(origin=Gitea)推分支。舊版拿 **hook 收到的 cwd** 去解 `origin`,
|
||||
# 於是 `git -C <InkStoneCo> push origin x` 被判成「remote origin 指向 GitHub」擋下
|
||||
# ——連 `-C` 都沒看。arcrun-hand 同日在 Arcrun#176 comment 6614 撞到同一支。
|
||||
# 判準仍是唯一識別碼(remote 解出來的 URL 主機),不是名字;**不是「有 -C 就放行」**——
|
||||
# 下面「該擋」那組就是 -C/cd 指到 GitHub 那份時照擋的證據。
|
||||
W=$(mktemp -d "${TMPDIR:-/tmp}/isep-ghc.XXXXXX"); trap 'rm -rf "$W"' EXIT
|
||||
W=$(cd "$W" && pwd -P)
|
||||
mk(){ git init -q -b main "$1" && git -C "$1" remote add origin "$2"; }
|
||||
mk "$W/shell" https://github.com/example/shell.git # 薄殼:origin=GitHub
|
||||
mk "$W/body" https://git.example.invalid/inkstone/body.git # 真身:origin=Gitea 的形狀(主機不是 github.com)
|
||||
mkdir -p "$W/body/sub"
|
||||
runc(){ # $1=want $2=cwd $3=cmd —— 跟 run 一樣,只是 payload 帶 cwd(hook 真的會收到它)
|
||||
LAST_ERR=$(printf '%s' "{\"tool_name\":\"Bash\",\"cwd\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2"),\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$3")}}" \
|
||||
| bash "$H" 2>&1 >/dev/null)
|
||||
got=$?
|
||||
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s cwd=%s %.60s\n' "$1" "$got" "${2#$W/}" "$3"
|
||||
}
|
||||
has(){ if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi; printf '%s\n' "$2"; }
|
||||
|
||||
echo " · 該擋:這條指令實際推的那個 repo 的 origin 是 github.com"
|
||||
runc 2 "$W/shell" 'git push origin x'
|
||||
runc 2 "$W/body" "git -C $W/shell push origin x"
|
||||
has "$W/shell" " 訊息說得出是哪個目錄的 remote 指向 GitHub(人才知道自己站錯地方還是推錯地方)"
|
||||
runc 2 "$W/body" "cd $W/shell && git push origin x"
|
||||
runc 2 "$W/shell" "(cd $W/body && true); git push origin x"
|
||||
runc 2 "$W/shell" 'cd $SOMEWHERE && git push origin x'
|
||||
echo " · 不該擋:這條指令實際推的那個 repo 的 origin 不是 github.com(★=6629 那天被擋的形狀)"
|
||||
runc 0 "$W/shell" "git -C $W/body push origin x"
|
||||
runc 0 "$W/shell" "git -C $W/body push -u origin feat/x"
|
||||
runc 0 "$W/shell" "cd $W/body && git push origin x"
|
||||
runc 0 "$W/body/sub" 'git push origin x'
|
||||
runc 0 "$W/shell" "cd $W && git -C body push origin x"
|
||||
runc 0 "$W/body" "(cd $W/shell && true); git push origin x"
|
||||
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# 實測:scripts/isep-doctor 對**真的** Claude Code(這台的 CLI 與 Desktop 內建那一份)判得對不對(inkstone/ISEP#150)
|
||||
#
|
||||
# 不動 ~/.claude:把 inkstone marketplace 與 isep 安裝紀錄複製到 mktemp 的隔離 HOME,
|
||||
# 分別放「有 path」「沒有 path」兩份 settings.json,用真的可執行檔跑 doctor。
|
||||
# 會打網路:只有 Claude Code 自己可能匿名讀 git.uncle6.me(Gitea),不碰 GitHub、不寫任何遠端。
|
||||
#
|
||||
# 期望(09-13 在 CLI 2.1.220/Desktop 2.1.266 上實測過的行為):
|
||||
# 沒有 path ⇒ 兩個都 ✅,離開碼 0 (真陰性)
|
||||
# 有 path、Desktop 2.1.266 ⇒ ❌ 並點名 path (真陽性)
|
||||
# 有 path、CLI 2.1.220 ⇒ ✅,不因設定長相被判 ❌ (舊版 doctor 的假陽性)
|
||||
# 版本不是這兩個時,該版本那一格印 SKIP(行為可能不同,不假裝驗過)。
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
R="$HOME/.claude/plugins"
|
||||
[ -d "$R/cache/inkstone" ] && [ -d "$R/marketplaces/inkstone" ] || { echo "SKIP:這台沒有安裝 isep@inkstone,無從複製"; exit 0; }
|
||||
|
||||
CLI=$(command -v claude || true)
|
||||
DESK=$(ls -d "$HOME/Library/Application Support/Claude/claude-code/"*/claude.app/Contents/MacOS/claude 2>/dev/null \
|
||||
| python3 -c 'import sys,re;L=[l.strip() for l in sys.stdin if l.strip()];k=lambda p:tuple(int(x) for x in re.search(r"/claude-code/([0-9.]+)/",p).group(1).split(".") if x);print(sorted(L,key=k)[-1] if L else "")')
|
||||
BINS=""; [ -n "$CLI" ] && BINS="CLI=$CLI"; [ -n "$DESK" ] && BINS="${BINS:+$BINS:}Desktop=$DESK"
|
||||
[ -n "$BINS" ] || { echo "SKIP:找不到任何 Claude Code"; exit 0; }
|
||||
vcli=$([ -n "$CLI" ] && "$CLI" --version | awk '{print $1}'); vdesk=$([ -n "$DESK" ] && "$DESK" --version | awk '{print $1}')
|
||||
echo "CLI=${CLI:-無} ${vcli:-}|Desktop=${DESK:-無} ${vdesk:-}"
|
||||
|
||||
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||
C="$T/home/.claude"; mkdir -p "$C/plugins/cache" "$C/plugins/marketplaces"
|
||||
cp -R "$R/cache/inkstone" "$C/plugins/cache/" && cp -R "$R/marketplaces/inkstone" "$C/plugins/marketplaces/"
|
||||
python3 - "$R" "$C" <<'EOF'
|
||||
import json, sys
|
||||
R, C = sys.argv[1], sys.argv[2]
|
||||
km = json.load(open(R + "/known_marketplaces.json"))
|
||||
json.dump({"inkstone": json.loads(json.dumps(km["inkstone"]).replace(R, C + "/plugins"))}, open(C + "/plugins/known_marketplaces.json", "w"))
|
||||
ip = json.load(open(R + "/installed_plugins.json"))
|
||||
ip["plugins"] = {"isep@inkstone": ip["plugins"]["isep@inkstone"]}
|
||||
json.dump(json.loads(json.dumps(ip).replace(R, C + "/plugins")), open(C + "/plugins/installed_plugins.json", "w"))
|
||||
src = {"source": "git", "url": "https://git.uncle6.me/inkstone/ISEP.git"}
|
||||
json.dump({"enabledPlugins": {"isep@inkstone": True}, "extraKnownMarketplaces": {"inkstone": {"source": src}}}, open(C + "/settings.nopath.json", "w"))
|
||||
src["path"] = "/Users/youlinhsieh/Documents/tech_projects/ISEP"
|
||||
json.dump({"enabledPlugins": {"isep@inkstone": True}, "extraKnownMarketplaces": {"inkstone": {"source": src}}}, open(C + "/settings.path.json", "w"))
|
||||
EOF
|
||||
cp -R "$C" "$T/pristine"
|
||||
|
||||
pass=0; fail=0; skip=0
|
||||
doctor() { # settings 變體
|
||||
rm -rf "$C" && cp -R "$T/pristine" "$C" && cp "$C/settings.$1.json" "$C/settings.json"
|
||||
OUT=$(cd "$T" && env -u CLAUDE_CODE_CHILD_SESSION HOME="$T/home" ISEP_DOCTOR_CLAUDE_BINS="$BINS" python3 "$HERE/isep-doctor" 2>&1); CODE=$?
|
||||
echo "── settings=$1(exit=$CODE)"; printf '%s\n' "$OUT" | sed 's/^/ /'
|
||||
}
|
||||
has() { printf '%s' "$OUT" | grep -q -- "$1"; }
|
||||
judge() { if eval "$2"; then pass=$((pass+1)); echo "ok $1"; else fail=$((fail+1)); echo "FAIL $1"; fi; }
|
||||
|
||||
doctor nopath
|
||||
judge "真陰性:沒有 path → 離開碼 0、沒有 ❌" '[ "$CODE" = 0 ] && ! has "❌"'
|
||||
|
||||
doctor path
|
||||
if [ "$vdesk" = 2.1.266 ]; then
|
||||
judge "真陽性:Desktop 2.1.266 有 path → ❌ 並點名 path、離開碼 1" '[ "$CODE" = 1 ] && has "❌ Desktop 2.1.266" && has "多了 path"'
|
||||
else skip=$((skip+1)); echo "SKIP 真陽性:Desktop 是 ${vdesk:-無},不是 2.1.266"; fi
|
||||
if [ "$vcli" = 2.1.220 ]; then
|
||||
judge "假陽性:CLI 2.1.220 有 path 但實際載入 → ✅" 'has "✅ CLI 2.1.220"'
|
||||
else skip=$((skip+1)); echo "SKIP 假陽性:CLI 是 ${vcli:-無},不是 2.1.220"; fi
|
||||
|
||||
echo "pass=$pass fail=$fail skip=$skip"; [ "$fail" = 0 ]
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# 迴歸測試:scripts/isep-doctor(inkstone/ISEP#150)——全離線
|
||||
# 用假的 claude 可執行檔(shell 腳本)代替真的 CLI/Desktop,走的是 doctor 真正的 subprocess 那條路。
|
||||
# 真的 Claude Code 版本的實測在 scripts/test-isep-doctor-live.sh。
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||
pass=0; fail=0
|
||||
URL=https://git.uncle6.me/inkstone/ISEP.git
|
||||
|
||||
mkfake() { # 路徑 版本 plugin-list 輸出(原樣印出)
|
||||
mkdir -p "$(dirname "$1")"
|
||||
printf '%s' "$3" > "$1.out"
|
||||
cat > "$1" <<EOF
|
||||
#!/bin/sh
|
||||
if [ "\$1" = "--version" ]; then echo "$2 (Claude Code)"; exit 0; fi
|
||||
cat "$1.out"
|
||||
EOF
|
||||
chmod +x "$1"
|
||||
}
|
||||
run() { OUT=$(ISEP_DOCTOR_CLAUDE_HOME="$T/home" python3 "$HERE/isep-doctor" 2>&1); CODE=$?; }
|
||||
ok() { pass=$((pass+1)); echo "ok $1"; }
|
||||
bad() { fail=$((fail+1)); echo "FAIL $1 (exit=$CODE)"; printf '%s\n' "$OUT" | sed 's/^/ /'; }
|
||||
expect() { # 名稱 離開碼 要有的樣式(| 分隔,可空) 不准有的樣式(可空)
|
||||
local p miss=""
|
||||
[ "$CODE" = "$2" ] || miss="exit"
|
||||
local IFS='|'
|
||||
for p in $3; do printf '%s' "$OUT" | grep -q -- "$p" || miss="$miss 缺[$p]"; done
|
||||
if [ -n "${4:-}" ] && printf '%s' "$OUT" | grep -q -- "$4"; then miss="$miss 多了[$4]"; fi
|
||||
[ -z "$miss" ] && ok "$1" || { bad "$1 →$miss"; }
|
||||
}
|
||||
|
||||
mkdir -p "$T/home/plugins"
|
||||
echo "{\"inkstone\":{\"source\":{\"source\":\"git\",\"url\":\"$URL\"}}}" > "$T/home/plugins/known_marketplaces.json"
|
||||
SET_PATH="{\"extraKnownMarketplaces\":{\"inkstone\":{\"source\":{\"source\":\"git\",\"url\":\"$URL\",\"path\":\"/Users/x/ISEP\"}}},\"model\":\"keep\"}"
|
||||
SET_CLEAN="{\"extraKnownMarketplaces\":{\"inkstone\":{\"source\":{\"source\":\"git\",\"url\":\"$URL\"}}}}"
|
||||
LOADED='[{"id":"isep@inkstone","version":"0.30.1","enabled":true}]'
|
||||
NOTFOUND='[{"id":"isep@inkstone","version":"0.30.1","enabled":true,"errors":["Marketplace inkstone not found"]}]'
|
||||
|
||||
# ① 假陽性(c6991):設定有 path,但這個 Claude Code 自己回報載入正常 → ✅、不准點名 path
|
||||
echo "$SET_PATH" > "$T/home/settings.json"
|
||||
mkfake "$T/b/cli" 2.1.220 "$LOADED"
|
||||
export ISEP_DOCTOR_CLAUDE_BINS="CLI=$T/b/cli"; run
|
||||
expect "① 有 path 但實際載入(2.1.220 形狀)→ ✅ 不點名 path" 0 "✅ CLI 2.1.220" "path"
|
||||
|
||||
# ② 真陽性(c6990):同一份設定,CLI 載得進、Desktop 載不進 → 1;兩邊各講各的;點名 path;給修法
|
||||
mkfake "$T/b/desk" 2.1.266 "$NOTFOUND"
|
||||
export ISEP_DOCTOR_CLAUDE_BINS="CLI=$T/b/cli:Desktop=$T/b/desk"; run
|
||||
expect "② CLI 載得進、Desktop 載不進 → 1,逐一講、點名 path" 1 "✅ CLI 2.1.220|❌ Desktop 2.1.266|Marketplace inkstone not found|多了 path|^ python3 "
|
||||
printf '%s' "$OUT" | grep -q "CLI 與 Desktop 都一樣" && bad "② 不准再說 CLI 與 Desktop 都一樣" || ok "② 沒有把整台機器講成同一個結論"
|
||||
|
||||
# ③ 真陰性:拿掉 path,兩邊都載入 → 0
|
||||
echo "$SET_CLEAN" > "$T/home/settings.json"
|
||||
mkfake "$T/b/desk" 2.1.266 "$LOADED"; run
|
||||
expect "③ 沒有 path、兩邊都載入 → 0" 0 "✅ CLI 2.1.220|✅ Desktop 2.1.266" "❌"
|
||||
|
||||
# ④ 別的原因失敗(設定沒有多餘欄位)→ 1,照實轉述,不編原因、不給 path 修法
|
||||
mkfake "$T/b/desk" 2.1.266 '[{"id":"isep@inkstone","version":"0.30.1","enabled":true,"errors":["Plugin directory missing"]}]'; run
|
||||
expect "④ 其他錯誤照實轉述、不編原因" 1 "❌ Desktop|Plugin directory missing" "修法(一行"
|
||||
|
||||
# ⑤ 沒安裝 → 1
|
||||
mkfake "$T/b/desk" 2.1.266 '[]'; run
|
||||
expect "⑤ 沒安裝 → 1" 1 "❌ Desktop 2.1.266:isep@inkstone 沒有安裝"
|
||||
|
||||
# ⑥ 查不了(輸出不是 JSON)+另一個正常 → 2,◐,不准講成 ❌ 或 ✅
|
||||
mkfake "$T/b/desk" 2.1.266 'Error: something broke'; run
|
||||
expect "⑥ 一個查不了、一個正常 → 2" 2 "◐ Desktop|讀不到不等於沒載入|✅ CLI" "❌"
|
||||
|
||||
# ⑦ 一個 Claude Code 都找不到 → 2
|
||||
export ISEP_DOCTOR_CLAUDE_BINS=""; run
|
||||
expect "⑦ 找不到任何 Claude Code → 2" 2 "◐ 查不了"
|
||||
|
||||
# ⑧ 修法那一行真的修得好(在假 HOME 上原封不動執行印給 leo 的那一行)
|
||||
echo "$SET_PATH" > "$T/home/settings.json"
|
||||
mkfake "$T/b/desk" 2.1.266 "$NOTFOUND"
|
||||
export ISEP_DOCTOR_CLAUDE_BINS="Desktop=$T/b/desk"; run
|
||||
fixline=$(printf '%s\n' "$OUT" | grep '^ python3 ' | sed 's/^ //')
|
||||
mkdir -p "$T/fake/.claude" && cp "$T/home/settings.json" "$T/fake/.claude/settings.json"
|
||||
HOME="$T/fake" bash -c "$fixline"
|
||||
if [ -n "$fixline" ] && python3 -c "import json,sys;d=json.load(open('$T/fake/.claude/settings.json'));s=d['extraKnownMarketplaces']['inkstone']['source'];sys.exit(0 if 'path' not in s and d['model']=='keep' and s['url'].endswith('ISEP.git') else 1)"; then
|
||||
ok "⑧ 修法一行拿掉 path、其他設定不動"
|
||||
else fail=$((fail+1)); echo "FAIL ⑧ 修法一行"; cat "$T/fake/.claude/settings.json"; fi
|
||||
# ⑧b 修完再問一次(Desktop 改回報正常)→ 0:出路走得通
|
||||
cp "$T/fake/.claude/settings.json" "$T/home/settings.json"; mkfake "$T/b/desk" 2.1.266 "$LOADED"; run
|
||||
expect "⑧b 照修法修完再跑 → ✅" 0 "✅ Desktop" "多了 path"
|
||||
|
||||
# ⑨ 自動尋找:Desktop 取數字上最新的那一份(2.1.99 < 2.1.266,字串排序會選錯)
|
||||
unset ISEP_DOCTOR_CLAUDE_BINS
|
||||
D="$T/h9/Library/Application Support/Claude/claude-code"
|
||||
mkfake "$D/2.1.99/claude.app/Contents/MacOS/claude" 2.1.99 "$NOTFOUND"
|
||||
mkfake "$D/2.1.266/claude.app/Contents/MacOS/claude" 2.1.266 "$LOADED"
|
||||
OUT=$(HOME="$T/h9" PATH="/usr/bin:/bin" ISEP_DOCTOR_CLAUDE_HOME="$T/home" /usr/bin/env python3 "$HERE/isep-doctor" 2>&1); CODE=$?
|
||||
expect "⑨ Desktop 取最新版本(數字排序)" 0 "✅ Desktop 2.1.266" "2.1.99"
|
||||
|
||||
echo "pass=$pass fail=$fail"; [ "$fail" = 0 ]
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
# isep-notify 的「Bot API 直送」測試(inkstone/ISEP#130)
|
||||
#
|
||||
# 它守什麼: 雲端唯一走得通的通知路。2026-09-07 從雲端實測:leo21c 的 notify_leo 回 404、
|
||||
# youlin 被 egress proxy 擋(403 policy denial)、api.telegram.org 通。
|
||||
# ⇒ 有 TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID 就先直送;沒有要講清楚缺哪兩個,不能靜默。
|
||||
#
|
||||
# 🔴 全程不打真 Telegram:Bot API 指到本機一個假伺服器(只回固定 JSON),
|
||||
# 實例那條用 ISEP_NOTIFY_OFFLINE=1 關掉、閘用 ISEP_NOTIFY_GUARD 指到真跡。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
N=scripts/isep-notify
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"; [ -n "${SRV:-}" ] && kill "$SRV" 2>/dev/null' EXIT
|
||||
export ISEP_NOTIFY_STATE_DIR="$TMP/state" ISEP_NOTIFY_GUARD="$PWD/hooks/prod-write-guard.sh"
|
||||
unset TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID
|
||||
PASS=0; FAIL=0
|
||||
ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
|
||||
bad(){ FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
|
||||
|
||||
# 假 Bot API:記下收到什麼,回 Telegram 的形狀
|
||||
cat > "$TMP/srv.py" <<'PY'
|
||||
import json, sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
LOG = sys.argv[2]
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length") or 0)).decode()
|
||||
open(LOG, "a").write(json.dumps({"path": self.path, "body": body, "ua": self.headers.get("User-Agent")}) + "\n")
|
||||
ok = "/botGOOD/" in self.path
|
||||
out = json.dumps({"ok": True, "result": {"message_id": 42}} if ok else {"ok": False, "description": "Unauthorized"}).encode()
|
||||
self.send_response(200 if ok else 401); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(out)
|
||||
def log_message(self, *a): pass
|
||||
HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()
|
||||
PY
|
||||
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])')
|
||||
python3 "$TMP/srv.py" "$PORT" "$TMP/log" & SRV=$!
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do curl -s -o /dev/null "http://127.0.0.1:$PORT/" && break; sleep 0.2; done
|
||||
export ISEP_NOTIFY_BOT_API="http://127.0.0.1:$PORT"
|
||||
|
||||
echo "── A. 沒有憑證:講清楚缺哪兩個,不靜默 ──"
|
||||
OUT=$(ISEP_NOTIFY_OFFLINE=1 python3 "$N" --text "測試" 2>&1); RC=$?
|
||||
printf '%s' "$OUT" | grep -q 'TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID' && ok "A1 沒憑證 ⇒ 報告點名缺 TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID(leo 看了知道要在 Cloud environment 加什麼)" || bad "A1 沒點名缺什麼:$OUT"
|
||||
[ "$RC" != 0 ] && printf '%s' "$OUT" | grep -q 'leo 的手機上沒有出現' && ok "A2 兩條路都沒通 ⇒ 離開碼非 0、明說沒送到" || bad "A2 rc=$RC:$OUT"
|
||||
[ ! -s "$TMP/log" ] && ok "A3 沒憑證就不打 Bot API" || bad "A3 沒憑證還打了:$(cat "$TMP/log")"
|
||||
|
||||
echo "── B. 有憑證:先直送,送到就結束 ──"
|
||||
: > "$TMP/log"
|
||||
OUT=$(TELEGRAM_BOT_TOKEN=GOOD TELEGRAM_CHAT_ID=777 python3 "$N" --text "雲端收工摘要" 2>&1); RC=$?
|
||||
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q 'Bot API 直送):送到了' && ok "B1 憑證在 ⇒ Bot API 直送,離開碼 0" || bad "B1 rc=$RC:$OUT"
|
||||
python3 - "$TMP/log" <<'PY' && ok "B2 打到 /botGOOD/sendMessage、chat_id=777、內文有署名、UA 帶了" || bad "B2 送出去的形狀不對:$(cat "$TMP/log")"
|
||||
import json, sys
|
||||
rows = [json.loads(l) for l in open(sys.argv[1])]
|
||||
assert len(rows) == 1, rows
|
||||
r = rows[0]; b = json.loads(r["body"])
|
||||
assert r["path"] == "/botGOOD/sendMessage", r["path"]
|
||||
assert b["chat_id"] == "777" and b["text"].startswith("[總管] 雲端收工摘要"), b
|
||||
assert "isep-notify" in (r["ua"] or ""), r["ua"]
|
||||
PY
|
||||
printf '%s' "$OUT" | grep -q 'notify_leo' && bad "B3 直送成功後不該再碰實例那條" || ok "B3 直送成功 ⇒ 不再打實例的 notify_leo"
|
||||
|
||||
echo "── C. 憑證錯:Bot API 回 ok=false ⇒ 不算送到,退到原本那條 ──"
|
||||
: > "$TMP/log"
|
||||
# 實例那條也指到假伺服器(它對非 /botGOOD/ 的路徑回 401)——兩條都不出這台機器
|
||||
OUT=$(TELEGRAM_BOT_TOKEN=BAD TELEGRAM_CHAT_ID=777 ISEP_NOTIFY_URL="http://127.0.0.1:$PORT/webhooks/named/leo/notify_leo/trigger" python3 "$N" --text "x" 2>&1); RC=$?
|
||||
printf '%s' "$OUT" | grep -q 'Bot API HTTP 401' && ok "C1 401 被講出來(不是「送出成功」)" || bad "C1 沒講 401:$OUT"
|
||||
[ "$RC" != 0 ] && printf '%s' "$OUT" | grep -q 'leo 的手機上沒有出現' && ok "C2 直送失敗+實例關著 ⇒ 仍然明說沒送到" || bad "C2 rc=$RC:$OUT"
|
||||
|
||||
echo "── D. 閘說會擋,直送仍走得通(它不碰實例)──"
|
||||
: > "$TMP/log"; mkdir -p "$TMP/oldguard"
|
||||
printf '#!/bin/sh\nexit 2\n' > "$TMP/oldguard/prod-write-guard.sh"; chmod +x "$TMP/oldguard/prod-write-guard.sh"
|
||||
OUT=$(ISEP_NOTIFY_GUARD="$TMP/oldguard/prod-write-guard.sh" TELEGRAM_BOT_TOKEN=GOOD TELEGRAM_CHAT_ID=1 python3 "$N" --text "y" 2>&1); RC=$?
|
||||
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '通知閘:\*\*block\*\*' && printf '%s' "$OUT" | grep -q '直送):送到了' \
|
||||
&& ok "D1 閘 block + 憑證在 ⇒ 直送照樣送到(閘擋的是寫實例,直送沒碰實例)" || bad "D1 rc=$RC:$OUT"
|
||||
|
||||
echo "── E. --dry-run 什麼都不送 ──"
|
||||
: > "$TMP/log"
|
||||
OUT=$(TELEGRAM_BOT_TOKEN=GOOD TELEGRAM_CHAT_ID=1 python3 "$N" --text "z" --dry-run 2>&1)
|
||||
[ ! -s "$TMP/log" ] && printf '%s' "$OUT" | grep -q 'dry-run' && ok "E1 --dry-run:Bot API 一次都沒被打" || bad "E1 dry-run 打了:$(cat "$TMP/log")"
|
||||
|
||||
echo; echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/bin/bash
|
||||
# make-cloud-env.sh 的測試(docs/TESTING.md A14)
|
||||
#
|
||||
# 它守什麼: 那支腳本是「雲端總管手上有哪些憑證」的唯一清單。清單漏一把,
|
||||
# 雲端會在**流程末端**才發現跑不了(inkstone/ISEP#115 內文:派了工人寫完
|
||||
# 清空腳本、寫完測試,到要真的跑的那一刻才知道 CF token 根本不在)。
|
||||
#
|
||||
# 兩段:
|
||||
# A 段 —— 用假的 .env 隔離跑,證明「產物長對、缺值不會假綠、什麼都不寫進 repo」。
|
||||
# 全程不碰真金鑰、不碰 ~/.claude/cloud-env。
|
||||
# B 段 —— 拿這台機器真正的 .env 對帳,證明清單上每一把**這台都拿得到值**,
|
||||
# 而且**沒有一把的值來自 polaris/mira/.env**(leo21c 現役正式環境)。
|
||||
# .env 不在(例如雲端)⇒ 印 ⏭️ SKIP,不算通過也不算失敗。
|
||||
#
|
||||
# 🔴 B8/B9 的判準都不是名字,是機器算得出來的事實——**換什麼名字都躲不掉**。
|
||||
# B8 看「這個值是從哪個檔案拿到的」(出自 polaris/mira/.env = leo21c 現役正式環境)。
|
||||
# B9 看「這個值是不是就是 leo21c 那把 token」(值相等就紅,改名叫什麼都一樣)。
|
||||
# 🔴 兩條是分工不是重複:`CLOUDFLARE_API_TOKEN_leo21c` 住在**頂層** .env,
|
||||
# 不在 polaris/mira/.env ⇒ **B8 抓不到它**,這正是 B9 存在的理由。
|
||||
# 關鍵字黑名單反過來只擋得住已經被列進去的那幾個名字,新名字一律漏掉
|
||||
# (leo 2026-08-17 已證偽文字層封路:當日 8 次誤攔、0 次正確攔截)。
|
||||
#
|
||||
# 🔴 全程只印變數**名字**與值的**長度**,一個字元的值都不印。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
S=scripts/make-cloud-env.sh
|
||||
PASS=0; FAIL=0; SKIP=0
|
||||
ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
|
||||
bad(){ FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
|
||||
skip(){ SKIP=$((SKIP+1)); printf ' ⏭️ SKIP %s\n' "$1"; }
|
||||
chk(){ [ "$1" = "$2" ] && ok "$3($2)" || bad "$3:want=$1 got=$2"; }
|
||||
|
||||
# 清單與搜尋路徑都從腳本本身讀出來——測試不另養一份,養了就會漂。
|
||||
# 註:macOS 內建的是 bash 3.2,沒有 mapfile/readarray,一律用 while read 收陣列。
|
||||
NEEDED=()
|
||||
while IFS= read -r l; do NEEDED+=("$l"); done < <(
|
||||
sed -n '/^NEEDED=(/,/^)/p' "$S" | sed -E '1d;$d; s/[[:space:]]*#.*$//; s/[[:space:]]//g' | grep -v '^$')
|
||||
[ "${#NEEDED[@]}" -gt 0 ] || { echo "🔴 從 $S 讀不出 NEEDED,測試本身壞了"; exit 1; }
|
||||
MIRA_ALLOWED=($(sed -n 's/^MIRA_ALLOWED=(\(.*\))/\1/p' "$S"))
|
||||
echo "清單(只有名字):${NEEDED[*]}"
|
||||
echo
|
||||
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
FAKE="$TMP/fakeroot"; OUTD="$TMP/out"; mkdir -p "$FAKE"
|
||||
|
||||
echo "── A. 隔離跑(假 .env,不碰真金鑰、不碰 ~/.claude/cloud-env)──"
|
||||
: > "$FAKE/.env"
|
||||
for n in "${NEEDED[@]}"; do echo "$n=FAKEVALUE-$n" >> "$FAKE/.env"; done
|
||||
|
||||
BEFORE=$(git status --porcelain)
|
||||
OUT_LINE=$(INKSTONE_ROOT="$FAKE" OUT_DIR="$OUTD" bash "$S" 2>&1)
|
||||
F=$(printf '%s\n' "$OUT_LINE" | sed -n 's/^✅ 產生完成://p')
|
||||
|
||||
[ -f "$F" ] && ok "A1 產物產出來了" || bad "A1 產物不存在(腳本輸出:$OUT_LINE)"
|
||||
chk 600 "$(stat -f '%OLp' "$F" 2>/dev/null || stat -c '%a' "$F" 2>/dev/null)" "A2 產物權限"
|
||||
|
||||
miss=0
|
||||
for n in "${NEEDED[@]}"; do grep -qx "$n=FAKEVALUE-$n" "$F" || { miss=$((miss+1)); echo " ↑ 沒出現:$n"; }; done
|
||||
chk 0 "$miss" "A3 每個名字都帶著值出現在 ① 區塊"
|
||||
|
||||
printf '%s\n' "$OUT_LINE" | grep -q "找不到值的 0 個" \
|
||||
&& ok "A4 收尾那行說「找不到值的 0 個」" || bad "A4 收尾計數不對:$OUT_LINE"
|
||||
|
||||
# 版本(leo 2026-09-18):產物檔頭帶現行版本號;清單改了沒升版要拒絕
|
||||
grep -q "(v[0-9]" "${F%.txt}-README.txt" && ok "V1 README 寫著版本號" || bad "V1 README 沒有版本號"
|
||||
# 2026-09-18 實撞:兩段同檔、分隔線被貼進 Setup script 欄 ⇒ 雲端開機 exit 127。分檔後兩件事要守住:
|
||||
bad_lines=$(grep -cvE '^[A-Z][A-Z0-9_]*=' "$F")
|
||||
chk 0 "$bad_lines" "V3 變數檔每一行都是 KEY=VALUE(沒有分隔線、說明文字)"
|
||||
cmp -s "${F%.txt}-setup-script.sh" "$(cd "$(dirname "$S")/.." && pwd)/docs/cloud-setup-script.sh" \
|
||||
&& ok "V4 開機腳本檔與 docs/cloud-setup-script.sh 逐位元組相同" || bad "V4 開機腳本檔被加料了"
|
||||
VT="$(cd "$(dirname "$S")/.." && pwd)/docs/cloud-env-versions.tsv"; VB="$VT.bak-test"
|
||||
cp "$VT" "$VB"; printf 'v999\t2099-01-01\tNOPE_ONLY\t測試用\n' >> "$VT"
|
||||
INKSTONE_ROOT="$FAKE" OUT_DIR="$OUTD" bash "$S" >/dev/null 2>&1; rc=$?
|
||||
mv "$VB" "$VT"
|
||||
chk 2 "$rc" "V2 清單與最後一版 key 不一致時拒絕產生(離開碼 2)"
|
||||
|
||||
# 缺值時不准假綠:拿掉第一個,它要變成 <🔴 …> 且計數變 1
|
||||
grep -v "^${NEEDED[0]}=" "$FAKE/.env" > "$FAKE/.env.tmp" && mv "$FAKE/.env.tmp" "$FAKE/.env"
|
||||
OUT2=$(INKSTONE_ROOT="$FAKE" OUT_DIR="$OUTD" bash "$S" 2>&1)
|
||||
F2=$(printf '%s\n' "$OUT2" | sed -n 's/^✅ 產生完成://p')
|
||||
grep -q "^${NEEDED[0]}=<🔴" "$F2" && ok "A5a 缺值那行標成 <🔴 …>" || bad "A5a 缺值沒被標出來"
|
||||
printf '%s\n' "$OUT2" | grep -q "找不到值的 1 個 ${NEEDED[0]}" \
|
||||
&& ok "A5b 收尾點名缺的是誰" || bad "A5b 收尾沒點名:$OUT2"
|
||||
|
||||
if [ "$BEFORE" = "$(git status --porcelain)" ]; then ok "A6 跑完沒有任何檔案被寫進 repo"
|
||||
else bad "A6 repo 裡多了東西:$(diff <(printf '%s\n' "$BEFORE") <(git status --porcelain) | tr '\n' ' ')"; fi
|
||||
|
||||
echo
|
||||
echo "── B. 拿這台機器真正的 .env 對帳(只印長度,不印值)──"
|
||||
ENVF=()
|
||||
while IFS= read -r l; do ENVF+=("$l"); done < <(
|
||||
sed -n '/^ENV_FILES=(/,/^)/p' "$S" | sed -E '1d;$d; s/^[[:space:]]*"//; s/"[[:space:]]*$//' | grep -v '^$')
|
||||
BASE="${INKSTONE_ROOT:-$HOME/Documents/tech_projects/InkStoneCo}"
|
||||
real_src(){ # $1=名字 → 印出「值的長度 來源檔」;找不到回非 0
|
||||
local name="$1" f p v
|
||||
for p in "${ENVF[@]}"; do
|
||||
f="${p/\$BASE/$BASE}"; f="${f/\$\{BASE\}/$BASE}"
|
||||
[ -f "$f" ] || continue
|
||||
v=$(grep -E "^[[:space:]]*(export[[:space:]]+)?${name}=" "$f" 2>/dev/null | tail -1 \
|
||||
| sed -E "s/^[[:space:]]*(export[[:space:]]+)?${name}=//" | sed -E 's/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/')
|
||||
[ -n "$v" ] && { printf '%s %s' "${#v}" "$f"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# 只取值、不取來源,給 B9 比對用。值只活在變數裡,從不印出、也不寫檔。
|
||||
_val(){ local r f; r=$(real_src "$1") || return 1; f="${r#* }"
|
||||
grep -E "^[[:space:]]*(export[[:space:]]+)?$1=" "$f" | tail -1 \
|
||||
| sed -E "s/^[[:space:]]*(export[[:space:]]+)?$1=//" | sed -E 's/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/'; }
|
||||
|
||||
found=0
|
||||
for p in "${ENVF[@]}"; do f="${p/\$BASE/$BASE}"; [ -f "$f" ] && found=1; done
|
||||
|
||||
if [ "$found" = 0 ]; then
|
||||
skip "B7 這台機器一個 .env 都沒有(雲端就是這個狀態),對帳跳過"
|
||||
skip "B8 同上"
|
||||
else
|
||||
gone=0; prod=0
|
||||
for n in "${NEEDED[@]}"; do
|
||||
if r=$(real_src "$n"); then
|
||||
len="${r%% *}"; src="${r#* }"
|
||||
printf ' %-38s → 找得到(長度 %s)來自 %s\n' "$n" "$len" "${src#$BASE/}"
|
||||
case "$src" in */polaris/mira/.env) if printf '%s\n' "${MIRA_ALLOWED[@]}" | grep -qx "$n"; then echo " 🔓 ↑ 點名放行(MIRA_ALLOWED:通知 bot,不碰 CF)"; else prod=$((prod+1)); echo " 🔴 ↑ 這是 leo21c 現役正式環境那份 .env"; fi;; esac
|
||||
else
|
||||
printf ' %-38s → 🔴 這台拿不到\n' "$n"; gone=$((gone+1))
|
||||
fi
|
||||
done
|
||||
chk 0 "$gone" "B7 清單上每一把這台都拿得到值"
|
||||
chk 0 "$prod" "B8 沒有一把的值來自 polaris/mira/.env(leo21c 正式環境)"
|
||||
|
||||
# B9 —— leo 2026-09-01 的紅線:「CLOUDFLARE_API_TOKEN_leo21c 不准加」。
|
||||
# 🔴 判準是**值**不是名字:拿 leo21c 那把的值,比對清單上每一把解出來的值。
|
||||
# 有人把它改名叫 CF_TOKEN_BACKUP 照樣會紅,而黑名單會放它過去。
|
||||
# B8 抓不到這一條(那把住在頂層 .env,不在 polaris/mira/.env)。
|
||||
if leoval=$(_val CLOUDFLARE_API_TOKEN_leo21c); then
|
||||
hit=0
|
||||
for n in "${NEEDED[@]}"; do
|
||||
v=$(_val "$n") || continue
|
||||
[ "$v" = "$leoval" ] && { hit=$((hit+1)); echo " 🔴 $n 的值就是 leo21c 那把"; }
|
||||
done
|
||||
chk 0 "$hit" "B9 清單上沒有一把的值等於 CLOUDFLARE_API_TOKEN_leo21c"
|
||||
else
|
||||
skip "B9 這台找不到 CLOUDFLARE_API_TOKEN_leo21c,沒有比對基準"
|
||||
fi
|
||||
|
||||
# B8 全綠有兩種可能:真的沒有正式憑證,或**偵測根本不會亮**。分得開才算驗過。
|
||||
# 拿一個已知住在 polaris/mira/.env 的名字餵它,看它認不認得出來。
|
||||
if r=$(real_src CLOUDFLARE_ACCOUNT_ID); then
|
||||
case "${r#* }" in
|
||||
*/polaris/mira/.env) ok "B8x 反例自驗:把 leo21c 那份 .env 的變數餵進去,偵測認得出來" ;;
|
||||
*) skip "B8x 反例自驗:CLOUDFLARE_ACCOUNT_ID 這次解到 ${r#* },不是 mira 那份,這台沒得驗" ;;
|
||||
esac
|
||||
else
|
||||
skip "B8x 反例自驗:這台找不到 CLOUDFLARE_ACCOUNT_ID,沒有現成的反例可餵"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過,跳過 $SKIP 條"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/release-check 的迴歸測試(inkstone/ISEP#67)
|
||||
#
|
||||
# 兩個方向都要有證據:
|
||||
# A 群「該擋」(exit 2)——現行版本缺 tag 或缺 release,且**說得出缺哪一站+怎麼補**
|
||||
# (這就是工單驗收 4:「故意漏一步 → 管線要擋下來並說出缺哪一站」)
|
||||
# B 群「該過」(exit 0)——現行版本 tag+release 都在;更舊版本缺件只黃字提醒不擋;
|
||||
# 指定版本時看的是那一版;裁定不補的版本不再黃字
|
||||
#
|
||||
# 🔴 全程離線(RELEASE_CHECK_FIXTURE),不打 Gitea。fixture 直接給收貨端與送貨端的事實。
|
||||
set -u
|
||||
TOOL="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-check}"
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
PASS=0; FAIL=0; N=0
|
||||
|
||||
mkfx() { # $1=檔名 之後 heredoc
|
||||
cat > "$TMP/$1"
|
||||
}
|
||||
|
||||
# t <期望exit> <說明> <必須出現的字串> <fixture檔> [額外參數…]
|
||||
t() {
|
||||
want="$1"; desc="$2"; must="$3"; fx="$4"; shift 4
|
||||
N=$((N+1))
|
||||
out=$(RELEASE_CHECK_FIXTURE="$TMP/$fx" python3 "$TOOL" "$@" 2>&1); rc=$?
|
||||
ok=1
|
||||
[ "$rc" -eq "$want" ] || ok=0
|
||||
if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi
|
||||
if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
|
||||
"${must:+ 且輸出含「$must」}" "$rc"
|
||||
printf '%s\n' "$out" | sed -n '1,16p' | sed 's/^/ /'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
# not <說明> <不該出現的字串> <fixture檔> [額外參數…]
|
||||
not() {
|
||||
desc="$1"; bad="$2"; fx="$3"; shift 3
|
||||
N=$((N+1))
|
||||
out=$(RELEASE_CHECK_FIXTURE="$TMP/$fx" python3 "$TOOL" "$@" 2>&1)
|
||||
if printf '%s' "$out" | grep -qF "$bad"; then
|
||||
printf ' ❌ %s —— 不該出現卻出現了:%s\n' "$desc" "$bad"; FAIL=$((FAIL+1))
|
||||
else printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)); fi
|
||||
}
|
||||
|
||||
echo "── A 群:該擋(現行版本送貨端斷了,要說出缺哪一站)──"
|
||||
|
||||
# A1:這就是 2026-09-07 的真實狀態——v0.24.0 有 tag、沒 release
|
||||
mkfx a1.json <<'JSON'
|
||||
{"current":"0.24.0",
|
||||
"dingban":["0.24.0","0.23.0","0.22.0"],
|
||||
"tags":["v0.24.0","v0.23.0","v0.22.0"],
|
||||
"releases":["v0.22.0"],
|
||||
"loaded":"0.24.0"}
|
||||
JSON
|
||||
t 2 "現行 v0.24.0 有 tag 沒 release → 擋,且指名缺 release 站" "缺這一站:release(Gitea)" a1.json
|
||||
t 2 "缺 release 時給得出補法,而且是正本的絕對路徑(貼了就能跑)" "$(dirname "$TOOL")/release-ship v0.24.0" a1.json
|
||||
|
||||
# A2:連 tag 都沒有(定版了、tag 沒推)
|
||||
mkfx a2.json <<'JSON'
|
||||
{"current":"0.20.0",
|
||||
"dingban":["0.20.0","0.18.0"],
|
||||
"tags":["v0.18.0"],
|
||||
"releases":["v0.18.0"],
|
||||
"loaded":"0.20.0"}
|
||||
JSON
|
||||
t 2 "現行版本連 tag 都沒有 → 擋,且指名缺 tag 站" "缺這一站:tag(Gitea)" a2.json
|
||||
t 2 "缺 tag 時給得出補法(push tag)" "git push origin v0.20.0" a2.json
|
||||
|
||||
# A3:指定版本 → 判的是那一版
|
||||
mkfx a3.json <<'JSON'
|
||||
{"current":"0.24.0",
|
||||
"dingban":["0.24.0","0.23.0"],
|
||||
"tags":["v0.24.0","v0.23.0"],
|
||||
"releases":["v0.24.0"],
|
||||
"loaded":"0.24.0"}
|
||||
JSON
|
||||
t 2 "指定 v0.23.0 而它沒 release → 擋(就算工作樹宣稱的 v0.24.0 是好的)" "現行版本 v0.23.0 的送貨端斷了" a3.json v0.23.0
|
||||
t 0 "同一份事實不指定版本 → 看 v0.24.0,放行" "送貨端完成" a3.json
|
||||
|
||||
echo "── B 群:該過(現行版本 tag+release 都在)──"
|
||||
|
||||
# B1:全部送到
|
||||
mkfx b1.json <<'JSON'
|
||||
{"current":"0.18.0",
|
||||
"dingban":["0.18.0","0.9.0"],
|
||||
"tags":["v0.18.0","v0.9.0"],
|
||||
"releases":["v0.18.0","v0.9.0"],
|
||||
"loaded":"0.18.0"}
|
||||
JSON
|
||||
t 0 "現行版本 tag+release 都在 → 放行" "送貨端完成" b1.json
|
||||
|
||||
# B2:現行版本送到了,但更舊的 定版 版本缺件 → 仍放行,只黃字
|
||||
mkfx b2.json <<'JSON'
|
||||
{"current":"0.24.0",
|
||||
"dingban":["0.24.0","0.23.0","0.22.0"],
|
||||
"tags":["v0.24.0","v0.22.0"],
|
||||
"releases":["v0.24.0","v0.22.0"],
|
||||
"loaded":"0.24.0"}
|
||||
JSON
|
||||
t 0 "現行送到、舊版缺件 → 放行(不 over-block)" "送貨端完成" b2.json
|
||||
t 0 "舊版缺件會被黃字提醒出來" "v0.23.0 缺:tag(Gitea)+release(Gitea)" b2.json
|
||||
|
||||
# B3:載入端落後(送貨端完成,但這個 session 掛的是舊版)→ 仍 exit 0,但要報出來
|
||||
mkfx b3.json <<'JSON'
|
||||
{"current":"0.22.0",
|
||||
"dingban":["0.22.0","0.18.0"],
|
||||
"tags":["v0.22.0","v0.18.0"],
|
||||
"releases":["v0.22.0","v0.18.0"],
|
||||
"loaded":"0.21.0"}
|
||||
JSON
|
||||
t 0 "載入端落後 → 送貨端仍算完成(exit 0)" "送貨端完成" b3.json
|
||||
t 0 "載入端落後會被明確報出+給 update/重啟的修法" "載入端落後" b3.json
|
||||
|
||||
# B4:裁定不補的三版(總管 09-07)→ 不再黃字,表上標「裁定不補」
|
||||
mkfx b4.json <<'JSON'
|
||||
{"current":"0.24.0",
|
||||
"dingban":["0.24.0","0.22.0","0.21.0","0.20.0","0.19.0","0.18.0"],
|
||||
"tags":["v0.24.0","v0.22.0","v0.18.0"],
|
||||
"releases":["v0.24.0","v0.22.0","v0.18.0"],
|
||||
"loaded":"0.24.0"}
|
||||
JSON
|
||||
t 0 "v0.19/0.20/0.21 缺 tag+release 但已裁定不補 → 表上標出裁決出處" "裁定不補(inkstone/ISEP#67 → 6574)" b4.json
|
||||
not "裁定不補的版本不再黃字念(天天念會被學會忽略)" "更舊的定版版本也缺件" b4.json
|
||||
t 2 "但指定 v0.21.0 來問,它就是現行、照擋(裁決只免黃字,不改事實)" "現行版本 v0.21.0 的送貨端斷了" b4.json v0.21.0
|
||||
|
||||
echo ""
|
||||
echo "──────────────────────────────"
|
||||
printf '共 %d 項:✅ %d / ❌ %d\n' "$N" "$PASS" "$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/release-ship 的迴歸測試(inkstone/ISEP#67)
|
||||
#
|
||||
# 兩個方向都要有證據:
|
||||
# A 群「該擋」(exit 2)——前置條件沒滿足就不給建 release,且說得出是哪一個前置
|
||||
# ① 那個 tag 的樹裡 plugin.json 跟 tag 號碼對不上 ② Gitea 上沒那個 tag ③ 版本寫法不對
|
||||
# B 群「該放行」(exit 0)——前置都在就建;release 已存在則冪等不重複建;--dry-run 不 POST;
|
||||
# 🔴 **不是最新的那個 tag 也建得了**(總管 09-07 在 v0.23.0 被擋的那個情境)
|
||||
#
|
||||
# 🔴 全程離線(RELEASE_SHIP_FIXTURE + RELEASE_SHIP_SINK),不打 Gitea、不建任何真 release。
|
||||
# fixture 的形狀見 hooks/lib/release_chain.py `Chain` 的註解。
|
||||
set -u
|
||||
TOOL="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-ship}"
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
PASS=0; FAIL=0; N=0
|
||||
|
||||
# 🔴 工作樹故意擺一個跟任何 tag 都對不上的版本:前置①要是偷看工作樹,B 群會整組紅
|
||||
WT="$TMP/worktree"; mkdir -p "$WT/.claude-plugin"
|
||||
printf '{"name":"isep","version":"9.9.9"}\n' > "$WT/.claude-plugin/plugin.json"
|
||||
export CLAUDE_PROJECT_DIR="$WT"
|
||||
|
||||
# t <期望exit> <說明> <必須出現的字串> <fixture檔> [額外參數…]
|
||||
t() {
|
||||
want="$1"; desc="$2"; must="$3"; fx="$4"; shift 4
|
||||
N=$((N+1))
|
||||
rm -f "$TMP/sink.json" "$TMP/sink.json.checkfx.json"
|
||||
out=$(RELEASE_SHIP_FIXTURE="$TMP/$fx" RELEASE_SHIP_SINK="$TMP/sink.json" \
|
||||
python3 "$TOOL" "$@" 2>&1); rc=$?
|
||||
ok=1
|
||||
[ "$rc" -eq "$want" ] || ok=0
|
||||
if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi
|
||||
if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
|
||||
"${must:+ 且輸出含「$must」}" "$rc"
|
||||
printf '%s\n' "$out" | sed -n '1,14p' | sed 's/^/ /'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
# not <說明> <不該出現的字串> <fixture檔> [額外參數…]
|
||||
not() {
|
||||
desc="$1"; bad="$2"; fx="$3"; shift 3
|
||||
N=$((N+1))
|
||||
rm -f "$TMP/sink.json" "$TMP/sink.json.checkfx.json"
|
||||
out=$(RELEASE_SHIP_FIXTURE="$TMP/$fx" RELEASE_SHIP_SINK="$TMP/sink.json" \
|
||||
python3 "$TOOL" "$@" 2>&1)
|
||||
if printf '%s' "$out" | grep -qF "$bad"; then
|
||||
printf ' ❌ %s —— 不該出現卻出現了:%s\n' "$desc" "$bad"; FAIL=$((FAIL+1))
|
||||
else printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)); fi
|
||||
}
|
||||
|
||||
echo "── A 群:該擋(前置條件沒滿足)──"
|
||||
|
||||
# A1:tag 的樹裡 plugin.json 跟 tag 號碼對不上(這種 tag 本來就不該存在)
|
||||
cat > "$TMP/a1.json" <<'JSON'
|
||||
{"tags":["v0.22.0"],"manifest":{"v0.22.0":"0.21.0"},"releases":[]}
|
||||
JSON
|
||||
t 2 "tag 的樹裡 plugin.json 是 0.21.0、tag 是 v0.22.0 → 擋,指名前置①" "前置①" a1.json v0.22.0
|
||||
t 2 "前置①被擋時講得出修法(刪 tag、重新定版)" "重新定版" a1.json v0.22.0
|
||||
|
||||
# A2:Gitea 上沒有這個 tag
|
||||
cat > "$TMP/a2.json" <<'JSON'
|
||||
{"tags":["v0.18.0"],"manifest":{"v0.18.0":"0.18.0"},"releases":["v0.18.0"]}
|
||||
JSON
|
||||
t 2 "Gitea 沒有該 tag → 擋,指名前置②+叫人先推 tag" "git push origin v0.22.0" a2.json v0.22.0
|
||||
|
||||
# A3:版本寫法不對
|
||||
cat > "$TMP/a3.json" <<'JSON'
|
||||
{"tags":[],"releases":[]}
|
||||
JSON
|
||||
t 2 "版本寫法不對 → 擋" "版本寫法" a3.json "0.22"
|
||||
|
||||
echo "── B 群:該放行 ──"
|
||||
|
||||
# B1:前置都在、release 還沒建 → 建(fixture sink),然後覆核通過
|
||||
cat > "$TMP/b1.json" <<'JSON'
|
||||
{"current":"0.22.0","dingban":["0.22.0","0.18.0"],"loaded":"0.22.0",
|
||||
"tags":["v0.22.0","v0.18.0"],"manifest":{"v0.22.0":"0.22.0","v0.18.0":"0.18.0"},
|
||||
"releases":["v0.18.0"],
|
||||
"commits":{"v0.18.0...v0.22.0":["定版 v0.22.0(ship-check 送得到)","修一支閘(inkstone/ISEP#1)"]}}
|
||||
JSON
|
||||
t 0 "前置齊全、release 缺 → 建起來" "已建立 release「v0.22.0 — ship-check 送得到」" b1.json v0.22.0
|
||||
t 0 "建完回頭跑 release-check 覆核(問收貨端)" "覆核(release-check v0.22.0" b1.json v0.22.0
|
||||
t 0 "note 自動從 Gitea 的 commit 清單生成(不靠本機 git log)" "commit 清單自動生成" b1.json v0.22.0
|
||||
|
||||
# B2:release 已存在 → 冪等,不重複建、不動它
|
||||
cat > "$TMP/b2.json" <<'JSON'
|
||||
{"current":"0.18.0","dingban":["0.18.0"],"loaded":"0.18.0",
|
||||
"tags":["v0.18.0"],"manifest":{"v0.18.0":"0.18.0"},"releases":["v0.18.0"]}
|
||||
JSON
|
||||
t 0 "release 已存在 → 冪等放行、不重複建" "本來就在了" b2.json v0.18.0 --notes-file "$TMP/b2.json"
|
||||
not "release 已存在時不准說「已建立」(09-05 那句假綠)" "已建立 release" b2.json v0.18.0
|
||||
|
||||
# B3:--dry-run 不 POST
|
||||
cat > "$TMP/b3.json" <<'JSON'
|
||||
{"tags":["v0.22.0"],"manifest":{"v0.22.0":"0.22.0"},"releases":[]}
|
||||
JSON
|
||||
t 0 "--dry-run 全走一遍但不 POST" "不 POST" b3.json v0.22.0 --dry-run
|
||||
not "--dry-run 不寫 sink(真的沒建)" "fixture-created" b3.json v0.22.0 --dry-run
|
||||
|
||||
# B4:🔴 不是最新的 tag 也建得了(總管 09-07:在 v0.23.0 的 checkout 跑被擋,訊息說最新是 v0.24.0)
|
||||
cat > "$TMP/b4.json" <<'JSON'
|
||||
{"current":"0.24.0","dingban":["0.24.0","0.23.0","0.22.0"],"loaded":"0.24.0",
|
||||
"tags":["v0.24.0","v0.23.0","v0.22.0"],
|
||||
"manifest":{"v0.24.0":"0.24.0","v0.23.0":"0.23.0","v0.22.0":"0.22.0"},
|
||||
"releases":["v0.22.0"],
|
||||
"commits":{"v0.22.0...v0.23.0":["定版 v0.23.0(雲端拿得到票與主線)"]}}
|
||||
JSON
|
||||
t 0 "最新 tag 是 v0.24.0,要建的是 v0.23.0 → 照建(前置①看 tag 的樹,不看「最新」)" \
|
||||
"已建立 release「v0.23.0 — 雲端拿得到票與主線」" b4.json v0.23.0
|
||||
t 0 "覆核的是 v0.23.0 這一版,不是工作樹宣稱的 0.24.0" "v0.23.0 ✅ ✅ ✅ ←" b4.json v0.23.0
|
||||
not "工作樹 plugin.json 是 9.9.9 也不會被拿來比(B 群全綠就是證據,這條釘住訊息)" "9.9.9" b4.json v0.23.0
|
||||
|
||||
echo ""
|
||||
echo "──────────────────────────────"
|
||||
printf '共 %d 項:✅ %d / ❌ %d\n' "$N" "$PASS" "$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
# settings-allow-sync 的測試(inkstone/ISEP#130)
|
||||
#
|
||||
# 它守什麼: 「雲端 session 開工時就帶著跟本機同一份權限白名單」這件事的機器那一半。
|
||||
# leo 09-07 親手加的四條只在本機 InkStoneCo/.claude/settings.json;雲端沒有 ⇒ 同樣動作被分類器擋
|
||||
# (09-04 run log:permission_denials=6)。清單改住 ISEP 一份,本支把它寫進那台機器的 ~/.claude/settings.json。
|
||||
#
|
||||
# 🔴 全程在 TMP 底下的假 settings.json 上跑,不碰真的 ~/.claude/settings.json。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
S=scripts/settings-allow-sync
|
||||
LIST=docs/permissions-allow.json
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
PASS=0; FAIL=0
|
||||
ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
|
||||
bad(){ FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
|
||||
N=$(python3 -c "import json;print(len(json.load(open('$LIST'))['allow']))")
|
||||
|
||||
echo "── A. 清單本身的形狀 ──"
|
||||
python3 - "$LIST" <<'PY' && ok "A1 每一條都是 Bash(…) 形狀,而且六個正門工具都在場(ticket/mainline/gate-ok/gitea-pr-merge/stage/Arcrun 的 stage-deploy-artifacts.py)" || bad "A1 清單形狀不對或少了正門工具"
|
||||
import json, sys
|
||||
rules = json.load(open(sys.argv[1]))["allow"]
|
||||
assert rules and all(r.startswith("Bash(") and r.endswith(")") for r in rules), rules
|
||||
for tool in ("scripts/ticket", "scripts/mainline", "scripts/gate-ok", "scripts/gitea-pr-merge.sh", "scripts/stage ", "scripts/stage-deploy-artifacts.py"):
|
||||
assert any(tool in r for r in rules), "缺 " + tool
|
||||
assert not any(r.strip() in ("Bash(python3 *)", "Bash(*)", "Bash(bash *)") for r in rules), "整類放行"
|
||||
PY
|
||||
|
||||
echo "── B. 寫進去(假的 settings.json)──"
|
||||
T="$TMP/a/settings.json"
|
||||
OUT=$(python3 "$S" --target "$T" --home /root 2>&1); RC=$?
|
||||
[ "$RC" = 0 ] && [ -f "$T" ] && ok "B1 目標不存在 ⇒ 建出來(離開碼 0)" || bad "B1 rc=$RC:$OUT"
|
||||
python3 -c "import json;d=json.load(open('$T'));assert len(d['permissions']['allow'])==$N" && ok "B2 寫進 $N 條,JSON 合法" || bad "B2 條數不對或不是 JSON"
|
||||
grep -q 'Bash(python3 /root/.claude/plugins/cache/inkstone/isep/\*/scripts/ticket \*)' "$T" \
|
||||
&& ok "B3 開頭的 ~ 換成了 --home 給的家目錄(雲端=/root)" || bad "B3 ~ 沒換成家目錄"
|
||||
grep -q '"~/' "$T" && bad "B4 檔裡還留著 ~" || ok "B4 檔裡沒有殘留的 ~"
|
||||
grep -q 'CLAUDE_PLUGIN_ROOT' "$T" && ok "B5 \$CLAUDE_PLUGIN_ROOT 那幾條原樣保留(閘印出來的逃生門就是這個形狀)" || bad "B5 少了 \$CLAUDE_PLUGIN_ROOT 形狀"
|
||||
tail -c1 "$T" | od -An -c | grep -q '\\n' && ok "B6 收尾有換行" || bad "B6 沒換行"
|
||||
|
||||
SUM1=$(cksum < "$T"); OUT=$(python3 "$S" --target "$T" --home /root 2>&1); SUM2=$(cksum < "$T")
|
||||
[ "$SUM1" = "$SUM2" ] && printf '%s' "$OUT" | grep -q '已齊' && ok "B7 跑第二次:檔案一個位元組都沒變、說「已齊」(冪等)" || bad "B7 不冪等:$OUT"
|
||||
|
||||
echo "── C. 只加不減:別人手加的東西一個字不動 ──"
|
||||
T2="$TMP/b/settings.json"; mkdir -p "$TMP/b"
|
||||
cat > "$T2" <<'JSON'
|
||||
{
|
||||
"extraKnownMarketplaces": {"inkstone": {"source": {"source": "git", "url": "https://git.uncle6.me/inkstone/ISEP.git"}}},
|
||||
"enabledPlugins": {"isep@inkstone": true},
|
||||
"permissions": {
|
||||
"allow": ["Bash(npx wrangler secret:*)", "Bash(python3 scripts/ticket *)"],
|
||||
"deny": ["Edit(//Users/x/wrangler.toml)"]
|
||||
},
|
||||
"hooks": {"PreToolUse": []}
|
||||
}
|
||||
JSON
|
||||
OUT=$(python3 "$S" --target "$T2" --home /Users/youlinhsieh 2>&1)
|
||||
python3 - "$T2" "$N" <<'PY' && ok "C1 原有的 allow/deny/marketplace/hooks 全在,原本那條沒重複,只補了缺的" || bad "C1 動到了別人的東西:$(cat "$T2")"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1])); n = int(sys.argv[2])
|
||||
a = d["permissions"]["allow"]
|
||||
assert a[0] == "Bash(npx wrangler secret:*)" and a[1] == "Bash(python3 scripts/ticket *)", a[:2]
|
||||
assert a.count("Bash(python3 scripts/ticket *)") == 1
|
||||
assert len(a) == 2 + n - 1, (len(a), n)
|
||||
assert d["permissions"]["deny"] == ["Edit(//Users/x/wrangler.toml)"]
|
||||
assert d["enabledPlugins"] == {"isep@inkstone": True}
|
||||
assert d["hooks"] == {"PreToolUse": []}
|
||||
assert any("/Users/youlinhsieh/.claude/plugins/cache/inkstone/isep/*/scripts/mainline" in x for x in a)
|
||||
PY
|
||||
printf '%s' "$OUT" | grep -q "+$((N-1)) 條" && ok "C2 印出來的是「+$((N-1)) 條」(缺幾條補幾條)" || bad "C2 計數不對:$OUT"
|
||||
|
||||
echo "── D. --check/--dry-run ──"
|
||||
python3 "$S" --target "$T" --home /root --check >/dev/null 2>&1 && ok "D1 --check:全齊 ⇒ 離開碼 0" || bad "D1 --check 全齊卻非 0"
|
||||
T3="$TMP/c/settings.json"; mkdir -p "$TMP/c"; echo '{}' > "$T3"
|
||||
python3 "$S" --target "$T3" --home /root --check >/dev/null 2>&1; RC=$?
|
||||
[ "$RC" = 1 ] && ok "D2 --check:缺 ⇒ 離開碼 1(驗收用)" || bad "D2 --check 缺卻回 $RC"
|
||||
[ "$(cat "$T3")" = "{}" ] && ok "D3 --check 不寫檔" || bad "D3 --check 寫了檔"
|
||||
python3 "$S" --target "$T3" --home /root --dry-run >/dev/null 2>&1
|
||||
[ "$(cat "$T3")" = "{}" ] && ok "D4 --dry-run 不寫檔" || bad "D4 --dry-run 寫了檔"
|
||||
|
||||
echo "── E. 壞掉的目標:不碰、不炸(這支掛在 SessionStart)──"
|
||||
T4="$TMP/d/settings.json"; mkdir -p "$TMP/d"; printf '{ not json' > "$T4"
|
||||
OUT=$(python3 "$S" --target "$T4" --home /root 2>&1); RC=$?
|
||||
[ "$RC" = 0 ] && [ "$(cat "$T4")" = "{ not json" ] && printf '%s' "$OUT" | grep -q '沒有動它' \
|
||||
&& ok "E1 目標不是 JSON ⇒ 原封不動、講出來、離開碼 0" || bad "E1 rc=$RC 內容=$(cat "$T4") out=$OUT"
|
||||
python3 "$S" --target "$T4" --home /root --check >/dev/null 2>&1; [ $? = 1 ] && ok "E2 同一個檔用 --check 問 ⇒ 離開碼 1(不裝沒事)" || bad "E2 --check 對壞檔回 0"
|
||||
|
||||
echo "── F. 接線:SessionStart 有掛、雲端 setup script 有叫 ──"
|
||||
python3 - <<'PY' && ok "F1 hooks.json 的 SessionStart 註冊了 scripts/settings-allow-sync" || bad "F1 hooks.json 沒掛"
|
||||
import json
|
||||
d = json.load(open("hooks/hooks.json"))
|
||||
cmds = [h["command"] for g in d["hooks"]["SessionStart"] for h in g["hooks"]]
|
||||
assert any("scripts/settings-allow-sync" in c for c in cmds), cmds
|
||||
PY
|
||||
grep -q 'settings-allow-sync' docs/cloud-setup-script.sh && ok "F2 docs/cloud-setup-script.sh 裝完 plugin 會跑它" || bad "F2 setup script 沒叫它"
|
||||
grep -q 'exit 0$' docs/cloud-setup-script.sh && ok "F3 setup script 仍然 exit 0(非零會讓整個 session 開不起來)" || bad "F3 setup script 結尾不是 exit 0"
|
||||
|
||||
echo; echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/bin/bash
|
||||
# scripts/stage 的測試(inkstone/ISEP#137)
|
||||
#
|
||||
# 它守什麼: 「雲端總管對 stage 的每一種寫入都有一支固定形狀的正門,而且**只**寫得進 stage」這件事的機器那一半。
|
||||
# 09-07 三種寫入(`npx wrangler secret put`/`stage-deploy-artifacts.py`/curl POST 到 arcrun-yuga3bse)在雲端
|
||||
# 全被 auto mode 分類器擋(複合指令與任意 curl 對不上白名單任何一條)。
|
||||
# 收成 `stage secret …`(CF API)與 `stage api …`(curl 打實例);值只從環境變數名讀;
|
||||
# 判準是 CF 帳號 ID/stage 主機往放行方向比對,不看名字。
|
||||
#
|
||||
# 🔴 全程離線:CF API 與 stage 實例都指到本機一個假伺服器,不打真 Cloudflare、不碰任何 worker。
|
||||
# 真的種一把進 youlin、真的打 stage 實例的實跑在 docs/TESTING.md A36 另記。
|
||||
# 假伺服器對 secret PUT 回 **201**——真 CF 就是回 201,第一次實跑把 201 講成失敗就是這樣抓到的。
|
||||
#
|
||||
# 四個方向都要驗,缺一個就是假綠:
|
||||
# A 拒絕(非 stage/缺變數/值在指令列)⇒ 離開碼 2,而且**沒打網路**
|
||||
# B token 打不到 stage ⇒ 拒絕、印出它打得到誰、沒有任何 PUT
|
||||
# C 種進 stage ⇒ PUT 的路徑帶 stage ID、body 對、值不出現在任何輸出、種完用列表確認
|
||||
# D api:主機只會是 stage;標頭值從環境變數來;body 三種來源;非 2xx 離開碼 1
|
||||
# E 接線:白名單有它四種形狀+Arcrun 那支;ISEP 自己的 Bash 閘對這些呼叫形狀一支都不擋(誤攔比漏擋嚴重)
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
ROOT=$(pwd)
|
||||
S=scripts/stage
|
||||
STAGE=1129efd7df2e8899d537e9c8fbabb6cb
|
||||
TMP=$(mktemp -d); trap 'kill $SRV 2>/dev/null; rm -rf "$TMP"' EXIT
|
||||
PASS=0; FAIL=0
|
||||
ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
|
||||
bad(){ FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
|
||||
LOG="$TMP/log.jsonl"; MODE="$TMP/mode"; STATE="$TMP/secrets.json"
|
||||
export STAGE_TEST_VALUE="v-ISEP137-$(python3 -c 'import secrets;print(secrets.token_hex(12))')-must-never-print" # 每次隨機:C8 才驗得出「值落地」
|
||||
export TOK_STAGE='tok-stage-abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
export KBDB_TOK_TEST="kbdb-$(python3 -c 'import secrets;print(secrets.token_hex(8))')"
|
||||
|
||||
# ── 假伺服器:/accounts… 扮 CF API;/<worker>/… 扮 stage 實例。mode 檔決定 CF 扮誰 ──
|
||||
cat > "$TMP/stub.py" <<'PY'
|
||||
import json, os, sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
TMP = sys.argv[1]; PORT = int(sys.argv[2])
|
||||
STAGE = "1129efd7df2e8899d537e9c8fbabb6cb"
|
||||
def mode(): return open(os.path.join(TMP, "mode")).read().strip()
|
||||
def state():
|
||||
p = os.path.join(TMP, "secrets.json")
|
||||
return json.load(open(p)) if os.path.exists(p) else ["KBDB_INTERNAL_TOKEN"]
|
||||
def save(s): json.dump(s, open(os.path.join(TMP, "secrets.json"), "w"))
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
def _send(self, code, obj):
|
||||
b = json.dumps(obj).encode(); self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(b)))
|
||||
self.end_headers(); self.wfile.write(b)
|
||||
def _body(self):
|
||||
n = int(self.headers.get("Content-Length", "0")); return self.rfile.read(n).decode() if n else ""
|
||||
def _log(self, raw=None):
|
||||
rec = {"method": self.command, "path": self.path, "auth": self.headers.get("Authorization", ""),
|
||||
"ua": self.headers.get("User-Agent", ""), "ct": self.headers.get("Content-Type", ""),
|
||||
"xkey": self.headers.get("X-Arcrun-API-Key", "")}
|
||||
if raw is not None:
|
||||
try: body = json.loads(raw)
|
||||
except ValueError: body = {}
|
||||
rec["body_name"] = body.get("name"); rec["body_type"] = body.get("type")
|
||||
rec["text_ok"] = body.get("text") == os.environ.get("STAGE_TEST_VALUE")
|
||||
rec["body_raw"] = raw if os.environ.get("STAGE_TEST_VALUE") not in raw else "<redacted>"
|
||||
with open(os.path.join(TMP, "log.jsonl"), "a") as f: f.write(json.dumps(rec) + "\n")
|
||||
def _instance(self):
|
||||
# 扮 stage 實例:POST 沒帶 Bearer ⇒ 401(真 kbdb 就是這樣);其餘 200 回聲
|
||||
raw = self._body(); self._log(raw)
|
||||
if self.command != "GET" and not self.headers.get("Authorization"):
|
||||
return self._send(401, {"error": "Unauthorized"})
|
||||
self._send(200, {"ok": True, "echo_path": self.path, "echo_method": self.command})
|
||||
def do_GET(self):
|
||||
if not self.path.startswith("/accounts"): return self._instance()
|
||||
self._log(); m = mode()
|
||||
if self.path == "/accounts":
|
||||
if m == "401": return self._send(401, {"success": False, "errors": [{"code": 10000, "message": "Authentication error"}]})
|
||||
acct = {"stage": [{"id": STAGE, "name": "Youlin.hsieh.dev@gmail.com's Account"}],
|
||||
"leo21c": [{"id": "9f3b2c1d000000000000000000000leo", "name": "Leo21c's Account"}],
|
||||
"geek": [{"id": "77aa11bb000000000000000000000geek", "name": "geek6688's Account"}],
|
||||
"both": [{"id": STAGE, "name": "Youlin"}, {"id": "9f3b2c1d000000000000000000000leo", "name": "Leo21c"}]}
|
||||
return self._send(200, {"success": True, "errors": [], "result": acct.get(m, acct["stage"])})
|
||||
if self.path.endswith("/secrets"):
|
||||
names = state()
|
||||
if m == "put-ok-list-missing": names = [n for n in names if n != "ISEP_137_PROBE"]
|
||||
return self._send(200, {"success": True, "errors": [], "result": [{"name": n, "type": "secret_text"} for n in names]})
|
||||
self._send(404, {"success": False, "errors": [{"code": 7003, "message": "not found"}]})
|
||||
def do_PUT(self):
|
||||
if not self.path.startswith("/accounts"): return self._instance()
|
||||
raw = self._body(); self._log(raw); body = json.loads(raw or "{}")
|
||||
s = state(); s = [x for x in s if x != body.get("name")] + [body.get("name")]; save(s)
|
||||
self._send(201, {"success": True, "errors": [], "result": {"name": body.get("name"), "type": "secret_text"}}) # 真 CF 回 201
|
||||
def do_DELETE(self):
|
||||
if not self.path.startswith("/accounts"): return self._instance()
|
||||
self._log(); name = self.path.rsplit("/", 1)[-1]
|
||||
save([x for x in state() if x != name]); self._send(200, {"success": True, "errors": [], "result": None})
|
||||
def do_POST(self): self._instance()
|
||||
def do_PATCH(self): self._instance()
|
||||
HTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||
PY
|
||||
PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1])")
|
||||
python3 "$TMP/stub.py" "$TMP" "$PORT" & SRV=$!
|
||||
for _ in $(seq 1 50); do python3 -c "import socket;socket.create_connection(('127.0.0.1',$PORT),1)" 2>/dev/null && break; sleep 0.1; done
|
||||
export STAGE_SECRET_PUT_API_BASE="http://127.0.0.1:$PORT"
|
||||
export STAGE_API_BASE_OVERRIDE="http://127.0.0.1:$PORT"
|
||||
echo stage > "$MODE"; : > "$LOG"
|
||||
reset(){ : > "$LOG"; rm -f "$STATE"; echo "${1:-stage}" > "$MODE"; }
|
||||
run(){ OUT=$(python3 "$S" "$@" 2>&1); RC=$?; }
|
||||
hits(){ grep -c "\"method\": \"$1\"" "$LOG" 2>/dev/null || true; }
|
||||
no_value(){ ! printf '%s' "$OUT" | grep -qF "$STAGE_TEST_VALUE"; }
|
||||
|
||||
echo "── A. 本工具自己拒絕的:離開碼 2,而且沒打網路 ──"
|
||||
reset; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE --account 58309bb90fd93ad6d0fe0aae99170e9d
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q "$STAGE" && printf '%s' "$OUT" | grep -q '交回地端' && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||
&& ok "A1 --account 給非 stage(uncle6 的 ID)⇒ 拒絕、講出 stage 是誰、出路是交回地端、一個請求都沒發" || bad "A1 rc=$RC hits=$(wc -l < "$LOG") $OUT"
|
||||
reset; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env NOPE_TOKEN_VAR
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'NOPE_TOKEN_VAR' && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||
&& ok "A2 token 的環境變數沒值 ⇒ 拒絕、點名那個變數、沒打網路" || bad "A2 rc=$RC $OUT"
|
||||
reset; run secret put --worker w --secret X --from-env NOPE_VALUE_VAR --token-env TOK_STAGE
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'NOPE_VALUE_VAR' && printf '%s' "$OUT" | grep -q '紅線' && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||
&& ok "A3 值的環境變數沒值 ⇒ 拒絕、點名變數、明講不准改寫進指令列、沒打網路" || bad "A3 rc=$RC $OUT"
|
||||
reset; run secret put --worker w --secret X --value "$STAGE_TEST_VALUE" --token-env TOK_STAGE
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q -- '--value' && no_value && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||
&& ok "A4 --value(值在指令列)⇒ 拒收,而且拒絕訊息裡不回顯那個值" || bad "A4 rc=$RC $OUT"
|
||||
reset; run secret put --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q -- '--worker' && ok "A5 缺 --worker ⇒ 拒絕、講缺哪個" || bad "A5 rc=$RC $OUT"
|
||||
reset; run frobnicate --worker w
|
||||
[ "$RC" = 2 ] && ok "A6 不認得的子命令 ⇒ 拒絕" || bad "A6 rc=$RC $OUT"
|
||||
run --help; [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q -- '--from-env' && printf '%s' "$OUT" | grep -q -- '--bearer-env' && ok "A7 --help 印兩個子命令的用法、離開碼 0" || bad "A7 rc=$RC"
|
||||
|
||||
echo "── B. token 打不到 stage ⇒ 拒絕、印出它打得到誰、沒有 PUT ──"
|
||||
reset leo21c; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q "Leo21c's Account" && printf '%s' "$OUT" | grep -q "$STAGE" && [ "$(hits PUT)" = 0 ] && no_value \
|
||||
&& ok "B1 token 只打得到 leo21c ⇒ 拒絕、列出那個帳號、講出 stage 是哪個 ID、零 PUT" || bad "B1 rc=$RC PUT=$(hits PUT) $OUT"
|
||||
reset geek; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q "geek6688" && [ "$(hits PUT)" = 0 ] \
|
||||
&& ok "B2 token 只打得到 geek6688(出貨機)⇒ 同樣拒絕、零 PUT" || bad "B2 rc=$RC $OUT"
|
||||
reset 401; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q 'Authentication error' && [ "$(hits PUT)" = 0 ] \
|
||||
&& ok "B3 CF 回 401 ⇒ 離開碼 1(API 失敗,不是「拒絕」也不是 000)、帶 CF 的錯誤字串、零 PUT" || bad "B3 rc=$RC $OUT"
|
||||
reset; STAGE_SECRET_PUT_API_BASE="http://127.0.0.1:1" run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q '__agentproxy/status' && no_value \
|
||||
&& ok "B4 連不到 CF ⇒ 離開碼 1、印錯誤類型、指到 agentproxy status(不把三種病記成 000)" || bad "B4 rc=$RC $OUT"
|
||||
reset both; run secret put --worker w --secret ISEP_137_PROBE --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||
[ "$RC" = 0 ] && grep -q "\"path\": \"/accounts/$STAGE/workers/scripts/w/secrets\"" "$LOG" && ! grep -q '0000leo' "$LOG" \
|
||||
&& ok "B5 token 兩個帳號都打得到 ⇒ 放行,但 URL 只帶 stage 的 ID(寫入永遠只到 stage)" || bad "B5 rc=$RC $(cat "$LOG")"
|
||||
|
||||
echo "── C. 種進 stage(假 CF 對 PUT 回 201,跟真的一樣)──"
|
||||
reset; run secret put --worker arcrun-cypher-executor --secret ISEP_137_PROBE --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||
[ "$RC" = 0 ] && ok "C1 離開碼 0(201 算成功——第一次實跑把 201 講成失敗,就是這格抓的)" || bad "C1 rc=$RC $OUT"
|
||||
grep -q "\"method\": \"PUT\", \"path\": \"/accounts/$STAGE/workers/scripts/arcrun-cypher-executor/secrets\"" "$LOG" \
|
||||
&& ok "C2 PUT 打的是 stage 帳號 ID 底下那顆 worker" || bad "C2 $(cat "$LOG")"
|
||||
grep '"method": "PUT"' "$LOG" | grep -q '"body_name": "ISEP_137_PROBE", "body_type": "secret_text", "text_ok": true' \
|
||||
&& ok "C3 body 的 name/type 對,而且 text 就是環境變數裡那個值(不是變數名)" || bad "C3 $(grep PUT "$LOG")"
|
||||
no_value && ok "C4 stdout/stderr 不含那個值" || bad "C4 值印出來了"
|
||||
grep -q '"auth": "Bearer tok-stage-' "$LOG" && ok "C5 Authorization 帶的是 --token-env 指的那把" || bad "C5 $(head -1 "$LOG")"
|
||||
printf '%s' "$OUT" | grep -q 'ISEP_137_PROBE' && printf '%s' "$OUT" | grep -q 'KBDB_INTERNAL_TOKEN' \
|
||||
&& ok "C6 種完把列表印出來(新的那把+原本就有的)" || bad "C6 $OUT"
|
||||
[ "$(grep -c '"method": "GET", "path": "/accounts/'"$STAGE"'/workers/scripts/arcrun-cypher-executor/secrets"' "$LOG")" = 1 ] \
|
||||
&& ok "C7 種完真的去列了一次(不是只信 PUT 的回應)" || bad "C7 $(cat "$LOG")"
|
||||
! grep -rqF "$STAGE_TEST_VALUE" "$ROOT" 2>/dev/null \
|
||||
&& ok "C8 值沒落在 repo 樹裡任何檔案" || bad "C8 值落地了:$(grep -rlF "$STAGE_TEST_VALUE" "$ROOT")"
|
||||
reset put-ok-list-missing; run secret put --worker w --secret ISEP_137_PROBE --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q '列表卻沒有' \
|
||||
&& ok "C9 CF 說成功但列表沒有 ⇒ 離開碼 1(不把 2xx 講成種好了)" || bad "C9 rc=$RC $OUT"
|
||||
reset; run secret list --worker w --token-env TOK_STAGE
|
||||
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q 'KBDB_INTERNAL_TOKEN' && [ "$(hits PUT)" = 0 ] && ok "C10 secret list 印名字、不寫" || bad "C10 rc=$RC $OUT"
|
||||
reset; run secret delete --worker w --secret KBDB_INTERNAL_TOKEN --token-env TOK_STAGE
|
||||
[ "$RC" = 0 ] && grep -q "\"method\": \"DELETE\", \"path\": \"/accounts/$STAGE/workers/scripts/w/secrets/KBDB_INTERNAL_TOKEN\"" "$LOG" \
|
||||
&& ok "C11 secret delete 打對路徑、刪完列表確認" || bad "C11 rc=$RC $OUT $(cat "$LOG")"
|
||||
reset leo21c; run secret delete --worker w --secret X --token-env TOK_STAGE
|
||||
[ "$RC" = 2 ] && [ "$(hits DELETE)" = 0 ] && ok "C12 delete 對非 stage 的 token 一樣拒絕" || bad "C12 rc=$RC"
|
||||
|
||||
echo "── D. api:主機只會是 stage、標頭值從環境變數來、body 三種來源 ──"
|
||||
reset; run api POST https://arcrun-cypher-executor.leo21c.workers.dev/kbdb/entries --json '{}'
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'leo21c.workers.dev' && printf '%s' "$OUT" | grep -q 'arcrun-yuga3bse' && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||
&& ok "D1 完整網址指到 leo21c ⇒ 拒絕、講出 stage 主機是什麼、沒打網路" || bad "D1 rc=$RC $OUT"
|
||||
reset; run api POST https://arcrun-kbdb.youlin-hsieh-dev.workers.dev/records --json '{}'
|
||||
[ "$RC" = 2 ] && [ "$(wc -l < "$LOG")" = 0 ] && ok "D2 09-02 已死的舊子網域 youlin-hsieh-dev 也拒絕(不是 stage 的識別碼)" || bad "D2 rc=$RC $OUT"
|
||||
reset; run api POST arcrun-kbdb/records --bearer 'Bearer xyz' --json '{}'
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q -- '--bearer' && [ "$(wc -l < "$LOG")" = 0 ] && ok "D3 --bearer/--header(值在指令列)⇒ 拒收" || bad "D3 rc=$RC $OUT"
|
||||
reset; run api POST arcrun-kbdb/records --bearer-env NOPE_KBDB --json '{}'
|
||||
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'NOPE_KBDB' && [ "$(wc -l < "$LOG")" = 0 ] && ok "D4 --bearer-env 指的變數沒值 ⇒ 拒絕、點名" || bad "D4 rc=$RC $OUT"
|
||||
reset; run api POST 'arcrun-kbdb/map/recompute?library=rt-lib&owner_id=yuga3bse' --bearer-env KBDB_TOK_TEST
|
||||
[ "$RC" = 0 ] && grep -q '"method": "POST", "path": "/arcrun-kbdb/map/recompute?library=rt-lib&owner_id=yuga3bse", "auth": "Bearer kbdb-' "$LOG" \
|
||||
&& printf '%s' "$OUT" | grep -q '^HTTP 200' && ok "D5 POST <worker>/<path>?query:主機由工具補、query 原樣、Bearer 從環境變數來、印 HTTP 200、離開碼 0" || bad "D5 rc=$RC $OUT $(cat "$LOG")"
|
||||
reset; run api POST arcrun-kbdb/records --bearer-env KBDB_TOK_TEST --json '{"template":"triplet","values":{"s":"a"}}'
|
||||
grep -q '"ct": "application/json"' "$LOG" && grep -q '"body_raw": "{\\"template\\":\\"triplet\\",\\"values\\":{\\"s\\":\\"a\\"}}"' "$LOG" \
|
||||
&& ok "D6 --json:body 原樣送到、自動帶 Content-Type: application/json" || bad "D6 $(cat "$LOG")"
|
||||
reset; STAGE_BODY_TEST='{"secret_ref":"'"$STAGE_TEST_VALUE"'"}' run api PUT arcrun-cypher-executor/credentials/gitea_token --bearer-env KBDB_TOK_TEST --data-env STAGE_BODY_TEST
|
||||
[ "$RC" = 0 ] && grep -q '"method": "PUT", "path": "/arcrun-cypher-executor/credentials/gitea_token"' "$LOG" && grep -q '"body_raw": "<redacted>"' "$LOG" && no_value \
|
||||
&& ok "D7 --data-env:body 從環境變數來(機敏 body 不進指令列),值沒印出來" || bad "D7 rc=$RC $OUT $(cat "$LOG")"
|
||||
reset; printf '{"x":1}' > "$TMP/body.json"; run api POST arcrun-kbdb/records --bearer-env KBDB_TOK_TEST --data-file "$TMP/body.json"
|
||||
[ "$RC" = 0 ] && grep -q '"body_raw": "{\\"x\\":1}"' "$LOG" && ok "D8 --data-file:body 從檔案來" || bad "D8 rc=$RC $(cat "$LOG")"
|
||||
reset; run api POST arcrun-kbdb/records --json '{}'
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q '^HTTP 401' && ok "D9 沒帶 Bearer 的 POST ⇒ 實例回 401 ⇒ 離開碼 1(不把非 2xx 講成成功)" || bad "D9 rc=$RC $OUT"
|
||||
reset; run api GET arcrun-cypher-executor/health
|
||||
[ "$RC" = 0 ] && grep -q '"method": "GET", "path": "/arcrun-cypher-executor/health"' "$LOG" && ok "D10 GET 不帶 body、不帶 Content-Type" || bad "D10 rc=$RC $OUT"
|
||||
grep -q '"ct": ""' "$LOG" && ok "D11 GET 的請求真的沒有 Content-Type" || bad "D11 $(cat "$LOG")"
|
||||
reset; run api POST arcrun-cypher-executor/webhooks/named/yuga3bse/x/trigger --header-env X-Arcrun-API-Key=KBDB_TOK_TEST --bearer-env KBDB_TOK_TEST --json '{}'
|
||||
grep -q '"xkey": "kbdb-' "$LOG" && ok "D12 --header-env Name=VAR:任意標頭值從環境變數來" || bad "D12 $(cat "$LOG")"
|
||||
reset; run api POST https://arcrun-kbdb.arcrun-yuga3bse.workers.dev/records --bearer-env KBDB_TOK_TEST --json '{}'
|
||||
[ "$RC" = 0 ] && grep -q '"path": "/arcrun-kbdb/records"' "$LOG" && ok "D13 完整網址但主機就是 stage ⇒ 收" || bad "D13 rc=$RC $OUT"
|
||||
reset; run api POST 'arcrun-kbdb/records' --bearer-env KBDB_TOK_TEST --json '{}' --data-file x
|
||||
[ "$RC" = 2 ] && [ "$(wc -l < "$LOG")" = 0 ] && ok "D14 --json 與 --data-file 同時給 ⇒ 拒絕" || bad "D14 rc=$RC"
|
||||
reset; run api FROB arcrun-kbdb/records
|
||||
[ "$RC" = 2 ] && ok "D15 不認得的 METHOD ⇒ 拒絕" || bad "D15 rc=$RC"
|
||||
reset; STAGE_API_BASE_OVERRIDE="http://127.0.0.1:1" run api GET arcrun-cypher-executor/health
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q 'curl 失敗' && printf '%s' "$OUT" | grep -q '__agentproxy/status' && ok "D16 curl 連不上 ⇒ 離開碼 1、印 curl 的錯誤字串與 agentproxy 出路(不記成 000)" || bad "D16 rc=$RC $OUT"
|
||||
grep -q '"ua": "isep-stage/1' "$LOG" 2>/dev/null || { reset; run api GET arcrun-cypher-executor/health; }
|
||||
grep -q '"ua": "isep-stage/1' "$LOG" && ok "D17 UA 是自己的名字(youlin 邊緣擋 python-urllib UA,Arcrun#176 comment 6487)" || bad "D17 $(cat "$LOG")"
|
||||
|
||||
echo "── E. 接線 ──"
|
||||
python3 - <<'PY' && ok "E1 docs/permissions-allow.json 有 stage 四種呼叫形狀+Arcrun 的 stage-deploy-artifacts.py,沒放整類" || bad "E1 白名單形狀不齊或放了整類"
|
||||
import json
|
||||
rules = json.load(open("docs/permissions-allow.json"))["allow"]
|
||||
need = ['Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/stage *)',
|
||||
'Bash(python3 "$CLAUDE_PLUGIN_ROOT/scripts/stage" *)',
|
||||
'Bash(python3 "$CLAUDE_PLUGIN_ROOT"/scripts/stage *)',
|
||||
'Bash(python3 scripts/stage *)',
|
||||
'Bash(python3 scripts/stage-deploy-artifacts.py *)']
|
||||
for n in need: assert n in rules, "缺 " + n
|
||||
assert not any(r in ("Bash(npx *)", "Bash(wrangler *)", "Bash(npx wrangler *)", "Bash(python3 *)", "Bash(curl *)") for r in rules)
|
||||
PY
|
||||
# ISEP 自己掛在 PreToolUse Bash 上的每一支閘,對三種真實呼叫形狀都不准擋(清單當場從 hooks.json 讀,不寫死)
|
||||
BLOCKED=""
|
||||
while IFS= read -r CMD; do
|
||||
for h in $(python3 -c "
|
||||
import json
|
||||
d=json.load(open('hooks/hooks.json'))
|
||||
for g in d['hooks']['PreToolUse']:
|
||||
if 'Bash' in (g.get('matcher') or ''):
|
||||
for x in g['hooks']: print(x['command'].split('/')[-1])
|
||||
"); do
|
||||
[ -f "hooks/$h" ] || continue
|
||||
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$CMD")},\"session_id\":\"t137\",\"cwd\":\"$ROOT\"}" \
|
||||
| CLAUDE_PLUGIN_ROOT="$ROOT" CLAUDE_PROJECT_DIR="$ROOT" bash "hooks/$h" >/dev/null 2>"$TMP/hook.err"; rc=$?
|
||||
[ "$rc" = 2 ] && BLOCKED="$BLOCKED $h←$(printf '%s' "$CMD" | cut -c1-40)"
|
||||
done
|
||||
done <<'CMDS'
|
||||
python3 ~/.claude/plugins/cache/inkstone/isep/0.24.0/scripts/stage secret put --worker arcrun-cypher-executor --secret CF_SECRETS_API_TOKEN --from-env CF_SECRETS_API_TOKEN_VALUE
|
||||
python3 "$CLAUDE_PLUGIN_ROOT/scripts/stage" api POST 'arcrun-kbdb/map/recompute?library=rt-lib&owner_id=yuga3bse' --bearer-env KBDB_INTERNAL_TOKEN
|
||||
python3 scripts/stage-deploy-artifacts.py all --confirm
|
||||
CMDS
|
||||
[ -z "$BLOCKED" ] && ok "E2 ISEP 掛在 Bash 上的閘,對三種真實呼叫形狀(含 secret 字樣、POST、--confirm)一支都不擋——誤攔比漏擋嚴重" || bad "E2 這幾支誤攔:$BLOCKED"
|
||||
grep -q 'scripts/stage ' scripts/test-settings-allow-sync.sh && grep -q 'stage-deploy-artifacts.py' scripts/test-settings-allow-sync.sh \
|
||||
&& ok "E3 settings-allow-sync 的測試把兩支都算進正門工具(少了會紅)" || bad "E3 settings-allow-sync 測試不認得它們"
|
||||
[ ! -e scripts/stage-secret-put ] && ok "E4 沒有第二支平行的工具(stage-secret-put 已收進 stage secret)" || bad "E4 還留著 stage-secret-put"
|
||||
|
||||
echo; echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -68,7 +68,10 @@ echo " 裸引號的 python dict 寫法,跟 2026-08-27 那 13 張票實際的
|
||||
echo " curl -d 那種「JSON 被跳脫成字串塞進另一層引號」的寫法,本閘的猜題功能"
|
||||
echo " 刻意不支援,見 hook 檔頭「只認雙引號」那段註解,訊息裡不會列候選舊票,"
|
||||
echo " 但擋下來這件事不受影響,另有獨立案例覆蓋。)"
|
||||
if git remote -v 2>/dev/null | grep 'git\.uncle6\.me' | grep -q '@'; then
|
||||
# 能不能打網路:看 token 解不解得出來(inkstone/ISEP#47 之後 remote URL 乾淨、不再帶憑證,
|
||||
# 舊判準「remote 有沒有 @」會讓這條在每台機器上永遠 SKIP ⇒ 等於沒有這條測試)
|
||||
_GT_TOK=""; . "$(dirname "$0")/lib/gitea-token.sh" 2>/dev/null && _GT_TOK=$(gitea_token 2>/dev/null || true)
|
||||
if [ -n "$_GT_TOK" ]; then
|
||||
OUT=$(printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' 'req("POST", f"{API}/repos/{REPO}/issues", {"title":"總圖說我一條關聯都沒有,三元組 graph 全部消失"})')}}" \
|
||||
| bash "$H" 2>&1 >/dev/null)
|
||||
RC=$?
|
||||
@@ -79,7 +82,7 @@ if git remote -v 2>/dev/null | grep 'git\.uncle6\.me' | grep -q '@'; then
|
||||
printf '%s\n' "$OUT"
|
||||
fi
|
||||
else
|
||||
echo " ⏭️ SKIP(這台機器的 git remote 沒帶 git.uncle6.me 的憑證,連不上就不算數)"
|
||||
echo " ⏭️ SKIP(解不出 Gitea token:沒有 \$GITEA_TOKEN_CLAUDE_CODE、\$CLAUDE_PROJECT_DIR/.env 也沒有,連不上就不算數)"
|
||||
fi
|
||||
|
||||
[ -n "$SAVED" ] && printf '%s' "$SAVED" > /tmp/.ticket-where-ok
|
||||
|
||||
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/bin/bash
|
||||
# 雲端工人抓票規則的測試(inkstone/ISEP#131)
|
||||
#
|
||||
# 票上的驗收第 1 條:「`scripts/ticket pick` 給定主線檔,回一張符合規則的票;
|
||||
# 池子裡塞一張 `Human`、一張有 assignee、一張 backlog,三張都不會被挑到(測試綠)。」
|
||||
#
|
||||
# 這支測四件事:
|
||||
# ① `pickable()` 純函式——規則本體,一格一條,不打網路
|
||||
# ② `pick` 走整條路(把 api() 換成錄音機餵一個假池子):挑到誰、順序、主線抓盡才抓逾期、
|
||||
# 沒有可抓的票時離開碼 1、沒有主線時離開碼 2 且訊息講得出出路
|
||||
# ③ `claim` 一個動作三件事(指派/s/doing/【身份】留言),以及該擋的擋在**打 API 之前**
|
||||
# ④ 不該擋的(誤攔比漏擋更該修)
|
||||
#
|
||||
# 全程離線:假池子在下面的 POOL;TICKET_HOST 指到連不上的位址,任何一條真的走到網路會當場炸,
|
||||
# 不會靜靜變成假綠。不開票、不改票、不留任何東西在 Gitea 上。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
T=scripts/ticket
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
PASS=0; FAIL=0
|
||||
export TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
export ISEP_COUNTDOWN_NOW=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,9,7,0,0,tzinfo=d.timezone.utc).timestamp()))')
|
||||
unset ISEP_MAINLINE_FILE ISEP_IDENTITY CLAUDE_PROJECT_DIR ISEP_DRAIN ISEP_WORKER_LOGIN
|
||||
|
||||
MAIN="$TMP/mainline.json"
|
||||
cat > "$MAIN" <<'JSON'
|
||||
{"ref":"inkstone/Arcrun#48","owner":"inkstone","repo":"Arcrun","id":48,
|
||||
"title":"主線 M","description":"測試用主線","due_on":"2026-09-12T23:59:59Z",
|
||||
"open_issues":3,"closed_issues":0,"members":[],"set_at":"2026-09-07 07:30"}
|
||||
JSON
|
||||
|
||||
# ── 假池子:三個 repo、三個 open milestone、十一張票 ─────────────────────
|
||||
# 主線 M(未逾期,主線)/逾期 M(2026-08-19 到期)/未來 M(2026-10-01 到期,不是主線)
|
||||
cat > "$TMP/pool.py" <<'PY'
|
||||
import json, os
|
||||
MS = {"main": {"id": 48, "title": "主線 M", "due_on": "2026-09-12T23:59:59Z"},
|
||||
"late": {"id": 49, "title": "逾期 M", "due_on": "2026-08-19T23:59:59Z"},
|
||||
"future": {"id": 50, "title": "未來 M", "due_on": "2026-10-01T23:59:59Z"}}
|
||||
def I(repo, n, labels, ms=None, assignees=(), created="2026-09-01T00:00:00Z", pr=False, title=None):
|
||||
d = {"number": n, "title": title or f"票 {repo}#{n}", "state": "open",
|
||||
"labels": [{"name": l} for l in labels],
|
||||
"assignees": [{"login": a} for a in assignees],
|
||||
"milestone": MS[ms] if ms else None, "created_at": created,
|
||||
"repository": {"full_name": f"inkstone/{repo}"}}
|
||||
if pr: d["pull_request"] = {"merged": False}
|
||||
return d
|
||||
POOL = {
|
||||
"ISEP": [I("ISEP", 1, ["s/todo"], None), # ★ 可抓:沒里程碑,靠相依邊在主線上
|
||||
I("ISEP", 13, ["s/todo"], "main"), # ISEP 裡「同名」的里程碑:不是主線(ISEP#133)
|
||||
I("ISEP", 2, ["s/todo", "Human"], "main"), # 票上第 1 條:Human
|
||||
I("ISEP", 3, ["s/todo"], "main", assignees=("someone",)), # 票上第 1 條:有 assignee
|
||||
I("ISEP", 4, ["s/backlog"], None), # 票上第 1 條:backlog
|
||||
I("ISEP", 5, ["s/todo"], None), # 沒里程碑
|
||||
I("ISEP", 10, ["s/todo"], "main", pr=True), # PR 不是票
|
||||
I("ISEP", 11, ["s/todo", "human/exec"], "main")], # 執行者是人
|
||||
"Arcrun": [I("Arcrun", 6, ["s/todo", "p/high"], "main", created="2026-08-27T00:00:00Z"), # ★ 主線裡最優先
|
||||
I("Arcrun", 7, ["s/todo"], "late"), # 逾期里程碑:主線抓盡才輪到
|
||||
I("Arcrun", 8, ["s/todo"], "future")], # 不是主線也沒逾期:永遠不抓
|
||||
"mira": [I("mira", 9, ["s/todo", "s/doing"], "main"), # 09-07 實查 mira#6 的形狀:有人在做
|
||||
# ── 票池那一層才看得到的(inkstone/ISEP#130):都沒有里程碑 ──
|
||||
I("mira", 20, ["s/triage"], None), # 還沒決定要不要做:不抓
|
||||
I("mira", 21, [], None), # 沒有 s/*(還沒驗傷):不抓,但要被點名
|
||||
I("mira", 22, ["s/todo"], None, assignees=("Leo",)), # 指派 Leo:不抓
|
||||
I("mira", 23, ["s/backlog", "Human"], None), # Human:不抓
|
||||
I("mira", 24, ["s/review"], None), # 等總管:不抓
|
||||
I("mira", 25, ["s/backlog", "p/high"], None, created="2026-09-05T00:00:00Z"), # ★ 票池裡最優先
|
||||
I("mira", 26, ["s/todo", "hub"], None), # hub:容器不是任務
|
||||
I("mira", 27, ["Human"], None)], # 沒 s/* 但掛 Human:不抓、也不分診(ISEP#159)
|
||||
# 分頁:伺服器每頁上限 30(比 pick 要的 50 小)——只看「不滿一頁」就停的寫法會漏掉第 31 張之後的
|
||||
"big": [I("big", n, [], None) for n in range(1, 31)] + [I("big", 31, ["s/backlog"], None)],
|
||||
}
|
||||
MSN = {"ISEP": ["main", "late", "future"], "Arcrun": ["main", "late", "future"], "mira": ["main"]}
|
||||
SERVER_PAGE_MAX = 30
|
||||
# 相依邊(inkstone/ISEP#133):主線錨在 Arcrun#48,Arcrun#6 把 ISEP#1 當相依 ⇒ ISEP#1 在主線上;
|
||||
# ISEP#13 掛在 ISEP 的同名里程碑但沒有任何邊 ⇒ 不在主線上。mira#9 同理(同名、沒邊)。
|
||||
DEPS = {"Arcrun#6": ["ISEP#1"]}
|
||||
BLOCKS = {}
|
||||
for _p, _cs in DEPS.items():
|
||||
for _c in _cs:
|
||||
BLOCKS.setdefault(_c, []).append(_p)
|
||||
def _by_ref(ref):
|
||||
repo, n = ref.split("#")
|
||||
return next(i for i in POOL[repo] if i["number"] == int(n))
|
||||
DROP = set(json.loads(os.environ.get("DROP", "[]"))) # 測「主線抓盡」用:把某幾張從池子拿掉
|
||||
def fake_api(path, payload=None, method=None):
|
||||
rec.append((method or ("POST" if payload is not None else "GET"), path, payload))
|
||||
if path.startswith("/orgs/inkstone/repos"):
|
||||
return [{"name": r} for r in POOL]
|
||||
if path == "/user":
|
||||
return {"login": "claude-code"}
|
||||
if "/milestones?" in path:
|
||||
repo = path.split("/")[3]
|
||||
return [MS[k] for k in MSN.get(repo, [])]
|
||||
if "/issues?" in path:
|
||||
repo = path.split("/")[3]
|
||||
import urllib.parse
|
||||
q = urllib.parse.parse_qs(path.split("?", 1)[1])
|
||||
rows = [i for i in POOL[repo] if f"{repo}#{i['number']}" not in DROP]
|
||||
if "milestones" in q:
|
||||
want = q["milestones"][0]
|
||||
return [i for i in rows if (i["milestone"] or {}).get("title") == want]
|
||||
# 不帶里程碑=整個 repo 的 open 票(票池那一層),照 Gitea 的分頁
|
||||
page = int(q.get("page", ["1"])[0]); per = min(int(q.get("limit", ["50"])[0]), SERVER_PAGE_MAX)
|
||||
return rows[(page - 1) * per: page * per]
|
||||
if path.endswith("/labels?limit=100"):
|
||||
return [{"name": n, "id": k} for k, n in
|
||||
enumerate(["s/todo", "s/doing", "s/review", "p/high", "Human"], 1)]
|
||||
import re
|
||||
m = re.match(r"/repos/inkstone/(\w+)/issues/(\d+)/(dependencies|blocks)(\?.*)?$", path)
|
||||
if m:
|
||||
table = DEPS if m.group(3) == "dependencies" else BLOCKS
|
||||
return [_by_ref(r) for r in table.get(f"{m.group(1)}#{m.group(2)}", [])]
|
||||
m = re.match(r"/repos/inkstone/(\w+)/issues/(\d+)$", path)
|
||||
if m and (method or "GET") == "GET":
|
||||
return next(i for i in POOL[m.group(1)] if i["number"] == int(m.group(2)))
|
||||
return {}
|
||||
rec = []
|
||||
PY
|
||||
|
||||
# probe <python 片段> —— 把 scripts/ticket 當模組載進來、api() 換成假池子,印出片段的值
|
||||
probe() {
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import sys, os, json, io, contextlib, importlib.util as u
|
||||
from importlib.machinery import SourceFileLoader as L
|
||||
spec = u.spec_from_loader("t", L("t", "scripts/ticket"))
|
||||
m = u.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
ns = {}
|
||||
exec(open(os.environ["POOL_PY"]).read(), ns)
|
||||
m.api = ns["fake_api"]; rec = ns["rec"]
|
||||
argv = json.loads(sys.argv[2]) if sys.argv[2] else None
|
||||
buf = io.StringIO(); rc = None
|
||||
if argv is not None:
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||||
try:
|
||||
getattr(m, argv[0])(argv[1:])
|
||||
except SystemExit as e:
|
||||
rc = e.code
|
||||
print(eval(sys.argv[1], {"m": m, "rec": rec, "rc": rc, "out": buf.getvalue(), "I": ns["I"], "json": json}))
|
||||
PY
|
||||
}
|
||||
export POOL_PY="$TMP/pool.py"
|
||||
|
||||
p() { # p <說明> <want> <python 片段> [argv json]
|
||||
got=$(probe "$3" "${4:-}" 2>&1)
|
||||
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
|
||||
}
|
||||
|
||||
echo "── ① pickable():規則本體,一格一條(純函式,不打網路)──"
|
||||
p "★ s/todo+沒人+沒 Human+主線 → 可抓" "True" "m.pickable(I('ISEP',1,['s/todo'],'main'),'主線 M')[0]"
|
||||
p "★ 掛 Human → 不可抓(票上第 1 條)" "False" "m.pickable(I('ISEP',2,['s/todo','Human'],'main'),'主線 M')[0]"
|
||||
p "★ 有 assignee → 不可抓(票上第 1 條)" "False" "m.pickable(I('ISEP',3,['s/todo'],'main',assignees=('x',)),'主線 M')[0]"
|
||||
p "★ s/backlog、沒里程碑 → 不可抓(票上第 1 條)" "False" "m.pickable(I('ISEP',4,['s/backlog'],None),'主線 M')[0]"
|
||||
p "s/todo 但沒里程碑 → 不可抓(不抓沒里程碑的)" "False" "m.pickable(I('ISEP',5,['s/todo'],None),'主線 M')[0]"
|
||||
p "human/exec(執行者是人)→ 不可抓" "False" "m.pickable(I('ISEP',11,['s/todo','human/exec'],'main'),'主線 M')[0]"
|
||||
p "s/todo+s/doing 同時掛著(mira#6 的形狀)→ 不可抓,有人在做" "False" "m.pickable(I('mira',9,['s/todo','s/doing'],'main'),'主線 M')[0]"
|
||||
p "hub(scope 容器,09-07 實查 ISEP#31–35 的形狀)→ 不可抓" "False" "m.pickable(I('ISEP',12,['s/todo','hub'],'main'),'主線 M')[0]"
|
||||
p "PR 不是票 → 不可抓" "False" "m.pickable(I('ISEP',10,['s/todo'],'main',pr=True),'主線 M')[0]"
|
||||
p "里程碑不是要求的那個 → 不可抓" "False" "m.pickable(I('Arcrun',8,['s/todo'],'future'),'主線 M')[0]"
|
||||
p "里程碑標題只差空白/大小寫 → 仍算同一個(norm 只有 mainline.py 那一份)" "True" "m.pickable(I('ISEP',1,['s/todo'],'main'),' 主線 m ')[0]"
|
||||
p "★ 只指派給自己的 s/todo(總管 handback 回池子的 Arcrun#176 形狀)→ 可抓(ISEP#33)" "True" "m.pickable(I('Arcrun',176,['s/todo'],'main',assignees=('claude-code',)),'主線 M','claude-code')[0]"
|
||||
p "指派給自己+別人 → 不可抓" "False" "m.pickable(I('Arcrun',176,['s/todo'],'main',assignees=('claude-code','Leo')),'主線 M','claude-code')[0]"
|
||||
p "指派給自己但 s/doing(真的有人在做)→ 不可抓" "False" "m.pickable(I('Arcrun',197,['s/doing'],'main',assignees=('claude-code',)),'主線 M','claude-code')[0]"
|
||||
p "不知道自己是誰(me=None)→ 照舊不可抓,不猜" "False" "m.pickable(I('Arcrun',176,['s/todo'],'main',assignees=('claude-code',)),'主線 M')[0]"
|
||||
p "指派給自己但掛 Human → 不可抓(Human 照擋)" "False" "m.pickable(I('ISEP',130,['s/todo','Human'],'main',assignees=('claude-code',)),'主線 M','claude-code')[0]"
|
||||
p "理由講得出「為什麼不能抓」(不是只回 False)" "True" "'Human' in m.pickable(I('ISEP',2,['s/todo','Human'],'main'),'主線 M')[1]"
|
||||
p "順序:p/high 先於沒標,沒標先於 p/low" "True" \
|
||||
"m.pick_order(I('a',1,['s/todo','p/high'],'main')) < m.pick_order(I('a',2,['s/todo'],'main')) < m.pick_order(I('a',3,['s/todo','p/low'],'main'))"
|
||||
p "順序:同優先序看開票時間,舊的先" "True" \
|
||||
"m.pick_order(I('a',9,['s/todo'],'main',created='2026-08-01T00:00:00Z')) < m.pick_order(I('a',1,['s/todo'],'main',created='2026-09-01T00:00:00Z'))"
|
||||
|
||||
echo
|
||||
echo "── ② pick 走整條路(假池子 11 張,三個 repo)──"
|
||||
ARGS="[\"cmd_pick\",\"--mainline\",\"$MAIN\"]"
|
||||
ALL="[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--all\",\"--json\"]"
|
||||
p "★ 給定主線檔 → 回一張,而且是主線裡最優先的 Arcrun#6(p/high)" "True" "'inkstone/Arcrun#6' in out and rc is None" "$ARGS"
|
||||
p "只回一張(不是整份清單)" "1" "out.count(' ● ')" "$ARGS"
|
||||
p "★ --all:主線裡可抓的恰好是 Arcrun#6、ISEP#1 兩張——Human/assignee/backlog 三張都不在" \
|
||||
"['inkstone/Arcrun#6', 'inkstone/ISEP#1']" "[r['ref'] for r in json.loads(out) if r['group']=='主線']" "$ALL"
|
||||
p "★ ISEP#1 沒掛里程碑,是靠「Arcrun#6 把它當相依」進主線的(成員=相依邊,ISEP#133)" "True" \
|
||||
"'inkstone/ISEP#1' in out and any(p.endswith('/issues/6/dependencies?limit=100') for _,p,_ in rec)" "$ALL"
|
||||
p "★ ISEP#13 掛在 ISEP 裡**同名**的里程碑、沒有任何邊 → 不在主線裡(同名不算,leo 09-07)" "False" \
|
||||
"'inkstone/ISEP#13' in out" "$ALL"
|
||||
p "主線那一組只讀 hub 里程碑那一個 repo 的 milestone 清單(不去別的 repo 找同名)" "True" \
|
||||
"not any('/repos/inkstone/ISEP/issues?' in p and '主線' in p for _,p,_ in rec)" "$ALL"
|
||||
p "★ --all:逾期里程碑那張 Arcrun#7 排在主線之後(主線抓盡才輪到)" \
|
||||
"['inkstone/Arcrun#7']" "[r['ref'] for r in json.loads(out) if r['group']!='主線']" "$ALL"
|
||||
p "沒逾期也不是主線的 Arcrun#8 不在清單裡" "False" "'inkstone/Arcrun#8' in out" "$ALL"
|
||||
p "backlog(ISEP#4)/沒里程碑(ISEP#5)不在清單裡" "False" "'ISEP#4' in out or 'ISEP#5' in out" "$ALL"
|
||||
p "沒有任何一條真的走到網路(api 全被錄音機接住)" "True" "all(p.startswith('/') for _,p,_ in rec)" "$ALL"
|
||||
p "pick 只讀不寫(沒有 POST/PATCH/PUT)" "True" "all(mm=='GET' for mm,_,_ in rec)" "$ALL"
|
||||
p "訊息印出下一步(認領那一行用絕對路徑,貼到別的 cwd 也叫得到正本)" "True" "'claim inkstone/Arcrun#6' in out and m.self_path() in out" "$ARGS"
|
||||
|
||||
echo
|
||||
echo " (主線抓盡 → 才輪到逾期里程碑)"
|
||||
export DROP='["Arcrun#6","ISEP#1"]'
|
||||
p "★ 主線兩張都被拿走後 → 回逾期里程碑的 Arcrun#7" "True" "'inkstone/Arcrun#7' in out and rc is None" "$ARGS"
|
||||
export DROP='["Arcrun#6","ISEP#1","Arcrun#7"]'
|
||||
p "★ 主線與逾期都抓盡 → 離開碼 1(真的沒有,不是故障)" "1" "rc" "$ARGS"
|
||||
p "沒帶 --pool 時訊息明講「這一趟沒有看票池」並給出 --pool 這條路(ISEP#130:不再說成正常收工)" "True" \
|
||||
"'沒有看票池' in out and '--pool' in out and '不是故障' not in out" "$ARGS"
|
||||
p "沒帶 --pool、主線與逾期都抓盡 → 票池一通都沒讀(本機總管不會因此被點名去抓票池)" "False" \
|
||||
"any('page=' in p for _,p,_ in rec)" "$ARGS"
|
||||
p "抓盡時**沒有**回 Arcrun#8(未來里程碑)" "False" "'Arcrun#8' in out" "$ARGS"
|
||||
unset DROP
|
||||
|
||||
echo
|
||||
echo " (沒有主線 ≠ 去抓別的)"
|
||||
NOML="[\"cmd_pick\",\"--mainline\",\"$TMP/none.json\"]"
|
||||
p "主線檔讀不到 → 離開碼 2(讀不到 ≠ 沒有)" "2" "rc" "$NOML"
|
||||
p "…而且一通 API 都沒打" "0" "len(rec)" "$NOML"
|
||||
p "沒給 --mainline、狀態檔也沒有、cwd 也沒有 → 離開碼 2,訊息給出兩條出路" "True" \
|
||||
"rc==2 and 'mainline set' in out and 'system-dev/mainline.json' in out" "[\"cmd_pick\"]"
|
||||
|
||||
echo
|
||||
echo "── ③ claim:一個動作三件事 ──"
|
||||
CL="[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--name\",\"isep-hand\"]"
|
||||
p "★ 指派給本 token 的使用者(欄位,不是一句話)" "[['claude-code']]" \
|
||||
"[pl['assignees'] for mm,p,pl in rec if mm=='PATCH' and p=='/repos/inkstone/Arcrun/issues/6']" "$CL"
|
||||
p "★ s/todo 換成 s/doing,p/high 留著(不是整組蓋掉)" "True" \
|
||||
"[sorted(pl['labels'])==[2,4] for mm,p,pl in rec if mm=='PUT' and p.endswith('/issues/6/labels')]==[True]" "$CL"
|
||||
p "★ 留言第一行是【身份】isep-hand/inkstone/Arcrun/-" "True" \
|
||||
"[pl['body'].split(chr(10))[0] for mm,p,pl in rec if mm=='POST' and p.endswith('/issues/6/comments')]==['【身份】isep-hand/inkstone/Arcrun/-']" "$CL"
|
||||
p "留言講了做完要走 handback(不推 main、不出貨)" "True" \
|
||||
"any('handback' in pl['body'] and '不推 main' in pl['body'] for mm,p,pl in rec if mm=='POST' and p.endswith('/comments'))" "$CL"
|
||||
p "三件事都做了,離開碼 0" "None" "rc" "$CL"
|
||||
p "--as 指定登入名時用它" "[['cloud-bot']]" \
|
||||
"[pl['assignees'] for mm,p,pl in rec if mm=='PATCH']" "[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--as\",\"cloud-bot\"]"
|
||||
p "逾期里程碑的票(Arcrun#7)也認領得了——主線抓盡時輪到它" "None" "rc" \
|
||||
"[\"cmd_claim\",\"inkstone/Arcrun#7\",\"--mainline\",\"$MAIN\"]"
|
||||
p "pick --claim:挑到誰就認領誰(Arcrun#6)" "True" \
|
||||
"any(mm=='PATCH' and p.endswith('/issues/6') for mm,p,pl in rec)" "[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--claim\",\"--name\",\"isep-hand\"]"
|
||||
|
||||
echo
|
||||
echo " (該擋的:規則在 claim 這一端再驗一次,擋在**寫入之前**)"
|
||||
blocked() { # blocked <說明> <ref> [extra argv...]
|
||||
what=$1; ref=$2; shift 2
|
||||
extra=$(printf '%s\n' "$@" | python3 -c "import sys,json;print(','.join(json.dumps(l.rstrip()) for l in sys.stdin if l.strip()))")
|
||||
argv="[\"cmd_claim\",\"$ref\",\"--mainline\",\"$MAIN\"${extra:+,$extra}]"
|
||||
p "$what → 離開碼 2,且沒有任何寫入" "True" \
|
||||
"rc==2 and not any(mm in ('PATCH','PUT','POST') for mm,_,_ in rec)" "$argv"
|
||||
}
|
||||
blocked "掛 Human 的 ISEP#2" inkstone/ISEP#2
|
||||
blocked "已指派的 ISEP#3" inkstone/ISEP#3
|
||||
blocked "backlog 的 ISEP#4" inkstone/ISEP#4
|
||||
blocked "沒逾期也不是主線的 Arcrun#8" inkstone/Arcrun#8
|
||||
blocked "有人在做的 mira#9(s/doing 也掛著)" inkstone/mira#9
|
||||
blocked "掛在 ISEP 同名里程碑、沒有相依邊的 ISEP#13(同名不是主線)" inkstone/ISEP#13
|
||||
p "claim 靠相依進主線的 ISEP#1(沒里程碑)→ 過(留言寫「靠相依在主線上」)" "True" \
|
||||
"rc is None and any('靠相依' in pl['body'] for mm,p,pl in rec if mm=='POST' and p.endswith('/comments'))" \
|
||||
"[\"cmd_claim\",\"inkstone/ISEP#1\",\"--mainline\",\"$MAIN\"]"
|
||||
p "擋下時訊息講得出理由與出路(pick)" "True" "'Human' in out and 'pick' in out" \
|
||||
"[\"cmd_claim\",\"inkstone/ISEP#2\",\"--mainline\",\"$MAIN\"]"
|
||||
p "身份名不在名單上 → 在打任何 API 之前就擋(一通都沒打)" "True" "rc==2 and len(rec)==0" \
|
||||
"[\"cmd_claim\",\"inkstone/Arcrun#6\",\"--mainline\",\"$MAIN\",\"--name\",\"路人\"]"
|
||||
|
||||
echo
|
||||
echo "── ⑤ 票池那一層(inkstone/ISEP#130 c7801:幾百張 open 票,工人卻回「沒事做」)──"
|
||||
POOLA="[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--pool\"]"
|
||||
POOLALL="[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--pool\",\"--all\",\"--json\"]"
|
||||
p "pickable:s/backlog 在票池那一層可抓" "True" "m.pickable(I('ISEP',4,['s/backlog'],None),None,None,m.POOL_STATUSES)[0]"
|
||||
p "pickable:s/triage 在票池那一層仍不可抓(要不要做還沒決定)" "False" "m.pickable(I('x',1,['s/triage'],None),None,None,m.POOL_STATUSES)[0]"
|
||||
p "pickable:沒有 s/* 仍不可抓,理由講得出「沒有 s/*」" "True" "'沒有 s/*' in m.pickable(I('x',1,[],None),None,None,m.POOL_STATUSES)[1]"
|
||||
p "pickable:票池裡的 backlog 掛 Human → 不可抓(不該碰的一格都沒放寬)" "False" "m.pickable(I('x',1,['s/backlog','Human'],None),None,None,m.POOL_STATUSES)[0]"
|
||||
p "pickable:票池裡指派給 Leo → 不可抓" "False" "m.pickable(I('x',1,['s/todo'],None,assignees=('Leo',)),None,'claude-code',m.POOL_STATUSES)[0]"
|
||||
p "順序:同優先序 s/todo 先於 s/backlog" "True" \
|
||||
"m.pick_order(I('a',9,['s/todo'],None)) < m.pick_order(I('a',1,['s/backlog'],None))"
|
||||
p "主線有票時帶 --pool 也照樣先回主線的 Arcrun#6,票池一通都沒讀(能省就省)" "True" \
|
||||
"'inkstone/Arcrun#6' in out and not any('page=' in p for _,p,_ in rec)" "$POOLA"
|
||||
export DROP='["Arcrun#6","ISEP#1","Arcrun#7"]'
|
||||
p "★ 主線與逾期都抓盡、帶 --pool → 回票池裡最優先的 mira#25(p/high 的 backlog)" "True" \
|
||||
"'inkstone/mira#25' in out and rc is None" "$POOLA"
|
||||
p "★ --all:票池可抓的恰好是這幾張,照 p/high → s/todo → s/backlog 排(Human/指派 Leo/triage/沒 s/*/review/s/doing/hub/PR 都不在)" \
|
||||
"['inkstone/mira#25', 'inkstone/ISEP#5', 'inkstone/Arcrun#8', 'inkstone/ISEP#13', 'inkstone/ISEP#4', 'inkstone/big#31']" \
|
||||
"[r['ref'] for r in json.loads(out) if r['group']=='票池']" "$POOLALL"
|
||||
p "★ 分頁:伺服器每頁只給 30 張時,第 31 張(big#31)照樣讀得到" "True" "'inkstone/big#31' in out" "$POOLALL"
|
||||
p "票池那一層也只讀不寫" "True" "all(mm=='GET' for mm,_,_ in rec)" "$POOLALL"
|
||||
export ISEP_DRAIN=1
|
||||
p "★ 環境 ISEP_DRAIN=1(雲端工人的環境)= 帶 --pool,指令不必改" "True" \
|
||||
"'inkstone/mira#25' in out and rc==0" "[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--json\"]"
|
||||
unset ISEP_DRAIN
|
||||
unset DROP
|
||||
export DROP='["Arcrun#6","ISEP#1","Arcrun#7","mira#25","Arcrun#8","ISEP#13","ISEP#5","ISEP#4","big#31"]'
|
||||
p "★ 票池抓盡、但還有沒 s/* 的票 → 回一張待分診的(同一天開的,票號序第一的 big#1),下一步是 triage 不是 claim(ISEP#159)" "True" \
|
||||
"rc is None and 'inkstone/big#1' in out and ' triage inkstone/big#1 ' in out and 'claim inkstone/big#1' not in out" "$POOLA"
|
||||
p "--json:待分診那一列帶 group=待分診 與貼了就能跑的 next(佇列閘照它印)" "True" \
|
||||
"(lambda r: r['group']=='待分診' and ' triage ' in r['next'])(json.loads(out))" \
|
||||
"[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--pool\",\"--json\"]"
|
||||
p "待分診那一層不回掛 Human 的 mira#27(沒 s/* 也不碰)" "False" "'mira#27' in out" "$POOLALL"
|
||||
p "pick --claim 遇到待分診的票 → 不認領(沒有任何寫入)" "True" \
|
||||
"not any(mm in ('PATCH','PUT','POST') for mm,_,_ in rec)" \
|
||||
"[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--pool\",\"--claim\"]"
|
||||
export DROP='["Arcrun#6","ISEP#1","Arcrun#7","mira#25","Arcrun#8","ISEP#13","ISEP#5","ISEP#4","big#31","mira#21"'"$(python3 -c 'print("".join(",\"big#%d\"" % n for n in range(1,31)))')"']'
|
||||
p "★ 票池與待分診都抓盡 → 離開碼 1" "1" "rc" "$POOLA"
|
||||
p "★ 可抓 0 張時列出池子大小與「不能抓的理由」各幾張(沒 s/* 又掛 Human 的被點名),而且不說成「正常/不是故障」" "True" \
|
||||
"'open 票池' in out and '沒有 s/*' in out and '不是故障' not in out and '正常' not in out" "$POOLA"
|
||||
p "--json 抓盡時同一張理由表寫到 stderr(stdout 仍是 null,佇列閘照舊解析得動)" "True" \
|
||||
"out.startswith('null') and '不能抓' not in out.split(chr(10))[0] and '沒有 s/*' in out" \
|
||||
"[\"cmd_pick\",\"--mainline\",\"$MAIN\",\"--pool\",\"--json\"]"
|
||||
unset DROP
|
||||
|
||||
echo
|
||||
echo " (認領票池裡的票)"
|
||||
CLP="[\"cmd_claim\",\"inkstone/ISEP#4\",\"--mainline\",\"$MAIN\",\"--name\",\"isep-hand\",\"--pool\"]"
|
||||
p "★ --pool 認領 backlog 的 ISEP#4 → 過,s/backlog 換成 s/doing" "True" \
|
||||
"rc is None and [sorted(pl['labels']) for mm,p,pl in rec if mm=='PUT' and p.endswith('/issues/4/labels')]==[[2]]" "$CLP"
|
||||
p "認領留言寫明是從 open 票池抓的、狀態從 s/backlog 變 s/doing" "True" \
|
||||
"any('open 票池' in pl['body'] and 's/backlog → s/doing' in pl['body'] for mm,p,pl in rec if mm=='POST' and p.endswith('/comments'))" "$CLP"
|
||||
blocked "--pool 也擋掛 Human 的 mira#23" inkstone/mira#23 --pool
|
||||
blocked "--pool 也擋指派 Leo 的 mira#22" inkstone/mira#22 --pool
|
||||
blocked "--pool 也擋 s/triage 的 mira#20" inkstone/mira#20 --pool
|
||||
blocked "--pool 也擋沒有 s/* 的 mira#21" inkstone/mira#21 --pool
|
||||
|
||||
echo
|
||||
echo "── ④ 動詞接得上(不帶假池子,走到網路才炸)──"
|
||||
out=$(python3 "$T" pick --mainline "$MAIN" 2>&1); got=$?
|
||||
if [ "$got" = 2 ] && printf '%s' "$out" | grep -q '拿不到 Gitea'; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=2 got=%s pick 連不上 Gitea → 離開碼 2 並說「讀不到 ≠ 沒有」(不是離開碼 1 的假「沒票」)\n' "$got"
|
||||
out=$(python3 "$T" 2>&1)
|
||||
if printf '%s' "$out" | grep -q "ticket pick" && printf '%s' "$out" | grep -q "ticket claim"; then PASS=$((PASS+1)); printf ' ✅ '
|
||||
else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf '不帶參數的說明裡列得出 pick/claim(撈不到的東西等於不存在)\n'
|
||||
out=$(python3 "$T" usage pick 2>&1); got=$?
|
||||
if [ "$got" = 0 ] && printf '%s' "$out" | grep -q -- '--mainline'; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'ticket usage pick 印得出用法\n'
|
||||
|
||||
echo
|
||||
echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" = 0 ] || exit 1
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# 「收件 repo 寫 owner/repo 不該是 404」的測試(inkstone/ISEP#130)
|
||||
#
|
||||
# 2026-09-07 實撞:`scripts/ticket new inkstone/ISEP -F …` 回 Gitea 404。
|
||||
# 不是 ISEP 不存在,是這支把 `inkstone/ISEP` 原樣塞進 `/repos/inkstone/{repo}/issues`
|
||||
# ⇒ 打到 `/repos/inkstone/inkstone/ISEP/issues`。404 讀起來像「repo 不存在」,
|
||||
# 於是那天四張票全改成直接打 API 開——**正門壞了人就走側門**。
|
||||
#
|
||||
# 怎麼在不開任何票的前提下驗:把 `scripts/ticket` 當模組載進來,
|
||||
# 把 `api()` 換成一支只記錄路徑的假貨。全程離線、不打 Gitea、不留戳記。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
STAMP=/tmp/.ticket-where-ok
|
||||
SAVED=""; [ -f "$STAMP" ] && SAVED=$(cat "$STAMP")
|
||||
trap '[ -n "$SAVED" ] && printf "%s" "$SAVED" > "$STAMP" || rm -f "$STAMP"' EXIT
|
||||
|
||||
TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x python3 - <<'PY'
|
||||
import importlib.machinery, importlib.util, io, json, os, sys, tempfile, time, contextlib
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("isep_ticket", "scripts/ticket")
|
||||
spec = importlib.util.spec_from_file_location("isep_ticket", "scripts/ticket", loader=loader)
|
||||
T = importlib.util.module_from_spec(spec); loader.exec_module(T)
|
||||
|
||||
CALLS = []
|
||||
def fake_api(path, payload=None, method=None):
|
||||
CALLS.append((method or ("POST" if payload is not None else "GET"), path))
|
||||
if path.endswith("/labels?limit=100") or path.endswith("/labels?limit=60"):
|
||||
return [{"name": "s/todo", "id": 1}, {"name": "s/doing", "id": 2}]
|
||||
if path.endswith("/issues") and payload is not None:
|
||||
return {"number": 999, "html_url": "http://fake/999", "labels": []}
|
||||
if "/comments" in path:
|
||||
return {"id": 1, "html_url": "http://fake/c1"}
|
||||
if "/dependencies" in path or "/blocks" in path:
|
||||
return [] if payload is None else {}
|
||||
return {"labels": [], "number": 1}
|
||||
T.api = fake_api
|
||||
T.api_soft = lambda path: None
|
||||
|
||||
tmp = tempfile.mkdtemp()
|
||||
body = os.path.join(tmp, "body.md")
|
||||
open(body, "w").write("## 目標\n測試。\n\n## 驗收條件\n離線。\n\n## deliverable 類型\ncode\n")
|
||||
TITLE = "身為要開票的人,我要寫 owner/repo 也開得出票,我才不會被 404 逼去走側門"
|
||||
|
||||
def stamp():
|
||||
json.dump({"at": time.time(), "kws": ["x"], "n": 0, "top": [], "top_detail": [], "shown": True},
|
||||
open(T.stamp_path(), "w"))
|
||||
|
||||
PASS = FAIL = 0
|
||||
def ok(m, *_):
|
||||
global PASS; PASS += 1; print(" ✅ " + m)
|
||||
def bad(m, why="", *_):
|
||||
global FAIL; FAIL += 1; print(" ❌ " + m + (" —— " + why if why else ""))
|
||||
|
||||
def run(fn, argv):
|
||||
"""回 (離開碼, stderr)。die() 走 sys.exit(2)。"""
|
||||
CALLS.clear()
|
||||
err = io.StringIO(); out = io.StringIO()
|
||||
with contextlib.redirect_stderr(err), contextlib.redirect_stdout(out):
|
||||
try:
|
||||
fn(argv); rc = 0
|
||||
except SystemExit as e:
|
||||
rc = e.code if isinstance(e.code, int) else 1
|
||||
return rc, err.getvalue() + out.getvalue()
|
||||
|
||||
print("── new:兩種寫法都要打到同一條路 ──")
|
||||
stamp(); rc, msg = run(T.cmd_new, ["ISEP", "-F", body, "--title", TITLE])
|
||||
posts = [p for m, p in CALLS if m == "POST"]
|
||||
(ok if posts == ["/repos/inkstone/ISEP/issues"] else bad)("① `new ISEP` → /repos/inkstone/ISEP/issues", str(CALLS))
|
||||
|
||||
stamp(); rc, msg = run(T.cmd_new, ["inkstone/ISEP", "-F", body, "--title", TITLE])
|
||||
posts = [p for m, p in CALLS if m == "POST"]
|
||||
(ok if posts == ["/repos/inkstone/ISEP/issues"] else bad)("② `new inkstone/ISEP`(09-07 撞 404 的那一句)→ 同一條路,不再是 /repos/inkstone/inkstone/ISEP", str(CALLS))
|
||||
|
||||
print("── new:寫錯要當場講,不要變成一個 404 讓人猜 ──")
|
||||
stamp(); rc, msg = run(T.cmd_new, ["Leo/ISEP", "-F", body, "--title", TITLE])
|
||||
(ok if rc == 2 and not CALLS and "inkstone" in msg else bad)("③ `new Leo/ISEP` → 擋(exit 2)、一通 API 都不打、訊息說 org 是 inkstone", f"rc={rc} calls={CALLS} msg={msg[:120]}")
|
||||
|
||||
stamp(); rc, msg = run(T.cmd_new, ["inkstone/ISEP#130", "-F", body, "--title", TITLE])
|
||||
(ok if rc == 2 and not CALLS else bad)("④ `new inkstone/ISEP#130`(把票號當 repo)→ 擋,不打 API", f"rc={rc} calls={CALLS}")
|
||||
|
||||
print("── subtask:--to 也吃 owner/repo ──")
|
||||
rc, msg = run(T.cmd_subtask, ["inkstone/ISEP#130", "--title", TITLE, "-F", body, "--to", "inkstone/Arcrun"])
|
||||
posts = [p for m, p in CALLS if m == "POST" and p.endswith("/issues")]
|
||||
(ok if posts == ["/repos/inkstone/Arcrun/issues"] else bad)("⑤ `subtask … --to inkstone/Arcrun` → 子票開在 /repos/inkstone/Arcrun/issues", f"rc={rc} calls={CALLS} msg={msg[:200]}")
|
||||
|
||||
rc, msg = run(T.cmd_subtask, ["inkstone/ISEP#130", "--title", TITLE, "-F", body, "--to", "Arcrun"])
|
||||
posts = [p for m, p in CALLS if m == "POST" and p.endswith("/issues")]
|
||||
(ok if posts == ["/repos/inkstone/Arcrun/issues"] else bad)("⑥ `subtask … --to Arcrun`(原本的寫法)照舊", f"rc={rc} calls={CALLS}")
|
||||
|
||||
print("── 沒有任何東西真的出去 ──")
|
||||
(ok if not os.path.exists(T.stamp_path()) or True else bad)("⑦ 假 api 記到的路徑全部是 /repos/inkstone/<repo>/…,沒有一條含 inkstone/inkstone")
|
||||
if any("inkstone/inkstone" in p for _, p in CALLS): FAIL += 1; PASS -= 1; print(" ❌ ⑦ 出現 inkstone/inkstone")
|
||||
|
||||
print(f"\n通過 {PASS} 條,失敗 {FAIL} 條")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
PY
|
||||
Executable
+300
@@ -0,0 +1,300 @@
|
||||
#!/bin/bash
|
||||
# 雲端工人「分診+組里程碑」的測試(inkstone/ISEP#159)
|
||||
#
|
||||
# 票上的驗收:
|
||||
# 1. 沒狀態的票數下降,每張被動過的票上都有一則說明(分到哪、為什麼;重複的指向哪張)
|
||||
# 2. 提出新里程碑:載體票在 InkStoneCo、執行票掛成前置、名字不是版本號、有期限、deliverable=版本
|
||||
# 3. 不碰 Human、不碰指派給 Leo 的票
|
||||
# 4. 組了之後照里程碑往下做(pick --pool 先回它的前置票)
|
||||
#
|
||||
# 全程離線:api() 換成錄音機餵假池子;TICKET_HOST 指到連不上的位址,漏接一條會當場炸,不會假綠。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
T=scripts/ticket
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
PASS=0; FAIL=0
|
||||
export TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x ISEP_MEMBER_BACKOFF=0
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
# 時鐘定在 2026-09-18 00:00 UTC
|
||||
export ISEP_COUNTDOWN_NOW=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,9,18,0,0,tzinfo=d.timezone.utc).timestamp()))')
|
||||
unset ISEP_MAINLINE_FILE ISEP_IDENTITY CLAUDE_PROJECT_DIR ISEP_DRAIN
|
||||
export ISEP_WORKER_LOGIN=claude-code # 抓票者自己是誰(真跑時問 /user;離線測試不打網路)
|
||||
|
||||
MAIN="$TMP/mainline.json"
|
||||
cat > "$MAIN" <<'JSON'
|
||||
{"ref":"inkstone/InkStoneCo#65","owner":"inkstone","repo":"InkStoneCo","id":65,
|
||||
"title":"主線 M","due_on":"2026-09-25T23:59:59Z","members":[]}
|
||||
JSON
|
||||
|
||||
cat > "$TMP/pool.py" <<'PY'
|
||||
import json, os, re, urllib.parse
|
||||
MS = {"main": {"id": 65, "title": "主線 M", "due_on": "2026-09-25T23:59:59Z", "state": "open"},
|
||||
"mine": {"id": 70, "title": "組好的 M", "due_on": "2026-09-30T23:59:59Z", "state": "open"},
|
||||
"sub": {"id": 9, "title": "子 repo 的舊里程碑", "due_on": "2026-10-30T23:59:59Z", "state": "open"}}
|
||||
def I(repo, n, labels, ms=None, assignees=(), created="2026-09-01T00:00:00Z", pr=False, state="open", title=None):
|
||||
d = {"number": n, "title": title or f"票 {repo}#{n}", "state": state,
|
||||
"labels": [{"name": l} for l in labels],
|
||||
"assignees": [{"login": a} for a in assignees],
|
||||
"milestone": MS[ms] if ms else None, "created_at": created,
|
||||
"repository": {"full_name": f"inkstone/{repo}"}}
|
||||
if pr: d["pull_request"] = {"merged": False}
|
||||
return d
|
||||
POOL = {
|
||||
"InkStoneCo": [I("InkStoneCo", 55, [], None, title="身為一天只看兩眼的人…"), # ★ 載體候選:沒里程碑、沒人
|
||||
I("InkStoneCo", 132, ["Human", "s/pending"], "main", assignees=("Leo",)), # 主線載體(Leo 的)
|
||||
I("InkStoneCo", 80, ["s/backlog"], "mine"), # 組好的 M 的載體(有 s/* 也不是任務)
|
||||
I("InkStoneCo", 81, ["Human"], None), # 掛 Human 的載體候選
|
||||
I("InkStoneCo", 82, [], "main"),
|
||||
I("InkStoneCo", 83, [], None, assignees=("Leo",)), # 指派 Leo 的載體候選
|
||||
I("InkStoneCo", 84, ["s/backlog"], None)], # 已是「組好的 M」載體的前置——不能再當載體 # 已掛在主線里程碑的載體候選
|
||||
"ISEP": [I("ISEP", 1, [], None, created="2026-08-01T00:00:00Z"), # ★ 待分診(最舊)
|
||||
I("ISEP", 2, [], None), # 待分診
|
||||
I("ISEP", 3, ["Human"], None), # 沒 s/*、掛 Human:不碰
|
||||
I("ISEP", 4, [], None, assignees=("Leo",)), # 沒 s/*、指派 Leo:不碰
|
||||
I("ISEP", 5, ["s/backlog", "type/bug"], None), # ★ 已分診:可組進里程碑
|
||||
I("ISEP", 6, ["s/triage"], None), # 還沒決定:不可組
|
||||
I("ISEP", 7, ["s/backlog"], None), # 已是組好的 M 的前置
|
||||
I("ISEP", 8, ["s/backlog"], "sub"), # 掛著子 repo 里程碑
|
||||
I("ISEP", 9, [], None, pr=True), # PR
|
||||
I("ISEP", 10, [], None, assignees=("claude-code",))], # 只指派給自己:可分診
|
||||
"Arcrun": [I("Arcrun", 20, ["s/todo"], None), # ★ 已分診:可組進里程碑
|
||||
I("Arcrun", 21, ["s/todo"], None), # 組好的 M 的前置(pick 用)
|
||||
I("Arcrun", 22, ["s/backlog", "Human"], None), # Human 前置:不碰
|
||||
I("Arcrun", 23, ["s/backlog"], None, assignees=("Leo",))], # 指派 Leo 的前置:不碰 # Human 前置:不碰
|
||||
}
|
||||
MSN = {"InkStoneCo": ["main", "mine"], "ISEP": ["sub"]}
|
||||
DEPS = {"InkStoneCo#80": ["ISEP#7", "Arcrun#21", "InkStoneCo#84"]}
|
||||
def _by_ref(ref):
|
||||
repo, n = ref.split("#")
|
||||
return next(i for i in POOL[repo] if i["number"] == int(n))
|
||||
BLOCKS = {}
|
||||
for _p, _cs in DEPS.items():
|
||||
for _c in _cs:
|
||||
BLOCKS.setdefault(_c, []).append(_p)
|
||||
LABELS = ["s/triage", "s/backlog", "s/todo", "s/doing", "s/review", "s/pending", "close/duplicate",
|
||||
"Human", "type/bug", "p/high"]
|
||||
DROP = set(json.loads(os.environ.get("DROP", "[]")))
|
||||
rec = []
|
||||
def fake_api(path, payload=None, method=None):
|
||||
verb = method or ("POST" if payload is not None else "GET")
|
||||
rec.append((verb, path, payload))
|
||||
if path.startswith("/orgs/inkstone/repos"):
|
||||
return [{"name": r} for r in POOL]
|
||||
if path == "/user":
|
||||
return {"login": "claude-code"}
|
||||
if path.endswith("/labels?limit=100"):
|
||||
return [{"name": n, "id": k} for k, n in enumerate(LABELS, 1)]
|
||||
if "/milestones" in path:
|
||||
repo = path.split("/")[3]
|
||||
if verb == "POST":
|
||||
return {"id": 71, "title": payload["title"]}
|
||||
return [MS[k] for k in MSN.get(repo, [])]
|
||||
if "/issues?" in path:
|
||||
repo = path.split("/")[3]
|
||||
q = urllib.parse.parse_qs(path.split("?", 1)[1])
|
||||
rows = [i for i in POOL[repo] if f"{repo}#{i['number']}" not in DROP]
|
||||
if "milestones" in q:
|
||||
want = q["milestones"][0]
|
||||
return [i for i in rows if (i["milestone"] or {}).get("title") == want]
|
||||
rows = [i for i in rows if i["state"] == "open"]
|
||||
page = int(q.get("page", ["1"])[0]); per = int(q.get("limit", ["50"])[0])
|
||||
return rows[(page - 1) * per: page * per]
|
||||
m = re.match(r"/repos/inkstone/([\w-]+)/issues/(\d+)/(dependencies|blocks)(\?.*)?$", path)
|
||||
if m:
|
||||
if verb == "POST":
|
||||
return {}
|
||||
table = DEPS if m.group(3) == "dependencies" else BLOCKS
|
||||
return [_by_ref(r) for r in table.get(f"{m.group(1)}#{m.group(2)}", [])]
|
||||
m = re.match(r"/repos/inkstone/([\w-]+)/issues/(\d+)$", path)
|
||||
if m and verb == "GET":
|
||||
return _by_ref(f"{m.group(1)}#{m.group(2)}")
|
||||
return {}
|
||||
PY
|
||||
export POOL_PY="$TMP/pool.py"
|
||||
|
||||
probe() {
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import sys, os, json, io, contextlib, importlib.util as u
|
||||
from importlib.machinery import SourceFileLoader as L
|
||||
spec = u.spec_from_loader("t", L("t", "scripts/ticket"))
|
||||
m = u.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
ns = {}
|
||||
exec(open(os.environ["POOL_PY"]).read(), ns)
|
||||
m.api = ns["fake_api"]; rec = ns["rec"]
|
||||
argv = json.loads(sys.argv[2]) if sys.argv[2] else None
|
||||
buf = io.StringIO(); rc = None
|
||||
if argv is not None:
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||||
try:
|
||||
getattr(m, argv[0])(argv[1:])
|
||||
except SystemExit as e:
|
||||
rc = e.code
|
||||
W = [(mm, p, pl) for mm, p, pl in rec if mm in ("POST", "PATCH", "PUT", "DELETE")]
|
||||
print(eval(sys.argv[1], {"m": m, "rec": rec, "W": W, "rc": rc, "out": buf.getvalue(), "I": ns["I"], "json": json}))
|
||||
PY
|
||||
}
|
||||
p() { # p <說明> <want> <python 片段> [argv json]
|
||||
got=$(probe "$3" "${4:-}" 2>&1)
|
||||
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
|
||||
}
|
||||
j() { python3 -c 'import json,sys;print(json.dumps(sys.argv[1:],ensure_ascii=False))' "$@"; }
|
||||
|
||||
echo "── ① 規則本體(純函式)──"
|
||||
p "沒 s/*、沒人、沒 Human → 待分診" "True" "m.triageable(I('ISEP',1,[]),'claude-code')[0]"
|
||||
p "★ 掛 Human → 不碰(驗收 3)" "False" "m.triageable(I('ISEP',3,['Human']),'claude-code')[0]"
|
||||
p "human/exec 同組 → 不碰" "False" "m.triageable(I('ISEP',3,['human/exec']),'claude-code')[0]"
|
||||
p "★ 指派給 Leo → 不碰(驗收 3)" "False" "m.triageable(I('ISEP',4,[],assignees=('Leo',)),'claude-code')[0]"
|
||||
p "只指派給自己 → 可分診" "True" "m.triageable(I('ISEP',10,[],assignees=('claude-code',)),'claude-code')[0]"
|
||||
p "不知道自己是誰、票有指派 → 不碰(不猜)" "False" "m.triageable(I('ISEP',10,[],assignees=('claude-code',)),None)[0]"
|
||||
p "已經有 s/*(連 s/triage 也算)→ 不是待分診" "False" "m.triageable(I('ISEP',6,['s/triage']),'claude-code')[0]"
|
||||
p "PR → 不是票" "False" "m.triageable(I('ISEP',9,[],pr=True),'claude-code')[0]"
|
||||
p "已關 → 不碰" "False" "m.triageable(I('ISEP',1,[],state='closed'),'claude-code')[0]"
|
||||
p "InkStoneCo 裡掛在 open 里程碑的票(載體)→ 不是待分診(它是容器)" "False" "m.triageable(I('InkStoneCo',82,[],'main'),'claude-code')[0]"
|
||||
p "別的 repo 掛著里程碑、沒 s/* → 照樣待分診(載體只認 InkStoneCo)" "True" "m.triageable(I('ISEP',8,[],'sub'),'claude-code')[0]"
|
||||
p "名字以版本號開頭(1.4.52 雲端零件包)→ 算版本號名字" "True" "m._version_like('1.4.52 雲端零件包')"
|
||||
p "v 開頭也算" "True" "m._version_like('v0.30 什麼')"
|
||||
p "名字裡提到版本但不是開頭(封測不再斷,含 1.4 修正)→ 不算(誤攔比漏擋嚴重)" "False" "m._version_like('封測不再斷,含 1.4 修正')"
|
||||
|
||||
echo
|
||||
echo "── ② triage:一個動作=說明+欄位(驗收 1)──"
|
||||
TR=$(j cmd_triage inkstone/ISEP#1 --to s/backlog --why "還在用、要修" --name isep-hand)
|
||||
p "★ 先留說明、再改標籤:寫入恰好是 [留言, 改標籤]" "[('POST', '/repos/inkstone/ISEP/issues/1/comments'), ('PUT', '/repos/inkstone/ISEP/issues/1/labels')]" \
|
||||
"[(mm,p) for mm,p,_ in W]" "$TR"
|
||||
p "★ 說明第一行是【身份】,內文講得出分到哪、為什麼" "True" \
|
||||
"(lambda b: b.split(chr(10))[0]=='【身份】isep-hand/inkstone/ISEP/-' and 's/backlog' in b and '還在用、要修' in b)(W[0][2]['body'])" "$TR"
|
||||
p "標籤換成 s/backlog(id 2)" "[2]" "W[1][2]['labels']" "$TR"
|
||||
p "離開碼 0" "None" "rc" "$TR"
|
||||
TD=$(j cmd_triage inkstone/ISEP#2 --dup-of inkstone/ISEP#5 --why "同一件事" --name isep-hand)
|
||||
p "★ 重複:留說明→標 close/duplicate→關→在留下的那張留一則指回來" \
|
||||
"[('POST', '/repos/inkstone/ISEP/issues/2/comments'), ('PUT', '/repos/inkstone/ISEP/issues/2/labels'), ('PATCH', '/repos/inkstone/ISEP/issues/2'), ('POST', '/repos/inkstone/ISEP/issues/5/comments')]" \
|
||||
"[(mm,p) for mm,p,_ in W]" "$TD"
|
||||
p "★ 重複那則說明指向留下的那張(驗收 1:重複的指向哪張)" "True" "'inkstone/ISEP#5' in W[0][2]['body']" "$TD"
|
||||
p "關的是 state=closed、標的是 close/duplicate(id 7)" "True" "W[2][2]=={'state':'closed'} and W[1][2]['labels']==[7]" "$TD"
|
||||
TDL=$(j cmd_triage inkstone/ISEP#2 --dup-of inkstone/InkStoneCo#132 --why "同一件事")
|
||||
p "★ 重複的對象是 Leo 的票 → 這張照關,但**不在 Leo 的票上寫任何字**(驗收 3)" "True" \
|
||||
"rc is None and not any('/InkStoneCo/' in p for _,p,_ in W) and '沒在' in out" "$TDL"
|
||||
p "只指派給自己的 ISEP#10 → 分診得了" "None" "rc" "$(j cmd_triage inkstone/ISEP#10 --to s/triage --why 要總管看)"
|
||||
|
||||
echo
|
||||
echo " (該擋的:擋下時一筆都沒寫)"
|
||||
nb() { # nb <說明> <argv json> [要在訊息裡看到的字]
|
||||
p "$1 → 離開碼 2、零寫入${3:+、訊息有「$3」}" "True" "rc==2 and not W${3:+ and '$3' in out}" "$2"
|
||||
}
|
||||
nb "★ 掛 Human 的 ISEP#3(驗收 3)" "$(j cmd_triage inkstone/ISEP#3 --to s/backlog --why x)" "掛著 Human"
|
||||
nb "★ 指派給 Leo 的 ISEP#4(驗收 3)" "$(j cmd_triage inkstone/ISEP#4 --to s/backlog --why x)" "指派給 Leo"
|
||||
nb "已分診過的 ISEP#6" "$(j cmd_triage inkstone/ISEP#6 --to s/backlog --why x)" "已經分診過"
|
||||
nb "PR" "$(j cmd_triage inkstone/ISEP#9 --to s/backlog --why x)"
|
||||
p "triage --sign cloud-worker ⇒ 說明內文署 [cloud-worker]" "True" "'[cloud-worker]' in W[0][2]['body']" \
|
||||
"$(j cmd_triage inkstone/ISEP#1 --to s/backlog --why x --sign cloud-worker)"
|
||||
nb "沒寫 --why(說明是驗收 1 的本體)" "$(j cmd_triage inkstone/ISEP#1 --to s/backlog)" "--why"
|
||||
nb "--to s/todo(要經 compose 排進里程碑才給)" "$(j cmd_triage inkstone/ISEP#1 --to s/todo --why x)" "compose"
|
||||
nb "--to 與 --dup-of 都給" "$(j cmd_triage inkstone/ISEP#1 --to s/backlog --dup-of inkstone/ISEP#5 --why x)"
|
||||
nb "--dup-of 指到自己" "$(j cmd_triage inkstone/ISEP#1 --dup-of inkstone/ISEP#1 --why x)"
|
||||
p "參數錯(沒 --why)→ 一通 API 都沒打" "0" "len(rec)" "$(j cmd_triage inkstone/ISEP#1 --to s/backlog)"
|
||||
p "身份名不在名單上 → 一通 API 都沒打" "True" "rc==2 and len(rec)==0" "$(j cmd_triage inkstone/ISEP#1 --to s/backlog --why x --name 路人)"
|
||||
|
||||
echo
|
||||
echo " (--list:工人拿什麼來判斷)"
|
||||
p "★ --list 列出待分診的票(InkStoneCo 還沒分診的也算),不列 Human/Leo/已分診/PR" \
|
||||
"['inkstone/ISEP#1', 'inkstone/ISEP#10', 'inkstone/ISEP#2', 'inkstone/InkStoneCo#55']" \
|
||||
"[r['ref'] for r in json.loads(out)]" "$(j cmd_triage --list --json)"
|
||||
p "--list --all 連已分診的 open 票也列(找重複要看全部),並標出哪些可分診" "True" \
|
||||
"(lambda rows: len(rows)>10 and {r['ref'] for r in rows if r['triageable']}=={'inkstone/ISEP#1','inkstone/ISEP#2','inkstone/ISEP#10','inkstone/InkStoneCo#55'})(json.loads(out))" \
|
||||
"$(j cmd_triage --list --all --json)"
|
||||
p "--list 文字版講得出「不碰」的有幾張,並給出下一步那一行" "True" \
|
||||
"'不碰' in out and ' triage inkstone/ISEP#1 ' in out" "$(j cmd_triage --list)"
|
||||
p "--list 只讀不寫" "True" "not W" "$(j cmd_triage --list)"
|
||||
|
||||
echo
|
||||
echo "── ③ compose:組里程碑(驗收 2)──"
|
||||
CP=$(j cmd_compose --title "雲端自己把票池消化成版本" --goal "leo 不必一張張叫它做" --due 2026-10-01 \
|
||||
--carrier inkstone/InkStoneCo#55 inkstone/ISEP#5 inkstone/Arcrun#20 --name isep-hand)
|
||||
p "★ 里程碑建在 inkstone/InkStoneCo(開工規約第 7 條)" "True" \
|
||||
"[p for mm,p,_ in W if mm=='POST' and p.endswith('/milestones')]==['/repos/inkstone/InkStoneCo/milestones']" "$CP"
|
||||
p "★ 有期限(23:59:59Z),不是 9999" "2026-10-01T23:59:59Z" \
|
||||
"[pl['due_on'] for mm,p,pl in W if p.endswith('/milestones')][0]" "$CP"
|
||||
p "★ 描述寫明這一輪要讓什麼變成真的、deliverable=總管驗過的新版本、載體與前置" "True" \
|
||||
"(lambda d: '這一輪要讓什麼變成真的:leo 不必' in d and 'deliverable=' in d and '新版本' in d and 'inkstone/InkStoneCo#55' in d and 'inkstone/ISEP#5' in d)([pl['description'] for mm,p,pl in W if p.endswith('/milestones')][0])" "$CP"
|
||||
p "★ 載體票掛上新里程碑(id 71)" "True" \
|
||||
"('PATCH','/repos/inkstone/InkStoneCo/issues/55',{'milestone':71}) in W" "$CP"
|
||||
p "★ 兩張執行票都掛成載體票的前置(/dependencies)" "True" \
|
||||
"[pl for mm,p,pl in W if mm=='POST' and p=='/repos/inkstone/InkStoneCo/issues/55/dependencies']==[{'owner':'inkstone','repo':'ISEP','index':5},{'owner':'inkstone','repo':'Arcrun','index':20}]" "$CP"
|
||||
p "★ s/backlog → s/todo(已排進里程碑,等人領票),type/bug 留著" "[3, 9]" \
|
||||
"sorted([pl['labels'] for mm,p,pl in W if mm=='PUT' and p=='/repos/inkstone/ISEP/issues/5/labels'][0])" "$CP"
|
||||
p "本來就 s/todo 的 Arcrun#20 不重貼標籤" "False" "any(p.endswith('/Arcrun/issues/20/labels') for _,p,_ in W)" "$CP"
|
||||
p "每張前置與載體都留了一則說明(第一行【身份】)" "True" \
|
||||
"sorted(p for mm,p,pl in W if mm=='POST' and p.endswith('/comments') and pl['body'].startswith('【身份】'))==['/repos/inkstone/Arcrun/issues/20/comments','/repos/inkstone/ISEP/issues/5/comments','/repos/inkstone/InkStoneCo/issues/55/comments']" "$CP"
|
||||
p "★ 不開任何新票(開工規約第 2 條)" "False" \
|
||||
"any(mm=='POST' and p.endswith('/issues') for mm,p,_ in W)" "$CP"
|
||||
p "離開碼 0,印出里程碑網址" "True" "rc is None and '/inkstone/InkStoneCo/milestone/71' in out" "$CP"
|
||||
CPS=$(j cmd_compose --title "雲端自己把票池消化成版本" --goal g --due 2026-10-01 --carrier inkstone/InkStoneCo#55 inkstone/ISEP#5 --sign cloud-worker)
|
||||
p "--sign cloud-worker ⇒ 每則留言內文都署 [cloud-worker];--sign 的值不會被當成前置票" "True" \
|
||||
"rc is None and all('[cloud-worker]' in pl['body'] for mm,p,pl in W if p.endswith('/comments')) and not any('cloud-worker' in str(pl) for mm,p,pl in W if p.endswith('/dependencies'))" "$CPS"
|
||||
p "沒給 --sign ⇒ 不署一個寫死的名字(誰跑的就署誰)" "False" \
|
||||
"any('[cloud-worker]' in pl['body'] for mm,p,pl in W if p.endswith('/comments'))" "$CP"
|
||||
|
||||
echo
|
||||
echo " (該擋的:先全部驗過才寫——擋下時一筆都沒寫,而且理由一次列齊)"
|
||||
cb() { # cb <說明> <要在訊息裡看到的字> <argv...>
|
||||
what=$1; want=$2; shift 2
|
||||
p "$what → 離開碼 2、零寫入、訊息有「$want」" "True" "rc==2 and not W and '$want' in out" "$(j cmd_compose "$@")"
|
||||
}
|
||||
OK=(--goal g --due 2026-10-01 --carrier inkstone/InkStoneCo#55)
|
||||
cb "★ 名字是版本號(開工規約第 5 條)" "版本號" --title "1.4.52 雲端零件包" "${OK[@]}" inkstone/ISEP#5
|
||||
cb "沒名字" "--title" "${OK[@]}" inkstone/ISEP#5
|
||||
cb "★ 沒期限(開工規約第 6 條)" "--due" --title 好名字 --goal g --carrier inkstone/InkStoneCo#55 inkstone/ISEP#5
|
||||
cb "期限 9999" "天內" --title 好名字 --goal g --due 9999-01-01 --carrier inkstone/InkStoneCo#55 inkstone/ISEP#5
|
||||
cb "期限在過去" "天內" --title 好名字 --goal g --due 2026-09-01 --carrier inkstone/InkStoneCo#55 inkstone/ISEP#5
|
||||
cb "載體不在 InkStoneCo(不准在子 repo 建里程碑)" "InkStoneCo" --title 好名字 --goal g --due 2026-10-01 --carrier inkstone/ISEP#1 inkstone/ISEP#5
|
||||
cb "沒有前置" "前置" --title 好名字 "${OK[@]}"
|
||||
cb "★ 載體掛 Human(驗收 3)" "載體 inkstone/InkStoneCo#81:掛著 Human" --title 好名字 --goal g --due 2026-10-01 --carrier inkstone/InkStoneCo#81 inkstone/ISEP#5
|
||||
cb "★ 載體指派給 Leo(驗收 3)" "載體 inkstone/InkStoneCo#83:指派給 Leo" --title 好名字 --goal g --due 2026-10-01 --carrier inkstone/InkStoneCo#83 inkstone/ISEP#5
|
||||
cb "載體本身是別的 open 里程碑載體的前置(那一輪還在跑)" "那一輪還在跑" --title 好名字 --goal g --due 2026-10-01 --carrier inkstone/InkStoneCo#84 inkstone/ISEP#5
|
||||
cb "載體已在別的 open 里程碑" "已經掛在里程碑" --title 好名字 --goal g --due 2026-10-01 --carrier inkstone/InkStoneCo#82 inkstone/ISEP#5
|
||||
cb "同名 open 里程碑已在" "同名" --title "組好的 M" "${OK[@]}" inkstone/ISEP#5
|
||||
cb "★ 前置掛 Human(驗收 3)" "前置 inkstone/Arcrun#22:掛著 Human" --title 好名字 "${OK[@]}" inkstone/Arcrun#22
|
||||
cb "★ 前置指派給 Leo(驗收 3)" "前置 inkstone/Arcrun#23:指派給 Leo" --title 好名字 "${OK[@]}" inkstone/Arcrun#23
|
||||
cb "前置還沒分診(沒 s/*)" "還沒分診" --title 好名字 "${OK[@]}" inkstone/ISEP#1
|
||||
cb "前置是 s/triage(要不要做還沒決定)" "還沒分診" --title 好名字 "${OK[@]}" inkstone/ISEP#6
|
||||
cb "前置已經是別的里程碑載體的前置(一張票同時只排一輪)" "只排一輪" --title 好名字 "${OK[@]}" inkstone/ISEP#7
|
||||
cb "前置掛著子 repo 的里程碑" "已經掛在里程碑" --title 好名字 "${OK[@]}" inkstone/ISEP#8
|
||||
p "★ 好幾格不對時一次列齊(Human 前置+沒分診前置,兩個理由都在)" "True" \
|
||||
"rc==2 and not W and 'Arcrun#22:掛著 Human' in out and 'ISEP#1 還沒分診' in out" \
|
||||
"$(j cmd_compose --title 好名字 "${OK[@]}" inkstone/Arcrun#22 inkstone/ISEP#1)"
|
||||
p "參數層的錯(版本號名字)在打任何 API 之前就擋" "0" "len(rec)" \
|
||||
"$(j cmd_compose --title 1.2.3 "${OK[@]}" inkstone/ISEP#5)"
|
||||
|
||||
echo
|
||||
echo "── ④ 組了之後照里程碑往下做(驗收:再照里程碑往下做)──"
|
||||
POOLA=$(j cmd_pick --mainline "$MAIN" --pool --all --json)
|
||||
p "★ --pool:還沒到期的 InkStoneCo 里程碑「組好的 M」一組,成員是載體的前置(ISEP#7 的 s/backlog 不抓,Arcrun#21 s/todo 可抓)" \
|
||||
"['inkstone/Arcrun#21']" "[r['ref'] for r in json.loads(out) if r['group']=='里程碑']" "$POOLA"
|
||||
p "★ 載體票本身(InkStoneCo#80,s/backlog)不當成可做的票——里程碑那組與票池那組都沒有它" "False" \
|
||||
"any(r['ref']=='inkstone/InkStoneCo#80' for r in json.loads(out))" "$POOLA"
|
||||
p "里程碑那一組排在票池之前(先照里程碑做,再撿散票)" "True" \
|
||||
"(lambda g: g.index('里程碑') < g.index('票池'))([r['group'] for r in json.loads(out)])" "$POOLA"
|
||||
p "★ 待分診那一組最舊的先(ISEP#1 08-01 開),不含 Human/Leo" \
|
||||
"['inkstone/ISEP#1', 'inkstone/ISEP#10', 'inkstone/ISEP#2', 'inkstone/InkStoneCo#55']" \
|
||||
"[r['ref'] for r in json.loads(out) if r['group']=='待分診']" "$POOLA"
|
||||
p "每一列都帶下一步(里程碑那一列是 claim、待分診那一列是 triage)" "True" \
|
||||
"all((' triage ' in r['next']) == (r['group']=='待分診') for r in json.loads(out))" "$POOLA"
|
||||
p "沒帶 --pool(本機總管)→ 不看還沒到期的里程碑、也不回待分診(誤攔比漏擋嚴重)" "True" \
|
||||
"rc==1 and '里程碑「組好的' not in out and '待分診' not in out" "$(j cmd_pick --mainline "$MAIN")"
|
||||
p "pick 只讀不寫" "True" "not W" "$POOLA"
|
||||
|
||||
echo
|
||||
echo "── ⑤ 動詞接得上 ──"
|
||||
out=$(python3 "$T" usage triage 2>&1); got=$?
|
||||
if [ "$got" = 0 ] && printf '%s' "$out" | grep -q -- '--dup-of'; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'ticket usage triage 印得出用法\n'
|
||||
out=$(python3 "$T" usage compose 2>&1); got=$?
|
||||
if [ "$got" = 0 ] && printf '%s' "$out" | grep -q -- '--carrier'; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'ticket usage compose 印得出用法\n'
|
||||
out=$(python3 "$T" triage inkstone/ISEP#1 --to s/backlog --why x 2>&1); got=$?
|
||||
if [ "$got" != 0 ] && ! printf '%s' "$out" | grep -q '✅'; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'triage 連不上 Gitea → 非零離開、不印成功(got=%s)\n' "$got"
|
||||
|
||||
echo
|
||||
echo "通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" = 0 ] || exit 1
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/worktree 的測試(inkstone/ISEP#147)——重點是 `sweep`:leo 在 Mac 上跑一次的整理工具。
|
||||
#
|
||||
# 它守什麼: 「一個 repo 只有一個資料夾」的現場整理那一半。fixture 照 leo 09-07 在 Finder 看到的那排
|
||||
# 分身原樣造出來(七個,名字一字不差),每個分身的處境不同:
|
||||
# course_gen-ticket-3-notes 推了、乾淨 ← 但 --skip course_gen(地端在處理):列、不動
|
||||
# course_gen-ticket-4-handouts **沒推** ← 同上:列、不動
|
||||
# ax-courses-ticket1 **沒推** ← 列出分支與路徑,不動
|
||||
# ISEP-wt-122ship 推了、乾淨 ← --apply 收
|
||||
# ISEP-wt-117 推了、有未追蹤檔 ← 不動
|
||||
# ISEP-wt115 登記指向空氣 ← --apply prune
|
||||
# InkStoneCo-wt-112 推了、乾淨 ← --apply 收
|
||||
# 外加一份 `.worktrees/` 裡面的分身(新家)與一個沒人登記的孤兒目錄。
|
||||
#
|
||||
# 四個方向都要驗,缺一個就是假綠:
|
||||
# A dry-run:列表正確(每份都在對的組)、**沒有動任何東西**
|
||||
# B --apply:只收「推了且乾淨」的;沒推的、髒的、--skip 的、孤兒的一根寒毛都沒動
|
||||
# C 判準是 git 的事實不是名字:同樣叫 course_gen 的,不 --skip 就照規則走
|
||||
# D open/close/list 的基本盤(閘測試 G/H 群已驗過出路,這裡只驗工具本體)
|
||||
#
|
||||
# 全程離線:遠端是本機 bare repo。不打網路、不碰任何真的 repo。
|
||||
set -u
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
S="$(pwd)/scripts/worktree"
|
||||
PASS=0; FAIL=0; N=0
|
||||
ok(){ N=$((N+1)); PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
|
||||
bad(){ N=$((N+1)); FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
|
||||
chk(){ if eval "$2"; then ok "$1"; else bad "$1"; fi; } # chk <說明> <條件>
|
||||
ROOT=$(mktemp -d "${TMPDIR:-/tmp}/isep-sweep.XXXXXX"); ROOT=$(cd "$ROOT" && pwd -P)
|
||||
trap 'rm -rf "$ROOT"' EXIT
|
||||
TP="$ROOT/tech_projects"; mkdir -p "$TP" "$ROOT/remotes"
|
||||
|
||||
mkrepo() { # mkrepo <名>:主 repo + 本機 bare 遠端,main 已推
|
||||
git init -q --bare "$ROOT/remotes/$1.git"
|
||||
git init -q -b main "$TP/$1"
|
||||
git -C "$TP/$1" config user.email t@t; git -C "$TP/$1" config user.name t
|
||||
echo "$1" > "$TP/$1/README"; git -C "$TP/$1" add -A; git -C "$TP/$1" commit -qm init
|
||||
git -C "$TP/$1" remote add origin "$ROOT/remotes/$1.git"
|
||||
git -C "$TP/$1" push -q origin main 2>/dev/null
|
||||
}
|
||||
mkwt() { # mkwt <repo> <分身目錄名> <分支> [pushed|unpushed] [dirty]
|
||||
git -C "$TP/$1" worktree add -q "$TP/$2" -b "$3"
|
||||
echo "$2" > "$TP/$2/work.txt"; git -C "$TP/$2" add -A; git -C "$TP/$2" commit -qm "$2"
|
||||
[ "${4:-pushed}" = pushed ] && git -C "$TP/$2" push -q origin "$3" 2>/dev/null
|
||||
[ "${5:-}" = dirty ] && echo x > "$TP/$2/untracked.tmp"
|
||||
return 0
|
||||
}
|
||||
|
||||
mkrepo course_gen; mkrepo ax-courses; mkrepo ISEP; mkrepo InkStoneCo
|
||||
mkwt course_gen course_gen-ticket-3-notes feat/3 pushed
|
||||
mkwt course_gen course_gen-ticket-4-handouts feat/4 unpushed
|
||||
mkwt ax-courses ax-courses-ticket1 feat/1 unpushed
|
||||
mkwt ISEP ISEP-wt-122ship feat/122 pushed
|
||||
mkwt ISEP ISEP-wt-117 feat/117 pushed dirty
|
||||
mkwt ISEP ISEP-wt115 feat/115 pushed
|
||||
rm -rf "$TP/ISEP-wt115" # 登記還在、目錄沒了 ⇒ 指向空氣
|
||||
mkwt InkStoneCo InkStoneCo-wt-112 feat/112 pushed
|
||||
# 新家裡的一份(推了、乾淨)——sweep 同樣要看得到、收得掉
|
||||
python3 "$S" open inkstone/ISEP#140 --repo "$TP/ISEP" >/dev/null 2>&1
|
||||
# 孤兒:.git 檔指向一個不存在的登記
|
||||
mkdir -p "$TP/ISEP-wt-orphan"; printf 'gitdir: %s/ISEP/.git/worktrees/gone\n' "$TP" > "$TP/ISEP-wt-orphan/.git"
|
||||
|
||||
snapshot() { (cd "$TP" && find . -maxdepth 3 -not -path '*/.git/*' | sort | md5sum | cut -c1-12); }
|
||||
|
||||
echo
|
||||
echo "── A dry-run:列得對、不動 ──"
|
||||
BEFORE=$(snapshot)
|
||||
OUT=$(python3 "$S" sweep "$TP" --skip course_gen 2>&1); RC=$?
|
||||
chk "(1) dry-run 離開碼 2(有東西要人決定)" '[ "$RC" -eq 2 ]'
|
||||
chk "(2) 沒有動任何東西(目錄樹快照相同)" '[ "$(snapshot)" = "$BEFORE" ]'
|
||||
sec() { printf '%s\n' "$OUT" | awk -v s="$1" -v e='^(🟢|👻|🔴|🟡|❔|⏭️|❓|──)' 'index($0,s)==1{f=1;next} f&&$0~e{f=0} f'; }
|
||||
chk "(3) ISEP-wt-122ship 在「推了且乾淨」" 'sec "🟢" | grep -qF "ISEP-wt-122ship"'
|
||||
chk "(4) InkStoneCo-wt-112 在「推了且乾淨」" 'sec "🟢" | grep -qF "InkStoneCo-wt-112"'
|
||||
chk "(5) .worktrees/ISEP-140 也在「推了且乾淨」" 'sec "🟢" | grep -qF ".worktrees/ISEP-140"'
|
||||
chk "(6) ax-courses-ticket1 在「沒推」且印出分支 feat/1" 'sec "🔴" | grep -qF "ax-courses-ticket1" && sec "🔴" | grep -qF "feat/1"'
|
||||
chk "(7) 沒推的印得出推的指令" 'sec "🔴" | grep -qF "push origin feat/1"'
|
||||
chk "(8) ISEP-wt-117 在「有未 commit」" 'sec "🟡" | grep -qF "ISEP-wt-117"'
|
||||
chk "(9) ISEP-wt115 在「指向空氣」" 'sec "👻" | grep -qF "ISEP-wt115"'
|
||||
chk "(10) course_gen 兩份都在「--skip 不碰」" 'sec "⏭️" | grep -qF "course_gen-ticket-3-notes" && sec "⏭️" | grep -qF "course_gen-ticket-4-handouts"'
|
||||
chk "(11) course_gen 沒有出現在任何會被動的組" '! sec "🟢" | grep -qF course_gen && ! sec "👻" | grep -qF course_gen'
|
||||
chk "(12) 孤兒目錄在「沒人登記」" 'sec "❓" | grep -qF "ISEP-wt-orphan"'
|
||||
chk "(13) 結尾講「沒有動任何東西」" 'printf "%s" "$OUT" | grep -qF "沒有動任何東西"'
|
||||
|
||||
echo
|
||||
echo "── B --apply:只收推了且乾淨的 ──"
|
||||
OUT=$(python3 "$S" sweep "$TP" --apply --skip course_gen 2>&1); RC=$?
|
||||
chk "(14) --apply 離開碼 2(還有要人決定的)" '[ "$RC" -eq 2 ]'
|
||||
chk "(15) ISEP-wt-122ship 收掉了" '[ ! -e "$TP/ISEP-wt-122ship" ]'
|
||||
chk "(16) InkStoneCo-wt-112 收掉了" '[ ! -e "$TP/InkStoneCo-wt-112" ]'
|
||||
chk "(17) .worktrees/ISEP-140 收掉了" '[ ! -e "$TP/ISEP/.worktrees/ISEP-140" ]'
|
||||
chk "(18) ISEP-wt115 的空氣登記 prune 掉了" '! git -C "$TP/ISEP" worktree list | grep -qF ISEP-wt115'
|
||||
chk "(19) 沒推的 ax-courses-ticket1 還在" '[ -d "$TP/ax-courses-ticket1" ]'
|
||||
chk "(20) 髒的 ISEP-wt-117 還在、未追蹤檔還在" '[ -f "$TP/ISEP-wt-117/untracked.tmp" ]'
|
||||
chk "(21) course_gen-ticket-3-notes(推了乾淨但 --skip)還在" '[ -d "$TP/course_gen-ticket-3-notes" ]'
|
||||
chk "(22) course_gen-ticket-4-handouts 還在" '[ -d "$TP/course_gen-ticket-4-handouts" ]'
|
||||
chk "(23) 孤兒目錄還在(沒人登記的只能人看)" '[ -d "$TP/ISEP-wt-orphan" ]'
|
||||
chk "(24) 四個主 repo 都在、各自 main 沒動" 'for r in course_gen ax-courses ISEP InkStoneCo; do [ -f "$TP/$r/README" ] || exit 1; [ "$(git -C "$TP/$r" branch --show-current)" = main ] || exit 1; done'
|
||||
chk "(25) 收掉的分身,分支還在(收的是目錄不是分支)" 'git -C "$TP/ISEP" rev-parse --verify -q refs/heads/feat/122 >/dev/null'
|
||||
chk "(26) 結尾講收了 3 份" 'printf "%s" "$OUT" | grep -qF "收了 3 份"'
|
||||
chk "(27) 第二次 --apply 冪等:沒有可收的、離開碼仍 2" 'python3 "$S" sweep "$TP" --apply --skip course_gen >/dev/null 2>&1; [ $? -eq 2 ] && [ -d "$TP/ax-courses-ticket1" ]'
|
||||
|
||||
echo
|
||||
echo "── C 判準是事實不是名字 ──"
|
||||
OUT=$(python3 "$S" sweep "$TP" 2>&1)
|
||||
chk "(28) 不 --skip 時 course_gen-ticket-3-notes 落在「推了且乾淨」(名字不是判準)" 'sec "🟢" | grep -qF "course_gen-ticket-3-notes"'
|
||||
chk "(29) course_gen-ticket-4-handouts 落在「沒推」、印 feat/4" 'sec "🔴" | grep -qF "course_gen-ticket-4-handouts" && sec "🔴" | grep -qF "feat/4"'
|
||||
chk "(30) dry-run 仍然沒動 course_gen 那兩份" '[ -d "$TP/course_gen-ticket-3-notes" ] && [ -d "$TP/course_gen-ticket-4-handouts" ]'
|
||||
# 問不到遠端:拿掉 remote 之後,推過的也不當成沒推、也不收
|
||||
git -C "$TP/ax-courses" remote remove origin
|
||||
OUT=$(python3 "$S" sweep "$TP" --apply --skip course_gen 2>&1)
|
||||
chk "(31) 沒有 remote ⇒ ax-courses-ticket1 落在「問不到遠端」,不當成沒推" 'sec "❔" | grep -qF "ax-courses-ticket1"'
|
||||
chk "(32) 問不到的也不收" '[ -d "$TP/ax-courses-ticket1" ]'
|
||||
|
||||
echo
|
||||
echo "── D open/close/list 基本盤 ──"
|
||||
P=$(python3 "$S" open inkstone/ISEP#150 --repo "$TP/ISEP" 2>/dev/null)
|
||||
chk "(33) open 印出的路徑在 <repo>/.worktrees/ISEP-150" '[ "$P" = "$TP/ISEP/.worktrees/ISEP-150" ] && [ -d "$P" ]'
|
||||
chk "(34) 主 repo git status 乾淨(info/exclude 有 /.worktrees/)" '[ -z "$(git -C "$TP/ISEP" status --porcelain)" ] && grep -qxF "/.worktrees/" "$TP/ISEP/.git/info/exclude"'
|
||||
chk "(35) 預設分支 feat/150" '[ "$(git -C "$P" branch --show-current)" = feat/150 ]'
|
||||
chk "(36) tech_projects/ 沒有因 open 多出資料夾" '[ ! -e "$TP/ISEP-150" ] && [ ! -e "$TP/ISEP-wt-150" ]'
|
||||
echo z > "$P/z"; git -C "$P" add -A; git -C "$P" -c user.email=t@t -c user.name=t commit -qm z
|
||||
python3 "$S" close inkstone/ISEP#150 --repo "$TP/ISEP" >/dev/null 2>"$ROOT/close.err"; RC=$?
|
||||
chk "(37) close 沒推 ⇒ 離開碼 2、點名分支與路徑" '[ "$RC" -eq 2 ] && grep -qF "feat/150" "$ROOT/close.err" && grep -qF "$P" "$ROOT/close.err"'
|
||||
git -C "$P" push -q origin feat/150 2>/dev/null
|
||||
python3 "$S" close inkstone/ISEP#150 --repo "$TP/ISEP" >/dev/null 2>&1; RC=$?
|
||||
chk "(38) 推了之後 close ⇒ 0、目錄沒了" '[ "$RC" -eq 0 ] && [ ! -e "$P" ]'
|
||||
python3 "$S" close inkstone/ISEP#150 --repo "$TP/ISEP" --quiet >/dev/null 2>"$ROOT/close2.err"; RC=$?
|
||||
chk "(39) 再 close 一次 ⇒ 0、--quiet 不吵" '[ "$RC" -eq 0 ] && [ ! -s "$ROOT/close2.err" ]'
|
||||
chk "(40) 票寫錯形狀 ⇒ 離開碼 1、講得出正確形狀" 'python3 "$S" open ISEP-150 --repo "$TP/ISEP" 2>&1 | grep -qF "owner/repo#N"'
|
||||
chk "(41) list 列得出還在的分身" 'python3 "$S" list "$TP" 2>&1 | grep -qF "ISEP-wt-117"'
|
||||
chk "(42) 上面所有動作之後 tech_projects/ 沒多任何資料夾" '[ "$(ls -1 "$TP" | grep -c .)" -eq 9 ]' # 4 repo + 3 分身(ticket1/117/cg×2=4)+孤兒 = 9
|
||||
|
||||
echo
|
||||
echo "══ 通過 $PASS 條,失敗 $FAIL 條(共 $N)══"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+1173
-21
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user