diff --git a/docs/TESTING.md b/docs/TESTING.md index 7c5f581..be1e573 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -420,12 +420,12 @@ bash hooks/tests/gitea-arm-check.test.sh . - ①③⑤⑥ 任一紅 ⇒ 收手收得太晚,暫存區已經開始長東西(磁碟風險回來了) - ② 紅 ⇒ 訊息沒給出走得通的那一行(本票整張票在講的就是這件事) - ⑧⑨⑩ 任一紅 ⇒ **誤攔/複製錯**:正常用法被弄壞,或複本裡混進了 `hooks/` 以外的東西 -### A31 — 雲端工人抓票的規則是機械可判的:48 條 +### A31 — 雲端工人抓票的規則是機械可判的:53 條 ``` bash scripts/test-ticket-pick.sh ``` -**該看到**:`通過 48 條,失敗 0 條`。**全程離線**:假池子(3 個 repo、3 個 open milestone、 -11 張票)餵給換掉的 `api()`,時鐘走 `ISEP_COUNTDOWN_NOW`,`TICKET_HOST` 指到連不上的位址 +**該看到**:`通過 53 條,失敗 0 條`。**全程離線**:假池子(3 個 repo、3 個 open milestone、 +12 張票、1 條相依邊)餵給換掉的 `api()`,時鐘走 `ISEP_COUNTDOWN_NOW`,`TICKET_HOST` 指到連不上的位址 ——任何一條真的走到網路會當場炸,不會靜靜變成假綠。**不開票、不改票、不留任何東西在 Gitea 上。** **它在守什麼**(`inkstone/ISEP#131`):Routine 08-06 起讀 `journeys.md` 本 sprint 段, @@ -434,8 +434,10 @@ Gitea 主線里程碑抓票(`inkstone/InkStoneCo#118`),但那是一段給 這一支把規則做成 `scripts/ticket pick`/`claim` 兩個動詞,規則本體在 `pickable()` 一支純函式: ``` -可抓 s/* 恰好是 {s/todo} + 沒有 assignee + 沒有 Human/human/* + 不是 hub + 里程碑=主線 - 主線抓盡才抓「逾期的 open 里程碑」;backlog/沒里程碑一律不抓 +可抓 s/* 恰好是 {s/todo} + 沒有 assignee + 沒有 Human/human/* + 不是 hub + 在主線上 + 「在主線上」=hub 里程碑裡、或被主線成員當相依(跨 repo 邊,inkstone/ISEP#133); + 別 repo 裡同名的里程碑**不算**主線。主線抓盡才抓「逾期的 open 里程碑」(一個里程碑物件一組); + backlog/沒里程碑且沒有邊的一律不抓 認領 claim = 指派 + s/doing + 第一行【身份】的留言,一個動作 完成 handback --label s/review(本來就有的那半,不重做) ``` @@ -448,6 +450,8 @@ Gitea 主線里程碑抓票(`inkstone/InkStoneCo#118`),但那是一段給 要嘛抓盡了還回 `Arcrun#8`(既不是主線也沒逾期) - ②「離開碼 1」紅 ⇒ 「真的沒有票」與「讀不到」被講成同一句——後者要離開碼 2 - ②「沒有主線 → 離開碼 2 且一通 API 都沒打」紅 ⇒ 沒主線時去抓了別的 +- ②「ISEP#1 靠相依進主線」/「ISEP#13 同名沒邊 → 不在」任一紅 ⇒ 成員又變回靠名字算 + (leo 2026-09-07 推翻的那條判準回來了,A41 也會一起紅) - ③「該擋」六條任一紅 ⇒ 規則只長在 `pick` 這端,手動 `claim` 一張 Human 的票照樣過 - ③「身份名不在名單上 → 一通都沒打」紅 ⇒ 名單以外的名字碰到了 Gitea - ④ 第一條紅 ⇒ 連不上 Gitea 時回了離開碼 1,雲端會把「斷線」當「沒票」收工 @@ -1014,6 +1018,53 @@ bash scripts/test-worktree.sh - (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#:整個 Gitea 只有這一個 milestone 物件 +hub 票 mainline set … --hub :這條線的載體票,別 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 標籤 模組化 diff --git a/docs/governance/sdd-gitea-governance.md b/docs/governance/sdd-gitea-governance.md index 9a6d3a2..679b8f1 100644 --- a/docs/governance/sdd-gitea-governance.md +++ b/docs/governance/sdd-gitea-governance.md @@ -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) | |---|---|---| diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 8411838..f939b52 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -210,7 +210,7 @@ | `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-`(用一次就消失)。 | 🛑 擋 | @@ -233,7 +233,7 @@ | `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/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 就原封不動——**寧可資料舊,不要把主線弄丟**。跑一次就結束,不輪詢。**家目錄與 repo 那份都不在時**(雲端薄殼跑到這裡 `InkStoneCo/` 還沒 clone)先去 Gitea 讀 `inkstone/InkStoneCo` main 的 `system-dev/mainline.json` 一次寫進家目錄(`inkstone/ISEP#140`):匿名優先、被拒才帶 token;讀不到就退回,不報錯、逾時有上限。優先序寫在 `hooks/lib/mainline.py` 檔頭。 | 📝 記錄 | +| `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 給你**。沒東西可報時它會說「查過了,沒有」——**安靜跟壞掉長得一模一樣**。發不出去時不會靜默:它會先問這個 session 的 `prod-write-guard` 會不會擋(舊版把「發通知」誤認成「部署」),擋就改貼回票上並把原文印在眼前。只在開 session 時跑一次,**不輪詢、不掛排程**。 | 📝 記錄(context 注入 + Telegram) | | `wiki-size-guard.sh` | 對話一開始講出「哪幾個 wiki 檔已經沒有人讀得完了」(預設超過 1200 行就點名)。**讀不完的必讀檔,跟沒有那個檔的差別只在於它讓人以為有。** 另一半掛在寫檔上,見 B 組。 | 📝 記錄(context 注入) | | `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 | diff --git a/hooks/lib/mainline.py b/hooks/lib/mainline.py index 9e59533..12f9058 100644 --- a/hooks/lib/mainline.py +++ b/hooks/lib/mainline.py @@ -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,27 +14,35 @@ 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 的**標題**與主線標題相同(不分 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 用指針」。⇒ 檔頭寫的「既有做法」會被當成規則抄走,所以這裡改成規則本身: + + · **錨**(anchor)= `owner/repo#`:整個 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 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。 -唯一的例外是 `fetch_gitea()`(inkstone/ISEP#140,見下方第三個來源那段): -它會打一次 Gitea,但**只有 SessionStart 的 `scripts/mainline refresh` 叫它**, -`load()`/`line()` 永遠不叫——所以每一則訊息的路徑上仍然沒有網路。 +兩個例外都不在關鍵路徑上:`fetch_gitea()`(inkstone/ISEP#140,只有 SessionStart 的 +`scripts/mainline refresh` 叫)與 `collect_members()`(要呼叫端把 api 函式傳進來, +本檔自己不帶憑證、不認主機;`load()`/`line()` 永遠不叫它們)。 ── 三個來源,優先序寫死:家目錄 > repo 那份 > Gitea main 那份 ─────── ① 家目錄 `~/.claude/isep-countdown/mainline.json`(這台機器上 refresh 過、有進度) @@ -86,6 +95,21 @@ def path() -> str: 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 = [] @@ -94,11 +118,7 @@ def repo_candidates(): return [] # 測試用:把「repo 那份」整個拿掉,外界定住 if forced: out.append(forced) - roots = [] - for r in (os.environ.get("CLAUDE_PROJECT_DIR", "").strip(), os.getcwd()): - if r: - roots += [r, os.path.join(r, "InkStoneCo")] - for r in roots: + for r in project_roots(): p = os.path.join(r, REPO_REL) if p not in out: out.append(p) @@ -291,6 +311,8 @@ def norm(s) -> str: 🔴 刻意不做同義詞、不做模糊比對:主線是**被指定的**,不是被猜出來的。 猜錯的方向是「把不屬於主線的票放行」,那正是這張票要擋的事。 + inkstone/ISEP#133 起標題只用來**查**(Gitea 的 `milestones=` 只吃名字), + 成員判定看的是 milestone id 與相依邊,不再拿標題當判準。 """ return "".join((s or "").split()).lower() @@ -301,27 +323,155 @@ 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} + 🔴 `reached=False` = 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取** + (讀不到 ≠ 沒有成員)。 + 判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。 + """ + import urllib.parse + 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} + q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100}) + rows = api("/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 = api("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts) + if not isinstance(deps, list): + 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) + 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 問這張票掛在哪」換成一份現成的答案。 diff --git a/hooks/mainline-focus-guard.sh b/hooks/mainline-focus-guard.sh index 56b6e87..d026f26 100755 --- a/hooks/mainline-focus-guard.sh +++ b/hooks/mainline-focus-guard.sh @@ -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' diff --git a/hooks/tests/mainline-members-by-dependency.test.sh b/hooks/tests/mainline-members-by-dependency.test.sh new file mode 100755 index 0000000..3d1a610 --- /dev/null +++ b/hooks/tests/mainline-members-by-dependency.test.sh @@ -0,0 +1,318 @@ +#!/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": 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 ` 那行抽出來、填一格、執行 +LINE=$(printf '%s\n' "$OUT" | grep -E '^\s+\S+ adopt inkstone/ISEP#130 --via ' | head -1 | sed 's//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 "── 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 ] diff --git a/scripts/mainline b/scripts/mainline index 1eaec64..68a525a 100755 --- a/scripts/mainline +++ b/scripts/mainline @@ -7,15 +7,16 @@ leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」 ⇒ SOP 說的「**那個** active milestone」在現場根本沒有指涉對象—— 要注入不知道注哪一個,要擋跳線也不知道拿哪一個當基準。 -這支不解「milestone 太多」(跨 repo 同名是既有做法,不動它), -它解的是「**這一刻我在做哪一條**」沒有答案: - mainline 現在的主線是哪一個(= show,唯一答案,離線) mainline list 有哪些 open milestone 可以選(打網路) - mainline set 把某一個 milestone 標成主線(打網路,寫進 state) + mainline set [--hub ] + 把某一個 milestone 標成主線(打網路,寫進 state); + --hub 指定這條線的載體票(別 repo 的票用相依指到它) mainline clear 現在沒有主線(例:一條線收掉了,下一條還沒開始) - mainline refresh [--members] 更新進度/期限(SessionStart 跑一次,不輪詢) - mainline adopt 「補收」:把一張票掛進主線(打網路) + mainline refresh [--members] 更新進度/期限(SessionStart 跑一次,不輪詢);--members 順便重收成員 + mainline adopt [--via ] + 「補收」:把一張票掛進主線(打網路)。同 repo ⇒ 掛進 hub 里程碑; + 別 repo ⇒ 成為 hub 票的相依。**不建同名里程碑。** mainline has 這張票在不在主線上(exit 0 =在,1 =不在) 🔴 **`` 的 id 是 milestone 的 id,不是 issue 的號碼。** @@ -30,17 +31,24 @@ leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」 沒有清單、沒有優先序、沒有「前三名」——`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#`:整個 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__)) @@ -95,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): @@ -159,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"): @@ -199,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, mid = parse_ms_ref(argv[0]) + pos = positional(argv, "--hub") + if not pos: + die("用法:mainline set [--hub ]") + 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, @@ -216,15 +248,22 @@ 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, - }, mirror=True) - if not ok: + "hub_ticket": hub, + "members": [], + } + refs, info = collect_members(ms) + ms["members"] = refs + 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))) + if hub: + print(" hub 票:%s" % hub) + print(members_line(refs, info) if info.get("reached") + else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)") + if info.get("capped"): + print(" ⚠️ 相依走到 %d 張就停了(上限),剩下的沒收" % ML.MEMBER_WALK_CAP) print(mirror_line()) print() cmd_show([]) @@ -299,38 +338,66 @@ 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)就保留舊快取——寧可資料舊,不要把成員弄丟。 + try: + refs, info = collect_members(ms) + except SystemExit: + refs, info = [], {"reached": False} + if info.get("reached"): ms["members"] = refs ML.save(ms) # ── adopt(補收)────────────────────────────────────────────────────────── def cmd_adopt(argv): + """補收。同 repo ⇒ 掛進 hub 里程碑(原生欄位);別 repo ⇒ 成為 hub 票的相依。 + 🔴 不會在別的 repo 建同名里程碑——那是被 leo 推翻的做法(本檔檔頭)。""" need_net() - if not argv: - die("用法:mainline adopt (把一張票掛進主線=「補收」)") + pos = positional(argv, "--via") + if not pos: + die("用法:mainline adopt [--via ](把一張票掛進主線=「補收」)") 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}) + 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 + · 或這一次點名要掛在哪一張底下: + %s adopt %s --via +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) + if info.get("reached"): + ms["members"] = sorted(set(refs) | {ref}) + else: + ms["members"] = sorted(set(ms.get("members") or []) | {ref}) ML.save(ms, mirror=True) - print("✅ 補收:%s 已掛進「%s」(%s/%s#%d)" % (ref, ms["title"], owner, repo, mid)) + print("✅ 補收:%s → %s" % (ref, how)) + print(members_line(ms["members"], info) if info.get("reached") else " (成員清單沿用舊快取+這一張)") print(mirror_line()) @@ -345,18 +412,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) diff --git a/scripts/milestone-account b/scripts/milestone-account index 27cdc4c..e16300a 100644 --- a/scripts/milestone-account +++ b/scripts/milestone-account @@ -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") diff --git a/scripts/test-ticket-pick.sh b/scripts/test-ticket-pick.sh index 9336485..fa0266b 100755 --- a/scripts/test-ticket-pick.sh +++ b/scripts/test-ticket-pick.sh @@ -46,7 +46,8 @@ def I(repo, n, labels, ms=None, assignees=(), created="2026-09-01T00:00:00Z", pr if pr: d["pull_request"] = {"merged": False} return d POOL = { - "ISEP": [I("ISEP", 1, ["s/todo"], "main"), # ★ 可抓 + "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 @@ -59,6 +60,16 @@ POOL = { "mira": [I("mira", 9, ["s/todo", "s/doing"], "main")], # 09-07 實查 mira#6 的形狀:有人在做 } MSN = {"ISEP": ["main", "late", "future"], "Arcrun": ["main", "late", "future"], "mira": ["main"]} +# 相依邊(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)) @@ -80,6 +91,10 @@ def fake_api(path, payload=None, method=None): 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))) @@ -142,6 +157,12 @@ p "★ 給定主線檔 → 回一張,而且是主線裡最優先的 Arcrun#6 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" @@ -201,6 +222,10 @@ 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" \ diff --git a/scripts/ticket b/scripts/ticket index d418fa7..340f8e6 100755 --- a/scripts/ticket +++ b/scripts/ticket @@ -132,6 +132,10 @@ def token(): • 或設環境變數 GITEA_TOKEN_CLAUDE_CODE""") +_TOLERATE = () # 這一次呼叫可以吞掉的 HTTP 狀態碼(見 add_dependency) +_TOLERATED = object() # api() 吞掉時回的哨兵——不是 None,None 是「回應是 null」 + + def api(path, payload=None, method=None): url = path if path.startswith("http") else f"{HOST}/api/v1{path}" data = json.dumps(payload).encode() if payload is not None else None @@ -141,6 +145,8 @@ def api(path, payload=None, method=None): try: return json.load(urllib.request.urlopen(req, timeout=40)) except urllib.error.HTTPError as e: + if e.code in _TOLERATE: + return _TOLERATED die(f"🔴 Gitea {e.code}:{e.read().decode()[:300]}") @@ -663,6 +669,31 @@ def _deps(owner, repo, num): return api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or [] +def add_dependency(parent, child): + """讓 `child` 成為 `parent` 的相依(兩個都是 (owner, repo, num))。**冪等**: + 先讀 parent 的 `/dependencies`,邊已經在就不 POST、回 False——`mainline adopt` + 補收同一張票兩次不該炸。 + 🔴 冪等**不能靠回應碼**:2026-09-07 對真 Gitea 實測,重複相依回的是 **500**(不是想像中的 409) + ——判準改成「Gitea 現在有沒有這條邊」這個事實;409 仍容忍(別的版本可能這樣回)。 + `subtask` 與 `mainline adopt`(inkstone/ISEP#133)都走這一支:掛相依只有一條路, + 而且走的是 `api()`——離線測試把 `api()` 換成錄音機時才錄得到它。""" + global _TOLERATE + po, pr, pn = parent + co, cr, cn = child + want = f"{co}/{cr}#{cn}" + have = api(f"/repos/{po}/{pr}/issues/{pn}/dependencies?limit=100") or [] + for d in have: + full = ((d.get("repository") or {}).get("full_name")) if isinstance(d, dict) else None + if full and f"{full}#{d.get('number')}" == want: + return False # 已經是相依 + _TOLERATE = (409,) + try: + r = api(f"/repos/{po}/{pr}/issues/{pn}/dependencies", {"owner": co, "repo": cr, "index": cn}) + finally: + _TOLERATE = () + return r is not _TOLERATED + + def _blocks(owner, repo, num): """反方向:**誰把這張票當相依**——也就是這張票的頂層票(可能不只一張、可能跨 repo)。 @@ -845,7 +876,8 @@ def subtask_example(parent=None, draft=None): USAGE = { "pick": "用法:{bin} pick [--mainline <主線 json 檔>] [--all] [--json] [--claim [--as <登入名>] [--name <身份>]]\n" - " 回**一張**現在可以抓的票(主線里程碑抓盡才輪到逾期里程碑;不抓 backlog、不抓沒里程碑的)\n" + " 回**一張**現在可以抓的票(主線抓盡才輪到逾期里程碑;不抓 backlog)。主線的成員=hub 里程碑裡的票+\n" + " hub 票+它們的相依(跨 repo)——別 repo 的同名里程碑不算主線(inkstone/ISEP#133)\n" " --mainline 不給時依序找:$ISEP_MAINLINE_FILE → ~/.claude/isep-countdown/mainline.json" " → $CLAUDE_PROJECT_DIR/system-dev/mainline.json\n" " 離開碼:0 = 有票(印在 stdout);1 = 真的沒有可抓的票;2 = 讀不到(讀不到 ≠ 沒有)", @@ -966,8 +998,7 @@ def cmd_subtask(argv): print(f" 下一步:{nxt}") # 🔴 相依掛不上去 = 這張子票對母票是隱形的 ⇒ 整個動作算失敗,要讓人看到 - api(f"/repos/{powner}/{prepo}/issues/{pnum}/dependencies", - {"owner": ORG, "repo": repo, "index": cnum}) + add_dependency((powner, prepo, pnum), (ORG, repo, cnum)) deps = _deps(powner, prepo, pnum) openn = [i for i in deps if i["state"] == "open"] print(f" 相依:已掛上 {powner}/{prepo}#{pnum}(它存在、且擋著母票)") @@ -1299,6 +1330,10 @@ def pickable(issue, milestone_title): # 2026-09-07 實查:ISEP#31–35 五張 hub 掛在逾期里程碑上、s/todo、沒人—— # 照欄位規則會被抓走,但它們不是一件可以做完的事。容器不是任務。 return False, "是 hub(scope 容器,不是一件可以做完的事)" + if milestone_title is None: + # inkstone/ISEP#133:呼叫端已經用相依邊確認它在主線上(別 repo 的票靠相依進來, + # 本來就不掛 hub 里程碑)⇒ 這一格不看里程碑。 + return True, "ok" ms = (issue.get("milestone") or {}).get("title") if not ms: return False, "沒有里程碑(backlog/沒排進 sprint 的不抓)" @@ -1389,40 +1424,65 @@ def _issues_in(owner, repo, title): return [it for it in rows if isinstance(it, dict)] +def _fetch_issue(ref): + ML = _mainline_lib() + parts = ML.split_ref(ref) + if not parts: + return None + it = api("/repos/%s/%s/issues/%d" % parts) + if not isinstance(it, dict) or not it.get("number"): + return None + it.setdefault("repository", {"full_name": "%s/%s" % parts[:2]}) + return it + + +def mainline_members(ms): + """主線的成員(inkstone/ISEP#133):hub 里程碑裡的票+hub 票+它們的相依(跨 repo)。 + 判準在 `hooks/lib/mainline.py::collect_members`,這裡只是把本檔的 `api` 遞進去。""" + return _mainline_lib().collect_members(api, ms) + + def pick_candidates(ms, now=None): """依序回 [(組名, 里程碑標題, [可抓的票…])]:第一組是主線,之後是逾期的 open 里程碑 - (最逾期的先)。**每一組裡都已經照 pick_order 排好。**""" + (最逾期的先)。**每一組裡都已經照 pick_order 排好。** + + 主線那一組的成員由相依邊決定(`mainline_members`),**不是**同名里程碑: + 別 repo 裡叫同一個名字的里程碑,裡面的票不會被當成主線抓走(inkstone/ISEP#133)。 + 逾期那幾組是**一個里程碑物件一組**(owner/repo#id),同名不合併。""" ML = _mainline_lib() now = now or _now() owner = ms.get("owner") or ORG + anchor = "%s/%s#%s" % (ms.get("owner"), ms.get("repo"), ms.get("id")) + + refs, info = mainline_members(ms) + found = [] + for ref in refs: + it = _fetch_issue(ref) + if not it or (it.get("state") or "open") != "open": + continue + ok, _ = pickable(it, None) + if ok: + found.append(it) + groups = [("主線", ms["title"], sorted(found, key=pick_order))] + repos = _repos(owner) - opened = _open_milestones(owner, repos) - groups = [] - - def collect(title): - found = [] - for r, m in opened: - if ML.norm(m.get("title")) != ML.norm(title): - continue - for it in _issues_in(owner, r, m.get("title")): - ok, _ = pickable(it, title) - if ok: - found.append(it) - return sorted(found, key=pick_order) - - groups.append(("主線", ms["title"], collect(ms["title"]))) - - overdue = {} - for r, m in opened: - if ML.norm(m.get("title")) == ML.norm(ms["title"]): + overdue = [] + for r, m in _open_milestones(owner, repos): + if "%s/%s#%s" % (owner, r, m.get("id")) == anchor: continue due = ML.due_of(m) if due and due < now: - key = ML.norm(m.get("title")) - if key not in overdue or due < overdue[key][0]: - overdue[key] = (due, m.get("title")) - for due, title in sorted(overdue.values()): - groups.append(("逾期里程碑", title, collect(title))) + overdue.append((due, r, m)) + for due, r, m in sorted(overdue, key=lambda x: x[0]): + found = [] + for it in _issues_in(owner, r, m.get("title")): + if str((it.get("milestone") or {}).get("id")) != str(m.get("id")): + continue + ok, _ = pickable(it, m.get("title")) + if ok: + found.append(it) + groups.append(("逾期里程碑", "%s(%s/%s#%s)" % (m.get("title"), owner, r, m.get("id")), + sorted(found, key=pick_order))) return groups @@ -1496,17 +1556,20 @@ def cmd_claim(argv): issue = api("/repos/%s/%s/issues/%d" % (owner, repo, num)) issue.setdefault("repository", {"full_name": "%s/%s" % (owner, repo)}) ims = issue.get("milestone") or {} - ok, why = pickable(issue, ims.get("title") or "") - if ok: - on_mainline = ML.norm(ims.get("title")) == ML.norm(ms["title"]) + ref = "%s/%s#%d" % (owner, repo, num) + # 在不在主線上:看相依邊與 hub 里程碑(inkstone/ISEP#133),不看標題 + on_mainline = ref in set(mainline_members(ms)[0]) + ok, why = pickable(issue, None if on_mainline else (ims.get("title") or "")) + if ok and not on_mainline: due = ML.due_of(ims) - if not on_mainline and not (due and due < _now()): - ok, why = False, "里程碑「%s」既不是主線「%s」也還沒逾期" % (ims.get("title"), ms["title"]) + if not (due and due < _now()): + ok, why = False, "里程碑「%s」既不是主線「%s」也還沒逾期(別 repo 的同名里程碑不算主線)" % ( + ims.get("title"), ms["title"]) if not ok: die("""🚫 %s/%s#%d 現在不能抓:%s 抓票規則(inkstone/ISEP#131,全部是 Gitea 欄位): - s/* 恰好是 s/todo + 沒有 assignee + 沒有 Human/human/* + 里程碑是主線(主線抓盡才抓逾期) + s/* 恰好是 s/todo + 沒有 assignee + 沒有 Human/human/* + 在主線上(hub 里程碑或相依;主線抓盡才抓逾期) 出路:`%s pick` 會回一張符合規則的票;這張如果真的該你做,回票上問總管改欄位,不要硬抓。""" % ( owner, repo, num, why, self_path())) @@ -1515,7 +1578,7 @@ def cmd_claim(argv): _set_labels(owner, repo, num, keep) lines = [ident, "", "🙋 **認領** → `%s`" % who, "", - "- 里程碑:「%s」%s" % (ims.get("title"), "(主線)" if ML.norm(ims.get("title")) == ML.norm(ms["title"]) else "(逾期里程碑,主線已抓盡)"), + "- 里程碑:「%s」%s" % (ims.get("title") or "(無,靠相依在主線上)", "(主線)" if on_mainline else "(逾期里程碑,主線已抓盡)"), "- 狀態:s/todo → s/doing", "- 做完:分支+證據 → `s/review` + `ticket handback`(不推 main、不出貨、不改里程碑)"] api("/repos/%s/%s/issues/%d/comments" % (owner, repo, num), {"body": "\n".join(lines)}) diff --git a/system-dev/wiki/mistakes.md b/system-dev/wiki/mistakes.md index 46025cc..6d3303e 100644 --- a/system-dev/wiki/mistakes.md +++ b/system-dev/wiki/mistakes.md @@ -441,3 +441,33 @@ leo 當場:「**這些為什麼不寫到票裡?**」 📌 推廣:**一道閘若教人做一件「之後要還」的事(開 worktree、蓋戳記、借共用目錄), 「還」那一步也要長在某個事件上**,不然它必然被留下。收工的事件是 PostToolUse(Agent|Task), 票號就在那裡(`baton-handback-guard.sh`/`issue-status-autoflip.sh` 都站在同一個位置)。 + +## ⚠️ MISTAKE: 工具檔頭把「現場的樣子」寫成「既有做法」,於是它變成了規則 + +票: `inkstone/ISEP#133`(→ comment 6743) +日期: 2026-09-07 + +症狀: `scripts/mainline` 與 `hooks/lib/mainline.py` 檔頭寫「跨 repo 同名是既有做法,不動它」, + 成員判定=「票所屬 milestone 的標題與主線相同(不分 repo)」。總管早上照它做,把那句 + 抄進 InkStoneCo 的 ops-facts(「同名里程碑跨 repo 是 mainline 機制的一部分」), + 還在 arcrun-rag 補建了一個同名的(#61)。leo:「**拆,誰說『同名跨 repo 是 mainline + 機制的一部分』,早就定了只有一個 milestone,不同 repo 用指針**」。 + 規則其實早就寫在票上:`inkstone/ISEP#30`(08-20)、`inkstone/InkStoneCo#44`(08-27)。 + +原因: ISEP#82 那天現場真的有 5 個同名的「Mira 現代化」,工具作者把「現場如此」記成 + 「既有做法」——而讀檔頭的人分不出「作者觀察到的」與「leo 定的」。 + **工具是會被抄的文件**:它說什麼是規則,做的人就照什麼做;寫錯的方向會被放大成動作 + (多建一個里程碑),不是只停在一句話。 + 同款:帳本路徑退到「原始碼所在 repo 的 system-dev/」——本機是真 checkout 所以對, + 雲端的原始碼在 plugin 快取,那天記的 5 筆帳下一個 session 就不見。判準寫成 + 「原始碼在哪」而不是「哪裡會被 clone 走」,換一台機器就錯。 + +正確做法: + - 檔頭只准寫**規則與出處**(票號/leo 原話),不准把「現場現在長這樣」寫成「既有做法」。 + 要記現場,寫「2026-xx-xx 實查現場是 X」,並註明它不是判準。 + - 判準用 Gitea 的欄位:成員=hub 里程碑裡的票 + hub 票 + 沿 `/dependencies` 收到的(跨 repo); + `belongs()` 要同時拿到「掛哪個 milestone」與「誰把它當相依」才准說「不屬於」,只知一半回 None。 + - 帳本這種「隨 repo 走的狀態」找家用同一把尺(`mainline.py::project_roots()`), + 退回原始碼目錄只在它有 `.git`(真的 checkout)時;plugin 快取沒有 `.git`。 + - 驗法要驗「關掉同名里程碑之後成員還在不在」(A41 ㉛–㉞),不是「同名時列得出來」。 +