aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-20 15:06:17 -0400
committerCraig Jennings <c@cjennings.net>2026-05-20 15:06:17 -0400
commite9bd073b8133c50a2df425196e32fdf3f5c4c2bd (patch)
treeace469011d84044a78f93a2e6883de673b9a198d /.ai/scripts
parent49898a8c364430abf792567d2a51ac09db97a94f (diff)
downloadrulesets-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.sh85
-rw-r--r--.ai/scripts/tests/task-review-staleness.bats64
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 ]
+}