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 +5 -4
- package/README.zh-TW.md +5 -4
- package/cli/agents-md.js +1 -0
- package/cli/platforms/claude.js +1 -0
- package/commands/keep-all.md +93 -0
- package/commands/keep.md +2 -0
- package/core/scripts/devlog-md.sh +18 -1
- package/core/scripts/devlog-path.sh +17 -5
- package/core/scripts/keep-all.js +454 -0
- package/core/scripts/keep-all.sh +30 -0
- package/core/scripts/keep-all.test.js +210 -0
- package/core/scripts/keep-move.sh +9 -2
- package/core/scripts/round-start.sh +1 -1
- package/core/scripts/tests/test-devlog-path.sh +59 -0
- package/core/scripts/tests/test-keep-all.sh +56 -0
- package/core/scripts/tests/test-keep-move.sh +12 -0
- package/core/scripts/tests/test-round-start.sh +2 -2
- package/package.json +1 -1
- package/skills/devlog-tracker/SKILL.md +13 -3
- package/skills/devlog-tracker/references/contract.md +1 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
*English | [繁體中文](README.zh-TW.md)*
|
|
4
4
|
|
|
5
|
-
**Version** 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
|
|
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.
|
|
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` |
|
|
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
|
|
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\` |
|
package/cli/platforms/claude.js
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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`
|
|
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
|
|