devlog-tracker 0.27.0 → 0.30.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 (45) hide show
  1. package/README.md +109 -100
  2. package/README.zh-TW.md +235 -0
  3. package/claude/hooks.json +21 -0
  4. package/cli/agents-md.js +1 -0
  5. package/commands/keep.md +18 -2
  6. package/commands/lessons-drift.md +2 -2
  7. package/commands/search.md +22 -0
  8. package/commands/status.md +2 -1
  9. package/core/scripts/clean-devlog.sh +1 -0
  10. package/core/scripts/devlog-lock.sh +17 -1
  11. package/core/scripts/devlog-path.sh +15 -8
  12. package/core/scripts/enforce-devlog.sh +25 -0
  13. package/core/scripts/handoff-file.sh +106 -0
  14. package/core/scripts/lessons-advisory-state.sh +51 -0
  15. package/core/scripts/lessons-append.sh +16 -0
  16. package/core/scripts/lessons-drift-set.sh +14 -10
  17. package/core/scripts/lessons-on.sh +8 -3
  18. package/core/scripts/lessons-subagent-done.sh +36 -0
  19. package/core/scripts/lessons-subagent-start.sh +36 -0
  20. package/core/scripts/round-start.sh +65 -16
  21. package/core/scripts/search-devlog.sh +63 -0
  22. package/core/scripts/session-start-devlog.sh +7 -0
  23. package/core/scripts/status-devlog.sh +4 -4
  24. package/core/scripts/tests/test-clean-devlog.sh +12 -0
  25. package/core/scripts/tests/test-devlog-lock.sh +39 -0
  26. package/core/scripts/tests/test-devlog-path.sh +44 -0
  27. package/core/scripts/tests/test-enforce-devlog-files.sh +11 -0
  28. package/core/scripts/tests/test-enforce-devlog-session-handoff.sh +178 -0
  29. package/core/scripts/tests/test-enforce-devlog-workspace.sh +35 -0
  30. package/core/scripts/tests/test-enforce-devlog.sh +174 -0
  31. package/core/scripts/tests/test-handoff-file.sh +98 -0
  32. package/core/scripts/tests/test-lessons-advisory-state.sh +55 -0
  33. package/core/scripts/tests/test-lessons-append.sh +18 -0
  34. package/core/scripts/tests/test-lessons-drift-set.sh +14 -5
  35. package/core/scripts/tests/test-lessons-on-off.sh +16 -6
  36. package/core/scripts/tests/test-lessons-subagent-hooks.sh +95 -0
  37. package/core/scripts/tests/test-round-start.sh +296 -13
  38. package/core/scripts/tests/test-search-devlog.sh +126 -0
  39. package/core/scripts/tests/test-session-start-devlog.sh +59 -0
  40. package/core/scripts/tests/test-status-span.sh +3 -3
  41. package/package.json +2 -2
  42. package/skills/devlog-tracker/SKILL.md +83 -13
  43. package/skills/devlog-tracker/references/checkpoint-mode.md +21 -6
  44. package/skills/devlog-tracker/references/contract.md +83 -0
  45. package/skills/devlog-tracker/references/lessons-mode.md +38 -8
@@ -14,23 +14,32 @@ OUT="$(CLAUDE_PROJECT_DIR="$TMP" bash "$SCRIPT_DIR/lessons-drift-set.sh" 5)"
14
14
  [ "$OUT" = "LESSONS_NOT_ENABLED" ] && echo "PASS: lessons not enabled" || { echo "FAIL: $OUT"; FAIL=1; }
15
15
 
16
16
  touch "$TMP/.devlog/.lessons-enabled"
17
- printf '%s\n' '{"mismatch_count": 2, "threshold": 3}' > "$TMP/.devlog/.lessons-drift-state"
17
+ printf '%s\n' '{"count": 2, "threshold": 3}' > "$TMP/.devlog/.lessons-advisory-state"
18
18
 
19
19
  OUT="$(CLAUDE_PROJECT_DIR="$TMP" bash "$SCRIPT_DIR/lessons-drift-set.sh" 5)"
20
20
  case "$OUT" in *LESSONS_DRIFT_THRESHOLD=5*) echo "PASS: set 5" ;; *) echo "FAIL: $OUT"; FAIL=1 ;; esac
21
- grep -q '"mismatch_count": 2' "$TMP/.devlog/.lessons-drift-state" \
21
+ grep -q '"count": 2' "$TMP/.devlog/.lessons-advisory-state" \
22
22
  && echo "PASS: count preserved" || { echo "FAIL: count reset"; FAIL=1; }
23
- grep -q '"threshold": 5' "$TMP/.devlog/.lessons-drift-state" \
23
+ grep -q '"threshold": 5' "$TMP/.devlog/.lessons-advisory-state" \
24
24
  && echo "PASS: threshold written" || { echo "FAIL: threshold"; FAIL=1; }
25
25
 
26
26
  CLAUDE_PROJECT_DIR="$TMP" bash "$SCRIPT_DIR/lessons-drift-set.sh" 0 >/dev/null 2>&1
27
27
  [ $? -eq 1 ] && echo "PASS: reject 0" || { echo "FAIL: accepted 0"; FAIL=1; }
28
28
 
29
- rm -f "$TMP/.devlog/.lessons-drift-state"
29
+ rm -f "$TMP/.devlog/.lessons-advisory-state"
30
30
  OUT="$(CLAUDE_PROJECT_DIR="$TMP" bash "$SCRIPT_DIR/lessons-drift-set.sh" 7)"
31
31
  case "$OUT" in *LESSONS_DRIFT_THRESHOLD=7*) echo "PASS: create 7" ;; *) echo "FAIL: create $OUT"; FAIL=1 ;; esac
32
- grep -q '"mismatch_count": 0' "$TMP/.devlog/.lessons-drift-state" \
32
+ grep -q '"count": 0' "$TMP/.devlog/.lessons-advisory-state" \
33
33
  && echo "PASS: create count 0" || { echo "FAIL: create count"; FAIL=1; }
34
34
 
35
+ # --- migrates an old .lessons-drift-state before applying the new threshold
36
+ rm -f "$TMP/.devlog/.lessons-advisory-state"
37
+ printf '%s\n' '{"mismatch_count": 4, "threshold": 3}' > "$TMP/.devlog/.lessons-drift-state"
38
+ OUT="$(CLAUDE_PROJECT_DIR="$TMP" bash "$SCRIPT_DIR/lessons-drift-set.sh" 9)"
39
+ case "$OUT" in *LESSONS_DRIFT_THRESHOLD=9*) echo "PASS: migrate then set 9" ;; *) echo "FAIL: $OUT"; FAIL=1 ;; esac
40
+ [ ! -f "$TMP/.devlog/.lessons-drift-state" ] && echo "PASS: old file removed after migrate+set" || { echo "FAIL: old file still present"; FAIL=1; }
41
+ grep -q '"count": 4' "$TMP/.devlog/.lessons-advisory-state" && echo "PASS: migrated count preserved through set" || { echo "FAIL: migrated count lost"; FAIL=1; }
42
+ grep -q '"threshold": 9' "$TMP/.devlog/.lessons-advisory-state" && echo "PASS: new threshold applied after migrate" || { echo "FAIL: threshold not applied"; FAIL=1; }
43
+
35
44
  if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
36
45
  else echo "Some checks FAILED."; exit 1; fi
@@ -44,11 +44,21 @@ case "$OUT" in
44
44
  *"LESSONS_ENABLED="*) echo "PASS: reports LESSONS_ENABLED" ;;
45
45
  *) echo "FAIL: expected LESSONS_ENABLED=..., got $OUT"; FAIL=1 ;;
46
46
  esac
47
- assert_file ".lessons-drift-state created alongside .lessons-enabled" "$TMP_ROOT/.devlog/.lessons-drift-state"
48
- grep -q '"mismatch_count": 0' "$TMP_ROOT/.devlog/.lessons-drift-state" \
49
- && echo "PASS: drift count starts at 0" || { echo "FAIL: drift count not 0"; FAIL=1; }
50
- grep -q '"threshold": 3' "$TMP_ROOT/.devlog/.lessons-drift-state" \
51
- && echo "PASS: drift threshold defaults to 3" || { echo "FAIL: drift threshold not 3"; FAIL=1; }
47
+ assert_file ".lessons-advisory-state created alongside .lessons-enabled" "$TMP_ROOT/.devlog/.lessons-advisory-state"
48
+ grep -q '"count": 0' "$TMP_ROOT/.devlog/.lessons-advisory-state" \
49
+ && echo "PASS: advisory count starts at 0" || { echo "FAIL: advisory count not 0"; FAIL=1; }
50
+ grep -q '"threshold": 3' "$TMP_ROOT/.devlog/.lessons-advisory-state" \
51
+ && echo "PASS: advisory threshold defaults to 3" || { echo "FAIL: advisory threshold not 3"; FAIL=1; }
52
+
53
+ # --- lessons-on migrates an old .lessons-drift-state on re-run -------------
54
+ rm -f "$TMP_ROOT/.devlog/.lessons-advisory-state"
55
+ printf '%s\n' '{"mismatch_count": 2, "threshold": 7}' > "$TMP_ROOT/.devlog/.lessons-drift-state"
56
+ OUT="$(bash "$SCRIPT_DIR/lessons-on.sh")"
57
+ assert_exit "lessons-on migrates old state file -> 0" 0 $?
58
+ assert_file "migration produced .lessons-advisory-state" "$TMP_ROOT/.devlog/.lessons-advisory-state"
59
+ assert_not_file "migration removed .lessons-drift-state" "$TMP_ROOT/.devlog/.lessons-drift-state"
60
+ grep -q '"count": 2' "$TMP_ROOT/.devlog/.lessons-advisory-state" && echo "PASS: migrated count preserved" || { echo "FAIL: migrated count lost"; FAIL=1; }
61
+ grep -q '"threshold": 7' "$TMP_ROOT/.devlog/.lessons-advisory-state" && echo "PASS: migrated threshold preserved" || { echo "FAIL: migrated threshold lost"; FAIL=1; }
52
62
 
53
63
  # --- lessons-off disables, leaves main switch and any lessons files alone --
54
64
  echo 'keep me' > "$TMP_ROOT/.devlog/devlog.lessons.foo.md"
@@ -61,7 +71,7 @@ esac
61
71
  assert_not_file ".lessons-enabled removed" "$TMP_ROOT/.devlog/.lessons-enabled"
62
72
  assert_file "main .enabled untouched" "$TMP_ROOT/.devlog/.enabled"
63
73
  assert_file "lessons file untouched" "$TMP_ROOT/.devlog/devlog.lessons.foo.md"
64
- assert_file ".lessons-drift-state untouched by lessons-off" "$TMP_ROOT/.devlog/.lessons-drift-state"
74
+ assert_file ".lessons-advisory-state untouched by lessons-off" "$TMP_ROOT/.devlog/.lessons-advisory-state"
65
75
 
66
76
  # --- lessons-off when already off -> reports NOT_ENABLED, no error --------
67
77
  OUT="$(bash "$SCRIPT_DIR/lessons-off.sh")"
@@ -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
@@ -634,6 +634,49 @@ fi
634
634
  assert_file_absent "task-notification during open span: no .round-open" "$DEVLOG_DIR/.round-open"
635
635
  rm -f "$DEVLOG_DIR/.span-open"
636
636
 
637
+ # --- 19: devlog-tracker admin slash commands are exempt from Round logging -
638
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md" "$DEVLOG_DIR/.checkpoint-state"
639
+ printf '%s\n' '{"rounds_since_checkpoint": 3, "max_silent_rounds": 20, "checkpoint_marker_count": 0}' > "$DEVLOG_DIR/.checkpoint-state"
640
+ ADMIN_PROMPT='<command-name>/devlog-tracker:status</command-name><command-message>status</command-message><command-args></command-args>'
641
+ printf '{"prompt":"%s"}' "$ADMIN_PROMPT" | bash "$SCRIPT_DIR/round-start.sh"
642
+ assert_file_absent "admin command: no .round-current.md" "$DEVLOG_DIR/.round-current.md"
643
+ assert_file_absent "admin command: no .round-open" "$DEVLOG_DIR/.round-open"
644
+ assert_file_absent "admin command: no .turn-start" "$DEVLOG_DIR/.turn-start"
645
+ assert_file_absent "admin command: no devlog.md created" "$DEVLOG_DIR/devlog.md"
646
+ CP="$(grep -o '"rounds_since_checkpoint"[[:space:]]*:[[:space:]]*[0-9]\+' "$DEVLOG_DIR/.checkpoint-state" | grep -o '[0-9]\+$')"
647
+ if [ "$CP" = "3" ]; then
648
+ echo "PASS: admin command does not increment rounds_since_checkpoint"
649
+ else
650
+ echo "FAIL: expected rounds_since_checkpoint to stay 3, got $CP"
651
+ FAIL=1
652
+ fi
653
+ rm -f "$DEVLOG_DIR/.checkpoint-state"
654
+
655
+ # --- 20: a namespaced-but-different command name is not falsely exempted ---
656
+ # "lessons-on" must not match the "lessons" case arm (prefix collision guard).
657
+ LESSONS_ON_PROMPT='<command-name>/devlog-tracker:lessons-on</command-name><command-message>lessons-on</command-message><command-args></command-args>'
658
+ printf '{"prompt":"%s"}' "$LESSONS_ON_PROMPT" | bash "$SCRIPT_DIR/round-start.sh"
659
+ if [ -f "$DEVLOG_DIR/.round-current.md" ]; then
660
+ echo "FAIL: lessons-on command should still be exempt (matched its own arm)"
661
+ FAIL=1
662
+ else
663
+ echo "PASS: lessons-on command exempt via its own case arm"
664
+ fi
665
+
666
+ # --- 21: /devlog-tracker:continue is NOT exempt -- real work may follow ----
667
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
668
+ CONTINUE_PROMPT='<command-name>/devlog-tracker:continue</command-name><command-message>continue</command-message><command-args></command-args>'
669
+ printf '{"prompt":"%s"}' "$CONTINUE_PROMPT" | bash "$SCRIPT_DIR/round-start.sh"
670
+ CUR_BODY="$(cat "$DEVLOG_DIR/.round-current.md" 2>/dev/null || echo '')"
671
+ assert_contains "continue command still opens a Round" "## Round 1 —" "$CUR_BODY"
672
+ if [ -f "$DEVLOG_DIR/.round-open" ]; then
673
+ echo "PASS: continue command still sets .round-open"
674
+ else
675
+ echo "FAIL: continue command should still set .round-open"
676
+ FAIL=1
677
+ fi
678
+ rm -f "$DEVLOG_DIR/devlog.md" "$DEVLOG_DIR/.round-open" "$DEVLOG_DIR/.turn-start" "$DEVLOG_DIR/.round-current.md"
679
+
637
680
  # --- workspace claim on next prompt ----------------------------------------
638
681
  WS="$TMP_ROOT/ws"
639
682
  mkdir -p "$WS/.devlog"
@@ -674,6 +717,10 @@ OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh"
674
717
  grep -q '^## Round 2' "$WS/.devlog/.round-current.md" && echo "PASS: still opened Round 2" || { echo "FAIL: no round 2"; FAIL=1; }
675
718
 
676
719
  rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md"
720
+ # This fixture is reused below by the drift-only counter assertions (1 -> 2 ->
721
+ # reset-at-3). Its ### Status must stay non-BLOCKED: if it were BLOCKED, the
722
+ # BLOCKED-round bump would also fire on the same invocation and double-count
723
+ # the shared counter, breaking those assertions with a confusing failure.
677
724
  cat > "$WS/.devlog/devlog.md" <<EOF
678
725
  ## Round 1 — 2026-09-11T00:00:00+08:00
679
726
 
@@ -708,35 +755,35 @@ MARKER="$(cat "$WS/.devlog/.workspace-mismatch")"
708
755
  # --- lessons drift counter: mechanical nudge on repeated mismatch --------
709
756
  rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.workspace-mismatch"
710
757
  touch "$WS/.devlog/.lessons-enabled"
711
- printf '%s\n' '{"mismatch_count": 0, "threshold": 3}' > "$WS/.devlog/.lessons-drift-state"
758
+ printf '%s\n' '{"count": 0, "threshold": 3}' > "$WS/.devlog/.lessons-advisory-state"
712
759
 
713
760
  OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
714
761
  assert_not_contains "drift count 1/3: no nudge yet" "[Lessons Mode 提示]" "$OUT"
715
- grep -q '"mismatch_count": 1' "$WS/.devlog/.lessons-drift-state" && echo "PASS: drift count -> 1" || { echo "FAIL: drift count not 1"; FAIL=1; }
762
+ grep -q '"count": 1' "$WS/.devlog/.lessons-advisory-state" && echo "PASS: drift count -> 1" || { echo "FAIL: drift count not 1"; FAIL=1; }
716
763
 
717
764
  rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md"
718
765
  OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
719
766
  assert_not_contains "drift count 2/3: no nudge yet" "[Lessons Mode 提示]" "$OUT"
720
- grep -q '"mismatch_count": 2' "$WS/.devlog/.lessons-drift-state" && echo "PASS: drift count -> 2" || { echo "FAIL: drift count not 2"; FAIL=1; }
767
+ grep -q '"count": 2' "$WS/.devlog/.lessons-advisory-state" && echo "PASS: drift count -> 2" || { echo "FAIL: drift count not 2"; FAIL=1; }
721
768
 
722
769
  rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md"
723
770
  OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
724
771
  assert_contains "drift count hits threshold: prints nudge" "[Lessons Mode 提示]" "$OUT"
725
- grep -q '"mismatch_count": 0' "$WS/.devlog/.lessons-drift-state" && echo "PASS: drift count reset after nudge" || { echo "FAIL: drift count not reset"; FAIL=1; }
772
+ grep -q '"count": 0' "$WS/.devlog/.lessons-advisory-state" && echo "PASS: drift count reset after nudge" || { echo "FAIL: drift count not reset"; FAIL=1; }
726
773
 
727
774
  # without .lessons-enabled: no counting, no nudge, state file untouched
728
775
  rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.lessons-enabled"
729
- printf '%s\n' '{"mismatch_count": 2, "threshold": 3}' > "$WS/.devlog/.lessons-drift-state"
776
+ printf '%s\n' '{"count": 2, "threshold": 3}' > "$WS/.devlog/.lessons-advisory-state"
730
777
  OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
731
778
  assert_not_contains "lessons off: no nudge even near threshold" "[Lessons Mode 提示]" "$OUT"
732
- grep -q '"mismatch_count": 2' "$WS/.devlog/.lessons-drift-state" && echo "PASS: drift count untouched when lessons off" || { echo "FAIL: drift count changed when lessons off"; FAIL=1; }
733
- rm -f "$WS/.devlog/.lessons-drift-state" "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md"
779
+ grep -q '"count": 2' "$WS/.devlog/.lessons-advisory-state" && echo "PASS: drift count untouched when lessons off" || { echo "FAIL: drift count changed when lessons off"; FAIL=1; }
780
+ rm -f "$WS/.devlog/.lessons-advisory-state" "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md"
734
781
 
735
782
  # --- lessons drift counter: upgrade path — .lessons-enabled exists (from
736
- # before this branch) but .lessons-drift-state was never created. round-start.sh
783
+ # before this branch) but .lessons-advisory-state was never created. round-start.sh
737
784
  # must create it with defaults and still act on this same invocation's
738
785
  # mismatch, not require a second message ------------------------------------
739
- rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.workspace-mismatch" "$WS/.devlog/.lessons-drift-state"
786
+ rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.workspace-mismatch" "$WS/.devlog/.lessons-advisory-state"
740
787
  touch "$WS/.devlog/.lessons-enabled"
741
788
  cat > "$WS/.devlog/devlog.md" <<EOF
742
789
  ## Round 1 — 2026-09-11T00:00:00+08:00
@@ -761,11 +808,11 @@ do x
761
808
  IN_PROGRESS
762
809
  EOF
763
810
  OUT="$(printf '%s' '{"prompt":"keep going"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
764
- [ -f "$WS/.devlog/.lessons-drift-state" ] && echo "PASS: upgrade path creates .lessons-drift-state on first drift" || { echo "FAIL: .lessons-drift-state not created for pre-existing lessons-enabled"; FAIL=1; }
765
- grep -q '"mismatch_count": 1' "$WS/.devlog/.lessons-drift-state" 2>/dev/null && echo "PASS: newly-created state already counted this invocation's mismatch (1)" || { echo "FAIL: mismatch_count not 1 in newly-created state"; FAIL=1; }
766
- grep -q '"threshold": 3' "$WS/.devlog/.lessons-drift-state" 2>/dev/null && echo "PASS: newly-created state has default threshold 3" || { echo "FAIL: threshold not 3 in newly-created state"; FAIL=1; }
811
+ [ -f "$WS/.devlog/.lessons-advisory-state" ] && echo "PASS: upgrade path creates .lessons-advisory-state on first drift" || { echo "FAIL: .lessons-advisory-state not created for pre-existing lessons-enabled"; FAIL=1; }
812
+ grep -q '"count": 1' "$WS/.devlog/.lessons-advisory-state" 2>/dev/null && echo "PASS: newly-created state already counted this invocation's mismatch (1)" || { echo "FAIL: count not 1 in newly-created state"; FAIL=1; }
813
+ grep -q '"threshold": 3' "$WS/.devlog/.lessons-advisory-state" 2>/dev/null && echo "PASS: newly-created state has default threshold 3" || { echo "FAIL: threshold not 3 in newly-created state"; FAIL=1; }
767
814
  assert_not_contains "upgrade path: no nudge yet on first-ever drift (1 < 3)" "[Lessons Mode 提示]" "$OUT"
768
- rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.lessons-enabled" "$WS/.devlog/.lessons-drift-state"
815
+ rm -f "$WS/.devlog/.round-open" "$WS/.devlog/.round-current.md" "$WS/.devlog/.lessons-enabled" "$WS/.devlog/.lessons-advisory-state"
769
816
 
770
817
  cat > "$WS/.devlog/devlog.md" <<EOF
771
818
  ## Round 1 — 2026-09-11T00:00:00+08:00
@@ -816,6 +863,242 @@ printf '%s' '{"prompt":"tick"}' | bash "$SCRIPT_DIR/round-start.sh" >/dev/null
816
863
  [ ! -f "$WS/.devlog/.workspace-mismatch" ] && echo "PASS: span skip writes no marker" || { echo "FAIL: span marker"; FAIL=1; }
817
864
  rm -f "$WS/.devlog/.span-open"
818
865
 
866
+ # --- BLOCKED-round accumulation shares the advisory counter -----------------
867
+ BLK_DIR="$(mktemp -d)"
868
+ export CLAUDE_PROJECT_DIR="$BLK_DIR"
869
+ mkdir -p "$BLK_DIR/.devlog"
870
+ touch "$BLK_DIR/.devlog/.enabled" "$BLK_DIR/.devlog/.lessons-enabled"
871
+ printf '%s\n' '{"count": 0, "threshold": 3}' > "$BLK_DIR/.devlog/.lessons-advisory-state"
872
+ cat > "$BLK_DIR/.devlog/devlog.md" <<'EOF'
873
+ ## Round 1 — 2026-09-20T00:00:00+08:00
874
+
875
+ ### Summary
876
+ s
877
+
878
+ ### Reply
879
+ fixture reply.
880
+
881
+ ### Handoff
882
+ #### 現況
883
+ waiting
884
+ #### 完成條件
885
+ 缺的外部輸入已出現,且可觀察條件達成。
886
+ #### 下一步
887
+ apply the answer
888
+
889
+ ### Status
890
+ BLOCKED
891
+ EOF
892
+ OUT="$(printf '%s' '{"prompt":"next"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
893
+ assert_not_contains "BLOCKED round 1/3: no advisory yet" "[Lessons Mode 提示]" "$OUT"
894
+ grep -q '"count": 1' "$BLK_DIR/.devlog/.lessons-advisory-state" && echo "PASS: BLOCKED round bumped counter to 1" || { echo "FAIL: counter not bumped to 1"; FAIL=1; }
895
+
896
+ rm -f "$BLK_DIR/.devlog/.round-open" "$BLK_DIR/.devlog/.round-current.md"
897
+ cat >> "$BLK_DIR/.devlog/devlog.md" <<'EOF'
898
+
899
+ ## Round 2 — 2026-09-20T00:05:00+08:00
900
+
901
+ ### Summary
902
+ s
903
+
904
+ ### Reply
905
+ fixture reply.
906
+
907
+ ### Handoff
908
+ #### 現況
909
+ waiting
910
+ #### 完成條件
911
+ 缺的外部輸入已出現,且可觀察條件達成。
912
+ #### 下一步
913
+ apply the answer
914
+
915
+ ### Status
916
+ BLOCKED
917
+ EOF
918
+ OUT="$(printf '%s' '{"prompt":"next again"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
919
+ assert_not_contains "BLOCKED round 2/3: no advisory yet" "[Lessons Mode 提示]" "$OUT"
920
+ grep -q '"count": 2' "$BLK_DIR/.devlog/.lessons-advisory-state" && echo "PASS: BLOCKED round bumped counter to 2" || { echo "FAIL: counter not bumped to 2"; FAIL=1; }
921
+
922
+ rm -f "$BLK_DIR/.devlog/.round-open" "$BLK_DIR/.devlog/.round-current.md"
923
+ cat >> "$BLK_DIR/.devlog/devlog.md" <<'EOF'
924
+
925
+ ## Round 3 — 2026-09-20T00:10:00+08:00
926
+
927
+ ### Summary
928
+ s
929
+
930
+ ### Reply
931
+ fixture reply.
932
+
933
+ ### Handoff
934
+ #### 現況
935
+ waiting
936
+ #### 完成條件
937
+ 缺的外部輸入已出現,且可觀察條件達成。
938
+ #### 下一步
939
+ apply the answer
940
+
941
+ ### Status
942
+ BLOCKED
943
+ EOF
944
+ OUT="$(printf '%s' '{"prompt":"next once more"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
945
+ assert_contains "BLOCKED round 3/3: hits threshold" "[Lessons Mode 提示]" "$OUT"
946
+ grep -q '"count": 0' "$BLK_DIR/.devlog/.lessons-advisory-state" && echo "PASS: counter reset after threshold" || { echo "FAIL: counter not reset"; FAIL=1; }
947
+ rm -rf "$BLK_DIR"
948
+
949
+ # --- BLOCKED->resolved: mechanical, one-shot print, not counted -------------
950
+ RES_DIR="$(mktemp -d)"
951
+ export CLAUDE_PROJECT_DIR="$RES_DIR"
952
+ mkdir -p "$RES_DIR/.devlog"
953
+ touch "$RES_DIR/.devlog/.enabled" "$RES_DIR/.devlog/.lessons-enabled"
954
+ cat > "$RES_DIR/.devlog/devlog.md" <<'EOF'
955
+ ## Round 1 — 2026-09-20T00:00:00+08:00
956
+
957
+ ### Summary
958
+ s
959
+
960
+ ### Reply
961
+ fixture reply.
962
+
963
+ ### Handoff
964
+ #### 現況
965
+ waiting
966
+ #### 完成條件
967
+ 缺的外部輸入已出現,且可觀察條件達成。
968
+ #### 下一步
969
+ apply the answer
970
+
971
+ ### Status
972
+ BLOCKED
973
+
974
+ ## Round 2 — 2026-09-20T00:05:00+08:00
975
+
976
+ ### Summary
977
+ resolved it
978
+
979
+ ### Reply
980
+ fixture reply.
981
+
982
+ ### Handoff
983
+ #### 現況
984
+ done
985
+
986
+ ### Status
987
+ DONE
988
+ EOF
989
+ OUT="$(printf '%s' '{"prompt":"whats next"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
990
+ assert_contains "BLOCKED->resolved prints a one-shot advisory" "上一輪從 BLOCKED 解開了" "$OUT"
991
+ [ ! -f "$RES_DIR/.devlog/.lessons-advisory-state" ] && echo "PASS: resolved-transition print does not touch the shared counter" || { echo "FAIL: resolved-transition print created/touched the counter file"; FAIL=1; }
992
+
993
+ # resolved print requires two rounds of history: with only one round of
994
+ # history (regardless of its own status) there's no "second-to-last" round
995
+ # to compare against.
996
+ rm -rf "$RES_DIR"
997
+ RES2_DIR="$(mktemp -d)"
998
+ export CLAUDE_PROJECT_DIR="$RES2_DIR"
999
+ mkdir -p "$RES2_DIR/.devlog"
1000
+ touch "$RES2_DIR/.devlog/.enabled" "$RES2_DIR/.devlog/.lessons-enabled"
1001
+ cat > "$RES2_DIR/.devlog/devlog.md" <<'EOF'
1002
+ ## Round 1 — 2026-09-20T00:00:00+08:00
1003
+
1004
+ ### Summary
1005
+ s
1006
+
1007
+ ### Reply
1008
+ fixture reply.
1009
+
1010
+ ### Handoff
1011
+ #### 現況
1012
+ done
1013
+
1014
+ ### Status
1015
+ DONE
1016
+ EOF
1017
+ OUT="$(printf '%s' '{"prompt":"go"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1018
+ assert_not_contains "cold start (one round of history): no resolved print" "上一輪從 BLOCKED 解開了" "$OUT"
1019
+ rm -rf "$RES2_DIR"
1020
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
1021
+
1022
+ # --- without .lessons-enabled: BLOCKED accumulation does not run at all ----
1023
+ NOLES_DIR="$(mktemp -d)"
1024
+ export CLAUDE_PROJECT_DIR="$NOLES_DIR"
1025
+ mkdir -p "$NOLES_DIR/.devlog"
1026
+ touch "$NOLES_DIR/.devlog/.enabled"
1027
+ cat > "$NOLES_DIR/.devlog/devlog.md" <<'EOF'
1028
+ ## Round 1 — 2026-09-20T00:00:00+08:00
1029
+
1030
+ ### Summary
1031
+ s
1032
+
1033
+ ### Reply
1034
+ fixture reply.
1035
+
1036
+ ### Handoff
1037
+ #### 現況
1038
+ waiting
1039
+ #### 完成條件
1040
+ 缺的外部輸入已出現,且可觀察條件達成。
1041
+ #### 下一步
1042
+ apply the answer
1043
+
1044
+ ### Status
1045
+ BLOCKED
1046
+ EOF
1047
+ OUT="$(printf '%s' '{"prompt":"next"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1048
+ assert_not_contains "lessons off: no BLOCKED-accumulation advisory" "[Lessons Mode 提示]" "$OUT"
1049
+ [ ! -f "$NOLES_DIR/.devlog/.lessons-advisory-state" ] && echo "PASS: lessons off leaves no advisory-state file" || { echo "FAIL: advisory-state file created while lessons off"; FAIL=1; }
1050
+ rm -rf "$NOLES_DIR"
1051
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
1052
+
1053
+ # --- lessons: background agent / workflow task-notification advisory ------
1054
+ TN_DIR="$(mktemp -d)"
1055
+ mkdir -p "$TN_DIR/.devlog"
1056
+ touch "$TN_DIR/.devlog/.enabled" "$TN_DIR/.devlog/.lessons-enabled"
1057
+ export CLAUDE_PROJECT_DIR="$TN_DIR"
1058
+ tn_notif() {
1059
+ printf '{"prompt":"<task-notification><task-id>t9</task-id><status>%s</status><summary>Agent \\"x\\" finished</summary></task-notification>"}' "$1"
1060
+ }
1061
+ tn_reset() {
1062
+ rm -f "$TN_DIR/.devlog/.round-open" "$TN_DIR/.devlog/.round-current.md"
1063
+ 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"
1064
+ }
1065
+
1066
+ tn_reset
1067
+ printf '%s\n' '{"count": 0, "threshold": 3}' > "$TN_DIR/.devlog/.lessons-advisory-state"
1068
+ OUT="$(tn_notif completed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1069
+ assert_contains "task-notif completed: sub agent advisory printed" "[Lessons Mode 提示] 背景任務完成(status=completed)" "$OUT"
1070
+ 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; }
1071
+
1072
+ tn_reset
1073
+ OUT="$(tn_notif failed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1074
+ assert_contains "task-notif failed: advisory printed" "背景任務完成(status=failed)" "$OUT"
1075
+ 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; }
1076
+
1077
+ tn_reset
1078
+ printf '%s\n' '{"count": 2, "threshold": 3}' > "$TN_DIR/.devlog/.lessons-advisory-state"
1079
+ OUT="$(tn_notif killed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1080
+ assert_contains "task-notif killed at threshold: shared nudge printed" "流程訊號已累積出現 3 次" "$OUT"
1081
+ 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; }
1082
+
1083
+ tn_reset
1084
+ printf '%s\n' '{"round": 1, "ticks_since_checkin": 0, "max_silent_ticks": 5}' > "$TN_DIR/.devlog/.span-open"
1085
+ OUT="$(tn_notif completed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1086
+ assert_contains "task-notif during span: advisory still printed" "背景任務完成(status=completed)" "$OUT"
1087
+ rm -f "$TN_DIR/.devlog/.span-open"
1088
+
1089
+ tn_reset
1090
+ rm -f "$TN_DIR/.devlog/.lessons-enabled" "$TN_DIR/.devlog/.lessons-advisory-state"
1091
+ OUT="$(tn_notif failed | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1092
+ assert_not_contains "task-notif with lessons off: no advisory" "[Lessons Mode 提示]" "$OUT"
1093
+ [ ! -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; }
1094
+
1095
+ touch "$TN_DIR/.devlog/.lessons-enabled"
1096
+ tn_reset
1097
+ OUT="$(printf '%s' '{"prompt":"plain message"}' | bash "$SCRIPT_DIR/round-start.sh" 2>/dev/null)"
1098
+ assert_not_contains "plain prompt: no background-task advisory" "背景任務完成" "$OUT"
1099
+ rm -rf "$TN_DIR"
1100
+ export CLAUDE_PROJECT_DIR="$TMP_ROOT"
1101
+
819
1102
  if [ "$FAIL" -eq 0 ]; then
820
1103
  echo "All checks passed."
821
1104
  exit 0