devlog-tracker 0.30.0 → 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.
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for report-devlog.sh (docs/design/read-side-and-promote.md B).
3
+ set -uo pipefail
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ TMP_ROOT="$(mktemp -d)"
6
+ trap 'rm -rf "$TMP_ROOT"' EXIT
7
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
8
+ DEVLOG_DIR="$TMP_ROOT/.devlog"
9
+ FAIL=0
10
+ assert_line() {
11
+ local desc="$1" expected="$2" out="$3"
12
+ if printf '%s\n' "$out" | grep -qxF "$expected"; then echo "PASS: $desc"
13
+ else echo "FAIL: $desc (missing line [$expected] in: $out)"; FAIL=1; fi
14
+ }
15
+ assert_no_line_prefix() {
16
+ local desc="$1" prefix="$2" out="$3"
17
+ if printf '%s\n' "$out" | grep -q "^$prefix"; then echo "FAIL: $desc (found $prefix)"; FAIL=1
18
+ else echo "PASS: $desc"; fi
19
+ }
20
+ jcheck() {
21
+ local desc="$1" json="$2" expr="$3"
22
+ if ! command -v node >/dev/null 2>&1; then echo "SKIP: $desc (no node)"; return 0; fi
23
+ if printf '%s' "$json" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); process.exit(($expr)?0:1)"; then
24
+ echo "PASS: $desc"
25
+ else echo "FAIL: $desc"; FAIL=1; fi
26
+ }
27
+
28
+ # --- No .devlog -> NOT_STARTED ----------------------------------------------
29
+ OUT="$(bash "$SCRIPT_DIR/report-devlog.sh")"
30
+ assert_line "no .devlog -> NOT_STARTED" "NOT_STARTED" "$OUT"
31
+
32
+ # --- Fixture ------------------------------------------------------------------
33
+ mkdir -p "$DEVLOG_DIR"
34
+ {
35
+ cat <<'EOF'
36
+ ## Round 1 — 2026-09-01T10:00:00+0800
37
+
38
+ ### User Input
39
+ secret-input-text
40
+
41
+ ### Summary
42
+ EOF
43
+ printf 'first "quoted" \\ back\ttab 中文\n'
44
+ cat <<'EOF'
45
+
46
+ ### Handoff
47
+ #### 現況
48
+ x
49
+
50
+ ### Status
51
+ DONE
52
+
53
+ ## Round 2 — 2026-09-02T10:00:00+0800
54
+
55
+ ### Summary
56
+ blocked one
57
+ ```
58
+ ## Round 9 fake inside fence
59
+ ```
60
+
61
+ ### Status
62
+ BLOCKED
63
+
64
+ ## Checkpoint(Round 1-2)
65
+ ### 決策
66
+ - keep it
67
+
68
+ ## Round 3 — 2026-09-03T10:00:00+0800
69
+
70
+ ### Summary
71
+ in progress
72
+
73
+ ### 段落 探索
74
+ seg body
75
+
76
+ ### Status
77
+ INTERRUPTED
78
+ [reason: dangling:next_prompt]
79
+
80
+ ## Kept 索引
81
+ - `devlog.topic-a.md`:Round 5-5,kept_at 2026-09-05T00:00:00+0800,topic a
82
+ EOF
83
+ } > "$DEVLOG_DIR/devlog.md"
84
+ cat > "$DEVLOG_DIR/devlog.archive.md" <<'EOF'
85
+ ## Round 0 — 2026-08-31T10:00:00+0800
86
+
87
+ ### Summary
88
+ archived
89
+
90
+ ### Status
91
+ DONE
92
+ EOF
93
+ cat > "$DEVLOG_DIR/devlog.topic-a.md" <<'EOF'
94
+ ## Round 5 — 2026-09-05T10:00:00+0800
95
+
96
+ ### Summary
97
+ kept topic
98
+
99
+ ### Status
100
+ DONE
101
+ EOF
102
+ cat > "$DEVLOG_DIR/devlog.feat-x.md" <<'EOF'
103
+ ## Round 1 — 2026-09-04T10:00:00+0800
104
+
105
+ ### Summary
106
+ branch work
107
+
108
+ ### Status
109
+ IN_PROGRESS
110
+ EOF
111
+ printf '# Lessons: foo\n\n## 2026-09-01T00:00:00+0800\nlesson.\n' > "$DEVLOG_DIR/devlog.lessons.foo.md"
112
+
113
+ # --- Default KEY=VALUE --------------------------------------------------------
114
+ OUT="$(bash "$SCRIPT_DIR/report-devlog.sh")"
115
+ assert_line "branch" "BRANCH=main" "$OUT"
116
+ assert_line "total rounds = archive + main, fenced fake ignored" "ROUNDS_TOTAL=4" "$OUT"
117
+ assert_line "main rounds" "ROUNDS_MAIN=3" "$OUT"
118
+ assert_line "archive rounds" "ROUNDS_ARCHIVE=1" "$OUT"
119
+ assert_line "done count" "STATUS_DONE=2" "$OUT"
120
+ assert_line "in-progress count (branch file not scanned)" "STATUS_IN_PROGRESS=0" "$OUT"
121
+ assert_line "blocked count" "STATUS_BLOCKED=1" "$OUT"
122
+ assert_line "interrupted count despite reason line" "STATUS_INTERRUPTED=1" "$OUT"
123
+ assert_line "blocked ratio" "BLOCKED_RATIO=25" "$OUT"
124
+ assert_line "checkpoints" "CHECKPOINTS=1" "$OUT"
125
+ assert_line "kept topics" "KEPT_TOPICS=1" "$OUT"
126
+ assert_line "lessons topics" "LESSONS_TOPICS=1" "$OUT"
127
+ assert_line "first round at" "FIRST_ROUND_AT=2026-08-31T10:00:00+0800" "$OUT"
128
+ assert_line "last round at" "LAST_ROUND_AT=2026-09-03T10:00:00+0800" "$OUT"
129
+ assert_no_line_prefix "no advisory state -> no LESSONS_ADVISORY" "LESSONS_ADVISORY=" "$OUT"
130
+
131
+ printf '%s\n' '{"count": 2, "threshold": 3}' > "$DEVLOG_DIR/.lessons-advisory-state"
132
+ OUT="$(bash "$SCRIPT_DIR/report-devlog.sh")"
133
+ assert_line "advisory state present" "LESSONS_ADVISORY=2/3" "$OUT"
134
+ rm -f "$DEVLOG_DIR/.lessons-advisory-state"
135
+
136
+ # --- Empty devlog dir: zeros and none ----------------------------------------
137
+ EMPTY_ROOT="$(mktemp -d)"
138
+ mkdir -p "$EMPTY_ROOT/.devlog"
139
+ OUT="$(CLAUDE_PROJECT_DIR="$EMPTY_ROOT" bash "$SCRIPT_DIR/report-devlog.sh")"
140
+ assert_line "empty: zero rounds" "ROUNDS_TOTAL=0" "$OUT"
141
+ assert_line "empty: zero ratio" "BLOCKED_RATIO=0" "$OUT"
142
+ assert_line "empty: first none" "FIRST_ROUND_AT=none" "$OUT"
143
+ rm -rf "$EMPTY_ROOT"
144
+
145
+ # --- Unknown arg -> exit 2 ------------------------------------------------------
146
+ bash "$SCRIPT_DIR/report-devlog.sh" --bogus >/dev/null 2>&1
147
+ RC=$?
148
+ if [ "$RC" -eq 2 ]; then echo "PASS: unknown arg exits 2"; else echo "FAIL: unknown arg exit $RC"; FAIL=1; fi
149
+
150
+ # --- --json (no rounds) -------------------------------------------------------
151
+ J="$(bash "$SCRIPT_DIR/report-devlog.sh" --json)"
152
+ jcheck "json: started + counts are numbers" "$J" 'd.started===true && d.rounds_total===4 && d.status_blocked===1 && d.blocked_ratio===25'
153
+ jcheck "json: branch + times" "$J" 'd.branch==="main" && d.first_round_at==="2026-08-31T10:00:00+0800"'
154
+ jcheck "json: no advisory -> null" "$J" 'd.lessons_advisory===null'
155
+ jcheck "json: no rounds array without --rounds" "$J" '!("rounds" in d) && !("checkpoint_blocks" in d)'
156
+
157
+ # --- --json --rounds ------------------------------------------------------------
158
+ J="$(bash "$SCRIPT_DIR/report-devlog.sh" --json --rounds)"
159
+ jcheck "rounds: 4 entries, archive first" "$J" 'd.rounds.length===4 && d.rounds[0].branch==="archive" && d.rounds[0].n===0'
160
+ jcheck "rounds: escaping survives quote/backslash/tab/CJK" "$J" 'd.rounds[1].summary.includes("\"quoted\"") && d.rounds[1].summary.includes("\\") && d.rounds[1].summary.includes("\t") && d.rounds[1].summary.includes("中文")'
161
+ jcheck "rounds: fenced fake heading stays inside summary" "$J" 'd.rounds[2].summary.includes("## Round 9 fake inside fence")'
162
+ jcheck "rounds: status parsed despite reason line" "$J" 'd.rounds[3].status==="INTERRUPTED"'
163
+ jcheck "rounds: segments captured" "$J" 'd.rounds[3].segments.length===1 && d.rounds[3].segments[0]==="段落 探索\nseg body"'
164
+ jcheck "rounds: handoff keeps #### lines" "$J" 'd.rounds[1].handoff.startsWith("#### 現況")'
165
+ jcheck "rounds: line numbers" "$J" 'd.rounds[1].line===1 && d.rounds[1].file==="devlog.md"'
166
+ jcheck "rounds: no input by default" "$J" 'd.rounds.every(r => !("input" in r))'
167
+ jcheck "checkpoint_blocks: one block with body" "$J" 'd.checkpoint_blocks.length===1 && d.checkpoint_blocks[0].heading==="Checkpoint(Round 1-2)" && d.checkpoint_blocks[0].body.includes("keep it")'
168
+
169
+ J="$(bash "$SCRIPT_DIR/report-devlog.sh" --json --rounds --with-input)"
170
+ jcheck "with-input: input field present" "$J" 'd.rounds[1].input==="secret-input-text"'
171
+
172
+ # --- --json on NOT_STARTED ------------------------------------------------------
173
+ NS_ROOT="$(mktemp -d)"
174
+ J="$(CLAUDE_PROJECT_DIR="$NS_ROOT" bash "$SCRIPT_DIR/report-devlog.sh" --json)"
175
+ jcheck "json NOT_STARTED" "$J" 'd.started===false'
176
+ rm -rf "$NS_ROOT"
177
+
178
+ # --- --all-branches ---------------------------------------------------------------
179
+ OUT="$(bash "$SCRIPT_DIR/report-devlog.sh" --all-branches)"
180
+ assert_line "all-branches: branch file added, kept file excluded" "ROUNDS_TOTAL=5" "$OUT"
181
+ assert_line "all-branches: in-progress from branch file" "STATUS_IN_PROGRESS=1" "$OUT"
182
+ assert_line "all-branches: last round from branch file" "LAST_ROUND_AT=2026-09-04T10:00:00+0800" "$OUT"
183
+ J="$(bash "$SCRIPT_DIR/report-devlog.sh" --json --rounds --all-branches)"
184
+ jcheck "all-branches: feat-x rounds tagged" "$J" 'd.rounds.some(r => r.branch==="feat-x" && r.summary==="branch work")'
185
+ jcheck "all-branches: no kept or lessons rounds" "$J" 'd.rounds.every(r => r.branch!=="topic-a" && !r.branch.startsWith("lessons."))'
186
+
187
+ # --- control chars outside \t\r\n (e.g. pasted ANSI \033) escape to \uXXXX ------
188
+ CTRL_ROOT="$(mktemp -d)"
189
+ mkdir -p "$CTRL_ROOT/.devlog"
190
+ printf '## Round 1 — 2026-09-06T10:00:00+0800\n\n### Summary\nboom\033[31mred\n\n### Status\nDONE\n' \
191
+ > "$CTRL_ROOT/.devlog/devlog.md"
192
+ J="$(CLAUDE_PROJECT_DIR="$CTRL_ROOT" bash "$SCRIPT_DIR/report-devlog.sh" --json --rounds)"
193
+ jcheck "control char (ESC) escapes to \\u001b, JSON still parses" "$J" 'd.rounds[0].summary.includes("\x1b")'
194
+ rm -rf "$CTRL_ROOT"
195
+
196
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
197
+ else echo "Some checks FAILED."; exit 1; fi
@@ -652,6 +652,13 @@ else
652
652
  fi
653
653
  rm -f "$DEVLOG_DIR/.checkpoint-state"
654
654
 
655
+ # --- 19b: report / timeline are admin (read-side) commands too -------------
656
+ for ADMIN_CMD in report timeline; do
657
+ rm -f "$DEVLOG_DIR/.round-current.md" "$DEVLOG_DIR/.round-open"
658
+ printf '{"prompt":"<command-name>/devlog-tracker:%s</command-name>"}' "$ADMIN_CMD" | bash "$SCRIPT_DIR/round-start.sh"
659
+ assert_file_absent "admin $ADMIN_CMD: no .round-current.md" "$DEVLOG_DIR/.round-current.md"
660
+ done
661
+
655
662
  # --- 20: a namespaced-but-different command name is not falsely exempted ---
656
663
  # "lessons-on" must not match the "lessons" case arm (prefix collision guard).
657
664
  LESSONS_ON_PROMPT='<command-name>/devlog-tracker:lessons-on</command-name><command-message>lessons-on</command-message><command-args></command-args>'
@@ -677,6 +684,15 @@ else
677
684
  fi
678
685
  rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
679
686
 
687
+ # --- 21b: /devlog-tracker:pr and :promote are NOT exempt (outward / file writes)
688
+ for WORK_CMD in pr promote; do
689
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
690
+ printf '{"prompt":"<command-name>/devlog-tracker:%s</command-name>"}' "$WORK_CMD" | bash "$SCRIPT_DIR/round-start.sh"
691
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
692
+ assert_contains "$WORK_CMD command still opens a Round" "## Round 1 —" "$CUR_BODY"
693
+ done
694
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
695
+
680
696
  # --- workspace claim on next prompt ----------------------------------------
681
697
  WS="$TMP_ROOT/ws"
682
698
  mkdir -p "$WS/.devlog"
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for timeline-devlog.sh (docs/design/read-side-and-promote.md C).
3
+ set -uo pipefail
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ TMP_ROOT="$(mktemp -d)"
6
+ trap 'rm -rf "$TMP_ROOT"' EXIT
7
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
8
+ DEVLOG_DIR="$TMP_ROOT/.devlog"
9
+ FAIL=0
10
+ pass() { echo "PASS: $1"; }
11
+ fail() { echo "FAIL: $1"; FAIL=1; }
12
+
13
+ # --- NO_NODE: node missing from PATH (checked before anything else) ----------
14
+ EMPTY_BIN="$TMP_ROOT/empty-bin"
15
+ mkdir -p "$EMPTY_BIN"
16
+ BASH_BIN="$(command -v bash)"
17
+ OUT="$(PATH="$EMPTY_BIN" "$BASH_BIN" "$SCRIPT_DIR/timeline-devlog.sh" 2>&1)"
18
+ [ "$OUT" = "NO_NODE" ] && pass "no node -> NO_NODE" || fail "no node -> NO_NODE (got $OUT)"
19
+
20
+ if ! command -v node >/dev/null 2>&1; then
21
+ echo "SKIP: remaining timeline checks need node"
22
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0; else echo "Some checks FAILED."; exit 1; fi
23
+ fi
24
+
25
+ # --- NOT_STARTED --------------------------------------------------------------
26
+ OUT="$(bash "$SCRIPT_DIR/timeline-devlog.sh")"
27
+ [ "$OUT" = "NOT_STARTED" ] && pass "no .devlog -> NOT_STARTED" || fail "NOT_STARTED (got $OUT)"
28
+ [ ! -e "$DEVLOG_DIR" ] && pass "NOT_STARTED creates nothing" || fail "NOT_STARTED created .devlog"
29
+
30
+ # --- End to end -----------------------------------------------------------------
31
+ mkdir -p "$DEVLOG_DIR"
32
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF2'
33
+ ## Round 1 — 2026-09-01T10:00:00+0800
34
+
35
+ ### User Input
36
+ secret-input-text
37
+
38
+ ### Summary
39
+ <script>alert(1)</script> 中文摘要
40
+
41
+ ### Status
42
+ DONE
43
+ EOF2
44
+ OUT="$(bash "$SCRIPT_DIR/timeline-devlog.sh")"
45
+ case "$OUT" in
46
+ "OUT=$(cd "$DEVLOG_DIR" && pwd)/timeline.html") pass "default OUT path is absolute .devlog/timeline.html" ;;
47
+ *) fail "default OUT (got $OUT)" ;;
48
+ esac
49
+ HTML="$DEVLOG_DIR/timeline.html"
50
+ grep -q '中文摘要' "$HTML" && pass "summary rendered" || fail "summary missing"
51
+ grep -q '<script>alert' "$HTML" && fail "raw script tag leaked" || pass "script tag escaped"
52
+ grep -q 'secret-input-text' "$HTML" && fail "User Input leaked" || pass "User Input not included"
53
+ [ ! -e "$HTML.tmp" ] && pass "no leftover tmp file" || fail "leftover $HTML.tmp"
54
+
55
+ # --- --out ------------------------------------------------------------------------
56
+ OUT="$(bash "$SCRIPT_DIR/timeline-devlog.sh" --out "$TMP_ROOT/custom.html")"
57
+ [ -s "$TMP_ROOT/custom.html" ] && pass "--out writes the given path" || fail "--out ($OUT)"
58
+
59
+ # --- unknown arg ------------------------------------------------------------------
60
+ bash "$SCRIPT_DIR/timeline-devlog.sh" --bogus >/dev/null 2>&1
61
+ RC=$?
62
+ [ "$RC" -eq 2 ] && pass "unknown arg exits 2" || fail "unknown arg exit $RC"
63
+
64
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
65
+ else echo "Some checks FAILED."; exit 1; fi
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # /devlog-tracker:timeline and `npx devlog-tracker timeline`
3
+ # (docs/design/read-side-and-promote.md C): report-devlog.sh --json --rounds
4
+ # piped into timeline-render.js, written to .devlog/timeline.html (gitignored).
5
+ set -uo pipefail
6
+
7
+ _src="${BASH_SOURCE[0]}"
8
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
9
+
10
+ # Checked before sourcing anything: Claude plugin users may not have Node.
11
+ command -v node >/dev/null 2>&1 || { echo "NO_NODE"; exit 0; }
12
+
13
+ # shellcheck source=devlog-path.sh
14
+ . "$SCRIPT_DIR/devlog-path.sh"
15
+
16
+ ALL=0
17
+ OUT=""
18
+ while [ $# -gt 0 ]; do
19
+ case "$1" in
20
+ --all-branches) ALL=1 ;;
21
+ --out) shift; OUT="${1:-}" ;;
22
+ *) echo "UNKNOWN_ARG=$1" >&2; exit 2 ;;
23
+ esac
24
+ shift
25
+ done
26
+
27
+ devlog_resolve_paths "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
28
+ [ -d "$DEVLOG_DIR" ] || { echo "NOT_STARTED"; exit 0; }
29
+ [ -n "$OUT" ] || OUT="$DEVLOG_DIR/timeline.html"
30
+
31
+ ARGS=(--json --rounds)
32
+ [ "$ALL" -eq 1 ] && ARGS+=(--all-branches)
33
+
34
+ JSON_TMP="$(mktemp "${TMPDIR:-/tmp}/devlog-timeline.XXXXXX")" || exit 1
35
+ trap 'rm -f "$JSON_TMP" "$OUT.tmp"' EXIT
36
+ bash "$SCRIPT_DIR/report-devlog.sh" "${ARGS[@]}" > "$JSON_TMP" || exit 1
37
+ node "$SCRIPT_DIR/timeline-render.js" < "$JSON_TMP" > "$OUT.tmp" || exit 1
38
+ mv "$OUT.tmp" "$OUT" || exit 1
39
+
40
+ OUT_DIR="$(cd "$(dirname "$OUT")" && pwd)"
41
+ echo "OUT=$OUT_DIR/$(basename "$OUT")"
@@ -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, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
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 };