Files
Claude 129fe79ecb fix(collector): daemon 不再改用戶版控中的檔案,接 repo 只讀整理好的 wiki(#105/#104)
## #105 daemon 會改用戶的檔案(不可逆,先修)

2026-08-14 21:45 實撞:InkStoneCo 在看守清單裡,daemon 回報「已把 1 張舊卡片歸位」,
實際把 system-dev/wiki/cards/autonomy/ 整個子目錄壓平改名,16 個版控中的檔案變成刪除。

真兇是 MigrateCardNames 的一句假設:「那兩個目錄從頭到尾只有 daemon 會寫」。
那句話在 vault 上成立,在 repo 上不成立——system-dev/wiki/cards/ 是 template 的規約
路徑,而 template 就是要裝進開發者自己的 repo,那裡本來就有人家自己的檔案。

- 新增 repoguard.go:`.git` 判準(含 linked worktree),與 #104 共用同一個判準源
- 版控中的資料夾一個檔都不自動動;改成記帳+講給使用者聽,出口是 collector tidy --apply
- Blocked 只算「確定是我們寫的」(帶 arcrun- 標記)——報使用者自己的卡等於發假訊息
- 落卡與工作區改走 .arcrun-rag/(與 vault 同待遇),自帶 .gitignore(*) 讓它對 git 隱形
- 非版控資料夾(含 vault)行為與 #60 第三輪完全一致,不推翻前兩輪的成果

## #104 接上開發 repo 會把上萬個原始檔排進佇列

leo 的規格:「它要辨識這個庫已經有 wiki,那就直接 ingest 了」「只有文件要讀,程式碼不用讀」。

- 新增 ingestplan.go:掃描前先問「這個資料夾是什麼」——all/curated-wiki/docs-only
- 排除靠路徑身分不靠副檔名:依賴、建置產物、templatefs 範本、linked worktree、巢狀子 repo
- 策略與擋掉的數字經 TriggerPayload.Plan 走進 status.json,CLI 走 stderr(排除規則要看得見)
- 子專案自己的 wiki 刻意不收,但一定列出來讓使用者知道去哪裡找
- 與 TemplateOwns(把 system-dev/ 整棵當開發用的)的對撞用身分化解,不拿掉任一條:
  我們代裝的資料夾沒有 .git(舊規則照舊),他自己的 repo 有(收那份 wiki)

## 實測

#105:舊版 daemon 對真 repo 跑一輪 → git status 32 行(16 個 D + 16 個 ??,與 21:45
      撞到的一字不差);新版跑同一輪 → 0 行。另有真 git init 的端到端測試。
#104:造出 leo 那棵樹的形狀(InkStoneCo + products/arcrun-rag + 3 份出貨 worktree,
      967 個文件檔)→ 舊版送 91 個檔(多收的全是 -wt*ship/ 裡的 templatefs 範本、
      benchmark 結果、docs-site 產物),新版送 32 個,全部落在 system-dev/wiki/ 底下。

MachineMark 規約新增唯一例外 IsMachineOwnedRel:.arcrun-rag/ 底下的檔以目錄名為標記
(.gitignore 的檔名是 git 定的,改不得)。迴歸網判準同步換過去。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUwsLkFEGN8496bZTqhjFq
2026-08-14 14:41:08 +00:00

125 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// repoguard.go — 認出「這個監看根是版本控制中的工作目錄」,並據此把 daemon 的手綁起來
// arcrun-rag#1052026-08-14 leo 實撞)。
//
// 🔴 為什麼要有這支檔:
//
// 2026-08-14 21:45`InkStoneCo` 在看守清單裡,daemon 回報「已把 1 張舊卡片歸位」,
// 實際做的事是把 `system-dev/wiki/cards/autonomy/` 整個子目錄**壓平+改名**:
// `cards/autonomy/X.md` → `cards/arcrun-X.md`**16 個版控中的檔案顯示為刪除**。
// 那些是 leo 自己寫的知識卡,不是 daemon 的產物。
//
// 程式沒有做錯它以為自己在做的事——`MigrateCardNames` 的註解白紙黑字寫著
// 「那兩個目錄從頭到尾只有 daemon 會寫」。**錯的是那個假設**:
// `system-dev/wiki/cards/` 是 system-dev-template 的規約路徑,而 template 的用途
// 正是「裝進開發者自己的 repo」——所以在**任何裝過 template 的 repo 裡**
// 那個目錄裡本來就有人家自己的檔案。「只有我們會寫」在 vault 上成立,在 repo 上不成立。
//
// 🔴 判準:`.git` 的存在。這夠不夠?(#105 明著問了這題)
//
// 夠,而且它是我們手上**唯一**不必猜的訊號:
// `.git` 在=這裡的每個檔案都有人在追它的歷史,改名/搬移都會變成 `git status` 上的
// 刪除+新增,使用者一個 `git add -A` 就把我們的手筆 commit 進他的歷史。
// 它會不會漏(hg/svn/沒版控但很珍貴的資料夾)?會。但漏判的代價是「少整理一次」,
// 誤判的代價是「改掉別人版控中的檔案」——**代價不對稱,就往安全那邊倒**。
//
// ⇒ 兩條規則,本檔負責第一條,落點那條在 extract.go:
//
// ① 版控中的資料夾:**一個檔都不自動動**(改名/搬移一律停手,改成報告給使用者)。
// ② daemon 自己要寫的東西(卡片、工作區):落進 `.arcrun-rag/`,並讓那個目錄
// 對 git 完全隱形(見 EnsureWorkspaceIgnored)⇒ 跑完一輪 `git status` 是乾淨的。
package collector
import (
"os"
"path/filepath"
)
// workspaceRelDirdaemon 在監看根底下唯一有權寫入的目錄。
// 卡片產物區(vaultCardsRelDir)與收容處(legacyTemplateRelDir)都住在它底下。
const workspaceRelDir = ".arcrun-rag"
// DetectRepoRoot 從 absPath 自己開始往上找最近的版控工作目錄,回傳它的根;
// 找不到回空字串。
//
// 認的是 `.git` **存在**,不分目錄或檔案:
// - 目錄=一般 clone。
// - 檔案=linked worktree`git worktree add` 產生,內容是 `gitdir: …`)。
// 那一種同樣是版控中的工作目錄,同樣不能亂動;而且 #104 的實據裡
// `.claude/worktrees/…`、`products/arcrun-rag-wt60ship`)滿地都是。
//
// 停止條件與 DetectVaultContext 同一套保守原則:走到檔案系統根或使用者家目錄就停
// (家目錄本身若是個 repo——有人會 `git init ~`——不拿它把底下每個監看根都判成版控,
// 那種病態擺法的漏判,遠比誤判所有人便宜)。
func DetectRepoRoot(absPath string) string {
dir := filepath.Clean(absPath)
home := ""
if h, err := os.UserHomeDir(); err == nil && h != "" {
home = filepath.Clean(h)
}
for {
if home != "" && dir == home {
return "" // 家目錄(含)以上不猜
}
if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return "" // 檔案系統根
}
dir = parent
}
}
// UnderVersionControl 是 DetectRepoRoot 的布林簡寫。
func UnderVersionControl(absPath string) bool { return DetectRepoRoot(absPath) != "" }
// IsLinkedWorktree 回答「這個目錄是不是 git 的 linked worktree 根」
// `.git` 是檔案而非目錄)。#104 用它整棵跳過出貨用 worktree——
// 那些是主 repo 的第二份簽出,內容與主 repo 相同,收進去就是同一份收十幾次。
func IsLinkedWorktree(absDir string) bool {
info, err := os.Lstat(filepath.Join(absDir, ".git"))
return err == nil && !info.IsDir()
}
// IsRepoRoot 回答「這一層自己就是版控工作目錄的根」(不往上找)。
// #104 用它認出巢狀的子專案:監看根底下的獨立 repo 有自己的知識,
// 但那是**另一個專案**,要收請把它自己加進看守清單。
func IsRepoRoot(absDir string) bool {
_, err := os.Lstat(filepath.Join(absDir, ".git"))
return err == nil
}
// workspaceIgnoreBody=寫進 `.arcrun-rag/.gitignore` 的內容。
//
// `*` 讓這個目錄底下的一切(含 .gitignore 自己)對 git 隱形 ⇒ 整個 `.arcrun-rag/`
// 不會出現在 `git status` 的 untracked 清單裡。**這正是 #105 驗收條件的機制**:
// daemon 要有地方放東西,但使用者的 `git status` 必須乾淨。
//
// 為什麼不去改使用者的 `.gitignore`:那是他版控中的檔案,改它就是我們自己犯的錯
// (同一條紅線)。自帶一份放在自己的目錄裡,git 一樣認,而且他刪掉整個
// `.arcrun-rag/` 就等於把我們留下的痕跡清乾淨,不留殘渣在他的檔案裡。
const workspaceIgnoreBody = "# 這個資料夾是 Arcrun RAG 同步小幫手的工作區,不是你的檔案。\n" +
"# `*` 讓整個目錄對 git 隱形,你的 git status 不會因為我們跑過而變髒。\n" +
"# 整個資料夾可以安全刪除(下次同步會重建,已同步的紀錄會重來一次)。\n" +
"*\n"
// EnsureWorkspaceIgnored 確保 `<absRoot>/.arcrun-rag/.gitignore` 存在。
//
// 只在監看根落在版控範圍內時才需要(非版控資料夾沒有 git status 可弄髒),
// 但無條件呼叫也無害——多一個隱藏檔,不影響 vault(點開頭目錄筆記軟體本來就不掃)。
//
// 冪等且不覆蓋:檔案已存在就什麼都不做(使用者可能自己改過內容)。
// 失敗一律靜默——寫不進去最多是 git status 多一行 untracked,不值得中斷同步。
func EnsureWorkspaceIgnored(absRoot string) {
dir := filepath.Join(absRoot, workspaceRelDir)
target := filepath.Join(dir, ".gitignore")
if _, err := os.Stat(target); err == nil {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
_ = os.WriteFile(target, []byte(workspaceIgnoreBody), 0o644)
}