3bc7f3c5f5
leo 2026-08-27(inkstone/ISEP#30 comment 4322/4325/4327): 「這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?」 「你用一個 output parser 把你給 subagent 的指令規範,分作幾點,每一點規定格式, 照這種散文寫法根本無法迭代」「警察也不能抓」 「交件方式不需要寫,定義在原則裡⋯⋯每次都一樣提取出來變成共通規定」 「(那些 session 事實)這些為什麼不寫到票裡?」「subagent 回覆時要表明身份」 病根:no-ticket-no-dispatch.sh 驗的是「有沒有一行【工單】owner/repo#N」, 而規則的原文是「派工單只寫票號」。⇒ 把 40 行任務全寫在 prompt 裡、票號補一行, 閘照樣放行。2026-08-27 一天內這樣做了 5 次,每次票上都沒有那份任務。 規則存在,閘只驗了它的殼——同款第 N 次(history-first/KBDB-first/stage-first)。 新增 hooks/dispatch-format-guard.sh(PreToolUse Task|Agent),兩件事: - 擋:【工單】以外還有實質內容就 exit 2,並指出那些內容該搬去哪 (每次都一樣 → 共通規定;這次才知道 → 寫進那張票。 判準「這句話換一張票還成立嗎?」) - 注入:合規的派工自動把共通規定送給收工方(交件方式、不准 push main、 org 是 inkstone…)——這是「派工單只剩票號」能成立的前提, leo 的驗收條件之一就是「收工方沒讀派工單也知道要貼回原票」 判準是結構不是文字(leo 2026-08-17 那條檢驗):問的是「這一行是不是【工單】欄位」 ——在不在,不是寫什麼。hooks/lib/dispatch_parse.py 全檔零個「命中某個詞就違規」的比對。 ⇒ 也因此不需要語意判官:免費、瞬間、每次結果一樣。 新增 hooks/reply-identity-guard.sh(PreToolUse Bash)+ scripts/ticket 內建檢查: 票上每一則留言第一行要有【身份】(總管/subagent/leo)。貼留言有兩條路,兩條都封 ——ticket-api-bypass-guard 是刻意放行「對既有票留言」的,只封正門等於沒封。 實害:多條線並行時總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。 規約寫成文件:docs/governance/dispatch-and-reply-format.md (§2 那段就是被注入的那份共通規定本體——只有一份,改那裡等於改所有派工) 實測(離線、不打網路、不花錢): hooks/tests/dispatch-format-guard.test.sh 19/19 hooks/tests/reply-identity.test.sh 11/11 測資裡的 B⑨ 是真跡:產生 ISEP#30 這條線的那一次派工,一字未改。 另 4 份 leo 點名的違規派工拿不回來了——它們住在 prompt 裡,agent 一停就沒了, 這件事本身就是這條規則的證據(見 hooks/tests/fixtures/README.md,不用想像的例子替補)。 升版 0.4.0 → 0.5.0(產物按版本號分資料夾,不升版新閘不會被載入)。 tag 照慣例打在 merge commit 上,所以這條分支上 check-version-consistency.sh 是紅的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
4.5 KiB
Bash
Executable File
100 lines
4.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 身份欄的兩道閘(inkstone/ISEP#30 comment 4325:「subagent 回覆時要表明身份」)。
|
||
#
|
||
# 貼留言到票上有**兩條路**,所以測兩道閘:
|
||
# 正門 scripts/ticket say / decide —— 檢查在打任何 API 之前,所以本測試離線跑得動
|
||
# 側門 直接打 Gitea API —— hooks/reply-identity-guard.sh
|
||
#
|
||
# **離線、不打網路。** 正門的案例全部在 `die()` 之前就結束,不會真的送出留言。
|
||
#
|
||
# 用法:hooks/tests/reply-identity.test.sh
|
||
|
||
set -u
|
||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||
HOOK="$ROOT/hooks/reply-identity-guard.sh"
|
||
TICKET="$ROOT/scripts/ticket"
|
||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||
|
||
PASS=0; FAIL=0; N=0
|
||
|
||
ok() { printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); N=$((N+1)); }
|
||
bad() { printf ' ❌ %s\n' "$1"; shift; printf '%s\n' "$*" | sed -n '1,6p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1)); N=$((N+1)); }
|
||
|
||
# ── 側門:hooks/reply-identity-guard.sh ─────────────────────────────────
|
||
h() { # h <期望 exit> <說明> <command 字串>
|
||
want="$1"; desc="$2"; cmd="$3"
|
||
body=$(python3 - "$cmd" <<'PY'
|
||
import json, sys
|
||
print(json.dumps({"tool_name": "Bash", "tool_input": {"command": sys.argv[1]}},
|
||
ensure_ascii=False))
|
||
PY
|
||
)
|
||
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||
if [ "$rc" -eq "$want" ]; then ok "$desc"; else
|
||
bad "$desc —— 期望 exit=$want,實得 exit=$rc" "$err"; fi
|
||
}
|
||
|
||
echo "── 側門(直接打 Gitea API)─────────────────────────────────────"
|
||
|
||
h 2 "① POST 留言但沒有【身份】→ 擋" \
|
||
'curl -X POST -H "Authorization: token $T" -d @body.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments'
|
||
|
||
h 0 "② POST 留言且內文帶【身份】→ 放行" \
|
||
'curl -X POST -d "{\"body\":\"【身份】subagent/inkstone/ISEP/feat/x\n做完了\"}" https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments'
|
||
|
||
h 0 "③ 純讀取(GET 撈留言)→ 放行,這是最常做的動作,誤攔它比漏擋更糟" \
|
||
'curl -s -H "Authorization: token $T" https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments?limit=60'
|
||
|
||
h 0 "④ 走正門 scripts/ticket → 放行(正門有自己的閘,兩支同時擋會互相打架)" \
|
||
'scripts/ticket say inkstone/ISEP#30 -F /tmp/body.md'
|
||
|
||
h 0 "⑤ 開新票的端點(不帶票號)→ 不是本閘的地盤" \
|
||
'curl -X POST -d @t.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues'
|
||
|
||
h 0 "⑥ 逃生口 reply-identity-ok → 放行(留在指令歷史上)" \
|
||
'curl -X POST -d @body.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments # reply-identity-ok'
|
||
|
||
h 0 "⑦ 只是在講這件事(把端點寫進文件)而沒有 POST → 不擋" \
|
||
'echo "留言端點是 issues/30/comments" >> docs/notes.md'
|
||
|
||
echo
|
||
echo "── 正門(scripts/ticket)───────────────────────────────────────"
|
||
|
||
g() { # g <期望 exit> <說明> <內文>
|
||
want="$1"; desc="$2"; content="$3"
|
||
printf '%s' "$content" > "$TMP/body.md"
|
||
err=$(python3 "$TICKET" say inkstone/ISEP#30 -F "$TMP/body.md" 2>&1 >/dev/null); rc=$?
|
||
if [ "$rc" -eq "$want" ]; then ok "$desc"; else
|
||
bad "$desc —— 期望 exit=$want,實得 exit=$rc" "$err"; fi
|
||
}
|
||
|
||
g 2 "⑧ 內文沒有身份欄 → 在打 API 之前就擋(所以這一格離線也測得動)" \
|
||
'做完了,分支是 feat/x'
|
||
|
||
g 2 "⑨ 有【身份】但角色不在三選一之內 → 擋" \
|
||
'【身份】機器人/inkstone/ISEP/feat/x
|
||
|
||
做完了'
|
||
|
||
g 2 "⑩ 身份欄不在第一行 → 擋(要一眼看得到,不是藏在中間)" \
|
||
'做完了
|
||
【身份】subagent/inkstone/ISEP/feat/x'
|
||
|
||
# ⑪ 合規的內文要能通過身份檢查——它會往下走到打 API,
|
||
# 離線環境打不出去所以 exit 非 0;判準改成「錯誤訊息不是身份欄那一段」。
|
||
printf '%s' '【身份】subagent/inkstone/ISEP/feat/x
|
||
|
||
做完了' > "$TMP/ok.md"
|
||
err=$(python3 "$TICKET" say inkstone/ISEP#30 -F "$TMP/ok.md" 2>&1 >/dev/null || true)
|
||
if printf '%s' "$err" | grep -q '第一行要表明身份'; then
|
||
bad "⑪ 合規的內文被身份閘擋掉了(誤攔)" "$err"
|
||
else
|
||
ok "⑪ 合規的內文通過身份閘(之後成不成功是網路的事,不是本閘的事)"
|
||
fi
|
||
|
||
echo
|
||
echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗} ══"
|
||
[ "$FAIL" -eq 0 ]
|