aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/todo-cleanup.el
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts/todo-cleanup.el')
-rw-r--r--.ai/scripts/todo-cleanup.el187
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"))