devlog-tracker 0.33.5 → 0.34.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.
Files changed (51) hide show
  1. package/README.md +41 -10
  2. package/README.zh-TW.md +41 -10
  3. package/cli/agents-md.js +1 -0
  4. package/cli/agents-md.test.js +10 -0
  5. package/cli/platforms/claude.js +1 -0
  6. package/codex/hooks/on-interrupt.sh +13 -0
  7. package/codex/hooks/on-pre-tool.sh +69 -1
  8. package/codex/hooks/on-session-end.sh +5 -5
  9. package/codex/hooks/on-session-start.sh +5 -6
  10. package/codex/hooks/on-stop.sh +3 -2
  11. package/codex/hooks/on-subagent-start.sh +19 -0
  12. package/codex/hooks/project-dir.sh +16 -1
  13. package/codex/hooks/test-adapters.sh +127 -9
  14. package/codex/hooks.json +21 -0
  15. package/commands/continue.md +3 -3
  16. package/commands/keep-all.md +1 -1
  17. package/commands/keep.md +2 -2
  18. package/commands/lessons-on.md +1 -1
  19. package/commands/migrate.md +18 -0
  20. package/commands/pr.md +3 -3
  21. package/commands/resume.md +1 -1
  22. package/core/scripts/close-open-round.sh +8 -2
  23. package/core/scripts/devlog-md.sh +6 -11
  24. package/core/scripts/enforce-devlog.sh +79 -85
  25. package/core/scripts/handoff-convert.sh +187 -0
  26. package/core/scripts/handoff-fields.sh +219 -0
  27. package/core/scripts/handoff-file.sh +19 -88
  28. package/core/scripts/lessons-subagent-start.sh +1 -1
  29. package/core/scripts/migrate-handoff.sh +91 -0
  30. package/core/scripts/round-start.sh +13 -2
  31. package/core/scripts/segment-watch.sh +1 -1
  32. package/core/scripts/tests/lib/xml-fixture.sh +10 -0
  33. package/core/scripts/tests/test-close-open-round.sh +2 -1
  34. package/core/scripts/tests/test-devlog-md.sh +21 -0
  35. package/core/scripts/tests/test-enforce-devlog-files.sh +4 -1
  36. package/core/scripts/tests/test-enforce-devlog-handoff-order.sh +149 -81
  37. package/core/scripts/tests/test-enforce-devlog-session-handoff.sh +92 -11
  38. package/core/scripts/tests/test-enforce-devlog-workspace.sh +42 -26
  39. package/core/scripts/tests/test-enforce-devlog.sh +70 -0
  40. package/core/scripts/tests/test-handoff-fields.sh +171 -0
  41. package/core/scripts/tests/test-handoff-file.sh +67 -45
  42. package/core/scripts/tests/test-migrate-handoff.sh +278 -0
  43. package/core/scripts/tests/test-round-start.sh +54 -1
  44. package/core/scripts/tests/test-session-start-devlog.sh +45 -0
  45. package/core/scripts/timeline-render.js +26 -2
  46. package/core/scripts/timeline-render.test.js +23 -1
  47. package/package.json +3 -3
  48. package/skills/devlog-tracker/SKILL.md +80 -55
  49. package/skills/devlog-tracker/references/contract.md +4 -3
  50. package/skills/devlog-tracker/references/lessons-mode.md +7 -7
  51. package/skills/devlog-tracker/references/round-segments.md +11 -5
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for handoff-convert.sh + migrate-handoff.sh (docs/design/handoff-xml.md).
3
+ set -uo pipefail
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ # shellcheck source=../handoff-convert.sh
6
+ . "$SCRIPT_DIR/handoff-convert.sh"
7
+ TMP="$(mktemp -d)"
8
+ trap 'rm -rf "$TMP"' EXIT
9
+ FAIL=0
10
+ eq() {
11
+ if [ "$2" = "$3" ]; then echo "PASS: $1"; else printf 'FAIL: %s\n--- expected\n%s\n--- actual\n%s\n' "$1" "$2" "$3"; FAIL=1; fi
12
+ }
13
+
14
+ cat > "$TMP/in.md" <<'EOF'
15
+ # Project
16
+
17
+ ## Round 1 — t1
18
+
19
+ ### Summary
20
+ s1
21
+
22
+ ### Handoff
23
+ #### 決策
24
+ pick A
25
+
26
+ #### 工作區
27
+ main @ abc1234,工作樹乾淨
28
+ #### 下一步
29
+ run `x`
30
+ ```
31
+ #### 下一步
32
+ fenced, not a heading
33
+ ```
34
+
35
+ ### Session Handoff
36
+
37
+ #### 決策
38
+ - (無)
39
+
40
+ #### 待解問題
41
+ - q
42
+
43
+ #### 失敗嘗試
44
+ - (無)
45
+
46
+ ### Status
47
+ IN_PROGRESS
48
+
49
+ ## Checkpoint(Round 1-1 摘要)
50
+ ### 決策
51
+ untouched
52
+
53
+ ## Round 2 — t2
54
+
55
+ ### Handoff
56
+ #### 自訂小節
57
+ x
58
+
59
+ ### Status
60
+ DONE
61
+
62
+ ## Round 3 — t3
63
+
64
+ ### Handoff
65
+ <handoff>
66
+ <state>
67
+ already xml
68
+ </state>
69
+ </handoff>
70
+
71
+ ### Status
72
+ DONE
73
+ EOF
74
+
75
+ cat > "$TMP/want.md" <<'EOF'
76
+ # Project
77
+
78
+ ## Round 1 — t1
79
+
80
+ ### Summary
81
+ s1
82
+
83
+ ### Handoff
84
+ <handoff>
85
+ <decisions>
86
+ pick A
87
+ </decisions>
88
+ <workspace>
89
+ main @ abc1234,工作樹乾淨
90
+ </workspace>
91
+ <next>
92
+ run `x`
93
+ ```
94
+ #### 下一步
95
+ fenced, not a heading
96
+ ```
97
+ </next>
98
+ </handoff>
99
+
100
+ ### Session Handoff
101
+ <session-handoff>
102
+ <decisions>
103
+ - (無)
104
+ </decisions>
105
+ <open-questions>
106
+ - q
107
+ </open-questions>
108
+ <failed-attempts>
109
+ - (無)
110
+ </failed-attempts>
111
+ </session-handoff>
112
+
113
+ ### Status
114
+ IN_PROGRESS
115
+
116
+ ## Checkpoint(Round 1-1 摘要)
117
+ ### 決策
118
+ untouched
119
+
120
+ ## Round 2 — t2
121
+
122
+ ### Handoff
123
+ #### 自訂小節
124
+ x
125
+
126
+ ### Status
127
+ DONE
128
+
129
+ ## Round 3 — t3
130
+
131
+ ### Handoff
132
+ <handoff>
133
+ <state>
134
+ already xml
135
+ </state>
136
+ </handoff>
137
+
138
+ ### Status
139
+ DONE
140
+ EOF
141
+
142
+ handoff_convert_file "$TMP/in.md" "$TMP/out.md" "$TMP/report"
143
+ eq "convert output" "$(cat "$TMP/want.md")" "$(cat "$TMP/out.md")"
144
+ eq "report" "MIGRATED 1
145
+ SKIP 2 不認得的小節「#### 自訂小節」" "$(cat "$TMP/report")"
146
+
147
+ handoff_convert_file "$TMP/out.md" "$TMP/out2.md" "$TMP/report2"
148
+ eq "idempotent output" "$(cat "$TMP/out.md")" "$(cat "$TMP/out2.md")"
149
+ eq "idempotent report only repeats skip" "SKIP 2 不認得的小節「#### 自訂小節」" "$(cat "$TMP/report2")"
150
+
151
+ printf '## Round 4 — t\n\n### Handoff\n#### 現況\na\n#### 決策\nb\n' > "$TMP/order.md"
152
+ handoff_convert_file "$TMP/order.md" "$TMP/order.out" "$TMP/order.rep"
153
+ eq "bad order untouched" "$(cat "$TMP/order.md")" "$(cat "$TMP/order.out")"
154
+ case "$(cat "$TMP/order.rep")" in SKIP\ 4\ *) echo "PASS: bad order reported" ;; *) echo "FAIL: bad order report"; FAIL=1 ;; esac
155
+
156
+ printf '## Round 5 — t\n\n### Handoff\n#### 現況\n```\nunclosed\n' > "$TMP/fence.md"
157
+ handoff_convert_file "$TMP/fence.md" "$TMP/fence.out" "$TMP/fence.rep"
158
+ eq "odd fence untouched" "$(cat "$TMP/fence.md")" "$(cat "$TMP/fence.out")"
159
+
160
+ printf '## Session Handoff\n\n### 決策\n- a\n\n### 待解問題\n- b\n\n### 失敗嘗試\n- (無)\n' > "$TMP/h.md"
161
+ handoff_convert_snapshot "$TMP/h.md" "$TMP/h.out"; RC=$?
162
+ eq "snapshot rc" 0 "$RC"
163
+ eq "snapshot xml" "<session-handoff>
164
+ <decisions>
165
+ - a
166
+ </decisions>
167
+ <open-questions>
168
+ - b
169
+ </open-questions>
170
+ <failed-attempts>
171
+ - (無)
172
+ </failed-attempts>
173
+ </session-handoff>" "$(cat "$TMP/h.out")"
174
+ handoff_convert_snapshot "$TMP/h.out" "$TMP/h.out2"; RC=$?
175
+ eq "snapshot already xml rc" 2 "$RC"
176
+
177
+ # ---- migrate-handoff.sh (Task 3 appends below this line) ----
178
+
179
+ P="$TMP/proj"; D="$P/.devlog"; mkdir -p "$D"
180
+ cp "$TMP/in.md" "$D/devlog.md"
181
+ { printf '<!-- devlog-origin: branch=feat/x -->\n\n'; cat "$TMP/in.md"; } > "$D/devlog.feat-x.md"
182
+ cp "$TMP/in.md" "$D/devlog.archive.md"
183
+ cp "$TMP/in.md" "$D/devlog.mytopic.md"
184
+ cp "$TMP/h.md" "$D/handoff.md"
185
+ OUT="$(DEVLOG_PROJECT_DIR="$P" bash "$SCRIPT_DIR/migrate-handoff.sh")"; RC=$?
186
+ eq "migrate rc" 0 "$RC"
187
+ case "$OUT" in *"MIGRATED=2"*) echo "PASS: migrated count (main + branch)" ;; *) echo "FAIL: migrated count: $OUT"; FAIL=1 ;; esac
188
+ case "$OUT" in *"SKIP devlog.md Round 2: "*) echo "PASS: skip line" ;; *) echo "FAIL: skip line: $OUT"; FAIL=1 ;; esac
189
+ eq "main converted" "$(cat "$TMP/want.md")" "$(cat "$D/devlog.md")"
190
+ eq "backup kept original" "$(cat "$TMP/in.md")" "$(cat "$D/devlog.md.pre-migrate")"
191
+ grep -q '^<handoff>$' "$D/devlog.feat-x.md" && echo "PASS: branch file converted" || { echo "FAIL: branch file"; FAIL=1; }
192
+ eq "archive untouched" "$(cat "$TMP/in.md")" "$(cat "$D/devlog.archive.md")"
193
+ eq "keep file untouched" "$(cat "$TMP/in.md")" "$(cat "$D/devlog.mytopic.md")"
194
+ grep -q '^<session-handoff>$' "$D/handoff.md" && echo "PASS: handoff.md converted" || { echo "FAIL: handoff.md"; FAIL=1; }
195
+ OUT2="$(DEVLOG_PROJECT_DIR="$P" bash "$SCRIPT_DIR/migrate-handoff.sh")"
196
+ case "$OUT2" in *"MIGRATED=0"*) echo "PASS: second run migrates nothing" ;; *) echo "FAIL: second run: $OUT2"; FAIL=1 ;; esac
197
+ [ ! -d "$D/.lock" ] && echo "PASS: lock released" || { echo "FAIL: lock left behind"; FAIL=1; }
198
+ OUT3="$(DEVLOG_PROJECT_DIR="$TMP/nope" bash "$SCRIPT_DIR/migrate-handoff.sh")"; RC=$?
199
+ eq "no devlog rc" 1 "$RC"
200
+ eq "no devlog out" "NO_DEVLOG" "$OUT3"
201
+
202
+ # Coverage gap 1: a duplicated `#### 現況` inside one Handoff leaves that
203
+ # round byte-for-byte untouched and is reported as SKIP mentioning 重複.
204
+ printf '## Round 10 — dup\n\n### Handoff\n#### 現況\na\n#### 現況\nb\n\n### Status\nIN_PROGRESS\n' > "$TMP/dup.md"
205
+ handoff_convert_file "$TMP/dup.md" "$TMP/dup.out" "$TMP/dup.rep"
206
+ eq "duplicate section untouched" "$(cat "$TMP/dup.md")" "$(cat "$TMP/dup.out")"
207
+ case "$(cat "$TMP/dup.rep")" in SKIP\ 10\ *重複*) echo "PASS: duplicate section reported" ;; *) echo "FAIL: duplicate section report: $(cat "$TMP/dup.rep")"; FAIL=1 ;; esac
208
+
209
+ # Coverage gap 2: Handoff is valid legacy but Session Handoff has an unknown
210
+ # `#### 其他` subsection — the WHOLE round (including the convertible
211
+ # Handoff) is left byte-for-byte untouched and reported SKIP.
212
+ printf '## Round 11 — mixed\n\n### Handoff\n#### 決策\npick\n\n### Session Handoff\n#### 其他\nx\n\n### Status\nDONE\n' > "$TMP/mixed.md"
213
+ handoff_convert_file "$TMP/mixed.md" "$TMP/mixed.out" "$TMP/mixed.rep"
214
+ eq "unknown session-handoff subsection leaves whole round untouched" "$(cat "$TMP/mixed.md")" "$(cat "$TMP/mixed.out")"
215
+ case "$(cat "$TMP/mixed.rep")" in SKIP\ 11\ *) echo "PASS: mixed round skipped" ;; *) echo "FAIL: mixed round report: $(cat "$TMP/mixed.rep")"; FAIL=1 ;; esac
216
+
217
+ # Odd fence (a): the open round's User Input pasted an unbalanced fence (3
218
+ # ``` lines). Stop's NOFENCE gate still sees the legacy Handoff, so migrate
219
+ # must report it SKIP instead of silently finding nothing.
220
+ P2="$TMP/proj-odd"; D2="$P2/.devlog"; mkdir -p "$D2"
221
+ printf '## Round 7 — t\n\n### User Input\npaste:\n```\ncode\n```\n```\nunclosed\n\n### Summary\ns\n\n### Reply\nr\n\n### Handoff\n#### 現況\na\n\n#### 下一步\nb\n' > "$D2/.round-current.md"
222
+ cp "$D2/.round-current.md" "$TMP/odd-a.orig"
223
+ OUT4="$(DEVLOG_PROJECT_DIR="$P2" bash "$SCRIPT_DIR/migrate-handoff.sh")"
224
+ case "$OUT4" in *"SKIP .round-current.md Round 7: fence 沒有成對"*) echo "PASS: odd fence in open round reported SKIP" ;; *) echo "FAIL: odd fence open round: $OUT4"; FAIL=1 ;; esac
225
+ case "$OUT4" in *"SKIPPED=1"*) echo "PASS: odd fence open round counted" ;; *) echo "FAIL: odd fence open round count: $OUT4"; FAIL=1 ;; esac
226
+ eq "odd fence open round untouched" "$(cat "$TMP/odd-a.orig")" "$(cat "$D2/.round-current.md")"
227
+
228
+ # Odd fence (b): an unbalanced fence in an old round's Summary must not
229
+ # swallow every later `## Round` heading.
230
+ printf '## Round 1 — old\n\n### Summary\n```\nunclosed\n\n### Handoff\n#### 現況\na\n\n### Status\nDONE\n\n## Round 2 — new\n\n### Handoff\n#### 現況\nb\n\n### Status\nDONE\n' > "$TMP/odd-b.md"
231
+ handoff_convert_file "$TMP/odd-b.md" "$TMP/odd-b.out" "$TMP/odd-b.rep"
232
+ eq "odd fence (b) report" "SKIP 1 fence 沒有成對
233
+ MIGRATED 2" "$(cat "$TMP/odd-b.rep")"
234
+ eq "odd fence (b) output" "## Round 1 — old
235
+
236
+ ### Summary
237
+ \`\`\`
238
+ unclosed
239
+
240
+ ### Handoff
241
+ #### 現況
242
+ a
243
+
244
+ ### Status
245
+ DONE
246
+
247
+ ## Round 2 — new
248
+
249
+ ### Handoff
250
+ <handoff>
251
+ <state>
252
+ b
253
+ </state>
254
+ </handoff>
255
+
256
+ ### Status
257
+ DONE" "$(cat "$TMP/odd-b.out")"
258
+
259
+ # migrate_snapshot_file rc=1: an unrecognised handoff.md is reported, untouched.
260
+ P3="$TMP/proj-snap"; D3="$P3/.devlog"; mkdir -p "$D3"
261
+ printf 'random notes\n' > "$D3/handoff.md"
262
+ OUT5="$(DEVLOG_PROJECT_DIR="$P3" bash "$SCRIPT_DIR/migrate-handoff.sh")"
263
+ case "$OUT5" in *"SKIP handoff.md: "*) echo "PASS: unknown snapshot reported" ;; *) echo "FAIL: unknown snapshot: $OUT5"; FAIL=1 ;; esac
264
+ eq "unknown snapshot untouched" "random notes" "$(cat "$D3/handoff.md")"
265
+
266
+ # Branch file from before the devlog-origin marker existed: still migrated
267
+ # when that branch is checked out (resolved via devlog_resolve_paths).
268
+ P4="$TMP/proj-git"; D4="$P4/.devlog"; mkdir -p "$D4"
269
+ git -C "$P4" init -q
270
+ git -C "$P4" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
271
+ git -C "$P4" checkout -q -b feat/y
272
+ cp "$TMP/in.md" "$D4/devlog.feat-y.md"
273
+ OUT6="$(DEVLOG_PROJECT_DIR="$P4" bash "$SCRIPT_DIR/migrate-handoff.sh")"
274
+ eq "markerless current branch file converted" "$(cat "$TMP/want.md")" "$(cat "$D4/devlog.feat-y.md")"
275
+ case "$OUT6" in *"MIGRATED=1"*) echo "PASS: markerless branch file converted once" ;; *) echo "FAIL: markerless branch count: $OUT6"; FAIL=1 ;; esac
276
+
277
+ [ "$FAIL" -eq 0 ] || exit 1
278
+ echo "All migrate-handoff checks passed."
@@ -6,6 +6,8 @@ set -uo pipefail
6
6
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7
7
  # shellcheck source=../json-field.sh
8
8
  . "$SCRIPT_DIR/json-field.sh"
9
+ # shellcheck source=lib/xml-fixture.sh
10
+ . "$SCRIPT_DIR/tests/lib/xml-fixture.sh"
9
11
  TMP_ROOT="$(mktemp -d)"
10
12
  trap 'rm -rf "$TMP_ROOT"' EXIT
11
13
 
@@ -664,6 +666,16 @@ else
664
666
  fi
665
667
  rm -f "$DEVLOG_DIR/.checkpoint-state"
666
668
 
669
+ # Codex invokes project skills through $devlog-<name> in the prompt.
670
+ for ADMIN_PROMPT in '$devlog-status' '$devlog-report --json'; do
671
+ printf '{"prompt":"%s"}' "$ADMIN_PROMPT" | bash "$SCRIPT_DIR/round-start.sh"
672
+ assert_file_absent "Codex admin $ADMIN_PROMPT: no .round-current.md" "$DEVLOG_DIR/.round-current.md"
673
+ done
674
+ printf '{"prompt":"%s"}' '$devlog-continue' | bash "$SCRIPT_DIR/round-start.sh"
675
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
676
+ assert_contains "Codex continue still opens a Round" "## Round 1 —" "$CUR_BODY"
677
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
678
+
667
679
  # --- 19b: report / timeline / keep-all are admin commands too -------------
668
680
  for ADMIN_CMD in report timeline keep-all; do
669
681
  rm -f "$DEVLOG_DIR/.round-current.md" "$DEVLOG_DIR/.round-open"
@@ -772,7 +784,7 @@ do x
772
784
  IN_PROGRESS
773
785
  EOF
774
786
  OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
775
- assert_contains "mismatch stdout names 工作區" "#### 工作區" "$OUT"
787
+ assert_contains "mismatch stdout names 工作區(<workspace>)" "工作區(\`<workspace>\`" "$OUT"
776
788
  assert_contains "mismatch stdout has 宣稱" "宣稱:" "$OUT"
777
789
  assert_contains "mismatch stdout has 實際" "實際:" "$OUT"
778
790
  assert_contains "mismatch stdout has live snapshot" "$LIVE" "$OUT"
@@ -780,6 +792,47 @@ assert_contains "mismatch stdout has live snapshot" "$LIVE" "$OUT"
780
792
  MARKER="$(cat "$WS/.devlog/.workspace-mismatch")"
781
793
  [ "$MARKER" = "$LIVE" ] && echo "PASS: marker is live snapshot" || { echo "FAIL: marker [$MARKER]"; FAIL=1; }
782
794
 
795
+ # --- same MISMATCH injection, Handoff in XML form (docs/design/handoff-xml.md) --
796
+ WS2="$TMP_ROOT/ws-xml"
797
+ mkdir -p "$WS2/.devlog"
798
+ touch "$WS2/.devlog/.enabled"
799
+ git -C "$WS2" init -q -b main
800
+ git -C "$WS2" config user.email test@example.com
801
+ git -C "$WS2" config user.name test
802
+ echo hello > "$WS2/a.txt"
803
+ git -C "$WS2" add a.txt
804
+ git -C "$WS2" commit -q -m init
805
+ cat > "$WS2/.devlog/devlog.md" <<EOF
806
+ ## Round 1 — 2026-09-11T00:00:00+08:00
807
+
808
+ ### Summary
809
+ s
810
+
811
+ ### Reply
812
+ fixture reply.
813
+
814
+ ### Handoff
815
+ #### 工作區
816
+ main @ deadbeef,工作樹乾淨
817
+ #### 現況
818
+ going
819
+ #### 完成條件
820
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
821
+ #### 下一步
822
+ do x
823
+
824
+ ### Status
825
+ IN_PROGRESS
826
+ EOF
827
+ xml_fixture "$WS2/.devlog/devlog.md"
828
+ export CLAUDE_PROJECT_DIR="$WS2"
829
+ XML_OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
830
+ assert_contains "XML Handoff mismatch stdout names 工作區(<workspace>)" "工作區(\`<workspace>\`" "$XML_OUT"
831
+ assert_contains "XML Handoff mismatch stdout has 宣稱" "宣稱:" "$XML_OUT"
832
+ assert_contains "XML Handoff mismatch stdout has 實際" "實際:" "$XML_OUT"
833
+ [ -f "$WS2/.devlog/.workspace-mismatch" ] && echo "PASS: XML Handoff mismatch writes marker" || { echo "FAIL: XML Handoff mismatch: no marker"; FAIL=1; }
834
+ export CLAUDE_PROJECT_DIR="$WS"
835
+
783
836
  # --- lessons drift counter: mechanical nudge on repeated mismatch --------
784
837
  rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.workspace-mismatch"
785
838
  touch "$WS/.devlog/.lessons-enabled"
@@ -539,6 +539,51 @@ else
539
539
  FAIL=1
540
540
  fi
541
541
 
542
+ # --- XML Handoff + XML handoff.md: both injected --------------------------
543
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/handoff.md"
544
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
545
+ ## Round 1 — 2026-09-22T00:00:00+08:00
546
+
547
+ ### Summary
548
+ summary 1
549
+
550
+ ### Reply
551
+ fixture reply.
552
+
553
+ ### Handoff
554
+ <handoff>
555
+ <state>
556
+ xml state line
557
+ </state>
558
+ <done-when>
559
+ tests pass
560
+ </done-when>
561
+ <next>
562
+ edit xml-next-target.sh
563
+ </next>
564
+ </handoff>
565
+
566
+ ### Status
567
+ IN_PROGRESS
568
+ EOF
569
+ cat > "$DEVLOG_DIR/handoff.md" <<'EOF'
570
+ <session-handoff>
571
+ <decisions>
572
+ - keep route A
573
+ </decisions>
574
+ <open-questions>
575
+ - xml open Q
576
+ </open-questions>
577
+ <failed-attempts>
578
+ - (無)
579
+ </failed-attempts>
580
+ </session-handoff>
581
+ EOF
582
+ OUTPUT="$(echo '{"source":"startup"}' | bash "$SCRIPT_DIR/session-start-devlog.sh" 2>&1)"
583
+ assert_contains "XML handoff <next> content injected" "edit xml-next-target.sh" "$OUTPUT"
584
+ assert_contains "XML handoff.md block injected" "<session-handoff>" "$OUTPUT"
585
+ assert_contains "XML handoff.md body injected" "xml open Q" "$OUTPUT"
586
+
542
587
  # --- no handoff.md: excerpt only -----------------------------------------
543
588
  rm -f "$DEVLOG_DIR/handoff.md"
544
589
  OUTPUT="$(echo '{"source":"startup"}' | bash "$SCRIPT_DIR/session-start-devlog.sh" 2>&1)"
@@ -136,6 +136,30 @@ function renderMarkdown(md) {
136
136
  return out.join('\n');
137
137
  }
138
138
 
139
+ const HANDOFF_FIELDS = {
140
+ decisions: '決策', files: '檔案', workspace: '工作區', state: '現況',
141
+ 'done-when': '完成條件', next: '下一步', 'open-questions': '待解問題', 'failed-attempts': '失敗嘗試',
142
+ };
143
+ const HANDOFF_BLOCK_TAG = /^\s*<\/?(handoff|session-handoff)>\s*$/;
144
+ const HANDOFF_TAG = /^\s*<(\/?)([a-z-]+)>\s*$/;
145
+
146
+ // Line-based XML Handoff (docs/design/handoff-xml.md) → `#### 中文名`
147
+ // headings so renderMarkdown shows it like the legacy form. Legacy text
148
+ // passes through unchanged.
149
+ function handoffToMarkdown(text) {
150
+ const out = [];
151
+ for (const line of String(text).split('\n')) {
152
+ if (HANDOFF_BLOCK_TAG.test(line)) continue;
153
+ const m = HANDOFF_TAG.exec(line);
154
+ if (m && HANDOFF_FIELDS[m[2]]) {
155
+ if (!m[1]) out.push(`#### ${HANDOFF_FIELDS[m[2]]}`);
156
+ continue;
157
+ }
158
+ out.push(line);
159
+ }
160
+ return out.join('\n').trim();
161
+ }
162
+
139
163
  function statusOf(s) {
140
164
  return STATUSES.includes(s) ? s : 'NONE';
141
165
  }
@@ -145,7 +169,7 @@ function roundCard(r) {
145
169
  const parts = [];
146
170
  if (r.input) parts.push(`<h4>User Input</h4><pre><code>${escapeHtml(r.input)}</code></pre>`);
147
171
  if (r.reply) parts.push(`<h4>Reply</h4>${renderMarkdown(r.reply)}`);
148
- if (r.handoff) parts.push(`<h4>Handoff</h4>${renderMarkdown(r.handoff)}`);
172
+ if (r.handoff) parts.push(`<h4>Handoff</h4>${renderMarkdown(handoffToMarkdown(r.handoff))}`);
149
173
  for (const seg of r.segments || []) parts.push(`<div class="seg">${renderMarkdown('#### ' + seg)}</div>`);
150
174
  const details = parts.length ? `<details><summary>詳細</summary>${parts.join('\n')}</details>` : '';
151
175
  return `<article class="card round" data-status="${st}" data-branch="${escapeHtml(r.branch)}">
@@ -244,4 +268,4 @@ if (require.main === module) {
244
268
  });
245
269
  }
246
270
 
247
- module.exports = { render, renderMarkdown, inline, escapeHtml, safeHref, timelineItems };
271
+ module.exports = { render, renderMarkdown, inline, escapeHtml, safeHref, timelineItems, handoffToMarkdown, HANDOFF_FIELDS };
@@ -3,7 +3,7 @@ const test = require('node:test');
3
3
  const assert = require('node:assert/strict');
4
4
  const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
- const { render, renderMarkdown, safeHref, escapeHtml } = require('./timeline-render');
6
+ const { render, renderMarkdown, safeHref, escapeHtml, handoffToMarkdown, HANDOFF_FIELDS } = require('./timeline-render');
7
7
 
8
8
  function round(extra) {
9
9
  return { branch: 'main', file: 'devlog.md', line: 1, n: 1, at: '2026-09-01T10:00:00+0800',
@@ -90,6 +90,28 @@ test('render: empty data shows an empty-state message', () => {
90
90
  assert.ok(render({ started: true, rounds: [], checkpoint_blocks: [] }).includes('沒有 Round 可顯示'));
91
91
  });
92
92
 
93
+ test('handoffToMarkdown turns XML fields into headings and drops block tags', () => {
94
+ const md = handoffToMarkdown('<handoff>\n<state>\nok\n</state>\n<next>\nrun `x`\n</next>\n</handoff>');
95
+ assert.equal(md, '#### 現況\nok\n#### 下一步\nrun `x`');
96
+ });
97
+
98
+ test('handoffToMarkdown leaves legacy Handoff untouched', () => {
99
+ assert.equal(handoffToMarkdown('#### 現況\nok'), '#### 現況\nok');
100
+ });
101
+
102
+ test('XML Handoff renders as headings, never as raw tags', () => {
103
+ const html = render({ rounds: [round({ handoff: '<handoff>\n<state>\nok\n</state>\n</handoff>' })], checkpoint_blocks: [] });
104
+ assert.ok(!html.includes('&lt;state&gt;'));
105
+ assert.ok(html.includes('現況'));
106
+ });
107
+
108
+ test('HANDOFF_FIELDS matches handoff-fields.sh', () => {
109
+ const script = path.join(__dirname, 'handoff-fields.sh');
110
+ const r = spawnSync('bash', ['-c', `. "${script}"; for k in $HANDOFF_KEYS $SESSION_HANDOFF_KEYS; do printf '%s=%s\\n' "$k" "$(handoff_key_heading "$k")"; done`], { encoding: 'utf8' });
111
+ const fromBash = Object.fromEntries(r.stdout.trim().split('\n').map((l) => l.split('=')));
112
+ assert.deepEqual(fromBash, HANDOFF_FIELDS);
113
+ });
114
+
93
115
  test('CLI: stdin JSON -> stdout HTML; invalid JSON -> exit 1', () => {
94
116
  const script = path.join(__dirname, 'timeline-render.js');
95
117
  const ok = spawnSync(process.execPath, [script], { input: JSON.stringify({ rounds: [round({})] }), encoding: 'utf8' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlog-tracker",
3
- "version": "0.33.5",
3
+ "version": "0.34.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,8 +21,8 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "scripts": {
24
- "test": "node --test cli/*.test.js cli/platforms/*.test.js scripts/*.test.js core/scripts/*.test.js",
25
- "version": "node scripts/sync-version.js && git add .claude-plugin/plugin.json .claude-plugin/marketplace.json README.md README.zh-TW.md"
24
+ "test": "node scripts/sync-codex-plugin.js --check && node --test cli/*.test.js cli/platforms/*.test.js scripts/*.test.js core/scripts/*.test.js",
25
+ "version": "node scripts/sync-version.js && git add .claude-plugin/plugin.json .codex-plugin/plugin.json .claude-plugin/marketplace.json README.md README.zh-TW.md"
26
26
  },
27
27
  "license": "Apache-2.0",
28
28
  "author": {