#!/usr/bin/env bash # # task-review-staleness.sh — surface top-level todo.org tasks by review age. # # Usage: # task-review-staleness.sh # count mode (default) # task-review-staleness.sh --list # list mode # # 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 qualifying tasks, oldest-reviewed first, # one per line, tab-separated as: \t\t. # 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 task is stale (count mode), and sorts oldest (list mode), when its # :LAST_REVIEWED: property is missing (NIL sorts first) or when its age # strictly exceeds the threshold (age > N days; age == N is still fresh). # # :LAST_REVIEWED: accepts a bare date (2026-07-09) or an org-native # timestamp ([2026-07-09 Thu] or <2026-07-09 Thu ...>); both normalize to # the ISO date. A value that is present but parses to neither is a data # error: the script warns loudly to stderr (file:line:value) and leaves it # out of the stale count rather than silently treating it as never-reviewed. set -euo pipefail die() { echo "task-review-staleness: $*" >&2; exit 2; } mode=count if [ "${1:-}" = "--list" ]; then mode=list shift fi [ "$#" -eq 2 ] || die "usage: $(basename "$0") [--list] " todo_file="$1" num="$2" [ -f "$todo_file" ] || die "no such file: $todo_file" [[ "$num" =~ ^[0-9]+$ ]] || die "expected a non-negative integer, got: $num" # Emit one line per qualifying top-level task: \t\t, # where 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 -v fname="$todo_file" ' function flush() { if (in_task) printf "%s\t%s\t%s\n", hline, (have_lr ? lr : "NONE"), heading } /^\*+ / { flush() have_lr = 0; lr = "" 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]*/ { v = $0 sub(/^[ \t]*:LAST_REVIEWED:[ \t]*/, "", v) sub(/[ \t]*$/, "", v) raw = v # Accept org-native stamps: strip a leading [ or < (inactive/active # timestamp bracket) and take the leading ISO date, so [2026-07-09 Thu] # and 2026-07-09 both normalize to 2026-07-09. sub(/^[[<]/, "", v) if (match(v, /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/)) { lr = substr(v, RSTART, RLENGTH); have_lr = 1 } else { # Present but unparseable — a data error, not "never reviewed". Warn # loudly (file:line:value) instead of silently folding it into the # stale count, where a re-review in the same bad format would never # drop the number and nothing would explain why. printf "task-review-staleness: %s:%d: unparseable :LAST_REVIEWED: %s (expected YYYY-MM-DD or [YYYY-MM-DD Day])\n", fname, NR, raw > "/dev/stderr" lr = "INVALID"; have_lr = 1 } next } END { flush() } ' "$todo_file" } 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=$'\t' read -r hline value heading; do if [ "$value" = "NONE" ]; then count=$((count + 1)) continue fi # Malformed stamp: extract_tasks already warned. A data error is not # "never reviewed", so it stays out of the stale count. if [ "$value" = "INVALID" ]; then continue fi # A shape-valid but impossible date (e.g. 2026-13-45) slips past the awk # regex; warn and skip rather than silently counting it. if ! rev_epoch=$(date -d "$value" +%s 2>/dev/null); then echo "task-review-staleness: $todo_file: unparseable :LAST_REVIEWED: $value" >&2 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 "$num" ]; then count=$((count + 1)) fi done < <(extract_tasks) echo "$count"