aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/task-review-staleness.sh
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts/task-review-staleness.sh')
-rwxr-xr-x.ai/scripts/task-review-staleness.sh83
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"