diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-20 13:37:39 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-20 13:37:39 -0400 |
| commit | 64b617e58a5e95c01b9c9662c8c8f5ba35909299 (patch) | |
| tree | dcb9844091c218a3f1800eac1e402a853dd02611 /.ai/scripts/task-review-staleness.sh | |
| parent | b3186104b1496a52b655ad1d2fd5c2d44445d572 (diff) | |
| download | rulesets-64b617e58a5e95c01b9c9662c8c8f5ba35909299.tar.gz rulesets-64b617e58a5e95c01b9c9662c8c8f5ba35909299.zip | |
test(scripts): add task-review-staleness.sh + bats harness
First component of the daily task-review habit from docs/design/task-review.org. The staleness count is the shared primitive both the wrap-up health check (threshold 30) and the startup reminder (threshold 7) call, so it lives in one tested script rather than being reimplemented in each workflow.
The script counts top-level todo.org tasks whose review has gone stale: depth-2 headings with a TODO/DOING/VERIFY keyword and an [#A]/[#B]/[#C] cookie, where LAST_REVIEWED is missing, unparseable, or older than the threshold. Age uses a strict greater-than, so a task reviewed exactly N days ago is still fresh. Today normalizes to local midnight before the diff, and the day count rounds to the nearest day, so a DST hour can't push a boundary task across the line.
Twelve bats cases cover the normal, boundary, and error categories. Dates are generated relative to the current date rather than hardcoded. The script path resolves as the sibling-of-parent of the test file, so the suite runs identically from the canonical claude-templates tree and the rsync'd project mirror. Makefile test target now globs .ai/scripts/tests for bats alongside scripts/tests.
Diffstat (limited to '.ai/scripts/task-review-staleness.sh')
| -rwxr-xr-x | .ai/scripts/task-review-staleness.sh | 83 |
1 files changed, 83 insertions, 0 deletions
diff --git a/.ai/scripts/task-review-staleness.sh b/.ai/scripts/task-review-staleness.sh new file mode 100755 index 0000000..b52cd3d --- /dev/null +++ b/.ai/scripts/task-review-staleness.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# task-review-staleness.sh — count top-level todo.org tasks whose review +# has gone stale. +# +# Usage: task-review-staleness.sh <todo-file> <threshold-days> +# +# Prints a single integer to stdout: the number of qualifying tasks that +# are stale. Shared by the wrap-up health check (threshold 30) and the +# startup reminder (threshold 7) so both count the same way. +# +# A qualifying task is a depth-2 (**) heading carrying a TODO/DOING/VERIFY +# keyword and an [#A]/[#B]/[#C] priority cookie. DONE/CANCELLED tasks, +# deeper headings, and cookie-less headings are not review units. +# +# A qualifying task is stale when its :LAST_REVIEWED: property is missing +# or unparseable (NIL sorts oldest), or when its age strictly exceeds the +# threshold (age > N days; age == N is still fresh). + +set -euo pipefail + +die() { echo "task-review-staleness: $*" >&2; exit 2; } + +[ "$#" -eq 2 ] || die "usage: $(basename "$0") <todo-file> <threshold-days>" + +todo_file="$1" +threshold="$2" + +[ -f "$todo_file" ] || die "no such file: $todo_file" +[[ "$threshold" =~ ^[0-9]+$ ]] || die "threshold must be a non-negative integer: $threshold" + +# Emit one line per qualifying top-level task: its LAST_REVIEWED value, or +# "NONE" when the task has no such property. Body prose is ignored; only +# the property drawer between a qualifying heading and the next heading is +# scanned. +extract_review_dates() { + awk ' + function flush() { + if (in_task) print (have_lr ? lr : "NONE") + } + /^\*+ / { + flush() + have_lr = 0; lr = "" + in_task = ($0 ~ /^\*\* (TODO|DOING|VERIFY) \[#[ABC]\]/) + next + } + in_task && /^[ \t]*:LAST_REVIEWED:[ \t]*/ { + line = $0 + sub(/^[ \t]*:LAST_REVIEWED:[ \t]*/, "", line) + sub(/[ \t]*$/, "", line) + lr = line; have_lr = 1 + next + } + END { flush() } + ' "$todo_file" +} + +# Normalize "today" to local midnight so a task reviewed exactly N days +# ago measures as N, not N-and-a-fraction. LAST_REVIEWED dates parse to +# midnight already, so both ends of the diff sit on day boundaries. +today_epoch=$(date -d "$(date +%F)" +%s) +count=0 + +while IFS= read -r value; do + if [ "$value" = "NONE" ]; then + count=$((count + 1)) + continue + fi + + # Unparseable date → treat as NIL (stale). + if ! rev_epoch=$(date -d "$value" +%s 2>/dev/null); then + count=$((count + 1)) + continue + fi + + # Round to nearest day so a DST hour can't shift a boundary task. + age_days=$(( (today_epoch - rev_epoch + 43200) / 86400 )) + if [ "$age_days" -gt "$threshold" ]; then + count=$((count + 1)) + fi +done < <(extract_review_dates) + +echo "$count" |
