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,19 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced helper: redact_prompt reads stdin and writes sanitized prompt text.
3
+ redact_prompt() {
4
+ awk '
5
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/ { skip=1; print "(已遮罩)"; next }
6
+ skip && /-----END [A-Z ]*PRIVATE KEY-----/ { skip=0; next }
7
+ skip { next }
8
+ { print }
9
+ ' | sed -E \
10
+ -e 's/sk-ant-api03-[A-Za-z0-9_-]+/(已遮罩)/g' \
11
+ -e 's/sk-ant-[A-Za-z0-9_-]{20,}/(已遮罩)/g' \
12
+ -e 's/sk-[A-Za-z0-9]{20,}/(已遮罩)/g' \
13
+ -e 's/ghp_[A-Za-z0-9]{20,}/(已遮罩)/g' \
14
+ -e 's/github_pat_[A-Za-z0-9_]{20,}/(已遮罩)/g' \
15
+ -e 's/xox[baprs]-[A-Za-z0-9-]{10,}/(已遮罩)/g' \
16
+ -e 's/AKIA[A-Z0-9]{16}/(已遮罩)/g' \
17
+ -e 's/Bearer [A-Za-z0-9._+=/-]{20,}/(已遮罩)/g' \
18
+ -e 's/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/(已遮罩)/g'
19
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ _src="${BASH_SOURCE[0]}"
5
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
6
+ # shellcheck source=json-field.sh
7
+ . "$SCRIPT_DIR/json-field.sh"
8
+
9
+ NAME=""
10
+ if [ "${1:-}" = "--name" ]; then
11
+ NAME="${2:-}"
12
+ else
13
+ echo "MISSING_NAME" >&2
14
+ exit 1
15
+ fi
16
+ case "$NAME" in devlog.md|*'/'*|*'\'*|*'..'*) echo "INVALID_NAME" >&2; exit 1 ;; esac
17
+ case "$NAME" in devlog.*) NAME="${NAME#devlog.}" ;; esac
18
+ case "$NAME" in *.md) NAME="${NAME%.md}" ;; esac
19
+ NAME="$(slugify "$NAME")"
20
+ [ -n "$NAME" ] && [ "$NAME" != "archive" ] && [ "${#NAME}" -le 64 ] \
21
+ || { echo "INVALID_NAME" >&2; exit 1; }
22
+
23
+ DEVLOG_DIR="${CLAUDE_PROJECT_DIR:-.}/.devlog"
24
+ FILE="$DEVLOG_DIR/devlog.$NAME.md"
25
+ if [ ! -f "$FILE" ]; then
26
+ candidates=""
27
+ shopt -s nullglob
28
+ for path in "$DEVLOG_DIR"/devlog.*.md; do
29
+ leaf="${path##*/}"
30
+ case "$leaf" in devlog.md|devlog.archive.md) continue ;; esac
31
+ candidate="${leaf#devlog.}"
32
+ candidate="${candidate%.md}"
33
+ [ -z "$candidates" ] || candidates="$candidates,"
34
+ candidates="$candidates$candidate"
35
+ done
36
+ echo "MISSING"
37
+ echo "CANDIDATES=$candidates"
38
+ exit 1
39
+ fi
40
+ printf 'PATH=.devlog/devlog.%s.md\n' "$NAME"
41
+ cat "$FILE"
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env bash
2
+ # UserPromptSubmit hook:每次使用者送出新訊息時執行。
3
+ # 互動輪次:先(如有)把上一輪未收尾標成 INTERRUPTED,再追加本輪 User Input
4
+ # skeleton,然後把 .turn-start 設成「寫完 skeleton 之後」的雜湊,供 Stop hook
5
+ # 判斷 Claude 有沒有再寫 Summary/Handoff。
6
+ # Span 開著且檔案格式有效時不開新 Round。
7
+ # fail-open:這支腳本本身出任何問題都不該影響使用者送出訊息,一律 exit 0。
8
+
9
+ set -uo pipefail
10
+
11
+ _src="${BASH_SOURCE[0]}"
12
+ HOOKS_DIR="$(cd "${_src%/*}" && pwd)"
13
+ # shellcheck source=json-field.sh
14
+ . "$HOOKS_DIR/json-field.sh"
15
+ # shellcheck source=redact-prompt.sh
16
+ . "$HOOKS_DIR/redact-prompt.sh"
17
+ # shellcheck source=devlog-lock.sh
18
+ . "$HOOKS_DIR/devlog-lock.sh"
19
+ # shellcheck source=devlog-md.sh
20
+ . "$HOOKS_DIR/devlog-md.sh"
21
+ # shellcheck source=workspace-snapshot.sh
22
+ . "$HOOKS_DIR/workspace-snapshot.sh"
23
+ # shellcheck source=detect-pending-question.sh
24
+ . "$HOOKS_DIR/detect-pending-question.sh"
25
+ # shellcheck source=devlog-path.sh
26
+ . "$HOOKS_DIR/devlog-path.sh"
27
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
28
+ [ -f "$PROJECT_DIR/.devlog/.enabled" ] || exit 0
29
+ devlog_resolve_paths "$PROJECT_DIR"
30
+ ROUND_CURRENT="$DEVLOG_DIR/.round-current.md"
31
+ SPAN_FILE="$DEVLOG_DIR/.span-open"
32
+ CHECKPOINT_FILE="$DEVLOG_DIR/.checkpoint-state"
33
+ SEGMENT_FILE="$DEVLOG_DIR/.segment-state"
34
+ ROUND_OPEN="$DEVLOG_DIR/.round-open"
35
+ AWAITING_FILE="$DEVLOG_DIR/.awaiting-reply"
36
+ DRIFT_FILE="$DEVLOG_DIR/.lessons-drift-state"
37
+
38
+ devlog_lock_acquire
39
+ trap 'devlog_lock_release' EXIT
40
+
41
+ INPUT="$(cat 2>/dev/null || true)"
42
+ SESSION_ID="$(json_str_field "$INPUT" session_id)"
43
+
44
+ if [ -f "$ROUND_OPEN" ]; then
45
+ DANGLING_DETAIL=""
46
+ if [ -n "$(detect_pending_question "$(json_str_field "$INPUT" transcript_path)")" ]; then
47
+ DANGLING_DETAIL="awaiting_question"
48
+ fi
49
+ bash "$HOOKS_DIR/close-open-round.sh" "dangling:next_prompt" "$DANGLING_DETAIL" || true
50
+ fi
51
+
52
+ FOLD_ROUND=""
53
+ FOLD_NOTE="(回覆上一輪的問題)"
54
+ if [ -f "$AWAITING_FILE" ]; then
55
+ AWAIT_ROUND="$(json_int_get "$AWAITING_FILE" round)"
56
+ rm -f "$AWAITING_FILE" 2>/dev/null || true
57
+ case "$AWAIT_ROUND" in
58
+ ''|*[!0-9]*) AWAIT_ROUND='' ;;
59
+ esac
60
+ if [ -n "$AWAIT_ROUND" ] && [ -f "$DEVLOG_FILE" ]; then
61
+ CURRENT_LAST="$(devlog_list_round_starts "$DEVLOG_FILE" | awk 'END { print $2 }')"
62
+ if [ "$CURRENT_LAST" = "$AWAIT_ROUND" ]; then
63
+ FOLD_ROUND="$AWAIT_ROUND"
64
+ fi
65
+ fi
66
+ fi
67
+
68
+ SPAN_SKIP=0
69
+ if [ -f "$SPAN_FILE" ]; then
70
+ SPAN_TICKS="$(json_int_get "$SPAN_FILE" ticks_since_checkin)"
71
+ SPAN_MAX="$(json_int_get "$SPAN_FILE" max_silent_ticks)"
72
+ case "$SPAN_TICKS" in ''|*[!0-9]*) SPAN_TICKS='' ;; esac
73
+ case "$SPAN_MAX" in ''|*[!0-9]*) SPAN_MAX='' ;; esac
74
+ if [ -n "$SPAN_TICKS" ] && [ -n "$SPAN_MAX" ]; then
75
+ SPAN_SKIP=1
76
+ fi
77
+ fi
78
+
79
+ PROMPT="$(json_str_field "$INPUT" prompt)"
80
+
81
+ # 背景 task-notification(例如子 agent 完成通知)不是使用者真的打字:不留原始
82
+ # XML,換成精簡摘要;也不當成新話題開新 Round,改折進最後一個 Round 當段落。
83
+ TASK_NOTIF=0
84
+ case "$PROMPT" in
85
+ *'<task-notification>'*'</task-notification>'*) TASK_NOTIF=1 ;;
86
+ esac
87
+ if [ "$TASK_NOTIF" -eq 1 ]; then
88
+ NOTIF_SUMMARY="$(printf '%s\n' "$PROMPT" | sed -n 's/.*<summary>\(.*\)<\/summary>.*/\1/p' | head -1)"
89
+ NOTIF_STATUS="$(printf '%s\n' "$PROMPT" | sed -n 's/.*<status>\(.*\)<\/status>.*/\1/p' | head -1)"
90
+ NOTIF_TASKID="$(printf '%s\n' "$PROMPT" | sed -n 's/.*<task-id>\(.*\)<\/task-id>.*/\1/p' | head -1)"
91
+ [ -n "$NOTIF_SUMMARY" ] || NOTIF_SUMMARY="背景任務通知"
92
+ NOTIF_META=""
93
+ [ -n "$NOTIF_STATUS" ] && NOTIF_META="status=$NOTIF_STATUS"
94
+ if [ -n "$NOTIF_TASKID" ]; then
95
+ if [ -n "$NOTIF_META" ]; then NOTIF_META="$NOTIF_META, task-id=$NOTIF_TASKID"
96
+ else NOTIF_META="task-id=$NOTIF_TASKID"
97
+ fi
98
+ fi
99
+ if [ -n "$NOTIF_META" ]; then
100
+ PROMPT="${NOTIF_SUMMARY}(${NOTIF_META})"
101
+ else
102
+ PROMPT="$NOTIF_SUMMARY"
103
+ fi
104
+ fi
105
+
106
+ if [ "$SPAN_SKIP" -eq 1 ]; then
107
+ FOLD_ROUND=""
108
+ fi
109
+
110
+ if [ "$TASK_NOTIF" -eq 1 ] && [ -z "$FOLD_ROUND" ] && [ "$SPAN_SKIP" -eq 0 ] && [ -f "$DEVLOG_FILE" ]; then
111
+ TASK_NOTIF_LAST="$(devlog_list_round_starts "$DEVLOG_FILE" | awk 'END { print $2 }')"
112
+ case "$TASK_NOTIF_LAST" in
113
+ ''|*[!0-9]*) : ;;
114
+ *)
115
+ FOLD_ROUND="$TASK_NOTIF_LAST"
116
+ FOLD_NOTE="(背景任務通知)"
117
+ ;;
118
+ esac
119
+ fi
120
+
121
+ MISMATCH_FILE="$DEVLOG_DIR/.workspace-mismatch"
122
+ rm -f "$MISMATCH_FILE" 2>/dev/null || true
123
+ if [ "$SPAN_SKIP" -eq 0 ] && [ "$TASK_NOTIF" -eq 0 ] && [ -f "$DEVLOG_FILE" ]; then
124
+ CLAIM_ST="$(workspace_claim_state "$PROJECT_DIR" "$DEVLOG_FILE" 2>/dev/null || echo NO_CLAIM)"
125
+ if [ "$CLAIM_ST" = "MISMATCH" ]; then
126
+ LAST_START="$(devlog_list_round_starts "$DEVLOG_FILE" | awk 'END { print $1 }')"
127
+ LAST_END="$(devlog_block_end "$DEVLOG_FILE" "$LAST_START")"
128
+ CLAIMED_WS="$(devlog_round_workspace_body "$DEVLOG_FILE" "$LAST_START" "$LAST_END")"
129
+ LIVE_WS="$(workspace_snapshot "$PROJECT_DIR")"
130
+ if [ -n "$LIVE_WS" ]; then
131
+ printf '%s\n' "$LIVE_WS" > "$MISMATCH_FILE" 2>/dev/null || true
132
+ printf '%s\n' "上一輪 Handoff「#### 工作區」跟目前 git 不符。先在這一輪追加 ### 段落,寫宣稱 vs 實際(實際用下面「實際」逐字內容),再依實際工作樹行動,不要照上一輪「現況/下一步」的字面。"
133
+ printf '\n宣稱:\n%s\n\n實際:\n%s\n' "$CLAIMED_WS" "$LIVE_WS"
134
+
135
+ if [ -f "$DEVLOG_DIR/.lessons-enabled" ]; then
136
+ if [ ! -f "$DRIFT_FILE" ]; then
137
+ printf '%s\n' '{"mismatch_count": 0, "threshold": 3}' \
138
+ > "$DRIFT_FILE" 2>/dev/null || true
139
+ fi
140
+ DRIFT_COUNT="$(json_int_get "$DRIFT_FILE" mismatch_count)"
141
+ DRIFT_MAX="$(json_int_get "$DRIFT_FILE" threshold)"
142
+ case "$DRIFT_COUNT" in ''|*[!0-9]*) DRIFT_COUNT=0 ;; esac
143
+ case "$DRIFT_MAX" in ''|*[!0-9]*) DRIFT_MAX=3 ;; esac
144
+ NEW_DRIFT_COUNT=$((DRIFT_COUNT + 1))
145
+ if [ "$NEW_DRIFT_COUNT" -ge "$DRIFT_MAX" ]; then
146
+ json_int_set "$DRIFT_FILE" mismatch_count 0
147
+ printf '\n[Lessons Mode 提示] 工作區宣稱與實際不符已累積出現 %s 次(門檻 %s)。可考慮用 lessons-append.sh 記一筆流程教訓,非強制。\n' "$NEW_DRIFT_COUNT" "$DRIFT_MAX"
148
+ else
149
+ json_int_set "$DRIFT_FILE" mismatch_count "$NEW_DRIFT_COUNT"
150
+ fi
151
+ fi
152
+ fi
153
+ fi
154
+ fi
155
+
156
+ if [ -n "$FOLD_ROUND" ]; then
157
+ devlog_reopen_last_round "$DEVLOG_FILE" "$ROUND_CURRENT" || : > "$ROUND_CURRENT"
158
+ if [ -z "$PROMPT" ]; then
159
+ PROMPT="(無 prompt)"
160
+ fi
161
+ TRUNC_NOTE=""
162
+ if [ "${#PROMPT}" -gt 4000 ]; then
163
+ PROMPT="${PROMPT:0:4000}"
164
+ TRUNC_NOTE="(後略,已截斷至 4000 字)"
165
+ fi
166
+ PROMPT="$(printf '%s' "$PROMPT" | sed 's/```/⟨fence⟩/g')"
167
+ PROMPT="$(printf '%s' "$PROMPT" | redact_prompt)"
168
+
169
+ TS="$(date +%H:%M 2>/dev/null || echo unknown)"
170
+ FULL_TS="$(date +%Y-%m-%dT%H:%M:%S%z 2>/dev/null || echo unknown)"
171
+ START_LINE="$(devlog_list_round_starts "$ROUND_CURRENT" | awk 'END { print $1 }')"
172
+ END_LINE="$(devlog_block_end "$ROUND_CURRENT" "$START_LINE")"
173
+ SEG_N=$(( $(devlog_count_segments "$ROUND_CURRENT" "$START_LINE" "$END_LINE") + 1 ))
174
+
175
+ SEG_TMP="$DEVLOG_DIR/.segment-insert.tmp"
176
+ {
177
+ printf '### 段落 %s - %s%s\n' "$SEG_N" "$TS" "$FOLD_NOTE"
178
+ printf '```text\n'
179
+ printf '%s\n' "$PROMPT"
180
+ printf '```\n'
181
+ if [ -n "$TRUNC_NOTE" ]; then
182
+ printf '%s\n' "$TRUNC_NOTE"
183
+ fi
184
+ printf '\n'
185
+ } > "$SEG_TMP" 2>/dev/null || true
186
+
187
+ if [ -f "$SEG_TMP" ]; then
188
+ devlog_insert_before_summary "$ROUND_CURRENT" "$START_LINE" "$END_LINE" "$SEG_TMP" > "$ROUND_CURRENT.tmp" 2>/dev/null \
189
+ && mv "$ROUND_CURRENT.tmp" "$ROUND_CURRENT" 2>/dev/null || rm -f "$ROUND_CURRENT.tmp" 2>/dev/null || true
190
+ rm -f "$SEG_TMP" 2>/dev/null || true
191
+ fi
192
+
193
+ printf '{"round": %s, "opened_at": "%s", "file": "%s"}\n' "$FOLD_ROUND" "$FULL_TS" "${DEVLOG_FILE##*/}" > "$ROUND_OPEN" 2>/dev/null || true
194
+ elif [ "$SPAN_SKIP" -eq 0 ]; then
195
+ if [ -z "$PROMPT" ]; then
196
+ PROMPT="(無 prompt)"
197
+ fi
198
+ TRUNC_NOTE=""
199
+ if [ "${#PROMPT}" -gt 4000 ]; then
200
+ PROMPT="${PROMPT:0:4000}"
201
+ TRUNC_NOTE="(後略,已截斷至 4000 字)"
202
+ fi
203
+ PROMPT="$(printf '%s' "$PROMPT" | sed 's/```/⟨fence⟩/g')"
204
+ PROMPT="$(printf '%s' "$PROMPT" | redact_prompt)"
205
+
206
+ # 救孤兒:正常情況下這裡 .round-current.md 應該已經是空的/不存在(上一輪
207
+ # 已經正常收尾併回 devlog.md)。如果不是——例如上一次 Stop 或
208
+ # close-open-round.sh 併入失敗,內容被孤立在這裡卻沒有任何機制知道要去
209
+ # 救它——在下面用 `>` 蓋掉新 skeleton 之前,先把它搶救併回 devlog.md,
210
+ # 而不是讓 `>` 直接蓋掉遺失。正常情況(檔案已空/不存在)這裡是 no-op。
211
+ # 必須放在下面的 LAST_N 掃描之前:這樣被搶救回來的孤兒 Round 才會被算進
212
+ # 編號,下一輪不會意外沿用它的號碼。
213
+ devlog_merge_round_current "$DEVLOG_FILE" "$ROUND_CURRENT"
214
+
215
+ LAST_N=0
216
+ if [ -f "$DEVLOG_FILE" ]; then
217
+ LAST_N="$(awk '
218
+ /^[ \t]*```/ { fence = !fence }
219
+ !fence && /^## Round / {
220
+ split($0, parts, /[ \t]+/)
221
+ n = parts[3] + 0
222
+ if (n > last) last = n
223
+ }
224
+ END { print last + 0 }
225
+ ' "$DEVLOG_FILE" 2>/dev/null || echo 0)"
226
+ case "$LAST_N" in ''|*[!0-9]*) LAST_N=0 ;; esac
227
+ fi
228
+ NEXT_N=$((LAST_N + 1))
229
+ TS="$(date +%Y-%m-%dT%H:%M:%S%z 2>/dev/null || echo unknown)"
230
+
231
+ {
232
+ printf '## Round %s — %s\n\n' "$NEXT_N" "$TS"
233
+ printf '### User Input\n'
234
+ printf '```text\n'
235
+ printf '%s\n' "$PROMPT"
236
+ printf '```\n'
237
+ if [ -n "$TRUNC_NOTE" ]; then
238
+ printf '%s\n' "$TRUNC_NOTE"
239
+ fi
240
+ printf '\n### Status\nIN_PROGRESS\n'
241
+ } > "$ROUND_CURRENT" 2>/dev/null || true
242
+
243
+ if [ -f "$ROUND_CURRENT" ]; then
244
+ printf '{"round": %s, "opened_at": "%s", "file": "%s"}\n' "$NEXT_N" "$TS" "${DEVLOG_FILE##*/}" > "$ROUND_OPEN" 2>/dev/null || true
245
+ fi
246
+ fi
247
+
248
+ if [ -f "$ROUND_CURRENT" ]; then
249
+ cksum < "$ROUND_CURRENT" > "$DEVLOG_DIR/.turn-start" 2>/dev/null || true
250
+ else
251
+ echo "MISSING" > "$DEVLOG_DIR/.turn-start" 2>/dev/null || true
252
+ fi
253
+
254
+ SPAN_WILL_PASS_THROUGH=0
255
+ if [ -f "$SPAN_FILE" ]; then
256
+ SPAN_TICKS="$(json_int_get "$SPAN_FILE" ticks_since_checkin)"
257
+ SPAN_MAX="$(json_int_get "$SPAN_FILE" max_silent_ticks)"
258
+ case "$SPAN_TICKS" in ''|*[!0-9]*) SPAN_TICKS='' ;; esac
259
+ case "$SPAN_MAX" in ''|*[!0-9]*) SPAN_MAX='' ;; esac
260
+ if [ -n "$SPAN_TICKS" ]; then
261
+ NEW_TICKS=$((SPAN_TICKS + 1))
262
+ json_int_set "$SPAN_FILE" ticks_since_checkin "$NEW_TICKS"
263
+ if [ -n "$SPAN_MAX" ] && [ "$NEW_TICKS" -lt "$SPAN_MAX" ]; then
264
+ SPAN_WILL_PASS_THROUGH=1
265
+ fi
266
+ fi
267
+ fi
268
+
269
+ if [ "$SPAN_WILL_PASS_THROUGH" -eq 0 ] && [ -z "$FOLD_ROUND" ] && [ -f "$CHECKPOINT_FILE" ]; then
270
+ CP_ROUNDS="$(json_int_get "$CHECKPOINT_FILE" rounds_since_checkpoint)"
271
+ case "$CP_ROUNDS" in
272
+ ''|*[!0-9]*) : ;;
273
+ *)
274
+ NEW_CP_ROUNDS=$((CP_ROUNDS + 1))
275
+ json_int_set "$CHECKPOINT_FILE" rounds_since_checkpoint "$NEW_CP_ROUNDS"
276
+ ;;
277
+ esac
278
+ fi
279
+
280
+ if [ -f "$SEGMENT_FILE" ]; then
281
+ SEG_EPOCH="$(json_int_get "$SEGMENT_FILE" last_change_epoch)"
282
+ SEG_MAX="$(json_int_get "$SEGMENT_FILE" max_silent_seconds)"
283
+ SEG_SUM_KEY="$(grep -o '"last_seen_cksum"[[:space:]]*:' "$SEGMENT_FILE" 2>/dev/null || echo '')"
284
+ case "$SEG_EPOCH" in ''|*[!0-9]*) SEG_EPOCH='' ;; esac
285
+ case "$SEG_MAX" in ''|*[!0-9]*) SEG_MAX='' ;; esac
286
+ if [ -n "$SEG_EPOCH" ] && [ -n "$SEG_MAX" ] && [ -n "$SEG_SUM_KEY" ]; then
287
+ SEG_NOW="$(date +%s 2>/dev/null || echo '')"
288
+ case "$SEG_NOW" in
289
+ ''|*[!0-9]*) : ;;
290
+ *)
291
+ if [ -f "$ROUND_CURRENT" ]; then
292
+ SEG_CUR="$(cksum < "$ROUND_CURRENT" 2>/dev/null | tr -d '\n' || echo '')"
293
+ else
294
+ SEG_CUR="MISSING"
295
+ fi
296
+ if [ -n "$SEG_CUR" ]; then
297
+ json_int_set "$SEGMENT_FILE" last_change_epoch "$SEG_NOW"
298
+ json_str_set "$SEGMENT_FILE" last_seen_cksum "$SEG_CUR"
299
+ SEG_MT=""; SEG_SZ=""
300
+ if [ -f "$ROUND_CURRENT" ]; then
301
+ if SEG_ID="$(stat -f '%m %z' "$ROUND_CURRENT" 2>/dev/null || stat -c '%Y %s' "$ROUND_CURRENT" 2>/dev/null || true)"; then
302
+ SEG_MT="${SEG_ID%% *}"
303
+ SEG_SZ="${SEG_ID#* }"
304
+ fi
305
+ fi
306
+ if [ -n "$SEG_MT" ] && [ -n "$SEG_SZ" ]; then
307
+ if ! grep -q '"last_seen_mtime"' "$SEGMENT_FILE" 2>/dev/null; then
308
+ SEG_SID="$(json_str_get "$SEGMENT_FILE" session_id 2>/dev/null || true)"
309
+ if [ -n "$SESSION_ID" ]; then SEG_SID="$SESSION_ID"; fi
310
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "%s", "last_seen_mtime": "%s", "last_seen_size": "%s", "max_silent_seconds": %s, "session_id": "%s"}\n' \
311
+ "$SEG_NOW" "$SEG_CUR" "$SEG_MT" "$SEG_SZ" "$SEG_MAX" "${SEG_SID}" > "$SEGMENT_FILE" 2>/dev/null || true
312
+ else
313
+ json_str_set "$SEGMENT_FILE" last_seen_mtime "$SEG_MT"
314
+ json_str_set "$SEGMENT_FILE" last_seen_size "$SEG_SZ"
315
+ if [ -n "$SESSION_ID" ]; then
316
+ json_str_set "$SEGMENT_FILE" session_id "$SESSION_ID"
317
+ fi
318
+ fi
319
+ elif [ -n "$SESSION_ID" ]; then
320
+ json_str_set "$SEGMENT_FILE" session_id "$SESSION_ID"
321
+ SEG_SESSION="$(json_str_get "$SEGMENT_FILE" session_id)"
322
+ if [ "$SEG_SESSION" != "$SESSION_ID" ]; then
323
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "%s", "max_silent_seconds": %s, "session_id": "%s"}\n' \
324
+ "$SEG_NOW" "$SEG_CUR" "$SEG_MAX" "$SESSION_ID" > "$SEGMENT_FILE" 2>/dev/null || true
325
+ fi
326
+ fi
327
+ fi
328
+ ;;
329
+ esac
330
+ fi
331
+ fi
332
+
333
+ exit 0
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # Run every hook self-check. Usage: bash hooks/scripts/run-tests.sh
3
+ set -uo pipefail
4
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ FAIL=0
6
+ shopt -s nullglob
7
+ for t in "$DIR"/tests/test-*.sh; do
8
+ echo "=== $(basename "$t") ==="
9
+ if ! bash "$t"; then
10
+ FAIL=1
11
+ fi
12
+ done
13
+ echo "=== test-adapters.sh ==="
14
+ if ! bash "$DIR/../../cursor/hooks/test-adapters.sh"; then
15
+ FAIL=1
16
+ fi
17
+ echo "=== codex/test-adapters.sh ==="
18
+ if ! bash "$DIR/../../codex/hooks/test-adapters.sh"; then
19
+ FAIL=1
20
+ fi
21
+ if [ "$FAIL" -ne 0 ]; then
22
+ echo "Some hook self-checks FAILED."
23
+ exit 1
24
+ fi
25
+ echo "All hook self-checks passed."
26
+ exit 0
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # User-invoked: /devlog-tracker:segment-watch filesystem side.
3
+ # Sets Segment Watch's max_silent_seconds threshold. $1 is required: a
4
+ # positive integer number of seconds.
5
+ set -uo pipefail
6
+ _src="${BASH_SOURCE[0]}"
7
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
8
+ # shellcheck source=json-field.sh
9
+ . "$SCRIPT_DIR/json-field.sh"
10
+
11
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
12
+ DEVLOG_DIR="$PROJECT_DIR/.devlog"
13
+
14
+ if [ ! -f "$DEVLOG_DIR/.enabled" ]; then
15
+ echo "NOT_STARTED"
16
+ exit 0
17
+ fi
18
+
19
+ SECONDS_ARG="${1:-}"
20
+ case "$SECONDS_ARG" in
21
+ ''|*[!0-9]*|0)
22
+ echo "max_silent_seconds must be a positive integer number of seconds, got: $SECONDS_ARG" >&2
23
+ exit 1
24
+ ;;
25
+ esac
26
+
27
+ if [ -f "$DEVLOG_DIR/.segment-state" ]; then
28
+ json_int_set "$DEVLOG_DIR/.segment-state" max_silent_seconds "$SECONDS_ARG"
29
+ APPLIED="$(json_int_get "$DEVLOG_DIR/.segment-state" max_silent_seconds)"
30
+ if [ "$APPLIED" != "$SECONDS_ARG" ]; then
31
+ # Existing file didn't have a matching max_silent_seconds key to patch
32
+ # (malformed / hand-edited) -- rebuild it fresh rather than leave the
33
+ # requested value silently unapplied.
34
+ LAST_EPOCH="$(json_int_get "$DEVLOG_DIR/.segment-state" last_change_epoch)"
35
+ LAST_SUM="$(json_str_get "$DEVLOG_DIR/.segment-state" last_seen_cksum)"
36
+ SESSION="$(json_str_get "$DEVLOG_DIR/.segment-state" session_id)"
37
+ case "$LAST_EPOCH" in ''|*[!0-9]*) LAST_EPOCH=0 ;; esac
38
+ printf '%s\n' "{\"last_change_epoch\": ${LAST_EPOCH}, \"last_seen_cksum\": \"${LAST_SUM}\", \"max_silent_seconds\": ${SECONDS_ARG}, \"session_id\": \"${SESSION}\"}" \
39
+ > "$DEVLOG_DIR/.segment-state" || exit 1
40
+ fi
41
+ else
42
+ printf '%s\n' "{\"last_change_epoch\": 0, \"last_seen_cksum\": \"\", \"max_silent_seconds\": ${SECONDS_ARG}, \"session_id\": \"\"}" \
43
+ > "$DEVLOG_DIR/.segment-state" || exit 1
44
+ fi
45
+
46
+ echo "SEGMENT_MAX_SILENT_SECONDS=$(json_int_get "$DEVLOG_DIR/.segment-state" max_silent_seconds)"
47
+ exit 0
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook:同一輪若太久沒改 .round-current.md,擋住下一個工具,逼補 ### 段落。
3
+ # fail-open:這支腳本自己出錯一律 exit 0,不該卡死使用者的 session。
4
+
5
+ set -uo pipefail
6
+
7
+ _src="${BASH_SOURCE[0]}"
8
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
9
+ # shellcheck source=json-field.sh
10
+ . "$SCRIPT_DIR/json-field.sh"
11
+ # shellcheck source=devlog-md.sh
12
+ . "$SCRIPT_DIR/devlog-md.sh"
13
+ # shellcheck source=devlog-path.sh
14
+ . "$SCRIPT_DIR/devlog-path.sh"
15
+
16
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
17
+ [ -f "$PROJECT_DIR/.devlog/.enabled" ] || exit 0
18
+ devlog_resolve_paths "$PROJECT_DIR"
19
+ ROUND_CURRENT="$DEVLOG_DIR/.round-current.md"
20
+ SEGMENT_FILE="$DEVLOG_DIR/.segment-state"
21
+
22
+ INPUT="$(cat 2>/dev/null || true)"
23
+ [ -n "$INPUT" ] || exit 0
24
+
25
+ TOOL_NAME="$(json_str_field "$INPUT" tool_name)"
26
+ FILE_PATH=""
27
+ COMMAND=""
28
+ if command -v jq >/dev/null 2>&1; then
29
+ FILE_PATH="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null || echo '')"
30
+ [ "$FILE_PATH" = "null" ] && FILE_PATH=""
31
+ COMMAND="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || echo '')"
32
+ [ "$COMMAND" = "null" ] && COMMAND=""
33
+ else
34
+ FILE_PATH="$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo '')"
35
+ if [ -z "$FILE_PATH" ]; then
36
+ FILE_PATH="$(printf '%s' "$INPUT" | grep -o '"path"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo '')"
37
+ fi
38
+ COMMAND="$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo '')"
39
+ fi
40
+
41
+ # Blocked-and-stuck recovery: Bash itself is denied while blocked, so there
42
+ # was no way to run `git status`/`git diff` to see what actually changed —
43
+ # exactly the thing both valves below are asking Claude to reconcile against.
44
+ # Allow a small allowlist of git subcommands that are non-mutating in every
45
+ # documented invocation form (no flag combination writes). Reject anything
46
+ # with shell metacharacters first — chaining/redirection/substitution could
47
+ # otherwise smuggle a second, unrelated command past the git-prefix match.
48
+ is_safe_readonly_git_command() {
49
+ local cmd
50
+ cmd="$(printf '%s' "$1" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
51
+ [ -n "$cmd" ] || return 1
52
+ case "$cmd" in
53
+ *';'*|*'&'*|*'|'*|*'`'*|*'$('*|*'>'*|*'<'*|*$'\n'*) return 1 ;;
54
+ esac
55
+ case "$cmd" in
56
+ git\ status|git\ status\ *) return 0 ;;
57
+ git\ diff|git\ diff\ *) return 0 ;;
58
+ git\ log|git\ log\ *) return 0 ;;
59
+ git\ show|git\ show\ *) return 0 ;;
60
+ git\ rev-parse|git\ rev-parse\ *) return 0 ;;
61
+ esac
62
+ return 1
63
+ }
64
+
65
+ is_devlog_tool_allowed() {
66
+ case "$1" in
67
+ Write|Edit|StrReplace|Read|Grep)
68
+ case "$2" in
69
+ .devlog/.round-current.md|*/.devlog/.round-current.md) return 0 ;;
70
+ esac
71
+ ;;
72
+ Bash)
73
+ is_safe_readonly_git_command "$3" && return 0
74
+ ;;
75
+ esac
76
+ return 1
77
+ }
78
+
79
+ AGENT_ID="$(json_str_field "$INPUT" agent_id)"
80
+ [ "$AGENT_ID" = "null" ] && AGENT_ID=""
81
+ if [ -n "$AGENT_ID" ]; then
82
+ exit 0
83
+ fi
84
+
85
+ MISMATCH_FILE="$DEVLOG_DIR/.workspace-mismatch"
86
+ if [ -f "$MISMATCH_FILE" ]; then
87
+ LIVE_MARK="$(cat "$MISMATCH_FILE" 2>/dev/null || true)"
88
+ if [ -z "$LIVE_MARK" ]; then
89
+ rm -f "$MISMATCH_FILE" 2>/dev/null || true
90
+ else
91
+ SEGMENTS=""
92
+ if [ -f "$ROUND_CURRENT" ]; then
93
+ LAST_START="$(devlog_list_round_starts "$ROUND_CURRENT" | awk 'END { print $1 }')"
94
+ if [ -n "$LAST_START" ]; then
95
+ LAST_END="$(devlog_block_end "$ROUND_CURRENT" "$LAST_START")"
96
+ SEGMENTS="$(devlog_round_segments_body "$ROUND_CURRENT" "$LAST_START" "$LAST_END")"
97
+ fi
98
+ fi
99
+ segments_flat="$(printf '%s' "$SEGMENTS" | tr '\n' '\036')"
100
+ mark_flat="$(printf '%s' "$LIVE_MARK" | tr '\n' '\036')"
101
+ # "$mark_flat" is quoted so it is literal; dirty paths with *?[ do not glob.
102
+ case "$segments_flat" in
103
+ *"$mark_flat"*) rm -f "$MISMATCH_FILE" 2>/dev/null || true ;;
104
+ *)
105
+ if ! is_devlog_tool_allowed "$TOOL_NAME" "$FILE_PATH" "$COMMAND"; then
106
+ echo "上一輪「#### 工作區」跟目前 git 不符。請先 Read .devlog/.round-current.md,再用 Edit/StrReplace 在這一輪追加 ### 段落,把下面「實際」逐字貼進段落(宣稱 vs 實際)。寫完再呼叫其他工具。不要照上一輪 Handoff「現況/下一步」的字面行動。(唯讀的 git status/diff/log/show/rev-parse 仍可執行,方便自行核對。)" >&2
107
+ echo "" >&2
108
+ echo "實際:" >&2
109
+ printf '%s\n' "$LIVE_MARK" >&2
110
+ exit 2
111
+ fi
112
+ ;;
113
+ esac
114
+ fi
115
+ fi
116
+
117
+ [ -f "$SEGMENT_FILE" ] || exit 0
118
+
119
+ STORED="$(json_str_get "$SEGMENT_FILE" session_id 2>/dev/null || true)"
120
+ INCOMING="$(json_str_field "$INPUT" session_id)"
121
+ if [ -n "$STORED" ] && [ -n "$INCOMING" ] && [ "$STORED" != "$INCOMING" ]; then
122
+ exit 0
123
+ fi
124
+
125
+ SEG_EPOCH="$(json_int_get "$SEGMENT_FILE" last_change_epoch)"
126
+ SEG_MAX="$(json_int_get "$SEGMENT_FILE" max_silent_seconds)"
127
+ SEG_SUM="$(json_str_get "$SEGMENT_FILE" last_seen_cksum)"
128
+ SEG_SUM_KEY="$(grep -o '"last_seen_cksum"[[:space:]]*:' "$SEGMENT_FILE" 2>/dev/null || echo '')"
129
+
130
+ case "$SEG_EPOCH" in ''|*[!0-9]*) exit 0 ;; esac
131
+ case "$SEG_MAX" in ''|*[!0-9]*) exit 0 ;; esac
132
+ [ -n "$SEG_SUM_KEY" ] || exit 0
133
+
134
+ persist_seen() {
135
+ local epoch="$1" sum="$2" mt="$3" sz="$4"
136
+ json_int_set "$SEGMENT_FILE" last_change_epoch "$epoch"
137
+ json_str_set "$SEGMENT_FILE" last_seen_cksum "$sum"
138
+ [ -n "$mt" ] && json_str_set "$SEGMENT_FILE" last_seen_mtime "$mt"
139
+ [ -n "$sz" ] && json_str_set "$SEGMENT_FILE" last_seen_size "$sz"
140
+ }
141
+
142
+ devlog_file_identity() {
143
+ # prints: "<mtime_epoch> <size_bytes>" or empty on failure
144
+ local f="$1" mt sz
145
+ if mt="$(stat -f '%m' "$f" 2>/dev/null)" && sz="$(stat -f '%z' "$f" 2>/dev/null)"; then
146
+ printf '%s %s\n' "$mt" "$sz"
147
+ return 0
148
+ fi
149
+ if mt="$(stat -c '%Y' "$f" 2>/dev/null)" && sz="$(stat -c '%s' "$f" 2>/dev/null)"; then
150
+ printf '%s %s\n' "$mt" "$sz"
151
+ return 0
152
+ fi
153
+ return 1
154
+ }
155
+
156
+ NOW="$(date +%s 2>/dev/null || echo '')"
157
+ case "$NOW" in ''|*[!0-9]*) exit 0 ;; esac
158
+
159
+ ID_MT=""; ID_SZ=""
160
+ if [ -f "$ROUND_CURRENT" ]; then
161
+ ID="$(devlog_file_identity "$ROUND_CURRENT" || true)"
162
+ ID_MT="${ID%% *}"
163
+ ID_SZ="${ID#* }"
164
+ STORED_MT="$(json_str_get "$SEGMENT_FILE" last_seen_mtime 2>/dev/null || true)"
165
+ STORED_SZ="$(json_str_get "$SEGMENT_FILE" last_seen_size 2>/dev/null || true)"
166
+ if [ -n "$ID_MT" ] && [ -n "$ID_SZ" ] && [ "$ID_MT" = "$STORED_MT" ] && [ "$ID_SZ" = "$STORED_SZ" ] && [ -n "$SEG_SUM" ]; then
167
+ CURRENT="$SEG_SUM"
168
+ else
169
+ CURRENT="$(cksum < "$ROUND_CURRENT" 2>/dev/null | tr -d '\n' || echo '')"
170
+ ID="$(devlog_file_identity "$ROUND_CURRENT" || true)"
171
+ ID_MT="${ID%% *}"
172
+ ID_SZ="${ID#* }"
173
+ fi
174
+ else
175
+ CURRENT="MISSING"
176
+ ID_MT=""; ID_SZ=""
177
+ fi
178
+ [ -n "$CURRENT" ] || exit 0
179
+
180
+ if [ "$CURRENT" != "$SEG_SUM" ]; then
181
+ persist_seen "$NOW" "$CURRENT" "$ID_MT" "$ID_SZ"
182
+ exit 0
183
+ fi
184
+
185
+ [ -n "$TOOL_NAME" ] || exit 0
186
+
187
+ if is_devlog_tool_allowed "$TOOL_NAME" "$FILE_PATH" "$COMMAND"; then
188
+ exit 0
189
+ fi
190
+
191
+ ELAPSED=$((NOW - SEG_EPOCH))
192
+ if [ "$ELAPSED" -ge "$SEG_MAX" ]; then
193
+ echo "這一輪已經 ${ELAPSED} 秒沒有更新 .devlog/.round-current.md(門檻 ${SEG_MAX} 秒)。請先 Read .devlog/.round-current.md,再用 Edit 或 StrReplace **追加**一段「### 段落」(一行也可以);禁止用 Write 覆寫整份檔。寫完再繼續呼叫其他工具。(唯讀的 git status/diff/log/show/rev-parse 仍可執行。)" >&2
194
+ exit 2
195
+ fi
196
+
197
+ exit 0