aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-18 21:03:50 -0500
committerCraig Jennings <c@cjennings.net>2026-07-18 21:03:50 -0500
commitddbd47fbbea06e71456b7bc4228626d3a126bf28 (patch)
tree06211963ee5fbb85b8c9eb0d4f2b860e5c74dfa1
parentd2b1bef686fd6c22a6303c13829f88bdd110fa7b (diff)
downloadrulesets-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.
-rw-r--r--.ai/scripts/lint-org.el37
-rw-r--r--.ai/scripts/tests/test-lint-org.el42
-rw-r--r--.ai/scripts/tests/test-todo-cleanup.el140
-rw-r--r--.ai/scripts/todo-cleanup.el187
-rw-r--r--claude-rules/todo-format.md7
-rw-r--r--claude-templates/.ai/scripts/lint-org.el37
-rw-r--r--claude-templates/.ai/scripts/tests/test-lint-org.el42
-rw-r--r--claude-templates/.ai/scripts/tests/test-todo-cleanup.el140
-rw-r--r--claude-templates/.ai/scripts/todo-cleanup.el187
-rw-r--r--todo.org6
10 files changed, 746 insertions, 79 deletions
diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el
index 55727ef..47e8bf1 100644
--- a/.ai/scripts/lint-org.el
+++ b/.ai/scripts/lint-org.el
@@ -39,6 +39,7 @@
;; malformed-priority-cookie [#x]-shaped token org rejected
;; level2-done-without-closed completed level-2 task with no CLOSED
;; subtask-done-not-dated level-3+ done sub-task still a DONE keyword
+;; dated-log-heading-active-timestamp dated-log heading with a live SCHEDULED/DEADLINE
;; (anything else) surfaced as judgment with checker name
;;
;; Output format on stdout:
@@ -551,6 +552,41 @@ Emits one judgment item per offending heading (checker
"level-3+ done sub-task should be a dated event-log entry (todo-format.md): run todo-cleanup.el --convert-subtasks to rewrite it")))))
;;; ---------------------------------------------------------------------------
+;;; dated-log heading with a stale active planning timestamp (todo-format.md)
+;;
+;; The mechanical backstop for the planning-line-strip rule. A dated event-log
+;; heading (`<stars> YYYY-MM-DD Day @ ...', no TODO keyword) records completed
+;; work — its date lives in the heading. An active `<...>' SCHEDULED or DEADLINE
+;; left on it pins the entry to the agenda forever: org renders any headline with
+;; an active planning timestamp, keyword or not, so a stale SCHEDULED shows as
+;; weeks-overdue long after the work is done. Invisible to a keyword scan (no
+;; TODO) and it survives --archive-done, so nothing else catches it. The
+;; completion rewrite and todo-cleanup --convert-subtasks now strip the planning
+;; line; this flags any that slipped through before that landed, the same way
+;; subtask-done-not-dated backstops the depth rule. Judgment-only.
+
+(defun lo--check-dated-log-active-timestamp ()
+ "Flag a dated event-log heading that still carries an active SCHEDULED/DEADLINE.
+The heading matches `<stars> YYYY-MM-DD Day @ ...' with no TODO keyword; an
+active `<...>' planning timestamp in its entry is the defect. An inactive
+`[...]' timestamp is ignored (org doesn't render it on the agenda). Emits one
+judgment item per offending heading (checker `dated-log-heading-active-timestamp')."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((case-fold-search nil))
+ (while (re-search-forward
+ "^\\*+ [0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]+ @ " nil t)
+ (let ((hline (line-number-at-pos))
+ (entry-end (save-excursion (outline-next-heading) (point))))
+ (save-excursion
+ (forward-line 1)
+ (when (re-search-forward
+ "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\):[ \t]*<" entry-end t)
+ (lo--emit-judgment
+ 'dated-log-heading-active-timestamp hline
+ "dated-log heading carries an active SCHEDULED/DEADLINE — org renders any active planning timestamp (keyword or not), so it stays on the agenda as weeks-overdue; delete the planning line (todo-format.md)"))))))))
+
+;;; ---------------------------------------------------------------------------
;;; File processing
(defun lo--backup (file)
@@ -592,6 +628,7 @@ left unmodified and mechanical entries are recorded with :preview t."
(lo--check-malformed-priority-cookies)
(lo--check-level2-done-without-closed)
(lo--check-subtask-done-not-dated)
+ (lo--check-dated-log-active-timestamp)
(when (and (not lo-check-only) (buffer-modified-p))
(save-buffer)))
(with-current-buffer buf (set-buffer-modified-p nil))
diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
index 8e3e190..30a79bd 100644
--- a/.ai/scripts/tests/test-lint-org.el
+++ b/.ai/scripts/tests/test-lint-org.el
@@ -739,6 +739,48 @@ missing-rules violation."
(judgments (lo-test--judgments (plist-get out :issues))))
(should-not (member 'subtask-done-not-dated (lo-test--checkers judgments)))))
+;;; dated-log-heading-active-timestamp check (stale SCHEDULED/DEADLINE on a
+;;; completed dated-log entry — the home 2026-07-17 agenda-pollution bug)
+
+(ert-deftest lo-dated-log-active-scheduled-is-flagged ()
+ "A dated-log entry still carrying an active SCHEDULED is flagged: org renders
+it on the agenda forever despite the missing keyword."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 trip booked\nSCHEDULED: <2026-06-18 Thu>\nBody.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed
+ (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-active-deadline-is-flagged ()
+ "An active DEADLINE on a dated-log entry is flagged too."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 shipped\nDEADLINE: <2026-06-25 Thu>\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-clean-entry-not-flagged ()
+ "A dated-log entry with no active planning timestamp is correct — not flagged."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 done cleanly\nBody only.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-inactive-timestamp-not-flagged ()
+ "An inactive [..] timestamp doesn't render on the agenda, so it isn't flagged —
+only active <..> planning timestamps are the defect."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 recorded\nSCHEDULED: [2026-06-18 Thu]\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-active-scheduled-on-live-todo-not-flagged ()
+ "A live TODO (keyword present) that legitimately carries an active SCHEDULED is
+not a dated-log heading, so this checker leaves it alone."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** TODO [#C] real upcoming task\nSCHEDULED: <2026-06-18 Thu>\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
;;; ---------------------------------------------------------------------------
;;; structural heading checks (org-lint gaps)
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el
index ffbf2fb..838913f 100644
--- a/.ai/scripts/tests/test-todo-cleanup.el
+++ b/.ai/scripts/tests/test-todo-cleanup.el
@@ -31,6 +31,7 @@
(defun tc-test--reset (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil tc-convert-subtasks nil
tc-check-only (and check t)
tc-archive-done t tc-sync-child-priority nil
tc-current-file nil
@@ -40,6 +41,7 @@
(defun tc-test--reset-sync (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority t
tc-current-file nil
@@ -578,6 +580,95 @@ entry is added for it."
(should (> (plist-get out :archived) 0)))))
;;; ---------------------------------------------------------------------------
+;;; --archive-done retention default
+
+(ert-deftest tc-archive-retain-default-is-one-month ()
+ "The shipped retention default is one month (31 days), not the legacy 7.
+The defvar initializes from this defconst; the live var itself is mutated by
+other tests, so the immutable defconst is the stable contract to pin."
+ (should (= 31 tc-archive-retain-days-default)))
+
+;;; ---------------------------------------------------------------------------
+;;; --seal: rename the working archive to resolved-YYYY-MM-DD.org
+
+(defun tc-test--seal (&optional opts)
+ "Run `--seal' against a temp todo file with a temp archive dir.
+OPTS is a plist: :archive-content (seed task-archive.org with this; nil = no
+working archive), :ref (YEAR MONTH DAY seal date; default (2026 7 18)),
+:check, :presealed (also create resolved-<ref>.org first, to test collision).
+Returns a plist: :sealed count, :issues, :working-exists, :sealed-exists,
+:sealed-name, :report."
+ (let* ((ref (or (plist-get opts :ref) '(2026 7 18)))
+ (check (plist-get opts :check))
+ (archive-content (plist-get opts :archive-content))
+ (todo (make-temp-file "tc-seal-todo-" nil ".org"))
+ (adir (make-temp-file "tc-seal-arch-" t))
+ (afile (expand-file-name "task-archive.org" adir))
+ (sealed-name (format "resolved-%04d-%02d-%02d.org"
+ (nth 0 ref) (nth 1 ref) (nth 2 ref)))
+ (sealed (expand-file-name sealed-name adir)))
+ (unwind-protect
+ (progn
+ (with-temp-file todo (insert "* Open Work\n** TODO [#A] live\n"))
+ (when archive-content (with-temp-file afile (insert archive-content)))
+ (when (plist-get opts :presealed)
+ (with-temp-file sealed (insert "pre-existing seal\n")))
+ (tc-test--reset check)
+ ;; Set every mode flag explicitly: tc-test--reset leaves
+ ;; tc-convert-subtasks untouched, so a convert test running earlier in
+ ;; the suite would otherwise still own the dispatch and run convert.
+ (setq tc-archive-done nil tc-sync-child-priority nil
+ tc-convert-subtasks nil tc-seal t tc-sealed 0
+ tc-archive-reference-date ref
+ tc-archive-file afile)
+ (let ((report (with-output-to-string (tc-process-file todo) (tc-emit-report))))
+ (tc-test--drop-buffer todo)
+ (list :sealed tc-sealed
+ :issues tc-issues
+ :working-exists (file-readable-p afile)
+ :sealed-exists (file-readable-p sealed)
+ :sealed-name sealed-name
+ :report report)))
+ (tc-test--drop-buffer todo)
+ (delete-file todo)
+ (delete-directory adir t))))
+
+(ert-deftest tc-seal-renames-working-archive-to-dated-file ()
+ "Normal: --seal renames task-archive.org to resolved-<seal-date>.org."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n** DONE old\n"
+ :ref (2026 7 18)))))
+ (should (= 1 (plist-get out :sealed)))
+ (should-not (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (equal "resolved-2026-07-18.org" (plist-get out :sealed-name)))
+ (should (tc-test--has (plist-get out :report) "sealed task-archive.org → resolved-2026-07-18.org"))))
+
+(ert-deftest tc-seal-nothing-to-seal-is-a-reported-noop ()
+ "Boundary: no working archive present — reported no-op, nothing created."
+ (let ((out (tc-test--seal '(:ref (2026 7 18)))))
+ (should (= 0 (plist-get out :sealed)))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "no working archive to seal"))))
+
+(ert-deftest tc-seal-check-mode-previews-without-renaming ()
+ "Boundary: --check reports the seal but leaves the working archive in place."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :check t))))
+ (should (= 1 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "would seal"))))
+
+(ert-deftest tc-seal-refuses-to-clobber-existing-sealed-file ()
+ "Error: resolved-<today>.org already exists — refuse, leave both files intact."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :presealed t))))
+ (should (= 0 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "already exists"))))
+
+;;; ---------------------------------------------------------------------------
;;; Sync-child-priority harness + fixtures
(defun tc-test--sync (content &optional runs check)
@@ -773,7 +864,7 @@ in ISSUES, in document order."
(defun tc-test--reset-convert (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-converted 0 tc-archived-to-file 0
- tc-issues nil
+ tc-issues nil tc-sealed 0 tc-seal nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t
tc-current-file nil
@@ -927,8 +1018,9 @@ CLOSED: [2026-06-27 Sat 12:50] DEADLINE: <2026-06-30 Tue>
Body line.
")
-(ert-deftest tc-convert-preserves-deadline-on-shared-planning-line-boundary ()
- "Boundary: removing the CLOSED cookie keeps a DEADLINE sharing its planning line."
+(ert-deftest tc-convert-strips-deadline-sharing-the-planning-line-boundary ()
+ "Boundary: a DEADLINE sharing the CLOSED planning line goes too — a dated-log
+entry carries no active planning timestamp (todo-format.md). Body survives."
(let* ((out (tc-test--convert tc-test--convert-closed-with-deadline))
(res (plist-get out :result)))
(should (= 1 (plist-get out :converted)))
@@ -936,8 +1028,48 @@ Body line.
"^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Ship the panel$"
res))
(should-not (string-match-p "CLOSED:" res))
- (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res))
+ (should-not (string-match-p "DEADLINE:" res))
(should (string-match-p "^Body line\\.$" res))))
+(defconst tc-test--convert-closed-and-scheduled-separate-lines
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Book the venue :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+SCHEDULED: <2026-06-20 Sat>
+Body line.
+")
+
+(ert-deftest tc-convert-strips-scheduled-on-its-own-line ()
+ "Normal (the home bug): a SCHEDULED planning line on its own — the completion
+rewrite dropped keyword/priority/tags but left the SCHEDULED, pinning the dated
+entry to the agenda as weeks-overdue. Both planning lines go; body survives."
+ (let* ((out (tc-test--convert tc-test--convert-closed-and-scheduled-separate-lines))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should (string-match-p
+ "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Book the venue$"
+ res))
+ (should-not (string-match-p "CLOSED:" res))
+ (should-not (string-match-p "SCHEDULED:" res))
+ (should (string-match-p "^Body line\\.$" res))))
+
+(defconst tc-test--convert-scheduled-in-body-prose
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Note the mechanism :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+An active SCHEDULED: <2026-06-20 Sat> in prose must survive.
+")
+
+(ert-deftest tc-convert-leaves-planning-shaped-body-prose-alone ()
+ "Boundary: a planning-shaped token inside body prose (not a canonical planning
+line) is left untouched — the strip stops at the first non-planning line."
+ (let* ((out (tc-test--convert tc-test--convert-scheduled-in-body-prose))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should-not (string-match-p "CLOSED:" res))
+ (should (string-match-p "An active SCHEDULED: <2026-06-20 Sat> in prose must survive\\." res))))
+
(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 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"))
diff --git a/claude-rules/todo-format.md b/claude-rules/todo-format.md
index 2cdc76c..09b39cf 100644
--- a/claude-rules/todo-format.md
+++ b/claude-rules/todo-format.md
@@ -217,6 +217,7 @@ A completed sub-task disappears as a task and becomes an in-place event-log entr
2. Generate the timestamp with `date "+%Y-%m-%d %a @ %H:%M:%S %z"`.
3. Reword the original imperative title into the past-tense action that landed. Trim or restate if the original wording doesn't fit the action.
4. Drop the `TODO`/`DOING` keyword, the priority cookie, and the tags. The body stays as the record of what was done (if useful).
+5. Remove any `SCHEDULED:`/`DEADLINE:` planning line. The completion time lives in the heading now, so `CLOSED:` is redundant and an active planning date on a historical log entry is always wrong. Org renders any headline carrying an active `<...>` `SCHEDULED`/`DEADLINE` on the agenda, keyword or not, so a stale one pins the finished entry there as weeks-overdue forever. An interactive close (`org-log-done`) stamps `CLOSED:` but never strips a pre-existing planning line, which is exactly how the stale dates survive.
**Example:**
@@ -226,7 +227,7 @@ becomes
*** 2026-05-15 Fri @ 12:58:08 -0500 Wired yasnippet for universal availability
-**Enforcement.** This is applied at close time by whoever closes the task, but an interactive org close (`org-log-done` flips the keyword to `DONE` and stamps `CLOSED:`) never applies the dated rewrite, so level-3+ closes accumulate as `DONE` keywords. `todo-cleanup.el --convert-subtasks` (run in the `clean-todo` and wrap-up cleanup passes) normalizes them mechanically: it rewrites any level-3+ `DONE`/`CANCELLED`/`FAILED` heading into the dated form above, pulling the timestamp from the `CLOSED` cookie and keeping the heading text verbatim (a batch tool can't reliably past-tense a title — polish wording by hand where it matters). `lint-org.el` flags any that slip through (checker `subtask-done-not-dated`). So the depth rule holds even when tasks are closed interactively rather than by an agent applying this section.
+**Enforcement.** This is applied at close time by whoever closes the task, but an interactive org close (`org-log-done` flips the keyword to `DONE` and stamps `CLOSED:`) never applies the dated rewrite, so level-3+ closes accumulate as `DONE` keywords. `todo-cleanup.el --convert-subtasks` (run in the `clean-todo` and wrap-up cleanup passes) normalizes them mechanically: it rewrites any level-3+ `DONE`/`CANCELLED`/`FAILED` heading into the dated form above, pulling the timestamp from the `CLOSED` cookie, dropping the whole planning line (`CLOSED`, `SCHEDULED`, and `DEADLINE` together — step 5), and keeping the heading text verbatim (a batch tool can't reliably past-tense a title — polish wording by hand where it matters). `lint-org.el` flags any that slip through: checker `subtask-done-not-dated` for a still-keyworded sub-task, and `dated-log-heading-active-timestamp` for a dated entry that kept an active `SCHEDULED`/`DEADLINE`. So the depth rule holds even when tasks are closed interactively rather than by an agent applying this section.
### Why depth-based
@@ -309,6 +310,10 @@ tasks — dated entries at `***` and deeper, terminal keyword at `**`.
*** 2026-05-15 Fri @ 14:00:00 -0500 <what was answered or done>
Generate the timestamp with `date "+%Y-%m-%d %a @ %H:%M:%S %z"`.
+ Remove any `SCHEDULED:`/`DEADLINE:` planning line too, same as the
+ sub-task rule above — a dated event-log entry carries its date in the
+ heading, and an active planning date left on it pins the finished entry
+ to the agenda forever.
- **At `**` — terminal keyword, like any top-level task.** Change
`VERIFY` to `DONE` (answered / check passed) or `CANCELLED` (abandoned),
diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el
index 55727ef..47e8bf1 100644
--- a/claude-templates/.ai/scripts/lint-org.el
+++ b/claude-templates/.ai/scripts/lint-org.el
@@ -39,6 +39,7 @@
;; malformed-priority-cookie [#x]-shaped token org rejected
;; level2-done-without-closed completed level-2 task with no CLOSED
;; subtask-done-not-dated level-3+ done sub-task still a DONE keyword
+;; dated-log-heading-active-timestamp dated-log heading with a live SCHEDULED/DEADLINE
;; (anything else) surfaced as judgment with checker name
;;
;; Output format on stdout:
@@ -551,6 +552,41 @@ Emits one judgment item per offending heading (checker
"level-3+ done sub-task should be a dated event-log entry (todo-format.md): run todo-cleanup.el --convert-subtasks to rewrite it")))))
;;; ---------------------------------------------------------------------------
+;;; dated-log heading with a stale active planning timestamp (todo-format.md)
+;;
+;; The mechanical backstop for the planning-line-strip rule. A dated event-log
+;; heading (`<stars> YYYY-MM-DD Day @ ...', no TODO keyword) records completed
+;; work — its date lives in the heading. An active `<...>' SCHEDULED or DEADLINE
+;; left on it pins the entry to the agenda forever: org renders any headline with
+;; an active planning timestamp, keyword or not, so a stale SCHEDULED shows as
+;; weeks-overdue long after the work is done. Invisible to a keyword scan (no
+;; TODO) and it survives --archive-done, so nothing else catches it. The
+;; completion rewrite and todo-cleanup --convert-subtasks now strip the planning
+;; line; this flags any that slipped through before that landed, the same way
+;; subtask-done-not-dated backstops the depth rule. Judgment-only.
+
+(defun lo--check-dated-log-active-timestamp ()
+ "Flag a dated event-log heading that still carries an active SCHEDULED/DEADLINE.
+The heading matches `<stars> YYYY-MM-DD Day @ ...' with no TODO keyword; an
+active `<...>' planning timestamp in its entry is the defect. An inactive
+`[...]' timestamp is ignored (org doesn't render it on the agenda). Emits one
+judgment item per offending heading (checker `dated-log-heading-active-timestamp')."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((case-fold-search nil))
+ (while (re-search-forward
+ "^\\*+ [0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]+ @ " nil t)
+ (let ((hline (line-number-at-pos))
+ (entry-end (save-excursion (outline-next-heading) (point))))
+ (save-excursion
+ (forward-line 1)
+ (when (re-search-forward
+ "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\):[ \t]*<" entry-end t)
+ (lo--emit-judgment
+ 'dated-log-heading-active-timestamp hline
+ "dated-log heading carries an active SCHEDULED/DEADLINE — org renders any active planning timestamp (keyword or not), so it stays on the agenda as weeks-overdue; delete the planning line (todo-format.md)"))))))))
+
+;;; ---------------------------------------------------------------------------
;;; File processing
(defun lo--backup (file)
@@ -592,6 +628,7 @@ left unmodified and mechanical entries are recorded with :preview t."
(lo--check-malformed-priority-cookies)
(lo--check-level2-done-without-closed)
(lo--check-subtask-done-not-dated)
+ (lo--check-dated-log-active-timestamp)
(when (and (not lo-check-only) (buffer-modified-p))
(save-buffer)))
(with-current-buffer buf (set-buffer-modified-p nil))
diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el
index 8e3e190..30a79bd 100644
--- a/claude-templates/.ai/scripts/tests/test-lint-org.el
+++ b/claude-templates/.ai/scripts/tests/test-lint-org.el
@@ -739,6 +739,48 @@ missing-rules violation."
(judgments (lo-test--judgments (plist-get out :issues))))
(should-not (member 'subtask-done-not-dated (lo-test--checkers judgments)))))
+;;; dated-log-heading-active-timestamp check (stale SCHEDULED/DEADLINE on a
+;;; completed dated-log entry — the home 2026-07-17 agenda-pollution bug)
+
+(ert-deftest lo-dated-log-active-scheduled-is-flagged ()
+ "A dated-log entry still carrying an active SCHEDULED is flagged: org renders
+it on the agenda forever despite the missing keyword."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 trip booked\nSCHEDULED: <2026-06-18 Thu>\nBody.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed
+ (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-active-deadline-is-flagged ()
+ "An active DEADLINE on a dated-log entry is flagged too."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 shipped\nDEADLINE: <2026-06-25 Thu>\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-clean-entry-not-flagged ()
+ "A dated-log entry with no active planning timestamp is correct — not flagged."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 done cleanly\nBody only.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-inactive-timestamp-not-flagged ()
+ "An inactive [..] timestamp doesn't render on the agenda, so it isn't flagged —
+only active <..> planning timestamps are the defect."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 recorded\nSCHEDULED: [2026-06-18 Thu]\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-active-scheduled-on-live-todo-not-flagged ()
+ "A live TODO (keyword present) that legitimately carries an active SCHEDULED is
+not a dated-log heading, so this checker leaves it alone."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** TODO [#C] real upcoming task\nSCHEDULED: <2026-06-18 Thu>\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
;;; ---------------------------------------------------------------------------
;;; structural heading checks (org-lint gaps)
diff --git a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
index ffbf2fb..838913f 100644
--- a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
+++ b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
@@ -31,6 +31,7 @@
(defun tc-test--reset (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil tc-convert-subtasks nil
tc-check-only (and check t)
tc-archive-done t tc-sync-child-priority nil
tc-current-file nil
@@ -40,6 +41,7 @@
(defun tc-test--reset-sync (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority t
tc-current-file nil
@@ -578,6 +580,95 @@ entry is added for it."
(should (> (plist-get out :archived) 0)))))
;;; ---------------------------------------------------------------------------
+;;; --archive-done retention default
+
+(ert-deftest tc-archive-retain-default-is-one-month ()
+ "The shipped retention default is one month (31 days), not the legacy 7.
+The defvar initializes from this defconst; the live var itself is mutated by
+other tests, so the immutable defconst is the stable contract to pin."
+ (should (= 31 tc-archive-retain-days-default)))
+
+;;; ---------------------------------------------------------------------------
+;;; --seal: rename the working archive to resolved-YYYY-MM-DD.org
+
+(defun tc-test--seal (&optional opts)
+ "Run `--seal' against a temp todo file with a temp archive dir.
+OPTS is a plist: :archive-content (seed task-archive.org with this; nil = no
+working archive), :ref (YEAR MONTH DAY seal date; default (2026 7 18)),
+:check, :presealed (also create resolved-<ref>.org first, to test collision).
+Returns a plist: :sealed count, :issues, :working-exists, :sealed-exists,
+:sealed-name, :report."
+ (let* ((ref (or (plist-get opts :ref) '(2026 7 18)))
+ (check (plist-get opts :check))
+ (archive-content (plist-get opts :archive-content))
+ (todo (make-temp-file "tc-seal-todo-" nil ".org"))
+ (adir (make-temp-file "tc-seal-arch-" t))
+ (afile (expand-file-name "task-archive.org" adir))
+ (sealed-name (format "resolved-%04d-%02d-%02d.org"
+ (nth 0 ref) (nth 1 ref) (nth 2 ref)))
+ (sealed (expand-file-name sealed-name adir)))
+ (unwind-protect
+ (progn
+ (with-temp-file todo (insert "* Open Work\n** TODO [#A] live\n"))
+ (when archive-content (with-temp-file afile (insert archive-content)))
+ (when (plist-get opts :presealed)
+ (with-temp-file sealed (insert "pre-existing seal\n")))
+ (tc-test--reset check)
+ ;; Set every mode flag explicitly: tc-test--reset leaves
+ ;; tc-convert-subtasks untouched, so a convert test running earlier in
+ ;; the suite would otherwise still own the dispatch and run convert.
+ (setq tc-archive-done nil tc-sync-child-priority nil
+ tc-convert-subtasks nil tc-seal t tc-sealed 0
+ tc-archive-reference-date ref
+ tc-archive-file afile)
+ (let ((report (with-output-to-string (tc-process-file todo) (tc-emit-report))))
+ (tc-test--drop-buffer todo)
+ (list :sealed tc-sealed
+ :issues tc-issues
+ :working-exists (file-readable-p afile)
+ :sealed-exists (file-readable-p sealed)
+ :sealed-name sealed-name
+ :report report)))
+ (tc-test--drop-buffer todo)
+ (delete-file todo)
+ (delete-directory adir t))))
+
+(ert-deftest tc-seal-renames-working-archive-to-dated-file ()
+ "Normal: --seal renames task-archive.org to resolved-<seal-date>.org."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n** DONE old\n"
+ :ref (2026 7 18)))))
+ (should (= 1 (plist-get out :sealed)))
+ (should-not (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (equal "resolved-2026-07-18.org" (plist-get out :sealed-name)))
+ (should (tc-test--has (plist-get out :report) "sealed task-archive.org → resolved-2026-07-18.org"))))
+
+(ert-deftest tc-seal-nothing-to-seal-is-a-reported-noop ()
+ "Boundary: no working archive present — reported no-op, nothing created."
+ (let ((out (tc-test--seal '(:ref (2026 7 18)))))
+ (should (= 0 (plist-get out :sealed)))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "no working archive to seal"))))
+
+(ert-deftest tc-seal-check-mode-previews-without-renaming ()
+ "Boundary: --check reports the seal but leaves the working archive in place."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :check t))))
+ (should (= 1 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "would seal"))))
+
+(ert-deftest tc-seal-refuses-to-clobber-existing-sealed-file ()
+ "Error: resolved-<today>.org already exists — refuse, leave both files intact."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :presealed t))))
+ (should (= 0 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "already exists"))))
+
+;;; ---------------------------------------------------------------------------
;;; Sync-child-priority harness + fixtures
(defun tc-test--sync (content &optional runs check)
@@ -773,7 +864,7 @@ in ISSUES, in document order."
(defun tc-test--reset-convert (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-converted 0 tc-archived-to-file 0
- tc-issues nil
+ tc-issues nil tc-sealed 0 tc-seal nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t
tc-current-file nil
@@ -927,8 +1018,9 @@ CLOSED: [2026-06-27 Sat 12:50] DEADLINE: <2026-06-30 Tue>
Body line.
")
-(ert-deftest tc-convert-preserves-deadline-on-shared-planning-line-boundary ()
- "Boundary: removing the CLOSED cookie keeps a DEADLINE sharing its planning line."
+(ert-deftest tc-convert-strips-deadline-sharing-the-planning-line-boundary ()
+ "Boundary: a DEADLINE sharing the CLOSED planning line goes too — a dated-log
+entry carries no active planning timestamp (todo-format.md). Body survives."
(let* ((out (tc-test--convert tc-test--convert-closed-with-deadline))
(res (plist-get out :result)))
(should (= 1 (plist-get out :converted)))
@@ -936,8 +1028,48 @@ Body line.
"^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Ship the panel$"
res))
(should-not (string-match-p "CLOSED:" res))
- (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res))
+ (should-not (string-match-p "DEADLINE:" res))
(should (string-match-p "^Body line\\.$" res))))
+(defconst tc-test--convert-closed-and-scheduled-separate-lines
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Book the venue :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+SCHEDULED: <2026-06-20 Sat>
+Body line.
+")
+
+(ert-deftest tc-convert-strips-scheduled-on-its-own-line ()
+ "Normal (the home bug): a SCHEDULED planning line on its own — the completion
+rewrite dropped keyword/priority/tags but left the SCHEDULED, pinning the dated
+entry to the agenda as weeks-overdue. Both planning lines go; body survives."
+ (let* ((out (tc-test--convert tc-test--convert-closed-and-scheduled-separate-lines))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should (string-match-p
+ "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Book the venue$"
+ res))
+ (should-not (string-match-p "CLOSED:" res))
+ (should-not (string-match-p "SCHEDULED:" res))
+ (should (string-match-p "^Body line\\.$" res))))
+
+(defconst tc-test--convert-scheduled-in-body-prose
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Note the mechanism :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+An active SCHEDULED: <2026-06-20 Sat> in prose must survive.
+")
+
+(ert-deftest tc-convert-leaves-planning-shaped-body-prose-alone ()
+ "Boundary: a planning-shaped token inside body prose (not a canonical planning
+line) is left untouched — the strip stops at the first non-planning line."
+ (let* ((out (tc-test--convert tc-test--convert-scheduled-in-body-prose))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should-not (string-match-p "CLOSED:" res))
+ (should (string-match-p "An active SCHEDULED: <2026-06-20 Sat> in prose must survive\\." res))))
+
(provide 'test-todo-cleanup)
;;; test-todo-cleanup.el ends here
diff --git a/claude-templates/.ai/scripts/todo-cleanup.el b/claude-templates/.ai/scripts/todo-cleanup.el
index bd8166d..c4a87de 100644
--- a/claude-templates/.ai/scripts/todo-cleanup.el
+++ b/claude-templates/.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"))
diff --git a/todo.org b/todo.org
index f4b3a55..aa45b1d 100644
--- a/todo.org
+++ b/todo.org
@@ -1316,7 +1316,8 @@ having a skill to generate or check OV-1-shaped artifacts. Don't build
speculatively — defense-specific notations are narrow enough that each
skill should be driven by a concrete contract need, not aspiration.
-** TODO [#B] todo-cleanup.el dated-seal archiving :feature:solo:
+** DONE [#B] todo-cleanup.el dated-seal archiving :feature:solo:
+CLOSED: [2026-07-18 Sat]
Redefine =--archive-done= aging from a 7-day roll-into-one-file model to a
one-month retention with dated seals. Craig ratified the design in the work
project 2026-07-17; origin handoff preserved at
@@ -1341,7 +1342,8 @@ TDD, canonical-then-mirror per the sync-check invariant. Home's planning-line
strip proposal (filed alongside) also touches todo-cleanup.el =--convert-subtasks=
— the two can be built as one batch.
-** TODO [#B] Strip stale planning lines on dated completion + lint backstop :feature:solo:
+** DONE [#B] Strip stale planning lines on dated completion + lint backstop :feature:solo:
+CLOSED: [2026-07-18 Sat]
Two linked fixes so a closed sub-task can't keep polluting the org agenda. Origin:
home handoff 2026-07-17; design preserved at
[[file:docs/design/2026-07-17-dated-log-planning-line-strip-proposal.md]]. A dated-log