fix(hooks): 補回 arcrun_intent_guard.py 主體——這支閘自 ISEP 建立以來就是空殼

發現於 inkstone/InkStoneCo#57/inkstone/ISEP#32:要把 InkStoneCo 本機
`.claude/hooks/arcrun-intent-guard.sh`(連同它同目錄的 arcrun_intent_guard.py)
移除、改吃 ISEP 這份之前,實測 ISEP 版才發現 `hooks/arcrun-intent-guard.sh`
呼叫的 `arcrun_intent_guard.py` 從未進過 ISEP 的 git 歷史(`git log --all` 0
命中)——`exec python3 "$DIR/arcrun_intent_guard.py"` 找不到檔案,
python3 直接噴 OS 層錯誤、exit 2,**每一次 Write/Edit/MultiEdit 都被無條件擋下**,
不是「規則判定違規才擋」,是檔案不存在導致的硬當機。

這比 kbdb_cmd_check.py 那次(PR #39)更嚴重:那次是「該擋的沒擋」(靜默放行),
這次是「不管寫什麼都擋」——方向相反,但同一個病根:hook 的 shell 外殼進了
git,Python 主體沒有。

修法:把 InkStoneCo 本機那份(唯一存在的正本)原樣搬進來。這支腳本本來就設計
成離開 InkStoneCo 語境會優雅放行(`rules_path` 不存在 → return 0),只是
之前連跑到那行都做不到。

實測 3 種情境:
- ISEP 語境(沒有 intent-rules.json):exit 0(修好前是 exit 2,任何寫入都被當機式擋下)
- InkStoneCo 語境、合法內容:exit 0
- InkStoneCo 語境、真違規(`ON_FAILURE` 這種不存在的邊):exit 2(行為不變,沒有變寬鬆)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 19:49:34 +08:00
parent 1dc97f0bd6
commit b790c3a78d
+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())