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,1290 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for round-start.sh + enforce-devlog.sh cooperating through the
3
+ # .devlog/.turn-start marker. No framework — plain assert-and-exit, matching
4
+ # this repo's existing style. Run directly:
5
+ # bash hooks/scripts/test-enforce-devlog.sh
6
+ set -uo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ TMP_ROOT="$(mktemp -d)"
10
+ trap 'rm -rf "$TMP_ROOT"' EXIT
11
+
12
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
13
+ DEVLOG_DIR="$TMP_ROOT/.devlog"
14
+ mkdir -p "$DEVLOG_DIR"
15
+ touch "$DEVLOG_DIR/.enabled"
16
+
17
+ FAIL=0
18
+ assert_exit() {
19
+ local desc="$1" expected="$2" actual="$3"
20
+ if [ "$actual" -eq "$expected" ]; then
21
+ echo "PASS: $desc"
22
+ else
23
+ echo "FAIL: $desc (expected exit $expected, got $actual)"
24
+ FAIL=1
25
+ fi
26
+ }
27
+
28
+ assert_file_absent() {
29
+ local desc="$1" path="$2"
30
+ if [ ! -e "$path" ]; then
31
+ echo "PASS: $desc"
32
+ else
33
+ echo "FAIL: $desc (expected absent: $path)"
34
+ FAIL=1
35
+ fi
36
+ }
37
+
38
+ assert_contains() {
39
+ local desc="$1" needle="$2" haystack="$3"
40
+ case "$haystack" in
41
+ *"$needle"*) echo "PASS: $desc" ;;
42
+ *) echo "FAIL: $desc (expected to contain '$needle')"; FAIL=1 ;;
43
+ esac
44
+ }
45
+
46
+ assert_not_contains() {
47
+ local desc="$1" needle="$2" haystack="$3"
48
+ case "$haystack" in
49
+ *"$needle"*) echo "FAIL: $desc (expected NOT to contain '$needle')"; FAIL=1 ;;
50
+ *) echo "PASS: $desc" ;;
51
+ esac
52
+ }
53
+
54
+ # Round-current split: enforce-devlog.sh validates .round-current.md now
55
+ # (not devlog.md), and merges it into devlog.md on success. One assertion
56
+ # per "should succeed" case exercises that merge.
57
+ assert_round_merged() {
58
+ local desc="$1" marker="${2:-fixture}"
59
+ assert_file_absent "$desc: .round-current.md merged away" "$DEVLOG_DIR/.round-current.md"
60
+ assert_contains "$desc: content landed in devlog.md" "$marker" "$(cat "$DEVLOG_DIR/devlog.md" 2>/dev/null || true)"
61
+ }
62
+
63
+ # Completes "the round being validated this Stop call": overwrites the
64
+ # skeleton round-start.sh just opened in .round-current.md with a full
65
+ # Summary/Handoff/Status(DONE) round body (same header shape devlog.md
66
+ # rounds use — devlog_merge_round_current appends this verbatim on success).
67
+ append_minimal_round() {
68
+ local n="$1" ts="$2"
69
+ cat > "$DEVLOG_DIR/.round-current.md" <<EOF
70
+ ## Round ${n} — ${ts}
71
+
72
+ ### Summary
73
+ fixture
74
+
75
+ ### Reply
76
+ fixture reply.
77
+
78
+ ### Handoff
79
+ #### 現況
80
+ fixture
81
+
82
+ ### Status
83
+ DONE
84
+ EOF
85
+ }
86
+
87
+ # Seeds *prior, already-closed* history directly into devlog.md — content
88
+ # that is NOT "the round being validated" by an immediately-following
89
+ # enforce-devlog.sh call (enforce-devlog.sh only ever reads .round-current.md
90
+ # for content validation now).
91
+ seed_history_round() {
92
+ local n="$1" ts="$2"
93
+ cat >> "$DEVLOG_DIR/devlog.md" <<EOF
94
+ ## Round ${n} — ${ts}
95
+
96
+ ### Summary
97
+ fixture
98
+
99
+ ### Reply
100
+ fixture reply.
101
+
102
+ ### Handoff
103
+ #### 現況
104
+ fixture
105
+
106
+ ### Status
107
+ DONE
108
+ EOF
109
+ }
110
+
111
+ # --- Scenario 1: round starts, devlog never touched -> must block ---------
112
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
113
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
114
+ assert_exit "no write during round -> blocked" 2 $?
115
+
116
+ # --- Scenario 2: round starts, devlog gets a new Round block -> must pass.
117
+ # This whole script runs in well under a second, so round-start.sh's marker
118
+ # capture and this write below routinely land in the same wall-clock second
119
+ # -- exactly the condition that used to race under the old mtime check.
120
+ append_minimal_round 1 "2026-09-08T00:00:00+08:00"
121
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
122
+ assert_exit "devlog written this round (same-second race) -> allowed" 0 $?
123
+ assert_round_merged "devlog written this round (same-second race)"
124
+
125
+ # --- Scenario 3: next round starts, devlog NOT touched again -> must block
126
+ # again, proving the marker was correctly refreshed and isn't just "always
127
+ # pass after the first write".
128
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
129
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
130
+ assert_exit "no write in the following round -> blocked again" 2 $?
131
+
132
+ # --- Scenario 4: stop_hook_active guard must work even without jq --------
133
+ PATH_NO_JQ="$TMP_ROOT/no-jq-path"
134
+ mkdir -p "$PATH_NO_JQ"
135
+ for bin in bash cat printf cksum; do
136
+ bin_path="$(command -v "$bin" 2>/dev/null || true)"
137
+ [ -n "$bin_path" ] && ln -sf "$bin_path" "$PATH_NO_JQ/$bin"
138
+ done
139
+ echo '{"stop_hook_active":true}' | PATH="$PATH_NO_JQ" bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
140
+ assert_exit "stop_hook_active=true without jq -> exits 0 immediately" 0 $?
141
+
142
+ # Regression: false positive when another field contains "true" after stop_hook_active
143
+ echo '{"stop_hook_active":false,"other":true}' | PATH="$PATH_NO_JQ" bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
144
+ assert_exit "stop_hook_active=false with other:true -> does NOT short-circuit (blocks due to empty devlog)" 2 $?
145
+
146
+ # Regression: false positive with typo in field name but "true" appears later
147
+ echo '{"stop_hock_active":false,"transcript_path":"/tmp/xtrue.jsonl"}' | PATH="$PATH_NO_JQ" bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
148
+ assert_exit "misspelled field with 'true' in path -> does NOT short-circuit (blocks due to empty devlog)" 2 $?
149
+
150
+ # --- Span Mode Scenario 1: span open, ticks below max, no write -----------
151
+ # -> allowed even though nothing was written this tick; round-start.sh must
152
+ # have incremented ticks_since_checkin from 0 to 1.
153
+ cat > "$DEVLOG_DIR/.span-open" <<'SPANEOF'
154
+ {
155
+ "round": 1,
156
+ "opened_at": "2026-09-08T00:00:00+08:00",
157
+ "ticks_since_checkin": 0,
158
+ "max_silent_ticks": 5
159
+ }
160
+ SPANEOF
161
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
162
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
163
+ assert_exit "span open, ticks below max, no write -> allowed" 0 $?
164
+ SPAN_TICKS_AFTER="$(grep -o '"ticks_since_checkin"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.span-open" | grep -o '[0-9]\+$')"
165
+ if [ "$SPAN_TICKS_AFTER" = "1" ]; then
166
+ echo "PASS: round-start.sh incremented ticks_since_checkin to 1"
167
+ else
168
+ echo "FAIL: round-start.sh should have incremented ticks_since_checkin to 1, got '$SPAN_TICKS_AFTER'"
169
+ FAIL=1
170
+ fi
171
+
172
+ # --- Span Mode Scenario 2: ticks reach max, devlog still untouched --------
173
+ # -> falls back to normal enforcement, blocks.
174
+ cat > "$DEVLOG_DIR/.span-open" <<'SPANEOF'
175
+ {
176
+ "round": 1,
177
+ "opened_at": "2026-09-08T00:00:00+08:00",
178
+ "ticks_since_checkin": 5,
179
+ "max_silent_ticks": 5
180
+ }
181
+ SPANEOF
182
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
183
+ SPAN_HASH_MISS_MSG="$(echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" 2>&1)"
184
+ assert_exit "span open, ticks at max, no write -> blocked (falls back to normal check)" 2 $?
185
+ case "$SPAN_HASH_MISS_MSG" in
186
+ *"User Input / Summary / Reply / Handoff / Status"*) echo "PASS: expired-span hash-miss message names all Round sections" ;;
187
+ *) echo "FAIL: expired-span hash-miss message should name User Input / Summary / Reply / Handoff / Status, got: $SPAN_HASH_MISS_MSG"; FAIL=1 ;;
188
+ esac
189
+ case "$SPAN_HASH_MISS_MSG" in
190
+ *"不要再新增一個 ## Round"*) echo "FAIL: expired-span hash-miss message must not forbid a new Round"; FAIL=1 ;;
191
+ *) echo "PASS: expired-span hash-miss message does not forbid a new Round" ;;
192
+ esac
193
+ case "$SPAN_HASH_MISS_MSG" in
194
+ *".round-current.md"*'## Round'*) echo "PASS: expired-span hash-miss message points at .round-current.md" ;;
195
+ *) echo "FAIL: expired-span hash-miss message should point at .round-current.md, got: $SPAN_HASH_MISS_MSG"; FAIL=1 ;;
196
+ esac
197
+ case "$SPAN_HASH_MISS_MSG" in
198
+ *"檔案尾端"*) echo "FAIL: expired-span hash-miss message still says 檔案尾端 (pre round-current-split wording)"; FAIL=1 ;;
199
+ *) echo "PASS: expired-span hash-miss message no longer says 檔案尾端" ;;
200
+ esac
201
+
202
+ # --- Span Mode Scenario 3: ticks at max, devlog IS written this tick ------
203
+ # -> allowed, and ticks_since_checkin resets to 0.
204
+ append_minimal_round 2 "2026-09-08T00:10:00+08:00"
205
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
206
+ assert_exit "span open, ticks at max, write happens -> allowed" 0 $?
207
+ assert_round_merged "span open, ticks at max, write happens"
208
+ SPAN_TICKS_RESET="$(grep -o '"ticks_since_checkin"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.span-open" | grep -o '[0-9]\+$')"
209
+ if [ "$SPAN_TICKS_RESET" = "0" ]; then
210
+ echo "PASS: ticks_since_checkin reset to 0 after a successful write"
211
+ else
212
+ echo "FAIL: ticks_since_checkin should have reset to 0, got '$SPAN_TICKS_RESET'"
213
+ FAIL=1
214
+ fi
215
+
216
+ # --- Span Mode Scenario 4: malformed .span-open -> treated as absent ------
217
+ echo "not valid json at all" > "$DEVLOG_DIR/.span-open"
218
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
219
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
220
+ assert_exit "malformed .span-open -> falls through to normal check, blocked (no write)" 2 $?
221
+
222
+ # --- Checkpoint Mode Scenario 1: below threshold, no checkpoint yet -------
223
+ # round-start.sh should bump rounds_since_checkpoint from 0 to 1; a normal
224
+ # round write still satisfies the base hash check, so this must pass and
225
+ # leave the counter at 1 (untouched, since no new "## Checkpoint" appeared).
226
+ rm -f "$DEVLOG_DIR/.span-open"
227
+ cat > "$DEVLOG_DIR/.checkpoint-state" <<'CPEOF'
228
+ {"rounds_since_checkpoint": 0, "max_silent_rounds": 2, "checkpoint_marker_count": 0}
229
+ CPEOF
230
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
231
+ append_minimal_round 3 "2026-09-08T00:20:00+08:00"
232
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
233
+ assert_exit "checkpoint below threshold, round written -> allowed" 0 $?
234
+ assert_round_merged "checkpoint below threshold, round written"
235
+ CP_ROUNDS_AFTER="$(grep -o '"rounds_since_checkpoint"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
236
+ if [ "$CP_ROUNDS_AFTER" = "1" ]; then
237
+ echo "PASS: round-start.sh incremented rounds_since_checkpoint to 1"
238
+ else
239
+ echo "FAIL: rounds_since_checkpoint should be 1, got '$CP_ROUNDS_AFTER'"
240
+ FAIL=1
241
+ fi
242
+
243
+ # --- Checkpoint Mode Scenario 2: threshold reached, no checkpoint written -
244
+ # max_silent_rounds is 2; this is the second round-start.sh increment
245
+ # (1 -> 2), so rounds_since_checkpoint hits the threshold. Even though the
246
+ # round writes normal content (satisfying the base hash check), no
247
+ # "## Checkpoint" heading exists yet -> must be blocked.
248
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
249
+ append_minimal_round 4 "2026-09-08T00:25:00+08:00"
250
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
251
+ assert_exit "checkpoint threshold reached, no checkpoint block -> blocked" 2 $?
252
+
253
+ # --- Checkpoint Mode Scenario 3: checkpoint block written -> resolves it --
254
+ # Simulates Claude responding to the block by appending a checkpoint
255
+ # heading. The retry must pass and reset both counters.
256
+ echo "## Checkpoint(Round 3-4 摘要)" >> "$DEVLOG_DIR/devlog.md"
257
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
258
+ assert_exit "checkpoint block written -> allowed" 0 $?
259
+ CP_ROUNDS_RESET="$(grep -o '"rounds_since_checkpoint"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
260
+ CP_MARKER_AFTER="$(grep -o '"checkpoint_marker_count"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
261
+ if [ "$CP_ROUNDS_RESET" = "0" ] && [ "$CP_MARKER_AFTER" = "1" ]; then
262
+ echo "PASS: checkpoint write reset rounds_since_checkpoint to 0 and checkpoint_marker_count to 1"
263
+ else
264
+ echo "FAIL: expected rounds_since_checkpoint=0 and checkpoint_marker_count=1, got rounds=$CP_ROUNDS_RESET marker=$CP_MARKER_AFTER"
265
+ FAIL=1
266
+ fi
267
+
268
+ # --- Checkpoint Mode Scenario 4: an open, under-budget span pauses the ----
269
+ # checkpoint counter, so it doesn't get double-jeopardy'd during an
270
+ # automated run that Span Mode is deliberately keeping quiet.
271
+ cat > "$DEVLOG_DIR/.span-open" <<'SPANEOF'
272
+ {
273
+ "round": 4,
274
+ "opened_at": "2026-09-08T00:30:00+08:00",
275
+ "ticks_since_checkin": 0,
276
+ "max_silent_ticks": 5
277
+ }
278
+ SPANEOF
279
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
280
+ CP_ROUNDS_DURING_SPAN="$(grep -o '"rounds_since_checkpoint"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
281
+ if [ "$CP_ROUNDS_DURING_SPAN" = "0" ]; then
282
+ echo "PASS: rounds_since_checkpoint not incremented while span silently passes this tick"
283
+ else
284
+ echo "FAIL: rounds_since_checkpoint should stay 0 during span pass-through, got '$CP_ROUNDS_DURING_SPAN'"
285
+ FAIL=1
286
+ fi
287
+ rm -f "$DEVLOG_DIR/.span-open"
288
+
289
+ # --- Checkpoint Mode Scenario 5: malformed .checkpoint-state -> fail-open -
290
+ echo "not valid json at all" > "$DEVLOG_DIR/.checkpoint-state"
291
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
292
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
293
+ assert_exit "malformed .checkpoint-state, no write this round -> blocked by normal check only" 2 $?
294
+ append_minimal_round 5 "2026-09-08T00:35:00+08:00"
295
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
296
+ assert_exit "malformed .checkpoint-state, round written -> allowed (checkpoint check skipped)" 0 $?
297
+ assert_round_merged "malformed .checkpoint-state, round written"
298
+
299
+ # --- Checkpoint Mode Scenario 6: stored count higher than live count ------
300
+ # (simulating post-archival/manual-edit decrease). Proves the resync guard
301
+ # actually persists the corrected (lower) checkpoint_marker_count to disk
302
+ # the moment the decrease is observed -- not just in the shell variable for
303
+ # one invocation -- since this script is a fresh process every Stop hook
304
+ # call and can only ever see what's on disk. Also proves a bare decrease
305
+ # must not touch rounds_since_checkpoint, and that the very next genuine
306
+ # checkpoint write correctly resets both counters off the now-correct base.
307
+ cat > "$DEVLOG_DIR/.checkpoint-state" <<'CPEOF'
308
+ {"rounds_since_checkpoint": 3, "max_silent_rounds": 20, "checkpoint_marker_count": 1}
309
+ CPEOF
310
+ : > "$DEVLOG_DIR/devlog.md"
311
+ seed_history_round 1 "2026-09-09T00:00:00+08:00"
312
+ # devlog.md now has 0 "## Checkpoint" headings, but checkpoint_marker_count
313
+ # says 1 (stale, from before an archival/manual edit removed it): live (0) <
314
+ # stored (1) -- the decrease case.
315
+
316
+ # Round A: round-start.sh bumps rounds_since_checkpoint 3 -> 4, then Claude
317
+ # writes an ORDINARY round block (no new "## Checkpoint" heading) -- this
318
+ # satisfies the base hash check but leaves the live checkpoint-heading count
319
+ # at 0, still below the stale stored value of 1. This is the moment
320
+ # enforce-devlog.sh must observe the decrease and persist it immediately,
321
+ # without waiting for any future checkpoint write.
322
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
323
+ append_minimal_round 2 "2026-09-09T00:05:00+08:00"
324
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
325
+ assert_exit "decrease observed on an ordinary (non-checkpoint) write -> allowed (below round threshold)" 0 $?
326
+ assert_round_merged "decrease observed on an ordinary (non-checkpoint) write"
327
+ CP_ROUNDS_MID="$(grep -o '"rounds_since_checkpoint"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
328
+ CP_MARKER_MID="$(grep -o '"checkpoint_marker_count"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
329
+ if [ "$CP_ROUNDS_MID" = "4" ] && [ "$CP_MARKER_MID" = "0" ]; then
330
+ echo "PASS: downward-count observation persisted checkpoint_marker_count=0 to disk IMMEDIATELY (before any new checkpoint heading existed), while leaving rounds_since_checkpoint at round-start.sh's plain increment (not reset)"
331
+ else
332
+ echo "FAIL: expected rounds_since_checkpoint=4 (untouched by resync) and checkpoint_marker_count=0 (persisted to disk by the resync itself), got rounds=$CP_ROUNDS_MID marker=$CP_MARKER_MID"
333
+ FAIL=1
334
+ fi
335
+
336
+ # Round B: a genuine checkpoint write now brings the live count to 1, which
337
+ # is above the just-corrected stored value of 0 -- the normal -gt path must
338
+ # fire and reset/persist both counters together, proving a real checkpoint
339
+ # write is never blocked or wedged after a resync.
340
+ # round-start.sh opens a skeleton Round first; complete it (Summary+Handoff)
341
+ # before the Checkpoint so the last Round is not left incomplete. Both the
342
+ # completion and the Checkpoint heading are written into .round-current.md —
343
+ # this is the flow Step 9's merge-before-checkpoint-check exists for: a
344
+ # "## Checkpoint" written as part of finishing this round is already inside
345
+ # devlog.md (merged) by the time the checkpoint-marker count below runs.
346
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
347
+ cat >> "$DEVLOG_DIR/.round-current.md" <<'EOF'
348
+
349
+ ### Summary
350
+ fixture
351
+
352
+ ### Reply
353
+ fixture reply.
354
+
355
+ ### Handoff
356
+ #### 現況
357
+ fixture
358
+
359
+ ### Status
360
+ DONE
361
+
362
+ ## Checkpoint(Round 2 摘要)
363
+ EOF
364
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
365
+ assert_exit "genuine checkpoint write above the corrected stored value -> allowed" 0 $?
366
+ assert_round_merged "genuine checkpoint write above the corrected stored value" "## Checkpoint(Round 2 摘要)"
367
+ CP_ROUNDS_FINAL="$(grep -o '"rounds_since_checkpoint"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
368
+ CP_MARKER_FINAL="$(grep -o '"checkpoint_marker_count"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
369
+ if [ "$CP_ROUNDS_FINAL" = "0" ] && [ "$CP_MARKER_FINAL" = "1" ]; then
370
+ echo "PASS: genuine checkpoint write past the corrected stored value reset rounds_since_checkpoint to 0 and persisted checkpoint_marker_count to 1 -- resync was not a permanent wedge"
371
+ else
372
+ echo "FAIL: expected rounds_since_checkpoint=0 and checkpoint_marker_count=1 after the follow-up genuine write, got rounds=$CP_ROUNDS_FINAL marker=$CP_MARKER_FINAL"
373
+ FAIL=1
374
+ fi
375
+
376
+ # --- Heading check: reset span/checkpoint so only hash + headings matter --
377
+ rm -f "$DEVLOG_DIR/.span-open" "$DEVLOG_DIR/.checkpoint-state"
378
+
379
+ # --- Heading Scenario 1: hash miss message names Summary / Handoff --------
380
+ cat > "$DEVLOG_DIR/devlog.md" <<'DEVEOF'
381
+ ## Round 1 — 2026-09-09T10:00:00+08:00
382
+
383
+ ### Summary
384
+ prior
385
+
386
+ ### Reply
387
+ fixture reply.
388
+
389
+ ### Handoff
390
+ #### 現況
391
+ prior
392
+
393
+ ### Status
394
+ DONE
395
+ DEVEOF
396
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
397
+ HASH_MISS_MSG="$(echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" 2>&1)"
398
+ assert_exit "no write this round -> blocked (heading tests setup)" 2 $?
399
+ case "$HASH_MISS_MSG" in
400
+ *"User Input / Summary / Reply / Handoff / Status"*) echo "PASS: hash-miss message names Summary / Reply / Handoff" ;;
401
+ *) echo "FAIL: hash-miss message should name User Input / Summary / Reply / Handoff / Status, got: $HASH_MISS_MSG"; FAIL=1 ;;
402
+ esac
403
+ case "$HASH_MISS_MSG" in
404
+ *"User Input / Response / Status"*) echo "FAIL: hash-miss message still names Response"; FAIL=1 ;;
405
+ *) echo "PASS: hash-miss message no longer names Response" ;;
406
+ esac
407
+ case "$HASH_MISS_MSG" in
408
+ *"編輯最後一個 Round"*) echo "PASS: hash-miss message says edit the last Round" ;;
409
+ *) echo "FAIL: hash-miss message should say 編輯最後一個 Round, got: $HASH_MISS_MSG"; FAIL=1 ;;
410
+ esac
411
+ case "$HASH_MISS_MSG" in
412
+ *"不要再新增一個 ## Round"*) echo "PASS: hash-miss message forbids a second Round" ;;
413
+ *) echo "FAIL: hash-miss message should say 不要再新增一個 ## Round, got: $HASH_MISS_MSG"; FAIL=1 ;;
414
+ esac
415
+ case "$HASH_MISS_MSG" in
416
+ *"在檔案尾端補上"*) echo "FAIL: hash-miss message still tells Claude to append a Round"; FAIL=1 ;;
417
+ *) echo "PASS: hash-miss message no longer says 在檔案尾端補上" ;;
418
+ esac
419
+
420
+ # --- Heading Scenario 2: Round written without ### Summary -> blocked -----
421
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
422
+ cat > "$DEVLOG_DIR/.round-current.md" <<'DEVEOF'
423
+ ## Round 2 — 2026-09-09T10:05:00+08:00
424
+
425
+ ### Handoff
426
+ #### 現況
427
+ missing summary
428
+
429
+ ### Status
430
+ DONE
431
+ DEVEOF
432
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
433
+ assert_exit "last Round missing ### Summary -> blocked" 2 $?
434
+
435
+ # --- Heading Scenario 3: Round written without ### Handoff -> blocked -----
436
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
437
+ cat > "$DEVLOG_DIR/.round-current.md" <<'DEVEOF'
438
+ ## Round 3 — 2026-09-09T10:10:00+08:00
439
+
440
+ ### Summary
441
+ missing handoff
442
+
443
+ ### Reply
444
+ has reply
445
+
446
+ ### Status
447
+ DONE
448
+ DEVEOF
449
+ HEADING_MISS_MSG="$(echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" 2>&1)"
450
+ assert_exit "last Round missing ### Handoff -> blocked" 2 $?
451
+ case "$HEADING_MISS_MSG" in
452
+ *'### Summary'*'### Reply'*'### Handoff'*) echo "PASS: heading-miss message names required headings" ;;
453
+ *) echo "FAIL: heading-miss message should mention ### Summary / ### Reply / ### Handoff, got: $HEADING_MISS_MSG"; FAIL=1 ;;
454
+ esac
455
+ case "$HEADING_MISS_MSG" in
456
+ *"不要再新增一個 ## Round"*) echo "PASS: heading-miss message forbids a second Round" ;;
457
+ *) echo "FAIL: heading-miss message should say 不要再新增一個 ## Round, got: $HEADING_MISS_MSG"; FAIL=1 ;;
458
+ esac
459
+
460
+ # --- Heading Scenario 4: both headings present, empty bodies -> allowed ---
461
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
462
+ cat > "$DEVLOG_DIR/.round-current.md" <<'DEVEOF'
463
+ ## Round 4 — 2026-09-09T10:15:00+08:00
464
+
465
+ ### Summary
466
+ ### Reply
467
+ ### Handoff
468
+ ### Status
469
+ DONE
470
+ DEVEOF
471
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
472
+ assert_exit "both headings present with empty bodies -> blocked" 2 $?
473
+ EMPTY_MSG="$(echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" 2>&1 >/dev/null)"
474
+ case "$EMPTY_MSG" in
475
+ *"是空的"*) echo "PASS: empty-body message" ;;
476
+ *) echo "FAIL: empty-body stderr, got: $EMPTY_MSG"; FAIL=1 ;;
477
+ esac
478
+
479
+ # --- Heading Scenario 5: hash changed, content has no "## Round " at all --
480
+ # Fixed on review: enforce-devlog.sh must still fail-open (exit 0) when
481
+ # .round-current.md's content contains zero "## Round " lines anywhere —
482
+ # matching the pre-split last_round_block()'s `if (start == 0) exit 0`
483
+ # behavior, and the plan's Global Constraints ("解析不到任何 `## Round`:
484
+ # fail-open(不擋)"). Fixed again on the final whole-branch review: this
485
+ # content must NOT be merged into devlog.md — there is nothing round-shaped
486
+ # here, and merging would silently commit unstructured noise into the
487
+ # permanent historical record. .round-current.md is left in place untouched
488
+ # so a later turn (or round-start.sh's rescue-merge) can still recover it.
489
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
490
+ printf '%s\n' "just a note, not a round" > "$DEVLOG_DIR/.round-current.md"
491
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
492
+ RC5=$?
493
+ assert_exit "no ## Round line at all -> fail-open, allowed" 0 "$RC5"
494
+ assert_contains "no ## Round line at all: .round-current.md left untouched" "just a note, not a round" "$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || true)"
495
+ assert_not_contains "no ## Round line at all: not merged into devlog.md" "just a note, not a round" "$(cat "$DEVLOG_DIR/devlog.md" 2>/dev/null || true)"
496
+
497
+ # --- Heading Scenario 6: Checkpoint text must not satisfy headings --------
498
+ # Last Round lacks both headings; a following, trailing "## Checkpoint"
499
+ # section quotes fully valid-looking Summary/Handoff/Status content. Fixed
500
+ # on review: the extraction of .round-current.md must be bounded the same
501
+ # way the pre-split last_round_block() was — stop at the next unfenced
502
+ # "## " line — so this trailing Checkpoint section can never satisfy the
503
+ # Round's own requirements. The fixture is deliberately built so the two
504
+ # implementations diverge: a naive whole-file read (the bug) would find
505
+ # the quoted headings, bodies, and Status under Checkpoint and wrongly
506
+ # allow (exit 0); the bounded read excludes all of that, sees a Round with
507
+ # no headings at all, and correctly blocks (exit 2). This makes the
508
+ # assertion below a real regression guard, not just an accidental pass.
509
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
510
+ cat > "$DEVLOG_DIR/.round-current.md" <<'DEVEOF'
511
+ ## Round 5 — 2026-09-09T10:20:00+08:00
512
+ old response blob
513
+
514
+ ## Checkpoint(Round 5 摘要)
515
+ ### Summary
516
+ quoted summary text that must not satisfy the Round above
517
+
518
+ ### Handoff
519
+ #### 現況
520
+ quoted handoff text that must not satisfy the Round above
521
+
522
+ ### Status
523
+ DONE
524
+ DEVEOF
525
+ ERR6="$(echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" 2>&1 >/dev/null)"
526
+ RC6=$?
527
+ assert_exit "headings only inside trailing Checkpoint -> blocked" 2 "$RC6"
528
+ case "$ERR6" in
529
+ *"缺少"*"Summary"*|*"缺少"*"Handoff"*) echo "PASS: blocked for missing headings in the bounded Round, not the Checkpoint's" ;;
530
+ *) echo "FAIL: expected missing-headings message, got: $ERR6"; FAIL=1 ;;
531
+ esac
532
+
533
+ # --- Heading Scenario 7: Checkpoint after completing the open skeleton Round -
534
+ # round-start.sh always opens a skeleton; Claude must finish Summary+Handoff
535
+ # on that Round. A Checkpoint may follow; the last Round must still have both
536
+ # headings. Round 6 is prior, already-merged history (stays in devlog.md);
537
+ # round-start.sh opens Round 7's skeleton into .round-current.md, which gets
538
+ # completed there. The Checkpoint line is appended straight to devlog.md
539
+ # (independent of the round being validated) -- devlog.md content is no
540
+ # longer read for round validation at all under the split, so this also
541
+ # proves the two are now fully decoupled.
542
+ cat > "$DEVLOG_DIR/devlog.md" <<'DEVEOF'
543
+ ## Round 6 — 2026-09-09T10:25:00+08:00
544
+
545
+ ### Summary
546
+ complete
547
+
548
+ ### Reply
549
+ fixture reply.
550
+
551
+ ### Handoff
552
+ #### 現況
553
+ complete
554
+
555
+ ### Status
556
+ DONE
557
+ DEVEOF
558
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
559
+ cat >> "$DEVLOG_DIR/.round-current.md" <<'DEVEOF'
560
+
561
+ ### Summary
562
+ complete
563
+
564
+ ### Reply
565
+ fixture reply.
566
+
567
+ ### Handoff
568
+ #### 現況
569
+ complete
570
+
571
+ ### Status
572
+ DONE
573
+ DEVEOF
574
+ echo "## Checkpoint(Round 6 摘要)" >> "$DEVLOG_DIR/devlog.md"
575
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
576
+ assert_exit "Checkpoint-only append after a complete last Round -> allowed" 0 $?
577
+ assert_round_merged "Checkpoint-only append after a complete last Round" "complete"
578
+
579
+ # --- Heading Scenario 8: span one-liner on a complete last Round ----------
580
+ # ticks at max so we do not silent-pass; a one-line append must still pass
581
+ # heading check because it lands inside the last Round, which already has
582
+ # both headings. Span Mode leaves .round-current.md alone across ticks (it
583
+ # is the round Claude keeps extending), so the "complete Round" content must
584
+ # already be sitting in .round-current.md before round-start.sh captures its
585
+ # hash into .turn-start -- otherwise there is nothing for round-start.sh to
586
+ # hash in the first place.
587
+ cat > "$DEVLOG_DIR/.round-current.md" <<'DEVEOF'
588
+ ## Round 6 — 2026-09-09T10:25:00+08:00
589
+
590
+ ### Summary
591
+ complete
592
+
593
+ ### Reply
594
+ fixture reply.
595
+
596
+ ### Handoff
597
+ #### 現況
598
+ complete
599
+
600
+ ### Status
601
+ DONE
602
+ DEVEOF
603
+ cat > "$DEVLOG_DIR/.span-open" <<'SPANEOF'
604
+ {
605
+ "round": 6,
606
+ "opened_at": "2026-09-09T10:25:00+08:00",
607
+ "ticks_since_checkin": 5,
608
+ "max_silent_ticks": 5
609
+ }
610
+ SPANEOF
611
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
612
+ echo "span check-in: still looping" >> "$DEVLOG_DIR/.round-current.md"
613
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
614
+ assert_exit "span budget expired, one-line append on complete last Round -> allowed" 0 $?
615
+ assert_round_merged "span budget expired, one-line append on complete last Round" "complete"
616
+ SPAN_TICKS_ONELINE="$(grep -o '"ticks_since_checkin"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.span-open" | grep -o '[0-9]\+$')"
617
+ if [ "$SPAN_TICKS_ONELINE" = "0" ]; then
618
+ echo "PASS: span ticks reset after one-line append that passed heading check"
619
+ else
620
+ echo "FAIL: ticks_since_checkin should reset to 0 after passing write, got '$SPAN_TICKS_ONELINE'"
621
+ FAIL=1
622
+ fi
623
+
624
+ # --- Heading Scenario 9: heading miss must not reset span ticks -----------
625
+ cat > "$DEVLOG_DIR/.span-open" <<'SPANEOF'
626
+ {
627
+ "round": 6,
628
+ "opened_at": "2026-09-09T10:25:00+08:00",
629
+ "ticks_since_checkin": 5,
630
+ "max_silent_ticks": 5
631
+ }
632
+ SPANEOF
633
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
634
+ cat > "$DEVLOG_DIR/.round-current.md" <<'DEVEOF'
635
+ ## Round 7 — 2026-09-09T10:30:00+08:00
636
+ incomplete new round
637
+ DEVEOF
638
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
639
+ assert_exit "span at max, new Round missing headings -> blocked" 2 $?
640
+ SPAN_TICKS_HELD="$(grep -o '"ticks_since_checkin"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.span-open" | grep -o '[0-9]\+$')"
641
+ if [ "$SPAN_TICKS_HELD" = "6" ]; then
642
+ echo "PASS: heading-check failure did not reset ticks_since_checkin (stayed at 6 after round-start increment)"
643
+ else
644
+ echo "FAIL: ticks_since_checkin should stay 6 (5 + round-start increment, not reset), got '$SPAN_TICKS_HELD'"
645
+ FAIL=1
646
+ fi
647
+ rm -f "$DEVLOG_DIR/.span-open"
648
+
649
+ # --- Heading Scenario 10: fenced ## Round / ## 安裝 must not bound the Round
650
+ # A valid last Round has both required headings, but User Input quotes a
651
+ # markdown example containing `## Round 15` and `## 安裝`. Those fenced lines
652
+ # must not become the last-Round start or the next-heading end.
653
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
654
+ cat > "$DEVLOG_DIR/.round-current.md" <<'DEVEOF'
655
+ ## Round 8 — 2026-09-09T10:35:00+08:00
656
+
657
+ ### User Input
658
+ 格式要怎麼寫?
659
+
660
+ ```markdown
661
+ ## Round 15
662
+ ## 安裝
663
+ ```
664
+
665
+ ### Summary
666
+ 說明 Round 格式。
667
+
668
+ ### Reply
669
+ fixture reply.
670
+
671
+ ### Handoff
672
+ #### 現況
673
+ 已回覆格式問題。
674
+
675
+ ### Status
676
+ DONE
677
+ DEVEOF
678
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
679
+ assert_exit "last Round complete, fenced ## Round 15 and ## 安裝 in body -> allowed" 0 $?
680
+ assert_round_merged "last Round complete, fenced ## Round 15 and ## 安裝 in body" "已回覆格式問題"
681
+
682
+ # --- Heading Scenario 11: older ### Response Round must not block last Round
683
+ # History may still use ### Response. That older round is genuinely prior,
684
+ # already-merged history -- it stays in devlog.md. Only .round-current.md
685
+ # (Round 2, the round being validated) is read for content validation now,
686
+ # so devlog.md's legacy shape can never interfere with it by construction.
687
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
688
+ cat > "$DEVLOG_DIR/devlog.md" <<'DEVEOF'
689
+ ## Round 1 — 2026-09-08T09:00:00+08:00
690
+
691
+ ### User Input
692
+ legacy request
693
+
694
+ ### Response
695
+ old blob without Summary or Handoff
696
+
697
+ ### Status
698
+ DONE
699
+ DEVEOF
700
+ cat > "$DEVLOG_DIR/.round-current.md" <<'DEVEOF'
701
+ ## Round 2 — 2026-09-09T10:40:00+08:00
702
+
703
+ ### Summary
704
+ new shape
705
+
706
+ ### Reply
707
+ fixture reply.
708
+
709
+ ### Handoff
710
+ #### 現況
711
+ complete last Round
712
+
713
+ ### Status
714
+ DONE
715
+ DEVEOF
716
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
717
+ assert_exit "older Round still uses ### Response, complete last Round -> allowed" 0 $?
718
+ assert_round_merged "older Round still uses ### Response, complete last Round" "complete last Round"
719
+
720
+ # --- Recording moments: skeleton hash-equal still blocks -------------------
721
+ rm -f "$DEVLOG_DIR/.span-open" "$DEVLOG_DIR/.checkpoint-state" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.interrupted"
722
+ : > "$DEVLOG_DIR/devlog.md"
723
+ printf '%s' '{"prompt":"block me"}' | bash "$SCRIPT_DIR/round-start.sh"
724
+ printf '%s\n' 'main @ deadbeef,工作樹乾淨' > "$DEVLOG_DIR/.workspace-mismatch"
725
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
726
+ assert_exit "skeleton only (hash equal after submit write) -> blocked" 2 $?
727
+ if [ -f "$DEVLOG_DIR/.round-open" ]; then
728
+ echo "PASS: .round-open remains after hash-miss block"
729
+ else
730
+ echo "FAIL: .round-open should remain when Stop blocks"
731
+ FAIL=1
732
+ fi
733
+ if [ -f "$DEVLOG_DIR/.workspace-mismatch" ]; then
734
+ echo "PASS: .workspace-mismatch remains after hash-miss block"
735
+ else
736
+ echo "FAIL: .workspace-mismatch should remain when Stop blocks"
737
+ FAIL=1
738
+ fi
739
+
740
+ # --- Recording moments: Summary+Handoff on same Round deletes .round-open --
741
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
742
+ ## Round 1 — 2026-09-09T12:00:00+08:00
743
+
744
+ ### User Input
745
+ ```text
746
+ block me
747
+ ```
748
+
749
+ ### Summary
750
+ done
751
+
752
+ ### Reply
753
+ fixture reply.
754
+
755
+ ### Handoff
756
+ #### 現況
757
+ done
758
+
759
+ ### Status
760
+ DONE
761
+ EOF
762
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
763
+ printf '%s\n' 'main @ deadbeef,工作樹乾淨' > "$DEVLOG_DIR/.workspace-mismatch"
764
+ cksum < "$DEVLOG_DIR/.round-current.md" > "$DEVLOG_DIR/.turn-start"
765
+ # Hash equal would block — simulate Claude's edit by appending a newline after snapshot:
766
+ printf '\n' >> "$DEVLOG_DIR/.round-current.md"
767
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
768
+ assert_exit "headings present and hash changed -> allowed" 0 $?
769
+ assert_round_merged "headings present and hash changed" "done"
770
+ if [ -f "$DEVLOG_DIR/.round-open" ]; then
771
+ echo "FAIL: .round-open should be deleted on successful Stop"
772
+ FAIL=1
773
+ else
774
+ echo "PASS: .round-open deleted after successful close"
775
+ fi
776
+ if [ -f "$DEVLOG_DIR/.workspace-mismatch" ]; then
777
+ echo "FAIL: .workspace-mismatch should be deleted on successful Stop"
778
+ FAIL=1
779
+ else
780
+ echo "PASS: .workspace-mismatch deleted after successful close"
781
+ fi
782
+
783
+ # --- Recording moments: .interrupted stamps and does not block -------------
784
+ # The dangling round content lives in .round-current.md now (Task 3);
785
+ # close-open-round.sh stamps it INTERRUPTED there and merges it into
786
+ # devlog.md, removing .round-current.md -- the key new behavior this task's
787
+ # retargeted short-circuit (Step 1) depends on.
788
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
789
+ ## Round 1 — 2026-09-09T12:00:00+08:00
790
+
791
+ ### User Input
792
+ ```text
793
+ esc
794
+ ```
795
+
796
+ ### Status
797
+ IN_PROGRESS
798
+ EOF
799
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
800
+ cksum < "$DEVLOG_DIR/.round-current.md" > "$DEVLOG_DIR/.turn-start"
801
+ touch "$DEVLOG_DIR/.interrupted"
802
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
803
+ assert_exit ".interrupted -> exit 0 (does not fight Esc)" 0 $?
804
+ BODY="$(cat "$DEVLOG_DIR/devlog.md")"
805
+ case "$BODY" in
806
+ *INTERRUPTED*) echo "PASS: .interrupted stamped INTERRUPTED" ;;
807
+ *) echo "FAIL: expected INTERRUPTED after .interrupted Stop"; FAIL=1 ;;
808
+ esac
809
+ case "$BODY" in
810
+ *user_interrupt*) echo "PASS: reason is user_interrupt" ;;
811
+ *) echo "FAIL: expected user_interrupt reason"; FAIL=1 ;;
812
+ esac
813
+ if [ -f "$DEVLOG_DIR/.interrupted" ] || [ -f "$DEVLOG_DIR/.round-open" ] || [ -f "$DEVLOG_DIR/.round-current.md" ]; then
814
+ echo "FAIL: interrupt path should delete both markers and merge round-current away"
815
+ FAIL=1
816
+ else
817
+ echo "PASS: interrupt path deleted markers and merged round-current away"
818
+ fi
819
+
820
+ # --- Recording moments: completed round + .interrupted must NOT stamp ------
821
+ # Esc/is_interrupt fired, but Claude recovered and wrote Summary+Handoff.
822
+ # Stop must clear the marker and take the normal success path — not overwrite
823
+ # a finished Round to INTERRUPTED. This is the "recovered" outcome from
824
+ # close-open-round.sh: it still merges .round-current.md into devlog.md
825
+ # (Task 3's design change), so this Stop invocation's own .round-current.md
826
+ # check (Step 1) correctly finds nothing left to validate and exits 0.
827
+ # devlog.md is reset first: by this point it has accumulated merged content
828
+ # from earlier scenarios in this file (including the previous scenario's
829
+ # genuine INTERRUPTED stamp), and the BODY check below only cares about what
830
+ # this call itself merges.
831
+ : > "$DEVLOG_DIR/devlog.md"
832
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
833
+ ## Round 1 — 2026-09-09T12:00:00+08:00
834
+
835
+ ### User Input
836
+ ```text
837
+ recover
838
+ ```
839
+
840
+ ### Summary
841
+ recovered after interrupt
842
+
843
+ ### Reply
844
+ fixture reply.
845
+
846
+ ### Handoff
847
+ #### 現況
848
+ round finished normally
849
+
850
+ ### Status
851
+ DONE
852
+ EOF
853
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
854
+ cksum < "$DEVLOG_DIR/.round-current.md" > "$DEVLOG_DIR/.turn-start"
855
+ printf '\n' >> "$DEVLOG_DIR/.round-current.md"
856
+ touch "$DEVLOG_DIR/.interrupted"
857
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
858
+ assert_exit "complete last Round + .interrupted + hash changed -> exit 0" 0 $?
859
+ BODY="$(cat "$DEVLOG_DIR/devlog.md")"
860
+ case "$BODY" in
861
+ *INTERRUPTED*) echo "FAIL: completed Round must not be stamped INTERRUPTED"; FAIL=1 ;;
862
+ *) echo "PASS: completed Round Status left as written (not INTERRUPTED)" ;;
863
+ esac
864
+ case "$BODY" in
865
+ *DONE*) echo "PASS: Status DONE preserved" ;;
866
+ *) echo "FAIL: expected Status DONE to remain"; FAIL=1 ;;
867
+ esac
868
+ if [ -f "$DEVLOG_DIR/.interrupted" ] || [ -f "$DEVLOG_DIR/.round-open" ] || [ -f "$DEVLOG_DIR/.round-current.md" ]; then
869
+ echo "FAIL: recovered-complete path should delete .interrupted/.round-open and merge round-current away"
870
+ FAIL=1
871
+ else
872
+ echo "PASS: recovered-complete path deleted markers and merged round-current away"
873
+ fi
874
+
875
+ # --- Recording moments: interrupted-and-recovered short-circuit reads
876
+ # .round-current.md's presence directly (Step 1), not a devlog.md string
877
+ # match. Separate throwaway project dir so it doesn't disturb $DEVLOG_DIR's
878
+ # ongoing state above.
879
+ RECOVER_DIR="$(mktemp -d)"
880
+ export CLAUDE_PROJECT_DIR="$RECOVER_DIR"
881
+ mkdir -p "$RECOVER_DIR/.devlog"
882
+ touch "$RECOVER_DIR/.devlog/.enabled"
883
+ : > "$RECOVER_DIR/.devlog/devlog.md"
884
+ printf '## Round 1 — 2026-09-17T09:00:00+0800\n\n### User Input\n```text\nX\n```\n\n### Summary\n完成\n\n### Handoff\n#### 現況\nok\n\n### Status\nDONE\n' > "$RECOVER_DIR/.devlog/.round-current.md"
885
+ printf '{"round": 1}\n' > "$RECOVER_DIR/.devlog/.round-open"
886
+ echo "stale" > "$RECOVER_DIR/.devlog/.turn-start"
887
+ touch "$RECOVER_DIR/.devlog/.interrupted"
888
+
889
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
890
+ RC=$?
891
+ assert_exit "recovered-through-stop: exits 0" 0 "$RC"
892
+ assert_file_absent "recovered-through-stop: .round-current.md merged away" "$RECOVER_DIR/.devlog/.round-current.md"
893
+ MAIN_AFTER="$(cat "$RECOVER_DIR/.devlog/devlog.md")"
894
+ assert_contains "recovered-through-stop: content landed in devlog.md" "完成" "$MAIN_AFTER"
895
+ rm -rf "$RECOVER_DIR"
896
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
897
+
898
+ # --- Recording moments: stale .interrupted without .round-open -------------
899
+ # A leftover .interrupted must not permanently short-circuit Stop when there
900
+ # is no open round — the next Stop must run normal hash/heading enforcement.
901
+ # With no .round-open, close-open-round.sh no-ops and leaves .round-current.md
902
+ # untouched (Task 3) -- Step 1's redesigned short-circuit must then find it
903
+ # still non-empty and fall through to normal enforcement, not exit 0.
904
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
905
+ ## Round 1 — 2026-09-09T12:00:00+08:00
906
+
907
+ ### User Input
908
+ ```text
909
+ stale interrupt
910
+ ```
911
+
912
+ ### Status
913
+ IN_PROGRESS
914
+ EOF
915
+ cksum < "$DEVLOG_DIR/.round-current.md" > "$DEVLOG_DIR/.turn-start"
916
+ rm -f "$DEVLOG_DIR/.round-open"
917
+ touch "$DEVLOG_DIR/.interrupted"
918
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
919
+ assert_exit "stale .interrupted, no .round-open -> normal enforcement blocks" 2 $?
920
+ if [ -f "$DEVLOG_DIR/.interrupted" ]; then
921
+ echo "FAIL: first Stop should clear .interrupted even without .round-open"
922
+ FAIL=1
923
+ else
924
+ echo "PASS: stale .interrupted cleared on first Stop"
925
+ fi
926
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
927
+ assert_exit "after stale interrupt cleared -> normal enforcement blocks (hash equal)" 2 $?
928
+
929
+ # --- Recording moments: loop guard leaves .round-open ----------------------
930
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
931
+ ## Round 1 — 2026-09-09T12:00:00+08:00
932
+
933
+ ### Status
934
+ IN_PROGRESS
935
+ EOF
936
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
937
+ echo '{"stop_hook_active":true}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
938
+ assert_exit "loop guard -> exit 0" 0 $?
939
+ if [ -f "$DEVLOG_DIR/.round-open" ]; then
940
+ echo "PASS: loop guard did not delete .round-open"
941
+ else
942
+ echo "FAIL: loop guard should leave .round-open for later heal"
943
+ FAIL=1
944
+ fi
945
+
946
+ # --- Recording moments: paused project drops stale .interrupted ------------
947
+ rm -f "$DEVLOG_DIR/.enabled" "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open"
948
+ touch "$DEVLOG_DIR/.interrupted"
949
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
950
+ assert_exit "disabled project with .interrupted -> exits 0" 0 $?
951
+ if [ -f "$DEVLOG_DIR/.interrupted" ]; then
952
+ echo "FAIL: disabled project Stop should delete stale .interrupted"
953
+ FAIL=1
954
+ else
955
+ echo "PASS: disabled project Stop deleted stale .interrupted"
956
+ fi
957
+ if [ -e "$DEVLOG_DIR/devlog.md" ]; then
958
+ echo "FAIL: disabled project Stop should not create devlog.md"
959
+ FAIL=1
960
+ else
961
+ echo "PASS: disabled project Stop did not create or edit devlog.md"
962
+ fi
963
+
964
+ # --- Heading Scenario 12: legal DONE with bodies, no 下一步 -> allowed ----
965
+ touch "$DEVLOG_DIR/.enabled"
966
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
967
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
968
+ ## Round 90 — 2026-09-09T10:40:00+08:00
969
+
970
+ ### Summary
971
+ 一句話。
972
+
973
+ ### Reply
974
+ fixture reply.
975
+
976
+ ### Handoff
977
+ #### 現況
978
+ 做完了。
979
+
980
+ ### Status
981
+ DONE
982
+ EOF
983
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
984
+ assert_exit "DONE with bodies and no 下一步 -> allowed" 0 $?
985
+ assert_round_merged "DONE with bodies and no 下一步" "做完了"
986
+
987
+ # --- Heading Scenario 13: IN_PROGRESS without 下一步 -> blocked -----------
988
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
989
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
990
+ ## Round 91 — 2026-09-09T10:41:00+08:00
991
+
992
+ ### Summary
993
+ 還在做。
994
+
995
+ ### Reply
996
+ fixture reply.
997
+
998
+ ### Handoff
999
+ #### 現況
1000
+ 做到一半。
1001
+
1002
+ ### Status
1003
+ IN_PROGRESS
1004
+ EOF
1005
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
1006
+ assert_exit "IN_PROGRESS without 下一步 -> blocked" 2 $?
1007
+
1008
+ # --- Heading Scenario 14: IN_PROGRESS with 下一步 -> allowed --------------
1009
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1010
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1011
+ ## Round 92 — 2026-09-09T10:42:00+08:00
1012
+
1013
+ ### Summary
1014
+ 還在做。
1015
+
1016
+ ### Reply
1017
+ fixture reply.
1018
+
1019
+ ### Handoff
1020
+ #### 工作區
1021
+ 非 git 工作區
1022
+ #### 現況
1023
+ 做到一半。
1024
+ #### 完成條件
1025
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
1026
+ #### 下一步
1027
+ 打開 foo.ts 繼續。
1028
+
1029
+ ### Status
1030
+ IN_PROGRESS
1031
+ EOF
1032
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
1033
+ assert_exit "IN_PROGRESS with 下一步 -> allowed" 0 $?
1034
+ assert_round_merged "IN_PROGRESS with 下一步" "打開 foo.ts 繼續"
1035
+
1036
+ # --- Heading Scenario 15: illegal Status -> blocked -----------------------
1037
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1038
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1039
+ ## Round 93 — 2026-09-09T10:43:00+08:00
1040
+
1041
+ ### Summary
1042
+ x
1043
+
1044
+ ### Reply
1045
+ fixture reply.
1046
+
1047
+ ### Handoff
1048
+ #### 現況
1049
+ y
1050
+
1051
+ ### Status
1052
+ WIP
1053
+ EOF
1054
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
1055
+ assert_exit "illegal Status -> blocked" 2 $?
1056
+
1057
+ # --- Next-step blacklist Scenario 1: pure filler phrase -> blocked --------
1058
+ # (docs/design/next-step-blacklist.md)
1059
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1060
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1061
+ ## Round 94 — 2026-09-09T10:44:00+08:00
1062
+
1063
+ ### Summary
1064
+ 還在做。
1065
+
1066
+ ### Reply
1067
+ fixture reply.
1068
+
1069
+ ### Handoff
1070
+ #### 現況
1071
+ 做到一半。
1072
+ #### 完成條件
1073
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
1074
+ #### 下一步
1075
+ 繼續完成
1076
+
1077
+ ### Status
1078
+ IN_PROGRESS
1079
+ EOF
1080
+ BLACKLIST_MSG="$(echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" 2>&1 >/dev/null)"
1081
+ assert_exit "下一步 is pure filler phrase '繼續完成' -> blocked" 2 $?
1082
+ case "$BLACKLIST_MSG" in
1083
+ *"是空話"*) echo "PASS: filler-phrase message names it as 空話" ;;
1084
+ *) echo "FAIL: expected filler-phrase message, got: $BLACKLIST_MSG"; FAIL=1 ;;
1085
+ esac
1086
+
1087
+ # --- Next-step blacklist Scenario 2: filler phrase with trailing 。 --------
1088
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1089
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1090
+ ## Round 95 — 2026-09-09T10:45:00+08:00
1091
+
1092
+ ### Summary
1093
+ 還在做。
1094
+
1095
+ ### Reply
1096
+ fixture reply.
1097
+
1098
+ ### Handoff
1099
+ #### 現況
1100
+ 做到一半。
1101
+ #### 完成條件
1102
+ 缺的外部輸入已出現,且可觀察條件達成。
1103
+ #### 下一步
1104
+ 持續優化。
1105
+
1106
+ ### Status
1107
+ BLOCKED
1108
+ EOF
1109
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
1110
+ assert_exit "下一步 is '持續優化。' with trailing punctuation -> blocked (BLOCKED status too)" 2 $?
1111
+
1112
+ # --- Next-step blacklist Scenario 3: phrase embedded in a longer sentence -
1113
+ # Must NOT block -- only an exact whole-body match is filler.
1114
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1115
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1116
+ ## Round 96 — 2026-09-09T10:46:00+08:00
1117
+
1118
+ ### Summary
1119
+ 還在做。
1120
+
1121
+ ### Reply
1122
+ fixture reply.
1123
+
1124
+ ### Handoff
1125
+ #### 工作區
1126
+ 非 git 工作區
1127
+ #### 現況
1128
+ 做到一半。
1129
+ #### 完成條件
1130
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
1131
+ #### 下一步
1132
+ 先繼續完成 foo.ts 的錯誤處理,再跑一次測試。
1133
+
1134
+ ### Status
1135
+ IN_PROGRESS
1136
+ EOF
1137
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
1138
+ assert_exit "下一步 contains 繼續完成 inside a concrete sentence -> allowed" 0 $?
1139
+ assert_round_merged "下一步 contains 繼續完成 inside a concrete sentence" "錯誤處理"
1140
+
1141
+ # --- Next-step blacklist Scenario 4: multi-line body, one line is filler --
1142
+ # Must NOT block -- the blacklist only fires when the whole body is a
1143
+ # single line.
1144
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1145
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1146
+ ## Round 97 — 2026-09-09T10:47:00+08:00
1147
+
1148
+ ### Summary
1149
+ 還在做。
1150
+
1151
+ ### Reply
1152
+ fixture reply.
1153
+
1154
+ ### Handoff
1155
+ #### 工作區
1156
+ 非 git 工作區
1157
+ #### 現況
1158
+ 做到一半。
1159
+ #### 完成條件
1160
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
1161
+ #### 下一步
1162
+ 繼續完成
1163
+ 打開 bar.ts 補上測試。
1164
+
1165
+ ### Status
1166
+ IN_PROGRESS
1167
+ EOF
1168
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
1169
+ assert_exit "下一步 multi-line body including a filler line -> allowed" 0 $?
1170
+ assert_round_merged "下一步 multi-line body including a filler line" "bar.ts"
1171
+
1172
+ # --- L1: missing ### Reply -> blocked --------------------------------------
1173
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1174
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1175
+ ## Round 98 — 2026-09-15T10:00:00+08:00
1176
+
1177
+ ### Summary
1178
+ x
1179
+
1180
+ ### Handoff
1181
+ #### 現況
1182
+ y
1183
+
1184
+ ### Status
1185
+ DONE
1186
+ EOF
1187
+ REPLY_MISS_MSG="$(echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" 2>&1 >/dev/null)"
1188
+ assert_exit "last Round missing ### Reply -> blocked" 2 $?
1189
+ case "$REPLY_MISS_MSG" in
1190
+ *'### Reply'*) echo "PASS: missing-Reply message names ### Reply" ;;
1191
+ *) echo "FAIL: expected ### Reply in message, got: $REPLY_MISS_MSG"; FAIL=1 ;;
1192
+ esac
1193
+
1194
+ # --- L1: IN_PROGRESS 下一步 without actionable signal -> blocked ------------
1195
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1196
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1197
+ ## Round 99 — 2026-09-15T10:01:00+08:00
1198
+
1199
+ ### Summary
1200
+ 還在做。
1201
+
1202
+ ### Reply
1203
+ 還在處理。
1204
+
1205
+ ### Handoff
1206
+ #### 工作區
1207
+ 非 git 工作區
1208
+ #### 現況
1209
+ 做到一半。
1210
+ #### 完成條件
1211
+ 功能可用。
1212
+ #### 下一步
1213
+ 再想一下怎麼做比較好
1214
+
1215
+ ### Status
1216
+ IN_PROGRESS
1217
+ EOF
1218
+ ACTION_MSG="$(echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" 2>&1 >/dev/null)"
1219
+ assert_exit "IN_PROGRESS 下一步 without path/cmd/skill -> blocked" 2 $?
1220
+ case "$ACTION_MSG" in
1221
+ *"可執行跡象"*) echo "PASS: actionable-lint message" ;;
1222
+ *) echo "FAIL: expected 可執行跡象 message, got: $ACTION_MSG"; FAIL=1 ;;
1223
+ esac
1224
+
1225
+ # --- L1: BLOCKED without 缺件句式 -> blocked --------------------------------
1226
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1227
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1228
+ ## Round 100 — 2026-09-15T10:02:00+08:00
1229
+
1230
+ ### Summary
1231
+ 卡住。
1232
+
1233
+ ### Reply
1234
+ 需要更多資訊。
1235
+
1236
+ ### Handoff
1237
+ #### 工作區
1238
+ 非 git 工作區
1239
+ #### 現況
1240
+ 做到一半。
1241
+ #### 完成條件
1242
+ 拿到資料後把 hooks/foo.sh 改完。
1243
+ #### 下一步
1244
+ 改 hooks/foo.sh
1245
+
1246
+ ### Status
1247
+ BLOCKED
1248
+ EOF
1249
+ BLOCKED_MSG="$(echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" 2>&1 >/dev/null)"
1250
+ assert_exit "BLOCKED without 缺件句式 -> blocked" 2 $?
1251
+ case "$BLOCKED_MSG" in
1252
+ *"缺什麼"*) echo "PASS: BLOCKED 缺件 message" ;;
1253
+ *) echo "FAIL: expected 缺什麼 message, got: $BLOCKED_MSG"; FAIL=1 ;;
1254
+ esac
1255
+
1256
+ # --- L1: BLOCKED with 缺件句式 -> allowed -----------------------------------
1257
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
1258
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
1259
+ ## Round 101 — 2026-09-15T10:03:00+08:00
1260
+
1261
+ ### Summary
1262
+ 卡住。
1263
+
1264
+ ### Reply
1265
+ 還在等 API key。
1266
+
1267
+ ### Handoff
1268
+ #### 工作區
1269
+ 非 git 工作區
1270
+ #### 現況
1271
+ 缺使用者提供的 API key(寫進 .env 即算到)。
1272
+ #### 完成條件
1273
+ .env 有 key 且 hooks/foo.sh 改完。
1274
+ #### 下一步
1275
+ 等使用者提供 key 後改 hooks/foo.sh
1276
+
1277
+ ### Status
1278
+ BLOCKED
1279
+ EOF
1280
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
1281
+ assert_exit "BLOCKED with 缺件句式 -> allowed" 0 $?
1282
+
1283
+ if [ "$FAIL" -eq 0 ]; then
1284
+ echo "All checks passed."
1285
+ exit 0
1286
+ else
1287
+ echo "Some checks FAILED."
1288
+ exit 1
1289
+ fi
1290
+