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,28 @@
1
+ #!/usr/bin/env bash
2
+ # SubagentStart hook(Claude Code):Lessons Mode 開著時,把「可以自己呼叫
3
+ # lessons-append.sh」的說明注入 sub agent/Workflow agent 的 context
4
+ # (docs/design/lessons-mode.md「sub agent/workflow 情境」)。
5
+ # sub agent 的 Bash 裡沒有 CLAUDE_PROJECT_DIR,isolation: "worktree" 時 cwd
6
+ # 還是 worktree(沒有 .devlog/),所以專案目錄跟腳本路徑都在這裡寫死成
7
+ # 主專案的絕對路徑。fail-open:任何問題都 exit 0、不輸出。
8
+
9
+ set -uo pipefail
10
+
11
+ _src="${BASH_SOURCE[0]}"
12
+ HOOKS_DIR="$(cd "${_src%/*}" && pwd)"
13
+ # shellcheck source=json-field.sh
14
+ . "$HOOKS_DIR/json-field.sh"
15
+ PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
16
+ [ -f "$PROJECT_DIR/.devlog/.enabled" ] || exit 0
17
+ [ -f "$PROJECT_DIR/.devlog/.lessons-enabled" ] || exit 0
18
+ PROJECT_DIR="$(cd "$PROJECT_DIR" 2>/dev/null && pwd)" || exit 0
19
+ cat >/dev/null 2>&1 || true
20
+
21
+ MSG="[devlog-tracker Lessons Mode] 這個專案開著 Lessons Mode:記錄開發**過程**踩過的坑(不是架構知識)。如果這次任務中你繞了一大圈才找到對的做法、先前的 fix/claim 被驗證推翻、或卡在某個值得後人避開的坑,可以直接記一筆(非強制,沒有就不用記):
22
+
23
+ DEVLOG_PROJECT_DIR='${PROJECT_DIR}' bash '${HOOKS_DIR}/lessons-append.sh' --topic '<kebab-case 主題,2–4 段>' --text '<一段自由散文:卡在哪、怎麼解開、下次怎麼避免>'
24
+
25
+ 路徑都是絕對路徑,在 worktree 裡也照原樣用,不要改成相對路徑。有記的話,在最終回報裡用一句話說明記了哪個主題;腳本回報 NEW_TOPIC 時可改用它列出的既有主題重跑。"
26
+
27
+ printf '{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"%s"}}\n' "$(json_escape "$MSG")"
28
+ exit 0
@@ -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
@@ -123,6 +123,20 @@ if [ "$TASK_NOTIF" -eq 1 ]; then
123
123
  else
124
124
  PROMPT="$NOTIF_SUMMARY"
125
125
  fi
126
+
127
+ # Lessons Mode:背景 sub agent/Workflow 完成時機械提示主 session 檢查
128
+ # sub agent 訊號(docs/design/lessons-mode.md「sub agent/workflow 情境」);
129
+ # failed/killed 另外算進共用計數器。span 開著也照印——長任務正是會派
130
+ # 背景 agent 的場景。
131
+ if [ -f "$DEVLOG_DIR/.lessons-enabled" ]; then
132
+ printf '\n[Lessons Mode 提示] 背景任務完成(status=%s)。讀完回報後檢查:verify 推翻先前的 fix/claim、sub agent 自陳繞路、多個 agent 卡在類似問題、成果被打回票——有的話可考慮用 lessons-append.sh 記一筆(sub agent 若已自己記過就不用重複),非強制。\n' "${NOTIF_STATUS:-unknown}"
133
+ case "$NOTIF_STATUS" in
134
+ failed|killed)
135
+ lessons_advisory_migrate "$DEVLOG_DIR"
136
+ lessons_advisory_bump "$ADVISORY_FILE"
137
+ ;;
138
+ esac
139
+ fi
126
140
  fi
127
141
 
128
142
  if [ "$SPAN_SKIP" -eq 1 ]; then
@@ -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,95 @@
1
+ #!/usr/bin/env bash
2
+ # Self-check for lessons-subagent-start.sh (SubagentStart) and
3
+ # lessons-subagent-done.sh (PostToolUse Agent|Task). Run:
4
+ # bash core/scripts/tests/test-lessons-subagent-hooks.sh
5
+ set -uo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
8
+ TMP_ROOT="$(mktemp -d)"
9
+ trap 'rm -rf "$TMP_ROOT"' EXIT
10
+
11
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
12
+ DEVLOG_DIR="$TMP_ROOT/.devlog"
13
+ mkdir -p "$DEVLOG_DIR"
14
+
15
+ FAIL=0
16
+ assert_contains() {
17
+ local desc="$1" needle="$2" haystack="$3"
18
+ case "$haystack" in
19
+ *"$needle"*) echo "PASS: $desc" ;;
20
+ *) echo "FAIL: $desc (missing: $needle)"; FAIL=1 ;;
21
+ esac
22
+ }
23
+ assert_empty() {
24
+ local desc="$1" out="$2"
25
+ if [ -z "$out" ]; then echo "PASS: $desc"; else echo "FAIL: $desc (got: $out)"; FAIL=1; fi
26
+ }
27
+ assert_valid_json() {
28
+ local desc="$1" out="$2"
29
+ if ! command -v jq >/dev/null 2>&1; then echo "SKIP: $desc (no jq)"; return 0; fi
30
+ if printf '%s' "$out" | jq -e . >/dev/null 2>&1; then echo "PASS: $desc"; else echo "FAIL: $desc (invalid JSON: $out)"; FAIL=1; fi
31
+ }
32
+
33
+ START_IN='{"session_id":"s1","cwd":"/elsewhere/.claude/worktrees/agent-x","agent_id":"a1","agent_type":"general-purpose","hook_event_name":"SubagentStart"}'
34
+ SYNC_DONE='{"session_id":"s1","hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"p"},"tool_response":{"status":"completed","content":[{"type":"text","text":"done"}]}}'
35
+ ASYNC_DONE='{"session_id":"s1","hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"p"},"tool_response":{"isAsync":true,"status":"async_launched","agentId":"a1"}}'
36
+ NESTED_DONE='{"session_id":"s1","agent_id":"a1","hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"p"},"tool_response":{"status":"completed"}}'
37
+
38
+ # --- disabled: both hooks silent -------------------------------------------
39
+ OUT="$(printf '%s' "$START_IN" | bash "$SCRIPT_DIR/lessons-subagent-start.sh")"
40
+ assert_empty "start: .enabled absent -> silent" "$OUT"
41
+ OUT="$(printf '%s' "$SYNC_DONE" | bash "$SCRIPT_DIR/lessons-subagent-done.sh")"
42
+ assert_empty "done: .enabled absent -> silent" "$OUT"
43
+
44
+ touch "$DEVLOG_DIR/.enabled"
45
+ OUT="$(printf '%s' "$START_IN" | bash "$SCRIPT_DIR/lessons-subagent-start.sh")"
46
+ assert_empty "start: lessons off -> silent" "$OUT"
47
+ OUT="$(printf '%s' "$SYNC_DONE" | bash "$SCRIPT_DIR/lessons-subagent-done.sh")"
48
+ assert_empty "done: lessons off -> silent" "$OUT"
49
+
50
+ touch "$DEVLOG_DIR/.lessons-enabled"
51
+
52
+ # --- SubagentStart: inject lessons-append.sh instructions with absolute paths
53
+ OUT="$(printf '%s' "$START_IN" | bash "$SCRIPT_DIR/lessons-subagent-start.sh")"
54
+ assert_valid_json "start: output is valid JSON" "$OUT"
55
+ assert_contains "start: hookEventName SubagentStart" '"hookEventName":"SubagentStart"' "$OUT"
56
+ assert_contains "start: additionalContext present" '"additionalContext":' "$OUT"
57
+ assert_contains "start: pins the main project dir, not the agent cwd" "DEVLOG_PROJECT_DIR='$TMP_ROOT'" "$OUT"
58
+ assert_contains "start: absolute lessons-append.sh path" "$SCRIPT_DIR/lessons-append.sh" "$OUT"
59
+ assert_contains "start: asks to mention it in the final report" "最終回報" "$OUT"
60
+
61
+ # relative CLAUDE_PROJECT_DIR still resolves to an absolute path
62
+ (cd "$TMP_ROOT" && OUT="$(printf '%s' "$START_IN" | CLAUDE_PROJECT_DIR=. bash "$SCRIPT_DIR/lessons-subagent-start.sh")"
63
+ case "$OUT" in *"DEVLOG_PROJECT_DIR='$TMP_ROOT'"*|*"DEVLOG_PROJECT_DIR='$(pwd -P)'"*) echo "PASS: start: relative project dir made absolute" ;;
64
+ *) echo "FAIL: start: relative project dir not made absolute ($OUT)"; exit 1 ;; esac) || FAIL=1
65
+
66
+ # path needing JSON escaping stays valid JSON
67
+ QDIR="$TMP_ROOT/we\"ird"
68
+ mkdir -p "$QDIR/.devlog"
69
+ touch "$QDIR/.devlog/.enabled" "$QDIR/.devlog/.lessons-enabled"
70
+ OUT="$(printf '%s' "$START_IN" | CLAUDE_PROJECT_DIR="$QDIR" bash "$SCRIPT_DIR/lessons-subagent-start.sh")"
71
+ assert_valid_json "start: quote in project path still valid JSON" "$OUT"
72
+
73
+ # --- PostToolUse(Agent): foreground completion only -------------------------
74
+ OUT="$(printf '%s' "$SYNC_DONE" | bash "$SCRIPT_DIR/lessons-subagent-done.sh")"
75
+ assert_valid_json "done: output is valid JSON" "$OUT"
76
+ assert_contains "done: hookEventName PostToolUse" '"hookEventName":"PostToolUse"' "$OUT"
77
+ assert_contains "done: sub agent advisory" "[Lessons Mode 提示] sub agent 完成" "$OUT"
78
+
79
+ OUT="$(printf '%s' "$ASYNC_DONE" | bash "$SCRIPT_DIR/lessons-subagent-done.sh")"
80
+ assert_empty "done: async launch -> silent (task-notification covers it)" "$OUT"
81
+
82
+ OUT="$(printf '%s' "$NESTED_DONE" | bash "$SCRIPT_DIR/lessons-subagent-done.sh")"
83
+ assert_empty "done: nested (agent_id set) -> silent" "$OUT"
84
+
85
+ OUT="$(printf '%s' 'not json' | bash "$SCRIPT_DIR/lessons-subagent-done.sh")"
86
+ RC=$?
87
+ [ "$RC" -eq 0 ] && echo "PASS: done: garbage stdin fails open" || { echo "FAIL: done: garbage stdin exit $RC"; FAIL=1; }
88
+
89
+ if [ "$FAIL" -eq 0 ]; then
90
+ echo "All checks passed."
91
+ exit 0
92
+ else
93
+ echo "Some checks FAILED."
94
+ exit 1
95
+ fi