devlog-tracker 0.22.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 (120) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +234 -0
  3. package/bin/devlog-tracker.js +51 -0
  4. package/cli/init.js +55 -0
  5. package/cli/init.test.js +37 -0
  6. package/cli/merge-hooks.js +60 -0
  7. package/cli/merge-hooks.test.js +187 -0
  8. package/cli/platforms/codex.js +13 -0
  9. package/cli/platforms/cursor.js +13 -0
  10. package/cli/status.js +14 -0
  11. package/cli/status.test.js +33 -0
  12. package/cli/vendor.js +37 -0
  13. package/cli/vendor.test.js +84 -0
  14. package/codex/hooks/on-pre-tool.sh +19 -0
  15. package/codex/hooks/on-session-end.sh +31 -0
  16. package/codex/hooks/on-session-start.sh +37 -0
  17. package/codex/hooks/on-stop.sh +18 -0
  18. package/codex/hooks/on-user-prompt-submit.sh +15 -0
  19. package/codex/hooks/project-dir.sh +13 -0
  20. package/codex/hooks/test-adapters.sh +98 -0
  21. package/codex/hooks.json +54 -0
  22. package/commands/checkpoint.md +17 -0
  23. package/commands/clean.md +50 -0
  24. package/commands/compact.md +24 -0
  25. package/commands/continue.md +30 -0
  26. package/commands/keep.md +100 -0
  27. package/commands/lessons-drift.md +19 -0
  28. package/commands/lessons-off.md +16 -0
  29. package/commands/lessons-on.md +20 -0
  30. package/commands/lessons.md +20 -0
  31. package/commands/overview.md +32 -0
  32. package/commands/pause.md +20 -0
  33. package/commands/resume.md +18 -0
  34. package/commands/segment-watch.md +21 -0
  35. package/commands/span.md +13 -0
  36. package/commands/start.md +19 -0
  37. package/commands/status.md +14 -0
  38. package/cursor/hooks/on-pre-tool.sh +24 -0
  39. package/cursor/hooks/on-session-end.sh +11 -0
  40. package/cursor/hooks/on-session-start.sh +15 -0
  41. package/cursor/hooks/on-stop.sh +44 -0
  42. package/cursor/hooks/on-submit-prompt.sh +28 -0
  43. package/cursor/hooks/on-tool-failure.sh +16 -0
  44. package/cursor/hooks/project-dir.sh +10 -0
  45. package/cursor/hooks/test-adapters.sh +201 -0
  46. package/cursor/hooks.json +26 -0
  47. package/hooks/scripts/await-open.sh +23 -0
  48. package/hooks/scripts/checkpoint-set.sh +44 -0
  49. package/hooks/scripts/clean-devlog.sh +69 -0
  50. package/hooks/scripts/close-open-round.sh +186 -0
  51. package/hooks/scripts/compact-devlog.sh +72 -0
  52. package/hooks/scripts/detect-pending-question.sh +31 -0
  53. package/hooks/scripts/devlog-lock.sh +38 -0
  54. package/hooks/scripts/devlog-md.sh +222 -0
  55. package/hooks/scripts/devlog-path.sh +70 -0
  56. package/hooks/scripts/enforce-devlog.sh +629 -0
  57. package/hooks/scripts/files-snapshot.sh +70 -0
  58. package/hooks/scripts/json-field.sh +76 -0
  59. package/hooks/scripts/keep-move.sh +155 -0
  60. package/hooks/scripts/kept-list.sh +40 -0
  61. package/hooks/scripts/lessons-append.sh +93 -0
  62. package/hooks/scripts/lessons-drift-set.sh +42 -0
  63. package/hooks/scripts/lessons-off.sh +15 -0
  64. package/hooks/scripts/lessons-on.sh +21 -0
  65. package/hooks/scripts/lessons-read.sh +60 -0
  66. package/hooks/scripts/on-session-end.sh +14 -0
  67. package/hooks/scripts/on-stop-failure.sh +18 -0
  68. package/hooks/scripts/on-tool-failure.sh +25 -0
  69. package/hooks/scripts/pause-devlog.sh +13 -0
  70. package/hooks/scripts/redact-prompt.sh +19 -0
  71. package/hooks/scripts/resume-devlog.sh +41 -0
  72. package/hooks/scripts/round-start.sh +333 -0
  73. package/hooks/scripts/run-tests.sh +26 -0
  74. package/hooks/scripts/segment-watch-set.sh +47 -0
  75. package/hooks/scripts/segment-watch.sh +197 -0
  76. package/hooks/scripts/session-start-devlog.sh +157 -0
  77. package/hooks/scripts/span-close.sh +9 -0
  78. package/hooks/scripts/span-open.sh +21 -0
  79. package/hooks/scripts/start-devlog.sh +24 -0
  80. package/hooks/scripts/status-devlog.sh +43 -0
  81. package/hooks/scripts/tests/test-await-open.sh +112 -0
  82. package/hooks/scripts/tests/test-branch-scoped-integration.sh +108 -0
  83. package/hooks/scripts/tests/test-checkpoint-set.sh +36 -0
  84. package/hooks/scripts/tests/test-clean-devlog.sh +160 -0
  85. package/hooks/scripts/tests/test-cli-init-e2e.sh +37 -0
  86. package/hooks/scripts/tests/test-close-open-round.sh +325 -0
  87. package/hooks/scripts/tests/test-compact-devlog.sh +62 -0
  88. package/hooks/scripts/tests/test-devlog-lock.sh +37 -0
  89. package/hooks/scripts/tests/test-devlog-md.sh +530 -0
  90. package/hooks/scripts/tests/test-devlog-path.sh +118 -0
  91. package/hooks/scripts/tests/test-enforce-devlog-files.sh +233 -0
  92. package/hooks/scripts/tests/test-enforce-devlog-handoff-order.sh +223 -0
  93. package/hooks/scripts/tests/test-enforce-devlog-workspace.sh +333 -0
  94. package/hooks/scripts/tests/test-enforce-devlog.sh +1290 -0
  95. package/hooks/scripts/tests/test-files-snapshot.sh +142 -0
  96. package/hooks/scripts/tests/test-json-field.sh +57 -0
  97. package/hooks/scripts/tests/test-keep-move.sh +127 -0
  98. package/hooks/scripts/tests/test-kept-list.sh +80 -0
  99. package/hooks/scripts/tests/test-lessons-append.sh +138 -0
  100. package/hooks/scripts/tests/test-lessons-drift-set.sh +36 -0
  101. package/hooks/scripts/tests/test-lessons-on-off.sh +75 -0
  102. package/hooks/scripts/tests/test-lessons-read.sh +97 -0
  103. package/hooks/scripts/tests/test-on-interrupt.sh +186 -0
  104. package/hooks/scripts/tests/test-redact-prompt.sh +27 -0
  105. package/hooks/scripts/tests/test-resume-devlog.sh +35 -0
  106. package/hooks/scripts/tests/test-round-start.sh +825 -0
  107. package/hooks/scripts/tests/test-segment-watch-set.sh +75 -0
  108. package/hooks/scripts/tests/test-segment-watch.sh +468 -0
  109. package/hooks/scripts/tests/test-session-start-devlog.sh +508 -0
  110. package/hooks/scripts/tests/test-start-pause-devlog.sh +84 -0
  111. package/hooks/scripts/tests/test-status-span.sh +50 -0
  112. package/hooks/scripts/tests/test-workspace-snapshot.sh +130 -0
  113. package/hooks/scripts/workspace-snapshot.sh +69 -0
  114. package/package.json +37 -0
  115. package/skills/devlog-tracker/SKILL.md +411 -0
  116. package/skills/devlog-tracker/references/checkpoint-mode.md +42 -0
  117. package/skills/devlog-tracker/references/lessons-mode.md +32 -0
  118. package/skills/devlog-tracker/references/reply-fold.md +129 -0
  119. package/skills/devlog-tracker/references/round-segments.md +55 -0
  120. package/skills/devlog-tracker/references/span-mode.md +65 -0
@@ -0,0 +1,530 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for devlog-md.sh's segment-counting and insertion helpers. Run:
3
+ # bash hooks/scripts/test-devlog-md.sh
4
+ set -uo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7
+ # shellcheck source=devlog-md.sh
8
+ . "$SCRIPT_DIR/devlog-md.sh"
9
+
10
+ TMP_ROOT="$(mktemp -d)"
11
+ trap 'rm -rf "$TMP_ROOT"' EXIT
12
+
13
+ FAIL=0
14
+ assert_eq() {
15
+ local desc="$1" expected="$2" actual="$3"
16
+ if [ "$actual" = "$expected" ]; then
17
+ echo "PASS: $desc"
18
+ else
19
+ echo "FAIL: $desc (expected [$expected], got [$actual])"
20
+ FAIL=1
21
+ fi
22
+ }
23
+ assert_contains() {
24
+ local desc="$1" needle="$2" haystack="$3"
25
+ case "$haystack" in
26
+ *"$needle"*) echo "PASS: $desc" ;;
27
+ *) echo "FAIL: $desc (missing: $needle)"; FAIL=1 ;;
28
+ esac
29
+ }
30
+ assert_not_contains() {
31
+ local desc="$1" needle="$2" haystack="$3"
32
+ case "$haystack" in
33
+ *"$needle"*) echo "FAIL: $desc (should not contain: $needle)"; FAIL=1 ;;
34
+ *) echo "PASS: $desc" ;;
35
+ esac
36
+ }
37
+ assert_file_absent() {
38
+ local desc="$1" file="$2"
39
+ if [ ! -f "$file" ]; then
40
+ echo "PASS: $desc"
41
+ else
42
+ echo "FAIL: $desc (file exists: $file)"
43
+ FAIL=1
44
+ fi
45
+ }
46
+ assert_exit() {
47
+ local desc="$1" expected="$2" actual="$3"
48
+ if [ "$actual" -eq "$expected" ]; then
49
+ echo "PASS: $desc"
50
+ else
51
+ echo "FAIL: $desc (expected exit code $expected, got $actual)"
52
+ FAIL=1
53
+ fi
54
+ }
55
+
56
+ # --- devlog_count_segments: zero segments ----------------------------------
57
+ cat > "$TMP_ROOT/devlog.md" <<'EOF'
58
+ ## Round 1 — 2026-09-09T12:00:00+08:00
59
+
60
+ ### User Input
61
+ ```text
62
+ hello
63
+ ```
64
+
65
+ ### Summary
66
+ done
67
+
68
+ ### Reply
69
+ fixture reply.
70
+
71
+ ### Handoff
72
+ #### 現況
73
+ done
74
+
75
+ ### Status
76
+ DONE
77
+ EOF
78
+ START="$(devlog_list_round_starts "$TMP_ROOT/devlog.md" | awk '$2==1{print $1}')"
79
+ END="$(devlog_block_end "$TMP_ROOT/devlog.md" "$START")"
80
+ COUNT="$(devlog_count_segments "$TMP_ROOT/devlog.md" "$START" "$END")"
81
+ assert_eq "zero segments" "0" "$COUNT"
82
+
83
+ # --- devlog_count_segments: two existing segments ---------------------------
84
+ cat > "$TMP_ROOT/devlog.md" <<'EOF'
85
+ ## Round 1 — 2026-09-09T12:00:00+08:00
86
+
87
+ ### User Input
88
+ ```text
89
+ hello
90
+ ```
91
+
92
+ ### 段落 1 - 09:00
93
+ first
94
+
95
+ ### 段落 2 - 09:10
96
+ second
97
+
98
+ ### Summary
99
+ done
100
+
101
+ ### Reply
102
+ fixture reply.
103
+
104
+ ### Handoff
105
+ #### 現況
106
+ done
107
+
108
+ ### Status
109
+ DONE
110
+ EOF
111
+ START="$(devlog_list_round_starts "$TMP_ROOT/devlog.md" | awk '$2==1{print $1}')"
112
+ END="$(devlog_block_end "$TMP_ROOT/devlog.md" "$START")"
113
+ COUNT="$(devlog_count_segments "$TMP_ROOT/devlog.md" "$START" "$END")"
114
+ assert_eq "two existing segments" "2" "$COUNT"
115
+
116
+ # --- devlog_count_segments: a fenced line that itself looks like a heading
117
+ # must not count (fence-awareness, not just the regex anchor) ---------------
118
+ cat > "$TMP_ROOT/devlog.md" <<'EOF'
119
+ ## Round 1 — 2026-09-09T12:00:00+08:00
120
+
121
+ ### User Input
122
+ ```text
123
+ ### 段落 1 - 09:00
124
+ ```
125
+
126
+ ### Summary
127
+ done
128
+
129
+ ### Reply
130
+ fixture reply.
131
+
132
+ ### Handoff
133
+ #### 現況
134
+ done
135
+
136
+ ### Status
137
+ DONE
138
+ EOF
139
+ START="$(devlog_list_round_starts "$TMP_ROOT/devlog.md" | awk '$2==1{print $1}')"
140
+ END="$(devlog_block_end "$TMP_ROOT/devlog.md" "$START")"
141
+ COUNT="$(devlog_count_segments "$TMP_ROOT/devlog.md" "$START" "$END")"
142
+ assert_eq "fenced mention of 段落 not counted" "0" "$COUNT"
143
+
144
+ # --- devlog_insert_before_summary: inserts right before ### Summary --------
145
+ cat > "$TMP_ROOT/devlog.md" <<'EOF'
146
+ ## Round 1 — 2026-09-09T12:00:00+08:00
147
+
148
+ ### User Input
149
+ ```text
150
+ hello
151
+ ```
152
+
153
+ ### Summary
154
+ done
155
+
156
+ ### Reply
157
+ fixture reply.
158
+
159
+ ### Handoff
160
+ #### 現況
161
+ done
162
+
163
+ ### Status
164
+ DONE
165
+
166
+ ## Round 2 — 2026-09-09T13:00:00+08:00
167
+
168
+ ### Summary
169
+ other round summary marker should not be touched
170
+ EOF
171
+ printf '### 段落 1 - 09:30(回覆上一輪的問題)\n```text\nmy answer\n```\n\n' > "$TMP_ROOT/segment.txt"
172
+ START="$(devlog_list_round_starts "$TMP_ROOT/devlog.md" | awk '$2==1{print $1}')"
173
+ END="$(devlog_block_end "$TMP_ROOT/devlog.md" "$START")"
174
+ RESULT="$(devlog_insert_before_summary "$TMP_ROOT/devlog.md" "$START" "$END" "$TMP_ROOT/segment.txt")"
175
+ assert_contains "inserted segment text present" "my answer" "$RESULT"
176
+ # The inserted block must appear before Round 1's Summary, not Round 2's.
177
+ BEFORE_R1_SUMMARY="$(printf '%s\n' "$RESULT" | awk '/my answer/{print NR} /^### Summary$/{print NR; exit}')"
178
+ FIRST_LINE="$(printf '%s\n' "$BEFORE_R1_SUMMARY" | head -1)"
179
+ SECOND_LINE="$(printf '%s\n' "$BEFORE_R1_SUMMARY" | tail -1)"
180
+ if [ "$FIRST_LINE" -lt "$SECOND_LINE" ]; then
181
+ echo "PASS: segment text appears before Round 1's ### Summary"
182
+ else
183
+ echo "FAIL: segment text did not land before Round 1's ### Summary"
184
+ FAIL=1
185
+ fi
186
+ ROUND2_COUNT="$(printf '%s\n' "$RESULT" | grep -c 'other round summary marker')"
187
+ assert_eq "round 2 summary untouched (still exactly once)" "1" "$ROUND2_COUNT"
188
+
189
+ # --- devlog_insert_before_summary: still matches "### Summary" with a
190
+ # trailing space/tab (built via printf, not a literal in the heredoc, since
191
+ # editors tend to strip trailing whitespace on save) -----------------------
192
+ {
193
+ printf '## Round 1 — 2026-09-09T12:00:00+08:00\n\n'
194
+ printf '### User Input\n```text\nhello\n```\n\n'
195
+ printf '### Summary \n'
196
+ printf 'done\n\n'
197
+ printf '### Handoff\n#### 現況\ndone\n\n'
198
+ printf '### Status\nDONE\n'
199
+ } > "$TMP_ROOT/devlog_trailing_ws.md"
200
+ printf '### 段落 1 - 09:30(回覆上一輪的問題)\n```text\nmy trailing-ws answer\n```\n\n' > "$TMP_ROOT/segment_ws.txt"
201
+ START="$(devlog_list_round_starts "$TMP_ROOT/devlog_trailing_ws.md" | awk '$2==1{print $1}')"
202
+ END="$(devlog_block_end "$TMP_ROOT/devlog_trailing_ws.md" "$START")"
203
+ RESULT="$(devlog_insert_before_summary "$TMP_ROOT/devlog_trailing_ws.md" "$START" "$END" "$TMP_ROOT/segment_ws.txt")"
204
+ assert_contains "inserted segment text present (Summary has trailing space)" "my trailing-ws answer" "$RESULT"
205
+ BEFORE_SUMMARY_WS="$(printf '%s\n' "$RESULT" | awk '/my trailing-ws answer/{print NR} /^### Summary[ \t]*$/{print NR; exit}')"
206
+ FIRST_LINE_WS="$(printf '%s\n' "$BEFORE_SUMMARY_WS" | head -1)"
207
+ SECOND_LINE_WS="$(printf '%s\n' "$BEFORE_SUMMARY_WS" | tail -1)"
208
+ if [ "$FIRST_LINE_WS" -lt "$SECOND_LINE_WS" ]; then
209
+ echo "PASS: segment text appears before the trailing-space ### Summary line"
210
+ else
211
+ echo "FAIL: segment text did not land before the trailing-space ### Summary line"
212
+ FAIL=1
213
+ fi
214
+
215
+
216
+ # --- indented fence must hide ## Round N from list_round_starts ----------
217
+ cat > "$TMP_ROOT/indent.md" <<'EOF'
218
+ ## Round 1 — real
219
+
220
+ ### Status
221
+ DONE
222
+
223
+ ```text
224
+ ## Round 99 — fake
225
+ ```
226
+
227
+ ## Round 2 — real
228
+
229
+ ### Status
230
+ DONE
231
+ EOF
232
+ OUT="$(devlog_list_round_starts "$TMP_ROOT/indent.md")"
233
+ echo "$OUT" | grep -q ' 1$' || { echo "FAIL: missing round 1"; FAIL=1; }
234
+ echo "$OUT" | grep -q ' 2$' || { echo "FAIL: missing round 2"; FAIL=1; }
235
+ if echo "$OUT" | grep -q '99'; then
236
+ echo "FAIL: fenced round 99 counted"
237
+ FAIL=1
238
+ else
239
+ echo "PASS: indented fence hides Round 99"
240
+ fi
241
+
242
+ # --- devlog_strip_kept_index: rebuilding ## Kept 索引 repeatedly must not
243
+ # grow the blank-line separator in front of it (regression: the strip used
244
+ # to preserve the pre-existing separator blank line while the rebuild step
245
+ # in keep-move.sh unconditionally added another one on top of it) ----------
246
+ cat > "$TMP_ROOT/kept.md" <<'EOF'
247
+ ## Round 1 — 2026-09-09T12:00:00+08:00
248
+
249
+ ### Status
250
+ DONE
251
+ EOF
252
+ for i in 1 2 3; do
253
+ STRIPPED="$TMP_ROOT/kept-stripped-$i"
254
+ devlog_strip_kept_index "$TMP_ROOT/kept.md" "$STRIPPED"
255
+ {
256
+ cat "$STRIPPED"
257
+ printf '\n## Kept 索引\n'
258
+ printf -- '- devlog.topic-%s.md\n' "$i"
259
+ } > "$TMP_ROOT/kept.md"
260
+ done
261
+ HEADING_LINE="$(grep -n '^## Kept 索引' "$TMP_ROOT/kept.md" | head -1 | cut -d: -f1)"
262
+ PREV_LINE="$(sed -n "$((HEADING_LINE - 1))p" "$TMP_ROOT/kept.md")"
263
+ PREV2_LINE="$(sed -n "$((HEADING_LINE - 2))p" "$TMP_ROOT/kept.md")"
264
+ if [ -z "$PREV_LINE" ] && [ -n "$PREV2_LINE" ]; then
265
+ echo "PASS: exactly one blank line separates content from ## Kept 索引 after 3 rebuilds"
266
+ else
267
+ echo "FAIL: expected exactly one blank line before ## Kept 索引 after 3 rebuilds, got line $((HEADING_LINE - 1))=[$PREV_LINE] line $((HEADING_LINE - 2))=[$PREV2_LINE]"
268
+ FAIL=1
269
+ fi
270
+
271
+ # --- devlog_strip_lessons_index: same blank-line regression check, for
272
+ # "## Lessons 索引" (docs/design/lessons-mode.md) -----------------------
273
+ cat > "$TMP_ROOT/lessons.md" <<'EOF'
274
+ ## Round 1 — 2026-09-09T12:00:00+08:00
275
+
276
+ ### Status
277
+ DONE
278
+ EOF
279
+ for i in 1 2 3; do
280
+ STRIPPED="$TMP_ROOT/lessons-stripped-$i"
281
+ devlog_strip_lessons_index "$TMP_ROOT/lessons.md" "$STRIPPED"
282
+ {
283
+ cat "$STRIPPED"
284
+ printf '\n## Lessons 索引\n'
285
+ printf -- '- devlog.lessons.topic-%s.md\n' "$i"
286
+ } > "$TMP_ROOT/lessons.md"
287
+ done
288
+ HEADING_LINE="$(grep -n '^## Lessons 索引' "$TMP_ROOT/lessons.md" | head -1 | cut -d: -f1)"
289
+ PREV_LINE="$(sed -n "$((HEADING_LINE - 1))p" "$TMP_ROOT/lessons.md")"
290
+ PREV2_LINE="$(sed -n "$((HEADING_LINE - 2))p" "$TMP_ROOT/lessons.md")"
291
+ if [ -z "$PREV_LINE" ] && [ -n "$PREV2_LINE" ]; then
292
+ echo "PASS: exactly one blank line separates content from ## Lessons 索引 after 3 rebuilds"
293
+ else
294
+ echo "FAIL: expected exactly one blank line before ## Lessons 索引 after 3 rebuilds, got line $((HEADING_LINE - 1))=[$PREV_LINE] line $((HEADING_LINE - 2))=[$PREV2_LINE]"
295
+ FAIL=1
296
+ fi
297
+
298
+ # --- devlog_lessons_index_lines: reads back only the block body ------------
299
+ LINES="$(devlog_lessons_index_lines "$TMP_ROOT/lessons.md")"
300
+ case "$LINES" in
301
+ *"devlog.lessons.topic-3.md"*) echo "PASS: devlog_lessons_index_lines reads the rebuilt line" ;;
302
+ *) echo "FAIL: expected devlog.lessons.topic-3.md in lines, got: $LINES"; FAIL=1 ;;
303
+ esac
304
+ case "$LINES" in
305
+ *"## Lessons 索引"*) echo "FAIL: devlog_lessons_index_lines must not include the heading itself"; FAIL=1 ;;
306
+ *) echo "PASS: devlog_lessons_index_lines excludes the heading" ;;
307
+ esac
308
+
309
+ # --- last-round 工作區 body + claim state (no git needed for NO_CLAIM) ----
310
+ cat > "$TMP_ROOT/claim.md" <<'EOF'
311
+ ## Round 1 — 2026-09-11T00:00:00+08:00
312
+
313
+ ### Summary
314
+ old
315
+
316
+ ### Reply
317
+ fixture reply.
318
+
319
+ ### Handoff
320
+ #### 工作區
321
+ main @ deadbeef,工作樹乾淨
322
+ #### 現況
323
+ still going
324
+
325
+ ### Status
326
+ IN_PROGRESS
327
+
328
+ ## Round 2 — 2026-09-11T00:01:00+08:00
329
+
330
+ ### Summary
331
+ newer
332
+
333
+ ### Reply
334
+ fixture reply.
335
+
336
+ ### Handoff
337
+ #### 工作區
338
+ feat/foo @ abc1234
339
+ 未提交:a.txt
340
+ #### 現況
341
+ x
342
+
343
+ ### Status
344
+ BLOCKED
345
+ EOF
346
+ START="$(devlog_list_round_starts "$TMP_ROOT/claim.md" | awk 'END { print $1 }')"
347
+ END="$(devlog_block_end "$TMP_ROOT/claim.md" "$START")"
348
+ assert_eq "last round is 2" "2" "$(devlog_list_round_starts "$TMP_ROOT/claim.md" | awk 'END { print $2 }')"
349
+ assert_eq "last-round status BLOCKED" "BLOCKED" "$(devlog_round_status "$TMP_ROOT/claim.md" "$START" "$END")"
350
+ WS_BODY="$(devlog_round_workspace_body "$TMP_ROOT/claim.md" "$START" "$END")"
351
+ EXPECTED_BODY='feat/foo @ abc1234
352
+ 未提交:a.txt'
353
+ assert_eq "workspace body two lines" "$EXPECTED_BODY" "$WS_BODY"
354
+
355
+ cat > "$TMP_ROOT/segments.md" <<'EOF'
356
+ ## Round 1 — 2026-09-11T00:00:00+08:00
357
+
358
+ ### User Input
359
+ main @ user-input-only,工作樹乾淨
360
+
361
+ ### 段落 1
362
+ first
363
+ ```text
364
+ main @ live123,工作樹乾淨
365
+ ```
366
+
367
+ ### Summary
368
+ main @ summary-only,工作樹乾淨
369
+
370
+ ### 段落 2
371
+ second
372
+ EOF
373
+ SEG_START="$(devlog_list_round_starts "$TMP_ROOT/segments.md" | awk 'END { print $1 }')"
374
+ SEG_END="$(devlog_block_end "$TMP_ROOT/segments.md" "$SEG_START")"
375
+ SEG_BODY="$(devlog_round_segments_body "$TMP_ROOT/segments.md" "$SEG_START" "$SEG_END")"
376
+ assert_contains "segment bodies include first segment" "first" "$SEG_BODY"
377
+ assert_contains "segment bodies include fenced snapshot" "main @ live123,工作樹乾淨" "$SEG_BODY"
378
+ assert_contains "segment bodies include second segment" "second" "$SEG_BODY"
379
+ case "$SEG_BODY" in
380
+ *user-input-only*|*summary-only*) echo "FAIL: non-segment text leaked"; FAIL=1 ;;
381
+ *) echo "PASS: non-segment text excluded" ;;
382
+ esac
383
+
384
+ cat > "$TMP_ROOT/done.md" <<'EOF'
385
+ ## Round 1 — 2026-09-11T00:00:00+08:00
386
+
387
+ ### Handoff
388
+ #### 工作區
389
+ main @ deadbeef,工作樹乾淨
390
+
391
+ ### Status
392
+ DONE
393
+ EOF
394
+ assert_eq "DONE is NO_CLAIM" "NO_CLAIM" "$(workspace_claim_state "$TMP_ROOT" "$TMP_ROOT/done.md")"
395
+
396
+ cat > "$TMP_ROOT/empty-ws.md" <<'EOF'
397
+ ## Round 1 — 2026-09-11T00:00:00+08:00
398
+
399
+ ### Handoff
400
+ #### 現況
401
+ x
402
+
403
+ ### Status
404
+ IN_PROGRESS
405
+ EOF
406
+ assert_eq "missing 工作區 is NO_CLAIM" "NO_CLAIM" "$(workspace_claim_state "$TMP_ROOT" "$TMP_ROOT/empty-ws.md")"
407
+
408
+ cat > "$TMP_ROOT/interrupted.md" <<'EOF'
409
+ ## Round 1 — 2026-09-11T00:00:00+08:00
410
+
411
+ ### Handoff
412
+ #### 工作區
413
+ main @ deadbeef,工作樹乾淨
414
+
415
+ ### Status
416
+ INTERRUPTED
417
+ [reason: dangling:next_prompt]
418
+ EOF
419
+ assert_eq "INTERRUPTED+reason with 工作區 is not DONE" "MISMATCH" "$(workspace_claim_state "$TMP_ROOT" "$TMP_ROOT/interrupted.md")"
420
+
421
+ # --- fence parity bug: an unclosed ``` earlier in the round (e.g. pasted
422
+ # code in User Input that forgot to close its fence) must not make the
423
+ # real #### 工作區 further down invisible. enforce-devlog.sh guards this
424
+ # exact class of bug with NOFENCE fail-open; devlog-md.sh's range-scoped
425
+ # functions (used by workspace_claim_state / round-start.sh /
426
+ # segment-watch.sh) must degrade the same way, not silently return empty. --
427
+ cat > "$TMP_ROOT/unclosed-fence.md" <<'EOF'
428
+ ## Round 1 — 2026-09-11T00:00:00+08:00
429
+
430
+ ### User Input
431
+ ```text
432
+ 使用者貼了一段忘記收尾的程式碼
433
+ def foo():
434
+ pass
435
+
436
+ ### Handoff
437
+ #### 工作區
438
+ main @ deadbeef,工作樹乾淨
439
+ #### 現況
440
+ 測試 fence parity bug
441
+
442
+ ### Status
443
+ IN_PROGRESS
444
+ EOF
445
+ START="$(devlog_list_round_starts "$TMP_ROOT/unclosed-fence.md" | awk 'END { print $1 }')"
446
+ END="$(devlog_block_end "$TMP_ROOT/unclosed-fence.md" "$START")"
447
+ assert_eq "status survives unclosed fence" "IN_PROGRESS" "$(devlog_round_status "$TMP_ROOT/unclosed-fence.md" "$START" "$END")"
448
+ assert_eq "workspace body survives unclosed fence" "main @ deadbeef,工作樹乾淨" "$(devlog_round_workspace_body "$TMP_ROOT/unclosed-fence.md" "$START" "$END")"
449
+ # workspace_claim_state on a non-git dir: workspace_snapshot returns "非 git
450
+ # 工作區", which differs from the claimed body above -> must be MISMATCH.
451
+ # Before the fix this came back NO_CLAIM because the claimed body parsed
452
+ # empty, silently defeating the whole drift-detection feature.
453
+ NONGIT_DIR="$TMP_ROOT/nongit"
454
+ mkdir -p "$NONGIT_DIR"
455
+ assert_eq "unclosed fence still yields a claim (MISMATCH, not NO_CLAIM)" "MISMATCH" "$(workspace_claim_state "$NONGIT_DIR" "$TMP_ROOT/unclosed-fence.md")"
456
+
457
+ cat > "$TMP_ROOT/unclosed-fence-segments.md" <<'EOF'
458
+ ## Round 1 — 2026-09-11T00:00:00+08:00
459
+
460
+ ### User Input
461
+ ```text
462
+ 使用者貼了一段忘記收尾的程式碼
463
+ def foo():
464
+ pass
465
+
466
+ ### 段落 1
467
+ real segment content
468
+
469
+ ### Status
470
+ IN_PROGRESS
471
+ EOF
472
+ SEG_START="$(devlog_list_round_starts "$TMP_ROOT/unclosed-fence-segments.md" | awk 'END { print $1 }')"
473
+ SEG_END="$(devlog_block_end "$TMP_ROOT/unclosed-fence-segments.md" "$SEG_START")"
474
+ assert_contains "segment body survives unclosed fence" "real segment content" "$(devlog_round_segments_body "$TMP_ROOT/unclosed-fence-segments.md" "$SEG_START" "$SEG_END")"
475
+
476
+ # --- devlog_merge_round_current ---------------------------------------
477
+ mkdir -p "$TMP_ROOT/.devlog"
478
+ MERGE_MAIN="$TMP_ROOT/.devlog/merge-main.md"
479
+ MERGE_CUR="$TMP_ROOT/.devlog/merge-current.md"
480
+
481
+ printf '## Round 1 — 2026-09-17T09:00:00+0800\n\n### Summary\ndone\n\n### Handoff\n#### 現況\nok\n\n### Status\nDONE\n' > "$MERGE_MAIN"
482
+ printf '## Round 2 — 2026-09-17T09:05:00+0800\n\n### User Input\n```text\nhi\n```\n\n### Summary\ns2\n\n### Handoff\n#### 現況\nc2\n\n### Status\nDONE\n' > "$MERGE_CUR"
483
+
484
+ devlog_merge_round_current "$MERGE_MAIN" "$MERGE_CUR"
485
+
486
+ MERGED="$(cat "$MERGE_MAIN")"
487
+ assert_contains "merge: round 1 still present" "## Round 1" "$MERGED"
488
+ assert_contains "merge: round 2 appended" "## Round 2" "$MERGED"
489
+ assert_file_absent "merge: round-current removed after merge" "$MERGE_CUR"
490
+
491
+ # merging an absent/empty round-current is a silent no-op
492
+ BEFORE="$(cat "$MERGE_MAIN")"
493
+ devlog_merge_round_current "$MERGE_MAIN" "$MERGE_CUR"
494
+ AFTER="$(cat "$MERGE_MAIN")"
495
+ if [ "$BEFORE" = "$AFTER" ]; then echo "PASS: merge no-op when round-current absent"; else echo "FAIL: merge no-op when round-current absent"; FAIL=1; fi
496
+
497
+ # --- devlog_reopen_last_round ------------------------------------------
498
+ REOPEN_MAIN="$TMP_ROOT/.devlog/reopen-main.md"
499
+ REOPEN_CUR="$TMP_ROOT/.devlog/reopen-current.md"
500
+ rm -f "$REOPEN_CUR"
501
+
502
+ printf '## Round 1 — 2026-09-17T09:00:00+0800\n\n### Summary\ns1\n\n### Handoff\n#### 現況\nc1\n\n### Status\nDONE\n\n## Round 2 — 2026-09-17T09:10:00+0800\n\n### User Input\n```text\n問題?\n```\n\n### Summary\ns2\n\n### Handoff\n#### 現況\nBLOCKED 等答案\n\n### Status\nBLOCKED\n' > "$REOPEN_MAIN"
503
+
504
+ devlog_reopen_last_round "$REOPEN_MAIN" "$REOPEN_CUR"
505
+ RC=$?
506
+ assert_exit "reopen: returns 0 when a round exists" 0 "$RC"
507
+
508
+ REOPENED="$(cat "$REOPEN_CUR" 2>/dev/null || echo '')"
509
+ assert_contains "reopen: round 2 moved into round-current" "## Round 2" "$REOPENED"
510
+ assert_not_contains "reopen: round 1 not pulled along" "## Round 1" "$REOPENED"
511
+
512
+ REMAINING="$(cat "$REOPEN_MAIN")"
513
+ assert_contains "reopen: round 1 stays in main file" "## Round 1" "$REMAINING"
514
+ assert_not_contains "reopen: round 2 removed from main file" "## Round 2" "$REMAINING"
515
+
516
+ # reopen on a file with no round at all fails without touching either file
517
+ EMPTY_MAIN="$TMP_ROOT/.devlog/empty-main.md"
518
+ : > "$EMPTY_MAIN"
519
+ rm -f "$TMP_ROOT/.devlog/should-not-exist.md"
520
+ devlog_reopen_last_round "$EMPTY_MAIN" "$TMP_ROOT/.devlog/should-not-exist.md"
521
+ assert_exit "reopen: returns 1 when no round exists" 1 $?
522
+ assert_file_absent "reopen: no round-current created on failure" "$TMP_ROOT/.devlog/should-not-exist.md"
523
+
524
+ if [ "$FAIL" -eq 0 ]; then
525
+ echo "All checks passed."
526
+ exit 0
527
+ else
528
+ echo "Some checks FAILED."
529
+ exit 1
530
+ fi
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ . "$SCRIPT_DIR/devlog-path.sh"
5
+ TMP="$(mktemp -d)"
6
+ trap 'rm -rf "$TMP"' EXIT
7
+ FAIL=0
8
+
9
+ git_setup() {
10
+ git -C "$1" init -q
11
+ git -C "$1" -c user.email=t@t.t -c user.name=t commit -q --allow-empty -m init
12
+ }
13
+
14
+ # --- non-git directory: falls back to devlog.md -------------------------
15
+ NONGIT="$TMP/nongit"
16
+ mkdir -p "$NONGIT"
17
+ devlog_resolve_paths "$NONGIT"
18
+ [ "$DEVLOG_FILE" = "$NONGIT/.devlog/devlog.md" ] \
19
+ && echo "PASS: non-git falls back to devlog.md" \
20
+ || { echo "FAIL: non-git got $DEVLOG_FILE"; FAIL=1; }
21
+
22
+ # --- main branch: devlog.md ----------------------------------------------
23
+ MAINREPO="$TMP/mainrepo"
24
+ mkdir -p "$MAINREPO"
25
+ git_setup "$MAINREPO"
26
+ git -C "$MAINREPO" branch -M main
27
+ devlog_resolve_paths "$MAINREPO"
28
+ [ "$DEVLOG_FILE" = "$MAINREPO/.devlog/devlog.md" ] \
29
+ && echo "PASS: main branch keeps devlog.md" \
30
+ || { echo "FAIL: main got $DEVLOG_FILE"; FAIL=1; }
31
+
32
+ # --- master branch: devlog.md --------------------------------------------
33
+ MASTERREPO="$TMP/masterrepo"
34
+ mkdir -p "$MASTERREPO"
35
+ git_setup "$MASTERREPO"
36
+ git -C "$MASTERREPO" branch -M master
37
+ devlog_resolve_paths "$MASTERREPO"
38
+ [ "$DEVLOG_FILE" = "$MASTERREPO/.devlog/devlog.md" ] \
39
+ && echo "PASS: master branch keeps devlog.md" \
40
+ || { echo "FAIL: master got $DEVLOG_FILE"; FAIL=1; }
41
+
42
+ # --- feature branch: devlog.<branch>.md ----------------------------------
43
+ FEATREPO="$TMP/featrepo"
44
+ mkdir -p "$FEATREPO"
45
+ git_setup "$FEATREPO"
46
+ git -C "$FEATREPO" checkout -q -b feature-x
47
+ devlog_resolve_paths "$FEATREPO"
48
+ [ "$DEVLOG_FILE" = "$FEATREPO/.devlog/devlog.feature-x.md" ] \
49
+ && echo "PASS: feature branch maps to devlog.feature-x.md" \
50
+ || { echo "FAIL: feature-x got $DEVLOG_FILE"; FAIL=1; }
51
+
52
+ # --- branch with slash: sanitized ----------------------------------------
53
+ SLASHREPO="$TMP/slashrepo"
54
+ mkdir -p "$SLASHREPO"
55
+ git_setup "$SLASHREPO"
56
+ git -C "$SLASHREPO" checkout -q -b feature/foo
57
+ devlog_resolve_paths "$SLASHREPO"
58
+ [ "$DEVLOG_FILE" = "$SLASHREPO/.devlog/devlog.feature-foo.md" ] \
59
+ && echo "PASS: slash in branch name sanitized to -" \
60
+ || { echo "FAIL: feature/foo got $DEVLOG_FILE"; FAIL=1; }
61
+
62
+ # --- detached HEAD: falls back to worktree dirname -----------------------
63
+ DETACHEDREPO="$TMP/detached-repo"
64
+ mkdir -p "$DETACHEDREPO"
65
+ git_setup "$DETACHEDREPO"
66
+ git -C "$DETACHEDREPO" checkout -q --detach
67
+ devlog_resolve_paths "$DETACHEDREPO"
68
+ [ "$DEVLOG_FILE" = "$DETACHEDREPO/.devlog/devlog.detached-repo.md" ] \
69
+ && echo "PASS: detached HEAD falls back to worktree dirname" \
70
+ || { echo "FAIL: detached got $DEVLOG_FILE"; FAIL=1; }
71
+
72
+ # --- migration: existing devlog.md content moves to branch file ----------
73
+ MIGREPO="$TMP/migrepo"
74
+ mkdir -p "$MIGREPO/.devlog"
75
+ : > "$MIGREPO/.devlog/.enabled"
76
+ git_setup "$MIGREPO"
77
+ git -C "$MIGREPO" checkout -q -b feature-y
78
+ echo "## Round 1 existing content" > "$MIGREPO/.devlog/devlog.md"
79
+ devlog_resolve_paths "$MIGREPO"
80
+ if [ "$DEVLOG_FILE" = "$MIGREPO/.devlog/devlog.feature-y.md" ] \
81
+ && [ -f "$DEVLOG_FILE" ] \
82
+ && [ ! -f "$MIGREPO/.devlog/devlog.md" ] \
83
+ && grep -q "existing content" "$DEVLOG_FILE"; then
84
+ echo "PASS: migration renames devlog.md into branch file"
85
+ else
86
+ echo "FAIL: migration file=$DEVLOG_FILE"
87
+ FAIL=1
88
+ fi
89
+
90
+ # --- migration is idempotent: second call doesn't re-move or error -------
91
+ echo "## Round 2 written after migration" >> "$MIGREPO/.devlog/devlog.feature-y.md"
92
+ devlog_resolve_paths "$MIGREPO"
93
+ if [ "$DEVLOG_FILE" = "$MIGREPO/.devlog/devlog.feature-y.md" ] \
94
+ && grep -q "Round 2 written after migration" "$DEVLOG_FILE"; then
95
+ echo "PASS: second resolve is a no-op, content preserved"
96
+ else
97
+ echo "FAIL: second resolve altered state, got $DEVLOG_FILE"
98
+ FAIL=1
99
+ fi
100
+
101
+ # --- migration does NOT fire when .devlog/.enabled is absent (Fix 6) -----
102
+ NOENABLEDREPO="$TMP/noenabledrepo"
103
+ mkdir -p "$NOENABLEDREPO/.devlog"
104
+ git_setup "$NOENABLEDREPO"
105
+ git -C "$NOENABLEDREPO" checkout -q -b feature-z
106
+ echo "## Round 1 existing content" > "$NOENABLEDREPO/.devlog/devlog.md"
107
+ devlog_resolve_paths "$NOENABLEDREPO"
108
+ if [ "$DEVLOG_FILE" = "$NOENABLEDREPO/.devlog/devlog.feature-z.md" ] \
109
+ && [ ! -f "$DEVLOG_FILE" ] \
110
+ && [ -f "$NOENABLEDREPO/.devlog/devlog.md" ]; then
111
+ echo "PASS: migration does not fire when .enabled is absent, old devlog.md left untouched"
112
+ else
113
+ echo "FAIL: no-.enabled migration file=$DEVLOG_FILE resolved_exists=$([ -f "$DEVLOG_FILE" ] && echo y || echo n) old_exists=$([ -f "$NOENABLEDREPO/.devlog/devlog.md" ] && echo y || echo n)"
114
+ FAIL=1
115
+ fi
116
+
117
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
118
+ else echo "Some checks FAILED."; exit 1; fi