devlog-tracker 0.32.0 → 0.33.1
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 +96 -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 +465 -0
- package/core/scripts/keep-all.sh +30 -0
- package/core/scripts/keep-all.test.js +230 -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
|
@@ -0,0 +1,230 @@
|
|
|
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 deletes a branch file it emptied, but not an active branch or devlog.md', () => {
|
|
139
|
+
const d = setup({
|
|
140
|
+
'devlog.feat-main.md': '<!-- devlog-origin: branch=feat/main -->\n\n' + round(1, '2026-09-05T10:00:00+0800', 'IN_PROGRESS'),
|
|
141
|
+
'devlog.md': '# proj\n\n' + round(1, '2026-09-01T10:00:00+0800', 'DONE'),
|
|
142
|
+
'devlog.old.md': '<!-- devlog-origin: branch=old -->\n\n' + round(1, '2026-09-02T10:00:00+0800', 'DONE'),
|
|
143
|
+
'devlog.keep-me.md': '<!-- devlog-origin: branch=keep-me -->\n\n' + round(1, '2026-09-03T10:00:00+0800', 'DONE') +
|
|
144
|
+
'\n' + round(2, '2026-09-03T11:00:00+0800', 'DONE'),
|
|
145
|
+
});
|
|
146
|
+
const c = ctx(d, { current: 'devlog.feat-main.md', open: '1' });
|
|
147
|
+
const s = scan(c);
|
|
148
|
+
const ids = [...idsOf(s, 'devlog.md', [1]), ...idsOf(s, 'devlog.old.md', [1]), ...idsOf(s, 'devlog.keep-me.md', [1])];
|
|
149
|
+
const out = apply(c, `alpha\t\t${ids.join(',')}\n`, s.fingerprint, s.rounds.length);
|
|
150
|
+
assert.ok(!exists(d, 'devlog.old.md'), 'emptied branch file deleted');
|
|
151
|
+
assert.match(out, /^DELETED=.*devlog\.old\.md$/m);
|
|
152
|
+
assert.ok(exists(d, 'devlog.keep-me.md'), 'branch file with rounds left stays');
|
|
153
|
+
assert.ok(exists(d, 'devlog.md'), 'devlog.md is never deleted');
|
|
154
|
+
const backup = out.match(/^BACKUP=(.*)$/m)[1];
|
|
155
|
+
assert.ok(fs.existsSync(path.join(backup, 'devlog.old.md')), 'deleted branch file is backed up');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('apply may reuse the name of a kept file it consumes', () => {
|
|
159
|
+
const d = world();
|
|
160
|
+
const c = ctx(d);
|
|
161
|
+
const s = scan(c);
|
|
162
|
+
const all = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])];
|
|
163
|
+
apply(c, `topic\t\t${all.join(',')}\n`, s.fingerprint, s.rounds.length);
|
|
164
|
+
assert.match(read(d, 'devlog.topic.md'), /- source: keep-all/);
|
|
165
|
+
assert.match(read(d, 'devlog.md'), /- `devlog\.topic\.md`:keep-all,2 輪/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
function snapshot(d) {
|
|
169
|
+
return Object.fromEntries(fs.readdirSync(d).map(f => [f, fs.readFileSync(path.join(d, f), 'utf8')]));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const [label, planFor, pattern] of [
|
|
173
|
+
['uncovered kept round', s => `a\t\t${idsOf(s, 'devlog.topic.md', [5])}\n`, /devlog\.other\.md/],
|
|
174
|
+
['id in two segments', s => {
|
|
175
|
+
const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
|
|
176
|
+
return `a\t\t${k}\nb\t\t${idsOf(s, 'devlog.topic.md', [5])}\n`;
|
|
177
|
+
}, /重複/],
|
|
178
|
+
['unmovable round', s => {
|
|
179
|
+
const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
|
|
180
|
+
return `a\t\t${k},${idsOf(s, 'devlog.feat-x.md', [2])}\n`;
|
|
181
|
+
}, /不可搬/],
|
|
182
|
+
['collision with an existing branch file', s => {
|
|
183
|
+
const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
|
|
184
|
+
return `feat-x\t\t${k}\n`;
|
|
185
|
+
}, /已存在/],
|
|
186
|
+
['reserved name', s => {
|
|
187
|
+
const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
|
|
188
|
+
return `archive\t\t${k}\n`;
|
|
189
|
+
}, /檔名無效/],
|
|
190
|
+
]) {
|
|
191
|
+
test(`apply rejects ${label} and writes nothing`, () => {
|
|
192
|
+
const d = world();
|
|
193
|
+
const c = ctx(d);
|
|
194
|
+
const s = scan(c);
|
|
195
|
+
const before = snapshot(d);
|
|
196
|
+
assert.throws(() => apply(c, planFor(s), s.fingerprint, s.rounds.length), pattern);
|
|
197
|
+
assert.deepEqual(snapshot(d), before);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test('apply refuses when a scanned round changed, but tolerates a newly appended round', () => {
|
|
202
|
+
const d = world();
|
|
203
|
+
const c = ctx(d);
|
|
204
|
+
const s = scan(c);
|
|
205
|
+
const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9])].join(',');
|
|
206
|
+
fs.appendFileSync(path.join(d, 'devlog.md'), '\n' + round(3, '2026-09-10T10:00:00+0800', 'DONE'));
|
|
207
|
+
const d2 = world();
|
|
208
|
+
const c2 = ctx(d2);
|
|
209
|
+
const s2 = scan(c2);
|
|
210
|
+
fs.writeFileSync(path.join(d2, 'devlog.archive.md'), round(1, '2026-08-01T10:00:00+0800', 'DONE', 'edited'));
|
|
211
|
+
const before = snapshot(d2);
|
|
212
|
+
assert.throws(() => apply(c2, `a\t\t${k}\n`, s2.fingerprint, s2.rounds.length), /fingerprint/);
|
|
213
|
+
assert.deepEqual(snapshot(d2), before);
|
|
214
|
+
apply(c, `a\t\t${k}\n`, s.fingerprint, s.rounds.length);
|
|
215
|
+
assert.match(read(d, 'devlog.md'), /Round 3 —/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('apply clears .span-open only when its round moved out of current', () => {
|
|
219
|
+
const d = world();
|
|
220
|
+
fs.writeFileSync(path.join(d, '.span-open'), '{"round": 1, "ticks": 0}\n');
|
|
221
|
+
const c = ctx(d);
|
|
222
|
+
const s = scan(c);
|
|
223
|
+
const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9]), ...idsOf(s, 'devlog.md', [2])].join(',');
|
|
224
|
+
apply(c, `a\t\t${k}\n`, s.fingerprint, s.rounds.length);
|
|
225
|
+
assert.ok(exists(d, '.span-open'));
|
|
226
|
+
const s2 = scan(c);
|
|
227
|
+
const k2 = [...s2.rounds.filter(r => r.file === 'devlog.a.md').map(r => r.id), ...idsOf(s2, 'devlog.md', [1])];
|
|
228
|
+
apply(c, `b\t\t${k2.join(',')}\n`, s2.fingerprint, s2.rounds.length);
|
|
229
|
+
assert.ok(!exists(d, '.span-open'));
|
|
230
|
+
});
|
|
@@ -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 "emptied merged/gone branch files deleted" '[ ! -e "$R/.devlog/devlog.feat-merged.md" ] && [ ! -e "$R/.devlog/devlog.feat-deleted.md" ] && grep -q "^DELETED=.*devlog.feat-merged.md" <<<"$APPLY"'
|
|
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` 之後的未完成尾巴與開著的那一輪不搬;搬空的分支檔(分支非 `active`)會刪除,目前主檔與 `devlog.md` 不刪。
|
|
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
|
|