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,160 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ . "$SCRIPT_DIR/devlog-md.sh"
5
+ TMP="$(mktemp -d)"
6
+ trap 'rm -rf "$TMP"' EXIT
7
+ FAIL=0
8
+ assert_eq() {
9
+ local d="$1" e="$2" a="$3"
10
+ if [ "$e" = "$a" ]; then echo "PASS: $d"; else echo "FAIL: $d expected [$e] got [$a]"; FAIL=1; fi
11
+ }
12
+ perm_of() {
13
+ stat -f '%Lp' "$1" 2>/dev/null || stat -c '%a' "$1" 2>/dev/null
14
+ }
15
+ make_round() {
16
+ local file="$1" n="$2" status="$3"
17
+ cat >> "$file" <<EOF
18
+ ## Round $n — 2026-09-09T12:00:00+08:00
19
+
20
+ ### Status
21
+ $status
22
+ EOF
23
+ }
24
+
25
+ # no --confirmed -> exit 1, nothing changes, no stdout
26
+ NOFLAG="$TMP/noflag"
27
+ mkdir -p "$NOFLAG/.devlog"
28
+ export CLAUDE_PROJECT_DIR="$NOFLAG"
29
+ printf '# project\n\n' > "$NOFLAG/.devlog/devlog.md"
30
+ make_round "$NOFLAG/.devlog/devlog.md" 1 DONE
31
+ OUT="$(bash "$SCRIPT_DIR/clean-devlog.sh" 2>/dev/null)"
32
+ assert_eq "no --confirmed: exit 1" 1 "$?"
33
+ assert_eq "no --confirmed: empty stdout" "" "$OUT"
34
+ grep -q '^## Round 1' "$NOFLAG/.devlog/devlog.md" && echo "PASS: no --confirmed leaves devlog.md untouched" || { echo "FAIL: devlog.md changed without --confirmed"; FAIL=1; }
35
+
36
+ # missing devlog.md -> exit 1, nothing created, no stdout
37
+ MISSING="$TMP/missing"
38
+ mkdir -p "$MISSING/.devlog"
39
+ export CLAUDE_PROJECT_DIR="$MISSING"
40
+ OUT="$(bash "$SCRIPT_DIR/clean-devlog.sh" --confirmed 2>/dev/null)"
41
+ assert_eq "missing log exit 1" 1 "$?"
42
+ assert_eq "missing log: empty stdout" "" "$OUT"
43
+ [ ! -e "$MISSING/.devlog/devlog.md" ] || { echo "FAIL: devlog.md created for missing case"; FAIL=1; }
44
+
45
+ # .round-open names a Round that isn't in the file -> refuse, don't touch anything
46
+ STALEOPEN="$TMP/staleopen"
47
+ mkdir -p "$STALEOPEN/.devlog"
48
+ export CLAUDE_PROJECT_DIR="$STALEOPEN"
49
+ printf '# project\n\n' > "$STALEOPEN/.devlog/devlog.md"
50
+ make_round "$STALEOPEN/.devlog/devlog.md" 1 DONE
51
+ make_round "$STALEOPEN/.devlog/devlog.md" 2 DONE
52
+ printf '%s\n' '{"round": 7, "opened_at": "now"}' > "$STALEOPEN/.devlog/.round-open"
53
+ OUT="$(bash "$SCRIPT_DIR/clean-devlog.sh" --confirmed 2>/dev/null)"
54
+ assert_eq "stale round-open: exit 1" 1 "$?"
55
+ assert_eq "stale round-open: empty stdout" "" "$OUT"
56
+ [ -f "$STALEOPEN/.devlog/devlog.md" ] && grep -q '^## Round 1' "$STALEOPEN/.devlog/devlog.md" && echo "PASS: stale round-open leaves devlog.md untouched" || { echo "FAIL: stale round-open touched devlog.md"; FAIL=1; }
57
+ grep -q '"round": 7' "$STALEOPEN/.devlog/.round-open" && echo "PASS: stale round-open file untouched" || { echo "FAIL: round-open file changed"; FAIL=1; }
58
+
59
+ # .round-open has an unparseable round while a real open Round exists -> refuse, don't touch anything
60
+ BADOPEN="$TMP/badopen"
61
+ mkdir -p "$BADOPEN/.devlog"
62
+ export CLAUDE_PROJECT_DIR="$BADOPEN"
63
+ printf '# project\n\n' > "$BADOPEN/.devlog/devlog.md"
64
+ make_round "$BADOPEN/.devlog/devlog.md" 1 IN_PROGRESS
65
+ printf '%s\n' '{"round": "abc", "opened_at": "now"}' > "$BADOPEN/.devlog/.round-open"
66
+ OUT="$(bash "$SCRIPT_DIR/clean-devlog.sh" --confirmed 2>/dev/null)"
67
+ assert_eq "unparseable round-open: exit 1" 1 "$?"
68
+ assert_eq "unparseable round-open: empty stdout" "" "$OUT"
69
+ grep -q '^## Round 1' "$BADOPEN/.devlog/devlog.md" && echo "PASS: unparseable round-open leaves the real open round in place" || { echo "FAIL: unparseable round-open deleted the open round"; FAIL=1; }
70
+
71
+ # open round: everything else discarded, this round renumbered to 1, body preserved verbatim
72
+ # (including a fenced fake heading, to prove the rename only touches the real heading line)
73
+ OPEN="$TMP/open"
74
+ mkdir -p "$OPEN/.devlog"
75
+ export CLAUDE_PROJECT_DIR="$OPEN"
76
+ printf '# project summary\n\nsome durable notes\n\n' > "$OPEN/.devlog/devlog.md"
77
+ make_round "$OPEN/.devlog/devlog.md" 1 DONE
78
+ make_round "$OPEN/.devlog/devlog.md" 2 DONE
79
+ {
80
+ printf '## Round 3 — 2026-09-09T12:00:00+08:00\n\n'
81
+ printf '### User Input\n```text\nplease do not touch this fake heading:\n## Round 1 fake\n## Round 99 — fake heading\n```\n\n'
82
+ printf '### Status\nIN_PROGRESS\n'
83
+ } > "$OPEN/.devlog/.round-current.md"
84
+ printf '%s\n' '{"round": 3, "opened_at": "now"}' > "$OPEN/.devlog/.round-open"
85
+ printf '%s\n' '{"round": 2, "ticks": 1, "max_silent_ticks": 5}' > "$OPEN/.devlog/.span-open"
86
+ printf '%s\n' '{"rounds_since_checkpoint": 19, "max_silent_rounds": 20, "checkpoint_marker_count": 2}' > "$OPEN/.devlog/.checkpoint-state"
87
+ : > "$OPEN/.devlog/.interrupted"
88
+ : > "$OPEN/.devlog/.awaiting-reply"
89
+ OUT="$(bash "$SCRIPT_DIR/clean-devlog.sh" --confirmed)"
90
+ assert_eq "open round: exit 0" 0 "$?"
91
+ assert_eq "open round: stdout" "CLEANED KEPT_ROUND=1" "$OUT"
92
+ # Round-current split invariant: the surviving round lands in
93
+ # .round-current.md (renumbered to Round 1), and devlog.md is emptied/gone
94
+ # — never the other way around (that would leave .round-open pointing at a
95
+ # round Stop/close-open-round.sh can no longer find).
96
+ [ ! -s "$OPEN/.devlog/devlog.md" ] && echo "PASS: devlog.md emptied/absent (project summary + old rounds discarded)" || { echo "FAIL: devlog.md still has content"; FAIL=1; }
97
+ grep -q '^## Round 1 ' "$OPEN/.devlog/.round-current.md" && echo "PASS: open round renumbered to 1" || { echo "FAIL: renumber"; FAIL=1; }
98
+ grep -q '^## Round 2 ' "$OPEN/.devlog/.round-current.md" && { echo "FAIL: old round 2 survived"; FAIL=1; } || echo "PASS: old rounds gone"
99
+ [ "$(devlog_list_round_starts "$OPEN/.devlog/.round-current.md" | wc -l | tr -d ' ')" = "1" ] && echo "PASS: exactly one real heading remains (fence-aware count)" || { echo "FAIL: heading count"; FAIL=1; }
100
+ grep -q '^please do not touch this fake heading:$' "$OPEN/.devlog/.round-current.md" && echo "PASS: fenced body preserved verbatim" || { echo "FAIL: fenced body altered"; FAIL=1; }
101
+ grep -q '^## Round 1 fake$' "$OPEN/.devlog/.round-current.md" && echo "PASS: fenced fake heading untouched" || { echo "FAIL: fenced fake heading renamed"; FAIL=1; }
102
+ grep -q '^## Round 99 — fake heading$' "$OPEN/.devlog/.round-current.md" && echo "PASS: second fenced fake heading untouched" || { echo "FAIL: second fenced fake heading renamed"; FAIL=1; }
103
+ [ -s "$OPEN/.devlog/.round-current.md" ] && echo "PASS: .round-current.md holds the renumbered round" || { echo "FAIL: .round-current.md empty/missing"; FAIL=1; }
104
+ grep -q '"round": 1' "$OPEN/.devlog/.round-open" && echo "PASS: round-open reset to 1" || { echo "FAIL: round-open"; FAIL=1; }
105
+ [ ! -e "$OPEN/.devlog/.span-open" ] && echo "PASS: span-open removed" || { echo "FAIL: span-open remains"; FAIL=1; }
106
+ [ ! -e "$OPEN/.devlog/.interrupted" ] && echo "PASS: interrupted removed" || { echo "FAIL: interrupted remains"; FAIL=1; }
107
+ [ ! -e "$OPEN/.devlog/.awaiting-reply" ] && echo "PASS: awaiting-reply removed" || { echo "FAIL: awaiting-reply remains"; FAIL=1; }
108
+ grep -q '"rounds_since_checkpoint": 0' "$OPEN/.devlog/.checkpoint-state" && echo "PASS: checkpoint rounds reset" || { echo "FAIL: checkpoint rounds"; FAIL=1; }
109
+ grep -q '"checkpoint_marker_count": 0' "$OPEN/.devlog/.checkpoint-state" && echo "PASS: checkpoint marker count reset" || { echo "FAIL: checkpoint marker count"; FAIL=1; }
110
+ grep -q '"max_silent_rounds": 20' "$OPEN/.devlog/.checkpoint-state" && echo "PASS: checkpoint max kept" || { echo "FAIL: checkpoint max"; FAIL=1; }
111
+ PERM="$(perm_of "$OPEN/.devlog/.round-current.md")"
112
+ [ "$PERM" != "600" ] && echo "PASS: rewritten .round-current.md is not owner-only (mode $PERM)" || { echo "FAIL: rewritten .round-current.md is 600"; FAIL=1; }
113
+
114
+ # no open round (never started / paused): whole file deleted
115
+ NOOPEN="$TMP/noopen"
116
+ mkdir -p "$NOOPEN/.devlog"
117
+ export CLAUDE_PROJECT_DIR="$NOOPEN"
118
+ printf '# project summary\n\n' > "$NOOPEN/.devlog/devlog.md"
119
+ make_round "$NOOPEN/.devlog/devlog.md" 1 DONE
120
+ make_round "$NOOPEN/.devlog/devlog.md" 2 DONE
121
+ printf '%s\n' '{"rounds_since_checkpoint": 5, "max_silent_rounds": 20, "checkpoint_marker_count": 1}' > "$NOOPEN/.devlog/.checkpoint-state"
122
+ printf '%s\n' '# archive' > "$NOOPEN/.devlog/devlog.archive.md"
123
+ printf '%s\n' '# kept' > "$NOOPEN/.devlog/devlog.span-mode.md"
124
+ printf '%s\n' 'enabled' > "$NOOPEN/.devlog/.enabled"
125
+ OUT="$(bash "$SCRIPT_DIR/clean-devlog.sh" --confirmed)"
126
+ assert_eq "no open round: exit 0" 0 "$?"
127
+ assert_eq "no open round: stdout" "CLEANED KEPT_ROUND=0" "$OUT"
128
+ [ ! -e "$NOOPEN/.devlog/devlog.md" ] && echo "PASS: devlog.md deleted" || { echo "FAIL: devlog.md remains"; FAIL=1; }
129
+ grep -q '"rounds_since_checkpoint": 0' "$NOOPEN/.devlog/.checkpoint-state" && echo "PASS: checkpoint rounds reset (no-open)" || { echo "FAIL: checkpoint rounds (no-open)"; FAIL=1; }
130
+ grep -q '"checkpoint_marker_count": 0' "$NOOPEN/.devlog/.checkpoint-state" && echo "PASS: checkpoint marker count reset (no-open)" || { echo "FAIL: checkpoint marker count (no-open)"; FAIL=1; }
131
+ grep -q '^# archive' "$NOOPEN/.devlog/devlog.archive.md" && echo "PASS: archive.md untouched (no-open)" || { echo "FAIL: archive.md touched (no-open)"; FAIL=1; }
132
+ grep -q '^# kept' "$NOOPEN/.devlog/devlog.span-mode.md" && echo "PASS: named keep file untouched (no-open)" || { echo "FAIL: named keep file touched (no-open)"; FAIL=1; }
133
+ [ -f "$NOOPEN/.devlog/.enabled" ] && echo "PASS: .enabled untouched (no-open)" || { echo "FAIL: .enabled removed (no-open)"; FAIL=1; }
134
+
135
+ # does not touch archive.md or named keep files (open-round branch)
136
+ SIB="$TMP/siblings"
137
+ mkdir -p "$SIB/.devlog"
138
+ export CLAUDE_PROJECT_DIR="$SIB"
139
+ printf '%s\n' '# archive' > "$SIB/.devlog/devlog.archive.md"
140
+ printf '%s\n' '# kept' > "$SIB/.devlog/devlog.span-mode.md"
141
+ printf '# project\n\n' > "$SIB/.devlog/devlog.md"
142
+ make_round "$SIB/.devlog/.round-current.md" 1 DONE
143
+ printf '%s\n' '{"round": 1, "opened_at": "now"}' > "$SIB/.devlog/.round-open"
144
+ bash "$SCRIPT_DIR/clean-devlog.sh" --confirmed >/dev/null
145
+ grep -q '^# archive' "$SIB/.devlog/devlog.archive.md" && echo "PASS: archive.md untouched" || { echo "FAIL: archive.md touched"; FAIL=1; }
146
+ grep -q '^# kept' "$SIB/.devlog/devlog.span-mode.md" && echo "PASS: named keep file untouched" || { echo "FAIL: named keep file touched"; FAIL=1; }
147
+
148
+ # .enabled is never touched either way
149
+ ENAB="$TMP/enabled"
150
+ mkdir -p "$ENAB/.devlog"
151
+ export CLAUDE_PROJECT_DIR="$ENAB"
152
+ printf '%s\n' 'enabled' > "$ENAB/.devlog/.enabled"
153
+ printf '# project\n\n' > "$ENAB/.devlog/devlog.md"
154
+ make_round "$ENAB/.devlog/.round-current.md" 1 IN_PROGRESS
155
+ printf '%s\n' '{"round": 1, "opened_at": "now"}' > "$ENAB/.devlog/.round-open"
156
+ bash "$SCRIPT_DIR/clean-devlog.sh" --confirmed >/dev/null
157
+ [ -f "$ENAB/.devlog/.enabled" ] && echo "PASS: .enabled untouched" || { echo "FAIL: .enabled removed"; FAIL=1; }
158
+
159
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
160
+ else echo "Some checks FAILED."; exit 1; fi
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
5
+ TMP="$(mktemp -d)"
6
+ trap 'rm -rf "$TMP"' EXIT
7
+ FAIL=0
8
+
9
+ command -v node >/dev/null 2>&1 || { echo "SKIP: node not installed"; exit 0; }
10
+
11
+ PROJECT="$TMP/project"
12
+ mkdir -p "$PROJECT"
13
+
14
+ cd "$PROJECT" || exit 1
15
+ NODE_BIN="$(command -v node)"
16
+ "$NODE_BIN" "$REPO_ROOT/bin/devlog-tracker.js" init --codex --cursor >/dev/null 2>"$TMP/stderr" \
17
+ || { echo "FAIL: init exited non-zero"; cat "$TMP/stderr"; FAIL=1; }
18
+
19
+ [ -f "$PROJECT/.devlog-tracker/VERSION" ] && echo "PASS: vendored VERSION exists" || { echo "FAIL: no VERSION"; FAIL=1; }
20
+ [ -f "$PROJECT/.devlog-tracker/env.sh" ] && echo "PASS: vendored env.sh exists" || { echo "FAIL: no env.sh"; FAIL=1; }
21
+ [ -f "$PROJECT/.codex/hooks.json" ] && echo "PASS: .codex/hooks.json written" || { echo "FAIL: no .codex/hooks.json"; FAIL=1; }
22
+ [ -f "$PROJECT/.cursor/hooks.json" ] && echo "PASS: .cursor/hooks.json written" || { echo "FAIL: no .cursor/hooks.json"; FAIL=1; }
23
+
24
+ # re-running must not duplicate entries
25
+ COUNT_BEFORE=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$PROJECT/.codex/hooks.json')).hooks.Stop.length)")
26
+ "$NODE_BIN" "$REPO_ROOT/bin/devlog-tracker.js" init --codex --cursor >/dev/null 2>"$TMP/stderr" \
27
+ || { echo "FAIL: second init exited non-zero"; cat "$TMP/stderr"; FAIL=1; }
28
+ COUNT_AFTER=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$PROJECT/.codex/hooks.json')).hooks.Stop.length)")
29
+ if [ "$COUNT_BEFORE" = "$COUNT_AFTER" ]; then
30
+ echo "PASS: re-running init does not duplicate hook entries"
31
+ else
32
+ echo "FAIL: hook entries duplicated ($COUNT_BEFORE -> $COUNT_AFTER)"; FAIL=1
33
+ fi
34
+
35
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
36
+ else echo "Some checks FAILED."; exit 1
37
+ fi
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for close-open-round.sh. Run:
3
+ # bash hooks/scripts/test-close-open-round.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
+ touch "$DEVLOG_DIR/.enabled"
14
+
15
+ FAIL=0
16
+ assert_exit() {
17
+ local desc="$1" expected="$2" actual="$3"
18
+ if [ "$actual" -eq "$expected" ]; then
19
+ echo "PASS: $desc"
20
+ else
21
+ echo "FAIL: $desc (expected exit $expected, got $actual)"
22
+ FAIL=1
23
+ fi
24
+ }
25
+ assert_contains() {
26
+ local desc="$1" needle="$2" haystack="$3"
27
+ case "$haystack" in
28
+ *"$needle"*) echo "PASS: $desc" ;;
29
+ *) echo "FAIL: $desc (missing: $needle)"; FAIL=1 ;;
30
+ esac
31
+ }
32
+ assert_not_contains() {
33
+ local desc="$1" needle="$2" haystack="$3"
34
+ case "$haystack" in
35
+ *"$needle"*) echo "FAIL: $desc (unexpected: $needle)"; FAIL=1 ;;
36
+ *) echo "PASS: $desc" ;;
37
+ esac
38
+ }
39
+ assert_file_absent() {
40
+ local desc="$1" path="$2"
41
+ if [ -f "$path" ]; then
42
+ echo "FAIL: $desc (file still present: $path)"
43
+ FAIL=1
44
+ else
45
+ echo "PASS: $desc"
46
+ fi
47
+ }
48
+
49
+ write_skeleton() {
50
+ : > "$DEVLOG_DIR/devlog.md"
51
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
52
+ ## Round 1 — 2026-09-09T12:00:00+08:00
53
+
54
+ ### User Input
55
+ ```text
56
+ hello
57
+ ```
58
+
59
+ ### Status
60
+ IN_PROGRESS
61
+ EOF
62
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
63
+ }
64
+
65
+ # --- 1: no .enabled -> no-op, .round-current.md untouched -------------------
66
+ rm -f "$DEVLOG_DIR/.enabled"
67
+ write_skeleton
68
+ BEFORE="$(cat "$DEVLOG_DIR/.round-current.md")"
69
+ bash "$SCRIPT_DIR/close-open-round.sh" "dangling:next_prompt"
70
+ assert_exit "no .enabled -> exit 0" 0 $?
71
+ AFTER="$(cat "$DEVLOG_DIR/.round-current.md")"
72
+ if [ "$BEFORE" = "$AFTER" ]; then
73
+ echo "PASS: no .enabled -> .round-current.md unchanged"
74
+ else
75
+ echo "FAIL: no .enabled should not edit .round-current.md"
76
+ FAIL=1
77
+ fi
78
+ touch "$DEVLOG_DIR/.enabled"
79
+
80
+ # --- 2: matching skeleton, never touched (not recovered) -> INTERRUPTED +
81
+ # "no record left" stub wording, merged into devlog.md, markers gone -
82
+ write_skeleton
83
+ touch "$DEVLOG_DIR/.interrupted"
84
+ bash "$SCRIPT_DIR/close-open-round.sh" "StopFailure:server_error"
85
+ assert_exit "matching open round -> exit 0" 0 $?
86
+ assert_file_absent "stamp: .round-current.md removed after merge" "$DEVLOG_DIR/.round-current.md"
87
+ BODY="$(cat "$DEVLOG_DIR/devlog.md")"
88
+ assert_contains "status INTERRUPTED" $'### Status\nINTERRUPTED' "$BODY"
89
+ assert_contains "reason line" "StopFailure:server_error" "$BODY"
90
+ assert_contains "summary stub (not recovered)" "devlog.md 沒有留下任何後續處理紀錄就結束了" "$BODY"
91
+ assert_contains "handoff stub heading" "#### 現況" "$BODY"
92
+ assert_contains "handoff stub body (not recovered)" "這一輪沒有留下任何處理紀錄" "$BODY"
93
+ assert_contains "user input kept" "hello" "$BODY"
94
+
95
+ # --- 2b: skeleton + a segment was recorded (recovered) but Summary/Handoff
96
+ # still missing -> INTERRUPTED + the old "interrupted mid-way" stub --
97
+ write_skeleton
98
+ cksum < "$DEVLOG_DIR/devlog.md" > "$DEVLOG_DIR/.turn-start"
99
+ printf '\n### 段落 1 - 12:01\n```text\nsome progress\n```\n\n' >> "$DEVLOG_DIR/devlog.md"
100
+ bash "$SCRIPT_DIR/close-open-round.sh" "user_interrupt"
101
+ BODY="$(cat "$DEVLOG_DIR/devlog.md")"
102
+ assert_contains "recovered summary stub" "這輪意外中斷。" "$BODY"
103
+ assert_contains "recovered handoff stub" "這輪沒有正常收尾。" "$BODY"
104
+ assert_contains "recovered segment kept" "some progress" "$BODY"
105
+ if [ -f "$DEVLOG_DIR/.round-open" ] || [ -f "$DEVLOG_DIR/.interrupted" ]; then
106
+ echo "FAIL: markers should be deleted after a successful stamp"
107
+ FAIL=1
108
+ else
109
+ echo "PASS: .round-open and .interrupted deleted"
110
+ fi
111
+
112
+ # --- 3: already has headings -> do not duplicate stubs, still stamp+merge ---
113
+ : > "$DEVLOG_DIR/devlog.md"
114
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
115
+ ## Round 1 — 2026-09-09T12:00:00+08:00
116
+
117
+ ### User Input
118
+ ```text
119
+ hello
120
+ ```
121
+
122
+ ### Summary
123
+ partial
124
+
125
+ ### Reply
126
+ fixture reply.
127
+
128
+ ### Handoff
129
+ #### 現況
130
+ partial
131
+
132
+ ### Status
133
+ IN_PROGRESS
134
+ EOF
135
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
136
+ rm -f "$DEVLOG_DIR/.turn-start"
137
+ bash "$SCRIPT_DIR/close-open-round.sh" "user_interrupt"
138
+ assert_file_absent "existing-headings: .round-current.md removed after merge" "$DEVLOG_DIR/.round-current.md"
139
+ BODY="$(cat "$DEVLOG_DIR/devlog.md")"
140
+ assert_contains "kept existing summary" "partial" "$BODY"
141
+ COUNT="$(printf '%s\n' "$BODY" | grep -c '^### Summary' || true)"
142
+ if [ "$COUNT" = "1" ]; then
143
+ echo "PASS: did not add a second ### Summary"
144
+ else
145
+ echo "FAIL: expected one ### Summary, got $COUNT"
146
+ FAIL=1
147
+ fi
148
+ assert_contains "stamped interrupt" "INTERRUPTED" "$BODY"
149
+ assert_contains "user_interrupt reason" "user_interrupt" "$BODY"
150
+
151
+ # --- 4: no .round-open -> delete .interrupted, do not touch either file -----
152
+ write_skeleton
153
+ rm -f "$DEVLOG_DIR/.round-open"
154
+ touch "$DEVLOG_DIR/.interrupted"
155
+ BEFORE_CUR="$(cat "$DEVLOG_DIR/.round-current.md")"
156
+ BEFORE_MAIN="$(cat "$DEVLOG_DIR/devlog.md")"
157
+ bash "$SCRIPT_DIR/close-open-round.sh" "SessionEnd:other"
158
+ AFTER_CUR="$(cat "$DEVLOG_DIR/.round-current.md")"
159
+ AFTER_MAIN="$(cat "$DEVLOG_DIR/devlog.md")"
160
+ if [ "$BEFORE_CUR" = "$AFTER_CUR" ] && [ "$BEFORE_MAIN" = "$AFTER_MAIN" ]; then
161
+ echo "PASS: missing .round-open -> nothing merged or edited"
162
+ else
163
+ echo "FAIL: missing .round-open touched a file"
164
+ FAIL=1
165
+ fi
166
+ if [ -f "$DEVLOG_DIR/.interrupted" ]; then
167
+ echo "FAIL: missing .round-open should still delete .interrupted"
168
+ FAIL=1
169
+ else
170
+ echo "PASS: missing .round-open deleted .interrupted"
171
+ fi
172
+ rm -f "$DEVLOG_DIR/.round-current.md"
173
+
174
+ # --- 4b: malformed .round-open -> delete both markers, do not touch either --
175
+ write_skeleton
176
+ printf '%s\n' 'not valid json' > "$DEVLOG_DIR/.round-open"
177
+ touch "$DEVLOG_DIR/.interrupted"
178
+ BEFORE_CUR="$(cat "$DEVLOG_DIR/.round-current.md")"
179
+ BEFORE_MAIN="$(cat "$DEVLOG_DIR/devlog.md")"
180
+ bash "$SCRIPT_DIR/close-open-round.sh" "SessionEnd:other"
181
+ AFTER_CUR="$(cat "$DEVLOG_DIR/.round-current.md")"
182
+ AFTER_MAIN="$(cat "$DEVLOG_DIR/devlog.md")"
183
+ if [ "$BEFORE_CUR" = "$AFTER_CUR" ] && [ "$BEFORE_MAIN" = "$AFTER_MAIN" ]; then
184
+ echo "PASS: malformed .round-open -> nothing merged or edited"
185
+ else
186
+ echo "FAIL: malformed .round-open touched a file"
187
+ FAIL=1
188
+ fi
189
+ if [ -f "$DEVLOG_DIR/.interrupted" ] || [ -f "$DEVLOG_DIR/.round-open" ]; then
190
+ echo "FAIL: malformed .round-open should delete both markers"
191
+ FAIL=1
192
+ else
193
+ echo "PASS: malformed .round-open deleted both markers"
194
+ fi
195
+ rm -f "$DEVLOG_DIR/.round-current.md"
196
+
197
+ # --- 5: prior finished history in devlog.md is preserved across the merge --
198
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF'
199
+ ## Round 1 — 2026-09-09T11:00:00+08:00
200
+
201
+ ### Summary
202
+ old
203
+
204
+ ### Reply
205
+ fixture reply.
206
+
207
+ ### Handoff
208
+ #### 現況
209
+ old
210
+
211
+ ### Status
212
+ DONE
213
+
214
+ ## Checkpoint(Round 1 摘要)
215
+ keep me
216
+ EOF
217
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
218
+ ## Round 2 — 2026-09-09T12:00:00+08:00
219
+
220
+ ### User Input
221
+ ```text
222
+ new
223
+ ```
224
+
225
+ ### Status
226
+ IN_PROGRESS
227
+ EOF
228
+ printf '%s\n' '{"round": 2, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
229
+ bash "$SCRIPT_DIR/close-open-round.sh" "SessionEnd:clear"
230
+ assert_file_absent "prior-history: .round-current.md removed after merge" "$DEVLOG_DIR/.round-current.md"
231
+ BODY="$(cat "$DEVLOG_DIR/devlog.md")"
232
+ assert_contains "round 1 stays DONE" $'### Status\nDONE' "$BODY"
233
+ assert_contains "round 2 interrupted" "INTERRUPTED" "$BODY"
234
+ assert_contains "checkpoint kept" "keep me" "$BODY"
235
+
236
+ # --- 6: headings present but hash equal -> still stamp (no recovery proof) --
237
+ # (main's independent "completed last Round + hash moved -> do not stamp"
238
+ # fixture, which asserted devlog.md stayed byte-for-byte unchanged, tested a
239
+ # pre-merge assumption this branch deliberately changes: a recovered round
240
+ # now always gets merged into devlog.md rather than left in place untouched.
241
+ # That property is covered below by scenario 8 ("recovered case also
242
+ # merges"), which asserts the merge instead of asserting no-op.)
243
+ : > "$DEVLOG_DIR/devlog.md"
244
+ cat > "$DEVLOG_DIR/.round-current.md" <<'EOF'
245
+ ## Round 1 — 2026-09-09T12:00:00+08:00
246
+
247
+ ### User Input
248
+ ```text
249
+ hello
250
+ ```
251
+
252
+ ### Summary
253
+ partial
254
+
255
+ ### Reply
256
+ fixture reply.
257
+
258
+ ### Handoff
259
+ #### 現況
260
+ partial
261
+
262
+ ### Status
263
+ IN_PROGRESS
264
+ EOF
265
+ printf '%s\n' '{"round": 1, "opened_at": "2026-09-09T12:00:00+08:00"}' > "$DEVLOG_DIR/.round-open"
266
+ cksum < "$DEVLOG_DIR/.round-current.md" > "$DEVLOG_DIR/.turn-start"
267
+ bash "$SCRIPT_DIR/close-open-round.sh" "user_interrupt"
268
+ assert_file_absent "hash-equal: .round-current.md removed after merge" "$DEVLOG_DIR/.round-current.md"
269
+ BODY="$(cat "$DEVLOG_DIR/devlog.md")"
270
+ assert_contains "hash-equal headings still stamp" "INTERRUPTED" "$BODY"
271
+ assert_contains "hash-equal reason" "user_interrupt" "$BODY"
272
+
273
+ # --- 7: stamp-then-merge, prior round in devlog.md preserved ---------------
274
+ STAMP_DIR="$(mktemp -d)"
275
+ export CLAUDE_PROJECT_DIR="$STAMP_DIR"
276
+ mkdir -p "$STAMP_DIR/.devlog"
277
+ touch "$STAMP_DIR/.devlog/.enabled"
278
+ printf '## Round 7 — 2026-09-17T09:00:00+0800\n\n### User Input\n```text\ndo X\n```\n\n### Status\nIN_PROGRESS\n' > "$STAMP_DIR/.devlog/devlog.md"
279
+ printf '## Round 8 — 2026-09-17T09:30:00+0800\n\n### User Input\n```text\ndo Y\n```\n\n### Status\nIN_PROGRESS\n' > "$STAMP_DIR/.devlog/.round-current.md"
280
+ printf '{"round": 8}\n' > "$STAMP_DIR/.devlog/.round-open"
281
+ cksum < "$STAMP_DIR/.devlog/.round-current.md" > "$STAMP_DIR/.devlog/.turn-start"
282
+
283
+ bash "$SCRIPT_DIR/close-open-round.sh" "test_reason"
284
+
285
+ assert_file_absent "stamp: .round-current.md removed after merge" "$STAMP_DIR/.devlog/.round-current.md"
286
+ MAIN_AFTER="$(cat "$STAMP_DIR/.devlog/devlog.md")"
287
+ assert_contains "stamp: round 7 (prior history) still present" "## Round 7" "$MAIN_AFTER"
288
+ assert_contains "stamp: round 8 merged into devlog.md" "## Round 8" "$MAIN_AFTER"
289
+ assert_contains "stamp: INTERRUPTED status merged in" "INTERRUPTED" "$MAIN_AFTER"
290
+ assert_contains "stamp: reason tag merged in" "[reason: test_reason]" "$MAIN_AFTER"
291
+ rm -rf "$STAMP_DIR"
292
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
293
+
294
+ # --- 8: recovered case also merges (Claude finished before the signal) -----
295
+ REC_DIR="$(mktemp -d)"
296
+ export CLAUDE_PROJECT_DIR="$REC_DIR"
297
+ mkdir -p "$REC_DIR/.devlog"
298
+ touch "$REC_DIR/.devlog/.enabled"
299
+ : > "$REC_DIR/.devlog/devlog.md"
300
+ printf '## Round 1 — 2026-09-17T09:00:00+0800\n\n### User Input\n```text\ndo X\n```\n\n### Summary\n完成了\n\n### Handoff\n#### 現況\n收尾\n\n### Status\nDONE\n' > "$REC_DIR/.devlog/.round-current.md"
301
+ printf '{"round": 1}\n' > "$REC_DIR/.devlog/.round-open"
302
+ echo "stale-hash-that-does-not-match" > "$REC_DIR/.devlog/.turn-start"
303
+
304
+ bash "$SCRIPT_DIR/close-open-round.sh" "user_interrupt"
305
+
306
+ assert_file_absent "recovered: .round-current.md removed after merge" "$REC_DIR/.devlog/.round-current.md"
307
+ MAIN_AFTER="$(cat "$REC_DIR/.devlog/devlog.md")"
308
+ assert_contains "recovered: finished round merged into devlog.md" "完成了" "$MAIN_AFTER"
309
+ assert_not_contains "recovered: no INTERRUPTED stamped over real content" "INTERRUPTED" "$MAIN_AFTER"
310
+ if [ -f "$REC_DIR/.devlog/.round-open" ]; then
311
+ echo "FAIL: recovered case should still delete .round-open"
312
+ FAIL=1
313
+ else
314
+ echo "PASS: recovered case deleted .round-open"
315
+ fi
316
+ rm -rf "$REC_DIR"
317
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
318
+
319
+ if [ "$FAIL" -eq 0 ]; then
320
+ echo "All checks passed."
321
+ exit 0
322
+ else
323
+ echo "Some checks FAILED."
324
+ exit 1
325
+ fi
@@ -0,0 +1,62 @@
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
+ export CLAUDE_PROJECT_DIR="$TMP"
7
+ mkdir -p "$TMP/.devlog"
8
+ FAIL=0
9
+ assert_eq() {
10
+ local d="$1" e="$2" a="$3"
11
+ if [ "$e" = "$a" ]; then echo "PASS: $d"; else echo "FAIL: $d expected [$e] got [$a]"; FAIL=1; fi
12
+ }
13
+
14
+ bash "$SCRIPT_DIR/compact-devlog.sh" >/dev/null
15
+ assert_eq "missing log exit 1" "1" "$?"
16
+
17
+ round() {
18
+ local n="$1" st="$2"
19
+ cat >> "$TMP/.devlog/devlog.md" <<EOF
20
+ ## Round ${n} — 2026-09-09T12:00:00+08:00
21
+
22
+ ### Summary
23
+ s${n}
24
+
25
+ ### Reply
26
+ fixture reply.
27
+
28
+ ### Handoff
29
+ #### 現況
30
+ h${n}
31
+
32
+ ### Status
33
+ ${st}
34
+ EOF
35
+ }
36
+
37
+ : > "$TMP/.devlog/devlog.md"
38
+ printf '%s\n\n' '# preamble' >> "$TMP/.devlog/devlog.md"
39
+ n=1
40
+ while [ "$n" -le 8 ]; do round "$n" DONE; n=$((n+1)); done
41
+ round 9 IN_PROGRESS
42
+ cat >> "$TMP/.devlog/devlog.md" <<'EOF'
43
+ ## Checkpoint(Round 1-3 摘要)
44
+ cp body
45
+ EOF
46
+
47
+ OUT="$(bash "$SCRIPT_DIR/compact-devlog.sh")"
48
+ assert_eq "compact exit 0" "0" "$?"
49
+ echo "$OUT" | grep -q 'MOVED=3' && echo "PASS: moved 1-3" || { echo "FAIL: $OUT"; FAIL=1; }
50
+ grep -q '# preamble' "$TMP/.devlog/devlog.md" && echo "PASS: preamble stays" || { echo "FAIL: preamble"; FAIL=1; }
51
+ grep -q '## Checkpoint' "$TMP/.devlog/devlog.md" && echo "PASS: checkpoint stays" || { echo "FAIL: cp"; FAIL=1; }
52
+ grep -q '## Round 9' "$TMP/.devlog/devlog.md" && echo "PASS: in-progress stays" || { echo "FAIL: r9"; FAIL=1; }
53
+ grep -q '## Round 4' "$TMP/.devlog/devlog.md" && echo "PASS: last-5 keep 4" || { echo "FAIL: r4"; FAIL=1; }
54
+ grep -q '## Round 1' "$TMP/.devlog/devlog.archive.md" && echo "PASS: archive has 1" || { echo "FAIL: archive"; FAIL=1; }
55
+ grep -q '## Round 9' "$TMP/.devlog/devlog.archive.md" && { echo "FAIL: archived in-progress"; FAIL=1; } || echo "PASS: did not archive 9"
56
+ test -f "$TMP/.devlog/devlog.foo.md" && { echo "FAIL: touched keep file"; FAIL=1; } || echo "PASS: no keep file"
57
+
58
+ OUT2="$(bash "$SCRIPT_DIR/compact-devlog.sh")"
59
+ echo "$OUT2" | grep -q 'MOVED=0' && echo "PASS: second compact moves 0" || { echo "FAIL: $OUT2"; FAIL=1; }
60
+
61
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
62
+ else echo "Some checks FAILED."; exit 1; fi
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4
+ . "$SCRIPT_DIR/devlog-lock.sh"
5
+ TMP="$(mktemp -d)"
6
+ trap 'rm -rf "$TMP"' EXIT
7
+ DEVLOG_DIR="$TMP/.devlog"
8
+ mkdir -p "$DEVLOG_DIR"
9
+ FAIL=0
10
+
11
+ devlog_lock_acquire
12
+ [ "${LOCK_HELD:-0}" -eq 1 ] && [ -d "$DEVLOG_DIR/.lock" ] \
13
+ && echo "PASS: acquired" || { echo "FAIL: acquire"; FAIL=1; }
14
+ mkdir "$DEVLOG_DIR/.lock" >/dev/null 2>&1
15
+ [ "$?" -ne 0 ] && echo "PASS: second mkdir excluded" || { echo "FAIL: duplicate mkdir"; FAIL=1; }
16
+ devlog_lock_release
17
+ devlog_lock_acquire
18
+ [ "${LOCK_HELD:-0}" -eq 1 ] && echo "PASS: reacquired after release" || { echo "FAIL: reacquire"; FAIL=1; }
19
+ devlog_lock_release
20
+
21
+ mkdir "$DEVLOG_DIR/.lock"
22
+ START="$(date +%s)"
23
+ devlog_lock_acquire
24
+ END="$(date +%s)"
25
+ ELAPSED=$((END - START))
26
+ [ "${LOCK_HELD:-0}" -eq 0 ] && [ "$ELAPSED" -ge 2 ] && [ "$ELAPSED" -le 3 ] \
27
+ && echo "PASS: contention fails open" || { echo "FAIL: contention held=$LOCK_HELD elapsed=$ELAPSED"; FAIL=1; }
28
+ rmdir "$DEVLOG_DIR/.lock"
29
+
30
+ MIN_PATH="$TMP/min-path"
31
+ mkdir "$MIN_PATH"
32
+ ln -s "$(command -v bash)" "$MIN_PATH/bash"
33
+ HELD="$(PATH="$MIN_PATH" bash -c '. "$1"; DEVLOG_DIR="$2"; devlog_lock_acquire; echo "${LOCK_HELD:-0}"' _ "$SCRIPT_DIR/devlog-lock.sh" "$DEVLOG_DIR")"
34
+ [ "$HELD" = "0" ] && echo "PASS: missing lock tools fails open" || { echo "FAIL: restricted path held=$HELD"; FAIL=1; }
35
+
36
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
37
+ else echo "Some checks FAILED."; exit 1; fi