#!/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())