Files
arcrun-collector/ingestplan.go
T
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

324 lines
14 KiB
Go
Raw 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.
// 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
}