devlog-tracker 0.34.0 → 1.1.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 (56) hide show
  1. package/README.md +7 -5
  2. package/README.zh-TW.md +7 -5
  3. package/codex/hooks/on-interrupt.sh +1 -0
  4. package/codex/hooks/on-pre-tool.sh +9 -7
  5. package/codex/hooks/on-session-end.sh +1 -0
  6. package/codex/hooks/on-session-start.sh +1 -0
  7. package/codex/hooks/on-stop.sh +1 -0
  8. package/codex/hooks/on-subagent-start.sh +1 -0
  9. package/codex/hooks/on-user-prompt-submit.sh +1 -0
  10. package/codex/hooks/test-adapters.sh +29 -19
  11. package/commands/clean.md +2 -2
  12. package/commands/continue.md +1 -1
  13. package/commands/keep-all.md +4 -4
  14. package/commands/keep.md +4 -4
  15. package/commands/migrate.md +2 -2
  16. package/commands/span.md +4 -4
  17. package/commands/status.md +1 -1
  18. package/core/scripts/await-open.sh +4 -4
  19. package/core/scripts/clean-devlog.sh +11 -5
  20. package/core/scripts/close-open-round.sh +0 -4
  21. package/core/scripts/devlog-md.sh +89 -8
  22. package/core/scripts/devlog-path.sh +128 -10
  23. package/core/scripts/enforce-devlog.sh +10 -16
  24. package/core/scripts/keep-all.js +4 -3
  25. package/core/scripts/keep-all.sh +2 -3
  26. package/core/scripts/keep-all.test.js +31 -0
  27. package/core/scripts/keep-move.sh +13 -9
  28. package/core/scripts/migrate-handoff.sh +2 -2
  29. package/core/scripts/on-tool-failure.sh +6 -1
  30. package/core/scripts/pause-devlog.sh +5 -3
  31. package/core/scripts/round-start.sh +31 -20
  32. package/core/scripts/segment-watch-set.sh +12 -8
  33. package/core/scripts/segment-watch.sh +3 -6
  34. package/core/scripts/session-start-devlog.sh +11 -5
  35. package/core/scripts/span-close.sh +7 -3
  36. package/core/scripts/span-open.sh +4 -4
  37. package/core/scripts/status-devlog.sh +6 -6
  38. package/core/scripts/tests/test-await-open.sh +9 -0
  39. package/core/scripts/tests/test-devlog-md.sh +76 -15
  40. package/core/scripts/tests/test-devlog-path.sh +126 -0
  41. package/core/scripts/tests/test-multi-platform.sh +286 -0
  42. package/core/scripts/tests/test-segment-watch-set.sh +10 -0
  43. package/core/scripts/tests/test-session-start-devlog.sh +15 -0
  44. package/cursor/hooks/on-pre-tool.sh +1 -0
  45. package/cursor/hooks/on-session-end.sh +1 -0
  46. package/cursor/hooks/on-session-start.sh +1 -0
  47. package/cursor/hooks/on-stop.sh +1 -0
  48. package/cursor/hooks/on-submit-prompt.sh +1 -0
  49. package/cursor/hooks/on-tool-failure.sh +1 -0
  50. package/cursor/hooks/test-adapters.sh +19 -16
  51. package/package.json +1 -1
  52. package/skills/devlog-tracker/SKILL.md +23 -18
  53. package/skills/devlog-tracker/references/contract.md +2 -2
  54. package/skills/devlog-tracker/references/reply-fold.md +8 -8
  55. package/skills/devlog-tracker/references/round-segments.md +2 -2
  56. package/skills/devlog-tracker/references/span-mode.md +10 -3
@@ -22,10 +22,18 @@ _devlog_fence_nofence() {
22
22
  if [ $((count % 2)) -eq 0 ]; then printf '0\n'; else printf '1\n'; fi
23
23
  }
24
24
 
25
+ # Prints "<line> <round>" per round heading outside fences. With platform $2,
26
+ # only rounds it owns: a heading ending in " · <platform>" (lowercase word)
27
+ # belongs to it; any other heading (every pre-upgrade round) belongs to claude.
25
28
  devlog_list_round_starts() {
26
- awk '
29
+ LC_ALL=C awk -v p="${2:-}" '
27
30
  /^[ \t]*```/ { fence = !fence; next }
28
31
  !fence && /^## Round [0-9]+/ {
32
+ if (p != "") {
33
+ owner = $0
34
+ if (!(sub(/.* · /, "", owner) && owner ~ /^[a-z]+$/)) owner = "claude"
35
+ if (owner != p) next
36
+ }
29
37
  round = $0
30
38
  sub(/^## Round /, "", round)
31
39
  sub(/[^0-9].*$/, "", round)
@@ -34,6 +42,24 @@ devlog_list_round_starts() {
34
42
  ' "$1"
35
43
  }
36
44
 
45
+ devlog_list_round_starts_of() {
46
+ devlog_list_round_starts "$1" "$2"
47
+ }
48
+
49
+ # Start line of the last "## Round $2" heading; with platform $3, only its own.
50
+ devlog_round_start_by_number() {
51
+ devlog_list_round_starts "$1" "${3:-}" | awk -v n="$2" '$2 == n { s = $1 } END { if (s) print s }'
52
+ }
53
+
54
+ # Reply Fold target for platform $3: its own "## Round $2" first, else any
55
+ # owner's (a pre-claim heading it wrote without a suffix).
56
+ devlog_fold_round_start() {
57
+ local s
58
+ s="$(devlog_round_start_by_number "$1" "$2" "$3")"
59
+ [ -n "$s" ] || s="$(devlog_round_start_by_number "$1" "$2")"
60
+ [ -z "$s" ] || printf '%s\n' "$s"
61
+ }
62
+
37
63
  devlog_block_end() {
38
64
  awk -v start="$2" '
39
65
  NR < start { next }
@@ -178,6 +204,59 @@ devlog_origin_of() {
178
204
  sed -n '1s/^<!-- devlog-origin: \(.*\) -->$/\1/p' "$1"
179
205
  }
180
206
 
207
+ # Span Mode rounds are numbered by the LLM, not reserved by round-start.sh.
208
+ # So before a merge (caller holds the lock), every "## Round N" heading in
209
+ # $2 whose N is already a round in $1, reserved by another platform's open
210
+ # round on $1, or repeated within $2 gets the next free number; a
211
+ # non-Claude heading without an owner suffix gains " · <platform>". The
212
+ # caller's own reserved round passes through unchanged. Leaves $2 alone on
213
+ # any error.
214
+ _devlog_renumber_current() {
215
+ local devlog="$1" current="$2" p="${DEVLOG_PLATFORM:-claude}" rows="" own="" reserved=""
216
+ if [ -n "${DEVLOG_DIR:-}" ] && type devlog_open_rounds >/dev/null 2>&1; then
217
+ rows="$(devlog_open_rounds | awk -F '\t' -v f="${devlog##*/}" '($3 == f || $3 == "") && $2 ~ /^[0-9]+$/')"
218
+ own="$(printf '%s\n' "$rows" | awk -F '\t' -v p="$p" '$1 == p { print $2 }')"
219
+ reserved="$(printf '%s\n' "$rows" | awk -F '\t' -v p="$p" '$1 != p && $2 != "" { printf "%s ", $2 }')"
220
+ fi
221
+ LC_ALL=C awk -v p="$p" -v own="$own" -v reserved="$reserved" -v devlog="$devlog" '
222
+ function num(s) { sub(/^## Round /, "", s); sub(/[^0-9].*$/, "", s); return s + 0 }
223
+ BEGIN {
224
+ k = split(reserved, r, " ")
225
+ for (i = 1; i <= k; i++) { taken[r[i] + 0] = 1; if (r[i] + 0 > max) max = r[i] + 0 }
226
+ if (own != "") { taken[own + 0] = 1; if (own + 0 > max) max = own + 0 }
227
+ while ((getline line < devlog) > 0) {
228
+ if (line ~ /^[ \t]*```/) { f = !f; continue }
229
+ if (!f && line ~ /^## Round [0-9]+/) { x = num(line); taken[x] = 1; if (x > max) max = x }
230
+ }
231
+ close(devlog)
232
+ }
233
+ { lines[NR] = $0 }
234
+ /^[ \t]*```/ { fence = !fence; next }
235
+ !fence && /^## Round [0-9]+/ { head[NR] = 1; x = num($0); if (x > max) max = x }
236
+ END {
237
+ next_n = max + 1
238
+ for (i = 1; i <= NR; i++) {
239
+ line = lines[i]
240
+ if (head[i] && own != "" && !own_seen && num(line) == own + 0) { own_seen = 1 }
241
+ else if (head[i]) {
242
+ x = num(line)
243
+ if (x in taken) {
244
+ rest = line; sub(/^## Round [0-9]+/, "", rest)
245
+ x = next_n++
246
+ line = "## Round " x rest
247
+ }
248
+ taken[x] = 1
249
+ owner = line
250
+ if (p != "claude" && !(sub(/.* · /, "", owner) && owner ~ /^[a-z]+$/)) line = line " · " p
251
+ }
252
+ print line
253
+ }
254
+ }
255
+ ' "$current" 2>/dev/null >"$current.renum" && mv "$current.renum" "$current" 2>/dev/null
256
+ rm -f "$current.renum" 2>/dev/null
257
+ return 0
258
+ }
259
+
181
260
  devlog_merge_round_current() {
182
261
  # Appends $2's content onto $1 (one blank line separator, matching the
183
262
  # existing "\n## Round N" append convention) and removes $2. No-op if $2
@@ -186,6 +265,7 @@ devlog_merge_round_current() {
186
265
  # goes first.
187
266
  local devlog="$1" current="$2"
188
267
  [ -s "$current" ] || return 0
268
+ _devlog_renumber_current "$devlog" "$current"
189
269
  {
190
270
  [ -s "$devlog" ] || [ -z "${DEVLOG_ORIGIN:-}" ] || devlog_origin_line "$DEVLOG_ORIGIN"
191
271
  printf '\n'
@@ -195,12 +275,13 @@ devlog_merge_round_current() {
195
275
  return 0
196
276
  }
197
277
 
198
- devlog_reopen_last_round() {
199
- # Moves the last "## Round N ..." block out of $1 into $2 (creating $2),
200
- # removing those lines from $1. Returns 1 and touches neither file if $1
201
- # has no round to move.
278
+ devlog_reopen_round() {
279
+ # Moves the "## Round $3" block (wherever it sits in $1) into $2, removing
280
+ # it from $1. With platform $4, picks the block devlog_fold_round_start
281
+ # does. Returns 1 and touches neither file if $1 has no such round.
202
282
  local devlog="$1" current="$2" start end
203
- start="$(devlog_list_round_starts "$devlog" | awk 'END { print $1 }')"
283
+ if [ -n "${4:-}" ]; then start="$(devlog_fold_round_start "$devlog" "$3" "$4")"
284
+ else start="$(devlog_round_start_by_number "$devlog" "$3")"; fi
204
285
  [ -n "$start" ] || return 1
205
286
  end="$(devlog_block_end "$devlog" "$start")"
206
287
  awk -v start="$start" -v end="$end" 'NR >= start && NR <= end' "$devlog" > "$current" 2>/dev/null || return 1
@@ -210,12 +291,12 @@ devlog_reopen_last_round() {
210
291
  }
211
292
 
212
293
  workspace_claim_state() {
213
- local dir="$1" file="$2" start end status claimed live
294
+ local dir="$1" file="$2" platform="${3:-claude}" start end status claimed live
214
295
  if ! type workspace_snapshot >/dev/null 2>&1; then
215
296
  # shellcheck source=workspace-snapshot.sh
216
297
  . "$_DEVLOG_MD_DIR/workspace-snapshot.sh"
217
298
  fi
218
- start="$(devlog_list_round_starts "$file" | awk 'END { print $1 }')"
299
+ start="$(devlog_list_round_starts_of "$file" "$platform" | awk 'END { print $1 }')"
219
300
  [ -n "$start" ] || { printf 'NO_CLAIM\n'; return 0; }
220
301
  end="$(devlog_block_end "$file" "$start")"
221
302
  status="$(devlog_round_status "$file" "$start" "$end")"
@@ -8,6 +8,112 @@ _DEVLOG_PATH_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
8
8
  . "$_DEVLOG_PATH_DIR/devlog-lock.sh"
9
9
  # shellcheck source=devlog-md.sh
10
10
  . "$_DEVLOG_PATH_DIR/devlog-md.sh"
11
+ # shellcheck source=json-field.sh
12
+ . "$_DEVLOG_PATH_DIR/json-field.sh"
13
+
14
+ # Every platform that can own per-round state. Claude keeps the pre-0.x
15
+ # un-suffixed names; the others add "@<platform>" (docs/design/
16
+ # multi-platform-concurrency.md). "@" never survives _devlog_sanitize_name,
17
+ # so a branch named "codex" cannot collide with platform Codex.
18
+ DEVLOG_PLATFORMS="claude codex cursor"
19
+
20
+ devlog_platform() {
21
+ case "${DEVLOG_PLATFORM:-}" in
22
+ codex|cursor) printf '%s\n' "$DEVLOG_PLATFORM" ;;
23
+ *) printf 'claude\n' ;;
24
+ esac
25
+ }
26
+
27
+ # $1 stem (".round-current", "handoff.feat-x"), $2 extension (".md" or ""),
28
+ # $3 platform. Prints a basename.
29
+ devlog_platform_file() {
30
+ if [ "$3" = claude ]; then printf '%s%s\n' "$1" "$2"
31
+ else printf '%s@%s%s\n' "$1" "$3" "$2"; fi
32
+ }
33
+
34
+ _devlog_set_state_paths() {
35
+ local p="$1" d="$DEVLOG_DIR"
36
+ # shellcheck disable=SC2034 # consumed by callers
37
+ ROUND_CURRENT="$d/$(devlog_platform_file .round-current .md "$p")"
38
+ # shellcheck disable=SC2034 # consumed by callers
39
+ ROUND_OPEN="$d/$(devlog_platform_file .round-open '' "$p")"
40
+ # shellcheck disable=SC2034 # consumed by callers
41
+ TURN_MARKER="$d/$(devlog_platform_file .turn-start '' "$p")"
42
+ # shellcheck disable=SC2034 # consumed by callers
43
+ SEGMENT_FILE="$d/$(devlog_platform_file .segment-state '' "$p")"
44
+ # shellcheck disable=SC2034 # consumed by callers
45
+ AWAITING_FILE="$d/$(devlog_platform_file .awaiting-reply '' "$p")"
46
+ # shellcheck disable=SC2034 # consumed by callers
47
+ INTERRUPTED_FLAG="$d/$(devlog_platform_file .interrupted '' "$p")"
48
+ # shellcheck disable=SC2034 # consumed by callers
49
+ SPAN_FILE="$d/$(devlog_platform_file .span-open '' "$p")"
50
+ # shellcheck disable=SC2034 # consumed by callers
51
+ MISMATCH_FILE="$d/$(devlog_platform_file .workspace-mismatch '' "$p")"
52
+ }
53
+
54
+ # First resolve after upgrade: before this change every platform wrote the
55
+ # un-suffixed names. The platform that resolves first most likely wrote
56
+ # them, so a non-Claude first resolver renames them to its own names.
57
+ # .segment-state is configuration (start-devlog.sh creates it), not round
58
+ # state, so it stays. Runs once; .platform-claimed stops it.
59
+ _devlog_claim_legacy_state() {
60
+ local p="$1" d="$DEVLOG_DIR" name f stem
61
+ [ -f "$d/.enabled" ] && [ ! -e "$d/.platform-claimed" ] || return 0
62
+ devlog_lock_acquire
63
+ if [ ! -e "$d/.platform-claimed" ]; then
64
+ if [ "$p" != claude ]; then
65
+ for name in .round-open .turn-start .awaiting-reply .interrupted .span-open .workspace-mismatch; do
66
+ [ -e "$d/$name" ] && [ ! -e "$d/$name@$p" ] && mv "$d/$name" "$d/$name@$p" 2>/dev/null
67
+ done
68
+ [ -e "$d/.round-current.md" ] && [ ! -e "$d/.round-current@$p.md" ] \
69
+ && mv "$d/.round-current.md" "$d/.round-current@$p.md" 2>/dev/null
70
+ for f in "$d"/handoff.md "$d"/handoff.*.md; do
71
+ [ -f "$f" ] || continue
72
+ case "${f##*/}" in *@*) continue ;; esac
73
+ stem="${f%.md}"
74
+ [ -e "$stem@$p.md" ] || mv "$f" "$stem@$p.md" 2>/dev/null
75
+ done
76
+ fi
77
+ : > "$d/.platform-claimed" 2>/dev/null || true
78
+ fi
79
+ devlog_lock_release
80
+ }
81
+
82
+ # One line per existing .round-open marker: platform, round, file field.
83
+ devlog_open_rounds() {
84
+ local q f
85
+ for q in $DEVLOG_PLATFORMS; do
86
+ f="$DEVLOG_DIR/$(devlog_platform_file .round-open '' "$q")"
87
+ [ -f "$f" ] || continue
88
+ printf '%s\t%s\t%s\n' "$q" "$(json_int_get "$f" round)" "$(json_str_get "$f" file)"
89
+ done
90
+ }
91
+
92
+ # Prints "<platform>\t<round>" of the only open round; nothing when none.
93
+ # Returns 2 (message on stderr) when two or more platforms have one open.
94
+ devlog_single_open_round() {
95
+ local rows count
96
+ rows="$(devlog_open_rounds)"
97
+ [ -n "$rows" ] || return 0
98
+ count="$(printf '%s\n' "$rows" | grep -c .)"
99
+ if [ "$count" -gt 1 ]; then
100
+ echo "其他平台還有進行中的輪次($(printf '%s\n' "$rows" | cut -f1 | grep -vxF "$(devlog_platform)" | paste -sd '、' -)),等它們收尾再執行。若該平台已不再使用,可先 pause 再 start 清掉它的標記。" >&2
101
+ return 2
102
+ fi
103
+ printf '%s\n' "$rows" | cut -f1,2
104
+ }
105
+
106
+ # One line per other platform's non-empty handoff for this branch.
107
+ devlog_other_handoffs() {
108
+ local q f
109
+ for q in $DEVLOG_PLATFORMS; do
110
+ [ "$q" = "$DEVLOG_PLATFORM" ] && continue
111
+ f="$HANDOFF_STEM.md"
112
+ [ "$q" = claude ] || f="$HANDOFF_STEM@$q.md"
113
+ [ -s "$f" ] && printf '%s\t%s\n' "$q" "$f"
114
+ done
115
+ return 0
116
+ }
11
117
 
12
118
  # Turns an arbitrary branch/worktree name into a safe devlog.<name>.md
13
119
  # filename segment: anything outside [A-Za-z0-9._-] becomes '-', repeats
@@ -24,8 +130,9 @@ _devlog_sanitize_name() {
24
130
  # work in progress). When $6 (an origin like "branch=feat/x") is given,
25
131
  # the branch file starts with its origin marker. The project header,
26
132
  # Checkpoints, and Kept/Lessons
27
- # indexes always stay in $2. When rounds move, $4 (handoff.md) moves to
28
- # $5 too, since it snapshots that same unfinished work. Caller holds the
133
+ # indexes always stay in $2. When rounds move, every platform's handoff
134
+ # under stem $4 moves to stem $5 too, since it snapshots that same
135
+ # unfinished work. Caller holds the
29
136
  # devlog lock.
30
137
  _devlog_migrate_unfinished_tail() {
31
138
  local dir="$1" src="$2" dst="$3" src_handoff="$4" dst_handoff="$5" origin="${6:-}"
@@ -82,9 +189,13 @@ _devlog_migrate_unfinished_tail() {
82
189
 
83
190
  # Branch file first: if the second mv fails the tail is duplicated, never lost.
84
191
  if mv "$dst.tmp" "$dst" 2>/dev/null && mv "$src.tmp" "$src" 2>/dev/null; then
85
- if [ -f "$src_handoff" ] && [ ! -f "$dst_handoff" ]; then
86
- mv "$src_handoff" "$dst_handoff" 2>/dev/null || true
87
- fi
192
+ local q suffix
193
+ for q in $DEVLOG_PLATFORMS; do
194
+ suffix="$(devlog_platform_file '' .md "$q")"
195
+ if [ -f "$src_handoff$suffix" ] && [ ! -f "$dst_handoff$suffix" ]; then
196
+ mv "$src_handoff$suffix" "$dst_handoff$suffix" 2>/dev/null || true
197
+ fi
198
+ done
88
199
  fi
89
200
  rm -f "$dst.tmp" "$src.tmp" 2>/dev/null || true
90
201
  }
@@ -102,13 +213,19 @@ _devlog_migrate_unfinished_tail() {
102
213
  # Also sets DEVLOG_ORIGIN ("branch=<raw name>" / "detached=<dir>", empty
103
214
  # for devlog.md): the first line written into a new branch file records it
104
215
  # as an origin marker (docs/design/keep-all.md "Origin marker").
216
+ # Call before taking the devlog lock: it may acquire/release the lock
217
+ # itself, and a nested acquire would reset the caller's lock state.
105
218
  devlog_resolve_paths() {
106
219
  local dir="${1:-.}"
107
220
  DEVLOG_DIR="$dir/.devlog"
108
221
  DEVLOG_FILE="$DEVLOG_DIR/devlog.md"
109
- # shellcheck disable=SC2034 # consumed by callers (e.g. enforce-devlog.sh), not used in this file
110
- HANDOFF_FILE="$DEVLOG_DIR/handoff.md"
222
+ DEVLOG_PLATFORM="$(devlog_platform)"
223
+ HANDOFF_STEM="$DEVLOG_DIR/handoff"
111
224
  DEVLOG_ORIGIN=""
225
+ _devlog_set_state_paths "$DEVLOG_PLATFORM"
226
+ _devlog_claim_legacy_state "$DEVLOG_PLATFORM"
227
+ # shellcheck disable=SC2034 # consumed by callers (e.g. enforce-devlog.sh), not used in this file
228
+ HANDOFF_FILE="$DEVLOG_DIR/$(devlog_platform_file handoff .md "$DEVLOG_PLATFORM")"
112
229
 
113
230
  local branch raw name origin
114
231
  branch="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
@@ -146,15 +263,16 @@ devlog_resolve_paths() {
146
263
  esac
147
264
 
148
265
  local resolved="$DEVLOG_DIR/devlog.$name.md"
149
- local resolved_handoff="$DEVLOG_DIR/handoff.$name.md"
266
+ local resolved_stem="$DEVLOG_DIR/handoff.$name"
150
267
  if [ -f "$DEVLOG_DIR/.enabled" ] && [ ! -f "$resolved" ] && [ -s "$DEVLOG_FILE" ]; then
151
268
  devlog_lock_acquire
152
269
  [ -f "$resolved" ] || _devlog_migrate_unfinished_tail \
153
- "$dir" "$DEVLOG_FILE" "$resolved" "$HANDOFF_FILE" "$resolved_handoff" "$origin"
270
+ "$dir" "$DEVLOG_FILE" "$resolved" "$HANDOFF_STEM" "$resolved_stem" "$origin"
154
271
  devlog_lock_release
155
272
  fi
156
273
  DEVLOG_FILE="$resolved"
274
+ HANDOFF_STEM="$resolved_stem"
157
275
  # shellcheck disable=SC2034 # consumed by callers (e.g. enforce-devlog.sh), not used in this file
158
- HANDOFF_FILE="$resolved_handoff"
276
+ HANDOFF_FILE="$DEVLOG_DIR/$(devlog_platform_file "handoff.$name" .md "$DEVLOG_PLATFORM")"
159
277
  DEVLOG_ORIGIN="$origin"
160
278
  }
@@ -48,21 +48,18 @@ PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
48
48
  # DEVLOG_DIR 永遠是 $PROJECT_DIR/.devlog,與分支無關,所以兩者等價。
49
49
  ENABLED_FLAG="$PROJECT_DIR/.devlog/.enabled"
50
50
  if [ ! -f "$ENABLED_FLAG" ]; then
51
- # 沒啟用就不做任何強制,但仍要清掉殘留的 .interrupted,否則之後重新
52
- # /devlog-tracker:start 會繼承一個陳舊的中斷旗標。.interrupted 跟
51
+ # 沒啟用就不做任何強制,但仍要清掉每個平台殘留的 .interrupted,否則之後
52
+ # 重新 /devlog-tracker:start 會繼承一個陳舊的中斷旗標。.interrupted 跟
53
53
  # DEVLOG_DIR 一樣與分支無關,所以這裡不必解析分支。
54
- if [ -f "$PROJECT_DIR/.devlog/.interrupted" ]; then
55
- rm -f "$PROJECT_DIR/.devlog/.interrupted" 2>/dev/null || true
56
- fi
54
+ rm -f "$PROJECT_DIR"/.devlog/.interrupted "$PROJECT_DIR"/.devlog/.interrupted@* 2>/dev/null || true
57
55
  exit 0
58
56
  fi
59
57
  # shellcheck source=devlog-path.sh
60
58
  . "$SCRIPT_DIR/devlog-path.sh"
61
59
  devlog_resolve_paths "$PROJECT_DIR"
62
- ROUND_CURRENT="$DEVLOG_DIR/.round-current.md"
63
- if [ -f "$DEVLOG_DIR/.interrupted" ]; then
60
+ if [ -f "$INTERRUPTED_FLAG" ]; then
64
61
  bash "$SCRIPT_DIR/close-open-round.sh" "user_interrupt" || true
65
- rm -f "$DEVLOG_DIR/.interrupted" 2>/dev/null || true
62
+ rm -f "$INTERRUPTED_FLAG" 2>/dev/null || true
66
63
  # close-open-round.sh merges and removes .round-current.md whenever it
67
64
  # decided the round was finished (stamped INTERRUPTED, or recovered —
68
65
  # Claude had already written Summary/Handoff before the interrupt signal
@@ -89,8 +86,6 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
89
86
  fi
90
87
 
91
88
  # --- 開關檢查 -----------------------------------------------------------
92
- TURN_MARKER="$DEVLOG_DIR/.turn-start"
93
-
94
89
  devlog_lock_acquire
95
90
  trap 'devlog_lock_release' EXIT
96
91
  if [ -n "${LOCK_CONTENDED_BY:-}" ]; then
@@ -104,7 +99,6 @@ fi
104
99
  # 每個 tick 遞增)沒有累積超過 max_silent_ticks。超過門檻就退回下面正常的
105
100
  # 雜湊比對,逼這輪真的寫點東西;寫成功後把計數器歸零。span 檔案壞掉、缺欄位
106
101
  # 或不是數字,一律當作沒有 span,直接往下走正常流程——fail-open。
107
- SPAN_FILE="$DEVLOG_DIR/.span-open"
108
102
  SPAN_VALID=0
109
103
  if [ -f "$SPAN_FILE" ]; then
110
104
  SPAN_TICKS="$(json_int_get "$SPAN_FILE" ticks_since_checkin)"
@@ -138,7 +132,7 @@ fi
138
132
 
139
133
  if [ "$CURRENT_HASH" = "$TURN_START_HASH" ]; then
140
134
  if [ "$SPAN_VALID" -eq 1 ]; then
141
- echo "這一輪尚未寫入。請依 skills/devlog-tracker/SKILL.md 在 .devlog/.round-current.md 建立一個新的 ## Round(編號接在 devlog.md 目前最後一輪之後),包含 User Input / Summary / Reply / Handoff / Status;收尾成功後 hook 會自動併回 devlog.md,不要自己直接寫進 devlog.md。" >&2
135
+ echo "這一輪尚未寫入。請依 skills/devlog-tracker/SKILL.md 在 .devlog/${ROUND_CURRENT##*/} 建立一個新的 ## Round(編號接在 devlog.md 目前最後一輪之後),包含 User Input / Summary / Reply / Handoff / Status;收尾成功後 hook 會自動併回 devlog.md,不要自己直接寫進 devlog.md。" >&2
142
136
  else
143
137
  echo "這一輪的 Round 只有 hook 寫的 User Input skeleton,還沒有收尾。請依 skills/devlog-tracker/SKILL.md 編輯最後一個 Round,補上 User Input / Summary / Reply / Handoff / Status。不要再新增一個 ## Round。" >&2
144
138
  fi
@@ -553,7 +547,7 @@ ${ACTUAL_DIRTY:-(沒有,工作樹乾淨)}"
553
547
  {
554
548
  echo "Session Handoff 格式不對:${SESSION_ERR}"
555
549
  echo ""
556
- echo "正確格式(三個標籤都要有,沒有內容就寫 - (無))。寫完後 hook 會覆寫 .devlog/handoff.md 給下一 session:"
550
+ echo "正確格式(三個標籤都要有,沒有內容就寫 - (無))。寫完後 hook 會覆寫 .devlog/${HANDOFF_FILE##*/} 給下一 session:"
557
551
  handoff_xml_template session-handoff
558
552
  } >&2
559
553
  exit 2
@@ -561,7 +555,7 @@ ${ACTUAL_DIRTY:-(沒有,工作樹乾淨)}"
561
555
  fi
562
556
  fi
563
557
 
564
- rm -f "$DEVLOG_DIR/.workspace-mismatch" 2>/dev/null || true
558
+ rm -f "$MISMATCH_FILE" 2>/dev/null || true
565
559
 
566
560
  # 這一輪通過所有驗證,正式收尾:把 .round-current.md 併回 devlog.md(併完
567
561
  # 就地刪除 .round-current.md)。放在 checkpoint 計數檢查之前,這樣如果這輪
@@ -581,7 +575,7 @@ rm -f "$DEVLOG_DIR/.workspace-mismatch" 2>/dev/null || true
581
575
  # 知道要去救它。
582
576
  if [ -n "$LAST_ROUND" ]; then
583
577
  if devlog_merge_round_current "$DEVLOG_FILE" "$ROUND_CURRENT"; then
584
- rm -f "$DEVLOG_DIR/.round-open" 2>/dev/null || true
578
+ rm -f "$ROUND_OPEN" 2>/dev/null || true
585
579
  case "${STATUS_VAL:-}" in
586
580
  IN_PROGRESS|BLOCKED)
587
581
  handoff_write "$HANDOFF_FILE" "$LAST_ROUND" 2>/dev/null || \
@@ -594,7 +588,7 @@ if [ -n "$LAST_ROUND" ]; then
594
588
  esac
595
589
  fi
596
590
  else
597
- rm -f "$DEVLOG_DIR/.round-open" 2>/dev/null || true
591
+ rm -f "$ROUND_OPEN" 2>/dev/null || true
598
592
  fi
599
593
 
600
594
  # 這輪真的有寫東西:如果剛剛因為 span 過期才走到這裡,把計數器歸零,
@@ -53,7 +53,8 @@ function roundStatus(lines) {
53
53
  }
54
54
 
55
55
  function tsKey(ts) {
56
- const iso = ts.replace(/([+-]\d\d)(\d\d)$/, '$1:$2');
56
+ // Platform ownership is part of the displayed heading, not the timestamp.
57
+ const iso = ts.replace(/ · [a-z]+$/, '').replace(/([+-]\d\d)(\d\d)$/, '$1:$2');
57
58
  const t = Date.parse(iso);
58
59
  return Number.isNaN(t) ? Infinity : t;
59
60
  }
@@ -408,8 +409,8 @@ function apply(c, planText, expected, count) {
408
409
  throw new Error(`寫入途中失敗:${e.message}\n原始檔備份在 ${backup}`);
409
410
  }
410
411
 
411
- const span = path.join(c.devlogDir, '.span-open');
412
- if (fs.existsSync(span)) {
412
+ for (const name of fs.readdirSync(c.devlogDir).filter(f => /^\.span-open(@[a-z]+)?$/.test(f))) {
413
+ const span = path.join(c.devlogDir, name);
413
414
  const m = fs.readFileSync(span, 'utf8').match(/"round"\s*:\s*(\d+)/);
414
415
  if (m && movedFromCurrent.has(Number(m[1]))) fs.rmSync(span, { force: true });
415
416
  }
@@ -20,11 +20,10 @@ PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
20
20
  devlog_resolve_paths "$PROJECT_DIR"
21
21
  [ -d "$DEVLOG_DIR" ] || { echo "NOTHING"; exit 0; }
22
22
 
23
- OPEN=""
24
- [ ! -f "$DEVLOG_DIR/.round-open" ] || OPEN="$(json_int_get "$DEVLOG_DIR/.round-open" round)"
25
-
26
23
  devlog_lock_acquire
27
24
  trap 'devlog_lock_release' EXIT
25
+ OPEN_ROW="$(devlog_single_open_round)" || exit 1
26
+ OPEN="$(printf '%s' "$OPEN_ROW" | cut -f2)"
28
27
  node "$SCRIPT_DIR/keep-all.js" "$@" \
29
28
  --dir "$DEVLOG_DIR" --project "$PROJECT_DIR" --current "${DEVLOG_FILE##*/}" \
30
29
  --open "$OPEN" --origin "${DEVLOG_ORIGIN:-}"
@@ -135,6 +135,23 @@ test('apply moves a cross-file topic into one file in time order and consumes ke
135
135
  assert.ok(fs.existsSync(path.join(backup, 'devlog.md')));
136
136
  });
137
137
 
138
+ test('scan and apply keep platform-tagged rounds in chronological order', () => {
139
+ const d = setup({
140
+ 'devlog.md': round(3, '2026-09-01T11:00:00+0800', 'DONE') + '\n' +
141
+ round(2, '2026-09-01T10:00:00+0800 · codex', 'DONE') + '\n' +
142
+ round(1, '2026-09-01T01:00:00Z · cursor', 'DONE'),
143
+ });
144
+ const c = ctx(d);
145
+ const s = scan(c);
146
+ assert.deepEqual(s.rounds.map(r => r.round), [1, 2, 3]);
147
+
148
+ apply(c, 'topic\t\t1,2,3\n', s.fingerprint, s.rounds.length);
149
+ const kept = read(d, 'devlog.topic.md');
150
+ assert.deepEqual([...kept.matchAll(/^## Round (\d+)/gm)].map(m => Number(m[1])), [1, 2, 3]);
151
+ assert.match(kept, /^## Round 1 — 2026-09-01T01:00:00Z · cursor$/m);
152
+ assert.match(kept, /^## Round 2 — 2026-09-01T10:00:00\+0800 · codex$/m);
153
+ });
154
+
138
155
  test('apply deletes a branch file it emptied, but not an active branch or devlog.md', () => {
139
156
  const d = setup({
140
157
  'devlog.feat-main.md': '<!-- devlog-origin: branch=feat/main -->\n\n' + round(1, '2026-09-05T10:00:00+0800', 'IN_PROGRESS'),
@@ -228,3 +245,17 @@ test('apply clears .span-open only when its round moved out of current', () => {
228
245
  apply(c, `b\t\t${k2.join(',')}\n`, s2.fingerprint, s2.rounds.length);
229
246
  assert.ok(!exists(d, '.span-open'));
230
247
  });
248
+
249
+ test('apply clears a platform .span-open@codex when its round moved out of current', () => {
250
+ const d = world();
251
+ fs.writeFileSync(path.join(d, '.span-open@codex'), '{"round": 1, "ticks": 0}\n');
252
+ const c = ctx(d);
253
+ const s = scan(c);
254
+ const k = [...idsOf(s, 'devlog.topic.md', [5]), ...idsOf(s, 'devlog.other.md', [9]), ...idsOf(s, 'devlog.md', [2])].join(',');
255
+ apply(c, `a\t\t${k}\n`, s.fingerprint, s.rounds.length);
256
+ assert.ok(exists(d, '.span-open@codex'));
257
+ const s2 = scan(c);
258
+ const k2 = [...s2.rounds.filter(r => r.file === 'devlog.a.md').map(r => r.id), ...idsOf(s2, 'devlog.md', [1])];
259
+ apply(c, `b\t\t${k2.join(',')}\n`, s2.fingerprint, s2.rounds.length);
260
+ assert.ok(!exists(d, '.span-open@codex'));
261
+ });
@@ -41,8 +41,9 @@ TARGET="$DEVLOG_DIR/devlog.$NAME.md"
41
41
  [ -f "$MAIN" ] || { echo "devlog.md 不存在" >&2; exit 1; }
42
42
  [ ! -e "$TARGET" ] || { echo "目標檔案已存在:$TARGET" >&2; exit 1; }
43
43
 
44
- OPEN=""
45
- [ ! -f "$DEVLOG_DIR/.round-open" ] || OPEN="$(json_int_get "$DEVLOG_DIR/.round-open" round)"
44
+ OPEN_ROW="$(devlog_single_open_round)" || exit 1
45
+ OPEN="$(printf '%s' "$OPEN_ROW" | cut -f2)"
46
+ OPEN_MARKER="$DEVLOG_DIR/$(devlog_platform_file .round-open '' "${OPEN_ROW%% *}")"
46
47
  TMP="$(mktemp -d "${TMPDIR:-/tmp}/devlog-keep.XXXXXX")" || exit 1
47
48
  trap 'rm -rf "$TMP"; devlog_lock_release' EXIT
48
49
  STARTS="$TMP/starts"
@@ -148,14 +149,17 @@ EXISTING_KEPT_LINES="$(devlog_kept_index_lines "$MAIN")"
148
149
  } > "$KEPT_STRIPPED.new" && mv "$KEPT_STRIPPED.new" "$MAIN" || exit 1
149
150
 
150
151
  if [ "$FULL" -eq 1 ]; then
151
- rm -f "$DEVLOG_DIR/.span-open"
152
- [ ! -f "$DEVLOG_DIR/.round-open" ] || json_int_set "$DEVLOG_DIR/.round-open" round 1
152
+ rm -f "$DEVLOG_DIR"/.span-open "$DEVLOG_DIR"/.span-open@*
153
+ [ -z "$OPEN_ROW" ] || json_int_set "$OPEN_MARKER" round 1
153
154
  [ ! -f "$DEVLOG_DIR/.checkpoint-state" ] || json_int_set "$DEVLOG_DIR/.checkpoint-state" rounds_since_checkpoint 0
154
- elif [ -f "$DEVLOG_DIR/.span-open" ]; then
155
- SPAN_ROUND="$(json_int_get "$DEVLOG_DIR/.span-open" round)"
156
- if [ -n "$SPAN_ROUND" ] && [ "$SPAN_ROUND" -ge "$FROM" ] && [ "$SPAN_ROUND" -le "$TO" ]; then
157
- rm -f "$DEVLOG_DIR/.span-open"
158
- fi
155
+ else
156
+ for SPAN in "$DEVLOG_DIR"/.span-open "$DEVLOG_DIR"/.span-open@*; do
157
+ [ -f "$SPAN" ] || continue
158
+ SPAN_ROUND="$(json_int_get "$SPAN" round)"
159
+ if [ -n "$SPAN_ROUND" ] && [ "$SPAN_ROUND" -ge "$FROM" ] && [ "$SPAN_ROUND" -le "$TO" ]; then
160
+ rm -f "$SPAN"
161
+ fi
162
+ done
159
163
  fi
160
164
 
161
165
  REMAINING="$(devlog_list_round_starts "$MAIN" | wc -l | tr -d ' ')"
@@ -73,7 +73,7 @@ first_visit() {
73
73
  DONE_FILES="${DONE_FILES}${1}"$'\n'
74
74
  }
75
75
 
76
- for f in "$DEVLOG_DIR/.round-current.md" "$DEVLOG_DIR/devlog.md" "$CUR_DEVLOG"; do
76
+ for f in "$DEVLOG_DIR"/.round-current.md "$DEVLOG_DIR"/.round-current@*.md "$DEVLOG_DIR/devlog.md" "$CUR_DEVLOG"; do
77
77
  [ -n "$f" ] && [ -s "$f" ] && first_visit "$f" && migrate_round_file "$f"
78
78
  done
79
79
  for f in "$DEVLOG_DIR"/devlog.*.md; do
@@ -81,7 +81,7 @@ for f in "$DEVLOG_DIR"/devlog.*.md; do
81
81
  head -n 1 "$f" | grep -q '^<!-- devlog-origin: ' || continue
82
82
  first_visit "$f" && migrate_round_file "$f"
83
83
  done
84
- for f in "$DEVLOG_DIR"/handoff.md "$CUR_HANDOFF" "$DEVLOG_DIR"/handoff.*.md; do
84
+ for f in "$DEVLOG_DIR"/handoff.md "$CUR_HANDOFF" "$DEVLOG_DIR"/handoff@*.md "$DEVLOG_DIR"/handoff.*.md; do
85
85
  [ -n "$f" ] && [ -s "$f" ] && first_visit "$f" && migrate_snapshot_file "$f"
86
86
  done
87
87
 
@@ -1,11 +1,16 @@
1
1
  #!/usr/bin/env bash
2
2
  # PostToolUseFailure: remember user Esc so Stop can stamp without blocking.
3
3
  set -uo pipefail
4
+ _src="${BASH_SOURCE[0]}"
5
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
4
6
 
5
7
  PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
6
8
  DEVLOG_DIR="$PROJECT_DIR/.devlog"
7
9
  ENABLED_FLAG="$DEVLOG_DIR/.enabled"
8
10
  [ -f "$ENABLED_FLAG" ] || exit 0
11
+ # shellcheck source=devlog-path.sh
12
+ . "$SCRIPT_DIR/devlog-path.sh"
13
+ devlog_resolve_paths "$PROJECT_DIR"
9
14
 
10
15
  INPUT="$(cat 2>/dev/null || true)"
11
16
  IS_INT="false"
@@ -20,6 +25,6 @@ fi
20
25
 
21
26
  if [ "$IS_INT" = "true" ]; then
22
27
  mkdir -p "$DEVLOG_DIR" 2>/dev/null || true
23
- : > "$DEVLOG_DIR/.interrupted" 2>/dev/null || true
28
+ : > "$INTERRUPTED_FLAG" 2>/dev/null || true
24
29
  fi
25
30
  exit 0
@@ -6,8 +6,10 @@ if [ ! -f "$DEVLOG_DIR/.enabled" ]; then
6
6
  echo "NOT_ENABLED"
7
7
  exit 0
8
8
  fi
9
- rm -f "$DEVLOG_DIR/.enabled" "$DEVLOG_DIR/.span-open" \
10
- "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.interrupted" \
11
- "$DEVLOG_DIR/.awaiting-reply"
9
+ rm -f "$DEVLOG_DIR/.enabled" \
10
+ "$DEVLOG_DIR"/.span-open "$DEVLOG_DIR"/.span-open@* \
11
+ "$DEVLOG_DIR"/.round-open "$DEVLOG_DIR"/.round-open@* \
12
+ "$DEVLOG_DIR"/.interrupted "$DEVLOG_DIR"/.interrupted@* \
13
+ "$DEVLOG_DIR"/.awaiting-reply "$DEVLOG_DIR"/.awaiting-reply@*
12
14
  echo "PAUSED"
13
15
  exit 0