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
This commit is contained in:
Claude
2026-08-14 14:41:08 +00:00
parent 64cb20a1aa
commit 129fe79ecb
13 changed files with 1104 additions and 36 deletions
+39 -4
View File
@@ -949,13 +949,30 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// arcrun-rag#60:把上一版 daemon 落下的舊卡歸位——沒帶 arcrun- 標記的改名,
// 位置不對的(第三輪:監看根在筆記庫裡,卡卻落在看得見的 system-dev/wiki/cards/)搬進隱藏目錄。
// 自動跑而不是叫人下指令——leo 的紅線:「他不該為了保護自己的筆記去學新選項」。
// 只碰卡片產物區(那整個目錄只有我們會寫),目標已存在就跳過、永不刪檔,見 tidy.go。
// 目標已存在就跳過、永不刪檔,見 tidy.go。
// template 殘留不在這裡處理:那要人確認「這是不是你自己的 repo」,走 `collector tidy`。
//
// 🔴 arcrun-rag#105:監看根在版控裡時**一個檔都不動**MigrateCardNames 自己擋),
// 這裡負責把「沒動、以及為什麼沒動」講出來——靜默跳過等於讓使用者猜。
if !dryRun {
if n := MigrateCardNames(absRoot); n > 0 {
// daemon 自己的工作區先自我忽略,之後落卡/收容/身分標記檔才不會弄髒使用者的
// git status#105 驗收條件就是「跑一輪 git status 必須乾淨」)。
EnsureWorkspaceIgnored(absRoot)
mig := MigrateCardNames(absRoot)
if mig.Moved > 0 {
results = append(results, DirectResult{
Type: "warning", Path: absRoot, Status: "skipped",
Error: fmt.Sprintf("已把 %d 張舊卡片歸位(加上 arcrun- 前綴/搬離筆記軟體看得到的位置)", n),
Error: fmt.Sprintf("已把 %d 張舊卡片歸位(加上 arcrun- 前綴/搬離筆記軟體看得到的位置)", mig.Moved),
})
}
if mig.Blocked > 0 {
results = append(results, DirectResult{
Type: "warning", Path: absRoot, Status: "skipped",
Error: fmt.Sprintf(
"這個資料夾在版本控制裡(%s),所以有 %d 個舊卡片我沒有自動整理"+
"——改名搬移會變成你 git status 上的刪除。要整理請自己跑:"+
"collector tidy --folder %s(先看清單,確認後再加 --apply",
mig.RepoRoot, mig.Blocked, absRoot),
})
}
}
@@ -973,6 +990,23 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
for k, v := range m.Entries {
preScanEntries[k] = v
}
// arcrun-rag#104:走訪之前先問「這個資料夾是什麼」——是開發專案就只讀它整理好的
// wiki(沒有 wiki 才退到文件區),是一般資料夾/筆記庫才全收。見 ingestplan.go。
plan := PlanIngest(absRoot)
// 🔴 #104 的一個必然後果:curated-wiki 模式要收的正是 `system-dev/wiki/`
// 而 daemon-beta task 2 為了「template 代裝的產物區不要被當原稿」把整個
// `system-dev` 列進 SkipDirNames——兩者直接對撞,不處理的話這個模式會一個檔都收不到。
//
// 解法不是拿掉那條保護,是**看它保護的是誰**:task 2 擋的是「**我們自己**代裝進
// 使用者資料夾的 template 產物」;curated-wiki 模式的前提則是「**使用者自己**
// 在他的 repo 裡整理好的知識庫」——同一個路徑,兩種身分,由 PlanIngest 分辨
// (他的 repo 有 `.git`,我們代裝的資料夾沒有)。所以只在 curated-wiki 模式解除。
skipDirNames := map[string]bool{"system-dev": true}
if plan.Mode == IngestCuratedWiki && strings.HasPrefix(plan.WikiRelDir, "system-dev/") {
skipDirNames = map[string]bool{}
}
payload, err := Scan(absRoot, m, ScanOptions{
MaxRemovedRatio: cfg.MaxRemoved,
SkipPaths: map[string]bool{
@@ -981,7 +1015,8 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
filepath.Join(absRoot, "CLAUDE.md"): true,
},
// template 代裝後 system-dev/wiki 產物區)不得被當原稿掃進 ingest(task 2
SkipDirNames: map[string]bool{"system-dev": true},
SkipDirNames: skipDirNames,
Plan: plan,
})
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
+9 -3
View File
@@ -73,15 +73,21 @@ const cardsRelDir = "system-dev/wiki/cards"
const vaultCardsRelDir = ".arcrun-rag/wiki/cards"
// cardsRelDirFor 決定某次萃取的卡片該落在哪個相對路徑:
// 監看根不在任何筆記庫範圍內cardsRelDir(一般資料夾行為,零改變);
// 落在筆記庫範圍內=vaultCardsRelDir(隱藏目錄)。
// 監看根不在筆記庫、也不在版控裡cardsRelDir(一般資料夾行為,零改變);
// 落在筆記庫或版控範圍內=vaultCardsRelDir(隱藏目錄)。
//
// 🔴 arcrun-rag#60 第三輪:判準從 IsVault(只看監看根**這一層**)換成 DetectVaultContext
// (監看根**在不在**某個筆記庫裡)。前者在「監看根是 vault 底下的子資料夾」時回 false,
// 於是卡片落回 `<監看根>/system-dev/wiki/cards/`——那個位置就在使用者的 vault 裡面,
// 而且不是隱藏目錄,Logseq/Obsidian 會把每一張卡收編成一頁。全文見 vault.go 第三輪那段。
//
// 🔴 arcrun-rag#105**版控中的資料夾與 vault 同待遇**。理由是同一個形狀——
// `system-dev/wiki/cards/` 在 repo 裡不是空地,那是 template 規約路徑,
// 使用者自己的卡就住在那裡;而且不管撞不撞名,往那裡寫東西就是往他的 git status
// 裡塞 untracked 檔。落進 `.arcrun-rag/`(配 EnsureWorkspaceIgnored 自我忽略)
// =寫得進去、又不弄髒他的版控。
func cardsRelDirFor(absRoot string) string {
if DetectVaultContext(absRoot).InVault() {
if DetectVaultContext(absRoot).InVault() || UnderVersionControl(absRoot) {
return vaultCardsRelDir
}
return cardsRelDir
+323
View File
@@ -0,0 +1,323 @@
// ingestplan.go — 掃描之前先問一句「這個資料夾是什麼」,再決定要讀哪些檔
// arcrun-rag#1042026-08-14 leo 規格)。
//
// 🔴 leo 的原話就是這支檔的規格,順序不能顛倒:
//
// 「這些庫都早就萃好了,所以它要**辨識這個庫已經有 wiki,那就直接 ingest 了**」
// 「它應該**第一步查看 wiki 裡有沒有內容**,再去辨識哪些檔案是需要讀的,
// **只有文件要讀,程式碼不用讀**」
//
// 🔴 為什麼非做不可(實測,非推測):leo 把 5 個資料夾接上去,App 顯示
// 12,022 份檔案、11,183 卡在佇列。光 `InkStoneCo` 一個就 8,339 份——
// **而那個 repo 真正整理好的知識庫只有 `system-dev/wiki/` 的幾十張,差兩個數量級。**
// 更糟的是掃到 45 個名為 `wiki` 的目錄(8 份 `.claude/worktrees`、5 份出貨用 worktree、
// 8 份 `templatefs` 範本、還有 `.next``.vercel` 建置產物)⇒ **同一份被收十幾次**,
// 與 2026-08-13 查明的「48 萬筆廢資料」是同款成因(重複放大)。
//
// ⇒ 本檔回答一個問題:**這個監看根該收哪些檔**。三種答案:
//
// IngestAll 一般資料夾/筆記庫——收全部(行為與先前完全一致,零改變)
// IngestCuratedWiki 版控中的專案,而且**已經有整理好的 wiki** ⇒ 只收那一份
// IngestDocsOnly 版控中的專案,但沒有現成 wiki ⇒ 只收文件區,程式碼一律不讀
//
// 🔴 「不要用副檔名白名單當唯一判準」(票上的紅線):本檔的主判準是**路徑身分**
// (這個目錄在這個 repo 裡扮演什麼角色),副檔名只是最後一道。`.md` 在 repo 裡
// 大多不是知識——是 README、是 CHANGELOG、是 template 範本、是建置產物。
//
// 🔴 「排除規則要看得見」(票上的紅線,同 #121):本檔算出來的每一條理由都會經由
// TriggerPayload.Plan 走進 status.json ⇒ 使用者看得到「有 8,000 個檔沒被收,因為它們是程式碼」,
// 而不是安靜地少收。
package collector
import (
"os"
"path/filepath"
"sort"
"strings"
)
// IngestMode=這個監看根的收檔策略。
type IngestMode string
const (
IngestAll IngestMode = "all" // 收全部(一般資料夾/筆記庫)
IngestCuratedWiki IngestMode = "curated-wiki" // 只收現成的整理好的 wiki
IngestDocsOnly IngestMode = "docs-only" // 只收文件區
)
// IngestPlan=掃描前算出來的策略,連同「講給使用者聽的理由」。
//
// 🔴 Reason 不是 debug 訊息,是**產品文案**:使用者看到「12,022 個檔只送了 32 個」
// 的當下,唯一能讓他不慌的東西就是這句話。用他的話寫,不要寫路徑術語。
type IngestPlan struct {
Mode IngestMode `json:"mode"`
// RepoRoot=版控工作目錄的根(Mode != IngestAll 時非空)。
RepoRoot string `json:"repo_root,omitempty"`
// WikiRelDir=現成 wiki 的相對路徑(僅 IngestCuratedWiki)。
WikiRelDir string `json:"wiki_rel_dir,omitempty"`
// DocRelDirs=要收的文件目錄(僅 IngestDocsOnly;根層 .md 另由 keepsRootDoc 放行)。
DocRelDirs []string `json:"doc_rel_dirs,omitempty"`
// Reason=一句話講給使用者聽的「為什麼只收這些」。
Reason string `json:"reason"`
// OtherWikiDirs=這個 repo 底下**其他**子專案自己的 wiki(相對路徑)。
// 刻意不收(見 wantsPath 的說明),但一定要列出來——不然使用者只會覺得東西不見了。
OtherWikiDirs []string `json:"other_wiki_dirs,omitempty"`
}
// curatedWikiCandidates=「整理好的知識庫」慣例位置,依優先序。
// `system-dev/wiki` 是 system-dev-template 的規約(leo 全部的 repo 都是這個),
// 其餘兩個是一般開源專案的慣例。
var curatedWikiCandidates = []string{"system-dev/wiki", "docs/wiki", "wiki"}
// docDirCandidates=沒有現成 wiki 時,「文件住在哪」的慣例位置。
var docDirCandidates = []string{"docs", "doc", "documentation"}
// noiseDirNames=任何模式下都整棵跳過的目錄名。
//
// 分三類,全部都是「這個 repo 的零件,不是誰的知識」:
// - 依賴:別人的原始碼,不是使用者的
// - 建置產物:從別的檔生出來的,收了就是同一份內容收兩次(#104 實據:`.next``.vercel`
// - 範本/樣板:`templatefs` 是我們自己要鋪給別人的檔,收自己鋪的東西最荒謬
// #104 實據:8 份 `collector/templatefs/system-dev/wiki`
var noiseDirNames = map[string]bool{
// 依賴
"node_modules": true, "vendor": true, "bower_components": true,
"site-packages": true, "venv": true, "virtualenv": true, "__pycache__": true,
"Pods": true, "Carthage": true,
// 建置產物/快取
"dist": true, "build": true, "out": true, "target": true, "bin": true, "obj": true,
".next": true, ".nuxt": true, ".vercel": true, ".output": true, ".turbo": true,
".parcel-cache": true, "coverage": true, ".pytest_cache": true, ".gradle": true,
// 範本/樣板(我們自己鋪給別人的檔)
"templatefs": true, "template-fs": true, "skeleton": true,
// 測試素材(不是知識,是給程式吃的樣本)
"testdata": true, "fixtures": true, "__fixtures__": true, "__snapshots__": true,
}
// PlanIngest 決定某個監看根的收檔策略。**這支是 #104 的入口,Scan 在走訪前呼叫一次。**
//
// 判斷順序刻意照 leo 的原話:先問「是不是專案」,再問「有沒有現成 wiki」,最後才退到文件區。
//
// 為什麼「是不是專案」用版控(`.git`)判:那是唯一不必猜的訊號,而且與 #105 同一個判準
// ——一個資料夾在版控裡,就代表裡面有人在追每個檔案的歷史,那幾乎必然是原始碼專案而不是
// 誰的筆記本。判準只有一個地方(repoguard.go),兩張票共用,不會漂移。
func PlanIngest(absRoot string) IngestPlan {
repoRoot := DetectRepoRoot(absRoot)
if repoRoot == "" {
return IngestPlan{
Mode: IngestAll,
Reason: "這是一般資料夾,裡面的文件我全部都會讀。",
}
}
others := otherWikiDirs(absRoot)
if wiki := findCuratedWiki(absRoot); wiki != "" {
return IngestPlan{
Mode: IngestCuratedWiki,
RepoRoot: repoRoot,
WikiRelDir: wiki,
Reason: "這是一個開發專案,而且你已經整理好一份知識庫(" + wiki + ")——" +
"我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。",
OtherWikiDirs: others,
}
}
docs := existingDocDirs(absRoot)
reason := "這是一個開發專案,我只讀文件、不讀程式碼。"
if len(docs) > 0 {
reason = "這是一個開發專案,我只讀文件(" + strings.Join(docs, "、") + ")與根目錄的說明檔,不讀程式碼。"
}
return IngestPlan{
Mode: IngestDocsOnly,
RepoRoot: repoRoot,
DocRelDirs: docs,
Reason: reason,
OtherWikiDirs: others,
}
}
// findCuratedWiki 回傳第一個「存在且真的有內容」的現成 wiki 目錄(相對路徑),沒有回空字串。
//
// 「有內容」=底下至少有一個 `.md`。空目錄不算——不然一個剛跑完 template 安裝、
// wiki 還沒寫的 repo 會被判成 curated-wiki,結果一個檔都不收(那比收太多更糟:
// 使用者會以為系統壞了)。
func findCuratedWiki(absRoot string) string {
for _, rel := range curatedWikiCandidates {
dir := filepath.Join(absRoot, filepath.FromSlash(rel))
if !isDir(dir) {
continue
}
if dirHasMarkdown(dir) {
return rel
}
}
return ""
}
// dirHasMarkdown 淺層回答「這個目錄樹裡有沒有 .md」,找到一個就停(不走完整棵)。
func dirHasMarkdown(dir string) bool {
found := false
_ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err != nil || found {
return nil
}
if d.IsDir() {
if p != dir && strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir
}
return nil
}
if strings.EqualFold(filepath.Ext(d.Name()), ".md") && !strings.HasPrefix(d.Name(), ".") {
found = true
}
return nil
})
return found
}
func existingDocDirs(absRoot string) []string {
var out []string
for _, rel := range docDirCandidates {
if isDir(filepath.Join(absRoot, filepath.FromSlash(rel))) {
out = append(out, rel)
}
}
return out
}
// otherWikiDirs 找出監看根底下**其他**地方的 wiki(子專案自己的知識庫)。
//
// 🔴 為什麼找出來卻不收:leo 的 `InkStoneCo` 底下有 `products/*`、`matrix/*` 這些
// **各自獨立的 repo**,每一個都有自己的 `system-dev/wiki`。它們是**別的專案**的知識,
// 混進這個資料夾的知識庫裡,AR-Mira 搜一個主題就會回一堆分不清屬於誰的東西。
// 要收哪一個,是使用者的決定——把那個子專案自己加進看守清單即可。
//
// 但**一定要講出來**:使用者接了一個 monorepo 卻只看到 32 張卡,不告訴他其餘的在哪,
// 他只會覺得東西不見了(票上的紅線:不要讓他猜)。
//
// 走訪時套用與 Scan 相同的跳過規則(隱藏目錄、noise、linked worktree、
// 已知的 curated 位置自己),所以出貨用 worktree 與 templatefs 的那十幾份不會列進來。
func otherWikiDirs(absRoot string) []string {
seen := map[string]bool{}
for _, rel := range curatedWikiCandidates {
seen[rel] = true
}
var out []string
_ = filepath.WalkDir(absRoot, func(p string, d os.DirEntry, err error) error {
if err != nil || !d.IsDir() || p == absRoot {
return nil
}
name := d.Name()
if strings.HasPrefix(name, ".") || noiseDirNames[name] {
return filepath.SkipDir
}
if IsLinkedWorktree(p) {
return filepath.SkipDir // 同一個 repo 的第二份簽出,內容重複
}
rel, rerr := filepath.Rel(absRoot, p)
if rerr != nil {
return nil
}
relSlash := filepath.ToSlash(rel)
if name == "wiki" && !seen[relSlash] && dirHasMarkdown(p) {
out = append(out, relSlash)
return filepath.SkipDir
}
return nil
})
sort.Strings(out)
return out
}
// ─────────────────────────────────────────────────────────────────────────────
// 走訪判準:Scan 在 WalkDir 裡逐目錄/逐檔問這兩支
// ─────────────────────────────────────────────────────────────────────────────
// SkipsDir 回答「走訪時要不要整棵跳過這個目錄」。relSlash 是相對監看根的路徑。
//
// 三類跳過,全模式適用:
// 1. noise(依賴/建置產物/範本)——名字判準,見 noiseDirNames
// 2. linked worktree——`.git` 是檔案。#104 實據裡的 `.claude/worktrees/…` 與
// `products/arcrun-rag-wt60ship` 全是這一種:主 repo 的第二份簽出,收了就是重複
// 3. 巢狀 repo(自己有 `.git` 的子目錄)——那是別的專案,見 otherWikiDirs 的說明
//
// 再加上模式限定的:curated-wiki 只走那一份 wiki 的路;docs-only 只走文件目錄的路。
// (隱藏目錄由 Scan 自己擋,那條規則比本檔更早存在,不搬過來。)
func (p IngestPlan) SkipsDir(relSlash, absPath string) bool {
name := filepath.Base(relSlash)
if noiseDirNames[name] {
return true
}
if IsLinkedWorktree(absPath) {
return true
}
// 巢狀 repo:監看根自己不算(relSlash == "." 走不到這裡,Scan 只對子目錄呼叫)。
if IsRepoRoot(absPath) {
return true
}
switch p.Mode {
case IngestCuratedWiki:
// 只有「通往那份 wiki 的路」與「那份 wiki 底下」要走。
return !onPathTo(relSlash, p.WikiRelDir)
case IngestDocsOnly:
for _, d := range p.DocRelDirs {
if onPathTo(relSlash, d) {
return false
}
}
return true
}
return false
}
// KeepsFile 回答「這個檔要不要收」。relSlash 是相對監看根的路徑。
//
// curated-wiki:只收那份 wiki 底下的檔。
// docs-only:收文件目錄底下的檔,外加**根層的說明檔**READMECONTRIBUTING 那些是
// 專案唯一的入門文件,把它們漏掉,一個只有 README 的 repo 會變成一個檔都不收)。
// all:全收,判準交回 Scan 原本的副檔名白名單。
func (p IngestPlan) KeepsFile(relSlash string) bool {
switch p.Mode {
case IngestCuratedWiki:
return strings.HasPrefix(relSlash, p.WikiRelDir+"/")
case IngestDocsOnly:
for _, d := range p.DocRelDirs {
if strings.HasPrefix(relSlash, d+"/") {
return true
}
}
return !strings.Contains(relSlash, "/") // 根層說明檔
}
return true
}
// OverridesTemplateOwned 回答「這個檔雖然被 TemplateOwns 認成『開發用的』,
// 但本次策略仍然要收它嗎」。
//
// 🔴 只有 curated-wiki 模式、且只針對那份 wiki 底下的檔會回 true。
//
// 為什麼需要這個開關:`TemplateOwns` 把 `system-dev/` 整棵當成「開發用的、不該進知識庫」
// 2026-08-06,理由是「我們代裝進使用者資料夾的 template 產物不是他的知識」)。
// 但 leo 2026-08-14 的規格要的正是那個位置——`system-dev/wiki/` 在**他自己的 repo** 裡
// 是他親手整理的知識庫,而且是唯一該收的東西。
//
// 兩條規則對撞,用**身分**化解而不是拿掉任何一條:
// - 我們代裝的資料夾沒有 `.git` ⇒ PlanIngest 回 IngestAll ⇒ 這裡回 false ⇒ 舊規則照舊
// - 他自己的 repo 有 `.git` 且有現成 wiki ⇒ curated-wiki ⇒ 這裡回 true ⇒ 收那份 wiki
func (p IngestPlan) OverridesTemplateOwned(relSlash string) bool {
if p.Mode != IngestCuratedWiki {
return false
}
return strings.HasPrefix(relSlash, p.WikiRelDir+"/")
}
// onPathTo 回答「relSlash 是不是 target 的祖先、target 自己、或 target 的子孫」
// ——也就是走訪時「這條路要不要繼續走下去」。
func onPathTo(relSlash, target string) bool {
if relSlash == target ||
strings.HasPrefix(relSlash, target+"/") ||
strings.HasPrefix(target, relSlash+"/") {
return true
}
return false
}
+273
View File
@@ -0,0 +1,273 @@
// ingestplan_test.go — arcrun-rag#104 的驗收網:**接上一個開發用的 repo 時,
// 送進知識庫的應該是那幾十張整理好的卡,不是幾千個原始檔。**
//
// fixture 刻意照票上那份實據的形狀造:45 個名為 wiki 的目錄裡,8 份 `.claude/worktrees`、
// 5 份出貨用 worktree、8 份 `templatefs` 範本、還有 `.next``.vercel` 建置產物。
package collector
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// eventPaths 取出本輪要送上去的檔案路徑(排序,好當實測輸出貼進 issue)。
func eventPaths(p *TriggerPayload) []string {
var out []string
for _, e := range p.Events {
out = append(out, e.Path)
}
sort.Strings(out)
return out
}
// scanWithPlan 照 daemon 的真實接法跑一輪掃描(PlanIngest curated-wiki 時解除
// system-dev 的 SkipDirNames,見 direct.go 那段註解)。
func scanWithPlan(t *testing.T, root string) (*TriggerPayload, IngestPlan) {
t.Helper()
plan := PlanIngest(root)
skip := map[string]bool{"system-dev": true}
if plan.Mode == IngestCuratedWiki && strings.HasPrefix(plan.WikiRelDir, "system-dev/") {
skip = map[string]bool{}
}
m := &Manifest{Entries: map[string]*ManifestEntry{}}
payload, err := Scan(root, m, ScanOptions{SkipDirNames: skip, Plan: plan})
if err != nil {
t.Fatalf("掃描失敗:%v", err)
}
return payload, plan
}
// makeMonorepoFixture 造一個「leo 的 InkStoneCo」形狀的 repo。
// 回傳「真正整理好的那份 wiki」有幾個檔——那就是唯一該被送上去的量。
func makeMonorepoFixture(t *testing.T) (root string, curatedCount int) {
t.Helper()
root = t.TempDir()
files := map[string]string{}
// ① 真正整理好的知識庫(唯一該收的)
curated := []string{"status.md", "principles.md", "mistakes.md", "decisions-summary.md", "INDEX.md"}
for _, n := range curated {
files["system-dev/wiki/"+n] = "# 整理好的知識:" + n
}
files["system-dev/wiki/cards/autonomy/自動派工心法.md"] = "# 我自己寫的卡"
curatedCount = len(curated) + 1
// ② 程式碼與一般專案檔(leo:「只有文件要讀,程式碼不用讀」)
for i := 0; i < 40; i++ {
files[fmt.Sprintf("collector/file%02d.go", i)] = "package collector"
files[fmt.Sprintf("collector/note%02d.md", i)] = "# 散落在程式碼旁邊的說明"
}
files["README.md"] = "# 專案說明"
files["CHANGELOG.md"] = "# 版本紀錄"
// ③ templatefs 範本:我們自己要鋪給別人的檔,不是誰的知識(實據:8 份)
for i := 0; i < 8; i++ {
files[fmt.Sprintf("collector/templatefs/system-dev/wiki/status%d.md", i)] = "# 範本"
}
// ④ 建置產物(實據:.next.vercel
files["landing/.next/server/pages/doc.md"] = "# 建置產物"
files["landing/.vercel/output/static/doc.md"] = "# 建置產物"
files["landing/node_modules/some-pkg/README.md"] = "# 別人的套件"
for rel, body := range files {
p := filepath.Join(root, filepath.FromSlash(rel))
mustMkdir(t, filepath.Dir(p))
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// 監看根是版控中的專案
mustMkdir(t, filepath.Join(root, ".git"))
// ⑤ 出貨用 worktree`.git` 是**檔案**)——主 repo 的第二份簽出,內容重複(實據:5 份)
for i := 0; i < 5; i++ {
wt := filepath.Join(root, fmt.Sprintf("products/arcrun-rag-wt%dship", i))
mustMkdir(t, filepath.Join(wt, "system-dev", "wiki"))
if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, n := range curated {
if err := os.WriteFile(filepath.Join(wt, "system-dev", "wiki", n), []byte("# 同一份 wiki 的第 N 份副本"), 0o644); err != nil {
t.Fatal(err)
}
}
}
// ⑥ 巢狀的獨立子專案(自己有 .git 目錄)——那是別的專案的知識
sub := filepath.Join(root, "products", "arcrun-rag")
mustMkdir(t, filepath.Join(sub, ".git"))
mustMkdir(t, filepath.Join(sub, "system-dev", "wiki"))
if err := os.WriteFile(filepath.Join(sub, "system-dev", "wiki", "status.md"), []byte("# 子專案自己的知識"), 0o644); err != nil {
t.Fatal(err)
}
return root, curatedCount
}
// 🔴 #104 的驗收條件本身:接上一個開發 repo,送進去的是幾十張,不是幾千個。
func TestPlanIngest_MonorepoSendsOnlyCuratedWiki(t *testing.T) {
root, curatedCount := makeMonorepoFixture(t)
// 對照組:沒有策略時(也就是修好之前的行為)會送多少。
baseM := &Manifest{Entries: map[string]*ManifestEntry{}}
baseline, err := Scan(root, baseM, ScanOptions{SkipDirNames: map[string]bool{"system-dev": true}})
if err != nil {
t.Fatal(err)
}
payload, plan := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("策略:%s%s", plan.Mode, plan.Reason)
t.Logf("修好之前會送:%d 個檔|現在送:%d 個檔(策略擋掉 %d 個)",
len(baseline.Events), len(got), payload.ExcludedByPlan)
for _, p := range got {
t.Logf(" → %s", p)
}
if plan.Mode != IngestCuratedWiki {
t.Fatalf("策略=%swant %s(這個 repo 有現成的 system-dev/wiki", plan.Mode, IngestCuratedWiki)
}
if len(got) != curatedCount {
t.Fatalf("送了 %d 個檔,want %d(只有整理好的那份 wiki):%v", len(got), curatedCount, got)
}
for _, p := range got {
if !strings.HasPrefix(p, "system-dev/wiki/") {
t.Fatalf("送了不該送的檔:%s", p)
}
}
// 量級檢查:這一票的實據是「差 115 倍」,修好之後不該只差一點點。
if len(baseline.Events) < len(got)*5 {
t.Fatalf("對照組只有 %d 個檔,fixture 沒造出「被淹沒」的形狀,這個測試證明不了什麼",
len(baseline.Events))
}
}
// 同一份 wiki 被出貨用 worktreetemplatefs 收十幾次——那是「48 萬筆廢資料」的同款成因。
func TestPlanIngest_NoDuplicateWikiCopies(t *testing.T) {
root, _ := makeMonorepoFixture(t)
payload, _ := scanWithPlan(t, root)
seen := map[string]int{}
for _, p := range eventPaths(payload) {
seen[filepath.Base(p)]++
}
for base, n := range seen {
if n > 1 {
t.Fatalf("%s 被收了 %d 次——同一份內容重複放大,AR-Mira 會搜出一堆一模一樣的東西", base, n)
}
}
for _, p := range eventPaths(payload) {
for _, bad := range []string{"templatefs/", "-wt0ship/", ".next/", ".vercel/", "node_modules/"} {
if strings.Contains(p, bad) {
t.Fatalf("收到了不該收的 %s(命中 %q", p, bad)
}
}
}
}
// 排除規則要看得見:使用者要知道「有幾千個檔沒被收、為什麼」,以及「其他子專案的 wiki 在哪」。
func TestPlanIngest_ExclusionsAreVisible(t *testing.T) {
root, _ := makeMonorepoFixture(t)
payload, plan := scanWithPlan(t, root)
if plan.Reason == "" {
t.Fatal("沒有給使用者一句話解釋——他只會覺得系統壞了(票上的紅線)")
}
if !strings.Contains(plan.Reason, plan.WikiRelDir) {
t.Fatalf("理由沒說出是讀了哪一份 wiki%q", plan.Reason)
}
if payload.ExcludedByPlan <= 0 {
t.Fatal("擋掉的檔案數是 0——那個數字就是「有 N 個檔沒被收」要顯示的東西")
}
// 子專案的 wiki 刻意不收,但一定要列出來讓使用者知道去哪裡找。
if len(plan.OtherWikiDirs) == 0 {
t.Fatal("沒列出子專案自己的 wiki——使用者接了 monorepo 只看到幾十張,會以為東西不見了")
}
t.Logf("理由:%s", plan.Reason)
t.Logf("擋掉 %d 個檔|其他子專案的 wiki%v", payload.ExcludedByPlan, plan.OtherWikiDirs)
}
// 沒有現成 wiki 的 repo:退到「只讀文件、不讀程式碼」(leo 明講的第三步)。
func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
root := t.TempDir()
files := map[string]string{
"README.md": "# 專案",
"docs/請假規則.md": "# 特休 14 天",
"docs/報銷政策.md": "# 每日 3000 元",
"src/main.go": "package main",
"src/說明.md": "# 散在程式碼旁邊",
"internal/notes.md": "# 也是程式碼旁邊",
}
for rel, body := range files {
p := filepath.Join(root, filepath.FromSlash(rel))
mustMkdir(t, filepath.Dir(p))
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
mustMkdir(t, filepath.Join(root, ".git"))
payload, plan := scanWithPlan(t, root)
got := eventPaths(payload)
t.Logf("策略:%s%s)|送:%v", plan.Mode, plan.Reason, got)
if plan.Mode != IngestDocsOnly {
t.Fatalf("策略=%swant %s", plan.Mode, IngestDocsOnly)
}
want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("送了 %vwant %v(程式碼旁邊的 .md 不該收)", got, want)
}
}
// 一般資料夾/筆記庫:行為與修這一票之前**完全一致**,全部照收。
// 這條是防止「修好 repo 卻把一般使用者的資料夾弄壞」——他們才是產品的主要客群。
func TestPlanIngest_PlainFolderUnchanged(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, map[string]string{
"會議記錄.md": "# 會議",
"專案/請假規則.md": "# 特休",
"專案/深/一點/報銷.md": "# 報銷",
"src/main.go": "package main", // 非文件,本來就被副檔名白名單擋
"隨手/notes/2026.md": "# 隨手記",
})
plan := PlanIngest(root)
if plan.Mode != IngestAll {
t.Fatalf("一般資料夾的策略=%swant %s——不准因為修 repo 而改變一般使用者的行為",
plan.Mode, IngestAll)
}
payload, _ := scanWithPlan(t, root)
got := eventPaths(payload)
want := []string{"會議記錄.md", "專案/深/一點/報銷.md", "專案/請假規則.md", "隨手/notes/2026.md"}
sort.Strings(want)
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("一般資料夾送了 %vwant %v", got, want)
}
if payload.ExcludedByPlan != 0 {
t.Fatalf("一般資料夾不該有任何檔被策略擋掉,卻擋了 %d 個", payload.ExcludedByPlan)
}
}
// 空的 wiki 目錄不算「已經整理好」——不然剛裝完 template 的 repo 會一個檔都不收,
// 那比收太多更糟(使用者會以為系統壞了)。
func TestPlanIngest_EmptyWikiFallsBackToDocs(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "system-dev", "wiki"))
mustMkdir(t, filepath.Join(root, ".git"))
writeFixture(t, root, map[string]string{"docs/說明.md": "# 說明"})
plan := PlanIngest(root)
if plan.Mode != IngestDocsOnly {
t.Fatalf("wiki 是空的,策略應退到 %s,卻是 %s", IngestDocsOnly, plan.Mode)
}
}
+41
View File
@@ -55,3 +55,44 @@ func IsMarked(base string) bool {
func UnmarkName(base string) string {
return strings.TrimPrefix(base, MachineMark)
}
// IsMachineOwnedRel 回答「這個相對路徑是不是我們寫的東西」——**規約的完整判準**,
// 迴歸網(vault_footprint_test.govault_subdir_test.go)用的就是這一支。
//
// 兩條路,第二條是規約唯一認可的例外:
//
// ① basename 帶 MachineMark ⇒ 走在使用者的檔案之間也一眼分得出(規約主體)
// ② 路徑上**任何一層**是 `.arcrun-rag/` ⇒ **那個目錄名自己就是標記**,且它是隱藏目錄
//
// ②「任何一層」而不是「開頭」:呼叫端給的相對起點不一定是監看根——迴歸網比對整個
// 筆記庫時,同一個檔的相對路徑是 `docs/.arcrun-rag/…`(監看根是 vault 底下的 docs/)。
// 判「誰擁有這個檔」不該受呼叫端從哪裡起算影響。
//
// 🔴 為什麼需要 ②(arcrun-rag#105):我們得在使用者的 repo 裡放一份 `.gitignore`
// (讓整個工作區對 git 隱形,他跑一輪 `git status` 才會是乾淨的)。那個檔名是
// **git 定的,我們改不得**——叫 `arcrun-.gitignore` 的話 git 根本不認,功能等於沒做。
//
// 這不是替規約開後門:規約的目的是「打開資料夾一眼分得出哪些是機器寫的」,
// 而 `.arcrun-rag/` 這個目錄名把整叢東西一次講完,比逐檔前綴**更強**。
// ⚠️ 例外只有這一個。監看根底下**其他任何位置**的新檔,一律得帶前綴——
// 想在別處寫一個「名字不能改」的檔時,先想清楚那個檔為什麼不能住進 `.arcrun-rag/`。
func IsMachineOwnedRel(relSlash string) bool {
if IsMarked(pathBase(relSlash)) {
return true
}
for _, seg := range strings.Split(relSlash, "/") {
if seg == workspaceRelDir {
return true
}
}
return false
}
// pathBase 取斜線路徑的最後一段(不用 filepath.Base:這裡的輸入一律是斜線分隔的
// 相對路徑,不該受執行平台的分隔符影響)。
func pathBase(relSlash string) string {
if i := strings.LastIndex(relSlash, "/"); i >= 0 {
return relSlash[i+1:]
}
return relSlash
}
+17
View File
@@ -163,13 +163,30 @@ func run(args []string, mode runMode) int {
if err != nil {
return fail(err)
}
// arcrun-rag#104CLI 與 daemon 走同一套收檔策略——兩邊對同一個資料夾得出不同答案
// 本身就是 bug(同 vault.go 開頭那條「安裝器與 daemon 必須一致」)。
// 這裡刻意**不**動 CLI 原有的 SkipDirNames 行為(它本來就沒有;`system-dev/` 由
// TemplateOwns 擋,curated-wiki 模式的解除也走 Plan 那條路,見 scan.go)。
plan := PlanIngest(absRoot)
payload, err := Scan(absRoot, m, ScanOptions{
MaxRemovedRatio: *ratio,
SkipPaths: map[string]bool{absManifest: true}, // manifest 若住在 root 底下,不掃自己
Plan: plan,
})
if err != nil {
return fail(err)
}
// 策略走 stderrstdout 是給程式吃的 payload JSON,不能污染。
// 印出來不是 debug 訊息,是 #104 的紅線——排除規則要看得見。
fmt.Fprintf(os.Stderr, "收檔策略:%s — %s\n", plan.Mode, plan.Reason)
if payload.ExcludedByPlan > 0 {
fmt.Fprintf(os.Stderr, "依這個策略跳過了 %d 個檔案。\n", payload.ExcludedByPlan)
}
if len(plan.OtherWikiDirs) > 0 {
fmt.Fprintf(os.Stderr,
"這個資料夾底下還有 %d 個子專案有自己的知識庫,我沒有收(要收請個別加進看守清單):%v\n",
len(plan.OtherWikiDirs), plan.OtherWikiDirs)
}
exitCode := 0
+124
View File
@@ -0,0 +1,124 @@
// 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)
}
+160
View File
@@ -0,0 +1,160 @@
// repoguard_test.go — arcrun-rag#105 的驗收網:**把一個 git repo 加進看守清單跑一輪,
// git status 必須乾淨。**
//
// 這張網刻意分兩層:
// - 單元層(不需要 git 執行檔):MigrateCardNames 在版控資料夾裡一個檔都沒動。
// 這是 2026-08-14 那件事的直接迴歸——當時被壓平改名的正是
// `system-dev/wiki/cards/autonomy/*.md`。
// - 端到端層(需要 git):真的 `git init`、真的 commit、真的跑一輪同步、
// 真的 `git status --porcelain` ⇒ 必須是空的。沒有 git 就 skip,不假裝驗過。
package collector
import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
// gitOrSkip 準備一個「已 commit 乾淨」的 repo;環境沒有 git 就 skip。
func gitOrSkip(t *testing.T, dir string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("這台機器沒有 git,跳過端到端層(單元層仍然有跑)")
}
for _, args := range [][]string{
{"init", "-q"},
{"config", "user.email", "test@example.com"},
{"config", "user.name", "test"},
{"add", "-A"},
{"commit", "-q", "-m", "初始"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v 失敗:%v\n%s", args, err, out)
}
}
}
func gitStatus(t *testing.T, dir string) []string {
t.Helper()
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git status 失敗:%v\n%s", err, out)
}
var lines []string
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if strings.TrimSpace(l) != "" {
lines = append(lines, l)
}
}
return lines
}
// 🔴 2026-08-14 21:45 那件事的直接迴歸:leo 的 `InkStoneCo` 在看守清單裡,
// daemon 把 `system-dev/wiki/cards/autonomy/` 整個子目錄壓平改名,
// 16 個版控中的檔案顯示為刪除。
func TestMigrateCardNames_VersionedRepoUntouched(t *testing.T) {
root := t.TempDir()
// leo 自己寫的知識卡,住在 template 規約的卡片產物區底下(還有自己開的子目錄)。
writeFixture(t, root, map[string]string{
"system-dev/wiki/cards/autonomy/自動派工心法.md": "# 我自己寫的",
"system-dev/wiki/cards/autonomy/接關術.md": "# 我自己寫的",
"system-dev/wiki/cards/決策紀錄.md": "# 我自己寫的",
})
// 這個資料夾在版控裡(只放 .git 目錄就夠——判準不需要 git 執行檔)。
mustMkdir(t, filepath.Join(root, ".git"))
before := snapshotTree(t, root)
mig := MigrateCardNames(root)
after := snapshotTree(t, root)
if mig.Moved != 0 {
t.Fatalf("在版控資料夾裡動了 %d 個檔——這正是 #105 的病", mig.Moved)
}
if mig.RepoRoot != root {
t.Fatalf("沒認出這是版控資料夾(RepoRoot=%qwant %q", mig.RepoRoot, root)
}
// Blocked 只算「確定是我們寫的」——使用者自己的卡不該被算進去報一個假數字。
if mig.Blocked != 0 {
t.Fatalf("Blocked=%d,但這三張都是使用者自己的卡(沒帶 arcrun- 標記),"+
"報進去等於告訴他「有 3 個舊卡片沒整理」,那是假訊息", mig.Blocked)
}
if len(before) != len(after) {
t.Fatalf("檔案數變了:%d → %d", len(before), len(after))
}
for rel, want := range before {
got, still := after[rel]
if !still {
t.Fatalf("版控中的檔案不見了:%s(這就是 leo 看到的那 16 個刪除)", rel)
}
if got.hash != want.hash {
t.Fatalf("版控中的檔案被改了:%s", rel)
}
}
}
// 我們自己寫的卡(帶標記、只是位置舊)在版控資料夾裡同樣不自動動,
// 但**要被算進 Blocked 並講給使用者聽**——那個數字是誠實的,因為那些確實是我們的。
func TestMigrateCardNames_VersionedRepoReportsOwnCards(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, map[string]string{
"system-dev/wiki/cards/arcrun-舊卡.md": "# 我們寫的,位置舊了",
"system-dev/wiki/cards/他自己的卡.md": "# 使用者的",
})
mustMkdir(t, filepath.Join(root, ".git"))
mig := MigrateCardNames(root)
if mig.Moved != 0 {
t.Fatalf("版控資料夾裡不該動任何檔,卻動了 %d 個", mig.Moved)
}
if mig.Blocked != 1 {
t.Fatalf("Blocked=%dwant 1(只有那張帶 arcrun- 標記的是我們的)", mig.Blocked)
}
}
// 非版控資料夾的行為必須與 #60 第三輪完全一致——本次改動不准把那兩輪的成果弄壞。
func TestMigrateCardNames_NonRepoStillMigrates(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, map[string]string{
"system-dev/wiki/cards/舊卡.md": "# 舊",
})
mig := MigrateCardNames(root)
if mig.Moved != 1 {
t.Fatalf("非版控資料夾應照舊自動歸位,Moved=%d want 1", mig.Moved)
}
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-舊卡.md")); err != nil {
t.Fatalf("舊卡沒被改名:%v", err)
}
}
// 🔴 #105 的驗收條件本身:**把一個 git repo 加進看守清單跑一輪,git status 必須乾淨。**
func TestSyncOnce_GitRepoStaysClean(t *testing.T) {
root := t.TempDir()
writeFixture(t, root, map[string]string{
"system-dev/wiki/cards/autonomy/自動派工心法.md": "# 我自己寫的知識卡",
"system-dev/wiki/status.md": "# 現況",
"docs/請假規則.md": "# 特休 14 天",
"README.md": "# 這個專案",
"main.go": "package main",
})
gitOrSkip(t, root)
if dirty := gitStatus(t, root); len(dirty) > 0 {
t.Fatalf("前置條件就不乾淨:%v", dirty)
}
runSyncOnce(t, root)
if dirty := gitStatus(t, root); len(dirty) > 0 {
sort.Strings(dirty)
t.Fatalf("跑完一輪後 git status 髒了(#105 的驗收條件就是這個):\n%s",
strings.Join(dirty, "\n"))
}
}
+45 -2
View File
@@ -118,6 +118,13 @@ type TriggerPayload struct {
// DuplicateFormats=本輪偵測到、同檔名主幹的多格式重複(2026-08-07,見 FormatDuplicate)。
// 同 Skipped:只給本機使用者看,不隨 payload 送雲端(schema additionalProperties:false 會擋)。
DuplicateFormats []FormatDuplicate `json:"-"`
// PlanExcludedByPlan=這一輪用了什麼收檔策略、據此擋掉幾個檔(arcrun-rag#104)。
// 同上,`json:"-"`:給本機使用者看的,不送雲端。
// 🔴 這兩個欄位就是 #104 那條紅線的載體——「排除規則要看得見」。少了它們,
// 使用者接上一個一萬檔的 repo 只看到 32 個進度,會以為系統壞了。
Plan IngestPlan `json:"-"`
ExcludedByPlan int `json:"-"`
}
// FormatDuplicate=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。
@@ -217,6 +224,9 @@ type ScanOptions struct {
// SkipDirNames:目錄名黑名單(任一層命中整棵跳過)。daemon-beta task 2
// template 代裝後 `system-dev/`wiki 產物區)不得被當成原稿掃進 ingest。
SkipDirNames map[string]bool
// Plan:收檔策略(arcrun-rag#104)。零值=IngestAll,行為與加這個欄位之前**完全一致**
// ——既有呼叫端與測試不必全部改。要拿到 #104 的效果就傳 PlanIngest(root)。
Plan IngestPlan
}
const DefaultMaxRemovedRatio = 0.4
@@ -259,11 +269,21 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
var skipped []SkippedFile
skippedOther := 0
var skippedOtherNames []string
// arcrun-rag#104:被策略擋掉的檔案數。**一定要數出來**——票上的紅線是
// 「用戶要知道有 8,000 個檔沒被收,因為它們是程式碼」,不是安靜地少收。
excludedByPlan := 0
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
name := d.Name()
relOf := func() (string, bool) {
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return "", false
}
return filepath.ToSlash(rel), true
}
if d.IsDir() {
if p != root && strings.HasPrefix(name, ".") {
return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過
@@ -271,11 +291,23 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
if p != root && opts.SkipDirNames[name] {
return filepath.SkipDir // 名單目錄(system-dev…)整棵跳過
}
// #104:依策略整棵跳過(依賴/建置產物/範本/worktree/巢狀 repo
// 以及非本次策略要收的區域)。整棵跳掉的檔不逐一計數——
// 那個數字對使用者沒有意義,Plan.Reason 那句話才是他要的解釋。
if p != root {
if rel, ok := relOf(); ok && opts.Plan.SkipsDir(rel, p) {
return filepath.SkipDir
}
}
return nil
}
if strings.HasPrefix(name, ".") {
return nil
}
if rel, ok := relOf(); ok && !opts.Plan.KeepsFile(rel) {
excludedByPlan++
return nil
}
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
return nil
}
@@ -284,8 +316,17 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
// template 本來就不是隱藏的,靠隱藏判斷會漏掉一大半。
// 也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的,
// 我們自己鋪的東西不該佔用他的注意力。
if rel, rerr := filepath.Rel(root, p); rerr == nil && TemplateOwns(filepath.ToSlash(rel)) {
return nil
//
// 🔴 arcrun-rag#104 例外:curated-wiki 模式下,`system-dev/wiki/` **正是要收的那一份**。
// 上面那條規則(2026-08-06)與 leo 2026-08-14 的規格直接對撞,衝突由身分化解:
// · 我們代裝 template 的資料夾(沒有 `.git`)⇒ 那些檔是**我們鋪的**,照舊不收
// · 使用者自己的 repo(有 `.git`)⇒ 那份 wiki 是**他寫的**,正是他要我們讀的
// 判準只有 PlanIngest 一個地方,見 ingestplan.go。
if rel, rerr := filepath.Rel(root, p); rerr == nil {
relSlash := filepath.ToSlash(rel)
if TemplateOwns(relSlash) && !opts.Plan.OverridesTemplateOwned(relSlash) {
return nil
}
}
ext := strings.ToLower(filepath.Ext(name))
if !allowedExt[ext] {
@@ -505,5 +546,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
SkippedOther: skippedOther,
SkippedOtherNames: skippedOtherNames,
DuplicateFormats: duplicateFormats,
Plan: opts.Plan,
ExcludedByPlan: excludedByPlan,
}, nil
}
+49 -11
View File
@@ -192,18 +192,56 @@ func collectTemplateItems(absRoot string, isVault bool) []TidyItem {
return items
}
// CardMigrationMigrateCardNames 一輪的帳目。
//
// Blocked 不是「失敗」,是**刻意沒動**:那個資料夾在版控裡,我們沒有資格自己決定
// 改別人版控中的檔案。它要被講出來(direct.go 收進 results ⇒ status.json ⇒ App),
// 不是靜默跳過——#105 的紅線與 #104 同一條:「不要讓用戶猜」。
type CardMigration struct {
Moved int // 真的改名/搬移了幾個
Blocked int // 認出是舊卡、但因為在版控裡而沒動的
RepoRoot string // Blocked > 0 時:那個版控工作目錄的根
}
// MigrateCardNames 把卡片產物區裡「位置不對或沒帶標記」的舊卡歸位。daemon 每輪自動呼叫。
// 回傳實際動到的筆數。**只碰卡片產物區**,其餘一概不動。
// **只碰卡片產物區**,其餘一概不動。
//
// 為什麼可以自動:那兩個目錄從頭到尾只有 daemon 會寫(見 cardsRelDirFor 的註解),
// 不存在「誤把使用者的檔案改名」的可能。目標已存在就跳過,不覆蓋。
// 🔴 第四輪=arcrun-rag#1052026-08-14 實撞):**版控中的資料夾一個檔都不動。**
//
// 🔴 第三輪起也負責**搬移**(不只改名):監看根在 vault 裡時,舊版寫在可見目錄的卡
// 會被搬進 `.arcrun-rag/wiki/cards/`。這是自動的,因為紅線寫著「不准要使用者去設定
// 什麼開關才能保護自己的筆記」——他不該為了收拾機器留下的東西去學一個新指令。
func MigrateCardNames(absRoot string) int {
n := 0
for _, it := range collectCardItems(absRoot) {
// 前三輪(#60f71e5a2/578ac2b)修的都是同一個場景的深度——「卡片會不會汙染
// 使用者的**筆記庫**」,方向一路往「更自動」走,因為那條紅線是「不准要使用者
// 去設定什麼開關才能保護自己的筆記」。**本輪不推翻那個方向**:非版控資料夾
// (含 vault)的行為與 578ac2b 完全一致。
//
// 本輪修的是另一個場景——「監看的是他的 **git repo**」。上一版這裡的理由是
// 「那兩個目錄從頭到尾只有 daemon 會寫,不存在誤把使用者的檔案改名的可能」。
// 那句話在 repo 上錯了,而且錯得很貴:`system-dev/wiki/cards/` 是
// system-dev-template 的規約路徑,而 template 就是要裝進**開發者自己的 repo**——
// leo 的 `InkStoneCo` 裡那個目錄底下是他自己寫的知識卡(還有 `autonomy/` 這種
// 他自己開的子目錄)。daemon 把它們壓平改名,**16 個版控中的檔案當場變成刪除**。
//
// ⇒ 判準加一問「這裡有沒有 `.git`」(見 repoguard.go 對「這夠不夠」的討論)。
// 有=停手、記帳、講給使用者聽,要整理請他自己跑 `collector tidy --apply`
// (那條路預設 dry-run,會先把要動什麼列給他看)。
// 兩條紅線不衝突:「不要他學新選項才能保護筆記」管的是筆記庫;
// repo 那邊 leo 的原話是「**預設應該是不動,要動要有出口**」。
func MigrateCardNames(absRoot string) CardMigration {
items := collectCardItems(absRoot)
if repoRoot := DetectRepoRoot(absRoot); repoRoot != "" {
// 只把**確定是我們寫的**(帶 arcrun- 標記、只是位置不對)算進 Blocked。
// 沒帶標記的在 repo 裡預設就是使用者自己的檔案——把它們算進來,訊息就會變成
// 「有 40 個舊卡片沒整理」,而那 40 張是 leo 自己寫的知識卡,
// 報一個全錯的數字比不報更糟(#105 要的是誠實,不是熱心)。
blocked := 0
for _, it := range items {
if IsMarked(filepath.Base(it.Rel)) {
blocked++
}
}
return CardMigration{Blocked: blocked, RepoRoot: repoRoot}
}
out := CardMigration{}
for _, it := range items {
from := filepath.Join(absRoot, filepath.FromSlash(it.Rel))
to := filepath.Join(absRoot, filepath.FromSlash(it.To))
if _, err := os.Stat(to); err == nil {
@@ -213,10 +251,10 @@ func MigrateCardNames(absRoot string) int {
continue
}
if err := os.Rename(from, to); err == nil {
n++
out.Moved++
}
}
return n
return out
}
// Tidy 掃出所有舊產物;apply=false 只報告,apply=true 才真的改名/搬移。
+2 -2
View File
@@ -250,8 +250,8 @@ func TestMigrateCardNames_TouchesOnlyCardDirs(t *testing.T) {
t.Fatal(err)
}
if n := MigrateCardNames(root); n != 1 {
t.Fatalf("改名筆數=%dwant 1", n)
if mig := MigrateCardNames(root); mig.Moved != 1 {
t.Fatalf("改名筆數=%dwant 1", mig.Moved)
}
if _, err := os.Stat(filepath.Join(cardDir, "arcrun-舊卡.md")); err != nil {
t.Fatalf("舊卡沒被改名:%v", err)
+2 -2
View File
@@ -213,7 +213,7 @@ func TestVaultFootprint_EveryNewFileIsMarked(t *testing.T) {
continue
}
added = append(added, rel)
if !IsMarked(filepath.Base(rel)) {
if !IsMachineOwnedRel(rel) {
unmarked = append(unmarked, rel)
}
}
@@ -312,7 +312,7 @@ func TestPlainFolderFootprint_EveryNewFileIsMarked(t *testing.T) {
continue
}
newCount++
if !IsMarked(filepath.Base(rel)) {
if !IsMachineOwnedRel(rel) {
t.Fatalf("一般資料夾也不准有沒帶標記的新檔:%s", rel)
}
}
+20 -12
View File
@@ -262,7 +262,7 @@ func assertVaultUntouched(t *testing.T, vaultDir string, before, after map[strin
t.Logf("筆記庫裡新增的檔案(%d 個):%v", len(added), added)
for _, rel := range added {
if !IsMarked(filepath.Base(rel)) {
if !IsMachineOwnedRel(rel) {
t.Fatalf("筆記庫裡多了一個沒帶 %q 標記的檔:%s", MachineMark, rel)
}
if !isHiddenRel(rel) {
@@ -324,11 +324,19 @@ func TestVaultFootprint_WatchRootInsideVault(t *testing.T) {
if len(added) == 0 {
t.Fatal("這一輪什麼都沒產出——紅線是「東西照樣產出、但不撞進使用者的命名空間」")
}
// 卡片確實落在 docs/.arcrun-rag/... 底下(在監看根裡,且是隱藏的)
wantPrefix := filepath.ToSlash(filepath.Join("docs", vaultCardsRelDir)) + "/"
// 產物一律落在 docs/.arcrun-rag/ 底下(在監看根裡,且是隱藏的)
// 其中**卡片**還要再進一層 wiki/cards/。
// 分兩層檢查是因為工作區裡不只有卡片:#105 起還有一份 `.gitignore`
// (讓整個工作區對 git 隱形)。它同樣是隱藏的、同樣不撞使用者的命名空間,
// 但它本來就不該住在 cards/ 裡。
workspacePrefix := filepath.ToSlash(filepath.Join("docs", workspaceRelDir)) + "/"
cardsPrefix := filepath.ToSlash(filepath.Join("docs", vaultCardsRelDir)) + "/"
for _, rel := range added {
if !strings.HasPrefix(rel, wantPrefix) {
t.Fatalf("產物 %s 不在預期的隱藏卡片區 %s 底下", rel, wantPrefix)
if !strings.HasPrefix(rel, workspacePrefix) {
t.Fatalf("產物 %s 不在預期的隱藏工作區 %s 底下", rel, workspacePrefix)
}
if strings.HasSuffix(rel, ".md") && !strings.HasPrefix(rel, cardsPrefix) {
t.Fatalf("卡片 %s 不在預期的隱藏卡片區 %s 底下", rel, cardsPrefix)
}
}
})
@@ -396,7 +404,7 @@ func TestVaultFootprint_SubVaultBelowWatchRoot(t *testing.T) {
continue
}
produced++
if !IsMarked(filepath.Base(rel)) {
if !IsMachineOwnedRel(rel) {
t.Fatalf("監看根上多了一個沒帶標記的檔:%s", rel)
}
}
@@ -466,12 +474,12 @@ func TestMigrateCardNames_RelocatesVisibleCardsInsideVault(t *testing.T) {
})
t.Logf("收拾前:%v", relList(t, watch))
n := MigrateCardNames(watch)
mig := MigrateCardNames(watch)
got := relList(t, watch)
t.Logf("收拾後(搬了 %d 筆):%v", n, got)
t.Logf("收拾後(搬了 %d 筆):%v", mig.Moved, got)
if n != 2 {
t.Fatalf("搬了 %d 筆,want 2", n)
if mig.Moved != 2 {
t.Fatalf("搬了 %d 筆,want 2", mig.Moved)
}
for _, rel := range got {
if !isHiddenRel(rel) {
@@ -483,8 +491,8 @@ func TestMigrateCardNames_RelocatesVisibleCardsInsideVault(t *testing.T) {
}
// 冪等:再跑一次不該有任何動作。
if again := MigrateCardNames(watch); again != 0 {
t.Fatalf("第二次跑又搬了 %d 筆,應為 0(冪等)", again)
if again := MigrateCardNames(watch); again.Moved != 0 {
t.Fatalf("第二次跑又搬了 %d 筆,應為 0(冪等)", again.Moved)
}
}