diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-18 21:03:50 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-18 21:03:50 -0500 |
| commit | ddbd47fbbea06e71456b7bc4228626d3a126bf28 (patch) | |
| tree | 06211963ee5fbb85b8c9eb0d4f2b860e5c74dfa1 /.ai/scripts/todo-cleanup.el | |
| parent | d2b1bef686fd6c22a6303c13829f88bdd110fa7b (diff) | |
| download | rulesets-ddbd47fbbea06e71456b7bc4228626d3a126bf28.tar.gz rulesets-ddbd47fbbea06e71456b7bc4228626d3a126bf28.zip | |
feat(todo-cleanup): dated-seal archiving and planning-line strip on convert
Two batched changes to the todo hygiene tooling.
Dated-seal archiving: --archive-done aging now keeps one month in-file (retain default 7 → 31, named as tc-archive-retain-days-default) and a new --seal renames the working task-archive.org to resolved-YYYY-MM-DD.org beside it, letting the next aging start a fresh working file. Dating the sealed file by the seal run rather than the calendar quarter is what avoids mislabeling a task closed late in a quarter and archived after the boundary. The unparseable-CLOSED case is now explicit in the contract: a keyword-complete task with no readable close date ages out too. The sealed file inherits the todo file's gitignore status.
Planning-line strip: --convert-subtasks now drops the whole planning line when it rewrites a done sub-task to a dated event-log entry, not just the CLOSED cookie. A dated-log heading carries its date in the heading, so an active SCHEDULED/DEADLINE left on it pins the finished entry to the agenda as weeks-overdue forever (org renders any active planning timestamp, keyword or not). New lint-org checker dated-log-heading-active-timestamp backstops any that predate this. todo-format.md's sub-task and VERIFY completion rules gain the strip step.
Diffstat (limited to '.ai/scripts/todo-cleanup.el')
| -rw-r--r-- | .ai/scripts/todo-cleanup.el | 187 |
1 files changed, 153 insertions, 34 deletions
diff --git a/.ai/scripts/todo-cleanup.el b/.ai/scripts/todo-cleanup.el index bd8166d..c4a87de 100644 --- a/.ai/scripts/todo-cleanup.el +++ b/.ai/scripts/todo-cleanup.el @@ -5,6 +5,8 @@ ;; 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 --seal todo.org # seal the working archive to resolved-YYYY-MM-DD.org +;; emacs --batch -q -l todo-cleanup.el --seal --check todo.org # preview the seal ;; emacs --batch -q -l todo-cleanup.el --convert-subtasks todo.org # dated-rewrite done level-3+ sub-tasks ;; emacs --batch -q -l todo-cleanup.el --convert-subtasks --check todo.org # preview the conversion ;; emacs --batch -q -l todo-cleanup.el --sync-child-priority todo.org # bump children whose priority drifted below the parent's @@ -37,23 +39,37 @@ ;; a message. Only direct level-2 children move — a DONE entry nested under ;; an open parent stays put. ;; -;; 2. Ages the "Resolved" section: a level-2 DONE/CANCELLED subtree whose -;; CLOSED date is older than `tc-archive-retain-days' (default 7) is moved +;; 2. Ages the "Resolved" section: a level-2 DONE/CANCELLED subtree is moved ;; out to `tc-archive-file' (default `archive/task-archive.org' beside the -;; todo file), keeping only the last week of closed tasks in the file -;; itself. Only subtrees closed within the window stay; older ones, and -;; those with no parseable CLOSED date, are moved out. Set -;; `tc-archive-retain-days' to nil to disable this step (legacy in-file-only -;; behavior). The aging date is `tc-archive-reference-date' when set -;; (tests), otherwise the real current date. The archive inherits the todo -;; file's gitignore status: when the todo file is gitignored, the archive -;; path is added to .gitignore before the first write, so private task -;; history never lands in a tracked path (see +;; todo file) when its CLOSED date is older than `tc-archive-retain-days' +;; (default 31 — one month) OR its CLOSED date can't be parsed. The last +;; month of closed tasks stays browsable in the file itself; older ones age +;; out. The unparseable-CLOSED case archives too, deliberately: a +;; keyword-complete task with no readable close date is cruft, not live +;; work. Set `tc-archive-retain-days' to nil to disable this step (legacy +;; in-file-only behavior). The aging date is `tc-archive-reference-date' +;; when set (tests), otherwise the real current date. The archive inherits +;; the todo file's gitignore status: when the todo file is gitignored, the +;; archive path is added to .gitignore before the first write, so private +;; task history never lands in a tracked path (see ;; `tc--ensure-archive-gitignored'). ;; ;; Archiving is consequential, so it's never run by default; it does *not* ;; also run the hygiene passes. ;; +;; * --seal (opt-in). Renames the working archive file (`tc-archive-file', +;; default `archive/task-archive.org') to `resolved-YYYY-MM-DD.org' beside it, +;; dated by the seal run, and leaves the next `--archive-done' to recreate a +;; fresh working file. The dated file means "everything sealed as of that +;; date" — not a calendar quarter — so a task closed late in a quarter and +;; archived after the boundary is never mislabeled; cadence (e.g. quarterly) +;; becomes independent of correctness and any slip is harmless. The sealed +;; file inherits the todo file's gitignore status the same way the working +;; archive does. A no-op (reported) when there's no working archive to seal; +;; refuses to clobber an existing `resolved-<today>.org'. Honors `--check'. +;; The seal date is `tc-archive-reference-date' when set (tests), otherwise the +;; real current date. +;; ;; * --convert-subtasks (opt-in). Rewrites every level-3-and-deeper heading whose ;; TODO state is DONE/CANCELLED/FAILED into a dated event-log entry ;; (`<stars> YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <text>'), dropping the keyword, @@ -102,6 +118,17 @@ sub-task is terminal too and belongs in the parent's dated history.") (defconst tc--priority-cookie-regexp "\\[#\\([A-Z]\\)\\]" "Regexp matching an org priority cookie. Match group 1 is the letter.") +(defconst tc--planning-cookie-regexp + "\\(?:CLOSED\\|DEADLINE\\|SCHEDULED\\):[ \t]*[[<][^]>\n]*[]>]" + "One org planning cookie: a CLOSED/DEADLINE/SCHEDULED keyword followed by a +bracketed (inactive) or angled (active) timestamp.") + +(defconst tc--planning-line-regexp + (concat "\\`[ \t]*\\(?:" tc--planning-cookie-regexp "[ \t]*\\)+\\'") + "A whole org planning line: nothing but planning cookies and whitespace. +Anchored to a single line's contents so a line mixing a cookie with real body +text is never matched.") + (defconst tc-no-sync-tag "no-sync" "Org tag that opts a heading and all its descendants out of `--sync-child-priority'. Inherits down: a tag on an ancestor counts for @@ -112,20 +139,30 @@ every heading below it.") (defvar tc-bumped 0) (defvar tc-converted 0) (defvar tc-issues nil) +(defvar tc-sealed 0) (defvar tc-check-only nil) (defvar tc-archive-done nil) (defvar tc-sync-child-priority nil) (defvar tc-convert-subtasks nil) +(defvar tc-seal nil) (defvar tc-current-file nil) (defvar tc-current-dir nil) (defvar tc-archived-to-file 0) -(defvar tc-archive-retain-days 7 +(defconst tc-archive-retain-days-default 31 + "Default retention window (days) for the `--archive-done' file-aging step — +one month. A closed Resolved subtree stays in-file for this long before it ages +out to `tc-archive-file'; the last month of resolved work stays browsable in the +todo file itself. Named so the \"one month\" contract is explicit and testable.") + +(defvar tc-archive-retain-days tc-archive-retain-days-default "Retention window for the `--archive-done' file-aging step. A closed Resolved subtree whose CLOSED date is within this many days of the reference date stays in the in-file Resolved section; an older one is moved out to `tc-archive-file'. -A subtree with no parseable CLOSED date stays. nil disables the aging step -entirely, leaving the legacy in-file-only behavior.") +A subtree with no parseable CLOSED date is aged out too (a keyword-complete task +with no readable close date is cruft, not live work). nil disables the aging +step entirely, leaving the legacy in-file-only behavior. Defaults to +`tc-archive-retain-days-default' (one month).") (defvar tc-archive-reference-date nil "(YEAR MONTH DAY) treated as \"today\" when aging Resolved subtrees out to a @@ -479,6 +516,51 @@ step. Honors `tc-check-only' (report only)." tc-issues)))))))))) ;;; --------------------------------------------------------------------------- +;;; --seal mode: rename the working archive to a dated resolved-YYYY-MM-DD.org + +(defun tc--seal-date-string () + "YYYY-MM-DD for the seal — `tc-archive-reference-date' when set (tests), +otherwise the real current date." + (if tc-archive-reference-date + (pcase-let ((`(,y ,m ,d) tc-archive-reference-date)) + (format "%04d-%02d-%02d" y m d)) + (format-time-string "%Y-%m-%d"))) + +(defun tc-seal-archive-file () + "Rename the working archive file to `resolved-YYYY-MM-DD.org' beside it. +The next `--archive-done' run recreates a fresh working file. No-op (reported) +when there is no working archive to seal; refuses to clobber an existing +`resolved-<today>.org'. Ensures the sealed file inherits the todo file's +gitignore status. Honors `tc-check-only'." + (let ((path (tc--archive-file-path))) + (cond + ((or (null path) (not (file-readable-p path))) + (push (list :kind 'seal-nothing :file tc-current-file) tc-issues)) + (t + (let* ((dir (file-name-directory path)) + (sealed (expand-file-name + (format "resolved-%s.org" (tc--seal-date-string)) dir))) + (cond + ((file-exists-p sealed) + (push (list :kind 'seal-collision :file tc-current-file + :detail (file-name-nondirectory sealed)) + tc-issues)) + (tc-check-only + (cl-incf tc-sealed) + (push (list :kind 'seal-would :file tc-current-file + :detail (file-name-nondirectory sealed)) + tc-issues)) + (t + ;; Ignore the sealed name before the rename so its history stays as + ;; private as the working archive it derives from. + (tc--ensure-archive-gitignored sealed) + (rename-file path sealed) + (cl-incf tc-sealed) + (push (list :kind 'seal-done :file tc-current-file + :detail (file-name-nondirectory sealed)) + tc-issues)))))))) + +;;; --------------------------------------------------------------------------- ;;; --sync-child-priority mode (defun tc--heading-priority-letter () @@ -617,6 +699,14 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas ;; as written). Idempotent: an already-dated heading has no done keyword, so it ;; is skipped. A done sub-task with no parseable CLOSED cookie can't be dated, so ;; it is flagged and left alone rather than stamped with a fabricated date. +;; +;; The planning line goes entirely. A dated-log entry carries its date in the +;; heading, so CLOSED is redundant and an active DEADLINE/SCHEDULED is wrong: org +;; renders any headline with an active planning timestamp — keyword or not — so a +;; SCHEDULED left on a dated-log heading pins it to the agenda as weeks-overdue +;; long after the work is done. The conversion deletes the whole planning line, +;; not just the CLOSED cookie (todo-format.md; lint checker +;; `dated-log-heading-active-timestamp' backstops any that slip through). (defun tc--closed-parts-in-entry () "Return a plist (:year :month :day :dow :hour :minute) from the CLOSED cookie @@ -676,6 +766,27 @@ in-progress `org-map-entries' walk; markers track their headings across edits." nil 'file) (nreverse targets))) +(defun tc--strip-planning-lines-in-entry () + "Delete the canonical planning line(s) directly under the heading at point. +A planning line is one composed solely of CLOSED/DEADLINE/SCHEDULED cookies and +whitespace. Walks the lines immediately after the heading and stops at the first +non-planning line, so a planning-shaped line deeper in the body (e.g. in a code +block) is never touched. Returns the count of lines removed." + (save-excursion + (org-back-to-heading t) + (forward-line 1) + (let ((removed 0) (continue t)) + (while (and continue (not (eobp))) + (let ((line (buffer-substring-no-properties + (line-beginning-position) (line-end-position)))) + (if (string-match-p tc--planning-line-regexp line) + (progn + (delete-region (line-beginning-position) + (min (1+ (line-end-position)) (point-max))) + (cl-incf removed)) + (setq continue nil)))) + removed))) + (defun tc--convert-one-subtask (marker) "Convert the done sub-task heading at MARKER to a dated event-log entry. Under `tc-check-only' the conversion is reported but not performed." @@ -698,27 +809,13 @@ Under `tc-check-only' the conversion is reported but not performed." (push (list :kind 'convert-would :file tc-current-file :line line :heading title :new new) tc-issues) - ;; Replace the heading line, then drop the now-redundant CLOSED - ;; cookie from the entry (its date now lives in the header). Only - ;; the cookie goes: a planning line can also carry DEADLINE: or - ;; SCHEDULED: beside it, and those survive on their line. A line - ;; left blank by the removal is deleted whole. + ;; Replace the heading line, then drop the whole planning line. The + ;; date now lives in the header, so CLOSED is redundant and an active + ;; DEADLINE/SCHEDULED would wrongly pin this completed entry to the + ;; agenda (todo-format.md). Both go, not just the CLOSED cookie. (delete-region (line-beginning-position) (line-end-position)) (insert new) - (let ((end (save-excursion - (or (outline-next-heading) (goto-char (point-max))) - (point)))) - (save-excursion - (when (re-search-forward "CLOSED:[ \t]*\\[[^]]*\\][ \t]*" end t) - (replace-match "") - (let ((bol (line-beginning-position)) - (eol (line-end-position))) - (if (string-match-p "\\`[ \t]*\\'" - (buffer-substring bol eol)) - (delete-region bol (min (1+ eol) (point-max))) - (goto-char bol) - (when (looking-at "[ \t]+") - (replace-match ""))))))) + (tc--strip-planning-lines-in-entry) (push (list :kind 'convert-done :file tc-current-file :line line :heading title :new new) tc-issues))))))) @@ -747,6 +844,8 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors (tc-sync-child-priority-in-file)) (tc-convert-subtasks (tc-convert-subtasks-in-file)) + (tc-seal + (tc-seal-archive-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) @@ -865,10 +964,26 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors (plist-get i :file) (plist-get i :line) (plist-get i :heading) (plist-get i :detail))))))))) +(defun tc--emit-seal-report () + (dolist (i (reverse tc-issues)) + (pcase (plist-get i :kind) + ('seal-done + (princ (format "todo-cleanup --seal: sealed task-archive.org → %s\n" + (plist-get i :detail)))) + ('seal-would + (princ (format "todo-cleanup --seal: would seal task-archive.org → %s — CHECK MODE (no writes)\n" + (plist-get i :detail)))) + ('seal-collision + (princ (format "todo-cleanup --seal: %s already exists — not sealing (already sealed today?)\n" + (plist-get i :detail)))) + ('seal-nothing + (princ "todo-cleanup --seal: no working archive to seal\n"))))) + (defun tc-emit-report () (cond (tc-archive-done (tc--emit-archive-report)) (tc-sync-child-priority (tc--emit-sync-report)) (tc-convert-subtasks (tc--emit-convert-report)) + (tc-seal (tc--emit-seal-report)) (t (tc--emit-hygiene-report)))) (defun tc-main () @@ -886,6 +1001,9 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors (when (member "--convert-subtasks" command-line-args-left) (setq tc-convert-subtasks t) (setq command-line-args-left (delete "--convert-subtasks" command-line-args-left))) + (when (member "--seal" command-line-args-left) + (setq tc-seal t) + (setq command-line-args-left (delete "--seal" 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) @@ -893,7 +1011,7 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors (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 | --convert-subtasks | --sync-child-priority | --check-child-priority] FILE...\n") + (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --seal | --convert-subtasks | --sync-child-priority | --check-child-priority] FILE...\n") (kill-emacs 1)) (let ((files command-line-args-left)) (setq command-line-args-left nil) @@ -912,6 +1030,7 @@ ert-run-tests-batch-and-exit'." (cl-every (lambda (a) (cond ((member a '("--check" "--archive-done" + "--seal" "--convert-subtasks" "--sync-child-priority" "--check-child-priority")) |
