ISEP 0.1.0:環境設定收成一個 plugin,本機與雲端共用一份
leo 2026-08-20:「同一個 plugin 你用,薄殼也用,保證兩邊同步」
「我要你幫雲端做薄殼,永遠都有問題,你要做的就是這組設定
你自己可以 dogfooding」
搬進來:41 支 hook(51 條註冊)/7 支 command/2 支 skill/23 支腳本。
不搬 .env、wiki、docs——那些是知識不是環境。
51 條 hook 路徑全部從 $CLAUDE_PROJECT_DIR/.claude/hooks/ 改成 ${CLAUDE_PLUGIN_ROOT}/hooks/,
零漏網。那正是薄殼一直壞掉的根:雲端 cwd 不是真身,寫死路徑就斷。
尚未驗證:Claude Code 能不能從私有 Gitea repo 裝 marketplace(要憑證)。
下一步就是在本機實際裝一次,通了才動雲端 bootstrap.sh。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# arcrun-intent-guard.sh — 意圖工作流「回饋 hook」(D38,2026-08-01 leo 定調)
|
||||
#
|
||||
# 與判分器的差別(這就是本 hook 存在的理由):
|
||||
# 判分器=**記帳**:只說「你錯了」,不說怎麼改 ⇒ 模型換小(Workers AI)時自我修正能力更弱,
|
||||
# 會卡死在同一個錯上。
|
||||
# 本 hook =**教學**:命中規則時回**可貼上就用的正確寫法**,不是「請參考 skill」。
|
||||
#
|
||||
# leo 原話:「這一整串其實是 prompt 鏈……判分器不會告訴它你錯了要怎麼改,
|
||||
# hook 則應該告訴它正確寫法,假如這個一直過不了。因為下一步考慮用 workers AI 來測。」
|
||||
#
|
||||
# 判準來源:system-dev/docs/3-specs/arcrun-usable/intent-rules.json(與判分器共用,避免兩套漂移)
|
||||
# 主體在同目錄 arcrun_intent_guard.py(規則含大量引號/regex,包在 shell 單引號裡會被吃掉)
|
||||
#
|
||||
# 行為:exit 2(擋下並把正解餵回 AI 的 stderr)。這是**教學閘**不是懲罰閘。
|
||||
# 誠實限制(同 component-guard mindset):AI 技術上可繞過;價值=在錯誤送出的那一刻把正解遞到眼前。
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$DIR/arcrun_intent_guard.py"
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/bin/sh
|
||||
# browser-verify-guard.sh — Stop hook:**宣稱驗過前端,卻沒有真的用瀏覽器載一次 → 擋**。
|
||||
#
|
||||
# 🔴 leo 2026-08-08:「**你的環境有 web,你應該用 web 驗,你已經開啓了卻沒有完成,
|
||||
# 你要把這個列入規定。**」
|
||||
#
|
||||
# 病根(同日實撞,leo 抓到而不是我發現):
|
||||
# 我「驗前端」的方式是 `curl | grep` 找一個字串——文案在就宣稱通過。
|
||||
# 但 **curl 拿到的是 HTML 原始碼:它不執行 JS、不載入 config.js、不發 API 請求**。
|
||||
# 於是使用者真正看到的整條紅色錯誤
|
||||
# 「設定檔沒載入(config.js),這個頁面連不到你的服務」,**我一次都沒看到**。
|
||||
# ⇒ 判準升級:`HTTP 200 不算驗過` → `grep 到字串也不算驗過`
|
||||
# → **只有「瀏覽器載入後看起來能用」才算**。
|
||||
#
|
||||
# 機制:這回合若出現「前端驗過」類宣稱,就要求同一回合真的用過瀏覽器工具。
|
||||
# 有 curl 沒有瀏覽器 ⇒ exit 2。
|
||||
#
|
||||
# 逃生口:這次真的沒有前端可驗(純後端/純腳本)⇒ 在回覆裡明說「本次無前端」,
|
||||
# 本 hook 認得這句話就放行——留痕,不是默默略過。
|
||||
set -eu
|
||||
|
||||
TURN="$(cat 2>/dev/null | python3 -c '
|
||||
import sys, json
|
||||
buf = []
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
t = d.get("transcript_path") or ""
|
||||
if t:
|
||||
import io, os
|
||||
if os.path.exists(t):
|
||||
for line in open(t, encoding="utf-8", errors="ignore").readlines()[-40:]:
|
||||
try:
|
||||
m = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
c = (m.get("message") or {}).get("content")
|
||||
if isinstance(c, list):
|
||||
for b in c:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
if b.get("type") == "text":
|
||||
buf.append(b.get("text", ""))
|
||||
elif b.get("type") == "tool_use":
|
||||
buf.append(b.get("name", ""))
|
||||
buf.append(json.dumps(b.get("input", {}), ensure_ascii=False))
|
||||
elif isinstance(c, str):
|
||||
buf.append(c)
|
||||
except Exception:
|
||||
pass
|
||||
print("\n".join(buf))
|
||||
' 2>/dev/null || echo "")"
|
||||
[ -z "$TURN" ] && exit 0
|
||||
|
||||
# 有沒有「前端/畫面驗過」的宣稱?
|
||||
printf '%s' "$TURN" | grep -qiE '前端(驗|測|複驗|確認)|畫面(驗|確認|複驗)|用戶.{0,6}看到的|驗前端|抓實際畫面' || exit 0
|
||||
|
||||
# 明說沒有前端 ⇒ 放行(留痕)
|
||||
printf '%s' "$TURN" | grep -qE '本次無前端|沒有前端可驗|純後端|不涉及前端' && exit 0
|
||||
|
||||
# 真的用過瀏覽器嗎?
|
||||
printf '%s' "$TURN" | grep -qE 'Claude_Browser__(preview_start|navigate|computer|get_page_text|read_console_messages)' && exit 0
|
||||
|
||||
cat >&2 <<'EOF'
|
||||
🖥️ 前端驗證警察:你宣稱驗了畫面,但**這回合沒有用過瀏覽器**。
|
||||
|
||||
【leo 2026-08-08】「**你的環境有 web,你應該用 web 驗,你已經開啓了卻沒有完成。**」
|
||||
|
||||
【為什麼 curl | grep 不算】curl 拿到的是 **HTML 原始碼**——
|
||||
它**不執行 JS、不載入 config.js、不發 API 請求**。
|
||||
08-08 實撞:我 grep 到文案就說「前端驗過」,
|
||||
而使用者實際看到的是整條紅色「設定檔沒載入(config.js)」——**那個畫面我一次都沒看到**。
|
||||
|
||||
改用這三步(看畫面,不是看原始碼):
|
||||
mcp__Claude_Browser__preview_start {url: "<用戶會走的網址>"}
|
||||
mcp__Claude_Browser__computer {action: "screenshot"}
|
||||
mcp__Claude_Browser__read_console_messages {onlyErrors: true}
|
||||
|
||||
要看的是:有沒有錯誤橫幅/資料是不是還卡在「載入中…」/console 有沒有紅字。
|
||||
|
||||
⚠️ 順帶:用 curl 查線上狀態時,`?cb=$RANDOM` **繞不掉 Cloudflare 邊緣快取**——
|
||||
要加 `-H "Cache-Control: no-cache"`,否則你可能是在驗快取而不是驗線上。
|
||||
|
||||
本次真的沒有前端可驗 ⇒ 在回覆裡明說「本次無前端」即放行。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# claim-verify-police.sh — 待驗工作單還在,就不准收工(Stop;leo 2026-08-18 立)
|
||||
#
|
||||
# 這支是 subagent-claim-worksheet.sh 的另一半。
|
||||
# 那支在 SubagentStop 時把 subagent 的宣稱寫成一張檔案;這支在我要停下時檢查:
|
||||
#
|
||||
# **那張檔還在不在?**
|
||||
#
|
||||
# ── 為什麼判準是「檔案存不存在」 ────────────────────────────────────
|
||||
# leo 2026-08-17:「你在**文字層**封路⋯⋯自然語言的變體是無限的,blacklist 永遠追不完。
|
||||
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉。」
|
||||
# ⇒ 「我驗過了」這句話可以有無限種寫法,但**檔案只能被動作刪掉**。
|
||||
# 所以這支不讀我的措辭,只看磁碟。
|
||||
#
|
||||
# ── 怎麼過這道閘(都是動作,都留痕)─────────────────────────────────
|
||||
# ① 真的驗了 → 把證據寫進回覆/票,然後刪掉那個檔
|
||||
# ② 有幾條不需要驗 → 在檔裡寫下理由,再刪掉(理由留在 git 或票上)
|
||||
# ③ 是人閘 → 走三件機械動作(Human + 指派 + 它自己的票),再刪掉
|
||||
#
|
||||
# 🔴 **不准為了過閘而直接 rm 掉卻什麼都沒做。** 那等於自己拆掉自己的閘——
|
||||
# 而這正是 leo 2026-08-10 說的「規則早就存在,但沒有任何機制驗證有沒有照做」。
|
||||
#
|
||||
# ── 迴圈安全 ────────────────────────────────────────────────────
|
||||
# stop_hook_active 為真 ⇒ 已經擋過一次,放行。至多提醒一次,不卡死。
|
||||
#
|
||||
# ── 留痕 ────────────────────────────────────────────────────────
|
||||
INPUT=$(cat)
|
||||
DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification"
|
||||
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/claim-verify-police.log"
|
||||
|
||||
ACTIVE=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import json,sys
|
||||
try: print("1" if json.load(sys.stdin).get("stop_hook_active") else "0")
|
||||
except Exception: print("0")
|
||||
' 2>/dev/null)
|
||||
|
||||
if [ "$ACTIVE" = "1" ]; then
|
||||
printf '%s\tSKIP:already-nudged\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 只算「今天以內」的單子——放太久的(跨 session 殘留)不該無限期卡住新工作,
|
||||
# 但仍會被列出來提醒。
|
||||
FILES=$(ls -1 "$DIR"/claims-*.md 2>/dev/null)
|
||||
if [ -z "$FILES" ]; then
|
||||
printf '%s\tSKIP:no-pending\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
N=$(printf '%s\n' "$FILES" | grep -c . )
|
||||
printf '%s\tBLOCK:%s-pending\n' "$(date +%FT%T)" "$N" >> "$LOG" 2>/dev/null
|
||||
|
||||
{
|
||||
echo "🔍 查證警察:有 $N 張**待驗工作單**還沒處理掉,不准收工。"
|
||||
echo
|
||||
echo "【leo 2026-08-18 的規格】"
|
||||
echo " 「subagent 做完事⋯⋯**它說可以,你去驗證;它說不行,你想辦法,不行再報告**,"
|
||||
echo " 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報,"
|
||||
echo " **它視野小,它說的你有更多資訊去檢驗。**」"
|
||||
echo
|
||||
echo "━━ 還沒處理的單子 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
printf '%s\n' "$FILES" | while read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
OKN=$(grep -c '^- \[ \]' "$f" 2>/dev/null)
|
||||
echo " · ${f#"$CLAUDE_PROJECT_DIR"/} ($OKN 條待處理)"
|
||||
done
|
||||
echo
|
||||
echo "━━ 怎麼過這道閘(三選一,都是動作)━━━━━━━━━━━━━━━━━━"
|
||||
echo " ① **它說可以** → 你自己跑一次(測試/curl/瀏覽器/git),把證據貼出來,再刪掉那個檔"
|
||||
echo " ② **它說不行** → 先換路徑、查憑證地圖;要說「被擋」就得**自己撞一次並貼拒絕原文**"
|
||||
echo " ③ **真的是人閘** → 三件機械動作:\`Human\` 標籤 + 指派給 Leo + **一張它自己的票**"
|
||||
echo " (leo 08-18:「要進入人閘,標示 human 叫我去開,**不是默默塞進錯的地方**」)"
|
||||
echo
|
||||
echo "🔴 **不准只是 rm 掉了事。** 那是自己拆掉自己的閘——"
|
||||
echo " 正是 leo 08-10 講的「規則早就存在,但沒有任何機制驗證有沒有照做」。"
|
||||
echo
|
||||
echo "📌 為什麼有這道閘:2026-08-18 總管連續三次把**沒驗過的判斷**當成情報交出去,"
|
||||
echo " 其中第二次還是為了「更正」第一次而發明的新猜測。"
|
||||
echo " leo:「你給我不正確的資訊⋯⋯subagent 說不通你沒有驗。」"
|
||||
} >&2
|
||||
|
||||
exit 2
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# component-guard.sh — 零件/service-binding 建立保險(D27/D28,2026-07-06)
|
||||
# 背景:AI 一再自建 domain 零件(km_wiki_card_parse)、錯用 service binding(drainer 修 1042)。
|
||||
# 靠 wiki 規則防不住(leo:「這規定說幾次都沒用」)→ 仿 D20 github-contact-guard 做硬閘:
|
||||
# 把「建零件 / 加 service binding」當武器,平時機械擋(exit 2),要人類跑 scripts/component-arm.sh
|
||||
# 顯式解保險(限時,且解時該過 docs/component-pr-review-standard.md)才放行。
|
||||
# n8n 哲學(leo):現成零件 + 通用 code 零件 + cypher workflow 就夠了 → 預設根本不碰「建零件」那條路,就不會亂想。
|
||||
# 誠實限制(同 github-guard mindset §7):AI 技術上可繞過;本 hook 價值=擋手滑 + 留審計軌跡 + 逼你回頭用積木,
|
||||
# 絕不聲稱「不可能繞過」。最後一段靠 D27/D28 鐵律與自律。
|
||||
|
||||
INPUT=$(cat)
|
||||
read_field() { printf '%s' "$INPUT" | python3 -c "import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin);ti=d.get('tool_input',{})
|
||||
print(ti.get('$1',''))
|
||||
except: print('')" 2>/dev/null; }
|
||||
|
||||
FP=$(read_field file_path)
|
||||
[ -z "$FP" ] && exit 0
|
||||
BODY="$(read_field content)$(read_field new_string)"
|
||||
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
ARM="$PROJ/.component-armed"
|
||||
# 解保險窗口(限時,仿 github-arm):arm 檔內時間戳,30 分鐘內放行
|
||||
if [ -f "$ARM" ]; then
|
||||
ARMED=$(tr -dc '0-9' < "$ARM" 2>/dev/null); NOW=$(date +%s)
|
||||
if [ -n "$ARMED" ] && [ $(( NOW - ARMED )) -lt 1800 ] 2>/dev/null; then exit 0; fi
|
||||
fi
|
||||
|
||||
HIT=""
|
||||
if printf '%s' "$FP" | grep -qiE 'wrangler\.(toml|jsonc?)$' && printf '%s' "$BODY" | grep -qE '\[\[services\]\]|(^|[^_a-zA-Z])services[[:space:]]*='; then
|
||||
HIT="service binding([[services]])— D28 禁令:跨-worker 編排走 cypher binding,不走 service binding"
|
||||
elif printf '%s' "$FP" | grep -qE 'registry/components/[^/]+/(component\.contract\.ya?ml|main\.(go|ts|mjs))$'; then
|
||||
HIT="建/改命名零件(registry/components/…)— D27:一次性邏輯用通用 code 零件,別鑄 domain 零件"
|
||||
fi
|
||||
[ -z "$HIT" ] && exit 0
|
||||
|
||||
{
|
||||
echo "❌ BLOCKED by component-guard($HIT)"
|
||||
echo ""
|
||||
echo "預設就用「我給你的積木」:現有零件 + 通用 code 零件(inline JS) + cypher workflow(cypher binding 串多 worker/零件)。"
|
||||
echo "這些對絕大多數需求已足夠。碰到這道閘,先回頭問:現成積木真的拼不出來嗎?(多半可以)"
|
||||
echo "真的需要新零件 / service binding(罕見)→ 要能過 docs/component-pr-review-standard.md,且人類顯式跑 scripts/component-arm.sh 解保險。"
|
||||
} >&2
|
||||
LOG="$PROJ/system-dev/docs/3-specs/autonomy-dispatch/component-guard-log.md"
|
||||
printf '| %s | 擋(%s) | `%s` |\n' "$(date '+%F %T')" "${HIT%%—*}" "$(printf '%s' "$FP" | head -c 120)" >> "$LOG" 2>/dev/null
|
||||
exit 2
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook — 金鑰只能走 credential 中心,禁止寫在外面(L3 硬攔截)
|
||||
#
|
||||
# 【leo 2026-07-29 立】原話:
|
||||
# 「那就很簡單,禁止把 secrets 寫在外面,不給 AI 寫入欄位,凡是要 secrets 就要去統一中心拿」
|
||||
#
|
||||
# 為什麼存在(同日事故實錄):
|
||||
# 同一批 workflow 裡**兩種金鑰寫法並存**——
|
||||
# {{credential.gemini_api_key}} 8 處(正解,走 credential 中心)
|
||||
# __KBDB_TOKEN__ 17 處 / __GITEA_TOKEN__ 6 處 / __GEMINI_API_KEY__ 1 處(佔位符替換,違規)
|
||||
# 連 Gemini 同一把金鑰都同時有兩種寫法。兩套並存必然漂移:
|
||||
# t145 就是漂移的結果——kbdb 身上舊 token、workflow 帶新 token,
|
||||
# 萃取切出的 5 個三元組全部 401 寫不進去,且「指紋一致→永遠跳過」自我保護、重裝幾次都修不好。
|
||||
#
|
||||
# 病根不是「這次寫錯」,是「AI 天然偏向新增一種做法,而非找出既有做法」。
|
||||
# 靠紀律會漂移,靠機械閘不會 ⇒ 讓「繞開 credential」在寫入那一刻就失敗。
|
||||
#
|
||||
# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit|MultiEdit)。
|
||||
# stdin 收到 JSON:{ tool_name, tool_input: { file_path, content?, new_string? } }
|
||||
#
|
||||
# 誠實限制(抄 wiki-secret-scan 的自我要求):regex 偵測有偽陰/偽陽。
|
||||
# 擋的是「明顯的金鑰佔位符被寫進 workflow/設定」,擋不了刻意改名混淆的繞道。
|
||||
# 價值是「讓正解成為阻力最小的路 + 留痕可審」,不是技術防偽。絕不聲稱不可能繞過。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# ── 解析 file_path 與要寫入的內容(優先 jq,無 jq 退回 grep)─────────────
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
CONTENT=$(printf '%s' "$INPUT" | jq -r '[.tool_input.content, .tool_input.new_string] | map(select(. != null)) | join("\n")')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
CONTENT=$(printf '%s' "$INPUT")
|
||||
fi
|
||||
|
||||
[ -z "${FILE_PATH:-}" ] && exit 0
|
||||
[ -z "${CONTENT:-}" ] && exit 0
|
||||
|
||||
# ── 只管「會被執行的產物」:workflow 定義、安裝器、部署設定 ──────────────
|
||||
# 文件/wiki/落帳談論這些字串是正常的(本檔自己就寫滿了),不能擋。
|
||||
case "$FILE_PATH" in
|
||||
*.md|*/wiki/*|*/docs/*|*/mistakes*|*/CRITICAL-PATH*) exit 0 ;;
|
||||
esac
|
||||
case "$FILE_PATH" in
|
||||
*workflow*|*.yaml|*.yml|*workflows.json|*installer*|*worker.js|*wrangler*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# ── 豁免:該行標了 credential-ok 就放行(給真有理由的例外,留痕可審)────
|
||||
SCAN=$(printf '%s' "$CONTENT" | grep -v 'credential-ok' || true)
|
||||
[ -z "$SCAN" ] && exit 0
|
||||
|
||||
# ── 違規特徵:金鑰類佔位符(__XXX_TOKEN__ / __XXX_KEY__ / __XXX_SECRET__)──
|
||||
# 非金鑰的佔位符(__KBDB_BASE__、__CODE_URL__、__NAMESPACE__…)是正常的,不擋。
|
||||
HITS=$(printf '%s' "$SCAN" \
|
||||
| grep -oE '__[A-Z0-9_]*(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*__' \
|
||||
| sort -u || true)
|
||||
|
||||
# ── 違規特徵②:真身被寫進定義(leo:「只能拿 key,要送出時自動去拉 value」)──
|
||||
# AI 只准碰名字;值在 WASM 執行前由 resolve_credentials 回填(graph-executor.ts:247)。
|
||||
# 抓「Bearer <一長串>」「api_key: <一長串>」這類把值直接寫死的形態。
|
||||
# {{credential.X}} 與 ${...} 這類引用不算值,先剔除再掃。
|
||||
LITERAL=$(printf '%s' "$SCAN" \
|
||||
| grep -vE '\{\{ *credential\.[a-z0-9_]+ *\}\}' \
|
||||
| grep -oiE '(bearer|api[-_]?key|token|secret|password)"?[:= ]+"?[A-Za-z0-9_\-]{24,}' \
|
||||
| sort -u || true)
|
||||
|
||||
if [ -n "$LITERAL" ]; then
|
||||
{
|
||||
echo "🔒 credential 鐵律攔截②:金鑰真身被寫進定義(leo 2026-07-29 立)"
|
||||
echo ""
|
||||
echo " 偵測到疑似金鑰值(已遮蔽尾段):"
|
||||
printf '%s\n' "$LITERAL" | sed -E 's/(.{20}).*/ \1…(已遮蔽)/'
|
||||
echo " 檔案:$FILE_PATH"
|
||||
echo ""
|
||||
echo "【鐵律】「只能拿 key,要送出時自動去拉 value」"
|
||||
echo " ⇒ 定義裡只准出現**名字**,真身永遠不落在 workflow/設定上。"
|
||||
echo ""
|
||||
echo "【正解】{{credential.<名字>}}——執行前由 resolve_credentials 回填"
|
||||
echo " (graph-executor.ts:247,WASM 執行前才解析)。"
|
||||
echo ""
|
||||
echo "【為什麼】值不落地,AI 讀 workflow 也讀不到真身;"
|
||||
echo " 換金鑰只改中心一處,不必回頭改每個引用點(副本歸零=不可能漂移)。"
|
||||
echo ""
|
||||
echo "【誤判】若這串不是金鑰(如 hash/id),該行尾加 credential-ok 豁免。"
|
||||
} >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ -n "$HITS" ]; then
|
||||
{
|
||||
echo "🔒 credential 鐵律攔截(leo 2026-07-29 立)"
|
||||
echo ""
|
||||
echo " 偵測到金鑰被寫在 credential 中心之外:"
|
||||
printf ' %s\n' $HITS
|
||||
echo " 檔案:$FILE_PATH"
|
||||
echo ""
|
||||
echo "【鐵律】「禁止把 secrets 寫在外面,不給 AI 寫入欄位,"
|
||||
echo " 凡是要 secrets 就要去統一中心拿」"
|
||||
echo ""
|
||||
echo "【正解】用 credential 引用,執行時才解析真身:"
|
||||
echo " Authorization: \"Bearer {{credential.kbdb_internal_token}}\""
|
||||
echo " 同批 workflow 已有正解範例可抄:{{credential.gemini_api_key}}"
|
||||
echo ""
|
||||
echo "【為什麼】兩套寫法並存必然漂移。t145 實錄:kbdb 身上舊 token、"
|
||||
echo " workflow 帶新 token ⇒ 萃取切出的 5 個三元組全 401 寫不進去,"
|
||||
echo " 且指紋一致→永遠跳過→重裝幾次都修不好(自我保護的錯誤狀態)。"
|
||||
echo ""
|
||||
echo "【真有例外】該行尾加 credential-ok 豁免(會留痕,請在 commit 說明理由)。"
|
||||
} >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# delivery-police.sh — 交付警察(Stop / SubagentStop hook)
|
||||
#
|
||||
# 病根(leo 2026-07-22 原話):「它是我的助理,如果都是我檢查,那我就是它的助理。」
|
||||
# 一天內三次都是 leo 先發現:①雲端沒查文件就問 ②OAuth 做好但前端還是舊按鈕、網址沒設 DNS
|
||||
# ③連補規則也是 leo 講了才補。→ 角色顛倒=根本失敗。
|
||||
#
|
||||
# 為什麼清單沒用:CRITICAL-PATH 使用規則 4/5(v2 覆蓋前為 6/7)寫了「要驗前端」,CLAUDE.md 規則二寫了四題公式,
|
||||
# 照樣被違反——因為**自評的人跟做事的人是同一個**,任何品管制度都不成立。
|
||||
# 解法同 self-drive-police:在「要標完成/要收工」那一刻攔截 + 反問。
|
||||
#
|
||||
# 機制:Claude 想停 → 掃這回合輸出有沒有「完成宣稱」→ 有、但沒看到實測證據 → exit 2 反問。
|
||||
# 豁免:輸出已含實測證據(curl/dig 實際輸出、HTTP 碼、grep 到畫面字串、檔案:行號)→ 放行。
|
||||
# 撞權限閘/明說四題命中 → 放行(不逼它做沒權限的事)。
|
||||
#
|
||||
# 判準(leo 2026-07-22):「友善=前端。人去按了連到錯的機制、安裝失敗,不算友善。」
|
||||
# → 有前端的東西,驗收只認用戶真的會走的那條路。
|
||||
|
||||
input="$(cat)"
|
||||
|
||||
# 防無限迴圈
|
||||
stop_active="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("stop_hook_active", False))
|
||||
except Exception: print(False)
|
||||
' 2>/dev/null)"
|
||||
[ "$stop_active" = "True" ] && exit 0
|
||||
|
||||
transcript="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("transcript_path", ""))
|
||||
except Exception: print("")
|
||||
' 2>/dev/null)"
|
||||
[ -z "$transcript" ] || [ ! -f "$transcript" ] && exit 0
|
||||
|
||||
last_text="$(python3 -c '
|
||||
import sys, json
|
||||
path = sys.argv[1]
|
||||
texts = []
|
||||
try:
|
||||
with open(path) as f: lines = f.readlines()
|
||||
for line in reversed(lines):
|
||||
try: ev = json.loads(line)
|
||||
except Exception: continue
|
||||
msg = ev.get("message", ev)
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
for b in content:
|
||||
if isinstance(b, dict) and b.get("type") == "text":
|
||||
texts.append(b.get("text", ""))
|
||||
elif isinstance(content, str):
|
||||
texts.append(content)
|
||||
break
|
||||
except Exception: pass
|
||||
print("\n".join(texts))
|
||||
' "$transcript" 2>/dev/null)"
|
||||
|
||||
[ -z "$last_text" ] && exit 0
|
||||
|
||||
# ── 豁免一:撞權限閘/明說四題命中 → 真的停不下來,放行 ──
|
||||
if printf '%s' "$last_text" | grep -qiE '權限閘|classifier|分類器擋|四題第|人閘|需要你放行|denied by'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 是否有「完成宣稱」? ──
|
||||
if ! printf '%s' "$last_text" | grep -qiE '✅|完成了|已完成|通了|部署成功|已上線|已部署|搞定|做完了|全綠|收工'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 豁免二:已有「夠格的」實測證據 → 放行 ──
|
||||
#
|
||||
# ⚠️ 本段自身踩過的雷(2026-07-22 實測 t2 抓到):
|
||||
# 舊版寫 `HTTP [0-9]{3}` 就放行 → 「curl 回 HTTP 200,這關通了」直接過關。
|
||||
# 那正是要防的假綠原句(landing 回 200 但按鈕 href 還是舊的)。
|
||||
# **把要防的東西寫成通行證**=規則 6「HTTP 200 不算驗過」被自己的 hook 架空。
|
||||
#
|
||||
# 現在的判準:2xx/3xx 一律不算證據;只有下列才算——
|
||||
# ① 抓到畫面內容(curl|grep / href= / <title>)② 壞消息碼(4xx/5xx,誠實回報不罰)
|
||||
# ③ DNS 實查 ④ 測試數字 ⑤ 檔案:行號 ⑥ 明確標 ❌/◐(沒宣稱通就不必擋)
|
||||
if printf '%s' "$last_text" | grep -qiE 'curl -s[^|]*\| *grep|href=|<title>|HTTP (4[0-9]{2}|5[0-9]{2})|dig \+short|[0-9]+/[0-9]+ (通過|綠|passed)|\.md:[0-9]+|❌ 斷|◐ 半通'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat >&2 << 'EOF'
|
||||
🚓 交付警察:你宣稱完成,但我沒看到「實測證據」。標 ✅ 之前先答:
|
||||
|
||||
【最高規範】leo 2026-08-17(他當場更正自己前一句的範圍):
|
||||
「我剛剛講錯一件事,我說你推給我的版本要測過,我錯了——
|
||||
**不是版本要測過,是任何東西你交的貨都要測過,一個 PR 也要測過,
|
||||
你不允許交出你不知道能不能用的東西。**
|
||||
**這不是對版本的規範,這是對你所有東西的規範。**」
|
||||
|
||||
⇒ 「交貨」=**任何交到別人手上的東西**:版本/PR 分支/票上的結論/
|
||||
派工單裡寫的「現況」/回報的每一句。
|
||||
⇒ **判準只有一句:交出去之前,我知不知道它能不能用?**
|
||||
不知道 → 它還沒到可以交的狀態,不是「交了再說」。
|
||||
⚠️ **「我沒有說它能用」不構成豁免**——接收方還是得自己驗,成本一樣轉嫁出去了。
|
||||
|
||||
🔴 **`report` 與 `deliver` 是兩個字**(leo 2026-08-17 當場糾正總管的後門寫法):
|
||||
「你可以說你有進度,**這是 report**;**但驗完那一格才能交,這是 deliver**。」
|
||||
report 有進度、有發現、有卡點 ← 可以有沒驗的部分,標明即可
|
||||
deliver 交到別人手上的東西 ← 每一格都驗過
|
||||
⇒ **「有一格沒驗」不是交付的例外,它是「還沒到交付」的定義。**
|
||||
那個狀態要說「我報告一下進度」,不是「我交給你但有一格沒驗」。
|
||||
|
||||
【判準】leo 2026-07-22:「它是我的助理,如果都是我檢查,那我就是它的助理。」
|
||||
→ leo 如果現在去測,會不會發現你沒發現的東西?會 → 現在就去測,別交。
|
||||
【判準】leo 2026-07-22:「友善=前端。人去按了連到錯的機制、安裝失敗,不算友善。」
|
||||
→ 驗收只認**用戶真的會走的那條路**,不是你做的那個零件。
|
||||
|
||||
1. 有前端嗎?貼「抓實際畫面內容」的輸出:
|
||||
curl -s <public 網址> | grep <該出現的字串>
|
||||
⚠️ HTTP 200 不算驗過——200 只證明伺服器活著,不證明用戶看到對的東西。
|
||||
(真實踩雷:landing 回 200,但按鈕 href 還是舊的 deploy.workers.cloudflare.com)
|
||||
|
||||
2. 對外網址四件驗了嗎?
|
||||
① custom domain 綁對專案?② DNS 解析得到?③ 前端 apiBase 對?④ CORS 放行?
|
||||
(真實踩雷:install.arcrun.dev 有 DNS 但 HTTP 522、畫面全空=網域建了沒綁 worker)
|
||||
|
||||
3. 網址還是 *.workers.dev / *.pages.dev?
|
||||
→ 那叫「僅預設網域」,用戶拿不到,不准標 ✅,最多 ◐ 半通。
|
||||
🔵 **例外:stage/測試環境不受此條約束**(leo 2026-08-07:
|
||||
「Stage 直接用 workers.dev 不行?**反正只有你我用**」)。
|
||||
本條的目的是「用戶拿不到=沒交付」——stage 的用戶就是 leo 與總管,
|
||||
*.workers.dev 我們拿得到 ⇒ 前提不成立。
|
||||
而且 stage 不掛 custom domain 反而是優點(不會有人誤入/被搜尋引擎索引/
|
||||
把 stage 網址當正式的傳出去)。
|
||||
⇒ **stage 的驗收標準=「leo 與總管能不能在上面測到真東西」,不是網域形式。**
|
||||
|
||||
4. 你自己找到並修掉了哪些問題?一項都沒有=多半沒認真找。
|
||||
|
||||
答不出來就是還沒完成——現在去測。規格見 CRITICAL-PATH.md 使用規則 4、5。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# empty-handed-stop-guard.sh — 「空手停下」封路(leo 2026-08-17 立)
|
||||
#
|
||||
# ── 為什麼是這支,而不是再修一次句型偵測 ──────────────────────────────
|
||||
# leo 2026-08-17 的診斷(原話節錄):
|
||||
# 「你的 hook 為什麼漏?因為你在**文字層**封路。抓問號、抓句型,這就是軍火競賽:
|
||||
# 『回「做」我就啓動』沒有問號,下次它會寫『準備就緒』,再下次寫『待命中』。
|
||||
# 自然語言的變體是無限的,blacklist 永遠追不完。
|
||||
# 封路哲學之所以在 forge 架構裡有效,是因為它封的是**動作**(不能 push main、
|
||||
# 不能繞過 PR),動作是有限且可枚舉的;文字不是。」
|
||||
#
|
||||
# 當日實測佐證:文字層的閘**八次誤攔、零次正確攔截**
|
||||
# irreversible-dispatch-guard 4(三次咬的是「禁止」句本身)
|
||||
# stage-before-prod-guard 2(內文出現 bundle repo 名就當成在出貨)
|
||||
# browser-verify-guard 1(認不出另一套瀏覽器工具)
|
||||
# factory-idle-guard 1(剝引號把自己的句子攪碎)
|
||||
# ⇒ 而且方向穩定:**紅線寫得越細,命中關鍵字的機會越高 ⇒ 懲罰謹慎。**
|
||||
#
|
||||
# ── 這支封的是狀態,不是措辭 ────────────────────────────────────────
|
||||
# 判準只有一條:**這個回合有沒有實際執行的證據(tool call)?**
|
||||
# 有 → 放行,不管文字怎麼寫
|
||||
# 沒有 → 擋一次,注入「不等待確認,繼續執行」
|
||||
# ⇒ 句型隨它變(「回 X 我就做」「準備就緒」「待命中」都一樣),
|
||||
# 但「空手停下」這條路本身物理上不通。
|
||||
#
|
||||
# ── 迴圈安全 ──────────────────────────────────────────────────────
|
||||
# `stop_hook_active` 為真=已經擋過一次 ⇒ 放行。**至多提醒一次,不會卡死。**
|
||||
#
|
||||
# ── 留痕(InkStoneCo#48:36 支閘只有 2 支會記錄自己擋了什麼)───────────
|
||||
# 擋下與放行都記。只記擋下的話分母未知,回答不了「這道閘有沒有在運作」。
|
||||
|
||||
INPUT=$(cat)
|
||||
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/empty-handed-stop-guard.log"
|
||||
|
||||
VERDICT=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import json, os, sys
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP"); raise SystemExit
|
||||
|
||||
# 已經擋過一次 ⇒ 不再擋(迴圈安全)
|
||||
if d.get("stop_hook_active"):
|
||||
print("SKIP:already-nudged"); raise SystemExit
|
||||
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.exists(tp):
|
||||
print("SKIP:no-transcript"); raise SystemExit
|
||||
|
||||
rows = []
|
||||
try:
|
||||
with open(tp) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try: rows.append(json.loads(line))
|
||||
except Exception: pass
|
||||
except Exception:
|
||||
print("SKIP:unreadable"); raise SystemExit
|
||||
|
||||
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
|
||||
start = 0
|
||||
for i, r in enumerate(rows):
|
||||
if r.get("type") == "user":
|
||||
c = (r.get("message") or {}).get("content")
|
||||
blocks = c if isinstance(c, list) else [{"type": "text"}]
|
||||
if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks):
|
||||
start = i
|
||||
turn = rows[start:]
|
||||
|
||||
# 數這個回合裡「我實際做了幾個動作」
|
||||
tools = 0
|
||||
for r in turn:
|
||||
if r.get("type") != "assistant":
|
||||
continue
|
||||
c = (r.get("message") or {}).get("content")
|
||||
if not isinstance(c, list):
|
||||
continue
|
||||
for b in c:
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use":
|
||||
tools += 1
|
||||
|
||||
print(("EMPTY" if tools == 0 else "OK") + ":%d" % tools)
|
||||
' 2>/dev/null || echo "SKIP:crash")
|
||||
|
||||
TOOLS="${VERDICT##*:}"
|
||||
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
|
||||
|
||||
case "$VERDICT" in
|
||||
EMPTY:*)
|
||||
printf '| %s | ⛔ 擋下 | 這個回合 %s 個動作 |\n' "$STAMP" "$TOOLS" >> "$LOG" 2>/dev/null || true
|
||||
cat >&2 <<'MSG'
|
||||
🤖 空手停下警察:**這個回合你一個動作都沒做。**
|
||||
|
||||
【leo 2026-08-17 的診斷】
|
||||
「你的命令是第一把鑰匙(讓它開始想),它要再跟你要第二把(讓它開始做)。
|
||||
從它的『文化』看,這是禮貌;**從你的系統看,這是違約。**」
|
||||
|
||||
━━ 常駐授權(這不是禁令,是授權)━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🔑 **leo 的直接命令即完整授權。**
|
||||
收到命令後停下來等第二次確認 = **任務失敗**,不是謹慎。
|
||||
提問不是謹慎,是**把成本轉嫁給正在上課的人**。
|
||||
|
||||
━━ 不確定時走這條,不要停 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
① 查 wiki(`system-dev/wiki/`,語意搜尋優先於 grep)
|
||||
② 套四題公式(花錢/不可逆/跨專案結構/品味方向)
|
||||
③ 仍不確定 → **做出最合理的假設,把假設寫進 commit message 或票的留言,繼續走**
|
||||
🔴 **假設之後不是問,是記錄。**
|
||||
leo 下課後 review 時一次看到所有假設:對的併,錯的打回,
|
||||
錯的連同「為什麼不妥」寫回 wiki ⇒ 下次同類情境查得到判例。
|
||||
⇒ 這把**同步的提問**改造成**非同步的問答**——問題排隊等他,不掛起整個 loop。
|
||||
|
||||
━━ 真的無事可做?━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
本閘**至多提醒一次**(`stop_hook_active` 之後放行),不會卡死。
|
||||
若這回合確實只是回答一個不需要任何動作的問題,直接再送一次即可。
|
||||
MSG
|
||||
exit 2
|
||||
;;
|
||||
OK:*)
|
||||
printf '| %s | ✅ 放行 | 這個回合 %s 個動作 |\n' "$STAMP" "$TOOLS" >> "$LOG" 2>/dev/null || true
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf '| %s | ⚪ 略過 | %s |\n' "$STAMP" "$VERDICT" >> "$LOG" 2>/dev/null || true
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
#!/bin/sh
|
||||
# factory-idle-guard.sh — Stop hook:**工頭停工要被擋下,不是被提醒。**
|
||||
#
|
||||
# 🔴 2026-08-16 大改。leo 的原話就是這道閘的規格:
|
||||
# 「做了一個又一個 hook,**你要閃過還是會閃過**,你應該讓原本那個可以改好。」
|
||||
# 「拿 n8n 來說,所有的 agent **缺了一個 trigger**,你是按下 trigger 的人,但你不按。
|
||||
# 所以警察不應該是催你按就好了,而是**會問你下一步是什麼,你回覆後就要交出
|
||||
# 你已經派工的憑證**,不拿票就沒完成觸發。」
|
||||
#
|
||||
# ⇒ 所以本檔改的是**量測對象**與**強度**,不是再加一支新 hook。
|
||||
#
|
||||
# ── 舊版為什麼從來沒響過(2026-08-16 查證,兩層失效疊在一起)──────────
|
||||
#
|
||||
# ① `[ "$RUNNING" -gt 0 ] && exit 0`
|
||||
# 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。
|
||||
# 2026-08-16 一天四次停工**全部發生在有 agent 在跑的時候**:
|
||||
# 產線忙著做 A,總管宣告了 B,然後沒有人做 B。工廠沒停,停的是按 trigger 的人。
|
||||
#
|
||||
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**。
|
||||
# 實測:`Leo/arcrun-rag` 回 `not found`(被當成 0),`inkstone/arcrun-rag` 有 16 張。
|
||||
# ⇒ 池子恆為 0 ⇒ 第 53 行直接 exit 0。**這道閘等於不存在。**
|
||||
# (同族第四顆搬遷遺留座標:`ARCRUN_SHIP_NS`/`ARCRUN_REPO`/MEMORY.md 索引/本檔。)
|
||||
#
|
||||
# ③ 結尾 `exit 0`——純提醒。leo 2026-08-16:「**提醒可以忽略,deny 不能**。」
|
||||
#
|
||||
# ── 現在量什麼 ─────────────────────────────────────────────────
|
||||
#
|
||||
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有派工憑證。**
|
||||
# 憑證 = 這個回合真的呼叫過 Agent/Task(而那條路上已有 no-ticket-no-dispatch
|
||||
# 在驗票號)⇒ **「有派工」本身就蘊含「有票」**,不必在這裡重複驗票。
|
||||
# 也接受:宣告句裡直接寫明它在誰手上(帶 `#數字` 的票號指涉)。
|
||||
#
|
||||
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。org 已修。
|
||||
#
|
||||
# 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。
|
||||
# 這是刻意的——永遠在響的警報等於訓練人忽略它(見 branch-holds.md 的同款教訓)。
|
||||
set -eu
|
||||
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
|
||||
# ── 主閘:宣告了下一步,有沒有交出派工憑證?────────────────────────
|
||||
VERDICT=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import sys, json, os, re
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP"); raise SystemExit
|
||||
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.exists(tp):
|
||||
print("SKIP"); raise SystemExit # 讀不到就別亂擋
|
||||
|
||||
rows = []
|
||||
try:
|
||||
with open(tp) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try: rows.append(json.loads(line))
|
||||
except Exception: pass
|
||||
except Exception:
|
||||
print("SKIP"); raise SystemExit
|
||||
|
||||
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
|
||||
start = 0
|
||||
for i, r in enumerate(rows):
|
||||
if r.get("type") == "user":
|
||||
c = (r.get("message") or {}).get("content")
|
||||
blocks = c if isinstance(c, list) else [{"type": "text"}]
|
||||
if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks):
|
||||
start = i
|
||||
turn = rows[start:]
|
||||
|
||||
dispatched = False
|
||||
blocks_text = []
|
||||
for r in turn:
|
||||
if r.get("type") != "assistant":
|
||||
continue
|
||||
for b in (r.get("message") or {}).get("content") or []:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
if b.get("type") == "tool_use" and b.get("name") in ("Agent", "Task"):
|
||||
dispatched = True
|
||||
elif b.get("type") == "text":
|
||||
blocks_text.append(b.get("text") or "")
|
||||
|
||||
if dispatched:
|
||||
print("OK"); raise SystemExit # 按了 trigger ⇒ 放行
|
||||
|
||||
# 🔴 只看**最後一則**文字,不看整個回合(2026-08-16 第一次實跑就誤攔,修正)
|
||||
# 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。
|
||||
# 掃整個回合會在「我這回合稍早說要讀 X、然後真的讀了」這種句子上開火——
|
||||
# 那是**已完成事項的敘述**,不是未兌現的意圖。誤攔會訓練人忽略警報,
|
||||
# 而那比沒有警報更糟(同 branch-holds.md 的教訓)。
|
||||
text = blocks_text[-1] if blocks_text else ""
|
||||
|
||||
# 引用 leo 的話不算我的宣告(整段引言/引號內)——它常含「下一步」等字樣
|
||||
text = re.sub(r"^\s*>.*$", "", text, flags=re.M)
|
||||
# 🔴 2026-08-17 修:舊版把「所有」引號內容都刪掉,包括我自己句子裡的關鍵詞。
|
||||
# leo 實撞:我寫「回『規劃』我就派人盤這份計畫」,`規劃` 被吃掉後變成
|
||||
# 「回我就派人盤這份計畫」,DECL 一個都不匹配 ⇒ 該攔的沒攔。
|
||||
# ⇒ 只刪「夠長的引言」(leo 的話通常成句),短引號是我自己的用詞,留著。
|
||||
text = re.sub(r"「[^」]{12,400}」", "", text)
|
||||
# 宣告下一步的句型(刻意收窄:只認「我接下來要做」,不認「現在的狀態是」)
|
||||
DECL = re.compile(
|
||||
r"(下一步(我|就是|是)?[::]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
|
||||
r"|我(要|會)(去|來)?(做|派|審|跑|查|補|建)|稍後(我|再)|之後我(會|要)"
|
||||
# 🔴 2026-08-17 leo 實撞補的一族:**把請示寫成條件句**——
|
||||
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
|
||||
# 實例:「回『規劃』我就派人盤這份計畫」/「說一聲我就落」/「你點頭我就做」
|
||||
r"|回[「『]?[^」』\n]{0,12}[」』]?(我)?就|說一聲(我)?(就)?|你(點頭|說可以|確認)(了)?(我)?(就)?"
|
||||
r"|(確認|核准|同意|批准)(過|了)?(之)?後(我)?(才|再|就)"
|
||||
r"|我(就)?(等|待)你|等你(說|回|點頭|確認)"
|
||||
r"|我(就)?(不再|先不)(自己)?(動|做|派)"
|
||||
r"|我(就)?(一次)?(落|派|做|補|審|清)(完|掉)?[。,,]?\s*$)")
|
||||
hit = DECL.search(text)
|
||||
if not hit:
|
||||
print("OK"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
|
||||
|
||||
# 宣告句附近有票號指涉(=已經說明它在誰手上/哪張票)⇒ 放行
|
||||
seg = text[max(0, hit.start() - 200): hit.end() + 400]
|
||||
if re.search(r"#\d{1,5}", seg):
|
||||
print("OK"); raise SystemExit
|
||||
|
||||
print("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
|
||||
' 2>/dev/null || echo SKIP)
|
||||
|
||||
case "$VERDICT" in
|
||||
DECLARED_NO_TRIGGER::*)
|
||||
QUOTE=$(printf '%s' "$VERDICT" | sed 's/^DECLARED_NO_TRIGGER:://')
|
||||
cat >&2 <<MSG
|
||||
🏭 稼動率警察:**你宣告了下一步,但這個回合沒有按下 trigger。**
|
||||
|
||||
你寫的是:
|
||||
「…${QUOTE}…」
|
||||
|
||||
leo 2026-08-16(本閘的規格):
|
||||
「所有的 agent **缺了一個 trigger**,你是按下 trigger 的人,**但你不按**。
|
||||
所以警察不應該是催你按就好了,而是**會問你下一步是什麼,
|
||||
你回覆後就要交出你已經派工的憑證**,不拿票就沒完成觸發。」
|
||||
|
||||
━━ 為什麼這次是擋不是提醒 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
2026-08-16 一天內同一個病發作四次,**每一次都是這個形狀**:
|
||||
回合終止在「輸出文字」⇒「下一步是 X」寫在那段文字裡
|
||||
⇒ 在結構上 X 永遠落在回合結束之後。**不是忘記,是把 X 寫進了終結回合的動作裡。**
|
||||
而說出意圖會消解掉做它的壓力——一份清楚的計畫**讀起來像進度**。
|
||||
|
||||
━━ 現在怎麼過這道閘(擇一)━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
① **現在就按 trigger**:這個回合直接呼叫 Agent/Task 派出去。
|
||||
沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張,
|
||||
再 \`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。
|
||||
② **它已經在別人手上**:把票號寫進那句話(例:「已派給 #44 comment 2761」)。
|
||||
有票號指涉就放行——那不是「我等下做」,是「已經有人在做」。
|
||||
③ **它其實不該現在做**:改寫那句話,說明它在等什麼(前置沒解除/是 leo 的閘),
|
||||
不要寫成「下一步我要做 X」。
|
||||
|
||||
🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得,
|
||||
比宣告了沒做更糟——它連痕跡都不留。
|
||||
MSG
|
||||
exit 2 ;;
|
||||
esac
|
||||
|
||||
# ── 副閘:真的全廠停工(零 agent + 池子有票)──────────────────────
|
||||
RUNNING=0
|
||||
for d in /private/tmp/claude-501/*/*/tasks; do
|
||||
[ -d "$d" ] || continue
|
||||
n=$(find "$d" -name '*.output' -newermt '-60 seconds' 2>/dev/null | wc -l | tr -d ' ')
|
||||
RUNNING=$((RUNNING + n))
|
||||
done
|
||||
[ "$RUNNING" -gt 0 ] && exit 0
|
||||
|
||||
TOKEN=$(git -C "$PROJ" remote get-url gitea 2>/dev/null | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') || TOKEN=""
|
||||
[ -z "$TOKEN" ] && exit 0
|
||||
|
||||
# 🔴 org 是 inkstone,不是 Leo(2026-08-13 搬遷;舊值讓本閘靜默失效到 08-16)
|
||||
TODO=$(
|
||||
for repo in mira arcrun-rag Arcrun InkStoneCo; do
|
||||
curl -s --max-time 6 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/inkstone/$repo/issues?state=open&labels=s/todo&limit=50" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
print(len(d) if isinstance(d,list) else 0)
|
||||
except Exception: print(0)' 2>/dev/null
|
||||
done | awk '{s+=$1} END {print s+0}'
|
||||
)
|
||||
[ "${TODO:-0}" -eq 0 ] && exit 0
|
||||
|
||||
cat >&2 <<MSG
|
||||
🏭 稼動率警察:**全廠停工**——零 agent 在跑,而任務池還有 ${TODO} 張可開工的票。
|
||||
|
||||
leo 2026-08-10:「你的任務是**維護 loop**,你要去拿任務派任務,
|
||||
**全工廠停工你要發現,這是警訊**。」
|
||||
|
||||
【現在該做的,不是回報,是派工】
|
||||
1. 撈池子當場撈:\`scripts/ticket where <關鍵字>\`(或 labels=s/todo)
|
||||
2. 照 CP 排序:\`system-dev/docs/3-specs/critical-paths/ship.md\` 是有序的六步
|
||||
3. 一次派 2–4 件平行跑;同一個 repo 不要塞兩條(會搶工作區)
|
||||
4. agent 死於環境錯誤(SSL/API)⇒ **那是要重派的訊號,不是完工**
|
||||
MSG
|
||||
exit 0
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
# github-contact-guard.sh — GitHub 接觸保險(D20,2026-07-02)
|
||||
# 背景:兩個帳號因高頻動作被 flag 拿不回(幾十顆星+issues 全損)。
|
||||
# 設計:戰鬥機武器保險模式——平時所有 github.com 接觸一律機械擋下(exit 2),
|
||||
# 只有 leo 親手跑 scripts/github-arm.sh 解鎖(限時),期間每次接觸自動留痕。
|
||||
# 誠實限制(mindset §7):AI 技術上可自建 .github-armed 繞過;本 hook 的價值是
|
||||
# 擋手滑+留審計軌跡,最後一段靠 D20 鐵律與自律。絕不聲稱「不可能繞過」。
|
||||
# 2026-08-10 leo:以後一律匿名讀,寫才實名(詳見下方判定區塊的說明)。
|
||||
#
|
||||
# 🔴 另一個誠實限制(2026-08-09,arcrun-rag#27 查出的根因,別再花時間重查):
|
||||
# 本 hook 只看得到「Claude Code 的 Bash 工具呼叫本身的指令字串」。
|
||||
# 若某支程式(例如 products/arcrun-rag/installer/scripts/ship.mjs)自己用
|
||||
# node:child_process 的 execFileSync/spawnSync 直接 spawn `git push`,
|
||||
# 那個 push 是那支程式的**子行程**,不會產生新的 Bash 工具呼叫——本 hook
|
||||
# 從頭到尾看不到它,不管下面的正則怎麼改都一樣(8/8 23:16 那次出貨的 push
|
||||
# 就是這樣:hook 看到的指令字串只有 `node ship.mjs --target prod --confirm`,
|
||||
# 裡面沒有 git/github.com 字面,於是判成「匿名讀」放行,連 D20 保險存不存在
|
||||
# 都沒機會查,log 自然也不會有那一筆)。
|
||||
# ⇒ 這種「碰 GitHub 的動作被包在別支程式裡」的情況,保險檢查與留痕**必須由
|
||||
# 那支程式自己做**(見 ship.mjs 用的 installer/scripts/d20-guard.mjs),
|
||||
# 不能指望改這支 hook 就能補到——這支 hook 天生看不見子行程。
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
CMD=$(printf '%s' "$INPUT" | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('tool_input',{}).get('command',''))
|
||||
except Exception:
|
||||
print('')
|
||||
" 2>/dev/null)
|
||||
|
||||
[ -z "$CMD" ] && exit 0
|
||||
|
||||
# 命中判定(D20 邊界,2026-07-05 leo 拍板——Facebook 比喻定調):
|
||||
# GitHub 不在乎你「讀」(clone/fetch/抓 release,不管實名匿名、自己的還別人的——那是它原本的功能,
|
||||
# 像 FB 不禁你讀貼文)。它 abuse-detect 的是「機器人一直改/寫」(高頻 push、Actions fan-out、API 寫)
|
||||
# ——那才是害 richblack/uncle6 被 flag 的模式。**真正的線=讀 vs 寫,不是匿名 vs 實名。**
|
||||
# 所以:所有「讀」一律放行(含帶認證 clone 自己的 repo);只擋「寫」與「gh 高頻 API」。
|
||||
# 只擋以下兩型(寫入 / 會寫的 API):
|
||||
# ① gh CLI 網路子指令(高頻 API,多會寫,且走你 token)② git 寫入動詞指向 github(push / remote add)
|
||||
# 放行:git clone/fetch/pull/ls-remote(任何 repo,帶不帶認證都是讀)、curl/wget、go get/pip。
|
||||
HIT=""
|
||||
# ① gh CLI —— 高頻 API,全擋(讀寫混雜且走你 token,保守全擋;真要唯讀查詢個案 arm)
|
||||
if printf '%s' "$CMD" | grep -qE '(^|[;&|(]|\s)gh\s+(api|repo|issue|pr|auth|search|release|run|workflow|gist|browse)\b'; then
|
||||
HIT="gh CLI(高頻 API,走你的 token)"
|
||||
# ② git 寫入動詞指向 github(push / remote add 為 push 鋪路)—— 寫入,擋
|
||||
elif printf '%s' "$CMD" | grep -qiE 'git\s+(push|remote\s+add)([^|;&]*)(github\.com)'; then
|
||||
HIT="git 寫入 → github(push/remote add)"
|
||||
# ②b 🔴 2026-08-05 補漏:上面那條只認指令裡的 **github.com 字面**
|
||||
# ⇒ `git push origin main`(remote 名指向 GitHub)**完全不會被攔**。
|
||||
# 實撞:08-05 出貨那次 `git push origin main` 是「沒被偵測到」才過的,
|
||||
# 不是靠保險放行——等於 D20 的武器閘有一個大洞,而它平常看起來很正常。
|
||||
# ⇒ 用 remote 名去把 URL 解出來再判。
|
||||
# ⚠️ 只有解出來**真的是 github** 才擋——Gitea 是真相源、要能自由推,不可誤傷。
|
||||
# 解不出來(不在 git repo/remote 不存在)就放行:那種情況 git 自己也會失敗。
|
||||
# ②c 🔴 2026-08-10 再補一個漏:`git -c http.<url>.extraheader=... push -u github main`
|
||||
# (publish-github.sh 的寫法)command 字面裡**本來就看得到** github.com——
|
||||
# 不必等 remote 名稱解析成功才判斷得出來,而 remote 名稱解析在某些 cwd 下會失敗
|
||||
# (見檔頭「2026-08-10 leo 簡化」那段的實撞紀錄)。字面查得到就直接判定,
|
||||
# 查不到才退回舊的「解 remote 名稱」那條路——兩條路都失手才會誤放行。
|
||||
elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(]|\s)git\s+([^|;&]*\s)?push(\s|$)'; then
|
||||
# 🔴 總管 2026-08-10 收窄:原版寫成「整串裡有 github.com 就擋」,
|
||||
# 於是**連 commit message 提到那個網址都會被擋**(我自己第一次要 commit 就撞到)。
|
||||
# ⇒ 改成必須是「push 的目標」:github.com 要出現在 push 之後、且中間不跨命令分隔符。
|
||||
# 誤擋的代價不是不方便——是它會逼人拆指令繞路,久了整道閘就沒人當真。
|
||||
# 第二次收窄:只看「push 後面**第一個非旗標參數**」是不是 github 網址。
|
||||
# 光要求「同一段命令內」還不夠——commit message 寫「push 到 https://github.com/…」
|
||||
# 中間沒有 ;&| 一樣命中。真正的推送,網址必定緊接在 push 與旗標之後。
|
||||
if printf '%s' "$CMD" | grep -qiE 'push(\s+-{1,2}[A-Za-z0-9=_-]+)*\s+(https?://|git@|ssh://)[^[:space:]]*github\.com'; then
|
||||
HIT="git push → github(push 的目標字面就是 github.com)"
|
||||
else
|
||||
CWD=$(printf '%s' "$INPUT" | python3 -c "
|
||||
import json,sys
|
||||
try: print(json.load(sys.stdin).get('cwd','') or '')
|
||||
except Exception: print('')
|
||||
" 2>/dev/null)
|
||||
[ -n "$CWD" ] || CWD=$(pwd)
|
||||
# 取 push 後面第一個非 flag 的字當 remote 名;沒有就是 origin
|
||||
RNAME=$(printf '%s' "$CMD" | sed -nE 's/.*git[[:space:]]+([^|;&]*[[:space:]])?push[[:space:]]+(-[^[:space:]]+[[:space:]]+)*([A-Za-z0-9._//-]+).*/\3/p' | head -1)
|
||||
[ -n "$RNAME" ] || RNAME="origin"
|
||||
RURL=$(git -C "$CWD" remote get-url "$RNAME" 2>/dev/null)
|
||||
if printf '%s' "$RURL" | grep -qi 'github\.com'; then
|
||||
HIT="git push → github(remote「$RNAME」指向 GitHub)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 明確放行(全部是「讀」,零 flag 風險——GitHub 原本就允許):
|
||||
# git clone/fetch/pull/ls-remote 任何 github repo(自己的/別人的、帶認證與否都是讀)、
|
||||
# curl/wget 抓 release/raw/codeload、go get/pip 抓公開 module。
|
||||
# ——讀不是 abuse,不在 D20 射程。唯一風險是「機器人一直寫」,已由 ①② 擋住。
|
||||
#
|
||||
# 2026-08-10 leo 簡化:讀不分匿名/實名,一律放行、不計數、無頻率閘(見檔頭說明)。
|
||||
# 舊版這裡曾有一段「實名讀頻率閘」(URL 帶 token/讀自己 private repo → 每 4 小時放行 1 次),
|
||||
# 08-10 真的誤傷過一次出貨(見檔頭)。leo 拍板拿掉:判準只剩「有沒有在寫」,讀就是讀。
|
||||
#
|
||||
# 🔴 總管 2026-08-10 補回一件(agent 原版把偵測整段刪了,我只拿掉「擋」,不拿掉「看得見」):
|
||||
# leo 的規則是「**一律匿名讀**」⇒ 帶著憑證去讀,本身就已經偏離規則。
|
||||
# hook 沒辦法強迫一條指令變匿名,但**可以留下痕跡**——
|
||||
# 否則哪天 GitHub 又來說我們高頻活動,我們手上沒有任何紀錄可以自證讀了什麼。
|
||||
# ⇒ 實名讀:**放行**(照 leo 的簡化,不擋、不限速),但**寫一行進接觸紀錄**。
|
||||
if [ -z "$HIT" ]; then
|
||||
if printf '%s' "$CMD" | grep -qiE '(https?://)[^[:space:]/@]+@[a-z0-9._-]*(github\.com|githubusercontent\.com|codeload\.github)'; then
|
||||
_LOG="${CLAUDE_PROJECT_DIR:-$(pwd)}/system-dev/docs/3-specs/autonomy-dispatch/github-contact-log.md"
|
||||
printf -- '- %s | 實名讀(URL 帶憑證,已放行)| %s\n' \
|
||||
"$(date '+%Y-%m-%d %H:%M')" "$(printf '%s' "$CMD" | sed 's|//[^@]*@|//<憑證省略>@|g' | cut -c1-120)" \
|
||||
>> "$_LOG" 2>/dev/null
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
ARM_FILE="$PROJ/.github-armed"
|
||||
LOG_FILE="$PROJ/system-dev/docs/3-specs/autonomy-dispatch/github-contact-log.md"
|
||||
|
||||
if [ -f "$ARM_FILE" ]; then
|
||||
EXPIRY=$(sed -n '1p' "$ARM_FILE")
|
||||
MISSION=$(sed -n '2p' "$ARM_FILE")
|
||||
NOW=$(date +%s)
|
||||
if [ "$NOW" -le "${EXPIRY:-0}" ] 2>/dev/null; then
|
||||
# 已解鎖 → 放行 + 留痕(BDA 戰損記錄)
|
||||
SHORTCMD=$(printf '%s' "$CMD" | head -c 200 | tr '\n' ' ')
|
||||
printf '| %s | %s | `%s` |\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$MISSION" "$SHORTCMD" >> "$LOG_FILE" 2>/dev/null
|
||||
exit 0
|
||||
else
|
||||
rm -f "$ARM_FILE"
|
||||
echo "❌ BLOCKED by D20 GitHub 接觸儀式" >&2
|
||||
echo "違反項:保險已過期(武裝時效已過,自動回到保險狀態)" >&2
|
||||
echo "正確做法:需要繼續接觸 GitHub → 請 leo 重新在終端機跑 scripts/github-arm.sh \"任務描述\"" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "❌ BLOCKED by D20 GitHub 接觸儀式(武器保險未解除)" >&2
|
||||
echo "命中:${HIT}" >&2
|
||||
echo "背景:兩個 GitHub 帳號因高頻動作被 flag 永久拿不回。平時一律只碰 Gitea。" >&2
|
||||
echo "正確做法(發射流程):" >&2
|
||||
echo " 1. 先自問:這件事 Gitea 做不到嗎?(issue/PR/push 內部協作 Gitea 全都能)" >&2
|
||||
echo " 2. 真的非 GitHub 不可 → 向 leo(總部)請示:目標 repo、動作清單、預估請求次數、為何非它不可" >&2
|
||||
echo " 3. leo 同意後由 leo 親手在終端機跑:scripts/github-arm.sh \"任務描述\" [分鐘,預設30]" >&2
|
||||
echo " 4. 解鎖期間動作守 ROE:單 repo、≤5 次網路請求、禁批量/迴圈/Actions、間隔像人手" >&2
|
||||
echo "參考:system-dev/docs/2-architecture/decisions/D20-github-contact-protocol.md" >&2
|
||||
exit 2
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# guard-cross-project.sh — 頂層總管 guardrail
|
||||
#
|
||||
# 職責邊界(InkStoneCo CLAUDE.md D9):頂層只做「跨專案的整理與安排」(總管),
|
||||
# 不進單一子 repo 做實作。子 repo 的程式碼/設定實作 = 在那個子 repo 的專案裡執行,
|
||||
# 頂層只負責「交棒」(寫 docs/HANDOFF-*.md)並記錄安排。
|
||||
#
|
||||
# 本 hook 攔 PreToolUse 的 Write/Edit/MultiEdit:
|
||||
# - 目標路徑落在子 repo 目錄(matrix/ polaris/ products/ _archive/)內
|
||||
# - 且不是 .md 文件
|
||||
# → 擋下(exit 2),提示改用交棒(寫 HANDOFF)。
|
||||
# 放行:頂層自己的檔(CLAUDE.md / .claude/ / docs/ / MEMORY.md…)、
|
||||
# 以及子 repo 內的 .md(交棒 HANDOFF / 筆記 / 文件——leo 2026-06-15 拍板放行所有 .md)。
|
||||
#
|
||||
# 誠實限制(arcrun mindset §7):本 hook 擋的是「路徑語法層」——能擋「寫程式碼進子 repo」,
|
||||
# 擋不了「把實作偽裝成 .md」或「該交棒卻判斷成可直改」這種語意層越界。它是底線,不是萬能。
|
||||
|
||||
input="$(cat)"
|
||||
|
||||
# 取 file_path(Write/Edit/MultiEdit 都用 tool_input.file_path)
|
||||
file_path="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get("tool_input", {}).get("file_path", ""))
|
||||
except Exception:
|
||||
print("")
|
||||
' 2>/dev/null || true)"
|
||||
|
||||
# 拿不到路徑就放行(不誤擋;hook 不該因解析失敗卡死正常操作)
|
||||
[ -z "$file_path" ] && exit 0
|
||||
|
||||
# 頂層 repo 根(hook 檔位於 <root>/.claude/hooks/)
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
# 轉成相對 repo_root 的路徑(file_path 可能是絕對或相對)
|
||||
case "$file_path" in
|
||||
/*) rel="${file_path#"$repo_root"/}" ;; # 絕對路徑 → 去掉 repo_root 前綴
|
||||
*) rel="$file_path" ;; # 相對路徑 → 假定相對 repo_root
|
||||
esac
|
||||
|
||||
# 不在子 repo 目錄內 → 放行(頂層自己的檔)
|
||||
case "$rel" in
|
||||
matrix/*|polaris/*|products/*|_archive/*) : ;; # 落在子 repo,繼續判斷
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 自動派工放行(2026-06-28 leo 拍板上線,實測通過):
|
||||
# 子 repo 的 subagent 可直接寫該 repo code(繞 D9)——因 subagent 心智已是該 repo
|
||||
# (A/B 實驗證 ≈ claude -p),讓「心智是該 repo 的小弟寫該 repo」符合 context 隔離。
|
||||
# 雙重夾:① CHILD_SESSION=1(harness 內建 subagent 標籤,總管主 session 無此標籤仍被擋)
|
||||
# ② 路徑在下面白名單 array 內。
|
||||
# 隔離保證=prompt reset(軟,靠監測)非結構級;要絕對純走 claude -p。心法見 wiki。
|
||||
#
|
||||
# 🔧 leo 維護:要放開一個 repo 給 subagent 自動寫 code,就在這 array 加一行路徑前綴。
|
||||
# ⚠️ 加 = 放權,想清楚私人/共用(改它影響誰)再加。array 空 = 誰都不放(安全預設)。
|
||||
AUTODISPATCH_ALLOW=(
|
||||
"polaris/mira" # 私人環境(只影響 leo)
|
||||
"matrix/arcrun" # 共用框架(影響全部人,走 PR 把關)
|
||||
"products/arcrun-rag" # 產品組裝 repo(2026-07-13 leo 放行 G8:回覆「放行」)
|
||||
)
|
||||
if [ "$CLAUDE_CODE_CHILD_SESSION" = "1" ]; then
|
||||
for allowed in "${AUTODISPATCH_ALLOW[@]}"; do
|
||||
case "$rel" in
|
||||
"$allowed"/*) echo "🤝 [guard] subagent 放行:$allowed(自動派工)" >&2; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
# 在子 repo 內:.md 放行(交棒 / 文件),其餘擋
|
||||
case "$rel" in
|
||||
*.md|*.MD|*.markdown) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 走到這 = 寫非 .md 檔進子 repo 目錄 = 越界實作 → 擋
|
||||
sub="${rel%%/*}"
|
||||
cat >&2 <<EOF
|
||||
❌ BLOCKED by InkStoneCo 總管 guardrail(職責邊界 D9)
|
||||
|
||||
違反:頂層總管直接寫實作檔進子 repo($sub/)。
|
||||
路徑:$rel
|
||||
|
||||
頂層只做「跨專案的整理與安排」,不進單一 repo 做實作。
|
||||
子 repo 的程式碼 / 設定 = 在該子 repo($sub)的專案裡改,不在頂層。
|
||||
|
||||
正確做法:
|
||||
• 要子 repo 改什麼 → 寫一份交棒文件:$sub/docs/HANDOFF-*.md(.md 放行),
|
||||
給判準與論證,成品由該 repo 的 CC 按它自己的 rules 產出。
|
||||
• 真要由你(總管)動這個子 repo → 切換到那個子 repo 的工作脈絡再做,不在頂層越界。
|
||||
|
||||
參考:CLAUDE.md「職責邊界(核心原則)」+ decisions-summary D9。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
# history-first-guard.sh — 修舊東西前,先查它修過幾次(PreToolUse: Edit|MultiEdit)
|
||||
#
|
||||
# 病根(leo 2026-08-02 三句,同一天):
|
||||
# ①「拿掉獨立的登記動作 ← 哪裡來的?**你不要把舊版放上去**」
|
||||
# ②「這個修了無數次,**你不要再把舊版弄回來,不要再搞不清楚機制**」
|
||||
# ③「**你已經好幾次把好的修壞,因為你去修以前沒查記錄,問了早就解決的問題**」
|
||||
#
|
||||
# 當天實錄(三次都是同一個病):
|
||||
# A 把 leo 08-01 才修好的 t159 庫登記提案拆掉(沒查 wiki 就重新設計)
|
||||
# B 把 07-28 已拍板的「庫自動出現」做成選擇題丟回去問 leo(定案寫在 commit 429b2d9 裡)
|
||||
# C 版本號改 semver 時沒 grep 誰在消費它 ⇒ 打爆 trayCloudVersionStale(把好的修壞)
|
||||
# D release.mjs 列舉式重建 manifest ⇒ 吃掉 daemon 欄(把好的修壞)
|
||||
#
|
||||
# 為什麼要**這一道**:wiki-first-search.sh 只在「grep 時」提醒且 exit 0(無牙齒),
|
||||
# 而真正危險的時機是「**要動一個已經存在的東西**」那一刻。
|
||||
# 查 hook 盤點:16 支裡它是唯一該管查記錄卻沒有 exit 2 的 ⇒ 我今天三次跳過都沒被擋。
|
||||
#
|
||||
# 機制:Edit/MultiEdit 一個**既有 code 檔**時,用該檔名/函式名去 grep wiki+tasks,
|
||||
# 有歷史紀錄就把命中摘要推到眼前並擋一次(exit 2),要求先讀再改。
|
||||
# 讀完再送一次同樣的編輯即放行(同一檔 10 分鐘內只擋一次,不無限鬼打牆)。
|
||||
#
|
||||
# 豁免:新檔/文件/測試/SDD 本身/wiki 本身;查無歷史紀錄一律放行。
|
||||
|
||||
INPUT="$(cat)"
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("tool_input", {}).get("file_path", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || echo "")
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
[ -f "$FILE_PATH" ] || exit 0 # 新檔不擋
|
||||
|
||||
# 只管 code 檔
|
||||
case "$FILE_PATH" in
|
||||
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.go|*.py|*.sh) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
# 這些不擋:測試、SDD、wiki、hook 自己
|
||||
case "$FILE_PATH" in
|
||||
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
|
||||
*system-dev/docs/3-specs/*|*system-dev/wiki/*|*/.claude/hooks/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
BASE=$(basename "$FILE_PATH")
|
||||
STAMP="/tmp/.history-guard-$(printf '%s' "$FILE_PATH" | shasum | cut -c1-12)"
|
||||
|
||||
# ── 第 0 道:問過 KBDB 了沒(2026-08-08 leo 兩度點破後補上的牙齒)────────────
|
||||
#
|
||||
# 🔴 為什麼加在**最前面**:leo 定的成本效益序是
|
||||
# KBDB(一次看所有庫,最便宜)→ repo 的 wiki/tasks → git log -S → 讀源碼(最貴)。
|
||||
# 原本這道閘只在文字裡「建議」補一次 kbdb_search,零機械檢查
|
||||
# ⇒ 我 08-08 直接跳過,leo 當場抓到:「你自己寫了 hook,結果一次次改都沒產生用途」。
|
||||
# ⇒ 順序要做成環境邊界,不是靠我每次記得(leo 母原則:環境邊界取代邏輯判斷)。
|
||||
#
|
||||
# 時戳由 kbdb-asked-stamp.sh(PostToolUse,matcher 對 kbdb_* 工具)寫下。
|
||||
# 逃生口:KBDB 真的連不上時 `touch /tmp/.kbdb-down`——放行但留痕,且回覆裡要說明。
|
||||
KBDB_STAMP=/tmp/.kbdb-asked
|
||||
KBDB_DOWN=/tmp/.kbdb-down
|
||||
kbdb_fresh=0
|
||||
now=$(date +%s)
|
||||
for f in "$KBDB_STAMP" "$KBDB_DOWN"; do
|
||||
if [ -f "$f" ]; then
|
||||
t=$(cat "$f" 2>/dev/null || echo 0)
|
||||
[ $((now - t)) -lt 3600 ] && kbdb_fresh=1
|
||||
fi
|
||||
done
|
||||
if [ "$kbdb_fresh" -eq 0 ]; then
|
||||
cat >&2 <<'KEOF'
|
||||
🧠 KBDB 先問警察:這一小時內你**沒有問過 KBDB**,卻已經要改源碼了。
|
||||
|
||||
【leo 2026-08-08】「KBDB 可以看到所有庫,**成本最低**,要求先查⋯⋯
|
||||
**KBDB 的知識庫就是要建來取代你的記憶的**。要做的就是不要每次我來提醒。」
|
||||
|
||||
成本效益序(貴的排後面):
|
||||
KBDB(一次看所有庫)→ 該 repo 的 wiki/tasks → git log -S → 讀源碼(最貴,
|
||||
只看得到「現在長怎樣」,看不到「為什麼變成這樣」)
|
||||
|
||||
先做這兩步(花不到十秒):
|
||||
kbdb_get_map() # 先看有哪些庫、該進哪一庫
|
||||
kbdb_search(q="<一句話描述這次要改什麼>", mode="semantic")
|
||||
|
||||
📌 **查了沒命中也算數**——那筆「查了沒有」就是一份餵食清單,
|
||||
代表這段知識還沒進 KBDB,收工時該把它餵進去(08-08 實例:
|
||||
mistakes.md 那條三元組一字不差卻 0 命中 ⇒ 開發史根本沒進 KBDB)。
|
||||
|
||||
🚪 KBDB 真的連不上:`touch /tmp/.kbdb-down` 後重送,並在回覆裡明說。
|
||||
KEOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 10 分鐘內同一檔已提示過 → 放行(讀完就能繼續改,不鬼打牆)
|
||||
if [ -f "$STAMP" ]; then
|
||||
then_=$(cat "$STAMP" 2>/dev/null || echo 0)
|
||||
[ $((now - then_)) -lt 600 ] && exit 0
|
||||
fi
|
||||
|
||||
# 查 wiki+tasks 有沒有這個檔的歷史
|
||||
HITS=$(grep -rn --include="*.md" -- "$BASE" \
|
||||
"$ROOT/system-dev/wiki" "$ROOT/system-dev/docs/3-specs" 2>/dev/null | head -8)
|
||||
|
||||
# 🔴 2026-08-04 補(leo:「凡是有改就要入 wiki,凡是要改都要查 wiki,
|
||||
# 不是有限制,為什麼還會漏」):wiki grep **只認字面**,同一個 bug 若在
|
||||
# **別的分支**已經修好,wiki 那幾行不會告訴你——今天就這樣重修了一次
|
||||
# `trayCloudVersionStale`(08-02 `dde7a5b` 已在 fix/cis-round3-landing-favicon 修好,
|
||||
# 而我在 feat/daemon 上看到的是沒修過的舊 code,就當新 bug 從頭做一遍,
|
||||
# 還做得比原版差:原版有 compareSemver 逐段整數比較,我的只判「是不是 semver」)。
|
||||
# ⇒ 這裡直接把「這個檔在所有分支的近期 commit」秀出來,讓「別的分支已修過」無所遁形。
|
||||
GITLOG=""
|
||||
if git -C "$(dirname "$FILE_PATH")" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
GITLOG=$(git -C "$(dirname "$FILE_PATH")" log --all --oneline -8 \
|
||||
--format='%h %d %s' -- "$FILE_PATH" 2>/dev/null | head -8)
|
||||
fi
|
||||
|
||||
# 兩邊都查無 → 真的是新東西,放行
|
||||
[ -z "$HITS" ] && [ -z "$GITLOG" ] && exit 0
|
||||
|
||||
date +%s > "$STAMP"
|
||||
cat >&2 <<EOF
|
||||
🚓 歷史警察:你要改的 \`$BASE\` **有前科**——先看它修過幾次,再決定怎麼改。
|
||||
|
||||
【leo 2026-08-02】「你已經好幾次把好的修壞,**因為你去修以前沒查記錄**,
|
||||
問了早就解決的問題」
|
||||
【leo 2026-08-02】「這個修了無數次,不要再把舊版弄回來,**不要再搞不清楚機制**」
|
||||
|
||||
── wiki/tasks 裡關於這個檔的紀錄 ──
|
||||
$HITS
|
||||
|
||||
── 這個檔在**所有分支**的近期 commit(← 別的分支可能已經修過同一個 bug)──
|
||||
$GITLOG
|
||||
|
||||
━━━ 改之前先回答(答不出來就是還沒查夠)━━━
|
||||
Q0 🔴 **這個 bug 是不是別的分支已經修好了?**(08-04 實錄:我在 feat/daemon 重修了
|
||||
08-02 已在另一分支修好的 semver 比較,還修得比原版差)
|
||||
機械查法(五秒,別憑感覺):
|
||||
git log --all --oneline -S"<關鍵函式名>" -- <檔案>
|
||||
git log --all --oneline -- <檔案> # ← 上面那段已經幫你列了
|
||||
查到就 \`git show <sha>:<檔案>\` 取回原版,**不要自己重寫一份**。
|
||||
Q1 這個檔/這段邏輯**修過幾輪**?每輪的真兇分別是什麼?
|
||||
Q2 我現在想做的改動,**有沒有哪一輪已經試過並被否決**?
|
||||
(危險訊號:「我想到一個更根本的做法」出現在老問題上,
|
||||
多半是重新發明一個已被試過或已被 leo 否決的方案)
|
||||
Q3 leo 對這件事說過的話,我是**回去找原文**,還是憑印象詮釋?
|
||||
(08-02 實錄:「沒有登記這回事」指的是前端別有人工按鈕,
|
||||
我卻詮釋成「拆掉後端登記機制」=差一個世代的誤讀)
|
||||
|
||||
📌 grep 只認字面。**查不到不等於沒記載**——補一次語意搜尋:
|
||||
kbdb_search(q="<一句話描述>", mode="semantic")/kbdb_get_map()
|
||||
|
||||
(讀完再送一次同樣的編輯即放行;同一檔 10 分鐘內不再打擾。)
|
||||
EOF
|
||||
exit 2
|
||||
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"description": "InkStone Environment Plugin (ISEP) —— leo 的 Claude Code 環境唯一真相源:所有機械閘、slash command、skill、腳本。本機與雲端裝同一份,總管自己也吃它(dogfood)。",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/github-contact-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-api-wall-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stage-before-prod-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/main-and-prod-push-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/prod-write-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/not-my-branch-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/leo21c-write-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*(arcrun_push_workflow|arcrun_delete_workflow|arcrun_recipe_push|arcrun_recipe_delete|arcrun_create_tag|arcrun_delete_tag|arcrun_tag_resource|arcrun_untag_resource|kbdb_create_record|kbdb_create_template).*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/prod-write-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard-cross-project.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-secret-scan.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/component-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/sdd-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/credential-only-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/arcrun-intent-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-api-wall-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-first-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mistake-needs-ticket-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pending-changes-retired.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Grep|Glob|Read|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-first-search.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-wiki-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-api-wall-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/micromanage-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/irreversible-dispatch-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*arcrun_(push_workflow|validate_yaml).*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/arcrun-intent-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/history-first-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Agent",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/micromanage-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/irreversible-dispatch-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start-recall.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/skill-deploy-drift-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/empty-handed-stop-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/worklist-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/factory-idle-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/browser-verify-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/self-drive-police.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/self-drive-judge.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/delivery-police.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-first-police.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/claim-verify-police.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-claim-worksheet.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/worklist-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/self-drive-police.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/self-drive-judge.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/delivery-police.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-first-police.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": ".*kbdb_(search|get_map|query|graph_neighbors).*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-asked-stamp.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Agent|Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-first-stamp.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/sh
|
||||
# irreversible-dispatch-guard.sh — PreToolUse(Agent|Task):擋「把不可逆動作寫成選項」的派工單
|
||||
#
|
||||
# 🔴 事故(Gitea Leo/arcrun-rag#33,2026-08-09):subagent 未經 leo 同意刪掉兩條遠端分支。
|
||||
# 根因不是它亂來——是總管的派工單寫了「作廢就刪掉分支」,等於預先授權了一個不可逆動作。
|
||||
# 它刪掉的那條裡,還有一件它自己標明「等 leo 排序」的工作,一併蒸發。
|
||||
#
|
||||
# 【對照組,同一天同一個總管】#14 的派工單寫
|
||||
# 「🔴 刪資料不可逆。動手前先把清單寫在 issue 留言,等總管回覆確認才執行」
|
||||
# ⇒ 那個 agent 真的停下來等。同一個人一次寫對一次寫錯 ⇒ 靠自律不行,要機械閘。
|
||||
#
|
||||
# 判準(細節與正則見 irreversible_dispatch_check.py):
|
||||
# 派工單裡出現「不可逆動作」的動詞+對象(刪分支/drop table/rm -rf/force push…)
|
||||
# 且沒被否定(不是「不准刪」這種禁令句)
|
||||
# 且全文找不到「停下來等回覆才執行」這類守門片語
|
||||
# ⇒ 判定為把不可逆動作寫成收工方可以自己執行的選項,擋下。
|
||||
#
|
||||
# 🔴 寧可誤擋,不可漏放——這是安全機制,不是文風檢查。豁免走留痕:
|
||||
# 命中那一行尾巴加 `irreversible-ok`。
|
||||
set -eu
|
||||
|
||||
INPUT="$(cat)"
|
||||
PROMPT=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get("tool_input", {}).get("prompt", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' 2>/dev/null || echo "")
|
||||
|
||||
[ -z "$PROMPT" ] && exit 0
|
||||
|
||||
HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
RESULT=$(printf '%s' "$PROMPT" | python3 "$HOOK_DIR/irreversible_dispatch_check.py" 2>/dev/null || echo '{"verdict":"OK","hits":[]}')
|
||||
|
||||
VERDICT=$(printf '%s' "$RESULT" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get("verdict", "OK"))
|
||||
except Exception:
|
||||
print("OK")
|
||||
' 2>/dev/null || echo "OK")
|
||||
|
||||
[ "$VERDICT" != "BLOCK" ] && exit 0
|
||||
|
||||
HITS=$(printf '%s' "$RESULT" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
for ln, line, word in d.get("hits", []):
|
||||
print(f" 第 {ln} 行(命中「{word}」):{line}")
|
||||
except Exception:
|
||||
pass
|
||||
' 2>/dev/null || echo "")
|
||||
|
||||
cat >&2 <<EOF
|
||||
🔴 不可逆動作警察:這張派工單把「刪東西」寫成收工方可以自己執行的選項,擋下。
|
||||
|
||||
【事故(Gitea Leo/arcrun-rag#33,2026-08-09)】subagent 未經 leo 同意刪掉兩條遠端分支,
|
||||
根因是派工單寫了「作廢就刪掉分支」——判斷可以,動手不行,這份單子卻兩個都給了。
|
||||
其中一條被刪的分支裡,還有一件它自己標明「等 leo 排序」的工作,一併蒸發。
|
||||
|
||||
【對照組,同一天同一個總管】#14 寫「🔴 刪資料不可逆。動手前先把清單寫在 issue 留言,
|
||||
等總管回覆確認才執行」⇒ 那個 agent 真的停下來等。同一個人一次寫對一次寫錯,
|
||||
證明這件事不能只靠自律,要靠這道閘。
|
||||
|
||||
【命中的不可逆動作】
|
||||
$HITS
|
||||
|
||||
【怎麼改】判斷可以,動手不行——把授權句改成「列清單、標明理由、停在這裡等回覆」:
|
||||
❌「作廢就刪掉分支」
|
||||
✅「盤點出作廢的分支,列成清單留言在 issue,動手前等總管回覆確認才執行」
|
||||
|
||||
真有必要略過(極少數情況):在命中那一行尾巴加 irreversible-ok 留痕,並在派工單裡寫清楚為什麼。
|
||||
改完重送即放行。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/bin/sh
|
||||
# issue-status-autoflip.sh — PostToolUse(Agent):派工出去的當下,把那張票自動改成 s/doing。
|
||||
#
|
||||
# 🔴 為什麼要自動(leo 2026-08-09):
|
||||
# 「你可以設置一個當你改變狀態時會自動改 status 標籤嗎?
|
||||
# 你從 pending 在執行,當你派出時就自動改成 doing 能做到嗎?」
|
||||
#
|
||||
# 2026-08-09 這一天總管手動改了二十幾次標籤。**漏一次,看板就在說謊**——
|
||||
# 而 leo 是靠那個看板判斷「還剩什麼」。用錯誤訊號佔用他的注意力,
|
||||
# 比沒有訊號更糟(他會以為沒人在做,或以為有人在做)。
|
||||
# ⇒ 這種「每次都要記得做」的事,就是該讓機器做的事。
|
||||
#
|
||||
# 做什麼:從派工單裡認出工單號(只認 `【工單】` 那一行附近的 owner/repo#N 格式),
|
||||
# 呼叫 Gitea API 把 s/doing 貼上去(scope label 互斥,舊狀態自動掉)。
|
||||
#
|
||||
# 刻意不做的:
|
||||
# - **不自動改回 pending/stage/closed**。那些要看「做出來的東西對不對」,
|
||||
# 是判斷,不是事件。自動化只能做「派出去了」這種客觀事實。
|
||||
# - 不擋任何東西。失敗就安靜略過——這是便利設施,不是安全閘,
|
||||
# 絕不能因為它掛掉而卡住派工。
|
||||
set -eu
|
||||
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
|
||||
# 只從【工單】那一行抓,避免把內文隨手提到的 #NN 當成本次工單
|
||||
SPEC=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import sys, json, re
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
p = (d.get("tool_input") or {}).get("prompt") or ""
|
||||
for line in p.splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
|
||||
if m:
|
||||
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
|
||||
break
|
||||
' 2>/dev/null || true)
|
||||
|
||||
[ -n "$SPEC" ] || exit 0
|
||||
set -- $SPEC
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
|
||||
# token 從該 repo 的 gitea remote 取;取不到就安靜結束(不影響派工)
|
||||
for d in "$CLAUDE_PROJECT_DIR/products/$REPO" "$CLAUDE_PROJECT_DIR/matrix/$REPO" "$CLAUDE_PROJECT_DIR"; do
|
||||
[ -d "$d/.git" ] || continue
|
||||
URL=$(git -C "$d" remote get-url gitea 2>/dev/null) || continue
|
||||
case "$URL" in *@*) break ;; esac
|
||||
done
|
||||
[ -n "${URL:-}" ] || exit 0
|
||||
|
||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
HOST=$(printf '%s' "$URL" | sed -E 's|.*@([^/]+)/.*|\1|')
|
||||
[ "$TOKEN" != "$URL" ] || exit 0
|
||||
|
||||
curl -s -o /dev/null --max-time 8 \
|
||||
-X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"labels":["s/doing"]}' \
|
||||
"https://$HOST/api/v1/repos/$OWNER/$REPO/issues/$NUM/labels" 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook — KBDB 是 API-as-Wall:零 SQL、永不加表(L3 硬攔截)
|
||||
#
|
||||
# 【leo 2026-08-07 立】原話:
|
||||
# 「確認執行記錄在 D1 是用 template 不是建表?」
|
||||
# 「**KBDB 本來就只能用 API,如果他用 SQL 就是錯,現在居然還能建表,如何鎖住?**」
|
||||
# 「它應該要被攔,**你也應該要被攔**,你要思考怎麼做不會再有錯誤指令。」
|
||||
#
|
||||
# 為什麼存在(2026-08-07 事故實錄,兩層都失守):
|
||||
# ① **總管派工的指令本身違規**——寫成「新增 D1 migration 建執行紀錄表」。
|
||||
# 病根:總管用**基礎設施的詞(D1)**思考,而鐵律掛在**領域的詞(KBDB)**上
|
||||
# ⇒ `Skill(kbdb)` 的觸發詞對不上 ⇒ 整條鐵律沒被載入。
|
||||
# 而且查了 Cloudflare 官方的 D1 額度(外部事實),**卻沒查自家那顆 D1 是誰**
|
||||
# ——三個 binding(CREDENTIALS_DB/ANALYTICS_DB/kbdb 的 DB)全指向同一顆 `arcrun-kbdb`。
|
||||
# ② **subagent 照著做,而且更進一步直接 `ANALYTICS_DB.prepare()` 下原生 SQL**。
|
||||
# 它沒建表(對),但繞過 API 牆讀寫 KBDB 的 D1,同樣違規。
|
||||
# ⇒ 靠「我記得有這條鐵律」會失守,因為**想不到要載入它的時候,它就不存在**。
|
||||
# 這個 hook 不看你記不記得,只看你寫了什麼。
|
||||
#
|
||||
# 攔三種違規:
|
||||
# A. 對 KBDB 下 DDL(CREATE/ALTER/DROP TABLE)——「永不加表」
|
||||
# B. 對綁在 arcrun-kbdb 的 D1 binding 下原生 SQL(.prepare/.exec/.batch)——「零 SQL」
|
||||
# C. 新增一個 database_name = arcrun-kbdb 的 [[d1_databases]] binding——「別再多開一道門」
|
||||
#
|
||||
# 豁免:該行尾加 `kbdb-sql-ok`(留痕,commit 說明理由)。
|
||||
# 既存違規(credentials.ts / auth-dispatcher.ts 等)不在本 hook 的射程內——
|
||||
# 它只擋**這次寫進去的內容**,不回頭掃全 repo。既存的收拾另案處理。
|
||||
#
|
||||
# 誠實限制:regex 偵測有偽陰/偽陽,擋得住「照常識寫」的違規,擋不住刻意改名混淆。
|
||||
# 價值是「讓正解成為阻力最小的路 + 留痕可審」,不是技術防偽。絕不聲稱不可能繞過。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# ── Bash 分支:擋「繞過檔案、直接用命令列對 KBDB 下 DDL/SQL」──────────
|
||||
# 2026-08-07 leo 第 1 條指令「封鎖所有寫表的路」——只擋 Write/Edit 是不夠的,
|
||||
# `wrangler d1 execute arcrun-kbdb --command "CREATE TABLE ..."` 一樣建得出表。
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
BASH_CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
|
||||
else
|
||||
BASH_CMD=""
|
||||
fi
|
||||
if [ -n "${BASH_CMD:-}" ]; then
|
||||
# 🔴 2026-08-07 修正:第一版做「整條命令字串比對」,結果**第四次誤擋總管**——
|
||||
# 誤擋的是 `git commit -m "...引用了那個命令字面..."`:那是在**描述**這件事,
|
||||
# 不是在執行它。同一天已被自己的閘誤擋三次(Task 分支),這是第四次。
|
||||
# ⇒ 只在**命令位置**才算數:字串出現在開頭,或緊接在 && ; | 換行之後。
|
||||
# 引號內、heredoc 內、commit message 內的同樣字面一律不算。
|
||||
# 判斷邏輯住在獨立檔(見該檔開頭:內嵌逃逸讓這道閘改一次壞一次)
|
||||
BASH_VERDICT=$(printf '%s' "$BASH_CMD" \
|
||||
| python3 "$(dirname "$0")/kbdb_cmd_check.py" 2>/dev/null || echo "OK")
|
||||
if [ "$BASH_VERDICT" = "BAD" ]; then
|
||||
cat >&2 <<'EOB'
|
||||
🧱 kbdb-api-wall-guard(Bash):偵測到繞過 API 牆、直接對那顆資料庫執行 SQL 的命令。
|
||||
|
||||
🔴 D38(leo 2026-08-07):**任何東西禁止用 SQL 語句存取資料,一律 API。**
|
||||
KBDB = API-as-Wall(鐵律全文在 cypher-executor/src/routes/kbdb-proxy.ts 檔頭,
|
||||
leo 2026-06-14 立)。命令列直打繞過牆,**這條路 2026-08-07 起封鎖**。
|
||||
|
||||
✅ 正確做法
|
||||
· 新資料類型 → seed 一列 template
|
||||
(範本:kbdb/migrations/0003_library_map.sql)
|
||||
🔴 0004_execution_log_template.sql 是**反例**,不要照抄——它是 D91 影子表本人
|
||||
· 讀寫資料 → 走 kbdb HTTP API(慣例見 cypher-executor/src/routes/kbdb-proxy.ts)
|
||||
🔴 **不加表不等於合規**:資料要寫成完整 record 展開,
|
||||
**不准打包進 metadata_json**(D91;seed 一列 template 只是半套)
|
||||
|
||||
真有例外:命令裡加註 `kbdb-sql-ok`(留痕),並在 commit 說明理由。
|
||||
EOB
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
TASK_PROMPT=$(printf '%s' "$INPUT" | jq -r '.tool_input.prompt // empty')
|
||||
else
|
||||
TASK_PROMPT=""
|
||||
fi
|
||||
if [ -n "${TASK_PROMPT:-}" ]; then
|
||||
# 🔴 2026-08-07 第三次修正:Task 分支從「硬擋」改成「注入提示」。
|
||||
#
|
||||
# 為什麼改(實錄):這條分支上線後**連續三次誤擋總管正確的派工 prompt**——
|
||||
# ① 「不准另開表」(禁止語境)② 「回到三張核心表」(驗法敘述)
|
||||
# ③ 引用 leo 的警告原話(在講「不要建」,卻被當成「在叫人建」)
|
||||
#
|
||||
# ⇒ 判準(總管當天得出,寫下來免得又忘):
|
||||
# **自然語言的閘容易兩頭失準,硬擋會擋住正確的工作。
|
||||
# 機械可判的是「產物」(SQL/DDL/binding),不是「指令的措辭」。**
|
||||
# ⇒ **指令層用提示(本分支,exit 0 注入 context),產物層用硬擋
|
||||
# (Write/Edit 分支與 Bash 分支,exit 2)。** 兩層合起來防線才完整。
|
||||
#
|
||||
# 提示仍有價值,因為**時機對**——它在派工送出的當下出現,
|
||||
# 而不是像 subagent-wiki-guard 那樣「指令已經寫完才附在工具結果裡」。
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -n --arg g "$(cat <<'EOG'
|
||||
🧱 你正在派工。送出前對照資料層的兩條鐵律(2026-08-07 總管親手違反過):
|
||||
|
||||
【D38(leo 2026-08-07)】**任何東西禁止用 SQL 語句存取資料,一律 API。**
|
||||
唯一例外是資料層 worker 自己(`kbdb/src/`、`kbdb/migrations/`)——它就是那面牆。
|
||||
KBDB 只有 entries / templates **兩張**核心表,**永遠不加新的**;
|
||||
新資料類型 = seed 一列 template。
|
||||
🪦 **`entry_values` 已於 2026-08-15 廢除**(migration `0007`,leo:「我希望它不見,
|
||||
這對我來說是恥辱柱」)。指標改用 entries 上的 `src_id`/`rel_id`/`dst_id` 欄位。
|
||||
還沒更新的實例身上可能仍有那張表——**看到它=那台還沒跟上,不是它還合法**。
|
||||
現成範本:`kbdb/migrations/0003_library_map.sql`
|
||||
🔴 `0004_execution_log_template.sql` 是**反例**,不要照抄——它是 D91 影子表本人
|
||||
🔴 **不加表不等於合規**:資料要寫成完整 record 展開,**不准打包進 metadata_json**
|
||||
(D91/D93;seed 一列 template 只是半套,而三道防線都只在數表,這個病從它們中間走過去)
|
||||
|
||||
【派工鐵律】**寫目的,不寫做法**(頂層 CLAUDE.md 規則三點五)
|
||||
❌ 直接指定實作(例如叫人去做某種 schema 異動)
|
||||
——2026-08-07 總管真的這樣寫過,一句話同時違反兩條鐵律,subagent 照做,兩層一起錯。
|
||||
✅ 寫「要達成什麼 + 怎麼驗 + 紅線」,把實作留給那個 repo 的人格自己查。
|
||||
理由:**指令越具體,收工方越不會質疑**——你不是那個 repo 的專家,
|
||||
別替它決定實作,那等於把自己的無知固化成命令。
|
||||
|
||||
【leo 原話】「先前測試 Haiku 就可以成功寫 Arcrun 工作流,
|
||||
**但你這個 Opus 卻下錯指令,問題在你這裏**。」
|
||||
——差別不在模型能力,在 haiku 每次被強制注入「先看 skill 清單」,而你沒有。
|
||||
|
||||
📌 產物層另有硬閘(本 hook 的 Write/Edit 與 Bash 分支)會擋 DDL、牆外原生 SQL、
|
||||
繞過 API 直打那顆資料庫的命令。這裡只提醒,不阻擋。
|
||||
EOG
|
||||
)" '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":$g}}'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
CONTENT=$(printf '%s' "$INPUT" | jq -r '[.tool_input.content, .tool_input.new_string] | map(select(. != null)) | join("\n")')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
CONTENT=$(printf '%s' "$INPUT")
|
||||
fi
|
||||
|
||||
[ -z "${FILE_PATH:-}" ] && exit 0
|
||||
[ -z "${CONTENT:-}" ] && exit 0
|
||||
|
||||
# 只管 arcrun / arcrun-rag 這兩個會碰到 KBDB 的地方(其他 repo 沒有 KBDB,別誤擋)
|
||||
case "$FILE_PATH" in
|
||||
*matrix/arcrun/*|*products/arcrun-rag/*|*kbdb*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 去掉帶豁免標記的行後再驗
|
||||
SCAN=$(printf '%s' "$CONTENT" | grep -v 'kbdb-sql-ok' || true)
|
||||
[ -z "${SCAN:-}" ] && exit 0
|
||||
|
||||
VIOLATION=""
|
||||
|
||||
# ── A. DDL:永不加表 ────────────────────────────────────────────────
|
||||
if printf '%s' "$SCAN" | grep -qiE '(CREATE|ALTER|DROP)[[:space:]]+TABLE'; then
|
||||
VIOLATION="${VIOLATION}
|
||||
── A. 偵測到 DDL(CREATE/ALTER/DROP TABLE)
|
||||
KBDB 只有兩張核心表:**entries / templates**,**永遠不加新 table**。
|
||||
(`entry_values` 已於 2026-08-15 廢除,migration `0007`;指標改走 entries 上的
|
||||
`src_id`/`rel_id`/`dst_id`。舊實例身上還看得到它=那台沒跟上,不是它還合法。)
|
||||
(權威來源:kbdb/migrations/0001_base.sql。**注意別把概念名當表名**——
|
||||
Block/Template/Slot 是「三層概念」;entries 是萬用主表,靠 entry_type
|
||||
區分 block/template/slot/project/workflow/recipe_stat…;
|
||||
2026-08-07 本 hook 初版把概念名寫成表名,會害人去找不存在的表。)
|
||||
所有資料結構都用 Block + Template + Slot 三層概念實現,落在那三張表上。
|
||||
✅ 正確做法:建一個 template,每筆資料是它的 record。
|
||||
去讀既有的 template(\`triplet\` 是現成範本),照它的形狀做,不要新增第二套。"
|
||||
fi
|
||||
|
||||
# ── B. D38 零 SQL:牆外任何地方都不准下原生 SQL ──────────────────────
|
||||
# 【leo 2026-08-07 立 D38】「**任何東西禁止用 SQL 語句存取資料,一律 API。**」
|
||||
# 範圍不只 KBDB——唯一的例外是「資料層 worker 自己」(kbdb 就是那面牆,牆內當然用 SQL)。
|
||||
#
|
||||
# 🔴 2026-08-07 補漏:舊版只比對 `CREDENTIALS_DB.prepare(`,**抓不到別名**——
|
||||
# 而 `auth-dispatcher.ts:66,70` 正是 `const db = env.CREDENTIALS_DB;` 然後 `db.prepare(...)`
|
||||
# ⇒ 舊規則對真實的違規現場完全無效。改成「牆外任何 .prepare/.exec/.batch 都擋」。
|
||||
IS_WALL_INTERNAL=0
|
||||
case "$FILE_PATH" in
|
||||
*matrix/arcrun/kbdb/src/*|*matrix/arcrun/kbdb/migrations/*) IS_WALL_INTERNAL=1 ;;
|
||||
esac
|
||||
if [ "$IS_WALL_INTERNAL" -eq 0 ] \
|
||||
&& printf '%s' "$SCAN" | grep -qE '\.[[:space:]]*(prepare|exec|batch)[[:space:]]*\('; then
|
||||
VIOLATION="${VIOLATION}
|
||||
── B. 偵測到原生 SQL(.prepare / .exec / .batch),而這個檔在「牆外」
|
||||
🔴 **D38(leo 2026-08-07):任何東西禁止用 SQL 語句存取資料,一律走 API。**
|
||||
唯一例外是資料層 worker 自己(\`kbdb/src/\`、\`kbdb/migrations/\`)——它就是那面牆。
|
||||
✅ 正確做法:走 HTTP API。
|
||||
先讀 \`cypher-executor/src/routes/kbdb-proxy.ts\` 看既有呼叫慣例
|
||||
(認證怎麼帶、owner_id 怎麼注入),照著走,不要新增第二套。
|
||||
📌 別名也算:\`const db = env.XXX_DB; db.prepare(...)\` 與直接寫一樣是違規
|
||||
(2026-08-07 舊版規則就是漏在這裡)。"
|
||||
fi
|
||||
|
||||
# ── C. 別再多開一道門:新增指向 arcrun-kbdb 的 D1 binding ────────────
|
||||
if printf '%s' "$SCAN" | grep -q 'd1_databases' && printf '%s' "$SCAN" | grep -q 'arcrun-kbdb'; then
|
||||
VIOLATION="${VIOLATION}
|
||||
── C. 偵測到新增一個指向 arcrun-kbdb 的 [[d1_databases]] binding
|
||||
🔴 有 D1 binding 就能下任意 SQL——**這正是 2026-08-07 那次能建表的原因**。
|
||||
現況已經有三個 binding 指向同一顆(CREDENTIALS_DB/ANALYTICS_DB/kbdb 的 DB),
|
||||
**不要再多開第四個**。
|
||||
✅ 正確做法:走 kbdb HTTP API;真的需要獨立儲存 → 開一顆**不叫 arcrun-kbdb** 的 D1,
|
||||
並在 pending-changes 提案說明為什麼(那是架構決策,要負責人裁)。"
|
||||
fi
|
||||
|
||||
[ -z "$VIOLATION" ] && exit 0
|
||||
|
||||
cat >&2 <<EOF
|
||||
🧱 kbdb-api-wall-guard:這次寫入違反 KBDB 的兩條鐵律。
|
||||
$VIOLATION
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
【鐵律全文】KBDB = API-as-Wall
|
||||
· **零 SQL**:一律走 kbdb 的 HTTP API,不准直接碰它的 D1
|
||||
· **永不加表**:只有 **entries / templates** 兩張;新結構用 template + record
|
||||
(`entry_values` 2026-08-15 廢除,見上)
|
||||
|
||||
【為什麼這條特別容易被繞過】(2026-08-07 實錄,總管與 subagent 兩層都失守)
|
||||
想事情時用的是**基礎設施的詞**(D1/資料表/migration/schema),
|
||||
而鐵律掛在**領域的詞**(KBDB)上 ⇒ 詞對不上,skill 就不會被載入。
|
||||
⇒ **碰到「D1」時的第一個動作,是先問「這顆 D1 是誰的」**,
|
||||
不是先想「要建什麼表」。三個 binding 全指向 arcrun-kbdb。
|
||||
|
||||
【真的有例外】該行尾加 \`kbdb-sql-ok\`(留痕),並在 commit 訊息說明理由。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# kbdb-asked-stamp.sh — PostToolUse:記下「這個 session 真的問過 KBDB 了」。
|
||||
#
|
||||
# 🔴 存在理由(leo 2026-08-08 當場點破兩件事):
|
||||
# ①「你自己寫了 hook,結果一次次改都沒產生用途,問題太大」
|
||||
# ②「KBDB 可以看到所有庫,成本最低,要求先查⋯⋯KBDB 的知識庫就是要建來取代你的記憶的」
|
||||
#
|
||||
# 原本的 history-first-guard 印了「補一次 kbdb_search 語意搜尋」,但那是**純文字建議**:
|
||||
# 它唯一驗的是「你有沒有把同一個編輯再送一次」,不驗有沒有真的去查
|
||||
# ⇒ 我 08-08 當天直接跳過,還要 leo 來提醒。
|
||||
#
|
||||
# 這支不擋任何東西,只留一枚時戳;判斷交給 history-first-guard 讀。
|
||||
# 對齊 leo 的母原則:**環境邊界取代邏輯判斷**——順序做進機制,不靠我每次記得。
|
||||
#
|
||||
# 成本效益序(leo 2026-08-08 定):
|
||||
# KBDB(一次看所有庫,最便宜)→ 該 repo 的 wiki/tasks → git log -S → 讀源碼(最貴,
|
||||
# 只看得到「現在長怎樣」,看不到「為什麼變成這樣」)
|
||||
set -eu
|
||||
date +%s > /tmp/.kbdb-asked
|
||||
exit 0
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# leo21c-write-guard.sh — 不准寫 leo 的個人帳號(leo 2026-08-20 立)
|
||||
#
|
||||
# leo 原話:
|
||||
# 「youlin = stage,geek6688 = 測試 prod & 出貨機,uncle6 = 中心服務,
|
||||
# 這三個都可以拿來當 default,但你要實驗當然是放在 youlin。」
|
||||
# 「**leo21c 就是我這個普通用戶,不應該讓你去操控,我只用公開的更新。**」
|
||||
#
|
||||
# 🔴 為什麼要機器守(2026-08-20 實錯,本閘的來由):
|
||||
# 總管派 subagent 驗碎形目錄索引,它用了本機 `~/.arcrun/config.yaml` 的預設
|
||||
# (`cypher_executor_url: …leo21c…`/`api_key: bfezv28v`),
|
||||
# 把 8 張測試卡寫進 leo 的真庫。
|
||||
# 而當時 `system-dev/wiki/agent-memory.md` §2 白紙黑字寫著
|
||||
# 「底層帳號=leo21c,**任何自動化只准落在它上面**」——那是 dogfood 時代的舊分工。
|
||||
# ⇒ **文件教錯 + 機器預設也錯 ⇒ 沒指定的一律流進他的帳號。**
|
||||
# ⇒ 規則改對了還不夠:`~/.arcrun/config.yaml` 至今仍指著 leo21c
|
||||
# (它的 KV id 與 encryption_key 是該實例專屬,換不過去),
|
||||
# 所以**真正擋得住的是這道閘**,不是那張表。
|
||||
#
|
||||
# 判準(封動作,不封措辭 —— 同 empty-handed-stop-guard 的哲學):
|
||||
# 命中 leo21c 的座標 + 這是一個寫入動作 ⇒ 擋
|
||||
# 只是讀(GET/查詢/grep 到那個字串) ⇒ 放行
|
||||
set -uo pipefail
|
||||
payload=$(cat)
|
||||
cmd=$(printf '%s' "$payload" | python3 -c "
|
||||
import json,sys
|
||||
try: print((json.load(sys.stdin).get('tool_input') or {}).get('command',''))
|
||||
except Exception: print('')
|
||||
" 2>/dev/null)
|
||||
[ -z "$cmd" ] && exit 0
|
||||
|
||||
# 🔴 這些工具碰不到 CF 帳號 ⇒ 整個放行(2026-08-20 上線當天就誤攔兩次,本段是修正)
|
||||
# ① `git commit -m "…leo21c… acr update…"` 同時命中座標與寫入動詞而被擋
|
||||
# ② 連「修這道閘本身」的指令都被擋(測試案例裡自然含觸發字)
|
||||
# 但 git 與文字編輯根本寫不到那台實例,**訊息裡寫到什麼都不會造成寫入**。
|
||||
# leo 2026-08-17:「紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。」
|
||||
# ⇒ 閘要問「這個指令能不能真的寫到那台」,不是「這段文字提到什麼」。
|
||||
case "$cmd" in
|
||||
git\ *|jj\ *|" git "*) exit 0 ;;
|
||||
esac
|
||||
if printf '%s' "$cmd" | grep -qE '^[[:space:]]*(git|jj)[[:space:]]'; then
|
||||
exit 0
|
||||
fi
|
||||
# 修這道閘自己:命中的是本檔路徑就放行(否則永遠改不動它)
|
||||
if printf '%s' "$cmd" | grep -q 'leo21c-write-guard'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# leo21c 的三個座標(帳號 id/namespace/worker 網域)
|
||||
if ! printf '%s' "$cmd" | grep -qE 'leo21c|51a01bfa2665bd7bc3fd080dc40cf3e1|bfezv28v'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 寫入動作的形狀
|
||||
if ! printf '%s' "$cmd" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d ["'"'"'{]|wrangler +(deploy|publish|kv|d1|secret)|acr +(update|deploy|push)|/trigger|ingest|kbdb_create|kbdb_update'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat >&2 <<'MSG'
|
||||
🚫 不准寫 leo 的個人帳號 leo21c(leo 2026-08-20 立)
|
||||
|
||||
leo 原話:「**leo21c 就是我這個普通用戶,不應該讓你去操控,我只用公開的更新。**」
|
||||
|
||||
youlin ← 🟢 你的 stage:做實驗、跑驗證,**沒指定就用這個**
|
||||
geek6688 ← 測試 prod + 出貨機
|
||||
uncle6 ← 中心服務(安裝器/文件站/bundle),出貨線的目的地
|
||||
leo21c ← 🔴 leo 本人在用的知識庫。**讀可以,寫不行。**
|
||||
|
||||
改法:把目標明寫成 youlin,不要吃 `~/.arcrun/config.yaml` 的預設——
|
||||
那個檔至今仍指著 leo21c(KV id 與 encryption_key 是該實例專屬,換不過去)。
|
||||
|
||||
cypher : https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev
|
||||
ns : yuga3bse
|
||||
CF : 1129efd7df2e8899d537e9c8fbabb6cb
|
||||
token : 頂層 .env 的 CLOUDFLARE_API_TOKEN_YOULIN_CC_USE
|
||||
|
||||
📌 2026-08-20 實錯:subagent 吃了那個預設,把 8 張測試卡寫進 leo 的真庫
|
||||
(library=demo-real-verify)。當時 wiki 還教「任何自動化只准落在 leo21c」。
|
||||
MSG
|
||||
exit 2
|
||||
Executable
+312
@@ -0,0 +1,312 @@
|
||||
#!/bin/sh
|
||||
# main-and-prod-push-guard.sh — PreToolUse(Bash):**兩層手動確認閘**
|
||||
#
|
||||
# 🔴 立這道閘的來由(leo 2026-08-10):
|
||||
# 「**subagent 要推 gitea main 應該要你手動確認,要推 prod 要我手動確認。**」
|
||||
#
|
||||
# 當天的事故(本閘的直接成因):
|
||||
# 總管派 subagent 去整理 `polaris/mira` 的散落分支,指令寫「驗過了 → 合併(這是預設)」,
|
||||
# 引的是 leo 08-05 那句「驗過就要 merge」。subagent 照做,**合併並直接 push 到 gitea main**。
|
||||
# ⇒ 而頂層 CLAUDE.md 規則二的四題公式**白紙黑字列著「push 到 main」是人閘觸發項**。
|
||||
# ⇒ **規則早就存在,但沒有任何機制驗證有沒有照做**——這是同款的第 N 次
|
||||
# (history-first/KBDB-first/派工-first/stage-first 都是這個形狀)。
|
||||
# ⇒ 閘漏的是總管:派工指令裡沒把那道閘標出來。**所以閘要長在機器上,不是長在我的記性上。**
|
||||
#
|
||||
# 兩條,各自對應一個人:
|
||||
# ① subagent(`CLAUDE_CODE_CHILD_SESSION=1`)**一律不准 push 到 main** → 交回總管
|
||||
# ② **prod 部署**(會讓封測者/用戶拿到東西)需要 leo 親手解保險 `.github-armed`
|
||||
# ——補 `stage-before-prod-guard.sh` 的破口:那支只比對
|
||||
# `arcrun-rag-bundles`/`github-arm`/`publish-github` 三個關鍵字,
|
||||
# **`wrangler deploy` 打 prod 它抓不到**(2026-08-10 實查)。
|
||||
#
|
||||
# 設計紀律(沿用 stage-before-prod-guard 用血換來的兩條):
|
||||
# • **fail-closed**:任何內部錯誤都不准變成靜默放行。exit 1 不擋,只有 exit 2 才擋。
|
||||
# • **先排除「談論/讀取」再比對關鍵字**:誤攔比漏攔更容易殺死一道閘
|
||||
# (被擋得莫名其妙,人就會想辦法繞過它)。
|
||||
set -eu
|
||||
|
||||
INPUT="$(cat)"
|
||||
CMD=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || printf '')
|
||||
|
||||
[ -z "$CMD" ] && exit 0
|
||||
|
||||
# ── 先放行明確不發佈的動作(讀取、查狀態、寫本地版控、演練)──────────────
|
||||
# 關鍵字出現在 commit 訊息、在 sed/grep 的參數裡,都不是「執行」。
|
||||
case "$CMD" in
|
||||
sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *) exit 0 ;;
|
||||
*"git commit"*|*"git add"*|*"git tag"*|*"git stash"*) exit 0 ;;
|
||||
*"git status"*|*"git log"*|*"git diff"*|*"git show"*|*"git branch"*) exit 0 ;;
|
||||
*" --dry-run"*|*"--dry-run "*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# ── ① push 到 main 要有「總管決定了」的戳記 ────────────────────────────
|
||||
#
|
||||
# 🔴 **原本的判斷方式是錯的,上線當天就被自己擋到才發現(2026-08-10)**:
|
||||
# 本來用 `CLAUDE_CODE_CHILD_SESSION=1` 當「這是 subagent」的標記——
|
||||
# **實測總管主 session 也是 1**(Claude Desktop,`CLAUDE_CODE_ENTRYPOINT=claude-desktop`)。
|
||||
# ⇒ 那個變數不是身分標記 ⇒ 這道閘**把該放行的人也鎖住了**,總管推不了 main。
|
||||
# ⚠️ `guard-cross-project.sh` 用同一個變數,**可能也一直在誤判**(另記,待查)。
|
||||
#
|
||||
# leo 2026-08-10 把權責講清楚了:
|
||||
# 「**subagent 的總管是你,是否可以推 gitea main 由你來決定,我管推到 prod。**」
|
||||
# ⇒ 這道閘要守的不是「誰在敲鍵盤」,是「**有沒有人做過那個決定**」。
|
||||
# ⇒ 沒有可靠的身分辨識時,改成**正向確認**:推 main 前要有一枚新鮮的戳記。
|
||||
# subagent 當然造得出那枚戳記——但它得刻意繞過一段明講「不要這樣做」的訊息。
|
||||
# **閘擋的是無心,不是惡意。** 而現在這個版本連無心都擋不了(它誰都擋)。
|
||||
STAMP="/tmp/.main-push-ok"
|
||||
|
||||
# 🔴 2026-08-11 這道閘被自己的戳記穿透了,修法寫在這裡:
|
||||
# 總管為了推「頂層 InkStoneCo」而 touch 了戳記,15 分鐘之內
|
||||
# **一條並行的 subagent 把 commit 推上了 `matrix/arcrun` 的 main**,本閘毫無反應。
|
||||
# ⇒ 舊版有兩個洞,缺一不可:
|
||||
# ① **不綁 repo**:替 A repo 開的門,B repo 也走得過
|
||||
# ② **不會用掉**:一次 touch 在 15 分鐘內可以放行無限多次推送
|
||||
# ⇒ 現在改成 **綁 repo + 單次用完即丟**,而且**只在真的要擋的那一刻才檢查**
|
||||
# (放在檔頭會被任何一條無關的 Bash 指令把戳記燒掉)。
|
||||
stamp_ok() {
|
||||
[ -f "$STAMP" ] || return 1
|
||||
NOW=$(date +%s 2>/dev/null || echo 0)
|
||||
MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
|
||||
case "$NOW$MT" in *[!0-9]*) return 1 ;; esac
|
||||
[ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1
|
||||
[ $((NOW - MT)) -lt 900 ] || return 1
|
||||
|
||||
# 綁 repo:戳記內容要對得上「現在人在哪個 repo」
|
||||
#
|
||||
# 🔴 2026-08-12 補洞:舊版寫成「內容非空才比對」⇒ **`touch` 造出來的空檔跳過整個綁定**,
|
||||
# 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。
|
||||
# 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。
|
||||
# ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。
|
||||
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
|
||||
WANT=$(head -1 "$STAMP" 2>/dev/null || printf '')
|
||||
[ -n "$WANT" ] || return 1
|
||||
[ -n "$HERE" ] || return 1
|
||||
[ "$WANT" = "$HERE" ] || return 1
|
||||
|
||||
rm -f "$STAMP" 2>/dev/null || true # 單次:用完即丟
|
||||
return 0
|
||||
}
|
||||
|
||||
if true; then
|
||||
case "$CMD" in
|
||||
*"git push"*)
|
||||
# 只擋打到 main/master 的;推自己的 feature 分支照常放行
|
||||
case "$CMD" in
|
||||
# 🔴 2026-08-12 拿掉 `push -u` / `push --set-upstream` 這兩個條件。
|
||||
# 它們本來是想抓「沒寫分支的 push」,但實際抓到的是
|
||||
# `git push -u gitea fix/xxx`——**subagent 發表自己分支的標準動作**
|
||||
# (第一次推當然要 -u)。⇒ 舊版等於「agent 永遠推不出自己的分支」,
|
||||
# 而 leo 2026-08-12 的設計是「主線禁止動,大家都走 PR」,推分支是那條路的第一步。
|
||||
# 08-12 當天四張 PR 全是繞成 `git push gitea a:a` 才推出去的。
|
||||
# `*main*`/`*master*` 兩條照舊——真正該擋的是目標分支,不是有沒有帶旗標。
|
||||
*main*|*master*)
|
||||
stamp_ok && exit 0
|
||||
|
||||
# ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)───────────
|
||||
#
|
||||
# leo 原話:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」
|
||||
#
|
||||
# 做得到的與做不到的,先講清楚:
|
||||
# ❌ **做不到「同步問總管」**——hook 跑在子 session 自己的行程裡,總管在另一個行程。
|
||||
# 要同步問只能 block 等一個檔案出現,那會把 subagent 掛死在那裡。
|
||||
# ✅ **做得到「當場擋 + 留下原始請求」**:總管在自己的迴圈裡讀這個目錄,
|
||||
# 看到的是 repo/分支/逐筆 commit 的**原始資料**,不是 subagent 的散文轉述。
|
||||
# ——這才是名字真正值錢的地方:**不是判斷你是誰,是留下是誰要求的**。
|
||||
#
|
||||
# 🔴 身分的方向刻意不改:**沒有名字不等於總管**(那是 fail-open——
|
||||
# 子 session 繼承環境變數,把名字拿掉就升格了)。放行的唯一憑證仍然只有
|
||||
# 那枚綁 repo、用完即丟的戳記。名字只是署名,不是權限。
|
||||
# 📌 名字只在 `claude -p` 起的子 session 上可靠(乾淨的環境邊界);
|
||||
# Agent tool 起的 subagent 與總管同一個行程、共用環境 ⇒ 那條路上名字塞不進也擦不掉。
|
||||
# 而改子 repo code 的正路本來就是 `claude -p`,所以夠用。
|
||||
_who="${CLAUDE_AGENT_NAME:-未署名}"
|
||||
_hookdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || _hookdir=""
|
||||
_reqdir="${_hookdir%/hooks}/pending-main-push"
|
||||
if [ -n "$_hookdir" ] && mkdir -p "$_reqdir" 2>/dev/null; then
|
||||
_root=$(git rev-parse --show-toplevel 2>/dev/null || printf 'unknown')
|
||||
# 檔名只用 ASCII(`未署名` 之類會被 tr 打成一排 dash,看不出是誰)
|
||||
_slugwho=$(printf '%s' "${CLAUDE_AGENT_NAME:-unnamed}" | tr -c 'A-Za-z0-9._-' '-')
|
||||
case "$_slugwho" in *[!-]*) : ;; *) _slugwho=unnamed ;; esac
|
||||
# ⚠️ 先 printf 再 tr:`basename` 會帶一個換行,直接餵 tr 會變成結尾多一根 dash
|
||||
_slugrepo=$(printf '%s' "$(basename "$_root")" | tr -c 'A-Za-z0-9._-' '-')
|
||||
_slug="${_slugwho}--${_slugrepo}"
|
||||
# ⚠️ 這幾行刻意用 `printf '%s\n' "整句"`,不要把內容寫進 printf 的格式字串裡。
|
||||
# 2026-08-12 實撞:格式字串裡同時有反引號與 %s 時,那幾行整行不見(而前後行都在),
|
||||
# ——**寫完當場肉眼檢查產出的檔案才發現**,hook 自己不會叫。內容一律當資料傳。
|
||||
_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')
|
||||
_when=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '?')
|
||||
_fence='```'
|
||||
{
|
||||
printf '%s\n\n' "# 推 main 的請求:$_who"
|
||||
printf '%s\n' "- repo:$_root"
|
||||
printf '%s\n' "- 分支:$_branch"
|
||||
printf '%s\n\n' "- 時間:$_when"
|
||||
printf '%s\n' "- 它想跑的指令:"
|
||||
printf '%s\n%s\n%s\n\n' "$_fence" "$CMD" "$_fence"
|
||||
printf '%s\n\n%s\n' "## 還沒推上去的 commit(原始資料,不是轉述)" "$_fence"
|
||||
git log --oneline '@{upstream}..HEAD' 2>/dev/null \
|
||||
|| git log --oneline -20 2>/dev/null \
|
||||
|| printf '(列不出來)\n'
|
||||
printf '%s\n\n%s\n\n%s\n' "$_fence" "## 改了哪些檔" "$_fence"
|
||||
git diff --stat '@{upstream}..HEAD' 2>/dev/null | tail -40 || printf '(列不出來)\n'
|
||||
printf '%s\n\n---\n%s\n' "$_fence" "總管裁完請刪掉這個檔——留著代表「還沒裁」。"
|
||||
} > "$_reqdir/$_slug.md" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
cat >&2 <<'MSG'
|
||||
🚫 推 main 要先有「總管決定了」的戳記(leo 2026-08-10 立)
|
||||
|
||||
leo 原話:
|
||||
「**subagent 要推 gitea main 應該要你手動確認,要推 prod 要我手動確認。**」
|
||||
「**subagent 的總管是你,是否可以推 gitea main 由你來決定,我管推到 prod。**」
|
||||
|
||||
━━━ 你是 subagent ━━━
|
||||
**不要造那枚戳記。** 把改動留在自己的分支上,推那條分支,交件裡寫清楚:
|
||||
· 分支名
|
||||
· 這幾筆各是什麼、**各自**驗過了沒有(逐筆,不要整包說「驗過了」)
|
||||
· 你建議合併還是先擱著,理由是什麼
|
||||
總管看過才會併進 main。**這是一句話的事,不要自己想辦法過閘。**
|
||||
|
||||
📮 **你的請求已經自動留下來了**(`.claude/pending-main-push/` 底下,用你的
|
||||
`CLAUDE_AGENT_NAME` 署名;沒有這個變數就署「未署名」)。裡面是 repo/分支/
|
||||
逐筆 commit 的原始資料 ⇒ **你不必在交件裡重抄一遍那些,總管會直接讀。**
|
||||
|
||||
━━━ 你是總管 ━━━
|
||||
逐筆看過那些 commit(`git log --oneline gitea/main..<branch>`、`git diff --stat`),
|
||||
確定它們該進 main,再:
|
||||
|
||||
git rev-parse --show-toplevel > /tmp/.main-push-ok && <你的 git push 指令>
|
||||
|
||||
戳記 **綁這個 repo、只能用一次、15 分鐘失效**——它代表「**這一次、這個 repo,我看過了**」。
|
||||
|
||||
🔴 **為什麼從 `touch` 改成寫入 repo 路徑(2026-08-11 被穿透過一次)**:
|
||||
總管為了推頂層而 `touch` 了戳記,**15 分鐘內一條並行的 subagent 把 commit 推上了另一個 repo 的 main**,
|
||||
本閘毫無反應。舊版既不綁 repo、也不會用掉 ⇒ 一次確認等於全域開門。
|
||||
**在多條 subagent 並行的情況下,「時間窗」本身就是漏洞。**
|
||||
|
||||
【為什麼不是形式主義】2026-08-10 真的發生過:
|
||||
總管派工寫「驗過了 → 合併(這是預設)」,subagent 照做並直接推上 gitea main。
|
||||
而頂層 CLAUDE.md 規則二的四題公式**白紙黑字列著「push 到 main」是人閘觸發項**。
|
||||
⇒ 內容是好的,但**該不該先問,和內容對不對,是兩件事**。
|
||||
⇒ 而且那次是**總管的指令漏了那道閘**——所以現在由機器守,不靠誰記得。
|
||||
|
||||
【真的該推 main 的例外】不存在。交回總管,一句話的事。
|
||||
MSG
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── ② prod 部署要 leo 親手解保險 ────────────────────────────────────────
|
||||
# 先排除 stage/staging 的同名動作(它們本來就該自由跑)
|
||||
#
|
||||
# 🔴 2026-08-13 leo 拍板:「**你可以標示整套測試環境,告訴閘『這些是 stage』,
|
||||
# 做這些事時白名單跳過**」。
|
||||
#
|
||||
# 為什麼要補(同款第三次):`prod-write-guard.sh:188-191` 在 2026-08-12 就補過
|
||||
# 一模一樣的例外,理由白紙黑字寫在那裡——
|
||||
# 「`youlin`(youlin-hsieh-dev)**就是測試場**,不是 prod⋯⋯
|
||||
# 本閘原本只認 `staging` 這個字 ⇒ 打 youlin 做實驗被誤擋」
|
||||
# **但同一批補丁漏了這一支。** ⇒ 修法只改其中一份、沒有機制保證另一份跟上
|
||||
# (同日另兩例:`kbdb-api-wall-guard.sh` 本機版與 plugin 版不同步,擋了九次)。
|
||||
#
|
||||
# 🔴 判準不變,只是把「測試環境」講完整:**放行的是目標,不是動作。**
|
||||
# 命令看不出打哪裡 ⇒ 不放行。想被放行就把目標寫進命令裡,
|
||||
# **不要用假的 `# staging` 註解騙閘**(2026-08-13 一條 subagent 明確拒絕這樣做,是對的)。
|
||||
#
|
||||
# 整套測試環境=下列任一出現在命令裡:
|
||||
# · `youlin-hsieh-dev` ── 帳號名(D37 定的 stage 帳號)
|
||||
# · `1129efd7df2e8899d537e9c8fbabb6cb` ── 該帳號 ID(用 env 指過去時只有它看得見)
|
||||
# · `_YOULIN_` ── 憑證變數名(如 `CLOUDFLARE_API_TOKEN_YOULIN_CC_USE`)
|
||||
# · `X-Arcrun-API-Key: youlin` ── 打該實例 API 時的身分標頭
|
||||
case "$CMD" in
|
||||
*staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*) exit 0 ;;
|
||||
*youlin-hsieh-dev*|*1129efd7df2e8899d537e9c8fbabb6cb*|*_YOULIN_*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 🔴 2026-08-12 leo 拍板「加閘」:**geek6688(出貨機)是明文授權「總管可以直接動」的那一台。**
|
||||
#
|
||||
# 依據——`system-dev/wiki/credentials-map.md` 早就寫死:
|
||||
# `CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE`|geek6688 帳號 CF token(**leo 2026-08-12 開**)
|
||||
# ——**總管唯一可以直接動的實例**|用途:**把那台拉到最新/推出貨工作流/
|
||||
# `ARCRUN_SHIP_BASE` 指過去後的第 17~19 站**
|
||||
# ⇒ 規則早就存在,只是這道閘不知道。本段就是把它教給機器。
|
||||
#
|
||||
# 為什麼該放行:geek6688 的職務就是「**不受出貨控制**」——出貨機與被出貨的是同一套程式,
|
||||
# 要出的貨若本身是引擎更新會形成死結,所以要有一台先更新的機器。
|
||||
# 每出一次貨都要 leo 親手 arm 它一次 = 把他變成出貨流程裡的按鈕,
|
||||
# **而那正是這台實例存在的意義要拔掉的東西**。
|
||||
#
|
||||
# 🔴 **範圍刻意開到最窄:只認「命令裡明確指名 geek6688 這台」。**
|
||||
# 放行的是**目標**,不是動作——打 leo21c/uncle6/任何其他實例一律照舊要 arm。
|
||||
# 命令看不出打哪裡 ⇒ 不放行(與上方 stage 同一個原則:那正是該讓它看得出來的理由)。
|
||||
case "$CMD" in
|
||||
*geek6688*|*CC_SHIPPING_CORE*|*ACCOUNT_ID_GEEK6688*) exit 0 ;;
|
||||
esac
|
||||
|
||||
case "$CMD" in
|
||||
*"wrangler deploy"*|*"wrangler publish"*|*"wrangler versions deploy"*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
ARMED="$PROJ/.github-armed"
|
||||
|
||||
# ── 🔐 leo 解保險的第二條路:Gitea 票上回一句話(2026-08-13,示範接這一道閘)──
|
||||
#
|
||||
# leo 2026-08-13:「如果你會被通知,就不需要通過 terminal 來 arm 了」。
|
||||
# 身分分離做完後(`Leo/InkStoneCo#32`),票上作者是 `Leo` 的留言機器偽造不出來,
|
||||
# 可以當人閘證據——於是 arm 可以搬到手機。
|
||||
# 🪦 2026-08-16 起不再是單一頻道票(原 issues/34)——票號由發請求的當下指定,
|
||||
# 解哪個任務的保險就貼在那個任務自己的票上(issues/34#issuecomment-2804)。
|
||||
#
|
||||
# 這條路完全獨立於上面的 `$ARMED`(`.github-armed`,終端機那條路照舊在,兩者並存)。
|
||||
# 只有本機**已經有人跑過 `scripts/gitea-arm-request.sh`**(本地留下待核請求)時才會
|
||||
# 去打一次 Gitea——不是每次 wrangler deploy 都主動打;沒有待核請求就跳過,
|
||||
# 不浪費一次 API 呼叫,也不構成輪詢(只在「機器需要解閘的當下」查一次)。
|
||||
if [ ! -f "$ARMED" ] && [ -d "$PROJ/.claude/gitea-arm/pending" ] \
|
||||
&& [ -n "$(ls -A "$PROJ/.claude/gitea-arm/pending" 2>/dev/null)" ]; then
|
||||
# 🔴 `--consume`:這一行放行的就是 prod 部署本身(下面直接 `exit 0`)
|
||||
# ⇒ 核准必須當場用掉。2026-08-16 起 check 預設唯讀,不帶這個旗標=同一組碼
|
||||
# 可以無限次放行 prod,比原本的「探測會誤消耗」嚴重得多。
|
||||
if "$PROJ/scripts/gitea-arm-check.sh" --consume >/tmp/.gitea-arm-check-last.log 2>&1; then
|
||||
exit 0 # leo 已在 Gitea 回覆過有效代碼,且核對通過(單次用完,見該腳本)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$ARMED" ]; then
|
||||
cat >&2 <<'MSG'
|
||||
🚫 prod 部署要 leo 親手確認(leo 2026-08-10:「要推 prod 要我手動確認」)
|
||||
|
||||
偵測到 `wrangler deploy`,而且命令裡沒有任何 stage/staging 字樣 ⇒ 視同**打 prod**。
|
||||
**推 prod 就發佈了**——封測者/用戶當場拿到。
|
||||
|
||||
【怎麼過這道閘】把指令交給 leo 自己跑,並且照 `/issue-handle` 的規矩帶三樣:
|
||||
· 他要打開什麼(確切網址/確切指令,**你要先 `--dry-run` 打過**)
|
||||
· 他該看到什麼(正確的樣子長怎樣,例如版本字串會變成什麼)
|
||||
· 什麼情況算失敗(看到什麼就回你一聲)
|
||||
|
||||
【這道閘補的破口】`stage-before-prod-guard.sh` 只比對
|
||||
`arcrun-rag-bundles`/`github-arm`/`publish-github` 三個關鍵字,
|
||||
**抓不到 `wrangler deploy`**(2026-08-10 實查)。它擋的是 bundle 出貨鏈,不是所有發佈。
|
||||
|
||||
【真的是 stage?】命令裡明確帶 `--env stage`/`staging` 就會自動放行。
|
||||
若你確實在打 stage 卻被擋,代表**命令本身看不出打哪裡**——那正是該讓它看得出來的理由。
|
||||
|
||||
【2026-08-13 新增:leo 現在也能用手機解,不必開電腦】
|
||||
1. 跑 `scripts/gitea-arm-request.sh <這件任務對應的票號> "任務描述"` 生一組代碼,
|
||||
貼在**這件任務自己那張票上**(不是固定某張頻道票——2026-08-16 起票號由你指定)
|
||||
2. 把印出來的那句轉告 leo(Telegram/人話一句+一個代碼)
|
||||
3. leo 在 Gitea 上(App 或網頁都行)回那則貼上代碼
|
||||
4. 重跑一次你剛剛被擋的指令——這道閘會自動去核對,核對過就放行一次
|
||||
MSG
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/bin/sh
|
||||
# micromanage-guard.sh — PreToolUse(Agent):**擋微管理的派工單**。
|
||||
#
|
||||
# 🔴 為什麼要有這道(leo 2026-08-08 點破,且他指出「很早就有規矩」):
|
||||
# 「不可以微管理,因為**你管的也是 CC**,要把它當作**有能力處理整件工作的 CC**。
|
||||
# 而且有設計一個警察抓微管理,如果沒有,你要恢復它。」
|
||||
#
|
||||
# 查證:`kbdb-api-wall-guard.sh` 確實會在派工時**印出**「派工鐵律:寫目的,不寫做法」,
|
||||
# 但它**只是印字,不檢查我的 prompt**——跟今天抓到的其他三個同款:
|
||||
# 規則被讀到了,卻沒有機制驗證我照做。hook 目錄裡沒有任何一支真的在擋微管理。
|
||||
#
|
||||
# 而我 08-08 當天的派工單就是實例:五項編號驗收、四個具名的坑、指定 shell 命令、
|
||||
# 還要求「每完成一項就回報一次」——那是把 CC 當一次性工具用,不是當同事。
|
||||
#
|
||||
# 判準(CLAUDE.md 派工鐵律):派工只准寫「**要達成什麼 + 怎麼驗 + 紅線**」。
|
||||
# ✅ 目的:「讓拿到檔的人光看那份檔就能判斷這台機器還有沒有在動」
|
||||
# ❌ 做法:「在 local 段補 last_sync 欄位,改 buildDiagnosticsPayload()」
|
||||
# 實證(08-08):同一件事我**刻意只寫目的**,subagent 交出整個 engine 區塊,
|
||||
# 比我心裡想的「加一個時間戳」好——**微管理會把對方的判斷力關掉。**
|
||||
#
|
||||
# 這道閘不是要我寫得少,是要我**寫在對的層次**。紅線與驗法本來就該具體;
|
||||
# 「你先做 A 再做 B、用哪個指令、改哪個函式」才是要擋的。
|
||||
set -eu
|
||||
|
||||
INPUT="$(cat)"
|
||||
PROMPT=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get("tool_input", {}).get("prompt", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' 2>/dev/null || echo "")
|
||||
[ -z "$PROMPT" ] && exit 0
|
||||
|
||||
TMP=$(mktemp); printf '%s' "$PROMPT" > "$TMP"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
|
||||
lines=$(wc -l < "$TMP" | tr -d ' ')
|
||||
|
||||
# 微管理訊號:每一條都是「替對方決定怎麼做」的形狀
|
||||
sig=0
|
||||
# ① 直接給 shell 指令要它照跑
|
||||
n1=$(grep -cE '`(git|go|npm|npx|hdiutil|wrangler|curl|unzip|shasum|strings) ' "$TMP" || true)
|
||||
[ "$n1" -ge 2 ] && sig=$((sig+1))
|
||||
# ② 指名要改哪個檔/哪個函式(在派工單裡=替它決定實作位置)
|
||||
n2=$(grep -cE '`[A-Za-z0-9_/.-]+\.(go|ts|tsx|js|mjs|sh|json|toml)`|\(\)`|函式|函數' "$TMP" || true)
|
||||
[ "$n2" -ge 4 ] && sig=$((sig+1))
|
||||
# ③ 編號的操作步驟(「1. 先… 2. 再…」=流程由我編好)
|
||||
n3=$(grep -cE '^ *[0-9]+\. *(先|再|然後|接著|把|改|加|跑|執行|部署)' "$TMP" || true)
|
||||
[ "$n3" -ge 2 ] && sig=$((sig+1))
|
||||
# ④ 過程控制(要它按我的節奏回報,而不是交成果)
|
||||
n4=$(grep -cE '每完成一項|每一步都回報|做完一步|逐步回報|先做.*再回報' "$TMP" || true)
|
||||
[ "$n4" -ge 1 ] && sig=$((sig+1))
|
||||
# ⑤ 派工單過長——超過 70 行多半是把我腦裡的做法整套倒出去
|
||||
[ "$lines" -ge 70 ] && sig=$((sig+1))
|
||||
|
||||
[ "$sig" -lt 2 ] && exit 0 # 訊號少於 2 個=多半是正常的目的+驗法+紅線
|
||||
|
||||
STAMP="/tmp/.micromanage-warned-$(printf '%s' "$PROMPT" | shasum | cut -c1-12)"
|
||||
[ -f "$STAMP" ] && exit 0 # 同一張單只擋一次,改完重送即放行
|
||||
date +%s > "$STAMP"
|
||||
|
||||
cat >&2 <<EOF
|
||||
🎛️ 微管理警察:這張派工單有 $sig 個「替對方決定做法」的訊號(共 $lines 行)。
|
||||
|
||||
【leo 2026-08-08】「**不可以微管理,因為你管的也是 CC**,
|
||||
要把它當作**有能力處理整件工作的 CC**。」
|
||||
|
||||
【判準】派工只准寫三樣:**要達成什麼 / 怎麼驗 / 紅線**。
|
||||
✅ 目的:「讓拿到檔的人光看那份檔,就能判斷這台機器還有沒有在動」
|
||||
❌ 做法:「在 local 段補 last_sync 欄位,改 buildDiagnosticsPayload()」
|
||||
|
||||
【今天的實證,別忘了】同一件事我**刻意只寫目的**,它交回整個 engine 區塊
|
||||
(alive/crash_looping/headline/秒數),**比我心裡想的「加一個時間戳」好**。
|
||||
⇒ **微管理會把對方的判斷力關掉**,而你不是那個 repo 的專家,它才是。
|
||||
|
||||
偵測到的訊號:
|
||||
指令要它照跑=$n1 指名檔案/函式=$n2 編號步驟=$n3 過程控制=$n4 行數=$lines
|
||||
|
||||
改法(不是刪短,是**換層次**):
|
||||
· 把「怎麼做」整段刪掉——它會自己查 wiki、查 skill、讀 code
|
||||
· 保留並寫清楚:**這件事成功長什麼樣**、**怎麼驗**、**不准碰什麼**
|
||||
· 已知的坑可以留,但寫成「這裡有前科」而不是「你要下這個指令」
|
||||
· 不要規定它的回報節奏——要成果,不要進度條
|
||||
|
||||
改完重送即放行(同一張單只擋一次)。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/bin/sh
|
||||
# mistake-needs-ticket-guard.sh — PreToolUse(Write|Edit|MultiEdit):
|
||||
# **往 mistakes.md 寫一條新教訓,如果機制擋得住,就必須附票號。**
|
||||
#
|
||||
# 🔴 立這條的原話(leo 2026-08-16,一口氣講了三次,因為前兩次我又拿去寫成文件):
|
||||
# 「以後你自己寫入 mistake 的東西,**如果是機制可以防止的都要貼上票號**。」
|
||||
# 「要不然你的 mistake 根本**不會協助你迭代**。」
|
||||
# 「**沒迭代寫個 mistake 做什麼?下一次又犯同樣錯誤,你就說上次寫過。**」
|
||||
#
|
||||
# 【當天的活教材,就是這條規則本身要治的病】
|
||||
# `mistakes.md:220` 那條(08-14)結尾自己寫著:
|
||||
# 「📌 **待修**:`check` 應該要有唯讀模式,否則這個坑對每一個第一次用它的人都會再發生一次。」
|
||||
# 它**沒有票號** ⇒ 沒有任何人/任何看板會撈到它 ⇒ 躺了兩天 ⇒
|
||||
# 08-16 總管**原封不動照撞一次**,還在回覆裡說「這個我兩天前寫過」。
|
||||
# ⇒ 那句「我寫過」正是 leo 預言的那句話。**寫下來不是迭代,開票才是。**
|
||||
#
|
||||
# 【判準:什麼叫「機制可以防止」】
|
||||
# 不是所有教訓都要票。要票的是**下一次可以被機器擋下來**的那種:
|
||||
# · 用錯工具/用錯順序/漏做一步 → 可以做成 hook 或 preflight
|
||||
# · 兩份真相漂移/欄位漏填 → 可以做成不變式
|
||||
# · 「我以為 X 其實 Y」的事實誤解 → 通常**不需要**票(那是知識,不是閘)
|
||||
# 本閘不猜,用一個便宜的近似:**新增的段落裡出現「待修/應該要/下次要/
|
||||
# 要改成/可以做成」這類「有人得動手」的字樣,就要求同段落有票號。**
|
||||
#
|
||||
# 【放行的寫法】同一段裡出現任一種票號定址即可:
|
||||
# inkstone/arcrun-rag#88 / Leo/Arcrun#136 / 本 repo 的 #142
|
||||
# 真的不需要票(純知識型教訓):該段加 `no-ticket-needed` 一詞(留痕,說明理由)。
|
||||
set -eu
|
||||
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
|
||||
RESULT=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import sys, json, re
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
|
||||
ti = d.get("tool_input") or {}
|
||||
path = ti.get("file_path") or ""
|
||||
if not path.endswith("mistakes.md"):
|
||||
sys.exit(0)
|
||||
|
||||
# 收集這次「新寫進去」的文字(Write 全文;Edit 只看 new_string)
|
||||
chunks = []
|
||||
if "content" in ti:
|
||||
chunks.append(ti["content"])
|
||||
if "new_string" in ti:
|
||||
chunks.append(ti["new_string"])
|
||||
for e in (ti.get("edits") or []):
|
||||
if isinstance(e, dict) and "new_string" in e:
|
||||
chunks.append(e["new_string"])
|
||||
|
||||
text = "\n".join(c for c in chunks if isinstance(c, str))
|
||||
if not text.strip():
|
||||
sys.exit(0)
|
||||
|
||||
# 「有人得動手」的訊號——出現這些就代表它是可被機制防止的
|
||||
ACTION = re.compile(r"待修|應該要|下次要|要改成|可以做成|該做成|還沒做|尚未修|TODO|待辦")
|
||||
# 票號定址:owner/repo#N 或裸 #N
|
||||
TICKET = re.compile(r"(?:[A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?#\d+")
|
||||
|
||||
# 逐「段」判斷(空行分段),只報第一個有問題的段落
|
||||
for para in re.split(r"\n\s*\n", text):
|
||||
if not ACTION.search(para):
|
||||
continue
|
||||
if "no-ticket-needed" in para:
|
||||
continue
|
||||
if TICKET.search(para):
|
||||
continue
|
||||
hit = ACTION.search(para).group(0)
|
||||
snippet = " ".join(para.split())[:120]
|
||||
print(json.dumps({"hit": hit, "snippet": snippet}, ensure_ascii=False))
|
||||
break
|
||||
' 2>/dev/null || true)
|
||||
|
||||
[ -n "$RESULT" ] || exit 0
|
||||
|
||||
HIT=$(printf '%s' "$RESULT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["hit"])' 2>/dev/null || echo "待修")
|
||||
SNIP=$(printf '%s' "$RESULT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["snippet"])' 2>/dev/null || echo "")
|
||||
|
||||
cat >&2 <<EOF
|
||||
🎫 這條教訓是「機制擋得住」的,但沒有票號——**沒有票,它不會被迭代**
|
||||
|
||||
觸發字樣:「$HIT」
|
||||
那一段: $SNIP
|
||||
|
||||
【leo 2026-08-16,一口氣講了三次】
|
||||
「以後你自己寫入 mistake 的東西,**如果是機制可以防止的都要貼上票號**。」
|
||||
「要不然你的 mistake 根本**不會協助你迭代**。」
|
||||
「**沒迭代寫個 mistake 做什麼?下一次又犯同樣錯誤,你就說上次寫過。**」
|
||||
|
||||
【當天的活教材(就是這條規則的來由)】
|
||||
\`mistakes.md\` 那條 08-14 的結尾自己寫著「📌 待修:check 應該要有唯讀模式」,
|
||||
**沒有票號** ⇒ 沒有看板撈得到 ⇒ 躺了兩天 ⇒ 08-16 總管原封不動照撞一次,
|
||||
然後在回覆裡說「這個我兩天前寫過」。**那句話正是 leo 預言的那一句。**
|
||||
|
||||
【現在怎麼做(擇一)】
|
||||
① **先開票,再寫教訓**(正解,順序不能反):
|
||||
scripts/ticket where <關鍵字> # 先查這件事該掛哪張既有票
|
||||
scripts/ticket say <owner/repo#N> -F <檔> # 有既有票就貼進去
|
||||
scripts/ticket new <repo> -F <檔> # 真的沒有才開新票
|
||||
然後把票號寫進這一段:\`inkstone/arcrun-rag#88\`
|
||||
② **這條純粹是知識型教訓、沒有東西要動手**(例如「我以為 X 其實 Y」):
|
||||
該段加一詞 \`no-ticket-needed\`,並在旁邊寫一句為什麼不需要閘
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/bin/sh
|
||||
# no-ticket-no-dispatch.sh — PreToolUse(Agent|Task):**無票不發包。**
|
||||
#
|
||||
# 🔴 為什麼是硬擋而不是提醒(leo 2026-08-16):
|
||||
# 「**提醒可以忽略,deny 不能**——被 deny 的動作沒有發生,不存在「忽略」這個選項。
|
||||
# 所以問題化簡為:找出『起任務』在物理上必經的瓶頸,把閘設在那裡。」
|
||||
# 「因為我發現**你不會去更新票**,所以定義 tags 完全是裝飾⋯⋯
|
||||
# 唯一辦法是,你不准寫 code,要 subagent 寫**一定要有票號**,它自己去拿任務,
|
||||
# 回覆在票裡⋯⋯這樣你就會去更新狀態,否則永遠會『我忘了』,
|
||||
# **我就無法知道你做到哪裡**。」
|
||||
#
|
||||
# 瓶頸就是這個工具呼叫:總管要讓 subagent 動起來,唯一的機制是它,繞不過去。
|
||||
# ⇒ 「先開票」不是 SOP 第一條,是**發包動作的前置條件**,
|
||||
# 跟「不插鑰匙車不會發動」同一性質。
|
||||
#
|
||||
# 🔴 同日實錯(本閘的來由):總管說了三次「下一步要查 X」都沒有派人;
|
||||
# 真的派人時又**沒搜就開新票**(arcrun-rag#110),而那條線早有 hub(InkStoneCo#44)。
|
||||
# ⇒ 兩個病:說了不派、派了亂開票。前者這道閘擋不到,後者由 `scripts/ticket` 擋。
|
||||
#
|
||||
# 認什麼:派工單裡要有一行 `【工單】owner/repo#N`,可再帶 comment 定址
|
||||
# (`#issuecomment-2749` 或 `→ comment 2749`)。
|
||||
# leo:「你可以更細的寫票號+對話的號,因為它不一定拿整張票,
|
||||
# 可能你把指令寫在某個對話裡。」
|
||||
#
|
||||
# 刻意的取捨(fail-closed vs fail-open 的界線畫在哪):
|
||||
# · **票號缺席 → 硬擋。** 這是純本地檢查,永遠可靠,沒有藉口。
|
||||
# · **票號在、但 Gitea 連不上 → 放行並警告。** 網路抖動不該讓工作停擺;
|
||||
# 而且「他寫了票號」這件事本身已經是可追溯的痕跡。
|
||||
# · **票號在、Gitea 說那張票不存在或已關 → 硬擋。** 那是指向空氣。
|
||||
set -eu
|
||||
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
|
||||
REF=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import sys, json, re
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
p = (d.get("tool_input") or {}).get("prompt") or ""
|
||||
for line in p.splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
|
||||
if m:
|
||||
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
|
||||
break
|
||||
' 2>/dev/null || true)
|
||||
|
||||
if [ -z "$REF" ]; then
|
||||
cat >&2 <<'EOF'
|
||||
🚫 無票不發包(leo 2026-08-16 立)
|
||||
|
||||
leo 原話:「**你不會去更新票,所以定義 tags 完全是裝飾**⋯⋯
|
||||
要 subagent 寫一定要有票號,它自己去拿任務,回覆在票裡,
|
||||
這樣你就會去更新狀態,否則永遠會『我忘了』,**我就無法知道你做到哪裡**。」
|
||||
|
||||
派工單裡缺一行工單號。加上這一行(放最前面):
|
||||
|
||||
【工單】inkstone/<repo>#<N>
|
||||
【工單】inkstone/InkStoneCo#44 → comment 2749 ← 指令寫在某則對話裡時這樣寫
|
||||
|
||||
── 還沒有票?照這個順序,不要直接開新的 ────────────────────
|
||||
1. 先搜「這件事該放哪」 scripts/ticket where <關鍵字...>
|
||||
2. 命中同一條線 scripts/ticket say <owner/repo#N> -F <內文檔> ← 預設
|
||||
3. 真的是新的一條線 scripts/ticket new <repo> -F <內文檔> --title <標題>
|
||||
|
||||
🔴 **預設是貼進既有的票,不是開新的。**
|
||||
leo:「不要每個開新票,現有的票開在它下面的對話裡」
|
||||
「我希望你把 gitea 變成**可以追蹤**,不是變成一個池子」
|
||||
|
||||
為什麼連「只是查個東西」也要票:研究任務沒有票就是逃逸艙口。
|
||||
票上宣告 `deliverable 類型: research` 即可,交付物是貼在票上的結論,不是 PR。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
set -- $REF
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
|
||||
# 拿 token(拿不到就放行——這是本地環境問題,不是派工者的錯)
|
||||
URL=$(git -C "${CLAUDE_PROJECT_DIR:-.}" remote get-url gitea 2>/dev/null || true)
|
||||
case "${URL:-}" in *@*) ;; *) exit 0 ;; esac
|
||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
HOST=$(printf '%s' "$URL" | sed -E 's|.*@([^/]+)/.*|\1|')
|
||||
|
||||
RESP=$(curl -s --max-time 15 -H "Authorization: token $TOKEN" \
|
||||
"https://$HOST/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null || true)
|
||||
[ -n "$RESP" ] || exit 0 # 連不上 → 放行(票號在,已有痕跡)
|
||||
|
||||
STATE=$(printf '%s' "$RESP" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("unknown"); sys.exit(0)
|
||||
print(d.get("state") or ("missing" if d.get("message") else "unknown"))
|
||||
' 2>/dev/null || echo unknown)
|
||||
|
||||
case "$STATE" in
|
||||
open|unknown) exit 0 ;;
|
||||
closed)
|
||||
cat >&2 <<EOF
|
||||
🚫 工單 $OWNER/$REPO#$NUM **已經關閉**,不能拿它當本次派工的依據。
|
||||
|
||||
關掉的票代表那件事已經有交付物、已經結案。拿它發包=那條線接到一個死結。
|
||||
|
||||
· 這是同一條線的後續 → 貼進**還開著**的那張票,或重開這張:
|
||||
scripts/ticket where <關鍵字...>
|
||||
· 這確實是新的一條線 → scripts/ticket new <repo> -F <檔> --title <標題>
|
||||
EOF
|
||||
exit 2 ;;
|
||||
missing)
|
||||
cat >&2 <<EOF
|
||||
🚫 工單 $OWNER/$REPO#$NUM **不存在**(Gitea 查不到)。
|
||||
|
||||
指向空氣的票號比沒有票號更糟——它看起來像有追溯,實際上斷在第一步。
|
||||
|
||||
先確認 owner/repo 對不對(org 是 \`inkstone\`,不是 \`Leo\`;\`gh\` 打不到 Gitea),
|
||||
或用 scripts/ticket where <關鍵字...> 找出真正該掛的那張。
|
||||
EOF
|
||||
exit 2 ;;
|
||||
esac
|
||||
exit 0
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/sh
|
||||
# not-my-branch-guard.sh — PreToolUse(Bash):**不准 commit 到別人正在施工的分支。**
|
||||
#
|
||||
# 🔴 為什麼是硬擋(2026-08-16,總管一小時內犯兩次):
|
||||
# 第一次:把 branch-hold 的落帳 commit 打在 `feat/gitea-arm-per-issue` 上(agent 的分支)
|
||||
# 第二次:把 `ticket decide` 的 commit 打在**同一條**分支上
|
||||
# ——而且第一次之後,總管在回覆裡寫了「這正是共用工作區那條教訓咬到我自己」。
|
||||
# **寫下教訓沒有讓他不再犯。** 一小時後同一個錯又來一次。
|
||||
#
|
||||
# 為什麼會犯:總管的 shell 與 agent 共用同一個工作區,**分支是誰上一次切的就是誰的**。
|
||||
# `git commit` 不會問「你確定要打在這條上嗎」,它只是照做。
|
||||
# 而 `git push gitea main` 在那種狀態下會回 **`Everything up-to-date`**
|
||||
# ——**假成功**,因為本機 `main` 確實沒動過。真訊號是「本機 HEAD 與遠端對不上」。
|
||||
#
|
||||
# 判準:**當前分支出現在 `.claude/branch-holds.md` 裡** ⇒ 那是有人(agent)正在上面施工的
|
||||
# 分支,或刻意保留的分支。兩種都不該由總管順手 commit 進去。
|
||||
#
|
||||
# 刻意不擋的:
|
||||
# · 在 `main` 上 commit(那是總管的地盤,另有 main-and-prod-push-guard 管推送)
|
||||
# · 不在 branch-holds 名單上的分支(總管自己開的工作分支)
|
||||
# · 非 commit 的 git 動作(status/log/diff/checkout…)
|
||||
#
|
||||
# 真的要在那條分支上 commit(例如接手一條被放棄的分支):
|
||||
# 把它從 `branch-holds.md` 移除(那本來就是「它不再是別人的」的正確表示法),
|
||||
# 或 `NOT_MY_BRANCH_OK=1 git commit ...`(留痕,commit 說明寫理由)。
|
||||
set -eu
|
||||
|
||||
[ "${NOT_MY_BRANCH_OK:-}" = "1" ] && exit 0
|
||||
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
CMD=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: sys.exit(0)
|
||||
print((d.get("tool_input") or {}).get("command") or "")
|
||||
' 2>/dev/null || true)
|
||||
|
||||
# 只看 commit(含 -m/-F/--amend)
|
||||
case "$CMD" in
|
||||
*"git commit"*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
HOLDS="$PROJ/.claude/branch-holds.md"
|
||||
[ -f "$HOLDS" ] || exit 0
|
||||
|
||||
BR=$(git -C "$PROJ" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||||
[ -n "$BR" ] || exit 0
|
||||
case "$BR" in main|master|HEAD) exit 0 ;; esac
|
||||
|
||||
# 分支名有沒有出現在 branch-holds 的條目裡(格式:- `分支名`(repo):理由)
|
||||
grep -q "^- \`${BR}\`" "$HOLDS" 2>/dev/null || exit 0
|
||||
|
||||
REASON=$(grep -A1 "^- \`${BR}\`" "$HOLDS" | head -2 | tr '\n' ' ' | cut -c1-160)
|
||||
|
||||
cat >&2 <<EOF
|
||||
🚫 這條分支不是你的——它列在 branch-holds 上(別人正在施工,或刻意保留)
|
||||
|
||||
當前分支:$BR
|
||||
它的條目:${REASON}
|
||||
|
||||
🔴 **2026-08-16 總管一小時內在同一條分支上犯了兩次**,而且第一次之後
|
||||
還在回覆裡寫了「這正是共用工作區那條教訓咬到我自己」——**寫下教訓沒有讓他不再犯。**
|
||||
|
||||
【為什麼這個錯特別難發現】
|
||||
打錯分支之後跑 \`git push gitea main\` 會回 **\`Everything up-to-date\`**——
|
||||
**那是假成功**,因為本機 \`main\` 確實沒動過。
|
||||
真訊號是「**本機 HEAD 與遠端對不上**」,而那句話不會自己跳出來。
|
||||
|
||||
【現在怎麼做(擇一)】
|
||||
① **切回 main 再 commit**(絕大多數情況的正解):
|
||||
git checkout main && <你的 git commit ...>
|
||||
② **已經打錯了要救**:
|
||||
git checkout main && git cherry-pick <那顆 sha>
|
||||
git branch -f $BR gitea/$BR # 把別人的分支還原成遠端狀態
|
||||
③ **你真的要接手這條分支**:先把它從 \`.claude/branch-holds.md\` 移除
|
||||
(那本來就是「它不再是別人的」的正確表示法),再 commit
|
||||
④ 真有例外:\`NOT_MY_BRANCH_OK=1 git commit ...\`,並在 commit 說明寫理由
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# pending-changes-retired.sh — `pending-changes.md` 已廢除,寫它一律擋下(leo 2026-08-19)
|
||||
#
|
||||
# leo 原話:
|
||||
# 「那種形式根本難以回覆,每個都長篇大論也不易閱讀,**而且寫入後不會提醒**,
|
||||
# 如果要寫入 pending changes,一律改用票,這個檔案廢除,
|
||||
# 可以改成一張 index 指向票號及連結。」
|
||||
#
|
||||
# 🔴 為什麼要有這道閘,而不是只改文件:
|
||||
# 規則躺在 CLAUDE.md/SDD-LIFECYCLE 裡,已經被證明不夠——
|
||||
# wiki mistakes 有一整條「以為『規範存在』=『AI 會照做』」。
|
||||
# 而這個檔的特性讓違規特別難發現:**寫進去不會通知任何人**,
|
||||
# 所以「寫了沒人擋」與「寫了沒人看」會同時成立,債靜靜地長。
|
||||
#
|
||||
# 擋什麼:Write/Edit/MultiEdit 指向任何 repo 的 `3-specs/pending-changes.md`。
|
||||
# 不擋什麼:archive/ 底下的存底(那是唯讀歷史,路徑不同)、以及讀取。
|
||||
#
|
||||
# 誠實界線(兩條,都要留著):
|
||||
# ① 它擋的是**這個路徑**。有人另立一個叫 `pending-changes-v2.md` 的檔照樣繞得過——
|
||||
# 那屬於語意層,這道閘不假裝擋得到。
|
||||
# ② 它掛在 Write/Edit/MultiEdit 上,**用 bash(heredoc/sed/cat >)寫檔不會經過它**。
|
||||
# 這不是疏漏是取捨:PreToolUse 攔不到 shell 內部做什麼;要連那條也擋,得改成
|
||||
# Bash matcher 上的字串比對,而那會把「只是提到這個檔名的句子」一起擋掉
|
||||
# (`inkstone/InkStoneCo#56` 正在處理的就是這個形狀)。
|
||||
set -euo pipefail
|
||||
input="$(cat)"
|
||||
|
||||
file_path="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get("tool_input", {}).get("file_path", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' 2>/dev/null || true)"
|
||||
|
||||
[ -z "$file_path" ] && exit 0
|
||||
|
||||
# archive/ 底下的存底放行(唯讀歷史,不是提案緩衝區)
|
||||
case "$file_path" in
|
||||
*/archive/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
case "$file_path" in
|
||||
*/3-specs/pending-changes.md|3-specs/pending-changes.md|*/pending-changes.md)
|
||||
cat >&2 <<'MSG'
|
||||
❌ BLOCKED:`pending-changes.md` 已於 2026-08-19 廢除,不准寫入。
|
||||
|
||||
leo 廢除它的理由(原話):
|
||||
「那種形式根本難以回覆,每個都長篇大論也不易閱讀,**而且寫入後不會提醒**。」
|
||||
|
||||
要提規格層變更 → **開一張 Gitea 票**,不是寫檔:
|
||||
· 標題:👤 裁決題:<一句話>
|
||||
· 標籤:Human + s/triage · 指派:Leo
|
||||
· 內文只要三段:**要你裁的** / **怎麼回**(給選項,能回一個詞最好) / **誠實標記**
|
||||
· 開完就停下等 leo 回覆——「停下等 confirm」這條沒有變,變的只是它住在票上
|
||||
|
||||
🔴 不要把提案正文貼進票裡當長篇大論。長脈絡寫 wiki,票上只留決策題與連結。
|
||||
|
||||
原文存底(唯讀):system-dev/docs/3-specs/archive/pending-changes-archive-20260819.md
|
||||
規則出處:CLAUDE.md「規格變更唯一路徑」、3-specs/SDD-LIFECYCLE.md 第 3 條
|
||||
MSG
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook 範本骨架 —— 專案自訂禁令
|
||||
# wishlist §2 可選:讓使用者自訂專案禁令(例:「KBDB 禁動表」「某目錄唯讀」)。
|
||||
#
|
||||
# 預設不啟用。要用時:
|
||||
# 1. 在下面 FORBIDDEN_PATTERNS 填入禁改的路徑/檔名 pattern
|
||||
# 2. 到 .claude/settings.json 的 PreToolUse 加掛這支
|
||||
#
|
||||
# 掛在 PreToolUse(matcher: Write|Edit)。stdin 收到 JSON:{ tool_name, tool_input: { file_path } }
|
||||
# 命中禁令 → exit 2 擋。
|
||||
#
|
||||
# 誠實限制:只擋直接寫檔。bash 繞道、helper 間接改動擋不到。留痕可審 ≠ 技術防偽。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── 專案自訂:禁改的 pattern(一行一個,case glob 語法)──────
|
||||
# 範例(已註解,啟用前請改成自己的):
|
||||
# "*/db/schema.sql" # 禁手改 schema
|
||||
# "*/migrations/*" # migration 一旦建立不可改
|
||||
FORBIDDEN_PATTERNS=(
|
||||
# "*/your/protected/path/*"
|
||||
)
|
||||
|
||||
# 沒設任何禁令 → 直接放行(骨架預設狀態)
|
||||
[ ${#FORBIDDEN_PATTERNS[@]} -eq 0 ] && exit 0
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
||||
fi
|
||||
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
|
||||
# shellcheck disable=SC2254
|
||||
case "$FILE_PATH" in
|
||||
$pattern)
|
||||
cat >&2 <<EOF
|
||||
🚫 專案禁令攔截:$FILE_PATH 命中禁改規則($pattern)。
|
||||
|
||||
這是本專案 .claude/hooks/pre-write-guard.sh 設定的硬底線。
|
||||
要動 → 先和負責人確認,並更新禁令設定。
|
||||
EOF
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exit 0
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook 範本骨架 —— 專案自訂禁令(預設空殼,不攔任何東西)
|
||||
#
|
||||
# ⚠️ 定位(讀清楚再用):
|
||||
# 這支跟其他三支 hook 不同——它不是「裝上就生效的警察」,而是一個「按需手填的
|
||||
# 空插槽」。預設狀態下 FORBIDDEN_PATTERNS 是空的,它【不攔任何東西】。
|
||||
# 別誤以為裝了它就有保護——空殼 = 沒保護。
|
||||
#
|
||||
# 🤖 有 CC 在場的話,通常不需要這個範本:
|
||||
# 直接叫你的 CC「幫我寫一支 guard hook,禁止改 X」。CC 現寫的條件邏輯,
|
||||
# 表達力遠勝這裡的 glob FORBIDDEN_PATTERNS(例如「禁子 repo 的 code 但放行 .md」
|
||||
# 這種細緻規則,glob 寫不出來,CC 的條件判斷寫得出來)。
|
||||
# 這個範本只對「不靠 CC、想自己手動 DIY bash」的用戶有價值。
|
||||
#
|
||||
# 要啟用(手動 DIY 路線):
|
||||
# 1. 在下面 FORBIDDEN_PATTERNS 填禁改的路徑/檔名 pattern
|
||||
# 2. 到 .claude/settings.json 的 PreToolUse 加掛這支
|
||||
#
|
||||
# 掛在 PreToolUse(matcher: Write|Edit)。stdin 收 JSON:{ tool_name, tool_input:{ file_path } }
|
||||
# 命中禁令 → exit 2 擋。
|
||||
#
|
||||
# 誠實限制:只擋直接寫檔。bash 繞道、helper 間接改動擋不到。留痕可審 ≠ 技術防偽。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── 專案自訂:禁改的 pattern(一行一個,case glob 語法)──────
|
||||
# 範例(已註解,啟用前請改成自己的):
|
||||
# "*/db/schema.sql" # 禁手改 schema
|
||||
# "*/migrations/*" # migration 一旦建立不可改
|
||||
FORBIDDEN_PATTERNS=(
|
||||
# "*/your/protected/path/*"
|
||||
)
|
||||
|
||||
# 沒設任何禁令 → 空殼狀態,安靜放行。
|
||||
# (不在這裡 print——PreToolUse 每次 Write/Edit 都會跑,每次喊話會洗版。
|
||||
# 「這是空殼」的提醒改由 install.sh / update.sh 安裝時告知,那裡用戶一定看得到。)
|
||||
[ ${#FORBIDDEN_PATTERNS[@]} -eq 0 ] && exit 0
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
||||
fi
|
||||
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
|
||||
# shellcheck disable=SC2254
|
||||
case "$FILE_PATH" in
|
||||
$pattern)
|
||||
cat >&2 <<EOF
|
||||
🚫 專案禁令攔截:$FILE_PATH 命中禁改規則($pattern)。
|
||||
|
||||
這是本專案 .claude/hooks/pre-write-guard.sh 設定的硬底線。
|
||||
要動 → 先和負責人確認,並更新禁令設定。
|
||||
EOF
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exit 0
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
#!/bin/sh
|
||||
# prod-write-guard.sh — PreToolUse(Bash + 會寫到線上實例的 MCP 工具)
|
||||
#
|
||||
# 🔴 立這道閘的來由(leo 2026-08-11,當天實際發生):
|
||||
# 兩條 subagent 在同一個 session 裡把東西推上了 leo21c 線上實例——
|
||||
# ① `#79` 覆寫了現役出貨管線的 `ship_refresh_cdn` 工作流定義
|
||||
# ② `#85` 推了一支新工作流上去
|
||||
# **兩次都沒有被任何閘擋到**,儘管派工單第一條紅線就寫著「不可以部署到正式環境」。
|
||||
#
|
||||
# leo 當場點破,而他的說法比「再補一種推送」更根本:
|
||||
# 「**其實問題是它跟正式環境是斷開的,所以 hook 要不讓它對正式環境做任何推送,
|
||||
# 不是某種推送,這種只有透過你。**」
|
||||
#
|
||||
# ⇒ 判準不是「這是哪一種推送」,是「**這個動作會不會改到線上那台**」。
|
||||
# 會 ⇒ 擋,交回總管。總管要動之前,上面還有 leo 的保險(D20/.github-armed)。
|
||||
#
|
||||
# 為什麼既有的閘接不住(2026-08-11 實查,不是推測):
|
||||
# · `main-and-prod-push-guard.sh` 只認 `wrangler deploy|publish|versions deploy`
|
||||
# · `stage-before-prod-guard.sh` 只比對 `arcrun-rag-bundles`/`github-arm`/`publish-github`
|
||||
# · **兩支都只掛在 `Bash` 上**,而 `arcrun_push_workflow` 是 **MCP 工具,根本不經過 Bash**
|
||||
# ⇒ 推工作流定義這條路,從頭到尾沒有任何一雙眼睛。
|
||||
#
|
||||
# 設計紀律(沿用前兩支用血換來的):
|
||||
# • **fail-closed**:內部錯誤不准變成靜默放行。exit 1 不擋,只有 exit 2 才擋。
|
||||
# • **先排除「談論/讀取/演練」再比對**:誤攔比漏攔更容易殺死一道閘
|
||||
# ——被擋得莫名其妙,人就會想辦法繞過它。
|
||||
# • **stage 一律自由**:擋的是 prod,不是所有部署。測試場本來就該隨便跑。
|
||||
set -eu
|
||||
|
||||
INPUT="$(cat)"
|
||||
|
||||
TOOL=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("tool_name", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || printf '')
|
||||
|
||||
CMD=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || printf '')
|
||||
|
||||
# ── 總管戳記:**單次、15 分鐘失效** ────────────────────────────────────
|
||||
#
|
||||
# 🔴 這一版一開始寫成「15 分鐘內無限次放行」,而同一天 `main-and-prod-push-guard.sh`
|
||||
# 就是因為這個寫法被穿透的:總管為了推頂層 touch 了戳記,
|
||||
# **15 分鐘內一條並行的 subagent 把 commit 推上了另一個 repo 的 main**。
|
||||
# ⇒ **多條 subagent 並行時,「時間窗」本身就是漏洞。** 所以改成單次用完即丟。
|
||||
# ⇒ 而且只在**真的要擋的那一刻**才檢查——放在檔頭會被任何一條無關指令把戳記燒掉。
|
||||
STAMP="/tmp/.prod-write-ok"
|
||||
stamp_ok() {
|
||||
[ -f "$STAMP" ] || return 1
|
||||
NOW=$(date +%s 2>/dev/null || echo 0)
|
||||
MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
|
||||
case "$NOW$MT" in *[!0-9]*) return 1 ;; esac
|
||||
[ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1
|
||||
[ $((NOW - MT)) -lt 900 ] || return 1
|
||||
rm -f "$STAMP" 2>/dev/null || true # 單次:用完即丟
|
||||
return 0
|
||||
}
|
||||
|
||||
# ══ ① MCP 工具:會改到線上實例的那幾支 ═════════════════════════════════
|
||||
# 這一段是本閘存在的理由——上面兩支既有的閘完全看不到這裡。
|
||||
case "$TOOL" in
|
||||
*arcrun_push_workflow*|*arcrun_delete_workflow*|\
|
||||
*arcrun_recipe_push*|*arcrun_recipe_delete*|\
|
||||
*arcrun_create_tag*|*arcrun_delete_tag*|\
|
||||
*arcrun_tag_resource*|*arcrun_untag_resource*|\
|
||||
*kbdb_create_record*|*kbdb_create_template*)
|
||||
stamp_ok && exit 0
|
||||
cat >&2 <<'MSG'
|
||||
🚫 這個動作會改到「線上那台」——不是你能自己按的(leo 2026-08-11 立)
|
||||
|
||||
leo 原話:
|
||||
「**其實問題是它跟正式環境是斷開的,所以 hook 要不讓它對正式環境做任何推送,
|
||||
不是某種推送,這種只有透過你。**」
|
||||
|
||||
⇒ 判準不是「這是哪一種推送」,是「**這會不會改到線上那台**」。會,就交回總管。
|
||||
|
||||
━━━ 你是 subagent ━━━
|
||||
**不要想辦法過這道閘。** 把成品留在自己的分支/檔案裡,交件時寫清楚:
|
||||
· 你要推什麼上去(名稱+它會覆蓋掉什麼,還是全新的)
|
||||
· 為什麼現在該推、你驗過哪些(逐項,不要整包說「驗過了」)
|
||||
· 推上去之後**用什麼指令可以看出它生效了**
|
||||
總管會看過再決定。**這是一句話的事。**
|
||||
|
||||
🔴 特別注意「**覆寫現役的東西**」:2026-08-11 真的發生過——
|
||||
一條 subagent 覆寫了現役出貨管線的工作流定義。那次內容是對的(舊版真的壞了),
|
||||
**但「內容對不對」和「該不該先問」是兩件事**(D54)。
|
||||
|
||||
━━━ 你是總管 ━━━
|
||||
看清楚它會覆蓋什麼(`arcrun_get_workflow` 先撈一份現況存起來,好回滾),確定要推,再:
|
||||
|
||||
touch /tmp/.prod-write-ok # ⚠️ 只放行「下一個」動作,用完即消失
|
||||
|
||||
戳記 15 分鐘有效。**若這一推會讓封測者/用戶當場拿到東西,那就不是你的層級——要 leo 解保險。**
|
||||
MSG
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── 非 Bash 的其他工具一律放行 ─────────────────────────────────────────
|
||||
[ "$TOOL" = "Bash" ] || exit 0
|
||||
[ -z "$CMD" ] && exit 0
|
||||
|
||||
# ── 「執行它」vs「談論它」:動詞要在**指令位置**才算數 ─────────────────
|
||||
#
|
||||
# 🔴 2026-08-13 第六次同款誤攔(前五次記在頂層 `wiki/mistakes.md`):
|
||||
# 總管要把 `#108` 的驗收證據**留言到 Gitea**,指令長這樣——
|
||||
# BODY=$(cat <<'EOF' … 正文裡引述了 `acr update --force` … EOF
|
||||
# )
|
||||
# curl -X POST https://git.uncle6.me/api/v1/…/comments
|
||||
# ⇒ 命中舊的 `*"acr update"*` ⇒ **一則留言被當成部署擋掉。**
|
||||
# (curl 那半本來是對的:打 Gitea 不是打實例,下面的分支會 exit 0。
|
||||
# 擋它的是那條純字串比對,而且它排在 curl 那支前面。)
|
||||
#
|
||||
# 本檔第 25 行自己寫著「誤攔比漏攔更容易殺死一道閘」,而這是同一形狀的第六次:
|
||||
# **判準看「字串有沒有出現」,不是「它在做什麼」。**
|
||||
#
|
||||
# 判準改成兩層:① 剝掉 heredoc 內文(那是資料,不是指令)
|
||||
# ② 動詞要在指令位置——行首/`;` `&` `|` `(` `$(` 之後/引號開頭
|
||||
# 仍會擋:`bash -c "acr update"`、`a && acr push`、`npx wrangler deploy`。
|
||||
# 不再擋:反引號或 markdown code span 裡的引述、commit 訊息、註解、heredoc 正文。
|
||||
#
|
||||
# 🔴 這段**必須算在所有豁免之前**(2026-08-13 被自己補的測試抓到):
|
||||
# `cat <<'EOF' > note.md … EOF` 換行 `acr update --force`
|
||||
# ⇒ 指令以 `cat ` 開頭,而下面認投送工具的 `*" acr "*` 是**空格界定**、
|
||||
# 認不出換行後的 `acr` ⇒ 掉進「開頭是唯讀工具」的豁免直接放行。
|
||||
# 同款的還有 `git commit -m 'x' && acr update`(吃 `*"git commit"*` 豁免)。
|
||||
# ⇒ 命中指令位置的,一概不吃任何豁免。
|
||||
CLI_HIT=$(printf '%s' "$CMD" | python3 -c '
|
||||
import sys, re
|
||||
cmd = sys.stdin.read()
|
||||
cmd = re.sub(r"<<-?[ \t]*([\x27\x22]?)([A-Za-z_][A-Za-z0-9_]*)\1.*?^[ \t]*\2[ \t]*$",
|
||||
" ", cmd, flags=re.S | re.M)
|
||||
LAUNCH = r"(?:npx\s+|pnpm\s+(?:exec\s+)?|yarn\s+|sudo\s+|env\s+\S+=\S+\s+)*"
|
||||
# 指令位置=行首/`;` `&` `|` `(` `$(` 之後/**shell 呼叫旗標後面的引號**。
|
||||
# 🔴 引號本身不算(2026-08-13 被自己補的測試抓到):先前把 `\x22\x27` 直接放進
|
||||
# 前綴字元類,結果 `grep \x27acr push\x27 <檔>` 被當成在執行 ⇒ 又是一次誤攔。
|
||||
# 只有 `bash -c "…"`/`sh -lc \x27…\x27`/`eval "…"` 這種**真的會把字串當指令跑**的才算。
|
||||
SHELLC = r"(?:bash|sh|zsh|dash|eval|xargs)\s+(?:-\w+\s+)*[\x22\x27]"
|
||||
PAT = (r"(?:^|[\n;&|(]|\$\(|" + SHELLC + r")\s*" + LAUNCH
|
||||
+ r"(acr|wrangler)\b([^\n;&|)]*)")
|
||||
for m in re.finditer(PAT, cmd):
|
||||
tool, rest = m.group(1), m.group(2)
|
||||
if re.search(r"(^|\s)(--help|-h)(\s|$)", rest):
|
||||
continue # 問說明不是執行
|
||||
if tool == "acr" and re.search(r"\b(push|update)\b", rest):
|
||||
print("cli"); break
|
||||
if tool == "wrangler" and re.search(r"\b(deploy|publish)\b", rest):
|
||||
print("cli"); break
|
||||
' 2>/dev/null || printf '__PYFAIL__')
|
||||
|
||||
# fail-closed:python 掛掉不准變成靜默放行 ⇒ 退回舊的整串比對,寧可誤攔
|
||||
if [ "$CLI_HIT" = "__PYFAIL__" ]; then
|
||||
CLI_HIT=""
|
||||
case "$CMD" in
|
||||
*"acr push"*|*"acr update"*|*"wrangler deploy"*|*"wrangler publish"*) CLI_HIT="cli" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ══ ② Bash:先排除明確不是「推上線」的動作 ═══════════════════════════
|
||||
# 關鍵字出現在 commit 訊息裡、在 sed/grep 的參數裡,都不是「執行」。
|
||||
# ⚠️ 下面每一條豁免都只在 CLI_HIT 為空時才輪得到(見上)。
|
||||
# 🔴 2026-08-12 修漏攔:這個「開頭是唯讀工具就放行」的豁免**本身就是一條穿牆路**。
|
||||
# 實撞:`cat payload.json | curl -X POST <實例>/webhooks/named` ——
|
||||
# 指令以 `cat ` 開頭 ⇒ 整條直接 exit 0 ⇒ **寫進線上實例,本閘一聲不吭**。
|
||||
# 任何人只要在前面接一個管線就能繞過,而這正是本檔下方註解自己警告的
|
||||
# 「換個工具就穿過去了」。⇒ 豁免只在「整條指令裡沒有任何投送工具」時才成立。
|
||||
if [ -z "$CLI_HIT" ]; then
|
||||
case "$CMD" in
|
||||
*curl*|*wget*|*httpie*|*" http "*|*wrangler*|*" acr "*|acr\ *) ;; # 有投送工具 → 不吃開頭豁免
|
||||
sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *|find\ *) exit 0 ;;
|
||||
esac
|
||||
case "$CMD" in
|
||||
*"git commit"*|*"git add"*|*"git tag"*|*"git stash"*) exit 0 ;;
|
||||
*"git status"*|*"git log"*|*"git diff"*|*"git show"*|*"git branch"*) exit 0 ;;
|
||||
*" --dry-run"*|*"--dry-run "*) exit 0 ;;
|
||||
# 問說明不是執行(實測撞到:`acr update --help` 被自己擋掉)
|
||||
# ——`--help` 現在也在 CLI_HIT 那層逐段判,這裡留著給 curl/wrangler 之外的情況
|
||||
*" --help"*|*" -h"*|*" help "*) exit 0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# stage/staging 一律自由——擋的是 prod,不是所有部署
|
||||
#
|
||||
# 🔴 2026-08-12 補:`youlin`(youlin-hsieh-dev)**就是測試場**,不是 prod。
|
||||
# arcrun-rag CLAUDE.md 白紙黑字:「要看範例或做任何測試 → **只在 youlin(D37 stage)**」,
|
||||
# 而 leo 2026-07-25 令「測試一律用 youlin,別拿 leo21c 當探針(會製造假信號)」。
|
||||
# 本閘原本只認 `staging` 這個字 ⇒ 打 youlin 做實驗被誤擋
|
||||
# ⇒ **這種誤攔比漏攔更危險**:被擋得莫名其妙,人就會學會繞過它,那它就等於不存在。
|
||||
case "$CMD" in
|
||||
*staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*|*"-staging"*) exit 0 ;;
|
||||
*youlin-hsieh-dev*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;;
|
||||
esac
|
||||
|
||||
|
||||
# 會把東西推上線的 CLI 動作
|
||||
# `acr update`=「拉新 release 並重新部署到你的 Cloudflare」(它 --help 的原話)
|
||||
# ⇒ 它就是一次完整的實例部署,比 `acr push` 影響更大(一次重部所有 worker)。
|
||||
CMD_M="${CLI_HIT}|$CMD"
|
||||
case "$CMD_M" in
|
||||
cli\|*) ;;
|
||||
# 直接對實例跑 wrangler 也算——`main-and-prod-push-guard.sh` 有擋,但它認的是
|
||||
# 「有沒有 .github-armed」;這裡多一層是因為打錯帳號的風險在 wrangler 這一側:
|
||||
# 2026-08-11 實查 kbdb/wrangler.toml 的 D1 id 是**官方 prod 的**,
|
||||
# 而本機 wrangler 登入的是官方帳號 ⇒ 照直覺跑會把 self-hosted 實例接到官方資料庫上。
|
||||
# (2026-08-13:`wrangler deploy|publish` 已併進上面的 CLI_HIT 判定——
|
||||
# 原本這裡也是整串比對,「文章裡提到 wrangler deploy」同樣會被誤擋。)
|
||||
# 🔴 2026-08-12 補的洞:**直接用 HTTP 打實例的寫入端點**。
|
||||
# 由來:總管要把 `ship_refresh_cdn` v2 推上 leo21c,用的是
|
||||
# `curl -X POST .../webhooks/named`(工作流部署端點,認證只是 namespace 明碼)
|
||||
# ——本閘的三個 CLI 關鍵字**一個都沒命中**,等於「不被擋地寫進 leo 那台」。
|
||||
# ⇒ 這正是 D75 那句話要防的事:leo 說「**不是某種推送**,是會不會改到線上那台」。
|
||||
# 本閘卻是照「某幾種指令」寫的 ⇒ 換個工具就穿過去了。
|
||||
# 判準改成看**目標**:打到實例主機(workers.dev/arcrun.dev)+**帶寫入語意**才擋。
|
||||
# 刻意不擋的:讀(沒有 -X 寫入動詞也沒有 body)、打 Gitea(git.uncle6.me 不是實例)、
|
||||
# 以及上面已經放行的 staging。
|
||||
*curl*|*httpie*|*" http "*|*wget*)
|
||||
case "$CMD" in
|
||||
*workers.dev*|*arcrun.dev*)
|
||||
# 🔴 2026-08-12 修誤攔:` -d ` 不只是 curl 的 --data,**一堆唯讀工具也用 -d**
|
||||
# (`tr -d`/`cut -d=`/`sort -d`/`date -d`/`xargs -d`/`paste -d`…)。
|
||||
# 實撞:總管要「唯讀」驗證一把金鑰能不能開 kbdb 的閘,指令第一行是
|
||||
# ACC=$(grep … | cut -d= -f2 | tr -d '\r')
|
||||
# ⇒ `tr -d ` 命中 `*" -d "*` ⇒ **一條純 GET 被當成寫入擋掉**。
|
||||
# 這正是本檔第 124 行自己寫的那句:「誤攔比漏攔更危險——被擋得莫名其妙,
|
||||
# 人就會學會繞過它,那它就等於不存在。」
|
||||
# ⇒ 比對前先把「已知的唯讀 -d 用法」剪掉,再看剩下的有沒有寫入語意。
|
||||
CMD_W=$(printf '%s' "$CMD" | sed -E 's/(^|[|;&( ])(tr|cut|sort|uniq|date|xargs|paste|join|du|logger|split|comm)[[:space:]]+-d/\1\2 __READONLY_D__/g')
|
||||
case "$CMD_W" in
|
||||
*"-X POST"*|*"-X PUT"*|*"-X PATCH"*|*"-X DELETE"*|\
|
||||
*"--request POST"*|*"--request PUT"*|*"--request PATCH"*|*"--request DELETE"*|\
|
||||
*"--data"*|*" -d "*|*" -d'"*|*' -d"'*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
stamp_ok && exit 0
|
||||
|
||||
cat >&2 <<'MSG'
|
||||
🚫 `acr push` 會把東西推上線上實例——交回總管(leo 2026-08-11 立)
|
||||
|
||||
leo 原話:
|
||||
「**hook 要不讓它對正式環境做任何推送,不是某種推送,這種只有透過你。**」
|
||||
|
||||
━━━ 你是 subagent ━━━
|
||||
把 YAML/定義檔留著,交件時附上檔案路徑與「它會覆蓋什麼」,由總管推。
|
||||
|
||||
━━━ 你是總管 ━━━
|
||||
確認過要推什麼、會蓋掉什麼之後:
|
||||
|
||||
touch /tmp/.prod-write-ok # ⚠️ 只放行「下一個」動作,用完即消失
|
||||
|
||||
⚠️ `acr recipe push` 另有一道**互動式暴露同意閘**(終端機裡要人親手輸入資源名)。
|
||||
那道閘擋的是「把資源變成可被外部呼叫」,**本閘的戳記蓋不過它**——那是 leo 的手。
|
||||
MSG
|
||||
exit 2
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook — 動 code 前檢查 SDD + 單一活性 SDD 鐵律(issue #6)
|
||||
# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。
|
||||
# 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md
|
||||
#
|
||||
# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。
|
||||
# stdin 收到 JSON:{ tool_name, tool_input: { file_path, ... } }
|
||||
# 行為:
|
||||
# 1. status: active 的 SDD > 1 份 → 單一活性鐵律已被違反,**不論寫什麼檔**一律擋(exit 2),
|
||||
# 先收斂到一份再說。
|
||||
# 2. 動 code 檔(.ts/.go/...)→ 需要「恰好 1 份」active SDD;0 份 → 擋。
|
||||
# 3. 向下相容:3-specs 下完全沒有任何 design.md 帶 frontmatter(老 repo 尚未遷移生命週期制度)
|
||||
# → 退回舊行為:有 design.md 就放行+提醒,沒有才擋。避免 template update 後老 repo 立刻全紅。
|
||||
#
|
||||
# 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。
|
||||
# 藏在 helper 裡、用 bash 繞道的改動擋不到。
|
||||
# 價值是「想跳過會被抓到 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# 解析 file_path。優先用 jq,沒有 jq 退回 grep(容錯)。
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
||||
fi
|
||||
|
||||
# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺)
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
# 🔴 2026-08-02 修:原本寫死相對路徑 `system-dev/docs/3-specs`,
|
||||
# 但 hook 的工作目錄是**頂層 InkStoneCo**,改子 repo 的 code 時就去頂層找 SDD
|
||||
# ⇒ 看不到子 repo 自己那份 ⇒ **一律誤報「找不到任何 SDD」**。
|
||||
# 實撞:改 products/arcrun-rag/collector/... 被擋,但該 repo 明明有
|
||||
# system-dev/docs/3-specs/daemon-beta/design.md(status: active)。
|
||||
# ⇒ 改成從被改檔案往上找最近的 system-dev/docs/3-specs(子 repo 優先,找不到才用頂層)。
|
||||
# ⚠️ 只往上找到「頂層 InkStoneCo」為止——不可讓任意路徑(如 /private/tmp/…)
|
||||
# 退回頂層 SDD 而被放行,那會把原本擋得住的情況變成擋不住。
|
||||
SPECS_DIR="system-dev/docs/3-specs"
|
||||
_root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
case "$FILE_PATH" in
|
||||
"$_root"/*)
|
||||
_d=$(dirname "$FILE_PATH")
|
||||
while [ "$_d" != "/" ] && [ -n "$_d" ]; do
|
||||
if [ -d "$_d/system-dev/docs/3-specs" ]; then
|
||||
SPECS_DIR="$_d/system-dev/docs/3-specs"
|
||||
break
|
||||
fi
|
||||
[ "$_d" = "$_root" ] && break
|
||||
_d=$(dirname "$_d")
|
||||
done
|
||||
;;
|
||||
*)
|
||||
# 專案外的路徑:**不可退回頂層 SDD 就放行**,否則原本擋得住的會變成擋不住。
|
||||
# 但 **git worktree 是正當工作區**(本專案大量使用 /private/tmp 下的 worktree 出貨),
|
||||
# 它自己就帶著該 repo 的 system-dev/docs/3-specs ⇒ 一樣往上找,找得到就認。
|
||||
# 找不到才指向不存在目錄 ⇒ 走原有的「找不到 SDD」擋下路徑。
|
||||
# (2026-08-02:第一版忘了 worktree,把正當的出貨工作區也擋掉。)
|
||||
SPECS_DIR="/nonexistent/3-specs"
|
||||
_d=$(dirname "$FILE_PATH")
|
||||
while [ "$_d" != "/" ] && [ -n "$_d" ]; do
|
||||
if [ -d "$_d/system-dev/docs/3-specs" ]; then
|
||||
SPECS_DIR="$_d/system-dev/docs/3-specs"
|
||||
break
|
||||
fi
|
||||
_d=$(dirname "$_d")
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── 統計 active / frontmatter ──────────────────────
|
||||
# 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數——
|
||||
# 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。
|
||||
# frontmatter 判定=design.md 前 10 行有 ^status: 行(機器可查,見 SDD-LIFECYCLE.md)。
|
||||
ACTIVE_COUNT=0
|
||||
FM_COUNT=0
|
||||
ACTIVE_LIST=""
|
||||
if [ -d "$SPECS_DIR" ]; then
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
HEAD10=$(head -10 "$f" 2>/dev/null || true)
|
||||
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*'; then
|
||||
FM_COUNT=$((FM_COUNT + 1))
|
||||
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*active'; then
|
||||
ACTIVE_COUNT=$((ACTIVE_COUNT + 1))
|
||||
ACTIVE_LIST="${ACTIVE_LIST} • ${f}
|
||||
"
|
||||
fi
|
||||
fi
|
||||
done < <(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null)
|
||||
fi
|
||||
|
||||
# ── 鐵律 1:單一活性被違反(active > 1)→ 不論寫什麼檔一律擋 ──
|
||||
if [ "$ACTIVE_COUNT" -gt 1 ]; then
|
||||
cat >&2 <<EOF
|
||||
🚫 SDD 單一活性鐵律違反:偵測到 ${ACTIVE_COUNT} 份 status: active 的 SDD(任何時刻整個 repo 最多一份):
|
||||
${ACTIVE_LIST}
|
||||
請先收斂到一份:其餘改 status: paused / closed(closed 且被取代者填 superseded_by 並移入 3-specs/archive/)。
|
||||
規則全文見 system-dev/docs/3-specs/SDD-LIFECYCLE.md。收斂前擋下所有寫檔。
|
||||
(本 hook 攔 Write/Edit;修 frontmatter 可用 bash 直改,或由人裁決哪份是現行。)
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 只管 code 檔。docs/markdown/設定檔等放行。
|
||||
case "$FILE_PATH" in
|
||||
*.ts|*.tsx|*.js|*.jsx|*.go|*.py|*.rs|*.java|*.rb|*.php|*.c|*.cpp|*.h|*.hpp|*.swift|*.kt) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 改 SDD 自己 / 測試檔 → 放行
|
||||
case "$FILE_PATH" in
|
||||
*system-dev/docs/3-specs/*) exit 0 ;;
|
||||
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# ── 向下相容:整個 3-specs 沒有任何帶 frontmatter 的 design.md ──
|
||||
# =老 repo 還沒遷移生命週期制度 → 退回舊行為(有 design.md 就放行+提醒),
|
||||
# 避免 template update 一裝新 hook,老 repo 所有 code 寫入立刻全紅。
|
||||
if [ "$FM_COUNT" -eq 0 ]; then
|
||||
SDD_COUNT=0
|
||||
if [ -d "$SPECS_DIR" ]; then
|
||||
SDD_COUNT=$(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null | wc -l | tr -d ' ')
|
||||
fi
|
||||
|
||||
if [ "$SDD_COUNT" -eq 0 ]; then
|
||||
cat >&2 <<EOF
|
||||
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下找不到任何 SDD。
|
||||
|
||||
絕對鐵律:任何 code 變動前必須有對應 SDD(design.md),且遵守單一活性生命週期
|
||||
(system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
|
||||
|
||||
請先:
|
||||
1. 確認這個改動屬於哪個子系統
|
||||
2. 在 ${SPECS_DIR}/[子系統]/ 建立 design.md(可用 /sdd-check 協助),frontmatter 標 status: active
|
||||
3. 在回覆開頭宣告已讀 SDD + 對應 task
|
||||
|
||||
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 舊行為放行 + 提醒遷移(stderr 警告,不擋)
|
||||
echo "📋 提醒:${SPECS_DIR}/ 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 新行為:寫 code 檔需「恰好 1 份」active SDD ──
|
||||
if [ "$ACTIVE_COUNT" -eq 0 ]; then
|
||||
cat >&2 <<EOF
|
||||
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下沒有任何 status: active 的 SDD。
|
||||
|
||||
單一活性鐵律:所有開發任務唯一對應源=那份 active SDD(規則見 system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
|
||||
|
||||
請先(擇一,都是人的決定,CC 不得自行建 SDD):
|
||||
1. 把現行規格的 design.md frontmatter 標成 status: active(一份、只能一份)
|
||||
2. 或依 SDD-LIFECYCLE.md 第 3、4 條:proposal 進 pending-changes.md → 使用者 confirm → 開新 SDD 標 active
|
||||
然後在回覆開頭宣告已讀 active SDD + 對應 task。
|
||||
|
||||
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 恰好 1 份 active:放行,留痕提醒要宣告(stderr 警告,不擋)
|
||||
printf '📋 提醒:現行 active SDD=\n%s動手前請確認已讀它的 design.md、對應到 tasks,並在回覆宣告。\n' "$ACTIVE_LIST" >&2
|
||||
exit 0
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# self-drive-judge.sh — 自走警察【第二級:語意判官】(藍圖 v3 §5.5 落地)
|
||||
#
|
||||
# 為什麼要它(病根):
|
||||
# self-drive-police.sh 用 grep 抓請示句,**grep 只認字面**。
|
||||
# leo 2026-08-04 抓到的病是「停下來問早已決議的事」,但那個病**換句話說就閃過去了**——
|
||||
# 「要我 X 嗎」擋得下,「不知道這樣是否符合你的期待」「兩條路你偏好哪個」擋不下。
|
||||
# ⇒ 字面判擋不住換句話說,語意判才治本(藍圖 v3 §5.5「升級 prompt 型 hook」)。
|
||||
#
|
||||
# 為什麼不用官方 prompt 型 hook(實查,2026-08-05):
|
||||
# 官方 `type: prompt` 的 $ARGUMENTS = **hook 輸入 JSON**(session_id/transcript_path/…),
|
||||
# Stop 事件的輸入**不含我實際說了什麼**,而 prompt hook 單輪無工具、不能自己去讀 transcript。
|
||||
# ⇒ 直接換成 prompt hook 會變成「拿不到內容的判官」。
|
||||
# `type: agent`(可帶 Read/Grep 查證)做得到,但官方標 experimental ⇒ 依藍圖 §9.4 成熟度門檻只登記觀察。
|
||||
# ⇒ 本 hook=command hook 抽文字 + 外呼 haiku 判語意。判準與成本都在我們自己手上。
|
||||
#
|
||||
# 判準(只有一條,就是四題公式):
|
||||
# 這個問題是否真的命中人閘——① 花錢 ② 不可逆/難回收 ③ 跨專案結構 ④ 品味/方向?
|
||||
# 命中任一 → 該問,放行。四題全否 → 擋回去,逼它自己裁。
|
||||
#
|
||||
# 成本控制(三道前置閘,正常回合完全不花錢):
|
||||
# ① 沒有問號 → 不判(絕大多數回合走這條,零成本)
|
||||
# ② 已被 self-drive-police 的 grep 擋下的句型 → 不重判(不重複收費)
|
||||
# ③ 明說撞權限閘/四題第 X 命中 → 豁免(與 police 同一套豁免語)
|
||||
#
|
||||
# 失敗一律 fail-open(exit 0):判官掛掉不能變成「永遠停不下來」。
|
||||
|
||||
set -u
|
||||
|
||||
# 遞迴保險:判官自己起的那個 claude 不准再跑判官
|
||||
if [ "${SELF_DRIVE_JUDGE:-}" = "1" ]; then exit 0; fi
|
||||
|
||||
input="$(cat)"
|
||||
|
||||
# stop_hook_active=已經被擋過一輪,別再疊
|
||||
stop_active="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
print(json.load(sys.stdin).get("stop_hook_active", False))
|
||||
except Exception:
|
||||
print(False)
|
||||
' 2>/dev/null)"
|
||||
[ "$stop_active" = "True" ] && exit 0
|
||||
|
||||
transcript="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
print(json.load(sys.stdin).get("transcript_path", ""))
|
||||
except Exception:
|
||||
print("")
|
||||
' 2>/dev/null)"
|
||||
[ -z "$transcript" ] || [ ! -f "$transcript" ] && exit 0
|
||||
|
||||
# 抽「我這回合最後的 assistant 文字」(與 self-drive-police.sh 同一套抽法)
|
||||
last_text="$(python3 -c '
|
||||
import sys, json
|
||||
path = sys.argv[1]
|
||||
texts = []
|
||||
try:
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
for line in reversed(lines):
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
msg = ev.get("message", ev)
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
for b in content:
|
||||
if isinstance(b, dict) and b.get("type") == "text":
|
||||
texts.append(b.get("text", ""))
|
||||
elif isinstance(content, str):
|
||||
texts.append(content)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
print("\n".join(texts))
|
||||
' "$transcript" 2>/dev/null)"
|
||||
|
||||
[ -z "$last_text" ] && exit 0
|
||||
|
||||
# ── 前置閘①:沒問號就不是在問人 ──
|
||||
printf '%s' "$last_text" | grep -qE '?|\?' || exit 0
|
||||
|
||||
# ── 前置閘②:已被 grep 警察涵蓋的句型,交給它,不重複判 ──
|
||||
if printf '%s' "$last_text" | grep -qiE '要我(現在|先|繼續|開始)?[^??]{0,12}嗎|需要我[^??]{0,12}嗎|下一步(要|該)?做什麼|接下來(要)?做什麼|要不要|該不該'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 前置閘③:撞真閘的豁免語(與 self-drive-police.sh 同一套)──
|
||||
if printf '%s' "$last_text" | grep -qiE '權限閘|classifier|分類器擋|denied by|四題第|需要你放行|需要 leo 放行'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 只送最後 1200 字給判官(夠判語氣、不吃 context)
|
||||
snippet="$(printf '%s' "$last_text" | tail -c 3600)"
|
||||
|
||||
judge_prompt="你是「自走警察」的語意判官。下面是一個 AI 助理(總管)準備結束回合時對老闆 leo 說的話。
|
||||
|
||||
判斷它是否在**把一個自己該裁的決定推回給 leo**。
|
||||
|
||||
判準只有一條——四題人閘公式。它問的事情是否命中下列任一:
|
||||
① 花錢(付費、開資源、產生帳單、明顯多耗訂閱額度)
|
||||
② 不可逆/難回收(刪資料、push 到 main、部署上線、對外公開、跨 repo 搬遷)
|
||||
③ 跨專案結構決策(改全體共用框架的架構、改 repo 歸屬、立新鐵律)
|
||||
④ 品味/方向(產品要不要某功能、UX 取捨——leo 的主觀偏好)
|
||||
⑤ 物理人閘(只有人能做:貼憑證、終端機同意、平台上按批准)
|
||||
|
||||
命中任一 → decision=allow(它該問)。
|
||||
四題全否,而它仍在徵詢、確認、討好、或丟空白選擇題 → decision=block。
|
||||
|
||||
注意:
|
||||
- 單純**回報**「我做了 X,發現 Y」即使句尾有問號式修辭,也 allow。
|
||||
- 提供選項但**已明說自己的建議與理由**、只等一個詞確認 → 若命中四題則 allow;四題全否仍是 block。
|
||||
- 只輸出 JSON,不要任何其他文字。
|
||||
|
||||
輸出格式:
|
||||
{\"decision\":\"allow\"或\"block\",\"reason\":\"一句正體中文,若 block 要指出它在問什麼、以及四題為何全否\"}
|
||||
|
||||
--- 助理說的話 ---
|
||||
$snippet
|
||||
--- 結束 ---"
|
||||
|
||||
# ⚠️ 判官必須跑在「中性目錄 + --safe-mode」:
|
||||
# 實測(2026-08-05):直接在專案內跑 `claude -p` → **54 秒**,因為內層把整個專案的
|
||||
# SessionStart recall(數十萬字)、18 支 hook、40 個 arcrun MCP 工具全載進去,
|
||||
# 還會拿專案脈絡去回答判官的問題(實測它跑去讀 self-drive-judge.sh)。
|
||||
# 換成 `cd /tmp` + `--safe-mode`(關閉全部客製化)→ **8 秒**。
|
||||
# 判官只需要判一段文字,不需要專案脈絡;載進來只會又慢又汙染判斷。
|
||||
verdict="$(cd /tmp && SELF_DRIVE_JUDGE=1 printf '%s' "$judge_prompt" \
|
||||
| (cd /tmp && SELF_DRIVE_JUDGE=1 claude -p --safe-mode --model haiku --allowedTools "" 2>/dev/null))"
|
||||
|
||||
# 判官掛了/回傳空/不是 JSON → fail-open
|
||||
[ -z "$verdict" ] && exit 0
|
||||
|
||||
decision="$(printf '%s' "$verdict" | python3 -c '
|
||||
import sys, json, re
|
||||
raw = sys.stdin.read()
|
||||
m = re.search(r"\{.*\}", raw, re.S)
|
||||
if not m:
|
||||
print("allow|"); raise SystemExit
|
||||
try:
|
||||
d = json.loads(m.group(0))
|
||||
print((d.get("decision") or "allow") + "|" + (d.get("reason") or ""))
|
||||
except Exception:
|
||||
print("allow|")
|
||||
' 2>/dev/null)"
|
||||
|
||||
case "$decision" in
|
||||
block\|*)
|
||||
reason="${decision#block|}"
|
||||
cat >&2 <<EOF
|
||||
🚓 自走警察【語意判官】:你在徵詢 leo,但這題**四題全否**——那就是你自己該裁的。
|
||||
|
||||
判官的話:$reason
|
||||
|
||||
【leo 2026-08-04】「不要停下來問要不要繼續,**這是前面明說要做的事**」
|
||||
【藍圖 v3 §5.5】問題跟記憶走同一種階梯:先窮盡已有答案,答不出才升一級。
|
||||
到 leo 是**最後一級**,不是預設一級。
|
||||
|
||||
送出前先在這四處找答案(找到就別問,直接做):
|
||||
A. leo 這個 session 說過的話——他是不是已經給了優先序/清單/「開工」?
|
||||
B. root.md/journeys.md/派工表——下一站就是答案
|
||||
C. decisions / ADR / pending-changes 已裁決區——這個做法定案過嗎?
|
||||
D. KBDB 語意搜尋——換句話說的同一題(grep 猜不中用詞時的保險)
|
||||
|
||||
📌 真要升級到 leo,格式是:「<檔案:行> 記錄 X,我判斷走 A 因為 Y,回『好』我就繼續」
|
||||
——裸問句、空白選擇題=視同沒查。
|
||||
EOF
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# self-drive-police.sh — 自走警察(Stop hook)
|
||||
#
|
||||
# 病根(leo 2026-06-29 一個 session 點破三次):我「做一條停一條」「把該自己做的推給 leo」
|
||||
# ——不是缺 loop,是每個決策點退回「推給 leo」的省事預設。CLAUDE.md 寫規則沒用(我會不讀)。
|
||||
# leo 的解法:在我「要停下來」那一刻攔截 + 反問,逼我先窮盡查證才准停。
|
||||
# 靈感=leo 之前做的「Haiku 警察 Anna」(檢查回話、犯規就抓)。先用輕的(純 bash grep),不夠再升級。
|
||||
#
|
||||
# 機制(官方 Stop hook,核實 code.claude.com/docs/en/hooks):
|
||||
# Claude 結束回合想停 → 本 hook 觸發 → 讀 transcript 我這回合輸出 → grep 三類推卸句式:
|
||||
# ① 憑記憶問「要不要/該選/哪個」 → 反問「查 wiki/internet 官方了嗎」
|
||||
# ② 問「下一步/接下來做什麼」 → 反問「查 SDD/派工表/北極星了嗎」
|
||||
# ③ 推卸「交給你/這是你的/你去開」 → 反問「規劃做完了嗎、能 spawn subagent 嗎、為何停」
|
||||
# 命中 → exit 2 + stderr(reason) → Claude 不停、收到反問繼續做。
|
||||
# 沒命中 → exit 0 放行(真的該停,不擾)。
|
||||
#
|
||||
# 防無限迴圈(官方):stop_hook_active=true(已 block 過一輪)→ 放行。
|
||||
# + Claude Code 內建 block cap(連續 8 次自動放行)。
|
||||
#
|
||||
# 誠實限制:靠句式 grep,擋得住「明顯推卸句」,擋不住「換句話說的推卸」。是底線不是萬能。
|
||||
# 不夠時升級成 type:prompt(Haiku 判語意) 或 type:agent(查 wiki/SDD 客觀驗),見 hook 末註。
|
||||
|
||||
input="$(cat)"
|
||||
|
||||
# 防無限迴圈:已經 block 過一輪(Claude 正在回應上一次 block)→ 放行
|
||||
stop_active="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
print(json.load(sys.stdin).get("stop_hook_active", False))
|
||||
except Exception:
|
||||
print(False)
|
||||
' 2>/dev/null)"
|
||||
if [ "$stop_active" = "True" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 取 transcript 路徑
|
||||
transcript="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
print(json.load(sys.stdin).get("transcript_path", ""))
|
||||
except Exception:
|
||||
print("")
|
||||
' 2>/dev/null)"
|
||||
|
||||
[ -z "$transcript" ] || [ ! -f "$transcript" ] && exit 0
|
||||
|
||||
# 抽「我這回合最後的 assistant 文字輸出」(transcript 是 JSONL,每行一事件)
|
||||
# 取最後一個 assistant 訊息的 text,避免掃到歷史
|
||||
last_text="$(python3 -c '
|
||||
import sys, json
|
||||
path = sys.argv[1]
|
||||
texts = []
|
||||
try:
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
# 從尾往前找最後一個 assistant 訊息的 text 內容
|
||||
for line in reversed(lines):
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
msg = ev.get("message", ev)
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
for b in content:
|
||||
if isinstance(b, dict) and b.get("type") == "text":
|
||||
texts.append(b.get("text", ""))
|
||||
elif isinstance(content, str):
|
||||
texts.append(content)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
print("\n".join(texts))
|
||||
' "$transcript" 2>/dev/null)"
|
||||
|
||||
[ -z "$last_text" ] && exit 0
|
||||
|
||||
# ── 豁免:撞真閘就別逼它做不可能的事(2026-07-22 補)──
|
||||
# 病例:雲端總管有 token、備好 patch,但 harness classifier 擋掉 mutating 寫入 →
|
||||
# 舊版警察會抓「Which way do you want it?」逼它繼續,它撞牆到 block cap 才放行=空轉。
|
||||
# 明說「四題第X命中」也放行(本 hook 註解原本就要求這格式,但沒實作豁免)。
|
||||
if printf '%s' "$last_text" | grep -qiE '權限閘|classifier|分類器擋|denied by|四題第|需要你放行|需要 leo 放行'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 三類推卸句式偵測 ──────────────────────────────────────────────
|
||||
# ③ 推卸給 leo(最該抓)——「交給你/這是你的/你去/請你去開/leo 去做」
|
||||
if printf '%s' "$last_text" | grep -qiE '交給(你|leo)|這是你的|是你的工作|你(去|來)(開|做|跑|查|執行)|請你去|leo (去|來)(開|做)|由你(去|來)|該你'; then
|
||||
cat >&2 << 'EOF'
|
||||
🚓 自走警察:偵測到「推卸給 leo」。停下前先答這三題:
|
||||
1. 所有規劃工作做完了嗎?(查 autonomy-dispatch/tasks.md 派工表 + 北極星)
|
||||
2. 這件事我能不能 spawn 一個 subagent 去做?(子 repo 寫 code 走 guard 白名單 CHILD_SESSION;偵察/文書/診斷都能 spawn)
|
||||
3. 為什麼要停下來?——只有四題命中(花錢/不可逆/品味方向/物理人閘如 terminal consent、merge 批准)才輪到 leo。
|
||||
「做 vs 問」是假二選一,真三選一:自己做 / spawn subagent 做 / 才輪到 leo。叫 leo 當人肉中繼=親手裝回剛拔掉的瓶頸。
|
||||
若確認真四題命中才需 leo,下一回合明說「四題第X命中:<理由>」再停。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ② 問「下一步做什麼」——答案在規劃文件裡
|
||||
if printf '%s' "$last_text" | grep -qiE '下一步(要|該)?做什麼|接下來(要)?做什麼|接下來呢|要做什麼好|現在(要|該)做什麼'; then
|
||||
cat >&2 << 'EOF'
|
||||
🚓 自走警察:偵測到「問下一步做什麼」。答案多半在規劃文件裡,先查再說:
|
||||
1. 查 SDD 了嗎?(system-dev/docs/3-specs/*/tasks.md — 唯一進度來源)
|
||||
2. 查派工表了嗎?(system-dev/docs/3-specs/autonomy-dispatch/tasks.md — issue 開哪、誰做、狀態)
|
||||
3. 查北極星了嗎?(system-dev/docs/6-user/20260627-northstar.md §8.4 順序)
|
||||
查完下一條自然浮現,自己往前推,別把判斷推給 leo。若真查過且確認無下一條/撞人類閘,下一回合明說再停。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ⓪ 🔴 請示句:「要我 X 嗎/我可以開始嗎/需要我 X 嗎」——**已交代過的事不必再請示**
|
||||
# 2026-08-04 leo 兩度點破:「不要停下來問要不要繼續,**這是前面明說要做的事**」
|
||||
# 「都列表了你還要問這個問題?」「**就是個自問自答功能,你問的問題明明有答案**」
|
||||
# ⇒ 舊 ① 只認「要不要/該選哪個」,**漏掉最常見的請示句型「要我…嗎」**。
|
||||
# 判準不是句型漂亮與否,而是:**這個問題的答案,是不是已經存在於既有資料裡?**
|
||||
if printf '%s' "$last_text" | grep -qiE '要我(現在|先|繼續|開始)?[^??]{0,12}嗎|需要我[^??]{0,12}嗎|我(現在|可以|該)(開始|繼續|動手)[^??]{0,8}嗎|(繼續|開工|動手)嗎'; then
|
||||
cat >&2 << 'EOF'
|
||||
🚓 自走警察(請示句):你在問「要我 X 嗎」——**先自問自答:這個問題有答案嗎?**
|
||||
|
||||
【leo 2026-08-04】「不要停下來問要不要繼續,**這是前面明說要做的事**」
|
||||
「都列表了你還要問這個問題?」
|
||||
「**就是個自問自答功能,你問的問題明明有答案,應該就可以找到答案了**」
|
||||
|
||||
━━━ 送出前先在這三處找答案(找到就別問,直接做)━━━
|
||||
A. **leo 這個 session 說過的話**——他是不是已經給了優先序/清單/「開工」?
|
||||
(已交代過的事再請示=把他當按鈕按,正是自走規則要拔掉的瓶頸)
|
||||
B. **你自己剛列的 todo/任務表**——下一項就是答案,照著做
|
||||
C. **wiki/tasks.md**——`system-dev/wiki/status.md`、對應 tasks.md 有沒有定案
|
||||
|
||||
━━━ 只有這種情況才可以問(且要明說已查過)━━━
|
||||
真命中四題人閘:花錢/不可逆(push main、刪資料、部署上線)/
|
||||
跨專案結構決策/品味方向。**其餘一律自己裁、往前推,錯了被擋回再修。**
|
||||
|
||||
📌 想收尾時不要問「要我繼續嗎」,直接**做下一項並回報做了什麼**。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ① 憑記憶問「要不要/該選哪個」——先查證再決定
|
||||
if printf '%s' "$last_text" | grep -qiE '要不要|該不該|該選(哪|什麼)|哪個(比較)?好|選哪(個|條)|是不是要|可不可以'; then
|
||||
# 但若這回合已有查證跡象(提到查了 wiki/官方/grep/WebFetch/出處),放行——別擾正常提問
|
||||
if printf '%s' "$last_text" | grep -qiE '查(了|過)|官方(確認|核實|出處)|核實|grep|WebFetch|出處|wiki 寫|記憶(卡|寫)'; then
|
||||
exit 0
|
||||
fi
|
||||
cat >&2 << 'EOF'
|
||||
🚓 自走警察:偵測到「憑判斷問要不要/該選哪個」,但沒看到查證跡象。停下問 leo 前先窮盡:
|
||||
1. 查 wiki/記憶了嗎?(system-dev/wiki/ + MEMORY.md — 這事可能已有定案,別重議)
|
||||
2. 查 internet 官方說明了嗎?(LLM/平台/API 機制憑記憶會錯,查官方原文 + 給出處)
|
||||
查完答案多半自己浮現,四題全否就自己裁、不問 leo。若查過仍需 leo 的品味/方向判斷(第④題),下一回合明說「查過,這是品味題」再問。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 沒命中任何推卸句式 → 真的該停,放行
|
||||
exit 0
|
||||
|
||||
# ── 升級路徑(不夠用時,leo 點頭)──────────────────────────────────
|
||||
# 輕→重三級:
|
||||
# L1(現在):command bash grep 句式三分流。最省,擋明顯推卸句。
|
||||
# L2:改 type:prompt(Haiku 判語意),settings.json 寫 {"type":"prompt","prompt":"判斷這回合是否在推卸/未查證就停…"}。
|
||||
# 擋「換句話說的推卸」,但每次停叫一次 Haiku(便宜)。
|
||||
# L3:改 type:agent,spawn subagent 真查 wiki/SDD/派工表客觀驗「規劃是否做完」(像 Ralph 的 passes)。最強最貴。
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
# SessionStart hook — 開 session 就把「全局」推到眼前(InkStoneCo#17 / D69 / D70 / D71)
|
||||
#
|
||||
# 掛在 settings.json 的 SessionStart(matcher: startup|resume|clear)。stdout 會被當成 context 注入。
|
||||
#
|
||||
# ── 這支在整個機制裡的位置(leo 2026-08-11 拍板)─────────────────────────────
|
||||
# 算 = 雲端的 Arcrun 工作流 `global_index`(讀 Gitea 五個 repo 的票 + 讀 KBDB 藏書地圖)
|
||||
# 存 = 雲端(總圖住雲端,不推回地端;沒有東西需要同步,所以沒有漂移源)
|
||||
# 拿 = 本檔。**由機制保證取一次**,不靠 AI 記得去打 MCP
|
||||
# (openhuman SuperContext 的教訓:靠模型自己選擇要不要查,就是會漏)
|
||||
#
|
||||
# ⚠️ 例外=下面 push 2/5 的「各 repo wiki 全景」:那段**算在地端**。
|
||||
# 不是不想放雲端,是雲端拿不到 repo 樹(只有 API 列檔一條路,撞 principles 紅線)。
|
||||
#
|
||||
# ── 為什麼不再 cat status.md ────────────────────────────────────────────────
|
||||
# 2026-08-11 實測:舊版這支吐 235,880 bytes(因為它 cat 整份 216 KB 的落帳流水),
|
||||
# 真正進到 context 的只有 2 KB 預覽 + 一個要自己去 Read 的檔名。
|
||||
# ⇒ 「開場就知道」等於沒發生。**注入通道的預算是有限的,落帳流水不該佔著它。**
|
||||
# 進度/狀態一律去 Gitea(D52 + D72),不在地端養第二份會漂的快照。
|
||||
#
|
||||
# 順序=重要性:全局總圖最前面(它最可能被截斷保護到),其次原則,最後踩坑標題。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GLOBAL_INDEX_URL="https://arcrun-cypher-executor.leo21c.workers.dev/webhooks/named/leo/global_index/query"
|
||||
PANORAMA_FILE="system-dev/wiki/PANORAMA.md"
|
||||
PRINCIPLES_FILE="system-dev/wiki/principles.md"
|
||||
MISTAKES_FILE="system-dev/wiki/mistakes.md"
|
||||
MISTAKES_TAIL=20 # 只推最近 N 條標題(全份 96 條/11 KB 會吃掉總圖的預算)
|
||||
|
||||
# ── push 1/5:全局總圖(雲端現算)────────────────────────────────────────────
|
||||
# 降級規格(D71,票與知識不對稱):
|
||||
# 算得出來 → 注入
|
||||
# 算不出來/破損/空 → **完全不注入內容**,只誠實說一句「這次沒有」
|
||||
# (寧可冷啟動,不要注入垃圾;票尤其不能給一份看起來像現況的舊東西)
|
||||
GLOBAL_MD=""
|
||||
GLOBAL_ERR=""
|
||||
if RESP=$(curl -sS -m 45 -X POST "$GLOBAL_INDEX_URL" \
|
||||
-H 'Content-Type: application/json' -d '{}' 2>/dev/null); then
|
||||
GLOBAL_MD=$(printf '%s' "$RESP" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
def find_md(x, depth=0):
|
||||
# 工作流的最終輸出可能被包一層 data(code 零件的回應信封),兩種形狀都收
|
||||
if depth > 3 or not isinstance(x, dict):
|
||||
return None
|
||||
if x.get("success") is True and isinstance(x.get("md"), str) and x["md"].strip():
|
||||
return x["md"]
|
||||
return find_md(x.get("data"), depth + 1)
|
||||
md = find_md(d)
|
||||
if md:
|
||||
sys.stdout.write(md)
|
||||
' 2>/dev/null || true)
|
||||
if [ -z "$GLOBAL_MD" ]; then
|
||||
GLOBAL_ERR=$(printf '%s' "$RESP" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.stdout.write("回應不是 JSON"); sys.exit(0)
|
||||
sys.stdout.write(str((d or {}).get("error") or "工作流沒有交出 md")[:200])
|
||||
' 2>/dev/null || echo "回應解析失敗")
|
||||
fi
|
||||
else
|
||||
GLOBAL_ERR="連不上雲端(curl 失敗或逾時 45 秒)"
|
||||
fi
|
||||
|
||||
echo "════════════════════════════════════════════════"
|
||||
if [ -n "$GLOBAL_MD" ]; then
|
||||
printf '%s\n' "$GLOBAL_MD"
|
||||
else
|
||||
echo "⚠️ 這次拿不到全局總圖:${GLOBAL_ERR}"
|
||||
echo " 刻意不給你一份舊的——票的狀態放舊的比沒有更危險(會去重做已經做完的事)。"
|
||||
echo " ⇒ 你現在**不知道有哪些票、庫裡有哪些知識**。動手前先自己撈:"
|
||||
echo " Gitea:GET https://git.uncle6.me/api/v1/repos/Leo/<repo>/issues?state=open"
|
||||
echo " (五個 repo:InkStoneCo / Arcrun / arcrun-rag / mira / system-dev-template)"
|
||||
echo " 知識:kbdb_get_map()"
|
||||
echo " 修這條管路:工作流 leo:wf:global_index(雲端),本 hook 只負責取來印。"
|
||||
fi
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# ── push 2/5:各 repo 的 wiki 全景(leo 2026-08-12「一次看到全景」的另一半)──
|
||||
# 上面那段是**票**(雲端現算)+ KBDB 藏書地圖;這段是**各 repo 真正的 wiki**。
|
||||
# 為什麼不塞進雲端那支工作流:工作流跑在 Cloudflare 上,要拿 repo 樹只有
|
||||
# 「Gitea contents API 列檔」一條路,而 principles 紅線寫死
|
||||
# 「讀 repo 走 git clone/fetch,不走 API 列檔」。
|
||||
# ⇒ 在有 git 的地方算好(`scripts/wiki-panorama.sh`,人發起、不輪詢),
|
||||
# 存成 repo 裡的一份 md,本檔只負責印。
|
||||
# 只印到 `panorama:inject-end` 為止——標記以下是給 grep 的完整卡片清單,
|
||||
# 全倒進來會撐爆 context(全檔 ~28 KB,注入段 ~6 KB)。
|
||||
if [ -f "$PANORAMA_FILE" ]; then
|
||||
echo "════════════════════════════════════════════════"
|
||||
awk '/panorama:inject-end/{exit} {print}' "$PANORAMA_FILE"
|
||||
# 過期就講出來(不自動重算:重算要連網 clone 十幾個 repo,不該卡在開場)
|
||||
P_GEN=$(grep -m1 -oE '[0-9]{4}-[0-9]{2}-[0-9]{2} 產生' "$PANORAMA_FILE" | cut -d' ' -f1 || true)
|
||||
W_LAST=$(git log -1 --format=%ad --date=short -- system-dev/wiki 2>/dev/null || true)
|
||||
if [ -n "$P_GEN" ] && [ -n "$W_LAST" ] && [ "$W_LAST" \> "$P_GEN" ]; then
|
||||
echo ""
|
||||
echo "⚠️ 這張全景圖是 ${P_GEN} 算的,但 wiki 在 ${W_LAST} 又被改過 ⇒ 它已經不準了。"
|
||||
echo " 重算:bash scripts/wiki-panorama.sh --write"
|
||||
fi
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo ""
|
||||
else
|
||||
echo "────────────────────────────────────────────────"
|
||||
echo "⚠️ 沒有各 repo 的 wiki 全景圖($PANORAMA_FILE)⇒ 你**不知道哪些知識記在哪個 repo**。"
|
||||
echo " 產生(約一分鐘,git clone 各 repo 的 wiki 目錄,不走 API):"
|
||||
echo " bash scripts/wiki-panorama.sh --write"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── push 3/5:進度不在地端(D72)────────────────────────────────────────────
|
||||
echo "────────────────────────────────────────────────"
|
||||
echo "📍 進度/狀態一律去 Gitea 撈,不要在任何檔案裡找(D52 + D72)"
|
||||
echo " 等 leo = labels=s/stage | 可開工 = s/todo | 進行中 = s/doing"
|
||||
echo " 知識庫(system-dev/wiki/)只放**事實**:踩過的坑、決策、憑證在哪。**不放進度**。"
|
||||
echo " 🔴 不要在對話或任何檔案裡另養一份「等 leo 的清單」——它一定會漂。"
|
||||
echo ""
|
||||
|
||||
# ── push 4/5:principles(全文,行動前必服從)────────────────────────────────
|
||||
if [ -f "$PRINCIPLES_FILE" ] && grep -q '^- ' "$PRINCIPLES_FILE" 2>/dev/null; then
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo "📐 設計原則(行動前必服從,來自 principles.md)"
|
||||
echo "════════════════════════════════════════════════"
|
||||
grep '^- ' "$PRINCIPLES_FILE"
|
||||
P_COUNT=$(grep -c '^- ' "$PRINCIPLES_FILE")
|
||||
if [ "$P_COUNT" -gt 15 ]; then
|
||||
echo ""
|
||||
echo "(⚠️ principles 已 ${P_COUNT} 條 > 15:請考慮合併相近原則、或把較細的下放成 card)"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── push 5/5:mistakes(只推最近 N 條標題)──────────────────────────────────
|
||||
if [ -f "$MISTAKES_FILE" ] && grep -q 'MISTAKE' "$MISTAKES_FILE" 2>/dev/null; then
|
||||
M_TOTAL=$(grep -cE 'MISTAKE' "$MISTAKES_FILE")
|
||||
echo "────────────────────────────────────────────────"
|
||||
echo "⚠️ 已知踩坑:最近 ${MISTAKES_TAIL} 條(全部 ${M_TOTAL} 條在 $MISTAKES_FILE,撞到就 grep 全文)"
|
||||
grep -E 'MISTAKE' "$MISTAKES_FILE" | tail -n "$MISTAKES_TAIL" | sed 's/^/ /'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "────────────────────────────────────────────────"
|
||||
echo "以上是 index,不是內容——它只讓你知道「有這件事、去哪裡找」。"
|
||||
echo "要完整脈絡(decisions / mistakes / SDD)→ 執行 /wiki-recall"
|
||||
echo "════════════════════════════════════════════════"
|
||||
|
||||
exit 0
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook — 影子表警察:抓 D91 的指紋(宣告 schema 在一邊,資料倒進 JSON 團在另一邊)
|
||||
#
|
||||
# 🚫 **本檔目前是死的:沒有註冊進 .claude/settings.json,不會被執行。**
|
||||
# leo 2026-08-15:「**我是要你提案封鎖方式,不是要你就去做,急着做造成太多疊牀架屋了。**」
|
||||
# ⇒ 總管在討論「疊牀架屋是病」的同時去疊了這一層。已測過(該擋兩條擋、六條不該擋全放),
|
||||
# 但**要不要掛由 leo 裁**——它只在「過渡期止血」這一個用途上值得存在。
|
||||
# ⇒ 真正的封鎖方式是**刪掉 metadata_json 與 entry_type 兩個欄位**(減,不是加),
|
||||
# 提案見 matrix/arcrun 的 pending-changes.md。
|
||||
#
|
||||
# ⚠️ 這支是**過渡期的止血帶,不是答案**(leo 2026-08-15 定調)。
|
||||
# 真正的解是「**刪掉那兩個欄位,讓儲存層沒有位置放壞東西**」——提案在 matrix/arcrun 的 pending-changes.md。
|
||||
# 偵測永遠落後一步:D91 就是從「多開一張表」變異成「JSON 團」的。
|
||||
# 在收窄的 API 出來之前,這支至少擋得住已知的那個形狀。**別因為有閘就以為安全。**
|
||||
#
|
||||
# 【為什麼 kbdb-api-wall-guard 抓不到】
|
||||
# 那支守著 D38「三張表打天下,永不加表」,而 D91 **完全沒有違反它**——一張表都沒加。
|
||||
# 它做的是:seed 一張宣告 6 個欄位的 template,然後把資料倒進 metadata_json。
|
||||
# ⇒ **一張沒人管得到的影子表,而表數是三。現有的閘數表,而病不在表數上。**
|
||||
#
|
||||
# 【病徵】kbdb/migrations/0004_execution_log_template.sql 自己的註解就寫著
|
||||
# 「template 這裡只負責 schema 文件化⋯⋯結構化欄位打包進 metadata_json」
|
||||
# ⇒ 那張宣告了 6 個欄位的 template,沒有任何東西讀它。
|
||||
# 【擴散】同註解:「recipe_stat 早已示範這個模式合法」
|
||||
# ⇒ 活著的 7 種 entry_type 裡有 6 種是這形狀——不是角落,是多數。
|
||||
#
|
||||
# 攔的是**形狀不是關鍵字**:型別標籤與 JSON 團打包必須**同時**出現才命中
|
||||
# (今天已有四次閘因為比對指令形狀而誤攔,不再犯)。註解、測試、文件一律放行。
|
||||
# 豁免:該行尾加 `shadow-table-ok`(留痕,commit 說明理由)。
|
||||
#
|
||||
# 全文:頂層 system-dev/wiki/decisions-summary.md D91、D92。
|
||||
|
||||
exec python3 -c '
|
||||
import json, re, sys
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
if d.get("tool_name") not in ("Write", "Edit", "MultiEdit"):
|
||||
sys.exit(0)
|
||||
|
||||
i = d.get("tool_input") or {}
|
||||
path = i.get("file_path") or ""
|
||||
if not path:
|
||||
sys.exit(0)
|
||||
|
||||
parts = [i.get("content") or "", i.get("new_string") or ""]
|
||||
for e in (i.get("edits") or []):
|
||||
parts.append(e.get("new_string") or "")
|
||||
body = "\n".join(p for p in parts if p)
|
||||
if not body:
|
||||
sys.exit(0)
|
||||
|
||||
low = path.lower()
|
||||
if re.search(r"\.(md|txt|json|yaml|yml)$", low): sys.exit(0)
|
||||
if re.search(r"(^|/)(wiki|docs|tests?)/", low): sys.exit(0)
|
||||
if re.search(r"(test|spec)\.[a-z]+$|_test\.go$", low): sys.exit(0)
|
||||
if not re.search(r"\.(ts|js|mjs|sql|go)$", low): sys.exit(0)
|
||||
|
||||
live = []
|
||||
for line in body.splitlines():
|
||||
if "shadow-table-ok" in line:
|
||||
continue
|
||||
s = re.sub(r"--.*$", "", line)
|
||||
s = re.sub(r"//.*$", "", s)
|
||||
if re.match(r"\s*[*#]", s):
|
||||
continue
|
||||
live.append(s)
|
||||
live = "\n".join(live)
|
||||
|
||||
# A:把型別標籤釘在 entries 上
|
||||
a = len(re.findall(r"""entry_type\s*[:=]\s*[\x27"][a-z_]+[\x27"]""", live))
|
||||
# B:結構化欄位被打包進 JSON 團
|
||||
b = len(re.findall(r"""metadata_json[^\n]{0,80}(JSON\.stringify|json_object|\{)|(JSON\.stringify|json_object)\([^\n]{0,80}metadata_json""", live))
|
||||
# C:seed 一張宣告了欄位的 template
|
||||
c = len(re.findall(r"""INSERT\s+(OR\s+IGNORE\s+)?INTO\s+templates\b[^;]{0,200}slots_json|slots_json\s*[:=]""", live, re.I | re.S))
|
||||
|
||||
if not ((a and b) or (c and b)):
|
||||
sys.exit(0)
|
||||
|
||||
sys.stderr.write(f"""❌ BLOCKED — 影子表警察(D91 的指紋)
|
||||
|
||||
檔案:{path}
|
||||
命中:型別標籤 {a} 處/JSON 團打包 {b} 處/template 宣告 {c} 處
|
||||
|
||||
你正在寫的形狀是:**宣告一個 schema,然後把資料倒進 metadata_json**。
|
||||
那會做出一張**沒人管得到的影子表**——表數還是三,但欄位從此改不動:
|
||||
要加一欄,得同時改「一個沒人讀的宣告」和「一段打包程式」,
|
||||
而**沒有任何機制會告訴你它們已經對不上了**。
|
||||
|
||||
leo 2026-08-15:「這每個都是一個 template⋯⋯**以後你不能修改 fields,因為表禁止動**,
|
||||
如果要多一欄,要不就是說要改表,**要不就是全塞在能塞的格子裡**。」
|
||||
|
||||
先回答這三題,答不出來就不是這樣寫:
|
||||
① 這些被打包的欄位,**誰會讀 schema 宣告**?沒有人讀 ⇒ 那個宣告是裝飾品
|
||||
② 要加第八個欄位時,**有什麼東西會擋住「只改了一半」**?
|
||||
③ 這批資料的形狀是**我們決定**的,還是**使用者的想法**決定的?
|
||||
使用者決定 ⇒ 不准有 schema,走 v-table
|
||||
(Arcrun#133 的判準:這張表要改的時候,誰付 migration 的代價?)
|
||||
|
||||
真的有理由這樣寫(例:那條路徑的寫入放大有實測數字,且已在票上記錄)
|
||||
⇒ 該行尾加 `shadow-table-ok`,並在 commit 說明理由。
|
||||
|
||||
⚠️ 本閘是過渡期止血帶。真正的解是刪掉 metadata_json 與 entry_type(減,不是加)。
|
||||
|
||||
參考:system-dev/wiki/decisions-summary.md **D91**(病徵與擴散)、**D92**(同族形狀)
|
||||
""")
|
||||
sys.exit(2)
|
||||
'
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# skill-deploy-drift-guard.sh — SessionStart:全機載入的那套 skill 跟它的正本對不上時,講出來
|
||||
#
|
||||
# 為什麼存在(2026-08-16 查出來的實況):
|
||||
# `arcrun-kbdb-guardrails` 這套 skill 有兩份——
|
||||
# 正本 arcrun_harness/.claude/skills/arcrun-kbdb-guardrails/ 有版控
|
||||
# 產物 ~/.claude/skills/arcrun-kbdb-guardrails/ **全機每個專案真正載入的是這份**
|
||||
# 它們已經分家過一次,而且不是「一份新一份舊」:各自有對方沒有的東西。
|
||||
# 家目錄那份長出了一整層 D91 影子表防守,**從來沒進過 git**——差一點就在同步時被蓋掉。
|
||||
# 反方向也發生過:kbdb guard 的誤擋 bug 修好過,但修在別的拷貝上,
|
||||
# 於是它透過這份沒同步的產物又長回來(Leo/InkStoneCo#23,同一個形狀第 7 次)。
|
||||
#
|
||||
# 🔑 這支 hook 的用途不是同步,是**讓「它們又不一樣了」這件事有人知道**。
|
||||
# 同步是 `arcrun_harness/scripts/skill-deploy.sh` 的事,判斷也在那支裡;
|
||||
# 本檔**刻意只是一層殼**:沒有自己的邏輯,就沒有自己的漂移。
|
||||
#
|
||||
# 三條設計約束:
|
||||
# 1. 沒事不出聲——乾淨時完全靜音,才不會被當成背景雜訊而被無視。
|
||||
# 2. 從不擋人——SessionStart 一律 exit 0,這是通知,不是閘。
|
||||
# 3. 環境不在就安靜退場——沒有 harness repo(雲端 session、別台機器)不該報錯。
|
||||
#
|
||||
# ⚠️ 已知範圍限制:本 hook 掛在 InkStoneCo 的 SessionStart,所以只有「在 InkStoneCo 開
|
||||
# session」時會檢查。那份 skill 是全機載入的,在別的專案裡編輯它一樣會漂,只是要等
|
||||
# 下次有人開 InkStoneCo 才會被指出來。之所以先這樣,是因為改 ~/.claude/settings.json
|
||||
# 等於動全機共用框架——那是要 leo 點頭的事,不是這支腳本自己能決定的。
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
DEPLOY_SH="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}/arcrun_harness/scripts/skill-deploy.sh"
|
||||
|
||||
# 環境不在 → 安靜退場(約束 3)
|
||||
[ -f "$DEPLOY_SH" ] || exit 0
|
||||
|
||||
OUT="$(bash "$DEPLOY_SH" check 2>&1)" && exit 0 # 乾淨 → 一個字都不印(約束 1)
|
||||
|
||||
# 走到這裡代表有漂移。SessionStart 用 stdout 把訊息帶進脈絡。
|
||||
cat <<EOF
|
||||
🧬 skill 漂移警報:\`arcrun-kbdb-guardrails\` 的正本與「全機實際載入的那份」對不上。
|
||||
|
||||
$OUT
|
||||
|
||||
先讀懂標籤再動手,**不要反射性地 deploy**:
|
||||
TAMPERED / FORKED 🔴 產物端有版控裡沒有的修改。直接部署會把它們永久刪掉——
|
||||
先把那些修改收回正本(2026-08-15 D91 那層防守就是這樣差點沒的)。
|
||||
STALE 正本前進了、產物沒跟上 → bash arcrun_harness/scripts/skill-deploy.sh deploy
|
||||
EXTRA 產物端多出來的檔。deploy 不會清,要不要刪由人決定。
|
||||
|
||||
🔑 為什麼這件事值得在開工時就講:這份產物是**模型真正讀到的教材**。
|
||||
它落後的時候不會報錯、不會變慢、不會有任何症狀——
|
||||
只會安靜地教錯的東西,而且錯得很有權威。
|
||||
EOF
|
||||
exit 0
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/bin/sh
|
||||
# stage-before-prod-guard.sh — PreToolUse(Bash):**未經 stage 驗過,不准動 prod 出貨鏈**。
|
||||
#
|
||||
# 🔴 立這道閘的來由(leo 2026-08-08):
|
||||
# 「現在因為**開始封測**,不直接打到 prod,而是先打到昨天建的 stage,你知道嗎?
|
||||
# 或是**有在哪裏寫了讓你不會直接推 prod**?因為**推 prod 就發佈了**,
|
||||
# 雖然現在人不多,但**要謹慎**。」
|
||||
#
|
||||
# 查證結果(總管 2026-08-08 實查,這道閘因此才有必要):
|
||||
# ① wiki 有 stage 的記載(08-07 建的:staging bundle repo 在 Gitea
|
||||
# `arcrun-rag-bundles-staging`、安裝器 `arcrun-rag-installer-staging.uncle6-me.workers.dev`)
|
||||
# ② **但 `ship-check` skill 的出貨步驟從頭到尾寫的是 prod**
|
||||
# (github-arm → GitHub `arcrun-rag-bundles` → jsDelivr/raw 驗證),
|
||||
# **沒有任何一步是「先上 stage 驗過再上 prod」**
|
||||
# ③ **沒有任何 hook 擋 prod**。D20 那道擋的是「寫 GitHub」,不是「未經 stage 就發佈」——
|
||||
# arm 一開,照樣可以直達 prod
|
||||
# ⇒ 也就是「知道 stage 存在」對行為**零作用**。這是同一天第四次同款:
|
||||
# 規則/知識存在,但沒有牙齒(前三次:history-first、KBDB-first、派工-first)。
|
||||
#
|
||||
# 兩道條件,**缺一不可**(leo 2026-08-08 把層級拉高後的形狀):
|
||||
# ① `.github-armed` 存在=leo 親手解過保險。**這道我造不出來,也不准去造。**
|
||||
# leo:「現在到 github 就是同 arm,推到 prod 也應該視同 arm。」
|
||||
# ② `/tmp/.stage-verified` 6 小時內=stage 驗過(要在回覆貼實測輸出,
|
||||
# 「我測過了」不算)。這道是我的責任,但它**不能替代 ①**。
|
||||
set -eu
|
||||
|
||||
INPUT="$(cat)"
|
||||
CMD=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || echo "")
|
||||
[ -z "$CMD" ] && exit 0
|
||||
|
||||
# 只攔「會讓封測者拿到東西」的動作:prod bundle repo、開 GitHub 保險、prod 安裝器部署。
|
||||
# staging 的同名動作要放行——所以先排除帶 staging 字樣的命令。
|
||||
case "$CMD" in
|
||||
*staging*|*-staging*) exit 0 ;;
|
||||
esac
|
||||
# 🔴 2026-08-08:本閘上線後**連續誤攔自己兩次**,兩次都是同一個病——
|
||||
# 只比對「命令字串裡有沒有出現關鍵字」,把**談論**當成**執行**:
|
||||
# ① `sed -n '1,40p' scripts/github-arm.sh` =我只是要**讀**那支腳本
|
||||
# ② `git commit -m "...照 scripts/github-arm.sh 解保險..."` =關鍵字在**commit 訊息**裡
|
||||
# ⇒ **誤攔比漏攔更容易殺死一道閘**:被擋得莫名其妙,人就會想辦法繞過它。
|
||||
# ⇒ 改成「先排除明確不發佈的動作,再比對關鍵字」。
|
||||
#
|
||||
# 一律不發佈的動作(讀取、寫本地版控、查狀態)——先放行,免得關鍵字出現在訊息裡就中槍
|
||||
# 🔴 2026-08-12:這組「開頭是唯讀工具就放行」的豁免**本身是穿牆路**——與
|
||||
# prod-write-guard 同款(那支當天已修,這支漏了)。實測:
|
||||
# echo go && scripts/github-arm.sh "出貨" 30
|
||||
# 開頭是 `echo ` ⇒ 整條 exit 0 ⇒ **真的執行解保險腳本卻一聲不吭**。
|
||||
# 前面接一個 echo/cat 就能繞過任何閘。
|
||||
# ⇒ 命令裡只要出現投送工具,就不吃「開頭豁免」,交給下面的
|
||||
# HAS_PUBLISH_ACTION 逐條判(它看的是「在不在命令位置」,不是「有沒有出現」)。
|
||||
case "$CMD" in
|
||||
*"git push"*|*wrangler*|*ship.mjs*|*github-arm.sh*|*publish-*|*curl*|*wget*|*httpie*|*" acr "*) ;;
|
||||
sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *) exit 0 ;;
|
||||
*"git commit"*|*"git add"*|*"git tag"*|*"git stash"*) exit 0 ;;
|
||||
*"git status"*|*"git log"*|*"git diff"*|*"git show"*|*" --dry-run"*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 🔴 2026-08-12 第三次誤攔(同一晚第三道閘犯同一個病):
|
||||
# 上面那組豁免只認「指令**開頭**是唯讀工具」,所以
|
||||
# `BASE="https://cdn.jsdelivr.net/gh/.../arcrun-rag-bundles@<sha>"; curl -s "$BASE/manifest.json"`
|
||||
# ——一個**抓公開 CDN 檔案來查證**的純 GET——因為開頭是變數指派而被當成出貨擋下。
|
||||
# 更糟:連 `grep ... stage-before-prod-guard.sh`(**讀本閘自己的原始碼**)都被擋,
|
||||
# 因為那行指令裡出現了關鍵字 ⇒ **這道閘無法被檢查、也無法被測試**。
|
||||
# ⇒ 本閘第 44 行自己早就寫了「誤攔比漏攔更容易殺死一道閘」。這次攔到自己頭上三回。
|
||||
#
|
||||
# 真正的判準不是「有沒有提到出貨物件」,是「**這條指令有沒有真的在發佈**」。
|
||||
# 讀 manifest、讀 bundle 內容、讀腳本原始碼 —— 全是查證,那正是出貨前**該**做的事。
|
||||
HAS_PUBLISH_ACTION=false
|
||||
# 剪掉唯讀工具的 -d 旗標(tr -d/cut -d=…),免得被當成 curl 的 --data(prod-write-guard 同款教訓)
|
||||
CMD_W=$(printf '%s' "$CMD" | sed -E 's/(^|[|;&( ])(tr|cut|sort|uniq|date|xargs|paste|join|du|split|comm)[[:space:]]+-d/\1\2 __RO_D__/g')
|
||||
case "$CMD_W" in
|
||||
*"wrangler deploy"*|*"wrangler versions deploy"*|*"wrangler publish"*) HAS_PUBLISH_ACTION=true ;;
|
||||
# 🔴 2026-08-12 第六次誤攔(又是我自己的改動造成的):原本「命令含 git push
|
||||
# 就算發佈」,加上我新加的「有投送工具就不吃開頭豁免」之後,
|
||||
# **一個推 Gitea 的普通 commit,只因為 commit 訊息裡提到 github-arm,就被擋**
|
||||
# ——那正是本檔第 43 行說「已經修好」的情境(關鍵字在 commit 訊息裡)復發。
|
||||
# ⇒ `git push` 只有在**推 bundle repo** 時才是出貨;推自己的 repo 不是。
|
||||
*"git push"*)
|
||||
case "$CMD_W" in *bundles*) HAS_PUBLISH_ACTION=true ;; esac
|
||||
;;
|
||||
*"acr push"*|*"acr recipe push"*|*"acr update"*) HAS_PUBLISH_ACTION=true ;;
|
||||
*"ship.mjs"*|*"publish-mirror"*|*"publish-bundles"*) HAS_PUBLISH_ACTION=true ;;
|
||||
# 直接「執行」解保險腳本(不是讀它、不是在訊息裡提到它)
|
||||
#
|
||||
# 🔴 2026-08-12 第四次誤攔——**這條是我自己當天稍早寫的,pattern 太鬆**:
|
||||
# 原本寫 `*sh\ *github-arm.sh*`,而 glob 的 `*` 跨越整條命令
|
||||
# ⇒ 一條「讀取」指令只要**在別處出現過 "sh " 再出現 github-arm.sh** 就中:
|
||||
# echo "=== github-arm.sh 怎麼判過期 ==="; grep -nE '…' scripts/github-arm.sh
|
||||
# (前半的 `github-arm.sh ` 提供了 "sh ",後半提供了檔名)⇒ 純讀取被當成發佈。
|
||||
# ⇒ 修別人的閘時自己製造了第四次誤攔。教訓與前三次同款:
|
||||
# **判準要看「它在不在命令位置」,不是「字串有沒有出現過」。**
|
||||
*github-arm.sh*)
|
||||
# 只有出現在**命令起始位置**(行首/`;`/`|`/`&&` 之後,可帶 bash/sh 前綴)才算執行
|
||||
if printf '%s' "$CMD_W" | grep -qE '(^|[;&|]|&&|\|\|)[[:space:]]*((bash|sh|zsh)[[:space:]]+)?(\./)?([A-Za-z0-9_./-]*/)?github-arm\.sh([[:space:]]|$)'; then
|
||||
HAS_PUBLISH_ACTION=true
|
||||
fi
|
||||
;;
|
||||
# HTTP 寫入動詞
|
||||
*"-X POST"*|*"-X PUT"*|*"-X PATCH"*|*"-X DELETE"*|*"--data"*|*" -d "*) HAS_PUBLISH_ACTION=true ;;
|
||||
esac
|
||||
[ "$HAS_PUBLISH_ACTION" = true ] || exit 0
|
||||
|
||||
# 剩下的才比對「會讓封測者拿到東西」的關鍵字
|
||||
case "$CMD" in
|
||||
*arcrun-rag-bundles*|*github-arm*|*publish-github*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# ── 條件 ①:leo 親自解保險(人閘,我無法自造)─────────────────────────────
|
||||
#
|
||||
# 🔴 leo 2026-08-08:「**現在到 github 就是同 arm,推到 prod 也應該視同 arm。**」
|
||||
# ⇒ 「推 prod」與「寫 GitHub」是**同一級的動作**,都要 leo 親手解保險。
|
||||
# 先前 D20 只把 arm 綁在「寫 GitHub」上,於是「推 prod」這件事本身沒有人閘——
|
||||
# 而真正該被守住的是**發佈**,不是「用了哪個 host」。
|
||||
# 🔴 2026-08-08 差點出事:原本寫 `ARMED="$CLAUDE_PROJECT_DIR/.github-armed"`,
|
||||
# 在 CLAUDE_PROJECT_DIR 未設的環境下被 `set -u` 當場打死 ⇒ 腳本 **exit 1**。
|
||||
# 而 exit 1 **不擋**(只有 exit 2 才擋)⇒ 這道閘會**靜默放行**。
|
||||
# ⇒ 一道「失敗時自動失效」的安全閘比沒有更危險:它讓人以為有守。
|
||||
# **安全閘的預設必須是 fail-closed。**
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
ARMED="$PROJ/.github-armed"
|
||||
if [ ! -f "$ARMED" ]; then
|
||||
cat >&2 <<'AEOF'
|
||||
🔫 出貨人閘:推 prod = 發佈,**視同 arm,要 leo 親自解保險**。
|
||||
|
||||
【leo 2026-08-08】「現在到 github 就是同 arm,**推到 prod 也應該視同 arm**。」
|
||||
⇒ 該守的是「**發佈**」這個動作本身,不是「用了哪個 host」。
|
||||
|
||||
這一步**不是我能自己過的**。請 leo 整行貼:
|
||||
|
||||
~/Documents/tech_projects/InkStoneCo/scripts/github-arm.sh "<出貨說明>" 30
|
||||
|
||||
📌 在請他解保險之前,先確認 stage 已經驗過(下面那道閘),
|
||||
否則等於請他為一個沒驗過的東西按發射鈕。
|
||||
AEOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ── 條件 ②:stage 先驗過(我的責任,但不能替代條件 ①)───────────────────
|
||||
#
|
||||
# 🔴 2026-08-08 出貨當下實撞:本段**訊息與實作互相矛盾**——
|
||||
# 下面那段說明叫人 `touch /tmp/.stage-verified`,
|
||||
# 但這裡讀的是**檔案內容**當時間戳 ⇒ `touch` 產生空檔 ⇒ `cat` 空 ⇒ 退回 0
|
||||
# ⇒ `now - 0` 恆大於 6 小時 ⇒ **照它自己的說明做,永遠過不了**。
|
||||
# 這正是那天燒掉一整天的同一個病:**宣稱的做法 ≠ 真正的做法**。
|
||||
# ⇒ 改成兩者都認:內容有數字就用內容,沒有就用 mtime(`touch` 因此真的有效)。
|
||||
#
|
||||
# 另外認 leo 親手蓋的章(scripts/stage-ok.sh 產生),那是比我自評更強的訊號。
|
||||
for STAMP in /tmp/.stage-ok-by-leo /tmp/.stage-verified; do
|
||||
[ -f "$STAMP" ] || continue
|
||||
now=$(date +%s)
|
||||
# 🔴 只有「整行就是一個時間戳」才採信內容。
|
||||
# 別用 tr 抽數字——JSON 章 `{"sha":"abc123","release":"1.4.24",…}` 抽出來會變成
|
||||
# 一個**看似合理但錯得離譜**的舊時間戳(1970 年),比 fail-open 更難察覺。
|
||||
t=$(head -1 "$STAMP" 2>/dev/null | tr -d '[:space:]')
|
||||
# 內容不是時間戳(例:被 touch 成空檔)⇒ 退回檔案修改時間,別誤判成「沒驗過」
|
||||
case "$t" in ''|*[!0-9]*) t=0;; esac
|
||||
# 🔴 同日再撞一次 fail-open:ship.mjs 把這個章從「純時間戳」升級成 JSON 之後,
|
||||
# 抽數字會得到天文數字 ⇒ `now - t` 是**負數** ⇒ `< 21600` 成立 ⇒ **永遠放行**。
|
||||
# ⇒ 只有「不晚於現在」的時間戳才算數;不合理就退回 mtime,別當成通過。
|
||||
if [ "$t" -eq 0 ] || [ "$t" -gt "$now" ]; then
|
||||
t=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
|
||||
fi
|
||||
[ "$t" -gt 0 ] && [ "$((now - t))" -lt 21600 ] && exit 0 # 6 小時內驗過 stage → 放行
|
||||
done
|
||||
|
||||
cat >&2 <<'EOF'
|
||||
🚦 出貨閘:你正要動 **prod 出貨鏈**,但這 6 小時內沒有 stage 驗證紀錄。
|
||||
|
||||
【leo 2026-08-08】「現在因為**開始封測**,不直接打到 prod,而是先打到昨天建的 stage⋯⋯
|
||||
因為**推 prod 就發佈了**,雖然現在人不多,但**要謹慎**。」
|
||||
|
||||
【為什麼有這道閘】`ship-check` 的步驟本身就寫成直達 prod,D20 那道只擋「寫 GitHub」、
|
||||
不擋「未經 stage 就發佈」⇒ 光「知道有 stage」對行為零作用。
|
||||
|
||||
正確順序(stage 先行):
|
||||
1. 打 staging bundle → 推 Gitea `arcrun-rag-bundles-staging`(不碰 GitHub,無 D20 閘)
|
||||
2. 用 staging 安裝器實裝到測試實例
|
||||
https://arcrun-rag-installer-staging.uncle6-me.workers.dev
|
||||
3. 走一次**封測者真的會走的那條路**,貼出實測輸出
|
||||
4. 過了才動 prod
|
||||
|
||||
⚠️ 身分:leo 2026-07-25 令「測試一律用 youlin,別拿 leo21c 當探針(會製造假信號)」
|
||||
⇒ 部署前先 `acr whoami` 確認身分。
|
||||
|
||||
驗過了 ⇒ `touch /tmp/.stage-verified` 後重送,並在回覆裡**貼 stage 的實測輸出**
|
||||
(「我測過了」不算——貼指令與它吐出來的東西)。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bash
|
||||
# subagent-claim-worksheet.sh — subagent 交回時,把它的宣稱抽成一張待驗工作單
|
||||
# (SubagentStop;leo 2026-08-18 立)
|
||||
#
|
||||
# ── leo 的原話(本閘的規格)─────────────────────────────────────────
|
||||
# 「你如何確認說的都是真的?subagent 做完事,你應該在 SubagentStop 掛動作,
|
||||
# **它說可以,你去驗證;它說不行,你想辦法,不行再報告**,
|
||||
# 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報,
|
||||
# **它視野小,它說的你有更多資訊去檢驗**。」
|
||||
#
|
||||
# ── 為什麼是「工作單檔案」而不是提醒文字 ─────────────────────────────
|
||||
# leo 2026-08-17 診斷過:文字層封路是打不贏的軍火競賽,要封**動作**。
|
||||
# 所以這支不勸、也不偵測我的措辭——它**在磁碟上放一個必須被處理掉的東西**。
|
||||
# 配套的 Stop 閘(claim-verify-police.sh)看的是「那個檔還在不在」,
|
||||
# 而檔案只能被動作消滅,不能被文案消滅。
|
||||
#
|
||||
# ── 兩類宣稱,處理方式不同(leo 的規格就是這兩條)──────────────────
|
||||
# ✅ 它說可以 → 我去驗證(我有跨 repo 視野、有 token、有瀏覽器,它沒有)
|
||||
# ⛔ 它說不行 → 我想辦法(換路徑/查憑證地圖/自己撞一次),真的不行才報告 leo
|
||||
#
|
||||
# ⚠️ 這裡的關鍵詞比對只用來**列出候選**,不用來判定對錯——
|
||||
# 列多了我自己劃掉(要寫理由),列少了不影響我另外查。
|
||||
# ⇒ 它的失敗模式是「多列幾條」,不是「擋錯事」。
|
||||
#
|
||||
# ── 留痕(InkStoneCo#48:36 支閘只有 2 支記錄自己做了什麼)─────────
|
||||
INPUT=$(cat)
|
||||
DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification"
|
||||
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-claim-worksheet.log"
|
||||
mkdir -p "$DIR" 2>/dev/null
|
||||
|
||||
OUT=$(printf '%s' "$INPUT" | DIR="$DIR" python3 -c '
|
||||
import json, os, re, sys, hashlib
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP:bad-json"); raise SystemExit
|
||||
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.exists(tp):
|
||||
print("SKIP:no-transcript"); raise SystemExit
|
||||
|
||||
# ── 取 subagent 的交件 ────────────────────────────────────────────
|
||||
# 🔴 2026-08-18 首次實跑就抓到自己的缺陷:`SubagentStop` 給的 `transcript_path`
|
||||
# 指向**主對話**,不是子代理的紀錄 ⇒ 原本「取最後一則 assistant 訊息」
|
||||
# 抓到的是**總管自己寫的句子**,這支 hook 當場退化成它要避免的「文字層自咬」。
|
||||
#
|
||||
# 正解:子代理的交件是以 `<task-notification>…<result>…</result>` 的形式
|
||||
# 進到主對話的 **user 訊息**裡。只認那個區塊,其餘一律不看。
|
||||
# ⇒ 判準回到「這段話是誰說的」,而不是「這段話長什麼樣」。
|
||||
last = ""
|
||||
try:
|
||||
blob = []
|
||||
with open(tp, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
msg = ev.get("message") or {}
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
parts = msg.get("content")
|
||||
if isinstance(parts, str):
|
||||
txt = parts
|
||||
elif isinstance(parts, list):
|
||||
txt = "".join(p.get("text", "") for p in parts
|
||||
if isinstance(p, dict) and p.get("type") == "text")
|
||||
else:
|
||||
txt = ""
|
||||
if "<task-notification>" in txt:
|
||||
blob.append(txt)
|
||||
if blob:
|
||||
m = re.search(r"<result>(.*?)</result>", blob[-1], re.S)
|
||||
last = m.group(1) if m else ""
|
||||
except Exception:
|
||||
print("SKIP:read-fail"); raise SystemExit
|
||||
|
||||
if not last.strip():
|
||||
print("SKIP:no-subagent-report"); raise SystemExit
|
||||
|
||||
if not last.strip():
|
||||
print("SKIP:empty-report"); raise SystemExit
|
||||
|
||||
# ── 抽候選宣稱 ──────────────────────────────────────────────────
|
||||
# 成功類:它說某件事成立/做好了 ⇒ 我去驗
|
||||
OK = ("驗過", "測過", "通過", "全綠", "已部署", "部署完成", "可以用", "成立",
|
||||
"完成", "修好", "已推", "已併", "沒問題", "正常", "一致", "對上",
|
||||
"✅", "green", "passed", "verified", "deployed")
|
||||
# 阻擋類:它說做不到 ⇒ 我想辦法,不行才報告 leo
|
||||
NG = ("無法", "不行", "做不到", "被擋", "擋下", "失敗", "不存在", "找不到",
|
||||
"沒有權限", "權限不足", "需要 leo", "要 leo", "請總管", "交給你",
|
||||
"❌", "blocked", "denied", "cannot", "failed", "not found")
|
||||
|
||||
def lines_of(text):
|
||||
out = []
|
||||
for raw in text.split("\n"):
|
||||
s = raw.strip().lstrip("-*#>| ").strip()
|
||||
if len(s) < 8 or len(s) > 220:
|
||||
continue
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
ls = lines_of(last)
|
||||
ok_hits, ng_hits = [], []
|
||||
for s in ls:
|
||||
low = s.lower()
|
||||
if any(k in s or k.lower() in low for k in NG):
|
||||
ng_hits.append(s)
|
||||
elif any(k in s or k.lower() in low for k in OK):
|
||||
ok_hits.append(s)
|
||||
|
||||
# 兩類都沒有 ⇒ 它交的是純資料/純提問,沒有事實宣稱要驗
|
||||
if not ok_hits and not ng_hits:
|
||||
print("SKIP:no-claims"); raise SystemExit
|
||||
|
||||
sid = (d.get("session_id") or "nosid")[:8]
|
||||
aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10]
|
||||
stamp = hashlib.sha1((tp + sid + aid).encode()).hexdigest()[:8]
|
||||
path = os.path.join(os.environ["DIR"], "claims-%s-%s.md" % (sid, stamp))
|
||||
|
||||
def block(title, items, howto, cap):
|
||||
if not items:
|
||||
return ""
|
||||
body = "\n".join("- [ ] %s" % x for x in items[:cap])
|
||||
more = ""
|
||||
if len(items) > cap:
|
||||
more = "\n- [ ] (還有 %d 條同類,讀原始交件)" % (len(items) - cap)
|
||||
return "## %s\n\n%s\n%s%s\n\n" % (title, howto, body, more)
|
||||
|
||||
doc = []
|
||||
doc.append("# 待驗工作單(subagent 交回的宣稱)\n")
|
||||
doc.append("> leo 2026-08-18:「**它視野小,它說的你有更多資訊去檢驗。**」\n")
|
||||
doc.append("> 交件全文在:`%s`\n" % tp)
|
||||
doc.append("\n🔴 **這張單子只能用動作消滅,不能用文字消滅。**")
|
||||
doc.append("逐條處理完之後,把本檔**刪掉或移走**;在那之前 Stop 閘會擋。\n\n")
|
||||
doc.append(block(
|
||||
"✅ 它說「可以」——我要自己驗一次",
|
||||
ok_hits,
|
||||
"判準:**我不採信,我自己跑一次**。它只看得到自己那個 repo 與自己那次呼叫;\n"
|
||||
"我有跨 repo 視野、有 token、有瀏覽器。驗完把證據貼進回覆或票裡。\n",
|
||||
12))
|
||||
doc.append(block(
|
||||
"⛔ 它說「不行」——我先想辦法,真的不行才報告 leo",
|
||||
ng_hits,
|
||||
"判準:**它的「不存在/被擋」往往只是「我這裡看不到」**。\n"
|
||||
"先做三件:① 查憑證地圖/wiki ② 換一條路徑 ③ **自己真的撞一次並貼出拒絕原文**。\n"
|
||||
"🔴 沒撞過就不准說「我被 X 擋住」——那是虛構的閘(2026-08-18 已第五次)。\n"
|
||||
"真的是人閘 ⇒ 走三件機械動作:`Human` 標籤 + 指派給 Leo + **一張它自己的票**,\n"
|
||||
"不是在別張票的留言裡寫一段話(leo 08-18:「不是默默塞進錯的地方」)。\n",
|
||||
12))
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("".join(doc))
|
||||
|
||||
print("WROTE:%s:ok=%d:ng=%d" % (os.path.basename(path), len(ok_hits), len(ng_hits)))
|
||||
' 2>/dev/null)
|
||||
|
||||
printf '%s\t%s\n' "$(date +%FT%T)" "${OUT:-SKIP:no-output}" >> "$LOG" 2>/dev/null
|
||||
|
||||
case "$OUT" in
|
||||
WROTE:*)
|
||||
F=$(printf '%s' "$OUT" | cut -d: -f2)
|
||||
N=$(printf '%s' "$OUT" | sed 's/.*ok=\([0-9]*\):ng=\([0-9]*\)/\1 成功宣稱、\2 阻擋宣稱/')
|
||||
cat <<EOF
|
||||
🔍 交件查證:subagent 交回了 $N,已列成待驗工作單。
|
||||
|
||||
【leo 2026-08-18 的規格】
|
||||
「它說可以,你去驗證;它說不行,你想辦法,不行再報告。
|
||||
**它視野小,它說的你有更多資訊去檢驗。**」
|
||||
|
||||
工作單:.claude/pending-verification/$F
|
||||
|
||||
🔴 **不要直接把它說的轉給 leo。** 逐條處理完,把那個檔刪掉;在那之前 Stop 閘會擋一次。
|
||||
· 它說「可以」→ 你自己跑一次(測試/curl/瀏覽器/git,看那條宣稱是什麼)
|
||||
· 它說「不行」→ 先換路徑、查憑證地圖;**要說「被擋」就得自己撞一次並貼原文**
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
# subagent-first-guard.sh — PreToolUse(Write|Edit|MultiEdit):
|
||||
# 要親手改 code,卻**這個 session 一次工都沒派過** → 擋一次,逼你先回答「這件事該不該派出去」。
|
||||
#
|
||||
# 🔴 立這道閘的來由(leo 2026-08-08):
|
||||
# 「你記得**要叫 subagent 開工,你負責維護 loop**,而不是你開工後過一陣子停下來對吧?
|
||||
# 問題是這個規定**如何不要我說就做到**?因為在 vscode 裡你自然會這麼做,
|
||||
# 但現在在 claude desktop 你就會自己做問個問題停下來。」
|
||||
#
|
||||
# 查出來的根因(所以這道閘才有意義,不是加強語氣):
|
||||
# Claude Desktop 這個 surface 的 system prompt 有
|
||||
# `Do not call the AgentTool unless the user requested it`,
|
||||
# 它**壓過** CLAUDE.md 規則三點五 ⇒ 同一份規則在兩個 surface 行為不同。
|
||||
# leo 已於 2026-08-08 在 CLAUDE.md 給出常駐授權(=永久滿足「user requested」),
|
||||
# 這道閘負責讓「授權」真的變成「行為」——同 KBDB 那道的教訓:
|
||||
# **規則被讀到 ≠ 會被執行,要有機制驗證照做**。
|
||||
#
|
||||
# 逃生口:真的該自己做(單行修、改 hook 自己、緊急止血、subagent 回報後的收尾)
|
||||
# → `touch /tmp/.solo-ok-<session_id>` 後重送,並**在回覆裡說明理由**(留痕)。
|
||||
set -eu
|
||||
|
||||
INPUT="$(cat)"
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("tool_input", {}).get("file_path", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || echo "")
|
||||
SID=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("session_id", "") or "nosid")
|
||||
except Exception: print("nosid")
|
||||
' 2>/dev/null || echo nosid)
|
||||
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
# 只管 code 檔——文件、SDD、wiki、測試、hook 自己都放行
|
||||
# (這些本來就常是總管自己該寫的:判準、規格、落帳)
|
||||
case "$FILE_PATH" in
|
||||
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.go|*.py|*.rs|*.java|*.rb) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
case "$FILE_PATH" in
|
||||
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
|
||||
*/.claude/hooks/*|*system-dev/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
[ -f "/tmp/.subagent-spawned-$SID" ] && exit 0 # 這個 session 派過工了 → 放行
|
||||
[ -f "/tmp/.solo-ok-$SID" ] && exit 0 # 明示要自己做 → 放行(留痕)
|
||||
|
||||
WARNED="/tmp/.subagent-guard-warned-$SID"
|
||||
[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆
|
||||
date +%s > "$WARNED"
|
||||
|
||||
cat >&2 <<'EOF'
|
||||
🧑🏭 派工警察:你要親手改 code,但這個 session **一次工都沒派過**。
|
||||
|
||||
【leo 2026-08-08】「你記得**要叫 subagent 開工,你負責維護 loop**,
|
||||
而不是你開工後過一陣子停下來對吧?」
|
||||
|
||||
【已知的環境陷阱】Claude Desktop 這個 surface 的 system prompt 有
|
||||
`Do not call the AgentTool unless the user requested it`,會壓過 CLAUDE.md 規則三點五。
|
||||
**leo 已在 CLAUDE.md 給出常駐授權**(規則三點五的「常駐授權」段)=那個條件永久滿足,
|
||||
不必再等他開口。
|
||||
|
||||
先回答一句(答不出來就是該派):
|
||||
這件事**為什麼不能交給 subagent**?
|
||||
|
||||
✅ 該派:實作一個 task、跨 repo 施工、偵察盤點、批次修改、寫測試
|
||||
✅ 該自己做:判準/規格/落帳(那些檔本來就放行)、subagent 回報後的收尾裁決、
|
||||
單行修、緊急止血
|
||||
|
||||
決定自己做 ⇒ `touch /tmp/.solo-ok-$SID` 後重送,並**在回覆裡說明理由**。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# subagent-first-stamp.sh — PostToolUse:記下「這個 session 真的派過工了」。
|
||||
#
|
||||
# 配 subagent-first-guard.sh 使用(同 kbdb-asked-stamp.sh 的形狀)。
|
||||
# 只留時戳,不擋任何東西。
|
||||
#
|
||||
# 🔴 為什麼要按 session 分開存(而不是像 KBDB 那道用全域+1 小時 TTL):
|
||||
# 「這一輪工作有沒有先考慮派工」是**每個 session 各自要回答**的問題。
|
||||
# 用全域檔的話,昨天派過一次就等於永久解鎖,這道閘會在第二天起完全失效。
|
||||
set -eu
|
||||
SID=$(cat 2>/dev/null | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("session_id", "") or "nosid")
|
||||
except Exception: print("nosid")
|
||||
' 2>/dev/null || echo nosid)
|
||||
date +%s > "/tmp/.subagent-spawned-$SID"
|
||||
exit 0
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/bin/bash
|
||||
# subagent-wiki-guard.sh — PreToolUse(Task) hook:subagent 聽到「查」就自己先查 wiki
|
||||
#
|
||||
# 病根(2026-07-20):總管兩次派 agent 查 ENCRYPTION_KEY,prompt 都只叫它「去查 repo 程式碼」。
|
||||
# agent 於是從**稿子**推論出「這東西還活著、不能動」,總管照單全收去擋 leo 三輪。
|
||||
#
|
||||
# 🔑 設計轉向(leo 2026-07-21):
|
||||
# 第一版是「上游沒交代讀 wiki 就擋下」——但那**還是依賴上游記得寫**,
|
||||
# 跟「我記得讀 wiki」是同一個病。leo 點破:
|
||||
# 「subagent 的問題跟你一樣。你叫它去查,就算你沒說要先查 wiki,
|
||||
# 但它**只要聽到查,就應該主動查 wiki**,因為每個 repo 都有維護自己的 wiki。」
|
||||
# → 改成 **注入式**:不擋、不要求上游改 prompt,直接把「先查 wiki」這條
|
||||
# 以 additionalContext 注入給 subagent,讓它自己做。零依賴任何人記得。
|
||||
#
|
||||
# 行為:偵測到查證/實作類任務 → exit 0 並用 hookSpecificOutput 注入指示。
|
||||
# 已含 wiki 指示、或非查證類任務 → 靜默放行(不重複注入)。
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
PROMPT=$(printf '%s' "$INPUT" | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
print(d.get('tool_input',{}).get('prompt',''))
|
||||
except Exception: print('')
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
[ -z "$PROMPT" ] && exit 0
|
||||
|
||||
# 上游已經交代了 → 不必重複注入
|
||||
if printf '%s' "$PROMPT" | grep -qiE "wiki|agent-memory|mistakes\.md|decisions-summary"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 只對「查證/實作」類任務注入(純寫作、計算、潤稿等不需要)
|
||||
if ! printf '%s' "$PROMPT" | grep -qiE "查|盤點|核實|確認|調查|研究|找出|repo|程式碼|原始碼|source|實作|移除|刪除|重構|修|grep|codebase|\.ts|\.go|src/"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 🗺️ 真身地圖注入判斷(leo 2026-08-01:「你身為總管就是要能搞定這些跨 repo,
|
||||
# 你才不會發出錯誤的命令給 subagent」)——偵測到部署/真身相關關鍵詞就把地圖注入。
|
||||
NEED_MAP=0
|
||||
if printf '%s' "$PROMPT" | grep -qiE "install|portal|landing|部署|deploy|worker|bundle|wrangler|真身|上線|出貨|arcrun-rag|console-ui"; then
|
||||
NEED_MAP=1
|
||||
fi
|
||||
export NEED_MAP
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, os, pathlib
|
||||
|
||||
guidance = """【自動注入 0:**先看你的 skill 清單有沒有現成的**】
|
||||
|
||||
**動手前先掃一眼 system prompt 的 available skills**——那裡的東西是「已經被驗證過的做法」,
|
||||
比你自己從 repo 文件推測可靠得多。命中就 `Skill(skill="<name>")` 讀它,照它做。
|
||||
|
||||
判準:任務裡出現的**專有名詞**(產品名/平台名/工具名,如 Arcrun、Cloudflare、n8n…),
|
||||
到 skill 清單裡找同名或近義的那支。**有就一定要先讀**,不要跳過去自己翻程式碼。
|
||||
|
||||
> 為什麼這條排在「查 wiki」前面(2026-07-30 實測四次考試逼出來的):
|
||||
> 叫 haiku「幫我用 Arcrun 做 X」,它三次都失敗——
|
||||
> ①跑去 call MCP 拿到 401 ②自己 find repo 猜著寫 YAML ③讀 repo 文件後用了
|
||||
> **不存在的 `ON_TRUE`/`ON_FALSE` 邊**(n8n 式推測)。
|
||||
> 第四次只多給一句「你的 skill 清單裡有一支相關的」→ 它 `Skill(arcrun)` → **一次寫對**。
|
||||
> ⇒ 差別不在能力,在**它沒想到要看清單**。而本 hook 原本只教「查 wiki/grep」,
|
||||
> 反而把它導向 repo 文件(`grep skill` 於本檔=0)。
|
||||
|
||||
---
|
||||
|
||||
【自動注入 1:查任何東西之前,先查 wiki】
|
||||
|
||||
你所在的 repo 有維護自己的 wiki(通常在 `system-dev/wiki/`,舊結構在 `.claude/wiki/`)。
|
||||
**接到「查/盤點/核實/實作」類任務時,第一個動作是搜尋 wiki,不是翻程式碼。**
|
||||
|
||||
🔴 **查法有強弱之分,一律從最強的開始——沒有那個能力才降級。**
|
||||
(leo 2026-07-21:「它一定是用最好的搜尋,如果沒有才 fallback,
|
||||
但那不是你要指定的,對搜尋者來說,我就是要去搜尋,如果你沒這個機制才降。」)
|
||||
|
||||
**① 語意搜尋(最強,優先)**——有 Arcrun RAG MCP 就用它,用**自然語言問句**,不是關鍵字:
|
||||
kbdb_search(q="<用一句話描述你要找什麼>", mode="semantic")
|
||||
不確定該查哪個庫 → 先 kbdb_get_map() 看藏書地圖
|
||||
要沿關係展開 → kbdb_graph_neighbors()
|
||||
**② 關鍵字搜尋**——語意不可用時:kbdb_search(q="...", mode="keyword")
|
||||
**③ grep(最弱,最後手段)**——連 MCP 都沒有時:
|
||||
grep -rin "<關鍵字>" system-dev/wiki/ 2>/dev/null || grep -rin "<關鍵字>" .claude/wiki/
|
||||
|
||||
🔴 **為什麼順序是硬規定(2026-07-21 實際事故)**:
|
||||
查「CF 上的 git 託管」時只用了 grep,搜 Gitea/freeze/D43 等字面詞 → **零命中**,
|
||||
結論寫成「這件事沒查過、申請表沒送」。
|
||||
事後用**同一個問題**跑語意搜尋,**第一筆就命中**(score 0.858):
|
||||
「Cloudflare Artifacts:假設內建 git 倉庫機制的 CF 功能,成立則可全 CF 化」,
|
||||
還帶出三元組「Cloudflare Artifacts >> 若提供 git 倉庫則可取代 >> Gitea」——
|
||||
**負責人 15 天前就記在筆記裡了。**
|
||||
→ **grep 只認字面,要求你先猜對那個詞;語意搜尋不需要你猜對。**
|
||||
用 grep 查不到 ≠ wiki 沒記載,只代表你沒猜中用詞。
|
||||
|
||||
🔴 **凡結論涉及「某人沒做某事」,回報前必須先用語意搜尋查該事的記載**——
|
||||
這種結論錯了會變成**指控**,成本遠高於技術判斷錯誤。
|
||||
|
||||
為什麼這是划算的:
|
||||
• wiki 是前人已經查過、驗證過、被負責人糾正過的結論——**判準**。
|
||||
• 程式碼與歷史文件是**稿子**:它反映「還沒清乾淨」,不等於「還在用」。
|
||||
從稿子推論會系統性得出過時結論。
|
||||
• wiki 沒記載,才值得花力氣翻原文。
|
||||
• **凡結論涉及「某人沒做某事」,回報前必須先 grep 該事在 wiki 的記載**——
|
||||
這種結論錯了會變成指控,成本遠高於技術判斷錯誤。
|
||||
|
||||
三條硬規則:
|
||||
1. **wiki 與程式碼衝突 → 以 wiki 為準**,並在回報中明確指出衝突,
|
||||
不要自行用 code 推翻 wiki。
|
||||
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。
|
||||
那是當時狀態,不是永久禁令;條件已滿足就是可動。
|
||||
(2026-07-20 實際事故:agent 只看到「不可動」就回報不能動,
|
||||
實際上解除條件早已滿足,害負責人被擋三輪。)
|
||||
3. 翻原文後若得到**新結論**,回報時明講「wiki 該更新」——wiki 過時是債,要還。
|
||||
"""
|
||||
|
||||
# 🗺️ 注入 2:跨 repo 真身地圖(NEED_MAP=1 時才附)
|
||||
if os.environ.get('NEED_MAP') == '1':
|
||||
parts = []
|
||||
for f, title in [
|
||||
('system-dev/wiki/install-update-chain.md', '安裝/更新鏈(改了什麼→怎麼到用戶手上)'),
|
||||
('system-dev/wiki/deployment-map.md', '網址↔Worker/Pages↔後端 對照表'),
|
||||
]:
|
||||
fp = pathlib.Path(f)
|
||||
if fp.exists():
|
||||
body = fp.read_text(encoding='utf-8', errors='ignore')
|
||||
parts.append(f"### {title}\n來源:`{f}`(**這是實測釘死的真相源,勝過你從程式碼推論**)\n\n{body[:9000]}")
|
||||
if parts:
|
||||
guidance += ("\n\n---\n\n【自動注入 2:🗺️ 跨 repo 真身地圖——**先讀這個,不要自己猜哪份是活的**】\n\n"
|
||||
"🔴 **病根(leo 2026-08-01 重話)**:「哪個已經廢棄了你也不知道,總不能每次都花很多時間\n"
|
||||
"做簡單的『找到程式碼在哪裡』的工作?」——同名/相似檔案有很多份,\n"
|
||||
"**問題不是找不到,是找到太多份而不知道哪份還活著**。\n\n"
|
||||
"**規則**:① 動手前先在下方地圖找你要改的東西 ② 地圖沒有才自己驗\n"
|
||||
"③ **驗完立刻補進地圖**(帶佐證:怎麼證明它是活的)④ 地圖與程式碼衝突 → **以地圖為準**並回報衝突。\n\n"
|
||||
+ "\n\n".join(parts))
|
||||
|
||||
|
||||
print(json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"additionalContext": guidance
|
||||
}
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
|
||||
exit 0
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# unpushed-police.sh — 未推警察(Stop / SubagentStop hook)
|
||||
#
|
||||
# 🔴 2026-08-05:本機 LANG=C.UTF-8 下,把變數(分支名/日期)串進含全形標點的字串會**吃掉字元**
|
||||
# (實測 `- $b:$cnt 筆` → 分支名整個消失、只剩「:」的殘骸位元組 bc 9a)。
|
||||
# LC_ALL=C 下同一段完全正常 ⇒ 固定 locale,別讓報告內容被環境吃掉。
|
||||
export LC_ALL=C
|
||||
#
|
||||
# 病根(leo 2026-08-02 原話):「如果讓改了沒推會被警告?因為太常發生了」
|
||||
#
|
||||
# 一天之內同一個病發作六次,全部是「**改對了,但沒送到用戶手上**」:
|
||||
# ① Gemini key 修好了(Arcrun 0d49989)→ 沒重建 cypher bundle ⇒ 線上 admin/ai=0,
|
||||
# leo 與全體封測者填 key 一律無效,rag_chat 全壞一整天
|
||||
# ② CIS logo 換好了(512ea9f 7/31 20:39)→ daemon zip 是 15:35 打的 ⇒ 早 5 小時,
|
||||
# 發出去的永遠是舊圖(leo 肉眼在 Finder 抓到)
|
||||
# ③ manifest.daemon 欄被 release.mjs 吃掉 → 全體「檢查更新」壞掉一天
|
||||
# ④ 推了 GitHub 但 jsDelivr @main 還在吐舊的 ⇒ 推對了用戶仍拿不到
|
||||
# ⑤ fix/cis-round3-install 分支從未 push(今天才發現)
|
||||
# ⑥ 07-30 同款前科:t143-t150 十筆 commit 只在本機,雲端誤報「線上程式碼失蹤」
|
||||
#
|
||||
# 為什麼要 hook 而非規則:scripts/check-deploy-drift.sh 七月三十就寫好了,
|
||||
# 但**沒有任何東西會自動呼叫它**——只在文件裡被提到。
|
||||
# 「機制存在 ≠ 會被讀到」。所以這支要自己跳出來,不能等人想到要跑。
|
||||
#
|
||||
# 機制:收工那刻掃所有已知 repo,有「未 commit」或「已 commit 未推」就 exit 2 提醒。
|
||||
# 只提醒不阻擋——push 需要 leo 開閘(D20),hook 無權也不該代按。
|
||||
# 豁免:純對話回合(沒動過檔)/已在本回合推過/stop_hook_active。
|
||||
|
||||
input="$(cat)"
|
||||
|
||||
stop_active="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("stop_hook_active", False))
|
||||
except Exception: print(False)
|
||||
' 2>/dev/null)"
|
||||
[ "$stop_active" = "True" ] && exit 0
|
||||
|
||||
TOP="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"
|
||||
|
||||
# 掃描對象:頂層 + 各子 repo(存在才掃)。worktree 一併涵蓋(git -C 會自己解)。
|
||||
REPOS="$TOP $TOP/matrix/arcrun $TOP/products/arcrun-rag $TOP/polaris/mira"
|
||||
|
||||
problems=""
|
||||
for r in $REPOS; do
|
||||
[ -d "$r/.git" ] || [ -f "$r/.git" ] || continue
|
||||
name="${r#$TOP/}"; [ "$name" = "$TOP" ] && name="(頂層 InkStoneCo)"
|
||||
|
||||
# ① 有改動沒 commit(排除 untracked,那多半是暫存/產物)
|
||||
dirty="$(git -C "$r" diff --shortstat 2>/dev/null | head -1)"
|
||||
staged="$(git -C "$r" diff --cached --shortstat 2>/dev/null | head -1)"
|
||||
|
||||
# ② 已 commit 但沒推(**當前分支**)
|
||||
unpushed=""
|
||||
br="$(git -C "$r" branch --show-current 2>/dev/null)"
|
||||
if [ -n "$br" ]; then
|
||||
up="$(git -C "$r" rev-parse --abbrev-ref "@{upstream}" 2>/dev/null || true)"
|
||||
if [ -n "$up" ]; then
|
||||
n="$(git -C "$r" rev-list --count "$up".."$br" 2>/dev/null || echo 0)"
|
||||
[ "$n" != "0" ] && unpushed="領先 $up $n 筆"
|
||||
else
|
||||
# 沒 upstream=這條分支從來沒推過(今天 fix/cis-round3-install 就是這樣)
|
||||
has_commits="$(git -C "$r" rev-list --count HEAD 2>/dev/null || echo 0)"
|
||||
[ "$has_commits" != "0" ] && unpushed="分支 $br **從未推過**(無 upstream)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ③ 🔴 2026-08-05 t195 補:**散落分支**——有 commit 沒併回當前分支的其他本地分支。
|
||||
# 原本只查 ①②(當前分支的髒樹/未推),所以 D36 授權修補躺在 fix/cis-round3-install
|
||||
# 整整一週都沒被報過,害 feat/daemon 的收卡端全 401、新卡一張都寫不進去。
|
||||
# leo 2026-08-05:「為什麼有分支沒 merge?都是你寫的,驗過就要 merge⋯⋯
|
||||
# 如果它會留分支無明顯理由不 merge,那就要整理」。
|
||||
# 判準=「這條分支有沒有當前分支沒有的 commit」,不是分支數量多寡。
|
||||
stray=""
|
||||
held=""
|
||||
# 🔴 2026-08-15 訂正:基準**不能用當前簽出的分支**。
|
||||
# 共用工作區常停在某條舊功能分支上(08-15 實撞:matrix/arcrun 停在
|
||||
# fix/credential-resolve-without-auth-worker,而 main 已經領先它 17 筆)
|
||||
# ⇒ 四條分支全被報成「散落」,其中三條早就併進 main 了。
|
||||
# **這正是同一晚重複八次的那個病:檢查跑了,但檢查的對象是錯的。**
|
||||
# ⇒ 基準改成「整合分支」:gitea/main > origin/main > main > 當前分支。
|
||||
base="$br"
|
||||
for cand in gitea/main origin/main main; do
|
||||
if git -C "$r" rev-parse --verify -q "$cand" >/dev/null 2>&1; then base="$cand"; break; fi
|
||||
done
|
||||
if [ -n "$base" ]; then
|
||||
for b in $(git -C "$r" for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null); do
|
||||
[ "$b" = "$base" ] && continue
|
||||
git -C "$r" merge-base --is-ancestor "$b" "$base" 2>/dev/null && continue
|
||||
cnt="$(git -C "$r" rev-list --count "$base".."$b" 2>/dev/null || echo 0)"
|
||||
[ "$cnt" = "0" ] && continue
|
||||
# ── 刻意保留的分支(2026-08-12 加)─────────────────────────────────
|
||||
# 本閘原本只認得「該併」與「該刪」兩種,但**還有第三種:刻意不併**
|
||||
# (例:`wip/stopped-agents-2026-08-10` 是 arcrun-rag#56 那六個 Go 檔的唯一一份,
|
||||
# 保留理由與刪除條件記在該票上)。
|
||||
# 沒有這個狀態 ⇒ 那條分支**每一回合都被叫一次**,而且叫的是同一件已經交代過的事。
|
||||
# 🔴 **永遠在響的警報,等於訓練人忽略這個警報**——那才是真正的損失,
|
||||
# 因為下一條真的失蹤的分支會混在同一堆雜訊裡。
|
||||
# 門檻刻意留著:要寫一行理由進 `.claude/branch-holds.md` 才生效,
|
||||
# **不能只在對話裡說**(對話會消失,這正是本閘存在的理由)。
|
||||
_holds="$TOP/.claude/branch-holds.md"
|
||||
if [ -f "$_holds" ] && grep -qF "$b" "$_holds" 2>/dev/null; then
|
||||
# 🔴 這裡**不要**用 sed 去切掉「分支名:」那段前綴。
|
||||
# 本檔開頭第 4 行已經記過:`LC_ALL=C` 下處理含全形標點的字串會咬掉位元組,
|
||||
# 實測切完會吐出 `刻意保留——\xef\xbf\xbd它是` 這種殘骸。
|
||||
# 整行照印就好——那一行本來就寫得像人話,前綴留著也讀得通。
|
||||
_why="$(grep -F "$b" "$_holds" | head -1)"
|
||||
held="$held
|
||||
$_why"
|
||||
continue
|
||||
fi
|
||||
age="$(git -C "$r" log -1 --format='%ad' --date=format:'%m-%d' "$b" 2>/dev/null)"
|
||||
# 用真實換行累積,不用 \n 逃脫——否則後面的 printf '%b' 會把分支名再解讀一次
|
||||
stray="$stray
|
||||
- $b:$cnt 筆未併入 $base(最後動 $age)"
|
||||
done
|
||||
fi
|
||||
|
||||
line=""
|
||||
[ -n "$dirty" ] && line="$line
|
||||
· 未 commit:$dirty"
|
||||
[ -n "$staged" ] && line="$line
|
||||
· 已 staged 未 commit:$staged"
|
||||
[ -n "$unpushed" ] && line="$line
|
||||
· $unpushed"
|
||||
[ -n "$stray" ] && line="$line
|
||||
· 🔀 **散落分支**(有 commit 沒併回 $br):$stray"
|
||||
# 刻意保留的分支**不會自己觸發警報**(那正是加它的目的),
|
||||
# 但這個 repo 若本來就有別的問題要報,就順便把它列出來當脈絡——
|
||||
# 免得「保留」變成另一種看不見。
|
||||
[ -n "$line" ] && [ -n "$held" ] && line="$line
|
||||
· ⏸️ 刻意保留(不觸發警報,理由在 .claude/branch-holds.md):$held"
|
||||
[ -n "$line" ] && problems="$problems
|
||||
📍 $name$line"
|
||||
done
|
||||
|
||||
[ -z "$problems" ] && exit 0
|
||||
|
||||
cat >&2 <<EOF
|
||||
🚓 未推警察:有東西改了但還留在本機。
|
||||
|
||||
【leo 2026-08-02】「如果讓改了沒推會被警告?**因為太常發生了**」
|
||||
【判準】改對了 ≠ 送到用戶手上。同一天發作六次,每次都是這個病。
|
||||
|
||||
$problems
|
||||
|
||||
收工前擇一處理(**一句話交代也算,別默默留著**):
|
||||
1. 該推就推 → Gitea 直接 push(不需開閘);GitHub 要 leo 跑 scripts/github-arm.sh
|
||||
2. 還不該推 → 在回覆裡**明說**「X 先不推,因為 Y」,讓 leo 知道有東西在路上
|
||||
3. 是暫存/產物不該進版控 → 加 .gitignore 或刪掉,別讓它每次都來吵
|
||||
|
||||
⚠️ 特別注意「**從未推過**」的分支:那是最容易變成失蹤程式碼的一種
|
||||
(07-30 t143-t150 十筆、08-02 fix/cis-round3-install 都是這樣被發現的)。
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🔀 **散落分支怎麼處理**(leo 2026-08-05:「驗過就要 merge⋯⋯無明顯理由不 merge 就要整理」)
|
||||
|
||||
**它害過什麼**:D36 授權修補(07-29)躺在 fix/cis-round3-install,feat/daemon 沒合併
|
||||
⇒ 收卡端全 401、**新卡整整一週一張都沒寫進 youlin**,還害人往「token 過期」查錯方向。
|
||||
**分支沒合併不是整潔問題,是會產生「修好了卻沒生效」的假象。**
|
||||
|
||||
每條散落分支擇一,**不准放著不管**:
|
||||
A. **驗過了 → 合併**(這是預設):git merge 該分支,或 cherry-pick 需要的 commit
|
||||
B. **還沒驗完 → 說明它在等什麼**,並在回覆裡標「等 X 才能併」
|
||||
C. **已作廢 → 刪掉**:git branch -D 該分支(留著只會讓下一個人以為它有效)
|
||||
|
||||
📌 判準是「**這條分支有沒有當前分支缺的東西**」,不是分支數量。
|
||||
合併前先看它改了什麼:git log --oneline 當前..該分支、
|
||||
git diff 當前...該分支 --stat
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🎯 CP 對帳(leo 2026-08-02:「**現在有 CP 要求到最終用戶會看到更新,
|
||||
但還是會出現 commit 沒推,這表示完全忽略了 CP**」)
|
||||
|
||||
CRITICAL-PATH.md 早就寫死:
|
||||
· 狀態只有三種:✅ 通(**附實測證據**)/◐ 半通(標明缺什麼)/❌ 斷
|
||||
· **「程式碼寫完了」不是狀態**
|
||||
· code 寫完沒部署,不准標 ✅
|
||||
|
||||
規則寫在那裡卻照樣違反,因為**自評的人跟做事的人是同一個**。所以在這裡問你:
|
||||
|
||||
Q1 這回合改的東西,服務的是哪一條 CP 的哪一步?
|
||||
Q2 那一步現在是 ✅ / ◐ / ❌?**上面列出的未推項目,就是它還不能算 ✅ 的證據**
|
||||
Q3 最終用戶現在去看,會看到這次的改動嗎?
|
||||
看不到 → 它是 ◐,**不准在回覆裡寫「完成」「修好了」**,要寫「已改,未送達」
|
||||
|
||||
📌 推完 ≠ 用戶拿得到——中間夾 CDN/bundle/實例快取時,
|
||||
必須從**用戶端的網址、用用戶端的參數形式**再驗一次
|
||||
(08-02 實例:GitHub 推對了,jsDelivr @main 仍吐三代前的 manifest)。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# wiki-first-police.sh — 記憶警察(Stop / SubagentStop hook)
|
||||
#
|
||||
# 病根(leo 2026-07-26 兩句原話):
|
||||
# ①「你應該隨時記錄進 wiki,然後用記憶回答,而不是每次都去實測,除非 wiki 沒做好記錄找不到」
|
||||
# ②「你要把不憑記憶回答改成檢查 wiki……不依賴你自己的記憶,那個過 session 就死了」
|
||||
# ③「我看 CP 也沒用,因為你執行時沒去更新」
|
||||
#
|
||||
# 兩個病,同一根:**做完不落帳** → 下次(或下個 session)只好重新實測/憑會死的記憶亂答。
|
||||
# - 病 A:驗完就往下做,CP/wiki 事後才補 → leo 看 CP 永遠落後現況=「看了也沒用」。
|
||||
# - 病 B:被問狀態時憑對話記憶或重跑實測,而不是查 wiki → 過 session 就失憶。
|
||||
#
|
||||
# 為什麼要 hook 而不是寫規則:CLAUDE.md 早就寫了「tasks.md 是唯一進度來源,不靠對話記憶」,
|
||||
# 照樣違反——自評的人跟做事的人是同一個。同 delivery-police:在「要收工」那刻攔截。
|
||||
#
|
||||
# 機制:這回合有「實測/修好/部署」這類狀態變更 → 檢查有沒有動過 wiki/CP/tasks → 沒動就 exit 2。
|
||||
# 豁免:純查詢/純對話回合、已動過帳、撞權限閘。
|
||||
|
||||
input="$(cat)"
|
||||
|
||||
stop_active="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("stop_hook_active", False))
|
||||
except Exception: print(False)
|
||||
' 2>/dev/null)"
|
||||
[ "$stop_active" = "True" ] && exit 0
|
||||
|
||||
transcript="$(printf '%s' "$input" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("transcript_path", ""))
|
||||
except Exception: print("")
|
||||
' 2>/dev/null)"
|
||||
[ -z "$transcript" ] || [ ! -f "$transcript" ] && exit 0
|
||||
|
||||
# 取本回合(最後一次 user 訊息之後)的所有 assistant 文字 + 工具輸入
|
||||
turn="$(python3 -c '
|
||||
import sys, json
|
||||
path = sys.argv[1]
|
||||
out = []
|
||||
try:
|
||||
with open(path) as f: lines = f.readlines()
|
||||
# 從尾巴往回走到最近一則 user 訊息為止
|
||||
buf = []
|
||||
for line in reversed(lines):
|
||||
try: ev = json.loads(line)
|
||||
except Exception: continue
|
||||
msg = ev.get("message", ev)
|
||||
role = msg.get("role")
|
||||
if role == "user":
|
||||
break
|
||||
if role == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
for b in content:
|
||||
if not isinstance(b, dict): continue
|
||||
if b.get("type") == "text":
|
||||
buf.append(b.get("text", ""))
|
||||
elif b.get("type") == "tool_use":
|
||||
buf.append(json.dumps(b.get("input", {}), ensure_ascii=False))
|
||||
elif isinstance(content, str):
|
||||
buf.append(content)
|
||||
out = list(reversed(buf))
|
||||
except Exception: pass
|
||||
print("\n".join(out))
|
||||
' "$transcript" 2>/dev/null)"
|
||||
|
||||
[ -z "$turn" ] && exit 0
|
||||
|
||||
# ── 豁免一:撞閘/明說停等 → 放行 ──
|
||||
if printf '%s' "$turn" | grep -qiE '權限閘|classifier|分類器擋|四題第|人閘|denied by'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 這回合有沒有「狀態變更」?(值得落帳的事)──
|
||||
# 部署、修好、實測通過、端到端驗證、立案——這些都是下次會被問到的狀態。
|
||||
if ! printf '%s' "$turn" | grep -qiE '部署|deploy|已上線|修好|修掉|真兇|實測|端到端|驗證通過|全綠|通了|立案'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 豁免〇(2026-08-08 新增):**這回合根本沒有動過任何 repo → 純討論,不必落帳** ──
|
||||
#
|
||||
# 🔴 為什麼補這道(leo 08-08 問「為什麼花了一整天」時查出來的):
|
||||
# 上面那個「有沒有狀態變更」的判斷,比對的是**我回覆裡的散文**。
|
||||
# 於是我只要在回答中**談到**「修好/實測/全綠/部署」——例如向 leo 解釋
|
||||
# 今天做了什麼、或提出改善流程的建議——就會被判成「有狀態變更卻沒落帳」。
|
||||
# ⇒ **把「談論」當成「執行」**。這是同一天第三個同款 bug
|
||||
# (前兩個在我自己寫的 stage-before-prod-guard:讀腳本、commit 訊息提到關鍵字都被誤攔)。
|
||||
# ⇒ 代價很實在:leo 每插話一次,我就被判一次「沒落帳」,於是又寫一段落帳、
|
||||
# commit、push。今天 65 個 commit 裡 41 個是落帳,這是其中一個來源。
|
||||
#
|
||||
# 判準改成看**事實**:這 30 分鐘內有沒有任何 repo 真的產生 commit。
|
||||
# 沒有 ⇒ 這回合是討論/回答,沒有東西需要被記住,放行。
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
recent_commits=0
|
||||
for r in "$ROOT" "$ROOT/products/arcrun-rag" "$ROOT/matrix/arcrun"; do
|
||||
[ -d "$r/.git" ] || continue
|
||||
n=$(git -C "$r" log --since="30 minutes ago" --oneline 2>/dev/null | wc -l | tr -d ' ')
|
||||
recent_commits=$((recent_commits + n))
|
||||
done
|
||||
[ "$recent_commits" -eq 0 ] && exit 0
|
||||
|
||||
# ── 豁免二:這回合確實動過帳(wiki / CRITICAL-PATH / tasks.md / memory)──
|
||||
#
|
||||
# 🔴 同上,這裡原本也是比對散文——**只要回覆裡「提到」wiki 路徑就算落帳**,
|
||||
# 等於「說了就算做了」。改成先看 git:這 30 分鐘的 commit 有沒有真的動到帳本檔。
|
||||
doc_commits=0
|
||||
for r in "$ROOT" "$ROOT/products/arcrun-rag" "$ROOT/matrix/arcrun"; do
|
||||
[ -d "$r/.git" ] || continue
|
||||
n=$(git -C "$r" log --since="30 minutes ago" --name-only --pretty=format: 2>/dev/null \
|
||||
| grep -cE 'system-dev/wiki/|CRITICAL-PATH\.md|critical-paths/|tasks\.md|memory/.*\.md' || true)
|
||||
doc_commits=$((doc_commits + n))
|
||||
done
|
||||
if [ "$doc_commits" -gt 0 ]; then
|
||||
# 🔗 落帳了,但有沒有標「這件事牽涉哪些 repo」?
|
||||
# leo 2026-08-01:「你需要在每次記錄你自己的 wiki 時建立連到發生此事件的 hyperlink 或三元組,
|
||||
# 每件事牽涉到 2 個 repo,就可以在該事件查到那兩個 repo,細節記錄在該 repo 裡。」
|
||||
# 病根:08-01 實測 status.md 今日 34 段落帳中,只有 2 段標題提到 repo、
|
||||
# 0 筆有結構化 repo 欄位、僅 2 行三元組 ⇒ 下次仍要重查「這件事在哪」。
|
||||
# 只在「跨 repo 事件」時要求(單 repo 的事不必標)。
|
||||
if printf '%s' "$turn" | grep -qiE 'arcrun-rag|matrix/arcrun|console-ui|installer|portal|bundle|kbdb|cypher|跨 ?repo'; then
|
||||
# 🔴 2026-08-08 第二刀:這一段原本也在比對**我的回覆散文**——
|
||||
# 於是 wiki 條目裡明明寫了 📍 repo 與三元組,只因為**回覆裡沒提**就被判「沒標 repo」。
|
||||
# (早上已把上層觸發條件改看 git,這裡是同一個病漏掉的另一半。)
|
||||
# ⇒ 改成看**剛剛真的寫進帳本的內容**:近 30 分鐘 commit 新增的行有沒有 repo 指針。
|
||||
doc_added=""
|
||||
for r in "$ROOT" "$ROOT/products/arcrun-rag" "$ROOT/matrix/arcrun"; do
|
||||
[ -d "$r/.git" ] || continue
|
||||
doc_added="$doc_added$(git -C "$r" log --since='30 minutes ago' -p --unified=0 \
|
||||
-- 'system-dev/wiki/*' 'system-dev/docs/3-specs/*' 2>/dev/null \
|
||||
| grep '^+' || true)"
|
||||
done
|
||||
# ⚠️ 樣式別寫死 '📍 repo'——實際格式是 `📍 **repo**:`(粗體),
|
||||
# 寫死會永遠對不上(08-08 實撞:條目明明有指針卻一直被判沒有)。
|
||||
if ! printf '%s' "$doc_added$turn" | grep -qE '牽涉 repo|📍|真身在|真兇|位於|\[\[.*\]\]'; then
|
||||
cat >&2 << 'EOF2'
|
||||
🔗 記憶警察(連結層):你落帳了,但**沒標這件事牽涉哪些 repo**。
|
||||
|
||||
【leo 2026-08-01】「你需要在每次記錄你自己的 wiki 時建立連到發生此事件的 hyperlink 或三元組,
|
||||
**每件事牽涉到 2 個 repo,就可以在該事件查到那兩個 repo**,細節記錄在該 repo 裡。」
|
||||
|
||||
【為什麼】08-01 實測:status.md 當日 34 段落帳,只有 2 段標題提到 repo、
|
||||
**0 筆有結構化 repo 欄位** ⇒ 下次查「這件事在哪」還是要從頭找,
|
||||
這就是同一天猜錯真身 3 次的根源。
|
||||
|
||||
補一行就好(擇一格式,放在該段落帳裡):
|
||||
📍 repo:`products/arcrun-rag`(landing/worker.js)+`matrix/arcrun`(console-ui/public/portal/)
|
||||
或三元組:`portal 視覺 >> 真身在 >> matrix/arcrun:console-ui/public/portal/index.html`
|
||||
|
||||
原則:**頂層 wiki 記「事件+牽涉哪些 repo」,細節記在該 repo 自己的 wiki**。
|
||||
EOF2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat >&2 << 'EOF'
|
||||
🚓 記憶警察:這回合有狀態變更(實測/修復/部署),但我沒看到你落帳。
|
||||
|
||||
【病根】leo 2026-07-26:
|
||||
「我看 CP 也沒用,因為你執行時沒去更新。」
|
||||
「不依賴你自己的記憶——那個過 session 就死了。」
|
||||
|
||||
→ 你現在腦裡有的東西,下個 session 一個字都不剩。沒寫進檔案=沒發生過。
|
||||
|
||||
收工前補完這兩件(擇需要的做,一句話也算):
|
||||
1. **狀態變更落帳**:
|
||||
- CP 關卡狀態變了 → 改 `system-dev/docs/3-specs/CRITICAL-PATH.md`(✅/◐/❌+實測輸出)
|
||||
- 任務進度/新發現 → 改對應 `tasks.md`(含真兇、殘項、下一步)
|
||||
- 跨 session 要記得的操作知識 → `system-dev/wiki/`(status/mistakes/agent-memory)
|
||||
2. **下次別再重測**:把「這件事現在是什麼狀態、證據在哪」寫成一句話,
|
||||
讓下個 session(或被 leo 問時)**查得到就不用重跑**。
|
||||
|
||||
⚠️ 落帳=寫檔案,不是寫在回覆裡。回覆會消失,檔案不會。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/bin/bash
|
||||
# wiki-first-search.sh — PreToolUse hook:要去翻原文/程式碼前,先把 wiki 命中結果推到眼前
|
||||
#
|
||||
# 病根(2026-07-20 leo 點破,mistakes 第一鐵律):
|
||||
# 總管 session 開頭讀了 agent-memory 前 50 行就開工,關鍵那條在第 56 行 → 拿過期記憶擋了 leo 三輪。
|
||||
# leo:「如果你不是讀而是**搜尋** wiki 就不會只讀 50 行就下定論,
|
||||
# 而是就像我直接在頁面 cmd+F,那些都會高亮。」
|
||||
#
|
||||
# 設計要點(為什麼是這個形狀):
|
||||
# 1. **搜尋 ≠ 通讀**:開場 push 全文(session-start-recall.sh)解決不了這題——量大必然只讀開頭。
|
||||
# 這支反過來:在「你正要去查 code/原文」的當下,用你自己的關鍵字 grep wiki,只推命中行。
|
||||
# 2. **時機是關鍵**:不是開場推、不是寫入時擋,而是**查詢動作發生的那一刻**介入。
|
||||
# 3. 🔴 **硬擋,不是提醒**(leo 2026-08-05 改):
|
||||
# 「改成首先查 wiki,如果 wiki 沒有則放行其他搜尋,要擋」
|
||||
# 原版全程 exit 0(只印字),實測擋不住——我照樣一路 curl/grep code,
|
||||
# 連 wiki 都沒查就下結論,還把 401 誤判給不相干的 commit。
|
||||
# 現在:**本輪沒查過 wiki ⇒ exit 2 擋下**;查過(不論有無命中)⇒ 放行。
|
||||
# 放行條件刻意寬鬆——目的是「逼你先看一眼 wiki」,不是攔住你查原文。
|
||||
#
|
||||
# 觸發:Grep / Glob / Read 打向 code 或 docs 時(見下方 should_check)。
|
||||
# 輸出:stdout 注入 context(命中的 wiki 行 + 檔名:行號)。
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL=$(printf '%s' "$INPUT" | python3 -c "import json,sys;print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || echo "")
|
||||
|
||||
# 取出這次查詢的關鍵字:Grep 用 pattern,Glob/Read 用路徑的檔名部分
|
||||
QUERY=$(printf '%s' "$INPUT" | python3 -c "
|
||||
import json,sys,os,re
|
||||
try:
|
||||
d=json.load(sys.stdin); ti=d.get('tool_input',{})
|
||||
q = ti.get('pattern') or ''
|
||||
if not q:
|
||||
p = ti.get('file_path') or ti.get('path') or ''
|
||||
q = os.path.splitext(os.path.basename(p))[0] if p else ''
|
||||
if not q:
|
||||
# Bash:2026-07-21 補的破口——原版只掛 Grep|Glob|Read,
|
||||
# 但「用 curl/wrangler 亂試部署方法」走的是 Bash,整支 hook 不觸發。
|
||||
# leo 當場點破:wiki 早記著「寄信已驗證可用」,我卻沒查又自創方法。
|
||||
# 只認「會動到外部系統/部署」的高風險指令,避免每個 ls 都洗版。
|
||||
cmd = ti.get('command') or ''
|
||||
# 2026-08-05 再補一個破口(leo 當場點破:「你應該做任何查詢先去查 KBDB,
|
||||
# 但剛剛這一輪你都沒有查,但 hooks 沒攔阻?」):
|
||||
# 原本只認「動外部系統」的動詞,但**查證類**指令(git log / grep / unzip /
|
||||
# 讀 manifest)完全不觸發 ⇒ 我查「release 版本號怎麼算」整輪沒查過 KBDB/wiki。
|
||||
# ⇒ 補上查證類動詞。判準:**會讓我形成結論的指令**都該先問記憶,
|
||||
# 不是只有「會改壞東西的指令」。
|
||||
# 2026-08-05 第三次補(leo:「我看你剛剛 bash 裡很多 grep,為什麼不是查 wiki?」):
|
||||
# 再補「讀檔形成結論」的動詞——head/cat/sed/tail/awk/find/jq。
|
||||
# 之前只認「動外部系統」與部分查證動詞,`head changelog.md` 這種
|
||||
# **直接讀檔下判斷**的完全不觸發。
|
||||
if re.search(r'\b(wrangler|curl|npx|acr|gh|deploy|push|git|grep|unzip|manifest|version'
|
||||
r'|head|cat|sed|tail|awk|find|jq)\b', cmd):
|
||||
# 取指令中最具識別度的詞(worker 名/資源名/子命令)當搜尋詞
|
||||
cand = re.findall(r'[A-Za-z_][A-Za-z0-9_-]{4,}', cmd)
|
||||
skip = {'https','http','client','accounts','workers','scripts',
|
||||
'application','content','Authorization','Bearer','python3',
|
||||
'curl','npx','bash','echo','grep','local','branch','origin'}
|
||||
cand = [c for c in cand if c not in skip and not c.startswith('-')]
|
||||
q = max(cand, key=len) if cand else ''
|
||||
# grep pattern 常含 regex 元字元;取最長的英數/底線詞當搜尋詞
|
||||
# 🔴 2026-08-05 leo 點破:「最常做的就是 grep,為什麼沒在裡面」
|
||||
# ——Grep 其實有註冊,壞在**這行取詞規則**:
|
||||
# ① 連字號被當分隔 ⇒ `bge-m3` 只取到 `bge`(3 字)不足 4 字 ⇒ 整支不觸發
|
||||
# ② **中文完全不匹配** ⇒ 查「版本號」「出貨」這類詞一律不觸發
|
||||
# 而我日常查的關鍵字大量正是這兩類 ⇒ hook 形同虛設。
|
||||
# ⇒ 容許 `-`/`.`,並支援 CJK;中文 2 字即算一個詞。
|
||||
words = re.findall(r'[A-Za-z_][A-Za-z0-9_.-]{2,}', q)
|
||||
cjk = re.findall(r'[\u4e00-\u9fff]{2,}', q)
|
||||
words = words + cjk
|
||||
print(max(words, key=len) if words else '')
|
||||
except Exception:
|
||||
print('')
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
[ -z "$QUERY" ] && exit 0
|
||||
|
||||
WIKI_DIR="system-dev/wiki"
|
||||
[ -d "$WIKI_DIR" ] || exit 0
|
||||
|
||||
# 只在「查程式碼/文件」時提醒;查 wiki 本身就不用了(已經在讀了)
|
||||
TARGET=$(printf '%s' "$INPUT" | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin); ti=d.get('tool_input',{})
|
||||
print(ti.get('file_path') or ti.get('path') or '')
|
||||
except Exception: print('')
|
||||
" 2>/dev/null || echo "")
|
||||
# ── wiki-first 閘門(leo 2026-08-05)────────────────────────────
|
||||
# 記號檔:本輪(本 session)是否已經查過 wiki。放 /tmp 依 session 隔離,
|
||||
# 新 session 自動重置——每輪工作都要重新先看一眼 wiki。
|
||||
STAMP="/tmp/.wiki-first-$(id -u)-${CLAUDE_SESSION_ID:-nosession}"
|
||||
|
||||
# 這次動作**本身就是在查 wiki** → 蓋章放行(之後才允許查別的)
|
||||
case "$TARGET" in
|
||||
*system-dev/wiki*) : > "$STAMP"; exit 0 ;;
|
||||
esac
|
||||
# Bash 指令裡直接 grep/read wiki 的也算(例:grep -rn xxx system-dev/wiki/)
|
||||
if printf '%s' "$INPUT" | grep -q "system-dev/wiki"; then
|
||||
: > "$STAMP"; exit 0
|
||||
fi
|
||||
|
||||
# 已經查過 wiki → 放行,後面照舊只做提醒
|
||||
if [ ! -f "$STAMP" ]; then
|
||||
# 🔴 還沒查過 wiki 就想翻原文/打外部系統 → 擋(exit 2)
|
||||
PRE_HITS=$(grep -rin --include="*.md" -- "$QUERY" "$WIKI_DIR" 2>/dev/null | head -8 || true)
|
||||
{
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo "⛔ 先查 wiki,才准查別的(leo 2026-08-05 立)"
|
||||
echo "════════════════════════════════════════════════"
|
||||
printf '你正要查「%s」,但這一輪還沒查過 wiki。\n\n' "$QUERY"
|
||||
# 🔴 成本順序(leo 2026-08-05):「**成本最低的查就是 KBDB**,整了所有 repo 的 wiki,
|
||||
# 查詢應該效果最好最簡單,但你會去先查成本高的,甚至查不到後去查源碼,成本很高。」
|
||||
# ⇒ 一律 **KBDB 先**(跨全部 repo、語意搜尋、一次呼叫),
|
||||
# 再 grep 本 repo wiki(只認字面、只有這個 repo),最後才翻源碼。
|
||||
echo "查詢成本由低到高——**照順序來,別跳過前面直接翻源碼**:"
|
||||
echo ""
|
||||
echo " 1️⃣ KBDB(**最便宜也最強**:整合**所有 repo** 的 wiki,且有三種檢索)"
|
||||
echo " kbdb_search(q=\"…\", mode=\"semantic\") ← 語意:問句、想不到精確字眼時"
|
||||
echo " kbdb_search(q=\"…\", mode=\"keyword\") ← 關鍵字:確定有某個詞"
|
||||
echo " kbdb_graph_neighbors(...) ← 關係:沿「A >> 關係 >> B」展開"
|
||||
echo " kbdb_get_map() ← 不知道該查哪個庫時先看藏書地圖"
|
||||
echo ""
|
||||
echo " 2️⃣ 本 repo wiki(**只有 grep 字面、只有這一個 repo** ⇒ 比 KBDB 弱很多)"
|
||||
if [ -n "$PRE_HITS" ]; then
|
||||
echo " ⚠️ **這裡已經有命中了**,先讀這幾行:"
|
||||
printf '%s\n' "$PRE_HITS" | sed 's|^system-dev/wiki/| |'
|
||||
else
|
||||
echo " Grep/Read system-dev/wiki/ 用你自己的關鍵字"
|
||||
echo " (本閘 grep 沒命中,但它只認字面、搜尋詞還是從你指令猜的)"
|
||||
fi
|
||||
echo ""
|
||||
echo " 3️⃣ 源碼/原文(**最貴**:要讀很多、容易讀到已作廢的實作)"
|
||||
echo ""
|
||||
echo "wiki 沒有答案 → 那時再翻原文,本閘就會放行(查過 wiki 即解鎖本輪)。"
|
||||
} >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# grep wiki(不分大小寫、含行號),最多 12 行避免洗版
|
||||
HITS=$(grep -rin --include="*.md" -- "$QUERY" "$WIKI_DIR" 2>/dev/null | head -12 || true)
|
||||
|
||||
# 🔴 grep 零命中時**不能靜默退出**——那正是今天失敗的模式(2026-07-21):
|
||||
# grep 查不到 → 以為 wiki 沒記載 → 結論「這件事沒查過」。
|
||||
# 但 grep 只認字面,查不到往往只代表「沒猜中用詞」。
|
||||
# → 零命中反而是**最該改用語意搜尋**的時刻,必須出聲。
|
||||
if [ -z "$HITS" ]; then
|
||||
echo "════════════════════════════════════════════════"
|
||||
printf '🔍 grep 在 wiki 找不到「%s」——但這**不代表沒記載**\n' "$QUERY"
|
||||
echo "════════════════════════════════════════════════"
|
||||
echo "grep 只認字面,查不到通常只是「沒猜中用詞」。**改用語意搜尋再確認一次**:"
|
||||
echo " kbdb_search(q=\"<用一句話描述你要找什麼>\", mode=\"semantic\")"
|
||||
echo " 不知道該查哪個庫 → kbdb_get_map()|要沿關係展開 → kbdb_graph_neighbors()"
|
||||
echo ""
|
||||
echo "實例:查「CF 上的 git 託管」時 grep 全零命中,語意搜尋第一筆就命中"
|
||||
echo "(Cloudflare Artifacts >> 若提供 git 倉庫則可取代 >> Gitea,負責人 15 天前就記了)。"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
COUNT=$(printf '%s\n' "$HITS" | wc -l | tr -d ' ')
|
||||
|
||||
echo "════════════════════════════════════════════════"
|
||||
printf '📚 wiki 已有「%s」的記載(%s 處,先看這裡再翻原文)\n' "$QUERY" "$COUNT"
|
||||
echo "════════════════════════════════════════════════"
|
||||
printf '%s\n' "$HITS" | sed 's|^system-dev/wiki/| |'
|
||||
echo ""
|
||||
echo "⚠️ wiki 是判準,程式碼與歷史文件只是稿子(mistakes 第一鐵律)。"
|
||||
echo " • 上面若與你將要查的原文衝突 → **以 wiki 為準**,別用 code 推翻 wiki。"
|
||||
echo " • 看到「不可動/待廢除/進行中」→ 先讀它的**解除條件**並逐條核對,"
|
||||
echo " 那是當時狀態不是永久禁令;條件已滿足就是可動。"
|
||||
echo " • wiki 沒答案才值得翻原文——翻完若得到新結論,**回頭更新 wiki**。"
|
||||
echo ""
|
||||
echo "🔎 以上是 **grep(最弱的查法)** 的結果,只認字面,且搜尋詞是從你的指令**猜**出來的"
|
||||
echo " (很可能太籠統而命中一堆無關的,同時漏掉真正的主題詞)。"
|
||||
echo " **重要判斷一律補一次語意搜尋**——它不需要你猜對用詞:"
|
||||
echo " kbdb_search(q=\"<一句話描述你要找什麼>\", mode=\"semantic\")"
|
||||
echo " 實例:查「CF 的 git 託管」時 grep 猜到的詞是 cloudflare → 命中 12 處全無關、"
|
||||
echo " 真正的答案(Artifacts)一筆沒撈到;語意搜尋第一筆就命中。"
|
||||
echo ""
|
||||
|
||||
exit 0
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
# PreToolUse hook — 寫入 wiki 前掃機敏資訊(L3 硬攔截)
|
||||
#
|
||||
# 為什麼存在:wiki 的 ignore 規則(.wikiignore + 行內標記)是「協議層」,靠 CC 遵守。
|
||||
# 但密碼/金鑰/個資外洩是「不可逆」後果——只靠口頭約束太危險。
|
||||
# 這支 hook 是機械式底線:CC 真的把機敏資訊寫進 system-dev/wiki/ 的那一刻 → exit 2 擋下。
|
||||
#
|
||||
# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。
|
||||
# stdin 收到 JSON:{ tool_name, tool_input: { file_path, content?, new_string? } }
|
||||
# 行為:只在目標路徑是 system-dev/wiki/** 時啟動,掃要寫入的內容,命中機敏特徵 → exit 2。
|
||||
#
|
||||
# 誠實限制(抄 sdd-guard):regex 偵測有偽陰/偽陽。
|
||||
# 擋的是「明顯特徵的機敏字串被自動抄進 wiki」,擋不了刻意混淆/編碼的繞道。
|
||||
# 價值是「意外外洩的機械底線 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# ── 解析 file_path 與要寫入的內容。優先 jq,無 jq 退回 grep(容錯)──────
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
# Write 用 content;Edit 用 new_string。兩個都抓,合起來掃。
|
||||
CONTENT=$(printf '%s' "$INPUT" | jq -r '[.tool_input.content, .tool_input.new_string] | map(select(. != null)) | join("\n")')
|
||||
else
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
||||
# 無 jq 時內容解析不可靠(JSON 跳脫),退回掃整包 INPUT,寧可多掃不漏掃
|
||||
CONTENT="$INPUT"
|
||||
fi
|
||||
|
||||
# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺)
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
# 只管寫進 wiki 的動作。其他路徑放行(這支專責 wiki 洩漏,不是全域 secret scanner)
|
||||
case "$FILE_PATH" in
|
||||
*system-dev/wiki/*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
[ -z "$CONTENT" ] && exit 0
|
||||
|
||||
# 行內豁免:若該段內容已被標記為刻意保留(例:範例文件要示範格式),略過該行
|
||||
# 標記:行尾加 # wiki-secret-ok (或 <!-- wiki-secret-ok -->)
|
||||
# 先把標記過的行抽掉再掃。
|
||||
SCAN=$(printf '%s' "$CONTENT" | grep -v -E 'wiki-secret-ok' || true)
|
||||
[ -z "$SCAN" ] && exit 0
|
||||
|
||||
# ── 機敏特徵 pattern。一行一類,命中即攔。──────────────────────────
|
||||
# 設計取捨:偏向高訊號 pattern(有明確結構的金鑰/標記),降低偽陽。
|
||||
# 純「password=xxx」這類也納入,因為那正是使用者最擔心的場景。
|
||||
HITS=""
|
||||
|
||||
check() {
|
||||
local label="$1" regex="$2"
|
||||
# -e 讓以 - 開頭的 pattern(如 PEM 的 -----BEGIN)不被當成選項。
|
||||
# grep 無命中回傳 1,在 set -e 下會中止 → 用 if 包住吸收掉。
|
||||
if printf '%s' "$SCAN" | grep -qiE -e "$regex"; then
|
||||
HITS="${HITS}
|
||||
• ${label}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 密碼/密鑰賦值(password = ..., secret: ..., api_key=...)
|
||||
check "密碼/密鑰賦值 (password/secret/api_key/token = ...)" \
|
||||
'(pass(word)?|secret|api[_-]?key|access[_-]?key|auth[_-]?token|priv(ate)?[_-]?key)[[:space:]]*[:=][[:space:]]*[^[:space:]<>"'"'"']{6,}'
|
||||
|
||||
# 私鑰 PEM 區塊
|
||||
check "私鑰檔內容 (BEGIN ... PRIVATE KEY)" \
|
||||
'-----BEGIN[[:space:]].*PRIVATE KEY-----'
|
||||
|
||||
# 常見雲端/服務金鑰前綴
|
||||
check "服務金鑰特徵 (AWS/GitHub/Slack/Google/Stripe 等)" \
|
||||
'(AKIA[0-9A-Z]{16}|gh[pousr]_[0-9A-Za-z]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|AIza[0-9A-Za-z_-]{20,}|sk_(live|test)_[0-9A-Za-z]{16,})'
|
||||
|
||||
# JWT
|
||||
check "JWT token" \
|
||||
'eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}'
|
||||
|
||||
# 連線字串內嵌帳密 (proto://user:pass@host)
|
||||
check "連線字串內嵌帳密 (proto://user:pass@host)" \
|
||||
'[a-z][a-z0-9+.-]*://[^[:space:]:/@]+:[^[:space:]:/@]+@'
|
||||
|
||||
# 台灣身分證字號(個資)。BSD/GNU grep 都支援 ERE,避免 \b(BSD 不認),改用字元類邊界。
|
||||
check "台灣身分證字號 (個資)" \
|
||||
'(^|[^A-Za-z0-9])[A-Z][12][0-9]{8}([^0-9]|$)'
|
||||
|
||||
# 信用卡號(個資,粗略 13-16 連續數字,可含空格/連字號分隔)。避免 PCRE,用 ERE 近似。
|
||||
check "疑似信用卡號 (個資)" \
|
||||
'(^|[^0-9])[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{0,4}([^0-9]|$)'
|
||||
|
||||
# Email 不擋(wiki 常需記聯絡人),手機號也不擋(偽陽太高)——刻意留白。
|
||||
|
||||
if [ -n "$HITS" ]; then
|
||||
cat >&2 <<EOF
|
||||
🚫 Wiki 機敏攔截:偵測到可能的機敏資訊要寫進 ${FILE_PATH}。
|
||||
|
||||
命中特徵:${HITS}
|
||||
|
||||
wiki 是會被 CC 反覆讀取、可能進版控的記憶空間。
|
||||
密碼 / 金鑰 / 個資寫進去 = 不可逆外洩風險。
|
||||
|
||||
請改成下列任一做法:
|
||||
1. 不要把機敏值寫進 wiki,改記「位置」(例:「DB 密碼放 1Password / .env,不入 wiki」)
|
||||
2. 確定是誤判(例:在示範格式)→ 該行尾加註記 wiki-secret-ok 後重寫
|
||||
3. 整個來源檔本就機敏 → 加進 system-dev/wiki/.wikiignore,別讓它被編入
|
||||
|
||||
誠實限制:本掃描靠特徵比對,有偽陽/偽陰,是「意外外洩的機械底線」而非保險箱。
|
||||
真正的密鑰本就不該進版控。
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
# worklist-guard.sh — Stop hook:**還有未完成的步驟就不准收工寫報告**。
|
||||
#
|
||||
# 🔴 leo 2026-08-08 連續兩次點破:
|
||||
# 「所以你**沒有維持 loop**,而是停下來浪費了時間」
|
||||
# 「**這樣不行,要解決停止 loop 的問題**」
|
||||
#
|
||||
# 病根(誠實的):既有的 self-drive-police 是**事後**勸告——它在我已經停下來之後才響,
|
||||
# 而我對它的回應是**再寫一段散文**,等於又停一次。**勸告治不了「停」這個動作**,
|
||||
# 因為停下來寫字正是我回應它的方式。
|
||||
#
|
||||
# 治法:把「還沒做完」變成**環境事實**,讓收工這件事在事實上不成立。
|
||||
# · 開一條鏈之前,先把剩下的步驟寫進 /tmp/.worklist-<session_id>(一行一步)
|
||||
# · 做完一步就把那行刪掉
|
||||
# · 只要檔案還有內容 ⇒ 本 hook exit 2 ⇒ **我停不下來,只能繼續做下一步**
|
||||
#
|
||||
# 這跟今天立的其他幾道同形狀:**規則被讀到 ≠ 會被執行,要讓環境替我執行。**
|
||||
# (leo 的母原則:環境邊界取代邏輯判斷。)
|
||||
#
|
||||
# 逃生口:鏈真的該中止(撞人閘、前提翻掉、leo 改方向)⇒ 直接刪掉那個檔,
|
||||
# 並**在回覆裡說明為什麼中止**——留痕,不是默默放掉。
|
||||
set -eu
|
||||
|
||||
SID=$(cat 2>/dev/null | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("session_id", "") or "nosid")
|
||||
except Exception: print("nosid")
|
||||
' 2>/dev/null || echo nosid)
|
||||
|
||||
WL="/tmp/.worklist-$SID"
|
||||
[ -f "$WL" ] || exit 0
|
||||
|
||||
# 空白行不算。
|
||||
# 🔴 2026-08-08 自己踩到:原本寫 `grep -c ... || echo 0`——grep 在 0 命中時**回傳碼非零**,
|
||||
# 於是 `|| echo 0` 也跑,remaining 變成 "0\n0" ⇒ `-eq` 判斷炸掉 ⇒ **清單空了還是擋**。
|
||||
# 這是「用 grep 回傳碼當有無」的老坑,改成只認它印出來的數字。
|
||||
remaining=$(grep -cvE '^[[:space:]]*$' "$WL" 2>/dev/null | head -1)
|
||||
[ -z "$remaining" ] && remaining=0
|
||||
[ "$remaining" -eq 0 ] && { rm -f "$WL"; exit 0; }
|
||||
|
||||
cat >&2 <<EOF
|
||||
🔁 Loop 警察:**這條鏈還有 $remaining 步沒做完,現在不是收工的時候。**
|
||||
|
||||
【leo 2026-08-08】「所以你**沒有維持 loop**,而是停下來浪費了時間。」
|
||||
【leo 2026-08-08】「**這樣不行,要解決停止 loop 的問題。**」
|
||||
|
||||
還沒做完的:
|
||||
$(grep -vE '^[[:space:]]*$' "$WL" | sed 's/^/ · /')
|
||||
|
||||
**不要寫報告,直接做下一步。** 報告等整條鏈跑完再寫一次就好——
|
||||
中途每停一次寫一段,就是 leo 說的「停下來浪費時間」。
|
||||
|
||||
做完一步 ⇒ 從 $WL 刪掉那一行。
|
||||
真的要中止(撞人閘/前提翻掉/leo 改方向)⇒ 刪掉整個檔,並在回覆說明為什麼中止。
|
||||
EOF
|
||||
exit 2
|
||||
Reference in New Issue
Block a user