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,825 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for round-start.sh skeleton + dangling heal. Run:
3
+ # bash hooks/scripts/test-round-start.sh
4
+ set -uo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7
+ # shellcheck source=../json-field.sh
8
+ . "$SCRIPT_DIR/json-field.sh"
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
+
16
+ FAIL=0
17
+ assert_exit() {
18
+ local desc="$1" expected="$2" actual="$3"
19
+ if [ "$actual" -eq "$expected" ]; then
20
+ echo "PASS: $desc"
21
+ else
22
+ echo "FAIL: $desc (expected exit $expected, got $actual)"
23
+ FAIL=1
24
+ fi
25
+ }
26
+ assert_contains() {
27
+ local desc="$1" needle="$2" haystack="$3"
28
+ case "$haystack" in
29
+ *"$needle"*) echo "PASS: $desc" ;;
30
+ *) echo "FAIL: $desc (missing: $needle)"; FAIL=1 ;;
31
+ esac
32
+ }
33
+ assert_not_contains() {
34
+ local desc="$1" needle="$2" haystack="$3"
35
+ case "$haystack" in
36
+ *"$needle"*) echo "FAIL: $desc (unexpected: $needle)"; FAIL=1 ;;
37
+ *) echo "PASS: $desc" ;;
38
+ esac
39
+ }
40
+ assert_file_absent() {
41
+ local desc="$1" path="$2"
42
+ if [ -e "$path" ]; then
43
+ echo "FAIL: $desc ($path exists)"
44
+ FAIL=1
45
+ else
46
+ echo "PASS: $desc"
47
+ fi
48
+ }
49
+
50
+ PROMPT_HELLO='{"prompt":"hello world"}'
51
+
52
+ # --- 1: disabled -> no files ----------------------------------------------
53
+ printf '%s' "$PROMPT_HELLO" | bash "$SCRIPT_DIR/round-start.sh"
54
+ assert_exit "disabled -> exit 0" 0 $?
55
+ assert_file_absent "disabled -> no devlog.md" "$DEVLOG_DIR/devlog.md"
56
+ assert_file_absent "disabled -> no .round-open" "$DEVLOG_DIR/.round-open"
57
+ assert_file_absent "disabled -> no .turn-start" "$DEVLOG_DIR/.turn-start"
58
+
59
+ touch "$DEVLOG_DIR/.enabled"
60
+
61
+ # --- 2: Round 1 skeleton + markers + hash after write ---------------------
62
+ printf '%s' "$PROMPT_HELLO" | bash "$SCRIPT_DIR/round-start.sh"
63
+ assert_exit "first prompt -> exit 0" 0 $?
64
+ BODY="$(cat "$DEVLOG_DIR/.round-current.md")"
65
+ assert_contains "Round 1 heading" "## Round 1 —" "$BODY"
66
+ assert_contains "fenced user input" $'```text\nhello world\n```' "$BODY"
67
+ assert_contains "status in progress" $'### Status\nIN_PROGRESS' "$BODY"
68
+ assert_file_absent "Round 1 skeleton -> devlog.md untouched" "$DEVLOG_DIR/devlog.md"
69
+ if [ -f "$DEVLOG_DIR/.round-open" ]; then
70
+ echo "PASS: .round-open created"
71
+ else
72
+ echo "FAIL: .round-open missing"
73
+ FAIL=1
74
+ fi
75
+ OPEN_N="$(grep -o '"round"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.round-open" | grep -o '[0-9]\+$')"
76
+ if [ "$OPEN_N" = "1" ]; then
77
+ echo "PASS: .round-open round=1"
78
+ else
79
+ echo "FAIL: .round-open round should be 1, got $OPEN_N"
80
+ FAIL=1
81
+ fi
82
+ AFTER_HASH="$(cksum < "$DEVLOG_DIR/.round-current.md")"
83
+ START_HASH="$(cat "$DEVLOG_DIR/.turn-start")"
84
+ if [ "$AFTER_HASH" = "$START_HASH" ]; then
85
+ echo "PASS: .turn-start matches post-skeleton hash"
86
+ else
87
+ echo "FAIL: .turn-start should be captured after skeleton write"
88
+ FAIL=1
89
+ fi
90
+
91
+ # --- 2b: normal (non-fold) new-round path writes to .round-current.md,
92
+ # leaving devlog.md's prior history untouched, and .turn-start hashes
93
+ # .round-current.md -----------------------------------------------------
94
+ NEW_DIR="$(mktemp -d)"
95
+ export CLAUDE_PROJECT_DIR="$NEW_DIR"
96
+ mkdir -p "$NEW_DIR/.devlog"
97
+ touch "$NEW_DIR/.devlog/.enabled"
98
+ printf '## Round 3 — 2026-09-17T08:00:00+0800\n\n### Summary\ns3\n\n### Handoff\n#### 現況\nc3\n\n### Status\nDONE\n' > "$NEW_DIR/.devlog/devlog.md"
99
+
100
+ echo '{"session_id":"s2","prompt":"下一件事"}' | bash "$SCRIPT_DIR/round-start.sh"
101
+
102
+ MAIN_AFTER="$(cat "$NEW_DIR/.devlog/devlog.md")"
103
+ CURRENT_AFTER="$(cat "$NEW_DIR/.devlog/.round-current.md" 2>/dev/null || echo '')"
104
+ assert_not_contains "new round: not appended to devlog.md directly" "## Round 4" "$MAIN_AFTER"
105
+ assert_contains "new round: skeleton written to .round-current.md" "## Round 4" "$CURRENT_AFTER"
106
+ assert_contains "new round: devlog.md keeps prior history" "## Round 3" "$MAIN_AFTER"
107
+
108
+ TURN_HASH="$(cat "$NEW_DIR/.devlog/.turn-start" 2>/dev/null || echo '')"
109
+ EXPECTED_HASH="$(cksum < "$NEW_DIR/.devlog/.round-current.md")"
110
+ if [ "$TURN_HASH" = "$EXPECTED_HASH" ]; then echo "PASS: .turn-start hashes .round-current.md"; else echo "FAIL: .turn-start hashes .round-current.md"; FAIL=1; fi
111
+ rm -rf "$NEW_DIR"
112
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
113
+
114
+ # --- 3: next prompt is Round 2 (dangling heal stamps devlog.md; the new
115
+ # round opens in .round-current.md) — a genuinely-open Round 1 sitting in
116
+ # .round-current.md is a hand-built fixture representing what
117
+ # close-open-round.sh operates on ------------------------------------------
118
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
119
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
120
+ ## Round 1 — 2026-09-09T12:00:00+08:00
121
+
122
+ ### User Input
123
+ ```text
124
+ hello world
125
+ ```
126
+ EOF
127
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
128
+ printf '%s' '{"prompt":"second"}' | bash "$SCRIPT_DIR/round-start.sh"
129
+ MAIN_BODY="$(cat "$DEVLOG_DIR/devlog.md")"
130
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
131
+ assert_contains "Round 2 heading in .round-current.md" "## Round 2 —" "$CUR_BODY"
132
+ assert_contains "previous round interrupted (dangling heal) in devlog.md" "INTERRUPTED" "$MAIN_BODY"
133
+ assert_contains "dangling reason" "dangling:next_prompt" "$MAIN_BODY"
134
+ OPEN_N="$(grep -o '"round"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.round-open" | grep -o '[0-9]\+$')"
135
+ if [ "$OPEN_N" = "2" ]; then
136
+ echo "PASS: .round-open now round=2"
137
+ else
138
+ echo "FAIL: .round-open round should be 2, got $OPEN_N"
139
+ FAIL=1
140
+ fi
141
+
142
+ # --- 4: checkpoint increments once on dangling+new ------------------------
143
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start"
144
+ printf '%s\n' '{"rounds_since_checkpoint": 0, "max_silent_rounds": 20, "checkpoint_marker_count": 0}' > "$DEVLOG_DIR/.checkpoint-state"
145
+ printf '%s' "$PROMPT_HELLO" | bash "$SCRIPT_DIR/round-start.sh"
146
+ printf '%s' '{"prompt":"again"}' | bash "$SCRIPT_DIR/round-start.sh"
147
+ CP="$(grep -o '"rounds_since_checkpoint"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
148
+ if [ "$CP" = "2" ]; then
149
+ echo "PASS: two UserPromptSubmit ticks -> rounds_since_checkpoint=2 (dangling+new still one increment each)"
150
+ else
151
+ echo "FAIL: expected rounds_since_checkpoint=2, got $CP"
152
+ FAIL=1
153
+ fi
154
+
155
+ # --- 5: well-formed span skips new Round, still increments ticks ----------
156
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open"
157
+ : > "$DEVLOG_DIR/devlog.md"
158
+ cat > "$DEVLOG_DIR/.span-open" <<'SPANEOF'
159
+ {
160
+ "round": 9,
161
+ "opened_at": "2026-09-09T00:00:00+08:00",
162
+ "ticks_since_checkin": 0,
163
+ "max_silent_ticks": 5
164
+ }
165
+ SPANEOF
166
+ printf '%s' "$PROMPT_HELLO" | bash "$SCRIPT_DIR/round-start.sh"
167
+ if grep -q '## Round' "$DEVLOG_DIR/devlog.md"; then
168
+ echo "FAIL: span tick should not append a Round"
169
+ FAIL=1
170
+ else
171
+ echo "PASS: span tick appended no Round"
172
+ fi
173
+ assert_file_absent "span tick -> no .round-open" "$DEVLOG_DIR/.round-open"
174
+ TICKS="$(grep -o '"ticks_since_checkin"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.span-open" | grep -o '[0-9]\+$')"
175
+ if [ "$TICKS" = "1" ]; then
176
+ echo "PASS: span ticks incremented to 1"
177
+ else
178
+ echo "FAIL: ticks_since_checkin should be 1, got $TICKS"
179
+ FAIL=1
180
+ fi
181
+ rm -f "$DEVLOG_DIR/.span-open"
182
+
183
+ # --- 6: missing prompt -> placeholder -------------------------------------
184
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open"
185
+ printf '%s' '{}' | bash "$SCRIPT_DIR/round-start.sh"
186
+ BODY="$(cat "$DEVLOG_DIR/.round-current.md")"
187
+ assert_contains "placeholder prompt" "(無 prompt)" "$BODY"
188
+
189
+ # --- 7a: inner fence neutralized before wrapping ----------------------------
190
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open"
191
+ printf '%s' '{"prompt":"before```inside```after"}' | bash "$SCRIPT_DIR/round-start.sh"
192
+ BODY="$(cat "$DEVLOG_DIR/.round-current.md")"
193
+ assert_contains "inner fence neutralized" "⟨fence⟩" "$BODY"
194
+ if printf '%s\n' "$BODY" | grep -q '```inside'; then
195
+ echo "FAIL: raw inner fence leaked into the wrapper"
196
+ FAIL=1
197
+ else
198
+ echo "PASS: wrapper fence stayed closed"
199
+ fi
200
+
201
+ # --- 7aa: common provider tokens are masked in User Input -------------------
202
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open"
203
+ RAW_TOKEN="ghp_abcdefghijklmnopqrstuvwxyz0123456789"
204
+ printf '{"prompt":"token %s"}' "$RAW_TOKEN" | bash "$SCRIPT_DIR/round-start.sh"
205
+ BODY="$(cat "$DEVLOG_DIR/.round-current.md")"
206
+ assert_contains "prompt token masked" "(已遮罩)" "$BODY"
207
+ assert_not_contains "raw prompt token absent" "$RAW_TOKEN" "$BODY"
208
+
209
+ # --- 7b: truncation notice when prompt exceeds 4000 -------------------------
210
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open"
211
+ LONG="$(awk 'BEGIN { s=""; for (i=0;i<4005;i++) s=s "a"; print s }')"
212
+ printf '{"prompt":"%s"}' "$LONG" | bash "$SCRIPT_DIR/round-start.sh"
213
+ BODY="$(cat "$DEVLOG_DIR/.round-current.md")"
214
+ assert_contains "truncation notice" "(後略,已截斷至 4000 字)" "$BODY"
215
+ LEN="$(awk '/^```text$/{p=1;next} /^```$/{p=0;next} p{s=s $0} END{print length(s)}' "$DEVLOG_DIR/.round-current.md")"
216
+ if [ "$LEN" -le 4000 ]; then
217
+ echo "PASS: fenced body length <= 4000 (got $LEN)"
218
+ else
219
+ echo "FAIL: fenced body length $LEN exceeds 4000"
220
+ FAIL=1
221
+ fi
222
+
223
+ # --- 8: heading injection stays inside fence --------------------------------
224
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open"
225
+ printf '%s' '{"prompt":"see\n## Round 99\n### Status"}' | bash "$SCRIPT_DIR/round-start.sh"
226
+ # Only one unfenced ## Round (the real heading). Count fence-aware:
227
+ UNFENCED="$(awk '/^[ \t]*```/{f=!f} !f && /^## Round /{c++} END{print c+0}' "$DEVLOG_DIR/.round-current.md")"
228
+ if [ "$UNFENCED" = "1" ]; then
229
+ echo "PASS: prompt ## Round 99 did not create a second unfenced Round heading"
230
+ else
231
+ echo "FAIL: expected 1 unfenced ## Round heading, got $UNFENCED"
232
+ FAIL=1
233
+ fi
234
+
235
+ # --- 9: segment last_seen_cksum is post-skeleton, tracking .round-current.md
236
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open"
237
+ printf '%s\n' '{"last_change_epoch": 1, "last_seen_cksum": "old", "max_silent_seconds": 900}' > "$DEVLOG_DIR/.segment-state"
238
+ printf '%s' "$PROMPT_HELLO" | bash "$SCRIPT_DIR/round-start.sh"
239
+ POST="$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')"
240
+ SEEN="$(grep -o '"last_seen_cksum"[[:space:]]*:[[:space:]]*"[^"]*"' "$DEVLOG_DIR/.segment-state" | sed 's/.*"last_seen_cksum"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
241
+ if [ "$SEEN" = "$POST" ]; then
242
+ echo "PASS: segment last_seen_cksum matches post-skeleton hash"
243
+ else
244
+ echo "FAIL: last_seen_cksum=$SEEN expected $POST"
245
+ FAIL=1
246
+ fi
247
+
248
+ # --- 9c: segment-state cksum identity tracks .round-current.md, in isolation
249
+ SEG_DIR="$(mktemp -d)"
250
+ export CLAUDE_PROJECT_DIR="$SEG_DIR"
251
+ mkdir -p "$SEG_DIR/.devlog"
252
+ touch "$SEG_DIR/.devlog/.enabled"
253
+ printf '{"last_change_epoch": 0, "last_seen_cksum": "", "max_silent_seconds": 600}\n' > "$SEG_DIR/.devlog/.segment-state"
254
+
255
+ echo '{"session_id":"s3","prompt":"go"}' | bash "$SCRIPT_DIR/round-start.sh"
256
+
257
+ STORED_CKSUM="$(json_str_get "$SEG_DIR/.devlog/.segment-state" last_seen_cksum)"
258
+ ACTUAL_CKSUM="$(cksum < "$SEG_DIR/.devlog/.round-current.md" | tr -d '\n')"
259
+ if [ "$STORED_CKSUM" = "$ACTUAL_CKSUM" ]; then echo "PASS: segment-state cksum tracks .round-current.md"; else echo "FAIL: segment-state cksum tracks .round-current.md (got $STORED_CKSUM want $ACTUAL_CKSUM)"; FAIL=1; fi
260
+ rm -rf "$SEG_DIR"
261
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
262
+
263
+ # --- 9b: segment state stores the submitting session id ---------------------
264
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open"
265
+ printf '%s\n' '{"last_change_epoch": 1, "last_seen_cksum": "old", "max_silent_seconds": 900}' > "$DEVLOG_DIR/.segment-state"
266
+ printf '%s' '{"prompt":"hi","session_id":"s1"}' | bash "$SCRIPT_DIR/round-start.sh"
267
+ SESSION_ID="$(grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' "$DEVLOG_DIR/.segment-state" | sed 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
268
+ if [ "$SESSION_ID" = "s1" ]; then
269
+ echo "PASS: segment state stores session_id"
270
+ else
271
+ echo "FAIL: expected session_id=s1, got [$SESSION_ID]"
272
+ FAIL=1
273
+ fi
274
+
275
+ # --- 10: dangling heal skips a completed last Round, still opens Round 2 ----
276
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.span-open" "$DEVLOG_DIR/.round-current.md"
277
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
278
+ ## Round 1 — 2026-09-09T12:00:00+08:00
279
+
280
+ ### User Input
281
+ ```text
282
+ done
283
+ ```
284
+
285
+ ### Summary
286
+ finished
287
+
288
+ ### Reply
289
+ fixture reply.
290
+
291
+ ### Handoff
292
+ #### 現況
293
+ finished
294
+
295
+ ### Status
296
+ DONE
297
+ EOF
298
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
299
+ cksum < "$DEVLOG_DIR/.round-current.md" > "$DEVLOG_DIR/.turn-start"
300
+ printf '\n' >> "$DEVLOG_DIR/.round-current.md"
301
+ printf '%s' '{"prompt":"next"}' | bash "$SCRIPT_DIR/round-start.sh"
302
+ BODY="$(cat "$DEVLOG_DIR/devlog.md")"
303
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
304
+ assert_contains "completed dangling keeps DONE" $'### Status\nDONE' "$BODY"
305
+ assert_not_contains "completed dangling must not stamp Round 1" "INTERRUPTED" "$BODY"
306
+ assert_contains "still opened Round 2" "## Round 2 —" "$CUR_BODY"
307
+
308
+ # --- 11: .awaiting-reply matches last Round -> folds as a segment, reopening
309
+ # the Round out of devlog.md and into .round-current.md --------------------
310
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.span-open" "$DEVLOG_DIR/.checkpoint-state" "$DEVLOG_DIR/.round-current.md"
311
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
312
+ ## Round 5 — 2026-09-09T12:00:00+08:00
313
+
314
+ ### User Input
315
+ ```text
316
+ what color should the button be?
317
+ ```
318
+
319
+ ### Summary
320
+ Asked the user to pick a color.
321
+
322
+ ### Reply
323
+ fixture reply.
324
+
325
+ ### Handoff
326
+ #### 現況
327
+ Waiting on the user's color choice.
328
+ #### 完成條件
329
+ 缺的外部輸入已出現,且可觀察條件達成。
330
+ #### 下一步
331
+ Apply the chosen color once they answer.
332
+
333
+ ### Status
334
+ BLOCKED
335
+ EOF
336
+ printf '%s\n' '{"round": 5, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.awaiting-reply"
337
+ printf '%s' '{"prompt":"blue please"}' | bash "$SCRIPT_DIR/round-start.sh"
338
+ MAIN_BODY="$(cat "$DEVLOG_DIR/devlog.md" 2>/dev/null || echo '')"
339
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
340
+ assert_not_contains "fold: round 5 no longer in devlog.md" "## Round 5" "$MAIN_BODY"
341
+ UNFENCED_ROUNDS="$(awk '/^[ \t]*```/{f=!f} !f && /^## Round /{c++} END{print c+0}' "$DEVLOG_DIR/.round-current.md")"
342
+ if [ "$UNFENCED_ROUNDS" = "1" ]; then
343
+ echo "PASS: fold did not open a second Round"
344
+ else
345
+ echo "FAIL: expected 1 unfenced Round heading, got $UNFENCED_ROUNDS"
346
+ FAIL=1
347
+ fi
348
+ assert_contains "folded segment heading" "### 段落 1 -" "$CUR_BODY"
349
+ assert_contains "folded segment marks it a reply" "(回覆上一輪的問題)" "$CUR_BODY"
350
+ assert_contains "folded segment keeps user's words" "blue please" "$CUR_BODY"
351
+ # segment must land before Summary, not after
352
+ SEG_LINE="$(grep -n '### 段落 1' "$DEVLOG_DIR/.round-current.md" | head -1 | cut -d: -f1)"
353
+ SUM_LINE="$(grep -n '^### Summary$' "$DEVLOG_DIR/.round-current.md" | head -1 | cut -d: -f1)"
354
+ if [ "$SEG_LINE" -lt "$SUM_LINE" ]; then
355
+ echo "PASS: folded segment inserted before Summary"
356
+ else
357
+ echo "FAIL: folded segment landed after Summary (seg=$SEG_LINE summary=$SUM_LINE)"
358
+ FAIL=1
359
+ fi
360
+ if [ -f "$DEVLOG_DIR/.awaiting-reply" ]; then
361
+ echo "FAIL: .awaiting-reply should be consumed after use"
362
+ FAIL=1
363
+ else
364
+ echo "PASS: .awaiting-reply consumed"
365
+ fi
366
+ OPEN_N="$(grep -o '"round"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.round-open" | grep -o '[0-9]\+$')"
367
+ if [ "$OPEN_N" = "5" ]; then
368
+ echo "PASS: .round-open rewritten to the folded Round (5)"
369
+ else
370
+ echo "FAIL: expected .round-open round=5, got $OPEN_N"
371
+ FAIL=1
372
+ fi
373
+ AFTER_HASH="$(cksum < "$DEVLOG_DIR/.round-current.md")"
374
+ START_HASH="$(cat "$DEVLOG_DIR/.turn-start")"
375
+ if [ "$AFTER_HASH" = "$START_HASH" ]; then
376
+ echo "PASS: .turn-start matches post-fold hash"
377
+ else
378
+ echo "FAIL: .turn-start should be captured after the fold insert"
379
+ FAIL=1
380
+ fi
381
+
382
+ # --- 11b (Step 3 brief test): Reply Fold reopens the awaited round into
383
+ # .round-current.md, not editing it in place inside devlog.md — devlog.md
384
+ # should no longer contain it, and .round-current.md should hold exactly
385
+ # that round plus the folded segment. Own isolated fixture. --------------
386
+ FOLD_DIR="$(mktemp -d)"
387
+ export CLAUDE_PROJECT_DIR="$FOLD_DIR"
388
+ mkdir -p "$FOLD_DIR/.devlog"
389
+ touch "$FOLD_DIR/.devlog/.enabled"
390
+ printf '## Round 5 — 2026-09-17T09:00:00+0800\n\n### User Input\n```text\n要不要修 A?\n```\n\n### Summary\n問了一題\n\n### Handoff\n#### 現況\n等回覆\n\n### Status\nBLOCKED\n' > "$FOLD_DIR/.devlog/devlog.md"
391
+ printf '{"round": 5}\n' > "$FOLD_DIR/.devlog/.awaiting-reply"
392
+
393
+ echo '{"session_id":"s1","prompt":"要"}' | bash "$SCRIPT_DIR/round-start.sh"
394
+
395
+ MAIN_AFTER="$(cat "$FOLD_DIR/.devlog/devlog.md")"
396
+ CURRENT_AFTER="$(cat "$FOLD_DIR/.devlog/.round-current.md" 2>/dev/null || echo '')"
397
+ assert_not_contains "fold: round 5 no longer in devlog.md" "## Round 5" "$MAIN_AFTER"
398
+ assert_contains "fold: round 5 moved into .round-current.md" "## Round 5" "$CURRENT_AFTER"
399
+ assert_contains "fold: folded segment written into .round-current.md" "回覆上一輪的問題" "$CURRENT_AFTER"
400
+ rm -rf "$FOLD_DIR"
401
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
402
+
403
+ # --- 12: second reply on the same Round numbers segments incrementally,
404
+ # standalone fixture: devlog.md holds the round already folded once
405
+ # (matching the merged-back-between-turns invariant) -----------------------
406
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
407
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
408
+ ## Round 5 — 2026-09-09T12:00:00+08:00
409
+
410
+ ### User Input
411
+ ```text
412
+ what color should the button be?
413
+ ```
414
+
415
+ ### 段落 1 - 12:01(回覆上一輪的問題)
416
+ ```text
417
+ blue please
418
+ ```
419
+
420
+ ### Summary
421
+ Asked the user to pick a color.
422
+
423
+ ### Handoff
424
+ #### 現況
425
+ Waiting on the user's color choice.
426
+ #### 下一步
427
+ Apply the chosen color once they answer.
428
+
429
+ ### Status
430
+ BLOCKED
431
+ EOF
432
+ printf '%s\n' '{"round": 5, "opened_at": "2026-09-09T12:05:00+08:00"}' > "$DEVLOG_DIR/.awaiting-reply"
433
+ printf '%s' '{"prompt":"actually make it green"}' | bash "$SCRIPT_DIR/round-start.sh"
434
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
435
+ assert_contains "first segment still present" "### 段落 1 -" "$CUR_BODY"
436
+ assert_contains "second segment numbered 2" "### 段落 2 -" "$CUR_BODY"
437
+ assert_contains "second segment content" "actually make it green" "$CUR_BODY"
438
+
439
+ # --- 13: .awaiting-reply round is stale -> dropped, normal new Round opens -
440
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
441
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
442
+ ## Round 1 — 2026-09-09T12:00:00+08:00
443
+
444
+ ### Summary
445
+ done
446
+
447
+ ### Reply
448
+ fixture reply.
449
+
450
+ ### Handoff
451
+ #### 現況
452
+ done
453
+
454
+ ### Status
455
+ DONE
456
+ EOF
457
+ printf '%s\n' '{"round": 99, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.awaiting-reply"
458
+ printf '%s' '{"prompt":"unrelated new request"}' | bash "$SCRIPT_DIR/round-start.sh"
459
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
460
+ assert_contains "stale marker -> normal new Round 2 opened" "## Round 2 —" "$CUR_BODY"
461
+ assert_not_contains "stale marker -> no segment inserted" "### 段落" "$CUR_BODY"
462
+ if [ -f "$DEVLOG_DIR/.awaiting-reply" ]; then
463
+ echo "FAIL: stale .awaiting-reply should still be consumed"
464
+ FAIL=1
465
+ else
466
+ echo "PASS: stale .awaiting-reply consumed without folding"
467
+ fi
468
+
469
+ # --- 14: valid Span Mode suppresses the fold and just consumes the marker --
470
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start"
471
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
472
+ ## Round 9 — 2026-09-09T12:00:00+08:00
473
+
474
+ ### Summary
475
+ in progress
476
+
477
+ ### Reply
478
+ fixture reply.
479
+
480
+ ### Handoff
481
+ #### 現況
482
+ running
483
+ #### 完成條件
484
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
485
+ #### 下一步
486
+ keep going
487
+
488
+ ### Status
489
+ IN_PROGRESS
490
+ EOF
491
+ cat > "$DEVLOG_DIR/.span-open" <<'SPANEOF'
492
+ {
493
+ "round": 9,
494
+ "opened_at": "2026-09-09T00:00:00+08:00",
495
+ "ticks_since_checkin": 0,
496
+ "max_silent_ticks": 5
497
+ }
498
+ SPANEOF
499
+ printf '%s\n' '{"round": 9, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.awaiting-reply"
500
+ printf '%s' '{"prompt":"automated tick"}' | bash "$SCRIPT_DIR/round-start.sh"
501
+ BODY="$(cat "$DEVLOG_DIR/devlog.md")"
502
+ assert_not_contains "span suppresses fold: no segment" "### 段落" "$BODY"
503
+ UNFENCED_ROUNDS="$(awk '/^[ \t]*```/{f=!f} !f && /^## Round /{c++} END{print c+0}' "$DEVLOG_DIR/devlog.md")"
504
+ if [ "$UNFENCED_ROUNDS" = "1" ]; then
505
+ echo "PASS: span suppresses fold: no new Round either"
506
+ else
507
+ echo "FAIL: expected 1 unfenced Round heading, got $UNFENCED_ROUNDS"
508
+ FAIL=1
509
+ fi
510
+ if [ -f "$DEVLOG_DIR/.awaiting-reply" ]; then
511
+ echo "FAIL: .awaiting-reply should be consumed even when span suppresses the fold"
512
+ FAIL=1
513
+ else
514
+ echo "PASS: .awaiting-reply consumed under an active span"
515
+ fi
516
+ rm -f "$DEVLOG_DIR/.span-open"
517
+
518
+ # --- 15: fold does not increment rounds_since_checkpoint --------------------
519
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start"
520
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
521
+ ## Round 1 — 2026-09-09T12:00:00+08:00
522
+
523
+ ### Summary
524
+ waiting
525
+
526
+ ### Reply
527
+ fixture reply.
528
+
529
+ ### Handoff
530
+ #### 現況
531
+ waiting on reply
532
+ #### 完成條件
533
+ 缺的外部輸入已出現,且可觀察條件達成。
534
+ #### 下一步
535
+ apply the answer
536
+
537
+ ### Status
538
+ BLOCKED
539
+ EOF
540
+ printf '%s\n' '{"rounds_since_checkpoint": 3, "max_silent_rounds": 20, "checkpoint_marker_count": 0}' > "$DEVLOG_DIR/.checkpoint-state"
541
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.awaiting-reply"
542
+ printf '%s' '{"prompt":"the reply"}' | bash "$SCRIPT_DIR/round-start.sh"
543
+ CP="$(grep -o '"rounds_since_checkpoint"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
544
+ if [ "$CP" = "3" ]; then
545
+ echo "PASS: fold does not increment rounds_since_checkpoint"
546
+ else
547
+ echo "FAIL: expected rounds_since_checkpoint to stay 3, got $CP"
548
+ FAIL=1
549
+ fi
550
+ rm -f "$DEVLOG_DIR/.checkpoint-state"
551
+
552
+ # --- 16: task-notification prompt folds into the last Round, condensed -----
553
+ NOTIF_PROMPT='<task-notification><task-id>t1</task-id><status>completed</status><summary>Agent \"Fix wave\" finished</summary></task-notification>'
554
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.checkpoint-state" "$DEVLOG_DIR/.round-current.md"
555
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
556
+ ## Round 7 — 2026-09-09T12:00:00+08:00
557
+
558
+ ### User Input
559
+ ```text
560
+ refactor the widget module
561
+ ```
562
+
563
+ ### Summary
564
+ Refactored the widget module.
565
+
566
+ ### Reply
567
+ fixture reply.
568
+
569
+ ### Handoff
570
+ #### 現況
571
+ Done, tests pass.
572
+
573
+ ### Status
574
+ DONE
575
+ EOF
576
+ printf '%s\n' '{"rounds_since_checkpoint": 0, "max_silent_rounds": 20, "checkpoint_marker_count": 0}' > "$DEVLOG_DIR/.checkpoint-state"
577
+ printf '{"prompt":"%s"}' "$NOTIF_PROMPT" | bash "$SCRIPT_DIR/round-start.sh"
578
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
579
+ UNFENCED_ROUNDS="$(awk '/^[ \t]*```/{f=!f} !f && /^## Round /{c++} END{print c+0}' "$DEVLOG_DIR/.round-current.md")"
580
+ if [ "$UNFENCED_ROUNDS" = "1" ]; then
581
+ echo "PASS: task-notification did not open a second Round"
582
+ else
583
+ echo "FAIL: expected 1 unfenced Round heading, got $UNFENCED_ROUNDS"
584
+ FAIL=1
585
+ fi
586
+ assert_contains "task-notification segment heading" "### 段落 1 -" "$CUR_BODY"
587
+ assert_contains "task-notification segment marked as background" "(背景任務通知)" "$CUR_BODY"
588
+ assert_contains "task-notification segment keeps the condensed summary" 'Agent "Fix wave" finished' "$CUR_BODY"
589
+ assert_contains "task-notification segment keeps status" "status=completed" "$CUR_BODY"
590
+ assert_contains "task-notification segment keeps task-id" "task-id=t1" "$CUR_BODY"
591
+ assert_not_contains "task-notification raw tag not recorded" "<task-notification>" "$CUR_BODY"
592
+ SEG_LINE="$(grep -n '### 段落 1' "$DEVLOG_DIR/.round-current.md" | head -1 | cut -d: -f1)"
593
+ SUM_LINE="$(grep -n '^### Summary$' "$DEVLOG_DIR/.round-current.md" | head -1 | cut -d: -f1)"
594
+ if [ "$SEG_LINE" -lt "$SUM_LINE" ]; then
595
+ echo "PASS: task-notification segment inserted before Summary"
596
+ else
597
+ echo "FAIL: task-notification segment landed after Summary (seg=$SEG_LINE summary=$SUM_LINE)"
598
+ FAIL=1
599
+ fi
600
+ OPEN_N="$(grep -o '"round"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.round-open" | grep -o '[0-9]\+$')"
601
+ if [ "$OPEN_N" = "7" ]; then
602
+ echo "PASS: .round-open reopened to the folded Round (7)"
603
+ else
604
+ echo "FAIL: expected .round-open round=7, got $OPEN_N"
605
+ FAIL=1
606
+ fi
607
+ CP="$(grep -o '"rounds_since_checkpoint"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
608
+ if [ "$CP" = "0" ]; then
609
+ echo "PASS: task-notification fold does not increment rounds_since_checkpoint"
610
+ else
611
+ echo "FAIL: expected rounds_since_checkpoint to stay 0, got $CP"
612
+ FAIL=1
613
+ fi
614
+ rm -f "$DEVLOG_DIR/.checkpoint-state"
615
+
616
+ # --- 17: task-notification with no existing Round opens one, still condensed
617
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
618
+ printf '{"prompt":"%s"}' "$NOTIF_PROMPT" | bash "$SCRIPT_DIR/round-start.sh"
619
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
620
+ assert_contains "fresh task-notification opens Round 1" "## Round 1 —" "$CUR_BODY"
621
+ assert_contains "fresh task-notification condensed summary" 'Agent "Fix wave" finished' "$CUR_BODY"
622
+ assert_not_contains "fresh task-notification raw tag not recorded" "<task-notification>" "$CUR_BODY"
623
+
624
+ # --- 18: task-notification during an open span is still suppressed ---------
625
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start"
626
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00", "ticks_since_checkin": 0, "max_silent_ticks": 5}' > "$DEVLOG_DIR/.span-open"
627
+ printf '{"prompt":"%s"}' "$NOTIF_PROMPT" | bash "$SCRIPT_DIR/round-start.sh"
628
+ if [ -f "$DEVLOG_DIR/devlog.md" ]; then
629
+ echo "FAIL: task-notification during open span should not create devlog.md"
630
+ FAIL=1
631
+ else
632
+ echo "PASS: task-notification during open span created no devlog.md"
633
+ fi
634
+ assert_file_absent "task-notification during open span: no .round-open" "$DEVLOG_DIR/.round-open"
635
+ rm -f "$DEVLOG_DIR/.span-open"
636
+
637
+ # --- workspace claim on next prompt ----------------------------------------
638
+ WS="$TMP_ROOT/ws"
639
+ mkdir -p "$WS/.devlog"
640
+ export CLAUDE_PROJECT_DIR="$WS"
641
+ touch "$WS/.devlog/.enabled"
642
+ git -C "$WS" init -q -b main
643
+ git -C "$WS" config user.email test@example.com
644
+ git -C "$WS" config user.name test
645
+ echo hello > "$WS/a.txt"
646
+ git -C "$WS" add a.txt
647
+ git -C "$WS" commit -q -m init
648
+ LIVE="$(bash "$SCRIPT_DIR/workspace-snapshot.sh" "$WS")"
649
+ cat > "$WS/.devlog/devlog.md" <<EOF
650
+ ## Round 1 — 2026-09-11T00:00:00+08:00
651
+
652
+ ### Summary
653
+ s
654
+
655
+ ### Reply
656
+ fixture reply.
657
+
658
+ ### Handoff
659
+ #### 工作區
660
+ ${LIVE}
661
+ #### 現況
662
+ going
663
+ #### 完成條件
664
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
665
+ #### 下一步
666
+ do x
667
+
668
+ ### Status
669
+ IN_PROGRESS
670
+ EOF
671
+ OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
672
+ [ -z "$OUT" ] && echo "PASS: matching 工作區 prints nothing" || { echo "FAIL: match stdout [$OUT]"; FAIL=1; }
673
+ [ ! -f "$WS/.devlog/.workspace-mismatch" ] && echo "PASS: matching 工作區 writes no marker" || { echo "FAIL: marker on match"; FAIL=1; }
674
+ grep -q '^## Round 2' "$WS/.devlog/.round-current.md" && echo "PASS: still opened Round 2" || { echo "FAIL: no round 2"; FAIL=1; }
675
+
676
+ rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md"
677
+ cat > "$WS/.devlog/devlog.md" <<EOF
678
+ ## Round 1 — 2026-09-11T00:00:00+08:00
679
+
680
+ ### Summary
681
+ s
682
+
683
+ ### Reply
684
+ fixture reply.
685
+
686
+ ### Handoff
687
+ #### 工作區
688
+ main @ deadbeef,工作樹乾淨
689
+ #### 現況
690
+ going
691
+ #### 完成條件
692
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
693
+ #### 下一步
694
+ do x
695
+
696
+ ### Status
697
+ IN_PROGRESS
698
+ EOF
699
+ OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
700
+ assert_contains "mismatch stdout names 工作區" "#### 工作區" "$OUT"
701
+ assert_contains "mismatch stdout has 宣稱" "宣稱:" "$OUT"
702
+ assert_contains "mismatch stdout has 實際" "實際:" "$OUT"
703
+ assert_contains "mismatch stdout has live snapshot" "$LIVE" "$OUT"
704
+ [ -f "$WS/.devlog/.workspace-mismatch" ] && echo "PASS: mismatch writes marker" || { echo "FAIL: no marker"; FAIL=1; }
705
+ MARKER="$(cat "$WS/.devlog/.workspace-mismatch")"
706
+ [ "$MARKER" = "$LIVE" ] && echo "PASS: marker is live snapshot" || { echo "FAIL: marker [$MARKER]"; FAIL=1; }
707
+
708
+ # --- lessons drift counter: mechanical nudge on repeated mismatch --------
709
+ rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.workspace-mismatch"
710
+ touch "$WS/.devlog/.lessons-enabled"
711
+ printf '%s\n' '{"mismatch_count": 0, "threshold": 3}' > "$WS/.devlog/.lessons-drift-state"
712
+
713
+ OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
714
+ assert_not_contains "drift count 1/3: no nudge yet" "[Lessons Mode 提示]" "$OUT"
715
+ grep -q '"mismatch_count": 1' "$WS/.devlog/.lessons-drift-state" && echo "PASS: drift count -> 1" || { echo "FAIL: drift count not 1"; FAIL=1; }
716
+
717
+ rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md"
718
+ OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
719
+ assert_not_contains "drift count 2/3: no nudge yet" "[Lessons Mode 提示]" "$OUT"
720
+ grep -q '"mismatch_count": 2' "$WS/.devlog/.lessons-drift-state" && echo "PASS: drift count -> 2" || { echo "FAIL: drift count not 2"; FAIL=1; }
721
+
722
+ rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md"
723
+ OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
724
+ assert_contains "drift count hits threshold: prints nudge" "[Lessons Mode 提示]" "$OUT"
725
+ grep -q '"mismatch_count": 0' "$WS/.devlog/.lessons-drift-state" && echo "PASS: drift count reset after nudge" || { echo "FAIL: drift count not reset"; FAIL=1; }
726
+
727
+ # without .lessons-enabled: no counting, no nudge, state file untouched
728
+ rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.lessons-enabled"
729
+ printf '%s\n' '{"mismatch_count": 2, "threshold": 3}' > "$WS/.devlog/.lessons-drift-state"
730
+ OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
731
+ assert_not_contains "lessons off: no nudge even near threshold" "[Lessons Mode 提示]" "$OUT"
732
+ grep -q '"mismatch_count": 2' "$WS/.devlog/.lessons-drift-state" && echo "PASS: drift count untouched when lessons off" || { echo "FAIL: drift count changed when lessons off"; FAIL=1; }
733
+ rm -f "$WS/.devlog/.lessons-drift-state" "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md"
734
+
735
+ # --- lessons drift counter: upgrade path — .lessons-enabled exists (from
736
+ # before this branch) but .lessons-drift-state was never created. round-start.sh
737
+ # must create it with defaults and still act on this same invocation's
738
+ # mismatch, not require a second message ------------------------------------
739
+ rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.workspace-mismatch" "$WS/.devlog/.lessons-drift-state"
740
+ touch "$WS/.devlog/.lessons-enabled"
741
+ cat > "$WS/.devlog/devlog.md" <<EOF
742
+ ## Round 1 — 2026-09-11T00:00:00+08:00
743
+
744
+ ### Summary
745
+ s
746
+
747
+ ### Reply
748
+ fixture reply.
749
+
750
+ ### Handoff
751
+ #### 工作區
752
+ main @ deadbeef,工作樹乾淨
753
+ #### 現況
754
+ going
755
+ #### 完成條件
756
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
757
+ #### 下一步
758
+ do x
759
+
760
+ ### Status
761
+ IN_PROGRESS
762
+ EOF
763
+ OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
764
+ [ -f "$WS/.devlog/.lessons-drift-state" ] && echo "PASS: upgrade path creates .lessons-drift-state on first drift" || { echo "FAIL: .lessons-drift-state not created for pre-existing lessons-enabled"; FAIL=1; }
765
+ grep -q '"mismatch_count": 1' "$WS/.devlog/.lessons-drift-state" 2>/dev/null && echo "PASS: newly-created state already counted this invocation's mismatch (1)" || { echo "FAIL: mismatch_count not 1 in newly-created state"; FAIL=1; }
766
+ grep -q '"threshold": 3' "$WS/.devlog/.lessons-drift-state" 2>/dev/null && echo "PASS: newly-created state has default threshold 3" || { echo "FAIL: threshold not 3 in newly-created state"; FAIL=1; }
767
+ assert_not_contains "upgrade path: no nudge yet on first-ever drift (1 < 3)" "[Lessons Mode 提示]" "$OUT"
768
+ rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.lessons-enabled" "$WS/.devlog/.lessons-drift-state"
769
+
770
+ cat > "$WS/.devlog/devlog.md" <<EOF
771
+ ## Round 1 — 2026-09-11T00:00:00+08:00
772
+
773
+ ### Summary
774
+ s
775
+
776
+ ### Reply
777
+ fixture reply.
778
+
779
+ ### Handoff
780
+ #### 工作區
781
+ main @ deadbeef,工作樹乾淨
782
+
783
+ ### Status
784
+ DONE
785
+ EOF
786
+ rm -f "$WS/.devlog/.workspace-mismatch"
787
+ OUT="$(printf '%s' '{"prompt":"new topic"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
788
+ [ -z "$OUT" ] && echo "PASS: DONE prints nothing" || { echo "FAIL: DONE stdout [$OUT]"; FAIL=1; }
789
+ [ ! -f "$WS/.devlog/.workspace-mismatch" ] && echo "PASS: DONE writes no marker" || { echo "FAIL: DONE marker"; FAIL=1; }
790
+
791
+ cat > "$WS/.devlog/devlog.md" <<EOF
792
+ ## Round 1 — 2026-09-11T00:00:00+08:00
793
+
794
+ ### Summary
795
+ s
796
+
797
+ ### Reply
798
+ fixture reply.
799
+
800
+ ### Handoff
801
+ #### 工作區
802
+ main @ deadbeef,工作樹乾淨
803
+ #### 現況
804
+ going
805
+ #### 完成條件
806
+ `bash hooks/scripts/tests/test-enforce-devlog.sh` 相關情境通過。
807
+ #### 下一步
808
+ do x
809
+
810
+ ### Status
811
+ IN_PROGRESS
812
+ EOF
813
+ printf '%s\n' '{"round": 1, "ticks_since_checkin": 0, "max_silent_ticks": 5}' > "$WS/.devlog/.span-open"
814
+ rm -f "$WS/.devlog/.workspace-mismatch"
815
+ printf '%s' '{"prompt":"tick"}' | bash "$SCRIPT_DIR/round-start.sh" >/dev/null
816
+ [ ! -f "$WS/.devlog/.workspace-mismatch" ] && echo "PASS: span skip writes no marker" || { echo "FAIL: span marker"; FAIL=1; }
817
+ rm -f "$WS/.devlog/.span-open"
818
+
819
+ if [ "$FAIL" -eq 0 ]; then
820
+ echo "All checks passed."
821
+ exit 0
822
+ else
823
+ echo "Some checks FAILED."
824
+ exit 1
825
+ fi