devlog-tracker 0.29.2 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -7
- package/README.zh-TW.md +23 -7
- package/bin/devlog-tracker.js +14 -1
- package/claude/hooks.json +21 -0
- package/cli/agents-md.js +3 -0
- package/cli/agents-md.test.js +32 -0
- package/cli/core-script.js +25 -0
- package/cli/core-script.test.js +62 -0
- package/cli/platforms/claude.js +3 -0
- package/commands/overview.md +1 -1
- package/commands/pr.md +61 -0
- package/commands/promote.md +54 -0
- package/commands/report.md +23 -0
- package/commands/timeline.md +18 -0
- package/core/scripts/json-field.sh +16 -0
- package/core/scripts/lessons-subagent-done.sh +36 -0
- package/core/scripts/lessons-subagent-start.sh +28 -0
- package/core/scripts/pr-context.sh +64 -0
- package/core/scripts/promote-sources.sh +40 -0
- package/core/scripts/promote-target.sh +37 -0
- package/core/scripts/promote-write.sh +74 -0
- package/core/scripts/report-devlog.sh +140 -0
- package/core/scripts/report-scan.awk +74 -0
- package/core/scripts/round-start.sh +15 -1
- package/core/scripts/tests/test-json-field.sh +5 -0
- package/core/scripts/tests/test-lessons-subagent-hooks.sh +95 -0
- package/core/scripts/tests/test-pr-context.sh +107 -0
- package/core/scripts/tests/test-promote.sh +158 -0
- package/core/scripts/tests/test-report.sh +197 -0
- package/core/scripts/tests/test-round-start.sh +65 -0
- package/core/scripts/tests/test-timeline.sh +65 -0
- package/core/scripts/timeline-devlog.sh +41 -0
- package/core/scripts/timeline-render.js +247 -0
- package/core/scripts/timeline-render.test.js +101 -0
- package/package.json +2 -2
- package/skills/devlog-tracker/SKILL.md +5 -3
- package/skills/devlog-tracker/references/lessons-mode.md +10 -4
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// report-devlog.sh --json --rounds 的輸出 → 一份自足的 HTML 時間軸
|
|
4
|
+
// (docs/design/read-side-and-promote.md C)。零相依;所有 devlog 文字先跳脫
|
|
5
|
+
// 再轉 Markdown 子集,不輸出任何來自 devlog 的原始 HTML。
|
|
6
|
+
|
|
7
|
+
const STATUSES = ['DONE', 'IN_PROGRESS', 'BLOCKED', 'INTERRUPTED'];
|
|
8
|
+
|
|
9
|
+
function escapeHtml(s) {
|
|
10
|
+
return String(s)
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// http(s) 與相對路徑可以當連結;其他 scheme(javascript:、data: …)與
|
|
19
|
+
// protocol-relative(//host)一律不給連結。
|
|
20
|
+
function safeHref(url) {
|
|
21
|
+
const u = String(url).trim();
|
|
22
|
+
// A leading/embedded C0 control char (e.g. \u0001) hides the real scheme
|
|
23
|
+
// from the tests below, but browsers strip it and still run it — reject.
|
|
24
|
+
if (/[\u0000-\u001F\u007F]/.test(u)) return null;
|
|
25
|
+
if (/^https?:\/\//i.test(u)) return u;
|
|
26
|
+
if (u.startsWith('//')) return null;
|
|
27
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(u)) return null;
|
|
28
|
+
return u;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function emphasis(raw) {
|
|
32
|
+
return escapeHtml(raw).replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function inline(text) {
|
|
36
|
+
return String(text)
|
|
37
|
+
.split(/(`[^`]+`)/)
|
|
38
|
+
.map((part) => {
|
|
39
|
+
if (/^`[^`]+`$/.test(part)) return `<code>${escapeHtml(part.slice(1, -1))}</code>`;
|
|
40
|
+
const re = /\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
41
|
+
let out = '';
|
|
42
|
+
let last = 0;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m = re.exec(part))) {
|
|
45
|
+
out += emphasis(part.slice(last, m.index));
|
|
46
|
+
const href = safeHref(m[2]);
|
|
47
|
+
out += href === null ? emphasis(m[0]) : `<a href="${escapeHtml(href)}">${emphasis(m[1])}</a>`;
|
|
48
|
+
last = re.lastIndex;
|
|
49
|
+
}
|
|
50
|
+
return out + emphasis(part.slice(last));
|
|
51
|
+
})
|
|
52
|
+
.join('');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FENCE = /^\s*```/;
|
|
56
|
+
const HEADING = /^(#{1,4})\s+(.*)$/;
|
|
57
|
+
const LIST_ITEM = /^(\s*)([-*]|\d+\.)\s+(.*)$/;
|
|
58
|
+
const TABLE_ROW = /^\s*\|/;
|
|
59
|
+
const TABLE_SEP = /^\s*\|?\s*:?-{3,}/;
|
|
60
|
+
|
|
61
|
+
function tableCells(line) {
|
|
62
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderMarkdown(md) {
|
|
66
|
+
const lines = String(md || '').split('\n');
|
|
67
|
+
const out = [];
|
|
68
|
+
let i = 0;
|
|
69
|
+
while (i < lines.length) {
|
|
70
|
+
const line = lines[i];
|
|
71
|
+
if (FENCE.test(line)) {
|
|
72
|
+
const buf = [];
|
|
73
|
+
i++;
|
|
74
|
+
while (i < lines.length && !FENCE.test(lines[i])) buf.push(lines[i++]);
|
|
75
|
+
i++;
|
|
76
|
+
out.push(`<pre><code>${escapeHtml(buf.join('\n'))}</code></pre>`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const h = HEADING.exec(line);
|
|
80
|
+
if (h) {
|
|
81
|
+
const level = Math.min(h[1].length + 2, 6);
|
|
82
|
+
out.push(`<h${level}>${inline(h[2])}</h${level}>`);
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (TABLE_ROW.test(line) && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) {
|
|
87
|
+
const head = tableCells(line);
|
|
88
|
+
i += 2;
|
|
89
|
+
const rows = [];
|
|
90
|
+
while (i < lines.length && TABLE_ROW.test(lines[i])) rows.push(tableCells(lines[i++]));
|
|
91
|
+
const th = head.map((c) => `<th>${inline(c)}</th>`).join('');
|
|
92
|
+
const tb = rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('');
|
|
93
|
+
out.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (LIST_ITEM.test(line)) {
|
|
97
|
+
const m = LIST_ITEM.exec(line);
|
|
98
|
+
const ordered = /\d/.test(m[2]);
|
|
99
|
+
const items = [];
|
|
100
|
+
while (i < lines.length) {
|
|
101
|
+
const m = LIST_ITEM.exec(lines[i]);
|
|
102
|
+
if (m) {
|
|
103
|
+
const isOrdered = /\d/.test(m[2]);
|
|
104
|
+
if (isOrdered !== ordered) break; // Different list type, stop here
|
|
105
|
+
items.push({ nested: m[1].length >= 2, text: m[3] });
|
|
106
|
+
i++;
|
|
107
|
+
} else if (items.length && /^\s{2,}\S/.test(lines[i])) {
|
|
108
|
+
items[items.length - 1].text += ' ' + lines[i].trim();
|
|
109
|
+
i++;
|
|
110
|
+
} else break;
|
|
111
|
+
}
|
|
112
|
+
const tag = ordered ? 'ol' : 'ul';
|
|
113
|
+
const lis = items.map((it) => `<li${it.nested ? ' class="nested"' : ''}>${inline(it.text)}</li>`).join('');
|
|
114
|
+
out.push(`<${tag}>${lis}</${tag}>`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (/^\s*$/.test(line)) {
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// 至少吃掉目前這一行,避免「像表格但不是表格」的行卡住迴圈。
|
|
122
|
+
const para = [line];
|
|
123
|
+
i++;
|
|
124
|
+
while (
|
|
125
|
+
i < lines.length &&
|
|
126
|
+
!/^\s*$/.test(lines[i]) &&
|
|
127
|
+
!FENCE.test(lines[i]) &&
|
|
128
|
+
!HEADING.test(lines[i]) &&
|
|
129
|
+
!LIST_ITEM.test(lines[i]) &&
|
|
130
|
+
!TABLE_ROW.test(lines[i])
|
|
131
|
+
) {
|
|
132
|
+
para.push(lines[i++]);
|
|
133
|
+
}
|
|
134
|
+
out.push(`<p>${para.map(inline).join('<br>')}</p>`);
|
|
135
|
+
}
|
|
136
|
+
return out.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function statusOf(s) {
|
|
140
|
+
return STATUSES.includes(s) ? s : 'NONE';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function roundCard(r) {
|
|
144
|
+
const st = statusOf(r.status);
|
|
145
|
+
const parts = [];
|
|
146
|
+
if (r.input) parts.push(`<h4>User Input</h4><pre><code>${escapeHtml(r.input)}</code></pre>`);
|
|
147
|
+
if (r.reply) parts.push(`<h4>Reply</h4>${renderMarkdown(r.reply)}`);
|
|
148
|
+
if (r.handoff) parts.push(`<h4>Handoff</h4>${renderMarkdown(r.handoff)}`);
|
|
149
|
+
for (const seg of r.segments || []) parts.push(`<div class="seg">${renderMarkdown('#### ' + seg)}</div>`);
|
|
150
|
+
const details = parts.length ? `<details><summary>詳細</summary>${parts.join('\n')}</details>` : '';
|
|
151
|
+
return `<article class="card round" data-status="${st}" data-branch="${escapeHtml(r.branch)}">
|
|
152
|
+
<header><span class="badge b-${st}">${st === 'NONE' ? '—' : st}</span><strong>Round ${Number(r.n)}</strong><time>${escapeHtml(r.at || '')}</time><span class="branch">${escapeHtml(r.branch)}</span></header>
|
|
153
|
+
<div class="summary">${renderMarkdown(r.summary)}</div>
|
|
154
|
+
${details}
|
|
155
|
+
</article>`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function checkpointCard(c) {
|
|
159
|
+
return `<article class="card checkpoint" data-status="CHECKPOINT" data-branch="${escapeHtml(c.branch)}">
|
|
160
|
+
<header><span class="badge b-CHECKPOINT">CHECKPOINT</span><strong>${escapeHtml(c.heading)}</strong><span class="branch">${escapeHtml(c.branch)}</span></header>
|
|
161
|
+
<details open><summary>內容</summary>${renderMarkdown(c.body)}</details>
|
|
162
|
+
</article>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Round 依時間排序;Checkpoint 沒有時間,排在同一檔案中位於它之前的最後一個
|
|
166
|
+
// Round 後面。
|
|
167
|
+
function timelineItems(data) {
|
|
168
|
+
const rounds = data.rounds || [];
|
|
169
|
+
const items = rounds.map((r, idx) => ({ key: r.at || '', order: 0, idx, html: roundCard(r) }));
|
|
170
|
+
(data.checkpoint_blocks || []).forEach((c, idx) => {
|
|
171
|
+
let prev = null;
|
|
172
|
+
for (const r of rounds) {
|
|
173
|
+
if (r.file === c.file && r.line < c.line && (!prev || r.line > prev.line)) prev = r;
|
|
174
|
+
}
|
|
175
|
+
items.push({ key: prev ? prev.at || '' : '', order: 1, idx, html: checkpointCard(c) });
|
|
176
|
+
});
|
|
177
|
+
items.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : a.order - b.order || a.idx - b.idx));
|
|
178
|
+
return items.map((it) => it.html);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const CSS = `:root{--bg:#f7f7f5;--fg:#1f2328;--muted:#656d76;--card:#fff;--line:#d0d7de;--code:#eff1f3;
|
|
182
|
+
--done:#1a7f37;--progress:#0969da;--blocked:#cf222e;--interrupted:#9a6700;--checkpoint:#8250df;--none:#6e7781}
|
|
183
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8d96a0;--card:#161b22;--line:#30363d;--code:#1f242c;
|
|
184
|
+
--done:#3fb950;--progress:#4493f8;--blocked:#f85149;--interrupted:#d29922;--checkpoint:#ab7df8;--none:#8d96a0}}
|
|
185
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 system-ui,-apple-system,"Segoe UI","Noto Sans TC",sans-serif}
|
|
186
|
+
main{max-width:920px;margin:0 auto;padding:24px 16px 64px}h1{font-size:22px;margin:0 0 4px}.meta{color:var(--muted);font-size:13px;margin-bottom:16px}
|
|
187
|
+
.stats{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px}.stat{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:6px 12px;font-size:13px}
|
|
188
|
+
.stat b{font-size:17px;margin-right:4px}.filters{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}
|
|
189
|
+
.filters input,.filters select{font:inherit;padding:6px 10px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg)}
|
|
190
|
+
.filters input{flex:1;min-width:160px}.card{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--none);border-radius:8px;padding:12px 16px;margin-bottom:12px;overflow-wrap:anywhere}
|
|
191
|
+
.card[data-status=DONE]{border-left-color:var(--done)}.card[data-status=IN_PROGRESS]{border-left-color:var(--progress)}
|
|
192
|
+
.card[data-status=BLOCKED]{border-left-color:var(--blocked)}.card[data-status=INTERRUPTED]{border-left-color:var(--interrupted)}
|
|
193
|
+
.card[data-status=CHECKPOINT]{border-left-color:var(--checkpoint);background:color-mix(in srgb,var(--checkpoint) 6%,var(--card))}
|
|
194
|
+
.card header{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:14px}.card time,.card .branch{color:var(--muted);font-size:12px}
|
|
195
|
+
.badge{font-size:11px;font-weight:600;padding:1px 8px;border-radius:999px;color:#fff;background:var(--none)}
|
|
196
|
+
.b-DONE{background:var(--done)}.b-IN_PROGRESS{background:var(--progress)}.b-BLOCKED{background:var(--blocked)}.b-INTERRUPTED{background:var(--interrupted)}.b-CHECKPOINT{background:var(--checkpoint)}
|
|
197
|
+
details summary{cursor:pointer;color:var(--muted);font-size:13px;margin-top:6px}code{background:var(--code);padding:1px 4px;border-radius:4px;font-size:.9em}
|
|
198
|
+
pre{background:var(--code);padding:10px;border-radius:6px;overflow-x:auto}pre code{padding:0;background:none}
|
|
199
|
+
table{border-collapse:collapse;display:block;overflow-x:auto}th,td{border:1px solid var(--line);padding:4px 8px;text-align:left}
|
|
200
|
+
li.nested{margin-left:1.5em}h3,h4,h5,h6{margin:12px 0 4px}.seg{border-top:1px dashed var(--line);margin-top:8px}.empty{color:var(--muted)}`;
|
|
201
|
+
|
|
202
|
+
const SCRIPT = `(function(){var q=document.getElementById('q'),s=document.getElementById('fs'),b=document.getElementById('fb');
|
|
203
|
+
function f(){var t=q.value.toLowerCase(),sv=s.value,bv=b.value;document.querySelectorAll('.card').forEach(function(c){
|
|
204
|
+
c.hidden=!((!sv||c.dataset.status===sv)&&(!bv||c.dataset.branch===bv)&&(!t||c.textContent.toLowerCase().indexOf(t)>=0));});}
|
|
205
|
+
[q,s,b].forEach(function(e){e.addEventListener('input',f);});})();`;
|
|
206
|
+
|
|
207
|
+
function render(data) {
|
|
208
|
+
const d = data || {};
|
|
209
|
+
const branches = [...new Set([...(d.rounds || []), ...(d.checkpoint_blocks || [])].map((x) => x.branch))].sort();
|
|
210
|
+
const stat = (label, n) => `<span class="stat"><b>${Number(n) || 0}</b>${label}</span>`;
|
|
211
|
+
const range = d.first_round_at ? `${escapeHtml(d.first_round_at)} → ${escapeHtml(d.last_round_at || '')}` : '沒有 Round';
|
|
212
|
+
const items = timelineItems(d);
|
|
213
|
+
const opt = (v, label) => `<option value="${escapeHtml(v)}">${escapeHtml(label)}</option>`;
|
|
214
|
+
return `<!doctype html>
|
|
215
|
+
<html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
216
|
+
<title>Devlog Timeline</title><style>${CSS}</style></head><body><main>
|
|
217
|
+
<h1>Devlog Timeline</h1>
|
|
218
|
+
<div class="meta">branch ${escapeHtml(d.branch || '')} · ${range}</div>
|
|
219
|
+
<div class="stats">${stat('Rounds', d.rounds_total)}${stat('DONE', d.status_done)}${stat('IN_PROGRESS', d.status_in_progress)}${stat('BLOCKED', d.status_blocked)}${stat('INTERRUPTED', d.status_interrupted)}${stat('Checkpoints', d.checkpoints)}</div>
|
|
220
|
+
<div class="filters"><input id="q" type="search" placeholder="關鍵字篩選">
|
|
221
|
+
<select id="fs">${opt('', '全部 Status')}${[...STATUSES, 'CHECKPOINT'].map((s) => opt(s, s)).join('')}</select>
|
|
222
|
+
<select id="fb">${opt('', '全部 branch')}${branches.map((b) => opt(b, b)).join('')}</select></div>
|
|
223
|
+
${items.length ? items.join('\n') : '<p class="empty">沒有 Round 可顯示。</p>'}
|
|
224
|
+
</main><script>${SCRIPT}</script></body></html>
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (require.main === module) {
|
|
229
|
+
let input = '';
|
|
230
|
+
process.stdin.setEncoding('utf8');
|
|
231
|
+
process.stdin.on('data', (chunk) => {
|
|
232
|
+
input += chunk;
|
|
233
|
+
});
|
|
234
|
+
process.stdin.on('end', () => {
|
|
235
|
+
let data;
|
|
236
|
+
try {
|
|
237
|
+
data = JSON.parse(input);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
process.stderr.write('INVALID_JSON\n');
|
|
240
|
+
process.exitCode = 1;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
process.stdout.write(render(data));
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = { render, renderMarkdown, inline, escapeHtml, safeHref, timelineItems };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const { render, renderMarkdown, safeHref, escapeHtml } = require('./timeline-render');
|
|
7
|
+
|
|
8
|
+
function round(extra) {
|
|
9
|
+
return { branch: 'main', file: 'devlog.md', line: 1, n: 1, at: '2026-09-01T10:00:00+0800',
|
|
10
|
+
status: 'DONE', summary: 's', reply: '', handoff: '', segments: [], ...extra };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('escapeHtml escapes the five HTML metacharacters', () => {
|
|
14
|
+
assert.equal(escapeHtml(`<a href="x">'&'</a>`), '<a href="x">'&'</a>');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('safeHref allows http(s) and relative, rejects other schemes', () => {
|
|
18
|
+
assert.equal(safeHref('https://example.com'), 'https://example.com');
|
|
19
|
+
assert.equal(safeHref('docs/design/x.md'), 'docs/design/x.md');
|
|
20
|
+
assert.equal(safeHref('javascript:alert(1)'), null);
|
|
21
|
+
assert.equal(safeHref('JaVaScRiPt:alert(1)'), null);
|
|
22
|
+
assert.equal(safeHref('data:text/html,x'), null);
|
|
23
|
+
assert.equal(safeHref('//evil.example'), null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('safeHref rejects a leading C0 control char that browsers strip', () => {
|
|
27
|
+
assert.equal(safeHref('\u0001javascript:alert(1)'), null);
|
|
28
|
+
const html = renderMarkdown('[x](\u0001javascript:alert(1))');
|
|
29
|
+
assert.ok(!/href=/i.test(html));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('renderMarkdown never emits raw HTML from content', () => {
|
|
33
|
+
const html = renderMarkdown('<script>alert(1)</script>\n<img src=x onerror=alert(1)>');
|
|
34
|
+
assert.ok(!html.includes('<script>'));
|
|
35
|
+
assert.ok(!html.includes('<img'));
|
|
36
|
+
assert.ok(html.includes('<script>'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('renderMarkdown neutralises javascript: links', () => {
|
|
40
|
+
const html = renderMarkdown('[x](javascript:alert(1)) [ok](https://a.example)');
|
|
41
|
+
assert.ok(!/href="javascript/i.test(html));
|
|
42
|
+
assert.ok(html.includes('<a href="https://a.example">ok</a>'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('renderMarkdown subset: headings, lists, fences, inline code, bold, tables', () => {
|
|
46
|
+
const html = renderMarkdown('#### 現況\n- a\n - b\n1. one\n\n```\n<b>\n```\nuse `x<y` and **bold**\n\n| h | i |\n|---|---|\n| 1 | 2 |');
|
|
47
|
+
assert.ok(html.includes('<h6>現況</h6>'));
|
|
48
|
+
assert.ok(html.includes('<ul><li>a</li><li class="nested">b</li></ul>'));
|
|
49
|
+
assert.ok(html.includes('<ol><li>one</li></ol>'));
|
|
50
|
+
assert.ok(html.includes('<pre><code><b></code></pre>'));
|
|
51
|
+
assert.ok(html.includes('<code>x<y</code>'));
|
|
52
|
+
assert.ok(html.includes('<strong>bold</strong>'));
|
|
53
|
+
assert.ok(html.includes('<th>h</th>') && html.includes('<td>2</td>'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a pipe line that is not a table does not hang', () => {
|
|
57
|
+
assert.equal(renderMarkdown('| not a table'), '<p>| not a table</p>');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('render: status badges, CJK, checkpoint after its preceding round', () => {
|
|
61
|
+
const html = render({
|
|
62
|
+
started: true, branch: 'main', rounds_total: 2, status_done: 1, status_blocked: 1,
|
|
63
|
+
first_round_at: '2026-09-01T10:00:00+0800', last_round_at: '2026-09-02T10:00:00+0800',
|
|
64
|
+
rounds: [
|
|
65
|
+
round({ n: 2, line: 20, at: '2026-09-02T10:00:00+0800', status: 'BLOCKED', summary: '卡住了' }),
|
|
66
|
+
round({ n: 1, line: 1, summary: '中文摘要', segments: ['段落 探索\nseg body'] }),
|
|
67
|
+
],
|
|
68
|
+
checkpoint_blocks: [{ branch: 'main', file: 'devlog.md', line: 10, heading: 'Checkpoint(Round 1-1)', body: '### 決策\n- d' }],
|
|
69
|
+
});
|
|
70
|
+
assert.ok(html.startsWith('<!doctype html>'));
|
|
71
|
+
assert.ok(html.includes('中文摘要') && html.includes('卡住了'));
|
|
72
|
+
assert.ok(html.includes('data-status="BLOCKED"') && html.includes('b-DONE'));
|
|
73
|
+
const r1 = html.indexOf('Round 1</strong>');
|
|
74
|
+
const cp = html.indexOf('Checkpoint(Round 1-1)');
|
|
75
|
+
const r2 = html.indexOf('Round 2</strong>');
|
|
76
|
+
assert.ok(r1 < cp && cp < r2, 'order should be Round 1, Checkpoint, Round 2');
|
|
77
|
+
assert.ok(html.includes('<h6>段落 探索</h6>'));
|
|
78
|
+
assert.ok(!/<(script|link)[^>]+(src|href)=/i.test(html), 'no external resources');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('render: unknown status renders as NONE; no input section by default', () => {
|
|
82
|
+
const html = render({ rounds: [round({ status: '' })], checkpoint_blocks: [] });
|
|
83
|
+
assert.ok(html.includes('data-status="NONE"'));
|
|
84
|
+
assert.ok(!html.includes('User Input'));
|
|
85
|
+
const withInput = render({ rounds: [round({ input: '<secret>' })], checkpoint_blocks: [] });
|
|
86
|
+
assert.ok(withInput.includes('User Input') && withInput.includes('<secret>'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('render: empty data shows an empty-state message', () => {
|
|
90
|
+
assert.ok(render({ started: true, rounds: [], checkpoint_blocks: [] }).includes('沒有 Round 可顯示'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('CLI: stdin JSON -> stdout HTML; invalid JSON -> exit 1', () => {
|
|
94
|
+
const script = path.join(__dirname, 'timeline-render.js');
|
|
95
|
+
const ok = spawnSync(process.execPath, [script], { input: JSON.stringify({ rounds: [round({})] }), encoding: 'utf8' });
|
|
96
|
+
assert.equal(ok.status, 0);
|
|
97
|
+
assert.ok(ok.stdout.includes('Round 1'));
|
|
98
|
+
const bad = spawnSync(process.execPath, [script], { input: 'not json', encoding: 'utf8' });
|
|
99
|
+
assert.equal(bad.status, 1);
|
|
100
|
+
assert.equal(bad.stderr, 'INVALID_JSON\n');
|
|
101
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlog-tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
|
-
"test": "node --test cli/*.test.js scripts/*.test.js",
|
|
24
|
+
"test": "node --test cli/*.test.js cli/platforms/*.test.js scripts/*.test.js core/scripts/*.test.js",
|
|
25
25
|
"version": "node scripts/sync-version.js && git add .claude-plugin/plugin.json .claude-plugin/marketplace.json README.md README.zh-TW.md"
|
|
26
26
|
},
|
|
27
27
|
"license": "Apache-2.0",
|
|
@@ -315,7 +315,7 @@ Stop hook 會檢查最後一個 Round 是否同時有 `### Summary`、`### Reply
|
|
|
315
315
|
|
|
316
316
|
這一輪如果是使用者直接呼叫 devlog-tracker 自己的純管理指令——`/devlog-tracker:checkpoint`、
|
|
317
317
|
`clean`、`compact`、`keep`、`lessons`、`lessons-drift`、`lessons-off`、`lessons-on`、
|
|
318
|
-
`overview`、`pause`、`search`、`segment-watch`、`span`、`start`、`status`——`round-start.sh`
|
|
318
|
+
`overview`、`pause`、`report`、`search`、`segment-watch`、`span`、`start`、`status`、`timeline`——`round-start.sh`
|
|
319
319
|
會整輪直接放行,不開 Round、不動任何計數器,等於這個 tick 沒發生過;不用、也不會被
|
|
320
320
|
Stop hook 要求補寫 Summary/Reply/Handoff。這些指令本身就是在操作 devlog 系統,不是開發
|
|
321
321
|
工作,記錄下來對接續開發沒有幫助。
|
|
@@ -467,8 +467,10 @@ Handoff。核對用 `commands/continue.md` 步驟 5.1–5.2(不要跟著做 5.
|
|
|
467
467
|
若這輪任務是透過 Agent 工具派 sub agent,或用 Workflow 工具跑多階段 pipeline,一樣可能
|
|
468
468
|
踩到值得記的坑,只是沒有 Round/Status 可比對訊號;讀完 sub agent/workflow 的最終回報後
|
|
469
469
|
自我判斷(例如 verify 推翻了它先前的 fix、它自陳繞了一圈、多個 agent 重複卡在同一種問題、
|
|
470
|
-
或成果被打回票要求重做),值得的話一樣呼叫 `lessons-append.sh
|
|
471
|
-
|
|
470
|
+
或成果被打回票要求重做),值得的話一樣呼叫 `lessons-append.sh`。Claude Code 上 hook 會自動
|
|
471
|
+
觸發:sub agent/Workflow agent 開始時被注入 `lessons-append.sh` 的絕對路徑用法、可以自己記;
|
|
472
|
+
它回來或背景任務通知到達時,你會看到一句 `[Lessons Mode 提示]`(`failed`/`killed` 另算進
|
|
473
|
+
共用計數器)。sub agent 已記過的不用重複記。
|
|
472
474
|
|
|
473
475
|
寫法、per-topic 存檔規則、索引重建、機制性訊號細節,見
|
|
474
476
|
`${CLAUDE_PLUGIN_ROOT}/skills/devlog-tracker/references/lessons-mode.md`(完整
|
|
@@ -49,8 +49,14 @@ verify 階段推翻了 sub agent 先前的 fix/claim、sub agent 自陳繞了
|
|
|
49
49
|
裡多個 agent 各自卡在類似問題(彙整成一筆更有代表性的)、sub agent 的成果被使用者或
|
|
50
50
|
reviewer 打回票要求重做。
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
Claude Code 上 hook 會自動觸發(只在 Lessons Mode 開著時):
|
|
53
|
+
- sub agent/Workflow agent 開始時(`SubagentStart`),hook 會把 `lessons-append.sh` 的用法
|
|
54
|
+
連同**主專案絕對路徑**注入它的 context,它覺得值得就自己記一筆,並在最終回報裡說一聲。
|
|
55
|
+
worktree isolation 下也照那條絕對路徑指令跑,不會寫錯地方。
|
|
56
|
+
- 前景 sub agent 回來時(`PostToolUse`),或背景 sub agent/Workflow 的 task-notification
|
|
57
|
+
到達時(`round-start.sh`),你會看到一句 `[Lessons Mode 提示]`,提醒你檢查上面四種訊號;
|
|
58
|
+
背景任務 `status` 是 `failed`/`killed` 時還會算進共用計數器。
|
|
59
|
+
|
|
60
|
+
看到提示後:sub agent 已說明記過的主題不要重複記;多個 agent 卡在同一種問題時,由你彙整成
|
|
61
|
+
一筆更有代表性的。一樣全部非強制。Codex/Cursor 沒有對應 hook,仍是你自己判斷。詳見
|
|
56
62
|
`docs/design/lessons-mode.md`「sub agent/workflow 情境的自我判斷訊號」。
|