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.
- package/README.md +7 -5
- package/README.zh-TW.md +7 -5
- package/codex/hooks/on-interrupt.sh +1 -0
- package/codex/hooks/on-pre-tool.sh +9 -7
- package/codex/hooks/on-session-end.sh +1 -0
- package/codex/hooks/on-session-start.sh +1 -0
- package/codex/hooks/on-stop.sh +1 -0
- package/codex/hooks/on-subagent-start.sh +1 -0
- package/codex/hooks/on-user-prompt-submit.sh +1 -0
- package/codex/hooks/test-adapters.sh +29 -19
- package/commands/clean.md +2 -2
- package/commands/continue.md +1 -1
- package/commands/keep-all.md +4 -4
- package/commands/keep.md +4 -4
- package/commands/migrate.md +2 -2
- package/commands/span.md +4 -4
- package/commands/status.md +1 -1
- package/core/scripts/await-open.sh +4 -4
- package/core/scripts/clean-devlog.sh +11 -5
- package/core/scripts/close-open-round.sh +0 -4
- package/core/scripts/devlog-md.sh +89 -8
- package/core/scripts/devlog-path.sh +128 -10
- package/core/scripts/enforce-devlog.sh +10 -16
- package/core/scripts/keep-all.js +4 -3
- package/core/scripts/keep-all.sh +2 -3
- package/core/scripts/keep-all.test.js +31 -0
- package/core/scripts/keep-move.sh +13 -9
- package/core/scripts/migrate-handoff.sh +2 -2
- package/core/scripts/on-tool-failure.sh +6 -1
- package/core/scripts/pause-devlog.sh +5 -3
- package/core/scripts/round-start.sh +31 -20
- package/core/scripts/segment-watch-set.sh +12 -8
- package/core/scripts/segment-watch.sh +3 -6
- package/core/scripts/session-start-devlog.sh +11 -5
- package/core/scripts/span-close.sh +7 -3
- package/core/scripts/span-open.sh +4 -4
- package/core/scripts/status-devlog.sh +6 -6
- package/core/scripts/tests/test-await-open.sh +9 -0
- package/core/scripts/tests/test-devlog-md.sh +76 -15
- package/core/scripts/tests/test-devlog-path.sh +126 -0
- package/core/scripts/tests/test-multi-platform.sh +286 -0
- package/core/scripts/tests/test-segment-watch-set.sh +10 -0
- package/core/scripts/tests/test-session-start-devlog.sh +15 -0
- package/cursor/hooks/on-pre-tool.sh +1 -0
- package/cursor/hooks/on-session-end.sh +1 -0
- package/cursor/hooks/on-session-start.sh +1 -0
- package/cursor/hooks/on-stop.sh +1 -0
- package/cursor/hooks/on-submit-prompt.sh +1 -0
- package/cursor/hooks/on-tool-failure.sh +1 -0
- package/cursor/hooks/test-adapters.sh +19 -16
- package/package.json +1 -1
- package/skills/devlog-tracker/SKILL.md +23 -18
- package/skills/devlog-tracker/references/contract.md +2 -2
- package/skills/devlog-tracker/references/reply-fold.md +8 -8
- package/skills/devlog-tracker/references/round-segments.md +2 -2
- 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
|
-
|
|
199
|
-
# Moves the
|
|
200
|
-
#
|
|
201
|
-
# has no round
|
|
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="$(
|
|
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="$(
|
|
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,
|
|
28
|
-
# $5 too, since it snapshots that same
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
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" "$
|
|
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="$
|
|
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
|
-
#
|
|
52
|
-
# /devlog-tracker:start 會繼承一個陳舊的中斷旗標。.interrupted 跟
|
|
51
|
+
# 沒啟用就不做任何強制,但仍要清掉每個平台殘留的 .interrupted,否則之後
|
|
52
|
+
# 重新 /devlog-tracker:start 會繼承一個陳舊的中斷旗標。.interrupted 跟
|
|
53
53
|
# DEVLOG_DIR 一樣與分支無關,所以這裡不必解析分支。
|
|
54
|
-
|
|
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
|
-
|
|
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 "$
|
|
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
|
|
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
|
|
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 "$
|
|
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 "$
|
|
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 "$
|
|
591
|
+
rm -f "$ROUND_OPEN" 2>/dev/null || true
|
|
598
592
|
fi
|
|
599
593
|
|
|
600
594
|
# 這輪真的有寫東西:如果剛剛因為 span 過期才走到這裡,把計數器歸零,
|
package/core/scripts/keep-all.js
CHANGED
|
@@ -53,7 +53,8 @@ function roundStatus(lines) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function tsKey(ts) {
|
|
56
|
-
|
|
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
|
|
412
|
-
|
|
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
|
}
|
package/core/scripts/keep-all.sh
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
: > "$
|
|
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"
|
|
10
|
-
"$DEVLOG_DIR/.
|
|
11
|
-
"$DEVLOG_DIR/.
|
|
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
|