aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/todo-cleanup.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-14 07:29:57 -0500
committerCraig Jennings <c@cjennings.net>2026-05-14 07:29:57 -0500
commit99252c04a5a0113947681dfb4276116d4aa69983 (patch)
treefbbbbdc64a92a02e3a9e36144dcece7cc9b7bd82 /.ai/scripts/todo-cleanup.el
parent3abac9b797e31429b55cd08f5d102275c25b75a4 (diff)
downloadrulesets-99252c04a5a0113947681dfb4276116d4aa69983.tar.gz
rulesets-99252c04a5a0113947681dfb4276116d4aa69983.zip
feat(todo-cleanup): add --sync-child-priority mode for drifted children
When a parent task in todo.org gets reprioritized, its children frequently keep their original (lower) priority cookies, which then mismatches the parent's new importance. The new mode walks every heading with a priority cookie and bumps any direct child whose own cookie is lower (D ranks below A in org's default scheme). Down-only: parents are never bumped up to a child's priority. Priority-less parents and priority-less children are both left alone — sync does not invent priorities. Children opt out by carrying the :no-sync: literal tag, useful for Follow-up:/Spike: sub-tasks that are deliberately deprioritized. The tag match is literal regex against the heading line rather than going through org-get-tags, because org's default tag character class excludes hyphens — :no-sync: would not be parsed as a real tag in batch mode without a custom org-tag-re. org-map-entries visits headings in document order, so a multi-level chain [#A] → [#B] → [#D] collapses to the top priority in one pass: the middle bumps to [#A] before the walk reaches the leaf. wrap-it-up.org Step 3 now invokes --sync-child-priority after --archive-done. --check-child-priority is the report-only alias (--sync-child-priority --check) for previewing before applying. Default cadence is auto-apply, same as --archive-done.
Diffstat (limited to '.ai/scripts/todo-cleanup.el')
-rw-r--r--.ai/scripts/todo-cleanup.el180
1 files changed, 169 insertions, 11 deletions
diff --git a/.ai/scripts/todo-cleanup.el b/.ai/scripts/todo-cleanup.el
index 4988db0..97be96e 100644
--- a/.ai/scripts/todo-cleanup.el
+++ b/.ai/scripts/todo-cleanup.el
@@ -1,12 +1,14 @@
;;; todo-cleanup.el --- Auto-fix and audit for todo.org hygiene -*- lexical-binding: t; -*-
;;
;; Usage:
-;; emacs --batch -q -l todo-cleanup.el todo.org # apply hygiene fixes in place
-;; emacs --batch -q -l todo-cleanup.el --check todo.org # hygiene report only
-;; emacs --batch -q -l todo-cleanup.el --archive-done todo.org # archive completed subtrees
-;; emacs --batch -q -l todo-cleanup.el --archive-done --check todo.org # preview the archive
+;; emacs --batch -q -l todo-cleanup.el todo.org # apply hygiene fixes in place
+;; emacs --batch -q -l todo-cleanup.el --check todo.org # hygiene report only
+;; emacs --batch -q -l todo-cleanup.el --archive-done todo.org # archive completed subtrees
+;; emacs --batch -q -l todo-cleanup.el --archive-done --check todo.org # preview the archive
+;; emacs --batch -q -l todo-cleanup.el --sync-child-priority todo.org # bump children whose priority drifted below the parent's
+;; emacs --batch -q -l todo-cleanup.el --check-child-priority todo.org # preview the sync (same as --sync-child-priority --check)
;;
-;; Two independent modes:
+;; Three independent modes:
;;
;; * Default (hygiene). Designed for the wrap-it-up workflow: cheap, idempotent,
;; safe to run every session.
@@ -31,6 +33,19 @@
;; skipped with a message. Only direct level-2 children move — a DONE entry
;; nested under an open parent stays put. Archiving is consequential, so it's
;; never run by default; it does *not* also run the hygiene passes.
+;;
+;; * --sync-child-priority (opt-in). Walks every heading with a priority cookie
+;; ([#A]-[#D]) and, for each of its direct child headings whose own priority
+;; is lower (later in the alphabet — D is lower than A), bumps the child's
+;; cookie to match the parent's. Down-only: parents are never adjusted to
+;; match a child. Children with no priority cookie at all are left alone, as
+;; are parents with no priority cookie. A child can opt out of being bumped
+;; by carrying the `:no-sync:' tag — useful for `Follow-up:'/`Spike:' children
+;; that are deliberately deprioritized. Because the walk visits parents
+;; before their descendants in document order, a multi-level chain
+;; ([#A] → [#B] → [#D]) collapses to the top priority in a single pass.
+;; --check-child-priority is the report-only alias for --sync-child-priority
+;; --check.
(require 'org)
(require 'cl-lib)
@@ -41,11 +56,19 @@
(defconst tc-done-states '("DONE" "CANCELLED")
"TODO keywords that mark an entry as completed for `--archive-done'.")
+(defconst tc--priority-cookie-regexp "\\[#\\([A-Z]\\)\\]"
+ "Regexp matching an org priority cookie. Match group 1 is the letter.")
+
+(defconst tc-no-sync-tag "no-sync"
+ "Org tag a child heading carries to opt out of `--sync-child-priority'.")
+
(defvar tc-fixes 0)
(defvar tc-archived 0)
+(defvar tc-bumped 0)
(defvar tc-issues nil)
(defvar tc-check-only nil)
(defvar tc-archive-done nil)
+(defvar tc-sync-child-priority nil)
(defvar tc-current-file nil)
;;; ---------------------------------------------------------------------------
@@ -227,18 +250,123 @@ are reported but not performed."
tc-issues)))))))))
;;; ---------------------------------------------------------------------------
+;;; --sync-child-priority mode
+
+(defun tc--heading-priority-letter ()
+ "Return the priority letter (a character) on the heading at point, or nil
+if the heading has no priority cookie."
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((eol (line-end-position)))
+ (when (re-search-forward tc--priority-cookie-regexp eol t)
+ (string-to-char (match-string 1))))))
+
+(defun tc--priority-lower-p (child parent)
+ "Non-nil when CHILD priority letter ranks lower than PARENT — i.e. later in
+the alphabet, since A is highest in org's default priority scheme."
+ (and child parent (> child parent)))
+
+(defun tc--heading-has-no-sync-tag-p ()
+ "Non-nil when the heading line at point carries `:no-sync:' as a trailing
+tag-style marker. Uses a literal regex match rather than `org-get-tags'
+because org's default tag character class (`org-tag-re') excludes hyphens —
+`no-sync' isn't recognized as a real org tag in batch mode unless the user
+has extended that regex. The literal `:no-sync:' is what wrap-up sessions
+actually type, so match it directly: any `:no-sync:' preceded by whitespace
+on the heading line counts."
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((line (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position))))
+ (string-match-p (format "[ \t]:%s:" (regexp-quote tc-no-sync-tag))
+ line))))
+
+(defun tc--set-heading-priority (letter)
+ "Rewrite the priority cookie on the heading at point to LETTER (a character)."
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((eol (line-end-position)))
+ (when (re-search-forward tc--priority-cookie-regexp eol t)
+ (replace-match (format "[#%c]" letter) t t)))))
+
+(defun tc--direct-children-of-current-heading ()
+ "Return heading positions (beginning of line) of the direct children of the
+heading at point, in document order. Direct children = headings exactly one
+level deeper than the parent."
+ (save-excursion
+ (org-back-to-heading t)
+ (let* ((parent-level (org-current-level))
+ (child-level (1+ parent-level))
+ (subtree-end (save-excursion (org-end-of-subtree t t) (point)))
+ (positions nil))
+ (forward-line 1)
+ (while (re-search-forward "^\\(\\*+\\)[ \t]" subtree-end t)
+ (let ((lvl (length (match-string 1)))
+ (pos (match-beginning 0)))
+ (when (= lvl child-level)
+ (push pos positions))))
+ (nreverse positions))))
+
+(defun tc-sync-child-priority-at-heading ()
+ "If the heading at point carries a priority cookie, bump any direct child
+heading whose own priority is lower, skipping children tagged
+`tc-no-sync-tag'. A priority-less parent is a no-op; priority-less children
+are left untouched (down-only does not invent priorities)."
+ (let ((parent (tc--heading-priority-letter)))
+ (when parent
+ (let ((parent-heading (org-get-heading t t t t)))
+ (dolist (child-pos (tc--direct-children-of-current-heading))
+ (save-excursion
+ (goto-char child-pos)
+ (let ((child (tc--heading-priority-letter)))
+ (when (and child
+ (tc--priority-lower-p child parent)
+ (not (tc--heading-has-no-sync-tag-p)))
+ (let ((child-heading (org-get-heading t t t t))
+ (child-line (line-number-at-pos)))
+ (cl-incf tc-bumped)
+ (if tc-check-only
+ (push (list :kind 'sync-would
+ :file tc-current-file
+ :line child-line
+ :child-heading child-heading
+ :parent-heading parent-heading
+ :from (char-to-string child)
+ :to (char-to-string parent))
+ tc-issues)
+ (tc--set-heading-priority parent)
+ (push (list :kind 'sync-bumped
+ :file tc-current-file
+ :line child-line
+ :child-heading child-heading
+ :parent-heading parent-heading
+ :from (char-to-string child)
+ :to (char-to-string parent))
+ tc-issues)))))))))))
+
+(defun tc-sync-child-priority-in-file ()
+ "Walk every heading in the buffer and run `tc-sync-child-priority-at-heading'.
+`org-map-entries' visits headings in document order, so parents are bumped
+before their descendants — a [#A] → [#B] → [#D] chain collapses in one pass."
+ (org-map-entries #'tc-sync-child-priority-at-heading nil 'file))
+
+;;; ---------------------------------------------------------------------------
;;; Driver + reporting
(defun tc-process-file (file)
(setq tc-current-file (file-name-nondirectory file))
(with-current-buffer (find-file-noselect file)
(org-mode)
- (if tc-archive-done
- (tc-archive-done-in-file)
+ (cond
+ (tc-archive-done
+ (tc-archive-done-in-file))
+ (tc-sync-child-priority
+ (tc-sync-child-priority-in-file))
+ (t
;; Pass 1: auto-fix bogus state logs (or report under --check).
(org-map-entries #'tc-fix-bogus-state-log-in-entry nil 'file)
;; Pass 2: detect orphan planning lines (always report-only).
- (org-map-entries #'tc-detect-orphan-planning-in-entry nil 'file))
+ (org-map-entries #'tc-detect-orphan-planning-in-entry nil 'file)))
(when (and (not tc-check-only) (buffer-modified-p))
(save-buffer))))
@@ -287,8 +415,26 @@ are reported but not performed."
(plist-get i :heading)
(plist-get i :detail)))))))
+(defun tc--emit-sync-report ()
+ (princ (format "todo-cleanup --sync-child-priority: %d child priority cookie(s) %s%s\n"
+ tc-bumped
+ (if tc-check-only "would bump" "bumped")
+ (if tc-check-only " — CHECK MODE (no writes)" "")))
+ (dolist (i (reverse tc-issues))
+ (pcase (plist-get i :kind)
+ ((or 'sync-bumped 'sync-would)
+ (princ (format " %s:%d: [#%s] → [#%s] %s (under: %s)\n"
+ (plist-get i :file)
+ (plist-get i :line)
+ (plist-get i :from)
+ (plist-get i :to)
+ (plist-get i :child-heading)
+ (plist-get i :parent-heading)))))))
+
(defun tc-emit-report ()
- (if tc-archive-done (tc--emit-archive-report) (tc--emit-hygiene-report)))
+ (cond (tc-archive-done (tc--emit-archive-report))
+ (tc-sync-child-priority (tc--emit-sync-report))
+ (t (tc--emit-hygiene-report))))
(defun tc-main ()
;; Strip our flags from `command-line-args-left' so emacs's own arg parser
@@ -299,9 +445,17 @@ are reported but not performed."
(when (member "--archive-done" command-line-args-left)
(setq tc-archive-done t)
(setq command-line-args-left (delete "--archive-done" command-line-args-left)))
+ (when (member "--sync-child-priority" command-line-args-left)
+ (setq tc-sync-child-priority t)
+ (setq command-line-args-left (delete "--sync-child-priority" command-line-args-left)))
+ ;; --check-child-priority is the report-only alias for
+ ;; `--sync-child-priority --check'.
+ (when (member "--check-child-priority" command-line-args-left)
+ (setq tc-sync-child-priority t tc-check-only t)
+ (setq command-line-args-left (delete "--check-child-priority" command-line-args-left)))
(if (null command-line-args-left)
(progn
- (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done] FILE...\n")
+ (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --sync-child-priority | --check-child-priority] FILE...\n")
(kill-emacs 1))
(let ((files command-line-args-left))
(setq command-line-args-left nil)
@@ -318,7 +472,11 @@ during a test run the trailing args are things like `-f
ert-run-tests-batch-and-exit'."
(and command-line-args-left
(cl-every (lambda (a)
- (cond ((member a '("--check" "--archive-done")) t)
+ (cond ((member a '("--check"
+ "--archive-done"
+ "--sync-child-priority"
+ "--check-child-priority"))
+ t)
((string-prefix-p "-" a) nil)
(t (file-readable-p a))))
command-line-args-left)))