diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-14 07:29:57 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-14 07:29:57 -0500 |
| commit | 99252c04a5a0113947681dfb4276116d4aa69983 (patch) | |
| tree | fbbbbdc64a92a02e3a9e36144dcece7cc9b7bd82 /.ai/scripts/todo-cleanup.el | |
| parent | 3abac9b797e31429b55cd08f5d102275c25b75a4 (diff) | |
| download | rulesets-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.el | 180 |
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))) |
