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.
- package/LICENSE +201 -0
- package/README.md +234 -0
- package/bin/devlog-tracker.js +51 -0
- package/cli/init.js +55 -0
- package/cli/init.test.js +37 -0
- package/cli/merge-hooks.js +60 -0
- package/cli/merge-hooks.test.js +187 -0
- package/cli/platforms/codex.js +13 -0
- package/cli/platforms/cursor.js +13 -0
- package/cli/status.js +14 -0
- package/cli/status.test.js +33 -0
- package/cli/vendor.js +37 -0
- package/cli/vendor.test.js +84 -0
- package/codex/hooks/on-pre-tool.sh +19 -0
- package/codex/hooks/on-session-end.sh +31 -0
- package/codex/hooks/on-session-start.sh +37 -0
- package/codex/hooks/on-stop.sh +18 -0
- package/codex/hooks/on-user-prompt-submit.sh +15 -0
- package/codex/hooks/project-dir.sh +13 -0
- package/codex/hooks/test-adapters.sh +98 -0
- package/codex/hooks.json +54 -0
- package/commands/checkpoint.md +17 -0
- package/commands/clean.md +50 -0
- package/commands/compact.md +24 -0
- package/commands/continue.md +30 -0
- package/commands/keep.md +100 -0
- package/commands/lessons-drift.md +19 -0
- package/commands/lessons-off.md +16 -0
- package/commands/lessons-on.md +20 -0
- package/commands/lessons.md +20 -0
- package/commands/overview.md +32 -0
- package/commands/pause.md +20 -0
- package/commands/resume.md +18 -0
- package/commands/segment-watch.md +21 -0
- package/commands/span.md +13 -0
- package/commands/start.md +19 -0
- package/commands/status.md +14 -0
- package/cursor/hooks/on-pre-tool.sh +24 -0
- package/cursor/hooks/on-session-end.sh +11 -0
- package/cursor/hooks/on-session-start.sh +15 -0
- package/cursor/hooks/on-stop.sh +44 -0
- package/cursor/hooks/on-submit-prompt.sh +28 -0
- package/cursor/hooks/on-tool-failure.sh +16 -0
- package/cursor/hooks/project-dir.sh +10 -0
- package/cursor/hooks/test-adapters.sh +201 -0
- package/cursor/hooks.json +26 -0
- package/hooks/scripts/await-open.sh +23 -0
- package/hooks/scripts/checkpoint-set.sh +44 -0
- package/hooks/scripts/clean-devlog.sh +69 -0
- package/hooks/scripts/close-open-round.sh +186 -0
- package/hooks/scripts/compact-devlog.sh +72 -0
- package/hooks/scripts/detect-pending-question.sh +31 -0
- package/hooks/scripts/devlog-lock.sh +38 -0
- package/hooks/scripts/devlog-md.sh +222 -0
- package/hooks/scripts/devlog-path.sh +70 -0
- package/hooks/scripts/enforce-devlog.sh +629 -0
- package/hooks/scripts/files-snapshot.sh +70 -0
- package/hooks/scripts/json-field.sh +76 -0
- package/hooks/scripts/keep-move.sh +155 -0
- package/hooks/scripts/kept-list.sh +40 -0
- package/hooks/scripts/lessons-append.sh +93 -0
- package/hooks/scripts/lessons-drift-set.sh +42 -0
- package/hooks/scripts/lessons-off.sh +15 -0
- package/hooks/scripts/lessons-on.sh +21 -0
- package/hooks/scripts/lessons-read.sh +60 -0
- package/hooks/scripts/on-session-end.sh +14 -0
- package/hooks/scripts/on-stop-failure.sh +18 -0
- package/hooks/scripts/on-tool-failure.sh +25 -0
- package/hooks/scripts/pause-devlog.sh +13 -0
- package/hooks/scripts/redact-prompt.sh +19 -0
- package/hooks/scripts/resume-devlog.sh +41 -0
- package/hooks/scripts/round-start.sh +333 -0
- package/hooks/scripts/run-tests.sh +26 -0
- package/hooks/scripts/segment-watch-set.sh +47 -0
- package/hooks/scripts/segment-watch.sh +197 -0
- package/hooks/scripts/session-start-devlog.sh +157 -0
- package/hooks/scripts/span-close.sh +9 -0
- package/hooks/scripts/span-open.sh +21 -0
- package/hooks/scripts/start-devlog.sh +24 -0
- package/hooks/scripts/status-devlog.sh +43 -0
- package/hooks/scripts/tests/test-await-open.sh +112 -0
- package/hooks/scripts/tests/test-branch-scoped-integration.sh +108 -0
- package/hooks/scripts/tests/test-checkpoint-set.sh +36 -0
- package/hooks/scripts/tests/test-clean-devlog.sh +160 -0
- package/hooks/scripts/tests/test-cli-init-e2e.sh +37 -0
- package/hooks/scripts/tests/test-close-open-round.sh +325 -0
- package/hooks/scripts/tests/test-compact-devlog.sh +62 -0
- package/hooks/scripts/tests/test-devlog-lock.sh +37 -0
- package/hooks/scripts/tests/test-devlog-md.sh +530 -0
- package/hooks/scripts/tests/test-devlog-path.sh +118 -0
- package/hooks/scripts/tests/test-enforce-devlog-files.sh +233 -0
- package/hooks/scripts/tests/test-enforce-devlog-handoff-order.sh +223 -0
- package/hooks/scripts/tests/test-enforce-devlog-workspace.sh +333 -0
- package/hooks/scripts/tests/test-enforce-devlog.sh +1290 -0
- package/hooks/scripts/tests/test-files-snapshot.sh +142 -0
- package/hooks/scripts/tests/test-json-field.sh +57 -0
- package/hooks/scripts/tests/test-keep-move.sh +127 -0
- package/hooks/scripts/tests/test-kept-list.sh +80 -0
- package/hooks/scripts/tests/test-lessons-append.sh +138 -0
- package/hooks/scripts/tests/test-lessons-drift-set.sh +36 -0
- package/hooks/scripts/tests/test-lessons-on-off.sh +75 -0
- package/hooks/scripts/tests/test-lessons-read.sh +97 -0
- package/hooks/scripts/tests/test-on-interrupt.sh +186 -0
- package/hooks/scripts/tests/test-redact-prompt.sh +27 -0
- package/hooks/scripts/tests/test-resume-devlog.sh +35 -0
- package/hooks/scripts/tests/test-round-start.sh +825 -0
- package/hooks/scripts/tests/test-segment-watch-set.sh +75 -0
- package/hooks/scripts/tests/test-segment-watch.sh +468 -0
- package/hooks/scripts/tests/test-session-start-devlog.sh +508 -0
- package/hooks/scripts/tests/test-start-pause-devlog.sh +84 -0
- package/hooks/scripts/tests/test-status-span.sh +50 -0
- package/hooks/scripts/tests/test-workspace-snapshot.sh +130 -0
- package/hooks/scripts/workspace-snapshot.sh +69 -0
- package/package.json +37 -0
- package/skills/devlog-tracker/SKILL.md +411 -0
- package/skills/devlog-tracker/references/checkpoint-mode.md +42 -0
- package/skills/devlog-tracker/references/lessons-mode.md +32 -0
- package/skills/devlog-tracker/references/reply-fold.md +129 -0
- package/skills/devlog-tracker/references/round-segments.md +55 -0
- 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
|
+
|