Imported from SpringRain555/tagcor-ledger (
AGENTS.md). Install upstream withnpx skills add SpringRain555/tagcor-ledger. Copyright stays with the author.
AGENTS — TagCor Ledger
這份與
CLAUDE.md是平級的兩份完整規則(Codex 只自動載入AGENTS.md, Claude Code 只自動載入CLAUDE.md)。這一份的本文是從CLAUDE.md產生的:改規則一律改CLAUDE.md,然後跑.\tools\Sync-AgentDocs.ps1;直接改這裡會在下一次同步時被蓋掉 (2026-09-13 起;之前靠「同一個 commit 改兩份」這條儀式,而儀式會漏)。tools\Verify.ps1會驗兩份本文逐字相同。唯一該有的差異是「工具專屬」那一節。產生器會比對兩份最後被改的 commit,不一致就報
agent-doc-drift; 也會剝掉這兩對標記之間的內容再比 hash,不一致就報agent-doc-content-drift。 後者是 opt-in 的 —— 沒有標記它根本不啟動,而這兩對標記是 2026-09-06 才補上的。2026-08-30 之前
CLAUDE.md只是一份指向這裡的指路檔 —— 也就是說 Claude Code 自動載入到的只有那幾行,底下這一整份規則要靠它自己去跟。
上游規範。 這個專案在
D:\Projects底下,治理規則的單一真實來源是_meta\CONVENTIONS.md。下面是摘要不是副本,有出入時以那一份為準。適用:§3 命名|§6 git 與守門|§8 編碼(
.ps1要 BOM、.md不要)|§9 README 與 agent 檔|§11 外部資料根|§12 文件區骨架|§13 公開前準備|§14 規則的上行 |§16 本機 app 的生命週期(這個專案適用 —— 使用者自己開、自己關)。本專案的例外:
docs\archive\phase-0-2\是 §12 沒有的第七區, 只供追溯、不是目前規格來源。不要假設上游那一份會被讀到。 Claude Code 會往上層目錄串接,但 Codex 的慣例是 「最近的那一份勝出」—— 失敗方向是「Codex 讀不到上游規則」,所以這一份必須自給自足 (§14)。完整脈絡見
_meta\cards\tagcor-ledger.md。
工具專屬
Codex(讀這一份):沒有額外規則,往下讀就好。
Claude Code(讀 CLAUDE.md):
- 用專案的 conda 直譯器,不要用 PATH 上的 python:
<conda-root>\envs\tagcor-ledger\python.exe。 工具 shell 不載入 profile,conda activate會回報成功卻什麼都沒換。 .claude\settings.json的 deny 攔不住 Bash 與 PowerShell(Get-Content、cat、Select-String都能讀檔)。用 shell 時靠的是下面「資料位置與讀取邊界」 的成文規則,不是設定檔。- 主要 shell 是 PowerShell 5.1,沒有
&&/||/三元運算子。
專案定位
Windows-first、純本機、完全不連網的個人記帳工具。主資料庫是 SQLite,CSV 只作交換格式。介面固定使用繁體中文與 PySide6。
每一筆帳都由使用者手動輸入 —— 這是刻意的設計,不是還沒做的功能。手動輸入才讓使用者感受得到花費的程度。
資料位置與讀取邊界
「運作的地方」與「儲存的地方」是分開的,這樣專案日後若推上 remote,只會公開程式,不會公開任何個人財務資料。
| 位置 | |
|---|---|
| 程式(可公開) | 這個 repo 的資料夾 |
| 資料(絕不公開) | <資料根目錄> |
| 指標檔 | %LOCALAPPDATA%\TagCor\TagCorLedger\system_paths.json |
資料根目錄底下固定五個平輩資料夾:ledger\、backups\、exports\、logs\、tmp\。
例外:程式資料夾裡的 userdata\(ADR-0018)。從 GitHub 下載來用的人,第一次啟動可以選把資料放在
這裡;它整個在 .gitignore,有測試守著。看到它就當成資料根:只由 App 寫、內容不貼進對話,
規則跟下面的 <資料根目錄> 一樣。這個 repo 的開發者本機不用它 —— 資料在外部資料根,由指標檔指定。
<資料根目錄>與<私人資料樹>是佔位符,因為這個 repo 是公開的 —— 把私人資料夾的實際名稱寫進版控,等於把那份清單一起發佈出去。 實際路徑有兩個權威來源:指標檔system_paths.json的data_root, 以及本機那份.claude/settings.json(不進版控,範本是.claude/settings.example.json)。要知道現在指到哪,跑& $env:TAGCOR_PYTHON -m tagcor_ledger --json。
agent 的讀取規則
-
可讀:本專案全部 +
<資料根目錄>\**底下所有檔案。 -
可寫:只有
<資料根目錄>頂層的.md(上線 runbook 那類文件)。帳本、備份與其他子資料夾一律不寫 —— 那些只由 App 自己寫。 -
不可讀、不可寫:
<私人資料樹>\底下其他任何資料夾。 -
即使在允許範圍內,也不要把實際內容貼進對話。 分兩級:
- 摘要式比對可以直接說 —— 欄位名、筆數、日期範圍、「幾處不符」。這種描述已經足夠決定要不要動手、動哪裡。
- 逐值列出要先問 —— 實際的帳戶名、金額、日期、第三方服務名,即使那個值在某份文件裡已經有(那份文件在資料側,對話不在)。
判準是「離開這段對話之後,這句話還能不能指認出一個人的什麼」。寫進資料側檔案的不受此限 —— 這條限制的是對話,不是工作成果。
這條 2026-09-05 破過一次:任務是拿 runbook 跟帳本對帳,而對帳不指名就沒有結果, 於是帳戶名、本金與第三方服務名全進了對話。當時的規則只說「用筆數這種描述」, 沒說對帳怎麼辦。一條做不到的規則不會被遵守,只會被繞過,所以改成兩級。
.claude\settings.json 怎麼設、以及它的三個極限
deny 優先於 allow,而且路徑 pattern 沒有否定語法,所以做不到「deny 整個 <私人資料樹>\** 但 allow 其中一個子樹」—— 那樣寫會連允許的資料夾一起擋掉。
2026-09-05 之前這裡是「寫入絕對禁止,無例外」,而那是錯的方向。 資料根裡除了帳本, 還有上線 runbook 這類本來就該被修改的文件;一律 deny 的話它們改不動,合法的修改 只剩下走 shell —— 而下面第一個極限正是「攔不住 shell」。 閘門把守規矩的那條路擋死、只留沒有守門的那條,方向是反的。
改成整棵樹的讀寫都 ask,硬擋改用**「只 deny 該擋的葉子」**—— 那個方向 glob 寫得出來:
| 規則 | 範圍 | 作用 |
|---|---|---|
ask |
Read/Edit on <私人資料樹>/** |
一律先問過使用者 |
deny |
Edit on 帳本與備份資料夾、以及 *.sqlite/*.db |
無例外。 那些只由 App 自己寫,agent 永遠不需要寫 |
allow |
Read on <資料根目錄>/**;Edit 只到 <資料根目錄>/*.md |
寫入只放行頂層文件(有使用者層基線時仍會先問,見下) |
規則只寫 Read(...) 與 Edit(...)。 Claude Code 的檔案權限只比對這兩種:Edit 涵蓋所有寫檔的工具,
Read 也套到 Grep/Glob。寫成 Write(...)/Glob(...)/Grep(...) 的規則會被收下、但從來不比對
(2026-10-11 實測)。另外 ask 優先於 allow,具體程度不改變順序 —— 使用者層對整棵 <私人資料樹> 的 ask
蓋過上表的 allow,所以讀資料根、改 runbook 照樣會先問。失敗方向是「多問一次」,是對的。
allow 的 *.md 用單一 * 是刻意的 —— 它只 match 一層,底下的資料夾一個都不放行。
deny 則一定要用 /** 結尾,理由正好相反:單一 * 會涵蓋 X 這個資料夾本身卻涵蓋
不到 X\secret.txt。同一個 glob 特性,兩個方向各用一次。
這一份不是唯一一層。
~\.claude\settings.json是「你,每個專案」,不管從哪個目錄 啟動都生效,而permissions這種清單鍵跨層是合併不是覆蓋。所以整棵樹的ask與 不綁專案名的葉子deny應該放在那裡,這一份只留這個專案自己的規則。 但專案層只在「從專案目錄啟動」時載入,從上層工作區啟動時它完全不生效 —— 所以真正要硬擋的東西不能只寫在這一份。沒有使用者層基線的人(例如剛 clone 的人) 把兩邊合起來放進這一份即可,settings.example.json給的就是合起來的完整版。
三個極限,不要當成滴水不漏:
- 攔不住 shell。
Get-Content、cat、type、Select-String都能讀檔,任意 shell 指令無法可靠地用 pattern 比對。用 shell 時靠的是這一節的成文規則。 - 不能假設什麼時候生效。 這裡原本寫「改了一定要重開 session 才會載入」,2026-09-05 實測不是:改完基線那兩層之後,同一個 session 裡原本被擋掉的 Edit 當場就寫得動了。兩個方向都別假設 —— 要驗就用全新 session 驗,因為要驗的本來就是「全新 session 看到什麼」。
- 新資料夾不會自動被擋。
<私人資料樹>\底下新增的資料夾不在 deny 清單裡,只會被ask攔下來問。tools\Verify.ps1的漂移檢查會列出它們並印出該加的規則字串。
改資料路徑是三步,不是一步
路徑寫死在兩個地方,改一個而不改另一個會造成漂移(agent 讀不到真資料夾,或舊路徑仍掛在允許清單上而該位置日後被別的東西佔用):
- App「系統設定 → 記帳資料路徑」改路徑。
.claude\settings.json的 deny/allow 清單同步改。- 跑
.\tools\Verify.ps1確認漂移檢查通過。
架構邊界
domain/:Money、帳戶、類別、交易、模板、餘額盤點、定存模型;不得依賴 Qt 或 SQLite,也不得 import 其他任何一層。application/:use case、Result、設定、備份/還原/重製協調;不得直接寫 UI。infrastructure/:SQLite migration、store、backup、CSV export。store 一律放在infrastructure/stores/,一個聚合一個檔,LedgerStore在sqlite_store.py用繼承把它們組起來(唯一的例外是LedgerStore自己,它只負責組裝)。stores/__init__.py的__all__必須與LedgerStore的基底一致。兩條都有測試守著。- 「一筆交易長什麼樣」只有一個地方說了算:
StoreBase._write_transaction()/_write_transfer()。它們收connection而不是自己開,所以「就寫這一筆」與「建交易+改別的表的狀態」兩種情境都能用同一份實作。transactions、transaction_fts、audit_events三張表只能有一個寫入點(stores/base.py),tests/unit/test_architecture.py會擋。要寫交易就呼叫那兩個,不要再開一條路 —— 分岔過一次,代價寫在docs/lessons.md。 ui/:PySide6 視圖與 controller;不得直接撰寫 SQL。一個檔案一個畫面放在ui/pages/,頁面之間不互相 import,跨頁連動一律集中在ui/main_window.py。LedgerController由ui/controller/底下的 section 用繼承組起來,比照LedgerStore。__init__.py只放組裝、不得定義任何方法;section 之間彼此不呼叫對方(唯一的例外是OverviewSection,它明說自己是聚合層)。有測試守著。DepositService由application/deposits/底下的 section 用繼承組起來,同一套做法。它沒有聚合層例外 —— section 之間完全不互相呼叫,test_no_deposit_section_calls_another_section守著。- 「一列長什麼樣」只由
ui/formatting/決定,ui/pages/不得自己定義會return [...]的*_values。同一個狀態有兩個拼法,兩張表就會對同一筆資料講不同的話。 - 系統路徑設定不存放在 ledger SQLite,使用外部 JSON 設定檔(資料庫路徑本身不能可靠地存在資料庫裡)。
application/的except只能用failures.py的兩個具名常數,見下一節。
這幾條由 tests/unit/test_architecture.py 用 AST 守著,不是只寫在文件上。同一份測試還會擋檔案過大:src/ 700 行、tests/ 1200 行(測試本來就比實作長,但 2026-08-22 拆掉的那個 UI 測試檔已經 2,153 行 66 條、橫跨八個頁面,因為當時只掃 src/)。完整的檔案地圖見 docs/architecture/overview.md。
application/ 怎麼接例外
只用 application/failures.py 的兩個常數,不要自己拼 tuple:
| 常數 | 內容 | 用在哪 |
|---|---|---|
STORE_FAILURES |
(ValueError, NotFoundError, sqlite3.Error) |
單層 handler —— 包一個 store 呼叫,整包交給 failure() |
DOMAIN_FAILURES |
(ValueError, NotFoundError) |
兩層寫入路徑的第一層 |
NotFoundError 繼承 RuntimeError 不是 ValueError,所以 except (ValueError, sqlite3.Error) 接不到它。2026-08-22 盤點時,這一層有 70 個 handler、17 種形狀,其中 15 個包著會丟 NotFoundError 的 store 方法卻沒有列它 —— 真的觸發時使用者看到的是全域錯誤對話框,不是中文。MoneyError(繼承 ValueError)與 sqlite3.IntegrityError(繼承 sqlite3.Error)不要另外列,那只是雜訊。
兩層寫入路徑是刻意的,不要為了整齊把它壓成一層:交易與餘額盤點的寫入要分開講「內容有問題」(第一層,failure() 保留原碼)與「內容沒問題但寫不進去」(第二層,except sqlite3.Error + 一句「什麼都沒變」)。守門用結構認第二層(同一個 try 裡第一個 handler 是 DOMAIN_FAILURES),不靠名單。
三種允許不一樣的情形,都要在 test_architecture.py 的名單裡帶一句理由:
- 還沒碰到 store —— 解析金額、建列舉。那是輸入驗證,不是寫入層失敗。
- 刻意收窄 ——
catalogs.create的(ValueError, sqlite3.IntegrityError):走到那裡表示上面三道重名檢查都沒攔到,放寬會把真正的 bug 藏成一句客氣的中文。 - 整個模組不經過 store ——
diagnostics.py(自己開連線跑 PRAGMA)、reference.py(另一個唯讀資料庫)。
重要規則
- 金額一律使用
Money(amount_minor: int, currency: str),禁止 float。 - 目前固定 TWD 與 Asia/Taipei。
- UI 用詞:
類別表示第一層,項目表示第二層;不要再用「分類/細項」。 - v0.6.0 已移除「對象/商家」欄位,不得新增 payee model、payees table 或 payee UI。
- 不得為一卡通/悠遊卡/iCash 建立帳戶、餘額欄位,或任何「卡內餘額」概念。 電子票證只記 儲值當下的支出,見下一節。
- 備份只能由使用者手動建立;啟動流程不得自動備份。
- 還原/重製前的保護備份必須由使用者明確勾選。
- 刪除設定項只允許未被任何歷史資料引用;否則使用封存。
- 盤點不建立交易、不建立 posting、不改變帳戶餘額。
- 禁止把資料撈進 Python 再排序或搜尋。 篩選、排序、分頁、加總一律在 SQL 裡做 —— 帳本會長大,而「先全部載入」的寫法在資料少的時候完全看不出問題。新增常用查詢時先看
EXPLAIN QUERY PLAN,並在tests/integration/test_query_plans.py加一條。 ORDER BY只能由stores/base.py的order_by()組,欄位只能來自各 store 的白名單(CATEGORY_SORT_FIELDS/ACCOUNT_SORT_FIELDS/TEMPLATE_SORT_FIELDS)。那是整個專案唯一把字串拼進 SQL 的地方:畫面永遠只送 key,認不出來的那一層直接跳過,整份都認不出來就退回該清單的預設。白名單的值裡不得出現引號、分號、%、{——tests/unit/test_order_by.py會掃。合法的字面值(COALESCE(x, ''))本來就可以改寫成不需要引號的形式,放行引號等於讓「哪一個引號是安全的」變成要逐一判斷。ledger_dir、backup_dir必須都在data_root底下,且彼此不得相同或互相包含。違反時丟PATH_OUTSIDE_DATA_ROOT/LEDGER_BACKUP_PATH_SAME/LEDGER_BACKUP_PATH_NESTED。- 搬移資料的順序不可調換:先複製到新位置 → 確認成功 → 寫指標檔 → 才刪舊檔。反過來會在搬移失敗時留下「指標指向新位置、資料還在舊位置」,下次啟動就在新位置建一個空資料庫,看起來像資料全部消失。
- 「從外部檔案還原」會讀取使用者從對話框挑選的任意路徑。這是刻意保留的例外(否則無法從外接硬碟還原),由使用者主動觸發。
電子票證慣例(悠遊卡/一卡通/iCash)
這是記帳慣例,不是功能。 不需要任何程式支援,用現有的「支出 + 類別/項目」就能表達。
| 發生什麼 | 怎麼記 |
|---|---|
| 儲值 500 元 | 支出 500,類別「交通」、項目「電子票證儲值」,帳戶選錢實際離開的那個 |
| 用卡片搭車、買東西 | 不記。 錢在儲值那一刻就已經離開了 |
| 退卡拿回卡內餘額 | 收入,同一個類別與項目 |
- 複數張卡共用同一個項目,不依卡片拆項目、不在備註裡分卡。
- 不建卡片帳戶、不追蹤卡內餘額。 這條由
tests/integration/test_ledger_core.py的test_schema_never_grows_a_stored_value_card_concept掃 schema 守著。
那個測試守的是 schema,不是使用者怎麼命名 —— 使用者要把帳戶取名叫「悠遊卡」程式攔不住, 也不該攔。會被擋下來的是「資料庫裡長出卡內餘額這個概念」。
代價是看不出電子票證的消費結構,這是知情且接受的。要改變這個取捨的話,
正確做法是新增一份 ADR 推翻 ADR-0006,
不是「順手」加一個欄位。市面產品幾乎都做卡片歸戶,所以這裡的壓力是持續的。
對外轉帳慣例
這是記帳慣例,不是功能。 記帳頁的「轉帳」底下有三種對象,但資料庫只有一種轉帳:
| 轉帳對象 | 存成 | 為什麼 |
|---|---|---|
| 我的帳戶之間 | transfer,兩筆 posting |
錢沒有離開你,只是換了地方 |
| 別人轉入 | 收入 + 類別/項目 | 錢進入你的總資產 |
| 轉出給別人 | 支出 + 類別/項目 | 錢離開你的總資產 |
判準是總資產有沒有變。這與「利息記成收入,不是轉帳」是同一條原則。
- 建議自己建一個類別「轉帳」,底下放「他人轉入」「轉出給他人」這類項目。 程式不自動建立任何類別 —— 名字該叫什麼是使用者的事。
- 「轉給誰」寫在備註。不得因此重新引入 payee model。
- 不得新增
transfer_in/transfer_out這類entry_type。 理由與被否決的 兩個替代方案寫在ADR-0010; 要改變這個取捨就新增一份 ADR 推翻它,不要順手加一個列舉值。
提款也是轉帳
從郵局領現金=錢在自己的兩個帳戶之間移動,總資產不變 —— 同一條判準,所以它就是 轉帳・我的帳戶之間(轉出帳戶=郵局、轉入帳戶=現金),一筆交易兩筆 posting。
- 不要記成「郵局一筆支出 + 現金一筆收入」。 總資產碰巧會對,但支出總額與收入 總額各被灌水一次,類別統計也跟著髒掉 —— 而那兩個數字才是記帳想看的東西。
- 不新增「提款」流向。 按下「轉帳」之後「轉帳對象」預設就停在「我的帳戶之間」
(
ui/pages/entry.py的scope_buttons),第四顆按鈕省不到任何一次點擊, 代價卻與 ADR-0010 否決的「方案甲」一模一樣。 - 嫌每次選兩個帳戶麻煩的話,去「操作設定 → 模板」建一個「郵局提款」模板 (轉帳型、金額留空),之後按「填入記帳頁」只要打金額。這是使用者自己建的資料, 程式不預先建立任何模板。
不做的事(非目標,不是待辦)
- 不做銀行同步、不串接電子發票載具、不做任何自動匯入。
- 不連網。App 永遠不發出網路請求。
- 不做多幣別與匯率、不做預算、不做雲端同步。
- 不重新加入 PyQt6、TagPath、CSV/JSON runtime store 或 importer。
UI 樣式規範
正本在 docs/architecture/ui-workflows.md 的〈UI 樣式規範〉
(2026-09-13 搬過去的;之前 55 行樣式細節住在這份硬規則檔裡,把真正會出事的那幾條淹掉了)。
改任何 UI 之前讀那一節。留在這裡的只有猜錯會出事、而且不是每個人都猜得到的:
- 色票的正本是
ui/colors.py,QSS 不得出現清單以外的色碼(test_resources.py掃)。 圖表的灰階CHART_SLICES刻意不在ALL_TOKENS裡,不要加進去。 - 表格不得在 QSS 設
color或selection-color—— 會蓋掉 model 的ForegroundRole, 金額的紅綠被壓成同一個白。 - QSS 一碰 subcontrol,Fusion 就不再畫它自己的圖形:不要覆寫
QComboBox::drop-down; 勾選框的勾號要自己給圖,而且只能是 PNG(標準與@2x兩個尺寸),SVG 與data:URI 都不畫。 apply_dark_theme()要在任何 widget 建出來之前套、一個 process 只套一次 —— 反了會讓測試從 46 秒變 32 分鐘,而且表格量到的是預設字體的寬度。- UI 測試量 geometry,不用
isVisible()過濾(offscreen 上它永遠是 False,整條守門會靜默跳過); 每一個帶continue的檢查迴圈都要有陽性對照。 - 改配色或樣式之後要真的看一眼(
window.grab().save(...),用QT_QPA_PLATFORM=windows才有中文字型)。 UI 變更至少跑tests/uismoke。
環境與驗證
環境是 conda(environment.yaml 的 tagcor-ledger),專案裡沒有 .venv。
PySide6 由 conda dependency 管理,不能放回 pyproject.toml 讓 pip 安裝 —— Windows 下混用 conda/pip 的 PySide6 會造成 Qt DLL 載入失敗。
人要開程式:雙擊 start_tagcor-ledger.cmd。 它只做一件事:把事情交給 tools\Launch.ps1,
而那一支用絕對路徑呼叫環境直譯器,不碰 PATH。(2026-09-13 之前叫 Start TagCor Ledger.cmd,
邏輯在根目錄的 Launch.ps1;改名與搬家是為了四個本機 app 長得一樣 —— CONVENTIONS §12/§16。)
人在互動式 PowerShell 裡:先啟動環境。
conda activate tagcor-ledger
python -m tagcor_ledger --gui
但終端機裡若已經啟動了別的專案的 venv,這樣會失敗 —— venv 的 Scripts 排在 PATH 最前面,conda activate 之後仍然是它贏,兩個環境名稱都出現在提示字元上卻跑到錯的直譯器。先 deactivate,或直接用一鍵啟動。
agent 在工具 shell 裡:一律用完整路徑,不要用 conda activate。
& $env:TAGCOR_PYTHON -m tagcor_ledger --json
理由是工具 shell 以 -NonInteractive 啟動、不載入 profile.ps1,所以 conda init powershell 裝的 hook 沒生效。此時 conda activate 會跑在子 process 裡改不到父層環境,回報成功、退出碼 0、實際上什麼都沒換(2026-08-18 實測)。接著跑到的會是 PATH 上碰巧排在前面的別的直譯器 —— 那個直譯器多半沒有 PySide6,於是失敗訊息會指向完全無關的方向。
.\tools\Verify.ps1 # 路徑漂移檢查 + Qt stub + agent 檔同步 + ruff + mypy --strict + pytest
.\tools\Verify.ps1 -Ui # 加跑 tests\ui(offscreen)
.\tools\Verify.ps1 -Performance # 加跑 20 萬筆效能測試
三層閘門,各擋不同的東西:
| 層 | 何時 | 跑什麼 | 耗時 |
|---|---|---|---|
.githooks/pre-commit |
每次 git commit |
ruff + mypy | 約 2 秒 |
.githooks/pre-push |
每次 git push |
ruff + mypy + 全部測試 | 約 70 秒 |
| GitHub Actions | push 之後 | 同上,但少了量像素的那幾條 | 約 60 秒 |
pytest 進得了 pre-push 卻進不了 pre-commit,差別在頻率。 pre-commit 一天觸發
十幾次,66 秒會讓人習慣性打 --no-verify,而一個被習慣性繞過的閘門比沒有閘門更糟;
pre-push 通常一個工作段落才一次,70 秒可以接受。
pre-push 跑得比 CI 完整 —— 它在 Windows 上,所以量像素的那幾條(geometry
marker)只有這裡會自動跑到。CI 在 Linux,字體與 Qt 平台外掛都不同,量出來的不是
使用者會看到的東西。
兩個 hook 的直譯器查找共用 .githooks/resolve-python.sh,認的環境變數與
tools\Launch.ps1/tools\Verify.ps1 相同($TAGCOR_PYTHON)。要跳過用 --no-verify;
完整輸出仍然是 .\tools\Verify.ps1 -Ui。
hook 沒生效的話跑一次 git config core.hooksPath .githooks。
語言與編碼
- 中文一律繁體。 不得混入簡體字。
tests/unit/test_traditional_chinese.py是自動守門,tools\Verify.ps1每次都會跑。它只收「簡體專用」字,繁簡同形的字(量、常、伙、台…)不列入,所以零誤報。 - 介面文字、錯誤訊息、註解、文件全部繁體中文;程式識別字用英文。
.md/.json:UTF-8 無 BOM。.ps1/.psm1:UTF-8 必須有 BOM(PowerShell 5.1 沒有 BOM 會退回 Big5 而整份亂碼)。
先讀什麼
兩份一定要讀:這一份(硬規則與邊界)+ docs/lessons.md(踩過的坑;
動 migration 或路徑之前必讀)。然後只讀擁有你要動的東西的那一份 ——
哪一份擁有哪件事,docs/index.md 的權威範圍表說了算:
| 要動的是 | 讀 |
|---|---|
| 狀態與轉移 | docs/architecture/state-machines.md(哪些轉移合法、哪裡刻意不推論) |
| 表與欄位 | docs/architecture/data-model.md |
| 錯誤碼 | docs/architecture/error-codes.md(成因與使用者該怎麼做) |
| 用詞 | docs/architecture/glossary.md(什麼該叫什麼,以及不該叫什麼) |
| 某一頁、任何樣式 | docs/architecture/ui-workflows.md(含〈UI 樣式規範〉) |
| 某個功能的範圍與決定 | 那份 docs/requirements/REQ-XXXX 與 docs/decisions/ADR-XXXX |
2026-09-13 之前這裡是七步「不要跳」的閱讀順序。七份全讀,對改一行 UI 文字的人是 過度約束,而過度約束的清單會被整份跳過 —— 那比「只讀相關的那一份」更糟。
docs/archive/phase-0-2/ 只是歷史紀錄,不是現行規格。
文件維護
功能變更一定要寫的只有兩份:docs/changelog.md(那一版改了什麼、為什麼)+
擁有那件事的那一份 —— 改了狀態就 state-machines.md、錯誤碼就 error-codes.md、
頁面就 ui-workflows.md、用詞就 glossary.md、環境就 environment/index.md、
需求範圍就那份 REQ。哪一份擁有哪件事,docs/index.md 的權威範圍表說了算。
其餘不必每次改,交給 drift 測試:頁面名、圖、連結、錯誤碼、版本號、分頁數都有測試
逐字比對(tests/unit/test_docs_drift.py、test_docs_links.py、test_diagrams_drift.py
等),漂了會紅;沒有測試守的(README 的功能清單、roadmap)講的是方向,不是每一版的內容。
2026-09-13 之前這裡寫「任何功能變更都要同步更新 README、requirements、architecture、 roadmap、changelog」五份。那條規則沒有被遵守 —— roadmap 的逐版表停在 v0.24.1, 後面三版沒有人補 —— 而一條做不到的規則不會被遵守,只會被繞過(同「資料邊界」那一節 2026-09-05 的教訓)。
三份跨區文件各自的範圍,每一份只寫屬於它的那一段:
| 文件 | 寫什麼 | 不要寫什麼 |
|---|---|---|
docs/roadmap.md |
做過什麼、接下來做什麼;計畫改變時才動它 | 每一版的內容(那是 changelog;版本索引是 grep "^## " docs/changelog.md) |
docs/changelog.md |
那一版改了什麼、為什麼這樣改 | 未來的計畫 |
docs/lessons.md |
踩到的坑與根因。append-only | 功能說明 |
同一個結論寫在三個地方,改的時候就要記得改三個地方 —— 而漏掉的那一份會繼續
用權威的語氣講一件已經不成立的事。2026-08-22 清掉的就是這種重複:roadmap 每發一版
長出一節「這一版最值得記住的一件事」,內容與 lessons.md 重疊,而且長在
「後續候選 Phase」底下。
改了 docs/architecture/*.md 裡的 ```mermaid 區塊,要跑 .\tools\diagrams\Render-Diagrams.ps1 重新產生 SVG(tests/unit/test_diagrams_drift.py 會擋;那條測試不需要 node)。踩到坑要在 docs/lessons.md 追加一筆 —— 那是 append-only 的失敗紀錄,目的是不要重蹈覆轍。
新增或改名一頁時,docs/architecture/ui-workflows.md 的頁面地圖要跟著改,
而且那一列的**「不在這裡做的事」不可以留空** —— 那一欄才是整張表存在的理由。
v0.14.0 之前沒有這張表,症狀是連作者自己都會忘記「待確認」是做什麼的;
一頁說不出自己不做什麼,下一個功能就會被塞進去。名稱由
tests/unit/test_docs_drift.py 逐字守著,那一欄的內容只有人看得出來。
