diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-20 15:06:17 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-20 15:06:17 -0400 |
| commit | e9bd073b8133c50a2df425196e32fdf3f5c4c2bd (patch) | |
| tree | ace469011d84044a78f93a2e6883de673b9a198d /.ai/scripts | |
| parent | 49898a8c364430abf792567d2a51ac09db97a94f (diff) | |
| download | rulesets-e9bd073b8133c50a2df425196e32fdf3f5c4c2bd.tar.gz rulesets-e9bd073b8133c50a2df425196e32fdf3f5c4c2bd.zip | |
feat(workflows): add task-review list-hygiene habit
The new task-review.org workflow is the daily habit that retires the old date-coverage scan. It surfaces the oldest-unreviewed top-level tasks, walks them one at a time, and records each outcome — keep, re-grade, kill, mark DOING, or edit — stamping :LAST_REVIEWED: as it goes. It's a pure Claude workflow, no elisp. open-tasks.org displays the list; this one changes it.
task-review-staleness.sh gains a --list mode that emits the N oldest-unreviewed tasks (line, review date, heading), oldest first, so the workflow walks a deterministic batch instead of eyeballing todo.org. Never-reviewed and unparseable-date tasks sort oldest. Seven new bats cases cover ordering, the count limit, exclusions, and output format; count mode is unchanged.
startup.org gains the matching nudge. Phase A counts tasks unreviewed for >7 days and Phase C surfaces one line when that count is non-zero, pointing at the workflow. It lives in the template startup.org rather than the project-only startup-extras layer, so every project picks it up the same way it picks up the wrap-up health check.
The INDEX entry is added with the "task review" triggers the rename freed up.
Diffstat (limited to '.ai/scripts')
| -rwxr-xr-x | .ai/scripts/task-review-staleness.sh | 85 | ||||
| -rw-r--r-- | .ai/scripts/tests/task-review-staleness.bats | 64 |
2 files changed, 122 insertions, 27 deletions
diff --git a/.ai/scripts/task-review-staleness.sh b/.ai/scripts/task-review-staleness.sh index b52cd3d..ed43712 100755 --- a/.ai/scripts/task-review-staleness.sh +++ b/.ai/scripts/task-review-staleness.sh @@ -1,67 +1,98 @@ #!/usr/bin/env bash # -# task-review-staleness.sh — count top-level todo.org tasks whose review -# has gone stale. +# task-review-staleness.sh — surface top-level todo.org tasks by review age. # -# Usage: task-review-staleness.sh <todo-file> <threshold-days> +# Usage: +# task-review-staleness.sh <todo-file> <threshold-days> # count mode (default) +# task-review-staleness.sh --list <todo-file> <count> # list mode # -# Prints a single integer to stdout: the number of qualifying tasks that +# Count mode prints a single integer: 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. # +# List mode prints up to <count> qualifying tasks, oldest-reviewed first, +# one per line, tab-separated as: <line>\t<LAST_REVIEWED-or-NONE>\t<heading>. +# The task-review workflow uses it to pick the batch to walk each day. +# # 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). +# A task is stale (count mode), and sorts oldest (list mode), when its +# :LAST_REVIEWED: property is missing or unparseable (NIL sorts first), 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>" +mode=count +if [ "${1:-}" = "--list" ]; then + mode=list + shift +fi + +[ "$#" -eq 2 ] || die "usage: $(basename "$0") [--list] <todo-file> <number>" todo_file="$1" -threshold="$2" +num="$2" [ -f "$todo_file" ] || die "no such file: $todo_file" -[[ "$threshold" =~ ^[0-9]+$ ]] || die "threshold must be a non-negative integer: $threshold" +[[ "$num" =~ ^[0-9]+$ ]] || die "expected a non-negative integer, got: $num" -# 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() { +# Emit one line per qualifying top-level task: <line>\t<value>\t<heading>, +# where <value> is its LAST_REVIEWED date or "NONE". Body prose is ignored; +# only the property drawer between a qualifying heading and the next heading +# is scanned. +extract_tasks() { awk ' function flush() { - if (in_task) print (have_lr ? lr : "NONE") + if (in_task) printf "%s\t%s\t%s\n", hline, (have_lr ? lr : "NONE"), heading } /^\*+ / { flush() have_lr = 0; lr = "" - in_task = ($0 ~ /^\*\* (TODO|DOING|VERIFY) \[#[ABC]\]/) + if ($0 ~ /^\*\* (TODO|DOING|VERIFY) \[#[ABC]\]/) { + in_task = 1; hline = NR; heading = $0 + sub(/[ \t]*$/, "", heading) + } else { + in_task = 0 + } next } in_task && /^[ \t]*:LAST_REVIEWED:[ \t]*/ { - line = $0 - sub(/^[ \t]*:LAST_REVIEWED:[ \t]*/, "", line) - sub(/[ \t]*$/, "", line) - lr = line; have_lr = 1 + v = $0 + sub(/^[ \t]*:LAST_REVIEWED:[ \t]*/, "", v) + sub(/[ \t]*$/, "", v) + lr = v; 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. +if [ "$mode" = "list" ]; then + # Prefix each task with a sort key (the review date, or 0000-00-00 when + # missing or unparseable so it sorts oldest), order by key then file line, + # take the requested count, and drop the key column. + extract_tasks | awk -F'\t' -v OFS='\t' ' + { + key = ($2 ~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) ? $2 : "0000-00-00" + print key, $1, $2, $3 + } + ' | sort -t"$(printf '\t')" -k1,1 -k2,2n | awk -v n="$num" 'NR <= n' | cut -f2- + exit 0 +fi + +# Count mode. +# 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 +while IFS=$'\t' read -r hline value heading; do if [ "$value" = "NONE" ]; then count=$((count + 1)) continue @@ -75,9 +106,9 @@ while IFS= read -r value; do # 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 + if [ "$age_days" -gt "$num" ]; then count=$((count + 1)) fi -done < <(extract_review_dates) +done < <(extract_tasks) echo "$count" diff --git a/.ai/scripts/tests/task-review-staleness.bats b/.ai/scripts/tests/task-review-staleness.bats index abb7585..488b023 100644 --- a/.ai/scripts/tests/task-review-staleness.bats +++ b/.ai/scripts/tests/task-review-staleness.bats @@ -147,3 +147,67 @@ task_unreviewed() { run bash "$SCRIPT" "$TEST_DIR/does-not-exist.org" 30 [ "$status" -ne 0 ] } + +# ---- --list mode ----------------------------------------------------- + +@test "staleness --list: orders oldest-reviewed first, unreviewed before dated" { + task_reviewed TODO A "Reviewed recently" "$D5" + task_reviewed TODO B "Reviewed long ago" "$D40" + task_unreviewed DOING A "Never reviewed" + run bash "$SCRIPT" --list "$TODO" 10 + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"Never reviewed"* ]] + [[ "${lines[1]}" == *"Reviewed long ago"* ]] + [[ "${lines[2]}" == *"Reviewed recently"* ]] +} + +@test "staleness --list: takes only the requested count" { + task_unreviewed TODO A "First" + task_reviewed TODO B "Second" "$D40" + task_reviewed TODO C "Third" "$D30" + run bash "$SCRIPT" --list "$TODO" 2 + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 2 ] +} + +@test "staleness --list: count larger than available returns all candidates" { + task_unreviewed TODO A "Only one" + run bash "$SCRIPT" --list "$TODO" 10 + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] +} + +@test "staleness --list: excludes DONE, deeper headings, and cookie-less headings" { + task_reviewed DONE A "Shipped" "$D40" + printf '*** TODO [#A] Child task\n\n' >> "$TODO" + printf '** TODO Cookie-less\n\n' >> "$TODO" + task_unreviewed TODO B "Real one" + run bash "$SCRIPT" --list "$TODO" 10 + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [[ "${lines[0]}" == *"Real one"* ]] +} + +@test "staleness --list: emits line, review value, and heading tab-separated" { + task_unreviewed TODO A "Never reviewed" + run bash "$SCRIPT" --list "$TODO" 10 + [ "$status" -eq 0 ] + line_no=$(printf '%s' "${lines[0]}" | cut -f1) + value=$(printf '%s' "${lines[0]}" | cut -f2) + heading=$(printf '%s' "${lines[0]}" | cut -f3) + [ "$line_no" = "1" ] + [ "$value" = "NONE" ] + [ "$heading" = "** TODO [#A] Never reviewed" ] +} + +@test "staleness --list: empty file produces no output" { + : > "$TODO" + run bash "$SCRIPT" --list "$TODO" 10 + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "staleness --list: missing file exits non-zero" { + run bash "$SCRIPT" --list "$TEST_DIR/nope.org" 10 + [ "$status" -ne 0 ] +} |
