戳記要證明「看過」,不是「跑過」(inkstone/ISEP#72 → comment 4873)
2026-08-27 實犯:`ticket where … >/dev/null 2>&1` 之後直接 `ticket new`—— 戳記寫成功了,命中的 72 張一眼沒看,於是開出 arcrun-rag#147, 而第一名 arcrun-rag#104 講的是同一件事,已經開了 13 天。 ⇒ 舊戳記證明的是「這個程序被執行過」,而「有沒有看」在 stdout 那一端, 閘本來完全碰不到。 做法:把 stdout 那一端變成機械事實,不是文字判斷。 - `fd_is_devnull(fd)`:fstat 問得出來的事實。刻意只認 /dev/null 這一種 (寫進檔案讀得回來、pipe 有下游,只有 /dev/null 物理上找不回來)。 - `where` 把 `shown` 記進戳記;順便替前 3 名補內文摘要——今天的實害正是 「光看標題看不出是同一條線」(#104 的標題完全沒提 ingest)。 - `new` 的閘一之二:命中 >0 且 shown 為假就擋,並把那份被丟掉的清單 交到眼前;只要 stderr 不是 /dev/null,這一次的擋就記成「看過了」, 重下一模一樣的指令就會過——成本落在「看」,不落在「寫」。 - 刻意排在閘二**之前**:否則第一次就帶 `--not-a-comment "理由"` 的人 永遠看不到候選清單,理由是閉著眼睛寫的。 - 舊格式戳記(沒有 shown 欄位)當成沒看過(fail-closed)。 不該擋的(測試覆蓋):沒命中就不吵、看過了就不吵、`--not-a-comment` 既有欄位照舊、不判斷理由寫得好不好(leo 2026-08-17 已證偽文字層判準)。 測試 17/17:scripts/test-ticket-where-seen-guard.sh(離線,不打真實 Gitea、 不留測試票;離開碼 2=擋、1=放行走到網路才炸)。docs/TESTING.md 新增 A13。 plugin.json 升到 0.8.0——版本沒動=沒有人吃得到。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+126
-9
@@ -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 票,你卻要開新的。
|
||||
|
||||
命中的前幾張:
|
||||
|
||||
Reference in New Issue
Block a user