From b790c3a78d2775410238f02b87c940bb984b5499 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 20 Aug 2026 19:49:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20=E8=A3=9C=E5=9B=9E=20arcrun=5Fint?= =?UTF-8?q?ent=5Fguard.py=20=E4=B8=BB=E9=AB=94=E2=80=94=E2=80=94=E9=80=99?= =?UTF-8?q?=E6=94=AF=E9=96=98=E8=87=AA=20ISEP=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E4=BB=A5=E4=BE=86=E5=B0=B1=E6=98=AF=E7=A9=BA=E6=AE=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 發現於 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 --- hooks/arcrun_intent_guard.py | 212 +++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100755 hooks/arcrun_intent_guard.py 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())