diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4630b7f..319e0c5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:49 支機械閘(60 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.7.0", + "version": "0.8.0", "keywords": [ "inkstone", "guardrails", diff --git a/docs/TESTING.md b/docs/TESTING.md index 82f0a04..9c27150 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -53,6 +53,24 @@ bash scripts/test-ticket-api-bypass-guard.sh `⏭️ SKIP`,不算失敗,但也不算驗過)。 **失敗**:任何一條不符,特別看「不該擋」那幾條——誤攔比漏擋更該修。 +### A13 — 搜尋戳記要證明「看過」,不是「跑過」:17 條 +``` +bash scripts/test-ticket-where-seen-guard.sh +``` +**該看到**:`通過 17 條,失敗 0 條`。全程離線(`TICKET_HOST` 指到連不上的位址: +離開碼 2 =被閘擋、離開碼 1 =閘全放行走到網路才炸),**不打真實 Gitea、不留測試票**。 + +**它在守什麼**(inkstone/ISEP#72 → comment 4873,2026-08-27 實犯): +`ticket where … >/dev/null` 之後 `ticket new`——戳記寫成功了,命中的 72 張一眼沒看, +於是開出 `arcrun-rag#147`,而第一名 `arcrun-rag#104` 是同一件事、已經開了 13 天。 + +**失敗**: +- 「★」那兩條紅 ⇒ 今天這個形狀會再發生一次(尤其第二條:先寫好 `--not-a-comment` + 理由就能閉著眼睛開票) +- 「不該擋」3 條任一紅 ⇒ **誤攔**,這比漏擋嚴重——沒命中也吵、看過了還吵, + 人就學會忽略它 +- 最後那組 `fd_is_devnull` 紅 ⇒ 判準從「fstat 問得出來的事實」滑回猜文字 + ### A11 — 討論串裡的任務要長成子票:22 條 ``` bash scripts/test-comment-carries-task-guard.sh @@ -265,6 +283,7 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面 | A2 版本三處一致 | 總管 | ✅ | | A3 打 tag 閘 | 總管 | ✅ 3/3 | | **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/24(2026-08-27,inkstone/ISEP#72) | +| **A13 戳記證明看過不是跑過** | 總管 | ✅ 17/17(2026-08-27,inkstone/ISEP#72→4873) | | A5 搜尋跨 repo | 總管 | ✅ | | A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 | | **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) | diff --git a/scripts/test-ticket-where-seen-guard.sh b/scripts/test-ticket-where-seen-guard.sh new file mode 100755 index 0000000..400e544 --- /dev/null +++ b/scripts/test-ticket-where-seen-guard.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# 「戳記要證明看過,不是跑過」的測試(inkstone/ISEP#72 → comment 4873) +# +# 這支測的是 scripts/ticket 的閘一之二:搜尋跑了,但輸出被丟進 /dev/null 時, +# `ticket new` 要擋下來,並把那份被丟掉的候選清單交到眼前。 +# +# 怎麼在「不真的開票」的前提下分辨擋/放行: +# TICKET_HOST 指到一個連不上的位址(127.0.0.1:9) +# 離開碼 2 = 閘擋下(die) +# 離開碼 1 = 閘全部放行,走到網路那一層才炸(Connection refused) +# 全程離線、不打真實 Gitea、不產生任何票。 +cd "$(dirname "$0")/.." || exit 1 +T=scripts/ticket +STAMP=/tmp/.ticket-where-ok +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0 + +SAVED=""; [ -f "$STAMP" ] && SAVED=$(cat "$STAMP") +rm -f "$STAMP" + +cat > "$TMP/body.md" <<'MD' +## 目標 +測試用內文。 + +## 驗收條件 +離開碼 2 = 被擋;離開碼 1 = 放行(走到網路才炸)。 + +## deliverable 類型 +code +MD +TITLE="身為要開票的人,我要先看過命中的舊票,我才不會又開一張一樣的" + +# stamp +stamp() { + python3 - "$1" "$2" "$3" <<'PY' +import json, sys, time +n, shown, age = int(sys.argv[1]), sys.argv[2], float(sys.argv[3]) +st = {"at": time.time() - age, "kws": ["ingest", "queue"], "n": n, + "top": ["inkstone/arcrun-rag#104"], + "top_detail": [{"ref": "inkstone/arcrun-rag#104", "score": 2, + "title": "它把我整個 repo 的一萬多個檔案排進佇列", + "labels": ["Human"], + "excerpt": "這些庫都早就萃好了,所以它要辨識這個庫已經有 wiki"}]} +if shown != "none": + st["shown"] = (shown == "true") +json.dump(st, open("/tmp/.ticket-where-ok", "w")) +PY +} + +# run <說明> [額外參數...] +run() { + want=$1; what=$2; shift 2 + out=$(TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x \ + python3 "$T" new ISEP -F "$TMP/body.md" --title "$TITLE" "$@" 2>&1) + got=$? + LAST_OUT="$out" + if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi + printf 'want=%s got=%s %s\n' "$want" "$got" "$what" +} + +has() { # has <字串> <說明> + if printf '%s' "$LAST_OUT" | grep -q "$1"; then PASS=$((PASS+1)); printf ' ✅ ' + else FAIL=$((FAIL+1)); printf ' ❌ '; fi + printf '訊息裡有「%s」 %s\n' "$1" "$2" +} + +echo "── 該擋 ──" +rm -f "$STAMP" +run 2 "完全沒搜過(既有閘一,回歸)" +stamp 3 true 3600 +run 2 "戳記過期(既有閘一,回歸)" +stamp 72 false 10 +run 2 "★ 今天的重演:where 的輸出被丟進 /dev/null,命中 72 張一眼沒看" +has "沒有到任何人眼前" "擋下的理由講的是「沒看」不是「沒搜」" +has "arcrun-rag#104" "擋下的訊息把被丟掉的候選清單交到眼前" +has "這些庫都早就萃好了" "連內文摘要都給了(光看標題看不出是同一條線)" +stamp 72 false 10 +run 2 "★ 閉著眼睛先寫好理由也繞不過(--not-a-comment 在前面就被擋)" --not-a-comment "這是新的一條線" +stamp 72 none 10 +run 2 "舊格式戳記(沒有 shown 欄位)→ fail-closed,當成沒看過" --not-a-comment "x" +stamp 72 true 10 +run 2 "命中卻沒給理由(既有閘二,回歸)" + +echo +echo "── 不該擋(誤攔比漏擋更該修)──" +stamp 72 true 10 +run 1 "看過了、也給了理由 → 放行(走到網路才炸)" --not-a-comment "這是新的一條線" +stamp 0 false 10 +run 1 "沒有命中任何票 → 沒東西可看,不要吵" +stamp 0 none 10 +run 1 "沒命中 + 舊格式戳記 → 一樣不吵" + +echo +echo "── 擋完之後:那一次的擋本身就是「把清單交到眼前」 ──" +stamp 72 false 10 +run 2 "第一次:被擋" --not-a-comment "這是新的一條線" +run 1 "第二次下一模一樣的指令 → 放行(不必多打任何字)" --not-a-comment "這是新的一條線" + +echo +echo "── fd_is_devnull:判準是 fstat 問出來的事實,不是猜文字 ──" +u() { # u <說明> + got=$(eval "$3") + if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi + printf 'want=%s got=%s %s\n' "$2" "$got" "$1" +} +PROBE="import importlib.util as u,sys; from importlib.machinery import SourceFileLoader as L; s=u.spec_from_loader('t',L('t','scripts/ticket')); m=u.module_from_spec(s); s.loader.exec_module(m); print(m.fd_is_devnull(1), file=sys.stderr)" +u "stdout 丟進 /dev/null → True" "True" "python3 -c \"\$PROBE\" >/dev/null 2>&1; python3 -c \"\$PROBE\" 2>&1 >/dev/null" +u "stdout 接到 pipe(Claude Code 就是這種)→ False" "False" "python3 -c \"\$PROBE\" 2>&1 | cat" +u "stdout 寫進一般檔案(讀得回來)→ False" "False" "python3 -c \"\$PROBE\" 2>&1 >\"$TMP/o.txt\"" + +rm -f "$STAMP" +[ -n "$SAVED" ] && printf '%s' "$SAVED" > "$STAMP" +echo +echo "通過 $PASS 條,失敗 $FAIL 條" +[ "$FAIL" = 0 ] || exit 1 diff --git a/scripts/ticket b/scripts/ticket index f8f1e44..6ba4fa6 100755 --- a/scripts/ticket +++ b/scripts/ticket @@ -29,6 +29,7 @@ leo 2026-08-16 三句話,本工具就是它們的機械化: import json import os import re +import stat import subprocess import sys import time @@ -71,7 +72,9 @@ def check_identity(body, what): 規約全文:docs/governance/dispatch-and-reply-format.md §3""") -HOST = "https://git.uncle6.me" +# TICKET_HOST 只給測試用(指到一個連不上的位址,就能在不真的開票的前提下 +# 驗「閘放行了沒」——閘擋下=離開碼 2 且有 🚫;閘放行=走到網路那一層才炸)。 +HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me" ORG = "inkstone" STAMP_DIR = "/tmp" STAMP_TTL = 30 * 60 # 戳記 30 分鐘失效——搜過就要趁記憶還熱的時候開 @@ -138,10 +141,65 @@ def parse_ref(s): return m.group(1), m.group(2), int(m.group(3)) +def api_soft(path): + """讀一筆,失敗就回 None(不 die)。給「錦上添花」的資訊用,不影響任何判斷。""" + try: + url = f"{HOST}/api/v1{path}" + req = urllib.request.Request( + url, headers={"Authorization": f"token {token()}"}) + return json.load(urllib.request.urlopen(req, timeout=20)) + except SystemExit: + raise + except Exception: + return None + + def stamp_path(): return os.path.join(STAMP_DIR, ".ticket-where-ok") +# ── 「跑過」與「看過」的分界(inkstone/ISEP#72 → comment 4873)──────────── +# +# 2026-08-27 實錯(本段的來由,leo 當場點破): +# python3 scripts/ticket where 卡片 沒上雲 收檔 佇列 送達 ingest >/dev/null 2>&1 +# python3 scripts/ticket new arcrun-rag -F … --title "…" → ✅ #147 已開 +# 而重跑同一個搜尋、這次看輸出:**第一名就是 arcrun-rag#104**,同一件事,開了 13 天。 +# +# ⇒ 舊戳記證明的是「這個程序被執行過」,不是「這個人看過結果」。 +# 而「有沒有看」在 stdout 那一端——閘本來完全碰不到。 +# +# 這支函式就是把那一端變成**機械事實**:一個 fd 是不是 /dev/null, +# 是 fstat 問得出來的,不是對文字的猜測(本 repo 心法第 1 條:封動作不封文字)。 +# +# 🔴 刻意只認 /dev/null 這一種,不擴大到「重導到檔案」「接到 pipe」: +# 寫進檔案還讀得回來、接進 pipe 還有下游,只有 /dev/null 是**物理上找不回來**。 +# 多認一種都會開始誤攔(心法第 2 條:永遠在響的警報等於沒有警報)。 +def fd_is_devnull(fd): + try: + s = os.fstat(fd) + if not stat.S_ISCHR(s.st_mode): + return False # 一般檔案/pipe/socket:內容至少送到了讀得回來的地方 + return s.st_rdev == os.stat(os.devnull).st_rdev + except Exception: + return True # 問不出來就當「沒被看見」——閘不准因為內部錯誤靜默放行 + + +def excerpt(body, n=220): + t = re.sub(r"\s+", " ", body or "").strip() + return (t[:n] + "…") if len(t) > n else (t or "(沒有內文)") + + +def candidate_lines(detail): + """把候選票印成同一個樣子——`where` 印它,`new` 擋下來時也印它(同一份東西)。""" + out = [] + for d in detail: + out.append(f" [{d.get('score', '?')}] {d['ref']} {d.get('labels', [])}") + out.append(f" {(d.get('title') or '')[:70]}") + if d.get("excerpt"): + out.append(f" ↳ {d['excerpt'][:170]}") + return out + + # ── where ──────────────────────────────────────────────────────────────── def cmd_where(argv): if not argv: @@ -158,18 +216,39 @@ def cmd_where(argv): seen[ref] = {"score": 1, "it": it} hits = sorted(seen.values(), key=lambda x: -x["score"]) + detail = [] + for h in hits[:12]: + it = h["it"] + detail.append({"ref": it["repository"]["full_name"] + "#" + str(it["number"]), + "score": h["score"], "title": it["title"] or "", + "labels": [l["name"] for l in it.get("labels", [])]}) + + # 前 3 名補內文摘要。2026-08-27 的實害正是「光看標題看不出是同一條線」: + # arcrun-rag#104 的標題是「它把我整個 repo 的一萬多個檔案排進佇列」, + # 而它票頭第一段講的就是「這些庫都早就萃好了…直接 ingest」——同一件事。 + # 拿不到就安靜跳過(純加分,不影響任何判斷)。 + for d in detail[:3]: + o, r, n = parse_ref(d["ref"]) + got = api_soft(f"/repos/{o}/{r}/issues/{n}") + if got: + d["excerpt"] = excerpt(got.get("body")) + print(f"🔍 搜尋:{' '.join(kws)} → 命中 {len(hits)} 張 open 票\n") if not hits: print(" (沒有命中——換幾個講法再試一次。真的沒有,才輪到開新票)") - for h in hits[:12]: - it, labels = h["it"], [l["name"] for l in h["it"].get("labels", [])] - print(f" [{h['score']}] {it['repository']['full_name']}#{it['number']} {labels}") - print(f" {it['title'][:70]}") + for line in candidate_lines(detail): + print(line) + # 🔴 這行是本閘的全部:輸出有沒有可能到得了人眼前,是 fstat 問得出來的事實。 + shown = not fd_is_devnull(1) with open(stamp_path(), "w") as f: - json.dump({"at": time.time(), "kws": kws, "n": len(hits), - "top": [h["it"]["repository"]["full_name"] + "#" + str(h["it"]["number"]) - for h in hits[:12]]}, f) + json.dump({"at": time.time(), "kws": kws, "n": len(hits), "shown": shown, + "top": [d["ref"] for d in detail], "top_detail": detail}, f) + if not shown: + # stdout 被丟進 /dev/null。訊息改走 stderr(它常常還活著); + # 就算兩邊都被丟掉也沒關係——戳記已經記下 shown=false,`new` 那端會擋。 + print("\n⚠️ 這次搜尋的輸出被丟進 /dev/null,等於沒有人看過。" + "\n `ticket new` 會擋下來並把上面這份清單再交到你眼前一次。", file=sys.stderr) print(f""" ── 決定要做什麼 ─────────────────────────────────────────────── @@ -221,9 +300,47 @@ leo 原話:「**寫開票前先去搜尋要開在哪裡,不然你永遠會 if time.time() - st["at"] > STAMP_TTL: die(f"🚫 搜尋戳記已過期(超過 {STAMP_TTL // 60} 分鐘)。重跑一次 ticket where") + # 閘一之二:搜尋的結果**到得了人眼前**了沒(inkstone/ISEP#72 → comment 4873) + # + # 🔴 這道閘要擋的不是「沒搜」,是「搜了但沒看」——今天實犯: + # `ticket where … >/dev/null 2>&1` 之後直接 `new`,命中的 72 張一眼都沒看, + # 而第一名 arcrun-rag#104 就是同一件事(開了 13 天)。 + # + # ⚠️ 這一段刻意放在閘二**之前**:否則第一次就帶 `--not-a-comment "理由"` + # 的人永遠不會看到候選清單——理由是閉著眼睛寫的,那道閘等於沒有。 + # + # 成本落在「看」不落在「寫」:擋下來的訊息**本身就是那份被丟掉的輸出**, + # 看完重下同一個指令就會過。不要求多打任何一個字,也不判斷理由寫得好不好 + # (leo 2026-08-17 已證偽文字層判準:8 次誤攔、0 次正確攔截)。 + if st["n"] > 0 and not st.get("shown"): + lines = candidate_lines(st.get("top_detail") or []) or \ + [" " + r for r in (st.get("top") or [])] + # 只要 stderr 不是 /dev/null,這一次的擋就已經把清單交到眼前了 → 記進戳記。 + # 兩邊都被丟掉時**不記**(fail-closed):下次還是擋,不會靜默放行。 + if not fd_is_devnull(2): + st["shown"] = True + with open(stamp_path(), "w") as f: + json.dump(st, f) + die("""🚫 搜尋跑過了,但那份輸出沒有到任何人眼前(stdout 是 /dev/null)。 + +leo 2026-08-27:「**同一個 session 開兩個一樣的任務就算了,新開票沒搜尋就隨便動手開**」 +實錯(同日):`ticket where … >/dev/null` → 開出 arcrun-rag#147, +而命中的第一名 arcrun-rag#104 講的就是同一件事,已經開了 13 天。 + +⇒ **戳記證明的是「這個程序被執行過」,不是「這個人看過結果」。** + +命中的 {n} 張,前幾張在這裡——這就是剛才被丟掉的那一份: + +{lines} + +看完覺得真的都不是同一條線 → **重下一次一模一樣的指令就會過**,不必多打任何字。 +(判準是「輸出有沒有進得了 /dev/null 以外的地方」,不看你寫了什麼理由。)""".format( + n=st["n"], lines="\n".join(lines))) + # 閘二:搜到了東西,就要說明為什麼不是貼進去 if st["n"] > 0 and "--not-a-comment" not in argv: - top = "\n".join(" " + t for t in st["top"][:8]) + top = "\n".join(candidate_lines((st.get("top_detail") or [])[:8])) or \ + "\n".join(" " + t for t in st["top"][:8]) die(f"""🚫 剛才那次搜尋命中 {st['n']} 張 open 票,你卻要開新的。 命中的前幾張: