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
@@ -0,0 +1,51 @@
1
+ # shellcheck shell=bash
2
+ # Sourced by round-start.sh / lessons-on.sh / lessons-drift-set.sh. Owns the shared
3
+ # `.lessons-advisory-state` file (docs/design/lessons-mode.md 「機制性訊號:
4
+ # 共用計數器」): a `count`/`threshold` pair fed by more than one mechanical
5
+ # signal (workspace drift, accumulated BLOCKED rounds), so the file and
6
+ # function names are signal-agnostic rather than "drift"-specific.
7
+ _lessons_advisory_state_src="${BASH_SOURCE[0]}"
8
+ _LESSONS_ADVISORY_STATE_DIR="$(cd "${_lessons_advisory_state_src%/*}" && pwd)"
9
+ # shellcheck source=json-field.sh
10
+ . "$_LESSONS_ADVISORY_STATE_DIR/json-field.sh"
11
+
12
+ # One-time migration from the old, drift-only file name/fields. No-op unless
13
+ # the old file exists and the new one doesn't; safe to call on every
14
+ # invocation (idempotent, cheap after the first migration).
15
+ lessons_advisory_migrate() {
16
+ local devlog_dir="$1"
17
+ local old="$devlog_dir/.lessons-drift-state"
18
+ local new="$devlog_dir/.lessons-advisory-state"
19
+ [ -f "$new" ] && return 0
20
+ [ -f "$old" ] || return 0
21
+ local count threshold
22
+ count="$(json_int_get "$old" mismatch_count)"
23
+ threshold="$(json_int_get "$old" threshold)"
24
+ case "$count" in ''|*[!0-9]*) count=0 ;; esac
25
+ case "$threshold" in ''|*[!0-9]*) threshold=3 ;; esac
26
+ printf '{"count": %s, "threshold": %s}\n' "$count" "$threshold" > "$new" 2>/dev/null || return 0
27
+ rm -f "$old" 2>/dev/null || true
28
+ }
29
+
30
+ # Increments $1 (the advisory-state file, created with defaults if missing),
31
+ # printing the shared advisory line and resetting to 0 once the threshold is
32
+ # reached. Callers call this once per mechanical signal occurrence; a
33
+ # printed line means "surface this to Claude" (round-start.sh's stdout is
34
+ # the only place a non-blocking hook message reaches Claude's context).
35
+ lessons_advisory_bump() {
36
+ local file="$1" count max newcount
37
+ if [ ! -f "$file" ]; then
38
+ printf '%s\n' '{"count": 0, "threshold": 3}' > "$file" 2>/dev/null || true
39
+ fi
40
+ count="$(json_int_get "$file" count)"
41
+ max="$(json_int_get "$file" threshold)"
42
+ case "$count" in ''|*[!0-9]*) count=0 ;; esac
43
+ case "$max" in ''|*[!0-9]*) max=3 ;; esac
44
+ newcount=$((count + 1))
45
+ if [ "$newcount" -ge "$max" ]; then
46
+ json_int_set "$file" count 0
47
+ printf '\n[Lessons Mode 提示] 流程訊號已累積出現 %s 次(門檻 %s)。可考慮用 lessons-append.sh 記一筆流程教訓,非強制。\n' "$newcount" "$max"
48
+ else
49
+ json_int_set "$file" count "$newcount"
50
+ fi
51
+ }
@@ -52,8 +52,10 @@ trap 'devlog_lock_release' EXIT
52
52
  TARGET="$DEVLOG_DIR/devlog.lessons.$TOPIC.md"
53
53
  TS="$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S%z')"
54
54
 
55
+ IS_NEW_TOPIC=0
55
56
  if [ ! -f "$TARGET" ]; then
56
57
  printf '# Lessons: %s\n\n- source: `.devlog/devlog.md`\n' "$TOPIC" > "$TARGET" || exit 1
58
+ IS_NEW_TOPIC=1
57
59
  fi
58
60
  {
59
61
  printf '\n## %s\n' "$TS"
@@ -66,6 +68,7 @@ fi
66
68
  # heading in that file, truncated at the first 。/. (whichever comes
67
69
  # first); no truncation if neither appears.
68
70
  INDEX_LINES=""
71
+ OTHER_TOPICS=""
69
72
  shopt -s nullglob
70
73
  for f in "$DEVLOG_DIR"/devlog.lessons.*.md; do
71
74
  [ -f "$f" ] || continue
@@ -73,6 +76,13 @@ for f in "$DEVLOG_DIR"/devlog.lessons.*.md; do
73
76
  n="$(grep -c '^## ' "$f" 2>/dev/null || echo 0)"
74
77
  case "$n" in ''|*[!0-9]*) n=0 ;; esac
75
78
  [ "$n" -gt 0 ] || continue
79
+ if [ "$leaf" = "devlog.lessons.$TOPIC.md" ]; then
80
+ THIS_TOPIC_COUNT="$n"
81
+ else
82
+ OTHER_TOPIC_NAME="${leaf#devlog.lessons.}"
83
+ OTHER_TOPIC_NAME="${OTHER_TOPIC_NAME%.md}"
84
+ OTHER_TOPICS="${OTHER_TOPICS:+$OTHER_TOPICS, }${OTHER_TOPIC_NAME}"
85
+ fi
76
86
  last_ln="$(grep -n '^## ' "$f" | tail -1 | cut -d: -f1)"
77
87
  updated_at="$(sed -n "${last_ln}p" "$f" | sed -E 's/^## //')"
78
88
  title="$(awk -v start="$last_ln" 'NR>start && $0 ~ /[^[:space:]]/ {print; exit}' "$f")"
@@ -91,3 +101,9 @@ devlog_strip_lessons_index "$MAIN" "$STRIPPED"
91
101
  } > "$STRIPPED.new" && mv "$STRIPPED.new" "$MAIN" || exit 1
92
102
 
93
103
  printf 'PATH=.devlog/devlog.lessons.%s.md\n' "$TOPIC"
104
+ if [ -n "${THIS_TOPIC_COUNT:-}" ] && [ $((THIS_TOPIC_COUNT % 3)) -eq 0 ]; then
105
+ printf '[Lessons Mode 提示] 這個主題已經累積 %s 則。可考慮升級成 docs/design/*.md 的正式決策,不強制。\n' "$THIS_TOPIC_COUNT"
106
+ fi
107
+ if [ "$IS_NEW_TOPIC" -eq 1 ] && [ -n "${OTHER_TOPICS:-}" ]; then
108
+ printf 'NEW_TOPIC。既有主題:%s(如果內容其實屬於這些主題之一,改用 --topic 該名稱重跑,避免同一件事分裂成兩個檔案)。\n' "$OTHER_TOPICS"
109
+ fi
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env bash
2
2
  # User-invoked: /devlog-tracker:lessons-drift filesystem side.
3
- # Sets the workspace-drift nudge's threshold (docs/design/lessons-mode.md
4
- # 「機制性訊號:工作區漂移重複發生」): how many cumulative 工作區-mismatch
5
- # occurrences (round-start.sh) before printing one advisory suggestion to
6
- # write a Lessons Mode entry. Nested under Lessons Mode, which is itself
7
- # nested under the main switch. $1 is required: a positive integer.
3
+ # Sets the shared advisory-signal threshold (docs/design/lessons-mode.md
4
+ # 「機制性訊號:共用計數器」): how many cumulative occurrences — workspace-
5
+ # drift mismatches or accumulated BLOCKED rounds (both round-start.sh) —
6
+ # before printing one advisory suggestion to write a Lessons Mode entry.
7
+ # Nested under Lessons Mode, which is itself nested under the main switch.
8
+ # $1 is required: a positive integer.
8
9
  set -uo pipefail
9
10
  _src="${BASH_SOURCE[0]}"
10
11
  SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
11
12
  # shellcheck source=json-field.sh
12
13
  . "$SCRIPT_DIR/json-field.sh"
14
+ # shellcheck source=lessons-advisory-state.sh
15
+ . "$SCRIPT_DIR/lessons-advisory-state.sh"
13
16
 
14
17
  PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
15
18
  DEVLOG_DIR="$PROJECT_DIR/.devlog"
@@ -31,12 +34,13 @@ case "$COUNT_ARG" in
31
34
  ;;
32
35
  esac
33
36
 
34
- if [ -f "$DEVLOG_DIR/.lessons-drift-state" ]; then
35
- json_int_set "$DEVLOG_DIR/.lessons-drift-state" threshold "$COUNT_ARG"
37
+ lessons_advisory_migrate "$DEVLOG_DIR"
38
+ if [ -f "$DEVLOG_DIR/.lessons-advisory-state" ]; then
39
+ json_int_set "$DEVLOG_DIR/.lessons-advisory-state" threshold "$COUNT_ARG"
36
40
  else
37
- printf '%s\n' "{\"mismatch_count\": 0, \"threshold\": ${COUNT_ARG}}" \
38
- > "$DEVLOG_DIR/.lessons-drift-state" || exit 1
41
+ printf '%s\n' "{\"count\": 0, \"threshold\": ${COUNT_ARG}}" \
42
+ > "$DEVLOG_DIR/.lessons-advisory-state" || exit 1
39
43
  fi
40
44
 
41
- echo "LESSONS_DRIFT_THRESHOLD=$(json_int_get "$DEVLOG_DIR/.lessons-drift-state" threshold)"
45
+ echo "LESSONS_DRIFT_THRESHOLD=$(json_int_get "$DEVLOG_DIR/.lessons-advisory-state" threshold)"
42
46
  exit 0
@@ -4,6 +4,10 @@
4
4
  # disable」): refuses if .enabled is absent, since there is no Round/Status
5
5
  # history to detect a BLOCKED->resolved transition against.
6
6
  set -uo pipefail
7
+ _src="${BASH_SOURCE[0]}"
8
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
9
+ # shellcheck source=lessons-advisory-state.sh
10
+ . "$SCRIPT_DIR/lessons-advisory-state.sh"
7
11
  PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
8
12
  DEVLOG_DIR="$PROJECT_DIR/.devlog"
9
13
  if [ ! -f "$DEVLOG_DIR/.enabled" ]; then
@@ -13,9 +17,10 @@ fi
13
17
  if [ ! -f "$DEVLOG_DIR/.lessons-enabled" ]; then
14
18
  date -Iseconds > "$DEVLOG_DIR/.lessons-enabled" 2>/dev/null || echo enabled > "$DEVLOG_DIR/.lessons-enabled"
15
19
  fi
16
- if [ ! -f "$DEVLOG_DIR/.lessons-drift-state" ]; then
17
- printf '%s\n' '{"mismatch_count": 0, "threshold": 3}' \
18
- > "$DEVLOG_DIR/.lessons-drift-state" 2>/dev/null || true
20
+ lessons_advisory_migrate "$DEVLOG_DIR"
21
+ if [ ! -f "$DEVLOG_DIR/.lessons-advisory-state" ]; then
22
+ printf '%s\n' '{"count": 0, "threshold": 3}' \
23
+ > "$DEVLOG_DIR/.lessons-advisory-state" 2>/dev/null || true
19
24
  fi
20
25
  echo "LESSONS_ENABLED=$DEVLOG_DIR/.lessons-enabled"
21
26
  exit 0
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # PostToolUse hook(Claude Code,matcher Agent|Task):前景 sub agent 跑完、
3
+ # 結果回到主 session 時,Lessons Mode 開著就用 additionalContext 提示主
4
+ # session 檢查 sub agent 訊號(docs/design/lessons-mode.md「sub agent/
5
+ # workflow 情境」)。背景 agent 在這裡只是剛啟動(tool_response.status 為
6
+ # async_launched),完成時改由 round-start.sh 的 task-notification 分支提示,
7
+ # 這裡略過;sub agent 自己再派 agent(payload 帶 agent_id)也略過。
8
+ # fail-open:任何問題都 exit 0、不輸出。
9
+
10
+ set -uo pipefail
11
+
12
+ _src="${BASH_SOURCE[0]}"
13
+ HOOKS_DIR="$(cd "${_src%/*}" && pwd)"
14
+ # shellcheck source=json-field.sh
15
+ . "$HOOKS_DIR/json-field.sh"
16
+ PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
17
+ [ -f "$PROJECT_DIR/.devlog/.enabled" ] || exit 0
18
+ [ -f "$PROJECT_DIR/.devlog/.lessons-enabled" ] || exit 0
19
+
20
+ INPUT="$(cat 2>/dev/null || true)"
21
+ [ -n "$(json_str_field "$INPUT" agent_id)" ] && exit 0
22
+
23
+ ASYNC=0
24
+ if command -v jq >/dev/null 2>&1; then
25
+ if printf '%s' "$INPUT" | jq -e '(.tool_response.status? == "async_launched") or (.tool_response.isAsync? == true)' >/dev/null 2>&1; then
26
+ ASYNC=1
27
+ fi
28
+ else
29
+ case "$INPUT" in
30
+ *'"status":"async_launched"'*|*'"isAsync":true'*) ASYNC=1 ;;
31
+ esac
32
+ fi
33
+ [ "$ASYNC" -eq 1 ] && exit 0
34
+
35
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"[Lessons Mode 提示] sub agent 完成。讀完回報後檢查:verify 推翻先前的 fix/claim、sub agent 自陳繞路、多個 agent 卡在類似問題、成果被打回票——有的話可考慮用 lessons-append.sh 記一筆(sub agent 若已自己記過就不用重複),非強制。"}}'
36
+ exit 0
@@ -0,0 +1,36 @@
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
+ PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
14
+ [ -f "$PROJECT_DIR/.devlog/.enabled" ] || exit 0
15
+ [ -f "$PROJECT_DIR/.devlog/.lessons-enabled" ] || exit 0
16
+ PROJECT_DIR="$(cd "$PROJECT_DIR" 2>/dev/null && pwd)" || exit 0
17
+ cat >/dev/null 2>&1 || true
18
+
19
+ MSG="[devlog-tracker Lessons Mode] 這個專案開著 Lessons Mode:記錄開發**過程**踩過的坑(不是架構知識)。如果這次任務中你繞了一大圈才找到對的做法、先前的 fix/claim 被驗證推翻、或卡在某個值得後人避開的坑,可以直接記一筆(非強制,沒有就不用記):
20
+
21
+ DEVLOG_PROJECT_DIR='${PROJECT_DIR}' bash '${HOOKS_DIR}/lessons-append.sh' --topic '<kebab-case 主題,2–4 段>' --text '<一段自由散文:卡在哪、怎麼解開、下次怎麼避免>'
22
+
23
+ 路徑都是絕對路徑,在 worktree 裡也照原樣用,不要改成相對路徑。有記的話,在最終回報裡用一句話說明記了哪個主題;腳本回報 NEW_TOPIC 時可改用它列出的既有主題重跑。"
24
+
25
+ json_escape() {
26
+ printf '%s' "$1" | awk '
27
+ BEGIN { ORS = "" }
28
+ {
29
+ gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t"); gsub(/\r/, "\\r")
30
+ if (NR > 1) print "\\n"
31
+ print
32
+ }'
33
+ }
34
+
35
+ printf '{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"%s"}}\n' "$(json_escape "$MSG")"
36
+ exit 0
@@ -24,6 +24,8 @@ HOOKS_DIR="$(cd "${_src%/*}" && pwd)"
24
24
  . "$HOOKS_DIR/detect-pending-question.sh"
25
25
  # shellcheck source=devlog-path.sh
26
26
  . "$HOOKS_DIR/devlog-path.sh"
27
+ # shellcheck source=lessons-advisory-state.sh
28
+ . "$HOOKS_DIR/lessons-advisory-state.sh"
27
29
  PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
28
30
  [ -f "$PROJECT_DIR/.devlog/.enabled" ] || exit 0
29
31
  devlog_resolve_paths "$PROJECT_DIR"
@@ -33,7 +35,7 @@ CHECKPOINT_FILE="$DEVLOG_DIR/.checkpoint-state"
33
35
  SEGMENT_FILE="$DEVLOG_DIR/.segment-state"
34
36
  ROUND_OPEN="$DEVLOG_DIR/.round-open"
35
37
  AWAITING_FILE="$DEVLOG_DIR/.awaiting-reply"
36
- DRIFT_FILE="$DEVLOG_DIR/.lessons-drift-state"
38
+ ADVISORY_FILE="$DEVLOG_DIR/.lessons-advisory-state"
37
39
 
38
40
  devlog_lock_acquire
39
41
  trap 'devlog_lock_release' EXIT
@@ -78,6 +80,26 @@ fi
78
80
 
79
81
  PROMPT="$(json_str_field "$INPUT" prompt)"
80
82
 
83
+ # devlog-tracker 自己的純管理指令(讀狀態或操作 devlog 系統本身,不做開發工作):
84
+ # 這一輪完全不開 Round、不動任何計數器,整輪直接放行,等於這個 tick 沒發生過。
85
+ # 不包含 continue/resume——這兩個會接著做實際開發工作,仍要照常強制記錄。
86
+ # 偵測依據:Claude Code 呼叫 slash command/skill 時,這一輪的 prompt 內容會帶
87
+ # 一個 <command-name> 標籤(本專案這些指令都是以 skill 形式提供給 Claude)。
88
+ # 抓不到就落回正常流程(fail-open,不影響一般訊息)。
89
+ CMD_NAME=""
90
+ case "$PROMPT" in
91
+ *'<command-name>'*)
92
+ CMD_NAME="${PROMPT#*<command-name>}"
93
+ CMD_NAME="${CMD_NAME%%</command-name>*}"
94
+ CMD_NAME="${CMD_NAME#/}"
95
+ ;;
96
+ esac
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)
99
+ exit 0
100
+ ;;
101
+ esac
102
+
81
103
  # 背景 task-notification(例如子 agent 完成通知)不是使用者真的打字:不留原始
82
104
  # XML,換成精簡摘要;也不當成新話題開新 Round,改折進最後一個 Round 當段落。
83
105
  TASK_NOTIF=0
@@ -101,6 +123,20 @@ if [ "$TASK_NOTIF" -eq 1 ]; then
101
123
  else
102
124
  PROMPT="$NOTIF_SUMMARY"
103
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
104
140
  fi
105
141
 
106
142
  if [ "$SPAN_SKIP" -eq 1 ]; then
@@ -133,21 +169,34 @@ if [ "$SPAN_SKIP" -eq 0 ] && [ "$TASK_NOTIF" -eq 0 ] && [ -f "$DEVLOG_FILE" ]; t
133
169
  printf '\n宣稱:\n%s\n\n實際:\n%s\n' "$CLAIMED_WS" "$LIVE_WS"
134
170
 
135
171
  if [ -f "$DEVLOG_DIR/.lessons-enabled" ]; then
136
- if [ ! -f "$DRIFT_FILE" ]; then
137
- printf '%s\n' '{"mismatch_count": 0, "threshold": 3}' \
138
- > "$DRIFT_FILE" 2>/dev/null || true
139
- fi
140
- DRIFT_COUNT="$(json_int_get "$DRIFT_FILE" mismatch_count)"
141
- DRIFT_MAX="$(json_int_get "$DRIFT_FILE" threshold)"
142
- case "$DRIFT_COUNT" in ''|*[!0-9]*) DRIFT_COUNT=0 ;; esac
143
- case "$DRIFT_MAX" in ''|*[!0-9]*) DRIFT_MAX=3 ;; esac
144
- NEW_DRIFT_COUNT=$((DRIFT_COUNT + 1))
145
- if [ "$NEW_DRIFT_COUNT" -ge "$DRIFT_MAX" ]; then
146
- json_int_set "$DRIFT_FILE" mismatch_count 0
147
- printf '\n[Lessons Mode 提示] 工作區宣稱與實際不符已累積出現 %s 次(門檻 %s)。可考慮用 lessons-append.sh 記一筆流程教訓,非強制。\n' "$NEW_DRIFT_COUNT" "$DRIFT_MAX"
148
- else
149
- json_int_set "$DRIFT_FILE" mismatch_count "$NEW_DRIFT_COUNT"
150
- fi
172
+ lessons_advisory_migrate "$DEVLOG_DIR"
173
+ lessons_advisory_bump "$ADVISORY_FILE"
174
+ fi
175
+ fi
176
+ fi
177
+ fi
178
+
179
+ if [ "$SPAN_SKIP" -eq 0 ] && [ "$TASK_NOTIF" -eq 0 ] && [ -f "$DEVLOG_FILE" ] && [ -f "$DEVLOG_DIR/.lessons-enabled" ]; then
180
+ BLOCKED_ROUND_STARTS="$(devlog_list_round_starts "$DEVLOG_FILE")"
181
+ BLOCKED_ROUND_COUNT="$(printf '%s\n' "$BLOCKED_ROUND_STARTS" | grep -c '.' || true)"
182
+ BLOCKED_LAST_LINE="$(printf '%s\n' "$BLOCKED_ROUND_STARTS" | awk 'END { print }')"
183
+ BLOCKED_LAST_START="$(printf '%s\n' "$BLOCKED_LAST_LINE" | awk '{ print $1 }')"
184
+ if [ -n "$BLOCKED_LAST_START" ]; then
185
+ BLOCKED_LAST_END="$(devlog_block_end "$DEVLOG_FILE" "$BLOCKED_LAST_START")"
186
+ BLOCKED_LAST_STATUS="$(devlog_round_status "$DEVLOG_FILE" "$BLOCKED_LAST_START" "$BLOCKED_LAST_END")"
187
+
188
+ if [ "$BLOCKED_LAST_STATUS" = "BLOCKED" ]; then
189
+ lessons_advisory_migrate "$DEVLOG_DIR"
190
+ lessons_advisory_bump "$ADVISORY_FILE"
191
+ fi
192
+
193
+ if [ "$BLOCKED_ROUND_COUNT" -ge 2 ]; then
194
+ BLOCKED_PREV_LINE="$(printf '%s\n' "$BLOCKED_ROUND_STARTS" | tail -2 | head -1)"
195
+ BLOCKED_PREV_START="$(printf '%s\n' "$BLOCKED_PREV_LINE" | awk '{ print $1 }')"
196
+ BLOCKED_PREV_END="$(devlog_block_end "$DEVLOG_FILE" "$BLOCKED_PREV_START")"
197
+ BLOCKED_PREV_STATUS="$(devlog_round_status "$DEVLOG_FILE" "$BLOCKED_PREV_START" "$BLOCKED_PREV_END")"
198
+ if [ "$BLOCKED_PREV_STATUS" = "BLOCKED" ] && [ "$BLOCKED_LAST_STATUS" != "BLOCKED" ]; then
199
+ printf '\n[Lessons Mode 提示] 上一輪從 BLOCKED 解開了。可考慮用 lessons-append.sh 記一筆這次卡在哪、怎麼解開,非強制。\n'
151
200
  fi
152
201
  fi
153
202
  fi
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # Filesystem side of /devlog-tracker:search <關鍵字>. Plain read only -- no
3
+ # workspace verification, no wait-for-confirmation flow, same posture as
4
+ # overview.md / lessons.md. Greps every devlog*.md file under .devlog/:
5
+ # devlog.md, devlog.archive.md, kept devlog.<name>.md, and
6
+ # devlog.lessons.<topic>.md all match this one glob, so there is no
7
+ # per-file-type logic to keep in sync when a new kind of devlog file is
8
+ # added. Case-insensitive fixed-string match; not fence-aware (a match or a
9
+ # heading inside a ``` block is still reported) -- this is a navigation aid,
10
+ # not a Stop-grade verifier.
11
+ set -uo pipefail
12
+
13
+ _src="${BASH_SOURCE[0]}"
14
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
15
+ # shellcheck source=devlog-path.sh
16
+ . "$SCRIPT_DIR/devlog-path.sh"
17
+
18
+ PROJECT_DIR="${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
19
+ devlog_resolve_paths "$PROJECT_DIR"
20
+
21
+ KEYWORD="${1:-}"
22
+ if [ -z "$KEYWORD" ]; then
23
+ echo "MISSING_KEYWORD" >&2
24
+ exit 1
25
+ fi
26
+
27
+ if [ ! -d "$DEVLOG_DIR" ]; then
28
+ echo "NO_INDEX"
29
+ exit 0
30
+ fi
31
+
32
+ shopt -s nullglob
33
+ FILES=("$DEVLOG_DIR"/devlog*.md)
34
+ if [ ${#FILES[@]} -eq 0 ]; then
35
+ echo "NO_INDEX"
36
+ exit 0
37
+ fi
38
+
39
+ FOUND=0
40
+ for f in "${FILES[@]}"; do
41
+ [ -f "$f" ] || continue
42
+ leaf="${f##*/}"
43
+ MATCHES="$(awk -v kw="$KEYWORD" '
44
+ BEGIN { heading = "" }
45
+ /^#{2,3} / { heading = $0 }
46
+ {
47
+ if (index(tolower($0), tolower(kw)) > 0) {
48
+ h = heading
49
+ gsub(/"/, "\\\"", h)
50
+ printf "HEADING=\"%s\" LINE=%d: %s\n", h, NR, $0
51
+ }
52
+ }
53
+ ' "$f")"
54
+ if [ -n "$MATCHES" ]; then
55
+ FOUND=1
56
+ printf 'FILE=.devlog/%s\n' "$leaf"
57
+ printf '%s\n' "$MATCHES"
58
+ fi
59
+ done
60
+
61
+ if [ "$FOUND" -eq 0 ]; then
62
+ echo "NO_MATCH"
63
+ fi
@@ -69,6 +69,13 @@ if [ -f "$SPAN_FILE" ]; then
69
69
  fi
70
70
  fi
71
71
 
72
+ if [ -s "${HANDOFF_FILE:-}" ]; then
73
+ echo "以下是目前的 Session Handoff 快照(.devlog/${HANDOFF_FILE##*/};精簡狀態,不是全文):"
74
+ echo ""
75
+ cat "$HANDOFF_FILE" 2>/dev/null || true
76
+ echo ""
77
+ fi
78
+
72
79
  if [ -f "$DEVLOG_FILE" ]; then
73
80
  echo "以下是本專案 .devlog/${DEVLOG_FILE##*/} 的接手摘要(不是全文;完整紀錄請自行讀取原檔):"
74
81
  echo ""
@@ -11,10 +11,10 @@ devlog_resolve_paths "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
11
11
 
12
12
  [ -f "$DEVLOG_DIR/.enabled" ] && echo "ENABLED=yes" || echo "ENABLED=no"
13
13
  [ -f "$DEVLOG_DIR/.lessons-enabled" ] && echo "LESSONS=yes" || echo "LESSONS=no"
14
- if [ -f "$DEVLOG_DIR/.lessons-drift-state" ]; then
15
- drift_count="$(json_int_get "$DEVLOG_DIR/.lessons-drift-state" mismatch_count)"
16
- drift_max="$(json_int_get "$DEVLOG_DIR/.lessons-drift-state" threshold)"
17
- printf 'LESSONS_DRIFT=%s/%s\n' "${drift_count:-0}" "${drift_max:-3}"
14
+ if [ -f "$DEVLOG_DIR/.lessons-advisory-state" ]; then
15
+ advisory_count="$(json_int_get "$DEVLOG_DIR/.lessons-advisory-state" count)"
16
+ advisory_max="$(json_int_get "$DEVLOG_DIR/.lessons-advisory-state" threshold)"
17
+ printf 'LESSONS_ADVISORY=%s/%s\n' "${advisory_count:-0}" "${advisory_max:-3}"
18
18
  fi
19
19
  if [ -f "$DEVLOG_DIR/.span-open" ]; then
20
20
  r="$(json_int_get "$DEVLOG_DIR/.span-open" round)"
@@ -156,5 +156,17 @@ printf '%s\n' '{"round": 1, "opened_at": "now"}' > "$ENAB/.devlog/.round-open"
156
156
  bash "$SCRIPT_DIR/clean-devlog.sh" --confirmed >/dev/null
157
157
  [ -f "$ENAB/.devlog/.enabled" ] && echo "PASS: .enabled untouched" || { echo "FAIL: .enabled removed"; FAIL=1; }
158
158
 
159
+ # clean removes handoff.md
160
+ HOFF="$TMP/handoffclean"
161
+ mkdir -p "$HOFF/.devlog"
162
+ export CLAUDE_PROJECT_DIR="$HOFF"
163
+ printf '# project\n\n' > "$HOFF/.devlog/devlog.md"
164
+ make_round "$HOFF/.devlog/devlog.md" 1 DONE
165
+ echo "## Session Handoff" > "$HOFF/.devlog/handoff.md"
166
+ bash "$SCRIPT_DIR/clean-devlog.sh" --confirmed >/dev/null
167
+ [ ! -f "$HOFF/.devlog/handoff.md" ] \
168
+ && echo "PASS: clean removes handoff.md" \
169
+ || { echo "FAIL: handoff.md survived clean"; FAIL=1; }
170
+
159
171
  if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
160
172
  else echo "Some checks FAILED."; exit 1; fi
@@ -33,5 +33,44 @@ ln -s "$(command -v bash)" "$MIN_PATH/bash"
33
33
  HELD="$(PATH="$MIN_PATH" bash -c '. "$1"; DEVLOG_DIR="$2"; devlog_lock_acquire; echo "${LOCK_HELD:-0}"' _ "$SCRIPT_DIR/devlog-lock.sh" "$DEVLOG_DIR")"
34
34
  [ "$HELD" = "0" ] && echo "PASS: missing lock tools fails open" || { echo "FAIL: restricted path held=$HELD"; FAIL=1; }
35
35
 
36
+ # Acquire writes its own pid into the lock dir; release removes the whole
37
+ # dir (pid file included), not just an empty rmdir.
38
+ devlog_lock_acquire
39
+ [ -f "$DEVLOG_DIR/.lock/pid" ] && [ "$(cat "$DEVLOG_DIR/.lock/pid")" = "$$" ] \
40
+ && echo "PASS: acquire writes own pid" || { echo "FAIL: pid file missing/wrong"; FAIL=1; }
41
+ devlog_lock_release
42
+ [ ! -d "$DEVLOG_DIR/.lock" ] && echo "PASS: release removes dir incl. pid file" \
43
+ || { echo "FAIL: lock dir survives release"; FAIL=1; }
44
+
45
+ # Stale lock (pid file names a process that is no longer running): reclaimed
46
+ # near-instantly instead of waiting out the full contention timeout.
47
+ mkdir "$DEVLOG_DIR/.lock"
48
+ echo 999999 > "$DEVLOG_DIR/.lock/pid"
49
+ START="$(date +%s)"
50
+ devlog_lock_acquire
51
+ END="$(date +%s)"
52
+ ELAPSED=$((END - START))
53
+ [ "${LOCK_HELD:-0}" -eq 1 ] && [ "$ELAPSED" -le 1 ] \
54
+ && echo "PASS: stale lock reclaimed fast" || { echo "FAIL: stale reclaim held=$LOCK_HELD elapsed=$ELAPSED"; FAIL=1; }
55
+ devlog_lock_release
56
+
57
+ # Live contention (pid file names a still-running process): fails open after
58
+ # the timeout as before, but now reports who is holding it.
59
+ sleep 5 &
60
+ LIVE_PID=$!
61
+ mkdir "$DEVLOG_DIR/.lock"
62
+ echo "$LIVE_PID" > "$DEVLOG_DIR/.lock/pid"
63
+ START="$(date +%s)"
64
+ devlog_lock_acquire
65
+ END="$(date +%s)"
66
+ ELAPSED=$((END - START))
67
+ [ "${LOCK_HELD:-0}" -eq 0 ] && [ "$ELAPSED" -ge 2 ] && [ "$ELAPSED" -le 3 ] \
68
+ && [ "${LOCK_CONTENDED_BY:-}" = "$LIVE_PID" ] \
69
+ && echo "PASS: live contention reports holder pid" \
70
+ || { echo "FAIL: live contention held=$LOCK_HELD elapsed=$ELAPSED by=${LOCK_CONTENDED_BY:-}"; FAIL=1; }
71
+ kill "$LIVE_PID" 2>/dev/null || true
72
+ wait "$LIVE_PID" 2>/dev/null || true
73
+ rm -rf "$DEVLOG_DIR/.lock"
74
+
36
75
  if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
37
76
  else echo "Some checks FAILED."; exit 1; fi
@@ -114,5 +114,49 @@ else
114
114
  FAIL=1
115
115
  fi
116
116
 
117
+ # --- HANDOFF_FILE mirrors DEVLOG_FILE naming (no rename migration) --------
118
+ devlog_resolve_paths "$NONGIT"
119
+ [ "$HANDOFF_FILE" = "$NONGIT/.devlog/handoff.md" ] \
120
+ && echo "PASS: non-git HANDOFF_FILE is handoff.md" \
121
+ || { echo "FAIL: non-git HANDOFF_FILE=$HANDOFF_FILE"; FAIL=1; }
122
+
123
+ devlog_resolve_paths "$MAINREPO"
124
+ [ "$HANDOFF_FILE" = "$MAINREPO/.devlog/handoff.md" ] \
125
+ && echo "PASS: main HANDOFF_FILE is handoff.md" \
126
+ || { echo "FAIL: main HANDOFF_FILE=$HANDOFF_FILE"; FAIL=1; }
127
+
128
+ devlog_resolve_paths "$FEATREPO"
129
+ [ "$HANDOFF_FILE" = "$FEATREPO/.devlog/handoff.feature-x.md" ] \
130
+ && echo "PASS: feature HANDOFF_FILE is handoff.feature-x.md" \
131
+ || { echo "FAIL: feature HANDOFF_FILE=$HANDOFF_FILE"; FAIL=1; }
132
+
133
+ devlog_resolve_paths "$SLASHREPO"
134
+ [ "$HANDOFF_FILE" = "$SLASHREPO/.devlog/handoff.feature-foo.md" ] \
135
+ && echo "PASS: slash branch HANDOFF_FILE sanitized" \
136
+ || { echo "FAIL: slash HANDOFF_FILE=$HANDOFF_FILE"; FAIL=1; }
137
+
138
+ devlog_resolve_paths "$DETACHEDREPO"
139
+ [ "$HANDOFF_FILE" = "$DETACHEDREPO/.devlog/handoff.detached-repo.md" ] \
140
+ && echo "PASS: detached HANDOFF_FILE uses worktree dirname" \
141
+ || { echo "FAIL: detached HANDOFF_FILE=$HANDOFF_FILE"; FAIL=1; }
142
+
143
+ # migration must NOT move handoff.md
144
+ MIGHO="$TMP/mighandoff"
145
+ mkdir -p "$MIGHO/.devlog"
146
+ : > "$MIGHO/.devlog/.enabled"
147
+ git_setup "$MIGHO"
148
+ git -C "$MIGHO" checkout -q -b feature-h
149
+ echo "old handoff" > "$MIGHO/.devlog/handoff.md"
150
+ echo "## Round 1" > "$MIGHO/.devlog/devlog.md"
151
+ devlog_resolve_paths "$MIGHO"
152
+ if [ -f "$MIGHO/.devlog/handoff.md" ] \
153
+ && [ ! -f "$MIGHO/.devlog/handoff.feature-h.md" ] \
154
+ && [ "$HANDOFF_FILE" = "$MIGHO/.devlog/handoff.feature-h.md" ]; then
155
+ echo "PASS: handoff.md is not renamed on first branch resolve"
156
+ else
157
+ echo "FAIL: handoff migration diverged (HANDOFF_FILE=$HANDOFF_FILE)"
158
+ FAIL=1
159
+ fi
160
+
117
161
  if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
118
162
  else echo "Some checks FAILED."; exit 1; fi
@@ -80,6 +80,17 @@ write_round() {
80
80
  echo "observable done via test."
81
81
  echo "#### 下一步"
82
82
  echo "edit hooks/scripts/enforce-devlog.sh"
83
+ echo ""
84
+ echo "### Session Handoff"
85
+ echo ""
86
+ echo "#### 決策"
87
+ echo "- (無)"
88
+ echo ""
89
+ echo "#### 待解問題"
90
+ echo "- fixture open"
91
+ echo ""
92
+ echo "#### 失敗嘗試"
93
+ echo "- (無)"
83
94
  fi
84
95
  echo ""
85
96
  echo "### Status"