devlog-tracker 0.29.2 → 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.
Files changed (37) hide show
  1. package/README.md +23 -7
  2. package/README.zh-TW.md +23 -7
  3. package/bin/devlog-tracker.js +14 -1
  4. package/claude/hooks.json +21 -0
  5. package/cli/agents-md.js +3 -0
  6. package/cli/agents-md.test.js +32 -0
  7. package/cli/core-script.js +25 -0
  8. package/cli/core-script.test.js +62 -0
  9. package/cli/platforms/claude.js +3 -0
  10. package/commands/overview.md +1 -1
  11. package/commands/pr.md +61 -0
  12. package/commands/promote.md +54 -0
  13. package/commands/report.md +23 -0
  14. package/commands/timeline.md +18 -0
  15. package/core/scripts/json-field.sh +16 -0
  16. package/core/scripts/lessons-subagent-done.sh +36 -0
  17. package/core/scripts/lessons-subagent-start.sh +28 -0
  18. package/core/scripts/pr-context.sh +64 -0
  19. package/core/scripts/promote-sources.sh +40 -0
  20. package/core/scripts/promote-target.sh +37 -0
  21. package/core/scripts/promote-write.sh +74 -0
  22. package/core/scripts/report-devlog.sh +140 -0
  23. package/core/scripts/report-scan.awk +74 -0
  24. package/core/scripts/round-start.sh +15 -1
  25. package/core/scripts/tests/test-json-field.sh +5 -0
  26. package/core/scripts/tests/test-lessons-subagent-hooks.sh +95 -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 +65 -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 +5 -3
  37. package/skills/devlog-tracker/references/lessons-mode.md +10 -4
@@ -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
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for report-devlog.sh (docs/design/read-side-and-promote.md B).
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
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
8
+ DEVLOG_DIR="$TMP_ROOT/.devlog"
9
+ FAIL=0
10
+ assert_line() {
11
+ local desc="$1" expected="$2" out="$3"
12
+ if printf '%s\n' "$out" | grep -qxF "$expected"; then echo "PASS: $desc"
13
+ else echo "FAIL: $desc (missing line [$expected] in: $out)"; FAIL=1; fi
14
+ }
15
+ assert_no_line_prefix() {
16
+ local desc="$1" prefix="$2" out="$3"
17
+ if printf '%s\n' "$out" | grep -q "^$prefix"; then echo "FAIL: $desc (found $prefix)"; FAIL=1
18
+ else echo "PASS: $desc"; fi
19
+ }
20
+ jcheck() {
21
+ local desc="$1" json="$2" expr="$3"
22
+ if ! command -v node >/dev/null 2>&1; then echo "SKIP: $desc (no node)"; return 0; fi
23
+ if printf '%s' "$json" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); process.exit(($expr)?0:1)"; then
24
+ echo "PASS: $desc"
25
+ else echo "FAIL: $desc"; FAIL=1; fi
26
+ }
27
+
28
+ # --- No .devlog -> NOT_STARTED ----------------------------------------------
29
+ OUT="$(bash "$SCRIPT_DIR/report-devlog.sh")"
30
+ assert_line "no .devlog -> NOT_STARTED" "NOT_STARTED" "$OUT"
31
+
32
+ # --- Fixture ------------------------------------------------------------------
33
+ mkdir -p "$DEVLOG_DIR"
34
+ {
35
+ cat <<'EOF'
36
+ ## Round 1 — 2026-09-01T10:00:00+0800
37
+
38
+ ### User Input
39
+ secret-input-text
40
+
41
+ ### Summary
42
+ EOF
43
+ printf 'first "quoted" \\ back\ttab 中文\n'
44
+ cat <<'EOF'
45
+
46
+ ### Handoff
47
+ #### 現況
48
+ x
49
+
50
+ ### Status
51
+ DONE
52
+
53
+ ## Round 2 — 2026-09-02T10:00:00+0800
54
+
55
+ ### Summary
56
+ blocked one
57
+ ```
58
+ ## Round 9 fake inside fence
59
+ ```
60
+
61
+ ### Status
62
+ BLOCKED
63
+
64
+ ## Checkpoint(Round 1-2)
65
+ ### 決策
66
+ - keep it
67
+
68
+ ## Round 3 — 2026-09-03T10:00:00+0800
69
+
70
+ ### Summary
71
+ in progress
72
+
73
+ ### 段落 探索
74
+ seg body
75
+
76
+ ### Status
77
+ INTERRUPTED
78
+ [reason: dangling:next_prompt]
79
+
80
+ ## Kept 索引
81
+ - `devlog.topic-a.md`:Round 5-5,kept_at 2026-09-05T00:00:00+0800,topic a
82
+ EOF
83
+ } > "$DEVLOG_DIR/devlog.md"
84
+ cat > "$DEVLOG_DIR/devlog.archive.md" <<'EOF'
85
+ ## Round 0 — 2026-08-31T10:00:00+0800
86
+
87
+ ### Summary
88
+ archived
89
+
90
+ ### Status
91
+ DONE
92
+ EOF
93
+ cat > "$DEVLOG_DIR/devlog.topic-a.md" <<'EOF'
94
+ ## Round 5 — 2026-09-05T10:00:00+0800
95
+
96
+ ### Summary
97
+ kept topic
98
+
99
+ ### Status
100
+ DONE
101
+ EOF
102
+ cat > "$DEVLOG_DIR/devlog.feat-x.md" <<'EOF'
103
+ ## Round 1 — 2026-09-04T10:00:00+0800
104
+
105
+ ### Summary
106
+ branch work
107
+
108
+ ### Status
109
+ IN_PROGRESS
110
+ EOF
111
+ printf '# Lessons: foo\n\n## 2026-09-01T00:00:00+0800\nlesson.\n' > "$DEVLOG_DIR/devlog.lessons.foo.md"
112
+
113
+ # --- Default KEY=VALUE --------------------------------------------------------
114
+ OUT="$(bash "$SCRIPT_DIR/report-devlog.sh")"
115
+ assert_line "branch" "BRANCH=main" "$OUT"
116
+ assert_line "total rounds = archive + main, fenced fake ignored" "ROUNDS_TOTAL=4" "$OUT"
117
+ assert_line "main rounds" "ROUNDS_MAIN=3" "$OUT"
118
+ assert_line "archive rounds" "ROUNDS_ARCHIVE=1" "$OUT"
119
+ assert_line "done count" "STATUS_DONE=2" "$OUT"
120
+ assert_line "in-progress count (branch file not scanned)" "STATUS_IN_PROGRESS=0" "$OUT"
121
+ assert_line "blocked count" "STATUS_BLOCKED=1" "$OUT"
122
+ assert_line "interrupted count despite reason line" "STATUS_INTERRUPTED=1" "$OUT"
123
+ assert_line "blocked ratio" "BLOCKED_RATIO=25" "$OUT"
124
+ assert_line "checkpoints" "CHECKPOINTS=1" "$OUT"
125
+ assert_line "kept topics" "KEPT_TOPICS=1" "$OUT"
126
+ assert_line "lessons topics" "LESSONS_TOPICS=1" "$OUT"
127
+ assert_line "first round at" "FIRST_ROUND_AT=2026-08-31T10:00:00+0800" "$OUT"
128
+ assert_line "last round at" "LAST_ROUND_AT=2026-09-03T10:00:00+0800" "$OUT"
129
+ assert_no_line_prefix "no advisory state -> no LESSONS_ADVISORY" "LESSONS_ADVISORY=" "$OUT"
130
+
131
+ printf '%s\n' '{"count": 2, "threshold": 3}' > "$DEVLOG_DIR/.lessons-advisory-state"
132
+ OUT="$(bash "$SCRIPT_DIR/report-devlog.sh")"
133
+ assert_line "advisory state present" "LESSONS_ADVISORY=2/3" "$OUT"
134
+ rm -f "$DEVLOG_DIR/.lessons-advisory-state"
135
+
136
+ # --- Empty devlog dir: zeros and none ----------------------------------------
137
+ EMPTY_ROOT="$(mktemp -d)"
138
+ mkdir -p "$EMPTY_ROOT/.devlog"
139
+ OUT="$(CLAUDE_PROJECT_DIR="$EMPTY_ROOT" bash "$SCRIPT_DIR/report-devlog.sh")"
140
+ assert_line "empty: zero rounds" "ROUNDS_TOTAL=0" "$OUT"
141
+ assert_line "empty: zero ratio" "BLOCKED_RATIO=0" "$OUT"
142
+ assert_line "empty: first none" "FIRST_ROUND_AT=none" "$OUT"
143
+ rm -rf "$EMPTY_ROOT"
144
+
145
+ # --- Unknown arg -> exit 2 ------------------------------------------------------
146
+ bash "$SCRIPT_DIR/report-devlog.sh" --bogus >/dev/null 2>&1
147
+ RC=$?
148
+ if [ "$RC" -eq 2 ]; then echo "PASS: unknown arg exits 2"; else echo "FAIL: unknown arg exit $RC"; FAIL=1; fi
149
+
150
+ # --- --json (no rounds) -------------------------------------------------------
151
+ J="$(bash "$SCRIPT_DIR/report-devlog.sh" --json)"
152
+ jcheck "json: started + counts are numbers" "$J" 'd.started===true && d.rounds_total===4 && d.status_blocked===1 && d.blocked_ratio===25'
153
+ jcheck "json: branch + times" "$J" 'd.branch==="main" && d.first_round_at==="2026-08-31T10:00:00+0800"'
154
+ jcheck "json: no advisory -> null" "$J" 'd.lessons_advisory===null'
155
+ jcheck "json: no rounds array without --rounds" "$J" '!("rounds" in d) && !("checkpoint_blocks" in d)'
156
+
157
+ # --- --json --rounds ------------------------------------------------------------
158
+ J="$(bash "$SCRIPT_DIR/report-devlog.sh" --json --rounds)"
159
+ jcheck "rounds: 4 entries, archive first" "$J" 'd.rounds.length===4 && d.rounds[0].branch==="archive" && d.rounds[0].n===0'
160
+ jcheck "rounds: escaping survives quote/backslash/tab/CJK" "$J" 'd.rounds[1].summary.includes("\"quoted\"") && d.rounds[1].summary.includes("\\") && d.rounds[1].summary.includes("\t") && d.rounds[1].summary.includes("中文")'
161
+ jcheck "rounds: fenced fake heading stays inside summary" "$J" 'd.rounds[2].summary.includes("## Round 9 fake inside fence")'
162
+ jcheck "rounds: status parsed despite reason line" "$J" 'd.rounds[3].status==="INTERRUPTED"'
163
+ jcheck "rounds: segments captured" "$J" 'd.rounds[3].segments.length===1 && d.rounds[3].segments[0]==="段落 探索\nseg body"'
164
+ jcheck "rounds: handoff keeps #### lines" "$J" 'd.rounds[1].handoff.startsWith("#### 現況")'
165
+ jcheck "rounds: line numbers" "$J" 'd.rounds[1].line===1 && d.rounds[1].file==="devlog.md"'
166
+ jcheck "rounds: no input by default" "$J" 'd.rounds.every(r => !("input" in r))'
167
+ jcheck "checkpoint_blocks: one block with body" "$J" 'd.checkpoint_blocks.length===1 && d.checkpoint_blocks[0].heading==="Checkpoint(Round 1-2)" && d.checkpoint_blocks[0].body.includes("keep it")'
168
+
169
+ J="$(bash "$SCRIPT_DIR/report-devlog.sh" --json --rounds --with-input)"
170
+ jcheck "with-input: input field present" "$J" 'd.rounds[1].input==="secret-input-text"'
171
+
172
+ # --- --json on NOT_STARTED ------------------------------------------------------
173
+ NS_ROOT="$(mktemp -d)"
174
+ J="$(CLAUDE_PROJECT_DIR="$NS_ROOT" bash "$SCRIPT_DIR/report-devlog.sh" --json)"
175
+ jcheck "json NOT_STARTED" "$J" 'd.started===false'
176
+ rm -rf "$NS_ROOT"
177
+
178
+ # --- --all-branches ---------------------------------------------------------------
179
+ OUT="$(bash "$SCRIPT_DIR/report-devlog.sh" --all-branches)"
180
+ assert_line "all-branches: branch file added, kept file excluded" "ROUNDS_TOTAL=5" "$OUT"
181
+ assert_line "all-branches: in-progress from branch file" "STATUS_IN_PROGRESS=1" "$OUT"
182
+ assert_line "all-branches: last round from branch file" "LAST_ROUND_AT=2026-09-04T10:00:00+0800" "$OUT"
183
+ J="$(bash "$SCRIPT_DIR/report-devlog.sh" --json --rounds --all-branches)"
184
+ jcheck "all-branches: feat-x rounds tagged" "$J" 'd.rounds.some(r => r.branch==="feat-x" && r.summary==="branch work")'
185
+ jcheck "all-branches: no kept or lessons rounds" "$J" 'd.rounds.every(r => r.branch!=="topic-a" && !r.branch.startsWith("lessons."))'
186
+
187
+ # --- control chars outside \t\r\n (e.g. pasted ANSI \033) escape to \uXXXX ------
188
+ CTRL_ROOT="$(mktemp -d)"
189
+ mkdir -p "$CTRL_ROOT/.devlog"
190
+ printf '## Round 1 — 2026-09-06T10:00:00+0800\n\n### Summary\nboom\033[31mred\n\n### Status\nDONE\n' \
191
+ > "$CTRL_ROOT/.devlog/devlog.md"
192
+ J="$(CLAUDE_PROJECT_DIR="$CTRL_ROOT" bash "$SCRIPT_DIR/report-devlog.sh" --json --rounds)"
193
+ jcheck "control char (ESC) escapes to \\u001b, JSON still parses" "$J" 'd.rounds[0].summary.includes("\x1b")'
194
+ rm -rf "$CTRL_ROOT"
195
+
196
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
197
+ else echo "Some checks FAILED."; exit 1; fi
@@ -652,6 +652,13 @@ else
652
652
  fi
653
653
  rm -f "$DEVLOG_DIR/.checkpoint-state"
654
654
 
655
+ # --- 19b: report / timeline are admin (read-side) commands too -------------
656
+ for ADMIN_CMD in report timeline; do
657
+ rm -f "$DEVLOG_DIR/.round-current.md" "$DEVLOG_DIR/.round-open"
658
+ printf '{"prompt":"<command-name>/devlog-tracker:%s</command-name>"}' "$ADMIN_CMD" | bash "$SCRIPT_DIR/round-start.sh"
659
+ assert_file_absent "admin $ADMIN_CMD: no .round-current.md" "$DEVLOG_DIR/.round-current.md"
660
+ done
661
+
655
662
  # --- 20: a namespaced-but-different command name is not falsely exempted ---
656
663
  # "lessons-on" must not match the "lessons" case arm (prefix collision guard).
657
664
  LESSONS_ON_PROMPT='<command-name>/devlog-tracker:lessons-on</command-name><command-message>lessons-on</command-message><command-args></command-args>'
@@ -677,6 +684,15 @@ else
677
684
  fi
678
685
  rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
679
686
 
687
+ # --- 21b: /devlog-tracker:pr and :promote are NOT exempt (outward / file writes)
688
+ for WORK_CMD in pr promote; do
689
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
690
+ printf '{"prompt":"<command-name>/devlog-tracker:%s</command-name>"}' "$WORK_CMD" | bash "$SCRIPT_DIR/round-start.sh"
691
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
692
+ assert_contains "$WORK_CMD command still opens a Round" "## Round 1 —" "$CUR_BODY"
693
+ done
694
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
695
+
680
696
  # --- workspace claim on next prompt ----------------------------------------
681
697
  WS="$TMP_ROOT/ws"
682
698
  mkdir -p "$WS/.devlog"
@@ -1050,6 +1066,55 @@ assert_not_contains "lessons off: no BLOCKED-accumulation advisory" "[Lessons Mo
1050
1066
  rm -rf "$NOLES_DIR"
1051
1067
  export CLAUDE_PROJECT_DIR="$TMP_ROOT"
1052
1068
 
1069
+ # --- lessons: background agent / workflow task-notification advisory ------
1070
+ TN_DIR="$(mktemp -d)"
1071
+ mkdir -p "$TN_DIR/.devlog"
1072
+ touch "$TN_DIR/.devlog/.enabled" "$TN_DIR/.devlog/.lessons-enabled"
1073
+ export CLAUDE_PROJECT_DIR="$TN_DIR"
1074
+ tn_notif() {
1075
+ printf '{"prompt":"<task-notification><task-id>t9</task-id><status>%s</status><summary>Agent \\"x\\" finished</summary></task-notification>"}' "$1"
1076
+ }
1077
+ tn_reset() {
1078
+ rm -f "$TN_DIR/.devlog/.round-open" "$TN_DIR/.devlog/.round-current.md"
1079
+ printf '## Round 1 — 2026-09-23T00:00:00+0800\n\n### User Input\n```text\nx\n```\n\n### Summary\ns\n\n### Status\nDONE\n' > "$TN_DIR/.devlog/devlog.md"
1080
+ }
1081
+
1082
+ tn_reset
1083
+ printf '%s\n' '{"count": 0, "threshold": 3}' > "$TN_DIR/.devlog/.lessons-advisory-state"
1084
+ OUT="$(tn_notif completed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1085
+ assert_contains "task-notif completed: sub agent advisory printed" "[Lessons Mode 提示] 背景任務完成(status=completed)" "$OUT"
1086
+ grep -q '"count": 0' "$TN_DIR/.devlog/.lessons-advisory-state" && echo "PASS: task-notif completed does not bump counter" || { echo "FAIL: completed bumped counter"; FAIL=1; }
1087
+
1088
+ tn_reset
1089
+ OUT="$(tn_notif failed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1090
+ assert_contains "task-notif failed: advisory printed" "背景任務完成(status=failed)" "$OUT"
1091
+ grep -q '"count": 1' "$TN_DIR/.devlog/.lessons-advisory-state" && echo "PASS: task-notif failed bumps counter" || { echo "FAIL: failed did not bump counter"; FAIL=1; }
1092
+
1093
+ tn_reset
1094
+ printf '%s\n' '{"count": 2, "threshold": 3}' > "$TN_DIR/.devlog/.lessons-advisory-state"
1095
+ OUT="$(tn_notif killed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1096
+ assert_contains "task-notif killed at threshold: shared nudge printed" "流程訊號已累積出現 3 次" "$OUT"
1097
+ grep -q '"count": 0' "$TN_DIR/.devlog/.lessons-advisory-state" && echo "PASS: task-notif killed resets counter at threshold" || { echo "FAIL: killed did not reset counter"; FAIL=1; }
1098
+
1099
+ tn_reset
1100
+ printf '%s\n' '{"round": 1, "ticks_since_checkin": 0, "max_silent_ticks": 5}' > "$TN_DIR/.devlog/.span-open"
1101
+ OUT="$(tn_notif completed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1102
+ assert_contains "task-notif during span: advisory still printed" "背景任務完成(status=completed)" "$OUT"
1103
+ rm -f "$TN_DIR/.devlog/.span-open"
1104
+
1105
+ tn_reset
1106
+ rm -f "$TN_DIR/.devlog/.lessons-enabled" "$TN_DIR/.devlog/.lessons-advisory-state"
1107
+ OUT="$(tn_notif failed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1108
+ assert_not_contains "task-notif with lessons off: no advisory" "[Lessons Mode 提示]" "$OUT"
1109
+ [ ! -f "$TN_DIR/.devlog/.lessons-advisory-state" ] && echo "PASS: task-notif with lessons off leaves no advisory-state" || { echo "FAIL: advisory-state created while lessons off"; FAIL=1; }
1110
+
1111
+ touch "$TN_DIR/.devlog/.lessons-enabled"
1112
+ tn_reset
1113
+ OUT="$(printf '%s' '{"prompt":"plain message"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1114
+ assert_not_contains "plain prompt: no background-task advisory" "背景任務完成" "$OUT"
1115
+ rm -rf "$TN_DIR"
1116
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
1117
+
1053
1118
  if [ "$FAIL" -eq 0 ]; then
1054
1119
  echo "All checks passed."
1055
1120
  exit 0
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for timeline-devlog.sh (docs/design/read-side-and-promote.md C).
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
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
8
+ DEVLOG_DIR="$TMP_ROOT/.devlog"
9
+ FAIL=0
10
+ pass() { echo "PASS: $1"; }
11
+ fail() { echo "FAIL: $1"; FAIL=1; }
12
+
13
+ # --- NO_NODE: node missing from PATH (checked before anything else) ----------
14
+ EMPTY_BIN="$TMP_ROOT/empty-bin"
15
+ mkdir -p "$EMPTY_BIN"
16
+ BASH_BIN="$(command -v bash)"
17
+ OUT="$(PATH="$EMPTY_BIN" "$BASH_BIN" "$SCRIPT_DIR/timeline-devlog.sh" 2>&1)"
18
+ [ "$OUT" = "NO_NODE" ] && pass "no node -> NO_NODE" || fail "no node -> NO_NODE (got $OUT)"
19
+
20
+ if ! command -v node >/dev/null 2>&1; then
21
+ echo "SKIP: remaining timeline checks need node"
22
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0; else echo "Some checks FAILED."; exit 1; fi
23
+ fi
24
+
25
+ # --- NOT_STARTED --------------------------------------------------------------
26
+ OUT="$(bash "$SCRIPT_DIR/timeline-devlog.sh")"
27
+ [ "$OUT" = "NOT_STARTED" ] && pass "no .devlog -> NOT_STARTED" || fail "NOT_STARTED (got $OUT)"
28
+ [ ! -e "$DEVLOG_DIR" ] && pass "NOT_STARTED creates nothing" || fail "NOT_STARTED created .devlog"
29
+
30
+ # --- End to end -----------------------------------------------------------------
31
+ mkdir -p "$DEVLOG_DIR"
32
+ cat > "$DEVLOG_DIR/devlog.md" <<'EOF2'
33
+ ## Round 1 — 2026-09-01T10:00:00+0800
34
+
35
+ ### User Input
36
+ secret-input-text
37
+
38
+ ### Summary
39
+ <script>alert(1)</script> 中文摘要
40
+
41
+ ### Status
42
+ DONE
43
+ EOF2
44
+ OUT="$(bash "$SCRIPT_DIR/timeline-devlog.sh")"
45
+ case "$OUT" in
46
+ "OUT=$(cd "$DEVLOG_DIR" && pwd)/timeline.html") pass "default OUT path is absolute .devlog/timeline.html" ;;
47
+ *) fail "default OUT (got $OUT)" ;;
48
+ esac
49
+ HTML="$DEVLOG_DIR/timeline.html"
50
+ grep -q '中文摘要' "$HTML" && pass "summary rendered" || fail "summary missing"
51
+ grep -q '<script>alert' "$HTML" && fail "raw script tag leaked" || pass "script tag escaped"
52
+ grep -q 'secret-input-text' "$HTML" && fail "User Input leaked" || pass "User Input not included"
53
+ [ ! -e "$HTML.tmp" ] && pass "no leftover tmp file" || fail "leftover $HTML.tmp"
54
+
55
+ # --- --out ------------------------------------------------------------------------
56
+ OUT="$(bash "$SCRIPT_DIR/timeline-devlog.sh" --out "$TMP_ROOT/custom.html")"
57
+ [ -s "$TMP_ROOT/custom.html" ] && pass "--out writes the given path" || fail "--out ($OUT)"
58
+
59
+ # --- unknown arg ------------------------------------------------------------------
60
+ bash "$SCRIPT_DIR/timeline-devlog.sh" --bogus >/dev/null 2>&1
61
+ RC=$?
62
+ [ "$RC" -eq 2 ] && pass "unknown arg exits 2" || fail "unknown arg exit $RC"
63
+
64
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
65
+ else echo "Some checks FAILED."; exit 1; fi
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # /devlog-tracker:timeline and `npx devlog-tracker timeline`
3
+ # (docs/design/read-side-and-promote.md C): report-devlog.sh --json --rounds
4
+ # piped into timeline-render.js, written to .devlog/timeline.html (gitignored).
5
+ set -uo pipefail
6
+
7
+ _src="${BASH_SOURCE[0]}"
8
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
9
+
10
+ # Checked before sourcing anything: Claude plugin users may not have Node.
11
+ command -v node >/dev/null 2>&1 || { echo "NO_NODE"; exit 0; }
12
+
13
+ # shellcheck source=devlog-path.sh
14
+ . "$SCRIPT_DIR/devlog-path.sh"
15
+
16
+ ALL=0
17
+ OUT=""
18
+ while [ $# -gt 0 ]; do
19
+ case "$1" in
20
+ --all-branches) ALL=1 ;;
21
+ --out) shift; OUT="${1:-}" ;;
22
+ *) echo "UNKNOWN_ARG=$1" >&2; exit 2 ;;
23
+ esac
24
+ shift
25
+ done
26
+
27
+ devlog_resolve_paths "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
28
+ [ -d "$DEVLOG_DIR" ] || { echo "NOT_STARTED"; exit 0; }
29
+ [ -n "$OUT" ] || OUT="$DEVLOG_DIR/timeline.html"
30
+
31
+ ARGS=(--json --rounds)
32
+ [ "$ALL" -eq 1 ] && ARGS+=(--all-branches)
33
+
34
+ JSON_TMP="$(mktemp "${TMPDIR:-/tmp}/devlog-timeline.XXXXXX")" || exit 1
35
+ trap 'rm -f "$JSON_TMP" "$OUT.tmp"' EXIT
36
+ bash "$SCRIPT_DIR/report-devlog.sh" "${ARGS[@]}" > "$JSON_TMP" || exit 1
37
+ node "$SCRIPT_DIR/timeline-render.js" < "$JSON_TMP" > "$OUT.tmp" || exit 1
38
+ mv "$OUT.tmp" "$OUT" || exit 1
39
+
40
+ OUT_DIR="$(cd "$(dirname "$OUT")" && pwd)"
41
+ echo "OUT=$OUT_DIR/$(basename "$OUT")"