1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
|
#!/usr/bin/env bash
#
# task-review-staleness.sh — surface top-level todo.org tasks by review age.
#
# Usage:
# task-review-staleness.sh <todo-file> <threshold-days> # count mode (default)
# task-review-staleness.sh --list <todo-file> <count> # 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 <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 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> <number>"
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: <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 -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"
|