b790c3a78d
發現於 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>
213 lines
10 KiB
Python
Executable File
213 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""arcrun 意圖工作流「回饋 hook」主體(D38,2026-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 失敗寫的正是 YAML(componentId: 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)))))
|
||
|
||
# R4:code 節點在做流程控制
|
||
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 失敗寫的是 YAML(componentId: 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())
|