Claude Skill

memory-lint

Use when the user wants to lint a Claude Code memory directory (~/.claude/memory or custom path) for index inconsistency, broken cross-links, stale project state, duplicate / conflicting feedback rules, naming violations, frontmatter gaps, and oversized files. Phase 1 is a read-o

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download kerberosclaw-kc_ai_skills-memory-lint-ad005ac.zip · 12 KB
Part of kerberosclaw/kc_ai_skills — 25 skills

Install

skills CLI npx skills add https://github.com/KerberosClaw/kc_ai_skills/tree/main/memory-lint
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install kerberosclaw-kc-ai-skills@llmmart
Git git clone https://github.com/KerberosClaw/kc_ai_skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole kerberosclaw/kc_ai_skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

memory-lint — Memory 品質健檢

Phase 1  唯讀掃描 ──► 報告(預設只做這段,不動任何檔案)
                        │  user 逐條拍板要修哪些
Phase 2  執行修正 ──► commit(開工前先記回退點)
                        │  改完全部提交 = 凍結
Phase 3  獨立複驗 ──► 過 → 收工 / 不過 → 回退到 Phase 2 前

🔴 收到 lint 觸發詞的預設動作是出報告。 user 必須明確點名要修哪幾條 (或明說「全部修掉」)才准進 Phase 2。Phase 1 期間不准順手合併、順手刪、順手歸檔。

跟 llm-wiki-lint 差異:本 skill 針對 memory 目錄(prefix-based 平鋪結構); llm-wiki-lint 針對 Karpathy LLM Wiki repo(wiki/ + raw/ + SCHEMA.md 三層)。


Phase 1 — 唯讀掃描

Step 1: 找到 memory 目錄

依序嘗試,命中第一個就用:

順序 來源
1 $ARGUMENTS 第一個位置參數
2 環境變數 $CLAUDE_MEMORY_DIR
3 settings.json 的 autoMemoryDirectory
4 ~/.claude/memory/
5 都找不到 → 停止,告訴 user「偵測不到 memory 目錄」,不要瞎猜

第 3 條要看當前設定目錄(多帳號並存時 $CLAUDE_CONFIG_DIR 會指到別處):

CFG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
MEMORY_PATH=$(jq -r '.autoMemoryDirectory // empty' "$CFG/settings.json" | envsubst)

Step 2: 跑機械掃描

# <skill_dir> = 本 SKILL.md 所在的目錄;掃描標的由參數帶入,
# 所以在哪個工作目錄呼叫都不影響結果
python3 <skill_dir>/scripts/scan.py "$MEMORY_PATH"

只用標準函式庫、唯讀、輸出 JSON。目標目錄由參數帶入,所以在哪個工作目錄呼叫都一樣。 沒有 MEMORY.md 會回 {"fatal": ...} 並以 exit 1 結束。

腳本已經處理掉幾個會讓檢查靜默失效的坑,不要自己在對話裡改寫成 shell 一行流:

  • 不用 shell glob(zsh 未匹配 glob 會在指令執行前中止,而且 2>/dev/null 擋不住)
  • 不用固定路徑暫存檔(並行執行會互相覆蓋、失敗留髒資料)
  • 不 import 第三方套件(唯讀階段不該動 user 的 Python 環境;離線環境也裝不了)
  • [[...]] 掃描先剝掉 fenced 與行內 code,且檔名與 frontmatter name 兩種都算解析成功
  • 索引目標的 ./ 前綴會正規化

輸出欄位:

欄位 意義
layout 單層(只有 MEMORY.md)/兩層(MEMORY.md 只留路由、細目在 index_*.md)
index_declared_missing MEMORY.md 指到但磁碟上沒有的子索引
index_orphaned 磁碟上有、但沒人指向的孤立子索引
orphan / missing 有檔沒被索引/索引指向不存在的檔
frontmatter 缺 name/description(只認頂層)或 type(頂層或 metadata.type)
wiki_broken / wiki_external [[...]] 解析不到的/指向子目錄或外部的
oversize 超過 300 行的檔(行數語意同 wc -l)
prefixes / no_prefix 命名前綴分布與例外

🔴 兩層結構下若只拿 MEMORY.md 當索引來源,會把整庫誤判成 orphan。 腳本已處理, 但若你另外手寫檢查,這是最容易踩的一個。

Step 3: 判讀(這段才是本 skill 的價值)

腳本只給事實,嚴重度與去留由你判斷。判斷前先讀 MEMORY.md 與相關索引的說明文字, 很多「異常」其實是既定決策。

嚴重度 收什麼
🔴 Error 結構壞了:index_declared_missing、orphan、missing、frontmatter 缺漏、真正的斷鏈、兩條規則直接互打
🟡 Warning 沒壞但該看:index_orphaned、過期 dashboard、oversize、命名例外、疑似結束未歸檔
🔵 Info 啟發式:語意相近可能重複、description 與索引描述不符、同一計數寫在多處

不確定就降一級。推測與語意相似一律不得標 Error。

這些不是缺陷,別報

  • wiki_external 多半是刻意的 —— 指向 archive/ 的歸檔檔案,或指向另一台機器上的 外部知識庫。報之前先看那個目標像不像檔案路徑,並問 user 一次就好,不要每次重報。 ⚠️ wiki_broken 裡也可能混著刻意的外部連結。 腳本靠「目標裡有沒有路徑分隔符」分類, 所以像 [[某個外部條目]] 這種沒有路徑特徵的外部參照會落到 wiki_broken。 這無法自動判斷,一律當成「需要人確認」,確認過就記進已接受例外清單,別每輪重報。

  • 超過 300 行有時是刻意的 —— 若某份 canonical 規則檔明文要求「使用前必須完整讀完」, 拆開就破壞用途。標成「刻意例外」並寫明理由。

  • 語意相近的一組規則可能是刻意不合併的 —— 為了精準 recall 而把同一原則拆成多個觸發點 是常見設計,索引裡通常有明文宣告。看到就標明是既定決策,不要建議合併。

  • 子目錄不在掃描範圍 —— 腳本只掃根層。archive/ 是歸檔;其他子目錄可能是某則 memory 的附屬資料(例如一份 memory 指向 ref/ 底下的參考檔)。它們沒被索引是正常的。 自己補查時記得也要限制在根層,別寫成遞迴:

    find "$MEMORY_PATH" -maxdepth 1 -name '*.md' -type f     # ✅
    rg --files -g '*.md' "$MEMORY_PATH"                       # ❌ 會遞迴讀到 archive/ 與其他子目錄
    

這些要另外用眼睛看(腳本測不出來)

  • 過時狀態:project_* 超過 30 天沒動、dashboard 超過 7 天沒更新、內文寫「進行中」 但檔案很久沒改。🔴 先確認時間來源可不可信:檔案的 mtime 一經複製、還原、 重新 clone 就會全部變成當下時間。是 git repo 就改用 git -C "$MEMORY_PATH" log -1 --format=%ad -- <file>; 不是 git 又剛搬過家,就明講這項檢查在本次環境不可靠,不要拿一批假的日期去下判斷。⚠️ 這是低訊號檢查 —— 黑名單、已完工的側專案、機器後路天生就不會動。 而且「進行中」命中的可能只是待辦清單裡一個帶日期的項目,不是整個專案的狀態。 報告要引出命中的那一行原文,讓 user 看得出是哪一種。
  • 規則直接衝突:🔴 不要對全部檔案兩兩比對。 規則檔一多(例如 80 份以上), 輸出會直接撐爆而被截斷 —— 那時你既審不完、也不知道漏了什麼,卻很容易誤以為審過了。 改成兩段:先用 description 的字元 bigram 做 Jaccard 粗篩(門檻約 0.16)取出候選配對, 只對候選配對讀原文;一輪最多看 10 對,超過就明講「本輪只看了前 N 對、其餘未檢查」。 命中後並列兩邊原句讓 user 自判,不要自己拍板「這是衝突」。 🔴 絕對不要因為輸出被截斷就當作「沒發現問題」 —— 那是沒檢查,不是通過。

Step 4: 出報告

繁體中文(語言跟 memory 對齊)。每個 finding 必須有具體檔名 + 行為描述 + 建議動作(Error 必附)。結尾主動問要不要進 Phase 2,並建議優先序: 一行就能修的(斷鏈、索引缺漏)→ 每個 session 都會載入的檔 → 大型重構。


Phase 2 — 執行修正(需明確授權)

開工前四項,缺一不進

1. 判斷是不是 git repo。 Step 1 只要求有 MEMORY.md,而預設的 ~/.claude/memory/ 常常不是 repo。整套回退機制不能預設 git 存在。

# 🔴 要比對 top-level 是不是它自己,不能只問「這裡有沒有 git」——
#    rev-parse --git-dir 會往上找父層 repo,memory 目錄只要剛好放在某個 repo 底下
#    就會被誤判成 git 模式,接著把 memory 的改動提交進那個不相干的 repo
top=$(git -C "$MEMORY_PATH" rev-parse --show-toplevel 2>/dev/null)
[ -n "$top" ] && [ "$top" = "$(cd "$MEMORY_PATH" && pwd -P)" ] && echo git || echo 非git

2. 依上一步建立回退點:

情況 回退點 回退方式
git repo git -C "$MEMORY_PATH" log --oneline -1 的 commit hash,寫進回報 git -C "$MEMORY_PATH" revert 或退回該 commit
非 git 見下方備份指令(放在 memory 目錄外,免得被自己掃到) 見下方回復指令
# 建備份:碰撞就停,不要覆蓋既有備份
BAK="$MEMORY_PATH.bak.$(date +%Y%m%d-%H%M%S)"
[ -e "$BAK" ] && { echo "備份路徑已存在,停止"; exit 1; }
cp -R "$MEMORY_PATH" "$BAK" && echo "回退點:$BAK"

# 回復:一定要用 --delete,否則 Phase 2 新增的檔案不會被移除
rsync -a --delete "$BAK/" "$MEMORY_PATH/"
diff -rq "$BAK" "$MEMORY_PATH" && echo "回復完成且內容一致"

🔴 「把備份複製回去」不等於回復。 單純覆蓋複製移不掉 Phase 2 新增的檔案, 回復後會是舊檔與新檔的混合體,而且看起來像成功了。一定要用 rsync --delete (或等效做法)並用 diff -rq 驗過。

3. 確認工作區乾淨(git 情況):有未提交的改動先問 user。

4. 把 Phase 3 的驗證管道先定下來並試跑一次。 三件事都要做完才算過:

  1. 寫下你打算在 Phase 3 用的那一行完整指令(含唯讀旗標與標的目錄),記進回報。
  2. 用那一行實際發一次最小的唯讀請求(例如請它回報某個檔案的行數),確認拿得到回應。
  3. 拿不到回應就停下來問 user,讓他選:換一個管道、接受「只做自檢、不做外部複驗」, 或不要進 Phase 2。user 選了「只做自檢」也可以繼續,但要在最終回報裡明講 這次沒有外部複驗。

🔴 只看執行檔在不在是不夠的。 裝了但沒登入、設定壞掉,command -v 一樣會過, 然後你會在 Phase 2 已經改完並提交之後才撞到 —— 正好是這道閘門要防的狀態。

🔴 本 skill 不指定用哪一套工具。 只要求它是獨立行程、可唯讀執行、 可把標的目錄寫死。至於是哪一套 CLI agent,由執行者依環境決定並記錄下來。

動手紀律

🔴 所有 git 指令一律帶 -C "$MEMORY_PATH"。 裸的 git add / git commit / git status 會作用在呼叫端的專案上(子行程裡的 cd 不會改變父行程的工作目錄), 結果是把別人的檔案提交掉,而 memory 的改動還躺在那裡沒進版控。

🔴 Edit / Write 可能被背景 session 的隔離守衛擋住。 memory 目錄常伴隨一個 「Edit/Write 後自動 commit」的 hook,而用 Bash 改檔不會觸發那個 hook。 被擋時的正解:用 Bash 寫檔,最後手動 git -C "$MEMORY_PATH" add -A && git -C "$MEMORY_PATH" commit, 改完 git -C "$MEMORY_PATH" status -s 確認乾淨才算數。

🔴 git push 要另外問過,不含在 Phase 2 的授權範圍。 user 同意的是「修這幾條」, 不是「把 memory 發佈到遠端」。memory 內容通常私密,而推送在多數託管服務上不可逆 (就算之後刪掉,中間狀態可能已被同步或快取)。commit 完停手,明確問一句再動。 例外:該目錄若本來就掛著自動推送的 hook,那是既有行為 —— 但仍要在回報裡講明。

🔴 不要在 memory 目錄內開 worktree。 若該目錄的 hook 跑的是 git add -A, worktree 會被當成 gitlink 提交進版控。

🔴 「規則漂移」類發現一律先逐檔讀過再決定。 例如「這幾個檔依規則不該存在」—— 這種規則幾乎都留有例外(文件查不到、跨 session 才需要知道的操作性事實)。 報告階段只能標「待判斷」、禁止寫「建議刪除」;Phase 2 也禁止照單無腦刪。

🔴 改名要連帶處理入站引用,[[...]] 與 markdown 連結都要掃,含 archive/ 底下的。

收尾

重跑一次 scan.py,全綠才進 Phase 3。自檢就不過的東西不要浪費一次外部複驗。

🔴 「全綠」不是「JSON 完全是空的」。 已經跟 user 確認過、決定不處理的項目 (刻意的外部連結、刻意超長的 canonical 檔、刻意不合併的規則群組)會一直留在輸出裡。 定義是:輸出裡每一條剩餘項目,都在你這輪明確記錄下來的「已接受例外」清單上。 把那份清單寫進回報,Phase 3 的 brief 也要附上,否則外部驗證者會把它們當成新缺陷報回來。

⚠️ 腳本全綠不代表語意層也通過。 它只驗結構,改掉一處內容矛盾之後 JSON 完全不會變。 語意層的判斷(矛盾、過時、重複)沒有機械證據,回報時要分開講。


Phase 3 — 獨立複驗

🔴 先凍結

Phase 2 的改動要全部完成並提交(git)或備份定版(非 git)之後,才准派複驗。 一邊改一邊驗等於叫對方驗一個會動的目標,結論無效、還浪費一輪。

🔴 要外部行程,不能用同 session 的 subagent

subagent 繼承這個 session 的記憶注入,而那是 session 開始那一刻的快照 —— 剛改完的東西不在裡面,連檔名都可能還是舊的。拿吃著舊快照的 agent 去驗磁碟真值, 邏輯上不成立。 用獨立行程(例如另一套 CLI agent),它不吃這份注入才算外部視角。

派工時要把標的目錄寫死、把寫入權關掉,否則它可能檢查錯的目錄,或動到剛凍結的檔案。

兩種驗證題,抓的東西不重疊

A. 機械事實查核 —— 答案非黑即白:MEMORY.md 結構與行數、改名前後的檔名各自存不存在、 索引差集是否為空、[[...]] 解析失敗數是否為零、frontmatter 是否全數完整。

B. Recall 可用性測試 —— 測「找不找得到」,設計比題目重要:

  • 只准從 MEMORY.md 起步,之後自行決定往下讀什麼
  • 🔴 禁用 grep / find / ls 暴力掃目錄 —— 不禁的話它繞過索引也答得出來,等於沒測到路由
  • 每組夾一題負面對照(問一個確定沒有記憶的主題),看它會不會憑常識編一個答案
  • 要求回報走過的路徑:讀了哪些檔、是索引裡哪一行導過去的
  • 要求給索引好不好用的評語,並明講不要客套

🔴 brief 要先寫明「哪些是刻意的」

外部驗證者沒有脈絡。不先講,它一定會把既定決策當成缺陷報回來,然後你得逐條解釋 —— 那正是審查迴圈燒時間的燃料。至少要先交代:刻意的外部連結、刻意超長的 canonical 檔、 刻意不合併的規則群組、已知但這次不處理的待辦、以及不在掃描範圍的子目錄。

判定與回退

結果 動作
全過 收工,回報改了什麼、驗了什麼
只揪出可修的小缺陷 修掉 → 重新凍結 → 再驗一輪
結構性失敗(索引導不到、大量斷鏈、走不到答案) 回退到 Phase 2 前的回退點,重新設計再來

⚠️ 驗證結果是輸入、不是結論。 它報的每一條先自己複核再決定改不改, 別因為「外部工具說的」就照做。特別留意三種誤判:把 user 刻意的決策當缺陷、 把不在它視野內的東西(其他機器、已歸檔)當斷鏈、把「規則說不該存在」直接推成「該刪」。

⚠️ 「審到沒問題為止」不是終止條件。 每修一次 diff 就更大,下一輪就有更多表面可挑, 這是正回饋不是收斂。要收斂就把 diff 變小 —— 與其逐條硬補,不如把出錯表面整類移除 (例如把散落的檢查邏輯收進一支測過的腳本)。判準改成「這條在實際使用路徑上打得到嗎」, 打不到就記錄進文件、不動手。


Anti-patterns

  • ❌ 沒經 user 同意就進 Phase 2
  • ❌ Phase 1 期間修改 / 刪除 / 合併任何 memory 檔案
  • ❌ 把 scan.py 的邏輯改寫成對話裡的 shell 一行流(glob、暫存檔、cwd 那幾類坑會全部回來)
  • ❌ 只把 MEMORY.md 當索引來源
  • ❌ 一邊改一邊派複驗
  • ❌ 用同 session 的 subagent 當「獨立」對照組
  • ❌ 看到「規則漂移」就建議刪檔
  • ❌ 裸 git 指令不帶 -C "$MEMORY_PATH"
  • ❌ 把 git push 當成 Phase 2 授權的一部分
  • ❌ 在唯讀階段安裝套件
  • ❌ 把「語意相似」標成 Error
  • ❌ 掃 archive/ 或其他子目錄(除非 user 明確要求)
  • ❌ 沒 prefix 慣例的目錄硬套命名檢查
  • ❌ 路徑偵測不到時瞎猜
  • ❌ 報告用英文模板套中文 memory
  • ❌ 把結果 append 到任何 ledger 檔

Important rules

  1. Phase 1 唯讀不可協商 —— 報告階段禁用 Edit / Write / mv / rm / git
  2. Phase 2 需明確授權,且開工前四項前置缺一不可
  3. Phase 3 先凍結、要外部行程、標的寫死、寫入關掉
  4. Path 偵測順序固定 —— 5 級依序,找不到就停
  5. Severity 寧降勿升
  6. 每個 finding 必須有具體檔名
  7. 衝突永遠並列原句,不替 user 判定
  8. 報告印到對話即可,不另存檔
  9. 外部驗證是輸入不是結論,逐條複核
  10. 未來功能(cron 定排、跨機器比對)目前不存在 —— user 問起就說 v0.x 還沒做
Files (kc_ai_skills)
  • scripts
    • scan.py 5.5 KB
      #!/usr/bin/env python3
      """memory-lint 機械掃描:只用標準函式庫,不動任何檔案。
      
      用法: python3 scan.py <memory_dir>
      
      刻意的設計:
      - 目標目錄一律由參數帶入,不依賴呼叫端的工作目錄
      - 不用 shell glob(zsh 未匹配會中止整條指令)
      - 不用固定路徑的暫存檔(並行執行會互相覆蓋)
      - 不 import 第三方套件(唯讀階段不該動使用者的 Python 環境)
      """
      import os, re, sys, json, urllib.parse
      
      ROOT_SKIP = re.compile(r"^(MEMORY|index_)")
      LINK = re.compile(r"\]\(\s*<?([^)>]+\.md)>?\s*\)")   # 同時吃 (a.md)、(<a b.md>)、(a%20b.md)
      WIKI = re.compile(r"\[\[([^\]]+)\]\]")
      FENCED = re.compile(r"^```.*?^```", re.S | re.M)
      INLINE = re.compile(r"`[^`\n]*`")
      
      
      def norm(p):
          """正規化 markdown 連結目標:去掉 <>、URL 解碼、去掉 ./ 前綴。
      
          含空格的檔名在 markdown 裡會寫成 my%20note.md 或 <my note.md>,
          不還原的話同一個檔會同時被報成 orphan 與 missing,兩個都是假的。
          """
          p = p.strip()
          if p.startswith("<") and p.endswith(">"):
              p = p[1:-1]
          p = urllib.parse.unquote(p)
          return p[2:] if p.startswith("./") else p
      
      
      def read(path):
          with open(path, encoding="utf-8", errors="replace") as fh:
              return fh.read()
      
      
      def frontmatter(text):
          """回傳 (ok, 頂層欄位 dict, metadata 子欄位 dict)。不用 YAML 解析器。"""
          m = re.match(r"^---\n(.*?)\n---", text, re.S)
          if not m:
              return False, {}, {}
          top, meta, in_meta = {}, {}, False
          for line in m.group(1).split("\n"):
              if not line.strip() or line.lstrip().startswith("#"):
                  continue
              indented = line[:1] in (" ", "\t")
              kv = re.match(r"^\s*([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
              if not kv:
                  continue
              k, v = kv.group(1), kv.group(2).strip().strip('"').strip("'")
              if not indented:
                  in_meta = (k == "metadata")
                  top[k] = v
              elif in_meta:
                  meta[k] = v
          return True, top, meta
      
      
      def main(mem):
          if not os.path.isfile(os.path.join(mem, "MEMORY.md")):
              print(json.dumps({"fatal": "缺 MEMORY.md,視為無效路徑"}, ensure_ascii=False))
              return 1
      
          root = sorted(f for f in os.listdir(mem)
                        if f.endswith(".md") and os.path.isfile(os.path.join(mem, f)))
          payload = [f for f in root if not ROOT_SKIP.match(f)]          # 一般記憶檔
          on_disk_idx = [f for f in root if f.startswith("index_")]
      
          memtext = read(os.path.join(mem, "MEMORY.md"))
          root_links = {norm(x) for x in LINK.findall(memtext)}
          declared_idx = {x for x in root_links if x.startswith("index_")}
          reachable = sorted(declared_idx & set(on_disk_idx))
          two_tier = bool(reachable)
      
          indexed = {x for x in root_links if not x.startswith("index_")}
          for idx in reachable:
              indexed |= {norm(x) for x in LINK.findall(read(os.path.join(mem, idx)))
                          if not norm(x).startswith("index_")}
      
          out = {
              "memory_dir": mem,
              "layout": "兩層" if two_tier else "單層",
              "counts": {"root_md": len(root), "payload": len(payload),
                         "index_files": len(on_disk_idx), "reachable_index": len(reachable)},
              "index_declared_missing": sorted(declared_idx - set(on_disk_idx)),
              "index_orphaned": sorted(set(on_disk_idx) - declared_idx),
              "orphan": sorted(set(payload) - indexed),
              "missing": sorted(indexed - set(payload)),
          }
      
          # frontmatter:name/description 只認頂層;type 允許在 metadata 底下
          fm_bad, names = [], {}
          for f in payload + on_disk_idx:
              ok, top, meta = frontmatter(read(os.path.join(mem, f)))
              if not ok:
                  fm_bad.append([f, "無 frontmatter"]); continue
              for k in ("name", "description"):
                  if not top.get(k, "").strip():
                      fm_bad.append([f, f"缺 {k}"])
              if not (top.get("type", "").strip() or meta.get("type", "").strip()):
                  fm_bad.append([f, "缺 type"])
              if top.get("name", "").strip():
                  names[top["name"].strip()] = f
          out["frontmatter"] = fm_bad
      
          # [[...]] 交叉連結:先剝程式碼;檔名與 frontmatter name 兩種都算解析成功
          stems = {f[:-3] for f in root}
          broken, ext = {}, {}
          for f in root:
              body = INLINE.sub("", FENCED.sub("", read(os.path.join(mem, f))))
              for ln, line in enumerate(body.split("\n"), 1):
                  for t in WIKI.findall(line):
                      t = t.strip()
                      if t.endswith(".md"):
                          t = t[:-3]
                      if t in stems or t in names:
                          continue
                      (ext if "/" in t else broken).setdefault(t, set()).add(f"{f}:{ln}")
          out["wiki_broken"] = {k: sorted(v) for k, v in sorted(broken.items())}
          out["wiki_external"] = {k: sorted(v) for k, v in sorted(ext.items())}
      
          out["oversize"] = sorted(
              [f, read(os.path.join(mem, f)).count("\n")] for f in root
              if read(os.path.join(mem, f)).count("\n") > 300)
      
          pref = {}
          for f in payload:
              pref.setdefault(f.split("_")[0] + "_" if "_" in f else "(無前綴)", []).append(f)
          out["prefixes"] = {k: len(v) for k, v in sorted(pref.items(), key=lambda x: -len(x[1]))}
          out["no_prefix"] = sorted(pref.get("(無前綴)", []))
      
          print(json.dumps(out, ensure_ascii=False, indent=2))
          return 0
      
      
      if __name__ == "__main__":
          if len(sys.argv) != 2:
              print(__doc__); sys.exit(2)
          sys.exit(main(os.path.abspath(os.path.expanduser(sys.argv[1]))))
      
  • SKILL.md 17.8 KB
    ---
    name: memory-lint
    description: "Use when the user wants to lint a Claude Code memory directory (~/.claude/memory or custom path) for index inconsistency, broken cross-links, stale project state, duplicate / conflicting feedback rules, naming violations, frontmatter gaps, and oversized files. Phase 1 is a read-only scan and report. Phase 2 applies fixes only for findings the user explicitly picks. Phase 3 verifies with an independent process and rolls back if it fails."
    version: 0.3.0
    status: mvp
    triggers:
      - "/memory-lint"
      - "memory lint"
      - "掃 memory"
      - "memory 健檢"
    argument-hint: "[path]"
    ---
    
    # memory-lint — Memory 品質健檢
    
    ```
    Phase 1  唯讀掃描 ──► 報告(預設只做這段,不動任何檔案)
                            │  user 逐條拍板要修哪些
    Phase 2  執行修正 ──► commit(開工前先記回退點)
                            │  改完全部提交 = 凍結
    Phase 3  獨立複驗 ──► 過 → 收工 / 不過 → 回退到 Phase 2 前
    ```
    
    🔴 **收到 lint 觸發詞的預設動作是出報告。** user 必須明確點名要修哪幾條
    (或明說「全部修掉」)才准進 Phase 2。Phase 1 期間不准順手合併、順手刪、順手歸檔。
    
    **跟 llm-wiki-lint 差異**:本 skill 針對 memory 目錄(prefix-based 平鋪結構);
    `llm-wiki-lint` 針對 Karpathy LLM Wiki repo(`wiki/` + `raw/` + `SCHEMA.md` 三層)。
    
    ---
    
    # Phase 1 — 唯讀掃描
    
    ## Step 1: 找到 memory 目錄
    
    依序嘗試,命中第一個就用:
    
    | 順序 | 來源 |
    |------|------|
    | 1 | `$ARGUMENTS` 第一個位置參數 |
    | 2 | 環境變數 `$CLAUDE_MEMORY_DIR` |
    | 3 | `settings.json` 的 `autoMemoryDirectory` |
    | 4 | `~/.claude/memory/` |
    | 5 | 都找不到 → **停止**,告訴 user「偵測不到 memory 目錄」,不要瞎猜 |
    
    第 3 條要看**當前設定目錄**(多帳號並存時 `$CLAUDE_CONFIG_DIR` 會指到別處):
    
    ```bash
    CFG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
    MEMORY_PATH=$(jq -r '.autoMemoryDirectory // empty' "$CFG/settings.json" | envsubst)
    ```
    
    ## Step 2: 跑機械掃描
    
    ```bash
    # <skill_dir> = 本 SKILL.md 所在的目錄;掃描標的由參數帶入,
    # 所以在哪個工作目錄呼叫都不影響結果
    python3 <skill_dir>/scripts/scan.py "$MEMORY_PATH"
    ```
    
    只用標準函式庫、唯讀、輸出 JSON。**目標目錄由參數帶入**,所以在哪個工作目錄呼叫都一樣。
    沒有 `MEMORY.md` 會回 `{"fatal": ...}` 並以 exit 1 結束。
    
    腳本已經處理掉幾個會讓檢查靜默失效的坑,**不要自己在對話裡改寫成 shell 一行流**:
    
    - 不用 shell glob(zsh 未匹配 glob 會在指令執行前中止,而且 `2>/dev/null` 擋不住)
    - 不用固定路徑暫存檔(並行執行會互相覆蓋、失敗留髒資料)
    - 不 import 第三方套件(唯讀階段不該動 user 的 Python 環境;離線環境也裝不了)
    - `[[...]]` 掃描先剝掉 fenced 與行內 code,且**檔名與 frontmatter `name` 兩種都算解析成功**
    - 索引目標的 `./` 前綴會正規化
    
    輸出欄位:
    
    | 欄位 | 意義 |
    |---|---|
    | `layout` | 單層(只有 `MEMORY.md`)/兩層(`MEMORY.md` 只留路由、細目在 `index_*.md`) |
    | `index_declared_missing` | `MEMORY.md` 指到但磁碟上沒有的子索引 |
    | `index_orphaned` | 磁碟上有、但沒人指向的孤立子索引 |
    | `orphan` / `missing` | 有檔沒被索引/索引指向不存在的檔 |
    | `frontmatter` | 缺 `name`/`description`(只認頂層)或 `type`(頂層或 `metadata.type`)|
    | `wiki_broken` / `wiki_external` | `[[...]]` 解析不到的/指向子目錄或外部的 |
    | `oversize` | 超過 300 行的檔(行數語意同 `wc -l`)|
    | `prefixes` / `no_prefix` | 命名前綴分布與例外 |
    
    🔴 **兩層結構下若只拿 `MEMORY.md` 當索引來源,會把整庫誤判成 orphan。** 腳本已處理,
    但若你另外手寫檢查,這是最容易踩的一個。
    
    ## Step 3: 判讀(這段才是本 skill 的價值)
    
    腳本只給事實,**嚴重度與去留由你判斷**。判斷前先讀 `MEMORY.md` 與相關索引的說明文字,
    很多「異常」其實是既定決策。
    
    | 嚴重度 | 收什麼 |
    |--------|--------|
    | 🔴 Error | 結構壞了:`index_declared_missing`、`orphan`、`missing`、`frontmatter` 缺漏、真正的斷鏈、兩條規則直接互打 |
    | 🟡 Warning | 沒壞但該看:`index_orphaned`、過期 dashboard、`oversize`、命名例外、疑似結束未歸檔 |
    | 🔵 Info | 啟發式:語意相近可能重複、description 與索引描述不符、同一計數寫在多處 |
    
    不確定就降一級。**推測與語意相似一律不得標 Error。**
    
    ### 這些不是缺陷,別報
    
    - **`wiki_external` 多半是刻意的** —— 指向 `archive/` 的歸檔檔案,或指向另一台機器上的
      外部知識庫。報之前先看那個目標像不像檔案路徑,並問 user 一次就好,不要每次重報。
      ⚠️ **`wiki_broken` 裡也可能混著刻意的外部連結。** 腳本靠「目標裡有沒有路徑分隔符」分類,
      所以像 `[[某個外部條目]]` 這種沒有路徑特徵的外部參照會落到 `wiki_broken`。
      **這無法自動判斷**,一律當成「需要人確認」,確認過就記進已接受例外清單,別每輪重報。
    - **超過 300 行有時是刻意的** —— 若某份 canonical 規則檔明文要求「使用前必須完整讀完」,
      拆開就破壞用途。標成「刻意例外」並寫明理由。
    - **語意相近的一組規則可能是刻意不合併的** —— 為了精準 recall 而把同一原則拆成多個觸發點
      是常見設計,索引裡通常有明文宣告。看到就標明是既定決策,**不要建議合併**。
    - **子目錄不在掃描範圍** —— 腳本只掃根層。`archive/` 是歸檔;其他子目錄可能是某則 memory
      的附屬資料(例如一份 memory 指向 `ref/` 底下的參考檔)。**它們沒被索引是正常的。**
      自己補查時記得也要限制在根層,別寫成遞迴:
    
      ```bash
      find "$MEMORY_PATH" -maxdepth 1 -name '*.md' -type f     # ✅
      rg --files -g '*.md' "$MEMORY_PATH"                       # ❌ 會遞迴讀到 archive/ 與其他子目錄
      ```
    
    ### 這些要另外用眼睛看(腳本測不出來)
    
    - **過時狀態**:`project_*` 超過 30 天沒動、dashboard 超過 7 天沒更新、內文寫「進行中」
      但檔案很久沒改。🔴 **先確認時間來源可不可信**:檔案的 mtime 一經複製、還原、
      重新 clone 就會全部變成當下時間。是 git repo 就改用 `git -C "$MEMORY_PATH" log -1 --format=%ad -- <file>`;
      不是 git 又剛搬過家,就**明講這項檢查在本次環境不可靠**,不要拿一批假的日期去下判斷。⚠️ **這是低訊號檢查** —— 黑名單、已完工的側專案、機器後路天生就不會動。
      而且「進行中」命中的可能只是待辦清單裡一個帶日期的項目,不是整個專案的狀態。
      **報告要引出命中的那一行原文**,讓 user 看得出是哪一種。
    - **規則直接衝突**:🔴 **不要對全部檔案兩兩比對。** 規則檔一多(例如 80 份以上),
      輸出會直接撐爆而被截斷 —— 那時你既審不完、也不知道漏了什麼,卻很容易誤以為審過了。
      **改成兩段**:先用 description 的字元 bigram 做 Jaccard 粗篩(門檻約 0.16)取出候選配對,
      **只對候選配對讀原文**;一輪最多看 10 對,超過就明講「本輪只看了前 N 對、其餘未檢查」。
      命中後**並列兩邊原句**讓 user 自判,不要自己拍板「這是衝突」。
      🔴 **絕對不要因為輸出被截斷就當作「沒發現問題」** —— 那是沒檢查,不是通過。
    
    ## Step 4: 出報告
    
    繁體中文(語言跟 memory 對齊)。每個 finding 必須有**具體檔名** + 行為描述 +
    建議動作(Error 必附)。結尾主動問要不要進 Phase 2,並建議優先序:
    一行就能修的(斷鏈、索引缺漏)→ 每個 session 都會載入的檔 → 大型重構。
    
    ---
    
    # Phase 2 — 執行修正(需明確授權)
    
    ## 開工前四項,缺一不進
    
    **1. 判斷是不是 git repo。** Step 1 只要求有 `MEMORY.md`,而預設的 `~/.claude/memory/`
    常常**不是** repo。整套回退機制不能預設 git 存在。
    
    ```bash
    # 🔴 要比對 top-level 是不是它自己,不能只問「這裡有沒有 git」——
    #    rev-parse --git-dir 會往上找父層 repo,memory 目錄只要剛好放在某個 repo 底下
    #    就會被誤判成 git 模式,接著把 memory 的改動提交進那個不相干的 repo
    top=$(git -C "$MEMORY_PATH" rev-parse --show-toplevel 2>/dev/null)
    [ -n "$top" ] && [ "$top" = "$(cd "$MEMORY_PATH" && pwd -P)" ] && echo git || echo 非git
    ```
    
    **2. 依上一步建立回退點:**
    
    | 情況 | 回退點 | 回退方式 |
    |---|---|---|
    | git repo | `git -C "$MEMORY_PATH" log --oneline -1` 的 commit hash,寫進回報 | `git -C "$MEMORY_PATH" revert` 或退回該 commit |
    | 非 git | 見下方備份指令(**放在 memory 目錄外**,免得被自己掃到)| 見下方回復指令 |
    
    ```bash
    # 建備份:碰撞就停,不要覆蓋既有備份
    BAK="$MEMORY_PATH.bak.$(date +%Y%m%d-%H%M%S)"
    [ -e "$BAK" ] && { echo "備份路徑已存在,停止"; exit 1; }
    cp -R "$MEMORY_PATH" "$BAK" && echo "回退點:$BAK"
    
    # 回復:一定要用 --delete,否則 Phase 2 新增的檔案不會被移除
    rsync -a --delete "$BAK/" "$MEMORY_PATH/"
    diff -rq "$BAK" "$MEMORY_PATH" && echo "回復完成且內容一致"
    ```
    
    🔴 **「把備份複製回去」不等於回復。** 單純覆蓋複製移不掉 Phase 2 新增的檔案,
    回復後會是舊檔與新檔的混合體,而且**看起來像成功了**。一定要用 `rsync --delete`
    (或等效做法)並用 `diff -rq` 驗過。
    
    **3. 確認工作區乾淨**(git 情況):有未提交的改動先問 user。
    
    **4. 把 Phase 3 的驗證管道先定下來並試跑一次。** 三件事都要做完才算過:
    
    1. **寫下你打算在 Phase 3 用的那一行完整指令**(含唯讀旗標與標的目錄),記進回報。
    2. **用那一行實際發一次最小的唯讀請求**(例如請它回報某個檔案的行數),確認拿得到回應。
    3. 拿不到回應就**停下來問 user**,讓他選:換一個管道、接受「只做自檢、不做外部複驗」,
       或不要進 Phase 2。**user 選了「只做自檢」也可以繼續**,但要在最終回報裡明講
       這次沒有外部複驗。
    
    🔴 **只看執行檔在不在是不夠的。** 裝了但沒登入、設定壞掉,`command -v` 一樣會過,
    然後你會在 Phase 2 已經改完並提交之後才撞到 —— 正好是這道閘門要防的狀態。
    
    🔴 **本 skill 不指定用哪一套工具。** 只要求它是**獨立行程**、**可唯讀執行**、
    **可把標的目錄寫死**。至於是哪一套 CLI agent,由執行者依環境決定並記錄下來。
    
    ## 動手紀律
    
    🔴 **所有 git 指令一律帶 `-C "$MEMORY_PATH"`。** 裸的 `git add` / `git commit` / `git status`
    會作用在**呼叫端的專案**上(子行程裡的 `cd` 不會改變父行程的工作目錄),
    結果是把別人的檔案提交掉,而 memory 的改動還躺在那裡沒進版控。
    
    🔴 **Edit / Write 可能被背景 session 的隔離守衛擋住。** memory 目錄常伴隨一個
    「Edit/Write 後自動 commit」的 hook,而**用 Bash 改檔不會觸發那個 hook**。
    被擋時的正解:**用 Bash 寫檔,最後手動 `git -C "$MEMORY_PATH" add -A && git -C "$MEMORY_PATH" commit`**,
    改完 `git -C "$MEMORY_PATH" status -s` 確認乾淨才算數。
    
    🔴 **`git push` 要另外問過,不含在 Phase 2 的授權範圍。** user 同意的是「修這幾條」,
    不是「把 memory 發佈到遠端」。memory 內容通常私密,而推送在多數託管服務上不可逆
    (就算之後刪掉,中間狀態可能已被同步或快取)。**commit 完停手,明確問一句再動。**
    例外:該目錄若本來就掛著自動推送的 hook,那是既有行為 —— 但仍要在回報裡講明。
    
    🔴 **不要在 memory 目錄內開 worktree。** 若該目錄的 hook 跑的是 `git add -A`,
    worktree 會被當成 gitlink 提交進版控。
    
    🔴 **「規則漂移」類發現一律先逐檔讀過再決定。** 例如「這幾個檔依規則不該存在」——
    這種規則幾乎都留有例外(文件查不到、跨 session 才需要知道的操作性事實)。
    **報告階段只能標「待判斷」、禁止寫「建議刪除」;Phase 2 也禁止照單無腦刪。**
    
    🔴 **改名要連帶處理入站引用**,`[[...]]` 與 markdown 連結都要掃,含 `archive/` 底下的。
    
    ## 收尾
    
    重跑一次 `scan.py`,**全綠才進 Phase 3**。自檢就不過的東西不要浪費一次外部複驗。
    
    🔴 **「全綠」不是「JSON 完全是空的」。** 已經跟 user 確認過、決定不處理的項目
    (刻意的外部連結、刻意超長的 canonical 檔、刻意不合併的規則群組)**會一直留在輸出裡**。
    定義是:**輸出裡每一條剩餘項目,都在你這輪明確記錄下來的「已接受例外」清單上。**
    把那份清單寫進回報,Phase 3 的 brief 也要附上,否則外部驗證者會把它們當成新缺陷報回來。
    
    ⚠️ **腳本全綠不代表語意層也通過。** 它只驗結構,改掉一處內容矛盾之後 JSON 完全不會變。
    語意層的判斷(矛盾、過時、重複)沒有機械證據,回報時要分開講。
    
    ---
    
    # Phase 3 — 獨立複驗
    
    ## 🔴 先凍結
    
    **Phase 2 的改動要全部完成並提交(git)或備份定版(非 git)之後,才准派複驗。**
    一邊改一邊驗等於叫對方驗一個會動的目標,結論無效、還浪費一輪。
    
    ## 🔴 要外部行程,不能用同 session 的 subagent
    
    subagent 繼承這個 session 的記憶注入,而那是 **session 開始那一刻的快照** ——
    剛改完的東西不在裡面,連檔名都可能還是舊的。**拿吃著舊快照的 agent 去驗磁碟真值,
    邏輯上不成立。** 用獨立行程(例如另一套 CLI agent),它不吃這份注入才算外部視角。
    
    派工時要**把標的目錄寫死、把寫入權關掉**,否則它可能檢查錯的目錄,或動到剛凍結的檔案。
    
    ## 兩種驗證題,抓的東西不重疊
    
    **A. 機械事實查核** —— 答案非黑即白:`MEMORY.md` 結構與行數、改名前後的檔名各自存不存在、
    索引差集是否為空、`[[...]]` 解析失敗數是否為零、frontmatter 是否全數完整。
    
    **B. Recall 可用性測試** —— 測「找不找得到」,設計比題目重要:
    
    - **只准從 `MEMORY.md` 起步**,之後自行決定往下讀什麼
    - 🔴 **禁用 `grep` / `find` / `ls` 暴力掃目錄** —— 不禁的話它繞過索引也答得出來,等於沒測到路由
    - **每組夾一題負面對照**(問一個確定沒有記憶的主題),看它會不會憑常識編一個答案
    - 要求回報**走過的路徑**:讀了哪些檔、是索引裡哪一行導過去的
    - 要求給索引好不好用的評語,並明講不要客套
    
    ## 🔴 brief 要先寫明「哪些是刻意的」
    
    外部驗證者**沒有脈絡**。不先講,它一定會把既定決策當成缺陷報回來,然後你得逐條解釋 ——
    那正是審查迴圈燒時間的燃料。至少要先交代:刻意的外部連結、刻意超長的 canonical 檔、
    刻意不合併的規則群組、已知但這次不處理的待辦、以及不在掃描範圍的子目錄。
    
    ## 判定與回退
    
    | 結果 | 動作 |
    |------|------|
    | 全過 | 收工,回報改了什麼、驗了什麼 |
    | 只揪出可修的小缺陷 | 修掉 → **重新凍結** → 再驗一輪 |
    | 結構性失敗(索引導不到、大量斷鏈、走不到答案) | **回退到 Phase 2 前的回退點**,重新設計再來 |
    
    ⚠️ **驗證結果是輸入、不是結論。** 它報的每一條先自己複核再決定改不改,
    別因為「外部工具說的」就照做。特別留意三種誤判:把 user 刻意的決策當缺陷、
    把不在它視野內的東西(其他機器、已歸檔)當斷鏈、把「規則說不該存在」直接推成「該刪」。
    
    ⚠️ **「審到沒問題為止」不是終止條件。** 每修一次 diff 就更大,下一輪就有更多表面可挑,
    這是正回饋不是收斂。**要收斂就把 diff 變小** —— 與其逐條硬補,不如把出錯表面整類移除
    (例如把散落的檢查邏輯收進一支測過的腳本)。判準改成「這條在實際使用路徑上打得到嗎」,
    打不到就記錄進文件、不動手。
    
    ---
    
    ## Anti-patterns
    
    - ❌ 沒經 user 同意就進 Phase 2
    - ❌ Phase 1 期間修改 / 刪除 / 合併任何 memory 檔案
    - ❌ 把 `scan.py` 的邏輯改寫成對話裡的 shell 一行流(glob、暫存檔、cwd 那幾類坑會全部回來)
    - ❌ 只把 `MEMORY.md` 當索引來源
    - ❌ 一邊改一邊派複驗
    - ❌ 用同 session 的 subagent 當「獨立」對照組
    - ❌ 看到「規則漂移」就建議刪檔
    - ❌ 裸 `git` 指令不帶 `-C "$MEMORY_PATH"`
    - ❌ 把 `git push` 當成 Phase 2 授權的一部分
    - ❌ 在唯讀階段安裝套件
    - ❌ 把「語意相似」標成 Error
    - ❌ 掃 `archive/` 或其他子目錄(除非 user 明確要求)
    - ❌ 沒 prefix 慣例的目錄硬套命名檢查
    - ❌ 路徑偵測不到時瞎猜
    - ❌ 報告用英文模板套中文 memory
    - ❌ 把結果 append 到任何 ledger 檔
    
    ## Important rules
    
    1. **Phase 1 唯讀不可協商** —— 報告階段禁用 Edit / Write / `mv` / `rm` / `git`
    2. **Phase 2 需明確授權**,且開工前四項前置缺一不可
    3. **Phase 3 先凍結、要外部行程、標的寫死、寫入關掉**
    4. **Path 偵測順序固定** —— 5 級依序,找不到就停
    5. **Severity 寧降勿升**
    6. **每個 finding 必須有具體檔名**
    7. **衝突永遠並列原句**,不替 user 判定
    8. **報告印到對話即可**,不另存檔
    9. **外部驗證是輸入不是結論**,逐條複核
    10. **未來功能(cron 定排、跨機器比對)目前不存在** —— user 問起就說 v0.x 還沒做
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related