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,629 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook:Claude 想結束這一輪回應時執行。
3
+ # 檢查 devlog.md 的內容雜湊有沒有在這一輪開始之後變過,沒有就擋下來(exit 2),
4
+ # 逼 Claude 先依 SKILL.md 格式補寫這一輪的 Round 區塊,才能真正結束。
5
+ # 這是保證每輪都記錄的關鍵:不依賴 Claude 自行判斷「值不值得記錄」。
6
+ #
7
+ # 用內容雜湊(cksum)取代舊版的 mtime 比對:mtime 只有整秒精度,快速連續的
8
+ # 對話很容易讓「上一輪的寫入」跟「這一輪的開始」落在同一秒,導致誤判成
9
+ # 「已經寫過」而放行。雜湊直接比對內容有沒有變,不受時間精度影響,也不需要
10
+ # 再處理 GNU/BSD stat 的跨平台差異。
11
+ #
12
+ # 兩個穩健性設計,參考 agfnow/agentflow 的 stop-hook.js:
13
+ # 1. loop guard:讀 stdin 的 stop_hook_active 欄位,這是 Claude Code 官方標準欄位,
14
+ # 代表「這輪已經被本支 hook 擋下來、Claude 正在重跑」,此時直接放行,避免無窮迴圈
15
+ # (Claude Code 本身也有連續擋 8 次的上限保護,這裡是多一層保險,且能更快恢復)。
16
+ # 2. fail-open:不用 set -e,每一步可能失敗的地方都明確接住、失敗就直接放行(exit 0),
17
+ # 絕不讓這支腳本自己的錯誤意外卡死使用者的 session——這支腳本的職責是「檢查」,
18
+ # 不該因為自己壞掉就變成「阻擋」。
19
+
20
+ set -uo pipefail
21
+
22
+ _src="${BASH_SOURCE[0]}"
23
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
24
+ # shellcheck source=json-field.sh
25
+ . "$SCRIPT_DIR/json-field.sh"
26
+ # shellcheck source=devlog-lock.sh
27
+ . "$SCRIPT_DIR/devlog-lock.sh"
28
+ # shellcheck source=workspace-snapshot.sh
29
+ . "$SCRIPT_DIR/workspace-snapshot.sh"
30
+ # shellcheck source=files-snapshot.sh
31
+ . "$SCRIPT_DIR/files-snapshot.sh"
32
+ # shellcheck source=devlog-md.sh
33
+ . "$SCRIPT_DIR/devlog-md.sh"
34
+
35
+ # --- loop guard -------------------------------------------------------
36
+ # 有 jq 就用 jq 精準解析;沒有 jq 就退化成字串比對(沒有更嚴謹的 parse,但
37
+ # 足以涵蓋 Claude Code 實際送出的 stop_hook_active 欄位形狀),兩種環境都要生效。
38
+ INPUT="$(cat 2>/dev/null || true)"
39
+
40
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
41
+ # 沒下過 /devlog-tracker:start,代表這個專案沒啟動強制記錄,直接放行。
42
+ # 這是唯一的判斷依據——不猜這輪是否呼叫了某個 skill,也不解析 transcript。
43
+ # 這個 gate 放在路徑解析之前,沒啟用時就不必付 git rev-parse 的成本;
44
+ # DEVLOG_DIR 永遠是 $PROJECT_DIR/.devlog,與分支無關,所以兩者等價。
45
+ ENABLED_FLAG="$PROJECT_DIR/.devlog/.enabled"
46
+ if [ ! -f "$ENABLED_FLAG" ]; then
47
+ # 沒啟用就不做任何強制,但仍要清掉殘留的 .interrupted,否則之後重新
48
+ # /devlog-tracker:start 會繼承一個陳舊的中斷旗標。.interrupted 跟
49
+ # DEVLOG_DIR 一樣與分支無關,所以這裡不必解析分支。
50
+ if [ -f "$PROJECT_DIR/.devlog/.interrupted" ]; then
51
+ rm -f "$PROJECT_DIR/.devlog/.interrupted" 2>/dev/null || true
52
+ fi
53
+ exit 0
54
+ fi
55
+ # shellcheck source=devlog-path.sh
56
+ . "$SCRIPT_DIR/devlog-path.sh"
57
+ devlog_resolve_paths "$PROJECT_DIR"
58
+ ROUND_CURRENT="$DEVLOG_DIR/.round-current.md"
59
+ if [ -f "$DEVLOG_DIR/.interrupted" ]; then
60
+ bash "$SCRIPT_DIR/close-open-round.sh" "user_interrupt" || true
61
+ rm -f "$DEVLOG_DIR/.interrupted" 2>/dev/null || true
62
+ # close-open-round.sh merges and removes .round-current.md whenever it
63
+ # decided the round was finished (stamped INTERRUPTED, or recovered —
64
+ # Claude had already written Summary/Handoff before the interrupt signal
65
+ # arrived). Either way there is nothing left in .round-current.md for
66
+ # this Stop invocation to validate. If .round-current.md still has
67
+ # content, .round-open was stale/absent and close-open-round.sh was a
68
+ # no-op — fall through to the normal flow below, which validates
69
+ # whatever the user's actual in-progress turn wrote.
70
+ if [ ! -s "$ROUND_CURRENT" ]; then
71
+ exit 0
72
+ fi
73
+ fi
74
+
75
+ if command -v jq >/dev/null 2>&1; then
76
+ STOP_HOOK_ACTIVE="$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false)"
77
+ else
78
+ case "$INPUT" in
79
+ *'"stop_hook_active":true'*|*'"stop_hook_active": true'*) STOP_HOOK_ACTIVE=true ;;
80
+ *) STOP_HOOK_ACTIVE=false ;;
81
+ esac
82
+ fi
83
+ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
84
+ exit 0
85
+ fi
86
+
87
+ # --- 開關檢查 -----------------------------------------------------------
88
+ TURN_MARKER="$DEVLOG_DIR/.turn-start"
89
+
90
+ devlog_lock_acquire
91
+ trap 'devlog_lock_release' EXIT
92
+
93
+ # --- span 檢查(Span Mode:橫跨多次自動續接的長任務)---------------------
94
+ # Claude 主動宣告的 .devlog/.span-open 存在時(見 SKILL.md),這個 tick 不
95
+ # 強制要求 devlog.md 有變動,只要求 ticks_since_checkin(由 round-start.sh
96
+ # 每個 tick 遞增)沒有累積超過 max_silent_ticks。超過門檻就退回下面正常的
97
+ # 雜湊比對,逼這輪真的寫點東西;寫成功後把計數器歸零。span 檔案壞掉、缺欄位
98
+ # 或不是數字,一律當作沒有 span,直接往下走正常流程——fail-open。
99
+ SPAN_FILE="$DEVLOG_DIR/.span-open"
100
+ SPAN_VALID=0
101
+ if [ -f "$SPAN_FILE" ]; then
102
+ SPAN_TICKS="$(json_int_get "$SPAN_FILE" ticks_since_checkin)"
103
+ SPAN_MAX="$(json_int_get "$SPAN_FILE" max_silent_ticks)"
104
+ case "$SPAN_TICKS" in ''|*[!0-9]*) SPAN_TICKS='' ;; esac
105
+ case "$SPAN_MAX" in ''|*[!0-9]*) SPAN_MAX='' ;; esac
106
+ if [ -n "$SPAN_TICKS" ] && [ -n "$SPAN_MAX" ]; then
107
+ SPAN_VALID=1
108
+ fi
109
+ fi
110
+
111
+ if [ "$SPAN_VALID" -eq 1 ] && [ "$SPAN_TICKS" -lt "$SPAN_MAX" ]; then
112
+ exit 0
113
+ fi
114
+
115
+ # 還沒有 turn marker,代表 UserPromptSubmit hook 這次沒跑到(例如剛裝上、
116
+ # 或是某種特殊情況),直接放行避免卡住——fail-open。
117
+ [ -f "$TURN_MARKER" ] || exit 0
118
+
119
+ TURN_START_HASH="$(cat "$TURN_MARKER" 2>/dev/null || echo '')"
120
+ # marker 內容讀不出來(讀取失敗、被意外改壞等)就當作沒有可靠依據,放行。
121
+ [ -n "$TURN_START_HASH" ] || exit 0
122
+
123
+ if [ -f "$ROUND_CURRENT" ]; then
124
+ CURRENT_HASH="$(cksum < "$ROUND_CURRENT" 2>/dev/null || echo '')"
125
+ else
126
+ CURRENT_HASH="MISSING"
127
+ fi
128
+ # 一樣的防呆:雜湊算不出來就放行,不要因為偵測異常反而卡住使用者。
129
+ [ -n "$CURRENT_HASH" ] || exit 0
130
+
131
+ if [ "$CURRENT_HASH" = "$TURN_START_HASH" ]; then
132
+ if [ "$SPAN_VALID" -eq 1 ]; then
133
+ echo "這一輪尚未寫入。請依 skills/devlog-tracker/SKILL.md 在 .devlog/.round-current.md 建立一個新的 ## Round(編號接在 devlog.md 目前最後一輪之後),包含 User Input / Summary / Reply / Handoff / Status;收尾成功後 hook 會自動併回 devlog.md,不要自己直接寫進 devlog.md。" >&2
134
+ else
135
+ echo "這一輪的 Round 只有 hook 寫的 User Input skeleton,還沒有收尾。請依 skills/devlog-tracker/SKILL.md 編輯最後一個 Round,補上 User Input / Summary / Reply / Handoff / Status。不要再新增一個 ## Round。" >&2
136
+ fi
137
+ exit 2
138
+ fi
139
+
140
+ # --- 標題檢查(Summary + Reply + Handoff)---------------------------------
141
+ # 雜湊已經證明這輪有寫入。.round-current.md 理論上恰好裝著這一輪(且只有
142
+ # 這一輪),但不能整份 cat 進來當作要驗證的內容:Claude 收尾這一輪時可能
143
+ # 已經在同一個檔案尾端接著寫了一段「## Checkpoint」(見下面 Step 9 的
144
+ # merge,會把整個檔案一起併進 devlog.md),而內容裡如果完全沒有
145
+ # `## Round ` 這一行(例如只是一段雜訊文字),代表根本沒有 Round 可驗——
146
+ # 兩種情況都必須比照舊版 last_round_block() 的
147
+ # 邊界規則來擷取:找第一行 `## Round `(fence 之外),一路擷取到下一個
148
+ # 不在 fence 裡的 `## ` 為止(或檔尾),把後面接的 Checkpoint 等區段排除
149
+ # 在外。完全找不到 `## Round ` 這一行:fail-open(LAST_ROUND 留空,不擋),
150
+ # 對應舊版 `if (start == 0) exit 0` 的行為。
151
+ LAST_ROUND="$(awk '
152
+ /^[ \t]*```/ { fence = !fence }
153
+ !fence && /^## Round / { start = NR }
154
+ { lines[NR] = $0; infence[NR] = fence }
155
+ END {
156
+ if (start == 0) exit 0
157
+ end = NR
158
+ for (i = start + 1; i <= NR; i++) {
159
+ if (!infence[i] && lines[i] ~ /^## /) { end = i - 1; break }
160
+ }
161
+ for (i = start; i <= end; i++) print lines[i]
162
+ }
163
+ ' "$ROUND_CURRENT" 2>/dev/null || true)"
164
+ if [ -n "$LAST_ROUND" ]; then
165
+ HAS_SUMMARY=0
166
+ HAS_REPLY=0
167
+ HAS_HANDOFF=0
168
+ printf '%s\n' "$LAST_ROUND" | grep -q '^### Summary' && HAS_SUMMARY=1
169
+ printf '%s\n' "$LAST_ROUND" | grep -q '^### Reply' && HAS_REPLY=1
170
+ printf '%s\n' "$LAST_ROUND" | grep -q '^### Handoff' && HAS_HANDOFF=1
171
+ if [ "$HAS_SUMMARY" -eq 0 ] || [ "$HAS_REPLY" -eq 0 ] || [ "$HAS_HANDOFF" -eq 0 ]; then
172
+ echo "最後一個 Round 缺少 \`### Summary\`、\`### Reply\` 或 \`### Handoff\`。請依 skills/devlog-tracker/SKILL.md 補上這三個標題(Summary 給人掃、Reply 記對使用者說過的話、Handoff 給下一輪接續),寫在同一個 Round 裡,不要再新增一個 ## Round。" >&2
173
+ exit 2
174
+ fi
175
+
176
+ # Fence-aware: a heading-looking line inside a ``` fence (e.g. a markdown
177
+ # example quoting #### 決策 / #### 現況) must not be mistaken for a real
178
+ # heading, but its fenced content is still part of the body once grab has
179
+ # started.
180
+ #
181
+ # NOFENCE 防呆:如果這個 Round 裡 ``` 記號的數量是奇數(代表圍欄沒有正常
182
+ # 收尾——真的寫錯了,不是刻意的範例),fence 變數會在這輪剩下的內容裡卡在
183
+ # 1,導致下面三段 fence-aware awk 把明明存在的內容誤判成「被吃掉、看起來
184
+ # 是空的」而擋下使用者(exit 2、訊息卻說「是空的」)。這違反本專案的
185
+ # fail-open 原則,也重現了 Task 1 想解決的那種卡死。NOFENCE=1 時強制
186
+ # fence 變數維持 0,讓這三段退化回 Task 1 之前的單純掃描行為——退化後
187
+ # 「卡住的圍欄」不可能吃掉內容,是 fail-open 安全的;圍欄成雙成對(含 0
188
+ # 個)時,NOFENCE=0,Task 1 加入的 fence-aware 行為完全不變。
189
+ FENCE_MARKER_COUNT="$(printf '%s\n' "$LAST_ROUND" | grep -c '^[ \t]*```')"
190
+ NOFENCE=0
191
+ [ $((FENCE_MARKER_COUNT % 2)) -eq 0 ] || NOFENCE=1
192
+
193
+ section_body() {
194
+ local heading="$1"
195
+ printf '%s\n' "$LAST_ROUND" | awk -v h="$heading" -v nofence="$NOFENCE" '
196
+ /^[ \t]*```/ { if (!nofence) fence = !fence; if (grab) print; next }
197
+ !fence && $0 ~ h { grab=1; next }
198
+ grab && !fence && /^### / { exit }
199
+ grab && !fence && /^## / { exit }
200
+ grab { print }
201
+ '
202
+ }
203
+
204
+ handoff_subsection_body() {
205
+ local heading="$1"
206
+ printf '%s\n' "$LAST_ROUND" | awk -v h="$heading" -v nofence="$NOFENCE" '
207
+ /^[ \t]*```/ { if (!nofence) fence = !fence; if (grab) print; next }
208
+ !fence && $0 ~ h { grab=1; next }
209
+ grab && !fence && /^#### / { exit }
210
+ grab && !fence && /^### / { exit }
211
+ grab && !fence && /^## / { exit }
212
+ grab { print }
213
+ '
214
+ }
215
+
216
+ nonempty_body() {
217
+ section_body "$1" | grep -q '[^[:space:]]'
218
+ }
219
+
220
+ SUM_BODY_OK=0
221
+ REPLY_BODY_OK=0
222
+ HAN_BODY_OK=0
223
+ nonempty_body '^### Summary' && SUM_BODY_OK=1
224
+ nonempty_body '^### Reply' && REPLY_BODY_OK=1
225
+ nonempty_body '^### Handoff' && HAN_BODY_OK=1
226
+ if [ "$SUM_BODY_OK" -eq 0 ] || [ "$REPLY_BODY_OK" -eq 0 ] || [ "$HAN_BODY_OK" -eq 0 ]; then
227
+ echo "最後一個 Round 的 ### Summary、### Reply 或 ### Handoff 是空的。請依 skills/devlog-tracker/SKILL.md 寫上內容(不要只留標題),寫在同一個 Round 裡,不要再新增一個 ## Round。" >&2
228
+ exit 2
229
+ fi
230
+
231
+ # --- Handoff subsection order check (docs/design/devlog-as-ssot-assessment.md,
232
+ # Phase 2 + L1 完成條件). 決策 → 檔案 → 工作區 → 現況 → 完成條件 → 下一步
233
+ # is a fixed order. Detect a present-but-reordered or duplicated recognized
234
+ # subsection. Unrecognized #### headings are ignored.
235
+ HANDOFF_BODY="$(section_body '^### Handoff')"
236
+ ORDER_ERR="$(printf '%s\n' "$HANDOFF_BODY" | awk -v nofence="$NOFENCE" '
237
+ BEGIN {
238
+ order["決策"] = 1; order["檔案"] = 2; order["工作區"] = 3
239
+ order["現況"] = 4; order["完成條件"] = 5; order["下一步"] = 6
240
+ last = 0; prev_name = ""
241
+ }
242
+ /^[ \t]*```/ { if (!nofence) fence = !fence; next }
243
+ fence { next }
244
+ /^#### / {
245
+ name = $0
246
+ sub(/^#### [ \t]*/, "", name)
247
+ sub(/[ \t]+$/, "", name)
248
+ if (!(name in order)) next
249
+ idx = order[name]
250
+ if (seen[name]) { print "duplicate:" name; exit }
251
+ seen[name] = 1
252
+ if (idx < last) { print "order:" prev_name ">" name; exit }
253
+ last = idx
254
+ prev_name = name
255
+ }
256
+ ')"
257
+ if [ -n "$ORDER_ERR" ]; then
258
+ case "$ORDER_ERR" in
259
+ duplicate:*)
260
+ DUP_NAME="${ORDER_ERR#duplicate:}"
261
+ echo "Handoff 的「#### ${DUP_NAME}」出現超過一次。請合併成一節。" >&2
262
+ ;;
263
+ order:*)
264
+ echo "Handoff 小節順序錯了(應該是 決策 → 檔案 → 工作區 → 現況 → 完成條件 → 下一步):${ORDER_ERR#order:}" >&2
265
+ ;;
266
+ esac
267
+ exit 2
268
+ fi
269
+
270
+ STATUS_VAL="$(printf '%s\n' "$LAST_ROUND" | awk '
271
+ /^### Status/ { grab=1; val=""; next }
272
+ grab && /^### / { grab=0 }
273
+ grab && /^## / { grab=0 }
274
+ grab && $0 ~ /[^[:space:]]/ && val == "" { val=$0 }
275
+ END { print val }
276
+ ')"
277
+ case "$STATUS_VAL" in
278
+ DONE|IN_PROGRESS|BLOCKED|INTERRUPTED) ;;
279
+ *)
280
+ echo "### Status 必須是 DONE、IN_PROGRESS、BLOCKED、INTERRUPTED 其中一個。" >&2
281
+ exit 2
282
+ ;;
283
+ esac
284
+
285
+ if [ "$STATUS_VAL" = "IN_PROGRESS" ] || [ "$STATUS_VAL" = "BLOCKED" ]; then
286
+ HAS_DONE_CRITERIA=0
287
+ printf '%s\n' "$LAST_ROUND" | grep -q '^#### 完成條件' && HAS_DONE_CRITERIA=1
288
+ DONE_CRITERIA_OK=0
289
+ if [ "$HAS_DONE_CRITERIA" -eq 1 ]; then
290
+ handoff_subsection_body '^#### 完成條件' | grep -q '[^[:space:]]' && DONE_CRITERIA_OK=1
291
+ fi
292
+ if [ "$DONE_CRITERIA_OK" -eq 0 ]; then
293
+ echo "Status 是 IN_PROGRESS 或 BLOCKED 時,Handoff 必須有「#### 完成條件」且後面有內容(可觀察的做完判準)。" >&2
294
+ exit 2
295
+ fi
296
+
297
+ HAS_NEXT=0
298
+ printf '%s\n' "$LAST_ROUND" | grep -q '^#### 下一步' && HAS_NEXT=1
299
+ NEXT_OK=0
300
+ if [ "$HAS_NEXT" -eq 1 ]; then
301
+ handoff_subsection_body '^#### 下一步' | grep -q '[^[:space:]]' && NEXT_OK=1
302
+ fi
303
+ if [ "$NEXT_OK" -eq 0 ]; then
304
+ echo "Status 是 IN_PROGRESS 或 BLOCKED 時,Handoff 必須有「#### 下一步」且後面有內容。" >&2
305
+ exit 2
306
+ fi
307
+
308
+ # --- 下一步 filler blacklist (docs/design/next-step-blacklist.md):
309
+ # non-semantic string match, not prose scoring. Only fires when the
310
+ # entire trimmed body is a single line that exactly equals one of a
311
+ # fixed set of known-empty phrases (a real 下一步 with extra content
312
+ # around one of these phrases always passes — see the design doc's
313
+ # Match rule). Deliberately scoped to 下一步 only, never Summary/
314
+ # 決策/現況.
315
+ NEXT_BODY_TRIMMED="$(handoff_subsection_body '^#### 下一步' \
316
+ | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
317
+ | grep -v '^$' || true)"
318
+ NEXT_LINE_COUNT="$(printf '%s\n' "$NEXT_BODY_TRIMMED" | grep -c '.' || true)"
319
+ if [ "$NEXT_LINE_COUNT" -eq 1 ]; then
320
+ NEXT_STRIPPED="$(printf '%s' "$NEXT_BODY_TRIMMED" | sed -e 's/[。.!!]*$//')"
321
+ case "$NEXT_STRIPPED" in
322
+ 繼續完成|持續完成|持續優化|持續改進|之後再看|視情況調整|待確認|繼續|持續推進|繼續處理)
323
+ echo "「#### 下一步」目前只寫了「${NEXT_STRIPPED}」,這是空話,不算具體下一步。請寫清楚下一輪打開就能做的具體動作(路徑/指令/要載入的 skill)。" >&2
324
+ exit 2
325
+ ;;
326
+ esac
327
+ fi
328
+
329
+ # --- IN_PROGRESS only: light actionable lint for 下一步 (L1).
330
+ # Pass if body shows a path (/), backtick command, file-ish token, or
331
+ # skill mention. False negatives OK; avoid scoring prose.
332
+ if [ "$STATUS_VAL" = "IN_PROGRESS" ]; then
333
+ if ! printf '%s\n' "$NEXT_BODY_TRIMMED" | grep -qE '/|`|\.[A-Za-z0-9]{1,10}([^A-Za-z0-9]|$)|[Ss][Kk][Ii][Ll][Ll]|hooks/|docs/|commands/|skills/'; then
334
+ echo "Status 是 IN_PROGRESS 時,「#### 下一步」須含可執行跡象(路徑、反引號指令、檔名或 skill)。請寫到下一輪打開就能做。" >&2
335
+ exit 2
336
+ fi
337
+ fi
338
+
339
+ # --- BLOCKED: 缺件句式 in 現況 or 下一步 (binary check for next agent).
340
+ if [ "$STATUS_VAL" = "BLOCKED" ]; then
341
+ BLOCKED_HINT="$( {
342
+ handoff_subsection_body '^#### 現況'
343
+ handoff_subsection_body '^#### 下一步'
344
+ } | tr '\n' ' ')"
345
+ if ! printf '%s\n' "$BLOCKED_HINT" | grep -qE '缺|等待|等使用者|需要.*提供|尚未|出現.*算|出現即'; then
346
+ echo "Status 是 BLOCKED 時,「#### 現況」或「#### 下一步」須寫清楚缺什麼、出現長怎樣(缺件句式),讓下一輪能判斷缺件是否已到。" >&2
347
+ exit 2
348
+ fi
349
+ fi
350
+ fi
351
+
352
+ # --- 工作區 machine-verify (docs/design/devlog-as-ssot-assessment.md,
353
+ # Phase 1 + DONE-with-檔案 extension): #### 工作區 must match a freshly
354
+ # computed git snapshot exactly. Turns it from an unverified claim into a
355
+ # write-time fact instead of something only continue/resume catch on the
356
+ # next turn.
357
+ #
358
+ # Required for IN_PROGRESS/BLOCKED (unchanged from Phase 1) and for DONE
359
+ # only when this round's Handoff has a non-empty #### 檔案 — i.e. it
360
+ # claims to have touched/committed files. Without this, "已 commit 完成,
361
+ # Status: DONE" was never checked against live git: the single most
362
+ # common false-completion claim, and one prose-quality checks elsewhere
363
+ # in this file explicitly leave unverified. A trivial DONE round with no
364
+ # #### 檔案 still omits 工作區 entirely per SKILL.md's 瑣碎輪 convention —
365
+ # unaffected.
366
+ #
367
+ # git unavailable -> fail-open, skip this check like every other one here.
368
+ NEEDS_WORKSPACE_CHECK=0
369
+ case "$STATUS_VAL" in
370
+ IN_PROGRESS|BLOCKED) NEEDS_WORKSPACE_CHECK=1 ;;
371
+ DONE)
372
+ handoff_subsection_body '^#### 檔案' | grep -q '[^[:space:]]' && NEEDS_WORKSPACE_CHECK=1
373
+ ;;
374
+ esac
375
+ if [ "$NEEDS_WORKSPACE_CHECK" -eq 1 ] && command -v git >/dev/null 2>&1; then
376
+ EXPECTED_WS="$(workspace_snapshot "$PROJECT_DIR" 2>/dev/null || true)"
377
+ if [ -n "$EXPECTED_WS" ]; then
378
+ ACTUAL_WS="$(handoff_subsection_body '^#### 工作區' | sed -e '/^[[:space:]]*$/d')"
379
+ if [ "$ACTUAL_WS" != "$EXPECTED_WS" ]; then
380
+ echo "#### 工作區 跟目前 git 狀態不符(或缺漏)。請把這一節內容換成以下逐字內容:" >&2
381
+ echo "" >&2
382
+ printf '%s\n' "$EXPECTED_WS" >&2
383
+ exit 2
384
+ fi
385
+ fi
386
+ fi
387
+
388
+ # --- 檔案 machine-verify (docs/design/files-verify.md, devlog ssot
389
+ # Phase 4): #### 檔案 must describe real git changes. Runs whenever this
390
+ # round's Handoff has a non-empty #### 檔案, independent of Status — a
391
+ # trivial round with no #### 檔案 (the 瑣碎輪 convention) is unaffected.
392
+ #
393
+ # Grammar: zero or more "commit <hash>:" blocks (checked exactly,
394
+ # category-precise, against files_snapshot $PROJECT_DIR $hash) followed
395
+ # by at most one "尚未 commit:" block (checked one-directionally: every
396
+ # claimed path must be in files_snapshot $PROJECT_DIR's current dirty
397
+ # set; extra unclaimed dirty paths are not an error — cross-round
398
+ # residue, see docs/design/files-verify.md Decision 4). A line that
399
+ # isn't a recognized header or category line is a format violation and
400
+ # blocks (not fail-open — Claude is expected to produce this grammar,
401
+ # same as #### 工作區's seven formats). git unavailable, or a commit
402
+ # hash that doesn't resolve, skips just that check (fail-open).
403
+ FILES_BODY="$(handoff_subsection_body '^#### 檔案')"
404
+ if printf '%s\n' "$FILES_BODY" | grep -q '[^[:space:]]' && command -v git >/dev/null 2>&1; then
405
+ FILES_ERR=""
406
+ CUR_KIND=""
407
+ CUR_HASH=""
408
+ CUR_CLAIM=""
409
+ UNCOMMITTED_CLAIM_PATHS=""
410
+ # Printed after a format-violation message so Claude has the exact
411
+ # grammar to correct against, same rigor #### 工作區 already gets on
412
+ # mismatch (it prints its own EXPECTED_WS). Category lines can be
413
+ # omitted per block for a category with nothing to report, same as
414
+ # files-snapshot.sh's own output.
415
+ FILES_GRAMMAR="正確格式(照這個結構寫,分類行可依實際情況省略沒有變更的類別):
416
+ commit <hash>:
417
+ 新增:<path>, <path>
418
+ 修改:<path>
419
+ 刪除:<path>
420
+
421
+ 尚未 commit:
422
+ 新增:<path>
423
+ 修改:<path>
424
+ 刪除:<path>"
425
+
426
+ files_body_parse() {
427
+ # Normalizes #### 檔案's body into tagged records, one per input
428
+ # line (blank/whitespace-only lines dropped):
429
+ # HDR\tcommit\t<hash>
430
+ # HDR\tuncommitted
431
+ # CAT\t<新增|修改|刪除>\t<comma-space-joined paths>
432
+ # ERR\t<original line> (anything else non-blank)
433
+ awk '
434
+ function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
435
+ {
436
+ line = $0
437
+ if (trim(line) == "") next
438
+ if (line ~ /^commit [0-9a-f]+:$/) {
439
+ hash = line
440
+ sub(/^commit /, "", hash); sub(/:$/, "", hash)
441
+ print "HDR\tcommit\t" hash
442
+ next
443
+ }
444
+ if (line ~ /^尚未 commit:$/) { print "HDR\tuncommitted"; next }
445
+ if (line ~ /^新增:/) { rest = line; sub(/^新增:/, "", rest); print "CAT\t新增\t" rest; next }
446
+ if (line ~ /^修改:/) { rest = line; sub(/^修改:/, "", rest); print "CAT\t修改\t" rest; next }
447
+ if (line ~ /^刪除:/) { rest = line; sub(/^刪除:/, "", rest); print "CAT\t刪除\t" rest; next }
448
+ print "ERR\t" line
449
+ }
450
+ '
451
+ }
452
+
453
+ check_commit_block() {
454
+ # files_snapshot returns empty stdout for two different reasons: the
455
+ # hash doesn't resolve at all, or it resolves fine but that commit's
456
+ # diff is entirely inside .devlog/ (or the commit is empty). Only the
457
+ # former is fail-open territory — a resolvable commit must still be
458
+ # compared even when its expected content is genuinely empty, or a
459
+ # claim naming fabricated paths against it would silently pass. So
460
+ # resolve the hash here, separately from computing $expected, instead
461
+ # of inferring "unresolvable" from empty output.
462
+ local h="$1" claimed="$2" expected
463
+ git -C "$PROJECT_DIR" rev-parse --verify -q "${h}^{commit}" >/dev/null 2>&1 || return 0
464
+ expected="$(files_snapshot "$PROJECT_DIR" "$h" 2>/dev/null || true)"
465
+ if [ "$claimed" != "$expected" ]; then
466
+ FILES_ERR="commit ${h} 的內容跟宣稱不符。請把這個區塊換成以下逐字內容:"
467
+ if [ -n "$expected" ]; then
468
+ FILES_ERR="$FILES_ERR
469
+ commit ${h}:
470
+ ${expected}"
471
+ else
472
+ FILES_ERR="$FILES_ERR
473
+ 這個 commit 在 .devlog/ 以外沒有變更,「#### 檔案」裡不該有這個 commit 區塊(或整節省略,如果沒有其他 commit/尚未 commit 內容要報)。"
474
+ fi
475
+ fi
476
+ }
477
+
478
+ path_in_list() {
479
+ # $1 = needle path (already trimmed by the caller), $2 = comma-space
480
+ # -joined haystack (may be empty — files_snapshot's join format, see
481
+ # files-snapshot.sh). Array-free on purpose: bash 3.2 (macOS's system
482
+ # bash, this suite's de-facto floor) makes "${arr[@]}" on a genuinely
483
+ # empty array an unbound-variable error under `set -uo pipefail`, and
484
+ # an empty haystack (clean tree) is exactly the case this check must
485
+ # not crash on. Padding both sides with ", " avoids matching a needle
486
+ # that is only a substring of a longer path (e.g. "a.txt" must not
487
+ # match "za.txt" or "a.txt2").
488
+ local needle="$1" haystack="$2"
489
+ case ", $haystack, " in
490
+ *", $needle, "*) return 0 ;;
491
+ esac
492
+ return 1
493
+ }
494
+
495
+ while IFS=$'\t' read -r tag a b; do
496
+ [ -z "$FILES_ERR" ] || break
497
+ case "$tag" in
498
+ HDR)
499
+ if [ "$CUR_KIND" = "commit" ]; then
500
+ check_commit_block "$CUR_HASH" "$CUR_CLAIM"
501
+ fi
502
+ CUR_CLAIM=""
503
+ CUR_KIND="$a"
504
+ CUR_HASH="$b"
505
+ ;;
506
+ CAT)
507
+ case "$CUR_KIND" in
508
+ commit)
509
+ CUR_CLAIM="${CUR_CLAIM:+$CUR_CLAIM$'\n'}${a}:${b}"
510
+ ;;
511
+ uncommitted)
512
+ UNCOMMITTED_CLAIM_PATHS="${UNCOMMITTED_CLAIM_PATHS:+$UNCOMMITTED_CLAIM_PATHS, }${b}"
513
+ ;;
514
+ *)
515
+ FILES_ERR="#### 檔案 格式不對:分類行出現在任何 commit/尚未 commit 標頭之前。
516
+
517
+ ${FILES_GRAMMAR}"
518
+ ;;
519
+ esac
520
+ ;;
521
+ ERR)
522
+ FILES_ERR="#### 檔案 格式不對,看不懂這一行:${a}
523
+
524
+ ${FILES_GRAMMAR}"
525
+ ;;
526
+ esac
527
+ done < <(printf '%s\n' "$FILES_BODY" | files_body_parse)
528
+
529
+ if [ -z "$FILES_ERR" ] && [ "$CUR_KIND" = "commit" ]; then
530
+ check_commit_block "$CUR_HASH" "$CUR_CLAIM"
531
+ fi
532
+
533
+ if [ -z "$FILES_ERR" ] && [ -n "$UNCOMMITTED_CLAIM_PATHS" ]; then
534
+ ACTUAL_DIRTY="$(files_snapshot "$PROJECT_DIR" 2>/dev/null || true)"
535
+ ACTUAL_JOINED="$(printf '%s\n' "$ACTUAL_DIRTY" | sed -E 's/^(新增|修改|刪除)://' | awk 'BEGIN{ORS=""} NF{print (out?", ":"") $0; out=1}')"
536
+ IFS=',' read -ra _CLAIMED_ARR <<< "$UNCOMMITTED_CLAIM_PATHS"
537
+ for _p in "${_CLAIMED_ARR[@]}"; do
538
+ _p="$(printf '%s' "$_p" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
539
+ [ -n "$_p" ] || continue
540
+ if ! path_in_list "$_p" "$ACTUAL_JOINED"; then
541
+ FILES_ERR="#### 檔案 的「尚未 commit」宣稱了 ${_p},但它目前不在實際變更的檔案裡。目前實際的未提交變更是:
542
+ ${ACTUAL_DIRTY:-(沒有,工作樹乾淨)}"
543
+ break
544
+ fi
545
+ done
546
+ fi
547
+
548
+ if [ -n "$FILES_ERR" ]; then
549
+ echo "$FILES_ERR" >&2
550
+ exit 2
551
+ fi
552
+ fi
553
+ fi
554
+
555
+ rm -f "$DEVLOG_DIR/.workspace-mismatch" 2>/dev/null || true
556
+
557
+ # 這一輪通過所有驗證,正式收尾:把 .round-current.md 併回 devlog.md(併完
558
+ # 就地刪除 .round-current.md)。放在 checkpoint 計數檢查之前,這樣如果這輪
559
+ # 內容裡本來就有 Claude 寫的「## Checkpoint」,併進去之後馬上就會被下面的
560
+ # 數量比對算到,不用再等下一輪。
561
+ #
562
+ # 只有 LAST_ROUND 非空(代表上面真的抓到、驗證過一個 `## Round `)才併入。
563
+ # LAST_ROUND 是空的代表整個驗證區塊 fail-open 跳過了(.round-current.md
564
+ # 裡完全沒有 `## Round ` 這一行,例如純雜訊)——這種情況併入只會把未結構化
565
+ # 的內容寫進 devlog.md 的永久歷史,所以刻意保留 .round-current.md 原封不動,
566
+ # 讓之後的輪次或人工介入還能回頭處理,而不是併進去就再也分不出來。
567
+ #
568
+ # .round-open 只有在真的併入成功之後才刪除:devlog_merge_round_current
569
+ # 失敗時(例如寫入失敗)保留 .round-open,讓既有的 dangling-heal 機制
570
+ # (close-open-round.sh,下次 UserPromptSubmit / SessionStart 都會跑到)
571
+ # 之後還有機會重試,而不是內容被孤立在 .round-current.md 卻沒有任何機制
572
+ # 知道要去救它。
573
+ if [ -n "$LAST_ROUND" ]; then
574
+ if devlog_merge_round_current "$DEVLOG_FILE" "$ROUND_CURRENT"; then
575
+ rm -f "$DEVLOG_DIR/.round-open" 2>/dev/null || true
576
+ fi
577
+ else
578
+ rm -f "$DEVLOG_DIR/.round-open" 2>/dev/null || true
579
+ fi
580
+
581
+ # 這輪真的有寫東西:如果剛剛因為 span 過期才走到這裡,把計數器歸零,
582
+ # 讓 span 繼續正常運作而不是每輪都卡在「超過門檻」。
583
+ if [ "$SPAN_VALID" -eq 1 ]; then
584
+ json_int_set "$SPAN_FILE" ticks_since_checkin 0
585
+ fi
586
+
587
+ # --- checkpoint 檢查(Checkpoint Mode)----------------------------------
588
+ # 這輪確實寫了東西(上面的雜湊比對通過)之後,才檢查 checkpoint 狀態。
589
+ # 用「## Checkpoint 標題數量有沒有變多」當作可驗證的訊號,而不是「有沒有
590
+ # 寫東西」——因為每輪本來就一定會寫東西(上面的雜湊檢查已經保證),用寫入
591
+ # 當訊號會讓計數器每輪都被歸零,永遠到不了門檻。
592
+ CHECKPOINT_FILE="$DEVLOG_DIR/.checkpoint-state"
593
+ if [ -f "$CHECKPOINT_FILE" ]; then
594
+ CP_ROUNDS="$(json_int_get "$CHECKPOINT_FILE" rounds_since_checkpoint)"
595
+ CP_MAX="$(json_int_get "$CHECKPOINT_FILE" max_silent_rounds)"
596
+ CP_SEEN="$(json_int_get "$CHECKPOINT_FILE" checkpoint_marker_count)"
597
+ case "$CP_ROUNDS" in ''|*[!0-9]*) CP_ROUNDS='' ;; esac
598
+ case "$CP_MAX" in ''|*[!0-9]*) CP_MAX='' ;; esac
599
+ case "$CP_SEEN" in ''|*[!0-9]*) CP_SEEN='' ;; esac
600
+
601
+ if [ -n "$CP_ROUNDS" ] && [ -n "$CP_MAX" ] && [ -n "$CP_SEEN" ]; then
602
+ CURRENT_MARKER_COUNT="$(grep -c '^## Checkpoint' "$DEVLOG_FILE" 2>/dev/null || echo 0)"
603
+ case "$CURRENT_MARKER_COUNT" in ''|*[!0-9]*) CURRENT_MARKER_COUNT=0 ;; esac
604
+
605
+ # 下修同步:如果現在看到的數量比上次記的還少(compact 把 checkpoint 搬走了,
606
+ # 或有人手動改了 devlog.md),代表 CP_SEEN 是過期的高估值,往下的 -gt 比對
607
+ # 會永遠卡住(真的新寫的 checkpoint 也追不上這個虛高的門檻)。這裡必須立刻
608
+ # 把 checkpoint_marker_count 寫回檔案修正——這支腳本每次 Stop hook 都是全新
609
+ # process,只改 shell 變數不寫檔的話,下一次呼叫又會從檔案讀回舊的高估值,
610
+ # 等於什麼都沒修到。只動 checkpoint_marker_count 這個欄位,不動
611
+ # rounds_since_checkpoint——單純「數量變少」不代表寫了 checkpoint,不該歸零
612
+ # 沉默輪數計數器。同時更新本次呼叫用的 CP_SEEN 變數,讓下面這次 invocation
613
+ # 的 -gt / elif 判斷也立刻用修正後的值。
614
+ if [ "$CURRENT_MARKER_COUNT" -lt "$CP_SEEN" ]; then
615
+ json_int_set "$CHECKPOINT_FILE" checkpoint_marker_count "$CURRENT_MARKER_COUNT"
616
+ CP_SEEN="$CURRENT_MARKER_COUNT"
617
+ fi
618
+
619
+ if [ "$CURRENT_MARKER_COUNT" -gt "$CP_SEEN" ]; then
620
+ json_int_set "$CHECKPOINT_FILE" rounds_since_checkpoint 0
621
+ json_int_set "$CHECKPOINT_FILE" checkpoint_marker_count "$CURRENT_MARKER_COUNT"
622
+ elif [ "$CP_ROUNDS" -ge "$CP_MAX" ]; then
623
+ echo "已經 ${CP_ROUNDS} 輪沒有寫 checkpoint 摘要了(門檻 ${CP_MAX})。請在 .devlog/${DEVLOG_FILE##*/} 追加一段「## Checkpoint(Round X-Y 摘要)」,總結這段期間做了什麼,寫完再結束這一輪。" >&2
624
+ exit 2
625
+ fi
626
+ fi
627
+ fi
628
+
629
+ exit 0