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.
- 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/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-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 +16 -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,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, '&')
|
|
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 };
|