aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-x.ai/scripts/task-review-staleness.sh85
-rw-r--r--.ai/scripts/tests/task-review-staleness.bats64
-rw-r--r--.ai/workflows/INDEX.org2
-rw-r--r--.ai/workflows/startup.org2
-rw-r--r--.ai/workflows/task-review.org97
-rwxr-xr-xclaude-templates/.ai/scripts/task-review-staleness.sh85
-rw-r--r--claude-templates/.ai/scripts/tests/task-review-staleness.bats64
-rw-r--r--claude-templates/.ai/workflows/INDEX.org2
-rw-r--r--claude-templates/.ai/workflows/startup.org2
-rw-r--r--claude-templates/.ai/workflows/task-review.org97
10 files changed, 446 insertions, 54 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 ]
+}
diff --git a/.ai/workflows/INDEX.org b/.ai/workflows/INDEX.org
index 5a68765..7e65763 100644
--- a/.ai/workflows/INDEX.org
+++ b/.ai/workflows/INDEX.org
@@ -26,6 +26,8 @@ This index must list every =.org= file in =.ai/workflows/= except this one. Star
- =open-tasks.org= — list all open tasks (list mode) or pick the next task (next mode).
- Triggers: "what's next", "what should I work on", "list open tasks", "show me all tasks", "what's on my plate", "show me my tasks", "I need a recommendation"
+- =task-review.org= — daily list-hygiene habit: walk the oldest-unreviewed top-level tasks, re-grade / kill / keep / mark DOING, and stamp =:LAST_REVIEWED:=. Distinct from =open-tasks.org=, which only displays.
+ - Triggers: "task review", "review tasks", "let's do a task review", "groom my tasks", "task-review"
- =daily-prep.org= — prep brief for the next workday. Two modes: full-prep (default) or standup-only.
- Full-prep triggers: "let's prep for tomorrow", "daily prep"
- Standup-only triggers: "what's my standup report", "let's do the daily standup report", "give me the standup brief"
diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org
index fabd607..3503a60 100644
--- a/.ai/workflows/startup.org
+++ b/.ai/workflows/startup.org
@@ -103,6 +103,7 @@ These calls have no dependencies on each other. Issue them all together in one m
8. =cross-agent-status 2>/dev/null || true= — snapshot of pending cross-agent messages across local projects. This is layer A of the cold-start design from =cross-agent-comms.org=: pending messages from other agents (delivered while no session was active here) get surfaced on session start. The =|| true= keeps Phase A from failing if =cross-agent-status= isn't installed yet — older projects without the script still boot cleanly. If HALT is active, =cross-agent-status= prints a banner; surface that prominently in Phase C.
9. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
10. Read =.ai/project-workflows/startup-extras.org= if it exists.
+11. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm.
Notes on the rsync commands:
- Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside.
@@ -131,6 +132,7 @@ This phase touches the user and runs sequentially:
- Surface Active Reminders from notes.org immediately.
- Mention Pending Decisions from notes.org.
- Briefly note significant template updates noticed during sync (new workflows, protocol changes).
+ - *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "=<N>= top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing.
- *Surface pending cross-agent messages.* If =cross-agent-status= reported any pending messages, list them with their =cross-agent-recv= decision (process / query / reject) per file. For =process= messages in this project's inbox, propose handling now or after the current task. For pending in other projects, mention the count so the user knows to switch projects when ready. If HALT was active, surface that prominently — cross-agent activity is paused until =cross-agent-resume= clears it.
2. *Process inbox if non-empty.* Mandatory — don't ask, just do it. For each file: determine action, recommend filing, get approval, move. For =.eml= files use the extract script (not raw Read):
#+begin_src bash
diff --git a/.ai/workflows/task-review.org b/.ai/workflows/task-review.org
new file mode 100644
index 0000000..febb986
--- /dev/null
+++ b/.ai/workflows/task-review.org
@@ -0,0 +1,97 @@
+#+TITLE: Task Review Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-20
+
+* Overview
+
+A short, daily list-hygiene habit. Each run surfaces the handful of top-level tasks that have gone longest without a review, walks them with Craig one at a time, and records the outcome — re-grade, kill, mark in-progress, or just confirm it's still right — stamping =:LAST_REVIEWED:= as it goes.
+
+The point is /trust/: when Craig opens =todo.org=, the priorities and wording should feel accurate. Tasks drift — an =[#A]= from six months ago may not be urgent now, a commitment keeps getting deferred without ever being killed, a body loses the context that made it actionable. Walking a small rotating batch every day keeps the list honest without a big periodic purge.
+
+This is *not* =open-tasks.org=, which just displays the list or picks the next task. This workflow *changes* tasks. Full design and rationale: [[file:../../docs/design/task-review.org][docs/design/task-review.org]].
+
+* When to Use This Workflow
+
+User says one of: "task review", "review tasks", "let's do a task review", "groom my tasks", "task-review".
+
+Cadence is daily and rotational — the batch is the oldest-unreviewed tasks, so consecutive days cover different tasks. At the default batch size of 7 over ~80 open tasks, the full list rotates about every 12 days.
+
+* Phase A: Precondition
+
+Confirm =todo.org= exists at the project root. If it doesn't, say "this project has no =todo.org= to review" and stop — there's nothing to do.
+
+* Phase B: Select the batch
+
+Run the staleness script in list mode to get the oldest-unreviewed top-level tasks. Default batch size is 7; let Craig override if he asks for more or fewer.
+
+#+begin_src bash
+.ai/scripts/task-review-staleness.sh --list todo.org 7
+#+end_src
+
+Each output line is tab-separated: =<line> <LAST_REVIEWED-or-NONE> <heading>=. The list is already ordered oldest-first (never-reviewed tasks sort before dated ones), so walk it top to bottom. Selection only includes depth-2 =**= headings that are =TODO=/=DOING=/=VERIFY= with an =[#A]=/=[#B]=/=[#C]= cookie — DONE/CANCELLED and deeper headings are out by construction.
+
+If the list is empty, report "nothing to review — every task has been looked at recently" and stop.
+
+Read =todo.org= once now so the body of each selected task is in context for the walk. Don't re-read per task.
+
+* Phase C: Walk the batch
+
+Get today's date once for the stamp:
+
+#+begin_src bash
+date "+%Y-%m-%d"
+#+end_src
+
+Then take the tasks one at a time, in list order. For each, show Craig the heading, its priority, when it was last reviewed (or "never"), and a one-line reminder of the body. Then ask what to do. The actions:
+
+| Action | What it does | Stamp =:LAST_REVIEWED:=? |
+|--------+--------------+--------------------------|
+| *Keep* | Still correct as-is — no change beyond recording the review | Yes |
+| *Re-grade A/B/C* | Change the priority cookie to =[#A]= / =[#B]= / =[#C]= | Yes |
+| *Kill* | Abandon it: =TODO=/=DOING= → =CANCELLED= plus a =CLOSED: [YYYY-MM-DD Day]= line | No (it leaves the pool as CANCELLED) |
+| *Doing* | =TODO= → =DOING= (no-op if already DOING/VERIFY) | Yes |
+| *Edit* | Reword the heading or body — do it with Craig, then record the review | Yes |
+| *Skip* | Not now — leave it untouched so it resurfaces next run | No |
+
+Keep is the common case — most tasks are still right and just need re-stamping. Be decisive and quick; this is a 5-minute habit, not a planning session.
+
+*** Stamping =:LAST_REVIEWED:=
+
+Set =:LAST_REVIEWED:= to today's date (from above) in the task's =:PROPERTIES:= drawer:
+
+- If the task already has a =:PROPERTIES:= drawer, update or add the =:LAST_REVIEWED:= line inside it.
+- If it has no drawer, insert one immediately after the heading — or, when the heading carries a =DEADLINE:=/=SCHEDULED:=/=CLOSED:= planning line, immediately after that line (org requires the planning line to sit directly under the heading, with the drawer below it):
+
+#+begin_example
+** TODO [#A] Some topic :tag:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-20
+:END:
+Body...
+#+end_example
+
+The exact date string matters: =task-review-staleness.sh= and the wrap-up health check both parse =:LAST_REVIEWED: YYYY-MM-DD=.
+
+*** Killing a task
+
+Follow the completion rules in [[file:../../claude-rules/todo-format.md][todo-format.md]]. A killed top-level =**= task stays task-shaped: change the keyword to =CANCELLED=, add a =CLOSED: [YYYY-MM-DD Day]= line under the heading (generate with =date "+%Y-%m-%d %a"=), and leave the priority and tags intact. It's then a candidate for =--archive-done= at the next cleanup. Don't stamp =:LAST_REVIEWED:= on a kill — it's leaving the review pool anyway.
+
+* Phase D: Close out
+
+When the batch is done (or Craig calls it early):
+
+1. Summarize what changed — re-grades, kills, anything marked DOING — in a couple of lines. Don't list the keeps individually; "the rest were confirmed as-is" is enough.
+2. The edits are already written to =todo.org=. If Craig keeps =todo.org= open in Emacs, remind him to revert the buffer so his editor picks up the changes (and flag that any unsaved edits he had open could collide — re-running picks up whatever's on disk).
+
+* Common Mistakes
+
+1. *Re-reading =todo.org= per task* — read it once in Phase B; the in-memory view is canonical for the walk.
+2. *Stamping a killed task* — a kill leaves the pool; no =:LAST_REVIEWED:= needed.
+3. *Walking more than the batch* — staleness ordering means the batch is the right set; don't expand it into a full-list review.
+4. *Turning it into planning* — this is hygiene (is each task still right?), not "what should I do today". For that, use =open-tasks.org=.
+5. *Drifting the date format* — =:LAST_REVIEWED:= must be =YYYY-MM-DD=, or the staleness script won't read it.
+6. *Marking a kill DONE instead of CANCELLED* — DONE means finished, CANCELLED means abandoned. A task review kills tasks that shouldn't be done at all.
+
+* Living Document
+
+Adjust the batch size if 7/day feels wrong — smaller for a tighter daily touch, larger to rotate faster. If the action set grows (e.g. a "defer with a date" action), add it to the Phase C table and note the stamping behavior.
diff --git a/claude-templates/.ai/scripts/task-review-staleness.sh b/claude-templates/.ai/scripts/task-review-staleness.sh
index b52cd3d..ed43712 100755
--- a/claude-templates/.ai/scripts/task-review-staleness.sh
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/task-review-staleness.bats b/claude-templates/.ai/scripts/tests/task-review-staleness.bats
index abb7585..488b023 100644
--- a/claude-templates/.ai/scripts/tests/task-review-staleness.bats
+++ b/claude-templates/.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 ]
+}
diff --git a/claude-templates/.ai/workflows/INDEX.org b/claude-templates/.ai/workflows/INDEX.org
index 5a68765..7e65763 100644
--- a/claude-templates/.ai/workflows/INDEX.org
+++ b/claude-templates/.ai/workflows/INDEX.org
@@ -26,6 +26,8 @@ This index must list every =.org= file in =.ai/workflows/= except this one. Star
- =open-tasks.org= — list all open tasks (list mode) or pick the next task (next mode).
- Triggers: "what's next", "what should I work on", "list open tasks", "show me all tasks", "what's on my plate", "show me my tasks", "I need a recommendation"
+- =task-review.org= — daily list-hygiene habit: walk the oldest-unreviewed top-level tasks, re-grade / kill / keep / mark DOING, and stamp =:LAST_REVIEWED:=. Distinct from =open-tasks.org=, which only displays.
+ - Triggers: "task review", "review tasks", "let's do a task review", "groom my tasks", "task-review"
- =daily-prep.org= — prep brief for the next workday. Two modes: full-prep (default) or standup-only.
- Full-prep triggers: "let's prep for tomorrow", "daily prep"
- Standup-only triggers: "what's my standup report", "let's do the daily standup report", "give me the standup brief"
diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org
index fabd607..3503a60 100644
--- a/claude-templates/.ai/workflows/startup.org
+++ b/claude-templates/.ai/workflows/startup.org
@@ -103,6 +103,7 @@ These calls have no dependencies on each other. Issue them all together in one m
8. =cross-agent-status 2>/dev/null || true= — snapshot of pending cross-agent messages across local projects. This is layer A of the cold-start design from =cross-agent-comms.org=: pending messages from other agents (delivered while no session was active here) get surfaced on session start. The =|| true= keeps Phase A from failing if =cross-agent-status= isn't installed yet — older projects without the script still boot cleanly. If HALT is active, =cross-agent-status= prints a banner; surface that prominently in Phase C.
9. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
10. Read =.ai/project-workflows/startup-extras.org= if it exists.
+11. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm.
Notes on the rsync commands:
- Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside.
@@ -131,6 +132,7 @@ This phase touches the user and runs sequentially:
- Surface Active Reminders from notes.org immediately.
- Mention Pending Decisions from notes.org.
- Briefly note significant template updates noticed during sync (new workflows, protocol changes).
+ - *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "=<N>= top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing.
- *Surface pending cross-agent messages.* If =cross-agent-status= reported any pending messages, list them with their =cross-agent-recv= decision (process / query / reject) per file. For =process= messages in this project's inbox, propose handling now or after the current task. For pending in other projects, mention the count so the user knows to switch projects when ready. If HALT was active, surface that prominently — cross-agent activity is paused until =cross-agent-resume= clears it.
2. *Process inbox if non-empty.* Mandatory — don't ask, just do it. For each file: determine action, recommend filing, get approval, move. For =.eml= files use the extract script (not raw Read):
#+begin_src bash
diff --git a/claude-templates/.ai/workflows/task-review.org b/claude-templates/.ai/workflows/task-review.org
new file mode 100644
index 0000000..febb986
--- /dev/null
+++ b/claude-templates/.ai/workflows/task-review.org
@@ -0,0 +1,97 @@
+#+TITLE: Task Review Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-20
+
+* Overview
+
+A short, daily list-hygiene habit. Each run surfaces the handful of top-level tasks that have gone longest without a review, walks them with Craig one at a time, and records the outcome — re-grade, kill, mark in-progress, or just confirm it's still right — stamping =:LAST_REVIEWED:= as it goes.
+
+The point is /trust/: when Craig opens =todo.org=, the priorities and wording should feel accurate. Tasks drift — an =[#A]= from six months ago may not be urgent now, a commitment keeps getting deferred without ever being killed, a body loses the context that made it actionable. Walking a small rotating batch every day keeps the list honest without a big periodic purge.
+
+This is *not* =open-tasks.org=, which just displays the list or picks the next task. This workflow *changes* tasks. Full design and rationale: [[file:../../docs/design/task-review.org][docs/design/task-review.org]].
+
+* When to Use This Workflow
+
+User says one of: "task review", "review tasks", "let's do a task review", "groom my tasks", "task-review".
+
+Cadence is daily and rotational — the batch is the oldest-unreviewed tasks, so consecutive days cover different tasks. At the default batch size of 7 over ~80 open tasks, the full list rotates about every 12 days.
+
+* Phase A: Precondition
+
+Confirm =todo.org= exists at the project root. If it doesn't, say "this project has no =todo.org= to review" and stop — there's nothing to do.
+
+* Phase B: Select the batch
+
+Run the staleness script in list mode to get the oldest-unreviewed top-level tasks. Default batch size is 7; let Craig override if he asks for more or fewer.
+
+#+begin_src bash
+.ai/scripts/task-review-staleness.sh --list todo.org 7
+#+end_src
+
+Each output line is tab-separated: =<line> <LAST_REVIEWED-or-NONE> <heading>=. The list is already ordered oldest-first (never-reviewed tasks sort before dated ones), so walk it top to bottom. Selection only includes depth-2 =**= headings that are =TODO=/=DOING=/=VERIFY= with an =[#A]=/=[#B]=/=[#C]= cookie — DONE/CANCELLED and deeper headings are out by construction.
+
+If the list is empty, report "nothing to review — every task has been looked at recently" and stop.
+
+Read =todo.org= once now so the body of each selected task is in context for the walk. Don't re-read per task.
+
+* Phase C: Walk the batch
+
+Get today's date once for the stamp:
+
+#+begin_src bash
+date "+%Y-%m-%d"
+#+end_src
+
+Then take the tasks one at a time, in list order. For each, show Craig the heading, its priority, when it was last reviewed (or "never"), and a one-line reminder of the body. Then ask what to do. The actions:
+
+| Action | What it does | Stamp =:LAST_REVIEWED:=? |
+|--------+--------------+--------------------------|
+| *Keep* | Still correct as-is — no change beyond recording the review | Yes |
+| *Re-grade A/B/C* | Change the priority cookie to =[#A]= / =[#B]= / =[#C]= | Yes |
+| *Kill* | Abandon it: =TODO=/=DOING= → =CANCELLED= plus a =CLOSED: [YYYY-MM-DD Day]= line | No (it leaves the pool as CANCELLED) |
+| *Doing* | =TODO= → =DOING= (no-op if already DOING/VERIFY) | Yes |
+| *Edit* | Reword the heading or body — do it with Craig, then record the review | Yes |
+| *Skip* | Not now — leave it untouched so it resurfaces next run | No |
+
+Keep is the common case — most tasks are still right and just need re-stamping. Be decisive and quick; this is a 5-minute habit, not a planning session.
+
+*** Stamping =:LAST_REVIEWED:=
+
+Set =:LAST_REVIEWED:= to today's date (from above) in the task's =:PROPERTIES:= drawer:
+
+- If the task already has a =:PROPERTIES:= drawer, update or add the =:LAST_REVIEWED:= line inside it.
+- If it has no drawer, insert one immediately after the heading — or, when the heading carries a =DEADLINE:=/=SCHEDULED:=/=CLOSED:= planning line, immediately after that line (org requires the planning line to sit directly under the heading, with the drawer below it):
+
+#+begin_example
+** TODO [#A] Some topic :tag:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-05-20
+:END:
+Body...
+#+end_example
+
+The exact date string matters: =task-review-staleness.sh= and the wrap-up health check both parse =:LAST_REVIEWED: YYYY-MM-DD=.
+
+*** Killing a task
+
+Follow the completion rules in [[file:../../claude-rules/todo-format.md][todo-format.md]]. A killed top-level =**= task stays task-shaped: change the keyword to =CANCELLED=, add a =CLOSED: [YYYY-MM-DD Day]= line under the heading (generate with =date "+%Y-%m-%d %a"=), and leave the priority and tags intact. It's then a candidate for =--archive-done= at the next cleanup. Don't stamp =:LAST_REVIEWED:= on a kill — it's leaving the review pool anyway.
+
+* Phase D: Close out
+
+When the batch is done (or Craig calls it early):
+
+1. Summarize what changed — re-grades, kills, anything marked DOING — in a couple of lines. Don't list the keeps individually; "the rest were confirmed as-is" is enough.
+2. The edits are already written to =todo.org=. If Craig keeps =todo.org= open in Emacs, remind him to revert the buffer so his editor picks up the changes (and flag that any unsaved edits he had open could collide — re-running picks up whatever's on disk).
+
+* Common Mistakes
+
+1. *Re-reading =todo.org= per task* — read it once in Phase B; the in-memory view is canonical for the walk.
+2. *Stamping a killed task* — a kill leaves the pool; no =:LAST_REVIEWED:= needed.
+3. *Walking more than the batch* — staleness ordering means the batch is the right set; don't expand it into a full-list review.
+4. *Turning it into planning* — this is hygiene (is each task still right?), not "what should I do today". For that, use =open-tasks.org=.
+5. *Drifting the date format* — =:LAST_REVIEWED:= must be =YYYY-MM-DD=, or the staleness script won't read it.
+6. *Marking a kill DONE instead of CANCELLED* — DONE means finished, CANCELLED means abandoned. A task review kills tasks that shouldn't be done at all.
+
+* Living Document
+
+Adjust the batch size if 7/day feels wrong — smaller for a tighter daily touch, larger to rotate faster. If the action set grows (e.g. a "defer with a date" action), add it to the Phase C table and note the stamping behavior.