devlog-tracker 0.31.1 → 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.
@@ -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
@@ -69,35 +69,124 @@ devlog_resolve_paths "$DETACHEDREPO"
69
69
  && echo "PASS: detached HEAD falls back to worktree dirname" \
70
70
  || { echo "FAIL: detached got $DEVLOG_FILE"; FAIL=1; }
71
71
 
72
- # --- migration: existing devlog.md content moves to branch file ----------
73
- MIGREPO="$TMP/migrepo"
74
- mkdir -p "$MIGREPO/.devlog"
75
- : > "$MIGREPO/.devlog/.enabled"
76
- git_setup "$MIGREPO"
77
- git -C "$MIGREPO" checkout -q -b feature-y
78
- echo "## Round 1 existing content" > "$MIGREPO/.devlog/devlog.md"
79
- devlog_resolve_paths "$MIGREPO"
80
- if [ "$DEVLOG_FILE" = "$MIGREPO/.devlog/devlog.feature-y.md" ] \
72
+ # Writes a devlog.md with a project header, the given rounds (as
73
+ # "<n>:<STATUS>" pairs), a Checkpoint between rounds 1 and 2, and a
74
+ # trailing Kept 索引.
75
+ write_main_devlog() {
76
+ local file="$1"; shift
77
+ {
78
+ printf '# 專案摘要\n\nheader line\n'
79
+ local pair n st
80
+ for pair in "$@"; do
81
+ n="${pair%%:*}"; st="${pair#*:}"
82
+ printf '\n## Round %s — t\n\n### Summary\nround %s body\n\n### Status\n%s\n' "$n" "$n" "$st"
83
+ [ "$n" = 1 ] && printf '\n## Checkpoint — t\n\ncheckpoint body\n'
84
+ done
85
+ printf '\n## Kept 索引\n\n- devlog.old.md — kept topic\n'
86
+ } > "$file"
87
+ }
88
+
89
+ # New repo on main with .enabled; leaves the caller on main.
90
+ mig_repo() {
91
+ mkdir -p "$1/.devlog"
92
+ : > "$1/.devlog/.enabled"
93
+ git_setup "$1"
94
+ git -C "$1" branch -M main
95
+ }
96
+
97
+ # --- main's last round DONE: new branch starts empty, devlog.md untouched --
98
+ DONEREPO="$TMP/donerepo"
99
+ mig_repo "$DONEREPO"
100
+ write_main_devlog "$DONEREPO/.devlog/devlog.md" 1:DONE 2:DONE
101
+ cp "$DONEREPO/.devlog/devlog.md" "$TMP/done-before.md"
102
+ echo "main handoff" > "$DONEREPO/.devlog/handoff.md"
103
+ git -C "$DONEREPO" checkout -q -b feature-done
104
+ devlog_resolve_paths "$DONEREPO"
105
+ if [ "$DEVLOG_FILE" = "$DONEREPO/.devlog/devlog.feature-done.md" ] \
106
+ && [ ! -f "$DEVLOG_FILE" ] \
107
+ && cmp -s "$DONEREPO/.devlog/devlog.md" "$TMP/done-before.md" \
108
+ && [ -f "$DONEREPO/.devlog/handoff.md" ] \
109
+ && [ ! -f "$DONEREPO/.devlog/handoff.feature-done.md" ]; then
110
+ echo "PASS: main ending DONE leaves devlog.md alone, branch starts empty"
111
+ else
112
+ echo "FAIL: DONE-tail migration moved something (file=$DEVLOG_FILE)"
113
+ FAIL=1
114
+ fi
115
+
116
+ # --- unfinished tail: rounds after the last DONE are cut into branch file --
117
+ TAILREPO="$TMP/tailrepo"
118
+ mig_repo "$TAILREPO"
119
+ write_main_devlog "$TAILREPO/.devlog/devlog.md" 1:DONE 2:DONE 3:BLOCKED 4:IN_PROGRESS
120
+ echo "main handoff" > "$TAILREPO/.devlog/handoff.md"
121
+ git -C "$TAILREPO" checkout -q -b feature-y
122
+ devlog_resolve_paths "$TAILREPO"
123
+ MAINF="$TAILREPO/.devlog/devlog.md"
124
+ if [ "$DEVLOG_FILE" = "$TAILREPO/.devlog/devlog.feature-y.md" ] \
81
125
  && [ -f "$DEVLOG_FILE" ] \
82
- && [ ! -f "$MIGREPO/.devlog/devlog.md" ] \
83
- && grep -q "existing content" "$DEVLOG_FILE"; then
84
- echo "PASS: migration renames devlog.md into branch file"
126
+ && [ "$(devlog_list_round_starts "$DEVLOG_FILE" | awk '{print $2}' | tr '\n' ' ')" = "3 4 " ] \
127
+ && [ "$(devlog_list_round_starts "$MAINF" | awk '{print $2}' | tr '\n' ' ')" = "1 2 " ] \
128
+ && ! grep -q "round 3 body\|round 4 body" "$MAINF" \
129
+ && grep -q "^# 專案摘要" "$MAINF" && ! grep -q "^# 專案摘要" "$DEVLOG_FILE" \
130
+ && grep -q "^## Checkpoint" "$MAINF" && ! grep -q "^## Checkpoint" "$DEVLOG_FILE" \
131
+ && grep -q "^## Kept 索引" "$MAINF" && ! grep -q "^## Kept 索引" "$DEVLOG_FILE" \
132
+ && [ ! -f "$TAILREPO/.devlog/handoff.md" ] \
133
+ && grep -q "main handoff" "$TAILREPO/.devlog/handoff.feature-y.md"; then
134
+ echo "PASS: unfinished tail (rounds after last DONE) cut into branch file with handoff"
85
135
  else
86
- echo "FAIL: migration file=$DEVLOG_FILE"
136
+ echo "FAIL: tail migration (file=$DEVLOG_FILE)"
137
+ echo "--- devlog.md"; cat "$MAINF"; echo "--- branch"; cat "$DEVLOG_FILE" 2>/dev/null
87
138
  FAIL=1
88
139
  fi
89
140
 
90
- # --- migration is idempotent: second call doesn't re-move or error -------
91
- echo "## Round 2 written after migration" >> "$MIGREPO/.devlog/devlog.feature-y.md"
92
- devlog_resolve_paths "$MIGREPO"
93
- if [ "$DEVLOG_FILE" = "$MIGREPO/.devlog/devlog.feature-y.md" ] \
94
- && grep -q "Round 2 written after migration" "$DEVLOG_FILE"; then
141
+ # --- tail migration is idempotent: branch file exists, nothing re-moves ----
142
+ echo "## Round 5 written after migration" >> "$TAILREPO/.devlog/devlog.feature-y.md"
143
+ cp "$MAINF" "$TMP/tail-main-after.md"
144
+ devlog_resolve_paths "$TAILREPO"
145
+ if [ "$DEVLOG_FILE" = "$TAILREPO/.devlog/devlog.feature-y.md" ] \
146
+ && grep -q "Round 5 written after migration" "$DEVLOG_FILE" \
147
+ && cmp -s "$MAINF" "$TMP/tail-main-after.md"; then
95
148
  echo "PASS: second resolve is a no-op, content preserved"
96
149
  else
97
150
  echo "FAIL: second resolve altered state, got $DEVLOG_FILE"
98
151
  FAIL=1
99
152
  fi
100
153
 
154
+ # --- no DONE round at all: every round moves, header/index stay ----------
155
+ NODONEREPO="$TMP/nodonerepo"
156
+ mig_repo "$NODONEREPO"
157
+ write_main_devlog "$NODONEREPO/.devlog/devlog.md" 1:INTERRUPTED 2:IN_PROGRESS
158
+ git -C "$NODONEREPO" checkout -q -b feature-n
159
+ devlog_resolve_paths "$NODONEREPO"
160
+ if [ "$(devlog_list_round_starts "$DEVLOG_FILE" 2>/dev/null | awk '{print $2}' | tr '\n' ' ')" = "1 2 " ] \
161
+ && [ -z "$(devlog_list_round_starts "$NODONEREPO/.devlog/devlog.md")" ] \
162
+ && grep -q "^## Kept 索引" "$NODONEREPO/.devlog/devlog.md" \
163
+ && grep -q "^## Checkpoint" "$NODONEREPO/.devlog/devlog.md"; then
164
+ echo "PASS: with no DONE round, all rounds move and non-round sections stay"
165
+ else
166
+ echo "FAIL: no-DONE migration (file=$DEVLOG_FILE)"
167
+ FAIL=1
168
+ fi
169
+
170
+ # --- stale branch (doesn't contain main's tip): nothing moves -------------
171
+ STALEREPO="$TMP/stalerepo"
172
+ mig_repo "$STALEREPO"
173
+ git -C "$STALEREPO" branch old-branch
174
+ git -C "$STALEREPO" -c user.email=t@t.t -c user.name=t commit -q --allow-empty -m "main moves on"
175
+ write_main_devlog "$STALEREPO/.devlog/devlog.md" 1:DONE 2:IN_PROGRESS
176
+ cp "$STALEREPO/.devlog/devlog.md" "$TMP/stale-before.md"
177
+ echo "main handoff" > "$STALEREPO/.devlog/handoff.md"
178
+ git -C "$STALEREPO" checkout -q old-branch
179
+ devlog_resolve_paths "$STALEREPO"
180
+ if [ "$DEVLOG_FILE" = "$STALEREPO/.devlog/devlog.old-branch.md" ] \
181
+ && [ ! -f "$DEVLOG_FILE" ] \
182
+ && cmp -s "$STALEREPO/.devlog/devlog.md" "$TMP/stale-before.md" \
183
+ && [ -f "$STALEREPO/.devlog/handoff.md" ]; then
184
+ echo "PASS: checking out a branch that lacks main's tip moves nothing"
185
+ else
186
+ echo "FAIL: stale branch took main's tail (file=$DEVLOG_FILE)"
187
+ FAIL=1
188
+ fi
189
+
101
190
  # --- migration does NOT fire when .devlog/.enabled is absent (Fix 6) -----
102
191
  NOENABLEDREPO="$TMP/noenabledrepo"
103
192
  mkdir -p "$NOENABLEDREPO/.devlog"
@@ -114,6 +203,65 @@ else
114
203
  FAIL=1
115
204
  fi
116
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
+
117
265
  # --- HANDOFF_FILE mirrors DEVLOG_FILE naming (no rename migration) --------
118
266
  devlog_resolve_paths "$NONGIT"
119
267
  [ "$HANDOFF_FILE" = "$NONGIT/.devlog/handoff.md" ] \
@@ -140,23 +288,5 @@ devlog_resolve_paths "$DETACHEDREPO"
140
288
  && echo "PASS: detached HANDOFF_FILE uses worktree dirname" \
141
289
  || { echo "FAIL: detached HANDOFF_FILE=$HANDOFF_FILE"; FAIL=1; }
142
290
 
143
- # migration must NOT move handoff.md
144
- MIGHO="$TMP/mighandoff"
145
- mkdir -p "$MIGHO/.devlog"
146
- : > "$MIGHO/.devlog/.enabled"
147
- git_setup "$MIGHO"
148
- git -C "$MIGHO" checkout -q -b feature-h
149
- echo "old handoff" > "$MIGHO/.devlog/handoff.md"
150
- echo "## Round 1" > "$MIGHO/.devlog/devlog.md"
151
- devlog_resolve_paths "$MIGHO"
152
- if [ -f "$MIGHO/.devlog/handoff.md" ] \
153
- && [ ! -f "$MIGHO/.devlog/handoff.feature-h.md" ] \
154
- && [ "$HANDOFF_FILE" = "$MIGHO/.devlog/handoff.feature-h.md" ]; then
155
- echo "PASS: handoff.md is not renamed on first branch resolve"
156
- else
157
- echo "FAIL: handoff migration diverged (HANDOFF_FILE=$HANDOFF_FILE)"
158
- FAIL=1
159
- fi
160
-
161
291
  if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
162
292
  else echo "Some checks FAILED."; exit 1; fi
@@ -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.31.1",
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/devlog.md` 已有內容時,會把它改名(非複製)成該分支的檔案。細節見 `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