fix(collector+rules): daemon 不再搬用戶既有的卡+第一鐵律改成「照索引走」

## 一、daemon 把 leo 手寫的 16 張決策卡搬走並改名(今天實撞,已還原)

這是 cc6e500(08-14,MigrateCardNames)同一個事故的第二次,連數字都一樣:
那次壓平 cards/autonomy/,今天換成 cards/decisions/,同樣 16 個版控中的檔案變成刪除。
上次只修了 MigrateCardNames,enforceCardMarks 做一模一樣的事卻被漏掉。

真兇在 diffCards 把「新增」與「內容變動」併成同一個回傳值:
萃取 skill 正當地更新一張既有索引卡 → mtime 變了 → 被當成「本次產出的卡」
→ 判定不在 want 目錄 → 搬走改名。**更新一張索引卡,就會害它被踢出自己的桶子。**

第一版修法(在 cards 樹底下就不動)打掛兩支測試,而它們守的是真東西——
#60 前綴防撞名、#105 落 .arcrun-rag 不弄髒版控。改成正確的分界:
  這次新生的 → 歸位+加前綴(兩條保護原封不動)
  本來就在的 → 不管在哪、叫什麼都不准動(可能是使用者手寫的)
測試全綠。

## 二、第一鐵律從「用 grep」改成「照索引走」(leo 2026-08-15 當面糾正)

grep 繞過索引 ⇒ 索引永遠不會被驗證、永遠不會變準 ⇒ AI 再用「它可能不準」
當理由繼續 grep。自我實現。實測:arcrun 的 wiki 64 張卡只有 1 張走得到索引
(63 張機器產的從沒加入任何索引),而沒有人發現——因為 grep 找得到。

改成三步:先照索引走 → 走不到就先把索引缺陷講出來 → 才准 fallback grep。
不全面禁止 grep:索引壞掉那天 AI 會瞎掉且沒人知道。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 11:00:37 +08:00
parent 129fe79ecb
commit 35b113f147
+27 -2
View File
@@ -184,7 +184,7 @@ func ExtractWithClaude(binPath, absRoot, relPath string) ([]string, error) {
if len(cards) == 0 {
return nil, fmt.Errorf("claude 跑完但 %s 沒有新卡片(輸出尾段:%.200s", cardsRelDirFor(absRoot), string(out))
}
return enforceCardMarks(absRoot, cards), nil
return enforceCardMarks(absRoot, cards, before), nil
}
// enforceCardMarks 把「不是我們親手命名」的卡片**歸位並補上標記**。
@@ -198,7 +198,7 @@ func ExtractWithClaude(binPath, absRoot, relPath string) ([]string, error) {
// ⇒ **命名與落點的契約收回 Go 這一側強制執行**:不管上游寫成什麼、寫到哪,
// 離開這個函式時一定是 cardRelFor 算出來的那個位置與名字。
// 目標已被佔用或搬不動時,照實回報原路徑(不覆蓋、不假裝成功),剩下的交給 tidy。
func enforceCardMarks(absRoot string, cards []string) []string {
func enforceCardMarks(absRoot string, cards []string, before map[string]fileState) []string {
want := cardsRelDirFor(absRoot)
out := make([]string, 0, len(cards))
for _, rel := range cards {
@@ -208,6 +208,31 @@ func enforceCardMarks(absRoot string, cards []string) []string {
out = append(out, rel)
continue
}
// 🔴 arcrun-rag#105 第二輪(2026-08-15):**本來就存在的檔案,一律不搬。**
//
// 這是 `cc6e500`08-14`MigrateCardNames`)同一個事故的第二次,連數字都一樣:
// 那次「把 system-dev/wiki/cards/autonomy/ 整個子目錄壓平改名,16 個版控中的檔案
// 變成刪除」;今天換成 `cards/decisions/`,同樣 16 個。
// **上次只修了 MigrateCardNames,這支做一模一樣的事卻被漏掉。**
//
// 真兇在 `cards` 這份清單的來源:`diffCards` 把「**新增**」與「**內容變動**」
// 併成同一個回傳值,而 `snapshotCards` **同時掃 cardsRelDir 與 vaultCardsRelDir
// 兩棵樹**。於是萃取 skill 正當地更新一張既有索引卡(`cards/decisions/00-INDEX.md`
// ⇒ mtime 變了 ⇒ 被當成「本次產出的卡」⇒ 判定「不在 want 目錄」⇒ 搬走改名。
// **更新一張索引卡,就會害它被踢出自己的桶子。**
// 而子資料夾不是意外,是規約:`system-dev/wiki/INDEX.md` 寫著
// 「INDEX →『cards/<bucket>/00-INDEX.md』→ 概念原子卡」。壓平=把那條檢索鏈剪斷。
//
// 🔴 分界是「**這次新生的,還是本來就在的**」,不是「在不在某個目錄底下」:
// - 這次新生 ⇒ 就是 skill 剛寫的那張,歸位+加前綴(#60 防撞名、#105 落 .arcrun-rag
// 兩條保護都要留著,那是這支存在的理由)
// - 本來就在 ⇒ **不管它在哪、叫什麼,都不准動**。它可能是使用者手寫的 wiki。
// `IsMarked` 分不出「上游寫錯位置的卡」與「使用者自己的檔案」,而猜錯的代價不對等:
// 名字沒前綴只是難看,**搬走使用者的檔案是不可逆的**。
if _, existed := before[rel]; existed {
out = append(out, rel)
continue
}
newRel := cardRelFor(absRoot, UnmarkName(strings.TrimSuffix(base, ".md")))
if newRel == rel {
out = append(out, rel)