diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-01 21:35:16 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-01 21:35:16 -0400 |
| commit | 19ba7cb40c5a448bb28f0217d8cc4718dd450f91 (patch) | |
| tree | 6eeeaf8e10f701c8daa96d2853f1043d3960f3c2 | |
| parent | c976f5b6166b0596daefa6c6dcfc2b684563e13c (diff) | |
| download | rulesets-19ba7cb40c5a448bb28f0217d8cc4718dd450f91.tar.gz rulesets-19ba7cb40c5a448bb28f0217d8cc4718dd450f91.zip | |
feat(todo-cleanup): add --convert-subtasks dated-rewrite mode
Rewrites every level-3+ DONE/CANCELLED/FAILED heading into a dated event-log entry from its CLOSED cookie, enforcing the todo-format depth rule that interactive closes and --archive-done (level-2 only) leave unapplied. A new lint-org checker (subtask-done-not-dated) flags stragglers, and the clean-todo, wrap-up, open-tasks, and task-review workflows now run the converter before archiving.
Removing the CLOSED cookie keeps a DEADLINE or SCHEDULED cookie that shares its planning line, rather than dropping the whole line.
From the .emacs.d handoff (2026-07-01 convert-subtasks bundle).
| -rw-r--r-- | .ai/scripts/lint-org.el | 28 | ||||
| -rw-r--r-- | .ai/scripts/tests/test-lint-org.el | 31 | ||||
| -rw-r--r-- | .ai/scripts/tests/test-todo-cleanup.el | 171 | ||||
| -rw-r--r-- | .ai/scripts/todo-cleanup.el | 192 | ||||
| -rw-r--r-- | .ai/workflows/clean-todo.org | 19 | ||||
| -rw-r--r-- | .ai/workflows/open-tasks.org | 7 | ||||
| -rw-r--r-- | .ai/workflows/task-review.org | 2 | ||||
| -rw-r--r-- | .ai/workflows/wrap-it-up.org | 18 | ||||
| -rw-r--r-- | claude-rules/todo-format.md | 2 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/lint-org.el | 28 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test-lint-org.el | 31 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test-todo-cleanup.el | 171 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/todo-cleanup.el | 192 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/clean-todo.org | 19 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/open-tasks.org | 7 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/task-review.org | 2 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/wrap-it-up.org | 18 |
17 files changed, 916 insertions, 22 deletions
diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el index 5447cb3..90b1b1d 100644 --- a/.ai/scripts/lint-org.el +++ b/.ai/scripts/lint-org.el @@ -35,6 +35,7 @@ ;; empty-heading bare stars with no title ;; 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 ;; (anything else) surfaced as judgment with checker name ;; ;; Output format on stdout: @@ -503,6 +504,32 @@ the live file on the next `task-sorted'." "level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately")))))))) ;;; --------------------------------------------------------------------------- +;;; level-3+ dated-header check (claude-rules/todo-format.md) +;; +;; The inverse of the level-2 check above. A completed sub-task — a heading at +;; level 3 or deeper, under a parent task — becomes a dated event-log entry, not +;; a DONE keyword, so the parent's subtree grows a chronological history instead +;; of a long tail of nested DONE lines. An interactive org close +;; (`org-log-done' → DONE + CLOSED) leaves the keyword in place, and +;; `--archive-done' only touches level 2, so these accumulate. Flag them for +;; conversion. Judgment-only and regex-based (independent of which TODO keywords +;; the batch Emacs recognizes); todo-cleanup.el --convert-subtasks does the fix. + +(defun lo--check-subtask-done-not-dated () + "Flag level-3+ headings carrying a done keyword (DONE/CANCELLED/FAILED). +Emits one judgment item per offending heading (checker +`subtask-done-not-dated')." + (save-excursion + (goto-char (point-min)) + ;; Case-sensitive: the keywords are uppercase, not the words in a title. + (let ((case-fold-search nil)) + (while (re-search-forward + "^\\*\\{3,\\} \\(DONE\\|CANCELLED\\|FAILED\\) " nil t) + (lo--emit-judgment + 'subtask-done-not-dated (line-number-at-pos) + "level-3+ done sub-task should be a dated event-log entry (todo-format.md): run todo-cleanup.el --convert-subtasks to rewrite it"))))) + +;;; --------------------------------------------------------------------------- ;;; File processing (defun lo--backup (file) @@ -543,6 +570,7 @@ left unmodified and mechanical entries are recorded with :preview t." (lo--check-empty-headings) (lo--check-malformed-priority-cookies) (lo--check-level2-done-without-closed) + (lo--check-subtask-done-not-dated) (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 3b8a9bb..d14879f 100644 --- a/.ai/scripts/tests/test-lint-org.el +++ b/.ai/scripts/tests/test-lint-org.el @@ -685,6 +685,37 @@ missing-rules violation." (judgments (lo-test--judgments (plist-get out :issues)))) (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) +;;; subtask-done-not-dated check (the inverse: level-3+ done keyword) + +(ert-deftest lo-subtask-done-not-dated-flags-level3 () + "A level-3 DONE sub-task still carrying the keyword is flagged for conversion." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** DONE [#C] Sub-task done\nCLOSED: [2026-06-20 Sat 10:00]\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed + (should (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +(ert-deftest lo-subtask-done-not-dated-flags-level4-cancelled () + "A level-4 CANCELLED sub-task is flagged too." + (let* ((out (lo-test--run + "* Open Work\n\n** PROJECT [#B] Parent\n*** TODO Mid\n**** CANCELLED Deep abandoned\nCLOSED: [2026-06-20 Sat]\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +(ert-deftest lo-subtask-done-not-dated-ignores-level2 () + "A level-2 DONE task is a top-level task, not a sub-task — this checker skips it." + (let* ((out (lo-test--run + "* Open Work\n\n** DONE [#B] Top-level\nCLOSED: [2026-06-20 Sat]\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +(ert-deftest lo-subtask-done-not-dated-ignores-dated-and-lowercase () + "An already-dated level-3 entry, and the word done in a title, are not flagged." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0400 landed\n*** TODO wrap the done cleanup\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'subtask-done-not-dated (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 e569d9a..ffbf2fb 100644 --- a/.ai/scripts/tests/test-todo-cleanup.el +++ b/.ai/scripts/tests/test-todo-cleanup.el @@ -768,5 +768,176 @@ in ISSUES, in document order." (should (= 2 (plist-get once :bumped))) (should (= 2 (plist-get twice :bumped))))) +;;; --------------------------------------------------------------------------- +;;; --convert-subtasks harness + tests + +(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-check-only (and check t) + tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t + tc-current-file nil + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) + +(defun tc-test--convert (content &optional runs check) + "Write CONTENT to a temp .org file, run `--convert-subtasks' RUNS times (default 1). +Return a plist: :result final file contents, :converted count from the last run, +:issues from the last run. CHECK non-nil ⇒ --check (preview, no writes)." + (let ((file (make-temp-file "tc-test-" nil ".org")) + last-converted last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset-convert check) + (tc-process-file file) + (setq last-converted tc-converted last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :converted last-converted + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +;; The UTC offset in a converted header is the test machine's local offset for +;; that date, so assertions match it as `[-+]NNNN' rather than a fixed value — +;; the mode's job is to emit a well-formed offset, not to run in one timezone. + +(defconst tc-test--convert-timed + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] F12 opens the terminal :feature:quick: +CLOSED: [2026-06-27 Sat 12:50] +Verified live: docks, toggles, colors clean. +") + +(ert-deftest tc-convert-timed-subtask-normal () + "Normal: a timed CLOSED close becomes a dated header, keyword/priority/tags/CLOSED gone." + (let* ((out (tc-test--convert tc-test--convert-timed)) + (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\\} F12 opens the terminal$" + res)) + (should-not (string-match-p "CLOSED:" res)) + (should-not (string-match-p "DONE" res)) + (should (string-match-p "Verified live: docks, toggles, colors clean\\." res)) + (should (string-match-p "^\\*\\* TODO \\[#B\\] Parent task$" res)))) + +(defconst tc-test--convert-dateonly + "* Project Open Work +** PROJECT [#B] Parent +**** DONE [#B] Write full spec :refactor: +CLOSED: [2026-05-04 Mon] +Body. +") + +(ert-deftest tc-convert-dateonly-boundary-midnight () + "Boundary: a date-only CLOSED (no time) yields 00:00:00, at level 4." + (let ((res (plist-get (tc-test--convert tc-test--convert-dateonly) :result))) + (should (string-match-p + "^\\*\\*\\*\\* 2026-05-04 Mon @ 00:00:00 [-+][0-9]\\{4\\} Write full spec$" + res)) + (should-not (string-match-p "CLOSED:" res)))) + +(defconst tc-test--convert-level2 + "* Project Open Work +** DONE [#B] Top-level task +CLOSED: [2026-06-01 Mon 09:00] +Body. +") + +(ert-deftest tc-convert-leaves-level-2-alone-boundary () + "Boundary: a level-2 DONE task is a top-level task, not a sub-task — untouched." + (let ((out (tc-test--convert tc-test--convert-level2))) + (should (= 0 (plist-get out :converted))) + (should (equal tc-test--convert-level2 (plist-get out :result))))) + +(ert-deftest tc-convert-idempotent-boundary () + "Boundary: a second run over an already-dated entry converts nothing new." + (let ((once (tc-test--convert tc-test--convert-timed 1)) + (twice (tc-test--convert tc-test--convert-timed 2))) + (should (equal (plist-get once :result) (plist-get twice :result))) + (should (= 0 (plist-get twice :converted))))) + +(defconst tc-test--convert-nested + "* Project Open Work +** TODO [#B] Parent +*** DONE Outer sub :feature: +CLOSED: [2026-06-10 Wed 08:15] +**** DONE Inner sub +CLOSED: [2026-06-09 Tue 07:00] +Inner body. +") + +(ert-deftest tc-convert-nested-done-subtasks-boundary () + "Boundary: a done sub-task nested under a done sub-task — both convert." + (let* ((out (tc-test--convert tc-test--convert-nested)) + (res (plist-get out :result))) + (should (= 2 (plist-get out :converted))) + (should (string-match-p + "^\\*\\*\\* 2026-06-10 Wed @ 08:15:00 [-+][0-9]\\{4\\} Outer sub$" res)) + (should (string-match-p + "^\\*\\*\\*\\* 2026-06-09 Tue @ 07:00:00 [-+][0-9]\\{4\\} Inner sub$" res)) + (should-not (string-match-p "CLOSED:" res)))) + +(defconst tc-test--convert-cancelled + "* Project Open Work +** TODO [#B] Parent +*** CANCELLED [#C] Abandoned idea :feature: +CLOSED: [2026-06-15 Mon 10:00] +") + +(ert-deftest tc-convert-cancelled-subtask-boundary () + "Boundary: a CANCELLED sub-task converts too (terminal state)." + (let ((res (plist-get (tc-test--convert tc-test--convert-cancelled) :result))) + (should (string-match-p + "^\\*\\*\\* 2026-06-15 Mon @ 10:00:00 [-+][0-9]\\{4\\} Abandoned idea$" res)) + (should-not (string-match-p "CANCELLED" res)))) + +(defconst tc-test--convert-noclosed + "* Project Open Work +** TODO [#B] Parent +*** DONE Orphan with no closed date +Body only. +") + +(ert-deftest tc-convert-skips-subtask-without-closed-error () + "Error: a done sub-task with no parseable CLOSED is flagged and left unchanged." + (let ((out (tc-test--convert tc-test--convert-noclosed))) + (should (= 0 (plist-get out :converted))) + (should (equal tc-test--convert-noclosed (plist-get out :result))) + (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-skip)) + (plist-get out :issues))))) + +(ert-deftest tc-convert-check-mode-previews-without-writing () + "Check mode reports the conversion but writes nothing." + (let ((out (tc-test--convert tc-test--convert-timed 1 t))) + (should (= 1 (plist-get out :converted))) + (should (equal tc-test--convert-timed (plist-get out :result))) + (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-would)) + (plist-get out :issues))))) + +(defconst tc-test--convert-closed-with-deadline + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] Ship the panel :feature: +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." + (let* ((out (tc-test--convert tc-test--convert-closed-with-deadline)) + (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\\} Ship the panel$" + res)) + (should-not (string-match-p "CLOSED:" res)) + (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res)) + (should (string-match-p "^Body line\\.$" 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 541d106..bd8166d 100644 --- a/.ai/scripts/todo-cleanup.el +++ b/.ai/scripts/todo-cleanup.el @@ -5,10 +5,12 @@ ;; 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 --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 ;; emacs --batch -q -l todo-cleanup.el --check-child-priority todo.org # preview the sync (same as --sync-child-priority --check) ;; -;; Three independent modes: +;; Four independent modes: ;; ;; * Default (hygiene). Designed for the wrap-it-up workflow: cheap, idempotent, ;; safe to run every session. @@ -52,6 +54,20 @@ ;; Archiving is consequential, so it's never run by default; it does *not* ;; also run the hygiene passes. ;; +;; * --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, +;; priority cookie, and tags, and removing the now-redundant CLOSED line. The +;; date and time come from that entry's own CLOSED cookie; a date-only close +;; yields 00:00:00, and the UTC offset is computed DST-aware for that date. +;; This enforces the todo-format depth rule that interactive closes +;; (`org-log-done' → DONE + CLOSED) and `--archive-done' (level-2 only) leave +;; unapplied. The heading text is preserved verbatim — a batch tool can't +;; past-tense an imperative title reliably. Idempotent (an already-dated +;; heading has no done keyword); a done sub-task with no parseable CLOSED date +;; is flagged and left alone, never stamped with a fabricated date. Like +;; --archive-done it does not also run the hygiene passes. +;; ;; * --sync-child-priority (opt-in). Walks every heading with a priority cookie ;; ([#A]-[#D]) and, for each of its direct child headings whose own priority ;; is lower (later in the alphabet — D is lower than A), bumps the child's @@ -73,11 +89,16 @@ (require 'calendar) (setq org-todo-keywords - '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED"))) + '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED" "FAILED"))) (defconst tc-done-states '("DONE" "CANCELLED") "TODO keywords that mark an entry as completed for `--archive-done'.") +(defconst tc--convert-done-states '("DONE" "CANCELLED" "FAILED") + "TODO keywords whose level-3-and-deeper entries `--convert-subtasks' rewrites +to dated event-log entries. Broader than `tc-done-states' because a FAILED +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.") @@ -89,10 +110,12 @@ every heading below it.") (defvar tc-fixes 0) (defvar tc-archived 0) (defvar tc-bumped 0) +(defvar tc-converted 0) (defvar tc-issues nil) (defvar tc-check-only nil) (defvar tc-archive-done nil) (defvar tc-sync-child-priority nil) +(defvar tc-convert-subtasks nil) (defvar tc-current-file nil) (defvar tc-current-dir nil) (defvar tc-archived-to-file 0) @@ -578,6 +601,138 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (org-map-entries #'tc-sync-child-priority-at-heading nil 'file)) ;;; --------------------------------------------------------------------------- +;;; --convert-subtasks mode +;; +;; A sub-task (a heading at level 3 or deeper, i.e. under a parent task) that is +;; marked DONE/CANCELLED/FAILED should become a dated event-log entry per the +;; todo-format depth rule: drop the keyword, priority cookie, and tags, and +;; rewrite the heading to `<stars> YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <text>' so the +;; parent's subtree grows a chronological history instead of a long tail of +;; nested DONE lines. Nothing enforced this before: `org-log-done' just flips an +;; interactive close to DONE + CLOSED, and `--archive-done' only touches level 2. +;; So level-3+ closes piled up as DONE keywords. This mode converts them +;; mechanically, pulling the timestamp from each entry's own CLOSED cookie. The +;; heading text is kept verbatim (a batch tool can't reliably past-tense an +;; imperative title, and guessing prose in the task file is worse than leaving it +;; 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. + +(defun tc--closed-parts-in-entry () + "Return a plist (:year :month :day :dow :hour :minute) from the CLOSED cookie +of the entry at point, or nil when the entry has no parseable CLOSED line. +:hour and :minute are nil when the cookie carries only a date. The CLOSED line +sits in canonical position directly under the heading, so the first match within +the entry is the task's own close." + (save-excursion + (org-back-to-heading t) + (let ((end (save-excursion + (or (outline-next-heading) (goto-char (point-max))) + (point)))) + (when (re-search-forward + (concat "CLOSED:[ \t]*\\[\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" + "[ \t]+\\([A-Za-z]+\\)" + "\\(?:[ \t]+\\([0-9]\\{2\\}\\):\\([0-9]\\{2\\}\\)\\)?\\]") + end t) + (list :year (match-string 1) :month (match-string 2) :day (match-string 3) + :dow (match-string 4) + :hour (match-string 5) :minute (match-string 6)))))) + +(defun tc--tz-offset-string (year month day hour minute) + "Return the local UTC offset (e.g. \"-0500\") for the given wall-clock instant. +DST-aware: `encode-time' with an unknown-DST field lets the system pick the +correct offset for that date, so a summer close reads -0400 and a winter one +-0500 without hardcoding either." + (format-time-string + "%z" (encode-time (list 0 minute hour day month year nil -1 nil)))) + +(defun tc--dated-header-line (level parts title) + "Build the dated event-log heading string from LEVEL, CLOSED PARTS, and TITLE. +Missing time in PARTS defaults to 00:00:00 (the close logged only a date)." + (let* ((year (plist-get parts :year)) + (month (plist-get parts :month)) + (day (plist-get parts :day)) + (dow (plist-get parts :dow)) + (hh (or (plist-get parts :hour) "00")) + (mm (or (plist-get parts :minute) "00")) + (tz (tc--tz-offset-string (string-to-number year) + (string-to-number month) + (string-to-number day) + (string-to-number hh) + (string-to-number mm)))) + (format "%s %s-%s-%s %s @ %s:%s:00 %s %s" + (make-string level ?*) year month day dow hh mm tz title))) + +(defun tc--convert-collect-targets () + "Markers at every heading at level >= 3 whose TODO state is a done state. +Collected up front so the rewrite loop can edit the buffer without disturbing an +in-progress `org-map-entries' walk; markers track their headings across edits." + (let (targets) + (org-map-entries + (lambda () + (when (and (>= (org-current-level) 3) + (member (org-get-todo-state) tc--convert-done-states)) + (push (copy-marker (point)) targets))) + nil 'file) + (nreverse targets))) + +(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." + (goto-char marker) + (org-back-to-heading t) + (let* ((level (org-current-level)) + (title (org-get-heading t t t t)) + (line (line-number-at-pos)) + (parts (tc--closed-parts-in-entry))) + (cond + ((null parts) + (push (list :kind 'convert-skip :file tc-current-file + :line line :heading title + :detail "no CLOSED date to derive the timestamp") + tc-issues)) + (t + (let ((new (tc--dated-header-line level parts title))) + (cl-incf tc-converted) + (if tc-check-only + (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. + (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 ""))))))) + (push (list :kind 'convert-done :file tc-current-file + :line line :heading title :new new) + tc-issues))))))) + +(defun tc-convert-subtasks-in-file () + "Rewrite every level-3-and-deeper DONE/CANCELLED/FAILED heading to a dated +event-log entry, pulling the timestamp from its CLOSED cookie. Honors +`tc-check-only'." + (let ((targets (tc--convert-collect-targets))) + (dolist (m targets) + (tc--convert-one-subtask m) + (set-marker m nil)))) + +;;; --------------------------------------------------------------------------- ;;; Driver + reporting (defun tc-process-file (file) @@ -590,6 +745,8 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (tc-archive-done-in-file)) (tc-sync-child-priority (tc-sync-child-priority-in-file)) + (tc-convert-subtasks + (tc-convert-subtasks-in-file)) (t ;; Pass 1: auto-fix bogus state logs (or report under --check). (org-map-entries #'tc-fix-bogus-state-log-in-entry nil 'file) @@ -684,9 +841,34 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (plist-get i :child-heading) (plist-get i :parent-heading))))))) +(defun tc--emit-convert-report () + ;; Silent on a real-mode no-op (nothing to convert and nothing skipped), for + ;; the same reason as the archive report: the wrap runs cleanup passes more + ;; than once, and a vocal \"0 converted\" reads as noise. Check mode always + ;; reports (the preview is what the caller asked for), and a skip always + ;; reports (a done sub-task with no CLOSED date is a real condition to see). + (let ((has-skip (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-skip)) + tc-issues))) + (when (or tc-check-only (> tc-converted 0) has-skip) + (princ (format "todo-cleanup --convert-subtasks: %d sub-task(s) %s%s\n" + tc-converted + (if tc-check-only "would convert" "converted") + (if tc-check-only " — CHECK MODE (no writes)" ""))) + (dolist (i (reverse tc-issues)) + (pcase (plist-get i :kind) + ((or 'convert-done 'convert-would) + (princ (format " %s:%d: %s\n → %s\n" + (plist-get i :file) (plist-get i :line) + (plist-get i :heading) (plist-get i :new)))) + ('convert-skip + (princ (format " skipped %s:%d: %s — %s\n" + (plist-get i :file) (plist-get i :line) + (plist-get i :heading) (plist-get i :detail))))))))) + (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)) (t (tc--emit-hygiene-report)))) (defun tc-main () @@ -701,6 +883,9 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (when (member "--sync-child-priority" command-line-args-left) (setq tc-sync-child-priority t) (setq command-line-args-left (delete "--sync-child-priority" command-line-args-left))) + (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))) ;; --check-child-priority is the report-only alias for ;; `--sync-child-priority --check'. (when (member "--check-child-priority" command-line-args-left) @@ -708,7 +893,7 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (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 | --sync-child-priority | --check-child-priority] FILE...\n") + (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --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) @@ -727,6 +912,7 @@ ert-run-tests-batch-and-exit'." (cl-every (lambda (a) (cond ((member a '("--check" "--archive-done" + "--convert-subtasks" "--sync-child-priority" "--check-child-priority")) t) diff --git a/.ai/workflows/clean-todo.org b/.ai/workflows/clean-todo.org index dd33056..a1b2af5 100644 --- a/.ai/workflows/clean-todo.org +++ b/.ai/workflows/clean-todo.org @@ -27,7 +27,17 @@ Deletes bogus =- State "X" from "X" [date]= log lines (state didn't actually cha To preview without writing, run =--check= first: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --check todo.org=. -** Step 2: Archive completed work +** Step 2: Convert done sub-tasks to dated entries + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks todo.org +#+end_src + +Rewrites every heading at level 3 or deeper 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, priority cookie, and tags, and removing the =CLOSED:= line. Enforces the depth rule that a completed sub-task becomes dated history — a shape interactive org closes and =--archive-done= (level-2 only) leave unapplied. Timestamp comes from each entry's =CLOSED= cookie; heading text kept verbatim; idempotent; a done sub-task with no parseable =CLOSED= is flagged and left alone. Run before archiving so a parent's sub-tasks are already dated when it moves. Capture the output. + +To preview without writing: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks --check todo.org=. + +** Step 3: Archive completed work #+begin_src bash emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done todo.org @@ -37,10 +47,11 @@ Moves every level-2 subtree whose TODO state is DONE or CANCELLED out of the "Op To preview the moves without writing: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done --check todo.org=. -** Step 3: Summarize +** Step 4: Summarize -Report to Craig from the two captured outputs: +Report to Craig from the three captured outputs: - Hygiene: how many bogus state-log lines were deleted; any orphan-planning warnings (file:line + heading), or "none". +- Convert: how many done sub-tasks were rewritten to dated entries (heading + line), any flagged for no =CLOSED= date, or "nothing to convert". - Archive: how many subtrees moved and which (heading + line), or "nothing to move" / the skip reason if a section was missing or ambiguous. - If the file changed, note that =todo.org= now has an uncommitted edit — review =git diff -- todo.org= and commit it (in this repo's commit style) if it looks right. If nothing changed, say so and stop. @@ -49,7 +60,7 @@ Don't auto-commit. The summary is the review point; Craig decides whether the di * Principles - *Both passes apply, not just preview.* The workflow is invoked because cleanup is wanted. Use the =--check= variants only when Craig asks for a dry run. -- *Two passes, two invocations.* =--archive-done= is its own mode and does not run the hygiene pass; run both. +- *Separate modes, separate invocations.* =--convert-subtasks=, =--archive-done=, and the hygiene pass are each their own mode and don't run the others; run all three. - *Never auto-commit todo.org.* Surface the diff and let Craig commit it. The cleanup is a working-tree change, fully reversible until committed. - *Trust the script.* It's fast and idempotent; if there's nothing to do, it reports zero and exits clean. No pre-checks. diff --git a/.ai/workflows/open-tasks.org b/.ai/workflows/open-tasks.org index 4ba29dd..02a0847 100644 --- a/.ai/workflows/open-tasks.org +++ b/.ai/workflows/open-tasks.org @@ -23,15 +23,16 @@ Don't route "task review" / "review tasks" here — those trigger the hygiene ha * Phase A: Data Gathering (both modes) -** Phase A pre-step — archive any freshly-DONE tasks +** Phase A pre-step — normalize freshly-closed tasks -Before reading =todo.org=, run the cleanup script's archive-done sweep so completed level-2 subtrees move from =* $Project Open Work= to =* $Project Resolved=: +Before reading =todo.org=, run two cleanup sweeps so the read reflects current state. First convert any done sub-tasks to dated entries, then archive completed level-2 subtrees from =* $Project Open Work= to =* $Project Resolved=: #+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks todo.org emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done todo.org #+end_src -Costs a few hundred milliseconds. Without it, a task that completed earlier in the session sits as =** DONE= under Open Work until the next =clean-todo= or wrap-up pass, and Next Mode would surface it as a "what's next" candidate. The sweep makes Phase A's read of =todo.org= reflect current state. +Costs a few hundred milliseconds. Without the archive sweep, a task that completed earlier in the session sits as =** DONE= under Open Work until the next =clean-todo= or wrap-up pass, and Next Mode would surface it as a "what's next" candidate. The convert sweep runs first so a completed parent's sub-tasks are already dated when it archives; it also keeps interactive level-3 closes from lingering as DONE keywords. Together they make Phase A's read of =todo.org= reflect current state. Skip the sweep if the workflow is invoked in an explicit read-only or dry-run context. Default is to run it. diff --git a/.ai/workflows/task-review.org b/.ai/workflows/task-review.org index 69e172d..7b6327b 100644 --- a/.ai/workflows/task-review.org +++ b/.ai/workflows/task-review.org @@ -96,6 +96,8 @@ The exact date string matters: =task-review-staleness.sh= and the wrap-up health Follow the completion rules in [[file:../../claude-rules/todo-format.md][todo-format.md]]. A killed top-level =**= task stays task-shaped: change the keyword to =CANCELLED=, add a =CLOSED: [YYYY-MM-DD Day]= line under the heading (generate with =date "+%Y-%m-%d %a"=), and leave the priority and tags intact. It's then a candidate for =--archive-done= at the next cleanup. Don't stamp =:LAST_REVIEWED:= on a kill — it's leaving the review pool anyway. +A killed *sub-task* (=***= or deeper, under a parent task) instead becomes a dated event-log entry per the depth rule — but you don't have to hand-format it here. =todo-cleanup.el --convert-subtasks= (run in the =clean-todo= and wrap-up cleanup passes) rewrites any level-3+ DONE/CANCELLED/FAILED heading into its dated form mechanically from the =CLOSED= cookie, so a keyword-plus-=CLOSED= close at depth gets normalized on the next cleanup rather than lingering. =lint-org.el= flags any that slip through (checker =subtask-done-not-dated=). + * Phase D: Close out When the batch is done (or Craig calls it early): diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org index 5d2cdd2..4fa5a3a 100644 --- a/.ai/workflows/wrap-it-up.org +++ b/.ai/workflows/wrap-it-up.org @@ -137,6 +137,22 @@ Run the report-only variant first if you want to see what would change without w emacs --batch -q -l .ai/scripts/todo-cleanup.el --check todo.org #+end_src +*** Convert done sub-tasks to dated entries + +#+begin_src bash +[ -f todo.org ] && emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks todo.org +#+end_src + +=--convert-subtasks= rewrites every heading at level 3 or deeper 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, priority cookie, and tags, and removing the now-redundant =CLOSED:= line. This enforces the =todo-format.md= depth rule that a completed *sub-task* (a heading under a parent task) becomes dated history, not a lingering DONE keyword — a shape an interactive org close (=org-log-done= → DONE + CLOSED) never applies and =--archive-done= (level-2 only) never reaches. The timestamp comes from each entry's own =CLOSED= cookie; a date-only close yields =00:00:00=. Heading text is kept verbatim. Idempotent (an already-dated heading has no keyword to match), and a done sub-task with no parseable =CLOSED= is flagged and left alone rather than stamped with a fabricated date. + +Run this *before* =--archive-done= so that when a completed level-2 parent is archived, its sub-tasks already carry their dated form. Any rewrites show up in the wrap-up commit's diff for review before push. + +Preview without writing: + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks --check todo.org +#+end_src + *** Archive completed work #+begin_src bash @@ -536,7 +552,7 @@ Before considering wrap-up complete: - [ ] The Summary ends with the =KB: promoted N / consulted yes-no= line (promotion check ran) - [ ] File renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= - [ ] =.ai/session-context.org= no longer exists -- [ ] =todo-cleanup.el= ran — hygiene pass + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root) +- [ ] =todo-cleanup.el= ran — hygiene pass + =--convert-subtasks= + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root) - [ ] =lint-org.el= ran on =todo.org= — mechanical fixes applied, judgments appended to follow-ups file (if =todo.org= exists) - [ ] Any orphan-planning-line warnings reviewed (fix or accept) - [ ] Inbox carries nothing but expected pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes), OR each remaining handoff has an explicit deferral logged in the valediction diff --git a/claude-rules/todo-format.md b/claude-rules/todo-format.md index 55530de..90d801f 100644 --- a/claude-rules/todo-format.md +++ b/claude-rules/todo-format.md @@ -172,6 +172,8 @@ 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. + ### Why depth-based The agenda view (`org-agenda`) shows entries at the section + top-task level. Letting `**` tasks stay task-shaped preserves their visibility as "things that recently shipped." Letting `***+` sub-tasks flip to dated entries keeps the agenda from being clogged with a long list of completed sub-tasks at every depth — those become history within their parent instead. diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el index 5447cb3..90b1b1d 100644 --- a/claude-templates/.ai/scripts/lint-org.el +++ b/claude-templates/.ai/scripts/lint-org.el @@ -35,6 +35,7 @@ ;; empty-heading bare stars with no title ;; 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 ;; (anything else) surfaced as judgment with checker name ;; ;; Output format on stdout: @@ -503,6 +504,32 @@ the live file on the next `task-sorted'." "level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately")))))))) ;;; --------------------------------------------------------------------------- +;;; level-3+ dated-header check (claude-rules/todo-format.md) +;; +;; The inverse of the level-2 check above. A completed sub-task — a heading at +;; level 3 or deeper, under a parent task — becomes a dated event-log entry, not +;; a DONE keyword, so the parent's subtree grows a chronological history instead +;; of a long tail of nested DONE lines. An interactive org close +;; (`org-log-done' → DONE + CLOSED) leaves the keyword in place, and +;; `--archive-done' only touches level 2, so these accumulate. Flag them for +;; conversion. Judgment-only and regex-based (independent of which TODO keywords +;; the batch Emacs recognizes); todo-cleanup.el --convert-subtasks does the fix. + +(defun lo--check-subtask-done-not-dated () + "Flag level-3+ headings carrying a done keyword (DONE/CANCELLED/FAILED). +Emits one judgment item per offending heading (checker +`subtask-done-not-dated')." + (save-excursion + (goto-char (point-min)) + ;; Case-sensitive: the keywords are uppercase, not the words in a title. + (let ((case-fold-search nil)) + (while (re-search-forward + "^\\*\\{3,\\} \\(DONE\\|CANCELLED\\|FAILED\\) " nil t) + (lo--emit-judgment + 'subtask-done-not-dated (line-number-at-pos) + "level-3+ done sub-task should be a dated event-log entry (todo-format.md): run todo-cleanup.el --convert-subtasks to rewrite it"))))) + +;;; --------------------------------------------------------------------------- ;;; File processing (defun lo--backup (file) @@ -543,6 +570,7 @@ left unmodified and mechanical entries are recorded with :preview t." (lo--check-empty-headings) (lo--check-malformed-priority-cookies) (lo--check-level2-done-without-closed) + (lo--check-subtask-done-not-dated) (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 3b8a9bb..d14879f 100644 --- a/claude-templates/.ai/scripts/tests/test-lint-org.el +++ b/claude-templates/.ai/scripts/tests/test-lint-org.el @@ -685,6 +685,37 @@ missing-rules violation." (judgments (lo-test--judgments (plist-get out :issues)))) (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) +;;; subtask-done-not-dated check (the inverse: level-3+ done keyword) + +(ert-deftest lo-subtask-done-not-dated-flags-level3 () + "A level-3 DONE sub-task still carrying the keyword is flagged for conversion." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** DONE [#C] Sub-task done\nCLOSED: [2026-06-20 Sat 10:00]\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed + (should (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +(ert-deftest lo-subtask-done-not-dated-flags-level4-cancelled () + "A level-4 CANCELLED sub-task is flagged too." + (let* ((out (lo-test--run + "* Open Work\n\n** PROJECT [#B] Parent\n*** TODO Mid\n**** CANCELLED Deep abandoned\nCLOSED: [2026-06-20 Sat]\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +(ert-deftest lo-subtask-done-not-dated-ignores-level2 () + "A level-2 DONE task is a top-level task, not a sub-task — this checker skips it." + (let* ((out (lo-test--run + "* Open Work\n\n** DONE [#B] Top-level\nCLOSED: [2026-06-20 Sat]\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments))))) + +(ert-deftest lo-subtask-done-not-dated-ignores-dated-and-lowercase () + "An already-dated level-3 entry, and the word done in a title, are not flagged." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0400 landed\n*** TODO wrap the done cleanup\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'subtask-done-not-dated (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 e569d9a..ffbf2fb 100644 --- a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el +++ b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el @@ -768,5 +768,176 @@ in ISSUES, in document order." (should (= 2 (plist-get once :bumped))) (should (= 2 (plist-get twice :bumped))))) +;;; --------------------------------------------------------------------------- +;;; --convert-subtasks harness + tests + +(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-check-only (and check t) + tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t + tc-current-file nil + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) + +(defun tc-test--convert (content &optional runs check) + "Write CONTENT to a temp .org file, run `--convert-subtasks' RUNS times (default 1). +Return a plist: :result final file contents, :converted count from the last run, +:issues from the last run. CHECK non-nil ⇒ --check (preview, no writes)." + (let ((file (make-temp-file "tc-test-" nil ".org")) + last-converted last-issues) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (dotimes (_ (or runs 1)) + (tc-test--reset-convert check) + (tc-process-file file) + (setq last-converted tc-converted last-issues tc-issues) + (tc-test--drop-buffer file)) + (list :result (with-temp-buffer (insert-file-contents file) + (buffer-string)) + :converted last-converted + :issues last-issues)) + (tc-test--drop-buffer file) + (delete-file file)))) + +;; The UTC offset in a converted header is the test machine's local offset for +;; that date, so assertions match it as `[-+]NNNN' rather than a fixed value — +;; the mode's job is to emit a well-formed offset, not to run in one timezone. + +(defconst tc-test--convert-timed + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] F12 opens the terminal :feature:quick: +CLOSED: [2026-06-27 Sat 12:50] +Verified live: docks, toggles, colors clean. +") + +(ert-deftest tc-convert-timed-subtask-normal () + "Normal: a timed CLOSED close becomes a dated header, keyword/priority/tags/CLOSED gone." + (let* ((out (tc-test--convert tc-test--convert-timed)) + (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\\} F12 opens the terminal$" + res)) + (should-not (string-match-p "CLOSED:" res)) + (should-not (string-match-p "DONE" res)) + (should (string-match-p "Verified live: docks, toggles, colors clean\\." res)) + (should (string-match-p "^\\*\\* TODO \\[#B\\] Parent task$" res)))) + +(defconst tc-test--convert-dateonly + "* Project Open Work +** PROJECT [#B] Parent +**** DONE [#B] Write full spec :refactor: +CLOSED: [2026-05-04 Mon] +Body. +") + +(ert-deftest tc-convert-dateonly-boundary-midnight () + "Boundary: a date-only CLOSED (no time) yields 00:00:00, at level 4." + (let ((res (plist-get (tc-test--convert tc-test--convert-dateonly) :result))) + (should (string-match-p + "^\\*\\*\\*\\* 2026-05-04 Mon @ 00:00:00 [-+][0-9]\\{4\\} Write full spec$" + res)) + (should-not (string-match-p "CLOSED:" res)))) + +(defconst tc-test--convert-level2 + "* Project Open Work +** DONE [#B] Top-level task +CLOSED: [2026-06-01 Mon 09:00] +Body. +") + +(ert-deftest tc-convert-leaves-level-2-alone-boundary () + "Boundary: a level-2 DONE task is a top-level task, not a sub-task — untouched." + (let ((out (tc-test--convert tc-test--convert-level2))) + (should (= 0 (plist-get out :converted))) + (should (equal tc-test--convert-level2 (plist-get out :result))))) + +(ert-deftest tc-convert-idempotent-boundary () + "Boundary: a second run over an already-dated entry converts nothing new." + (let ((once (tc-test--convert tc-test--convert-timed 1)) + (twice (tc-test--convert tc-test--convert-timed 2))) + (should (equal (plist-get once :result) (plist-get twice :result))) + (should (= 0 (plist-get twice :converted))))) + +(defconst tc-test--convert-nested + "* Project Open Work +** TODO [#B] Parent +*** DONE Outer sub :feature: +CLOSED: [2026-06-10 Wed 08:15] +**** DONE Inner sub +CLOSED: [2026-06-09 Tue 07:00] +Inner body. +") + +(ert-deftest tc-convert-nested-done-subtasks-boundary () + "Boundary: a done sub-task nested under a done sub-task — both convert." + (let* ((out (tc-test--convert tc-test--convert-nested)) + (res (plist-get out :result))) + (should (= 2 (plist-get out :converted))) + (should (string-match-p + "^\\*\\*\\* 2026-06-10 Wed @ 08:15:00 [-+][0-9]\\{4\\} Outer sub$" res)) + (should (string-match-p + "^\\*\\*\\*\\* 2026-06-09 Tue @ 07:00:00 [-+][0-9]\\{4\\} Inner sub$" res)) + (should-not (string-match-p "CLOSED:" res)))) + +(defconst tc-test--convert-cancelled + "* Project Open Work +** TODO [#B] Parent +*** CANCELLED [#C] Abandoned idea :feature: +CLOSED: [2026-06-15 Mon 10:00] +") + +(ert-deftest tc-convert-cancelled-subtask-boundary () + "Boundary: a CANCELLED sub-task converts too (terminal state)." + (let ((res (plist-get (tc-test--convert tc-test--convert-cancelled) :result))) + (should (string-match-p + "^\\*\\*\\* 2026-06-15 Mon @ 10:00:00 [-+][0-9]\\{4\\} Abandoned idea$" res)) + (should-not (string-match-p "CANCELLED" res)))) + +(defconst tc-test--convert-noclosed + "* Project Open Work +** TODO [#B] Parent +*** DONE Orphan with no closed date +Body only. +") + +(ert-deftest tc-convert-skips-subtask-without-closed-error () + "Error: a done sub-task with no parseable CLOSED is flagged and left unchanged." + (let ((out (tc-test--convert tc-test--convert-noclosed))) + (should (= 0 (plist-get out :converted))) + (should (equal tc-test--convert-noclosed (plist-get out :result))) + (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-skip)) + (plist-get out :issues))))) + +(ert-deftest tc-convert-check-mode-previews-without-writing () + "Check mode reports the conversion but writes nothing." + (let ((out (tc-test--convert tc-test--convert-timed 1 t))) + (should (= 1 (plist-get out :converted))) + (should (equal tc-test--convert-timed (plist-get out :result))) + (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-would)) + (plist-get out :issues))))) + +(defconst tc-test--convert-closed-with-deadline + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] Ship the panel :feature: +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." + (let* ((out (tc-test--convert tc-test--convert-closed-with-deadline)) + (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\\} Ship the panel$" + res)) + (should-not (string-match-p "CLOSED:" res)) + (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res)) + (should (string-match-p "^Body line\\.$" 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 541d106..bd8166d 100644 --- a/claude-templates/.ai/scripts/todo-cleanup.el +++ b/claude-templates/.ai/scripts/todo-cleanup.el @@ -5,10 +5,12 @@ ;; 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 --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 ;; emacs --batch -q -l todo-cleanup.el --check-child-priority todo.org # preview the sync (same as --sync-child-priority --check) ;; -;; Three independent modes: +;; Four independent modes: ;; ;; * Default (hygiene). Designed for the wrap-it-up workflow: cheap, idempotent, ;; safe to run every session. @@ -52,6 +54,20 @@ ;; Archiving is consequential, so it's never run by default; it does *not* ;; also run the hygiene passes. ;; +;; * --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, +;; priority cookie, and tags, and removing the now-redundant CLOSED line. The +;; date and time come from that entry's own CLOSED cookie; a date-only close +;; yields 00:00:00, and the UTC offset is computed DST-aware for that date. +;; This enforces the todo-format depth rule that interactive closes +;; (`org-log-done' → DONE + CLOSED) and `--archive-done' (level-2 only) leave +;; unapplied. The heading text is preserved verbatim — a batch tool can't +;; past-tense an imperative title reliably. Idempotent (an already-dated +;; heading has no done keyword); a done sub-task with no parseable CLOSED date +;; is flagged and left alone, never stamped with a fabricated date. Like +;; --archive-done it does not also run the hygiene passes. +;; ;; * --sync-child-priority (opt-in). Walks every heading with a priority cookie ;; ([#A]-[#D]) and, for each of its direct child headings whose own priority ;; is lower (later in the alphabet — D is lower than A), bumps the child's @@ -73,11 +89,16 @@ (require 'calendar) (setq org-todo-keywords - '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED"))) + '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED" "FAILED"))) (defconst tc-done-states '("DONE" "CANCELLED") "TODO keywords that mark an entry as completed for `--archive-done'.") +(defconst tc--convert-done-states '("DONE" "CANCELLED" "FAILED") + "TODO keywords whose level-3-and-deeper entries `--convert-subtasks' rewrites +to dated event-log entries. Broader than `tc-done-states' because a FAILED +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.") @@ -89,10 +110,12 @@ every heading below it.") (defvar tc-fixes 0) (defvar tc-archived 0) (defvar tc-bumped 0) +(defvar tc-converted 0) (defvar tc-issues nil) (defvar tc-check-only nil) (defvar tc-archive-done nil) (defvar tc-sync-child-priority nil) +(defvar tc-convert-subtasks nil) (defvar tc-current-file nil) (defvar tc-current-dir nil) (defvar tc-archived-to-file 0) @@ -578,6 +601,138 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (org-map-entries #'tc-sync-child-priority-at-heading nil 'file)) ;;; --------------------------------------------------------------------------- +;;; --convert-subtasks mode +;; +;; A sub-task (a heading at level 3 or deeper, i.e. under a parent task) that is +;; marked DONE/CANCELLED/FAILED should become a dated event-log entry per the +;; todo-format depth rule: drop the keyword, priority cookie, and tags, and +;; rewrite the heading to `<stars> YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <text>' so the +;; parent's subtree grows a chronological history instead of a long tail of +;; nested DONE lines. Nothing enforced this before: `org-log-done' just flips an +;; interactive close to DONE + CLOSED, and `--archive-done' only touches level 2. +;; So level-3+ closes piled up as DONE keywords. This mode converts them +;; mechanically, pulling the timestamp from each entry's own CLOSED cookie. The +;; heading text is kept verbatim (a batch tool can't reliably past-tense an +;; imperative title, and guessing prose in the task file is worse than leaving it +;; 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. + +(defun tc--closed-parts-in-entry () + "Return a plist (:year :month :day :dow :hour :minute) from the CLOSED cookie +of the entry at point, or nil when the entry has no parseable CLOSED line. +:hour and :minute are nil when the cookie carries only a date. The CLOSED line +sits in canonical position directly under the heading, so the first match within +the entry is the task's own close." + (save-excursion + (org-back-to-heading t) + (let ((end (save-excursion + (or (outline-next-heading) (goto-char (point-max))) + (point)))) + (when (re-search-forward + (concat "CLOSED:[ \t]*\\[\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" + "[ \t]+\\([A-Za-z]+\\)" + "\\(?:[ \t]+\\([0-9]\\{2\\}\\):\\([0-9]\\{2\\}\\)\\)?\\]") + end t) + (list :year (match-string 1) :month (match-string 2) :day (match-string 3) + :dow (match-string 4) + :hour (match-string 5) :minute (match-string 6)))))) + +(defun tc--tz-offset-string (year month day hour minute) + "Return the local UTC offset (e.g. \"-0500\") for the given wall-clock instant. +DST-aware: `encode-time' with an unknown-DST field lets the system pick the +correct offset for that date, so a summer close reads -0400 and a winter one +-0500 without hardcoding either." + (format-time-string + "%z" (encode-time (list 0 minute hour day month year nil -1 nil)))) + +(defun tc--dated-header-line (level parts title) + "Build the dated event-log heading string from LEVEL, CLOSED PARTS, and TITLE. +Missing time in PARTS defaults to 00:00:00 (the close logged only a date)." + (let* ((year (plist-get parts :year)) + (month (plist-get parts :month)) + (day (plist-get parts :day)) + (dow (plist-get parts :dow)) + (hh (or (plist-get parts :hour) "00")) + (mm (or (plist-get parts :minute) "00")) + (tz (tc--tz-offset-string (string-to-number year) + (string-to-number month) + (string-to-number day) + (string-to-number hh) + (string-to-number mm)))) + (format "%s %s-%s-%s %s @ %s:%s:00 %s %s" + (make-string level ?*) year month day dow hh mm tz title))) + +(defun tc--convert-collect-targets () + "Markers at every heading at level >= 3 whose TODO state is a done state. +Collected up front so the rewrite loop can edit the buffer without disturbing an +in-progress `org-map-entries' walk; markers track their headings across edits." + (let (targets) + (org-map-entries + (lambda () + (when (and (>= (org-current-level) 3) + (member (org-get-todo-state) tc--convert-done-states)) + (push (copy-marker (point)) targets))) + nil 'file) + (nreverse targets))) + +(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." + (goto-char marker) + (org-back-to-heading t) + (let* ((level (org-current-level)) + (title (org-get-heading t t t t)) + (line (line-number-at-pos)) + (parts (tc--closed-parts-in-entry))) + (cond + ((null parts) + (push (list :kind 'convert-skip :file tc-current-file + :line line :heading title + :detail "no CLOSED date to derive the timestamp") + tc-issues)) + (t + (let ((new (tc--dated-header-line level parts title))) + (cl-incf tc-converted) + (if tc-check-only + (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. + (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 ""))))))) + (push (list :kind 'convert-done :file tc-current-file + :line line :heading title :new new) + tc-issues))))))) + +(defun tc-convert-subtasks-in-file () + "Rewrite every level-3-and-deeper DONE/CANCELLED/FAILED heading to a dated +event-log entry, pulling the timestamp from its CLOSED cookie. Honors +`tc-check-only'." + (let ((targets (tc--convert-collect-targets))) + (dolist (m targets) + (tc--convert-one-subtask m) + (set-marker m nil)))) + +;;; --------------------------------------------------------------------------- ;;; Driver + reporting (defun tc-process-file (file) @@ -590,6 +745,8 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (tc-archive-done-in-file)) (tc-sync-child-priority (tc-sync-child-priority-in-file)) + (tc-convert-subtasks + (tc-convert-subtasks-in-file)) (t ;; Pass 1: auto-fix bogus state logs (or report under --check). (org-map-entries #'tc-fix-bogus-state-log-in-entry nil 'file) @@ -684,9 +841,34 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (plist-get i :child-heading) (plist-get i :parent-heading))))))) +(defun tc--emit-convert-report () + ;; Silent on a real-mode no-op (nothing to convert and nothing skipped), for + ;; the same reason as the archive report: the wrap runs cleanup passes more + ;; than once, and a vocal \"0 converted\" reads as noise. Check mode always + ;; reports (the preview is what the caller asked for), and a skip always + ;; reports (a done sub-task with no CLOSED date is a real condition to see). + (let ((has-skip (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-skip)) + tc-issues))) + (when (or tc-check-only (> tc-converted 0) has-skip) + (princ (format "todo-cleanup --convert-subtasks: %d sub-task(s) %s%s\n" + tc-converted + (if tc-check-only "would convert" "converted") + (if tc-check-only " — CHECK MODE (no writes)" ""))) + (dolist (i (reverse tc-issues)) + (pcase (plist-get i :kind) + ((or 'convert-done 'convert-would) + (princ (format " %s:%d: %s\n → %s\n" + (plist-get i :file) (plist-get i :line) + (plist-get i :heading) (plist-get i :new)))) + ('convert-skip + (princ (format " skipped %s:%d: %s — %s\n" + (plist-get i :file) (plist-get i :line) + (plist-get i :heading) (plist-get i :detail))))))))) + (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)) (t (tc--emit-hygiene-report)))) (defun tc-main () @@ -701,6 +883,9 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (when (member "--sync-child-priority" command-line-args-left) (setq tc-sync-child-priority t) (setq command-line-args-left (delete "--sync-child-priority" command-line-args-left))) + (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))) ;; --check-child-priority is the report-only alias for ;; `--sync-child-priority --check'. (when (member "--check-child-priority" command-line-args-left) @@ -708,7 +893,7 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (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 | --sync-child-priority | --check-child-priority] FILE...\n") + (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --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) @@ -727,6 +912,7 @@ ert-run-tests-batch-and-exit'." (cl-every (lambda (a) (cond ((member a '("--check" "--archive-done" + "--convert-subtasks" "--sync-child-priority" "--check-child-priority")) t) diff --git a/claude-templates/.ai/workflows/clean-todo.org b/claude-templates/.ai/workflows/clean-todo.org index dd33056..a1b2af5 100644 --- a/claude-templates/.ai/workflows/clean-todo.org +++ b/claude-templates/.ai/workflows/clean-todo.org @@ -27,7 +27,17 @@ Deletes bogus =- State "X" from "X" [date]= log lines (state didn't actually cha To preview without writing, run =--check= first: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --check todo.org=. -** Step 2: Archive completed work +** Step 2: Convert done sub-tasks to dated entries + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks todo.org +#+end_src + +Rewrites every heading at level 3 or deeper 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, priority cookie, and tags, and removing the =CLOSED:= line. Enforces the depth rule that a completed sub-task becomes dated history — a shape interactive org closes and =--archive-done= (level-2 only) leave unapplied. Timestamp comes from each entry's =CLOSED= cookie; heading text kept verbatim; idempotent; a done sub-task with no parseable =CLOSED= is flagged and left alone. Run before archiving so a parent's sub-tasks are already dated when it moves. Capture the output. + +To preview without writing: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks --check todo.org=. + +** Step 3: Archive completed work #+begin_src bash emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done todo.org @@ -37,10 +47,11 @@ Moves every level-2 subtree whose TODO state is DONE or CANCELLED out of the "Op To preview the moves without writing: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done --check todo.org=. -** Step 3: Summarize +** Step 4: Summarize -Report to Craig from the two captured outputs: +Report to Craig from the three captured outputs: - Hygiene: how many bogus state-log lines were deleted; any orphan-planning warnings (file:line + heading), or "none". +- Convert: how many done sub-tasks were rewritten to dated entries (heading + line), any flagged for no =CLOSED= date, or "nothing to convert". - Archive: how many subtrees moved and which (heading + line), or "nothing to move" / the skip reason if a section was missing or ambiguous. - If the file changed, note that =todo.org= now has an uncommitted edit — review =git diff -- todo.org= and commit it (in this repo's commit style) if it looks right. If nothing changed, say so and stop. @@ -49,7 +60,7 @@ Don't auto-commit. The summary is the review point; Craig decides whether the di * Principles - *Both passes apply, not just preview.* The workflow is invoked because cleanup is wanted. Use the =--check= variants only when Craig asks for a dry run. -- *Two passes, two invocations.* =--archive-done= is its own mode and does not run the hygiene pass; run both. +- *Separate modes, separate invocations.* =--convert-subtasks=, =--archive-done=, and the hygiene pass are each their own mode and don't run the others; run all three. - *Never auto-commit todo.org.* Surface the diff and let Craig commit it. The cleanup is a working-tree change, fully reversible until committed. - *Trust the script.* It's fast and idempotent; if there's nothing to do, it reports zero and exits clean. No pre-checks. diff --git a/claude-templates/.ai/workflows/open-tasks.org b/claude-templates/.ai/workflows/open-tasks.org index 4ba29dd..02a0847 100644 --- a/claude-templates/.ai/workflows/open-tasks.org +++ b/claude-templates/.ai/workflows/open-tasks.org @@ -23,15 +23,16 @@ Don't route "task review" / "review tasks" here — those trigger the hygiene ha * Phase A: Data Gathering (both modes) -** Phase A pre-step — archive any freshly-DONE tasks +** Phase A pre-step — normalize freshly-closed tasks -Before reading =todo.org=, run the cleanup script's archive-done sweep so completed level-2 subtrees move from =* $Project Open Work= to =* $Project Resolved=: +Before reading =todo.org=, run two cleanup sweeps so the read reflects current state. First convert any done sub-tasks to dated entries, then archive completed level-2 subtrees from =* $Project Open Work= to =* $Project Resolved=: #+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks todo.org emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done todo.org #+end_src -Costs a few hundred milliseconds. Without it, a task that completed earlier in the session sits as =** DONE= under Open Work until the next =clean-todo= or wrap-up pass, and Next Mode would surface it as a "what's next" candidate. The sweep makes Phase A's read of =todo.org= reflect current state. +Costs a few hundred milliseconds. Without the archive sweep, a task that completed earlier in the session sits as =** DONE= under Open Work until the next =clean-todo= or wrap-up pass, and Next Mode would surface it as a "what's next" candidate. The convert sweep runs first so a completed parent's sub-tasks are already dated when it archives; it also keeps interactive level-3 closes from lingering as DONE keywords. Together they make Phase A's read of =todo.org= reflect current state. Skip the sweep if the workflow is invoked in an explicit read-only or dry-run context. Default is to run it. diff --git a/claude-templates/.ai/workflows/task-review.org b/claude-templates/.ai/workflows/task-review.org index 69e172d..7b6327b 100644 --- a/claude-templates/.ai/workflows/task-review.org +++ b/claude-templates/.ai/workflows/task-review.org @@ -96,6 +96,8 @@ The exact date string matters: =task-review-staleness.sh= and the wrap-up health Follow the completion rules in [[file:../../claude-rules/todo-format.md][todo-format.md]]. A killed top-level =**= task stays task-shaped: change the keyword to =CANCELLED=, add a =CLOSED: [YYYY-MM-DD Day]= line under the heading (generate with =date "+%Y-%m-%d %a"=), and leave the priority and tags intact. It's then a candidate for =--archive-done= at the next cleanup. Don't stamp =:LAST_REVIEWED:= on a kill — it's leaving the review pool anyway. +A killed *sub-task* (=***= or deeper, under a parent task) instead becomes a dated event-log entry per the depth rule — but you don't have to hand-format it here. =todo-cleanup.el --convert-subtasks= (run in the =clean-todo= and wrap-up cleanup passes) rewrites any level-3+ DONE/CANCELLED/FAILED heading into its dated form mechanically from the =CLOSED= cookie, so a keyword-plus-=CLOSED= close at depth gets normalized on the next cleanup rather than lingering. =lint-org.el= flags any that slip through (checker =subtask-done-not-dated=). + * Phase D: Close out When the batch is done (or Craig calls it early): diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org index 5d2cdd2..4fa5a3a 100644 --- a/claude-templates/.ai/workflows/wrap-it-up.org +++ b/claude-templates/.ai/workflows/wrap-it-up.org @@ -137,6 +137,22 @@ Run the report-only variant first if you want to see what would change without w emacs --batch -q -l .ai/scripts/todo-cleanup.el --check todo.org #+end_src +*** Convert done sub-tasks to dated entries + +#+begin_src bash +[ -f todo.org ] && emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks todo.org +#+end_src + +=--convert-subtasks= rewrites every heading at level 3 or deeper 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, priority cookie, and tags, and removing the now-redundant =CLOSED:= line. This enforces the =todo-format.md= depth rule that a completed *sub-task* (a heading under a parent task) becomes dated history, not a lingering DONE keyword — a shape an interactive org close (=org-log-done= → DONE + CLOSED) never applies and =--archive-done= (level-2 only) never reaches. The timestamp comes from each entry's own =CLOSED= cookie; a date-only close yields =00:00:00=. Heading text is kept verbatim. Idempotent (an already-dated heading has no keyword to match), and a done sub-task with no parseable =CLOSED= is flagged and left alone rather than stamped with a fabricated date. + +Run this *before* =--archive-done= so that when a completed level-2 parent is archived, its sub-tasks already carry their dated form. Any rewrites show up in the wrap-up commit's diff for review before push. + +Preview without writing: + +#+begin_src bash +emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks --check todo.org +#+end_src + *** Archive completed work #+begin_src bash @@ -536,7 +552,7 @@ Before considering wrap-up complete: - [ ] The Summary ends with the =KB: promoted N / consulted yes-no= line (promotion check ran) - [ ] File renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= - [ ] =.ai/session-context.org= no longer exists -- [ ] =todo-cleanup.el= ran — hygiene pass + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root) +- [ ] =todo-cleanup.el= ran — hygiene pass + =--convert-subtasks= + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root) - [ ] =lint-org.el= ran on =todo.org= — mechanical fixes applied, judgments appended to follow-ups file (if =todo.org= exists) - [ ] Any orphan-planning-line warnings reviewed (fix or accept) - [ ] Inbox carries nothing but expected pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes), OR each remaining handoff has an explicit deferral logged in the valediction |
