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,28 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ _src="${BASH_SOURCE[0]}"
4
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
5
+ PLUGIN_SCRIPTS="$(cd "$SCRIPT_DIR/../../hooks/scripts" && pwd)"
6
+ INPUT="$(cat 2>/dev/null || true)"
7
+ ROOT="$(printf '%s' "$INPUT" | bash "$SCRIPT_DIR/project-dir.sh")"
8
+ export CLAUDE_PROJECT_DIR="$ROOT"
9
+ PROMPT=""
10
+ SESSION_ID=""
11
+ if command -v jq >/dev/null 2>&1; then
12
+ PROMPT="$(printf '%s' "$INPUT" | jq -r '.prompt // empty' 2>/dev/null || true)"
13
+ SESSION_ID="$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || true)"
14
+ PAYLOAD="$(jq -n --arg prompt "$PROMPT" --arg session_id "$SESSION_ID" '{prompt:$prompt,session_id:$session_id}')"
15
+ else
16
+ PROMPT="$(printf '%s' "$INPUT" | grep -o '"prompt"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"prompt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)"
17
+ SESSION_ID="$(printf '%s' "$INPUT" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)"
18
+ # Escape only what we embed; prompts with quotes remain a known no-jq limitation.
19
+ PAYLOAD="$(printf '{"prompt":"%s","session_id":"%s"}' "$PROMPT" "$SESSION_ID")"
20
+ fi
21
+ NOTE="$(printf '%s' "$PAYLOAD" | bash "$PLUGIN_SCRIPTS/round-start.sh" 2>/dev/null || true)"
22
+ if command -v jq >/dev/null 2>&1; then
23
+ jq -cn --arg n "$NOTE" '{continue:true, additional_context:$n}'
24
+ else
25
+ esc="$(printf '%s' "$NOTE" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | awk '{printf "%s\\n", $0}' | sed '$ s/\\n$//')"
26
+ printf '{"continue":true,"additional_context":"%s"}\n' "$esc"
27
+ fi
28
+ exit 0
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ _src="${BASH_SOURCE[0]}"
4
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
5
+ PLUGIN_SCRIPTS="$(cd "$SCRIPT_DIR/../../hooks/scripts" && pwd)"
6
+ INPUT="$(cat 2>/dev/null || true)"
7
+ ROOT="$(printf '%s' "$INPUT" | bash "$SCRIPT_DIR/project-dir.sh")"
8
+ export CLAUDE_PROJECT_DIR="$ROOT"
9
+ if command -v jq >/dev/null 2>&1; then
10
+ IS_INTERRUPT="$(printf '%s' "$INPUT" | jq -r '.is_interrupt // .tool_result.is_interrupt // false' 2>/dev/null || echo false)"
11
+ else
12
+ case "$INPUT" in *'"is_interrupt":true'*|*'"is_interrupt": true'*) IS_INTERRUPT=true ;; *) IS_INTERRUPT=false ;; esac
13
+ fi
14
+ printf '{"is_interrupt":%s}' "$IS_INTERRUPT" | bash "$PLUGIN_SCRIPTS/on-tool-failure.sh" >/dev/null 2>&1 || true
15
+ echo '{}'
16
+ exit 0
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ INPUT="$(cat 2>/dev/null || true)"
4
+ ROOT=""
5
+ if command -v jq >/dev/null 2>&1; then
6
+ ROOT="$(printf '%s' "$INPUT" | jq -r '.workspace_roots[0] // empty' 2>/dev/null || true)"
7
+ else
8
+ ROOT="$(printf '%s' "$INPUT" | grep -o '"workspace_roots"[[:space:]]*:[[:space:]]*\["[^"]*"' 2>/dev/null | head -1 | sed 's/.*"workspace_roots"[[:space:]]*:[[:space:]]*\["\([^"]*\)".*/\1/' || true)"
9
+ fi
10
+ printf '%s\n' "${ROOT:-.}"
@@ -0,0 +1,201 @@
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
+ assert_single_json() {
8
+ local desc="$1" json="$2" count
9
+ if command -v jq >/dev/null 2>&1; then
10
+ count="$(printf '%s' "$json" | jq -s 'length' 2>/dev/null || echo '')"
11
+ else
12
+ # No jq: count top-level {...} closures ourselves, quote/escape-aware so
13
+ # a brace inside a string value doesn't throw the depth count off.
14
+ count="$(printf '%s' "$json" | awk '
15
+ {
16
+ line = $0
17
+ for (i = 1; i <= length(line); i++) {
18
+ c = substr(line, i, 1)
19
+ if (esc) { esc = 0; continue }
20
+ if (c == "\\" && in_str) { esc = 1; continue }
21
+ if (c == "\"") { in_str = !in_str; continue }
22
+ if (in_str) continue
23
+ if (c == "{") depth++
24
+ else if (c == "}") { depth--; if (depth == 0) n_obj++ }
25
+ }
26
+ }
27
+ END { print n_obj + 0 }
28
+ ' 2>/dev/null || echo '')"
29
+ fi
30
+ if [ "$count" = "1" ]; then
31
+ echo "PASS: $desc"
32
+ else
33
+ echo "FAIL: $desc (expected 1 top-level JSON value, got [$count]) [$json]"; FAIL=1
34
+ fi
35
+ }
36
+ mkdir -p "$TMP/project/.devlog"
37
+ cat > "$TMP/project/.devlog/devlog.md" <<'EOF'
38
+ ## Round 1 — now
39
+
40
+ ### Summary
41
+ done
42
+
43
+ ### Handoff
44
+ #### 現況
45
+ done
46
+
47
+ ### Status
48
+ DONE
49
+ EOF
50
+ OUT="$(printf '{"workspace_roots":["%s"]}' "$TMP/project" | bash "$SCRIPT_DIR/on-session-start.sh")"
51
+ case "$OUT" in *'"additional_context"'*"Round 1"*) echo "PASS: sessionStart context" ;; *) echo "FAIL: sessionStart [$OUT]"; FAIL=1 ;; esac
52
+ assert_single_json "sessionStart single json" "$OUT"
53
+ EMPTY="$(printf '{}' | bash "$SCRIPT_DIR/on-session-start.sh")"
54
+ case "$EMPTY" in \{*\}) echo "PASS: empty workspace valid json" ;; *) echo "FAIL: empty json [$EMPTY]"; FAIL=1 ;; esac
55
+ assert_single_json "empty workspace single json" "$EMPTY"
56
+
57
+ # submit prompt
58
+ SUBMIT="$TMP/submit"
59
+ mkdir -p "$SUBMIT/.devlog"
60
+ touch "$SUBMIT/.devlog/.enabled"
61
+ OUT="$(printf '{"workspace_roots":["%s"],"prompt":"hello","session_id":"cursor-1"}' "$SUBMIT" | bash "$SCRIPT_DIR/on-submit-prompt.sh")"
62
+ case "$OUT" in *'"continue":true'*) echo "PASS: submit continues" ;; *) echo "FAIL: submit [$OUT]"; FAIL=1 ;; esac
63
+ assert_single_json "submit single json" "$OUT"
64
+ grep -q 'hello' "$SUBMIT/.devlog/.round-current.md" && echo "PASS: submit writes round" || { echo "FAIL: submit round"; FAIL=1; }
65
+ ROUNDS="$(grep -c '^## Round ' "$SUBMIT/.devlog/.round-current.md" || true)"
66
+ if [ "$ROUNDS" -eq 1 ]; then echo "PASS: submit writes one round"; else echo "FAIL: submit round count [$ROUNDS]"; FAIL=1; fi
67
+
68
+ # submit prompt with stale 工作區 forwards additional_context
69
+ MIS="$TMP/mismatch-submit"
70
+ mkdir -p "$MIS/.devlog"
71
+ touch "$MIS/.devlog/.enabled"
72
+ git -C "$MIS" init -q -b main
73
+ git -C "$MIS" config user.email test@example.com
74
+ git -C "$MIS" config user.name test
75
+ echo hello > "$MIS/a.txt"
76
+ git -C "$MIS" add a.txt
77
+ git -C "$MIS" commit -q -m init
78
+ cat > "$MIS/.devlog/devlog.md" <<'EOF'
79
+ ## Round 1 — 2026-09-11T00:00:00+08:00
80
+
81
+ ### Handoff
82
+ #### 工作區
83
+ main @ deadbeef,工作樹乾淨
84
+ #### 現況
85
+ going
86
+ #### 下一步
87
+ do x
88
+
89
+ ### Status
90
+ IN_PROGRESS
91
+ EOF
92
+ OUT="$(printf '{"workspace_roots":["%s"],"prompt":"keep going","session_id":"cursor-mismatch"}' "$MIS" | bash "$SCRIPT_DIR/on-submit-prompt.sh")"
93
+ case "$OUT" in *'"continue":true'*) echo "PASS: mismatch submit continues" ;; *) echo "FAIL: mismatch submit [$OUT]"; FAIL=1 ;; esac
94
+ case "$OUT" in *'"additional_context"'*工作區*) echo "PASS: mismatch submit additional_context" ;; *) echo "FAIL: mismatch context [$OUT]"; FAIL=1 ;; esac
95
+ assert_single_json "mismatch submit single json" "$OUT"
96
+ [ -f "$MIS/.devlog/.workspace-mismatch" ] && echo "PASS: mismatch submit writes marker" || { echo "FAIL: no cursor marker"; FAIL=1; }
97
+
98
+ # preToolUse allowlists writes to the devlog and denies expired other tools
99
+ NOW="$(date +%s)"
100
+ OLD=$((NOW - 1000))
101
+ SUM="$(cksum < "$SUBMIT/.devlog/.round-current.md" | tr -d '\n')"
102
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "%s", "max_silent_seconds": 900, "session_id": "cursor-1"}\n' "$OLD" "$SUM" > "$SUBMIT/.devlog/.segment-state"
103
+ OUT="$(printf '{"workspace_roots":["%s"],"tool_name":"Write","tool_input":{"file_path":"%s/.devlog/.round-current.md"},"session_id":"cursor-1"}' "$SUBMIT" "$SUBMIT" | bash "$SCRIPT_DIR/on-pre-tool.sh")"
104
+ case "$OUT" in *'"permission":"deny"'*) echo "FAIL: devlog write denied"; FAIL=1 ;; *) echo "PASS: devlog write allowed" ;; esac
105
+ assert_single_json "preTool write single json" "$OUT"
106
+ OUT="$(printf '{"workspace_roots":["%s"],"tool_name":"StrReplace","tool_input":{"file_path":"%s/.devlog/.round-current.md"},"session_id":"cursor-1"}' "$SUBMIT" "$SUBMIT" | bash "$SCRIPT_DIR/on-pre-tool.sh")"
107
+ case "$OUT" in *'"permission":"deny"'*) echo "FAIL: StrReplace devlog denied"; FAIL=1 ;; *) echo "PASS: StrReplace devlog allowed" ;; esac
108
+ assert_single_json "preTool StrReplace single json" "$OUT"
109
+ OUT="$(printf '{"workspace_roots":["%s"],"tool_name":"Bash","tool_input":{},"session_id":"cursor-1"}' "$SUBMIT" | bash "$SCRIPT_DIR/on-pre-tool.sh")"
110
+ case "$OUT" in *permission*deny*"### 段落"*) echo "PASS: expired tool denied" ;; *) echo "FAIL: preTool [$OUT]"; FAIL=1 ;; esac
111
+ assert_single_json "preTool deny single json" "$OUT"
112
+
113
+ # stop: incomplete round asks Cursor for one follow-up; loop_count prevents retry
114
+ OUT="$(printf '{"workspace_roots":["%s"],"status":"completed","loop_count":0}' "$SUBMIT" | bash "$SCRIPT_DIR/on-stop.sh")"
115
+ case "$OUT" in *'"followup_message"'*"Summary"*) echo "PASS: stop followup" ;; *) echo "FAIL: stop [$OUT]"; FAIL=1 ;; esac
116
+ assert_single_json "stop followup single json" "$OUT"
117
+ OUT="$(printf '{"workspace_roots":["%s"],"status":"completed","loop_count":1}' "$SUBMIT" | bash "$SCRIPT_DIR/on-stop.sh")"
118
+ case "$OUT" in *followup_message*) echo "FAIL: loop guard followup"; FAIL=1 ;; *) echo "PASS: stop loop guard" ;; esac
119
+ assert_single_json "stop loop guard single json" "$OUT"
120
+ OUT="$(printf '{"workspace_roots":["%s"],"status":"running","loop_count":0}' "$SUBMIT" | bash "$SCRIPT_DIR/on-stop.sh")"
121
+ case "$OUT" in *followup_message*) echo "FAIL: non-completed stop enforced [$OUT]"; FAIL=1 ;; *) echo "PASS: non-completed stop skipped" ;; esac
122
+ assert_single_json "non-completed stop single json" "$OUT"
123
+
124
+ # aborted stop and session end close the open round
125
+ printf '%s\n' '{"round": 1, "opened_at": "now"}' > "$SUBMIT/.devlog/.round-open"
126
+ OUT="$(printf '{"workspace_roots":["%s"],"status":"aborted"}' "$SUBMIT" | bash "$SCRIPT_DIR/on-stop.sh")"
127
+ grep -q 'INTERRUPTED' "$SUBMIT/.devlog/devlog.md" && echo "PASS: aborted closes round" || { echo "FAIL: aborted"; FAIL=1; }
128
+ assert_single_json "aborted stop single json" "$OUT"
129
+
130
+ # sessionEnd delegates its reason to the existing close helper
131
+ printf '\n## Round 2 — now\n\n### Status\nIN_PROGRESS\n' > "$SUBMIT/.devlog/.round-current.md"
132
+ printf '%s\n' '{"round": 2, "opened_at": "now"}' > "$SUBMIT/.devlog/.round-open"
133
+ cksum < "$SUBMIT/.devlog/.round-current.md" > "$SUBMIT/.devlog/.turn-start"
134
+ OUT="$(printf '{"workspace_roots":["%s"],"reason":"windowClosed"}' "$SUBMIT" | bash "$SCRIPT_DIR/on-session-end.sh")"
135
+ grep -q 'SessionEnd:windowClosed' "$SUBMIT/.devlog/devlog.md" && echo "PASS: session end reason" || { echo "FAIL: session end"; FAIL=1; }
136
+ assert_single_json "sessionEnd single json" "$OUT"
137
+
138
+ # project-dir without jq still reads workspace_roots[0]
139
+ PATH_NO_JQ="$TMP/no-jq-path"
140
+ mkdir -p "$PATH_NO_JQ"
141
+ for bin in bash cat printf grep sed head mktemp rm date cksum awk tr mkdir; do
142
+ bin_path="$(command -v "$bin" 2>/dev/null || true)"
143
+ [ -n "$bin_path" ] && ln -sf "$bin_path" "$PATH_NO_JQ/$bin"
144
+ done
145
+ ROOT="$(printf '{"workspace_roots":["%s"]}' "$SUBMIT" | PATH="$PATH_NO_JQ" bash "$SCRIPT_DIR/project-dir.sh")"
146
+ if [ "$ROOT" = "$SUBMIT" ]; then echo "PASS: project-dir without jq"; else echo "FAIL: project-dir without jq [$ROOT]"; FAIL=1; fi
147
+
148
+ # tool interruption marker
149
+ rm -f "$SUBMIT/.devlog/.interrupted"
150
+ OUT="$(printf '{"workspace_roots":["%s"],"is_interrupt":true}' "$SUBMIT" | bash "$SCRIPT_DIR/on-tool-failure.sh")"
151
+ [ -f "$SUBMIT/.devlog/.interrupted" ] && echo "PASS: tool interruption marked" || { echo "FAIL: interrupt marker"; FAIL=1; }
152
+ assert_single_json "toolFailure single json" "$OUT"
153
+
154
+ # --- no-jq session_id must reach segment-state via round-start ------------
155
+ NOJQ_HOME="$TMP/nojq_home"
156
+ mkdir -p "$NOJQ_HOME/.devlog" "$TMP/nojq_path"
157
+ touch "$NOJQ_HOME/.devlog/.enabled"
158
+ printf '%s\n' '{"last_change_epoch": 0, "last_seen_cksum": "", "max_silent_seconds": 600, "session_id": ""}' \
159
+ > "$NOJQ_HOME/.devlog/.segment-state"
160
+ CLEAN_PATH="$TMP/nojq_path"
161
+ # Resolve real binaries (zsh `command -v` may return aliases / builtins).
162
+ for cmd in bash sh cksum date grep sed cat mkdir tr mktemp rm head awk touch \
163
+ pwd dirname uname ln printf sort cut wc env sleep; do
164
+ src=""
165
+ for cand in "/bin/$cmd" "/usr/bin/$cmd"; do
166
+ if [ -x "$cand" ]; then src="$cand"; break; fi
167
+ done
168
+ [ -n "$src" ] && ln -sf "$src" "$CLEAN_PATH/$cmd"
169
+ done
170
+ # Explicitly do NOT link jq.
171
+ OUT="$(printf '{"workspace_roots":["%s"],"prompt":"nojq-hi","session_id":"cursor-nojq"}' "$NOJQ_HOME" \
172
+ | env PATH="$CLEAN_PATH" bash "$SCRIPT_DIR/on-submit-prompt.sh")"
173
+ case "$OUT" in *'"continue":true'*) echo "PASS: nojq submit continues" ;; *) echo "FAIL: nojq submit [$OUT]"; FAIL=1 ;; esac
174
+ SEG_SID="$(grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' "$NOJQ_HOME/.devlog/.segment-state" 2>/dev/null | head -1 | sed 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)"
175
+ if [ "$SEG_SID" = "cursor-nojq" ]; then
176
+ echo "PASS: nojq session_id stored in segment-state"
177
+ else
178
+ echo "FAIL: nojq session_id missing (got [$SEG_SID])"; FAIL=1
179
+ fi
180
+
181
+ # --- no-jq PreToolUse deny must emit a single-line JSON object --------------
182
+ # segment-watch deny stderr is multiline; without newline escaping, bash 3.2
183
+ # prints unparseable JSON and Cursor fail-opens (valve vanishes).
184
+ OUT="$(printf '{"workspace_roots":["%s"],"tool_name":"Bash","tool_input":{},"session_id":"cursor-mismatch"}' "$MIS" \
185
+ | env PATH="$CLEAN_PATH" bash "$SCRIPT_DIR/on-pre-tool.sh")"
186
+ case "$OUT" in *'"permission":"deny"'*) echo "PASS: nojq mismatch preTool denies" ;; *) echo "FAIL: nojq mismatch preTool [$OUT]"; FAIL=1 ;; esac
187
+ assert_single_json "nojq mismatch preTool deny single json" "$OUT"
188
+ case "$OUT" in
189
+ *$'\n'*$'\n'*) echo "FAIL: nojq deny JSON contains raw newlines [$OUT]"; FAIL=1 ;;
190
+ *) echo "PASS: nojq deny JSON has no raw newlines inside the string" ;;
191
+ esac
192
+ if command -v python3 >/dev/null 2>&1; then
193
+ if printf '%s' "$OUT" | python3 -c 'import json,sys; json.load(sys.stdin)' >/dev/null 2>&1; then
194
+ echo "PASS: nojq deny JSON is parseable"
195
+ else
196
+ echo "FAIL: nojq deny JSON is not parseable [$OUT]"; FAIL=1
197
+ fi
198
+ fi
199
+
200
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
201
+ else echo "Some checks FAILED."; exit 1; fi
@@ -0,0 +1,26 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "sessionStart": [
5
+ { "command": "bash \"${DEVLOG_TRACKER_ROOT}/cursor/hooks/on-session-start.sh\"" }
6
+ ],
7
+ "beforeSubmitPrompt": [
8
+ { "command": "bash \"${DEVLOG_TRACKER_ROOT}/cursor/hooks/on-submit-prompt.sh\"" }
9
+ ],
10
+ "preToolUse": [
11
+ { "command": "bash \"${DEVLOG_TRACKER_ROOT}/cursor/hooks/on-pre-tool.sh\"" }
12
+ ],
13
+ "stop": [
14
+ {
15
+ "command": "bash \"${DEVLOG_TRACKER_ROOT}/cursor/hooks/on-stop.sh\"",
16
+ "loop_limit": 1
17
+ }
18
+ ],
19
+ "sessionEnd": [
20
+ { "command": "bash \"${DEVLOG_TRACKER_ROOT}/cursor/hooks/on-session-end.sh\"" }
21
+ ],
22
+ "postToolUseFailure": [
23
+ { "command": "bash \"${DEVLOG_TRACKER_ROOT}/cursor/hooks/on-tool-failure.sh\"" }
24
+ ]
25
+ }
26
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ _src="${BASH_SOURCE[0]}"
4
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
5
+ # shellcheck source=json-field.sh
6
+ . "$SCRIPT_DIR/json-field.sh"
7
+ # shellcheck source=devlog-md.sh
8
+ . "$SCRIPT_DIR/devlog-md.sh"
9
+ # shellcheck source=devlog-path.sh
10
+ . "$SCRIPT_DIR/devlog-path.sh"
11
+
12
+ devlog_resolve_paths "${CLAUDE_PROJECT_DIR:-.}"
13
+ [ -f "$DEVLOG_DIR/.enabled" ] || { echo "NOT_ENABLED"; exit 1; }
14
+ [ ! -e "$DEVLOG_DIR/.awaiting-reply" ] || { echo "ALREADY_OPEN"; exit 1; }
15
+ ROUND=""
16
+ [ ! -f "$DEVLOG_DIR/.round-open" ] || ROUND="$(json_int_get "$DEVLOG_DIR/.round-open" round)"
17
+ if [ -z "$ROUND" ] && [ -f "$DEVLOG_FILE" ]; then
18
+ ROUND="$(devlog_list_round_starts "$DEVLOG_FILE" | awk 'END { print $2 }')"
19
+ fi
20
+ [ -n "$ROUND" ] || { echo "NO_ROUND"; exit 1; }
21
+ OPENED_AT="$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S%z')"
22
+ printf '{"round": %s, "opened_at": "%s"}\n' "$ROUND" "$OPENED_AT" > "$DEVLOG_DIR/.awaiting-reply" || exit 1
23
+ printf 'OPENED=%s\n' "$ROUND"
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # User-invoked: /devlog-tracker:checkpoint filesystem side.
3
+ # Sets Checkpoint Mode's max_silent_rounds threshold. $1 is required: a
4
+ # positive integer number of rounds.
5
+ set -uo pipefail
6
+ _src="${BASH_SOURCE[0]}"
7
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
8
+ # shellcheck source=json-field.sh
9
+ . "$SCRIPT_DIR/json-field.sh"
10
+
11
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
12
+ DEVLOG_DIR="$PROJECT_DIR/.devlog"
13
+
14
+ if [ ! -f "$DEVLOG_DIR/.enabled" ]; then
15
+ echo "NOT_STARTED"
16
+ exit 0
17
+ fi
18
+
19
+ ROUNDS_ARG="${1:-}"
20
+ case "$ROUNDS_ARG" in
21
+ ''|*[!0-9]*|0)
22
+ echo "max_silent_rounds must be a positive integer number of rounds, got: $ROUNDS_ARG" >&2
23
+ exit 1
24
+ ;;
25
+ esac
26
+
27
+ if [ -f "$DEVLOG_DIR/.checkpoint-state" ]; then
28
+ json_int_set "$DEVLOG_DIR/.checkpoint-state" max_silent_rounds "$ROUNDS_ARG"
29
+ APPLIED="$(json_int_get "$DEVLOG_DIR/.checkpoint-state" max_silent_rounds)"
30
+ if [ "$APPLIED" != "$ROUNDS_ARG" ]; then
31
+ ROUNDS="$(json_int_get "$DEVLOG_DIR/.checkpoint-state" rounds_since_checkpoint)"
32
+ MARKERS="$(json_int_get "$DEVLOG_DIR/.checkpoint-state" checkpoint_marker_count)"
33
+ case "$ROUNDS" in ''|*[!0-9]*) ROUNDS=0 ;; esac
34
+ case "$MARKERS" in ''|*[!0-9]*) MARKERS=0 ;; esac
35
+ printf '%s\n' "{\"rounds_since_checkpoint\": ${ROUNDS}, \"max_silent_rounds\": ${ROUNDS_ARG}, \"checkpoint_marker_count\": ${MARKERS}}" \
36
+ > "$DEVLOG_DIR/.checkpoint-state" || exit 1
37
+ fi
38
+ else
39
+ printf '%s\n' "{\"rounds_since_checkpoint\": 0, \"max_silent_rounds\": ${ROUNDS_ARG}, \"checkpoint_marker_count\": 0}" \
40
+ > "$DEVLOG_DIR/.checkpoint-state" || exit 1
41
+ fi
42
+
43
+ echo "CHECKPOINT_MAX_SILENT_ROUNDS=$(json_int_get "$DEVLOG_DIR/.checkpoint-state" max_silent_rounds)"
44
+ exit 0
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ _src="${BASH_SOURCE[0]}"
5
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
6
+ . "$SCRIPT_DIR/devlog-md.sh"
7
+ . "$SCRIPT_DIR/json-field.sh"
8
+ . "$SCRIPT_DIR/devlog-lock.sh"
9
+ . "$SCRIPT_DIR/devlog-path.sh"
10
+
11
+ # Destructive and irreversible: only run when the caller has explicitly
12
+ # confirmed with the user first. This is a mechanical guard, not a
13
+ # replacement for that confirmation.
14
+ [ "${1:-}" = "--confirmed" ] || { echo "需要 --confirmed(使用者尚未確認,不要呼叫這支腳本)" >&2; exit 1; }
15
+
16
+ devlog_resolve_paths "${CLAUDE_PROJECT_DIR:-.}"
17
+ devlog_lock_acquire
18
+ trap 'devlog_lock_release' EXIT
19
+ MAIN="$DEVLOG_FILE"
20
+ ROUND_OPEN="$DEVLOG_DIR/.round-open"
21
+ ROUND_CURRENT="$DEVLOG_DIR/.round-current.md"
22
+ # Post-split, devlog.md may legitimately not exist yet (a project's very
23
+ # first round can still be open, with real content sitting only in
24
+ # .round-current.md) — accept either devlog.md existing or .round-current.md
25
+ # having content to clean.
26
+ [ -f "$MAIN" ] || [ -s "$ROUND_CURRENT" ] || { echo "devlog.md 不存在" >&2; exit 1; }
27
+
28
+ HAS_OPEN=0
29
+ OPEN=""
30
+ if [ -f "$ROUND_OPEN" ]; then
31
+ OPEN="$(json_int_get "$ROUND_OPEN" round)"
32
+ [ -n "$OPEN" ] || { echo ".round-open 內容無法解析,未清空" >&2; exit 1; }
33
+ [ -s "$ROUND_CURRENT" ] || { echo "找不到開著的 .round-current.md 內容,狀態可能不一致,未清空" >&2; exit 1; }
34
+ HAS_OPEN=1
35
+ fi
36
+
37
+ if [ "$HAS_OPEN" -eq 1 ]; then
38
+ # Keep the split's invariant intact: the open round's content stays in
39
+ # .round-current.md (renumbered to Round 1), never in devlog.md. Writing
40
+ # it into devlog.md here would leave .round-open pointing at a round
41
+ # that Stop/close-open-round.sh can't find (they only ever look at
42
+ # .round-current.md), silently disabling enforcement for the rest of
43
+ # this turn and orphaning recovery on a crash.
44
+ TMP_DIR="$(mktemp -d "$DEVLOG_DIR/.clean.XXXXXX")" || exit 1
45
+ trap 'rm -rf "$TMP_DIR"; devlog_lock_release' EXIT
46
+ NEW_CURRENT="$TMP_DIR/round-current.md"
47
+ awk -v open="$OPEN" '
48
+ NR == 1 && $0 ~ ("^## Round " open "([^0-9]|$)") { sub("^## Round " open, "## Round 1") }
49
+ { print }
50
+ ' "$ROUND_CURRENT" > "$NEW_CURRENT" || exit 1
51
+ [ -s "$NEW_CURRENT" ] && grep -q '^## Round 1' "$NEW_CURRENT" || { echo "重寫結果異常,未寫入" >&2; exit 1; }
52
+ mv "$NEW_CURRENT" "$ROUND_CURRENT" || exit 1
53
+ rm -f "$MAIN" || exit 1
54
+ rm -rf "$TMP_DIR"
55
+ json_int_set "$ROUND_OPEN" round 1
56
+ KEPT_ROUND=1
57
+ else
58
+ rm -f "$MAIN" "$ROUND_CURRENT" || exit 1
59
+ KEPT_ROUND=0
60
+ fi
61
+
62
+ rm -f "$DEVLOG_DIR/.span-open" "$DEVLOG_DIR/.interrupted" "$DEVLOG_DIR/.awaiting-reply"
63
+ if [ -f "$DEVLOG_DIR/.checkpoint-state" ]; then
64
+ json_int_set "$DEVLOG_DIR/.checkpoint-state" rounds_since_checkpoint 0
65
+ json_int_set "$DEVLOG_DIR/.checkpoint-state" checkpoint_marker_count 0
66
+ fi
67
+
68
+ printf 'CLEANED KEPT_ROUND=%s\n' "$KEPT_ROUND"
69
+ exit 0
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env bash
2
+ # Stamp the open Round as INTERRUPTED. Always exit 0 (fail-open).
3
+ # Usage: close-open-round.sh <reason> [detail]
4
+ # The reason is written as a bracketed tag under Status (`[reason: ...]`) so
5
+ # it reads as internal debug metadata, not a broken/extra Status value — and
6
+ # stays visible even through a Markdown renderer (unlike an HTML comment,
7
+ # which a renderer would silently drop, losing the debug trail).
8
+ # detail (optional): "awaiting_question" makes the stub Summary/Handoff name
9
+ # the likely cause (interrupted while an AskUserQuestion answer was pending)
10
+ # instead of the generic "沒有正常收尾" — caller decides via
11
+ # detect-pending-question.sh; empty/unknown detail falls back to the generic
12
+ # stub, same as before.
13
+ # When detail is empty, the stub wording also depends on RECOVERED (did
14
+ # devlog.md change at all since the round's skeleton was written): if not,
15
+ # nothing was ever written back for this round (most likely the next prompt
16
+ # arrived before Claude engaged with it at all), so the stub says that
17
+ # plainly instead of implying work was interrupted mid-way.
18
+ # Silent on stdout — SessionStart injects stdout as additionalContext.
19
+ set -uo pipefail
20
+
21
+ REASON="${1:-unknown}"
22
+ DETAIL="${2:-}"
23
+
24
+ _src="${BASH_SOURCE[0]}"
25
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
26
+ # shellcheck source=json-field.sh
27
+ . "$SCRIPT_DIR/json-field.sh"
28
+ # shellcheck source=devlog-lock.sh
29
+ . "$SCRIPT_DIR/devlog-lock.sh"
30
+ # shellcheck source=devlog-path.sh
31
+ . "$SCRIPT_DIR/devlog-path.sh"
32
+ # shellcheck source=devlog-md.sh
33
+ . "$SCRIPT_DIR/devlog-md.sh"
34
+
35
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
36
+ devlog_resolve_paths "$PROJECT_DIR"
37
+ ENABLED_FLAG="$DEVLOG_DIR/.enabled"
38
+ ROUND_OPEN="$DEVLOG_DIR/.round-open"
39
+ INTERRUPTED_FLAG="$DEVLOG_DIR/.interrupted"
40
+ ROUND_CURRENT="$DEVLOG_DIR/.round-current.md"
41
+ TURN_MARKER="$DEVLOG_DIR/.turn-start"
42
+
43
+ drop_markers() {
44
+ rm -f "$ROUND_OPEN" "$INTERRUPTED_FLAG" 2>/dev/null || true
45
+ }
46
+
47
+ [ -f "$ENABLED_FLAG" ] || exit 0
48
+ devlog_lock_acquire
49
+ trap 'devlog_lock_release' EXIT
50
+ if [ ! -f "$ROUND_OPEN" ]; then
51
+ rm -f "$INTERRUPTED_FLAG" 2>/dev/null || true
52
+ exit 0
53
+ fi
54
+
55
+ ROUND_OPEN_FILE="$(json_str_get "$ROUND_OPEN" file 2>/dev/null || true)"
56
+ if [ -n "$ROUND_OPEN_FILE" ] && [ "$ROUND_OPEN_FILE" != "${DEVLOG_FILE##*/}" ]; then
57
+ # This marker belongs to a different branch's devlog file (e.g. the user
58
+ # switched branches between round-start and now). Leave it untouched —
59
+ # whichever branch it actually belongs to will resolve and close it
60
+ # correctly when that branch is current again.
61
+ exit 0
62
+ fi
63
+
64
+ OPEN_ROUND="$(json_int_get "$ROUND_OPEN" round)"
65
+ case "$OPEN_ROUND" in
66
+ ''|*[!0-9]*) drop_markers; exit 0 ;;
67
+ esac
68
+
69
+ if [ ! -f "$ROUND_CURRENT" ] || [ ! -s "$ROUND_CURRENT" ]; then
70
+ drop_markers
71
+ exit 0
72
+ fi
73
+
74
+ RECOVERED=0
75
+ if [ -f "$TURN_MARKER" ]; then
76
+ TURN_HASH="$(cat "$TURN_MARKER" 2>/dev/null || echo '')"
77
+ CUR_HASH="$(cksum < "$ROUND_CURRENT" 2>/dev/null || echo '')"
78
+ if [ -n "$TURN_HASH" ] && [ -n "$CUR_HASH" ] && [ "$CUR_HASH" != "$TURN_HASH" ]; then
79
+ RECOVERED=1
80
+ fi
81
+ fi
82
+
83
+ TMP="$ROUND_CURRENT.tmp"
84
+ awk -v reason="$REASON" -v detail="$DETAIL" -v recovered="$RECOVERED" '
85
+ function summary_stub() {
86
+ if (detail == "awaiting_question") return "這輪在等待使用者回答 AskUserQuestion 時結束,還沒收到答案。"
87
+ if (recovered == 0) return "這一輪送出後,devlog.md 沒有留下任何後續處理紀錄就結束了。"
88
+ return "這輪意外中斷。"
89
+ }
90
+ function reply_stub() {
91
+ if (detail == "awaiting_question") return "(中斷前正在等使用者回答問題。)"
92
+ if (recovered == 0) return "(這一輪在 devlog.md 裡沒有留下回覆紀錄。)"
93
+ return "(這輪意外中斷,沒有對使用者完成回覆。)"
94
+ }
95
+ function handoff_stub() {
96
+ if (detail == "awaiting_question") return "Claude 提了問題還在等回答,session 就先結束了;不是中途出錯,只是還沒收到答案。需要的話重新確認一次問題再繼續。"
97
+ if (recovered == 0) return "這一輪沒有留下任何處理紀錄;下一步請看上面的 User Input 原文決定要不要接著處理。"
98
+ return "這輪沒有正常收尾。"
99
+ }
100
+ { lines[NR] = $0; n = NR }
101
+ END {
102
+ has_s = 0
103
+ has_r = 0
104
+ has_h = 0
105
+ for (i = 1; i <= n; i++) {
106
+ if (lines[i] ~ /^### Summary/) has_s = 1
107
+ if (lines[i] ~ /^### Reply/) has_r = 1
108
+ if (lines[i] ~ /^### Handoff/) has_h = 1
109
+ }
110
+ # Recovered-complete still keys off Summary+Handoff (pre-Reply contract);
111
+ # Stop will require Reply on the next normal close if missing.
112
+ if (recovered && has_s && has_h) { exit 3 }
113
+
114
+ skip_val = 0
115
+ saw_status = 0
116
+ for (i = 1; i <= n; i++) {
117
+ if (skip_val) { skip_val = 0; continue }
118
+ if (lines[i] ~ /^### Status/) {
119
+ saw_status = 1
120
+ if (!has_s) {
121
+ print "### Summary"
122
+ print summary_stub()
123
+ print ""
124
+ }
125
+ if (!has_r) {
126
+ print "### Reply"
127
+ print reply_stub()
128
+ print ""
129
+ }
130
+ if (!has_h) {
131
+ print "### Handoff"
132
+ print "#### 現況"
133
+ print handoff_stub()
134
+ print ""
135
+ }
136
+ print "### Status"
137
+ print "INTERRUPTED"
138
+ print "[reason: " reason "]"
139
+ skip_val = 1
140
+ continue
141
+ }
142
+ print lines[i]
143
+ }
144
+ if (!saw_status) {
145
+ if (!has_s) {
146
+ print "### Summary"
147
+ print summary_stub()
148
+ print ""
149
+ }
150
+ if (!has_r) {
151
+ print "### Reply"
152
+ print reply_stub()
153
+ print ""
154
+ }
155
+ if (!has_h) {
156
+ print "### Handoff"
157
+ print "#### 現況"
158
+ print handoff_stub()
159
+ print ""
160
+ }
161
+ print "### Status"
162
+ print "INTERRUPTED"
163
+ print "[reason: " reason "]"
164
+ }
165
+ }
166
+ ' "$ROUND_CURRENT" > "$TMP" 2>/dev/null
167
+ AWK_RC=$?
168
+ if [ "$AWK_RC" -eq 0 ]; then
169
+ mv "$TMP" "$ROUND_CURRENT" 2>/dev/null || true
170
+ else
171
+ rm -f "$TMP" 2>/dev/null || true
172
+ fi
173
+ if [ "$AWK_RC" -eq 0 ] || [ "$AWK_RC" -eq 3 ]; then
174
+ # 只有併入真的成功才清掉 .round-open:併入失敗(例如寫入失敗)時保留它,
175
+ # 讓下一次 dangling-heal(下一則訊息/下次 SessionStart 都會跑到)還有
176
+ # 機會重試,而不是內容被孤立在 .round-current.md 卻沒有任何機制知道要去
177
+ # 救它。.interrupted 這個訊號本身已經處理完,兩種結果都清掉。
178
+ if devlog_merge_round_current "$DEVLOG_FILE" "$ROUND_CURRENT"; then
179
+ drop_markers
180
+ else
181
+ rm -f "$INTERRUPTED_FLAG" 2>/dev/null || true
182
+ fi
183
+ else
184
+ drop_markers
185
+ fi
186
+ exit 0