devlog-tracker 0.30.0 → 0.31.1
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/devlog-lock.sh +10 -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-close-open-round.sh +54 -0
- package/core/scripts/tests/test-devlog-lock.sh +44 -0
- 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 +28 -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,64 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Context for /devlog-tracker:pr (docs/design/read-side-and-promote.md A).
|
|
3
|
+
# Plain read only: git is only queried, and gh is only asked `auth status`
|
|
4
|
+
# and `pr view` -- creating/editing the PR is the command doc's job, after
|
|
5
|
+
# the user confirms.
|
|
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 "NOT_A_REPO"; exit 0; }
|
|
16
|
+
git -C "$PROJECT_DIR" rev-parse --git-dir >/dev/null 2>&1 || { echo "NOT_A_REPO"; exit 0; }
|
|
17
|
+
|
|
18
|
+
BRANCH="$(git -C "$PROJECT_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null || echo '')"
|
|
19
|
+
case "$BRANCH" in
|
|
20
|
+
'') echo "DETACHED_HEAD"; exit 0 ;;
|
|
21
|
+
[Mm][Aa][Ii][Nn]|[Mm][Aa][Ss][Tt][Ee][Rr]) echo "ON_DEFAULT_BRANCH"; exit 0 ;;
|
|
22
|
+
esac
|
|
23
|
+
|
|
24
|
+
BASE=""
|
|
25
|
+
BASE_REF=""
|
|
26
|
+
ORIGIN_HEAD="$(git -C "$PROJECT_DIR" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || echo '')"
|
|
27
|
+
if [ -n "$ORIGIN_HEAD" ]; then
|
|
28
|
+
BASE="${ORIGIN_HEAD#origin/}"
|
|
29
|
+
BASE_REF="$ORIGIN_HEAD"
|
|
30
|
+
else
|
|
31
|
+
for candidate in main master; do
|
|
32
|
+
if git -C "$PROJECT_DIR" rev-parse --verify --quiet "refs/heads/$candidate" >/dev/null; then
|
|
33
|
+
BASE="$candidate"
|
|
34
|
+
BASE_REF="$candidate"
|
|
35
|
+
break
|
|
36
|
+
fi
|
|
37
|
+
done
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
echo "BRANCH=$BRANCH"
|
|
41
|
+
[ -n "$BASE" ] || { echo "NO_BASE"; exit 0; }
|
|
42
|
+
echo "BASE=$BASE"
|
|
43
|
+
echo "BASE_REF=$BASE_REF"
|
|
44
|
+
|
|
45
|
+
devlog_resolve_paths "$PROJECT_DIR"
|
|
46
|
+
echo "DEVLOG_FILE=$DEVLOG_FILE"
|
|
47
|
+
ROUNDS=""
|
|
48
|
+
if [ -f "$DEVLOG_FILE" ]; then
|
|
49
|
+
ROUNDS="$(devlog_list_round_starts "$DEVLOG_FILE" | awk '{ printf "%s%s", (n++ ? " " : ""), $1 }')"
|
|
50
|
+
fi
|
|
51
|
+
if [ -n "$ROUNDS" ]; then echo "ROUNDS=$ROUNDS"; else echo "NO_BRANCH_DEVLOG"; fi
|
|
52
|
+
|
|
53
|
+
COMMITS="$(git -C "$PROJECT_DIR" rev-list --count "$BASE_REF..HEAD" 2>/dev/null || echo 0)"
|
|
54
|
+
echo "COMMITS=$COMMITS"
|
|
55
|
+
|
|
56
|
+
GH=no
|
|
57
|
+
PR=none
|
|
58
|
+
if command -v gh >/dev/null 2>&1 && (cd "$PROJECT_DIR" && gh auth status >/dev/null 2>&1); then
|
|
59
|
+
GH=yes
|
|
60
|
+
n="$(cd "$PROJECT_DIR" && gh pr view --json number -q .number 2>/dev/null || echo '')"
|
|
61
|
+
case "$n" in ''|*[!0-9]*) ;; *) PR="$n" ;; esac
|
|
62
|
+
fi
|
|
63
|
+
echo "GH=$GH"
|
|
64
|
+
echo "PR=$PR"
|
|
@@ -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
|
|
@@ -316,6 +316,60 @@ fi
|
|
|
316
316
|
rm -rf "$REC_DIR"
|
|
317
317
|
export CLAUDE_PROJECT_DIR="$TMP_ROOT"
|
|
318
318
|
|
|
319
|
+
# --- L1: run as a child of the lock holder (round-start.sh's dangling heal)
|
|
320
|
+
# -> no wait on the parent's lock, still stamps, and its EXIT-trap
|
|
321
|
+
# release leaves the parent's lock in place ----------------------
|
|
322
|
+
# shellcheck source=../devlog-lock.sh
|
|
323
|
+
. "$SCRIPT_DIR/devlog-lock.sh"
|
|
324
|
+
write_skeleton
|
|
325
|
+
devlog_lock_acquire
|
|
326
|
+
START="$(date +%s)"
|
|
327
|
+
bash "$SCRIPT_DIR/close-open-round.sh" "dangling:next_prompt"
|
|
328
|
+
ELAPSED=$(( $(date +%s) - START ))
|
|
329
|
+
if [ "$ELAPSED" -le 1 ]; then
|
|
330
|
+
echo "PASS: child of lock holder doesn't wait (${ELAPSED}s)"
|
|
331
|
+
else
|
|
332
|
+
echo "FAIL: child of lock holder waited ${ELAPSED}s"
|
|
333
|
+
FAIL=1
|
|
334
|
+
fi
|
|
335
|
+
assert_contains "child of lock holder still stamps" $'### Status\nINTERRUPTED' "$(cat "$DEVLOG_DIR/devlog.md")"
|
|
336
|
+
if [ -d "$DEVLOG_DIR/.lock" ] && [ "$(cat "$DEVLOG_DIR/.lock/pid" 2>/dev/null)" = "$$" ]; then
|
|
337
|
+
echo "PASS: child's exit leaves the holder's lock"
|
|
338
|
+
else
|
|
339
|
+
echo "FAIL: holder's lock removed by child"
|
|
340
|
+
FAIL=1
|
|
341
|
+
fi
|
|
342
|
+
devlog_lock_release
|
|
343
|
+
assert_file_absent "holder release removes lock" "$DEVLOG_DIR/.lock/pid"
|
|
344
|
+
|
|
345
|
+
# --- L2: lock held by an unrelated live process (no inherited owner) ->
|
|
346
|
+
# still waits out the contention timeout, stamps fail-open, and
|
|
347
|
+
# leaves the foreign lock alone --------------------------------------
|
|
348
|
+
write_skeleton
|
|
349
|
+
sleep 5 &
|
|
350
|
+
LIVE_PID=$!
|
|
351
|
+
mkdir "$DEVLOG_DIR/.lock"
|
|
352
|
+
echo "$LIVE_PID" > "$DEVLOG_DIR/.lock/pid"
|
|
353
|
+
START="$(date +%s)"
|
|
354
|
+
bash "$SCRIPT_DIR/close-open-round.sh" "dangling:next_prompt"
|
|
355
|
+
ELAPSED=$(( $(date +%s) - START ))
|
|
356
|
+
if [ "$ELAPSED" -ge 2 ]; then
|
|
357
|
+
echo "PASS: unrelated holder still contends (${ELAPSED}s)"
|
|
358
|
+
else
|
|
359
|
+
echo "FAIL: unrelated holder skipped contention (${ELAPSED}s)"
|
|
360
|
+
FAIL=1
|
|
361
|
+
fi
|
|
362
|
+
assert_contains "contended run still stamps (fail-open)" $'### Status\nINTERRUPTED' "$(cat "$DEVLOG_DIR/devlog.md")"
|
|
363
|
+
if [ "$(cat "$DEVLOG_DIR/.lock/pid" 2>/dev/null)" = "$LIVE_PID" ]; then
|
|
364
|
+
echo "PASS: foreign lock left in place"
|
|
365
|
+
else
|
|
366
|
+
echo "FAIL: foreign lock touched"
|
|
367
|
+
FAIL=1
|
|
368
|
+
fi
|
|
369
|
+
kill "$LIVE_PID" 2>/dev/null || true
|
|
370
|
+
wait "$LIVE_PID" 2>/dev/null || true
|
|
371
|
+
rm -rf "$DEVLOG_DIR/.lock"
|
|
372
|
+
|
|
319
373
|
if [ "$FAIL" -eq 0 ]; then
|
|
320
374
|
echo "All checks passed."
|
|
321
375
|
exit 0
|
|
@@ -72,5 +72,49 @@ kill "$LIVE_PID" 2>/dev/null || true
|
|
|
72
72
|
wait "$LIVE_PID" 2>/dev/null || true
|
|
73
73
|
rm -rf "$DEVLOG_DIR/.lock"
|
|
74
74
|
|
|
75
|
+
# Re-entry from a child process: a script run by the lock holder (e.g.
|
|
76
|
+
# round-start.sh -> close-open-round.sh) sees the lock as already its own —
|
|
77
|
+
# returns at once, and its release leaves the parent's lock in place.
|
|
78
|
+
devlog_lock_acquire
|
|
79
|
+
START="$(date +%s)"
|
|
80
|
+
CHILD="$(bash -c '. "$1"; DEVLOG_DIR="$2"; devlog_lock_acquire; echo "held=${LOCK_HELD:-0} by=${LOCK_CONTENDED_BY:-}"; devlog_lock_release' _ "$SCRIPT_DIR/devlog-lock.sh" "$DEVLOG_DIR")"
|
|
81
|
+
ELAPSED=$(( $(date +%s) - START ))
|
|
82
|
+
[ "$ELAPSED" -le 1 ] && [ "$CHILD" = "held=0 by=" ] \
|
|
83
|
+
&& echo "PASS: child of holder re-enters without waiting" \
|
|
84
|
+
|| { echo "FAIL: child re-entry elapsed=$ELAPSED child='$CHILD'"; FAIL=1; }
|
|
85
|
+
[ -d "$DEVLOG_DIR/.lock" ] && [ "$(cat "$DEVLOG_DIR/.lock/pid")" = "$$" ] \
|
|
86
|
+
&& echo "PASS: child release leaves parent's lock" \
|
|
87
|
+
|| { echo "FAIL: parent's lock gone after child release"; FAIL=1; }
|
|
88
|
+
# Grandchild (holder -> script -> script) inherits the owner too.
|
|
89
|
+
START="$(date +%s)"
|
|
90
|
+
GRAND="$(bash -c 'bash -c ". \"\$1\"; DEVLOG_DIR=\"\$2\"; devlog_lock_acquire; echo held=\${LOCK_HELD:-0}; devlog_lock_release" _ "$1" "$2"' _ "$SCRIPT_DIR/devlog-lock.sh" "$DEVLOG_DIR")"
|
|
91
|
+
ELAPSED=$(( $(date +%s) - START ))
|
|
92
|
+
[ "$GRAND" = "held=0" ] && [ "$ELAPSED" -le 1 ] && [ -d "$DEVLOG_DIR/.lock" ] \
|
|
93
|
+
&& echo "PASS: grandchild of holder re-enters" || { echo "FAIL: grandchild got '$GRAND' elapsed=$ELAPSED"; FAIL=1; }
|
|
94
|
+
devlog_lock_release
|
|
95
|
+
[ -z "${DEVLOG_LOCK_OWNER:-}" ] && [ ! -d "$DEVLOG_DIR/.lock" ] \
|
|
96
|
+
&& echo "PASS: release clears owner" || { echo "FAIL: owner=${DEVLOG_LOCK_OWNER:-} survives release"; FAIL=1; }
|
|
97
|
+
|
|
98
|
+
# After the holder releases, a child it spawns later no longer carries the
|
|
99
|
+
# owner, so it would contend on a lock someone else took meanwhile.
|
|
100
|
+
OUT="$(bash -c 'echo "owner=${DEVLOG_LOCK_OWNER:-}"')"
|
|
101
|
+
[ "$OUT" = "owner=" ] && echo "PASS: owner not exported after release" \
|
|
102
|
+
|| { echo "FAIL: child still sees $OUT"; FAIL=1; }
|
|
103
|
+
|
|
104
|
+
# A process that did not inherit the owner (another hook launched by the
|
|
105
|
+
# agent while the lock is held) still waits out the timeout.
|
|
106
|
+
sleep 5 &
|
|
107
|
+
LIVE_PID=$!
|
|
108
|
+
mkdir "$DEVLOG_DIR/.lock"
|
|
109
|
+
echo "$LIVE_PID" > "$DEVLOG_DIR/.lock/pid"
|
|
110
|
+
START="$(date +%s)"
|
|
111
|
+
OUT="$(DEVLOG_LOCK_OWNER=12345 bash -c '. "$1"; DEVLOG_DIR="$2"; devlog_lock_acquire; echo "${LOCK_HELD:-0}"' _ "$SCRIPT_DIR/devlog-lock.sh" "$DEVLOG_DIR")"
|
|
112
|
+
ELAPSED=$(( $(date +%s) - START ))
|
|
113
|
+
[ "$OUT" = "0" ] && [ "$ELAPSED" -ge 2 ] \
|
|
114
|
+
&& echo "PASS: unrelated owner still contends" || { echo "FAIL: unrelated owner out=$OUT elapsed=$ELAPSED"; FAIL=1; }
|
|
115
|
+
kill "$LIVE_PID" 2>/dev/null || true
|
|
116
|
+
wait "$LIVE_PID" 2>/dev/null || true
|
|
117
|
+
rm -rf "$DEVLOG_DIR/.lock"
|
|
118
|
+
|
|
75
119
|
if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
|
|
76
120
|
else echo "Some checks FAILED."; exit 1; fi
|
|
@@ -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
|