diff --git a/hooks/arcrun_intent_guard.py b/hooks/arcrun_intent_guard.py new file mode 100755 index 0000000..a040067 --- /dev/null +++ b/hooks/arcrun_intent_guard.py @@ -0,0 +1,212 @@ +#!/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())