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,75 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for segment-watch-set.sh. Run:
3
+ # bash hooks/scripts/test-segment-watch-set.sh
4
+ set -uo pipefail
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
6
+ TMP_ROOT="$(mktemp -d)"
7
+ trap 'rm -rf "$TMP_ROOT"' EXIT
8
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
9
+ FAIL=0
10
+
11
+ assert_exit() {
12
+ local desc="$1" expected="$2" actual="$3"
13
+ if [ "$actual" -eq "$expected" ]; then echo "PASS: $desc"
14
+ else echo "FAIL: $desc (expected $expected got $actual)"; FAIL=1; fi
15
+ }
16
+
17
+ # --- 1: never started -> NOT_STARTED, no files created ----------------------
18
+ OUT="$(bash "$SCRIPT_DIR/segment-watch-set.sh" 300)"
19
+ assert_exit "never started -> exit 0" 0 $?
20
+ case "$OUT" in
21
+ *"NOT_STARTED"*) echo "PASS: reports NOT_STARTED" ;;
22
+ *) echo "FAIL: expected NOT_STARTED, got $OUT"; FAIL=1 ;;
23
+ esac
24
+ if [ -e "$TMP_ROOT/.devlog" ]; then
25
+ echo "FAIL: NOT_STARTED path should not create .devlog"; FAIL=1
26
+ else
27
+ echo "PASS: NOT_STARTED path creates no .devlog"
28
+ fi
29
+
30
+ mkdir -p "$TMP_ROOT/.devlog"
31
+ touch "$TMP_ROOT/.devlog/.enabled"
32
+
33
+ # --- 2: started, no .segment-state yet -> creates one with the given value --
34
+ OUT="$(bash "$SCRIPT_DIR/segment-watch-set.sh" 300)"
35
+ assert_exit "fresh set -> exit 0" 0 $?
36
+ grep -q '"max_silent_seconds": 300' "$TMP_ROOT/.devlog/.segment-state" \
37
+ && echo "PASS: fresh set writes 300" || { echo "FAIL: fresh set"; FAIL=1; }
38
+ case "$OUT" in
39
+ *"SEGMENT_MAX_SILENT_SECONDS=300"*) echo "PASS: fresh set reports 300" ;;
40
+ *) echo "FAIL: expected SEGMENT_MAX_SILENT_SECONDS=300, got $OUT"; FAIL=1 ;;
41
+ esac
42
+
43
+ # --- 3: started, existing .segment-state -> overrides just the threshold ----
44
+ printf '%s\n' '{"last_change_epoch": 42, "last_seen_cksum": "abc", "max_silent_seconds": 300, "session_id": "s1"}' \
45
+ > "$TMP_ROOT/.devlog/.segment-state"
46
+ OUT="$(bash "$SCRIPT_DIR/segment-watch-set.sh" 120)"
47
+ assert_exit "override set -> exit 0" 0 $?
48
+ grep -q '"max_silent_seconds": 120' "$TMP_ROOT/.devlog/.segment-state" \
49
+ && echo "PASS: override set writes 120" || { echo "FAIL: override set"; FAIL=1; }
50
+ grep -q '"last_change_epoch": 42' "$TMP_ROOT/.devlog/.segment-state" \
51
+ && echo "PASS: override set keeps last_change_epoch" || { echo "FAIL: last_change_epoch clobbered"; FAIL=1; }
52
+ grep -q '"session_id": "s1"' "$TMP_ROOT/.devlog/.segment-state" \
53
+ && echo "PASS: override set keeps session_id" || { echo "FAIL: session_id clobbered"; FAIL=1; }
54
+ case "$OUT" in
55
+ *"SEGMENT_MAX_SILENT_SECONDS=120"*) echo "PASS: override set reports 120" ;;
56
+ *) echo "FAIL: expected SEGMENT_MAX_SILENT_SECONDS=120, got $OUT"; FAIL=1 ;;
57
+ esac
58
+
59
+ # --- 4: invalid seconds rejected, state untouched ---------------------------
60
+ BEFORE="$(cat "$TMP_ROOT/.devlog/.segment-state")"
61
+ bash "$SCRIPT_DIR/segment-watch-set.sh" abc >/dev/null 2>"$TMP_ROOT/err"
62
+ assert_exit "bad arg -> exit 1" 1 $?
63
+ AFTER="$(cat "$TMP_ROOT/.devlog/.segment-state")"
64
+ if [ "$BEFORE" = "$AFTER" ]; then echo "PASS: bad arg leaves segment-state untouched"
65
+ else echo "FAIL: bad arg changed segment-state"; FAIL=1; fi
66
+ [ -s "$TMP_ROOT/err" ] && echo "PASS: bad arg prints stderr" || { echo "FAIL: bad arg silent"; FAIL=1; }
67
+
68
+ bash "$SCRIPT_DIR/segment-watch-set.sh" 0 >/dev/null 2>&1
69
+ assert_exit "zero -> exit 1" 1 $?
70
+
71
+ bash "$SCRIPT_DIR/segment-watch-set.sh" >/dev/null 2>&1
72
+ assert_exit "missing arg -> exit 1" 1 $?
73
+
74
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
75
+ else echo "Some checks FAILED."; exit 1; fi
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for segment-watch.sh (+ round-start.sh clock reset in later
3
+ # scenarios). No framework — plain assert-and-exit. Run directly:
4
+ # bash hooks/scripts/test-segment-watch.sh
5
+ set -uo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
8
+ TMP_ROOT="$(mktemp -d)"
9
+ trap 'rm -rf "$TMP_ROOT"' EXIT
10
+
11
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
12
+ DEVLOG_DIR="$TMP_ROOT/.devlog"
13
+ mkdir -p "$DEVLOG_DIR"
14
+ touch "$DEVLOG_DIR/.enabled"
15
+ printf 'seed\n' > "$DEVLOG_DIR/.round-current.md"
16
+ SEED_CKSUM="$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')"
17
+
18
+ FAIL=0
19
+ assert_exit() {
20
+ local desc="$1" expected="$2" actual="$3"
21
+ if [ "$actual" -eq "$expected" ]; then
22
+ echo "PASS: $desc"
23
+ else
24
+ echo "FAIL: $desc (expected exit $expected, got $actual)"
25
+ FAIL=1
26
+ fi
27
+ }
28
+
29
+ assert_contains() {
30
+ local desc="$1" needle="$2" haystack="$3"
31
+ case "$haystack" in
32
+ *"$needle"*) echo "PASS: $desc" ;;
33
+ *) echo "FAIL: $desc (missing: $needle)"; FAIL=1 ;;
34
+ esac
35
+ }
36
+
37
+ write_state() {
38
+ local epoch="$1" sum="$2" max="$3"
39
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "%s", "max_silent_seconds": %s, "session_id": "aaa"}\n' \
40
+ "$epoch" "$sum" "$max" > "$DEVLOG_DIR/.segment-state"
41
+ }
42
+
43
+ NOW="$(date +%s)"
44
+ EXPIRED="$((NOW - 960))"
45
+ BASH_PAYLOAD='{"tool_name":"Bash","tool_input":{"command":"echo hi"}}'
46
+ WRITE_REL='{"tool_name":"Write","tool_input":{"file_path":".devlog/.round-current.md"}}'
47
+ WRITE_ABS="$(printf '{"tool_name":"Write","tool_input":{"file_path":"%s/.devlog/.round-current.md"}}' "$TMP_ROOT")"
48
+ WRITE_OTHER='{"tool_name":"Write","tool_input":{"file_path":"src/foo.ts"}}'
49
+ EDIT_REL='{"tool_name":"Edit","tool_input":{"file_path":".devlog/.round-current.md"}}'
50
+ STRREPLACE_REL='{"tool_name":"StrReplace","tool_input":{"file_path":".devlog/.round-current.md"}}'
51
+
52
+ # --- Scenario 1: no .enabled -> exit 0 ------------------------------------
53
+ rm -f "$DEVLOG_DIR/.enabled"
54
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
55
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
56
+ assert_exit "no .enabled -> allowed" 0 $?
57
+ touch "$DEVLOG_DIR/.enabled"
58
+
59
+ # --- Scenario 2: fresh clock, Bash immediately -> exit 0 --------------------
60
+ write_state "$NOW" "$SEED_CKSUM" 900
61
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
62
+ assert_exit "fresh epoch, Bash -> allowed" 0 $?
63
+
64
+ # --- Scenario 3: expired + Bash -> exit 2 + stderr phrase -------------------
65
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
66
+ ERR="$(printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" 2>&1 >/dev/null)"
67
+ assert_exit "expired + Bash -> blocked" 2 $?
68
+ assert_contains "block message names ### 段落" "### 段落" "$ERR"
69
+ assert_contains "block message names 門檻 900" "門檻 900 秒" "$ERR"
70
+ assert_contains "block message starts 這一輪已經" "這一輪已經" "$ERR"
71
+ assert_contains "block message ends 寫完再繼續呼叫其他工具" "寫完再繼續呼叫其他工具" "$ERR"
72
+
73
+ # --- Scenario 3a: expired silence valve also allows read-only git inspection,
74
+ # same allowlist as the workspace-mismatch block above ----------------------
75
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
76
+ printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git log -5"}}' \
77
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
78
+ assert_exit "expired silence + read-only git log -> allowed" 0 $?
79
+
80
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
81
+ printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git status && echo done"}}' \
82
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
83
+ assert_exit "expired silence + chained command -> still blocked" 2 $?
84
+
85
+ # --- Scenario 3b: only the parent session uses this valve -------------------
86
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
87
+ printf '%s' '{"tool_name":"Bash","session_id":"aaa"}' | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
88
+ assert_exit "expired + matching session_id -> blocked" 2 $?
89
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
90
+ printf '%s' '{"tool_name":"Bash","session_id":"bbb"}' | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
91
+ assert_exit "expired + different session_id -> allowed" 0 $?
92
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
93
+ printf '%s' '{"tool_name":"Bash"}' | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
94
+ assert_exit "expired + missing session_id -> blocked" 2 $?
95
+
96
+ # --- expired + Read / Grep / agent_id (unblock plan) ------------------------
97
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
98
+ printf '%s' '{"tool_name":"Read","tool_input":{"file_path":".devlog/.round-current.md"},"session_id":"aaa"}' \
99
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
100
+ assert_exit "expired + Read .round-current.md -> allowed" 0 $?
101
+
102
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
103
+ printf '%s' '{"tool_name":"Read","tool_input":{"file_path":"README.md"},"session_id":"aaa"}' \
104
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
105
+ assert_exit "expired + Read other -> blocked" 2 $?
106
+
107
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
108
+ printf '%s' '{"tool_name":"Grep","tool_input":{"path":".devlog/.round-current.md","pattern":"Round"},"session_id":"aaa"}' \
109
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
110
+ assert_exit "expired + Grep .round-current.md -> allowed" 0 $?
111
+
112
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
113
+ printf '%s' '{"tool_name":"Bash","tool_input":{},"session_id":"aaa","agent_id":"agent-xyz"}' \
114
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
115
+ assert_exit "expired + agent_id -> allowed" 0 $?
116
+
117
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
118
+ printf '%s' '{"tool_name":"Bash","tool_input":{},"session_id":"aaa","agent_id":""}' \
119
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
120
+ assert_exit "expired + empty agent_id -> blocked" 2 $?
121
+
122
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
123
+ ERR="$(printf '%s' '{"tool_name":"Bash","tool_input":{},"session_id":"aaa"}' \
124
+ | bash "$SCRIPT_DIR/segment-watch.sh" 2>&1 >/dev/null || true)"
125
+ case "$ERR" in
126
+ *Read*.devlog/.round-current.md*禁止*覆寫*) echo "PASS: stderr instructs Read then append" ;;
127
+ *) echo "FAIL: stderr [$ERR]"; FAIL=1 ;;
128
+ esac
129
+
130
+ # --- Scenario 4: expired + Write relative .round-current.md -> exit 0 ---------------
131
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
132
+ printf '%s' "$WRITE_REL" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
133
+ assert_exit "expired + Write .devlog/.round-current.md -> allowed" 0 $?
134
+
135
+ # --- Scenario 5: expired + Write absolute path ending in /.devlog/.round-current.md -
136
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
137
+ printf '%s' "$WRITE_ABS" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
138
+ assert_exit "expired + Write absolute /.devlog/.round-current.md -> allowed" 0 $?
139
+
140
+ # --- Scenario 6: expired + Edit relative .round-current.md -> exit 0 -----------------
141
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
142
+ printf '%s' "$EDIT_REL" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
143
+ assert_exit "expired + Edit .devlog/.round-current.md -> allowed" 0 $?
144
+
145
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
146
+ printf '%s' "$STRREPLACE_REL" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
147
+ assert_exit "expired + StrReplace .devlog/.round-current.md -> allowed" 0 $?
148
+
149
+ # --- Scenario 6b: allowlist path check against .round-current.md ------------
150
+ ALLOW_DIR="$(mktemp -d)"
151
+ export CLAUDE_PROJECT_DIR="$ALLOW_DIR"
152
+ mkdir -p "$ALLOW_DIR/.devlog"
153
+ touch "$ALLOW_DIR/.devlog/.enabled"
154
+ printf '## Round 1 — 2026-09-17T09:00:00+0800\n\n### User Input\n```text\nX\n```\n\n### Status\nIN_PROGRESS\n' > "$ALLOW_DIR/.devlog/.round-current.md"
155
+ NOW_EPOCH="$(date +%s)"
156
+ OLD_EPOCH=$((NOW_EPOCH - 1000))
157
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "stale", "max_silent_seconds": 600}\n' "$OLD_EPOCH" > "$ALLOW_DIR/.devlog/.segment-state"
158
+
159
+ INPUT='{"tool_name":"Edit","tool_input":{"file_path":".devlog/.round-current.md"}}'
160
+ echo "$INPUT" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
161
+ RC=$?
162
+ assert_exit "allowlist: Edit on .round-current.md passes through" 0 "$RC"
163
+ rm -rf "$ALLOW_DIR"
164
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
165
+
166
+ # --- Scenario 7: expired + Write other file -> exit 2 -----------------------
167
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
168
+ printf '%s' "$WRITE_OTHER" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
169
+ assert_exit "expired + Write other file -> blocked" 2 $?
170
+
171
+ # --- Scenario 8: hash changed since last_seen -> exit 0 and persist ---------
172
+ write_state "$EXPIRED" "stale-sum" 900
173
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
174
+ assert_exit "hash differs from last_seen_cksum -> allowed" 0 $?
175
+ SEEN_AFTER="$(grep -o '"last_seen_cksum"[[:space:]]*:[[:space:]]*"[^"]*"' "$DEVLOG_DIR/.segment-state" | sed 's/.*"last_seen_cksum"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
176
+ EPOCH_AFTER="$(grep -o '"last_change_epoch"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.segment-state" | grep -o '[0-9]\+$')"
177
+ MAX_AFTER="$(grep -o '"max_silent_seconds"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.segment-state" | grep -o '[0-9]\+$')"
178
+ if [ "$SEEN_AFTER" = "$SEED_CKSUM" ] && [ "$EPOCH_AFTER" -ge "$NOW" ] && [ "$MAX_AFTER" = "900" ]; then
179
+ echo "PASS: hash-change persist updated cksum/epoch and left max_silent_seconds=900"
180
+ else
181
+ echo "FAIL: persist expected cksum=$SEED_CKSUM epoch>=$NOW max=900, got cksum=$SEEN_AFTER epoch=$EPOCH_AFTER max=$MAX_AFTER"
182
+ FAIL=1
183
+ fi
184
+
185
+ # --- Scenario 9: malformed state -> fail-open --------------------------------
186
+ echo 'not json' > "$DEVLOG_DIR/.segment-state"
187
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
188
+ assert_exit "malformed .segment-state -> allowed" 0 $?
189
+
190
+ # --- Scenario 10: missing .segment-state -> fail-open ------------------------
191
+ rm -f "$DEVLOG_DIR/.segment-state"
192
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
193
+ assert_exit "missing .segment-state -> allowed" 0 $?
194
+
195
+ # --- Scenario 11: span open does NOT disable the valve ----------------------
196
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
197
+ cat > "$DEVLOG_DIR/.span-open" <<'SPANEOF'
198
+ {"round": 1, "opened_at": "2026-09-09T00:00:00+08:00", "ticks_since_checkin": 1, "max_silent_ticks": 5}
199
+ SPANEOF
200
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
201
+ assert_exit "expired + open under-budget span + Bash -> still blocked" 2 $?
202
+ rm -f "$DEVLOG_DIR/.span-open"
203
+
204
+ # --- Scenario 12: no jq, expired + Bash still blocks ------------------------
205
+ PATH_NO_JQ="$TMP_ROOT/no-jq-path"
206
+ mkdir -p "$PATH_NO_JQ"
207
+ for bin in bash cat printf cksum date grep sed awk mv tr head; do
208
+ bin_path="$(command -v "$bin" 2>/dev/null || true)"
209
+ [ -n "$bin_path" ] && ln -sf "$bin_path" "$PATH_NO_JQ/$bin"
210
+ done
211
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
212
+ printf '%s' "$BASH_PAYLOAD" | PATH="$PATH_NO_JQ" bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
213
+ assert_exit "expired + Bash without jq -> blocked" 2 $?
214
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
215
+ printf '%s' "$WRITE_REL" | PATH="$PATH_NO_JQ" bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
216
+ assert_exit "expired + Write without jq -> allowed" 0 $?
217
+ WRITE_SPACED='{ "tool_name" : "Write" , "tool_input": {"file_path":".devlog/.round-current.md"} }'
218
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
219
+ printf '%s' "$WRITE_SPACED" | PATH="$PATH_NO_JQ" bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
220
+ assert_exit "expired + spaced Write without jq -> allowed" 0 $?
221
+
222
+ # --- Scenario 12b: expired + empty stdin -> fail-open exit 0 ----------------
223
+ write_state "$EXPIRED" "$SEED_CKSUM" 900
224
+ printf '' | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
225
+ assert_exit "expired + empty stdin -> allowed" 0 $?
226
+
227
+ # --- Scenario 13: round-start.sh resets clock and preserves max -------------
228
+ write_state 1 "old-sum" 600
229
+ bash "$SCRIPT_DIR/round-start.sh" < /dev/null
230
+ POST_SKEL="$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')"
231
+ RS_EPOCH="$(grep -o '"last_change_epoch"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.segment-state" | grep -o '[0-9]\+$')"
232
+ RS_SUM="$(grep -o '"last_seen_cksum"[[:space:]]*:[[:space:]]*"[^"]*"' "$DEVLOG_DIR/.segment-state" | sed 's/.*"last_seen_cksum"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
233
+ RS_MAX="$(grep -o '"max_silent_seconds"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.segment-state" | grep -o '[0-9]\+$')"
234
+ if [ "$RS_EPOCH" -ge "$NOW" ] && [ "$RS_SUM" = "$POST_SKEL" ] && [ "$RS_MAX" = "600" ]; then
235
+ echo "PASS: round-start.sh reset epoch/cksum to post-skeleton hash and kept max_silent_seconds=600"
236
+ else
237
+ echo "FAIL: round-start expected epoch>=$NOW cksum=$POST_SKEL max=600, got epoch=$RS_EPOCH cksum=$RS_SUM max=$RS_MAX"
238
+ FAIL=1
239
+ fi
240
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
241
+ assert_exit "immediately after round-start.sh, Bash -> allowed" 0 $?
242
+
243
+ # --- Scenario 14: missing last_seen_cksum key -> fail-open ------------------
244
+ printf '{"last_change_epoch": 1, "max_silent_seconds": 900}\n' > "$DEVLOG_DIR/.segment-state"
245
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
246
+ assert_exit "segment-state missing last_seen_cksum key -> allowed" 0 $?
247
+
248
+ # --- Scenario 15: identity match skips cksum but still blocks when expired --
249
+ # round-start may have rewritten the file; re-seed so cksum matches identity.
250
+ printf 'seed\n' > "$DEVLOG_DIR/.round-current.md"
251
+ SEED_CKSUM="$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')"
252
+
253
+ file_identity() {
254
+ local f="$1" mt sz
255
+ if mt="$(stat -f '%m' "$f" 2>/dev/null)" && sz="$(stat -f '%z' "$f" 2>/dev/null)"; then
256
+ printf '%s %s\n' "$mt" "$sz"
257
+ return 0
258
+ fi
259
+ if mt="$(stat -c '%Y' "$f" 2>/dev/null)" && sz="$(stat -c '%s' "$f" 2>/dev/null)"; then
260
+ printf '%s %s\n' "$mt" "$sz"
261
+ return 0
262
+ fi
263
+ return 1
264
+ }
265
+
266
+ ID_LINE="$(file_identity "$DEVLOG_DIR/.round-current.md")"
267
+ ID_MT="${ID_LINE%% *}"
268
+ ID_SZ="${ID_LINE#* }"
269
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "%s", "last_seen_mtime": "%s", "last_seen_size": "%s", "max_silent_seconds": 900, "session_id": "aaa"}\n' \
270
+ "$EXPIRED" "$SEED_CKSUM" "$ID_MT" "$ID_SZ" > "$DEVLOG_DIR/.segment-state"
271
+
272
+ SPY="$TMP_ROOT/spybin"
273
+ mkdir -p "$SPY"
274
+ REAL_CKSUM="$(command -v cksum)"
275
+ rm -f "$TMP_ROOT/cksum_count"
276
+ cat > "$SPY/cksum" <<EOF
277
+ #!/usr/bin/env bash
278
+ echo 1 >> "$TMP_ROOT/cksum_count"
279
+ exec "$REAL_CKSUM" "\$@"
280
+ EOF
281
+ chmod +x "$SPY/cksum"
282
+ printf '%s' "$BASH_PAYLOAD" | PATH="$SPY:$PATH" bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
283
+ assert_exit "identity match + expired -> still blocked" 2 $?
284
+ if [ ! -f "$TMP_ROOT/cksum_count" ]; then
285
+ echo "PASS: identity match skipped cksum"
286
+ else
287
+ echo "FAIL: cksum was invoked despite matching identity"
288
+ FAIL=1
289
+ fi
290
+
291
+ # --- Scenario 16: identity mismatch forces cksum and clears silence ---------
292
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "%s", "last_seen_mtime": "%s", "last_seen_size": "%s", "max_silent_seconds": 900, "session_id": "aaa"}\n' \
293
+ "$EXPIRED" "$SEED_CKSUM" "$ID_MT" "$ID_SZ" > "$DEVLOG_DIR/.segment-state"
294
+ printf 'x' >> "$DEVLOG_DIR/.round-current.md"
295
+ NEW_CKSUM="$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')"
296
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
297
+ assert_exit "mtime/size change -> allowed (re-hash)" 0 $?
298
+ SEEN_MT="$(grep -o '"last_seen_mtime"[[:space:]]*:[[:space:]]*"[^"]*"' "$DEVLOG_DIR/.segment-state" | sed 's/.*"last_seen_mtime"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
299
+ SEEN_SZ="$(grep -o '"last_seen_size"[[:space:]]*:[[:space:]]*"[^"]*"' "$DEVLOG_DIR/.segment-state" | sed 's/.*"last_seen_size"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
300
+ SEEN_SUM="$(grep -o '"last_seen_cksum"[[:space:]]*:[[:space:]]*"[^"]*"' "$DEVLOG_DIR/.segment-state" | sed 's/.*"last_seen_cksum"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
301
+ ID_LINE2="$(file_identity "$DEVLOG_DIR/.round-current.md")"
302
+ ID_MT2="${ID_LINE2%% *}"
303
+ ID_SZ2="${ID_LINE2#* }"
304
+ if [ "$SEEN_MT" = "$ID_MT2" ] && [ "$SEEN_SZ" = "$ID_SZ2" ] && [ "$SEEN_SUM" = "$NEW_CKSUM" ]; then
305
+ echo "PASS: identity mismatch refreshed cksum + identity fields"
306
+ else
307
+ echo "FAIL: expected mtime=$ID_MT2 size=$ID_SZ2 cksum=$NEW_CKSUM, got mtime=$SEEN_MT size=$SEEN_SZ cksum=$SEEN_SUM"
308
+ FAIL=1
309
+ fi
310
+
311
+ # --- Scenario 17: missing identity fields fall through to cksum -------------
312
+ printf 'seed\n' > "$DEVLOG_DIR/.round-current.md"
313
+ SEED_CKSUM="$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')"
314
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "%s", "max_silent_seconds": 900, "session_id": "aaa"}\n' \
315
+ "$EXPIRED" "$SEED_CKSUM" > "$DEVLOG_DIR/.segment-state"
316
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
317
+ assert_exit "missing identity fields + expired -> still blocked via cksum" 2 $?
318
+
319
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "stale-sum", "max_silent_seconds": 900, "session_id": "aaa"}\n' \
320
+ "$EXPIRED" > "$DEVLOG_DIR/.segment-state"
321
+ printf '%s' "$BASH_PAYLOAD" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
322
+ assert_exit "missing identity + hash change -> allowed" 0 $?
323
+
324
+ # --- workspace mismatch marker blocks non-devlog tools ----------------------
325
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
326
+ ## Round 1 — 2026-09-11T00:00:00+08:00
327
+
328
+ ### User Input
329
+ ```text
330
+ keep going
331
+ ```
332
+
333
+ ### Status
334
+ IN_PROGRESS
335
+ EOF
336
+ printf '%s\n' 'main @ deadbeef,工作樹乾淨' > "$DEVLOG_DIR/.workspace-mismatch"
337
+ write_state "$NOW" "$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')" 900
338
+ printf '%s' '{"tool_name":"Bash","tool_input":{},"session_id":"aaa"}' \
339
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
340
+ assert_exit "mismatch marker + Bash -> blocked" 2 $?
341
+
342
+ printf '%s' '{"tool_name":"Read","tool_input":{"file_path":".devlog/.round-current.md"},"session_id":"aaa"}' \
343
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
344
+ assert_exit "mismatch marker + Read devlog -> allowed" 0 $?
345
+
346
+ ERR="$(printf '%s' '{"tool_name":"Bash","tool_input":{},"session_id":"aaa"}' \
347
+ | bash "$SCRIPT_DIR/segment-watch.sh" 2>&1 >/dev/null || true)"
348
+ case "$ERR" in
349
+ *實際*deadbeef*) echo "PASS: mismatch stderr includes live snapshot" ;;
350
+ *) echo "FAIL: mismatch stderr [$ERR]"; FAIL=1 ;;
351
+ esac
352
+
353
+ # last Round contains the live snapshot -> marker cleared, Bash allowed
354
+ # (fresh clock so the silence valve does not also fire)
355
+ cat >> "$DEVLOG_DIR/.round-current.md" <<'EOF'
356
+ ### 段落 1
357
+ 宣稱 vs 實際
358
+ main @ deadbeef,工作樹乾淨
359
+ EOF
360
+ write_state "$NOW" "$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')" 900
361
+ printf '%s' '{"tool_name":"Bash","tool_input":{},"session_id":"aaa"}' \
362
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
363
+ assert_exit "mismatch recorded in 段落 -> Bash allowed" 0 $?
364
+ [ ! -f "$DEVLOG_DIR/.workspace-mismatch" ] && echo "PASS: marker removed after 段落" || { echo "FAIL: marker remains"; FAIL=1; }
365
+
366
+ # two-line dirty snapshot (branch @ hash + 未提交) recorded in 段落 -> marker cleared
367
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
368
+ ## Round 1 — 2026-09-11T00:00:00+08:00
369
+
370
+ ### User Input
371
+ ```text
372
+ keep going
373
+ ```
374
+
375
+ ### 段落 1
376
+ feat/foo @ abc1234
377
+ 未提交:a.txt
378
+
379
+ ### Status
380
+ IN_PROGRESS
381
+ EOF
382
+ cat > "$DEVLOG_DIR/.workspace-mismatch" <<'EOF'
383
+ feat/foo @ abc1234
384
+ 未提交:a.txt
385
+ EOF
386
+ write_state "$NOW" "$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')" 900
387
+ printf '%s' '{"tool_name":"Bash","tool_input":{},"session_id":"aaa"}' \
388
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
389
+ assert_exit "two-line dirty snapshot in 段落 -> Bash allowed" 0 $?
390
+ [ ! -f "$DEVLOG_DIR/.workspace-mismatch" ] && echo "PASS: two-line marker removed after 段落" || { echo "FAIL: two-line marker remains"; FAIL=1; }
391
+
392
+ # Same string outside a 段落 must not clear the marker
393
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
394
+ ## Round 1 — 2026-09-11T00:00:00+08:00
395
+
396
+ ### User Input
397
+ main @ deadbeef,工作樹乾淨
398
+
399
+ ### Status
400
+ IN_PROGRESS
401
+ EOF
402
+ printf '%s\n' 'main @ deadbeef,工作樹乾淨' > "$DEVLOG_DIR/.workspace-mismatch"
403
+ write_state "$NOW" "$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')" 900
404
+ printf '%s' '{"tool_name":"Bash","tool_input":{},"session_id":"aaa"}' \
405
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
406
+ assert_exit "snapshot in User Input only -> still blocked" 2 $?
407
+
408
+ # --- read-only git inspection commands stay allowed while blocked ----------
409
+ # The whole point of the mismatch block is to force a look at live git
410
+ # before acting — but Bash itself is blocked, so there was no way to run
411
+ # `git status`/`git diff` to see what actually changed. A small allowlist of
412
+ # unconditionally non-mutating git subcommands stays open; anything with
413
+ # shell metacharacters (chaining/redirection/substitution) or a mutating
414
+ # subcommand (commit, branch -d, etc.) stays blocked.
415
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
416
+ ## Round 1 — 2026-09-11T00:00:00+08:00
417
+
418
+ ### User Input
419
+ ```text
420
+ keep going
421
+ ```
422
+
423
+ ### Status
424
+ IN_PROGRESS
425
+ EOF
426
+ printf '%s\n' 'main @ deadbeef,工作樹乾淨' > "$DEVLOG_DIR/.workspace-mismatch"
427
+ write_state "$NOW" "$(cksum < "$DEVLOG_DIR/.round-current.md" | tr -d '\n')" 900
428
+ printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git status --short"},"session_id":"aaa"}' \
429
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
430
+ assert_exit "mismatch marker + read-only git status -> allowed" 0 $?
431
+ [ -f "$DEVLOG_DIR/.workspace-mismatch" ] && echo "PASS: read-only git inspection does not clear the marker" || { echo "FAIL: marker cleared by mere inspection"; FAIL=1; }
432
+
433
+ printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git diff"},"session_id":"aaa"}' \
434
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
435
+ assert_exit "mismatch marker + read-only git diff -> allowed" 0 $?
436
+
437
+ printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git status; rm -rf /tmp/whatever"},"session_id":"aaa"}' \
438
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
439
+ assert_exit "mismatch marker + chained command after git status -> still blocked" 2 $?
440
+
441
+ printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git commit -am oops"},"session_id":"aaa"}' \
442
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
443
+ assert_exit "mismatch marker + git commit -> still blocked" 2 $?
444
+
445
+ printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git branch -d main"},"session_id":"aaa"}' \
446
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
447
+ assert_exit "mismatch marker + git branch -d (mutating, not allowlisted) -> still blocked" 2 $?
448
+
449
+ # marker works even without .segment-state (today that file missing exits 0)
450
+ rm -f "$DEVLOG_DIR/.segment-state"
451
+ printf '%s\n' 'main @ deadbeef,工作樹乾淨' > "$DEVLOG_DIR/.workspace-mismatch"
452
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
453
+ ## Round 1 — 2026-09-11T00:00:00+08:00
454
+
455
+ ### Status
456
+ IN_PROGRESS
457
+ EOF
458
+ printf '%s' '{"tool_name":"Bash","tool_input":{},"session_id":"aaa"}' \
459
+ | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1
460
+ assert_exit "mismatch marker without segment-state + Bash -> blocked" 2 $?
461
+
462
+ if [ "$FAIL" -eq 0 ]; then
463
+ echo "All checks passed."
464
+ exit 0
465
+ else
466
+ echo "Some checks FAILED."
467
+ exit 1
468
+ fi