fix(hooks): 補上 leo21c-write-guard.sh 第二、三次誤攔的修法(反向移植) #40

Merged
claude-code merged 3 commits from fix/one-copy-per-gate into main 2026-08-20 12:06:52 +00:00
Showing only changes of commit b790c3a78d - Show all commits
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""arcrun 意圖工作流「回饋 hook」主體(D382026-08-01)。
由 arcrun-intent-guard.sh 呼叫(stdin = Claude Code 的 PreToolUse JSON)。
獨立成 .py 的理由:規則含大量引號與 regex,包在 shell 單引號裡會被吃掉
2026-08-01 實際踩到:'"componentId"' 被 shell 剝成裸字 → NameError)。
判準來源:system-dev/docs/3-specs/arcrun-usable/intent-rules.json
——與判分器共用同一份,避免兩套判準漂移。
"""
import json
import os
import re
import sys
def main():
try:
d = json.load(sys.stdin)
except Exception:
return 0
ti = d.get("tool_input") or {}
# 收集這次要送出的文字:檔案寫入 / 編輯 / bash 指令 / MCP 參數都看
parts = []
for k in ("content", "new_string", "command", "prompt"):
v = ti.get(k)
if isinstance(v, str):
parts.append(v)
for k in ("graph", "yaml", "workflow", "triplets", "body"):
v = ti.get(k)
if v is not None:
parts.append(v if isinstance(v, str) else json.dumps(v, ensure_ascii=False))
body = "\n".join(parts)
if not body.strip():
return 0
proj = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
rules_path = os.path.join(
proj, "system-dev", "docs", "3-specs", "arcrun-usable", "intent-rules.json")
if not os.path.exists(rules_path):
return 0
R = json.load(open(rules_path, encoding="utf-8"))
rules = {r["id"]: r for r in R["rules"]}
LEGAL = re.compile(R["legal_edges"]["regex"])
# 只在「這確實是 arcrun 意圖工作流」時才出手,避免亂吵。
# 排除文件語境的雜訊(2026-08-01 自己編修 GUIDE 時被自己誤攔):
# markdown 表格列、引用行、註解行——教材列舉或引述錯誤寫法時必然含箭頭
def is_prose(l):
s = l.strip()
return (s.startswith("|") or s.startswith(chr(62) + " ")
or s.startswith("#") or s.startswith("//"))
triplet_lines = [l.strip() for l in body.split("\n")
if (chr(62) * 2) in l and not is_prose(l)]
# 2026-08-01 補漏:原本只認 JSON 圖(帶引號)⇒ **YAML 圖整份被放行**。
# leo 那次 haiku 失敗寫的正是 YAMLcomponentId: code,無引號),hook 完全沒攔到。
is_graph_json = ('"componentId"' in body) or ('"edges"' in body)
is_graph_yaml = bool(re.search(r'^\s*-?\s*componentId\s*:', body, re.M)
or re.search(r'^\s*edges\s*:', body, re.M))
is_graph = is_graph_json or is_graph_yaml
if not triplet_lines and not is_graph:
return 0
# 文件檔(.md)本來就會「引述錯誤寫法來教學」=R6 關鍵詞必然出現。
# 對 .md 只保留結構性規則,不做過時教材主張的字面偵測。
is_doc = str(ti.get("file_path") or "").endswith(".md")
# 🔴 2026-08-08:兩道 hook 直接打架,本閘誤攔 wiki 落帳。
# `wiki-first-police` **要求**我在 wiki 用知識三元組 `A >> 關係 >> B`
# leo 2026-08-01 立:「每件事牽涉到 2 個 repo,就可以在該事件查到那兩個 repo」)。
# 但本閘把任何含 `>>` 的行都當成 Arcrun 工作流的邊 ⇒ 落帳寫
# 「勸告治不了停 >> 所以改成 >> InkStoneCo:...」就被判「非法邊」+「第一個節點不是 input」。
# ⇒ 同一個符號、兩套約定。**.md 裡沒有真的工作流圖時,本閘不該有意見。**
# (有 componentId/edges 的 .md=真的在寫工作流文件,仍照常檢查。)
if is_doc and not is_graph:
return 0
# 🔴 2026-08-08 第二例:hook/腳本自己的原始碼裡出現 `>>` 是**正則樣式或 shell 重導向**,
# 不是 Arcrun 工作流的邊。實撞:wiki-first-police.sh 裡的
# `grep -qE '… >> 真身在 >> …'`(比對知識三元組用的樣式)被判「三元組格式錯」。
# ⇒ 機制程式碼一律豁免;真的工作流定義不會住在 .claude/hooks/。
_fp = str(ti.get("file_path") or "")
if "/.claude/hooks/" in _fp or _fp.endswith((".sh", ".py")):
if not is_graph:
return 0
hits = []
def hit(rid, detail):
if not any(h[0] == rid for h in hits):
hits.append((rid, detail))
COND = re.compile(
r"判斷|檢查|如果|大於|小於|是否|超過|比較|驗證|check|if_|ifcontrol|compare|verify|threshold",
re.I)
if triplet_lines:
edges_of = {}
parsed = []
for l in triplet_lines:
# 2026-08-05 修:先剝掉 YAML 清單語法再切段。
# 原本直接切,第一段會是 `- "input` 而不是 `input`
# => R1「第一個節點必須是 input」永遠判為不符
# => 任何對 flow: 區塊的編輯都被擋死(實撞:改 rag-ingest-card 三次全被擋,
# 而該檔 flow 本來就合法)。
l = l.strip()
if l.startswith("- "):
l = l[2:].strip()
l = l.strip('"').strip("'").strip()
seg = [p.strip() for p in l.split(">>")]
if len(seg) != 3:
hit("R5-triplet-format", l[:60])
continue
a, e, c = seg
parsed.append((a, e, c))
if not LEGAL.match(e):
hit("R2-illegal-edge", "非法邊「%s」(%s" % (e, l[:50]))
edges_of.setdefault(a, []).append(e)
if parsed:
# 2026-08-13 修(同一段的第二次誤攔修正,前一次見上方 08-05 註解):
# R1 問的是「**整個工作流**的第一個節點是不是 input」——
# 那個問題只有在**看得到整份檔案**時才答得出來。
#
# `Write` 送的是 `content`(整檔)⇒ parsed[0] 真的是第一個節點 ✅
# `Edit` 送的是 `new_string`(片段)⇒ parsed[0] 是**那段片段的第一行**,
# 而增量編輯必然要帶一行既有的錨點來定位插入點
# ⇒ **錨點永遠被誤判成「工作流起點」**,不管它實際排第幾。
#
# ⇒ 這會結構性地擋住**任何**對既有多行 `flow:` 的增量修改。
# 實撞(2026-08-13):往 `km_wiki_ingest.yaml` 的 flow 尾端接五行新邊,
# 錨點是 `decide >> 對每個 update_item >> update_entry`
# ⇒ 報「第一個節點是『decide』」。而該檔第一個節點叫 `seed`,
# 是檔案自己 §4 註解寫明的刻意設計(避開引擎的觸發保留字)。
#
# 🔴 只放寬 R1,**R2(邊合法性)/R3/R4/R5 對片段照樣生效**——
# 那幾條檢查的是內容本身,沒有「位置語意」的問題。
if isinstance(ti.get("content"), str):
first = parsed[0][0]
if first.lower() != "input":
hit("R1-first-node-input", "第一個節點是「%s" % first)
for a, outs in edges_of.items():
branchy = any(
o in ("ON_TRUE", "ON_FALSE") or o.startswith("ON_BRANCH") for o in outs)
if COND.search(a) and not branchy:
hit("R3-condition-via-on_success",
"%s」有條件語意,出邊卻只有 %s" % (a, "".join(sorted(set(outs)))))
# R4code 節點在做流程控制
LOGIC = re.compile(
r"\bif\s*\(|\belse\b|\bfor\s*\(|\bwhile\s*\(|\.filter\(|\.map\(|\?\s*[^:\n]{1,40}\s*:")
if is_graph:
m = re.search(r"\{.*\}", body, re.S)
if m:
try:
g = json.loads(m.group(0))
for n in (g.get("nodes") or []):
if n.get("componentId") == "code":
ctx = json.dumps(
n.get("config") or n.get("payload") or n, ensure_ascii=False)
if LOGIC.search(ctx):
hit("R4-code-node-doing-logic",
"code 節點「%s」內含流程控制" % n.get("id"))
except Exception:
pass
if not any(h[0] == "R4-code-node-doing-logic" for h in hits):
# 2026-08-01 補:原本只認 JSON(帶引號)與 `>> code`
# 但 leo 那次 haiku 失敗寫的是 YAMLcomponentId: code,無引號)⇒ 漏抓。
looks_code = re.search(r">>\s*(code|js|script)\b", body, re.I) or \
re.search(r'"componentId"\s*:\s*"code"', body) or \
re.search(r'^\s*-?\s*componentId\s*:\s*["\']?code["\']?\s*$', body, re.M) or \
re.search(r'^\s*-?\s*(id|name)\s*:\s*["\']?(code|js|script)["\']?\s*$', body, re.M)
if looks_code and LOGIC.search(body):
hit("R4-code-node-doing-logic", "偵測到 code 節點且內容含 if/for/filter")
# R6:過時教材主張(會把 AI 擋在正解門外)
if not is_doc and (
re.search(r"(不支援|沒有|無)\s*(原生)?\s*(條件分支|ON_TRUE|ON_FALSE)", body)
or re.search(r"只有\s*ON_SUCCESS\s*(與|和|、)\s*(FOREACH|對每個)", body)):
hit("R6-stale-doc-claim", "文字聲稱引擎不支援條件分支")
if not hits:
return 0
out = []
out.append("🎓 arcrun-intent-guard:這份意圖有 %d 處可以更好——下面是**正確寫法,可直接照抄**。"
% len(hits))
out.append("")
for rid, detail in hits:
r = rules.get(rid, {})
out.append("── %s" % rid)
out.append(" 現場:%s" % detail)
out.append(" ✅ 正確寫法:%s" % r.get("teach", ""))
if r.get("fix_example"):
out.append(" 範例(照抄改內容即可):")
for line in r["fix_example"].split("\n"):
out.append(" %s" % line)
if r.get("payload_hint"):
out.append(" payload%s" % r["payload_hint"])
out.append("")
out.append("判準來源:system-dev/docs/3-specs/arcrun-usable/intent-rules.json(與判分器共用)")
out.append("不確定時**問實例**POST /cypher/search,回應裡的 branch_hint 會直接告訴你分支怎麼接。")
sys.stderr.write("\n".join(out) + "\n")
return 2
if __name__ == "__main__":
sys.exit(main())