#!/usr/bin/env bash # 管什麼: 一條派工線要在「共用的工作目錄」裡切分支時擋下來,叫它去開一份自己的 worktree——開在 repo **裡面**(`.worktrees/`),不開在旁邊;同一支閘在那條線收工那一刻(PostToolUse Agent|Task)把分身收掉:分支推了就 remove,沒推就點名分支與路徑,不靜默留著。 # 為什麼: 2026-08-29 inkstone/ISEP#109 comment 5391——每個 repo 只有一份工作目錄,所有線共用。#163 切到自己的分支、交回時沒切回來,總管後來在同一個目錄跑 ship.mjs,讀到的是別人留下的 HEAD。票號說得出「這是哪個任務」,沒有任何東西說得出「這個目錄現在是誰留下的狀態」。 # 🔴 2026-09-07 inkstone/ISEP#147:第一版教工人「開在 repo 旁邊、收工 worktree remove」,而 remove 那一步只寫在閘訊息裡、沒有任何機制驗 ⇒ 每個工人留一份,leo 在 Finder 看到 `ISEP-wt-117`/`ISEP-wt115`/`InkStoneCo-wt-112`/`course_gen-ticket-3-notes`…一排同頭不同尾的分身。leo:「一個 repo 只看得到一個資料夾」「禁止這樣產出一大堆資料夾」。規則在文字裡不在機器上——所以現在兩端都在機器上:開在裡面(Finder 看不到),收在 PostToolUse(忘了也會被收)。工具=scripts/worktree(open/close/sweep 同一份「推了沒」判準);規約=docs/governance/worktree-placement.md。 # 誤觸時怎麼關: `WORKTREE_OK=1 git checkout …`——認的是**指令字串裡**這個字面前綴(留痕就在指令歷史上)。 # 🔴 2026-09-07(inkstone/ISEP#109 → comment 6587):舊版讀的是 hook 自己的環境變數 `$WORKTREE_OK`, # 而 PreToolUse hook 跟指令不是同一個行程——指令前綴的賦值到不了它 ⇒ 閘印出來的出路在 Claude Code # 底下永遠走不通(總管三次照貼三次被擋)。現在兩種都認:環境變數(本機 shell export 過)與字面前綴 # (閘訊息教的那一行)。判準是位置不是字:前綴必須掛在會移動 HEAD 的那條 git 指令上,見 # lib/checkout_target_dir.py 檔頭;測試 E 群把閘印出來的那一行原樣餵回去驗它走得通。 # # line-needs-own-worktree.sh — 共用工作目錄裡不准切分支(PreToolUse: Bash) # # ── 判準:git 自己回答的兩個問題,不看任何措辭 ──────────────────────── # ① 這條命令會不會**移動 HEAD**?(checkout / switch,含 -b / -c) # —— 動作有限且可枚舉。`git checkout -- <檔>`/`git restore` 是還原檔案,不動 HEAD,放行。 # ② 它要動的那個目錄,是不是那個 repo 的**主工作目錄**(=大家共用的那一份)? # git rev-parse --git-dir == git rev-parse --git-common-dir ⇒ 主工作目錄(共用) # 兩者不同 ⇒ 這已經是一份 worktree(自己的) # —— 這是 git 自己算出來的事實,**不需要任何人去登記**。 # # ①②都成立、而且送出命令的是一條線(`CLAUDE_CODE_CHILD_SESSION=1`)⇒ 擋。 # # ── 為什麼是「線」而不是所有人(這不是總管例外)────────────────────── # 共用工作目錄**就是總管那一份**——他在自己的目錄裡切分支不會偷走別人的狀態。 # 會出事的是**線把總管的目錄留在自己的分支上**(08-29 實錄就是這個形狀)。 # ⇒ 規則不是「誰比較大」,是「**誰的目錄**」。線要動工,就去開一份自己的。 # # ── 為什麼不靠 `.claude/branch-holds.md`(驗收條件 6 的原話:不靠人記得)───── # `not-my-branch-guard.sh` 守的是「**別 commit 到別人登記過的分支**」—— # ① 只認 `git commit`、② 只看頂層 repo、③ **要有人先手動登記**。 # 而 08-29 出事的是子 repo(`products/arcrun-rag`、`matrix/arcrun`)、動作是 `checkout`、 # 而且沒有人登記過。**兩支守的不是同一件事,這支不動那支。** # # ── worktree 開在哪:repo **裡面**的 `.worktrees/`,不在旁邊、不在 /tmp ───────── # /tmp(2026-08-29 實測): # $ git -C products/arcrun-rag worktree list # /private/tmp/.../scratchpad/wt-180 … prunable ← 目錄被系統清掉了 # /private/tmp/wt167src … prunable # ISEP 同一天有 **12 筆 prunable**,全部在 /private/tmp 底下。 # ⇒ 開在 `/tmp` 的會被系統清掉,而**登記簿還留著** ⇒ 登記簿說謊。 # 旁邊(2026-09-07 inkstone/ISEP#147 實錄):一份都沒掉——**也一份都沒收**。 # tech_projects/ 底下躺著 ISEP-wt-122ship、ISEP-wt-117、ISEP-wt115、InkStoneCo-wt-112… # leo 在 Finder 看到的是「一個 repo 長出一排同頭不同尾的分身」。 # ⇒ 住 `/.worktrees/<票 repo>-<票號>`:Finder 看不到(點開頭)、`git status` 看不到 # (`scripts/worktree open` 寫進 `.git/info/exclude`,不動被追蹤的檔)、 # `git worktree list` 照樣列得到(收工那一半靠它找)。 # # ── 收工那一半:同一支閘掛在 PostToolUse(Agent|Task)(inkstone/ISEP#147)─────── # 派工單裡的 `【工單】owner/repo#N` 就是分身的名字(`.worktrees/-`), # 跟 baton-handback-guard.sh/issue-status-autoflip.sh 同一個位置、同一種抓法。 # 線收工那一刻:`scripts/worktree close <票>` —— # 推了且乾淨 ⇒ remove+prune,安靜。 # 沒推/有未 commit/問不到遠端 ⇒ exit 2 點名分支與路徑,並印推的指令。**不 force、不刪。** # 為什麼不掛 SubagentStop:那裡拿不到票號(見 baton-handback-guard.sh 檔頭)。 # 為什麼不另開一支閘:教你開的和替你收的是同一件事的兩半,拆成兩支就又是「規則在兩個地方」。 # # ── 順手把說謊的登記清掉(驗收條件 7:prune 是收工動作,不是偶爾整理)─────── # 擋下來的同時對那個 repo 跑一次 `git worktree prune`,並把清掉幾筆講出來。 # prune **只刪「目錄已經不存在」的登記**,不碰任何還在的 worktree、不碰任何檔案。 # # ── 誠實邊界 ──────────────────────────────────────────────────── # 1. 它只擋 `checkout`/`switch`。**已經切過去之後**的 commit/build 這支看不到 # (那是別的閘的地盤)。它守的是「別把共用目錄帶離原位」這一個動作。 # 2. 它不知道那個 repo 現在有沒有別條線在動——**不知道就一律隔離**, # 因為「現在剛好沒人」是會變的,而分支留在那裡是不會自己回去的。 # 3. `git worktree add` 之後那份 worktree 裡的 checkout 全部放行(判準 ②)。 # 4. **目錄解不出來就放行**(`cd $VAR && git checkout`、`cd -`、引號壞掉)。 # 不是漏了,是選的:猜錯會指著別的 repo 叫人去開 worktree,而他會照做、 # 然後以為自己隔離好了。**寧可漏擋,也不要給錯的下一步**(comment 5398)。 set -eu PAYLOAD=$(cat 2>/dev/null || echo '{}') # ── 收工那一半(PostToolUse Agent|Task):先於任何環境變數判斷——這裡跑的是總管的 session ── EVENT=$(printf '%s' "$PAYLOAD" | python3 -c ' import json, sys try: d = json.load(sys.stdin) except Exception: raise SystemExit sys.stdout.write(str(d.get("hook_event_name") or "")) ' 2>/dev/null) || EVENT="" if [ "${EVENT:-}" = "PostToolUse" ]; then SPEC=$(printf '%s' "$PAYLOAD" | python3 -c ' import json, re, sys try: d = json.load(sys.stdin) except Exception: raise SystemExit if d.get("tool_name") not in ("Agent", "Task"): raise SystemExit p = (d.get("tool_input") or {}).get("prompt") or "" seen = [] for line in p.splitlines(): if "【工單】" not in line: continue for m in re.finditer(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line): t = "%s/%s#%s" % m.groups() if t not in seen: seen.append(t) sys.stdout.write(" ".join(seen)) ' 2>/dev/null) || SPEC="" [ -n "${SPEC:-}" ] || exit 0 WT_TOOL="$(cd "$(dirname "$0")/.." && pwd)/scripts/worktree" if [ ! -f "$WT_TOOL" ]; then echo "⚠️ line-needs-own-worktree:找不到 scripts/worktree,這條線的分身這一次沒有收。" >&2 exit 0 fi ROOT="${CLAUDE_PROJECT_DIR:-$PWD}" # 掃哪裡:專案根、它的上一層(Mac:tech_projects/ 底下 ISEP 與 InkStoneCo 是兄弟)、 # 雲端薄殼底下的真身(子 repo 在 InkStoneCo/matrix|products|polaris 底下,工具自己會往下找一層)。 SCAN="--scan $ROOT --scan $(dirname "$ROOT")" [ -d "$ROOT/InkStoneCo" ] && SCAN="$SCAN --scan $ROOT/InkStoneCo" RC=0 for T in $SPEC; do rc=0 # shellcheck disable=SC2086 OUT=$(python3 "$WT_TOOL" close "$T" $SCAN --quiet 2>&1 >/dev/null) || rc=$? [ -n "${OUT:-}" ] && printf '%s\n' "$OUT" | sed 's/^/ /' >&2 [ "$rc" -eq 2 ] && RC=2 done [ "$RC" -eq 2 ] || exit 0 cat >&2 <<'EOF' 🌳 這條線收工了,但它的分身還留在 repo 裡(上面點名的那幾份)。 不會靜默留著、也不會替它刪沒推的東西——推了再收,或確認作廢後自己刪。 規約:docs/governance/worktree-placement.md(inkstone/ISEP#147) EOF exit 2 fi [ "${WORKTREE_OK:-}" = "1" ] && exit 0 [ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] || exit 0 # 總管在自己的目錄裡,不管 # 這條指令會動到哪個目錄的 HEAD——**不是問「hook 站在哪」**。 # 2026-08-29 實測(inkstone/ISEP#109 → comment 5398):`cd <別的 repo> && git checkout` # 舊版拿 payload 的 cwd 當答案,於是擋對了、卻**指著另一個 repo 叫人去開 worktree**。 # 照著做的人會在錯的 repo 開一份用不到的,真正要隔離的那個沒開到,而他以為隔離好了。 # 🔴 一道閘給錯下一步,比不擋更糟。解析住在 lib/checkout_target_dir.py(純 tokenize, # 不執行任何指令;cd 鏈、子殼不外洩、-C 贏過 cd 的優先序都在那裡,理由見該檔檔頭)。 CMD=$(printf '%s' "$PAYLOAD" | python3 -c ' import json, sys try: d = json.load(sys.stdin) except Exception: raise SystemExit ti = d.get("tool_input") or {} sys.stdout.write((ti.get("command") or "") if isinstance(ti, dict) else "") ' 2>/dev/null) || exit 0 [ -n "${CMD:-}" ] || exit 0 # heredoc 的 body 是資料不是指令(inkstone/ISEP#125 缺陷 D/E,2026-09-02 兩次實地命中): # 用 heredoc 寫一份「內含 `cd <目錄> && git checkout X` 示範」的說明文件,整條指令沒有任何 # git 操作,卻被當成在切分支擋下——被擋的是「寫文件」這個動作。跟 github-contact-guard 一樣 # 先把 body 剝掉再解析;剝不動就用原字串(fail-open 的是「這支 helper 有沒有跑」,不是判準)。 CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD") [ -n "${CMD:-}" ] || exit 0 CWD=$(printf '%s' "$PAYLOAD" | python3 -c ' import json, sys try: d = json.load(sys.stdin) except Exception: raise SystemExit sys.stdout.write(d.get("cwd") or "") ' 2>/dev/null) || exit 0 [ -n "${CWD:-}" ] && [ -d "$CWD" ] || CWD=$PWD RESOLVER="$(dirname "$0")/lib/checkout_target_dir.py" if [ ! -f "$RESOLVER" ]; then # 解析器不見了=安裝壞了。**不靜默**:講出來再放行,因為這時候唯一能給的 # 「下一步」就是錯的那一個(拿 cwd 當答案),而那正是本輪修掉的病。 echo "⚠️ line-needs-own-worktree:找不到 lib/checkout_target_dir.py,這一次沒有檢查。" >&2 exit 0 fi EXPR=$(printf '%s' "$CMD" | python3 "$RESOLVER" 2>/dev/null) || exit 0 [ -n "${EXPR:-}" ] || exit 0 # 沒有會移動 HEAD 的 checkout/switch [ "$EXPR" = "?" ] && exit 0 # 目錄解不出來(cd $VAR / cd -)⇒ 不猜,見該檔檔頭 # 出路:那條 checkout 自己帶著 `WORKTREE_OK=1` 前綴 ⇒ 放行(comment 6587)。 # 看的是指令字串裡的位置,不是 hook 的環境——hook 的環境裡永遠不會有它。 ESC=$(printf '%s' "$CMD" | python3 "$RESOLVER" --escape WORKTREE_OK=1 2>/dev/null) || ESC="" [ "${ESC:-}" = "1" ] && exit 0 TARGET=$(cd "$CWD" 2>/dev/null && cd "$EXPR" 2>/dev/null && pwd) || exit 0 [ -n "${TARGET:-}" ] || exit 0 # 那個目錄是不是 repo 的**主工作目錄**(=共用的那一份)——git 自己回答 GD=$(git -C "$TARGET" rev-parse --git-dir 2>/dev/null) || exit 0 CD=$(git -C "$TARGET" rev-parse --git-common-dir 2>/dev/null) || exit 0 ABS() { (cd "$TARGET" 2>/dev/null && cd "$1" 2>/dev/null && pwd) || printf '%s' "$1"; } [ "$(ABS "$GD")" = "$(ABS "$CD")" ] || exit 0 # 已經在自己的 worktree 裡 ⇒ 放行 TOP=$(git -C "$TARGET" rev-parse --show-toplevel 2>/dev/null) || exit 0 NAME=$(basename "$TOP") PARENT=$(dirname "$TOP") # 印給人複製的指令用工具的絕對路徑——相對路徑會對貼的人的 cwd 解析(mistakes.md「出路跑不動」) WT_TOOL="$(cd "$(dirname "$0")/.." && pwd)/scripts/worktree" NOW=$(git -C "$TOP" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?') # 驗收條件 7:說謊的登記順手清掉(只刪目錄已經不存在的那些) BEFORE=$(git -C "$TOP" worktree list 2>/dev/null | grep -c 'prunable' || true) git -C "$TOP" worktree prune 2>/dev/null || true PRUNED="" [ "${BEFORE:-0}" -gt 0 ] && PRUNED=" 🧹 順手清掉 $BEFORE 筆**指向空氣**的 worktree 登記(目錄早就被清掉、登記還留著)。 只刪登記,沒有動任何還在的 worktree、沒有動任何檔案。" cat >&2 < --repo $TOP -b <你的分支> cd $TOP/.worktrees/<票的 repo 名>-<票號> 🔴 **不要開在 /tmp 底下**(08-29 實測:$NAME 指向 /private/tmp 的登記全部 prunable), **也不要開在 repo 旁邊**(09-07 實錄:$PARENT/ 底下躺了一排 $NAME-wt-…, leo 在 Finder 看到「一個 repo 一排分身」——inkstone/ISEP#147,禁止)。 裡面的那份 Finder 看不到、git status 看不到,git worktree list 找得到。 收工時(推了會收掉;沒推會點名,不會靜默留著;你忘了,線收工那一刻這支閘也會替你收): python3 $WT_TOOL close --repo $TOP 規約:docs/governance/worktree-placement.md 【真的要在共用目錄裡切(接手、緊急止血)】 WORKTREE_OK=1 git -C $TOP checkout <分支> 留痕在指令歷史上,而且**切完要切回去**——$NOW 是你進來時的樣子。 EOF exit 2