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,465 @@
|
|
|
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
|
+
const touchedFiles = new Set();
|
|
271
|
+
|
|
272
|
+
for (const src of s.sources) {
|
|
273
|
+
const { lines, blocks, firstBlock } = src.parsed;
|
|
274
|
+
const roundOf = new Map(s.rounds.filter(r => r.file === src.file).map(r => [r.block, r]));
|
|
275
|
+
const stay = [];
|
|
276
|
+
let prev = null; // last Round block's {seg, id}
|
|
277
|
+
let pendingKept = []; // kept-file blocks before the first Round
|
|
278
|
+
let touched = false;
|
|
279
|
+
blocks.forEach((b, k) => {
|
|
280
|
+
const blines = lines.slice(b.start, b.end + 1);
|
|
281
|
+
const r = roundOf.get(b);
|
|
282
|
+
if (r) {
|
|
283
|
+
const seg = owner.get(r.id);
|
|
284
|
+
prev = seg ? { seg, id: r.id, sub: 0 } : null;
|
|
285
|
+
if (seg) {
|
|
286
|
+
touched = true;
|
|
287
|
+
pieces.get(seg).push({ id: r.id, sub: 0, lines: blines });
|
|
288
|
+
if (src.kind === 'current') movedFromCurrent.add(r.round);
|
|
289
|
+
for (const p of pendingKept) pieces.get(seg).push({ id: r.id, sub: -1 + p.k / 1e6, lines: p.lines });
|
|
290
|
+
pendingKept = [];
|
|
291
|
+
} else stay.push(blines);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const follows = src.kind === 'kept' || b.heading.startsWith('## Checkpoint');
|
|
295
|
+
if (follows && prev) {
|
|
296
|
+
prev.sub += 1;
|
|
297
|
+
pieces.get(prev.seg).push({ id: prev.id, sub: prev.sub, lines: blines });
|
|
298
|
+
touched = true;
|
|
299
|
+
} else if (src.kind === 'kept' && !prev) {
|
|
300
|
+
pendingKept.push({ k, lines: blines });
|
|
301
|
+
} else {
|
|
302
|
+
stay.push(blines);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (src.kind === 'kept' && consumed.has(src.file)) {
|
|
307
|
+
const first = s.rounds.find(r => r.file === src.file);
|
|
308
|
+
const hEnd = keptHeaderEnd(lines, firstBlock);
|
|
309
|
+
prefixes.get(owner.get(first.id)).push({ id: first.id, lines: lines.slice(hEnd, firstBlock) });
|
|
310
|
+
rewritten.set(src.file, null);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!touched) continue;
|
|
314
|
+
touchedFiles.add(src.file);
|
|
315
|
+
const text = joinPieces([lines.slice(0, firstBlock), ...stay]);
|
|
316
|
+
const left = stay.length && parseBlocks(text).blocks.some(b => b.round !== null);
|
|
317
|
+
rewritten.set(src.file, src.kind === 'archive' && !left && trimBlank(lines.slice(0, firstBlock)).length === 0 ? null : text);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Kept index: drop lines pointing at deleted kept files everywhere, add
|
|
321
|
+
// the new files to the current file's index.
|
|
322
|
+
const deletedKept = [...consumed].filter(f => !segs.some(seg => seg.file === f));
|
|
323
|
+
const reused = [...consumed].filter(f => segs.some(seg => seg.file === f));
|
|
324
|
+
const staleIndex = new Set([...deletedKept, ...reused]);
|
|
325
|
+
const keptAt = nowIso();
|
|
326
|
+
const newIndex = segs.map(seg => {
|
|
327
|
+
const n = pieces.get(seg).filter(p => p.sub === 0).length;
|
|
328
|
+
return `- \`${seg.file}\`:keep-all,${n} 輪,kept_at ${keptAt}${seg.desc ? `,${seg.desc}` : ''}`;
|
|
329
|
+
});
|
|
330
|
+
for (const src of s.sources) {
|
|
331
|
+
if (rewritten.get(src.file) === null) continue;
|
|
332
|
+
const base = rewritten.has(src.file) ? rewritten.get(src.file) : src.text;
|
|
333
|
+
const updated = rewriteIndex(base, staleIndex, src.kind === 'current' ? newIndex : []);
|
|
334
|
+
if (updated !== base) rewritten.set(src.file, updated);
|
|
335
|
+
}
|
|
336
|
+
// A branch file keep-all emptied (only its origin marker left) is
|
|
337
|
+
// deleted, unless the branch is still active: checking it out again
|
|
338
|
+
// would migrate main's unfinished tail into a fresh file.
|
|
339
|
+
for (const src of s.sources) {
|
|
340
|
+
const t = rewritten.get(src.file);
|
|
341
|
+
if (src.kind !== 'branch' || src.file === 'devlog.md' || src.state === 'active' || typeof t !== 'string') continue;
|
|
342
|
+
if (!touchedFiles.has(src.file)) continue;
|
|
343
|
+
if (t.split('\n').every(l => l.trim() === '' || ORIGIN.test(l))) rewritten.set(src.file, null);
|
|
344
|
+
}
|
|
345
|
+
if (!s.sources.some(x => x.kind === 'current')) {
|
|
346
|
+
const head = c.origin ? `<!-- devlog-origin: ${c.origin} -->\n` : '';
|
|
347
|
+
rewritten.set(c.current, rewriteIndex(head, staleIndex, newIndex));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Build targets.
|
|
351
|
+
const targets = new Map();
|
|
352
|
+
for (const seg of segs) {
|
|
353
|
+
const ps = pieces.get(seg).sort((a, b) => a.id - b.id || a.sub - b.sub);
|
|
354
|
+
const perFile = new Map();
|
|
355
|
+
for (const id of seg.ids) {
|
|
356
|
+
const r = byId.get(id);
|
|
357
|
+
if (!perFile.has(r.file)) perFile.set(r.file, []);
|
|
358
|
+
perFile.get(r.file).push(r.round);
|
|
359
|
+
}
|
|
360
|
+
const header = ['# Kept log', '', '- source: keep-all',
|
|
361
|
+
...[...perFile].map(([f, ns]) => `- rounds: ${f} ${ranges(ns)}`), `- kept_at: ${keptAt}`];
|
|
362
|
+
const pre = prefixes.get(seg).sort((a, b) => a.id - b.id).map(p => p.lines);
|
|
363
|
+
targets.set(seg.file, joinPieces([header, ...pre, ...ps.map(p => p.lines)]));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Verify: every moved Round lands exactly once, none stays behind.
|
|
367
|
+
const movedCount = owner.size;
|
|
368
|
+
let inTargets = 0;
|
|
369
|
+
for (const text of targets.values()) inTargets += parseBlocks(text).blocks.filter(b => b.round !== null).length;
|
|
370
|
+
let before = 0, after = 0;
|
|
371
|
+
for (const src of s.sources) {
|
|
372
|
+
if (!rewritten.has(src.file)) continue;
|
|
373
|
+
before += src.parsed.blocks.filter(b => b.round !== null).length;
|
|
374
|
+
const t = rewritten.get(src.file);
|
|
375
|
+
if (t !== null) after += parseBlocks(t).blocks.filter(b => b.round !== null).length;
|
|
376
|
+
}
|
|
377
|
+
if (inTargets !== movedCount || before - after !== movedCount) {
|
|
378
|
+
throw new Error(`搬移驗證失敗:預期 ${movedCount} 輪,目標檔 ${inTargets} 輪,來源減少 ${before - after} 輪;未寫入任何檔`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Backup, then commit: targets first, then rewritten sources, then deletions.
|
|
382
|
+
const stamp = keptAt.slice(0, 19).replace(/[-:]/g, '').replace('T', '-');
|
|
383
|
+
const backup = path.join(c.devlogDir, '.keep-all-backup', stamp);
|
|
384
|
+
fs.mkdirSync(backup, { recursive: true });
|
|
385
|
+
for (const file of rewritten.keys()) {
|
|
386
|
+
const p = path.join(c.devlogDir, file);
|
|
387
|
+
if (fs.existsSync(p)) fs.copyFileSync(p, path.join(backup, file));
|
|
388
|
+
}
|
|
389
|
+
const out = [];
|
|
390
|
+
try {
|
|
391
|
+
const writeAtomic = (file, text) => {
|
|
392
|
+
const p = path.join(c.devlogDir, file);
|
|
393
|
+
fs.writeFileSync(`${p}.keep-all.tmp`, text);
|
|
394
|
+
fs.renameSync(`${p}.keep-all.tmp`, p);
|
|
395
|
+
};
|
|
396
|
+
for (const [file, text] of targets) {
|
|
397
|
+
writeAtomic(file, text);
|
|
398
|
+
out.push(`KEPT=${path.join(c.devlogDir, file)} ROUNDS=${pieces.get(segs.find(x => x.file === file)).filter(p => p.sub === 0).length}`);
|
|
399
|
+
}
|
|
400
|
+
for (const [file, text] of rewritten) {
|
|
401
|
+
if (text === null) {
|
|
402
|
+
if (targets.has(file)) continue; // reused name: already replaced
|
|
403
|
+
fs.rmSync(path.join(c.devlogDir, file), { force: true });
|
|
404
|
+
out.push(`DELETED=${path.join(c.devlogDir, file)}`);
|
|
405
|
+
} else writeAtomic(file, text);
|
|
406
|
+
}
|
|
407
|
+
} catch (e) {
|
|
408
|
+
throw new Error(`寫入途中失敗:${e.message}\n原始檔備份在 ${backup}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const span = path.join(c.devlogDir, '.span-open');
|
|
412
|
+
if (fs.existsSync(span)) {
|
|
413
|
+
const m = fs.readFileSync(span, 'utf8').match(/"round"\s*:\s*(\d+)/);
|
|
414
|
+
if (m && movedFromCurrent.has(Number(m[1]))) fs.rmSync(span, { force: true });
|
|
415
|
+
}
|
|
416
|
+
out.push(`BACKUP=${backup}`);
|
|
417
|
+
return out.join('\n') + '\n';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Removes index lines naming a file in `stale`, appends `add` to the
|
|
421
|
+
// `## Kept 索引` block (created at the end if missing). A block left
|
|
422
|
+
// with no lines is dropped.
|
|
423
|
+
function rewriteIndex(text, stale, add) {
|
|
424
|
+
const { lines, blocks, firstBlock } = parseBlocks(text);
|
|
425
|
+
const idx = blocks.find(b => b.heading.startsWith('## Kept 索引'));
|
|
426
|
+
const named = l => stale.has((l.match(/`(devlog\.[^`]+\.md)`/) || [])[1]);
|
|
427
|
+
if (!idx) return add.length ? joinPieces([lines, ['## Kept 索引', ...add]]) : text;
|
|
428
|
+
const body = lines.slice(idx.start + 1, idx.end + 1);
|
|
429
|
+
if (!add.length && !body.some(named)) return text;
|
|
430
|
+
const kept = [...trimBlank(body.filter(l => !named(l))), ...add];
|
|
431
|
+
const pieces = [lines.slice(0, firstBlock)];
|
|
432
|
+
for (const b of blocks) {
|
|
433
|
+
if (b !== idx) pieces.push(lines.slice(b.start, b.end + 1));
|
|
434
|
+
else if (kept.length) pieces.push(['## Kept 索引', ...kept]);
|
|
435
|
+
}
|
|
436
|
+
return joinPieces(pieces);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function main(argv) {
|
|
440
|
+
const opts = {};
|
|
441
|
+
for (let i = 0; i < argv.length; i++) {
|
|
442
|
+
const k = argv[i];
|
|
443
|
+
if (k === '--scan') opts.scan = true;
|
|
444
|
+
else if (['--dir', '--project', '--current', '--open', '--origin', '--apply', '--fingerprint', '--count'].includes(k)) opts[k.slice(2)] = argv[++i] ?? '';
|
|
445
|
+
else { process.stderr.write(`未知參數:${k}\n`); return 2; }
|
|
446
|
+
}
|
|
447
|
+
const c = { devlogDir: opts.dir, projectDir: opts.project, current: opts.current, open: opts.open || '', origin: opts.origin || '' };
|
|
448
|
+
try {
|
|
449
|
+
if (opts.scan) { process.stdout.write(formatScan(scan(c))); return 0; }
|
|
450
|
+
if (opts.apply) {
|
|
451
|
+
const plan = fs.readFileSync(opts.apply, 'utf8');
|
|
452
|
+
process.stdout.write(apply(c, plan, opts.fingerprint || '', Number(opts.count || 0)));
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
process.stderr.write('需要 --scan 或 --apply\n');
|
|
456
|
+
return 2;
|
|
457
|
+
} catch (e) {
|
|
458
|
+
process.stderr.write(`${e.message}\n`);
|
|
459
|
+
return 1;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
module.exports = { parseBlocks, scan, apply, formatScan, normalizeName, parsePlan };
|
|
464
|
+
|
|
465
|
+
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:-}"
|