#!/bin/bash # jdd-format-guard.sh — PM 軌文件的格式閘(JDD 規則 J4+J5+J8) # # 掛 PreToolUse(matcher: Write|Edit|MultiEdit)。 # # J4 root.md 的 🔴 卡缺【要驗證 + 對帳日】 → 擋 # 為什麼:紅卡=賭注。賭注沒有「賭輸了怎麼知道」和「什麼時候結算」, # 就會永遠是「還在做」,永遠不必認賠——那不是賭注,是藉口。 # # J5 tasks.md **新增**的任務缺站號 → 擋 # 為什麼:sprint 的單位是站。任務不掛站,就沒有人答得出 # 「這個任務不做,哪一站會掛?」——那正是認領流程唯一的問題。 # # J8 root.md/journeys.md 出現技術名詞 → 擋 # 為什麼:這兩份是給不懂技術的人讀的。技術是達成手段,寫進各專案自己的規格。 # # ── J8 的關鍵細節:只掃「卡片/站的本體」,其餘一概不掃 ────────── # 踩過兩次同一類坑(第二次是本閘自己被真實文件抓包): # ① 文件開頭的規矩說明裡寫「各 repo 的規格」→ 被自己的自檢抓到 # ② 文件結尾「這卷還缺什麼」的自述裡提到跨 repo 鏈路 → 又被抓到 # 兩者都不是卡片內容,是**文件在講自己**。 # 閘要是連這些都掃,人就只能把說明寫得不清不楚來換綠燈,本末倒置。 # # 第一版用「排除法」(跳過 > 引言、註解、標題)——不夠,因為自述段是普通條列。 # 改用**正面圈定**:只掃真正的內容體 # · root.md = `- **P**` 卡片行 + 它底下的縮排子項 # · journeys.md = `#### S` 站標題以下、到下一個標題之前的內文 # 自述段、說明區、索引表因為不在這兩種範圍裡,自然就不會被掃到—— # 不必為它們一個個開例外。 # # 誠實限制:只認字面與行首形狀。 # 「把技術概念用白話包裝起來」它看不出來(那要人讀); # 用 bash 繞道改檔也擋不到。價值是擋掉明顯的格式錯誤與手滑,不是技術防偽。 set -uo pipefail HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$HOOK_DIR/lib/role-lib.sh" 2>/dev/null || exit 0 INPUT="$(cat)" FILE_PATH="$(sdt_file_path "$INPUT")" [ -z "$FILE_PATH" ] && exit 0 REL="$(sdt_rel_path "$FILE_PATH")" CONTENT="$(sdt_write_content "$INPUT")" [ -z "$CONTENT" ] && exit 0 TECH_TERMS='API|SDK|CLI|MCP|WASM|endpoint|schema|webhook|resturl|JSON|YAML|SQL|資料庫|後端|前端|部署|repo|commit|branch' # 正面圈定要掃的行(見上方說明): # root.md → `- **P**` 卡片行 + 其縮排子項 # journeys.md → `#### S` 站標題以下到下一個標題之前的內文 card_body_only() { # $1=root|journeys if [ "$1" = "root" ]; then awk ' //) inc=0; next } /^[[:space:]]*-[[:space:]]*\*\*P[0-9]+\*\*/ { incard=1; print NR "\t" $0; next } incard && /^[[:space:]]+[-*]/ { print NR "\t" $0; next } # 卡片的縮排子項 { incard=0 } ' else awk ' //) inc=0; next } /^####[[:space:]]*S[0-9]+/ { instation=1; next } # 進入某一站 /^#/ { instation=0; next } # 任何標題結束該站 instation && /^[[:space:]]*>/ { next } # 站內引言仍不掃 instation && /^[[:space:]]*$/ { next } instation { print NR "\t" $0 } ' fi } fail() { # $1=規則 $2=標題 $3=細節 cat >&2 <。 - 【要驗證:<能用真實數字或事實判真假的判準> | 對帳日 YYYY-MM-DD】 為什麼擋:紅卡是賭注。沒有判準和結算日的賭注,永遠不必認賠—— 那不是賭注,是把「還沒做到」講得像「正在做」。" fi fi # ── J8:root.md/journeys.md 不准出現技術名詞 ──────── case "$REL" in *root.md|*journeys.md) KIND="journeys"; printf '%s' "$REL" | grep -q 'root\.md$' && KIND="root" HITS="$(printf '%s' "$CONTENT" | card_body_only "$KIND" | grep -inE "$TECH_TERMS" | head -8 || true)" if [ -n "$HITS" ]; then fail "J8" "PM 軌文件出現技術名詞。" \ " 命中(只掃卡片本體,已排除說明區/註解/標題): $(printf '%s' "$HITS" | cut -c1-120 | sed 's/^/ /') 怎麼修: · 把它翻成「使用者感覺得到的事」——例如不是「呼叫 API 取得資料」, 而是「我按下去之後,畫面上出現我的東西」 · 真的必須談技術 → 那句話屬於各專案自己的規格,不屬於這裡 為什麼擋:這兩份文件唯一的讀者是「不懂技術但要點頭或搖頭的人」。 出現一個他看不懂的詞,他就沒辦法判斷這張卡是不是他的意思。" fi ;; esac # ── J5:tasks.md 新增的任務必須掛站號 ───────────────── if printf '%s' "$REL" | grep -q 'tasks\.md$'; then # 只看這次要寫入的內容裡「長得像新任務」的行 NEWTASKS="$(printf '%s' "$CONTENT" | grep -nE '^[[:space:]]*-[[:space:]]*\[[ x~!🔄]\][[:space:]]*[0-9]+\.[0-9]+' || true)" if [ -n "$NEWTASKS" ]; then # 站號標注:任務行本身或緊接的子項出現 S / S- / 「服務:S…」 NOSTATION="$(printf '%s' "$CONTENT" | awk ' /^[[:space:]]*-[[:space:]]*\[[ x~!🔄]\][[:space:]]*[0-9]+\.[0-9]+/ { if (pending != "" && !found) print pending pending = NR "\t" $0; found = 0 if ($0 ~ /S-?[0-9]+/) found = 1 next } pending != "" && /S-?[0-9]+/ { found = 1 } /^[[:space:]]*$/ { if (pending != "" && !found) { print pending; pending=""; found=0 } } END { if (pending != "" && !found) print pending } ')" if [ -n "$NOSTATION" ]; then fail "J5" "新增的任務沒有標注它服務哪一站。" \ " 下列任務缺站號: $(printf '%s' "$NOSTATION" | cut -c1-120 | sed 's/^/ 行 /') 補成這樣: - [ ] 1.1 <任務描述> - 服務:S3(我按一次就裝到自己的地方) - 驗收:<客觀可驗證的完成標準> 為什麼擋:sprint 的單位是站。任務不掛站,就沒有人答得出認領流程唯一的問題—— 「這個任務不做,指定站的考題會掛嗎?」答不出來,這個任務就沒有理由在這一期做。" fi fi fi exit 0