devlog-tracker 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  *English | [繁體中文](README.zh-TW.md)*
4
4
 
5
- **Version** 0.32.0
5
+ **Version** 0.33.0
6
6
 
7
7
  Maintains a `.devlog/devlog.md` in your project, turning each conversation round's requests, decisions, and outcomes into a permanent record. A conversation disappears the moment you `/clear` or switch sessions; this file fills that gap so work can pause and resume. Nothing is touched until you explicitly run `/devlog-tracker:start` — installing the plugin alone doesn't create or modify any files.
8
8
 
@@ -90,7 +90,7 @@ bash "$DEVLOG_TRACKER_ROOT/core/scripts/segment-watch-set.sh" 600
90
90
  bash "$DEVLOG_TRACKER_ROOT/core/scripts/checkpoint-set.sh" 20
91
91
  bash "$DEVLOG_TRACKER_ROOT/core/scripts/report-devlog.sh" --json
92
92
  bash "$DEVLOG_TRACKER_ROOT/core/scripts/timeline-devlog.sh"
93
- # pause / span-open / span-close / compact / keep-move / clean / resume / pr / promote: see commands/*.md
93
+ # pause / span-open / span-close / compact / keep-move / keep-all / clean / resume / pr / promote: see commands/*.md
94
94
  ```
95
95
 
96
96
  ## Quick start
@@ -115,7 +115,8 @@ The table below uses the plugin's `/devlog-tracker:*` namespace; `npx init --cla
115
115
  | `/devlog-tracker:continue` | Reads `.devlog/devlog.md`, checks the last round's Handoff "Workspace" section, then continues per its next step. Use this after `/clear` to resume. See [`docs/design/continue.md`](docs/design/continue.md). |
116
116
  | `/devlog-tracker:pause` | Pauses enforced recording; history files are untouched, and you can `start` again later. |
117
117
  | `/devlog-tracker:compact` | A script moves older `DONE` rounds into `devlog.archive.md` (Checkpoints and unfinished rounds stay in the main file). |
118
- | `/devlog-tracker:keep` | Scans the whole file, groups it by topic, lists suggestions at once, then — after confirmation — moves each section out into its own `devlog.<name>.md` (leaving a `## Kept index` pointer line with a one-sentence topic description in the main file); can also extract a single section or merge everything into one history file. Not the same as compact. See [`docs/design/keep.md`](docs/design/keep.md). |
118
+ | `/devlog-tracker:keep` | Scans the current branch's main file (to reorganize every devlog at once, use `keep-all`), groups it by topic, lists suggestions at once, then — after confirmation — moves each section out into its own `devlog.<name>.md` (leaving a `## Kept index` pointer line with a one-sentence topic description in the main file); can also extract a single section or merge everything into one history file. Not the same as compact. See [`docs/design/keep.md`](docs/design/keep.md). |
119
+ | `/devlog-tracker:keep-all` | Reorganizes **every** devlog, not just the current branch's: the current main file, every other branch's `devlog.<branch>.md`, `devlog.archive.md`, and all files earlier `keep`/`keep-all` runs produced. Claude regroups their Rounds by topic across files (one topic spread over main, a feature branch and the archive ends up in one file), lists every proposed file at once, and after confirmation one script run writes them all or nothing. Existing kept files are broken up and rebuilt; each branch's unfinished tail (Rounds after its last `DONE`) and the open Round never move; branch files are never deleted. Every touched file is backed up to `.devlog/.keep-all-backup/<timestamp>/` first. Needs Node ≥18. See [`docs/design/keep-all.md`](docs/design/keep-all.md). |
119
120
  | `/devlog-tracker:overview` | Reads all kept `devlog.<name>.md` files and merges them into a cross-topic overview, plus a list of candidate rules that look like they belong in `CLAUDE.md`. Read-only — no workspace check, no confirmation, no writes. See the Kept index section of [`docs/design/keep.md`](docs/design/keep.md). |
120
121
  | `/devlog-tracker:promote` | Picks rule candidates from kept files, lessons files and Checkpoint `### 決策` sections, lists them numbered, and — only for the ones you choose — appends them to a managed `<!-- devlog-tracker:rules:begin/end -->` block in `CLAUDE.md` (or `AGENTS.md` when `CLAUDE.md` is just `@AGENTS.md`, or on Codex-only projects). Append-only, exact duplicates skipped; `init` never rewrites this block. |
121
122
  | `/devlog-tracker:search <keyword>` | Case-insensitive string search across `devlog.md`/`devlog.archive.md`/kept `devlog.<name>.md`/`devlog.lessons.<topic>.md`; Claude answers in its own words from the hits (with file/heading/line as evidence). Read-only — no workspace check, no confirmation, no writes. |
@@ -233,7 +234,7 @@ When Claude ends a turn with a plain-text question and the next message is the a
233
234
 
234
235
  #### Per-branch devlog files
235
236
 
236
- Switching branches within the same working directory automatically splits the main file by the checked-out branch: `main`/`master` keeps using `.devlog/devlog.md`, while other branches each use `.devlog/devlog.<branch>.md` (slashes converted to `-`). A separate `git worktree` (a different directory) already has its own independent `.devlog/` and is unaffected by this mechanism. The first time a branch is detected without its own file, only the unfinished tail of `devlog.md` (the Rounds after the last `DONE`, plus `handoff.md`) is cut into that branch's file; `main`'s own history, project summary, Checkpoints, and Kept/Lessons indexes stay in `devlog.md`. If the last Round is already `DONE`, or the branch checked out doesn't contain the current `main` tip (an older branch), nothing moves and the branch starts with an empty file. See [`docs/design/branch-scoped-devlog.md`](docs/design/branch-scoped-devlog.md).
237
+ Switching branches within the same working directory automatically splits the main file by the checked-out branch: `main`/`master` keeps using `.devlog/devlog.md`, while other branches each use `.devlog/devlog.<branch>.md` (slashes converted to `-`). A separate `git worktree` (a different directory) already has its own independent `.devlog/` and is unaffected by this mechanism. The first time a branch is detected without its own file, only the unfinished tail of `devlog.md` (the Rounds after the last `DONE`, plus `handoff.md`) is cut into that branch's file; `main`'s own history, project summary, Checkpoints, and Kept/Lessons indexes stay in `devlog.md`. If the last Round is already `DONE`, or the branch checked out doesn't contain the current `main` tip (an older branch), nothing moves and the branch starts with an empty file. A branch file's first line is an origin marker, `<!-- devlog-origin: branch=<raw branch name> -->` (invisible when rendered), recording the real branch name that the sanitized filename can't carry; `/devlog-tracker:keep-all` uses it to tell branch files from kept files and to report whether the branch is still active, merged or deleted. Files created before this marker existed are left as they are. See [`docs/design/branch-scoped-devlog.md`](docs/design/branch-scoped-devlog.md).
237
238
 
238
239
  #### Lessons Mode (off by default, not automatic)
239
240
 
package/README.zh-TW.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  *[English](README.md) | 繁體中文*
4
4
 
5
- **版本** 0.32.0
5
+ **版本** 0.33.0
6
6
 
7
7
  在專案中維護一份 `.devlog/devlog.md`,把每一輪對話的請求、決策與結果寫成永久紀錄。對話一 `/clear` 或換 session 就沒了;這份檔案取代那個缺口,讓工作可以中斷再接。沒下過 `/devlog-tracker:start` 時,裝著也不會動任何檔案。
8
8
 
@@ -90,7 +90,7 @@ bash "$DEVLOG_TRACKER_ROOT/core/scripts/segment-watch-set.sh" 600
90
90
  bash "$DEVLOG_TRACKER_ROOT/core/scripts/checkpoint-set.sh" 20
91
91
  bash "$DEVLOG_TRACKER_ROOT/core/scripts/report-devlog.sh" --json
92
92
  bash "$DEVLOG_TRACKER_ROOT/core/scripts/timeline-devlog.sh"
93
- # pause / span-open / span-close / compact / keep-move / clean / resume / pr / promote:見 commands/*.md
93
+ # pause / span-open / span-close / compact / keep-move / keep-all / clean / resume / pr / promote:見 commands/*.md
94
94
  ```
95
95
 
96
96
  ## 快速開始
@@ -115,7 +115,8 @@ $devlog-start # npx init --codex
115
115
  | `/devlog-tracker:continue` | 讀 `.devlog/devlog.md`,核對最後一輪 Handoff「工作區」後再依下一步接著做。`/clear` 之後要接續用這個。細節見 [`docs/design/continue.md`](docs/design/continue.md)。 |
116
116
  | `/devlog-tracker:pause` | 暫停強制記錄,歷史檔不動,之後可再 `start`。 |
117
117
  | `/devlog-tracker:compact` | 腳本把較舊的 `DONE` 輪次搬到 `devlog.archive.md`(Checkpoint 與未完成輪留在主檔)。 |
118
- | `/devlog-tracker:keep` | 掃全檔分主題,一次列出建議,確認後把各段各自搬走成 `devlog.<name>.md`(並在主檔留一個 `## Kept 索引` 指標行,含一句主題描述);也可抽出一段或合併成全部歷史一檔。不是 compact。細節見 [`docs/design/keep.md`](docs/design/keep.md)。 |
118
+ | `/devlog-tracker:keep` | 掃目前分支的主檔分主題(要一次整理所有 devlog 用 `keep-all`),一次列出建議,確認後把各段各自搬走成 `devlog.<name>.md`(並在主檔留一個 `## Kept 索引` 指標行,含一句主題描述);也可抽出一段或合併成全部歷史一檔。不是 compact。細節見 [`docs/design/keep.md`](docs/design/keep.md)。 |
119
+ | `/devlog-tracker:keep-all` | 整理**所有** devlog,不只目前分支:目前的主檔、其他分支的 `devlog.<branch>.md`、`devlog.archive.md`,以及先前 `keep`/`keep-all` 產生的所有具名檔。Claude 跨檔依主題重新分段(同一個主題散在 main、feature 分支與 archive 的片段會合回同一檔),一次列出所有建議檔案,確認後由一支腳本整批寫入,全有或全無。既有的具名檔會被拆開重組;各分支未完成的尾巴(最後一個 `DONE` 之後的 Round)與開著的那一輪不會被搬,分支檔也不會被刪。動手前會先把所有會動到的檔備份到 `.devlog/.keep-all-backup/<時間戳>/`。需要 Node ≥18。細節見 [`docs/design/keep-all.md`](docs/design/keep-all.md)。 |
119
120
  | `/devlog-tracker:overview` | 讀完所有已 keep 的 `devlog.<name>.md`,整合成跨主題總覽,並列出看起來該進 `CLAUDE.md` 的規範候選。純讀取,不核對工作區、不等確認、不寫檔。細節見 [`docs/design/keep.md`](docs/design/keep.md) Kept index。 |
120
121
  | `/devlog-tracker:promote` | 從已 keep 的檔、lessons 檔與 Checkpoint 的 `### 決策` 挑出規範候選並編號列出;只有你選定的才追加到 `CLAUDE.md`(`CLAUDE.md` 只有 `@AGENTS.md` 或只裝 Codex 時改寫 `AGENTS.md`)的 `<!-- devlog-tracker:rules:begin/end -->` 受管區塊。只追加、一字不差的重複會跳過;`init` 不會覆寫這個區塊。 |
121
122
  | `/devlog-tracker:search <關鍵字>` | 在 `devlog.md`/`devlog.archive.md`/已 keep 的 `devlog.<name>.md`/`devlog.lessons.<topic>.md` 裡做不分大小寫的字串搜尋;Claude 讀完命中後用自己的話回答(必要時附檔名/標題/行號)。純讀取,不核對工作區、不等確認、不寫檔。 |
@@ -233,7 +234,7 @@ Claude 用純文字結尾提出問題、下一則訊息才拿到答案時,不
233
234
 
234
235
  #### 分支各自的 devlog 檔
235
236
 
236
- 同一個工作目錄裡切換分支時,主檔會依目前 checkout 的分支自動分開:`main`/`master` 繼續用 `.devlog/devlog.md`,其他分支各自用 `.devlog/devlog.<branch>.md`(斜線轉成 `-`)。另開一個 `git worktree`(不同目錄)本來就有自己獨立的 `.devlog/`,不受這個機制影響。第一次在某分支偵測到還沒有專屬檔案時,只會把 `devlog.md` 裡還沒完成的尾巴(最後一個 `DONE` 之後的 Round,連同 `handoff.md`)剪到該分支的檔案;`main` 自己的歷史、專案摘要、Checkpoint、Kept/Lessons 索引都留在 `devlog.md`。最後一輪已經是 `DONE`,或切到的是不含目前 `main` 最新 commit 的舊分支時,什麼都不搬,新分支從空檔開始。細節見 [`docs/design/branch-scoped-devlog.md`](docs/design/branch-scoped-devlog.md)。
237
+ 同一個工作目錄裡切換分支時,主檔會依目前 checkout 的分支自動分開:`main`/`master` 繼續用 `.devlog/devlog.md`,其他分支各自用 `.devlog/devlog.<branch>.md`(斜線轉成 `-`)。另開一個 `git worktree`(不同目錄)本來就有自己獨立的 `.devlog/`,不受這個機制影響。第一次在某分支偵測到還沒有專屬檔案時,只會把 `devlog.md` 裡還沒完成的尾巴(最後一個 `DONE` 之後的 Round,連同 `handoff.md`)剪到該分支的檔案;`main` 自己的歷史、專案摘要、Checkpoint、Kept/Lessons 索引都留在 `devlog.md`。最後一輪已經是 `DONE`,或切到的是不含目前 `main` 最新 commit 的舊分支時,什麼都不搬,新分支從空檔開始。分支檔的第一行是 origin 標記 `<!-- devlog-origin: branch=<原始分支名> -->`(渲染時看不到),記下檔名轉換後已經還原不回來的真實分支名;`/devlog-tracker:keep-all` 靠它分辨分支檔與具名檔,並回報該分支還在開發、已合併或已刪除。這個標記出現前就存在的舊檔維持原樣。細節見 [`docs/design/branch-scoped-devlog.md`](docs/design/branch-scoped-devlog.md)。
237
238
 
238
239
  #### Lessons Mode(預設關閉,不自動)
239
240
 
package/cli/agents-md.js CHANGED
@@ -23,6 +23,7 @@ function codexBlock() {
23
23
  | 歸檔 / compact | \`commands/compact.md\` |
24
24
  | 清空重編 / clean(不可復原,先問使用者確認) | \`commands/clean.md\` |
25
25
  | 保存主題 / keep、接續具名檔 / resume、跨主題總覽 / overview | \`commands/keep.md\`、\`commands/resume.md\`、\`commands/overview.md\` |
26
+ | 整理所有 devlog(跨分支檔、archive、既有 keep 檔重新分主題) / keep-all | \`commands/keep-all.md\` |
26
27
  | 沉澱規範寫進 CLAUDE.md/AGENTS.md / promote | \`commands/promote.md\` |
27
28
  | 搜尋 / search | \`commands/search.md\` |
28
29
  | 統計 / report、HTML 時間軸 / timeline | \`commands/report.md\`、\`commands/timeline.md\` |
@@ -22,6 +22,7 @@ function claudeBlock() {
22
22
  | 歸檔 / compact | \`commands/compact.md\` |
23
23
  | 清空重編 / clean(不可復原,先問使用者確認) | \`commands/clean.md\` |
24
24
  | 保存主題 / keep、接續具名檔 / resume、跨主題總覽 / overview | \`commands/keep.md\`、\`commands/resume.md\`、\`commands/overview.md\` |
25
+ | 整理所有 devlog(跨分支檔、archive、既有 keep 檔重新分主題) / keep-all | \`commands/keep-all.md\` |
25
26
  | 沉澱規範寫進 CLAUDE.md/AGENTS.md / promote | \`commands/promote.md\` |
26
27
  | 統計 / report、HTML 時間軸 / timeline | \`commands/report.md\`、\`commands/timeline.md\` |
27
28
  | 產生 PR 描述 / pr | \`commands/pr.md\` |
@@ -0,0 +1,93 @@
1
+ ---
2
+ description: 整理所有 devlog:跨主檔、所有分支檔、archive 與既有 keep 檔,依主題重新分成 devlog.<name>.md
3
+ ---
4
+
5
+ 請執行 devlog keep-all(整理全部 devlog)。這是使用者主動執行 `/devlog-tracker:keep-all` 時才做的事,不要自動觸發。
6
+
7
+ 跟 `/devlog-tracker:keep` 的差別:keep 只整理目前分支的主檔;keep-all 一次看 `.devlog/` 裡**所有** devlog 檔——目前分支的主檔、其他分支的 `devlog.<branch>.md`、`devlog.archive.md`,以及先前 keep/keep-all 產生的具名檔——跨檔依主題重新分段。既有的具名檔會被拆開重組,所以一個主題散在 main、feature 分支與 archive 的片段可以合回同一個檔。設計見 `docs/design/keep-all.md`。
8
+
9
+ 確認之前不要寫任何檔。
10
+
11
+ 先決定 plugin 根目錄(有 `DEVLOG_TRACKER_ROOT` 用它;否則用 `CLAUDE_PLUGIN_ROOT`;兩者都空就用含 `.claude-plugin/plugin.json` 的本 plugin 根目錄),並記下目前已確認的專案根目錄絕對路徑(後面兩次呼叫都用同一個值,不要用 `$(pwd)` 重新推)。
12
+
13
+ ## 1. 掃描
14
+
15
+ ```bash
16
+ PLUGIN_ROOT="${DEVLOG_TRACKER_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}"
17
+ DEVLOG_PROJECT_DIR="<專案根目錄絕對路徑>" bash "${PLUGIN_ROOT}/core/scripts/keep-all.sh" --scan
18
+ ```
19
+
20
+ - `NO_NODE`:告知 keep-all 需要 Node.js(可改用 `/devlog-tracker:keep` 整理目前分支),結束。
21
+ - `NOTHING`:告知目前沒有可整理的 Round,不要建立任何檔,結束。
22
+ - 否則輸出是:
23
+ - `FINGERPRINT=<fp> COUNT=<n>`:記下這兩個值,步驟 4 原樣帶回。
24
+ - `SOURCE file=… kind=…`:每個來源檔。`kind` 由腳本判定,不要自己依檔名猜:
25
+ - `current`:目前分支的主檔
26
+ - `branch`:其他分支的主檔;`origin` 是分支名,`state` 是 `active`(還在開發)/`merged`(已合併進 main)/`gone`(本地分支已刪)/`unknown`/`n/a`
27
+ - `archive`:`devlog.archive.md`
28
+ - `kept`:既有的具名檔(第一行是 `# Kept log`)
29
+ - `ROUND id=… file=… round=… line=… ts=… status=… movable=0|1`:每個 Round 一行,`id` 是依時間排序的全域編號(不同檔的 Round 編號會重複,一律用 `id` 指稱)。`movable=0` 的是目前開著的 Round,或其他分支最後一個 `DONE` 之後還沒完成的尾巴——這些不能搬,也不要列進任何段落。
30
+
31
+ ## 2. 讀檔、分主題
32
+
33
+ 讀完每個 `SOURCE` 檔(至少讀 `movable=1` 的 Round 所在範圍)。依 `### User Input` 與 `### Summary` 的主題,把 `movable=1` 的 Round 分成主題段落:
34
+
35
+ - 一段是一組 `id`,**可以跨檔、可以不連續**——同一主題在 main、分支與 archive 的片段應該放進同一段。
36
+ - `kind=kept` 來源的 Round **全部都必須**分進某一段(它們先前已被判定值得留),可以跟其他主題合併或拆開重組。
37
+ - 其他來源的瑣碎 Round(只有確認、閒聊、重複)可以不收,留在原檔。判斷標準同 `/devlog-tracker:keep` 步驟 2。
38
+ - 每段產生建議 `<name>`(小寫 ASCII kebab-case,2–4 段,只反映主題,不加日期),以及一句這段在做什麼的描述(純文字、不換行、不含 tab 與反引號;步驟 4 原樣寫進索引)。同一批兩段撞名時,較晚的加 `-2`/`-3`。既有 kept 檔的名稱若仍貼切可以沿用——它會被這次整理取代。
39
+
40
+ ## 3. 一次列出,然後停下來等
41
+
42
+ ```
43
+ 掃到 N 段主題:
44
+ 1. <一句描述> → devlog.<name>.md
45
+ <來源檔> Round <編號…>(#<id…>)、<來源檔> Round <編號…>(#<id…>,分支 <origin> <state>)
46
+ 2. ...
47
+ 會被重整並刪除的既有 keep 檔:<檔名,逗號分隔,或「無」>
48
+ 可搬但偏瑣碎、留在原檔:<來源檔與 Round,或「無」>
49
+ 不能搬、保持原樣:<各分支未完成尾巴與開著的 Round,或「無」>
50
+
51
+ 回覆:
52
+ 採用 → 全部照上面寫入
53
+ 改第 N 段檔名 <name> / 移除第 N 段 / 第 N 段併入第 M 段
54
+ 摘要第 N 段 → 搬完後把該段敘事改寫得更精簡
55
+ 取消
56
+ ```
57
+
58
+ 同一則回覆可以合併多條修改;`N` 一律對應原始編號。使用者用文字要求把某些 Round 換段,照做後重新列一次再等。然後停止,使用者回覆前不要寫任何檔。
59
+
60
+ - 取消 → 不改檔,結束。
61
+ - `移除第 N 段`:該段若含 `kind=kept` 的 Round,拒絕並說明只能 `併入`;其餘 Round 留在原檔。
62
+ - `第 N 段併入第 M 段`:合併 id,沿用第 M 段的檔名與描述。
63
+ - 檔名規則同 `/devlog-tracker:keep` 步驟 4(`devlog.` 前綴與 `.md` 後綴會剝掉;不可為空、`archive`、`lessons.*`,不可含 `/`、`\`、`..`,最多 64 字元);目標檔已存在(且不是這次會被整理掉的 kept 檔)時改建議 `-2`。
64
+
65
+ ## 4. 寫計畫並執行(整批一次,全有或全無)
66
+
67
+ 把確認後的段落寫到 `.devlog/.keep-all-plan.tsv`,一段一行,三欄以 tab 分隔:
68
+
69
+ ```
70
+ <name> <描述> <id 清單,例如 3-8,20,24-25>
71
+ ```
72
+
73
+ 然後:
74
+
75
+ ```bash
76
+ DEVLOG_PROJECT_DIR="<專案根目錄絕對路徑>" bash "${PLUGIN_ROOT}/core/scripts/keep-all.sh" \
77
+ --apply "<專案根目錄絕對路徑>/.devlog/.keep-all-plan.tsv" --fingerprint <fp> --count <n>
78
+ ```
79
+
80
+ `<fp>`/`<n>` 用步驟 1 的值。不要自己搬檔、刪檔或改索引——腳本是唯一的實作:驗證計畫、把所有會動到的檔備份到 `.devlog/.keep-all-backup/<時間戳>/`、組好新檔、改寫來源、刪除被整理掉的 kept 檔與清空的 archive、重建 `## Kept 索引`(新索引行寫在目前分支的主檔)。分支檔與目前主檔即使搬空也不會被刪。
81
+
82
+ - exit 1:原樣顯示 stderr,不要自行重試或手動補做。驗證錯誤時不會寫任何檔;fingerprint 不符代表掃描後檔案被改過,回到步驟 1 重新掃描。
83
+ - 成功後刪除 `.devlog/.keep-all-plan.tsv`。
84
+
85
+ ## 5. 選配:摘要
86
+
87
+ 只對使用者標記「摘要第 N 段」的段落做,規則同 `/devlog-tracker:keep` 步驟 5.5(只改寫 `### Summary`、`### Reply`、`#### 決策`、`#### 現況` 的敘事;標題、`### User Input`、`#### 工作區`、`#### 檔案`、`#### 完成條件`、`#### 下一步`、`### Status` 一律不動;用 Edit 不用 Write)。
88
+
89
+ ## 6. 回報
90
+
91
+ 依 stdout 回報:每個 `KEPT=<路徑> ROUNDS=<n>`、每個 `DELETED=<路徑>`、`BACKUP=<目錄>`(出錯時可從這裡還原),以及哪幾段套用了摘要。
92
+
93
+ 若 `.devlog/.round-open` 存在:在開著的那一輪補上 `### Summary` / `### Handoff` / `### Status` 再結束(Stop hook 仍會檢查)。
package/commands/keep.md CHANGED
@@ -4,6 +4,8 @@ description: 掃描整份 devlog.md,把值得留名的主題段落一次分別
4
4
 
5
5
  請執行 devlog keep(具名搬走)。這是使用者主動執行 `/devlog-tracker:keep` 時才做的事,不要自動觸發。
6
6
 
7
+ 這個指令只整理**目前分支的主檔**(`devlog.md` 或 `devlog.<branch>.md`)。要一次整理所有 devlog——其他分支的檔、`devlog.archive.md`、既有的 keep 檔——用 `/devlog-tracker:keep-all`(`commands/keep-all.md`)。
8
+
7
9
  確認之前不要寫任何檔。若這一輪本身是開著的 Round,永遠不要把它搬走,這一輪結束前仍要補 `### Summary` / `### Handoff` / `### Status`(若沒有開著的 Round,見步驟 1 第 2 點)。
8
10
 
9
11
  ## 1. 讀檔、找出開著的 Round
@@ -169,13 +169,30 @@ devlog_round_segments_body() {
169
169
  ' "$1"
170
170
  }
171
171
 
172
+ # The first line of a branch-scoped devlog file: records which branch (or
173
+ # detached worktree dir) it belongs to, since the sanitized filename can't
174
+ # be mapped back. $1 is DEVLOG_ORIGIN ("branch=feat/x"). An HTML comment,
175
+ # so it neither renders nor parses as a `## ` block.
176
+ devlog_origin_line() {
177
+ printf '<!-- devlog-origin: %s -->\n' "$1"
178
+ }
179
+
180
+ # Prints the origin ("branch=feat/x") if line 1 of $1 is an origin marker.
181
+ devlog_origin_of() {
182
+ [ -f "$1" ] || return 0
183
+ sed -n '1s/^<!-- devlog-origin: \(.*\) -->$/\1/p' "$1"
184
+ }
185
+
172
186
  devlog_merge_round_current() {
173
187
  # Appends $2's content onto $1 (one blank line separator, matching the
174
188
  # existing "\n## Round N" append convention) and removes $2. No-op if $2
175
- # is absent or empty — nothing to merge.
189
+ # is absent or empty — nothing to merge. When $1 doesn't exist yet and
190
+ # DEVLOG_ORIGIN is set (a branch file being created), its origin marker
191
+ # goes first.
176
192
  local devlog="$1" current="$2"
177
193
  [ -s "$current" ] || return 0
178
194
  {
195
+ [ -s "$devlog" ] || [ -z "${DEVLOG_ORIGIN:-}" ] || devlog_origin_line "$DEVLOG_ORIGIN"
179
196
  printf '\n'
180
197
  cat "$current"
181
198
  } >> "$devlog" 2>/dev/null || return 1
@@ -21,12 +21,14 @@ _devlog_sanitize_name() {
21
21
  # whose Status is DONE. Nothing moves when the last round is DONE, when
22
22
  # there are no rounds, or when HEAD doesn't contain the local main/master
23
23
  # tip (an older branch being checked out, not one just forked from the
24
- # work in progress). The project header, Checkpoints, and Kept/Lessons
24
+ # work in progress). When $6 (an origin like "branch=feat/x") is given,
25
+ # the branch file starts with its origin marker. The project header,
26
+ # Checkpoints, and Kept/Lessons
25
27
  # indexes always stay in $2. When rounds move, $4 (handoff.md) moves to
26
28
  # $5 too, since it snapshots that same unfinished work. Caller holds the
27
29
  # devlog lock.
28
30
  _devlog_migrate_unfinished_tail() {
29
- local dir="$1" src="$2" dst="$3" src_handoff="$4" dst_handoff="$5"
31
+ local dir="$1" src="$2" dst="$3" src_handoff="$4" dst_handoff="$5" origin="${6:-}"
30
32
  [ -s "$src" ] || return 0
31
33
 
32
34
  # Cheap early exit first: until the branch file exists, every hook
@@ -71,7 +73,10 @@ _devlog_migrate_unfinished_tail() {
71
73
  print moved ? "M" $0 : "K" $0
72
74
  }
73
75
  ' "$src" > "$src.split" 2>/dev/null || { rm -f "$src.split"; return 0; }
74
- sed -n 's/^M//p' "$src.split" | awk "$drop_trailing_blanks" > "$dst.tmp" 2>/dev/null
76
+ {
77
+ [ -z "$origin" ] || { devlog_origin_line "$origin"; printf '\n'; }
78
+ sed -n 's/^M//p' "$src.split" | awk "$drop_trailing_blanks"
79
+ } > "$dst.tmp" 2>/dev/null
75
80
  sed -n 's/^K//p' "$src.split" | awk "$drop_trailing_blanks" > "$src.tmp" 2>/dev/null
76
81
  rm -f "$src.split"
77
82
 
@@ -94,14 +99,18 @@ _devlog_migrate_unfinished_tail() {
94
99
  # time a branch resolves to a file that doesn't exist yet, only
95
100
  # devlog.md's unfinished tail is cut into it (see
96
101
  # _devlog_migrate_unfinished_tail); main's own history stays in devlog.md.
102
+ # Also sets DEVLOG_ORIGIN ("branch=<raw name>" / "detached=<dir>", empty
103
+ # for devlog.md): the first line written into a new branch file records it
104
+ # as an origin marker (docs/design/keep-all.md "Origin marker").
97
105
  devlog_resolve_paths() {
98
106
  local dir="${1:-.}"
99
107
  DEVLOG_DIR="$dir/.devlog"
100
108
  DEVLOG_FILE="$DEVLOG_DIR/devlog.md"
101
109
  # shellcheck disable=SC2034 # consumed by callers (e.g. enforce-devlog.sh), not used in this file
102
110
  HANDOFF_FILE="$DEVLOG_DIR/handoff.md"
111
+ DEVLOG_ORIGIN=""
103
112
 
104
- local branch raw name
113
+ local branch raw name origin
105
114
  branch="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
106
115
 
107
116
  case "$branch" in
@@ -110,9 +119,11 @@ devlog_resolve_paths() {
110
119
  ;;
111
120
  HEAD)
112
121
  raw="$(basename "$(cd "$dir" 2>/dev/null && pwd)" 2>/dev/null || echo '')"
122
+ origin="detached=$raw"
113
123
  ;;
114
124
  *)
115
125
  raw="$branch"
126
+ origin="branch=$raw"
116
127
  ;;
117
128
  esac
118
129
  [ -n "$raw" ] || return 0
@@ -139,10 +150,11 @@ devlog_resolve_paths() {
139
150
  if [ -f "$DEVLOG_DIR/.enabled" ] && [ ! -f "$resolved" ] && [ -s "$DEVLOG_FILE" ]; then
140
151
  devlog_lock_acquire
141
152
  [ -f "$resolved" ] || _devlog_migrate_unfinished_tail \
142
- "$dir" "$DEVLOG_FILE" "$resolved" "$HANDOFF_FILE" "$resolved_handoff"
153
+ "$dir" "$DEVLOG_FILE" "$resolved" "$HANDOFF_FILE" "$resolved_handoff" "$origin"
143
154
  devlog_lock_release
144
155
  fi
145
156
  DEVLOG_FILE="$resolved"
146
157
  # shellcheck disable=SC2034 # consumed by callers (e.g. enforce-devlog.sh), not used in this file
147
158
  HANDOFF_FILE="$resolved_handoff"
159
+ DEVLOG_ORIGIN="$origin"
148
160
  }
@@ -0,0 +1,454 @@
1
+ 'use strict';
2
+ // /devlog-tracker:keep-all (docs/design/keep-all.md): scan every devlog file
3
+ // in .devlog/ and, after the user confirms a plan, move Rounds from any of
4
+ // them into topic files in one all-or-nothing run. keep-all.sh resolves
5
+ // paths, holds the devlog lock and calls this; the functions are exported
6
+ // for keep-all.test.js.
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const crypto = require('crypto');
10
+ const { spawnSync } = require('child_process');
11
+
12
+ const FENCE = /^[ \t]*```/;
13
+ const ORIGIN = /^<!-- devlog-origin: (branch|detached)=(.*) -->$/;
14
+ const KEPT_HEAD = '# Kept log';
15
+
16
+ // Splits a devlog file into `## ` blocks, ignoring headings inside ```
17
+ // fences (same rule as devlog-md.sh). Lines before the first block are the
18
+ // prefix (project summary, origin marker, kept provenance header).
19
+ function parseBlocks(text) {
20
+ const lines = text.replace(/\n$/, '').split('\n');
21
+ if (text === '') lines.length = 0;
22
+ const starts = [];
23
+ let fence = false;
24
+ lines.forEach((line, i) => {
25
+ if (FENCE.test(line)) { fence = !fence; return; }
26
+ if (!fence && line.startsWith('## ')) starts.push(i);
27
+ });
28
+ const blocks = starts.map((start, k) => {
29
+ const end = k + 1 < starts.length ? starts[k + 1] - 1 : lines.length - 1;
30
+ const heading = lines[start];
31
+ const m = heading.match(/^## Round (\d+)/);
32
+ const block = { start, end, heading, round: m ? Number(m[1]) : null, ts: '', status: '' };
33
+ if (m) {
34
+ const t = heading.match(/ — (.*)$/);
35
+ block.ts = t ? t[1].trim() : '';
36
+ block.status = roundStatus(lines.slice(start, end + 1));
37
+ }
38
+ return block;
39
+ });
40
+ return { lines, blocks, firstBlock: starts.length ? starts[0] : lines.length };
41
+ }
42
+
43
+ function roundStatus(lines) {
44
+ let fence = false, inStatus = false, value = '';
45
+ for (const line of lines) {
46
+ if (FENCE.test(line)) { fence = !fence; continue; }
47
+ if (fence) continue;
48
+ if (/^### Status\s*$/.test(line)) { inStatus = true; value = ''; continue; }
49
+ if (/^### /.test(line)) { inStatus = false; continue; }
50
+ if (inStatus && line.trim() !== '') value = line.trim();
51
+ }
52
+ return value;
53
+ }
54
+
55
+ function tsKey(ts) {
56
+ const iso = ts.replace(/([+-]\d\d)(\d\d)$/, '$1:$2');
57
+ const t = Date.parse(iso);
58
+ return Number.isNaN(t) ? Infinity : t;
59
+ }
60
+
61
+ function git(projectDir, args) {
62
+ const r = spawnSync('git', ['-C', projectDir, ...args], { encoding: 'utf8' });
63
+ return r.status === 0 ? r.stdout.trim() : null;
64
+ }
65
+
66
+ function branchState(projectDir, name) {
67
+ if (git(projectDir, ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]) === null) {
68
+ return git(projectDir, ['rev-parse', '--git-dir']) === null ? 'unknown' : 'gone';
69
+ }
70
+ const base = (git(projectDir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/']) || '')
71
+ .split('\n').find(b => /^(main|master)$/i.test(b));
72
+ if (!base || base === name) return 'active';
73
+ return git(projectDir, ['merge-base', '--is-ancestor', name, base]) === null ? 'active' : 'merged';
74
+ }
75
+
76
+ function classify(file, text, c) {
77
+ if (/^devlog\.lessons\..*\.md$/.test(file)) return null;
78
+ if (file === c.current) return 'current';
79
+ if (file === 'devlog.archive.md') return 'archive';
80
+ if (text.split('\n', 1)[0] === KEPT_HEAD) return 'kept';
81
+ return 'branch';
82
+ }
83
+
84
+ function describeOrigin(src, text, c) {
85
+ if (src.kind !== 'current' && src.kind !== 'branch') return;
86
+ const m = text.split('\n', 1)[0].match(ORIGIN);
87
+ if (m) {
88
+ src.origin = m[2]; src.originFrom = 'marker';
89
+ src.state = m[1] === 'branch' ? branchState(c.projectDir, m[2]) : 'n/a';
90
+ } else if (src.file === 'devlog.md') {
91
+ src.origin = 'main'; src.originFrom = 'default'; src.state = 'n/a';
92
+ } else if (src.kind === 'current' && c.origin) {
93
+ const [kind, name] = [c.origin.slice(0, c.origin.indexOf('=')), c.origin.slice(c.origin.indexOf('=') + 1)];
94
+ src.origin = name; src.originFrom = 'env';
95
+ src.state = kind === 'branch' ? branchState(c.projectDir, name) : 'n/a';
96
+ } else {
97
+ src.origin = src.file.replace(/^devlog\./, '').replace(/\.md$/, '');
98
+ src.originFrom = 'filename';
99
+ const st = branchState(c.projectDir, src.origin);
100
+ src.state = st === 'gone' ? 'unknown' : st;
101
+ }
102
+ }
103
+
104
+ function sha(s) {
105
+ return crypto.createHash('sha1').update(s).digest('hex');
106
+ }
107
+
108
+ function scan(c) {
109
+ const files = fs.existsSync(c.devlogDir)
110
+ ? fs.readdirSync(c.devlogDir).filter(f => /^devlog.*\.md$/.test(f)).sort()
111
+ : [];
112
+ const sources = [];
113
+ const rounds = [];
114
+ files.forEach((file, order) => {
115
+ const text = fs.readFileSync(path.join(c.devlogDir, file), 'utf8');
116
+ const kind = classify(file, text, c);
117
+ if (!kind) return;
118
+ const parsed = parseBlocks(text);
119
+ const src = { file, kind, text, parsed };
120
+ describeOrigin(src, text, c);
121
+ sources.push(src);
122
+ const rb = parsed.blocks.filter(b => b.round !== null);
123
+ let lastDone = -1;
124
+ rb.forEach((b, i) => { if (b.status === 'DONE') lastDone = i; });
125
+ rb.forEach((b, i) => {
126
+ let movable = true;
127
+ if (kind === 'current' && String(b.round) === String(c.open)) movable = false;
128
+ if (kind === 'branch' && i > lastDone) movable = false;
129
+ rounds.push({
130
+ file, kind, order, block: b, round: b.round, ts: b.ts, status: b.status, movable,
131
+ line: b.start + 1, text: parsed.lines.slice(b.start, b.end + 1).join('\n'),
132
+ });
133
+ });
134
+ });
135
+ rounds.sort((a, b) => tsKey(a.ts) - tsKey(b.ts) || a.order - b.order || a.line - b.line);
136
+ rounds.forEach((r, i) => { r.id = i + 1; });
137
+ return { sources, rounds, fingerprint: fingerprint(sources, rounds, rounds.length) };
138
+ }
139
+
140
+ // Covers the scanned Rounds and every non-current source, not whole files:
141
+ // between scan and apply, Stop merges the proposal turn's Round into the
142
+ // current file (and may create it), which must not invalidate the plan.
143
+ function fingerprint(sources, rounds, count) {
144
+ const parts = sources.filter(s => s.kind !== 'current').map(s => `S ${s.file} ${s.kind}`);
145
+ for (const r of rounds.slice(0, count)) parts.push(`R ${r.file} ${r.block.heading} ${r.movable} ${sha(r.text)}`);
146
+ return sha(parts.join('\n')).slice(0, 16);
147
+ }
148
+
149
+ function formatScan(s) {
150
+ if (!s.rounds.some(r => r.movable)) return 'NOTHING\n';
151
+ const out = [`FINGERPRINT=${s.fingerprint} COUNT=${s.rounds.length}`];
152
+ for (const src of s.sources) {
153
+ let line = `SOURCE file=${src.file} kind=${src.kind}`;
154
+ if (src.origin !== undefined) line += ` origin=${src.origin} origin_from=${src.originFrom} state=${src.state}`;
155
+ out.push(line);
156
+ }
157
+ for (const r of s.rounds) {
158
+ out.push(`ROUND id=${r.id} file=${r.file} round=${r.round} line=${r.line} ts=${r.ts || '-'} status=${r.status || '-'} movable=${r.movable ? 1 : 0}`);
159
+ }
160
+ return out.join('\n') + '\n';
161
+ }
162
+
163
+ function normalizeName(raw) {
164
+ if (raw === 'devlog.md') return null;
165
+ let n = raw.trim();
166
+ if (n.startsWith('devlog.')) n = n.slice(7);
167
+ if (n.endsWith('.md')) n = n.slice(0, -3);
168
+ n = n.replace(/\s/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
169
+ if (!n || n === 'archive' || /^lessons\./.test(n) || /[/\\]|\.\./.test(n) || n.length > 64) return null;
170
+ return n;
171
+ }
172
+
173
+ function parsePlan(planText) {
174
+ const segs = [];
175
+ for (const line of planText.split('\n')) {
176
+ if (line.trim() === '') continue;
177
+ const [name = '', desc = '', ids = ''] = line.split('\t');
178
+ const list = [];
179
+ for (const part of ids.split(',').map(p => p.trim()).filter(Boolean)) {
180
+ const m = part.match(/^(\d+)(?:-(\d+))?$/);
181
+ if (!m) throw new Error(`id 清單格式錯誤:${part}`);
182
+ const a = Number(m[1]), b = m[2] ? Number(m[2]) : a;
183
+ if (a > b) throw new Error(`id 範圍起點大於終點:${part}`);
184
+ for (let i = a; i <= b; i++) list.push(i);
185
+ }
186
+ segs.push({ rawName: name.trim(), desc: desc.replace(/[\r\n]+/g, ' ').trim(), ids: list });
187
+ }
188
+ if (!segs.length) throw new Error('計畫是空的');
189
+ return segs;
190
+ }
191
+
192
+ function trimBlank(lines) {
193
+ let a = 0, b = lines.length;
194
+ while (a < b && lines[a].trim() === '') a++;
195
+ while (b > a && lines[b - 1].trim() === '') b--;
196
+ return lines.slice(a, b);
197
+ }
198
+
199
+ function joinPieces(pieces) {
200
+ const kept = pieces.map(trimBlank).filter(p => p.length);
201
+ return kept.map(p => p.join('\n')).join('\n\n') + (kept.length ? '\n' : '');
202
+ }
203
+
204
+ function ranges(nums) {
205
+ const s = [...nums].sort((a, b) => a - b);
206
+ const out = [];
207
+ for (let i = 0; i < s.length; i++) {
208
+ let j = i;
209
+ while (j + 1 < s.length && s[j + 1] === s[j] + 1) j++;
210
+ out.push(i === j ? `${s[i]}` : `${s[i]}-${s[j]}`);
211
+ i = j;
212
+ }
213
+ return out.join(', ');
214
+ }
215
+
216
+ function nowIso() {
217
+ const d = new Date();
218
+ const pad = n => String(Math.abs(n)).padStart(2, '0');
219
+ const off = -d.getTimezoneOffset();
220
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:` +
221
+ `${pad(d.getMinutes())}:${pad(d.getSeconds())}${off >= 0 ? '+' : '-'}${pad(Math.trunc(off / 60))}:${pad(off % 60)}`;
222
+ }
223
+
224
+ // Kept files begin with "# Kept log", then blank / "- key: value" lines.
225
+ function keptHeaderEnd(lines, firstBlock) {
226
+ let i = 1;
227
+ while (i < firstBlock && (lines[i].trim() === '' || lines[i].startsWith('- '))) i++;
228
+ return i;
229
+ }
230
+
231
+ function apply(c, planText, expected, count) {
232
+ const s = scan(c);
233
+ if (s.rounds.length < count || fingerprint(s.sources, s.rounds, count) !== expected) {
234
+ throw new Error('fingerprint 不符:scan 之後 devlog 檔已被改動,請重新執行 keep-all');
235
+ }
236
+ const segs = parsePlan(planText);
237
+ const byId = new Map(s.rounds.slice(0, count).map(r => [r.id, r]));
238
+ const owner = new Map();
239
+ const errors = [];
240
+ const consumed = new Set(s.sources.filter(x => x.kind === 'kept' && x.parsed.blocks.some(b => b.round !== null)).map(x => x.file));
241
+
242
+ segs.forEach((seg, i) => {
243
+ seg.name = normalizeName(seg.rawName);
244
+ if (!seg.name) { errors.push(`第 ${i + 1} 段檔名無效:${seg.rawName}`); return; }
245
+ seg.file = `devlog.${seg.name}.md`;
246
+ if (segs.findIndex(o => o.file === seg.file) !== i) errors.push(`第 ${i + 1} 段檔名與其他段重複:${seg.file}`);
247
+ if (fs.existsSync(path.join(c.devlogDir, seg.file)) && !consumed.has(seg.file)) {
248
+ errors.push(`第 ${i + 1} 段目標檔已存在:${seg.file}`);
249
+ }
250
+ if (!seg.ids.length) errors.push(`第 ${i + 1} 段沒有任何 Round`);
251
+ for (const id of seg.ids) {
252
+ const r = byId.get(id);
253
+ if (!r) errors.push(`#${id} 不存在`);
254
+ else if (!r.movable) errors.push(`#${id}(${r.file} Round ${r.round})不可搬`);
255
+ else if (owner.has(id)) errors.push(`#${id} 重複出現在兩段`);
256
+ else owner.set(id, seg);
257
+ }
258
+ });
259
+ for (const r of byId.values()) {
260
+ if (r.kind === 'kept' && !owner.has(r.id)) errors.push(`#${r.id}(${r.file} Round ${r.round})是既有 kept 檔的 Round,必須分進某一段`);
261
+ }
262
+ if (errors.length) throw new Error(errors.join('\n'));
263
+
264
+ // Assign every block of every source: moved Rounds to their segment,
265
+ // Checkpoints (any block, for kept files) along with the Round before them.
266
+ const pieces = new Map(segs.map(seg => [seg, []])); // seg -> [{id, sub, lines}]
267
+ const prefixes = new Map(segs.map(seg => [seg, []]));
268
+ const rewritten = new Map(); // file -> new text | null (delete)
269
+ const movedFromCurrent = new Set();
270
+
271
+ for (const src of s.sources) {
272
+ const { lines, blocks, firstBlock } = src.parsed;
273
+ const roundOf = new Map(s.rounds.filter(r => r.file === src.file).map(r => [r.block, r]));
274
+ const stay = [];
275
+ let prev = null; // last Round block's {seg, id}
276
+ let pendingKept = []; // kept-file blocks before the first Round
277
+ let touched = false;
278
+ blocks.forEach((b, k) => {
279
+ const blines = lines.slice(b.start, b.end + 1);
280
+ const r = roundOf.get(b);
281
+ if (r) {
282
+ const seg = owner.get(r.id);
283
+ prev = seg ? { seg, id: r.id, sub: 0 } : null;
284
+ if (seg) {
285
+ touched = true;
286
+ pieces.get(seg).push({ id: r.id, sub: 0, lines: blines });
287
+ if (src.kind === 'current') movedFromCurrent.add(r.round);
288
+ for (const p of pendingKept) pieces.get(seg).push({ id: r.id, sub: -1 + p.k / 1e6, lines: p.lines });
289
+ pendingKept = [];
290
+ } else stay.push(blines);
291
+ return;
292
+ }
293
+ const follows = src.kind === 'kept' || b.heading.startsWith('## Checkpoint');
294
+ if (follows && prev) {
295
+ prev.sub += 1;
296
+ pieces.get(prev.seg).push({ id: prev.id, sub: prev.sub, lines: blines });
297
+ touched = true;
298
+ } else if (src.kind === 'kept' && !prev) {
299
+ pendingKept.push({ k, lines: blines });
300
+ } else {
301
+ stay.push(blines);
302
+ }
303
+ });
304
+
305
+ if (src.kind === 'kept' && consumed.has(src.file)) {
306
+ const first = s.rounds.find(r => r.file === src.file);
307
+ const hEnd = keptHeaderEnd(lines, firstBlock);
308
+ prefixes.get(owner.get(first.id)).push({ id: first.id, lines: lines.slice(hEnd, firstBlock) });
309
+ rewritten.set(src.file, null);
310
+ continue;
311
+ }
312
+ if (!touched) continue;
313
+ const text = joinPieces([lines.slice(0, firstBlock), ...stay]);
314
+ const left = stay.length && parseBlocks(text).blocks.some(b => b.round !== null);
315
+ rewritten.set(src.file, src.kind === 'archive' && !left && trimBlank(lines.slice(0, firstBlock)).length === 0 ? null : text);
316
+ }
317
+
318
+ // Kept index: drop lines pointing at deleted kept files everywhere, add
319
+ // the new files to the current file's index.
320
+ const deletedKept = [...consumed].filter(f => !segs.some(seg => seg.file === f));
321
+ const reused = [...consumed].filter(f => segs.some(seg => seg.file === f));
322
+ const staleIndex = new Set([...deletedKept, ...reused]);
323
+ const keptAt = nowIso();
324
+ const newIndex = segs.map(seg => {
325
+ const n = pieces.get(seg).filter(p => p.sub === 0).length;
326
+ return `- \`${seg.file}\`:keep-all,${n} 輪,kept_at ${keptAt}${seg.desc ? `,${seg.desc}` : ''}`;
327
+ });
328
+ for (const src of s.sources) {
329
+ if (rewritten.get(src.file) === null) continue;
330
+ const base = rewritten.has(src.file) ? rewritten.get(src.file) : src.text;
331
+ const updated = rewriteIndex(base, staleIndex, src.kind === 'current' ? newIndex : []);
332
+ if (updated !== base) rewritten.set(src.file, updated);
333
+ }
334
+ if (!s.sources.some(x => x.kind === 'current')) {
335
+ const head = c.origin ? `<!-- devlog-origin: ${c.origin} -->\n` : '';
336
+ rewritten.set(c.current, rewriteIndex(head, staleIndex, newIndex));
337
+ }
338
+
339
+ // Build targets.
340
+ const targets = new Map();
341
+ for (const seg of segs) {
342
+ const ps = pieces.get(seg).sort((a, b) => a.id - b.id || a.sub - b.sub);
343
+ const perFile = new Map();
344
+ for (const id of seg.ids) {
345
+ const r = byId.get(id);
346
+ if (!perFile.has(r.file)) perFile.set(r.file, []);
347
+ perFile.get(r.file).push(r.round);
348
+ }
349
+ const header = ['# Kept log', '', '- source: keep-all',
350
+ ...[...perFile].map(([f, ns]) => `- rounds: ${f} ${ranges(ns)}`), `- kept_at: ${keptAt}`];
351
+ const pre = prefixes.get(seg).sort((a, b) => a.id - b.id).map(p => p.lines);
352
+ targets.set(seg.file, joinPieces([header, ...pre, ...ps.map(p => p.lines)]));
353
+ }
354
+
355
+ // Verify: every moved Round lands exactly once, none stays behind.
356
+ const movedCount = owner.size;
357
+ let inTargets = 0;
358
+ for (const text of targets.values()) inTargets += parseBlocks(text).blocks.filter(b => b.round !== null).length;
359
+ let before = 0, after = 0;
360
+ for (const src of s.sources) {
361
+ if (!rewritten.has(src.file)) continue;
362
+ before += src.parsed.blocks.filter(b => b.round !== null).length;
363
+ const t = rewritten.get(src.file);
364
+ if (t !== null) after += parseBlocks(t).blocks.filter(b => b.round !== null).length;
365
+ }
366
+ if (inTargets !== movedCount || before - after !== movedCount) {
367
+ throw new Error(`搬移驗證失敗:預期 ${movedCount} 輪,目標檔 ${inTargets} 輪,來源減少 ${before - after} 輪;未寫入任何檔`);
368
+ }
369
+
370
+ // Backup, then commit: targets first, then rewritten sources, then deletions.
371
+ const stamp = keptAt.slice(0, 19).replace(/[-:]/g, '').replace('T', '-');
372
+ const backup = path.join(c.devlogDir, '.keep-all-backup', stamp);
373
+ fs.mkdirSync(backup, { recursive: true });
374
+ for (const file of rewritten.keys()) {
375
+ const p = path.join(c.devlogDir, file);
376
+ if (fs.existsSync(p)) fs.copyFileSync(p, path.join(backup, file));
377
+ }
378
+ const out = [];
379
+ try {
380
+ const writeAtomic = (file, text) => {
381
+ const p = path.join(c.devlogDir, file);
382
+ fs.writeFileSync(`${p}.keep-all.tmp`, text);
383
+ fs.renameSync(`${p}.keep-all.tmp`, p);
384
+ };
385
+ for (const [file, text] of targets) {
386
+ writeAtomic(file, text);
387
+ out.push(`KEPT=${path.join(c.devlogDir, file)} ROUNDS=${pieces.get(segs.find(x => x.file === file)).filter(p => p.sub === 0).length}`);
388
+ }
389
+ for (const [file, text] of rewritten) {
390
+ if (text === null) {
391
+ if (targets.has(file)) continue; // reused name: already replaced
392
+ fs.rmSync(path.join(c.devlogDir, file), { force: true });
393
+ out.push(`DELETED=${path.join(c.devlogDir, file)}`);
394
+ } else writeAtomic(file, text);
395
+ }
396
+ } catch (e) {
397
+ throw new Error(`寫入途中失敗:${e.message}\n原始檔備份在 ${backup}`);
398
+ }
399
+
400
+ const span = path.join(c.devlogDir, '.span-open');
401
+ if (fs.existsSync(span)) {
402
+ const m = fs.readFileSync(span, 'utf8').match(/"round"\s*:\s*(\d+)/);
403
+ if (m && movedFromCurrent.has(Number(m[1]))) fs.rmSync(span, { force: true });
404
+ }
405
+ out.push(`BACKUP=${backup}`);
406
+ return out.join('\n') + '\n';
407
+ }
408
+
409
+ // Removes index lines naming a file in `stale`, appends `add` to the
410
+ // `## Kept 索引` block (created at the end if missing). A block left
411
+ // with no lines is dropped.
412
+ function rewriteIndex(text, stale, add) {
413
+ const { lines, blocks, firstBlock } = parseBlocks(text);
414
+ const idx = blocks.find(b => b.heading.startsWith('## Kept 索引'));
415
+ const named = l => stale.has((l.match(/`(devlog\.[^`]+\.md)`/) || [])[1]);
416
+ if (!idx) return add.length ? joinPieces([lines, ['## Kept 索引', ...add]]) : text;
417
+ const body = lines.slice(idx.start + 1, idx.end + 1);
418
+ if (!add.length && !body.some(named)) return text;
419
+ const kept = [...trimBlank(body.filter(l => !named(l))), ...add];
420
+ const pieces = [lines.slice(0, firstBlock)];
421
+ for (const b of blocks) {
422
+ if (b !== idx) pieces.push(lines.slice(b.start, b.end + 1));
423
+ else if (kept.length) pieces.push(['## Kept 索引', ...kept]);
424
+ }
425
+ return joinPieces(pieces);
426
+ }
427
+
428
+ function main(argv) {
429
+ const opts = {};
430
+ for (let i = 0; i < argv.length; i++) {
431
+ const k = argv[i];
432
+ if (k === '--scan') opts.scan = true;
433
+ else if (['--dir', '--project', '--current', '--open', '--origin', '--apply', '--fingerprint', '--count'].includes(k)) opts[k.slice(2)] = argv[++i] ?? '';
434
+ else { process.stderr.write(`未知參數:${k}\n`); return 2; }
435
+ }
436
+ const c = { devlogDir: opts.dir, projectDir: opts.project, current: opts.current, open: opts.open || '', origin: opts.origin || '' };
437
+ try {
438
+ if (opts.scan) { process.stdout.write(formatScan(scan(c))); return 0; }
439
+ if (opts.apply) {
440
+ const plan = fs.readFileSync(opts.apply, 'utf8');
441
+ process.stdout.write(apply(c, plan, opts.fingerprint || '', Number(opts.count || 0)));
442
+ return 0;
443
+ }
444
+ process.stderr.write('需要 --scan 或 --apply\n');
445
+ return 2;
446
+ } catch (e) {
447
+ process.stderr.write(`${e.message}\n`);
448
+ return 1;
449
+ }
450
+ }
451
+
452
+ module.exports = { parseBlocks, scan, apply, formatScan, normalizeName, parsePlan };
453
+
454
+ if (require.main === module) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # /devlog-tracker:keep-all (docs/design/keep-all.md). Resolves the current
3
+ # branch's devlog, holds the devlog lock, and hands off to keep-all.js:
4
+ # keep-all.sh --scan
5
+ # keep-all.sh --apply <plan.tsv> --fingerprint <fp> --count <n>
6
+ set -uo pipefail
7
+
8
+ _src="${BASH_SOURCE[0]}"
9
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
10
+
11
+ # Checked before sourcing anything: Claude plugin users may not have Node.
12
+ command -v node >/dev/null 2>&1 || { echo "NO_NODE"; exit 0; }
13
+
14
+ # shellcheck source=devlog-path.sh
15
+ . "$SCRIPT_DIR/devlog-path.sh"
16
+ # shellcheck source=json-field.sh
17
+ . "$SCRIPT_DIR/json-field.sh"
18
+
19
+ PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
20
+ devlog_resolve_paths "$PROJECT_DIR"
21
+ [ -d "$DEVLOG_DIR" ] || { echo "NOTHING"; exit 0; }
22
+
23
+ OPEN=""
24
+ [ ! -f "$DEVLOG_DIR/.round-open" ] || OPEN="$(json_int_get "$DEVLOG_DIR/.round-open" round)"
25
+
26
+ devlog_lock_acquire
27
+ trap 'devlog_lock_release' EXIT
28
+ node "$SCRIPT_DIR/keep-all.js" "$@" \
29
+ --dir "$DEVLOG_DIR" --project "$PROJECT_DIR" --current "${DEVLOG_FILE##*/}" \
30
+ --open "$OPEN" --origin "${DEVLOG_ORIGIN:-}"
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { parseBlocks, scan, apply, formatScan } = require('./keep-all');
8
+
9
+ function round(n, ts, status, body = `round ${n} body`) {
10
+ return `## Round ${n} — ${ts}\n\n### Summary\n${body}\n\n### Status\n${status}\n`;
11
+ }
12
+
13
+ function setup(files) {
14
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'keep-all-'));
15
+ const devlogDir = path.join(dir, '.devlog');
16
+ fs.mkdirSync(devlogDir);
17
+ for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(devlogDir, name), text);
18
+ return devlogDir;
19
+ }
20
+
21
+ function ctx(devlogDir, extra = {}) {
22
+ return { devlogDir, projectDir: path.dirname(devlogDir), current: 'devlog.md', open: '', origin: '', ...extra };
23
+ }
24
+
25
+ const read = (d, f) => fs.readFileSync(path.join(d, f), 'utf8');
26
+ const exists = (d, f) => fs.existsSync(path.join(d, f));
27
+
28
+ function idsOf(result, file, rounds) {
29
+ return result.rounds.filter(r => r.file === file && rounds.includes(r.round)).map(r => r.id);
30
+ }
31
+
32
+ test('parseBlocks is fence-aware and reads round number, timestamp and status', () => {
33
+ const text = '# head\n\n' + round(1, '2026-09-01T10:00:00+0800', 'DONE',
34
+ '```\n## Round 99 — fake\n```') + '\n## Checkpoint — x\n\ncp\n';
35
+ const { blocks, firstBlock } = parseBlocks(text);
36
+ assert.equal(firstBlock, 2);
37
+ assert.equal(blocks.length, 2);
38
+ assert.equal(blocks[0].round, 1);
39
+ assert.equal(blocks[0].status, 'DONE');
40
+ assert.equal(blocks[0].ts, '2026-09-01T10:00:00+0800');
41
+ assert.equal(blocks[1].round, null);
42
+ });
43
+
44
+ test('scan classifies sources and marks what is movable', () => {
45
+ const d = setup({
46
+ 'devlog.md': '# proj\n\n' + round(1, '2026-09-01T10:00:00+0800', 'DONE') + '\n' +
47
+ round(2, '2026-09-01T11:00:00+0800', 'DONE') + '\n' + round(3, '2026-09-05T10:00:00+0800', 'IN_PROGRESS'),
48
+ 'devlog.feat-x.md': '<!-- devlog-origin: branch=feat/x -->\n\n' +
49
+ round(1, '2026-09-02T10:00:00+0800', 'DONE') + '\n' + round(2, '2026-09-02T11:00:00+0800', 'IN_PROGRESS'),
50
+ 'devlog.legacy.md': round(1, '2026-09-03T10:00:00+0800', 'DONE'),
51
+ 'devlog.archive.md': round(1, '2026-08-01T10:00:00+0800', 'DONE'),
52
+ 'devlog.topic.md': '# Kept log\n\n- source: `.devlog/devlog.md`\n- rounds: 5-5\n- kept_at: x\n\n' +
53
+ round(5, '2026-08-15T10:00:00+0800', 'DONE'),
54
+ 'devlog.lessons.git.md': '# lessons\n',
55
+ });
56
+ const r = scan(ctx(d, { open: '3' }));
57
+ const kind = Object.fromEntries(r.sources.map(s => [s.file, s.kind]));
58
+ assert.deepEqual(kind, {
59
+ 'devlog.archive.md': 'archive', 'devlog.feat-x.md': 'branch', 'devlog.legacy.md': 'branch',
60
+ 'devlog.md': 'current', 'devlog.topic.md': 'kept',
61
+ });
62
+ const fx = r.sources.find(s => s.file === 'devlog.feat-x.md');
63
+ assert.equal(fx.origin, 'feat/x');
64
+ assert.equal(fx.originFrom, 'marker');
65
+ assert.equal(r.sources.find(s => s.file === 'devlog.legacy.md').originFrom, 'filename');
66
+ // global ids ordered by timestamp
67
+ assert.deepEqual(r.rounds.map(x => `${x.file}:${x.round}`), [
68
+ 'devlog.archive.md:1', 'devlog.topic.md:5', 'devlog.md:1', 'devlog.md:2',
69
+ 'devlog.feat-x.md:1', 'devlog.feat-x.md:2', 'devlog.legacy.md:1', 'devlog.md:3',
70
+ ]);
71
+ const mov = x => r.rounds.find(y => `${y.file}:${y.round}` === x).movable;
72
+ assert.equal(mov('devlog.md:3'), false, 'open round');
73
+ assert.equal(mov('devlog.feat-x.md:2'), false, 'branch unfinished tail');
74
+ assert.equal(mov('devlog.feat-x.md:1'), true);
75
+ assert.match(formatScan(r), /^FINGERPRINT=\S+ COUNT=8\n/);
76
+ });
77
+
78
+ test('scan reports NOTHING when no round is movable', () => {
79
+ const d = setup({ 'devlog.md': round(1, '2026-09-01T10:00:00+0800', 'IN_PROGRESS') });
80
+ const r = scan(ctx(d, { open: '1' }));
81
+ assert.equal(formatScan(r), 'NOTHING\n');
82
+ });
83
+
84
+ function world() {
85
+ return setup({
86
+ 'devlog.md': '# proj\n\n' + round(1, '2026-09-01T10:00:00+0800', 'DONE') +
87
+ '\n## Checkpoint — Round 1-1\n\ncp one\n\n' + round(2, '2026-09-01T11:00:00+0800', 'DONE') +
88
+ '\n## Kept 索引\n- `devlog.topic.md`:Round 5-5,kept_at x\n- `devlog.other.md`:Round 9-9,kept_at x\n',
89
+ 'devlog.feat-x.md': '<!-- devlog-origin: branch=feat/x -->\n\n' +
90
+ round(1, '2026-09-02T10:00:00+0800', 'DONE') + '\n' + round(2, '2026-09-02T11:00:00+0800', 'IN_PROGRESS'),
91
+ 'devlog.archive.md': round(1, '2026-08-01T10:00:00+0800', 'DONE'),
92
+ 'devlog.topic.md': '# Kept log\n\n- source: `.devlog/devlog.md`\n- rounds: 5-5\n- kept_at: x\n\nold summary\n\n' +
93
+ round(5, '2026-08-15T10:00:00+0800', 'DONE'),
94
+ 'devlog.other.md': '# Kept log\n\n- rounds: 9-9\n\n' + round(9, '2026-08-20T10:00:00+0800', 'DONE'),
95
+ });
96
+ }
97
+
98
+ test('apply moves a cross-file topic into one file in time order and consumes kept files', () => {
99
+ const d = world();
100
+ const c = ctx(d);
101
+ const s = scan(c);
102
+ const a = [...idsOf(s, 'devlog.archive.md', [1]), ...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.feat-x.md', [1])];
103
+ const b = [...idsOf(s, 'devlog.other.md', [9]), ...idsOf(s, 'devlog.md', [1])];
104
+ const plan = `alpha\tfirst topic\t${a.join(',')}\nbeta\t\t${b.join(',')}\n`;
105
+ const out = apply(c, plan, s.fingerprint, s.rounds.length);
106
+
107
+ const alpha = read(d, 'devlog.alpha.md');
108
+ assert.match(alpha, /^# Kept log\n\n- source: keep-all\n/);
109
+ assert.match(alpha, /- rounds: devlog\.archive\.md 1\n/);
110
+ assert.match(alpha, /old summary/, 'kept file summary travels with its first round');
111
+ const order = [...alpha.matchAll(/^## Round (\d+) — (\S+)/gm)].map(m => m[2]);
112
+ assert.deepEqual(order, ['2026-08-01T10:00:00+0800', '2026-08-15T10:00:00+0800', '2026-09-02T10:00:00+0800']);
113
+
114
+ const beta = read(d, 'devlog.beta.md');
115
+ assert.match(beta, /## Round 1 — 2026-09-01T10:00:00\+0800[\s\S]*## Checkpoint — Round 1-1/, 'checkpoint follows its round');
116
+
117
+ assert.ok(!exists(d, 'devlog.topic.md') && !exists(d, 'devlog.other.md'), 'kept sources deleted');
118
+ assert.ok(!exists(d, 'devlog.archive.md'), 'empty archive deleted');
119
+ const main = read(d, 'devlog.md');
120
+ assert.match(main, /^# proj\n/);
121
+ assert.ok(!/Round 1 —/.test(main) && /Round 2 —/.test(main));
122
+ assert.ok(!/Checkpoint/.test(main));
123
+ assert.ok(!/devlog\.topic\.md|devlog\.other\.md/.test(main), 'ghost index lines removed');
124
+ assert.match(main, /- `devlog\.alpha\.md`:keep-all,3 輪,kept_at \S+,first topic\n/);
125
+ assert.match(main, /- `devlog\.beta\.md`:keep-all,2 輪,kept_at \S+\n/);
126
+
127
+ const fx = read(d, 'devlog.feat-x.md');
128
+ assert.match(fx, /^<!-- devlog-origin: branch=feat\/x -->\n/);
129
+ assert.ok(!/Round 1 —/.test(fx) && /Round 2 —/.test(fx));
130
+
131
+ assert.match(out, /^KEPT=.*devlog\.alpha\.md ROUNDS=3$/m);
132
+ assert.match(out, /^DELETED=.*devlog\.topic\.md$/m);
133
+ const backup = out.match(/^BACKUP=(.*)$/m)[1];
134
+ assert.ok(fs.existsSync(path.join(backup, 'devlog.topic.md')));
135
+ assert.ok(fs.existsSync(path.join(backup, 'devlog.md')));
136
+ });
137
+
138
+ test('apply may reuse the name of a kept file it consumes', () => {
139
+ const d = world();
140
+ const c = ctx(d);
141
+ const s = scan(c);
142
+ const all = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])];
143
+ apply(c, `topic\t\t${all.join(',')}\n`, s.fingerprint, s.rounds.length);
144
+ assert.match(read(d, 'devlog.topic.md'), /- source: keep-all/);
145
+ assert.match(read(d, 'devlog.md'), /- `devlog\.topic\.md`:keep-all,2 輪/);
146
+ });
147
+
148
+ function snapshot(d) {
149
+ return Object.fromEntries(fs.readdirSync(d).map(f => [f, fs.readFileSync(path.join(d, f), 'utf8')]));
150
+ }
151
+
152
+ for (const [label, planFor, pattern] of [
153
+ ['uncovered kept round', s => `a\t\t${idsOf(s, 'devlog.topic.md', [5])}\n`, /devlog\.other\.md/],
154
+ ['id in two segments', s => {
155
+ const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
156
+ return `a\t\t${k}\nb\t\t${idsOf(s, 'devlog.topic.md', [5])}\n`;
157
+ }, /重複/],
158
+ ['unmovable round', s => {
159
+ const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
160
+ return `a\t\t${k},${idsOf(s, 'devlog.feat-x.md', [2])}\n`;
161
+ }, /不可搬/],
162
+ ['collision with an existing branch file', s => {
163
+ const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
164
+ return `feat-x\t\t${k}\n`;
165
+ }, /已存在/],
166
+ ['reserved name', s => {
167
+ const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
168
+ return `archive\t\t${k}\n`;
169
+ }, /檔名無效/],
170
+ ]) {
171
+ test(`apply rejects ${label} and writes nothing`, () => {
172
+ const d = world();
173
+ const c = ctx(d);
174
+ const s = scan(c);
175
+ const before = snapshot(d);
176
+ assert.throws(() => apply(c, planFor(s), s.fingerprint, s.rounds.length), pattern);
177
+ assert.deepEqual(snapshot(d), before);
178
+ });
179
+ }
180
+
181
+ test('apply refuses when a scanned round changed, but tolerates a newly appended round', () => {
182
+ const d = world();
183
+ const c = ctx(d);
184
+ const s = scan(c);
185
+ const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
186
+ fs.appendFileSync(path.join(d, 'devlog.md'), '\n' + round(3, '2026-09-10T10:00:00+0800', 'DONE'));
187
+ const d2 = world();
188
+ const c2 = ctx(d2);
189
+ const s2 = scan(c2);
190
+ fs.writeFileSync(path.join(d2, 'devlog.archive.md'), round(1, '2026-08-01T10:00:00+0800', 'DONE', 'edited'));
191
+ const before = snapshot(d2);
192
+ assert.throws(() => apply(c2, `a\t\t${k}\n`, s2.fingerprint, s2.rounds.length), /fingerprint/);
193
+ assert.deepEqual(snapshot(d2), before);
194
+ apply(c, `a\t\t${k}\n`, s.fingerprint, s.rounds.length);
195
+ assert.match(read(d, 'devlog.md'), /Round 3 —/);
196
+ });
197
+
198
+ test('apply clears .span-open only when its round moved out of current', () => {
199
+ const d = world();
200
+ fs.writeFileSync(path.join(d, '.span-open'), '{"round": 1, "ticks": 0}\n');
201
+ const c = ctx(d);
202
+ const s = scan(c);
203
+ const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9]), ...idsOf(s, 'devlog.md', [2])].join(',');
204
+ apply(c, `a\t\t${k}\n`, s.fingerprint, s.rounds.length);
205
+ assert.ok(exists(d, '.span-open'));
206
+ const s2 = scan(c);
207
+ const k2 = [...s2.rounds.filter(r => r.file === 'devlog.a.md').map(r => r.id), ...idsOf(s2, 'devlog.md', [1])];
208
+ apply(c, `b\t\t${k2.join(',')}\n`, s2.fingerprint, s2.rounds.length);
209
+ assert.ok(!exists(d, '.span-open'));
210
+ });
@@ -97,12 +97,18 @@ while IFS="$(printf '\t')" read -r start heading; do
97
97
  esac
98
98
  done < "$H2"
99
99
 
100
+ # A branch file's origin marker (line 1) is the file's identity, not part
101
+ # of the project summary: it never moves, even on full keep.
102
+ MARKER=0
103
+ [ -z "$(devlog_origin_of "$MAIN")" ] || MARKER=1
104
+
100
105
  KEPT_AT="$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S%z')"
101
106
  {
102
107
  printf '# Kept log\n\n- source: `.devlog/devlog.md`\n- rounds: %s-%s\n- kept_at: %s\n\n' \
103
108
  "$FROM" "$TO" "$KEPT_AT"
104
- awk -v selected="$MOVE_BLOCKS" -v full="$FULL" -v first_round="$FIRST_ROUND" '
109
+ awk -v selected="$MOVE_BLOCKS" -v full="$FULL" -v first_round="$FIRST_ROUND" -v marker="$MARKER" '
105
110
  BEGIN { while ((getline n < selected) > 0) move[n] = 1 }
111
+ marker && NR == 1 { next }
106
112
  /^[ \t]*```/ { fence = !fence }
107
113
  !fence && /^## / { moving = (NR in move) }
108
114
  full && NR < first_round { print; next }
@@ -115,8 +121,9 @@ while read -r start round; do
115
121
  grep -Fqx "$heading" "$TARGET" || { echo "具名檔驗證失敗" >&2; exit 1; }
116
122
  done < "$MOVE_ROUNDS"
117
123
 
118
- awk -v selected="$MOVE_BLOCKS" -v full="$FULL" -v first_round="$FIRST_ROUND" -v open="$OPEN" '
124
+ awk -v selected="$MOVE_BLOCKS" -v full="$FULL" -v first_round="$FIRST_ROUND" -v open="$OPEN" -v marker="$MARKER" '
119
125
  BEGIN { while ((getline n < selected) > 0) move[n] = 1 }
126
+ marker && NR == 1 { print; next }
120
127
  /^[ \t]*```/ { fence = !fence }
121
128
  !fence && /^## / { moving = (NR in move) }
122
129
  full && NR < first_round { next }
@@ -95,7 +95,7 @@ case "$PROMPT" in
95
95
  ;;
96
96
  esac
97
97
  case "$CMD_NAME" in
98
- devlog-tracker:checkpoint|devlog-tracker:clean|devlog-tracker:compact|devlog-tracker:keep|devlog-tracker:lessons|devlog-tracker:lessons-drift|devlog-tracker:lessons-off|devlog-tracker:lessons-on|devlog-tracker:overview|devlog-tracker:pause|devlog-tracker:report|devlog-tracker:search|devlog-tracker:segment-watch|devlog-tracker:span|devlog-tracker:start|devlog-tracker:status|devlog-tracker:timeline)
98
+ devlog-tracker:checkpoint|devlog-tracker:clean|devlog-tracker:compact|devlog-tracker:keep|devlog-tracker:keep-all|devlog-tracker:lessons|devlog-tracker:lessons-drift|devlog-tracker:lessons-off|devlog-tracker:lessons-on|devlog-tracker:overview|devlog-tracker:pause|devlog-tracker:report|devlog-tracker:search|devlog-tracker:segment-watch|devlog-tracker:span|devlog-tracker:start|devlog-tracker:status|devlog-tracker:timeline)
99
99
  exit 0
100
100
  ;;
101
101
  esac
@@ -203,6 +203,65 @@ else
203
203
  FAIL=1
204
204
  fi
205
205
 
206
+ # --- origin marker: DEVLOG_ORIGIN reflects the raw branch / detached dir ----
207
+ devlog_resolve_paths "$MAINREPO"
208
+ [ -z "$DEVLOG_ORIGIN" ] \
209
+ && echo "PASS: main has no DEVLOG_ORIGIN" \
210
+ || { echo "FAIL: main DEVLOG_ORIGIN=$DEVLOG_ORIGIN"; FAIL=1; }
211
+ devlog_resolve_paths "$NONGIT"
212
+ [ -z "$DEVLOG_ORIGIN" ] \
213
+ && echo "PASS: non-git has no DEVLOG_ORIGIN" \
214
+ || { echo "FAIL: non-git DEVLOG_ORIGIN=$DEVLOG_ORIGIN"; FAIL=1; }
215
+ devlog_resolve_paths "$SLASHREPO"
216
+ [ "$DEVLOG_ORIGIN" = "branch=feature/foo" ] \
217
+ && echo "PASS: DEVLOG_ORIGIN keeps the raw (unsanitized) branch name" \
218
+ || { echo "FAIL: slash DEVLOG_ORIGIN=$DEVLOG_ORIGIN"; FAIL=1; }
219
+ devlog_resolve_paths "$DETACHEDREPO"
220
+ [ "$DEVLOG_ORIGIN" = "detached=detached-repo" ] \
221
+ && echo "PASS: detached DEVLOG_ORIGIN names the worktree dir" \
222
+ || { echo "FAIL: detached DEVLOG_ORIGIN=$DEVLOG_ORIGIN"; FAIL=1; }
223
+
224
+ # --- origin marker: written as line 1 when the tail migrates -------------
225
+ MARKREPO="$TMP/markrepo"
226
+ mig_repo "$MARKREPO"
227
+ write_main_devlog "$MARKREPO/.devlog/devlog.md" 1:DONE 2:IN_PROGRESS
228
+ git -C "$MARKREPO" checkout -q -b feat/mark
229
+ devlog_resolve_paths "$MARKREPO"
230
+ if [ "$(head -n 1 "$DEVLOG_FILE")" = "<!-- devlog-origin: branch=feat/mark -->" ] \
231
+ && [ "$(devlog_list_round_starts "$DEVLOG_FILE" | awk '{print $2}' | tr '\n' ' ')" = "2 " ] \
232
+ && ! grep -q "devlog-origin" "$MARKREPO/.devlog/devlog.md"; then
233
+ echo "PASS: migrated branch file starts with its origin marker"
234
+ else
235
+ echo "FAIL: migrated marker"; cat "$DEVLOG_FILE" 2>/dev/null; FAIL=1
236
+ fi
237
+
238
+ # --- origin marker: first merge into a missing branch file writes it -----
239
+ MERGEREPO="$TMP/mergerepo"
240
+ mig_repo "$MERGEREPO"
241
+ git -C "$MERGEREPO" checkout -q -b feat/merge
242
+ devlog_resolve_paths "$MERGEREPO"
243
+ printf '## Round 1 — t\n\n### Status\nDONE\n' > "$TMP/rc1.md"
244
+ devlog_merge_round_current "$DEVLOG_FILE" "$TMP/rc1.md"
245
+ printf '## Round 2 — t\n\n### Status\nDONE\n' > "$TMP/rc2.md"
246
+ devlog_merge_round_current "$DEVLOG_FILE" "$TMP/rc2.md"
247
+ if [ "$(head -n 1 "$DEVLOG_FILE")" = "<!-- devlog-origin: branch=feat/merge -->" ] \
248
+ && [ "$(grep -c "devlog-origin" "$DEVLOG_FILE")" = 1 ] \
249
+ && [ "$(devlog_list_round_starts "$DEVLOG_FILE" | awk '{print $2}' | tr '\n' ' ')" = "1 2 " ]; then
250
+ echo "PASS: first merge into a new branch file writes the marker once"
251
+ else
252
+ echo "FAIL: merge marker"; cat "$DEVLOG_FILE" 2>/dev/null; FAIL=1
253
+ fi
254
+
255
+ # --- origin marker: devlog.md never gets one -----------------------------
256
+ MAINMERGE="$TMP/mainmerge"
257
+ mig_repo "$MAINMERGE"
258
+ devlog_resolve_paths "$MAINMERGE"
259
+ printf '## Round 1 — t\n\n### Status\nDONE\n' > "$TMP/rc3.md"
260
+ devlog_merge_round_current "$DEVLOG_FILE" "$TMP/rc3.md"
261
+ ! grep -q "devlog-origin" "$DEVLOG_FILE" \
262
+ && echo "PASS: devlog.md gets no origin marker" \
263
+ || { echo "FAIL: devlog.md got a marker"; FAIL=1; }
264
+
206
265
  # --- HANDOFF_FILE mirrors DEVLOG_FILE naming (no rename migration) --------
207
266
  devlog_resolve_paths "$NONGIT"
208
267
  [ "$HANDOFF_FILE" = "$NONGIT/.devlog/handoff.md" ] \
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # End-to-end check of keep-all.sh in a real git repo: branch-state
3
+ # reporting, current-file resolution, and a full scan → apply cycle.
4
+ # keep-all.js's own rules are covered by core/scripts/keep-all.test.js.
5
+ set -uo pipefail
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7
+ TMP="$(mktemp -d)"
8
+ trap 'rm -rf "$TMP"' EXIT
9
+ FAIL=0
10
+ check() { if eval "$2"; then echo "PASS: $1"; else echo "FAIL: $1"; FAIL=1; fi; }
11
+ round() { printf '## Round %s — %s\n\n### Status\n%s\n\n' "$1" "$2" "$3"; }
12
+
13
+ command -v node >/dev/null 2>&1 || { echo "SKIP: node not installed"; exit 0; }
14
+
15
+ R="$TMP/repo"
16
+ mkdir -p "$R/.devlog"
17
+ git -C "$R" init -q
18
+ git -C "$R" -c user.email=t@t.t -c user.name=t commit -q --allow-empty -m init
19
+ git -C "$R" branch -M main
20
+ git -C "$R" branch feat/merged
21
+ git -C "$R" checkout -q -b feat/live
22
+ git -C "$R" -c user.email=t@t.t -c user.name=t commit -q --allow-empty -m live
23
+ export DEVLOG_PROJECT_DIR="$R"
24
+
25
+ { printf '# proj\n\n'; round 1 2026-09-01T10:00:00+0800 DONE; } > "$R/.devlog/devlog.md"
26
+ { printf '<!-- devlog-origin: branch=feat/live -->\n\n'; round 1 2026-09-02T10:00:00+0800 DONE; round 2 2026-09-02T11:00:00+0800 IN_PROGRESS; } > "$R/.devlog/devlog.feat-live.md"
27
+ { printf '<!-- devlog-origin: branch=feat/merged -->\n\n'; round 1 2026-09-03T10:00:00+0800 DONE; } > "$R/.devlog/devlog.feat-merged.md"
28
+ { printf '<!-- devlog-origin: branch=feat/deleted -->\n\n'; round 1 2026-09-04T10:00:00+0800 DONE; } > "$R/.devlog/devlog.feat-deleted.md"
29
+ printf '{"round": 2, "opened_at": "now"}\n' > "$R/.devlog/.round-open"
30
+
31
+ OUT="$(bash "$SCRIPT_DIR/keep-all.sh" --scan)"
32
+ check "current file is the checked-out branch's" 'grep -q "^SOURCE file=devlog.feat-live.md kind=current origin=feat/live origin_from=marker state=active" <<<"$OUT"'
33
+ check "devlog.md off main is a branch source" 'grep -q "^SOURCE file=devlog.md kind=branch origin=main" <<<"$OUT"'
34
+ check "merged branch reported" 'grep -q "file=devlog.feat-merged.md kind=branch origin=feat/merged origin_from=marker state=merged" <<<"$OUT"'
35
+ check "deleted branch reported" 'grep -q "file=devlog.feat-deleted.md kind=branch origin=feat/deleted origin_from=marker state=gone" <<<"$OUT"'
36
+ check "open round not movable" 'grep -q "file=devlog.feat-live.md round=2 .* movable=0" <<<"$OUT"'
37
+
38
+ FP="$(sed -n 's/^FINGERPRINT=\([^ ]*\) COUNT=.*/\1/p' <<<"$OUT")"
39
+ COUNT="$(sed -n 's/^FINGERPRINT=[^ ]* COUNT=\(.*\)/\1/p' <<<"$OUT")"
40
+ ID_MERGED="$(sed -n 's/^ROUND id=\([0-9]*\) file=devlog.feat-merged.md .*/\1/p' <<<"$OUT")"
41
+ ID_DELETED="$(sed -n 's/^ROUND id=\([0-9]*\) file=devlog.feat-deleted.md .*/\1/p' <<<"$OUT")"
42
+ printf 'old-branches\told branch work\t%s,%s\n' "$ID_MERGED" "$ID_DELETED" > "$TMP/plan.tsv"
43
+ APPLY="$(bash "$SCRIPT_DIR/keep-all.sh" --apply "$TMP/plan.tsv" --fingerprint "$FP" --count "$COUNT")"
44
+ if [ $? -eq 0 ]; then echo "PASS: apply exits 0"; else echo "FAIL: apply exits 0"; FAIL=1; fi
45
+ [ -f "$R/.devlog/devlog.old-branches.md" ] && grep -q "^KEPT=.*devlog.old-branches.md ROUNDS=2" <<<"$APPLY" \
46
+ && echo "PASS: named file written" || { echo "FAIL: named file written"; FAIL=1; }
47
+ check "branch files keep their marker and are not deleted" '[ "$(head -n 1 "$R/.devlog/devlog.feat-merged.md")" = "<!-- devlog-origin: branch=feat/merged -->" ]'
48
+ check "index written to the current branch file" 'grep -q "devlog.old-branches.md\`:keep-all,2 輪" "$R/.devlog/devlog.feat-live.md"'
49
+ check "lock released" '[ ! -e "$R/.devlog/.lock" ] && [ ! -d "$R/.devlog/.lock.d" ]'
50
+
51
+ bash "$SCRIPT_DIR/keep-all.sh" --apply "$TMP/plan.tsv" --fingerprint "$FP" --count "$COUNT" >/dev/null 2>"$TMP/err"
52
+ if [ $? -eq 1 ] && grep -q fingerprint "$TMP/err"; then echo "PASS: stale fingerprint rejected"
53
+ else echo "FAIL: stale fingerprint rejected"; FAIL=1; fi
54
+
55
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
56
+ else echo "Some checks FAILED."; exit 1; fi
@@ -123,5 +123,17 @@ case "$LINE_NO_DESC" in
123
123
  *) echo "FAIL: index line for no-desc missing: $LINE_NO_DESC"; FAIL=1 ;;
124
124
  esac
125
125
 
126
+ MARK="$TMP/marker"
127
+ mkdir -p "$MARK/.devlog"
128
+ export CLAUDE_PROJECT_DIR="$MARK"
129
+ printf '<!-- devlog-origin: branch=feat/x -->\n# project\n\n' > "$MARK/.devlog/devlog.md"
130
+ make_round "$MARK/.devlog/devlog.md" 1 DONE
131
+ make_round "$MARK/.devlog/devlog.md" 2 IN_PROGRESS
132
+ printf '%s\n' '{"round": 2, "opened_at": "now"}' > "$MARK/.devlog/.round-open"
133
+ bash "$SCRIPT_DIR/keep-move.sh" --from 1 --to 1 --name all >/dev/null
134
+ assert_eq "full keep leaves origin marker on line 1" "<!-- devlog-origin: branch=feat/x -->" "$(head -n 1 "$MARK/.devlog/devlog.md")"
135
+ grep -q 'devlog-origin' "$MARK/.devlog/devlog.all.md" && { echo "FAIL: marker copied into kept file"; FAIL=1; } || echo "PASS: kept file has no origin marker"
136
+ grep -q '^# project' "$MARK/.devlog/devlog.all.md" && echo "PASS: project summary still moves on full keep" || { echo "FAIL: summary not moved"; FAIL=1; }
137
+
126
138
  if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
127
139
  else echo "Some checks FAILED."; exit 1; fi
@@ -664,8 +664,8 @@ else
664
664
  fi
665
665
  rm -f "$DEVLOG_DIR/.checkpoint-state"
666
666
 
667
- # --- 19b: report / timeline are admin (read-side) commands too -------------
668
- for ADMIN_CMD in report timeline; do
667
+ # --- 19b: report / timeline / keep-all are admin commands too -------------
668
+ for ADMIN_CMD in report timeline keep-all; do
669
669
  rm -f "$DEVLOG_DIR/.round-current.md" "$DEVLOG_DIR/.round-open"
670
670
  printf '{"prompt":"<command-name>/devlog-tracker:%s</command-name>"}' "$ADMIN_CMD" | bash "$SCRIPT_DIR/round-start.sh"
671
671
  assert_file_absent "admin $ADMIN_CMD: no .round-current.md" "$DEVLOG_DIR/.round-current.md"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlog-tracker",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "npx installer for devlog-tracker's Claude Code, Cursor and Codex adapters.",
5
5
  "bin": {
6
6
  "devlog-tracker": "bin/devlog-tracker.js"
@@ -70,9 +70,10 @@ Summary/Handoff」——後面這句要整句刪掉,不是縮短。
70
70
  ## 檔案位置
71
71
 
72
72
  - 主檔:`.devlog/devlog.md`——在 `main`/`master` 分支上工作時使用
73
- - 分支主檔:`.devlog/devlog.<branch>.md`——在同一個 worktree 裡切換到其他分支時,主檔會依目前 checkout 的分支自動分開(斜線轉成 `-`);detached HEAD 退回用 worktree 目錄名。另開一個 `git worktree`(不同目錄)本來就有自己獨立的 `.devlog/`,不受這個機制影響。第一次在某分支偵測到還沒有專屬檔案時,只會把 `devlog.md` 裡還沒完成的尾巴(最後一個 `DONE` 之後的 Round,連同 `handoff.md`)剪到該分支的檔案;`main` 自己的歷史、專案摘要、Checkpoint、Kept/Lessons 索引都留在 `devlog.md`。最後一輪已經是 `DONE`,或切到的是不含目前 `main` 最新 commit 的舊分支時,什麼都不搬,新分支從空檔開始。細節見 `docs/design/branch-scoped-devlog.md`。
73
+ - 分支主檔:`.devlog/devlog.<branch>.md`——在同一個 worktree 裡切換到其他分支時,主檔會依目前 checkout 的分支自動分開(斜線轉成 `-`);detached HEAD 退回用 worktree 目錄名。另開一個 `git worktree`(不同目錄)本來就有自己獨立的 `.devlog/`,不受這個機制影響。第一次在某分支偵測到還沒有專屬檔案時,只會把 `devlog.md` 裡還沒完成的尾巴(最後一個 `DONE` 之後的 Round,連同 `handoff.md`)剪到該分支的檔案;`main` 自己的歷史、專案摘要、Checkpoint、Kept/Lessons 索引都留在 `devlog.md`。最後一輪已經是 `DONE`,或切到的是不含目前 `main` 最新 commit 的舊分支時,什麼都不搬,新分支從空檔開始。分支檔第一行是 origin 標記 `<!-- devlog-origin: branch=<原始分支名> -->`(detached HEAD 是 `detached=<目錄名>`),由 hook 在建立分支檔時寫入;不要刪改這一行。細節見 `docs/design/branch-scoped-devlog.md`。
74
74
  - 歸檔:`.devlog/devlog.archive.md`
75
- - 具名保存:`.devlog/devlog.<name>.md`(`/devlog-tracker:keep` 搬走的主題檔;SessionStart 不讀這些檔)
75
+ - 具名保存:`.devlog/devlog.<name>.md`(`/devlog-tracker:keep`/`keep-all` 搬走的主題檔,第一行是 `# Kept log`;SessionStart 不讀這些檔)
76
+ - keep-all 備份:`.devlog/.keep-all-backup/<時間戳>/`(`/devlog-tracker:keep-all` 動手前的原始檔複本)
76
77
  - 當輪暫存:`.devlog/.round-current.md`(目前開著的那一輪,Claude 該讀寫的是這個檔,不是 `devlog.md`;
77
78
  收尾或中斷時由 hook 自動合併回 `devlog.md` 並清空,設計見 `docs/design/round-current-split.md`)
78
79
  - Session Handoff 快照:`.devlog/handoff.md`(`main`/`master`);其他分支 `.devlog/handoff.<branch>.md`。
@@ -314,7 +315,7 @@ Stop hook 會檢查最後一個 Round 是否同時有 `### Summary`、`### Reply
314
315
  ### devlog-tracker 自己的管理指令不記錄
315
316
 
316
317
  這一輪如果是使用者直接呼叫 devlog-tracker 自己的純管理指令——`/devlog-tracker:checkpoint`、
317
- `clean`、`compact`、`keep`、`lessons`、`lessons-drift`、`lessons-off`、`lessons-on`、
318
+ `clean`、`compact`、`keep`、`keep-all`、`lessons`、`lessons-drift`、`lessons-off`、`lessons-on`、
318
319
  `overview`、`pause`、`report`、`search`、`segment-watch`、`span`、`start`、`status`、`timeline`——`round-start.sh`
319
320
  會整輪直接放行,不開 Round、不動任何計數器,等於這個 tick 沒發生過;不用、也不會被
320
321
  Stop hook 要求補寫 Summary/Reply/Handoff。這些指令本身就是在操作 devlog 系統,不是開發
@@ -425,6 +426,15 @@ hook 會要求補一段。
425
426
  讓「哪個主題被搬去哪個檔」不用翻完整份 `devlog.md` 或憑印象猜檔名
426
427
  (`docs/design/devlog-as-ssot-assessment.md` Phase 3)。
427
428
 
429
+ ## 整理所有 devlog:`/devlog-tracker:keep-all`
430
+
431
+ keep 只整理目前分支的主檔;keep-all 一次整理 `.devlog/` 裡**所有** devlog 檔:目前分支的
432
+ 主檔、其他分支的 `devlog.<branch>.md`、`devlog.archive.md`,以及既有的具名檔。Round 依時間
433
+ 拿到全域編號,Claude 跨檔依主題重新分段、一次列出建議,確認後 `core/scripts/keep-all.sh`
434
+ 整批執行(全有或全無,先備份到 `.devlog/.keep-all-backup/`)。既有 kept 檔的 Round 必須全部
435
+ 重新分配;各分支最後一個 `DONE` 之後的未完成尾巴與開著的那一輪不搬;分支檔搬空也不刪。
436
+ 新索引行寫進目前分支主檔的 `## Kept 索引`。需要 Node。步驟見 `commands/keep-all.md`。不要自動觸發。
437
+
428
438
  ## 接續具名保存:`/devlog-tracker:resume <name>`
429
439
 
430
440
  需要重啟具名主題時,用 resume 讀取 `.devlog/devlog.<name>.md` 的最後一輪與
@@ -60,6 +60,7 @@
60
60
  | 接續上一題 | `commands/continue.md` |
61
61
  | 暫停/狀態/壓縮/清空 | `commands/pause.md`/`status.md`/`compact.md`/`clean.md` |
62
62
  | 具名保存/接續/總覽 | `commands/keep.md`/`resume.md`/`overview.md` |
63
+ | 整理所有 devlog(跨分支檔、archive、既有 keep 檔) | `commands/keep-all.md` |
63
64
  | Span/Checkpoint/Segment/Lessons | `commands/span.md`/`checkpoint.md`/`segment-watch.md`/`lessons*.md` |
64
65
  | 協定與跨指令行為 | `SKILL.md`(本層不重抄步驟) |
65
66