#!/usr/bin/env bash # # task-review-staleness.sh — count top-level todo.org tasks whose review # has gone stale. # # Usage: task-review-staleness.sh # # 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="$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"