Compare commits

..

2 Commits

Author SHA1 Message Date
Leo 0999effcfc 權限一律用無副作用試寫判定,不採信 API permissions 欄位(inkstone/ISEP#120)
派工共通規定(dispatch-and-reply-format.md §2,dispatch-format-guard 每次派工自動注入)
新增第 10 條:判斷「能不能寫進某個 repo」用 `git push --dry-run` 這種無副作用試寫,
不採信 API 的 permissions 欄位——它會回 push:false 卻推得進去
(2026-09-01 實錯 inkstone/Arcrun#190 comment 5805:拿 permissions.push=false
去叫 leo 加協作者,實際推得進去)。並把失敗分成掛住(傳輸)/被拒(權限)/
找不到(不存在)三種,混成「不行」會往錯方向查(同票 comment 5821)。

- §2.1 附完整實測與理由(不進注入,只是背景)
- 掃過現存閘與腳本:零命中——沒有一支用 API permissions 欄位判寫入權限;
  唯一的 permissions 字樣是 Claude Code 自己的 permissions.allow 白名單,另一件事
- dispatch-format-guard.test.sh ⑦b 驗那條規定真的被注入(52→53)
- 用 env -u CLAUDE_CODE_CHILD_SESSION 跑;subagent 環境殘留會假紅(TESTING 已註記)

改到會被注入的內容 ⇒ 待總管定版

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGxitYq49FzYC7EFkbhzF5
2026-09-18 17:57:21 +00:00
Leo ebe71e3283 fix: 三支閘印出來的 ticket 一律絕對路徑,不叫到 InkStoneCo 舊複本(inkstone/ISEP#113)
dispatch-format-guard.sh 的出路 `scripts/ticket say`、factory-idle-guard.sh 主閘與
副閘的 `scripts/ticket where` 原本印相對路徑。相對路徑是對貼上去那個人的 cwd 解析的,
而收工方 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是 2026-08-27 之前的舊複本
(grep -c subtask → 0)⇒ 照著貼會叫到舊的、什麼都不會發生(同 ISEP#112 的病)。

兩支都改成從 ${CLAUDE_PLUGIN_ROOT} 解出絕對路徑再印(沿用 baton-handback-guard 的做法;
baton 本輪未動,早已是絕對路徑)。dispatch 用既有的佔位符替換機制加 __TICKET__;
factory 是 POSIX sh、unquoted heredoc,直接展開 $TICKET,並用 baton 的引號慣例包住路徑。

測試各加一條在驗這件事:
- dispatch-format-guard.test.sh ⑰ 改成驗絕對路徑+確認沒有相對的 scripts/ticket(52/52)
- factory-idle-guard.test.sh 加主閘訊息實跑 + 掃全支源碼兩條(33→35)
- baton 既有 24/24 不動

版本待總管定(plugin.json 未動)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGxitYq49FzYC7EFkbhzF5
2026-09-18 17:45:46 +00:00
17 changed files with 211 additions and 150 deletions
+12 -3
View File
@@ -1177,16 +1177,25 @@ bash hooks/tests/ask-user-question-guard.live.test.sh
```
bash hooks/tests/dispatch-format-guard.test.sh
```
**該看到**`52/52 通過``inkstone/ISEP#88` 之前是 33 條;本頁一度寫 19,那是更早的數字,
**沒有人回頭改** —— 同一個病,只是換一欄)。**離線、不打網路、不花錢**——這支閘是純結構判斷,沒有語意判官,
**該看到**`53/53 通過``inkstone/ISEP#88` 之前是 33 條`#113` 是 52 條;
本頁一度寫 19,那是更早的數字,**沒有人回頭改** —— 同一個病,只是換一欄)。
**離線、不打網路、不花錢**——這支閘是純結構判斷,沒有語意判官,
所以它不需要像 A10 那樣另開一支 live 測試量準度,**每次結果都一樣**。
🔴 **在 subagent 裡跑會假紅**`CLAUDE_CODE_CHILD_SESSION=1` 殘留時,回覆那條路(DE 群)
會被當成子 session 全放行 ⇒ 12 條該擋的變 exit=02026-09-18 實撞)。前面加 `env -u CLAUDE_CODE_CHILD_SESSION` 再跑。
**失敗**
- A 群任何一條紅 ⇒ **誤攔**。合規的派工只有一行票號,擋掉它等於整台機器派不了工
- ⑦ 紅 ⇒ **共通規定沒有被注入**。這是「派工單只剩票號」能成立的前提:
交件方式、不准 push main、org 是 `inkstone` 這些不必有人記得寫,機器每次都補。
它壞了不會有人立刻發現——派工照樣送出去,只是收工方**不知道要貼回原票**
- ⑦b 紅 ⇒ **§2 第 10 條沒被注入**`inkstone/ISEP#120`):收工方拿不到「用試寫判寫入權限、
別信 API 的 permissions 欄位」+「掛住/被拒/找不到」三種失敗形狀,就會再犯 09-01 那次
(拿 `permissions.push=false` 去叫 leo 加協作者,而其實推得進去)
- ⑨ 紅 ⇒ 真跡放行了。那份測資是**真的發生過的那一次派工**(見 `hooks/tests/fixtures/README.md`
- ⑰ 紅 ⇒ 訊息被 shell 展開了(同 A9 ⑩b 那個病:閘照擋,但它教人怎麼解的那兩行變成空白)
- ⑰ 紅 ⇒ 訊息被 shell 展開了(同 A9 ⑩b 那個病:閘照擋,但它教人怎麼解的那兩行變成空白)
**或出路又印回相對路徑的 `scripts/ticket`**`inkstone/ISEP#113`):收工方 cwd 常是
InkStoneCo,那裡的 `scripts/ticket` 是 2026-08-27 之前的舊複本,相對路徑貼過去會叫到它、
什麼都不會發生——同 comment-carries ⑮、`baton-handback-guard`。出路要印**絕對路徑**的 ticket
- **D 群⑲⑳㉑㉒ 紅 ⇒ `inkstone/ISEP#88` 的驗收條件沒過**:回覆一個正在跑的 subagent
時夾帶指令沒被擋(⑲)、或擋了卻沒把那段內容原文印出來(㉑,出路是「貼上票」,
找不到原文就得回頭自己翻)
@@ -120,6 +120,14 @@ Task(subagent_type="isep-hand", prompt="【工單】inkstone/ISEP#86")
有一格沒驗 ⇒ 那是 report 不是 deliver,講清楚哪一格。
9. **改了會被載入的東西(pluginworkerbundle)就要升版**
否則產物按版本號分資料夾,你的改動到不了任何人手上。
10. **要判斷「我能不能寫進某個 repo」時,用一次無副作用的試寫探測,不要採信 API 的
`permissions` 欄位**——它會回 `push:false` 卻推得進去(實測見 §2.1)。用了那個欄位下結論,
你會去麻煩 leo 加協作者,而其實根本不必。探測法:
`git push --dry-run <remote> HEAD:refs/heads/<probe 分支>`(不真的建分支、無副作用),
或該平台等價的無副作用寫入探測。**失敗要分辨三種病,不要混成一句「不行」往錯方向查**:
- **掛住**=傳輸/逾時(如 `RPC failed; GnuTLS recv error (-110)`、大檔卡到逾時)——不是權限問題
- **被拒**=權限(試寫明確回 `403``permission denied``pre-receive` 拒絕)
- **找不到**=repo 或分支不存在(`404``repository not found`
<!-- INJECT:END -->
> 🔴 **這一段是「每次都一樣」的唯一真相源。**
@@ -128,6 +136,54 @@ Task(subagent_type="isep-hand", prompt="【工單】inkstone/ISEP#86")
---
## 2.1 為什麼「能不能寫」只信試寫,不信 permissions 欄位(inkstone/ISEP#120
**§2 第 10 條的實測與理由。** 這一段不進注入(它是背景,不是每次要念的規定)——
上面第 10 條才是要被讀到的那一行。
2026-09-01 實錯(`inkstone/Arcrun#190` → comment 5805):總管拿 Gitea API 的
`permissions.push` 欄位判「能不能推」,得到 `false`,於是**叫 leo 去加協作者**——
而實際上推得進去。同一把 `GITEA_TOKEN_CLAUDE_CODE`,兩種問法給相反的答案:
```
# API 怎麼說(會說謊的欄位)
GET /api/v1/repos/Leo/arcrun-rag-bundles-staging
→ permissions {admin:false, push:false, pull:true}
# 真的試著寫(無副作用,推到 probe 分支,dry-run 不會真的建)
git push --dry-run https://…@git.uncle6.me/Leo/arcrun-rag-bundles-staging.git \
HEAD:refs/heads/ship-probe-total
→ To https://git.uncle6.me/Leo/arcrun-rag-bundles-staging.git
* [new branch] HEAD -> ship-probe-total
→ exit=0
```
leo 當場(同票):「**沒有這個東西,現在已經有權限,而且地端用此帳號已經出貨很多次了**」。
⇒ 那個欄位反映的是某種角色設定,不是「這一次這個動作會不會成功」。
**只有試寫問得到後者。**
### 失敗的形狀要分辨(同票 comment 5821
同一天把「push 掛住到 4 分鐘逾時」(大檔傳輸)講成「被拒絕」(權限),往錯方向查了兩輪:
```
git clone <完整歷史> → RPC failed; GnuTLS recv error (-110) ← 傳輸(掛住)
git clone --filter=blob:none --depth 1 … → 成功,.git 409 MB ← 同一個 repo
```
**掛住/被拒絕/找不到是三種不同的病,混成「不行」就查不下去。**
分辨法(可帶進派工)寫在 §2 第 10 條那三個 bullet。
> 📌 掃過現存的閘與腳本(`inkstone/ISEP#120` 驗收第 2 條):
> **沒有任何一支在用 API 的 permissions 欄位判寫入權限——零命中。**
> 唯一出現「permissions」字樣的地方是 Claude Code 自己的 `permissions.allow`
> 設定白名單(`docs/permissions-allow.json``scripts/settings-allow-sync`),
> 那是「這台機器准不准跑這個工具」,跟「這個 repo 能不能推」是兩件事,不在本條射程內。
> `scripts/pr-verdict merge`、`scripts/release-ship`、`hooks/github-contact-guard.sh`
> 都是直接動作或看 push 目標,沒有一支先去問那個欄位。
---
## 3. 交件回覆 = 第一行表明身份
```
+18
View File
@@ -11,6 +11,24 @@
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。
> 📌 **`inkstone/ISEP#120`2026-09-18,待總管定版)+0 支、+0 條、腳本不變**:沒加閘、沒加腳本。
> `dispatch-format-guard.sh` 注入的共通規定(`docs/governance/dispatch-and-reply-format.md` §2)多一條第 10 條——
> **要判斷「能不能寫進某個 repo」一律用無副作用的試寫(`git push --dry-run`),不採信 API 的 `permissions` 欄位**
> (它會回 `push:false` 卻推得進去,實測見該檔 §2.1`inkstone/Arcrun#190` comment 58055821),
> 並把「掛住(傳輸)/被拒(權限)/找不到(不存在)」三種失敗分開。掃過現存的閘與腳本:**沒有一支在用那個欄位判寫入權限,零命中**
> (唯一出現 `permissions` 的是 Claude Code 自己的 `permissions.allow` 白名單,另一件事)。`dispatch-format-guard.test.sh` ⑦b 驗那條規定真的被注入(52→53)。
> **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#113`2026-09-18,待總管定版)+0 支、+0 條、腳本不變**:沒加閘、沒加腳本。
> `dispatch-format-guard.sh`(出路 `scripts/ticket say`)與 `factory-idle-guard.sh`(主閘+副閘的 `scripts/ticket where`
> 印出來的 ticket 指令改成**絕對路徑**`${CLAUDE_PLUGIN_ROOT}/scripts/ticket`)——收工方 cwd 常是 InkStoneCo
> 那裡的 `scripts/ticket` 是 2026-08-27 之前的舊複本,相對路徑貼過去會叫到它、什麼都不會發生
> (同 `comment-carries-task-guard` ⑮/`baton-handback-guard``inkstone/ISEP#112`)。`baton-handback-guard` 早已是絕對路徑,本輪未動。
> 兩支的測試各加一條在驗這件事:`dispatch-format-guard.test.sh` ⑰(52/52)、`factory-idle-guard.test.sh`33→35)。
> **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#159`2026-09-18,待總管定版)+0 支、+0 條、腳本 65→66**:沒加閘。`scripts/ticket` 多兩個動詞
> `triage`(沒 s/* 的票給狀態+留說明,或當重複關掉並指回去)與 `compose`(把已分診的票組成 InkStoneCo 里程碑:載體+前置,
> 先全部驗過才寫);`pick --pool` 多兩層(還沒到期的 InkStoneCo 里程碑的前置、最後是待分診的票),每一列帶 `next`。
+10 -6
View File
@@ -149,12 +149,16 @@ check_one(){ # $1 owner $2 repo $3 num
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
else
# token來源=env.envinkstone/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
# token掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法
local ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" URL="" U dir TOKEN
for dir in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
[ -e "$dir/.git" ] || continue
U=$(git -C "$dir" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
[ -n "${U:-}" ] && { URL="$U"; break; }
done
[ -n "$URL" ] || return 0
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
[ "$TOKEN" != "$URL" ] || 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" \
+7 -2
View File
@@ -83,6 +83,10 @@ set -u
INPUT="$(cat)"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
# 訊息裡的出路一律印絕對路徑:收工方的 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是
# 2026-08-27 之前的舊複本,照著相對路徑貼會叫到它(同 comment-carries-task-guard ⑮、
# baton-handback-guardinkstone/ISEP#112#113)。
TICKET="${CLAUDE_PLUGIN_ROOT:-$(cd "$HERE/.." && pwd)}/scripts/ticket"
FACTS="$(printf '%s' "$INPUT" | python3 "$HERE/lib/dispatch_parse.py" dispatch 2>/dev/null)"
[ -n "$FACTS" ] || exit 0 # parser 掛了 → fail-open(回到「沒有這支閘」的狀態)
@@ -151,7 +155,7 @@ __CARRY__
**本閘每次派工都會自動注入給收工方**,你不必寫、也不會漏。
只有這次 ⇒ **寫進那張票**(main 是哪顆、今天撞過什麼、另一條線在動什麼)
`scripts/ticket say <owner/repo#N> -F <檔>`
`"__TICKET__" say <owner/repo#N> -F <檔>`
然後派工單寫 `【工單】<owner/repo#N> → comment <剛拿到的號碼>`
🔴 **「票上還沒有」不是把它寫進 prompt 的理由——它就是「去把它寫上票」的指令。**
@@ -207,13 +211,14 @@ if t:
fi
DFG_MSG="$MSG" DFG_VIOL="$VIOL_TEXT" DFG_SESSION="$SESSION" \
DFG_HEAD="$HEADLINE" DFG_CARRY="$CARRY_TEXT" python3 -c '
DFG_HEAD="$HEADLINE" DFG_CARRY="$CARRY_TEXT" DFG_TICKET="$TICKET" python3 -c '
import os, sys
sys.stderr.write(
os.environ["DFG_MSG"]
.replace("__HEADLINE__", os.environ.get("DFG_HEAD", ""))
.replace("__VIOLATIONS__", os.environ.get("DFG_VIOL", ""))
.replace("__CARRY__", os.environ.get("DFG_CARRY", ""))
.replace("__TICKET__", os.environ.get("DFG_TICKET", "") or "scripts/ticket")
.replace("__SESSION__", os.environ.get("DFG_SESSION", "") or "<session_id>")
+ "\n"
)
+8 -7
View File
@@ -82,6 +82,10 @@
set -eu
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
# 訊息裡的出路一律印絕對路徑:收工方的 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是
# 2026-08-27 之前的舊複本,照著相對路徑貼會叫到它(同 comment-carries-task-guard ⑮、
# baton-handback-guardinkstone/ISEP#112#113)。$0 是本檔路徑,往上一層就是 plugin 根。
TICKET="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}/scripts/ticket"
PAYLOAD=$(cat 2>/dev/null || echo '{}')
# ── 主閘:宣告了下一步,有沒有交出派工憑證?────────────────────────
@@ -263,8 +267,8 @@ leo 2026-08-16(本閘的規格):
━━ 現在怎麼過這道閘(四條,程式碼裡都真的有對應的分支)━━━━━━━━━
① **現在就按 trigger**:這個回合直接呼叫 AgentTask 派出去。
沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張,
再 \`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。
沒有票 → 先 \`"$TICKET" where <關鍵字>\` 搜該掛哪張,
再 \`"$TICKET" say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。
② **它已經在別人手上**:把票號寫進這段收尾文字(例:「已派給 inkstone/ISEP#30」)。
有 \`#數字\` 就放行——那不是「我等下做」,是「已經有人在做」。
③ **它其實不該現在做**:寫一行說明它在等什麼,格式就是這一個標記——
@@ -294,10 +298,7 @@ for d in /private/tmp/claude-501/*/*/tasks; do
done
[ "$RUNNING" -gt 0 ] && exit 0
# token 來源=env.envinkstone/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=""
TOKEN=$(git -C "$PROJ" remote get-url gitea 2>/dev/null | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') || TOKEN=""
[ -z "$TOKEN" ] && exit 0
# 🔴 org 是 inkstone,不是 Leo2026-08-13 搬遷;舊值讓本閘靜默失效到 08-16)
@@ -321,7 +322,7 @@ leo 2026-08-10:「你的任務是**維護 loop**,你要去拿任務派任務
**全工廠停工你要發現,這是警訊**。」
【現在該做的,不是回報,是派工】
1. 撈池子當場撈:\`scripts/ticket where <關鍵字>\`(或 labels=s/todo
1. 撈池子當場撈:\`"$TICKET" where <關鍵字>\`(或 labels=s/todo
2. 照 CP 排序:\`system-dev/docs/3-specs/critical-paths/ship.md\` 是有序的六步
3. 一次派 2–4 件平行跑;同一個 repo 不要塞兩條(會搶工作區)
4. agent 死於環境錯誤(SSL/API)⇒ **那是要重派的訊號,不是完工**
+11 -6
View File
@@ -43,12 +43,17 @@ for line in p.splitlines():
set -- $SPEC
OWNER="$1"; REPO="$2"; NUM="$3"
# token 來源=env.envinkstone/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"
# 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
curl -s -o /dev/null --max-time 8 \
-X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
+4 -6
View File
@@ -79,12 +79,10 @@ set -- $REF
OWNER="$1"; REPO="$2"; NUM="$3"
# 拿 token(拿不到就放行——這是本地環境問題,不是派工者的錯)
# token 來源=env.envinkstone/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"
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|')
RESP=$(curl -s --max-time 15 -H "Authorization: token $TOKEN" \
"https://$HOST/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null || true)
+9 -4
View File
@@ -62,12 +62,17 @@ set -u
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
PAYLOAD=$(cat 2>/dev/null || echo '{}')
# ── token來源=env.envinkstone/ISEP#47),不再從 remote URL 抽 ──
# (舊法掃各 repo 的 remote 找帶憑證的網址,代表 token 得明文嵌在 URL 裡 → 會洩進 log)
# ── token同 scripts/ticket 的做法(掃 remote 找帶憑證的,再退到環境變數)──
TOKEN=""
if [ -z "${PR_VERDICT_FIXTURE:-}" ]; then
_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=""
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
[ -n "$TOKEN" ] || TOKEN="${GITEA_TOKEN_CLAUDE_CODE:-${GITEA_TOKEN:-}}"
fi
+24 -3
View File
@@ -127,6 +127,21 @@ else
FAIL=$((FAIL+1))
fi
# 注入:§2 第 10 條的「試寫判權限+掛住/被拒/找不到」要真的被送出去
# inkstone/ISEP#120:閘教的出路本身就是這條規則,收工方沒讀文件也要拿到)
N=$((N+1))
if printf '%s' "$inj" | python3 -c '
import sys, json
c = json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"]
assert "--dry-run" in c, "沒有試寫探測法"
assert "permissions" in c, "沒有點名那個會說謊的欄位"
assert "掛住" in c and "被拒" in c and "找不到" in c, "三種失敗的形狀沒齊"
' 2>/dev/null; then
printf ' ✅ ⑦b 注入內容帶「試寫判寫入權限+掛住/被拒/找不到」(inkstone/ISEP#120\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑦b §2 第 10 條沒被注入,或三種失敗形狀缺一(收工方就拿不到那條判準)\n'; FAIL=$((FAIL+1))
fi
clean
touch /tmp/.dispatch-ok-S-TEST
t 0 "⑧ 明示豁免戳記在 → 放行一次" \
@@ -216,13 +231,19 @@ fi
# 迴歸:訊息裡的反引號一度在別支閘被 shell 當成命令替換執行掉
# 2026-08-26 ask-user-question-guard:閘照擋,但**它教人怎麼解的那兩行變成空白**)
# ⑰ 兩件事一起驗(都會讓「照著貼」失敗):
# ① 反引號沒被 shell 當命令替換執行掉(出路那兩行沒變空白)——原本這條的用意
# ② 出路印的是**絕對路徑**的 ticket,不是相對的 `scripts/ticket`inkstone/ISEP#113):
# 收工方 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是舊複本(`grep -c subtask` → 0
# ⇒ 相對路徑貼過去會叫到舊的、什麼都不會發生(同 comment-carries ⑮、baton 那條)
N=$((N+1))
if printf '%s' "$msg" | grep -q 'scripts/ticket say' \
if printf '%s' "$msg" | grep -qE '/scripts/ticket" say' \
&& ! printf '%s' "$msg" | grep -qE '`scripts/ticket' \
&& printf '%s' "$msg" | grep -q 'touch /tmp/.dispatch-ok-S-TEST' \
&& ! printf '%s' "$msg" | grep -qi 'is a directory\|command not found'; then
printf ' ✅ ⑰ 訊息原文照印反引號沒被當命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
printf ' ✅ ⑰ 訊息原文照印反引號沒被執行)+出路的 ticket 是絕對路徑,沒有相對的 scripts/ticketISEP#113\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑰ 訊息被 shell 展開了(指令消失,或冒出 shell 錯誤\n'
printf ' ❌ ⑰ 訊息被 shell 展開,或出路又印回相對路徑的 scripts/ticketInkStoneCo 那份是舊複本\n'
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
+29
View File
@@ -181,6 +181,35 @@ echo "── 出路③ 的反面:沒寫標記就照樣擋(不准靠刪掉那
t 2 "同一句話拿掉 ⏸ 等: 標記 ⇒ 擋" \
'T:那批 workflow 要重打 bundle。我等你確認,這件事我不自己動。'
echo
echo "── 出路裡的 ticket 是絕對路徑,不是相對的 scripts/ticketinkstone/ISEP#113)─"
# 收工方的 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是 2026-08-27 之前的舊複本
# `grep -c subtask` → 0)⇒ 相對路徑貼過去會叫到它、什麼都不會發生(同 comment-carries ⑮、
# baton-handback)。這兩條分別驗「主閘印出來的那塊」與「整支閘的訊息裡再也沒有相對路徑」。
N=$((N+1))
mk "$TMP/tr-path.jsonl" 'T:我看完了。下一步我去把那支閘的誤攔補上。'
pmsg=$(printf '{"transcript_path":"%s"}' "$TMP/tr-path.jsonl" \
| CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" 2>&1)
if printf '%s' "$pmsg" | grep -qE '/scripts/ticket" where' \
&& ! printf '%s' "$pmsg" | grep -qE '`scripts/ticket'; then
printf ' ✅ 主閘出路的 ticket 印絕對路徑,沒有相對的 scripts/ticket\n'; PASS=$((PASS+1))
else
printf ' ❌ 主閘出路又印回相對的 scripts/ticket ⇒ 在 InkStoneCo cwd 貼上去會叫到舊複本\n'
printf '%s\n' "$pmsg" | grep -n 'ticket' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 副閘(全廠停工那塊)在離線測試裡拿不到 gitea token、走不到,所以直接掃這支閘的原始碼:
# 印出來的每一處 ticket 指令都不准是相對路徑(backtick 直接接 scripts/ticket)。
N=$((N+1))
if grep -n '`scripts/ticket' "$HOOK" >/dev/null 2>&1; then
printf ' ❌ 這支閘的訊息裡還有相對路徑的 scripts/ticket(副閘那塊漏改了)\n'
grep -n '`scripts/ticket' "$HOOK" | sed 's/^/ /'
FAIL=$((FAIL+1))
else
printf ' ✅ 整支閘的訊息裡沒有任何相對路徑的 scripts/ticket(主閘+副閘都改了)\n'; PASS=$((PASS+1))
fi
echo
echo "── 壞輸入不准亂擋(fail-open)────────────────────────────────"
+4 -4
View File
@@ -116,10 +116,10 @@ fi
# 不影響「擋下來」這個判斷本身。
HITS=""
if [ "$RESOURCE" = "issues" ]; then
# token 來源=env.envinkstone/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
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 -djson.dumps 輸出的實際格式,也是
# 2026-08-27 那 13 張票真實用的寫法,見 scripts/test-ticket-api-bypass-guard.sh
# 既有的模擬指令)。刻意不支援單引號版本——這段 python 要塞進 bash 單引號
-26
View File
@@ -1,26 +0,0 @@
#!/bin/sh
# git-credential-gitea.sh — git credential helper:把 Gitea 憑證從 env.env 餵給 git
# 讓 remote URL 不必嵌 tokeninkstone/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 供憑證;storeerase 不做事(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"
+5 -20
View File
@@ -16,29 +16,14 @@ set -eu
REPO=""
[ "${1:-}" = "-r" ] && { REPO="${2:?-r 後面要接 owner/repo}"; }
# 取當前 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)
# 去掉網址裡可能嵌著的憑證 → 乾淨 URLinkstone/ISEP#47token 不進 remote URL
REMOTE=$(printf '%s' "$REMOTE" | sed -E 's#//[^/@]*@#//#')
REMOTE=$(git remote get-url gitea 2>/dev/null) || {
echo "✗ 當前目錄沒有名為 gitea 的 remote。請 cd 到目標 repo,或先加 remote。" >&2; exit 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|')
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|')
[ -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 那行)也不會洩 tokeninkstone/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)。順序=出貨流程:
+6 -5
View File
@@ -30,15 +30,16 @@ if [[ ! -f "$LABELS_YAML" ]]; then
exit 1
fi
# --- token來源=env.envinkstone/ISEP#47),不再從 remote URL 抽(那會逼 token 明文嵌在 URL 裡 ---
# --- token跟 InkStoneCo 頂層 gitea remote 拿同一把(不落地檔案、不印出來 ---
TOKEN="${GITEA_TOKEN:-}"
if [[ -z "$TOKEN" ]]; then
_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)"
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
fi
if [[ -z "$TOKEN" ]]; then
echo "拿不到 Gitea token——設 GITEA_TOKEN_CLAUDE_CODE 環境變數,或在 .env/頂層 .env 放 GITEA_TOKEN_CLAUDE_CODE=" >&2
echo "拿不到 Gitea token——設 GITEA_TOKEN 環境變數,或確認 $INKSTONE_TOP 的 gitea remote 存在" >&2
exit 1
fi
-44
View File
@@ -1,44 +0,0 @@
#!/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
}
+8 -14
View File
@@ -61,21 +61,15 @@ done
[ -f "$ROSTER" ] || { echo "找不到 roster$ROSTER" >&2; exit 1; }
# ── Gitea base乾淨網址;token 來源=env.env,不從 URL 抽——inkstone/ISEP#47──────────
_GT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
[ -f "$_GT_DIR/lib/gitea-token.sh" ] && . "$_GT_DIR/lib/gitea-token.sh"
REMOTE=$(git -C "$ROOT" remote get-url gitea 2>/dev/null || git -C "$ROOT" remote get-url origin 2>/dev/null || true)
[ -n "$REMOTE" ] || { echo "本 repo 沒有 gitea/origin remote,無法取 repo。" >&2; exit 1; }
# 去掉網址裡可能還嵌著的舊憑證(遷移期相容)→ 乾淨 baseclone/fetch 靠 credential helper 認證
REMOTE=$(printf '%s' "$REMOTE" | sed -E 's#//[^/@]*@#//#')
# ── Gitea base含憑證,**絕不可印出來**)────────────────────────────────
REMOTE=$(git -C "$ROOT" remote get-url gitea 2>/dev/null || true)
[ -n "$REMOTE" ] || { echo "本 repo 沒有 gitea remote,無法取 repo。" >&2; exit 1; }
BASE=${REMOTE%/InkStoneCo.git}
BASE=${BASE%/InkStoneCo}
SELF=$(basename "$REMOTE" .git)
# scrub 用的 SECRETtoken 從 env.env 取(不從 URL 抽),用來遮掉萬一夾帶在錯誤訊息裡的 token
SECRET=""; command -v gitea_token >/dev/null 2>&1 && SECRET=$(gitea_token || true)
SECRET=$(printf '%s' "$REMOTE" | sed -nE 's|.*//[^:]+:([^@]+)@.*|\1|p')
# 任何要外流的字串都先過這關(錯誤訊息可能夾帶 clone URL)
scrub() { if [ -n "$SECRET" ]; then sed "s|$SECRET|***|g"; else cat; fi; }
# clone/fetch 的認證:乾淨 URL + credential helpertoken 不進 URL、不洩進 log
GIT_CRED=""; [ -f "$_GT_DIR/git-credential-gitea.sh" ] && GIT_CRED="-c credential.helper=$_GT_DIR/git-credential-gitea.sh"
mkdir -p "$CACHE"
# 自己這個 repo 一律讀工作副本 ⇒ 快取裡若留著一份舊的自己,是誤導來源(會被人拿去讀)
@@ -101,7 +95,7 @@ for repo in $REPOS; do
if [ -d "$dst/.git" ]; then
if [ "$DO_FETCH" = 1 ]; then
br=$(git -C "$dst" symbolic-ref --short HEAD 2>/dev/null || echo main)
git $GIT_CRED -C "$dst" fetch --quiet origin "$br" 2>&1 | scrub >&2 || true
git -C "$dst" fetch --quiet origin "$br" 2>&1 | scrub >&2 || true
git -C "$dst" reset --quiet --hard FETCH_HEAD 2>/dev/null || true
fi
else
@@ -110,7 +104,7 @@ for repo in $REPOS; do
fi
# blobless + sparse:只下載 wiki 目錄的內容,但保留完整 commit 歷史
#(要歷史才算得出「每個檔最後更新是哪天」——淺 clone 會讓所有檔同一天)
if ! err=$(git $GIT_CRED clone --quiet --filter=blob:none --sparse "$BASE/$repo.git" "$dst" 2>&1 | scrub); then
if ! err=$(git clone --quiet --filter=blob:none --sparse "$BASE/$repo.git" "$dst" 2>&1 | scrub); then
printf '%s\tclone-failed\t%s\n' "$repo" "$(printf '%s' "$err" | tr '\n' ' ')" >> "$STATUS_TSV"
rm -rf "$dst"; continue
fi
@@ -318,7 +312,7 @@ if gap > 0:
f"Gitea `Leo/*` 在 {ASOF} 實查是 **{TOTAL} 個**,名單 {len(repos)} 個 ⇒ **還有 {gap} 個沒被點名**。")
w(" 補法(人發起,一次呼叫,不排程)——拿到完整清單、把缺的名字加進名單再重跑:")
w(" ```")
w(" TOKEN=$GITEA_TOKEN_CLAUDE_CODE # 來源=env.env,不從 remote URL 抽(inkstone/ISEP#47")
w(" TOKEN=$(git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\\1|')")
w(" curl -s -H \"Authorization: token $TOKEN\" \\")
w(" 'https://git.uncle6.me/api/v1/orgs/Leo/repos?limit=100' | python3 -c \\")
w(" 'import sys,json;[print(r[\"name\"]) for r in json.load(sys.stdin)]'")