From 58c3689e4baef0c1cec2a782e2be71cd63bba840 Mon Sep 17 00:00:00 2001 From: isep-hand Date: Mon, 31 Aug 2026 11:01:45 +0000 Subject: [PATCH] =?UTF-8?q?=E9=96=98=E6=95=99=E7=9A=84=E9=82=A3=E6=A2=9D?= =?UTF-8?q?=E5=87=BA=E8=B7=AF=E8=A6=81=E7=9C=9F=E7=9A=84=E8=B5=B0=E5=BE=97?= =?UTF-8?q?=E9=80=9A=EF=BC=88inkstone/ISEP#112=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit comment-carries-task-guard 擋下留言時教人跑 `scripts/ticket subtask …`, 而那一行跑不動。兩個獨立的原因,各自都足以害死它: ① 相對路徑對「貼上去那個人的 cwd」解析。總管的 cwd 是 InkStoneCo/, 那裡的 scripts/ticket 是停在 08-27 之前的舊複本(grep -c subtask → 0), 照著貼只印出它「四個動詞」的說明然後 exit 0。 ② 用法是手抄的第二份,而它已經漂了:寫了 --assign 卻沒有 --next, 正本會因為「指派了人卻沒寫 --next」直接擋下(exit 2,在打 API 之前)。 修法是拿掉可以漂的那一格,不是把兩份對齊一次: - 路徑 scripts/ticket 的 self_path()(__file__ 的絕對路徑) ⇒ 印指令的人與跑指令的人是同一個檔案 - 用法 USAGE / usage_text() 一份,閘去 `ticket usage subtask --example` 現要 - 內文模板 body_template() 由 REQUIRED_SECTIONS 現生,跟檢查同源 新增動詞 `ticket usage [--example] [--parent] [--draft]`。 閘的訊息改成整塊可貼可跑:腳本絕對路徑、母票已填、內文草稿附在同一塊裡, 只剩標題/目標/驗收條件三格是機器不可能知道的。 正本叫不動時 fail-open 的只有「訊息內容」(只指路,不補一份手抄用法), 擋不擋不受影響。 測試 scripts/test-comment-carries-task-guard.sh 22 → 29 條。 新增的 ⑮–㉑ 驗的是「出路走不走得通」,不是「有沒有擋」—— ⑱ 把閘 stderr 裡那塊指令原樣抽出來、只填三格、真的執行一次, 離開碼必須是 1(走到網路才停);是 2 就代表這個病復發。 六個變異全部驗過會紅(相對路徑/閘自己抄用法/--assign 沒配 --next/ 模板寫死少一段/fallback 補回手抄用法)。 版本:待總管定版(plugin update 比的是版本號不是內容)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SP7DiGMfSkWjZ15CHMbPFE --- docs/TESTING.md | 23 +++- docs/governance/sdd-gitea-governance.md | 2 +- docs/hooks-inventory.md | 2 +- hooks/comment-carries-task-guard.sh | 86 ++++++++++-- scripts/test-comment-carries-task-guard.sh | 152 ++++++++++++++++++++ scripts/ticket | 153 +++++++++++++++++++-- system-dev/wiki/mistakes.md | 45 ++++++ 7 files changed, 440 insertions(+), 23 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index b7e9dd8..0bcf7b9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -71,15 +71,32 @@ bash scripts/test-ticket-where-seen-guard.sh 人就學會忽略它 - 最後那組 `fd_is_devnull` 紅 ⇒ 判準從「fstat 問得出來的事實」滑回猜文字 -### A11 — 討論串裡的任務要長成子票:22 條 +### A11 — 討論串裡的任務要長成子票:29 條 ``` bash scripts/test-comment-carries-task-guard.sh ``` -**該看到**:`22/22 通過`。 +**該看到**:`29/29 通過`。全程離線(`TICKET_HOST` 指到連不上的位址),不開任何測試票。 **失敗**: - 「該擋」6 條任一紅 ⇒ 08-26 那則真的掉了的留言形狀(「等雲端那半出貨才驗得了」)會漏抓 - 「不該擋」10 條任一紅 ⇒ **誤攔,這比漏擋嚴重**——每次留言都被擋,人就學會忽略它 -- 最後兩條(`-F <檔>`)紅 ⇒ 內文放在檔案裡時閘看不到,等於走 `ticket say` 就自動繞過 +- `-F <檔>` 那兩條紅 ⇒ 內文放在檔案裡時閘看不到,等於走 `ticket say` 就自動繞過 +- **⑮~⑳(`inkstone/ISEP#112`)**:這一組驗的不是「有沒有擋」,是**擋下來之後教的那條路走不走得通**。 + - ⑮⑯ 紅 ⇒ 訊息又印出相對路徑的 `scripts/ticket`。相對路徑是對**貼上去那個人的 cwd** + 解析的,而 `InkStoneCo/scripts/ticket` 是舊複本(`grep -c subtask` → 0) + ⇒ 照著貼會印出它「四個動詞」的說明然後什麼都沒發生。 + - ⑰ 紅 ⇒ 閘又自己抄了一份用法(它應該去 `ticket usage subtask` 現要)。 + - **⑱ 紅 ⇒ 最嚴重**:把訊息裡那塊指令原樣抽出來、只填標題/目標/驗收條件三格, + **真的執行**,離開碼必須是 1(走到網路才停)。是 2 就表示又被某道閘擋下, + 這張票的病復發了。 + - ⑲ 是 ⑱ 的鑑別力對照(把閘原本那組 `--assign` 沒配 `--next` 接回去,必須紅); + ⑲ 綠不了就代表 ⑱ 那條綠燈沒有意義。 + - ⑳ 紅 ⇒ 內文草稿模板跟 `REQUIRED_SECTIONS` 分家了,會生出「照著貼卻過不了自己那道閘」的票。 + - ㉑ 紅 ⇒ 正本叫不動時(雲端裝壞、路徑不對)閘不是不擋了,就是**又在訊息裡補了一份手抄用法**。 + fail-open 的只准是「訊息內容」,不准是「擋不擋」;而補一份副本就是把這張票拔掉的東西裝回去。 + +> 🔴 **這一組刻意驗訊息內容,不是只驗離開碼。** `system-dev/wiki/mistakes.md` +> 記過:訊息壞掉時閘照樣 `exit 2`,只看離開碼完全看不出來—— +> 而這張票(`inkstone/ISEP#112`)就是那個形狀的極端版:**閘擋對了,但它教的解法根本不存在。** ### A12 — 收工要把棒子交回來:10 條 ``` diff --git a/docs/governance/sdd-gitea-governance.md b/docs/governance/sdd-gitea-governance.md index a19138a..9a6d3a2 100644 --- a/docs/governance/sdd-gitea-governance.md +++ b/docs/governance/sdd-gitea-governance.md @@ -853,7 +853,7 @@ scripts/ticket mine [--user <誰>] | 閘 | 掛在哪 | 抓什麼 | 測試 | |---|---|---|---| -| `hooks/comment-carries-task-guard.sh` | PreToolUse `Bash` | 留言裡帶「等 X 才…/驗不了/還沒…」這種**未完成的未來式**,卻沒開子票 → 擋一次 | `scripts/test-comment-carries-task-guard.sh`(22 條) | +| `hooks/comment-carries-task-guard.sh` | PreToolUse `Bash` | 留言裡帶「等 X 才…/驗不了/還沒…」這種**未完成的未來式**,卻沒開子票 → 擋一次 | `scripts/test-comment-carries-task-guard.sh`(29 條) | | `hooks/baton-handback-guard.sh` | PostToolUse `Agent\|Task` | 一條線收工,票上**指派/tag/下一步**缺哪一格 → 當場說出來(提醒,不擋) | `scripts/test-baton-handback-guard.sh`(10 條) | 🔴 **兩支閘的誤攔設計**:`comment-carries-task-guard` 同一 session 只擋一次, diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 53b6354..52d2246 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -128,7 +128,7 @@ | `milestone-due-guard.sh` | AI 想開一個新的里程碑(milestone)卻沒填期限就擋下,**填 `9999-01-01` 一樣擋**——那是「沒有期限」穿了一件期限的衣服,盤點時每一格看起來都有值,其實一格都沒有。只管「建里程碑」這個動作,查詢/改別的欄位都放行。 | 🛑 擋 | | `milestone-account-guard.sh` | AI 想直接打 API 把某個里程碑改成「已關閉」,卻**還沒留下「估多久/花多久/差多少/為什麼差」**就擋下。跟上一列是同一條線的兩端:那支管**開**里程碑要有真的期限,這支管**關**的時候要留下帳。走正門 `scripts/milestone-account close` 就會自己算(數字取自 Gitea 的 `created_at`/`due_on`/`closed_at`,不用手填),差超過 ±25% 才要你從**七個固定代號**裡挑一個,而且那個代號要有 Gitea 時間軸撐得住——**不採信自述**。純讀取/建里程碑/改期限/只是談論它/走正門,全部放行;真要手動關就在指令裡加 `milestone-account-ok`(留痕)。 | 🛑 擋 | | `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 | -| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) | +| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。**擋下來時印的是一整塊可以直接貼著跑的指令**(腳本用絕對路徑、母票已經填好、內文草稿附在同一塊裡),那塊字是跟 `scripts/ticket usage subtask` 現要的,不是閘自己抄的一份(`inkstone/ISEP#112`)。 | 🛑 擋(同一輪只擋一次) | | `search-is-not-proof-guard.sh` | AI 用知識庫的**搜尋**找到一筆東西,然後把那筆東西的原始編號直接貼進票裡或派工單裡,當成「某個功能壞了」的證據——而它從沒用正規的檢索路徑去查過那筆——擋一次。你關心的是:不會再有人拿錯的方法量出一個錯的結論,然後退回一張其實已經做完的票。 | 🛑 擋(同一輪只擋一次) | | `line-needs-own-worktree.sh` | 一條派工線想在**大家共用的那份工作目錄**裡切分支(`git checkout`/`git switch`)就擋下,叫它先開一份自己的 worktree。你關心的是:不會再有人交回工作時把某個 repo 留在自己的分支上,而下一個人在同一個目錄跑出貨、讀到的是別人留下的 HEAD。**總管在自己的目錄裡切分支不管**(那是他的目錄);已經在自己 worktree 裡的切換全部放行;還原檔案(`git checkout -- 檔`)不管。擋下來的同時會順手把「指向空氣」的 worktree 登記清掉。 | 🛑 擋 | | `diagnosis-evidence-guard.sh` | 你想把一段有份量的東西寫上票,而這個 session **沒派人去查過那張票**、那段話裡也**沒有任何走得過去的出處**(檔案:行號/commit/comment 號/票號/網址/貼出來的實測輸出)就擋一次——未經調查的診斷寫在票上會長得像事實,工人會照著它去驗證而不是去查。派過人查、或把出處帶上,都放行;一句短回覆不管。 | 🛑 擋 | diff --git a/hooks/comment-carries-task-guard.sh b/hooks/comment-carries-task-guard.sh index e35c584..6005298 100755 --- a/hooks/comment-carries-task-guard.sh +++ b/hooks/comment-carries-task-guard.sh @@ -103,10 +103,64 @@ WARNED="/tmp/.comment-task-guard-warned-$SID" [ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆 date +%s > "$WARNED" -cat >&2 < --draft <草稿路徑> +# 正本印的路徑是它自己的 `__file__`(絕對路徑)⇒ 印指令的人與跑指令的人 +# 是同一個檔案,沒有第二種可能。 +ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +TICKET="$ROOT/scripts/ticket" + +# 母票是誰:兩條路都認(走正門 `ticket say`,或直接打 API)。認不出來就留 <母票 …>, +# 正本那邊會照樣印出可讀的樣板——**寧可少填一格,不要猜錯一張票**。 +PARENT=$(printf '%s' "$CMD" | python3 -c ' +import re, sys +c = sys.stdin.read() +m = re.search(r"/repos/([\w.-]+)/([\w.-]+)/issues/(\d+)/comments", c) +if m: + print("%s/%s#%s" % m.groups()); raise SystemExit +m = re.search(r"\bticket\s+say\s+([\w.-]+)/([\w.-]+)#(\d+)", c) +if m: + print("%s/%s#%s" % m.groups()) +' 2>/dev/null) + +DRAFT="/tmp/isep-subtask-$SID.md" +EXAMPLE=$(python3 "$TICKET" usage subtask --example \ + ${PARENT:+--parent "$PARENT"} --draft "$DRAFT" 2>/dev/null) || EXAMPLE="" +MORE=$(python3 "$TICKET" usage subtask 2>/dev/null) || MORE="" + +# fail-open 的**訊息**(不是閘):正本叫不動時仍然要擋,但不准在這裡補一份手抄用法 +# ——那樣就把剛拔掉的那個副本裝回來了。只指路,指到唯一那一份。 +LEAD="整段複製貼上就跑得動。腳本是絕對路徑、母票已經填好、必填段落已經在草稿裡, + 只剩三格是機器不可能知道的:標題、目標、驗收條件——改完那三格再送。 + (下面這塊**不要縮排**,\`<<'MD'\` 的結束標記要頂在行首。)" +if [ -z "$EXAMPLE" ]; then + LEAD="把它長成子票。**這台機器叫不動正本,所以下面只給路、不給整塊指令** + ——這裡刻意不補一份手抄的用法,那份副本正是 inkstone/ISEP#112 拔掉的東西。" + EXAMPLE="python3 $TICKET usage subtask --example" + MORE="" +fi + +# 🔴 訊息一律用 `<<'EOF'`(標記加引號)+ 事後用 python 換佔位符。 +# ISEP wiki mistakes.md 記過:不加引號的 heredoc 會把訊息裡的反引號當命令執行, +# 而**閘照樣 exit 2,所以只看離開碼的測試完全看不出來**。 +MSG=$(cat <<'EOF' 🎫 這則留言裡藏著一件**還沒做完的事**,而它現在只是文字。 -命中的詞:$HIT +命中的詞:__HIT__ 【leo 2026-08-27】「**每個任務長出子票做為這張票的相依⋯⋯票沒完工可以察覺嗎?**」 @@ -115,18 +169,32 @@ cat >&2 < \\ - --title "身為<誰>,我要<什麼>,我才<為什麼>" \\ - -F <內文檔> --label s/todo --assign <誰做> - - ⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證。** +__EXAMPLE__ +__MORE__ + ⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證**(Gitea 原生相依,實測 412)。 【粒度】leo 2026-08-27:「**小任務做完就關閉,不會讓池子爆掉, 每個事情沒有歷史記錄才是大問題**」⇒ 判準是「這件事需要被記得嗎」, 傾向**多開票**。不要用「會不會太多」當理由不開。 -【真的不是待辦】在指令裡加 \`no-subtask: <一句理由>\` 重送(留痕)。 +【真的不是待辦】在指令裡加 `no-subtask: <一句理由>` 重送(留痕)。 EOF +) + +MORE_BLOCK="" +[ -n "$MORE" ] && MORE_BLOCK=" + 還有哪些參數(指派給誰+下一步、旅程標籤、換 repo): +$(printf '%s' "$MORE" | sed 's/^/ /; s/[[:space:]]*$//') +" + +HIT="$HIT" LEAD="$LEAD" EXAMPLE="$EXAMPLE" MORE_BLOCK="$MORE_BLOCK" MSG="$MSG" python3 -c ' +import os, sys +m = os.environ["MSG"] +for k in ("HIT", "LEAD", "EXAMPLE"): + m = m.replace("__%s__" % k, os.environ.get(k, "")) +m = m.replace("__MORE__", os.environ.get("MORE_BLOCK", "")) +sys.stderr.write(m + "\n") +' exit 2 diff --git a/scripts/test-comment-carries-task-guard.sh b/scripts/test-comment-carries-task-guard.sh index ba92f94..d9774ae 100755 --- a/scripts/test-comment-carries-task-guard.sh +++ b/scripts/test-comment-carries-task-guard.sh @@ -64,6 +64,158 @@ printf '已驗完,全部綠燈\n' > "$TMPF" run 0 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF" rm -f "$TMPF" "/tmp/.comment-task-guard-warned-$SID" +echo "── 閘教的那行指令要真的跑得動(inkstone/ISEP#112)──" +# 🔴 這一組**不看離開碼**,看訊息內容、而且**真的把訊息裡那塊指令執行一次**。 +# ISEP wiki mistakes.md 記過:訊息壞掉時閘照樣 exit 2,只驗離開碼完全看不出來。 +# 這張票就是那個形狀的極端版——閘擋對了,但它教的解法**根本不存在**。 +SID2=test-cctg-msg-$$ +rm -f "/tmp/.comment-task-guard-warned-$SID2" +PAYLOAD=$(python3 -c ' +import json, sys +cmd = ("curl -X POST \"$API/repos/inkstone/arcrun-rag/issues/136/comments\" " + "-d \x27{\"body\":\"Phase 1 的驗收在雲端那半出貨之前驗不了\"}\x27") +print(json.dumps({"session_id": sys.argv[1], "tool_name": "Bash", + "tool_input": {"command": cmd}}, ensure_ascii=False)) +' "$SID2") +MSGFILE=$(mktemp) +printf '%s' "$PAYLOAD" | bash "$H" 2>"$MSGFILE" >/dev/null +rm -f "/tmp/.comment-task-guard-warned-$SID2" + +RESFILE=$(mktemp) +PAYFILE=$(mktemp); printf '%s' "$PAYLOAD" > "$PAYFILE" +rm -f "/tmp/.comment-task-guard-warned-$SID2" +MSGFILE="$MSGFILE" RESFILE="$RESFILE" GUARD="$H" PAYLOAD="$PAYFILE" python3 - <<'PY' +import io, os, re, subprocess, sys + +msg = io.open(os.environ["MSGFILE"], encoding="utf-8").read() +res = [] + + +def t(ok, name, detail=""): + res.append((bool(ok), name, detail)) + + +here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) \ + if "__file__" in dir() else os.getcwd() +ticket = os.path.realpath(os.path.join(os.getcwd(), "scripts", "ticket")) + +# ⑮ 那塊指令**真正要執行的那一行**,開頭是絕對路徑,而且指到這一份正本。 +# 🔴 判準刻意放在「要執行的那一行」上,不是「訊息裡出現過的所有路徑」—— +# 後者在相對路徑混進來時是**空過**的(別的行還有絕對路徑,all() 照樣真)。 +# 2026-08-31 變異測試當場抓到:把 example 改回相對路徑,舊寫法的 ⑮ 沒紅。 +run_line = "" +for l in msg.splitlines(): + if " subtask " in l and l.strip().endswith("\\"): + run_line = l.strip() + break +first = run_line.split()[0] if run_line else "" +t(first.startswith("/") and os.path.realpath(first) == ticket, + "⑮ 要執行的那一行開頭是絕對路徑,且就是這一份正本", + "那一行:%s" % (run_line or "(找不到)")) + +# ⑯ 訊息裡**不准**出現相對的 `scripts/ticket` +# 相對路徑是對「貼上去那個人的 cwd」解析的,而 InkStoneCo/scripts/ticket 是舊複本。 +rel = [l for l in msg.splitlines() + if re.search(r"(? ")) + md = next(k for k in range(i, len(lines)) if lines[k] == "MD") + j = next(k for k in range(md + 1, len(lines)) if not lines[k].endswith("\\")) + return "\n".join(lines[i:j + 1]) + + +def fill(block): + """只填三格機器不可能知道的:標題、目標、驗收條件。其餘一個字都不改。""" + block = (block.replace("<誰>", "跑這行指令的人") + .replace("<什麼>", "它真的跑得動") + .replace("<為什麼>", "不會被迫去繞過它")) + return re.sub(r"<[^<>\n]+>", "測試填的內容", block) + + +ENV = dict(os.environ, TICKET_HOST="http://127.0.0.1:9", + GITEA_TOKEN_CLAUDE_CODE="dummy", GITEA_TOKEN="dummy") + +# ⑱ ★ 把它原樣貼上、只填那三格,**真的跑一次**。 +# 離開碼 2 = 被某道閘擋下(動詞不存在/旗標不成組/標題不合格…)= 這張票的病復發 +# 離開碼 1 = 一路走到網路才炸(TICKET_HOST 指到連不上的位址)= 參數全部過關 +try: + blk = fill(extract_block(msg)) + r = subprocess.run(["bash", "-c", blk], capture_output=True, text=True, env=ENV) + code = r.returncode +except StopIteration: + blk, code, r = "", -1, None +t(code == 1, "⑱ ★ 訊息裡那塊指令原樣貼上就跑得完(走到網路才停,不是被閘擋)", + "離開碼 %s(2 = 又被某道閘擋下)%s" + % (code, ("|" + (r.stderr or "").strip().splitlines()[0][:80]) if r and code == 2 else "")) + +# ⑲ 鑑別力對照:⑱ 不是「只要跑就會過」。 +# 把閘**原本**印的那組旗標(`--assign <誰做>` 沒配 `--next`)接回去 ⇒ 必須紅。 +if blk: + bad = blk.rstrip() + " --assign claude-code" + rb = subprocess.run(["bash", "-c", bad], capture_output=True, text=True, env=ENV) + t(rb.returncode == 2, + "⑲ 對照組:閘原本那組旗標(--assign 沒配 --next)確實跑不完", + "離開碼 %s(要是 2;不是 2 就表示 ⑱ 沒有鑑別力)" % rb.returncode) +else: + t(False, "⑲ 對照組:抽不出指令區塊", "") + +# ⑳ 草稿模板與必填檢查**同源**:模板裡一定有 REQUIRED_SECTIONS 的每一段。 +# 寫死一份模板的話,有人往 REQUIRED_SECTIONS 加一段就會生出 +# 「照著貼卻過不了自己那道閘」的票——正是這張票要修的形狀。 +req = subprocess.run( + [sys.executable, "-c", + "import runpy,sys;m=runpy.run_path(sys.argv[1]);" + "print('\\x00'.join(m['REQUIRED_SECTIONS']));" + "print(m['body_template']())", ticket], + capture_output=True, text=True) +head, _, tmpl = req.stdout.partition("\n") +secs = [x for x in head.split("\x00") if x] +t(secs and all(x in tmpl for x in secs) and all(x in msg for x in secs), + "⑳ 草稿模板由 REQUIRED_SECTIONS 現生,訊息裡也齊", + "必填段:%s" % secs) + +# ㉑ 正本叫不動時(雲端裝壞、路徑不對):**閘照樣擋**,而且**不准就地補一份手抄用法** +# ——那份副本正是這張票拔掉的東西。fail-open 的是「訊息內容」,不是「擋不擋」。 +fb = subprocess.run(["bash", os.environ["GUARD"]], + input=io.open(os.environ["PAYLOAD"], encoding="utf-8").read(), + capture_output=True, text=True, + env=dict(os.environ, CLAUDE_PLUGIN_ROOT="/tmp/isep-no-such-root")) +fbmsg = fb.stderr +t(fb.returncode == 2 and "usage subtask" in fbmsg + and "--title" not in fbmsg and "[--journey" not in fbmsg, + "㉑ 正本叫不動時仍然擋,且只指路、不補一份手抄用法", + "離開碼 %s|訊息裡有沒有手抄用法:%s" + % (fb.returncode, "有(違規)" if "[--journey" in fbmsg else "沒有")) + +npass = 0 +for ok, name, detail in res: + print(" %s %s%s" % ("✅" if ok else "❌", name, + (" ※ " + detail) if (detail and not ok) else "")) + npass += 1 if ok else 0 +io.open(os.environ["RESFILE"], "w", encoding="utf-8").write( + "%d %d" % (npass, len(res) - npass)) +PY +read -r P2 F2 < "$RESFILE" +PASS=$((PASS + P2)); FAIL=$((FAIL + F2)) +rm -f "$MSGFILE" "$RESFILE" "$PAYFILE" "/tmp/isep-subtask-$SID2.md" \ + "/tmp/.comment-task-guard-warned-$SID2" + echo echo "$PASS/$((PASS+FAIL)) 通過" [ "$FAIL" = 0 ] diff --git a/scripts/ticket b/scripts/ticket index 75e6ffc..ec586f3 100755 --- a/scripts/ticket +++ b/scripts/ticket @@ -26,6 +26,8 @@ leo 2026-08-16 三句話,本工具就是它們的機械化: ticket mine [--user <誰>] 撈一次:棒子在誰手上、每根下一步是什麼 ticket loose 撈一次:下游都關了、自己還開著的頂層票 + ticket usage <動詞> [--example] 用法的唯一真相源——**閘印的那一行從這裡來** + 票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44` """ import json @@ -504,6 +506,10 @@ def _writeback(owner, repo, num, deliv): for i in open_left] lines += ["", "⇒ 本票維持原狀,不要當成可以收了。"] else: + # 🔴 這一段是要**貼進 Gitea 留言**的,所以 `ticket close` 刻意寫成相對的 + # ——留言會被別台機器讀到,寫死本機的絕對路徑等於寫死一個他們沒有的檔案。 + # 絕對路徑那條規約(inkstone/ISEP#112)管的是**印到終端機、要人複製貼上**的字, + # 兩者分界就是這一句:「這行字會被誰複製、在哪台機器上跑?」 lines += ["", "**這是最後一張下游——本票的下游已經全部關閉。**", "", f"{BATON_MARK} → `{HANDBACK_TO}`", "", "**下一步**:驗一次頂層要的東西真的到齊了,到齊就 " @@ -719,6 +725,134 @@ def writeback_plan(parent_state, parent_deps): return ("note" if open_left else "handback"), open_left +# ══════════════════════════════════════════════════════════════════════════ +# inkstone/ISEP#112 — 閘教的那行指令要真的跑得動 +# +# 票上的原話:「**當它教的做法不存在,它就從『幫你』變成『擋你』**—— +# 而且被擋的人當下正在做別的事,最可能的反應是找個理由繞過去。」 +# +# 那次它為什麼跑不動,兩個獨立的原因(2026-08-31 實測,兩個都會單獨害死它): +# +# ① **相對路徑**。閘印的是 `scripts/ticket subtask …`,而相對路徑是對 +# **貼上去那個人的 cwd** 解析的。總管的 cwd 是 `InkStoneCo/`,那裡的 +# `scripts/ticket` 是本檔停在 2026-08-27 之前的**舊複本**: +# grep -c subtask InkStoneCo/scripts/ticket → 0 +# 它的 docstring 只列到「四個動詞」,於是照著貼會印出那段說明、exit 0、 +# 什麼都沒發生 —— 正是票上寫的症狀。 +# (`hooks/lib/beacon_report.py` ② 已經會在 SessionStart 報這個舊複本, +# 但**報告不會改掉閘印出來的那一行**。) +# +# ② **用法各寫一份**。閘手寫了一段用法,`cmd_subtask` 的 die() 又寫了一段。 +# 兩份已經漂了:閘那份寫 `--assign <誰做>` 卻**沒有 `--next`**, +# 而 cmd_subtask 會因為「🚫 指派了人卻沒寫 --next。」直接 die(實測 exit 2, +# 而且是在打任何 API 之前,所以離線也複現得出來)。 +# ⇒ 就算路徑對了,那一行照樣跑不完。 +# +# 🔴 修法是**拿掉可以漂的那一格**,不是把兩份對齊一次: +# · 路徑 → self_path(),`__file__` 的絕對路徑。印指令的人與跑指令的人 +# 是同一個檔案,沒有第二種可能。 +# · 用法 → USAGE 這一份。閘去 `ticket usage subtask` 拿,不自己抄。 +# · 內文模板 → body_template() 由 REQUIRED_SECTIONS 現生。 +# 檢查與模板同一個來源 ⇒ 模板不可能少一段。 +# ══════════════════════════════════════════════════════════════════════════ + +def self_path(): + """這支腳本自己的絕對路徑。**閘印出來的指令一律用它。** + + 不用 `sys.argv[0]`:那是「別人怎麼叫我」,被相對路徑叫進來時它就是相對的。 + `__file__` 走 realpath 是「我是誰」——貼到任何 cwd 底下都指得回同一個檔案。 + """ + return os.path.realpath(__file__) + + +# 每一段必填欄位的草稿提示。**鍵一定要是 REQUIRED_SECTIONS 裡的字串**—— +# 對不上也不會漏掉那一段(下面會退成 `<填這裡>`),只是提示變笨。 +SECTION_HINTS = { + "## 目標": "<這件事要達成什麼。寫目的,不要寫做法>", + "## 驗收條件": "- [ ] <怎麼算做完——別人可以自己跑一次的那種>", + "## deliverable 類型": "code", +} + + +def body_template(): + """子票內文的草稿,**用 REQUIRED_SECTIONS 現生**。 + + 🔴 不要把這段字面寫死:寫死就變成第二份,而 `cmd_subtask` 檢查的是 + REQUIRED_SECTIONS。有人往那個清單加一段,寫死的模板就會生出一張 + **照著貼卻過不了自己那道閘**的票 —— 這張票要修的就是這個形狀。 + """ + out = [] + for sec in REQUIRED_SECTIONS: + out.append(sec) + out.append(SECTION_HINTS.get(sec, "<填這裡>")) + out.append("") + return "\n".join(out).rstrip() + "\n" + + +def subtask_example(parent=None, draft=None): + """可以整段複製貼上的那一塊:先把內文草稿寫出來,再開子票。 + + 刻意**不放 `--assign`**:`--assign` 沒配 `--next` 會被擋(見 cmd_subtask), + 而一行印出來卻跑不完的指令,正是這張票在修的病。要指派的人看 USAGE 那份, + 那裡兩個參數是綁在一起寫的。 + + 留在 <> 裡的只有三件**機器不可能知道**的事:標題、目標、驗收條件。 + 其餘(腳本路徑、母票、必填段落、標籤)全部已經填好。 + """ + parent = parent or "<母票 owner/repo#N>" + draft = draft or "/tmp/subtask-body.md" + return ( + "cat > %s <<'MD'\n" % draft + + body_template() + + "MD\n" + + "%s subtask %s \\\n" % (self_path(), parent) + + ' --title "身為<誰>,我要<什麼>,我才<為什麼>" \\\n' + + " -F %s --label s/todo\n" % draft + ) + + +# 🔴 `{bin}` 一定要留成佔位符、由 usage_text() 現填 self_path(): +# 用法裡寫死 `ticket` 或 `scripts/ticket`,讀的人照著貼就又踩回這張票的坑。 +USAGE = { + "subtask": + "用法:{bin} subtask <母票 owner/repo#N> --title \"\" -F <內文檔>\n" + " [--repo <收件 repo,預設跟母票同一個>] [--label ]\n" + " [--journey <旅程名>] ← 這件事服務哪條使用者旅程(對不上就不要加)\n" + " [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 這兩個是一組," + "指派了就一定要寫下一步\n" + "\n(`ticket handoff` 是同一個動作的別名:頂層票 → 下游 repo)", +} + + +def usage_text(verb): + """用法的**唯一**一份,路徑現填成絕對路徑(inkstone/ISEP#112)。""" + return USAGE[verb].format(bin=self_path()) + + +def cmd_usage(argv): + """把「用法」與「可以貼著跑的那一塊」交給同一個地方產生(inkstone/ISEP#112)。 + + 閘的訊息**呼叫這一支**,不自己抄一份 ⇒ 訊息與能跑的指令之間沒有第二份可以漂。 + """ + verbs = "|".join(sorted(USAGE)) + if not argv or argv[0] not in USAGE: + die("用法:ticket usage <%s> [--example] [--parent ] " + "[--draft <內文檔路徑>]\n" + " --example = 印一塊可以整段貼著跑的指令(路徑是絕對路徑,母票已填好)" % verbs) + verb = argv[0] + + def opt(name, default=None): + return argv[argv.index(name) + 1] if name in argv else default + + if "--example" in argv: + if verb != "subtask": + die("🚫 `ticket usage %s --example` 還沒有可貼著跑的版本。\n" + " 先看用法:ticket usage %s" % (verb, verb)) + sys.stdout.write(subtask_example(opt("--parent"), opt("--draft"))) + return + sys.stdout.write(usage_text(verb).rstrip() + "\n") + + # ── subtask ────────────────────────────────────────────────────────────── def cmd_subtask(argv): """把「討論串裡的一件事」長成看得見的子票,並掛成母票的 Gitea 原生相依。 @@ -737,11 +871,9 @@ def cmd_subtask(argv): 不是「新開一條線」——那道閘擋的是後者。 """ if len(argv) < 1: - die("用法:ticket subtask <母票 owner/repo#N> --title \"\" -F <內文檔>\n" - " [--repo <收件 repo,預設跟母票同一個>] [--label ]\n" - " [--journey <旅程名>] ← 這件事服務哪條使用者旅程(對不上就不要加)\n" - " [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 指派了就一定要寫下一步\n" - "\n(`ticket handoff` 是同一個動作的別名:頂層票 → 下游 repo)") + # 🔴 用法只有 USAGE 那一份(inkstone/ISEP#112)。這裡不准再抄一段: + # 抄一段就是又多一個會漂的副本,而閘印的那一行也是從 USAGE 來的。 + die(usage_text("subtask")) powner, prepo, pnum = parse_ref(argv[0]) def opt(name, default=None): @@ -830,7 +962,7 @@ def cmd_subtask(argv): print(f" 旅程:`{jlabel}`(母子兩端都貼——只貼一端就聚不起來)") else: print(" 旅程:沒有貼(沒給 --journey)。對得上就補一次:" - f"`ticket subtask … --journey <旅程名>`;對不上就不要硬湊。") + f"`{self_path()} subtask … --journey <旅程名>`;對不上就不要硬湊。") print(f"\n📌 母票 {powner}/{prepo}#{pnum} 目前有 {len(openn)} 張未關的相依:") for i in deps: mark = "◻" if i["state"] == "open" else "☑" @@ -1026,8 +1158,10 @@ def cmd_loose(argv): for d in deps: print(f" ☑ {d['repository']['full_name']}#{d['number']}" f" {d['title'][:52]}") - print(f" → 驗過就關:ticket close {full}#{n} --deliverable ") - print(f" 還缺東西:ticket subtask {full}#{n} --title \"\" -F <檔>") + # 絕對路徑(inkstone/ISEP#112):相對的 `scripts/ticket` 會被貼到別的 cwd 底下, + # 在那裡叫到的可能是本檔的舊複本(`InkStoneCo/scripts/ticket` 就是)。 + print(f" → 驗過就關:{self_path()} close {full}#{n} --deliverable ") + print(f" 還缺東西:{self_path()} subtask {full}#{n} --title \"\" -F <檔>") print() print("📌 這張表撈的是**機械事實**(相依邊的 state),不是猜文字。") @@ -1038,7 +1172,8 @@ def cmd_loose(argv): CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, "decide": cmd_decide, "subtask": cmd_subtask, "handoff": cmd_subtask, - "handback": cmd_handback, "mine": cmd_mine, "loose": cmd_loose} + "handback": cmd_handback, "mine": cmd_mine, "loose": cmd_loose, + "usage": cmd_usage} if __name__ == "__main__": if len(sys.argv) < 2 or sys.argv[1] not in CMDS: diff --git a/system-dev/wiki/mistakes.md b/system-dev/wiki/mistakes.md index 61cc562..3e7da8f 100644 --- a/system-dev/wiki/mistakes.md +++ b/system-dev/wiki/mistakes.md @@ -154,3 +154,48 @@ leo 當場:「**這些為什麼不寫到票裡?**」 📌 KBDB 缺這一段: 2026-08-31 用 `kbdb_search(mode='semantic')` 查 「SDD 生命週期/單一活性/取消 Active SDD」**0 命中**(最接近的是 2026-08-09 一張講 SDD × Gitea 整合摩擦的卡,那是裁決之前)。⇒ 這條開發史還沒進 KBDB。 + +## ⚠️ MISTAKE: 閘擋對了,但它印出來的那條出路**跑不動** + +票: `inkstone/ISEP#112` +日期: 2026-08-31 + +症狀: `comment-carries-task-guard.sh` 擋下留言時教你跑 + `scripts/ticket subtask …`。照著貼,會印出一段「四個動詞」的說明然後 + 什麼都沒發生。被擋的人當下正在做別的事,最可能的反應是**找個理由繞過去**。 + +實查: 兩個**各自獨立**的原因,任何一個單獨存在都足以害死它—— + ① **相對路徑**。閘印的是 `scripts/ticket`,而相對路徑是對 + **貼上去那個人的 cwd** 解析的。總管的 cwd 是 `InkStoneCo/`, + 那裡的 `scripts/ticket` 是正本停在 2026-08-27 之前的舊複本: + `grep -c subtask InkStoneCo/scripts/ticket` → **0**(正本是 10)。 + ⇒ 「`subtask` 這個子命令不存在」這個結論**在那個 cwd 底下是真的**。 + ② **用法各寫一份**。閘手抄了一段用法,`cmd_subtask` 的 `die()` 又寫了一段。 + 兩份已經漂了:閘那份寫 `--assign <誰做>` 卻**沒有 `--next`**, + 而正本會因為「指派了人卻沒寫 --next」直接擋下(實測 exit 2)。 + ⇒ **就算路徑對了,那一行照樣跑不完。** + +原因: 閘的訊息與它教的那支工具是**兩份各自維護的字**。 + `hooks/lib/beacon_report.py` ② 早就會在 SessionStart 報「專案裡有舊複本」, + **但報告不會改掉閘印出來的那一行**——知道有這個坑,跟那一行會不會踩到它, + 是兩件事。 + +正確做法: + - **路徑**:印出來要人複製的指令,一律用正本自己的 `__file__` 絕對路徑 + (`scripts/ticket` 的 `self_path()`)。印指令的人與跑指令的人是同一個檔案, + 沒有第二種可能。 + ⚠️ 分界線是「**這行字會被誰複製、在哪台機器上跑?**」—— + 要**貼進 Gitea 留言**的指令反而要維持相對寫法(留言會被別台機器讀到)。 + - **用法**:只留一份(`scripts/ticket` 的 `USAGE`/`usage_text()`), + 閘去 `ticket usage subtask --example` 現要,不自己抄。 + - **模板**:子票內文草稿由 `REQUIRED_SECTIONS` 現生(`body_template()`), + 跟檢查同源 ⇒ 不可能生出「照著貼卻過不了自己那道閘」的票。 + +🔴 **驗法要驗到「它跑不跑得動」,不是「它有沒有擋」。** +`scripts/test-comment-carries-task-guard.sh` ⑱ 把閘 stderr 裡那塊指令 +**原樣抽出來、只填三格、真的執行一次**:離開碼 1(走到網路才停)=過, +2(被某道閘擋下)=這個病復發。⑲ 是它的鑑別力對照組。 + +📌 推廣:**一支閘的價值是「擋下來 + 給一條走得通的路」。** +出路走不通時它不是幫你,是擋你——而被擋的人不會停下來修閘, +他會去找繞過去的方法。所以「出路能不能跑」要跟「判準準不準」一樣被測。 -- 2.52.0