devlog-tracker 0.33.5 → 1.0.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 (51) hide show
  1. package/README.md +41 -10
  2. package/README.zh-TW.md +41 -10
  3. package/cli/agents-md.js +1 -0
  4. package/cli/agents-md.test.js +10 -0
  5. package/cli/platforms/claude.js +1 -0
  6. package/codex/hooks/on-interrupt.sh +13 -0
  7. package/codex/hooks/on-pre-tool.sh +69 -1
  8. package/codex/hooks/on-session-end.sh +5 -5
  9. package/codex/hooks/on-session-start.sh +5 -6
  10. package/codex/hooks/on-stop.sh +3 -2
  11. package/codex/hooks/on-subagent-start.sh +19 -0
  12. package/codex/hooks/project-dir.sh +16 -1
  13. package/codex/hooks/test-adapters.sh +127 -9
  14. package/codex/hooks.json +21 -0
  15. package/commands/continue.md +3 -3
  16. package/commands/keep-all.md +1 -1
  17. package/commands/keep.md +2 -2
  18. package/commands/lessons-on.md +1 -1
  19. package/commands/migrate.md +18 -0
  20. package/commands/pr.md +3 -3
  21. package/commands/resume.md +1 -1
  22. package/core/scripts/close-open-round.sh +8 -2
  23. package/core/scripts/devlog-md.sh +6 -11
  24. package/core/scripts/enforce-devlog.sh +79 -85
  25. package/core/scripts/handoff-convert.sh +187 -0
  26. package/core/scripts/handoff-fields.sh +219 -0
  27. package/core/scripts/handoff-file.sh +19 -88
  28. package/core/scripts/lessons-subagent-start.sh +1 -1
  29. package/core/scripts/migrate-handoff.sh +91 -0
  30. package/core/scripts/round-start.sh +13 -2
  31. package/core/scripts/segment-watch.sh +1 -1
  32. package/core/scripts/tests/lib/xml-fixture.sh +10 -0
  33. package/core/scripts/tests/test-close-open-round.sh +2 -1
  34. package/core/scripts/tests/test-devlog-md.sh +21 -0
  35. package/core/scripts/tests/test-enforce-devlog-files.sh +4 -1
  36. package/core/scripts/tests/test-enforce-devlog-handoff-order.sh +149 -81
  37. package/core/scripts/tests/test-enforce-devlog-session-handoff.sh +92 -11
  38. package/core/scripts/tests/test-enforce-devlog-workspace.sh +42 -26
  39. package/core/scripts/tests/test-enforce-devlog.sh +70 -0
  40. package/core/scripts/tests/test-handoff-fields.sh +171 -0
  41. package/core/scripts/tests/test-handoff-file.sh +67 -45
  42. package/core/scripts/tests/test-migrate-handoff.sh +278 -0
  43. package/core/scripts/tests/test-round-start.sh +54 -1
  44. package/core/scripts/tests/test-session-start-devlog.sh +45 -0
  45. package/core/scripts/timeline-render.js +26 -2
  46. package/core/scripts/timeline-render.test.js +23 -1
  47. package/package.json +3 -3
  48. package/skills/devlog-tracker/SKILL.md +80 -55
  49. package/skills/devlog-tracker/references/contract.md +4 -3
  50. package/skills/devlog-tracker/references/lessons-mode.md +7 -7
  51. package/skills/devlog-tracker/references/round-segments.md +11 -5
@@ -33,6 +33,8 @@ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
33
33
  . "$SCRIPT_DIR/devlog-md.sh"
34
34
  # shellcheck source=handoff-file.sh
35
35
  . "$SCRIPT_DIR/handoff-file.sh"
36
+ # shellcheck source=handoff-fields.sh
37
+ . "$SCRIPT_DIR/handoff-fields.sh"
36
38
 
37
39
  # --- loop guard -------------------------------------------------------
38
40
  # 有 jq 就用 jq 精準解析;沒有 jq 就退化成字串比對(沒有更嚴謹的 parse,但
@@ -207,15 +209,13 @@ if [ -n "$LAST_ROUND" ]; then
207
209
  '
208
210
  }
209
211
 
210
- handoff_subsection_body() {
211
- local heading="$1"
212
- printf '%s\n' "$LAST_ROUND" | awk -v h="$heading" -v nofence="$NOFENCE" '
213
- /^[ \t]*```/ { if (!nofence) fence = !fence; if (grab) print; next }
214
- !fence && $0 ~ h { grab=1; next }
215
- grab && !fence && /^#### / { exit }
216
- grab && !fence && /^### / { exit }
217
- grab && !fence && /^## / { exit }
218
- grab { print }
212
+ # True when heading regex $1 matches a line outside ``` fences (same
213
+ # fence/NOFENCE rules as section_body).
214
+ section_present() {
215
+ printf '%s\n' "$LAST_ROUND" | awk -v h="$1" -v nofence="$NOFENCE" '
216
+ /^[ \t]*```/ { if (!nofence) fence = !fence; next }
217
+ !fence && $0 ~ h { found = 1; exit }
218
+ END { exit(found ? 0 : 1) }
219
219
  '
220
220
  }
221
221
 
@@ -234,44 +234,23 @@ if [ -n "$LAST_ROUND" ]; then
234
234
  exit 2
235
235
  fi
236
236
 
237
- # --- Handoff subsection order check (docs/design/devlog-as-ssot-assessment.md,
238
- # Phase 2 + L1 完成條件). 決策 → 檔案 → 工作區 → 現況 → 完成條件 → 下一步
239
- # is a fixed order. Detect a present-but-reordered or duplicated recognized
240
- # subsection. Unrecognized #### headings are ignored.
237
+ # --- Handoff format gate (docs/design/handoff-xml.md): the round Stop
238
+ # validates must use the XML form; legacy `#### ` rounds are history only.
241
239
  HANDOFF_BODY="$(section_body '^### Handoff')"
242
- ORDER_ERR="$(printf '%s\n' "$HANDOFF_BODY" | awk -v nofence="$NOFENCE" '
243
- BEGIN {
244
- order["決策"] = 1; order["檔案"] = 2; order["工作區"] = 3
245
- order["現況"] = 4; order["完成條件"] = 5; order["下一步"] = 6
246
- last = 0; prev_name = ""
247
- }
248
- /^[ \t]*```/ { if (!nofence) fence = !fence; next }
249
- fence { next }
250
- /^#### / {
251
- name = $0
252
- sub(/^#### [ \t]*/, "", name)
253
- sub(/[ \t]+$/, "", name)
254
- if (!(name in order)) next
255
- idx = order[name]
256
- if (seen[name]) { print "duplicate:" name; exit }
257
- seen[name] = 1
258
- if (idx < last) { print "order:" prev_name ">" name; exit }
259
- last = idx
260
- prev_name = name
261
- }
262
- ')"
263
- if [ -n "$ORDER_ERR" ]; then
264
- case "$ORDER_ERR" in
265
- duplicate:*)
266
- DUP_NAME="${ORDER_ERR#duplicate:}"
267
- echo "Handoff 的「#### ${DUP_NAME}」出現超過一次。請合併成一節。" >&2
268
- ;;
269
- order:*)
270
- echo "Handoff 小節順序錯了(應該是 決策 → 檔案 → 工作區 → 現況 → 完成條件 → 下一步):${ORDER_ERR#order:}" >&2
271
- ;;
272
- esac
240
+ if [ "$(handoff_format "$HANDOFF_BODY")" = "md" ]; then
241
+ handoff_legacy_message "$SCRIPT_DIR/migrate-handoff.sh" "$(cd "$PROJECT_DIR" 2>/dev/null && pwd || printf '%s' "$PROJECT_DIR")" >&2
273
242
  exit 2
274
243
  fi
244
+ if ! HANDOFF_ERR="$(handoff_xml_check "$HANDOFF_BODY" handoff)"; then
245
+ {
246
+ echo "Handoff 格式不對:${HANDOFF_ERR}"
247
+ echo ""
248
+ echo "正確格式(標籤自己一行、沒有的欄位整個省略):"
249
+ handoff_xml_template handoff
250
+ } >&2
251
+ exit 2
252
+ fi
253
+ hf() { handoff_field "$HANDOFF_BODY" "$1"; }
275
254
 
276
255
  STATUS_VAL="$(printf '%s\n' "$LAST_ROUND" | awk '
277
256
  /^### Status/ { grab=1; val=""; next }
@@ -289,25 +268,17 @@ if [ -n "$LAST_ROUND" ]; then
289
268
  esac
290
269
 
291
270
  if [ "$STATUS_VAL" = "IN_PROGRESS" ] || [ "$STATUS_VAL" = "BLOCKED" ]; then
292
- HAS_DONE_CRITERIA=0
293
- printf '%s\n' "$LAST_ROUND" | grep -q '^#### 完成條件' && HAS_DONE_CRITERIA=1
294
271
  DONE_CRITERIA_OK=0
295
- if [ "$HAS_DONE_CRITERIA" -eq 1 ]; then
296
- handoff_subsection_body '^#### 完成條件' | grep -q '[^[:space:]]' && DONE_CRITERIA_OK=1
297
- fi
272
+ hf done-when | grep -q '[^[:space:]]' && DONE_CRITERIA_OK=1
298
273
  if [ "$DONE_CRITERIA_OK" -eq 0 ]; then
299
- echo "Status 是 IN_PROGRESS 或 BLOCKED 時,Handoff 必須有「#### 完成條件」且後面有內容(可觀察的做完判準)。" >&2
274
+ echo "Status 是 IN_PROGRESS 或 BLOCKED 時,Handoff 必須有 \`<done-when>\`(完成條件)且裡面有內容(可觀察的做完判準)。" >&2
300
275
  exit 2
301
276
  fi
302
277
 
303
- HAS_NEXT=0
304
- printf '%s\n' "$LAST_ROUND" | grep -q '^#### 下一步' && HAS_NEXT=1
305
278
  NEXT_OK=0
306
- if [ "$HAS_NEXT" -eq 1 ]; then
307
- handoff_subsection_body '^#### 下一步' | grep -q '[^[:space:]]' && NEXT_OK=1
308
- fi
279
+ hf next | grep -q '[^[:space:]]' && NEXT_OK=1
309
280
  if [ "$NEXT_OK" -eq 0 ]; then
310
- echo "Status 是 IN_PROGRESS 或 BLOCKED 時,Handoff 必須有「#### 下一步」且後面有內容。" >&2
281
+ echo "Status 是 IN_PROGRESS 或 BLOCKED 時,Handoff 必須有 \`<next>\`(下一步)且裡面有內容。" >&2
311
282
  exit 2
312
283
  fi
313
284
 
@@ -318,7 +289,7 @@ if [ -n "$LAST_ROUND" ]; then
318
289
  # around one of these phrases always passes — see the design doc's
319
290
  # Match rule). Deliberately scoped to 下一步 only, never Summary/
320
291
  # 決策/現況.
321
- NEXT_BODY_TRIMMED="$(handoff_subsection_body '^#### 下一步' \
292
+ NEXT_BODY_TRIMMED="$(hf next \
322
293
  | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
323
294
  | grep -v '^$' || true)"
324
295
  NEXT_LINE_COUNT="$(printf '%s\n' "$NEXT_BODY_TRIMMED" | grep -c '.' || true)"
@@ -326,7 +297,7 @@ if [ -n "$LAST_ROUND" ]; then
326
297
  NEXT_STRIPPED="$(printf '%s' "$NEXT_BODY_TRIMMED" | sed -e 's/[。.!!]*$//')"
327
298
  case "$NEXT_STRIPPED" in
328
299
  繼續完成|持續完成|持續優化|持續改進|之後再看|視情況調整|待確認|繼續|持續推進|繼續處理)
329
- echo "「#### 下一步」目前只寫了「${NEXT_STRIPPED}」,這是空話,不算具體下一步。請寫清楚下一輪打開就能做的具體動作(路徑/指令/要載入的 skill)。" >&2
300
+ echo "\`<next>\`(下一步)目前只寫了「${NEXT_STRIPPED}」,這是空話,不算具體下一步。請寫清楚下一輪打開就能做的具體動作(路徑/指令/要載入的 skill)。" >&2
330
301
  exit 2
331
302
  ;;
332
303
  esac
@@ -337,7 +308,7 @@ if [ -n "$LAST_ROUND" ]; then
337
308
  # skill mention. False negatives OK; avoid scoring prose.
338
309
  if [ "$STATUS_VAL" = "IN_PROGRESS" ]; then
339
310
  if ! printf '%s\n' "$NEXT_BODY_TRIMMED" | grep -qE '/|`|\.[A-Za-z0-9]{1,10}([^A-Za-z0-9]|$)|[Ss][Kk][Ii][Ll][Ll]|hooks/|docs/|commands/|skills/'; then
340
- echo "Status 是 IN_PROGRESS 時,「#### 下一步」須含可執行跡象(路徑、反引號指令、檔名或 skill)。請寫到下一輪打開就能做。" >&2
311
+ echo "Status 是 IN_PROGRESS 時,\`<next>\`(下一步)須含可執行跡象(路徑、反引號指令、檔名或 skill)。請寫到下一輪打開就能做。" >&2
341
312
  exit 2
342
313
  fi
343
314
  fi
@@ -345,29 +316,29 @@ if [ -n "$LAST_ROUND" ]; then
345
316
  # --- BLOCKED: 缺件句式 in 現況 or 下一步 (binary check for next agent).
346
317
  if [ "$STATUS_VAL" = "BLOCKED" ]; then
347
318
  BLOCKED_HINT="$( {
348
- handoff_subsection_body '^#### 現況'
349
- handoff_subsection_body '^#### 下一步'
319
+ hf state
320
+ hf next
350
321
  } | tr '\n' ' ')"
351
322
  if ! printf '%s\n' "$BLOCKED_HINT" | grep -qE '缺|等待|等使用者|需要.*提供|尚未|出現.*算|出現即'; then
352
- echo "Status 是 BLOCKED 時,「#### 現況」或「#### 下一步」須寫清楚缺什麼、出現長怎樣(缺件句式),讓下一輪能判斷缺件是否已到。" >&2
323
+ echo "Status 是 BLOCKED 時,\`<state>\`(現況)或 \`<next>\`(下一步)須寫清楚缺什麼、出現長怎樣(缺件句式),讓下一輪能判斷缺件是否已到。" >&2
353
324
  exit 2
354
325
  fi
355
326
  fi
356
327
  fi
357
328
 
358
329
  # --- 工作區 machine-verify (docs/design/devlog-as-ssot-assessment.md,
359
- # Phase 1 + DONE-with-檔案 extension): #### 工作區 must match a freshly
330
+ # Phase 1 + DONE-with-檔案 extension): <workspace> must match a freshly
360
331
  # computed git snapshot exactly. Turns it from an unverified claim into a
361
332
  # write-time fact instead of something only continue/resume catch on the
362
333
  # next turn.
363
334
  #
364
335
  # Required for IN_PROGRESS/BLOCKED (unchanged from Phase 1) and for DONE
365
- # only when this round's Handoff has a non-empty #### 檔案 — i.e. it
336
+ # only when this round's Handoff has a non-empty <files> — i.e. it
366
337
  # claims to have touched/committed files. Without this, "已 commit 完成,
367
338
  # Status: DONE" was never checked against live git: the single most
368
339
  # common false-completion claim, and one prose-quality checks elsewhere
369
340
  # in this file explicitly leave unverified. A trivial DONE round with no
370
- # #### 檔案 still omits 工作區 entirely per SKILL.md's 瑣碎輪 convention —
341
+ # <files> still omits 工作區 entirely per SKILL.md's 瑣碎輪 convention —
371
342
  # unaffected.
372
343
  #
373
344
  # git unavailable -> fail-open, skip this check like every other one here.
@@ -375,15 +346,15 @@ if [ -n "$LAST_ROUND" ]; then
375
346
  case "$STATUS_VAL" in
376
347
  IN_PROGRESS|BLOCKED) NEEDS_WORKSPACE_CHECK=1 ;;
377
348
  DONE)
378
- handoff_subsection_body '^#### 檔案' | grep -q '[^[:space:]]' && NEEDS_WORKSPACE_CHECK=1
349
+ hf files | grep -q '[^[:space:]]' && NEEDS_WORKSPACE_CHECK=1
379
350
  ;;
380
351
  esac
381
352
  if [ "$NEEDS_WORKSPACE_CHECK" -eq 1 ] && command -v git >/dev/null 2>&1; then
382
353
  EXPECTED_WS="$(workspace_snapshot "$PROJECT_DIR" 2>/dev/null || true)"
383
354
  if [ -n "$EXPECTED_WS" ]; then
384
- ACTUAL_WS="$(handoff_subsection_body '^#### 工作區' | sed -e '/^[[:space:]]*$/d')"
355
+ ACTUAL_WS="$(hf workspace | sed -e '/^[[:space:]]*$/d')"
385
356
  if [ "$ACTUAL_WS" != "$EXPECTED_WS" ]; then
386
- echo "#### 工作區 跟目前 git 狀態不符(或缺漏)。請把這一節內容換成以下逐字內容:" >&2
357
+ echo "\`<workspace>\`(工作區)跟目前 git 狀態不符(或缺漏)。請把 \`<workspace>\` 裡的內容換成以下逐字內容:" >&2
387
358
  echo "" >&2
388
359
  printf '%s\n' "$EXPECTED_WS" >&2
389
360
  exit 2
@@ -392,9 +363,9 @@ if [ -n "$LAST_ROUND" ]; then
392
363
  fi
393
364
 
394
365
  # --- 檔案 machine-verify (docs/design/files-verify.md, devlog ssot
395
- # Phase 4): #### 檔案 must describe real git changes. Runs whenever this
396
- # round's Handoff has a non-empty #### 檔案, independent of Status — a
397
- # trivial round with no #### 檔案 (the 瑣碎輪 convention) is unaffected.
366
+ # Phase 4): <files> must describe real git changes. Runs whenever this
367
+ # round's Handoff has a non-empty <files>, independent of Status — a
368
+ # trivial round with no <files> (the 瑣碎輪 convention) is unaffected.
398
369
  #
399
370
  # Grammar: zero or more "commit <hash>:" blocks (checked exactly,
400
371
  # category-precise, against files_snapshot $PROJECT_DIR $hash) followed
@@ -404,9 +375,9 @@ if [ -n "$LAST_ROUND" ]; then
404
375
  # residue, see docs/design/files-verify.md Decision 4). A line that
405
376
  # isn't a recognized header or category line is a format violation and
406
377
  # blocks (not fail-open — Claude is expected to produce this grammar,
407
- # same as #### 工作區's seven formats). git unavailable, or a commit
378
+ # same as <workspace>'s seven formats). git unavailable, or a commit
408
379
  # hash that doesn't resolve, skips just that check (fail-open).
409
- FILES_BODY="$(handoff_subsection_body '^#### 檔案')"
380
+ FILES_BODY="$(hf files)"
410
381
  if printf '%s\n' "$FILES_BODY" | grep -q '[^[:space:]]' && command -v git >/dev/null 2>&1; then
411
382
  FILES_ERR=""
412
383
  CUR_KIND=""
@@ -414,7 +385,7 @@ if [ -n "$LAST_ROUND" ]; then
414
385
  CUR_CLAIM=""
415
386
  UNCOMMITTED_CLAIM_PATHS=""
416
387
  # Printed after a format-violation message so Claude has the exact
417
- # grammar to correct against, same rigor #### 工作區 already gets on
388
+ # grammar to correct against, same rigor <workspace> already gets on
418
389
  # mismatch (it prints its own EXPECTED_WS). Category lines can be
419
390
  # omitted per block for a category with nothing to report, same as
420
391
  # files-snapshot.sh's own output.
@@ -430,7 +401,7 @@ commit <hash>:
430
401
  刪除:<path>"
431
402
 
432
403
  files_body_parse() {
433
- # Normalizes #### 檔案's body into tagged records, one per input
404
+ # Normalizes <files>'s body into tagged records, one per input
434
405
  # line (blank/whitespace-only lines dropped):
435
406
  # HDR\tcommit\t<hash>
436
407
  # HDR\tuncommitted
@@ -476,7 +447,7 @@ commit ${h}:
476
447
  ${expected}"
477
448
  else
478
449
  FILES_ERR="$FILES_ERR
479
- 這個 commit 在 .devlog/ 以外沒有變更,「#### 檔案」裡不該有這個 commit 區塊(或整節省略,如果沒有其他 commit/尚未 commit 內容要報)。"
450
+ 這個 commit 在 .devlog/ 以外沒有變更,\`<files>\`(檔案)裡不該有這個 commit 區塊(或整個 \`<files>\` 省略,如果沒有其他 commit/尚未 commit 內容要報)。"
480
451
  fi
481
452
  fi
482
453
  }
@@ -518,14 +489,14 @@ ${expected}"
518
489
  UNCOMMITTED_CLAIM_PATHS="${UNCOMMITTED_CLAIM_PATHS:+$UNCOMMITTED_CLAIM_PATHS, }${b}"
519
490
  ;;
520
491
  *)
521
- FILES_ERR="#### 檔案 格式不對:分類行出現在任何 commit/尚未 commit 標頭之前。
492
+ FILES_ERR="\`<files>\`(檔案)格式不對:分類行出現在任何 commit/尚未 commit 標頭之前。
522
493
 
523
494
  ${FILES_GRAMMAR}"
524
495
  ;;
525
496
  esac
526
497
  ;;
527
498
  ERR)
528
- FILES_ERR="#### 檔案 格式不對,看不懂這一行:${a}
499
+ FILES_ERR="\`<files>\`(檔案)格式不對,看不懂這一行:${a}
529
500
 
530
501
  ${FILES_GRAMMAR}"
531
502
  ;;
@@ -544,7 +515,7 @@ ${FILES_GRAMMAR}"
544
515
  _p="$(printf '%s' "$_p" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
545
516
  [ -n "$_p" ] || continue
546
517
  if ! path_in_list "$_p" "$ACTUAL_JOINED"; then
547
- FILES_ERR="#### 檔案 的「尚未 commit」宣稱了 ${_p},但它目前不在實際變更的檔案裡。目前實際的未提交變更是:
518
+ FILES_ERR="\`<files>\`(檔案)的「尚未 commit」宣稱了 ${_p},但它目前不在實際變更的檔案裡。目前實際的未提交變更是:
548
519
  ${ACTUAL_DIRTY:-(沒有,工作樹乾淨)}"
549
520
  break
550
521
  fi
@@ -557,11 +528,34 @@ ${ACTUAL_DIRTY:-(沒有,工作樹乾淨)}"
557
528
  fi
558
529
  fi
559
530
 
560
- # Session Handoff → .devlog/handoff.md(docs/design/session-handoff-file.md)
531
+ # Session Handoff → .devlog/handoff.md(docs/design/session-handoff-file.md,
532
+ # docs/design/handoff-xml.md)。Present in any status → must be XML;
533
+ # required for IN_PROGRESS/BLOCKED.
561
534
  # 放在其他 IN_PROGRESS/BLOCKED 檢查之後,避免搶先蓋掉既有失敗訊息。
562
- if [ "$STATUS_VAL" = "IN_PROGRESS" ] || [ "$STATUS_VAL" = "BLOCKED" ]; then
563
- if ! handoff_session_section_ok "$LAST_ROUND"; then
564
- echo "Status 是 IN_PROGRESS 或 BLOCKED 時,必須有 \`### Session Handoff\`,且依序包含 \`#### 決策\`/\`#### 待解問題\`/\`#### 失敗嘗試\`(可寫 \`- (無)\`)。寫完後 hook 會覆寫 .devlog/handoff.md 給下一 session。" >&2
535
+ # Exact heading only (same as handoff_section_of, which handoff_write uses).
536
+ SESSION_HEADING_RE='^### Session Handoff[ \t]*$'
537
+ SESSION_BODY="$(section_body "$SESSION_HEADING_RE")"
538
+ HAS_SESSION=0
539
+ section_present "$SESSION_HEADING_RE" && HAS_SESSION=1
540
+ if [ "$HAS_SESSION" -eq 1 ] && [ "$(handoff_format "$SESSION_BODY")" = "md" ]; then
541
+ handoff_legacy_message "$SCRIPT_DIR/migrate-handoff.sh" "$(cd "$PROJECT_DIR" 2>/dev/null && pwd || printf '%s' "$PROJECT_DIR")" >&2
542
+ exit 2
543
+ fi
544
+ NEED_SESSION=0
545
+ case "$STATUS_VAL" in IN_PROGRESS|BLOCKED) NEED_SESSION=1 ;; esac
546
+ if [ "$HAS_SESSION" -eq 1 ] || [ "$NEED_SESSION" -eq 1 ]; then
547
+ if [ "$HAS_SESSION" -eq 0 ]; then
548
+ SESSION_ERR="缺少 ### Session Handoff(Status 是 IN_PROGRESS 或 BLOCKED 時必寫)"
549
+ elif SESSION_ERR="$(handoff_xml_check "$SESSION_BODY" session-handoff)"; then
550
+ SESSION_ERR=""
551
+ fi
552
+ if [ -n "$SESSION_ERR" ]; then
553
+ {
554
+ echo "Session Handoff 格式不對:${SESSION_ERR}"
555
+ echo ""
556
+ echo "正確格式(三個標籤都要有,沒有內容就寫 - (無))。寫完後 hook 會覆寫 .devlog/handoff.md 給下一 session:"
557
+ handoff_xml_template session-handoff
558
+ } >&2
565
559
  exit 2
566
560
  fi
567
561
  fi
@@ -591,11 +585,11 @@ if [ -n "$LAST_ROUND" ]; then
591
585
  case "${STATUS_VAL:-}" in
592
586
  IN_PROGRESS|BLOCKED)
593
587
  handoff_write "$HANDOFF_FILE" "$LAST_ROUND" 2>/dev/null || \
594
- echo "警告:無法寫入 Session Handoff 檔($HANDOFF_FILE),本輪仍已收尾。" >&2
588
+ echo "警告:無法寫入 Session Handoff 檔(${HANDOFF_FILE}),本輪仍已收尾。" >&2
595
589
  ;;
596
590
  DONE)
597
591
  handoff_clear "$HANDOFF_FILE" 2>/dev/null || \
598
- echo "警告:無法清除 Session Handoff 檔($HANDOFF_FILE),本輪仍已收尾。" >&2
592
+ echo "警告:無法清除 Session Handoff 檔(${HANDOFF_FILE}),本輪仍已收尾。" >&2
599
593
  ;;
600
594
  esac
601
595
  fi
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced helper: legacy `#### ` Handoff → line-based XML. Pure file-in /
3
+ # file-out; locking, backups and target discovery live in
4
+ # migrate-handoff.sh. Converts history as-is: never validates, fills or
5
+ # drops fields; a round it cannot map exactly is left byte-for-byte.
6
+ # docs/design/handoff-xml.md「Migrate」.
7
+
8
+ _HANDOFF_CONVERT_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
9
+ # shellcheck source=handoff-fields.sh
10
+ . "$_HANDOFF_CONVERT_DIR/handoff-fields.sh"
11
+
12
+ _handoff_convert_awk() {
13
+ # Heading → tag tables are generated from handoff-fields.sh so the map
14
+ # lives in one place.
15
+ local hmap="" smap="" k
16
+ for k in $HANDOFF_KEYS; do hmap="$hmap $(handoff_key_heading "$k")=$k"; done
17
+ for k in $SESSION_HANDOFF_KEYS; do smap="$smap $(handoff_key_heading "$k")=$k"; done
18
+ awk -v hmap="$hmap" -v smap="$smap" -v report="$1" '
19
+ function load(map, dst, n, i, p, kv) {
20
+ n = split(map, p, " ")
21
+ for (i = 1; i <= n; i++) { split(p[i], kv, "="); dst[kv[1]] = kv[2]; dst["#" kv[1]] = i }
22
+ }
23
+ BEGIN { load(hmap, H); load(smap, S) }
24
+ function emit_round( i) { for (i = 1; i <= rn; i++) print rl[i] }
25
+ # Converts section lines rl[a..b] (after the ### heading) into out[];
26
+ # returns "" on success or a reason. Fills fk/fs/fe (1..nf) and on/out
27
+ # as side effects.
28
+ function conv(a, b, sess, i, t, name, key, idx, last, fences, nf, j, e, s) {
29
+ fences = 0
30
+ for (i = a; i <= b; i++) if (rl[i] ~ /^[ \t]*```/) fences++
31
+ if (fences % 2) return "fence 沒有成對"
32
+ for (i = a; i <= b; i++) if (rl[i] ~ /[^ \t]/) break
33
+ if (i > b) return "EMPTY"
34
+ t = rl[i]; sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
35
+ if (t == "<handoff>" || t == "<session-handoff>") return "XML"
36
+ nf = 0; last = 0; fence = 0; delete seen
37
+ for (i = a; i <= b; i++) {
38
+ if (rl[i] ~ /^[ \t]*```/) { fence = !fence; if (nf > 0) continue }
39
+ else if (!fence && rl[i] ~ /^#### /) {
40
+ name = rl[i]; sub(/^#### [ \t]*/, "", name); sub(/[ \t]+$/, "", name)
41
+ key = sess ? S[name] : H[name]
42
+ idx = sess ? S["#" name] : H["#" name]
43
+ if (key == "") return "不認得的小節「#### " name "」"
44
+ if (seen[key]) return "小節「#### " name "」重複"
45
+ if (idx < last) return "小節順序不對(#### " name ")"
46
+ seen[key] = 1; last = idx
47
+ nf++; fk[nf] = key; fs[nf] = i + 1; if (nf > 1) fe[nf - 1] = i - 1
48
+ continue
49
+ }
50
+ if (nf == 0 && rl[i] ~ /[^ \t]/) return "第一個 #### 小節前面有內容"
51
+ }
52
+ if (nf == 0) return "沒有 #### 小節"
53
+ fe[nf] = b
54
+ on = 0
55
+ out[++on] = sess ? "<session-handoff>" : "<handoff>"
56
+ for (j = 1; j <= nf; j++) {
57
+ e = fe[j]
58
+ while (e >= fs[j] && rl[e] !~ /[^ \t]/) e--
59
+ s = fs[j]
60
+ while (s <= e && rl[s] !~ /[^ \t]/) s++
61
+ out[++on] = "<" fk[j] ">"
62
+ for (i = s; i <= e; i++) out[++on] = rl[i]
63
+ out[++on] = "</" fk[j] ">"
64
+ }
65
+ out[++on] = sess ? "</session-handoff>" : "</handoff>"
66
+ return ""
67
+ }
68
+ # First non-blank line of rl[a..b]: "EMPTY", "XML" or "" (legacy).
69
+ function kind(a, b, i, t) {
70
+ for (i = a; i <= b; i++) if (rl[i] ~ /[^ \t]/) break
71
+ if (i > b) return "EMPTY"
72
+ t = rl[i]; sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
73
+ return (t == "<handoff>" || t == "<session-handoff>") ? "XML" : ""
74
+ }
75
+ function flush( i, t, k, sb, r, conv_any, cur, nof) {
76
+ if (rn == 0) return
77
+ if (!inround) { emit_round(); rn = 0; return }
78
+ # NOFENCE fail-open (same rule as _hf_nofence/enforce-devlog.sh): an
79
+ # odd number of ``` markers in this round means a fence was never
80
+ # closed, so stop tracking fences while locating sections — that is
81
+ # what the Stop gate sees too.
82
+ nof = 0
83
+ for (i = 1; i <= rn; i++) if (rl[i] ~ /^[ \t]*```/) nof = !nof
84
+ # locate ### Handoff / ### Session Handoff sections (fence-aware)
85
+ ns = 0; fence = 0
86
+ for (i = 1; i <= rn; i++) {
87
+ if (rl[i] ~ /^[ \t]*```/) { if (!nof) fence = !fence; continue }
88
+ if (fence) continue
89
+ if (rl[i] ~ /^### /) {
90
+ if (ns && !se[ns]) se[ns] = i - 1
91
+ t = rl[i]; sub(/[ \t]+$/, "", t)
92
+ if (t == "### Handoff" || t == "### Session Handoff") { ns++; sh[ns] = i; ss[ns] = (t == "### Session Handoff"); se[ns] = 0 }
93
+ }
94
+ }
95
+ if (ns && !se[ns]) se[ns] = rn
96
+ if (nof) {
97
+ # Cannot map a round with a broken fence exactly: leave it as-is,
98
+ # but say so when it still carries a legacy Handoff.
99
+ for (k = 1; k <= ns; k++) if (kind(sh[k] + 1, se[k]) == "") {
100
+ print "SKIP " roundno " fence 沒有成對" > report; break
101
+ }
102
+ emit_round(); rn = 0; return
103
+ }
104
+ conv_any = 0
105
+ for (k = 1; k <= ns; k++) {
106
+ # keep trailing blank lines of the section outside the tag block
107
+ sb = se[k]; while (sb > sh[k] && rl[sb] !~ /[^ \t]/) sb--
108
+ r = conv(sh[k] + 1, sb, ss[k])
109
+ if (r == "XML" || r == "EMPTY") { cstart[k] = 0; continue }
110
+ if (r != "") { print "SKIP " roundno " " r > report; emit_round(); rn = 0; delete cstart; return }
111
+ cstart[k] = sh[k]; cend[k] = sb; conv_any = 1
112
+ cn[k] = on; for (i = 1; i <= on; i++) cl[k, i] = out[i]
113
+ }
114
+ cur = 0
115
+ for (i = 1; i <= rn; i++) {
116
+ if (cur && i <= cend[cur]) {
117
+ if (i == cend[cur]) cur = 0
118
+ continue
119
+ }
120
+ for (k = 1; k <= ns; k++) {
121
+ if (cstart[k] && i == cstart[k] + 1) {
122
+ for (j = 1; j <= cn[k]; j++) print cl[k, j]
123
+ if (cend[k] > i) cur = k; else cur = 0
124
+ break
125
+ }
126
+ }
127
+ if (k <= ns && cstart[k] && i == cstart[k] + 1) continue
128
+ print rl[i]
129
+ }
130
+ if (conv_any) print "MIGRATED " roundno > report
131
+ rn = 0; delete cstart
132
+ }
133
+ { L[++nl] = $0; if ($0 ~ /^[ \t]*```/) tf++ }
134
+ END {
135
+ # Round boundaries: fence-aware, except when the whole file has an
136
+ # odd ``` count — then one unclosed fence would swallow every later
137
+ # `## ` heading, so fall back to plain scanning (NOFENCE rule). A
138
+ # round that ends up with an odd count is SKIPped by flush().
139
+ gnf = tf % 2
140
+ for (li = 1; li <= nl; li++) {
141
+ line = L[li]
142
+ if (!gnf && line ~ /^[ \t]*```/) gfence = !gfence
143
+ if (!gfence && line ~ /^## /) {
144
+ flush()
145
+ inround = (line ~ /^## Round [0-9]+/)
146
+ if (inround) { roundno = line; sub(/^## Round /, "", roundno); sub(/[^0-9].*$/, "", roundno) }
147
+ }
148
+ rl[++rn] = line
149
+ }
150
+ flush()
151
+ }
152
+ '
153
+ }
154
+
155
+ handoff_convert_file() {
156
+ local in="$1" out="$2" report="$3"
157
+ : > "$report"
158
+ _handoff_convert_awk "$report" < "$in" > "$out"
159
+ }
160
+
161
+ handoff_convert_snapshot() {
162
+ local in="$1" out="$2" first
163
+ first="$(awk 'NF { sub(/^[ \t]+/, ""); sub(/[ \t]+$/, ""); print; exit }' "$in")"
164
+ if [ "$first" = "<session-handoff>" ]; then cp "$in" "$out"; return 2; fi
165
+ [ "$first" = "## Session Handoff" ] || return 1
166
+ awk '
167
+ function tagof(n) { return n == "決策" ? "decisions" : n == "待解問題" ? "open-questions" : n == "失敗嘗試" ? "failed-attempts" : "" }
168
+ function close_field( e, s, i) {
169
+ if (cur == "") return
170
+ e = n; while (e > 0 && buf[e] !~ /[^ \t]/) e--
171
+ s = 1; while (s <= e && buf[s] !~ /[^ \t]/) s++
172
+ print "<" cur ">"; for (i = s; i <= e; i++) print buf[i]; print "</" cur ">"
173
+ cur = ""; n = 0
174
+ }
175
+ BEGIN { print "<session-handoff>"; want = "decisions open-questions failed-attempts"; split(want, w, " "); wi = 1 }
176
+ /^## Session Handoff[ \t]*$/ { next }
177
+ /^### / {
178
+ name = $0; sub(/^### [ \t]*/, "", name); sub(/[ \t]+$/, "", name)
179
+ close_field(); cur = tagof(name)
180
+ if (cur != w[wi]) { bad = 1; exit }
181
+ wi++; next
182
+ }
183
+ { if (cur == "") { if ($0 ~ /[^ \t]/) { bad = 1; exit } ; next } buf[++n] = $0 }
184
+ END { if (bad || wi != 4) exit 1; close_field(); print "</session-handoff>" }
185
+ ' "$in" > "$out.tmp" || { rm -f "$out.tmp"; return 1; }
186
+ mv "$out.tmp" "$out"
187
+ }