devlog-tracker 0.33.4 → 0.34.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.
- package/README.md +41 -10
- package/README.zh-TW.md +41 -10
- package/cli/agents-md.js +1 -0
- package/cli/agents-md.test.js +10 -0
- package/cli/platforms/claude.js +1 -0
- package/codex/hooks/on-interrupt.sh +13 -0
- package/codex/hooks/on-pre-tool.sh +69 -1
- package/codex/hooks/on-session-end.sh +5 -5
- package/codex/hooks/on-session-start.sh +5 -6
- package/codex/hooks/on-stop.sh +3 -2
- package/codex/hooks/on-subagent-start.sh +19 -0
- package/codex/hooks/project-dir.sh +16 -1
- package/codex/hooks/test-adapters.sh +127 -9
- package/codex/hooks.json +21 -0
- package/commands/continue.md +3 -3
- package/commands/keep-all.md +1 -1
- package/commands/keep.md +2 -2
- package/commands/lessons-on.md +1 -1
- package/commands/migrate.md +18 -0
- package/commands/pr.md +3 -3
- package/commands/resume.md +1 -1
- package/core/scripts/close-open-round.sh +8 -2
- package/core/scripts/devlog-md.sh +6 -11
- package/core/scripts/enforce-devlog.sh +79 -85
- package/core/scripts/handoff-convert.sh +187 -0
- package/core/scripts/handoff-fields.sh +219 -0
- package/core/scripts/handoff-file.sh +19 -88
- package/core/scripts/lessons-subagent-start.sh +1 -1
- package/core/scripts/migrate-handoff.sh +91 -0
- package/core/scripts/round-start.sh +13 -2
- package/core/scripts/segment-watch.sh +1 -1
- package/core/scripts/tests/lib/xml-fixture.sh +10 -0
- package/core/scripts/tests/test-close-open-round.sh +2 -1
- package/core/scripts/tests/test-devlog-md.sh +21 -0
- package/core/scripts/tests/test-enforce-devlog-files.sh +4 -1
- package/core/scripts/tests/test-enforce-devlog-handoff-order.sh +149 -81
- package/core/scripts/tests/test-enforce-devlog-session-handoff.sh +92 -11
- package/core/scripts/tests/test-enforce-devlog-workspace.sh +42 -26
- package/core/scripts/tests/test-enforce-devlog.sh +70 -0
- package/core/scripts/tests/test-handoff-fields.sh +171 -0
- package/core/scripts/tests/test-handoff-file.sh +67 -45
- package/core/scripts/tests/test-migrate-handoff.sh +278 -0
- package/core/scripts/tests/test-round-start.sh +54 -1
- package/core/scripts/tests/test-session-start-devlog.sh +45 -0
- package/core/scripts/tests/test-workspace-snapshot.sh +32 -0
- package/core/scripts/timeline-render.js +26 -2
- package/core/scripts/timeline-render.test.js +23 -1
- package/core/scripts/workspace-snapshot.sh +15 -2
- package/package.json +3 -3
- package/skills/devlog-tracker/SKILL.md +81 -56
- package/skills/devlog-tracker/references/contract.md +4 -3
- package/skills/devlog-tracker/references/lessons-mode.md +7 -7
- 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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
238
|
-
#
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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="$(
|
|
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 "
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
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
|
|
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):
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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="$(
|
|
355
|
+
ACTUAL_WS="$(hf workspace | sed -e '/^[[:space:]]*$/d')"
|
|
385
356
|
if [ "$ACTUAL_WS" != "$EXPECTED_WS" ]; then
|
|
386
|
-
echo "
|
|
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):
|
|
396
|
-
# round's Handoff has a non-empty
|
|
397
|
-
# trivial round with no
|
|
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
|
|
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="$(
|
|
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
|
|
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
|
|
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/
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
+
}
|