diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e4eea3a..01b9e3a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.5.0", + "version": "0.5.1", "keywords": [ "inkstone", "guardrails", diff --git a/docs/TESTING.md b/docs/TESTING.md index 01a6c43..8493d39 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -43,12 +43,15 @@ bash scripts/test-release-tag-guard.sh **該看到**:`3/3 通過`(1 個該擋、2 個不該擋)。 **失敗**:該擋的放行(假綠);或不該擋的被擋——**誤攔比漏擋更該修**,誤攔會懲罰謹慎。 -### A4 — 開票側門閘:13 條 +### A4 — 新增 Gitea 東西的側門閘:24 條 ``` bash scripts/test-ticket-api-bypass-guard.sh ``` -**該看到**:`13/13 通過`。 -**失敗**:任何一條不符,特別看「不該擋」那 8 條。 +**該看到**:`24/24 通過`(前 13 條是 v1 的開票案例;後面是 inkstone/ISEP#72 +擴大範圍後補的:隱式/小寫 POST、milestone/label/PR、org 端點、以及一條打 +真實 Gitea 網路重演 Arcrun#100 的案例——這台機器的 remote 沒帶憑證時會印 +`⏭️ SKIP`,不算失敗,但也不算驗過)。 +**失敗**:任何一條不符,特別看「不該擋」那幾條——誤攔比漏擋更該修。 ### A5 — 開票前的搜尋是跨 repo 的 ``` @@ -241,7 +244,7 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面 | A1 manifest 合法 | 總管 | ✅ | | A2 版本三處一致 | 總管 | ✅ | | A3 打 tag 閘 | 總管 | ✅ 3/3 | -| A4 開票側門閘 | 總管 | ✅ 13/13 | +| **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/24(2026-08-27,inkstone/ISEP#72) | | A5 搜尋跨 repo | 總管 | ✅ | | A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 | | **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) | diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 23695ea..793fa3e 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -43,7 +43,7 @@ | `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 | | `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 | | `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 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 | +| `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 新增 Gitea 東西(開新票/milestone/label/PR,會漏掉「新增前先搜過」這道檢查)就擋下——不管是隱式 POST(urllib 傳 data= 沒寫 method)還是小寫 `requests.post(...)`。 | 🛑 擋 | | `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 | ## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit) diff --git a/hooks/ticket-api-bypass-guard.sh b/hooks/ticket-api-bypass-guard.sh index 10ce272..b5cf7e2 100755 --- a/hooks/ticket-api-bypass-guard.sh +++ b/hooks/ticket-api-bypass-guard.sh @@ -1,26 +1,45 @@ #!/bin/bash -# 管什麼: 用 Gitea API 直接開新票時,要求這一輪有跑過跨 repo 的搜尋(/tmp/.ticket-where-ok,30 分鐘內)。 -# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人。2026-08-20 總管走 API 側門開了 12 張票,每一張都跟舊票重疊。 -# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘 API 也放行),或在指令裡加 `ticket-api-ok` 留痕放行。 -# ticket-api-bypass-guard.sh — 開票的「側門」也要經過同一道搜尋閘 +# 管什麼: 用 Gitea API 直接「新增」東西時(開新票/開新 milestone/開新 label/開新 PR), +# 要求這一輪有跑過搜尋(/tmp/.ticket-where-ok,30 分鐘內)。 +# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人;而擋側門的第一版只認 +# 大寫裸字 POST,2026-08-27 總管用 `requests.post(...)`/隱式 POST 的 urllib +# 繞了過去,同一天又用 API 直接開了 milestone/label/PR(這版都沒管)。 +# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘所有寫入端點都放行), +# 或在指令裡加 `ticket-api-ok` 留痕放行。 +# ticket-api-bypass-guard.sh — 新增 Gitea 東西的每一道側門,都要經過同一道搜尋閘 # -# 來由(leo 2026-08-20 當場問「如何防止」): +# 來由(leo 2026-08-20 當場問「如何防止」;2026-08-27 leo 把範圍講寬: +# 「不只開票前⋯⋯所有新增 gitea 的東西,都要搜尋」): # `scripts/ticket new` 早就強制「開票前先搜」(/tmp/.ticket-where-ok 戳記,30 分鐘失效)。 -# 但總管當天開了 12 張與舊票重疊的新票——因為他**沒用那支工具,直接打 Gitea API**。 -# ⇒ 規範有、閘也有,但閘長在「工具」上,而那個動作有兩條路,只封了一條。 -# leo:「你在讓事情複雜化」/「每張票開以前都要搜尋現有票,你為什麼會開了不搜?」 +# 但 2026-08-20 總管開了 12 張與舊票重疊的新票——沒用那支工具,直接打 API。 +# 2026-08-27 總管又開了 13 張,這次連當時的 v1 guard 都繞過了: +# v1 判準只認一個裸字 `grep -qw POST`(leo 2026-08-17 定的哲學:封動作不封文字, +# 一個裸字夠了),但那天實際指令是 `req = urllib.request.Request(url, data=...)` +# (靠傳 data= 隱式變成 POST,指令裡從頭到尾沒有 "POST" 三個字母),或 +# `requests.post(...)`(小寫 post,`grep -qw POST` 大小寫敏感照樣漏)。 +# ⇒ 這不是「該封文字改封動作」的問題(v1 本來就在封動作:打了寫入端點), +# 是「認動作的方式」本身漏了 HTTP 語意裡的隱式 POST。本版把「寫入訊號」從 +# 單一裸字,換成「curl 的資料類旗標/`.post(`/`method=post`/urllib 隱式 POST」 +# 這組結構性訊號——訊號種類有限、可枚舉,跟關鍵字黑名單不是同一件事。 # -# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的 issues 端點做寫入, +# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的「集合端點」 +# (issues/milestones/labels/pulls,不帶 ID=新增;帶 ID=改既有的,放行)做寫入, # 就要有一個新鮮的搜尋戳記。它不強迫你用 scripts/ticket,只強迫你搜過。 # (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。 -# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板; -# 所以判準取「端點 + 寫入動詞」兩個都命中才擋,讓純讀取一律放行。) +# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板。) # -# 放行(刻意,這些都不是「開票」): +# 放行(刻意,這些都不是「新增」): # - 只讀不寫(GET):撈清單、看票、對帳 -# - 對既有票的留言/改標籤/關票(/issues//... 這種帶票號的子路徑) -# - scripts/ticket 自己(它有自己的閘,重複擋只會互相打架) +# - 對既有票號的子路徑(/issues//... //milestones/ //labels/ //pulls/...): +# 留言、改標籤、關票、改狀態、合併 PR——leo 要的是「不要亂開新的」,不是「不准動 Gitea」 +# - scripts/ticket 自己(它有自己的三道閘,重複擋只會互相打架) # - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上) +# +# 只對「issues」做主動搜尋+在擋下的訊息裡直接點名可能撞到的舊票(milestones/labels/pulls +# 沒有對應的跨 repo 全文搜尋端點可用,一律只要求「搜尋戳記新鮮」,不生產額外訊息—— +# 這是刻意的簡化,不是漏做;真要幫 milestone/label/PR 做語意重複檢查是另一張票的事)。 +# 搜尋本身連不上網路 → 不影響擋/放行的判斷,只影響訊息裡有沒有列出候選舊票(fail-open +# 在「錦上添花」的部分,不 fail-open 在「該不該擋」這個核心判斷上)。 set -uo pipefail INPUT=$(cat) @@ -34,18 +53,54 @@ except Exception: print('') # 逃生口(留痕) case "$CMD" in *ticket-api-ok*) exit 0 ;; esac # scripts/ticket 有自己的閘 -case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*) exit 0 ;; esac +case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*|*"ticket close"*|*"ticket decide"*) exit 0 ;; esac -# ① 有沒有打到 Gitea 的 issues 端點(不帶票號的那個=建立新票的路徑) -printf '%s' "$CMD" | grep -qE 'repos/[^ "'"'"']*/issues([?"'"'"'`,)\\[:space:]]|$)' || exit 0 +# ① 先放行「帶了 ID 的子路徑」——issues/、milestones/、labels/、 +# pulls/(後面不管接留言/labels/合併都算),這些是在動既有的東西, +# 不是新增。獨立判斷、排在最前面,不要留給下面那條「集合端點」regex 用 +# 貪婪比對去猜——第一版把 owner/repo 寫成「允許斜線的萬用字元」時, +# `/repos/{REPO}/issues/5/labels`(既有票 #5 底下的 labels,不該擋)會被 +# 貪婪比對成「owner/repo 吃掉 {REPO}/issues/5,資源名是 labels」,跟真正 +# 的頂層 /labels 端點混在一起——先用「resource/數字」這個特徵擋在前面, +# 後面那條就不必再猜。 +printf '%s' "$CMD" | grep -qE '(issues|milestones|labels|pulls)/[0-9]+' && exit 0 -# ② 指令裡有沒有 POST 這個詞(純 GET 一律放行) -# 刻意只認一個裸字:跳脫引號、heredoc、python、curl、各種包裝的寫法無限多, -# 逐個補 pattern 追不完(leo 2026-08-17:「自然語言的變體是無限的,blacklist 永遠追不完」)。 -# ①已經確定這是「開票那條端點」,純讀取的指令不會出現 POST ⇒ 一個字就夠,而且沒有跳脫的破口。 -printf '%s' "$CMD" | grep -qw 'POST' || exit 0 +# ② 有沒有打到 Gitea 的「集合端點」(不帶 ID 的那個=新增的路徑) +# repos/// 或 orgs//,資源名後面立刻是 +# 「不是路徑字元」的東西(問號/引號/逗號/右括號/空白/結尾)。 +# 中間那段刻意只排除空白(不必排引號):①已經先擋掉「帶 ID」的形狀,這裡 +# 只要认出「打到了這個資源的集合端點」就好,多抓一點字元進中間那段不影響 +# 判斷——這也是為什麼 f-string 占位符 `f"{API}/repos/{REPO}/issues"` +# (owner/repo 被壓成一個不含斜線的 token)跟真實網址 `repos/inkstone/ISEP/issues` +# (owner/repo 是兩段)都吃得下,不必為了兩種形狀各寫一條 pattern。 +ENDPOINT_RE='(repos|orgs)/[^[:space:]]+/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)' +printf '%s' "$CMD" | grep -qE "$ENDPOINT_RE" || exit 0 -# ③ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘) +RESOURCE=$(printf '%s' "$CMD" | grep -oE '/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)' | head -1 | grep -oE 'issues|milestones|labels|pulls') + +# ③ 有沒有寫入訊號(純 GET 一律放行)。刻意取「結構性訊號」而非單一裸字: +# - curl:顯式 -X/--request POST,或帶了資料類旗標(curl 帶 -d/--data* 時, +# 沒寫 -X 也會自動變 POST——這正是舊版漏掉的其中一種隱式 POST) +# - requests 系函式庫:`.post(`(大小寫都收) +# - 任何函式庫的 `method=` 參數值是 post(大小寫都收) +# - urllib.request:`Request(`/`urlopen(` 與 `data=` 同時出現(不要求同一行—— +# 多行 heredoc 裡兩者常常不在同一行)=隱式 POST,這是 2026-08-27 實際撞到的那種寫法 +WRITE_SIGNAL=no +if printf '%s' "$CMD" | grep -qiw 'POST'; then + # 保留 v1 的裸字比對,但**大小寫不敏感**——v1 只認大寫 POST, + # 2026-08-27 `requests.post(...)` 小寫照樣繞過,這裡補上不分大小寫的字。 + WRITE_SIGNAL=yes +elif printf '%s' "$CMD" | grep -qiE -- '-X[[:space:]]*POST|--request[[:space:]=]*POST|--data(-raw|-binary|-urlencode)?([[:space:]=]|$)|--json([[:space:]=]|$)|\.post\(|method[[:space:]]*=[[:space:]]*.{0,2}post'; then + WRITE_SIGNAL=yes +elif printf '%s' "$CMD" | grep -qE 'urllib\.request\.(Request|urlopen)\(' \ + && printf '%s' "$CMD" | grep -qE '\bdata[[:space:]]*='; then + # 隱式 POST:urllib.request 傳了 data= 卻沒寫明 method——這是 2026-08-27 + # 實際撞到的寫法,指令裡從頭到尾沒有 "POST" 三個字母,上面兩層都攔不到。 + WRITE_SIGNAL=yes +fi +[ "$WRITE_SIGNAL" = yes ] || exit 0 + +# ④ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘) STAMP=/tmp/.ticket-where-ok NOW=$(date +%s) FRESH=no @@ -55,30 +110,108 @@ if [ -f "$STAMP" ]; then fi [ "$FRESH" = "yes" ] && exit 0 -cat >&2 <<'MSG' -🚫 你正在用 Gitea API 直接開新票,而這一輪沒有搜尋紀錄 +# 只有 issues 才做「主動幫你搜一次」——從指令裡的 title 欄位猜關鍵字, +# 真的搜得到就把候選舊票直接列進擋下的訊息裡(重演 2026-08-27 的 Arcrun#100)。 +# 任何一步失敗(拿不到 token、連不上網、payload 裡沒有 title)都安靜放棄, +# 不影響「擋下來」這個判斷本身。 +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|') + # 只認雙引號的 "title": "..."(curl -d/json.dumps 輸出的實際格式,也是 + # 2026-08-27 那 13 張票真實用的寫法,見 scripts/test-ticket-api-bypass-guard.sh + # 既有的模擬指令)。刻意不支援單引號版本——這段 python 要塞進 bash 單引號 + # 參數字串(`python3 -c '...'`),bash 的單引號字串裡放不進一個字面單引號 + # (沒有跳脫機制),另外分岔一段處理單引號只會逼自己走回「兩層語言的引號 + # 疊在一起」那個坑。找不到 title 就安靜放棄——不影響「擋下來」這個判斷本身, + # 只是訊息裡少列候選舊票。 + HITS=$(printf '%s' "$CMD" | _TICKET_TOKEN="$TOKEN" python3 -c ' +import os, re, sys, json, urllib.request, urllib.parse -leo 2026-08-16:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」 -leo 2026-08-20:「**每張票開以前都要搜尋現有票,你為什麼會開了不搜?這個規範不是早就有 hook 了?**」 +cmd = sys.stdin.read() +pat = "\"title\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"" +m = re.search(pat, cmd) +if not m: + sys.exit(0) +title = m.group(1) +title = title.replace("\\n", " ").replace("\\\"", "\"") -規範有,閘也有——但那道閘長在 `scripts/ticket` 這支工具裡, -而你走的是 API 這條側門。**本閘就是把那道門也封上。** +toks = [] +for t in re.findall(r"[A-Za-z0-9]+|[一-鿿]{2,}", title): + if t not in toks: + toks.append(t) +toks = toks[:3] +if not toks: + sys.exit(0) -實錯(2026-08-20 同日):總管用 API 開了 12 張票,事後盤點**每一張都跟舊票重疊**, -全部只能關掉指回舊票。leo:「**你在讓事情複雜化**」。 +token = os.environ.get("_TICKET_TOKEN", "") +seen = {} +for kw in toks: + q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 20}) + url = "https://git.uncle6.me/api/v1/repos/issues/search?" + q + try: + req = urllib.request.Request(url, headers={"Authorization": "token " + token}) + data = json.load(urllib.request.urlopen(req, timeout=6)) + except Exception: + continue + for it in (data or []): + ref = it["repository"]["full_name"] + "#" + str(it["number"]) + row = seen.setdefault(ref, {"score": 0, "title": it["title"]}) + row["score"] = row["score"] + 1 + +hits = sorted(seen.items(), key=lambda kv: -kv[1]["score"])[:6] +if not hits: + sys.exit(0) +joined = " ".join(toks) +print("(用猜出來的關鍵字「" + joined + "」順手搜了一次,命中:)") +for ref, info in hits: + score = info["score"] + t2 = info["title"][:56] + print(" [" + str(score) + "] " + ref + " " + t2) +' 2>/dev/null || true) + fi +fi + +case "$RESOURCE" in + issues) VERB="開新票" ;; + milestones) VERB="開新 milestone" ;; + labels) VERB="開新 label" ;; + pulls) VERB="開新 PR" ;; + *) VERB="新增東西" ;; +esac + +{ +cat < - 🔴 搜到了就**貼進那張票**,不要開新的: + 🔴 搜到同一條線就貼進那張票,不要開新的: scripts/ticket say -F <內文檔> - 2. 真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完: + 2. 開票真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完: scripts/ticket new -F <內文檔> --title <標題> - 3. 這次確實不是在開新票(例如批次改標籤/關票/留言) + 3. 這次確實不是在新增(例如批次改標籤/關票/留言/改 milestone 狀態) → 指令裡加 `ticket-api-ok` 說明理由,留痕放行。 -放行的情況(本閘不管):純讀取(GET)、對既有票 /issues// 的留言與標籤、scripts/ticket 自己。 +放行的情況(本閘不管):純讀取(GET)、對既有票號的留言與標籤與關票、 +scripts/ticket 自己。 MSG +} >&2 exit 2 diff --git a/scripts/test-ticket-api-bypass-guard.sh b/scripts/test-ticket-api-bypass-guard.sh index ca99599..c6dc9d6 100755 --- a/scripts/test-ticket-api-bypass-guard.sh +++ b/scripts/test-ticket-api-bypass-guard.sh @@ -1,6 +1,8 @@ #!/bin/bash # 開票側門閘的測試(docs/TESTING.md A4) -# 判準:4 種該擋、8 種不該擋、1 種有戳記時放行 = 13 條 +# 判準:4 種該擋、8 種不該擋、1 種有戳記時放行 = 13 條(v1,仍須全過,不能為了 +# 新案例把舊的改壞)+ inkstone/ISEP#72 擴大範圍後新增的案例(隱式/小寫 POST、 +# milestone/label/PR、org 端點、Arcrun#100 重演)。 cd "$(dirname "$0")/.." || exit 1 H=hooks/ticket-api-bypass-guard.sh PASS=0; FAIL=0 @@ -33,8 +35,53 @@ run 0 'curl -s "$API/repos/inkstone/Arcrun/issues?state=open&limit=100"' echo "── 有新鮮戳記時放行 ──" python3 -c "import json,time;json.dump({'at':time.time(),'n':0,'top':[]},open('/tmp/.ticket-where-ok','w'))" run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues' - rm -f /tmp/.ticket-where-ok + +echo +echo "── ISEP#72:2026-08-27 實際撞到的繞法(v1 grep -qw POST 漏掉的),該擋 ──" +run 2 'url = "https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues"; req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=h); urllib.request.urlopen(req)' +run 2 'python3 -c "requests.post(f\"{API}/repos/{REPO}/issues\", json={\"title\":\"x\"})"' + +echo +echo "── ISEP#72:範圍擴大到 milestone/label/PR,該擋 ──" +run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones -d @m.json' +run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/labels -d @l.json' +run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/pulls -d @p.json' +run 2 'curl -X POST https://git.uncle6.me/api/v1/orgs/inkstone/labels -d @l.json' + +echo +echo "── ISEP#72:帶 ID 的 milestone/label/PR 子路徑,不該擋(改狀態/合併,不是新增)──" +run 0 'curl -X PATCH https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/3 -d @m.json' +run 0 'curl -X PATCH https://git.uncle6.me/api/v1/repos/inkstone/ISEP/labels/9 -d @l.json' +run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/pulls/12/merge' + +echo +echo "── ISEP#72:有戳記時,milestone/label/PR 也放行(不是只有 issues)──" +python3 -c "import json,time;json.dump({'at':time.time(),'n':0,'top':[]},open('/tmp/.ticket-where-ok','w'))" +run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones -d @m.json' +rm -f /tmp/.ticket-where-ok + +echo +echo "── ISEP#72:Arcrun#100 重演——標題撞同主題舊票,擋下來還要點名 ──" +echo " (這條打真實 Gitea 網路;離線環境會印 SKIP,不算失敗。標題故意用" +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 + 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=$? + if [ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'Arcrun#100'; then + PASS=$((PASS+1)); echo " ✅ 擋下來(rc=2)且點名 Arcrun#100" + else + FAIL=$((FAIL+1)); echo " ❌ rc=$RC,訊息裡有沒有 Arcrun#100:"; printf '%s\n' "$OUT" | grep -q 'Arcrun#100' && echo "有" || echo "沒有" + printf '%s\n' "$OUT" + fi +else + echo " ⏭️ SKIP(這台機器的 git remote 沒帶 git.uncle6.me 的憑證,連不上就不算數)" +fi + [ -n "$SAVED" ] && printf '%s' "$SAVED" > /tmp/.ticket-where-ok echo echo "$PASS/$((PASS+FAIL)) 通過"