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 | |
| 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.
| -rw-r--r-- | .ai/scripts/tests/test-todo-cleanup.el | 181 | ||||
| -rw-r--r-- | .ai/scripts/todo-cleanup.el | 180 | ||||
| -rw-r--r-- | .ai/workflows/wrap-it-up.org | 26 |
3 files changed, 373 insertions, 14 deletions
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el index 9d11b5c..2a9184d 100644 --- a/.ai/scripts/tests/test-todo-cleanup.el +++ b/.ai/scripts/tests/test-todo-cleanup.el @@ -30,8 +30,16 @@ ;;; Harness (defun tc-test--reset (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-issues nil - tc-check-only (and check t) tc-archive-done t tc-current-file nil)) + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + tc-check-only (and check t) + tc-archive-done t tc-sync-child-priority nil + tc-current-file nil)) + +(defun tc-test--reset-sync (&optional check) + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + tc-check-only (and check t) + tc-archive-done nil tc-sync-child-priority t + tc-current-file nil)) (defun tc-test--drop-buffer (file) (let ((buf (find-buffer-visiting file))) @@ -315,5 +323,174 @@ from the heading line through (not including) the next level-1 heading or EOF." ;; something actually moved (should (> (plist-get out :archived) 0))))) +;;; --------------------------------------------------------------------------- +;;; Sync-child-priority harness + fixtures + +(defun tc-test--sync (content &optional runs check) + "Write CONTENT to a temp .org file, run `--sync-child-priority' RUNS times +\(default 1\). Return a plist: :result final file contents, :bumped count from +the last run, :issues from the last run. CHECK non-nil ⇒ --check (preview)." + (let ((file (make-temp-file "tc-test-sync-" nil ".org")) + last-bumped last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset-sync check) + (tc-process-file file) + (setq last-bumped tc-bumped last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :bumped last-bumped + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +(defun tc-test--priority-of (content heading-substring) + "Return the priority letter (a string like \"A\") on the first heading line +in CONTENT that contains HEADING-SUBSTRING, or nil if the heading has no +priority cookie." + (with-temp-buffer + (insert content) + (goto-char (point-min)) + (let (found-line found-prio) + (while (and (not found-line) (re-search-forward "^\\*+ .*$" nil t)) + (let ((line (match-string 0))) + (when (string-match-p (regexp-quote heading-substring) line) + (setq found-line line) + (when (string-match "\\[#\\([A-Z]\\)\\]" line) + (setq found-prio (match-string 1 line)))))) + (unless found-line + (error "no heading containing %S" heading-substring)) + found-prio))) + +(defun tc-test--sync-bumped-headings (issues) + "Return the heading texts of every `:kind' sync-bumped or sync-would entry +in ISSUES, in document order." + (mapcar (lambda (i) (plist-get i :child-heading)) + (cl-remove-if-not + (lambda (i) (memq (plist-get i :kind) '(sync-bumped sync-would))) + (reverse issues)))) + +(defconst tc-test--sync-basic "\ +* Open Work +** TODO [#B] Parent +*** TODO [#D] Drifted child +*** TODO [#B] Already in sync +") + +(defconst tc-test--sync-multi "\ +* Open Work +** TODO [#B] Parent +*** TODO [#A] Higher-priority child stays +*** TODO [#B] Equal-priority child stays +*** TODO [#C] Lower-priority child bumps +*** TODO [#D] Way-lower-priority child bumps +*** TODO Priority-less child stays +") + +(defconst tc-test--sync-no-sync-tag "\ +* Open Work +** TODO [#B] Parent +*** TODO [#D] Regular drifted child +*** TODO [#D] Follow-up: opted-out :no-sync: +") + +(defconst tc-test--sync-priority-less-parent "\ +* Open Work +** TODO Parent with no priority +*** TODO [#D] Child with priority should not move +") + +(defconst tc-test--sync-cascade "\ +* Open Work +** TODO [#A] Top +*** TODO [#B] Middle +**** TODO [#D] Leaf +") + +(defconst tc-test--sync-no-change "\ +* Open Work +** TODO [#B] Parent +*** TODO [#A] Child higher +*** TODO [#B] Child equal +") + +;;; --------------------------------------------------------------------------- +;;; Sync-child-priority tests + +(ert-deftest tc-sync-bumps-lower-priority-child () + (let* ((out (tc-test--sync tc-test--sync-basic)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Drifted child"))) + (should (equal "B" (tc-test--priority-of res "Already in sync"))) + (should (equal "B" (tc-test--priority-of res "Parent"))))) + +(ert-deftest tc-sync-leaves-higher-and-equal-children-alone () + (let* ((out (tc-test--sync tc-test--sync-multi)) + (res (plist-get out :result))) + (should (= 2 (plist-get out :bumped))) + (should (equal "A" (tc-test--priority-of res "Higher-priority child"))) + (should (equal "B" (tc-test--priority-of res "Equal-priority child"))) + (should (equal "B" (tc-test--priority-of res "Lower-priority child"))) + (should (equal "B" (tc-test--priority-of res "Way-lower-priority child"))) + (should-not (tc-test--priority-of res "Priority-less child")))) + +(ert-deftest tc-sync-skips-no-sync-tagged-child () + (let* ((out (tc-test--sync tc-test--sync-no-sync-tag)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :bumped))) + (should (equal "B" (tc-test--priority-of res "Regular drifted child"))) + (should (equal "D" (tc-test--priority-of res "Follow-up: opted-out"))))) + +(ert-deftest tc-sync-leaves-priority-less-parent-alone () + (let ((out (tc-test--sync tc-test--sync-priority-less-parent))) + (should (= 0 (plist-get out :bumped))) + (should (equal tc-test--sync-priority-less-parent (plist-get out :result))))) + +(ert-deftest tc-sync-cascades-through-multiple-levels () + (let* ((out (tc-test--sync tc-test--sync-cascade)) + (res (plist-get out :result))) + ;; one pass should collapse [#A] → [#B] → [#D] to all [#A] because + ;; org-map-entries visits the parent first, bumps the middle, then visits + ;; the (now bumped) middle and bumps its leaf + (should (= 2 (plist-get out :bumped))) + (should (equal "A" (tc-test--priority-of res "Top"))) + (should (equal "A" (tc-test--priority-of res "Middle"))) + (should (equal "A" (tc-test--priority-of res "Leaf"))))) + +(ert-deftest tc-sync-no-change-when-all-children-at-or-above-parent () + (let ((out (tc-test--sync tc-test--sync-no-change))) + (should (= 0 (plist-get out :bumped))) + (should (equal tc-test--sync-no-change (plist-get out :result))))) + +(ert-deftest tc-sync-is-idempotent () + (dolist (fixture (list tc-test--sync-basic + tc-test--sync-multi + tc-test--sync-no-sync-tag + tc-test--sync-priority-less-parent + tc-test--sync-cascade + tc-test--sync-no-change)) + (let ((once (plist-get (tc-test--sync fixture 1) :result)) + (twice (plist-get (tc-test--sync fixture 2) :result))) + (should (equal once twice))))) + +(ert-deftest tc-sync-check-mode-previews-without-writing () + (let ((out (tc-test--sync tc-test--sync-basic 1 t))) + (should (= 1 (plist-get out :bumped))) + (should (equal tc-test--sync-basic (plist-get out :result))) + (should (member "Drifted child" + (tc-test--sync-bumped-headings (plist-get out :issues)))))) + +(ert-deftest tc-sync-check-mode-is-idempotent () + (let ((once (tc-test--sync tc-test--sync-cascade 1 t)) + (twice (tc-test--sync tc-test--sync-cascade 2 t))) + (should (equal tc-test--sync-cascade (plist-get once :result))) + (should (equal tc-test--sync-cascade (plist-get twice :result))) + (should (= 2 (plist-get once :bumped))) + (should (= 2 (plist-get twice :bumped))))) + (provide 'test-todo-cleanup) ;;; test-todo-cleanup.el ends here 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))) diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org index 5c1af26..03ee520 100644 --- a/.ai/workflows/wrap-it-up.org +++ b/.ai/workflows/wrap-it-up.org @@ -107,6 +107,30 @@ Preview the moves without writing: emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done --check todo.org #+end_src +*** Sync child priorities + +#+begin_src bash +[ -f todo.org ] && emacs --batch -q -l .ai/scripts/todo-cleanup.el --sync-child-priority todo.org +#+end_src + +=--sync-child-priority= walks every heading with a priority cookie =[#A]=–=[#D]= and, for each of its direct child headings whose own priority cookie is /lower/ (later in the alphabet — D is below A), bumps the child to match the parent. Down-only: parents are never bumped up to match a higher-priority child. Children without a priority cookie are left alone, as are parents without one. The walk visits parents before descendants, so a multi-level chain (=[#A]= → =[#B]= → =[#D]=) collapses to the top priority in a single pass. Idempotent. + +Opt-out for deliberately-lower children: tag the heading =:no-sync:= (the literal six-character tag, including the hyphen). The script matches the tag literally on the heading line, so it works whether or not the surrounding emacs config has extended =org-tag-re= to allow hyphens. + +#+begin_example +*** TODO [#D] Follow-up: VAD :no-sync: +#+end_example + +Use this for =Follow-up:=, =Spike:=, =Stretch:= sub-tasks that are deliberately deprioritized below their parent — without the tag, the wrap-up would silently bump them back up. + +Preview the bumps without writing: + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --check-child-priority todo.org +#+end_src + +(=--check-child-priority= is the report-only alias for =--sync-child-priority --check=.) + ** Step 3.5: Linear ticket-state hygiene (skip if project doesn't use Linear) If the project uses Linear and has any tickets currently in *Dev Review* assigned to Craig, sweep them before the wrap-up commit. The check is fast and keeps the board honest — tickets stuck in Dev Review after their PR merges hide actual work-in-progress. @@ -288,7 +312,7 @@ Before considering wrap-up complete: - [ ] =.ai/session-context.org= =* Summary= section populated - [ ] File renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= - [ ] =.ai/session-context.org= no longer exists -- [ ] =todo-cleanup.el= ran — hygiene pass + =--archive-done= (if =todo.org= exists at project root) +- [ ] =todo-cleanup.el= ran — hygiene pass + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root) - [ ] Any orphan-planning-line warnings reviewed (fix or accept) - [ ] Linear Dev-Review sweep ran; any merged-PR tickets moved to Done or PM Acceptance (skip if project doesn't use Linear) - [ ] After wrap-up commit + push, =git status --short= is empty OR every remaining line has an explicit user-deferred decision logged in the valediction |
