Files
arcrun-collector/tidy.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

399 lines
17 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.
// tidy.go — 認出並收拾「daemon 以前寫進使用者資料夾、但還沒帶標記」的舊產物
// arcrun-rag#60 第二輪驗收條件③)。
//
// 🔴 為什麼光「以後不再撞名」不夠:
//
// leo 打開資料夾的當下,裡面已經有一批舊產物了(`status.md``mistakes.md`
// 日期檔那些)。修好未來不會讓現在變乾淨——**他抱怨的是他現在看到的東西**。
// 所以這一輪必須同時交出「一次認出來/一次改名」的辦法。
//
// 🔴 這是別人的資料,所以本檔只做兩件可回收的事:**改名**與**搬移**。
//
// 一行 os.Remove 都沒有。誤判時他把檔案搬回去就好,不會有東西不見。
// 同理,目標檔名已經存在時一律**跳過並報告**,不覆蓋(safewrite.go 同一條紅線)。
//
// 兩個入口,責任不同:
//
// ① MigrateCardNames()daemon 每輪自動跑,只碰**卡片產物區**(那整個目錄是我們寫的,
// 百分之百不會誤傷)。leo 不必知道它存在——「不該為了保護自己的筆記去學新選項」。
// ② Tidy() / `collector tidy`:人工跑,多處理一類——**template 殘留**。
// 那一類不能自動搬,因為同樣那幾個路徑在「leo 自己的開發 repo」裡是**他的真檔案**
// TemplateOwns 是路徑身分判準,分不出「daemon 鋪的」還是「repo 本來就有的」)。
// ⇒ 只有在**確定是 vault**(沒有人會把 system-dev template 放進 Logseq 筆記庫)
// 時才動手,其餘一律只列出來讓人自己決定。
package collector
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// 產物分類(報告用;也是「動不動手」的判準)。
const (
TidyKindCard = "card" // 卡片本體
TidyKindCardBak = "card-backup" // safeWriteCard 產生的 .bak-<unixnano>
TidyKindTemplate = "template-leftover" // 舊版 daemon 代裝的 system-dev template 殘留
)
// 動作(報告用)。dry-run 時只會出現 would-* 與 report-only。
const (
TidyActionRenamed = "renamed"
TidyActionMoved = "moved"
TidyActionWillRename = "would-rename"
TidyActionWillMove = "would-move"
TidyActionReport = "report-only" // 我不動它,請人自己判斷
TidyActionSkipped = "skipped" // 想動但不能動(目標已存在/搬不動)
)
// legacyTemplateRelDir 是 template 殘留的收容處:監看根底下的隱藏目錄,
// 保留原本的相對路徑結構(`.arcrun-rag/legacy-template/system-dev/wiki/status.md`)。
// 刻意留在同一個資料夾裡而不是丟到 ~/ 或垃圾桶——東西還在他手上,要還原只是搬回去。
const legacyTemplateRelDir = ".arcrun-rag/legacy-template"
// bakSuffix 認 safeWriteCard 的備份檔:`<原名>.bak-<unixnano>`。
var bakSuffix = regexp.MustCompile(`\.bak-\d+$`)
// TidyItem=一筆產物與我們對它做了(或會做)什麼。
type TidyItem struct {
Rel string `json:"rel"` // 相對監看根的路徑(斜線分隔)
To string `json:"to,omitempty"` // 改名/搬移的目的地(同樣相對監看根)
Kind string `json:"kind"` // TidyKind*
Action string `json:"action"` // TidyAction*
Note string `json:"note,omitempty"` // 沒動它的原因(白話)
}
// TidyReport=一次收拾的完整帳目。
type TidyReport struct {
Root string `json:"root"`
VaultType VaultType `json:"vault_type"`
// VaultRoot=筆記庫的根。與 Root 不同時代表「監看的是筆記庫底下的一層子資料夾」
// ——第三輪修的正是這一種(arcrun-rag#60)。空字串=不在任何筆記庫裡。
VaultRoot string `json:"vault_root,omitempty"`
Applied bool `json:"applied"` // false=只看不動(dry-run
Items []TidyItem `json:"items"`
}
// Counts 依 action 統計,給人看的一行摘要用。
func (r *TidyReport) Counts() map[string]int {
out := map[string]int{}
for _, it := range r.Items {
out[it.Action]++
}
return out
}
// cardDirsToScan 回傳「卡片可能住過的所有目錄」(相對監看根)。
// 刻意兩個都掃,不看目前是不是 vault——舊產物可能是在資料夾還沒變成 vault 之前落下的,
// 也可能是第一輪修復前落在 system-dev/wiki/cards/ 的。認人要認全,不能只認現在這一種。
func cardDirsToScan() []string {
return []string{cardsRelDir, vaultCardsRelDir}
}
// isUnderCardDir 回答「這個相對路徑是不是住在卡片產物區底下」。
func isUnderCardDir(relSlash string) bool {
for _, d := range cardDirsToScan() {
if strings.HasPrefix(relSlash, d+"/") {
return true
}
}
return false
}
// collectCardItems 掃出卡片產物區裡「不在它現在該在的位置/還沒帶標記」的檔案,
// 算出它們該搬去哪、該叫什麼名字。已經到位又帶標記的不列入(冪等:跑第二次是空的)。
//
// 🔴 arcrun-rag#60 第三輪:判準從「有沒有帶標記」擴充成「**位置對不對** + 有沒有帶標記」。
// 只看標記在第三輪不夠——監看根是 vault 子資料夾時,前兩輪的 daemon 已經把一批
// **帶標記但落在可見目錄**`system-dev/wiki/cards/arcrun-*.md`)的卡寫進使用者的
// 筆記庫了。那些卡在 Logseq 眼裡照樣是一頁一頁的機器頁面,改名救不了,要搬進隱藏目錄。
// 少了這一段,票上「已經寫進去的那一批要能收拾」對這一類就等於沒做。
func collectCardItems(absRoot string) []TidyItem {
want := cardsRelDirFor(absRoot)
var items []TidyItem
for _, relDir := range cardDirsToScan() {
base := filepath.Join(absRoot, filepath.FromSlash(relDir))
_ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
name := d.Name()
rel, rerr := filepath.Rel(absRoot, p)
if rerr != nil {
return nil
}
relSlash := filepath.ToSlash(rel)
if IsMarked(name) && filepath.ToSlash(filepath.Dir(relSlash)) == want {
return nil // 位置對、標記也有,已經是新的了
}
kind := TidyKindCard
if bakSuffix.MatchString(name) {
kind = TidyKindCardBak
}
items = append(items, TidyItem{
Rel: relSlash,
To: filepath.ToSlash(filepath.Join(want, MarkName(name))),
Kind: kind,
})
return nil
})
}
return items
}
// collectTemplateItems 掃出 template 殘留(TemplateOwns 命中的路徑)。
//
// vault=可以搬(沒人會把開發用 template 放進筆記庫,判定為 daemon 舊版鋪的)。
// 非 vault=**只列不動**:那可能正是使用者自己的開發 repo,搬了會把他的 repo 弄壞。
func collectTemplateItems(absRoot string, isVault bool) []TidyItem {
var items []TidyItem
_ = filepath.WalkDir(absRoot, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
// 隱藏目錄整棵跳過——收容處自己就在 .arcrun-rag/ 底下,
// 不跳過的話第二次跑會把搬過去的東西再搬一次。
if p != absRoot && strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir
}
return nil
}
rel, rerr := filepath.Rel(absRoot, p)
if rerr != nil {
return nil
}
relSlash := filepath.ToSlash(rel)
if !TemplateOwns(relSlash) {
return nil
}
// 🔴 卡片產物區就住在 `system-dev/wiki/cards/` 底下,而 TemplateOwns 是**目錄前綴**
// 判準(`system-dev/` 整棵都算 template)⇒ 不排除的話,同一張卡會同時被認成
// 「卡片(該改名)」與「template 殘留(該搬走)」,兩個動作搶同一個檔,
// 先跑的改完名、後跑的就 Stat 不到 ⇒ 報告出現假的失敗。卡片一律由 collectCardItems 管。
if isUnderCardDir(relSlash) {
return nil
}
it := TidyItem{Rel: relSlash, Kind: TidyKindTemplate}
if isVault {
it.To = filepath.ToSlash(filepath.Join(legacyTemplateRelDir, relSlash))
} else {
it.Action = TidyActionReport
it.Note = "這不是 vault,可能是你自己的開發 repo(template 本來就該在)——我不動它,要清請自己確認"
}
items = append(items, it)
return nil
})
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 每輪自動呼叫。
// **只碰卡片產物區**,其餘一概不動。
//
// 🔴 第四輪=arcrun-rag#1052026-08-14 實撞):**版控中的資料夾一個檔都不動。**
//
// 前三輪(#60f71e5a2578ac2b)修的都是同一個場景的深度——「卡片會不會汙染
// 使用者的**筆記庫**」,方向一路往「更自動」走,因為那條紅線是「不准要使用者
// 去設定什麼開關才能保護自己的筆記」。**本輪不推翻那個方向**:非版控資料夾
// (含 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 {
continue // 新名字已經有東西了,不覆蓋
}
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
continue
}
if err := os.Rename(from, to); err == nil {
out.Moved++
}
}
return out
}
// Tidy 掃出所有舊產物;apply=false 只報告,apply=true 才真的改名/搬移。
func Tidy(root string, apply bool) (*TidyReport, error) {
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, err
}
info, err := os.Stat(absRoot)
if err != nil {
return nil, fmt.Errorf("看不到這個資料夾(%s):%w", absRoot, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("%s 不是資料夾", absRoot)
}
// 🔴 兩個判準,回答兩個不同的問題,不要合併(arcrun-rag#60 第三輪):
//
// · ctx(含往上找)=「我寫東西會不會落進誰的筆記庫」——決定卡片該在哪,也是報告顯示的身分。
// · DetectVaultType(absRoot)(只看這一層)=「這個資料夾**本身**是不是筆記庫」——
// 只有它才夠格授權「把 template 殘留搬走」。理由:一個住在筆記庫底下的
// **使用者自己的開發 repo**`KB/some-repo/system-dev/…`)在 ctx 眼中也「在 vault 裡」,
// 但那些 template 檔是他的真檔案,不是 daemon 鋪的。搬了就是弄壞他的 repo。
// 分不出來的時候只列不動——這條在第二輪就寫了,第三輪擴大偵測範圍時更要守住。
ctx := DetectVaultContext(absRoot)
rep := &TidyReport{Root: absRoot, VaultType: ctx.Type, VaultRoot: ctx.Root, Applied: apply}
items := collectCardItems(absRoot)
items = append(items, collectTemplateItems(absRoot, DetectVaultType(absRoot) != VaultNone)...)
sort.Slice(items, func(i, j int) bool { return items[i].Rel < items[j].Rel })
for _, it := range items {
if it.Action == TidyActionReport { // 已經判定只列不動
rep.Items = append(rep.Items, it)
continue
}
// 換了目錄就是「搬走」,只換名字才是「改名」——報告要說實話,
// 使用者才知道去哪裡找他的東西(第三輪起卡片也會換目錄)。
willMove := it.Kind == TidyKindTemplate ||
filepath.ToSlash(filepath.Dir(it.Rel)) != filepath.ToSlash(filepath.Dir(it.To))
from := filepath.Join(absRoot, filepath.FromSlash(it.Rel))
to := filepath.Join(absRoot, filepath.FromSlash(it.To))
if _, serr := os.Stat(to); serr == nil {
it.Action = TidyActionSkipped
it.Note = "目的地已經有同名檔案了,我不覆蓋——請自己看一眼哪一份要留"
rep.Items = append(rep.Items, it)
continue
}
if !apply {
it.Action = TidyActionWillRename
if willMove {
it.Action = TidyActionWillMove
}
rep.Items = append(rep.Items, it)
continue
}
if merr := os.MkdirAll(filepath.Dir(to), 0o755); merr != nil {
it.Action, it.Note = TidyActionSkipped, "建目的地資料夾失敗:"+merr.Error()
rep.Items = append(rep.Items, it)
continue
}
if rerr := os.Rename(from, to); rerr != nil {
it.Action, it.Note = TidyActionSkipped, "搬不動:"+rerr.Error()
rep.Items = append(rep.Items, it)
continue
}
it.Action = TidyActionRenamed
if willMove {
it.Action = TidyActionMoved
}
rep.Items = append(rep.Items, it)
}
return rep, nil
}
// runTidy 是 `collector tidy` 子命令主體。
// 預設**只看不動**(要真的動手得自己加 --apply)——別人的資料夾,預設值就該是安全的那一邊。
func runTidy(args []string) int {
fs2 := newFlagSet()
folder := fs2.String("folder", "", "要收拾的資料夾(必填,就是 daemon 在看守的那個)")
apply := fs2.Bool("apply", false, "真的動手改名/搬移(不加=只列出會動到什麼)")
asJSON := fs2.Bool("json", false, "輸出 JSON(給程式讀;不加=給人看的清單)")
if err := fs2.Parse(args); err != nil {
return 2
}
if *folder == "" {
fmt.Fprintln(os.Stderr, "錯誤:--folder 為必填")
return 2
}
rep, err := Tidy(*folder, *apply)
if err != nil {
fmt.Fprintln(os.Stderr, "collector tidy:", err)
return 1
}
if *asJSON {
data, _ := json.MarshalIndent(rep, "", " ")
fmt.Println(string(data))
return 0
}
if len(rep.Items) == 0 {
fmt.Printf("%s:沒有找到任何沒帶標記的舊產物,這個資料夾是乾淨的。\n", rep.Root)
return 0
}
kind := "一般資料夾"
switch {
case rep.VaultType != VaultNone && rep.VaultRoot != rep.Root:
// 第三輪的那一種擺法——講清楚是誰的庫,不然使用者看不懂為什麼要搬。
kind = fmt.Sprintf("%s 筆記庫「%s」底下的子資料夾", rep.VaultType, rep.VaultRoot)
case rep.VaultType != VaultNone:
kind = string(rep.VaultType) + " 筆記庫"
}
fmt.Printf("%s%s\n", rep.Root, kind)
if !rep.Applied {
fmt.Println("※ 這只是預覽,什麼都還沒動。確認清單沒問題後,同一行指令加上 --apply 才會真的執行。")
}
for _, it := range rep.Items {
switch it.Action {
case TidyActionRenamed, TidyActionWillRename:
fmt.Printf(" 改名 %s → %s\n", it.Rel, it.To)
case TidyActionMoved, TidyActionWillMove:
why := "舊版 daemon 鋪的開發用檔案,不是你的筆記"
if it.Kind != TidyKindTemplate {
why = "舊版寫在看得見的位置,你的筆記軟體會把它當成一頁——搬進隱藏目錄"
}
fmt.Printf(" 搬走 %s → %s%s\n", it.Rel, it.To, why)
case TidyActionReport:
fmt.Printf(" 略過 %s%s\n", it.Rel, it.Note)
case TidyActionSkipped:
fmt.Printf(" 沒動 %s%s\n", it.Rel, it.Note)
}
}
c := rep.Counts()
fmt.Printf("合計:改名 %d、搬走 %d、只列出 %d、沒動 %d\n",
c[TidyActionRenamed]+c[TidyActionWillRename],
c[TidyActionMoved]+c[TidyActionWillMove],
c[TidyActionReport], c[TidyActionSkipped])
fmt.Println("(本指令從不刪除任何東西:只有改名與搬移,覺得不對就自己搬回去。)")
return 0
}