devlog-tracker 0.30.0 → 0.31.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.
@@ -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
@@ -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
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for pr-context.sh (docs/design/read-side-and-promote.md A).
3
+ set -uo pipefail
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ TMP_ROOT="$(mktemp -d)"
6
+ trap 'rm -rf "$TMP_ROOT"' EXIT
7
+ FAIL=0
8
+ assert_line() {
9
+ local desc="$1" expected="$2" out="$3"
10
+ if printf '%s\n' "$out" | grep -qxF "$expected"; then echo "PASS: $desc"
11
+ else echo "FAIL: $desc (missing [$expected] in: $out)"; FAIL=1; fi
12
+ }
13
+ assert_eq() {
14
+ local desc="$1" expected="$2" actual="$3"
15
+ if [ "$expected" = "$actual" ]; then echo "PASS: $desc"
16
+ else echo "FAIL: $desc (expected [$expected] got [$actual])"; FAIL=1; fi
17
+ }
18
+
19
+ # gh stubs: one that is not logged in, one that is and has PR #42.
20
+ GH_OFF="$TMP_ROOT/gh-off"; GH_ON="$TMP_ROOT/gh-on"
21
+ mkdir -p "$GH_OFF" "$GH_ON"
22
+ printf '#!/usr/bin/env bash\nexit 1\n' > "$GH_OFF/gh"
23
+ cat > "$GH_ON/gh" <<'EOF'
24
+ #!/usr/bin/env bash
25
+ case "$1 $2" in
26
+ "auth status") exit 0 ;;
27
+ "pr view") echo 42 ;;
28
+ *) echo "unexpected gh call: $*" >&2; exit 9 ;;
29
+ esac
30
+ EOF
31
+ chmod +x "$GH_OFF/gh" "$GH_ON/gh"
32
+ ctx() { PATH="$1:$PATH" CLAUDE_PROJECT_DIR="$2" bash "$SCRIPT_DIR/pr-context.sh"; }
33
+
34
+ # --- Not a git repo -------------------------------------------------------------
35
+ PLAIN="$TMP_ROOT/plain"; mkdir -p "$PLAIN"
36
+ assert_eq "not a repo" "NOT_A_REPO" "$(ctx "$GH_OFF" "$PLAIN")"
37
+
38
+ # --- Repo fixture ------------------------------------------------------------------
39
+ REPO="$TMP_ROOT/repo"; mkdir -p "$REPO"
40
+ gitc() { git -C "$REPO" -c user.email=t@example.com -c user.name=t -c commit.gpgsign=false "$@"; }
41
+ gitc init -q -b main
42
+ echo a > "$REPO/a.txt"; gitc add a.txt; gitc commit -qm init
43
+
44
+ assert_eq "on main" "ON_DEFAULT_BRANCH" "$(ctx "$GH_OFF" "$REPO")"
45
+ gitc checkout -q -b MASTER
46
+ assert_eq "on MASTER (case-insensitive)" "ON_DEFAULT_BRANCH" "$(ctx "$GH_OFF" "$REPO")"
47
+ gitc checkout -q main
48
+ gitc checkout -q --detach
49
+ assert_eq "detached" "DETACHED_HEAD" "$(ctx "$GH_OFF" "$REPO")"
50
+ gitc checkout -q main
51
+
52
+ # --- Feature branch, no devlog, gh logged out --------------------------------------
53
+ gitc checkout -q -b feat/x
54
+ echo b > "$REPO/b.txt"; gitc add b.txt; gitc commit -qm "feat: b"
55
+ OUT="$(ctx "$GH_OFF" "$REPO")"
56
+ assert_line "branch" "BRANCH=feat/x" "$OUT"
57
+ assert_line "base from local main" "BASE=main" "$OUT"
58
+ assert_line "base ref" "BASE_REF=main" "$OUT"
59
+ assert_line "devlog file is branch-scoped + absolute" "DEVLOG_FILE=$(cd "$REPO" && pwd)/.devlog/devlog.feat-x.md" "$OUT"
60
+ assert_line "no branch devlog" "NO_BRANCH_DEVLOG" "$OUT"
61
+ assert_line "commit count" "COMMITS=1" "$OUT"
62
+ assert_line "gh logged out" "GH=no" "$OUT"
63
+ assert_line "no pr" "PR=none" "$OUT"
64
+
65
+ # --- Branch devlog with rounds + gh logged in -------------------------------------
66
+ mkdir -p "$REPO/.devlog"
67
+ cat > "$REPO/.devlog/devlog.feat-x.md" <<'EOF'
68
+ ## Round 1 — 2026-09-01T10:00:00+0800
69
+
70
+ ### Summary
71
+ one
72
+
73
+ ### Status
74
+ DONE
75
+
76
+ ## Round 2 — 2026-09-02T10:00:00+0800
77
+
78
+ ### Summary
79
+ two
80
+
81
+ ### Status
82
+ DONE
83
+ EOF
84
+ OUT="$(ctx "$GH_ON" "$REPO")"
85
+ assert_line "round start lines" "ROUNDS=1 9" "$OUT"
86
+ assert_line "gh logged in" "GH=yes" "$OUT"
87
+ assert_line "existing pr number" "PR=42" "$OUT"
88
+
89
+ # --- origin/HEAD wins over local main ---------------------------------------------
90
+ gitc update-ref refs/remotes/origin/main main
91
+ gitc symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
92
+ OUT="$(ctx "$GH_OFF" "$REPO")"
93
+ assert_line "base from origin/HEAD" "BASE=main" "$OUT"
94
+ assert_line "base ref is remote" "BASE_REF=origin/main" "$OUT"
95
+
96
+ # --- No main/master and no origin/HEAD -> NO_BASE ------------------------------
97
+ REPO2="$TMP_ROOT/repo2"; mkdir -p "$REPO2"
98
+ git -C "$REPO2" -c user.email=t@example.com -c user.name=t init -q -b trunk
99
+ echo a > "$REPO2/a.txt"; git -C "$REPO2" add a.txt
100
+ git -C "$REPO2" -c user.email=t@example.com -c user.name=t -c commit.gpgsign=false commit -qm init
101
+ git -C "$REPO2" checkout -q -b dev
102
+ OUT="$(ctx "$GH_OFF" "$REPO2")"
103
+ assert_line "no base: branch still printed" "BRANCH=dev" "$OUT"
104
+ assert_line "no base" "NO_BASE" "$OUT"
105
+
106
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
107
+ else echo "Some checks FAILED."; exit 1; fi
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for promote-{sources,target,write}.sh
3
+ # (docs/design/read-side-and-promote.md D).
4
+ set -uo pipefail
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
6
+ TMP_ROOT="$(mktemp -d)"
7
+ trap 'rm -rf "$TMP_ROOT"' EXIT
8
+ FAIL=0
9
+ assert_line() {
10
+ local desc="$1" expected="$2" out="$3"
11
+ if printf '%s\n' "$out" | grep -qxF "$expected"; then echo "PASS: $desc"
12
+ else echo "FAIL: $desc (missing [$expected] in: $out)"; FAIL=1; fi
13
+ }
14
+ assert_eq() {
15
+ local desc="$1" expected="$2" actual="$3"
16
+ if [ "$expected" = "$actual" ]; then echo "PASS: $desc"
17
+ else echo "FAIL: $desc (expected [$expected] got [$actual])"; FAIL=1; fi
18
+ }
19
+ new_project() { local d; d="$(mktemp -d "$TMP_ROOT/p.XXXXXX")"; (cd "$d" && pwd); }
20
+
21
+ # === promote-sources.sh =========================================================
22
+ P="$(new_project)"
23
+ assert_eq "sources: no devlog -> NO_SOURCES" "NO_SOURCES" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-sources.sh")"
24
+
25
+ mkdir -p "$P/.devlog"
26
+ cat > "$P/.devlog/devlog.md" <<'EOF'
27
+ ## Round 1 — 2026-09-01T10:00:00+0800
28
+
29
+ ### Summary
30
+ x
31
+
32
+ ### Status
33
+ DONE
34
+ EOF
35
+ assert_eq "sources: devlog without index/checkpoint -> NO_SOURCES" "NO_SOURCES" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-sources.sh")"
36
+
37
+ cat >> "$P/.devlog/devlog.md" <<'EOF'
38
+
39
+ ```
40
+ ## Checkpoint fake inside fence
41
+ ```
42
+
43
+ ## Checkpoint(Round 1-1)
44
+ ### 決策
45
+ - always run shellcheck
46
+
47
+ ## Kept 索引
48
+ - `devlog.topic-a.md`:Round 1-1,kept_at 2026-09-05T00:00:00+0800,topic a
49
+ - `devlog.ghost.md`:Round 2-2,kept_at 2026-09-05T00:00:00+0800,gone
50
+
51
+ ## Lessons 索引
52
+ - `devlog.lessons.lock-files.md`:1 則,最新一則「x。」(updated_at 2026-09-01T00:00:00+0800)
53
+ EOF
54
+ printf 'kept\n' > "$P/.devlog/devlog.topic-a.md"
55
+ printf '# Lessons: lock-files\n' > "$P/.devlog/devlog.lessons.lock-files.md"
56
+ OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-sources.sh")"
57
+ assert_line "sources: kept file exists" "FILE=$P/.devlog/devlog.topic-a.md KIND=kept EXISTS=1" "$OUT"
58
+ assert_line "sources: kept ghost row" "FILE=$P/.devlog/devlog.ghost.md KIND=kept EXISTS=0" "$OUT"
59
+ assert_line "sources: lessons file" "FILE=$P/.devlog/devlog.lessons.lock-files.md KIND=lessons EXISTS=1" "$OUT"
60
+ CP_LINE="$(grep -n '^## Checkpoint(Round 1-1)' "$P/.devlog/devlog.md" | cut -d: -f1)"
61
+ assert_line "sources: real checkpoint line" "CHECKPOINT=$P/.devlog/devlog.md:$CP_LINE" "$OUT"
62
+ assert_eq "sources: fenced checkpoint ignored" "1" "$(printf '%s\n' "$OUT" | grep -c '^CHECKPOINT=')"
63
+
64
+ # === promote-target.sh ==========================================================
65
+ P="$(new_project)"
66
+ assert_eq "target: empty project -> CLAUDE.md" "TARGET=$P/CLAUDE.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
67
+
68
+ printf '@AGENTS.md\n\n' > "$P/CLAUDE.md"
69
+ assert_eq "target: CLAUDE.md only imports AGENTS.md -> AGENTS.md" "TARGET=$P/AGENTS.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
70
+
71
+ printf '# Rules\n\n@AGENTS.md\n' > "$P/CLAUDE.md"
72
+ assert_eq "target: CLAUDE.md with own content -> CLAUDE.md" "TARGET=$P/CLAUDE.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
73
+
74
+ P="$(new_project)"
75
+ mkdir -p "$P/.codex"; printf '{}\n' > "$P/.codex/hooks.json"
76
+ assert_eq "target: codex-only (hooks.json) -> AGENTS.md" "TARGET=$P/AGENTS.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
77
+
78
+ P="$(new_project)"
79
+ mkdir -p "$P/.agents/skills/devlog-start"
80
+ assert_eq "target: codex-only (skills) -> AGENTS.md" "TARGET=$P/AGENTS.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
81
+
82
+ P="$(new_project)"
83
+ printf '# Mine\n\n<!-- devlog-tracker:rules:begin -->\n## devlog-tracker 沉澱的規範\n\n- a\n- b\n<!-- devlog-tracker:rules:end -->\n' > "$P/CLAUDE.md"
84
+ OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
85
+ assert_line "target: existing block counted" "EXISTING=2" "$OUT"
86
+
87
+ # === promote-write.sh ==========================================================
88
+ BEGIN_MARK='<!-- devlog-tracker:rules:begin -->'
89
+ END_MARK='<!-- devlog-tracker:rules:end -->'
90
+ P="$(new_project)"
91
+ RULES="$TMP_ROOT/rules.txt"
92
+
93
+ # --- target missing -> created with header ---------------------------------------
94
+ printf ' 改核心腳本後跑 run-tests.sh \n\n- 一般 PR 不改版號\n' > "$RULES"
95
+ OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES")"
96
+ assert_eq "write: new file result" "ADDED=2 SKIPPED_DUP=0 TARGET=$P/CLAUDE.md" "$OUT"
97
+ EXPECTED="$(printf '%s\n## devlog-tracker 沉澱的規範\n\n- 改核心腳本後跑 run-tests.sh\n- 一般 PR 不改版號\n%s' "$BEGIN_MARK" "$END_MARK")"
98
+ assert_eq "write: new file content (trimmed, '- ' prefixed, blank skipped)" "$EXPECTED" "$(cat "$P/CLAUDE.md")"
99
+
100
+ # --- existing content without block -> appended after a blank line -------------------
101
+ P="$(new_project)"
102
+ printf '# Mine\nkeep me' > "$P/CLAUDE.md"
103
+ printf 'rule one\n' > "$RULES"
104
+ CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
105
+ EXPECTED="$(printf '# Mine\nkeep me\n\n%s\n## devlog-tracker 沉澱的規範\n\n- rule one\n%s' "$BEGIN_MARK" "$END_MARK")"
106
+ assert_eq "write: appended after existing content (missing trailing newline handled)" "$EXPECTED" "$(cat "$P/CLAUDE.md")"
107
+
108
+ # --- existing block -> inserted before end mark; content after block kept -----------
109
+ printf '\n## After\ntail\n' >> "$P/CLAUDE.md"
110
+ printf 'rule two\n' > "$RULES"
111
+ CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
112
+ EXPECTED="$(printf '# Mine\nkeep me\n\n%s\n## devlog-tracker 沉澱的規範\n\n- rule one\n- rule two\n%s\n\n## After\ntail' "$BEGIN_MARK" "$END_MARK")"
113
+ assert_eq "write: appended inside existing block" "$EXPECTED" "$(cat "$P/CLAUDE.md")"
114
+
115
+ # --- exact duplicates (existing + within input) skipped; file unchanged -------------
116
+ BEFORE="$(cat "$P/CLAUDE.md")"
117
+ printf -- '- rule one\nrule two\n' > "$RULES"
118
+ OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES")"
119
+ assert_eq "write: all duplicates" "ADDED=0 SKIPPED_DUP=2 TARGET=$P/CLAUDE.md" "$OUT"
120
+ assert_eq "write: file unchanged on all-dup" "$BEFORE" "$(cat "$P/CLAUDE.md")"
121
+ printf 'rule three\nrule three\n' > "$RULES"
122
+ OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES")"
123
+ assert_eq "write: duplicate within input counted once" "ADDED=1 SKIPPED_DUP=1 TARGET=$P/CLAUDE.md" "$OUT"
124
+
125
+ # --- symlink target: write-in-place preserves the link, rules land in the real file --
126
+ P="$(new_project)"
127
+ printf '# Base\n' > "$P/AGENTS.md"
128
+ ln -s AGENTS.md "$P/CLAUDE.md"
129
+ printf 'rule via symlink\n' > "$RULES"
130
+ CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
131
+ if [ -L "$P/CLAUDE.md" ]; then echo "PASS: write: symlink target stays a symlink"
132
+ else echo "FAIL: write: symlink was replaced by a regular file"; FAIL=1; fi
133
+ if grep -qF "rule via symlink" "$P/AGENTS.md"; then echo "PASS: write: rules land in the symlink's real target"
134
+ else echo "FAIL: write: rules missing from AGENTS.md"; FAIL=1; fi
135
+
136
+ # --- mode is preserved on a plain (non-symlink) target -------------------------------
137
+ P="$(new_project)"
138
+ printf '# Mode\n' > "$P/CLAUDE.md"
139
+ chmod 640 "$P/CLAUDE.md"
140
+ printf 'rule mode\n' > "$RULES"
141
+ CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
142
+ assert_eq "write: mode preserved (640)" "-rw-r-----" "$(ls -l "$P/CLAUDE.md" | cut -c1-10)"
143
+
144
+ # --- lock released ------------------------------------------------------------------
145
+ mkdir -p "$P/.devlog"
146
+ CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
147
+ [ ! -e "$P/.devlog/.lock" ] && echo "PASS: write: lock released" || { echo "FAIL: write: .lock left behind"; FAIL=1; }
148
+
149
+ # --- usage errors ---------------------------------------------------------------------
150
+ bash "$SCRIPT_DIR/promote-write.sh" >/dev/null 2>&1
151
+ RC=$?
152
+ assert_eq "write: no args exits 2" "2" "$RC"
153
+ bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$TMP_ROOT/missing.txt" >/dev/null 2>&1
154
+ RC=$?
155
+ assert_eq "write: missing rules file exits 2" "2" "$RC"
156
+
157
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
158
+ else echo "Some checks FAILED."; exit 1; fi