devlog-tracker 0.30.0 → 0.31.1

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 (36) hide show
  1. package/README.md +22 -6
  2. package/README.zh-TW.md +22 -6
  3. package/bin/devlog-tracker.js +14 -1
  4. package/cli/agents-md.js +3 -0
  5. package/cli/agents-md.test.js +32 -0
  6. package/cli/core-script.js +25 -0
  7. package/cli/core-script.test.js +62 -0
  8. package/cli/platforms/claude.js +3 -0
  9. package/commands/overview.md +1 -1
  10. package/commands/pr.md +61 -0
  11. package/commands/promote.md +54 -0
  12. package/commands/report.md +23 -0
  13. package/commands/timeline.md +18 -0
  14. package/core/scripts/devlog-lock.sh +10 -0
  15. package/core/scripts/json-field.sh +16 -0
  16. package/core/scripts/lessons-subagent-start.sh +2 -10
  17. package/core/scripts/pr-context.sh +64 -0
  18. package/core/scripts/promote-sources.sh +40 -0
  19. package/core/scripts/promote-target.sh +37 -0
  20. package/core/scripts/promote-write.sh +74 -0
  21. package/core/scripts/report-devlog.sh +140 -0
  22. package/core/scripts/report-scan.awk +74 -0
  23. package/core/scripts/round-start.sh +1 -1
  24. package/core/scripts/tests/test-close-open-round.sh +54 -0
  25. package/core/scripts/tests/test-devlog-lock.sh +44 -0
  26. package/core/scripts/tests/test-json-field.sh +5 -0
  27. package/core/scripts/tests/test-pr-context.sh +107 -0
  28. package/core/scripts/tests/test-promote.sh +158 -0
  29. package/core/scripts/tests/test-report.sh +197 -0
  30. package/core/scripts/tests/test-round-start.sh +28 -0
  31. package/core/scripts/tests/test-timeline.sh +65 -0
  32. package/core/scripts/timeline-devlog.sh +41 -0
  33. package/core/scripts/timeline-render.js +247 -0
  34. package/core/scripts/timeline-render.test.js +101 -0
  35. package/package.json +2 -2
  36. package/skills/devlog-tracker/SKILL.md +1 -1
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # Context for /devlog-tracker:pr (docs/design/read-side-and-promote.md A).
3
+ # Plain read only: git is only queried, and gh is only asked `auth status`
4
+ # and `pr view` -- creating/editing the PR is the command doc's job, after
5
+ # the user confirms.
6
+ set -uo pipefail
7
+
8
+ _src="${BASH_SOURCE[0]}"
9
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
10
+ # shellcheck source=devlog-md.sh
11
+ . "$SCRIPT_DIR/devlog-md.sh"
12
+ # shellcheck source=devlog-path.sh
13
+ . "$SCRIPT_DIR/devlog-path.sh"
14
+
15
+ PROJECT_DIR="$(cd "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}" 2>/dev/null && pwd)" || { echo "NOT_A_REPO"; exit 0; }
16
+ git -C "$PROJECT_DIR" rev-parse --git-dir >/dev/null 2>&1 || { echo "NOT_A_REPO"; exit 0; }
17
+
18
+ BRANCH="$(git -C "$PROJECT_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null || echo '')"
19
+ case "$BRANCH" in
20
+ '') echo "DETACHED_HEAD"; exit 0 ;;
21
+ [Mm][Aa][Ii][Nn]|[Mm][Aa][Ss][Tt][Ee][Rr]) echo "ON_DEFAULT_BRANCH"; exit 0 ;;
22
+ esac
23
+
24
+ BASE=""
25
+ BASE_REF=""
26
+ ORIGIN_HEAD="$(git -C "$PROJECT_DIR" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || echo '')"
27
+ if [ -n "$ORIGIN_HEAD" ]; then
28
+ BASE="${ORIGIN_HEAD#origin/}"
29
+ BASE_REF="$ORIGIN_HEAD"
30
+ else
31
+ for candidate in main master; do
32
+ if git -C "$PROJECT_DIR" rev-parse --verify --quiet "refs/heads/$candidate" >/dev/null; then
33
+ BASE="$candidate"
34
+ BASE_REF="$candidate"
35
+ break
36
+ fi
37
+ done
38
+ fi
39
+
40
+ echo "BRANCH=$BRANCH"
41
+ [ -n "$BASE" ] || { echo "NO_BASE"; exit 0; }
42
+ echo "BASE=$BASE"
43
+ echo "BASE_REF=$BASE_REF"
44
+
45
+ devlog_resolve_paths "$PROJECT_DIR"
46
+ echo "DEVLOG_FILE=$DEVLOG_FILE"
47
+ ROUNDS=""
48
+ if [ -f "$DEVLOG_FILE" ]; then
49
+ ROUNDS="$(devlog_list_round_starts "$DEVLOG_FILE" | awk '{ printf "%s%s", (n++ ? " " : ""), $1 }')"
50
+ fi
51
+ if [ -n "$ROUNDS" ]; then echo "ROUNDS=$ROUNDS"; else echo "NO_BRANCH_DEVLOG"; fi
52
+
53
+ COMMITS="$(git -C "$PROJECT_DIR" rev-list --count "$BASE_REF..HEAD" 2>/dev/null || echo 0)"
54
+ echo "COMMITS=$COMMITS"
55
+
56
+ GH=no
57
+ PR=none
58
+ if command -v gh >/dev/null 2>&1 && (cd "$PROJECT_DIR" && gh auth status >/dev/null 2>&1); then
59
+ GH=yes
60
+ n="$(cd "$PROJECT_DIR" && gh pr view --json number -q .number 2>/dev/null || echo '')"
61
+ case "$n" in ''|*[!0-9]*) ;; *) PR="$n" ;; esac
62
+ fi
63
+ echo "GH=$GH"
64
+ echo "PR=$PR"
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ # Sources for /devlog-tracker:promote (docs/design/read-side-and-promote.md D):
3
+ # kept files, lessons files, and Checkpoint headings in the current branch's
4
+ # devlog. Plain read only. compact never moves Checkpoints, so the archive has
5
+ # none to list.
6
+ set -uo pipefail
7
+
8
+ _src="${BASH_SOURCE[0]}"
9
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
10
+ # shellcheck source=devlog-md.sh
11
+ . "$SCRIPT_DIR/devlog-md.sh"
12
+ # shellcheck source=devlog-path.sh
13
+ . "$SCRIPT_DIR/devlog-path.sh"
14
+
15
+ PROJECT_DIR="$(cd "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}" 2>/dev/null && pwd)" || { echo "NO_SOURCES"; exit 0; }
16
+ devlog_resolve_paths "$PROJECT_DIR"
17
+ MAIN="$DEVLOG_FILE"
18
+ [ -f "$MAIN" ] || { echo "NO_SOURCES"; exit 0; }
19
+
20
+ OUT=""
21
+ add() { OUT="$OUT$1"$'\n'; }
22
+ exists_flag() { if [ -f "$1" ]; then printf '1'; else printf '0'; fi; }
23
+
24
+ while IFS= read -r name; do
25
+ [ -n "$name" ] || continue
26
+ f="$DEVLOG_DIR/devlog.$name.md"
27
+ add "FILE=$f KIND=kept EXISTS=$(exists_flag "$f")"
28
+ done < <(devlog_kept_index_lines "$MAIN" | sed -nE 's/.*`devlog\.([^`]+)\.md`.*/\1/p')
29
+
30
+ while IFS= read -r topic; do
31
+ [ -n "$topic" ] || continue
32
+ f="$DEVLOG_DIR/devlog.lessons.$topic.md"
33
+ add "FILE=$f KIND=lessons EXISTS=$(exists_flag "$f")"
34
+ done < <(devlog_lessons_index_lines "$MAIN" | sed -nE 's/.*`devlog\.lessons\.([^`]+)\.md`.*/\1/p')
35
+
36
+ while IFS= read -r ln; do
37
+ [ -n "$ln" ] && add "CHECKPOINT=$MAIN:$ln"
38
+ done < <(awk '/^[ \t]*```/ { fence = !fence; next } !fence && /^## Checkpoint/ { print NR }' "$MAIN")
39
+
40
+ if [ -z "$OUT" ]; then echo "NO_SOURCES"; else printf '%s' "$OUT"; fi
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # Picks the file /devlog-tracker:promote writes rules into
3
+ # (docs/design/read-side-and-promote.md D). Plain read only.
4
+ # 1. Codex-only project (no CLAUDE.md, Codex hooks or devlog skills present) -> AGENTS.md
5
+ # 2. CLAUDE.md that is nothing but "@AGENTS.md" -> AGENTS.md
6
+ # 3. otherwise -> CLAUDE.md
7
+ set -uo pipefail
8
+
9
+ PROJECT_DIR="$(cd "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}" 2>/dev/null && pwd)" || exit 1
10
+ CLAUDE_MD="$PROJECT_DIR/CLAUDE.md"
11
+ AGENTS_MD="$PROJECT_DIR/AGENTS.md"
12
+ BEGIN_MARK='<!-- devlog-tracker:rules:begin -->'
13
+ END_MARK='<!-- devlog-tracker:rules:end -->'
14
+
15
+ HAS_CODEX=0
16
+ [ -f "$PROJECT_DIR/.codex/hooks.json" ] && HAS_CODEX=1
17
+ for d in "$PROJECT_DIR"/.agents/skills/devlog-*; do
18
+ [ -d "$d" ] && HAS_CODEX=1
19
+ done
20
+
21
+ TARGET="$CLAUDE_MD"
22
+ if [ ! -f "$CLAUDE_MD" ] && [ "$HAS_CODEX" -eq 1 ]; then
23
+ TARGET="$AGENTS_MD"
24
+ elif [ -f "$CLAUDE_MD" ] && [ "$(tr -d '[:space:]' < "$CLAUDE_MD")" = "@AGENTS.md" ]; then
25
+ TARGET="$AGENTS_MD"
26
+ fi
27
+ echo "TARGET=$TARGET"
28
+
29
+ if [ -f "$TARGET" ] && grep -qxF "$BEGIN_MARK" "$TARGET"; then
30
+ n="$(awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
31
+ $0 == b { g = 1; next }
32
+ $0 == e { g = 0 }
33
+ g && /^- / { c++ }
34
+ END { print c + 0 }
35
+ ' "$TARGET")"
36
+ echo "EXISTING=$n"
37
+ fi
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # Writes rules chosen in /devlog-tracker:promote into the managed block
3
+ # <!-- devlog-tracker:rules:begin/end --> of <target>
4
+ # (docs/design/read-side-and-promote.md D). Append-only: never edits or
5
+ # removes a rule already in the block, never touches anything outside it.
6
+ # Exact-line duplicates (against the block, and within the input) are skipped.
7
+ # Usage: promote-write.sh <target> <rules-file>
8
+ set -uo pipefail
9
+
10
+ _src="${BASH_SOURCE[0]}"
11
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
12
+ # shellcheck source=devlog-path.sh
13
+ . "$SCRIPT_DIR/devlog-path.sh"
14
+
15
+ TARGET="${1:-}"
16
+ RULES="${2:-}"
17
+ if [ -z "$TARGET" ] || [ ! -f "$RULES" ]; then
18
+ echo "USAGE: promote-write.sh <target> <rules-file>" >&2
19
+ exit 2
20
+ fi
21
+ BEGIN_MARK='<!-- devlog-tracker:rules:begin -->'
22
+ END_MARK='<!-- devlog-tracker:rules:end -->'
23
+
24
+ devlog_resolve_paths "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
25
+ NEW="$(mktemp "${TMPDIR:-/tmp}/devlog-promote.XXXXXX")" || exit 1
26
+ EXISTING="$(mktemp "${TMPDIR:-/tmp}/devlog-promote.XXXXXX")" || exit 1
27
+ TO_ADD="$(mktemp "${TMPDIR:-/tmp}/devlog-promote.XXXXXX")" || exit 1
28
+ trap 'rm -f "$NEW" "$EXISTING" "$TO_ADD" "$TARGET.tmp"; devlog_lock_release' EXIT
29
+ [ -d "$DEVLOG_DIR" ] && devlog_lock_acquire
30
+
31
+ # Normalise: trim, drop blank lines, ensure a "- " list prefix.
32
+ awk '
33
+ { sub(/^[ \t]+/, ""); sub(/[ \t]+$/, "") }
34
+ $0 != "" { if ($0 !~ /^- /) $0 = "- " $0; print }
35
+ ' "$RULES" > "$NEW"
36
+
37
+ if [ -f "$TARGET" ]; then
38
+ awk -v b="$BEGIN_MARK" -v e="$END_MARK" '$0 == b { g = 1; next } $0 == e { g = 0 } g' "$TARGET" > "$EXISTING"
39
+ fi
40
+
41
+ # FILENAME, not NR==FNR: an empty $EXISTING would make NR==FNR true for $NEW too.
42
+ awk -v ex="$EXISTING" 'FILENAME == ex { seen[$0] = 1; next } !seen[$0]++' "$EXISTING" "$NEW" > "$TO_ADD"
43
+
44
+ TOTAL="$(awk 'END { print NR }' "$NEW")"
45
+ ADDED="$(awk 'END { print NR }' "$TO_ADD")"
46
+ SKIPPED=$((TOTAL - ADDED))
47
+
48
+ if [ "$ADDED" -gt 0 ]; then
49
+ if [ -f "$TARGET" ] && grep -qxF "$BEGIN_MARK" "$TARGET" && grep -qxF "$END_MARK" "$TARGET"; then
50
+ awk -v e="$END_MARK" -v add="$TO_ADD" '
51
+ $0 == e && !done { while ((getline l < add) > 0) print l; done = 1 }
52
+ { print }
53
+ ' "$TARGET" > "$TARGET.tmp" || exit 1
54
+ else
55
+ {
56
+ if [ -s "$TARGET" ]; then
57
+ cat "$TARGET"
58
+ [ -n "$(tail -c 1 "$TARGET")" ] && printf '\n'
59
+ printf '\n'
60
+ fi
61
+ printf '%s\n## devlog-tracker 沉澱的規範\n\n' "$BEGIN_MARK"
62
+ cat "$TO_ADD"
63
+ printf '%s\n' "$END_MARK"
64
+ } > "$TARGET.tmp" || exit 1
65
+ fi
66
+ # Write back in place (not mv): TARGET may be a symlink (e.g. CLAUDE.md ->
67
+ # AGENTS.md) or have a non-default mode; mv would replace it with a plain
68
+ # 644 file and break the link. $TARGET.tmp is fully built above already,
69
+ # so this is the only step that touches TARGET.
70
+ cat "$TARGET.tmp" > "$TARGET" || exit 1
71
+ rm -f "$TARGET.tmp"
72
+ fi
73
+
74
+ echo "ADDED=$ADDED SKIPPED_DUP=$SKIPPED TARGET=$TARGET"
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bash
2
+ # Stats + structured Round data for /devlog-tracker:report, `npx devlog-tracker
3
+ # report`, and timeline-devlog.sh (docs/design/read-side-and-promote.md B).
4
+ # Plain read only: never edits a devlog file. devlog_resolve_paths may still
5
+ # do its documented first-resolve rename, same as every other reader.
6
+ set -uo pipefail
7
+
8
+ _src="${BASH_SOURCE[0]}"
9
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
10
+ # shellcheck source=json-field.sh
11
+ . "$SCRIPT_DIR/json-field.sh"
12
+ # shellcheck source=devlog-md.sh
13
+ . "$SCRIPT_DIR/devlog-md.sh"
14
+ # shellcheck source=devlog-path.sh
15
+ . "$SCRIPT_DIR/devlog-path.sh"
16
+
17
+ JSON=0
18
+ ROUNDS=0
19
+ ALL=0
20
+ WITH_INPUT=0
21
+ for arg in "$@"; do
22
+ case "$arg" in
23
+ --json) JSON=1 ;;
24
+ --rounds) ROUNDS=1 ;;
25
+ --all-branches) ALL=1 ;;
26
+ --with-input) WITH_INPUT=1 ;;
27
+ *) echo "UNKNOWN_ARG=$arg" >&2; exit 2 ;;
28
+ esac
29
+ done
30
+
31
+ devlog_resolve_paths "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
32
+ if [ ! -d "$DEVLOG_DIR" ]; then
33
+ if [ "$JSON" -eq 1 ]; then echo '{"started":false}'; else echo "NOT_STARTED"; fi
34
+ exit 0
35
+ fi
36
+
37
+ # devlog.md -> main, devlog.archive.md -> archive, devlog.<x>.md -> <x>.
38
+ branch_of_file() {
39
+ local leaf="${1##*/}"
40
+ case "$leaf" in
41
+ devlog.md) printf 'main' ;;
42
+ devlog.archive.md) printf 'archive' ;;
43
+ *) leaf="${leaf#devlog.}"; printf '%s' "${leaf%.md}" ;;
44
+ esac
45
+ }
46
+
47
+ # Archive first (oldest), then the branch file(s).
48
+ FILES=()
49
+ [ -f "$DEVLOG_DIR/devlog.archive.md" ] && FILES+=("$DEVLOG_DIR/devlog.archive.md")
50
+ if [ "$ALL" -eq 1 ]; then
51
+ shopt -s nullglob
52
+ KEPT_NAMES=" "
53
+ for f in "$DEVLOG_DIR"/devlog*.md; do
54
+ while IFS= read -r name; do
55
+ [ -n "$name" ] && KEPT_NAMES="$KEPT_NAMES$name "
56
+ done < <(devlog_kept_index_lines "$f" | sed -nE 's/.*`devlog\.([^`]+)\.md`.*/\1/p')
57
+ done
58
+ for f in "$DEVLOG_DIR"/devlog*.md; do
59
+ case "${f##*/}" in devlog.archive.md|devlog.lessons.*.md) continue ;; esac
60
+ case "$KEPT_NAMES" in *" $(branch_of_file "$f") "*) continue ;; esac
61
+ FILES+=("$f")
62
+ done
63
+ shopt -u nullglob
64
+ else
65
+ [ -f "$DEVLOG_FILE" ] && FILES+=("$DEVLOG_FILE")
66
+ fi
67
+
68
+ SCAN="$(mktemp "${TMPDIR:-/tmp}/devlog-report.XXXXXX")" || exit 1
69
+ trap 'rm -f "$SCAN"' EXIT
70
+ for f in ${FILES[@]+"${FILES[@]}"}; do
71
+ awk -v branch="$(branch_of_file "$f")" -v label="${f##*/}" -v with_input="$WITH_INPUT" \
72
+ -f "$SCRIPT_DIR/report-scan.awk" "$f" >> "$SCAN"
73
+ done
74
+
75
+ count_status() { awk -F'\t' -v s="$1" '$1 == "R" && $2 == s { c++ } END { print c + 0 }' "$SCAN"; }
76
+ BRANCH="$(branch_of_file "$DEVLOG_FILE")"
77
+ ROUNDS_TOTAL="$(awk -F'\t' '$1 == "R" { c++ } END { print c + 0 }' "$SCAN")"
78
+ ROUNDS_ARCHIVE="$(awk -F'\t' '$1 == "R" && $4 == "archive" { c++ } END { print c + 0 }' "$SCAN")"
79
+ ROUNDS_MAIN=$((ROUNDS_TOTAL - ROUNDS_ARCHIVE))
80
+ STATUS_DONE="$(count_status DONE)"
81
+ STATUS_IN_PROGRESS="$(count_status IN_PROGRESS)"
82
+ STATUS_BLOCKED="$(count_status BLOCKED)"
83
+ STATUS_INTERRUPTED="$(count_status INTERRUPTED)"
84
+ BLOCKED_RATIO=0
85
+ [ "$ROUNDS_TOTAL" -gt 0 ] && BLOCKED_RATIO=$((STATUS_BLOCKED * 100 / ROUNDS_TOTAL))
86
+ CHECKPOINTS="$(awk -F'\t' '$1 == "C" { c++ } END { print c + 0 }' "$SCAN")"
87
+ KEPT_TOPICS=0
88
+ if [ -f "$DEVLOG_FILE" ]; then
89
+ KEPT_TOPICS="$(devlog_kept_index_lines "$DEVLOG_FILE" | grep -c '^- `devlog\.' || true)"
90
+ fi
91
+ LESSONS_TOPICS=0
92
+ for f in "$DEVLOG_DIR"/devlog.lessons.*.md; do
93
+ [ -f "$f" ] && LESSONS_TOPICS=$((LESSONS_TOPICS + 1))
94
+ done
95
+ ADVISORY=""
96
+ if [ -f "$DEVLOG_DIR/.lessons-advisory-state" ]; then
97
+ a_count="$(json_int_get "$DEVLOG_DIR/.lessons-advisory-state" count)"
98
+ a_max="$(json_int_get "$DEVLOG_DIR/.lessons-advisory-state" threshold)"
99
+ ADVISORY="${a_count:-0}/${a_max:-3}"
100
+ fi
101
+ FIRST_ROUND_AT="$(awk -F'\t' '$1 == "R" && $3 != "" { print $3 }' "$SCAN" | sort | head -1)"
102
+ LAST_ROUND_AT="$(awk -F'\t' '$1 == "R" && $3 != "" { print $3 }' "$SCAN" | sort | tail -1)"
103
+ [ -n "$FIRST_ROUND_AT" ] || FIRST_ROUND_AT=none
104
+ [ -n "$LAST_ROUND_AT" ] || LAST_ROUND_AT=none
105
+
106
+ if [ "$JSON" -eq 0 ]; then
107
+ printf 'BRANCH=%s\n' "$BRANCH"
108
+ printf 'ROUNDS_TOTAL=%s\nROUNDS_MAIN=%s\nROUNDS_ARCHIVE=%s\n' "$ROUNDS_TOTAL" "$ROUNDS_MAIN" "$ROUNDS_ARCHIVE"
109
+ printf 'STATUS_DONE=%s\nSTATUS_IN_PROGRESS=%s\nSTATUS_BLOCKED=%s\nSTATUS_INTERRUPTED=%s\n' \
110
+ "$STATUS_DONE" "$STATUS_IN_PROGRESS" "$STATUS_BLOCKED" "$STATUS_INTERRUPTED"
111
+ printf 'BLOCKED_RATIO=%s\nCHECKPOINTS=%s\nKEPT_TOPICS=%s\nLESSONS_TOPICS=%s\n' \
112
+ "$BLOCKED_RATIO" "$CHECKPOINTS" "$KEPT_TOPICS" "$LESSONS_TOPICS"
113
+ [ -n "$ADVISORY" ] && printf 'LESSONS_ADVISORY=%s\n' "$ADVISORY"
114
+ printf 'FIRST_ROUND_AT=%s\nLAST_ROUND_AT=%s\n' "$FIRST_ROUND_AT" "$LAST_ROUND_AT"
115
+ exit 0
116
+ fi
117
+
118
+ jstr() {
119
+ if [ -z "$1" ] || [ "$1" = none ]; then printf 'null'
120
+ else printf '"%s"' "$(json_escape "$1")"; fi
121
+ }
122
+ {
123
+ printf '{"started":true,"branch":"%s"' "$(json_escape "$BRANCH")"
124
+ printf ',"rounds_total":%s,"rounds_main":%s,"rounds_archive":%s' "$ROUNDS_TOTAL" "$ROUNDS_MAIN" "$ROUNDS_ARCHIVE"
125
+ printf ',"status_done":%s,"status_in_progress":%s,"status_blocked":%s,"status_interrupted":%s' \
126
+ "$STATUS_DONE" "$STATUS_IN_PROGRESS" "$STATUS_BLOCKED" "$STATUS_INTERRUPTED"
127
+ printf ',"blocked_ratio":%s,"checkpoints":%s,"kept_topics":%s,"lessons_topics":%s' \
128
+ "$BLOCKED_RATIO" "$CHECKPOINTS" "$KEPT_TOPICS" "$LESSONS_TOPICS"
129
+ printf ',"lessons_advisory":%s,"first_round_at":%s,"last_round_at":%s' \
130
+ "$(jstr "$ADVISORY")" "$(jstr "$FIRST_ROUND_AT")" "$(jstr "$LAST_ROUND_AT")"
131
+ if [ "$ROUNDS" -eq 1 ]; then
132
+ printf ',"rounds":['
133
+ awk -F'\t' '$1 == "R" { printf "%s%s", (n++ ? "," : ""), $5 }' "$SCAN"
134
+ printf '],"checkpoint_blocks":['
135
+ awk -F'\t' '$1 == "C" { printf "%s%s", (n++ ? "," : ""), $3 }' "$SCAN"
136
+ printf ']'
137
+ fi
138
+ printf '}\n'
139
+ }
140
+ exit 0
@@ -0,0 +1,74 @@
1
+ # One pass over one devlog file for report-devlog.sh
2
+ # (docs/design/read-side-and-promote.md B). POSIX awk only (mawk / BSD awk).
3
+ # Vars: branch, label (file leaf name), with_input (0|1).
4
+ # Emits one line per block:
5
+ # R<TAB>status<TAB>at<TAB>branch<TAB>round-json
6
+ # C<TAB>branch<TAB>checkpoint-json
7
+ # Headings inside ``` fences are content, not structure (same rule as
8
+ # devlog-md.sh).
9
+ BEGIN {
10
+ # Build the 0x01-0x1F control bytes once; \t \r \n get their short escapes
11
+ # below, the rest (e.g. pasted ANSI \033) fall back to \u00XX.
12
+ for (ci = 1; ci <= 31; ci++) { cchar[ci] = sprintf("%c", ci); cesc[ci] = sprintf("\\u%04x", ci) }
13
+ }
14
+ function esc(s, ci) {
15
+ gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
16
+ gsub(/\t/, "\\t", s); gsub(/\r/, "\\r", s); gsub(/\n/, "\\n", s)
17
+ for (ci = 1; ci <= 31; ci++) if (index(s, cchar[ci]) > 0) gsub(cchar[ci], cesc[ci], s)
18
+ return s
19
+ }
20
+ function trim_nl(s) { sub(/^\n+/, "", s); sub(/\n+$/, "", s); return s }
21
+ function flush_section() {
22
+ if (sec == "") return
23
+ if (sec == "segment") segs[nseg++] = trim_nl(buf)
24
+ else part[sec] = trim_nl(buf)
25
+ sec = ""; buf = ""
26
+ }
27
+ function flush_block( i, st, js) {
28
+ flush_section()
29
+ if (kind == "round") {
30
+ st = part["status"]; sub(/\n.*/, "", st); gsub(/[ \t`*]/, "", st)
31
+ if (st !~ /^(DONE|IN_PROGRESS|BLOCKED|INTERRUPTED)$/) st = ""
32
+ js = "{\"branch\":\"" esc(branch) "\",\"file\":\"" esc(label) "\",\"line\":" rline ",\"n\":" rn
33
+ js = js ",\"at\":\"" esc(at) "\",\"status\":\"" st "\""
34
+ js = js ",\"summary\":\"" esc(part["summary"]) "\",\"reply\":\"" esc(part["reply"]) "\""
35
+ js = js ",\"handoff\":\"" esc(part["handoff"]) "\",\"segments\":["
36
+ for (i = 0; i < nseg; i++) js = js (i ? "," : "") "\"" esc(segs[i]) "\""
37
+ js = js "]"
38
+ if (with_input) js = js ",\"input\":\"" esc(part["input"]) "\""
39
+ js = js "}"
40
+ printf "R\t%s\t%s\t%s\t%s\n", st, at, branch, js
41
+ } else if (kind == "checkpoint") {
42
+ printf "C\t%s\t{\"branch\":\"%s\",\"file\":\"%s\",\"line\":%d,\"heading\":\"%s\",\"body\":\"%s\"}\n", \
43
+ branch, esc(branch), esc(label), cline, esc(cheading), esc(trim_nl(buf_cp))
44
+ }
45
+ kind = ""; split("", part); split("", segs); nseg = 0; buf_cp = ""
46
+ }
47
+ /^[ \t]*```/ { fence = !fence }
48
+ !fence && /^## / {
49
+ flush_block()
50
+ if ($0 ~ /^## Round [0-9]+/) {
51
+ kind = "round"; rline = NR
52
+ rn = $0; sub(/^## Round /, "", rn); sub(/[^0-9].*$/, "", rn)
53
+ at = ""
54
+ if (index($0, "— ")) at = substr($0, index($0, "— ") + length("— "))
55
+ } else if ($0 ~ /^## Checkpoint/) {
56
+ kind = "checkpoint"; cline = NR; cheading = substr($0, 4)
57
+ }
58
+ next
59
+ }
60
+ kind == "checkpoint" { buf_cp = buf_cp $0 "\n"; next }
61
+ kind == "round" && !fence && /^### / {
62
+ flush_section()
63
+ h = substr($0, 5)
64
+ if (h ~ /^User Input[ \t]*$/) sec = "input"
65
+ else if (h ~ /^Summary[ \t]*$/) sec = "summary"
66
+ else if (h ~ /^Reply[ \t]*$/) sec = "reply"
67
+ else if (h ~ /^Handoff[ \t]*$/) sec = "handoff"
68
+ else if (h ~ /^Status[ \t]*$/) sec = "status"
69
+ else if (h ~ /^段落 /) { sec = "segment"; buf = h "\n"; next }
70
+ else sec = "other"
71
+ next
72
+ }
73
+ kind == "round" && sec != "" { buf = buf $0 "\n" }
74
+ END { flush_block() }
@@ -95,7 +95,7 @@ case "$PROMPT" in
95
95
  ;;
96
96
  esac
97
97
  case "$CMD_NAME" in
98
- devlog-tracker:checkpoint|devlog-tracker:clean|devlog-tracker:compact|devlog-tracker:keep|devlog-tracker:lessons|devlog-tracker:lessons-drift|devlog-tracker:lessons-off|devlog-tracker:lessons-on|devlog-tracker:overview|devlog-tracker:pause|devlog-tracker:search|devlog-tracker:segment-watch|devlog-tracker:span|devlog-tracker:start|devlog-tracker:status)
98
+ devlog-tracker:checkpoint|devlog-tracker:clean|devlog-tracker:compact|devlog-tracker:keep|devlog-tracker:lessons|devlog-tracker:lessons-drift|devlog-tracker:lessons-off|devlog-tracker:lessons-on|devlog-tracker:overview|devlog-tracker:pause|devlog-tracker:report|devlog-tracker:search|devlog-tracker:segment-watch|devlog-tracker:span|devlog-tracker:start|devlog-tracker:status|devlog-tracker:timeline)
99
99
  exit 0
100
100
  ;;
101
101
  esac
@@ -316,6 +316,60 @@ fi
316
316
  rm -rf "$REC_DIR"
317
317
  export CLAUDE_PROJECT_DIR="$TMP_ROOT"
318
318
 
319
+ # --- L1: run as a child of the lock holder (round-start.sh's dangling heal)
320
+ # -> no wait on the parent's lock, still stamps, and its EXIT-trap
321
+ # release leaves the parent's lock in place ----------------------
322
+ # shellcheck source=../devlog-lock.sh
323
+ . "$SCRIPT_DIR/devlog-lock.sh"
324
+ write_skeleton
325
+ devlog_lock_acquire
326
+ START="$(date +%s)"
327
+ bash "$SCRIPT_DIR/close-open-round.sh" "dangling:next_prompt"
328
+ ELAPSED=$(( $(date +%s) - START ))
329
+ if [ "$ELAPSED" -le 1 ]; then
330
+ echo "PASS: child of lock holder doesn't wait (${ELAPSED}s)"
331
+ else
332
+ echo "FAIL: child of lock holder waited ${ELAPSED}s"
333
+ FAIL=1
334
+ fi
335
+ assert_contains "child of lock holder still stamps" $'### Status\nINTERRUPTED' "$(cat "$DEVLOG_DIR/devlog.md")"
336
+ if [ -d "$DEVLOG_DIR/.lock" ] && [ "$(cat "$DEVLOG_DIR/.lock/pid" 2>/dev/null)" = "$$" ]; then
337
+ echo "PASS: child's exit leaves the holder's lock"
338
+ else
339
+ echo "FAIL: holder's lock removed by child"
340
+ FAIL=1
341
+ fi
342
+ devlog_lock_release
343
+ assert_file_absent "holder release removes lock" "$DEVLOG_DIR/.lock/pid"
344
+
345
+ # --- L2: lock held by an unrelated live process (no inherited owner) ->
346
+ # still waits out the contention timeout, stamps fail-open, and
347
+ # leaves the foreign lock alone --------------------------------------
348
+ write_skeleton
349
+ sleep 5 &
350
+ LIVE_PID=$!
351
+ mkdir "$DEVLOG_DIR/.lock"
352
+ echo "$LIVE_PID" > "$DEVLOG_DIR/.lock/pid"
353
+ START="$(date +%s)"
354
+ bash "$SCRIPT_DIR/close-open-round.sh" "dangling:next_prompt"
355
+ ELAPSED=$(( $(date +%s) - START ))
356
+ if [ "$ELAPSED" -ge 2 ]; then
357
+ echo "PASS: unrelated holder still contends (${ELAPSED}s)"
358
+ else
359
+ echo "FAIL: unrelated holder skipped contention (${ELAPSED}s)"
360
+ FAIL=1
361
+ fi
362
+ assert_contains "contended run still stamps (fail-open)" $'### Status\nINTERRUPTED' "$(cat "$DEVLOG_DIR/devlog.md")"
363
+ if [ "$(cat "$DEVLOG_DIR/.lock/pid" 2>/dev/null)" = "$LIVE_PID" ]; then
364
+ echo "PASS: foreign lock left in place"
365
+ else
366
+ echo "FAIL: foreign lock touched"
367
+ FAIL=1
368
+ fi
369
+ kill "$LIVE_PID" 2>/dev/null || true
370
+ wait "$LIVE_PID" 2>/dev/null || true
371
+ rm -rf "$DEVLOG_DIR/.lock"
372
+
319
373
  if [ "$FAIL" -eq 0 ]; then
320
374
  echo "All checks passed."
321
375
  exit 0
@@ -72,5 +72,49 @@ kill "$LIVE_PID" 2>/dev/null || true
72
72
  wait "$LIVE_PID" 2>/dev/null || true
73
73
  rm -rf "$DEVLOG_DIR/.lock"
74
74
 
75
+ # Re-entry from a child process: a script run by the lock holder (e.g.
76
+ # round-start.sh -> close-open-round.sh) sees the lock as already its own —
77
+ # returns at once, and its release leaves the parent's lock in place.
78
+ devlog_lock_acquire
79
+ START="$(date +%s)"
80
+ CHILD="$(bash -c '. "$1"; DEVLOG_DIR="$2"; devlog_lock_acquire; echo "held=${LOCK_HELD:-0} by=${LOCK_CONTENDED_BY:-}"; devlog_lock_release' _ "$SCRIPT_DIR/devlog-lock.sh" "$DEVLOG_DIR")"
81
+ ELAPSED=$(( $(date +%s) - START ))
82
+ [ "$ELAPSED" -le 1 ] && [ "$CHILD" = "held=0 by=" ] \
83
+ && echo "PASS: child of holder re-enters without waiting" \
84
+ || { echo "FAIL: child re-entry elapsed=$ELAPSED child='$CHILD'"; FAIL=1; }
85
+ [ -d "$DEVLOG_DIR/.lock" ] && [ "$(cat "$DEVLOG_DIR/.lock/pid")" = "$$" ] \
86
+ && echo "PASS: child release leaves parent's lock" \
87
+ || { echo "FAIL: parent's lock gone after child release"; FAIL=1; }
88
+ # Grandchild (holder -> script -> script) inherits the owner too.
89
+ START="$(date +%s)"
90
+ GRAND="$(bash -c 'bash -c ". \"\$1\"; DEVLOG_DIR=\"\$2\"; devlog_lock_acquire; echo held=\${LOCK_HELD:-0}; devlog_lock_release" _ "$1" "$2"' _ "$SCRIPT_DIR/devlog-lock.sh" "$DEVLOG_DIR")"
91
+ ELAPSED=$(( $(date +%s) - START ))
92
+ [ "$GRAND" = "held=0" ] && [ "$ELAPSED" -le 1 ] && [ -d "$DEVLOG_DIR/.lock" ] \
93
+ && echo "PASS: grandchild of holder re-enters" || { echo "FAIL: grandchild got '$GRAND' elapsed=$ELAPSED"; FAIL=1; }
94
+ devlog_lock_release
95
+ [ -z "${DEVLOG_LOCK_OWNER:-}" ] && [ ! -d "$DEVLOG_DIR/.lock" ] \
96
+ && echo "PASS: release clears owner" || { echo "FAIL: owner=${DEVLOG_LOCK_OWNER:-} survives release"; FAIL=1; }
97
+
98
+ # After the holder releases, a child it spawns later no longer carries the
99
+ # owner, so it would contend on a lock someone else took meanwhile.
100
+ OUT="$(bash -c 'echo "owner=${DEVLOG_LOCK_OWNER:-}"')"
101
+ [ "$OUT" = "owner=" ] && echo "PASS: owner not exported after release" \
102
+ || { echo "FAIL: child still sees $OUT"; FAIL=1; }
103
+
104
+ # A process that did not inherit the owner (another hook launched by the
105
+ # agent while the lock is held) still waits out the timeout.
106
+ sleep 5 &
107
+ LIVE_PID=$!
108
+ mkdir "$DEVLOG_DIR/.lock"
109
+ echo "$LIVE_PID" > "$DEVLOG_DIR/.lock/pid"
110
+ START="$(date +%s)"
111
+ OUT="$(DEVLOG_LOCK_OWNER=12345 bash -c '. "$1"; DEVLOG_DIR="$2"; devlog_lock_acquire; echo "${LOCK_HELD:-0}"' _ "$SCRIPT_DIR/devlog-lock.sh" "$DEVLOG_DIR")"
112
+ ELAPSED=$(( $(date +%s) - START ))
113
+ [ "$OUT" = "0" ] && [ "$ELAPSED" -ge 2 ] \
114
+ && echo "PASS: unrelated owner still contends" || { echo "FAIL: unrelated owner out=$OUT elapsed=$ELAPSED"; FAIL=1; }
115
+ kill "$LIVE_PID" 2>/dev/null || true
116
+ wait "$LIVE_PID" 2>/dev/null || true
117
+ rm -rf "$DEVLOG_DIR/.lock"
118
+
75
119
  if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
76
120
  else echo "Some checks FAILED."; exit 1; fi
@@ -70,5 +70,10 @@ assert_eq "slugify basic" "foo-bar" "$(slugify 'foo bar')"
70
70
  assert_eq "slugify collapses runs" "foo-bar" "$(slugify 'foo bar')"
71
71
  assert_eq "slugify trims edges" "foo" "$(slugify ' foo ')"
72
72
 
73
+ assert_eq "json_escape quotes and backslash" 'a \"q\" \\ b' "$(json_escape 'a "q" \ b')"
74
+ assert_eq "json_escape tab" 'x\ty' "$(json_escape "$(printf 'x\ty')")"
75
+ assert_eq "json_escape newline" 'l1\nl2' "$(json_escape "$(printf 'l1\nl2')")"
76
+ assert_eq "json_escape CJK passthrough" '中文' "$(json_escape '中文')"
77
+
73
78
  if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
74
79
  else echo "Some checks FAILED."; exit 1; fi