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,157 @@
1
+ #!/usr/bin/env bash
2
+ # SessionStart hook:在 startup / resume / clear / compact / fork 時執行。
3
+ # startup / resume / compact / fork:讀 .devlog/devlog.md 最後 N 輪 + 開頭摘要,
4
+ # 印到 stdout 讓 Claude Code 當成 additionalContext 注入。
5
+ # source=clear:只做 dangling heal,不注入、不印 span 提醒——/clear 必須是真的空
6
+ # context,接續改由 /devlog-tracker:continue。找不到檔案就 exit 0。
7
+ #
8
+ # Span Mode:.span-open 還開著時,在注入內容最前面加一段提醒(clear 除外)。
9
+ # 讀不到/格式壞掉就靜默跳過,fail-open。
10
+ #
11
+ # Dangling heal:startup / resume / clear / fork 把殘留的 .round-open 標成
12
+ # INTERRUPTED。source=compact(以及 source 缺失/讀不到)跳過 heal,避免
13
+ # mid-turn auto-compact 改雜湊讓 Stop 靜默放行;compact 仍注入 excerpt
14
+ #(最後一個 Checkpoint + 最近兩輪 Summary/Handoff/Status),不 heal。
15
+ # close-open-round.sh 必須對 stdout 保持沉默。
16
+ #
17
+ # .round-current.md excerpt:因為 compact 不 heal,這一輪真的可能還開著、
18
+ # 內容只在 .devlog/.round-current.md 裡(尚未併回 devlog.md)——起始時的
19
+ # devlog.md 摘要看不到它,會讓 mid-turn auto-compact 的注入內容悄悄漏掉
20
+ # Claude 正在寫的這一輪。devlog.md 摘要印完之後,只要 .round-current.md
21
+ # 非空就整份印出來、清楚標示「尚未收尾」,跟上面的歷史摘要分開。startup /
22
+ # resume / fork 這幾個 source 因為上面已經先 heal 過,這裡通常是空的、印出
23
+ # 來是 no-op,不特別排除,寫法比較單純。讀不到就跳過,fail-open。
24
+
25
+ set -uo pipefail
26
+
27
+ _src="${BASH_SOURCE[0]}"
28
+ HOOKS_DIR="$(cd "${_src%/*}" && pwd)"
29
+ # shellcheck source=json-field.sh
30
+ . "$HOOKS_DIR/json-field.sh"
31
+ # shellcheck source=detect-pending-question.sh
32
+ . "$HOOKS_DIR/detect-pending-question.sh"
33
+ # shellcheck source=devlog-path.sh
34
+ . "$HOOKS_DIR/devlog-path.sh"
35
+
36
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
37
+ devlog_resolve_paths "$PROJECT_DIR"
38
+ ROUND_CURRENT="$DEVLOG_DIR/.round-current.md"
39
+ SPAN_FILE="$DEVLOG_DIR/.span-open"
40
+
41
+ INPUT="$(cat 2>/dev/null || true)"
42
+ SOURCE="$(json_str_field "$INPUT" source)"
43
+
44
+ case "$SOURCE" in
45
+ startup|resume|clear|fork)
46
+ if [ -f "$DEVLOG_DIR/.round-open" ]; then
47
+ DANGLING_DETAIL=""
48
+ if [ -n "$(detect_pending_question "$(json_str_field "$INPUT" transcript_path)")" ]; then
49
+ DANGLING_DETAIL="awaiting_question"
50
+ fi
51
+ bash "$HOOKS_DIR/close-open-round.sh" "dangling:session_start" "$DANGLING_DETAIL" || true
52
+ fi
53
+ ;;
54
+ esac
55
+
56
+ # /clear 清空對話;不要把 devlog 或 span 提醒打回 context。
57
+ if [ "$SOURCE" = "clear" ]; then
58
+ exit 0
59
+ fi
60
+
61
+ if [ -f "$SPAN_FILE" ]; then
62
+ SPAN_ROUND="$(json_int_get "$SPAN_FILE" round)"
63
+ SPAN_OPENED_AT="$(json_str_get "$SPAN_FILE" opened_at)"
64
+ if [ -n "$SPAN_ROUND" ] && [ -n "$SPAN_OPENED_AT" ]; then
65
+ echo "⚠️ 有一個開啟中的 span:Round ${SPAN_ROUND},從 ${SPAN_OPENED_AT} 開始,"
66
+ echo "還沒有正式結束。請先確認要繼續這個自動化任務,還是要明確關閉它"
67
+ echo "(刪除 .devlog/.span-open 並補寫收尾的 Round)。"
68
+ echo ""
69
+ fi
70
+ fi
71
+
72
+ if [ -f "$DEVLOG_FILE" ]; then
73
+ echo "以下是本專案 .devlog/${DEVLOG_FILE##*/} 的接手摘要(不是全文;完整紀錄請自行讀取原檔):"
74
+ echo ""
75
+ awk '
76
+ /^[ \t]*```/ { fence = !fence }
77
+ {
78
+ lines[NR] = $0
79
+ infence[NR] = fence
80
+ }
81
+ END {
82
+ n = NR
83
+ last_cp = 0
84
+ last_kept = 0
85
+ last_lessons = 0
86
+ rc = 0
87
+ for (i = 1; i <= n; i++) {
88
+ if (infence[i]) continue
89
+ if (lines[i] ~ /^## Checkpoint/) last_cp = i
90
+ if (lines[i] ~ /^## Kept 索引/) last_kept = i
91
+ if (lines[i] ~ /^## Lessons 索引/) last_lessons = i
92
+ if (lines[i] ~ /^## Round /) { rc++; round_at[rc] = i }
93
+ }
94
+ if (last_cp > 0) {
95
+ cp_end = n
96
+ for (j = last_cp + 1; j <= n; j++) {
97
+ if (!infence[j] && lines[j] ~ /^## /) { cp_end = j - 1; break }
98
+ }
99
+ for (j = last_cp; j <= cp_end; j++) print lines[j]
100
+ print ""
101
+ }
102
+ if (last_kept > 0) {
103
+ kp_end = n
104
+ for (j = last_kept + 1; j <= n; j++) {
105
+ if (!infence[j] && lines[j] ~ /^## /) { kp_end = j - 1; break }
106
+ }
107
+ for (j = last_kept; j <= kp_end; j++) print lines[j]
108
+ print ""
109
+ }
110
+ if (last_lessons > 0) {
111
+ ls_end = n
112
+ for (j = last_lessons + 1; j <= n; j++) {
113
+ if (!infence[j] && lines[j] ~ /^## /) { ls_end = j - 1; break }
114
+ }
115
+ for (j = last_lessons; j <= ls_end; j++) print lines[j]
116
+ print ""
117
+ }
118
+ start_i = (rc > 2) ? rc - 1 : 1
119
+ if (rc == 0) exit 0
120
+ for (r = start_i; r <= rc; r++) {
121
+ rs = round_at[r]
122
+ re = n
123
+ for (j = rs + 1; j <= n; j++) {
124
+ if (!infence[j] && lines[j] ~ /^## /) { re = j - 1; break }
125
+ }
126
+ print lines[rs]
127
+ print ""
128
+ has_summary = 0
129
+ for (j = rs; j <= re; j++) if (!infence[j] && lines[j] ~ /^### Summary/) has_summary = 1
130
+ keep = 0
131
+ for (j = rs + 1; j <= re; j++) {
132
+ if (infence[j]) {
133
+ if (keep) print lines[j]
134
+ continue
135
+ }
136
+ if (lines[j] ~ /^### Summary/ || lines[j] ~ /^### Handoff/ || lines[j] ~ /^### Status/) { keep = 1; print lines[j]; continue }
137
+ if (lines[j] ~ /^### User Input/) { keep = (has_summary ? 0 : 1); if (keep) print lines[j]; continue }
138
+ if (lines[j] ~ /^### /) { keep = 0; continue }
139
+ if (keep) print lines[j]
140
+ }
141
+ print ""
142
+ }
143
+ }
144
+ ' "$DEVLOG_FILE" 2>/dev/null || true
145
+ fi
146
+
147
+ # See header comment: this round may still be genuinely open (compact skips
148
+ # heal) with its real content sitting only in .round-current.md. Surface it
149
+ # separately from the devlog.md excerpt above so it isn't silently dropped.
150
+ if [ -s "$ROUND_CURRENT" ]; then
151
+ echo "以下是目前還沒收尾、仍在 .devlog/.round-current.md 裡的這一輪內容(跟上面的歷史摘要分開,尚未併入 devlog.md):"
152
+ echo ""
153
+ cat "$ROUND_CURRENT" 2>/dev/null || true
154
+ echo ""
155
+ fi
156
+
157
+ exit 0
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ DEVLOG_DIR="${CLAUDE_PROJECT_DIR:-.}/.devlog"
4
+ if [ ! -e "$DEVLOG_DIR/.span-open" ]; then
5
+ echo "NOT_OPEN"
6
+ exit 0
7
+ fi
8
+ rm -f "$DEVLOG_DIR/.span-open" || exit 1
9
+ echo "CLOSED"
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ _src="${BASH_SOURCE[0]}"
4
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
5
+ . "$SCRIPT_DIR/json-field.sh"
6
+ . "$SCRIPT_DIR/devlog-md.sh"
7
+ . "$SCRIPT_DIR/devlog-path.sh"
8
+
9
+ devlog_resolve_paths "${CLAUDE_PROJECT_DIR:-.}"
10
+ [ -f "$DEVLOG_DIR/.enabled" ] || { echo "NOT_ENABLED" >&2; exit 1; }
11
+ [ ! -e "$DEVLOG_DIR/.span-open" ] || { echo "ALREADY_OPEN" >&2; exit 1; }
12
+ ROUND=""
13
+ [ ! -f "$DEVLOG_DIR/.round-open" ] || ROUND="$(json_int_get "$DEVLOG_DIR/.round-open" round)"
14
+ if [ -z "$ROUND" ] && [ -f "$DEVLOG_FILE" ]; then
15
+ ROUND="$(devlog_list_round_starts "$DEVLOG_FILE" | awk 'END { print $2 }')"
16
+ fi
17
+ [ -n "$ROUND" ] || { echo "NO_ROUND" >&2; exit 1; }
18
+ OPENED_AT="$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S%z')"
19
+ printf '{"round": %s, "opened_at": "%s", "ticks_since_checkin": 0, "max_silent_ticks": 5}\n' \
20
+ "$ROUND" "$OPENED_AT" > "$DEVLOG_DIR/.span-open" || exit 1
21
+ printf 'OPENED=%s\n' "$ROUND"
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # User-invoked: /devlog-tracker:start filesystem side.
3
+ set -uo pipefail
4
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
5
+ DEVLOG_DIR="$PROJECT_DIR/.devlog"
6
+ mkdir -p "$DEVLOG_DIR" || exit 1
7
+ if [ ! -f "$DEVLOG_DIR/.enabled" ]; then
8
+ date -Iseconds > "$DEVLOG_DIR/.enabled" 2>/dev/null || echo enabled > "$DEVLOG_DIR/.enabled"
9
+ fi
10
+ if [ ! -f "$DEVLOG_DIR/.checkpoint-state" ]; then
11
+ printf '%s\n' '{"rounds_since_checkpoint": 0, "max_silent_rounds": 20, "checkpoint_marker_count": 0}' \
12
+ > "$DEVLOG_DIR/.checkpoint-state" || exit 1
13
+ fi
14
+ if [ ! -f "$DEVLOG_DIR/.segment-state" ]; then
15
+ printf '%s\n' '{"last_change_epoch": 0, "last_seen_cksum": "", "max_silent_seconds": 600, "session_id": ""}' \
16
+ > "$DEVLOG_DIR/.segment-state" || exit 1
17
+ fi
18
+ GITIGNORE_DEVLOG=no
19
+ if [ -f "$PROJECT_DIR/.gitignore" ] && grep -E -q '(^|/)\.devlog(/|$)' "$PROJECT_DIR/.gitignore"; then
20
+ GITIGNORE_DEVLOG=yes
21
+ fi
22
+ echo "ENABLED=$DEVLOG_DIR/.enabled"
23
+ echo "GITIGNORE_DEVLOG=$GITIGNORE_DEVLOG"
24
+ exit 0
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ _src="${BASH_SOURCE[0]}"
4
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
5
+ . "$SCRIPT_DIR/json-field.sh"
6
+ . "$SCRIPT_DIR/devlog-md.sh"
7
+ . "$SCRIPT_DIR/devlog-path.sh"
8
+
9
+ devlog_resolve_paths "${CLAUDE_PROJECT_DIR:-.}"
10
+ [ -d "$DEVLOG_DIR" ] || { echo "NOT_STARTED"; exit 0; }
11
+
12
+ [ -f "$DEVLOG_DIR/.enabled" ] && echo "ENABLED=yes" || echo "ENABLED=no"
13
+ [ -f "$DEVLOG_DIR/.lessons-enabled" ] && echo "LESSONS=yes" || echo "LESSONS=no"
14
+ if [ -f "$DEVLOG_DIR/.lessons-drift-state" ]; then
15
+ drift_count="$(json_int_get "$DEVLOG_DIR/.lessons-drift-state" mismatch_count)"
16
+ drift_max="$(json_int_get "$DEVLOG_DIR/.lessons-drift-state" threshold)"
17
+ printf 'LESSONS_DRIFT=%s/%s\n' "${drift_count:-0}" "${drift_max:-3}"
18
+ fi
19
+ if [ -f "$DEVLOG_DIR/.span-open" ]; then
20
+ r="$(json_int_get "$DEVLOG_DIR/.span-open" round)"
21
+ opened="$(json_str_get "$DEVLOG_DIR/.span-open" opened_at)"
22
+ ticks="$(json_int_get "$DEVLOG_DIR/.span-open" ticks_since_checkin)"
23
+ max="$(json_int_get "$DEVLOG_DIR/.span-open" max_silent_ticks)"
24
+ printf 'SPAN=%s,%s,%s,%s\n' "${r:-?}" "${opened:-?}" "${ticks:-?}" "${max:-5}"
25
+ else
26
+ echo "SPAN=closed"
27
+ fi
28
+ rounds="$(json_int_get "$DEVLOG_DIR/.checkpoint-state" rounds_since_checkpoint)"
29
+ checkpoint_max="$(json_int_get "$DEVLOG_DIR/.checkpoint-state" max_silent_rounds)"
30
+ printf 'CHECKPOINT=%s/%s\n' "${rounds:-0}" "${checkpoint_max:-20}"
31
+ seconds="$(json_int_get "$DEVLOG_DIR/.segment-state" max_silent_seconds)"
32
+ printf 'SEGMENT=max_silent_seconds=%s\n' "${seconds:-600}"
33
+
34
+ status="none"
35
+ if [ -f "$DEVLOG_FILE" ]; then
36
+ last="$(devlog_list_round_starts "$DEVLOG_FILE" | awk 'END { print $1 }')"
37
+ if [ -n "$last" ]; then
38
+ end="$(devlog_block_end "$DEVLOG_FILE" "$last")"
39
+ found="$(devlog_round_status "$DEVLOG_FILE" "$last" "$end")"
40
+ [ -z "$found" ] || status="$found"
41
+ fi
42
+ fi
43
+ printf 'LAST_STATUS=%s\n' "$status"
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for await-open.sh. Run:
3
+ # bash hooks/scripts/test-await-open.sh
4
+ set -uo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7
+ TMP_ROOT="$(mktemp -d)"
8
+ trap 'rm -rf "$TMP_ROOT"' EXIT
9
+
10
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
11
+ DEVLOG_DIR="$TMP_ROOT/.devlog"
12
+ mkdir -p "$DEVLOG_DIR"
13
+
14
+ FAIL=0
15
+ assert_exit() {
16
+ local desc="$1" expected="$2" actual="$3"
17
+ if [ "$actual" -eq "$expected" ]; then
18
+ echo "PASS: $desc"
19
+ else
20
+ echo "FAIL: $desc (expected exit $expected, got $actual)"
21
+ FAIL=1
22
+ fi
23
+ }
24
+ assert_contains() {
25
+ local desc="$1" needle="$2" haystack="$3"
26
+ case "$haystack" in
27
+ *"$needle"*) echo "PASS: $desc" ;;
28
+ *) echo "FAIL: $desc (missing: $needle)"; FAIL=1 ;;
29
+ esac
30
+ }
31
+
32
+ # --- 1: not enabled -> NOT_ENABLED, no file --------------------------------
33
+ OUT="$(bash "$SCRIPT_DIR/await-open.sh")"
34
+ assert_exit "not enabled -> exit 1" 1 $?
35
+ assert_contains "reports NOT_ENABLED" "NOT_ENABLED" "$OUT"
36
+ if [ -f "$DEVLOG_DIR/.awaiting-reply" ]; then
37
+ echo "FAIL: .awaiting-reply should not exist when disabled"
38
+ FAIL=1
39
+ else
40
+ echo "PASS: no .awaiting-reply when disabled"
41
+ fi
42
+
43
+ touch "$DEVLOG_DIR/.enabled"
44
+
45
+ # --- 2: resolves round from .round-open when present -----------------------
46
+ printf '%s\n' '{"round": 4, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
47
+ OUT="$(bash "$SCRIPT_DIR/await-open.sh")"
48
+ assert_exit "opens -> exit 0" 0 $?
49
+ assert_contains "reports OPENED=4" "OPENED=4" "$OUT"
50
+ ROUND_FIELD="$(grep -o '"round"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.awaiting-reply" | grep -o '[0-9]\+$')"
51
+ if [ "$ROUND_FIELD" = "4" ]; then
52
+ echo "PASS: .awaiting-reply round=4 (from .round-open)"
53
+ else
54
+ echo "FAIL: expected round=4, got $ROUND_FIELD"
55
+ FAIL=1
56
+ fi
57
+ grep -q '"opened_at"' "$DEVLOG_DIR/.awaiting-reply" \
58
+ && echo "PASS: opened_at field present" || { echo "FAIL: opened_at missing"; FAIL=1; }
59
+ rm -f "$DEVLOG_DIR/.awaiting-reply" "$DEVLOG_DIR/.round-open"
60
+
61
+ # --- 3: falls back to last ## Round in devlog.md when .round-open absent ---
62
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
63
+ ## Round 1 — 2026-09-09T12:00:00+08:00
64
+
65
+ ### Summary
66
+ done
67
+
68
+ ### Reply
69
+ fixture reply.
70
+
71
+ ### Handoff
72
+ #### 現況
73
+ done
74
+
75
+ ### Status
76
+ DONE
77
+
78
+ ## Round 2 — 2026-09-09T13:00:00+08:00
79
+
80
+ ### Summary
81
+ done
82
+
83
+ ### Reply
84
+ fixture reply.
85
+
86
+ ### Handoff
87
+ #### 現況
88
+ done
89
+
90
+ ### Status
91
+ DONE
92
+ EOF
93
+ OUT="$(bash "$SCRIPT_DIR/await-open.sh")"
94
+ assert_contains "falls back to last Round in devlog.md" "OPENED=2" "$OUT"
95
+ rm -f "$DEVLOG_DIR/.awaiting-reply"
96
+
97
+ # --- 4: already open -> ALREADY_OPEN, does not overwrite --------------------
98
+ printf '%s\n' '{"round": 99, "opened_at": "sentinel"}' > "$DEVLOG_DIR/.awaiting-reply"
99
+ OUT="$(bash "$SCRIPT_DIR/await-open.sh")"
100
+ assert_exit "already open -> exit 1" 1 $?
101
+ assert_contains "reports ALREADY_OPEN" "ALREADY_OPEN" "$OUT"
102
+ grep -q 'sentinel' "$DEVLOG_DIR/.awaiting-reply" \
103
+ && echo "PASS: existing marker left untouched" || { echo "FAIL: existing marker was overwritten"; FAIL=1; }
104
+ rm -f "$DEVLOG_DIR/.awaiting-reply" "$DEVLOG_DIR/devlog.md"
105
+
106
+ if [ "$FAIL" -eq 0 ]; then
107
+ echo "All checks passed."
108
+ exit 0
109
+ else
110
+ echo "Some checks FAILED."
111
+ exit 1
112
+ fi
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ TMP="$(mktemp -d)"
5
+ trap 'rm -rf "$TMP"' EXIT
6
+ FAIL=0
7
+
8
+ REPO="$TMP/repo"
9
+ mkdir -p "$REPO"
10
+ git -C "$REPO" init -q
11
+ git -C "$REPO" -c user.email=t@t.t -c user.name=t commit -q --allow-empty -m init
12
+ git -C "$REPO" branch -M main
13
+ mkdir -p "$REPO/.devlog"
14
+ : > "$REPO/.devlog/.enabled"
15
+ export CLAUDE_PROJECT_DIR="$REPO"
16
+
17
+ git -C "$REPO" checkout -q -b fix-issue-123
18
+
19
+ # --- round-start.sh opens a Round in .round-current.md -------------------
20
+ # Round-current split: the open round lives in the shared, non-branch-scoped
21
+ # .devlog/.round-current.md until Stop merges it — it is NOT written directly
22
+ # into the branch-scoped file (that only happens once the round closes).
23
+ OUT="$(echo '{"prompt":"hello"}' | bash "$SCRIPT_DIR/round-start.sh" 2>&1)"
24
+ BRANCH_FILE="$REPO/.devlog/devlog.fix-issue-123.md"
25
+ ROUND_CURRENT="$REPO/.devlog/.round-current.md"
26
+ if [ -f "$ROUND_CURRENT" ] && grep -q '^## Round 1' "$ROUND_CURRENT" && [ ! -f "$BRANCH_FILE" ] && [ ! -f "$REPO/.devlog/devlog.md" ]; then
27
+ echo "PASS: round-start.sh opens Round 1 in .round-current.md, not yet in devlog.fix-issue-123.md"
28
+ else
29
+ echo "FAIL: round-start.sh output=$OUT branch_file_exists=$([ -f "$BRANCH_FILE" ] && echo y || echo n)"
30
+ FAIL=1
31
+ fi
32
+
33
+ # --- segment-watch.sh: Read of .round-current.md is allowed ---------------
34
+ INPUT='{"tool_name":"Read","tool_input":{"file_path":".devlog/.round-current.md"}}'
35
+ if echo "$INPUT" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>&1; then
36
+ echo "PASS: segment-watch.sh allows Read of .round-current.md"
37
+ else
38
+ echo "FAIL: segment-watch.sh denied Read of .round-current.md"
39
+ FAIL=1
40
+ fi
41
+
42
+ # --- segment-watch.sh: its own deny message names .round-current.md, not the
43
+ # branch-scoped file (that's only the merge target, not what's tracked
44
+ # while the round is still open) -----------------------------------------
45
+ # Force a stale segment-state so the silence-timeout path fires. The stored
46
+ # cksum must MATCH .round-current.md as it is right now: a mismatch means
47
+ # "the devlog just changed", which resets the timer and exits 0 instead of
48
+ # ever reaching the deny message we're trying to assert on.
49
+ SEEN_SUM="$(cksum < "$ROUND_CURRENT" | tr -d '\n')"
50
+ cat > "$REPO/.devlog/.segment-state" <<JSON
51
+ {"last_change_epoch": 1, "last_seen_cksum": "$SEEN_SUM", "max_silent_seconds": 1}
52
+ JSON
53
+ DENY_INPUT='{"tool_name":"Bash","tool_input":{"command":"echo hi"}}'
54
+ DENY_MSG="$(echo "$DENY_INPUT" | bash "$SCRIPT_DIR/segment-watch.sh" 2>&1 1>/dev/null)"
55
+ DENY_RC=0
56
+ echo "$DENY_INPUT" | bash "$SCRIPT_DIR/segment-watch.sh" >/dev/null 2>/dev/null || DENY_RC=$?
57
+ if [ "$DENY_RC" -eq 2 ] && printf '%s' "$DENY_MSG" | grep -q '\.round-current\.md' \
58
+ && ! printf '%s' "$DENY_MSG" | grep -q 'devlog.fix-issue-123.md'; then
59
+ echo "PASS: segment-watch.sh deny message names .round-current.md, not the branch-scoped file"
60
+ else
61
+ echo "FAIL: segment-watch.sh deny message rc=$DENY_RC msg=$DENY_MSG"
62
+ FAIL=1
63
+ fi
64
+
65
+ # --- enforce-devlog.sh: Stop hook checks the branch-scoped file's hash ----
66
+ echo '{}' | bash "$SCRIPT_DIR/enforce-devlog.sh" >/dev/null 2>&1
67
+ ENFORCE_RC=$?
68
+ if [ "$ENFORCE_RC" -eq 2 ]; then
69
+ echo "PASS: enforce-devlog.sh blocks when the branch-scoped file's Round has no Summary/Handoff yet"
70
+ else
71
+ echo "FAIL: enforce-devlog.sh unexpected rc=$ENFORCE_RC (expected 2, unchanged branch file with only User Input)"
72
+ FAIL=1
73
+ fi
74
+
75
+ # --- devlog_resolve_paths: reserved name and CJK fallback (Fix 2 / Fix 3) ---
76
+ . "$SCRIPT_DIR/devlog-path.sh"
77
+
78
+ ARCHREPO="$TMP/archrepo"
79
+ mkdir -p "$ARCHREPO"
80
+ git -C "$ARCHREPO" init -q
81
+ git -C "$ARCHREPO" -c user.email=t@t.t -c user.name=t commit -q --allow-empty -m init
82
+ git -C "$ARCHREPO" checkout -q -b archive
83
+ devlog_resolve_paths "$ARCHREPO"
84
+ if [ "$DEVLOG_FILE" = "$ARCHREPO/.devlog/devlog.md" ]; then
85
+ echo "PASS: branch named 'archive' falls back to shared devlog.md, doesn't collide with devlog.archive.md"
86
+ else
87
+ echo "FAIL: 'archive' branch got $DEVLOG_FILE"
88
+ FAIL=1
89
+ fi
90
+
91
+ CJKREPO="$TMP/cjkrepo"
92
+ mkdir -p "$CJKREPO"
93
+ git -C "$CJKREPO" init -q
94
+ git -C "$CJKREPO" -c user.email=t@t.t -c user.name=t commit -q --allow-empty -m init
95
+ git -C "$CJKREPO" checkout -q -b 修復登入問題
96
+ devlog_resolve_paths "$CJKREPO"
97
+ case "$DEVLOG_FILE" in
98
+ "$CJKREPO"/.devlog/devlog.b-*.md)
99
+ echo "PASS: all-CJK branch name gets a stable hash-derived file, not shared devlog.md"
100
+ ;;
101
+ *)
102
+ echo "FAIL: CJK branch got $DEVLOG_FILE"
103
+ FAIL=1
104
+ ;;
105
+ esac
106
+
107
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
108
+ else echo "Some checks FAILED."; exit 1; fi
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ FAIL=0
5
+ TMP="$(mktemp -d)"
6
+ trap 'rm -rf "$TMP"' EXIT
7
+
8
+ OUT="$(CLAUDE_PROJECT_DIR="$TMP" bash "$SCRIPT_DIR/checkpoint-set.sh" 25)"
9
+ [ "$OUT" = "NOT_STARTED" ] && echo "PASS: not started" || { echo "FAIL: $OUT"; FAIL=1; }
10
+
11
+ mkdir -p "$TMP/.devlog"
12
+ touch "$TMP/.devlog/.enabled"
13
+ printf '%s\n' '{"rounds_since_checkpoint": 7, "max_silent_rounds": 20, "checkpoint_marker_count": 2}' \
14
+ > "$TMP/.devlog/.checkpoint-state"
15
+
16
+ OUT="$(CLAUDE_PROJECT_DIR="$TMP" bash "$SCRIPT_DIR/checkpoint-set.sh" 30)"
17
+ case "$OUT" in *CHECKPOINT_MAX_SILENT_ROUNDS=30*) echo "PASS: set 30" ;; *) echo "FAIL: $OUT"; FAIL=1 ;; esac
18
+ grep -q '"rounds_since_checkpoint": 7' "$TMP/.devlog/.checkpoint-state" \
19
+ && echo "PASS: rounds preserved" || { echo "FAIL: rounds reset"; FAIL=1; }
20
+ grep -q '"checkpoint_marker_count": 2' "$TMP/.devlog/.checkpoint-state" \
21
+ && echo "PASS: markers preserved" || { echo "FAIL: markers reset"; FAIL=1; }
22
+ grep -q '"max_silent_rounds": 30' "$TMP/.devlog/.checkpoint-state" \
23
+ && echo "PASS: max written" || { echo "FAIL: max"; FAIL=1; }
24
+
25
+ CLAUDE_PROJECT_DIR="$TMP" bash "$SCRIPT_DIR/checkpoint-set.sh" 0 >/dev/null 2>&1
26
+ [ $? -eq 1 ] && echo "PASS: reject 0" || { echo "FAIL: accepted 0"; FAIL=1; }
27
+
28
+ # create when missing
29
+ rm -f "$TMP/.devlog/.checkpoint-state"
30
+ OUT="$(CLAUDE_PROJECT_DIR="$TMP" bash "$SCRIPT_DIR/checkpoint-set.sh" 15)"
31
+ case "$OUT" in *CHECKPOINT_MAX_SILENT_ROUNDS=15*) echo "PASS: create 15" ;; *) echo "FAIL: create $OUT"; FAIL=1 ;; esac
32
+ grep -q '"rounds_since_checkpoint": 0' "$TMP/.devlog/.checkpoint-state" \
33
+ && echo "PASS: create rounds 0" || { echo "FAIL: create rounds"; FAIL=1; }
34
+
35
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
36
+ else echo "Some checks FAILED."; exit 1; fi