devlog-tracker 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -6
- package/README.zh-TW.md +22 -6
- package/bin/devlog-tracker.js +14 -1
- package/cli/agents-md.js +3 -0
- package/cli/agents-md.test.js +32 -0
- package/cli/core-script.js +25 -0
- package/cli/core-script.test.js +62 -0
- package/cli/platforms/claude.js +3 -0
- package/commands/overview.md +1 -1
- package/commands/pr.md +61 -0
- package/commands/promote.md +54 -0
- package/commands/report.md +23 -0
- package/commands/timeline.md +18 -0
- package/core/scripts/json-field.sh +16 -0
- package/core/scripts/lessons-subagent-start.sh +2 -10
- package/core/scripts/pr-context.sh +64 -0
- package/core/scripts/promote-sources.sh +40 -0
- package/core/scripts/promote-target.sh +37 -0
- package/core/scripts/promote-write.sh +74 -0
- package/core/scripts/report-devlog.sh +140 -0
- package/core/scripts/report-scan.awk +74 -0
- package/core/scripts/round-start.sh +1 -1
- package/core/scripts/tests/test-json-field.sh +5 -0
- package/core/scripts/tests/test-pr-context.sh +107 -0
- package/core/scripts/tests/test-promote.sh +158 -0
- package/core/scripts/tests/test-report.sh +197 -0
- package/core/scripts/tests/test-round-start.sh +16 -0
- package/core/scripts/tests/test-timeline.sh +65 -0
- package/core/scripts/timeline-devlog.sh +41 -0
- package/core/scripts/timeline-render.js +247 -0
- package/core/scripts/timeline-render.test.js +101 -0
- package/package.json +2 -2
- package/skills/devlog-tracker/SKILL.md +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sources for /devlog-tracker:promote (docs/design/read-side-and-promote.md D):
|
|
3
|
+
# kept files, lessons files, and Checkpoint headings in the current branch's
|
|
4
|
+
# devlog. Plain read only. compact never moves Checkpoints, so the archive has
|
|
5
|
+
# none to list.
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
|
|
8
|
+
_src="${BASH_SOURCE[0]}"
|
|
9
|
+
SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
|
|
10
|
+
# shellcheck source=devlog-md.sh
|
|
11
|
+
. "$SCRIPT_DIR/devlog-md.sh"
|
|
12
|
+
# shellcheck source=devlog-path.sh
|
|
13
|
+
. "$SCRIPT_DIR/devlog-path.sh"
|
|
14
|
+
|
|
15
|
+
PROJECT_DIR="$(cd "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}" 2>/dev/null && pwd)" || { echo "NO_SOURCES"; exit 0; }
|
|
16
|
+
devlog_resolve_paths "$PROJECT_DIR"
|
|
17
|
+
MAIN="$DEVLOG_FILE"
|
|
18
|
+
[ -f "$MAIN" ] || { echo "NO_SOURCES"; exit 0; }
|
|
19
|
+
|
|
20
|
+
OUT=""
|
|
21
|
+
add() { OUT="$OUT$1"$'\n'; }
|
|
22
|
+
exists_flag() { if [ -f "$1" ]; then printf '1'; else printf '0'; fi; }
|
|
23
|
+
|
|
24
|
+
while IFS= read -r name; do
|
|
25
|
+
[ -n "$name" ] || continue
|
|
26
|
+
f="$DEVLOG_DIR/devlog.$name.md"
|
|
27
|
+
add "FILE=$f KIND=kept EXISTS=$(exists_flag "$f")"
|
|
28
|
+
done < <(devlog_kept_index_lines "$MAIN" | sed -nE 's/.*`devlog\.([^`]+)\.md`.*/\1/p')
|
|
29
|
+
|
|
30
|
+
while IFS= read -r topic; do
|
|
31
|
+
[ -n "$topic" ] || continue
|
|
32
|
+
f="$DEVLOG_DIR/devlog.lessons.$topic.md"
|
|
33
|
+
add "FILE=$f KIND=lessons EXISTS=$(exists_flag "$f")"
|
|
34
|
+
done < <(devlog_lessons_index_lines "$MAIN" | sed -nE 's/.*`devlog\.lessons\.([^`]+)\.md`.*/\1/p')
|
|
35
|
+
|
|
36
|
+
while IFS= read -r ln; do
|
|
37
|
+
[ -n "$ln" ] && add "CHECKPOINT=$MAIN:$ln"
|
|
38
|
+
done < <(awk '/^[ \t]*```/ { fence = !fence; next } !fence && /^## Checkpoint/ { print NR }' "$MAIN")
|
|
39
|
+
|
|
40
|
+
if [ -z "$OUT" ]; then echo "NO_SOURCES"; else printf '%s' "$OUT"; fi
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Picks the file /devlog-tracker:promote writes rules into
|
|
3
|
+
# (docs/design/read-side-and-promote.md D). Plain read only.
|
|
4
|
+
# 1. Codex-only project (no CLAUDE.md, Codex hooks or devlog skills present) -> AGENTS.md
|
|
5
|
+
# 2. CLAUDE.md that is nothing but "@AGENTS.md" -> AGENTS.md
|
|
6
|
+
# 3. otherwise -> CLAUDE.md
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
|
|
9
|
+
PROJECT_DIR="$(cd "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}" 2>/dev/null && pwd)" || exit 1
|
|
10
|
+
CLAUDE_MD="$PROJECT_DIR/CLAUDE.md"
|
|
11
|
+
AGENTS_MD="$PROJECT_DIR/AGENTS.md"
|
|
12
|
+
BEGIN_MARK='<!-- devlog-tracker:rules:begin -->'
|
|
13
|
+
END_MARK='<!-- devlog-tracker:rules:end -->'
|
|
14
|
+
|
|
15
|
+
HAS_CODEX=0
|
|
16
|
+
[ -f "$PROJECT_DIR/.codex/hooks.json" ] && HAS_CODEX=1
|
|
17
|
+
for d in "$PROJECT_DIR"/.agents/skills/devlog-*; do
|
|
18
|
+
[ -d "$d" ] && HAS_CODEX=1
|
|
19
|
+
done
|
|
20
|
+
|
|
21
|
+
TARGET="$CLAUDE_MD"
|
|
22
|
+
if [ ! -f "$CLAUDE_MD" ] && [ "$HAS_CODEX" -eq 1 ]; then
|
|
23
|
+
TARGET="$AGENTS_MD"
|
|
24
|
+
elif [ -f "$CLAUDE_MD" ] && [ "$(tr -d '[:space:]' < "$CLAUDE_MD")" = "@AGENTS.md" ]; then
|
|
25
|
+
TARGET="$AGENTS_MD"
|
|
26
|
+
fi
|
|
27
|
+
echo "TARGET=$TARGET"
|
|
28
|
+
|
|
29
|
+
if [ -f "$TARGET" ] && grep -qxF "$BEGIN_MARK" "$TARGET"; then
|
|
30
|
+
n="$(awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
|
|
31
|
+
$0 == b { g = 1; next }
|
|
32
|
+
$0 == e { g = 0 }
|
|
33
|
+
g && /^- / { c++ }
|
|
34
|
+
END { print c + 0 }
|
|
35
|
+
' "$TARGET")"
|
|
36
|
+
echo "EXISTING=$n"
|
|
37
|
+
fi
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Writes rules chosen in /devlog-tracker:promote into the managed block
|
|
3
|
+
# <!-- devlog-tracker:rules:begin/end --> of <target>
|
|
4
|
+
# (docs/design/read-side-and-promote.md D). Append-only: never edits or
|
|
5
|
+
# removes a rule already in the block, never touches anything outside it.
|
|
6
|
+
# Exact-line duplicates (against the block, and within the input) are skipped.
|
|
7
|
+
# Usage: promote-write.sh <target> <rules-file>
|
|
8
|
+
set -uo pipefail
|
|
9
|
+
|
|
10
|
+
_src="${BASH_SOURCE[0]}"
|
|
11
|
+
SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
|
|
12
|
+
# shellcheck source=devlog-path.sh
|
|
13
|
+
. "$SCRIPT_DIR/devlog-path.sh"
|
|
14
|
+
|
|
15
|
+
TARGET="${1:-}"
|
|
16
|
+
RULES="${2:-}"
|
|
17
|
+
if [ -z "$TARGET" ] || [ ! -f "$RULES" ]; then
|
|
18
|
+
echo "USAGE: promote-write.sh <target> <rules-file>" >&2
|
|
19
|
+
exit 2
|
|
20
|
+
fi
|
|
21
|
+
BEGIN_MARK='<!-- devlog-tracker:rules:begin -->'
|
|
22
|
+
END_MARK='<!-- devlog-tracker:rules:end -->'
|
|
23
|
+
|
|
24
|
+
devlog_resolve_paths "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
|
|
25
|
+
NEW="$(mktemp "${TMPDIR:-/tmp}/devlog-promote.XXXXXX")" || exit 1
|
|
26
|
+
EXISTING="$(mktemp "${TMPDIR:-/tmp}/devlog-promote.XXXXXX")" || exit 1
|
|
27
|
+
TO_ADD="$(mktemp "${TMPDIR:-/tmp}/devlog-promote.XXXXXX")" || exit 1
|
|
28
|
+
trap 'rm -f "$NEW" "$EXISTING" "$TO_ADD" "$TARGET.tmp"; devlog_lock_release' EXIT
|
|
29
|
+
[ -d "$DEVLOG_DIR" ] && devlog_lock_acquire
|
|
30
|
+
|
|
31
|
+
# Normalise: trim, drop blank lines, ensure a "- " list prefix.
|
|
32
|
+
awk '
|
|
33
|
+
{ sub(/^[ \t]+/, ""); sub(/[ \t]+$/, "") }
|
|
34
|
+
$0 != "" { if ($0 !~ /^- /) $0 = "- " $0; print }
|
|
35
|
+
' "$RULES" > "$NEW"
|
|
36
|
+
|
|
37
|
+
if [ -f "$TARGET" ]; then
|
|
38
|
+
awk -v b="$BEGIN_MARK" -v e="$END_MARK" '$0 == b { g = 1; next } $0 == e { g = 0 } g' "$TARGET" > "$EXISTING"
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# FILENAME, not NR==FNR: an empty $EXISTING would make NR==FNR true for $NEW too.
|
|
42
|
+
awk -v ex="$EXISTING" 'FILENAME == ex { seen[$0] = 1; next } !seen[$0]++' "$EXISTING" "$NEW" > "$TO_ADD"
|
|
43
|
+
|
|
44
|
+
TOTAL="$(awk 'END { print NR }' "$NEW")"
|
|
45
|
+
ADDED="$(awk 'END { print NR }' "$TO_ADD")"
|
|
46
|
+
SKIPPED=$((TOTAL - ADDED))
|
|
47
|
+
|
|
48
|
+
if [ "$ADDED" -gt 0 ]; then
|
|
49
|
+
if [ -f "$TARGET" ] && grep -qxF "$BEGIN_MARK" "$TARGET" && grep -qxF "$END_MARK" "$TARGET"; then
|
|
50
|
+
awk -v e="$END_MARK" -v add="$TO_ADD" '
|
|
51
|
+
$0 == e && !done { while ((getline l < add) > 0) print l; done = 1 }
|
|
52
|
+
{ print }
|
|
53
|
+
' "$TARGET" > "$TARGET.tmp" || exit 1
|
|
54
|
+
else
|
|
55
|
+
{
|
|
56
|
+
if [ -s "$TARGET" ]; then
|
|
57
|
+
cat "$TARGET"
|
|
58
|
+
[ -n "$(tail -c 1 "$TARGET")" ] && printf '\n'
|
|
59
|
+
printf '\n'
|
|
60
|
+
fi
|
|
61
|
+
printf '%s\n## devlog-tracker 沉澱的規範\n\n' "$BEGIN_MARK"
|
|
62
|
+
cat "$TO_ADD"
|
|
63
|
+
printf '%s\n' "$END_MARK"
|
|
64
|
+
} > "$TARGET.tmp" || exit 1
|
|
65
|
+
fi
|
|
66
|
+
# Write back in place (not mv): TARGET may be a symlink (e.g. CLAUDE.md ->
|
|
67
|
+
# AGENTS.md) or have a non-default mode; mv would replace it with a plain
|
|
68
|
+
# 644 file and break the link. $TARGET.tmp is fully built above already,
|
|
69
|
+
# so this is the only step that touches TARGET.
|
|
70
|
+
cat "$TARGET.tmp" > "$TARGET" || exit 1
|
|
71
|
+
rm -f "$TARGET.tmp"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
echo "ADDED=$ADDED SKIPPED_DUP=$SKIPPED TARGET=$TARGET"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Stats + structured Round data for /devlog-tracker:report, `npx devlog-tracker
|
|
3
|
+
# report`, and timeline-devlog.sh (docs/design/read-side-and-promote.md B).
|
|
4
|
+
# Plain read only: never edits a devlog file. devlog_resolve_paths may still
|
|
5
|
+
# do its documented first-resolve rename, same as every other reader.
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
|
|
8
|
+
_src="${BASH_SOURCE[0]}"
|
|
9
|
+
SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
|
|
10
|
+
# shellcheck source=json-field.sh
|
|
11
|
+
. "$SCRIPT_DIR/json-field.sh"
|
|
12
|
+
# shellcheck source=devlog-md.sh
|
|
13
|
+
. "$SCRIPT_DIR/devlog-md.sh"
|
|
14
|
+
# shellcheck source=devlog-path.sh
|
|
15
|
+
. "$SCRIPT_DIR/devlog-path.sh"
|
|
16
|
+
|
|
17
|
+
JSON=0
|
|
18
|
+
ROUNDS=0
|
|
19
|
+
ALL=0
|
|
20
|
+
WITH_INPUT=0
|
|
21
|
+
for arg in "$@"; do
|
|
22
|
+
case "$arg" in
|
|
23
|
+
--json) JSON=1 ;;
|
|
24
|
+
--rounds) ROUNDS=1 ;;
|
|
25
|
+
--all-branches) ALL=1 ;;
|
|
26
|
+
--with-input) WITH_INPUT=1 ;;
|
|
27
|
+
*) echo "UNKNOWN_ARG=$arg" >&2; exit 2 ;;
|
|
28
|
+
esac
|
|
29
|
+
done
|
|
30
|
+
|
|
31
|
+
devlog_resolve_paths "${DEVLOG_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-.}}"
|
|
32
|
+
if [ ! -d "$DEVLOG_DIR" ]; then
|
|
33
|
+
if [ "$JSON" -eq 1 ]; then echo '{"started":false}'; else echo "NOT_STARTED"; fi
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# devlog.md -> main, devlog.archive.md -> archive, devlog.<x>.md -> <x>.
|
|
38
|
+
branch_of_file() {
|
|
39
|
+
local leaf="${1##*/}"
|
|
40
|
+
case "$leaf" in
|
|
41
|
+
devlog.md) printf 'main' ;;
|
|
42
|
+
devlog.archive.md) printf 'archive' ;;
|
|
43
|
+
*) leaf="${leaf#devlog.}"; printf '%s' "${leaf%.md}" ;;
|
|
44
|
+
esac
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Archive first (oldest), then the branch file(s).
|
|
48
|
+
FILES=()
|
|
49
|
+
[ -f "$DEVLOG_DIR/devlog.archive.md" ] && FILES+=("$DEVLOG_DIR/devlog.archive.md")
|
|
50
|
+
if [ "$ALL" -eq 1 ]; then
|
|
51
|
+
shopt -s nullglob
|
|
52
|
+
KEPT_NAMES=" "
|
|
53
|
+
for f in "$DEVLOG_DIR"/devlog*.md; do
|
|
54
|
+
while IFS= read -r name; do
|
|
55
|
+
[ -n "$name" ] && KEPT_NAMES="$KEPT_NAMES$name "
|
|
56
|
+
done < <(devlog_kept_index_lines "$f" | sed -nE 's/.*`devlog\.([^`]+)\.md`.*/\1/p')
|
|
57
|
+
done
|
|
58
|
+
for f in "$DEVLOG_DIR"/devlog*.md; do
|
|
59
|
+
case "${f##*/}" in devlog.archive.md|devlog.lessons.*.md) continue ;; esac
|
|
60
|
+
case "$KEPT_NAMES" in *" $(branch_of_file "$f") "*) continue ;; esac
|
|
61
|
+
FILES+=("$f")
|
|
62
|
+
done
|
|
63
|
+
shopt -u nullglob
|
|
64
|
+
else
|
|
65
|
+
[ -f "$DEVLOG_FILE" ] && FILES+=("$DEVLOG_FILE")
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
SCAN="$(mktemp "${TMPDIR:-/tmp}/devlog-report.XXXXXX")" || exit 1
|
|
69
|
+
trap 'rm -f "$SCAN"' EXIT
|
|
70
|
+
for f in ${FILES[@]+"${FILES[@]}"}; do
|
|
71
|
+
awk -v branch="$(branch_of_file "$f")" -v label="${f##*/}" -v with_input="$WITH_INPUT" \
|
|
72
|
+
-f "$SCRIPT_DIR/report-scan.awk" "$f" >> "$SCAN"
|
|
73
|
+
done
|
|
74
|
+
|
|
75
|
+
count_status() { awk -F'\t' -v s="$1" '$1 == "R" && $2 == s { c++ } END { print c + 0 }' "$SCAN"; }
|
|
76
|
+
BRANCH="$(branch_of_file "$DEVLOG_FILE")"
|
|
77
|
+
ROUNDS_TOTAL="$(awk -F'\t' '$1 == "R" { c++ } END { print c + 0 }' "$SCAN")"
|
|
78
|
+
ROUNDS_ARCHIVE="$(awk -F'\t' '$1 == "R" && $4 == "archive" { c++ } END { print c + 0 }' "$SCAN")"
|
|
79
|
+
ROUNDS_MAIN=$((ROUNDS_TOTAL - ROUNDS_ARCHIVE))
|
|
80
|
+
STATUS_DONE="$(count_status DONE)"
|
|
81
|
+
STATUS_IN_PROGRESS="$(count_status IN_PROGRESS)"
|
|
82
|
+
STATUS_BLOCKED="$(count_status BLOCKED)"
|
|
83
|
+
STATUS_INTERRUPTED="$(count_status INTERRUPTED)"
|
|
84
|
+
BLOCKED_RATIO=0
|
|
85
|
+
[ "$ROUNDS_TOTAL" -gt 0 ] && BLOCKED_RATIO=$((STATUS_BLOCKED * 100 / ROUNDS_TOTAL))
|
|
86
|
+
CHECKPOINTS="$(awk -F'\t' '$1 == "C" { c++ } END { print c + 0 }' "$SCAN")"
|
|
87
|
+
KEPT_TOPICS=0
|
|
88
|
+
if [ -f "$DEVLOG_FILE" ]; then
|
|
89
|
+
KEPT_TOPICS="$(devlog_kept_index_lines "$DEVLOG_FILE" | grep -c '^- `devlog\.' || true)"
|
|
90
|
+
fi
|
|
91
|
+
LESSONS_TOPICS=0
|
|
92
|
+
for f in "$DEVLOG_DIR"/devlog.lessons.*.md; do
|
|
93
|
+
[ -f "$f" ] && LESSONS_TOPICS=$((LESSONS_TOPICS + 1))
|
|
94
|
+
done
|
|
95
|
+
ADVISORY=""
|
|
96
|
+
if [ -f "$DEVLOG_DIR/.lessons-advisory-state" ]; then
|
|
97
|
+
a_count="$(json_int_get "$DEVLOG_DIR/.lessons-advisory-state" count)"
|
|
98
|
+
a_max="$(json_int_get "$DEVLOG_DIR/.lessons-advisory-state" threshold)"
|
|
99
|
+
ADVISORY="${a_count:-0}/${a_max:-3}"
|
|
100
|
+
fi
|
|
101
|
+
FIRST_ROUND_AT="$(awk -F'\t' '$1 == "R" && $3 != "" { print $3 }' "$SCAN" | sort | head -1)"
|
|
102
|
+
LAST_ROUND_AT="$(awk -F'\t' '$1 == "R" && $3 != "" { print $3 }' "$SCAN" | sort | tail -1)"
|
|
103
|
+
[ -n "$FIRST_ROUND_AT" ] || FIRST_ROUND_AT=none
|
|
104
|
+
[ -n "$LAST_ROUND_AT" ] || LAST_ROUND_AT=none
|
|
105
|
+
|
|
106
|
+
if [ "$JSON" -eq 0 ]; then
|
|
107
|
+
printf 'BRANCH=%s\n' "$BRANCH"
|
|
108
|
+
printf 'ROUNDS_TOTAL=%s\nROUNDS_MAIN=%s\nROUNDS_ARCHIVE=%s\n' "$ROUNDS_TOTAL" "$ROUNDS_MAIN" "$ROUNDS_ARCHIVE"
|
|
109
|
+
printf 'STATUS_DONE=%s\nSTATUS_IN_PROGRESS=%s\nSTATUS_BLOCKED=%s\nSTATUS_INTERRUPTED=%s\n' \
|
|
110
|
+
"$STATUS_DONE" "$STATUS_IN_PROGRESS" "$STATUS_BLOCKED" "$STATUS_INTERRUPTED"
|
|
111
|
+
printf 'BLOCKED_RATIO=%s\nCHECKPOINTS=%s\nKEPT_TOPICS=%s\nLESSONS_TOPICS=%s\n' \
|
|
112
|
+
"$BLOCKED_RATIO" "$CHECKPOINTS" "$KEPT_TOPICS" "$LESSONS_TOPICS"
|
|
113
|
+
[ -n "$ADVISORY" ] && printf 'LESSONS_ADVISORY=%s\n' "$ADVISORY"
|
|
114
|
+
printf 'FIRST_ROUND_AT=%s\nLAST_ROUND_AT=%s\n' "$FIRST_ROUND_AT" "$LAST_ROUND_AT"
|
|
115
|
+
exit 0
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
jstr() {
|
|
119
|
+
if [ -z "$1" ] || [ "$1" = none ]; then printf 'null'
|
|
120
|
+
else printf '"%s"' "$(json_escape "$1")"; fi
|
|
121
|
+
}
|
|
122
|
+
{
|
|
123
|
+
printf '{"started":true,"branch":"%s"' "$(json_escape "$BRANCH")"
|
|
124
|
+
printf ',"rounds_total":%s,"rounds_main":%s,"rounds_archive":%s' "$ROUNDS_TOTAL" "$ROUNDS_MAIN" "$ROUNDS_ARCHIVE"
|
|
125
|
+
printf ',"status_done":%s,"status_in_progress":%s,"status_blocked":%s,"status_interrupted":%s' \
|
|
126
|
+
"$STATUS_DONE" "$STATUS_IN_PROGRESS" "$STATUS_BLOCKED" "$STATUS_INTERRUPTED"
|
|
127
|
+
printf ',"blocked_ratio":%s,"checkpoints":%s,"kept_topics":%s,"lessons_topics":%s' \
|
|
128
|
+
"$BLOCKED_RATIO" "$CHECKPOINTS" "$KEPT_TOPICS" "$LESSONS_TOPICS"
|
|
129
|
+
printf ',"lessons_advisory":%s,"first_round_at":%s,"last_round_at":%s' \
|
|
130
|
+
"$(jstr "$ADVISORY")" "$(jstr "$FIRST_ROUND_AT")" "$(jstr "$LAST_ROUND_AT")"
|
|
131
|
+
if [ "$ROUNDS" -eq 1 ]; then
|
|
132
|
+
printf ',"rounds":['
|
|
133
|
+
awk -F'\t' '$1 == "R" { printf "%s%s", (n++ ? "," : ""), $5 }' "$SCAN"
|
|
134
|
+
printf '],"checkpoint_blocks":['
|
|
135
|
+
awk -F'\t' '$1 == "C" { printf "%s%s", (n++ ? "," : ""), $3 }' "$SCAN"
|
|
136
|
+
printf ']'
|
|
137
|
+
fi
|
|
138
|
+
printf '}\n'
|
|
139
|
+
}
|
|
140
|
+
exit 0
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# One pass over one devlog file for report-devlog.sh
|
|
2
|
+
# (docs/design/read-side-and-promote.md B). POSIX awk only (mawk / BSD awk).
|
|
3
|
+
# Vars: branch, label (file leaf name), with_input (0|1).
|
|
4
|
+
# Emits one line per block:
|
|
5
|
+
# R<TAB>status<TAB>at<TAB>branch<TAB>round-json
|
|
6
|
+
# C<TAB>branch<TAB>checkpoint-json
|
|
7
|
+
# Headings inside ``` fences are content, not structure (same rule as
|
|
8
|
+
# devlog-md.sh).
|
|
9
|
+
BEGIN {
|
|
10
|
+
# Build the 0x01-0x1F control bytes once; \t \r \n get their short escapes
|
|
11
|
+
# below, the rest (e.g. pasted ANSI \033) fall back to \u00XX.
|
|
12
|
+
for (ci = 1; ci <= 31; ci++) { cchar[ci] = sprintf("%c", ci); cesc[ci] = sprintf("\\u%04x", ci) }
|
|
13
|
+
}
|
|
14
|
+
function esc(s, ci) {
|
|
15
|
+
gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
|
|
16
|
+
gsub(/\t/, "\\t", s); gsub(/\r/, "\\r", s); gsub(/\n/, "\\n", s)
|
|
17
|
+
for (ci = 1; ci <= 31; ci++) if (index(s, cchar[ci]) > 0) gsub(cchar[ci], cesc[ci], s)
|
|
18
|
+
return s
|
|
19
|
+
}
|
|
20
|
+
function trim_nl(s) { sub(/^\n+/, "", s); sub(/\n+$/, "", s); return s }
|
|
21
|
+
function flush_section() {
|
|
22
|
+
if (sec == "") return
|
|
23
|
+
if (sec == "segment") segs[nseg++] = trim_nl(buf)
|
|
24
|
+
else part[sec] = trim_nl(buf)
|
|
25
|
+
sec = ""; buf = ""
|
|
26
|
+
}
|
|
27
|
+
function flush_block( i, st, js) {
|
|
28
|
+
flush_section()
|
|
29
|
+
if (kind == "round") {
|
|
30
|
+
st = part["status"]; sub(/\n.*/, "", st); gsub(/[ \t`*]/, "", st)
|
|
31
|
+
if (st !~ /^(DONE|IN_PROGRESS|BLOCKED|INTERRUPTED)$/) st = ""
|
|
32
|
+
js = "{\"branch\":\"" esc(branch) "\",\"file\":\"" esc(label) "\",\"line\":" rline ",\"n\":" rn
|
|
33
|
+
js = js ",\"at\":\"" esc(at) "\",\"status\":\"" st "\""
|
|
34
|
+
js = js ",\"summary\":\"" esc(part["summary"]) "\",\"reply\":\"" esc(part["reply"]) "\""
|
|
35
|
+
js = js ",\"handoff\":\"" esc(part["handoff"]) "\",\"segments\":["
|
|
36
|
+
for (i = 0; i < nseg; i++) js = js (i ? "," : "") "\"" esc(segs[i]) "\""
|
|
37
|
+
js = js "]"
|
|
38
|
+
if (with_input) js = js ",\"input\":\"" esc(part["input"]) "\""
|
|
39
|
+
js = js "}"
|
|
40
|
+
printf "R\t%s\t%s\t%s\t%s\n", st, at, branch, js
|
|
41
|
+
} else if (kind == "checkpoint") {
|
|
42
|
+
printf "C\t%s\t{\"branch\":\"%s\",\"file\":\"%s\",\"line\":%d,\"heading\":\"%s\",\"body\":\"%s\"}\n", \
|
|
43
|
+
branch, esc(branch), esc(label), cline, esc(cheading), esc(trim_nl(buf_cp))
|
|
44
|
+
}
|
|
45
|
+
kind = ""; split("", part); split("", segs); nseg = 0; buf_cp = ""
|
|
46
|
+
}
|
|
47
|
+
/^[ \t]*```/ { fence = !fence }
|
|
48
|
+
!fence && /^## / {
|
|
49
|
+
flush_block()
|
|
50
|
+
if ($0 ~ /^## Round [0-9]+/) {
|
|
51
|
+
kind = "round"; rline = NR
|
|
52
|
+
rn = $0; sub(/^## Round /, "", rn); sub(/[^0-9].*$/, "", rn)
|
|
53
|
+
at = ""
|
|
54
|
+
if (index($0, "— ")) at = substr($0, index($0, "— ") + length("— "))
|
|
55
|
+
} else if ($0 ~ /^## Checkpoint/) {
|
|
56
|
+
kind = "checkpoint"; cline = NR; cheading = substr($0, 4)
|
|
57
|
+
}
|
|
58
|
+
next
|
|
59
|
+
}
|
|
60
|
+
kind == "checkpoint" { buf_cp = buf_cp $0 "\n"; next }
|
|
61
|
+
kind == "round" && !fence && /^### / {
|
|
62
|
+
flush_section()
|
|
63
|
+
h = substr($0, 5)
|
|
64
|
+
if (h ~ /^User Input[ \t]*$/) sec = "input"
|
|
65
|
+
else if (h ~ /^Summary[ \t]*$/) sec = "summary"
|
|
66
|
+
else if (h ~ /^Reply[ \t]*$/) sec = "reply"
|
|
67
|
+
else if (h ~ /^Handoff[ \t]*$/) sec = "handoff"
|
|
68
|
+
else if (h ~ /^Status[ \t]*$/) sec = "status"
|
|
69
|
+
else if (h ~ /^段落 /) { sec = "segment"; buf = h "\n"; next }
|
|
70
|
+
else sec = "other"
|
|
71
|
+
next
|
|
72
|
+
}
|
|
73
|
+
kind == "round" && sec != "" { buf = buf $0 "\n" }
|
|
74
|
+
END { flush_block() }
|
|
@@ -95,7 +95,7 @@ case "$PROMPT" in
|
|
|
95
95
|
;;
|
|
96
96
|
esac
|
|
97
97
|
case "$CMD_NAME" in
|
|
98
|
-
devlog-tracker:checkpoint|devlog-tracker:clean|devlog-tracker:compact|devlog-tracker:keep|devlog-tracker:lessons|devlog-tracker:lessons-drift|devlog-tracker:lessons-off|devlog-tracker:lessons-on|devlog-tracker:overview|devlog-tracker:pause|devlog-tracker:search|devlog-tracker:segment-watch|devlog-tracker:span|devlog-tracker:start|devlog-tracker:status)
|
|
98
|
+
devlog-tracker:checkpoint|devlog-tracker:clean|devlog-tracker:compact|devlog-tracker:keep|devlog-tracker:lessons|devlog-tracker:lessons-drift|devlog-tracker:lessons-off|devlog-tracker:lessons-on|devlog-tracker:overview|devlog-tracker:pause|devlog-tracker:report|devlog-tracker:search|devlog-tracker:segment-watch|devlog-tracker:span|devlog-tracker:start|devlog-tracker:status|devlog-tracker:timeline)
|
|
99
99
|
exit 0
|
|
100
100
|
;;
|
|
101
101
|
esac
|
|
@@ -70,5 +70,10 @@ assert_eq "slugify basic" "foo-bar" "$(slugify 'foo bar')"
|
|
|
70
70
|
assert_eq "slugify collapses runs" "foo-bar" "$(slugify 'foo bar')"
|
|
71
71
|
assert_eq "slugify trims edges" "foo" "$(slugify ' foo ')"
|
|
72
72
|
|
|
73
|
+
assert_eq "json_escape quotes and backslash" 'a \"q\" \\ b' "$(json_escape 'a "q" \ b')"
|
|
74
|
+
assert_eq "json_escape tab" 'x\ty' "$(json_escape "$(printf 'x\ty')")"
|
|
75
|
+
assert_eq "json_escape newline" 'l1\nl2' "$(json_escape "$(printf 'l1\nl2')")"
|
|
76
|
+
assert_eq "json_escape CJK passthrough" '中文' "$(json_escape '中文')"
|
|
77
|
+
|
|
73
78
|
if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
|
|
74
79
|
else echo "Some checks FAILED."; exit 1; fi
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Self-check for pr-context.sh (docs/design/read-side-and-promote.md A).
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
TMP_ROOT="$(mktemp -d)"
|
|
6
|
+
trap 'rm -rf "$TMP_ROOT"' EXIT
|
|
7
|
+
FAIL=0
|
|
8
|
+
assert_line() {
|
|
9
|
+
local desc="$1" expected="$2" out="$3"
|
|
10
|
+
if printf '%s\n' "$out" | grep -qxF "$expected"; then echo "PASS: $desc"
|
|
11
|
+
else echo "FAIL: $desc (missing [$expected] in: $out)"; FAIL=1; fi
|
|
12
|
+
}
|
|
13
|
+
assert_eq() {
|
|
14
|
+
local desc="$1" expected="$2" actual="$3"
|
|
15
|
+
if [ "$expected" = "$actual" ]; then echo "PASS: $desc"
|
|
16
|
+
else echo "FAIL: $desc (expected [$expected] got [$actual])"; FAIL=1; fi
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# gh stubs: one that is not logged in, one that is and has PR #42.
|
|
20
|
+
GH_OFF="$TMP_ROOT/gh-off"; GH_ON="$TMP_ROOT/gh-on"
|
|
21
|
+
mkdir -p "$GH_OFF" "$GH_ON"
|
|
22
|
+
printf '#!/usr/bin/env bash\nexit 1\n' > "$GH_OFF/gh"
|
|
23
|
+
cat > "$GH_ON/gh" <<'EOF'
|
|
24
|
+
#!/usr/bin/env bash
|
|
25
|
+
case "$1 $2" in
|
|
26
|
+
"auth status") exit 0 ;;
|
|
27
|
+
"pr view") echo 42 ;;
|
|
28
|
+
*) echo "unexpected gh call: $*" >&2; exit 9 ;;
|
|
29
|
+
esac
|
|
30
|
+
EOF
|
|
31
|
+
chmod +x "$GH_OFF/gh" "$GH_ON/gh"
|
|
32
|
+
ctx() { PATH="$1:$PATH" CLAUDE_PROJECT_DIR="$2" bash "$SCRIPT_DIR/pr-context.sh"; }
|
|
33
|
+
|
|
34
|
+
# --- Not a git repo -------------------------------------------------------------
|
|
35
|
+
PLAIN="$TMP_ROOT/plain"; mkdir -p "$PLAIN"
|
|
36
|
+
assert_eq "not a repo" "NOT_A_REPO" "$(ctx "$GH_OFF" "$PLAIN")"
|
|
37
|
+
|
|
38
|
+
# --- Repo fixture ------------------------------------------------------------------
|
|
39
|
+
REPO="$TMP_ROOT/repo"; mkdir -p "$REPO"
|
|
40
|
+
gitc() { git -C "$REPO" -c user.email=t@example.com -c user.name=t -c commit.gpgsign=false "$@"; }
|
|
41
|
+
gitc init -q -b main
|
|
42
|
+
echo a > "$REPO/a.txt"; gitc add a.txt; gitc commit -qm init
|
|
43
|
+
|
|
44
|
+
assert_eq "on main" "ON_DEFAULT_BRANCH" "$(ctx "$GH_OFF" "$REPO")"
|
|
45
|
+
gitc checkout -q -b MASTER
|
|
46
|
+
assert_eq "on MASTER (case-insensitive)" "ON_DEFAULT_BRANCH" "$(ctx "$GH_OFF" "$REPO")"
|
|
47
|
+
gitc checkout -q main
|
|
48
|
+
gitc checkout -q --detach
|
|
49
|
+
assert_eq "detached" "DETACHED_HEAD" "$(ctx "$GH_OFF" "$REPO")"
|
|
50
|
+
gitc checkout -q main
|
|
51
|
+
|
|
52
|
+
# --- Feature branch, no devlog, gh logged out --------------------------------------
|
|
53
|
+
gitc checkout -q -b feat/x
|
|
54
|
+
echo b > "$REPO/b.txt"; gitc add b.txt; gitc commit -qm "feat: b"
|
|
55
|
+
OUT="$(ctx "$GH_OFF" "$REPO")"
|
|
56
|
+
assert_line "branch" "BRANCH=feat/x" "$OUT"
|
|
57
|
+
assert_line "base from local main" "BASE=main" "$OUT"
|
|
58
|
+
assert_line "base ref" "BASE_REF=main" "$OUT"
|
|
59
|
+
assert_line "devlog file is branch-scoped + absolute" "DEVLOG_FILE=$(cd "$REPO" && pwd)/.devlog/devlog.feat-x.md" "$OUT"
|
|
60
|
+
assert_line "no branch devlog" "NO_BRANCH_DEVLOG" "$OUT"
|
|
61
|
+
assert_line "commit count" "COMMITS=1" "$OUT"
|
|
62
|
+
assert_line "gh logged out" "GH=no" "$OUT"
|
|
63
|
+
assert_line "no pr" "PR=none" "$OUT"
|
|
64
|
+
|
|
65
|
+
# --- Branch devlog with rounds + gh logged in -------------------------------------
|
|
66
|
+
mkdir -p "$REPO/.devlog"
|
|
67
|
+
cat > "$REPO/.devlog/devlog.feat-x.md" <<'EOF'
|
|
68
|
+
## Round 1 — 2026-09-01T10:00:00+0800
|
|
69
|
+
|
|
70
|
+
### Summary
|
|
71
|
+
one
|
|
72
|
+
|
|
73
|
+
### Status
|
|
74
|
+
DONE
|
|
75
|
+
|
|
76
|
+
## Round 2 — 2026-09-02T10:00:00+0800
|
|
77
|
+
|
|
78
|
+
### Summary
|
|
79
|
+
two
|
|
80
|
+
|
|
81
|
+
### Status
|
|
82
|
+
DONE
|
|
83
|
+
EOF
|
|
84
|
+
OUT="$(ctx "$GH_ON" "$REPO")"
|
|
85
|
+
assert_line "round start lines" "ROUNDS=1 9" "$OUT"
|
|
86
|
+
assert_line "gh logged in" "GH=yes" "$OUT"
|
|
87
|
+
assert_line "existing pr number" "PR=42" "$OUT"
|
|
88
|
+
|
|
89
|
+
# --- origin/HEAD wins over local main ---------------------------------------------
|
|
90
|
+
gitc update-ref refs/remotes/origin/main main
|
|
91
|
+
gitc symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
|
|
92
|
+
OUT="$(ctx "$GH_OFF" "$REPO")"
|
|
93
|
+
assert_line "base from origin/HEAD" "BASE=main" "$OUT"
|
|
94
|
+
assert_line "base ref is remote" "BASE_REF=origin/main" "$OUT"
|
|
95
|
+
|
|
96
|
+
# --- No main/master and no origin/HEAD -> NO_BASE ------------------------------
|
|
97
|
+
REPO2="$TMP_ROOT/repo2"; mkdir -p "$REPO2"
|
|
98
|
+
git -C "$REPO2" -c user.email=t@example.com -c user.name=t init -q -b trunk
|
|
99
|
+
echo a > "$REPO2/a.txt"; git -C "$REPO2" add a.txt
|
|
100
|
+
git -C "$REPO2" -c user.email=t@example.com -c user.name=t -c commit.gpgsign=false commit -qm init
|
|
101
|
+
git -C "$REPO2" checkout -q -b dev
|
|
102
|
+
OUT="$(ctx "$GH_OFF" "$REPO2")"
|
|
103
|
+
assert_line "no base: branch still printed" "BRANCH=dev" "$OUT"
|
|
104
|
+
assert_line "no base" "NO_BASE" "$OUT"
|
|
105
|
+
|
|
106
|
+
if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
|
|
107
|
+
else echo "Some checks FAILED."; exit 1; fi
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Self-check for promote-{sources,target,write}.sh
|
|
3
|
+
# (docs/design/read-side-and-promote.md D).
|
|
4
|
+
set -uo pipefail
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
6
|
+
TMP_ROOT="$(mktemp -d)"
|
|
7
|
+
trap 'rm -rf "$TMP_ROOT"' EXIT
|
|
8
|
+
FAIL=0
|
|
9
|
+
assert_line() {
|
|
10
|
+
local desc="$1" expected="$2" out="$3"
|
|
11
|
+
if printf '%s\n' "$out" | grep -qxF "$expected"; then echo "PASS: $desc"
|
|
12
|
+
else echo "FAIL: $desc (missing [$expected] in: $out)"; FAIL=1; fi
|
|
13
|
+
}
|
|
14
|
+
assert_eq() {
|
|
15
|
+
local desc="$1" expected="$2" actual="$3"
|
|
16
|
+
if [ "$expected" = "$actual" ]; then echo "PASS: $desc"
|
|
17
|
+
else echo "FAIL: $desc (expected [$expected] got [$actual])"; FAIL=1; fi
|
|
18
|
+
}
|
|
19
|
+
new_project() { local d; d="$(mktemp -d "$TMP_ROOT/p.XXXXXX")"; (cd "$d" && pwd); }
|
|
20
|
+
|
|
21
|
+
# === promote-sources.sh =========================================================
|
|
22
|
+
P="$(new_project)"
|
|
23
|
+
assert_eq "sources: no devlog -> NO_SOURCES" "NO_SOURCES" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-sources.sh")"
|
|
24
|
+
|
|
25
|
+
mkdir -p "$P/.devlog"
|
|
26
|
+
cat > "$P/.devlog/devlog.md" <<'EOF'
|
|
27
|
+
## Round 1 — 2026-09-01T10:00:00+0800
|
|
28
|
+
|
|
29
|
+
### Summary
|
|
30
|
+
x
|
|
31
|
+
|
|
32
|
+
### Status
|
|
33
|
+
DONE
|
|
34
|
+
EOF
|
|
35
|
+
assert_eq "sources: devlog without index/checkpoint -> NO_SOURCES" "NO_SOURCES" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-sources.sh")"
|
|
36
|
+
|
|
37
|
+
cat >> "$P/.devlog/devlog.md" <<'EOF'
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
## Checkpoint fake inside fence
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Checkpoint(Round 1-1)
|
|
44
|
+
### 決策
|
|
45
|
+
- always run shellcheck
|
|
46
|
+
|
|
47
|
+
## Kept 索引
|
|
48
|
+
- `devlog.topic-a.md`:Round 1-1,kept_at 2026-09-05T00:00:00+0800,topic a
|
|
49
|
+
- `devlog.ghost.md`:Round 2-2,kept_at 2026-09-05T00:00:00+0800,gone
|
|
50
|
+
|
|
51
|
+
## Lessons 索引
|
|
52
|
+
- `devlog.lessons.lock-files.md`:1 則,最新一則「x。」(updated_at 2026-09-01T00:00:00+0800)
|
|
53
|
+
EOF
|
|
54
|
+
printf 'kept\n' > "$P/.devlog/devlog.topic-a.md"
|
|
55
|
+
printf '# Lessons: lock-files\n' > "$P/.devlog/devlog.lessons.lock-files.md"
|
|
56
|
+
OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-sources.sh")"
|
|
57
|
+
assert_line "sources: kept file exists" "FILE=$P/.devlog/devlog.topic-a.md KIND=kept EXISTS=1" "$OUT"
|
|
58
|
+
assert_line "sources: kept ghost row" "FILE=$P/.devlog/devlog.ghost.md KIND=kept EXISTS=0" "$OUT"
|
|
59
|
+
assert_line "sources: lessons file" "FILE=$P/.devlog/devlog.lessons.lock-files.md KIND=lessons EXISTS=1" "$OUT"
|
|
60
|
+
CP_LINE="$(grep -n '^## Checkpoint(Round 1-1)' "$P/.devlog/devlog.md" | cut -d: -f1)"
|
|
61
|
+
assert_line "sources: real checkpoint line" "CHECKPOINT=$P/.devlog/devlog.md:$CP_LINE" "$OUT"
|
|
62
|
+
assert_eq "sources: fenced checkpoint ignored" "1" "$(printf '%s\n' "$OUT" | grep -c '^CHECKPOINT=')"
|
|
63
|
+
|
|
64
|
+
# === promote-target.sh ==========================================================
|
|
65
|
+
P="$(new_project)"
|
|
66
|
+
assert_eq "target: empty project -> CLAUDE.md" "TARGET=$P/CLAUDE.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
|
|
67
|
+
|
|
68
|
+
printf '@AGENTS.md\n\n' > "$P/CLAUDE.md"
|
|
69
|
+
assert_eq "target: CLAUDE.md only imports AGENTS.md -> AGENTS.md" "TARGET=$P/AGENTS.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
|
|
70
|
+
|
|
71
|
+
printf '# Rules\n\n@AGENTS.md\n' > "$P/CLAUDE.md"
|
|
72
|
+
assert_eq "target: CLAUDE.md with own content -> CLAUDE.md" "TARGET=$P/CLAUDE.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
|
|
73
|
+
|
|
74
|
+
P="$(new_project)"
|
|
75
|
+
mkdir -p "$P/.codex"; printf '{}\n' > "$P/.codex/hooks.json"
|
|
76
|
+
assert_eq "target: codex-only (hooks.json) -> AGENTS.md" "TARGET=$P/AGENTS.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
|
|
77
|
+
|
|
78
|
+
P="$(new_project)"
|
|
79
|
+
mkdir -p "$P/.agents/skills/devlog-start"
|
|
80
|
+
assert_eq "target: codex-only (skills) -> AGENTS.md" "TARGET=$P/AGENTS.md" "$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
|
|
81
|
+
|
|
82
|
+
P="$(new_project)"
|
|
83
|
+
printf '# Mine\n\n<!-- devlog-tracker:rules:begin -->\n## devlog-tracker 沉澱的規範\n\n- a\n- b\n<!-- devlog-tracker:rules:end -->\n' > "$P/CLAUDE.md"
|
|
84
|
+
OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-target.sh")"
|
|
85
|
+
assert_line "target: existing block counted" "EXISTING=2" "$OUT"
|
|
86
|
+
|
|
87
|
+
# === promote-write.sh ==========================================================
|
|
88
|
+
BEGIN_MARK='<!-- devlog-tracker:rules:begin -->'
|
|
89
|
+
END_MARK='<!-- devlog-tracker:rules:end -->'
|
|
90
|
+
P="$(new_project)"
|
|
91
|
+
RULES="$TMP_ROOT/rules.txt"
|
|
92
|
+
|
|
93
|
+
# --- target missing -> created with header ---------------------------------------
|
|
94
|
+
printf ' 改核心腳本後跑 run-tests.sh \n\n- 一般 PR 不改版號\n' > "$RULES"
|
|
95
|
+
OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES")"
|
|
96
|
+
assert_eq "write: new file result" "ADDED=2 SKIPPED_DUP=0 TARGET=$P/CLAUDE.md" "$OUT"
|
|
97
|
+
EXPECTED="$(printf '%s\n## devlog-tracker 沉澱的規範\n\n- 改核心腳本後跑 run-tests.sh\n- 一般 PR 不改版號\n%s' "$BEGIN_MARK" "$END_MARK")"
|
|
98
|
+
assert_eq "write: new file content (trimmed, '- ' prefixed, blank skipped)" "$EXPECTED" "$(cat "$P/CLAUDE.md")"
|
|
99
|
+
|
|
100
|
+
# --- existing content without block -> appended after a blank line -------------------
|
|
101
|
+
P="$(new_project)"
|
|
102
|
+
printf '# Mine\nkeep me' > "$P/CLAUDE.md"
|
|
103
|
+
printf 'rule one\n' > "$RULES"
|
|
104
|
+
CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
|
|
105
|
+
EXPECTED="$(printf '# Mine\nkeep me\n\n%s\n## devlog-tracker 沉澱的規範\n\n- rule one\n%s' "$BEGIN_MARK" "$END_MARK")"
|
|
106
|
+
assert_eq "write: appended after existing content (missing trailing newline handled)" "$EXPECTED" "$(cat "$P/CLAUDE.md")"
|
|
107
|
+
|
|
108
|
+
# --- existing block -> inserted before end mark; content after block kept -----------
|
|
109
|
+
printf '\n## After\ntail\n' >> "$P/CLAUDE.md"
|
|
110
|
+
printf 'rule two\n' > "$RULES"
|
|
111
|
+
CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
|
|
112
|
+
EXPECTED="$(printf '# Mine\nkeep me\n\n%s\n## devlog-tracker 沉澱的規範\n\n- rule one\n- rule two\n%s\n\n## After\ntail' "$BEGIN_MARK" "$END_MARK")"
|
|
113
|
+
assert_eq "write: appended inside existing block" "$EXPECTED" "$(cat "$P/CLAUDE.md")"
|
|
114
|
+
|
|
115
|
+
# --- exact duplicates (existing + within input) skipped; file unchanged -------------
|
|
116
|
+
BEFORE="$(cat "$P/CLAUDE.md")"
|
|
117
|
+
printf -- '- rule one\nrule two\n' > "$RULES"
|
|
118
|
+
OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES")"
|
|
119
|
+
assert_eq "write: all duplicates" "ADDED=0 SKIPPED_DUP=2 TARGET=$P/CLAUDE.md" "$OUT"
|
|
120
|
+
assert_eq "write: file unchanged on all-dup" "$BEFORE" "$(cat "$P/CLAUDE.md")"
|
|
121
|
+
printf 'rule three\nrule three\n' > "$RULES"
|
|
122
|
+
OUT="$(CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES")"
|
|
123
|
+
assert_eq "write: duplicate within input counted once" "ADDED=1 SKIPPED_DUP=1 TARGET=$P/CLAUDE.md" "$OUT"
|
|
124
|
+
|
|
125
|
+
# --- symlink target: write-in-place preserves the link, rules land in the real file --
|
|
126
|
+
P="$(new_project)"
|
|
127
|
+
printf '# Base\n' > "$P/AGENTS.md"
|
|
128
|
+
ln -s AGENTS.md "$P/CLAUDE.md"
|
|
129
|
+
printf 'rule via symlink\n' > "$RULES"
|
|
130
|
+
CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
|
|
131
|
+
if [ -L "$P/CLAUDE.md" ]; then echo "PASS: write: symlink target stays a symlink"
|
|
132
|
+
else echo "FAIL: write: symlink was replaced by a regular file"; FAIL=1; fi
|
|
133
|
+
if grep -qF "rule via symlink" "$P/AGENTS.md"; then echo "PASS: write: rules land in the symlink's real target"
|
|
134
|
+
else echo "FAIL: write: rules missing from AGENTS.md"; FAIL=1; fi
|
|
135
|
+
|
|
136
|
+
# --- mode is preserved on a plain (non-symlink) target -------------------------------
|
|
137
|
+
P="$(new_project)"
|
|
138
|
+
printf '# Mode\n' > "$P/CLAUDE.md"
|
|
139
|
+
chmod 640 "$P/CLAUDE.md"
|
|
140
|
+
printf 'rule mode\n' > "$RULES"
|
|
141
|
+
CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
|
|
142
|
+
assert_eq "write: mode preserved (640)" "-rw-r-----" "$(ls -l "$P/CLAUDE.md" | cut -c1-10)"
|
|
143
|
+
|
|
144
|
+
# --- lock released ------------------------------------------------------------------
|
|
145
|
+
mkdir -p "$P/.devlog"
|
|
146
|
+
CLAUDE_PROJECT_DIR="$P" bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$RULES" >/dev/null
|
|
147
|
+
[ ! -e "$P/.devlog/.lock" ] && echo "PASS: write: lock released" || { echo "FAIL: write: .lock left behind"; FAIL=1; }
|
|
148
|
+
|
|
149
|
+
# --- usage errors ---------------------------------------------------------------------
|
|
150
|
+
bash "$SCRIPT_DIR/promote-write.sh" >/dev/null 2>&1
|
|
151
|
+
RC=$?
|
|
152
|
+
assert_eq "write: no args exits 2" "2" "$RC"
|
|
153
|
+
bash "$SCRIPT_DIR/promote-write.sh" "$P/CLAUDE.md" "$TMP_ROOT/missing.txt" >/dev/null 2>&1
|
|
154
|
+
RC=$?
|
|
155
|
+
assert_eq "write: missing rules file exits 2" "2" "$RC"
|
|
156
|
+
|
|
157
|
+
if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
|
|
158
|
+
else echo "Some checks FAILED."; exit 1; fi
|