devlog-tracker 0.30.0 → 0.31.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 +22 -6
- package/README.zh-TW.md +22 -6
- package/bin/devlog-tracker.js +14 -1
- 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/devlog-lock.sh +10 -0
- package/core/scripts/json-field.sh +16 -0
- package/core/scripts/lessons-subagent-start.sh +2 -10
- 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 +1 -1
- package/core/scripts/tests/test-close-open-round.sh +54 -0
- package/core/scripts/tests/test-devlog-lock.sh +44 -0
- package/core/scripts/tests/test-json-field.sh +5 -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 +28 -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 +1 -1
|
@@ -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.1",
|
|
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
|
工作,記錄下來對接續開發沒有幫助。
|